@vodmal/vdx-cli 0.36.0 → 0.37.0

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/README.md CHANGED
@@ -50,8 +50,11 @@ vdx push [project-path] [--dry-run] # default: cwd
50
50
 
51
51
  # Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin; git hooks of the project; the GitHub token vdx asks with)
52
52
  vdx doctor [project-path] [--format=ansi|markdown|json] # default: cwd
53
- vdx doctor --check [project-path] # one line per hook declared but not running here — the plugin's SessionStart hook tells the agent
54
- vdx doctor --fix # write the profile's personal hooks (git.hooks) into ~/.gitconfig
53
+ vdx doctor --check [project-path] # one line per hook declared but not running here, and one when the memory is
54
+ # not inside — the plugin's SessionStart hook tells the agent
55
+ vdx doctor --fix [project-path] # write the profile's personal hooks (git.hooks) into ~/.gitconfig, and put
56
+ # the project's Claude Code memory inside it: .claude/memory/ (after a
57
+ # folder move, its path and conversations follow)
55
58
 
56
59
  # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
57
60
  vdx ai[@host] [project-path] [--new | --conversation <id>] [--focused | --intercom] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there; --intercom: the project's mail session
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
5
5
  "keywords": [
6
6
  "vdx",
@@ -11,7 +11,7 @@ schema_version: "0.3"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "1.6.1"
14
+ version: "1.7.0"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
package/src/ai.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  parseMachineReport,
30
30
  runsInProject,
31
31
  } from './conversations.ts';
32
+ import { describeMemory, fixProjectMemory, memoryNeedsFix, memoryState } from './memory.ts';
32
33
  import { at, errorMessage, isStringList, str } from './narrow.ts';
33
34
  import { type Lag, sessionLag } from './session-versions.ts';
34
35
 
@@ -115,6 +116,8 @@ interface GitProfile {
115
116
  /** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
116
117
  author_pool?: string[];
117
118
  hooks?: PersonalHook[];
119
+ /** Hosts whose repos are the owner's alone: Claude Code's memory goes under git there (claude-memory-in-project). */
120
+ personal_hosts?: string[];
118
121
  }
119
122
 
