agent-skillboard 0.2.18 → 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 (76) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +129 -258
  3. package/docs/adapters.md +37 -113
  4. package/docs/ai-skill-routing-goal.md +35 -109
  5. package/docs/capabilities.md +17 -104
  6. package/docs/install.md +36 -485
  7. package/docs/policy-model.md +50 -280
  8. package/docs/positioning.md +19 -86
  9. package/docs/profiles.md +21 -153
  10. package/docs/reference.md +115 -363
  11. package/docs/rollout-runbook.md +23 -25
  12. package/docs/routing.md +23 -90
  13. package/docs/user-flow.md +60 -284
  14. package/docs/value-proof.md +23 -181
  15. package/docs/variant-lifecycle.md +47 -67
  16. package/docs/versioning.md +31 -264
  17. package/examples/v2-multi-source.config.yaml +35 -0
  18. package/examples/v2-policy-error.config.yaml +6 -0
  19. package/package.json +1 -1
  20. package/src/advisor/actions.mjs +102 -6
  21. package/src/advisor/application-commands.mjs +10 -9
  22. package/src/advisor/apply-action.mjs +74 -1
  23. package/src/advisor/guidance.mjs +24 -16
  24. package/src/advisor/schema.mjs +17 -6
  25. package/src/advisor/skills.mjs +18 -5
  26. package/src/advisor.mjs +27 -9
  27. package/src/agent-integration-cli.mjs +13 -4
  28. package/src/agent-integration-content.mjs +21 -11
  29. package/src/agent-integration-files.mjs +1 -1
  30. package/src/agent-inventory-platforms.mjs +10 -0
  31. package/src/agent-inventory.mjs +23 -1
  32. package/src/agent-skill-import.mjs +2 -2
  33. package/src/audit-paths.mjs +42 -0
  34. package/src/brief-cli.mjs +3 -2
  35. package/src/brief-renderer.mjs +1 -0
  36. package/src/cli.mjs +498 -232
  37. package/src/compatibility.mjs +24 -0
  38. package/src/control/can-use-guard.mjs +21 -1
  39. package/src/control/config-write.mjs +32 -2
  40. package/src/control/skill-crud.mjs +5 -0
  41. package/src/control/v2-guard.mjs +175 -0
  42. package/src/control/v2-skill-crud.mjs +32 -0
  43. package/src/control/v2-skill-forget.mjs +38 -0
  44. package/src/control/variant-status.mjs +47 -1
  45. package/src/control.mjs +55 -0
  46. package/src/doctor.mjs +63 -4
  47. package/src/domain/v2-policy.mjs +111 -0
  48. package/src/hook-plan.mjs +33 -3
  49. package/src/impact.mjs +52 -29
  50. package/src/index.mjs +25 -1
  51. package/src/init.mjs +50 -34
  52. package/src/inventory-install-units.mjs +63 -0
  53. package/src/inventory-json.mjs +279 -0
  54. package/src/inventory-refresh.mjs +163 -18
  55. package/src/lifecycle-cli.mjs +40 -12
  56. package/src/lifecycle-content.mjs +52 -67
  57. package/src/migration/v1-to-v2.mjs +212 -0
  58. package/src/migration/v2-files.mjs +211 -0
  59. package/src/migration/v2-journal.mjs +169 -0
  60. package/src/migration/v2-projection.mjs +108 -0
  61. package/src/migration/v2-transaction.mjs +205 -0
  62. package/src/policy.mjs +3 -0
  63. package/src/reconcile.mjs +139 -111
  64. package/src/report.mjs +168 -148
  65. package/src/review.mjs +2 -0
  66. package/src/route-advisory.mjs +47 -2
  67. package/src/route-selection.mjs +38 -2
  68. package/src/route.mjs +62 -2
  69. package/src/shared-skill.mjs +301 -0
  70. package/src/source-digest.mjs +42 -0
  71. package/src/source-profiles.mjs +171 -144
  72. package/src/source-verification.mjs +32 -48
  73. package/src/uninstall.mjs +22 -0
  74. package/src/user-state-paths.mjs +19 -0
  75. package/src/user-uninstall.mjs +146 -0
  76. package/src/workspace.mjs +119 -79
