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,24 @@
1
+ export const V1_COMPATIBILITY_REMOVAL_VERSION = "v0.4.0";
2
+ export const V1_MIGRATION_COMMAND = "skillboard migrate v2";
3
+ export const V1_COMPATIBILITY_NOTICE =
4
+ `Version 1 policy is deprecated and read-only; run \`${V1_MIGRATION_COMMAND}\`. Support ends in package release ${V1_COMPATIBILITY_REMOVAL_VERSION}.`;
5
+ export const V1_MUTATION_ERROR = `Version 1 policy is read-only. Run \`${V1_MIGRATION_COMMAND}\`.`;
6
+ export const STALE_V1_PROJECTION_ERROR = "This pre-v2 policy projection is stale; regenerate it from version 2 policy.";
7
+
8
+ export function compatibilityForVersion(version) {
9
+ return version === 1 ? { notice: V1_COMPATIBILITY_NOTICE, removalVersion: V1_COMPATIBILITY_REMOVAL_VERSION } : null;
10
+ }
11
+
12
+ export function assertV2MutationVersion(version) {
13
+ if (version === 1 || version === undefined) throw new Error(V1_MUTATION_ERROR);
14
+ }
15
+
16
+ export function isPreV2ActionId(actionId) {
17
+ return /^(?:review-install-unit|trust-install-unit|activate-skill|block-install-unit):/.test(actionId);
18
+ }
19
+
20
+ export function assertCurrentProjectionVersion(projectionVersion, policyVersion) {
21
+ if (projectionVersion !== undefined && Number(projectionVersion) !== policyVersion) {
22
+ throw new Error(`${STALE_V1_PROJECTION_ERROR} Expected policy projection version ${policyVersion}, got ${projectionVersion}.`);
23
+ }
24
+ }
@@ -6,8 +6,12 @@ import {
6
6
  import { workflowConflictEntriesForSkill } from "../conflicts.mjs";
7
7
  import { isModelSelectableInvocation, isUserControlledSource } from "../domain/source-classes.mjs";
8
8
  import { classifySkillTrust, workflowSkillRole } from "./source-trust.mjs";
9
+ import { authorizeV2Skill } from "./v2-guard.mjs";
9
10
 
10
- export function canUseSkill(workspace, skillId, workflowName) {
11
+ export function canUseSkill(workspace, skillId, workflowName, agentName) {
12
+ if (workspace.version === 2) {
13
+ return canUseV2Skill(workspace, skillId, agentName);
14
+ }
11
15
  const policy = checkPolicy(workspace);
12
16
  const skill = workspace.skills.find((candidate) => candidate.id === skillId);
13
17
  const workflow = workspace.workflows.find((candidate) => candidate.name === workflowName);
@@ -51,6 +55,22 @@ export function canUseSkill(workspace, skillId, workflowName) {
51
55
  return canUseResult(reasons.length === 0, skillId, workflowName, skill, workflow, reasons, role, classifySkillTrust(workspace, skill));
52
56
  }
53
57
 
58
+ function canUseV2Skill(workspace, skillId, agentName) {
59
+ const skill = workspace.skills.find((candidate) => candidate.id === skillId);
60
+ const authorization = authorizeV2Skill(skillId, skill, workspace.inventory, agentName);
61
+ return {
62
+ allowed: authorization.allowed,
63
+ automaticAllowed: authorization.allowed,
64
+ allowedUse: authorization.allowed ? allowedUse(skillId) : null,
65
+ skill: skillId,
66
+ workflow: null,
67
+ agent: agentName ?? null,
68
+ workflowKnown: true,
69
+ integrityError: authorization.integrityError,
70
+ reasons: authorization.reasons
71
+ };
72
+ }
73
+
54
74
  function trustUseReasons(workspace, skill) {
55
75
  const unit = skill.ownerInstallUnit === undefined
56
76
  ? undefined
@@ -1,4 +1,4 @@
1
- import { readFile, rename, rm, writeFile } from "node:fs/promises";
1
+ import { open, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import YAML from "yaml";
@@ -6,6 +6,7 @@ import { loadWorkspace } from "../workspace.mjs";
6
6
  import { checkPolicy } from "../policy.mjs";
7
7
  import { textChangePlan } from "../change-plan.mjs";
8
8
  import { canUseSkill } from "./can-use-guard.mjs";
9
+ import { assertV2MutationVersion } from "../compatibility.mjs";
9
10
 
10
11
  export async function loadConfig(path) {
11
12
  const text = await readFile(path, "utf8");
@@ -21,10 +22,12 @@ export async function loadConfig(path) {
21
22
  }
22
23
 
23
24
  export async function writeCheckedConfig(document, originalText, options, message) {
25
+ assertV2MutationVersion(document.get("version") ?? 1);
24
26
  const nextText = preserveLineEndings(String(document), originalText);
25
27
  const plan = textChangePlan(originalText, nextText);
26
28
  const tempPath = tempConfigPath(options.configPath);
27
- await writeFile(tempPath, nextText, { encoding: "utf8", flag: "wx" });
29
+ const originalMode = (await stat(options.configPath)).mode & 0o777;
30
+ await writeFile(tempPath, nextText, { encoding: "utf8", flag: "wx", mode: originalMode });
28
31
  try {
29
32
  const workspace = await loadWorkspace({ configPath: tempPath, skillsRoot: options.skillsRoot });
30
33
  const policy = checkPolicy(workspace);
@@ -50,6 +53,33 @@ export async function writeCheckedConfig(document, originalText, options, messag
50
53
  }
51
54
  }
52
55
 
56
+ export async function withPolicyMutationLock(configPath, operation) {
57
+ const canonicalPath = await realpath(configPath);
58
+ const lockPath = `${canonicalPath}.migrate.lock`;
59
+ const deadline = Date.now() + 5_000;
60
+ let handle;
61
+ while (handle === undefined) {
62
+ try {
63
+ handle = await open(lockPath, "wx", 0o600);
64
+ } catch (error) {
65
+ if (error?.code !== "EEXIST" || Date.now() >= deadline) {
66
+ throw error?.code === "EEXIST"
67
+ ? new Error("Another policy update is already using this config.")
68
+ : error;
69
+ }
70
+ await new Promise((resolve) => setTimeout(resolve, 10));
71
+ }
72
+ }
73
+ try {
74
+ await handle.writeFile(`${process.pid}\n`);
75
+ await handle.sync();
76
+ return await operation(canonicalPath);
77
+ } finally {
78
+ await handle.close();
79
+ await rm(lockPath, { force: true });
80
+ }
81
+ }
82
+
53
83
  function tempConfigPath(configPath) {
54
84
  return join(dirname(configPath), `.${basename(configPath)}.${randomUUID()}.tmp`);
55
85
  }
@@ -26,12 +26,14 @@ import {
26
26
  writeCheckedConfig
27
27
  } from "./config-write.mjs";
28
28
  import { canUseSkill } from "./can-use-guard.mjs";
29
+ import { assertV2MutationVersion } from "../compatibility.mjs";
29
30
 
30
31
  const WRITABLE_INVOCATIONS = new Set(["manual-only", "router-only", "workflow-auto", "global-auto"]);
31
32
  const NON_ACTIVATABLE_STATUSES = new Set(["blocked", "deprecated", "archived", "removed"]);
32
33
 
33
34
  export async function activateSkill(options) {
34
35
  const { document, originalText } = await loadConfig(options.configPath);
36
+ assertV2MutationVersion(document.get("version") ?? 1);
35
37
  const skill = requireConfigSkill(document, options.skillId);
36
38
  const workflow = requireConfigWorkflow(document, options.workflow);
37
39
  const mode = options.mode ?? readMapString(skill, "invocation", "manual-only");
@@ -55,6 +57,7 @@ export async function activateSkill(options) {
55
57
 
56
58
  export async function addSkill(options) {
57
59
  const { document, originalText } = await loadConfig(options.configPath);
60
+ assertV2MutationVersion(document.get("version") ?? 1);
58
61
  const skills = ensureMapAt(document, ["skills"], "skills");
59
62
  if (skills.get(options.skillId, true) !== undefined) {
60
63
  throw new Error(`Skill already exists: ${options.skillId}`);
@@ -147,6 +150,7 @@ export async function addSkillVariant(options) {
147
150
 
148
151
  export async function blockSkill(options) {
149
152
  const { document, originalText } = await loadConfig(options.configPath);
153
+ assertV2MutationVersion(document.get("version") ?? 1);
150
154
  requireConfigSkill(document, options.skillId);
151
155
  const workflow = requireConfigWorkflow(document, options.workflow);
152
156
 
@@ -188,6 +192,7 @@ export async function removeSkill(options) {
188
192
 
189
193
  export async function preferSkill(options) {
190
194
  const { document, originalText } = await loadConfig(options.configPath);
195
+ assertV2MutationVersion(document.get("version") ?? 1);
191
196
  const skill = requireConfigSkill(document, options.skillId);
192
197
  const workflow = requireConfigWorkflow(document, options.workflow);
193
198
  const capabilities = requireMapAt(document, ["capabilities"], "capabilities");
@@ -0,0 +1,175 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { supportedAgentNames } from "../agent-skill-roots.mjs";
3
+
4
+ export async function loadV2InventoryIndex(path) {
5
+ const text = await readFile(path, "utf8").catch((error) => {
6
+ if (error?.code === "ENOENT") {
7
+ return null;
8
+ }
9
+ throw error;
10
+ });
11
+ if (text === null) {
12
+ return { path, integrityErrors: [`generated inventory is missing: ${path}`], skillIds: [], skills: [], installUnits: [] };
13
+ }
14
+ try {
15
+ const value = JSON.parse(text);
16
+ const errors = validateInventory(value);
17
+ const skillIds = errors.length === 0
18
+ ? value.skills.map((skill) => skill.id).sort((left, right) => left.localeCompare(right))
19
+ : [];
20
+ const installUnits = errors.length === 0
21
+ ? (value.install_units ?? []).map(projectInstallUnitObservation)
22
+ : [];
23
+ return { path, integrityErrors: errors, skillIds, skills: errors.length === 0 ? value.skills : [], installUnits };
24
+ } catch (error) {
25
+ return { path, integrityErrors: [`generated inventory is invalid JSON: ${errorMessage(error)}`], skillIds: [], skills: [], installUnits: [] };
26
+ }
27
+ }
28
+
29
+ export function authorizeV2Skill(skillId, policy, inventory, agentName) {
30
+ if (typeof agentName !== "string" || agentName.length === 0) {
31
+ return {
32
+ allowed: false,
33
+ integrityError: false,
34
+ reasons: ["Current agent is required for version 2 availability checks."]
35
+ };
36
+ }
37
+ if (!supportedAgentNames().includes(agentName)) {
38
+ return {
39
+ allowed: false,
40
+ integrityError: false,
41
+ reasons: [`Unsupported agent: ${agentName}.`]
42
+ };
43
+ }
44
+ const integrityReasons = inventoryIntegrityReasons(inventory, skillId);
45
+ if (integrityReasons.length > 0) {
46
+ return { allowed: false, integrityError: true, reasons: integrityReasons };
47
+ }
48
+ if (policy === undefined) {
49
+ return { allowed: false, integrityError: false, reasons: ["Skill has no version 2 policy entry."] };
50
+ }
51
+ if (!policy.enabled) {
52
+ return { allowed: false, integrityError: false, reasons: [`Skill ${policy.id} is disabled.`] };
53
+ }
54
+ const observation = inventory.skills?.find((skill) => skill.id === skillId);
55
+ const installedOn = Array.isArray(observation?.installed_on) ? observation.installed_on : [];
56
+ if (!installedOn.includes(agentName)) {
57
+ return {
58
+ allowed: false,
59
+ integrityError: false,
60
+ reasons: [`Skill ${policy.id} is not installed for agent ${agentName}.`]
61
+ };
62
+ }
63
+ return { allowed: true, integrityError: false, reasons: [] };
64
+ }
65
+
66
+ function validateInventory(value) {
67
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
68
+ return ["generated inventory root must be an object"];
69
+ }
70
+ const errors = [];
71
+ if (value.format_version !== 1) errors.push("generated inventory format_version must be 1");
72
+ if (value.generated !== true) errors.push("generated inventory must declare generated: true");
73
+ if (value.authoritative_for_availability !== false) {
74
+ errors.push("generated inventory must declare authoritative_for_availability: false");
75
+ }
76
+ if (!Array.isArray(value.skills)) {
77
+ errors.push("generated inventory skills must be an array");
78
+ return errors;
79
+ }
80
+ const ids = new Set();
81
+ for (const [index, skill] of value.skills.entries()) {
82
+ if (skill === null || typeof skill !== "object" || Array.isArray(skill)) {
83
+ errors.push(`generated inventory skill ${index} must be an object`);
84
+ continue;
85
+ }
86
+ for (const field of ["id", "path", "owner_install_unit"]) {
87
+ if (typeof skill[field] !== "string" || skill[field].trim() === "") {
88
+ errors.push(`generated inventory skill ${index}.${field} must be a non-empty string`);
89
+ }
90
+ }
91
+ if (skill.installed_on !== undefined && (!Array.isArray(skill.installed_on)
92
+ || skill.installed_on.some((agent) => typeof agent !== "string" || agent.trim() === ""))) {
93
+ errors.push(`generated inventory skill ${index}.installed_on must be a list of agent names`);
94
+ }
95
+ if (typeof skill.id === "string") {
96
+ if (ids.has(skill.id)) errors.push(`generated inventory contains duplicate skill id: ${skill.id}`);
97
+ ids.add(skill.id);
98
+ }
99
+ }
100
+ if (value.install_units !== undefined && !Array.isArray(value.install_units)) {
101
+ errors.push("generated inventory install_units must be an array");
102
+ return errors;
103
+ }
104
+ const unitIds = new Set();
105
+ for (const [index, unit] of (value.install_units ?? []).entries()) {
106
+ if (unit === null || typeof unit !== "object" || Array.isArray(unit)) {
107
+ errors.push(`generated inventory install unit ${index} must be an object`);
108
+ continue;
109
+ }
110
+ if (typeof unit.id !== "string" || unit.id.trim() === "") {
111
+ errors.push(`generated inventory install unit ${index}.id must be a non-empty string`);
112
+ } else if (unitIds.has(unit.id)) {
113
+ errors.push(`generated inventory contains duplicate install unit id: ${unit.id}`);
114
+ } else {
115
+ unitIds.add(unit.id);
116
+ }
117
+ }
118
+ return errors;
119
+ }
120
+
121
+ function projectInstallUnitObservation(unit) {
122
+ const runtime = unit.runtime_components ?? {};
123
+ return {
124
+ id: unit.id,
125
+ kind: unit.kind ?? "skill",
126
+ sourceClass: unit.source_class,
127
+ priority: undefined,
128
+ trustLevel: unit.trust_observation ?? "unreviewed",
129
+ sourceDigest: unit.source_digest,
130
+ signature: unit.signature_observed === true ? "observed" : undefined,
131
+ publicKey: undefined,
132
+ verifiedAt: unit.verified_at,
133
+ source: unit.source ?? "",
134
+ scope: "inventory-observation",
135
+ manifestPath: unit.manifest_path ?? "",
136
+ cachePath: unit.cache_path ?? "",
137
+ providedComponents: [],
138
+ components: {
139
+ skills: stringList(unit.skills),
140
+ commands: stringList(runtime.commands),
141
+ hooks: stringList(runtime.hooks),
142
+ mcpServers: stringList(runtime.mcp_servers)
143
+ },
144
+ modifiedConfigFiles: [],
145
+ autoUpdate: false,
146
+ enabled: true,
147
+ workflowDependencies: [],
148
+ permissionRisk: unit.permission_risk ?? "unknown",
149
+ rollback: "observation-only"
150
+ };
151
+ }
152
+
153
+ function stringList(value) {
154
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
155
+ }
156
+
157
+ function errorMessage(error) {
158
+ return error instanceof Error ? error.message : String(error);
159
+ }
160
+
161
+ function inventoryIntegrityReasons(inventory, skillId) {
162
+ if (inventory === null || typeof inventory !== "object") {
163
+ return ["Inventory integrity error: generated inventory is unavailable."];
164
+ }
165
+ if (Array.isArray(inventory.integrityErrors) && inventory.integrityErrors.length > 0) {
166
+ return inventory.integrityErrors.map((reason) => `Inventory integrity error: ${reason}`);
167
+ }
168
+ const ids = inventory.skillIds instanceof Set
169
+ ? inventory.skillIds
170
+ : new Set(Array.isArray(inventory.skillIds) ? inventory.skillIds : []);
171
+ if (!ids.has(skillId)) {
172
+ return [`Inventory integrity error: skill ${skillId ?? "<unknown>"} is missing from generated inventory.`];
173
+ }
174
+ return [];
175
+ }
@@ -0,0 +1,32 @@
1
+ import { ensureMapAt, loadConfig, requireYamlMap, withPolicyMutationLock, writeCheckedConfig } from "./config-write.mjs";
2
+
3
+ export async function setV2SkillEnabled(options) {
4
+ return mutate(options, (skill) => skill.set("enabled", options.enabled), `${options.enabled ? "Enabled" : "Disabled"} ${options.skillId}`);
5
+ }
6
+
7
+ export async function setV2SkillShared(options) {
8
+ return mutate(options, (skill) => skill.set("shared", options.shared), `${options.shared ? "Shared" : "Unshared"} ${options.skillId}`);
9
+ }
10
+
11
+ export async function setV2SkillPreference(options) {
12
+ const intents = names(options.intents, "--intent");
13
+ if (!Number.isInteger(options.priority)) throw new Error("--priority must be an integer");
14
+ return mutate(options, (skill, document) => skill.set("preference", document.createNode({ intents, priority: options.priority })), `Updated preference for ${options.skillId}`);
15
+ }
16
+
17
+ async function mutate(options, update, message) {
18
+ return await withPolicyMutationLock(options.configPath, async (configPath) => {
19
+ const { document, originalText } = await loadConfig(configPath);
20
+ const skills = ensureMapAt(document, ["skills"], "skills");
21
+ const raw = skills.get(options.skillId, true);
22
+ if (raw === undefined) throw new Error(`Unknown skill: ${options.skillId}`);
23
+ update(requireYamlMap(raw, `skills.${options.skillId}`), document);
24
+ return await writeCheckedConfig(document, originalText, { ...options, configPath }, message);
25
+ });
26
+ }
27
+
28
+ function names(values, option) {
29
+ const result = [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))].sort();
30
+ if (result.length === 0) throw new Error(`${option} requires at least one value`);
31
+ return result;
32
+ }
@@ -0,0 +1,38 @@
1
+ import { loadWorkspace } from "../workspace.mjs";
2
+ import {
3
+ loadConfig,
4
+ requireYamlMap,
5
+ withPolicyMutationLock,
6
+ writeCheckedConfig
7
+ } from "./config-write.mjs";
8
+
9
+ export async function forgetV2Skill(options) {
10
+ return await withPolicyMutationLock(options.configPath, async (configPath) => {
11
+ const workspace = await loadWorkspace({
12
+ configPath,
13
+ inventoryPath: options.inventoryPath,
14
+ skillsRoot: options.skillsRoot
15
+ });
16
+ const policy = workspace.skills.find((skill) => skill.id === options.skillId);
17
+ if (policy === undefined) throw new Error(`Unknown skill: ${options.skillId}`);
18
+ if (workspace.inventory.integrityErrors.length > 0) {
19
+ throw new Error(`Cannot forget a skill while generated inventory is unhealthy: ${workspace.inventory.integrityErrors.join("; ")}`);
20
+ }
21
+ if (policy.shared) {
22
+ throw new Error(`Skill ${options.skillId} is shared. Run skillboard skill unshare ${options.skillId} before forgetting it.`);
23
+ }
24
+ if (workspace.inventory.skills.some((skill) => skill.id === options.skillId)) {
25
+ throw new Error(`Skill ${options.skillId} is still installed. Remove it from its owning agent before forgetting its policy.`);
26
+ }
27
+
28
+ const { document, originalText } = await loadConfig(configPath);
29
+ const skills = requireYamlMap(document.get("skills", true), "skills");
30
+ skills.delete(options.skillId);
31
+ return await writeCheckedConfig(
32
+ document,
33
+ originalText,
34
+ { ...options, configPath },
35
+ `Forgot ${options.skillId}`
36
+ );
37
+ });
38
+ }
@@ -7,7 +7,10 @@ import {
7
7
 
8
8
  export async function variantLifecycleStatus(options) {
9
9
  const workspace = await loadWorkspace({ configPath: options.configPath, skillsRoot: options.skillsRoot });
10
- const skill = workspace.skills.find((candidate) => candidate.id === options.variantId);
10
+ const policySkill = workspace.skills.find((candidate) => candidate.id === options.variantId);
11
+ const skill = workspace.version === 2
12
+ ? historicalVariantSkill(workspace, policySkill)
13
+ : policySkill;
11
14
  if (skill === undefined) {
12
15
  throw new Error(`Unknown skill: ${options.variantId}`);
13
16
  }
@@ -39,6 +42,49 @@ export async function variantLifecycleStatus(options) {
39
42
  };
40
43
  }
41
44
 
45
+ function historicalVariantSkill(workspace, policySkill) {
46
+ if (policySkill === undefined) return undefined;
47
+ const observed = workspace.inventory?.skills?.find((candidate) => candidate.id === policySkill.id);
48
+ const rawVariant = observed?.observations?.variant;
49
+ if (observed === undefined || rawVariant === undefined) return policySkill;
50
+ return {
51
+ ...policySkill,
52
+ path: observed.path,
53
+ variant: parseHistoricalVariant(rawVariant, policySkill.id)
54
+ };
55
+ }
56
+
57
+ function parseHistoricalVariant(raw, skillId) {
58
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
59
+ throw new Error(`Inventory variant observation for ${skillId} must be an object`);
60
+ }
61
+ const approved = raw.approved === undefined ? undefined : historicalCheckpoint(raw.approved, `${skillId}.approved`);
62
+ return {
63
+ of: requiredString(raw.of, `${skillId}.variant.of`),
64
+ adaptedFor: typeof raw.adapted_for === "string" ? raw.adapted_for : null,
65
+ capability: requiredString(raw.capability, `${skillId}.variant.capability`),
66
+ workflow: requiredString(raw.workflow, `${skillId}.variant.workflow`),
67
+ status: requiredString(raw.status, `${skillId}.variant.status`),
68
+ base: historicalCheckpoint(raw.base, `${skillId}.variant.base`),
69
+ ...(approved === undefined ? {} : { approved })
70
+ };
71
+ }
72
+
73
+ function historicalCheckpoint(raw, label) {
74
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
75
+ throw new Error(`Inventory variant checkpoint ${label} must be an object`);
76
+ }
77
+ return {
78
+ contentDigest: requiredString(raw.content_digest, `${label}.content_digest`),
79
+ snapshot: requiredString(raw.snapshot, `${label}.snapshot`)
80
+ };
81
+ }
82
+
83
+ function requiredString(value, label) {
84
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`);
85
+ return value;
86
+ }
87
+
42
88
  function computeStatus(lifecycleStatus, liveDigest, baseDigest, approvedDigest) {
43
89
  if (liveDigest === null) {
44
90
  return "missing-live-file";
package/src/control.mjs CHANGED
@@ -29,14 +29,22 @@ import { variantLifecycleStatus } from "./control/variant-status.mjs";
29
29
  import { addHarness, addWorkflow } from "./control/workflow-crud.mjs";
30
30
  import {
31
31
  GUARD_HOOK_MODE,
32
+ assertHookTargetContained,
32
33
  assertGuardHookPlanIsInstallable,
33
34
  buildGuardHookInstallPlan,
34
35
  planGuardHookInstall
35
36
  } from "./hook-plan.mjs";
36
37
 
37
38
  export { planGuardHookInstall } from "./hook-plan.mjs";
39
+ export { setV2SkillEnabled, setV2SkillPreference, setV2SkillShared } from "./control/v2-skill-crud.mjs";
40
+ export { forgetV2Skill } from "./control/v2-skill-forget.mjs";
38
41
 
39
42
  export function listSkills(workspace, options = {}) {
43
+ if (workspace.version === 2) {
44
+ return workspace.skills
45
+ .map((skill) => v2SkillSummary(workspace, skill))
46
+ .sort((left, right) => left.id.localeCompare(right.id));
47
+ }
40
48
  const workflow = options.workflow === undefined ? undefined : workflowByName(workspace, options.workflow);
41
49
  const skillIds = workflow === undefined
42
50
  ? new Set(workspace.skills.map((skill) => skill.id))
@@ -57,6 +65,9 @@ export function listSkills(workspace, options = {}) {
57
65
  }
58
66
 
59
67
  export function listWorkflows(workspace) {
68
+ if (workspace.version === 2) {
69
+ return [];
70
+ }
60
71
  return workspace.workflows.map((workflow) => ({
61
72
  name: workflow.name,
62
73
  harness: workflow.harness,
@@ -96,6 +107,12 @@ export function listInstallUnits(workspace) {
96
107
 
97
108
  export function explainSkill(workspace, skillId) {
98
109
  const skill = skillById(workspace, skillId);
110
+ if (workspace.version === 2) {
111
+ return {
112
+ ...v2SkillSummary(workspace, skill),
113
+ agents: v2InventoryObservation(workspace, skill.id).installed_on
114
+ };
115
+ }
99
116
  const source = classifySkillSource(workspace, skill);
100
117
  const workflows = workspace.workflows
101
118
  .map((workflow) => workflowSkillRole(workspace, workflow, skillId))
@@ -119,6 +136,43 @@ export function explainSkill(workspace, skillId) {
119
136
  };
120
137
  }
121
138
 
139
+ function v2SkillSummary(workspace, skill) {
140
+ const inventory = v2InventoryObservation(workspace, skill.id);
141
+ return {
142
+ id: skill.id,
143
+ enabled: skill.enabled,
144
+ shared: skill.shared,
145
+ preference: skill.preference,
146
+ path: inventory.path,
147
+ inventory,
148
+ variant: inventory.observations.variant ?? null
149
+ };
150
+ }
151
+
152
+ function v2InventoryObservation(workspace, skillId) {
153
+ const observed = workspace.inventory?.skills?.find((candidate) => candidate.id === skillId);
154
+ if (observed === undefined) {
155
+ return {
156
+ present: false, path: null, owner_install_unit: null, source: null,
157
+ category: null, description: null, content_digest: null, installed_on: [], aliases: [], observations: {}
158
+ };
159
+ }
160
+ return {
161
+ present: true,
162
+ path: observed.path,
163
+ owner_install_unit: observed.owner_install_unit,
164
+ source: observed.source ?? null,
165
+ category: observed.category ?? null,
166
+ description: observed.description ?? null,
167
+ content_digest: observed.content_digest ?? null,
168
+ installed_on: Array.isArray(observed.installed_on) ? observed.installed_on : [],
169
+ aliases: Array.isArray(observed.aliases) ? observed.aliases : [],
170
+ observations: observed.observations !== null && typeof observed.observations === "object" && !Array.isArray(observed.observations)
171
+ ? observed.observations
172
+ : {}
173
+ };
174
+ }
175
+
122
176
  export {
123
177
  activateSkill,
124
178
  addHarness,
@@ -143,6 +197,7 @@ export async function installGuardHook(options) {
143
197
  const { plan, script } = await buildGuardHookInstallPlan(options);
144
198
  assertGuardHookPlanIsInstallable(plan);
145
199
 
200
+ await assertHookTargetContained(options.configPath, plan.path);
146
201
  await mkdir(dirname(plan.path), { recursive: true });
147
202
  await assertNewNonSymlinkPath(plan.path);
148
203
  await writeFile(plan.path, script, { encoding: "utf8", flag: "wx", mode: GUARD_HOOK_MODE });