@dzhechkov/harness-core 0.8.34 → 0.8.35

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.
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 {
@@ -246,6 +252,7 @@ export {
246
252
  applyLegHookEntries,
247
253
  applyLegStatus,
248
254
  applyLegReasonMessage,
255
+ probeApplyLeg,
249
256
  resolveIdleMs,
250
257
  IDLE_MS_INT32_MAX,
251
258
  } from './apply-leg.js';
@@ -255,6 +262,7 @@ export type {
255
262
  ApplyLegHookPresence,
256
263
  ApplyLegStatus,
257
264
  ApplyLegNotInstalledReason,
265
+ ApplyLegProbeResult,
258
266
  ResolvedIdleMs,
259
267
  } from './apply-leg.js';
260
268
  // embed-socket-short-path: the ONE resolver the daemon (inlined text), the recall hook (inlined
@@ -303,7 +311,7 @@ export {
303
311
  segmentRun,
304
312
  } from './eta.js';
305
313
  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';
314
+ 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
315
  export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
308
316
  export { DEFAULT_EMBED_MODEL, LEGACY_EMBED_MODEL, DEFAULT_EMBED_DIM, KNOWN_EMBED_DIMS, resolveEmbedModel, readEmbedManifest, writeEmbedManifest, embedManifestPath, legacyEmbedManifest } from './embedding-config.js';
309
317
  export type { EmbedModelConfig, EmbedModelSource, EmbedManifest } from './embedding-config.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,57 @@ 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 } {
1856
1897
  const shell = process.env['SHELL'] ?? '/bin/sh';
1857
1898
  try {
1858
- const res = spawnSync(shell, ['-lc', command], {
1899
+ // AM-5 (fix round 1, apply-leg-never-silent): `detached: true` puts the shell in its OWN
1900
+ // process GROUP (pgid === its own pid) instead of sharing the caller's — `spawnSync`'s own
1901
+ // timeout kill signals only the DIRECT child (the shell), never anything the shell forked, so a
1902
+ // `node "<hook>" || true` grandchild that is still running when the shell dies is orphaned but
1903
+ // free to keep touching whatever this probe is about to remove (the beacon, the temp cwd).
1904
+ // Killing the NEGATIVE pid below reaches the whole group in one signal. `@types/node`'s
1905
+ // `SpawnSyncOptions` does not DECLARE `detached` (only the async `SpawnOptions` does) — MEASURED
1906
+ // this is a typings gap, not a runtime one: a real child under `spawnSync(..., {detached:true})`
1907
+ // reports its own pgid === its own pid (verified with `ps -o pgid=`), exactly as it would under
1908
+ // async `spawn`. Widened via an inline type intersection rather than `as any` so every OTHER key
1909
+ // stays checked.
1910
+ const spawnOpts: SpawnSyncOptionsWithStringEncoding & { readonly detached?: boolean } = {
1859
1911
  input: payload,
1860
1912
  encoding: 'utf8',
1861
- timeout: 20_000,
1862
- env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1' },
1863
- });
1864
- return { status: res.status, stderr: res.stderr ?? '' };
1913
+ timeout: opts.timeoutMs ?? 20_000,
1914
+ detached: true,
1915
+ ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
1916
+ env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1', ...(opts.env ?? {}) },
1917
+ };
1918
+ const res = spawnSync(shell, ['-lc', command], spawnOpts);
1919
+ // Belt, run on EVERY outcome (timeout OR a clean, on-time exit): a `node` grandchild can still
1920
+ // be alive in the group even after the shell itself exited normally (e.g. it double-forked or
1921
+ // outlived a `|| true` that already returned). ESRCH — the common, successful case, everything
1922
+ // already exited — is swallowed; this is best-effort cleanup, never a probe failure.
1923
+ if (typeof res.pid === 'number' && res.pid > 0) {
1924
+ try {
1925
+ process.kill(-res.pid, 'SIGKILL');
1926
+ } catch {
1927
+ /* group already gone */
1928
+ }
1929
+ }
1930
+ return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
1865
1931
  } catch (err) {
1866
- return { status: null, stderr: String((err as Error)?.message ?? err) };
1932
+ return { status: null, stdout: '', stderr: String((err as Error)?.message ?? err) };
1867
1933
  }
1868
1934
  }
1869
1935
 
@@ -2043,7 +2109,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
2043
2109
  // a home that never opted in (the leg-1 F12 lesson).
2044
2110
  if (options.check === true) {
2045
2111
  const drift = diffCodexHooks(currentText, entries, manifest);
2046
- const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stderr: '' };
2112
+ const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stdout: '', stderr: '' };
2047
2113
  const executable = drift.installed && (options.liveness === false || live.status === 0 || live.status === 2);