@@ -2,17 +2,8 @@ export const BRIDGE_START = "<!-- BEGIN SKILLBOARD -->";
2
2
  export const BRIDGE_END = "<!-- END SKILLBOARD -->";
3
3
 
4
4
  export function defaultConfig() {
5
- return `version: 1
6
- defaults:
7
- invocation_policy: deny-by-default
8
- allow_model_invocation: false
9
- require_explicit_workflow: true
10
-
5
+ return `version: 2
11
6
  skills: {}
12
- capabilities: {}
13
- harnesses: {}
14
- workflows: {}
15
- install_units: {}
16
7
  `;
17
8
  }
18
9
 
@@ -20,40 +11,49 @@ export function bridgeBlock() {
20
11
  return `${BRIDGE_START}
21
12
  # SkillBoard Control Plane
22
13
 
23
- Your responsibility is to answer skill availability questions from SkillBoard, translate user intent into current action ids, ask for one confirmation only before policy-changing actions, apply one current action, reread the post-apply brief, and run the guard automatically before invocation.
24
-
25
- ## Product Goal
26
-
27
- - SkillBoard is a permissive AI skill routing layer, not a pre-task settings checklist.
28
- - Keep installed skills broadly available by default; use SkillBoard to resolve overlap when several skills could steer the same task.
29
- - Read \`docs/ai-skill-routing-goal.md\` before changing routing, brief, bridge, policy, or workflow UX.
30
- - Preserve the loop: observe route work → explain briefly → ask after → remember policy.
31
- - SkillBoard does not rewrite \`SKILL.md\` bodies to personalize behavior; record usage policy for when to use, prefer, reference, avoid, or block skills.
32
-
33
- ## Availability
34
-
35
- - Use SkillBoard as the source of truth for project-local policy and workflow priority; installed \`SKILL.md\` files are candidates, not enough to resolve overlap by themselves.
36
- - Read the current brief before answering: \`skillboard brief --json --config skillboard.config.yaml --skills skills\`. If the workflow is known, include \`--workflow <name>\`; add \`--include-actions\` when the user wants you to mediate a change.
37
- - For ordinary user requests, work normally unless skill choice is ambiguous, several skills overlap, workflow priority matters, or the user explicitly asks for a SkillBoard or skill decision. In those cases, read \`skillboard brief --intent <request> --json --config skillboard.config.yaml --skills skills\`. Include \`--workflow <name>\` when known. Read \`assistant_guidance.route\`; use \`recommended_skill\`, \`fallback_skills\`, \`route_candidates\`, \`overlap_resolution\`, \`policy_memory\`, \`post_use_policy_suggestion\`, and \`guard_command\` instead of guessing from raw skill text. Inspect \`overlap_resolution\` and \`route_candidates\` when several skills match so allowed overlap, denied candidates, and selected fallbacks are clear. If \`policy_memory\` is present, mention after completion that remembered or configured policy selected this skill even though other allowed skills were available. If \`post_use_policy_suggestion\` is present, work first with the allowed routed skill, then ask after completion whether to remember the suggested policy. If no skill matches, ask a clarifying question before choosing a skill.
38
- - If the user explicitly requests a specific already-allowed skill, honor that request after guard use instead of rerouting away solely because another skill also matches.
39
- - Treat the brief sections headed "What your AI can use now", "Needs your decision", and "Blocked for safety" as the availability summary; do not infer availability from \`SKILL.md\` bodies.
40
- - Treat "Needs your decision" as a one-time decision queue, not a persistent blocked state. "Blocked for safety" means the skill/source/workflow is hard-blocked until policy or provenance changes.
41
- - Use \`skillboard can-use <skill-id> --workflow <name> --config skillboard.config.yaml --skills skills --json\` for machine-readable agent decisions.
42
-
43
- ## Intent to Action
44
-
45
- - Translate user intent into current action ids from the current brief, not saved output.
46
- - For action cards, read \`skillboard brief --json --config skillboard.config.yaml --skills skills --include-actions\`, pick one current action id, ask the user for one confirmation, then run \`skillboard apply-action <action-id> --config skillboard.config.yaml --skills skills --yes --json\`. Include \`--workflow <name>\` when a workflow is selected.
47
- - After \`skillboard apply-action <action-id> ... --yes --json\`, reread the returned post-apply brief before answering the next availability question or applying another action. \`apply-action\` re-resolves current actions; do not apply cached or stale action ids, do not apply multiple actions, and do not run raw action-card shell text as the primary apply path.
48
- - For an already-allowed skill, do not ask for another approval. Run \`skillboard guard use <skill-id> --workflow <name> --config skillboard.config.yaml --skills skills\` automatically. Say at the start: "I will use <skill-id> for this request." Say at completion: "I used <skill-id> for this request." Treat this disclosure as an audit trace, not a permission prompt. Ask the user only if the guard denies use or a policy-changing action is needed.
49
-
50
- ## Operations
51
-
52
- - Source trust: run \`skillboard audit sources --config skillboard.config.yaml --skills skills\` before trusting newly imported external skill sources; after review, use \`skillboard review install-unit <unit-id> --trust-level reviewed --config skillboard.config.yaml --skills skills\`. Unreviewed runtime sources are a one-time review decision, not a default block recommendation; after review, activate only the needed quarantined skills as manual-only workflow skills and use ask-after policy suggestions for future preferences.
53
- - Health: run \`skillboard doctor --config skillboard.config.yaml --skills skills\` or \`skillboard status --config skillboard.config.yaml --skills skills --json\`; add \`--strict\` when review-needed safe mode should fail automation.
54
- - Hook action cards: prefer \`skillboard apply-action <action-id> --yes --json\`. The underlying manual preview is \`skillboard hook install --workflow <name> --config skillboard.config.yaml --skills skills --out .skillboard/hooks/<name>-guard.sh --dry-run --json\`; inspect \`planned.preview.shell\` before materializing an executable guard hook outside the action-card control loop. Generated hooks pin the install-time SkillBoard command, config, skills root, and workflow; set those values with hook install options such as \`--skillboard-bin\`, not runtime environment overrides.
55
- - Inventory/import growth: run \`skillboard inventory refresh --dry-run --config skillboard.config.yaml\` after installing local skill packs, plugins, workflow bundles, or harnesses; run \`skillboard import --profile <id-or-path> --source-root <repo> --out .skillboard/reports/import-fragment.yaml\` after installing a new skill repository, then review the fragment before merging it into \`skillboard.config.yaml\`.
56
- - Prefer workflow-scoped skills over global skill invocation. Only \`global-meta\` skills may be treated as globally available.
14
+ SkillBoard keeps one user-level policy. A valid installed skill defaults to
15
+ enabled and agent-local. Users may opt individual skills into cross-agent
16
+ sharing. Optional preference ranks enabled skills installed for the current
17
+ agent and never changes availability or copies files.
18
+
19
+ ## Route and use
20
+
21
+ - Work normally unless skills overlap or the user asks
22
+ for a skill decision.
23
+ - Read \`skillboard brief --intent <request> --agent <agent> --json\`.
24
+ - Use the recommended enabled, installed skill and ordered fallbacks. Honor an
25
+ explicit user choice when the guard allows it.
26
+ - Run \`skillboard guard use <skill-id> --agent <agent> --json\` immediately
27
+ before use.
28
+ - Do not ask for another approval when guard allows use. Briefly disclose the
29
+ selected skill as an audit trace.
30
+ - If the routed brief suggests policy memory, finish the work first, then ask
31
+ once whether to remember that intent preference. Do not prompt before use.
32
+
33
+ ## Change policy
34
+
35
+ - Policy changes only enable/disable a skill, share/unshare one skill, or
36
+ remember optional ranking preference.
37
+ - Read a fresh brief with \`--include-actions\`, choose one current action id, ask
38
+ for one confirmation, then run \`skillboard apply-action <action-id> --yes
39
+ --json\`.
40
+ - Reread the returned post-apply brief. Never reuse cached action ids or apply
41
+ multiple actions from one confirmation.
42
+ - If inventory no longer observes a permanently removed unshared skill, use its
43
+ current forget action after confirmation. Forget removes policy only and never
44
+ deletes skill files.
45
+
46
+ ## Compatibility and boundaries
47
+
48
+ - If the brief reports stale version 1 policy, do not mutate it. Preview
49
+ \`skillboard migrate v2 --config skillboard.config.yaml --json\`, then obtain
50
+ confirmation before applying with \`--yes --json\`. Rollback uses
51
+ \`--rollback <backup> --json\`.
52
+ - Source and provenance observations are optional audit metadata and never
53
+ determine availability.
54
+ - Runtime and action authorization are outside SkillBoard's scope. Follow the
55
+ agent or harness permission boundary for commands, hooks, MCP servers,
56
+ external writes, destructive actions, network access, and secrets.
57
57
 
58
58
  ${BRIDGE_END}`;
59
59
  }
