@opengsd/gsd-core 1.13.0 → 1.14.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 (257) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-advisor-researcher.compact.md +85 -0
  4. package/agents/gsd-ai-researcher.compact.md +96 -0
  5. package/agents/gsd-assumptions-analyzer.compact.md +81 -0
  6. package/agents/gsd-code-fixer.compact.md +458 -0
  7. package/agents/gsd-code-fixer.md +5 -5
  8. package/agents/gsd-code-reviewer.compact.md +269 -0
  9. package/agents/gsd-code-reviewer.md +15 -3
  10. package/agents/gsd-codebase-mapper.compact.md +760 -0
  11. package/agents/gsd-debug-session-manager.compact.md +345 -0
  12. package/agents/gsd-doc-classifier.compact.md +192 -0
  13. package/agents/gsd-doc-synthesizer.compact.md +200 -0
  14. package/agents/gsd-doc-verifier.compact.md +143 -0
  15. package/agents/gsd-doc-writer.compact.md +440 -0
  16. package/agents/gsd-dom-verifier.compact.md +138 -0
  17. package/agents/gsd-domain-researcher.compact.md +141 -0
  18. package/agents/gsd-eval-auditor.compact.md +160 -0
  19. package/agents/gsd-eval-planner.compact.md +137 -0
  20. package/agents/gsd-framework-selector.compact.md +82 -0
  21. package/agents/gsd-integration-checker.compact.md +245 -0
  22. package/agents/gsd-intel-updater.compact.md +226 -0
  23. package/agents/gsd-mempalace-curator.compact.md +45 -0
  24. package/agents/gsd-nyquist-auditor.compact.md +179 -0
  25. package/agents/gsd-pattern-mapper.compact.md +275 -0
  26. package/agents/gsd-project-researcher.compact.md +587 -0
  27. package/agents/gsd-research-synthesizer.compact.md +212 -0
  28. package/agents/gsd-roadmapper.compact.md +454 -0
  29. package/agents/gsd-roadmapper.md +13 -0
  30. package/agents/gsd-security-auditor.compact.md +162 -0
  31. package/agents/gsd-ui-auditor.compact.md +404 -0
  32. package/agents/gsd-ui-checker.compact.md +277 -0
  33. package/agents/gsd-ui-researcher.compact.md +282 -0
  34. package/agents/gsd-user-profiler.compact.md +108 -0
  35. package/bin/install.js +206 -68
  36. package/commands/gsd/cleanup.md +1 -0
  37. package/commands/gsd/code-review.md +2 -1
  38. package/commands/gsd/complete-milestone.md +1 -0
  39. package/commands/gsd/config.md +1 -0
  40. package/commands/gsd/debug.md +1 -0
  41. package/commands/gsd/graphify.md +1 -0
  42. package/commands/gsd/health.md +1 -0
  43. package/commands/gsd/mempalace-capture.md +1 -0
  44. package/commands/gsd/mempalace-recall.md +1 -0
  45. package/commands/gsd/new-milestone.md +1 -0
  46. package/commands/gsd/new-project.md +1 -0
  47. package/commands/gsd/next.md +1 -0
  48. package/commands/gsd/pause-work.md +1 -0
  49. package/commands/gsd/phase.md +1 -0
  50. package/commands/gsd/pr-branch.md +1 -0
  51. package/commands/gsd/resume-work.md +1 -0
  52. package/commands/gsd/review-backlog.md +1 -0
  53. package/commands/gsd/settings.md +2 -1
  54. package/commands/gsd/stats.md +1 -0
  55. package/commands/gsd/thread.md +1 -0
  56. package/commands/gsd/workspace.md +1 -0
  57. package/commands/gsd/workstreams.md +1 -0
  58. package/gsd-core/bin/check-latest-version.cjs +8 -3
  59. package/gsd-core/bin/gsd-tools.cjs +338 -125
  60. package/gsd-core/bin/lib/adr-parser.cjs +1 -1
  61. package/gsd-core/bin/lib/artifacts.cjs +2 -1
  62. package/gsd-core/bin/lib/audit.cjs +39 -22
  63. package/gsd-core/bin/lib/broken-windows.cjs +168 -49
  64. package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
  65. package/gsd-core/bin/lib/capability-loader.cjs +135 -1
  66. package/gsd-core/bin/lib/capability-registry.cjs +79 -67
  67. package/gsd-core/bin/lib/capability-source.cjs +19 -2
  68. package/gsd-core/bin/lib/capability-validator.cjs +14 -1
  69. package/gsd-core/bin/lib/check-command-router.cjs +113 -36
  70. package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
  71. package/gsd-core/bin/lib/commands.cjs +650 -72
  72. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  73. package/gsd-core/bin/lib/config.cjs +153 -38
  74. package/gsd-core/bin/lib/coverage.cjs +1 -1
  75. package/gsd-core/bin/lib/decisions.cjs +137 -34
  76. package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
  77. package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
  78. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
  79. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
  80. package/gsd-core/bin/lib/init.cjs +409 -47
  81. package/gsd-core/bin/lib/install-engine.cjs +16 -3
  82. package/gsd-core/bin/lib/install-profiles.cjs +14 -0
  83. package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
  84. package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
  85. package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
  86. package/gsd-core/bin/lib/milestone.cjs +19 -8
  87. package/gsd-core/bin/lib/model-resolver.cjs +101 -10
  88. package/gsd-core/bin/lib/phase-command-router.cjs +7 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +161 -22
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
  91. package/gsd-core/bin/lib/phase.cjs +167 -63
  92. package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
  93. package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
  94. package/gsd-core/bin/lib/planning-workspace.cjs +50 -1
  95. package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
  96. package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
  97. package/gsd-core/bin/lib/quick-batch.cjs +1 -1
  98. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
  99. package/gsd-core/bin/lib/research-store.cjs +11 -12
  100. package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
  101. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
  102. package/gsd-core/bin/lib/roadmap-parser.cjs +56 -15
  103. package/gsd-core/bin/lib/roadmap.cjs +108 -14
  104. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +27 -10
  105. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +12 -3
  106. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
  107. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +193 -4
  108. package/gsd-core/bin/lib/security.cjs +126 -7
  109. package/gsd-core/bin/lib/state-document.cjs +130 -28
  110. package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
  111. package/gsd-core/bin/lib/state-transition.cjs +142 -28
  112. package/gsd-core/bin/lib/state.cjs +223 -27
  113. package/gsd-core/bin/lib/surface.cjs +60 -2
  114. package/gsd-core/bin/lib/task-command-router.cjs +12 -6
  115. package/gsd-core/bin/lib/uat.cjs +1 -1
  116. package/gsd-core/bin/lib/update-context.cjs +30 -24
  117. package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
  118. package/gsd-core/bin/lib/verification.cjs +47 -15
  119. package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
  120. package/gsd-core/bin/lib/verify.cjs +188 -23
  121. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
  122. package/gsd-core/bin/lib/worktree-safety.cjs +13 -7
  123. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  124. package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
  125. package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
  126. package/gsd-core/references/compact-content-gate.md +66 -0
  127. package/gsd-core/references/loop-hook-dispatch.md +18 -0
  128. package/gsd-core/references/model-profiles.md +12 -3
  129. package/gsd-core/references/planning-config.md +3 -0
  130. package/gsd-core/references/tdd.md +5 -2
  131. package/gsd-core/references/thinking-models-planning.md +18 -2
  132. package/gsd-core/references/verification-patterns.md +17 -4
  133. package/gsd-core/references/worktree-path-safety.md +112 -2
  134. package/gsd-core/templates/README.md +7 -1
  135. package/gsd-core/templates/state.md +6 -3
  136. package/gsd-core/templates/summary.compact.md +212 -0
  137. package/gsd-core/templates/user-setup.compact.md +199 -0
  138. package/gsd-core/templates/user-setup.md +0 -9
  139. package/gsd-core/workflows/add-todo.md +3 -2
  140. package/gsd-core/workflows/autonomous.md +13 -10
  141. package/gsd-core/workflows/check-todos.md +4 -2
  142. package/gsd-core/workflows/cleanup.md +3 -1
  143. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +7 -0
  144. package/gsd-core/workflows/code-review-fix.md +3 -3
  145. package/gsd-core/workflows/code-review.md +156 -30
  146. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
  147. package/gsd-core/workflows/complete-milestone.md +39 -262
  148. package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
  149. package/gsd-core/workflows/docs-update.md +14 -155
  150. package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
  151. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +18 -3
  152. package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
  153. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +7 -2
  154. package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
  155. package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
  156. package/gsd-core/workflows/execute-phase.md +53 -152
  157. package/gsd-core/workflows/execute-plan.md +20 -7
  158. package/gsd-core/workflows/help/modes/full.compact.md +398 -0
  159. package/gsd-core/workflows/help.md +1 -1
  160. package/gsd-core/workflows/map-codebase.md +50 -3
  161. package/gsd-core/workflows/new-milestone.md +54 -12
  162. package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
  163. package/gsd-core/workflows/new-project.md +32 -202
  164. package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
  165. package/gsd-core/workflows/plan-phase.md +22 -181
  166. package/gsd-core/workflows/pr-branch.md +19 -7
  167. package/gsd-core/workflows/quick.md +8 -1
  168. package/gsd-core/workflows/reapply-patches.md +77 -3
  169. package/gsd-core/workflows/settings.md +18 -5
  170. package/gsd-core/workflows/update.md +7 -5
  171. package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
  172. package/gsd-core/workflows/verify-work.md +20 -180
  173. package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
  174. package/hooks/dist/gsd-context-monitor.js +88 -15
  175. package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
  176. package/hooks/dist/gsd-secret-read-guard.js +44 -18
  177. package/hooks/dist/gsd-statusline.js +11 -7
  178. package/hooks/dist/gsd-validate-commit.sh +34 -4
  179. package/hooks/dist/gsd-worktree-path-guard.js +25 -14
  180. package/hooks/dist/gsd-write-guard.js +46 -1
  181. package/hooks/dist/lib/dispatch-identity.js +187 -0
  182. package/hooks/dist/lib/filename-classification.js +64 -0
  183. package/hooks/dist/lib/isolation-deny-reason.js +53 -1
  184. package/hooks/dist/lib/isolation-sentinel.js +58 -19
  185. package/hooks/gsd-agent-isolation-guard.js +42 -16
  186. package/hooks/gsd-context-monitor.js +88 -15
  187. package/hooks/gsd-cursor-subagent-start.js +34 -14
  188. package/hooks/gsd-secret-read-guard.js +44 -18
  189. package/hooks/gsd-statusline.js +11 -7
  190. package/hooks/gsd-validate-commit.sh +34 -4
  191. package/hooks/gsd-worktree-path-guard.js +25 -14
  192. package/hooks/gsd-write-guard.js +46 -1
  193. package/hooks/lib/dispatch-identity.js +187 -0
  194. package/hooks/lib/filename-classification.js +64 -0
  195. package/hooks/lib/isolation-deny-reason.js +53 -1
  196. package/hooks/lib/isolation-sentinel.js +58 -19
  197. package/package.json +10 -6
  198. package/scripts/benchmark-compact-content-variants.cjs +298 -0
  199. package/scripts/benchmark-compact-content.cjs +368 -0
  200. package/scripts/check-contract-drift.cjs +4 -1
  201. package/scripts/check-env.cjs +36 -8
  202. package/scripts/check-glossary-refs.cjs +25 -21
  203. package/scripts/ci-next-health.cjs +271 -0
  204. package/scripts/ci-prepare-test-scope.cjs +7 -7
  205. package/scripts/ci-test-scope.cjs +126 -20
  206. package/scripts/ci-timeout-report.cjs +1 -1
  207. package/scripts/diff-touches-shipped-paths.cjs +1 -1
  208. package/scripts/docs-guard-registry.cjs +7 -2
  209. package/scripts/gen-adr-index.cjs +8 -2
  210. package/scripts/gen-inventory-manifest.cjs +12 -0
  211. package/scripts/gen-platform-conformance-tier.cjs +557 -0
  212. package/scripts/lib/drift-scan.cjs +1 -1
  213. package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
  214. package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
  215. package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
  216. package/scripts/lib/suite-detection.cjs +32 -0
  217. package/scripts/lint-allowed-tools-parity.cjs +221 -0
  218. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +19 -2
  219. package/scripts/lint-phase-id-drift.cjs +338 -13
  220. package/scripts/lint-response-language-coverage.cjs +9 -3
  221. package/scripts/lint-source-test-name-collision.cjs +1 -1
  222. package/scripts/lint-test-file-count.allowlist.json +1 -0
  223. package/scripts/lint-vendored-deps.cjs +128 -17
  224. package/scripts/lint-workflow-shellcheck-baseline.json +85 -0
  225. package/scripts/prompt-injection-scan.sh +14 -0
  226. package/scripts/workflow-size.cjs +139 -0
  227. package/skills/gsd-cleanup/SKILL.md +1 -0
  228. package/skills/gsd-code-review/SKILL.md +2 -1
  229. package/skills/gsd-complete-milestone/SKILL.md +1 -0
  230. package/skills/gsd-config/SKILL.md +1 -0
  231. package/skills/gsd-debug/SKILL.md +1 -0
  232. package/skills/gsd-graphify/SKILL.md +1 -0
  233. package/skills/gsd-health/SKILL.md +1 -0
  234. package/skills/gsd-mempalace-capture/SKILL.md +1 -0
  235. package/skills/gsd-mempalace-recall/SKILL.md +1 -0
  236. package/skills/gsd-new-milestone/SKILL.md +1 -0
  237. package/skills/gsd-new-project/SKILL.md +1 -0
  238. package/skills/gsd-next/SKILL.md +1 -0
  239. package/skills/gsd-pause-work/SKILL.md +1 -0
  240. package/skills/gsd-phase/SKILL.md +1 -0
  241. package/skills/gsd-pr-branch/SKILL.md +1 -0
  242. package/skills/gsd-resume-work/SKILL.md +1 -0
  243. package/skills/gsd-review-backlog/SKILL.md +1 -0
  244. package/skills/gsd-settings/SKILL.md +2 -1
  245. package/skills/gsd-stats/SKILL.md +1 -0
  246. package/skills/gsd-thread/SKILL.md +1 -0
  247. package/skills/gsd-workspace/SKILL.md +1 -0
  248. package/skills/gsd-workstreams/SKILL.md +1 -0
  249. package/vscode/package.json +1 -1
  250. package/gsd-core/templates/claude-md.md +0 -145
  251. package/gsd-core/templates/codebase/concerns.md +0 -310
  252. package/gsd-core/templates/codebase/conventions.md +0 -307
  253. package/gsd-core/templates/codebase/integrations.md +0 -280
  254. package/gsd-core/templates/codebase/structure.md +0 -285
  255. package/gsd-core/templates/codebase/testing.md +0 -480
  256. package/gsd-core/templates/debug-subagent-prompt.md +0 -91
  257. package/gsd-core/templates/discovery.md +0 -146
