agent-skillboard 0.2.17 → 0.3.0

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 (77) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +128 -260
  3. package/bin/postinstall.mjs +2 -2
  4. package/docs/adapters.md +37 -113
  5. package/docs/ai-skill-routing-goal.md +35 -109
  6. package/docs/capabilities.md +17 -104
  7. package/docs/install.md +39 -493
  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 +117 -356
  12. package/docs/rollout-runbook.md +23 -25
  13. package/docs/routing.md +23 -90
  14. package/docs/user-flow.md +60 -292
  15. package/docs/value-proof.md +23 -181
  16. package/docs/variant-lifecycle.md +47 -67
  17. package/docs/versioning.md +31 -264
  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 +13 -4
  29. package/src/agent-integration-content.mjs +22 -12
  30. package/src/agent-integration-files.mjs +1 -1
  31. package/src/agent-inventory-platforms.mjs +10 -0
  32. package/src/agent-inventory.mjs +23 -1
  33. package/src/agent-skill-import.mjs +2 -2
  34. package/src/audit-paths.mjs +42 -0
  35. package/src/brief-cli.mjs +3 -2
  36. package/src/brief-renderer.mjs +1 -0
  37. package/src/cli.mjs +398 -127
  38. package/src/compatibility.mjs +24 -0
  39. package/src/control/can-use-guard.mjs +21 -1
  40. package/src/control/config-write.mjs +32 -2
  41. package/src/control/skill-crud.mjs +5 -0
  42. package/src/control/v2-guard.mjs +175 -0
  43. package/src/control/v2-skill-crud.mjs +32 -0
  44. package/src/control/v2-skill-forget.mjs +38 -0
  45. package/src/control/variant-status.mjs +47 -1
  46. package/src/control.mjs +55 -0
  47. package/src/doctor.mjs +65 -6
  48. package/src/domain/v2-policy.mjs +111 -0
  49. package/src/hook-plan.mjs +33 -3
  50. package/src/impact.mjs +52 -29
  51. package/src/index.mjs +25 -1
  52. package/src/init.mjs +50 -34
  53. package/src/inventory-install-units.mjs +63 -0
  54. package/src/inventory-json.mjs +279 -0
  55. package/src/inventory-refresh.mjs +163 -18
  56. package/src/lifecycle-cli.mjs +40 -12
  57. package/src/lifecycle-content.mjs +52 -67
  58. package/src/migration/v1-to-v2.mjs +212 -0
  59. package/src/migration/v2-files.mjs +211 -0
  60. package/src/migration/v2-journal.mjs +169 -0
  61. package/src/migration/v2-projection.mjs +108 -0
  62. package/src/migration/v2-transaction.mjs +205 -0
  63. package/src/policy.mjs +3 -0
  64. package/src/reconcile.mjs +139 -111
  65. package/src/report.mjs +168 -148
  66. package/src/review.mjs +2 -0
  67. package/src/route-advisory.mjs +47 -2
  68. package/src/route-selection.mjs +38 -2
  69. package/src/route.mjs +62 -2
  70. package/src/shared-skill.mjs +301 -0
  71. package/src/source-digest.mjs +42 -0
  72. package/src/source-profiles.mjs +27 -0
  73. package/src/source-verification.mjs +32 -48
  74. package/src/uninstall.mjs +22 -0
  75. package/src/user-state-paths.mjs +19 -0
  76. package/src/user-uninstall.mjs +146 -0
  77. package/src/workspace.mjs +41 -1
@@ -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
+ }
package/src/policy.mjs CHANGED
@@ -14,6 +14,9 @@ const RULES = [
14
14
  ];
15
15
 
16
16
  export function checkPolicy(workspace) {
17
+ if (workspace.version === 2) {
18
+ return { ok: true, errors: [], warnings: [] };
19
+ }
17
20
  const ctx = buildPolicyContext(workspace);
18
21
  const errors = [];
19
22
  const warnings = [];