@dzhechkov/harness-core 0.8.34 → 0.8.36

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 (62) hide show
  1. package/.dz-manifest.json +61 -61
  2. package/README.md +255 -12
  3. package/dist/agentdb-index.d.ts +22 -1
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +156 -6
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +180 -2
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +781 -38
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/codex-hooks-assets.d.ts.map +1 -1
  12. package/dist/codex-hooks-assets.js +67 -5
  13. package/dist/codex-hooks-assets.js.map +1 -1
  14. package/dist/codex-hooks.d.ts +13 -1
  15. package/dist/codex-hooks.d.ts.map +1 -1
  16. package/dist/codex-hooks.js +13 -1
  17. package/dist/codex-hooks.js.map +1 -1
  18. package/dist/index.d.ts +8 -6
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +9 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/mutation-gate.d.ts +19 -0
  23. package/dist/mutation-gate.d.ts.map +1 -1
  24. package/dist/mutation-gate.js +37 -1
  25. package/dist/mutation-gate.js.map +1 -1
  26. package/dist/operations.d.ts +17 -1
  27. package/dist/operations.d.ts.map +1 -1
  28. package/dist/operations.js +88 -9
  29. package/dist/operations.js.map +1 -1
  30. package/dist/publish.d.ts +59 -7
  31. package/dist/publish.d.ts.map +1 -1
  32. package/dist/publish.js +205 -32
  33. package/dist/publish.js.map +1 -1
  34. package/dist/release-line.d.ts +16 -0
  35. package/dist/release-line.d.ts.map +1 -1
  36. package/dist/release-line.js +31 -0
  37. package/dist/release-line.js.map +1 -1
  38. package/dist/setup.d.ts.map +1 -1
  39. package/dist/setup.js +90 -14
  40. package/dist/setup.js.map +1 -1
  41. package/dist/skills.d.ts +87 -3
  42. package/dist/skills.d.ts.map +1 -1
  43. package/dist/skills.js +266 -15
  44. package/dist/skills.js.map +1 -1
  45. package/dist/vector-tier.d.ts +34 -3
  46. package/dist/vector-tier.d.ts.map +1 -1
  47. package/dist/vector-tier.js +117 -22
  48. package/dist/vector-tier.js.map +1 -1
  49. package/package.json +2 -2
  50. package/sbom.json +60 -60
  51. package/src/agentdb-index.ts +158 -7
  52. package/src/apply-leg.ts +824 -38
  53. package/src/codex-hooks-assets.ts +67 -5
  54. package/src/codex-hooks.ts +13 -1
  55. package/src/index.ts +15 -2
  56. package/src/mutation-gate.ts +58 -2
  57. package/src/operations.ts +91 -10
  58. package/src/publish.ts +247 -30
  59. package/src/release-line.ts +32 -0
  60. package/src/setup.ts +81 -16
  61. package/src/skills.ts +303 -14
  62. package/src/vector-tier.ts +147 -24
