mandrel 1.88.0 → 1.90.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 (145) 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 +62 -27
  17. package/.agents/docs/configuration.md +5 -4
  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 +10 -6
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  28. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
  29. package/.agents/schemas/signal-event.schema.json +28 -13
  30. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  31. package/.agents/scripts/check-context-budget.js +320 -0
  32. package/.agents/scripts/coverage-capture.js +17 -0
  33. package/.agents/scripts/diagnose-friction.js +4 -4
  34. package/.agents/scripts/epic-audit-prepare.js +30 -2
  35. package/.agents/scripts/epic-audit-recheck.js +46 -13
  36. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  37. package/.agents/scripts/epic-plan-spec.js +4 -8
  38. package/.agents/scripts/generate-lens-checklists.js +180 -0
  39. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  40. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  41. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  42. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  43. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  44. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  45. package/.agents/scripts/lib/cli-args.js +8 -0
  46. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  47. package/.agents/scripts/lib/config/ci.js +12 -1
  48. package/.agents/scripts/lib/config/runners.js +13 -5
  49. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  50. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
  51. package/.agents/scripts/lib/doc-tiers.js +291 -0
  52. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  53. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  54. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  55. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
  56. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  57. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  58. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  59. package/.agents/scripts/lib/gates/friction.js +15 -5
  60. package/.agents/scripts/lib/npm-scripts.js +55 -0
  61. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  62. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  63. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  64. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  65. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  66. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  67. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  68. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  69. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  70. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  71. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  72. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  73. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
  79. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  80. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  81. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  82. package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
  83. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  84. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  85. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  86. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  87. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  88. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  89. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  92. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  93. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  94. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  95. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  96. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  97. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  98. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  99. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  100. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  101. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  102. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  103. package/.agents/scripts/lib/planning-corpus.js +306 -0
  104. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  105. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  106. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  107. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  108. package/.agents/scripts/lib/signals/schema.js +56 -81
  109. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  110. package/.agents/scripts/lib/story-plan.js +3 -0
  111. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  112. package/.agents/scripts/lifecycle-emit.js +39 -8
  113. package/.agents/scripts/providers/github/issues.js +12 -1
  114. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  115. package/.agents/scripts/retro-run.js +51 -0
  116. package/.agents/scripts/signals-view.js +1 -1
  117. package/.agents/scripts/single-story-close.js +20 -1
  118. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  119. package/.agents/scripts/story-close.js +48 -0
  120. package/.agents/scripts/story-plan.js +51 -12
  121. package/.agents/scripts/validate-docs-freshness.js +69 -15
  122. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  123. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  124. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  125. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  126. package/.agents/skills/skills.index.json +3 -3
  127. package/.agents/workflows/audit-documentation.md +82 -2
  128. package/.agents/workflows/helpers/code-review.md +116 -43
  129. package/.agents/workflows/helpers/deliver-epic.md +123 -54
  130. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  131. package/.agents/workflows/helpers/epic-audit.md +116 -366
  132. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  133. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  134. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  135. package/.agents/workflows/helpers/plan-epic.md +141 -105
  136. package/.agents/workflows/helpers/plan-story.md +32 -0
  137. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  138. package/.agents/workflows/loops/nightly-audit.md +9 -7
  139. package/docs/CHANGELOG.md +29 -0
  140. package/lib/cli/doctor.js +44 -0
  141. package/package.json +4 -3
  142. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  143. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
  144. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  145. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -8,12 +8,10 @@
8
8
  * - `computeStoryPerfSummary(events, opts)` → `<!-- structured:story-perf-summary -->`
9
9
  * - `computeEpicPerfReport(perStorySummaries, opts)` → `<!-- structured:epic-perf-report -->`
10
10
  *
