archgen-skill 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +10 -0
  2. package/bin/archgen.mjs +23 -0
  3. package/lib/block.js +94 -9
  4. package/lib/config.js +96 -0
  5. package/lib/doctor.js +123 -26
  6. package/lib/init.js +3 -2
  7. package/lib/install.js +105 -14
  8. package/lib/migrate.js +88 -0
  9. package/lib/migrations/001-stamp-provenance.mjs +77 -0
  10. package/lib/migrations/index.mjs +170 -0
  11. package/lib/restore.js +239 -0
  12. package/lib/store.js +27 -12
  13. package/lib/uninstall-project.js +6 -3
  14. package/lib/version-stamp.js +47 -0
  15. package/package.json +37 -9
  16. package/vendor/skills/archgen/SKILL.md +242 -74
  17. package/vendor/skills/archgen/archgen.config.json +16 -0
  18. package/vendor/skills/archgen/assets/skill-registry.json +4 -3
  19. package/vendor/skills/archgen/references/artifact-templates.md +251 -7
  20. package/vendor/skills/archgen/references/interview.md +180 -16
  21. package/vendor/skills/archgen/references/mcp-and-skills.md +88 -17
  22. package/vendor/skills/archgen/references/orchestration.md +102 -39
  23. package/vendor/skills/archgen/references/platforms.md +1 -14
  24. package/vendor/skills/archgen/scripts/doc-index.mjs +457 -0
  25. package/vendor/skills/archgen/scripts/impact.mjs +14 -1
  26. package/vendor/skills/archgen/scripts/lib/graph.mjs +82 -3
  27. package/vendor/skills/archgen/scripts/lib/scope.mjs +65 -0
  28. package/vendor/skills/archgen/scripts/lib/yaml.mjs +19 -3
  29. package/vendor/skills/archgen/scripts/plan-graph.mjs +329 -0
  30. package/vendor/skills/archgen/scripts/set-status.mjs +26 -6
  31. package/vendor/skills/archgen/scripts/update-agents.mjs +4 -2
  32. package/vendor/skills/archgen/scripts/validate.mjs +18 -1
  33. package/vendor/skills/archgen/scripts/verify-plan.mjs +8 -3
package/lib/install.js CHANGED
@@ -2,19 +2,31 @@
2
2
  // Installs the archgen skill into every EXISTING harness skills dir; manifest
3
3
  // records entries so --uninstall removes exactly what we put there.
4
4
 
5
- import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, lstatSync } from 'node:fs';
5
+ import { cpSync, existsSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, lstatSync, unlinkSync } from 'node:fs';
6
6
  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';
11
+ import { writeStamp } from './version-stamp.js';
12
+ import { loadConfig } from './config.js';
10
13
 
11
14
  // Module-anchored default: works from the npm package (cli/lib -> cli/vendor)
12
15
  // AND from a monorepo checkout (packages/cli/lib -> ../../skill) without depending on cwd.
13
16
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
14
17
 
15
- const MANIFEST_NAME = '.archgen-install-manifest.list';
18
+ export const MANIFEST_NAME = loadConfig().files.globalManifest;
16
19
 