@@ -141,6 +141,66 @@ function findProjectRoot(startDir) {
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * FR-1 (codex-hook-root-provenance): the ONE place both hooks compute their start directory and
146
+ * walk to a project root — replacing two independent copies of the same ternary. T1 (fix-round 1:
147
+ * the original reproducer had an unexported \`BASE\`, so it measured the wrong file; corrected and
148
+ * re-run — see the feature's change manifest for both) measured LIVE on \`codex-cli 0.154.0\`: three
149
+ * real \`codex exec\` sessions (project root, a nested subdirectory, a directory with no \`.dz\`
150
+ * anywhere in its ancestry), each with BOTH hook events (\`PreToolUse\` and \`UserPromptSubmit\`)
151
+ * captured SEPARATELY. \`payload.cwd\` was present and equal to both \`PWD\` and the hook's own
152
+ * \`process.cwd()\` in every one of the 6 captures. \`PWD\`/\`process.cwd()\` therefore stay only as a
153
+ * DEFENSIVE fallback for a payload shaped without \`cwd\` — not because that fallback was ever
154
+ * observed to fire. This is a SCOPED finding, not a claim that the "hook read the wrong project"
155
+ * defect class cannot exist: it was not observed on codex-cli 0.154.0 across these 3 scenarios / 6
156
+ * captures, and 01_requirements.md's own Ограничение C-3 is what permits cutting FR-3 (the explicit
157
+ * \`DZ_PROJECT_ROOT\` override) on a scoped finding like that — not a claim of nonexistence.
158
+ */
159
+ function resolveHookRoot(payload) {
160
+ const hasPayloadCwd = typeof payload.cwd === 'string' && payload.cwd !== '';
161
+ const startDir = hasPayloadCwd ? payload.cwd : (process.env.PWD || process.cwd());
162
+ const source = hasPayloadCwd ? 'payload-cwd' : (process.env.PWD ? 'env-pwd' : 'process-cwd');
163
+ return { root: findProjectRoot(startDir), source, startDir };
164
+ }
165
+
166
+ /**
167
+ * Fix-round 1, item 4: a path interpolated into the provenance line below can itself carry control
168
+ * characters — a \`payload.cwd\` from an untrusted producer, or a \`PWD\` set to something hostile —
169
+ * and a bare newline in the middle of it would defeat the "ONE line" promise the diagnostic makes.
170
+ * Escape the whole C0 range (0x00-0x1F) plus DEL (0x7F) into a visible \`\\n\`/\\r\`/\\t\`/\\xHH\`
171
+ * representation; every other byte, including non-ASCII path segments, passes through unchanged.
172
+ */
173
+ function escapeControlChars(value) {
174
+ return String(value).replace(/[\\x00-\\x1f\\x7f]/g, function (ch) {
175
+ var code = ch.charCodeAt(0);
176
+ if (code === 10) return '\\\\n';
177
+ if (code === 13) return '\\\\r';
178
+ if (code === 9) return '\\\\t';
179
+ return '\\\\x' + code.toString(16).padStart(2, '0');
180
+ });
181
+ }
182
+
183
+ /**
184
+ * FR-2: ONE provenance line, same shape as the Claude hook's (\`apply-leg.ts\`'s \`skip()\`), printed
185
+ * to stderr. When no root was found it ALWAYS prints (the walk's whole verdict was silent before
186
+ * this feature); when a root WAS found it prints only under \`DZ_CODEX_HOOK_DEBUG\`, so the found
187
+ * path stays byte-for-byte silent by default (NFR-2). Tradeoff, named plainly: this line discloses
188
+ * the absolute directory the hook was asked about (which can embed a username, a customer or
189
+ * repository name) to stderr — accepted because it is a diagnostic aimed at the person running the
190
+ * hook, not a return value, and redacting it would make the not-found case as silent as the bug
191
+ * this feature exists to fix. \`startDir\`/\`root\` are escaped via \`escapeControlChars\` first, so an
192
+ * adversarial value cannot itself defeat the "ONE line" guarantee.
193
+ */
194
+ function reportRootProvenance(resolved) {
195
+ if (resolved.root === null) {
196
+ process.stderr.write(\`[\${HELPER}] skipped reason=no-project-root start=\${escapeControlChars(resolved.startDir)} (\${resolved.source})\\n\`);
197
+ return;
198
+ }
199
+ if (process.env.DZ_CODEX_HOOK_DEBUG) {
200
+ process.stderr.write(\`[\${HELPER}] root=\${escapeControlChars(resolved.root)} start=\${escapeControlChars(resolved.startDir)} (\${resolved.source})\\n\`);
201
+ }
202
+ }
203
+
144
204
  function readProjectConfig(root) {
145
205
  try {
146
206
  return JSON.parse(fs.readFileSync(path.join(root, '.dz', 'config.json'), 'utf8'));
@@ -229,9 +289,10 @@ async function main() {
229
289
  const command = input && typeof input === 'object' ? input.command : undefined;
230
290
  if (typeof command !== 'string' || command === '') return 0;
231
291
 
232
- const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : process.env.PWD || process.cwd();
233
- const root = findProjectRoot(cwd);
234
- if (root === null) return 0; // inert outside an opted-in dz project: no decision, no output, no write
292
+ const resolved = resolveHookRoot(payload);
293
+ reportRootProvenance(resolved);
294
+ const root = resolved.root;
295
+ if (root === null) return 0; // inert outside an opted-in dz project: no DECISION and no WRITE — one diagnostic line on stderr (FR-2), nothing else
235
296
 
236
297
  // (1) The destructive-command guard. Never blocks on our own failure: an absent module, a throw,
237
298
  // or an \`undecidable\` verdict all fall through to the shell veto below (AC-10).
@@ -370,8 +431,9 @@ async function main() {
370
431
  const prompt = typeof payload.prompt === 'string' ? payload.prompt : '';
371
432
  if (prompt.trim() === '') return;
372
433
 
373
- const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : process.env.PWD || process.cwd();
374
- const root = findProjectRoot(cwd);
434
+ const resolved = resolveHookRoot(payload);
435
+ reportRootProvenance(resolved);
436
+ const root = resolved.root;
375
437
  if (root === null) return; // inert outside an opted-in dz project
376
438
 
377
439
  const policy = await loadCore(root, 'recall-hook-policy.js', (m) => typeof m.selectHookHits === 'function');
@@ -56,8 +56,20 @@ import { mergeManagedHookEntries } from './managed-hooks.js';
56
56
  * run was indistinguishable from a clean allow. The Claude hook already failed open loudly here.
57
57
  * Now it prints ONE line, `DZ-DESTRUCTIVE-WARN: classifier threw — <message>`, and still exits 0.
58
58
  * A changed body ⇒ re-trust.
59
+ * 8 — `codex-hook-root-provenance`: both hooks now share ONE `resolveHookRoot(payload)` instead of
60
+ * two copies of the same `payload.cwd || PWD || cwd()` ternary, and a silent `root === null` early
61
+ * return now prints one provenance line (`[dz-codex-<hook>] skipped reason=no-project-root
62
+ * start=<startDir> (<source>)`); the found-root path stays silent unless `DZ_CODEX_HOOK_DEBUG` is
63
+ * set. T1 (live probe, codex-cli 0.154.0) found `payload.cwd` always present and equal to `PWD`/
64
+ * `process.cwd()`, so no explicit-override knob was added. A changed body ⇒ re-trust.
65
+ * 9 — fix-round 1: the provenance line's interpolated paths are now escaped via
66
+ * `escapeControlChars` (C0 range + DEL) before printing, so a hostile `payload.cwd` cannot defeat
67
+ * the "ONE line" promise with an embedded newline; the corrected T1 re-run (both hook events
68
+ * captured separately, per-scenario — the original reproducer's `BASE` was never exported) reached
69
+ * the SAME conclusion, scoped honestly as "not observed on codex-cli 0.154.0 across 3 scenarios / 6
70
+ * captures", not "does not exist". A changed body ⇒ re-trust.
59
71
  */
60
- export const DZ_HOOK_HELPER_VERSION = 7;
72
+ export const DZ_HOOK_HELPER_VERSION = 9;
61
73
 
62
74
  /** Seconds. Probe-proven (spike S2): `timeout` is honored, the unset default is 600 s. */
63
75
  export const DZ_HOOK_TIMEOUT_SECONDS = 5;
package/src/index.ts CHANGED
@@ -12,6 +12,12 @@ export const HARNESS_CORE_VERSION: string =
12
12
 
13
13
  export { REPOSITORY_ORIGIN } from './repository-origin.js';
14
14
 
15
+ // Fix-round 1 (feature recall-short-terms, Codex HIGH-1c): `dz recall`'s CLI printer needs the
16
+ // single source of truth for "why did this query tokenize to nothing" without harness-cli taking
17
+ // a new direct dependency on `@dzhechkov/memory` (a publishing-surface change outside this
18
+ // feature's scope) — harness-core already depends on memory, so it re-exports the one helper.
19
+ export { noSearchableTermsReason } from '@dzhechkov/memory';
20
+
15
21
  export * from './skills.js';
16
22
  export * from './apply.js';
17
23
  export {
@@ -197,6 +203,9 @@ export {
197
203
  mirrorPatternsToVector,
198
204
  backfillVectorMirror,
199
205
  mergeHybridHits,
206
+ compareHybridHits,
207
+ evidenceRank,
208
+ orderHitsForReRank,
200
209
  recallHybrid,
201
210
  teachGuard,
202
211
  vectorTierStatus,
@@ -218,6 +227,7 @@ export type {
218
227
  HybridRecall,
219
228
  HybridRecallMode,
220
229
  HybridHit,
230
+ HybridOrderKey,
221
231
  RankedPattern,
222
232
  VectorServiceOptions,
223
233
  VectorTierStatus,
@@ -246,6 +256,7 @@ export {
246
256
  applyLegHookEntries,
247
257
  applyLegStatus,
248
258
  applyLegReasonMessage,
259
+ probeApplyLeg,
249
260
  resolveIdleMs,
250
261
  IDLE_MS_INT32_MAX,
251
262
  } from './apply-leg.js';
@@ -255,6 +266,7 @@ export type {
255
266
  ApplyLegHookPresence,
256
267
  ApplyLegStatus,
257
268
  ApplyLegNotInstalledReason,
269
+ ApplyLegProbeResult,
258
270
  ResolvedIdleMs,
259
271
  } from './apply-leg.js';
260
272
  // embed-socket-short-path: the ONE resolver the daemon (inlined text), the recall hook (inlined
@@ -303,7 +315,7 @@ export {
303
315
  segmentRun,
304
316
  } from './eta.js';
305
317
  export type { CheckpointObservation, EtaEstimate, EtaInput, IncompleteCoverageSample, RunSegment, StageDurationSample, StageSample } from './eta.js';
306
- export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, resetAgentdbEmbedderCache, getAgentdbEmbedderCacheStats, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema } from './agentdb-index.js';
318
+ export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, resetAgentdbEmbedderCache, getAgentdbEmbedderCacheStats, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema, readStoreGeneration, bumpStoreGeneration } from './agentdb-index.js';
307
319
  export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
308
320
  export { DEFAULT_EMBED_MODEL, LEGACY_EMBED_MODEL, DEFAULT_EMBED_DIM, KNOWN_EMBED_DIMS, resolveEmbedModel, readEmbedManifest, writeEmbedManifest, embedManifestPath, legacyEmbedManifest } from './embedding-config.js';
309
321
  export type { EmbedModelConfig, EmbedModelSource, EmbedManifest } from './embedding-config.js';
@@ -722,7 +734,8 @@ export type {
722
734
  ChainDefectAges,
723
735
  ChainedJournal,
724
736
  } from './event-chain.js';
725
- export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion } from './publish.js';
737
+ export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
738
+ export type { ReadmeVersionSyncPlan, ReadmeSyncRewrite } from './publish.js';
726
739
  export { RELEASE_LINE_RE, findReleaseLine, rewriteReleaseLine } from './release-line.js';
727
740
  export * from './course-staleness.js';
728
741
  export { fetchAllDownloads } from './downloads.js';
@@ -77,6 +77,22 @@ export interface MutationRegistry {
77
77
  readonly testCommand?: string;
78
78
  /** opt-in proof that the suite harness reached its clean completion path. */
79
79
  readonly requireCompletionReceipt?: boolean;
80
+ /**
81
+ * optional per-registry suite-run ceiling in milliseconds (mutation-gate-timeout-verdict FR-3):
82
+ * a package whose real baseline runs longer than the executor's 300000ms default (e.g. this
83
+ * repo's core package, MEASURED ≈5-8 min) declares its own floor here so `dz mutation-gate` with
84
+ * no `--timeout` flag still succeeds — precedence is flag > this field > the 300000ms default.
85
+ */
86
+ readonly timeoutMs?: number;
87
+ /**
88
+ * optional per-registry vitest worker ceiling (mutation-gate-baseline-honesty FR-2): baseline and
89
+ * mutant runs spawn the package's FULL `testCommand` at vitest's default worker count (= cpu
90
+ * cores), and under embedding-daemon tests (0.7-3.5 GB/process) this repo's core package measured
91
+ * load 62-358 and 0.4-1.8 GB free on an 8-core/16GB box — three full runs died overnight
92
+ * (0bb74d66). The same suite with `--maxWorkers=2` passed (6909/6909). Precedence is the
93
+ * `--max-workers` flag > this field > `min(4, max(1, floor(cpus/2)))`.
94
+ */
95
+ readonly maxWorkers?: number;
80
96
  readonly entries: readonly MutationRegistryEntry[];
81
97
  }
82
98
 
@@ -306,10 +322,12 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
306
322
  let entriesRaw: unknown;
307
323
  let testCommand: string | undefined;
308
324
  let requireCompletionReceipt: boolean | undefined;
325
+ let timeoutMs: number | undefined;
326
+ let maxWorkers: number | undefined;
309
327
  if (Array.isArray(raw)) {
310
328
  entriesRaw = raw;
311
329
  } else if (raw && typeof raw === 'object') {
312
- const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; entries?: unknown };
330
+ const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; timeoutMs?: unknown; maxWorkers?: unknown; entries?: unknown };
313
331
  entriesRaw = obj.entries;
314
332
  if (obj.testCommand !== undefined) {
315
333
  if (typeof obj.testCommand !== 'string' || obj.testCommand.trim() === '') {
@@ -323,6 +341,18 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
323
341
  }
324
342
  requireCompletionReceipt = obj.requireCompletionReceipt;
325
343
  }
344
+ if (obj.timeoutMs !== undefined) {
345
+ if (typeof obj.timeoutMs !== 'number' || !Number.isFinite(obj.timeoutMs) || obj.timeoutMs <= 0) {
346
+ return { registry: null, entryResults: [], errors: ['timeoutMs must be a finite number > 0 when present'] };
347
+ }
348
+ timeoutMs = obj.timeoutMs;
349
+ }
350
+ if (obj.maxWorkers !== undefined) {
351
+ if (typeof obj.maxWorkers !== 'number' || !Number.isInteger(obj.maxWorkers) || obj.maxWorkers < 1) {
352
+ return { registry: null, entryResults: [], errors: ['maxWorkers must be a positive integer when present'] };
353
+ }
354
+ maxWorkers = obj.maxWorkers;
355
+ }
326
356
  }
327
357
  if (!Array.isArray(entriesRaw)) {
328
358
  return { registry: null, entryResults: [], errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
@@ -439,6 +469,8 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
439
469
  registry: {
440
470
  ...(testCommand !== undefined ? { testCommand } : {}),
441
471
  ...(requireCompletionReceipt !== undefined ? { requireCompletionReceipt } : {}),
472
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
473
+ ...(maxWorkers !== undefined ? { maxWorkers } : {}),
442
474
  entries,
443
475
  },
444
476
  entryResults,
@@ -446,6 +478,26 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
446
478
  };
447
479
  }
448
480
 
481
+ // ── Feature scoping (qe-step-gate-scoped-to-feature) — pure registry diff, no IO ───────────────
482
+ //
483
+ // Step 8's QE mutation gate ran the FULL registry unconditionally (MEASURED 2026-09-12: 358
484
+ // entries on this repo's core package, 30-40 minutes, timed out INCONCLUSIVE every time), though a
485
+ // feature only owns its own touched files and any entries it newly declares. The `--touched` and
486
+ // `--added-since` CLI selectors (harness-cli's executor) scope the run; the executor reads the base
487
+ // registry via `git show <ref>:<path>` (I/O) and hands both parsed registries to this PURE diff so
488
+ // the comparison itself stays testable without a filesystem or git process (NFR-1).
489
+
490
+ /** Entry ids present in `current` but absent from `base` (by id, not by content). A `null` base
491
+ * means the registry did not exist at the reference point — every current entry counts as added. */
492
+ export function registryEntriesAddedSince(
493
+ base: MutationRegistry | null,
494
+ current: MutationRegistry,
495
+ ): string[] {
496
+ if (base === null) return current.entries.map((entry) => entry.id);
497
+ const baseIds = new Set(base.entries.map((entry) => entry.id));
498
+ return current.entries.filter((entry) => !baseIds.has(entry.id)).map((entry) => entry.id);
499
+ }
500
+
449
501
  // ── Mutation application — exact text surgery, exactly once (rule 1) ──────────────────────────
450
502
 
451
503
  export interface AppliedMutation {
@@ -732,7 +784,11 @@ export function attributeBaselineRedness(
732
784
  if (file !== null && !files.includes(file)) files.push(file);
733
785
  };
734
786
 
735
- const vitestMatches = [...output.matchAll(/^\s*FAIL\s+(\S+)/gm)];
787
+ // mutation-gate-baseline-honesty FR-1: vitest 3 prints an optional POOL LABEL between `FAIL` and
788
+ // the file path (`FAIL |serial| test/x.test.ts > case`, `FAIL |parallel| …`) — the plain
789
+ // `(\S+)` used to capture the label itself as "the file", which normaliseReportedFile then
790
+ // rejects, turning a perfectly parseable red run into `unparseable from runner output`.
791
+ const vitestMatches = [...output.matchAll(/^\s*FAIL\s+(?:\|[^|\n]*\|\s+)?(\S+)/gm)];
736
792
  for (const match of vitestMatches) add(match[1] ?? '');
737
793
 
738
794
  const tapMatches = [...output.matchAll(/^not ok \d+\s+-\s+(.+)$/gm)];
package/src/operations.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * @packageDocumentation
7
7
  */
8
8
 
9
- import { execFileSync, spawnSync } from 'node:child_process';
9
+ import { execFileSync, spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
12
12
  import { homedir, tmpdir } from 'node:os';
@@ -1542,6 +1542,33 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1542
1542
  ? `embed socket present at ${resolved.path}${tmpdirNote}${engineNote} — recall injection can run`
1543
1543
  : `embed socket ABSENT at ${resolved.path}: the recall hook is wired but cannot inject (the hook self-heals on the next prompt; a persistent absence means the daemon cannot start)`,
1544
1544
  });
1545
+
1546
+ // APPLY-LEG INJECTS — live probe (feature `apply-leg-never-silent`, ADR-001 Decision 1).
1547
+ // Issue #2's whole defect was that `installed`/`alive` above can ALL read green while the
1548
+ // leg injects nothing in every session but one (a foreign install-root symptom the file-
1549
+ // presence and socket-presence checks above cannot see by construction — they check for
1550
+ // the RIGHT FILES at the RIGHT PATH, never whether the deployed COMMAND actually resolves
1551
+ // to this project's store from a foreign cwd). This row is the difference: it runs the
1552
+ // ACTUAL configured hook command end-to-end and is green ONLY on an observed injection.
1553
+ // Deliberately a SEPARATE try/catch from the socket-alive check above: a probe failure
1554
+ // must never suppress the (already useful) socket-presence row, and vice versa.
1555
+ try {
1556
+ const { probeApplyLeg } = await import('./apply-leg.js');
1557
+ const probe = await probeApplyLeg(root);
1558
+ checks.push({
1559
+ name: 'apply-leg injects (live probe)',
1560
+ ok: probe.ok,
1561
+ detail: probe.ok
1562
+ ? `live probe injected its beacon lesson in ${probe.elapsedMs}ms — the recall hook actually finds this project's store`
1563
+ : `installed but silent: ${probe.reason ?? 'unknown'} (probed in ${probe.elapsedMs}ms) — dz setup wrote the hook, but it is not injecting anything into real sessions`,
1564
+ });
1565
+ } catch (err) {
1566
+ checks.push({
1567
+ name: 'apply-leg injects (live probe)',
1568
+ ok: false,
1569
+ detail: `live probe could not run: ${err instanceof Error ? err.message : String(err)}`,
1570
+ });
1571
+ }
1545
1572
  }
