Skip to content

fix: complete planningDir migration for config CRUD, template fill, and verify#1986

Merged
trek-e merged 3 commits intogsd-build:mainfrom
NedMalki-Chief:fix/multi-project-routing-completion
Apr 10, 2026
Merged

fix: complete planningDir migration for config CRUD, template fill, and verify#1986
trek-e merged 3 commits intogsd-build:mainfrom
NedMalki-Chief:fix/multi-project-routing-completion

Conversation

@NedMalki-Chief
Copy link
Copy Markdown
Contributor

@NedMalki-Chief NedMalki-Chief commented Apr 8, 2026

Closes #1987

Summary

PR #1484 added planningDir(cwd) and the GSD_PROJECT env var so a workspace can host multiple projects under .planning/{project}/. loadConfig() in core.cjs:256 was migrated at the time, but several CRUD entry points and template/verify helpers were left resolving against planningRoot(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_PROJECT set:

  • cmdConfigGet, setConfigValue, ensureConfigFile, cmdConfigNewProject all read from and write to .planning/config.json (unrouted)
  • loadConfig reads from .planning/{GSD_PROJECT}/config.json (project-routed)

The reader and writer pointed at different files. gsd-tools config-get workflow.discuss_mode returned "unset" in multi-project workspaces even when the value was correctly stored in the project-routed file.

planningPaths() in core.cjs 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. This PR resolves the contradiction in favor of loadConfig's behavior so the contract matches the only function that successfully read config.json in the multi-project case.

Commits

  1. fix(config): route CRUD through planningDir to honor GSD_PROJECT — fixes the four config.cjs CRUD entry points (cmdConfigNewProject, ensureConfigFile, setConfigValue, cmdConfigGet) and the planningPaths() helper in core.cjs (project + config keys). +15/-11 across 2 files.

  2. fix(template): emit project-aware references in template fill plancmdTemplateFill plan branch hardcoded @.planning/PROJECT.md, @.planning/ROADMAP.md, @.planning/STATE.md references 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 through planningDir(cwd) and normalizes via the existing toPosixPath helper. +8/-4 in 1 file.

  3. fix(verify): planningDir for cmdValidateHealth and regenerateStatecmdValidateHealth 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. The regenerateState repair path (/gsd:health --repair) hardcoded See: .planning/PROJECT.md in the regenerated body, which immediately fails the same reference check it just regenerated for. Both fixed; the unused planningRoot import in verify.cjs was also dropped. +7/-5 in 1 file.

Total: +30/-20 across 4 files.

Backward compatibility

Single-project users (no GSD_PROJECT set) are unaffected. planningDir() falls back to .planning/ when no project is configured, so it returns the same path as planningRoot() 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.json and GSD_PROJECT=projectA:

# Before this PR:
GSD_PROJECT=projectA gsd-tools config-get workflow.discuss_mode
# Error: Key not found

# After this PR:
GSD_PROJECT=projectA gsd-tools config-get workflow.discuss_mode
# "discuss"

Single-project regression check (no GSD_PROJECT set, 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:

# Generated PLAN.md should reference @.planning/{GSD_PROJECT}/PROJECT.md when GSD_PROJECT is set
GSD_PROJECT=projectA gsd-tools template fill plan --phase 01 --plan 99
cat .planning/projectA/phases/01-*/01-99-PLAN.md | grep '@.planning'

# /gsd:health --repair should regenerate STATE.md with a project-aware See: ref
GSD_PROJECT=projectA gsd-tools verify health --repair
cat .planning/projectA/STATE.md | grep 'Project Reference' -A 2

Test plan

  • CI passes on the PR branch
  • Manual verification: multi-project layout reads config correctly after the fix
  • Manual verification: single-project layout unchanged
  • gsd-tools verify references no longer reports .planning/PROJECT.md, .planning/ROADMAP.md, and .planning/STATE.md as missing on PLAN.md files generated by template fill in multi-project workspaces

🤖 Generated with Claude Code

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)
Copy link
Copy Markdown
Collaborator

@trek-e trek-e left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 planningRootplanningDir: 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.

No issues found

@trek-e trek-e added the review: approved PR reviewed and approved by maintainer label Apr 10, 2026
@trek-e trek-e merged commit f8526b5 into gsd-build:main Apr 10, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review: approved PR reviewed and approved by maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-project routing: config CRUD, template fill, and verify still resolve against planningRoot after PR #1484

2 participants