@dzhechkov/harness-core 0.8.10 → 0.8.20

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 (251) hide show
  1. package/.dz-manifest.json +490 -170
  2. package/LICENSE +13 -0
  3. package/README.md +420 -6
  4. package/dist/agents-policy.d.ts +15 -1
  5. package/dist/agents-policy.d.ts.map +1 -1
  6. package/dist/agents-policy.js +27 -1
  7. package/dist/agents-policy.js.map +1 -1
  8. package/dist/amendment-trace.d.ts +72 -0
  9. package/dist/amendment-trace.d.ts.map +1 -1
  10. package/dist/amendment-trace.js +385 -17
  11. package/dist/amendment-trace.js.map +1 -1
  12. package/dist/backlog-public.d.ts +153 -0
  13. package/dist/backlog-public.d.ts.map +1 -0
  14. package/dist/backlog-public.js +415 -0
  15. package/dist/backlog-public.js.map +1 -0
  16. package/dist/backlog-transitions.d.ts +48 -0
  17. package/dist/backlog-transitions.d.ts.map +1 -0
  18. package/dist/backlog-transitions.js +64 -0
  19. package/dist/backlog-transitions.js.map +1 -0
  20. package/dist/backlog.d.ts.map +1 -1
  21. package/dist/backlog.js +13 -0
  22. package/dist/backlog.js.map +1 -1
  23. package/dist/claim-check.d.ts.map +1 -1
  24. package/dist/claim-check.js +24 -2
  25. package/dist/claim-check.js.map +1 -1
  26. package/dist/claude-hooks-assets.d.ts +93 -0
  27. package/dist/claude-hooks-assets.d.ts.map +1 -0
  28. package/dist/claude-hooks-assets.js +208 -0
  29. package/dist/claude-hooks-assets.js.map +1 -0
  30. package/dist/clean-room-smoke.d.ts +83 -0
  31. package/dist/clean-room-smoke.d.ts.map +1 -0
  32. package/dist/clean-room-smoke.js +138 -0
  33. package/dist/clean-room-smoke.js.map +1 -0
  34. package/dist/cmd-usage.d.ts.map +1 -1
  35. package/dist/cmd-usage.js +36 -6
  36. package/dist/cmd-usage.js.map +1 -1
  37. package/dist/codex-hooks-assets.d.ts +25 -7
  38. package/dist/codex-hooks-assets.d.ts.map +1 -1
  39. package/dist/codex-hooks-assets.js +138 -30
  40. package/dist/codex-hooks-assets.js.map +1 -1
  41. package/dist/codex-hooks.d.ts +21 -1
  42. package/dist/codex-hooks.d.ts.map +1 -1
  43. package/dist/codex-hooks.js +21 -1
  44. package/dist/codex-hooks.js.map +1 -1
  45. package/dist/codex-invoke.d.ts +73 -0
  46. package/dist/codex-invoke.d.ts.map +1 -0
  47. package/dist/codex-invoke.js +80 -0
  48. package/dist/codex-invoke.js.map +1 -0
  49. package/dist/course-staleness.d.ts +19 -0
  50. package/dist/course-staleness.d.ts.map +1 -0
  51. package/dist/course-staleness.js +95 -0
  52. package/dist/course-staleness.js.map +1 -0
  53. package/dist/destructive-guard-hook.d.ts +40 -0
  54. package/dist/destructive-guard-hook.d.ts.map +1 -0
  55. package/dist/destructive-guard-hook.js +109 -0
  56. package/dist/destructive-guard-hook.js.map +1 -0
  57. package/dist/destructive-guard.d.ts +27 -0
  58. package/dist/destructive-guard.d.ts.map +1 -0
  59. package/dist/destructive-guard.js +2808 -0
  60. package/dist/destructive-guard.js.map +1 -0
  61. package/dist/discrimination-gate.d.ts +88 -3
  62. package/dist/discrimination-gate.d.ts.map +1 -1
  63. package/dist/discrimination-gate.js +173 -16
  64. package/dist/discrimination-gate.js.map +1 -1
  65. package/dist/event-chain.d.ts +30 -0
  66. package/dist/event-chain.d.ts.map +1 -1
  67. package/dist/event-chain.js +24 -0
  68. package/dist/event-chain.js.map +1 -1
  69. package/dist/feature-adr-routing.d.ts +95 -1
  70. package/dist/feature-adr-routing.d.ts.map +1 -1
  71. package/dist/feature-adr-routing.js +193 -27
  72. package/dist/feature-adr-routing.js.map +1 -1
  73. package/dist/guard.d.ts +98 -0
  74. package/dist/guard.d.ts.map +1 -1
  75. package/dist/guard.js +308 -0
  76. package/dist/guard.js.map +1 -1
  77. package/dist/harness-core-location.d.ts +18 -0
  78. package/dist/harness-core-location.d.ts.map +1 -0
  79. package/dist/harness-core-location.js +42 -0
  80. package/dist/harness-core-location.js.map +1 -0
  81. package/dist/index.d.ts +32 -11
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +32 -7
  84. package/dist/index.js.map +1 -1
  85. package/dist/lead-shift-gate.d.ts +95 -0
  86. package/dist/lead-shift-gate.d.ts.map +1 -0
  87. package/dist/lead-shift-gate.js +100 -0
  88. package/dist/lead-shift-gate.js.map +1 -0
  89. package/dist/ledger-backfill.d.ts +11 -1
  90. package/dist/ledger-backfill.d.ts.map +1 -1
  91. package/dist/ledger-backfill.js +19 -0
  92. package/dist/ledger-backfill.js.map +1 -1
  93. package/dist/loop-blobs.generated.d.ts +1 -1
  94. package/dist/loop-blobs.generated.d.ts.map +1 -1
  95. package/dist/loop-blobs.generated.js +12 -3
  96. package/dist/loop-blobs.generated.js.map +1 -1
  97. package/dist/loop-lint.d.ts.map +1 -1
  98. package/dist/loop-lint.js +56 -7
  99. package/dist/loop-lint.js.map +1 -1
  100. package/dist/loop-plan-graph.d.ts +1 -3
  101. package/dist/loop-plan-graph.d.ts.map +1 -1
  102. package/dist/loop-plan-graph.js +70 -1
  103. package/dist/loop-plan-graph.js.map +1 -1
  104. package/dist/loop-trace.d.ts.map +1 -1
  105. package/dist/loop-trace.js +16 -2
  106. package/dist/loop-trace.js.map +1 -1
  107. package/dist/managed-hooks.d.ts +5 -6
  108. package/dist/managed-hooks.d.ts.map +1 -1
  109. package/dist/managed-hooks.js +2 -2
  110. package/dist/managed-hooks.js.map +1 -1
  111. package/dist/model-recommender.d.ts.map +1 -1
  112. package/dist/model-recommender.js +14 -3
  113. package/dist/model-recommender.js.map +1 -1
  114. package/dist/mutation-gate.d.ts +39 -36
  115. package/dist/mutation-gate.d.ts.map +1 -1
  116. package/dist/mutation-gate.js +111 -5
  117. package/dist/mutation-gate.js.map +1 -1
  118. package/dist/operations.d.ts.map +1 -1
  119. package/dist/operations.js +90 -5
  120. package/dist/operations.js.map +1 -1
  121. package/dist/patterns.d.ts +23 -0
  122. package/dist/patterns.d.ts.map +1 -1
  123. package/dist/patterns.js +10 -0
  124. package/dist/patterns.js.map +1 -1
  125. package/dist/plugin.d.ts.map +1 -1
  126. package/dist/plugin.js +27 -5
  127. package/dist/plugin.js.map +1 -1
  128. package/dist/publish.d.ts +11 -0
  129. package/dist/publish.d.ts.map +1 -1
  130. package/dist/publish.js +16 -2
  131. package/dist/publish.js.map +1 -1
  132. package/dist/recommend.d.ts +4 -5
  133. package/dist/recommend.d.ts.map +1 -1
  134. package/dist/recommend.js +110 -45
  135. package/dist/recommend.js.map +1 -1
  136. package/dist/registry.d.ts +32 -1
  137. package/dist/registry.d.ts.map +1 -1
  138. package/dist/registry.js +168 -11
  139. package/dist/registry.js.map +1 -1
  140. package/dist/run-records.d.ts +3 -0
  141. package/dist/run-records.d.ts.map +1 -1
  142. package/dist/run-records.js +18 -0
  143. package/dist/run-records.js.map +1 -1
  144. package/dist/score.d.ts +95 -0
  145. package/dist/score.d.ts.map +1 -1
  146. package/dist/score.js +289 -6
  147. package/dist/score.js.map +1 -1
  148. package/dist/session-retro.d.ts +121 -2
  149. package/dist/session-retro.d.ts.map +1 -1
  150. package/dist/session-retro.js +454 -11
  151. package/dist/session-retro.js.map +1 -1
  152. package/dist/setup.d.ts +28 -0
  153. package/dist/setup.d.ts.map +1 -1
  154. package/dist/setup.js +223 -6
  155. package/dist/setup.js.map +1 -1
  156. package/dist/sign.d.ts.map +1 -1
  157. package/dist/sign.js +18 -1
  158. package/dist/sign.js.map +1 -1
  159. package/dist/skill-selection.d.ts +72 -0
  160. package/dist/skill-selection.d.ts.map +1 -0
  161. package/dist/skill-selection.js +76 -0
  162. package/dist/skill-selection.js.map +1 -0
  163. package/dist/skills-verify.d.ts +34 -1
  164. package/dist/skills-verify.d.ts.map +1 -1
  165. package/dist/skills-verify.js +82 -0
  166. package/dist/skills-verify.js.map +1 -1
  167. package/dist/stage-line.d.ts +68 -0
  168. package/dist/stage-line.d.ts.map +1 -0
  169. package/dist/stage-line.js +129 -0
  170. package/dist/stage-line.js.map +1 -0
  171. package/dist/statusline.d.ts +99 -0
  172. package/dist/statusline.d.ts.map +1 -1
  173. package/dist/statusline.js +310 -44
  174. package/dist/statusline.js.map +1 -1
  175. package/dist/stem.d.ts +12 -0
  176. package/dist/stem.d.ts.map +1 -0
  177. package/dist/stem.js +89 -0
  178. package/dist/stem.js.map +1 -0
  179. package/dist/store-counts.d.ts +26 -0
  180. package/dist/store-counts.d.ts.map +1 -0
  181. package/dist/store-counts.js +125 -0
  182. package/dist/store-counts.js.map +1 -0
  183. package/dist/store-guard.d.ts +106 -0
  184. package/dist/store-guard.d.ts.map +1 -0
  185. package/dist/store-guard.js +294 -0
  186. package/dist/store-guard.js.map +1 -0
  187. package/dist/swarm-brief.d.ts +95 -0
  188. package/dist/swarm-brief.d.ts.map +1 -0
  189. package/dist/swarm-brief.js +660 -0
  190. package/dist/swarm-brief.js.map +1 -0
  191. package/dist/telemetry-vocabulary.d.ts +7 -0
  192. package/dist/telemetry-vocabulary.d.ts.map +1 -1
  193. package/dist/telemetry-vocabulary.js +29 -0
  194. package/dist/telemetry-vocabulary.js.map +1 -1
  195. package/dist/trace-bundle.d.ts +8 -0
  196. package/dist/trace-bundle.d.ts.map +1 -1
  197. package/dist/trace-bundle.js +11 -0
  198. package/dist/trace-bundle.js.map +1 -1
  199. package/package.json +10 -9
  200. package/sbom.json +1033 -233
  201. package/src/agents-policy.ts +46 -2
  202. package/src/amendment-trace.ts +441 -19
  203. package/src/backlog-public.ts +503 -0
  204. package/src/backlog-transitions.ts +77 -0
  205. package/src/backlog.ts +12 -0
  206. package/src/claim-check.ts +25 -2
  207. package/src/claude-hooks-assets.ts +227 -0
  208. package/src/clean-room-smoke.ts +195 -0
  209. package/src/cmd-usage.ts +29 -5
  210. package/src/codex-hooks-assets.ts +140 -30
  211. package/src/codex-hooks.ts +21 -1
  212. package/src/codex-invoke.ts +138 -0
  213. package/src/course-staleness.ts +125 -0
  214. package/src/destructive-guard-hook.ts +151 -0
  215. package/src/destructive-guard.ts +3027 -0
  216. package/src/discrimination-gate.ts +262 -19
  217. package/src/event-chain.ts +41 -0
  218. package/src/feature-adr-routing.ts +220 -22
  219. package/src/guard.ts +358 -0
  220. package/src/harness-core-location.ts +44 -0
  221. package/src/index.ts +121 -7
  222. package/src/lead-shift-gate.ts +145 -0
  223. package/src/ledger-backfill.ts +20 -1
  224. package/src/loop-blobs.generated.ts +12 -3
  225. package/src/loop-lint.ts +52 -7
  226. package/src/loop-plan-graph.ts +66 -1
  227. package/src/loop-trace.ts +13 -1
  228. package/src/managed-hooks.ts +5 -6
  229. package/src/model-recommender.ts +14 -3
  230. package/src/mutation-gate.ts +165 -5
  231. package/src/operations.ts +83 -5
  232. package/src/patterns.ts +33 -0
  233. package/src/plugin.ts +27 -5
  234. package/src/publish.ts +27 -2
  235. package/src/recommend.ts +116 -46
  236. package/src/registry.ts +147 -13
  237. package/src/run-records.ts +23 -0
  238. package/src/score.ts +377 -7
  239. package/src/session-retro.ts +466 -11
  240. package/src/setup.ts +246 -9
  241. package/src/sign.ts +18 -1
  242. package/src/skill-selection.ts +111 -0
  243. package/src/skills-verify.ts +99 -1
  244. package/src/stage-line.ts +151 -0
  245. package/src/statusline.ts +396 -47
  246. package/src/stem.ts +87 -0
  247. package/src/store-counts.ts +154 -0
  248. package/src/store-guard.ts +388 -0
  249. package/src/swarm-brief.ts +661 -0
  250. package/src/telemetry-vocabulary.ts +36 -0
  251. package/src/trace-bundle.ts +10 -0
