mandrel 1.87.0 → 1.89.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 (140) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -274,7 +274,7 @@ export async function aggregateBaselineFrictionFromSignals({
274
274
  ) {
275
275
  return;
276
276
  }
277
- const ts = Date.parse(record.timestamp);
277
+ const ts = Date.parse(record.ts);
278
278
  if (Number.isFinite(ts) && ts < cutoffMs) return;
279
279
 
280
280
  totalRecords += 1;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * signal-validator.js — write-time validation of NDJSON signal records
3
+ * against the canonical `signal-event.schema.json` (Epic #4406 /
4
+ * Story #4413).
5
+ *
6
+ * The signals writer calls {@link validateSignal} before appending a
7
+ * record so a schema-invalid line is dropped with a `Logger.warn` naming
8
+ * the violating field rather than polluting the stream (and defeating the
9
+ * downstream consumers that assume the canonical shape). Validation is
10
+ * best-effort: the writer contract (Tech Spec #1032) is preserved —
11
+ * observability MUST NOT throw into the runner, so every helper here
12
+ * swallows its own faults and degrades to "treat as valid" only when the
13
+ * validator itself cannot be constructed.
14
+ *
15
+ * The AJV instance compiles the on-disk schema **once** at module load so
16
+ * the writer and the contract test validate against the exact same
17
+ * document (no hand-rolled drift). `strict: false` matches the repo's
18
+ * other draft-07 validators (see `tests/schemas/signal-schemas.test.js`).
19
+ *
20
+ * A per-Epic reject tally is persisted under the Epic temp tree
21
+ * (`temp/epic-<eid>/signal-rejects.json`) so a cross-process reader (the
22
+ * loop-health check, a follow-on Story) can surface how many records were
23
+ * dropped. The tally is read-modify-written best-effort; a lost increment
24
+ * under a write race is acceptable for a diagnostic counter.
25
+ */
26
+
27
+ import { readFileSync } from 'node:fs';
28
+ import fs from 'node:fs/promises';
29
+ import path from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ import Ajv from 'ajv';
33
+ import addFormats from 'ajv-formats';
34
+
35
+ import { epicArtifactPath } from '../config/temp-paths.js';
36
+ import { Logger } from '../Logger.js';
37
+
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+ const SCHEMA_PATH = path.resolve(
40
+ __dirname,
41
+ '..',
42
+ '..',
43
+ '..',
44
+ 'schemas',
45
+ 'signal-event.schema.json',
46
+ );
47
+
48
+ const REJECT_TALLY_BASENAME = 'signal-rejects.json';
49
+
50
+ /**
51
+ * Compile the signal-event schema once. Returns `null` when the schema
52
+ * cannot be read or compiled — the caller then treats every record as
53
+ * valid (fail-open) so a packaging error never silently drops all
54
+ * signals.
55
+ *
56
+ * @returns {import('ajv').ValidateFunction | null}
57
+ */
58
+ function buildValidator() {
59
+ try {
60
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
61
+ const ajv = new Ajv({ allErrors: true, strict: false });
62
+ addFormats(ajv);
63
+ return ajv.compile(schema);
64
+ } catch (err) {
65
+ Logger.warn(
66
+ `signal-validator: failed to compile signal-event schema (${
67
+ err instanceof Error ? err.message : String(err)
68
+ }); write-time validation disabled for this process.`,
69
+ );
70
+ return null;
71
+ }
72
+ }
73
+
74
+ const _validate = buildValidator();
75
+
76
+ /**
77
+ * Derive a human-readable "violating field" label from the first AJV
78
+ * error. Prefers the missing property name (required errors) or the
79
+ * instance path, falling back to the raw message.
80
+ *
81
+ * @param {import('ajv').ErrorObject[] | null | undefined} errors
82
+ * @returns {string}
83
+ */
84
+ function violatingFieldOf(errors) {
85
+ const first = Array.isArray(errors) && errors.length > 0 ? errors[0] : null;
86
+ if (!first) return 'unknown';
87
+ if (first.keyword === 'required' && first.params?.missingProperty) {
88
+ return String(first.params.missingProperty);
89
+ }
90
+ if (typeof first.instancePath === 'string' && first.instancePath.length > 0) {
91
+ return first.instancePath.replace(/^\//, '').replace(/\//g, '.');
92
+ }
93
+ if (
94
+ first.keyword === 'additionalProperties' &&
95
+ first.params?.additionalProperty
96
+ ) {
97
+ return String(first.params.additionalProperty);
98
+ }
99
+ return first.message ?? 'unknown';
100
+ }
101
+
102
+ /**
103
+ * Validate a signal record against the canonical schema.
104
+ *
105
+ * Fail-open: when the validator could not be compiled the record is
106
+ * reported valid (the writer keeps working). A non-object record is
107
+ * reported invalid without invoking AJV.
108
+ *
109
+ * @param {unknown} record
110
+ * @returns {{ valid: boolean, violatingField: string|null, message: string|null }}
111
+ */
112
+ export function validateSignal(record) {
113
+ if (_validate === null) {
114
+ return { valid: true, violatingField: null, message: null };
115
+ }
116
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) {
117
+ return {
118
+ valid: false,
119
+ violatingField: 'record',
120
+ message: 'signal record must be a plain object',
121
+ };
122
+ }
123
+ const valid = _validate(record);
124
+ if (valid) return { valid: true, violatingField: null, message: null };
125
+ const field = violatingFieldOf(_validate.errors);
126
+ const message = _validate.errors?.[0]?.message ?? 'schema validation failed';
127
+ return { valid: false, violatingField: field, message };
128
+ }
129
+
130
+ /**
131
+ * Increment the per-Epic reject tally persisted under the Epic temp tree.
132
+ * Best-effort and never throws: a missing/corrupt tally file resets to a
133
+ * count of 1. When `epicId` is not a positive integer (e.g. a standalone
134
+ * friction record with `epicId: null`) the tally write is skipped — there
135
+ * is no Epic temp tree to anchor it to.
136
+ *
137
+ * @param {{ epicId: number|null|undefined, config?: object, field?: string|null }} args
138
+ * @returns {Promise<number|null>} the new count, or null when skipped/failed.
139
+ */
140
+ export async function recordSignalReject({ epicId, config, field }) {
141
+ if (!Number.isInteger(epicId) || epicId <= 0) return null;
142
+ let target;
143
+ try {
144
+ target = epicArtifactPath(epicId, REJECT_TALLY_BASENAME, config);
145
+ } catch {
146
+ return null;
147
+ }
148
+ try {
149
+ let prior = { count: 0, lastField: null };
150
+ try {
151
+ const raw = await fs.readFile(target, 'utf8');
152
+ const parsed = JSON.parse(raw);
153
+ if (
154
+ parsed &&
155
+ typeof parsed === 'object' &&
156
+ Number.isFinite(parsed.count)
157
+ ) {
158
+ prior = { count: parsed.count, lastField: parsed.lastField ?? null };
159
+ }
160
+ } catch {
161
+ // Missing or corrupt file → start a fresh tally.
162
+ }
163
+ const next = {
164
+ count: prior.count + 1,
165
+ lastField: field ?? prior.lastField ?? null,
166
+ updatedAt: new Date().toISOString(),
167
+ };
168
+ await fs.mkdir(path.dirname(target), { recursive: true });
169
+ await fs.writeFile(target, `${JSON.stringify(next)}\n`, 'utf8');
170
+ return next.count;
171
+ } catch (err) {
172
+ Logger.warn(
173
+ `signal-validator: failed to persist reject tally for epic-${epicId}: ${
174
+ err instanceof Error ? err.message : String(err)
175
+ }`,
176
+ );
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Read the current per-Epic reject count. Cross-process readable
183
+ * counterpart to {@link recordSignalReject}. Returns 0 when the tally
184
+ * file is absent or unreadable.
185
+ *
186
+ * @param {{ epicId: number, config?: object }} args
187
+ * @returns {Promise<number>}
188
+ */
189
+ export async function readSignalRejectCount({ epicId, config }) {
190
+ if (!Number.isInteger(epicId) || epicId <= 0) return 0;
191
+ let target;
192
+ try {
193
+ target = epicArtifactPath(epicId, REJECT_TALLY_BASENAME, config);
194
+ } catch {
195
+ return 0;
196
+ }
197
+ try {
198
+ const raw = await fs.readFile(target, 'utf8');
199
+ const parsed = JSON.parse(raw);
200
+ return parsed && Number.isFinite(parsed.count) ? parsed.count : 0;
201
+ } catch {
202
+ return 0;
203
+ }
204
+ }
@@ -42,6 +42,7 @@ import {
42
42
  storyTempDir,
43
43
  } from '../config/temp-paths.js';
44
44
  import { Logger } from '../Logger.js';
45
+ import { recordSignalReject, validateSignal } from './signal-validator.js';
45
46
  import { classifyPathSource } from './source-classifier.js';
46
47
 
47
48
  const TRACES_BASENAME = 'traces.ndjson';
@@ -60,20 +61,23 @@ function tracesFile(eid, sid, config) {
60
61
  * Best-effort decoration of a signal record with a `source` field
61
62
  * (`"framework"` or `"consumer"`) produced by `classifyPathSource`.
62
63
  *
63
- * Rules (Epic #2547 / Story #2553 / Tech Spec #2550):
64
+ * Post the Epic #4406 cutover `source` is reserved **exclusively** for
65
+ * this framework/consumer classification — a record's originating tool
66
+ * lives in `emitter`, never `source`. That freed the classifier to run
67
+ * for every friction record (pre-cutover a provenance object under the
68
+ * `source` key blocked it via `Object.hasOwn`).
69
+ *
70
+ * Rules:
64
71
  * - If the record is not a plain object (string, number, null,
65
- * undefined), return it unchanged — the writer's existing
66
- * serialisation guard will reject or pass it through as before.
67
- * - If the caller pre-set `signal.source`, preserve it verbatim. Some
68
- * detectors classify upstream (e.g. wave-lifecycle signals always
69
- * belong to the framework) and we MUST NOT overwrite their
70
- * intentional tag.
71
- * - Otherwise, invoke `classifyPathSource` against the record's
72
- * `failingPath` / `path` and `command` fields, and inject the result
73
- * as a new `source` key. The classifier itself never throws, but we
74
- * belt-and-braces a try/catch so an unexpected fault degrades to a
75
- * `Logger.warn` and a passthrough of the original signal — never a
76
- * dropped write.
72
+ * undefined), return it unchanged.
73
+ * - If the caller pre-set `source` to exactly `"framework"` or
74
+ * `"consumer"`, preserve it verbatim — some detectors classify
75
+ * upstream and we MUST NOT overwrite their intentional tag.
76
+ * - Otherwise (absent, or any other value defense in depth against a
77
+ * stray non-canonical `source`), invoke `classifyPathSource` against
78
+ * the record's `failingPath` / `path` and `command` /
79
+ * `emitter.command` fields and inject/overwrite `source` with the
80
+ * result.
77
81
  *
78
82
  * @param {unknown} signal
79
83
  * @returns {unknown}
@@ -82,16 +86,17 @@ function tagSignalSource(signal) {
82
86
  if (signal === null || typeof signal !== 'object' || Array.isArray(signal)) {
83
87
  return signal;
84
88
  }
85
- // Caller-supplied source wins, even when undefined-typed but present as
86
- // an own property only inject when the key is absent entirely so we
87
- // never overwrite an explicit decision.
88
- if (Object.hasOwn(signal, 'source')) {
89
- return signal;
89
+ const record = /** @type {Record<string, unknown>} */ (signal);
90
+ if (record.source === 'framework' || record.source === 'consumer') {
91
+ return record;
90
92
  }
91
93
  try {
92
- const record = /** @type {Record<string, unknown>} */ (signal);
93
94
  const failingPath = record.failingPath ?? record.path;
94
- const command = record.command;
95
+ const emitter =
96
+ record.emitter && typeof record.emitter === 'object'
97
+ ? /** @type {Record<string, unknown>} */ (record.emitter)
98
+ : null;
99
+ const command = record.command ?? emitter?.command;
95
100
  const source = classifyPathSource(failingPath, command);
96
101
  return { ...record, source };
97
102
  } catch (err) {
@@ -104,6 +109,27 @@ function tagSignalSource(signal) {
104
109
  }
105
110
  }
106
111
 
112
+ /**
113
+ * Validate a record against the canonical `signal-event.schema.json`
114
+ * before it is appended. On failure the record is **dropped** (never
115
+ * appended), a `Logger.warn` names the violating field, and the per-Epic
116
+ * reject tally is incremented under the Epic temp tree. Never throws —
117
+ * the writer's best-effort contract is preserved.
118
+ *
119
+ * @param {unknown} record
120
+ * @param {{ epicId?: number|null, config?: object, label: string }} ctx
121
+ * @returns {Promise<boolean>} true when the record is valid (safe to append).
122
+ */
123
+ async function validateOrDrop(record, { epicId, config, label }) {
124
+ const { valid, violatingField, message } = validateSignal(record);
125
+ if (valid) return true;
126
+ Logger.warn(
127
+ `signals-writer: dropping schema-invalid ${label} record — violating field '${violatingField}' (${message}).`,
128
+ );
129
+ await recordSignalReject({ epicId, config, field: violatingField });
130
+ return false;
131
+ }
132
+
107
133
  /**
108
134
  * Best-effort append of a single record as one newline-terminated JSON
109
135
  * line. Caller-supplied record must be JSON-serialisable; circular refs
@@ -163,14 +189,24 @@ export async function appendSignal(args) {
163
189
  );
164
190
  return false;
165
191
  }
166
- return appendOne(target, tagSignalSource(signal));
192
+ const tagged = tagSignalSource(signal);
193
+ const ok = await validateOrDrop(tagged, {
194
+ epicId: Number.isInteger(epicId) ? epicId : null,
195
+ config,
196
+ label: 'signal',
197
+ });
198
+ if (!ok) return false;
199
+ return appendOne(target, tagged);
167
200
  }
168
201
 
169
202
  /**
170
203
  * Append one signal record to the per-Epic stream at
171
- * `temp/epic-<eid>/signals.ndjson` — used for wave-lifecycle signals
172
- * (`wave-start`, `wave-tick`, `wave-complete`, `epic-complete`) that are
173
- * not scoped to an individual Story.
204
+ * `temp/epic-<eid>/signals.ndjson` — used for Epic-scoped signals not tied
205
+ * to an individual Story: the wave-window forensics kinds (`wave-start`,
206
+ * `wave-complete`), notification-derived anchors (`state-transition`,
207
+ * `notification.emitted`), and Epic-level `friction` (e.g.
208
+ * `lifecycle-emit.js`). The retired write-only kinds `wave-tick` /
209
+ * `epic-complete` are gone (Story #3909) and are NOT valid here.
174
210
  *
175
211
  * @param {{ epicId: number, signal: unknown, config?: object }} args
176
212
  * @returns {Promise<boolean>}
@@ -188,7 +224,14 @@ export async function appendEpicSignal(args) {
188
224
  );
189
225
  return false;
190
226
  }
191
- return appendOne(target, tagSignalSource(signal));
227
+ const tagged = tagSignalSource(signal);
228
+ const ok = await validateOrDrop(tagged, {
229
+ epicId: Number.isInteger(epicId) ? epicId : null,
230
+ config,
231
+ label: 'epic signal',
232
+ });
233
+ if (!ok) return false;
234
+ return appendOne(target, tagged);
192
235
  }
193
236
 
194
237
  /**
@@ -211,41 +254,32 @@ export async function appendTrace(args) {
211
254
  );
212
255
  return false;
213
256
  }
257
+ const ok = await validateOrDrop(trace, {
258
+ epicId: Number.isInteger(epicId) ? epicId : null,
259
+ config,
260
+ label: 'trace',
261
+ });
262
+ if (!ok) return false;
214
263
  return appendOne(target, trace);
215
264
  }
216
265
 
217
266
  /**
218
- * Stream `signals.ndjson` line by line, invoking `cb(parsed, lineNumber)`
219
- * for each successfully parsed JSON line. `lineNumber` is 1-based to
220
- * match operator log expectations. Malformed lines are skipped with a
221
- * `Logger.warn`. A missing file resolves with `missing: true` rather
222
- * than throwing the analyzer treats absence as "no signals yet" and
223
- * keeps walking.
267
+ * Stream any NDJSON `target` file line by line, invoking
268
+ * `cb(parsed, lineNumber)` for each successfully parsed JSON line.
269
+ * `lineNumber` is 1-based to match operator log expectations. Malformed
270
+ * lines are skipped with a `Logger.warn`. A missing file resolves with
271
+ * `missing: true` rather than throwing.
224
272
  *
225
- * @param {number} epicId
226
- * @param {number} storyId
273
+ * Shared spine for `forEachLine` (per-Story stream) and `forEachEpicLine`
274
+ * (per-Epic stream) so the two readers cannot drift in their
275
+ * malformed-line / missing-file / cb-throw handling.
276
+ *
277
+ * @param {string} target
227
278
  * @param {(parsed: unknown, lineNumber: number) => unknown | Promise<unknown>} cb
228
- * @param {object} [config]
279
+ * @param {string} label Reader name used in warn messages.
229
280
  * @returns {Promise<{ linesRead: number, linesParsed: number, missing: boolean }>}
230
281
  */
231
- export async function forEachLine(epicId, storyId, cb, config) {
232
- if (typeof cb !== 'function') {
233
- Logger.warn('signals-writer: forEachLine called without a callback');
234
- return { linesRead: 0, linesParsed: 0, missing: false };
235
- }
236
-
237
- let target;
238
- try {
239
- target = signalsFile(epicId, storyId, config);
240
- } catch (err) {
241
- Logger.warn(
242
- `signals-writer: invalid epicId/storyId for forEachLine: ${
243
- err instanceof Error ? err.message : String(err)
244
- }`,
245
- );
246
- return { linesRead: 0, linesParsed: 0, missing: false };
247
- }
248
-
282
+ async function forEachLineIn(target, cb, label) {
249
283
  try {
250
284
  await fs.access(target);
251
285
  } catch {
@@ -278,7 +312,7 @@ export async function forEachLine(epicId, storyId, cb, config) {
278
312
  await cb(parsed, linesRead);
279
313
  } catch (err) {
280
314
  Logger.warn(
281
- `signals-writer: forEachLine cb threw at ${target}:${linesRead}: ${
315
+ `signals-writer: ${label} cb threw at ${target}:${linesRead}: ${
282
316
  err instanceof Error ? err.message : String(err)
283
317
  }`,
284
318
  );
@@ -286,7 +320,7 @@ export async function forEachLine(epicId, storyId, cb, config) {
286
320
  }
287
321
  } catch (err) {
288
322
  Logger.warn(
289
- `signals-writer: forEachLine read failed for ${target}: ${
323
+ `signals-writer: ${label} read failed for ${target}: ${
290
324
  err instanceof Error ? err.message : String(err)
291
325
  }`,
292
326
  );
@@ -294,3 +328,72 @@ export async function forEachLine(epicId, storyId, cb, config) {
294
328
 
295
329
  return { linesRead, linesParsed, missing: false };
296
330
  }
331
+
332
+ /**
333
+ * Stream a per-Story `signals.ndjson` line by line, invoking
334
+ * `cb(parsed, lineNumber)` for each successfully parsed JSON line. A
335
+ * missing file resolves with `missing: true` rather than throwing — the
336
+ * analyzer treats absence as "no signals yet" and keeps walking.
337
+ *
338
+ * @param {number} epicId
339
+ * @param {number} storyId
340
+ * @param {(parsed: unknown, lineNumber: number) => unknown | Promise<unknown>} cb
341
+ * @param {object} [config]
342
+ * @returns {Promise<{ linesRead: number, linesParsed: number, missing: boolean }>}
343
+ */
344
+ export async function forEachLine(epicId, storyId, cb, config) {
345
+ if (typeof cb !== 'function') {
346
+ Logger.warn('signals-writer: forEachLine called without a callback');
347
+ return { linesRead: 0, linesParsed: 0, missing: false };
348
+ }
349
+
350
+ let target;
351
+ try {
352
+ target = signalsFile(epicId, storyId, config);
353
+ } catch (err) {
354
+ Logger.warn(
355
+ `signals-writer: invalid epicId/storyId for forEachLine: ${
356
+ err instanceof Error ? err.message : String(err)
357
+ }`,
358
+ );
359
+ return { linesRead: 0, linesParsed: 0, missing: false };
360
+ }
361
+
362
+ return forEachLineIn(target, cb, 'forEachLine');
363
+ }
364
+
365
+ /**
366
+ * Stream the per-Epic `signals.ndjson` line by line, invoking
367
+ * `cb(parsed, lineNumber)` for each successfully parsed JSON line. This is
368
+ * the read side of `appendEpicSignal` — the wave-lifecycle stream at
369
+ * `temp/epic-<eid>/signals.ndjson` written by `appendEpicSignal` callers
370
+ * (e.g. `lifecycle-emit`). A missing file resolves with `missing: true`
371
+ * rather than throwing, mirroring the per-Story `forEachLine` contract so
372
+ * the retro's unified counts scan can fold the Epic stream in alongside the
373
+ * Story streams without a special-cased absence branch.
374
+ *
375
+ * @param {number} epicId
376
+ * @param {(parsed: unknown, lineNumber: number) => unknown | Promise<unknown>} cb
377
+ * @param {object} [config]
378
+ * @returns {Promise<{ linesRead: number, linesParsed: number, missing: boolean }>}
379
+ */
380
+ export async function forEachEpicLine(epicId, cb, config) {
381
+ if (typeof cb !== 'function') {
382
+ Logger.warn('signals-writer: forEachEpicLine called without a callback');
383
+ return { linesRead: 0, linesParsed: 0, missing: false };
384
+ }
385
+
386
+ let target;
387
+ try {
388
+ target = epicArtifactPath(epicId, EPIC_SIGNALS_BASENAME, config);
389
+ } catch (err) {
390
+ Logger.warn(
391
+ `signals-writer: invalid epicId for forEachEpicLine: ${
392
+ err instanceof Error ? err.message : String(err)
393
+ }`,
394
+ );
395
+ return { linesRead: 0, linesParsed: 0, missing: false };
396
+ }
397
+
398
+ return forEachLineIn(target, cb, 'forEachEpicLine');
399
+ }
@@ -220,10 +220,10 @@ function hashBashInput(toolInput) {
220
220
  * (`tool_input.file_path`, `tool_input.path`, `tool_input.pattern`)
221
221
  * before recording. The raw value never appears on disk.
222
222
  *
223
- * @param {{ tool: string, toolInput?: object, durationMs?: number|null }} args
223
+ * @param {{ tool: string, toolInput?: object, durationMs?: number|null, exitCode?: number|null }} args
224
224
  * @returns {object}
225
225
  */
226
- function buildDetails({ tool, toolInput, durationMs }) {
226
+ function buildDetails({ tool, toolInput, durationMs, exitCode }) {
227
227
  const details = {};
228
228
  if (typeof durationMs === 'number') {
229
229
  details.durationMs = durationMs;
@@ -231,6 +231,14 @@ function buildDetails({ tool, toolInput, durationMs }) {
231
231
  details.durationMs = null;
232
232
  }
233
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
+
234
242
  if (toolInput && typeof toolInput === 'object') {
235
243
  // Bash: hash `command` so a token-laden string never lands on disk.
236
244
  // Story #1768 also records `normalizedHash` (paraphrase-collapsed)
@@ -273,6 +281,35 @@ function buildDetails({ tool, toolInput, durationMs }) {
273
281
  return details;
274
282
  }
275
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
+
276
313
  /**
277
314
  * PreToolUse handler. Stashes `{ startedAt, tool }` into the in-flight
278
315
  * Map keyed by the harness `tool_use_id`. Does not append to
@@ -306,6 +343,7 @@ export async function handlePost(event, active) {
306
343
  const id = event?.tool_use_id ?? event?.id ?? null;
307
344
  const tool = event?.tool_name ?? event?.tool ?? 'unknown';
308
345
  const toolInput = event?.tool_input;
346
+ const exitCode = extractExitCode(event);
309
347
 
310
348
  let durationMs = null;
311
349
  if (id && inflight.has(id)) {
@@ -317,13 +355,13 @@ export async function handlePost(event, active) {
317
355
  const trace = {
318
356
  ts: new Date().toISOString(),
319
357
  kind: 'trace',
320
- source: { tool: clamp(tool) },
358
+ emitter: { tool: clamp(tool) },
321
359
  epicId: active.epicId,
322
360
  storyId: active.storyId,
323
361
  taskId: null,
324
362
  phase:
325
363
  typeof process.env.CC_PHASE === 'string' ? process.env.CC_PHASE : null,
326
- details: buildDetails({ tool, toolInput, durationMs }),
364
+ details: buildDetails({ tool, toolInput, durationMs, exitCode }),
327
365
  };
328
366
 
329
367
  await appendTrace({
@@ -174,7 +174,7 @@ export function buildAcceptanceEvalSignal({
174
174
  epicId: epicId ?? null,
175
175
  storyId,
176
176
  phase,
177
- source: { tool: 'acceptance-eval.js' },
177
+ emitter: { tool: 'acceptance-eval.js' },
178
178
  details: {
179
179
  decision: outcome.decision,
180
180
  round: outcome.round,