@vodmal/vdx-cli 0.38.0 → 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
@@ -56,7 +56,8 @@ vdx doctor --check [project-path] # one line per hook declared but not running
56
56
  # not inside — the plugin's SessionStart hook tells the agent
57
57
  vdx doctor --fix [project-path] # write the profile's personal hooks (git.hooks) into ~/.gitconfig, and put
58
58
  # the project's Claude Code memory inside it: .claude/memory/ (after a
59
- # 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)
60
61
 
61
62
  # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
62
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
@@ -116,7 +117,8 @@ The session's `{project}` is `[_.vdx] name` (or the older `[vdx]`) from the proj
116
117
  first file in the profile's `session.project_names` that has one, else the repo
117
118
  name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machine's vdx,
118
119
  profile and tmux, the path taken under its home directory; `--detach` only
119
- 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
120
122
  commit author, `vdx ai` proposes one (Enter writes it into `.git/config`).
121
123
  For Claude Code `vdx ai` picks the conversation: each one is marked with the
122
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.38.0",
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,
@@ -1767,9 +1768,10 @@ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = proc
1767
1768
  `Hand the user the fix — ${reload}` +
1768
1769
  (deps.env['TMUX']
1769
1770
  ? `\`${vdxAi} --restart ${root}\`: it stops this session and starts it again${again}` +
1770
- // 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).
1771
1773
  (focused || intercom
1772
- ? ', 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"
1773
1775
  : plan.resumeArgs.length
1774
1776
  ? `, resuming the conversation (${plan.resumeArgs.join(' ')})`
1775
1777
  : '') +
@@ -2102,6 +2104,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
2102
2104
  if (live) plan = { ...plan, resumeArgs: resumeId(live) };
2103
2105
  }
2104
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
+
2105
2115
  if (current) {
2106
2116
  const line = agentLine(plan, [...plan.args, ...plan.resumeArgs]);
2107
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/index.ts CHANGED
@@ -129,7 +129,10 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
129
129
  Without it, an agent running without the profile's flags is
130
130
  a question in a terminal: Enter attaches to it as is, r
131
131
  restarts it, q leaves it
132
- --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
133
136
  --dry-run print the plan and the project's running agents; start nothing
134
137
  --check for the agent's own session: how the agent here is launched,
135
138
  whether this session carries the profile's flags, and whether it
@@ -258,7 +261,11 @@ takes it along. Reads only, unless --fix. Exit 2 when something is missing.
258
261
  its commits go — see --staged), else into .claude/settings.local.json,
259
262
  with the memory kept out of git (.git/info/exclude) unless you say
260
263
  yes. After a folder is moved: the path follows it, and the project's
261
- 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
262
269
  --staged the profile's pre-commit hook: refuses a commit that stages
263
270
  .claude/memory/ unless only you read every place the commit reaches —
264
271
  origin on a host of git.personal_hosts or a private GitHub repo with
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,7 +2,7 @@ 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';
5
+ import { claudeConfigDir, claudeTrusts, projectDirName } from './conversations.ts';
6
6
  import { githubToken, machineIo, type HostingIo } from './hosting.ts';
7
7
  import { at, dict, errorMessage, list, str } from './narrow.ts';
8
8
 
@@ -18,8 +18,11 @@ import { at, dict, errorMessage, list, str } from './narrow.ts';
18
18
  export type MemoryState =
19
19
  /** In `.claude/memory/`, nothing left in the old place. */
20
20
  | { kind: 'inside' }
21
- /** In `.claude/memory/`, and notes still lie in the old place — a move made by hand. */
22
- | { 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 }
23
26
  /** Not set: memory goes to the old place. */
24
27
  | { kind: 'outside'; old: string; notes: number }
