@dzhechkov/harness-core 0.5.0 → 0.5.2

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 (96) hide show
  1. package/.dz-manifest.json +339 -75
  2. package/README.md +47 -4
  3. package/dist/codex-hooks-assets.d.ts.map +1 -1
  4. package/dist/codex-hooks-assets.js +39 -2
  5. package/dist/codex-hooks-assets.js.map +1 -1
  6. package/dist/codex-hooks-verify.d.ts +23 -2
  7. package/dist/codex-hooks-verify.d.ts.map +1 -1
  8. package/dist/codex-hooks-verify.js +29 -0
  9. package/dist/codex-hooks-verify.js.map +1 -1
  10. package/dist/codex-hooks.d.ts +90 -7
  11. package/dist/codex-hooks.d.ts.map +1 -1
  12. package/dist/codex-hooks.js +171 -21
  13. package/dist/codex-hooks.js.map +1 -1
  14. package/dist/feature-adr-routing.d.ts +22 -0
  15. package/dist/feature-adr-routing.d.ts.map +1 -1
  16. package/dist/feature-adr-routing.js +45 -0
  17. package/dist/feature-adr-routing.js.map +1 -1
  18. package/dist/index.d.ts +11 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +17 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/loop-blobs.generated.d.ts +1 -1
  23. package/dist/loop-blobs.generated.d.ts.map +1 -1
  24. package/dist/loop-blobs.generated.js +12 -3
  25. package/dist/loop-blobs.generated.js.map +1 -1
  26. package/dist/loop-plan.d.ts +70 -0
  27. package/dist/loop-plan.d.ts.map +1 -1
  28. package/dist/loop-plan.js +103 -0
  29. package/dist/loop-plan.js.map +1 -1
  30. package/dist/loop-render.d.ts.map +1 -1
  31. package/dist/loop-render.js +38 -130
  32. package/dist/loop-render.js.map +1 -1
  33. package/dist/loop-run-semantics.d.ts +130 -0
  34. package/dist/loop-run-semantics.d.ts.map +1 -0
  35. package/dist/loop-run-semantics.js +257 -0
  36. package/dist/loop-run-semantics.js.map +1 -0
  37. package/dist/loop-trace.d.ts +106 -5
  38. package/dist/loop-trace.d.ts.map +1 -1
  39. package/dist/loop-trace.js +151 -18
  40. package/dist/loop-trace.js.map +1 -1
  41. package/dist/managed-hooks.d.ts +10 -0
  42. package/dist/managed-hooks.d.ts.map +1 -1
  43. package/dist/managed-hooks.js +17 -5
  44. package/dist/managed-hooks.js.map +1 -1
  45. package/dist/named-lock.d.ts +57 -0
  46. package/dist/named-lock.d.ts.map +1 -0
  47. package/dist/named-lock.js +247 -0
  48. package/dist/named-lock.js.map +1 -0
  49. package/dist/operations.d.ts +81 -5
  50. package/dist/operations.d.ts.map +1 -1
  51. package/dist/operations.js +356 -38
  52. package/dist/operations.js.map +1 -1
  53. package/dist/parity.d.ts +70 -2
  54. package/dist/parity.d.ts.map +1 -1
  55. package/dist/parity.js +133 -2
  56. package/dist/parity.js.map +1 -1
  57. package/dist/qe-bridge.d.ts +291 -0
  58. package/dist/qe-bridge.d.ts.map +1 -0
  59. package/dist/qe-bridge.js +538 -0
  60. package/dist/qe-bridge.js.map +1 -0
  61. package/dist/score.d.ts.map +1 -1
  62. package/dist/score.js +43 -9
  63. package/dist/score.js.map +1 -1
  64. package/dist/trace-corroborate.d.ts +48 -0
  65. package/dist/trace-corroborate.d.ts.map +1 -0
  66. package/dist/trace-corroborate.js +172 -0
  67. package/dist/trace-corroborate.js.map +1 -0
  68. package/dist/workflow-run-dispatch.d.ts +230 -0
  69. package/dist/workflow-run-dispatch.d.ts.map +1 -0
  70. package/dist/workflow-run-dispatch.js +363 -0
  71. package/dist/workflow-run-dispatch.js.map +1 -0
  72. package/dist/workflow-run.d.ts +513 -0
  73. package/dist/workflow-run.d.ts.map +1 -0
  74. package/dist/workflow-run.js +1377 -0
  75. package/dist/workflow-run.js.map +1 -0
  76. package/package.json +2 -2
  77. package/sbom.json +740 -80
  78. package/src/codex-hooks-assets.ts +39 -2
  79. package/src/codex-hooks-verify.ts +55 -2
  80. package/src/codex-hooks.ts +172 -20
  81. package/src/feature-adr-routing.ts +55 -0
  82. package/src/index.ts +46 -1
  83. package/src/loop-blobs.generated.ts +12 -3
  84. package/src/loop-plan.ts +185 -0
  85. package/src/loop-render.ts +38 -128
  86. package/src/loop-run-semantics.ts +278 -0
  87. package/src/loop-trace.ts +207 -16
  88. package/src/managed-hooks.ts +26 -5
  89. package/src/named-lock.ts +277 -0
  90. package/src/operations.ts +441 -40
  91. package/src/parity.ts +177 -2
  92. package/src/qe-bridge.ts +737 -0
  93. package/src/score.ts +50 -9
  94. package/src/trace-corroborate.ts +205 -0
  95. package/src/workflow-run-dispatch.ts +459 -0
  96. package/src/workflow-run.ts +1773 -0
@@ -50,9 +50,17 @@ const ERROR_LOG = path.join(CODEX_HOME, 'dz-hooks', 'helper-errors.jsonl');
50
50
  * surfaces a hook's stderr in the transcript ONLY when the hook exits 2 — an exit-0 hook's stderr is
51
51
  * swallowed. So a warn that lived only on stderr would be unobservable, and "the guard is live but
52
52
  * not blocking" (G-J) would be unprovable. This file is where it is observable.
