@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.
@@ -0,0 +1,166 @@
1
+ /** Exact consumer npm persistence and local native readiness. No ancestor/global installs. */
2
+ import { existsSync, readFileSync, writeFileSync, rmSync, realpathSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+
7
+ export const MEMORY_DEFAULTS = { agentdb: '3.0.0-alpha.20', 'better-sqlite3': '11.10.0' } as const;
8
+ const names = Object.keys(MEMORY_DEFAULTS) as (keyof typeof MEMORY_DEFAULTS)[];
9
+ const sections = ['dependencies', 'devDependencies', 'optionalDependencies'] as const;
10
+ type Json = Record<string, any>;
11
+ export interface MemoryDependencyResult {
12
+ readonly ready: boolean;
13
+ readonly changedManifest: boolean;
14
+ readonly changedLock: boolean;
15
+ readonly detail: string;
16
+ }
17
+ const object = (value: unknown): value is Json => value !== null && typeof value === 'object' && !Array.isArray(value);
18
+ const same = (a: unknown, b: unknown) => isDeepStrictEqual(a, b);
19
+ const bytes = (path: string) => existsSync(path) ? readFileSync(path, 'utf8') : null;
20
+ function json(path: string): Json {
21
+ const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
22
+ if (!object(value)) throw Error(`${path}: expected a JSON object`);
23
+ return value;
24
+ }
25
+ export function supportedMemoryVersion(name: keyof typeof MEMORY_DEFAULTS, value: unknown): value is string {
26
+ const number = '(?:0|[1-9][0-9]*)';
27
+ return typeof value === 'string' && new RegExp(name === 'agentdb'
28
+ ? `^(?:3\\.${number}\\.${number}|3\\.0\\.0-alpha\\.${number})$`
29
+ : `^11\\.${number}\\.${number}$`).test(value);
30
+ }
31
+ function installed(root: string, name: string): string | null {
32
+ const path = join(root, 'node_modules', name, 'package.json');
33
+ if (!existsSync(path)) return null;
34
+ const pkg = json(path);
35
+ if (pkg['name'] !== name || typeof pkg['version'] !== 'string') throw Error(`${name}: malformed installed package metadata`);
36
+ return pkg['version'];
37
+ }
38
+ function lockVersion(lock: Json | null, name: string): string | null {
39
+ const item = lock?.['packages']?.[`node_modules/${name}`] ?? lock?.['dependencies']?.[name];
40
+ return item?.version ?? null;
41
+ }
42
+ function selectVersions(root: string, pkg: Json, lock: Json | null): Record<string, string> {
43
+ for (const section of [...sections, 'peerDependencies']) {
44
+ if (pkg[section] !== undefined && !object(pkg[section])) throw Error(`package.json: malformed ${section}`);
45
+ }
46
+ const versions: Record<string, string> = {};
47
+ for (const name of names) {
48
+ if (Object.hasOwn(pkg['peerDependencies'] ?? {}, name)) throw Error(`${name}: peer declaration is outside exact persistence repair`);
49
+ const declared = sections.flatMap(section => Object.hasOwn(pkg[section] ?? {}, name) ? [pkg[section][name]] : []);
50
+ if (declared.some(value => !supportedMemoryVersion(name, value)) || new Set(declared).size > 1) throw Error(`${name}: conflicting or non-exact/unsupported saved declaration; original files preserved`);
51
+ if (declared.length) { versions[name] = declared[0]; continue; }
52
+ const local = installed(root, name), locked = lockVersion(lock, name);
53
+ if (local !== null) {
54
+ if (!supportedMemoryVersion(name, local)) throw Error(`${name}: unsupported installed-only version ${local}`);
55
+ if (locked !== null && locked !== local) throw Error(`${name}: installed-only and orphan lock versions conflict`);
56
+ versions[name] = local;
57
+ } else if (locked !== null) throw Error(`${name}: incomplete legacy lock-only state; original files preserved`);
58
+ else versions[name] = MEMORY_DEFAULTS[name];
59
+ }
60
+ return versions;
61
+ }
62
+ function persisted(pkg: Json, lock: Json | null, versions: Record<string, string>): boolean {
63
+ return names.every(name => pkg['devDependencies']?.[name] === versions[name]
64
+ && !Object.hasOwn(pkg['dependencies'] ?? {}, name) && !Object.hasOwn(pkg['optionalDependencies'] ?? {}, name)
65
+ && lockVersion(lock, name) === versions[name] && lock?.['packages']?.['']?.['devDependencies']?.[name] === versions[name]
66
+ && !Object.hasOwn(lock?.['packages']?.['']?.['dependencies'] ?? {}, name)
67
+ && !Object.hasOwn(lock?.['packages']?.['']?.['optionalDependencies'] ?? {}, name));
68
+ }
69
+ function protectedManifest(pkg: Json): Json {
70
+ const copy: Json = JSON.parse(JSON.stringify(pkg));
71
+ for (const section of sections) {
72
+ if (!object(copy[section])) continue;
73
+ for (const name of names) delete copy[section][name];
74
+ if (!Object.keys(copy[section]).length) delete copy[section];
75
+ }
76
+ return copy;
77
+ }
78
+ function resolutions(lock: Json | null): Record<string, string> {
79
+ const result: Record<string, string> = {};
80
+ if (object(lock?.['packages'])) for (const [path, item] of Object.entries(lock['packages'])) {
81
+ if (path && !names.some(name => path === `node_modules/${name}`) && object(item) && typeof item['version'] === 'string') result[path] = item['version'];
82
+ }
83
+ else if (object(lock?.['dependencies'])) for (const [name, item] of Object.entries(lock['dependencies'])) {
84
+ if (!names.includes(name as keyof typeof MEMORY_DEFAULTS) && object(item) && typeof item['version'] === 'string') result[`node_modules/${name}`] = item['version'];
85
+ }
86
+ return result;
87
+ }
88
+ function npm(root: string, args: string[]): void {
89
+ execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, {
90
+ cwd: root, timeout: 300000, maxBuffer: 8 * 1024 * 1024, stdio: 'pipe', shell: process.platform === 'win32',
91
+ });
92
+ }
93
+ export function probeMemoryNative(root: string): string | null {
94
+ try {
95
+ for (const name of names) if (installed(root, name) === null) return `${name}: local package missing`;
96
+ const script = `const {createRequire}=require('node:module'),fs=require('node:fs'),path=require('node:path');
97
+ const root=process.argv[1],r=createRequire(path.join(root,'package.json')),local=fs.realpathSync(path.join(root,'node_modules'));
98
+ const resolved=fs.realpathSync(r.resolve('better-sqlite3')); if(path.relative(local,resolved).startsWith('..'))throw Error('native module resolved outside project');
99
+ const Sqlite=r('better-sqlite3'),db=new Sqlite(':memory:');try{if(db.prepare('SELECT 42 AS answer').get().answer!==42)throw Error('native SELECT failed');}finally{db.close();}
100
+ process.stdout.write('native-sql-ready');`;
101
+ const output = execFileSync(process.execPath, ['-e', script, realpathSync(root)], { cwd: root, timeout: 60000, maxBuffer: 4 * 1024 * 1024, stdio: 'pipe', encoding: 'utf8' });
102
+ return output === 'native-sql-ready' ? null : 'native SQL readiness stdout not established';
103
+ } catch (error) { return `native SQLite unavailable: ${error instanceof Error ? error.message : String(error)}`; }
104
+ }
105
+ export function reconcileMemoryDependencies(projectRoot: string, repair = true): MemoryDependencyResult {
106
+ const pkgPath = join(projectRoot, 'package.json'), lockPath = join(projectRoot, 'package-lock.json');
107
+ const beforePkg = bytes(pkgPath), beforeLock = bytes(lockPath);
108
+ let stage = 'validate', repaired = false;
109
+ const result = (ready: boolean, detail: string): MemoryDependencyResult => ({ ready, changedManifest: bytes(pkgPath) !== beforePkg, changedLock: bytes(lockPath) !== beforeLock, detail });
110
+ try {
111
+ const pkg = beforePkg === null ? { name: 'dz-harness-project', private: true, version: '0.0.0' } : json(pkgPath);
112
+ const lock = beforeLock === null ? null : json(lockPath);
113
+ const versions = selectVersions(projectRoot, pkg, lock);
114
+ const localCurrent = names.every(name => installed(projectRoot, name) === versions[name]);
115
+ const savedCurrent = persisted(pkg, lock, versions);
116
+ if (!repair && (!savedCurrent || !localCurrent)) return result(false, 'saved/local dependency state is inconsistent; run setup to repair exact devDependencies');
117
+ const nativeBefore = localCurrent ? probeMemoryNative(projectRoot) : 'local package versions need repair';
118
+ if (!repair && nativeBefore) return result(false, nativeBefore);
119
+ if (!savedCurrent || !localCurrent) {
120
+ stage = 'npm persistence repair';
121
+ const protectedFields = protectedManifest(pkg), protectedLock = resolutions(lock);
122
+ try {
123
+ // npm can retain an existing optional declaration despite --save-dev. Selection and
124
+ // conflicts were validated above; normalize ONLY the two memory declarations first.
125
+ const canonical: Json = JSON.parse(JSON.stringify(pkg));
126
+ for (const section of sections) {
127
+ if (!object(canonical[section])) continue;
128
+ let removed = false;
129
+ for (const name of names) if (Object.hasOwn(canonical[section], name)) { delete canonical[section][name]; removed = true; }
130
+ if (removed && !Object.keys(canonical[section]).length) delete canonical[section];
131
+ }
132
+ canonical['devDependencies'] = { ...(canonical['devDependencies'] ?? {}), ...versions };
133
+ if (beforePkg === null || !same(canonical, pkg)) writeFileSync(pkgPath, JSON.stringify(canonical, null, 2) + '\n');
134
+ npm(projectRoot, ['install', ...names.map(name => `${name}@${versions[name]}`), '--save-dev', '--save-exact', '--no-audit', '--no-fund', '--loglevel=error']);
135
+ const after = json(pkgPath), afterLock = json(lockPath), afterResolutions = resolutions(afterLock);
136
+ if (!same(protectedManifest(after), protectedFields) || Object.entries(protectedLock).some(([path, version]) => afterResolutions[path] !== version)) {
137
+ throw Error('npm changed protected unrelated manifest fields/lock resolutions');
138
+ }
139
+ if (!persisted(after, afterLock, versions) || !names.every(name => installed(projectRoot, name) === versions[name])) throw Error('npm did not establish requested exact manifest/lock/local versions');
140
+ repaired = true;
141
+ } catch (error) {
142
+ // npm is not a whole-project transaction. Restore only protected manifest/lock files.
143
+ let changedProtected = false;
144
+ try {
145
+ const after = existsSync(pkgPath) ? json(pkgPath) : {};
146
+ const afterResolutions = resolutions(existsSync(lockPath) ? json(lockPath) : null);
147
+ changedProtected = !same(protectedManifest(after), protectedFields) || Object.entries(protectedLock).some(([path, version]) => afterResolutions[path] !== version);
148
+ } catch { changedProtected = true; }
149
+ if (changedProtected) {
150
+ if (beforePkg === null) rmSync(pkgPath, { force: true }); else writeFileSync(pkgPath, beforePkg);
151
+ if (beforeLock === null) rmSync(lockPath, { force: true }); else writeFileSync(lockPath, beforeLock);
152
+ throw Error(`protected manifest/lock restored after npm changed unrelated state; local modules may be incomplete: ${String(error)}`);
153
+ }
154
+ throw error;
155
+ }
156
+ } else if (repair && nativeBefore) {
157
+ stage = 'native rebuild'; npm(projectRoot, ['rebuild', 'better-sqlite3', '--no-audit', '--no-fund', '--loglevel=error']); repaired = true;
158
+ }
159
+ stage = 'native SQL probe';
160
+ const nativeError = probeMemoryNative(projectRoot);
161
+ if (nativeError) return result(false, `INCOMPLETE ${stage}: ${nativeError}; package.json ${bytes(pkgPath) === beforePkg ? 'unchanged' : 'changed'}, package-lock.json ${bytes(lockPath) === beforeLock ? 'unchanged' : 'changed'}`);
162
+ return result(true, `${repaired ? 'repaired exact devDependencies/local readiness' : 'already current exact devDependencies and native SQLite'}; package.json ${bytes(pkgPath) === beforePkg ? 'unchanged' : 'changed'}, package-lock.json ${bytes(lockPath) === beforeLock ? 'unchanged' : 'changed'}; ${names.map(name => `${name}@${versions[name]}`).join(', ')}; native SELECT 42 passed`);
163
+ } catch (error) {
164
+ return result(false, `INCOMPLETE ${stage}: ${error instanceof Error ? error.message : String(error)}; package.json ${bytes(pkgPath) === beforePkg ? 'unchanged' : 'changed'}, package-lock.json ${bytes(lockPath) === beforeLock ? 'unchanged' : 'changed'}`);
165
+ }
166
+ }
package/src/setup.ts CHANGED
@@ -16,9 +16,12 @@
16
16
  * @packageDocumentation
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
19
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, renameSync } from 'node:fs';
20
20
  import { basename, dirname, isAbsolute, join, relative } from 'node:path';
