@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.
Files changed (141) hide show
  1. package/.dz-manifest.json +489 -105
  2. package/README.md +116 -5
  3. package/dist/agents-policy.d.ts +67 -0
  4. package/dist/agents-policy.d.ts.map +1 -0
  5. package/dist/agents-policy.js +258 -0
  6. package/dist/agents-policy.js.map +1 -0
  7. package/dist/codex-hooks-assets.d.ts +47 -0
  8. package/dist/codex-hooks-assets.d.ts.map +1 -0
  9. package/dist/codex-hooks-assets.js +324 -0
  10. package/dist/codex-hooks-assets.js.map +1 -0
  11. package/dist/codex-hooks-verify.d.ts +95 -0
  12. package/dist/codex-hooks-verify.d.ts.map +1 -0
  13. package/dist/codex-hooks-verify.js +169 -0
  14. package/dist/codex-hooks-verify.js.map +1 -0
  15. package/dist/codex-hooks.d.ts +341 -0
  16. package/dist/codex-hooks.d.ts.map +1 -0
  17. package/dist/codex-hooks.js +541 -0
  18. package/dist/codex-hooks.js.map +1 -0
  19. package/dist/discrimination-gate.d.ts +88 -15
  20. package/dist/discrimination-gate.d.ts.map +1 -1
  21. package/dist/discrimination-gate.js +343 -51
  22. package/dist/discrimination-gate.js.map +1 -1
  23. package/dist/feature-adr-checkpoints.d.ts +22 -0
  24. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  25. package/dist/feature-adr-checkpoints.js +42 -0
  26. package/dist/feature-adr-checkpoints.js.map +1 -1
  27. package/dist/feature-adr-routing.d.ts +218 -5
  28. package/dist/feature-adr-routing.d.ts.map +1 -1
  29. package/dist/feature-adr-routing.js +583 -54
  30. package/dist/feature-adr-routing.js.map +1 -1
  31. package/dist/guard.d.ts +13 -0
  32. package/dist/guard.d.ts.map +1 -1
  33. package/dist/guard.js +25 -1
  34. package/dist/guard.js.map +1 -1
  35. package/dist/index.d.ts +23 -7
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +37 -4
  38. package/dist/index.js.map +1 -1
  39. package/dist/loop-blobs.generated.d.ts +1 -1
  40. package/dist/loop-blobs.generated.d.ts.map +1 -1
  41. package/dist/loop-blobs.generated.js +12 -3
  42. package/dist/loop-blobs.generated.js.map +1 -1
  43. package/dist/loop-plan.d.ts +70 -0
  44. package/dist/loop-plan.d.ts.map +1 -1
  45. package/dist/loop-plan.js +103 -0
  46. package/dist/loop-plan.js.map +1 -1
  47. package/dist/loop-render.d.ts.map +1 -1
  48. package/dist/loop-render.js +37 -129
  49. package/dist/loop-render.js.map +1 -1
  50. package/dist/loop-run-semantics.d.ts +130 -0
  51. package/dist/loop-run-semantics.d.ts.map +1 -0
  52. package/dist/loop-run-semantics.js +257 -0
  53. package/dist/loop-run-semantics.js.map +1 -0
  54. package/dist/loop-trace.d.ts +31 -2
  55. package/dist/loop-trace.d.ts.map +1 -1
  56. package/dist/loop-trace.js +89 -12
  57. package/dist/loop-trace.js.map +1 -1
  58. package/dist/managed-hooks.d.ts +86 -0
  59. package/dist/managed-hooks.d.ts.map +1 -0
  60. package/dist/managed-hooks.js +101 -0
  61. package/dist/managed-hooks.js.map +1 -0
  62. package/dist/mutation-gate.d.ts +14 -0
  63. package/dist/mutation-gate.d.ts.map +1 -1
  64. package/dist/mutation-gate.js +25 -2
  65. package/dist/mutation-gate.js.map +1 -1
  66. package/dist/named-lock.d.ts +57 -0
  67. package/dist/named-lock.d.ts.map +1 -0
  68. package/dist/named-lock.js +247 -0
  69. package/dist/named-lock.js.map +1 -0
  70. package/dist/operations.d.ts +229 -0
  71. package/dist/operations.d.ts.map +1 -1
  72. package/dist/operations.js +880 -26
  73. package/dist/operations.js.map +1 -1
  74. package/dist/parity.d.ts +106 -1
  75. package/dist/parity.d.ts.map +1 -1
  76. package/dist/parity.js +209 -5
  77. package/dist/parity.js.map +1 -1
  78. package/dist/qe-bridge.d.ts +291 -0
  79. package/dist/qe-bridge.d.ts.map +1 -0
  80. package/dist/qe-bridge.js +538 -0
  81. package/dist/qe-bridge.js.map +1 -0
  82. package/dist/recall-usage.d.ts +53 -0
  83. package/dist/recall-usage.d.ts.map +1 -1
  84. package/dist/recall-usage.js +125 -2
  85. package/dist/recall-usage.js.map +1 -1
  86. package/dist/score.d.ts.map +1 -1
  87. package/dist/score.js +43 -9
  88. package/dist/score.js.map +1 -1
  89. package/dist/setup.d.ts.map +1 -1
  90. package/dist/setup.js +14 -26
  91. package/dist/setup.js.map +1 -1
  92. package/dist/shell-veto-policy.d.ts +53 -0
  93. package/dist/shell-veto-policy.d.ts.map +1 -0
  94. package/dist/shell-veto-policy.js +103 -0
  95. package/dist/shell-veto-policy.js.map +1 -0
  96. package/dist/skills.d.ts +86 -1
  97. package/dist/skills.d.ts.map +1 -1
  98. package/dist/skills.js +116 -1
  99. package/dist/skills.js.map +1 -1
  100. package/dist/targets.d.ts +75 -0
  101. package/dist/targets.d.ts.map +1 -1
  102. package/dist/targets.js +160 -0
  103. package/dist/targets.js.map +1 -1
  104. package/dist/workflow-run-dispatch.d.ts +230 -0
  105. package/dist/workflow-run-dispatch.d.ts.map +1 -0
  106. package/dist/workflow-run-dispatch.js +363 -0
  107. package/dist/workflow-run-dispatch.js.map +1 -0
  108. package/dist/workflow-run.d.ts +498 -0
  109. package/dist/workflow-run.d.ts.map +1 -0
  110. package/dist/workflow-run.js +1359 -0
  111. package/dist/workflow-run.js.map +1 -0
  112. package/package.json +6 -5
  113. package/sbom.json +1099 -139
  114. package/src/agents-policy.ts +338 -0
  115. package/src/codex-hooks-assets.ts +328 -0
  116. package/src/codex-hooks-verify.ts +237 -0
  117. package/src/codex-hooks.ts +723 -0
  118. package/src/discrimination-gate.ts +456 -58
  119. package/src/feature-adr-checkpoints.ts +38 -0
  120. package/src/feature-adr-routing.ts +697 -75
  121. package/src/guard.ts +36 -1
  122. package/src/index.ts +162 -2
  123. package/src/loop-blobs.generated.ts +12 -3
  124. package/src/loop-plan.ts +185 -0
  125. package/src/loop-render.ts +37 -127
  126. package/src/loop-run-semantics.ts +278 -0
  127. package/src/loop-trace.ts +84 -10
  128. package/src/managed-hooks.ts +150 -0
  129. package/src/mutation-gate.ts +24 -2
  130. package/src/named-lock.ts +277 -0
  131. package/src/operations.ts +1121 -29
  132. package/src/parity.ts +295 -6
  133. package/src/qe-bridge.ts +737 -0
  134. package/src/recall-usage.ts +184 -1
  135. package/src/score.ts +50 -9
  136. package/src/setup.ts +26 -27
  137. package/src/shell-veto-policy.ts +119 -0
  138. package/src/skills.ts +174 -1
  139. package/src/targets.ts +189 -0
  140. package/src/workflow-run-dispatch.ts +459 -0
  141. package/src/workflow-run.ts +1743 -0
