archgen-skill 0.0.5 → 0.0.7

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.
Files changed (39) hide show
  1. package/README.md +20 -7
  2. package/bin/archgen.mjs +142 -15
  3. package/lib/block.js +52 -11
  4. package/lib/bootstrap.js +107 -0
  5. package/lib/config.js +3 -2
  6. package/lib/doctor.js +197 -77
  7. package/lib/init.js +77 -19
  8. package/lib/install.js +79 -7
  9. package/lib/mcp-server.js +241 -0
  10. package/lib/migrations/001-stamp-provenance.mjs +15 -8
  11. package/lib/migrations/index.mjs +19 -8
  12. package/lib/restore.js +52 -16
  13. package/lib/spawn-npm.js +99 -0
  14. package/lib/store.js +175 -28
  15. package/lib/uninstall-project.js +38 -7
  16. package/lib/version.js +20 -4
  17. package/package.json +3 -3
  18. package/vendor/skills/archgen/SKILL.md +363 -213
  19. package/vendor/skills/archgen/archgen.config.json +1 -1
  20. package/vendor/skills/archgen/references/code-standards.md +2 -2
  21. package/vendor/skills/archgen/assets/mcp-template.json +0 -42
  22. package/vendor/skills/archgen/assets/skill-registry.json +0 -24
  23. package/vendor/skills/archgen/references/artifact-templates.md +0 -406
  24. package/vendor/skills/archgen/references/interview.md +0 -275
  25. package/vendor/skills/archgen/references/mcp-and-skills.md +0 -117
  26. package/vendor/skills/archgen/references/orchestration.md +0 -292
  27. package/vendor/skills/archgen/references/platforms.md +0 -138
  28. package/vendor/skills/archgen/scripts/doc-index.mjs +0 -457
  29. package/vendor/skills/archgen/scripts/impact.mjs +0 -77
  30. package/vendor/skills/archgen/scripts/lib/graph.mjs +0 -222
  31. package/vendor/skills/archgen/scripts/lib/plan-coverage.mjs +0 -32
  32. package/vendor/skills/archgen/scripts/lib/scope.mjs +0 -65
  33. package/vendor/skills/archgen/scripts/lib/yaml.mjs +0 -492
  34. package/vendor/skills/archgen/scripts/next-tasks.mjs +0 -55
  35. package/vendor/skills/archgen/scripts/plan-graph.mjs +0 -329
  36. package/vendor/skills/archgen/scripts/set-status.mjs +0 -82
  37. package/vendor/skills/archgen/scripts/update-agents.mjs +0 -213
  38. package/vendor/skills/archgen/scripts/validate.mjs +0 -87
  39. package/vendor/skills/archgen/scripts/verify-plan.mjs +0 -70
package/lib/doctor.js CHANGED
@@ -3,13 +3,13 @@
3
3
  // Verifies a project install: canonical store integrity, version stamp,
4
4
  // claude link resolution, managed blocks (exactly once each), and manifest
5
5
  // entry resolution. Safe issues are repaired in place unless --check is given
6
- // (report-only). Advisory FAIL rows (broken states only a human can fix) are
7
- // reported and counted in the summary but do NOT fail the run: the process
8
- // exits non-zero only when a repair operation itself throws.
6
+ // (report-only). A FAIL row is a broken state a human must fix: it is
7
+ // reported AND it fails the run (the process exits non-zero whenever any
8
+ // FAIL row was produced).
9
9
 
10
10
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
- import { dirname, join, resolve } from 'node:path';
12
+ import { dirname, join, resolve, sep } from 'node:path';
13
13
  import { globalTargets } from './install.js';
