archgen-skill 0.0.4 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -43
- package/bin/archgen.mjs +8 -1
- package/lib/block.js +54 -8
- package/lib/doctor.js +23 -4
- package/lib/init.js +11 -1
- package/lib/install.js +20 -3
- package/lib/store.js +99 -4
- package/lib/version-stamp.js +3 -1
- package/package.json +3 -2
- package/vendor/skills/archgen/SKILL.md +183 -278
- package/vendor/skills/archgen/archgen.config.json +1 -1
- package/vendor/skills/archgen/assets/skill-registry.json +1 -1
- package/vendor/skills/archgen/references/artifact-templates.md +57 -0
- package/vendor/skills/archgen/references/interview.md +51 -6
- package/vendor/skills/archgen/references/orchestration.md +136 -13
- package/vendor/skills/archgen/references/platforms.md +55 -0
- package/vendor/skills/archgen/scripts/lib/graph.mjs +18 -3
- package/vendor/skills/archgen/scripts/set-status.mjs +27 -5
- package/vendor/skills/archgen/scripts/update-agents.mjs +12 -3
package/README.md
CHANGED
|
@@ -32,15 +32,15 @@ What `init` writes (nothing is ever duplicated):
|
|
|
32
32
|
|
|
33
33
|
| Path | What |
|
|
34
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
|
|
37
|
-
| `AGENTS.md` | Managed hub block: skill pointer
|
|
38
|
-
| `CLAUDE.md` | One-line bridge: `@AGENTS.md` (existing
|
|
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 that needs it); skipped gracefully where symlinks require privileges |
|
|
37
|
+
| `AGENTS.md` | Managed hub block: skill pointer, quick triggers, and a features registry that stays current as you add features |
|
|
38
|
+
| `CLAUDE.md` | One-line bridge: `@AGENTS.md` (existing content is preserved; nothing is written if it already imports AGENTS.md) |
|
|
39
39
|
|
|
40
|
-
Existing projects
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
Existing projects are safe: managed blocks upsert between markers, modified skill
|
|
41
|
+
copies are backed up to `.archgen/.backup/<timestamp>/` before refresh, and legacy
|
|
42
|
+
dual-copy layouts are migrated to symlinks automatically. Verify anytime with
|
|
43
|
+
`archgen-skill doctor`.
|
|
44
44
|
|
|
45
45
|
**Or install globally** into every detected harness:
|
|
46
46
|
|
|
@@ -49,12 +49,13 @@ npx archgen-skill install # symlinks; --copy for real copies
|
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
Prefer shell? Clone the repo and run `./install.sh` (same behavior, plus
|
|
52
|
-
`--init`, `--project`, `--uninstall`). Requires Node.js ≥ 18.
|
|
53
|
-
|
|
52
|
+
`--init`, `--project`, `--uninstall`). Requires Node.js ≥ 18. The skill itself
|
|
53
|
+
has zero npm dependencies.
|
|
54
54
|
|
|
55
55
|
## Quick start
|
|
56
56
|
|
|
57
|
-
After `npx archgen-skill init`, open your project in any supported agent and talk
|
|
57
|
+
After `npx archgen-skill init`, open your project in any supported agent and talk
|
|
58
|
+
naturally:
|
|
58
59
|
|
|
59
60
|
| You say | What runs |
|
|
60
61
|
| --- | --- |
|
|
@@ -64,39 +65,33 @@ After `npx archgen-skill init`, open your project in any supported agent and tal
|
|
|
64
65
|
| *"roll back the auth changes"* | Impact analysis → reverse-order revert plan → approval |
|
|
65
66
|
|
|
66
67
|
Everything archgen produces lives in one reviewable `.archgen/<slug>/` folder —
|
|
67
|
-
architecture contract, docs, ADRs, plans, and a `tasks.yaml` task graph — versioned
|
|
68
|
+
architecture contract, docs, ADRs, plans, and a `tasks.yaml` task graph — versioned
|
|
69
|
+
by git.
|
|
68
70
|
|
|
69
71
|
## Commands
|
|
70
72
|
|
|
71
73
|
| Command | What it does |
|
|
72
74
|
| --- | --- |
|
|
73
|
-
| `npx archgen-skill init [dir]` |
|
|
75
|
+
| `npx archgen-skill init [dir]` | Prepare a project: canonical skill store at `.agents/skills/archgen`, Claude symlink adapter, AGENTS.md hub + CLAUDE.md bridge |
|
|
74
76
|
| `npx archgen-skill install [--copy]` | Install into global harness skill dirs (symlinks by default, real copies with `--copy`) |
|
|
75
77
|
| `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,
|
|
77
|
-
| `npx archgen-skill doctor [dir] [--check]` | Verify
|
|
78
|
-
| `npx archgen-skill
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
78
|
+
| `npx archgen-skill uninstall --project [dir]` | Remove a project install: strips managed blocks, symlink, and store **only if unmodified** — `.archgen/` feature folders and all user content are preserved |
|
|
79
|
+
| `npx archgen-skill doctor [dir] [--check]` | Verify and auto-repair an installation: store integrity, version stamp, Claude link, managed blocks, manifest (`--check` reports without fixing) |
|
|
80
|
+
| `npx archgen-skill update [dir]` | Check npm for a newer `archgen-skill`, upgrade the global install if outdated, then re-init the project and run doctor against the new version |
|
|
81
|
+
| `npx archgen-skill restore` | List backup snapshots; `--snapshot <ts>` restores one (current state is backed up first) |
|
|
82
|
+
| `npx archgen-skill migrate [dir] [--apply]` | Evolve generated-artifact formats in place (`--check` by default; every touched file is backed up) |
|
|
83
|
+
| `npx archgen-skill --version` / `--help` | Print the CLI version / show help |
|
|
89
84
|
|
|
90
85
|
## Why archgen
|
|
91
86
|
|
|
92
87
|
AI coding agents fail at scale for predictable reasons: plans with hidden cycles,
|
|
93
88
|
workers editing the same files, tasks without objective acceptance criteria, and no
|
|
94
89
|
human checkpoint before execution. archgen makes each of these a *structural*
|
|
95
|
-
guarantee
|
|
90
|
+
guarantee:
|
|
96
91
|
|
|
97
92
|
| Guarantee | Mechanism |
|
|
98
93
|
| --- | --- |
|
|
99
|
-
| Plans are valid before work starts | Verifier gate —
|
|
94
|
+
| Plans are valid before work starts | Verifier gate — cycles, dangling refs, ownership overlaps, plan↔task coverage |
|
|
100
95
|
| You approve before code is written | Explicit user gate after verifier approval |
|
|
101
96
|
| Parallel workers never collide | Disjoint `file_ownership` globs enforced per wave |
|
|
102
97
|
| Task order is always correct | Topological waves from `depends_on`; chains stay sequential |
|
|
@@ -124,23 +119,17 @@ guarantee rather than a hope:
|
|
|
124
119
|
|
|
125
120
|
## Supported harnesses
|
|
126
121
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
| OpenCode | ✅ | ✅ |
|
|
131
|
-
| Cursor | ✅ | ✅ |
|
|
132
|
-
| Codex | ✅ | ✅ |
|
|
133
|
-
| Gemini CLI | ✅ | ✅ |
|
|
134
|
-
| Antigravity | ✅ | ✅ |
|
|
135
|
-
| Any agentskills.io harness | ✅ | ✅ |
|
|
122
|
+
Claude Code, OpenCode, Cursor, Codex, Gemini CLI, Antigravity — and any
|
|
123
|
+
agentskills.io-compatible harness. Both global install and project-local `init`
|
|
124
|
+
are supported for all of them.
|
|
136
125
|
|
|
137
126
|
## VS Code extension (optional)
|
|
138
127
|
|
|
139
|
-
A read-only visual layer that renders `.archgen/` as a live task DAG
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
[GitHub Releases](https://github.com/akashmaurya99/archgen/releases). It
|
|
143
|
-
|
|
128
|
+
A read-only visual layer that renders `.archgen/` as a live task DAG, your
|
|
129
|
+
code-dependency graph, and rendered docs — with a ▶ button that hands any task to
|
|
130
|
+
your agent. Install it from the VS Code Marketplace or from a `.vsix` on
|
|
131
|
+
[GitHub Releases](https://github.com/akashmaurya99/archgen/releases). It never
|
|
132
|
+
edits anything: uninstalling it loses nothing.
|
|
144
133
|
|
|
145
134
|
## Documentation
|
|
146
135
|
|
|
@@ -149,7 +138,6 @@ a viewer: uninstalling it loses nothing.
|
|
|
149
138
|
| [Architecture walkthrough](https://github.com/akashmaurya99/archgen/blob/main/docs/architecture.md) | Dual-mode design, gates, wave mechanics, platform detection |
|
|
150
139
|
| [The skill contract](https://github.com/akashmaurya99/archgen/blob/main/skill/SKILL.md) | The full skill specification agents execute |
|
|
151
140
|
| [Changelog](https://github.com/akashmaurya99/archgen/blob/main/CHANGELOG.md) | Release history |
|
|
152
|
-
| [Contributing](https://github.com/akashmaurya99/archgen/blob/main/CONTRIBUTING.md) | Development setup and PR guide |
|
|
153
141
|
|
|
154
142
|
## Contributing
|
|
155
143
|
|
package/bin/archgen.mjs
CHANGED
|
@@ -193,7 +193,14 @@ try {
|
|
|
193
193
|
break;
|
|
194
194
|
}
|
|
195
195
|
console.log('\nre-running init + doctor with the new binary (' + latest + ')…');
|
|
196
|
-
spawnSync(freshBin, ['init', dir], { stdio: 'inherit' });
|
|
196
|
+
const upInit = spawnSync(freshBin, ['init', dir], { stdio: 'inherit' });
|
|
197
|
+
if (upInit.status !== 0) {
|
|
198
|
+
// An aborted init (e.g. symlink refusal) must surface as a failure —
|
|
199
|
+
// running doctor over a half-finished install would mask it.
|
|
200
|
+
console.error(' ! init failed during update - doctor skipped (see message above)');
|
|
201
|
+
process.exitCode = upInit.status ?? 1;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
197
204
|
spawnSync(freshBin, ['doctor', dir], { stdio: 'inherit' });
|
|
198
205
|
break;
|
|
199
206
|
}
|
package/lib/block.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
20
20
|
import { loadConfig } from './config.js';
|
|
21
|
+
import { assertNotSymlink } from './store.js';
|
|
21
22
|
|
|
22
23
|
// Markers + block-format version come from the single source of truth.
|
|
23
24
|
// Module-level load is safe: config resolution is module-relative and
|
|
@@ -127,8 +128,31 @@ export function renderClaudeBridgeText() {
|
|
|
127
128
|
return [START, provenanceLine(), '@AGENTS.md', END].join('\n');
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
function countOccurrences(haystack, needle) {
|
|
132
|
+
let n = 0;
|
|
133
|
+
for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length)) n++;
|
|
134
|
+
return n;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stripCompleteBlocks(text) {
|
|
138
|
+
let out = text;
|
|
139
|
+
for (;;) {
|
|
140
|
+
const s = out.indexOf(START);
|
|
141
|
+
if (s === -1) break;
|
|
142
|
+
const e = out.indexOf(END, s + START.length);
|
|
143
|
+
if (e === -1) break;
|
|
144
|
+
out = out.slice(0, s) + out.slice(e + END.length);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
130
149
|
/**
|
|
131
150
|
* Insert or replace the managed block inside existing file content.
|
|
151
|
+
* Corrupted input with DUPLICATED markers (more than one START or END, e.g. a
|
|
152
|
+
* twice-appended block) is normalized: the first complete block is replaced
|
|
153
|
+
* in place, every later complete block is removed, and anything outside the
|
|
154
|
+
* markers survives — the result carries exactly one block. Orphan markers
|
|
155
|
+
* left over after normalization still throw, as does a single orphan marker.
|
|
132
156
|
* @param {string} existing file contents ('' when creating new)
|
|
133
157
|
* @param {string} block rendered block
|
|
134
158
|
* @param {string} eol line ending used when appending to empty/missing files
|
|
@@ -138,6 +162,22 @@ export function upsertBlock(existing, block, eol = '\n') {
|
|
|
138
162
|
// caller rendered — this is what upgrades legacy unversioned blocks in place.
|
|
139
163
|
const fresh = injectProvenance(block);
|
|
140
164
|
const trimmed = existing ?? '';
|
|
165
|
+
const starts = countOccurrences(trimmed, START);
|
|
166
|
+
const ends = countOccurrences(trimmed, END);
|
|
167
|
+
|
|
168
|
+
if (starts > 1 || ends > 1) {
|
|
169
|
+
const s = trimmed.indexOf(START);
|
|
170
|
+
const e = s === -1 ? -1 : trimmed.indexOf(END, s + START.length);
|
|
171
|
+
if (s === -1 || e === -1) {
|
|
172
|
+
throw new Error('found only one archgen marker - fix or remove it manually');
|
|
173
|
+
}
|
|
174
|
+
const tail = stripCompleteBlocks(trimmed.slice(e + END.length));
|
|
175
|
+
if (tail.includes(START) || tail.includes(END)) {
|
|
176
|
+
throw new Error('found only one archgen marker - fix or remove it manually');
|
|
177
|
+
}
|
|
178
|
+
return trimmed.slice(0, s) + fresh + tail;
|
|
179
|
+
}
|
|
180
|
+
|
|
141
181
|
const startIdx = trimmed.indexOf(START);
|
|
142
182
|
const endIdx = trimmed.indexOf(END);
|
|
143
183
|
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
@@ -152,18 +192,16 @@ export function upsertBlock(existing, block, eol = '\n') {
|
|
|
152
192
|
}
|
|
153
193
|
|
|
154
194
|
/**
|
|
155
|
-
* Strip
|
|
195
|
+
* Strip EVERY managed block from file contents (duplicated markers normalize
|
|
196
|
+
* to zero blocks, matching upsertBlock's one-block guarantee).
|
|
156
197
|
* @returns {{content: string, hadBlock: boolean}}
|
|
157
198
|
*/
|
|
158
199
|
export function stripBlock(existing) {
|
|
159
200
|
const trimmed = existing ?? '';
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
if (startIdx === -1 || endIdx === -1) return { content: trimmed, hadBlock: false };
|
|
163
|
-
let out = trimmed.slice(0, startIdx) + trimmed.slice(endIdx + END.length);
|
|
201
|
+
const out = stripCompleteBlocks(trimmed);
|
|
202
|
+
if (out === trimmed) return { content: trimmed, hadBlock: false };
|
|
164
203
|
// Collapse whitespace left behind; drop file if nothing else remains.
|
|
165
|
-
|
|
166
|
-
return { content: out, hadBlock: true };
|
|
204
|
+
return { content: out.replace(/^\n+/, '').replace(/\n{3,}/g, '\n\n'), hadBlock: true };
|
|
167
205
|
}
|
|
168
206
|
|
|
169
207
|
function splitBom(raw) {
|
|
@@ -174,18 +212,24 @@ function splitBom(raw) {
|
|
|
174
212
|
/** Write via sibling temp file + rename so readers never observe partial content. */
|
|
175
213
|
function atomicWrite(absPath, data) {
|
|
176
214
|
const tmp = absPath + '.tmp-' + process.pid + '-' + Date.now();
|
|
177
|
-
|
|
215
|
+
// 'wx' (O_CREAT|O_EXCL): if the tmp name already exists — e.g. an attacker
|
|
216
|
+
// planted a symlink there to redirect the write — fail instead of following.
|
|
217
|
+
writeFileSync(tmp, data, { flag: 'wx' });
|
|
178
218
|
renameSync(tmp, absPath);
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
/**
|
|
182
222
|
* BOM/EOL-aware upsert of a managed block directly into a file.
|
|
183
223
|
* Preserves a leading UTF-8 BOM and the file's CRLF/LF convention.
|
|
224
|
+
* Refuses (throws) when the target is a symlink: managed files must never be
|
|
225
|
+
* written through a link (the final rename replaces the link itself, but the
|
|
226
|
+
* read+upsert would otherwise operate on attacker-chosen content).
|
|
184
227
|
* @param {string} absPath target file (created when missing)
|
|
185
228
|
* @param {string[]} blockLines lines between the managed markers
|
|
186
229
|
* @returns {boolean} whether the file existed before the call
|
|
187
230
|
*/
|
|
188
231
|
export function upsertManagedFile(absPath, blockLines) {
|
|
232
|
+
assertNotSymlink(absPath);
|
|
189
233
|
const existed = existsSync(absPath);
|
|
190
234
|
const raw = existed ? readFileSync(absPath, 'utf8') : '';
|
|
191
235
|
const { bom, body } = splitBom(raw);
|
|
@@ -208,6 +252,7 @@ export function importsAgents(absPath) {
|
|
|
208
252
|
|
|
209
253
|
/** Strip the managed block from a file on disk, preserving BOM. */
|
|
210
254
|
export function stripManagedFile(absPath) {
|
|
255
|
+
assertNotSymlink(absPath);
|
|
211
256
|
let raw = '';
|
|
212
257
|
try { raw = readFileSync(absPath, 'utf8'); } catch { return { hadBlock: false }; }
|
|
213
258
|
const { bom, body } = splitBom(raw);
|
|
@@ -233,6 +278,7 @@ function renderRow(row) {
|
|
|
233
278
|
* @returns {string} the new file content
|
|
234
279
|
*/
|
|
235
280
|
export function upsertFeaturesRegistry(fileAbsPath, rows = []) {
|
|
281
|
+
assertNotSymlink(fileAbsPath);
|
|
236
282
|
const raw = readFileSync(fileAbsPath, 'utf8');
|
|
237
283
|
const table = [
|
|
238
284
|
'| Feature | Status | Updated |',
|
package/lib/doctor.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// reported and counted in the summary but do NOT fail the run: the process
|
|
8
8
|
// exits non-zero only when a repair operation itself throws.
|
|
9
9
|
|
|
10
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readlinkSync, symlinkSync, unlinkSync
|
|
10
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { dirname, join, resolve } from 'node:path';
|
|
13
13
|
import { globalTargets } from './install.js';
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
upsertManagedFile,
|
|
23
23
|
} from './block.js';
|
|
24
24
|
import { resolveSkillSource } from './init.js';
|
|
25
|
-
import { CLAUDE_LINK_REL, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, hashDir, loadManifest, saveManifest } from './store.js';
|
|
25
|
+
import { CLAUDE_LINK_REL, DEV_ONLY_RELPATHS, MANIFEST_REL, STORE_REL, VERSION_FILE, cliVersion, hashDir, loadManifest, pruneDevOnly, saveManifest } from './store.js';
|
|
26
26
|
import { compareSemver, parseSemver } from './version.js';
|
|
27
27
|
import { readStamp, writeStamp } from './version-stamp.js';
|
|
28
28
|
|
|
@@ -98,6 +98,23 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// 1b. Prune dev-only artifacts (scripts/test) that older installers shipped
|
|
102
|
+
// into the store. They are repo-only CI tooling and never belong in an
|
|
103
|
+
// installed tree; removing them here cleans existing stores without a
|
|
104
|
+
// full backup+re-copy. hashDir ignores the same subtrees, so this prune
|
|
105
|
+
// neither masks real customization nor reads as divergence.
|
|
106
|
+
if (existsSync(storeAbs)) {
|
|
107
|
+
const devOnlyPresent = DEV_ONLY_RELPATHS.filter((rel) => existsSync(join(storeAbs, ...rel.split('/'))));
|
|
108
|
+
if (devOnlyPresent.length > 0) {
|
|
109
|
+
if (checkOnly) {
|
|
110
|
+
add('WOULD-FIX', `store carries dev-only ${devOnlyPresent.join(', ')} -> pruned`);
|
|
111
|
+
} else {
|
|
112
|
+
attempt(() => pruneDevOnly(storeAbs), 'FIXED',
|
|
113
|
+
`dev-only ${devOnlyPresent.join(', ')} pruned from store`, 'cannot prune dev-only artifacts');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
101
118
|
// 2. Version stamp currency.
|
|
102
119
|
const stampAbs = join(storeAbs, VERSION_FILE);
|
|
103
120
|
let stamp = null;
|
|
@@ -146,8 +163,10 @@ export function doctorProject(projectDir, packageRoot, opts = {}) {
|
|
|
146
163
|
if (!existsSync(agentsAbs)) {
|
|
147
164
|
if (checkOnly) add('WOULD-FIX', 'AGENTS.md missing -> created with managed block');
|
|
148
165
|
else {
|
|
149
|
-
|
|
150
|
-
|
|
166
|
+
// Through the guarded upsert (not raw writeFileSync): existsSync follows
|
|
167
|
+
// symlinks, so a dangling symlink would otherwise be written through.
|
|
168
|
+
attempt(() => upsertManagedFile(agentsAbs, renderBlock(STORE_REL).split('\n')), 'FIXED',
|
|
169
|
+
'AGENTS.md created with managed block', 'cannot create AGENTS.md managed block');
|
|
151
170
|
}
|
|
152
171
|
} else {
|
|
153
172
|
let raw = readFileSync(agentsAbs, 'utf8');
|
package/lib/init.js
CHANGED
|
@@ -21,7 +21,7 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, r
|
|
|
21
21
|
import { randomBytes } from 'node:crypto';
|
|
22
22
|
import { dirname, join, resolve } from 'node:path';
|
|
23
23
|
import { importsAgents, renderBlock, START, END, upsertManagedFile } from './block.js';
|
|
24
|
-
import { CLAUDE_LINK_REL, STORE_REL, VERSION_FILE, appendEntry, cliVersion, hashDir, moveToBackup } from './store.js';
|
|
24
|
+
import { CLAUDE_LINK_REL, STORE_REL, VERSION_FILE, appendEntry, assertNotSymlink, assertWithinRoot, cliVersion, hashDir, moveToBackup, pruneDevOnly } from './store.js';
|
|
25
25
|
import { writeStamp } from './version-stamp.js';
|
|
26
26
|
|
|
27
27
|
const CONTEXT_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
@@ -80,6 +80,9 @@ function installClaudeAdapter(root, source, warnings) {
|
|
|
80
80
|
return 'kept-divergent';
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Escape check: a symlinked .claude/.claude/skills parent must not redirect
|
|
84
|
+
// the adapter creation outside the project root.
|
|
85
|
+
assertWithinRoot(root, claudeAbs);
|
|
83
86
|
try {
|
|
84
87
|
mkdirSync(dirname(claudeAbs), { recursive: true });
|
|
85
88
|
symlinkSync('../../.agents/skills/archgen', claudeAbs);
|
|
@@ -116,6 +119,12 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
116
119
|
|
|
117
120
|
const storeAbs = join(root, relJoin(STORE_REL));
|
|
118
121
|
|
|
122
|
+
// Supply-chain guards BEFORE any mutation: a symlinked store (a malicious
|
|
123
|
+
// clone can ship .agents/skills/archgen as a link to an unrelated dir) is
|
|
124
|
+
// refused, and the resolved real path must stay inside the project root.
|
|
125
|
+
assertNotSymlink(storeAbs);
|
|
126
|
+
assertWithinRoot(root, storeAbs);
|
|
127
|
+
|
|
119
128
|
// 1. Canonical store — fingerprint before mutating (ownership check).
|
|
120
129
|
const prev = lstatSafe(storeAbs);
|
|
121
130
|
if (prev) {
|
|
@@ -129,6 +138,7 @@ export function initProject(projectDir, packageRoot, opts = {}) {
|
|
|
129
138
|
mkdirSync(skillsParent, { recursive: true });
|
|
130
139
|
const tmp = join(skillsParent, '.archgen-tmp-' + randomBytes(6).toString('hex'));
|
|
131
140
|
cpSync(source, tmp, { recursive: true });
|
|
141
|
+
pruneDevOnly(tmp); // dev-only artifacts (scripts/test) never reach the installed store
|
|
132
142
|
writeStamp(tmp, version);
|
|
133
143
|
rmSync(storeAbs, { recursive: true, force: true });
|
|
134
144
|
renameSync(tmp, storeAbs);
|
package/lib/install.js
CHANGED
|
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import { dirname, join, resolve } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { resolveSkillSource } from './init.js';
|
|
10
|
-
import { cliVersion, hashDir, moveToBackupInto } from './store.js';
|
|
10
|
+
import { assertNotSymlink, assertWithinRoot, cliVersion, hashDir, moveToBackupInto, pruneDevOnly, refuseSymlinkMessage } from './store.js';
|
|
11
11
|
import { writeStamp } from './version-stamp.js';
|
|
12
12
|
import { loadConfig } from './config.js';
|
|
13
13
|
|
|
@@ -36,6 +36,7 @@ export function globalTargets(home = homedir()) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function record(manifest, kind, path) {
|
|
39
|
+
assertNotSymlink(manifest);
|
|
39
40
|
const line = `${kind}\t${path}`;
|
|
40
41
|
let dup = false;
|
|
41
42
|
if (existsSync(manifest)) {
|
|
@@ -73,17 +74,31 @@ export function installGlobal(opts = {}) {
|
|
|
73
74
|
const backups = [];
|
|
74
75
|
let failures = 0;
|
|
75
76
|
|
|
76
|
-
const targets = [
|
|
77
|
-
|
|
77
|
+
const targets = [
|
|
78
|
+
...globalTargets(home).map((d) => [d, home]),
|
|
79
|
+
[join(process.cwd(), '.github', 'skills'), process.cwd()],
|
|
80
|
+
];
|
|
81
|
+
for (const [tdir, base] of targets) {
|
|
78
82
|
const dest = join(tdir, 'archgen');
|
|
79
83
|
if (!existsSync(tdir)) { rows.push(['SKIP', '-', tdir + ' (does not exist)']); continue; }
|
|
80
84
|
try {
|
|
85
|
+
// Realpath-escape guard: a symlinked harness dir (e.g. ~/.claude -> /etc)
|
|
86
|
+
// must not redirect the install outside the base it belongs to.
|
|
87
|
+
assertWithinRoot(base, tdir);
|
|
81
88
|
mkdirSync(tdir, { recursive: true }); // no-op when present
|
|
82
89
|
if (opts.copy) {
|
|
83
90
|
// Safety invariant: fingerprint dest BEFORE mutating; identical
|
|
84
91
|
// (stamp-ignoring) skips like link mode, divergent moves into the
|
|
85
92
|
// vault first — nothing is rmSync'd without a prior backup of it.
|
|
86
93
|
const prev = lstatSafe(dest);
|
|
94
|
+
if (prev && prev.isSymbolicLink()) {
|
|
95
|
+
// A symlink at dest is only ours when it points at this source (a
|
|
96
|
+
// previous link-mode install). Any other link is foreign: replacing
|
|
97
|
+
// it would let a planted symlink steer the copy, so refuse.
|
|
98
|
+
let linkTarget = null;
|
|
99
|
+
try { linkTarget = resolve(dirname(dest), readlinkSync(dest)); } catch { /* unreadable */ }
|
|
100
|
+
if (linkTarget !== source) throw new Error(refuseSymlinkMessage(dest));
|
|
101
|
+
}
|
|
87
102
|
let identical = false;
|
|
88
103
|
if (prev && prev.isDirectory() && !prev.isSymbolicLink()) {
|
|
89
104
|
try { identical = hashDir(dest) === hashDir(source); } catch { identical = false; }
|
|
@@ -100,7 +115,9 @@ export function installGlobal(opts = {}) {
|
|
|
100
115
|
rows.push(['BACKED-UP', '-', abs]);
|
|
101
116
|
}
|
|
102
117
|
rmSync(dest, { force: true, recursive: true }); // no-op after a move; guards exotic-FS fallbacks
|
|
118
|
+
assertNotSymlink(dest); // TOCTOU re-check: nothing may have re-linked dest between backup and copy
|
|
103
119
|
cpSync(source, dest, { recursive: true });
|
|
120
|
+
pruneDevOnly(dest); // dev-only artifacts (scripts/test) never reach global --copy trees
|
|
104
121
|
stampNote(rows, dest, () => writeStamp(dest, version));
|
|
105
122
|
record(manifest, 'copy', dest);
|
|
106
123
|
rows.push(['OK', 'copy', dest]);
|
package/lib/store.js
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
// - hashDir() fingerprints a tree (stable-order walk of relpaths + contents,
|
|
12
12
|
// sha256) so mutations only ever happen after verifying ownership/divergence;
|
|
13
13
|
// - GUARDED_RELPATHS (.claude, .agents, .claude/skills, .agents/skills) are
|
|
14
|
-
// never deleted: parent pruning stops before touching those levels
|
|
14
|
+
// never deleted: parent pruning stops before touching those levels;
|
|
15
|
+
// - assertNotSymlink()/assertWithinRoot() lstat BEFORE every write: a
|
|
16
|
+
// symlinked target (supply-chain attack: a malicious clone shipping
|
|
17
|
+
// .agents/skills/archgen or AGENTS.md as a link to an unrelated directory)
|
|
18
|
+
// is refused instead of written through.
|
|
15
19
|
|
|
16
20
|
import { createHash } from 'node:crypto';
|
|
17
21
|
import {
|
|
@@ -22,11 +26,12 @@ import {
|
|
|
22
26
|
readFileSync,
|
|
23
27
|
readdirSync,
|
|
24
28
|
readlinkSync,
|
|
29
|
+
realpathSync,
|
|
25
30
|
renameSync,
|
|
26
31
|
rmSync,
|
|
27
32
|
writeFileSync,
|
|
28
33
|
} from 'node:fs';
|
|
29
|
-
import { dirname, join } from 'node:path';
|
|
34
|
+
import { dirname, join, sep } from 'node:path';
|
|
30
35
|
import { loadConfig } from './config.js';
|
|
31
36
|
|
|
32
37
|
export const STORE_REL = '.agents/skills/archgen';
|
|
@@ -41,6 +46,71 @@ export const VERSION_FILE = CONFIG_FILES.stamp;
|
|
|
41
46
|
|
|
42
47
|
const GUARDED_RELPATHS = new Set(['.claude', '.agents', '.claude/skills', '.agents/skills']);
|
|
43
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Dev-only skill subtrees that must NEVER be distributed to end users. The
|
|
51
|
+
* repo's own node:test suite (scripts/test) is repo-only CI tooling: nothing
|
|
52
|
+
* imports it at runtime and it references repo-only fixtures
|
|
53
|
+
* (fixtures/yaml-corpus), so it cannot run in an installed project — shipping
|
|
54
|
+
* it into installed stores or the npm payload is dead weight. It is excluded
|
|
55
|
+
* consistently from the vendor mirror, every installed-store copy, and BOTH
|
|
56
|
+
* sides of hashDir comparisons (so its presence/absence never reads as drift).
|
|
57
|
+
* scripts/lib is deliberately NOT here — it is a runtime dependency of the
|
|
58
|
+
* skill scripts (imported by next-tasks/plan-graph/set-status/doc-index/etc).
|
|
59
|
+
*/
|
|
60
|
+
export const DEV_ONLY_RELPATHS = ['scripts/test'];
|
|
61
|
+
|
|
62
|
+
/** True when relpath `r` is (or lives inside) a dev-only excluded subtree. */
|
|
63
|
+
function isDevOnlyRelPath(r) {
|
|
64
|
+
for (const prefix of DEV_ONLY_RELPATHS) {
|
|
65
|
+
if (r === prefix || r.startsWith(prefix + '/')) return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Canonical refusal message for every symlink-write guard (CLI + install.sh). */
|
|
71
|
+
export function refuseSymlinkMessage(absPath) {
|
|
72
|
+
return 'Refusing to write through symlink at ' + absPath + ' — use --copy or remove symlink';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Supply-chain guard: lstat (never stat — the LINK itself, not its target) a
|
|
77
|
+
* path before writing it. Absent paths pass (safe to create); a symlink at
|
|
78
|
+
* the target throws so no write can ever be redirected through the link.
|
|
79
|
+
*/
|
|
80
|
+
export function assertNotSymlink(absPath) {
|
|
81
|
+
let st = null;
|
|
82
|
+
try { st = lstatSync(absPath); } catch { return; }
|
|
83
|
+
if (st.isSymbolicLink()) throw new Error(refuseSymlinkMessage(absPath));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Realpath-escape guard: resolve `absPath` (climbing to the nearest existing
|
|
88
|
+
* ancestor when it does not exist yet, so a symlinked PARENT dir is caught
|
|
89
|
+
* before anything is created beneath it) and refuse when the real location
|
|
90
|
+
* lands outside `rootAbs`. Catches `.agents -> /elsewhere` style redirects
|
|
91
|
+
* that a final-component lstat cannot see.
|
|
92
|
+
*/
|
|
93
|
+
export function assertWithinRoot(rootAbs, absPath) {
|
|
94
|
+
let rootReal;
|
|
95
|
+
try { rootReal = realpathSync(rootAbs); } catch { return; }
|
|
96
|
+
let probe = absPath;
|
|
97
|
+
for (;;) {
|
|
98
|
+
let exists = true;
|
|
99
|
+
try { lstatSync(probe); } catch { exists = false; }
|
|
100
|
+
if (exists) {
|
|
101
|
+
let real;
|
|
102
|
+
try { real = realpathSync(probe); } catch { return; } // broken chain: nothing writable through it
|
|
103
|
+
if (real !== rootReal && !real.startsWith(rootReal + sep)) {
|
|
104
|
+
throw new Error(refuseSymlinkMessage(absPath));
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const parent = dirname(probe);
|
|
109
|
+
if (parent === probe) return;
|
|
110
|
+
probe = parent;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
44
114
|
/** Version string of this CLI package (used for the in-store stamp). */
|
|
45
115
|
export function cliVersion(packageRoot) {
|
|
46
116
|
return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
@@ -56,6 +126,10 @@ function byName(a, b) {
|
|
|
56
126
|
* The version stamp is ALWAYS ignored on both sides of a comparison: it is
|
|
57
127
|
* CLI metadata written at destination time (global symlink installs stamp the
|
|
58
128
|
* shared real copy), never skill content, so it must not read as divergence.
|
|
129
|
+
* DEV_ONLY_RELPATHS (scripts/test) are ALSO always ignored on both sides: they
|
|
130
|
+
* are pruned from every distributed copy, so a source tree that still carries
|
|
131
|
+
* them (the dev-checkout skill/ fallback) must hash equal to an installed store
|
|
132
|
+
* that does not — while a real edit anywhere else must still read as divergence.
|
|
59
133
|
* @param {string} dir absolute directory to hash
|
|
60
134
|
* @param {{ignore?: string[]}} opts EXTRA relpaths to skip
|
|
61
135
|
*/
|
|
@@ -66,7 +140,7 @@ export function hashDir(dir, opts = {}) {
|
|
|
66
140
|
const entries = readdirSync(abs, { withFileTypes: true }).sort((a, b) => byName(a.name, b.name));
|
|
67
141
|
for (const ent of entries) {
|
|
68
142
|
const r = rel ? rel + '/' + ent.name : ent.name;
|
|
69
|
-
if (ignore.has(r)) continue;
|
|
143
|
+
if (ignore.has(r) || isDevOnlyRelPath(r)) continue;
|
|
70
144
|
const p = join(abs, ent.name);
|
|
71
145
|
if (ent.isSymbolicLink()) hash.update('l ' + r + ' -> ' + readlinkSync(p) + '\n');
|
|
72
146
|
else if (ent.isDirectory()) { hash.update('d ' + r + '\n'); walk(p, r); }
|
|
@@ -76,6 +150,20 @@ export function hashDir(dir, opts = {}) {
|
|
|
76
150
|
return hash.digest('hex');
|
|
77
151
|
}
|
|
78
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Remove every dev-only subtree (DEV_ONLY_RELPATHS) from an absolute skill-tree
|
|
155
|
+
* root; idempotent no-op when a subtree is absent. Called after EVERY copy of
|
|
156
|
+
* the skill into a distributed location (installed store, global --copy tree,
|
|
157
|
+
* vendor mirror) so dev-only artifacts never reach end users regardless of which
|
|
158
|
+
* source resolveSkillSource picked — vendor is already pruned by sync-vendor,
|
|
159
|
+
* but the dev-checkout skill/ fallback is not, and this normalizes both.
|
|
160
|
+
*/
|
|
161
|
+
export function pruneDevOnly(absRoot) {
|
|
162
|
+
for (const rel of DEV_ONLY_RELPATHS) {
|
|
163
|
+
rmSync(join(absRoot, ...rel.split('/')), { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
79
167
|
/** Load `.archgen/.install-manifest.json`; null when absent/corrupt. */
|
|
80
168
|
export function loadManifest(root) {
|
|
81
169
|
const p = join(root, ...MANIFEST_REL.split('/'));
|
|
@@ -91,6 +179,7 @@ export function loadManifest(root) {
|
|
|
91
179
|
export function saveManifest(root, manifest) {
|
|
92
180
|
const p = join(root, ...MANIFEST_REL.split('/'));
|
|
93
181
|
mkdirSync(dirname(p), { recursive: true });
|
|
182
|
+
assertNotSymlink(p);
|
|
94
183
|
writeFileSync(p, JSON.stringify(manifest, null, 2) + '\n');
|
|
95
184
|
}
|
|
96
185
|
|
|
@@ -121,7 +210,13 @@ export function moveToBackupInto(root, relPath, backupRootRel, ts = new Date().t
|
|
|
121
210
|
const srcAbs = join(root, ...relPath.split('/'));
|
|
122
211
|
try {
|
|
123
212
|
renameSync(srcAbs, destAbs);
|
|
124
|
-
} catch {
|
|
213
|
+
} catch (err) {
|
|
214
|
+
// Fallback copy+remove must NEVER run on a symlinked source: cpSync would
|
|
215
|
+
// read through the link and rmSync would remove it — rethrow instead so
|
|
216
|
+
// the caller refuses the replace rather than touching the link's target.
|
|
217
|
+
let st = null;
|
|
218
|
+
try { st = lstatSync(srcAbs); } catch { /* vanished mid-move */ }
|
|
219
|
+
if (st && st.isSymbolicLink()) throw err;
|
|
125
220
|
cpSync(srcAbs, destAbs, { recursive: true });
|
|
126
221
|
rmSync(srcAbs, { recursive: true, force: true });
|
|
127
222
|
}
|
package/lib/version-stamp.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
|
-
import { VERSION_FILE } from './store.js';
|
|
16
|
+
import { VERSION_FILE, assertNotSymlink } from './store.js';
|
|
17
17
|
|
|
18
18
|
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
19
19
|
|
|
@@ -42,6 +42,8 @@ export function readStamp(skillRoot) {
|
|
|
42
42
|
*/
|
|
43
43
|
export function writeStamp(skillRoot, version) {
|
|
44
44
|
const p = join(skillRoot, VERSION_FILE);
|
|
45
|
+
assertNotSymlink(skillRoot);
|
|
45
46
|
mkdirSync(dirname(p), { recursive: true });
|
|
47
|
+
assertNotSymlink(p);
|
|
46
48
|
writeFileSync(p, version + '\n', 'utf8');
|
|
47
49
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "archgen-skill",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
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",
|
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
"bugs": {
|
|
39
39
|
"url": "https://github.com/akashmaurya99/archgen/issues"
|
|
40
40
|
},
|
|
41
|
-
"//": "vendor/skills/archgen is
|
|
41
|
+
"//": "vendor/skills/archgen is a full mirror of ../../skill synced by scripts/sync-vendor.mjs (run automatically via prepublishOnly). The extension ships separately as a VS Code vsix.",
|
|
42
42
|
"scripts": {
|
|
43
43
|
"sync:config": "node scripts/sync-config.mjs",
|
|
44
|
+
"sync:check": "node scripts/sync-config.mjs --check",
|
|
44
45
|
"sync": "node scripts/sync-config.mjs && node scripts/sync-vendor.mjs",
|
|
45
46
|
"prepublishOnly": "node scripts/sync-config.mjs && node scripts/sync-vendor.mjs",
|
|
46
47
|
"test": "node --test test/*.test.mjs"
|