archgen-skill 0.0.3 → 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 (33) hide show
  1. package/README.md +10 -0
  2. package/bin/archgen.mjs +23 -0
  3. package/lib/block.js +94 -9
  4. package/lib/config.js +96 -0
  5. package/lib/doctor.js +123 -26
  6. package/lib/init.js +3 -2
  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 +27 -12
  13. package/lib/uninstall-project.js +6 -3
  14. package/lib/version-stamp.js +47 -0
  15. package/package.json +37 -9
  16. package/vendor/skills/archgen/SKILL.md +242 -74
  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 +251 -7
  20. package/vendor/skills/archgen/references/interview.md +180 -16
  21. package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
  22. package/vendor/skills/archgen/references/orchestration.md +102 -39
  23. package/vendor/skills/archgen/references/platforms.md +1 -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 +82 -3
  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 +26 -6
  31. package/vendor/skills/archgen/scripts/update-agents.mjs +4 -2
  32. package/vendor/skills/archgen/scripts/validate.mjs +18 -1
  33. package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
package/README.md CHANGED
@@ -77,6 +77,16 @@ architecture contract, docs, ADRs, plans, and a `tasks.yaml` task graph — vers
77
77
  | `npx archgen-skill doctor [dir] [--check]` | Verify + auto-repair an installation: store integrity, version stamp, Claude link, blocks present exactly once, manifest resolution (`--check` reports without fixing) |
78
78
  | `npx archgen-skill --help` | Help |
79
79
 
80
+ ### Version stamp (`.archgen-version`)
81
+
82
+ Every skill tree the CLI writes carries a one-line `MAJOR.MINOR.PATCH\n` stamp
83
+ at its root — the canonical store from `init`, each `install --copy` tree, and
84
+ the real directory behind global symlink installs. `update` refreshes it via
85
+ its init/doctor pass. `doctor` lists every detected install with its stamped
86
+ version (`missing` / `unknown (pre-0.1 stamp)` when absent or unparseable).
87
+ The VS Code extension reads the same file to warn about outdated skills;
88
+ absent or corrupt means "unknown (legacy)", never an error.
89
+
80
90
  ## Why archgen
81
91
 
82
92
  AI coding agents fail at scale for predictable reasons: plans with hidden cycles,
package/bin/archgen.mjs CHANGED
@@ -8,6 +8,8 @@
8
8
  // (Claude Code, OpenCode, Cursor, agentskills)
9
9
  // npx archgen-skill uninstall Remove globally-installed copies (manifest-based)
10
10
  // --project [dir] Remove archgen from a specific project instead
11
+ // npx archgen-skill restore List/restore archgen backups (project + global vaults)
12
+ // npx archgen-skill migrate [dir] Evolve generated-artifact formats (--check default)
11
13
  //
12
14
  // Zero dependencies. Cross-platform (Windows included — no bash required).
13
15
 
@@ -17,6 +19,8 @@ import { installGlobal, uninstallGlobal } from '../lib/install.js';
17
19
  import { initProject } from '../lib/init.js';
18
20
  import { doctorProject } from '../lib/doctor.js';
19
21
  import { uninstallProject } from '../lib/uninstall-project.js';
22
+ import { restoreMain } from '../lib/restore.js';
23
+ import { migrateMain } from '../lib/migrate.js';
20
24
  import { compareSemver, fetchLatestVersion } from '../lib/version.js';
21
25
  import { spawnSync } from 'node:child_process';
22
26
  import { readFileSync } from 'node:fs';
@@ -54,6 +58,14 @@ Usage:
54
58
  managed blocks, canonical store (kept when
55
59
  customized) and manifest; .archgen feature
56
60
  folders and backups are preserved
