archgen-skill 0.0.2 → 0.0.4

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 (34) hide show
  1. package/README.md +29 -3
  2. package/bin/archgen.mjs +146 -12
  3. package/lib/block.js +197 -8
  4. package/lib/config.js +96 -0
  5. package/lib/doctor.js +283 -0
  6. package/lib/init.js +130 -27
  7. package/lib/install.js +105 -14
  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 +161 -0
  13. package/lib/uninstall-project.js +95 -0
  14. package/lib/version-stamp.js +47 -0
  15. package/lib/version.js +23 -0
  16. package/package.json +37 -9
  17. package/vendor/skills/archgen/SKILL.md +252 -73
  18. package/vendor/skills/archgen/archgen.config.json +16 -0
  19. package/vendor/skills/archgen/assets/skill-registry.json +4 -3
  20. package/vendor/skills/archgen/references/artifact-templates.md +251 -7
  21. package/vendor/skills/archgen/references/interview.md +180 -16
  22. package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
  23. package/vendor/skills/archgen/references/orchestration.md +102 -39
  24. package/vendor/skills/archgen/references/platforms.md +18 -14
  25. package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
  26. package/vendor/skills/archgen/scripts/impact.mjs +14 -1
  27. package/vendor/skills/archgen/scripts/lib/graph.mjs +82 -3
  28. package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
  29. package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
  30. package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
  31. package/vendor/skills/archgen/scripts/set-status.mjs +26 -6
  32. package/vendor/skills/archgen/scripts/update-agents.mjs +204 -0
  33. package/vendor/skills/archgen/scripts/validate.mjs +18 -1
  34. package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