@@ -29,18 +29,26 @@
29
29
  */
30
30
 
31
31
  import { DZ_HOOK_HELPER_VERSION } from './codex-hooks.js';
32
+ import { harnessCoreDistDir } from './harness-core-location.js';
32
33
 
33
34
  /** Shared prologue: payload read, project-root walk, never-throw error note. */
34
- function preamble(): string {
35
+ function preamble(installedDistDir: string | null): string {
35
36
  return `#!/usr/bin/env node
36
37
  // GENERATED by @dzhechkov/harness-core — dz-hook-helper-version: ${DZ_HOOK_HELPER_VERSION}
37
38
  // Do not edit: dz rewrites this file, and any edit disarms the hook until it is re-trusted.
38
39
  'use strict';
39
40
  const fs = require('node:fs');
40
41
  const path = require('node:path');
42
+ const { pathToFileURL } = require('node:url');
41
43
 
42
44
  const HELPER_VERSION = ${DZ_HOOK_HELPER_VERSION};
43
45
  const CODEX_HOME = process.env.CODEX_HOME || path.join(require('node:os').homedir(), '.codex');
46
+ // The harness-core installation that EMITTED this helper, baked in at emission time.
47
+ // MEASURED 2026-09-05: with only the two project-relative candidates below, the documented
48
+ // global install ('npm i -g @dzhechkov/harness-cli') left every target repository without a
49
+ // resolvable module — every shell call reached 'guard not loaded' and was ALLOWED. It is null
50
+ // when the emitter could not name its own location; the project candidates still apply.
51
+ const INSTALLED_CORE_DIST = ${installedDistDir === null ? 'null' : JSON.stringify(installedDistDir)};
44
52
  const ERROR_LOG = path.join(CODEX_HOME, 'dz-hooks', 'helper-errors.jsonl');
45
53
 
46
54
  /**
@@ -141,35 +149,77 @@ function readProjectConfig(root) {
141
149
  }
142
150
  }
143
151
 
144
- /** Load a built module from the project's own harness-core, or null. Never throws. */
145
- function loadCore(root, fileName, predicate) {
152
+ /**
153
+ * Load a built module from the project's own harness-core. Never throws.
154
+ *
155
+ * \`import()\`, NEVER \`require()\`. \`@dzhechkov/harness-core\` is \`"type": "module"\`, so every file in
156
+ * its \`dist/\` is an ES module, while this package supports \`node >= 20\`. \`require()\` of an ES
157
+ * module was only unflagged in Node 20.19 / 22.12 — below that it throws ERR_REQUIRE_ESM, this
158
+ * loader turned that into \`null\`, and a \`null\` policy is a SILENT allow. MEASURED 2026-09-05 on
159
+ * this machine, reproducing the old loader with the flag that turns the unflagging back off:
160
+ * node --no-experimental-require-module <veto helper> on \`rm -rf .agentic-qe\`
161
+ * ⇒ exit 0 and an EMPTY stderr — the guard was not weak there, it was absent and mute.
162
+ * \`import()\` loads BOTH module kinds on every supported Node, so there is one loader instead of one
163
+ * that happens to work on the runtime that ran the tests.
164
+ *
165
+ * Returns \`{ mod, reason }\`. \`reason\` is the cause that was OBSERVED — the module id plus the code
166
+ * Node reported for it — never a cause we assumed: a warning that says "no build" about an
167
+ * ERR_REQUIRE_ESM sends the reader to fix something that is not broken.
168
+ */
169
+ async function loadCoreDetailed(root, fileName, predicate) {
146
170
  const candidates = [
147
171
  path.join(root, 'node_modules', '@dzhechkov', 'harness-core', 'dist', fileName),
148
172
  path.join(root, 'packages', '@dzhechkov', 'harness-core', 'dist', fileName),
149
173
  ];
174
+ // The emitting installation is the LAST resort, never the first: a project that ships its own
175
+ // harness-core must keep using its own copy rather than being pinned to whatever emitted the hook.
176
+ if (INSTALLED_CORE_DIST !== null) candidates.push(path.join(INSTALLED_CORE_DIST, fileName));
177
+ const failures = [];
150
178
  for (const candidate of candidates) {
151
179
  try {
152
- if (!fs.existsSync(candidate)) continue;
153
- const mod = require(candidate);
154
- if (predicate(mod)) return mod;
180
+ const mod = await import(pathToFileURL(candidate).href);
181
+ if (predicate(mod)) return { mod: mod, reason: null };
182
+ if (mod.default && predicate(mod.default)) return { mod: mod.default, reason: null };
183
+ failures.push(candidate + ': загружен, но нужный экспорт отсутствует');
155
184
  } catch (err) {
156
- note('load-core:' + fileName, err);
185
+ const code = (err && err.code) || 'ERR_UNKNOWN';
186
+ failures.push(candidate + ': ' + code + ' ' + String((err && err.message) || err));
187
+ // An absent optional module is the ORDINARY case (a project may not ship every policy), so it
188
+ // does not earn a durable note; anything else is a real load failure and does.
189
+ if (code !== 'ERR_MODULE_NOT_FOUND') note('load-core:' + fileName, err);
157
190
  }
158
191
  }
159
- return null;
192
+ return { mod: null, reason: failures.join(' | ').replace(/\\s*\\n\\s*/g, ' ') };
193
+ }
194
+
195
+ /** The module only, for callers that have nothing to say about WHY it is missing. */
196
+ async function loadCore(root, fileName, predicate) {
197
+ return (await loadCoreDetailed(root, fileName, predicate)).mod;
160
198
  }
161
199
  `;
162
200
  }
163
201
 
164
202
  /**
165
- * The PreToolUse veto helper.
203
+ * The PreToolUse veto helper — TWO checks behind one registry entry.
204
+ *
205
+ * 1. The destructive-command guard (feature `destructive-command-guard`, ADR-001): a deletion by a
206
+ * LITERAL path into a protected store (`.dz/`, `.agentic-qe/`, a database file). It blocks
207
+ * UNCONDITIONALLY, with no config mode, and that asymmetry with the shell veto below is
208
+ * deliberate: the shell veto's default is `warn` because its class is broad and its false-hit
209
+ * rate is unmeasured, while this guard's scope was cut by measurement over 20 938 real commands
210
+ * down to one refusal per ~510 commands (0,196 %) — narrow enough that a refusal is a fact about
211
+ * the command's FORM, not a judgement about its intent. The verdict, the text and the limits all
212
+ * come from `destructive-guard-hook.js`, the SAME module the Claude host's hook consumes, so
213
+ * both runtimes refuse the same commands in the same words.
214
+ * 2. The shell veto policy: fail-OPEN on OUR failure, mode-driven on a POLICY hit. Exit 2 only when
215
+ * the project explicitly opted in — the shipped default warns and returns 0 (AM-24 / G-J).
166
216
  *
167
- * Fail-OPEN on OUR failure, mode-driven on a POLICY hit. Exit 2 only when the project explicitly
168
- * opted in the shipped default warns and returns 0 (AM-24 / G-J).
217
+ * The destructive check runs FIRST and is loaded SEPARATELY, so a project whose build lacks the
218
+ * shell-veto policy still gets it, and vice versa.
169
219
  */
170
- export function generateCodexVetoHelper(): string {
171
- return `${preamble().replace('const HELPER_VERSION', "const HELPER = 'dz-codex-veto';\nconst HELPER_VERSION")}
172
- function main() {
220
+ export function generateCodexVetoHelper(installedDistDir: string | null = harnessCoreDistDir()): string {
221
+ return `${preamble(installedDistDir).replace('const HELPER_VERSION', "const HELPER = 'dz-codex-veto';\nconst HELPER_VERSION")}
222
+ async function main() {
173
223
  const payload = readPayload();
174
224
  if (payload === null) return 0;
175
225
 
@@ -183,7 +233,58 @@ function main() {
183
233
  const root = findProjectRoot(cwd);
184
234
  if (root === null) return 0; // inert outside an opted-in dz project: no decision, no output, no write
185
235
 
186
- const policy = loadCore(root, 'shell-veto-policy.js', (m) => typeof m.vetoShellCommand === 'function');
236
+ // (1) The destructive-command guard. Never blocks on our own failure: an absent module, a throw,
237
+ // or an \`undecidable\` verdict all fall through to the shell veto below (AC-10).
238
+ const destructiveLoad = await loadCoreDetailed(root, 'destructive-guard-hook.js', (m) => typeof m.decideDestructiveHook === 'function');
239
+ if (destructiveLoad.mod === null) {
240
+ // NEVER silence. A guard that could not load is indistinguishable from a guard that decided
241
+ // "allow" unless it SAYS so, and the measurement that produced this branch is exactly that:
242
+ // exit 0 with an empty stderr on \`rm -rf .agentic-qe\`. The exit code stays 0 — our failure is
243
+ // not the user's violation — but it is announced on ONE line in BOTH places a human can read
244
+ // it: the transcript, and the durable notes log (codex swallows an exit-0 stderr, M9).
245
+ const line = 'DZ-DESTRUCTIVE-WARN: guard not loaded — ' + destructiveLoad.reason;
246
+ process.stderr.write(line + '\\n');
247
+ note('destructive-not-loaded', null, Object.assign(
248
+ { marker: 'DZ-DESTRUCTIVE-WARN', reason: destructiveLoad.reason },
249
+ commandFacts(command),
250
+ ));
251
+ } else {
252
+ const destructive = destructiveLoad.mod;
253
+ let decision = null;
254
+ try {
255
+ decision = destructive.decideDestructiveHook(payload, 'codex');
256
+ // CHECKED, not assumed: a decision the helper cannot read must reach the loud path with a
257
+ // NAME. Both \`null\` and \`{}\` are falsy at \`decision.stderr\` further down, so an unreadable
258
+ // shape used to make the verdict simply vanish — a second door into the same silence.
259
+ if (!decision || typeof decision.stderr !== 'string') {
260
+ throw new TypeError('решение стража нечитаемо: ' + JSON.stringify(decision));
261
+ }
262
+ } catch (err) {
263
+ // ONE line, always (round 9). The Claude hook already failed open LOUDLY here; this helper
264
+ // only wrote a note, so in the transcript a thrown decider was indistinguishable from a clean
265
+ // allow — the round-2 defect class (absent AND mute) reached through another door. Exit stays
266
+ // 0, because our failure is not the user's violation, and the record lands in BOTH places a
267
+ // human can read: the transcript, and the notes log (codex swallows an exit-0 stderr, M9).
268
+ const why = String((err && err.message) || err).replace(/\\s*\\n\\s*/g, ' ');
269
+ process.stderr.write('DZ-DESTRUCTIVE-WARN: classifier threw — ' + why + '\\n');
270
+ note('destructive-threw', err, Object.assign({ marker: 'DZ-DESTRUCTIVE-WARN' }, commandFacts(command)));
271
+ decision = null;
272
+ }
273
+ if (decision && decision.stderr) process.stderr.write(decision.stderr);
274
+ if (decision && decision.exitCode === 2) {
275
+ // The note carries the RULE and the command's hash, never the command line itself (R2-8).
276
+ note('destructive-block', null, Object.assign({ marker: 'DZ-DESTRUCTIVE', rule: decision.rule }, commandFacts(command)));
277
+ return 2;
278
+ }
279
+ if (decision && decision.stderr) {
280
+ // MEASURED (M9): codex swallows an exit-0 hook's stderr, so a non-blocking word from this
281
+ // guard is unobservable unless it is ALSO written where it can be read back.
282
+ note('destructive-' + decision.outcome, null, Object.assign({ marker: 'DZ-DESTRUCTIVE-WARN' }, commandFacts(command)));
283
+ }
284
+ }
285
+
286
+ // (2) The shell veto policy.
287
+ const policy = await loadCore(root, 'shell-veto-policy.js', (m) => typeof m.vetoShellCommand === 'function');
187
288
  if (policy === null) return 0; // no logic available => allow (never block on OUR failure)
188
289
 
189
290
  let hit = null;
@@ -210,14 +311,12 @@ function main() {
210
311
  return 0;
211
312
  }
212
313
 
213
- let code = 0;
214
- try {
215
- code = main();
216
- } catch (err) {
217
- note('main', err);
218
- code = 0;
219
- }
220
- process.exit(code === 2 ? 2 : 0);
314
+ // \`main\` is async because the policy loader is (\`import()\`, not \`require()\` — see loadCoreDetailed).
315
+ // The exit code still comes from exactly one place, and a rejected promise is the same fail-open as
316
+ // a thrown error was: note it, exit 0.
317
+ main()
318
+ .catch((err) => { note('main', err); return 0; })
319
+ .then((code) => process.exit(code === 2 ? 2 : 0));
221
320
  `;
222
321
  }
223
322
 
@@ -228,8 +327,8 @@ process.exit(code === 2 ? 2 : 0);
228
327
  * relevant, which is exactly why the acceptance canary is a FORCED HIT plus a removed-hook twin
229
328
  * (AM-4) — a dead hook and a correctly-silent one are indistinguishable from the outside.
230
329
  */
231
- export function generateCodexRecallHelper(): string {
232
- return `${preamble().replace('const HELPER_VERSION', "const HELPER = 'dz-codex-recall';\nconst HELPER_VERSION")}
330
+ export function generateCodexRecallHelper(installedDistDir: string | null = harnessCoreDistDir()): string {
331
+ return `${preamble(installedDistDir).replace('const HELPER_VERSION', "const HELPER = 'dz-codex-recall';\nconst HELPER_VERSION")}
233
332
  const net = require('node:net');
234
333
  const DAEMON_TIMEOUT_MS = 2000;
235
334
 
@@ -275,7 +374,7 @@ async function main() {
275
374
  const root = findProjectRoot(cwd);
276
375
  if (root === null) return; // inert outside an opted-in dz project
277
376
 
278
- const policy = loadCore(root, 'recall-hook-policy.js', (m) => typeof m.selectHookHits === 'function');
377
+ const policy = await loadCore(root, 'recall-hook-policy.js', (m) => typeof m.selectHookHits === 'function');
279
378
  if (policy === null) return;
280
379
 
281
380
  const candidates = await askDaemon(root, prompt);
@@ -301,7 +400,7 @@ async function main() {
301
400
  // The usage row carries runtime: 'codex' (ADR-003 §3) through the SHARED chained appender in
302
401
  // harness-core — ONE writer implementation, two callers (AM-6/AM-7). Written AFTER stdout so a
303
402
  // logging failure can never cost the user their injection.
304
- const usage = loadCore(root, 'recall-usage.js', (m) => typeof m.appendRecallUsage === 'function');
403
+ const usage = await loadCore(root, 'recall-usage.js', (m) => typeof m.appendRecallUsage === 'function');
305
404
  if (usage === null) return;
306
405
  try {
307
406
  usage.appendRecallUsage({
@@ -322,7 +421,18 @@ main()
322
421
  `;
323
422
  }
324
423
 
325
- /** Both helper bodies, keyed by the file they are written to. */
326
- export function generateCodexHelpers(): Readonly<Record<'veto' | 'recall', string>> {
327
- return { veto: generateCodexVetoHelper(), recall: generateCodexRecallHelper() };
424
+ /**
425
+ * Both helper bodies, keyed by the file they are written to.
426
+ *
427
+ * `installedDistDir` defaults to the harness-core doing the emitting; pass `null` for a body with
428
+ * no absolute fallback (the pre-fix behaviour, kept so a test can prove the fallback is what makes
429
+ * a global install work rather than asserting it).
430
+ */
431
+ export function generateCodexHelpers(
432
+ installedDistDir: string | null = harnessCoreDistDir(),
433
+ ): Readonly<Record<'veto' | 'recall', string>> {
434
+ return {
435
+ veto: generateCodexVetoHelper(installedDistDir),
436
+ recall: generateCodexRecallHelper(installedDistDir),
437
+ };
328
438
  }
@@ -36,8 +36,28 @@ import { mergeManagedHookEntries } from './managed-hooks.js';
36
36
  * 3 — fix round 2: the note's `commandSynopsis` is a binary NAME or `(redacted)` — an
37
37
  * env-assignment first token (`SECRET=xyz ssh …`) carried the credential the redaction removed
38
38
  * everywhere else (R2-8).
39
+ * 4 — the destructive-command guard (feature `destructive-command-guard`, ADR-001, task T8): the
40
+ * veto helper consults `destructive-guard-hook.js` BEFORE the shell veto and blocks
41
+ * unconditionally on a literal path into a protected store. A changed body changes codex's
42
+ * `currentHash`, so the entry must be re-trusted — re-run `dz setup --target codex`.
43
+ * 5 — round 2 of the cross-family review, MEASURED: the helper's module loader was `require()`, and
44
+ * every file in this package's `dist/` is an ES module, so on Node below 20.19 / 22.12 the load
45
+ * threw ERR_REQUIRE_ESM, the catch returned `null`, and the helper exited 0 with an EMPTY stderr on
46
+ * `rm -rf .agentic-qe` — absent AND mute on a Node the package claims to support. The loader is now
47
+ * `import()`, `main` is async, and a decider that still cannot load says so on one line instead of
48
+ * staying silent. A changed body ⇒ a changed `currentHash` ⇒ re-trust.
49
+ * 6 — round 3, MEASURED: the body's module candidates were both PROJECT-relative, so in the
50
+ * documented global install (`npm i -g @dzhechkov/harness-cli`) an ordinary target repository
51
+ * resolved neither — every shell call reached `guard not loaded` and was ALLOWED, while the tests
52
+ * hid it by symlinking the package into the temp project. The emitting installation's absolute
53
+ * dist path is now baked in as the LAST candidate. A changed body ⇒ re-trust.
54
+ * 7 — round 9, MEASURED: when the decider THREW (or returned a shape the helper could not read),
55
+ * the helper wrote a note and printed NOTHING — stderr came back empty, so in the transcript the
56
+ * run was indistinguishable from a clean allow. The Claude hook already failed open loudly here.
57
+ * Now it prints ONE line, `DZ-DESTRUCTIVE-WARN: classifier threw — <message>`, and still exits 0.
58
+ * A changed body ⇒ re-trust.
39
59
  */
40
- export const DZ_HOOK_HELPER_VERSION = 3;
60
+ export const DZ_HOOK_HELPER_VERSION = 7;
41
61
 
42
62
  /** Seconds. Probe-proven (spike S2): `timeout` is honored, the unset default is 600 s. */
43
63
  export const DZ_HOOK_TIMEOUT_SECONDS = 5;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The one supported way to invoke Codex from a Claude Code session.
3
+ *
4
+ * Three failure modes were measured on 2026-08-31, each violating knowledge we had already
5
+ * written down and could not enforce: the fire-and-forget wrapper returning a dispatch stub for a
6
+ * stage whose deliverable is its return value; a prompt passed as a shell ARGUMENT whose backticks
7
+ * the shell read as command substitution, leaving codex to read an empty stdin and hang for 26
8
+ * minutes; and an unscoped run spending its whole budget exploring the tree and returning no
9
+ * verdict at all.
10
+ *
11
+ * The cure is structural, not advisory: this module has no parameter that accepts a prompt STRING
12
+ * (so a shell can never mangle it), a timeout is always present, and the outcome set is CLOSED —
13
+ * either the model's text, or one of five named refusals. There is no third state, which is the
14
+ * property the tests pin.
15
+ */
16
+
17
+ /** Every way this can fail, enumerated. A refusal outside this set is a bug, not a new case. */
18
+ export type CodexRefusal =
19
+ | 'timeout'
20
+ | 'no-output'
21
+ | 'model-unavailable'
22
+ | 'tool-error'
23
+ | 'bad-usage';
24
+
25
+ export interface CodexOk {
26
+ readonly ok: true;
27
+ readonly text: string;
28
+ readonly model: string;
29
+ readonly elapsedMs: number;
30
+ }
31
+
32
+ export interface CodexRefused {
33
+ readonly ok: false;
34
+ readonly refusal: CodexRefusal;
35
+ /** Human-readable, names WHAT was observed — never a guess at the cause. */
36
+ readonly detail: string;
37
+ readonly model?: string;
38
+ readonly elapsedMs?: number;
39
+ }
40
+
41
+ export type CodexOutcome = CodexOk | CodexRefused;
42
+
43
+ export const CODEX_DEFAULT_TIMEOUT_MS = 600_000;
44
+ export const CODEX_PROBE_TIMEOUT_MS = 90_000;
45
+
46
+ /** Model ids this account has been seen to answer on. A name here is spellable, never available. */
47
+ export const CODEX_KNOWN_MODELS = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5'] as const;
48
+
49
+ export interface CodexRunInput {
50
+ /** Absolute path to a file holding the prompt. There is deliberately no string variant. */
51
+ readonly promptFile: string;
52
+ readonly model: string;
53
+ readonly timeoutMs?: number;
54
+ /**
55
+ * Files the model may read. REQUIRED for review-shaped work: an unscoped run was measured at
56
+ * 280s / exit 124 / 416KB of exploration and no verdict, while the same question scoped to two
57
+ * named files answered in 41s. Empty means "no scope declared" and is allowed only for a
58
+ * self-contained question that needs no repository access.
59
+ */
60
+ readonly scope?: readonly string[];
61
+ readonly effort?: 'low' | 'medium' | 'high' | 'xhigh';
62
+ }
63
+
64
+ /** What a runner must provide. Kept tiny so tests can substitute it without spawning anything. */
65
+ export interface CodexRunner {
66
+ (argv: readonly string[], timeoutMs: number): {
67
+ readonly status: number | null;
68
+ readonly stdout: string;
69
+ readonly stderr: string;
70
+ readonly timedOut: boolean;
71
+ };
72
+ }
73
+
74
+ /** Build the argv. Exported so a test can assert the prompt never rides on the command line. */
75
+ export function codexArgv(input: CodexRunInput): string[] {
76
+ const argv = ['exec', '-m', input.model, '--skip-git-repo-check'];
77
+ if (input.effort !== undefined) argv.push('-c', `model_reasoning_effort=${input.effort}`);
78
+ return argv;
79
+ }
80
+
81
+ /** Turn a raw runner result into the closed outcome set. */
82
+ export function classifyCodexResult(
83
+ raw: { status: number | null; stdout: string; stderr: string; timedOut: boolean },
84
+ model: string,
85
+ elapsedMs: number,
86
+ ): CodexOutcome {
87
+ if (raw.timedOut) {
88
+ return {
89
+ ok: false,
90
+ refusal: 'timeout',
91
+ detail: `no answer within the deadline (${elapsedMs}ms); narrow the scope rather than raising the ceiling — an unscoped run spends the budget exploring`,
92
+ model,
93
+ elapsedMs,
94
+ };
95
+ }
96
+ if (raw.status !== 0) {
97
+ return {
98
+ ok: false,
99
+ refusal: 'tool-error',
100
+ detail: `codex exited ${raw.status === null ? 'by signal' : String(raw.status)}: ${raw.stderr.trim().slice(0, 300) || '(no stderr)'}`,
101
+ model,
102
+ elapsedMs,
103
+ };
104
+ }
105
+ const text = raw.stdout.trim();
106
+ if (text === '') {
107
+ // Silence is the failure this module exists to make impossible. A clean exit with nothing
108
+ // written is what a mangled prompt looks like from the outside, and it must never read as ok.
109
+ return {
110
+ ok: false,
111
+ refusal: 'no-output',
112
+ detail: 'codex exited 0 but wrote nothing — an empty answer is a refusal, never a clean result',
113
+ model,
114
+ elapsedMs,
115
+ };
116
+ }
117
+ return { ok: true, text, model, elapsedMs };
118
+ }
119
+
120
+ /** Compose the prompt file's content: the task, plus the scope fence when one is declared. */
121
+ export function codexPromptBody(task: string, scope?: readonly string[]): string {
122
+ const trimmed = String(task ?? '').trim();
123
+ if (scope === undefined || scope.length === 0) return trimmed;
124
+ const list = scope.map((p) => `- ${p}`).join('\n');
125
+ return [
126
+ trimmed,
127
+ '',
128
+ 'SCOPE — read ONLY these files and do not open others. This bound is what makes an answer',
129
+ 'possible at all: an unscoped run was measured spending its entire budget exploring the tree',
130
+ 'and returning no verdict.',
131
+ list,
132
+ ].join('\n');
133
+ }
134
+
135
+ /** True when the outcome may be consumed as an answer. Exists so callers cannot forget the check. */
136
+ export function codexAnswered(outcome: CodexOutcome): outcome is CodexOk {
137
+ return outcome.ok === true;
138
+ }
@@ -0,0 +1,125 @@
1
+ import { compareVersions } from './publish.js';
2
+
3
+ export type CourseStalenessState =
4
+ | 'S0_SHIPPED'
5
+ | 'S3_TUTORIAL_STALE'
6
+ | 'S4_PACKAGE_BEHIND'
7
+ | 'E2_UNSTAMPED'
8
+ | 'E3_INVALID_VERSION'
9
+ | 'E4_PACKAGE_MISMATCH'
10
+ | 'E5_REGISTRY_UNKNOWN';
11
+
12
+ export interface CourseStalenessInput {
13
+ readonly source: unknown;
14
+ readonly expectedPackage?: string | null;
15
+ readonly registryVersion?: string | null;
16
+ }
17
+
18
+ export interface CourseStalenessResult {
19
+ readonly state: CourseStalenessState;
20
+ readonly reason: string;
21
+ readonly courseVersion?: string;
22
+ readonly registryVersion?: string;
23
+ readonly package?: string;
24
+ }
25
+
26
+ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
27
+ const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
28
+
29
+ function isRecord(value: unknown): value is Record<string, unknown> {
30
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
31
+ }
32
+
33
+ /**
34
+ * Classify only the version evidence supplied by the caller. Obtaining a registry version is a
35
+ * caller responsibility; this function performs no filesystem, network, clock, or environment I/O.
36
+ */
37
+ export function classifyCourseStaleness(input: CourseStalenessInput): CourseStalenessResult {
38
+ const candidate = isRecord(input) ? input : undefined;
39
+ const source = candidate?.source;
40
+
41
+ // Presence and shape come first. Unknown values must never reach an equality comparison where
42
+ // two absences could be mistaken for parity.
43
+ if (!isRecord(source)
44
+ || typeof source.package !== 'string'
45
+ || source.package.trim() === ''
46
+ || typeof source.version !== 'string'
47
+ || source.version.trim() === '') {
48
+ return {
49
+ state: 'E2_UNSTAMPED',
50
+ reason: 'course.source.package and course.source.version must both be non-empty strings',
51
+ };
52
+ }
53
+
54
+ const packageName = source.package.trim();
55
+ const courseVersion = source.version.trim();
56
+ if (!SEMVER.test(courseVersion)) {
57
+ return {
58
+ state: 'E3_INVALID_VERSION',
59
+ reason: `course.source.version is not valid semver: ${courseVersion}`,
60
+ courseVersion,
61
+ package: packageName,
62
+ };
63
+ }
64
+
65
+ const expectedPackage = typeof candidate?.expectedPackage === 'string'
66
+ ? candidate.expectedPackage.trim()
67
+ : candidate?.expectedPackage;
68
+ if (!PACKAGE_NAME.test(packageName)
69
+ || (expectedPackage !== null && expectedPackage !== undefined
70
+ && (!PACKAGE_NAME.test(expectedPackage) || packageName !== expectedPackage))) {
71
+ return {
72
+ state: 'E4_PACKAGE_MISMATCH',
73
+ reason: expectedPackage
74
+ ? `course.source.package ${packageName} does not match expectedPackage ${expectedPackage}`
75
+ : `course.source.package is not a valid npm package name: ${packageName}`,
76
+ courseVersion,
77
+ package: packageName,
78
+ };
79
+ }
80
+
81
+ const registryVersion = candidate?.registryVersion;
82
+ if (registryVersion === null || registryVersion === undefined) {
83
+ return {
84
+ state: 'E5_REGISTRY_UNKNOWN',
85
+ reason: 'registryVersion is absent, so package parity is unknown',
86
+ courseVersion,
87
+ package: packageName,
88
+ };
89
+ }
90
+ if (typeof registryVersion !== 'string' || !SEMVER.test(registryVersion.trim())) {
91
+ return {
92
+ state: 'E3_INVALID_VERSION',
93
+ reason: `registryVersion is not valid semver: ${String(registryVersion)}`,
94
+ courseVersion,
95
+ package: packageName,
96
+ };
97
+ }
98
+
99
+ const normalizedRegistryVersion = registryVersion.trim();
100
+ const ordering = compareVersions(courseVersion, normalizedRegistryVersion);
101
+ const common = {
102
+ courseVersion,
103
+ registryVersion: normalizedRegistryVersion,
104
+ package: packageName,
105
+ };
106
+ if (ordering === 0) {
107
+ return {
108
+ state: 'S0_SHIPPED',
109
+ reason: 'course.source.version equals registryVersion',
110
+ ...common,
111
+ };
112
+ }
113
+ if (ordering < 0) {
114
+ return {
115
+ state: 'S3_TUTORIAL_STALE',
116
+ reason: 'course.source.version is below registryVersion',
117
+ ...common,
118
+ };
119
+ }
120
+ return {
121
+ state: 'S4_PACKAGE_BEHIND',
122
+ reason: 'course.source.version is above registryVersion',
123
+ ...common,
124
+ };
125
+ }