@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 +6 -2
- package/package.json +1 -1
- package/rubric/vdx-rubric.yaml +1 -1
- package/src/ai.ts +60 -12
- package/src/conversations.ts +35 -0
- package/src/doctor.ts +41 -2
- package/src/index.ts +33 -10
- package/src/init.ts +1 -1
- package/src/memory.ts +201 -27
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
|
|
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.
|
|
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",
|
package/rubric/vdx-rubric.yaml
CHANGED
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 {
|
|
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
|
-
/**
|
|
120
|
-
|
|
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
|
|
259
|
-
fail('git.personal_hosts', 'must be a list
|
|
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
|
-
|
|
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
|
-
?
|
|
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(
|
|
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(
|
|
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}`);
|
package/src/conversations.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
256
|
-
|
|
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
|
|
611
|
-
process.stderr.write('vdx doctor: --check reports
|
|
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.
|
|
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 {
|
|
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
|
-
/**
|
|
21
|
-
|
|
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 {
|
|
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
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
|
154
|
-
const url = git(
|
|
155
|
-
if (!url) return true;
|
|
156
|
-
const host = remoteParts(url)
|
|
157
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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;
|