@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/.dz-manifest.json +41 -41
- package/README.md +157 -12
- package/dist/agentdb-index.d.ts +22 -1
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +156 -6
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +142 -2
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +534 -38
- package/dist/apply-leg.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/mutation-gate.d.ts +19 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +37 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +16 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +72 -9
- package/dist/operations.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +90 -14
- package/dist/setup.js.map +1 -1
- package/dist/skills.d.ts +87 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +266 -15
- package/dist/skills.js.map +1 -1
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +12 -8
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +40 -40
- package/src/agentdb-index.ts +158 -7
- package/src/apply-leg.ts +556 -38
- package/src/index.ts +9 -1
- package/src/mutation-gate.ts +58 -2
- package/src/operations.ts +76 -10
- package/src/setup.ts +81 -16
- package/src/skills.ts +303 -14
- package/src/vector-tier.ts +20 -10
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';
|
package/src/mutation-gate.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
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
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
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-
|
|
816
|
-
//
|
|
817
|
-
// OR our own) — see the WHY above for why that
|
|
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
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
)
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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',
|