@opengsd/gsd-core 1.8.0 → 1.9.0

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 (174) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +31 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +186 -55
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +849 -2
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +5 -5
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +57 -5
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  49. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  50. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  51. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  53. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  54. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  55. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  56. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  57. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  58. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  59. package/gsd-core/bin/lib/state-document.cjs +164 -20
  60. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  61. package/gsd-core/bin/lib/state.cjs +141 -21
  62. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  63. package/gsd-core/bin/lib/uat.cjs +9 -7
  64. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  65. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  66. package/gsd-core/bin/lib/validate.cjs +32 -0
  67. package/gsd-core/bin/lib/verification.cjs +51 -14
  68. package/gsd-core/bin/lib/verify.cjs +128 -20
  69. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  70. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  71. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  72. package/gsd-core/bin/shared/model-catalog.json +5 -0
  73. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  74. package/gsd-core/references/context-budget.md +40 -0
  75. package/gsd-core/references/gate-prompts.md +6 -3
  76. package/gsd-core/references/model-profile-resolution.md +64 -13
  77. package/gsd-core/references/offer-next.md +88 -0
  78. package/gsd-core/references/planning-config.md +2 -1
  79. package/gsd-core/references/reviewer-instances.md +28 -21
  80. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  81. package/gsd-core/references/ui-consideration-probe.md +2 -2
  82. package/gsd-core/references/worktree-branch-check.md +4 -4
  83. package/gsd-core/templates/summary-minimal.md +4 -0
  84. package/gsd-core/templates/summary-standard.md +4 -0
  85. package/gsd-core/templates/summary.md +7 -0
  86. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  87. package/gsd-core/workflows/audit-fix.md +4 -0
  88. package/gsd-core/workflows/audit-milestone.md +8 -0
  89. package/gsd-core/workflows/autonomous.md +19 -15
  90. package/gsd-core/workflows/check-todos.md +2 -2
  91. package/gsd-core/workflows/code-review-fix.md +14 -6
  92. package/gsd-core/workflows/code-review.md +76 -19
  93. package/gsd-core/workflows/debug.md +10 -2
  94. package/gsd-core/workflows/diagnose-issues.md +4 -0
  95. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  96. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  97. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  98. package/gsd-core/workflows/discuss-phase.md +2 -2
  99. package/gsd-core/workflows/docs-update.md +8 -0
  100. package/gsd-core/workflows/eval-review.md +1 -1
  101. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  102. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  103. package/gsd-core/workflows/execute-phase.md +85 -115
  104. package/gsd-core/workflows/execute-plan.md +5 -4
  105. package/gsd-core/workflows/explore.md +4 -0
  106. package/gsd-core/workflows/extract-learnings.md +21 -0
  107. package/gsd-core/workflows/help/modes/full.md +3 -3
  108. package/gsd-core/workflows/import.md +4 -1
  109. package/gsd-core/workflows/ingest-docs.md +4 -0
  110. package/gsd-core/workflows/map-codebase.md +13 -6
  111. package/gsd-core/workflows/new-milestone.md +10 -2
  112. package/gsd-core/workflows/new-project.md +11 -4
  113. package/gsd-core/workflows/next.md +5 -2
  114. package/gsd-core/workflows/plan-phase.md +42 -46
  115. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  116. package/gsd-core/workflows/progress.md +1 -1
  117. package/gsd-core/workflows/quick.md +14 -3
  118. package/gsd-core/workflows/review.md +146 -575
  119. package/gsd-core/workflows/scan.md +9 -1
  120. package/gsd-core/workflows/secure-phase.md +10 -2
  121. package/gsd-core/workflows/ship.md +41 -11
  122. package/gsd-core/workflows/smart-entry.md +1 -1
  123. package/gsd-core/workflows/ui-phase.md +8 -1
  124. package/gsd-core/workflows/ui-review.md +8 -1
  125. package/gsd-core/workflows/update.md +104 -5
  126. package/gsd-core/workflows/validate-phase.md +10 -2
  127. package/gsd-core/workflows/verify-work.md +8 -1
  128. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  129. package/hooks/dist/gsd-cursor-stop.js +6 -2
  130. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  131. package/hooks/dist/gsd-graphify-update.sh +9 -0
  132. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  133. package/hooks/dist/gsd-prompt-guard.js +101 -2
  134. package/hooks/dist/gsd-read-guard.js +100 -2
  135. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  136. package/hooks/dist/gsd-statusline.js +9 -6
  137. package/hooks/dist/gsd-workflow-guard.js +110 -6
  138. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  139. package/hooks/dist/lib/cursor-workspace.js +74 -0
  140. package/hooks/gsd-cursor-session-start.js +6 -2
  141. package/hooks/gsd-cursor-stop.js +6 -2
  142. package/hooks/gsd-cursor-subagent-start.js +6 -2
  143. package/hooks/gsd-graphify-update.sh +9 -0
  144. package/hooks/gsd-phase-boundary.sh +14 -2
  145. package/hooks/gsd-prompt-guard.js +101 -2
  146. package/hooks/gsd-read-guard.js +100 -2
  147. package/hooks/gsd-read-injection-scanner.js +109 -2
  148. package/hooks/gsd-statusline.js +9 -6
  149. package/hooks/gsd-workflow-guard.js +110 -6
  150. package/hooks/gsd-worktree-path-guard.js +132 -8
  151. package/hooks/lib/cursor-workspace.js +74 -0
  152. package/package.json +7 -7
  153. package/pi/gsd.cjs +26 -1
  154. package/scripts/check-coverage-gate.cjs +51 -0
  155. package/scripts/check-glossary-refs.cjs +24 -0
  156. package/scripts/ci-test-scope.cjs +67 -17
  157. package/scripts/gen-adr-index.cjs +6 -4
  158. package/scripts/gen-capability-matrix.cjs +26 -2
  159. package/scripts/gen-capability-registry.cjs +132 -34
  160. package/scripts/gen-emitted-baseline.cjs +145 -0
  161. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  162. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  163. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  164. package/scripts/lint-resolution-provenance.cjs +9 -0
  165. package/scripts/mutation-matrix.cjs +4 -0
  166. package/scripts/prompt-injection-scan.sh +6 -0
  167. package/scripts/registry-schema.cjs +57 -8
  168. package/scripts/release-notes/conventional-title.cjs +19 -1
  169. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  170. package/scripts/workflow-size.cjs +16 -8
  171. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  172. package/vscode/package.json +1 -1
  173. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  174. package/scripts/update-size-baseline.cjs +0 -68
