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.
- package/.agents/README.md +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +62 -27
- package/.agents/docs/configuration.md +5 -4
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +10 -6
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/coverage-capture.js +17 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config/runners.js +13 -5
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/npm-scripts.js +55 -0
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +116 -43
- package/.agents/workflows/helpers/deliver-epic.md +123 -54
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -366
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- 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
|
-
*
|
|
12
|
-
* `lib/signals/read
|
|
13
|
-
*
|
|
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 `
|
|
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
|
-
|
|
83
|
-
? evt.
|
|
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 `
|
|
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 =
|
|
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.
|
|
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
|
|
165
|
-
* `details.
|
|
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
|
|
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.
|
|
178
|
-
|
|
186
|
+
if (typeof d.commandHash === 'string' && d.commandHash.length > 0) {
|
|
187
|
+
commandHashes.add(d.commandHash);
|
|
179
188
|
}
|
|
180
189
|
}
|
|
181
|
-
return { retries, uniqueCommands:
|
|
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 ??
|
|
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
|
|
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
|
-
}
|
|
@@ -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
|
+
}
|