@dzhechkov/harness-core 0.4.5 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +489 -105
- package/README.md +116 -5
- package/dist/agents-policy.d.ts +67 -0
- package/dist/agents-policy.d.ts.map +1 -0
- package/dist/agents-policy.js +258 -0
- package/dist/agents-policy.js.map +1 -0
- package/dist/codex-hooks-assets.d.ts +47 -0
- package/dist/codex-hooks-assets.d.ts.map +1 -0
- package/dist/codex-hooks-assets.js +324 -0
- package/dist/codex-hooks-assets.js.map +1 -0
- package/dist/codex-hooks-verify.d.ts +95 -0
- package/dist/codex-hooks-verify.d.ts.map +1 -0
- package/dist/codex-hooks-verify.js +169 -0
- package/dist/codex-hooks-verify.js.map +1 -0
- package/dist/codex-hooks.d.ts +341 -0
- package/dist/codex-hooks.d.ts.map +1 -0
- package/dist/codex-hooks.js +541 -0
- package/dist/codex-hooks.js.map +1 -0
- package/dist/discrimination-gate.d.ts +88 -15
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +343 -51
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +22 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +42 -0
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +218 -5
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +583 -54
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +13 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +25 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +23 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -4
- 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 +37 -129
- 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 +31 -2
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +89 -12
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +86 -0
- package/dist/managed-hooks.d.ts.map +1 -0
- package/dist/managed-hooks.js +101 -0
- package/dist/managed-hooks.js.map +1 -0
- package/dist/mutation-gate.d.ts +14 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +25 -2
- package/dist/mutation-gate.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 +229 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +880 -26
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +106 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +209 -5
- 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/recall-usage.d.ts +53 -0
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +125 -2
- package/dist/recall-usage.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +43 -9
- package/dist/score.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +14 -26
- package/dist/setup.js.map +1 -1
- package/dist/shell-veto-policy.d.ts +53 -0
- package/dist/shell-veto-policy.d.ts.map +1 -0
- package/dist/shell-veto-policy.js +103 -0
- package/dist/shell-veto-policy.js.map +1 -0
- package/dist/skills.d.ts +86 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +116 -1
- package/dist/skills.js.map +1 -1
- package/dist/targets.d.ts +75 -0
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +160 -0
- package/dist/targets.js.map +1 -1
- 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 +498 -0
- package/dist/workflow-run.d.ts.map +1 -0
- package/dist/workflow-run.js +1359 -0
- package/dist/workflow-run.js.map +1 -0
- package/package.json +6 -5
- package/sbom.json +1099 -139
- package/src/agents-policy.ts +338 -0
- package/src/codex-hooks-assets.ts +328 -0
- package/src/codex-hooks-verify.ts +237 -0
- package/src/codex-hooks.ts +723 -0
- package/src/discrimination-gate.ts +456 -58
- package/src/feature-adr-checkpoints.ts +38 -0
- package/src/feature-adr-routing.ts +697 -75
- package/src/guard.ts +36 -1
- package/src/index.ts +162 -2
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-plan.ts +185 -0
- package/src/loop-render.ts +37 -127
- package/src/loop-run-semantics.ts +278 -0
- package/src/loop-trace.ts +84 -10
- package/src/managed-hooks.ts +150 -0
- package/src/mutation-gate.ts +24 -2
- package/src/named-lock.ts +277 -0
- package/src/operations.ts +1121 -29
- package/src/parity.ts +295 -6
- package/src/qe-bridge.ts +737 -0
- package/src/recall-usage.ts +184 -1
- package/src/score.ts +50 -9
- package/src/setup.ts +26 -27
- package/src/shell-veto-policy.ts +119 -0
- package/src/skills.ts +174 -1
- package/src/targets.ts +189 -0
- package/src/workflow-run-dispatch.ts +459 -0
- package/src/workflow-run.ts +1743 -0
package/src/recall-usage.ts
CHANGED
|
@@ -13,13 +13,26 @@
|
|
|
13
13
|
* @packageDocumentation
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
// STATIC node: imports, deliberately (2026-08-19). A deferred `require('node:fs')` here compiled
|
|
17
|
+
// into an ESM dist, where `require` is NOT in scope: every call from the emitted `.cjs` hook threw
|
|
18
|
+
// ReferenceError into `appendRecallUsage`'s own catch and returned **0 rows appended, silently**.
|
|
19
|
+
// The Codex recall leg looked wired and correctly-silent for exactly the reason AM-4's forced-hit
|
|
20
|
+
// canary exists to expose. Importing node: modules at the top costs nothing — they are built in.
|
|
21
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
|
|
16
24
|
import {
|
|
25
|
+
EMPTY_LOG_TAIL,
|
|
17
26
|
EVENT_CHAIN_FIELD_OVERHEAD_BYTES,
|
|
18
27
|
EVENT_CHAIN_LEDGER_KIND,
|
|
28
|
+
EVENT_CHAIN_TAIL_BYTES,
|
|
29
|
+
appendChainedLines,
|
|
19
30
|
chainRewrite,
|
|
20
31
|
defaultEventWeight,
|
|
32
|
+
readTailInfo,
|
|
21
33
|
verifyEventChainText,
|
|
22
34
|
type EventChainDefect,
|
|
35
|
+
type LogTail,
|
|
23
36
|
} from './event-chain.js';
|
|
24
37
|
|
|
25
38
|
export const RECALL_USAGE_LOG_RELATIVE = '.dz/recall-usage.jsonl';
|
|
@@ -45,6 +58,35 @@ export interface RecallUsageReadRecord {
|
|
|
45
58
|
readonly eventId?: string;
|
|
46
59
|
/** True when the stored query is a PREFIX of the real prompt — not replayable. */
|
|
47
60
|
readonly queryTruncated?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* WHICH HOST injected the lesson (ADR-003 §1, H-B additive). Absent ⇒ `claude-code`, so every row
|
|
63
|
+
* written before the Codex leg keeps its meaning without a migration.
|
|
64
|
+
*/
|
|
65
|
+
readonly runtime?: Runtime;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The hosts that run the apply leg. Additive by design: an old reader ignores the field. */
|
|
69
|
+
export type Runtime = 'claude-code' | 'codex';
|
|
70
|
+
|
|
71
|
+
export const RUNTIMES: readonly Runtime[] = ['claude-code', 'codex'];
|
|
72
|
+
|
|
73
|
+
/** A row without `runtime` predates the Codex leg and is Claude Code's by construction. */
|
|
74
|
+
export function runtimeOf(record: RecallUsageReadRecord): Runtime {
|
|
75
|
+
return record.runtime ?? 'claude-code';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeRuntime(value: unknown): Runtime | undefined {
|
|
79
|
+
return value === 'claude-code' || value === 'codex' ? value : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeRuntimes(value: unknown): readonly Runtime[] | undefined {
|
|
83
|
+
if (!Array.isArray(value)) return undefined;
|
|
84
|
+
const set = new Set<Runtime>();
|
|
85
|
+
for (const v of value) {
|
|
86
|
+
const r = normalizeRuntime(v);
|
|
87
|
+
if (r !== undefined) set.add(r);
|
|
88
|
+
}
|
|
89
|
+
return set.size === 0 ? undefined : [...set].sort();
|
|
48
90
|
}
|
|
49
91
|
|
|
50
92
|
/** Query text is capped so a pasted wall of text cannot bloat the log. */
|
|
@@ -59,6 +101,21 @@ export interface RecallUsageAggregateRecord {
|
|
|
59
101
|
readonly maxScore: number;
|
|
60
102
|
readonly totalScore: number;
|
|
61
103
|
readonly compactedAt: string;
|
|
104
|
+
/**
|
|
105
|
+
* The SET UNION of the runtimes whose reads were folded into this record (sorted, deduped).
|
|
106
|
+
*
|
|
107
|
+
* AM-27, and the whole reason it exists: `compactVerifiedRecallUsageLog` keeps verbatim only the
|
|
108
|
+
* newest {@link RECALL_USAGE_REPLAY_KEEP} read rows that carry a `query`. Everything else — a
|
|
109
|
+
* *fresh* row without `query` included — is folded into this record, whose merge key is `dzId`
|
|
110
|
+
* alone. The allowlist on the READ record therefore never touches the path where provenance is
|
|
111
|
+
* actually lost. The union does.
|
|
112
|
+
*
|
|
113
|
+
* Residual accepted loss, registered in `architecture/degradations.md`: this says WHICH runtimes
|
|
114
|
+
* used a lesson, not HOW OFTEN each did. Widening the merge key to `(dzId, runtime)` would split
|
|
115
|
+
* the stats rows `buildRecallUsageReport` looks up by `dzId` alone — a cross-feature blast radius
|
|
116
|
+
* this leg has no mandate for.
|
|
117
|
+
*/
|
|
118
|
+
readonly runtimes?: readonly Runtime[];
|
|
62
119
|
}
|
|
63
120
|
|
|
64
121
|
export type RecallUsageRecord = RecallUsageReadRecord | RecallUsageAggregateRecord;
|
|
@@ -76,6 +133,8 @@ export interface RecallUsageStat {
|
|
|
76
133
|
readonly lastReadAt: string;
|
|
77
134
|
readonly maxScore: number;
|
|
78
135
|
readonly avgScore: number;
|
|
136
|
+
/** Set union of contributing runtimes (AM-27). Empty ⇒ nothing carried provenance. */
|
|
137
|
+
readonly runtimes: readonly Runtime[];
|
|
79
138
|
}
|
|
80
139
|
|
|
81
140
|
export interface RecallPatternUsageRef {
|
|
@@ -115,6 +174,7 @@ interface Acc {
|
|
|
115
174
|
lastMs: number;
|
|
116
175
|
maxScore: number;
|
|
117
176
|
totalScore: number;
|
|
177
|
+
runtimes: Set<Runtime>;
|
|
118
178
|
}
|
|
119
179
|
|
|
120
180
|
export interface RecallUsageRecordInput {
|
|
@@ -125,6 +185,7 @@ export interface RecallUsageRecordInput {
|
|
|
125
185
|
readonly runId?: unknown;
|
|
126
186
|
readonly eventId?: unknown;
|
|
127
187
|
readonly queryTruncated?: unknown;
|
|
188
|
+
readonly runtime?: unknown;
|
|
128
189
|
}
|
|
129
190
|
|
|
130
191
|
/**
|
|
@@ -182,6 +243,7 @@ export function aggregateRecallUsage(records: readonly RecallUsageRecord[]): rea
|
|
|
182
243
|
lastReadAt: a.lastReadAt,
|
|
183
244
|
maxScore: a.maxScore,
|
|
184
245
|
avgScore: a.totalScore / a.reads,
|
|
246
|
+
runtimes: [...a.runtimes].sort(),
|
|
185
247
|
}))
|
|
186
248
|
.sort(compareStats);
|
|
187
249
|
}
|
|
@@ -397,6 +459,10 @@ function normalizeReadRecord(value: unknown): RecallUsageReadRecord | undefined
|
|
|
397
459
|
...(typeof runId === 'string' && runId.trim() !== '' ? { runId: runId.trim() } : {}),
|
|
398
460
|
...(typeof value['eventId'] === 'string' && (value['eventId'] as string).trim() !== '' ? { eventId: (value['eventId'] as string).trim() } : {}),
|
|
399
461
|
...(value['queryTruncated'] === true ? { queryTruncated: true } : {}),
|
|
462
|
+
// ALLOWLIST (AM-20): the normaliser is an allowlist and `compactVerifiedRecallUsageLog`
|
|
463
|
+
// re-serialises every retained row through it — a non-allowlisted field dies at the first
|
|
464
|
+
// compaction. Necessary, but NOT sufficient: see `runtimes` on the aggregate (AM-27).
|
|
465
|
+
...(normalizeRuntime(value['runtime']) !== undefined ? { runtime: normalizeRuntime(value['runtime'])! } : {}),
|
|
400
466
|
};
|
|
401
467
|
}
|
|
402
468
|
|
|
@@ -413,7 +479,18 @@ function normalizeAggregateRecord(value: Record<string, unknown>): RecallUsageAg
|
|
|
413
479
|
if (!validTs(firstReadAt) || !validTs(lastReadAt) || !validTs(compactedAt)) return undefined;
|
|
414
480
|
if (typeof maxScore !== 'number' || !Number.isFinite(maxScore)) return undefined;
|
|
415
481
|
if (typeof totalScore !== 'number' || !Number.isFinite(totalScore)) return undefined;
|
|
416
|
-
|
|
482
|
+
const runtimes = normalizeRuntimes(value['runtimes']);
|
|
483
|
+
return {
|
|
484
|
+
kind: 'aggregate',
|
|
485
|
+
dzId: dzId.trim(),
|
|
486
|
+
reads,
|
|
487
|
+
firstReadAt,
|
|
488
|
+
lastReadAt,
|
|
489
|
+
maxScore,
|
|
490
|
+
totalScore,
|
|
491
|
+
compactedAt,
|
|
492
|
+
...(runtimes !== undefined ? { runtimes } : {}),
|
|
493
|
+
};
|
|
417
494
|
}
|
|
418
495
|
|
|
419
496
|
function mergeRead(byId: Map<string, Acc>, rec: RecallUsageReadRecord): void {
|
|
@@ -429,9 +506,11 @@ function mergeRead(byId: Map<string, Acc>, rec: RecallUsageReadRecord): void {
|
|
|
429
506
|
lastMs: ms,
|
|
430
507
|
maxScore: rec.score,
|
|
431
508
|
totalScore: rec.score,
|
|
509
|
+
runtimes: new Set<Runtime>([runtimeOf(rec)]),
|
|
432
510
|
});
|
|
433
511
|
return;
|
|
434
512
|
}
|
|
513
|
+
prev.runtimes.add(runtimeOf(rec));
|
|
435
514
|
prev.reads += 1;
|
|
436
515
|
prev.totalScore += rec.score;
|
|
437
516
|
prev.maxScore = Math.max(prev.maxScore, rec.score);
|
|
@@ -459,9 +538,11 @@ function mergeAggregate(byId: Map<string, Acc>, rec: RecallUsageAggregateRecord)
|
|
|
459
538
|
lastMs,
|
|
460
539
|
maxScore: rec.maxScore,
|
|
461
540
|
totalScore: rec.totalScore,
|
|
541
|
+
runtimes: new Set<Runtime>(rec.runtimes ?? []),
|
|
462
542
|
});
|
|
463
543
|
return;
|
|
464
544
|
}
|
|
545
|
+
for (const r of rec.runtimes ?? []) prev.runtimes.add(r);
|
|
465
546
|
prev.reads += rec.reads;
|
|
466
547
|
prev.totalScore += rec.totalScore;
|
|
467
548
|
prev.maxScore = Math.max(prev.maxScore, rec.maxScore);
|
|
@@ -485,6 +566,9 @@ function aggregateRecord(stat: RecallUsageStat, compactedAt: string): RecallUsag
|
|
|
485
566
|
maxScore: stat.maxScore,
|
|
486
567
|
totalScore: stat.avgScore * stat.reads,
|
|
487
568
|
compactedAt,
|
|
569
|
+
// AM-27's mutant: dropping this union silently destroys per-runtime provenance at the first
|
|
570
|
+
// compaction, and the READ-record allowlist cannot save it because this path never sees one.
|
|
571
|
+
...(stat.runtimes.length > 0 ? { runtimes: [...stat.runtimes].sort() } : {}),
|
|
488
572
|
};
|
|
489
573
|
}
|
|
490
574
|
|
|
@@ -535,3 +619,102 @@ function joinLines(lines: readonly string[]): string {
|
|
|
535
619
|
function byteLength(text: string): number {
|
|
536
620
|
return text.length;
|
|
537
621
|
}
|
|
622
|
+
|
|
623
|
+
/* ========================================================================== */
|
|
624
|
+
/* The SHARED writer (`crossrt-2-codex-hooks`, ADR-003 · AM-6/AM-7) */
|
|
625
|
+
/* ========================================================================== */
|
|
626
|
+
/*
|
|
627
|
+
* Everything above this line is pure. What follows is the ONE writer both hosts call, and it is
|
|
628
|
+
* here rather than in a helper because the alternative — a second inline appender per runtime — is
|
|
629
|
+
* exactly the drift AM-7 forbids: the Claude helper had the only implementation for 19 days and the
|
|
630
|
+
* Codex leg could not exist without either importing it or copying it.
|
|
631
|
+
*
|
|
632
|
+
* It NEVER throws. A hook that dies on a logging failure costs the user their turn, and the log is
|
|
633
|
+
* telemetry: losing a row is a smaller harm than losing a prompt.
|
|
634
|
+
*/
|
|
635
|
+
|
|
636
|
+
export interface AppendRecallUsageHit {
|
|
637
|
+
readonly dzId: unknown;
|
|
638
|
+
readonly score: unknown;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export interface AppendRecallUsageInput {
|
|
642
|
+
/** Project ROOT (the helper walks up to it; a cwd-relative writer splits the log — AM-5). */
|
|
643
|
+
readonly projectRoot: string;
|
|
644
|
+
readonly hits: readonly AppendRecallUsageHit[];
|
|
645
|
+
/** The host that injected. Omitted ⇒ `claude-code`, matching every pre-Codex row. */
|
|
646
|
+
readonly runtime?: Runtime;
|
|
647
|
+
readonly query?: string | undefined;
|
|
648
|
+
readonly runId?: string | undefined;
|
|
649
|
+
/** One id per PROMPT. Generated when absent so a multi-hit prompt still counts as one event. */
|
|
650
|
+
readonly eventId?: string | undefined;
|
|
651
|
+
readonly now?: string;
|
|
652
|
+
/** Test seam. Production leaves it unset and the path is derived from `projectRoot`. */
|
|
653
|
+
readonly logPath?: string;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Append one prompt's injected hits as CHAINED rows.
|
|
658
|
+
*
|
|
659
|
+
* @returns the number of rows appended; **0** on any failure (unwritable directory, unreadable
|
|
660
|
+
* tail, empty input). Never throws.
|
|
661
|
+
*/
|
|
662
|
+
export function appendRecallUsage(input: AppendRecallUsageInput): number {
|
|
663
|
+
try {
|
|
664
|
+
const hits = Array.isArray(input?.hits) ? input.hits : [];
|
|
665
|
+
if (hits.length === 0) return 0;
|
|
666
|
+
const ts = validTs(input.now) ? input.now : new Date().toISOString();
|
|
667
|
+
const eventId =
|
|
668
|
+
typeof input.eventId === 'string' && input.eventId.trim() !== ''
|
|
669
|
+
? input.eventId.trim()
|
|
670
|
+
: `${ts}:${Math.random().toString(36).slice(2, 10)}`;
|
|
671
|
+
const full = typeof input.query === 'string' ? input.query.trim() : '';
|
|
672
|
+
const query = full !== '' ? full.slice(0, RECALL_USAGE_QUERY_MAX_CHARS) : undefined;
|
|
673
|
+
const queryTruncated = full.length > RECALL_USAGE_QUERY_MAX_CHARS ? true : undefined;
|
|
674
|
+
|
|
675
|
+
const records: RecallUsageReadRecord[] = [];
|
|
676
|
+
for (const hit of hits) {
|
|
677
|
+
const rec = normalizeReadRecord({
|
|
678
|
+
dzId: hit?.dzId,
|
|
679
|
+
score: hit?.score,
|
|
680
|
+
ts,
|
|
681
|
+
query,
|
|
682
|
+
runId: input.runId,
|
|
683
|
+
eventId,
|
|
684
|
+
queryTruncated,
|
|
685
|
+
runtime: input.runtime ?? 'claude-code',
|
|
686
|
+
});
|
|
687
|
+
if (rec !== undefined) records.push(rec);
|
|
688
|
+
}
|
|
689
|
+
if (records.length === 0) return 0;
|
|
690
|
+
|
|
691
|
+
const logPath = input.logPath ?? join(input.projectRoot, ...RECALL_USAGE_LOG_RELATIVE.split('/'));
|
|
692
|
+
const payload = appendChainedLines(records, readLogTailSync(logPath));
|
|
693
|
+
if (payload === '') return 0;
|
|
694
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
695
|
+
appendFileSync(logPath, payload, 'utf-8');
|
|
696
|
+
return records.length;
|
|
697
|
+
} catch {
|
|
698
|
+
return 0; // never-block outranks telemetry completeness
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Read the chain tail. An UNREADABLE tail is a torn tail, never an empty one (event-chain AM-6). */
|
|
703
|
+
function readLogTailSync(file: string): LogTail {
|
|
704
|
+
try {
|
|
705
|
+
if (!existsSync(file)) return EMPTY_LOG_TAIL;
|
|
706
|
+
const fd = openSync(file, 'r');
|
|
707
|
+
try {
|
|
708
|
+
const size = fstatSync(fd).size;
|
|
709
|
+
if (!Number.isFinite(size) || size <= 0) return EMPTY_LOG_TAIL;
|
|
710
|
+
const want = Math.min(size, EVENT_CHAIN_TAIL_BYTES);
|
|
711
|
+
const buf = Buffer.alloc(want);
|
|
712
|
+
readSync(fd, buf, 0, want, size - want);
|
|
713
|
+
return readTailInfo(buf.toString('utf-8'), { partial: want < size });
|
|
714
|
+
} finally {
|
|
715
|
+
try { closeSync(fd); } catch { /* nothing to do */ }
|
|
716
|
+
}
|
|
717
|
+
} catch {
|
|
718
|
+
return EMPTY_LOG_TAIL;
|
|
719
|
+
}
|
|
720
|
+
}
|
package/src/score.ts
CHANGED
|
@@ -64,15 +64,28 @@ function evidenceLine(text: string, re: RegExp): string | null {
|
|
|
64
64
|
* heuristic table). A line whose match is preceded by a negation word is skipped. Heuristic — but
|
|
65
65
|
* the failure mode flips from a silent false pass to a visible miss the shown evidence exposes.
|
|
66
66
|
*/
|
|
67
|
+
// The DEFAULT vocabulary — verbs and determiners that deny the sentence they sit in.
|
|
67
68
|
const NEGATION_RE = /\b(no|not|never|without|wasn'?t|isn'?t)\b/i;
|
|
68
|
-
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The default vocabulary plus the negative QUANTIFIERS. Opt-in per site, because a quantifier
|
|
72
|
+
* negates a NOUN, not the claim: "None of the mutants survived; discrimination §42 is proven by the
|
|
73
|
+
* red run" is idiomatic POSITIVE evidence that the wide list silently discarded (QE B-F2 —
|
|
74
|
+
* negating-the-mutants is not negating-the-proof). It is passed only where a red test demanded it:
|
|
75
|
+
* "Nothing was MEASURED in this round" scored as proof of measurement, because the word boundary in
|
|
76
|
+
* `\bno\b` does NOT match "Nothing". Hedges like "skipped" stay out of both lists — they routinely
|
|
77
|
+
* appear inside genuine evidence lines.
|
|
78
|
+
*/
|
|
79
|
+
const NEGATION_QUANTIFIED_RE = /\b(no|not|never|without|nothing|none|neither|nor|nobody|wasn'?t|isn'?t)\b/i;
|
|
80
|
+
|
|
81
|
+
function evidenceLinePositive(text: string, re: RegExp, negationRe: RegExp = NEGATION_RE): string | null {
|
|
69
82
|
for (const line of text.split('\n')) {
|
|
70
83
|
if (!re.test(line)) continue;
|
|
71
84
|
// Whole-line negation: "Codex was NOT used" carries its negation AFTER the match, so a
|
|
72
85
|
// before-the-match check missed it. The trade is deliberate: a genuine line that happens to
|
|
73
86
|
// contain a negation is SKIPPED (a visible miss the evidence exposes) rather than a negated
|
|
74
87
|
// line being ACCEPTED (a silent false pass).
|
|
75
|
-
if (
|
|
88
|
+
if (negationRe.test(line)) continue;
|
|
76
89
|
return line.trim().slice(0, 140);
|
|
77
90
|
}
|
|
78
91
|
return null;
|
|
@@ -104,16 +117,25 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
|
|
|
104
117
|
if (adrText === '') {
|
|
105
118
|
add('adr-confirmation', 'ADR present, property → named test', 'absent', 'no 03_adr/*.md artifact');
|
|
106
119
|
} else {
|
|
107
|
-
|
|
120
|
+
// POSITIVE (QE B-F2 reversed my first call, which exempted this site as "structural"). The
|
|
121
|
+
// heading regex allows a SUFFIX, so `## Confirmation — not yet performed` — the realistic
|
|
122
|
+
// placeholder an unfinished ADR carries — scored a full PASS. Heading presence is structural;
|
|
123
|
+
// heading TEXT is not, and this one can deny itself. Default (narrow) vocabulary: a heading is
|
|
124
|
+
// a fragment, and the quantifiers only appear in prose. Pinned both ways by tests.
|
|
125
|
+
const conf = evidenceLinePositive(adrText, /^##+\s*Confirmation/i);
|
|
108
126
|
add(
|
|
109
127
|
'adr-confirmation',
|
|
110
128
|
'ADR present, property → named test',
|
|
111
129
|
conf !== null ? 'pass' : 'partial',
|
|
112
|
-
conf ?? 'ADR exists but has no Confirmation
|
|
130
|
+
conf ?? 'ADR exists but has no (non-negated) Confirmation heading — the load-bearing property names no test',
|
|
113
131
|
);
|
|
114
132
|
}
|
|
115
133
|
|
|
116
134
|
// 2. Discrimination — proof the test can FAIL (the §42 gate, or an explicit mutation proof).
|
|
135
|
+
// Default (narrow) vocabulary ON PURPOSE (QE B-F2): mutation evidence is written by negating the
|
|
136
|
+
// MUTANTS — "None of the mutants survived", "neither mutant escaped" — which is the proof, not
|
|
137
|
+
// its denial. The quantifiers would discard exactly the strongest lines this discipline exists
|
|
138
|
+
// to find. "No discrimination proof was performed" is still caught by the narrow list.
|
|
117
139
|
const discr =
|
|
118
140
|
evidenceLinePositive(allText, /discrimination|§42/i) ??
|
|
119
141
|
evidenceLinePositive(allText, /mutation[s]?\s.*(prov|kill)|mutant[s]?\s.*(kill|red)|RED on the old|goes? RED|failed as expected/i);
|
|
@@ -130,6 +152,13 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
|
|
|
130
152
|
add('cross-model-qe', 'independent cross-model review with a grade', 'absent', 'no 08_qe_report.md artifact');
|
|
131
153
|
} else {
|
|
132
154
|
const crossLine = evidenceLinePositive(qeText, /codex|gpt-|cross-model/i);
|
|
155
|
+
// EXEMPT from the negation filter (wave1-scorer-negation, per-site review): this line is a
|
|
156
|
+
// DISPLAY LOCATOR, not a verdict input — the verdict above rests on `crossLine` (already
|
|
157
|
+
// positive-filtered) AND on `grade`, parsed from the whole report. A letter grade is a
|
|
158
|
+
// structural token ("Grade: D"); there is no idiomatic "no Grade: D". Filtering here would only
|
|
159
|
+
// drop the most common real grade line ("**Grade: B** — no blockers remain") from the shown
|
|
160
|
+
// evidence for zero change in verdict. Pinned by a test. Residual, flagged not hidden:
|
|
161
|
+
// `extractQeGrade` itself is negation-blind and stays so — out of FR-B1's scope.
|
|
133
162
|
const gradeLine = grade !== null ? evidenceLine(qeText, GRADE_RE) : null;
|
|
134
163
|
add(
|
|
135
164
|
'cross-model-qe',
|
|
@@ -145,8 +174,11 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
|
|
|
145
174
|
|
|
146
175
|
// 4. Live verification — the property was observed, not inferred. The health-advisor 1.2.0 QE
|
|
147
176
|
// report is the cautionary case: "✅ (mechanism)" with no live evidence shipped a dead feature.
|
|
148
|
-
|
|
149
|
-
|
|
177
|
+
// POSITIVE (wave1-scorer-negation): "nothing was MEASURED" / "no reproducer was run" is the
|
|
178
|
+
// claim's exact opposite and used to score as proof of it (the crossrt-1 6/7 shape).
|
|
179
|
+
const live = evidenceLinePositive(qeText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i, NEGATION_QUANTIFIED_RE);
|
|
180
|
+
const liveAnywhere =
|
|
181
|
+
live ?? evidenceLinePositive(allText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i, NEGATION_QUANTIFIED_RE);
|
|
150
182
|
add(
|
|
151
183
|
'live-verification',
|
|
152
184
|
'claims verified by running, not by reasoning',
|
|
@@ -155,7 +187,10 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
|
|
|
155
187
|
);
|
|
156
188
|
|
|
157
189
|
// 5. README-first — the docs travelled in the same change.
|
|
158
|
-
|
|
190
|
+
// POSITIVE (wave1-scorer-negation): THE acid-A5 defect. crossrt-1-agents-md scored ✓ here over
|
|
191
|
+
// its own finding "README-first not satisfied — no README was touched": a negation rendered as
|
|
192
|
+
// a checkmark. A README mention is not a README update.
|
|
193
|
+
const readme = evidenceLinePositive(qeText + '\n' + manifestText, /README/, NEGATION_QUANTIFIED_RE);
|
|
159
194
|
add(
|
|
160
195
|
'readme-first',
|
|
161
196
|
'READMEs updated in the same change',
|
|
@@ -164,8 +199,14 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
|
|
|
164
199
|
);
|
|
165
200
|
|
|
166
201
|
// 6. The learning loop — Step-0 recall folded in, Step-8 lessons taught.
|
|
167
|
-
|
|
168
|
-
|
|
202
|
+
// POSITIVE both halves (wave1-scorer-negation): "recall was not performed" and "no lessons were
|
|
203
|
+
// taught (dz teach skipped)" both matched the plain regexes and scored the loop as RUN.
|
|
204
|
+
const recalled = evidenceLinePositive(
|
|
205
|
+
complexityText + '\n' + allText,
|
|
206
|
+
/LEARNED_PATTERNS|dz recall|recalled/i,
|
|
207
|
+
NEGATION_QUANTIFIED_RE,
|
|
208
|
+
);
|
|
209
|
+
const taught = evidenceLinePositive(allText, /lesson[s]? taught|dz teach|taught \(/i, NEGATION_QUANTIFIED_RE);
|
|
169
210
|
add(
|
|
170
211
|
'learning-loop',
|
|
171
212
|
'Step-0 recall used; Step-8 lessons taught',
|
package/src/setup.ts
CHANGED
|
@@ -19,6 +19,8 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node
|
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import { execSync } from 'node:child_process';
|
|
21
21
|
|
|
22
|
+
import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
23
|
+
|
|
22
24
|
/** Memory backend type. */
|
|
23
25
|
export type MemoryBackend = 'jsonl' | 'agentdb';
|
|
24
26
|
|
|
@@ -567,35 +569,32 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
567
569
|
} else {
|
|
568
570
|
try {
|
|
569
571
|
const existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
572
|
+
// ONE merge implementation, shared with the Codex target (AM-3 / G-E). The Claude path's
|
|
573
|
+
// historical SUBSTRING attribution is passed IN verbatim rather than reimplemented, so the
|
|
574
|
+
// emitted bytes, the report tail string and the no-write path all stay identical (AM-37).
|
|
575
|
+
const plan = mergeManagedHookEntries(
|
|
576
|
+
(existing['hooks'] ?? {}) as Record<string, unknown[]>,
|
|
577
|
+
generated.hooks as unknown as Record<string, unknown[]>,
|
|
578
|
+
{
|
|
579
|
+
// Drop dz-generated entries (any vintage, either shape) — keep the user's own hooks
|
|
580
|
+
// untouched. Flat dz entries (≤0.3.43) are dropped too, migrating them to the valid
|
|
581
|
+
// matcher-group shape appended below.
|
|
582
|
+
isManaged: (entry) =>
|
|
583
|
+
commandsOf(entry).some(
|
|
584
|
+
(cmd) => cmd.includes('agentdb add') || cmd.includes('agentdb-writer.mjs') || cmd.includes('sessions.jsonl'),
|
|
585
|
+
),
|
|
586
|
+
isLegacy: (entry) =>
|
|
587
|
+
!Array.isArray((entry as { hooks?: unknown[] })?.hooks) ||
|
|
588
|
+
commandsOf(entry).some((cmd) => cmd.includes('agentdb add')),
|
|
589
|
+
reportLabel: backend,
|
|
590
|
+
},
|
|
591
|
+
);
|
|
592
|
+
if (plan.changed) {
|
|
593
|
+
existing['hooks'] = plan.hooks;
|
|
591
594
|
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
|
592
|
-
steps.push({
|
|
593
|
-
name: 'Configure hooks',
|
|
594
|
-
status: 'done',
|
|
595
|
-
detail: replacedLegacy ? `replaced legacy dz hooks with ${backend} hooks` : `merged ${backend} hooks (user hooks preserved)`,
|
|
596
|
-
});
|
|
595
|
+
steps.push({ name: 'Configure hooks', status: 'done', detail: plan.report });
|
|
597
596
|
} else {
|
|
598
|
-
steps.push({ name: 'Configure hooks', status: 'skipped', detail:
|
|
597
|
+
steps.push({ name: 'Configure hooks', status: 'skipped', detail: plan.report });
|
|
599
598
|
}
|
|
600
599
|
} catch {
|
|
601
600
|
steps.push({ name: 'Configure hooks', status: 'error', detail: 'could not parse existing settings.json — fix it and re-run' });
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell veto policy (`crossrt-2-codex-hooks`, ADR-004 + ADR-005).
|
|
3
|
+
*
|
|
4
|
+
* ONE rule, judged on the raw command string, with no I/O of any kind. The mode — what the CALLER
|
|
5
|
+
* does with a hit — is decided by the caller from project config, never here.
|
|
6
|
+
*
|
|
7
|
+
* Polarity, in one sentence (ADR-004): **a policy hit ⇒ WARN by default, BLOCK only when the
|
|
8
|
+
* project opted in; our own failure ⇒ always ALLOW.**
|
|
9
|
+
*
|
|
10
|
+
* ## Why exactly one rule, and why this one
|
|
11
|
+
*
|
|
12
|
+
* `shell-veto-policy.ts` is NOT a general shell-guardrail engine (plan §0.1's C4 fence). Its
|
|
13
|
+
* charter is a single rule whose violation is **unambiguous**: the command line explicitly asks for
|
|
14
|
+
* WEAKER authentication than the ssh default. Every token below is one a safe invocation never
|
|
15
|
+
* contains, so a hit always means the user deliberately disabled a protection.
|
|
16
|
+
*
|
|
17
|
+
* The withdrawn v1 (`ssh-no-identity`, AM-23) judged the ABSENCE of `-i`/`IdentityFile=`, which
|
|
18
|
+
* blocked `ssh myhost` whenever the identity came from `~/.ssh/config` or `ssh-agent` — the normal
|
|
19
|
+
* secure case — from a user-global registry that reaches every directory on the machine. Absence of
|
|
20
|
+
* a token is not evidence of intent; presence of these four is.
|
|
21
|
+
*
|
|
22
|
+
* `StrictHostKeyChecking=no` and `UserKnownHostsFile=/dev/null` are deliberately NOT rules: they are
|
|
23
|
+
* a real weakening, but CI images use them legitimately, so no unambiguous verdict is available.
|
|
24
|
+
* They are the first candidates for a future rule with its own ADR — **no second rule ships in this
|
|
25
|
+
* leg**.
|
|
26
|
+
*
|
|
27
|
+
* @packageDocumentation
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** What the caller does with a hit. `warn` is the shipped default (ADR-004). */
|
|
31
|
+
export type VetoMode = 'off' | 'warn' | 'block';
|
|
32
|
+
|
|
33
|
+
/** A policy hit. `null` from {@link vetoShellCommand} means allow. */
|
|
34
|
+
export interface VetoHit {
|
|
35
|
+
readonly rule: string;
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The one rule id this leg ships. Exported so tests and the probe cannot drift from it. */
|
|
40
|
+
export const SHELL_VETO_RULE_ID = 'ssh-explicit-auth-weakening';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Shell word boundaries. Both ends of every token are anchored, so `echo "sshpassword"` and
|
|
44
|
+
* `--my-passwordauthentication=yes` cannot hit: a user-global guard that matches on a bare
|
|
45
|
+
* substring is one common word away from a machine-wide outage.
|
|
46
|
+
*/
|
|
47
|
+
const BOUNDARY = String.raw`[\s;&|()'"\`]`;
|
|
48
|
+
const OPT = String.raw`(?:^|${BOUNDARY})-o\s*`;
|
|
49
|
+
const END = String.raw`(?=$|${BOUNDARY})`;
|
|
50
|
+
|
|
51
|
+
/** `-o PasswordAuthentication=yes` — the user turns ON password auth against the ssh default. */
|
|
52
|
+
const PASSWORD_AUTHENTICATION = new RegExp(`${OPT}passwordauthentication\\s*=\\s*yes${END}`, 'i');
|
|
53
|
+
|
|
54
|
+
/** `-o PubkeyAuthentication=no` — the user turns OFF key auth. */
|
|
55
|
+
const PUBKEY_AUTHENTICATION = new RegExp(`${OPT}pubkeyauthentication\\s*=\\s*no${END}`, 'i');
|
|
56
|
+
|
|
57
|
+
/** `-o PreferredAuthentications=…password…` — the user ORDERS password auth ahead of pubkey. */
|
|
58
|
+
const PREFERRED_AUTHENTICATIONS = new RegExp(
|
|
59
|
+
`${OPT}preferredauthentications\\s*=\\s*` +
|
|
60
|
+
`(?:[a-z][a-z0-9-]*\\s*,\\s*)*` +
|
|
61
|
+
`(?:password|keyboard-interactive)` +
|
|
62
|
+
`(?:\\s*,\\s*[a-z][a-z0-9-]*)*${END}`,
|
|
63
|
+
'i',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** `sshpass` as a COMMAND TOKEN — a password is fed to ssh from the command line by construction. */
|
|
67
|
+
const SSHPASS_TOKEN = new RegExp(`(?:^|${BOUNDARY})sshpass${END}`, 'i');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Judge one raw shell command.
|
|
71
|
+
*
|
|
72
|
+
* @returns a {@link VetoHit} when the command EXPLICITLY weakens ssh authentication, else `null`.
|
|
73
|
+
* Pure: no filesystem, no environment, no config, no clock.
|
|
74
|
+
*/
|
|
75
|
+
export function vetoShellCommand(command: string): VetoHit | null {
|
|
76
|
+
if (typeof command !== 'string' || command === '') return null;
|
|
77
|
+
|
|
78
|
+
if (SSHPASS_TOKEN.test(command)) {
|
|
79
|
+
return {
|
|
80
|
+
rule: SHELL_VETO_RULE_ID,
|
|
81
|
+
reason: 'sshpass feeds an ssh password from the command line, disabling key-based auth by construction',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (PASSWORD_AUTHENTICATION.test(command)) {
|
|
85
|
+
return {
|
|
86
|
+
rule: SHELL_VETO_RULE_ID,
|
|
87
|
+
reason: 'PasswordAuthentication=yes turns ON password auth against the ssh default',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (PUBKEY_AUTHENTICATION.test(command)) {
|
|
91
|
+
return {
|
|
92
|
+
rule: SHELL_VETO_RULE_ID,
|
|
93
|
+
reason: 'PubkeyAuthentication=no turns OFF key auth',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (PREFERRED_AUTHENTICATIONS.test(command)) {
|
|
97
|
+
return {
|
|
98
|
+
rule: SHELL_VETO_RULE_ID,
|
|
99
|
+
reason: 'PreferredAuthentications orders password/keyboard-interactive auth ahead of pubkey',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the enforcement mode from a parsed `.dz/config.json`.
|
|
107
|
+
*
|
|
108
|
+
* ABSENT, unknown, non-string, or malformed ⇒ `'warn'`. Never `'block'` by default: fail-closed is
|
|
109
|
+
* a decision the owner makes, not one a user-global hook install imposes on every directory
|
|
110
|
+
* (ADR-004 / AM-24). A mutant that flips this default is registered in the mutation registry.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveVetoMode(projectConfig: unknown): VetoMode {
|
|
113
|
+
if (typeof projectConfig !== 'object' || projectConfig === null) return 'warn';
|
|
114
|
+
const hooks = (projectConfig as Record<string, unknown>)['hooks'];
|
|
115
|
+
if (typeof hooks !== 'object' || hooks === null) return 'warn';
|
|
116
|
+
const mode = (hooks as Record<string, unknown>)['shellVeto'];
|
|
117
|
+
if (mode === 'off' || mode === 'warn' || mode === 'block') return mode;
|
|
118
|
+
return 'warn';
|
|
119
|
+
}
|