@opengsd/gsd-core 1.10.0 → 1.12.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 (544) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.md +1 -1
  4. package/agents/gsd-debug-session-manager.md +12 -1
  5. package/agents/gsd-debugger.md +1 -1
  6. package/agents/gsd-doc-synthesizer.md +2 -4
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +22 -14
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +5 -3
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +23 -2
  15. package/agents/gsd-plan-checker.md +50 -53
  16. package/agents/gsd-planner.md +50 -50
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +63 -4
  21. package/agents/gsd-ui-researcher.md +41 -3
  22. package/agents/gsd-user-profiler.md +3 -0
  23. package/agents/gsd-verifier.md +13 -4
  24. package/bin/install.js +1448 -1103
  25. package/commands/gsd/code-review.md +1 -1
  26. package/commands/gsd/discuss-phase.md +1 -1
  27. package/commands/gsd/execute-phase.md +1 -1
  28. package/commands/gsd/import.md +1 -1
  29. package/commands/gsd/map-codebase.md +1 -1
  30. package/commands/gsd/mempalace-capture.md +1 -1
  31. package/commands/gsd/mempalace-recall.md +1 -1
  32. package/commands/gsd/new-milestone.md +1 -1
  33. package/commands/gsd/quick.md +9 -5
  34. package/commands/gsd/review-backlog.md +2 -1
  35. package/commands/gsd/verify-work.md +1 -1
  36. package/gsd-core/bin/gsd-tools.cjs +1035 -138
  37. package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
  38. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  39. package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
  40. package/gsd-core/bin/lib/api-coverage.cjs +33 -14
  41. package/gsd-core/bin/lib/artifacts.cjs +5 -0
  42. package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
  43. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  44. package/gsd-core/bin/lib/audit.cjs +1026 -268
  45. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  46. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  47. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  49. package/gsd-core/bin/lib/capability-registry.cjs +845 -130
  50. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  51. package/gsd-core/bin/lib/capability-state.cjs +18 -3
  52. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  53. package/gsd-core/bin/lib/capability-validator.cjs +700 -40
  54. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  55. package/gsd-core/bin/lib/check-command-router.cjs +216 -42
  56. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  57. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  58. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  59. package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
  60. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  61. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  62. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  63. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  64. package/gsd-core/bin/lib/commands.cjs +1172 -108
  65. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  66. package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
  67. package/gsd-core/bin/lib/config-loader.cjs +187 -23
  68. package/gsd-core/bin/lib/config.cjs +102 -3
  69. package/gsd-core/bin/lib/configuration.cjs +129 -37
  70. package/gsd-core/bin/lib/core-utils.cjs +208 -33
  71. package/gsd-core/bin/lib/decisions.cjs +23 -0
  72. package/gsd-core/bin/lib/edge-probe.cjs +9 -1
  73. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  74. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  75. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  76. package/gsd-core/bin/lib/frontmatter.cjs +899 -229
  77. package/gsd-core/bin/lib/gap-checker.cjs +95 -10
  78. package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
  79. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  80. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  81. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  82. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
  83. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  84. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  85. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  86. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
  87. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  88. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  89. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
  90. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  91. package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
  92. package/gsd-core/bin/lib/host-integration.cjs +39 -6
  93. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  94. package/gsd-core/bin/lib/init-command-router.cjs +118 -21
  95. package/gsd-core/bin/lib/init.cjs +439 -168
  96. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  97. package/gsd-core/bin/lib/install-engine.cjs +811 -259
  98. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  99. package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
  100. package/gsd-core/bin/lib/install-profiles.cjs +212 -61
  101. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  102. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  103. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  104. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  105. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  106. package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
  107. package/gsd-core/bin/lib/intel.cjs +101 -26
  108. package/gsd-core/bin/lib/io.cjs +170 -15
  109. package/gsd-core/bin/lib/learnings.cjs +85 -14
  110. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  111. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  112. package/gsd-core/bin/lib/markdown-table.cjs +183 -22
  113. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  114. package/gsd-core/bin/lib/milestone.cjs +842 -73
  115. package/gsd-core/bin/lib/model-catalog.cjs +232 -16
  116. package/gsd-core/bin/lib/model-resolver.cjs +193 -68
  117. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  118. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  119. package/gsd-core/bin/lib/pattern.cjs +122 -0
  120. package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
  121. package/gsd-core/bin/lib/phase-id.cjs +514 -40
  122. package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
  123. package/gsd-core/bin/lib/phase-locator.cjs +262 -34
  124. package/gsd-core/bin/lib/phase.cjs +1038 -214
  125. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  126. package/gsd-core/bin/lib/plan-document.cjs +263 -0
  127. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  128. package/gsd-core/bin/lib/plan-scan.cjs +98 -3
  129. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  130. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  131. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  132. package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
  133. package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
  134. package/gsd-core/bin/lib/probe-core.cjs +5 -2
  135. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  136. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  137. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  138. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  139. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
  140. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  141. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
  142. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  143. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  144. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  145. package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
  146. package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
  147. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  148. package/gsd-core/bin/lib/roadmap.cjs +442 -96
  149. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
  150. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  151. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
  152. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  153. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  154. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
  155. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  156. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  157. package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
  158. package/gsd-core/bin/lib/security.cjs +104 -5
  159. package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
  160. package/gsd-core/bin/lib/smart-entry.cjs +133 -23
  161. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  162. package/gsd-core/bin/lib/state-command-router.cjs +52 -19
  163. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  164. package/gsd-core/bin/lib/state-document.cjs +338 -8
  165. package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
  166. package/gsd-core/bin/lib/state-transition.cjs +846 -176
  167. package/gsd-core/bin/lib/state.cjs +2589 -369
  168. package/gsd-core/bin/lib/surface.cjs +33 -11
  169. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  170. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  171. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  172. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  173. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  174. package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
  175. package/gsd-core/bin/lib/uat.cjs +1761 -167
  176. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  177. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  178. package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
  179. package/gsd-core/bin/lib/unusable-input.cjs +37 -0
  180. package/gsd-core/bin/lib/update-context.cjs +8 -2
  181. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  182. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  183. package/gsd-core/bin/lib/validate.cjs +20 -6
  184. package/gsd-core/bin/lib/vendor/README.md +75 -0
  185. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  186. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  187. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  188. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  189. package/gsd-core/bin/lib/verification.cjs +272 -9
  190. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  191. package/gsd-core/bin/lib/verify.cjs +453 -918
  192. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  193. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  194. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  195. package/gsd-core/bin/lib/workstream.cjs +2 -2
  196. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  197. package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
  198. package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
  199. package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
  200. package/gsd-core/bin/shared/exit-codes.json +8 -0
  201. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  202. package/gsd-core/bin/shared/model-catalog.json +8 -1
  203. package/gsd-core/references/agent-contracts.md +44 -26
  204. package/gsd-core/references/api-coverage.md +24 -2
  205. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  206. package/gsd-core/references/checkpoints.md +39 -21
  207. package/gsd-core/references/context-budget.md +1 -1
  208. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  209. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  210. package/gsd-core/references/doc-conflict-engine.md +1 -1
  211. package/gsd-core/references/edge-probe.md +8 -0
  212. package/gsd-core/references/execute-mvp-tdd.md +4 -6
  213. package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
  214. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  215. package/gsd-core/references/execute-phase-response-language.md +1 -1
  216. package/gsd-core/references/execute-phase-wave-guard.md +17 -11
  217. package/gsd-core/references/failing-direction.md +78 -0
  218. package/gsd-core/references/gate-prompts.md +1 -1
  219. package/gsd-core/references/git-integration.md +5 -5
  220. package/gsd-core/references/git-planning-commit.md +5 -4
  221. package/gsd-core/references/gsd-run-resolver.md +1 -1
  222. package/gsd-core/references/loop-hook-dispatch.md +61 -2
  223. package/gsd-core/references/model-profiles.md +12 -4
  224. package/gsd-core/references/mvp-concepts.md +9 -9
  225. package/gsd-core/references/nyquist-compliance.md +74 -0
  226. package/gsd-core/references/offer-next.md +3 -5
  227. package/gsd-core/references/phase-argument-parsing.md +3 -3
  228. package/gsd-core/references/planner-failing-direction.md +53 -0
  229. package/gsd-core/references/planner-guidance.md +3 -9
  230. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  231. package/gsd-core/references/planner-preconditions.md +1 -1
  232. package/gsd-core/references/planner-reviews.md +1 -1
  233. package/gsd-core/references/planner-revision.md +1 -1
  234. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  235. package/gsd-core/references/planning-config.md +44 -13
  236. package/gsd-core/references/reviewer-instances.md +31 -0
  237. package/gsd-core/references/revision-loop.md +1 -1
  238. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  239. package/gsd-core/references/specless-probe-fallback.md +1 -1
  240. package/gsd-core/references/tdd.md +1 -3
  241. package/gsd-core/references/ui-brand.md +65 -21
  242. package/gsd-core/references/ui-consideration-probe.md +1 -1
  243. package/gsd-core/references/universal-anti-patterns.md +5 -5
  244. package/gsd-core/references/verifier-phase-gates.md +192 -0
  245. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  246. package/gsd-core/references/verify-mvp-mode.md +2 -2
  247. package/gsd-core/references/workstream-flag.md +33 -17
  248. package/gsd-core/templates/README.md +1 -1
  249. package/gsd-core/templates/SECURITY.md +3 -3
  250. package/gsd-core/templates/UI-SPEC.md +25 -3
  251. package/gsd-core/templates/VALIDATION.md +3 -3
  252. package/gsd-core/templates/discussion-log.md +1 -1
  253. package/gsd-core/templates/phase-prompt.md +5 -4
  254. package/gsd-core/templates/state.md +11 -4
  255. package/gsd-core/templates/verification-report.md +9 -1
  256. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  257. package/gsd-core/workflows/add-backlog.md +1 -1
  258. package/gsd-core/workflows/add-phase.md +3 -3
  259. package/gsd-core/workflows/add-tests.md +3 -8
  260. package/gsd-core/workflows/add-todo.md +1 -1
  261. package/gsd-core/workflows/ai-integration-phase.md +13 -20
  262. package/gsd-core/workflows/audit-fix.md +12 -3
  263. package/gsd-core/workflows/audit-milestone.md +9 -9
  264. package/gsd-core/workflows/audit-uat.md +17 -2
  265. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  266. package/gsd-core/workflows/autonomous.md +11 -27
  267. package/gsd-core/workflows/check-todos.md +1 -1
  268. package/gsd-core/workflows/cleanup.md +64 -5
  269. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
  270. package/gsd-core/workflows/code-review-fix.md +38 -11
  271. package/gsd-core/workflows/code-review.md +159 -52
  272. package/gsd-core/workflows/complete-milestone.md +151 -23
  273. package/gsd-core/workflows/debug.md +12 -8
  274. package/gsd-core/workflows/diagnose-issues.md +47 -15
  275. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  276. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
  277. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  278. package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
  279. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  280. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
  281. package/gsd-core/workflows/discuss-phase.md +1 -1
  282. package/gsd-core/workflows/do.md +3 -6
  283. package/gsd-core/workflows/docs-update.md +5 -4
  284. package/gsd-core/workflows/edit-phase.md +27 -2
  285. package/gsd-core/workflows/eval-review.md +7 -14
  286. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  287. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
  288. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  289. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  290. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  291. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
  292. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  293. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  294. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
  295. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  296. package/gsd-core/workflows/execute-phase.md +72 -100
  297. package/gsd-core/workflows/execute-plan.md +52 -15
  298. package/gsd-core/workflows/explore.md +131 -4
  299. package/gsd-core/workflows/extract-learnings.md +1 -1
  300. package/gsd-core/workflows/fast.md +10 -2
  301. package/gsd-core/workflows/forensics.md +1 -1
  302. package/gsd-core/workflows/graduation.md +5 -5
  303. package/gsd-core/workflows/health.md +76 -10
  304. package/gsd-core/workflows/import.md +18 -15
  305. package/gsd-core/workflows/inbox.md +4 -5
  306. package/gsd-core/workflows/ingest-docs.md +49 -16
  307. package/gsd-core/workflows/insert-phase.md +5 -5
  308. package/gsd-core/workflows/list-seeds.md +5 -3
  309. package/gsd-core/workflows/list-workspaces.md +1 -1
  310. package/gsd-core/workflows/manager.md +12 -23
  311. package/gsd-core/workflows/map-codebase.md +1 -1
  312. package/gsd-core/workflows/milestone-summary.md +1 -1
  313. package/gsd-core/workflows/mvp-phase.md +8 -5
  314. package/gsd-core/workflows/new-milestone.md +22 -29
  315. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  316. package/gsd-core/workflows/new-project.md +26 -40
  317. package/gsd-core/workflows/new-workspace.md +1 -1
  318. package/gsd-core/workflows/next.md +14 -2
  319. package/gsd-core/workflows/pause-work.md +1 -1
  320. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  321. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
  322. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
  323. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
  324. package/gsd-core/workflows/plan-phase.md +162 -59
  325. package/gsd-core/workflows/plan-review-convergence.md +96 -11
  326. package/gsd-core/workflows/plant-seed.md +2 -2
  327. package/gsd-core/workflows/pr-branch.md +187 -51
  328. package/gsd-core/workflows/profile-user.md +16 -14
  329. package/gsd-core/workflows/progress.md +61 -18
  330. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  331. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
  332. package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
  333. package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
  334. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  335. package/gsd-core/workflows/quick.md +55 -44
  336. package/gsd-core/workflows/remove-phase.md +4 -4
  337. package/gsd-core/workflows/remove-workspace.md +2 -2
  338. package/gsd-core/workflows/resume-project.md +8 -12
  339. package/gsd-core/workflows/review.md +219 -20
  340. package/gsd-core/workflows/scan.md +1 -1
  341. package/gsd-core/workflows/secure-phase.md +3 -3
  342. package/gsd-core/workflows/session-report.md +2 -1
  343. package/gsd-core/workflows/settings-advanced.md +7 -9
  344. package/gsd-core/workflows/settings-integrations.md +64 -31
  345. package/gsd-core/workflows/settings.md +69 -7
  346. package/gsd-core/workflows/ship.md +116 -50
  347. package/gsd-core/workflows/sketch-wrap-up.md +11 -17
  348. package/gsd-core/workflows/sketch.md +12 -18
  349. package/gsd-core/workflows/smart-entry.md +3 -5
  350. package/gsd-core/workflows/spec-phase.md +53 -13
  351. package/gsd-core/workflows/spike-wrap-up.md +7 -11
  352. package/gsd-core/workflows/spike.md +20 -31
  353. package/gsd-core/workflows/stats.md +2 -2
  354. package/gsd-core/workflows/sync-skills.md +64 -9
  355. package/gsd-core/workflows/thread.md +11 -7
  356. package/gsd-core/workflows/transition.md +49 -14
  357. package/gsd-core/workflows/ui-phase.md +15 -21
  358. package/gsd-core/workflows/ui-review.md +8 -12
  359. package/gsd-core/workflows/ultraplan-phase.md +5 -13
  360. package/gsd-core/workflows/undo.md +8 -16
  361. package/gsd-core/workflows/update.md +7 -11
  362. package/gsd-core/workflows/validate-phase.md +3 -3
  363. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  364. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  365. package/gsd-core/workflows/verify-work.md +66 -25
  366. package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
  367. package/hooks/dist/gsd-check-update-worker.js +56 -13
  368. package/hooks/dist/gsd-check-update.js +19 -1
  369. package/hooks/dist/gsd-config-reload.js +18 -12
  370. package/hooks/dist/gsd-context-monitor.js +19 -10
  371. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  372. package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
  373. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  374. package/hooks/dist/gsd-cursor-stop.js +2 -1
  375. package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
  376. package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
  377. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  378. package/hooks/dist/gsd-graphify-update.sh +22 -18
  379. package/hooks/dist/gsd-node-runner.sh +76 -0
  380. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  381. package/hooks/dist/gsd-prompt-guard.js +37 -27
  382. package/hooks/dist/gsd-read-guard.js +16 -7
  383. package/hooks/dist/gsd-read-injection-scanner.js +55 -32
  384. package/hooks/dist/gsd-session-state.sh +1 -0
  385. package/hooks/dist/gsd-statusline.js +231 -24
  386. package/hooks/dist/gsd-update-banner.js +22 -1
  387. package/hooks/dist/gsd-validate-commit.sh +80 -6
  388. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  389. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  390. package/hooks/dist/gsd-workflow-guard.js +162 -46
  391. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  392. package/hooks/dist/gsd-write-guard.js +35 -25
  393. package/hooks/dist/lib/cli-exit.js +560 -0
  394. package/hooks/dist/lib/exit-code-registry.js +98 -0
  395. package/hooks/dist/lib/git-cmd.js +92 -59
  396. package/hooks/dist/lib/git-probe.js +84 -0
  397. package/hooks/dist/lib/hook-exit.js +81 -0
  398. package/hooks/dist/lib/injection-patterns.js +45 -0
  399. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  400. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  401. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  402. package/hooks/gsd-agent-isolation-guard.js +158 -30
  403. package/hooks/gsd-check-update-worker.js +56 -13
  404. package/hooks/gsd-check-update.js +19 -1
  405. package/hooks/gsd-config-reload.js +18 -12
  406. package/hooks/gsd-context-monitor.js +19 -10
  407. package/hooks/gsd-cursor-post-tool.js +3 -1
  408. package/hooks/gsd-cursor-pre-tool.js +2 -3
  409. package/hooks/gsd-cursor-session-start.js +2 -1
  410. package/hooks/gsd-cursor-stop.js +2 -1
  411. package/hooks/gsd-cursor-subagent-start.js +83 -3
  412. package/hooks/gsd-cursor-subagent-stop.js +6 -3
  413. package/hooks/gsd-ensure-canonical-path.js +2 -1
  414. package/hooks/gsd-graphify-update.sh +22 -18
  415. package/hooks/gsd-node-runner.sh +76 -0
  416. package/hooks/gsd-phase-boundary.sh +1 -0
  417. package/hooks/gsd-prompt-guard.js +37 -27
  418. package/hooks/gsd-read-guard.js +16 -7
  419. package/hooks/gsd-read-injection-scanner.js +55 -32
  420. package/hooks/gsd-session-state.sh +1 -0
  421. package/hooks/gsd-statusline.js +231 -24
  422. package/hooks/gsd-update-banner.js +22 -1
  423. package/hooks/gsd-validate-commit.sh +80 -6
  424. package/hooks/gsd-windsurf-pre-command.js +16 -11
  425. package/hooks/gsd-windsurf-pre-write.js +22 -13
  426. package/hooks/gsd-workflow-guard.js +162 -46
  427. package/hooks/gsd-worktree-path-guard.js +36 -21
  428. package/hooks/gsd-write-guard.js +35 -25
  429. package/hooks/lib/cli-exit.js +560 -0
  430. package/hooks/lib/exit-code-registry.js +98 -0
  431. package/hooks/lib/git-cmd.js +92 -59
  432. package/hooks/lib/git-probe.js +84 -0
  433. package/hooks/lib/hook-exit.js +81 -0
  434. package/hooks/lib/injection-patterns.js +45 -0
  435. package/hooks/lib/isolation-deny-reason.js +39 -0
  436. package/hooks/lib/isolation-sentinel.js +9 -0
  437. package/hooks/managed-hooks-registry.cjs +3 -0
  438. package/package.json +28 -11
  439. package/pi/gsd.cjs +19 -5
  440. package/scripts/base64-scan.sh +74 -12
  441. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  442. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  443. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  444. package/scripts/build-hooks.js +5 -0
  445. package/scripts/changeset/lint.cjs +60 -5
  446. package/scripts/check-alias-drift.cjs +7 -43
  447. package/scripts/check-contract-drift.cjs +297 -0
  448. package/scripts/check-glossary-refs.cjs +77 -15
  449. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  450. package/scripts/ci-check-job-near-cap.cjs +49 -0
  451. package/scripts/ci-pr-mergeability.cjs +262 -0
  452. package/scripts/ci-test-scope.cjs +64 -14
  453. package/scripts/ci-timeout-report.cjs +230 -0
  454. package/scripts/command-contract-helpers.cjs +903 -1
  455. package/scripts/docs-guard-registry.cjs +396 -0
  456. package/scripts/gen-adr-index.cjs +728 -38
  457. package/scripts/gen-capability-registry.cjs +11 -21
  458. package/scripts/gen-context-index.cjs +2 -11
  459. package/scripts/gen-exit-code-docs.cjs +318 -0
  460. package/scripts/gen-exit-code-registry.cjs +891 -0
  461. package/scripts/gen-features.cjs +836 -0
  462. package/scripts/gen-health-docs.cjs +390 -0
  463. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  464. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  465. package/scripts/gen-inventory-manifest.cjs +50 -4
  466. package/scripts/gen-loop-host-contract.cjs +138 -25
  467. package/scripts/gen-registry.cjs +3 -14
  468. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  469. package/scripts/gen-state-md-docs.cjs +727 -0
  470. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  471. package/scripts/lib/alias-drift-families.cjs +46 -0
  472. package/scripts/lib/ci-job-timing.cjs +72 -0
  473. package/scripts/lib/cli-exit.cjs +546 -44
  474. package/scripts/lib/drift-scan.cjs +308 -0
  475. package/scripts/lib/exit-code-registry.cjs +98 -0
  476. package/scripts/lib/ndjson-reporter.cjs +119 -0
  477. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  478. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  479. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  480. package/scripts/lint-canary-version-leak.cjs +73 -0
  481. package/scripts/lint-command-contract.cjs +96 -13
  482. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  483. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  484. package/scripts/lint-default-flip-documentation.cjs +193 -0
  485. package/scripts/lint-docs-guard-registration.cjs +495 -0
  486. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
  487. package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
  488. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  489. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  490. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  491. package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
  492. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  493. package/scripts/lint-milestone-window-drift.cjs +468 -0
  494. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  495. package/scripts/lint-phase-enumeration-drift.cjs +492 -0
  496. package/scripts/lint-plan-count-drift.cjs +318 -0
  497. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  498. package/scripts/lint-planning-prompt-drift.cjs +471 -0
  499. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  500. package/scripts/lint-regression-test-names.cjs +15 -13
  501. package/scripts/lint-removed-but-needed.cjs +488 -0
  502. package/scripts/lint-seam-enforcement.cjs +182 -0
  503. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  504. package/scripts/lint-source-test-name-collision.cjs +241 -0
  505. package/scripts/lint-state-field-drift.cjs +805 -0
  506. package/scripts/lint-state-write-path-drift.cjs +950 -0
  507. package/scripts/lint-test-file-count.allowlist.json +137 -8
  508. package/scripts/lint-test-file-count.cjs +25 -3
  509. package/scripts/lint-unreachable-guard-drift.cjs +830 -0
  510. package/scripts/lint-vendored-deps.cjs +297 -0
  511. package/scripts/mutation-matrix.cjs +599 -50
  512. package/scripts/pr-changed-files.cjs +63 -0
  513. package/scripts/pr-template-policy.cjs +14 -4
  514. package/scripts/prompt-injection-scan.sh +100 -14
  515. package/scripts/require-issue-link-policy.cjs +192 -0
  516. package/scripts/secret-scan.sh +75 -13
  517. package/scripts/select-docs-guards.cjs +56 -0
  518. package/scripts/sync-runtime-launcher.cjs +24 -7
  519. package/skills/gsd-autonomous/SKILL.md +0 -1
  520. package/skills/gsd-code-review/SKILL.md +1 -1
  521. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  522. package/skills/gsd-execute-phase/SKILL.md +1 -2
  523. package/skills/gsd-import/SKILL.md +1 -1
  524. package/skills/gsd-map-codebase/SKILL.md +1 -1
  525. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  526. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  527. package/skills/gsd-new-milestone/SKILL.md +1 -1
  528. package/skills/gsd-next/SKILL.md +0 -1
  529. package/skills/gsd-plan-phase/SKILL.md +0 -1
  530. package/skills/gsd-progress/SKILL.md +0 -1
  531. package/skills/gsd-quick/SKILL.md +9 -5
  532. package/skills/gsd-review-backlog/SKILL.md +2 -1
  533. package/skills/gsd-stats/SKILL.md +0 -1
  534. package/skills/gsd-verify-work/SKILL.md +1 -1
  535. package/vscode/package.json +1 -1
  536. package/bin/lib/ui-safety-gate.cjs +0 -107
  537. package/gsd-core/workflows/discovery-phase.md +0 -298
  538. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  539. package/gsd-core/workflows/verify-phase.md +0 -574
  540. package/scripts/affected-tests-lib.cjs +0 -554
  541. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  542. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  543. package/scripts/run-affected-tests.cjs +0 -7
  544. package/scripts/run-tests.cjs +0 -1051
