@opengsd/gsd-core 1.12.0 → 1.13.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 (286) 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 +12 -0
  4. package/agents/gsd-executor.md +63 -35
  5. package/agents/gsd-plan-checker.md +76 -57
  6. package/agents/gsd-planner.md +14 -0
  7. package/agents/gsd-ui-checker.md +19 -3
  8. package/agents/gsd-ui-researcher.md +29 -0
  9. package/agents/gsd-verifier.md +23 -1
  10. package/bin/install.js +239 -67
  11. package/commands/gsd/execute-phase.md +1 -1
  12. package/commands/gsd/ns-workflow.md +2 -1
  13. package/commands/gsd/phase.md +1 -1
  14. package/commands/gsd/quick-batch.md +105 -0
  15. package/commands/gsd/surface.md +18 -8
  16. package/gsd-core/bin/gsd-tools.cjs +195 -50
  17. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +514 -114
  19. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  20. package/gsd-core/bin/lib/capability-validator.cjs +120 -4
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  22. package/gsd-core/bin/lib/check-command-router.cjs +85 -2
  23. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  24. package/gsd-core/bin/lib/clusters.cjs +1 -0
  25. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  26. package/gsd-core/bin/lib/commands.cjs +337 -13
  27. package/gsd-core/bin/lib/config-loader.cjs +3 -0
  28. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  29. package/gsd-core/bin/lib/decisions.cjs +213 -1
  30. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  31. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  32. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  33. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  34. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  35. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  36. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  37. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  38. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  39. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  40. package/gsd-core/bin/lib/init.cjs +132 -15
  41. package/gsd-core/bin/lib/install-engine.cjs +184 -12
  42. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  43. package/gsd-core/bin/lib/install-profiles.cjs +22 -14
  44. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  45. package/gsd-core/bin/lib/io.cjs +35 -0
  46. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  47. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  48. package/gsd-core/bin/lib/milestone.cjs +22 -2
  49. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  50. package/gsd-core/bin/lib/phase-id.cjs +251 -9
  51. package/gsd-core/bin/lib/phase.cjs +774 -35
  52. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  53. package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
  54. package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
  55. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  56. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  57. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  58. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  59. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  60. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  61. package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
  62. package/gsd-core/bin/lib/roadmap.cjs +187 -58
  63. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
  64. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  65. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
  66. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
  67. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  68. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  69. package/gsd-core/bin/lib/state-document.cjs +30 -5
  70. package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
  71. package/gsd-core/bin/lib/state-transition.cjs +333 -44
  72. package/gsd-core/bin/lib/state.cjs +684 -125
  73. package/gsd-core/bin/lib/surface.cjs +23 -8
  74. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  75. package/gsd-core/bin/lib/uat.cjs +1419 -515
  76. package/gsd-core/bin/lib/update-context.cjs +6 -2
  77. package/gsd-core/bin/lib/validate.cjs +230 -12
  78. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  79. package/gsd-core/bin/lib/verification.cjs +273 -12
  80. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  81. package/gsd-core/bin/lib/verify.cjs +346 -16
  82. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
  84. package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
  85. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  86. package/gsd-core/references/agent-contracts.md +3 -3
  87. package/gsd-core/references/edge-probe.md +17 -13
  88. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  89. package/gsd-core/references/execute-phase-response-language.md +6 -0
  90. package/gsd-core/references/executor-examples.md +42 -0
  91. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  92. package/gsd-core/references/mvp-concepts.md +2 -2
  93. package/gsd-core/references/plan-checker-examples.md +41 -0
  94. package/gsd-core/references/planner-antipatterns.md +25 -0
  95. package/gsd-core/references/planner-chunked.md +5 -1
  96. package/gsd-core/references/planner-coupling.md +42 -0
  97. package/gsd-core/references/planner-quick-batch.md +71 -0
  98. package/gsd-core/references/planner-reviews.md +47 -0
  99. package/gsd-core/references/planner-revision.md +75 -2
  100. package/gsd-core/references/planning-config.md +2 -1
  101. package/gsd-core/references/response-language-directive.md +9 -0
  102. package/gsd-core/references/revision-loop.md +118 -11
  103. package/gsd-core/references/tdd.md +14 -9
  104. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  105. package/gsd-core/templates/phase-prompt.md +4 -0
  106. package/gsd-core/templates/verification-report.md +5 -0
  107. package/gsd-core/workflows/add-backlog.md +2 -0
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +1 -1
  110. package/gsd-core/workflows/add-todo.md +1 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  112. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  113. package/gsd-core/workflows/audit-fix.md +2 -0
  114. package/gsd-core/workflows/audit-milestone.md +2 -0
  115. package/gsd-core/workflows/audit-uat.md +2 -0
  116. package/gsd-core/workflows/autonomous.md +2 -0
  117. package/gsd-core/workflows/check-todos.md +1 -1
  118. package/gsd-core/workflows/cleanup.md +1 -1
  119. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
  120. package/gsd-core/workflows/code-review-fix.md +2 -0
  121. package/gsd-core/workflows/code-review.md +73 -31
  122. package/gsd-core/workflows/complete-milestone.md +13 -4
  123. package/gsd-core/workflows/debug.md +1 -1
  124. package/gsd-core/workflows/diagnose-issues.md +5 -1
  125. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  126. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  127. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  128. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  129. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  130. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  131. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  132. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  133. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  134. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  135. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  136. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  137. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  138. package/gsd-core/workflows/discuss-phase.md +1 -1
  139. package/gsd-core/workflows/do.md +43 -13
  140. package/gsd-core/workflows/docs-update.md +1 -1
  141. package/gsd-core/workflows/edit-phase.md +2 -0
  142. package/gsd-core/workflows/eval-review.md +1 -1
  143. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
  144. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
  145. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  146. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  147. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  148. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  149. package/gsd-core/workflows/execute-phase.md +32 -14
  150. package/gsd-core/workflows/execute-plan.md +8 -8
  151. package/gsd-core/workflows/explore.md +2 -0
  152. package/gsd-core/workflows/extract-learnings.md +2 -0
  153. package/gsd-core/workflows/fast.md +6 -0
  154. package/gsd-core/workflows/forensics.md +2 -0
  155. package/gsd-core/workflows/graduation.md +1 -1
  156. package/gsd-core/workflows/health.md +1 -1
  157. package/gsd-core/workflows/help/modes/brief.md +2 -0
  158. package/gsd-core/workflows/help/modes/default.md +2 -0
  159. package/gsd-core/workflows/help/modes/full.md +12 -0
  160. package/gsd-core/workflows/help/modes/topic.md +2 -0
  161. package/gsd-core/workflows/help.md +2 -0
  162. package/gsd-core/workflows/import.md +3 -3
  163. package/gsd-core/workflows/inbox.md +1 -1
  164. package/gsd-core/workflows/ingest-docs.md +1 -1
  165. package/gsd-core/workflows/insert-phase.md +2 -0
  166. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  167. package/gsd-core/workflows/list-seeds.md +2 -0
  168. package/gsd-core/workflows/list-workspaces.md +2 -0
  169. package/gsd-core/workflows/manager.md +3 -3
  170. package/gsd-core/workflows/map-codebase.md +2 -0
  171. package/gsd-core/workflows/milestone-summary.md +2 -0
  172. package/gsd-core/workflows/mvp-phase.md +1 -1
  173. package/gsd-core/workflows/new-milestone.md +1 -1
  174. package/gsd-core/workflows/new-project.md +5 -3
  175. package/gsd-core/workflows/new-workspace.md +1 -1
  176. package/gsd-core/workflows/next.md +2 -0
  177. package/gsd-core/workflows/node-repair.md +2 -0
  178. package/gsd-core/workflows/note.md +2 -0
  179. package/gsd-core/workflows/onboard.md +1 -1
  180. package/gsd-core/workflows/pause-work.md +19 -4
  181. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  182. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  183. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  184. package/gsd-core/workflows/plan-phase.md +130 -12
  185. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  186. package/gsd-core/workflows/plant-seed.md +1 -1
  187. package/gsd-core/workflows/pr-branch.md +11 -3
  188. package/gsd-core/workflows/profile-user.md +1 -1
  189. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  190. package/gsd-core/workflows/progress.md +25 -3
  191. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  192. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  193. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  194. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  195. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  196. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  197. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  198. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  199. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  200. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  201. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  202. package/gsd-core/workflows/quick-batch.md +203 -0
  203. package/gsd-core/workflows/quick.md +13 -3
  204. package/gsd-core/workflows/reapply-patches.md +2 -0
  205. package/gsd-core/workflows/remove-phase.md +2 -0
  206. package/gsd-core/workflows/remove-workspace.md +1 -1
  207. package/gsd-core/workflows/resume-project.md +6 -2
  208. package/gsd-core/workflows/review.md +215 -10
  209. package/gsd-core/workflows/scan.md +2 -0
  210. package/gsd-core/workflows/section-manifest.json +12 -0
  211. package/gsd-core/workflows/secure-phase.md +1 -1
  212. package/gsd-core/workflows/session-report.md +2 -0
  213. package/gsd-core/workflows/settings-advanced.md +2 -0
  214. package/gsd-core/workflows/settings-integrations.md +9 -8
  215. package/gsd-core/workflows/settings.md +1 -1
  216. package/gsd-core/workflows/ship.md +10 -10
  217. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  218. package/gsd-core/workflows/sketch.md +1 -1
  219. package/gsd-core/workflows/smart-entry.md +1 -1
  220. package/gsd-core/workflows/spec-phase.md +24 -19
  221. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  222. package/gsd-core/workflows/spike.md +1 -1
  223. package/gsd-core/workflows/stats.md +2 -0
  224. package/gsd-core/workflows/sync-skills.md +12 -4
  225. package/gsd-core/workflows/thread.md +2 -0
  226. package/gsd-core/workflows/transition.md +2 -0
  227. package/gsd-core/workflows/ui-phase.md +26 -5
  228. package/gsd-core/workflows/ui-review.md +1 -1
  229. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  230. package/gsd-core/workflows/undo.md +1 -1
  231. package/gsd-core/workflows/update.md +41 -38
  232. package/gsd-core/workflows/validate-phase.md +1 -1
  233. package/gsd-core/workflows/verify-work.md +49 -3
  234. package/hooks/dist/gsd-check-update-worker.js +19 -2
  235. package/hooks/dist/gsd-context-monitor.js +283 -12
  236. package/hooks/dist/gsd-node-runner.sh +1 -0
  237. package/hooks/dist/gsd-prompt-guard.js +30 -5
  238. package/hooks/dist/gsd-read-guard.js +2 -0
  239. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  240. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  241. package/hooks/dist/gsd-statusline.js +7 -3
  242. package/hooks/dist/gsd-validate-commit.sh +444 -7
  243. package/hooks/dist/gsd-workflow-guard.js +2 -1
  244. package/hooks/dist/lib/git-cmd.js +210 -1
  245. package/hooks/dist/lib/injection-patterns.js +36 -6
  246. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  247. package/hooks/gsd-check-update-worker.js +19 -2
  248. package/hooks/gsd-context-monitor.js +283 -12
  249. package/hooks/gsd-node-runner.sh +1 -0
  250. package/hooks/gsd-prompt-guard.js +30 -5
  251. package/hooks/gsd-read-guard.js +2 -0
  252. package/hooks/gsd-read-injection-scanner.js +5 -5
  253. package/hooks/gsd-secret-read-guard.js +1079 -0
  254. package/hooks/gsd-statusline.js +7 -3
  255. package/hooks/gsd-validate-commit.sh +444 -7
  256. package/hooks/gsd-workflow-guard.js +2 -1
  257. package/hooks/hooks.json +6 -0
  258. package/hooks/lib/git-cmd.js +210 -1
  259. package/hooks/lib/injection-patterns.js +36 -6
  260. package/hooks/managed-hooks-registry.cjs +1 -0
  261. package/package.json +5 -5
  262. package/scripts/build-hooks.js +11 -4
  263. package/scripts/ci-test-scope.cjs +7 -0
  264. package/scripts/docs-guard-registry.cjs +10 -0
  265. package/scripts/gen-loop-host-contract.cjs +67 -15
  266. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  267. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  268. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  269. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  270. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
  271. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  272. package/scripts/lint-phase-id-drift.cjs +133 -8
  273. package/scripts/lint-portable-grep.cjs +176 -0
  274. package/scripts/lint-response-language-coverage.cjs +524 -0
  275. package/scripts/lint-test-file-count.allowlist.json +3 -1
  276. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  277. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  278. package/scripts/npm-audit-baseline.cjs +376 -0
  279. package/scripts/prompt-injection-scan.sh +8 -0
  280. package/scripts/require-issue-link-policy.cjs +16 -1
  281. package/skills/gsd-execute-phase/SKILL.md +1 -1
  282. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  283. package/skills/gsd-phase/SKILL.md +1 -1
  284. package/skills/gsd-quick-batch/SKILL.md +105 -0
  285. package/skills/gsd-surface/SKILL.md +18 -8
  286. package/vscode/package.json +1 -1
