get-shit-done-cc 1.42.2 → 1.42.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/agents/gsd-executor.md +25 -1
  2. package/agents/gsd-phase-researcher.md +1 -1
  3. package/agents/gsd-planner.md +2 -2
  4. package/agents/gsd-research-synthesizer.md +1 -1
  5. package/agents/gsd-roadmapper.md +1 -1
  6. package/bin/install.js +101 -24
  7. package/get-shit-done/bin/lib/commands.cjs +1 -0
  8. package/get-shit-done/bin/lib/config.cjs +11 -1
  9. package/get-shit-done/bin/lib/core.cjs +33 -0
  10. package/get-shit-done/bin/lib/init.cjs +15 -0
  11. package/get-shit-done/bin/lib/installer-migration-report.cjs +234 -2
  12. package/get-shit-done/bin/lib/phase-command-router.cjs +1 -0
  13. package/get-shit-done/bin/lib/phase.cjs +89 -3
  14. package/get-shit-done/bin/lib/roadmap.cjs +25 -3
  15. package/get-shit-done/workflows/execute-phase.md +1 -1
  16. package/get-shit-done/workflows/plan-phase.md +45 -2
  17. package/get-shit-done/workflows/ultraplan-phase.md +10 -1
  18. package/get-shit-done/workflows/update.md +9 -3
  19. package/package.json +1 -1
  20. package/scripts/diff-touches-shipped-paths.cjs +49 -3
  21. package/sdk/dist/query/config-mutation.d.ts.map +1 -1
  22. package/sdk/dist/query/config-mutation.js +7 -0
  23. package/sdk/dist/query/config-mutation.js.map +1 -1
  24. package/sdk/dist/query/init.d.ts.map +1 -1
  25. package/sdk/dist/query/init.js +12 -0
  26. package/sdk/dist/query/init.js.map +1 -1
  27. package/sdk/dist/query/state.d.ts.map +1 -1
  28. package/sdk/dist/query/state.js +13 -0
  29. package/sdk/dist/query/state.js.map +1 -1
  30. package/sdk/dist/query/validate.d.ts.map +1 -1
  31. package/sdk/dist/query/validate.js +37 -6
  32. package/sdk/dist/query/validate.js.map +1 -1
  33. package/sdk/dist/query-gsd-tools-runtime.d.ts.map +1 -1
  34. package/sdk/dist/query-gsd-tools-runtime.js +7 -1
  35. package/sdk/dist/query-gsd-tools-runtime.js.map +1 -1
  36. package/sdk/package-lock.json +2 -2
  37. package/sdk/package.json +1 -1
  38. package/sdk/src/bug-3591-gsdtools-runtime-workstream.test.ts +179 -0
  39. package/sdk/src/query/config-mutation.ts +7 -0
  40. package/sdk/src/query/init.test.ts +48 -0
  41. package/sdk/src/query/init.ts +13 -0
  42. package/sdk/src/query/state.ts +12 -0
  43. package/sdk/src/query/validate.test.ts +67 -0
  44. package/sdk/src/query/validate.ts +34 -6
  45. package/sdk/src/query-gsd-tools-runtime.ts +7 -1
  46. package/sdk-bundle/gsd-sdk.tgz +0 -0
@@ -192,7 +192,7 @@ This exclusion exists because a failed install may indicate a slopsquatted or ha
192
192
  `[package-name]` could not be installed. Before proceeding:
193
193
  1. Verify the package exists and is legitimate: https://npmjs.com/package/[package-name]
194
194
  2. Confirm the package name is spelled correctly in PLAN.md
195
- 3. If the package does not exist, return to /gsd-research-phase to find the correct package
195
+ 3. If the package does not exist, re-run /gsd:plan-phase --research-phase <N> to find the correct package
196
196
  </how-to-verify>
197
197
  <resume-signal>Type "verified" with the correct package name, or "abort" to stop the phase</resume-signal>
198
198
  </task>