2048
2114
  // `--check` must report the TRUST axis too. Without it the report said `installed && executable`
2049
2115
  // with `trust: 'unknown'`, and the CLI printed a success word for it — the exact G-G/AM-17
@@ -2142,7 +2208,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
2142
2208
 
2143
2209
 
2144
2210
  // (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);
2211
+ const live = options.liveness === false ? { status: 0, stdout: '', stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
2146
2212
  const executable = live.status === 0 || live.status === 2;
2147
2213
  if (!executable) {
2148
2214
  warnings.push(
package/src/setup.ts CHANGED
@@ -752,12 +752,17 @@ function installDriverDocs(projectRoot: string, force: boolean): string {
752
752
  * forth forever and neither step ever reports `skipped`, breaking the pre-existing
753
753
  * `setup.test.ts` "PreCompact merge is idempotent" contract (FR-6) this feature must not touch.
754
754
  *
755
- * ADDITIVE-ONLY, deliberately NOT `mergeManagedHookEntries`: this step never needs to REPLACE a
756
- * stale command text (the two commands `applyLegHookEntries()` emits do not change without an
757
- * `APPLY_LEG_VERSION` bump, and a version bump is about the FILE content, not the hook command) —
758
- * it only needs "is our command already referenced under this event, anywhere, in any position?".
759
- * That question is order-independent, so it can never itself be a source of reordering, and it is
760
- * exactly what keeps "Configure hooks" stable once the first run has established the layout above.
755
+ * ADD-OR-REPLACE-IN-PLACE, deliberately NOT `mergeManagedHookEntries`: this step never REORDERS —
756
+ * a match keeps its POSITION, only its command text is swapped — so it stays the same "is our
757
+ * command already referenced under this event, anywhere, in any position?" question
758
+ * `mergeManagedHookEntries`'s drop-and-reappend-at-tail algorithm answers differently (by moving
759
+ * the entry), which is exactly what "Configure hooks" must never do to a foreign SessionStart entry
760
+ * on the very first run (see above). Before feature `apply-leg-install-root` the two commands never
761
+ * changed without an `APPLY_LEG_VERSION` bump (a version bump is about the FILE content, not the
762
+ * hook command), so ADDITIVE-ONLY (skip on any match) and ADD-OR-REPLACE (rewrite text on a
763
+ * stale-form match) were behaviourally identical; an install-root migration now changes the command
764
+ * text on its own, independent of the file version, so a stale `CLAUDE_PROJECT_DIR`-relative entry
765
+ * from a pre-feature install must be rewritten in place on the next `dz setup`, not left stale.
761
766
  */
762
767
  function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupStep {
763
768
  if (opts.noHooks) return { name: 'Install apply-leg', status: 'skipped', detail: '--no-hooks' };
@@ -812,9 +817,10 @@ function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupSt
812
817
  wroteHelpers = true;
813
818
  }
814
819
 
815
- // ADD-IF-MISSING, per event: FR-2's literal contract — "ours is added only if no command of
816
- // the event already contains OUR entry". Never removes or reorders an existing entry (foreign
817
- // OR our own) — see the WHY above for why that matters here.
820
+ // ADD-OR-REPLACE, per event: "ours is added when no command of the event references OUR marker
821
+ // yet, and REWRITTEN IN PLACE (same position) when one does but its text is stale". Never
822
+ // removes or reorders an existing entry (foreign OR our own) — see the WHY above for why that
823
+ // matters here.
818
824
  //
819
825
  // MEDIUM finding "совпадение подстроки в чужой команде" (fix round 1): the substring probe used
820
826
  // to be the bare filename (`recall-hook.cjs`), so a foreign command that merely MENTIONS the
@@ -823,20 +829,79 @@ function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupSt
823
829
  // of the command we would emit, or the command containing our full relative PATH
824
830
  // (`.claude/helpers/<file>`, the same marker `applyLegStatus` structurally looks for) — a bare
825
831
  // filename mention under any other wrapper text no longer counts.
826
- const entries = applyLegHookEntries();
832
+ // FR-2 (ADR-001 D2, apply-leg-install-root): bake THIS install's own absolute root into the
833
+ // two commands — the deployed helper already bakes an absolute CORE_DIST_DIR, so a relative
834
+ // command only masked that non-portability (issue #2, `Cannot find module` when project ===
835
+ // $HOME and a foreign session's CLAUDE_PROJECT_DIR pointed elsewhere, swallowed by
836
+ // `2>/dev/null || true`).
837
+ const entries = applyLegHookEntries(opts.projectRoot);
827
838
  const existingSettings = existsSync(settingsPath)
828
839
  ? (JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>)
829
840
  : {};
830
841
  const hooks = { ...((existingSettings['hooks'] ?? {}) as Record<string, unknown[]>) };
831
842
  let hooksAdded = false;
843
+ // ADD-OR-REPLACE, per event (FR-2/AC-3, apply-leg-install-root): a command that already
844
+ // invokes our marker path is OURS, whatever exact form it takes — a pre-feature
845
+ // `CLAUDE_PROJECT_DIR`-relative entry (or, in principle, a relocated install's stale absolute
846
+ // one) is REPLACED by the current command in place, never left stale AND never duplicated. An
847
+ // EXACT match of the command we would emit is a true no-op (idempotent re-setup — this is what
848
+ // keeps a routine re-run from ever thrashing the file, same guarantee the prior ADDITIVE-ONLY
849
+ // design gave when the command text truly never changed without a version bump; it can now
850
+ // change on install-root migration too, so replace must be part of the contract).
851
+ //
852
+ // AM-2 (fix round 1, HIGH): the prior version replaced the WHOLE matching GROUP
853
+ // (`hooks[event][i]`) with our bare `entry` — a group is Claude Code's matcher-plus-commands
854
+ // shape (`{matcher, hooks:[...]}`), so that discarded the group's `matcher` and any FOREIGN
855
+ // sibling command sharing the same `hooks[]` array whenever ours needed an upgrade. Fixed: only
856
+ // the ONE command object inside the group's own `hooks[]` array that matches OUR marker is
857
+ // replaced — the matcher and every other command in that array survive untouched. A single pass
858
+ // also now upgrades EVERY matching group, not just the first `findIndex` hit, so two stale
859
+ // managed entries left in two different groups (a prior bug's residue, or a hand-edited file)
860
+ // are both fixed in place rather than the second one being silently ignored.
832
861
  const addIfMissing = (event: string, ownCommand: string, markerPath: string, entry: unknown): void => {
833
862
  const current = Array.isArray(hooks[event]) ? hooks[event] : [];
834
- const alreadyPresent = current.some((e) =>
835
- commandsOf(e).some((cmd) => cmd === ownCommand || hookCommandInvokes(cmd, markerPath)),
836
- );
837
- if (alreadyPresent) return;
838
- hooks[event] = [...current, entry];
839
- hooksAdded = true;
863
+ let anyMatch = false;
864
+ let anyChanged = false;
865
+ const updated = current.map((e) => {
866
+ const cmds = commandsOf(e);
867
+ const matchesHere = cmds.some((cmd) => cmd === ownCommand || hookCommandInvokes(cmd, markerPath));
868
+ if (!matchesHere) return e;
869
+ anyMatch = true;
870
+ const group = e as { hooks?: { command?: unknown }[] };
871
+ if (!Array.isArray(group.hooks)) {
872
+ if (cmds.some((cmd) => cmd === ownCommand)) return e; // legacy flat, already exact
873
+ anyChanged = true;
874
+ return entry; // legacy flat {command:...} — nothing else to preserve
875
+ }
876
+ // Codex round-2 (AM-2 residual): a group that holds BOTH the exact own command and a stale
877
+ // copy (or two stale copies) used to be skipped as "already exact" — the stale twin stayed
878
+ // forever. Walk the group once: the first own/stale command becomes the exact form, every
879
+ // later own/stale copy is dropped, every foreign sibling and the group's `matcher` survive.
880
+ let seenOwn = false;
881
+ let groupChanged = false;
882
+ const newGroupHooks: { command?: unknown }[] = [];
883
+ for (const h of group.hooks) {
884
+ const cmd = String((h as { command?: unknown })?.command ?? '');
885
+ const isOurs = cmd === ownCommand || hookCommandInvokes(cmd, markerPath);
886
+ if (!isOurs) { newGroupHooks.push(h); continue; }
887
+ if (seenOwn) { groupChanged = true; continue; } // duplicate of ours — dropped
888
+ seenOwn = true;
889
+ if (cmd !== ownCommand) groupChanged = true;
890
+ newGroupHooks.push(cmd === ownCommand ? h : { ...h, command: ownCommand });
891
+ }
892
+ if (!groupChanged) return e;
893
+ anyChanged = true;
894
+ return { ...(e as Record<string, unknown>), hooks: newGroupHooks };
895
+ });
896
+ if (!anyMatch) {
897
+ hooks[event] = [...current, entry];
898
+ hooksAdded = true;
899
+ return;
900
+ }
901
+ if (anyChanged) {
902
+ hooks[event] = updated;
903
+ hooksAdded = true;
904
+ }
840
905
  };
841
906
  addIfMissing(
842
907
  'UserPromptSubmit',