@@ -17,7 +17,11 @@ const pattern_cjs_1 = require("./pattern.cjs");
17
17
  const security_cjs_1 = require("./security.cjs");
18
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
19
  const ioMod = require("./io.cjs");
20
- const { output, error, ERROR_REASON } = ioMod;
20
+ const { output, ERROR_REASON } = ioMod;
21
+ // Explicitly annotated so TypeScript applies never-return control-flow narrowing.
22
+ // See the identical note in check-command-router.cts: a destructured const carries no
23
+ // type annotation, so TS will not narrow after `error(...)` without this.
24
+ const error = ioMod.error;
21
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
26
  const configLoaderMod = require("./config-loader.cjs");
23
27
  const { loadConfig, isGitIgnored } = configLoaderMod;
@@ -26,7 +30,7 @@ const coreUtilsMod = require("./core-utils.cjs");
26
30
  const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
27
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
28
32
  const phaseIdMod = require("./phase-id.cjs");
29
- const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
33
+ const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName } = phaseIdMod;
30
34
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
35
  const phaseLocatorMod = require("./phase-locator.cjs");
32
36
  const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
@@ -52,7 +56,7 @@ const codex_agent_toml_cjs_1 = require("./codex-agent-toml.cjs");
52
56
  const hostIntegrationMod = require("./host-integration.cjs");
53
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports
54
58
  const planningWorkspace = require("./planning-workspace.cjs");
55
- const { planningDir, planningPaths } = planningWorkspace;
59
+ const { planningDir, planningPaths, todosDir } = planningWorkspace;
56
60
  // eslint-disable-next-line @typescript-eslint/no-require-imports
57
61
  const frontmatter = require("./frontmatter.cjs");
58
62
  const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter;
@@ -186,7 +190,10 @@ function cmdCurrentTimestamp(format, raw) {
186
190
  output({ timestamp: result }, raw, result);
187
191
  }
