@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
@@ -389,8 +389,12 @@ function formatGsdState(s) {
389
389
  // Scene 2: idle + a recommended next command is visible to the user.
390
390
  // Surfaces "what to run next" without the user opening STATE.md.
391
391
  parts.push(`next ${s.nextAction} ${phasesStr}`);
392
- } else if (Number(s.percent) === 100 || (s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases)) {
393
- // Scene 3: milestone complete (every phase done).
392
+ } else if (Number(s.percent) === 100 || (Number(s.totalPhases) > 0 && Number(s.completedPhases) === Number(s.totalPhases))) {
393
+ // Scene 3: milestone complete (every phase done). #3945: the counters are
394
+ // regex-captured STRINGS, so the old `cp && tp && cp === tp` guard fired on
395
+ // the empty set ('0' is truthy, '0' === '0') — "0% · milestone complete".
396
+ // Numeric coercion + a non-empty denominator makes "nothing to measure"
397
+ // stop meaning "everything is done".
394
398
  parts.push('milestone complete');
395
399
  } else {
396
400
  // Backward-compatible default — preserved EXACTLY for STATE.md files that
@@ -500,7 +504,7 @@ function formatGsdStateCompact(s) {
500
504
  // still completes) wins over milestone-complete (Scene 3), even if a
501
505
  // non-atomic STATE.md edit leaves percent=100 alongside a lifecycle phase.
502
506
  const done = !s.activePhase && (Number(s.percent) === 100 ||
503
- (s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases));
507
+ (Number(s.totalPhases) > 0 && Number(s.completedPhases) === Number(s.totalPhases)));
504
508
 
505
509
  if (done) {
506
510
  parts.push('complete');
@@ -24,13 +24,38 @@ cleanup_temp_files() {
24
24
  }
25
25
  trap cleanup_temp_files EXIT
26
26
 
27
+ # The 10 built-in Conventional Commits types — the SINGLE declaration (#3811
28
+ # review finding: this was previously hand-typed a second time inside the
29
+ # node -e script below, a generative-fix-divergence risk per CLAUDE.md's
30
+ # known-defect list). Threaded into node via an env var; reused directly by
31
+ # bash below when building COMMIT_TYPES.
32
+ BUILTIN_COMMIT_TYPES=(feat fix docs style refactor perf test build ci chore)
33
+
27
34
  # Check opt-in config — exit silently if not enabled
28
35
  if [ -f .planning/config.json ]; then
29
36
  ENABLED_ERR=$(mktemp)
30
- ENABLED=$(node -e "
37
+ # Single node invocation reads BOTH hooks.community (line 1: '1'/'0') and
38
+ # hooks.commit_types (remaining lines: one sanitized extra type per line) —
39
+ # see #3811. Sanitizing here, not in bash, keeps the safe-token check in one
40
+ # place and guarantees only [a-z][a-z0-9-]* strings ever reach the regex
41
+ # built below, so a configured value can never alter the compiled pattern's
42
+ # structure.
43
+ BUILTIN_COMMIT_TYPES_CSV=$(IFS=,; echo "${BUILTIN_COMMIT_TYPES[*]}")
44
+ CONFIG_OUT=$(GSD_BUILTIN_COMMIT_TYPES="$BUILTIN_COMMIT_TYPES_CSV" node -e "
31
45
  try{
32
46
  const c=require('./.planning/config.json');
33
47
  process.stdout.write(c.hooks?.community===true?'1':'0');
48
+ process.stdout.write('\n');
49
+ const raw=c.hooks?.commit_types;
50
+ const list=Array.isArray(raw)?raw:[];
51
+ const seen=new Set((process.env.GSD_BUILTIN_COMMIT_TYPES||'').split(',').filter(Boolean));
52
+ for (const t of list){
53
+ if (typeof t!=='string') continue;
54
+ if (!/^[a-z][a-z0-9-]*\$/.test(t)) continue;
55
+ if (seen.has(t)) continue;
56
+ seen.add(t);
57
+ process.stdout.write(t+'\n');
58
+ }
34
59
  }catch(e){
35
60
  process.stderr.write('CONFIG_READ_FAILED: '+(e&&e.message?e.message:String(e)));
36
61
  process.exit(3);
@@ -44,7 +69,16 @@ if [ -f .planning/config.json ]; then
44
69
  echo "gsd-validate-commit.sh: could not read .planning/config.json (opt-in check) — validator disabled for this call. $(cat "$ENABLED_ERR")" >&2
45
70
  exit 0
46
71
  fi
72
+ ENABLED=$(printf '%s\n' "$CONFIG_OUT" | head -1)
47
73
  if [ "$ENABLED" != "1" ]; then exit 0; fi
74
+ # Remaining lines (if any) are the sanitized, deduped configured commit
75
+ # types beyond the 10 built-ins (#3811). Read into a bash-3.2-safe array —
76
+ # `mapfile`/`readarray` are bash 4+ only and this hook is tested against
77
+ # bash 3.2.57 (macOS default).
78
+ EXTRA_COMMIT_TYPES=()
79
+ while IFS= read -r _extra_type; do
80
+ [ -n "$_extra_type" ] && EXTRA_COMMIT_TYPES+=("$_extra_type")
81
+ done < <(printf '%s\n' "$CONFIG_OUT" | tail -n +2)
48
82
  else
49
83
  exit 0
50
84
  fi
@@ -104,21 +138,424 @@ if [ "$CLASSIFY_STATUS" != "0" ] && [ "$CLASSIFY_STATUS" != "1" ]; then
104
138
  exit 0
105
139
  fi
106
140
  if [ "$CLASSIFY_STATUS" = "0" ]; then
107
- # Extract message from -m flag
141
+ # Extract message from -m flag.
142
+ #
143
+ # MSG_QUOTE records WHICH arm matched. bash treats the two arms differently
144
+ # and the subject step below depends on that difference — see the resolver
145
+ # gate (review of #3816, round 4).
108
146
  MSG=""
147
+ MSG_QUOTE=""
148
+ MSG_MATCH=""
109
149
  if [[ "$CMD" =~ -m[[:space:]]+\"([^\"]+)\" ]]; then
110
150
  MSG="${BASH_REMATCH[1]}"
151
+ MSG_QUOTE=dq
152
+ MSG_MATCH="${BASH_REMATCH[0]}"
111
153
  elif [[ "$CMD" =~ -m[[:space:]]+\'([^\']+)\' ]]; then
112
154
  MSG="${BASH_REMATCH[1]}"
155
+ MSG_QUOTE=sq
156
+ MSG_MATCH="${BASH_REMATCH[0]}"
113
157
  fi
114
158
 
115
159
  if [ -n "$MSG" ]; then
116
- SUBJECT=$(echo "$MSG" | head -1)
160
+ # Subject = first line of the message, EXCEPT for the command-substituted
161
+ # heredoc form, where the first line is the opener rather than the message:
162
+ #
163
+ # git commit -m "$(cat <<'EOF'
164
+ # feat(auth): add login flow
165
+ # EOF
166
+ # )"
167
+ #
168
+ # The capture above spans it whole, because bash `[^"]` matches newlines, so
169
+ # `head -1` yielded the literal `$(cat <<'EOF'` and EVERY heredoc-form commit
170
+ # was blocked regardless of its message (#3802).
171
+ #
172
+ # Selection of WHICH argument is the message is unchanged above — only the
173
+ # subject-from-message step is delegated. Falls back to the previous `head -1`
174
+ # if node or the library is unavailable, so a broken extractor degrades to the
175
+ # old behavior instead of becoming a new silent-allow path.
176
+ #
177
+ # SINGLE-QUOTE GATE (review of #3816, round 4 — BLOCKER). The resolver may
178
+ # only run on the DOUBLE-quoted arm. Inside `-m '...'` bash performs NO
179
+ # command substitution, so `$(cat <<'EOF'` is literal text and git's real
180
+ # subject is that opener line — resolving the body there validates a
181
+ # message git never receives. Measured against the real hook, all four
182
+ # spellings (`<<'E'`, `<<"E"`, `<<\E`, `<<E`) went base=2 -> head=0: a
183
+ # net-new bypass reachable by the ordinary authoring slip of typing `'`
184
+ # for `"`. The sq arm therefore keeps the pre-fix `head -1`, which is exact
185
+ # base parity.
186
+ #
187
+ # ADJACENCY GUARD (review of #3816): text glued to the CLOSING quote —
188
+ # `-m "$(cat <<'EOF' ... )"suffix` — is concatenated by bash into the SAME
189
+ # argument, so the capture above holds only a PREFIX of the real message.
190
+ # Resolving a heredoc from a prefix hands the length gate a fraction of the
191
+ # real subject: a net-new bypass relative to base, which measured the
192
+ # opener line and blocked. When the quote is not followed by whitespace or
193
+ # the end of the command, skip the resolver and keep the pre-fix subject
194
+ # (first captured line): the heredoc form then fails the format gate
195
+ # exactly as it did on base, and the plain single-line form keeps base
196
+ # behavior unchanged. The guard is tested against the arm that MATCHED,
197
+ # not against both: testing both let a double-quoted heredoc whose BODY
198
+ # mentions a glued single-quoted token (`-m "... -m 'foo'bar ..."`) trip
199
+ # the sq arm and lose the fix for a message that never had a prefix
200
+ # problem (review of #3816, round 4, Minor 1).
201
+ # RESOLVER PRECONDITIONS. The resolver may run only where the captured text
202
+ # is provably the subject git receives. Each guard names an input where it
203
+ # is not; every refusal falls back to `head -1`, the pre-fix subject, which
204
+ # fails the format gate exactly as this whole form did before the fix.
205
+ RESOLVE=0
206
+ if [ "$MSG_QUOTE" = dq ]; then
207
+ RESOLVE=1
208
+ # Text before the message we matched. The heredoc BODY always sits after
209
+ # the match, so this window cannot be contaminated by message content —
210
+ # which is what lets the two guards below scan for tokens that would also
211
+ # be legal inside a commit message.
212
+ MSG_PREFIX="${CMD%%"$MSG_MATCH"*}"
213
+ # Text after it. Together, PREFIX and SUFFIX are the whole command MINUS
214
+ # the message — the window a guard must use when the token it scans for
215
+ # is also legal English inside a commit message, but may legally appear
216
+ # on EITHER side of the message on the command line.
217
+ MSG_SUFFIX="${CMD#*"$MSG_MATCH"}"
218
+ # LINE CONTINUATIONS ARE NOT SEPARATORS (review of #3816, rounds 8 and 9).
219
+ # `git commit \` newline ` -m "$(cat <<'EOF' …` is an ordinary way to
220
+ # spread an invocation over lines, and every guard below reads a newline in
221
+ # a window as a command separator, so the whole form was refused. That was
222
+ # disclosed as a fail-closed limit in round 8 because "is this newline a
223
+ # continuation" looked like the segmentation question this file has
224
+ # reverted twice. It is not: bash's rule is local and character-level. A
225
+ # newline preceded by an ODD run of backslashes is a continuation and bash
226
+ # removes both; an EVEN run (`\\` then newline) is a literal backslash
227
+ # followed by a real newline, which IS a separator. So the windows are
228
+ # joined the way bash joins them, in three bash-3.2-safe steps: every `\\`
229
+ # pair is parked on \x01, a byte no real command line carries, any
230
+ # backslash-newline that remains is a lone (odd) one and is removed, then
231
+ # the pairs are restored. Applied to BOTH windows, BEFORE the dequote
232
+ # copies are derived, so every scan sees the joined text.
233
+ #
234
+ # KNOWN OVER-BLOCK, fail-closed: a literal \x01 that IS present in the
235
+ # command is restored as `\\`, so an option-shaped token carrying one
236
+ # (`-\x01m`) reads as `-\\m`, dequotes to `-m`, and refuses where it did
237
+ # not before (independent review, round 9). Refusing is the recoverable
238
+ # direction; a control byte in an option name is not a spelling anyone
239
+ # types, and it is not a hole in the accept direction.
240
+ #
241
+ # Direction check: a continuation glued to the closing quote
242
+ # (`"$(…)"\` newline `suffix`) joins to `"$(…)"suffix`, which the glue
243
+ # guard refuses exactly as bash would have glued it; `\\` + newline keeps
244
+ # its newline and is still refused by the separator guard. Measured on
245
+ # bash 3.2.57 and 5.3.15 in tests/hooks-opt-in.test.cjs.
246
+ CONT_PARK=$'\x01'
247
+ MSG_PREFIX="${MSG_PREFIX//\\\\/$CONT_PARK}"
248
+ MSG_PREFIX="${MSG_PREFIX//\\$'\n'/}"
249
+ MSG_PREFIX="${MSG_PREFIX//$CONT_PARK/\\\\}"
250
+ MSG_SUFFIX="${MSG_SUFFIX//\\\\/$CONT_PARK}"
251
+ MSG_SUFFIX="${MSG_SUFFIX//\\$'\n'/}"
252
+ MSG_SUFFIX="${MSG_SUFFIX//$CONT_PARK/\\\\}"
253
+
254
+ # QUOTE-SPLICED SPELLINGS (independent review of #3816, round 6). Bash
255
+ # removes quotes before git ever sees an argument, so the same option has
256
+ # unboundedly many spellings on the command line: `--clean""up=verbatim`
257
+ # IS `--cleanup=verbatim` to git, and `-""m` IS `-m`. Both matched no
258
+ # literal and were measured ACCEPTING a 75-byte subject the length gate
259
+ # had recorded as 72. The guards below therefore scan a copy of their
260
+ # window with quote characters removed, which is what bash does to it.
261
+ # Only the two OPTION-NAME scans use it; the adjacency test deliberately
262
+ # does not, because it asks about a literal character position, and the
263
+ # message span itself is excluded from both windows either way.
264
+ MSG_PREFIX_DEQ="${MSG_PREFIX//[\"\']/}"
265
+ MSG_SUFFIX_DEQ="${MSG_SUFFIX//[\"\']/}"
266
+ # BACKSLASH-SPLICED SPELLINGS (independent review of #3816, round 7).
267
+ # Quote removal alone was not "the command as bash hands it to git": bash
268
+ # also removes syntactic backslashes, so `-\m WIP` IS `-m WIP` and
269
+ # `--clean\up=verbatim` IS `--cleanup=verbatim` to git, and both matched
270
+ # no literal. Measured: `-\m WIP -m <conforming heredoc>` accepted the
271
+ # heredoc while git recorded `WIP`, and a trailing `--clean\up=verbatim`
272
+ # accepted a 75-byte subject the length gate measured as 72. Stripped in a
273
+ # second pass so the class is unambiguous.
274
+ MSG_PREFIX_DEQ="${MSG_PREFIX_DEQ//\\/}"
275
+ MSG_SUFFIX_DEQ="${MSG_SUFFIX_DEQ//\\/}"
276
+ # DOLLAR-QUOTED SPELLINGS (independent review of #3816, round 8). The two
277
+ # passes above still were not "the command as bash hands it to git": bash
278
+ # has TWO more quoting forms whose introducer is a `$`, and removing the
279
+ # quote characters alone leaves that `$` stranded in the middle of the
280
+ # option name. `-$"m"` became `-$m` here while bash passes a real `-m` to
281
+ # git, and `--mes$'sage'=WIP` became `--mes$sage=WIP`; neither matched any
282
+ # literal, so the first-message guard below never fired. Measured on bash
283
+ # 3.2.57 and 5.3.15 against a real repository: the hook allowed
284
+ # `-$"m" WIP -m <conforming heredoc>` (exit 0) while `git cat-file -p`
285
+ # recorded the subject `WIP` — the same command spelled `-m WIP` is
286
+ # refused (exit 2). Stripping `$` closes both dollar-quote forms.
287
+ #
288
+ # RESIDUAL, and not fixable from a string: an option name assembled by an
289
+ # EXPANSION — `-${x}m`, `-$(printf m)` — is not knowable without running
290
+ # the command, the same limit this file already documents for expanded
291
+ # heredoc bodies. Stripping `$` makes those spellings collapse toward the
292
+ # literal too, which over-matches, and over-matching only refuses more.
293
+ MSG_PREFIX_DEQ="${MSG_PREFIX_DEQ//\$/}"
294
+ MSG_SUFFIX_DEQ="${MSG_SUFFIX_DEQ//\$/}"
295
+
296
+ # ADJACENCY GUARD (review of #3816): text glued to the CLOSING quote —
297
+ # `-m "$(cat <<'EOF' ... )"suffix` — is concatenated by bash into the SAME
298
+ # argument, so the capture holds only a PREFIX of the real message, and
299
+ # the length gate would measure a fraction of the real subject.
300
+ # SCOPE (review of #3816, round 6 — MAJOR). Glue is a property of the ONE
301
+ # character following the MATCHED span, so that character is the whole
302
+ # window. Scanning $CMD for the shape anywhere refused any conforming
303
+ # commit whose command merely CONTAINED a glued `-m` elsewhere —
304
+ # `git commit -m "<heredoc>" && echo -m "test"z` stayed blocked with
305
+ # CONVENTIONAL_COMMITS_VIOLATION. Base blocks it too, because base blocks
306
+ # EVERY heredoc form (that is #3802): this was the fix not reaching the
307
+ # shape, measured base=2 -> pre=2 -> post=0, not a regression.
308
+ # The separators and redirections are excluded because bash does NOT
309
+ # concatenate across them: in `-m "msg"&& echo hi` the argument ends at
310
+ # the quote, so there is no truncated capture to defend against.
311
+ # The class is held in a VARIABLE, not written inline. Inline, every
312
+ # member needs a backslash to get past the `[[ ]]` parser (`;`, `&` and
313
+ # `|` are metacharacters there) — and on bash 3.2, the system /bin/bash on
314
+ # macOS, those backslashes are passed THROUGH to the regex engine instead
315
+ # of being consumed by the shell, silently adding a literal `\` to the
316
+ # class. Unquoted expansion of a variable on the right of `=~` is the one
317
+ # spelling that is a plain regex on 3.2 and 5.x alike (review of #3816,
318
+ # round 8). Writing `[^[:space:];&|()<>]` inline is NOT the fix: it is a
319
+ # bash syntax error on both versions.
320
+ GLUE_CLASS='^[^[:space:];&|()<>]'
321
+ if [[ "$MSG_SUFFIX" =~ $GLUE_CLASS ]]; then RESOLVE=0; fi
322
+
323
+ # FIRST-MESSAGE GUARD (Codex review of #3816, round 4 — BLOCKER). The
324
+ # capture is a SEARCH over the whole command and the double-quoted arm is
325
+ # tried first, so it can select a `-m` that is not git's subject at all:
326
+ #
327
+ # git commit -m 'WIP first' -m "$(cat <<'EOF' -> git concatenates; the
328
+ # git commit -m WIP -m "$(cat <<'EOF' subject is `WIP first`
329
+ # git commit -m WIP -- -m "$(cat <<'EOF' -> after --, not a message
330
+ # git commit -m WIP && echo -m "$(cat <<'EOF' -> belongs to `echo`
331
+ #
332
+ # All four measured base=2 -> head=0, with git recording the FIRST message
333
+ # as the subject (verified against real commits, not the man page). The
334
+ # mis-selection is pre-existing; resolving it is what turned it into an
335
+ # enforcement bypass. Resolve only when nothing before the match could
336
+ # have been an earlier message, an end-of-options marker, or another
337
+ # command.
338
+ # BUNDLED SHORT OPTIONS (independent review of #3816, round 6). git splits
339
+ # `-am 'WIP first'` into `-a -m`, so the real subject is `WIP first` and
340
+ # the heredoc is git's SECOND message — measured accepting the heredoc's
341
+ # subject while git recorded `WIP first`. A standalone `-m` is therefore
342
+ # not the only spelling that claims the message; any short-option cluster
343
+ # ending in `m` does.
344
+ # ATTACHED VALUES AND --message ABBREVIATIONS (independent review of
345
+ # #3816, round 7). The scan required a space or `=` after the option name,
346
+ # so two spellings git accepts matched nothing: an ATTACHED short-option
347
+ # value (`-mWIP`, which git reads as `-m WIP`) and a long-option
348
+ # abbreviation (`--mes=WIP`), the same abbreviation behaviour this file
349
+ # already models for `--cleanup`. Both were measured accepting a later
350
+ # conforming heredoc while git recorded `WIP` as the subject — confirmed
351
+ # against the raw commit object, not `git log --pretty=%s`. The short arm
352
+ # therefore drops its trailing requirement entirely: a `-` followed by
353
+ # letters ending in `m` claims the message however it is spelled. Wider
354
+ # than git's own abbreviation set on purpose — over-matching only refuses
355
+ # more, which is the recoverable direction.
356
+ # Variable-held for the same bash-3.2 reason as GLUE_CLASS above.
357
+ # AN OPTION NAME BUILT BY A COMMAND SUBSTITUTION IS UNRESOLVABLE
358
+ # (independent review of #3816, round 8). Stripping `$` above collapses the
359
+ # two dollar-QUOTE forms onto their literals, but `--clean$(printf up)=`
360
+ # is a different thing: bash RUNS a program to finish the option name, so
361
+ # the argv git receives is not derivable from this string at all. Measured
362
+ # accepting a 75-byte subject the length gate had recorded as 72.
363
+ #
364
+ # SCOPED TO THE NAME, NOT THE VALUE. The class is a `-`-leading token whose
365
+ # characters up to the substitution contain no `=` — an option NAME being
366
+ # assembled. `--author="$(git config user.name)"` and `--author "$(…)"`
367
+ # both put the substitution in the VALUE, which this file never models and
368
+ # which stays allowed; only `-…$(` before any `=` refuses. Scanned on the
369
+ # RAW windows on purpose: the dequoted copies have had their `$` removed,
370
+ # so the shape is no longer visible there.
371
+ #
372
+ # This is a SHAPE, not a segmentation: it never tries to decide where
373
+ # git's own command ends. Two attempts at that were reverted for opening
374
+ # accept-direction holes, and the reasoning above still stands.
375
+ # WIDENED, and the strategy changed with it (independent review, round 9).
376
+ # The `$(`-only spelling above was the fourth patch in a row that tried to
377
+ # EMULATE what bash does to an argument before git sees it -- round 6
378
+ # removed quotes, round 7 backslashes, round 8 the `$` of a dollar-quote,
379
+ # and each time review found another transform that had been missed. Round
380
+ # 9 found four more, all measured accepting `WIP` as the real subject on
381
+ # bash 3.2.57 and 5.3.15 while the plain spelling of the same command is
382
+ # refused:
383
+ #
384
+ # -$'\155' WIP ANSI-C octal escape decodes to `m`
385
+ # -$'\x6d' WIP ANSI-C hex escape decodes to `m`
386
+ # -`printf m` WIP command substitution, backtick spelling
387
+ # x= … -${x}m WIP parameter expansion
388
+ # -? WIP pathname expansion, with a file named `-m`
389
+ #
390
+ # The last two settle the strategy: an option name finished by a PARAMETER
391
+ # expansion depends on a variable's runtime value, and one finished by a
392
+ # PATHNAME expansion depends on the contents of the working directory.
393
+ # Neither is derivable from the command string at any level of effort, so
394
+ # emulation cannot be completed -- not "has not been completed yet".
395
+ #
396
+ # So the rule is no longer "normalise it and match the literal". It is: an
397
+ # option NAME containing a shell expansion or quoting construct is
398
+ # UNRESOLVABLE, and unresolvable refuses. One rule covers every spelling
399
+ # above, and every spelling nobody has thought of yet, in the fail-closed
400
+ # direction. The dequoting passes above are kept: they still normalise the
401
+ # deterministic removals so the guards RECOGNISE `--clean""up=` and `-\m`
402
+ # rather than merely refusing them, which keeps the existing rows honest.
403
+ #
404
+ # SCOPED TO THE NAME, NOT THE VALUE, exactly as before: the class is a
405
+ # `-`-leading token whose characters up to the substitution contain no `=`.
406
+ # `--author="$(git config user.name)"` and `--author "$(…)"` put the
407
+ # construct in the VALUE and still resolve, pinned in both directions.
408
+ # Scanned on the RAW windows, because the dequoted copies have had `$` and
409
+ # the quote characters removed and the shape is no longer visible there.
410
+ #
411
+ # The class is bracket-only and holds no backslash, per round 8: a POSIX
412
+ # bracket expression has no escape mechanism, and a backslash written
413
+ # inside one becomes a literal member on bash 3.2.
414
+ SUBST_NAME_CLASS='(^|[[:space:]])-[^[:space:]=]*[$`?*[]'
415
+ if [[ "$MSG_PREFIX" =~ $SUBST_NAME_CLASS ]] \
416
+ || [[ "$MSG_SUFFIX" =~ $SUBST_NAME_CLASS ]]; then RESOLVE=0; fi
417
+ SEP_CLASS='[;&|]'
418
+ if [[ "$MSG_PREFIX_DEQ" =~ (^|[[:space:]])(-[a-zA-Z]*m|--m[a-z]*([=[:space:]]|$)) ]] \
419
+ || [[ "$MSG_PREFIX" =~ (^|[[:space:]])--([[:space:]]|$) ]] \
420
+ || [[ "$MSG_PREFIX" =~ $SEP_CLASS ]] \
421
+ || [[ "$MSG_PREFIX" == *$'\n'* ]]; then RESOLVE=0; fi
422
+ # NEWLINE IS A COMMAND SEPARATOR TOO (independent review of #3816, round
423
+ # 7) — the test above. The separator scan covered `;`, `&` and `|` but not
424
+ # a literal newline, so a LATER command's heredoc-shaped `-m` was taken
425
+ # for this commit's message:
426
+ #
427
+ # git commit --amend --no-edit
428
+ # echo -m "$(cat <<'EOF'
429
+ # fix: conforming text unrelated to the commit
430
+ # EOF
431
+ # )"
432
+ #
433
+ # The classifier recognises the leading commit, the capture reaches across
434
+ # the newline into `echo`'s argument, and a conforming string with no
435
+ # relationship to the commit was validated and allowed. Tested as a glob
436
+ # rather than folded into the bracket class, because a literal newline
437
+ # inside a bash regex bracket expression is not portably expressible.
438
+
439
+ # CLEANUP-MODE GUARD (Codex review of #3816, round 4 — BLOCKER). The
440
+ # resolver skips leading blank lines and strips trailing whitespace
441
+ # because git's DEFAULT cleanup=whitespace does. Under
442
+ # `--cleanup=verbatim` git does neither, so a 72-char subject plus three
443
+ # trailing spaces is committed as a 75-byte subject while the hook
444
+ # measured 72 — COMMIT_SUBJECT_TOO_LONG dodged (measured base=2 -> head=0;
445
+ # confirmed by reading the raw commit object, since `git log --pretty=%s`
446
+ # strips trailing whitespace in its own output and hides it).
447
+ # Any named mode other than `whitespace` refuses. A mode set persistently
448
+ # in git config is invisible here and stays a documented residual limit.
449
+ # SCOPE (review of #3816, round 5 — BLOCKER). This scan must exclude the
450
+ # message. `--cleanup=` and `commit.cleanup=` are ordinary English inside
451
+ # a commit message — this repository's own hooks and docs discuss them
452
+ # constantly — and the heredoc BODY sits verbatim inside $CMD, so
453
+ # scanning $CMD refused to resolve any conforming message that merely
454
+ # MENTIONED the token, blocking it with CONVENTIONAL_COMMITS_VIOLATION.
455
+ # Scanning $MSG_PREFIX alone (the fix as first prescribed) would reopen
456
+ # the bypass this guard exists for: git accepts the flag on either side
457
+ # of -m, and `git commit -m "<heredoc>" --cleanup=verbatim` is caught
458
+ # today only because the scan is command-wide. PREFIX + SUFFIX keeps both
459
+ # positions covered while excluding the one span that is message text.
460
+ # The two are joined with a space so a token cannot be forged across the
461
+ # seam out of a prefix tail and a suffix head.
462
+ # KNOWN LIMIT, deliberately fail-closed (#3816, round 6). This window is
463
+ # the whole command minus the message, so a `--cleanup=` that belongs to a
464
+ # DIFFERENT command — `git commit -m "<heredoc>" && echo --cleanup=verbatim`
465
+ # — also refuses, and a conforming commit git would accept stays blocked.
466
+ # Narrowing it to git's own segment was tried and reverted: deciding where
467
+ # git's command ends needs a shell parse, and a substring scan is not one.
468
+ # Trimming at the first `;&|` cut the window short whenever a separator sat
469
+ # inside an ordinary argument — `--author "a&b"`, and equally `--author
470
+ # a\&b` — which hid a REAL trailing `--cleanup=verbatim` and ACCEPTED a
471
+ # 75-byte subject the length gate had measured as 72. Two successive
472
+ # narrowings each reopened that hole on a shape the previous one missed, so
473
+ # the scan stays wide: refusing a commit git would take is recoverable,
474
+ # accepting an over-long subject is not.
475
+ # ABBREVIATIONS (independent review of #3816, round 6). git accepts any
476
+ # unambiguous prefix of a long option, so `--cle=verbatim` sets the mode
477
+ # while matching no literal `--cleanup` — measured accepting a 75-byte
478
+ # subject recorded as 72. The class is deliberately wider than git's own
479
+ # abbreviation set: over-matching only refuses more, which is the safe
480
+ # direction, and no other `--cl` option exists for git commit.
481
+ # LAST DIRECTIVE WINS, AND ONE MATCH CANNOT SEE IT (independent review of
482
+ # #3816, round 7). A bash regex yields ONE BASH_REMATCH, so only the
483
+ # FIRST cleanup directive was inspected — and git applies the LAST one.
484
+ # `--cleanup=whitespace -m <heredoc> --cleanup=verbatim` therefore read as
485
+ # mode=whitespace, resolution stayed enabled, and a 72-character subject
486
+ # plus trailing spaces was accepted while git recorded 75 bytes with the
487
+ # whitespace preserved (confirmed against the raw commit object). Deciding
488
+ # WHICH directive is last needs an argv order this substring scan does not
489
+ # have, so multiplicity itself refuses: more than one directive is
490
+ # unresolvable, not "probably fine". Single-directive behaviour is
491
+ # unchanged.
492
+ CLEANUP_WINDOW="$MSG_PREFIX_DEQ $MSG_SUFFIX_DEQ"
493
+ # `|| true` is load-bearing: this script runs under `set -euo pipefail`,
494
+ # and grep exits 1 when it matches NOTHING — which is the common case, a
495
+ # command with no cleanup directive at all. Without it the pipeline's
496
+ # non-zero status killed the hook outright (exit 1, no verdict) for every
497
+ # ordinary commit. Caught by running the real hook rather than the scan.
498
+ CLEANUP_HITS=$( { printf '%s' "$CLEANUP_WINDOW" | grep -oE '(--cl[a-z]*|commit\.cleanup)[=[:space:]]+[^[:space:]]+' || true; } | wc -l | tr -d ' ')
499
+ if [ "${CLEANUP_HITS:-0}" -gt 1 ]; then
500
+ RESOLVE=0
501
+ elif [[ "$CLEANUP_WINDOW" =~ (--cl[a-z]*|commit\.cleanup)[=[:space:]]+([^[:space:]]+) ]]; then
502
+ if [ "${BASH_REMATCH[2]}" != "whitespace" ]; then RESOLVE=0; fi
503
+ fi
504
+
505
+ # GIT-GENERATED SUBJECTS (independent review of #3816, round 7). With
506
+ # `--squash=<commit>` or `--fixup=<commit>` git composes the subject
507
+ # itself — measured recording `squash! base: something` while a conforming
508
+ # heredoc supplied via -m sailed through. The supplied message is not the
509
+ # subject in these modes at all, so there is nothing here worth measuring
510
+ # and resolution is refused outright. Abbreviations included for the same
511
+ # reason as --cleanup's. Deliberately NOT extended to the other
512
+ # message-SOURCE options (-C/--reuse-message, -c/--reedit-message,
513
+ # -F/--file, -t/--template): `-c` is also a git GLOBAL option that legally
514
+ # precedes the subcommand, so a scan for it would refuse ordinary
515
+ # `git -c k=v commit` invocations. Those remain a disclosed gap rather
516
+ # than a guessed guard.
517
+ if [[ "$MSG_PREFIX_DEQ $MSG_SUFFIX_DEQ" =~ (^|[[:space:]])--(squash|fixup|sq[a-z]*|fix[a-z]*)[=[:space:]] ]]; then RESOLVE=0; fi
518
+ fi
519
+
520
+ if [ "$RESOLVE" = 1 ]; then
521
+ SUBJECT=$(GIT_CMD_LIB="$HOOK_DIR/lib/git-cmd.js" MSG="$MSG" node -e "
522
+ const {resolveCommitSubject}=require(process.env.GIT_CMD_LIB);
523
+ process.stdout.write(resolveCommitSubject(process.env.MSG));
524
+ " 2>/dev/null) || SUBJECT=$(echo "$MSG" | head -1)
525
+ else
526
+ SUBJECT=$(echo "$MSG" | head -1)
527
+ fi
528
+ # Single source of truth for the accepted commit-type list (#3811): the
529
+ # 10 built-ins plus whatever passed the safe-token filter above. Both the
530
+ # regex alternation and the human-readable error text below are derived
531
+ # from this ONE array — no hand-synced second copy.
532
+ #
533
+ # The `"${EXTRA_COMMIT_TYPES[@]+"${EXTRA_COMMIT_TYPES[@]}"}"` form (not
534
+ # plain `"${EXTRA_COMMIT_TYPES[@]}"`) is required: on bash 3.2.57 (this
535
+ # repo's macOS test target), expanding `[@]` on an array that is declared
536
+ # but has zero elements throws "unbound variable" under `set -u` (which
537
+ # this script has via `set -euo pipefail`). Verified directly against
538
+ # /bin/bash 3.2.57 on macOS. The `${arr[@]+word}` form is the
539
+ # nounset-safe idiom for "expand if set, empty otherwise" on empty arrays.
540
+ COMMIT_TYPES=("${BUILTIN_COMMIT_TYPES[@]}" "${EXTRA_COMMIT_TYPES[@]+"${EXTRA_COMMIT_TYPES[@]}"}")
541
+ COMMIT_TYPE_ALT=$(IFS='|'; echo "${COMMIT_TYPES[*]}")
542
+ COMMIT_TYPE_LIST=$(printf '%s, ' "${COMMIT_TYPES[@]}")
543
+ COMMIT_TYPE_LIST="${COMMIT_TYPE_LIST%, }"
544
+ # Typed `valid_types` array (#3811 review finding): CONTRIBUTING.md bans
545
+ # substring/prose matching on `reason` in tests — a test needing to
546
+ # verify the accepted-type set must have a typed field, not grep prose.
547
+ # Safe to build with a bare printf (no JSON-escaping needed): every
548
+ # element of COMMIT_TYPES has already passed the `^[a-z][a-z0-9-]*$`
549
+ # safe-token filter (or is a literal built-in), so none can contain `"`
550
+ # or `\`.
551
+ COMMIT_TYPES_JSON=$(printf '"%s",' "${COMMIT_TYPES[@]}")
552
+ COMMIT_TYPES_JSON="[${COMMIT_TYPES_JSON%,}]"
117
553
  # Validate Conventional Commits format
118
- if ! [[ "$SUBJECT" =~ ^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+\))?:[[:space:]].+ ]]; then
119
- # Emit a typed `code` field alongside `reason` (#2974). Tests assert
120
- # on the stable code string; the reason is the human-readable copy.
121
- echo '{"decision": "block", "code": "CONVENTIONAL_COMMITS_VIOLATION", "reason": "Commit message must follow Conventional Commits: <type>(<scope>): <subject>. Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore. Subject must be <=72 chars, lowercase, imperative mood, no trailing period."}'
554
+ if ! [[ "$SUBJECT" =~ ^($COMMIT_TYPE_ALT)(\(.+\))?:[[:space:]].+ ]]; then
555
+ # Emit typed `code` and `valid_types` fields alongside `reason` (#2974,
556
+ # #3811). Tests assert on the stable code string and the typed array;
557
+ # the reason is the human-readable copy, never grepped by tests.
558
+ echo "{\"decision\": \"block\", \"code\": \"CONVENTIONAL_COMMITS_VIOLATION\", \"valid_types\": $COMMIT_TYPES_JSON, \"reason\": \"Commit message must follow Conventional Commits: <type>(<scope>): <subject>. Valid types: $COMMIT_TYPE_LIST. Subject must be <=72 chars, lowercase, imperative mood, no trailing period.\"}"
122
559
  exit 2
123
560
  fi
124
561
  if [ ${#SUBJECT} -gt 72 ]; then
@@ -366,7 +366,8 @@ process.stdin.on('end', () => {
366
366
  'This edit will not be tracked in STATE.md or produce a SUMMARY.md. ' +
367
367
  'Consider using /gsd:fast for trivial fixes or /gsd:quick for larger changes ' +
368
368
  'to maintain project state tracking. ' +
369
- 'If this is intentional (e.g., user explicitly asked for a direct edit), proceed normally.'
369
+ 'If this is intentional (e.g., user explicitly asked for a direct edit), proceed normally.',
370
+ code: 'WORKFLOW_ADVISORY'
370
371
  }
371
372
  };
372
373
 
package/hooks/hooks.json CHANGED
@@ -28,6 +28,12 @@
28
28
  { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-write-guard.js\"", "timeout": 5 }
29
29
  ]
30
30
  },
31
+ {
32
+ "matcher": "Read|Grep|Bash",
33
+ "hooks": [
34
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-secret-read-guard.js\"", "timeout": 5 }
35
+ ]
36
+ },
31
37
  {
32
38
  "matcher": "Agent|Task",
33
39
  "hooks": [