mandrel 2.31.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 (250) 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/git-conventions-reference.md +27 -27
  10. package/.agents/rules/git-conventions.md +4 -2
  11. package/.agents/rules/known-tooling-behavior.md +66 -30
  12. package/.agents/rules/testing-standards.md +35 -71
  13. package/.agents/runtime-deps.json +0 -1
  14. package/.agents/schemas/agentrc.schema.json +1939 -1400
  15. package/.agents/schemas/lifecycle/README.md +21 -14
  16. package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
  17. package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
  18. package/.agents/scripts/README.md +7 -29
  19. package/.agents/scripts/apply-quality-bootstrap.js +27 -34
  20. package/.agents/scripts/bootstrap.js +28 -26
  21. package/.agents/scripts/check-baseline-drift.js +73 -13
  22. package/.agents/scripts/check-baseline-scope.js +362 -0
  23. package/.agents/scripts/check-dead-exports.js +9 -1
  24. package/.agents/scripts/check-gherkin-corpus.js +508 -0
  25. package/.agents/scripts/check-knip-entries.js +136 -0
  26. package/.agents/scripts/check-lifecycle-lint.js +36 -112
  27. package/.agents/scripts/check-schema-references.js +1 -1
  28. package/.agents/scripts/diagnose-friction.js +7 -4
  29. package/.agents/scripts/generate-config-docs.js +263 -171
  30. package/.agents/scripts/install-matrix-assert.js +0 -1
  31. package/.agents/scripts/lib/ITicketingProvider.js +0 -58
  32. package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
  33. package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
  34. package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
  35. package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
  36. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +0 -1
  37. package/.agents/scripts/lib/baselines/envelope.js +41 -60
  38. package/.agents/scripts/lib/baselines/git-base.js +30 -37
  39. package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
  40. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
  41. package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
  42. package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
  43. package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
  44. package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
  45. package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
  46. package/.agents/scripts/lib/bdd-step-index.js +326 -0
  47. package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
  48. package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
  49. package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
  50. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
  51. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
  52. package/.agents/scripts/lib/checks/loop-health.js +9 -37
  53. package/.agents/scripts/lib/child-exec.js +193 -0
  54. package/.agents/scripts/lib/cli/standard-args.js +1 -1
  55. package/.agents/scripts/lib/cli-args.js +64 -0
  56. package/.agents/scripts/lib/close-validation/gates.js +2 -2
  57. package/.agents/scripts/lib/close-validation/runner.js +3 -3
  58. package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
  59. package/.agents/scripts/lib/config/commands.js +3 -5
  60. package/.agents/scripts/lib/config/explain.js +5 -7
  61. package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
  62. package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
  63. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
  64. package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
  65. package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
  66. package/.agents/scripts/lib/config/gates/index.js +5 -2
  67. package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
  68. package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
  69. package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
  70. package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
  71. package/.agents/scripts/lib/config/gates/shared.js +58 -9
  72. package/.agents/scripts/lib/config/github.js +0 -1
  73. package/.agents/scripts/lib/config/limits.js +3 -48
  74. package/.agents/scripts/lib/config/qa.js +105 -0
  75. package/.agents/scripts/lib/config/temp-paths.js +6 -5
  76. package/.agents/scripts/lib/config-settings-schema-delivery.js +237 -56
  77. package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
  78. package/.agents/scripts/lib/config-settings-schema.js +386 -39
  79. package/.agents/scripts/lib/crap-baseline-join.js +126 -9
  80. package/.agents/scripts/lib/crap-utils.js +84 -520
  81. package/.agents/scripts/lib/dead-exports-knip.js +79 -10
  82. package/.agents/scripts/lib/degraded-mode.js +2 -2
  83. package/.agents/scripts/lib/doc-tiers.js +3 -3
  84. package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
  85. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
  86. package/.agents/scripts/lib/fs-walk.js +52 -0
  87. package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
  88. package/.agents/scripts/lib/git-utils.js +16 -36
  89. package/.agents/scripts/lib/knip-entry-sync.js +469 -0
  90. package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
  91. package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
  92. package/.agents/scripts/lib/observability/signal-validator.js +5 -85
  93. package/.agents/scripts/lib/observability/signals-writer.js +19 -62
  94. package/.agents/scripts/lib/observability/source-classifier.js +5 -7
  95. package/.agents/scripts/lib/observability/terse-result.js +3 -3
  96. package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
  97. package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
  98. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
  99. package/.agents/scripts/lib/orchestration/code-review.js +2 -2
  100. package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
  101. package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
  102. package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
  103. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
  104. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
  105. package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
  106. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +6 -0
  107. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +0 -1
  108. package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
  109. package/.agents/scripts/lib/orchestration/resolve-stories.js +5 -15
  110. package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
  111. package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
  112. package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
  118. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
  119. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
  120. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
  121. package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
  122. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +59 -25
  123. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
  124. package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
  125. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
  126. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
  127. package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
  128. package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
  129. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
  130. package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
  131. package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
  132. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
  133. package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
  134. package/.agents/scripts/lib/signals/detectors/common.js +1 -1
  135. package/.agents/scripts/lib/signals/index.js +8 -6
  136. package/.agents/scripts/lib/signals/schema.js +20 -25
  137. package/.agents/scripts/lib/signals/write.js +8 -8
  138. package/.agents/scripts/lib/story-body/story-body.js +12 -59
  139. package/.agents/scripts/lib/temp-retention.js +1 -1
  140. package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
  141. package/.agents/scripts/lib/ticket-body-sections.js +4 -5
  142. package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
  143. package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
  144. package/.agents/scripts/lib/worktree-manager.js +2 -3
  145. package/.agents/scripts/lint-label-vocabulary.js +2 -24
  146. package/.agents/scripts/pr-watch-with-update.js +7 -5
  147. package/.agents/scripts/providers/github/cache.js +2 -2
  148. package/.agents/scripts/providers/github/comments.js +6 -28
  149. package/.agents/scripts/providers/github/compose.js +0 -15
  150. package/.agents/scripts/providers/github/errors.js +10 -27
  151. package/.agents/scripts/providers/github/request-helpers.js +1 -2
  152. package/.agents/scripts/providers/github/sub-issues.js +10 -218
  153. package/.agents/scripts/providers/github.js +4 -7
  154. package/.agents/scripts/prune-baseline-orphans.js +181 -0
  155. package/.agents/scripts/resolve-stories.js +0 -2
  156. package/.agents/scripts/run-lint.js +61 -61
  157. package/.agents/scripts/run-test-profile.js +6 -6
  158. package/.agents/scripts/run-verify.js +48 -30
  159. package/.agents/scripts/single-story-close.js +20 -0
  160. package/.agents/scripts/single-story-init.js +12 -35
  161. package/.agents/scripts/update-dead-exports-baseline.js +321 -0
  162. package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
  163. package/.agents/skills/skills.index.json +1 -11
  164. package/.agents/workflows/audit-documentation.md +5 -6
  165. package/.agents/workflows/audit-to-stories.md +2 -2
  166. package/.agents/workflows/helpers/audit-lens-core.md +11 -12
  167. package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
  168. package/.agents/workflows/helpers/code-review.md +3 -8
  169. package/.agents/workflows/helpers/deliver-reference.md +2 -1
  170. package/.agents/workflows/helpers/deliver-story-reference.md +27 -16
  171. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
  172. package/.agents/workflows/mandrel-update.md +10 -10
  173. package/.agents/workflows/qa-assist.md +15 -20
  174. package/.agents/workflows/qa-explore.md +9 -8
  175. package/README.md +1 -1
  176. package/docs/CHANGELOG.md +42 -0
  177. package/lib/migrations/index.js +2 -0
  178. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
  179. package/package.json +12 -3
  180. package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
  181. package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
  182. package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
  183. package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
  184. package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
  185. package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
  186. package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
  187. package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
  188. package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
  189. package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
  190. package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
  191. package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
  192. package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
  193. package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
  194. package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
  195. package/.agents/scripts/check-gherkin-placeholders.js +0 -663
  196. package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
  197. package/.agents/scripts/lib/audit-suite/cli.js +0 -64
  198. package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
  199. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
  200. package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
  201. package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
  202. package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
  203. package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
  204. package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
  205. package/.agents/scripts/lib/crap-baseline-index.js +0 -46
  206. package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
  207. package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
  208. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
  209. package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
  210. package/.agents/scripts/lib/mutation/config-detector.js +0 -119
  211. package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
  212. package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
  213. package/.agents/scripts/lib/observability/active-story-env.js +0 -170
  214. package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
  215. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
  216. package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
  217. package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
  218. package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
  219. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
  220. package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
  221. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
  222. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
  223. package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
  224. package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
  225. package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
  226. package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
  227. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
  228. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
  229. package/.agents/scripts/lib/planning-corpus.js +0 -37
  230. package/.agents/scripts/lib/qa/coverage-report.js +0 -181
  231. package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
  232. package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
  233. package/.agents/scripts/lib/signals/detectors/index.js +0 -14
  234. package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
  235. package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
  236. package/.agents/scripts/lib/signals/read.js +0 -268
  237. package/.agents/scripts/lib/signals/span-tree.js +0 -291
  238. package/.agents/scripts/lib/story-lifecycle.js +0 -194
  239. package/.agents/scripts/lib/story-plan.js +0 -379
  240. package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
  241. package/.agents/scripts/lib/util/phase-timer.js +0 -163
  242. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
  243. package/.agents/scripts/lint-baseline.js +0 -507
  244. package/.agents/scripts/providers/github/prs.js +0 -103
  245. package/.agents/scripts/signals-view.js +0 -309
  246. package/.agents/scripts/story-plan.js +0 -370
  247. package/.agents/scripts/sync-branch-from-base.js +0 -149
  248. package/.agents/scripts/validate-docs-freshness.js +0 -314
  249. package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
  250. package/.agents/workflows/helpers/signals.md +0 -112
