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.
- package/README.md +10 -0
- package/bin/archgen.mjs +23 -0
- package/lib/block.js +94 -9
- package/lib/config.js +96 -0
- package/lib/doctor.js +123 -26
- package/lib/init.js +3 -2
- package/lib/install.js +105 -14
- package/lib/migrate.js +88 -0
- package/lib/migrations/001-stamp-provenance.mjs +77 -0
- package/lib/migrations/index.mjs +170 -0
- package/lib/restore.js +239 -0
- package/lib/store.js +27 -12
- package/lib/uninstall-project.js +6 -3
- package/lib/version-stamp.js +47 -0
- package/package.json +37 -9
- package/vendor/skills/archgen/SKILL.md +242 -74
- package/vendor/skills/archgen/archgen.config.json +16 -0
- package/vendor/skills/archgen/assets/skill-registry.json +4 -3
- package/vendor/skills/archgen/references/artifact-templates.md +251 -7
- package/vendor/skills/archgen/references/interview.md +180 -16
- package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
- package/vendor/skills/archgen/references/orchestration.md +102 -39
- package/vendor/skills/archgen/references/platforms.md +1 -14
- package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
- package/vendor/skills/archgen/scripts/impact.mjs +14 -1
- package/vendor/skills/archgen/scripts/lib/graph.mjs +82 -3
- package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
- package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
- package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
- package/vendor/skills/archgen/scripts/set-status.mjs +26 -6
- package/vendor/skills/archgen/scripts/update-agents.mjs +4 -2
- package/vendor/skills/archgen/scripts/validate.mjs +18 -1
- 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
|
-
|
|
37
|
+
const PROVENANCE_LINE_RE = /^<!--\s*archgen:block\s+v[^\s>]*\s*-->$/;
|
|
13
38
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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) +
|
|
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
|
|
149
|
+
if (trimmed.trim() === '') return fresh + eol;
|
|
72
150
|
const core = trimmed.replace(/(?:\r?\n)+$/, '');
|
|
73
|
-
return core + eol + 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
|
-
|
|
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).
|
|
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 {
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
132
|
+
writeStamp(tmp, version);
|
|
132
133
|
rmSync(storeAbs, { recursive: true, force: true });
|
|
133
134
|
renameSync(tmp, storeAbs);
|
|
134
135
|
appendEntry(root, 'dir', STORE_REL);
|