1546
1573
  }
1547
1574
  } catch {
@@ -1852,18 +1879,72 @@ function probeCodexVersion(): string | null {
1852
1879
  * with the helper's own self-failure note unable to fire because the process never started.
1853
1880
  * Grading on file presence would call that "installed".
1854
1881
  */
1855
- export function probeHookLiveness(command: string, payload: string): { readonly status: number | null; readonly stderr: string } {
1882
+ /**
1883
+ * `opts` (feature `apply-leg-never-silent`, ADR-001 D1): additive, optional — every pre-existing
1884
+ * 2-arg caller (the Codex veto-hook liveness checks above) is unaffected. `cwd`/`env` let a caller
1885
+ * reproduce the EXACT conditions a real invoking session presents (a foreign cwd, an overridden
1886
+ * `CLAUDE_PROJECT_DIR`) rather than always running from THIS process's own cwd/env — the apply-leg
1887
+ * live probe needs exactly that to prove install-root resolution end-to-end, not merely structurally.
1888
+ * `stdout` is returned alongside `stderr`/`status` for the same reason: a UserPromptSubmit hook's
1889
+ * payload (`hookSpecificOutput.additionalContext`) rides stdout, not stderr (see `probeApplyLeg`'s
1890
+ * own doc comment for the measured stderr-visibility fact this displaces).
1891
+ */
1892
+ export function probeHookLiveness(
1893
+ command: string,
1894
+ payload: string,
1895
+ opts: { readonly cwd?: string; readonly env?: Readonly<Record<string, string>>; readonly timeoutMs?: number } = {},
1896
+ ): { readonly status: number | null; readonly stdout: string; readonly stderr: string; readonly groupKillAttempted: boolean } {
1856
1897
  const shell = process.env['SHELL'] ?? '/bin/sh';
1898
+ // Fix round 1 (apply-leg-never-silent, HIGH-1): a caller previously had to INFER "was the group
1899
+ // kill sent" by reading this function's source — a regression removing or bypassing the
1900
+ // `process.kill(-pid, ...)` call below would silently invalidate that inference. `groupKillAttempted`
1901
+ // is the OBSERVABLE fact instead: true exactly when this call reached the point of attempting the
1902
+ // kill syscall (`res.pid` was a real positive pid), false when it never got that far (e.g. the
1903
+ // spawn itself never produced a pid). It does NOT claim the signal found a live recipient — ESRCH
1904
+ // ("group already gone", the common successful-exit case) still counts as "sent": the syscall was
1905
+ // issued, its target simply no longer existed. That is a SEPARATE fact from whether the grandchild
1906
+ // is actually dead by the time a caller checks — see `probeApplyLeg`'s AM-5 test for the
1907
+ // kill-sent-vs-death-observed split this field exists to make possible.
1908
+ let groupKillAttempted = false;
1857
1909
  try {
1858
- const res = spawnSync(shell, ['-lc', command], {
1910
+ // AM-5 (fix round 1, apply-leg-never-silent): `detached: true` puts the shell in its OWN
1911
+ // process GROUP (pgid === its own pid) instead of sharing the caller's — `spawnSync`'s own
1912
+ // timeout kill signals only the DIRECT child (the shell), never anything the shell forked, so a
1913
+ // `node "<hook>" || true` grandchild that is still running when the shell dies is orphaned but
1914
+ // free to keep touching whatever this probe is about to remove (the beacon, the temp cwd).
1915
+ // Killing the NEGATIVE pid below reaches the whole group in one signal. `@types/node`'s
1916
+ // `SpawnSyncOptions` does not DECLARE `detached` (only the async `SpawnOptions` does) — MEASURED
1917
+ // this is a typings gap, not a runtime one: a real child under `spawnSync(..., {detached:true})`
1918
+ // reports its own pgid === its own pid (verified with `ps -o pgid=`), exactly as it would under
1919
+ // async `spawn`. Widened via an inline type intersection rather than `as any` so every OTHER key
1920
+ // stays checked.
1921
+ const spawnOpts: SpawnSyncOptionsWithStringEncoding & { readonly detached?: boolean } = {
1859
1922
  input: payload,
1860
1923
  encoding: 'utf8',
1861
- timeout: 20_000,
1862
- env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1' },
1863
- });
1864
- return { status: res.status, stderr: res.stderr ?? '' };
1924
+ timeout: opts.timeoutMs ?? 20_000,
1925
+ detached: true,
1926
+ ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
1927
+ env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1', ...(opts.env ?? {}) },
1928
+ };
1929
+ const res = spawnSync(shell, ['-lc', command], spawnOpts);
1930
+ // Belt, run on EVERY outcome (timeout OR a clean, on-time exit): a `node` grandchild can still
1931
+ // be alive in the group even after the shell itself exited normally (e.g. it double-forked or
1932
+ // outlived a `|| true` that already returned). ESRCH — the common, successful case, everything
1933
+ // already exited — is swallowed; this is best-effort cleanup, never a probe failure.
1934
+ if (typeof res.pid === 'number' && res.pid > 0) {
1935
+ try {
1936
+ process.kill(-res.pid, 'SIGKILL');
1937
+ } catch {
1938
+ /* group already gone */
1939
+ } finally {
1940
+ // set right after the process.kill(-pid, 'SIGKILL') attempt (HIGH-1 lead decision): reached
1941
+ // regardless of ESRCH, because ESRCH means "no recipient", not "syscall not issued".
1942
+ groupKillAttempted = true;
1943
+ }
1944
+ }
1945
+ return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '', groupKillAttempted };
1865
1946
  } catch (err) {
1866
- return { status: null, stderr: String((err as Error)?.message ?? err) };
1947
+ return { status: null, stdout: '', stderr: String((err as Error)?.message ?? err), groupKillAttempted };
1867
1948
  }
1868
1949
  }
1869
1950
 
@@ -2043,7 +2124,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
2043
2124
  // a home that never opted in (the leg-1 F12 lesson).
2044
2125
  if (options.check === true) {
2045
2126
  const drift = diffCodexHooks(currentText, entries, manifest);
2046
- const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stderr: '' };
2127
+ const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stdout: '', stderr: '' };
2047
2128
  const executable = drift.installed && (options.liveness === false || live.status === 0 || live.status === 2);
2048
2129
  // `--check` must report the TRUST axis too. Without it the report said `installed && executable`
2049
2130
  // with `trust: 'unknown'`, and the CLI printed a success word for it — the exact G-G/AM-17
@@ -2142,7 +2223,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
2142
2223
 
2143
2224
 
2144
2225
  // (6) LIVENESS: exit 127 is ALLOW to the runtime, so it must never be graded as installed (G-L).
2145
- const live = options.liveness === false ? { status: 0, stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
2226
+ const live = options.liveness === false ? { status: 0, stdout: '', stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
2146
2227
  const executable = live.status === 0 || live.status === 2;
2147
2228
  if (!executable) {
2148
2229
  warnings.push(