archgen-skill 0.0.3 → 0.0.5

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 (33) hide show
  1. package/README.md +31 -33
  2. package/bin/archgen.mjs +31 -1
  3. package/lib/block.js +147 -16
  4. package/lib/config.js +96 -0
  5. package/lib/doctor.js +144 -28
  6. package/lib/init.js +14 -3
  7. package/lib/install.js +124 -16
  8. package/lib/migrate.js +88 -0
  9. package/lib/migrations/001-stamp-provenance.mjs +77 -0
  10. package/lib/migrations/index.mjs +170 -0
  11. package/lib/restore.js +239 -0
  12. package/lib/store.js +125 -15
  13. package/lib/uninstall-project.js +6 -3
  14. package/lib/version-stamp.js +49 -0
  15. package/package.json +39 -10
  16. package/vendor/skills/archgen/SKILL.md +218 -145
  17. package/vendor/skills/archgen/archgen.config.json +16 -0
  18. package/vendor/skills/archgen/assets/skill-registry.json +4 -3
  19. package/vendor/skills/archgen/references/artifact-templates.md +308 -7
  20. package/vendor/skills/archgen/references/interview.md +226 -17
  21. package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
  22. package/vendor/skills/archgen/references/orchestration.md +230 -44
  23. package/vendor/skills/archgen/references/platforms.md +56 -14
  24. package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
  25. package/vendor/skills/archgen/scripts/impact.mjs +14 -1
  26. package/vendor/skills/archgen/scripts/lib/graph.mjs +99 -5
  27. package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
  28. package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
  29. package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
  30. package/vendor/skills/archgen/scripts/set-status.mjs +51 -9
  31. package/vendor/skills/archgen/scripts/update-agents.mjs +16 -5
  32. package/vendor/skills/archgen/scripts/validate.mjs +18 -1
  33. package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
package/lib/doctor.js CHANGED
@@ -3,20 +3,28 @@
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). Exit non-zero only for unrepairable failures.
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.
7
9
 
8
- import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
10
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
9
12
  import { dirname, join, resolve } from 'node:path';
13
+ import { globalTargets } from './install.js';
10
14
  import {
11
15
  END,
12
16
  FEATURES_END,
13
17
  FEATURES_START,
14
18
  START,
19
+ detectBlockVersion,
15
20
  renderBlock,
16
21
  upsertFeaturesRegistry,
17
22
  upsertManagedFile,
18
23
  } from './block.js';
19
- import { CLAUDE_LINK_REL, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, loadManifest, saveManifest } from './store.js';
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';
26
+ import { compareSemver, parseSemver } from './version.js';
27
+ import { readStamp, writeStamp } from './version-stamp.js';
20
28
 