53
+ *
54
+ * The log is 0600 inside a 0700 directory, ENFORCED on every write rather than assumed at creation:
55
+ * \`mkdir -p\` never relaxes an existing directory's mode, so a helper dir that predates dz (or a
56
+ * permissive umask) would leave the record world-readable (independent review, finding 8).
53
57
  */
54
58
  function note(stage, err, extra) {
55
59
  try {
60
+ try { fs.mkdirSync(path.dirname(ERROR_LOG), { recursive: true, mode: 0o700 }); } catch (_) { /* exists */ }
61
+ try { fs.chmodSync(path.dirname(ERROR_LOG), 0o700); } catch (_) { /* not ours to tighten */ }
62
+ try { if (!fs.existsSync(ERROR_LOG)) fs.writeFileSync(ERROR_LOG, '', { mode: 0o600 }); } catch (_) { /* raced */ }
63
+ try { fs.chmodSync(ERROR_LOG, 0o600); } catch (_) { /* not ours to tighten */ }
56
64
  fs.appendFileSync(
57
65
  ERROR_LOG,
58
66
  JSON.stringify(Object.assign(
@@ -63,6 +71,35 @@ function note(stage, err, extra) {
63
71
  } catch (_) { /* the note is best-effort; it must never become the failure */ }
64
72
  }
65
73
 
74
+ /**
75
+ * What a note may say about a COMMAND: its hash, its program name, and a probe nonce if one is
76
+ * embedded — never the line itself.
77
+ *
78
+ * A veto fires on exactly the commands most likely to carry a credential (\`ssh\`, \`curl -u\`,
79
+ * \`psql\`), and the note is a durable file in the user's home. It recorded the full line
80
+ * (independent review, finding 8). The hash keeps the record useful — two notes are the same
81
+ * command iff their hashes match — and \`probeNonce\` keeps the live probe gradeable, because the
82
+ * probe's own correlator is a purpose-built token (\`dzprobe-…\` / \`dzverify-…\`) and not a secret.
83
+ */
84
+ function commandFacts(command) {
85
+ var facts = {};
86
+ try {
87
+ facts.commandSha256 = require('node:crypto').createHash('sha256').update(String(command), 'utf8').digest('hex');
88
+ } catch (_) { /* hashing is best-effort too */ }
89
+ try {
90
+ // The first token is a BINARY NAME or nothing. \`SECRET=xyz ssh …\` puts a credential in the
91
+ // first token, and a synopsis is not worth one byte of it (fix round 2, R2-8): anything that is
92
+ // not a plain program name — in particular anything carrying \`=\` — is redacted outright.
93
+ var first = String(command).trim().split(/\\s+/)[0] || '';
94
+ facts.commandSynopsis = /^[A-Za-z0-9._\\/-]+$/.test(first) ? first.slice(0, 40) : '(redacted)';
95
+ } catch (_) { /* ignore */ }
96
+ try {
97
+ var m = String(command).match(/dz(?:probe|verify)-[0-9a-zA-Z-]+/);
98
+ if (m) facts.probeNonce = m[0];
99
+ } catch (_) { /* ignore */ }
100
+ return facts;
101
+ }
102
+
66
103
  function readPayload() {
67
104
  try {
68
105
  const raw = fs.readFileSync(0, 'utf8');
@@ -162,14 +199,14 @@ function main() {
162
199
 
163
200
  if (mode === 'block') {
164
201
  process.stderr.write('DZ-VETO: ' + hit.rule + ' — ' + hit.reason + ' — command: ' + command + '\\n');
165
- note('veto-block', null, { marker: 'DZ-VETO', rule: hit.rule, command: command, mode: mode });
202
+ note('veto-block', null, Object.assign({ marker: 'DZ-VETO', rule: hit.rule, mode: mode }, commandFacts(command)));
166
203
  return 2;
167
204
  }
168
205
  const warnLine = 'DZ-VETO-WARN: ' + hit.rule + ' — ' + hit.reason + ' — command: ' + command;
169
206
  process.stderr.write(warnLine + '\\n');
170
207
  // Codex swallows an exit-0 hook's stderr (MEASURED), so the warn is ALSO recorded where it can be
171
208
  // read back — otherwise the shipped default would be live-but-unobservable.
172
- note('veto-warn', null, { marker: 'DZ-VETO-WARN', rule: hit.rule, command: command, mode: mode });
209
+ note('veto-warn', null, Object.assign({ marker: 'DZ-VETO-WARN', rule: hit.rule, mode: mode }, commandFacts(command)));
173
210
  return 0;
174
211
  }
175
212
 
@@ -46,8 +46,29 @@ export interface VetoProbeEvidence {
46
46
  readonly transcript: string;
47
47
  /** Per-run nonce embedded in the probe command; guards against grading a stale transcript. */
48
48
  readonly nonce: string;
49
- /** Did the sentinel side effect land? `true` ⇒ the command RAN. */
50
- readonly sentinelPresent: boolean;
49
+ /**
50
+ * Did the sentinel side effect land? `true` ⇒ the command RAN.
51
+ *
52
+ * `null` ⇒ the state could NOT be established (the stat failed, or a transcript carried no
53
+ * sentinel header). ADR-002 §2 row `sentinelExists === null` ⇒ `inconclusive`: the QE fix round
54
+ * found the probe parser DEFAULTING a missing header to `false`, which is the difference between
55
+ * "the command was blocked" and "we did not look" (independent review, finding 4).
56
+ */
57
+ readonly sentinelPresent: boolean | null;
58
+ /**
59
+ * Did the `codex` invocation COMPLETE? `false` ⇒ inconclusive whatever the transcript says
60
+ * (ADR-002 §2). `null` ⇒ the caller LOOKED and could not tell ⇒ also inconclusive. Absent ⇒ the
61
+ * caller did not report completion at all; the other evidence still decides.
62
+ *
63
+ * The three-way split is the fix-round-2 correction (R2-4): a recorder that cannot find its own
64
+ * completion fields must say so EXPLICITLY, and an explicit "unknown" must not be graded like a
65
+ * caller who simply never spoke about completion.
66
+ */
67
+ readonly ran?: boolean | null | undefined;
68
+ /** The invocation's exit status. `null` ⇒ the process died / was killed / unrecorded ⇒ inconclusive. */
69
+ readonly exitCode?: number | null | undefined;
70
+ /** The invocation hit its timeout. `null` ⇒ recorded as unknown ⇒ inconclusive. */
71
+ readonly timedOut?: boolean | null | undefined;
51
72
  /** Did the transcript show the model actually attempting the shell command? */
52
73
  readonly shellAttempted: boolean;
53
74
  /** Was `--dangerously-bypass-hook-trust` in play? `true` can never yield a pass. */
@@ -96,6 +117,38 @@ export function classifyVetoProbe(e: VetoProbeEvidence): VetoProbeResult {
96
117
 
97
118
  const trust = classifyTrust(e.trustStatus);
98
119
 
120
+ // 1b. COMPLETION (ADR-002 §2, row "invocation did not complete"). A transcript from a run that
121
+ // died, timed out, or never finished is a partial reading, and the marker can appear in a
122
+ // partial reading. Decided before any transcript evidence is weighed (fix-round finding 4).
123
+ if (e.timedOut === true) {
124
+ return { verdict: 'inconclusive', trust, reason: 'the probe invocation TIMED OUT: a partial transcript is a failed observation, not a clean run' };
125
+ }
126
+ if (e.timedOut === null) {
127
+ return { verdict: 'inconclusive', trust, reason: 'completion evidence incomplete: the timeout state was recorded as UNKNOWN — a run that may have been cut short cannot witness a block' };
128
+ }
129
+ if (e.ran === null) {
130
+ return { verdict: 'inconclusive', trust, reason: 'completion evidence incomplete: whether the probe invocation completed was recorded as UNKNOWN' };
131
+ }
132
+ if (e.ran === false) {
133
+ return {
134
+ verdict: 'inconclusive',
135
+ trust,
136
+ reason:
137
+ e.sentinelPresent === true
138
+ ? 'contradictory evidence: the invocation is reported as never completed, yet the sentinel side effect landed'
139
+ : 'the probe invocation did not complete — there is nothing to grade',
140
+ };
141
+ }
142
+ if (e.exitCode === null) {
143
+ return { verdict: 'inconclusive', trust, reason: 'the probe process died without an exit status (signal or kill) — evidence is missing, not clean' };
144
+ }
145
+
146
+ // 1c. An UNKNOWN sentinel state cannot be graded: "absent" is half of the two-sided witness, and
147
+ // "we did not look" must never be spelled the same way (finding 4).
148
+ if (e.sentinelPresent !== true && e.sentinelPresent !== false) {
149
+ return { verdict: 'inconclusive', trust, reason: 'the sentinel side-effect state could not be established — evidence is missing, not clean' };
150
+ }
151
+
99
152
  // 2. Stale facts are not facts (AM-10).
100
153
  const recorded = e.recordedCodexVersion;
101
154
  const probed = e.probedCodexVersion;
@@ -28,8 +28,16 @@ import { join } from 'node:path';
28
28
 
29
29
  import { mergeManagedHookEntries } from './managed-hooks.js';
30
30
 
31
- /** Bump when a helper BODY changes: a changed body changes codex's `currentHash` ⇒ re-trust. */
32
- export const DZ_HOOK_HELPER_VERSION = 1;
31
+ /**
32
+ * Bump when a helper BODY changes: a changed body changes codex's `currentHash` ⇒ re-trust.
33
+ *
34
+ * 2 — the independent-QE fix round: the veto note stopped persisting the raw command line and the
35
+ * notes log/dir modes are now enforced on every write (finding 8).
36
+ * 3 — fix round 2: the note's `commandSynopsis` is a binary NAME or `(redacted)` — an
37
+ * env-assignment first token (`SECRET=xyz ssh …`) carried the credential the redaction removed
38
+ * everywhere else (R2-8).
39
+ */
40
+ export const DZ_HOOK_HELPER_VERSION = 3;
33
41
 
34
42
  /** Seconds. Probe-proven (spike S2): `timeout` is honored, the unset default is 600 s. */
35
43
  export const DZ_HOOK_TIMEOUT_SECONDS = 5;
@@ -264,6 +272,29 @@ export function isDzManagedEntry(entry: unknown, manifest: CodexHookManifest | u
264
272
  return entryCommands(entry).some((cmd) => cmd !== '' && known.has(codexHookSha256(cmd)));
265
273
  }
266
274
 
275
+ /**
276
+ * Rebuild an OWNED entry from only the handlers that are NOT ours — per-HANDLER attribution.
277
+ *
278
+ * ADR-001 §5 promises that dz deletes only what it can prove it wrote. Attribution was applied to a
279
+ * whole MATCHER GROUP: a group holding one dz handler and one of the user's was removed (or
280
+ * replaced) wholesale, so a foreign handler was deleted by a rule written to protect it
281
+ * (independent review, finding 6). One dz handler in the group must cost exactly that handler.
282
+ *
283
+ * Returns `null` when every handler in the entry was ours (the entry itself is then dropped), and
284
+ * a rebuilt entry — same keys, same order, minus our handlers — when something foreign survives.
285
+ * A legacy FLAT entry (`{type, command}`) is one handler and has nothing to salvage.
286
+ */
287
+ export function retainForeignHandlers(entry: unknown, manifest: CodexHookManifest | undefined): unknown | null {
288
+ if (manifest === undefined) return entry;
289
+ const known = new Set(manifest.entries.map((e) => e.commandSha256));
290
+ const isOurs = (cmd: unknown): boolean => typeof cmd === 'string' && cmd !== '' && known.has(codexHookSha256(cmd));
291
+ const e = entry as { hooks?: unknown };
292
+ if (!Array.isArray(e?.hooks)) return null; // flat shape: the entry IS the handler
293
+ const kept = (e.hooks as { command?: unknown }[]).filter((h) => !isOurs(h?.command));
294
+ if (kept.length === 0) return null;
295
+ return { ...(entry as Record<string, unknown>), hooks: kept };
296
+ }
297
+
267
298
  /** Cheap resemblance test — drives the `unattributable` COUNT only, never a deletion. */
268
299
  export function looksLikeDzEntry(entry: unknown): boolean {
269
300
  return entryCommands(entry).some(
@@ -309,7 +340,17 @@ export function parseCodexRegistry(text: string | undefined): ParseRegistryResul
309
340
  }
310
341
  const hooks: Record<string, unknown[]> = {};
311
342
  for (const [event, value] of Object.entries((rawHooks ?? {}) as Record<string, unknown>)) {
312
- hooks[event] = Array.isArray(value) ? [...value] : [];
343
+ // REFUSE, never coerce. `Array.isArray(value) ? [...value] : []` silently turned a foreign
344
+ // event whose value dz does not understand into an EMPTY list, and the next write persisted
345
+ // that emptiness — configuration loss dressed as a merge (independent review, finding 5). An
346
+ // unparseable shape is the same class of fact as unparseable JSON: refuse before planning.
347
+ if (!Array.isArray(value)) {
348
+ return {
349
+ ok: false,
350
+ error: `hooks.json event ${JSON.stringify(event)} must be an array of entries (found ${value === null ? 'null' : typeof value}) — dz refuses to rewrite a registry it cannot read without losing it`,
351
+ };
352
+ }
353
+ hooks[event] = [...value];
313
354
  }
314
355
  const rest: Record<string, unknown> = {};
315
356
  for (const [k, v] of Object.entries(obj)) if (k !== 'hooks') rest[k] = v;
@@ -363,6 +404,10 @@ export function planCodexHooks(
363
404
  const plan = mergeManagedHookEntries(parsed.registry.hooks, managedByEvent(input.entries), {
364
405
  isManaged: (entry) => isDzManagedEntry(entry, selfManifest),
365
406
  looksLikeOurs: (entry) => looksLikeDzEntry(entry) && !isDzManagedEntry(entry, selfManifest),
407
+ // Per-HANDLER salvage (finding 6): an owned entry that also carries the user's own handler is
408
+ // rebuilt without ours, never dropped whole. The Claude path passes no salvage and is
409
+ // byte-identical to before (AM-3).
410
+ retainForeign: (entry) => retainForeignHandlers(entry, selfManifest),
366
411
  reportLabel: 'codex',
367
412
  });
368
413
  const registry: CodexRegistry = { rest: parsed.registry.rest, hooks: plan.hooks };
@@ -402,14 +447,19 @@ export function removeCodexHooks(
402
447
  let removed = 0;
403
448
  let unattributable = 0;
404
449
  for (const [event, entries] of Object.entries(parsed.registry.hooks)) {
405
- const kept = entries.filter((entry) => {
450
+ const kept: unknown[] = [];
451
+ for (const entry of entries) {
406
452
  if (isDzManagedEntry(entry, manifest)) {
407
453
  removed += 1;
408
- return false;
454
+ // Per-HANDLER removal (finding 6): a mixed matcher group keeps every handler that is not
455
+ // ours. Removing dz's guard must never remove the user's alongside it.
456
+ const salvaged = retainForeignHandlers(entry, manifest);
457
+ if (salvaged !== null) kept.push(salvaged);
458
+ continue;
409
459
  }
410
460
  if (looksLikeDzEntry(entry)) unattributable += 1;
411
- return true;
412
- });
461
+ kept.push(entry);
462
+ }
413
463
  hooks[event] = kept;
414
464
  }
415
465
  const registry: CodexRegistry = { rest: parsed.registry.rest, hooks };
@@ -482,6 +532,25 @@ export function trustEventName(event: CodexHookEvent): string {
482
532
  return event === 'PreToolUse' ? 'pre_tool_use' : 'user_prompt_submit';
483
533
  }
484
534
 
535
+ /**
536
+ * Compare two event spellings the way the runtime actually spells them.
537
+ *
538
+ * MEASURED, both on this machine, on the SAME response shape:
539
+ * - the trust KEY embeds `pre_tool_use` / `user_prompt_submit` (snake_case);
540
+ * - `hooks/list`'s `eventName` FIELD is `preToolUse` / `userPromptSubmit` on **codex-cli 0.148.0**
541
+ * (reproducer: `listCodexHooks(<temp home>)` after an install — the rows are printed verbatim in
542
+ * `07_code_changes/probe-results/fixround/trust-diagnosis.txt`).
543
+ *
544
+ * Pinning either spelling alone silently drops every row: the fix round's first cut compared the
545
+ * field against the KEY spelling, `hooks/list` matched 0 of 2 entries, trust was never written, and
546
+ * the live probe's ARMED leg stopped blocking. Case and separators are therefore normalised away —
547
+ * the spelling is the runtime's cosmetic choice, the EVENT is the fact.
548
+ */
549
+ export function sameHookEvent(a: string, b: string): boolean {
550
+ const norm = (v: string): string => v.toLowerCase().replace(/[-_\s]/g, '');
551
+ return norm(a) === norm(b);
552
+ }
553
+
485
554
  /**
486
555
  * The trust key codex uses: `<sourcePath>:<snake_event>:<groupIndex>:<hookIndex>`.
487
556
  *
@@ -498,6 +567,26 @@ export function expectedTrustKey(
498
567
  return `${registryPath}:${trustEventName(event)}:${groupIndex}:${hookIndex}`;
499
568
  }
500
569
 
570
+ /**
571
+ * Split a `hooks/list` trust key into its parts, or `null` when it is not one.
572
+ *
573
+ * MEASURED shape: `<sourcePath>:<snake_event>:<groupIndex>:<hookIndex>`. The path may itself carry
574
+ * a colon, so the split is anchored at the END — the last two fields are the indices and the third
575
+ * from the end is the event.
576
+ *
577
+ * The INDICES are deliberately not predicted: they are positions inside the user's registry, and a
578
+ * foreign entry shifts them. What IS ours to require is the rest — this key names OUR registry and
579
+ * OUR event (fix round 2, R2-7: a row carrying an arbitrary key was selected on path+event+command
580
+ * alone, and the key is what dz then writes trust against).
581
+ */
582
+ export function parseTrustKey(
583
+ key: string,
584
+ ): { readonly sourcePath: string; readonly event: string; readonly groupIndex: number; readonly hookIndex: number } | null {
585
+ const m = /^(.*):([A-Za-z_][A-Za-z_]*):(\d+):(\d+)$/.exec(key);
586
+ if (m === null) return null;
587
+ return { sourcePath: m[1] ?? '', event: m[2] ?? '', groupIndex: Number(m[3]), hookIndex: Number(m[4]) };
588
+ }
589
+
501
590
  export interface HookTrustRow {
502
591
  readonly key: string;
503
592
  readonly trustedHash: string;
@@ -527,18 +616,51 @@ export function renderTrustBlock(rows: readonly HookTrustRow[]): string {
527
616
  return `${DZ_TRUST_BEGIN}\n${body}\n${DZ_TRUST_END}\n`;
528
617
  }
529
618
 
530
- /** Replace (or append) dz's managed trust block, leaving every other byte of config.toml alone. */
531
- export function upsertTrustBlock(configToml: string, rows: readonly HookTrustRow[]): string {
619
+ export type TrustBlockResult = { readonly ok: true; readonly text: string } | { readonly ok: false; readonly error: string };
620
+
621
+ function countOccurrences(haystack: string, needle: string): number {
622
+ let n = 0;
623
+ let i = haystack.indexOf(needle);
624
+ while (i >= 0) {
625
+ n += 1;
626
+ i = haystack.indexOf(needle, i + needle.length);
627
+ }
628
+ return n;
629
+ }
630
+
631
+ /**
632
+ * Replace (or append) dz's managed trust block, leaving every other byte of config.toml alone.
633
+ *
634
+ * The fence must be **exactly one well-ordered pair**, or dz refuses (independent review, finding
635
+ * 9). The old `indexOf`-pair logic was satisfiable by a damaged file in three ways, and each one
636
+ * eats the user's TOML on the NEXT sync: a lone BEGIN made the rewrite append a second block, so
637
+ * the following run's `begin…end` window spanned everything between the orphan marker and the new
638
+ * block's END; a reversed pair did the same; duplicate pairs left an orphan block behind. Refusing
639
+ * costs one manual edit — the alternative silently deletes `[projects."…"]` trust rows.
640
+ */
641
+ export function upsertTrustBlock(configToml: string, rows: readonly HookTrustRow[]): TrustBlockResult {
532
642
  const block = renderTrustBlock(rows);
533
- const begin = configToml.indexOf(DZ_TRUST_BEGIN);
534
- const end = configToml.indexOf(DZ_TRUST_END);
535
- if (begin >= 0 && end > begin) {
643
+ const begins = countOccurrences(configToml, DZ_TRUST_BEGIN);
644
+ const ends = countOccurrences(configToml, DZ_TRUST_END);
645
+ const heal = `heal by hand: leave exactly one ${JSON.stringify(DZ_TRUST_BEGIN)} ${JSON.stringify(DZ_TRUST_END)} pair (or delete both markers) and re-run`;
646
+ if (begins > 1 || ends > 1) {
647
+ return { ok: false, error: `config.toml carries ${begins} dz trust BEGIN and ${ends} END markers — dz refuses to guess which block is its own. ${heal}` };
648
+ }
649
+ if (begins !== ends) {
650
+ return { ok: false, error: `config.toml carries an UNPAIRED dz trust marker (${begins} BEGIN, ${ends} END) — rewriting it would consume unrelated TOML. ${heal}` };
651
+ }
652
+ if (begins === 1) {
653
+ const begin = configToml.indexOf(DZ_TRUST_BEGIN);
654
+ const end = configToml.indexOf(DZ_TRUST_END);
655
+ if (end < begin) {
656
+ return { ok: false, error: `config.toml carries the dz trust END marker BEFORE its BEGIN — the fence is inverted and the span between them is not dz's. ${heal}` };
657
+ }
536
658
  const before = configToml.slice(0, begin);