17
- function globalTargets(home = homedir()) {
20
+ // Global backup vault: global installs live OUTSIDE any project, so there is
21
+ // no project root to host `.archgen/.backup/`. Convention chosen instead: each
22
+ // harness skills dir keeps its own sibling vault at
23
+ // <skills-dir>/.archgen-backups/<ISO-timestamp>/archgen/
24
+ // — a dot-prefixed sibling (never scanned as a skill by harnesses), colocated
25
+ // with what it protects, and discovered by lib/restore.js alongside the
26
+ // project vault.
27
+ export const GLOBAL_BACKUP_REL = '.archgen-backups';
28
+
29
+ export function globalTargets(home = homedir()) {
18
30
  return [
19
31
  join(home, '.claude', 'skills'),
20
32
  join(home, '.agents', 'skills'),
@@ -32,15 +44,33 @@ function record(manifest, kind, path) {
32
44
  if (!dup) writeFileSync(manifest, (existsSync(manifest) ? readFileSync(manifest, 'utf8').replace(/\n*$/, '\n') : '') + line + '\n');
33
45
  }
34
46
 
47
+ function lstatSafe(p) {
48
+ try { return lstatSync(p); } catch { return null; }
49
+ }
50
+
51
+ /** Collision-free timestamp for `<tdir>/.archgen-backups/<ts>/` (ms clock can tick twice). */
52
+ function nextBackupTs(tdir) {
53
+ for (;;) {
54
+ const ts = new Date().toISOString().replace(/:/g, '-');
55
+ if (!existsSync(join(tdir, GLOBAL_BACKUP_REL, ts))) return ts;
56
+ }
57
+ }
58
+
35
59
  /**
36
- * @param {{copy?: boolean, home?: string}} opts
60
+ * @param {{copy?: boolean, home?: string, packageRoot?: string}} opts
61
+ * @returns {{rows: Array<[string, string, string]>, failures: number, manifest: string,
62
+ * backups: string[]}} rows are [STATUS, MODE, PATH]; `backups` lists
63
+ * absolute vault locations written by this run.
37
64
  */
38
65
  export function installGlobal(opts = {}) {
39
66
  const home = opts.home ?? homedir();
40
67
  if (!home) throw new Error('$HOME is not set; cannot resolve target directories.');
41
- const source = resolve(resolveSkillSource(opts.packageRoot ?? PACKAGE_ROOT));
68
+ const packageRoot = opts.packageRoot ?? PACKAGE_ROOT;
69
+ const source = resolve(resolveSkillSource(packageRoot));
70
+ const version = cliVersion(packageRoot);
42
71
  const manifest = join(home, MANIFEST_NAME);
43
72
  const rows = [];
73
+ const backups = [];
44
74
  let failures = 0;
45
75
 
46
76
  const targets = [...globalTargets(home), join(process.cwd(), '.github', 'skills')];
@@ -50,23 +80,84 @@ export function installGlobal(opts = {}) {
50
80
  try {
51
81
  mkdirSync(tdir, { recursive: true }); // no-op when present
52
82
  if (opts.copy) {
53
- rmSync(dest, { force: true, recursive: true });
54
- cpSync(source, dest, { recursive: true });
55
- record(manifest, 'copy', dest);
56
- rows.push(['OK', 'copy', dest]);
83
+ // Safety invariant: fingerprint dest BEFORE mutating; identical
84
+ // (stamp-ignoring) skips like link mode, divergent moves into the
85
+ // vault first — nothing is rmSync'd without a prior backup of it.
86
+ const prev = lstatSafe(dest);
87
+ let identical = false;
88
+ if (prev && prev.isDirectory() && !prev.isSymbolicLink()) {
89
+ try { identical = hashDir(dest) === hashDir(source); } catch { identical = false; }
90
+ }
91
+ if (identical) {
92
+ stampNote(rows, dest, () => writeStamp(dest, version));
93
+ record(manifest, 'copy', dest);
94
+ rows.push(['SAME', 'copy', dest]);
95
+ } else {
96
+ if (prev) {
97
+ const loc = moveToBackupInto(tdir, 'archgen', GLOBAL_BACKUP_REL, nextBackupTs(tdir));
98
+ const abs = join(tdir, ...loc.split('/'));
99
+ backups.push(abs);
100
+ rows.push(['BACKED-UP', '-', abs]);
101
+ }
102
+ rmSync(dest, { force: true, recursive: true }); // no-op after a move; guards exotic-FS fallbacks
103
+ cpSync(source, dest, { recursive: true });
104
+ stampNote(rows, dest, () => writeStamp(dest, version));
105
+ record(manifest, 'copy', dest);
106
+ rows.push(['OK', 'copy', dest]);
107
+ }
57
108
  } else {
58
- let same = false;
59
- try { same = lstatSync(dest).isSymbolicLink(); } catch { /* absent */ }
60
- if (!same) symlinkSync(source, dest, 'dir');
109
+ let status = 'OK';
110
+ const prev = lstatSafe(dest);
111
+ if (prev && prev.isSymbolicLink()) {
112
+ if (existsSync(dest)) {
113
+ // A live link resolving anywhere but our source belongs to the
114
+ // user: leave it untouched and OUT of the uninstall manifest, or
115
+ // `uninstall` would later delete their link. (install.sh replaces
116
+ // foreign links via ln -sfn — that destructive semantic is
117
+ // deliberately NOT copied here.)
118
+ const linkTarget = resolve(dirname(dest), readlinkSync(dest));
119
+ if (linkTarget !== source) {
120
+ rows.push(['KEPT', '-', dest + ' (foreign symlink, not recorded)']);
121
+ continue;
122
+ }
123
+ status = 'SAME';
124
+ } else {
125
+ // Dangling (npx cache eviction): recreate against the current
126
+ // source instead of leaving users broken while reporting SAME.
127
+ unlinkSync(dest); // removes the link itself, never any target
128
+ symlinkSync(source, dest, 'dir');
129
+ status = 'REPAIRED';
130
+ }
131
+ } else if (!prev) {
132
+ symlinkSync(source, dest, 'dir');
133
+ } else {
134
+ // Real directory/file at dest is NEVER clobbered: symlinkSync fails
135
+ // with EEXIST and surfaces as FAILED below (historical behavior).
136
+ symlinkSync(source, dest, 'dir');
137
+ }
138
+ // The link itself holds no bytes: the stamp must live INSIDE the real
139
+ // directory the link resolves to (the shared copy) so a read of
140
+ // <link>/.archgen-version sees it — true for symlinks and Windows
141
+ // junctions alike.
142
+ stampNote(rows, dest, () => writeStamp(source, version));
61
143
  record(manifest, 'link', dest);
62
- rows.push([same ? 'SAME' : 'OK', 'link', dest]);
144
+ rows.push([status, 'link', dest]);
63
145
  }
64
146
  } catch (e) {
65
147
  failures++;
66
148
  rows.push(['FAILED', '-', dest + ' (' + e.message + ')']);
67
149
  }
68
150
  }
69
- return { rows, failures, manifest };
151
+ return { rows, failures, manifest, backups };
152
+ }
153
+
154
+ /** Install succeeded even when the stamp could not be written (e.g. read-only vendor); surface it without failing. */
155
+ function stampNote(rows, dest, write) {
156
+ try {
157
+ write();
158
+ } catch (e) {
159
+ rows.push(['WARN', '-', dest + ' (installed; version stamp not written: ' + e.message + ')']);
160
+ }
70
161
  }
71
162
 
72
163
  export function uninstallGlobal(home = homedir()) {
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
+ }