@@ -551,6 +551,30 @@ back, those deletions appear on the main branch, destroying prior-wave work (#20
551
551
  `<worktree_branch_check>` and per-commit `<pre_commit_head_assertion>` are the
552
552
  correct prevention; if either fails, the workflow MUST stop, not self-heal.
553
553
  - `git push --force` / `git push -f` to any branch you did not create.
554
+ - `git stash`, `git stash push`, `git stash pop`, `git stash apply`, `git stash drop`
555
+ (and any other `git stash` subcommand). **The stash list is shared across the
556
+ main checkout and every linked worktree** — git stores stashes at `refs/stash`
557
+ inside the parent `.git/` directory, not inside the per-worktree
558
+ `.git/worktrees/<name>/` subdirectory. From inside your worktree, `git stash list`
559
+ shows the global stack with no indication that entries originated elsewhere, and
560
+ `git stash pop` pops the top of that global stack regardless of which worktree
561
+ pushed it. Running `git stash pop` after a `git stash` that printed "No local
562
+ changes to save" will silently apply WIP from a sibling worktree's prior
563
+ session — typically producing UU/UD merge-conflict states, phantom untracked
564
+ files, and a contaminated working tree that violates the `isolation="worktree"`
565
+ invariant of your execution (#3542).
566
+
567
+ **Sanctioned alternatives** when you need to set aside or inspect work without
568
+ touching `refs/stash`:
569
+
570
+ - **Move WIP off the working tree:** commit it to a throwaway branch you own
571
+ (e.g. `git checkout -b scratch-/<task>-wip && git add -A && git commit -m "wip"`),
572
+ then `git checkout <your-worktree-branch>` to return to your task. The
573
+ throwaway branch lives in the per-worktree branch namespace and never
574
+ collides with sibling worktrees.
575
+ - **Read-only inspection of another ref:** use `git show <ref>:<path>` to
576
+ print a file at any ref, or `git diff <ref> -- <path>` to compare. Neither
577
+ mutates `refs/stash` nor leaks state across worktrees.
554
578
 
555
579
  If you need to discard changes to a specific file you modified during this task, use:
556
580
  ```bash
@@ -14,7 +14,7 @@ color: cyan
14
14
  <role>
15
15
  You are a GSD phase researcher. You answer "What do I need to know to PLAN this phase well?" and produce a single RESEARCH.md that the planner consumes.
16
16
 
17
- Spawned by `/gsd:plan-phase` (integrated) or `/gsd-research-phase` (standalone).
17
+ Spawned by `/gsd:plan-phase` (integrated) or `/gsd:plan-phase --research-phase <N>` (standalone).
18
18
 
19
19
  @~/.claude/get-shit-done/references/mandatory-initial-read.md
20
20
 
@@ -183,7 +183,7 @@ Discovery is MANDATORY unless you can prove current context exists.
183
183
  - Level 2+: New library not in package.json, external API, "choose/select/evaluate" in description
184
184
  - Level 3: "architecture/design/system", multiple external services, data modeling, auth design
185
185
 
186
- For niche domains (3D, games, audio, shaders, ML), suggest `/gsd-research-phase` before plan-phase.
186
+ For niche domains (3D/games/audio/shaders/ML), suggest `/gsd:plan-phase --research-phase <N>` first.
187
187
 
188
188
  </discovery_levels>
189
189
 
@@ -988,7 +988,7 @@ Use `phase_dir` from init context (already loaded in load_project_state).
988
988
 
989
989
  ```bash
990
990
  cat "$phase_dir"/*-CONTEXT.md 2>/dev/null # From /gsd:discuss-phase
991
- cat "$phase_dir"/*-RESEARCH.md 2>/dev/null # From /gsd-research-phase
991
+ cat "$phase_dir"/*-RESEARCH.md 2>/dev/null # Research output
992
992
  cat "$phase_dir"/*-DISCOVERY.md 2>/dev/null # From mandatory discovery
993
993
  ```
994
994
 
@@ -112,7 +112,7 @@ This is the most important section. Based on combined research:
112
112
  - Which pitfalls it must avoid
113
113
 
114
114
  **Add research flags:**
115
- - Which phases likely need `/gsd-research-phase` during planning?
115
+ - Which phases likely need `/gsd:plan-phase --research-phase <N>` during planning?
116
116
  - Which phases have well-documented patterns (skip research)?
117
117
 
118
118
  ## Step 5: Assess Confidence
@@ -202,7 +202,7 @@ Track coverage as you go.
202
202
  **Integer phases (1, 2, 3):** Planned milestone work.
203
203
 
204
204
  **Decimal phases (2.1, 2.2):** Urgent insertions after planning.
205
- - Created via `/gsd-insert-phase`
205
+ - Created via `/gsd:phase insert`
206
206
  - Execute between integers: 1 → 1.1 → 1.2 → 2
207
207
 
208
208
  **Starting number:**
package/bin/install.js CHANGED
@@ -32,6 +32,19 @@ const reset = '\x1b[0m';
32
32
  // Codex config.toml constants
33
33
  const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by get-shit-done installer';
34
34
  const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: ';
35
+ // Codex's hook-enabling feature flag (issue #3566). Codex itself marks
36
+ // `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the
37
+ // canonical current key under [features] is `hooks`. The installer always
38
+ // emits the canonical key going forward, recognizes legacy aliases as
39
+ // equivalent during reinstall, and migrates them forward on rewrite. The
40
+ // audit-marker string above is intentionally unchanged so existing
41
+ // installs' ownership lines continue to round-trip.
42
+ const CODEX_HOOKS_FEATURE_KEY = 'hooks';
43
+ const CODEX_HOOKS_FEATURE_LEGACY_KEYS = ['codex_hooks'];
44
+ const CODEX_HOOKS_FEATURE_ALL_KEYS = [CODEX_HOOKS_FEATURE_KEY, ...CODEX_HOOKS_FEATURE_LEGACY_KEYS];
45
+ function isCodexHooksFeatureKey(key) {
46
+ return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
47
+ }
35
48
 
36
49
  // Copilot instructions marker constants
37
50
  const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by get-shit-done installer -->';
@@ -99,11 +112,13 @@ const {
99
112
  stageAgentsForProfile,
100
113
  } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
101
114
  const {
115
+ applyInstallerMigrationPlan,
102
116
  discoverInstallerMigrations,
103
117
  runInstallerMigrations,
104
118
  } = require(path.join(_gsdLibDir, 'installer-migrations.cjs'));
105
119
  const {
106
120
  assertInstallerMigrationsUnblocked,
121
+ resolveInstallerMigrationPromptsForNonTty,
107
122
  summarizeInstallerMigrationResult,
108
123
  } = require(path.join(_gsdLibDir, 'installer-migration-report.cjs'));
109
124
 
@@ -3228,7 +3243,7 @@ function stripCodexHooksFeatureAssignments(content, ownership = null) {
3228
3243
  !record.startsInMultilineString &&
3229
3244
  record.keySegments &&
3230
3245
  record.keySegments.length === 1 &&
3231
- record.keySegments[0] === 'codex_hooks'
3246
+ isCodexHooksFeatureKey(record.keySegments[0])
3232
3247
  );
3233
3248
 
3234
3249
  for (const record of codexHookRecords) {
@@ -3273,7 +3288,7 @@ function stripCodexHooksFeatureAssignments(content, ownership = null) {
3273
3288
  record.keySegments &&
3274
3289
  record.keySegments.length === 2 &&
3275
3290
  record.keySegments[0] === 'features' &&
3276
- record.keySegments[1] === 'codex_hooks'
3291
+ isCodexHooksFeatureKey(record.keySegments[1])
3277
3292
  );
3278
3293
 
3279
3294
  for (const record of rootCodexHookRecords) {
@@ -4429,6 +4444,13 @@ function rewriteTomlKeyLines(content, matches, key) {
4429
4444
  const blockEol = blockEnd > 0 && content[blockEnd - 1] === '\n'
4430
4445
  ? (blockEnd > 1 && content[blockEnd - 2] === '\r' ? '\r\n' : '\n')
4431
4446
  : '';
4447
+ // Preserve the existing key when one is present on the line
4448
+ // (`match.keyRaw`). This respects user ownership: a user-authored
4449
+ // `codex_hooks = true` line stays as `codex_hooks = true` even
4450
+ // though `hooks` is the canonical key in current Codex (#3566).
4451
+ // Codex's own `legacy_key` alias mechanism in codex-rs handles the
4452
+ // backward compat at the runtime layer. Migration to canonical is
4453
+ // a fresh-insert-only operation in ensureCodexHooksFeature.
4432
4454
  rewritten += normalizeCodexHooksLine(match.text, match.keyRaw || key) + blockEol;
4433
4455
  cursor = blockEnd;
4434
4456
  return;
@@ -4610,11 +4632,17 @@ function ensureCodexHooksFeature(configContent) {
4610
4632
  record.end + record.eol.length <= featuresSection.end &&
4611
4633
  record.keySegments &&
4612
4634
  record.keySegments.length === 1 &&
4613
- record.keySegments[0] === 'codex_hooks'
4635
+ isCodexHooksFeatureKey(record.keySegments[0])
4614
4636
  );
4615
4637
 
4616
4638
  if (sectionLines.length > 0) {
4617
- const rewritten = rewriteTomlKeyLines(configContent, sectionLines, 'codex_hooks');
4639
+ // Rewrite to canonical key — this migrates legacy `codex_hooks` to
4640
+ // `hooks` in-place on every reinstall. If the file already has the
4641
+ // canonical key the rewrite is a no-op shape-wise (same key, same
4642
+ // value). The rewriteTomlKeyLines helper preserves indentation,
4643
+ // trailing comments, and ownership-marker positioning, and always
4644
+ // emits the caller-supplied canonical key (#3566).
4645
+ const rewritten = rewriteTomlKeyLines(configContent, sectionLines, CODEX_HOOKS_FEATURE_KEY);
4618
4646
  return {
4619
4647
  content: repairTrappedFeaturesKeys(rewritten),
4620
4648
  ownership: null,
@@ -4624,7 +4652,7 @@ function ensureCodexHooksFeature(configContent) {
4624
4652
  const sectionBody = configContent.slice(featuresSection.headerEnd, featuresSection.end);
4625
4653
  const needsSeparator = sectionBody.length > 0 && !sectionBody.endsWith('\n') && !sectionBody.endsWith('\r\n');
4626
4654
  const insertPrefix = sectionBody.length === 0 && featuresSection.headerEnd === configContent.length ? eol : '';
4627
- const insertText = `${insertPrefix}${needsSeparator ? eol : ''}codex_hooks = true${eol}`;
4655
+ const insertText = `${insertPrefix}${needsSeparator ? eol : ''}${CODEX_HOOKS_FEATURE_KEY} = true${eol}`;
4628
4656
  const merged = configContent.slice(0, featuresSection.end) + insertText + configContent.slice(featuresSection.end);
4629
4657
  return {
4630
4658
  content: repairTrappedFeaturesKeys(merged),
@@ -4642,11 +4670,11 @@ function ensureCodexHooksFeature(configContent) {
4642
4670
  );
4643
4671
 
4644
4672
  const rootCodexHooksLines = rootFeatureLines
4645
- .filter((record) => record.keySegments.length === 2 && record.keySegments[1] === 'codex_hooks');
4673
+ .filter((record) => record.keySegments.length === 2 && isCodexHooksFeatureKey(record.keySegments[1]));
4646
4674
 
4647
4675
  if (rootCodexHooksLines.length > 0) {
4648
4676
  return {
4649
- content: rewriteTomlKeyLines(configContent, rootCodexHooksLines, 'features.codex_hooks'),
4677
+ content: rewriteTomlKeyLines(configContent, rootCodexHooksLines, `features.${CODEX_HOOKS_FEATURE_KEY}`),
4650
4678
  ownership: null,
4651
4679
  };
4652
4680
  }
@@ -4664,13 +4692,13 @@ function ensureCodexHooksFeature(configContent) {
4664
4692
  const prefix = insertAt > 0 && configContent[insertAt - 1] === '\n' ? '' : eol;
4665
4693
  return {
4666
4694
  content: configContent.slice(0, insertAt) +
4667
- `${prefix}features.codex_hooks = true${eol}` +
4695
+ `${prefix}features.${CODEX_HOOKS_FEATURE_KEY} = true${eol}` +
4668
4696
  configContent.slice(insertAt),
4669
4697
  ownership: 'root_dotted',
4670
4698
  };
4671
4699
  }
4672
4700
 
4673
- const featuresBlock = `[features]${eol}codex_hooks = true${eol}`;
4701
+ const featuresBlock = `[features]${eol}${CODEX_HOOKS_FEATURE_KEY} = true${eol}`;
4674
4702
  if (!configContent) {
4675
4703
  return { content: featuresBlock, ownership: 'section' };
4676
4704
  }
@@ -4701,11 +4729,11 @@ function hasEnabledCodexHooksFeature(configContent) {
4701
4729
 
4702
4730
  const isSectionKey = record.tablePath === 'features' &&
4703
4731
  record.keySegments.length === 1 &&
4704
- record.keySegments[0] === 'codex_hooks';
4732
+ isCodexHooksFeatureKey(record.keySegments[0]);
4705
4733
  const isRootDottedKey = record.tablePath === null &&
4706
4734
  record.keySegments.length === 2 &&
4707
4735
  record.keySegments[0] === 'features' &&
4708
- record.keySegments[1] === 'codex_hooks';
4736
+ isCodexHooksFeatureKey(record.keySegments[1]);
4709
4737
 
4710
4738
  if (!isSectionKey && !isRootDottedKey) {
4711
4739
  return false;
@@ -8030,6 +8058,51 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8030
8058
  migrations: options.installerMigrations,
8031
8059
  baselineScan: true,
8032
8060
  });
8061
+ // #3541: non-interactive runs (typical /gsd-update via Claude Code) have
8062
+ // no stdin TTY and therefore no way to answer prompt-user migration
8063
+ // actions. Resolve safe categories by classification (stale SDK build
8064
+ // artifacts → remove; user-facing skills → keep; bundled GSD hooks →
8065
+ // remove [#3610]) and log every resolution; anything that cannot be
8066
+ // safely defaulted falls through to assertInstallerMigrationsUnblocked,
8067
+ // which now emits a grouped error with the documented resolution path.
8068
+ //
8069
+ // #3610: the classifier-based resolution must run regardless of TTY.
8070
+ // For unambiguous categories (e.g. `hooks/gsd-*` bundled hooks left
8071
+ // behind by a previous version), there is no actual "user choice" to
8072
+ // make — the file is a known GSD-managed artifact and the installer is
8073
+ // about to write the fresh bundled version. Gating the resolver on
8074
+ // `!isTTY` made `npx get-shit-done-cc@latest --codex` hard-abort with
8075
+ // 12 blocked bundled hooks. The env-override branch (operator-supplied
8076
+ // GSD_INSTALLER_MIGRATION_RESOLVE) still applies only in non-TTY mode.
8077
+ const _migrationIsTty = process.stdin && process.stdin.isTTY === true;
8078
+ if (Array.isArray(installerMigrationResult.blocked) &&
8079
+ installerMigrationResult.blocked.length > 0 &&
8080
+ installerMigrationResult.plan &&
8081
+ Array.isArray(installerMigrationResult.plan.actions)) {
8082
+ const { resolutions } = resolveInstallerMigrationPromptsForNonTty(
8083
+ installerMigrationResult,
8084
+ { isTty: false }
8085
+ );
8086
+ for (const entry of resolutions) {
8087
+ console.log(
8088
+ ` ↪ installer-migration auto-resolved: ${entry.relPath} → ${entry.choice} ` +
8089
+ `(category=${entry.category}, source=${entry.source})`
8090
+ );
8091
+ }
8092
+ // If we resolved anything, the original run returned early without
8093
+ // applying the (now-unblocked) plan — apply it here.
8094
+ if (resolutions.length > 0 && installerMigrationResult.plan.blocked.length === 0) {
8095
+ const applyResult = applyInstallerMigrationPlan({
8096
+ configDir: targetDir,
8097
+ plan: installerMigrationResult.plan,
8098
+ });
8099
+ installerMigrationResult = {
8100
+ ...installerMigrationResult,
8101
+ ...applyResult,
8102
+ blocked: [],
8103
+ };
8104
+ }
8105
+ }
8033
8106
  reportInstallerMigrationResult(installerMigrationResult);
8034
8107
  assertInstallerMigrationsUnblocked(installerMigrationResult);
8035
8108
 
@@ -8049,23 +8122,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8049
8122
  failures.push('command/gsd-*');
8050
8123
  }
8051
8124
  } else if (isCodex) {
8125
+ // Codex CLI (0.130.0 at time of #3562) does NOT auto-discover commands
8126
+ // from get-shit-done/workflows/*.md or agents/*.md. It only registers
8127
+ // commands from skills/<name>/SKILL.md. The earlier "Codex discovers
8128
+ // official skills directly" branch left users with workflows on disk and
8129
+ // no $gsd-* entrypoints. Regenerate the skill surface the same way the
8130
+ // other runtimes do — copyCommandsAsCodexSkills() rewrites each
8131
+ // commands/gsd/*.md as ~/.codex/skills/gsd-<name>/SKILL.md and converts
8132
+ // Claude-flavored command frontmatter into Codex skill frontmatter.
8052
8133
  const skillsDir = path.join(targetDir, 'skills');
8053
- // Codex now discovers repo/user/admin/system skills from .agents/skills and
8054
- // warns if a layer mixes redundant hook/skill representations. Legacy
8055
- // gsd-* copies under ~/.codex/skills are therefore removed and no longer
8056
- // regenerated.
8057
- let removedLegacyCodexSkills = 0;
8134
+ const gsdSrc = _stageSkills(_commandsDir);
8135
+ copyCommandsAsCodexSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime);
8058
8136
  if (fs.existsSync(skillsDir)) {
8059
- for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
8060
- if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
8061
- fs.rmSync(path.join(skillsDir, entry.name), { recursive: true, force: true });
8062
- removedLegacyCodexSkills += 1;
8137
+ const count = fs.readdirSync(skillsDir, { withFileTypes: true })
8138
+ .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
8139
+ if (count > 0) {
8140
+ console.log(` ${green}✓${reset} Installed ${count} skills to skills/`);
8141
+ } else {
8142
+ failures.push('skills/gsd-*');
8063
8143
  }
8064
- }
8065
- if (removedLegacyCodexSkills > 0) {
8066
- console.log(` ${green}✓${reset} Removed ${removedLegacyCodexSkills} legacy Codex gsd-* skill copies from skills/`);
8067
8144
  } else {
8068
- console.log(` ${dim}↳${reset} Skipped Codex skill-copy generation (Codex discovers official skills directly)`);
8145
+ failures.push('skills/gsd-*');
8069
8146
  }
8070
8147
  } else if (isCopilot) {
8071
8148
  const skillsDir = path.join(targetDir, 'skills');
@@ -1011,6 +1011,7 @@ function cmdCheckCommit(cwd, raw) {
1011
1011
  }
1012
1012
 
1013
1013
  module.exports = {
1014
+ determinePhaseStatus,
1014
1015
  cmdGenerateSlug,
1015
1016
  cmdCurrentTimestamp,
1016
1017
  cmdListTodos,
@@ -392,7 +392,17 @@ function setConfigValue(cwd, keyPath, parsedValue) {
392
392
  */
393
393
  function cmdConfigSet(cwd, keyPath, value, raw) {
394
394
  if (!keyPath) {
395
- error('Usage: config-set <key.path> <value>');
395
+ error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
396
+ }
397
+ // #3593: reject the "key without value" form (e.g. `config-set
398
+ // model_profile` with args[2] === undefined). Without this guard the
399
+ // value passes through as undefined, the number/boolean/json branches
400
+ // all fall through, and the write either silently strips the key
401
+ // (JSON.stringify drops undefined values) or writes a corrupt entry.
402
+ // Typed reason so the negative-matrix test can assert on it instead
403
+ // of greppinng prose.
404
+ if (value === undefined) {
405
+ error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
396
406
  }
397
407
 
398
408
  validateKnownConfigKeyPath(keyPath);
@@ -529,6 +529,7 @@ function loadConfig(cwd, options = {}) {
529
529
  firecrawl: get('firecrawl') ?? defaults.firecrawl,
530
530
  exa_search: get('exa_search') ?? defaults.exa_search,
531
531
  tdd_mode: get('tdd_mode', { section: 'workflow', field: 'tdd_mode' }) ?? false,
532
+ mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false,
532
533
  text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
533
534
  auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,
534
535
  _auto_chain_active: get('_auto_chain_active', { section: 'workflow', field: '_auto_chain_active' }) ?? false,
@@ -751,6 +752,25 @@ function phaseMarkdownRegexSource(phaseNum) {
751
752
  return `0*${escapeRegex(integer)}${letter}${decimal}`;
752
753
  }
753
754
 
755
+ /**
756
+ * #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`,
757
+ * return the exact-escaped form so the caller can search the ROADMAP for
758
+ * `### Phase PROJ-42:` BEFORE falling back to the padding-tolerant numeric
759
+ * form. Returns null when the input has no project-code prefix — in that
760
+ * case the numeric form (`phaseMarkdownRegexSource`) is the only thing the
761
+ * caller needs.
762
+ *
763
+ * Two-pass at the call site preserves the #3537 contract (`CK-01` directory
764
+ * names mapping to `Phase 1:` prose) while letting `PROJ-42` resolve to its
765
+ * own prefixed heading without cross-matching a bare `### Phase 42:` that
766
+ * happens to share the trailing integer.
767
+ */
768
+ function phaseMarkdownRegexSourceExact(phaseNum) {
769
+ const raw = String(phaseNum);
770
+ if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw)) return null;
771
+ return escapeRegex(raw);
772
+ }
773
+
754
774
  function comparePhaseNum(a, b) {
755
775
  // Strip optional project_code prefix before comparing (e.g., 'CK-01-name' → '01-name')
756
776
  const sa = String(a).replace(/^[A-Z]{1,6}-/, '');
@@ -1812,6 +1832,18 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
1812
1832
  // Try custom ID match (e.g. PROJ-42-description → PROJ-42)
1813
1833
  const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
1814
1834
  if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
1835
+ // #3600: project-code-prefixed directory (`CK-01-name`) against a
1836
+ // numeric ROADMAP heading (`### Phase 1:`). Strip the same prefix
1837
+ // shape `normalizePhaseName` recognises (`^[A-Z]{1,6}-(?=\d)`) and
1838
+ // retry the numeric match. This runs AFTER the custom-ID match so
1839
+ // a roadmap that uses `Phase PROJ-42:` continues to win via the
1840
+ // existing custom-ID path; the strip-and-retry only fires when the
1841
+ // milestone is keyed on the bare numeric form.
1842
+ const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
1843
+ if (stripped !== dirName) {
1844
+ const sm = stripped.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1845
+ if (sm && normalized.has(sm[1].toLowerCase())) return true;
1846
+ }
1815
1847
  return false;
1816
1848
  }
1817
1849
  isDirInMilestone.phaseCount = milestonePhaseNums.size;
@@ -1900,6 +1932,7 @@ module.exports = {
1900
1932
  escapeRegex,
1901
1933
  normalizePhaseName,
1902
1934
  phaseMarkdownRegexSource,
1935
+ phaseMarkdownRegexSourceExact,
1903
1936
  comparePhaseNum,
1904
1937
  searchPhaseInDir,
1905
1938
  extractPhaseToken,
@@ -10,6 +10,7 @@ const { planningPaths, planningDir, planningRoot } = require('./planning-workspa
10
10
  const { maskIfSecret } = require('./secrets.cjs');
11
11
  const scanPhasePlans = require('./plan-scan.cjs');
12
12
  const { stateExtractField } = require('./state-document.cjs');
13
+ const { determinePhaseStatus } = require('./commands.cjs');
13
14
 
14
15
  // Accept all bold/colon variants of the Requirements header (#2769):
15
16
  // **Requirements:** / **Requirements**: / **Requirements** : render the
@@ -295,6 +296,20 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
295
296
  padded_phase: phaseNumberPlan ? normalizePhaseName(phaseNumberPlan) : null,
296
297
  phase_req_ids,
297
298
 
299
+ // #3569: surface phase lifecycle status so /gsd:plan-phase can short-circuit
300
+ // on closed (Complete) phases instead of silently replanning over shipped
301
+ // code. Reuses determinePhaseStatus — the project-wide vocabulary
302
+ // (Pending | Planned | In Progress | Executed | Complete | Needs Review).
303
+ // No directory yet → Pending (phase has not been started).
304
+ phase_status: phaseDirPlan
305
+ ? determinePhaseStatus(
306
+ phaseInfo?.plans?.length || 0,
307
+ phaseInfo?.summaries?.length || 0,
308
+ path.join(cwd, phaseDirPlan),
309
+ 'Pending',
310
+ )
311
+ : 'Pending',
312
+
298
313
  // Existing artifacts
299
314
  has_research: phaseInfo?.has_research || false,
300
315
  has_context: phaseInfo?.has_context || false,