@dzhechkov/harness-core 0.4.4 → 0.5.0

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 (97) hide show
  1. package/.dz-manifest.json +283 -103
  2. package/LICENSE +21 -0
  3. package/README.md +73 -5
  4. package/dist/agents-policy.d.ts +67 -0
  5. package/dist/agents-policy.d.ts.map +1 -0
  6. package/dist/agents-policy.js +258 -0
  7. package/dist/agents-policy.js.map +1 -0
  8. package/dist/codex-hooks-assets.d.ts +47 -0
  9. package/dist/codex-hooks-assets.d.ts.map +1 -0
  10. package/dist/codex-hooks-assets.js +287 -0
  11. package/dist/codex-hooks-assets.js.map +1 -0
  12. package/dist/codex-hooks-verify.d.ts +74 -0
  13. package/dist/codex-hooks-verify.d.ts.map +1 -0
  14. package/dist/codex-hooks-verify.js +140 -0
  15. package/dist/codex-hooks-verify.js.map +1 -0
  16. package/dist/codex-hooks.d.ts +258 -0
  17. package/dist/codex-hooks.d.ts.map +1 -0
  18. package/dist/codex-hooks.js +391 -0
  19. package/dist/codex-hooks.js.map +1 -0
  20. package/dist/discrimination-gate.d.ts +88 -15
  21. package/dist/discrimination-gate.d.ts.map +1 -1
  22. package/dist/discrimination-gate.js +343 -51
  23. package/dist/discrimination-gate.js.map +1 -1
  24. package/dist/feature-adr-checkpoints.d.ts +22 -0
  25. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  26. package/dist/feature-adr-checkpoints.js +42 -0
  27. package/dist/feature-adr-checkpoints.js.map +1 -1
  28. package/dist/feature-adr-routing.d.ts +196 -5
  29. package/dist/feature-adr-routing.d.ts.map +1 -1
  30. package/dist/feature-adr-routing.js +538 -54
  31. package/dist/feature-adr-routing.js.map +1 -1
  32. package/dist/guard.d.ts +13 -0
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +25 -1
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +17 -7
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +23 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/loop-blobs.generated.js +2 -2
  41. package/dist/loop-blobs.generated.js.map +1 -1
  42. package/dist/managed-hooks.d.ts +76 -0
  43. package/dist/managed-hooks.d.ts.map +1 -0
  44. package/dist/managed-hooks.js +89 -0
  45. package/dist/managed-hooks.js.map +1 -0
  46. package/dist/mutation-gate.d.ts +14 -0
  47. package/dist/mutation-gate.d.ts.map +1 -1
  48. package/dist/mutation-gate.js +25 -2
  49. package/dist/mutation-gate.js.map +1 -1
  50. package/dist/operations.d.ts +153 -0
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations.js +560 -24
  53. package/dist/operations.js.map +1 -1
  54. package/dist/parity.d.ts +38 -1
  55. package/dist/parity.d.ts.map +1 -1
  56. package/dist/parity.js +78 -5
  57. package/dist/parity.js.map +1 -1
  58. package/dist/recall-usage.d.ts +53 -0
  59. package/dist/recall-usage.d.ts.map +1 -1
  60. package/dist/recall-usage.js +125 -2
  61. package/dist/recall-usage.js.map +1 -1
  62. package/dist/setup.d.ts.map +1 -1
  63. package/dist/setup.js +14 -26
  64. package/dist/setup.js.map +1 -1
  65. package/dist/shell-veto-policy.d.ts +53 -0
  66. package/dist/shell-veto-policy.d.ts.map +1 -0
  67. package/dist/shell-veto-policy.js +103 -0
  68. package/dist/shell-veto-policy.js.map +1 -0
  69. package/dist/skills.d.ts +86 -1
  70. package/dist/skills.d.ts.map +1 -1
  71. package/dist/skills.js +116 -1
  72. package/dist/skills.js.map +1 -1
  73. package/dist/targets.d.ts +75 -0
  74. package/dist/targets.d.ts.map +1 -1
  75. package/dist/targets.js +160 -0
  76. package/dist/targets.js.map +1 -1
  77. package/package.json +20 -19
  78. package/sbom.json +552 -102
  79. package/src/agents-policy.ts +338 -0
  80. package/src/codex-hooks-assets.ts +291 -0
  81. package/src/codex-hooks-verify.ts +184 -0
  82. package/src/codex-hooks.ts +571 -0
  83. package/src/discrimination-gate.ts +456 -58
  84. package/src/feature-adr-checkpoints.ts +38 -0
  85. package/src/feature-adr-routing.ts +642 -75
  86. package/src/guard.ts +36 -1
  87. package/src/index.ts +118 -2
  88. package/src/loop-blobs.generated.ts +2 -2
  89. package/src/managed-hooks.ts +129 -0
  90. package/src/mutation-gate.ts +24 -2
  91. package/src/operations.ts +719 -28
  92. package/src/parity.ts +120 -6
  93. package/src/recall-usage.ts +184 -1
  94. package/src/setup.ts +26 -27
  95. package/src/shell-veto-policy.ts +119 -0
  96. package/src/skills.ts +174 -1
  97. package/src/targets.ts +189 -0
