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.
- 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 +63 -16
- package/.agents/docs/configuration.md +5 -3
- 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 +16 -4
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -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/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/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +21 -2
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- 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 +66 -20
- 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/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 +188 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
- 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 +218 -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/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/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 +193 -44
- package/.agents/workflows/helpers/deliver-epic.md +128 -39
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -283
- 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 -207
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -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
|
-
*
|
|
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
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* `
|
|
73
|
-
*
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
-
|
|
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 `
|
|
219
|
-
* for each successfully parsed JSON line.
|
|
220
|
-
* match operator log expectations. Malformed
|
|
221
|
-
* `Logger.warn`. A missing file resolves with
|
|
222
|
-
*
|
|
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
|
-
*
|
|
226
|
-
*
|
|
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 {
|
|
279
|
+
* @param {string} label Reader name used in warn messages.
|
|
229
280
|
* @returns {Promise<{ linesRead: number, linesParsed: number, missing: boolean }>}
|
|
230
281
|
*/
|
|
231
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
177
|
+
emitter: { tool: 'acceptance-eval.js' },
|
|
178
178
|
details: {
|
|
179
179
|
decision: outcome.decision,
|
|
180
180
|
round: outcome.round,
|