@@ -32,6 +32,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
32
32
  };
33
33
  const node_fs_1 = __importDefault(require("node:fs"));
34
34
  const node_path_1 = __importDefault(require("node:path"));
35
+ const node_crypto_1 = __importDefault(require("node:crypto"));
36
+ const project_root_cjs_1 = require("./project-root.cjs");
35
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
36
38
  const io = require("./io.cjs");
37
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
@@ -131,10 +133,180 @@ function projectNextCommand(bare, runtime, tail = '') {
131
133
  return '';
132
134
  return `${(0, runtime_slash_cjs_1.formatGsdSlash)(bare, runtime)}${tail}`;
133
135
  }
136
+ /**
137
+ * Real `node:fs`-backed default satisfying FsLike. Every method wraps a call
138
+ * to `fs.<method>` rather than capturing the function reference — existing
139
+ * tests mock individual `fs` methods in place (`t.mock.method(fs, 'statSync', …)`),
140
+ * and a captured reference taken at module-load time would be invisible to
141
+ * that late mock, silently un-mocking this seam's "default" path.
142
+ */
143
+ const defaultFsImpl = {
144
+ readdirSync: (dir) => node_fs_1.default.readdirSync(dir),
145
+ readFileSync: (filePath, encoding) => node_fs_1.default.readFileSync(filePath, encoding),
146
+ statSync: (filePath) => node_fs_1.default.statSync(filePath),
147
+ };
134
148
  /** Normalize separators to posix (git emits `/`; callers may pass `\` on Windows). */
