@vodmal/vdx-cli 0.37.1 → 0.38.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,6 +50,8 @@ 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.37.1",
3
+ "version": "0.38.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",
package/src/ai.ts CHANGED
@@ -29,7 +29,15 @@ import {
29
29
  parseMachineReport,
30
30
  runsInProject,
31
31
  } from './conversations.ts';
32
- import { describeMemory, fixProjectMemory, memoryNeedsFix, memoryState } from './memory.ts';
32
+ import {
33
+ describeMemory,
34
+ fixProjectMemory,
35
+ memoryApplies,
36
+ memoryNeedsFix,
37
+ memoryState,
38
+ personalHosts,
39
+ type PersonalHost,
40
+ } from './memory.ts';
33
41
  import { at, errorMessage, isStringList, str } from './narrow.ts';
34
42
  import { type Lag, sessionLag } from './session-versions.ts';
35
43
 
@@ -116,8 +124,11 @@ interface GitProfile {
116
124
  /** Directories whose repos are the neighbours an author is proposed from; default — the project's parent. */
117
125
  author_pool?: string[];
118
126
  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[];
127
+ /**
128
+ * Hosts whose repos are the owner's alone, and where their repos are mirrored: Claude Code's
129
+ * memory goes under git only where nobody else reads (claude-memory-in-project).
130
+ */
131
+ personal_hosts?: Array<string | { host: string; mirrors?: string[] }>;
121
132
  }
122
133
 