package/lib/doctor.js ADDED
@@ -0,0 +1,283 @@
1
+ // doctor.js — `archgen doctor [dir] [--check]` health-check + safe auto-repair.
2
+ //
3
+ // Verifies a project install: canonical store integrity, version stamp,
4
+ // claude link resolution, managed blocks (exactly once each), and manifest
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.
9
+
10
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { dirname, join, resolve } from 'node:path';
13
+ import { globalTargets } from './install.js';
14
+ import {
15
+ END,
16
+ FEATURES_END,
17
+ FEATURES_START,
18
+ START,
19
+ detectBlockVersion,
20
+ renderBlock,
21
+ upsertFeaturesRegistry,
22
+ upsertManagedFile,
23
+ } from './block.js';
24
+ import { resolveSkillSource } from './init.js';
25
+ import { CLAUDE_LINK_REL, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, hashDir, loadManifest, saveManifest } from './store.js';
26
+ import { compareSemver, parseSemver } from './version.js';
27
+ import { readStamp, writeStamp } from './version-stamp.js';
28
+
29
+ function lstatSafe(p) {
30
+ try { return lstatSync(p); } catch { return null; }
31
+ }
32
+
33
+ function countOccurrences(haystack, needle) {
34
+ let n = 0;
35
+ for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length)) n++;
36
+ return n;
37
+ }
38
+
39
+ function relJoin(rel) {
40
+ return join(...rel.split('/'));
41
+ }
42
+
43
+ /**
44
+ * @param {string} projectDir project root to inspect
45
+ * @param {string} packageRoot dir containing this CLI package
46
+ * @param {{check?: boolean, home?: string}} opts check=true → report-only, no
47
+ * mutations; home overrides the homedir scanned for global installs (tests)
48
+ * @returns {{root: string, checks: {status: 'OK'|'FIXED'|'WOULD-FIX'|'WARN'|'FAIL', msg: string}[],
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.
52
+ */
53
+ export function doctorProject(projectDir, packageRoot, opts = {}) {
54
+ const root = resolve(projectDir);
55
+ const checkOnly = opts.check === true;
56
+ const homeDir = opts.home ?? homedir();
57
+ const version = cliVersion(packageRoot);
58
+ const checks = [];
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
+ };
72
+
73
+ const storeAbs = join(root, relJoin(STORE_REL));
74
+ const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
75
+ const agentsAbs = join(root, 'AGENTS.md');
76
+ const claudeMdAbs = join(root, 'CLAUDE.md');
77
+
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'))) {
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
+ // 2. Version stamp currency.
102
+ const stampAbs = join(storeAbs, VERSION_FILE);
103
+ let stamp = null;
104
+ try { stamp = readFileSync(stampAbs, 'utf8').trim(); } catch { /* absent */ }
105
+ if (!existsSync(storeAbs)) {
106
+ // already reported above; skip stamp handling without touching anything
107
+ } else if (stamp === version) {
108
+ add('OK', `version stamp ${version} (current)`);
109
+ } else if (checkOnly) {
110
+ add('WOULD-FIX', `version stamp ${stamp || 'missing'} is stale -> ${version}`);
111
+ } else {
112
+ attempt(() => writeStamp(storeAbs, version), 'FIXED',
113
+ `version stamp updated ${stamp || 'missing'} -> ${version}`, 'cannot write version stamp');
114
+ }
115
+
116
+ // 3. Claude link resolution.
117
+ const repairLink = () => {
118
+ if (checkOnly) {
119
+ add('WOULD-FIX', 'claude link missing/dangling -> ../../.agents/skills/archgen');
120
+ return;
121
+ }
122
+ try {
123
+ try { unlinkSync(claudeAbs); } catch { /* absent */ }
124
+ mkdirSync(dirname(claudeAbs), { recursive: true });
125
+ symlinkSync('../../.agents/skills/archgen', claudeAbs);
126
+ add('FIXED', 'claude link recreated -> ../../.agents/skills/archgen');
127
+ } catch (e) {
128
+ add('FAIL', 'cannot recreate claude link: ' + e.message);
129
+ }
130
+ };
131
+ const st = lstatSafe(claudeAbs);
132
+ if (st && st.isSymbolicLink()) {
133
+ const target = resolve(dirname(claudeAbs), readlinkSync(claudeAbs));
134
+ if (target === storeAbs && existsSync(claudeAbs)) add('OK', 'claude link resolves to the canonical store');
135
+ else if (existsSync(claudeAbs)) add('WARN', 'claude link is foreign (points elsewhere) - left untouched');
136
+ else repairLink();
137
+ } else if (st && st.isDirectory()) {
138
+ add('WARN', '.claude/skills/archgen is a real directory (not managed) - inspect manually');
139
+ } else if (st) {
140
+ add('WARN', '.claude/skills/archgen is a regular file - left untouched');
141
+ } else {
142
+ repairLink();
143
+ }
144
+
145
+ // 4. AGENTS.md — managed block exactly once, features registry inside.
146
+ if (!existsSync(agentsAbs)) {
147
+ if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing -> created with managed block');
148
+ else {
149
+ writeFileSync(agentsAbs, renderBlock(STORE_REL) + '\n');
150
+ add('FIXED', 'AGENTS.md created with managed block');
151
+ }
152
+ } else {
153
+ let raw = readFileSync(agentsAbs, 'utf8');
154
+ const starts = countOccurrences(raw, START);
155
+ const ends = countOccurrences(raw, END);
156
+ if (starts === 0 && ends === 0) {
157
+ if (checkOnly) add('WOULD-FIX', 'AGENTS.md has no archgen block -> added');
158
+ else {
159
+ attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
160
+ 'AGENTS.md managed block added', 'cannot add AGENTS.md managed block');
161
+ }
162
+ } else if (starts > 1 || ends > 1) {
163
+ add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
164
+ } else if (starts === 0 || ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
165
+ // Orphan/inverted markers: repairing through upsertBlock would throw, so
166
+ // classify as FAIL instead of attempting the write.
167
+ add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
168
+ } else {
169
+ // Block-format currency: an unversioned or older-stamped managed block is
170
+ // upgraded in place through the normal upsert path (everything outside the
171
+ // markers survives byte-for-byte by design). Newer stamps are left alone.
172
+ const det = detectBlockVersion(raw);
173
+ const parsed = det.version ? parseSemver(det.version) : null;
174
+ const stale = !det.present || !parsed || compareSemver(det.version, version) < 0;
175
+ if (stale) {
176
+ const from = det.version ? 'v' + det.version : 'unversioned';
177
+ if (checkOnly) {
178
+ add('WOULD-FIX', `WOULD-UPGRADE: AGENTS.md block ${from} -> v${version}`);
179
+ } else {
180
+ let upgraded = false;
181
+ attempt(() => { upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')); upgraded = true; },
182
+ 'FIXED', `UPGRADED: AGENTS.md block ${from} -> v${version}`, 'cannot upgrade AGENTS.md block');
183
+ if (upgraded) raw = readFileSync(agentsAbs, 'utf8');
184
+ }
185
+ }
186
+ const fsCount = countOccurrences(raw, FEATURES_START);
187
+ const feCount = countOccurrences(raw, FEATURES_END);
188
+ const fsIdx = raw.indexOf(FEATURES_START);
189
+ const feIdx = raw.indexOf(FEATURES_END);
190
+ if (fsCount === 1 && feCount === 1 && fsIdx < feIdx) {
191
+ add('OK', 'AGENTS.md block + features registry present');
192
+ } else if (fsCount === 1 && feCount === 1) {
193
+ // Counts are right but ORDER is inverted (end before start): any
194
+ // registry write would throw (upsertFeaturesRegistry requires
195
+ // end > start), so flag the state instead of blessing it as OK.
196
+ if (checkOnly) add('WOULD-FIX', 'AGENTS.md archgen:features markers are reversed -> swap them');
197
+ else add('FAIL', 'AGENTS.md archgen:features markers are reversed - fix manually');
198
+ } else if (fsCount === 0 && feCount === 0) {
199
+ if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing features registry -> inserted');
200
+ else {
201
+ attempt(() => upsertFeaturesRegistry(agentsAbs, []), 'FIXED',
202
+ 'AGENTS.md features registry inserted', 'cannot insert AGENTS.md features registry');
203
+ }
204
+ } else {
205
+ add('FAIL', 'AGENTS.md has broken archgen:features markers - fix manually');
206
+ }
207
+ }
208
+ }
209
+
210
+ // 5. CLAUDE.md — @AGENTS.md bridge present exactly once.
211
+ if (!existsSync(claudeMdAbs)) {
212
+ if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing -> created with @AGENTS.md bridge');
213
+ else {
214
+ attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
215
+ 'CLAUDE.md created with @AGENTS.md bridge', 'cannot create CLAUDE.md bridge');
216
+ }
217
+ } else {
218
+ const raw = readFileSync(claudeMdAbs, 'utf8');
219
+ const body = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
220
+ const imports = body.split(/\r?\n/).some((l) => l.trim() === '@AGENTS.md');
221
+ const starts = countOccurrences(raw, START);
222
+ const ends = countOccurrences(raw, END);
223
+ if (imports && starts <= 1 && ends <= 1) add('OK', 'CLAUDE.md bridges to AGENTS.md');
224
+ else if (starts > 1 || ends > 1) add('FAIL', 'CLAUDE.md has multiple archgen blocks - fix manually');
225
+ else if (starts !== ends) add('FAIL', 'CLAUDE.md has broken archgen markers - fix manually');
226
+ else if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing @AGENTS.md bridge -> written');
227
+ else {
228
+ attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
229
+ 'CLAUDE.md @AGENTS.md bridge written', 'cannot write CLAUDE.md bridge');
230
+ }
231
+ }
232
+
233
+ for (const [status, msg] of deferredStoreFindings) add(status, msg);
234
+
235
+ // 6. Manifest entries resolve.
236
+ const manifest = loadManifest(root);
237
+ if (!manifest) {
238
+ if (existsSync(storeAbs)) add('WARN', `no install manifest at ${MANIFEST_REL} (re-run init to regenerate)`);
239
+ else add('OK', 'no manifest (nothing installed)');
240
+ } else {
241
+ const resolves = (e) => e && typeof e.path === 'string' && existsSync(join(root, ...e.path.split('/')));
242
+ const stale = manifest.entries.filter((e) => !resolves(e));
243
+ if (stale.length === 0) {
244
+ add('OK', `manifest: all ${manifest.entries.length} entr${manifest.entries.length === 1 ? 'y' : 'ies'} resolve`);
245
+ } else if (checkOnly) {
246
+ add('WOULD-FIX', `manifest: ${stale.length} stale entr${stale.length === 1 ? 'y' : 'ies'} -> pruned`);
247
+ } else {
248
+ saveManifest(root, { ...manifest, entries: manifest.entries.filter(resolves) });
249
+ add('FIXED', `manifest: pruned ${stale.length} stale entr${stale.length === 1 ? 'y' : 'ies'}`);
250
+ }
251
+ }
252
+
253
+ // 7. Version-stamp inventory — every archgen skill tree visible from this
254
+ // project/machine, one line each, for tools that probe
255
+ // <skillRoot>/.archgen-version (the VS Code extension reads it to warn
256
+ // "skill outdated"). Report-only: doctor repairs the project stamp in
257
+ // section 2, never global installs.
258
+ const seen = new Set();
259
+ const installs = [];
260
+ const noteInstall = (p) => {
261
+ // Dedupe by realpath (two harness dirs may link one tree) but DISPLAY the
262
+ // discovered path — that is the path tools probe <skillRoot>/.archgen-version through.
263
+ let real;
264
+ try { real = realpathSync(p); } catch { return; } // vanished mid-scan
265
+ if (!seen.has(real)) { seen.add(real); installs.push(p); }
266
+ };
267
+ if (existsSync(storeAbs)) noteInstall(storeAbs);
268
+ for (const t of globalTargets(homeDir)) {
269
+ const p = join(t, 'archgen');
270
+ if (existsSync(p)) noteInstall(p);
271
+ }
272
+ const ghSkills = join(root, '.github', 'skills', 'archgen');
273
+ if (existsSync(ghSkills)) noteInstall(ghSkills);
274
+ for (const p of installs) {
275
+ const v = readStamp(p);
276
+ const slot = v ?? (existsSync(join(p, VERSION_FILE)) ? 'unknown (pre-0.1 stamp)' : 'missing');
277
+ add(v ? 'OK' : 'WARN', `skill ${p}: ${slot}`);
278
+ }
279
+
280
+ const tally = { OK: 0, FIXED: 0, 'WOULD-FIX': 0, WARN: 0, FAIL: 0 };
281
+ for (const c of checks) tally[c.status]++;
282
+ return { root, checks, tally, failures: opFailures };
283
+ }
package/lib/init.js CHANGED
@@ -1,13 +1,28 @@
1
- // init.js — project-local skill install + AGENTS.md/CLAUDE.md context files.
1
+ // init.js — single-store project installer + AGENTS.md/CLAUDE.md context files.
2
2
  //