188
192
  function cmdListTodos(cwd, area, raw) {
189
- const pendingDir = node_path_1.default.join(planningDir(cwd), 'todos', 'pending');
193
+ // #4256: todos are root-scoped shared state — resolve via todosDir(cwd),
194
+ // never planningDir(cwd) (workstream-scoped), or the listing goes empty
195
+ // under a workstream.
196
+ const pendingDir = node_path_1.default.join(todosDir(cwd), 'pending');
190
197
  let count = 0;
191
198
  const todos = [];
192
199
  try {
@@ -282,7 +289,7 @@ function cmdListSeeds(cwd, statusFilter, raw) {
282
289
  continue;
283
290
  let safeFilePath;
284
291
  try {
285
- safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(seedsDir, entry.name), planDir, 'seed file', { allowAbsolute: true });
292
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(seedsDir, entry.name), planDir, 'seed file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
286
293
  }
287
294
  catch {
288
295
  continue;
@@ -553,13 +560,11 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
553
560
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
554
561
  const { getGlobalConfigDir } = require('./runtime-homes.cjs');
555
562
  const agentsDirEff = node_path_1.default.join(getGlobalConfigDir(runtime), 'agents');
556
- const agentPath = node_path_1.default.join(agentsDirEff, `${agentType}.md`);
557
563
  // agentType is an unvalidated CLI positional: keep the read inside the
558
564
  // agents dir so `../../x` cannot point it elsewhere (defense in depth —
559
- // the reflected surface is only a frontmatter effort line).
560
- if (!node_path_1.default.resolve(agentPath).startsWith(node_path_1.default.resolve(agentsDirEff) + node_path_1.default.sep)) {
561
- throw new Error('agent path escapes the agents directory');
562
- }
565
+ // the reflected surface is only a frontmatter effort line). Untrusted
566
+ // input feeding a real read → realpath family (ADR-4650 decision 6).
567
+ const agentPath = (0, security_cjs_1.assertWithinRoot)(`${agentType}.md`, agentsDirEff, 'agent file');
563
568
  const agentContent = node_fs_1.default.readFileSync(agentPath, 'utf8');
564
569
  // eslint-disable-next-line local/no-unbounded-quantifier -- same lazy `*?` bounded by the `^---$/m` closing anchor as the sibling frontmatter regexes in this file
565
570
  const fmMatchEff = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(agentContent);
@@ -1357,19 +1362,33 @@ function detectPhaseNumberFromFiles(files) {
1357
1362
  if (!phaseDir)
1358
1363
  continue;
1359
1364
  const token = extractPhaseToken(phaseDir);
1360
- // extractPhaseToken falls back to returning dirName unchanged when no
1361
- // numeric token is found. normalizePhaseName is the canonical arbiter
1362
- // of "is this a real phase token": it strips the project-code prefix
1363
- // and returns a zero-padded numeric form for a genuine phase token, or
1364
- // the input unchanged otherwise. Accept the token only when it
1365
- // normalizes to a numeric phase form (the single-owner rule shared by
1366
- // every other phase-token reader — see #2528).
1367
- const normalized = normalizePhaseName(token);
1365
+ // normalizePhaseName is the canonical arbiter of "is this a real phase
1366
+ // token": it strips the project-code prefix and returns a zero-padded
1367
+ // numeric form for a genuine phase token, or the input unchanged
1368
+ // otherwise. Accept the token whenever it normalizes to a numeric
1369
+ // phase form (the single-owner rule shared by every other phase-token
1370
+ // reader — see #2528).
1371
+ //
1372
+ // #4126 fix: this used to also require `token !== phaseDir`, on the
1373
+ // assumption that extractPhaseToken returning its input unchanged
1374
+ // always means "no numeric token found" (its no-match fallback).
1375
+ // That assumption is false for a BARE phase directory with no slug
1376
+ // remainder (e.g. `.planning/phases/01/`): extractPhaseToken correctly
1377
+ // reads "01" as the token, which is simply identical to the directory
1378
+ // name in that case — not a fallback. The stale equality check
1379
+ // rejected every such directory, leaving `phaseNum` null and silently
1380
+ // skipping the whole phase-branch block below (undetected because
1381
+ // `phaseTokenShape.test(normalized)` already excludes genuine
1382
+ // non-phase fallbacks — e.g. `docs`, `CK-docs` — on its own, since
1383
+ // extractPhaseToken's real no-match fallback only fires for dirNames
1384
+ // that do not start with a digit or short letter+digit prefix, which
1385
+ // normalizePhaseName's leading-`\d+` requirement rejects regardless).
1368
1386
  // Built from the single-owner PHASE_NUMBER_TOKEN_SOURCE (the canonical
1369
1387
  // phase-number grammar — #2128 anti-divergence guard) so this read-side
1370
1388
  // acceptance check cannot drift from every other phase-token reader.
1389
+ const normalized = normalizePhaseName(token);
1371
1390
  const phaseTokenShape = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i');
1372
- if (token !== phaseDir && phaseTokenShape.test(normalized)) {
1391
+ if (phaseTokenShape.test(normalized)) {
1373
1392
  return token;
1374
1393
  }
1375
1394
  }
@@ -1442,7 +1461,296 @@ const COMMIT_DOCS_SKIP_REASON = {
1442
1461
  config: 'skipped_commit_docs_false',
1443
1462
  gitignore: 'skipped_gitignored',
1444
1463
  };
1445
- function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1464
+ // #4208 review: the declared-removal staging lifted out of cmdCommit, which was
1465
+ // already a critical-risk hotspot before this flag existed. Pure motion -- the
1466
+ // classification, canonicalisation and entry recording below are unchanged; only
1467
+ // the two accumulators are local names that the caller merges. `removedPathspec`
1468
+ // is what joins the commit's pathspec; `removedEntries` is what the caller's
1469
+ // rollback and its no-change exits restore from.
1470
+ function stageDeclaredRemovals(cwd, removedDeclared) {
1471
+ const failures = [];
1472
+ const removedPathspec = [];
1473
+ // The empty blob under SHA-1 and SHA-256 object formats — intent-to-add's tell.
1474
+ const EMPTY_BLOBS = new Set(['e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', '473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813']);
1475
+ // A PATH FROM THE INDEX IS NOT A PATHSPEC. `git rm`, `ls-files` and friends
1476
+ // parse their operands as pathspecs, so a tracked file literally named
1477
+ // `.planning/*.md` GLOBS when handed back to git: driven, `rm --cached` on it
1478
+ // also removed `peer.md` and `stays.md`, and only the declared entry was
1479
+ // recorded — so the rollback restored one of three and the other two rode out
1480
+ // as undisclosed staged deletions. The magic-prefix twin is quieter still: a
1481
+ // file named `:(literal)mine` has its prefix PARSED, so the rm matches nothing,
1482
+ // exits 0, and the entry silently survives a removal this call then claims.
1483
+ // `:(literal)` disables every other magic, including globbing, so the operand
1484
+ // means the file it names.
1485
+ const lit = (p) => `:(literal)${p}`;
1486
+ const notARemoval = (e) => {
1487
+ if (e.mode === '160000')
1488
+ return 'a submodule gitlink, not a file';
1489
+ if (e.tag === 'S')
1490
+ return 'skip-worktree (sparse-checkout): absent by checkout, not removed';
1491
+ if (e.tag === 'h')
1492
+ return 'assume-unchanged: git does not consult its worktree state';
1493
+ if (e.stage !== '0')
1494
+ return 'an unmerged index entry';
1495
+ if (e.tag !== 'H')
1496
+ return `index state '${e.tag}'`;
1497
+ return null;
1498
+ };
1499
+ const lstatState = (p) => {
1500
+ try {
1501
+ node_fs_1.default.lstatSync(p);
1502
+ return 'present';
1503
+ }
1504
+ catch (e) {
1505
+ const err = e;
1506
+ return err.code === 'ENOENT' || err.code === 'ENOTDIR' ? 'absent' : err;
1507
+ }
1508
+ };
1509
+ // `rev-parse -q --verify HEAD` exits 1 both for an unborn HEAD and for a
1510
+ // spawn timeout (`execGit` collapses one to `exitCode: 1`). Only a probe that
1511
+ // actually answered may downgrade the union to index-only; an unanswered one
1512
+ // fails closed, because silently dropping the HEAD half re-opens the
1513
+ // pre-staged-deletion omission this union exists to close.
1514
+ let headExists = false;
1515
+ let headProbeFailure = null;
1516
+ if (removedDeclared.length > 0) {
1517
+ const headProbe = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'HEAD'], { cwd });
1518
+ if (headProbe.exitCode === 0) {
1519
+ headExists = true;
1520
+ }
1521
+ else if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(headProbe) || headProbe.error !== null) {
1522
+ headProbeFailure = { error: headProbe.stderr || headProbe.stdout || 'HEAD probe failed', timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(headProbe) };
1523
+ }
1524
+ }
1525
+ // Every index entry this call removes, recorded BEFORE the `rm --cached`
1526
+ // so the rollback below can put it back exactly — mode and blob — with
1527
+ // `update-index --cacheinfo`. `git reset -- <path>` cannot do that: it
1528
+ // restores from HEAD, which does not exist on an unborn branch (so a root
1529
+ // commit's failed call used to leave every earlier removal unstaged, in
1530
+ // violation of the only-what-THIS-call-staged invariant above) and which
1531
+ // is not what the index held when the caller had pre-staged a modified
1532
+ // blob at that path. Recording the entry answers both without putting the
1533
+ // path on the commit pathspec, where an unborn HEAD makes `git commit`
1534
+ // refuse it (driven; see the union note above).
1535
+ const removedEntries = [];
1536
+ for (const entry of removedDeclared) {
1537
+ if (headProbeFailure !== null) {
1538
+ failures.push({ file: entry, ...headProbeFailure });
1539
+ continue;
1540
+ }
1541
+ // `-v -s`: tag, mode, blob, stage and path per record — see notARemoval.
1542
+ // `lit` here too: the caller's declared entry is a PATH, not a glob —
1543
+ // that is `--files-removed`'s whole contract — and :(literal) still
1544
+ // resolves a directory to its descendants (driven), so the directory form
1545
+ // is unaffected while a file literally named `*.md` or `:(literal)x` means
1546
+ // itself.
1547
+ const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '-s', '-z', '--', lit(entry)], { cwd });
1548
+ if (listed.exitCode !== 0) {
1549
+ failures.push({
1550
+ file: entry,
1551
+ error: listed.stderr || listed.stdout,
1552
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(listed),
1553
+ });
1554
+ continue;
1555
+ }
1556
+ const indexed = new Map();
1557
+ let unparseable = null;
1558
+ for (const rec of listed.stdout.split('\0').filter(Boolean)) {
1559
+ const m = /^(\S) (\d{6}) ([0-9a-f]+) ([0-3])\t([\s\S]+)$/.exec(rec);
1560
+ if (m === null) {
1561
+ unparseable = rec;
1562
+ break;
1563
+ }
1564
+ indexed.set(m[5], { tag: m[1], mode: m[2], sha: m[3], stage: m[4] });
1565
+ }
1566
+ if (unparseable !== null) {
1567
+ // A record this code cannot read is not a path it may remove.
1568
+ failures.push({ file: entry, error: `unparseable ls-files record: ${unparseable}`, timed_out: false });
1569
+ continue;
1570
+ }
1571
+ const tracked = new Set(indexed.keys());
1572
+ // Does the entry name THIS tracked path itself (the caller declared a
1573
+ // FILE removed) or a directory above it? Decided on RESOLVED paths, never
1574
+ // on the strings: `ls-files` prints cwd-relative paths, and a caller may
1575
+ // pass an absolute path, `./x`, a trailing slash, or run under `--cwd`,
1576
+ // any of which fails a string compare and would silently take the
1577
+ // directory polarity — a directly named gitlink then SKIPS instead of
1578
+ // refusing (found by the round's review, driven with an absolute path).
1579
+ const entryAbs = node_path_1.default.resolve(cwd, entry);
1580
+ const entryRel = node_path_1.default.relative(cwd, entryAbs).split(node_path_1.default.sep).join('/');
1581
+ // Canonical form: realpath of the longest EXISTING prefix, with the absent
1582
+ // tail re-appended. The declared path is usually absent (that is the
1583
+ // point), and `process.cwd()` returns the real path where the caller may
1584
+ // hold a symlinked spelling — macOS `/var` → `/private/var` is the live
1585
+ // instance (CI, this PR's own test) — so a resolve-only compare still
1586
+ // took the directory polarity there.
1587
+ const canon = (p) => {
1588
+ let cur = node_path_1.default.resolve(cwd, p);
1589
+ const tail = [];
1590
+ for (;;) {
1591
+ try {
1592
+ return node_path_1.default.join(node_fs_1.default.realpathSync.native(cur), ...tail);
1593
+ }
1594
+ catch { /* absent: climb */ }
1595
+ const parent = node_path_1.default.dirname(cur);
1596
+ if (parent === cur)
1597
+ return node_path_1.default.join(cur, ...tail);
1598
+ tail.unshift(node_path_1.default.basename(cur));
1599
+ cur = parent;
1600
+ }
1601
+ };
1602
+ const namesItself = (p) => p === entryRel || node_path_1.default.resolve(cwd, p) === entryAbs || canon(p) === canon(entry);
1603
+ const inHeadPaths = new Set();
1604
+ if (headExists) {
1605
+ const inHead = (0, shell_command_projection_cjs_1.execGit)(['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', lit(entry)], { cwd });
1606
+ if (inHead.exitCode !== 0) {
1607
+ failures.push({
1608
+ file: entry,
1609
+ error: inHead.stderr || inHead.stdout,
1610
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(inHead),
1611
+ });
1612
+ continue;
1613
+ }
1614
+ for (const p of inHead.stdout.split('\0').filter(Boolean)) {
1615
+ tracked.add(p);
1616
+ inHeadPaths.add(p);
1617
+ }
1618
+ }
1619
+ if (tracked.size === 0)
1620
+ continue;
1621
+ const entryState = lstatState(node_path_1.default.resolve(cwd, entry));
1622
+ if (entryState !== 'present' && entryState !== 'absent') {
1623
+ failures.push({ file: entry, error: `lstat ${entryState.code ?? ''}: ${entryState.message}`, timed_out: false });
1624
+ continue;
1625
+ }
1626
+ let entryIsDirectory = false;
1627
+ if (entryState === 'present') {
1628
+ try {
1629
+ entryIsDirectory = node_fs_1.default.lstatSync(node_path_1.default.resolve(cwd, entry)).isDirectory();
1630
+ }
1631
+ catch { /* raced away: treat as a present non-directory below */ }
1632
+ }
1633
+ if (entryState === 'present' && !entryIsDirectory) {
1634
+ // A present non-directory entry (a file, or ANY symlink — a link to a
1635
+ // directory is still one tracked path) contradicts the declaration.
1636
+ failures.push({
1637
+ file: entry,
1638
+ error: `declared in --files-removed but still present on disk: ${entry}`,
1639
+ timed_out: false,
1640
+ });
1641
+ continue;
1642
+ }
1643
+ for (const trackedPath of tracked) {
1644
+ const indexEntry = indexed.get(trackedPath);
1645
+ let reason = indexEntry === undefined ? null : notARemoval(indexEntry);
1646
+ // Intent-to-add (`git add -N`) renders as a plain `H 100644 <empty
1647
+ // blob> 0` — the flag is not in the listing — yet nothing tracked exists
1648
+ // to remove, and a rollback via `--cacheinfo` cannot restore the flag.
1649
+ // It is the one state whose blob is the empty blob, whose path is not in
1650
+ // HEAD, and which `diff --cached` treats as absent from the index; an
1651
+ // ordinary staged empty file shows there as added. Three probes, on the
1652
+ // rare empty-blob path only.
1653
+ if (reason === null && indexEntry !== undefined && EMPTY_BLOBS.has(indexEntry.sha) && !inHeadPaths.has(trackedPath)) {
1654
+ const cached = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z', '--', lit(trackedPath)], { cwd });
1655
+ if (cached.exitCode === 0 && cached.stdout.split('\0').filter(Boolean).length === 0)
1656
+ reason = 'an intent-to-add entry (git add -N), not tracked content';
1657
+ }
1658
+ if (reason !== null) {
1659
+ if (namesItself(trackedPath)) {
1660
+ failures.push({
1661
+ file: entry,
1662
+ error: `declared in --files-removed but is ${reason}: ${trackedPath}`,
1663
+ timed_out: false,
1664
+ });
1665
+ }
1666
+ continue;
1667
+ }
1668
+ const state = lstatState(node_path_1.default.resolve(cwd, trackedPath));
1669
+ if (state === 'present')
1670
+ continue;
1671
+ if (state !== 'absent') {
1672
+ failures.push({ file: trackedPath, error: `lstat ${state.code ?? ''}: ${state.message}`, timed_out: false });
1673
+ continue;
1674
+ }
1675
+ // A HEAD-only path (the caller already `git rm`'d it) has no index entry
1676
+ // to record or restore; the `rm` below is then a no-op.
1677
+ // READ the entry before the mutation, RECORD it only after the mutation
1678
+ // SUCCEEDS. The read must precede (the rm is what destroys the mode/blob
1679
+ // the restore needs); the record must not, because `removedEntries` is
1680
+ // the set this call claims to have staged. Recording ahead of the rm made
1681
+ // a FAILED rm — a stale `index.lock` is the driven case — contribute an
1682
+ // entry the rollback then reported as "still staged in the index" when
1683
+ // nothing had been staged at all: a false disclosure, the mirror of the
1684
+ // silent one the disclosure was added to fix.
1685
+ const recordable = indexEntry !== undefined
1686
+ ? { path: trackedPath, mode: indexEntry.mode, sha: indexEntry.sha }
1687
+ : null;
1688
+ // `--ignore-unmatch` makes "no such index entry" a success, so a non-zero
1689
+ // exit is a real I/O failure — same reading as the default-mode branch.
1690
+ const rmResult = (0, shell_command_projection_cjs_1.execGit)(['rm', '--cached', '--ignore-unmatch', '--', lit(trackedPath)], { cwd });
1691
+ if (rmResult.exitCode === 0) {
1692
+ if (recordable !== null)
1693
+ removedEntries.push(recordable);
1694
+ // Re-check AFTER the index mutation. The absence test and the `rm` are
1695
+ // not atomic, and the scoped `git commit -- <paths>` below reads the
1696
+ // WORKTREE, so a path recreated in between would be committed as its
1697
+ // new content under a message that declared it removed. A reappearance
1698
+ // is a contradiction like any other: staging failure, and the rollback
1699
+ // restores the recorded entry. Narrows the window; does not close it.
1700
+ if (lstatState(node_path_1.default.resolve(cwd, trackedPath)) !== 'absent') {
1701
+ failures.push({
1702
+ file: trackedPath,
1703
+ error: `declared in --files-removed but reappeared on disk: ${trackedPath}`,
1704
+ timed_out: false,
1705
+ });
1706
+ continue;
1707
+ }
1708
+ // Unborn HEAD: nothing to delete FROM, so the path is unstaged only and
1709
+ // never joins the pathspec; its rollback is the recorded entry above.
1710
+ if (headExists)
1711
+ removedPathspec.push(trackedPath);
1712
+ }
1713
+ else {
1714
+ // A NON-ZERO rm is NOT proof the index is untouched. `execGit` collapses
1715
+ // a spawn timeout to a non-zero exit, and a killed `git rm` can already
1716
+ // have written the index — so keying the record on the exit code alone
1717
+ // drops a real mutation on the timeout path (driven: a post-index-change
1718
+ // hook that outlives the timeout leaves `D <path>` staged and reported
1719
+ // nowhere). The exit code answers "did the command succeed", never "did
1720
+ // the index change". ASK THE INDEX instead — three honest arms, and no
1721
+ // arm asserts a state it did not observe.
1722
+ // THE ORIGINAL FAILURE IS PUSHED FIRST. `failures[0]` sets the result's
1723
+ // `reason`, `file`, `error` and timeout classification, so appending the
1724
+ // probe's diagnostic ahead of it renamed the cause: a timed-out rm was
1725
+ // reported as a permission error and lost its `timed_out: true`.
1726
+ failures.push({
1727
+ file: trackedPath,
1728
+ error: rmResult.stderr || rmResult.stdout,
1729
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(rmResult),
1730
+ });
1731
+ if (recordable !== null) {
1732
+ const after = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-s', '-z', '--', lit(trackedPath)], { cwd });
1733
+ if (after.exitCode !== 0) {
1734
+ // Could not determine. Say so; never silently assume either way.
1735
+ failures.push({
1736
+ file: trackedPath,
1737
+ error: `removal failed and the index state for this path could NOT be determined: ${after.stderr || after.stdout}`,
1738
+ timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(after),
1739
+ });
1740
+ }
1741
+ else if (after.stdout.replace(/\0/g, '').trim() === '') {
1742
+ // The entry is gone: the rm mutated the index before it failed, so
1743
+ // this call owns the removal and must restore/disclose it.
1744
+ removedEntries.push(recordable);
1745
+ }
1746
+ // else: the entry is still there — nothing was staged, nothing to undo.
1747
+ }
1748
+ }
1749
+ }
1750
+ }
1751
+ return { removedEntries, removedPathspec, failures };
1752
+ }
1753
+ function cmdCommit(cwd, message, files, raw, amend, noVerify, filesRemoved) {
1446
1754
  if (!message && !amend) {
1447
1755
  error('commit message required');
1448
1756
  }
@@ -1478,6 +1786,10 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1478
1786
  const branchingStrategy = config['branching_strategy'];
1479
1787
  if (branchingStrategy && branchingStrategy !== 'none') {
1480
1788
  let branchName = null;
1789
+ // #4055: the phase directory (cwd-relative POSIX path from
1790
+ // findPhaseInternal) captured while resolving the phase identity — the
1791
+ // state-3 guard below needs it for the committed-history check.
1792
+ let phaseDirRelative = null;
1481
1793
  if (branchingStrategy === 'phase') {
1482
1794
  // Determine which phase we're committing for from the file paths.
1483
1795
  // #2539: the extraction is anchored to the directory SEGMENT immediately
@@ -1499,9 +1811,16 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1499
1811
  if (phaseNum && !isSentinelPhaseId(phaseNum)) {
1500
1812
  const phaseInfo = findPhaseInternal(cwd, phaseNum);
1501
1813
  if (phaseInfo) {
1502
- branchName = config['phase_branch_template']
1503
- .replace('{phase}', normalizePhaseName(phaseInfo['phase_number']))
1504
- .replace('{slug}', phaseInfo['phase_slug'] || 'phase');
1814
+ // #4126: shared with init.cts's cmdInitExecutePhase branch_name field
1815
+ // via the one canonical renderer (src/phase-id.cts) so an undeliverable
1816
+ // phase_slug degrades identically at both call sites instead of each
1817
+ // independently substituting the literal word 'phase'.
1818
+ branchName = renderPhaseBranchName(config['phase_branch_template'], phaseInfo['phase_number'], phaseInfo['phase_slug']);
1819
+ // #4055: findPhaseInternal already returns the directory as a
1820
+ // cwd-relative POSIX path.
1821
+ const dir = phaseInfo['directory'];
1822
+ if (typeof dir === 'string' && dir !== '')
1823
+ phaseDirRelative = dir;
1505
1824
  }
1506
1825
  }
1507
1826
  }
@@ -1526,6 +1845,14 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1526
1845
  }
1527
1846
  }
