@dzhechkov/harness-core 0.8.38 → 0.8.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.dz-manifest.json +223 -123
  2. package/README.md +97 -2
  3. package/dist/agentdb-index.d.ts +3 -3
  4. package/dist/agentdb-index.js +5 -5
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/amendment-trace.d.ts.map +1 -1
  7. package/dist/amendment-trace.js +4 -1
  8. package/dist/amendment-trace.js.map +1 -1
  9. package/dist/backlog.d.ts +2 -1
  10. package/dist/backlog.d.ts.map +1 -1
  11. package/dist/backlog.js +3 -2
  12. package/dist/backlog.js.map +1 -1
  13. package/dist/brain.d.ts.map +1 -1
  14. package/dist/brain.js +9 -2
  15. package/dist/brain.js.map +1 -1
  16. package/dist/bto-optimize.d.ts.map +1 -1
  17. package/dist/bto-optimize.js +9 -12
  18. package/dist/bto-optimize.js.map +1 -1
  19. package/dist/claim-check.d.ts.map +1 -1
  20. package/dist/claim-check.js +50 -14
  21. package/dist/claim-check.js.map +1 -1
  22. package/dist/experiment-assign.d.ts +110 -0
  23. package/dist/experiment-assign.d.ts.map +1 -0
  24. package/dist/experiment-assign.js +229 -0
  25. package/dist/experiment-assign.js.map +1 -0
  26. package/dist/feature-adr-checkpoints.d.ts +7 -2
  27. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  28. package/dist/feature-adr-checkpoints.js +20 -5
  29. package/dist/feature-adr-checkpoints.js.map +1 -1
  30. package/dist/feature-adr-envelope.d.ts +12 -0
  31. package/dist/feature-adr-envelope.d.ts.map +1 -1
  32. package/dist/feature-adr-envelope.js +12 -1
  33. package/dist/feature-adr-envelope.js.map +1 -1
  34. package/dist/feature-adr-routing.d.ts +4 -0
  35. package/dist/feature-adr-routing.d.ts.map +1 -1
  36. package/dist/feature-adr-routing.js +4 -0
  37. package/dist/feature-adr-routing.js.map +1 -1
  38. package/dist/index.d.ts +9 -4
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +8 -3
  41. package/dist/index.js.map +1 -1
  42. package/dist/ledger-cost-fill.d.ts +58 -0
  43. package/dist/ledger-cost-fill.d.ts.map +1 -0
  44. package/dist/ledger-cost-fill.js +78 -0
  45. package/dist/ledger-cost-fill.js.map +1 -0
  46. package/dist/loop-blobs.generated.js +2 -2
  47. package/dist/loop-blobs.generated.js.map +1 -1
  48. package/dist/mutation-gate.d.ts +29 -0
  49. package/dist/mutation-gate.d.ts.map +1 -1
  50. package/dist/mutation-gate.js +20 -0
  51. package/dist/mutation-gate.js.map +1 -1
  52. package/dist/name-check.d.ts +20 -1
  53. package/dist/name-check.d.ts.map +1 -1
  54. package/dist/name-check.js +42 -1
  55. package/dist/name-check.js.map +1 -1
  56. package/dist/no-stubs.d.ts +10 -0
  57. package/dist/no-stubs.d.ts.map +1 -1
  58. package/dist/no-stubs.js +13 -9
  59. package/dist/no-stubs.js.map +1 -1
  60. package/dist/patterns.d.ts +24 -0
  61. package/dist/patterns.d.ts.map +1 -1
  62. package/dist/patterns.js +49 -9
  63. package/dist/patterns.js.map +1 -1
  64. package/dist/publish-source-scope.d.ts +32 -0
  65. package/dist/publish-source-scope.d.ts.map +1 -0
  66. package/dist/publish-source-scope.js +53 -0
  67. package/dist/publish-source-scope.js.map +1 -0
  68. package/dist/publish.d.ts +5 -0
  69. package/dist/publish.d.ts.map +1 -1
  70. package/dist/publish.js +32 -21
  71. package/dist/publish.js.map +1 -1
  72. package/dist/qe-bridge.d.ts +4 -1
  73. package/dist/qe-bridge.d.ts.map +1 -1
  74. package/dist/qe-bridge.js +21 -11
  75. package/dist/qe-bridge.js.map +1 -1
  76. package/dist/rake-analyzer.d.ts +10 -3
  77. package/dist/rake-analyzer.d.ts.map +1 -1
  78. package/dist/rake-analyzer.js +84 -22
  79. package/dist/rake-analyzer.js.map +1 -1
  80. package/dist/recap.d.ts.map +1 -1
  81. package/dist/recap.js +8 -4
  82. package/dist/recap.js.map +1 -1
  83. package/dist/release.d.ts +2 -1
  84. package/dist/release.d.ts.map +1 -1
  85. package/dist/release.js +18 -4
  86. package/dist/release.js.map +1 -1
  87. package/dist/reqe.d.ts.map +1 -1
  88. package/dist/reqe.js +11 -1
  89. package/dist/reqe.js.map +1 -1
  90. package/dist/run-records.d.ts +18 -4
  91. package/dist/run-records.d.ts.map +1 -1
  92. package/dist/run-records.js +61 -7
  93. package/dist/run-records.js.map +1 -1
  94. package/dist/score.d.ts.map +1 -1
  95. package/dist/score.js +8 -1
  96. package/dist/score.js.map +1 -1
  97. package/dist/sign.d.ts +23 -0
  98. package/dist/sign.d.ts.map +1 -1
  99. package/dist/sign.js +52 -0
  100. package/dist/sign.js.map +1 -1
  101. package/dist/skills-verify.d.ts +8 -5
  102. package/dist/skills-verify.d.ts.map +1 -1
  103. package/dist/skills-verify.js +59 -7
  104. package/dist/skills-verify.js.map +1 -1
  105. package/dist/store-guard-prune.d.ts +22 -0
  106. package/dist/store-guard-prune.d.ts.map +1 -0
  107. package/dist/store-guard-prune.js +54 -0
  108. package/dist/store-guard-prune.js.map +1 -0
  109. package/dist/sweep-failure-classify.d.ts +45 -0
  110. package/dist/sweep-failure-classify.d.ts.map +1 -0
  111. package/dist/sweep-failure-classify.js +90 -0
  112. package/dist/sweep-failure-classify.js.map +1 -0
  113. package/dist/workflow-run-dispatch.d.ts +14 -2
  114. package/dist/workflow-run-dispatch.d.ts.map +1 -1
  115. package/dist/workflow-run-dispatch.js +14 -2
  116. package/dist/workflow-run-dispatch.js.map +1 -1
  117. package/package.json +1 -1
  118. package/sbom.json +372 -122
  119. package/src/agentdb-index.ts +5 -5
  120. package/src/amendment-trace.ts +4 -1
  121. package/src/backlog.ts +3 -2
  122. package/src/brain.ts +8 -2
  123. package/src/bto-optimize.ts +10 -8
  124. package/src/claim-check.ts +48 -12
  125. package/src/experiment-assign.ts +276 -0
  126. package/src/feature-adr-checkpoints.ts +20 -5
  127. package/src/feature-adr-envelope.ts +24 -1
  128. package/src/feature-adr-routing.ts +4 -0
  129. package/src/index.ts +17 -3
  130. package/src/loop-blobs.generated.ts +2 -2
  131. package/src/mutation-gate.ts +59 -0
  132. package/src/name-check.ts +43 -1
  133. package/src/no-stubs.ts +14 -5
  134. package/src/patterns.ts +49 -8
  135. package/src/publish-source-scope.ts +53 -0
  136. package/src/publish.ts +38 -16
  137. package/src/qe-bridge.ts +22 -11
  138. package/src/rake-analyzer.ts +75 -20
  139. package/src/rake-signatures.json +98 -0
  140. package/src/recap.ts +8 -4
  141. package/src/release.ts +18 -4
  142. package/src/reqe.ts +11 -1
  143. package/src/run-records.ts +78 -10
  144. package/src/score.ts +8 -1
  145. package/src/sign.ts +53 -0
  146. package/src/skills-verify.ts +69 -9
  147. package/src/store-guard-prune.ts +81 -0
  148. package/src/sweep-failure-classify.ts +88 -0
  149. package/src/workflow-run-dispatch.ts +14 -2
