@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.
- package/.dz-manifest.json +339 -75
- package/README.md +47 -4
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +39 -2
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks-verify.d.ts +23 -2
- package/dist/codex-hooks-verify.d.ts.map +1 -1
- package/dist/codex-hooks-verify.js +29 -0
- package/dist/codex-hooks-verify.js.map +1 -1
- package/dist/codex-hooks.d.ts +90 -7
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +171 -21
- package/dist/codex-hooks.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +22 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +45 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -2
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-plan.d.ts +70 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +103 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +38 -130
- package/dist/loop-render.js.map +1 -1
- package/dist/loop-run-semantics.d.ts +130 -0
- package/dist/loop-run-semantics.d.ts.map +1 -0
- package/dist/loop-run-semantics.js +257 -0
- package/dist/loop-run-semantics.js.map +1 -0
- package/dist/loop-trace.d.ts +106 -5
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +151 -18
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +10 -0
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +17 -5
- package/dist/managed-hooks.js.map +1 -1
- package/dist/named-lock.d.ts +57 -0
- package/dist/named-lock.d.ts.map +1 -0
- package/dist/named-lock.js +247 -0
- package/dist/named-lock.js.map +1 -0
- package/dist/operations.d.ts +81 -5
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +356 -38
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +70 -2
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +133 -2
- package/dist/parity.js.map +1 -1
- package/dist/qe-bridge.d.ts +291 -0
- package/dist/qe-bridge.d.ts.map +1 -0
- package/dist/qe-bridge.js +538 -0
- package/dist/qe-bridge.js.map +1 -0
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +43 -9
- package/dist/score.js.map +1 -1
- package/dist/trace-corroborate.d.ts +48 -0
- package/dist/trace-corroborate.d.ts.map +1 -0
- package/dist/trace-corroborate.js +172 -0
- package/dist/trace-corroborate.js.map +1 -0
- package/dist/workflow-run-dispatch.d.ts +230 -0
- package/dist/workflow-run-dispatch.d.ts.map +1 -0
- package/dist/workflow-run-dispatch.js +363 -0
- package/dist/workflow-run-dispatch.js.map +1 -0
- package/dist/workflow-run.d.ts +513 -0
- package/dist/workflow-run.d.ts.map +1 -0
- package/dist/workflow-run.js +1377 -0
- package/dist/workflow-run.js.map +1 -0
- package/package.json +2 -2
- package/sbom.json +740 -80
- package/src/codex-hooks-assets.ts +39 -2
- package/src/codex-hooks-verify.ts +55 -2
- package/src/codex-hooks.ts +172 -20
- package/src/feature-adr-routing.ts +55 -0
- package/src/index.ts +46 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-plan.ts +185 -0
- package/src/loop-render.ts +38 -128
- package/src/loop-run-semantics.ts +278 -0
- package/src/loop-trace.ts +207 -16
- package/src/managed-hooks.ts +26 -5
- package/src/named-lock.ts +277 -0
- package/src/operations.ts +441 -40
- package/src/parity.ts +177 -2
- package/src/qe-bridge.ts +737 -0
- package/src/score.ts +50 -9
- package/src/trace-corroborate.ts +205 -0
- package/src/workflow-run-dispatch.ts +459 -0
- 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,
|
|
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,
|
|
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
|
-
/**
|
|
50
|
-
|
|
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;
|
package/src/codex-hooks.ts
CHANGED
|
@@ -28,8 +28,16 @@ import { join } from 'node:path';
|
|
|
28
28
|
|
|
29
29
|
import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
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
|
-
|
|
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 =
|
|
450
|
+
const kept: unknown[] = [];
|
|
451
|
+
for (const entry of entries) {
|
|
406
452
|
if (isDzManagedEntry(entry, manifest)) {
|
|
407
453
|
removed += 1;
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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
|
|
534
|
-
const
|
|
535
|
-
|
|
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
|
|
558
|
-
*
|
|
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
|
|
565
|
-
const
|
|
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
|
-
|
|
568
|
-
|
|
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: "
|
|
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",
|