1528
1847
  if (branchName) {
1848
+ // #4055: state-3 discriminator for the create arm. `rev-parse --verify`
1849
+ // alone cannot distinguish "branch never existed" (create is the #1278
1850
+ // intent) from "branch existed, was merged, then deleted" (the phase is
1851
+ // over — recreating it hijacks the close-out commit onto a resurrected
1852
+ // ref, the #3079 bug #3363 reopened). Both extra conditions come from
1853
+ // the confirmed issue: the create arm may fire only for a phase whose
1854
+ // directory has NO committed history on the current line (a genuinely
1855
+ // new phase) while the caller sits on the resolved base branch.
1529
1856
  const currentBranch = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
1530
1857
  if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
1531
1858
  // #2539/#3079/#3207: two cases the prior (#3079) code collapsed into one.
@@ -1540,18 +1867,63 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1540
1867
  // EXISTING branch is never switched to (the else arm logs + commits in
1541
1868
  // place). The fresh create is logged so the first phase-scoped commit is
1542
1869
  // not silent about where the work is landing (#3207 AC3).
1870
+ // #4055: "brand-new" is now VERIFIED, not assumed — see the state-3
1871
+ // guard between the verify and the create below.
1543
1872
  const verify = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
1544
1873
  if (verify.exitCode !== 0) {
1545
- // Branch does not exist — CREATE AND SWITCH (the #1278 first-commit
1546
- // case). checkout -b cannot resurrect anything: the branch was just
1547
- // verified absent, so it is created fresh at HEAD.
1548
- const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
1549
- if (create.exitCode === 0) {
1550
- process.stderr.write(`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`);
1874
+ // Branch does not exist — but absence alone cannot distinguish a
1875
+ // genuinely new phase from a merged-and-deleted one (#4055).
1876
+ let createBlockReason = null;
1877
+ if (branchingStrategy === 'phase' && phaseDirRelative) {
1878
+ // #4055 residual: searchPhaseInDir's #2237 fail-safe can return an
1879
+ // empty `directory` for ambiguous phase names (leaving
1880
+ // phaseDirRelative null) — there the history half is skipped and
1881
+ // only the base check below guards; shallow clones can also show
1882
+ // an empty probe for old merged phases (depth-sensitive).
1883
+ const history = (0, shell_command_projection_cjs_1.execGit)(['log', 'HEAD', '--oneline', '--', phaseDirRelative], { cwd });
1884
+ if (history.exitCode === 0 && history.stdout.trim() !== '') {
1885
+ createBlockReason =
1886
+ 'its phase directory already has committed history (the phase is resolved)';
1887
+ }
1888
+ }
1889
+ if (!createBlockReason) {
1890
+ // The base half of the guard applies to BOTH strategies (it does
1891
+ // not need a directory): a phase/milestone branch is created only
1892
+ // from the resolved base branch. NOTE the milestone arm keeps its
1893
+ // existence-only guard for the HISTORY half — a merged-and-deleted
1894
+ // milestone branch remains resurrectable by an on-base caller
1895
+ // until a milestone-directory derivation exists here (#4055
1896
+ // follow-up candidate).
1897
+ /* eslint-disable @typescript-eslint/no-require-imports */
1898
+ const gitBaseBranch = require('./git-base-branch.cjs');
1899
+ /* eslint-enable @typescript-eslint/no-require-imports */
1900
+ const resolvedBase = gitBaseBranch.resolveBaseBranch(cwd);
1901
+ if (resolvedBase && resolvedBase !== currentBranch.stdout.trim()) {
1902
+ createBlockReason =
1903
+ `the current branch "${currentBranch.stdout.trim()}" is not the ` +
1904
+ `resolved base branch "${resolvedBase}"`;
1905
+ }
1906
+ }
1907
+ if (createBlockReason === null) {
1908
+ // State 1 confirmed: brand-new phase, first phase-scoped commit
1909
+ // from the base branch. CREATE AND SWITCH (the #1278 first-commit
1910
+ // case). checkout -b cannot resurrect anything: the branch was
1911
+ // just verified absent, so it is created fresh at HEAD.
1912
+ const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
1913
+ if (create.exitCode === 0) {
1914
+ process.stderr.write(`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`);
1915
+ }
1916
+ else {
1917
+ process.stderr.write(`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
1918
+ `(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`);
1919
+ }
1551
1920
  }
1552
1921
  else {
1553
- process.stderr.write(`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
1554
- `(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`);
1922
+ // State 3 (or a non-base caller): the phase is resolved — commit
1923
+ // in place, disclosed (#2539 AC2), never recreate the branch.
1924
+ process.stderr.write(`Warning: resolved ${branchingStrategy} branch "${branchName}" is absent and ` +
1925
+ `will not be recreated (${createBlockReason}); committing on the current ` +
1926
+ `branch "${currentBranch.stdout.trim()}" instead of recreating it.\n`);
1555
1927
  }
1556
1928
  }
1557
1929
  else {
@@ -1563,8 +1935,12 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1563
1935
  }
1564
1936
  }
1565
1937
  // Stage files
1566
- const explicitFiles = files && files.length > 0;
1567
- const filesToStage = explicitFiles ? files : ['.planning/'];
1938
+ // #4208: `--files-removed` is a declared scope in its own right — a caller
1939
+ // that names only removals must not fall through to the unscoped
1940
+ // `.planning/` sweep, which would commit everything under it.
1941
+ const removedDeclared = filesRemoved ?? [];
1942
+ const explicitFiles = (files && files.length > 0) || removedDeclared.length > 0;
1943
+ const filesToStage = explicitFiles ? (files ?? []) : ['.planning/'];
1568
1944
  const stagedPaths = [];
1569
1945
  // #2608: a `git add` that fails must abort the commit, not be skipped.
1570
1946
  // #2523 stopped a failed path entering the commit pathspec, but skipping it
@@ -1574,11 +1950,27 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1574
1950
  // a linked worktree, timeout) was discarded and the operator saw a downstream
1575
1951
  // pathspec error pointing at an innocent file.
1576
1952
  const stagingFailures = [];
1953
+ // #4454: explicit --files paths skipped because they were missing from disk
1954
+ // (the #2014 guard below). Tracked so the caller can tell a partial commit
1955
+ // from a complete one instead of an unqualified `committed: true`.
1956
+ const skippedFiles = [];
1577
1957
  // Paths already in the index BEFORE this call. On a staging failure the
1578
1958
  // rollback below unstages only what THIS call added — unstaging a path the
1579
1959
  // caller had staged themselves would destroy their work.
1580
- const preStaged = new Set((0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only'], { cwd })
1581
- .stdout.split('\n').map(s => s.trim()).filter(Boolean));
1960
+ // `-z`: without it `core.quotePath` renders a non-ASCII name as
1961
+ // `"caf\303\251.md"`, which never equals the raw path in `stagedPaths`, so
1962
+ // the rollback below would treat a caller-pre-staged `café.md` as this
1963
+ // call's own and unstage it (#4208 review, driven).
1964
+ // `--relative`: `diff --cached` prints REPO-relative paths whatever the cwd,
1965
+ // while `stagedPaths` holds the caller's own cwd-relative names. In a project
1966
+ // nested inside its repo (`<repo>/sub/.planning/...`) the two name spaces
1967
+ // never intersect, so `preStaged` matched NOTHING and the rollback unstaged
1968
+ // every path including the caller's own pre-staged work. Driven on a nested
1969
+ // fixture: a caller-staged deletion vanished from `diff --cached` after an
1970
+ // unrelated declaration failed. Pre-existing -- it governs the `--files` side
1971
+ // too -- and a no-op when the project IS the repo root.
1972
+ const preStaged = new Set((0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z', '--relative'], { cwd })
1973
+ .stdout.split('\0').filter(Boolean));
1582
1974
  for (const file of filesToStage) {
1583
1975
  const fullPath = node_path_1.default.resolve(cwd, file);
1584
1976
  if (!node_fs_1.default.existsSync(fullPath)) {
@@ -1586,6 +1978,9 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1586
1978
  // Caller passed an explicit --files list: missing files are skipped.
1587
1979
  // Staging a deletion here would silently remove tracked planning files
1588
1980
  // (e.g. STATE.md, ROADMAP.md) when they are temporarily absent (#2014).
1981
+ // #4454: record what was skipped so the caller can tell a partial
1982
+ // commit from a complete one, instead of an unqualified success.
1983
+ skippedFiles.push(file);
1589
1984
  continue;
1590
1985
  }
1591
1986
  // Default mode (staging all of .planning/): stage the deletion so
@@ -1623,6 +2018,75 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1623
2018
  }
1624
2019
  }
1625
2020
  }
2021
+ // #4208: caller-declared removals -- see stageDeclaredRemovals.
2022
+ const declaredRemovals = stageDeclaredRemovals(cwd, removedDeclared);
2023
+ const removedEntries = declaredRemovals.removedEntries;
2024
+ stagingFailures.push(...declaredRemovals.failures);
2025
+ stagedPaths.push(...declaredRemovals.removedPathspec);
2026
+ // A REMOVAL'S PATH IS A PATH DOWNSTREAM TOO. Literalising the staging alone
2027
+ // does not protect the COMMIT's own pathspec: with a tracked file literally
2028
+ // named `.planning/*.md` declared removed beside a MODIFIED `peer.md`, the
2029
+ // `git commit -- <paths>` below globs and commits `M peer.md` the caller
2030
+ // never declared — the sweep this flag exists to remove, arriving one step
2031
+ // later. Driven. Only the removal-derived entries are literalised: `--files`
2032
+ // entries keep whatever pathspec behaviour they have today, which is not this
2033
+ // change's to alter.
2034
+ const removalPathspecs = new Set(declaredRemovals.removedPathspec);
2035
+ const asPathspec = (p) => (removalPathspecs.has(p) ? `:(literal)${p}` : p);
2036
+ const restoreRemovedEntries = () => {
2037
+ if (removedEntries.length === 0)
2038
+ return 'restored';
2039
+ (0, shell_command_projection_cjs_1.execGit)(['update-index', '--add', ...removedEntries.flatMap(e => ['--cacheinfo', `${e.mode},${e.sha},${e.path}`])], { cwd });
2040
+ // VERIFY BY READING THE INDEX BACK, never by the exit code. `execGit`
2041
+ // collapses a spawn timeout to a non-zero exit, and a killed `update-index`
2042
+ // can already have written the index — so an exit code answers "did the
2043
+ // command succeed", never "is the entry back". Driven: a post-index-change
2044
+ // hook outliving the timeout made the restore report failure over an index
2045
+ // it had in fact restored, publishing a disclosure that was simply false.
2046
+ //
2047
+ // `-z` IS LOAD-BEARING, and its absence is the #2014-era defect this PR
2048
+ // already fixed once for `preStaged`: without it `core.quotePath` renders a
2049
+ // non-ASCII name as `"caf\303\251.md"`, which never equals the raw path, so
2050
+ // an exactly-restored `café.md` (and any name carrying a tab or a newline)
2051
+ // read as NOT restored. Driven on all three shapes.
2052
+ const back = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-s', '-z', '--', ...removedEntries.map(e => `:(literal)${e.path}`)], { cwd });
2053
+ if (back.exitCode !== 0)
2054
+ return 'unverified'; // no observation — never an assertion of failure
2055
+ // COMPARE THE WHOLE ENTRY, not just the path. `--cacheinfo` restores mode,
2056
+ // blob and stage; a path present at a DIFFERENT mode or blob is not the
2057
+ // entry this call removed. Driven: a hook that rewrote the restored entry
2058
+ // 100644 -> 100755 was reported as restored by a path-only test.
2059
+ const present = new Map();
2060
+ for (const rec of back.stdout.split('\0')) {
2061
+ if (rec === '')
2062
+ continue;
2063
+ const tab = rec.indexOf('\t');
2064
+ if (tab === -1)
2065
+ continue;
2066
+ present.set(rec.slice(tab + 1), rec.slice(0, tab));
2067
+ }
2068
+ const ok = removedEntries.every(e => present.get(e.path) === `${e.mode} ${e.sha} 0`);
2069
+ return ok ? 'restored' : 'not-restored';
2070
+ };
2071
+ // The no-change exits' shared arm: restore, and if the restore failed, say so
2072
+ // instead of claiming nothing changed. `staging_failed` is the honest reason —
2073
+ // the index carries a mutation this call made and could not undo.
2074
+ const removalsLeftStaged = (verdict) => ({
2075
+ committed: false,
2076
+ hash: null,
2077
+ reason: 'staging_failed',
2078
+ file: removedEntries[0]?.path ?? null,
2079
+ error: verdict === 'not-restored'
2080
+ ? `declared removal(s) staged but could not be restored after the commit recorded nothing: ${removedEntries.map(e => e.path).join(', ')}`
2081
+ : `declared removal(s) staged and the restore could NOT be VERIFIED after the commit recorded nothing: ${removedEntries.map(e => e.path).join(', ')}`,
2082
+ failures: removedEntries.map(e => ({
2083
+ file: e.path,
2084
+ error: verdict === 'not-restored'
2085
+ ? 'update-index --cacheinfo restore failed'
2086
+ : 'update-index --cacheinfo restore could not be verified — the index was not readable',
2087
+ timed_out: false,
2088
+ })),
2089
+ });
1626
2090
  // #2608: fail closed before `git commit` runs. Checked ahead of the
1627
2091
  // nothing_to_commit branch below so a run where EVERY path failed to stage
1628
2092
  // reports the staging cause rather than "nothing to commit", and ahead of the
@@ -1637,10 +2101,37 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1637
2101
  // best-effort: if the index is unwritable — the very failure being reported
1638
2102
  // — the reset cannot succeed either, and the staging error is still what
1639
2103
  // gets returned.
1640
- const toUnstage = stagedPaths.filter(p => !preStaged.has(p));
2104
+ const removedPaths = new Set(removedEntries.map(e => e.path));
2105
+ const toUnstage = stagedPaths.filter(p => !preStaged.has(p) && !removedPaths.has(p));
1641
2106
  if (toUnstage.length > 0) {
1642
- (0, shell_command_projection_cjs_1.execGit)(['reset', '-q', '--', ...toUnstage], { cwd });
1643
- }
2107
+ // `asPathspec` here too. This reset is the LAST place a removal-derived
2108
+ // name reaches git as a pathspec, and it is the most damaging: driven,
2109
+ // a wildcard-named entry that slipped into `toUnstage` globbed and
2110
+ // unstaged the CALLER'S OWN pre-staged deletion and modification, then
2111
+ // reported only the contradiction that triggered the rollback.
2112
+ (0, shell_command_projection_cjs_1.execGit)(['reset', '-q', '--', ...toUnstage.map(asPathspec)], { cwd });
2113
+ }
2114
+ // Removals are restored from the recorded entries, never via `reset`
2115
+ // (no HEAD to reset to on an unborn branch; not the pre-staged blob when
2116
+ // the caller had one) — and unconditionally, since a removal this call
2117
+ // performed is this call's to undo whether or not the path was pre-staged.
2118
+ // DISCLOSE a failed restore here too. The earlier reading -- that this exit
2119
+ // is already reporting a failure, so the restore's result adds nothing --
2120
+ // is wrong, and the counterexample is the ordinary one: the reported
2121
+ // failure is usually a DIFFERENT cause (a contradictory declaration, a
2122
+ // reappeared path), so a caller reading `failures` sees only that cause
2123
+ // and learns nothing about the removal still sitting in its index. Append
2124
+ // rather than replace: the original failure is still the reason.
2125
+ const restoreVerdict = restoreRemovedEntries();
2126
+ const failures = restoreVerdict === 'restored'
2127
+ ? stagingFailures
2128
+ : [...stagingFailures, ...removedEntries.map(e => ({
2129
+ file: e.path,
2130
+ error: restoreVerdict === 'not-restored'
2131
+ ? 'staged removal could NOT be restored during rollback — it is still staged in the index'
2132
+ : 'staged removal was rolled back but the result could NOT be VERIFIED — the index was not readable',
2133
+ timed_out: false,
2134
+ }))];
1644
2135
  const first = stagingFailures[0];
1645
2136
  const result = {
1646
2137
  committed: false,
@@ -1648,7 +2139,7 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1648
2139
  reason: first.timed_out ? 'staging_timeout' : 'staging_failed',
1649
2140
  file: first.file,
1650
2141
  error: first.error,
1651
- failures: stagingFailures,
2142
+ failures,
1652
2143
  };
1653
2144
  output(result, raw, 'failed');
1654
2145
  return;
@@ -1851,13 +2342,13 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1851
2342
  // failing-closed (drop the content) and failing-open (re-enter #3776) are
1852
2343
  // wrong answers to a question we can just ask directly.
1853
2344
  const assumeUnchangedWouldRecord = () => {
1854
- const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '--', ...stagedPaths], { cwd });
2345
+ const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '--', ...stagedPaths.map(asPathspec)], { cwd });
1855
2346
  // Only the TAG is read; the path is deliberately never parsed out — see the