21
29
  function lstatSafe(p) {
22
30
  try { return lstatSync(p); } catch { return null; }
@@ -35,27 +43,76 @@ function relJoin(rel) {
35
43
  /**
36
44
  * @param {string} projectDir project root to inspect
37
45
  * @param {string} packageRoot dir containing this CLI package
38
- * @param {{check?: boolean}} opts check=true → report-only, no mutations
46
+ * @param {{check?: boolean, home?: string}} opts check=true → report-only, no
47
+ * mutations; home overrides the homedir scanned for global installs (tests)
39
48
  * @returns {{root: string, checks: {status: 'OK'|'FIXED'|'WOULD-FIX'|'WARN'|'FAIL', msg: string}[],
40
- * tally: Record<string, number>, failures: number}}
49
+ * 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.
41
52
  */
42
53
  export function doctorProject(projectDir, packageRoot, opts = {}) {
43
54
  const root = resolve(projectDir);
44
55
  const checkOnly = opts.check === true;
56
+ const homeDir = opts.home ?? homedir();
45
57
  const version = cliVersion(packageRoot);
46
58
  const checks = [];
47
59
  const add = (status, msg) => checks.push({ status, msg });
60
+ let opFailures = 0;
61
+ // 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).
63
+ const attempt = (fn, okStatus, okMsg, failLabel) => {
64
+ try {
65
+ fn();
66
+ add(okStatus, okMsg);
67
+ } catch (e) {
68
+ opFailures++;
69
+ add('FAIL', `${failLabel}: ${e.message} - fix manually`);
70
+ }
71
+ };
48
72
 
49
73
  const storeAbs = join(root, relJoin(STORE_REL));
50
74
  const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
51
75
  const agentsAbs = join(root, 'AGENTS.md');
52
76
  const claudeMdAbs = join(root, 'CLAUDE.md');
53
77
 
54
- // 1. Canonical store validity.
55
- if (existsSync(join(storeAbs, 'SKILL.md'))) {
56
- add('OK', 'store .agents/skills/archgen (SKILL.md present)');
57
- } else {
78
+ // 1. Canonical store validity — SKILL.md alone is not enough: a store
79
+ // missing whole subtrees (scripts/, references/) green-lights installs
80
+ // whose entry points are gone. Divergence from the packaged skill is a
81
+ // WARN, not a failure: user customization is legitimate.
82
+ const deferredStoreFindings = [];
83
+ if (!existsSync(join(storeAbs, 'SKILL.md'))) {
58
84
  add('FAIL', 'store .agents/skills/archgen missing or incomplete - run `archgen init`');
85
+ } else {
86
+ const missingCore = ['scripts', 'references'].filter((d) => !existsSync(join(storeAbs, d)));
87
+ if (missingCore.length > 0) {
88
+ // Deferred so the managed-block FAIL rows below stay FIRST: existing
89
+ // consumers read checks.find(FAIL) expecting the marker verdict.
90
+ deferredStoreFindings.push(['FAIL', `store .agents/skills/archgen is missing core ${missingCore.map((d) => d + '/').join(', ')} - run \`archgen init\``]);
91
+ } else {
92
+ let diverged = false;
93
+ try {
94
+ diverged = hashDir(storeAbs) !== hashDir(resolveSkillSource(packageRoot));
95
+ } catch { /* packaged skill unavailable — skip the divergence probe */ }
96
+ if (diverged) add('WARN', 'store .agents/skills/archgen diverges from packaged skill (customized?)');
97
+ else add('OK', 'store .agents/skills/archgen (SKILL.md present)');
98
+ }
99
+ }
100
+
101
+ // 1b. Prune dev-only artifacts (scripts/test) that older installers shipped
102
+ // into the store. They are repo-only CI tooling and never belong in an
103
+ // installed tree; removing them here cleans existing stores without a
104
+ // full backup+re-copy. hashDir ignores the same subtrees, so this prune
105
+ // neither masks real customization nor reads as divergence.
106
+ if (existsSync(storeAbs)) {
107
+ const devOnlyPresent = DEV_ONLY_RELPATHS.filter((rel) => existsSync(join(storeAbs, ...rel.split('/'))));
108
+ if (devOnlyPresent.length > 0) {
109
+ if (checkOnly) {
110
+ add('WOULD-FIX', `store carries dev-only ${devOnlyPresent.join(', ')} -> pruned`);
111
+ } else {
112
+ attempt(() => pruneDevOnly(storeAbs), 'FIXED',
113
+ `dev-only ${devOnlyPresent.join(', ')} pruned from store`, 'cannot prune dev-only artifacts');
114
+ }
115
+ }
59
116
  }
60
117
 
61
118
  // 2. Version stamp currency.
@@ -69,9 +126,8 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
69
126
  } else if (checkOnly) {
70
127
  add('WOULD-FIX', `version stamp ${stamp || 'missing'} is stale -> ${version}`);
71
128
  } else {
72
- mkdirSync(dirname(stampAbs), { recursive: true });
73
- writeFileSync(stampAbs, version + '\n');
74
- add('FIXED', `version stamp updated ${stamp || 'missing'} -> ${version}`);
129
+ attempt(() => writeStamp(storeAbs, version), 'FIXED',
130
+ `version stamp updated ${stamp || 'missing'} -> ${version}`, 'cannot write version stamp');
75
131
  }
76
132
 
77
133
  // 3. Claude link resolution.
@@ -107,32 +163,62 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
107
163
  if (!existsSync(agentsAbs)) {
108
164
  if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing -> created with managed block');
109
165
  else {
110
- writeFileSync(agentsAbs, renderBlock(STORE_REL) + '\n');
111
- add('FIXED', 'AGENTS.md created with managed block');
166
+ // Through the guarded upsert (not raw writeFileSync): existsSync follows
167
+ // symlinks, so a dangling symlink would otherwise be written through.
168
+ attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
169
+ 'AGENTS.md created with managed block', 'cannot create AGENTS.md managed block');
112
170
  }
113
171
  } else {
114
- const raw = readFileSync(agentsAbs, 'utf8');
172
+ let raw = readFileSync(agentsAbs, 'utf8');
115
173
  const starts = countOccurrences(raw, START);
116
174
  const ends = countOccurrences(raw, END);
117
- if (starts === 0) {
175
+ if (starts === 0 && ends === 0) {
118
176
  if (checkOnly) add('WOULD-FIX', 'AGENTS.md has no archgen block -> added');
119
177
  else {
120
- upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n'));
121
- add('FIXED', 'AGENTS.md managed block added');
178
+ attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
179
+ 'AGENTS.md managed block added', 'cannot add AGENTS.md managed block');
122
180
  }
123
181
  } else if (starts > 1 || ends > 1) {
124
182
  add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
125
- } else if (ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
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.
126
186
  add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
127
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
+ }
128
205
  const fsCount = countOccurrences(raw, FEATURES_START);
129
206
  const feCount = countOccurrences(raw, FEATURES_END);
130
- if (fsCount === 1 && feCount === 1) add('OK', 'AGENTS.md block + features registry present');
131
- else if (fsCount === 0 && feCount === 0) {
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) {
132
218
  if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing features registry -> inserted');
133
219
  else {
134
- upsertFeaturesRegistry(agentsAbs, []);
135
- add('FIXED', 'AGENTS.md features registry inserted');
220
+ attempt(() => upsertFeaturesRegistry(agentsAbs, []), 'FIXED',
221
+ 'AGENTS.md features registry inserted', 'cannot insert AGENTS.md features registry');
136
222
  }
137
223
  } else {
138
224
  add('FAIL', 'AGENTS.md has broken archgen:features markers - fix manually');
@@ -144,8 +230,8 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
144
230
  if (!existsSync(claudeMdAbs)) {
145
231
  if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing -> created with @AGENTS.md bridge');
146
232
  else {
147
- upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]);
148
- add('FIXED', 'CLAUDE.md created with @AGENTS.md bridge');
233
+ attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
234
+ 'CLAUDE.md created with @AGENTS.md bridge', 'cannot create CLAUDE.md bridge');
149
235
  }
150
236
  } else {
151
237
  const raw = readFileSync(claudeMdAbs, 'utf8');
@@ -155,13 +241,16 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
155
241
  const ends = countOccurrences(raw, END);
156
242
  if (imports && starts <= 1 && ends <= 1) add('OK', 'CLAUDE.md bridges to AGENTS.md');
157
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');
158
245
  else if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing @AGENTS.md bridge -> written');
159
246
  else {
160
- upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]);
161
- add('FIXED', 'CLAUDE.md @AGENTS.md bridge written');
247
+ attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
248
+ 'CLAUDE.md @AGENTS.md bridge written', 'cannot write CLAUDE.md bridge');
162
249
  }