21
- import { execSync, spawnSync } from 'node:child_process';
21
+ import { spawnSync } from 'node:child_process';
22
+
23
+ import { randomUUID } from 'node:crypto';
24
+ import { reconcileMemoryDependencies } from './setup-memory-deps.js';
22
25
 
23
26
  import { mergeManagedHookEntries } from './managed-hooks.js';
24
27
  import { writeUniqueStampedFile } from './stamped-path.js';
@@ -146,6 +149,8 @@ export interface SetupResult {
146
149
  readonly skipped: number;
147
150
  /** The memory backend this run actually used (feature `setup-backend-from-config`, FR-1). */
148
151
  readonly memoryBackend: MemoryBackend;
152
+ /** Observed persisted state; unknown is explicit on failed/uninitialized setup. */
153
+ readonly memoryBackendObserved?: MemoryBackend | 'unknown';
149
154
  /** Where {@link memoryBackend} came from — FR-3, also the `--json` field name. */
150
155
  readonly memoryBackendSource: MemoryBackendSource;
151
156
  /** `true` when an explicit `--memory jsonl` pulled an agentdb-configured project down (FR-2). */
@@ -547,11 +552,6 @@ function generateDzConfig(target: string, preset: string | undefined, backend: M
547
552
  }, null, 2);
