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