120
123
  export interface Environment {
@@ -252,6 +255,9 @@ export function parseEnvironment(text: string, source: string): Environment {
252
255
  if (gitDoc.author_pool !== undefined && !isStringList(gitDoc.author_pool)) {
253
256
  fail('git.author_pool', 'must be a list of directories');
254
257
  }
258
+ if (gitDoc.personal_hosts !== undefined && !isStringList(gitDoc.personal_hosts)) {
259
+ fail('git.personal_hosts', 'must be a list of host names');
260
+ }
255
261
  if (gitDoc.hooks !== undefined) {
256
262
  if (!Array.isArray(gitDoc.hooks)) fail('git.hooks', 'must be a list');
257
263
  const names = new Set<string>();
@@ -535,7 +541,8 @@ export function missingArgs(procArgs: string, required: string[]): string[] {
535
541
  return required.filter((a) => (/\s/.test(a) ? !procArgs.includes(a) : !tokens.has(a)));
536
542
  }
537
543
 
538
- function resolveProjectRoot(p: string): string | null {
544
+ /** The git checkout `p` is in (its top), else the directory itself; null when `p` is no directory. */
545
+ export function resolveProjectRoot(p: string): string | null {
539
546
  const abs = path.resolve(p);
540
547
  try {
541
548
  if (!fs.statSync(abs).isDirectory()) return null;
@@ -1140,6 +1147,61 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
1140
1147
  }
1141
1148
  }
1142
1149
 
1150
+ /**
1151
+ * Claude Code's memory of the project lives inside it, `.claude/memory/`
1152
+ * (claude-memory-in-project): a start says when it does not, and at a terminal asks
1153
+ * to put it there — Enter does. With an agent of the project running it only says so:
1154
+ * that session keeps the old place until it restarts. Without a terminal it names
1155
+ * `vdx doctor --fix`, the same move.
1156
+ */
1157
+ export function ensureMemory(
1158
+ projectRoot: string,
1159
+ command: string,
1160
+ personalHosts: string[],
1161
+ deps: AiDeps,
1162
+ dryRun: boolean,
1163
+ agentRunning: boolean,
1164
+ ): void {
1165
+ if (!isClaude(command)) return;
1166
+ const ctx = { env: deps.env, home: deps.home };
1167
+ const state = memoryState(projectRoot, ctx);
1168
+ if (dryRun) {
1169
+ deps.out(` memory: ${describeMemory(state, ctx)}\n`);
1170
+ return;
1171
+ }
1172
+ if (!memoryNeedsFix(state)) return;
1173
+ const fix = `vdx doctor --fix ${shellQuote(projectRoot)}`;
1174
+ deps.log(`⚠ ${path.basename(projectRoot)}: Claude memory ${describeMemory(state, ctx)}`);
1175
+ if (!deps.interactive || agentRunning) {
1176
+ deps.log(` ${agentRunning ? 'once the running agent of the project ends' : 'put it there'}: ${fix}`);
1177
+ return;
1178
+ }
1179
+ const question =
1180
+ state.kind === 'outside'
1181
+ ? 'Move it into .claude/memory/ now?'
1182
+ : state.kind === 'leftovers'
1183
+ ? 'Set the old place aside?'
1184
+ : state.kind === 'moved' && state.conversations
1185
+ ? 'Point it here, and bring the conversations along?'
1186
+ : 'Point it here?';
1187
+ const answer = deps.ask(` ${question} [Y/n] `);
1188
+ if (answer === null || /^n(o)?$/i.test(answer.trim())) {
1189
+ deps.log(` skipped — later: ${fix}`);
1190
+ return;
1191
+ }
1192
+ try {
1193
+ const done = fixProjectMemory(projectRoot, state, ctx, {
1194
+ ask: deps.ask,
1195
+ log: deps.log,
1196
+ today: new Date().toLocaleDateString('sv-SE'),
1197
+ personalHosts,
1198
+ });
1199
+ for (const d of done) deps.log(`✓ ${d}`);
1200
+ } catch (e) {
1201
+ deps.log(`✗ ${errorMessage(e)} — ${fix}`);
1202
+ }
1203
+ }
1204
+
1143
1205
  /** `from` — where the person sits; a vdx that `vdx ai@<host>` started passes its own on. */
1144
1206
  export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, from: string = seatLabel(deps.env)): number {
1145
1207
  const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
@@ -1866,6 +1928,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1866
1928
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1867
1929
  for (const l of folders) deps.out(` ${l}\n`);
1868
1930
  ensureAuthor(projectRoot, poolDirs, deps, true);
1931
+ ensureMemory(projectRoot, plan.command, environment.git?.personal_hosts ?? [], deps, true, agentsHere.length > 0);
1869
1932
  return EXIT_OK;
1870
1933
  }
1871
1934
  for (const l of choice?.lines ?? []) log(l);
@@ -1901,6 +1964,7 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1901
1964
  plan = { ...plan, resumeArgs: resumeId(id) };
1902
1965
  }
1903
1966
  ensureAuthor(projectRoot, poolDirs, deps, false);
1967
+ ensureMemory(projectRoot, plan.command, environment.git?.personal_hosts ?? [], deps, false, agentsHere.length > 0);
1904
1968
 
1905
1969
  if (multiplexer === 'none') {
1906
1970
  if (opts.restart) {
package/src/doctor.ts CHANGED
@@ -4,9 +4,24 @@ import * as path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import { effectiveAuthor, isGitRepo, rankAuthors, remoteParts, scanPool } from './author.ts';
7
- import { authorPoolDirs, loadEnvironment, resolveEnvironmentPath, shellQuote, type Environment } from './ai.ts';
7
+ import {
8
+ authorPoolDirs,
9
+ loadEnvironment,
10
+ resolveEnvironmentPath,
11
+ resolveProjectRoot,
12
+ shellQuote,
13
+ type Environment,
14
+ } from './ai.ts';
8
15
  import { checkRepoHooks } from './clone-checks.ts';
9
16
  import { githubToken, machineIo, type HostingIo } from './hosting.ts';
17
+ import {
18
+ describeMemory,
19
+ fixProjectMemory,
20
+ memoryNeedsFix,
21
+ memoryState,
22
+ type MemoryCtx,
23
+ type MemoryState,
24
+ } from './memory.ts';
10
25
  import { at, dict, errorMessage, str } from './narrow.ts';
11
26
  import {
12
27
  checkPersonalHooks,
@@ -24,6 +39,8 @@ export interface CheckResult {
24
39
  level?: number;
25
40
  message: string;
26
41
  remedy?: string;
42
+ /** For the agent's own session (`--check`): what the fix does not change for it. */
43
+ sessionNote?: string;
27
44
  }
28
45
 
29
46
  /**
@@ -473,6 +490,52 @@ export function checkGithubToken(
473
490
  };
474
491
  }
475
492
 
493
+ /** The profile, or none: a broken one is `vdx ai`'s report, not a reason for doctor to stop. */
494
+ function profile(): Environment {
495
+ const file = resolveEnvironmentPath();
496
+ try {
497
+ return file ? loadEnvironment(file) : {};
498
+ } catch {
499
+ return {};
500
+ }
501
+ }
502
+
503
+ /** Claude Code is the project's agent: the profile names it, or names no agent at all. */
504
+ function claudeIsTheAgent(environment: Environment): boolean {
505
+ return path.basename(environment.agent?.command ?? 'claude') === 'claude';
506
+ }
507
+
508
+ function memoryCtx(): MemoryCtx {
509
+ return { env: process.env, home: os.homedir() };
510
+ }
511
+
512
+ /**
513
+ * Where Claude Code keeps the project's memory (claude-memory-in-project): inside the
514
+ * project, `.claude/memory/`, so a moved folder takes it along — and the path set there
515
+ * is the trace a move leaves.
516
+ */
517
+ export function checkClaudeMemory(
518
+ ctx: DoctorCtx,
519
+ environment: Environment = profile(),
520
+ mctx: MemoryCtx = memoryCtx(),
521
+ ): CheckResult | null {
522
+ if (ctx.projectRoot === null || !claudeIsTheAgent(environment)) return null;
523
+ // Claude Code's slot is the folder the agent starts in — the checkout's top for vdx ai.
524
+ const root = resolveProjectRoot(ctx.projectRoot) ?? ctx.projectRoot;
525
+ const state = memoryState(root, mctx);
526
+ return {
527
+ id: 'claude-memory',
528
+ label: 'Claude memory',
529
+ status: memoryNeedsFix(state) ? 'warning' : 'ok',
530
+ message: describeMemory(state, mctx),
531
+ ...(memoryNeedsFix(state) ? { remedy: `vdx doctor --fix ${shellQuote(root)}` } : {}),
532
+ // A session read the place when it started: after a fix it keeps writing to the old one.
533
+ ...(state.kind === 'outside' || state.kind === 'moved' || state.kind === 'elsewhere'
534
+ ? { sessionNote: 'This session keeps the old place until it restarts (vdx ai --restart).' }
535
+ : {}),
536
+ };
537
+ }
538
+
476
539
  const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
477
540
  checkVdxVersion,
478
541
  checkVdx,
@@ -487,6 +550,7 @@ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
487
550
  (ctx) => checkPersonalHooks(ctx.projectRoot),
488
551
  checkGitAuthor,
489
552
  (ctx) => checkGithubToken(ctx),
553
+ (ctx) => checkClaudeMemory(ctx),
490
554
  ];
491
555
 
492
556
  export function resolveDoctorCtx(cwd: string = process.cwd()): DoctorCtx {
@@ -510,13 +574,20 @@ export function runDoctor(ctx: DoctorCtx = resolveDoctorCtx()): DoctorReport {
510
574
  export function runDoctorCheck(
511
575
  ctx: DoctorCtx = resolveDoctorCtx(),
512
576
  deps: PersonalHookDeps = defaultPersonalHookDeps(),
577
+ environment: Environment = profile(),
578
+ mctx: MemoryCtx = memoryCtx(),
513
579
  ): string {
514
- const rows = [checkRepoHooks(ctx.projectRoot), checkPersonalHooks(ctx.projectRoot, deps)].filter(
515
- (c): c is CheckResult => c !== null && c.status !== 'ok',
516
- );
580
+ const rows = [
581
+ checkRepoHooks(ctx.projectRoot),
582
+ checkPersonalHooks(ctx.projectRoot, deps),
583
+ checkClaudeMemory(ctx, environment, mctx),
584
+ ].filter((c): c is CheckResult => c !== null && c.status !== 'ok');
517
585
  return rows
518
586
  .map(
519
- (c) => `vdx doctor: ${c.label} — ${c.message}.` + (c.remedy ? ` Fix, on the user's word: \`${c.remedy}\`.` : ''),
587
+ (c) =>
588
+ `vdx doctor: ${c.label} — ${c.message}.` +
589
+ (c.remedy ? ` Fix, on the user's word: \`${c.remedy}\`.` : '') +
590
+ (c.sessionNote ? ` ${c.sessionNote}` : ''),
520
591
  )
521
592
  .join('\n');
522
593
  }
@@ -530,6 +601,18 @@ export function runDoctorFix(deps: PersonalHookDeps = defaultPersonalHookDeps())
530
601
  return fixPersonalHooks(deps);
531
602
  }
532
603
 
604
+ /** `vdx doctor --fix` for the memory: the profile's personal hosts, this machine's Claude Code. */
605
+ export function fixDoctorMemory(
606
+ projectRoot: string,
607
+ state: MemoryState,
608
+ io: { ask: (prompt: string) => string | null; log: (line: string) => void; today: string },
609
+ ): string[] {
610
+ return fixProjectMemory(projectRoot, state, memoryCtx(), {
611
+ ...io,
612
+ personalHosts: profile().git?.personal_hosts ?? [],
613
+ });
614
+ }
615
+
533
616
  const PROJECT_MARKER_FILES = [
534
617
  'package.json',
535
618
  'composer.json',
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import * as os from 'node:os';
2
3
  import * as path from 'node:path';
3
4
  import { execFileSync } from 'node:child_process';
4
5
  import { loadRubric } from './rubric.ts';
@@ -16,6 +17,8 @@ import {
16
17
  reportDoctorAnsi,
17
18
  } from './report.ts';
18
19
  import {
20
+ checkClaudeMemory,
21
+ fixDoctorMemory,
19
22
  runDoctor,
20
23
  runDoctorCheck,
21
24
  runDoctorFix,
@@ -23,6 +26,7 @@ import {
23
26
  looksLikeProject,
24
27
  readCliVersion,
25
28
  } from './doctor.ts';
29
+ import { memoryState } from './memory.ts';
26
30
  import { planInit, writeInit, renderPlanSummary } from './init.ts';
27
31
  import {
28
32
  planPublish,
@@ -35,7 +39,17 @@ import {
35
39
  } from './publish.ts';
36
40
  import { resolveDefaultRubric } from './defaults.ts';
37
41
  import { defaultGateDeps, GATE_PREFIX, planGate, pushThroughGate } from './gate.ts';
38
- import { askLine, runAi, runAiCheck, runAiClose, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
42
+ import {
43
+ askLine,
44
+ runAi,
45
+ runAiCheck,
46
+ runAiClose,
47
+ runAiRemote,
48
+ defaultDeps,
49
+ hostLabel,
50
+ REMOTE_HOST_RE,
51
+ resolveProjectRoot,
52
+ } from './ai.ts';
39
53
  import { isConversationId } from './conversations.ts';
40
54
 
41
55
  const DEFAULT_RUBRIC = resolveDefaultRubric();
@@ -132,6 +146,11 @@ ones marked — Enter takes the newest; without a terminal it takes this
132
146
  machine's. A new conversation (--new, or none to continue) while an agent of
133
147
  the project runs on another machine starts only after a yes.
134
148
 
149
+ Claude Code's memory of the project (Claude Code only): when it is not inside the
150
+ project, .claude/memory/, or the folder was moved, a start in a terminal asks to
151
+ put it there — Enter does, as vdx doctor --fix; with an agent of the project
152
+ running, or no terminal, it only names that command.
153
+
135
154
  Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
136
155
  3 the running agent lacks profile flags, or is not focused under --focused,
137
156
  and was left as is — no terminal, or q (rerun with --restart); 4 the start
@@ -221,14 +240,23 @@ Claude Code plugin) and, inside a project (default: cwd), its commit author and
221
240
  its git hooks: are the hooks the repository declares on in this clone, and do
222
241
  the personal hooks of your profile (git.hooks) run on this machine; with origin on
223
242
  GitHub, the token vdx asks GitHub with ($GH_TOKEN, $GITHUB_TOKEN or gh) and
224
- whether it works — without one GitHub answers 60 requests an hour. Reads only.
225
- Exit 2 when something is missing.
243
+ whether it works — without one GitHub answers 60 requests an hour; and where
244
+ Claude Code keeps the project's memory: inside it, .claude/memory/, a moved folder
245
+ takes it along. Reads only, unless --fix. Exit 2 when something is missing.
226
246
 
227
247
  --check for the agent's own session (the vdx plugin's SessionStart hook): one
228
- line per hook that is declared but does not run here; nothing when in order
248
+ line per hook that is declared but does not run here, and one when the
249
+ memory is not inside; nothing when in order
229
250
  --fix write the profile's personal hooks into ~/.gitconfig where they are
230
- missing or differ; repository hooks are switched on by the project's
231
- install step, which the report names
251
+ missing or differ (repository hooks are switched on by the project's
252
+ install step, which the report names); and put the memory inside:
253
+ notes from ~/.claude/projects/<path>/memory into .claude/memory/, none
254
+ overwritten, the old place renamed, autoMemoryDirectory written —
255
+ into .claude/settings.json in a personal project (no origin, or origin
256
+ on a host of git.personal_hosts), else into .claude/settings.local.json,
257
+ with the memory kept out of git (.git/info/exclude) unless you say
258
+ yes. After a folder is moved: the path follows it, and the project's
259
+ conversations move to the new path
232
260
  `,
233
261
  bools: ['json', 'check', 'fix'],
234
262
  values: ['format'],
@@ -598,6 +626,22 @@ function cmdDoctor(opts: ParsedArgs): void {
598
626
  process.stderr.write(`vdx doctor --fix: ${errorMessage(e)}\n`);
599
627
  process.exit(2);
600
628
  }
629
+ const root = ctx.projectRoot ? resolveProjectRoot(ctx.projectRoot) : null;
630
+ const memory = root ? checkClaudeMemory({ projectRoot: root }) : null;
631
+ if (root && memory?.remedy) {
632
+ try {
633
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
634
+ const done = fixDoctorMemory(root, memoryState(root, { env: process.env, home: os.homedir() }), {
635
+ ask: (prompt) => (interactive ? askLine(prompt) : null),
636
+ log: (line) => process.stderr.write(line + '\n'),
637
+ today: new Date().toLocaleDateString('sv-SE'),
638
+ });
639
+ process.stderr.write(done.map((d) => `✓ ${d}`).join('\n') + (done.length ? '\n' : ''));
640
+ } catch (e) {
641
+ process.stderr.write(`vdx doctor --fix: Claude memory — ${errorMessage(e)}\n`);
642
+ process.exit(2);
643
+ }
644
+ }
601
645
  }
602
646
  const report = runDoctor(ctx);
603
647
 
package/src/init.ts CHANGED
@@ -6,7 +6,7 @@ import { at } from './narrow.ts';
6
6
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
7
7
  export type Verb = (typeof STANDARD_VERBS)[number];
8
8
 
9
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.6.1';
9
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.7.0';
10
10
 
11
11
  const VERB_ALIASES: Record<Verb, string[]> = {
12
12
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
package/src/memory.ts ADDED
@@ -0,0 +1,367 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { remoteParts } from './author.ts';
5
+ import { claudeConfigDir, projectDirName } from './conversations.ts';
6
+ import { dict, str } from './narrow.ts';
7
+
8
+ /**
9
+ * Where Claude Code keeps a project's memory (docs/tasks/claude-memory-in-project).
10
+ * Unset, it is `<config>/projects/<path, non-letters → "-">/memory/` — tied to the
11
+ * folder's path, so a moved folder loses it, and its conversations with it. The
12
+ * owner keeps it inside the project, `.claude/memory/`, named by
13
+ * `autoMemoryDirectory`: an absolute path or `~/`, nothing relative, no variables
14
+ * (Claude Code 2.1.308, DL #2). A path written there outlives a move as the trace of
15
+ * the old place.
16
+ */
17
+ export type MemoryState =
18
+ /** In `.claude/memory/`, nothing left in the old place. */
19
+ | { kind: 'inside' }
20
+ /** In `.claude/memory/`, and notes still lie in the old place — a move made by hand. */
21
+ | { kind: 'leftovers'; old: string; notes: number; differ: number; onlyOld: number }
22
+ /** Not set: memory goes to the old place. */
23
+ | { kind: 'outside'; old: string; notes: number }
24
+ /**
25
+ * Set to `<X>/.claude/memory/` of a folder that is gone: the project moved from X. `stray`: X
26
+ * holds nothing but that memory — a session that read the old path wrote there after the move.
27
+ */
28
+ | { kind: 'moved'; setting: MemorySetting; from: string; conversations: string | null; stray: string | null }
29
+ /** Set to `<X>/.claude/memory/` of another folder that exists. */
30
+ | { kind: 'elsewhere'; setting: MemorySetting; from: string }
31
+ /** Set to a path of the owner's own choosing. */
32
+ | { kind: 'custom'; setting: MemorySetting };
33
+
34
+ interface MemorySetting {
35
+ /** The settings file that names it. */
36
+ file: string;
37
+ /** The path it names, `~/` expanded, without the trailing slash. */
38
+ dir: string;
39
+ }
40
+
41
+ export interface MemoryCtx {
42
+ env: NodeJS.ProcessEnv;
43
+ home: string;
44
+ }
45
+
46
+ const MEMORY = path.join('.claude', 'memory');
47
+
48
+ function realRoot(root: string): string {
49
+ try {
50
+ return fs.realpathSync(root);
51
+ } catch {
52
+ return path.resolve(root);
53
+ }
54
+ }
55
+
56
+ /** Where the setting is read, in Claude Code's order: local settings, the project's, the user's. */
57
+ function settingFiles(root: string, ctx: MemoryCtx): string[] {
58
+ return [
59
+ path.join(root, '.claude', 'settings.local.json'),
60
+ path.join(root, '.claude', 'settings.json'),
61
+ path.join(claudeConfigDir(ctx.env, ctx.home), 'settings.json'),
62
+ ];
63
+ }
64
+
65
+ function readJson(file: string): Record<string, unknown> | null {
66
+ try {
67
+ return dict(JSON.parse(fs.readFileSync(file, 'utf8'))) ?? null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function readSetting(root: string, ctx: MemoryCtx): MemorySetting | null {
74
+ for (const file of settingFiles(root, ctx)) {
75
+ const value = str(readJson(file)?.['autoMemoryDirectory']);
76
+ if (!value) continue;
77
+ const expanded = value.startsWith('~/') ? path.join(ctx.home, value.slice(2)) : value;
78
+ // Claude Code drops a relative path without a word (DL #2): it is no setting.
79
+ if (!path.isAbsolute(expanded)) continue;
80
+ return { file, dir: path.resolve(expanded) };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** The old place: what Claude Code uses when nothing is set. */
86
+ export function defaultMemoryDir(root: string, ctx: MemoryCtx): string {
87
+ return path.join(claudeConfigDir(ctx.env, ctx.home), 'projects', projectDirName(realRoot(root)), 'memory');
88
+ }
89
+
90
+ function entries(dir: string): string[] {
91
+ try {
92
+ return fs.readdirSync(dir).filter((n) => n !== '.DS_Store');
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ function sameFile(a: string, b: string): boolean {
99
+ try {
100
+ return fs.readFileSync(a).equals(fs.readFileSync(b));
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ export function memoryState(projectRoot: string, ctx: MemoryCtx): MemoryState {
107
+ const root = realRoot(projectRoot);
108
+ const target = path.join(root, MEMORY);
109
+ const old = defaultMemoryDir(root, ctx);
110
+ const setting = readSetting(root, ctx);
111
+ const notes = entries(old);
112
+ if (!setting) return { kind: 'outside', old, notes: notes.length };
113
+ if (setting.dir === target) {
114
+ if (notes.length === 0) return { kind: 'inside' };
115
+ const differ = notes.filter(
116
+ (n) => fs.existsSync(path.join(target, n)) && !sameFile(path.join(old, n), path.join(target, n)),
117
+ );
118
+ const onlyOld = notes.filter((n) => !fs.existsSync(path.join(target, n)));
119
+ return { kind: 'leftovers', old, notes: notes.length, differ: differ.length, onlyOld: onlyOld.length };
120
+ }
121
+ if (path.basename(setting.dir) === 'memory' && path.basename(path.dirname(setting.dir)) === '.claude') {
122
+ const from = path.dirname(path.dirname(setting.dir));
123
+ const stub = entries(from).join() === '.claude' && entries(path.join(from, '.claude')).join() === 'memory';
124
+ if (fs.existsSync(from) && !stub) return { kind: 'elsewhere', setting, from };
125
+ const conversations = path.join(claudeConfigDir(ctx.env, ctx.home), 'projects', projectDirName(from));
126
+ return {
127
+ kind: 'moved',
128
+ setting,
129
+ from,
130
+ conversations: fs.existsSync(conversations) ? conversations : null,
131
+ stray: stub ? setting.dir : null,
132
+ };
133
+ }
134
+ return { kind: 'custom', setting };
135
+ }
136
+
137
+ function git(root: string, args: string[]): string | null {
138
+ try {
139
+ return execFileSync('git', ['--no-optional-locks', '-C', root, ...args], {
140
+ encoding: 'utf8',
141
+ stdio: ['ignore', 'pipe', 'ignore'],
142
+ }).trim();
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * A personal project's memory goes under git with it; anyone else's would reach
150
+ * whoever clones it (DL #3, #4). Personal: no origin, or origin on a host the
151
+ * profile names in `git.personal_hosts`.
152
+ */
153
+ export function isPersonalProject(projectRoot: string, personalHosts: string[]): boolean {
154
+ const url = git(projectRoot, ['remote', 'get-url', 'origin']);
155
+ if (!url) return true;
156
+ const host = remoteParts(url).host.toLowerCase();
157
+ return personalHosts.some((h) => h.toLowerCase() === host);
158
+ }
159
+
160
+ /** Needs `vdx doctor --fix`: anything but inside, or a path of the owner's own. */
161
+ export function memoryNeedsFix(state: MemoryState): boolean {
162
+ return state.kind !== 'inside' && state.kind !== 'custom';
163
+ }
164
+
165
+ function tilde(p: string, home: string): string {
166
+ return p === home || p.startsWith(home + path.sep) ? '~' + p.slice(home.length) : p;
167
+ }
168
+
169
+ /** One line on where the memory is, for doctor and `vdx ai`. */
170
+ export function describeMemory(state: MemoryState, ctx: MemoryCtx): string {
171
+ switch (state.kind) {
172
+ case 'inside':
173
+ return 'in .claude/memory/';
174
+ case 'leftovers': {
175
+ const parts = [state.differ ? `${state.differ} differ` : '', state.onlyOld ? `${state.onlyOld} only there` : '']
176
+ .filter(Boolean)
177
+ .join(', ');
178
+ return `in .claude/memory/, and ${state.notes} note(s) still in the old place, ${tilde(state.old, ctx.home)}${parts ? ` (${parts})` : ''} — the project's agent takes what it needs, vdx copies nothing back`;
179
+ }
180
+ case 'outside':
181
+ return state.notes
182
+ ? `outside the project: ${state.notes} note(s) in ${tilde(state.old, ctx.home)} — a moved folder loses them, and its conversations`
183
+ : `not set: notes would go to ${tilde(state.old, ctx.home)}, by the folder's path — a moved folder loses them, and its conversations`;
184
+ case 'moved':
185
+ return `autoMemoryDirectory points to ${tilde(state.setting.dir, ctx.home)}/, ${state.stray ? 'in a folder that holds nothing else' : 'a folder that is gone'} — the project moved from ${tilde(state.from, ctx.home)}${state.conversations ? ', and its conversations stay under the old path' : ''}`;
186
+ case 'elsewhere':
187
+ return `autoMemoryDirectory points to the memory of another folder, ${tilde(state.from, ctx.home)} (set in ${tilde(state.setting.file, ctx.home)})`;
188
+ case 'custom':
189
+ return `in ${tilde(state.setting.dir, ctx.home)}/ (autoMemoryDirectory, set in ${tilde(state.setting.file, ctx.home)})`;
190
+ }
191
+ }
192
+
193
+ export interface MemoryFixOptions {
194
+ /** Personal project (`isPersonalProject`): the path goes into `.claude/settings.json`, the memory under git. */
195
+ personal: boolean;
196
+ /** Not personal: keep `.claude/memory/` under git anyway — the person said yes. */
197
+ underGit: boolean;
198
+ /** YYYY-MM-DD, for the name the old place is set aside under. */
199
+ today: string;
200
+ }
201
+
202
+ /** A free name beside `dir`: `<dir>.moved-<date>`, then `-2`, `-3`… */
203
+ function asideName(dir: string, today: string): string {
204
+ const base = `${dir}.moved-${today}`;
205
+ let name = base;
206
+ for (let i = 2; fs.existsSync(name); i++) name = `${base}-${i}`;
207
+ return name;
208
+ }
209
+
210
+ /** Copies what `to` lacks; a name `to` already has stays as it is there. Returns the names kept. */
211
+ function copyMissing(from: string, to: string): { copied: number; kept: string[] } {
212
+ fs.mkdirSync(to, { recursive: true });
213
+ let copied = 0;
214
+ const kept: string[] = [];
215
+ for (const name of entries(from)) {
216
+ const src = path.join(from, name);
217
+ const dst = path.join(to, name);
218
+ if (fs.existsSync(dst)) {
219
+ if (!sameFile(src, dst)) kept.push(name);
220
+ continue;
221
+ }
222
+ fs.cpSync(src, dst, { recursive: true, errorOnExist: true, force: false });
223
+ copied++;
224
+ }
225
+ return { copied, kept };
226
+ }
227
+
228
+ /** Sets `autoMemoryDirectory` in a settings file, keeping its other keys. */
229
+ function writeSetting(file: string, dir: string): void {
230
+ let doc: Record<string, unknown> = {};
231
+ if (fs.existsSync(file)) {
232
+ const parsed = readJson(file);
233
+ if (!parsed)
234
+ throw new Error(`${file} is not a JSON object — nothing written; set autoMemoryDirectory there by hand`);
235
+ doc = parsed;
236
+ }
237
+ doc['autoMemoryDirectory'] = dir + '/';
238
+ fs.mkdirSync(path.dirname(file), { recursive: true });
239
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + '\n');
240
+ }
241
+
242
+ /** `/.claude/memory/` in the clone's own exclude file — the repository itself does not change. */
243
+ function excludeMemory(root: string): string | null {
244
+ const file = git(root, ['rev-parse', '--git-path', 'info/exclude']);
245
+ if (!file) return null;
246
+ const abs = path.resolve(root, file);
247
+ const line = '/.claude/memory/';
248
+ const text = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
249
+ if (!text.split('\n').includes(line)) {
250
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
251
+ fs.writeFileSync(abs, text + (text && !text.endsWith('\n') ? '\n' : '') + line + '\n');
252
+ }
253
+ return abs;
254
+ }
255
+
256
+ /**
257
+ * Moves the project's conversations to the folder's new path: Claude Code finds them
258
+ * by the path, and a moved folder starts with none. What the new place already has
259
+ * stays; the rest of the old place is set aside, never deleted.
260
+ */
261
+ function moveConversations(from: string, to: string, today: string): string {
262
+ if (!fs.existsSync(to)) {
263
+ fs.renameSync(from, to);
264
+ return `conversations ${path.basename(from)} → ${path.basename(to)}`;
265
+ }
266
+ let moved = 0;
267
+ for (const name of entries(from)) {
268
+ const dst = path.join(to, name);
269
+ if (fs.existsSync(dst)) continue;
270
+ fs.renameSync(path.join(from, name), dst);
271
+ moved++;
272
+ }
273
+ const left = entries(from).length;
274
+ const aside = left ? asideName(from, today) : null;
275
+ if (aside) fs.renameSync(from, aside);
276
+ else fs.rmdirSync(from);
277
+ return `${moved} conversation file(s) ${path.basename(from)} → ${path.basename(to)}${aside ? `; ${left} already there, the rest set aside as ${path.basename(aside)}` : ''}`;
278
+ }
279
+
280
+ /**
281
+ * Puts the project's memory inside it — `vdx doctor --fix` and the question in
282
+ * `vdx ai`. Nothing is overwritten and nothing deleted: a name `.claude/memory/`
283
+ * already has stays, the old place is renamed. Returns what was done, one line each.
284
+ */
285
+ export function fixMemory(projectRoot: string, state: MemoryState, ctx: MemoryCtx, opts: MemoryFixOptions): string[] {
286
+ const root = realRoot(projectRoot);
287
+ const target = path.join(root, MEMORY);
288
+ const done: string[] = [];
289
+ const setAside = (old: string) => {
290
+ if (!fs.existsSync(old)) return;
291
+ const aside = asideName(old, opts.today);
292
+ fs.renameSync(old, aside);
293
+ done.push(`the old place set aside: ${tilde(aside, ctx.home)}`);
294
+ };
295
+ switch (state.kind) {
296
+ case 'inside':
297
+ case 'custom':
298
+ return done;
299
+ case 'leftovers':
300
+ setAside(state.old);
301
+ return done;
302
+ case 'outside': {
303
+ const { copied, kept } = fs.existsSync(state.old) ? copyMissing(state.old, target) : { copied: 0, kept: [] };
304
+ if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true });
305
+ done.push(`${copied} note(s) → .claude/memory/`);
306
+ if (kept.length) done.push(`kept .claude/memory/ as it is for ${kept.join(', ')} — the old copy differs`);
307
+ const file = path.join(root, '.claude', opts.personal ? 'settings.json' : 'settings.local.json');
308
+ writeSetting(file, target);
309
+ done.push(`autoMemoryDirectory → .claude/${path.basename(file)}`);
310
+ if (!opts.personal && !opts.underGit && excludeMemory(root)) done.push('.claude/memory/ → .git/info/exclude');
311
+ setAside(state.old);
312
+ if (opts.personal && git(root, ['rev-parse', '--git-dir']) !== null) {
313
+ done.push('commit .claude/memory/ and .claude/settings.json with the project');
314
+ }
315
+ return done;
316
+ }
317
+ case 'moved':
318
+ case 'elsewhere': {
319
+ writeSetting(state.setting.file, target);
320
+ done.push(`autoMemoryDirectory → ${target}/ (${path.relative(root, state.setting.file)})`);
321
+ if (state.kind === 'moved' && state.stray) {
322
+ const { copied, kept } = copyMissing(state.stray, target);
323
+ done.push(
324
+ `${copied} note(s) written at the old path after the move → .claude/memory/${kept.length ? `; kept .claude/memory/ as it is for ${kept.join(', ')}` : ''} — ${tilde(state.from, ctx.home)} holds only them, remove it`,
325
+ );
326
+ }
327
+ if (state.kind === 'moved' && state.conversations) {
328
+ const here = path.join(claudeConfigDir(ctx.env, ctx.home), 'projects', projectDirName(root));
329
+ done.push(moveConversations(state.conversations, here, opts.today));
330
+ }
331
+ return done;
332
+ }
333
+ }
334
+ }
335
+
336
+ export interface ProjectMemoryDeps {
337
+ /** One line from the person at the terminal; null without one. */
338
+ ask: (prompt: string) => string | null;
339
+ log: (line: string) => void;
340
+ today: string;
341
+ /** The profile's `git.personal_hosts`. */
342
+ personalHosts: string[];
343
+ }
344
+
345
+ /**
346
+ * `fixMemory` with the question it needs: outside a personal project the memory stays
347
+ * out of git unless the person says yes — a publication cannot be taken back (DL #3,
348
+ * #4); without a terminal, out.
349
+ */
350
+ export function fixProjectMemory(
351
+ projectRoot: string,
352
+ state: MemoryState,
353
+ ctx: MemoryCtx,
354
+ deps: ProjectMemoryDeps,
355
+ ): string[] {
356
+ const personal = isPersonalProject(projectRoot, deps.personalHosts);
357
+ let underGit = false;
358
+ if (state.kind === 'outside' && !personal && git(projectRoot, ['rev-parse', '--git-dir']) !== null) {
359
+ deps.log(` ${path.basename(projectRoot)} is not a personal repo: memory under git reaches whoever clones it`);
360
+ underGit = /^y(es)?$/i.test(deps.ask(' Keep .claude/memory/ under git? [y/N] ')?.trim() ?? '');
361
+ }
362
+ const done = fixMemory(projectRoot, state, ctx, { personal, underGit, today: deps.today });
363
+ if (done.length && (state.kind === 'outside' || state.kind === 'moved')) {
364
+ done.push('sessions of this project running now keep the old place until they restart');
365
+ }
366
+ return done;
367
+ }