mandrel 2.30.0 → 2.32.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 (253) hide show
  1. package/.agents/README.md +13 -17
  2. package/.agents/agents/acceptance-critic.md +1 -2
  3. package/.agents/docs/SDLC.md +4 -4
  4. package/.agents/docs/agentrc-reference.json +61 -57
  5. package/.agents/docs/configuration.md +274 -227
  6. package/.agents/docs/execution-reference.md +13 -14
  7. package/.agents/docs/quality-gates.md +195 -23
  8. package/.agents/instructions.md +2 -5
  9. package/.agents/rules/ci-remediation.md +44 -1
  10. package/.agents/rules/git-conventions-reference.md +27 -27
  11. package/.agents/rules/git-conventions.md +4 -2
  12. package/.agents/rules/known-tooling-behavior.md +66 -30
  13. package/.agents/rules/testing-standards.md +35 -71
  14. package/.agents/runtime-deps.json +0 -1
  15. package/.agents/schemas/agentrc.schema.json +1939 -1400
  16. package/.agents/schemas/lifecycle/README.md +21 -14
  17. package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
  18. package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
  19. package/.agents/scripts/README.md +7 -29
  20. package/.agents/scripts/apply-quality-bootstrap.js +27 -34
  21. package/.agents/scripts/bootstrap.js +28 -26
  22. package/.agents/scripts/check-baseline-drift.js +73 -13
  23. package/.agents/scripts/check-baseline-scope.js +362 -0
  24. package/.agents/scripts/check-dead-exports.js +9 -1
  25. package/.agents/scripts/check-gherkin-corpus.js +508 -0
  26. package/.agents/scripts/check-knip-entries.js +136 -0
  27. package/.agents/scripts/check-lifecycle-lint.js +36 -112
  28. package/.agents/scripts/check-schema-references.js +1 -1
  29. package/.agents/scripts/diagnose-friction.js +7 -4
  30. package/.agents/scripts/generate-config-docs.js +263 -171
  31. package/.agents/scripts/install-matrix-assert.js +0 -1
  32. package/.agents/scripts/lib/ITicketingProvider.js +0 -58
  33. package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
  34. package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
  35. package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
  36. package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
  37. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +0 -1
  38. package/.agents/scripts/lib/baselines/envelope.js +41 -60
  39. package/.agents/scripts/lib/baselines/git-base.js +30 -37
  40. package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
  41. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
  42. package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
  43. package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
  44. package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
  45. package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
  46. package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
  47. package/.agents/scripts/lib/bdd-step-index.js +326 -0
  48. package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
  49. package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
  50. package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
  51. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
  52. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
  53. package/.agents/scripts/lib/checks/loop-health.js +9 -37
  54. package/.agents/scripts/lib/child-exec.js +193 -0
  55. package/.agents/scripts/lib/cli/standard-args.js +1 -1
  56. package/.agents/scripts/lib/cli-args.js +64 -0
  57. package/.agents/scripts/lib/close-validation/gates.js +2 -2
  58. package/.agents/scripts/lib/close-validation/runner.js +3 -3
  59. package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
  60. package/.agents/scripts/lib/config/commands.js +3 -5
  61. package/.agents/scripts/lib/config/explain.js +5 -7
  62. package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
  63. package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
  64. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
  65. package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
  66. package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
  67. package/.agents/scripts/lib/config/gates/index.js +5 -2
  68. package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
  69. package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
  70. package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
  71. package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
  72. package/.agents/scripts/lib/config/gates/shared.js +58 -9
  73. package/.agents/scripts/lib/config/github.js +0 -1
  74. package/.agents/scripts/lib/config/limits.js +3 -48
  75. package/.agents/scripts/lib/config/qa.js +105 -0
  76. package/.agents/scripts/lib/config/temp-paths.js +6 -5
  77. package/.agents/scripts/lib/config-settings-schema-delivery.js +237 -56
  78. package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
  79. package/.agents/scripts/lib/config-settings-schema.js +386 -39
  80. package/.agents/scripts/lib/crap-baseline-join.js +126 -9
  81. package/.agents/scripts/lib/crap-utils.js +84 -520
  82. package/.agents/scripts/lib/dead-exports-knip.js +79 -10
  83. package/.agents/scripts/lib/degraded-mode.js +2 -2
  84. package/.agents/scripts/lib/doc-tiers.js +3 -3
  85. package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
  86. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
  87. package/.agents/scripts/lib/fs-walk.js +52 -0
  88. package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
  89. package/.agents/scripts/lib/git-utils.js +16 -36
  90. package/.agents/scripts/lib/knip-entry-sync.js +469 -0
  91. package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
  92. package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
  93. package/.agents/scripts/lib/observability/signal-validator.js +5 -85
  94. package/.agents/scripts/lib/observability/signals-writer.js +19 -62
  95. package/.agents/scripts/lib/observability/source-classifier.js +5 -7
  96. package/.agents/scripts/lib/observability/terse-result.js +3 -3
  97. package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
  98. package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
  99. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
  100. package/.agents/scripts/lib/orchestration/code-review.js +2 -2
  101. package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
  102. package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
  103. package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
  104. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
  105. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
  106. package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
  107. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +6 -0
  108. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +0 -1
  109. package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
  110. package/.agents/scripts/lib/orchestration/resolve-stories.js +5 -15
  111. package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
  112. package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
  113. package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
  118. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
  119. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
  120. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +17 -2
  121. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
  122. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
  123. package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
  124. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +65 -25
  125. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
  126. package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
  127. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
  128. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
  129. package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
  130. package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
  131. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
  132. package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
  133. package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
  134. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
  135. package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
  136. package/.agents/scripts/lib/signals/detectors/common.js +1 -1
  137. package/.agents/scripts/lib/signals/index.js +8 -6
  138. package/.agents/scripts/lib/signals/schema.js +20 -25
  139. package/.agents/scripts/lib/signals/write.js +8 -8
  140. package/.agents/scripts/lib/story-body/story-body.js +12 -59
  141. package/.agents/scripts/lib/temp-retention.js +1 -1
  142. package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
  143. package/.agents/scripts/lib/ticket-body-sections.js +4 -5
  144. package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
  145. package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
  146. package/.agents/scripts/lib/worktree-manager.js +2 -3
  147. package/.agents/scripts/lint-label-vocabulary.js +2 -24
  148. package/.agents/scripts/pr-watch-with-update.js +7 -5
  149. package/.agents/scripts/providers/github/cache.js +2 -2
  150. package/.agents/scripts/providers/github/comments.js +6 -28
  151. package/.agents/scripts/providers/github/compose.js +0 -15
  152. package/.agents/scripts/providers/github/errors.js +10 -27
  153. package/.agents/scripts/providers/github/request-helpers.js +1 -2
  154. package/.agents/scripts/providers/github/sub-issues.js +10 -218
  155. package/.agents/scripts/providers/github.js +4 -7
  156. package/.agents/scripts/prune-baseline-orphans.js +181 -0
  157. package/.agents/scripts/resolve-stories.js +0 -2
  158. package/.agents/scripts/run-lint.js +61 -61
  159. package/.agents/scripts/run-test-profile.js +6 -6
  160. package/.agents/scripts/run-verify.js +48 -30
  161. package/.agents/scripts/single-story-close.js +20 -0
  162. package/.agents/scripts/single-story-init.js +12 -35
  163. package/.agents/scripts/update-dead-exports-baseline.js +321 -0
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
  165. package/.agents/skills/skills.index.json +2 -12
  166. package/.agents/skills/stack/qa/playwright/SKILL.md +48 -0
  167. package/.agents/workflows/audit-documentation.md +5 -6
  168. package/.agents/workflows/audit-to-stories.md +2 -2
  169. package/.agents/workflows/helpers/audit-lens-core.md +11 -12
  170. package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
  171. package/.agents/workflows/helpers/code-review.md +3 -8
  172. package/.agents/workflows/helpers/deliver-reference.md +2 -1
  173. package/.agents/workflows/helpers/deliver-story-reference.md +27 -16
  174. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
  175. package/.agents/workflows/mandrel-update.md +10 -10
  176. package/.agents/workflows/qa-assist.md +15 -20
  177. package/.agents/workflows/qa-explore.md +9 -8
  178. package/README.md +1 -1
  179. package/docs/CHANGELOG.md +49 -0
  180. package/lib/migrations/index.js +2 -0
  181. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
  182. package/package.json +12 -3
  183. package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
  184. package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
  185. package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
  186. package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
  187. package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
  188. package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
  189. package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
  190. package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
  191. package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
  192. package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
  193. package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
  194. package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
  195. package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
  196. package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
  197. package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
  198. package/.agents/scripts/check-gherkin-placeholders.js +0 -663
  199. package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
  200. package/.agents/scripts/lib/audit-suite/cli.js +0 -64
  201. package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
  202. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
  203. package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
  204. package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
  205. package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
  206. package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
  207. package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
  208. package/.agents/scripts/lib/crap-baseline-index.js +0 -46
  209. package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
  210. package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
  211. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
  212. package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
  213. package/.agents/scripts/lib/mutation/config-detector.js +0 -119
  214. package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
  215. package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
  216. package/.agents/scripts/lib/observability/active-story-env.js +0 -170
  217. package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
  218. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
  219. package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
  220. package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
  221. package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
  222. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
  223. package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
  224. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
  225. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
  226. package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
  227. package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
  228. package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
  229. package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
  230. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
  231. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
  232. package/.agents/scripts/lib/planning-corpus.js +0 -37
  233. package/.agents/scripts/lib/qa/coverage-report.js +0 -181
  234. package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
  235. package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
  236. package/.agents/scripts/lib/signals/detectors/index.js +0 -14
  237. package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
  238. package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
  239. package/.agents/scripts/lib/signals/read.js +0 -268
  240. package/.agents/scripts/lib/signals/span-tree.js +0 -291
  241. package/.agents/scripts/lib/story-lifecycle.js +0 -194
  242. package/.agents/scripts/lib/story-plan.js +0 -379
  243. package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
  244. package/.agents/scripts/lib/util/phase-timer.js +0 -163
  245. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
  246. package/.agents/scripts/lint-baseline.js +0 -507
  247. package/.agents/scripts/providers/github/prs.js +0 -103
  248. package/.agents/scripts/signals-view.js +0 -309
  249. package/.agents/scripts/story-plan.js +0 -370
  250. package/.agents/scripts/sync-branch-from-base.js +0 -149
  251. package/.agents/scripts/validate-docs-freshness.js +0 -314
  252. package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
  253. package/.agents/workflows/helpers/signals.md +0 -112