25
28
  /**
@@ -109,6 +112,11 @@ function entries(dir: string): string[] {
109
112
  }
110
113
  }
111
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
+
112
120
  function sameFile(a: string, b: string): boolean {
113
121
  try {
114
122
  return fs.readFileSync(a).equals(fs.readFileSync(b));
@@ -130,7 +138,14 @@ export function memoryState(projectRoot: string, ctx: MemoryCtx): MemoryState {
130
138
  (n) => fs.existsSync(path.join(target, n)) && !sameFile(path.join(old, n), path.join(target, n)),
131
139
  );
132
140
  const onlyOld = notes.filter((n) => !fs.existsSync(path.join(target, n)));
133
- 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
+ };
134
149
  }
135
150
  if (path.basename(setting.dir) === 'memory' && path.basename(path.dirname(setting.dir)) === '.claude') {
136
151
  const from = path.dirname(path.dirname(setting.dir));
@@ -253,6 +268,8 @@ export function describeMemory(state: MemoryState, ctx: MemoryCtx): string {
253
268
  case 'inside':
254
269
  return 'in .claude/memory/';
255
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`;
256
273
  const parts = [state.differ ? `${state.differ} differ` : '', state.onlyOld ? `${state.onlyOld} only there` : '']
257
274
  .filter(Boolean)
258
275
  .join(', ');
@@ -288,10 +305,10 @@ function asideName(dir: string, today: string): string {
288
305
  return name;
289
306
  }
290
307
 
291
- /** Copies what `to` lacks; a name `to` already has stays as it is there. Returns the names kept. */
292
- 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[] } {
293
310
  fs.mkdirSync(to, { recursive: true });
294
- let copied = 0;
311
+ const copied: string[] = [];
295
312
  const kept: string[] = [];
296
313
  for (const name of entries(from)) {
297
314
  const src = path.join(from, name);
@@ -301,11 +318,42 @@ function copyMissing(from: string, to: string): { copied: number; kept: string[]
301
318
  continue;
302
319
  }
303
320
  fs.cpSync(src, dst, { recursive: true, errorOnExist: true, force: false });
304
- copied++;
321
+ copied.push(name);
305
322
  }
306
323
  return { copied, kept };
307
324
  }
308
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
+
309
357
  /** Sets `autoMemoryDirectory` in a settings file, keeping its other keys. */
310
358
  function writeSetting(file: string, dir: string): void {
311
359
  let doc: Record<string, unknown> = {};
@@ -380,12 +428,22 @@ export function fixMemory(projectRoot: string, state: MemoryState, ctx: MemoryCt
380
428
  case 'custom':
381
429
  return done;
382
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
+ );
383
441
  setAside(state.old);
384
442
  return done;
385
443
  case 'outside': {
386
- 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: [] };
387
445
  if (!fs.existsSync(target)) fs.mkdirSync(target, { recursive: true });
388
- done.push(`${copied} note(s) → .claude/memory/`);
446
+ done.push(`${copied.length} note(s) → .claude/memory/`);
389
447
  if (kept.length) done.push(`kept .claude/memory/ as it is for ${kept.join(', ')} — the old copy differs`);
390
448
  const file = path.join(root, '.claude', opts.personal ? 'settings.json' : 'settings.local.json');
391
449
  writeSetting(file, target);
@@ -405,18 +463,40 @@ export function fixMemory(projectRoot: string, state: MemoryState, ctx: MemoryCt
405
463
  }
406
464
  case 'moved':
407
465
  case 'elsewhere': {
408
- writeSetting(state.setting.file, target);
409
- 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`);
410
479
  if (state.kind === 'moved' && state.stray) {
411
- 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
+ );
412
486
  done.push(
413
- `${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` : ''}`,
414
489
  );
415
490
  }
416
491
  if (state.kind === 'moved' && state.conversations) {
417
492
  const here = path.join(claudeConfigDir(ctx.env, ctx.home), 'projects', projectDirName(root));
418
493
  done.push(moveConversations(state.conversations, here, opts.today));
419
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
+ );
420
500
  return done;
421
501
  }
422
502
  }
@@ -451,7 +531,10 @@ export function fixProjectMemory(
451
531
  underGit = /^y(es)?$/i.test(deps.ask(' Keep .claude/memory/ under git? [y/N] ')?.trim() ?? '');
452
532
  }
453
533
  const done = fixMemory(projectRoot, state, ctx, { personal, underGit, today: deps.today });
454
- 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
+ ) {
455
538
  done.push('sessions of this project running now keep the old place until they restart');
456
539
  }
457
540
  return done;