@@ -61,39 +61,24 @@ ${BRIDGE_END}`;
61
61
  export function profileReadme() {
62
62
  return `# SkillBoard source profiles
63
63
 
64
- Put project-specific source profiles here when a skill repository or harness
65
- bundle is not covered by a built-in profile.
66
-
67
- Use:
68
-
69
- \`\`\`bash
70
- skillboard import --profile .skillboard/profiles/example.yaml --source-root /path/to/repo
71
- \`\`\`
72
-
73
- The import command emits a YAML fragment with governed \`skills\` and
74
- \`install_units\`. Review the fragment before merging it into
75
- \`skillboard.config.yaml\`; imported skills are not automatically active.
64
+ Put discovery profiles here when a skill repository is not
65
+ covered by a built-in profile. Profiles produce inventory observations; they do
66
+ not authorize availability. Newly discovered valid skills default to
67
+ enabled and agent-local while existing user policy is preserved.
76
68
  `;
77
69
  }
78
70
 
79
71
  export function hookReadme() {
80
72
  return `# SkillBoard hooks
81
73
 
82
- Use this directory for executable guard scripts generated by:
74
+ Preview a generated guard hook before installation:
83
75
 
84
76
  \`\`\`bash
85
77
  skillboard hook install --workflow <name> --config skillboard.config.yaml --skills skills --out .skillboard/hooks/<name>-guard.sh --dry-run --json
86
- skillboard hook install --workflow <name> --config skillboard.config.yaml --skills skills --out .skillboard/hooks/<name>-guard.sh
87
78
  \`\`\`
88
79
 
89
- For hook action cards, prefer the bridge control loop with
90
- \`skillboard apply-action <action-id> --yes --json\`. These raw hook commands
91
- are the underlying manual detail: preview the JSON plan first and inspect
92
- \`planned.preview.shell\` before materializing an executable guard hook.
93
- Generated hooks pin the install-time SkillBoard command, config, skills root,
94
- and workflow; set those values with hook install options such as
95
- \`--skillboard-bin\`, not runtime environment overrides. The generated script
96
- delegates to \`skillboard guard use\` and can be wired into the hook mechanism of
97
- the active harness.
80
+ For persistent changes prefer one current action card applied with
81
+ \`skillboard apply-action <action-id> --yes --json\`. Runtime hook permission is
82
+ owned by the active harness, not SkillBoard policy.
98
83
  `;
99
84
  }