61
+ npx archgen-skill restore List backup snapshots across the project and
62
+ global vaults; nothing is restored by default
63
+ [--snapshot <ts>] restore that snapshot (current state is backed
64
+ up first); --project <dir> / --global scope it
65
+ npx archgen-skill migrate [dir] Evolve generated-artifact formats in place;
66
+ --check (default) lists pending migrations
67
+ [--apply] apply them (every touched file is backed up)
68
+ [--migration <id>] target a single migration
57
69
  npx archgen-skill update [dir] Check npm for a newer archgen-skill, upgrade
58
70
  the global install when outdated, then
59
71
  re-init this project + doctor against the
@@ -132,6 +144,17 @@ try {
132
144
  }
133
145
  break;
134
146
  }
147
+ case 'restore': {
148
+ // restoreMain parses its own flags and returns its own exit code.
149
+ process.exitCode = await restoreMain(rest);
150
+ break;
151
+ }
152
+ case 'migrate': {
153
+ // migrateMain parses its own args and returns its own exit code
154
+ // (0 ok, 1 apply failure, 2 usage error).
155
+ process.exitCode = await migrateMain(rest);
156
+ break;
157
+ }
135
158
  case 'update': {
136
159
  const dir = positionalDir();
137
160
  const current = cliVersion();
package/lib/block.js CHANGED
@@ -8,13 +8,70 @@
8
8
  // AGENTS.md carries the full pointer block with an embedded features registry
9
9
  // (between FEATURES_START/FEATURES_END). CLAUDE.md carries a one-line
10
10
  // `@AGENTS.md` bridge inside the same managed markers.
11
+ //
12
+ // Block provenance: the FIRST line inside every managed block is a machine-
13
+ // readable `<!-- archgen:block vX.Y.Z -->` stamp sourced from the canonical
14
+ // config (lib/config.js loadConfig()). upsertBlock normalizes every block it
15
+ // writes to carry the CURRENT stamp, so legacy unversioned blocks upgrade in
16
+ // place on the next init/doctor pass and detectBlockVersion() lets doctor
17
+ // report stale formats precisely.
18
+
19
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
+ import { loadConfig } from './config.js';
21
+
22
+ // Markers + block-format version come from the single source of truth.
23
+ // Module-level load is safe: config resolution is module-relative and
24
+ // deterministic, and a missing/corrupt config fails loudly at import by design.
25
+ const CONFIG = loadConfig();
26
+
27
+ export const START = CONFIG.markers.start;
28
+ export const END = CONFIG.markers.end;
29
+ export const FEATURES_START = CONFIG.markers.featuresStart;
30
+ export const FEATURES_END = CONFIG.markers.featuresEnd;
31
+
32
+ /** The exact provenance line rendered as the first line of a managed block. */
33
+ export function provenanceLine() {
34
+ return '<!-- archgen:block v' + CONFIG.version + ' -->';
35
+ }
11
36
 
12
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
37
+ const PROVENANCE_LINE_RE = /^<!--\s*archgen:block\s+v[^\s>]*\s*-->$/;
13
38
 
14
- export const START = '<!-- archgen:start (managed block - do not edit between markers) -->';
15
- export const END = '<!-- archgen:end -->';
16
- export const FEATURES_START = '<!-- archgen:features:start -->';
17
- export const FEATURES_END = '<!-- archgen:features:end -->';
39
+ /**
40
+ * Normalize an archgen-rendered block so the current provenance line sits
41
+ * immediately after START: stale/differently-versioned stamps are dropped and
42
+ * the canonical one inserted. Blocks not beginning with START pass through
43
+ * untouched. Preserves the block's own CRLF/LF convention.
44
+ */
45
+ function injectProvenance(block) {
46
+ if (typeof block !== 'string' || !block.startsWith(START)) return block;
47
+ const eol = block.includes('\r\n') ? '\r\n' : '\n';
48
+ const lines = block.split(/\r?\n/).filter((l, i) => i === 0 || !PROVENANCE_LINE_RE.test(l.trim()));
49
+ lines.splice(1, 0, provenanceLine());
50
+ return lines.join(eol);
51
+ }
52
+
53
+ /**
54
+ * Detect the provenance stamp of a managed block inside file content.
55
+ * @param {string} content file contents (BOM/CRLF tolerated)
56
+ * @returns {{present: boolean, version: string|null}} present=true when a
57
+ * provenance line is found as the first non-empty line after START; version
58
+ * is the stamped semver, or null when the line is malformed.
59
+ */
60
+ export function detectBlockVersion(content) {
61
+ const src = String(content ?? '');
62
+ const s = src.indexOf(START);
63
+ if (s === -1) return { present: false, version: null };
64
+ const e = src.indexOf(END, s + START.length);
65
+ const head = src.slice(s + START.length, e === -1 ? undefined : e);
66
+ for (const line of head.split(/\r?\n/)) {
67
+ const t = line.trim();
68
+ if (t === '') continue;
69
+ if (!t.includes('archgen:block')) return { present: false, version: null };
70
+ const m = /^<!--\s*archgen:block\s+v([^\s>]+)\s*-->$/.exec(t);
71
+ return { present: true, version: m ? m[1] : null };
72
+ }
73
+ return { present: false, version: null };
74
+ }
18
75
 
19
76
  /**
20
77
  * Render the archgen pointer block for a project.
@@ -24,6 +81,7 @@ export const FEATURES_END = '<!-- archgen:features:end -->';
24
81
  export function renderBlock(skillRelPath = '.agents/skills/archgen', eol = '\n') {
25
82
  return [
26
83
  START,
84
+ provenanceLine(),
27
85
  '# ArchGen - Architecture Generation & Autonomous Task Execution',
28
86
  '',
29
87
  'This project uses the **archgen** skill, installed at `' + skillRelPath + '/`.',
@@ -52,6 +110,23 @@ export function renderBlock(skillRelPath = '.agents/skills/archgen', eol = '\n')
52
110
  ].join(eol);
53
111
  }
54
112
 
113
+ /**
114
+ * Canonical AGENTS.md managed-block text (LF-joined, provenance included).
115
+ * Integration contract: install.sh's heredoc is regenerated byte-identically
116
+ * from this function's output — do not let the two drift.
117
+ */
118
+ export function renderManagedBlockText() {
119
+ return renderBlock();
120
+ }
121
+
122
+ /**
123
+ * Canonical CLAUDE.md bridge text (LF-joined, provenance included).
124
+ * Same integration contract as renderManagedBlockText().
125
+ */
126
+ export function renderClaudeBridgeText() {
127
+ return [START, provenanceLine(), '@AGENTS.md', END].join('\n');
128
+ }
129
+
55
130
  /**
56
131
  * Insert or replace the managed block inside existing file content.
57
132
  * @param {string} existing file contents ('' when creating new)
@@ -59,18 +134,21 @@ export function renderBlock(skillRelPath = '.agents/skills/archgen', eol = '\n')
59
134
  * @param {string} eol line ending used when appending to empty/missing files
60
135
  */
61
136
  export function upsertBlock(existing, block, eol = '\n') {
137
+ // Every write carries the CURRENT provenance stamp regardless of what the
138
+ // caller rendered — this is what upgrades legacy unversioned blocks in place.
139
+ const fresh = injectProvenance(block);
62
140
  const trimmed = existing ?? '';
63
141
  const startIdx = trimmed.indexOf(START);
64
142
  const endIdx = trimmed.indexOf(END);
65
143
  if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
66
- return trimmed.slice(0, startIdx) + block + trimmed.slice(endIdx + END.length);
144
+ return trimmed.slice(0, startIdx) + fresh + trimmed.slice(endIdx + END.length);
67
145
  }
68
146
  if (startIdx !== -1 || endIdx !== -1) {
69
147
  throw new Error('found only one archgen marker - fix or remove it manually');
70
148
  }
71
- if (trimmed.trim() === '') return block + eol;
149
+ if (trimmed.trim() === '') return fresh + eol;
72
150
  const core = trimmed.replace(/(?:\r?\n)+$/, '');
73
- return core + eol + eol + block + eol;
151
+ return core + eol + eol + fresh + eol;
74
152
  }