123
134
  export interface Environment {
@@ -255,8 +266,21 @@ export function parseEnvironment(text: string, source: string): Environment {
255
266
  if (gitDoc.author_pool !== undefined && !isStringList(gitDoc.author_pool)) {
256
267
  fail('git.author_pool', 'must be a list of directories');
257
268
  }
258
- if (gitDoc.personal_hosts !== undefined && !isStringList(gitDoc.personal_hosts)) {
259
- fail('git.personal_hosts', 'must be a list of host names');
269
+ if (gitDoc.personal_hosts !== undefined) {
270
+ if (!Array.isArray(gitDoc.personal_hosts)) fail('git.personal_hosts', 'must be a list');
271
+ gitDoc.personal_hosts.forEach((h, i) => {
272
+ const at = `git.personal_hosts[${i}]`;
273
+ if (typeof h === 'string') {
274
+ if (h.trim() === '') fail(at, 'must be a host name');
275
+ return;
276
+ }
277
+ if (typeof h !== 'object' || h === null || typeof h.host !== 'string' || h.host.trim() === '') {
278
+ fail(at, 'must be a host name, or a mapping with host');
279
+ }
280
+ if (h.mirrors !== undefined && (!isStringList(h.mirrors) || !h.mirrors.every((m) => m.includes('{repo}')))) {
281
+ fail(`${at}.mirrors`, 'must be a list of places like github.com/<owner>/{repo}');
282
+ }
283
+ });
260
284
  }
261
285
  if (gitDoc.hooks !== undefined) {
262
286
  if (!Array.isArray(gitDoc.hooks)) fail('git.hooks', 'must be a list');
@@ -1157,13 +1181,13 @@ export function ensureAuthor(projectRoot: string, poolDirs: string[], deps: AiDe
1157
1181
  export function ensureMemory(
1158
1182
  projectRoot: string,
1159
1183
  command: string,
1160
- personalHosts: string[],
1184
+ hosts: PersonalHost[],
1161
1185
  deps: AiDeps,
1162
1186
  dryRun: boolean,
1163
1187
  agentRunning: boolean,
1164
1188
  ): void {
1165
- if (!isClaude(command)) return;
1166
1189
  const ctx = { env: deps.env, home: deps.home };
1190
+ if (!isClaude(command) || !memoryApplies(projectRoot, ctx)) return;
1167
1191
  const state = memoryState(projectRoot, ctx);
1168
1192
  if (dryRun) {
1169
1193
  deps.out(` memory: ${describeMemory(state, ctx)}\n`);
@@ -1194,7 +1218,7 @@ export function ensureMemory(
1194
1218
  ask: deps.ask,
1195
1219
  log: deps.log,
1196
1220
  today: new Date().toLocaleDateString('sv-SE'),
1197
- personalHosts,
1221
+ personalHosts: hosts,
1198
1222
  });
1199
1223
  for (const d of done) deps.log(`✓ ${d}`);
1200
1224
  } catch (e) {
@@ -1937,7 +1961,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1937
1961
  for (const l of choice?.lines ?? []) deps.out(` conversation: ${l}\n`);
1938
1962
  for (const l of folders) deps.out(` ${l}\n`);
1939
1963
  ensureAuthor(projectRoot, poolDirs, deps, true);
1940
- ensureMemory(projectRoot, plan.command, environment.git?.personal_hosts ?? [], deps, true, agentsHere.length > 0);
1964
+ ensureMemory(
1965
+ projectRoot,
1966
+ plan.command,
1967
+ personalHosts(environment.git?.personal_hosts),
1968
+ deps,
1969
+ true,
1970
+ agentsHere.length > 0,
1971
+ );
1941
1972
  return EXIT_OK;
1942
1973
  }
1943
1974
  for (const l of choice?.lines ?? []) log(l);
@@ -1973,7 +2004,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
1973
2004
  plan = { ...plan, resumeArgs: resumeId(id) };
1974
2005
  }
1975
2006
  ensureAuthor(projectRoot, poolDirs, deps, false);
1976
- ensureMemory(projectRoot, plan.command, environment.git?.personal_hosts ?? [], deps, false, agentsHere.length > 0);
2007
+ ensureMemory(
2008
+ projectRoot,
2009
+ plan.command,
2010
+ personalHosts(environment.git?.personal_hosts),
2011
+ deps,
2012
+ false,
2013
+ agentsHere.length > 0,
2014
+ );
1977
2015
 
1978
2016
  if (multiplexer === 'none') {
1979
2017
  if (opts.restart) {
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,
@@ -234,9 +235,10 @@ or GitHub stopped answering; a plain git push exits with git's own code.
234
235
  positionals: 1,
235
236
  },
236
237
  doctor: {
237
- usage: 'vdx doctor [project_path] [--check | --fix] [--format=ansi|markdown|json] [--json]',
238
+ usage: 'vdx doctor [project_path] [--check | --fix | --staged] [--format=ansi|markdown|json] [--json]',
238
239
  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
240
+ Claude Code plugin) and, inside a project (default: cwd, when it looks like one; a
241
+ folder named here is one, git or not — the home directory never), its commit author and
240
242
  its git hooks: are the hooks the repository declares on in this clone, and do
241
243
  the personal hooks of your profile (git.hooks) run on this machine; with origin on
242
244
  GitHub, the token vdx asks GitHub with ($GH_TOKEN, $GITHUB_TOKEN or gh) and
@@ -252,13 +254,18 @@ takes it along. Reads only, unless --fix. Exit 2 when something is missing.
252
254
  install step, which the report names); and put the memory inside:
253
255
  notes from ~/.claude/projects/<path>/memory into .claude/memory/, none
254
256
  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
+ into .claude/settings.json in a personal project (only you read where
258
+ its commits go — see --staged), else into .claude/settings.local.json,
257
259
  with the memory kept out of git (.git/info/exclude) unless you say
258
260
  yes. After a folder is moved: the path follows it, and the project's
259
261
  conversations move to the new path
262
+ --staged the profile's pre-commit hook: refuses a commit that stages
263
+ .claude/memory/ unless only you read every place the commit reaches —
264
+ origin on a host of git.personal_hosts or a private GitHub repo with
265
+ nobody else, and each mirror the profile names (GitHub asked with gh's
266
+ token); no answer, no token — refused. Silent otherwise
260
267
  `,
261
- bools: ['json', 'check', 'fix'],
268
+ bools: ['json', 'check', 'fix', 'staged'],
262
269
  values: ['format'],
263
270
  positionals: 1,
264
271
  },
@@ -606,11 +613,20 @@ function cmdRun(verb: LifecycleVerb): void {
606
613
  }
607
614
 
608
615
  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');
616
+ const ctx = resolveDoctorCtx(path.resolve(opts.positionals[0] ?? '.'), opts.positionals[0] !== undefined);
617
+ if ([opts.flags.check, opts.flags.fix, opts.flags.staged].filter((f) => f === true).length > 1) {
618
+ process.stderr.write('vdx doctor: --check reports, --fix writes, --staged guards a commit — pick one\n');
612
619
  process.exit(2);
613
620
  }
621
+ if (opts.flags.staged === true) {
622
+ const root = resolveProjectRoot(ctx.projectRoot ?? '.') ?? process.cwd();
623
+ const refusal = runDoctorStaged(root);
624
+ if (refusal) {
625
+ process.stderr.write(refusal + '\n');
626
+ process.exit(1);
627
+ }
628
+ return;
629
+ }
614
630
  if (opts.flags.check === true) {
615
631
  const text = runDoctorCheck(ctx);
616
632
  if (text) process.stdout.write(text + '\n');
package/src/memory.ts CHANGED
@@ -3,7 +3,8 @@ import * as path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { remoteParts } from './author.ts';
5
5
  import { claudeConfigDir, projectDirName } from './conversations.ts';
6
- import { dict, str } from './narrow.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).
@@ -53,6 +54,19 @@ function realRoot(root: string): string {
53
54
  }
54
55
  }
55
56
 
57
+ /**
58
+ * A folder whose memory vdx may move: not the home directory or above it — its
59
+ * `.claude/settings.json` is the user's own, read by every project — and not inside
60
+ * Claude Code's configuration.
61
+ */
62
+ export function memoryApplies(projectRoot: string, ctx: MemoryCtx): boolean {
63
+ const root = realRoot(projectRoot);
64
+ const home = realRoot(ctx.home);
65
+ const config = realRoot(claudeConfigDir(ctx.env, ctx.home));
66
+ const within = (a: string, b: string) => a === b || a.startsWith(b + path.sep);
67
+ return !within(home, root) && !within(root, config);
68
+ }
69
+
56
70
  /** Where the setting is read, in Claude Code's order: local settings, the project's, the user's. */
57
71
  function settingFiles(root: string, ctx: MemoryCtx): string[] {
58
72
  return [
@@ -145,16 +159,83 @@ function git(root: string, args: string[]): string | null {
145
159
  }
146
160
  }
147
161
 
162
+ /** A host of `git.personal_hosts`: its repos are the owner's alone; `mirrors` — where its repos may be copied. */
163
+ export interface PersonalHost {
164
+ host: string;
165
+ /** `github.com/<owner>/{repo}`: a repo of the host may be mirrored there (gitolite's post-update hook). */
166
+ mirrors: string[];
167
+ }
168
+
169
+ /** The profile's `git.personal_hosts`: a host name, or `{host, mirrors}`. */
170
+ export function personalHosts(
171
+ listed: Array<string | { host: string; mirrors?: string[] }> | undefined,
172
+ ): PersonalHost[] {
173
+ return (listed ?? []).map((h) =>
174
+ typeof h === 'string'
175
+ ? { host: h.toLowerCase(), mirrors: [] }
176
+ : { host: h.host.toLowerCase(), mirrors: h.mirrors ?? [] },
177
+ );
178
+ }
179
+
180
+ /** Who may read where the repo's commits go: only its owner, or — why not. */
181
+ export type Readers = { only: true } | { only: false; why: string };
182
+
148
183
  /**
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`.
184
+ * Whether only the owner reads every place a commit of this repo reaches (DL #12 of
185
+ * docs/tasks/claude-memory-in-project): origin on a host the profile names as the
186
+ * owner's (`git.personal_hosts`), or a private GitHub repo with no other collaborator;
187
+ * and every mirror the profile names for that host — absent, or private and nobody
188
+ * else's. No origin: nothing leaves the machine. GitHub is asked with the token gh
189
+ * keeps; no token, no answer, `VDX_OFFLINE` — not known, so not only.
152
190
  */
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);
191
+ export function memoryReaders(io: HostingIo, hosts: PersonalHost[]): Readers {
192
+ const url = io.git(['remote', 'get-url', 'origin']);
193
+ if (!url) return { only: true };
194
+ const { host, path: repo } = remoteParts(url);
195
+ const lower = host.toLowerCase();
196
+ const own = hosts.find((h) => h.host === lower);
197
+ const places: string[] = [];
198
+ if (lower === 'github.com') places.push(repo);
199
+ else if (!own)
200
+ return { only: false, why: `origin is on ${host} — not a host the profile names as yours (git.personal_hosts)` };
201
+ for (const m of own?.mirrors ?? []) {
202
+ const at = m.replace(/^https?:\/\//, '').replace('{repo}', repo);
203
+ if (!at.startsWith('github.com/'))
204
+ return { only: false, why: `the mirror ${m} is not on GitHub — vdx cannot tell who reads it` };
205
+ places.push(at.slice('github.com/'.length));
206
+ }
207
+ if (places.length === 0) return { only: true };
208
+ if (io.env.VDX_OFFLINE) return { only: false, why: 'VDX_OFFLINE is set — GitHub was not asked who reads' };
209
+ const token = githubToken(io)?.token ?? null;
210
+ if (!token) return { only: false, why: 'no GitHub token to ask who reads — gh auth login' };
211
+ try {
212
+ const me = str(at(io.get('/user', token).body, 'login'));
213
+ if (!me) return { only: false, why: 'GitHub named no user for the token' };
214
+ for (const p of places) {
215
+ const r = io.get(`/repos/${p}`, token);
216
+ if (r.status === 404 && p !== repo) continue; // that mirror does not exist
217
+ if (r.status !== 200) return { only: false, why: `GitHub answered ${r.status} for ${p}` };
218
+ if (at(r.body, 'private') !== true) return { only: false, why: `github.com/${p} is public` };
219
+ const c = io.get(`/repos/${p}/collaborators?per_page=100`, token);
220
+ if (c.status !== 200) return { only: false, why: `GitHub does not show who reads github.com/${p} (${c.status})` };
221
+ const others = list(c.body)
222
+ .map((x) => str(at(x, 'login')))
223
+ .filter((l) => l && l !== me);
224
+ if (others.length) return { only: false, why: `github.com/${p} is read by ${others.length} more besides ${me}` };
225
+ }
226
+ return { only: true };
227
+ } catch (e) {
228
+ return { only: false, why: `GitHub did not answer: ${errorMessage(e)}` };
229
+ }
230
+ }
231
+
232
+ /** A personal project: its memory may go under git (DL #3, #4, #12). */
233
+ export function isPersonalProject(
234
+ projectRoot: string,
235
+ hosts: PersonalHost[],
236
+ io: HostingIo = machineIo(projectRoot),
237
+ ): boolean {
238
+ return memoryReaders(io, hosts).only;
158
239
  }
159
240
 
160
241
  /** Needs `vdx doctor --fix`: anything but inside, or a path of the owner's own. */
@@ -283,6 +364,8 @@ function moveConversations(from: string, to: string, today: string): string {
283
364
  * already has stays, the old place is renamed. Returns what was done, one line each.
284
365
  */
285
366
  export function fixMemory(projectRoot: string, state: MemoryState, ctx: MemoryCtx, opts: MemoryFixOptions): string[] {
367
+ if (!memoryApplies(projectRoot, ctx))
368
+ throw new Error(`${projectRoot} is no project folder — its settings are the user's own`);
286
369
  const root = realRoot(projectRoot);
287
370
  const target = path.join(root, MEMORY);
288
371
  const done: string[] = [];
@@ -310,7 +393,13 @@ export function fixMemory(projectRoot: string, state: MemoryState, ctx: MemoryCt
310
393
  if (!opts.personal && !opts.underGit && excludeMemory(root)) done.push('.claude/memory/ → .git/info/exclude');
311
394
  setAside(state.old);
312
395
  if (opts.personal && git(root, ['rev-parse', '--git-dir']) !== null) {
313
- done.push('commit .claude/memory/ and .claude/settings.json with the project');
396
+ // The project's own .gitignore may keep .claude/ out (mediko, 2026-10-23).
397
+ const ignored = git(root, ['check-ignore', '-q', '.claude/memory/MEMORY.md']) !== null;
398
+ done.push(
399
+ ignored
400
+ ? ".claude/memory/ stays out of git by the project's own .gitignore"
401
+ : 'commit .claude/memory/ and .claude/settings.json with the project',
402
+ );
314
403
  }
315
404
  return done;
316
405
  }
@@ -339,7 +428,9 @@ export interface ProjectMemoryDeps {
339
428
  log: (line: string) => void;
340
429
  today: string;
341
430
  /** The profile's `git.personal_hosts`. */
342
- personalHosts: string[];
431
+ personalHosts: PersonalHost[];
432
+ /** Asks GitHub who reads the repo; the machine's own by default. */
433
+ io?: HostingIo;
343
434
  }
344
435
 
345
436
  /**
@@ -353,7 +444,7 @@ export function fixProjectMemory(
353
444
  ctx: MemoryCtx,
354
445
  deps: ProjectMemoryDeps,
355
446
  ): string[] {
356
- const personal = isPersonalProject(projectRoot, deps.personalHosts);
447
+ const personal = isPersonalProject(projectRoot, deps.personalHosts, deps.io);
357
448
  let underGit = false;
358
449
  if (state.kind === 'outside' && !personal && git(projectRoot, ['rev-parse', '--git-dir']) !== null) {
359
450
  deps.log(` ${path.basename(projectRoot)} is not a personal repo: memory under git reaches whoever clones it`);