@@ -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
- return { kind: 'aggregate', dzId: dzId.trim(), reads, firstReadAt, lastReadAt, maxScore, totalScore, compactedAt };
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
- function evidenceLinePositive(text: string, re: RegExp): string | null {
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 (NEGATION_RE.test(line)) continue;
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
- const conf = evidenceLine(adrText, /^##+\s*Confirmation/i);
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 section — the load-bearing property names no test',
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
- const live = evidenceLine(qeText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i);
149
- const liveAnywhere = live ?? evidenceLine(allText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i);
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
- const readme = evidenceLine(qeText + '\n' + manifestText, /README/);
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
- const recalled = evidenceLine(complexityText + '\n' + allText, /LEARNED_PATTERNS|dz recall|recalled/i);
168
- const taught = evidenceLine(allText, /lesson[s]? taught|dz teach|taught \(/i);
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
- const hooks = (existing['hooks'] ?? {}) as Record<string, unknown[]>;
571
- let changed = false;
572
- let replacedLegacy = false;
573
- for (const event of Object.keys(generated.hooks)) {
574
- const current = Array.isArray(hooks[event]) ? hooks[event] : [];
575
- // Drop dz-generated entries (any vintage, either shape) — keep the user's own hooks
576
- // untouched. Flat dz entries (≤0.3.43) are dropped too, migrating them to the valid
577
- // matcher-group shape appended below.
578
- const kept = current.filter((entry) => {
579
- const cmds = commandsOf(entry);
580
- const isDz = cmds.some((cmd) => cmd.includes('agentdb add') || cmd.includes('agentdb-writer.mjs') || cmd.includes('sessions.jsonl'));
581
- const isFlat = !Array.isArray((entry as { hooks?: unknown[] })?.hooks);
582
- if (isDz && (isFlat || cmds.some((cmd) => cmd.includes('agentdb add')))) replacedLegacy = true;
583
- return !isDz;
584
- });
585
- const next = [...kept, ...generated.hooks[event]!];
586
- if (JSON.stringify(next) !== JSON.stringify(current)) changed = true;
587
- hooks[event] = next;
588
- }
589
- if (changed) {
590
- existing['hooks'] = hooks;
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: 'hooks already current' });
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
+ }