1856
2347
  // `core.quotePath` note above, and the dry run below needs no path anyway.
1857
2348
  if (listed.exitCode === 0
1858
2349
  && !listed.stdout.split('\n').some((line) => /^[a-z] /.test(line)))
1859
2350
  return false;
1860
- const dryRun = (0, shell_command_projection_cjs_1.execGit)(['commit', '--dry-run', '--porcelain', '--no-verify', '-m', sanitizedMessage, '--', ...stagedPaths], { cwd });
2351
+ const dryRun = (0, shell_command_projection_cjs_1.execGit)(['commit', '--dry-run', '--porcelain', '--no-verify', '-m', sanitizedMessage, '--', ...stagedPaths.map(asPathspec)], { cwd });
1861
2352
  // Only a CONFIRMED "nothing to record" closes the path: rc 1 from a git
1862
2353
  // that actually answered. This is the one probe in the guard whose rc 0
1863
2354
  // is the REASSURING answer, so it inverts the diff probe's safety: there
@@ -1879,10 +2370,30 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1879
2370
  const nothingToCommit = guardApplies
1880
2371
  && (stagedPaths.length === 0
1881
2372
  || (!partialCommitRefused
1882
- && (0, shell_command_projection_cjs_1.execGit)(['diff', '--quiet', '--ignore-submodules=dirty', '--no-textconv', 'HEAD', '--', ...stagedPaths], { cwd }).exitCode === 0
2373
+ && (0, shell_command_projection_cjs_1.execGit)(['diff', '--quiet', '--ignore-submodules=dirty', '--no-textconv', 'HEAD', '--', ...stagedPaths.map(asPathspec)], { cwd }).exitCode === 0
1883
2374
  && !assumeUnchangedWouldRecord()));
