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

* fix(config): route CRUD through planningDir to honor GSD_PROJECT

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 (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)

* fix(template): emit project-aware references in template fill plan

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).

* fix(verify): planningDir for cmdValidateHealth and regenerateState

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)
This commit is contained in:
Ned Malki
2026-04-10 21:49:42 +07:00
committed by GitHub
parent adec4eef48
commit f8526b5c01
4 changed files with 30 additions and 20 deletions

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { output, error, planningRoot, withPlanningLock, CONFIG_DEFAULTS, atomicWriteFileSync } = require('./core.cjs');
const { output, error, planningDir, withPlanningLock, CONFIG_DEFAULTS, atomicWriteFileSync } = require('./core.cjs');
const {
VALID_PROFILES,
getAgentToModelMapForProfile,
@@ -200,7 +200,7 @@ function buildNewProjectConfig(userChoices) {
* Idempotent: if config.json already exists, returns { created: false }.
*/
function cmdConfigNewProject(cwd, choicesJson, raw) {
const planningBase = planningRoot(cwd);
const planningBase = planningDir(cwd);
const configPath = path.join(planningBase, 'config.json');
// Idempotent: don't overwrite existing config
@@ -245,7 +245,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
* the happy path. But note that `error()` will still `exit(1)` out of the process.
*/
function ensureConfigFile(cwd) {
const planningBase = planningRoot(cwd);
const planningBase = planningDir(cwd);
const configPath = path.join(planningBase, 'config.json');
// Ensure .planning directory exists
@@ -295,7 +295,7 @@ function cmdConfigEnsureSection(cwd, raw) {
* the happy path. But note that `error()` will still `exit(1)` out of the process.
*/
function setConfigValue(cwd, keyPath, parsedValue) {
const configPath = path.join(planningRoot(cwd), 'config.json');
const configPath = path.join(planningDir(cwd), 'config.json');
return withPlanningLock(cwd, () => {
// Load existing config or start with empty object
@@ -368,7 +368,7 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
}
function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
const configPath = path.join(planningRoot(cwd), 'config.json');
const configPath = path.join(planningDir(cwd), 'config.json');
const hasDefault = defaultValue !== undefined;
if (!keyPath) {

View File

@@ -692,19 +692,23 @@ function planningRoot(cwd) {
}
/**
* Get common .planning file paths, workstream-aware.
* Scoped paths (state, roadmap, phases, requirements) resolve to the active workstream.
* Shared paths (project, config) always resolve to the root .planning/.
* Get common .planning file paths, project-and-workstream-aware.
*
* All paths route through planningDir(cwd, ws), which honors the GSD_PROJECT
* env var and active workstream. This matches loadConfig() above (line 256),
* which has always read config.json via planningDir(cwd). Previously project
* and config were resolved against the unrouted .planning/ root, which broke
* `gsd-tools config-get` in multi-project layouts (the CRUD writers and the
* reader pointed at different files).
*/
function planningPaths(cwd, ws) {
const base = planningDir(cwd, ws);
const root = path.join(cwd, '.planning');
return {
planning: base,
state: path.join(base, 'STATE.md'),
roadmap: path.join(base, 'ROADMAP.md'),
project: path.join(root, 'PROJECT.md'),
config: path.join(root, 'config.json'),
project: path.join(base, 'PROJECT.md'),
config: path.join(base, 'config.json'),
phases: path.join(base, 'phases'),
requirements: path.join(base, 'REQUIREMENTS.md'),
};

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { normalizePhaseName, findPhaseInternal, generateSlugInternal, normalizeMd, toPosixPath, output, error } = require('./core.cjs');
const { normalizePhaseName, findPhaseInternal, generateSlugInternal, normalizeMd, toPosixPath, planningDir, output, error } = require('./core.cjs');
const { reconstructFrontmatter } = require('./frontmatter.cjs');
function cmdTemplateSelect(cwd, planPath, raw) {
@@ -131,6 +131,10 @@ function cmdTemplateFill(cwd, templateType, options, raw) {
must_haves: { truths: [], artifacts: [], key_links: [] },
...fields,
};
const planBase = planningDir(cwd);
const projectRef = toPosixPath(path.relative(cwd, path.join(planBase, 'PROJECT.md')));
const roadmapRef = toPosixPath(path.relative(cwd, path.join(planBase, 'ROADMAP.md')));
const stateRef = toPosixPath(path.relative(cwd, path.join(planBase, 'STATE.md')));
body = [
`# Phase ${options.phase} Plan ${planNum}: [Title]`,
'',
@@ -140,9 +144,9 @@ function cmdTemplateFill(cwd, templateType, options, raw) {
'- **Output:** [Concrete deliverable]',
'',
'## Context',
'@.planning/PROJECT.md',
'@.planning/ROADMAP.md',
'@.planning/STATE.md',
`@${projectRef}`,
`@${roadmapRef}`,
`@${stateRef}`,
'',
'## Tasks',
'',

View File

@@ -5,7 +5,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { safeReadFile, loadConfig, normalizePhaseName, escapeRegex, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, planningDir, planningRoot, output, error, checkAgentsInstalled, CONFIG_DEFAULTS } = require('./core.cjs');
const { safeReadFile, loadConfig, normalizePhaseName, escapeRegex, execGit, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, planningDir, output, error, checkAgentsInstalled, CONFIG_DEFAULTS } = require('./core.cjs');
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
const { writeStateMd } = require('./state.cjs');
@@ -534,11 +534,10 @@ function cmdValidateHealth(cwd, options, raw) {
}
const planBase = planningDir(cwd);
const planRoot = planningRoot(cwd);
const projectPath = path.join(planRoot, 'PROJECT.md');
const projectPath = path.join(planBase, 'PROJECT.md');
const roadmapPath = path.join(planBase, 'ROADMAP.md');
const statePath = path.join(planBase, 'STATE.md');
const configPath = path.join(planRoot, 'config.json');
const configPath = path.join(planBase, 'config.json');
const phasesDir = path.join(planBase, 'phases');
const errors = [];
@@ -865,9 +864,12 @@ function cmdValidateHealth(cwd, options, raw) {
}
// Generate minimal STATE.md from ROADMAP.md structure
const milestone = getMilestoneInfo(cwd);
const projectRef = path
.relative(cwd, path.join(planningDir(cwd), 'PROJECT.md'))
.split(path.sep).join('/');
let stateContent = `# Session State\n\n`;
stateContent += `## Project Reference\n\n`;
stateContent += `See: .planning/PROJECT.md\n\n`;
stateContent += `See: ${projectRef}\n\n`;
stateContent += `## Position\n\n`;
stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`;
stateContent += `**Current phase:** (determining...)\n`;