548
553
  }
549
554
 
550
- /** True if `agentdb` resolves from the project's node_modules (the hook writer needs it there). */
551
- function isAgentdbInstalledLocally(projectRoot: string): boolean {
552
- return existsSync(join(projectRoot, 'node_modules', 'agentdb', 'package.json'));
553
- }
554
-
555
555
  /**
556
556
  * The exact agentdb version installed in the project, or `'latest'` as a fallback. Used to pin the
557
557
  * MCP server spec (`agentdb@<version>`) so the long-running MCP server and the hook writer — which
@@ -573,38 +573,6 @@ function installedAgentdbSpec(projectRoot: string): string {
573
573
  * build tools — and gives true cross-process WAL concurrency so the hook and the MCP server share
574
574
  * one live store). Best-effort: returns false (caller degrades to jsonl) if install fails.
575
575
  */
576
- function installAgentdbLocally(projectRoot: string): boolean {
577
- if (isAgentdbInstalledLocally(projectRoot)) return true;
578
- try {
579
- // Anchor npm to THIS project: without a package.json here, npm's prefix walk-up would
580
- // install into (and mutate the lockfile of) the nearest ANCESTOR project (audit code#2).
581
- const pkgJsonPath = join(projectRoot, 'package.json');
582
- if (!existsSync(pkgJsonPath)) {
583
- writeFileSync(pkgJsonPath, JSON.stringify({ name: 'dz-harness-project', private: true, version: '0.0.0' }, null, 2) + '\n');
584
- }
585
- // NB: use the ESM-imported execSync — `require()` is undefined in this ESM module (the
586
- // original agentdb hooks failed silently for exactly this reason). stdio:'ignore' (not
587
- // 'pipe') avoids execSync's 1 MB maxBuffer aborting the child on npm's verbose output.
588
- // --save-exact: agentdb is alpha; a semver range would let a later `npm update` drift the
589
- // local copy away from the version the MCP registration pins (audit gap G7).
590
- //
591
- // better-sqlite3@^11 (AM-2, dz-harness-hub issue #10 defect 1, MEASURED Node 20.20.2 with no
592
- // `make` on PATH): an unpinned `npm install better-sqlite3` resolved 12.11.1, which ships no
593
- // prebuilt binary for Node 20's ABI 115 — the install fell through to a node-gyp source build
594
- // and failed on a machine with no C toolchain. `agentdb` itself requests `^11.8.1`, which DOES
595
- // publish an ABI-115 prebuild, so pinning the range here costs nothing agentdb wasn't already
596
- // going to resolve to, and buys a working install on a bare Node 20/22 host.
597
- execSync('npm install agentdb better-sqlite3@^11 --save-exact --no-audit --no-fund --loglevel=error', {
598
- cwd: projectRoot,
599
- stdio: 'ignore',
600
- timeout: 300000,
601
- });
602
- return isAgentdbInstalledLocally(projectRoot);
603
- } catch {
604
- return false;
605
- }
606
- }
607
-
608
576
  /** Run full environment setup. */