@@ -1,456 +0,0 @@
1
- /**
2
- * Tool-trace hook (Epic #1030 Story #1043 / Task #1058).
3
- *
4
- * Entry point invoked from `.claude/settings.json` PreToolUse and
5
- * PostToolUse hook entries. Resolves the active Epic + Story from
6
- * environment variables (`CC_EPIC_ID` / `CC_STORY_ID`), pairs Pre/Post
7
- * tool-call events, and appends one `kind:"trace"` NDJSON line per tool
8
- * call to `temp/run-<id>/stories/story-<sid>/traces.ndjson` via the
9
- * `signals-writer.appendTrace` helper.
10
- *
11
- * Robustness contract (Tech Spec #1032 §observability + §security):
12
- * - **No-op outside an active Story.** When `CC_EPIC_ID` or
13
- * `CC_STORY_ID` is unset / non-numeric the hook returns immediately
14
- * without touching the filesystem. This prevents tooling invoked
15
- * during planning, dispatch, or close phases from polluting random
16
- * NDJSON files.
17
- * - **Best-effort.** The top-level `main(event)` is wrapped in
18
- * `try/catch`. A failing hook MUST never block tool execution —
19
- * `appendTrace` already swallows fs / serialisation failures, but
20
- * the outer guard catches anything that might still escape (e.g. an
21
- * event with a circular reference reachable before serialisation).
22
- * - **Privacy-preserving detail.** Bash commands and file paths are
23
- * hashed (sha256) before being recorded. We deliberately store
24
- * `targetHash` rather than the raw value because a Bash command may
25
- * embed an env-var-laden token and a file path may leak the
26
- * operator's local layout. The hash gives the analyzer enough to
27
- * count repeats (churn / retry detectors) without retaining the
28
- * plaintext.
29
- * - **Capped detail size.** Even after hashing, supplementary fields
30
- * (durationMs, exit code summaries, etc.) are clamped to
31
- * `MAX_DETAIL_BYTES` so a runaway tool argument can't bloat the
32
- * trace file.
33
- *
34
- * Pre/Post pairing:
35
- * - `PreToolUse` records the start timestamp keyed by the event's
36
- * `tool_use_id` (provided by the harness) into a process-local
37
- * `Map`. Pre events themselves do NOT append to `traces.ndjson` —
38
- * only the matching Post event does, once it has a duration to
39
- * attach.
40
- * - `PostToolUse` looks up the start record, computes
41
- * `durationMs = now - startedAt`, appends the trace line, and
42
- * evicts the start record. A Post without a matching Pre still
43
- * appends a single trace line (with `durationMs: null`) so the
44
- * event is not silently dropped — this matches the AC: "PostToolUse
45
- * without a matching PreToolUse logs once and returns without
46
- * throwing".
47
- */
48
-
49
- import { createHash } from 'node:crypto';
50
-
51
- import { appendTrace } from './signals-writer.js';
52
-
53
- /**
54
- * Maximum size (bytes) for any single string field stored in the trace
55
- * `details` payload after hashing. Hashes are 64 hex chars (always under
56
- * the cap); the limit guards supplementary plaintext fields that may
57
- * legitimately appear (e.g. tool name, phase, exit-code summaries).
58
- */
59
- const MAX_DETAIL_BYTES = 1024;
60
-
61
- /**
62
- * Process-local Map keyed by the harness-provided `tool_use_id` (or a
63
- * synthetic key when the harness omits it). Holds `{ startedAt, tool }`
64
- * for each in-flight tool call until the matching Post event lands.
65
- *
66
- * The Map lives at module scope on purpose: PreToolUse and PostToolUse
67
- * are invoked as separate hook calls within the same parent agent
68
- * process, so a module-level Map preserves state across calls without
69
- * any external coordination. If the agent process crashes mid-tool-
70
- * call, the Map dies with it and no orphan record leaks.
71
- */
72
- const inflight = new Map();
73
-
74
- /**
75
- * SHA-256 hex digest of the input string, prefixed with `sha256:` to
76
- * mirror the convention used elsewhere in the signals pipeline (see
77
- * `signal-event.schema.json` `details.targetHash`). Returns `null` for
78
- * empty / non-string input so callers can omit the field entirely
79
- * rather than recording a hash of the empty string.
80
- *
81
- * @param {unknown} value
82
- * @returns {string|null}
83
- */
84
- function hashTarget(value) {
85
- if (typeof value !== 'string' || value.length === 0) return null;
86
- return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
87
- }
88
-
89
- /**
90
- * Normalise a Bash command string into a stable identity for the retry
91
- * detector (Story #1768 / Task #1775). The output is what we hash to
92
- * derive `details.normalizedHash`; the raw value is still hashed
93
- * separately into `details.targetHash` so the privacy contract for the
94
- * raw command is unchanged.
95
- *
96
- * ## Rules (documented contract — kept narrow on purpose)
97
- *
98
- * 1. **Whitespace collapse.** Leading/trailing whitespace is stripped
99
- * and runs of internal whitespace collapse to a single space.
100
- * `npm test` → `npm test`. `\n` and `\t` count as whitespace.
101
- *
102
- * 2. **Strip benign trailing flags.** `--no-color` and `--quiet`
103
- * anywhere in the argv are removed (with their surrounding
104
- * whitespace re-collapsed). These flags affect output only and
105
- * never change the command's identity for retry-detection
106
- * purposes. The list is deliberately tiny — adding more flags is
107
- * a new ADR conversation, not a hook tweak.
108
- *
109
- * 3. **`npm test` ≡ `npm run test`.** Only this single paraphrase
110
- * collapses. `npm run lint` and `npm test` do **not** collapse;
111
- * `npm run build` and `npm build` do **not** collapse. The
112
- * treatment is intentional: it covers the one paraphrase the
113
- * `package.json` `scripts.test` shorthand makes idiomatic, and
114
- * stops there.
115
- *
116
- * Returns `null` when the input is not a non-empty string (so the
117
- * caller can omit the field rather than recording a hash of `''`).
118
- *
119
- * Exported for testing — the unit suite asserts the collapse rules.
120
- *
121
- * @param {unknown} command
122
- * @returns {string|null}
123
- */
124
- export function normaliseBashCommand(command) {
125
- if (typeof command !== 'string' || command.length === 0) return null;
126
-
127
- // Step 1: whitespace collapse. Trim then collapse internal runs.
128
- let normalised = command.trim().replace(/\s+/g, ' ');
129
- if (normalised.length === 0) return null;
130
-
131
- // Step 2: strip benign trailing/inline flags. Token-level removal so
132
- // we don't accidentally chew into a longer flag (e.g. `--quiet-mode`
133
- // would not match `--quiet`).
134
- const benignFlags = new Set(['--no-color', '--quiet']);
135
- const tokens = normalised.split(' ').filter((tok) => !benignFlags.has(tok));
136
- normalised = tokens.join(' ');
137
- if (normalised.length === 0) return null;
138
-
139
- // Step 3: collapse `npm run test` → `npm test`. Only this exact
140
- // paraphrase. We rewrite to the shorter form because the longer form
141
- // is more verbose; either direction would work, but the `scripts.test`
142
- // shorthand is the form most operators type.
143
- if (normalised === 'npm run test') {
144
- normalised = 'npm test';
145
- }
146
-
147
- return normalised;
148
- }
149
-
150
- /**
151
- * Clamp a string field so a misbehaving tool argument can't bloat the
152
- * trace file. Non-string input is returned unchanged (numbers, booleans,
153
- * etc. are size-bounded by JSON serialisation).
154
- */
155
- function clamp(value) {
156
- if (typeof value !== 'string') return value;
157
- if (Buffer.byteLength(value, 'utf8') <= MAX_DETAIL_BYTES) return value;
158
- // Truncate by code units; exact byte cap not critical because the
159
- // limit exists for size protection, not for security.
160
- return `${value.slice(0, MAX_DETAIL_BYTES)}…`;
161
- }
162
-
163
- /**
164
- * Resolve the active Epic + Story from env vars. Returns `null` when
165
- * `CC_STORY_ID` is unset / non-numeric — the caller treats this as
166
- * the "outside an active Story, no-op" case.
167
- *
168
- * Story #2874 — `epicId` can be `null` for standalone Stories (run
169
- * via `/single-story-deliver`). When `CC_STORY_ID` is present but
170
- * `CC_EPIC_ID` is absent, the hook still emits trace lines, keyed
171
- * to the story only; the trace's `epicId` field is `null`. The
172
- * no-op contract for the fully-no-context case (both vars absent)
173
- * is preserved: it returns null.
174
- *
175
- * Exported for testing (the unit suite asserts that an unset
176
- * `CC_STORY_ID` makes the hook take the no-op branch, and that an
177
- * unset `CC_EPIC_ID` with a present `CC_STORY_ID` yields a
178
- * `{ epicId: null, storyId }` envelope).
179
- *
180
- * @param {NodeJS.ProcessEnv} [env]
181
- * @returns {{ epicId: number|null, storyId: number }|null}
182
- */
183
- export function resolveActiveStory(env = process.env) {
184
- const epicRaw = env.CC_EPIC_ID;
185
- const storyRaw = env.CC_STORY_ID;
186
- if (!storyRaw) return null;
187
- const storyId = Number.parseInt(storyRaw, 10);
188
- if (!Number.isInteger(storyId) || storyId <= 0) return null;
189
- if (!epicRaw) return { epicId: null, storyId };
190
- const epicId = Number.parseInt(epicRaw, 10);
191
- if (!Number.isInteger(epicId) || epicId <= 0) return null;
192
- return { epicId, storyId };
193
- }
194
-
195
- /**
196
- * Story #1768 / Task #1775 — extract the Bash-input hashing into a
197
- * helper so `buildDetails` stays under its CRAP baseline. Returns
198
- * `{ targetHash, normalizedHash? }` for Bash input, `null` for any
199
- * other shape so the caller can fall through to the file_path / pattern
200
- * branches unchanged. The `normalizedHash` is omitted when
201
- * `normaliseBashCommand` rejects the input (non-string / empty after
202
- * normalisation).
203
- *
204
- * @param {object} toolInput
205
- * @returns {{ targetHash: string, normalizedHash?: string } | null}
206
- */
207
- function hashBashInput(toolInput) {
208
- if (typeof toolInput?.command !== 'string') return null;
209
- const out = { targetHash: hashTarget(toolInput.command) };
210
- const normalised = normaliseBashCommand(toolInput.command);
211
- if (normalised !== null) {
212
- out.normalizedHash = hashTarget(normalised);
213
- }
214
- return out;
215
- }
216
-
217
- /**
218
- * Build the canonical `details` block for a trace line. Hashes the
219
- * Bash command (`tool_input.command`) and any file-path-shaped input
220
- * (`tool_input.file_path`, `tool_input.path`, `tool_input.pattern`)
221
- * before recording. The raw value never appears on disk.
222
- *
223
- * @param {{ tool: string, toolInput?: object, durationMs?: number|null, exitCode?: number|null }} args
224
- * @returns {object}
225
- */
226
- function buildDetails({ tool, toolInput, durationMs, exitCode }) {
227
- const details = {};
228
- if (typeof durationMs === 'number') {
229
- details.durationMs = durationMs;
230
- } else if (durationMs === null) {
231
- details.durationMs = null;
232
- }
233
-
234
- // Bash exit code — the retry detector's failure predicate
235
- // (`details.exitCode !== 0`) can only fire once this is recorded. Only
236
- // Bash PostToolUse events carry a meaningful exit code; other tools omit
237
- // the field entirely (Epic #4406 / Story #4413).
238
- if (tool === 'Bash' && typeof exitCode === 'number') {
239
- details.exitCode = exitCode;
240
- }
241
-
242
- if (toolInput && typeof toolInput === 'object') {
243
- // Bash: hash `command` so a token-laden string never lands on disk.
244
- // Story #1768 also records `normalizedHash` (paraphrase-collapsed)
245
- // for retry detection. See `hashBashInput`.
246
- const bash = hashBashInput(toolInput);
247
- if (bash) Object.assign(details, bash);
248
- // Edit / Write / Read: hash `file_path` for the same reason — the
249
- // operator's local path layout is not interesting to the analyzer.
250
- if (
251
- typeof toolInput.file_path === 'string' &&
252
- details.targetHash === undefined
253
- ) {
254
- details.targetHash = hashTarget(toolInput.file_path);
255
- }
256
- // Glob / Grep: pattern is not secret, but we hash for consistency
257
- // and so the `churn` detector can count repeats by hash equality.
258
- if (
259
- typeof toolInput.pattern === 'string' &&
260
- details.targetHash === undefined
261
- ) {
262
- details.targetHash = hashTarget(toolInput.pattern);
263
- }
264
- // Agent: record the literal `model` arg (Story #2590). The arg is a
265
- // short enum value ('haiku'/'sonnet'/'opus') with no operator-secret
266
- // content, so we store it plaintext. Records absence as `null` for
267
- // Agent calls only — the field is omitted for every other tool — so
268
- // the analyzer can distinguish "Agent fired without model" from
269
- // "non-Agent tool".
270
- if (tool === 'Agent') {
271
- details.model =
272
- typeof toolInput.model === 'string' ? clamp(toolInput.model) : null;
273
- }
274
- }
275
-
276
- // Clamp the tool name (defensive — Claude's tool names are short, but
277
- // a hostile harness could inject a long string and we'd rather cap
278
- // here than leak it onto disk).
279
- if (tool) details.tool = clamp(tool);
280
-
281
- return details;
282
- }
283
-
284
- /**
285
- * Extract the Bash exit code from a PostToolUse event. The harness reports
286
- * the tool result under `tool_response` (occasionally at the event root);
287
- * the exit-code field name is not contractually fixed across harness
288
- * versions, so we probe the known aliases and return the first numeric
289
- * hit. Returns `null` when no numeric exit code is present — the caller
290
- * omits `details.exitCode` entirely rather than recording a guess.
291
- *
292
- * Exported for testing.
293
- *
294
- * @param {object} event
295
- * @returns {number|null}
296
- */
297
- export function extractExitCode(event) {
298
- const candidates = [
299
- event?.tool_response?.exitCode,
300
- event?.tool_response?.exit_code,
301
- event?.tool_response?.returnCode,
302
- event?.tool_response?.code,
303
- event?.tool_response?.status,
304
- event?.exit_code,
305
- event?.exitCode,
306
- ];
307
- for (const c of candidates) {
308
- if (typeof c === 'number' && Number.isFinite(c)) return c;
309
- }
310
- return null;
311
- }
312
-
313
- /**
314
- * PreToolUse handler. Stashes `{ startedAt, tool }` into the in-flight
315
- * Map keyed by the harness `tool_use_id`. Does not append to
316
- * `traces.ndjson` — the matching Post event does that once it has a
317
- * duration to attach.
318
- *
319
- * Exported for testing.
320
- *
321
- * @param {object} event
322
- */
323
- export function handlePre(event) {
324
- const id = event?.tool_use_id ?? event?.id ?? null;
325
- if (!id) return; // No id => no way to pair on Post; drop silently.
326
- const tool = event?.tool_name ?? event?.tool ?? null;
327
- inflight.set(id, { startedAt: Date.now(), tool });
328
- }
329
-
330
- /**
331
- * PostToolUse handler. Looks up the start record, computes a duration,
332
- * appends one `kind:"trace"` line to `traces.ndjson`, and evicts the
333
- * start record. A Post without a matching Pre still appends a single
334
- * line with `durationMs: null` (AC: "PostToolUse without a matching
335
- * PreToolUse logs once and returns without throwing").
336
- *
337
- * Exported for testing.
338
- *
339
- * @param {object} event
340
- * @param {{ epicId: number, storyId: number }} active
341
- */
342
- export async function handlePost(event, active) {
343
- const id = event?.tool_use_id ?? event?.id ?? null;
344
- const tool = event?.tool_name ?? event?.tool ?? 'unknown';
345
- const toolInput = event?.tool_input;
346
- const exitCode = extractExitCode(event);
347
-
348
- let durationMs = null;
349
- if (id && inflight.has(id)) {
350
- const start = inflight.get(id);
351
- inflight.delete(id);
352
- durationMs = Date.now() - start.startedAt;
353
- }
354
-
355
- const trace = {
356
- ts: new Date().toISOString(),
357
- kind: 'trace',
358
- emitter: { tool: clamp(tool) },
359
- epicId: active.epicId,
360
- storyId: active.storyId,
361
- taskId: null,
362
- phase:
363
- typeof process.env.CC_PHASE === 'string' ? process.env.CC_PHASE : null,
364
- details: buildDetails({ tool, toolInput, durationMs, exitCode }),
365
- };
366
-
367
- await appendTrace({
368
- epicId: active.epicId,
369
- storyId: active.storyId,
370
- trace,
371
- });
372
- }
373
-
374
- /**
375
- * Top-level entry point. Routes by `event.hook_event_name` to the
376
- * Pre/Post handlers; swallows every error so tool execution is never
377
- * blocked.
378
- *
379
- * @param {object} event
380
- * @returns {Promise<void>}
381
- */
382
- export async function main(event) {
383
- try {
384
- if (!event || typeof event !== 'object') return;
385
- const active = resolveActiveStory();
386
-
387
- const phase = event.hook_event_name;
388
- if (phase === 'PreToolUse') {
389
- // Pre-pairing only matters for the trace-line duration, which only
390
- // the Story-scoped trace path records.
391
- if (active) handlePre(event);
392
- } else if (phase === 'PostToolUse') {
393
- if (active) await handlePost(event, active);
394
- }
395
- // Any other phase is silently ignored — the hook is registered for
396
- // Pre/Post only; receiving anything else is a configuration error
397
- // we should not amplify by throwing.
398
- } catch {
399
- // Swallow. Observability MUST NOT take down the runner.
400
- // signals-writer.appendTrace already logs its own failures via
401
- // Logger.warn; anything that escapes to here is a programmer
402
- // error in this module, not an operator-visible signal.
403
- }
404
- }
405
-
406
- /**
407
- * CLI entry point: read one JSON event from stdin, dispatch to `main`,
408
- * and exit 0 unconditionally. The harness pipes the hook payload as
409
- * stdin per the `.claude/settings.json` `type: "command"` contract.
410
- *
411
- * Exported so tests can run the file as a module without re-spawning
412
- * Node. Production callers go through the bin shim
413
- * (`node lib/observability/tool-trace-hook.js`) which immediately
414
- * delegates here.
415
- */
416
- export async function runFromStdin() {
417
- let raw = '';
418
- try {
419
- process.stdin.setEncoding('utf8');
420
- for await (const chunk of process.stdin) raw += chunk;
421
- } catch {
422
- return; // No stdin => nothing to do.
423
- }
424
-
425
- let event;
426
- try {
427
- event = raw.length > 0 ? JSON.parse(raw) : null;
428
- } catch {
429
- return; // Malformed JSON => silent no-op (never block the tool).
430
- }
431
- if (!event) return;
432
-
433
- await main(event);
434
- }
435
-
436
- // Auto-run when invoked directly: `node tool-trace-hook.js`.
437
- const isDirect = (() => {
438
- try {
439
- const argv1 = process.argv[1] ?? '';
440
- return argv1.endsWith('tool-trace-hook.js');
441
- } catch {
442
- return false;
443
- }
444
- })();
445
- if (isDirect) {
446
- runFromStdin();
447
- }
448
-
449
- /**
450
- * Test-only: clear the in-flight Map. Module-level state survives
451
- * across tests within the same Node process; the unit suite calls this
452
- * from `beforeEach` to keep tests independent.
453
- */
454
- export function _resetInflightForTests() {
455
- inflight.clear();
456
- }
@@ -1,111 +0,0 @@
1
- /**
2
- * acceptance-clusters.js — Epic #4475 (M4-B), design §S2a.
3
- *
4
- * The **acceptance-dilution guard** for single delivery. In the fan-out
5
- * shape, each Story ran its own fresh-context acceptance self-eval critic, so
6
- * a 14-AC Epic decomposed into 4 Stories got ~4 independent critic passes for
7
- * free. Single delivery collapses the whole Epic into ONE guarded session — if
8
- * we ran a single critic over all 14 ACs it would degrade to two redraft
9
- * rounds scoring everything at once (the exact "acceptance dilution" risk the
10
- * adopted design calls out as blocking).
11
- *
12
- * This module restores the distributed coverage: the Epic's
13
- * `## Acceptance Table` AC ids are split into clusters of at most
14
- * `clusterCeiling` (config `delivery.acceptanceEval.clusterCeiling`, default 4,
15
- * hard-clamped to `[1, 8]`), and the executor spawns ONE maker-blind
16
- * fresh-context `Agent` critic per cluster. The cluster count is therefore
17
- * `ceil(totalACs / clusterCeiling)` — a deterministic fan-out width the
18
- * consuming executor and its isolation test both key off.
19
- *
20
- * Pure and total — inputs in, clusters out. No I/O, no throws on malformed
21
- * input (a non-array / empty AC set yields zero clusters — the executor then
22
- * has nothing to critique, which the front gate already forecloses by refusing
23
- * `acceptance::n-a`).
24
- */
25
-
26
- import { getAcceptanceEval } from '../config/acceptance-eval.js';
27
-
28
- /**
29
- * @typedef {object} AcceptanceCluster
30
- * @property {number} clusterIndex Zero-based position in the fan-out.
31
- * @property {string} clusterId Stable id (`ac-cluster-<n>`, 1-based).
32
- * @property {string[]} acIds The AC ids this critic scores (≤ ceiling).
33
- */
34
-
35
- /**
36
- * Split an ordered list of AC ids into fresh-context critic clusters of at
37
- * most `ceiling` ids each. The count is exactly `ceil(acIds.length / ceiling)`
38
- * — the load-bearing fan-out-width invariant the isolation test pins.
39
- *
40
- * The input order is preserved (the executor orders ACs by their associated
41
- * Delivery-Slicing slice before calling this, so same-slice ACs land adjacent
42
- * and tend to share a cluster), but ordering is a nicety — the count guarantee
43
- * is what forecloses dilution.
44
- *
45
- * @param {string[]} acIds Ordered AC ids (e.g. `['AC-1', 'AC-2', …]`).
46
- * @param {number} [ceiling=4] Max ACs per cluster. A non-positive / non-
47
- * integer ceiling degrades to 1 (one AC per
48
- * cluster — the maximally-distributed, never-
49
- * collapsed fan-out).
50
- * @returns {AcceptanceCluster[]} `ceil(n / ceiling)` clusters; `[]` when there
51
- * are no AC ids.
52
- */
53
- export function clusterAcceptanceCriteria(acIds, ceiling = 4) {
54
- const ids = Array.isArray(acIds)
55
- ? acIds.filter((id) => typeof id === 'string' && id.length > 0)
56
- : [];
57
- if (ids.length === 0) return [];
58
-
59
- const size =
60
- typeof ceiling === 'number' && Number.isInteger(ceiling) && ceiling >= 1
61
- ? ceiling
62
- : 1;
63
-
64
- const clusters = [];
65
- for (let start = 0; start < ids.length; start += size) {
66
- const clusterIndex = clusters.length;
67
- clusters.push({
68
- clusterIndex,
69
- clusterId: `ac-cluster-${clusterIndex + 1}`,
70
- acIds: ids.slice(start, start + size),
71
- });
72
- }
73
- return clusters;
74
- }
75
-
76
- /**
77
- * The expected fan-out width for `total` ACs at `ceiling` — the number of
78
- * independent maker-blind critic passes the single-delivery executor spawns.
79
- * Exported so the executor (and its isolation test) can assert the number of
80
- * `Agent` critic spawns equals this without re-deriving the ceil math.
81
- *
82
- * @param {number} total Total AC count.
83
- * @param {number} ceiling Max ACs per cluster (≥ 1; degrades to 1 otherwise).
84
- * @returns {number} `ceil(total / ceiling)`; `0` when `total <= 0`.
85
- */
86
- export function expectedClusterCount(total, ceiling) {
87
- const n = Number.isInteger(total) && total > 0 ? total : 0;
88
- if (n === 0) return 0;
89
- const size =
90
- typeof ceiling === 'number' && Number.isInteger(ceiling) && ceiling >= 1
91
- ? ceiling
92
- : 1;
93
- return Math.ceil(n / size);
94
- }
95
-
96
- /**
97
- * Convenience wrapper: resolve the effective `clusterCeiling` from config
98
- * (applying the framework default + the undisableable `[1, 8]` clamp) and
99
- * cluster `acIds` with it. The single home the executor's `slice-phase`
100
- * substrate and any future CLI use so the clamp is applied once.
101
- *
102
- * @param {string[]} acIds
103
- * @param {object | null | undefined} config Resolved `.agentrc.json`.
104
- * @returns {{ clusters: AcceptanceCluster[], clusterCeiling: number, totalAcs: number }}
105
- */
106
- export function clusterAcceptanceForConfig(acIds, config) {
107
- const { clusterCeiling } = getAcceptanceEval(config);
108
- const clusters = clusterAcceptanceCriteria(acIds, clusterCeiling);
109
- const ids = Array.isArray(acIds) ? acIds.filter(Boolean) : [];
110
- return { clusters, clusterCeiling, totalAcs: ids.length };
111
- }