@@ -1,253 +0,0 @@
1
- /**
2
- * Retry detector — pure module (Epic #1721 / Story #1768 / Task #1773).
3
- *
4
- * Scans a `traces.ndjson` file for repeated **failed** Bash invocations
5
- * keyed by a stable command identity, and emits one `kind: 'retry'`
6
- * SignalEvent per identity whose failure count strictly exceeds the
7
- * configured threshold. Pure: takes a file path in, returns events out.
8
- * Emission to disk is the caller's job.
9
- *
10
- * ## Identity rule
11
- *
12
- * The detector groups failed Bash trace records by:
13
- *
14
- * 1. `details.normalizedHash` when present (canonical form — collapses
15
- * whitespace, strips benign flags like `--no-color` / `--quiet`,
16
- * and treats `npm test` ≡ `npm run test`). Set by
17
- * `lib/observability/tool-trace-hook.js` (Story #1768 / Task #1775).
18
- * 2. `details.targetHash` as the fallback when `normalizedHash` is
19
- * absent (e.g. legacy traces, or a Bash event whose command was
20
- * empty / non-string and the normaliser declined to emit a hash).
21
- *
22
- * Records that have neither hash are skipped — without a stable key we
23
- * cannot group repeats. The grouping is by the chosen identity hash, so
24
- * `npm test` and `npm run test` (different `targetHash`, identical
25
- * `normalizedHash`) collapse into one bucket.
26
- *
27
- * ## Failure rule
28
- *
29
- * A trace record is treated as **failed** when its `details.exitCode`
30
- * is a number and not `0`. As of Epic #4406 / Story #4413 the tool-trace
31
- * hook captures `details.exitCode` for Bash `PostToolUse` events, so this
32
- * detector fires on real deliveries; records without an `exitCode` field
33
- * (non-Bash tools, or tools that report no exit code) are ignored, which
34
- * matches the decision in the parent Epic body that retry only counts
35
- * non-zero-exit commands.
36
- *
37
- * Successful runs after failures **do not** cancel the count — failure-
38
- * count is monotonic per identity. This matches the Epic's intent: once
39
- * a command has failed N times, the friction signal has happened, even
40
- * if a later attempt succeeded. The detector is observational, not a
41
- * status check.
42
- *
43
- * ## Tool filter
44
- *
45
- * Only trace records whose `emitter.tool === 'Bash'` participate. Edit /
46
- * Write / Read / Grep / Glob events are not retries — those belong to
47
- * other detectors (rework for file-edit churn). The tool name is read
48
- * from `emitter.tool` first and falls back to `details.tool` (see
49
- * `common.extractTool`).
50
- *
51
- * ## Privacy contract
52
- *
53
- * Identity hashes are sha256 strings produced by the hook before any
54
- * raw value reaches disk (see `lib/observability/tool-trace-hook.js`
55
- * `hashTarget`). The detector never sees plaintext commands and never
56
- * reverses a hash. Emitted signals carry `details.commandHash` — the
57
- * same `sha256:<hex>` string that drove the grouping — so downstream
58
- * surfaces can dedupe across a Story without ever resolving back to a
59
- * raw command string.
60
- *
61
- * ## Threshold semantics
62
- *
63
- * `failureCount > threshold` (strictly greater than). An identity with
64
- * exactly `threshold` failures does NOT emit. This matches the
65
- * `delivery.signals.retry.repeatCount` config surface (Epic #1720) —
66
- * the configured value is the maximum tolerated repeat count, not the
67
- * trigger count.
68
- *
69
- * ## Robustness
70
- *
71
- * - Missing `tracesPath` file → returns `[]`. Never throws.
72
- * - Malformed JSON lines → silently skipped (consistent with
73
- * `lib/signals/read.js`).
74
- * - Non-trace records → ignored. The file may legitimately interleave
75
- * other kinds in future.
76
- * - Non-Bash trace records → ignored.
77
- *
78
- * @module lib/signals/detectors/retry
79
- */
80
-
81
- import { createReadStream } from 'node:fs';
82
- import fs from 'node:fs/promises';
83
- import { createInterface } from 'node:readline';
84
-
85
- import { extractTool, validateDetectorArgs } from './common.js';
86
-
87
- /**
88
- * Documented argv-normalisation rules — emitted verbatim onto every
89
- * retry signal so the downstream renderer can show the operator which
90
- * paraphrases collapsed. Kept as a frozen array so callers cannot
91
- * mutate the canonical list.
92
- *
93
- * Mirrors the rule list in `lib/observability/tool-trace-hook.js`
94
- * `normaliseBashCommand` — keep these in sync when extending the
95
- * normaliser.
96
- *
97
- * @type {ReadonlyArray<string>}
98
- */
99
- export const NORMALIZATION_RULES = Object.freeze([
100
- 'collapse-whitespace',
101
- 'strip-benign-flags:--no-color,--quiet',
102
- 'npm-test-equiv-npm-run-test',
103
- ]);
104
-
105
- /**
106
- * Resolve the identity key used to group a Bash trace record. Prefers
107
- * `details.normalizedHash` (canonical form, collapses paraphrases),
108
- * falls back to `details.targetHash` (raw-command hash). Returns `null`
109
- * when neither is present — the caller skips those records.
110
- *
111
- * @param {object} rec
112
- * @returns {string|null}
113
- */
114
- function resolveIdentity(rec) {
115
- const normalized = rec?.details?.normalizedHash;
116
- if (typeof normalized === 'string' && normalized.length > 0) {
117
- return normalized;
118
- }
119
- const target = rec?.details?.targetHash;
120
- if (typeof target === 'string' && target.length > 0) {
121
- return target;
122
- }
123
- return null;
124
- }
125
-
126
- /**
127
- * Decide whether a trace record represents a failed invocation. As of
128
- * Epic #4406 / Story #4413 the tool-trace hook records `details.exitCode`
129
- * for Bash `PostToolUse` events, so the field is present on real Bash
130
- * traces (and still set directly by tests). A record counts as failed
131
- * when `details.exitCode` is a number and not zero. Anything else
132
- * (missing field, null, non-number, zero) is NOT a failure and is ignored
133
- * entirely — the detector only counts non-zero-exit commands per the
134
- * parent Epic.
135
- *
136
- * @param {object} rec
137
- * @returns {boolean}
138
- */
139
- function isFailedBash(rec) {
140
- const code = rec?.details?.exitCode;
141
- return typeof code === 'number' && code !== 0;
142
- }
143
-
144
- /**
145
- * Stream `tracesPath` line-by-line and accumulate per-identity failure
146
- * counts. Returns a `Map<identityHash, count>`. Missing file → empty map.
147
- *
148
- * @param {string} tracesPath
149
- * @returns {Promise<Map<string, number>>}
150
- */
151
- async function tallyFailuresByIdentity(tracesPath) {
152
- const counts = new Map();
153
-
154
- // Existence check before opening the stream — `createReadStream`
155
- // defers ENOENT until the first read, which leaves the iterator in a
156
- // bad state on some Node versions. `fs.access` short-circuits cleanly.
157
- try {
158
- await fs.access(tracesPath);
159
- } catch {
160
- return counts;
161
- }
162
-
163
- const stream = createReadStream(tracesPath, { encoding: 'utf8' });
164
- const rl = createInterface({ input: stream, crlfDelay: Infinity });
165
-
166
- try {
167
- for await (const rawLine of rl) {
168
- if (rawLine.length === 0) continue;
169
- let parsed;
170
- try {
171
- parsed = JSON.parse(rawLine);
172
- } catch {
173
- // Mirrors lib/signals/read.js — partial-write races are common.
174
- continue;
175
- }
176
- if (parsed == null || typeof parsed !== 'object') continue;
177
- if (parsed.kind !== 'trace') continue;
178
-
179
- const tool = extractTool(parsed);
180
- if (tool !== 'Bash') continue;
181
-
182
- if (!isFailedBash(parsed)) continue;
183
-
184
- const identity = resolveIdentity(parsed);
185
- if (identity == null) continue;
186
-
187
- counts.set(identity, (counts.get(identity) ?? 0) + 1);
188
- }
189
- } finally {
190
- rl.close();
191
- if (!stream.destroyed) stream.destroy();
192
- }
193
-
194
- return counts;
195
- }
196
-
197
- /**
198
- * Detect retries against `tracesPath`. Returns a `kind: 'retry'`
199
- * SignalEvent for every identity whose failure count strictly exceeds
200
- * `threshold`. Pure — emission to disk is the caller's responsibility.
201
- *
202
- * The detector takes `threshold` as a function arg and never reaches
203
- * for `getSignals(config).retry.repeatCount`. Resolving the threshold
204
- * from config lives at the call site so this module stays import-graph-
205
- * free of `lib/config/*`.
206
- *
207
- * @param {object} args
208
- * @param {string} args.tracesPath — absolute path to a `traces.ndjson`.
209
- * @param {number} args.epicId — positive integer Epic ID.
210
- * @param {number} args.storyId — positive integer Story ID.
211
- * @param {number|null} [args.taskId] — positive integer Task ID, or null.
212
- * @param {number} args.threshold — the maximum tolerated failure count;
213
- * identities with `failureCount > threshold` emit. MUST be a
214
- * non-negative integer.
215
- * @param {() => string} [args.nowFn] — optional clock seam returning the
216
- * ISO-8601 `ts` stamped onto every emitted SignalEvent. Defaults to
217
- * `() => new Date().toISOString()`. Inject a fixed-return function in
218
- * tests to make the emitted `ts` deterministic. MUST, when provided, be
219
- * a function.
220
- * @returns {Promise<object[]>} array of SignalEvent objects conforming
221
- * to `.agents/schemas/signal-event.schema.json`.
222
- */
223
- export async function detectRetry(args) {
224
- const { tracesPath, epicId, storyId, taskId, threshold, nowFn } =
225
- validateDetectorArgs(args, { fnName: 'detectRetry' });
226
-
227
- const counts = await tallyFailuresByIdentity(tracesPath);
228
-
229
- // Stable order: sort by identity hash ascending so the same input
230
- // always yields the same emission sequence (eases snapshot tests and
231
- // downstream deduping).
232
- const offenders = [];
233
- for (const [identity, count] of counts) {
234
- if (count > threshold) offenders.push([identity, count]);
235
- }
236
- offenders.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
237
-
238
- const ts = nowFn();
239
- return offenders.map(([commandHash, failureCount]) => ({
240
- ts,
241
- kind: 'retry',
242
- emitter: { tool: 'retry-detector' },
243
- epicId,
244
- storyId,
245
- taskId,
246
- details: {
247
- commandHash,
248
- failureCount,
249
- threshold,
250
- normalizationRules: [...NORMALIZATION_RULES],
251
- },
252
- }));
253
- }
@@ -1,167 +0,0 @@
1
- /**
2
- * Rework detector — pure module (Epic #1721 / Story #1771 / Task #1774).
3
- *
4
- * Scans a `traces.ndjson` file for repeated edits against the same target
5
- * (keyed by hashed `details.targetHash`) and emits one `kind: 'rework'`
6
- * SignalEvent per offending target whose edit count strictly exceeds the
7
- * configured threshold. Pure: takes a file path in, returns events out.
8
- * Emission to disk is the caller's job.
9
- *
10
- * ## Counting rule
11
- *
12
- * Only file-mutating tools are counted:
13
- * - `Edit`
14
- * - `Write`
15
- * - `MultiEdit`
16
- * - `NotebookEdit`
17
- *
18
- * Every other tool (Read, Bash, Grep, Glob, …) is ignored. Trace records
19
- * without a `details.targetHash` are also skipped — without a stable key
20
- * we cannot group repeats.
21
- *
22
- * ## Privacy contract
23
- *
24
- * Trace records key off `details.targetHash` (a sha256 of the file path,
25
- * see `lib/observability/tool-trace-hook.js`). The detector groups by
26
- * the hash, never the raw path, so the privacy boundary established by
27
- * the hook is preserved end-to-end. A future analyzer that wants to
28
- * surface the offending path must resolve the hash through a separate
29
- * mapping — the detector itself never touches plaintext.
30
- *
31
- * ## Threshold semantics
32
- *
33
- * `editCount > threshold` (strictly greater than). A target with exactly
34
- * `threshold` edits does NOT emit; only the first edit *past* the
35
- * threshold trips the detector. This matches the
36
- * `delivery.signals.rework.editsPerFile` config surface (Epic #1720) —
37
- * the configured value is the maximum tolerated count, not the trigger
38
- * count.
39
- *
40
- * ## Robustness
41
- *
42
- * - Missing `tracesPath` file → returns `[]`. Never throws.
43
- * - Malformed JSON lines → silently skipped (consistent with the
44
- * `lib/signals/read.js` reader).
45
- * - Non-trace records → ignored (the file may legitimately contain
46
- * other kinds in future).
47
- *
48
- * @module lib/signals/detectors/rework
49
- */
50
-
51
- import { createReadStream } from 'node:fs';
52
- import fs from 'node:fs/promises';
53
- import { createInterface } from 'node:readline';
54
-
55
- import { extractTool, validateDetectorArgs } from './common.js';
56
-
57
- /**
58
- * Tools that mutate files. Only these contribute to the per-target edit
59
- * count. Anything outside this set is ignored.
60
- *
61
- * @type {ReadonlySet<string>}
62
- */
63
- const FILE_MUTATING_TOOLS = Object.freeze(
64
- new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']),
65
- );
66
-
67
- /**
68
- * Stream `tracesPath` line-by-line and accumulate per-targetHash edit
69
- * counts. Returns a `Map<targetHash, count>`. Missing file → empty map.
70
- *
71
- * @param {string} tracesPath
72
- * @returns {Promise<Map<string, number>>}
73
- */
74
- async function tallyEditsByTarget(tracesPath) {
75
- const counts = new Map();
76
-
77
- // Existence check before opening the stream — `createReadStream`
78
- // defers ENOENT until the first read, which leaves the iterator in a
79
- // bad state on some Node versions. `fs.access` short-circuits cleanly.
80
- try {
81
- await fs.access(tracesPath);
82
- } catch {
83
- return counts;
84
- }
85
-
86
- const stream = createReadStream(tracesPath, { encoding: 'utf8' });
87
- const rl = createInterface({ input: stream, crlfDelay: Infinity });
88
-
89
- try {
90
- for await (const rawLine of rl) {
91
- if (rawLine.length === 0) continue;
92
- let parsed;
93
- try {
94
- parsed = JSON.parse(rawLine);
95
- } catch {
96
- // Mirrors lib/signals/read.js — malformed lines are common
97
- // during a partial-write race; skip silently.
98
- continue;
99
- }
100
- if (parsed == null || typeof parsed !== 'object') continue;
101
- // We only care about trace records; the file is named
102
- // traces.ndjson but a future writer may interleave other kinds.
103
- if (parsed.kind !== 'trace') continue;
104
-
105
- const tool = extractTool(parsed);
106
- if (tool == null || !FILE_MUTATING_TOOLS.has(tool)) continue;
107
-
108
- const hash = parsed.details?.targetHash;
109
- if (typeof hash !== 'string' || hash.length === 0) continue;
110
-
111
- counts.set(hash, (counts.get(hash) ?? 0) + 1);
112
- }
113
- } finally {
114
- rl.close();
115
- if (!stream.destroyed) stream.destroy();
116
- }
117
-
118
- return counts;
119
- }
120
-
121
- /**
122
- * Detect rework against `tracesPath`. Returns a `kind: 'rework'`
123
- * SignalEvent for every targetHash whose edit count strictly exceeds
124
- * `threshold`. Pure — emission to disk is the caller's responsibility.
125
- *
126
- * @param {object} args
127
- * @param {string} args.tracesPath — absolute path to a `traces.ndjson`.
128
- * @param {number} args.epicId — positive integer Epic ID.
129
- * @param {number} args.storyId — positive integer Story ID.
130
- * @param {number|null} [args.taskId] — positive integer Task ID, or null.
131
- * @param {number} args.threshold — the maximum tolerated edit count;
132
- * targets with `editCount > threshold` emit. MUST be a non-negative
133
- * integer.
134
- * @param {() => string} [args.nowFn] — optional clock seam returning the
135
- * ISO-8601 `ts` stamped onto every emitted SignalEvent. Defaults to
136
- * `() => new Date().toISOString()`. Inject a fixed-return function in
137
- * tests to make the emitted `ts` deterministic. MUST, when provided, be
138
- * a function.
139
- * @returns {Promise<object[]>} array of SignalEvent objects
140
- * conforming to `.agents/schemas/signal-event.schema.json`.
141
- */
142
- export async function detectRework(args) {
143
- const { tracesPath, epicId, storyId, taskId, threshold, nowFn } =
144
- validateDetectorArgs(args, { fnName: 'detectRework' });
145
-
146
- const counts = await tallyEditsByTarget(tracesPath);
147
-
148
- // Stable order: sort by targetHash ascending so the same input always
149
- // yields the same emission sequence (eases snapshot testing and
150
- // downstream deduping).
151
- const offenders = [];
152
- for (const [hash, count] of counts) {
153
- if (count > threshold) offenders.push([hash, count]);
154
- }
155
- offenders.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
156
-
157
- const ts = nowFn();
158
- return offenders.map(([targetHash, editCount]) => ({
159
- ts,
160
- kind: 'rework',
161
- emitter: { tool: 'rework-detector' },
162
- epicId,
163
- storyId,
164
- taskId,
165
- details: { targetHash, editCount, threshold },
166
- }));
167
- }
@@ -1,268 +0,0 @@
1
- /**
2
- * Streaming signals reader (Epic #1181 / Story #1438 / Task #1459).
3
- *
4
- * Provides one async-iterator entry point — `read({ run, story?, kind?,
5
- * config? })` — that consumers use instead of opening the NDJSON file
6
- * themselves. The reader streams line-by-line so callers never load the
7
- * full file into memory (50MB+ traces stay under file size in RSS).
8
- *
9
- * On-disk layout (resolved via `lib/config/temp-paths.js`):
10
- *
11
- * <tempRoot>/run-<run>/story-<story>/signals.ndjson
12
- *
13
- * When `story` is omitted, the reader fans out across every
14
- * `story-<id>/signals.ndjson` under `<tempRoot>/run-<run>/`. When the
15
- * run directory is missing, the iterator yields nothing (consumers
16
- * treat absence as "no signals yet").
17
- *
18
- * ## Filter semantics
19
- *
20
- * - `kind` — when provided, only events whose `kind` matches the
21
- * argument are yielded. Filtering happens after the per-line JSON
22
- * parse + envelope guard (see `lib/signals/schema.js`).
23
- * - `story` — narrows to a single Story's stream; otherwise we walk
24
- * every Story directory under the run.
25
- *
26
- * ## Warn-once policy (AC #3)
27
- *
28
- * Malformed JSON lines are common during a partial write race; we
29
- * warn **once per process** (module-level latch) rather than per line
30
- * so a corrupted tail doesn't drown the operator log. The latch
31
- * carries the first offending path/line and a count for follow-up.
32
- *
33
- * ## Robustness
34
- *
35
- * The reader never throws on a missing file, a permission error, or
36
- * malformed JSON — every failure path resolves to "no more events"
37
- * so the analyzer can keep walking. Errors from the supplied
38
- * `tempRoot` resolver propagate (those are programmer errors, not
39
- * I/O conditions).
40
- *
41
- * @module lib/signals/read
42
- */
43
-
44
- import { createReadStream } from 'node:fs';
45
- import fs from 'node:fs/promises';
46
- import path from 'node:path';
47
- import { createInterface } from 'node:readline';
48
- import {
49
- runArtifactPath,
50
- runTempDir,
51
- signalsFile,
52
- storyTempDir,
53
- } from '../config/temp-paths.js';
54
- import { parseStoryBranch } from '../git-utils.js';
55
- import { Logger } from '../Logger.js';
56
-
57
- import { isPositiveInt } from './detectors/common.js';
58
- import { EVENT_KIND_VALUES, hasCommonEnvelope } from './schema.js';
59
-
60
- // Module-level latch. Carries `{ path, lineNumber, totalCount }` for the
61
- // first malformed line we see in this process; subsequent malformed
62
- // lines bump `totalCount` without emitting. Reset is intentionally
63
- // not exposed — the warn-once contract is process-wide.
64
- const _malformedLatch = { fired: false, totalCount: 0 };
65
-
66
- /**
67
- * Test-only helper to reset the warn-once latch. Not part of the public
68
- * surface — exported so the unit test can exercise per-test isolation
69
- * without spawning a new Node process.
70
- *
71
- * @returns {void}
72
- */
73
- export function __resetMalformedLatchForTests() {
74
- _malformedLatch.fired = false;
75
- _malformedLatch.totalCount = 0;
76
- }
77
-
78
- /**
79
- * Snapshot of the current warn-once state. Test-only — surfaces the
80
- * count of malformed lines observed so the unit suite can assert that
81
- * "10 bad lines → 1 warn but count=10".
82
- *
83
- * @returns {{ fired: boolean, totalCount: number }}
84
- */
85
- export function __getMalformedLatchForTests() {
86
- return { ..._malformedLatch };
87
- }
88
-
89
- function warnOnceMalformed(targetPath, lineNumber, parseErr) {
90
- _malformedLatch.totalCount += 1;
91
- if (_malformedLatch.fired) return;
92
- _malformedLatch.fired = true;
93
- Logger.warn(
94
- `signals/read: malformed JSON encountered (first at ${targetPath}:${lineNumber}: ${
95
- parseErr instanceof Error ? parseErr.message : String(parseErr)
96
- }); further malformed lines suppressed for this process.`,
97
- );
98
- }
99
-
100
- /**
101
- * Stream one signals.ndjson file as an async iterable of parsed records.
102
- * Internal helper; consumers go through `read()`.
103
- *
104
- * @param {string} target — absolute on-disk path
105
- * @param {string | null} kindFilter — kind to filter on (null = no filter)
106
- * @returns {AsyncGenerator<object>}
107
- */
108
- async function* streamFile(target, kindFilter) {
109
- // Existence check before opening the stream — `createReadStream`
110
- // defers ENOENT until the first `read()`, which leaves the async
111
- // iterator with an uncatchable error mid-flight on some Node
112
- // versions. `fs.access` is cheap and gives us a clean early return.
113
- try {
114
- await fs.access(target);
115
- } catch {
116
- return;
117
- }
118
-
119
- const stream = createReadStream(target, { encoding: 'utf8' });
120
- const rl = createInterface({ input: stream, crlfDelay: Infinity });
121
-
122
- let lineNumber = 0;
123
- try {
124
- for await (const rawLine of rl) {
125
- lineNumber += 1;
126
- if (rawLine.length === 0) continue;
127
- let parsed;
128
- try {
129
- parsed = JSON.parse(rawLine);
130
- } catch (err) {
131
- warnOnceMalformed(target, lineNumber, err);
132
- continue;
133
- }
134
- if (!hasCommonEnvelope(parsed)) continue;
135
- if (kindFilter != null && parsed.kind !== kindFilter) continue;
136
- yield parsed;
137
- }
138
- } finally {
139
- rl.close();
140
- // `stream.destroy()` is idempotent — `rl.close()` already pulls
141
- // the underlying stream down on its own, but we drop it explicitly
142
- // here to satisfy the Windows file-handle leak check.
143
- if (!stream.destroyed) stream.destroy();
144
- }
145
- }
146
-
147
- /**
148
- * List every `stories/story-<id>/signals.ndjson` path under the run's
149
- * `<tempRoot>/run-<run>/` directory. Returns an empty array when the
150
- * run directory is missing.
151
- *
152
- * The returned paths are sorted by Story ID ascending so the iterator's
153
- * output is stable across runs.
154
- *
155
- * Story #2940 nested per-Story directories under a `stories/` segment.
156
- * Run-level signals continue to live at the run root.
157
- *
158
- * @param {number} run
159
- * @param {object | undefined} config
160
- * @returns {Promise<string[]>}
161
- */
162
- async function listRunStorySignalsFiles(run, config) {
163
- const runDir = runTempDir(run, config);
164
- let runEntries;
165
- try {
166
- runEntries = await fs.readdir(runDir, { withFileTypes: true });
167
- } catch {
168
- return [];
169
- }
170
- let hasRunLevelSignals = false;
171
- let hasStoriesDir = false;
172
- for (const ent of runEntries) {
173
- if (ent.isDirectory() && ent.name === 'stories') {
174
- hasStoriesDir = true;
175
- } else if (ent.isFile() && ent.name === 'signals.ndjson') {
176
- // Story #1430 — wave-runner lifecycle signals land here.
177
- hasRunLevelSignals = true;
178
- }
179
- }
180
- const storyIds = [];
181
- if (hasStoriesDir) {
182
- let storyEntries;
183
- try {
184
- storyEntries = await fs.readdir(path.join(runDir, 'stories'), {
185
- withFileTypes: true,
186
- });
187
- } catch {
188
- storyEntries = [];
189
- }
190
- for (const ent of storyEntries) {
191
- if (!ent.isDirectory()) continue;
192
- const sid = parseStoryBranch(ent.name);
193
- if (sid === null || !isPositiveInt(sid)) continue;
194
- storyIds.push(sid);
195
- }
196
- }
197
- storyIds.sort((a, b) => a - b);
198
- // Yield run-level signals first (wave-start precedes per-Story friction),
199
- // then walk the per-Story streams in ascending ID order.
200
- const targets = hasRunLevelSignals
201
- ? [runArtifactPath(run, 'signals.ndjson', config)]
202
- : [];
203
- for (const sid of storyIds) {
204
- targets.push(path.join(storyTempDir(run, sid, config), 'signals.ndjson'));
205
- }
206
- return targets;
207
- }
208
-
209
- /**
210
- * Stream every event matching `{ run, story?, kind? }` from the
211
- * configured `tempRoot`'s `run-<run>/[story-<story>/]signals.ndjson`
212
- * file(s).
213
- *
214
- * Returns an async iterable so callers can `for await` over it without
215
- * buffering. The reader is **streaming** — peak memory stays below the
216
- * file size for any input.
217
- *
218
- * @param {{ run: number, story?: number, kind?: string, config?: object }} args
219
- * @returns {AsyncGenerator<object>}
220
- *
221
- * @example
222
- * for await (const evt of read({ run: 1181 })) { ... }
223
- * for await (const evt of read({ run: 1181, story: 1438, kind: 'friction' })) { ... }
224
- */
225
- export async function* read(args) {
226
- if (args == null || typeof args !== 'object') {
227
- throw new TypeError(
228
- `signals/read: args must be an object with at minimum { run }; got ${args}`,
229
- );
230
- }
231
- const { run, story, kind, config } = args;
232
- if (!isPositiveInt(run)) {
233
- throw new RangeError(
234
- `signals/read: run must be a positive integer (got ${run})`,
235
- );
236
- }
237
- if (story !== undefined && !isPositiveInt(story)) {
238
- throw new RangeError(
239
- `signals/read: story must be a positive integer when provided (got ${story})`,
240
- );
241
- }
242
- if (kind !== undefined && kind !== null) {
243
- if (typeof kind !== 'string' || kind.length === 0) {
244
- throw new TypeError(
245
- `signals/read: kind must be a non-empty string when provided (got ${kind})`,
246
- );
247
- }
248
- if (!EVENT_KIND_VALUES.has(kind)) {
249
- // Unknown kinds are not technically invalid — a future detector
250
- // might emit a new kind that we don't yet know about. We warn
251
- // (best effort) but still let the iterator run so the consumer
252
- // can adopt the new kind without code change here.
253
- Logger.warn(
254
- `signals/read: kind '${kind}' is not in the current EVENT_KINDS enumeration; iterating anyway.`,
255
- );
256
- }
257
- }
258
-
259
- const kindFilter = kind ?? null;
260
- const targets =
261
- story !== undefined
262
- ? [signalsFile(run, story, config)]
263
- : await listRunStorySignalsFiles(run, config);
264
-
265
- for (const target of targets) {
266
- yield* streamFile(target, kindFilter);
267
- }
268
- }