@vodmal/vdx-cli 0.37.1 → 0.39.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,11 +50,14 @@ 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 --staged # pre-commit guard: Claude Code memory goes into a commit only where nobody but
54
+ # you reads every place it reaches (origin, mirrors; GitHub asked with gh's token)
53
55
  vdx doctor --check [project-path] # one line per hook declared but not running here, and one when the memory is
54
56
  # not inside — the plugin's SessionStart hook tells the agent
55
57
  vdx doctor --fix [project-path] # write the profile's personal hooks (git.hooks) into ~/.gitconfig, and put
56
58
  # the project's Claude Code memory inside it: .claude/memory/ (after a
57
- # folder move, its path and conversations follow)
59
+ # folder move, its path and conversations follow; notes a session that
60
+ # started before the move wrote at the old place come in)
58
61
 
59
62
  # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
60
63
  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
@@ -114,7 +117,8 @@ The session's `{project}` is `[_.vdx] name` (or the older `[vdx]`) from the proj
114
117
  first file in the profile's `session.project_names` that has one, else the repo
115
118
  name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machine's vdx,
116
119
  profile and tmux, the path taken under its home directory; `--detach` only
117
- starts it. The machine's own label (`$VDX_HOST`) runs here. Where git knows no
120
+ starts it, and not in a folder Claude Code does not trust yet: nobody would answer
121
+ its trust window (exit 4; start once without `--detach`). The machine's own label (`$VDX_HOST`) runs here. Where git knows no
118
122
  commit author, `vdx ai` proposes one (Enter writes it into `.git/config`).
119
123
  For Claude Code `vdx ai` picks the conversation: each one is marked with the