package/src/parity.ts CHANGED
@@ -23,7 +23,13 @@ import type { TargetName } from './targets.js';
23
23
  export type RuntimeCapability =
24
24
  | 'shell' // can run the dz CLI, i.e. a shell WITH Node.js (what `npm i -g` implies on a dev machine)
25
25
  | 'skills' // consumes compiled skills (all adapters emit them; agents-md as one merged file)
26
- | 'hooks' // pre/post tool-call hooks (auto claim-check, auto recall apply-leg)
26
+ // The blanket `hooks` capability is deliberately GONE (AM-21). The matrix is COMPUTED, so one
27
+ // word here moves several cells: granting a target `hooks` promoted BOTH `claim-check` (a
28
+ // GATE_FEATURE_IDS member) and `learning-apply` to `full` in one edit, and a caveat comment
29
+ // cannot move a computed cell back.
30
+ | 'hooks-write' // PreToolUse on FILE writes — what claim-check's automatic form needs
31
+ | 'hooks-shell' // PreToolUse on SHELL commands — what the codex veto leg ships
32
+ | 'hooks-prompt' // UserPromptSubmit — what the auto-recall apply leg ships
27
33
  | 'mcp' // Model Context Protocol client exists on the platform (servers still need configuring)
28
34
  | 'mcp-configured' // our MCP servers (AgentDB / agentic-qe) are wired up out of the box (.mcp.json)
29
35
  | 'workflows' // deterministic multi-agent Workflow runtime (ultracode feature-adr, delivery gate)
