archgen-skill 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +29 -3
  2. package/bin/archgen.mjs +146 -12
  3. package/lib/block.js +197 -8
  4. package/lib/config.js +96 -0
  5. package/lib/doctor.js +283 -0
  6. package/lib/init.js +130 -27
  7. package/lib/install.js +105 -14
  8. package/lib/migrate.js +88 -0
  9. package/lib/migrations/001-stamp-provenance.mjs +77 -0
  10. package/lib/migrations/index.mjs +170 -0
  11. package/lib/restore.js +239 -0
  12. package/lib/store.js +161 -0
  13. package/lib/uninstall-project.js +95 -0
  14. package/lib/version-stamp.js +47 -0
  15. package/lib/version.js +23 -0
  16. package/package.json +37 -9
  17. package/vendor/skills/archgen/SKILL.md +252 -73
  18. package/vendor/skills/archgen/archgen.config.json +16 -0
  19. package/vendor/skills/archgen/assets/skill-registry.json +4 -3
  20. package/vendor/skills/archgen/references/artifact-templates.md +251 -7
  21. package/vendor/skills/archgen/references/interview.md +180 -16
  22. package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
  23. package/vendor/skills/archgen/references/orchestration.md +102 -39
  24. package/vendor/skills/archgen/references/platforms.md +18 -14
  25. package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
  26. package/vendor/skills/archgen/scripts/impact.mjs +14 -1
  27. package/vendor/skills/archgen/scripts/lib/graph.mjs +82 -3
  28. package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
  29. package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
  30. package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
  31. package/vendor/skills/archgen/scripts/set-status.mjs +26 -6
  32. package/vendor/skills/archgen/scripts/update-agents.mjs +204 -0
  33. package/vendor/skills/archgen/scripts/validate.mjs +18 -1
  34. package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