609
577
  /** Marker that brackets the dz-harness section in a shared CLAUDE.md/AGENTS.md. */
610
578
  const DRIVER_MARKER_START = '<!-- dz-harness-driver:start -->';
@@ -957,19 +925,39 @@ export function runSetup(opts: SetupOptions): SetupResult {
957
925
  const resolvedMemory = resolveSetupMemoryBackend(opts.projectRoot, opts.memory, opts.noMemory === true);
958
926
  const backend: MemoryBackend = resolvedMemory.backend;
959
927
 
960
- // Step 0: Install agentdb + better-sqlite3 locally so the session-hook writer can import them
961
- // and share a native store with the MCP server. Best-effort — the writer self-degrades to a
962
- // jsonl marker (and self-heals once the deps exist) if this fails.
963
- if (backend === 'agentdb') {
964
- const ready = installAgentdbLocally(opts.projectRoot);
965
- if (ready) {
966
- steps.push({ name: 'Install agentdb + better-sqlite3', status: 'done', detail: 'local deps for real vector writes' });
967
- } else {
968
- steps.push({
969
- name: 'Install agentdb + better-sqlite3',
970
- status: 'error',
971
- detail: 'install failed — hooks log to sessions.jsonl until you run: npm i agentdb better-sqlite3',
972
- });
928
+ const configPath = join(dzDir, 'config.json');
929
+ let config: Record<string, unknown> | undefined;
930
+ const observedBackend = (): MemoryBackend | 'unknown' => {
931
+ try {
932
+ const value = JSON.parse(readFileSync(configPath, 'utf8')) as { memory?: { backend?: unknown } };
933
+ return value?.memory?.backend === 'agentdb' || value?.memory?.backend === 'jsonl' ? value.memory.backend : 'unknown';
934
+ } catch { return 'unknown'; }
935
+ };
936
+ const finish = (): SetupResult => ({
937
+ steps, totalSteps: steps.length, completed: steps.filter(step => step.status === 'done').length,
938
+ skipped: steps.filter(step => step.status === 'skipped').length,
939
+ memoryBackend: observedBackend() === 'unknown' ? resolvedMemory.backend : observedBackend() as MemoryBackend,
940
+ memoryBackendObserved: observedBackend(), memoryBackendSource: resolvedMemory.source,
941
+ memoryBackendDowngraded: resolvedMemory.downgraded,
942
+ });
943
+ try {
944
+ if (existsSync(configPath)) {
945
+ const value: unknown = JSON.parse(readFileSync(configPath, 'utf8'));
946
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw Error('config must be an object');
947
+ config = value as Record<string, unknown>;
948
+ const memory = config['memory'];
949
+ if (memory === null || typeof memory !== 'object' || Array.isArray(memory) || !['jsonl', 'agentdb'].includes(String((memory as Record<string, unknown>)['backend']))) throw Error('memory.backend must be jsonl or agentdb');
950
+ }
951
+ } catch (error) {
952
+ steps.push({ name: 'Memory saved state', status: 'error', detail: `unknown INCOMPLETE: malformed .dz/config.json preserved; ${String(error)}` });
953
+ return finish();
954
+ }
955
+ if (backend === 'agentdb' && !opts.noMemory) {
956
+ const deps = reconcileMemoryDependencies(opts.projectRoot);
957
+ steps.push({ name: 'Memory dependencies', status: deps.ready ? (deps.changedManifest || deps.changedLock || deps.detail.startsWith('repaired') ? 'done' : 'skipped') : 'error', detail: deps.detail });
958
+ if (!deps.ready) {
959
+ 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` });
960
+ return finish();
973
961
  }
974
962
  }
975
963
 
@@ -981,38 +969,32 @@ export function runSetup(opts: SetupOptions): SetupResult {
981
969
  steps.push({ name: 'Create .dz directory', status: 'skipped', detail: 'already exists' });
982
970
  }
983
971
 
984
- // Step 2: Write .dz/config.json. FR-2: a DOWNGRADE (explicit --memory jsonl over an
985
- // agentdb-configured project) forces the write even without --force — "two truths after any
986
- // setup coincide" means the config may not keep claiming agentdb once the caller has explicitly
987
- // asked for jsonl.
988
- const configPath = join(dzDir, 'config.json');
989
- if (!existsSync(configPath) || opts.force) {
990
- writeFileSync(configPath, generateDzConfig(opts.target, opts.preset, backend));
991
- steps.push({ name: 'Write .dz/config.json', status: 'done', detail: `${backend} backend` });
992
- } else if (resolvedMemory.downgraded) {
993
- // Lead edit after Codex review (finding 3): a downgrade changes ONLY memory.backend — every other
994
- // field the owner keeps in .dz/config.json survives; an unparsable file falls back to regeneration.
995
- let rewritten = false;
996
- try {
997
- const cfg = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
998
- const memory = (cfg['memory'] !== null && typeof cfg['memory'] === 'object') ? (cfg['memory'] as Record<string, unknown>) : {};
999
- cfg['memory'] = { ...memory, backend };
1000
- writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n');
1001
- rewritten = true;
1002
- } catch { /* fall through to regeneration */ }
1003
- if (!rewritten) writeFileSync(configPath, generateDzConfig(opts.target, opts.preset, backend));
1004
- steps.push({ name: 'Write .dz/config.json', status: 'done', detail: `memory.backend → ${backend} (other fields kept)` });
972
+ // Persist only the requested backend field of readable existing config, atomically.
973
+ const priorBackend = observedBackend();
974
+ if (opts.noMemory) {
975
+ steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: '--no-memory: existing memory configuration preserved' });
1005
976
  } else {
1006
- steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: 'already exists (use --force)' });
1007
- }
1008
- if (resolvedMemory.downgraded) {
1009
- steps.push({
1010
- name: 'Memory backend downgrade',
1011
- status: 'done',
1012
- detail: '⚠ memory backend downgraded agentdb → jsonl by --memory jsonl',
1013
- });
977
+ const transition = config !== undefined && opts.memory !== undefined && priorBackend !== backend;
978
+ if (config === undefined || opts.force || transition) {
979
+ const next = config !== undefined
980
+ ? { ...config, memory: { ...(config['memory'] as Record<string, unknown>), backend } }
981
+ : JSON.parse(generateDzConfig(opts.target, opts.preset, backend));
982
+ const temporary = `${configPath}.${randomUUID()}.tmp`;
983
+ try {
984
+ writeFileSync(temporary, JSON.stringify(next, null, 2) + '\n', { flag: 'wx' });
985
+ renameSync(temporary, configPath);
986
+ steps.push({ name: 'Write .dz/config.json', status: 'done', detail: transition ? `memory.backend ${priorBackend} → ${backend}; all other config fields kept` : `${backend} backend` });
987
+ } catch (error) {
988
+ if (existsSync(temporary)) rmSync(temporary);
989
+ steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE: config persistence failed; later memory wiring not run; ${String(error)}` });
990
+ return finish();
991
+ }
992
+ } else steps.push({ name: 'Write .dz/config.json', status: 'skipped', detail: 'already current; existing config preserved' });
993
+ if (resolvedMemory.downgraded) steps.push({ name: 'Memory backend downgrade', status: 'done', detail: '⚠ memory backend downgraded agentdb → jsonl by --memory jsonl; other config fields kept' });
1014
994
  }