@@ -1168,9 +1168,9 @@ export async function importVectorsToAgentdb(
1168
1168
  }
1169
1169
 
1170
1170
  /**
1171
- * lesson-quarantine: clear the `qStatus` marker from mirrored rows after a promotion — the hook
1172
- * daemon reads ONLY this mirror's metadata, so a promoted lesson must stop being excluded there
1173
- * too. Best-effort, same custody model as {@link bumpAgentdbUses} (missing db/deps ⇒ no-op).
1171
+ * lesson-quarantine: mark mirrored rows as promoted after a promotion — the hook daemon reads ONLY
1172
+ * this mirror's metadata, so a promoted lesson must stop being excluded there while retaining its
1173
+ * quarantine history. Best-effort, same custody model as {@link bumpAgentdbUses} (missing db/deps ⇒ no-op).
1174
1174
  */
1175
1175
  export function clearAgentdbQuarantine(
1176
1176
  projectRoot: string,
@@ -1194,12 +1194,12 @@ export function clearAgentdbQuarantine(
1194
1194
  db.pragma('busy_timeout = 5000');
1195
1195
  db.exec(REASONING_BANK_SCHEMA);
1196
1196
  const stmt = db.prepare(
1197
- "UPDATE reasoning_patterns SET metadata = json_remove(metadata, '$.qStatus', '$.quarantinedAt') WHERE json_extract(metadata, '$.dzId') = ? AND json_extract(metadata, '$.qStatus') = 'quarantined'",
1197
+ "UPDATE reasoning_patterns SET metadata = json_set(metadata, '$.qStatus', 'promoted', '$.promotedAt', ?) WHERE json_extract(metadata, '$.dzId') = ? AND json_extract(metadata, '$.qStatus') = 'quarantined'",
1198
1198
  );
1199
1199
  const tx = db.transaction(() => {
1200
1200
  let cleared = 0;
1201
1201
  for (const dzId of dzIds) {
1202
- const r = stmt.run(dzId);
1202
+ const r = stmt.run(new Date().toISOString(), dzId);
1203
1203
  cleared += Number((r as unknown as { changes?: number }).changes ?? 0);
1204
1204
  }
1205
1205
  return cleared;
@@ -138,7 +138,10 @@ export function extractTestTitles(body: string): string[] {
138
138
  }
139
139
 
140
140
  export function normalizeTestId(s: string): string {
141
- return s.toLowerCase().replace(/[^a-z0-9]+/g, '');
141
+ // Unicode letter/number classes: the old `[^a-z0-9]` erased Cyrillic outright, so a Russian test title
142
+ // normalised to '' and tripped the floor as the author's fault (MEASURED 2026-09-04, backlog 191853a2).
143
+ // Re-run over all 519 features on 2026-09-20: zero verdicts changed — this only adds matches.
144
+ return s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
142
145
  }
143
146
 
144
147
  /**
package/src/backlog.ts CHANGED
@@ -266,7 +266,7 @@ export function readBacklogConfig(projectRoot: string): BacklogConfig {
266
266
  }
267
267
 
268
268
  /* ================================================================== */
269
- /* STORE (AM-1) — .dz/backlog/ideas.jsonl (append-only JSONL, ADR-005) */
269
+ /* STORE (AM-1) — .dz/backlog/ideas.jsonl (JSONL rewritten WHOLE per write, ADR-005 + amendment 2026-09-20) */
270
270
  /* ================================================================== */
271
271
 
272
272
  export function backlogDir(projectRoot: string): string {
@@ -335,7 +335,8 @@ function normaliseIdea(raw: Record<string, unknown>): IdeaRecord | undefined {
335
335
  return rec;
336
336
  }
337
337
 
338
- /** Read the append-only store. A corrupt line is SKIPPED (never fatal) — the whole store never throws. */
338
+ /** Read the store (one current line per id — `writeIdeas` rewrites it whole and never appends). A corrupt
339
+ * line is SKIPPED (never fatal) — the whole store never throws. */
339
340
  export function readIdeas(projectRoot: string): IdeaRecord[] {
340
341
  const path = ideasPath(projectRoot);
341
342
  if (!existsSync(path)) return [];
package/src/brain.ts CHANGED
@@ -21,6 +21,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdir
21
21
  import { pathToFileURL } from 'node:url';
22
22
  import { createRequire } from 'node:module';
23
23
  import { spawn } from 'node:child_process';
24
+ import { openSqliteReadOnly } from '@dzhechkov/memory';
24
25
  import { putBookKnowledge, queryBookKnowledge, bookKbPath, type BookKU, type BookKUHit } from './book-kb.js';
25
26
  import { indexPatternsToAgentdb, searchAgentdbPatterns, reindexAgentdbRows, type AgentdbRow } from './agentdb-index.js';
26
27
  import type { SnapshotRotationReport } from './agentdb-snapshot-rotation.js';
@@ -193,7 +194,8 @@ export function readBookKus(opts: {
193
194
  return { kus: [], error: 'better-sqlite3 not installed (run: dz setup --memory agentdb)' };
194
195
  }
195
196
  try {
196
- const db = new Database(opts.storePath, { readonly: true });
197
+ const handle = openSqliteReadOnly(opts.storePath, { Database });
198
+ const db = handle.db as NativeDb;
197
199
  try {
198
200
  const cols = 'book, ku_id, corpus_version, type, name, problem, content, chapter, pages, metadata';
199
201
  const rows = (opts.source !== undefined
@@ -201,7 +203,11 @@ export function readBookKus(opts: {
201
203
  : db.prepare(`SELECT ${cols} FROM book_knowledge`).all()) as ProjRow[];
202
204
  return { kus: rows.map(rowToKu) };
203
205
  } finally {
204
- db.close();
206
+ try {
207
+ db.close();
208
+ } finally {
209
+ handle.cleanup();
210
+ }
205
211
  }
206
212
  } catch (err) {
207
213
  return { kus: [], error: `read book KB failed: ${err instanceof Error ? err.message : String(err)}` };
@@ -20,6 +20,8 @@
20
20
 
21
21
  import { existsSync, readFileSync } from 'node:fs';
22
22
 
23
+ import { maskMarkdown } from './markdown-masker.js';
24
+
23
25
  export type BtoDimension = 'METHODOLOGY' | 'DEPTH' | 'CORRECTNESS' | 'USABILITY' | 'ROBUSTNESS';
24
26
  export const BTO_DIMENSIONS: readonly BtoDimension[] = Object.freeze(['METHODOLOGY', 'DEPTH', 'CORRECTNESS', 'USABILITY', 'ROBUSTNESS']);
25
27
  export type DimScores = Record<BtoDimension, number>;
@@ -203,22 +205,22 @@ const bodyAfterFrontmatter = (t: string): string => {
203
205
 
204
206
  /**
205
207
  * ALL structural markers on the BODY, not just space-delimited ATX (QE: `##\tNEW`, bare `##`, and setext
206
- * `Title\n===` / `Title\n---` evaded the old regex). Fenced code blocks (``` / ~~~) are SKIPPED so a `===`/`---`
207
- * line INSIDE code is not misread as a heading (QE false-positive). Collect ATX (`#`..`######` + any/no ws)
208
- * AND setext underlines (a non-empty line immediately followed by `=+`/`-+`).
208
+ * `Title\n===` / `Title\n---` evaded the old regex). The canonical masker skips fenced code blocks before
209
+ * collecting ATX (`#`..`######` + any/no ws) and setext underlines (a non-empty line immediately followed
210
+ * by `=+`/`-+`). Its `unclosed: 'restore'` policy is load-bearing: measured over 7,121 headed Markdown
211
+ * documents, the old parity toggle missed a deletion in 39 documents, masking with `hide` missed 22, and
212
+ * masking with `restore` missed 0.
209
213
  */
210
214
  const headings = (t: string): string[] => {
211
- const lines = bodyAfterFrontmatter(t).split('\n');
215
+ const body = bodyAfterFrontmatter(t);
216
+ const lines = maskMarkdown(body, { unclosed: 'restore' }).split('\n');
212
217
  const out: string[] = [];
213
- let inFence = false;
214
218
  for (let i = 0; i < lines.length; i++) {
215
219
  const line = lines[i]!;
216
- if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; }
217
- if (inFence) continue;
218
220
  const atx = /^(#{1,6})(?:\s.*)?$/.exec(line.replace(/\s+$/, ''));
219
221
  if (atx) { out.push('ATX:' + line.trim()); continue; }
220
222
  const next = lines[i + 1];
221
- if (line.trim() !== '' && !/^\s*(```|~~~)/.test(next ?? '') && next !== undefined && /^(=+|-+)\s*$/.test(next)) {
223
+ if (line.trim() !== '' && next !== undefined && /^(=+|-+)\s*$/.test(next)) {
222
224
  out.push('SETEXT:' + line.trim() + '|' + next.trim());
223
225
  }
224
226
  }
@@ -122,6 +122,8 @@ function paragraphAround(lines: readonly string[], i: number): string {
122
122
  /** Tags that make a claim honest (case-insensitive). */
123
123
  // `estimated` agrees with the `estimated: true` honest-uncertainty marker `dz usage` already
124
124
  // emits — the two honesty systems must not contradict each other.
125
+ import { maskMarkdown } from './markdown-masker.js';
126
+
125
127
  const HONEST_TAGS = ['measured', 'claimed', 'synthetic', 'unvalidated', 'baseline', 'estimated'];
126
128
 
127
129
  /**
@@ -136,17 +138,27 @@ const HONEST_TAGS = ['measured', 'claimed', 'synthetic', 'unvalidated', 'baselin
136
138
  */
137
139
  export function isFenced(text: string, line: number): boolean {
138
140
  if (typeof text !== 'string' || typeof line !== 'number' || !isFinite(line) || line < 1) return false;
139
- const lines = text.split(/\r?\n/);
140
- const upTo = Math.min(line - 1, lines.length);
141
- let open: '`' | '~' | null = null;
142
- for (let i = 0; i < upTo; i++) {
143
- const m = FENCE_RE.exec(lines[i] || '');
144
- if (!m) continue;
145
- const marker = m[1]![0] as '`' | '~';
146
- if (open === null) open = marker;
147
- else if (open === marker) open = null;
148
- }
149
- return open !== null;
141
+ // DELEGATED to the canonical masker. The local walk this replaces already tracked the marker
142
+ // CHARACTER — the naive-toggle bug the comment above describes was genuinely fixed — but it still
143
+ // broke two further CommonMark rules, and both were MEASURED 2026-09-20 to answer `false` for a
144
+ // line that IS inside a block: a closing fence may carry NO info string, so a second info-string
145
+ // line closed the block; and a closing fence may not be SHORTER than the opening one, so a
146
+ // three-backtick line closed a four-backtick block. Both make the engine scan a QUOTED example as
147
+ // a real claim, and make the hook's deny path stop exempting it.
148
+ //
149
+ // `unclosed: 'hide'` keeps the local walk's policy: an unclosed opener leaves every later line
150
+ // inside the block. One deliberate difference: the OPENING delimiter line now answers `true` (the
151
+ // local walk answered `false` for it and `true` for the closing one) — an info string is not
152
+ // prose, and the two delimiters answering differently was an artifact, not a decision.
153
+ let masked = false;
154
+ try {
155
+ const target = line - 1;
156
+ maskMarkdown(text.replace(/\r\n/g, '\n'), {
157
+ unclosed: 'hide',
158
+ onMasked: (i?: number) => { if (i === target) masked = true; },
159
+ });
160
+ } catch { return false; }
161
+ return masked;
150
162
  }
151
163
 
152
164
  const FENCE_RE = /^\s*(`{3,}|~{3,})/;
@@ -189,9 +201,33 @@ const MAP_METRIC_RE = new RegExp(
189
201
  * A shell reproducer is STRUCTURAL, never a word. `(MEASURED — reproducer)` is self-certifying and
190
202
  * must not pass; a backticked span whose first token is a command this repo actually measures with is
191
203
  * evidence. The allowlist boundary is exactly that: an unknown binary is a claim ABOUT evidence.
204
+ *
205
+ * `python3` joined the list for backlog 0b53470a, on the list's OWN criterion rather than by
206
+ * widening it: `packages/@dzhechkov/health-advisor/test/goap-python-suite.test.js` makes
207
+ * `python3 -m unittest discover` a GATE, so it is a command this repo measures with. Before the
208
+ * addition a Python measurer could not cite itself — MEASURED by twins, one line differing only in
209
+ * the backticked binary: `python3 …` → 1 finding "Tagged MEASURED but cites no reproducer",
210
+ * `pytest …` → 0, `git show …` → 0. Bare `python` was deliberately NOT added (cross-family review
211
+ * r1, MEDIUM): the gate this repo runs is `python3`, and the only measured usage in the tree is
212
+ * `python3 -m unittest` — an entry nothing measures with would be exactly the "claim ABOUT
213
+ * evidence" this list refuses. The list stays CLOSED: a measurer in any other language meets the
214
+ * same wall and needs the same deliberate entry. That is the price of the boundary, not a defect.
215
+ *
216
+ * THE BOUNDARY IS `(?![\w-])`, NOT `\b`, and that turned out to matter far beyond python
217
+ * (cross-family review r1, HIGH). `\b` treats a hyphen as a word boundary, so every backticked
218
+ * FILE NAME that begins with a listed command counted as a reproducer. MEASURED 2026-09-19:
219
+ * `pnpm-lock.yaml`, `dz-harness-hub`, `git-workflow` and `npm-shrinkwrap.json` all passed as
220
+ * evidence for a MEASURED claim, while `node_modules` was correctly refused — only because `_` is
221
+ * a word character and `-` is not. The repository holds hundreds of such tokens, so the check has
222
+ * been accepting file names as measurements for as long as the list has existed.
223
+ *
224
+ * WHAT THIS CHECK DOES NOT DO, said plainly because the previous wording implied more: it verifies
225
+ * the SHAPE of a citation, never that a measurement happened. `git --version` in backticks is
226
+ * accepted by construction — validating arguments per binary is a different mechanism with a
227
+ * different cost, and pretending otherwise would be the very laundering this file exists to stop.
192
228
  */
193
229
  const SHELL_REPRO_RE =
194
- /`\s*\$?\s*(?:ps|stat|lsof|time|git|npm|npx|node|pnpm|yarn|dz|curl|wc|grep|find|cargo|make|docker|kubectl|awk|sed|du|df|vitest|pytest)\b[^`]*`/i;
230
+ /`\s*\$?\s*(?:ps|stat|lsof|time|git|npm|npx|node|pnpm|yarn|dz|curl|wc|grep|find|cargo|make|docker|kubectl|awk|sed|du|df|vitest|pytest|python3)(?![\w-])[^`]*`/i;
195
231
 
196
232
  /** Reproducer references that count as evidence backing a MEASURED claim. */
197
233
  const REPRODUCER_HINTS = [
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Deterministic, blocked arm assignment for a prospective ablation (ADR-001, ablation-c-start).
3
+ *
4
+ * WHY. The owner's question — does the Step-8 QE mode change task speed — is only answerable
5
+ * causally if the variant a task receives is decided by chance, BEFORE the work starts, and tied to
6
+ * the task's own identity so a repeated query for the same task never reassigns it. This module is
7
+ * that decision: a PURE function of `(experiment, stratum, seed, index)`. No filesystem, no clock,
8
+ * no network — the caller (the cli) owns the journal, the lock, and the "did work already start?"
9
+ * check; this module only computes.
10
+ *
11
+ * ALGORITHM — block randomization, one pair per arm-combination per block. `mulberry32` is the
12
+ * repo's ONLY pseudo-random generator (`compounding.ts` — "no second RNG in this repo"); reused
13
+ * here rather than adding a second stream. Block size is `arms.length * 2` — two occurrences of
14
+ * every arm per block, so a completed block is always perfectly balanced and `propensity` (the
15
+ * arm's actual share of its block) is a fixed `1 / arms.length` — never guessed, never assumed
16
+ * 0.5 by default (NFR-4). The block's own permutation is seeded from
17
+ * `fnv1a(JSON.stringify([experiment, stratum, seed, block]))` (a JSON-tuple, not a delimiter join —
18
+ * the same reason `workOrderDigestInput` in epoch-replay.ts uses tuples: a delimiter in `experiment`
19
+ * or `stratum` could otherwise make two different keys hash identically) — so different strata (and
20
+ * different experiments) never share a stream, and the SAME `(experiment, stratum, seed, index)`
21
+ * always rederives the SAME arm (A1 idempotency, A2 block balance, A3 propensity).
22
+ */
23
+
24
+ import { fnv1a } from './feature-adr-checkpoints.js';
25
+ import { mulberry32 } from './compounding.js';
26
+
27
+ export interface AssignArmInput {
28
+ readonly experiment: string;
29
+ readonly stratum: string;
30
+ /** Pre-registered seed — fixed once, before any assignment is made. */
31
+ readonly seed: number;
32
+ /** 0-based ordinal of this task within its `(experiment, stratum)` sequence. */
33
+ readonly index: number;
34
+ /** The arms on offer, e.g. `['direct', 'reference']`. At least 2, all non-empty, all unique. */
35
+ readonly arms: readonly string[];
36
+ }
37
+
38
+ export type AssignArmResult =
39
+ | { readonly ok: true; readonly arm: string; readonly propensity: number; readonly block: number; readonly position: number }
40
+ | { readonly ok: false; readonly reason: string };
41
+
42
+ function isNonEmptyString(v: unknown): v is string {
43
+ return typeof v === 'string' && v.trim() !== '';
44
+ }
45
+
46
+ /** Fisher-Yates, driven by the given deterministic RNG. Does not mutate its input. */
47
+ function shuffle<T>(items: readonly T[], rand: () => number): T[] {
48
+ const out = items.slice();
49
+ for (let i = out.length - 1; i > 0; i--) {
50
+ const j = Math.floor(rand() * (i + 1));
51
+ const tmp = out[i]!;
52
+ out[i] = out[j]!;
53
+ out[j] = tmp;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Deterministically assign one arm to task ordinal `index` within `(experiment, stratum)`. Refuses
60
+ * (never throws, never guesses) on any malformed input — empty strings, a non-integer seed/index, a
61
+ * negative index, fewer than two arms, duplicate or blank arm names.
62
+ */
63
+ export function assignArm(input: AssignArmInput): AssignArmResult {
64
+ if (!isNonEmptyString(input?.experiment)) return { ok: false, reason: 'experiment: expected a non-empty string' };
65
+ if (!isNonEmptyString(input?.stratum)) return { ok: false, reason: 'stratum: expected a non-empty string' };
66
+ if (!Number.isInteger(input?.seed) || input.seed < 0) return { ok: false, reason: 'seed: expected a non-negative integer' };
67
+ if (!Number.isInteger(input?.index) || input.index < 0) return { ok: false, reason: 'index: expected a non-negative integer' };
68
+ if (!Array.isArray(input?.arms) || input.arms.length < 2) {
69
+ return { ok: false, reason: 'arms: expected an array of at least 2 arm names' };
70
+ }
71
+ if (!input.arms.every(isNonEmptyString)) return { ok: false, reason: 'arms: every arm name must be a non-empty string' };
72
+ const seen = new Set<string>();
73
+ for (const a of input.arms) {
74
+ if (seen.has(a)) return { ok: false, reason: `arms: duplicate arm name ${JSON.stringify(a)}` };
75
+ seen.add(a);
76
+ }
77
+
78
+ const blockSize = input.arms.length * 2; // two copies of every arm — always a perfect block
79
+ const block = Math.floor(input.index / input.arms.length / 2); // == Math.floor(index / blockSize)
80
+ const position = input.index % blockSize;
81
+
82
+ const key = JSON.stringify({ experiment: input.experiment, stratum: input.stratum, seed: input.seed, block });
83
+ const rand = mulberry32(parseInt(fnv1a(key), 16) >>> 0);
84
+ // Base card set for this block: every arm twice, in the input's own order — then permuted by the
85
+ // block-seeded stream, so the CONTENT of every block is fixed by construction (perfect balance)
86
+ // and only the ORDER is randomized.
87
+ const cards: string[] = [];
88
+ for (const a of input.arms) { cards.push(a); cards.push(a); }
89
+ const permuted = shuffle(cards, rand);
90
+ const arm = permuted[position]!;
91
+ const propensity = 2 / blockSize; // == 1 / arms.length — the arm's fixed, actual share of its block
92
+
93
+ return { ok: true, arm, propensity, block, position };
94
+ }
95
+
96
+ export interface AssignmentRecord {
97
+ readonly ts: string;
98
+ readonly experiment: string;
99
+ readonly taskId: string;
100
+ readonly stratum: string;
101
+ readonly seed: number;
102
+ readonly index: number;
103
+ readonly block: number;
104
+ readonly position: number;
105
+ readonly arm: string;
106
+ readonly propensity: number;
107
+ readonly assignedAt: string;
108
+ }
109
+
110
+ export interface ReadAssignmentsResult {
111
+ readonly records: readonly AssignmentRecord[];
112
+ /** Non-blank lines that did not parse as a valid assignment record. Never silently dropped: a
113
+ * caller checking "is this task assigned?" must be able to tell "no" from "the journal is
114
+ * unreadable here" (A5). */
115
+ readonly malformedLines: number;
116
+ }
117
+
118
+ /** fix-round-1 (Codex r1 HIGH #7): `new Date().toISOString()` is the ONLY producer of `ts`/
119
+ * `assignedAt` (see the cli's `experimentJournalPath` writer) — a record whose timestamp does not
120
+ * match that exact shape did not come from this code path, so it is corruption, not merely an
121
+ * unusual value. */
122
+ const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
123
+
124
+ function isValidIsoTimestamp(v: unknown): v is string {
125
+ // Lead delta after Codex r2 (MEDIUM): shape + `Date.parse` accepts impossible calendar dates —
126
+ // `2026-02-30T00:00:00.000Z` parses (it rolls into March) and passed. Round-tripping through
127
+ // `toISOString()` is the exact check: only a real instant re-serializes to the same string.
128
+ if (typeof v !== 'string' || !ISO_TIMESTAMP_RE.test(v)) return false;
129
+ const ms = Date.parse(v);
130
+ return Number.isFinite(ms) && new Date(ms).toISOString() === v;
131
+ }
132
+
133
+ /**
134
+ * PURELY STRUCTURAL: every field is present and the right TYPE. This is `readAssignments`'s gate —
135
+ * it decides whether a journal LINE parses at all (A5's `malformedLines`). It deliberately does NOT
136
+ * validate timestamp FORMAT, propensity RANGE, or arm MEMBERSHIP: a line that is well-typed but
137
+ * semantically wrong (a hand-edited `position:99`, `propensity:2`, a flipped `arm`) is exactly the
138
+ * "syntactically valid corruption" `verifyAssignmentRecord` below exists to catch — folding those
139
+ * checks in here would make a single semantically-tampered record poison `readAssignments` for
140
+ * every caller, including ones (like `status`'s per-task duration math) that need to name WHICH
141
+ * task is bad rather than refuse the whole read (fix-round-1 HIGH #7/#8 — see `verifyAssignmentRecord`).
142
+ */
143
+ function isValidAssignmentRecord(v: unknown): v is AssignmentRecord {
144
+ if (typeof v !== 'object' || v === null) return false;
145
+ const r = v as Record<string, unknown>;
146
+ return (
147
+ isNonEmptyString(r['ts']) &&
148
+ isNonEmptyString(r['experiment']) &&
149
+ isNonEmptyString(r['taskId']) &&
150
+ isNonEmptyString(r['stratum']) &&
151
+ Number.isInteger(r['seed']) && (r['seed'] as number) >= 0 &&
152
+ Number.isInteger(r['index']) && (r['index'] as number) >= 0 &&
153
+ Number.isInteger(r['block']) && (r['block'] as number) >= 0 &&
154
+ Number.isInteger(r['position']) && (r['position'] as number) >= 0 &&
155
+ isNonEmptyString(r['arm']) &&
156
+ typeof r['propensity'] === 'number' && Number.isFinite(r['propensity'] as number) &&
157
+ isNonEmptyString(r['assignedAt'])
158
+ );
159
+ }
160
+
161
+ export type VerifyAssignmentResult =
162
+ | { readonly ok: true }
163
+ | {
164
+ readonly ok: false;
165
+ readonly reason: string;
166
+ /** Lead delta after Codex r2: `null` when the record failed on its SEED — there is nothing to
167
+ * expect, because the tuple it would be derived from is itself the thing under suspicion. */
168
+ readonly expected: { readonly arm: string; readonly block: number; readonly position: number; readonly propensity: number } | null;
169
+ readonly actual: { readonly arm: string; readonly block: number; readonly position: number; readonly propensity: number };
170
+ };
171
+
172
+ /**
173
+ * fix-round-1 (Codex r1 HIGH #7) — "syntactically valid journal corruption is accepted and returned
174
+ * as the task's assignment". A record that passes `isValidAssignmentRecord`'s SHAPE checks can still
175
+ * have been hand-edited to a different arm/block/position/propensity while keeping every field the
176
+ * right TYPE (e.g. flipping `arm:"direct"` to `arm:"reference"`, or `position:99`). The only way to
177
+ * catch that is to REDERIVE the assignment from the record's own tuple
178
+ * `(experiment, stratum, seed, index)` via the SAME pure `assignArm` that produced it, and compare —
179
+ * never trust the stored arm/block/position/propensity on their own. `arms` is the experiment's own
180
+ * registered arm set (from its `dz experiment init` config), passed in by the caller — this module
181
+ * stays pure and fs-free.
182
+ */
183
+ export function verifyAssignmentRecord(record: AssignmentRecord, arms: readonly string[], pinnedSeed?: number): VerifyAssignmentResult {
184
+ // Lead delta after Codex r2 (BLOCKER): rederiving from `record.seed` proves the record is
185
+ // internally CONSISTENT, never that it is AUTHENTIC — a row carrying any seed at all passes,
186
+ // because the check compares the row with itself. The experiment's pinned seed (from its
187
+ // `dz experiment init` config, the one value fixed before the first assignment) is the trusted
188
+ // side of the comparison; a row that disagrees with it is tampered, however well-formed.
189
+ if (pinnedSeed !== undefined && record.seed !== pinnedSeed) {
190
+ return {
191
+ ok: false,
192
+ reason: `assignment-tampered: task ${JSON.stringify(record.taskId)} carries seed ${record.seed}, but experiment ${JSON.stringify(record.experiment)} is pinned to seed ${pinnedSeed} — a record's own seed can never authenticate it`,
193
+ expected: null,
194
+ actual: { arm: record.arm, block: record.block, position: record.position, propensity: record.propensity },
195
+ };
196
+ }
197
+ const recomputed = assignArm({ experiment: record.experiment, stratum: record.stratum, seed: record.seed, index: record.index, arms });
198
+ const actual = { arm: record.arm, block: record.block, position: record.position, propensity: record.propensity };
199
+ const expected = recomputed.ok
200
+ ? { arm: recomputed.arm, block: recomputed.block, position: recomputed.position, propensity: recomputed.propensity }
201
+ : { arm: '(tuple refused)', block: -1, position: -1, propensity: -1 };
202
+
203
+ if (!recomputed.ok) {
204
+ return {
205
+ ok: false,
206
+ reason: `assignment-tampered: task ${JSON.stringify(record.taskId)}'s stored tuple (experiment=${JSON.stringify(record.experiment)}, stratum=${JSON.stringify(record.stratum)}, seed=${record.seed}, index=${record.index}) no longer rederives a valid assignment (${recomputed.reason}) — the stored record does not match what assignArm would produce from its own tuple`,
207
+ expected,
208
+ actual,
209
+ };
210
+ }
211
+ if (expected.arm !== actual.arm || expected.block !== actual.block || expected.position !== actual.position || expected.propensity !== actual.propensity) {
212
+ return {
213
+ ok: false,
214
+ reason: `assignment-tampered: task ${JSON.stringify(record.taskId)}'s stored assignment does not match what assignArm rederives from its own tuple (experiment=${JSON.stringify(record.experiment)}, stratum=${JSON.stringify(record.stratum)}, seed=${record.seed}, index=${record.index}) — the journal line was modified after it was written`,
215
+ expected,
216
+ actual,
217
+ };
218
+ }
219
+ // fix-round-1 HIGH #7 (named explicitly in the brief, beyond what recompute-and-compare already
220
+ // implies): timestamp FORMAT, propensity RANGE, and arm MEMBERSHIP. Recompute already makes a
221
+ // WRONG arm/propensity/block/position fail above; these three checks catch what recompute cannot
222
+ // — `assignArm` never produces a `ts`/`assignedAt` at all (they are wall-clock, not derived from
223
+ // the tuple), so a hand-edited "not-a-date" needs its own check.
224
+ if (!isValidIsoTimestamp(record.ts) || !isValidIsoTimestamp(record.assignedAt)) {
225
+ return {
226
+ ok: false,
227
+ reason: `assignment-tampered: task ${JSON.stringify(record.taskId)}'s ts/assignedAt is not a valid ISO-8601 UTC timestamp (ts=${JSON.stringify(record.ts)}, assignedAt=${JSON.stringify(record.assignedAt)})`,
228
+ expected,
229
+ actual,
230
+ };
231
+ }
232
+ if (!(record.propensity >= 0 && record.propensity <= 1)) {
233
+ return {
234
+ ok: false,
235
+ reason: `assignment-tampered: task ${JSON.stringify(record.taskId)}'s propensity ${record.propensity} is outside the valid [0,1] range`,
236
+ expected,
237
+ actual,
238
+ };
239
+ }
240
+ if (!arms.includes(record.arm)) {
241
+ return {
242
+ ok: false,
243
+ reason: `assignment-tampered: task ${JSON.stringify(record.taskId)}'s arm ${JSON.stringify(record.arm)} is not one of this experiment's registered arms (${arms.join(', ')})`,
244
+ expected,
245
+ actual,
246
+ };
247
+ }
248
+ return { ok: true };
249
+ }
250
+
251
+ /**
252
+ * Parse a JSONL assignments journal (one record per line) into records, WITHOUT touching the
253
+ * filesystem — the caller reads the file, this only parses its text (NFR-1: the core stays
254
+ * `node:fs`-free). A line that is blank is skipped silently (append-only files end in a trailing
255
+ * newline); a line that is non-blank but does not parse as JSON, or parses to something missing a
256
+ * required field, counts toward `malformedLines` and is otherwise ignored — corruption is named, not
257
+ * folded into "not assigned" (A5).
258
+ */
259
+ export function readAssignments(text: string): ReadAssignmentsResult {
260
+ const records: AssignmentRecord[] = [];
261
+ let malformedLines = 0;
262
+ for (const raw of String(text ?? '').split('\n')) {
263
+ const line = raw.trim();
264
+ if (line === '') continue;
265
+ let parsed: unknown;
266
+ try {
267
+ parsed = JSON.parse(line);
268
+ } catch {
269
+ malformedLines++;
270
+ continue;
271
+ }
272
+ if (isValidAssignmentRecord(parsed)) records.push(parsed);
273
+ else malformedLines++;
274
+ }
275
+ return { records, malformedLines };
276
+ }
@@ -267,11 +267,11 @@ export function decideCheckpointResume(opts: {
267
267
  /** Serialize one checkpoint line, or null when the result is null/oversize/unserializable —
268
268
  * the caller logs the skip loudly; a missing checkpoint only costs a re-run, never corrupts.
269
269
  * A null result is never persisted (Codex QE #8: it would later parse as a resumable entry). */
270
- export function serializeCheckpoint(stage: string, inputHash: string, result: unknown): string | null {
270
+ export function serializeCheckpoint(stage: string, inputHash: string, result: unknown, ts?: string): string | null {
271
271
  if (result === null || result === undefined) return null;
272
272
  let line: string;
273
273
  try {
274
- line = JSON.stringify({ stage, inputHash, result });
274
+ line = JSON.stringify({ stage, inputHash, result, ...(ts === undefined ? {} : { ts }) });
275
275
  } catch {
276
276
  return null;
277
277
  }
@@ -289,6 +289,9 @@ export interface ParsedCheckpointRead {
289
289
  * command's stdout. */
290
290
  export const CHECKPOINT_LS_SENTINEL = '---FA-CKPT-LS---';
291
291
 
292
+ /** A writer-produced UTC instant. A malformed supplied value is unmeasured, not a bad record. */
293
+ const CHECKPOINT_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
294
+
292
295
  /** Parse the read-back agent's stdout: JSONL entries (LAST occurrence of a stage wins — a re-run
293
296
  * overwrites by append), then the sentinel ON ITS OWN LINE, then one artifact path per line
294
297
  * (relative to the feature dir). The sentinel match is LINE-ANCHORED: JSON.stringify never emits
@@ -308,7 +311,11 @@ export function parseCheckpointRead(text: string): ParsedCheckpointRead {
308
311
  try {
309
312
  const e = JSON.parse(t) as CheckpointEntry;
310
313
  if (e && typeof e === 'object' && typeof e.stage === 'string' && typeof e.inputHash === 'string' && 'result' in e && e.result !== null && e.result !== undefined) {
311
- out.entries[e.stage] = e;
314
+ const { ts, ...entry } = e;
315
+ // Narrow to `string` before the spread: under exactOptionalPropertyTypes an optional
316
+ // `ts?: string` refuses `string | undefined`, and an unmeasured record must carry NO key
317
+ // rather than an explicit undefined.
318
+ out.entries[e.stage] = typeof ts === 'string' && CHECKPOINT_TIMESTAMP.test(ts) ? { ...entry, ts } : entry;
312
319
  } else {
313
320
  // last-wins holds for BAD records too: a stage-identifiable null/invalid record ERASES the
314
321
  // older entry for that stage instead of silently reactivating it (Codex QE r2 #6).
@@ -345,11 +352,19 @@ export function checkpointReadCmd(fdirAbs: string): string {
345
352
 
346
353
  /** The one Bash command the write agent runs: mkdir the state dir, then append ONE line. The line
347
354
  * is single-quote-escaped as a whole — JSON.stringify output never contains literal newlines, so
348
- * printf '%s\n' emits exactly one record. */
355
+ * printf '%s\n' emits exactly one record.
356
+ *
357
+ * The record's `ts` is stamped SHELL-SIDE (`date -u`), the same idiom the decision-recall ledger
358
+ * uses, because the workflow sandbox forbids `Date` and this function must stay pure. A line that
359
+ * is not a JSON object falls back to the plain append: an unstamped record beats a lost one, and
360
+ * the return type stays `string` so no caller grows a new failure path. */
349
361
  export function checkpointAppendCmd(fdirAbs: string, line: string): string {
350
362
  const dir = shellQuote(fdirAbs + '/.fa-state');
351
363
  const file = shellQuote(fdirAbs + '/.fa-state/checkpoints.jsonl');
352
- return 'mkdir -p ' + dir + " && printf '%s\\n' " + shellQuote(line) + ' >> ' + file;
364
+ const plain = 'mkdir -p ' + dir + " && printf '%s\\n' " + shellQuote(line) + ' >> ' + file;
365
+ if (typeof line !== 'string' || !line.startsWith('{')) return plain;
366
+ return 'ts=$(date -u +%Y-%m-%dT%H:%M:%SZ); mkdir -p ' + dir
367
+ + ' && { printf \'{"ts":"%s",\' "$ts"; printf \'%s\\n\' ' + shellQuote(line.slice(1)) + '; } >> ' + file;
353
368
  }
354
369
 
355
370
  // ─────────────────────────────────────────────────────────────────────────────
@@ -34,6 +34,18 @@ export interface ExperimentEnvelopeChosen {
34
34
  * (usage-override, session-inherited fallback) is recorded HERE explicitly — arms stay the set that
35
35
  * was actually offered; the winner is never appended to them after the fact. */
36
36
  readonly overrides?: Readonly<Record<string, string>>;
37
+ /**
38
+ * ablation-c-start (ADR-001, T3; fix-round-1 BLOCKER #2): the pre-registered ablation-C arm
39
+ * (`direct` | `reference`) for THIS run, filled only when `args.experiment`/`args.taskId` were
40
+ * given AND the workflow successfully RESOLVED an existing assignment for that task via
41
+ * `dz experiment resolve` — never taken from a caller-supplied arm option (that was the BLOCKER
42
+ * fix-round-1 found: a caller could set the arm to anything, with zero journal entry). Deliberately
43
+ * a SEPARATE field from `mode` — `mode` already means "same-family vs cross-family reviewer" (a
44
+ * different axis) — so setting `qeMode` never redefines what `mode` has always meant. Absent (not
45
+ * `null`) when no arm was resolved, so `JSON.stringify` drops the key and an unflagged run's
46
+ * envelope stays byte-identical to before this feature (NFR-2).
47
+ */
48
+ readonly qeMode?: string;
37
49
  }
38
50
 
39
51
  export interface ExperimentEnvelopePolicy {
@@ -104,7 +116,12 @@ export function buildExperimentEnvelope(input: BuildExperimentEnvelopeInput): Ex
104
116
  treeSha: input.treeSha,
105
117
  treeShaReason: input.treeSha === null ? (input.treeShaReason ?? 'unavailable') : null,
106
118
  arms: { mode: [...input.arms.mode], stages: { ...input.arms.stages } },
107
- chosen: { mode: input.chosen.mode, stages: { ...input.chosen.stages }, overrides: { ...(input.chosen.overrides ?? {}) } },
119
+ chosen: {
120
+ mode: input.chosen.mode,
121
+ stages: { ...input.chosen.stages },
122
+ overrides: { ...(input.chosen.overrides ?? {}) },
123
+ ...(input.chosen.qeMode !== undefined ? { qeMode: input.chosen.qeMode } : {}),
124
+ },
108
125
  policy: { ...input.policy },
109
126
  evaluator: { ...input.evaluator },
110
127
  };
@@ -217,6 +234,12 @@ export function validateExperimentEnvelope(value: unknown): { ok: true } | { ok:
217
234
  return { ok: false, reason: `chosen.stages.${stage}: "${String(spec)}" is not a member of arms.stages.${stage} (${offered.join('|')}) and not declared in chosen.overrides` };
218
235
  }
219
236
  }
237
+ // ablation-c-start (ADR-001, T3): qeMode is OPTIONAL — absent on every run this feature does not
238
+ // touch — but when present must be a non-empty string, same shape rule as every other envelope
239
+ // field (never a silently-accepted empty label).
240
+ if (chosen.qeMode !== undefined && !isNonEmptyString(chosen.qeMode)) {
241
+ return { ok: false, reason: 'chosen.qeMode: expected a non-empty string when present' };
242
+ }
220
243
 
221
244
  if (!isPlainObject(v.policy)) return { ok: false, reason: 'policy: expected an object' };
222
245
  const policy = v.policy;
@@ -1675,6 +1675,10 @@ export function codexDispatchMode(stage: string): CodexDispatch {
1675
1675
  * SCOPE — `codexReviewCommand` (the diff defines it) and `scopedQePrompt` ("read ONLY these files").
1676
1676
  * This constant is retained as a sanity bound on an absurd payload, and is no longer claimed as the
1677
1677
  * thing that prevents a stall.
1678
+ *
1679
+ * THIS IS THE ONLY DEFINITION. `workflow-run-dispatch.ts` re-exports it rather than declaring its
1680
+ * own twin (it used to, and nothing compared the two). The direction is forced: this module is
1681
+ * lifted verbatim into the workflow sandbox by `scripts/gen-loop-blobs.mjs`, so it may not import.
1678
1682
  */
1679
1683
  export const CODEX_EXEC_PROMPT_CEILING_CHARS = 24_000;
1680
1684