agent-skillboard 0.2.18 → 0.3.1

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 (82) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +161 -255
  3. package/bin/postinstall.mjs +2 -1
  4. package/docs/adapters.md +37 -113
  5. package/docs/ai-skill-routing-goal.md +41 -108
  6. package/docs/capabilities.md +17 -104
  7. package/docs/install.md +70 -473
  8. package/docs/policy-model.md +50 -280
  9. package/docs/positioning.md +19 -86
  10. package/docs/profiles.md +21 -153
  11. package/docs/reference.md +133 -362
  12. package/docs/rollout-runbook.md +23 -25
  13. package/docs/routing.md +23 -90
  14. package/docs/user-flow.md +68 -279
  15. package/docs/value-proof.md +23 -181
  16. package/docs/variant-lifecycle.md +47 -67
  17. package/docs/versioning.md +49 -269
  18. package/examples/v2-multi-source.config.yaml +35 -0
  19. package/examples/v2-policy-error.config.yaml +6 -0
  20. package/package.json +1 -1
  21. package/src/advisor/actions.mjs +102 -6
  22. package/src/advisor/application-commands.mjs +10 -9
  23. package/src/advisor/apply-action.mjs +74 -1
  24. package/src/advisor/guidance.mjs +24 -16
  25. package/src/advisor/schema.mjs +17 -6
  26. package/src/advisor/skills.mjs +18 -5
  27. package/src/advisor.mjs +27 -9
  28. package/src/agent-integration-cli.mjs +96 -13
  29. package/src/agent-integration-content.mjs +21 -11
  30. package/src/agent-integration-files.mjs +1 -1
  31. package/src/agent-integration-home.mjs +14 -1
  32. package/src/agent-inventory-platforms.mjs +21 -8
  33. package/src/agent-inventory.mjs +44 -16
  34. package/src/agent-root-registry.mjs +127 -0
  35. package/src/agent-skill-import.mjs +2 -2
  36. package/src/agent-skill-roots.mjs +70 -13
  37. package/src/audit-paths.mjs +42 -0
  38. package/src/brief-cli.mjs +3 -2
  39. package/src/brief-renderer.mjs +1 -0
  40. package/src/cli.mjs +521 -235
  41. package/src/compatibility.mjs +24 -0
  42. package/src/control/can-use-guard.mjs +21 -1
  43. package/src/control/config-write.mjs +32 -2
  44. package/src/control/skill-crud.mjs +5 -0
  45. package/src/control/v2-guard.mjs +175 -0
  46. package/src/control/v2-skill-crud.mjs +32 -0
  47. package/src/control/v2-skill-forget.mjs +38 -0
  48. package/src/control/variant-status.mjs +47 -1
  49. package/src/control.mjs +55 -0
  50. package/src/doctor.mjs +71 -5
  51. package/src/domain/v2-policy.mjs +111 -0
  52. package/src/hook-plan.mjs +33 -3
  53. package/src/impact.mjs +52 -29
  54. package/src/index.mjs +25 -1
  55. package/src/init.mjs +50 -34
  56. package/src/install-health.mjs +177 -0
  57. package/src/inventory-install-units.mjs +63 -0
  58. package/src/inventory-json.mjs +279 -0
  59. package/src/inventory-refresh.mjs +168 -19
  60. package/src/lifecycle-cli.mjs +40 -12
  61. package/src/lifecycle-content.mjs +52 -67
  62. package/src/migration/v1-to-v2.mjs +212 -0
  63. package/src/migration/v2-files.mjs +211 -0
  64. package/src/migration/v2-journal.mjs +169 -0
  65. package/src/migration/v2-projection.mjs +108 -0
  66. package/src/migration/v2-transaction.mjs +205 -0
  67. package/src/policy.mjs +3 -0
  68. package/src/reconcile.mjs +139 -111
  69. package/src/report.mjs +168 -148
  70. package/src/review.mjs +2 -0
  71. package/src/route-advisory.mjs +47 -2
  72. package/src/route-selection.mjs +38 -2
  73. package/src/route.mjs +62 -2
  74. package/src/shared-skill-reconcile.mjs +97 -0
  75. package/src/shared-skill.mjs +325 -0
  76. package/src/source-digest.mjs +42 -0
  77. package/src/source-profiles.mjs +171 -144
  78. package/src/source-verification.mjs +32 -48
  79. package/src/uninstall.mjs +22 -0
  80. package/src/user-state-paths.mjs +19 -0
  81. package/src/user-uninstall.mjs +161 -0
  82. package/src/workspace.mjs +119 -79