163
250
  }
164
251
 
252
+ for (const [status, msg] of deferredStoreFindings) add(status, msg);
253
+
165
254
  // 6. Manifest entries resolve.
166
255
  const manifest = loadManifest(root);
167
256
  if (!manifest) {
@@ -180,7 +269,34 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
180
269
  }
181
270
  }
182
271
 
272
+ // 7. Version-stamp inventory — every archgen skill tree visible from this
273
+ // project/machine, one line each, for tools that probe
274
+ // <skillRoot>/.archgen-version (the VS Code extension reads it to warn
275
+ // "skill outdated"). Report-only: doctor repairs the project stamp in
276
+ // section 2, never global installs.
277
+ const seen = new Set();
278
+ const installs = [];
279
+ const noteInstall = (p) => {
280
+ // Dedupe by realpath (two harness dirs may link one tree) but DISPLAY the
281
+ // discovered path — that is the path tools probe <skillRoot>/.archgen-version through.
282
+ let real;
283
+ try { real = realpathSync(p); } catch { return; } // vanished mid-scan
284
+ if (!seen.has(real)) { seen.add(real); installs.push(p); }
285
+ };
286
+ if (existsSync(storeAbs)) noteInstall(storeAbs);
287
+ for (const t of globalTargets(homeDir)) {
288
+ const p = join(t, 'archgen');
289
+ if (existsSync(p)) noteInstall(p);
290
+ }
291
+ const ghSkills = join(root, '.github', 'skills', 'archgen');
292
+ if (existsSync(ghSkills)) noteInstall(ghSkills);
293
+ for (const p of installs) {
294
+ const v = readStamp(p);
295
+ const slot = v ?? (existsSync(join(p, VERSION_FILE)) ? 'unknown (pre-0.1 stamp)' : 'missing');
296
+ add(v ? 'OK' : 'WARN', `skill ${p}: ${slot}`);
297
+ }
298
+
183
299
  const tally = { OK: 0, FIXED: 0, 'WOULD-FIX': 0, WARN: 0, FAIL: 0 };