3
- // `archgen init` makes a repository SELF-CONTAINED: the skill is copied into
4
- // .agents/skills/archgen (committed to git, team-shared) and mirrored at
5
- // .claude/skills/archgen for Claude Code; AGENTS.md + CLAUDE.md carry a
6
- // managed pointer block so every agent harness auto-discovers the workflow.
3
+ // `archgen init` makes a repository SELF-CONTAINED with ONE real copy of the
4
+ // skill: the canonical store at .agents/skills/archgen (committed to git,
5
+ // team-shared). .claude/skills/archgen becomes a RELATIVE symlink to the
6
+ // store — skipped gracefully where symlinks are unavailable, never a second
7
+ // copy. AGENTS.md carries a managed pointer block with an embedded features
8
+ // registry; CLAUDE.md carries a one-line `@AGENTS.md` bridge. Every created
9
+ // artifact is recorded in .archgen/.install-manifest.json.
10
+ //
11
+ // Verified-surgical safety semantics:
12
+ // - existing paths are fingerprinted (stable-order sha256 over relpaths +
13
+ // contents) BEFORE any mutation;
14
+ // - an identical store is refreshed in place; a divergent one is moved to
15
+ // .archgen/.backup/<timestamp>/ FIRST, then reinstalled fresh;
16
+ // - a real-directory claude copy identical to the vendor skill is migrated
17
+ // to a symlink; a customized one is left untouched with a warning;
18
+ // - installs are atomic: built in a temp sibling dir, then renamed.
7
19
 
