From 3b0a7560e500f7bcb755dfb0a9fdfa3fa361bd85 Mon Sep 17 00:00:00 2001 From: Ned Malki Date: Mon, 30 Mar 2026 16:12:05 +0700 Subject: [PATCH 1/2] feat: add GSD_PROJECT env var for multi-project workspace support Adds project-scoped planning directory resolution via GSD_PROJECT environment variable. When set, planningDir() routes to .planning/{project}/ instead of .planning/, enabling multiple independent projects to coexist under a single .planning/ root. Use case: shared workspaces (e.g., Obsidian vaults, monorepo knowledge bases) where multiple projects are managed from one directory. Each project keeps its own config.json, ROADMAP.md, STATE.md, and phases/ under .planning/{project-name}/. GSD_PROJECT follows the same pattern as GSD_WORKSTREAM and can be combined with it: .planning/{project}/workstreams/{ws}/ Also updates loadConfig() to read config.json from the project-scoped directory when GSD_PROJECT is active. Co-Authored-By: Claude Opus 4.6 (1M context) --- get-shit-done/bin/lib/core.cjs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index e509e849a..7810e5932 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -195,7 +195,7 @@ function safeReadFile(filePath) { } function loadConfig(cwd) { - const configPath = path.join(cwd, '.planning', 'config.json'); + const configPath = path.join(planningDir(cwd), 'config.json'); const defaults = { model_profile: 'balanced', commit_docs: true, @@ -540,20 +540,34 @@ function withPlanningLock(cwd, fn) { } /** - * Get the .planning directory path, workstream-aware. - * When a workstream is active (via explicit ws arg or GSD_WORKSTREAM env var), - * returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`. + * Get the .planning directory path, project- and workstream-aware. + * + * Resolution order: + * 1. If GSD_PROJECT is set (env var or explicit `project` arg), routes to + * `.planning/{project}/` — supports multi-project workspaces where several + * independent projects share a single `.planning/` root directory (e.g., + * an Obsidian vault or monorepo knowledge base used as a command center). + * 2. If GSD_WORKSTREAM is set, routes to `.planning/workstreams/{ws}/`. + * 3. Otherwise returns `.planning/`. + * + * GSD_PROJECT and GSD_WORKSTREAM can be combined: + * `.planning/{project}/workstreams/{ws}/` * * @param {string} cwd - project root * @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var + * @param {string} [project] - explicit project name; if omitted, checks GSD_PROJECT env var */ -function planningDir(cwd, ws) { +function planningDir(cwd, ws, project) { + if (project === undefined) project = process.env.GSD_PROJECT || null; if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null; - if (!ws) return path.join(cwd, '.planning'); - return path.join(cwd, '.planning', 'workstreams', ws); + + let base = path.join(cwd, '.planning'); + if (project) base = path.join(base, project); + if (ws) base = path.join(base, 'workstreams', ws); + return base; } -/** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */ +/** Always returns the root .planning/ path, ignoring workstreams and projects. For shared resources. */ function planningRoot(cwd) { return path.join(cwd, '.planning'); } From 523c7199d0c480ab778c0ace9fcb070656df35d0 Mon Sep 17 00:00:00 2001 From: Ned Malki Date: Thu, 2 Apr 2026 09:18:39 +0700 Subject: [PATCH 2/2] fix: add path traversal validation and unit tests for planningDir Reject project/workstream names containing path separators or .. components. Covers both GSD_PROJECT and GSD_WORKSTREAM. Adds 9 tests for the full resolution matrix and traversal rejection cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- get-shit-done/bin/lib/core.cjs | 9 ++++ tests/core.test.cjs | 76 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 7810e5932..b38719259 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -561,6 +561,15 @@ function planningDir(cwd, ws, project) { if (project === undefined) project = process.env.GSD_PROJECT || null; if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null; + // Reject path separators and traversal components in project/workstream names + const BAD_SEGMENT = /[/\\]|\.\./; + if (project && BAD_SEGMENT.test(project)) { + throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`); + } + if (ws && BAD_SEGMENT.test(ws)) { + throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`); + } + let base = path.join(cwd, '.planning'); if (project) base = path.join(base, project); if (ws) base = path.join(base, 'workstreams', ws); diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 4211b3f8e..f99a31718 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -30,6 +30,7 @@ const { findPhaseInternal, findProjectRoot, detectSubRepos, + planningDir, } = require('../get-shit-done/bin/lib/core.cjs'); // ─── loadConfig ──────────────────────────────────────────────────────────────── @@ -1565,3 +1566,78 @@ describe('reapStaleTempFiles', () => { }); }); }); + +// ─── planningDir ────────────────────────────────────────────────────────────── + +describe('planningDir', () => { + const cwd = '/fake/repo'; + let savedProject, savedWorkstream; + + beforeEach(() => { + savedProject = process.env.GSD_PROJECT; + savedWorkstream = process.env.GSD_WORKSTREAM; + delete process.env.GSD_PROJECT; + delete process.env.GSD_WORKSTREAM; + }); + + afterEach(() => { + if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject; + else delete process.env.GSD_PROJECT; + if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream; + else delete process.env.GSD_WORKSTREAM; + }); + + test('returns .planning/ when neither project nor workstream is set', () => { + const result = planningDir(cwd, null, null); + assert.strictEqual(result, path.join(cwd, '.planning')); + }); + + test('returns .planning/{project}/ when project is set', () => { + const result = planningDir(cwd, null, 'my-app'); + assert.strictEqual(result, path.join(cwd, '.planning', 'my-app')); + }); + + test('returns .planning/workstreams/{ws}/ when workstream is set', () => { + const result = planningDir(cwd, 'feature-x', null); + assert.strictEqual(result, path.join(cwd, '.planning', 'workstreams', 'feature-x')); + }); + + test('returns .planning/{project}/workstreams/{ws}/ when both are set', () => { + const result = planningDir(cwd, 'feature-x', 'my-app'); + assert.strictEqual(result, path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x')); + }); + + test('reads GSD_PROJECT from env when project param is undefined', () => { + process.env.GSD_PROJECT = 'env-project'; + const result = planningDir(cwd); + assert.strictEqual(result, path.join(cwd, '.planning', 'env-project')); + }); + + test('rejects path traversal in project name', () => { + assert.throws( + () => planningDir(cwd, null, '../../etc'), + /invalid path characters/ + ); + }); + + test('rejects forward slash in project name', () => { + assert.throws( + () => planningDir(cwd, null, 'foo/bar'), + /invalid path characters/ + ); + }); + + test('rejects backslash in project name', () => { + assert.throws( + () => planningDir(cwd, null, 'foo\\bar'), + /invalid path characters/ + ); + }); + + test('rejects path traversal in workstream name', () => { + assert.throws( + () => planningDir(cwd, '../../../tmp', null), + /invalid path characters/ + ); + }); +});