@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
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Pure extraction, rendering, budget measurement, and drift detection for the
3
+ * always-on policy projection written to the root AGENTS.md.
4
+ *
5
+ * Callers own filesystem I/O. Keeping this module string-in/string-out makes
6
+ * the layer-1 drift guard deterministic and usable from both tests and CLI.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+
11
+ import { POLICY_BLOCK_BEGIN, POLICY_BLOCK_END } from '@dzhechkov/core';
12
+
13
+ export interface PolicySource {
14
+ readonly id: string;
15
+ readonly file: string;
16
+ readonly heading: string;
17
+ readonly why: string;
18
+ /** Clause whose loss would turn the policy into a slogan rather than a rule. */
19
+ readonly operativeClause: string;
20
+ }
21
+
22
+ /** Ordered by cold-start value; this order is also the deterministic emit order. */
23
+ export const POLICY_SOURCES: readonly PolicySource[] = [
24
+ {
25
+ id: 'integrity-rule',
26
+ file: 'CLAUDE.md',
27
+ heading: 'Integrity Rule (ABSOLUTE)',
28
+ why: 'Prevents fabricated evidence and unmeasured claims from a fresh runtime.',
29
+ operativeClause: 'measure before asserting',
30
+ },
31
+ {
32
+ id: 'verify-before-claiming',
33
+ file: 'CLAUDE.md',
34
+ heading: 'Verify before claiming',
35
+ why: 'Requires execution evidence instead of a prediction that work passes.',
36
+ operativeClause: 'verify before claiming success',
37
+ },
38
+ {
39
+ id: 'test-execution-no-watch',
40
+ file: 'CLAUDE.md',
41
+ heading: 'Test execution: never watch',
42
+ why: 'A watch-mode test run hangs an unattended coding session.',
43
+ operativeClause: 'npm test -- --run',
44
+ },
45
+ {
46
+ id: 'data-protection',
47
+ file: 'CLAUDE.md',
48
+ heading: 'Data protection',
49
+ why: 'Protects persistent QE and database state from destructive cleanup.',
50
+ operativeClause: 'without confirmation',
51
+ },
52
+ {
53
+ id: 'fixes-through-pipeline',
54
+ file: '.claude/rules/feature-adr-conventions.md',
55
+ heading: 'Fixes go through the pipeline',
56
+ why: 'Preserves the framing checkpoint for fixes and selects independent QE up front.',
57
+ operativeClause: 'ultracode or plain?',
58
+ },
59
+ {
60
+ id: 'publish-gate',
61
+ file: '.claude/rules/feature-adr-conventions.md',
62
+ heading: 'Publish gate',
63
+ why: 'Prevents publishing an unsettled disk version before independent verification lands.',
64
+ operativeClause: 'independent round with no blockers',
65
+ },
66
+ {
67
+ id: 'readme-first',
68
+ file: 'CLAUDE.md',
69
+ heading: 'README-first',
70
+ why: 'Keeps each changed package and the repository documentation current before a bump.',
71
+ operativeClause: 'in the SAME change',
72
+ },
73
+ {
74
+ id: 'cross-family-qe',
75
+ file: '.claude/rules/feature-adr-ultracode.md',
76
+ heading: 'Cross-family QE',
77
+ why: 'Prevents the model family that wrote code from certifying its own work.',
78
+ operativeClause: 'FAMILY, not host',
79
+ },
80
+ {
81
+ id: 'cost-of-detection-ladder',
82
+ file: '.claude/rules/feature-adr-conventions.md',
83
+ heading: 'Cost-of-detection ladder',
84
+ why: 'Places deterministic checks at the earliest reliable layer instead of reviewer memory.',
85
+ operativeClause: 'strongest layer that can express it',
86
+ },
87
+ ] as const;
88
+
89
+ export interface PolicyBlock {
90
+ readonly id: string;
91
+ readonly file: string;
92
+ readonly heading: string;
93
+ /** Verbatim anchor body after the documented minimal whitespace normalization. */
94
+ readonly text: string;
95
+ readonly sha: string;
96
+ }
97
+
98
+ export interface ExtractPolicyBlocksResult {
99
+ readonly blocks: readonly PolicyBlock[];
100
+ /** Registry ids whose source is absent/null or whose begin/end anchor is malformed. */
101
+ readonly missing: readonly string[];
102
+ }
103
+
104
+ export type PolicyDriftStatus = 'ok' | 'stale' | 'missing-stamp' | 'missing-anchor' | 'orphan-stamp';
105
+
106
+ export interface PolicyDriftFinding {
107
+ readonly id: string;
108
+ readonly file: string;
109
+ readonly status: PolicyDriftStatus;
110
+ readonly expectedSha: string | null;
111
+ readonly actualSha: string | null;
112
+ }
113
+
114
+ export interface PolicyDriftResult {
115
+ readonly applicable: boolean;
116
+ readonly findings: readonly PolicyDriftFinding[];
117
+ }
118
+
119
+ /** MEASURED 2026-08-18, codex-cli 0.147.0, project_doc_max_bytes unset: codex debug prompt-input included exactly 32768 bytes of a 414013-byte AGENTS.md and truncated mid-line with no notice. */
120
+ export const CODEX_PROJECT_DOC_MAX_BYTES = 32_768;
121
+ export const AGENTS_MD_BUDGET_WARN_FRACTION = 0.9;
122
+
123
+ export interface AgentsMdBudget {
124
+ readonly bytes: number;
125
+ readonly cap: number;
126
+ /** Percentage in the human convention, e.g. 50 means half the cap. */
127
+ readonly pct: number;
128
+ readonly overflow: boolean;
129
+ /** UTF-8 byte offset immediately after the policy END marker, or -1 when absent. */
130
+ readonly policyBlockEndsAtByte: number;
131
+ }
132
+
133
+ const POLICY_STAMP = /^<!-- dz:policy id=([a-z0-9-]+) src=([^\s]+) sha=([a-f0-9]{12}) -->$/gm;
134
+
135
+ export function normalizePolicyText(text: string): string {
136
+ return text
137
+ .replace(/\r\n?/g, '\n')
138
+ .split('\n')
139
+ .map((line) => line.replace(/[ \t]+$/g, ''))
140
+ .join('\n')
141
+ .trim();
142
+ }
143
+
144
+ export function policyTextSha(text: string): string {
145
+ return createHash('sha256').update(normalizePolicyText(text), 'utf8').digest('hex').slice(0, 12);
146
+ }
147
+
148
+ function anchorPair(id: string): { begin: string; end: string } {
149
+ return { begin: `<!-- dz:policy id=${id} -->`, end: '<!-- dz:policy end -->' };
150
+ }
151
+
152
+ export function extractPolicyBlocks(
153
+ files: ReadonlyMap<string, string | null>,
154
+ sources: readonly PolicySource[] = POLICY_SOURCES,
155
+ ): ExtractPolicyBlocksResult {
156
+ const blocks: PolicyBlock[] = [];
157
+ const missing: string[] = [];
158
+ for (const source of sources) {
159
+ const raw = files.get(source.file);
160
+ if (typeof raw !== 'string') {
161
+ missing.push(source.id);
162
+ continue;
163
+ }
164
+ const { begin, end } = anchorPair(source.id);
165
+ const beginIndex = raw.indexOf(begin);
166
+ const secondBegin = beginIndex === -1 ? -1 : raw.indexOf(begin, beginIndex + begin.length);
167
+ const endIndex = beginIndex === -1 ? -1 : raw.indexOf(end, beginIndex + begin.length);
168
+ const nestedBegin = beginIndex === -1 ? -1 : raw.indexOf('<!-- dz:policy id=', beginIndex + begin.length);
169
+ if (beginIndex === -1 || secondBegin !== -1 || endIndex === -1 || (nestedBegin !== -1 && nestedBegin < endIndex)) {
170
+ missing.push(source.id);
171
+ continue;
172
+ }
173
+ const text = normalizePolicyText(raw.slice(beginIndex + begin.length, endIndex));
174
+ if (text.length === 0) {
175
+ missing.push(source.id);
176
+ continue;
177
+ }
178
+ blocks.push({ id: source.id, file: source.file, heading: source.heading, text, sha: policyTextSha(text) });
179
+ }
180
+ return { blocks, missing };
181
+ }
182
+
183
+ export function renderPolicySections(
184
+ blocks: readonly PolicyBlock[],
185
+ sources: readonly PolicySource[] = POLICY_SOURCES,
186
+ ): readonly string[] {
187
+ const headingById = new Map(sources.map((source) => [source.id, source.heading]));
188
+ return blocks.map((block) => {
189
+ const heading = headingById.get(block.id) ?? block.heading;
190
+ return `<!-- dz:policy id=${block.id} src=${block.file} sha=${block.sha} -->\n## ${heading}\n\n${block.text}\n\nCanonical: ${block.file}`;
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Has this repository OPTED IN to policy sync? The `dz:policies` fence in `AGENTS.md` is the only
196
+ * durable on-disk signal that `dz agents-sync` was ever run here. Keeping the marker knowledge in
197
+ * this module means no caller has to re-spell the literal (a second spelling is a second surface).
198
+ */
199
+ export function hasPolicyFence(fileText: string | null | undefined): boolean {
200
+ return typeof fileText === 'string' && fileText.includes(POLICY_BLOCK_BEGIN);
201
+ }
202
+
203
+ export function measureAgentsMdBudget(fileText: string): AgentsMdBudget {
204
+ const bytes = Buffer.byteLength(fileText, 'utf8');
205
+ const endChar = fileText.indexOf(POLICY_BLOCK_END);
206
+ const policyBlockEndsAtByte = endChar === -1
207
+ ? -1
208
+ : Buffer.byteLength(fileText.slice(0, endChar + POLICY_BLOCK_END.length), 'utf8');
209
+ return {
210
+ bytes,
211
+ cap: CODEX_PROJECT_DOC_MAX_BYTES,
212
+ pct: Number(((bytes / CODEX_PROJECT_DOC_MAX_BYTES) * 100).toFixed(2)),
213
+ overflow: bytes > CODEX_PROJECT_DOC_MAX_BYTES,
214
+ policyBlockEndsAtByte,
215
+ };
216
+ }
217
+
218
+ interface ParsedStamp {
219
+ readonly id: string;
220
+ readonly file: string;
221
+ readonly sha: string;
222
+ readonly section: string;
223
+ }
224
+
225
+ function parsePolicyStamps(agentsMdText: string | null): ParsedStamp[] {
226
+ if (typeof agentsMdText !== 'string') return [];
227
+ const begin = agentsMdText.indexOf(POLICY_BLOCK_BEGIN);
228
+ const end = begin === -1 ? -1 : agentsMdText.indexOf(POLICY_BLOCK_END, begin + POLICY_BLOCK_BEGIN.length);
229
+ if (begin === -1 || end === -1) return [];
230
+ const inner = agentsMdText.slice(begin + POLICY_BLOCK_BEGIN.length, end);
231
+ const matches = [...inner.matchAll(POLICY_STAMP)];
232
+ return matches.map((match, index) => ({
233
+ id: match[1]!,
234
+ file: match[2]!,
235
+ sha: match[3]!,
236
+ section: inner.slice((match.index ?? 0) + match[0].length, matches[index + 1]?.index ?? inner.length),
237
+ }));
238
+ }
239
+
240
+ function markerCount(text: string, marker: string): number {
241
+ let count = 0;
242
+ let offset = 0;
243
+ while (true) {
244
+ const found = text.indexOf(marker, offset);
245
+ if (found === -1) return count;
246
+ count += 1;
247
+ offset = found + marker.length;
248
+ }
249
+ }
250
+
251
+ function hasUnclaimedPolicyPrefix(text: string): boolean {
252
+ const begin = text.indexOf(POLICY_BLOCK_BEGIN);
253
+ const end = begin === -1 ? -1 : text.indexOf(POLICY_BLOCK_END, begin + POLICY_BLOCK_BEGIN.length);
254
+ if (begin === -1 || end === -1) return false;
255
+ const inner = text.slice(begin + POLICY_BLOCK_BEGIN.length, end);
256
+ const firstStamp = [...inner.matchAll(POLICY_STAMP)][0];
257
+ return inner.slice(0, firstStamp?.index ?? inner.length).trim().length > 0;
258
+ }
259
+
260
+ function hasPolicyStampOutsideFence(text: string): boolean {
261
+ const begin = text.indexOf(POLICY_BLOCK_BEGIN);
262
+ const end = begin === -1 ? -1 : text.indexOf(POLICY_BLOCK_END, begin + POLICY_BLOCK_BEGIN.length);
263
+ if (begin === -1 || end === -1) return [...text.matchAll(POLICY_STAMP)].length > 0;
264
+ const innerStart = begin + POLICY_BLOCK_BEGIN.length;
265
+ return [...text.matchAll(POLICY_STAMP)].some((match) => (match.index ?? -1) < innerStart || (match.index ?? -1) >= end);
266
+ }
267
+
268
+ function emittedBody(stamp: ParsedStamp, source: PolicySource): string | null {
269
+ const normalized = stamp.section.replace(/^\n+/, '').replace(/\n+$/, '');
270
+ const heading = `## ${source.heading}`;
271
+ if (!normalized.startsWith(`${heading}\n`)) return null;
272
+ const afterHeading = normalized.slice(heading.length).replace(/^\n+/, '');
273
+ const canonical = `\n\nCanonical: ${source.file}`;
274
+ if (!afterHeading.endsWith(canonical)) return null;
275
+ return normalizePolicyText(afterHeading.slice(0, -canonical.length));
276
+ }
277
+
278
+ export function detectPolicyDrift(
279
+ sourceFiles: ReadonlyMap<string, string | null>,
280
+ agentsMdText: string | null,
281
+ sources: readonly PolicySource[] = POLICY_SOURCES,
282
+ ): PolicyDriftResult {
283
+ const sourceFilesPresent = new Set(sources.map((source) => source.file))
284
+ .size > 0 && sources.some((source) => typeof sourceFiles.get(source.file) === 'string');
285
+ const extracted = extractPolicyBlocks(sourceFiles, sources);
286
+ const blockById = new Map(extracted.blocks.map((block) => [block.id, block]));
287
+ const stamps = parsePolicyStamps(agentsMdText);
288
+ const stampsById = new Map<string, ParsedStamp>();
289
+ const duplicateStamps: ParsedStamp[] = [];
290
+ for (const stamp of stamps) {
291
+ if (stampsById.has(stamp.id)) duplicateStamps.push(stamp);
292
+ else stampsById.set(stamp.id, stamp);
293
+ }
294
+
295
+ const findings: PolicyDriftFinding[] = [];
296
+ for (const source of sources) {
297
+ const block = blockById.get(source.id);
298
+ const stamp = stampsById.get(source.id);
299
+ if (!block) {
300
+ findings.push({ id: source.id, file: source.file, status: 'missing-anchor', expectedSha: null, actualSha: stamp?.sha ?? null });
301
+ continue;
302
+ }
303
+ if (!stamp) {
304
+ findings.push({ id: source.id, file: source.file, status: 'missing-stamp', expectedSha: block.sha, actualSha: null });
305
+ continue;
306
+ }
307
+ const body = emittedBody(stamp, source);
308
+ const stale = stamp.file !== source.file || stamp.sha !== block.sha || body === null || policyTextSha(body) !== block.sha;
309
+ findings.push({
310
+ id: source.id,
311
+ file: source.file,
312
+ status: stale ? 'stale' : 'ok',
313
+ expectedSha: block.sha,
314
+ actualSha: stamp.sha,
315
+ });
316
+ }
317
+
318
+ const sourceIds = new Set(sources.map((source) => source.id));
319
+ for (const stamp of [...stamps.filter((entry) => !sourceIds.has(entry.id)), ...duplicateStamps]) {
320
+ findings.push({ id: stamp.id, file: stamp.file, status: 'orphan-stamp', expectedSha: null, actualSha: stamp.sha });
321
+ }
322
+
323
+ if (typeof agentsMdText === 'string') {
324
+ const beginCount = markerCount(agentsMdText, POLICY_BLOCK_BEGIN);
325
+ const endCount = markerCount(agentsMdText, POLICY_BLOCK_END);
326
+ if (
327
+ beginCount > 1 ||
328
+ endCount > 1 ||
329
+ beginCount !== endCount ||
330
+ hasUnclaimedPolicyPrefix(agentsMdText) ||
331
+ hasPolicyStampOutsideFence(agentsMdText)
332
+ ) {
333
+ findings.push({ id: 'dz:policies', file: 'AGENTS.md', status: 'orphan-stamp', expectedSha: null, actualSha: null });
334
+ }
335
+ }
336
+
337
+ return { applicable: sourceFilesPresent, findings };
338
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The emitted Codex helper bodies (`crossrt-2-codex-hooks`, ADR-003 / ADR-004).
3
+ *
4
+ * Generated-code-as-a-string, following the `generateAgentdbWriter` precedent (`setup.ts`): the
5
+ * helpers are THIN and the logic lives in the package, because a changed helper body changes
6
+ * codex's `currentHash` and disarms the entry until it is re-trusted (MEASURED — M0 spike §4).
7
+ * Every byte in here is therefore a liability; keep them boring.
8
+ *
9
+ * ## The contracts, side by side
10
+ *
11
+ * | | `dz-codex-veto.cjs` (PreToolUse) | `dz-codex-recall.cjs` (UserPromptSubmit) |
12
+ * |----------------|-----------------------------------------------------|------------------------------------------|
13
+ * | Polarity | mode-driven on POLICY (default `warn` ⇒ exit 0) | **never-block, always** |
14
+ * | Keys on | presence of `tool_input.command` (AM-8) | `payload.prompt` |
15
+ * | Activation | nearest `.dz` walking up from `payload.cwd` (AM-25) | same |
16
+ * | Our own failure| exit 0, silent, note to `helper-errors.jsonl` | exit 0, empty stdout, no write (AM-9) |
17
+ *
18
+ * `.git` is **not** an activation marker (AM-25): a user-global hook that treats "any git checkout"
19
+ * as opted-in reaches every repository on the machine, including trees nobody pointed at dz.
20
+ *
21
+ * The self-failure note goes to `$CODEX_HOME/dz-hooks/helper-errors.jsonl` (AM-33), never into a
22
+ * project — writing it into `<project>/.dz/` would CREATE a `.dz/` in a foreign repo, which is the
23
+ * exact thing the activation rule exists to prevent.
24
+ *
25
+ * A `UserPromptSubmit` hook that exits 2 **blocks the user's turn**, so the recall helper has no
26
+ * path to a non-zero exit at all.
27
+ *
28
+ * @packageDocumentation
29
+ */
30
+
31
+ import { DZ_HOOK_HELPER_VERSION } from './codex-hooks.js';
32
+
33
+ /** Shared prologue: payload read, project-root walk, never-throw error note. */
34
+ function preamble(): string {
35
+ return `#!/usr/bin/env node
36
+ // GENERATED by @dzhechkov/harness-core — dz-hook-helper-version: ${DZ_HOOK_HELPER_VERSION}
37
+ // Do not edit: dz rewrites this file, and any edit disarms the hook until it is re-trusted.
38
+ 'use strict';
39
+ const fs = require('node:fs');
40
+ const path = require('node:path');
41
+
42
+ const HELPER_VERSION = ${DZ_HOOK_HELPER_VERSION};
43
+ const CODEX_HOME = process.env.CODEX_HOME || path.join(require('node:os').homedir(), '.codex');
44
+ const ERROR_LOG = path.join(CODEX_HOME, 'dz-hooks', 'helper-errors.jsonl');
45
+
46
+ /**
47
+ * Append one line to the helper NOTES log. Never throws. Never writes into a project.
48
+ *
49
+ * Carries BOTH self-failures and policy WARNINGS. MEASURED (M9 live probe, 2026-08-19): codex
50
+ * surfaces a hook's stderr in the transcript ONLY when the hook exits 2 — an exit-0 hook's stderr is
51
+ * swallowed. So a warn that lived only on stderr would be unobservable, and "the guard is live but
52
+ * not blocking" (G-J) would be unprovable. This file is where it is observable.
53
+ */
54
+ function note(stage, err, extra) {
55
+ try {
56
+ fs.appendFileSync(
57
+ ERROR_LOG,
58
+ JSON.stringify(Object.assign(
59
+ { ts: new Date().toISOString(), helper: HELPER, helperVersion: HELPER_VERSION, stage, message: String((err && err.message) || err || '') },
60
+ extra || {},
61
+ )) + '\\n',
62
+ );
63
+ } catch (_) { /* the note is best-effort; it must never become the failure */ }
64
+ }
65
+
66
+ function readPayload() {
67
+ try {
68
+ const raw = fs.readFileSync(0, 'utf8');
69
+ const parsed = JSON.parse(raw);
70
+ return parsed && typeof parsed === 'object' ? parsed : null;
71
+ } catch (err) {
72
+ note('read-payload', err);
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Walk UP from cwd to the nearest directory containing a \`.dz\` DIRECTORY.
79
+ * \`.git\` is deliberately not a marker (AM-25). No root => the helper is inert.
80
+ */
81
+ function findProjectRoot(startDir) {
82
+ try {
83
+ let dir = path.resolve(startDir);
84
+ for (let i = 0; i < 64; i += 1) {
85
+ try {
86
+ if (fs.statSync(path.join(dir, '.dz')).isDirectory()) return dir;
87
+ } catch (_) { /* not here; keep walking */ }
88
+ const parent = path.dirname(dir);
89
+ if (parent === dir) return null;
90
+ dir = parent;
91
+ }
92
+ return null;
93
+ } catch (err) {
94
+ note('find-root', err);
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function readProjectConfig(root) {
100
+ try {
101
+ return JSON.parse(fs.readFileSync(path.join(root, '.dz', 'config.json'), 'utf8'));
102
+ } catch (_) {
103
+ return null; // absent or unreadable config is the DEFAULT case, not an error
104
+ }
105
+ }
106
+
107
+ /** Load a built module from the project's own harness-core, or null. Never throws. */
108
+ function loadCore(root, fileName, predicate) {
109
+ const candidates = [
110
+ path.join(root, 'node_modules', '@dzhechkov', 'harness-core', 'dist', fileName),
111
+ path.join(root, 'packages', '@dzhechkov', 'harness-core', 'dist', fileName),
112
+ ];
113
+ for (const candidate of candidates) {
114
+ try {
115
+ if (!fs.existsSync(candidate)) continue;
116
+ const mod = require(candidate);
117
+ if (predicate(mod)) return mod;
118
+ } catch (err) {
119
+ note('load-core:' + fileName, err);
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ `;
125
+ }
126
+
127
+ /**
128
+ * The PreToolUse veto helper.
129
+ *
130
+ * Fail-OPEN on OUR failure, mode-driven on a POLICY hit. Exit 2 only when the project explicitly
131
+ * opted in — the shipped default warns and returns 0 (AM-24 / G-J).
132
+ */
133
+ export function generateCodexVetoHelper(): string {
134
+ return `${preamble().replace('const HELPER_VERSION', "const HELPER = 'dz-codex-veto';\nconst HELPER_VERSION")}
135
+ function main() {
136
+ const payload = readPayload();
137
+ if (payload === null) return 0;
138
+
139
+ // AM-8: key on the PRESENCE of a command, never on tool_name equality. MEASURED payload
140
+ // (M0 spike): { tool_name: 'Bash', tool_input: { command: "…" }, cwd: "…" }.
141
+ const input = payload.tool_input;
142
+ const command = input && typeof input === 'object' ? input.command : undefined;
143
+ if (typeof command !== 'string' || command === '') return 0;
144
+
145
+ const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : process.env.PWD || process.cwd();
146
+ const root = findProjectRoot(cwd);
147
+ if (root === null) return 0; // inert outside an opted-in dz project: no decision, no output, no write
148
+
149
+ const policy = loadCore(root, 'shell-veto-policy.js', (m) => typeof m.vetoShellCommand === 'function');
150
+ if (policy === null) return 0; // no logic available => allow (never block on OUR failure)
151
+
152
+ let hit = null;
153
+ let mode = 'warn';
154
+ try {
155
+ hit = policy.vetoShellCommand(command);
156
+ mode = policy.resolveVetoMode(readProjectConfig(root));
157
+ } catch (err) {
158
+ note('policy', err);
159
+ return 0;
160
+ }
161
+ if (hit === null || mode === 'off') return 0;
162
+
163
+ if (mode === 'block') {
164
+ process.stderr.write('DZ-VETO: ' + hit.rule + ' — ' + hit.reason + ' — command: ' + command + '\\n');
165
+ note('veto-block', null, { marker: 'DZ-VETO', rule: hit.rule, command: command, mode: mode });
166
+ return 2;
167
+ }
168
+ const warnLine = 'DZ-VETO-WARN: ' + hit.rule + ' — ' + hit.reason + ' — command: ' + command;
169
+ process.stderr.write(warnLine + '\\n');
170
+ // Codex swallows an exit-0 hook's stderr (MEASURED), so the warn is ALSO recorded where it can be
171
+ // read back — otherwise the shipped default would be live-but-unobservable.
172
+ note('veto-warn', null, { marker: 'DZ-VETO-WARN', rule: hit.rule, command: command, mode: mode });
173
+ return 0;
174
+ }
175
+
176
+ let code = 0;
177
+ try {
178
+ code = main();
179
+ } catch (err) {
180
+ note('main', err);
181
+ code = 0;
182
+ }
183
+ process.exit(code === 2 ? 2 : 0);
184
+ `;
185
+ }
186
+
187
+ /**
188
+ * The UserPromptSubmit recall helper.
189
+ *
190
+ * NEVER blocks and NEVER exits non-zero (AM-9). Silence is its correct output when nothing is
191
+ * relevant, which is exactly why the acceptance canary is a FORCED HIT plus a removed-hook twin
192
+ * (AM-4) — a dead hook and a correctly-silent one are indistinguishable from the outside.
193
+ */
194
+ export function generateCodexRecallHelper(): string {
195
+ return `${preamble().replace('const HELPER_VERSION', "const HELPER = 'dz-codex-recall';\nconst HELPER_VERSION")}
196
+ const net = require('node:net');
197
+ const DAEMON_TIMEOUT_MS = 2000;
198
+
199
+ /** Ask the project's embed daemon for candidates. Resolves undefined on ANY problem. */
200
+ function askDaemon(root, prompt) {
201
+ return new Promise((resolve) => {
202
+ const socket = process.env.DZ_EMBED_SOCKET || path.join(root, '.dz', 'embed.sock');
203
+ let exists = false;
204
+ try { exists = fs.existsSync(socket); } catch (_) { exists = false; }
205
+ if (!exists) return resolve(undefined);
206
+ let settled = false;
207
+ const done = (v) => {
208
+ if (settled) return;
209
+ settled = true;
210
+ try { sock.destroy(); } catch (_) { /* ignore */ }
211
+ resolve(v);
212
+ };
213
+ const sock = net.connect(socket);
214
+ const timer = setTimeout(() => done(undefined), DAEMON_TIMEOUT_MS);
215
+ if (typeof timer.unref === 'function') timer.unref();
216
+ let buf = '';
217
+ sock.on('connect', () => sock.write(JSON.stringify({ op: 'recall', prompt: prompt, limit: 8 }) + '\\n'));
218
+ sock.on('data', (chunk) => {
219
+ buf += chunk.toString('utf8');
220
+ const nl = buf.indexOf('\\n');
221
+ if (nl === -1) return;
222
+ clearTimeout(timer);
223
+ let msg = null;
224
+ try { msg = JSON.parse(buf.slice(0, nl)); } catch (_) { msg = null; }
225
+ done(msg && Array.isArray(msg.hits) ? msg.hits : undefined);
226
+ });
227
+ sock.on('error', () => { clearTimeout(timer); done(undefined); });
228
+ });
229
+ }
230
+
231
+ async function main() {
232
+ const payload = readPayload();
233
+ if (payload === null) return;
234
+ const prompt = typeof payload.prompt === 'string' ? payload.prompt : '';
235
+ if (prompt.trim() === '') return;
236
+
237
+ const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : process.env.PWD || process.cwd();
238
+ const root = findProjectRoot(cwd);
239
+ if (root === null) return; // inert outside an opted-in dz project
240
+
241
+ const policy = loadCore(root, 'recall-hook-policy.js', (m) => typeof m.selectHookHits === 'function');
242
+ if (policy === null) return;
243
+
244
+ const candidates = await askDaemon(root, prompt);
245
+ if (!candidates || candidates.length === 0) return; // daemon dead or nothing relevant: silence
246
+
247
+ let selection = null;
248
+ try {
249
+ selection = policy.selectHookHits(prompt, candidates);
250
+ } catch (err) {
251
+ note('select-hits', err);
252
+ return;
253
+ }
254
+ if (!selection || !Array.isArray(selection.hits) || selection.hits.length === 0) return;
255
+
256
+ let context = '';
257
+ try { context = policy.renderHookContext(selection); } catch (err) { note('render', err); return; }
258
+ if (context === '') return; // empty context => print NOTHING (an empty block is noise)
259
+
260
+ process.stdout.write(
261
+ JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: context } }) + '\\n',
262
+ );
263
+
264
+ // The usage row carries runtime: 'codex' (ADR-003 §3) through the SHARED chained appender in
265
+ // harness-core — ONE writer implementation, two callers (AM-6/AM-7). Written AFTER stdout so a
266
+ // logging failure can never cost the user their injection.
267
+ const usage = loadCore(root, 'recall-usage.js', (m) => typeof m.appendRecallUsage === 'function');
268
+ if (usage === null) return;
269
+ try {
270
+ usage.appendRecallUsage({
271
+ projectRoot: root,
272
+ runtime: 'codex',
273
+ query: prompt,
274
+ runId: typeof payload.session_id === 'string' ? payload.session_id : undefined,
275
+ hits: selection.hits.map((h) => ({ dzId: h.dzId, score: h.score })),
276
+ });
277
+ } catch (err) {
278
+ note('append-usage', err);
279
+ }
280
+ }
281
+
282
+ main()
283
+ .catch((err) => note('main', err))
284
+ .finally(() => process.exit(0)); // a UserPromptSubmit exit 2 BLOCKS the user's turn
285
+ `;
286
+ }
287
+
288
+ /** Both helper bodies, keyed by the file they are written to. */
289
+ export function generateCodexHelpers(): Readonly<Record<'veto' | 'recall', string>> {
290
+ return { veto: generateCodexVetoHelper(), recall: generateCodexRecallHelper() };
291
+ }