75
153
 
76
154
  /**
@@ -93,6 +171,13 @@ function splitBom(raw) {
93
171
  return { bom, body: bom ? raw.slice(1) : raw };
94
172
  }
95
173
 
174
+ /** Write via sibling temp file + rename so readers never observe partial content. */
175
+ function atomicWrite(absPath, data) {
176
+ const tmp = absPath + '.tmp-' + process.pid + '-' + Date.now();
177
+ writeFileSync(tmp, data);
178
+ renameSync(tmp, absPath);
179
+ }
180
+
96
181
  /**
97
182
  * BOM/EOL-aware upsert of a managed block directly into a file.
98
183
  * Preserves a leading UTF-8 BOM and the file's CRLF/LF convention.
@@ -106,7 +191,7 @@ export function upsertManagedFile(absPath, blockLines) {
106
191
  const { bom, body } = splitBom(raw);
107
192
  const eol = body.includes('\r\n') ? '\r\n' : '\n';
108
193
  const next = upsertBlock(body, blockLines.join(eol), eol);
109
- writeFileSync(absPath, (bom ? '\uFEFF' : '') + next);
194
+ atomicWrite(absPath, (bom ? '\uFEFF' : '') + next);
110
195
  return existed;
111
196
  }
112
197
 
package/lib/config.js ADDED
@@ -0,0 +1,96 @@
1
+ // config.js — loader for the single-source-of-truth archgen.config.json.
2
+ //
3
+ // WHY: the version, managed-block marker strings, and well-known filenames
4
+ // were hardcoded independently in block.js, store.js, install.js, install.sh,
5
+ // and SKILL.md frontmatter — and drifted (SKILL.md advertised 1.0.0 while the
6
+ // CLI shipped 0.0.x). The canonical file lives at <repo-root>/archgen.config.json;
7
+ // a byte-identical derived copy ships inside the published npm package at
8
+ // vendor/skills/archgen/archgen.config.json (kept in sync by
9
+ // scripts/sync-config.mjs + scripts/sync-vendor.mjs).
10
+ //
11
+ // Resolution is module-relative (cwd-independent), tried in order:
12
+ // 1. ../../../archgen.config.json — monorepo checkout
13
+ // 2. ../vendor/skills/archgen/archgen.config.json — published package layout
14
+ // The first readable candidate wins; a candidate that exists but cannot be
15
+ // parsed/validated fails LOUDLY rather than silently falling through to a
16
+ // possibly stale copy. Cached per process.
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { dirname, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url)); // packages/cli/lib
23
+
24
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
25
+
26
+ /** Candidate canonical-config paths for a given lib directory. */
27
+ export function configCandidates(baseDir = HERE) {
28
+ return [
29
+ resolve(baseDir, '../../../archgen.config.json'),
30
+ resolve(baseDir, '../vendor/skills/archgen/archgen.config.json'),
31
+ ];
32
+ }
33
+
34
+ /**
35
+ * Parse + validate raw config JSON text.
36
+ * @param {string} text file contents
37
+ * @param {string} sourcePath path used in error messages
38
+ */
39
+ export function parseConfig(text, sourcePath) {
40
+ let cfg;
41
+ try {
42
+ cfg = JSON.parse(text);
43
+ } catch (e) {
44
+ throw new Error(`cannot load ${sourcePath}: invalid JSON (${e.message})`);
45
+ }
46
+ const bad = (msg) => new Error(`cannot load ${sourcePath}: ${msg}`);
47
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) throw bad('expected a JSON object');
48
+ if (typeof cfg.version !== 'string' || !SEMVER_RE.test(cfg.version)) {
49
+ throw bad(`"version" must be MAJOR.MINOR.PATCH, got ${JSON.stringify(cfg.version ?? null)}`);
50
+ }
51
+ if (typeof cfg.skillName !== 'string' || !cfg.skillName) throw bad('"skillName" must be a non-empty string');
52
+ const m = cfg.markers;
53
+ if (!m || typeof m !== 'object') throw bad('"markers" object is required');
54
+ for (const k of ['start', 'end', 'featuresStart', 'featuresEnd']) {
55
+ if (typeof m[k] !== 'string' || !m[k]) throw bad(`"markers.${k}" must be a non-empty string`);
56
+ }
57
+ const f = cfg.files;
58
+ if (!f || typeof f !== 'object') throw bad('"files" object is required');
59
+ for (const k of ['stamp', 'projectManifest', 'globalManifest', 'backupDir']) {
60
+ if (typeof f[k] !== 'string' || !f[k]) throw bad(`"files.${k}" must be a non-empty string`);
61
+ }
62
+ return cfg;
63
+ }
64
+
65
+ /**
66
+ * Resolve + parse the canonical config relative to `baseDir` (a lib dir).
67
+ * @returns {object} the validated config object
68
+ */
69
+ export function loadConfigFrom(baseDir) {
70
+ const candidates = configCandidates(baseDir);
71
+ for (const p of candidates) {
72
+ if (!existsSync(p)) continue;
73
+ let text = '';
74
+ try {
75
+ text = readFileSync(p, 'utf8');
76
+ } catch (e) {
77
+ throw new Error(`cannot load ${p}: ${e.message}`);
78
+ }
79
+ return parseConfig(text, p);
80
+ }
81
+ throw new Error(
82
+ 'archgen config not found. Expected one of:\n' +
83
+ candidates.map((c) => ' - ' + c).join('\n') +
84
+ '\nIn a repository checkout archgen.config.json lives at the repo root; ' +
85
+ 'in the published package it is vendored at vendor/skills/archgen/. ' +
86
+ 'Run `npm run sync:config` inside packages/cli to (re)generate both.',
87
+ );
88
+ }
89
+
90
+ let cached = null;
91
+
92
+ /** Load the canonical config for this process (cached after first call). */
93
+ export function loadConfig() {
94
+ if (!cached) cached = loadConfigFrom(HERE);
95
+ return cached;
96
+ }
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, writeFileSync } 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, 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';
20
28
 