135
149
  function toPosix(p) {
136
150
  return p.replace(/\\/g, '/');
137
151
  }
152
+ /**
153
+ * #4155: canonicalize a covered-input path before it becomes either a
154
+ * dedup/sort/hash key or a confinement-check subject. `path.posix.normalize`
155
+ * collapses `./`, redundant slashes, and internal `..` segments (`a/../../b`
156
+ * → `../b`) — without this, two spellings of the SAME file (`src/x.cts` vs
157
+ * `./src/x.cts`) hash as different covered inputs (spurious `stale`, or a
158
+ * file double-counted into the digest under two keys), and an escape
159
+ * disguised by an internal `..` segment slips past a check that only looks
160
+ * at the string's start.
161
+ */
162
+ function normalizeRel(p) {
163
+ return node_path_1.default.posix.normalize(toPosix(p));
164
+ }
165
+ /** Canonicalize a covered-files list: normalize, de-duplicate, sort — the SAME
166
+ * transform computeCoveredDigest and cmdVerificationFingerprint both need
167
+ * (the digest's own key order; the CLI's own `covered_files` JSON output). */
168
+ function canonicalizeCoveredFiles(files) {
169
+ return Array.from(new Set(files.map(normalizeRel))).sort();
170
+ }
171
+ // ─── #4155: covered-input fingerprint ──────────────────────────────────────────
172
+ /**
173
+ * Bump on any change to the digest's input shape (path list, hashing order,
174
+ * per-file hash algorithm) so an old stored digest can never collide with a
175
+ * differently-computed new one — a version mismatch is just a mismatch.
176
+ */
177
+ const FINGERPRINT_VERSION = 1;
178
+ /**
179
+ * #4155: recompute the deterministic content fingerprint over a verifier's
180
+ * declared covered-input set (phase PLAN/SUMMARY, mapped requirements,
181
+ * implementation files in the change set) and return the versioned digest
182
+ * string, or `null` if the set cannot be resolved.
183
+ *
184
+ * Determinism: paths are de-duplicated and SORTED before hashing (directory
185
+ * enumeration order is irrelevant), each path is resolved relative to
186
+ * `projectRoot` (the absolute checkout path never enters the digest), and
187
+ * file BYTES are hashed (mtime never enters the digest).
188
+ *
189
+ * NOT normalized: line endings. Unlike the report-frontmatter read (which
190
+ * runs every VERIFICATION.md through `normalizeLineEndings`), covered-file
191
+ * bytes are hashed exactly as they sit on disk. A covered text file checked
192
+ * out with CRLF line endings (e.g. a Windows checkout without a `.gitattributes
193
+ * eol=lf` rule pinning it to LF) hashes differently than the same file on an
194
+ * LF checkout — a real cross-platform digest mismatch, not a bug, since GSD
195
+ * installs into arbitrary user projects with no guaranteed line-ending policy.
196
+ *
197
+
198
+ * Fail closed: a covered path that is empty, absolute, escapes
199
+ * `projectRoot` (`..` traversal), or cannot be read (missing, unreadable,
200
+ * not a regular file) makes the WHOLE fingerprint unresolvable — returns
201
+ * `null` — rather than silently hashing a partial set. Callers treat `null`
202
+ * as stale (#4155), the same fail-closed shape #3057 B3 established for the
203
+ * legacy mtime staleness check.
204
+ *
205
+ * Always reads through the REAL `node:fs`, never a caller-injected `FsLike`
206
+ * seam — same reasoning as the root canonicalization below, extended to
207
+ * every covered file: `covered_files` is expected to span the whole
208
+ * `projectRoot` (implementation files under `src/`, not just `.planning/`
209
+ * artifacts), so a caller-scoped containment wrapper narrower than
210
+ * `projectRoot` (e.g. `planning-inspect.cts`'s `containmentEnforcingVerificationFs`,
211
+ * confined to `.planning/`) would reject every implementation-file read and
212
+ * report EVERY fingerprinted phase permanently `stale` regardless of actual
213
+ * drift — the bug this comment now documents against regressing. The
214
+ * `realRel`-vs-`realRoot` re-check a few lines below already does the real
215
+ * confinement work (against `projectRoot`, the correct boundary for this
216
+ * data), so no security property is lost by bypassing a narrower seam here.
217
+ */
218
+ function computeCoveredDigest(projectRoot, coveredFiles) {
219
+ const uniqueSorted = canonicalizeCoveredFiles(coveredFiles);
220
+ if (uniqueSorted.length === 0)
221
+ return null;
222
+ // Canonicalize the root ONCE — every candidate's realpath is checked against
223
+ // this, not the possibly-symlinked `projectRoot` argument itself. Always via
224
+ // the REAL fs, never fsImpl: `projectRoot` is a trusted anchor the CALLER
225
+ // derived (findProjectRoot), not attacker-influenced covered-input data —
226
+ // routing it through a caller-scoped containment seam (e.g. #4155's
227
+ // containmentEnforcingVerificationFs, confined to `.planning/`, a proper
228
+ // SUBSET of `projectRoot`) would reject the root itself and fail every
229
+ // lookup regardless of whether the covered files are legitimate.
230
+ let realRoot;
231
+ try {
232
+ realRoot = node_fs_1.default.realpathSync(projectRoot);
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ const parts = [];
238
+ for (const rel of uniqueSorted) {
239
+ // `normalizeRel` (already applied by `canonicalizeCoveredFiles` above)
240
+ // collapses internal `..` segments before `rel` ever reaches here
241
+ // (`a/../../b` → `../b`), so this start-of-string check is already the
242
+ // full lexical confinement test — no separate post-`path.resolve`
243
+ // re-check can observe a different answer.
244
+ if (rel === '' || rel === '..' || rel.startsWith('../') || node_path_1.default.isAbsolute(rel))
245
+ return null;
246
+ const resolved = node_path_1.default.resolve(projectRoot, rel);
247
+ let bytes;
248
+ try {
249
+ // A regular file INSIDE projectRoot can still be a symlink whose TARGET
250
+ // escapes it — statSync/readFileSync follow symlinks, so the lexical
251
+ // confinement check above is not enough. realpathSync resolves the
252
+ // actual target; re-confining against realRoot closes that gap.
253
+ const real = node_fs_1.default.realpathSync(resolved);
254
+ const realRel = node_path_1.default.relative(realRoot, real);
255
+ if (realRel === '' || realRel === '..' || realRel.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(realRel)) {
256
+ return null;
257
+ }
258
+ const st = node_fs_1.default.statSync(real);
259
+ if (!st.isFile())
260
+ return null;
261
+ bytes = node_fs_1.default.readFileSync(real);
262
+ }
263
+ catch {
264
+ return null;
265
+ }
266
+ const fileHash = node_crypto_1.default.createHash('sha256').update(bytes).digest('hex');
267
+ parts.push(`${rel}\n${fileHash}\n`);
268
+ }
269
+ const aggregate = node_crypto_1.default
270
+ .createHash('sha256')
271
+ .update(`v${FINGERPRINT_VERSION}\n${parts.join('')}`, 'utf-8')
272
+ .digest('hex');
273
+ return `v${FINGERPRINT_VERSION}:sha256:${aggregate}`;
274
+ }
275
+ /**
276
+ * #4155: the content fingerprint only recomputes digests for paths the
277
+ * verifier actually DECLARED in `covered_files` — it has no way to notice a
278
+ * plan or summary added to the phase directory AFTER verification if that
279
+ * new file was never declared. This closes that gap the same way the
280
+ * legacy mtime check always did: by re-scanning the LIVE directory (not the
281
+ * declared list) for every current `*-PLAN.md`/`*-SUMMARY.md` and checking
282
+ * each is represented in `coveredFiles` — matched by suffix (mirrors
283
+ * `matchRequestedFile`'s convention) since `coveredFiles` holds
284
+ * project-root-relative paths while the scan returns phase-relative
285
+ * filenames. Returns `true` if every current plan/summary is covered,
286
+ * `false` otherwise — callers only ever branch on this pass/fail, so no
287
+ * caller needs which artifact was uncovered.
288
+ *
289
+ * Fails CLOSED on an incomplete scan: `scanPhasePlans` never throws on a
290
+ * readdir failure — it reports it via `scope` (`SCOPE.UNREADABLE` for the
291
+ * phase dir itself, `SCOPE.TRUNCATED` for an unreadable nested `plans/`)
292
+ * with whatever files it DID manage to enumerate, per `SCOPE`'s own
293
+ * contract (`planning-scope.cts`): zero items under a non-`COMPLETE` scope
294
+ * is a NON-answer, never "this phase has no plans." Branching on `scope`
295
+ * here (rather than a try/catch, which this scan never triggers) is what
296
+ * makes an unreadable `plans/` dir report `false` instead of silently
297
+ * treating its invisible contents as vacuously covered — the same
298
+ * fail-open regression #3057 B3 fixed for the legacy path.
299
+ */
300
+ function allCurrentArtifactsCovered(phaseDir, coveredFiles) {
301
+ const scan = scanPhasePlans(phaseDir);
302
+ if (scan.scope !== SCOPE.COMPLETE)
303
+ return false;
304
+ const coveredPosix = canonicalizeCoveredFiles(coveredFiles);
305
+ return [...scan.allPlanFiles, ...scan.summaryFiles].every((artifact) => {
306
+ const artifactPosix = toPosix(artifact);
307
+ return coveredPosix.some((c) => c === artifactPosix || c.endsWith(`/${artifactPosix}`));
308
+ });
309
+ }
138
310
  /**
139
311
  * Match a git-emitted (repo-root-relative) path back to the caller's
140
312
  * phaseDir-relative request by exact match or `/`-bounded suffix — precise
@@ -356,7 +528,7 @@ function resolveVerificationFile(entries, options = {}) {
356
528
  function resolveUatFile(entries, options = {}) {
357
529
  return resolvePhaseArtifactFile(entries, 'UAT.md', options);
358
530
  }
359
- function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default, phaseCleanCommitTimesMs = defaultPhaseCleanCommitTimesMs) {
531
+ function findStaleVerificationSummary(phaseDir, fsImpl = defaultFsImpl, phaseCleanCommitTimesMs = defaultPhaseCleanCommitTimesMs) {
360
532
  // FS errors (TOCTOU: a SUMMARY listed by scanPhasePlans then removed before statSync;
361
533
  // unreadable dir; broken symlink; file->dir swap) must degrade rather than throw
362
534
  // uncaught into callers that are NOT under the planning lock (init.manager /
@@ -433,7 +605,7 @@ function findStaleVerificationSummary(phaseDir, fsImpl = node_fs_1.default, phas
433
605
  * projected into (#2617).
434
606
  */
435
607
  function readVerificationStatus(phaseDir, opts = {}) {
436
- const fsImpl = opts.fs ?? node_fs_1.default;
608
+ const fsImpl = opts.fs ?? defaultFsImpl;
437
609
  const phaseCleanCommitTimesMs = opts.phaseCleanCommitTimesMs ?? defaultPhaseCleanCommitTimesMs;
438
610
  const runtime = opts.runtime ?? 'claude';
439
611
  // Phase token for the gaps_found command
@@ -470,6 +642,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
470
642
  // extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
471
643
  const filePath = node_path_1.default.join(phaseDir, verificationFile);
472
644
  let rawStatus = null;
645
+ let fm = {};
473
646
  try {
474
647
  // #3707-CR follow-up MINOR 1: normalize line endings at this read
475
648
  // boundary — this function's own `readFileSync` is the equivalent seam
@@ -482,7 +655,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
482
655
  // verification as if the step never ran, the fail-safe direction but the
483
656
  // same root cause as the false-clean class fixed elsewhere in #3707-CR.
484
657
  const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
485
- const fm = extractFrontmatter(content, filePath);
658
+ fm = extractFrontmatter(content, filePath);
486
659
  const statusVal = fm['status'];
487
660
  // status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
488
661
  // only accept string values — arrays and objects are not valid status values.
@@ -507,8 +680,52 @@ function readVerificationStatus(phaseDir, opts = {}) {
507
680
  next_command: projectNextCommand('plan-phase', runtime, `${phaseArg} --gaps`),
508
681
  };
509
682
  }
510
- const staleCheck = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs);
511
- if (staleCheck.determined && staleCheck.stale) {
683
+ // #4155: a report that declares a covered-input fingerprint is checked by
684
+ // RECOMPUTING that fingerprint over current file content — strictly
685
+ // content-grounded, and it REPLACES (not supplements) the legacy
686
+ // SUMMARY-mtime check below for that report. A report with no fingerprint
687
+ // metadata (every report written before #4155) keeps the exact legacy
688
+ // mtime-based behavior, unchanged.
689
+ const coveredFilesVal = fm['covered_files'];
690
+ const coveredDigestVal = fm['covered_digest'];
691
+ // A report OPTS IN to the fingerprint check by declaring EITHER field —
692
+ // once opted in, an incomplete or malformed pair (one field present but
693
+ // not the other, an empty array, a non-array, a blank digest) fails closed
694
+ // to `stale` rather than silently downgrading to the weaker legacy
695
+ // mtime-only check, which would only ever notice a newer SUMMARY.
696
+ const declaresFingerprint = coveredFilesVal !== undefined || coveredDigestVal !== undefined;
697
+ const hasWellFormedFingerprint = Array.isArray(coveredFilesVal) &&
698
+ coveredFilesVal.length > 0 &&
699
+ coveredFilesVal.every((f) => typeof f === 'string') &&
700
+ typeof coveredDigestVal === 'string' &&
701
+ coveredDigestVal.trim().length > 0;
702
+ let staleCheckIndeterminate = false;
703
+ let isStale;
704
+ if (declaresFingerprint) {
705
+ // Stated directly rather than relying on `null !== coveredDigestVal`
706
+ // being true whenever the pair is malformed: `!hasWellFormedFingerprint`
707
+ // fails closed explicitly, and its `||` short-circuit means
708
+ // computeCoveredDigest/allCurrentArtifactsCovered never run on a
709
+ // malformed (wrong-shaped) `coveredFilesVal`. The two `||`s after it
710
+ // short-circuit in turn: the live-directory re-scan (for a plan/summary
711
+ // added AFTER verification and never declared in covered_files) only
712
+ // runs once the digest itself has already matched.
713
+ isStale =
714
+ !hasWellFormedFingerprint ||
715
+ computeCoveredDigest((0, project_root_cjs_1.findProjectRoot)(phaseDir), coveredFilesVal) !== coveredDigestVal ||
716
+ !allCurrentArtifactsCovered(phaseDir, coveredFilesVal);
717
+ }
718
+ else {
719
+ const staleCheck = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs);
720
+ isStale = staleCheck.determined && staleCheck.stale;
721
+ // staleCheck is either {determined:true, stale:false} (checked; nothing
722
+ // stale) or {determined:false} (could not check — fs/scan/clock failure).
723
+ // Both fall through to normal routing below (the pre-existing no-throw
724
+ // fail-open contract is unchanged), but the indeterminate case is flagged
725
+ // on the returned result so a caller can tell the two apart (#3057 B3).
726
+ staleCheckIndeterminate = !staleCheck.determined;
727
+ }
728
+ if (isStale) {
512
729
  const entry = VERIFICATION_ROUTING_TABLE['stale'];
513
730
  return {
514
731
  status: entry.status,
@@ -516,12 +733,6 @@ function readVerificationStatus(phaseDir, opts = {}) {
516
733
  next_command: projectNextCommand('verify-work', runtime, phaseArg),
517
734
  };
518
735
  }
519
- // staleCheck is either {determined:true, stale:false} (checked; nothing
520
- // stale) or {determined:false} (could not check — fs/scan/clock failure).
521
- // Both fall through to normal routing below (the pre-existing no-throw
522
- // fail-open contract is unchanged), but the indeterminate case is flagged
523
- // on the returned result so a caller can tell the two apart (#3057 B3).
524
- const staleCheckIndeterminate = !staleCheck.determined;
525
736
  // 3. Route — exclude internal sentinels from raw-file lookup (they are
526
737
  // constructed internally above, never written by the verifier).
527
738
  if (rawStatus in VERIFICATION_ROUTING_TABLE &&
@@ -576,7 +787,7 @@ function readVerificationStatus(phaseDir, opts = {}) {
576
787
  * never re-derives or requires them itself.
577
788
  */
578
789
  function isPhaseComplete(phaseDir, deps = {}) {
579
- const fsImpl = deps.fs ?? node_fs_1.default;
790
+ const fsImpl = deps.fs ?? defaultFsImpl;
580
791
  let readable = true;
581
792
  try {
582
793
  fsImpl.readdirSync(phaseDir);
@@ -655,6 +866,54 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
655
866
  }
656
867
  output({ verification_file: verificationPath }, raw, verificationPath);
657
868
  }
869
+ /**
870
+ * CLI command handler (#4155): compute the covered-input fingerprint the
871
+ * verifier embeds in VERIFICATION.md frontmatter (`covered_files`,
872
+ * `covered_digest`). The verifier is an LLM agent, not a hashing engine —
873
+ * this command does the deterministic math so the agent only has to name
874
+ * the covered paths and copy the result into frontmatter.
875
+ *
876
+ * Emits `{ covered_files: <sorted deduped paths>, covered_digest: <digest> }`
877
+ * on success. A covered path that is missing, unreadable, or escapes the
878
+ * project root fails the WHOLE command (fail closed — a partial fingerprint
879
+ * would be worse than none): `error()` is called and nothing is emitted.
880
+ *
881
+ * @param cwd - Current working directory.
882
+ * @param phaseDirArg - Phase directory path (absolute or relative to cwd);
883
+ * its project root is the base covered paths resolve against.
884
+ * @param files - Covered-input paths, relative to the project root.
885
+ * @param raw - Whether to emit raw (non-JSON) output: just the
886
+ * `covered_digest` string, so `VAR=$(gsd_run query
887
+ * verification.fingerprint "$PHASE_DIR" ... --raw)` is
888
+ * directly assignable. `covered_files` is unambiguous
889
+ * from the caller's own input list in that mode, so
890
+ * only the computed digest needs a raw form.
891
+ */
892
+ function cmdVerificationFingerprint(cwd, phaseDirArg, files, raw) {
893
+ if (!phaseDirArg) {
894
+ error('phase directory required for verification.fingerprint');
895
+ return;
896
+ }
897
+ if (files.length === 0) {
898
+ error('at least one covered file required for verification.fingerprint');
899
+ return;
900
+ }
901
+ const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
902
+ const projectRoot = (0, project_root_cjs_1.findProjectRoot)(phaseDir);
903
+ // canonicalizeCoveredFiles here is for the emitted `covered_files` field —
904
+ // computeCoveredDigest canonicalizes its own `coveredFiles` argument
905
+ // internally too (it must, for callers like readVerificationStatus that
906
+ // pass raw, un-canonicalized frontmatter values), so passing an
907
+ // already-canonical list keeps that internal pass a cheap no-op rather
908
+ // than a second meaningfully different canonicalization.
909
+ const uniqueSorted = canonicalizeCoveredFiles(files);
910
+ const digest = computeCoveredDigest(projectRoot, uniqueSorted);
911
+ if (digest === null) {
912
+ error('could not compute fingerprint — a covered file is missing, unreadable, or escapes the project root');
913
+ return;
914
+ }
915
+ output({ covered_files: uniqueSorted, covered_digest: digest }, raw, digest);
916
+ }
658
917
  module.exports = {
659
918
  VERIFIER_STATUSES,
660
919
  VERIFICATION_ROUTING_TABLE,
@@ -666,4 +925,6 @@ module.exports = {
666
925
  isPhaseComplete,
667
926
  cmdVerificationStatus,
668
927
  cmdVerificationResolveFile,
928
+ computeCoveredDigest,
929
+ cmdVerificationFingerprint,
669
930
  };
@@ -36,6 +36,7 @@ function routeVerifyCommand({ verify, args, cwd, raw, error }) {
36
36
  // per ADR/PRD 3524 §3 / L160 (CJS-only by design). Routing through
37
37
  // recursive dispatch would re-enter this router path.
38
38
  'codebase-drift': () => verify.cmdVerifyCodebaseDrift(cwd, raw),
39
+ 'context-drift': () => verify.cmdVerifyContextDrift(cwd, args[2], raw),
39
40
  },
40
41
  });
41
42
  }