@dzhechkov/harness-core 0.6.0 → 0.6.1

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.
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Amendment traceability — the deterministic half of the Step-8 amendment gate (ADR-001).
3
+ *
4
+ * The gate used to be a paragraph of prompt text asking the QE agent to confirm that every `AM-N`
5
+ * row names a real test. That is layer 4 on the cost-of-detection ladder, and the recalled lesson at
6
+ * reward 1.00 says what happens next: a safety property that lives only in a prompt disappears with
7
+ * the next prompt. `features/qe-scoped-review/08_qe_report.md` recorded the outcome — five ids named,
8
+ * none existing, and the plan writing `## Amendments: None`.
9
+ *
10
+ * Everything here is PURE: text in, verdicts out, file access through an injected reader (NFR-1).
11
+ * The CLI owns I/O and the exit code; this module owns the grammar and the rules.
12
+ *
13
+ * NOT PROVEN HERE: that a resolved test is non-vacuous. `dz discrimination-check` owns "would this
14
+ * test still pass with the protection deleted" (non-goal NG-1, acid case A8). A checker that implied
15
+ * it proved vacuity would be the same lie in a new place.
16
+ */
17
+
18
+ /** One `AM-N` row as it appears in a `## Amendments` section. */
19
+ export interface AmendmentRow {
20
+ readonly id: string;
21
+ readonly testIds: readonly string[];
22
+ readonly file: string | null;
23
+ readonly raw: string;
24
+ }
25
+
26
+ export type AmendmentVerdict =
27
+ | 'resolved'
28
+ | 'placeholder'
29
+ | 'unnamed'
30
+ | 'no-file-named'
31
+ | 'file-missing'
32
+ | 'name-absent-in-file';
33
+
34
+ export interface AmendmentResolution {
35
+ readonly id: string;
36
+ readonly testId: string | null;
37
+ readonly file: string | null;
38
+ readonly verdict: AmendmentVerdict;
39
+ readonly detail: string;
40
+ }
41
+
42
+ export type AmendmentOutcome = 'pass' | 'fail' | 'skip' | 'not-established';
43
+
44
+ export interface AmendmentDecision {
45
+ readonly outcome: AmendmentOutcome;
46
+ /** Derived FROM the outcome — one mapping, never two (AM-3, acid case A3/A7). */
47
+ readonly exit: 0 | 1 | 3;
48
+ readonly reasons: readonly string[];
49
+ readonly counts: Readonly<Record<AmendmentVerdict, number>>;
50
+ }
51
+
52
+ /**
53
+ * Template placeholders that reach shipped reports. A stub read as an ordinary unresolvable id lets
54
+ * the author believe they merely mistyped a name, so it earns its own verdict (acid case A1).
55
+ */
56
+ const PLACEHOLDER_IDS = new Set(['test_name', 'test-name', '<test>', '<test_name>', 'tbd', 'todo', 'name']);
57
+
58
+ /**
59
+ * Below this many normalised characters an id is too short to match anything meaningfully: the
60
+ * substring rule ADR-002 accepts would fire on unrelated prose. Guards the degenerate case the ADR
61
+ * names as its known false-positive risk.
62
+ */
63
+ export const MIN_MATCHABLE_ID_LENGTH = 8;
64
+
65
+ /** Case- and separator-folded form. Authors write ids in prose (`a_b_c`); test titles are sentences. */
66
+ /**
67
+ * Every `it()` / `test()` / `describe()` title in a test file. Empty when none parse.
68
+ *
69
+ * Comments are stripped FIRST. A commented-out `it('deny admin writes')` is not a test, and counting
70
+ * it would leave open the very forgery the title basis exists to close — the cross-family reviewer's
71
+ * two-comment-line attack in a slightly better costume. Table forms (`test.each([…])('…')`) carry an
72
+ * argument list between the modifier and the title, so the pattern allows one.
73
+ */
74
+ export function extractTestTitles(body: string): string[] {
75
+ const code = body.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
76
+ const out: string[] = [];
77
+ const re = /\b(?:it|test|describe)(?:\.\w+)*(?:\s*\([^()]{0,200}\))?\s*(?:`[^`]*`)?\s*\(\s*(['"`])([\s\S]{1,300}?)\1/g;
78
+ for (let m = re.exec(code); m !== null; m = re.exec(code)) if (m[2]) out.push(m[2]);
79
+ return out;
80
+ }
81
+
82
+ export function normalizeTestId(s: string): string {
83
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '');
84
+ }
85
+
86
+ /**
87
+ * Row starts, in BOTH shapes the corpus actually contains: a bullet (`- **AM-1 (…):**`) and a table
88
+ * cell (`| **AM-40** |`). A format LEGEND — the literal `AM-N` with an `N` that is not a digit, as in
89
+ * `features/ha-consilium/03.5_ideation_report.md` — is deliberately NOT a row: counting a legend as
90
+ * an amendment would open this feature by falsely accusing a feature that did nothing wrong.
91
+ */
92
+ const ROW_START = /^(?:[-*]\s+\*{0,2}AM-(\d+)|\|\s*\*{0,2}AM-(\d+))/gm;
93
+
94
+ /** The `## Amendments` section body, or null when the document has none (acid case A5). */
95
+ export function amendmentSection(md: string): string | null {
96
+ const m = /^ {0,3}#{2,4}\s+Amendments\b[^\n]*\n/m.exec(md);
97
+ if (!m) return null;
98
+ const start = m.index + m[0].length;
99
+ const rest = md.slice(start);
100
+ const next = /^ {0,3}#{2,4}\s+\S/m.exec(rest);
101
+ return next ? rest.slice(0, next.index) : rest;
102
+ }
103
+
104
+ /** `## Amendments` present but recording nothing to check — distinct from the section being absent. */
105
+ export function planSaysNoAmendments(planMd: string): boolean {
106
+ const sec = amendmentSection(planMd);
107
+ if (sec === null) return false;
108
+ return /^\s*(none|n\/a|нет)\b/i.test(sec.trim());
109
+ }
110
+
111
+ export function parseAmendments(md: string): AmendmentRow[] {
112
+ const section = amendmentSection(md);
113
+ if (section === null) return [];
114
+ const starts: { index: number; num: string }[] = [];
115
+ ROW_START.lastIndex = 0;
116
+ for (let m = ROW_START.exec(section); m !== null; m = ROW_START.exec(section)) {
117
+ starts.push({ index: m.index, num: (m[1] ?? m[2]) as string });
118
+ }
119
+ const rows: AmendmentRow[] = [];
120
+ for (let i = 0; i < starts.length; i++) {
121
+ const s = starts[i] as { index: number; num: string };
122
+ const end = i + 1 < starts.length ? (starts[i + 1] as { index: number }).index : section.length;
123
+ const raw = section.slice(s.index, end);
124
+ rows.push({ id: `AM-${s.num}`, testIds: extractTestIds(raw), file: extractFile(raw), raw });
125
+ }
126
+ return rows;
127
+ }
128
+
129
+ /** `→ test \`a\`` and the two-id shape `→ tests \`a\` and \`b\`` — both are in the corpus. */
130
+ function extractTestIds(raw: string): string[] {
131
+ const out: string[] = [];
132
+ const re = /→\s*tests?\s+`([^`]+)`(?:\s*(?:and|и)\s*`([^`]+)`)?/g;
133
+ for (let m = re.exec(raw); m !== null; m = re.exec(raw)) {
134
+ if (m[1]) out.push(m[1].trim());
135
+ if (m[2]) out.push(m[2].trim());
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * The `in \`<path>\`` half, which in real reports frequently opens the line AFTER the id. A pattern
142
+ * that cannot cross a newline finds almost nothing here — measured while writing this: three
143
+ * successive shell-written extractors returned 108, 111 and 13 rows over the identical corpus.
144
+ */
145
+ function extractFile(raw: string): string | null {
146
+ const m = /→\s*tests?\s+`[^`]+`(?:\s*(?:and|и)\s*`[^`]+`)?[\s\S]{0,40}?\bin\s+`([^`]+)`/.exec(raw);
147
+ return m && m[1] ? m[1].trim() : null;
148
+ }
149
+
150
+ export function resolveAmendments(
151
+ rows: readonly AmendmentRow[],
152
+ opts: { readFile: (p: string) => string | null },
153
+ ): AmendmentResolution[] {
154
+ const out: AmendmentResolution[] = [];
155
+ for (const row of rows) {
156
+ if (row.testIds.length === 0) {
157
+ out.push({
158
+ id: row.id,
159
+ testId: null,
160
+ file: row.file,
161
+ verdict: 'unnamed',
162
+ detail: 'the row carries no `→ test` token — an amendment with no pointer is not a passing amendment',
163
+ });
164
+ continue;
165
+ }
166
+ for (const testId of row.testIds) {
167
+ out.push(resolveOne(row, testId, opts.readFile));
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+
173
+ function resolveOne(
174
+ row: AmendmentRow,
175
+ testId: string,
176
+ readFile: (p: string) => string | null,
177
+ ): AmendmentResolution {
178
+ const base = { id: row.id, testId, file: row.file };
179
+ if (PLACEHOLDER_IDS.has(testId.trim().toLowerCase())) {
180
+ return { ...base, verdict: 'placeholder', detail: `\`${testId}\` is a template placeholder, not a test name` };
181
+ }
182
+ if (row.file === null) {
183
+ return { ...base, verdict: 'no-file-named', detail: 'the row names a test id but no file to find it in' };
184
+ }
185
+ const body = readFile(row.file);
186
+ if (body === null) {
187
+ return { ...base, verdict: 'file-missing', detail: `\`${row.file}\` does not exist or cannot be read` };
188
+ }
189
+ const needle = normalizeTestId(testId);
190
+ if (needle.length < MIN_MATCHABLE_ID_LENGTH) {
191
+ return {
192
+ ...base,
193
+ verdict: 'name-absent-in-file',
194
+ detail: `\`${testId}\` normalises to ${needle.length} characters — below the ${MIN_MATCHABLE_ID_LENGTH}-character floor, so a match would prove nothing`,
195
+ };
196
+ }
197
+ // An existing FILE never stands in for an existing TEST (ADR-002) — and neither does an existing
198
+ // COMMENT. Matching the whole file body is forgeable with two comment lines whose letters happen to
199
+ // spell the id, so the basis is the file's TEST TITLES. Falling back to the body when none parse is
200
+ // stated in the detail rather than done quietly: a silent fallback restores the hole it closes.
201
+ const titles = extractTestTitles(body);
202
+ const basis = titles.length > 0 ? titles.map(normalizeTestId).join('\n') : normalizeTestId(body);
203
+ const basisNote = titles.length > 0 ? `${titles.length} test title(s)` : 'the whole file body — NO test titles parsed, so this match is weaker';
204
+ if (!basis.includes(needle)) {
205
+ return {
206
+ ...base,
207
+ verdict: 'name-absent-in-file',
208
+ detail: `\`${row.file}\` exists but no test in it is named \`${testId}\` (searched ${basisNote})`,
209
+ };
210
+ }
211
+ return { ...base, verdict: 'resolved', detail: `found in \`${row.file}\` (searched ${basisNote})` };
212
+ }
213
+
214
+ const ZERO_COUNTS: Record<AmendmentVerdict, number> = {
215
+ resolved: 0,
216
+ placeholder: 0,
217
+ unnamed: 0,
218
+ 'no-file-named': 0,
219
+ 'file-missing': 0,
220
+ 'name-absent-in-file': 0,
221
+ };
222
+
223
+ export function decideAmendmentOutcome(input: {
224
+ sectionPresent: boolean;
225
+ rows: readonly AmendmentRow[];
226
+ resolutions: readonly AmendmentResolution[];
227
+ planSaysNone: boolean;
228
+ readError?: string | null;
229
+ /** Ideation amendments the plan fails to carry — absent, or reworded under the same id. */
230
+ missingFromPlan?: readonly { id: string; kind: 'dropped' | 'subject-changed' }[];
231
+ }): AmendmentDecision {
232
+ const counts: Record<AmendmentVerdict, number> = { ...ZERO_COUNTS };
233
+ for (const r of input.resolutions) counts[r.verdict]++;
234
+ const reasons: string[] = [];
235
+
236
+ // Inputs we could not read are never a verdict about the feature (acid case A7).
237
+ if (input.readError) {
238
+ return { outcome: 'not-established', exit: 3, reasons: [`inputs unreadable: ${input.readError}`], counts };
239
+ }
240
+ // Absence is a skip with a stated reason, never a pass and never a silent zero (acid case A5).
241
+ if (!input.sectionPresent) {
242
+ return {
243
+ outcome: 'skip',
244
+ exit: 0,
245
+ reasons: ['no `## Amendments` section — nothing to check (this is an absence, not a pass)'],
246
+ counts,
247
+ };
248
+ }
249
+ // The whole class this feature removes: a check that silently checked nothing (AM-1, acid case A7).
250
+ if (input.rows.length === 0) {
251
+ return {
252
+ outcome: 'not-established',
253
+ exit: 3,
254
+ reasons: [
255
+ 'the `## Amendments` section is present but ZERO rows parsed — the grammar matched nothing, which is not the same as nothing being wrong',
256
+ ],
257
+ counts,
258
+ };
259
+ }
260
+ // Ideation carries rows while the plan records "None" — this is HIGH-2 itself (acid case A6).
261
+ // Discovered while closing HIGH-2: the pointers belong in the PLAN. Step 6's own instruction is
262
+ // "carry AM-N into 06_implementation_plan.md verbatim", and the ideation report is a historical
263
+ // artifact — editing its rows to match tests that were named later would be rewriting the record
264
+ // rather than closing the trail. So the plan's rows are authoritative when present, and the rule
265
+ // that keeps that honest is coverage: an ideation amendment the plan never mentions is a DROPPED
266
+ // amendment, which is the renegotiating-away failure in a quieter form.
267
+ for (const gap of input.missingFromPlan ?? []) {
268
+ reasons.push(
269
+ gap.kind === 'dropped'
270
+ ? `${gap.id} is an amendment in 03.5_ideation_report.md that 06_implementation_plan.md never carries — an amendment dropped in planning is one nobody can audit`
271
+ : `${gap.id} appears in both documents but the plan describes a DIFFERENT change — "carry verbatim" means the subject survives; only the test pointer may be renamed`,
272
+ );
273
+ }
274
+ if (input.planSaysNone) {
275
+ reasons.push(
276
+ `the ideation report carries ${input.rows.length} amendment row(s) while 06_implementation_plan.md records \`## Amendments: None\` — the amendments were renegotiated away, and an amendment nobody can resolve is one nobody can audit`,
277
+ );
278
+ }
279
+ for (const r of input.resolutions) {
280
+ if (r.verdict !== 'resolved') reasons.push(`${r.id} → ${r.verdict}: ${r.detail}`);
281
+ }
282
+ return { outcome: reasons.length > 0 ? 'fail' : 'pass', exit: reasons.length > 0 ? 1 : 0, reasons, counts };
283
+ }
284
+
285
+ /** The one line every caller reads last, in the K2 gate's own shape so the two read alike. */
286
+ export function amendmentVerdictLine(d: AmendmentDecision): string {
287
+ const label = d.outcome === 'not-established' ? 'NOT-ESTABLISHED' : d.outcome.toUpperCase();
288
+ const head = `amendment traceability: ${label}`;
289
+ const tail =
290
+ d.outcome === 'pass'
291
+ ? `${d.counts.resolved} row(s) resolved`
292
+ : (d.reasons[0] ?? 'no reason recorded');
293
+ return `${head} — ${tail}`;
294
+ }
295
+
296
+ /** Printed on every run: this checker does NOT prove a resolved test discriminates (NG-1, A8). */
297
+ export const AMENDMENT_VACUITY_NOTE =
298
+ 'note: this checks that each amendment RESOLVES to a real test, not that the test is non-vacuous — `dz discrimination-check` owns vacuity.';
299
+
300
+
301
+ /**
302
+ * The amendment's own text with the `→ test …` pointer clause and markdown furniture removed — what
303
+ * "carry AM-N into the plan verbatim" is actually about. The POINTER may legitimately change (tests
304
+ * are named later than ideation guesses); the SUBJECT may not.
305
+ */
306
+ export function amendmentSubject(raw: string): string {
307
+ const withoutPointer = raw.split(/→\s*tests?\s/)[0] ?? '';
308
+ // Strip ONLY the row's furniture: bullet/table marks, the bold id, an optional `(source)` tag and
309
+ // a colon. An earlier version consumed up to 80 characters after the id, which ate the SUBJECT
310
+ // itself whenever a row carried no `(source):` tag — the checker then compared two truncations
311
+ // and called honest rows a mismatch.
312
+ const stripped = withoutPointer.replace(/^[\s|*\-]*\**AM-\d+\**\s*(?:\([^)]{0,80}\))?\s*:?\s*/, '');
313
+ return normalizeTestId(stripped);
314
+ }
315
+
316
+ export interface PlanCoverageGap {
317
+ readonly id: string;
318
+ readonly kind: 'dropped' | 'subject-changed';
319
+ }
320
+
321
+ /**
322
+ * Ideation amendments the plan fails to carry: either absent outright, or present under the same id
323
+ * with a DIFFERENT subject. Cross-family review (Codex gpt-5.6-sol, 2026-08-21) found the second
324
+ * case: comparing ids alone let a plan swap "deny unauthenticated deletes" for "render footer" under
325
+ * the same `AM-1` and still pass.
326
+ */
327
+ export function amendmentsMissingFromPlan(
328
+ ideationRows: readonly AmendmentRow[],
329
+ planRows: readonly AmendmentRow[],
330
+ ): PlanCoverageGap[] {
331
+ const byId = new Map(planRows.map((r) => [r.id, r]));
332
+ const gaps: PlanCoverageGap[] = [];
333
+ for (const row of ideationRows) {
334
+ const planRow = byId.get(row.id);
335
+ if (planRow === undefined) {
336
+ gaps.push({ id: row.id, kind: 'dropped' });
337
+ continue;
338
+ }
339
+ const want = amendmentSubject(row.raw);
340
+ const got = amendmentSubject(planRow.raw);
341
+ // Containment either way: a plan may append a note ("closes HIGH-2"), and ideation may be the
342
+ // longer prose. What it may not do is describe a different change.
343
+ if (want.length >= MIN_MATCHABLE_ID_LENGTH && !got.includes(want) && !want.includes(got)) {
344
+ gaps.push({ id: row.id, kind: 'subject-changed' });
345
+ }
346
+ }
347
+ return gaps;
348
+ }
package/src/index.ts CHANGED
@@ -240,6 +240,30 @@ export {
240
240
  export type { CheckpointStage, CheckpointEntry, ResumeMode, ResumeDecision, ParsedCheckpointRead, TrainingPairFamily, TrainingPair, TrainingPairEvaluation, TrainingPairProvenance, TrainingPairTruncation,
241
241
  CheckpointWriteVerdict,
242
242
  } from './feature-adr-checkpoints.js';
243
+
244
+ // amendment-traceability (ADR-001/002/003): the deterministic half of the Step-8 amendment gate.
245
+ export {
246
+ MIN_MATCHABLE_ID_LENGTH,
247
+ AMENDMENT_VACUITY_NOTE,
248
+ normalizeTestId,
249
+ amendmentSection,
250
+ planSaysNoAmendments,
251
+ parseAmendments,
252
+ resolveAmendments,
253
+ decideAmendmentOutcome,
254
+ amendmentVerdictLine,
255
+ amendmentsMissingFromPlan,
256
+ amendmentSubject,
257
+ extractTestTitles,
258
+ } from './amendment-trace.js';
259
+ export {
260
+ RECORD_MAX_LINE_CHARS,
261
+ decideRecordWrite,
262
+ decideReadBack,
263
+ recordVerdictLine,
264
+ } from './run-records.js';
265
+ export type { RecordKind, RecordVerdict, RecordDecision } from './run-records.js';
266
+ export type { AmendmentRow, AmendmentVerdict, AmendmentResolution, AmendmentOutcome, AmendmentDecision, PlanCoverageGap } from './amendment-trace.js';
243
267
  export {
244
268
  DOMAIN_LIFT_EXACT,
245
269
  DOMAIN_LIFT_RELATED,
package/src/patterns.ts CHANGED
@@ -287,7 +287,10 @@ export function computePatternBoost(
287
287
  matched = true;
288
288
  } else {
289
289
  // medium: a meaningful word from the pattern appears in the skill haystack
290
- for (const word of text.split(/[^a-z0-9]+/)) {
290
+ // `\p{L}\p{N}`, not `a-z0-9`: the ASCII-only class made every non-Latin word invisible to
291
+ // the boost, so a Cyrillic pattern could never match a skill haystack. The `>= 5` rule below
292
+ // is this site's OWN threshold and is deliberately unchanged — only the alphabet moved.
293
+ for (const word of text.split(/[^\p{L}\p{N}]+/u)) {
291
294
  if (word.length >= 5 && haystack.includes(word)) {
292
295
  matched = true;
293
296
  break;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Witnessed run records — the decision half of `dz feature-adr-record` (ADR-001 … ADR-003).
3
+ *
4
+ * Two durable writers in the /feature-adr workflow still handed a subagent a PRE-BAKED shell string
5
+ * carrying their payload: the run-cost ledger and the training-pair capture. That is the shape a
6
+ * security classifier blocked NINE times in one run — one entity instructing another to append state
7
+ * it never verified. The checkpoint writer was migrated for that reason; these two were left behind.
8
+ *
9
+ * The role change is the point: the subagent stops being a COURIER (handed a shell string, appends
10
+ * it) and becomes a CALLER (handed arguments; the command decides). A courier can neither refuse nor
11
+ * verify.
12
+ *
13
+ * Pure: payload in, verdict out. The CLI owns paths, the append, the read-back and the exit code.
14
+ */
15
+
16
+ export type RecordKind = 'ledger' | 'training-pair';
17
+
18
+ export type RecordVerdict =
19
+ /** the line was appended AND read back equal */
20
+ | 'written'
21
+ /** a mark shows another run got here first — nothing written, nothing wrong */
22
+ | 'duplicate'
23
+ /** the target already held this pair — nothing written, nothing wrong */
24
+ | 'skipped'
25
+ /** the payload was rejected before any write; the target is untouched */
26
+ | 'refused'
27
+ /** the append happened but the read-back disagreed — the caller MUST treat this as NOT written */
28
+ | 'not-verified';
29
+
30
+ export interface RecordDecision {
31
+ readonly verdict: RecordVerdict;
32
+ /** One mapping, never two: 0 written|duplicate|skipped · 2 refused · 3 not-verified. */
33
+ readonly exit: 0 | 2 | 3;
34
+ readonly reason: string;
35
+ /**
36
+ * ALWAYS false. A cost row and a training pair are observability, and observability must not take
37
+ * the run down with it (ADR-003). The field exists so the property is assertable rather than
38
+ * merely intended — a thrown refusal would turn bookkeeping into an outage.
39
+ */
40
+ readonly blocking: false;
41
+ /** The exact line to append, or null when nothing may be written. */
42
+ readonly line: string | null;
43
+ /** Set when a mark was found without its target — the previous holder died before writing. */
44
+ readonly staleMark?: boolean;
45
+ }
46
+
47
+ /** A serialised record line above this is refused rather than truncated (acid case A2). */
48
+ export const RECORD_MAX_LINE_CHARS = 24_000;
49
+
50
+ /** Fields every ledger row must carry before it is worth writing down. */
51
+ const LEDGER_REQUIRED = ['slug', 'stage'] as const;
52
+ /** Fields every training pair must carry — the dataset is worthless without input/output. */
53
+ const PAIR_REQUIRED = ['slug', 'stage', 'input', 'output'] as const;
54
+
55
+ const refuse = (reason: string): RecordDecision => ({ verdict: 'refused', exit: 2, reason, blocking: false, line: null });
56
+
57
+ /** `duplicate` and `skipped` both wrote nothing and both are fine — but they are DIFFERENT facts. */
58
+ const noop = (verdict: 'duplicate' | 'skipped', reason: string): RecordDecision => ({
59
+ verdict,
60
+ exit: 0,
61
+ reason,
62
+ blocking: false,
63
+ line: null,
64
+ });
65
+
66
+ function shapeMismatch(kind: RecordKind, payload: Record<string, unknown>): string | null {
67
+ // AM-1 FIRST, before the required-field sweep. A ledger row offered as a training pair fails BOTH
68
+ // checks, and the wrong-kind reason is the one that tells the caller what actually happened —
69
+ // "missing field `output`" sends them looking for a field they never meant to send.
70
+ if (kind === 'ledger' && 'input' in payload && 'output' in payload) {
71
+ // BOTH fields together are the training-pair signature. Either one alone is not: a ledger row may
72
+ // legitimately carry `input: {cached_tokens: 80}` (cross-family review, 2026-08-21) and refusing
73
+ // it would make the command reject honest data on a name collision.
74
+ return 'this payload carries BOTH `input` and `output` — it is a training pair, not a ledger row';
75
+ }
76
+ if (kind === 'training-pair' && 'tokens' in payload && !('input' in payload)) {
77
+ return 'this payload looks like a ledger row (`tokens` without `input`), not a training pair';
78
+ }
79
+ const required = kind === 'ledger' ? LEDGER_REQUIRED : PAIR_REQUIRED;
80
+ for (const field of required) {
81
+ const v = payload[field];
82
+ if (v === undefined || v === null) return `a ${kind} record is missing the required field \`${field}\``;
83
+ // EMPTY is not present. An earlier version checked only string-emptiness, so `input: []` and
84
+ // `output: {}` satisfied the requirement and an empty record reached the file — a pair with no
85
+ // content is worse than no pair, because it looks captured.
86
+ if (typeof v === 'string' && v.trim() === '') return `a ${kind} record has an EMPTY \`${field}\``;
87
+ if (Array.isArray(v) && v.length === 0) return `a ${kind} record has an EMPTY \`${field}\` (empty array)`;
88
+ if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v as object).length === 0) {
89
+ return `a ${kind} record has an EMPTY \`${field}\` (empty object)`;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ export function decideRecordWrite(input: {
96
+ kind: RecordKind;
97
+ /** The raw `--row` / `--pair` argument, exactly as the caller passed it. */
98
+ payloadRaw: string;
99
+ /** The stage this record belongs to; a record for a stage that produced nothing is refused. */
100
+ stage: string;
101
+ stageProducedResult?: boolean;
102
+ /** A backfill mark already present ⇒ another run got here first — unless the target is absent. */
103
+ markExists?: boolean;
104
+ /** Whether the target file exists; a mark without a target is a STALE mark, not a duplicate. */
105
+ targetExists?: boolean;
106
+ /** The target already holds this pair. */
107
+ targetHasPair?: boolean;
108
+ /** Stamped INTO the object before serialising — never rewritten in the shell afterwards (FR-7). */
109
+ timestamp?: string | null;
110
+ maxChars?: number;
111
+ }): RecordDecision {
112
+ const { kind, payloadRaw, stage } = input;
113
+ if (kind !== 'ledger' && kind !== 'training-pair') {
114
+ return refuse(`unknown --kind \`${String(kind)}\` — expected ledger or training-pair`);
115
+ }
116
+ if (typeof stage !== 'string' || stage.trim() === '') return refuse('--stage is required');
117
+ if (input.stageProducedResult === false) {
118
+ return refuse(`stage \`${stage}\` produced no result — there is nothing to record`);
119
+ }
120
+
121
+ let payload: unknown;
122
+ try {
123
+ payload = JSON.parse(payloadRaw);
124
+ } catch {
125
+ return refuse('the payload is not valid JSON — refused before any write, the target is untouched');
126
+ }
127
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
128
+ return refuse('the payload must be a JSON object');
129
+ }
130
+ const obj = payload as Record<string, unknown>;
131
+
132
+ const mismatch = shapeMismatch(kind, obj);
133
+ if (mismatch !== null) return refuse(mismatch);
134
+
135
+ // The record is filed under `--stage`, and the payload carries its own. A disagreement means the
136
+ // row would land in the wrong stage's file (pairs) or under a wrong label (ledger) — both available
137
+ // here, so leaving them uncompared was a free check declined.
138
+ const payloadStage = obj['stage'];
139
+ if (typeof payloadStage === 'string' && payloadStage !== stage) {
140
+ return refuse(`the payload's stage \`${payloadStage}\` disagrees with --stage \`${stage}\` — the record would be filed under the wrong stage`);
141
+ }
142
+
143
+ // The no-write outcomes are checked AFTER the payload is validated: reporting `duplicate` for a
144
+ // malformed payload would hide a real defect behind a benign-looking verdict.
145
+ // A mark whose TARGET does not exist is STALE: a previous run took the mark and died before writing.
146
+ // Reporting `duplicate` there lets one crash lose the record forever — the silent-loss shape this
147
+ // whole feature removes (cross-family review, 2026-08-21). A stale mark does NOT stop the write; it
148
+ // is recorded on the decision so the caller can say why it proceeded anyway.
149
+ const staleMark = input.markExists === true && input.targetExists === false;
150
+ if (input.markExists === true && !staleMark) {
151
+ return noop('duplicate', 'a mark for this record already exists — another run captured it first');
152
+ }
153
+ if (input.targetHasPair === true) {
154
+ return noop('skipped', 'the target already holds this record — nothing to add');
155
+ }
156
+
157
+ // The timestamp goes in BEFORE serialisation. The shell `sed` this replaces rewrote `"date":null`
158
+ // inside an already-serialised document — text surgery on a structured value, and the exact place
159
+ // a payload containing that literal token could corrupt itself.
160
+ const stamped: Record<string, unknown> = { ...obj };
161
+ if (input.timestamp != null && input.timestamp !== '') {
162
+ // An EMPTY STRING is a gap, not a value. Stamping only over null/undefined let
163
+ // `"date":""` through as `written` (cross-family review, 2026-08-21) — a row that looks recorded
164
+ // and carries no date.
165
+ const isGap = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
166
+ if (kind === 'ledger' && isGap(stamped['date'])) stamped['date'] = input.timestamp.slice(0, 10);
167
+ if (kind === 'training-pair' && isGap(stamped['ts'])) stamped['ts'] = input.timestamp;
168
+ }
169
+
170
+ let line: string;
171
+ try {
172
+ line = JSON.stringify(stamped);
173
+ } catch {
174
+ return refuse('the payload could not be serialised (circular or unsupported value)');
175
+ }
176
+ const cap = input.maxChars ?? RECORD_MAX_LINE_CHARS;
177
+ if (line.length > cap) {
178
+ return refuse(`the serialised record is ${line.length} chars, above the ${cap}-char cap — refused rather than truncated`);
179
+ }
180
+ if (line.includes('\n')) return refuse('the serialised record contains a newline — one record is one line');
181
+
182
+ return {
183
+ verdict: 'written',
184
+ exit: 0,
185
+ reason: staleMark
186
+ ? `${kind} record ready to append (a STALE mark was found — its target is absent, so a previous holder died before writing)`
187
+ : `${kind} record ready to append`,
188
+ blocking: false,
189
+ line,
190
+ staleMark,
191
+ };
192
+ }
193
+
194
+ /** The read-back verdict (ADR-002): equal bytes or NOT written. Never inferred from the absence of an error. */
195
+ export function decideReadBack(appended: string, lastLineOnDisk: string | null): RecordDecision {
196
+ if (lastLineOnDisk === null) {
197
+ return {
198
+ verdict: 'not-verified',
199
+ exit: 3,
200
+ reason: 'the record was appended but the file could not be read back — treat this as NOT written',
201
+ blocking: false,
202
+ line: appended,
203
+ };
204
+ }
205
+ if (lastLineOnDisk !== appended) {
206
+ return {
207
+ verdict: 'not-verified',
208
+ exit: 3,
209
+ reason: 'the last line on disk differs from what was appended — treat this as NOT written',
210
+ blocking: false,
211
+ line: appended,
212
+ };
213
+ }
214
+ return { verdict: 'written', exit: 0, reason: 'appended and verified by re-reading the tail', blocking: false, line: appended };
215
+ }
216
+
217
+ /** The one line every caller reads last, in the shape the other gates use. */
218
+ export function recordVerdictLine(kind: RecordKind, stage: string, d: RecordDecision): string {
219
+ return `feature-adr record (${kind}/${stage}): ${d.verdict.toUpperCase()} — ${d.reason}`;
220
+ }