21
29
  function lstatSafe(p) {
22
30
  try { return lstatSync(p); } catch { return null; }
@@ -35,27 +43,59 @@ 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
+ }
59
99
  }
60
100
 
61
101
  // 2. Version stamp currency.
@@ -69,9 +109,8 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
69
109
  } else if (checkOnly) {
70
110
  add('WOULD-FIX', `version stamp ${stamp || 'missing'} is stale -> ${version}`);
71
111
  } else {
72
- mkdirSync(dirname(stampAbs), { recursive: true });
73
- writeFileSync(stampAbs, version + '\n');
74
- add('FIXED', `version stamp updated ${stamp || 'missing'} -> ${version}`);
112
+ attempt(() => writeStamp(storeAbs, version), 'FIXED',
113
+ `version stamp updated ${stamp || 'missing'} -> ${version}`, 'cannot write version stamp');
75
114
  }
76
115
 
77
116
  // 3. Claude link resolution.
@@ -111,28 +150,56 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
111
150
  add('FIXED', 'AGENTS.md created with managed block');
112
151
  }
113
152
  } else {
114
- const raw = readFileSync(agentsAbs, 'utf8');
153
+ let raw = readFileSync(agentsAbs, 'utf8');
115
154
  const starts = countOccurrences(raw, START);
116
155
  const ends = countOccurrences(raw, END);
117
- if (starts === 0) {
156
+ if (starts === 0 && ends === 0) {
118
157
  if (checkOnly) add('WOULD-FIX', 'AGENTS.md has no archgen block -> added');
119
158
  else {
120
- upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n'));
121
- add('FIXED', 'AGENTS.md managed block added');
159
+ attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
160
+ 'AGENTS.md managed block added', 'cannot add AGENTS.md managed block');
122
161
  }
123
162
  } else if (starts > 1 || ends > 1) {
124
163
  add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
125
- } else if (ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
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.
126
167
  add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
127
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
+ }
128
186
  const fsCount = countOccurrences(raw, FEATURES_START);
129
187
  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) {
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) {
132
199
  if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing features registry -> inserted');
133
200
  else {
134
- upsertFeaturesRegistry(agentsAbs, []);
135
- add('FIXED', 'AGENTS.md features registry inserted');
201
+ attempt(() => upsertFeaturesRegistry(agentsAbs, []), 'FIXED',
202
+ 'AGENTS.md features registry inserted', 'cannot insert AGENTS.md features registry');
136
203
  }
137
204
  } else {
138
205
  add('FAIL', 'AGENTS.md has broken archgen:features markers - fix manually');
@@ -144,8 +211,8 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
144
211
  if (!existsSync(claudeMdAbs)) {
145
212
  if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing -> created with @AGENTS.md bridge');
146
213
  else {
147
- upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]);
148
- add('FIXED', 'CLAUDE.md created with @AGENTS.md bridge');
214
+ attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
215
+ 'CLAUDE.md created with @AGENTS.md bridge', 'cannot create CLAUDE.md bridge');
149
216
  }