@@ -20,9 +20,9 @@
20
20
  * ---
21
21
  * # Broken Windows Ledger
22
22
  * <human-readable prose>
23
- * ```json
23
+ * ````json
24
24
  * [ <entries array, canonical JSON> ]
25
- * ```
25
+ * ````
26
26
  *
27
27
  * Frontmatter holds scalar counts (the FAST path the ship gate reads via jq
28
28
  * without parsing JSON). The JSON code block is the AUTHORITATIVE entries
@@ -56,6 +56,8 @@ exports.markWaived = markWaived;
56
56
  exports.markFixed = markFixed;
57
57
  exports.parseLedger = parseLedger;
58
58
  exports.renderLedger = renderLedger;
59
+ exports.renderTable = renderTable;
60
+ exports.extractTableRegion = extractTableRegion;
59
61
  exports.cmdWindowsStatus = cmdWindowsStatus;
60
62
  exports.cmdWindowsAppend = cmdWindowsAppend;
61
63
  exports.cmdWindowsWaive = cmdWindowsWaive;
@@ -84,6 +86,10 @@ exports.REASON = Object.freeze({
84
86
  WINDOWS_INVALID_ID: 'windows_invalid_id',
85
87
  WINDOWS_APPEND_MISSING_FIELD: 'windows_append_missing_field',
86
88
  WINDOWS_USAGE: 'windows_usage',
89
+ // #3689: the rendered markdown table disagreed with the fenced JSON (the
90
+ // sole source of truth) at the pre-write seam — refuse rather than silently
91
+ // reconcile by overwriting the operator's hand-edit or dropping a row.
92
+ WINDOWS_LEDGER_TABLE_DRIFT: 'windows_ledger_table_drift',
87
93
  });