1884
2375
  if (nothingToCommit) {
1885
- const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
2376
+ // Nothing is being recorded, so any removal this call staged has no commit
2377
+ // to land in. Put it back before reporting no state change. Reachable on
2378
+ // two shapes, and keying on either one alone leaves the other broken:
2379
+ // an unborn HEAD (a removal never joins `stagedPaths`, so the pathspec is
2380
+ // empty), and a HEAD that simply does not carry the removed path -- an
2381
+ // index-only entry the caller `git add`ed but never committed, where the
2382
+ // `diff HEAD` probe reads clean because the path is absent on both sides.
2383
+ const rv = restoreRemovedEntries();
2384
+ if (rv !== 'restored') {
2385
+ output(removalsLeftStaged(rv), raw, 'failed');
2386
+ return;
2387
+ }
2388
+ // #4454: an explicit --files list where every named path was missing
2389
+ // reaches this branch via `stagedPaths.length === 0` above — surface
2390
+ // which path(s) were the reason, same as the success result below.
2391
+ const result = {
2392
+ committed: false,
2393
+ hash: null,
2394
+ reason: 'nothing_to_commit',
2395
+ ...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
2396
+ };
1886
2397
  output(result, raw, 'nothing');
1887
2398
  return;
1888
2399
  }
@@ -1894,7 +2405,7 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1894
2405
  if (noVerify)