8
- import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
- import { join, resolve } from 'node:path';
10
- import { renderBlock, upsertBlock } from './block.js';
20
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
21
+ import { randomBytes } from 'node:crypto';
22
+ import { dirname, join, resolve } from 'node:path';
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';
25
+ import { writeStamp } from './version-stamp.js';
11
26
 
12
27
  const CONTEXT_FILES = ['AGENTS.md', 'CLAUDE.md'];
13
28
 
@@ -21,36 +36,124 @@ export function resolveSkillSource(packageRoot) {
21
36
  throw new Error('bundled archgen skill not found (expected vendor/skills/archgen)');
22
37
  }
23
38
 
39
+ function lstatSafe(p) {
40
+ try { return lstatSync(p); } catch { return null; }
41
+ }
42
+
43
+ function relJoin(rel) {
44
+ return join(...rel.split('/'));
45
+ }
46
+
47
+ /**
48
+ * Create the claude adapter: `<project>/.claude/skills/archgen` as a RELATIVE
49
+ * symlink into the canonical store. Never copies. Migration/keep semantics:
50
+ * - ours already -> 'existing'
51
+ * - real dir identical to vendor -> replaced with symlink ('migrated')
52
+ * - real dir modified / foreign live symlink / stray file -> hands off + warn
53
+ * - dangling symlink -> recreated
54
+ * - symlink creation fails (privileges/FS) -> skipped + warning
55
+ */
56
+ function installClaudeAdapter(root, source, warnings) {
57
+ const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
58
+ const storeAbs = join(root, relJoin(STORE_REL));
59
+ const st = lstatSafe(claudeAbs);
60
+ let migrating = false;
61
+
62
+ if (st && st.isSymbolicLink()) {
63
+ const resolvedTarget = resolve(dirname(claudeAbs), readlinkSync(claudeAbs));
64
+ if (resolvedTarget === storeAbs && existsSync(claudeAbs)) return 'existing';
65
+ if (existsSync(claudeAbs)) {
66
+ warnings.push('.claude/skills/archgen is a foreign symlink - left untouched');
67
+ return 'kept-divergent';
68
+ }
69
+ unlinkSync(claudeAbs); // dangling: recreate ours below
70
+ } else if (st && st.isDirectory()) {
71
+ if (hashDir(claudeAbs) === hashDir(source)) {
72
+ rmSync(claudeAbs, { recursive: true }); // legacy dual copy -> migrate
73
+ migrating = true;
74
+ } else {
75
+ warnings.push('user-customized claude copy kept at .claude/skills/archgen (not managed)');
76
+ return 'kept-divergent';
77
+ }
78
+ } else if (st) {
79
+ warnings.push('.claude/skills/archgen exists and is not a directory - left untouched');
80
+ return 'kept-divergent';
81
+ }
82
+
83
+ try {
84
+ mkdirSync(dirname(claudeAbs), { recursive: true });
85
+ symlinkSync('../../.agents/skills/archgen', claudeAbs);
86
+ appendEntry(root, 'link', CLAUDE_LINK_REL);
87
+ return migrating ? 'migrated' : 'created';
88
+ } catch (e) {
89
+ warnings.push('claude adapter skipped: symlink unavailable (' + e.message + ')');
90
+ return 'skipped';
91
+ }
92
+ }
93
+
24
94
  /**
25
- * Initialize a project: copy the skill locally + write context pointers.
95
+ * Initialize a project: install the single canonical skill store, wire the
96
+ * claude adapter, and write context pointers.
26
97
  * @param {string} projectDir target repo root
27
98
  * @param {string} packageRoot dir containing this CLI package
28
- * @returns {{skillCopies: string[], contextFiles: string[], createdContextFiles: string[]}}
99
+ * @param {{force?: boolean}} opts --force proceeds automatically; divergent
100
+ * copies are always auto-backed-up (never refused, never destroyed)
101
+ * @returns {{storePath: string, claudeLink: 'created'|'existing'|'migrated'|'skipped'|'kept-divergent',
102
+ * backups: string[], contextFiles: string[], createdContextFiles: string[], warnings: string[]}}
29
103
  */