1015
995
 
996
+ try {
997
+ if (!opts.noMemory) {
1016
998
  // Step 3: Initialize session log
1017
999
  const sessionsPath = join(dzDir, 'sessions.jsonl');
1018
1000
  if (!existsSync(sessionsPath)) {
@@ -1064,18 +1046,20 @@ export function runSetup(opts: SetupOptions): SetupResult {
1064
1046
  }
1065
1047
  }
1066
1048
 
1049
+ } // --no-memory performs no memory store initialization.
1050
+
1067
1051
  // Step 4.6: Install apply-leg — the WORK happens here (before "Configure hooks" writes
1068
1052
  // SessionStart), so a foreign SessionStart entry is already in place before that step's own
1069
1053
  // merge ever sees it; see `applyLegStepResult`'s doc for why order matters. The STEP is reported
1070
1054
  // further down, after "Configure hooks" pushes its own, so the printed order still reads as
1071
1055
  // "collect → rank → apply".
1072
- const applyLegStep = applyLegStepResult(opts, backend);
1056
+ const applyLegStep: SetupStep = opts.noMemory ? { name: 'Install apply-leg', status: 'skipped', detail: '--no-memory' } : applyLegStepResult(opts, backend);
1073
1057
 
1074
1058
  // Step 5: Configure hooks (write to .claude/settings.json) — EVENT-LEVEL merge (gap G2):
1075
1059
  // dz-generated entries (recognized by signature, incl. the broken legacy `agentdb add` hooks
1076
1060
  // this feature fixes) are replaced in place WITHOUT --force; the user's own hooks and every
1077
1061
  // other settings key are preserved. Full-file overwrite happens only when the file is absent.
1078
- if (!opts.noHooks) {
1062
+ if (!opts.noHooks && !opts.noMemory) {
1079
1063
  const settingsDir = join(opts.projectRoot, '.claude');
1080
1064
  const settingsPath = join(settingsDir, 'settings.json');
1081
1065
 
@@ -1253,7 +1237,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
1253
1237
  }
1254
1238
  }
1255
1239
  } else {
1256
- steps.push({ name: 'Configure hooks', status: 'skipped', detail: '--no-hooks' });
1240
+ steps.push({ name: 'Configure hooks', status: 'skipped', detail: opts.noMemory ? '--no-memory: existing hooks and guards preserved; no delivery' : '--no-hooks' });
1257
1241
  }