1895
2406
  commitArgs.push('--no-verify');
1896
2407
  if (canScope) {
1897
- commitArgs.push('--', ...stagedPaths);
2408
+ commitArgs.push('--', ...stagedPaths.map(asPathspec));
1898
2409
  }
1899
2410
  // #3859 follow-up: on git 2.39.5 (confirmed on the CI Linux bench image,
1900
2411
  // ghcr.io/open-gsd/gsd-tester-linux:v1.8.0-node24; NOT reproducible on git
@@ -1952,7 +2463,28 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1952
2463
  return;
1953
2464
  }
1954
2465
  if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
1955
- const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
2466
+ // Same reading as the guard above: git recorded nothing, so a removal
2467
+ // this call staged must not be left behind under a `nothing_to_commit`
2468
+ // report. The failure exits below are deliberately NOT restored -- they
2469
+ // report a failure rather than "no state changed", and the addition side
2470
+ // leaves its own staged paths in place there too.
2471
+ const rv = restoreRemovedEntries();
2472
+ if (rv !== 'restored') {
2473
+ output(removalsLeftStaged(rv), raw, 'failed');
2474
+ return;
2475
+ }
2476
+ // #4454: this is the residual window the surrounding comments already
2477
+ // document (a partial skip + partialCommitRefused bypassing the diff
2478
+ // probe + git's own empty-commit refusal) — skippedFiles can be
2479
+ // non-empty here too, and omitting it would be the same misreport
2480
+ // this fix exists to close, just on the other branch that reaches
2481
+ // "nothing to commit".
2482
+ const result = {
2483
+ committed: false,
2484
+ hash: null,
2485
+ reason: 'nothing_to_commit',
2486
+ ...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
2487
+ };
1956
2488
  output(result, raw, 'nothing');
1957
2489
  return;
1958
2490
  }
@@ -1968,7 +2500,15 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
1968
2500
  // Get short hash
1969
2501
  const hashResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--short', 'HEAD'], { cwd });
1970
2502
  const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
1971
- const result = { committed: true, hash, reason: 'committed' };
2503
+ // #4454: report explicit --files paths that were skipped as missing (the
2504
+ // #2014 guard above) so a caller can tell a partial commit from a complete
2505
+ // one, without changing the payload shape when nothing was skipped.
2506
+ const result = {
2507
+ committed: true,
2508
+ hash,
2509
+ reason: 'committed',
2510
+ ...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
2511
+ };
1972
2512
  output(result, raw, hash || 'committed');
1973
2513
  }