@@ -33,7 +39,9 @@ export type RuntimeCapability =
33
39
  export const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [
34
40
  'shell',
35
41
  'skills',
36
- 'hooks',
42
+ 'hooks-write',
43
+ 'hooks-shell',
44
+ 'hooks-prompt',
37
45
  'mcp',
38
46
  'mcp-configured',
39
47
  'workflows',
@@ -47,9 +55,18 @@ export const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [
47
55
  export const TARGET_CAPABILITIES: Record<TargetName, readonly RuntimeCapability[]> = {
48
56
  // Daily-driven harness: hooks (claim-check PreToolUse, recall UserPromptSubmit), MCP servers,
49
57
  // Workflow runtime and the statusline are all exercised in this repo every session.
50
- 'claude-code': ['shell', 'skills', 'hooks', 'mcp', 'mcp-configured', 'workflows', 'statusline'],
58
+ 'claude-code': ['shell', 'skills', 'hooks-write', 'hooks-shell', 'hooks-prompt', 'mcp', 'mcp-configured', 'workflows', 'statusline'],
51
59
  // MCP subsystem probed live: `codex mcp --help` + `codex mcp list` answer (2026-07-19).
52
- codex: ['shell', 'skills', 'mcp'],
60
+ // hooks-shell + hooks-prompt: user-global `$CODEX_HOME/hooks.json`, installed and ARMED
61
+ // unattended, proved by a live two-sided block probe (2026-08-19, codex-cli 0.147.0 — the
62
+ // transcripts named in CAPABILITY_EVIDENCE). `hooks-write` is NOT granted: this leg ships no
63
+ // PreToolUse guard on Write/Edit, so `claim-check` stays `manual` on codex.
64
+ //
65
+ // Fact CORRECTED 2026-08-19: a project-level `<repo>/.codex/hooks.json` DOES load on 0.147.0
66
+ // (`source: "project"`, MEASURED). The earlier note that project-level files are ignored was
67
+ // measured on 0.144.6 and is stale. dz still writes only the user-global registry, but that is
68
+ // now a DECISION (one carrier, one removable unit) rather than a description of the runtime.
69
+ codex: ['shell', 'skills', 'mcp', 'hooks-shell', 'hooks-prompt'],
53
70
  // Conservative v1: skills emission verified by the adapters; richer runtimes unproven.
54
71
  opencode: ['shell', 'skills'],
55
72
  hermes: ['shell', 'skills'],
@@ -62,6 +79,103 @@ export const TARGET_CAPABILITIES: Record<TargetName, readonly RuntimeCapability[
62
79
  windsurf: ['shell', 'skills'],
63
80
  };
64
81
 
82
+ /**
83
+ * WHY each capability grant is believed, as machine-readable DATA (AM-22).
84
+ *
85
+ * `parity.ts` has declared an honesty contract in prose since day one — *"a capability flag states
86
+ * only what has been VERIFIED, each with its source in a comment"*. A comment is documentation, not
87
+ * a gate. The pinned map in `parity.test.ts` catches an ACCIDENTAL capability, but updating a pin is
88
+ * a mechanical edit that demands no evidence, so the contract had no layer-1 half.
89
+ *
90
+ * This is that half. `parity_no_capability_grant_without_evidence` fails when a target declares a
91
+ * capability with no record here, when a `transcript` record points at a file that does not exist,
92
+ * or when **any cell computes `full` on a target whose deciding capability has no transcript**.
93
+ *
94
+ * `kind: 'transcript'` means a recorded live run is on disk at `evidence`; `kind: 'reproducer'`
95
+ * means `evidence` is a command anyone can re-run.
96
+ */
97
+ export interface CapabilityEvidence {
98
+ readonly evidence: string;
99
+ readonly kind: 'transcript' | 'reproducer';
100
+ /** ISO date the evidence was produced. Stale evidence is still evidence — silence is not. */
101
+ readonly at: string;
102
+ }
103
+
104
+ export const CAPABILITY_EVIDENCE: Record<TargetName, Partial<Record<RuntimeCapability, CapabilityEvidence>>> = {
105
+ 'claude-code': {
106
+ shell: { evidence: 'dz --version', kind: 'reproducer', at: '2026-07-19' },
107
+ skills: { evidence: 'dz compose --target claude-code', kind: 'reproducer', at: '2026-07-19' },
108
+ 'hooks-write': { evidence: 'grep -n claim-check-hook .claude/settings.json', kind: 'reproducer', at: '2026-07-19' },
109
+ 'hooks-shell': { evidence: 'grep -n PreToolUse .claude/settings.json', kind: 'reproducer', at: '2026-07-19' },
110
+ 'hooks-prompt': { evidence: 'grep -n recall-hook.cjs .claude/settings.json', kind: 'reproducer', at: '2026-07-19' },
111
+ mcp: { evidence: 'cat .mcp.json', kind: 'reproducer', at: '2026-07-19' },
112
+ 'mcp-configured': { evidence: 'cat .mcp.json', kind: 'reproducer', at: '2026-07-19' },
113
+ workflows: { evidence: 'ls .claude/workflows/feature-adr.js', kind: 'reproducer', at: '2026-07-19' },
114
+ statusline: { evidence: 'dz statusline', kind: 'reproducer', at: '2026-07-19' },
115
+ },
116
+ codex: {
117
+ shell: { evidence: 'codex exec -m <id> "Reply with exactly: OK"', kind: 'reproducer', at: '2026-07-19' },
118
+ skills: { evidence: 'dz compose --target codex', kind: 'reproducer', at: '2026-07-19' },
119
+ mcp: { evidence: 'codex mcp list', kind: 'reproducer', at: '2026-07-19' },
120
+ // The two-sided live block: our marker in the transcript AND the sentinel side effect absent,
121
+ // in a NON-bypassed run, with the entry reported `trusted` by codex's own `hooks/list`.
122
+ 'hooks-shell': {
123
+ evidence: 'features/crossrt-2-codex-hooks/07_code_changes/probe-results/veto-armed.txt',
124
+ kind: 'transcript',
125
+ at: '2026-08-19',
126
+ },
127
+ 'hooks-prompt': {
128
+ evidence: 'features/crossrt-2-codex-hooks/07_code_changes/probe-results/recall-canary.md',
129
+ kind: 'transcript',
130
+ at: '2026-08-19',
131
+ },
132
+ },
133
+ opencode: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target opencode', kind: 'reproducer', at: '2026-07-19' } },
134
+ hermes: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target hermes', kind: 'reproducer', at: '2026-07-19' } },
135
+ openclaude: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target openclaude', kind: 'reproducer', at: '2026-07-19' } },
136
+ copilot: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target copilot', kind: 'reproducer', at: '2026-07-19' } },
137
+ 'agents-md': { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target agents-md', kind: 'reproducer', at: '2026-07-19' } },
138
+ cursor: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target cursor', kind: 'reproducer', at: '2026-07-19' } },
139
+ gemini: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target gemini', kind: 'reproducer', at: '2026-07-19' } },
140
+ windsurf: { shell: { evidence: 'adapter emit', kind: 'reproducer', at: '2026-07-19' }, skills: { evidence: 'dz compose --target windsurf', kind: 'reproducer', at: '2026-07-19' } },
141
+ };
142
+
143
+ export interface UnbackedCapability {
144
+ readonly target: TargetName;
145
+ readonly capability: RuntimeCapability;
146
+ readonly reason: 'no-evidence-record' | 'dangling-transcript';
147
+ readonly evidence?: string;
148
+ }
149
+
150
+ /**
151
+ * Every capability grant that is NOT backed by usable evidence.
152
+ *
153
+ * PURE, with the filesystem injected as `transcriptExists`. That is deliberate: the property this
154
+ * enforces — *a grant with a dangling transcript is not a grant* — has to be provable without a
155
+ * repository on disk, or the mutation gate (which copies the PACKAGE, not the repo) could never
156
+ * turn its mutant red, and an unkillable mutant is a false green wearing a gate's clothes.
157
+ */
158
+ export function findUnbackedCapabilities(
159
+ transcriptExists: (path: string) => boolean,
160
+ capabilities: Record<TargetName, readonly RuntimeCapability[]> = TARGET_CAPABILITIES,
161
+ evidence: Record<TargetName, Partial<Record<RuntimeCapability, CapabilityEvidence>>> = CAPABILITY_EVIDENCE,
162
+ ): UnbackedCapability[] {
163
+ const out: UnbackedCapability[] = [];
164
+ for (const target of Object.keys(capabilities) as TargetName[]) {
165
+ for (const capability of capabilities[target]) {
166
+ const record = evidence[target]?.[capability];
167
+ if (record === undefined) {
168
+ out.push({ target, capability, reason: 'no-evidence-record' });
169
+ continue;
170
+ }
171
+ if (record.kind === 'transcript' && !transcriptExists(record.evidence)) {
172
+ out.push({ target, capability, reason: 'dangling-transcript', evidence: record.evidence });
173
+ }
174
+ }
175
+ }
176
+ return out;
177
+ }
178
+
65
179
  /** How a feature manifests on a platform: a concrete FORM with its runtime requirements. */
66
180
  export interface FeatureForm {
67
181
  /** Human-readable name of the form, shown as the `via` of a parity cell (AM-2). */
@@ -124,7 +238,7 @@ export const PARITY_FEATURES: readonly ParityFeature[] = [
124
238
  id: 'claim-check',
125
239
  title: 'Integrity claim-check',
126
240
  forms: [
127
- { form: 'PreToolUse hook (automatic on Write/Edit)', requires: ['hooks'], level: 'full' },
241
+ { form: 'PreToolUse hook (automatic on Write/Edit)', requires: ['hooks-write'], level: 'full' },
128
242
  { form: 'dz claim-check (CLI) + publish gate', requires: ['shell'], level: 'manual' },
129
243
  ],
130
244
  },
@@ -137,7 +251,7 @@ export const PARITY_FEATURES: readonly ParityFeature[] = [
137
251
  id: 'learning-apply',
138
252
  title: 'Self-learning: automatic apply-leg',
139
253
  forms: [
140
- { form: 'UserPromptSubmit hook (auto recall)', requires: ['hooks'], level: 'full' },
254
+ { form: 'UserPromptSubmit hook (auto recall)', requires: ['hooks-prompt'], level: 'full' },
141
255
  { form: 'manual dz recall before a task', requires: ['shell'], level: 'manual' },
142
256
  ],
143
257
  },
@@ -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/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
+ }