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