1974
2514
  /**
@@ -2007,7 +2547,7 @@ function groupFilesBySubrepo(files, subRepos) {
2007
2547
  let matchLen = -1;
2008
2548
  if (candidates) {
2009
2549
  for (const repo of candidates) {
2010
- if (file.startsWith(repo + '/')) {
2550
+ if (file.startsWith(repo + '/')) { // allow-handrolled-containment: sub-repo file grouping, not a safety decision
2011
2551
  const repoLen = String(repo).length;
2012
2552
  if (repoLen > matchLen) {
2013
2553
  match = repo;
@@ -2156,15 +2696,13 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
2156
2696
  error(`Branch name must not start with '-': ${branch}`);
2157
2697
  }
2158
2698
  // 0. Security: validate repo path is contained within the workspace root.
2159
- // Uses security.cjs validatePath (symlink-safe realpathSync + startsWith guard)
2699
+ // Uses security.cjs tryWithinRoot (symlink-safe realpathSync + startsWith guard)
2160
2700
  // to reject ../escape, absolute paths, and symlink traversal.
2161
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
2162
- const { validatePath } = require('./security.cjs');
2163
- const pathCheck = validatePath(repo, cwd);
2164
- if (!pathCheck.safe) {
2165
- error(`Sub-repo path is unsafe: ${pathCheck.error}`);
2701
+ const repoContained = (0, security_cjs_1.tryWithinRoot)(repo, cwd);
2702
+ if (repoContained === null) {
2703
+ error(`Sub-repo path is unsafe: resolves outside the workspace root`);
2166
2704
  }
2167
- const repoCwd = pathCheck.resolved;
2705
+ const repoCwd = repoContained;
2168
2706
  if (!node_fs_1.default.existsSync(repoCwd)) {
2169
2707
  error(`Sub-repo not found: ${repoCwd}`);
2170
2708
  }
@@ -2508,9 +3046,7 @@ function cmdProgressRender(cwd, format, raw) {
2508
3046
  : null;
2509
3047
  if (format === 'table') {
2510
3048
  // Render markdown table
2511
- const barWidth = 10;
2512
- const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
2513
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
3049
+ const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 10);
2514
3050
  const percentSuffix = percent === null ? '' : ` (${percent}%)`;
2515
3051
  let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
2516
3052
  out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
@@ -2522,9 +3058,7 @@ function cmdProgressRender(cwd, format, raw) {
2522
3058
  output({ rendered: out }, raw, out);
2523
3059
  }
2524
3060
  else if (format === 'bar') {
2525
- const barWidth = 20;
2526
- const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
2527
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
3061
+ const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 20);
2528
3062
  const percentSuffix = percent === null ? '' : ` (${percent}%)`;
2529
3063
  const text = `[${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}`;
2530
3064
  output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
@@ -2553,7 +3087,8 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
2553
3087
  if (!phase) {
2554
3088
  error('phase required for todo match-phase');
2555
3089
  }
2556
- const pendingDir = node_path_1.default.join(planningDir(cwd), 'todos', 'pending');
3090
+ // #4256: root-scoped todos read — see cmdListTodos.
3091
+ const pendingDir = node_path_1.default.join(todosDir(cwd), 'pending');
2557
3092
  const todos = [];
2558
3093
  // Load pending todos
2559
3094
  try {
@@ -2685,13 +3220,58 @@ function cmdTodoComplete(cwd, filename, options, raw) {
2685
3220
  if (!filename) {
2686
3221
  error('filename required for todo complete');
2687
3222
  }
2688
- const pendingDir = node_path_1.default.join(planningDir(cwd), 'todos', 'pending');
2689
- const completedDir = node_path_1.default.join(planningDir(cwd), 'todos', 'completed');
3223
+ // #4256: root-scoped todos read/write — see cmdListTodos. The pending and
3224
+ // completed halves of the move must resolve from the SAME root or the
3225
+ // completion would strand files where no reader looks.
3226
+ const todosRoot = todosDir(cwd);
3227
+ const pendingDir = node_path_1.default.join(todosRoot, 'pending');
3228
+ const completedDir = node_path_1.default.join(todosRoot, 'completed');
3229
+ // #4652: containment against todosRoot only rejects paths that leave the
3230
+ // root — it cannot express "a todo name is a basename, not a path" (see
3231
+ // #4327). `../sibling.md`, `a/../../b.md`, and `sub/name.md` all resolve
3232
+ // to a location inside todosRoot (or inside pending/) and would pass
3233
+ // containment, yet none of them is a bare filename. Reject on basename
3234
+ // shape FIRST, before any path is even joined — same predicate shape as
3235
+ // findPhaseArtifact in check-command-router.cts. Checking both `/` and
3236
+ // `\` explicitly (not just path.basename) matters on POSIX, where a
3237
+ // literal backslash is just an ordinary filename character to
3238
+ // path.basename but not to path.win32.basename or to the user's intent.
3239
+ const rawFilename = filename;
3240
+ if (rawFilename === '.' ||
3241
+ rawFilename === '..' ||
3242
+ rawFilename.includes('\0') ||
3243
+ rawFilename.includes('/') ||
3244
+ rawFilename.includes('\\') ||
3245
+ node_path_1.default.basename(rawFilename) !== rawFilename ||
3246
+ node_path_1.default.win32.basename(rawFilename) !== rawFilename) {
3247
+ error(`todo name must be a plain filename inside the pending directory, not a path: ${rawFilename}`, ERROR_REASON.USAGE);
3248
+ }
2690
3249
  const sourcePath = node_path_1.default.join(pendingDir, filename);
2691
- if (!node_fs_1.default.existsSync(sourcePath)) {
3250
+ const targetPath = node_path_1.default.join(completedDir, filename);
3251
+ const sourceContained = (0, security_cjs_1.tryWithinRoot)(sourcePath, todosRoot, security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
3252
+ if (sourceContained === null) {
3253
+ error(`todo file escapes its allowed directory: ${filename}`, ERROR_REASON.USAGE);
3254
+ }
3255
+ const targetContained = (0, security_cjs_1.tryWithinRoot)(targetPath, todosRoot, security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
3256
+ if (targetContained === null) {
3257
+ error(`todo file escapes its allowed directory: ${filename}`, ERROR_REASON.USAGE);
3258
+ }
3259
+ const resolvedSource = sourceContained;
3260
+ const resolvedTarget = targetContained;
3261
+ if (!node_fs_1.default.existsSync(resolvedSource)) {
2692
3262
  error(`Todo not found: ${filename}`);
2693
3263
  }
2694
- const content = node_fs_1.default.readFileSync(sourcePath, 'utf-8');
3264
+ // #4652: a name that IS a bare basename can still resolve to something that
3265
+ // is not a regular file — a directory, symlink-to-directory, FIFO or socket
3266
+ // sitting in pending/ under an ordinary-looking name. `.` and `..` no longer
3267
+ // reach here (the basename guard above rejects them first), so this is not
3268
+ // about traversal; it stops fs.readFileSync from throwing an uncaught EISDIR
3269
+ // with an absolute-path stack trace where every sibling case gives a clean
3270
+ // USAGE rejection.
3271
+ if (!node_fs_1.default.statSync(resolvedSource).isFile()) {
3272
+ error(`todo name is not a file: ${filename}`, ERROR_REASON.USAGE);
3273
+ }
3274
+ const content = node_fs_1.default.readFileSync(resolvedSource, 'utf-8');
2695
3275
  const today = clock_cjs_1.realClock.localToday();
2696
3276
  // #4096: --dry-run mirrors `milestone complete --dry-run` (#2118) — every
2697
3277
  // existence check above still runs, nothing below mutates, and the payload
@@ -2703,8 +3283,8 @@ function cmdTodoComplete(cwd, filename, options, raw) {
2703
3283
  file: filename,
2704
3284
  date: today,
2705
3285
  would_move: {
2706
- source: node_path_1.default.relative(cwd, sourcePath).split(node_path_1.default.sep).join('/'),
2707
- target: node_path_1.default.relative(cwd, node_path_1.default.join(completedDir, filename)).split(node_path_1.default.sep).join('/'),
3286
+ source: node_path_1.default.relative(cwd, resolvedSource).split(node_path_1.default.sep).join('/'),
3287
+ target: node_path_1.default.relative(cwd, resolvedTarget).split(node_path_1.default.sep).join('/'),
2708
3288
  },
2709
3289
  would_set: { completed: today, status: 'completed' },
2710
3290
  }, raw);
@@ -2714,8 +3294,8 @@ function cmdTodoComplete(cwd, filename, options, raw) {
2714
3294
  // creates nothing).
2715
3295
  (0, shell_command_projection_cjs_1.platformEnsureDir)(completedDir);
2716
3296
  const completedContent = upsertTodoCompletionFields(content, today);
2717
- (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(completedDir, filename), completedContent);
2718
- node_fs_1.default.unlinkSync(sourcePath);
3297
+ (0, shell_command_projection_cjs_1.platformWriteSync)(resolvedTarget, completedContent);
3298
+ node_fs_1.default.unlinkSync(resolvedSource);
2719
3299
  output({ completed: true, file: filename, date: today }, raw, 'completed');
2720
3300
  }
2721
3301
  function cmdScaffold(cwd, type, options, raw) {
@@ -2925,9 +3505,7 @@ function cmdStats(cwd, format, raw) {
2925
3505
  phase_scope: phaseScope,
2926
3506
  };
2927
3507
  if (format === 'table') {
2928
- const barWidth = 10;
2929
- const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
2930
- const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
3508
+ const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 10);
2931
3509
  let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
2932
3510
  const percentSuffix = percent === null ? '' : ` (${percent}%)`;
2933
3511
  out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;