14
14
  import {
15
15
  END,
@@ -22,9 +22,10 @@ import {
22
22
  upsertManagedFile,
23
23
  } from './block.js';
24
24
  import { resolveSkillSource } from './init.js';
25
- import { CLAUDE_LINK_REL, DEV_ONLY_RELPATHS, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, hashDir, loadManifest, pruneDevOnly, saveManifest } from './store.js';
25
+ import { CLAUDE_LINK_REL, DEV_ONLY_RELPATHS, MANIFEST_REL, STORE_REL, VERSION_FILE, assertNotSymlink, assertWithinRoot, cliVersion, findObsolete, hashDir, loadManifest, pruneDevOnly, pruneObsolete, saveManifest } from './store.js';
26
26
  import { compareSemver, parseSemver } from './version.js';
27
27
  import { readStamp, writeStamp } from './version-stamp.js';
28
+ import { mcpDoctor } from './mcp-server.js';
28
29
 
29
30
  function lstatSafe(p) {
30
31
  try { return lstatSync(p); } catch { return null; }
@@ -47,8 +48,7 @@ function relJoin(rel) {
47
48
  * mutations; home overrides the homedir scanned for global installs (tests)
48
49
  * @returns {{root: string, checks: {status: 'OK'|'FIXED'|'WOULD-FIX'|'WARN'|'FAIL', msg: string}[],
49
50
  * tally: Record<string, number>, failures: number}} `failures` counts
50
- * repair operations that THEMSELVES threw — advisory FAIL rows are reported
51
- * but never fail the run.
51
+ * the FAIL rows — a broken state a human must fix always fails the run.
52
52
  */
53
53
  export function doctorProject(projectDir, packageRoot, opts = {}) {
54
54
  const root = resolve(projectDir);
@@ -56,16 +56,20 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
56
56
  const homeDir = opts.home ?? homedir();
57
57
  const version = cliVersion(packageRoot);
58
58
  const checks = [];
59
- const add = (status, msg) => checks.push({ status, msg });
60
- let opFailures = 0;
59
+ let failures = 0;
60
+ // A FAIL row is a broken state a human must fix, so it MUST fail the run:
61
+ // the counter below is what the CLI turns into a non-zero exit code.
62
+ const add = (status, msg) => {
63
+ checks.push({ status, msg });
64
+ if (status === 'FAIL') failures++;
65
+ };
61
66
  // Run one repair step; a throw becomes a FAIL row instead of aborting the
62
- // whole doctor pass (and is the only thing that flips the exit code).
67
+ // whole doctor pass.
63
68
  const attempt = (fn, okStatus, okMsg, failLabel) => {
64
69
  try {
65
70
  fn();
66
71
  add(okStatus, okMsg);
67
72
  } catch (e) {
68
- opFailures++;
69
73
  add('FAIL', `${failLabel}: ${e.message} - fix manually`);
70
74
  }
71
75
  };
@@ -83,7 +87,39 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
83
87
  if (!existsSync(join(storeAbs, 'SKILL.md'))) {
84
88
  add('FAIL', 'store .agents/skills/archgen missing or incomplete - run `archgen init`');
85
89
  } else {
86
- const missingCore = ['scripts', 'references'].filter((d) => !existsSync(join(storeAbs, d)));
90
+ // MIGRATION: the skill no longer ships code. An install carried over from
91
+ // the pre-MCP layout still has a scripts/ tree — dead weight that would
92
+ // otherwise read as "diverges from packaged skill (customized?)".
93
+ // Pruned BEFORE the divergence check so an upgrade is not misreported.
94
+ const packaged = (() => {
95
+ try { return resolveSkillSource(packageRoot); } catch { return null; }
96
+ })();
97
+ if (packaged) {
98
+ // --check reports WITHOUT mutating; repair mode prunes.
99
+ const obsolete = findObsolete(storeAbs, packaged);
100
+ if (obsolete.length > 0) {
101
+ if (checkOnly) {
102
+ for (const rel of obsolete) {
103
+ add('WOULD-FIX', `store carried the obsolete ${rel}/ tree (pre-MCP layout) -> would prune`);
104
+ }
105
+ } else {
106
+ // Pruning DELETES inside the store: refuse when the store (or one of
107
+ // its parents) is a symlink — the prune would otherwise reach
108
+ // outside the project through the link.
109
+ try {
110
+ assertNotSymlink(storeAbs);
111
+ assertWithinRoot(root, storeAbs);
112
+ pruneObsolete(storeAbs, packaged);
113
+ for (const rel of obsolete) {
114
+ add('FIXED', `store carried the obsolete ${rel}/ tree (pre-MCP layout) -> pruned`);
115
+ }
116
+ } catch (e) {
117
+ add('FAIL', `cannot prune the obsolete store tree: ${e.message} - fix manually`);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ const missingCore = ['references'].filter((d) => !existsSync(join(storeAbs, d)));
87
123
  if (missingCore.length > 0) {
88
124
  // Deferred so the managed-block FAIL rows below stay FIRST: existing
89
125
  // consumers read checks.find(FAIL) expecting the marker verdict.
@@ -109,7 +145,12 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
109
145
  if (checkOnly) {
110
146
  add('WOULD-FIX', `store carries dev-only ${devOnlyPresent.join(', ')} -> pruned`);
111
147
  } else {
112
- attempt(() => pruneDevOnly(storeAbs), 'FIXED',
148
+ // Same rule as the obsolete prune: never delete through a symlink.
149
+ attempt(() => {
150
+ assertNotSymlink(storeAbs);
151
+ assertWithinRoot(root, storeAbs);
152
+ pruneDevOnly(storeAbs);
153
+ }, 'FIXED',
113
154
  `dev-only ${devOnlyPresent.join(', ')} pruned from store`, 'cannot prune dev-only artifacts');
114
155
  }
115
156
  }
@@ -137,6 +178,10 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
137
178
  return;
138
179
  }
139
180
  try {
181
+ // Escape check FIRST, exactly as init does: a symlinked .claude/
182
+ // .claude/skills parent must not redirect the recreated link outside
183
+ // the project root.
184
+ assertWithinRoot(root, dirname(claudeAbs));
140
185
  try { unlinkSync(claudeAbs); } catch { /* absent */ }
141
186
  mkdirSync(dirname(claudeAbs), { recursive: true });
142
187
  symlinkSync('../../.agents/skills/archgen', claudeAbs);
@@ -169,60 +214,66 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
169
214
  'AGENTS.md created with managed block', 'cannot create AGENTS.md managed block');
170
215
  }
171
216
  } else {
172
- let raw = readFileSync(agentsAbs, 'utf8');
173
- const starts = countOccurrences(raw, START);
174
- const ends = countOccurrences(raw, END);
175
- if (starts === 0 && ends === 0) {
176
- if (checkOnly) add('WOULD-FIX', 'AGENTS.md has no archgen block -> added');
177
- else {
178
- attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
179
- 'AGENTS.md managed block added', 'cannot add AGENTS.md managed block');
180
- }
181
- } else if (starts > 1 || ends > 1) {
182
- add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
183
- } else if (starts === 0 || ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
184
- // Orphan/inverted markers: repairing through upsertBlock would throw, so
185
- // classify as FAIL instead of attempting the write.
186
- add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
187
- } else {
188
- // Block-format currency: an unversioned or older-stamped managed block is
189
- // upgraded in place through the normal upsert path (everything outside the
190
- // markers survives byte-for-byte by design). Newer stamps are left alone.
191
- const det = detectBlockVersion(raw);
192
- const parsed = det.version ? parseSemver(det.version) : null;
193
- const stale = !det.present || !parsed || compareSemver(det.version, version) < 0;
194
- if (stale) {
195
- const from = det.version ? 'v' + det.version : 'unversioned';
196
- if (checkOnly) {
197
- add('WOULD-FIX', `WOULD-UPGRADE: AGENTS.md block ${from} -> v${version}`);
198
- } else {
199
- let upgraded = false;
200
- attempt(() => { upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')); upgraded = true; },
201
- 'FIXED', `UPGRADED: AGENTS.md block ${from} -> v${version}`, 'cannot upgrade AGENTS.md block');
202
- if (upgraded) raw = readFileSync(agentsAbs, 'utf8');
203
- }
204
- }
205
- const fsCount = countOccurrences(raw, FEATURES_START);
206
- const feCount = countOccurrences(raw, FEATURES_END);
207
- const fsIdx = raw.indexOf(FEATURES_START);
208
- const feIdx = raw.indexOf(FEATURES_END);
209
- if (fsCount === 1 && feCount === 1 && fsIdx < feIdx) {
210
- add('OK', 'AGENTS.md block + features registry present');
211
- } else if (fsCount === 1 && feCount === 1) {
212
- // Counts are right but ORDER is inverted (end before start): any
213
- // registry write would throw (upsertFeaturesRegistry requires
214
- // end > start), so flag the state instead of blessing it as OK.
215
- if (checkOnly) add('WOULD-FIX', 'AGENTS.md archgen:features markers are reversed -> swap them');
216
- else add('FAIL', 'AGENTS.md archgen:features markers are reversed - fix manually');
217
- } else if (fsCount === 0 && feCount === 0) {
218
- if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing features registry -> inserted');
217
+ // An unreadable managed file is a FAIL row, not a reason to abort the
218
+ // whole doctor pass.
219
+ try {
220
+ let raw = readFileSync(agentsAbs, 'utf8');
221
+ const starts = countOccurrences(raw, START);
222
+ const ends = countOccurrences(raw, END);
223
+ if (starts === 0 && ends === 0) {
224
+ if (checkOnly) add('WOULD-FIX', 'AGENTS.md has no archgen block -> added');
219
225
  else {
220
- attempt(() => upsertFeaturesRegistry(agentsAbs, []), 'FIXED',
221
- 'AGENTS.md features registry inserted', 'cannot insert AGENTS.md features registry');
226
+ attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
227
+ 'AGENTS.md managed block added', 'cannot add AGENTS.md managed block');
222
228
  }
229
+ } else if (starts > 1 || ends > 1) {
230
+ add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
231
+ } else if (starts === 0 || ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
232
+ // Orphan/inverted markers: repairing through upsertBlock would throw, so
233
+ // classify as FAIL instead of attempting the write.
234
+ add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
223
235
  } else {
224
- add('FAIL', 'AGENTS.md has broken archgen:features markers - fix manually');
236
+ // Block-format currency: an unversioned or older-stamped managed block is
237
+ // upgraded in place through the normal upsert path (everything outside the
238
+ // markers survives byte-for-byte by design). Newer stamps are left alone.
239
+ const det = detectBlockVersion(raw);
240
+ const parsed = det.version ? parseSemver(det.version) : null;
241
+ const stale = !det.present || !parsed || compareSemver(det.version, version) < 0;
242
+ if (stale) {
243
+ const from = det.version ? 'v' + det.version : 'unversioned';
244
+ if (checkOnly) {
245
+ add('WOULD-FIX', `WOULD-UPGRADE: AGENTS.md block ${from} -> v${version}`);
246
+ } else {
247
+ let upgraded = false;
248
+ attempt(() => { upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')); upgraded = true; },
249
+ 'FIXED', `UPGRADED: AGENTS.md block ${from} -> v${version}`, 'cannot upgrade AGENTS.md block');
250
+ if (upgraded) raw = readFileSync(agentsAbs, 'utf8');
251
+ }
252
+ }
253
+ const fsCount = countOccurrences(raw, FEATURES_START);
254
+ const feCount = countOccurrences(raw, FEATURES_END);
255
+ const fsIdx = raw.indexOf(FEATURES_START);
256
+ const feIdx = raw.indexOf(FEATURES_END);
257
+ if (fsCount === 1 && feCount === 1 && fsIdx < feIdx) {
258
+ add('OK', 'AGENTS.md block + features registry present');
259
+ } else if (fsCount === 1 && feCount === 1) {
260
+ // Counts are right but ORDER is inverted (end before start): any
261
+ // registry write would throw (upsertFeaturesRegistry requires
262
+ // end > start), so flag the state instead of blessing it as OK.
263
+ if (checkOnly) add('WOULD-FIX', 'AGENTS.md archgen:features markers are reversed -> swap them');
264
+ else add('FAIL', 'AGENTS.md archgen:features markers are reversed - fix manually');
265
+ } else if (fsCount === 0 && feCount === 0) {
266
+ if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing features registry -> inserted');
267
+ else {
268
+ attempt(() => upsertFeaturesRegistry(agentsAbs, []), 'FIXED',
269
+ 'AGENTS.md features registry inserted', 'cannot insert AGENTS.md features registry');
270
+ }
271
+ } else {
272
+ add('FAIL', 'AGENTS.md has broken archgen:features markers - fix manually');
273
+ }
225
274
  }
275
+ } catch (e) {
276
+ add('FAIL', 'cannot read AGENTS.md: ' + e.message + ' - fix manually');
226
277
  }
227
278
  }
228
279
 
@@ -234,18 +285,44 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
234
285
  'CLAUDE.md created with @AGENTS.md bridge', 'cannot create CLAUDE.md bridge');
235
286
  }
236
287
  } else {
237
- const raw = readFileSync(claudeMdAbs, 'utf8');
238
- const body = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
239
- const imports = body.split(/\r?\n/).some((l) => l.trim() === '@AGENTS.md');
240
- const starts = countOccurrences(raw, START);
241
- const ends = countOccurrences(raw, END);
242
- if (imports && starts <= 1 && ends <= 1) add('OK', 'CLAUDE.md bridges to AGENTS.md');
243
- else if (starts > 1 || ends > 1) add('FAIL', 'CLAUDE.md has multiple archgen blocks - fix manually');
244
- else if (starts !== ends) add('FAIL', 'CLAUDE.md has broken archgen markers - fix manually');
245
- else if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing @AGENTS.md bridge -> written');
246
- else {
247
- attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
248
- 'CLAUDE.md @AGENTS.md bridge written', 'cannot write CLAUDE.md bridge');
288
+ // An unreadable managed file is a FAIL row, not a reason to abort the
289
+ // whole doctor pass.
290
+ let raw = null;
291
+ try {
292
+ raw = readFileSync(claudeMdAbs, 'utf8');
293
+ } catch (e) {
294
+ add('FAIL', 'cannot read CLAUDE.md: ' + e.message + ' - fix manually');
295
+ }
296
+ if (raw !== null) {
297
+ const body = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
298
+ const imports = body.split(/\r?\n/).some((l) => l.trim() === '@AGENTS.md');
299
+ const starts = countOccurrences(raw, START);
300
+ const ends = countOccurrences(raw, END);
301
+ if (starts > 1 || ends > 1) add('FAIL', 'CLAUDE.md has multiple archgen blocks - fix manually');
302
+ else if (starts !== ends) add('FAIL', 'CLAUDE.md has broken archgen markers - fix manually');
303
+ else if (starts === 1) {
304
+ // The managed block is ours: keep its provenance stamp current, the
305
+ // same way the AGENTS.md block is kept current.
306
+ const det = detectBlockVersion(raw);
307
+ const parsed = det.version ? parseSemver(det.version) : null;
308
+ const stale = !det.present || !parsed || compareSemver(det.version, version) < 0;
309
+ if (stale) {
310
+ const from = det.version ? 'v' + det.version : 'unversioned';
311
+ if (checkOnly) {
312
+ add('WOULD-FIX', `WOULD-UPGRADE: CLAUDE.md block ${from} -> v${version}`);
313
+ } else {
314
+ attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
315
+ `UPGRADED: CLAUDE.md block ${from} -> v${version}`, 'cannot upgrade CLAUDE.md block');
316
+ }
317
+ } else {
318
+ add('OK', 'CLAUDE.md bridges to AGENTS.md');
319
+ }
320
+ } else if (imports) add('OK', 'CLAUDE.md bridges to AGENTS.md');
321
+ else if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing @AGENTS.md bridge -> written');
322
+ else {
323
+ attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
324
+ 'CLAUDE.md @AGENTS.md bridge written', 'cannot write CLAUDE.md bridge');
325
+ }
249
326
  }
250
327
  }
251
328
 
@@ -256,8 +333,23 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
256
333
  if (!manifest) {
257
334
  if (existsSync(storeAbs)) add('WARN', `no install manifest at ${MANIFEST_REL} (re-run init to regenerate)`);
258
335
  else add('OK', 'no manifest (nothing installed)');
336
+ } else if (!Array.isArray(manifest.entries)) {
337
+ // A manifest without an entries array is malformed: report it instead of
338
+ // crashing the whole doctor pass on it.
339
+ add('FAIL', `manifest ${MANIFEST_REL} has no entries array - fix manually`);
259
340
  } else {
260
- const resolves = (e) => e && typeof e.path === 'string' && existsSync(join(root, ...e.path.split('/')));
341
+ // An entry only "resolves" when its path stays inside the project root:
342
+ // a manifest path like `../../etc/passwd` must never count as resolved.
343
+ const entryAbs = (rel) => {
344
+ if (typeof rel !== 'string' || rel === '') return null;
345
+ const abs = resolve(root, ...rel.split('/'));
346
+ if (abs !== root && !abs.startsWith(root + sep)) return null;
347
+ return abs;
348
+ };
349
+ const resolves = (e) => {
350
+ const abs = entryAbs(e && e.path);
351
+ return abs !== null && existsSync(abs);
352
+ };
261
353
  const stale = manifest.entries.filter((e) => !resolves(e));
262
354
  if (stale.length === 0) {
263
355
  add('OK', `manifest: all ${manifest.entries.length} entr${manifest.entries.length === 1 ? 'y' : 'ies'} resolve`);
@@ -296,7 +388,35 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
296
388
  add(v ? 'OK' : 'WARN', `skill ${p}: ${slot}`);
297
389
  }
298
390
 
391
+ // 8. MCP server wiring — the archgen MCP server is what actually gives the
392
+ // agent its tools. A skill with no MCP entry is a skill the agent cannot
393
+ // use, so this is a first-class check, not a footnote.
394
+ //
395
+ // READ-ONLY and NEVER FATAL: doctor must work on a machine with no npm, no
396
+ // network, and no MCP server installed. The MCP server is asked for its own
397
+ // report; if it cannot run, that is a WARN with the remediation command.
398
+ const mcp = mcpDoctor({ projectRoot: root, env: { ...process.env, HOME: homeDir } });
399
+ if (!mcp.report) {
400
+ add('WARN', `archgen MCP server not reachable${mcp.detail ? ` — ${mcp.detail}` : ''} — install with: npx archgen-mcp bootstrap --project . --yes`);
401
+ } else {
402
+ const rep = mcp.report;
403
+ add(
404
+ rep.skill ? 'OK' : 'WARN',
405
+ rep.skill
406
+ ? `archgen MCP server v${rep.server} ready (skill ${rep.skill.version ?? 'unknown'})`
407
+ : 'archgen MCP server ready, but no skill was found',
408
+ );
409
+ const wired = (rep.mcpTargets ?? []).filter((t) => t.exists).length;
410
+ if (wired === 0) {
411
+ add('WARN', 'archgen MCP is not wired into any harness config — run: npx archgen-mcp bootstrap --project . --yes');
412
+ } else {
413
+ add('OK', `archgen MCP wiring present in ${wired} harness config(s)`);
414
+ }
415
+ if (rep.codegraph) add('OK', `codegraph ${rep.codegraph.version}`);
416
+ else add('WARN', 'codegraph not installed — run: npx archgen-mcp bootstrap --project . --yes');
417
+ }
418
+
299
419
  const tally = { OK: 0, FIXED: 0, 'WOULD-FIX': 0, WARN: 0, FAIL: 0 };
300
420
  for (const c of checks) tally[c.status]++;
301
- return { root, checks, tally, failures: opFailures };
421
+ return { root, checks, tally, failures };
302
422
  }
package/lib/init.js CHANGED
@@ -17,12 +17,13 @@
17
17
  // to a symlink; a customized one is left untouched with a warning;
18
18
  // - installs are atomic: built in a temp sibling dir, then renamed.
19
19
 
20
- import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
20
+ import { cpSync, existsSync, lstatSync, mkdirSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
21
21
  import { randomBytes } from 'node:crypto';
22
22
  import { dirname, join, resolve } from 'node:path';
23
- import { importsAgents, renderBlock, START, END, upsertManagedFile } from './block.js';
23
+ import { importsAgents, hasManagedBlock, renderBlock, START, END, upsertManagedFile } from './block.js';
24
24
  import { CLAUDE_LINK_REL, STORE_REL, VERSION_FILE, appendEntry, assertNotSymlink, assertWithinRoot, cliVersion, hashDir, moveToBackup, pruneDevOnly } from './store.js';
25
25
  import { writeStamp } from './version-stamp.js';
26
+ import { bootstrapServers } from './bootstrap.js';
26
27
 
27
28
  const CONTEXT_FILES = ['AGENTS.md', 'CLAUDE.md'];
28
29
 
@@ -56,6 +57,12 @@ function relJoin(rel) {
56
57
  function installClaudeAdapter(root, source, warnings) {
57
58
  const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
58
59
  const storeAbs = join(root, relJoin(STORE_REL));
60
+
61
+ // Escape check FIRST — before anything is removed or created: a symlinked
62
+ // .claude/.claude/skills parent must not redirect the adapter (nor the
63
+ // removal of a legacy copy) outside the project root.
64
+ assertWithinRoot(root, dirname(claudeAbs));
65
+
59
66
  const st = lstatSafe(claudeAbs);
60
67
  let migrating = false;
61
68
 
@@ -80,9 +87,6 @@ function installClaudeAdapter(root, source, warnings) {
80
87
  return 'kept-divergent';
81
88
  }
82
89
 
83
- // Escape check: a symlinked .claude/.claude/skills parent must not redirect
84
- // the adapter creation outside the project root.
85
- assertWithinRoot(root, claudeAbs);
86
90
  try {
87
91
  mkdirSync(dirname(claudeAbs), { recursive: true });
88
92
  symlinkSync('../../.agents/skills/archgen', claudeAbs);
@@ -99,8 +103,11 @@ function installClaudeAdapter(root, source, warnings) {
99
103
  * claude adapter, and write context pointers.
100
104
  * @param {string} projectDir target repo root
101
105
  * @param {string} packageRoot dir containing this CLI package
102
- * @param {{force?: boolean}} opts --force proceeds automatically; divergent
103
- * copies are always auto-backed-up (never refused, never destroyed)
106
+ * @param {{force?: boolean, codegraph?: boolean, mcp?: boolean, log?: (s: string) => void, env?: NodeJS.ProcessEnv}} opts
107
+ * --force proceeds automatically; divergent copies are always auto-backed-up
108
+ * (never refused, never destroyed). opts.codegraph === false opts OUT of the
109
+ * automatic CodeGraph bootstrap (install + MCP wiring + index build);
110
+ * opts.mcp === false opts OUT of the archgen MCP wiring.
104
111
  * @returns {{storePath: string, claudeLink: 'created'|'existing'|'migrated'|'skipped'|'kept-divergent',
105
112
  * backups: string[], contextFiles: string[], createdContextFiles: string[], warnings: string[]}}
106
113
  */
@@ -110,6 +117,9 @@ export function initProject(projectDir, packageRoot, opts = {}) {
110
117
  // both plain and --force runs take the same safe path.
111
118
  const force = opts.force === true;
112
119
  void force;
120
+ const codegraphAllowed = opts.codegraph !== false;
121
+ const mcpAllowed = opts.mcp !== false;
122
+ const log = opts.log ?? console.log;
113
123
 
114
124
  const root = resolve(projectDir);
115
125
  const source = resolveSkillSource(packageRoot);
@@ -127,21 +137,44 @@ export function initProject(projectDir, packageRoot, opts = {}) {
127
137
 
128
138
  // 1. Canonical store — fingerprint before mutating (ownership check).
129
139
  const prev = lstatSafe(storeAbs);
130
- if (prev) {
131
- const identical = prev.isDirectory() && !prev.isSymbolicLink()
132
- && hashDir(storeAbs, { ignore: [VERSION_FILE] }) === hashDir(source);
133
- if (!identical) backups.push(moveToBackup(root, STORE_REL));
134
- }
140
+ const identical = !!(prev && prev.isDirectory() && !prev.isSymbolicLink()
141
+ && hashDir(storeAbs, { ignore: [VERSION_FILE] }) === hashDir(source));
135
142
 
136
- // Atomic install: build in a temp sibling dir, then rename into place.
143
+ // Atomic install: build in a temp sibling dir, then swap it into place.
144
+ // The current store is renamed aside (identical) or moved into the backup
145
+ // vault (divergent) so the swap can be undone: a failed swap puts the old
146
+ // store back, and a failed copy never leaves an orphan temp directory.
137
147
  const skillsParent = dirname(storeAbs);
138
148
  mkdirSync(skillsParent, { recursive: true });
139
149
  const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
140
- cpSync(source, tmp, { recursive: true });
141
- pruneDevOnly(tmp); // dev-only artifacts (scripts/test) never reach the installed store
142
- writeStamp(tmp, version);
143
- rmSync(storeAbs, { recursive: true, force: true });
144
- renameSync(tmp, storeAbs);
150
+ const aside = join(skillsParent, '.archgen-old-' + randomBytes(6).toString('hex'));
151
+ let asideUsed = false;
152
+ try {
153
+ cpSync(source, tmp, { recursive: true });
154
+ pruneDevOnly(tmp); // dev-only artifacts (scripts/test) never reach the installed store
155
+ writeStamp(tmp, version);
156
+ if (lstatSafe(storeAbs)) {
157
+ if (identical) {
158
+ renameSync(storeAbs, aside);
159
+ asideUsed = true;
160
+ } else {
161
+ backups.push(moveToBackup(root, STORE_REL));
162
+ }
163
+ }
164
+ renameSync(tmp, storeAbs);
165
+ } catch (e) {
166
+ if (asideUsed) {
167
+ // Put the previous store back: a failed swap must never leave the
168
+ // project without a store.
169
+ try {
170
+ renameSync(aside, storeAbs);
171
+ asideUsed = false;
172
+ } catch { /* keep it aside rather than deleting the user's data */ }
173
+ }
174
+ rmSync(tmp, { recursive: true, force: true });
175
+ throw e;
176
+ }
177
+ if (asideUsed) rmSync(aside, { recursive: true, force: true });
145
178
  appendEntry(root, 'dir', STORE_REL);
146
179
 
147
180
  // 2. Claude adapter — relative symlink, never a second copy.
@@ -152,7 +185,10 @@ export function initProject(projectDir, packageRoot, opts = {}) {
152
185
  const createdContextFiles = [];
153
186
  for (const name of CONTEXT_FILES) {
154
187
  const abs = join(root, name);
155
- if (name === 'CLAUDE.md' && importsAgents(abs)) {
188
+ // A hand-written `@AGENTS.md` import is the user's — leave it alone. A
189
+ // managed block is ours: refresh it so its provenance stamp tracks the
190
+ // running CLI instead of freezing at the first-ever install.
191
+ if (name === 'CLAUDE.md' && !hasManagedBlock(abs) && importsAgents(abs)) {
156
192
  warnings.push('CLAUDE.md already imports @AGENTS.md - skipped (already-imports)');
157
193
  continue;
158
194
  }
@@ -165,5 +201,27 @@ export function initProject(projectDir, packageRoot, opts = {}) {
165
201
  appendEntry(root, 'block', name);
166
202
  }
167
203
 
204
+ // 4. Server bootstrap — wire the archgen MCP server into every detected
205
+ // harness AND ensure CodeGraph (its CLI, its MCP entry, the project
206
+ // index). Non-fatal by design: an offline machine still gets the skill
207
+ // installed.
208
+ if (codegraphAllowed || mcpAllowed) {
209
+ const r = bootstrapServers({
210
+ projectDir: root,
211
+ location: 'both',
212
+ log,
213
+ env: opts.env,
214
+ noCodegraph: !codegraphAllowed,
215
+ noMcp: !mcpAllowed,
216
+ });
217
+ if (!r.ok) {
218
+ warnings.push(`server bootstrap incomplete${r.detail ? ': ' + r.detail : ''} — install later with: npx archgen-mcp bootstrap --project . --yes`);
219
+ } else if (r.skipped) {
220
+ warnings.push(`server bootstrap skipped (${r.skipped})`);
221
+ }
222
+ } else {
223
+ warnings.push('server bootstrap skipped (--no-codegraph --no-mcp)');
224
+ }
225
+
168
226
  return { storePath: storeAbs, claudeLink, backups, contextFiles, createdContextFiles, warnings };
169
227
  }
package/lib/install.js CHANGED
@@ -10,6 +10,7 @@ import { resolveSkillSource } from './init.js';
10
10
  import { assertNotSymlink, assertWithinRoot, cliVersion, hashDir, moveToBackupInto, pruneDevOnly, refuseSymlinkMessage } from './store.js';
11
11
  import { writeStamp } from './version-stamp.js';
12
12
  import { loadConfig } from './config.js';
13
+ import { bootstrapServers } from './bootstrap.js';
13
14
 
14
15
  // Module-anchored default: works from the npm package (cli/lib -> cli/vendor)
15
16
  // AND from a monorepo checkout (packages/cli/lib -> ../../skill) without depending on cwd.
@@ -58,7 +59,7 @@ function nextBackupTs(tdir) {
58
59
  }
59
60
 
60
61
  /**
61
- * @param {{copy?: boolean, home?: string, packageRoot?: string}} opts
62
+ * @param {{copy?: boolean, home?: string, packageRoot?: string, codegraph?: boolean, mcp?: boolean, log?: (s: string) => void, env?: NodeJS.ProcessEnv}} opts
62
63
  * @returns {{rows: Array<[string, string, string]>, failures: number, manifest: string,
63
64
  * backups: string[]}} rows are [STATUS, MODE, PATH]; `backups` lists
64
65
  * absolute vault locations written by this run.
@@ -165,6 +166,24 @@ export function installGlobal(opts = {}) {
165
166
  rows.push(['FAILED', '-', dest + ' (' + e.message + ')']);
166
167
  }
167
168
  }
169
+ // Server bootstrap — GLOBAL wiring only (project-local configs belong to
170
+ // `init`). Wires the archgen MCP server and CodeGraph into every DETECTED
171
+ // global harness. Non-fatal: an offline machine still gets the skill
172
+ // installed.
173
+ if (opts.codegraph !== false || opts.mcp !== false) {
174
+ const r = bootstrapServers({
175
+ projectDir: process.cwd(),
176
+ location: 'global',
177
+ log: opts.log ?? console.log,
178
+ env: opts.env,
179
+ noCodegraph: opts.codegraph === false,
180
+ noMcp: opts.mcp === false,
181
+ });
182
+ if (!r.ok) {
183
+ rows.push(['WARN', '-', 'server bootstrap incomplete' + (r.detail ? ` (${r.detail})` : '') + ' — run: npx archgen-mcp bootstrap --location global --yes']);
184
+ }
185
+ }
186
+
168
187
  return { rows, failures, manifest, backups };
169
188
  }
170
189
 
@@ -177,17 +196,70 @@ function stampNote(rows, dest, write) {
177
196
  }
178
197
  }
179
198
 
180
- export function uninstallGlobal(home = homedir()) {
199
+ /**
200
+ * The exact destinations a global install may create.
201
+ *
202
+ * A manifest entry is acted on ONLY when it names one of them. A plain
203
+ * suffix test (`endsWith('skills/archgen')`) would let any path with that
204
+ * ending be removed — including paths outside the known harness dirs.
205
+ */
206
+ function allowedGlobalDestinations(home) {
207
+ const set = new Set(globalTargets(home).map((d) => resolve(join(d, 'archgen'))));
208
+ set.add(resolve(join(process.cwd(), '.github', 'skills', 'archgen')));
209
+ return set;
210
+ }
211
+
212
+ /**
213
+ * Move an edited copy-mode install into the global backup vault before it is
214
+ * removed: the uninstall must never be the last place those edits exist.
215
+ * An install that still matches the packaged skill needs no backup.
216
+ */
217
+ function backupEditedCopy(dest, packageRoot, backups) {
218
+ let identical = false;
219
+ try {
220
+ identical = hashDir(dest) === hashDir(resolveSkillSource(packageRoot));
221
+ } catch {
222
+ identical = false; // unverifiable -> preserve it
223
+ }
224
+ if (identical) return;
225
+ const tdir = dirname(dest);
226
+ const loc = moveToBackupInto(tdir, 'archgen', GLOBAL_BACKUP_REL, nextBackupTs(tdir));
227
+ backups.push(join(tdir, ...loc.split('/')));
228
+ }
229
+
230
+ /**
231
+ * Remove the globally-installed copies recorded in the manifest.
232
+ *
233
+ * Only entries that name a known harness destination are removed. An entry
234
+ * that cannot be verified is SKIPPED and KEPT in the manifest — the manifest
235
+ * is only removed when nothing was left behind.
236
+ *
237
+ * @param {string} home home directory holding the manifest
238
+ * @param {{packageRoot?: string}} opts
239
+ */
240
+ export function uninstallGlobal(home = homedir(), opts = {}) {
181
241
  const manifest = join(home, MANIFEST_NAME);
182
242
  if (!existsSync(manifest)) return { removed: 0, failed: 0, noop: true };
243
+ const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
244
+ const allowed = allowedGlobalDestinations(home);
183
245
  let removed = 0, failed = 0;
246
+ const kept = [];
247
+ const backups = [];
184
248
  for (const line of readFileSync(manifest, 'utf8').split('\n')) {
185
249
  if (!line.trim()) continue;
186
250
  const [kind, p] = line.split('\t');
187
- if (!p || !p.endsWith(join('skills', 'archgen'))) { failed++; continue; } // safety guard
188
- try { rmSync(p, { force: true, recursive: true }); removed++; }
189
- catch { failed++; }
251
+ if (!p || !allowed.has(resolve(p))) { failed++; kept.push(line); continue; } // safety guard
252
+ try {
253
+ if (kind === 'copy') backupEditedCopy(p, packageRoot, backups);
254
+ rmSync(p, { force: true, recursive: true }); removed++;
255
+ } catch { failed++; kept.push(line); }
256
+ }
257
+ if (kept.length === 0) {
258
+ try { rmSync(manifest, { force: true }); } catch { failed++; }
259
+ } else {
260
+ // The skipped entries stay recorded: the manifest is the only thing that
261
+ // knows they were never removed.
262
+ try { writeFileSync(manifest, kept.join('\n') + '\n'); } catch { failed++; }
190
263
  }
191
- rmSync(manifest, { force: true });
192
- return { removed, failed, noop: false };
264
+ return { removed, failed, noop: false, backups };
193
265
  }