@@ -24,7 +24,7 @@ const coreUtilsMod = require("./core-utils.cjs");
24
24
  const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
25
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
26
26
  const phaseIdMod = require("./phase-id.cjs");
27
- const { normalizePhaseName, comparePhaseNum, extractPhaseToken } = phaseIdMod;
27
+ const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
28
28
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
29
  const phaseLocatorMod = require("./phase-locator.cjs");
30
30
  const { getArchivedPhaseDirs, findPhaseInternal } = phaseLocatorMod;
@@ -39,6 +39,8 @@ const agentCommandRouterMod = require("./agent-command-router.cjs");
39
39
  const { AGENT_FAILURE_CLASSES } = agentCommandRouterMod;
40
40
  const model_catalog_cjs_1 = require("./model-catalog.cjs");
41
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
+ const hostIntegrationMod = require("./host-integration.cjs");
43
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
42
44
  const planningWorkspace = require("./planning-workspace.cjs");
43
45
  const { planningDir, planningPaths } = planningWorkspace;
44
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -104,12 +106,13 @@ function determinePhaseStatus(plans, summaries, phaseDir, defaultPending) {
104
106
  const files = node_fs_1.default.readdirSync(phaseDir);
105
107
  const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
106
108
  if (verificationFile) {
107
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(phaseDir, verificationFile)) || '';
109
+ const verificationFilePath = node_path_1.default.join(phaseDir, verificationFile);
110
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(verificationFilePath) || '';
108
111
  // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false
109
112
  // matches from historical body metadata such as `previous_status: gaps_found`.
110
113
  // Full-text regexes like /status:\s*gaps_found/ match the substring inside
111
114
  // `previous_status: gaps_found`, producing incorrect phase status labels.
112
- const fm = extractFrontmatter(content);
115
+ const fm = extractFrontmatter(content, verificationFilePath);
113
116
  // Normalise to lower-case to preserve the prior case-insensitive behaviour
114
117
  // while reading only the frontmatter `status` key (not the full body text).
115
118
  const fmStatus = typeof fm['status'] === 'string' ? fm['status'].trim().toLowerCase() : '';
@@ -261,7 +264,7 @@ function cmdListSeeds(cwd, statusFilter, raw) {
261
264
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
262
265
  if (content === null)
263
266
  continue;
264
- const fm = extractFrontmatter(content);
267
+ const fm = extractFrontmatter(content, safeFilePath);
265
268
  const status = (fmStr(fm.status) || 'dormant').toLowerCase().trim() || 'dormant';
266
269
  // Match on the raw lowercased status (both sides already normalized);
267
270
  // sanitizeForDisplay is for output, not comparison.
@@ -347,11 +350,12 @@ function cmdHistoryDigest(cwd, raw) {
347
350
  for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
348
351
  const summaries = node_fs_1.default.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
349
352
  for (const summary of summaries) {
350
- const content = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(dirPath, summary));
353
+ const summaryFilePath = node_path_1.default.join(dirPath, summary);
354
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(summaryFilePath);
351
355
  if (content === null)
352
356
  continue;
353
357
  try {
354
- const fm = extractFrontmatter(content);
358
+ const fm = extractFrontmatter(content, summaryFilePath);
355
359
  const phaseNum = fm['phase'] || dir.split('-')[0];
356
360
  if (!digest.phases[phaseNum]) {
357
361
  digest.phases[phaseNum] = {
@@ -440,7 +444,7 @@ function cmdResolveGranularity(cwd, phaseType, raw, override) {
440
444
  * fast_mode, fast_mode_supported, [unknown_agent] }
441
445
  *
442
446
  * Flags: --effort <level>, --fast-mode <true|false>, --attempt <n>,
443
- * --failure-class <class> (#2296)
447
+ * --failure-class <class> (#2296), --host <runtime-id> (#2481)
444
448
  */
445
449
  function cmdResolveExecution(cwd, agentType, raw, opts) {
446
450
  if (!agentType) {
@@ -496,12 +500,54 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
496
500
  fast_mode: fastMode,
497
501
  fast_mode_supported: fastModeSupported,
498
502
  };
503
+ // ADR-1239 amendment (#2481) / ADR-443 path (a): invocation-time effort for a
504
+ // named host. The host's negotiated `effortSurface` decides WHETHER an argument
505
+ // is emitted; the catalog knows the syntax. Absent --host the contract is
506
+ // byte-identical to before, so every existing caller is unaffected.
507
+ if (typeof opts.host === 'string' && opts.host.length > 0) {
508
+ const surface = effortSurfaceForHost(cwd, opts.host);
509
+ const argvRendered = (0, model_catalog_cjs_1.renderEffortArgv)(opts.host, effort, surface);
510
+ result['host'] = opts.host;
511
+ result['effort_surface'] = surface;
512
+ result['effort_argv'] = argvRendered.argv;
513
+ result['effort_argv_string'] = argvRendered.argv.join(' ');
514
+ result['effort_argv_value'] = argvRendered.value;
515
+ }
499
516
  if (!agentModels)
500
517
  result['unknown_agent'] = true;
501
518
  if (escalation)
502
519
  result['escalation'] = escalation;
503
520
  output(result, raw, effort);
504
521
  }
522
+ /**
523
+ * ADR-1239 amendment (#2481) — resolve a host's negotiated `effortSurface`.
524
+ *
525
+ * Reads the host's runtime descriptor from the generated capability registry and
526
+ * runs it through the Host-Integration negotiation so the trust-boundary invariant
527
+ * applies here exactly as everywhere else: an unknown host, a missing axis, or the
528
+ * `undocumented` sentinel all degrade to the safe floor rather than being trusted.
529
+ * Never throws — a lookup failure yields `'none'`, which renders no argument.
530
+ */
531
+ function effortSurfaceForHost(cwd, host) {
532
+ void cwd;
533
+ try {
534
+ // Mirrors the lazy-require pattern from runtime-slash.cts §runtimeSlash —
535
+ // capability-registry.cjs is generated and carries no type declarations.
536
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
537
+ const { runtimes } = require('./capability-registry.cjs');
538
+ const declared = runtimes[host]?.runtime?.hostIntegration;
539
+ if (!declared || typeof declared !== 'object')
540
+ return 'none';
541
+ // The descriptor is untrusted JSON; negotiation applies the trust-boundary
542
+ // invariant (effective ⊆ host-declared ∩ engine-known) and fails closed.
543
+ const negotiated = hostIntegrationMod.negotiateHostCapabilities(declared);
544
+ const surface = negotiated?.effective?.effortSurface;
545
+ return typeof surface === 'string' ? surface : 'none';
546
+ }
547
+ catch {
548
+ return 'none';
549
+ }
550
+ }
505
551
  /**
506
552
  * #488 — Replace or inject the `effort:` value in YAML frontmatter.
507
553
  * Unlike injectEffortFrontmatter (install.js), this overwrites an existing value.
@@ -594,6 +640,59 @@ function cmdEffortSync(cwd, raw, opts) {
594
640
  }
595
641
  output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, synced > 0 ? 'changed' : 'ok');
596
642
  }
643
+ /**
644
+ * Detect the phase number for a commit from its `--files` path list.
645
+ *
646
+ * #2539: the extraction is anchored to the directory segment immediately under
647
+ * `.planning/phases/` or `.planning/milestones/<version>-phases/`, then run
648
+ * through the project-code-aware `extractPhaseToken` helper. The prior
649
+ * unanchored `match(/(\d+(?:\.\d+)*)-/)` returned the leftmost digit-run-then-
650
+ * hyphen anywhere in the joined path, so a project_code ending in a digit
651
+ * (e.g. PROJECT_V2) made `…/PROJECT_V2-07-name/…` match the `2-` inside `V2-`
652
+ * before the real `07-` phase token — resolving phase "2" instead of "7".
653
+ *
654
+ * Returns the phase number string (e.g. '07', '45.14'), or null when no phase
655
+ * directory segment is present in any of the file paths (e.g. a commit of
656
+ * `.planning/ROADMAP.md` has no phase segment, so no branch is resolved —
657
+ * matching the prior regex-no-match behaviour).
658
+ */
659
+ function detectPhaseNumberFromFiles(files) {
660
+ if (!files || files.length === 0)
661
+ return null;
662
+ // A phase directory lives one segment below a `phases` parent segment:
663
+ // .planning/phases/<phase-dir>/…
664
+ // .planning/milestones/v1.0-phases/<phase-dir>/…
665
+ // The segment immediately after the `…phases` segment is the phase directory
666
+ // name. extractPhaseToken owns the project-code-aware token read.
667
+ for (const file of files) {
668
+ const norm = String(file).replace(/\\/g, '/').replace(/^\.\//, '');
669
+ const segments = norm.split('/');
670
+ for (let i = 0; i < segments.length - 1; i++) {
671
+ if (segments[i] === 'phases' || segments[i].endsWith('-phases')) {
672
+ const phaseDir = segments[i + 1];
673
+ if (!phaseDir)
674
+ continue;
675
+ const token = extractPhaseToken(phaseDir);
676
+ // extractPhaseToken falls back to returning dirName unchanged when no
677
+ // numeric token is found. normalizePhaseName is the canonical arbiter
678
+ // of "is this a real phase token": it strips the project-code prefix
679
+ // and returns a zero-padded numeric form for a genuine phase token, or
680
+ // the input unchanged otherwise. Accept the token only when it
681
+ // normalizes to a numeric phase form (the single-owner rule shared by
682
+ // every other phase-token reader — see #2528).
683
+ const normalized = normalizePhaseName(token);
684
+ // Built from the single-owner PHASE_NUMBER_TOKEN_SOURCE (the canonical
685
+ // phase-number grammar — #2128 anti-divergence guard) so this read-side
686
+ // acceptance check cannot drift from every other phase-token reader.
687
+ const phaseTokenShape = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i');
688
+ if (token !== phaseDir && phaseTokenShape.test(normalized)) {
689
+ return token;
690
+ }
691
+ }
692
+ }
693
+ }
694
+ return null;
695
+ }
597
696
  function cmdCommit(cwd, message, files, raw, amend, noVerify) {
598
697
  if (!message && !amend) {
599
698
  error('commit message required');
@@ -629,10 +728,21 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
629
728
  if (branchingStrategy && branchingStrategy !== 'none') {
630
729
  let branchName = null;
631
730
  if (branchingStrategy === 'phase') {
632
- // Determine which phase we're committing for from the file paths
633
- const phaseMatch = (files || []).join(' ').match(/(\d+(?:\.\d+)*)-/);
634
- if (phaseMatch) {
635
- const phaseNum = phaseMatch[1];
731
+ // Determine which phase we're committing for from the file paths.
732
+ // #2539: the extraction is anchored to the directory SEGMENT immediately
733
+ // under `.planning/phases/` (or `.planning/milestones/<v>-phases/`) and
734
+ // runs through the project-code-aware extractPhaseToken helper, NOT a
735
+ // free unanchored regex. The prior `match(/(\d+(?:\.\d+)*)-/)` returned
736
+ // the leftmost digit-run-then-hyphen anywhere in the joined path, so a
737
+ // project_code ending in a digit (PROJECT_V2) made `.../PROJECT_V2-07-…`
738
+ // match the `2-` inside `V2-` before the real `07-` phase token —
739
+ // resolving phase "2" instead of phase "7" and silently checking out the
740
+ // wrong branch. extractPhaseToken already owns project-code-aware phase-
741
+ // token parsing (it is the single owner shared by the other 6 call sites
742
+ // — see #2528 for the parallel drift problem in phase-locator/phase),
743
+ // so this is the canonical path-segment-bound read, not a fourth copy.
744
+ const phaseNum = detectPhaseNumberFromFiles(files);
745
+ if (phaseNum) {
636
746
  const phaseInfo = findPhaseInternal(cwd, phaseNum);
637
747
  if (phaseInfo) {
638
748
  branchName = config['phase_branch_template']
@@ -652,10 +762,23 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
652
762
  if (branchName) {
653
763
  const currentBranch = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
654
764
  if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
655
- // Create branch if it doesn't exist, or switch to it if it does
765
+ // #2539: the #1278 intent is to CREATE the phase/milestone branch
766
+ // before the FIRST commit on it — not to force-switch an already-
767
+ // checked-out working branch onto a DIFFERENT existing branch. The
768
+ // prior fallback to a bare `git checkout <branch>` silently switched
769
+ // the whole working tree onto an existing unrelated branch in the same
770
+ // call that then committed (the only trace was a reflog entry). So:
771
+ // create-if-absent only. If the resolved branch already exists and the
772
+ // tree is on some other branch, do NOT switch — but never silently: log
773
+ // the resolution so the operator sees that the phase branch was
774
+ // resolved and deliberately not switched to (#2539 AC2: an auto-
775
+ // checkout mid-commit must never happen silently).
656
776
  const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
657
777
  if (create.exitCode !== 0) {
658
- (0, shell_command_projection_cjs_1.execGit)(['checkout', branchName], { cwd });
778
+ // `git checkout -b` fails (non-zero) when the branch already exists.
779
+ // The operator is on the branch they intend to be on; commit there.
780
+ process.stderr.write(`Warning: resolved ${branchingStrategy} branch "${branchName}" already exists; ` +
781
+ `committing on the current branch "${currentBranch.stdout.trim()}" instead of switching.\n`);
659
782
  }
660
783
  }
661
784
  }
@@ -664,8 +787,21 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
664
787
  const explicitFiles = files && files.length > 0;
665
788
  const filesToStage = explicitFiles ? files : ['.planning/'];
666
789
  const stagedPaths = [];
790
+ // #2608: a `git add` that fails must abort the commit, not be skipped.
791
+ // #2523 stopped a failed path entering the commit pathspec, but skipping it
792
+ // silently left two bad outcomes: a PARTIAL commit when only some requested
793
+ // paths failed, and a misleading `nothing_to_commit` when all of them did —
794
+ // in both cases the original staging error (permissions, unwritable index in
795
+ // a linked worktree, timeout) was discarded and the operator saw a downstream
796
+ // pathspec error pointing at an innocent file.
797
+ const stagingFailures = [];
798
+ // Paths already in the index BEFORE this call. On a staging failure the
799
+ // rollback below unstages only what THIS call added — unstaging a path the
800
+ // caller had staged themselves would destroy their work.
801
+ const preStaged = new Set((0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only'], { cwd })
802
+ .stdout.split('\n').map(s => s.trim()).filter(Boolean));
667
803
  for (const file of filesToStage) {
668
- const fullPath = node_path_1.default.join(cwd, file);
804
+ const fullPath = node_path_1.default.resolve(cwd, file);
669
805
  if (!node_fs_1.default.existsSync(fullPath)) {
670
806
  if (explicitFiles) {
671
807
  // Caller passed an explicit --files list: missing files are skipped.
@@ -675,13 +811,74 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
675
811
  }
676
812
  // Default mode (staging all of .planning/): stage the deletion so
677
813
  // removed planning files are not left dangling in the index.
678
- (0, shell_command_projection_cjs_1.execGit)(['rm', '--cached', '--ignore-unmatch', file], { cwd });
814
+ // This mutates the index exactly like `git add` does, so it fails closed
815
+ // the same way — an unwritable index must not be swallowed here either.
816
+ // `--ignore-unmatch` already makes "no such path" a success, so a non-zero
817
+ // exit is a real I/O failure, not a missing file.
818
+ const rmResult = (0, shell_command_projection_cjs_1.execGit)(['rm', '--cached', '--ignore-unmatch', file], { cwd });
819
+ if (rmResult.exitCode !== 0) {
820
+ const rmErr = rmResult.error;
821
+ stagingFailures.push({
822
+ file,
823
+ error: rmResult.stderr || rmResult.stdout,
824
+ timed_out: rmResult.signal === 'SIGTERM' && rmErr?.code === 'ETIMEDOUT',
825
+ });
826
+ }
679
827
  }
680
828
  else {
681
- (0, shell_command_projection_cjs_1.execGit)(['add', file], { cwd });
682
- stagedPaths.push(file);
829
+ const addResult = (0, shell_command_projection_cjs_1.execGit)(['add', file], { cwd });
830
+ // Only record paths that actually staged — a failed `git add` (permissions,
831
+ // out-of-repo edge) must not enter the commit pathspec (#2523). Mirrors
832
+ // cmdCommitToSubrepo's exitCode-gated push.
833
+ if (addResult.exitCode === 0) {
834
+ stagedPaths.push(file);
835
+ }
836
+ else {
837
+ // `SpawnResultOutput.error` is typed `Error | null`; widen to the errno
838
+ // shape by ANNOTATION rather than assertion — `Error` is assignable to
839
+ // `NodeJS.ErrnoException` (its extra fields are optional), so an `as`
840
+ // cast here trips no-unnecessary-type-assertion.
841
+ const addErr = addResult.error;
842
+ stagingFailures.push({
843
+ file,
844
+ error: addResult.stderr || addResult.stdout,
845
+ // The projection exposes a timeout distinctly (#2608 AC5); this is the
846
+ // same SIGTERM+ETIMEDOUT idiom worktree-safety.cts uses.
847
+ timed_out: addResult.signal === 'SIGTERM' && addErr?.code === 'ETIMEDOUT',
848
+ });
849
+ }
683
850
  }
684
851
  }
852
+ // #2608: fail closed before `git commit` runs. Checked ahead of the
853
+ // nothing_to_commit branch below so a run where EVERY path failed to stage
854
+ // reports the staging cause rather than "nothing to commit", and ahead of the
855
+ // commit itself so a multi-file scope never partially commits the subset that
856
+ // happened to stage.
857
+ if (stagingFailures.length > 0) {
858
+ // Fail closed AND clean. Without this the paths that DID stage stay in the
859
+ // index with no commit made, so the next bare `git commit` sweeps them up —
860
+ // the same silent partial commit this fix exists to prevent, deferred one
861
+ // step. Mirrors cmdPrSubrepo's rollback-then-error convention. Only paths
862
+ // this call staged are unstaged (preStaged is excluded), and the reset is
863
+ // best-effort: if the index is unwritable — the very failure being reported
864
+ // — the reset cannot succeed either, and the staging error is still what
865
+ // gets returned.
866
+ const toUnstage = stagedPaths.filter(p => !preStaged.has(p));
867
+ if (toUnstage.length > 0) {
868
+ (0, shell_command_projection_cjs_1.execGit)(['reset', '-q', '--', ...toUnstage], { cwd });
869
+ }
870
+ const first = stagingFailures[0];
871
+ const result = {
872
+ committed: false,
873
+ hash: null,
874
+ reason: first.timed_out ? 'staging_timeout' : 'staging_failed',
875
+ file: first.file,
876
+ error: first.error,
877
+ failures: stagingFailures,
878
+ };
879
+ output(result, raw, 'failed');
880
+ return;
881
+ }
685
882
  // Commit — when the caller declared a scope (--files), append a pathspec so
686
883
  // only the declared files land in the commit, not the entire index (#2112).
687
884
  // The pathspec uses stagedPaths (not filesToStage) so skipped missing files
@@ -802,13 +999,44 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
802
999
  for (const [repo, repoFiles] of Object.entries(grouped)) {
803
1000
  const repoCwd = node_path_1.default.join(cwd, repo);
804
1001
  // Stage files (strip sub-repo prefix for paths relative to that repo)
1002
+ // #2608: this is the sub-repo twin of cmdCommit's staging loop and carried
1003
+ // the identical defect — a failed `git add` was dropped silently and the
1004
+ // function went straight on to commit the subset that happened to stage,
1005
+ // discarding git's stderr. Fails closed per-repo, with the same rollback of
1006
+ // only what this call staged.
1007
+ const preStagedSub = new Set((0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only'], { cwd: repoCwd })
1008
+ .stdout.split('\n').map(s => s.trim()).filter(Boolean));
805
1009
  const stagedRelPaths = [];
1010
+ const subStagingFailures = [];
806
1011
  for (const file of repoFiles) {
807
1012
  const relativePath = file.slice(repo.length + 1);
808
1013
  const addResult = (0, shell_command_projection_cjs_1.execGit)(['add', relativePath], { cwd: repoCwd });
809
1014
  if (addResult.exitCode === 0) {
810
1015
  stagedRelPaths.push(relativePath);
811
1016
  }
1017
+ else {
1018
+ const addErr = addResult.error;
1019
+ subStagingFailures.push({
1020
+ file,
1021
+ error: addResult.stderr || addResult.stdout,
1022
+ timed_out: addResult.signal === 'SIGTERM' && addErr?.code === 'ETIMEDOUT',
1023
+ });
1024
+ }
1025
+ }
1026
+ if (subStagingFailures.length > 0) {
1027
+ const toUnstageSub = stagedRelPaths.filter(p => !preStagedSub.has(p));
1028
+ if (toUnstageSub.length > 0) {
1029
+ (0, shell_command_projection_cjs_1.execGit)(['reset', '-q', '--', ...toUnstageSub], { cwd: repoCwd });
1030
+ }
1031
+ const firstSub = subStagingFailures[0];
1032
+ repos[repo] = {
1033
+ committed: false,
1034
+ hash: null,
1035
+ files: repoFiles,
1036
+ reason: firstSub.timed_out ? 'staging_timeout' : 'staging_failed',
1037
+ error: firstSub.error,
1038
+ };
1039
+ continue;
812
1040
  }
813
1041
  // Commit — pathspec limits the commit to the staged files only (#2112)
814
1042
  const isMergeInProgressSub = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd: repoCwd }).exitCode === 0;
@@ -994,7 +1222,7 @@ function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
994
1222
  return;
995
1223
  }
996
1224
  const content = node_fs_1.default.readFileSync(fullPath, 'utf-8');
997
- const fm = extractFrontmatter(content);
1225
+ const fm = extractFrontmatter(content, fullPath);
998
1226
  // Parse key-decisions into structured format
999
1227
  const parseDecisions = (decisionsList) => {
1000
1228
  if (!decisionsList || !Array.isArray(decisionsList))