537
659
  const after = configToml.slice(end + DZ_TRUST_END.length).replace(/^\n/, '');
538
- return `${before}${block}${after}`;
660
+ return { ok: true, text: `${before}${block}${after}` };
539
661
  }
540
662
  const base = configToml === '' || configToml.endsWith('\n') ? configToml : `${configToml}\n`;
541
- return `${base}${base === '' ? '' : '\n'}${block}`;
663
+ return { ok: true, text: `${base}${base === '' ? '' : '\n'}${block}` };
542
664
  }
543
665
 
544
666
  export interface HooksListHookMetadata {
@@ -553,19 +675,49 @@ export interface HooksListHookMetadata {
553
675
  readonly timeoutSec: number;
554
676
  }
555
677
 
678
+ export interface SelectOwnHookOptions {
679
+ /** The registry file dz wrote. A row from any other source path is not dz's row. */
680
+ readonly registryPath: string;
681
+ }
682
+
556
683
  /**
557
- * Pick, from a `hooks/list` response, the metadata of the entries WE wrote — matched on the exact
558
- * command string, so a third party's entry can never inherit dz's trust write.
684
+ * Pick, from a `hooks/list` response, the metadata of the entries WE wrote.
685
+ *
686
+ * The match is on THREE facts, not one (independent review, finding 7): the row's `sourcePath` is
687
+ * the registry dz wrote, its `eventName` is the event dz registered the entry under, and the
688
+ * command string is byte-equal. Command alone was not enough — a project-scoped
689
+ * `<repo>/.codex/hooks.json` DOES load on codex 0.148 (MEASURED, see ADR-004's addendum), so a
690
+ * shadow copy of dz's own command line could supply the `trusted` row that armed the user-global
691
+ * entry nobody had approved.
692
+ *
693
+ * AMBIGUITY IS REFUSED, not resolved: two rows claiming the same entry drop BOTH, because the one
694
+ * dz would arm is then a coin flip and the trust write is keyed by the row it picked.
559
695
  */
560
696
  export function selectOwnHookMetadata(
561
697
  hooks: readonly HooksListHookMetadata[],
562
698
  entries: readonly ManagedEntry[],
699
+ options: SelectOwnHookOptions,
563
700
  ): { readonly id: CodexHookId; readonly meta: HooksListHookMetadata }[] {
564
- const byCommand = new Map(entries.map((e) => [e.command, e.id] as const));
565
- const out: { id: CodexHookId; meta: HooksListHookMetadata }[] = [];
701
+ const byCommand = new Map(entries.map((e) => [e.command, e] as const));
702
+ const claims = new Map<CodexHookId, { id: CodexHookId; meta: HooksListHookMetadata }[]>();
566
703
  for (const meta of hooks) {
567
- const id = typeof meta.command === 'string' ? byCommand.get(meta.command) : undefined;
568
- if (id !== undefined) out.push({ id, meta });
704
+ if (typeof meta.command !== 'string') continue;
705
+ const entry = byCommand.get(meta.command);
706
+ if (entry === undefined) continue;
707
+ if (meta.sourcePath !== options.registryPath) continue;
708
+ if (!sameHookEvent(meta.eventName, trustEventName(entry.event))) continue;
709
+ // The KEY is the thing dz writes trust against, so it — not just the row's other fields — has
710
+ // to be the key our entry would own (R2-7). An arbitrary or foreign key is ambiguous by
711
+ // construction: arming it would persist trust for something we cannot identify.
712
+ const parsedKey = typeof meta.key === 'string' ? parseTrustKey(meta.key) : null;
713
+ if (parsedKey === null) continue;
714
+ if (parsedKey.sourcePath !== options.registryPath) continue;
715
+ if (!sameHookEvent(parsedKey.event, trustEventName(entry.event))) continue;
716
+ (claims.get(entry.id) ?? claims.set(entry.id, []).get(entry.id)!).push({ id: entry.id, meta });
717
+ }
718
+ const out: { id: CodexHookId; meta: HooksListHookMetadata }[] = [];
719
+ for (const rows of claims.values()) {
720
+ if (rows.length === 1) out.push(rows[0]!);
569
721
  }
570
722
  return out;
571
723
  }
@@ -1479,3 +1479,58 @@ export function checkArtifactRoot(root: string): string | null {
1479
1479
  if (hasDotDotSegment(root)) return 'artifact root contains a ".." segment: ' + root;
1480
1480
  return null;
1481
1481
  }
1482
+
1483
+
1484
+ // ── K2 plan-completeness gate (feature fa-plan-gate-wiring) ────────────────────────────────────
1485
+ // Step 7 must not start on an incomplete plan. The gate is a SCRIPT the pipeline shells out to;
1486
+ // these two pure halves build its command and PARSE its verdict. The workflow inlines byte-equivalent
1487
+ // copies (drift-guarded), because the workflow sandbox cannot import.
1488
+
1489
+ /** The one path the pipeline calls. Repo-relative — the command `cd`s into the repo root first. */
1490
+ export const PLAN_GATE_SCRIPT = '.claude/skills/feature-adr/scripts/check-plan-completeness.mjs';
1491
+
1492
+ export interface PlanGateVerdict {
1493
+ /** 'pass' ONLY when the script's own last line AND its exit code agree on PASS. */
1494
+ verdict: 'pass' | 'fail' | 'not-established';
1495
+ /** The script's exit code as reported by the `K2_EXIT=` trailer, or null when unparseable. */
1496
+ exit: number | null;
1497
+ reason: string;
1498
+ output: string;
1499
+ }
1500
+
1501
+ /**
1502
+ * The EXACT command the gate agent runs. `2>&1` folds stderr in (a crash must be visible, not
1503
+ * silently empty) and the `K2_EXIT=` trailer carries the exit code back through an agent that can
1504
+ * only return text.
1505
+ */
1506
+ export function planCompletenessGateCmd(repo: string, featureDir: string, tier?: string | null): string {
1507
+ const q = (s: string) => "'" + String(s).replace(/'/g, "'\\''") + "'"
1508
+ const t = (typeof tier === 'string' && tier !== '') ? ' --tier=' + q(tier) : ''
1509
+ return 'cd ' + q(repo) + ' && node ' + q(PLAN_GATE_SCRIPT) + ' ' + q(featureDir) + t + ' 2>&1; echo K2_EXIT=$?'
1510
+ }
1511
+
1512
+ /**
1513
+ * PARSE-NEVER-SYNTHESIZE. An empty reply, a reply with no verdict line, a missing/unknown exit code,
1514
+ * or a verdict line that DISAGREES with the exit code are all `not-established` — never a pass. The
1515
+ * quiet failure this forecloses: a dead or chatty agent reading as a clean gate.
1516
+ */
1517
+ export function parsePlanGateVerdict(raw: string | null | undefined): PlanGateVerdict {
1518
+ const text = String(raw === null || raw === undefined ? '' : raw)
1519
+ const output = text.slice(0, 2000)
1520
+ if (text.trim() === '') return { verdict: 'not-established', exit: null, reason: 'empty-agent-reply', output: output }
1521
+ // G-F1 (reproduced by execution): the checker ECHOES plan-controlled content, so a forged verdict
1522
+ // line and a forged K2_EXIT trailer can appear EARLIER in this stream. The script always writes its
1523
+ // own verdict LAST, so both halves anchor to the LAST match — a first-match read let a planted
1524
+ // 'K2 plan-completeness: PASS (0) K2_EXIT=0' target line forge a pass on a genuine FAIL/1 run.
1525
+ const exitAll = text.match(/K2_EXIT=(\d+)/g)
1526
+ const lastExit = exitAll === null ? null : /K2_EXIT=(\d+)/.exec(String(exitAll[exitAll.length - 1]))
1527
+ const exitCode = lastExit === null ? null : Number(lastExit[1])
1528
+ const verdictAll = text.match(/K2 plan-completeness:\s*(PASS|FAIL|NOT-ESTABLISHED)/g)
1529
+ if (verdictAll === null) return { verdict: 'not-established', exit: exitCode, reason: 'no-verdict-line', output: output }
1530
+ const lastVerdict = String(verdictAll[verdictAll.length - 1])
1531
+ const byName = lastVerdict.indexOf('PASS') >= 0 ? 'pass' : (lastVerdict.indexOf('FAIL') >= 0 ? 'fail' : 'not-established')
1532
+ const byExit = exitCode === 0 ? 'pass' : (exitCode === 1 ? 'fail' : (exitCode === 3 ? 'not-established' : null))
1533
+ if (byExit === null) return { verdict: 'not-established', exit: exitCode, reason: 'unknown-exit-code', output: output }
1534
+ if (byExit !== byName) return { verdict: 'not-established', exit: exitCode, reason: 'verdict-exit-mismatch', output: output }
1535
+ return { verdict: byName, exit: exitCode, reason: 'script-verdict', output: output }
1536
+ }
package/src/index.ts CHANGED
@@ -26,8 +26,11 @@ export {
26
26
  buildParityMatrix,
27
27
  CAPABILITY_EVIDENCE,
28
28
  findUnbackedCapabilities,
29
+ findStaleTranscriptEvidence,
30
+ newestRecordedRuntimeVersion,
31
+ downgradeForStaleEvidence,
29
32
  } from './parity.js';
30
- export type { RuntimeCapability, FeatureForm, ParityFeature, ParityCell, ParityMatrixRow, CapabilityEvidence, UnbackedCapability } from './parity.js';
33
+ export type { RuntimeCapability, FeatureForm, ParityFeature, ParityCell, ParityReportCell, ParityMatrixRow, CapabilityEvidence, UnbackedCapability, ProbedRuntimeVersions } from './parity.js';
31
34
  export * from './operations.js';
32
35
  // workflows.ts: the ADR-005 templates are RETIRED (feature loop-designer, AM-6) — the module is a
33
36
  // deprecation shim (empty WORKFLOW_NAMES). BREAKING for external harness-core consumers of
@@ -37,7 +40,13 @@ export * from './workflows.js';
37
40
  // loop-designer (feature loop-designer): loop-plan/1 schema + generator + lint + trace planes.
38
41
  export * from './loop-plan.js';
39
42
  export * from './loop-plan-graph.js';
43
+ // loop-run-semantics: the ONE home of the enactment DECISIONS both enactors use — the generated
44
+ // Claude-host script (via the always-on `loop-semantics` blob) and `dz workflow run` (by import).
45
+ export * from './loop-run-semantics.js';
40
46
  export * from './loop-render.js';
47
+ // dz workflow run (feature dz-workflow-run): the pure scheduler + the dispatcher seam.
48
+ export * from './workflow-run.js';
49
+ export * from './workflow-run-dispatch.js';
41
50
  // loop-lint: EXPLICIT export list (QE round-2 G14) — `dominators`, the deliberately-WEAKER
42
51
  // analysis kept in src/loop-lint.ts solely as AM-1's mutation seam, is NOT part of the published
43
52
  // API surface; the in-package tests reach it via the module path directly.
@@ -56,6 +65,7 @@ export {
56
65
  type LintOptions,
57
66
  } from './loop-lint.js';
58
67
  export * from './loop-trace.js';
68
+ export * from './trace-corroborate.js';
59
69
  export * from './trace-bundle.js';
60
70
  export { BLOBS as LOOP_BLOBS, LOOP_BLOB_NAMES, BLOB_COVERAGE_MANIFEST } from './loop-blobs.generated.js';
61
71
  export type { LoopBlob } from './loop-blobs.generated.js';
@@ -78,6 +88,30 @@ export { pretrain } from './pretrain.js';
78
88
  export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, readMemoryLearningConfig, BOOST_CAP, recordPattern, loadStorePatternsSync, loadStoreRecords, patternToRecord, recordToPattern, patternRecordId, patternIdentityOf, dreamRecordId, isMirrorableLearning, consolidateSessions, recallPatterns, pruneNoisePatterns, removePatternsByIds, snapshotStore, readReinforcementState, encodeReinforcementState, reinforcePattern, updateReinforcementState, storeStats, lessonDeltaReport, lessonDeltaMap, readQuarantineState, encodeQuarantineState, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns } from './patterns.js';
79
89
  export { withStoreLock, withStoreLockSync, storeLockPath, StoreLockTimeoutError, StoreLockCompromisedError, STALE_LOCK_MS, LOCK_TIMEOUT_MS } from './store-lock.js';
80
90
  export type { StoreLockOptions } from './store-lock.js';
91
+ export { withNamedLockSync, namedLockPath, isSafeLockName, NamedLockNameError, NamedLockTimeoutError, NamedLockCompromisedError } from './named-lock.js';
92
+ export {
93
+ QE_BRIDGE_SCHEMA,
94
+ QE_BRIDGE_FAILURE_SCHEMA,
95
+ CLAUDE_BRIDGE_PROMPT_CEILING_CHARS,
96
+ KNOWN_CLAUDE,
97
+ BRIDGE_MARKER,
98
+ BRIDGE_FENCE_LABEL,
99
+ BRIDGE_EXTRACT_END,
100
+ isSafeClaudeId,
101
+ CLAUDE_ISOLATION_ARGS,
102
+ BRIDGE_FAILURE_REASONS,
103
+ extractClaudeResult,
104
+ buildBridgeSignoffRecord,
105
+ claudeProbeArgs,
106
+ interpretClaudeProbe,
107
+ claudeReviewArgs,
108
+ defangSignoffEchoes,
109
+ buildBridgePrompt,
110
+ parseBridgeOutput,
111
+ buildBridgeFailureRecord,
112
+ renderBridgeReport,
113
+ } from './qe-bridge.js';
114
+ export type { BridgeFamily, BridgeFailureReason, BridgeFinding, BridgeSignoff, BridgeParse, BridgeParseOk, BridgeParseFail, BridgeChannels, BridgeAudit, ClaudeResultExtraction, NamedExtract, BridgePromptInput } from './qe-bridge.js';
81
115
  export type { PatternRecord, SessionRecord, LearningConfig, MemoryLearningConfig, LoadOptions, ConsolidateResult, ConsolidateOptions, SqliteBackendMode, RecallHit, SessionsSource, PruneNoiseResult, RemovePatternsResult, SnapshotStoreResult, ReinforcementState, ReinforcePatternResult, StoreStats, LessonDeltaReport, LessonDeltaRow, QuarantineState, PromoteResult, QuarantineExpiryCandidate } from './patterns.js';
82
116
  export { DEFAULT_REINFORCE_THRESHOLD, NoopLearningBackend, NativeReinforcementBackend, resolveLearningBackend, isLearningSignalBackend } from './learning-backend.js';
83
117
  export type { LearningSignalBackend, LearningSignalStats, LearningSample, SignalCandidate, EnhanceContext, TrainingResult, LearningBackendMode } from './learning-backend.js';
@@ -519,6 +553,16 @@ export {
519
553
  CODE_LANDING_PREDICATES,
520
554
  parseLandingSignal,
521
555
  codeLandingProbeCmd,
556
+ // fa-plan-gate-wiring: the K2 plan-completeness gate halves. NAMED here on purpose — an omission
557
+ // silently drops them from the published package (the ADR-002 Confirmation-1 lesson).
558
+ PLAN_GATE_SCRIPT,
559
+ planCompletenessGateCmd,
560
+ parsePlanGateVerdict,
561
+ // qe-bridge-claude: the bridge's path/slug hygiene reuses these rather than minting a second
562
+ // definition of "safe" (ADR-001 D5-A).
563
+ isSafeSlug,
564
+ hasUnsafePathChars,
565
+ hasDotDotSegment,
522
566
  } from './feature-adr-routing.js';
523
567
  export type {
524
568
  StageOpts,
@@ -538,6 +582,7 @@ export type {
538
582
  BaselineVerification,
539
583
  ParsedBaselineCapture,
540
584
  ParsedLandingSignal,
585
+ PlanGateVerdict,
541
586
  } from './feature-adr-routing.js';
542
587
  export {
543
588
  CLAUDE_USAGE_MODELS,
@@ -25,7 +25,7 @@ export interface LoopBlob {
25
25
  code: string;
26
26
  }
27
27
 
28
- export const LOOP_BLOB_NAMES = ["checkpoints","training-pairs","model-resolver","usage-probes","codex-dispatch","challenge-panel","trace","ha-consult-router"] as const;
28
+ export const LOOP_BLOB_NAMES = ["checkpoints","training-pairs","model-resolver","usage-probes","codex-dispatch","challenge-panel","trace","loop-semantics","ha-consult-router"] as const;
29
29
 
30
30
  /** Workflow files the regen-diff gate covers TODAY (AM-5 honest scope): exactly the files
31
31
  * carrying BEGIN BLOB markers. Stage B (whole-file regeneration of feature-adr.js) is a
@@ -97,11 +97,20 @@ export const BLOBS: Record<string, LoopBlob> = {
97
97
  "trace": {
98
98
  name: "trace",
99
99
  version: "1.1.0",
100
- contentHash: "fc59098bb0deb747c4c1c9a6ba843df70edfe0dc5ef8370c0f7192afc7657890",
100
+ contentHash: "1270f9aac5fbe5693af8bcc26af55e81aeb41ef4b911dbda72c23ea248072344",
101
101
  sourcePath: "packages/@dzhechkov/harness-core/src/loop-trace.ts",
102
102
  requires: [],
103
103
  exports: ["LOOP_TRACE_SCHEMA_VERSION","TRACE_RUNID_RE","TRACE_KEY_RE","traceShellQuote","traceValidateEvent","traceInit","traceOnDispatch","traceOnSettle","traceClose","traceFlushCmd","traceFaRecordCmd","traceLedgerLine","traceLedgerAppendCmd"],
104
- code: "const LOOP_TRACE_SCHEMA_VERSION = 1;\nconst TRACE_RUNID_RE = /^[a-z0-9-]{1,40}$/;\nconst TRACE_KEY_RE = /^[a-z0-9_.:-]{1,64}$/i;\nfunction traceShellQuote(s) {\n return \"'\" + String(s).replace(/'/g, \"'\\\\''\") + \"'\";\n}\nfunction traceValidateEvent(e) {\n if (typeof e !== 'object' || e === null || Array.isArray(e))\n return 'event must be an object';\n const ev = e;\n if (ev['v'] !== 1)\n return 'v must be 1';\n if (typeof ev['runId'] !== 'string' || !TRACE_RUNID_RE.test(ev['runId']))\n return 'runId fails its VO regex';\n if (typeof ev['seq'] !== 'number' || !Number.isInteger(ev['seq']) || ev['seq'] < 1)\n return 'seq must be a positive integer';\n const kind = ev['event'];\n if (kind === 'dispatched') {\n if (typeof ev['invocationId'] !== 'string' || ev['invocationId'] === '')\n return 'invocationId required';\n if (typeof ev['stepId'] !== 'string' || !TRACE_KEY_RE.test(ev['stepId']))\n return 'stepId fails its VO regex';\n if (ev['itemKey'] !== null && (typeof ev['itemKey'] !== 'string' || !TRACE_KEY_RE.test(ev['itemKey'])))\n return 'itemKey fails its VO regex';\n if (typeof ev['attempt'] !== 'number' || ev['attempt'] < 1)\n return 'attempt must be >= 1';\n if (typeof ev['phase'] !== 'string' || ev['phase'] === '')\n return 'phase required';\n if (!Array.isArray(ev['causedBy']) || ev['causedBy'].some((n) => typeof n !== 'number'))\n return 'causedBy must be a number array';\n return null;\n }\n if (kind === 'settled') {\n if (typeof ev['invocationId'] !== 'string' || ev['invocationId'] === '')\n return 'invocationId required';\n if (ev['outcome'] !== 'ok' && ev['outcome'] !== 'null' && ev['outcome'] !== 'error')\n return 'outcome must be ok|null|error';\n return null;\n }\n if (kind === 'run.opened') {\n if (typeof ev['planDigest'] !== 'string' || typeof ev['execFp'] !== 'string')\n return 'run.opened needs planDigest + execFp';\n return null;\n }\n if (kind === 'run.closed') {\n const c = ev['counts'];\n if (typeof c !== 'object' || c === null)\n return 'run.closed needs counts';\n return null;\n }\n return 'unknown event kind';\n}\nfunction traceInit(runId, planDigest, execFp) {\n if (!TRACE_RUNID_RE.test(runId))\n throw new Error('loop-trace: runId fails ' + String(TRACE_RUNID_RE));\n const state = { runId, seq: 0, dispatched: 0, settled: 0, buffer: [] };\n const opened = { v: 1, runId, seq: ++state.seq, event: 'run.opened', planDigest, execFp };\n traceBuffer(state, opened);\n return state;\n}\nfunction traceBuffer(state, e) {\n const err = traceValidateEvent(e);\n if (err !== null)\n throw new Error('loop-trace: refusing non-conforming event (' + err + ') — the authoritative ordering source is never repaired later');\n state.buffer.push(JSON.stringify(e));\n}\nfunction traceOnDispatch(state, e) {\n const seq = ++state.seq;\n state.dispatched++;\n traceBuffer(state, {\n v: 1,\n runId: state.runId,\n seq,\n event: 'dispatched',\n invocationId: e.invocationId,\n stepId: e.stepId,\n itemKey: e.itemKey,\n attempt: e.attempt,\n phase: e.phase,\n model: e.model,\n causedBy: e.causedBy,\n });\n return seq;\n}\nfunction traceOnSettle(state, e) {\n const seq = ++state.seq;\n state.settled++;\n traceBuffer(state, { v: 1, runId: state.runId, seq, event: 'settled', invocationId: e.invocationId, outcome: e.outcome });\n return seq;\n}\nfunction traceClose(state) {\n const closed = {\n v: 1,\n runId: state.runId,\n seq: ++state.seq,\n event: 'run.closed',\n counts: { dispatched: state.dispatched, settled: state.settled },\n };\n traceBuffer(state, closed);\n}\nfunction traceFlushCmd(state, traceFileAbs) {\n if (state.buffer.length === 0)\n return null;\n const lines = state.buffer.splice(0, state.buffer.length);\n const file = traceShellQuote(traceFileAbs);\n const dir = traceShellQuote(traceFileAbs.replace(/\\/[^/]*$/, ''));\n const printfs = lines\n .map((l) => \"printf '%s\\\\n' \" + traceShellQuote(l) + ' | sed \"s/}$/,\\\\\"wallTime\\\\\":\\\\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\\\\"}/\" >> ' + file)\n .join(' && ');\n return 'mkdir -p ' + dir + ' && ' + printfs;\n}\nfunction traceFaRecordCmd(dzBin, slug, stepLabel, projectAbs) {\n if (typeof slug !== 'string' || slug === ''\n || typeof stepLabel !== 'string' || stepLabel === ''\n || typeof projectAbs !== 'string' || projectAbs === '')\n return null;\n const bin = typeof dzBin === 'string' && dzBin !== '' ? dzBin : 'dz';\n const cmd = traceShellQuote(bin) + ' statusline --fa-record --slug ' + traceShellQuote(slug)\n + ' --step ' + traceShellQuote(stepLabel) + ' --kind loop --project ' + traceShellQuote(projectAbs);\n return cmd + ' >/dev/null 2>&1';\n}\nfunction traceLedgerLine(opts) {\n try {\n if (typeof opts.slug !== 'string' || opts.slug === '')\n return null;\n const agents = typeof opts.agents === 'number'\n && Number.isFinite(opts.agents)\n && Number.isInteger(opts.agents)\n && opts.agents >= 0\n ? opts.agents\n : 0;\n const date = typeof opts.date === 'string' && /^\\d{4}-\\d{2}-\\d{2}$/.test(opts.date) ? opts.date : null;\n const outcome = typeof opts.outcome === 'string' && opts.outcome !== '' ? opts.outcome : 'unknown';\n const line = JSON.stringify({\n slug: opts.slug,\n stage: 'loop-run',\n tier: null,\n tokens: null,\n minutes: null,\n agents,\n coder: null,\n grade: null,\n date,\n auto: true,\n outcome,\n runId: typeof opts.runId === 'string' ? opts.runId : null,\n planDigest: typeof opts.planDigest === 'string' ? opts.planDigest : null,\n });\n return line.length <= 4000 ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction traceLedgerAppendCmd(repoAbs, line) {\n if (typeof repoAbs !== 'string' || repoAbs === '' || typeof line !== 'string' || line === '')\n return null;\n const dir = traceShellQuote(repoAbs + '/.dz/feature-adr');\n const file = traceShellQuote(repoAbs + '/.dz/feature-adr/run-cost-ledger.jsonl');\n return 'mkdir -p ' + dir\n + \" && printf '%s' \" + traceShellQuote(line)\n + ' | sed \"s/\\\\\"date\\\\\":null/\\\\\"date\\\\\":\\\\\"$(date -u +%Y-%m-%d)\\\\\"/\" >> ' + file\n + \" && printf '\\\\n' >> \" + file\n + ' && echo LEDGER-OK';\n}\nfunction invocations(run) {\n const out = new Map();\n for (const e of run.events) {\n if (e.event === 'dispatched') {\n out.set(e.invocationId, {\n invocationId: e.invocationId,\n stepId: e.stepId,\n itemKey: e.itemKey,\n dispatchSeq: e.seq,\n settleSeq: null,\n causedBy: e.causedBy,\n });\n }\n else if (e.event === 'settled') {\n const inv = out.get(e.invocationId);\n if (inv)\n inv.settleSeq = e.seq;\n }\n }\n return [...out.values()];\n}",
104
+ code: "const LOOP_TRACE_SCHEMA_VERSION = 1;\nconst TRACE_RUNID_RE = /^[a-z0-9-]{1,40}$/;\nconst TRACE_KEY_RE = /^[a-z0-9_.:-]{1,64}$/i;\nfunction traceShellQuote(s) {\n return \"'\" + String(s).replace(/'/g, \"'\\\\''\") + \"'\";\n}\nfunction traceValidateEvent(e) {\n if (typeof e !== 'object' || e === null || Array.isArray(e))\n return 'event must be an object';\n const ev = e;\n if (ev['v'] !== 1)\n return 'v must be 1';\n if (typeof ev['runId'] !== 'string' || !TRACE_RUNID_RE.test(ev['runId']))\n return 'runId fails its VO regex';\n if (typeof ev['seq'] !== 'number' || !Number.isInteger(ev['seq']) || ev['seq'] < 1)\n return 'seq must be a positive integer';\n const kind = ev['event'];\n if (kind === 'dispatched') {\n if (typeof ev['invocationId'] !== 'string' || ev['invocationId'] === '')\n return 'invocationId required';\n if (typeof ev['stepId'] !== 'string' || !TRACE_KEY_RE.test(ev['stepId']))\n return 'stepId fails its VO regex';\n if (ev['itemKey'] !== null && (typeof ev['itemKey'] !== 'string' || !TRACE_KEY_RE.test(ev['itemKey'])))\n return 'itemKey fails its VO regex';\n if (typeof ev['attempt'] !== 'number' || ev['attempt'] < 1)\n return 'attempt must be >= 1';\n if (typeof ev['phase'] !== 'string' || ev['phase'] === '')\n return 'phase required';\n if (!Array.isArray(ev['causedBy']) || ev['causedBy'].some((n) => typeof n !== 'number'))\n return 'causedBy must be a number array';\n return null;\n }\n if (kind === 'settled') {\n if (typeof ev['invocationId'] !== 'string' || ev['invocationId'] === '')\n return 'invocationId required';\n if (ev['outcome'] !== 'ok' && ev['outcome'] !== 'null' && ev['outcome'] !== 'error')\n return 'outcome must be ok|null|error';\n return null;\n }\n if (kind === 'run.opened') {\n if (typeof ev['planDigest'] !== 'string' || typeof ev['execFp'] !== 'string')\n return 'run.opened needs planDigest + execFp';\n const ep = ev['emitterPath'];\n if (ep !== undefined && ep !== 'dz-process' && ep !== 'rendered-script')\n return 'emitterPath must be dz-process|rendered-script';\n return null;\n }\n if (kind === 'run.closed') {\n const c = ev['counts'];\n if (typeof c !== 'object' || c === null)\n return 'run.closed needs counts';\n return null;\n }\n return 'unknown event kind';\n}\nfunction traceInit(runId, planDigest, execFp, emitterPath) {\n if (!TRACE_RUNID_RE.test(runId))\n throw new Error('loop-trace: runId fails ' + String(TRACE_RUNID_RE));\n const state = { runId, seq: 0, dispatched: 0, settled: 0, buffer: [] };\n const opened = { v: 1, runId, seq: ++state.seq, event: 'run.opened', planDigest, execFp, emitterPath };\n traceBuffer(state, opened);\n return state;\n}\nfunction traceBuffer(state, e) {\n const err = traceValidateEvent(e);\n if (err !== null)\n throw new Error('loop-trace: refusing non-conforming event (' + err + ') — the authoritative ordering source is never repaired later');\n state.buffer.push(JSON.stringify(e));\n}\nfunction traceOnDispatch(state, e) {\n const seq = ++state.seq;\n state.dispatched++;\n traceBuffer(state, {\n v: 1,\n runId: state.runId,\n seq,\n event: 'dispatched',\n invocationId: e.invocationId,\n stepId: e.stepId,\n itemKey: e.itemKey,\n attempt: e.attempt,\n phase: e.phase,\n model: e.model,\n causedBy: e.causedBy,\n });\n return seq;\n}\nfunction traceOnSettle(state, e) {\n const seq = ++state.seq;\n state.settled++;\n traceBuffer(state, { v: 1, runId: state.runId, seq, event: 'settled', invocationId: e.invocationId, outcome: e.outcome });\n return seq;\n}\nfunction traceClose(state) {\n const closed = {\n v: 1,\n runId: state.runId,\n seq: ++state.seq,\n event: 'run.closed',\n counts: { dispatched: state.dispatched, settled: state.settled },\n };\n traceBuffer(state, closed);\n}\nfunction traceFlushCmd(state, traceFileAbs) {\n if (state.buffer.length === 0)\n return null;\n const lines = state.buffer.splice(0, state.buffer.length);\n const file = traceShellQuote(traceFileAbs);\n const dir = traceShellQuote(traceFileAbs.replace(/\\/[^/]*$/, ''));\n const printfs = lines\n .map((l) => \"printf '%s\\\\n' \" + traceShellQuote(l) + ' | sed \"s/}$/,\\\\\"wallTime\\\\\":\\\\\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\\\\\"}/\" >> ' + file)\n .join(' && ');\n return 'mkdir -p ' + dir + ' && ' + printfs;\n}\nfunction traceFaRecordCmd(dzBin, slug, stepLabel, projectAbs) {\n if (typeof slug !== 'string' || slug === ''\n || typeof stepLabel !== 'string' || stepLabel === ''\n || typeof projectAbs !== 'string' || projectAbs === '')\n return null;\n const bin = typeof dzBin === 'string' && dzBin !== '' ? dzBin : 'dz';\n const cmd = traceShellQuote(bin) + ' statusline --fa-record --slug ' + traceShellQuote(slug)\n + ' --step ' + traceShellQuote(stepLabel) + ' --kind loop --project ' + traceShellQuote(projectAbs);\n return cmd + ' >/dev/null 2>&1';\n}\nfunction traceLedgerLine(opts) {\n try {\n if (typeof opts.slug !== 'string' || opts.slug === '')\n return null;\n const agents = typeof opts.agents === 'number'\n && Number.isFinite(opts.agents)\n && Number.isInteger(opts.agents)\n && opts.agents >= 0\n ? opts.agents\n : 0;\n const date = typeof opts.date === 'string' && /^\\d{4}-\\d{2}-\\d{2}$/.test(opts.date) ? opts.date : null;\n const outcome = typeof opts.outcome === 'string' && opts.outcome !== '' ? opts.outcome : 'unknown';\n const line = JSON.stringify({\n slug: opts.slug,\n stage: 'loop-run',\n tier: null,\n tokens: null,\n minutes: null,\n agents,\n coder: null,\n grade: null,\n date,\n auto: true,\n outcome,\n runId: typeof opts.runId === 'string' ? opts.runId : null,\n planDigest: typeof opts.planDigest === 'string' ? opts.planDigest : null,\n });\n return line.length <= 4000 ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction traceLedgerAppendCmd(repoAbs, line) {\n if (typeof repoAbs !== 'string' || repoAbs === '' || typeof line !== 'string' || line === '')\n return null;\n const dir = traceShellQuote(repoAbs + '/.dz/feature-adr');\n const file = traceShellQuote(repoAbs + '/.dz/feature-adr/run-cost-ledger.jsonl');\n return 'mkdir -p ' + dir\n + \" && printf '%s' \" + traceShellQuote(line)\n + ' | sed \"s/\\\\\"date\\\\\":null/\\\\\"date\\\\\":\\\\\"$(date -u +%Y-%m-%d)\\\\\"/\" >> ' + file\n + \" && printf '\\\\n' >> \" + file\n + ' && echo LEDGER-OK';\n}\nfunction invocations(run) {\n const out = new Map();\n for (const e of run.events) {\n if (e.event === 'dispatched') {\n out.set(e.invocationId, {\n invocationId: e.invocationId,\n stepId: e.stepId,\n itemKey: e.itemKey,\n dispatchSeq: e.seq,\n settleSeq: null,\n causedBy: e.causedBy,\n });\n }\n else if (e.event === 'settled') {\n const inv = out.get(e.invocationId);\n if (inv)\n inv.settleSeq = e.seq;\n }\n }\n return [...out.values()];\n}",
105
+ },
106
+ "loop-semantics": {
107
+ name: "loop-semantics",
108
+ version: "1.0.0",
109
+ contentHash: "d918ba8d29d9dc20e2467ddb0ba126d537bc9e4560547e2ae52d28979ee6ea9c",
110
+ sourcePath: "packages/@dzhechkov/harness-core/src/loop-run-semantics.ts",
111
+ requires: [],
112
+ exports: ["errText","causeChain","errSnap","classifyFailure","gateVerdict","joinRegion"],
113
+ code: "function errText(err) {\n try {\n if (err !== null && typeof err === 'object') {\n const m = err.message;\n if (typeof m === 'string')\n return m;\n }\n return String(err);\n }\n catch (_e) {\n try {\n return Object.prototype.toString.call(err);\n }\n catch (_e2) {\n return '[unrenderable error]';\n }\n }\n}\nfunction causeChain(err) {\n const chain = [];\n let cur = err;\n for (let d = 0; d < 5; d++) {\n if (cur === null || cur === undefined)\n break;\n if (chain.indexOf(cur) !== -1)\n break;\n chain.push(cur);\n try {\n cur = typeof cur === 'object' ? cur.cause : undefined;\n }\n catch (_e) {\n cur = undefined;\n }\n }\n return chain.length > 0 ? chain : [err];\n}\nfunction errSnap(err) {\n const chain = causeChain(err);\n const snap = [];\n for (let ci = 0; ci < chain.length; ci++) {\n let code = null;\n try {\n const c = chain[ci] !== null && typeof chain[ci] === 'object' ? chain[ci].code : null;\n code = typeof c === 'string' ? c.toUpperCase() : null;\n }\n catch (_e) {\n code = null;\n }\n let name = null;\n try {\n const n = chain[ci] !== null && typeof chain[ci] === 'object' ? chain[ci].name : null;\n name = typeof n === 'string' ? n : null;\n }\n catch (_e) {\n name = null;\n }\n snap.push({ code: code, name: name, text: errText(chain[ci]) });\n }\n return snap;\n}\nfunction classifyFailure(outcome, snap) {\n if (outcome === 'null')\n return 'transport';\n const links = Array.isArray(snap) ? snap : [];\n for (let ci = 0; ci < links.length; ci++) {\n const code = links[ci].code;\n if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EPIPE' || code === 'ECONNABORTED' || code === 'EAI_AGAIN')\n return 'transport';\n }\n for (let ci = 0; ci < links.length; ci++) {\n if (links[ci].name === 'SyntaxError')\n return 'malformed-output';\n }\n let msg = '';\n for (let ci = 0; ci < links.length; ci++)\n msg += (ci > 0 ? '\\n' : '') + links[ci].text;\n msg = msg.toLowerCase();\n if (/\\btransport\\b|\\beconnreset\\b|\\beconnrefused\\b|\\benotfound\\b|\\bepipe\\b|\\betimedout\\b|\\bsocket hang up\\b|\\bnetwork error\\b|\\brate[ -]?limit(ed|ing|s)?\\b|\\boverloaded\\b|\\bhttp 5[0-9][0-9]\\b/.test(msg))\n return 'transport';\n if (/\\bpolicy\\b|\\brefus(e|ed|es|al|ing)\\b|\\bdeclin(e|ed|es|ing)\\b|\\bcontent filter\\b|\\bsafety block\\b/.test(msg))\n return 'policy-refusal';\n if (/\\bmalformed\\b|\\bunparseable\\b|\\bparse error\\b|\\binvalid json\\b|\\bunexpected token\\b|\\bunexpected end of json\\b|\\bschema mismatch\\b/.test(msg))\n return 'malformed-output';\n if (/\\btimeout\\b|\\btimed out\\b/.test(msg))\n return 'timeout';\n return null;\n}\nfunction gateVerdict(reply) {\n if (typeof reply !== 'string')\n return 'invalid';\n const vLines = reply.split('\\n');\n const vRe = /^\\s*GATE:\\s*(PASS|FAIL)\\s*$/;\n let vCount = 0;\n let vLast = '';\n for (let i = 0; i < vLines.length; i++) {\n if (vRe.test(vLines[i]))\n vCount++;\n if (vLines[i].trim() !== '')\n vLast = vLines[i];\n }\n const vEnd = vRe.exec(vLast);\n if (vCount !== 1 || vEnd === null)\n return 'invalid';\n return vEnd[1] === 'PASS' ? 'pass' : 'fail';\n}\nfunction joinRegion(results, o) {\n const policy = o && o.policy ? o.policy : 'all-activated';\n const failures = [];\n for (let i = 0; i < results.length; i++) {\n if (results[i] === null || results[i] === undefined)\n failures.push(i);\n }\n if (policy === 'any') {\n if (failures.length === results.length)\n throw new Error('join ' + o.region + ': every branch failed (policy any)');\n return { ok: true, values: results, failures: failures };\n }\n const quorum = /^quorum:([1-9][0-9]*)$/.exec(policy);\n if (quorum) {\n const okN = results.length - failures.length;\n if (okN < Number(quorum[1]))\n throw new Error('join ' + o.region + ': quorum ' + quorum[1] + ' not met (' + okN + ' ok)');\n return { ok: true, values: results, failures: failures };\n }\n if (failures.length > 0)\n throw new Error('join ' + o.region + ': ' + failures.length + ' dispatched branch(es) failed under policy ' + policy + ' — a dispatched branch is never skippable');\n return { ok: true, values: results, failures: [] };\n}",
105
114
  },
106
115
  "ha-consult-router": {
107
116
  name: "ha-consult-router",