88
94
  /** Allowed window kinds. Aligned with the issue's enumerated sources. */
89
95
  exports.KINDS = Object.freeze([
@@ -286,6 +292,79 @@ function markFixed(ledger, id, opts = { now: new Date().toISOString() }) {
286
292
  const JSON_FENCE_OPEN = '````json';
287
293
  const JSON_FENCE_CLOSE = '````';
288
294
  const FORBIDDEN_BACKTICK_RUN = '````';
295
+ /**
296
+ * #3689: the ledger table's fixed header row literal. `renderTable` emits it
297
+ * on both the empty and non-empty branches; `extractTableRegion` anchors on
298
+ * it to bound the table region. Hoisted to one constant so the two surfaces
299
+ * cannot drift (see "Generative Fix Divergence" — CONTRIBUTING.md).
300
+ */
301
+ const TABLE_HEADER_LINE = '| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |';
302
+ /**
303
+ * Locate the entries JSON block by CommonMark fence rules rather than a fixed
304
+ * literal width. Both parseJsonBlock (strict) and writeLedgerAtomic's #2893
305
+ * prose preservation (lenient) go through this one function so read tolerance
306
+ * and splice tolerance cannot drift (#3657). A backtick-only line can never
307
+ * occur inside a body: JSON.stringify renders strings single-line-escaped, so
308
+ * an inline run inside a description is never a close-fence candidate.
309
+ *
310
+ * Disambiguation (#3657 security review): an entry description may contain
311
+ * newlines and 3-backtick runs (append validation rejects only 4+ runs), and
312
+ * renderTable renders descriptions into the prose ABOVE the JSON block — so
313
+ * hostile or accidental text can plant a second json fence above the real
314
+ * one. renderLedger always emits the entries block as the FINAL fenced
315
+ * section, so spans are scanned in REVERSE: prefer the latest span whose
316
+ * entries length equals the frontmatter total_count (the real block always
317
+ * satisfies it — parseLedger cross-checks that invariant), else the latest
318
+ * span whose body is a JSON array, else the first span so corrupt bodies keep
319
+ * their fail-closed parse errors. A mirror planted below with identical
320
+ * length and identical entries is indistinguishable by construction — and
321
+ * harmless.
322
+ */
323
+ function locateJsonBlock(raw, expectedTotal) {
324
+ const spans = [];
325
+ for (const open of raw.matchAll(/^(`{3,})json[ \t]*\r?$/gm)) {
326
+ const width = open[1].length;
327
+ const bodyStart = (open.index ?? 0) + open[0].length;
328
+ for (const close of raw.slice(bodyStart).matchAll(/^(`{3,})[ \t]*\r?$/gm)) {
329
+ if (close[1].length < width)
330
+ continue;
331
+ const bodyEnd = bodyStart + (close.index ?? 0);
332
+ const closeLineEnd = raw.indexOf('\n', bodyEnd);
333
+ spans.push({
334
+ bodyStart,
335
+ bodyEnd,
336
+ afterClose: closeLineEnd === -1 ? raw.length : closeLineEnd + 1,
337
+ });
338
+ break; // CommonMark: the first qualifying close ends this fence block
339
+ }
340
+ }
341
+ if (spans.length === 0) {
342
+ const sawOpen = /^(`{3,})json[ \t]*\r?$/m.test(raw);
343
+ return { ok: false, reason: sawOpen ? 'unterminated' : 'missing-open' };
344
+ }
345
+ const parseBody = (s) => {
346
+ try {
347
+ return JSON.parse(raw.slice(s.bodyStart, s.bodyEnd).trim());
348
+ }
349
+ catch {
350
+ return undefined;
351
+ }
352
+ };
353
+ if (expectedTotal !== undefined) {
354
+ for (let i = spans.length - 1; i >= 0; i--) {
355
+ const body = parseBody(spans[i]);
356
+ if (Array.isArray(body) && body.length === expectedTotal) {
357
+ return { ok: true, span: spans[i] };
358
+ }
359
+ }
360
+ }
361
+ for (let i = spans.length - 1; i >= 0; i--) {
362
+ if (Array.isArray(parseBody(spans[i]))) {
363
+ return { ok: true, span: spans[i] };
364
+ }
365
+ }
366
+ return { ok: true, span: spans[0] };
367
+ }
289
368
  /**
290
369
  * Minimal strict frontmatter parser for flat scalar keys. Only supports the
291
370
  * shape this module emits: `key: <number|string>` per line. Throws on any
@@ -333,16 +412,14 @@ function parseFrontmatterStrict(raw) {
333
412
  }
334
413
  return out;
335
414
  }
336
- function parseJsonBlock(raw) {
337
- const start = raw.indexOf(JSON_FENCE_OPEN);
338
- if (start === -1) {
339
- throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, 'Ledger missing JSON code block for entries.');
340
- }
341
- const end = raw.indexOf(JSON_FENCE_CLOSE, start + JSON_FENCE_OPEN.length);
342
- if (end === -1) {
343
- throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, 'Ledger JSON code block not terminated.');
415
+ function parseJsonBlock(raw, expectedTotal) {
416
+ const span = locateJsonBlock(raw, expectedTotal);
417
+ if (!span.ok) {
418
+ throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, span.reason === 'missing-open'
419
+ ? 'Ledger missing JSON code block for entries.'
420
+ : 'Ledger JSON code block not terminated.');
344
421
  }
345
- const jsonText = raw.slice(start + JSON_FENCE_OPEN.length, end).trim();
422
+ const jsonText = raw.slice(span.span.bodyStart, span.span.bodyEnd).trim();
346
423
  let parsed;
347
424
  try {
348
425
  parsed = JSON.parse(jsonText);
@@ -415,7 +492,7 @@ function parseLedger(raw) {
415
492
  if (typeof fm.last_updated !== 'string') {
416
493
  throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger last_updated must be a string; got ${JSON.stringify(fm.last_updated)}.`);
417
494
  }
418
- const entries = parseJsonBlock(raw);
495
+ const entries = parseJsonBlock(raw, typeof fm.total_count === 'number' ? fm.total_count : undefined);
419
496
  const ledger = {
420
497
  schema_version: exports.SCHEMA_VERSION,
421
498
  open_count: typeof fm.open_count === 'number' ? fm.open_count : 0,
@@ -464,13 +541,13 @@ function renderLedger(ledger) {
464
541
  function renderTable(entries) {
465
542
  if (entries.length === 0) {
466
543
  return [
467
- '| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |',
544
+ TABLE_HEADER_LINE,
468
545
  '|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|',
469
546
  '| _(none)_ | | | | | _No windows recorded._ | | | | |',
470
547
  ].join('\n');
471
548
  }
472
549
  const rows = [
473
- '| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |',
550
+ TABLE_HEADER_LINE,
474
551
  '|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|',
475
552
  ];
476
553
  for (const e of entries) {
@@ -491,6 +568,119 @@ function renderTable(entries) {
491
568
  }
492
569
  return rows.join('\n');
493
570
  }
571
+ /**
572
+ * #3689: extract the exact markdown table region a rendered ledger emits —
573
+ * the text `renderTable` produced, byte-for-byte — from a raw ledger file.
574
+ * Used by `writeLedgerAtomic`'s drift guard to compare the on-disk table
575
+ * against `renderTable(<on-disk JSON entries>)` without a table parser.
576
+ *
577
+ * Locates the JSON block with the same tolerant `locateJsonBlock` helper the
578
+ * rest of the module uses (#3657), so a formatter-normalized 3-backtick
579
+ * fence still resolves. Everything before the opening fence line, with
580
+ * trailing blank lines dropped, is the candidate region.
581
+ *
582
+ * #3689: the region is bounded by finding the LAST occurrence of the fixed
583
+ * `TABLE_HEADER_LINE` literal (anchored at a line start) within that
584
+ * candidate text, then taking everything from there through its end — NOT
585
+ * by scanning backward for a contiguous run of `|`-prefixed lines. A `|`
586
+ * prefix scan cannot bound the region: `validateDescription` rejects only
587
+ * empty strings and 4-backtick runs, so a description may contain a raw
588
+ * `\n`, and `renderTable`'s `cell()` escapes `\` and `|` but not newlines.
589
+ * Such a description renders a row that physically spans multiple file
590
+ * lines, and the continuation line does not start with `|` — a prefix scan
591
+ * either truncates the table or, when the row's tail is the last pre-fence
592
+ * line, returns null immediately, bricking every subsequent write with
593
+ * `WINDOWS_LEDGER_TABLE_DRIFT` on a ledger nobody hand-edited. Anchoring on
594
+ * the header instead includes any such row whole, so `renderTable`
595
+ * regenerates byte-identical text for it and the drift comparison passes.
596
+ *
597
+ * Returns null when the JSON block cannot be located, or no header line is
598
+ * present.
599
+ *
600
+ * `expectedTotal` (#3689 review finding 2) is threaded straight into
601
+ * `locateJsonBlock` so callers with trailing prose can disambiguate the real
602
+ * ledger block from an unrelated fenced JSON array a user pasted below the
603
+ * closing fence — without it, `locateJsonBlock`'s no-hint fallback picks the
604
+ * LATEST array-shaped span, which is the prose block, not the ledger, and
605
+ * every drift comparison then binds to the wrong table/JSON pairing.
606
+ */
607
+ function extractTableRegion(raw, expectedTotal) {
608
+ const span = locateJsonBlock(raw, expectedTotal);
609
+ if (!span.ok)
610
+ return null;
611
+ // bodyStart sits right before the newline (or CR) ending the opening fence
612
+ // line; walk back to the start of that line.
613
+ const fenceLineStart = raw.lastIndexOf('\n', span.span.bodyStart - 1) + 1;
614
+ const before = raw.slice(0, fenceLineStart).replace(/\r\n/g, '\n');
615
+ let trimmedEnd = before.length;
616
+ while (trimmedEnd > 0 && before[trimmedEnd - 1] === '\n') {
617
+ trimmedEnd--;
618
+ }
619
+ const candidate = before.slice(0, trimmedEnd);
620
+ // Find the LAST occurrence of TABLE_HEADER_LINE anchored at a line start —
621
+ // a plain string scan rather than a regex, since the module is a leaf
622
+ // (imports only node:fs/node:path) and cannot pull in the shared
623
+ // escapeRegex() helper for a one-off fixed-literal search.
624
+ let headerIndex = -1;
625
+ let searchFrom = candidate.length;
626
+ for (;;) {
627
+ const idx = candidate.lastIndexOf(TABLE_HEADER_LINE, searchFrom);
628
+ if (idx === -1)
629
+ break;
630
+ const atLineStart = idx === 0 || candidate[idx - 1] === '\n';
631
+ const atLineEnd = idx + TABLE_HEADER_LINE.length === candidate.length ||
632
+ candidate[idx + TABLE_HEADER_LINE.length] === '\n';
633
+ if (atLineStart && atLineEnd) {
634
+ headerIndex = idx;
635
+ break;
636
+ }
637
+ // #3689: lastIndexOf clamps a negative position into [0, length] per
638
+ // spec, so `searchFrom = -1` would re-search from 0 and re-find the same
639
+ // rejected match at idx===0 forever. Stop explicitly once there is
640
+ // nowhere left to search — this makes the bound strictly decrease each
641
+ // iteration, so the loop terminates within candidate.length steps.
642
+ if (idx === 0)
643
+ break;
644
+ searchFrom = idx - 1;
645
+ }
646
+ if (headerIndex === -1)
647
+ return null;
648
+ return candidate.slice(headerIndex);
649
+ }
650
+ /**
651
+ * #3689: diff two `renderTable` outputs by row id (the first cell of each
652
+ * data row), skipping the header + separator lines (always exactly two).
653
+ * A row whose line text differs between the two tables, or that is present
654
+ * in only one of them, contributes its id to the result — this is what lets
655
+ * the drift-guard error message name the specific drifted/table-only row(s)
656
+ * rather than just saying "the table disagrees".
657
+ */
658
+ function diffTableRowIds(expectedTable, actualTable) {
659
+ const rowId = (line) => (line.split('|')[1] ?? '').trim();
660
+ const dataRows = (table) => {
661
+ const lines = table.split('\n');
662
+ const map = new Map();
663
+ for (let i = 2; i < lines.length; i++) {
664
+ const line = lines[i];
665
+ if (!line.startsWith('|'))
666
+ continue;
667
+ map.set(rowId(line), line);
668
+ }
669
+ return map;
670
+ };
671
+ const expectedRows = dataRows(expectedTable);
672
+ const actualRows = dataRows(actualTable);
673
+ const ids = new Set();
674
+ for (const [id, line] of expectedRows) {
675
+ if (actualRows.get(id) !== line)
676
+ ids.add(id);
677
+ }
678
+ for (const id of actualRows.keys()) {
679
+ if (!expectedRows.has(id))
680
+ ids.add(id);
681
+ }
682
+ return Array.from(ids).sort();
683
+ }
494
684
  // ─── I/O entry points ──────────────────────────────────────────────────────
495
685
  function ledgerPath(cwd) {
496
686
  return node_path_1.default.join(cwd, '.planning', exports.LEDGER_FILE_NAME);
@@ -569,23 +759,111 @@ function writeLedgerAtomic(cwd, ledger) {
569
759
  // trailing prose that users may have written below the closing fence.
570
760
  // Without this, every append/waive/fixed silently destroys that prose.
571
761
  let trailingProse = '';
762
+ // #3689: read the pre-image once into `existing` outside the catch, rather
763
+ // than doing every subsequent step inside a bare try/catch, so that a
764
+ // WindowsError thrown by the drift guard below propagates instead of being
765
+ // swallowed by the ENOENT handler meant only for "no ledger yet".
766
+ let existing = null;
572
767
  try {
573
- const existing = node_fs_1.default.readFileSync(p, 'utf8');
574
- // #2893: search for the CLOSING fence starting AFTER the opening fence,
575
- // mirroring parseJsonBlock — indexOf(JSON_FENCE_CLOSE) alone would match
576
- // the opening fence ('````json' starts with '````').
577
- const openIdx = existing.indexOf(JSON_FENCE_OPEN);
578
- if (openIdx !== -1) {
579
- const fenceEnd = existing.indexOf(JSON_FENCE_CLOSE, openIdx + JSON_FENCE_OPEN.length);
580
- if (fenceEnd !== -1) {
581
- const afterFence = existing.slice(fenceEnd + JSON_FENCE_CLOSE.length);
582
- // Drop leading newlines; keep the rest as prose.
583
- trailingProse = afterFence.replace(/^\n+/, '');
584
- }
768
+ existing = node_fs_1.default.readFileSync(p, 'utf8');
769
+ }
770
+ catch (e) {
771
+ // #1950-H2 / #3689: ENOENT is the only "no ledger yet" case — mirror
772
+ // readLedgerOrNull's discipline exactly. A bare catch here would let
773
+ // EACCES/EIO/ENOTDIR/etc. fall through as "no pre-image", silently
774
+ // skipping BOTH the #2893 prose preservation and the drift guard below
775
+ // and proceeding to overwrite an unreadable file — a guard bypassable by
776
+ // making the pre-image unreadable is not a guard.
777
+ const code = (e && typeof e === 'object' && 'code' in e)
778
+ ? String(e.code)
779
+ : '';
780
+ if (code !== 'ENOENT') {
781
+ throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Could not read ledger at ${p} (${code || 'unknown fs error'}): ${e.message}.`);
585
782
  }
783
+ // File doesn't exist yet (first write) — no prose to preserve, and
784
+ // nothing on disk to disagree with, so the drift guard below is skipped.
586
785
  }
587
- catch {
588
- // File doesn't exist yet (first write) — no prose to preserve.
786
+ if (existing !== null) {
787
+ // #3689 review finding 2 / #3689 bug discovery: both the #2893 prose
788
+ // span AND the drift guard below must disambiguate `locateJsonBlock`
789
+ // against the SAME pre-image ledger block, so this is computed ONCE,
790
+ // hoisted above both uses. expectedTotal MUST be derived from the
791
+ // PRE-IMAGE's own frontmatter (never `ledger.total_count`, which is
792
+ // already post-mutation — e.g. N+1 on an append): #2893 exists precisely
793
+ // because operators may paste prose below the closing fence, and that
794
+ // prose can itself contain a fenced JSON array of a different length.
795
+ // Passing the post-mutation total here (as a since-fixed #3689 review
796
+ // pass once did for the guard alone) makes locateJsonBlock's expectedTotal
797
+ // scan find nothing against the pre-image — no span has N+1 entries yet —
798
+ // so it silently falls through to the no-hint fallback, which binds to
799
+ // the LATEST array-shaped span: the prose block, not the ledger. Left
800
+ // unfixed, that means the #2893 prose-preservation span itself would
801
+ // resolve to the prose fence's `afterClose`, silently dropping
802
+ // everything between the real ledger block and the prose block —
803
+ // including the operator's own prose ABOVE that array — on every
804
+ // append. This is exactly the failure #2893 was written to prevent,
805
+ // reintroduced through the disambiguation hint; it is caught here by
806
+ // deriving the hint from the pre-image, not the post-mutation ledger,
807
+ // for BOTH call sites below. If the pre-image frontmatter cannot be
808
+ // parsed unambiguously, that is itself the ambiguous case — fail closed
809
+ // rather than falling back to the no-hint scan.
810
+ let preImageExpectedTotal;
811
+ try {
812
+ const preFm = parseFrontmatterStrict(existing);
813
+ if (typeof preFm.total_count !== 'number' || !Number.isInteger(preFm.total_count)) {
814
+ throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger frontmatter total_count in ${p} is not an integer; refusing to write — ` +
815
+ 'the ledger JSON block cannot be identified unambiguously.');
816
+ }
817
+ preImageExpectedTotal = preFm.total_count;
818
+ }
819
+ catch (e) {
820
+ if (e instanceof WindowsError)
821
+ throw e;
822
+ throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger frontmatter in ${p} could not be parsed (${e.message}); refusing ` +
823
+ 'to write — the ledger JSON block cannot be identified unambiguously.');
824
+ }
825
+ // #2893: search for the CLOSING fence starting AFTER the opening fence.
826
+ // The span is located with the same tolerant + disambiguated fence rules
827
+ // parseJsonBlock uses (#3657), so a formatter-normalized 3-backtick ledger
828
+ // keeps its prose too — a literal-width search here would find no block
829
+ // and silently drop everything below the ledger on the next write. The
830
+ // hint passed here is `preImageExpectedTotal` (pre-image derived, see
831
+ // above) — NOT `ledger.total_count` — so this binds to the same span the
832
+ // drift guard below does.
833
+ const span = locateJsonBlock(existing, preImageExpectedTotal);
834
+ if (span.ok) {
835
+ const afterFence = existing.slice(span.span.afterClose);
836
+ // Drop leading newlines; keep the rest as prose.
837
+ trailingProse = afterFence.replace(/^(?:\r?\n)+/, '');
838
+ }
839
+ // #3689 review finding 2: refuse the write if the on-disk table has
840
+ // drifted from the on-disk JSON — the source of truth — BEFORE anything
841
+ // is regenerated. Baseline is the ON-DISK entries, not `ledger` (already
842
+ // the post-mutation state: an appended entry or a changed status);
843
+ // comparing against `ledger` would report drift on every legitimate
844
+ // write.
845
+ const onDiskEntries = parseJsonBlock(existing, preImageExpectedTotal);
846
+ const expectedTable = renderTable(onDiskEntries);
847
+ const actualTable = extractTableRegion(existing, preImageExpectedTotal);
848
+ if (actualTable === null) {
849
+ throw new WindowsError(exports.REASON.WINDOWS_LEDGER_TABLE_DRIFT, `Ledger table region could not be located in ${p}; refusing to write. Edit the ` +
850
+ 'fenced JSON block directly — the sole source of truth — or delete the corrupted ' +
851
+ 'table region and let gsd-tools regenerate it; never hand-edit the rendered table.');
852
+ }
853
+ if (actualTable !== expectedTable) {
854
+ const driftedIds = diffTableRowIds(expectedTable, actualTable);
855
+ // #3689 review finding 3: a header/separator-only drift (e.g. a
856
+ // hand-edited column name or mangled separator) produces no data-row
857
+ // diffs, so driftedIds is empty — naming nothing would read "...for
858
+ // row id(s): .". Say what actually differs instead.
859
+ const driftDescription = driftedIds.length > 0
860
+ ? `for row id(s): ${driftedIds.join(', ')}`
861
+ : "in its header or separator row (no data row differs from the expected rendering)";
862
+ throw new WindowsError(exports.REASON.WINDOWS_LEDGER_TABLE_DRIFT, `Ledger table in ${p} disagrees with the fenced JSON entries (the sole source of ` +
863
+ `truth) ${driftDescription}. Edit the fenced JSON block ` +
864
+ 'directly, or discard the table edit and re-run the command so gsd-tools ' +
865
+ 'regenerates the table; never hand-edit the rendered table.');
866
+ }
589
867
  }
590
868
  const rendered = renderLedger(ledger);
591
869
  const content = trailingProse ? `${rendered}${trailingProse}` : rendered;
@@ -16,8 +16,9 @@
16
16
  * project ledger — `'' === ''` is no binding) NOR to the `disclosureSignature` alone (which covers
17
17
  * only executable surfaces, so a declarative-only cap has a constant signature and a repo-write
18
18
  * attacker could swap `capability.json` for a malicious gate/contribution while consent still
19
- * matched). `bundleContentHash` is recomputed by the loader at load over EVERY file in the bundle
20
- * (manifest AND artifacts AND identity), so any tamper — declarative-only swap, hook-script edit,
19
+ * matched). `bundleContentHash` is recomputed by the loader at load over the bundle (manifest AND
20
+ * artifacts AND identity), excluding only derived Python bytecode/cache noise (#3631 — see
21
+ * `bundleContentHash`'s own doc comment), so any tamper — declarative-only swap, hook-script edit,
21
22
  * empty-integrity local install — changes the hash and leaves the cap inactive. `integrity` and
22
23
  * `disclosureSignature` remain on the record for the human disclosure + re-consent-on-executable-
23
24
  * change UX (TRUST-2); they are NO LONGER the security binding.
@@ -182,10 +183,31 @@ function normalizeSepBytes(rel) {
182
183
  }
183
184
  /**
184
185
  * Recursively collect every REGULAR file AND every DIRECTORY under `absDir` as RAW-BYTE POSIX-relative
185
- * paths (`rel`, relative to the bundle root), refusing to follow symlinks out of the bundle. Bounded:
186
- * throws if the entry count or total byte size exceeds the caps (fail closed — a hostile/runaway tree
187
- * never hashes unbounded content). A non-regular entry encountered IN the tree (FIFO/device) is a
188
- * fail-closed throw — a bundle must be plain files and directories.
186
+ * paths (`rel`, relative to the bundle root), refusing to follow symlinks out of the bundle. Two
187
+ * NARROW, RECURSION-PRESERVING exclusions apply (#3631, amended after two orthogonal reviews found the
188
+ * original wholesale-skip UNSAFE — see the doc comment on `bundleContentHash` for the full rationale
189
+ * and the accepted residual risk):
190
+ *
191
+ * 1. A DIRECTORY whose basename is exactly `__pycache__` or `.pytest_cache`: its own TAG_DIR marker
192
+ * is SUPPRESSED (not pushed), but it is STILL RECURSED INTO — every child that is not itself
193
+ * excluded is still hashed. Suppressing only the marker is what keeps an empty/all-excluded such
194
+ * directory from moving the digest; recursing is what closes the original hole (H1): skipping
195
+ * recursion made an excluded directory an unbounded, permanently-unhashed region a manifest could
196
+ * point a hook `script` at (`__pycache__/run.js`) to install benign, then rewrite freely post-consent.
197
+ * 2. A FILE whose name ends `.pyc` or `.pyo` is excluded ONLY when its PARENT directory's basename is
198
+ * exactly `__pycache__` (checked byte-exact, never `.pytest_cache`) — a `.pyc`/`.pyo` anywhere else
199
+ * (bundle root, `scripts/`, a directory literally named `cache.pyc`, etc.) is hashed normally,
200
+ * because a sourceless legacy `.pyc` there IS importable and executable (H2).
201
+ *
202
+ * A regular FILE literally named `__pycache__` or `.pytest_cache` is NOT excluded (only a DIRECTORY of
203
+ * that basename gets marker-suppression) — it is hashed like any other file. A DIRECTORY literally
204
+ * named `x.pyc` is NOT excluded either — the suffix rule only ever applies to files. All exclusion
205
+ * checks run strictly AFTER the lstat-backed symlink/non-regular rejections below, so a symlink or
206
+ * device masquerading under an excluded name is still fail-closed rejected rather than silently
207
+ * skipped. Bounded: throws if the entry count or total byte size exceeds the caps (fail closed — a
208
+ * hostile/runaway tree never hashes unbounded content); an excluded entry still counts toward BOTH
209
+ * caps (the caps guard the walk itself, not the digest). A non-regular entry encountered IN the tree
210
+ * (FIFO/device) is a fail-closed throw — a bundle must be plain files and directories.
189
211
  *
190
212
  * #1459 finding 2 (MED/HIGH, ROUND 6): the enumeration ITSELF is bounded. Instead of
191
213
  * `fs.readdirSync` (which loads + sorts a WHOLE directory before the count cap — so a malicious bundle
@@ -204,11 +226,72 @@ function normalizeSepBytes(rel) {
204
226
  * abs/rel paths are concatenated at the BYTE level, so an invalid-UTF-8 filename is never lossily
205
227
  * decoded — two filenames that differ only in invalid bytes produce distinct rel byte strings.
206
228
  *
207
- * @param absDir the absolute directory to scan, as RAW BYTES (Buffer).
208
- * @param relDir the relpath of `absDir` from the bundle root, as RAW BYTES (Buffer; empty at the root).
209
- * @param count the CUMULATIVE entry counter shared across the whole recursive walk (fail-closed at the cap).
229
+ * @param absDir the absolute directory to scan, as RAW BYTES (Buffer).
230
+ * @param relDir the relpath of `absDir` from the bundle root, as RAW BYTES (Buffer; empty at the root).
231
+ * @param dirBasename the basename of `absDir` itself, as RAW BYTES (Buffer) — the PARENT basename every
232
+ * entry collected at this level shares. Threaded down so a `.pyc`/`.pyo` FILE can be
233
+ * excluded only when ITS parent is exactly `__pycache__` (empty at the bundle root).
234
+ * @param count the CUMULATIVE entry counter shared across the whole recursive walk (fail-closed at the cap).
235
+ */
236
+ // #3631 (amended — see bundleContentHash's doc comment for the H1/H2 rationale and accepted residual
237
+ // risk): basename rules for what is excluded FROM THE DIGEST. They are NOT excluded from the walk's
238
+ // resource caps — see the count.n / total.bytes accounting below, which still sees every excluded
239
+ // entry.
240
+ //
241
+ // Exact byte comparison, case-sensitive, deliberately: CPython always writes a lowercase
242
+ // `__pycache__` directory, so byte-exact matching keeps the digest identical across case-insensitive
243
+ // filesystems (e.g. default macOS/Windows) instead of varying with how a name happens to be spelled
244
+ // on disk.
245
+ //
246
+ // DELIBERATELY NOT on this list: node_modules, dist, build, or similar. Those hold code that is
247
+ // actually required/executed at runtime, so excluding them from the digest would stop consent from
248
+ // binding executable content — turning a usability bug (noisy re-consent prompts) into a
249
+ // supply-chain hole (a swapped dependency that never re-triggers consent).
250
+ /** Directory basenames whose TAG_DIR marker is suppressed — but the directory is STILL RECURSED INTO. */
251
+ const PYCACHE_DIR_BASENAMES = [
252
+ Buffer.from('__pycache__'),
253
+ Buffer.from('.pytest_cache'),
254
+ ];
255
+ /** FILE-name suffixes excluded ONLY when the file's parent directory basename is `__pycache__` (see below). */
256
+ const PYCACHE_FILE_SUFFIXES = [
257
+ Buffer.from('.pyc'),
258
+ Buffer.from('.pyo'),
259
+ ];
260
+ /** The one parent directory basename that gates the `.pyc`/`.pyo` FILE-suffix exclusion — never `.pytest_cache`. */
261
+ const PYCACHE_PARENT_BASENAME = Buffer.from('__pycache__');
262
+ /**
263
+ * True when `name` (a raw-byte dirent basename) is a DIRECTORY whose TAG_DIR marker must be
264
+ * suppressed — `__pycache__` or `.pytest_cache`, exact byte match. The caller still recurses into it;
265
+ * this predicate answers ONLY "skip the marker", never "skip the subtree".
266
+ */
267
+ function isPycacheDirBasename(name) {
268
+ for (const exact of PYCACHE_DIR_BASENAMES) {
269
+ if (Buffer.compare(name, exact) === 0)
270
+ return true;
271
+ }
272
+ return false;
273
+ }
274
+ /** True when raw-byte basename `name` ends in `.pyc` or `.pyo` (byte-suffix match, never utf8-decoded). */
275
+ function hasPycacheFileSuffix(name) {
276
+ for (const suffix of PYCACHE_FILE_SUFFIXES) {
277
+ if (name.length >= suffix.length && Buffer.compare(name.subarray(name.length - suffix.length), suffix) === 0) {
278
+ return true;
279
+ }
280
+ }
281
+ return false;
282
+ }
283
+ /**
284
+ * True when a FILE with basename `name`, inside a directory whose OWN basename is `dirBasename`, must
285
+ * be excluded from the digest: a `.pyc`/`.pyo` suffix whose parent directory basename is exactly
286
+ * `__pycache__` (byte-exact — never `.pytest_cache`, so a `.pyc` sitting directly inside a
287
+ * `.pytest_cache` dir is still hashed). A `.pyc`/`.pyo` file anywhere else — bundle root, `scripts/`,
288
+ * a directory literally named `cache.pyc`, etc. — is NOT excluded (H2): a sourceless legacy `.pyc`
289
+ * there is importable and executable, so it must stay bound to consent.
210
290
  */
211
- function collectBundleEntries(absDir, relDir, acc, total, count) {
291
+ function isExcludedFileBasename(name, dirBasename) {
292
+ return hasPycacheFileSuffix(name) && Buffer.compare(dirBasename, PYCACHE_PARENT_BASENAME) === 0;
293
+ }
294
+ function collectBundleEntries(absDir, relDir, dirBasename, acc, total, count) {
212
295
  let dir;
213
296
  try {
214
297
  // RAW-BYTE streaming open: dirent names are Buffers (encoding: 'buffer'), so an invalid-UTF-8
@@ -269,19 +352,35 @@ function collectBundleEntries(absDir, relDir, acc, total, count) {
269
352
  throw new Error(`bundleContentHash: refusing to hash a symlink in the bundle: "${abs.toString('utf8')}"`);
270
353
  }
271
354
  if (st.isDirectory()) {
355
+ // #3631 (amended, H1): exclusion is checked HERE — after the symlink fail-closed throw above —
356
+ // so a symlink named e.g. "__pycache__" or "x.pyc" is never silently skipped; only a REAL
357
+ // (lstat-confirmed) dir/file can be excluded. An excluded directory's own dirent was already
358
+ // counted toward count.n above (the cap guards the walk itself). Only the TAG_DIR MARKER is
359
+ // suppressed for `__pycache__`/`.pytest_cache` — the directory is ALWAYS recursed into so every
360
+ // non-excluded child underneath is still hashed (closing H1: no unbounded unhashed region).
361
+ if (isPycacheDirBasename(name)) {
362
+ collectBundleEntries(abs, rel, name, acc, total, count);
363
+ continue;
364
+ }
272
365
  // Emit a typed DIR marker for THIS directory (so an empty dir is bound), then recurse into it.
273
366
  acc.push({ abs, rel, kind: 'dir' });
274
- collectBundleEntries(abs, rel, acc, total, count);
367
+ collectBundleEntries(abs, rel, name, acc, total, count);
275
368
  continue;
276
369
  }
277
370
  if (!st.isFile()) {
278
371
  throw new Error(`bundleContentHash: refusing to hash a non-regular file in the bundle: "${abs.toString('utf8')}"`);
279
372
  }
280
- acc.push({ abs, rel, kind: 'file' });
373
+ // #3631: an excluded FILE (a __pycache__/*.pyc or *.pyo) still counts its bytes toward the
374
+ // total-size cap below — exclusion answers "does this bind the digest?", not "is this safe to
375
+ // read unbounded?" — so it must never become a way to smuggle unbounded bytes past
376
+ // BUNDLE_MAX_TOTAL_BYTES.
281
377
  total.bytes += st.size;
282
378
  if (total.bytes > BUNDLE_MAX_TOTAL_BYTES) {
283
379
  throw new Error(`bundleContentHash: bundle size exceeds ${BUNDLE_MAX_TOTAL_BYTES} bytes (refusing)`);
284
380
  }
381
+ if (isExcludedFileBasename(name, dirBasename))
382
+ continue;
383
+ acc.push({ abs, rel, kind: 'file' });
285
384
  }
286
385
  }
287
386
  /** Encode an unsigned 32-bit length as 4 big-endian bytes (the path-length frame). */
@@ -304,8 +403,39 @@ const TAG_FILE = Buffer.from([0x01]);
304
403
  const TAG_DIR = Buffer.from([0x02]);
305
404
  /**
306
405
  * The recomputed full-bundle content hash (#1459 CB-1/CB-2/TRUST2-5) — the SECURITY BINDING. A
307
- * `sha512-<base64>` over a DETERMINISTIC, INJECTIVE, LOSSLESS serialization of EVERY regular file
308
- * AND directory under `capDir` (recursively).
406
+ * `sha512-<base64>` over a DETERMINISTIC, INJECTIVE, LOSSLESS serialization of every regular file
407
+ * AND directory under `capDir` (recursively), with two NARROW exclusions (#3631, amended after two
408
+ * orthogonal reviews found the original wholesale directory-skip UNSAFE):
409
+ * - A `__pycache__` or `.pytest_cache` DIRECTORY's own TAG_DIR marker is suppressed, but it is
410
+ * ALWAYS recursed into — every non-excluded child underneath is still hashed.
411
+ * - A `.pyc`/`.pyo` FILE is excluded ONLY when its immediate parent directory's basename is exactly
412
+ * `__pycache__`; a `.pyc`/`.pyo` anywhere else (bundle root, `scripts/`, etc.) is hashed normally,
413
+ * since a sourceless legacy `.pyc` there is importable and executable.
414
+ * `node_modules`, `dist`, `build`, and similar are deliberately NOT excluded: their contents are
415
+ * executed/required at runtime, so dropping them from the digest would stop consent from binding
416
+ * executable content.
417
+ *
418
+ * ACCEPTED RESIDUAL RISK (documented, not a defect to re-litigate): CPython's default `.pyc`
419
+ * invalidation (timestamp-based) validates a cached bytecode file against its SOURCE's mtime and size
420
+ * only — NOT against the source's content — and both mtime and size are attacker-settable by whoever
421
+ * can already write to the bundle. So a forged `__pycache__/mod.cpython-3XX.pyc` whose header mtime/size
422
+ * were copied from an unmodified, still-hashed `mod.py` will execute without moving this digest. Before
423
+ * this exclusion existed that write was detected (any file under `__pycache__` moved the hash); after
424
+ * it, it is not, for files matching the narrow `__pycache__/*.pyc` shape above. This is accepted
425
+ * deliberately — the alternative (H1's original wholesale skip, or hashing every regenerated bytecode
426
+ * file) either reopens an unbounded unhashed region or makes consent fire on routine bytecode caching —
427
+ * and is bounded by: (1) the attacker must already have POST-CONSENT write access to the bundle; (2)
428
+ * everything outside `__pycache__/*.pyc` — including sourceless legacy `.pyc`/`.pyo` files anywhere
429
+ * else — remains hashed. NOT a bound, despite `isSafeHookScriptPath` (capability-lifecycle.cts /
430
+ * capability-validator.cjs) rejecting a manifest-declared hook `script` that names a
431
+ * `__pycache__`/`.pytest_cache` segment or ends `.pyc`/`.pyo`: the excluded region is still reachable
432
+ * by ONE HOP of indirection from any hashed, consent-covered script — a `require`/`import` of a
433
+ * `__pycache__/*.pyc` module path is not itself a declared script and the validator never sees it —
434
+ * so a hashed `hooks/run.js` can load a `__pycache__/mod.pyc` whose bytes are then free to change
435
+ * post-consent with the digest unmoved. The validator guard raises the bar for DECLARED surfaces; it
436
+ * does not contain the risk. KNOWN LIMITATION: `.pytest_cache`'s CONTENTS still change the digest as
437
+ * normal files — only its directory marker is suppressed, so this residual risk does NOT extend to
438
+ * `.pytest_cache`.
309
439
  *
310
440
  * Canonicalization (#1459 findings 1 + 4 — the prior `relpath + NUL + content + NUL` over utf8-decoded
311
441
  * STRINGS was non-injective, lossy in CONTENT, AND lossy in the PATH component):
@@ -333,8 +463,12 @@ const TAG_DIR = Buffer.from([0x02]);
333
463
  function bundleContentHash(capDir) {
334
464
  // Resolve to an absolute path, then carry it as RAW BYTES so the walk never lossily decodes a name.
335
465
  const rootBytes = Buffer.from(node_path_1.default.resolve(capDir));
466
+ // The root's own basename is threaded as the initial `dirBasename` so the parent-basename check for
467
+ // a `.pyc`/`.pyo` FILE applies even to a file placed DIRECTLY at the bundle root (root literally
468
+ // named `__pycache__` is the only case this matters for, and it is a correct, if exotic, match).
469
+ const rootBasename = Buffer.from(node_path_1.default.basename(node_path_1.default.resolve(capDir)));
336
470
  const entries = [];
337
- collectBundleEntries(rootBytes, Buffer.alloc(0), entries, { bytes: 0 }, { n: 0 });
471
+ collectBundleEntries(rootBytes, Buffer.alloc(0), rootBasename, entries, { bytes: 0 }, { n: 0 });
338
472
  // Sort by the raw-byte (separator-normalized) relpath so the digest is identical on Windows and POSIX,
339
473
  // and is independent of the on-disk creation/readdir order. Tie-break on kind so a (degenerate, never
340
474
  // produced on a real fs) file-and-dir same-relpath pair still has a stable order.