184
300
  for (const c of checks) tally[c.status]++;
185
- return { root, checks, tally, failures: tally.FAIL };
301
+ return { root, checks, tally, failures: opFailures };
186
302
  }
package/lib/init.js CHANGED
@@ -17,11 +17,12 @@
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, writeFileSync } from 'node:fs';
20
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, 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
23
  import { importsAgents, renderBlock, START, END, upsertManagedFile } from './block.js';
24
- import { CLAUDE_LINK_REL, STORE_REL, VERSION_FILE, appendEntry, cliVersion, hashDir, moveToBackup } from './store.js';
24
+ import { CLAUDE_LINK_REL, STORE_REL, VERSION_FILE, appendEntry, assertNotSymlink, assertWithinRoot, cliVersion, hashDir, moveToBackup, pruneDevOnly } from './store.js';
25
+ import { writeStamp } from './version-stamp.js';
25
26
 
26
27
  const CONTEXT_FILES = ['AGENTS.md', 'CLAUDE.md'];
27
28
 
@@ -79,6 +80,9 @@ function installClaudeAdapter(root, source, warnings) {
79
80
  return 'kept-divergent';
80
81
  }
81
82
 
83
+ // Escape check: a symlinked .claude/.claude/skills parent must not redirect
84
+ // the adapter creation outside the project root.
85
+ assertWithinRoot(root, claudeAbs);
82
86
  try {
83
87
  mkdirSync(dirname(claudeAbs), { recursive: true });
84
88
  symlinkSync('../../.agents/skills/archgen', claudeAbs);
@@ -115,6 +119,12 @@ export function initProject(projectDir, packageRoot, opts = {}) {
115
119
 
116
120
  const storeAbs = join(root, relJoin(STORE_REL));
117
121
 
122
+ // Supply-chain guards BEFORE any mutation: a symlinked store (a malicious
123
+ // clone can ship .agents/skills/archgen as a link to an unrelated dir) is
124
+ // refused, and the resolved real path must stay inside the project root.
125
+ assertNotSymlink(storeAbs);
126
+ assertWithinRoot(root, storeAbs);
127
+
118
128
  // 1. Canonical store — fingerprint before mutating (ownership check).
119
129
  const prev = lstatSafe(storeAbs);
120
130
  if (prev) {
@@ -128,7 +138,8 @@ export function initProject(projectDir, packageRoot, opts = {}) {
128
138
  mkdirSync(skillsParent, { recursive: true });
129
139
  const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
130
140
  cpSync(source, tmp, { recursive: true });
131
- writeFileSync(join(tmp, VERSION_FILE), version + '\n');
141
+ pruneDevOnly(tmp); // dev-only artifacts (scripts/test) never reach the installed store
142
+ writeStamp(tmp, version);
132
143
  rmSync(storeAbs, { recursive: true, force: true });
133
144
  renameSync(tmp, storeAbs);
134
145
  appendEntry(root, 'dir', STORE_REL);
package/lib/install.js CHANGED
@@ -2,19 +2,31 @@
2
2
  // Installs the archgen skill into every EXISTING harness skills dir; manifest
3
3
  // records entries so --uninstall removes exactly what we put there.
4
4
 
5
- import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, lstatSync } from 'node:fs';
5
+ import { cpSync, existsSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, lstatSync, unlinkSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { dirname, join, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { resolveSkillSource } from './init.js';
10
+ import { assertNotSymlink, assertWithinRoot, cliVersion, hashDir, moveToBackupInto, pruneDevOnly, refuseSymlinkMessage } from './store.js';
11
+ import { writeStamp } from './version-stamp.js';
12
+ import { loadConfig } from './config.js';
10
13
 
11
14
  // Module-anchored default: works from the npm package (cli/lib -> cli/vendor)
12
15
  // AND from a monorepo checkout (packages/cli/lib -> ../../skill) without depending on cwd.
13
16
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
14
17
 
15
- const MANIFEST_NAME = '.archgen-install-manifest.list';
18
+ export const MANIFEST_NAME = loadConfig().files.globalManifest;
16
19
 
17
- function globalTargets(home = homedir()) {
20
+ // Global backup vault: global installs live OUTSIDE any project, so there is
21
+ // no project root to host `.archgen/.backup/`. Convention chosen instead: each
22
+ // harness skills dir keeps its own sibling vault at
23
+ // <skills-dir>/.archgen-backups/<ISO-timestamp>/archgen/
24
+ // — a dot-prefixed sibling (never scanned as a skill by harnesses), colocated
25
+ // with what it protects, and discovered by lib/restore.js alongside the
26
+ // project vault.
27
+ export const GLOBAL_BACKUP_REL = '.archgen-backups';
28
+
29
+ export function globalTargets(home = homedir()) {
18
30
  return [
19
31
  join(home, '.claude', 'skills'),
20
32
  join(home, '.agents', 'skills'),
@@ -24,6 +36,7 @@ function globalTargets(home = homedir()) {
24
36
  }
25
37
 
26
38
  function record(manifest, kind, path) {
39
+ assertNotSymlink(manifest);
27
40
  const line = `${kind}\t${path}`;
28
41
  let dup = false;
29
42
  if (existsSync(manifest)) {
@@ -32,41 +45,136 @@ function record(manifest, kind, path) {
32
45
  if (!dup) writeFileSync(manifest, (existsSync(manifest) ? readFileSync(manifest, 'utf8').replace(/\n*$/, '\n') : '') + line + '\n');
33
46
  }
34
47
 
48
+ function lstatSafe(p) {
49
+ try { return lstatSync(p); } catch { return null; }
50
+ }
51
+
52
+ /** Collision-free timestamp for `<tdir>/.archgen-backups/<ts>/` (ms clock can tick twice). */
53
+ function nextBackupTs(tdir) {
54
+ for (;;) {
55
+ const ts = new Date().toISOString().replace(/:/g, '-');
56
+ if (!existsSync(join(tdir, GLOBAL_BACKUP_REL, ts))) return ts;
57
+ }
58
+ }
59
+
35
60
  /**
36
- * @param {{copy?: boolean, home?: string}} opts
61
+ * @param {{copy?: boolean, home?: string, packageRoot?: string}} opts
62
+ * @returns {{rows: Array<[string, string, string]>, failures: number, manifest: string,
63
+ * backups: string[]}} rows are [STATUS, MODE, PATH]; `backups` lists
64
+ * absolute vault locations written by this run.
37
65
  */
38
66
  export function installGlobal(opts = {}) {
39
67
  const home = opts.home ?? homedir();
40
68
  if (!home) throw new Error('$HOME is not set; cannot resolve target directories.');
41
- const source = resolve(resolveSkillSource(opts.packageRoot ?? PACKAGE_ROOT));
69
+ const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
70
+ const source = resolve(resolveSkillSource(packageRoot));
71
+ const version = cliVersion(packageRoot);
42
72
  const manifest = join(home, MANIFEST_NAME);
43
73
  const rows = [];
74
+ const backups = [];
44
75
  let failures = 0;
45
76
 
46
- const targets = [...globalTargets(home), join(process.cwd(), '.github', 'skills')];
47
- for (const tdir of targets) {
77
+ const targets = [
78
+ ...globalTargets(home).map((d) => [d, home]),
79
+ [join(process.cwd(), '.github', 'skills'), process.cwd()],
80
+ ];
81
+ for (const [tdir, base] of targets) {
48
82
  const dest = join(tdir, 'archgen');
49
83
  if (!existsSync(tdir)) { rows.push(['SKIP', '-', tdir + ' (does not exist)']); continue; }
50
84
  try {
85
+ // Realpath-escape guard: a symlinked harness dir (e.g. ~/.claude -> /etc)
86
+ // must not redirect the install outside the base it belongs to.
87
+ assertWithinRoot(base, tdir);
51
88
  mkdirSync(tdir, { recursive: true }); // no-op when present
52
89
  if (opts.copy) {
53
- rmSync(dest, { force: true, recursive: true });
54
- cpSync(source, dest, { recursive: true });
55
- record(manifest, 'copy', dest);
56
- rows.push(['OK', 'copy', dest]);
90
+ // Safety invariant: fingerprint dest BEFORE mutating; identical
91
+ // (stamp-ignoring) skips like link mode, divergent moves into the
92
+ // vault first — nothing is rmSync'd without a prior backup of it.
93
+ const prev = lstatSafe(dest);
94
+ if (prev && prev.isSymbolicLink()) {
95
+ // A symlink at dest is only ours when it points at this source (a
96
+ // previous link-mode install). Any other link is foreign: replacing
97
+ // it would let a planted symlink steer the copy, so refuse.
98
+ let linkTarget = null;
99
+ try { linkTarget = resolve(dirname(dest), readlinkSync(dest)); } catch { /* unreadable */ }
100
+ if (linkTarget !== source) throw new Error(refuseSymlinkMessage(dest));
101
+ }
102
+ let identical = false;
103
+ if (prev && prev.isDirectory() && !prev.isSymbolicLink()) {
104
+ try { identical = hashDir(dest) === hashDir(source); } catch { identical = false; }
105
+ }
106
+ if (identical) {
107
+ stampNote(rows, dest, () => writeStamp(dest, version));
108
+ record(manifest, 'copy', dest);
109
+ rows.push(['SAME', 'copy', dest]);
110
+ } else {
111
+ if (prev) {
112
+ const loc = moveToBackupInto(tdir, 'archgen', GLOBAL_BACKUP_REL, nextBackupTs(tdir));
113
+ const abs = join(tdir, ...loc.split('/'));
114
+ backups.push(abs);
115
+ rows.push(['BACKED-UP', '-', abs]);
116
+ }
117
+ rmSync(dest, { force: true, recursive: true }); // no-op after a move; guards exotic-FS fallbacks
118
+ assertNotSymlink(dest); // TOCTOU re-check: nothing may have re-linked dest between backup and copy
119
+ cpSync(source, dest, { recursive: true });
120
+ pruneDevOnly(dest); // dev-only artifacts (scripts/test) never reach global --copy trees
121
+ stampNote(rows, dest, () => writeStamp(dest, version));
122
+ record(manifest, 'copy', dest);
123
+ rows.push(['OK', 'copy', dest]);
124
+ }
57
125
  } else {
58
- let same = false;
59
- try { same = lstatSync(dest).isSymbolicLink(); } catch { /* absent */ }
60
- if (!same) symlinkSync(source, dest, 'dir');
126
+ let status = 'OK';
127
+ const prev = lstatSafe(dest);
128
+ if (prev && prev.isSymbolicLink()) {
129
+ if (existsSync(dest)) {
130
+ // A live link resolving anywhere but our source belongs to the
131
+ // user: leave it untouched and OUT of the uninstall manifest, or
132
+ // `uninstall` would later delete their link. (install.sh replaces
133
+ // foreign links via ln -sfn — that destructive semantic is
134
+ // deliberately NOT copied here.)
135
+ const linkTarget = resolve(dirname(dest), readlinkSync(dest));
136
+ if (linkTarget !== source) {
137
+ rows.push(['KEPT', '-', dest + ' (foreign symlink, not recorded)']);
138
+ continue;
139
+ }
140
+ status = 'SAME';
141
+ } else {
142
+ // Dangling (npx cache eviction): recreate against the current
143
+ // source instead of leaving users broken while reporting SAME.
144
+ unlinkSync(dest); // removes the link itself, never any target
145
+ symlinkSync(source, dest, 'dir');
146
+ status = 'REPAIRED';
147
+ }
148
+ } else if (!prev) {
149
+ symlinkSync(source, dest, 'dir');
150
+ } else {
151
+ // Real directory/file at dest is NEVER clobbered: symlinkSync fails
152
+ // with EEXIST and surfaces as FAILED below (historical behavior).
153
+ symlinkSync(source, dest, 'dir');
154
+ }
155
+ // The link itself holds no bytes: the stamp must live INSIDE the real
156
+ // directory the link resolves to (the shared copy) so a read of
157
+ // <link>/.archgen-version sees it — true for symlinks and Windows
158
+ // junctions alike.
159
+ stampNote(rows, dest, () => writeStamp(source, version));
61
160
  record(manifest, 'link', dest);
62
- rows.push([same ? 'SAME' : 'OK', 'link', dest]);
161
+ rows.push([status, 'link', dest]);
63
162
  }
64
163
  } catch (e) {
65
164
  failures++;
66
165
  rows.push(['FAILED', '-', dest + ' (' + e.message + ')']);
67
166
  }
68
167
  }
69
- return { rows, failures, manifest };
168
+ return { rows, failures, manifest, backups };
169
+ }
170
+
171
+ /** Install succeeded even when the stamp could not be written (e.g. read-only vendor); surface it without failing. */
172
+ function stampNote(rows, dest, write) {
173
+ try {
174
+ write();
175
+ } catch (e) {
176
+ rows.push(['WARN', '-', dest + ' (installed; version stamp not written: ' + e.message + ')']);
177
+ }
70
178
  }
71
179
 
72
180
  export function uninstallGlobal(home = homedir()) {
package/lib/migrate.js ADDED
@@ -0,0 +1,88 @@
1
+ // migrate.js — `archgen migrate [dir] [--check] [--apply] [--migration <id>]`.
2
+ //
3
+ // Evolves generated-artifact formats in existing projects with zero data loss.
4
+ // Tone and safety model mirror doctor: dry-run is the DEFAULT (--check lists
5
+ // pending migrations and the exact files they would touch, writing nothing);
6
+ // --apply runs them, snapshotting every affected file into
7
+ // .archgen/.backup/<ts>/migrate/ BEFORE any modification. Exit codes:
8
+ // 0 nothing pending, or all migrations applied cleanly
9
+ // 1 a migration failed mid-apply (earlier files stay backed up)
10
+ // 2 usage error (unknown flag / unknown --migration id / --check + --apply)
11
+ //
12
+ // bin/archgen.mjs wiring (integrator):
13
+ // case 'migrate': process.exitCode = await migrateMain(rest); break;
14
+
15
+ import { applyMigration, listMigrations, pendingMigrations } from './migrations/index.mjs';
16
+ import { resolve } from 'node:path';
17
+
18
+ const USAGE = 'usage: archgen-skill migrate [dir] [--check|--apply] [--migration <id>]';
19
+
20
+ /**
21
+ * @param {string[]} argv args AFTER the subcommand, e.g. ['myproj', '--apply']
22
+ * @returns {Promise<number>} exit code int
23
+ */
24
+ export async function migrateMain(argv = []) {
25
+ let dir = null;
26
+ let check = false;
27
+ let apply = false;
28
+ let target = null;
29
+
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === '--check') check = true;
33
+ else if (a === '--apply') apply = true;
34
+ else if (a === '--migration') {
35
+ target = argv[++i];
36
+ if (!target || target.startsWith('-')) { console.error(USAGE); return 2; }
37
+ } else if (a.startsWith('-')) {
38
+ console.error(`unknown flag: ${a}\n${USAGE}`);
39
+ return 2;
40
+ } else if (dir === null) dir = a;
41
+ else { console.error(`unexpected argument: ${a}\n${USAGE}`); return 2; }
42
+ }
43
+ if (check && apply) { console.error('--check and --apply are mutually exclusive\n' + USAGE); return 2; }
44
+
45
+ const dryRun = !apply; // check mode is the default, always
46
+ const root = resolve(dir ?? process.cwd());
47
+
48
+ const all = await listMigrations();
49
+ if (target && !all.some((m) => m.id === target)) {
50
+ console.error(`unknown migration '${target}' (available: ${all.map((m) => m.id).join(', ') || 'none'})`);
51
+ return 2;
52
+ }
53
+
54
+ const pending = (await pendingMigrations(root)).filter((p) => !target || p.id === target);
55
+
56
+ console.log('archgen migrate — ' + root + (dryRun ? ' (check mode: no changes made)' : ''));
57
+
58
+ if (pending.length === 0) {
59
+ console.log(' = nothing to do — all migrations applied');
60
+ console.log('Summary: up to date — 0 pending');
61
+ return 0;
62
+ }
63
+
64
+ let fileCount = 0;
65
+ let failed = 0;
66
+ for (const p of pending) {
67
+ if (dryRun) {
68
+ console.log(` [would-apply] ${p.id} — ${p.description}`);
69
+ for (const f of p.files) console.log(` ${f}`);
70
+ fileCount += p.files.length;
71
+ continue;
72
+ }
73
+ try {
74
+ const r = await applyMigration(p.id, root, { dryRun: false });
75
+ if (r.status === 'clean') { console.log(` [skip] ${r.id} — already applied`); continue; }
76
+ console.log(` [applied] ${r.id} — ${r.description}`);
77
+ for (const b of r.backups ?? []) console.log(` ${b.file} (backup: ${b.backup})`);
78
+ fileCount += (r.files ?? []).length;
79
+ } catch (e) {
80
+ failed++;
81
+ console.log(` [fail] ${p.id} — ${e instanceof Error ? e.message : String(e)}`);
82
+ }
83
+ }
84
+
85
+ if (dryRun) console.log(`Summary: ${pending.length} migration(s) pending, ${fileCount} file(s) would change`);
86
+ else console.log(`Summary: ${pending.length - failed} applied, ${fileCount} file(s) changed, ${failed} failure(s)`);
87
+ return failed > 0 ? 1 : 0;
88
+ }