package/lib/migrate.js ADDED
@@ -0,0 +1,88 @@
1
+ // migrate.js — `archgen migrate [dir] [--check] [--apply] [--migration <id>]`.
2
+ //
3
+ // Evolves generated-artifact formats in existing projects with zero data loss.
4
+ // Tone and safety model mirror doctor: dry-run is the DEFAULT (--check lists
5
+ // pending migrations and the exact files they would touch, writing nothing);
6
+ // --apply runs them, snapshotting every affected file into
7
+ // .archgen/.backup/<ts>/migrate/ BEFORE any modification. Exit codes:
8
+ // 0 nothing pending, or all migrations applied cleanly
9
+ // 1 a migration failed mid-apply (earlier files stay backed up)
10
+ // 2 usage error (unknown flag / unknown --migration id / --check + --apply)
11
+ //
12
+ // bin/archgen.mjs wiring (integrator):
13
+ // case 'migrate': process.exitCode = await migrateMain(rest); break;
14
+
15
+ import { applyMigration, listMigrations, pendingMigrations } from './migrations/index.mjs';
16
+ import { resolve } from 'node:path';
17
+
18
+ const USAGE = 'usage: archgen-skill migrate [dir] [--check|--apply] [--migration <id>]';
19
+
20
+ /**
21
+ * @param {string[]} argv args AFTER the subcommand, e.g. ['myproj', '--apply']
22
+ * @returns {Promise<number>} exit code int
23
+ */
24
+ export async function migrateMain(argv = []) {
25
+ let dir = null;
26
+ let check = false;
27
+ let apply = false;
28
+ let target = null;
29
+
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === '--check') check = true;
33
+ else if (a === '--apply') apply = true;
34
+ else if (a === '--migration') {
35
+ target = argv[++i];
36
+ if (!target || target.startsWith('-')) { console.error(USAGE); return 2; }
37
+ } else if (a.startsWith('-')) {
38
+ console.error(`unknown flag: ${a}\n${USAGE}`);
39
+ return 2;
40
+ } else if (dir === null) dir = a;
41
+ else { console.error(`unexpected argument: ${a}\n${USAGE}`); return 2; }
42
+ }
43
+ if (check && apply) { console.error('--check and --apply are mutually exclusive\n' + USAGE); return 2; }
44
+
45
+ const dryRun = !apply; // check mode is the default, always
46
+ const root = resolve(dir ?? process.cwd());
47
+
48
+ const all = await listMigrations();
49
+ if (target && !all.some((m) => m.id === target)) {
50
+ console.error(`unknown migration '${target}' (available: ${all.map((m) => m.id).join(', ') || 'none'})`);
51
+ return 2;
52
+ }
53
+
54
+ const pending = (await pendingMigrations(root)).filter((p) => !target || p.id === target);
55
+
56
+ console.log('archgen migrate — ' + root + (dryRun ? ' (check mode: no changes made)' : ''));
57
+
58
+ if (pending.length === 0) {
59
+ console.log(' = nothing to do — all migrations applied');
60
+ console.log('Summary: up to date — 0 pending');
61
+ return 0;
62
+ }
63
+
64
+ let fileCount = 0;
65
+ let failed = 0;
66
+ for (const p of pending) {
67
+ if (dryRun) {
68
+ console.log(` [would-apply] ${p.id} — ${p.description}`);
69
+ for (const f of p.files) console.log(` ${f}`);
70
+ fileCount += p.files.length;
71
+ continue;
72
+ }
73
+ try {
74
+ const r = await applyMigration(p.id, root, { dryRun: false });
75
+ if (r.status === 'clean') { console.log(` [skip] ${r.id} — already applied`); continue; }
76
+ console.log(` [applied] ${r.id} — ${r.description}`);
77
+ for (const b of r.backups ?? []) console.log(` ${b.file} (backup: ${b.backup})`);
78
+ fileCount += (r.files ?? []).length;
79
+ } catch (e) {
80
+ failed++;
81
+ console.log(` [fail] ${p.id} — ${e instanceof Error ? e.message : String(e)}`);
82
+ }
83
+ }
84
+
85
+ if (dryRun) console.log(`Summary: ${pending.length} migration(s) pending, ${fileCount} file(s) would change`);
86
+ else console.log(`Summary: ${pending.length - failed} applied, ${fileCount} file(s) changed, ${failed} failure(s)`);
87
+ return failed > 0 ? 1 : 0;
88
+ }
@@ -0,0 +1,77 @@
1
+ // 001-stamp-provenance.mjs — backfill the provenance header onto artifacts
2
+ // generated before the convention existed (see skill/references/
3
+ // artifact-templates.md for the forward-looking rule).
4
+ //
5
+ // What it writes: three YAML COMMENT lines at the very top of tasks.yaml and
6
+ // architecture.yaml. Comments are inert to every consumer by contract — the
7
+ // skill's zero-dep parser records them positionally (set-status.mjs preserves
8
+ // them verbatim), verify-plan.mjs never sees them as data, and the VS Code
9
+ // extension port behaves identically. Additive-only: no existing key is
10
+ // touched, nothing becomes required.
11
+ //
12
+ // # schema_version: 1
13
+ // # generator: archgen v0.0.4 (version token omitted when unknowable)
14
+ // # generated_at: 2026-08-26T09:00:00.000Z
15
+ //
16
+ // Idempotency: detect/files/apply all key off the `# schema_version:` marker
17
+ // line; stamped files are invisible to this migration, so a second run is a
18
+ // no-op. apply() re-checks per file before writing — it can never double-stamp.
19
+
20
+ import { readFileSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+
23
+ const SCHEMA_VERSION = 1;
24
+ const MARKER_RE = /^#\s*schema_version:\s*\d+\s*$/m;
25
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
26
+ const STORE_VERSION_REL = '.agents/skills/archgen/.archgen-version';
27
+
28
+ export const id = '001-stamp-provenance';
29
+ export const description = 'stamp provenance header (schema_version / generator / generated_at) onto tasks.yaml and architecture.yaml';
30
+ export const fromSchema = null;
31
+ export const toSchema = SCHEMA_VERSION;
32
+
33
+ /**
34
+ * Code-defensive generator version: trust only an exact semver stamp in the
35
+ * canonical store; anything else (absent, corrupt, pre-stamp) omits the token
36
+ * rather than guessing.
37
+ */
38
+ function generatorToken(projectRoot) {
39
+ try {
40
+ const v = readFileSync(join(projectRoot, ...STORE_VERSION_REL.split('/')), 'utf8').trim();
41
+ if (SEMVER_RE.test(v)) return `archgen v${v}`;
42
+ } catch { /* absent or unreadable */ }
43
+ return 'archgen';
44
+ }
45
+
46
+ function stampLines(projectRoot, generatedAt) {
47
+ return [
48
+ `# schema_version: ${SCHEMA_VERSION}`,
49
+ `# generator: ${generatorToken(projectRoot)}`,
50
+ `# generated_at: ${generatedAt}`,
51
+ ];
52
+ }
53
+
54
+ function unstampedArtifacts(ctx) {
55
+ return ctx.scanArtifacts().filter((a) => {
56
+ const abs = join(ctx.projectRoot, ...a.rel.split('/'));
57
+ return !MARKER_RE.test(readFileSync(abs, 'utf8'));
58
+ });
59
+ }
60
+
61
+ export function detect(ctx) {
62
+ return unstampedArtifacts(ctx).length > 0;
63
+ }
64
+
65
+ export function files(ctx) {
66
+ return unstampedArtifacts(ctx).map((a) => a.rel);
67
+ }
68
+
69
+ export function apply(ctx) {
70
+ const generatedAt = new Date().toISOString(); // one timestamp per run
71
+ for (const a of unstampedArtifacts(ctx)) {
72
+ const abs = join(ctx.projectRoot, ...a.rel.split('/'));
73
+ const content = readFileSync(abs, 'utf8');
74
+ if (MARKER_RE.test(content)) continue; // re-check: never double-stamp
75
+ writeFileSync(abs, stampLines(ctx.projectRoot, generatedAt).join('\n') + '\n' + content);
76
+ }
77
+ }
@@ -0,0 +1,170 @@
1
+ // migrations/index.mjs — artifact-format migration framework.
2
+ //
3
+ // Generated artifacts live under `.archgen/<slug>/` and are written by LLM
4
+ // agents (never the CLI). As the artifact formats evolve, old projects must be
5
+ // brought forward WITHOUT data loss. This framework is the mechanism:
6
+ //
7
+ // - every migration is ONE numbered file (`NNN-name.mjs`) next to this file;
8
+ // - `detect(ctx)` decides whether a project still needs it (must be cheap,
9
+ // read-only, and false once applied — migrations are idempotent);
10
+ // - `apply(ctx)` mutates, but ONLY after the framework has snapshotted every
11
+ // file the migration declared via its optional `files(ctx)` export into
12
+ // `.archgen/.backup/<timestamp>/migrate/`;
13
+ // - dry-run is the DEFAULT everywhere: nothing is written unless a caller
14
+ // explicitly passes `{ dryRun: false }`.
15
+ //
16
+ // Migration file contract (named exports; a default object is also accepted):
17
+ // export const id — '001-stamp-provenance' (MUST equal the filename stem)
18
+ // export const description — one line, doctor-tone
19
+ // export const fromSchema — source schema number or null (pre-schema files)
20
+ // export const toSchema — target schema number
21
+ // export function detect(ctx) -> boolean
22
+ // export function apply(ctx) -> void (mutates; framework backed up first)
23
+ // export function files(ctx) -> string[] of project-relative paths that
24
+ // apply() may touch (drives --check output
25
+ // AND the mandatory pre-apply backup set)
26
+ //
27
+ // ctx shape (built here, passed to detect/files/apply):
28
+ // ctx.projectRoot absolute root
29
+ // ctx.dryRun true unless explicitly applying
30
+ // ctx.scanArtifacts() -> [{ rel, kind }] for .archgen/*/tasks.yaml and
31
+ // .archgen/*/architecture.yaml (dot-dirs skipped)
32
+ // ctx.backup(rel) snapshot one project-relative file into
33
+ // .archgen/.backup/<ts>/migrate/<rel>; THROWS under
34
+ // dry-run so a mutating migration can never run silently
35
+
36
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
37
+ import { dirname, join, resolve } from 'node:path';
38
+ import { fileURLToPath, pathToFileURL } from 'node:url';
39
+ import { BACKUP_ROOT_REL } from '../store.js';
40
+
41
+ const MIGRATION_FILE_RE = /^(\d{3}-[a-z0-9][a-z0-9-]*)\.mjs$/;
42
+
43
+ /**
44
+ * Load every migration file, sorted by id (numeric prefix == order).
45
+ * Malformed migrations fail LOUDLY here — a silently-skipped migration would
46
+ * corrupt the "everything applied" invariant.
47
+ * @returns {Promise<Array<{id: string, description: string, fromSchema: number|null,
48
+ * toSchema: number, detect: Function, apply: Function, files?: Function}>>}
49
+ */
50
+ export async function listMigrations() {
51
+ const dir = dirname(fileURLToPath(import.meta.url));
52
+ const stems = readdirSync(dir)
53
+ .map((f) => MIGRATION_FILE_RE.exec(f)?.[1])
54
+ .filter(Boolean)
55
+ .sort();
56
+ const out = [];
57
+ for (const stem of stems) {
58
+ const mod = await import(pathToFileURL(join(dir, stem + '.mjs')).href);
59
+ out.push(normalize(mod.default ?? mod, stem));
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function normalize(m, stem) {
65
+ const fail = (why) => {
66
+ throw new Error(`migration ${stem}.mjs is malformed: ${why}`);
67
+ };
68
+ if (!m || typeof m !== 'object') fail('no exports');
69
+ if (m.id !== stem) fail(`id '${m.id}' must equal filename stem '${stem}'`);
70
+ if (typeof m.description !== 'string' || !m.description) fail('missing description');
71
+ if (!(m.fromSchema === null || typeof m.fromSchema === 'number')) fail('fromSchema must be a number or null');
72
+ if (typeof m.toSchema !== 'number') fail('toSchema must be a number');
73
+ if (typeof m.detect !== 'function') fail('missing detect(ctx)');
74
+ if (typeof m.apply !== 'function') fail('missing apply(ctx)');
75
+ if (m.files !== undefined && typeof m.files !== 'function') fail('files must be a function');
76
+ return m;
77
+ }
78
+
79
+ /** Build the migration context. Internal — exported for tests only. */
80
+ export function makeCtx(projectRoot, { dryRun }) {
81
+ const root = resolve(projectRoot);
82
+ // One timestamp per context: all backups from a single apply run group
83
+ // under one directory (colons stripped, same convention as store.moveToBackup).
84
+ const ts = new Date().toISOString().replace(/:/g, '-');
85
+ return {
86
+ projectRoot: root,
87
+ dryRun: dryRun === true,
88
+
89
+ /** Discover generated artifacts this framework knows how to migrate. */
90
+ scanArtifacts() {
91
+ const base = join(root, '.archgen');
92
+ if (!existsSync(base)) return [];
93
+ const out = [];
94
+ const slugs = readdirSync(base, { withFileTypes: true })
95
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
96
+ .map((e) => e.name)
97
+ .sort();
98
+ for (const slug of slugs) {
99
+ for (const f of ['tasks.yaml', 'architecture.yaml']) {
100
+ const abs = join(base, slug, f);
101
+ if (existsSync(abs) && statSync(abs).isFile()) {
102
+ out.push({ rel: `.archgen/${slug}/${f}`, kind: f.replace(/\.yaml$/, '') });
103
+ }
104
+ }
105
+ }
106
+ return out;
107
+ },
108
+
109
+ /**
110
+ * Snapshot a project-relative file into `.archgen/.backup/<ts>/migrate/`.
111
+ * Copy (not move): the original stays in place until apply() rewrites it.
112
+ * @param {string} relPath project-relative, e.g. `.archgen/demo/tasks.yaml`
113
+ * @returns {string} backup location relative to the project root
114
+ */
115
+ backup(relPath) {
116
+ if (this.dryRun) throw new Error(`backup('${relPath}') called during dry-run — migrations must never mutate under --check`);
117
+ const srcAbs = join(root, ...relPath.split('/'));
118
+ const destRel = `${BACKUP_ROOT_REL}/${ts}/migrate/${relPath}`;
119
+ const destAbs = join(root, ...destRel.split('/'));
120
+ mkdirSync(dirname(destAbs), { recursive: true });
121
+ copyFileSync(srcAbs, destAbs);
122
+ return destRel;
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Migrations not yet satisfied by `projectRoot`, in order. ALWAYS read-only.
129
+ * @returns {Promise<Array<{id: string, description: string, fromSchema: number|null,
130
+ * toSchema: number, files: string[]}>>}
131
+ */
132
+ export async function pendingMigrations(projectRoot) {
133
+ const pending = [];
134
+ for (const mig of await listMigrations()) {
135
+ const ctx = makeCtx(projectRoot, { dryRun: true });
136
+ if (!mig.detect(ctx)) continue;
137
+ pending.push({
138
+ id: mig.id,
139
+ description: mig.description,
140
+ fromSchema: mig.fromSchema,
141
+ toSchema: mig.toSchema,
142
+ files: mig.files ? mig.files(ctx).slice() : [],
143
+ });
144
+ }
145
+ return pending;
146
+ }
147
+
148
+ /**
149
+ * Run one migration against `projectRoot`.
150
+ * @param {string} id migration id (filename stem)
151
+ * @param {string} projectRoot
152
+ * @param {{dryRun?: boolean}} opts dryRun defaults to TRUE — pass
153
+ * `{ dryRun: false }` to mutate (every declared file is backed up first)
154
+ * @returns {Promise<{status: 'applied'|'would-apply'|'clean'|'not-found',
155
+ * id: string, description?: string, files?: string[],
156
+ * backups?: Array<{file: string, backup: string}>}>}
157
+ */
158
+ export async function applyMigration(id, projectRoot, opts = {}) {
159
+ const dryRun = opts.dryRun !== false; // dry-run default, always
160
+ const mig = (await listMigrations()).find((m) => m.id === id);
161
+ if (!mig) return { status: 'not-found', id };
162
+ const ctx = makeCtx(projectRoot, { dryRun });
163
+ if (!mig.detect(ctx)) return { status: 'clean', id };
164
+ const files = mig.files ? mig.files(ctx).slice() : [];
165
+ if (dryRun) return { status: 'would-apply', id, description: mig.description, files };
166
+ // Backup EVERYTHING the migration declared before it touches anything.
167
+ const backups = files.map((file) => ({ file, backup: ctx.backup(file) }));
168
+ await mig.apply(ctx);
169
+ return { status: 'applied', id, description: mig.description, files, backups };
170
+ }
package/lib/restore.js ADDED
@@ -0,0 +1,239 @@
1
+ // restore.js — recovery tooling for archgen backups.
2
+ //
3
+ // Backups live in two vaults:
4
+ // project: <projectRoot>/.archgen/.backup/<ts>/<relpath…> (written by init)
5
+ // global: <skills-dir>/.archgen-backups/<ts>/archgen (written by install --copy)
6
+ //
7
+ // restoreBackups() discovers both, lists snapshots deterministically, and
8
+ // restores a chosen snapshot by copying each entry back to its original path.
9
+ // Safety invariant: the CURRENT state of every about-to-be-overwritten path is
10
+ // moved into a fresh safety snapshot FIRST — a restore never destroys anything
11
+ // without a prior backup of it. Restores themselves land atomically
12
+ // (temp-sibling dir, then rename).
13
+
14
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
15
+ import { randomBytes } from 'node:crypto';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, join, relative, resolve } from 'node:path';
18
+ import { BACKUP_ROOT_REL, moveToBackupInto } from './store.js';
19
+ import { GLOBAL_BACKUP_REL, globalTargets } from './install.js';
20
+
21
+ function toPosix(p) {
22
+ return p.split('\\').join('/');
23
+ }
24
+
25
+ function byName(a, b) {
26
+ return a < b ? -1 : a > b ? 1 : 0;
27
+ }
28
+
29
+ function lstatSafe(p) {
30
+ try { return lstatSync(p); } catch { return null; }
31
+ }
32
+
33
+ function summarize(dir) {
34
+ let files = 0;
35
+ let dirs = 0;
36
+ (function walk(d) {
37
+ for (const e of readdirSync(d, { withFileTypes: true })) {
38
+ if (e.isDirectory()) { dirs++; walk(join(d, e.name)); } else { files++; }
39
+ }
40
+ })(dir);
41
+ const topLevel = readdirSync(dir).sort(byName);
42
+ return { files, dirs, topLevel };
43
+ }
44
+
45
+ function snapshotsUnder(origin, base, vaultAbs) {
46
+ if (!existsSync(vaultAbs)) return [];
47
+ return readdirSync(vaultAbs, { withFileTypes: true })
48
+ .filter((e) => e.isDirectory())
49
+ .map((e) => e.name)
50
+ .sort(byName)
51
+ .reverse()
52
+ .map((ts) => {
53
+ const snapshotDir = join(vaultAbs, ts);
54
+ let summary = null;
55
+ try { summary = summarize(snapshotDir); } catch { summary = { files: 0, dirs: 0, topLevel: [] }; }
56
+ return { id: ts, ts, origin, base, snapshotDir, summary };
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Discover backup snapshots in both vaults.
62
+ * @param {{projectRoot?: string|null, home?: string, includeGlobal?: boolean}} opts
63
+ * @returns {Array<{id: string, ts: string, origin: 'project'|'global', base: string,
64
+ * snapshotDir: string, summary: {files: number, dirs: number, topLevel: string[]}}>}
65
+ * Sorted newest-first, project before global (deterministic order).
66
+ */
67
+ export function discoverSnapshots(opts = {}) {
68
+ const home = opts.home ?? homedir();
69
+ const out = [];
70
+ if (opts.projectRoot) {
71
+ out.push(...snapshotsUnder('project', resolve(opts.projectRoot), join(resolve(opts.projectRoot), ...BACKUP_ROOT_REL.split('/'))));
72
+ }
73
+ if (opts.includeGlobal !== false) {
74
+ for (const tdir of globalTargets(home)) {
75
+ out.push(...snapshotsUnder('global', tdir, join(tdir, GLOBAL_BACKUP_REL)));
76
+ }
77
+ }
78
+ return out.sort((a, b) => (a.ts === b.ts ? byName(a.origin + a.base, b.origin + b.base) : a.ts < b.ts ? 1 : -1));
79
+ }
80
+
81
+ /**
82
+ * List backups under a project root and the global harness dirs.
83
+ * @param {string} projectRoot project root to scan (may be '' when unused)
84
+ * @param {{home?: string, includeGlobal?: boolean}} options
85
+ */
86
+ export function listBackups(projectRoot, options = {}) {
87
+ return discoverSnapshots({ projectRoot: projectRoot || null, home: options.home, includeGlobal: options.includeGlobal });
88
+ }
89
+
90
+ function uniqueSafetyTs(base, backupRootRel) {
91
+ for (;;) {
92
+ const ts = new Date().toISOString().replace(/:/g, '-');
93
+ if (!existsSync(join(base, ...backupRootRel.split('/'), ts))) return ts;
94
+ }
95
+ }
96
+
97
+ /** Atomic copy-back: temp sibling dir, then rename into place. */
98
+ function layDown(srcAbs, destAbs) {
99
+ mkdirSync(dirname(destAbs), { recursive: true });
100
+ const tmp = join(dirname(destAbs), '.archgen-restore-' + randomBytes(6).toString('hex'));
101
+ cpSync(srcAbs, tmp, { recursive: true });
102
+ renameSync(tmp, destAbs);
103
+ }
104
+
105
+ /**
106
+ * Restore backups discovered across the project and global vaults.
107
+ *
108
+ * @param {string} projectRoot project root to scan (used for listing; may be ''
109
+ * when only global snapshots are wanted)
110
+ * @param {{list?: boolean, snapshot?: string, target?: string, home?: string,
111
+ * includeGlobal?: boolean}} options
112
+ * - `list` (default true): dry-run discovery, returns `snapshots`.
113
+ * - `snapshot`: timestamp id to restore (takes precedence over `list`).
114
+ * Ambiguous ids (same ts in several vaults) must be narrowed via
115
+ * `includeGlobal:false` or an exact single match.
116
+ * - `target`: restore INTO this directory instead of the recorded origin
117
+ * (entries keep their layout relative to it).
118
+ * @returns {{action: 'list'|'restored', snapshots?: Array<object>,
119
+ * restored?: Array<{from: string, to: string}>,
120
+ * safetyBackups?: Array<{rel: string, abs: string}>, warnings: string[]}}
121
+ */
122
+ export function restoreBackups(projectRoot, options = {}) {
123
+ const warnings = [];
124
+ const all = discoverSnapshots({
125
+ projectRoot,
126
+ home: options.home,
127
+ includeGlobal: options.includeGlobal !== false,
128
+ });
129
+
130
+ if (!options.snapshot) {
131
+ return { action: 'list', snapshots: all, warnings };
132
+ }
133
+
134
+ const matches = all.filter((s) => s.ts === options.snapshot || s.id === options.snapshot);
135
+ if (matches.length === 0) {
136
+ throw new Error(`no backup snapshot '${options.snapshot}' found (run with list mode to see available ids)`);
137
+ }
138
+ if (matches.length > 1) {
139
+ throw new Error(`ambiguous snapshot '${options.snapshot}' exists in ${matches.length} vaults; narrow the scope`);
140
+ }
141
+ const snap = matches[0];
142
+
143
+ const destBase = options.target ? resolve(options.target) : snap.base;
144
+ const entries = readdirSync(snap.snapshotDir, { withFileTypes: true })
145
+ .map((e) => e.name)
146
+ .sort(byName)
147
+ .map((name) => ({ name, srcAbs: join(snap.snapshotDir, name), destAbs: join(destBase, name) }));
148
+
149
+ // Pass 1 — safety: move EVERY existing destination aside before laying down
150
+ // anything, so a mid-restore failure still leaves current state fully backed up.
151
+ const safetyBackups = [];
152
+ const safetyRootRel = snap.origin === 'global' ? GLOBAL_BACKUP_REL : BACKUP_ROOT_REL;
153
+ const safetyBase = snap.origin === 'global' ? snap.base : destBase;
154
+ const safetyTs = uniqueSafetyTs(safetyBase, safetyRootRel);
155
+ for (const en of entries) {
156
+ if (!lstatSafe(en.destAbs)) continue;
157
+ const rel = snap.origin === 'global' ? en.name : toPosix(relative(destBase, en.destAbs));
158
+ const loc = moveToBackupInto(safetyBase, rel, safetyRootRel, safetyTs);
159
+ const abs = join(safetyBase, ...loc.split('/'));
160
+ safetyBackups.push({ rel: loc, abs });
161
+ }
162
+
163
+ // Pass 2 — copy back atomically.
164
+ const restored = [];
165
+ for (const en of entries) {
166
+ try {
167
+ layDown(en.srcAbs, en.destAbs);
168
+ restored.push({ from: en.srcAbs, to: en.destAbs });
169
+ } catch (e) {
170
+ warnings.push(`could not restore ${en.name}: ${e.message}`);
171
+ }
172
+ }
173
+
174
+ return { action: 'restored', snapshot: snap, restored, safetyBackups, warnings };
175
+ }
176
+
177
+ function parseFlags(argv) {
178
+ const opts = { list: true, snapshot: null, project: null, global: false, yes: false };
179
+ for (let i = 0; i < argv.length; i++) {
180
+ const a = argv[i];
181
+ if (a === '--list') opts.list = true;
182
+ else if (a === '--snapshot') {
183
+ const v = argv[++i];
184
+ if (!v || v.startsWith('--')) return { error: '--snapshot requires a timestamp argument' };
185
+ opts.snapshot = v;
186
+ } else if (a === '--project') {
187
+ const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : process.cwd();
188
+ opts.project = v;
189
+ } else if (a === '--global') opts.global = true;
190
+ else if (a === '--yes') opts.yes = true; // accepted for CLI ergonomics; restore is interactive-free
191
+ else return { error: `unknown flag: ${a}` };
192
+ }
193
+ return opts;
194
+ }
195
+
196
+ /**
197
+ * CLI entry point (`archgen-skill restore …`). Interactive-free and
198
+ * deterministic; never prompts. Exit codes: 0 success/listing, 1 on errors.
199
+ * @param {string[]} argv flags after the command name
200
+ * @returns {Promise<number>} exit code
201
+ */
202
+ export async function restoreMain(argv) {
203
+ const parsed = parseFlags(argv);
204
+ if (parsed.error) {
205
+ console.error('archgen restore: ' + parsed.error);
206
+ return 1;
207
+ }
208
+ const { list, snapshot, project, global: wantGlobal, yes } = parsed;
209
+ void yes;
210
+
211
+ const scope = {
212
+ projectRoot: wantGlobal ? null : (project ?? process.cwd()),
213
+ includeGlobal: !project || wantGlobal,
214
+ };
215
+
216
+ try {
217
+ if (!snapshot) {
218
+ const snapshots = listBackups(scope.projectRoot ?? '', { includeGlobal: scope.includeGlobal });
219
+ console.log(`archgen restore — ${snapshots.length} backup snapshot(s) found`);
220
+ for (const s of snapshots) {
221
+ const names = s.summary.topLevel.length ? s.summary.topLevel.join(', ') : '(empty)';
222
+ console.log(` ${s.ts} [${s.origin}] ${s.base} (${s.summary.files} file(s), ${s.summary.dirs} dir(s): ${names})`);
223
+ }
224
+ console.log('Dry-run listing only. Pass --snapshot <ts> to restore.');
225
+ return 0;
226
+ }
227
+
228
+ const r = restoreBackups(scope.projectRoot ?? '', { snapshot, includeGlobal: scope.includeGlobal });
229
+ console.log(`archgen restore — restoring ${snapshot} [${r.snapshot.origin}]`);
230
+ for (const sb of r.safetyBackups) console.log(` ~ safety backup of current state: ${sb.abs}`);
231
+ for (const en of r.restored) console.log(` + restored ${en.to}`);
232
+ for (const w of r.warnings) console.error(' ! ' + w);
233
+ console.log(`Done: ${r.restored.length} path(s) restored, ${r.safetyBackups.length} safety backup(s).`);
234
+ return r.warnings.length > 0 ? 1 : 0;
235
+ } catch (e) {
236
+ console.error('archgen restore: ' + (e instanceof Error ? e.message : String(e)));
237
+ return 1;
238
+ }
239
+ }