11
- * Plus streaming counterparts that consume the canonical
12
- * `lib/signals/read` iterator directly, so the aggregator owns its own
13
- * NDJSON ingestion through the shared reader (Task #1460 AC):
14
- *
15
- * - `computeStoryPerfSummaryFromStore({ storyId, epicId, config? })`
16
- * - `computeEpicPerfReportFromStore({ epicId, perStorySummaries, config? })`
11
+ * Both take a materialised event iterable; the caller (`analyze-execution.js`)
12
+ * owns NDJSON ingestion via `lib/signals/read.js`. (The former
13
+ * streaming store-reading variants were retired in the Epic #4406
14
+ * signal-contract cutover — they had no callers.)
17
15
  *
18
16
  * Schemas:
19
17
  * - `.agents/schemas/story-perf-summary.schema.json`
@@ -39,7 +37,6 @@
39
37
  */
40
38
 
41
39
  import { isObject } from '../json-utils.js';
42
- import { read as readSignals } from '../signals/read.js';
43
40
  import { EVENT_KINDS } from '../signals/schema.js';
44
41
 
45
42
  const FRICTION_KIND = EVENT_KINDS.FRICTION;
@@ -69,7 +66,10 @@ function nonNegativeNumber(v) {
69
66
 
70
67
  /**
71
68
  * Pull friction-by-category counts off a list of NDJSON events. Keys are
72
- * the `details.category` strings; values ≥ 0 integers.
69
+ * the **top-level** `category` strings (Epic #4406 canonical shape); a
70
+ * record with no top-level category buckets under `Unknown`. Reading the
71
+ * top-level key (not `details.category`) is what un-zeroes the report —
72
+ * every writer emits `category` at the envelope top level.
73
73
  *
74
74
  * @param {Iterable<object>} events
75
75
  * @returns {Object<string, number>}
@@ -79,8 +79,8 @@ function frictionByCategory(events) {
79
79
  for (const evt of events) {
80
80
  if (!isObject(evt) || evt.kind !== FRICTION_KIND) continue;
81
81
  const category =
82
- isObject(evt.details) && typeof evt.details.category === 'string'
83
- ? evt.details.category
82
+ typeof evt.category === 'string' && evt.category.length > 0
83
+ ? evt.category
84
84
  : 'Unknown';
85
85
  out[category] = (out[category] ?? 0) + 1;
86
86
  }
@@ -125,7 +125,10 @@ function topSlowPhasesVsBaseline(events, opts = {}) {
125
125
  /**
126
126
  * Build the `reworkScore` object: `{ filesEditedBeyondThreshold, topPath?,
127
127
  * topPathEdits? }`. We aggregate `kind: 'rework'` signals whose details
128
- * carry a `path` and an `edits` count. When the input has no rework
128
+ * carry a `targetHash` and an `editCount` — the exact keys
129
+ * `detectors/rework.js` emits (Epic #4406 canonical shape). `topPath` is
130
+ * the offending `targetHash` (a sha256; the raw path never reaches the
131
+ * aggregator by the privacy contract). When the input has no rework
129
132
  * signals we return the zero-shape: `{ filesEditedBeyondThreshold: 0 }`.
130
133
  *
131
134
  * @param {Iterable<object>} events
@@ -136,9 +139,12 @@ function reworkScore(events) {
136
139
  for (const evt of events) {
137
140
  if (!isObject(evt) || evt.kind !== REWORK_KIND) continue;
138
141
  const d = isObject(evt.details) ? evt.details : {};
139
- const p = typeof d.path === 'string' && d.path.length > 0 ? d.path : null;
142
+ const p =
143
+ typeof d.targetHash === 'string' && d.targetHash.length > 0
144
+ ? d.targetHash
145
+ : null;
140
146
  if (!p) continue;
141
- const edits = nonNegativeInt(d.edits);
147
+ const edits = nonNegativeInt(d.editCount);
142
148
  editsByPath.set(p, Math.max(editsByPath.get(p) ?? 0, edits));
143
149
  }
144
150
  if (editsByPath.size === 0) {
@@ -161,24 +167,27 @@ function reworkScore(events) {
161
167
 
162
168
  /**
163
169
  * Build the `retryDensity` object: `{ retries, uniqueCommands }`. Sums
164
- * `kind: 'retry'` signals; `uniqueCommands` is the number of distinct
165
- * `details.command` strings observed. Zero-shape on empty input.
170
+ * `kind: 'retry'` signals; `uniqueCommands` is the hash-cardinality of the
171
+ * distinct `details.commandHash` values observed — the exact key
172
+ * `detectors/retry.js` emits (Epic #4406 canonical shape). The raw command
173
+ * never reaches the aggregator by the privacy contract, so we count
174
+ * distinct hashes. Zero-shape on empty input.
166
175
  *
167
176
  * @param {Iterable<object>} events
168
177
  * @returns {{ retries: number, uniqueCommands: number }}
169
178
  */
170
179
  function retryDensity(events) {
171
180
  let retries = 0;
172
- const commands = new Set();
181
+ const commandHashes = new Set();
173
182
  for (const evt of events) {
174
183
  if (!isObject(evt) || evt.kind !== RETRY_KIND) continue;
175
184
  const d = isObject(evt.details) ? evt.details : {};
176
185
  retries += 1;
177
- if (typeof d.command === 'string' && d.command.length > 0) {
178
- commands.add(d.command);
186
+ if (typeof d.commandHash === 'string' && d.commandHash.length > 0) {
187
+ commandHashes.add(d.commandHash);
179
188
  }
180
189
  }
181
- return { retries, uniqueCommands: commands.size };
190
+ return { retries, uniqueCommands: commandHashes.size };
182
191
  }
183
192
 
184
193
  /**
@@ -375,7 +384,7 @@ const DEFAULT_VERIFY_CONCURRENCY_CAP = 4;
375
384
  const DEFAULT_WAVE_CONCURRENCY_CAP = 2;
376
385
 
377
386
  function tsOf(evt) {
378
- return evt?.ts ?? evt?.timestamp ?? null;
387
+ return evt?.ts ?? null;
379
388
  }
380
389
 
381
390
  function tsToMs(ts) {
@@ -385,8 +394,7 @@ function tsToMs(ts) {
385
394
  }
386
395
 
387
396
  function storyIdOf(evt) {
388
- const raw = evt?.story ?? evt?.storyId;
389
- const n = Number(raw);
397
+ const n = Number(evt?.storyId);
390
398
  return Number.isInteger(n) && n > 0 ? n : null;
391
399
  }
392
400
 
@@ -803,85 +811,3 @@ export function computeEpicPerfReport(perStorySummaries, opts) {
803
811
  mostFrictionStories,
804
812
  };
805
813
  }
806
-
807
- // ---------------------------------------------------------------------------
808
- // Streaming entry-points (Epic #1181 / Story #1438 / Task #1460)
809
- // ---------------------------------------------------------------------------
810
-
811
- /**
812
- * Streaming variant of `computeStoryPerfSummary` that ingests events
813
- * directly from `lib/signals/read.js` rather than expecting the caller
814
- * to materialise the iterable upstream. The aggregation logic is
815
- * identical — we collect the events through the shared reader and then
816
- * delegate to `computeStoryPerfSummary`.
817
- *
818
- * Use this when the caller is the analyzer and already has the
819
- * `{ epicId, storyId, config }` triple; use the pure
820
- * `computeStoryPerfSummary(events, opts)` when the caller already
821
- * holds an in-memory event array (tests, mock injections).
822
- *
823
- * @param {{
824
- * storyId: number,
825
- * epicId: number,
826
- * closedAt?: string,
827
- * phaseTiming?: object|null,
828
- * config?: object,
829
- * }} opts
830
- * @returns {Promise<object>} StoryPerfSummary payload.
831
- */
832
- export async function computeStoryPerfSummaryFromStore(opts) {
833
- if (!opts || typeof opts !== 'object') {
834
- throw new TypeError('computeStoryPerfSummaryFromStore: opts is required');
835
- }
836
- const { storyId, epicId, config } = opts;
837
- const events = [];
838
- for await (const evt of readSignals({
839
- epic: Number(epicId),
840
- story: Number(storyId),
841
- config,
842
- })) {
843
- events.push(evt);
844
- }
845
- return computeStoryPerfSummary(events, {
846
- storyId,
847
- epicId,
848
- closedAt: opts.closedAt,
849
- phaseTiming: opts.phaseTiming,
850
- });
851
- }
852
-
853
- /**
854
- * Streaming variant of `computeEpicPerfReport` that ingests the
855
- * raw-event roll-up directly from `lib/signals/read.js` (across every
856
- * Story under the Epic). Per-Story summaries are still passed in by
857
- * the caller — those are the canonical per-Story payloads upserted
858
- * onto each Story ticket and not derivable from the raw stream alone
859
- * (they fold in phase-timer data).
860
- *
861
- * @param {{
862
- * epicId: number,
863
- * perStorySummaries?: Iterable<object>,
864
- * generatedAt?: string,
865
- * waveParallelism?: Array<object>,
866
- * topHotspots?: Array<object>,
867
- * config?: object,
868
- * }} opts
869
- * @returns {Promise<object>} EpicPerfReport payload.
870
- */
871
- export async function computeEpicPerfReportFromStore(opts) {
872
- if (!opts || typeof opts !== 'object') {
873
- throw new TypeError('computeEpicPerfReportFromStore: opts is required');
874
- }
875
- const { epicId, perStorySummaries, config } = opts;
876
- const events = [];
877
- for await (const evt of readSignals({ epic: Number(epicId), config })) {
878
- events.push(evt);
879
- }
880
- return computeEpicPerfReport(perStorySummaries ?? [], {
881
- epicId,
882
- generatedAt: opts.generatedAt,
883
- events,
884
- waveParallelism: opts.waveParallelism,
885
- topHotspots: opts.topHotspots,
886
- });
887
- }
@@ -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
+ }