30
- export function initProject(projectDir, packageRoot) {
104
+ export function initProject(projectDir, packageRoot, opts = {}) {
105
+ // --force is accepted for explicitness: this installer never refuses and
106
+ // never destroys data — divergent copies are backed up automatically, so
107
+ // both plain and --force runs take the same safe path.
108
+ const force = opts.force === true;
109
+ void force;
110
+
31
111
  const root = resolve(projectDir);
32
112
  const source = resolveSkillSource(packageRoot);
113
+ const version = cliVersion(packageRoot);
114
+ const warnings = [];
115
+ const backups = [];
116
+
117
+ const storeAbs = join(root, relJoin(STORE_REL));
118
+
119
+ // 1. Canonical store — fingerprint before mutating (ownership check).
120
+ const prev = lstatSafe(storeAbs);
121
+ if (prev) {
122
+ const identical = prev.isDirectory() && !prev.isSymbolicLink()
123
+ && hashDir(storeAbs, { ignore: [VERSION_FILE] }) === hashDir(source);
124
+ if (!identical) backups.push(moveToBackup(root, STORE_REL));
125
+ }
126
+
127
+ // Atomic install: build in a temp sibling dir, then rename into place.
128
+ const skillsParent = dirname(storeAbs);
129
+ mkdirSync(skillsParent, { recursive: true });
130
+ const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
131
+ cpSync(source, tmp, { recursive: true });
132
+ writeStamp(tmp, version);
133
+ rmSync(storeAbs, { recursive: true, force: true });
134
+ renameSync(tmp, storeAbs);
135
+ appendEntry(root, 'dir', STORE_REL);
33
136
 
34
- // 1. Skill copies — real files (not symlinks) so git carries them for the team.
35
- const agentsSkills = join(root, '.agents', 'skills', 'archgen');
36
- const claudeSkills = join(root, '.claude', 'skills', 'archgen');
37
- mkdirSync(agentsSkills, { recursive: true });
38
- cpSync(source, agentsSkills, { recursive: true });
39
- mkdirSync(join(root, '.claude', 'skills'), { recursive: true });
40
- cpSync(agentsSkills, claudeSkills, { recursive: true });
137
+ // 2. Claude adapter — relative symlink, never a second copy.
138
+ const claudeLink = installClaudeAdapter(root, source, warnings);
41
139
 
42
- // 2. Context pointer files — managed blocks, user content preserved.
43
- const block = renderBlock('.agents/skills/archgen');
140
+ // 3. Context files — managed blocks, user content preserved.
44
141
  const contextFiles = [];
45
- const created = [];
142
+ const createdContextFiles = [];
46
143
  for (const name of CONTEXT_FILES) {
47
- const p = join(root, name);
48
- const existed = existsSync(p);
49
- const next = upsertBlock(existed ? readFileSync(p, 'utf8') : '', block);
50
- writeFileSync(p, next);
144
+ const abs = join(root, name);
145
+ if (name === 'CLAUDE.md' && importsAgents(abs)) {
146
+ warnings.push('CLAUDE.md already imports @AGENTS.md - skipped (already-imports)');
147
+ continue;
148
+ }
149
+ const existed = upsertManagedFile(
150
+ abs,
151
+ name === 'AGENTS.md' ? renderBlock(STORE_REL).split('\n') : [START, '@AGENTS.md', END],
152
+ );
51
153
  contextFiles.push(name);
52
- if (!existed) created.push(name);
154
+ if (!existed) createdContextFiles.push(name);
155
+ appendEntry(root, 'block', name);
53
156
  }
54
157
 
55
- return { skillCopies: [agentsSkills, claudeSkills], contextFiles, createdContextFiles: created };
158
+ return { storePath: storeAbs, claudeLink, backups, contextFiles, createdContextFiles, warnings };
56
159
  }
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 { cliVersion, hashDir, moveToBackupInto } 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'),
@@ -32,15 +44,33 @@ function record(manifest, kind, path) {
32
44
  if (!dup) writeFileSync(manifest, (existsSync(manifest) ? readFileSync(manifest, 'utf8').replace(/\n*$/, '\n') : '') + line + '\n');
33
45
  }
34
46
 
47
+ function lstatSafe(p) {
48
+ try { return lstatSync(p); } catch { return null; }
49
+ }
50
+
51
+ /** Collision-free timestamp for `<tdir>/.archgen-backups/<ts>/` (ms clock can tick twice). */
52
+ function nextBackupTs(tdir) {
53
+ for (;;) {
54
+ const ts = new Date().toISOString().replace(/:/g, '-');
55
+ if (!existsSync(join(tdir, GLOBAL_BACKUP_REL, ts))) return ts;
56
+ }
57
+ }
58
+
35
59
  /**
36
- * @param {{copy?: boolean, home?: string}} opts
60
+ * @param {{copy?: boolean, home?: string, packageRoot?: string}} opts
61
+ * @returns {{rows: Array<[string, string, string]>, failures: number, manifest: string,
62
+ * backups: string[]}} rows are [STATUS, MODE, PATH]; `backups` lists
63
+ * absolute vault locations written by this run.
37
64
  */
38
65
  export function installGlobal(opts = {}) {
39
66
  const home = opts.home ?? homedir();
40
67
  if (!home) throw new Error('$HOME is not set; cannot resolve target directories.');
41
- const source = resolve(resolveSkillSource(opts.packageRoot ?? PACKAGE_ROOT));
68
+ const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
69
+ const source = resolve(resolveSkillSource(packageRoot));
70
+ const version = cliVersion(packageRoot);
42
71
  const manifest = join(home, MANIFEST_NAME);
43
72
  const rows = [];
73
+ const backups = [];
44
74
  let failures = 0;
45
75
 
46
76
  const targets = [...globalTargets(home), join(process.cwd(), '.github', 'skills')];
@@ -50,23 +80,84 @@ export function installGlobal(opts = {}) {
50
80
  try {
51
81
  mkdirSync(tdir, { recursive: true }); // no-op when present
52
82
  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]);
83
+ // Safety invariant: fingerprint dest BEFORE mutating; identical
84
+ // (stamp-ignoring) skips like link mode, divergent moves into the
85
+ // vault first — nothing is rmSync'd without a prior backup of it.
86
+ const prev = lstatSafe(dest);
87
+ let identical = false;
88
+ if (prev && prev.isDirectory() && !prev.isSymbolicLink()) {
89
+ try { identical = hashDir(dest) === hashDir(source); } catch { identical = false; }
90
+ }
91
+ if (identical) {
92
+ stampNote(rows, dest, () => writeStamp(dest, version));
93
+ record(manifest, 'copy', dest);
94
+ rows.push(['SAME', 'copy', dest]);
95
+ } else {
96
+ if (prev) {
97
+ const loc = moveToBackupInto(tdir, 'archgen', GLOBAL_BACKUP_REL, nextBackupTs(tdir));
98
+ const abs = join(tdir, ...loc.split('/'));
99
+ backups.push(abs);
100
+ rows.push(['BACKED-UP', '-', abs]);
101
+ }
102
+ rmSync(dest, { force: true, recursive: true }); // no-op after a move; guards exotic-FS fallbacks
103
+ cpSync(source, dest, { recursive: true });
104
+ stampNote(rows, dest, () => writeStamp(dest, version));
105
+ record(manifest, 'copy', dest);
106
+ rows.push(['OK', 'copy', dest]);
107
+ }
57
108
  } else {
58
- let same = false;
59
- try { same = lstatSync(dest).isSymbolicLink(); } catch { /* absent */ }
60
- if (!same) symlinkSync(source, dest, 'dir');
109
+ let status = 'OK';
110
+ const prev = lstatSafe(dest);
111
+ if (prev && prev.isSymbolicLink()) {
112
+ if (existsSync(dest)) {
113
+ // A live link resolving anywhere but our source belongs to the
114
+ // user: leave it untouched and OUT of the uninstall manifest, or
115
+ // `uninstall` would later delete their link. (install.sh replaces
116
+ // foreign links via ln -sfn — that destructive semantic is
117
+ // deliberately NOT copied here.)
118
+ const linkTarget = resolve(dirname(dest), readlinkSync(dest));
119
+ if (linkTarget !== source) {
120
+ rows.push(['KEPT', '-', dest + ' (foreign symlink, not recorded)']);
121
+ continue;
122
+ }
123
+ status = 'SAME';
124
+ } else {
125
+ // Dangling (npx cache eviction): recreate against the current
126
+ // source instead of leaving users broken while reporting SAME.
127
+ unlinkSync(dest); // removes the link itself, never any target
128
+ symlinkSync(source, dest, 'dir');
129
+ status = 'REPAIRED';
130
+ }
131
+ } else if (!prev) {
132
+ symlinkSync(source, dest, 'dir');
133
+ } else {
134
+ // Real directory/file at dest is NEVER clobbered: symlinkSync fails
135
+ // with EEXIST and surfaces as FAILED below (historical behavior).
136
+ symlinkSync(source, dest, 'dir');
137
+ }
138
+ // The link itself holds no bytes: the stamp must live INSIDE the real
139
+ // directory the link resolves to (the shared copy) so a read of
140
+ // <link>/.archgen-version sees it — true for symlinks and Windows
141
+ // junctions alike.
142
+ stampNote(rows, dest, () => writeStamp(source, version));
61
143
  record(manifest, 'link', dest);
62
- rows.push([same ? 'SAME' : 'OK', 'link', dest]);
144
+ rows.push([status, 'link', dest]);
63
145
  }
64
146
  } catch (e) {
65
147
  failures++;
66
148
  rows.push(['FAILED', '-', dest + ' (' + e.message + ')']);
67
149
  }
68
150
  }
69
- return { rows, failures, manifest };
151
+ return { rows, failures, manifest, backups };
152
+ }
153
+
154
+ /** Install succeeded even when the stamp could not be written (e.g. read-only vendor); surface it without failing. */
155
+ function stampNote(rows, dest, write) {
156
+ try {
157
+ write();
158
+ } catch (e) {
159
+ rows.push(['WARN', '-', dest + ' (installed; version stamp not written: ' + e.message + ')']);
160
+ }
70
161
  }
71
162
 
72
163
  export function uninstallGlobal(home = homedir()) {