archgen-skill 0.0.2 → 0.0.3
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 +19 -3
- package/bin/archgen.mjs +123 -12
- package/lib/block.js +109 -5
- package/lib/doctor.js +186 -0
- package/lib/init.js +129 -27
- package/lib/store.js +146 -0
- package/lib/uninstall-project.js +92 -0
- package/lib/version.js +23 -0
- package/package.json +1 -1
- package/vendor/skills/archgen/SKILL.md +14 -3
- package/vendor/skills/archgen/references/platforms.md +17 -0
- package/vendor/skills/archgen/scripts/update-agents.mjs +202 -0
package/README.md
CHANGED
|
@@ -21,13 +21,27 @@ Antigravity** — and any [agentskills.io](https://agentskills.io)-compatible ha
|
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
24
|
-
**Prepare a project** (recommended) —
|
|
25
|
-
|
|
24
|
+
**Prepare a project** (recommended) — installs ONE canonical skill copy plus
|
|
25
|
+
harness bridges so every agent auto-discovers it:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
npx archgen-skill init
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
What `init` writes (nothing is ever duplicated):
|
|
32
|
+
|
|
33
|
+
| Path | What |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `.agents/skills/archgen/` | **The only real copy** — the agentskills.io standard location read natively by OpenCode, Cursor, Codex, Gemini CLI, Copilot and Antigravity |
|
|
36
|
+
| `.claude/skills/archgen → ../../.agents/skills/archgen` | Relative symlink for Claude Code (the one harness needing it). Skipped gracefully where symlinks need privileges — Claude still works via the bridge below |
|
|
37
|
+
| `AGENTS.md` | Managed hub block: skill pointer + quick triggers + a features registry that stays current as you add features |
|
|
38
|
+
| `CLAUDE.md` | One-line bridge: `@AGENTS.md` (existing files keep all their content; if it already imports AGENTS.md we write nothing) |
|
|
39
|
+
|
|
40
|
+
Existing projects with user content are safe: managed blocks upsert between
|
|
41
|
+
markers, modified skill copies are backed up to `.archgen/.backup/<timestamp>/`
|
|
42
|
+
before refresh, and legacy dual-copy layouts are migrated to symlinks
|
|
43
|
+
automatically. Verify anytime with `archgen-skill doctor`.
|
|
44
|
+
|
|
31
45
|
**Or install globally** into every detected harness:
|
|
32
46
|
|
|
33
47
|
```bash
|
|
@@ -56,9 +70,11 @@ architecture contract, docs, ADRs, plans, and a `tasks.yaml` task graph — vers
|
|
|
56
70
|
|
|
57
71
|
| Command | What it does |
|
|
58
72
|
| --- | --- |
|
|
59
|
-
| `npx archgen-skill init [dir]` |
|
|
73
|
+
| `npx archgen-skill init [dir]` | Install the single canonical skill store at `.agents/skills/archgen`, add the Claude symlink adapter, write the AGENTS.md hub + CLAUDE.md `@AGENTS.md` bridge |
|
|
60
74
|
| `npx archgen-skill install [--copy]` | Install into global harness skill dirs (symlinks by default, real copies with `--copy`) |
|
|
61
75
|
| `npx archgen-skill uninstall` | Remove globally-installed copies (manifest-recorded, safe removal) |
|
|
76
|
+
| `npx archgen-skill uninstall --project [dir]` | Remove project install: strips managed blocks, our symlink and the store **only if unmodified** — feature folders in `.archgen/` and all user content are preserved |
|
|
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) |
|
|
62
78
|
| `npx archgen-skill --help` | Help |
|
|
63
79
|
|
|
64
80
|
## Why archgen
|
package/bin/archgen.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// archgen — npm CLI for the archgen skill.
|
|
3
3
|
//
|
|
4
|
-
// npx archgen-skill init
|
|
5
|
-
// AGENTS.md / CLAUDE.md context pointers
|
|
4
|
+
// npx archgen-skill init [dir] ONE canonical skill store + claude symlink
|
|
5
|
+
// adapter + AGENTS.md / CLAUDE.md context pointers
|
|
6
|
+
// npx archgen-skill doctor [dir] Verify/repair a project install (--check = report only)
|
|
6
7
|
// npx archgen-skill install Install skill into global harness dirs
|
|
7
8
|
// (Claude Code, OpenCode, Cursor, agentskills)
|
|
8
9
|
// npx archgen-skill uninstall Remove globally-installed copies (manifest-based)
|
|
10
|
+
// --project [dir] Remove archgen from a specific project instead
|
|
9
11
|
//
|
|
10
12
|
// Zero dependencies. Cross-platform (Windows included — no bash required).
|
|
11
13
|
|
|
@@ -13,20 +15,50 @@ import { dirname, resolve } from 'node:path';
|
|
|
13
15
|
import { fileURLToPath } from 'node:url';
|
|
14
16
|
import { installGlobal, uninstallGlobal } from '../lib/install.js';
|
|
15
17
|
import { initProject } from '../lib/init.js';
|
|
18
|
+
import { doctorProject } from '../lib/doctor.js';
|
|
19
|
+
import { uninstallProject } from '../lib/uninstall-project.js';
|
|
20
|
+
import { compareSemver, fetchLatestVersion } from '../lib/version.js';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
16
23
|
|
|
17
24
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
25
|
|
|
26
|
+
function cliVersion() {
|
|
27
|
+
return JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runInitAndDoctor(dir) {
|
|
31
|
+
const r = initProject(dir, PACKAGE_ROOT);
|
|
32
|
+
console.log(' = store refreshed at ' + r.storePath);
|
|
33
|
+
doctorProject(dir, PACKAGE_ROOT, {});
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
const HELP = `archgen — architecture generation & autonomous task execution for coding agents
|
|
20
37
|
|
|
21
38
|
Usage:
|
|
22
|
-
npx archgen-skill init [dir] Prepare a project:
|
|
23
|
-
.agents/skills/archgen
|
|
24
|
-
|
|
25
|
-
|
|
39
|
+
npx archgen-skill init [dir] Prepare a project: create the canonical skill
|
|
40
|
+
store at .agents/skills/archgen, symlink
|
|
41
|
+
.claude/skills/archgen -> ../../.agents/skills/archgen,
|
|
42
|
+
and generate AGENTS.md / CLAUDE.md pointer blocks.
|
|
43
|
+
[--force] proceed automatically; divergent copies are
|
|
44
|
+
backed up to .archgen/.backup/ (never destroyed)
|
|
45
|
+
npx archgen-skill doctor [dir] Health-check a project install: store integrity,
|
|
46
|
+
version stamp, claude link, managed blocks,
|
|
47
|
+
manifest. Repairs safe issues in place.
|
|
48
|
+
[--check] report only - change nothing
|
|
26
49
|
npx archgen-skill install Install into global harness skill dirs
|
|
27
50
|
[--copy] real copies instead of symlinks
|
|
28
51
|
[--uninstall] remove manifest-recorded entries
|
|
29
|
-
npx archgen-skill uninstall
|
|
52
|
+
npx archgen-skill uninstall Remove globally-installed copies (manifest-based)
|
|
53
|
+
--project [dir] remove archgen from a project: claude link,
|
|
54
|
+
managed blocks, canonical store (kept when
|
|
55
|
+
customized) and manifest; .archgen feature
|
|
56
|
+
folders and backups are preserved
|
|
57
|
+
npx archgen-skill update [dir] Check npm for a newer archgen-skill, upgrade
|
|
58
|
+
the global install when outdated, then
|
|
59
|
+
re-init this project + doctor against the
|
|
60
|
+
new version. No-op when already latest.
|
|
61
|
+
npx archgen-skill --version Print the running CLI version
|
|
30
62
|
npx archgen-skill --help This help
|
|
31
63
|
|
|
32
64
|
Docs: https://github.com/akashmaurya99/archgen`;
|
|
@@ -40,19 +72,40 @@ function flag(name) {
|
|
|
40
72
|
return true;
|
|
41
73
|
}
|
|
42
74
|
|
|
75
|
+
function positionalDir() {
|
|
76
|
+
return rest[0] ? resolve(rest[0]) : process.cwd();
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
try {
|
|
44
80
|
switch (cmd) {
|
|
45
81
|
case 'init': {
|
|
46
|
-
|
|
47
|
-
const r = initProject(
|
|
48
|
-
console.log('archgen:
|
|
49
|
-
|
|
82
|
+
flag('--force');
|
|
83
|
+
const r = initProject(positionalDir(), PACKAGE_ROOT);
|
|
84
|
+
console.log('archgen: project ready (single-store layout)');
|
|
85
|
+
console.log(' + store ' + r.storePath);
|
|
86
|
+
if (r.claudeLink === 'created') console.log(' + link .claude/skills/archgen -> ../../.agents/skills/archgen');
|
|
87
|
+
else if (r.claudeLink === 'existing') console.log(' = link .claude/skills/archgen (already points at the store)');
|
|
88
|
+
else if (r.claudeLink === 'migrated') console.log(' ~ link .claude/skills/archgen migrated from copy to symlink');
|
|
89
|
+
else if (r.claudeLink === 'kept-divergent') console.log(' ! kept .claude/skills/archgen (divergent - left untouched)');
|
|
90
|
+
else console.log(' ! skip .claude/skills/archgen (symlink unavailable)');
|
|
91
|
+
for (const b of r.backups) console.log(' ~ backup ' + b + ' (previous store moved aside)');
|
|
50
92
|
for (const f of r.contextFiles) {
|
|
51
93
|
console.log(` ${r.createdContextFiles.includes(f) ? 'created' : 'updated'} ${f} (archgen pointer block)`);
|
|
52
94
|
}
|
|
95
|
+
for (const w of r.warnings) console.log(' note: ' + w);
|
|
53
96
|
console.log('\nNext: open this project in your agent and say "generate architecture" or "add feature X".');
|
|
54
97
|
break;
|
|
55
98
|
}
|
|
99
|
+
case 'doctor': {
|
|
100
|
+
const check = flag('--check');
|
|
101
|
+
const r = doctorProject(positionalDir(), PACKAGE_ROOT, { check });
|
|
102
|
+
console.log('archgen doctor — ' + r.root + (check ? ' (check mode: no changes made)' : ''));
|
|
103
|
+
for (const c of r.checks) console.log(` [${c.status.toLowerCase().padEnd(9)}] ${c.msg}`);
|
|
104
|
+
const t = r.tally;
|
|
105
|
+
console.log(`Summary: ${t.OK} ok, ${t.FIXED} fixed, ${t['WOULD-FIX']} would-fix, ${t.WARN} warning(s), ${t.FAIL} failure(s)`);
|
|
106
|
+
if (r.failures > 0) process.exitCode = 1;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
56
109
|
case 'install': {
|
|
57
110
|
const copy = flag('--copy');
|
|
58
111
|
if (flag('--uninstall')) { uninstall(); break; }
|
|
@@ -64,7 +117,65 @@ try {
|
|
|
64
117
|
break;
|
|
65
118
|
}
|
|
66
119
|
case 'uninstall': {
|
|
67
|
-
|
|
120
|
+
if (flag('--project')) {
|
|
121
|
+
const r = uninstallProject(positionalDir(), PACKAGE_ROOT);
|
|
122
|
+
console.log('archgen: project uninstall — ' + r.root);
|
|
123
|
+
if (r.linkRemoved) console.log(' - .claude/skills/archgen (link)');
|
|
124
|
+
if (r.strippedBlocks.length) console.log(' - managed blocks stripped from ' + r.strippedBlocks.join(', '));
|
|
125
|
+
if (r.storeRemoved) console.log(' - .agents/skills/archgen (unmodified store)');
|
|
126
|
+
if (r.storeKept) console.log(' = .agents/skills/archgen KEPT (customized)');
|
|
127
|
+
if (r.manifestRemoved) console.log(' - .archgen/.install-manifest.json');
|
|
128
|
+
for (const w of r.warnings) console.log(' note: ' + w);
|
|
129
|
+
console.log(' .archgen feature folders and backups are preserved.');
|
|
130
|
+
} else {
|
|
131
|
+
uninstall();
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'update': {
|
|
136
|
+
const dir = positionalDir();
|
|
137
|
+
const current = cliVersion();
|
|
138
|
+
console.log('archgen update — running v' + current + ', checking npm registry…');
|
|
139
|
+
const latest = fetchLatestVersion('archgen-skill');
|
|
140
|
+
if (!latest) {
|
|
141
|
+
console.log(' ! could not reach the npm registry (offline?). Nothing changed.');
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
const cmp = compareSemver(latest, current);
|
|
145
|
+
if (cmp === 0) {
|
|
146
|
+
console.log(' = already on the latest published version (' + latest + ').');
|
|
147
|
+
console.log(' Refreshing this project against it anyway…');
|
|
148
|
+
runInitAndDoctor(dir);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
if (cmp < 0) {
|
|
152
|
+
console.log(' ↑ running v' + current + ' is newer than npm (' + latest + ') — not published yet.');
|
|
153
|
+
console.log(' Refreshing this project against the running version…');
|
|
154
|
+
runInitAndDoctor(dir);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
console.log(' ↑ newer version available: ' + latest);
|
|
158
|
+
console.log(' upgrading global install: npm install -g archgen-skill@' + latest);
|
|
159
|
+
const up = spawnSync('npm', ['install', '-g', 'archgen-skill@' + latest], { stdio: 'inherit' });
|
|
160
|
+
if (up.status !== 0) {
|
|
161
|
+
console.error(' ! global upgrade failed — run it manually, then re-run: archgen-skill update');
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
// The running process is the OLD code; re-exec the freshly installed binary.
|
|
166
|
+
const bin = spawnSync('command', ['-v', 'archgen-skill'], { encoding: 'utf8', shell: true });
|
|
167
|
+
const freshBin = (bin.stdout || '').trim();
|
|
168
|
+
if (!freshBin) {
|
|
169
|
+
console.log('\nUpgraded to v' + latest + '. Now finish per project with:\n npx archgen-skill@latest init');
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
console.log('\nre-running init + doctor with the new binary (' + latest + ')…');
|
|
173
|
+
spawnSync(freshBin, ['init', dir], { stdio: 'inherit' });
|
|
174
|
+
spawnSync(freshBin, ['doctor', dir], { stdio: 'inherit' });
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case '--version': case '-v': {
|
|
178
|
+
console.log(cliVersion());
|
|
68
179
|
break;
|
|
69
180
|
}
|
|
70
181
|
case '--help': case '-h': case undefined: {
|
package/lib/block.js
CHANGED
|
@@ -4,15 +4,24 @@
|
|
|
4
4
|
// archgen writes lives between START/END markers; re-runs replace the block
|
|
5
5
|
// in place, uninstalls strip it, and anything the user wrote outside the
|
|
6
6
|
// markers is untouched.
|
|
7
|
+
//
|
|
8
|
+
// AGENTS.md carries the full pointer block with an embedded features registry
|
|
9
|
+
// (between FEATURES_START/FEATURES_END). CLAUDE.md carries a one-line
|
|
10
|
+
// `@AGENTS.md` bridge inside the same managed markers.
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
13
|
|
|
8
14
|
export const START = '<!-- archgen:start (managed block - do not edit between markers) -->';
|
|
9
15
|
export const END = '<!-- archgen:end -->';
|
|
16
|
+
export const FEATURES_START = '<!-- archgen:features:start -->';
|
|
17
|
+
export const FEATURES_END = '<!-- archgen:features:end -->';
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* Render the archgen pointer block for a project.
|
|
13
21
|
* @param {string} skillRelPath relative path to the skill dir from project root
|
|
22
|
+
* @param {string} eol line ending used to join the block lines
|
|
14
23
|
*/
|
|
15
|
-
export function renderBlock(skillRelPath = '.agents/skills/archgen') {
|
|
24
|
+
export function renderBlock(skillRelPath = '.agents/skills/archgen', eol = '\n') {
|
|
16
25
|
return [
|
|
17
26
|
START,
|
|
18
27
|
'# ArchGen - Architecture Generation & Autonomous Task Execution',
|
|
@@ -32,16 +41,24 @@ export function renderBlock(skillRelPath = '.agents/skills/archgen') {
|
|
|
32
41
|
'- Generated artifacts live ONLY under `.archgen/<slug>/`',
|
|
33
42
|
'- Never hand-edit task statuses - use `scripts/set-status.mjs` (comment-safe)',
|
|
34
43
|
'- Two gates before any execution: verifier approval, then human approval',
|
|
44
|
+
'',
|
|
45
|
+
'## Features registry',
|
|
46
|
+
'',
|
|
47
|
+
FEATURES_START,
|
|
48
|
+
'| Feature | Status | Updated |',
|
|
49
|
+
'| --- | --- | --- |',
|
|
50
|
+
FEATURES_END,
|
|
35
51
|
END,
|
|
36
|
-
].join(
|
|
52
|
+
].join(eol);
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
/**
|
|
40
56
|
* Insert or replace the managed block inside existing file content.
|
|
41
57
|
* @param {string} existing file contents ('' when creating new)
|
|
42
58
|
* @param {string} block rendered block
|
|
59
|
+
* @param {string} eol line ending used when appending to empty/missing files
|
|
43
60
|
*/
|
|
44
|
-
export function upsertBlock(existing, block) {
|
|
61
|
+
export function upsertBlock(existing, block, eol = '\n') {
|
|
45
62
|
const trimmed = existing ?? '';
|
|
46
63
|
const startIdx = trimmed.indexOf(START);
|
|
47
64
|
const endIdx = trimmed.indexOf(END);
|
|
@@ -51,8 +68,9 @@ export function upsertBlock(existing, block) {
|
|
|
51
68
|
if (startIdx !== -1 || endIdx !== -1) {
|
|
52
69
|
throw new Error('found only one archgen marker - fix or remove it manually');
|
|
53
70
|
}
|
|
54
|
-
if (trimmed.trim() === '') return block +
|
|
55
|
-
|
|
71
|
+
if (trimmed.trim() === '') return block + eol;
|
|
72
|
+
const core = trimmed.replace(/(?:\r?\n)+$/, '');
|
|
73
|
+
return core + eol + eol + block + eol;
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
/**
|
|
@@ -69,3 +87,89 @@ export function stripBlock(existing) {
|
|
|
69
87
|
out = out.replace(/^\n+/, '').replace(/\n{3,}/g, '\n\n');
|
|
70
88
|
return { content: out, hadBlock: true };
|
|
71
89
|
}
|
|
90
|
+
|
|
91
|
+
function splitBom(raw) {
|
|
92
|
+
const bom = raw.charCodeAt(0) === 0xfeff;
|
|
93
|
+
return { bom, body: bom ? raw.slice(1) : raw };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* BOM/EOL-aware upsert of a managed block directly into a file.
|
|
98
|
+
* Preserves a leading UTF-8 BOM and the file's CRLF/LF convention.
|
|
99
|
+
* @param {string} absPath target file (created when missing)
|
|
100
|
+
* @param {string[]} blockLines lines between the managed markers
|
|
101
|
+
* @returns {boolean} whether the file existed before the call
|
|
102
|
+
*/
|
|
103
|
+
export function upsertManagedFile(absPath, blockLines) {
|
|
104
|
+
const existed = existsSync(absPath);
|
|
105
|
+
const raw = existed ? readFileSync(absPath, 'utf8') : '';
|
|
106
|
+
const { bom, body } = splitBom(raw);
|
|
107
|
+
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
|
108
|
+
const next = upsertBlock(body, blockLines.join(eol), eol);
|
|
109
|
+
writeFileSync(absPath, (bom ? '\uFEFF' : '') + next);
|
|
110
|
+
return existed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* True when the file already contains a trimmed `@AGENTS.md` line anywhere
|
|
115
|
+
* (BOM- and CRLF-tolerant).
|
|
116
|
+
*/
|
|
117
|
+
export function importsAgents(absPath) {
|
|
118
|
+
let raw = '';
|
|
119
|
+
try { raw = readFileSync(absPath, 'utf8'); } catch { return false; }
|
|
120
|
+
const { body } = splitBom(raw);
|
|
121
|
+
return body.split(/\r?\n/).some((l) => l.trim() === '@AGENTS.md');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Strip the managed block from a file on disk, preserving BOM. */
|
|
125
|
+
export function stripManagedFile(absPath) {
|
|
126
|
+
let raw = '';
|
|
127
|
+
try { raw = readFileSync(absPath, 'utf8'); } catch { return { hadBlock: false }; }
|
|
128
|
+
const { bom, body } = splitBom(raw);
|
|
129
|
+
const { content, hadBlock } = stripBlock(body);
|
|
130
|
+
if (hadBlock) writeFileSync(absPath, (bom ? '\uFEFF' : '') + content);
|
|
131
|
+
return { hadBlock };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderRow(row) {
|
|
135
|
+
const cells = Array.isArray(row)
|
|
136
|
+
? row.map(String)
|
|
137
|
+
: [row.feature ?? row.name ?? '', row.status ?? '', row.updated ?? ''].map(String);
|
|
138
|
+
return '| ' + cells.join(' | ') + ' |';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Replace the content between the archgen:features markers in a file with the
|
|
143
|
+
* registry table (header rows plus `rows`). When the markers are missing but
|
|
144
|
+
* the managed block exists, the registry section is inserted before its END
|
|
145
|
+
* marker. Never touches anything outside the markers.
|
|
146
|
+
* @param {string} fileAbsPath absolute path to AGENTS.md-style file
|
|
147
|
+
* @param {Array<string[]|{feature:string,status:string,updated:string}>} rows
|
|
148
|
+
* @returns {string} the new file content
|
|
149
|
+
*/
|
|
150
|
+
export function upsertFeaturesRegistry(fileAbsPath, rows = []) {
|
|
151
|
+
const raw = readFileSync(fileAbsPath, 'utf8');
|
|
152
|
+
const table = [
|
|
153
|
+
'| Feature | Status | Updated |',
|
|
154
|
+
'| --- | --- | --- |',
|
|
155
|
+
...rows.map(renderRow),
|
|
156
|
+
].join('\n');
|
|
157
|
+
const s = raw.indexOf(FEATURES_START);
|
|
158
|
+
const e = raw.indexOf(FEATURES_END);
|
|
159
|
+
let next;
|
|
160
|
+
if (s !== -1 && e !== -1 && e > s) {
|
|
161
|
+
next = raw.slice(0, s + FEATURES_START.length) + '\n' + table + '\n' + raw.slice(e);
|
|
162
|
+
} else if (s === -1 && e === -1) {
|
|
163
|
+
const bStart = raw.indexOf(START);
|
|
164
|
+
const bEnd = raw.indexOf(END);
|
|
165
|
+
if (bStart === -1 || bEnd === -1 || bEnd < bStart) {
|
|
166
|
+
throw new Error('no archgen managed block - cannot place features registry');
|
|
167
|
+
}
|
|
168
|
+
const section = '\n## Features registry\n\n' + FEATURES_START + '\n' + table + '\n' + FEATURES_END + '\n';
|
|
169
|
+
next = raw.slice(0, bEnd) + section + raw.slice(bEnd);
|
|
170
|
+
} else {
|
|
171
|
+
throw new Error('found only one archgen:features marker - fix or remove it manually');
|
|
172
|
+
}
|
|
173
|
+
if (next !== raw) writeFileSync(fileAbsPath, next);
|
|
174
|
+
return next;
|
|
175
|
+
}
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
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). Exit non-zero only for unrepairable failures.
|
|
7
|
+
|
|
8
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import {
|
|
11
|
+
END,
|
|
12
|
+
FEATURES_END,
|
|
13
|
+
FEATURES_START,
|
|
14
|
+
START,
|
|
15
|
+
renderBlock,
|
|
16
|
+
upsertFeaturesRegistry,
|
|
17
|
+
upsertManagedFile,
|
|
18
|
+
} from './block.js';
|
|
19
|
+
import { CLAUDE_LINK_REL, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, loadManifest, saveManifest } from './store.js';
|
|
20
|
+
|
|
21
|
+
function lstatSafe(p) {
|
|
22
|
+
try { return lstatSync(p); } catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function countOccurrences(haystack, needle) {
|
|
26
|
+
let n = 0;
|
|
27
|
+
for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length)) n++;
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function relJoin(rel) {
|
|
32
|
+
return join(...rel.split('/'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} projectDir project root to inspect
|
|
37
|
+
* @param {string} packageRoot dir containing this CLI package
|
|
38
|
+
* @param {{check?: boolean}} opts check=true → report-only, no mutations
|
|
39
|
+
* @returns {{root: string, checks: {status: 'OK'|'FIXED'|'WOULD-FIX'|'WARN'|'FAIL', msg: string}[],
|
|
40
|
+
* tally: Record<string, number>, failures: number}}
|
|
41
|
+
*/
|
|
42
|
+
export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
43
|
+
const root = resolve(projectDir);
|
|
44
|
+
const checkOnly = opts.check === true;
|
|
45
|
+
const version = cliVersion(packageRoot);
|
|
46
|
+
const checks = [];
|
|
47
|
+
const add = (status, msg) => checks.push({ status, msg });
|
|
48
|
+
|
|
49
|
+
const storeAbs = join(root, relJoin(STORE_REL));
|
|
50
|
+
const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
|
|
51
|
+
const agentsAbs = join(root, 'AGENTS.md');
|
|
52
|
+
const claudeMdAbs = join(root, 'CLAUDE.md');
|
|
53
|
+
|
|
54
|
+
// 1. Canonical store validity.
|
|
55
|
+
if (existsSync(join(storeAbs, 'SKILL.md'))) {
|
|
56
|
+
add('OK', 'store .agents/skills/archgen (SKILL.md present)');
|
|
57
|
+
} else {
|
|
58
|
+
add('FAIL', 'store .agents/skills/archgen missing or incomplete - run `archgen init`');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Version stamp currency.
|
|
62
|
+
const stampAbs = join(storeAbs, VERSION_FILE);
|
|
63
|
+
let stamp = null;
|
|
64
|
+
try { stamp = readFileSync(stampAbs, 'utf8').trim(); } catch { /* absent */ }
|
|
65
|
+
if (!existsSync(storeAbs)) {
|
|
66
|
+
// already reported above; skip stamp handling without touching anything
|
|
67
|
+
} else if (stamp === version) {
|
|
68
|
+
add('OK', `version stamp ${version} (current)`);
|
|
69
|
+
} else if (checkOnly) {
|
|
70
|
+
add('WOULD-FIX', `version stamp ${stamp || 'missing'} is stale -> ${version}`);
|
|
71
|
+
} else {
|
|
72
|
+
mkdirSync(dirname(stampAbs), { recursive: true });
|
|
73
|
+
writeFileSync(stampAbs, version + '\n');
|
|
74
|
+
add('FIXED', `version stamp updated ${stamp || 'missing'} -> ${version}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 3. Claude link resolution.
|
|
78
|
+
const repairLink = () => {
|
|
79
|
+
if (checkOnly) {
|
|
80
|
+
add('WOULD-FIX', 'claude link missing/dangling -> ../../.agents/skills/archgen');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
try { unlinkSync(claudeAbs); } catch { /* absent */ }
|
|
85
|
+
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
86
|
+
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
87
|
+
add('FIXED', 'claude link recreated -> ../../.agents/skills/archgen');
|
|
88
|
+
} catch (e) {
|
|
89
|
+
add('FAIL', 'cannot recreate claude link: ' + e.message);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const st = lstatSafe(claudeAbs);
|
|
93
|
+
if (st && st.isSymbolicLink()) {
|
|
94
|
+
const target = resolve(dirname(claudeAbs), readlinkSync(claudeAbs));
|
|
95
|
+
if (target === storeAbs && existsSync(claudeAbs)) add('OK', 'claude link resolves to the canonical store');
|
|
96
|
+
else if (existsSync(claudeAbs)) add('WARN', 'claude link is foreign (points elsewhere) - left untouched');
|
|
97
|
+
else repairLink();
|
|
98
|
+
} else if (st && st.isDirectory()) {
|
|
99
|
+
add('WARN', '.claude/skills/archgen is a real directory (not managed) - inspect manually');
|
|
100
|
+
} else if (st) {
|
|
101
|
+
add('WARN', '.claude/skills/archgen is a regular file - left untouched');
|
|
102
|
+
} else {
|
|
103
|
+
repairLink();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. AGENTS.md — managed block exactly once, features registry inside.
|
|
107
|
+
if (!existsSync(agentsAbs)) {
|
|
108
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing -> created with managed block');
|
|
109
|
+
else {
|
|
110
|
+
writeFileSync(agentsAbs, renderBlock(STORE_REL) + '\n');
|
|
111
|
+
add('FIXED', 'AGENTS.md created with managed block');
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
const raw = readFileSync(agentsAbs, 'utf8');
|
|
115
|
+
const starts = countOccurrences(raw, START);
|
|
116
|
+
const ends = countOccurrences(raw, END);
|
|
117
|
+
if (starts === 0) {
|
|
118
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md has no archgen block -> added');
|
|
119
|
+
else {
|
|
120
|
+
upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n'));
|
|
121
|
+
add('FIXED', 'AGENTS.md managed block added');
|
|
122
|
+
}
|
|
123
|
+
} else if (starts > 1 || ends > 1) {
|
|
124
|
+
add('FAIL', `AGENTS.md has ${starts} start / ${ends} end markers - fix manually`);
|
|
125
|
+
} else if (ends === 0 || raw.indexOf(START) > raw.indexOf(END)) {
|
|
126
|
+
add('FAIL', 'AGENTS.md has broken archgen markers - fix manually');
|
|
127
|
+
} else {
|
|
128
|
+
const fsCount = countOccurrences(raw, FEATURES_START);
|
|
129
|
+
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) {
|
|
132
|
+
if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing features registry -> inserted');
|
|
133
|
+
else {
|
|
134
|
+
upsertFeaturesRegistry(agentsAbs, []);
|
|
135
|
+
add('FIXED', 'AGENTS.md features registry inserted');
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
add('FAIL', 'AGENTS.md has broken archgen:features markers - fix manually');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 5. CLAUDE.md — @AGENTS.md bridge present exactly once.
|
|
144
|
+
if (!existsSync(claudeMdAbs)) {
|
|
145
|
+
if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing -> created with @AGENTS.md bridge');
|
|
146
|
+
else {
|
|
147
|
+
upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]);
|
|
148
|
+
add('FIXED', 'CLAUDE.md created with @AGENTS.md bridge');
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
const raw = readFileSync(claudeMdAbs, 'utf8');
|
|
152
|
+
const body = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
153
|
+
const imports = body.split(/\r?\n/).some((l) => l.trim() === '@AGENTS.md');
|
|
154
|
+
const starts = countOccurrences(raw, START);
|
|
155
|
+
const ends = countOccurrences(raw, END);
|
|
156
|
+
if (imports && starts <= 1 && ends <= 1) add('OK', 'CLAUDE.md bridges to AGENTS.md');
|
|
157
|
+
else if (starts > 1 || ends > 1) add('FAIL', 'CLAUDE.md has multiple archgen blocks - fix manually');
|
|
158
|
+
else if (checkOnly) add('WOULD-FIX', 'CLAUDE.md missing @AGENTS.md bridge -> written');
|
|
159
|
+
else {
|
|
160
|
+
upsertManagedFile(claudeMdAbs, [START, '@AGENTS.md', END]);
|
|
161
|
+
add('FIXED', 'CLAUDE.md @AGENTS.md bridge written');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 6. Manifest entries resolve.
|
|
166
|
+
const manifest = loadManifest(root);
|
|
167
|
+
if (!manifest) {
|
|
168
|
+
if (existsSync(storeAbs)) add('WARN', `no install manifest at ${MANIFEST_REL} (re-run init to regenerate)`);
|
|
169
|
+
else add('OK', 'no manifest (nothing installed)');
|
|
170
|
+
} else {
|
|
171
|
+
const resolves = (e) => e && typeof e.path === 'string' && existsSync(join(root, ...e.path.split('/')));
|
|
172
|
+
const stale = manifest.entries.filter((e) => !resolves(e));
|
|
173
|
+
if (stale.length === 0) {
|
|
174
|
+
add('OK', `manifest: all ${manifest.entries.length} entr${manifest.entries.length === 1 ? 'y' : 'ies'} resolve`);
|
|
175
|
+
} else if (checkOnly) {
|
|
176
|
+
add('WOULD-FIX', `manifest: ${stale.length} stale entr${stale.length === 1 ? 'y' : 'ies'} -> pruned`);
|
|
177
|
+
} else {
|
|
178
|
+
saveManifest(root, { ...manifest, entries: manifest.entries.filter(resolves) });
|
|
179
|
+
add('FIXED', `manifest: pruned ${stale.length} stale entr${stale.length === 1 ? 'y' : 'ies'}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const tally = { OK: 0, FIXED: 0, 'WOULD-FIX': 0, WARN: 0, FAIL: 0 };
|
|
184
|
+
for (const c of checks) tally[c.status]++;
|
|
185
|
+
return { root, checks, tally, failures: tally.FAIL };
|
|
186
|
+
}
|
package/lib/init.js
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
// init.js —
|
|
1
|
+
// init.js — single-store project installer + AGENTS.md/CLAUDE.md context files.
|
|
2
2
|
//
|
|
3
|
-
// `archgen init` makes a repository SELF-CONTAINED
|
|
4
|
-
// .agents/skills/archgen (committed to git,
|
|
5
|
-
// .claude/skills/archgen
|
|
6
|
-
//
|
|
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 {
|
|
10
|
-
import {
|
|
20
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } 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';
|
|
11
25
|
|
|
12
26
|
const CONTEXT_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
13
27
|
|
|
@@ -21,36 +35,124 @@ export function resolveSkillSource(packageRoot) {
|
|
|
21
35
|
throw new Error('bundled archgen skill not found (expected vendor/skills/archgen)');
|
|
22
36
|
}
|
|
23
37
|
|
|
38
|
+
function lstatSafe(p) {
|
|
39
|
+
try { return lstatSync(p); } catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function relJoin(rel) {
|
|
43
|
+
return join(...rel.split('/'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create the claude adapter: `<project>/.claude/skills/archgen` as a RELATIVE
|
|
48
|
+
* symlink into the canonical store. Never copies. Migration/keep semantics:
|
|
49
|
+
* - ours already -> 'existing'
|
|
50
|
+
* - real dir identical to vendor -> replaced with symlink ('migrated')
|
|
51
|
+
* - real dir modified / foreign live symlink / stray file -> hands off + warn
|
|
52
|
+
* - dangling symlink -> recreated
|
|
53
|
+
* - symlink creation fails (privileges/FS) -> skipped + warning
|
|
54
|
+
*/
|
|
55
|
+
function installClaudeAdapter(root, source, warnings) {
|
|
56
|
+
const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
|
|
57
|
+
const storeAbs = join(root, relJoin(STORE_REL));
|
|
58
|
+
const st = lstatSafe(claudeAbs);
|
|
59
|
+
let migrating = false;
|
|
60
|
+
|
|
61
|
+
if (st && st.isSymbolicLink()) {
|
|
62
|
+
const resolvedTarget = resolve(dirname(claudeAbs), readlinkSync(claudeAbs));
|
|
63
|
+
if (resolvedTarget === storeAbs && existsSync(claudeAbs)) return 'existing';
|
|
64
|
+
if (existsSync(claudeAbs)) {
|
|
65
|
+
warnings.push('.claude/skills/archgen is a foreign symlink - left untouched');
|
|
66
|
+
return 'kept-divergent';
|
|
67
|
+
}
|
|
68
|
+
unlinkSync(claudeAbs); // dangling: recreate ours below
|
|
69
|
+
} else if (st && st.isDirectory()) {
|
|
70
|
+
if (hashDir(claudeAbs) === hashDir(source)) {
|
|
71
|
+
rmSync(claudeAbs, { recursive: true }); // legacy dual copy -> migrate
|
|
72
|
+
migrating = true;
|
|
73
|
+
} else {
|
|
74
|
+
warnings.push('user-customized claude copy kept at .claude/skills/archgen (not managed)');
|
|
75
|
+
return 'kept-divergent';
|
|
76
|
+
}
|
|
77
|
+
} else if (st) {
|
|
78
|
+
warnings.push('.claude/skills/archgen exists and is not a directory - left untouched');
|
|
79
|
+
return 'kept-divergent';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
84
|
+
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
85
|
+
appendEntry(root, 'link', CLAUDE_LINK_REL);
|
|
86
|
+
return migrating ? 'migrated' : 'created';
|
|
87
|
+
} catch (e) {
|
|
88
|
+
warnings.push('claude adapter skipped: symlink unavailable (' + e.message + ')');
|
|
89
|
+
return 'skipped';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
24
93
|
/**
|
|
25
|
-
* Initialize a project:
|
|
94
|
+
* Initialize a project: install the single canonical skill store, wire the
|
|
95
|
+
* claude adapter, and write context pointers.
|
|
26
96
|
* @param {string} projectDir target repo root
|
|
27
97
|
* @param {string} packageRoot dir containing this CLI package
|
|
28
|
-
* @
|
|
98
|
+
* @param {{force?: boolean}} opts --force proceeds automatically; divergent
|
|
99
|
+
* copies are always auto-backed-up (never refused, never destroyed)
|
|
100
|
+
* @returns {{storePath: string, claudeLink: 'created'|'existing'|'migrated'|'skipped'|'kept-divergent',
|
|
101
|
+
* backups: string[], contextFiles: string[], createdContextFiles: string[], warnings: string[]}}
|
|
29
102
|
*/
|
|
30
|
-
export function initProject(projectDir, packageRoot) {
|
|
103
|
+
export function initProject(projectDir, packageRoot, opts = {}) {
|
|
104
|
+
// --force is accepted for explicitness: this installer never refuses and
|
|
105
|
+
// never destroys data — divergent copies are backed up automatically, so
|
|
106
|
+
// both plain and --force runs take the same safe path.
|
|
107
|
+
const force = opts.force === true;
|
|
108
|
+
void force;
|
|
109
|
+
|
|
31
110
|
const root = resolve(projectDir);
|
|
32
111
|
const source = resolveSkillSource(packageRoot);
|
|
112
|
+
const version = cliVersion(packageRoot);
|
|
113
|
+
const warnings = [];
|
|
114
|
+
const backups = [];
|
|
115
|
+
|
|
116
|
+
const storeAbs = join(root, relJoin(STORE_REL));
|
|
117
|
+
|
|
118
|
+
// 1. Canonical store — fingerprint before mutating (ownership check).
|
|
119
|
+
const prev = lstatSafe(storeAbs);
|
|
120
|
+
if (prev) {
|
|
121
|
+
const identical = prev.isDirectory() && !prev.isSymbolicLink()
|
|
122
|
+
&& hashDir(storeAbs, { ignore: [VERSION_FILE] }) === hashDir(source);
|
|
123
|
+
if (!identical) backups.push(moveToBackup(root, STORE_REL));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Atomic install: build in a temp sibling dir, then rename into place.
|
|
127
|
+
const skillsParent = dirname(storeAbs);
|
|
128
|
+
mkdirSync(skillsParent, { recursive: true });
|
|
129
|
+
const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
|
|
130
|
+
cpSync(source, tmp, { recursive: true });
|
|
131
|
+
writeFileSync(join(tmp, VERSION_FILE), version + '\n');
|
|
132
|
+
rmSync(storeAbs, { recursive: true, force: true });
|
|
133
|
+
renameSync(tmp, storeAbs);
|
|
134
|
+
appendEntry(root, 'dir', STORE_REL);
|
|
33
135
|
|
|
34
|
-
//
|
|
35
|
-
const
|
|
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 });
|
|
136
|
+
// 2. Claude adapter — relative symlink, never a second copy.
|
|
137
|
+
const claudeLink = installClaudeAdapter(root, source, warnings);
|
|
41
138
|
|
|
42
|
-
//
|
|
43
|
-
const block = renderBlock('.agents/skills/archgen');
|
|
139
|
+
// 3. Context files — managed blocks, user content preserved.
|
|
44
140
|
const contextFiles = [];
|
|
45
|
-
const
|
|
141
|
+
const createdContextFiles = [];
|
|
46
142
|
for (const name of CONTEXT_FILES) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
143
|
+
const abs = join(root, name);
|
|
144
|
+
if (name === 'CLAUDE.md' && importsAgents(abs)) {
|
|
145
|
+
warnings.push('CLAUDE.md already imports @AGENTS.md - skipped (already-imports)');
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const existed = upsertManagedFile(
|
|
149
|
+
abs,
|
|
150
|
+
name === 'AGENTS.md' ? renderBlock(STORE_REL).split('\n') : [START, '@AGENTS.md', END],
|
|
151
|
+
);
|
|
51
152
|
contextFiles.push(name);
|
|
52
|
-
if (!existed)
|
|
153
|
+
if (!existed) createdContextFiles.push(name);
|
|
154
|
+
appendEntry(root, 'block', name);
|
|
53
155
|
}
|
|
54
156
|
|
|
55
|
-
return {
|
|
157
|
+
return { storePath: storeAbs, claudeLink, backups, contextFiles, createdContextFiles, warnings };
|
|
56
158
|
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// store.js — shared primitives for the single-store project layout.
|
|
2
|
+
//
|
|
3
|
+
// Layout (all paths relative to the project root):
|
|
4
|
+
// .agents/skills/archgen canonical store (the ONLY real copy)
|
|
5
|
+
// .agents/skills/archgen/.archgen-version CLI version stamp (rewritten every init)
|
|
6
|
+
// .claude/skills/archgen RELATIVE symlink -> ../../.agents/skills/archgen
|
|
7
|
+
// .archgen/.install-manifest.json manifest of everything archgen created
|
|
8
|
+
// .archgen/.backup/<timestamp>/ divergent copies moved aside before replace
|
|
9
|
+
//
|
|
10
|
+
// Safety rails:
|
|
11
|
+
// - hashDir() fingerprints a tree (stable-order walk of relpaths + contents,
|
|
12
|
+
// sha256) so mutations only ever happen after verifying ownership/divergence;
|
|
13
|
+
// - GUARDED_RELPATHS (.claude, .agents, .claude/skills, .agents/skills) are
|
|
14
|
+
// never deleted: parent pruning stops before touching those levels.
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import {
|
|
18
|
+
cpSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
lstatSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
readlinkSync,
|
|
25
|
+
renameSync,
|
|
26
|
+
rmSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from 'node:fs';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
export const STORE_REL = '.agents/skills/archgen';
|
|
32
|
+
export const CLAUDE_LINK_REL = '.claude/skills/archgen';
|
|
33
|
+
export const MANIFEST_REL = '.archgen/.install-manifest.json';
|
|
34
|
+
export const BACKUP_ROOT_REL = '.archgen/.backup';
|
|
35
|
+
export const VERSION_FILE = '.archgen-version';
|
|
36
|
+
|
|
37
|
+
const GUARDED_RELPATHS = new Set(['.claude', '.agents', '.claude/skills', '.agents/skills']);
|
|
38
|
+
|
|
39
|
+
/** Version string of this CLI package (used for the in-store stamp). */
|
|
40
|
+
export function cliVersion(packageRoot) {
|
|
41
|
+
return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function byName(a, b) {
|
|
45
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Stable-order directory fingerprint: sha256 over sorted relpaths plus file
|
|
50
|
+
* contents (symlinks hashed via their targets). Deterministic across machines.
|
|
51
|
+
* @param {string} dir absolute directory to hash
|
|
52
|
+
* @param {{ignore?: string[]}} opts relpaths to skip (e.g. our version stamp)
|
|
53
|
+
*/
|
|
54
|
+
export function hashDir(dir, opts = {}) {
|
|
55
|
+
const ignore = opts.ignore ?? [];
|
|
56
|
+
const hash = createHash('sha256');
|
|
57
|
+
(function walk(abs, rel) {
|
|
58
|
+
const entries = readdirSync(abs, { withFileTypes: true }).sort((a, b) => byName(a.name, b.name));
|
|
59
|
+
for (const ent of entries) {
|
|
60
|
+
const r = rel ? rel + '/' + ent.name : ent.name;
|
|
61
|
+
if (ignore.includes(r)) continue;
|
|
62
|
+
const p = join(abs, ent.name);
|
|
63
|
+
if (ent.isSymbolicLink()) hash.update('l ' + r + ' -> ' + readlinkSync(p) + '\n');
|
|
64
|
+
else if (ent.isDirectory()) { hash.update('d ' + r + '\n'); walk(p, r); }
|
|
65
|
+
else { hash.update('f ' + r + '\0'); hash.update(readFileSync(p)); hash.update('\n'); }
|
|
66
|
+
}
|
|
67
|
+
})(dir, '');
|
|
68
|
+
return hash.digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Load `.archgen/.install-manifest.json`; null when absent/corrupt. */
|
|
72
|
+
export function loadManifest(root) {
|
|
73
|
+
const p = join(root, ...MANIFEST_REL.split('/'));
|
|
74
|
+
if (!existsSync(p)) return null;
|
|
75
|
+
try {
|
|
76
|
+
const m = JSON.parse(readFileSync(p, 'utf8'));
|
|
77
|
+
return m && typeof m === 'object' ? m : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function saveManifest(root, manifest) {
|
|
84
|
+
const p = join(root, ...MANIFEST_REL.split('/'));
|
|
85
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
86
|
+
writeFileSync(p, JSON.stringify(manifest, null, 2) + '\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Record a created dir/link/block; dedupes on kind+path. */
|
|
90
|
+
export function appendEntry(root, kind, relPath) {
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
const m = loadManifest(root) ?? { version: 1, createdAt: now, entries: [] };
|
|
93
|
+
if (!Array.isArray(m.entries)) m.entries = [];
|
|
94
|
+
if (m.entries.some((e) => e && e.kind === kind && e.path === relPath)) return;
|
|
95
|
+
m.version = m.version ?? 1;
|
|
96
|
+
m.createdAt = m.createdAt ?? now;
|
|
97
|
+
m.entries.push({ kind, path: relPath, createdAt: now });
|
|
98
|
+
saveManifest(root, m);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Move an existing project-relative path into `.archgen/.backup/<ts>/<rel>`.
|
|
103
|
+
* Preserves the relative layout so backups restore unambiguously.
|
|
104
|
+
* @returns {string} backup location relative to the project root
|
|
105
|
+
*/
|
|
106
|
+
export function moveToBackup(root, relPath) {
|
|
107
|
+
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
108
|
+
const destRel = BACKUP_ROOT_REL + '/' + ts + '/' + relPath;
|
|
109
|
+
const destAbs = join(root, ...destRel.split('/'));
|
|
110
|
+
mkdirSync(dirname(destAbs), { recursive: true });
|
|
111
|
+
const srcAbs = join(root, ...relPath.split('/'));
|
|
112
|
+
try {
|
|
113
|
+
renameSync(srcAbs, destAbs);
|
|
114
|
+
} catch {
|
|
115
|
+
cpSync(srcAbs, destAbs, { recursive: true });
|
|
116
|
+
rmSync(srcAbs, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
return destRel;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function rmIfExists(absPath) {
|
|
122
|
+
let st = null;
|
|
123
|
+
try { st = lstatSync(absPath); } catch { /* absent */ }
|
|
124
|
+
if (!st) return false;
|
|
125
|
+
rmSync(absPath, { force: true, recursive: st.isDirectory() && !st.isSymbolicLink() });
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Walk UPWARD from relPath removing directories only while they are left
|
|
131
|
+
* completely empty by our removals. Stops at guarded levels and the root —
|
|
132
|
+
* `.claude`, `.agents`, `.claude/skills`, `.agents/skills` are never deleted.
|
|
133
|
+
*/
|
|
134
|
+
export function pruneEmptyParents(root, relPath) {
|
|
135
|
+
let cur = relPath;
|
|
136
|
+
while (cur && cur !== '.') {
|
|
137
|
+
if (GUARDED_RELPATHS.has(cur)) break;
|
|
138
|
+
const abs = join(root, ...cur.split('/'));
|
|
139
|
+
let empty = false;
|
|
140
|
+
try { empty = readdirSync(abs).length === 0; } catch { break; }
|
|
141
|
+
if (!empty) break;
|
|
142
|
+
rmSync(abs, { recursive: true, force: true });
|
|
143
|
+
const parent = dirname(cur);
|
|
144
|
+
cur = parent === '.' ? '' : parent;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// uninstall-project.js — `archgen uninstall --project [dir]` removal with rails.
|
|
2
|
+
//
|
|
3
|
+
// Removes exactly what archgen owns in a project: the claude symlink, the
|
|
4
|
+
// managed blocks, the canonical store (ONLY when unmodified vs the vendor
|
|
5
|
+
// skill — a customized store is kept with a warning), and the install
|
|
6
|
+
// manifest. `.archgen/<slug>` feature folders and `.archgen/.backup/` are
|
|
7
|
+
// always preserved. Parents are pruned only when left completely empty, and
|
|
8
|
+
// never past the guarded .claude/.agents levels.
|
|
9
|
+
|
|
10
|
+
import { existsSync, lstatSync, readlinkSync, rmSync, unlinkSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { stripManagedFile } from './block.js';
|
|
13
|
+
import {
|
|
14
|
+
CLAUDE_LINK_REL,
|
|
15
|
+
MANIFEST_REL,
|
|
16
|
+
STORE_REL,
|
|
17
|
+
VERSION_FILE,
|
|
18
|
+
hashDir,
|
|
19
|
+
pruneEmptyParents,
|
|
20
|
+
rmIfExists,
|
|
21
|
+
} from './store.js';
|
|
22
|
+
import { resolveSkillSource } from './init.js';
|
|
23
|
+
|
|
24
|
+
function lstatSafe(p) {
|
|
25
|
+
try { return lstatSync(p); } catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function relJoin(rel) {
|
|
29
|
+
return join(...rel.split('/'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} projectDir project root to clean
|
|
34
|
+
* @param {string} packageRoot dir containing this CLI package (vendor source)
|
|
35
|
+
* @returns {{root: string, linkRemoved: boolean, strippedBlocks: string[],
|
|
36
|
+
* storeRemoved: boolean, storeKept: boolean, manifestRemoved: boolean, warnings: string[]}}
|
|
37
|
+
*/
|
|
38
|
+
export function uninstallProject(projectDir, packageRoot) {
|
|
39
|
+
const root = resolve(projectDir);
|
|
40
|
+
const warnings = [];
|
|
41
|
+
const strippedBlocks = [];
|
|
42
|
+
|
|
43
|
+
const storeAbs = join(root, relJoin(STORE_REL));
|
|
44
|
+
const claudeAbs = join(root, relJoin(CLAUDE_LINK_REL));
|
|
45
|
+
|
|
46
|
+
// 1. Claude adapter — remove only our own symlink (dangling included).
|
|
47
|
+
let linkRemoved = false;
|
|
48
|
+
const lst = lstatSafe(claudeAbs);
|
|
49
|
+
if (lst && lst.isSymbolicLink()) {
|
|
50
|
+
const target = resolve(dirname(claudeAbs), readlinkSync(claudeAbs));
|
|
51
|
+
if (target === storeAbs || !existsSync(claudeAbs)) {
|
|
52
|
+
unlinkSync(claudeAbs); // removes the link itself, never its target
|
|
53
|
+
linkRemoved = true;
|
|
54
|
+
} else {
|
|
55
|
+
warnings.push('foreign symlink at .claude/skills/archgen - left untouched');
|
|
56
|
+
}
|
|
57
|
+
} else if (lst && lst.isDirectory()) {
|
|
58
|
+
warnings.push('.claude/skills/archgen is a real directory - left untouched (remove manually if unwanted)');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Managed blocks — strip markers, keep all user content.
|
|
62
|
+
for (const name of ['AGENTS.md', 'CLAUDE.md']) {
|
|
63
|
+
if (stripManagedFile(join(root, name)).hadBlock) strippedBlocks.push(name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. Canonical store — remove ONLY if unmodified vs vendor.
|
|
67
|
+
// The version stamp is our metadata, so it is ignored when comparing.
|
|
68
|
+
let storeRemoved = false;
|
|
69
|
+
let storeKept = false;
|
|
70
|
+
if (lstatSafe(storeAbs)) {
|
|
71
|
+
const st = lstatSafe(storeAbs);
|
|
72
|
+
const mine = st.isDirectory() && !st.isSymbolicLink()
|
|
73
|
+
? hashDir(storeAbs, { ignore: [VERSION_FILE] })
|
|
74
|
+
: null;
|
|
75
|
+
let vendor = null;
|
|
76
|
+
try { vendor = hashDir(resolveSkillSource(packageRoot)); } catch { vendor = null; }
|
|
77
|
+
if (vendor !== null && mine === vendor) {
|
|
78
|
+
rmSync(storeAbs, { recursive: true, force: true });
|
|
79
|
+
storeRemoved = true;
|
|
80
|
+
} else {
|
|
81
|
+
storeKept = true;
|
|
82
|
+
warnings.push('canonical store kept at .agents/skills/archgen: modified vs vendor (or unverifiable) - review manually');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4. Manifest file — then prune .archgen only if nothing else remains
|
|
87
|
+
// (feature folders / backups keep it alive).
|
|
88
|
+
const manifestRemoved = rmIfExists(join(root, relJoin(MANIFEST_REL)));
|
|
89
|
+
if (manifestRemoved) pruneEmptyParents(root, dirname(MANIFEST_REL));
|
|
90
|
+
|
|
91
|
+
return { root, linkRemoved, strippedBlocks, storeRemoved, storeKept, manifestRemoved, warnings };
|
|
92
|
+
}
|
package/lib/version.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// version.js — semver compare + npm registry lookup (zero deps).
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
export function parseSemver(v) {
|
|
5
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? '').trim());
|
|
6
|
+
return m ? { major: +m[1], minor: +m[2], patch: +m[3] } : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function compareSemver(a, b) {
|
|
10
|
+
const x = parseSemver(a);
|
|
11
|
+
const y = parseSemver(b);
|
|
12
|
+
if (!x || !y) return 0;
|
|
13
|
+
for (const k of ['major', 'minor', 'patch']) {
|
|
14
|
+
if (x[k] !== y[k]) return x[k] < y[k] ? -1 : 1;
|
|
15
|
+
}
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function fetchLatestVersion(pkg) {
|
|
20
|
+
const r = spawnSync('npm', ['view', pkg, 'version'], { encoding: 'utf8', timeout: 15000 });
|
|
21
|
+
if (r.status !== 0 || !r.stdout) return null;
|
|
22
|
+
return r.stdout.trim().split(/\r?\n/).pop() || null;
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "archgen-skill",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Architecture generation & autonomous task execution for coding agents - conversational requirements in, dependency-ordered tasks out, executed wave-by-wave with verifier gates. Works with Claude Code, OpenCode, Cursor, Codex, Gemini CLI and any agentskills.io harness.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,7 +34,8 @@ Do NOT load for plain bug fixes or one-line edits that need no planning.
|
|
|
34
34
|
1. **Everything you generate goes inside `.archgen/<project-slug>/`** at the
|
|
35
35
|
target repo root. NEVER write generated artifacts anywhere else. The only
|
|
36
36
|
files you may touch outside `.archgen/` are source-code edits performed by
|
|
37
|
-
worker sub-agents for approved tasks.
|
|
37
|
+
worker sub-agents for approved tasks and the `AGENTS.md` features registry,
|
|
38
|
+
maintained exclusively via `scripts/update-agents.mjs`.
|
|
38
39
|
2. **Two gates before execution**: (a) the VERIFIER sub-agent must return
|
|
39
40
|
APPROVE on plan+tasks, then (b) the USER must approve. Never skip either.
|
|
40
41
|
3. **Right-size every generation.** Classify scope BEFORE creating files:
|
|
@@ -98,9 +99,13 @@ Pick ONE mode from the user's intent and follow its section below:
|
|
|
98
99
|
Use templates from references/artifact-templates.md. Every task needs:
|
|
99
100
|
id, title, depends_on (prerequisite ids), file_ownership (disjoint globs!),
|
|
100
101
|
acceptance criteria, and status left unset (resolver derives readiness).
|
|
101
|
-
|
|
102
|
+
If `<slug>` already exists under `.archgen/`, pick `<slug>-2`, `<slug>-3`, …
|
|
103
|
+
— NEVER overwrite or merge into an existing feature folder.
|
|
104
|
+
4. **Self-check + registry:** run
|
|
102
105
|
`node <skill>/scripts/validate.mjs .archgen/<slug>/tasks.yaml`
|
|
103
|
-
Fix until exit 0.
|
|
106
|
+
Fix until exit 0. Then refresh the AGENTS.md features registry:
|
|
107
|
+
`node <skill>/scripts/update-agents.mjs <repo-root> --slug <slug> --status planned`.
|
|
108
|
+
Re-run it after ANY later artifact update so AGENTS.md never goes stale.
|
|
104
109
|
5. **VERIFIER GATE.** Dispatch a verifier sub-agent (brief in
|
|
105
110
|
references/orchestration.md §verifier). It must also run
|
|
106
111
|
`node <skill>/scripts/verify-plan.mjs .archgen/<slug>/tasks.yaml --plan .archgen/<slug>/plans`
|
|
@@ -145,6 +150,8 @@ Run from the target repo root where `.archgen/<slug>/` exists.
|
|
|
145
150
|
`set-status.mjs <id> done` on success (or `failed` on failure) and commit.
|
|
146
151
|
4. After the wave settles: re-run next-tasks. Failed tasks are NOT retried
|
|
147
152
|
automatically — surface them, propose a fix task, [wait for user].
|
|
153
|
+
Refresh the registry so AGENTS.md tracks the new statuses:
|
|
154
|
+
`node <skill>/scripts/update-agents.mjs <repo-root>`.
|
|
148
155
|
5. Repeat until no actionable tasks remain.
|
|
149
156
|
6. Final report: tasks done/failed/blocked, commits produced, suggested
|
|
150
157
|
follow-ups. Update `.archgen/<slug>/plans/*.md` checkboxes if present.
|
|
@@ -197,6 +204,8 @@ When a task domain matches an entry in `assets/skill-registry.json`
|
|
|
197
204
|
## Things you must NOT do
|
|
198
205
|
|
|
199
206
|
- Do NOT write artifacts outside `.archgen/<slug>/`.
|
|
207
|
+
- Do NOT reuse or overwrite an existing `.archgen/<slug>/` folder — suffix
|
|
208
|
+
`-2`, `-3`, … instead (multi-feature repos must stay isolated).
|
|
200
209
|
- Do NOT skip the verifier gate or the user gate — ever, even for SMALL scopes.
|
|
201
210
|
- Do NOT implement features yourself; dispatch workers.
|
|
202
211
|
- Do NOT put two same-wave tasks on overlapping file_ownership globs.
|
|
@@ -225,6 +234,8 @@ When a task domain matches an entry in `assets/skill-registry.json`
|
|
|
225
234
|
scripts/next-tasks.mjs <tasks.yaml> # waves JSON
|
|
226
235
|
scripts/validate.mjs <tasks.yaml> [--plan <dir>] # exit 0/1
|
|
227
236
|
scripts/set-status.mjs <tasks.yaml> <id> <status> [--force]
|
|
237
|
+
scripts/update-agents.mjs <projectRoot> [--slug <s>] [--status <s>] [--prune]
|
|
238
|
+
# AGENTS.md features registry
|
|
228
239
|
scripts/impact.mjs <tasks.yaml> <id-or-artifactPath> # ripple JSON
|
|
229
240
|
scripts/verify-plan.mjs <tasks.yaml> --plan <dir> # APPROVE|ISSUES
|
|
230
241
|
```
|
|
@@ -23,6 +23,23 @@ confirmed. The orchestrator loads ONLY the row matching Step-0 detection.
|
|
|
23
23
|
Universal fallback: `.agents/skills/` project + `~/.agents/skills/` user works
|
|
24
24
|
across most harnesses. Primary distribution: `npx skills add <repo>` (75+ agents).
|
|
25
25
|
|
|
26
|
+
## archgen installed layout
|
|
27
|
+
|
|
28
|
+
`install.sh` / `npx archgen-skill install` maintain ONE canonical store plus
|
|
29
|
+
thin bridges — the skill is never copied twice:
|
|
30
|
+
|
|
31
|
+
| Path | Role |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `.agents/skills/archgen/` | Canonical store — `SKILL.md`, `scripts/`, `references/`, `assets/` |
|
|
34
|
+
| `.claude/skills/archgen` | Optional symlink → canonical store, for Claude Code discovery |
|
|
35
|
+
| `CLAUDE.md` | One-line `@AGENTS.md` bridge only — no content of its own |
|
|
36
|
+
| `AGENTS.md` | The hub every harness reads; hosts the features registry between `<!-- archgen:features:start -->` and `<!-- archgen:features:end -->` |
|
|
37
|
+
|
|
38
|
+
The registry table (`| Feature | Status | Updated |`, one row per
|
|
39
|
+
`.archgen/<slug>/`) is machine-maintained by `scripts/update-agents.mjs` after
|
|
40
|
+
every generate/plan/wave run — never hand-edit it; edit `.archgen/` state and
|
|
41
|
+
re-run the script instead.
|
|
42
|
+
|
|
26
43
|
## MCP configuration
|
|
27
44
|
|
|
28
45
|
Four different top-level keys exist — NEVER assume `mcpServers`:
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// update-agents.mjs — keeps the AGENTS.md features registry in sync with .archgen/.
|
|
3
|
+
// Usage: node update-agents.mjs <projectRoot> [--slug <s>] [--status <s>] [--prune]
|
|
4
|
+
// Scans .archgen/*/tasks.yaml and rewrites the table between the
|
|
5
|
+
// <!-- archgen:features:start --> / <!-- archgen:features:end --> markers in
|
|
6
|
+
// <projectRoot>/AGENTS.md, preserving every byte outside the block (BOM and
|
|
7
|
+
// CRLF line endings included). A missing AGENTS.md gets a minimal scaffold; an
|
|
8
|
+
// existing file without markers gets them appended. Rows whose feature folder
|
|
9
|
+
// disappeared are pruned by default (--prune is accepted as the explicit
|
|
10
|
+
// no-op spelling of the same behavior). Writes atomically via temp + rename.
|
|
11
|
+
// Exit codes: 0 ok · 2 no .archgen/ dir · 4 usage/IO/marker errors.
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { parseYaml } from './lib/yaml.mjs';
|
|
15
|
+
|
|
16
|
+
const START_MARKER = '<!-- archgen:features:start -->';
|
|
17
|
+
const END_MARKER = '<!-- archgen:features:end -->';
|
|
18
|
+
const HEADER = '| Feature | Status | Updated |';
|
|
19
|
+
const SEPARATOR = '| --- | --- | --- |';
|
|
20
|
+
const DISPLAY_STATUSES = ['planned', 'in progress', 'done', 'blocked', 'unknown'];
|
|
21
|
+
// Task-level statuses set-status.mjs may write; anything else in tasks.yaml
|
|
22
|
+
// means the file is not a contract we can read -> row falls back to unknown.
|
|
23
|
+
const TASK_STATUSES = new Set(['pending', 'ready', 'running', 'blocked', 'done', 'failed']);
|
|
24
|
+
|
|
25
|
+
// ---- arguments ------------------------------------------------------------
|
|
26
|
+
const [projectRoot, ...flags] = process.argv.slice(2);
|
|
27
|
+
let slug = null;
|
|
28
|
+
let status = null;
|
|
29
|
+
let pruneFlag = false; // pruning is always on; the flag just makes it explicit
|
|
30
|
+
for (let i = 0; i < flags.length; i++) {
|
|
31
|
+
const arg = flags[i];
|
|
32
|
+
if (arg === '--slug' || arg === '--status') {
|
|
33
|
+
const value = flags[++i];
|
|
34
|
+
if (value === undefined) { console.error(`${arg} requires a value`); process.exit(4); }
|
|
35
|
+
if (arg === '--slug') slug = value;
|
|
36
|
+
else status = value;
|
|
37
|
+
} else if (arg === '--prune') {
|
|
38
|
+
pruneFlag = true;
|
|
39
|
+
} else {
|
|
40
|
+
console.error(`unknown argument '${arg}'`);
|
|
41
|
+
process.exit(4);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!projectRoot) {
|
|
45
|
+
console.error('usage: update-agents.mjs <projectRoot> [--slug <s>] [--status <s>] [--prune]');
|
|
46
|
+
process.exit(4);
|
|
47
|
+
}
|
|
48
|
+
if ((slug === null) !== (status === null)) {
|
|
49
|
+
console.error('--slug and --status must be passed together');
|
|
50
|
+
process.exit(4);
|
|
51
|
+
}
|
|
52
|
+
if (status !== null && !DISPLAY_STATUSES.includes(status)) {
|
|
53
|
+
console.error(`invalid status '${status}' (allowed: ${DISPLAY_STATUSES.join('|')})`);
|
|
54
|
+
process.exit(4);
|
|
55
|
+
}
|
|
56
|
+
if (slug !== null && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) {
|
|
57
|
+
console.error(`invalid slug '${slug}' (start alphanumeric; then letters, digits, '.', '_', '-')`);
|
|
58
|
+
process.exit(4);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- scan .archgen/*/tasks.yaml -------------------------------------------
|
|
62
|
+
const archgenDir = join(projectRoot, '.archgen');
|
|
63
|
+
if (!existsSync(archgenDir)) {
|
|
64
|
+
console.error(`no .archgen/ directory under ${projectRoot} — nothing to register (generate a plan first)`);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Date -> YYYY-MM-DD (local time, zero-padded). */
|
|
69
|
+
function fmtDate(d) {
|
|
70
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
71
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
72
|
+
return `${d.getFullYear()}-${mm}-${dd}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Derive the one-word registry status from a parsed tasks.yaml `tasks`
|
|
77
|
+
* sequence. Precedence: unreadable -> unknown; any blocked -> blocked; all
|
|
78
|
+
* done -> done; all pending/ready -> planned; everything else (any running,
|
|
79
|
+
* or a done/pending/failed mix) -> in progress.
|
|
80
|
+
*/
|
|
81
|
+
function deriveStatus(tasks) {
|
|
82
|
+
if (!Array.isArray(tasks)) return 'unknown';
|
|
83
|
+
const statuses = [];
|
|
84
|
+
for (const t of tasks) {
|
|
85
|
+
if (t === null || typeof t !== 'object' || Array.isArray(t)) return 'unknown';
|
|
86
|
+
const s = String(t.status ?? 'pending').trim().toLowerCase(); // SKILL.md leaves status unset until wave resolution
|
|
87
|
+
if (!TASK_STATUSES.has(s)) return 'unknown';
|
|
88
|
+
statuses.push(s);
|
|
89
|
+
}
|
|
90
|
+
if (statuses.length === 0) return 'planned'; // scaffolded folder, no entries yet
|
|
91
|
+
if (statuses.includes('blocked')) return 'blocked';
|
|
92
|
+
if (statuses.every((s) => s === 'done')) return 'done';
|
|
93
|
+
if (statuses.every((s) => s === 'pending' || s === 'ready')) return 'planned';
|
|
94
|
+
return 'in progress';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @type {Map<string, {feature: string, status: string, updated: string}>} */
|
|
98
|
+
const rows = new Map();
|
|
99
|
+
for (const ent of readdirSync(archgenDir, { withFileTypes: true })) {
|
|
100
|
+
if (!ent.isDirectory()) continue;
|
|
101
|
+
const tasksPath = join(archgenDir, ent.name, 'tasks.yaml');
|
|
102
|
+
if (!existsSync(tasksPath)) continue; // the scan target is exactly */tasks.yaml
|
|
103
|
+
let derived = 'unknown';
|
|
104
|
+
try {
|
|
105
|
+
const { data } = parseYaml(readFileSync(tasksPath, 'utf8'), { filename: tasksPath });
|
|
106
|
+
derived = deriveStatus(data?.tasks);
|
|
107
|
+
} catch {
|
|
108
|
+
derived = 'unknown'; // unparseable YAML is data about the feature, not a crash
|
|
109
|
+
}
|
|
110
|
+
rows.set(ent.name, { feature: ent.name, status: derived, updated: fmtDate(statSync(tasksPath).mtime) });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- upsert override (--slug/--status, run right after generating) --------
|
|
114
|
+
if (slug !== null) {
|
|
115
|
+
const existing = rows.get(slug);
|
|
116
|
+
if (existing) existing.status = status;
|
|
117
|
+
else rows.set(slug, { feature: slug, status, updated: fmtDate(new Date()) });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ordered = [...rows.values()].sort((a, b) => (a.feature < b.feature ? -1 : a.feature > b.feature ? 1 : 0));
|
|
121
|
+
|
|
122
|
+
// ---- render -----------------------------------------------------------------
|
|
123
|
+
function renderTable(eol) {
|
|
124
|
+
const lines = [HEADER, SEPARATOR, ...ordered.map((r) => `| ${r.feature} | ${r.status} | ${r.updated} |`)];
|
|
125
|
+
return lines.join(eol) + eol;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Parse the CURRENT registry rows out of an existing AGENTS.md (or null). */
|
|
129
|
+
function oldRowsFrom(raw) {
|
|
130
|
+
const si = raw.indexOf(START_MARKER);
|
|
131
|
+
if (si === -1) return null;
|
|
132
|
+
const ei = raw.indexOf(END_MARKER, si + START_MARKER.length);
|
|
133
|
+
if (ei === -1) return null;
|
|
134
|
+
const old = new Map();
|
|
135
|
+
for (const line of raw.slice(si + START_MARKER.length, ei).split(/\r?\n/)) {
|
|
136
|
+
const t = line.trim();
|
|
137
|
+
if (!t.startsWith('|') || t === HEADER || t === SEPARATOR) continue;
|
|
138
|
+
const cells = t.split('|').slice(1, -1).map((c) => c.trim());
|
|
139
|
+
if (!cells[0]) continue;
|
|
140
|
+
old.set(cells[0], { status: cells[1] ?? '', updated: cells[2] ?? '' });
|
|
141
|
+
}
|
|
142
|
+
return old;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const agentsPath = join(projectRoot, 'AGENTS.md');
|
|
146
|
+
let raw = null;
|
|
147
|
+
try { raw = readFileSync(agentsPath, 'utf8'); } catch { /* missing -> scaffold below */ }
|
|
148
|
+
const oldRows = raw === null ? new Map() : (oldRowsFrom(raw) ?? new Map());
|
|
149
|
+
|
|
150
|
+
let out;
|
|
151
|
+
let banner;
|
|
152
|
+
if (raw === null) {
|
|
153
|
+
// Missing AGENTS.md: minimal scaffold — H1, pointer paragraph, markers+table.
|
|
154
|
+
banner = `created ${agentsPath}`;
|
|
155
|
+
out = '# Agent Guide\n'
|
|
156
|
+
+ '\n'
|
|
157
|
+
+ 'This project uses the archgen skill at `.agents/skills/archgen/SKILL.md` for architecture generation and autonomous task execution.\n'
|
|
158
|
+
+ '\n'
|
|
159
|
+
+ START_MARKER + '\n'
|
|
160
|
+
+ renderTable('\n')
|
|
161
|
+
+ END_MARKER + '\n';
|
|
162
|
+
} else if (!raw.includes(START_MARKER)) {
|
|
163
|
+
// Existing user file: append newline + markers + table; prior bytes untouched.
|
|
164
|
+
banner = `appended features registry to ${agentsPath}`;
|
|
165
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
166
|
+
out = raw + eol + START_MARKER + eol + renderTable(eol) + END_MARKER + '\n';
|
|
167
|
+
} else {
|
|
168
|
+
// Markers present: replace ONLY the span between them; every outside byte
|
|
169
|
+
// (BOM, prose, trailing sections, CRLF endings) survives verbatim.
|
|
170
|
+
const si = raw.indexOf(START_MARKER);
|
|
171
|
+
const ei = raw.indexOf(END_MARKER, si + START_MARKER.length);
|
|
172
|
+
if (ei === -1) {
|
|
173
|
+
console.error(`${agentsPath}: found ${START_MARKER} but no ${END_MARKER} — repair the markers by hand`);
|
|
174
|
+
process.exit(4);
|
|
175
|
+
}
|
|
176
|
+
banner = `updated features registry in ${agentsPath}`;
|
|
177
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
178
|
+
out = raw.slice(0, si + START_MARKER.length) + eol + renderTable(eol) + raw.slice(ei);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- human summary ----------------------------------------------------------
|
|
182
|
+
const notes = [];
|
|
183
|
+
for (const feature of oldRows.keys()) {
|
|
184
|
+
if (!rows.has(feature)) notes.push(`pruned: ${feature} (missing .archgen/${feature}/tasks.yaml)`);
|
|
185
|
+
}
|
|
186
|
+
let unchanged = 0;
|
|
187
|
+
for (const r of ordered) {
|
|
188
|
+
const prev = oldRows.get(r.feature);
|
|
189
|
+
if (!prev) notes.push(`added: ${r.feature} (${r.status})`);
|
|
190
|
+
else if (prev.status !== r.status) notes.push(`updated: ${r.feature} (${prev.status || '?'} -> ${r.status})`);
|
|
191
|
+
else if (prev.updated !== r.updated) notes.push(`updated: ${r.feature} (${r.updated})`);
|
|
192
|
+
else unchanged++;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const tmp = `${agentsPath}.tmp-${process.pid}-${Date.now()}`;
|
|
196
|
+
writeFileSync(tmp, out);
|
|
197
|
+
renameSync(tmp, agentsPath); // atomic on POSIX: readers observe old-or-new, never partial
|
|
198
|
+
|
|
199
|
+
console.log(banner);
|
|
200
|
+
for (const n of notes) console.log(n);
|
|
201
|
+
if (unchanged) console.log(`unchanged: ${unchanged}`);
|
|
202
|
+
console.log(`registry: ${ordered.length} feature${ordered.length === 1 ? '' : 's'}`);
|