@dzhechkov/harness-core 0.8.47 → 0.8.48

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/dist/setup.js CHANGED
@@ -15,9 +15,11 @@
15
15
  *
16
16
  * @packageDocumentation
17
17
  */
18
- import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
18
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, renameSync } from 'node:fs';
19
19
  import { basename, dirname, isAbsolute, join, relative } from 'node:path';
20
- import { execSync, spawnSync } from 'node:child_process';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { randomUUID } from 'node:crypto';
22
+ import { reconcileMemoryDependencies } from './setup-memory-deps.js';
21
23
  import { mergeManagedHookEntries } from './managed-hooks.js';
22
24
  import { writeUniqueStampedFile } from './stamped-path.js';
23
25
  import { CLAUDE_DESTRUCTIVE_HOOK_COMMAND, CLAUDE_DESTRUCTIVE_HOOK_MATCHER, CLAUDE_DESTRUCTIVE_HOOK_RELPATH, generateClaudeDestructiveHook, isDzManagedHookBody, } from './claude-hooks-assets.js';
@@ -452,10 +454,6 @@ function generateDzConfig(target, preset, backend) {
452
454
  },
453
455
  }, null, 2);
454
456
  }
455
- /** True if `agentdb` resolves from the project's node_modules (the hook writer needs it there). */
456
- function isAgentdbInstalledLocally(projectRoot) {
457
- return existsSync(join(projectRoot, 'node_modules', 'agentdb', 'package.json'));
458
- }
459
457
  /**
460
458
  * The exact agentdb version installed in the project, or `'latest'` as a fallback. Used to pin the
461
459
  * MCP server spec (`agentdb@<version>`) so the long-running MCP server and the hook writer — which
@@ -477,39 +475,6 @@ function installedAgentdbSpec(projectRoot) {
477
475
  * build tools — and gives true cross-process WAL concurrency so the hook and the MCP server share
478
476
  * one live store). Best-effort: returns false (caller degrades to jsonl) if install fails.
479
477
  */
480
- function installAgentdbLocally(projectRoot) {
481
- if (isAgentdbInstalledLocally(projectRoot))
482
- return true;
483
- try {
484
- // Anchor npm to THIS project: without a package.json here, npm's prefix walk-up would
485
- // install into (and mutate the lockfile of) the nearest ANCESTOR project (audit code#2).
486
- const pkgJsonPath = join(projectRoot, 'package.json');
487
- if (!existsSync(pkgJsonPath)) {
488
- writeFileSync(pkgJsonPath, JSON.stringify({ name: 'dz-harness-project', private: true, version: '0.0.0' }, null, 2) + '\n');
489
- }
490
- // NB: use the ESM-imported execSync — `require()` is undefined in this ESM module (the
491
- // original agentdb hooks failed silently for exactly this reason). stdio:'ignore' (not
492
- // 'pipe') avoids execSync's 1 MB maxBuffer aborting the child on npm's verbose output.
493
- // --save-exact: agentdb is alpha; a semver range would let a later `npm update` drift the
494
- // local copy away from the version the MCP registration pins (audit gap G7).
495
- //
496
- // better-sqlite3@^11 (AM-2, dz-harness-hub issue #10 defect 1, MEASURED Node 20.20.2 with no
497
- // `make` on PATH): an unpinned `npm install better-sqlite3` resolved 12.11.1, which ships no
498
- // prebuilt binary for Node 20's ABI 115 — the install fell through to a node-gyp source build
499
- // and failed on a machine with no C toolchain. `agentdb` itself requests `^11.8.1`, which DOES
500
- // publish an ABI-115 prebuild, so pinning the range here costs nothing agentdb wasn't already
501
- // going to resolve to, and buys a working install on a bare Node 20/22 host.
502
- execSync('npm install agentdb better-sqlite3@^11 --save-exact --no-audit --no-fund --loglevel=error', {
503
- cwd: projectRoot,
504
- stdio: 'ignore',
505
- timeout: 300000,
506
- });
507
- return isAgentdbInstalledLocally(projectRoot);
508
- }
509
- catch {
510
- return false;
511
- }
512
- }
513
478
  /** Run full environment setup. */
514
479
  /** Marker that brackets the dz-harness section in a shared CLAUDE.md/AGENTS.md. */
515
480
  const DRIVER_MARKER_START = '<!-- dz-harness-driver:start -->';
