fix: complete planningDir migration for config CRUD, template fill, and verify#1986
Conversation
PR gsd-build#1484 added planningDir(cwd) and the GSD_PROJECT env var so a workspace can host multiple projects under .planning/{project}/. loadConfig() in core.cjs (line 256) was migrated at the time, but the four CRUD entry points in config.cjs and the planningPaths() helper in core.cjs were left resolving against planningRoot(cwd). The result was a silent split-brain in any multi-project workspace: - cmdConfigGet, setConfigValue, ensureConfigFile, cmdConfigNewProject all wrote to and read from .planning/config.json - loadConfig read from .planning/{GSD_PROJECT}/config.json So `gsd-tools config-get workflow.discuss_mode` returned "unset" even when the value was correctly stored in the project-routed file, because the reader and writer pointed at different paths. planningPaths() carried a comment that "Shared paths (project, config) always resolve to the root .planning/" which described the original intent, but loadConfig() already contradicted that intent for config.json. project and config now both resolve through planningDir() so the contract matches the only function that successfully read config.json in the multi-project case. Single-project users (no GSD_PROJECT set) are unaffected: planningRoot() and planningDir() return the same path when no project is configured. Verification: in a workspace with .planning/projectA/config.json and GSD_PROJECT=projectA, `gsd-tools config-get workflow.discuss_mode` now returns the value instead of "Error: Key not found". Backward compat verified by running the same command without GSD_PROJECT in a single-project layout. Affected sites: - get-shit-done/bin/lib/config.cjs cmdConfigNewProject (line 199) - get-shit-done/bin/lib/config.cjs ensureConfigFile (line 244) - get-shit-done/bin/lib/config.cjs setConfigValue (line 294) - get-shit-done/bin/lib/config.cjs cmdConfigGet (line 367) - get-shit-done/bin/lib/core.cjs planningPaths.config (line 706) - get-shit-done/bin/lib/core.cjs planningPaths.project (line 705)
The template fill plan body hardcoded `@.planning/PROJECT.md`,
`@.planning/ROADMAP.md`, and `@.planning/STATE.md` references. In a
multi-project workspace these resolve to nothing because the actual
project, roadmap, and state files live under .planning/{GSD_PROJECT}/.
`gsd-tools verify references` reports them as missing on every PLAN.md
generated by template fill in any GSD_PROJECT-routed workspace.
Fix: route the references through planningDir(cwd), normalize via the
existing toPosixPath helper for cross-platform path consistency, and
embed them as `@<relative-path>` matching the phase-relative reference
pattern used elsewhere in the file.
Single-project users (no GSD_PROJECT set) get exactly the same output
as before because planningDir() falls back to .planning/ when no project
is active.
Affected site: get-shit-done/bin/lib/template.cjs cmdTemplateFill plan
branch (lines 142-145, the @.planning/ refs in the Context section).
cmdValidateHealth resolved projectPath and configPath via planningRoot(cwd) while ROADMAP/STATE/phases/requirements went through planningDir(cwd). The inconsistency reported "missing PROJECT.md" and "missing config.json" in multi-project layouts even when the project-routed copies existed and the config CRUD writers (now also routed by the previous commit in this PR) were writing to them. regenerateState (the /gsd:health --repair STATE.md regeneration path) hardcoded `See: .planning/PROJECT.md` in the generated body, which fails the same reference check it just regenerated for in any GSD_PROJECT-routed workspace. Fix: route both sites through planningDir(cwd). For regenerateState, derive a POSIX-style relative reference from the resolved path so the reference matches verify references' resolution rules. Also dropped the planningRoot import from verify.cjs since it is no longer used after this change. Single-project users (no GSD_PROJECT set) get the same paths as before: planningDir() falls back to .planning/ when no project is configured. Affected sites: - get-shit-done/bin/lib/verify.cjs cmdValidateHealth (lines 536-541) - get-shit-done/bin/lib/verify.cjs regenerateState repair (line 865) - get-shit-done/bin/lib/verify.cjs core.cjs import (line 8, dropped unused planningRoot)
trek-e
left a comment
There was a problem hiding this comment.
Review: APPROVE
Summary
The split-brain diagnosis is correct: loadConfig() already read through planningDir() (line 256) while all CRUD writers used planningRoot(). This PR resolves the contradiction in favor of loadConfig()'s behavior, which is correct.
Changes verified
config.cjs: Four functions switched from planningRoot → planningDir: cmdConfigNewProject, ensureConfigFile, setConfigValue, cmdConfigGet. All are config CRUD entry points — the change is consistent and complete.
core.cjs (planningPaths): project and config paths now route through planningDir instead of the unrouted root. The comment update explaining the contract change is clear.
template.cjs (cmdTemplateFill): The @.planning/ references in generated PLAN.md body now route through planningDir + toPosixPath — fixes reference resolution in multi-project layouts.
verify.cjs (cmdValidateHealth, regenerateState): projectPath and configPath now use planningDir; the hardcoded See: .planning/PROJECT.md in regenerated STATE.md body now uses the routed path. The unused planningRoot import is dropped.
Backward compatibility
Single-project users unaffected: planningDir() falls back to .planning/ when no GSD_PROJECT is set. The TOCTOU/locking wrappers from #1944 are preserved.
Closes #1987
Summary
PR #1484 added
planningDir(cwd)and theGSD_PROJECTenv var so a workspace can host multiple projects under.planning/{project}/.loadConfig()incore.cjs:256was migrated at the time, but several CRUD entry points and template/verify helpers were left resolving againstplanningRoot(cwd). This PR completes that migration.Full reproduction and affected-sites list in #1987.
The split-brain bug
In any multi-project workspace with
GSD_PROJECTset:cmdConfigGet,setConfigValue,ensureConfigFile,cmdConfigNewProjectall read from and write to.planning/config.json(unrouted)loadConfigreads from.planning/{GSD_PROJECT}/config.json(project-routed)The reader and writer pointed at different files.
gsd-tools config-get workflow.discuss_modereturned"unset"in multi-project workspaces even when the value was correctly stored in the project-routed file.planningPaths()incore.cjscarried a comment that "Shared paths (project, config) always resolve to the root .planning/" which described the original intent, butloadConfig()already contradicted that intent forconfig.json. This PR resolves the contradiction in favor ofloadConfig's behavior so the contract matches the only function that successfully readconfig.jsonin the multi-project case.Commits
fix(config): route CRUD through planningDir to honor GSD_PROJECT— fixes the fourconfig.cjsCRUD entry points (cmdConfigNewProject,ensureConfigFile,setConfigValue,cmdConfigGet) and theplanningPaths()helper incore.cjs(project + config keys). +15/-11 across 2 files.fix(template): emit project-aware references in template fill plan—cmdTemplateFillplan branch hardcoded@.planning/PROJECT.md,@.planning/ROADMAP.md,@.planning/STATE.mdreferences in the body of generated PLAN.md files. These resolve to nothing in multi-project layouts because the actual files live under.planning/{GSD_PROJECT}/. Routes throughplanningDir(cwd)and normalizes via the existingtoPosixPathhelper. +8/-4 in 1 file.fix(verify): planningDir for cmdValidateHealth and regenerateState—cmdValidateHealthresolvedprojectPathandconfigPathviaplanningRoot(cwd)whileROADMAP/STATE/phases/requirementswent throughplanningDir(cwd). The inconsistency reported "missing PROJECT.md" and "missing config.json" in multi-project layouts. TheregenerateStaterepair path (/gsd:health --repair) hardcodedSee: .planning/PROJECT.mdin the regenerated body, which immediately fails the same reference check it just regenerated for. Both fixed; the unusedplanningRootimport inverify.cjswas also dropped. +7/-5 in 1 file.Total: +30/-20 across 4 files.
Backward compatibility
Single-project users (no
GSD_PROJECTset) are unaffected.planningDir()falls back to.planning/when no project is configured, so it returns the same path asplanningRoot()in that mode. All existing single-project workflows produce identical output before and after.The TOCTOU/locking wrappers added in #1944 (
withPlanningLock,atomicWriteFileSync) are preserved unchanged — only the path resolution inside them is migrated.Verification
In a workspace with
.planning/projectA/config.jsonandGSD_PROJECT=projectA:Single-project regression check (no
GSD_PROJECTset, value stored in.planning/config.json):gsd-tools config-get workflow.discuss_mode # Returns the value from .planning/config.json (unchanged behavior)For the template and verify commits:
Test plan
gsd-tools verify referencesno longer reports.planning/PROJECT.md,.planning/ROADMAP.md, and.planning/STATE.mdas missing on PLAN.md files generated by template fill in multi-project workspaces🤖 Generated with Claude Code