@@ -0,0 +1,211 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmod, lstat, mkdir, open, readFile, realpath, rename, rm, stat } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+
5
+ export async function createBackup(configPath, configBytes, inventoryPath, inventoryBytes, label = "v1") {
6
+ const stamp = new Date().toISOString().split(":").join("-");
7
+ const unique = randomUUID().slice(0, 8);
8
+ const configBackupPath = `${configPath}.${label}-${stamp}-${unique}.bak`;
9
+ const inventoryBackupPath = inventoryBytes === null ? null : `${configBackupPath}.inventory`;
10
+ const manifestPath = `${configBackupPath}.manifest.json`;
11
+ const configMode = await fileMode(configPath);
12
+ const inventoryMode = inventoryBytes === null ? null : await fileMode(inventoryPath);
13
+ await durableCreate(configBackupPath, configBytes);
14
+ try {
15
+ if (inventoryBytes !== null) await durableCreate(inventoryBackupPath, inventoryBytes);
16
+ const manifest = {
17
+ format_version: 1,
18
+ source_version: 1,
19
+ target_version: 2,
20
+ policy_projection_version: 2,
21
+ invalidates: ["v1-action-id", "v1-guard-hook", "v1-lock-projection"],
22
+ target_config: basename(configPath),
23
+ target_inventory: relativeInventoryPath(configPath, inventoryPath),
24
+ config_backup: basename(configBackupPath),
25
+ inventory_backup: inventoryBackupPath === null ? null : basename(inventoryBackupPath),
26
+ inventory_existed: inventoryBytes !== null,
27
+ config_mode: configMode,
28
+ inventory_mode: inventoryMode,
29
+ input_sha256: sha256(configBytes),
30
+ inventory_input_sha256: inventoryBytes === null ? null : sha256(inventoryBytes)
31
+ };
32
+ await durableCreate(manifestPath, Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`));
33
+ return {
34
+ configBackupPath,
35
+ inventoryBackupPath,
36
+ inventoryExisted: inventoryBytes !== null,
37
+ configMode,
38
+ inventoryMode,
39
+ manifestPath
40
+ };
41
+ } catch (error) {
42
+ await rm(configBackupPath, { force: true });
43
+ if (inventoryBackupPath !== null) await rm(inventoryBackupPath, { force: true });
44
+ await rm(manifestPath, { force: true });
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ export async function restoreSnapshot(configPath, inventoryPath, snapshot, hooks = {}) {
50
+ await atomicWrite(configPath, await readFile(snapshot.configBackupPath), snapshot.configMode);
51
+ await hooks.afterConfigRename?.();
52
+ if (snapshot.inventoryExisted) {
53
+ await atomicWrite(inventoryPath, await readFile(snapshot.inventoryBackupPath), snapshot.inventoryMode);
54
+ } else {
55
+ await rm(inventoryPath, { force: true });
56
+ await syncDirectory(dirname(inventoryPath));
57
+ }
58
+ await hooks.afterInventoryRename?.();
59
+ }
60
+
61
+ export async function removeBackupSet(backup) {
62
+ await rm(backup.configBackupPath, { force: true });
63
+ if (backup.inventoryBackupPath !== null) await rm(backup.inventoryBackupPath, { force: true });
64
+ await rm(backup.manifestPath, { force: true });
65
+ }
66
+
67
+ export async function atomicWrite(path, bytes, mode = 0o600) {
68
+ await mkdir(dirname(path), { recursive: true });
69
+ const tempPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
70
+ try {
71
+ await durableCreate(tempPath, bytes, mode);
72
+ await rename(tempPath, path);
73
+ await syncDirectory(dirname(path));
74
+ } finally {
75
+ await rm(tempPath, { force: true });
76
+ }
77
+ }
78
+
79
+ export async function optionalRead(path) {
80
+ try {
81
+ return await readFile(path);
82
+ } catch (error) {
83
+ if (error?.code === "ENOENT") return null;
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ export async function removeDurably(path) {
89
+ await rm(path, { force: true });
90
+ await syncDirectory(dirname(path));
91
+ }
92
+
93
+ export async function withConfigLock(configPath, operation) {
94
+ const canonicalPath = await realpath(resolve(configPath));
95
+ const lockPath = `${canonicalPath}.migrate.lock`;
96
+ let handle;
97
+ try {
98
+ handle = await open(lockPath, "wx", 0o600);
99
+ } catch (error) {
100
+ if (error?.code === "EEXIST") {
101
+ throw new Error("Another migration is already using this config. Remove a stale .migrate.lock only after confirming no migration is running.");
102
+ }
103
+ throw error;
104
+ }
105
+ try {
106
+ await handle.writeFile(`${process.pid}\n`);
107
+ await handle.sync();
108
+ return await operation();
109
+ } finally {
110
+ await handle.close();
111
+ await rm(lockPath, { force: true });
112
+ }
113
+ }
114
+
115
+ export async function canonicalMigrationPaths(requestedConfigPath, requestedInventoryPath) {
116
+ const requested = resolve(requestedConfigPath);
117
+ const stats = await lstat(requested);
118
+ if (stats.isSymbolicLink()) {
119
+ throw new Error("Migration config path must not be a symbolic link; use its real path explicitly.");
120
+ }
121
+ if (!stats.isFile()) throw new Error("Migration config path must be a regular file.");
122
+ const configPath = await realpath(requested);
123
+ const root = dirname(configPath);
124
+ const requestedInventory = resolve(requestedInventoryPath ?? join(root, ".skillboard", "inventory.json"));
125
+ const inventoryStats = await lstat(requestedInventory).catch(missingOnly);
126
+ if (inventoryStats?.isSymbolicLink()) {
127
+ throw new Error("Migration inventory target must not be a symbolic link.");
128
+ }
129
+ const inventoryDirectory = dirname(requestedInventory);
130
+ const directoryStats = await lstat(inventoryDirectory).catch(missingOnly);
131
+ if (directoryStats?.isSymbolicLink()) {
132
+ throw new Error("Migration inventory directory must not be a symbolic link.");
133
+ }
134
+ const existingParent = await nearestExistingDirectory(inventoryDirectory);
135
+ const realParent = await realpath(existingParent);
136
+ const inventoryPath = resolve(realParent, relative(existingParent, requestedInventory));
137
+ if (!isPathInside(root, realParent)) {
138
+ throw new Error("Migration inventory target resolves outside the config directory.");
139
+ }
140
+ if (!isPathInside(root, inventoryPath)) {
141
+ throw new Error("Migration inventory target must remain inside the config directory.");
142
+ }
143
+ return { configPath, inventoryPath };
144
+ }
145
+
146
+ export function sha256(bytes) {
147
+ return createHash("sha256").update(bytes).digest("hex");
148
+ }
149
+
150
+ export async function durableCreate(path, bytes, mode = 0o600) {
151
+ await mkdir(dirname(path), { recursive: true });
152
+ const handle = await open(path, "wx", mode);
153
+ try {
154
+ await handle.writeFile(bytes);
155
+ await chmod(path, mode);
156
+ await handle.sync();
157
+ } finally {
158
+ await handle.close();
159
+ }
160
+ }
161
+
162
+ export async function syncDirectory(path) {
163
+ if (process.platform === "win32") return;
164
+ let handle;
165
+ try {
166
+ handle = await open(path, "r");
167
+ } catch (error) {
168
+ if (error?.code === "ENOENT") return;
169
+ throw error;
170
+ }
171
+ try {
172
+ await handle.sync();
173
+ } finally {
174
+ await handle.close();
175
+ }
176
+ }
177
+
178
+ export function relativeInventoryPath(configPath, inventoryPath) {
179
+ const prefix = `${dirname(configPath)}/`;
180
+ return inventoryPath.startsWith(prefix) ? inventoryPath.slice(prefix.length) : basename(inventoryPath);
181
+ }
182
+
183
+ async function fileMode(path) {
184
+ return (await stat(path)).mode & 0o777;
185
+ }
186
+
187
+ async function nearestExistingDirectory(start) {
188
+ let candidate = start;
189
+ while (true) {
190
+ const stats = await lstat(candidate).catch(missingOnly);
191
+ if (stats !== undefined) {
192
+ if (!stats.isDirectory() && !stats.isSymbolicLink()) {
193
+ throw new Error("Migration inventory parent must be a directory.");
194
+ }
195
+ return candidate;
196
+ }
197
+ const parent = dirname(candidate);
198
+ if (parent === candidate) throw new Error("Migration inventory parent does not exist.");
199
+ candidate = parent;
200
+ }
201
+ }
202
+
203
+ function missingOnly(error) {
204
+ if (error?.code === "ENOENT") return undefined;
205
+ throw error;
206
+ }
207
+
208
+ function isPathInside(root, candidate) {
209
+ const rel = relative(root, candidate);
210
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
211
+ }
@@ -0,0 +1,169 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import {
4
+ atomicWrite,
5
+ optionalRead,
6
+ relativeInventoryPath,
7
+ removeBackupSet,
8
+ removeDurably,
9
+ restoreSnapshot,
10
+ sha256
11
+ } from "./v2-files.mjs";
12
+
13
+ const JOURNAL_FORMAT_VERSION = 1;
14
+
15
+ export async function beginMigrationTransaction(configPath, inventoryPath, backup) {
16
+ const journalPath = journalPathFor(configPath);
17
+ const journal = {
18
+ format_version: JOURNAL_FORMAT_VERSION,
19
+ pid: process.pid,
20
+ phase: "prepared",
21
+ target_config: basename(configPath),
22
+ target_inventory: relativeInventoryPath(configPath, inventoryPath),
23
+ config_backup: basename(backup.configBackupPath),
24
+ inventory_backup: backup.inventoryBackupPath === null ? null : basename(backup.inventoryBackupPath),
25
+ inventory_existed: backup.inventoryExisted,
26
+ config_mode: backup.configMode,
27
+ inventory_mode: backup.inventoryMode,
28
+ input_sha256: sha256(await readFile(backup.configBackupPath)),
29
+ inventory_input_sha256: backup.inventoryBackupPath === null
30
+ ? null
31
+ : sha256(await readFile(backup.inventoryBackupPath))
32
+ };
33
+ await atomicWrite(journalPath, Buffer.from(`${JSON.stringify(journal, null, 2)}\n`), 0o600);
34
+ return journalPath;
35
+ }
36
+
37
+ export async function commitMigrationTransaction(journalPath) {
38
+ const journal = parseJournal(await readFile(journalPath, "utf8"));
39
+ await atomicWrite(
40
+ journalPath,
41
+ Buffer.from(`${JSON.stringify({ ...journal, phase: "committed" }, null, 2)}\n`),
42
+ 0o600
43
+ );
44
+ }
45
+
46
+ export async function finishMigrationTransaction(journalPath) {
47
+ await removeDurably(journalPath);
48
+ }
49
+
50
+ export async function completeMigrationRecovery(journalPath, backup) {
51
+ const journal = parseJournal(await readFile(journalPath, "utf8"));
52
+ await writePhase(journalPath, journal, "recovered");
53
+ await removeBackupSet(backup);
54
+ await removeDurably(journalPath);
55
+ }
56
+
57
+ export async function recoverInterruptedMigration(configPath, inventoryPath, failpoint) {
58
+ const journalPath = journalPathFor(configPath);
59
+ const bytes = await optionalRead(journalPath);
60
+ if (bytes === null) return false;
61
+ const journal = parseJournal(bytes.toString("utf8"));
62
+ validateTargets(journal, configPath, inventoryPath);
63
+ if (processIsAlive(journal.pid)) {
64
+ throw new Error("Another migration is already using this config.");
65
+ }
66
+ const backup = backupFromJournal(journal, configPath);
67
+ if (journal.phase === "prepared") {
68
+ await validateBackupHashes(backup, journal);
69
+ await restoreSnapshot(configPath, inventoryPath, backup, {
70
+ afterConfigRename: () => terminateRecoveryAt(failpoint, "after-config-rename"),
71
+ afterInventoryRename: () => terminateRecoveryAt(failpoint, "after-inventory-rename")
72
+ });
73
+ await completeMigrationRecovery(journalPath, backup);
74
+ } else if (journal.phase === "recovered") {
75
+ await removeBackupSet(backup);
76
+ await removeDurably(journalPath);
77
+ } else {
78
+ await removeDurably(journalPath);
79
+ }
80
+ await removeDurably(`${configPath}.migrate.lock`);
81
+ return true;
82
+ }
83
+
84
+ export function journalPathFor(configPath) {
85
+ return `${configPath}.migrate.transaction.json`;
86
+ }
87
+
88
+ function parseJournal(text) {
89
+ const value = JSON.parse(text);
90
+ if (value?.format_version !== JOURNAL_FORMAT_VERSION
91
+ || !Number.isInteger(value.pid)
92
+ || value.pid <= 0
93
+ || !["prepared", "committed", "recovered"].includes(value.phase)) {
94
+ throw new Error("Invalid migration transaction journal; recovery was not attempted.");
95
+ }
96
+ return value;
97
+ }
98
+
99
+ async function writePhase(journalPath, journal, phase) {
100
+ await atomicWrite(
101
+ journalPath,
102
+ Buffer.from(`${JSON.stringify({ ...journal, phase }, null, 2)}\n`),
103
+ 0o600
104
+ );
105
+ }
106
+
107
+ function terminateRecoveryAt(failpoint, point) {
108
+ if (failpoint === `terminate-recovery-${point}`) process.exit(87);
109
+ }
110
+
111
+ function validateTargets(journal, configPath, inventoryPath) {
112
+ if (journal.target_config !== basename(configPath)
113
+ || journal.target_inventory !== relativeInventoryPath(configPath, inventoryPath)) {
114
+ throw new Error("Migration transaction target mismatch; recovery was not attempted.");
115
+ }
116
+ if (typeof journal.config_backup !== "string"
117
+ || basename(journal.config_backup) !== journal.config_backup
118
+ || !journal.config_backup.startsWith(`${basename(configPath)}.`)) {
119
+ throw new Error("Invalid migration transaction backup reference; recovery was not attempted.");
120
+ }
121
+ const validInventoryBackup = journal.inventory_existed === false && journal.inventory_backup === null
122
+ || journal.inventory_existed === true
123
+ && typeof journal.inventory_backup === "string"
124
+ && basename(journal.inventory_backup) === journal.inventory_backup;
125
+ const validModes = Number.isInteger(journal.config_mode)
126
+ && journal.config_mode >= 0
127
+ && journal.config_mode <= 0o777
128
+ && (journal.inventory_existed === false
129
+ ? journal.inventory_mode === null
130
+ : Number.isInteger(journal.inventory_mode)
131
+ && journal.inventory_mode >= 0
132
+ && journal.inventory_mode <= 0o777);
133
+ if (!validInventoryBackup || !validModes) {
134
+ throw new Error("Invalid migration transaction backup metadata; recovery was not attempted.");
135
+ }
136
+ }
137
+
138
+ function backupFromJournal(journal, configPath) {
139
+ const directory = dirname(configPath);
140
+ return {
141
+ configBackupPath: join(directory, journal.config_backup),
142
+ inventoryBackupPath: journal.inventory_backup === null ? null : join(directory, journal.inventory_backup),
143
+ inventoryExisted: journal.inventory_existed,
144
+ configMode: journal.config_mode,
145
+ inventoryMode: journal.inventory_mode,
146
+ manifestPath: `${join(directory, journal.config_backup)}.manifest.json`
147
+ };
148
+ }
149
+
150
+ async function validateBackupHashes(backup, journal) {
151
+ if (sha256(await readFile(backup.configBackupPath)) !== journal.input_sha256) {
152
+ throw new Error("Migration transaction backup hash mismatch; recovery was not attempted.");
153
+ }
154
+ if (backup.inventoryExisted
155
+ && sha256(await readFile(backup.inventoryBackupPath)) !== journal.inventory_input_sha256) {
156
+ throw new Error("Migration transaction inventory hash mismatch; recovery was not attempted.");
157
+ }
158
+ }
159
+
160
+ function processIsAlive(pid) {
161
+ try {
162
+ process.kill(pid, 0);
163
+ return true;
164
+ } catch (error) {
165
+ if (error?.code === "ESRCH") return false;
166
+ if (error?.code === "EPERM") return true;
167
+ throw error;
168
+ }
169
+ }
@@ -0,0 +1,108 @@
1
+ import YAML from "yaml";
2
+ import { sha256 } from "./v2-files.mjs";
3
+
4
+ const TARGET_VERSION = 2;
5
+ const INVENTORY_FORMAT_VERSION = 1;
6
+
7
+ export function parseMigrationConfig(text) {
8
+ const document = YAML.parseDocument(text);
9
+ if (document.errors.length > 0) {
10
+ throw new Error(`Invalid YAML config: ${document.errors.map((error) => error.message).join("; ")}`);
11
+ }
12
+ const parsed = document.toJS();
13
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
14
+ throw new Error("Config root must be an object mapping");
15
+ }
16
+ return { document, parsed };
17
+ }
18
+
19
+ export function renderMigratedPolicy(policy, sourceText, document) {
20
+ document.set("version", TARGET_VERSION);
21
+ document.delete("workflows");
22
+ let skills = document.get("skills", true);
23
+ if (!YAML.isMap(skills)) {
24
+ skills = document.createNode({});
25
+ document.set("skills", skills);
26
+ }
27
+ const retainedIds = new Set(Object.keys(policy.skills));
28
+ for (const item of [...skills.items]) {
29
+ const id = String(item.key?.value ?? item.key);
30
+ if (!retainedIds.has(id)) skills.delete(id);
31
+ }
32
+ for (const [id, entry] of Object.entries(policy.skills)) {
33
+ skills.set(id, document.createNode(entry));
34
+ }
35
+ for (const key of ["defaults", "capabilities", "harnesses", "install_units"]) {
36
+ document.delete(key);
37
+ }
38
+ const text = String(document);
39
+ return sourceText.includes("\r\n") ? text.replace(/(?<!\r)\n/g, "\r\n") : text;
40
+ }
41
+
42
+ export function migrationInventory(mapped, sourceBytes, losses) {
43
+ return {
44
+ format_version: INVENTORY_FORMAT_VERSION,
45
+ generated: true,
46
+ authoritative_for_availability: false,
47
+ skills: mapped.skills,
48
+ install_units: mapped.install_units,
49
+ migration: {
50
+ source_version: 1,
51
+ target_version: TARGET_VERSION,
52
+ policy_projection_version: TARGET_VERSION,
53
+ invalidates: ["v1-action-id", "v1-guard-hook", "v1-lock-projection"],
54
+ input_sha256: sha256(sourceBytes),
55
+ losses
56
+ }
57
+ };
58
+ }
59
+
60
+ export function migrationReport(before, mapped, inputBytes, configBytes, inventoryBytes) {
61
+ const skillEntries = Object.entries(before.skills ?? {}).sort(([left], [right]) => left.localeCompare(right));
62
+ return {
63
+ target_version: TARGET_VERSION,
64
+ input_sha256: sha256(inputBytes),
65
+ config_sha256: sha256(configBytes),
66
+ inventory_sha256: sha256(inventoryBytes),
67
+ counts: {
68
+ skills: skillEntries.length,
69
+ enabled: Object.values(mapped.policy.skills).filter((skill) => skill.enabled).length,
70
+ disabled: Object.values(mapped.policy.skills).filter((skill) => !skill.enabled).length,
71
+ warnings: mapped.warnings.length,
72
+ losses: mapped.losses.length
73
+ },
74
+ skills: skillEntries.map(([id, skill]) => ({
75
+ id,
76
+ before: { status: skill.status, invocation: skill.invocation, exposure: skill.exposure },
77
+ after: mapped.policy.skills[id]
78
+ })),
79
+ warnings: [...mapped.warnings],
80
+ losses: mapped.losses.map(({ path, disposition }) => ({ path, disposition })),
81
+ ambiguities: mapped.ambiguities,
82
+ grouped_decision: mapped.ambiguities.length === 0 ? null : {
83
+ action: "apply_v2_migration",
84
+ confirmation_option: "--yes",
85
+ ambiguity_count: mapped.ambiguities.length,
86
+ skill_count: mapped.ambiguities.reduce((count, ambiguity) => count + ambiguity.skill_ids.length, 0)
87
+ }
88
+ };
89
+ }
90
+
91
+ export function unchangedMigrationResult(inputBytes) {
92
+ return {
93
+ mode: "apply",
94
+ changed: false,
95
+ target_version: TARGET_VERSION,
96
+ input_sha256: sha256(inputBytes),
97
+ config_sha256: sha256(inputBytes),
98
+ inventory_sha256: null,
99
+ counts: { skills: 0, enabled: 0, disabled: 0, warnings: 0, losses: 0 },
100
+ skills: [],
101
+ warnings: [],
102
+ losses: [],
103
+ ambiguities: [],
104
+ grouped_decision: null,
105
+ backup: null,
106
+ manifest: null
107
+ };
108
+ }
@@ -0,0 +1,205 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { loadWorkspace } from "../workspace.mjs";
4
+ import { mapV1ConfigToV2 } from "./v1-to-v2.mjs";
5
+ import {
6
+ atomicWrite,
7
+ canonicalMigrationPaths,
8
+ createBackup,
9
+ optionalRead,
10
+ relativeInventoryPath,
11
+ restoreSnapshot,
12
+ sha256,
13
+ withConfigLock
14
+ } from "./v2-files.mjs";
15
+ import {
16
+ beginMigrationTransaction,
17
+ commitMigrationTransaction,
18
+ completeMigrationRecovery,
19
+ finishMigrationTransaction,
20
+ recoverInterruptedMigration
21
+ } from "./v2-journal.mjs";
22
+ import {
23
+ migrationInventory,
24
+ migrationReport,
25
+ parseMigrationConfig,
26
+ renderMigratedPolicy,
27
+ unchangedMigrationResult
28
+ } from "./v2-projection.mjs";
29
+
30
+ const TARGET_VERSION = 2;
31
+
32
+ export async function migrateV2(options) {
33
+ const { configPath, inventoryPath } = await canonicalMigrationPaths(options.configPath, options.inventoryPath);
34
+ await recoverInterruptedMigration(configPath, inventoryPath, options.failpoint);
35
+ return await withConfigLock(configPath, async () => {
36
+ if (options.rollbackPath !== undefined) {
37
+ const requestedRollbackPath = isAbsolute(options.rollbackPath)
38
+ ? resolve(options.rollbackPath)
39
+ : resolve(dirname(configPath), options.rollbackPath);
40
+ const rollbackPath = await realpath(requestedRollbackPath);
41
+ return await rollbackMigration(configPath, inventoryPath, rollbackPath, options);
42
+ }
43
+ return await migrateForward(configPath, inventoryPath, options);
44
+ });
45
+ }
46
+
47
+ async function migrateForward(configPath, inventoryPath, options) {
48
+ const inputBytes = await readFile(configPath);
49
+ const inputText = inputBytes.toString("utf8");
50
+ const { document, parsed } = parseMigrationConfig(inputText);
51
+ const version = parsed.version ?? 1;
52
+ if (version === TARGET_VERSION) {
53
+ await validateCurrentV2(configPath, inventoryPath, options.skillsRoot);
54
+ return unchangedMigrationResult(inputBytes);
55
+ }
56
+ if (version !== 1) {
57
+ throw new Error(`Unsupported config version: ${String(version)}. No files were changed.`);
58
+ }
59
+
60
+ const mapped = mapV1ConfigToV2(parsed);
61
+ const configBytes = Buffer.from(renderMigratedPolicy(mapped.policy, inputText, document));
62
+ const inventory = migrationInventory(mapped.inventory, inputBytes, mapped.losses);
63
+ const inventoryBytes = Buffer.from(`${JSON.stringify(inventory, null, 2)}\n`);
64
+ const report = migrationReport(parsed, mapped, inputBytes, configBytes, inventoryBytes);
65
+ if (options.apply !== true) {
66
+ return { mode: "preview", changed: true, backup: null, ...report };
67
+ }
68
+
69
+ const previousInventory = await optionalRead(inventoryPath);
70
+ const backup = await createBackup(configPath, inputBytes, inventoryPath, previousInventory);
71
+ const journalPath = await beginMigrationTransaction(configPath, inventoryPath, backup);
72
+ try {
73
+ await atomicWrite(configPath, configBytes, backup.configMode);
74
+ failAt(options, "after-config-rename");
75
+ failAt(options, "after-config-write");
76
+ await atomicWrite(inventoryPath, inventoryBytes, backup.inventoryMode ?? 0o600);
77
+ failAt(options, "after-inventory-rename");
78
+ failAt(options, "before-validation");
79
+ await validateCurrentV2(configPath, inventoryPath, options.skillsRoot);
80
+ await commitMigrationTransaction(journalPath);
81
+ failAt(options, "after-commit-journal");
82
+ await finishMigrationTransaction(journalPath);
83
+ return {
84
+ mode: "apply",
85
+ changed: true,
86
+ backup: basename(backup.configBackupPath),
87
+ manifest: basename(backup.manifestPath),
88
+ ...report
89
+ };
90
+ } catch (error) {
91
+ await restoreSnapshot(configPath, inventoryPath, backup);
92
+ await completeMigrationRecovery(journalPath, backup);
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ async function rollbackMigration(configPath, inventoryPath, configBackupPath, options) {
98
+ assertAdjacentBackup(configPath, configBackupPath);
99
+ const manifestPath = `${configBackupPath}.manifest.json`;
100
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
101
+ validateManifest(manifest, configPath, inventoryPath, configBackupPath);
102
+ const currentConfig = await readFile(configPath);
103
+ const currentInventory = await optionalRead(inventoryPath);
104
+ const snapshot = {
105
+ configBackupPath,
106
+ manifestPath,
107
+ inventoryBackupPath: manifest.inventory_backup === null
108
+ ? null
109
+ : join(dirname(configBackupPath), manifest.inventory_backup),
110
+ inventoryExisted: manifest.inventory_existed,
111
+ configMode: manifest.config_mode,
112
+ inventoryMode: manifest.inventory_mode
113
+ };
114
+ const backupBytes = await readFile(configBackupPath);
115
+ if (sha256(backupBytes) !== manifest.input_sha256) {
116
+ throw new Error("Migration backup hash mismatch; rollback was not attempted.");
117
+ }
118
+ if (snapshot.inventoryExisted) {
119
+ const inventoryBytes = await readFile(snapshot.inventoryBackupPath);
120
+ if (sha256(inventoryBytes) !== manifest.inventory_input_sha256) {
121
+ throw new Error("Migration inventory backup hash mismatch; rollback was not attempted.");
122
+ }
123
+ }
124
+
125
+ const safety = await createBackup(configPath, currentConfig, inventoryPath, currentInventory, "rollback-safety");
126
+ const journalPath = await beginMigrationTransaction(configPath, inventoryPath, safety);
127
+ try {
128
+ await restoreSnapshot(configPath, inventoryPath, snapshot, {
129
+ afterConfigRename: () => failAt(options, "after-config-rename"),
130
+ afterInventoryRename: () => failAt(options, "after-inventory-rename")
131
+ });
132
+ await commitMigrationTransaction(journalPath);
133
+ failAt(options, "after-commit-journal");
134
+ await finishMigrationTransaction(journalPath);
135
+ return {
136
+ mode: "rollback",
137
+ changed: true,
138
+ target_version: manifest.source_version,
139
+ input_sha256: sha256(currentConfig),
140
+ config_sha256: sha256(await readFile(configPath)),
141
+ inventory_sha256: (await optionalRead(inventoryPath)) === null
142
+ ? null
143
+ : sha256(await readFile(inventoryPath)),
144
+ backup: basename(configBackupPath),
145
+ safety_backup: basename(safety.configBackupPath)
146
+ };
147
+ } catch (error) {
148
+ await restoreSnapshot(configPath, inventoryPath, safety);
149
+ await completeMigrationRecovery(journalPath, safety);
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ async function validateCurrentV2(configPath, inventoryPath, skillsRoot) {
155
+ const workspace = await loadWorkspace({ configPath, inventoryPath, skillsRoot });
156
+ if (workspace.version !== TARGET_VERSION) {
157
+ throw new Error("Post-write validation did not load version 2 policy.");
158
+ }
159
+ if (workspace.inventory?.integrityErrors?.length > 0) {
160
+ throw new Error("Post-write inventory validation failed.");
161
+ }
162
+ const policyIds = workspace.skills.map(({ id }) => id).sort((left, right) => left.localeCompare(right));
163
+ const inventoryIds = [...(workspace.inventory?.skillIds ?? [])].sort((left, right) => left.localeCompare(right));
164
+ if (policyIds.length !== inventoryIds.length
165
+ || policyIds.some((id, index) => id !== inventoryIds[index])) {
166
+ throw new Error("Post-write policy and inventory skill-set agreement failed.");
167
+ }
168
+ }
169
+
170
+ function validateManifest(manifest, configPath, inventoryPath, backupPath) {
171
+ if (manifest?.format_version !== 1 || manifest.config_backup !== basename(backupPath)) {
172
+ throw new Error("Invalid migration backup manifest; rollback was not attempted.");
173
+ }
174
+ if (manifest.target_config !== basename(configPath)) {
175
+ throw new Error("Migration backup belongs to a different config; rollback was not attempted.");
176
+ }
177
+ const requestedInventory = relativeInventoryPath(configPath, inventoryPath);
178
+ if (manifest.target_inventory !== requestedInventory) {
179
+ throw new Error("Requested inventory target does not match the migration manifest; rollback was not attempted.");
180
+ }
181
+ if (!Number.isInteger(manifest.config_mode) || manifest.config_mode < 0 || manifest.config_mode > 0o777
182
+ || (manifest.inventory_existed === true
183
+ && (!Number.isInteger(manifest.inventory_mode) || manifest.inventory_mode < 0 || manifest.inventory_mode > 0o777))) {
184
+ throw new Error("Invalid migration backup mode metadata; rollback was not attempted.");
185
+ }
186
+ if (manifest.inventory_existed === true) {
187
+ const expected = `${basename(backupPath)}.inventory`;
188
+ if (manifest.inventory_backup !== expected || basename(manifest.inventory_backup) !== manifest.inventory_backup) {
189
+ throw new Error("Invalid migration inventory backup reference; rollback was not attempted.");
190
+ }
191
+ } else if (manifest.inventory_existed !== false || manifest.inventory_backup !== null) {
192
+ throw new Error("Invalid migration inventory backup state; rollback was not attempted.");
193
+ }
194
+ }
195
+
196
+ function assertAdjacentBackup(configPath, backupPath) {
197
+ if (dirname(configPath) !== dirname(backupPath) || !basename(backupPath).startsWith(`${basename(configPath)}.`)) {
198
+ throw new Error("Migration rollback backup must be adjacent to the selected config.");
199
+ }
200
+ }
201
+
202
+ function failAt(options, point) {
203
+ if (options.failpoint === `terminate-${point}`) process.exit(86);
204
+ if (options.failpoint === point) throw new Error(`Injected migration failure at ${point}.`);
205
+ }