@@ -0,0 +1,212 @@
1
+ import { EXPOSURE_VALUES, INVOCATION_VALUES, STATUS_VALUES } from "../domain/constants.mjs";
2
+ import { TERMINAL_STATUSES, validateSkillState } from "../domain/skill-state-matrix.mjs";
3
+
4
+ const V1_SKILL_POLICY_FIELDS = new Set(["status", "invocation", "exposure"]);
5
+ const V1_SKILL_LOCATION_FIELDS = new Set(["path", "owner_install_unit"]);
6
+
7
+ /**
8
+ * Purely project a parsed v1 config into v2 policy and audit observations.
9
+ * The caller retains ownership of input and may safely hash/reuse it afterward.
10
+ */
11
+ export function mapV1ConfigToV2(input) {
12
+ const config = requireRecord(input, "v1 config");
13
+ if (config.version !== undefined && config.version !== 1) {
14
+ throw new Error(`Expected version 1 config; got version ${String(config.version)}`);
15
+ }
16
+ const skills = requireRecord(config.skills ?? {}, "v1 config skills");
17
+ const workflows = requireRecord(config.workflows ?? {}, "v1 config workflows");
18
+ const capabilities = requireRecord(config.capabilities ?? {}, "v1 config capabilities");
19
+ const installUnits = requireRecord(config.install_units ?? {}, "v1 config install_units");
20
+ const workflowNames = Object.keys(workflows).sort();
21
+ const warnings = [];
22
+ const losses = collectLosses(config);
23
+ const policySkills = {};
24
+ const inventorySkills = [];
25
+ const uncertainQuarantines = [];
26
+
27
+ for (const [skillId, rawSkill] of Object.entries(skills).sort(byEntryKey)) {
28
+ const skill = requireRecord(rawSkill, `skill ${skillId}`);
29
+ validateState(skillId, skill);
30
+ const enabled = !TERMINAL_STATUSES.has(skill.status);
31
+ const preference = preferenceFor(skillId, capabilities, workflows);
32
+ policySkills[skillId] = {
33
+ enabled,
34
+ shared: false,
35
+ ...(preference === null ? {} : { preference })
36
+ };
37
+ if (isReviewOnlyQuarantine(skillId, skill, workflowNames, workflows)) {
38
+ uncertainQuarantines.push(skillId);
39
+ }
40
+ inventorySkills.push(skillObservation(skillId, skill));
41
+ }
42
+
43
+ const ambiguities = uncertainQuarantines.length === 0 ? [] : [{
44
+ kind: "review_only_quarantine",
45
+ skill_ids: uncertainQuarantines,
46
+ mapped_enabled: true,
47
+ requires_grouped_confirmation: true
48
+ }];
49
+ if (uncertainQuarantines.length > 0) {
50
+ warnings.push(
51
+ `${uncertainQuarantines.length} review-only quarantine state(s) were mapped enabled; confirm them together when applying the migration.`
52
+ );
53
+ }
54
+
55
+ return {
56
+ policy: {
57
+ version: 2,
58
+ skills: policySkills
59
+ },
60
+ inventory: {
61
+ format_version: 1,
62
+ skills: inventorySkills,
63
+ install_units: Object.entries(installUnits).sort(byEntryKey).map(([id, unit]) => ({
64
+ id,
65
+ observations: clone(requireRecord(unit, `install unit ${id}`))
66
+ }))
67
+ },
68
+ warnings,
69
+ losses,
70
+ ambiguities
71
+ };
72
+ }
73
+
74
+ function isReviewOnlyQuarantine(skillId, skill, workflowNames, workflows) {
75
+ if (skill.status !== "quarantined" || skill.invocation !== "blocked") return false;
76
+ return !workflowNames.some((name) => stringList(workflows[name]?.blocked_skills).includes(skillId));
77
+ }
78
+
79
+ function validateState(skillId, skill) {
80
+ if (!STATUS_VALUES.has(skill.status)) {
81
+ throw new Error(`Skill ${skillId} has unsupported status: ${String(skill.status)}`);
82
+ }
83
+ if (!INVOCATION_VALUES.has(skill.invocation)) {
84
+ throw new Error(`Skill ${skillId} has unsupported invocation: ${String(skill.invocation)}`);
85
+ }
86
+ if (!EXPOSURE_VALUES.has(skill.exposure)) {
87
+ throw new Error(`Skill ${skillId} has unsupported exposure: ${String(skill.exposure)}`);
88
+ }
89
+ const diagnostic = validateSkillState(skill.status, skill.invocation, skillId);
90
+ if (typeof diagnostic === "string") throw new Error(diagnostic);
91
+ }
92
+
93
+ function preferenceFor(skillId, capabilities, workflows) {
94
+ const roles = [];
95
+ for (const [name, rawCapability] of Object.entries(capabilities)) {
96
+ const capability = requireRecord(rawCapability, `capability ${name}`);
97
+ if (capability.canonical === skillId) roles.push({ intent: name, priority: 100 });
98
+ if (stringList(capability.alternatives).includes(skillId)) roles.push({ intent: name, priority: 50 });
99
+ }
100
+ for (const rawWorkflow of Object.values(workflows)) {
101
+ const workflow = requireRecord(rawWorkflow, "workflow");
102
+ const requirements = requireRecord(workflow.required_capabilities ?? {}, "workflow required_capabilities");
103
+ for (const [name, rawRequirement] of Object.entries(requirements)) {
104
+ const requirement = requireRecord(rawRequirement, `workflow capability ${name}`);
105
+ if (requirement.preferred === skillId) roles.push({ intent: name, priority: 100 });
106
+ stringList(requirement.fallback).forEach((candidate, index) => {
107
+ if (candidate === skillId) roles.push({ intent: name, priority: 90 - index });
108
+ });
109
+ }
110
+ }
111
+ if (roles.length === 0) return null;
112
+ return {
113
+ intents: [...new Set(roles.map(({ intent }) => intent))].sort(),
114
+ priority: Math.max(...roles.map(({ priority }) => priority))
115
+ };
116
+ }
117
+
118
+ function skillObservation(id, skill) {
119
+ const aliases = Array.isArray(skill.source_aliases) ? clone(skill.source_aliases) : [];
120
+ const observations = Object.fromEntries(
121
+ Object.entries(skill)
122
+ .filter(([key]) => !V1_SKILL_POLICY_FIELDS.has(key)
123
+ && !V1_SKILL_LOCATION_FIELDS.has(key)
124
+ && key !== "source_aliases")
125
+ .map(([key, value]) => [key, clone(value)])
126
+ );
127
+ return {
128
+ id,
129
+ path: typeof skill.path === "string" && skill.path.length > 0 ? skill.path : id,
130
+ owner_install_unit: typeof skill.owner_install_unit === "string" && skill.owner_install_unit.length > 0
131
+ ? skill.owner_install_unit
132
+ : "migration.unowned",
133
+ aliases,
134
+ installed_on: installedAgents(skill),
135
+ observations
136
+ };
137
+ }
138
+
139
+ function installedAgents(skill) {
140
+ const units = [skill.owner_install_unit, ...(skill.source_aliases ?? []).map((alias) => alias?.owner_install_unit)];
141
+ return [...new Set(units.map(agentForUnit).filter(Boolean))].sort();
142
+ }
143
+
144
+ function agentForUnit(unit) {
145
+ if (typeof unit !== "string") return "";
146
+ return ["codex", "claude", "opencode", "hermes"].find((agent) => unit === agent || unit.startsWith(`${agent}.`)) ?? "";
147
+ }
148
+
149
+ function collectLosses(config) {
150
+ const losses = [];
151
+ visit(config, "", (path, value) => {
152
+ if (path === "/version") return;
153
+ losses.push({ path, value: clone(value), disposition: lossDisposition(path) });
154
+ });
155
+ return losses;
156
+ }
157
+
158
+ function visit(value, path, add) {
159
+ if (value === null || typeof value !== "object") {
160
+ add(path, value);
161
+ return;
162
+ }
163
+ if (Array.isArray(value)) {
164
+ if (value.length === 0) add(path, []);
165
+ value.forEach((entry, index) => visit(entry, `${path}/${index}`, add));
166
+ return;
167
+ }
168
+ const entries = Object.entries(value);
169
+ if (entries.length === 0) add(path, {});
170
+ entries.forEach(([key, entry]) => visit(entry, `${path}/${escapePointer(key)}`, add));
171
+ }
172
+
173
+ function lossDisposition(path) {
174
+ if (/^\/skills\/[^/]+\/(status|invocation|exposure)$/.test(path)) return "mapped_to_v2_policy";
175
+ if (path.startsWith("/skills/") || path.startsWith("/install_units/")) return "preserved_in_inventory_observation";
176
+ if (path.startsWith("/capabilities/") || path.startsWith("/workflows/")) return "mapped_where_applicable";
177
+ return "not_part_of_v2_policy";
178
+ }
179
+
180
+ function requireRecord(value, label) {
181
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
182
+ throw new Error(`${label} must be an object mapping`);
183
+ }
184
+ return value;
185
+ }
186
+
187
+ function stringList(value) {
188
+ if (value === undefined) return [];
189
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
190
+ throw new Error("Expected a list of strings in v1 config");
191
+ }
192
+ return value;
193
+ }
194
+
195
+ function clone(value) {
196
+ if (Array.isArray(value)) {
197
+ return value.map((entry) => clone(entry));
198
+ }
199
+ if (value !== null && typeof value === "object") {
200
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, clone(entry)]));
201
+ }
202
+ return value;
203
+ }
204
+
205
+ /** @param {[string, unknown]} left @param {[string, unknown]} right */
206
+ function byEntryKey(left, right) {
207
+ return left[0].localeCompare(right[0]);
208
+ }
209
+
210
+ function escapePointer(value) {
211
+ return value.split("~").join("~0").split("/").join("~1");
212
+ }
@@ -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
+ }