120
124
  machine it was last started on (`vdx ai --check`, run by the plugin's hook, writes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.37.1",
3
+ "version": "0.39.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.7.0"
14
+ version: "1.8.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
@@ -10,6 +10,7 @@ import { loadManifestAt } from './manifest.ts';
10
10
  import { type Author, effectiveAuthor, isGitRepo, rankAuthors, scanPool, writeAuthor } from './author.ts';
11
11
  import type { Predicate } from './rubric.ts';
12
12
  import {
13
+ claudeTrusts,
13
14
  type Conversation,
14
15
  describeConversation,
15
16
  describeAge,
@@ -29,7 +30,15 @@ import {
29
30
  parseMachineReport,
30
31
  runsInProject,
31
32
  } from './conversations.ts';
32
- import { describeMemory, fixProjectMemory, memoryNeedsFix, memoryState } from './memory.ts';
33
+ import {
34
+ describeMemory,
35
+ fixProjectMemory,
36
+ memoryApplies,
37
+ memoryNeedsFix,
38
+ memoryState,
39
+ personalHosts,
40
+ type PersonalHost,
41
+ } from './memory.ts';
33
42
  import { at, errorMessage, isStringList, str } from './narrow.ts';
34
43
  import { type Lag, sessionLag } from './session-versions.ts';
35
44
 
@@ -116,8 +125,11 @@ interface GitProfile {
116
125
  /** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
117
126
  author_pool?: string[];
118
127
  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[];
128
+ /**
129
+ * Hosts whose repos are the owner's alone, and where their repos are mirrored: Claude Code's
130
+ * memory goes under git only where nobody else reads (claude-memory-in-project).
131
+ */
132
+ personal_hosts?: Array<string | { host: string; mirrors?: string[] }>;
121
133
  }
122
134
 
123
135
  export interface Environment {
@@ -255,8 +267,21 @@ export function parseEnvironment(text: string, source: string): Environment {
255
267
  if (gitDoc.author_pool !== undefined && !isStringList(gitDoc.author_pool)) {
256
268
  fail('git.author_pool', 'must be a list of directories');
257
269
  }
258
- if (gitDoc.personal_hosts !== undefined && !isStringList(gitDoc.personal_hosts)) {
259
- fail('git.personal_hosts', 'must be a list of host names');
270
+ if (gitDoc.personal_hosts !== undefined) {
271
+ if (!Array.isArray(gitDoc.personal_hosts)) fail('git.personal_hosts', 'must be a list');
272
+ gitDoc.personal_hosts.forEach((h, i) => {
273
+ const at = `git.personal_hosts[${i}]`;
274
+ if (typeof h === 'string') {
275
+ if (h.trim() === '') fail(at, 'must be a host name');
276
+ return;
277
+ }
278
+ if (typeof h !== 'object' || h === null || typeof h.host !== 'string' || h.host.trim() === '') {
279
+ fail(at, 'must be a host name, or a mapping with host');
280
+ }
281
+ if (h.mirrors !== undefined && (!isStringList(h.mirrors) || !h.mirrors.every((m) => m.includes('{repo}')))) {
282
+ fail(`${at}.mirrors`, 'must be a list of places like github.com/<owner>/{repo}');
283
+ }
284
+ });
260
285
  }
261
286
  if (gitDoc.hooks !== undefined) {
262
287
  if (!Array.isArray(gitDoc.hooks)) fail('git.hooks', 'must be a list');
@@ -1157,13 +1182,13 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
1157
1182
  export function ensureMemory(
1158
1183
  projectRoot: string,
1159
1184
  command: string,
1160
- personalHosts: string[],
1185
+ hosts: PersonalHost[],
1161
1186
  deps: AiDeps,
1162
1187
  dryRun: boolean,
1163
1188
  agentRunning: boolean,
1164
1189
  ): void {
1165
- if (!isClaude(command)) return;
1166
1190
  const ctx = { env: deps.env, home: deps.home };
1191
+ if (!isClaude(command) || !memoryApplies(projectRoot, ctx)) return;
1167
1192
  const state = memoryState(projectRoot, ctx);
1168
1193
  if (dryRun) {
1169
1194
  deps.out(` memory: ${describeMemory(state, ctx)}\n`);
@@ -1194,7 +1219,7 @@ export function ensureMemory(
1194
1219
  ask: deps.ask,
1195
1220
  log: deps.log,
1196
1221
  today: new Date().toLocaleDateString('sv-SE'),
1197
- personalHosts,
1222
+ personalHosts: hosts,
1198
1223
  });
1199
1224
  for (const d of done) deps.log(`✓ ${d}`);
1200
1225
  } catch (e) {
@@ -1743,9 +1768,10 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1743
1768
  `Hand the user the fix — ${reload}` +
1744
1769
  (deps.env['TMUX']
1745
1770
  ? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again${again}` +
1746
- // A focused session and the mail session restart into a new conversation.
1771
+ // A focused session and the mail session restart into a new conversation, and what lives
1772
+ // only in this one ends with it (ai-dev-plugins, restart-hint-wrap-first).
1747
1773
  (focused || intercom
1748
- ? ', in a new conversation'
1774
+ ? ", in a new conversation — before handing it over, run the project's wrap check (`/vdm:wrap` where vdm is installed): what lives only in this chat ends with it"
1749
1775
  : plan.resumeArgs.length
1750
1776
  ? `, resuming the conversation (${plan.resumeArgs.join(' ')})`
1751
1777
  : '') +
@@ -1937,7 +1963,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1937
1963
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1938
1964
  for (const l of folders) deps.out(` ${l}\n`);
1939
1965
  ensureAuthor(projectRoot, poolDirs, deps, true);
1940
- ensureMemory(projectRoot, plan.command, environment.git?.personal_hosts ?? [], deps, true, agentsHere.length > 0);
1966
+ ensureMemory(
1967
+ projectRoot,
1968
+ plan.command,
1969
+ personalHosts(environment.git?.personal_hosts),
1970
+ deps,
1971
+ true,
1972
+ agentsHere.length > 0,
1973
+ );
1941
1974
  return EXIT_OK;
1942
1975
  }
1943
1976
  for (const l of choice?.lines ?? []) log(l);
@@ -1973,7 +2006,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1973
2006
  plan = { ...plan, resumeArgs: resumeId(id) };
1974
2007
  }
1975
2008
  ensureAuthor(projectRoot, poolDirs, deps, false);
1976
- ensureMemory(projectRoot, plan.command, environment.git?.personal_hosts ?? [], deps, false, agentsHere.length > 0);
2009
+ ensureMemory(
2010
+ projectRoot,
2011
+ plan.command,
2012
+ personalHosts(environment.git?.personal_hosts),
2013
+ deps,
2014
+ false,
2015
+ agentsHere.length > 0,
2016
+ );
1977
2017
 
1978
2018
  if (multiplexer === 'none') {
1979
2019
  if (opts.restart) {
@@ -2064,6 +2104,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
2064
2104
  if (live) plan = { ...plan, resumeArgs: resumeId(live) };
2065
2105
  }
2066
2106
 
2107
+ // Nobody sits at a detached start, and Claude Code holds one in a folder it does not trust on
2108
+ // its trust window, unseen (intercom-session-mode Sidetrack #4). The answer is the person's.
2109
+ if (opts.detach && isClaude(plan.command) && claudeTrusts(projectRoot, deps.env, deps.home) === false) {
2110
+ log(`✗ Claude Code has not trusted ${projectRoot} yet: a detached agent would wait on its trust window, unseen`);
2111
+ log(` start it once at a terminal and answer the window: ${vdxAiHere} ${shellQuote(projectRoot)}`);
2112
+ return EXIT_LAUNCH_FAILED;
2113
+ }
2114
+
2067
2115
  if (current) {
2068
2116
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
2069
2117
  log(`↻ ${current.pane.session} ${current.pane.paneId}: restarting — ${line}`);
@@ -69,6 +69,41 @@ export function claudeConfigDir(env: NodeJS.ProcessEnv, home: string): string {
69
69
  return dir ? path.resolve(dir.replace(/^~(?=$|\/)/, home)) : path.join(home, '.claude');
70
70
  }
71
71
 
72
+ /**
73
+ * Whether Claude Code trusts a folder (2.1.308, claude-memory-in-project DL #13): its global
74
+ * config, `.claude.json` in `CLAUDE_CONFIG_DIR` or the home directory, marks
75
+ * `projects[<folder>].hasTrustDialogAccepted` for the folder or one above it — inside a git
76
+ * checkout only up to the checkout's top. A start in a folder it does not trust stops on the
77
+ * trust window until somebody answers. Null: the config cannot be read, nobody knows.
78
+ */
79
+ export function claudeTrusts(folder: string, env: NodeJS.ProcessEnv, home: string): boolean | null {
80
+ const dir = env['CLAUDE_CONFIG_DIR']?.trim();
81
+ let projects: Record<string, unknown> | undefined;
82
+ try {
83
+ const file = path.join(dir ? path.resolve(dir.replace(/^~(?=$|\/)/, home)) : home, '.claude.json');
84
+ projects = dict(at(JSON.parse(fs.readFileSync(file, 'utf8')), 'projects'));
85
+ } catch {
86
+ return null;
87
+ }
88
+ let d = path.resolve(folder);
89
+ try {
90
+ d = fs.realpathSync(d);
91
+ } catch {
92
+ // A folder that is not there is trusted by nothing but its own record.
93
+ }
94
+ let top: string | null = null;
95
+ for (let p = d; top === null; p = path.dirname(p)) {
96
+ if (fs.existsSync(path.join(p, '.git'))) top = p;
97
+ if (path.dirname(p) === p) break;
98
+ }
99
+ for (;;) {
100
+ if (at(projects?.[d.normalize('NFC')], 'hasTrustDialogAccepted') === true) return true;
101
+ const up = path.dirname(d);
102
+ if (d === top || up === d) return false;
103
+ d = up;
104
+ }
105
+ }
106
+
72
107
  export function projectDirName(projectRoot: string): string {
73
108
  return projectRoot.replace(/[^A-Za-z0-9]/g, '-');
74
109
  }
package/src/doctor.ts CHANGED
@@ -17,7 +17,10 @@ import { githubToken, machineIo, type HostingIo } from './hosting.ts';
17
17
  import {
18
18
  describeMemory,
19
19
  fixProjectMemory,
20
+ memoryApplies,
20
21
  memoryNeedsFix,
22
+ memoryReaders,
23
+ personalHosts,
21
24
  memoryState,
22
25
  type MemoryCtx,
23
26
  type MemoryState,
@@ -522,6 +525,7 @@ export function checkClaudeMemory(
522
525
  if (ctx.projectRoot === null || !claudeIsTheAgent(environment)) return null;
523
526
  // Claude Code's slot is the folder the agent starts in — the checkout's top for vdx ai.
524
527
  const root = resolveProjectRoot(ctx.projectRoot) ?? ctx.projectRoot;
528
+ if (!memoryApplies(root, mctx)) return null;
525
529
  const state = memoryState(root, mctx);
526
530
  return {
527
531
  id: 'claude-memory',
@@ -553,7 +557,19 @@ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
553
557
  (ctx) => checkClaudeMemory(ctx),
554
558
  ];
555
559
 
556
- export function resolveDoctorCtx(cwd: string = process.cwd()): DoctorCtx {
560
+ /**
561
+ * The project doctor looks at: a directory named on the command line is one, whatever it
562
+ * holds — a folder of notes or of other repos has Claude Code memory too (avatar,
563
+ * l10inc, 2026-10-23); the working directory only with a project's marker file.
564
+ */
565
+ export function resolveDoctorCtx(cwd: string = process.cwd(), named = false): DoctorCtx {
566
+ if (named) {
567
+ try {
568
+ if (fs.statSync(cwd).isDirectory()) return { projectRoot: cwd };
569
+ } catch {
570
+ /* no such directory: no project */
571
+ }
572
+ }
557
573
  return { projectRoot: looksLikeProject(cwd) ? cwd : null };
558
574
  }
559
575
 
@@ -609,10 +625,33 @@ export function fixDoctorMemory(
609
625
  ): string[] {
610
626
  return fixProjectMemory(projectRoot, state, memoryCtx(), {
611
627
  ...io,
612
- personalHosts: profile().git?.personal_hosts ?? [],
628
+ personalHosts: personalHosts(profile().git?.personal_hosts),
613
629
  });
614
630
  }
615
631
 
632
+ /**
633
+ * `vdx doctor --staged`, the profile's pre-commit hook: Claude Code's memory goes into a
634
+ * commit only where nobody but the owner reads every place the commit reaches
635
+ * (claude-memory-in-project DL #11, #12). Nothing staged under `.claude/memory/` — no word.
636
+ * Returns the refusal, or null to let the commit go.
637
+ */
638
+ export function runDoctorStaged(
639
+ projectRoot: string,
640
+ io: HostingIo = machineIo(projectRoot),
641
+ environment: Environment = profile(),
642
+ ): string | null {
643
+ const staged = io.git(['diff', '--cached', '--name-only', '--', '.claude/memory']);
644
+ if (!staged) return null;
645
+ const readers = memoryReaders(io, personalHosts(environment.git?.personal_hosts));
646
+ if (readers.only) return null;
647
+ const n = staged.split('\n').length;
648
+ return (
649
+ `vdx doctor: Claude memory stays out of this commit (${n} file(s)) — ${readers.why}.\n` +
650
+ ` commit the rest: git restore --staged .claude/memory, and commit again\n` +
651
+ ` keep it out of git here: echo /.claude/memory/ >> "$(git rev-parse --git-path info/exclude)"`
652
+ );
653
+ }
654
+
616
655
  const PROJECT_MARKER_FILES = [
617
656
  'package.json',
618
657
  'composer.json',
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  checkClaudeMemory,
21
21
  fixDoctorMemory,
22
+ runDoctorStaged,
22
23
  runDoctor,
23
24
  runDoctorCheck,
24
25
  runDoctorFix,
@@ -128,7 +129,10 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
128
129
  Without it, an agent running without the profile's flags is
129
130
  a question in a terminal: Enter attaches to it as is, r
130
131
  restarts it, q leaves it
131
- --detach start without attaching; print the command that attaches
132
+ --detach start without attaching; print the command that attaches. In a
133
+ folder Claude Code does not trust yet, nothing starts (exit 4):
134
+ its trust window would wait with nobody at it — start once
135
+ without --detach and answer it
132
136
  --dry-run print the plan and the project's running agents; start nothing
133
137
  --check for the agent's own session: how the agent here is launched,
134
138
  whether this session carries the profile's flags, and whether it
@@ -234,9 +238,10 @@ or GitHub stopped answering; a plain git push exits with git's own code.
234
238
  positionals: 1,
235
239
  },
236
240
  doctor: {
237
- usage: 'vdx doctor [project_path] [--check | --fix] [--format=ansi|markdown|json] [--json]',
241
+ usage: 'vdx doctor [project_path] [--check | --fix | --staged] [--format=ansi|markdown|json] [--json]',
238
242
  help: `Checks this machine for what vdx needs (Node, git, mise, npm auth, docker, the
239
- Claude Code plugin) and, inside a project (default: cwd), its commit author and
243
+ Claude Code plugin) and, inside a project (default: cwd, when it looks like one; a
244
+ folder named here is one, git or not — the home directory never), its commit author and
240
245
  its git hooks: are the hooks the repository declares on in this clone, and do
241
246
  the personal hooks of your profile (git.hooks) run on this machine; with origin on
242
247
  GitHub, the token vdx asks GitHub with ($GH_TOKEN, $GITHUB_TOKEN or gh) and
@@ -252,13 +257,22 @@ takes it along. Reads only, unless --fix. Exit 2 when something is missing.
252
257
  install step, which the report names); and put the memory inside:
253
258
  notes from ~/.claude/projects/<path>/memory into .claude/memory/, none
254
259
  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,
260
+ into .claude/settings.json in a personal project (only you read where
261
+ its commits go — see --staged), else into .claude/settings.local.json,
257
262
  with the memory kept out of git (.git/info/exclude) unless you say
258
263
  yes. After a folder is moved: the path follows it, and the project's
259
- conversations move to the new path
264
+ conversations move to the new path; a folder Claude Code has not
265
+ trusted yet is named. Notes a session that started before the move
266
+ wrote at the old place come in, with their MEMORY.md lines; a path
267
+ another clone left in the shared settings.json goes, for this clone,
268
+ into its .claude/settings.local.json
269
+ --staged the profile's pre-commit hook: refuses a commit that stages
270
+ .claude/memory/ unless only you read every place the commit reaches —
271
+ origin on a host of git.personal_hosts or a private GitHub repo with
272
+ nobody else, and each mirror the profile names (GitHub asked with gh's
273
+ token); no answer, no token — refused. Silent otherwise
260
274
  `,
261
- bools: ['json', 'check', 'fix'],
275
+ bools: ['json', 'check', 'fix', 'staged'],
262
276
  values: ['format'],
263
277
  positionals: 1,
264
278
  },
@@ -606,11 +620,20 @@ function cmdRun(verb: LifecycleVerb): void {
606
620
  }
607
621
 
608
622
  function cmdDoctor(opts: ParsedArgs): void {
609
- const ctx = resolveDoctorCtx(path.resolve(opts.positionals[0] ?? '.'));
610
- if (opts.flags.check === true && opts.flags.fix === true) {
611
- process.stderr.write('vdx doctor: --check reports and --fix writes — pick one\n');
623
+ const ctx = resolveDoctorCtx(path.resolve(opts.positionals[0] ?? '.'), opts.positionals[0] !== undefined);
624
+ if ([opts.flags.check, opts.flags.fix, opts.flags.staged].filter((f) => f === true).length > 1) {
625
+ process.stderr.write('vdx doctor: --check reports, --fix writes, --staged guards a commit — pick one\n');
612
626
  process.exit(2);
613
627
  }
628
+ if (opts.flags.staged === true) {
629
+ const root = resolveProjectRoot(ctx.projectRoot ?? '.') ?? process.cwd();
630
+ const refusal = runDoctorStaged(root);
631
+ if (refusal) {
632
+ process.stderr.write(refusal + '\n');
633
+ process.exit(1);
634
+ }
635
+ return;
636
+ }
614
637
  if (opts.flags.check === true) {
615
638
  const text = runDoctorCheck(ctx);
616
639
  if (text) process.stdout.write(text + '\n');
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.7.0';
9
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v1.8.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 CHANGED
@@ -2,8 +2,9 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { remoteParts } from './author.ts';
5
- import { claudeConfigDir, projectDirName } from './conversations.ts';
6
- import { dict, str } from './narrow.ts';
5
+ import { claudeConfigDir, claudeTrusts, projectDirName } from './conversations.ts';
6
+ import { githubToken, machineIo, type HostingIo } from './hosting.ts';
7
+ import { at, dict, errorMessage, list, str } from './narrow.ts';
7
8
 
8
9
  /**
9
10
  * Where Claude Code keeps a project's memory (docs/tasks/claude-memory-in-project).
@@ -17,8 +18,11 @@ import { dict, str } from './narrow.ts';
17
18
  export type MemoryState =
18
19
  /** In `.claude/memory/`, nothing left in the old place. */
19
20
  | { 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 }
21
+ /**
22
+ * In `.claude/memory/`, and notes still lie in the old place — a move made by hand. `afterMove`:
23
+ * vdx set the old place aside before, so a session that started before the move wrote them.
24
+ */
25
+ | { kind: 'leftovers'; old: string; notes: number; differ: number; onlyOld: number; afterMove: boolean }
22
26
  /** Not set: memory goes to the old place. */
23
27
  | { kind: 'outside'; old: string; notes: number }
24
28
  /**
@@ -53,6 +57,19 @@ function realRoot(root: string): string {
53
57
  }
54
58
  }
55
59
 
60
+ /**
61
+ * A folder whose memory vdx may move: not the home directory or above it — its
62
+ * `.claude/settings.json` is the user's own, read by every project — and not inside
63
+ * Claude Code's configuration.
64
+ */
65
+ export function memoryApplies(projectRoot: string, ctx: MemoryCtx): boolean {
66
+ const root = realRoot(projectRoot);
67
+ const home = realRoot(ctx.home);
68
+ const config = realRoot(claudeConfigDir(ctx.env, ctx.home));
69
+ const within = (a: string, b: string) => a === b || a.startsWith(b + path.sep);
70
+ return !within(home, root) && !within(root, config);
71
+ }
72
+
56
73
  /** Where the setting is read, in Claude Code's order: local settings, the project's, the user's. */
57
74
  function settingFiles(root: string, ctx: MemoryCtx): string[] {
58
75
  return [
@@ -95,6 +112,11 @@ function entries(dir: string): string[] {
95
112
  }
96
113
  }
97
114
 
115
+ /** vdx set this place aside once already (`asideName`): what lies there now came after (Sidetrack #5). */
116
+ function setAsideBefore(dir: string): boolean {
117
+ return entries(path.dirname(dir)).some((n) => n.startsWith(`${path.basename(dir)}.moved-`));
118
+ }
119
+
98
120
  function sameFile(a: string, b: string): boolean {
99
121
  try {
100
122
  return fs.readFileSync(a).equals(fs.readFileSync(b));
@@ -116,7 +138,14 @@ export function memoryState(projectRoot: string, ctx: MemoryCtx): MemoryState {
116
138
  (n) => fs.existsSync(path.join(target, n)) && !sameFile(path.join(old, n), path.join(target, n)),
117
139
  );
118
140
  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 };
141
+ return {
142
+ kind: 'leftovers',
143
+ old,
144
+ notes: notes.length,
145
+ differ: differ.length,
146
+ onlyOld: onlyOld.length,
147
+ afterMove: setAsideBefore(old),
148
+ };
120
149
  }
121
150
  if (path.basename(setting.dir) === 'memory' && path.basename(path.dirname(setting.dir)) === '.claude') {
122
151
  const from = path.dirname(path.dirname(setting.dir));
@@ -145,16 +174,83 @@ function git(root: string, args: string[]): string | null {
145
174
  }
146
175
  }
147
176
 
177
+ /** A host of `git.personal_hosts`: its repos are the owner's alone; `mirrors` — where its repos may be copied. */
178
+ export interface PersonalHost {
179
+ host: string;
180
+ /** `github.com/<owner>/{repo}`: a repo of the host may be mirrored there (gitolite's post-update hook). */
181
+ mirrors: string[];
182
+ }
183
+
184
+ /** The profile's `git.personal_hosts`: a host name, or `{host, mirrors}`. */
185
+ export function personalHosts(
186
+ listed: Array<string | { host: string; mirrors?: string[] }> | undefined,
187
+ ): PersonalHost[] {
188
+ return (listed ?? []).map((h) =>
189
+ typeof h === 'string'
190
+ ? { host: h.toLowerCase(), mirrors: [] }
191
+ : { host: h.host.toLowerCase(), mirrors: h.mirrors ?? [] },
192
+ );
193
+ }
194
+
195
+ /** Who may read where the repo's commits go: only its owner, or — why not. */
196
+ export type Readers = { only: true } | { only: false; why: string };
197
+
148
198
  /**
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`.
199
+ * Whether only the owner reads every place a commit of this repo reaches (DL #12 of
200
+ * docs/tasks/claude-memory-in-project): origin on a host the profile names as the
201
+ * owner's (`git.personal_hosts`), or a private GitHub repo with no other collaborator;
202
+ * and every mirror the profile names for that host — absent, or private and nobody
203
+ * else's. No origin: nothing leaves the machine. GitHub is asked with the token gh
204
+ * keeps; no token, no answer, `VDX_OFFLINE` — not known, so not only.
152
205
  */
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);
206
+ export function memoryReaders(io: HostingIo, hosts: PersonalHost[]): Readers {
207
+ const url = io.git(['remote', 'get-url', 'origin']);
208
+ if (!url) return { only: true };
209
+ const { host, path: repo } = remoteParts(url);
210
+ const lower = host.toLowerCase();
211
+ const own = hosts.find((h) => h.host === lower);
212
+ const places: string[] = [];
213
+ if (lower === 'github.com') places.push(repo);
214
+ else if (!own)
215
+ return { only: false, why: `origin is on ${host} — not a host the profile names as yours (git.personal_hosts)` };
216
+ for (const m of own?.mirrors ?? []) {
217
+ const at = m.replace(/^https?:\/\//, '').replace('{repo}', repo);
218
+ if (!at.startsWith('github.com/'))
219
+ return { only: false, why: `the mirror ${m} is not on GitHub — vdx cannot tell who reads it` };
220
+ places.push(at.slice('github.com/'.length));
221
+ }
222
+ if (places.length === 0) return { only: true };
223
+ if (io.env.VDX_OFFLINE) return { only: false, why: 'VDX_OFFLINE is set — GitHub was not asked who reads' };
224
+ const token = githubToken(io)?.token ?? null;
225
+ if (!token) return { only: false, why: 'no GitHub token to ask who reads — gh auth login' };
226
+ try {
227
+ const me = str(at(io.get('/user', token).body, 'login'));
228
+ if (!me) return { only: false, why: 'GitHub named no user for the token' };
229
+ for (const p of places) {
230
+ const r = io.get(`/repos/${p}`, token);
231
+ if (r.status === 404 && p !== repo) continue; // that mirror does not exist
232
+ if (r.status !== 200) return { only: false, why: `GitHub answered ${r.status} for ${p}` };
233
+ if (at(r.body, 'private') !== true) return { only: false, why: `github.com/${p} is public` };
234
+ const c = io.get(`/repos/${p}/collaborators?per_page=100`, token);
235
+ if (c.status !== 200) return { only: false, why: `GitHub does not show who reads github.com/${p} (${c.status})` };
236
+ const others = list(c.body)
237
+ .map((x) => str(at(x, 'login')))
238
+ .filter((l) => l && l !== me);
239
+ if (others.length) return { only: false, why: `github.com/${p} is read by ${others.length} more besides ${me}` };
240
+ }
241
+ return { only: true };
242
+ } catch (e) {
243
+ return { only: false, why: `GitHub did not answer: ${errorMessage(e)}` };
244
+ }
245
+ }
246
+
247
+ /** A personal project: its memory may go under git (DL #3, #4, #12). */
248
+ export function isPersonalProject(
249
+ projectRoot: string,
250
+ hosts: PersonalHost[],
251
+ io: HostingIo = machineIo(projectRoot),
252
+ ): boolean {
253
+ return memoryReaders(io, hosts).only;
158
254
  }
159
255
 
160
256
  /** Needs `vdx doctor --fix`: anything but inside, or a path of the owner's own. */
@@ -172,6 +268,8 @@ export function describeMemory(state: MemoryState, ctx: MemoryCtx): string {
172
268
  case 'inside':
173
269
  return 'in .claude/memory/';
174
270
  case 'leftovers': {
271
+ if (state.afterMove)
272
+ return `in .claude/memory/, and ${state.notes} note(s) written in the old place after the move, ${tilde(state.old, ctx.home)} — by a session that started before it; the fix brings them in`;
175
273
  const parts = [state.differ ? `${state.differ} differ` : '', state.onlyOld ? `${state.onlyOld} only there` : '']
176
274
  .filter(Boolean)
177
275
  .join(', ');
@@ -207,10 +305,10 @@ function asideName(dir: string, today: string): string {
207
305
  return name;
208
306
  }
209
307
 
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[] } {
308
+ /** Copies what `to` lacks; a name `to` already has stays as it is there. Returns the names copied and kept. */
309
+ function copyMissing(from: string, to: string): { copied: string[]; kept: string[] } {
212
310
  fs.mkdirSync(to, { recursive: true });
213
- let copied = 0;
311
+ const copied: string[] = [];
214
312
  const kept: string[] = [];
215
313
  for (const name of entries(from)) {
216
314
  const src = path.join(from, name);
@@ -220,11 +318,42 @@ function copyMissing(from: string, to: string): { copied: number; kept: string[]
220
318
  continue;
221
319
  }
222
320
  fs.cpSync(src, dst, { recursive: true, errorOnExist: true, force: false });
223
- copied++;
321
+ copied.push(name);
224
322
  }
225
323
  return { copied, kept };
226
324
  }
227
325
 
326
+ const INDEX = 'MEMORY.md';
327
+
328
+ /**
329
+ * Notes a session wrote at the old place after the move, brought into `to`: the agent finds a
330
+ * note by its line in MEMORY.md, so the lines of the old index that point at the notes
331
+ * brought in join `to`'s — those alone. A line about a note `to` already has is the
332
+ * project's agent's to judge (DL #8). Returns what was done, one line each, and the names kept.
333
+ */
334
+ function bringIn(from: string, to: string, what: string, aside: string): { done: string[]; differ: string[] } {
335
+ const { copied, kept } = copyMissing(from, to);
336
+ const done = [`${copied.length} note(s) ${what} → .claude/memory/`];
337
+ const notes = copied.filter((n) => n !== INDEX);
338
+ const src = path.join(from, INDEX);
339
+ const dst = path.join(to, INDEX);
340
+ if (notes.length && !copied.includes(INDEX) && fs.existsSync(src)) {
341
+ const have = fs.existsSync(dst) ? fs.readFileSync(dst, 'utf8') : '';
342
+ const lines = fs
343
+ .readFileSync(src, 'utf8')
344
+ .split('\n')
345
+ .filter((l) => notes.some((n) => l.includes(`](${n})`) && !have.includes(`(${n})`)));
346
+ if (lines.length) {
347
+ fs.writeFileSync(dst, have + (have && !have.endsWith('\n') ? '\n' : '') + lines.join('\n') + '\n');
348
+ done.push(`${lines.length} line(s) of its MEMORY.md → .claude/memory/MEMORY.md, for the notes brought in`);
349
+ }
350
+ }
351
+ const differ = kept.filter((n) => n !== INDEX);
352
+ if (differ.length)
353
+ done.push(`kept .claude/memory/ as it is for ${differ.join(', ')} — the later copy differs, it stays in ${aside}`);
354
+ return { done, differ };
355
+ }
356
+
228
357
  /** Sets `autoMemoryDirectory` in a settings file, keeping its other keys. */
229
358
  function writeSetting(file: string, dir: string): void {
230
359
  let doc: Record<string, unknown> = {};
@@ -283,6 +412,8 @@ function moveConversations(from: string, to: string, today: string): string {
283
412
  * already has stays, the old place is renamed. Returns what was done, one line each.
284
413
  */
285
414
  export function fixMemory(projectRoot: string, state: MemoryState, ctx: MemoryCtx, opts: MemoryFixOptions): string[] {
415
+ if (!memoryApplies(projectRoot, ctx))
416
+ throw new Error(`${projectRoot} is no project folder — its settings are the user's own`);
286
417
  const root = realRoot(projectRoot);
287
418
  const target = path.join(root, MEMORY);
288
419
  const done: string[] = [];
@@ -297,12 +428,22 @@ export function fixMemory(projectRoot: string, state: MemoryState, ctx: MemoryCt
297
428
  case 'custom':
298
429
  return done;
299
430
  case 'leftovers':
431
+ // Written after vdx's move by a session that started before it: newer than anything inside.
432
+ if (state.afterMove)
433
+ done.push(
434
+ ...bringIn(
435
+ state.old,
436
+ target,
437
+ 'written in the old place after the move',
438
+ tilde(asideName(state.old, opts.today), ctx.home),
439
+ ).done,
440
+ );
300
441
  setAside(state.old);
301
442
  return done;
302
443
  case 'outside': {
303
- const { copied, kept } = fs.existsSync(state.old) ? copyMissing(state.old, target) : { copied: 0, kept: [] };
444
+ const { copied, kept } = fs.existsSync(state.old) ? copyMissing(state.old, target) : { copied: [], kept: [] };
304
445
  if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true });
305
- done.push(`${copied} note(s) → .claude/memory/`);
446
+ done.push(`${copied.length} note(s) → .claude/memory/`);
306
447
  if (kept.length) done.push(`kept .claude/memory/ as it is for ${kept.join(', ')} — the old copy differs`);
307
448
  const file = path.join(root, '.claude', opts.personal ? 'settings.json' : 'settings.local.json');
308
449
  writeSetting(file, target);
@@ -310,24 +451,52 @@ export function fixMemory(projectRoot: string, state: MemoryState, ctx: MemoryCt
310
451
  if (!opts.personal && !opts.underGit && excludeMemory(root)) done.push('.claude/memory/ → .git/info/exclude');
311
452
  setAside(state.old);
312
453
  if (opts.personal && git(root, ['rev-parse', '--git-dir']) !== null) {
313
- done.push('commit .claude/memory/ and .claude/settings.json with the project');
454
+ // The project's own .gitignore may keep .claude/ out (mediko, 2026-10-23).
455
+ const ignored = git(root, ['check-ignore', '-q', '.claude/memory/MEMORY.md']) !== null;
456
+ done.push(
457
+ ignored
458
+ ? ".claude/memory/ stays out of git by the project's own .gitignore"
459
+ : 'commit .claude/memory/ and .claude/settings.json with the project',
460
+ );
314
461
  }
315
462
  return done;
316
463
  }
317
464
  case 'moved':
318
465
  case 'elsewhere': {
319
- writeSetting(state.setting.file, target);
320
- done.push(`autoMemoryDirectory → ${target}/ (${path.relative(root, state.setting.file)})`);
466
+ // The path is rewritten where it was read — unless that file is the user's, read by every
467
+ // project, or another clone's path in the repo's shared settings.json: then this clone's
468
+ // own local settings, read first (Sidetrack #4).
469
+ const local = path.join(root, '.claude', 'settings.local.json');
470
+ const inProject = path.dirname(state.setting.file) === path.join(root, '.claude');
471
+ const shared =
472
+ state.kind === 'elsewhere' &&
473
+ state.setting.file !== local &&
474
+ git(root, ['ls-files', '--error-unmatch', '--', path.relative(root, state.setting.file)]) !== null;
475
+ const file = inProject && !shared ? state.setting.file : local;
476
+ writeSetting(file, target);
477
+ done.push(`autoMemoryDirectory → ${target}/ (${path.relative(root, file)})`);
478
+ if (shared) done.push(`${path.relative(root, state.setting.file)} keeps the other clone's path — it is shared`);
321
479
  if (state.kind === 'moved' && state.stray) {
322
- const { copied, kept } = copyMissing(state.stray, target);
480
+ const brought = bringIn(
481
+ state.stray,
482
+ target,
483
+ 'written at the old path after the move',
484
+ tilde(state.stray, ctx.home),
485
+ );
323
486
  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`,
487
+ ...brought.done,
488
+ `${tilde(state.from, ctx.home)} holds only them — remove it${brought.differ.length ? ` once ${brought.differ.join(', ')} is settled` : ''}`,
325
489
  );
326
490
  }
327
491
  if (state.kind === 'moved' && state.conversations) {
328
492
  const here = path.join(claudeConfigDir(ctx.env, ctx.home), 'projects', projectDirName(root));
329
493
  done.push(moveConversations(state.conversations, here, opts.today));
330
494
  }
495
+ // Claude Code trusts a folder by its path: the moved one is new to it (Sidetrack #2).
496
+ if (state.kind === 'moved' && claudeTrusts(root, ctx.env, ctx.home) === false)
497
+ done.push(
498
+ 'Claude Code has not trusted this folder yet: its first start here stops on the trust window — start vdx ai here once and answer it; a detached start (the mail session) is refused until then',
499
+ );
331
500
  return done;
332
501
  }
333
502
  }
@@ -339,7 +508,9 @@ export interface ProjectMemoryDeps {
339
508
  log: (line: string) => void;
340
509
  today: string;
341
510
  /** The profile's `git.personal_hosts`. */
342
- personalHosts: string[];
511
+ personalHosts: PersonalHost[];
512
+ /** Asks GitHub who reads the repo; the machine's own by default. */
513
+ io?: HostingIo;
343
514
  }
344
515
 
345
516
  /**
@@ -353,14 +524,17 @@ export function fixProjectMemory(
353
524
  ctx: MemoryCtx,
354
525
  deps: ProjectMemoryDeps,
355
526
  ): string[] {
356
- const personal = isPersonalProject(projectRoot, deps.personalHosts);
527
+ const personal = isPersonalProject(projectRoot, deps.personalHosts, deps.io);
357
528
  let underGit = false;
358
529
  if (state.kind === 'outside' && !personal && git(projectRoot, ['rev-parse', '--git-dir']) !== null) {
359
530
  deps.log(` ${path.basename(projectRoot)} is not a personal repo: memory under git reaches whoever clones it`);
360
531
  underGit = /^y(es)?$/i.test(deps.ask(' Keep .claude/memory/ under git? [y/N] ')?.trim() ?? '');
361
532
  }
362
533
  const done = fixMemory(projectRoot, state, ctx, { personal, underGit, today: deps.today });
363
- if (done.length && (state.kind === 'outside' || state.kind === 'moved')) {
534
+ if (
535
+ done.length &&
536
+ (state.kind === 'outside' || state.kind === 'moved' || (state.kind === 'leftovers' && state.afterMove))
537
+ ) {
364
538
  done.push('sessions of this project running now keep the old place until they restart');
365
539
  }
366
540
  return done;