@@ -856,20 +821,45 @@ export function runSetup(opts) {
856
821
  // the comparison is against the PRIOR config, never the one this same call is about to produce.
857
822
  const resolvedMemory = resolveSetupMemoryBackend(opts.projectRoot, opts.memory, opts.noMemory === true);
858
823
  const backend = resolvedMemory.backend;
859
- // Step 0: Install agentdb + better-sqlite3 locally so the session-hook writer can import them
860
- // and share a native store with the MCP server. Best-effort — the writer self-degrades to a
861
- // jsonl marker (and self-heals once the deps exist) if this fails.
862
- if (backend === 'agentdb') {
863
- const ready = installAgentdbLocally(opts.projectRoot);
864
- if (ready) {
865
- steps.push({ name: 'Install agentdb + better-sqlite3', status: 'done', detail: 'local deps for real vector writes' });
824
+ const configPath = join(dzDir, 'config.json');
825
+ let config;
826
+ const observedBackend = () => {
827
+ try {
828
+ const value = JSON.parse(readFileSync(configPath, 'utf8'));
829
+ return value?.memory?.backend === 'agentdb' || value?.memory?.backend === 'jsonl' ? value.memory.backend : 'unknown';
866
830
  }
867
- else {
868
- steps.push({
869
- name: 'Install agentdb + better-sqlite3',
870
- status: 'error',
871
- detail: 'install failed — hooks log to sessions.jsonl until you run: npm i agentdb better-sqlite3',
872
- });
831
+ catch {
832
+ return 'unknown';
833
+ }
834
+ };
835
+ const finish = () => ({
836
+ steps, totalSteps: steps.length, completed: steps.filter(step => step.status === 'done').length,
837
+ skipped: steps.filter(step => step.status === 'skipped').length,
838
+ memoryBackend: observedBackend() === 'unknown' ? resolvedMemory.backend : observedBackend(),
839
+ memoryBackendObserved: observedBackend(), memoryBackendSource: resolvedMemory.source,
840
+ memoryBackendDowngraded: resolvedMemory.downgraded,
841
+ });
842
+ try {
843
+ if (existsSync(configPath)) {
844
+ const value = JSON.parse(readFileSync(configPath, 'utf8'));
845
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
846
+ throw Error('config must be an object');
847
+ config = value;
848
+ const memory = config['memory'];
849
+ if (memory === null || typeof memory !== 'object' || Array.isArray(memory) || !['jsonl', 'agentdb'].includes(String(memory['backend'])))
850
+ throw Error('memory.backend must be jsonl or agentdb');
851
+ }
852
+ }
853
+ catch (error) {
854
+ steps.push({ name: 'Memory saved state', status: 'error', detail: `unknown INCOMPLETE: malformed .dz/config.json preserved; ${String(error)}` });
855
+ return finish();
856
+ }
857
+ if (backend === 'agentdb' && !opts.noMemory) {
858
+ const deps = reconcileMemoryDependencies(opts.projectRoot);
859
+ steps.push({ name: 'Memory dependencies', status: deps.ready ? (deps.changedManifest || deps.changedLock || deps.detail.startsWith('repaired') ? 'done' : 'skipped') : 'error', detail: deps.detail });
860
+ if (!deps.ready) {
861
+ steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE before config persistence; dependency/npm/native failure; later memory phases not run` });
862
+ return finish();
873
863
  }
874
864
  }
875
865
  // Step 1: Create .dz directory
@@ -880,351 +870,364 @@ export function runSetup(opts) {
880
870
  else {
881
871
  steps.push({ name: 'Create .dz directory', status: 'skipped', detail: 'already exists' });
882
872
  }
883
- // Step 2: Write .dz/config.json. FR-2: a DOWNGRADE (explicit --memory jsonl over an
884
- // agentdb-configured project) forces the write even without --force — "two truths after any
885
- // setup coincide" means the config may not keep claiming agentdb once the caller has explicitly
886
- // asked for jsonl.
887
- const configPath = join(dzDir, 'config.json');
888
- if (!existsSync(configPath) || opts.force) {
889
- writeFileSync(configPath, generateDzConfig(opts.target, opts.preset, backend));
890
- steps.push({ name: 'Write .dz/config.json', status: 'done', detail: `${backend} backend` });
891
- }
892
- else if (resolvedMemory.downgraded) {
893
- // Lead edit after Codex review (finding 3): a downgrade changes ONLY memory.backend — every other
894
- // field the owner keeps in .dz/config.json survives; an unparsable file falls back to regeneration.
895
- let rewritten = false;
896
- try {
897
- const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
898
- const memory = (cfg['memory'] !== null && typeof cfg['memory'] === 'object') ? cfg['memory'] : {};
899
- cfg['memory'] = { ...memory, backend };
900
- writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
901
- rewritten = true;
902
- }
903
- catch { /* fall through to regeneration */ }
904
- if (!rewritten)
905
- writeFileSync(configPath, generateDzConfig(opts.target, opts.preset, backend));
906
- steps.push({ name: 'Write .dz/config.json', status: 'done', detail: `memory.backend → ${backend} (other fields kept)` });
907
- }
908
- else {
909
- steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: 'already exists (use --force)' });
910
- }
911
- if (resolvedMemory.downgraded) {
912
- steps.push({
913
- name: 'Memory backend downgrade',
914
- status: 'done',
915
- detail: '⚠ memory backend downgraded agentdb → jsonl by --memory jsonl',
916
- });
917
- }
918
- // Step 3: Initialize session log
919
- const sessionsPath = join(dzDir, 'sessions.jsonl');
920
- if (!existsSync(sessionsPath)) {
921
- writeFileSync(sessionsPath, '');
922
- steps.push({ name: 'Initialize sessions.jsonl', status: 'done', detail: 'session tracking ready' });
873
+ // Persist only the requested backend field of readable existing config, atomically.
874
+ const priorBackend = observedBackend();
875
+ if (opts.noMemory) {
876
+ steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: '--no-memory: existing memory configuration preserved' });
923
877
  }
924
878
  else {
925
- steps.push({ name: 'Initialize sessions.jsonl', status: 'skipped', detail: 'already exists' });
926
- }
927
- // Step 4: Initialize memory store
928
- if (backend === 'agentdb') {
929
- // Write the session-hook writer. The agentdb.db store itself is auto-created on first write
930
- // by createDatabase() (both the writer and the MCP server init the schema), so there is no
931
- // orphan placeholder file — the writer targets the real, shared native store.
932
- const writerPath = join(dzDir, 'agentdb-writer.mjs');
933
- // Regenerate when missing, forced, OR the deployed stamp is older than the current
934
- // generator — deployed writers must not fossilize outside the package lifecycle (gap G4).
935
- const deployedVersion = existsSync(writerPath) ? writerVersionOf(readFileSync(writerPath, 'utf-8')) : -1;
936
- if (deployedVersion === -1 || opts.force || deployedVersion < AGENTDB_WRITER_VERSION) {
937
- writeFileSync(writerPath, generateAgentdbWriter(opts.projectRoot));
938
- steps.push({
939
- name: 'Write agentdb-writer.mjs',
940
- status: 'done',
941
- detail: deployedVersion > -1 && deployedVersion < AGENTDB_WRITER_VERSION
942
- ? `upgraded v${deployedVersion} → v${AGENTDB_WRITER_VERSION}`
943
- : `session telemetry writer v${AGENTDB_WRITER_VERSION}`,
944
- });
945
- }
946
- else {
947
- steps.push({ name: 'Write agentdb-writer.mjs', status: 'skipped', detail: `current (v${deployedVersion})` });
948
- }
949
- // Keep the jsonl fallback log available for the writer's degraded path.
950
- const sessionsPath = join(dzDir, 'sessions.jsonl');
951
- if (!existsSync(sessionsPath))
952
- writeFileSync(sessionsPath, '');
953
- }
954
- else {
955
- // JSONL backend
956
- const sessionsPath = join(dzDir, 'sessions.jsonl');
957
- if (!existsSync(sessionsPath)) {
958
- writeFileSync(sessionsPath, '');
959
- steps.push({ name: 'Initialize sessions.jsonl', status: 'done', detail: 'session tracking ready' });
960
- }
961
- else {
962
- steps.push({ name: 'Initialize sessions.jsonl', status: 'skipped', detail: 'already exists' });
963
- }
964
- const patternsPath = join(dzDir, 'patterns.jsonl');
965
- if (!existsSync(patternsPath)) {
966
- writeFileSync(patternsPath, '');
967
- steps.push({ name: 'Initialize patterns.jsonl', status: 'done', detail: 'pattern learning ready' });
968
- }
969
- else {
970
- steps.push({ name: 'Initialize patterns.jsonl', status: 'skipped', detail: 'already exists' });
971
- }
972
- }
973
- // Step 4.6: Install apply-leg — the WORK happens here (before "Configure hooks" writes
974
- // SessionStart), so a foreign SessionStart entry is already in place before that step's own
975
- // merge ever sees it; see `applyLegStepResult`'s doc for why order matters. The STEP is reported
976
- // further down, after "Configure hooks" pushes its own, so the printed order still reads as
977
- // "collect → rank → apply".
978
- const applyLegStep = applyLegStepResult(opts, backend);
979
- // Step 5: Configure hooks (write to .claude/settings.json) — EVENT-LEVEL merge (gap G2):
980
- // dz-generated entries (recognized by signature, incl. the broken legacy `agentdb add` hooks
981
- // this feature fixes) are replaced in place WITHOUT --force; the user's own hooks and every
982
- // other settings key are preserved. Full-file overwrite happens only when the file is absent.
983
- if (!opts.noHooks) {
984
- const settingsDir = join(opts.projectRoot, '.claude');
985
- const settingsPath = join(settingsDir, 'settings.json');
986
- // The BODY goes in first, and the ENTRY goes in only after a LIVE receipt that the body runs
987
- // and refuses. Written from the INSTALLED package, never copied out of our repository — a
988
- // consumer has no `packages/@dzhechkov/...` above their project.
989
- //
990
- // Round 4, P2: these two used to be independent. A failed write was recorded as an error and
991
- // the entry was merged anyway, so a consumer whose install failed got a `PreToolUse` entry
992
- // pointing at something that is not a runnable hook — and that breaks EVERY Bash call, not one.
993
- //
994
- // Round 5, P1: the write was also UNCONDITIONAL. Setup is additive everywhere else — the
995
- // settings merge keeps the user's own hooks, `.gitignore` is appended to, an existing skill is
996
- // skipped — and this path overwrote a well-known filename with no ownership check, so a
997
- // consumer's hand-authored `.claude/hooks/destructive-guard.cjs` was destroyed by a routine
998
- // run. A body we wrote carries a MARKER; a file without it is the consumer's, and only an
999
- // explicit `--force` may replace it, after a timestamped backup.
1000
- const hookPath = join(opts.projectRoot, ...CLAUDE_DESTRUCTIVE_HOOK_RELPATH.split('/'));
1001
- let installError = null;
1002
- let preserved = null;
1003
- let backupPath = null;
1004
- // Unreadable (absent, or something that is not a file at all) is NOT a claim of ownership: it
1005
- // falls through to the write, whose failure the round-4 receipt below already reports.
1006
- let current = null;
1007
- try {
1008
- current = readFileSync(hookPath, 'utf-8');
1009
- }
1010
- catch {
1011
- current = null;
1012
- }
1013
- const foreign = current !== null && !isDzManagedHookBody(current);
1014
- if (foreign && opts.force !== true) {
1015
- preserved =
1016
- 'файл на этом пути не наш (нет маркера dz) — ОСТАВЛЕН нетронутым и НЕ ЗАПУСКАЛСЯ; запись в settings.json на этот путь тоже не трогаем (ни своей не добавляем, ни вашу не снимаем); заменить: dz setup --force';
1017
- }
1018
- else {
879
+ const transition = config !== undefined && opts.memory !== undefined && priorBackend !== backend;
880
+ if (config === undefined || opts.force || transition) {
881
+ const next = config !== undefined
882
+ ? { ...config, memory: { ...config['memory'], backend } }
883
+ : JSON.parse(generateDzConfig(opts.target, opts.preset, backend));
884
+ const temporary = `${configPath}.${randomUUID()}.tmp`;
1019
885
  try {
1020
- if (foreign && current !== null) {
1021
- // Same shape as the codex `hooks.json` backup: the original beside the original, stamped,
1022
- // so `--force` is recoverable rather than merely loud.
1023
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1024
- backupPath = writeUniqueStampedFile(`${hookPath}.bak-`, stamp, current, writeFileSync);
1025
- }
1026
- mkdirSync(dirname(hookPath), { recursive: true });
1027
- writeFileSync(hookPath, generateClaudeDestructiveHook(), { mode: 0o755 });
886
+ writeFileSync(temporary, JSON.stringify(next, null, 2) + '\n', { flag: 'wx' });
887
+ renameSync(temporary, configPath);
888
+ steps.push({ name: 'Write .dz/config.json', status: 'done', detail: transition ? `memory.backend ${priorBackend} → ${backend}; all other config fields kept` : `${backend} backend` });
1028
889
  }
1029
- catch (err) {
1030
- installError = String(err.message);
890
+ catch (error) {
891
+ if (existsSync(temporary))
892
+ rmSync(temporary);
893
+ steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE: config persistence failed; later memory wiring not run; ${String(error)}` });
894
+ return finish();
1031
895
  }
1032
896
  }
1033
- const foreignBodyKept = preserved !== null;
1034
- // The receipt SPAWNS the file, so it may only ever be taken on a body dz owns.
1035
- //
1036
- // Round 11, P1 SECURITY — correcting my own round-5 sentence, "the receipt is taken from the
1037
- // file that IS there". Combined with round 6, which preserves a body dz does not own, that made
1038
- // `dz setup` EXECUTE whatever a repository had committed at this path: clone a hostile repo,
1039
- // run the documented setup command, and its `.claude/hooks/destructive-guard.cjs` ran — with
1040
- // none of the host's hook-trust prompting in between. MEASURED: a foreign body writing a marker
1041
- // file had written it by the time setup returned.
1042
- //
1043
- // So a preserved foreign body is NOT probed, NOT registered, and NOT run. `--force` is consent
1044
- // to REPLACE it (our body is written above, before this line) — never consent to execute it.
1045
- // The receipt is still taken whether or not the write threw, because a failed write over an
1046
- // OLDER BODY OF OURS leaves something we may legitimately run.
1047
- const receipt = foreignBodyKept
1048
- ? { ok: false, detail: 'проба не проводилась — запускать чужой файл не наше право' }
1049
- : probeInstalledGuard(hookPath);
1050
- // Round 8, P2: ownership of the ENTRY follows ownership of the BODY, never the filename.
1051
- //
1052
- // Round 6 preserved a consumer's hook file; attribution of its registry entry stayed path-only,
1053
- // so a routine run deleted the registration of the very file it had just decided not to touch —
1054
- // their hook left on disk and switched off (MEASURED: their `PreToolUse` entry came back `[]`).
1055
- // The reverse was just as wrong: a foreign body that happened to refuse made dz ADD an entry
1056
- // for somebody else's file (MEASURED), taking responsibility for code it may neither read as
1057
- // its own nor replace.
1058
- //
1059
- // So when a foreign body is kept, dz stands down from the whole event: it adds nothing, and
1060
- // `isManaged` below stops claiming an entry that points at that path. Whether the foreign hook
1061
- // refuses is not merely the consumer's business — it is a question dz no longer ASKS, because
1062
- // asking meant running their file (round 11). All of it is said in one line rather than left
1063
- // for them to find by diffing settings.json.
1064
- const guardArmed = receipt.ok && !foreignBodyKept;
1065
- // The receipt is taken from the file that IS at the path — ours, or the one we preserved. A
1066
- // foreign hook that demonstrably refuses is registered on its own merits; a foreign hook that
1067
- // does not refuse gets no entry, exactly like a failed install (round 4).
1068
- const notes = [
1069
- preserved === null ? '' : `${preserved}; `,
1070
- backupPath === null ? '' : `прежний файл сохранён: ${basename(backupPath)}; `,
1071
- installError === null ? '' : `${installError}; `,
1072
- ].join('');
1073
- steps.push(guardArmed
1074
- ? { name: 'Install destructive guard', status: 'done', detail: `${notes}${CLAUDE_DESTRUCTIVE_HOOK_RELPATH} — ${receipt.detail}` }
1075
- : {
1076
- name: 'Install destructive guard',
1077
- status: preserved === null ? 'error' : 'skipped',
1078
- detail: `${notes}${receipt.detail} — запись в settings.json НЕ добавлена`,
1079
- });
1080
- const generated = JSON.parse(generateHooksConfig(opts.projectRoot, backend));
1081
- // No working body ⇒ no entry, and the EVENT KEY STAYS — as an empty managed list when nothing
1082
- // else of ours belongs there.
1083
- //
1084
- // CORRECTION OF RECORD (round 7, P1). The round-5 version DELETED the key and this comment
1085
- // claimed the merge would then also drop a guard entry left by an earlier setup. That was
1086
- // asserted without measuring and it is false: `mergeManagedHookEntries` iterates
1087
- // `Object.keys(managed)`, so an event absent from the managed input is copied through
1088
- // UNTOUCHED — a project whose guard used to be armed kept invoking it on every Bash call while
1089
- // the report said the entry was not added. Handing the event an EMPTY list is what makes the
1090
- // merge EXAMINE it: our entries are dropped by `isManaged`, the user's are preserved in order,
1091
- // and nothing is appended. The round-5 test passed for the wrong reason — its project had no
1092
- // pre-existing settings.json, so there was no stale entry for the claim to be wrong about.
1093
- if (!guardArmed) {
1094
- generated.hooks['PreToolUse'] = (generated.hooks['PreToolUse'] ?? []).filter((entry) => !entry.hooks.some((h) => isManagedClaudeDestructiveHookCommand(h.command)));
1095
- }
1096
- if (!existsSync(settingsPath)) {
1097
- mkdirSync(settingsDir, { recursive: true });
1098
- writeFileSync(settingsPath, JSON.stringify({ hooks: generated.hooks }, null, 2));
1099
- steps.push({ name: 'Configure hooks', status: 'done', detail: `${backend} session hooks` });
1100
- }
1101
- else {
1102
- try {
1103
- const existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
1104
- // ONE merge implementation, shared with the Codex target (AM-3 / G-E). Claude's exact
1105
- // command attribution is passed in rather than reimplemented, so emitted bytes, report
1106
- // tail text, and the no-write path stay on the shared merge contract (AM-37).
1107
- const isManagedCommand = (cmd) => cmd.includes('agentdb add') ||
1108
- cmd.includes('agentdb-writer.mjs') ||
1109
- cmd.includes('sessions.jsonl') ||
1110
- // Ours ONLY while the body at that path is ours (round 8, P2). Without the
1111
- // path clause a second `dz setup` would append a duplicate guard entry instead of
1112
- // replacing the first; without the ownership clause it would delete the entry a
1113
- // consumer wrote for their own preserved hook.
1114
- (!foreignBodyKept && isManagedClaudeDestructiveHookCommand(cmd));
1115
- const plan = mergeManagedHookEntries((existing['hooks'] ?? {}), generated.hooks, {
1116
- // Drop dz-generated entries (any vintage, either shape) — keep the user's own hooks
1117
- // untouched. Flat dz entries (≤0.3.43) are dropped too, migrating them to the valid
1118
- // matcher-group shape appended below.
1119
- isManaged: (entry) => commandsOf(entry).some(isManagedCommand),
1120
- isLegacy: (entry) => !Array.isArray(entry?.hooks) ||
1121
- commandsOf(entry).some((cmd) => cmd.includes('agentdb add')),
1122
- // Ownership is per HANDLER, not per matcher group. A user's handler may deliberately
1123
- // share the Bash group with dz's guard; replacing ours must retain their handler object
1124
- // and every surrounding group field byte-for-byte through JSON serialization.
1125
- retainForeign: (entry) => {
1126
- const grouped = entry;
1127
- if (!Array.isArray(grouped?.hooks))
1128
- return null;
1129
- const kept = grouped.hooks.filter((hook) => !isManagedCommand(String(hook?.command ?? '')));
1130
- return kept.length === 0
1131
- ? null
1132
- : { ...entry, hooks: kept };
1133
- },
1134
- reportLabel: backend,
1135
- });
1136
- if (plan.changed) {
1137
- existing['hooks'] = plan.hooks;
1138
- writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
1139
- steps.push({ name: 'Configure hooks', status: 'done', detail: plan.report });
897
+ else
898
+ steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: 'already current; existing config preserved' });
899
+ if (resolvedMemory.downgraded)
900
+ steps.push({ name: 'Memory backend downgrade', status: 'done', detail: '⚠ memory backend downgraded agentdb → jsonl by --memory jsonl; other config fields kept' });
901
+ }
902
+ try {
903
+ if (!opts.noMemory) {
904
+ // Step 3: Initialize session log
905
+ const sessionsPath = join(dzDir, 'sessions.jsonl');
906
+ if (!existsSync(sessionsPath)) {
907
+ writeFileSync(sessionsPath, '');
908
+ steps.push({ name: 'Initialize sessions.jsonl', status: 'done', detail: 'session tracking ready' });
909
+ }
910
+ else {
911
+ steps.push({ name: 'Initialize sessions.jsonl', status: 'skipped', detail: 'already exists' });
912
+ }
913
+ // Step 4: Initialize memory store
914
+ if (backend === 'agentdb') {
915
+ // Write the session-hook writer. The agentdb.db store itself is auto-created on first write
916
+ // by createDatabase() (both the writer and the MCP server init the schema), so there is no
917
+ // orphan placeholder file — the writer targets the real, shared native store.
918
+ const writerPath = join(dzDir, 'agentdb-writer.mjs');
919
+ // Regenerate when missing, forced, OR the deployed stamp is older than the current
920
+ // generator — deployed writers must not fossilize outside the package lifecycle (gap G4).
921
+ const deployedVersion = existsSync(writerPath) ? writerVersionOf(readFileSync(writerPath, 'utf-8')) : -1;
922
+ if (deployedVersion === -1 || opts.force || deployedVersion < AGENTDB_WRITER_VERSION) {
923
+ writeFileSync(writerPath, generateAgentdbWriter(opts.projectRoot));
924
+ steps.push({
925
+ name: 'Write agentdb-writer.mjs',
926
+ status: 'done',
927
+ detail: deployedVersion > -1 && deployedVersion < AGENTDB_WRITER_VERSION
928
+ ? `upgraded v${deployedVersion} → v${AGENTDB_WRITER_VERSION}`
929
+ : `session telemetry writer v${AGENTDB_WRITER_VERSION}`,
930
+ });
1140
931
  }
1141
932
  else {
1142
- steps.push({ name: 'Configure hooks', status: 'skipped', detail: plan.report });
933
+ steps.push({ name: 'Write agentdb-writer.mjs', status: 'skipped', detail: `current (v${deployedVersion})` });
1143
934
  }
935
+ // Keep the jsonl fallback log available for the writer's degraded path.
936
+ const sessionsPath = join(dzDir, 'sessions.jsonl');
937
+ if (!existsSync(sessionsPath))
938
+ writeFileSync(sessionsPath, '');
939
+ }
940
+ else {
941
+ // JSONL backend
942
+ const sessionsPath = join(dzDir, 'sessions.jsonl');
943
+ if (!existsSync(sessionsPath)) {
944
+ writeFileSync(sessionsPath, '');
945
+ steps.push({ name: 'Initialize sessions.jsonl', status: 'done', detail: 'session tracking ready' });
946
+ }
947
+ else {
948
+ steps.push({ name: 'Initialize sessions.jsonl', status: 'skipped', detail: 'already exists' });
949
+ }
950
+ const patternsPath = join(dzDir, 'patterns.jsonl');
951
+ if (!existsSync(patternsPath)) {
952
+ writeFileSync(patternsPath, '');
953
+ steps.push({ name: 'Initialize patterns.jsonl', status: 'done', detail: 'pattern learning ready' });
954
+ }
955
+ else {
956
+ steps.push({ name: 'Initialize patterns.jsonl', status: 'skipped', detail: 'already exists' });
957
+ }
958
+ }
959
+ } // --no-memory performs no memory store initialization.
960
+ // Step 4.6: Install apply-leg — the WORK happens here (before "Configure hooks" writes
961
+ // SessionStart), so a foreign SessionStart entry is already in place before that step's own
962
+ // merge ever sees it; see `applyLegStepResult`'s doc for why order matters. The STEP is reported
963
+ // further down, after "Configure hooks" pushes its own, so the printed order still reads as
964
+ // "collect → rank → apply".
965
+ const applyLegStep = opts.noMemory ? { name: 'Install apply-leg', status: 'skipped', detail: '--no-memory' } : applyLegStepResult(opts, backend);
966
+ // Step 5: Configure hooks (write to .claude/settings.json) — EVENT-LEVEL merge (gap G2):
967
+ // dz-generated entries (recognized by signature, incl. the broken legacy `agentdb add` hooks
968
+ // this feature fixes) are replaced in place WITHOUT --force; the user's own hooks and every
969
+ // other settings key are preserved. Full-file overwrite happens only when the file is absent.
970
+ if (!opts.noHooks && !opts.noMemory) {
971
+ const settingsDir = join(opts.projectRoot, '.claude');
972
+ const settingsPath = join(settingsDir, 'settings.json');
973
+ // The BODY goes in first, and the ENTRY goes in only after a LIVE receipt that the body runs
974
+ // and refuses. Written from the INSTALLED package, never copied out of our repository — a
975
+ // consumer has no `packages/@dzhechkov/...` above their project.
976
+ //
977
+ // Round 4, P2: these two used to be independent. A failed write was recorded as an error and
978
+ // the entry was merged anyway, so a consumer whose install failed got a `PreToolUse` entry
979
+ // pointing at something that is not a runnable hook — and that breaks EVERY Bash call, not one.
980
+ //
981
+ // Round 5, P1: the write was also UNCONDITIONAL. Setup is additive everywhere else — the
982
+ // settings merge keeps the user's own hooks, `.gitignore` is appended to, an existing skill is
983
+ // skipped — and this path overwrote a well-known filename with no ownership check, so a
984
+ // consumer's hand-authored `.claude/hooks/destructive-guard.cjs` was destroyed by a routine
985
+ // run. A body we wrote carries a MARKER; a file without it is the consumer's, and only an
986
+ // explicit `--force` may replace it, after a timestamped backup.
987
+ const hookPath = join(opts.projectRoot, ...CLAUDE_DESTRUCTIVE_HOOK_RELPATH.split('/'));
988
+ let installError = null;
989
+ let preserved = null;
990
+ let backupPath = null;
991
+ // Unreadable (absent, or something that is not a file at all) is NOT a claim of ownership: it
992
+ // falls through to the write, whose failure the round-4 receipt below already reports.
993
+ let current = null;
994
+ try {
995
+ current = readFileSync(hookPath, 'utf-8');
1144
996
  }
1145
997
  catch {
1146
- steps.push({ name: 'Configure hooks', status: 'error', detail: 'could not parse existing settings.json — fix it and re-run' });
998
+ current = null;
1147
999
  }
1148
- }
1149
- }
1150
- else {
1151
- steps.push({ name: 'Configure hooks', status: 'skipped', detail: '--no-hooks' });
1152
- }
1153
- // Step 5.6: Install apply-leg — report pushed AFTER "Configure hooks" below (for a report order
1154
- // that reads naturally), but see `applyLegStepResult()` above `runSetup` for why the WRITE itself
1155
- // happens BEFORE it.
1156
- steps.push(applyLegStep);
1157
- // Step 5.5: Register agentdb MCP through the SAME ownership-aware transaction used by `dz init`.
1158
- // `.mcp.json` is the project-scope carrier Claude Code actually loads. A known historical dz
1159
- // agentdb shape is adopted; an ambiguous hand-authored entry is preserved and named as an error.
1160
- if (backend === 'agentdb') {
1161
- const agentdbEntry = {
1162
- command: 'npx',
1163
- // Pin to the INSTALLED agentdb version (not @latest) so the MCP server and the hook
1164
- // writer run the same alpha schema against one DB.
1165
- args: [installedAgentdbSpec(opts.projectRoot), 'mcp', 'start'],
1166
- // Pin the server to its OWN store — NEVER the writer's .dz/agentdb.db. Two engines on one
1167
- // SQLite file (native better-sqlite3 + a silent sql.js fallback) whole-file-rewrite each
1168
- // other: measured 2026-07-09, 5 of 20 samples zero bytes and 4 torn (ADR-001, 2026-08-26).
1169
- env: { AGENTDB_PATH: agentdbMcpStorePath(opts.projectRoot) },
1170
- };
1171
- try {
1172
- const applied = applyIntegrationFragments({
1173
- projectRoot: opts.projectRoot,
1174
- fragments: [{
1175
- component: 'mcp',
1176
- carrierPath: '.mcp.json',
1177
- scope: 'project',
1178
- format: 'json',
1179
- rootKey: 'mcpServers',
1180
- entries: { agentdb: agentdbEntry },
1181
- }],
1182
- });
1183
- if (applied.written.includes('.mcp.json')) {
1184
- steps.push({
1185
- name: 'Register agentdb MCP',
1186
- status: 'done',
1187
- // No tool count: a hardcoded number is a lie waiting to age (the live server answered
1188
- // 35 while its own banner said 32 and the README said 41 — measured 2026-08-26).
1189
- detail: `.mcp.json: ${installedAgentdbSpec(opts.projectRoot)} → .dz/agentdb-mcp.db (own store; hooks keep .dz/agentdb.db)`,
1000
+ const foreign = current !== null && !isDzManagedHookBody(current);
1001
+ if (foreign && opts.force !== true) {
1002
+ preserved =
1003
+ 'файл на этом пути не наш (нет маркера dz) — ОСТАВЛЕН нетронутым и НЕ ЗАПУСКАЛСЯ; запись в settings.json на этот путь тоже не трогаем (ни своей не добавляем, ни вашу не снимаем); заменить: dz setup --force';
1004
+ }
1005
+ else {
1006
+ try {
1007
+ if (foreign && current !== null) {
1008
+ // Same shape as the codex `hooks.json` backup: the original beside the original, stamped,
1009
+ // so `--force` is recoverable rather than merely loud.
1010
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1011
+ backupPath = writeUniqueStampedFile(`${hookPath}.bak-`, stamp, current, writeFileSync);
1012
+ }
1013
+ mkdirSync(dirname(hookPath), { recursive: true });
1014
+ writeFileSync(hookPath, generateClaudeDestructiveHook(), { mode: 0o755 });
1015
+ }
1016
+ catch (err) {
1017
+ installError = String(err.message);
1018
+ }
1019
+ }
1020
+ const foreignBodyKept = preserved !== null;
1021
+ // The receipt SPAWNS the file, so it may only ever be taken on a body dz owns.
1022
+ //
1023
+ // Round 11, P1 SECURITY — correcting my own round-5 sentence, "the receipt is taken from the
1024
+ // file that IS there". Combined with round 6, which preserves a body dz does not own, that made
1025
+ // `dz setup` EXECUTE whatever a repository had committed at this path: clone a hostile repo,
1026
+ // run the documented setup command, and its `.claude/hooks/destructive-guard.cjs` ran — with
1027
+ // none of the host's hook-trust prompting in between. MEASURED: a foreign body writing a marker
1028
+ // file had written it by the time setup returned.
1029
+ //
1030
+ // So a preserved foreign body is NOT probed, NOT registered, and NOT run. `--force` is consent
1031
+ // to REPLACE it (our body is written above, before this line) — never consent to execute it.
1032
+ // The receipt is still taken whether or not the write threw, because a failed write over an
1033
+ // OLDER BODY OF OURS leaves something we may legitimately run.
1034
+ const receipt = foreignBodyKept
1035
+ ? { ok: false, detail: 'проба не проводилась — запускать чужой файл не наше право' }
1036
+ : probeInstalledGuard(hookPath);
1037
+ // Round 8, P2: ownership of the ENTRY follows ownership of the BODY, never the filename.
1038
+ //
1039
+ // Round 6 preserved a consumer's hook file; attribution of its registry entry stayed path-only,
1040
+ // so a routine run deleted the registration of the very file it had just decided not to touch —
1041
+ // their hook left on disk and switched off (MEASURED: their `PreToolUse` entry came back `[]`).
1042
+ // The reverse was just as wrong: a foreign body that happened to refuse made dz ADD an entry
1043
+ // for somebody else's file (MEASURED), taking responsibility for code it may neither read as
1044
+ // its own nor replace.
1045
+ //
1046
+ // So when a foreign body is kept, dz stands down from the whole event: it adds nothing, and
1047
+ // `isManaged` below stops claiming an entry that points at that path. Whether the foreign hook
1048
+ // refuses is not merely the consumer's business — it is a question dz no longer ASKS, because
1049
+ // asking meant running their file (round 11). All of it is said in one line rather than left
1050
+ // for them to find by diffing settings.json.
1051
+ const guardArmed = receipt.ok && !foreignBodyKept;
1052
+ // The receipt is taken from the file that IS at the path — ours, or the one we preserved. A
1053
+ // foreign hook that demonstrably refuses is registered on its own merits; a foreign hook that
1054
+ // does not refuse gets no entry, exactly like a failed install (round 4).
1055
+ const notes = [
1056
+ preserved === null ? '' : `${preserved}; `,
1057
+ backupPath === null ? '' : `прежний файл сохранён: ${basename(backupPath)}; `,
1058
+ installError === null ? '' : `${installError}; `,
1059
+ ].join('');
1060
+ steps.push(guardArmed
1061
+ ? { name: 'Install destructive guard', status: 'done', detail: `${notes}${CLAUDE_DESTRUCTIVE_HOOK_RELPATH} — ${receipt.detail}` }
1062
+ : {
1063
+ name: 'Install destructive guard',
1064
+ status: preserved === null ? 'error' : 'skipped',
1065
+ detail: `${notes}${receipt.detail} — запись в settings.json НЕ добавлена`,
1190
1066
  });
1067
+ const generated = JSON.parse(generateHooksConfig(opts.projectRoot, backend));
1068
+ // No working body ⇒ no entry, and the EVENT KEY STAYS — as an empty managed list when nothing
1069
+ // else of ours belongs there.
1070
+ //
1071
+ // CORRECTION OF RECORD (round 7, P1). The round-5 version DELETED the key and this comment
1072
+ // claimed the merge would then also drop a guard entry left by an earlier setup. That was
1073
+ // asserted without measuring and it is false: `mergeManagedHookEntries` iterates
1074
+ // `Object.keys(managed)`, so an event absent from the managed input is copied through
1075
+ // UNTOUCHED — a project whose guard used to be armed kept invoking it on every Bash call while
1076
+ // the report said the entry was not added. Handing the event an EMPTY list is what makes the
1077
+ // merge EXAMINE it: our entries are dropped by `isManaged`, the user's are preserved in order,
1078
+ // and nothing is appended. The round-5 test passed for the wrong reason — its project had no
1079
+ // pre-existing settings.json, so there was no stale entry for the claim to be wrong about.
1080
+ if (!guardArmed) {
1081
+ generated.hooks['PreToolUse'] = (generated.hooks['PreToolUse'] ?? []).filter((entry) => !entry.hooks.some((h) => isManagedClaudeDestructiveHookCommand(h.command)));
1082
+ }
1083
+ if (!existsSync(settingsPath)) {
1084
+ mkdirSync(settingsDir, { recursive: true });
1085
+ writeFileSync(settingsPath, JSON.stringify({ hooks: generated.hooks }, null, 2));
1086
+ steps.push({ name: 'Configure hooks', status: 'done', detail: `${backend} session hooks` });
1191
1087
  }
1192
1088
  else {
1193
- steps.push({ name: 'Register agentdb MCP', status: 'skipped', detail: 'already registered and current' });
1089
+ try {
1090
+ const existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
1091
+ // ONE merge implementation, shared with the Codex target (AM-3 / G-E). Claude's exact
1092
+ // command attribution is passed in rather than reimplemented, so emitted bytes, report
1093
+ // tail text, and the no-write path stay on the shared merge contract (AM-37).
1094
+ const isManagedCommand = (cmd) => cmd.includes('agentdb add') ||
1095
+ cmd.includes('agentdb-writer.mjs') ||
1096
+ cmd.includes('sessions.jsonl') ||
1097
+ // Ours ONLY while the body at that path is ours (round 8, P2). Without the
1098
+ // path clause a second `dz setup` would append a duplicate guard entry instead of
1099
+ // replacing the first; without the ownership clause it would delete the entry a
1100
+ // consumer wrote for their own preserved hook.
1101
+ (!foreignBodyKept && isManagedClaudeDestructiveHookCommand(cmd));
1102
+ const plan = mergeManagedHookEntries((existing['hooks'] ?? {}), generated.hooks, {
1103
+ // Drop dz-generated entries (any vintage, either shape) — keep the user's own hooks
1104
+ // untouched. Flat dz entries (≤0.3.43) are dropped too, migrating them to the valid
1105
+ // matcher-group shape appended below.
1106
+ isManaged: (entry) => commandsOf(entry).some(isManagedCommand),
1107
+ isLegacy: (entry) => !Array.isArray(entry?.hooks) ||
1108
+ commandsOf(entry).some((cmd) => cmd.includes('agentdb add')),
1109
+ // Ownership is per HANDLER, not per matcher group. A user's handler may deliberately
1110
+ // share the Bash group with dz's guard; replacing ours must retain their handler object
1111
+ // and every surrounding group field byte-for-byte through JSON serialization.
1112
+ retainForeign: (entry) => {
1113
+ const grouped = entry;
1114
+ if (!Array.isArray(grouped?.hooks))
1115
+ return null;
1116
+ const kept = grouped.hooks.filter((hook) => !isManagedCommand(String(hook?.command ?? '')));
1117
+ return kept.length === 0
1118
+ ? null
1119
+ : { ...entry, hooks: kept };
1120
+ },
1121
+ reportLabel: backend,
1122
+ });
1123
+ if (plan.changed) {
1124
+ existing['hooks'] = plan.hooks;
1125
+ writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
1126
+ steps.push({ name: 'Configure hooks', status: 'done', detail: plan.report });
1127
+ }
1128
+ else {
1129
+ steps.push({ name: 'Configure hooks', status: 'skipped', detail: plan.report });
1130
+ }
1131
+ }
1132
+ catch {
1133
+ steps.push({ name: 'Configure hooks', status: 'error', detail: 'could not parse existing settings.json — fix it and re-run' });
1134
+ }
1194
1135
  }
1195
1136
  }
1196
- catch (error) {
1197
- const reason = error instanceof IntegrationApplyError ? error.reasonCode : 'APPLY_FAILED';
1198
- const detail = error instanceof Error ? error.message : String(error);
1199
- steps.push({ name: 'Register agentdb MCP', status: 'error', detail: `${reason}: ${detail}` });
1137
+ else {
1138
+ steps.push({ name: 'Configure hooks', status: 'skipped', detail: opts.noMemory ? '--no-memory: existing hooks and guards preserved; no delivery' : '--no-hooks' });
1200
1139
  }
1201
- // Migrate off the legacy location: `.claude/mcp.json` is not loaded by Claude Code. If it
1202
- // holds ONLY our old agentdb registration, remove the file; otherwise leave it and warn.
1203
- const legacyPath = join(opts.projectRoot, '.claude', 'mcp.json');
1204
- if (existsSync(legacyPath)) {
1140
+ // Step 5.6: Install apply-leg — report pushed AFTER "Configure hooks" below (for a report order
1141
+ // that reads naturally), but see `applyLegStepResult()` above `runSetup` for why the WRITE itself
1142
+ // happens BEFORE it.
1143
+ steps.push(applyLegStep);
1144
+ // Step 5.5: Register agentdb MCP through the SAME ownership-aware transaction used by `dz init`.
1145
+ // `.mcp.json` is the project-scope carrier Claude Code actually loads. A known historical dz
1146
+ // agentdb shape is adopted; an ambiguous hand-authored entry is preserved and named as an error.
1147
+ if (backend === 'agentdb' && !opts.noHooks && !opts.noMemory) {
1148
+ const agentdbEntry = {
1149
+ command: 'npx',
1150
+ // Pin to the INSTALLED agentdb version (not @latest) so the MCP server and the hook
1151
+ // writer run the same alpha schema against one DB.
1152
+ args: [installedAgentdbSpec(opts.projectRoot), 'mcp', 'start'],
1153
+ // Pin the server to its OWN store — NEVER the writer's .dz/agentdb.db. Two engines on one
1154
+ // SQLite file (native better-sqlite3 + a silent sql.js fallback) whole-file-rewrite each
1155
+ // other: measured 2026-07-09, 5 of 20 samples zero bytes and 4 torn (ADR-001, 2026-08-26).
1156
+ env: { AGENTDB_PATH: agentdbMcpStorePath(opts.projectRoot) },
1157
+ };
1205
1158
  try {
1206
- const legacy = JSON.parse(readFileSync(legacyPath, 'utf-8'));
1207
- const keys = Object.keys(legacy.mcpServers ?? {});
1208
- if (keys.length === 1 && keys[0] === 'agentdb') {
1209
- rmSync(legacyPath);
1210
- steps.push({ name: 'Migrate legacy .claude/mcp.json', status: 'done', detail: 'removed (not loaded by Claude Code); registration now in .mcp.json' });
1159
+ const applied = applyIntegrationFragments({
1160
+ projectRoot: opts.projectRoot,
1161
+ fragments: [{
1162
+ component: 'mcp',
1163
+ carrierPath: '.mcp.json',
1164
+ scope: 'project',
1165
+ format: 'json',
1166
+ rootKey: 'mcpServers',
1167
+ entries: { agentdb: agentdbEntry },
1168
+ }],
1169
+ });
1170
+ if (applied.written.includes('.mcp.json')) {
1171
+ steps.push({
1172
+ name: 'Register agentdb MCP',
1173
+ status: 'done',
1174
+ // No tool count: a hardcoded number is a lie waiting to age (the live server answered
1175
+ // 35 while its own banner said 32 and the README said 41 — measured 2026-08-26).
1176
+ detail: `.mcp.json: ${installedAgentdbSpec(opts.projectRoot)} → .dz/agentdb-mcp.db (own store; hooks keep .dz/agentdb.db)`,
1177
+ });
1211
1178
  }
1212
1179
  else {
1213
- steps.push({ name: 'Migrate legacy .claude/mcp.json', status: 'error', detail: 'contains other servers — Claude Code does NOT load this file; move them to .mcp.json' });
1180
+ steps.push({ name: 'Register agentdb MCP', status: 'skipped', detail: 'already registered and current' });
1214
1181
  }
1215
1182
  }
1216
- catch {
1217
- steps.push({ name: 'Migrate legacy .claude/mcp.json', status: 'error', detail: 'unparseable legacy file — Claude Code does not load it; review manually' });
1183
+ catch (error) {
1184
+ const reason = error instanceof IntegrationApplyError ? error.reasonCode : 'APPLY_FAILED';
1185
+ const detail = error instanceof Error ? error.message : String(error);
1186
+ steps.push({ name: 'Register agentdb MCP', status: 'error', detail: `${reason}: ${detail}` });
1187
+ }
1188
+ // Migrate off the legacy location: `.claude/mcp.json` is not loaded by Claude Code. If it
1189
+ // holds ONLY our old agentdb registration, remove the file; otherwise leave it and warn.
1190
+ const legacyPath = join(opts.projectRoot, '.claude', 'mcp.json');
1191
+ if (existsSync(legacyPath)) {
1192
+ try {
1193
+ const legacy = JSON.parse(readFileSync(legacyPath, 'utf-8'));
1194
+ const keys = Object.keys(legacy.mcpServers ?? {});
1195
+ if (keys.length === 1 && keys[0] === 'agentdb') {
1196
+ rmSync(legacyPath);
1197
+ steps.push({ name: 'Migrate legacy .claude/mcp.json', status: 'done', detail: 'removed (not loaded by Claude Code); registration now in .mcp.json' });
1198
+ }
1199
+ else {
1200
+ steps.push({ name: 'Migrate legacy .claude/mcp.json', status: 'error', detail: 'contains other servers — Claude Code does NOT load this file; move them to .mcp.json' });
1201
+ }
1202
+ }
1203
+ catch {
1204
+ steps.push({ name: 'Migrate legacy .claude/mcp.json', status: 'error', detail: 'unparseable legacy file — Claude Code does not load it; review manually' });
1205
+ }
1218
1206
  }
1219
1207
  }
1220
1208
  }
1209
+ catch (error) {
1210
+ steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE: managed wiring failed after persistence; ${String(error)}` });
1211
+ return finish();
1212
+ }
1213
+ // Saved/local/native agreement is independent of hook installation.
1214
+ if (!opts.noMemory) {
1215
+ const actual = observedBackend();
1216
+ const problems = actual === backend ? [] : [`saved backend ${actual} differs from requested ${backend}`];
1217
+ if (backend === 'agentdb') {
1218
+ const deps = reconcileMemoryDependencies(opts.projectRoot, false);
1219
+ if (!deps.ready)
1220
+ problems.push(deps.detail);
1221
+ }
1222
+ steps.push({ name: 'Memory saved state', status: problems.length ? 'error' : 'done', detail: problems.length ? `saved backend ${actual} INCOMPLETE: ${problems.join('; ')}` : `saved backend ${actual}; dependencies/native state consistent` });
1223
+ }
1221
1224
  // Step 5.9: agentdb wiring invariant check (audit code#3). Skip-branches across repeated runs
1222
1225
  // can leave inconsistent combinations (e.g. writer+MCP present but hooks still jsonl). Verify
1223
1226
  // the three-way invariant explicitly and surface a loud error step instead of silent "skipped"s.
1224
- if (backend === 'agentdb' && !opts.noHooks) {
1227
+ if (backend === 'agentdb' && !opts.noHooks && !opts.noMemory) {
1225
1228
  const problems = [];
1226
- if (!isAgentdbInstalledLocally(opts.projectRoot))
1227
- problems.push('deps missing (npm i agentdb better-sqlite3)');
1229
+ if (observedBackend() !== backend)
1230
+ problems.push(`saved backend ${observedBackend()} differs from ${backend}`);
1228
1231
  try {
1229
1232
  const settings = JSON.parse(readFileSync(join(opts.projectRoot, '.claude', 'settings.json'), 'utf-8'));
1230
1233
  const refs = ['SessionStart', 'SessionEnd', 'PreCompact'].every((ev) => (settings.hooks?.[ev] ?? []).some((h) => commandsOf(h).some((cmd) => cmd.includes('agentdb-writer.mjs'))));
@@ -1252,7 +1255,7 @@ export function runSetup(opts) {
1252
1255
  // sentinel check (e.g. sessions.jsonl, present in both backends) would skip agentdb.db/-wal/-shm
1253
1256
  // on the documented jsonl→agentdb `--force` switch, leaking the binary store into git.
1254
1257
  const gitignorePath = join(opts.projectRoot, '.gitignore');
1255
- const dzIgnoreLines = backend === 'agentdb'
1258
+ const dzIgnoreLines = opts.noMemory ? [] : backend === 'agentdb'
1256
1259
  ? ['.dz/agentdb.db', '.dz/agentdb.db-wal', '.dz/agentdb.db-shm',
1257
1260
  '.dz/agentdb-mcp.db', '.dz/agentdb-mcp.db-wal', '.dz/agentdb-mcp.db-shm',
1258
1261
  '.dz/sessions.jsonl']
@@ -1270,21 +1273,19 @@ export function runSetup(opts) {
1270
1273
  });
1271
1274
  }
1272
1275
  else {
1273
- steps.push({ name: 'Update .gitignore', status: 'skipped', detail: 'already ignoring .dz data' });
1276
+ steps.push({ name: 'Update .gitignore', status: 'skipped', detail: opts.noMemory ? '--no-memory: existing ignore rules preserved' : 'already ignoring .dz data' });
1274
1277
  }
1275
1278
  // Step 7: Install the CLI-driver skill + agent docs (--install-driver)
1276
1279
  if (opts.installDriver) {
1277
1280
  const detail = installDriverDocs(opts.projectRoot, opts.force ?? false);
1278
1281
  steps.push({ name: 'Install driver skill', status: 'done', detail });
1279
1282
  }
1280
- return {
1281
- steps,
1282
- totalSteps: steps.length,
1283
- completed: steps.filter((s) => s.status === 'done').length,
1284
- skipped: steps.filter((s) => s.status === 'skipped').length,
1285
- memoryBackend: resolvedMemory.backend,
1286
- memoryBackendSource: resolvedMemory.source,
1287
- memoryBackendDowngraded: resolvedMemory.downgraded,
1288
- };
1283
+ const failed = steps.filter(step => step.status === 'error');
1284
+ if (!opts.noMemory && backend === 'agentdb')
1285
+ steps.push({
1286
+ name: 'Memory backend transition', status: failed.length ? 'error' : 'done',
1287
+ detail: failed.length ? `saved backend ${observedBackend()} INCOMPLETE; failed phase: ${failed.map(step => step.name).join(', ')}` : `saved backend ${observedBackend()} ready; ${priorBackend === backend ? 'already current' : `${priorBackend} → ${backend} transition complete`}`,
1288
+ });
1289
+ return finish();
1289
1290
  }
1290
1291
  //# sourceMappingURL=setup.js.map