150
217
  } else {
151
218
  const raw = readFileSync(claudeMdAbs, 'utf8');
@@ -155,13 +222,16 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
155
222
  const ends = countOccurrences(raw, END);
156
223
  if (imports && starts <= 1 && ends <= 1) add('OK', 'CLAUDE.md bridges to AGENTS.md');
157
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');
158
226
  else if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing @AGENTS.md bridge -> written');
159
227
  else {
160
- upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]);
161
- add('FIXED', 'CLAUDE.md @AGENTS.md bridge written');
228
+ attempt(() => upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]), 'FIXED',
229
+ 'CLAUDE.md @AGENTS.md bridge written', 'cannot write CLAUDE.md bridge');
162
230
  }
163
231
  }
164
232
 
233
+ for (const [status, msg] of deferredStoreFindings) add(status, msg);
234
+
165
235
  // 6. Manifest entries resolve.
166
236
  const manifest = loadManifest(root);
167
237
  if (!manifest) {
@@ -180,7 +250,34 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
180
250
  }
181
251
  }
182
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
+
183
280
  const tally = { OK: 0, FIXED: 0, 'WOULD-FIX': 0, WARN: 0, FAIL: 0 };
184
281
  for (const c of checks) tally[c.status]++;
185
- return { root, checks, tally, failures: tally.FAIL };
282
+ return { root, checks, tally, failures: opFailures };
186
283
  }
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
24
  import { CLAUDE_LINK_REL, STORE_REL, VERSION_FILE, appendEntry, cliVersion, hashDir, moveToBackup } from './store.js';
25
+ import { writeStamp } from './version-stamp.js';
25
26
 
26
27
  const CONTEXT_FILES = ['AGENTS.md', 'CLAUDE.md'];
27
28
 
@@ -128,7 +129,7 @@ export function initProject(projectDir, packageRoot, opts = {}) {
128
129
  mkdirSync(skillsParent, { recursive: true });
129
130
  const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
130
131
  cpSync(source, tmp, { recursive: true });
131
- writeFileSync(join(tmp, VERSION_FILE), version + '\n');
132
+ writeStamp(tmp, version);
132
133
  rmSync(storeAbs, { recursive: true, force: true });
133
134
  renameSync(tmp, storeAbs);
134
135
  appendEntry(root, 'dir', STORE_REL);