1258
1242
 
1259
1243
  // Step 5.6: Install apply-leg — report pushed AFTER "Configure hooks" below (for a report order
@@ -1264,7 +1248,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
1264
1248
  // Step 5.5: Register agentdb MCP through the SAME ownership-aware transaction used by `dz init`.
1265
1249
  // `.mcp.json` is the project-scope carrier Claude Code actually loads. A known historical dz
1266
1250
  // agentdb shape is adopted; an ambiguous hand-authored entry is preserved and named as an error.
1267
- if (backend === 'agentdb') {
1251
+ if (backend === 'agentdb' && !opts.noHooks && !opts.noMemory) {
1268
1252
  const agentdbEntry = {
1269
1253
  command: 'npx',
1270
1254
  // Pin to the INSTALLED agentdb version (not @latest) so the MCP server and the hook
@@ -1322,12 +1306,28 @@ export function runSetup(opts: SetupOptions): SetupResult {
1322
1306
  }
1323
1307
  }
1324
1308
 
1309
+ } catch (error) {
1310
+ steps.push({ name: 'Memory backend transition', status: 'error', detail: `saved backend ${observedBackend()} INCOMPLETE: managed wiring failed after persistence; ${String(error)}` });
1311
+ return finish();
1312
+ }
1313
+
1314
+ // Saved/local/native agreement is independent of hook installation.
1315
+ if (!opts.noMemory) {
1316
+ const actual = observedBackend();
1317
+ const problems: string[] = actual === backend ? [] : [`saved backend ${actual} differs from requested ${backend}`];
1318
+ if (backend === 'agentdb') {
1319
+ const deps = reconcileMemoryDependencies(opts.projectRoot, false);
1320
+ if (!deps.ready) problems.push(deps.detail);
1321
+ }
1322
+ 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` });
1323
+ }
1324
+
1325
1325
  // Step 5.9: agentdb wiring invariant check (audit code#3). Skip-branches across repeated runs
1326
1326
  // can leave inconsistent combinations (e.g. writer+MCP present but hooks still jsonl). Verify
1327
1327
  // the three-way invariant explicitly and surface a loud error step instead of silent "skipped"s.
1328
- if (backend === 'agentdb' && !opts.noHooks) {
1328
+ if (backend === 'agentdb' && !opts.noHooks && !opts.noMemory) {
1329
1329
  const problems: string[] = [];
1330
- if (!isAgentdbInstalledLocally(opts.projectRoot)) problems.push('deps missing (npm i agentdb better-sqlite3)');
1330
+ if (observedBackend() !== backend) problems.push(`saved backend ${observedBackend()} differs from ${backend}`);
1331
1331
  try {
1332
1332
  const settings = JSON.parse(readFileSync(join(opts.projectRoot, '.claude', 'settings.json'), 'utf-8')) as {
1333
1333
  hooks?: Record<string, unknown[]>;
@@ -1357,7 +1357,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
1357
1357
  // sentinel check (e.g. sessions.jsonl, present in both backends) would skip agentdb.db/-wal/-shm
1358
1358
  // on the documented jsonl→agentdb `--force` switch, leaking the binary store into git.
1359
1359
  const gitignorePath = join(opts.projectRoot, '.gitignore');
1360
- const dzIgnoreLines = backend === 'agentdb'
1360
+ const dzIgnoreLines = opts.noMemory ? [] : backend === 'agentdb'
1361
1361
  ? ['.dz/agentdb.db', '.dz/agentdb.db-wal', '.dz/agentdb.db-shm',
1362
1362
  '.dz/agentdb-mcp.db', '.dz/agentdb-mcp.db-wal', '.dz/agentdb-mcp.db-shm',
1363
1363
  '.dz/sessions.jsonl']
@@ -1374,7 +1374,7 @@ export function runSetup(opts: SetupOptions): SetupResult {
1374
1374
  detail: `added ${missing.join(', ')}`,
1375
1375
  });
1376
1376
  } else {
1377
- steps.push({ name: 'Update .gitignore', status: 'skipped', detail: 'already ignoring .dz data' });
1377
+ steps.push({ name: 'Update .gitignore', status: 'skipped', detail: opts.noMemory ? '--no-memory: existing ignore rules preserved' : 'already ignoring .dz data' });
1378
1378
  }
1379
1379
 
1380
1380
  // Step 7: Install the CLI-driver skill + agent docs (--install-driver)
@@ -1383,13 +1383,10 @@ export function runSetup(opts: SetupOptions): SetupResult {
1383
1383
  steps.push({ name: 'Install driver skill', status: 'done', detail });
1384
1384
  }
1385
1385
 
1386
- return {
1387
- steps,
1388
- totalSteps: steps.length,
1389
- completed: steps.filter((s) => s.status === 'done').length,
1390
- skipped: steps.filter((s) => s.status === 'skipped').length,
1391
- memoryBackend: resolvedMemory.backend,
1392
- memoryBackendSource: resolvedMemory.source,
1393
- memoryBackendDowngraded: resolvedMemory.downgraded,
1394
- };
1386
+ const failed = steps.filter(step => step.status === 'error');
1387
+ if (!opts.noMemory && backend === 'agentdb') steps.push({
1388
+ name: 'Memory backend transition', status: failed.length ? 'error' : 'done',
1389
+ 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`}`,
1390
+ });
1391
+ return finish();
1395
1392
  }