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,177 @@
1
+ import { constants } from "node:fs";
2
+ import { access, lstat, readFile, realpath } from "node:fs/promises";
3
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
4
+
5
+ const PACKAGE_NAME = "agent-skillboard";
6
+ const POSIX_COMMANDS = ["skillboard", "agent-skillboard"];
7
+ const WINDOWS_COMMANDS = [
8
+ "skillboard.cmd",
9
+ "skillboard.exe",
10
+ "skillboard",
11
+ "agent-skillboard.cmd",
12
+ "agent-skillboard.exe",
13
+ "agent-skillboard"
14
+ ];
15
+
16
+ export async function inspectInstallation(options = {}) {
17
+ const platform = options.platform ?? process.platform;
18
+ const pathDelimiter = options.pathDelimiter ?? delimiter;
19
+ const entrypoint = resolve(options.entrypointPath ?? process.argv[1] ?? "bin/skillboard.mjs");
20
+ const currentRealPath = await resolvedPath(entrypoint);
21
+ const currentPackage = await packageForEntrypoint(currentRealPath);
22
+ const pathCandidates = await findPathCandidates(options.env?.PATH ?? "", {
23
+ pathDelimiter,
24
+ platform,
25
+ currentRealPath,
26
+ currentPackageRoot: currentPackage?.root ?? null
27
+ });
28
+ const pathSelected = pathCandidates.find((candidate) => isPrimaryCommand(candidate.path, platform)) ?? null;
29
+ const installations = uniqueInstallations(pathCandidates);
30
+ const duplicateInstallations = installations.length > 1;
31
+ const shadowed = pathSelected !== null && !pathSelected.current;
32
+ const current = {
33
+ version: options.packageVersion ?? currentPackage?.version ?? null,
34
+ entrypoint,
35
+ realPath: currentRealPath,
36
+ packageRoot: currentPackage?.root ?? null
37
+ };
38
+ const warnings = installWarnings({ current, pathSelected, installations, duplicateInstallations, shadowed });
39
+ return {
40
+ current,
41
+ pathSelected,
42
+ pathCandidates,
43
+ installations,
44
+ duplicateInstallations,
45
+ shadowed,
46
+ warnings
47
+ };
48
+ }
49
+
50
+ async function findPathCandidates(pathValue, options) {
51
+ const commands = options.platform === "win32" ? WINDOWS_COMMANDS : POSIX_COMMANDS;
52
+ const directories = pathValue.split(options.pathDelimiter).filter((entry) => entry.trim() !== "");
53
+ const candidates = [];
54
+ const observedPaths = new Set();
55
+ for (const directory of directories) {
56
+ for (const command of commands) {
57
+ const path = resolve(directory, command);
58
+ if (observedPaths.has(path) || !await isExecutable(path, options.platform)) continue;
59
+ observedPaths.add(path);
60
+ candidates.push(await inspectCandidate(path, options));
61
+ }
62
+ }
63
+ return candidates;
64
+ }
65
+
66
+ async function inspectCandidate(path, options) {
67
+ const realPath = await resolvedPath(path);
68
+ const packageMetadata = await packageForCandidate(path, realPath);
69
+ const packageRoot = packageMetadata?.root ?? null;
70
+ return {
71
+ path,
72
+ realPath,
73
+ packageRoot,
74
+ version: packageMetadata?.version ?? null,
75
+ current: realPath === options.currentRealPath
76
+ || (packageRoot !== null && packageRoot === options.currentPackageRoot)
77
+ };
78
+ }
79
+
80
+ async function packageForCandidate(path, realPath) {
81
+ const fromTarget = await packageForEntrypoint(realPath);
82
+ if (fromTarget !== null) return fromTarget;
83
+ const commandDirectory = dirname(path);
84
+ for (const root of [
85
+ join(commandDirectory, "node_modules", PACKAGE_NAME),
86
+ resolve(commandDirectory, "..", PACKAGE_NAME)
87
+ ]) {
88
+ const metadata = await readPackage(root);
89
+ if (metadata !== null) return metadata;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ async function packageForEntrypoint(path) {
95
+ let directory = dirname(path);
96
+ for (let depth = 0; depth < 6; depth += 1) {
97
+ const metadata = await readPackage(directory);
98
+ if (metadata !== null) return metadata;
99
+ const parent = dirname(directory);
100
+ if (parent === directory) break;
101
+ directory = parent;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ async function readPackage(root) {
107
+ try {
108
+ const parsed = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
109
+ if (parsed?.name !== PACKAGE_NAME || typeof parsed.version !== "string") return null;
110
+ return { root: await realpath(root), version: parsed.version };
111
+ } catch (error) {
112
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return null;
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ async function isExecutable(path, platform) {
118
+ try {
119
+ const stats = await lstat(path);
120
+ if (!stats.isFile() && !stats.isSymbolicLink()) return false;
121
+ await access(path, platform === "win32" ? constants.F_OK : constants.X_OK);
122
+ return true;
123
+ } catch (error) {
124
+ if (["EACCES", "ELOOP", "ENOENT", "ENOTDIR"].includes(error?.code)) return false;
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ async function resolvedPath(path) {
130
+ try {
131
+ return await realpath(path);
132
+ } catch (error) {
133
+ if (error?.code === "ENOENT") return resolve(path);
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ function uniqueInstallations(candidates) {
139
+ const installations = [];
140
+ const observed = new Set();
141
+ for (const candidate of candidates) {
142
+ if (candidate.packageRoot === null || observed.has(candidate.packageRoot)) continue;
143
+ observed.add(candidate.packageRoot);
144
+ installations.push(candidate);
145
+ }
146
+ return installations;
147
+ }
148
+
149
+ function isPrimaryCommand(path, platform) {
150
+ const command = basename(path).toLowerCase();
151
+ return platform === "win32"
152
+ ? ["skillboard", "skillboard.cmd", "skillboard.exe"].includes(command)
153
+ : command === "skillboard";
154
+ }
155
+
156
+ function installWarnings(result) {
157
+ const warnings = [];
158
+ if (result.shadowed) {
159
+ warnings.push(
160
+ `PATH selects SkillBoard ${displayVersion(result.pathSelected.version)} at ${result.pathSelected.path} instead of this ${displayVersion(result.current.version)} invocation at ${result.current.entrypoint}.`
161
+ );
162
+ }
163
+ if (result.duplicateInstallations) {
164
+ warnings.push(
165
+ `Multiple SkillBoard installations were found on PATH: ${result.installations.map(displayInstallation).join(", ")}. Keep one npm prefix active and uninstall stale copies with the npm installation that owns them.`
166
+ );
167
+ }
168
+ return warnings;
169
+ }
170
+
171
+ function displayVersion(version) {
172
+ return version === null ? "with unknown version" : `version ${version}`;
173
+ }
174
+
175
+ function displayInstallation(candidate) {
176
+ return `${candidate.version ?? "unknown"} at ${candidate.path}`;
177
+ }
@@ -0,0 +1,63 @@
1
+ const TRUST_RANK = new Map([["blocked", 0], ["unreviewed", 1], ["reviewed", 2], ["trusted", 3]]);
2
+ const RISK_RANK = new Map([["high", 0], ["medium", 1], ["unknown", 2], ["low", 3]]);
3
+
4
+ export function coalesceInventoryInstallUnits(records) {
5
+ const groups = new Map();
6
+ for (const record of records) {
7
+ const group = groups.get(record.id) ?? [];
8
+ group.push(record);
9
+ groups.set(record.id, group);
10
+ }
11
+ return [...groups.values()].map(coalesceGroup).sort(byId);
12
+ }
13
+
14
+ function coalesceGroup(records) {
15
+ const sorted = [...records].sort((left, right) => stableKey(left).localeCompare(stableKey(right)));
16
+ const first = sorted[0];
17
+ const runtime = sorted.map((record) => record.runtime_components ?? {});
18
+ return compact({
19
+ ...first,
20
+ trust_observation: rankedValue(sorted.map((record) => record.trust_observation), TRUST_RANK),
21
+ permission_risk: rankedValue(sorted.map((record) => record.permission_risk), RISK_RANK),
22
+ signature_observed: sorted.some((record) => record.signature_observed === true),
23
+ runtime_components: {
24
+ commands: combined(runtime.map((value) => value.commands)),
25
+ hooks: combined(runtime.map((value) => value.hooks)),
26
+ mcp_servers: combined(runtime.map((value) => value.mcp_servers))
27
+ },
28
+ skills: combined(sorted.map((record) => record.skills)),
29
+ alias_skills: combined(sorted.map((record) => record.alias_skills)),
30
+ source_observations: observed(sorted, "source"),
31
+ manifest_path_observations: observed(sorted, "manifest_path"),
32
+ cache_path_observations: observed(sorted, "cache_path"),
33
+ source_digest_observations: observed(sorted, "source_digest")
34
+ });
35
+ }
36
+
37
+ function rankedValue(values, ranks) {
38
+ return values
39
+ .filter((value) => typeof value === "string" && value.length > 0)
40
+ .sort((left, right) => (ranks.get(left) ?? Number.MAX_SAFE_INTEGER) - (ranks.get(right) ?? Number.MAX_SAFE_INTEGER)
41
+ || left.localeCompare(right))[0];
42
+ }
43
+
44
+ function observed(records, key) {
45
+ const values = combined(records.map((record) => [record[key]]));
46
+ return values.length > 1 ? values : undefined;
47
+ }
48
+
49
+ function combined(groups) {
50
+ return [...new Set(groups.flat().filter((value) => typeof value === "string" && value.length > 0))].sort();
51
+ }
52
+
53
+ function compact(value) {
54
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
55
+ }
56
+
57
+ function stableKey(value) {
58
+ return JSON.stringify(value, Object.keys(value).sort());
59
+ }
60
+
61
+ function byId(left, right) {
62
+ return left.id.localeCompare(right.id);
63
+ }
@@ -0,0 +1,279 @@
1
+ import { basename, isAbsolute, relative, resolve } from "node:path";
2
+ import YAML from "yaml";
3
+ import { coalesceInventoryInstallUnits } from "./inventory-install-units.mjs";
4
+ import { normalizeSkillPath } from "./skill-paths.mjs";
5
+ import { skillContentDigest } from "./source-verification.mjs";
6
+
7
+ const FORMAT_VERSION = 1;
8
+
9
+ export async function buildGeneratedInventory(inventory, options = {}) {
10
+ const redactions = redactionContext(options);
11
+ const integrityErrors = [];
12
+ const skills = [];
13
+ for (const skill of [...(inventory.skills ?? [])].sort(byId)) {
14
+ try {
15
+ skills.push(await inventorySkillRecord(skill, redactions));
16
+ } catch (error) {
17
+ integrityErrors.push(`${skill?.id || "<missing-id>"}: ${errorMessage(error)}`);
18
+ }
19
+ }
20
+ if (integrityErrors.length > 0) {
21
+ throw new Error(`Inventory integrity error:\n${integrityErrors.join("\n")}`);
22
+ }
23
+ const installUnits = coalesceInventoryInstallUnits([...(inventory.installUnits ?? [])]
24
+ .sort(byId)
25
+ .map((unit) => inventoryInstallUnitRecord(unit, redactions)));
26
+ return {
27
+ format_version: FORMAT_VERSION,
28
+ generated: true,
29
+ authoritative_for_availability: false,
30
+ skills,
31
+ install_units: installUnits,
32
+ redactions: {
33
+ path_count: redactions.pathCount,
34
+ warnings: [...redactions.warnings].sort()
35
+ }
36
+ };
37
+ }
38
+
39
+ export function renderGeneratedInventory(value) {
40
+ return `${JSON.stringify(value, null, 2)}\n`;
41
+ }
42
+
43
+ export function mergeGeneratedInventory(existingText, added, options = {}) {
44
+ const existing = parseExistingInventory(existingText);
45
+ const replace = options.replace === true;
46
+ const skills = mergeRecords(existing.skills, added.skills ?? [], replace);
47
+ const installUnits = mergeRecords(existing.install_units ?? [], added.install_units ?? [], replace);
48
+ const warnings = uniqueStrings([
49
+ ...(existing.redactions?.warnings ?? []),
50
+ ...(added.redactions?.warnings ?? [])
51
+ ]);
52
+ return renderGeneratedInventory({
53
+ ...existing,
54
+ format_version: FORMAT_VERSION,
55
+ generated: true,
56
+ authoritative_for_availability: false,
57
+ skills,
58
+ install_units: installUnits,
59
+ redactions: {
60
+ path_count: countPortablePaths(skills, installUnits),
61
+ warnings
62
+ }
63
+ });
64
+ }
65
+
66
+ export function mergeV2InventoryPolicy(configText, inventory) {
67
+ const document = YAML.parseDocument(configText);
68
+ if (document.errors.length > 0) {
69
+ throw new Error(`Invalid YAML config: ${document.errors.map((error) => error.message).join("; ")}`);
70
+ }
71
+ if (document.get("version") !== 2) {
72
+ throw new Error("v2 inventory policy projection requires config version 2");
73
+ }
74
+ let skills = document.get("skills", true);
75
+ if (skills === undefined) {
76
+ skills = document.createNode({});
77
+ document.set("skills", skills);
78
+ }
79
+ if (!YAML.isMap(skills)) {
80
+ throw new Error("skills must be a mapping");
81
+ }
82
+ const addedSkills = [];
83
+ for (const skill of [...(inventory.skills ?? [])].sort(byId)) {
84
+ validateSkillIdentity(skill);
85
+ if (skills.get(skill.id, true) !== undefined) {
86
+ continue;
87
+ }
88
+ skills.set(skill.id, document.createNode({ enabled: true, shared: false }));
89
+ addedSkills.push(skill.id);
90
+ }
91
+ const parsed = document.toJS();
92
+ const policyProjection = Object.fromEntries(
93
+ Object.entries(parsed.skills ?? {}).map(([id, policy]) => [id, policy])
94
+ );
95
+ const text = preserveLineEndings(String(document), configText);
96
+ return { text, changed: text !== configText, addedSkills, policyProjection };
97
+ }
98
+
99
+ async function inventorySkillRecord(skill, redactions) {
100
+ validateSkillIdentity(skill);
101
+ const aliases = uniqueAliases(skill.sourceAliases ?? []);
102
+ let contentDigest = null;
103
+ if (typeof skill.skillFile === "string" && skill.skillFile.length > 0) {
104
+ if (!isAbsolute(skill.skillFile)) {
105
+ throw new Error("skill file must be absolute");
106
+ }
107
+ contentDigest = await skillContentDigest(skill.skillFile);
108
+ }
109
+ return compact({
110
+ id: skill.id,
111
+ path: normalizeSkillPath(skill.path, `inventory skill ${skill.id} path`),
112
+ owner_install_unit: nonEmptyString(skill.ownerInstallUnit, `inventory skill ${skill.id} owner install unit`),
113
+ source: portableSource(skill.source, redactions),
114
+ category: skill.category,
115
+ description: skill.description,
116
+ content_digest: contentDigest,
117
+ installed_on: installedAgents(skill),
118
+ aliases
119
+ });
120
+ }
121
+
122
+ function installedAgents(skill) {
123
+ return uniqueStrings([
124
+ agentForInstallUnit(skill.ownerInstallUnit),
125
+ ...(skill.sourceAliases ?? []).map((alias) => agentForInstallUnit(alias.ownerInstallUnit))
126
+ ]);
127
+ }
128
+
129
+ function agentForInstallUnit(unitId) {
130
+ if (typeof unitId !== "string") return "";
131
+ for (const agent of ["codex", "claude", "opencode", "hermes"]) {
132
+ if (unitId === agent || unitId.startsWith(`${agent}.`)) return agent;
133
+ }
134
+ return "";
135
+ }
136
+
137
+ function inventoryInstallUnitRecord(unit, redactions) {
138
+ return compact({
139
+ id: nonEmptyString(unit.id, "inventory install unit id"),
140
+ kind: unit.kind,
141
+ source: portableSource(unit.source, redactions),
142
+ source_class: unit.sourceClass,
143
+ manifest_path: portablePath(unit.manifestPath, redactions),
144
+ cache_path: portablePath(unit.cachePath, redactions),
145
+ trust_observation: unit.trustLevel,
146
+ permission_risk: unit.permissionRisk,
147
+ source_digest: unit.sourceDigest,
148
+ signature_observed: typeof unit.signature === "string" && unit.signature.length > 0,
149
+ runtime_components: {
150
+ commands: uniqueStrings(unit.commands ?? unit.components?.commands ?? []),
151
+ hooks: uniqueStrings(unit.hooks ?? unit.components?.hooks ?? []),
152
+ mcp_servers: uniqueStrings(unit.mcpServers ?? unit.components?.mcpServers ?? [])
153
+ },
154
+ skills: uniqueStrings(unit.skills ?? unit.components?.skills ?? []),
155
+ alias_skills: uniqueStrings(unit.sourceAliasSkills ?? [])
156
+ });
157
+ }
158
+
159
+ function validateSkillIdentity(skill) {
160
+ if (skill === null || typeof skill !== "object") {
161
+ throw new Error("inventory skill must be an object");
162
+ }
163
+ nonEmptyString(skill.id, "inventory skill id");
164
+ normalizeSkillPath(skill.path, `inventory skill ${skill.id} path`);
165
+ nonEmptyString(skill.ownerInstallUnit, `inventory skill ${skill.id} owner install unit`);
166
+ }
167
+
168
+ function uniqueAliases(aliases) {
169
+ const seen = new Set();
170
+ const result = [];
171
+ for (const alias of aliases) {
172
+ const owner = nonEmptyString(alias.ownerInstallUnit, "inventory alias owner install unit");
173
+ const path = normalizeSkillPath(alias.path, "inventory alias path");
174
+ const key = `${owner}\0${path}`;
175
+ if (!seen.has(key)) {
176
+ seen.add(key);
177
+ result.push({ owner_install_unit: owner, path });
178
+ }
179
+ }
180
+ return result.sort((left, right) => `${left.owner_install_unit}\0${left.path}`.localeCompare(`${right.owner_install_unit}\0${right.path}`));
181
+ }
182
+
183
+ function uniqueStrings(values) {
184
+ return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))].sort();
185
+ }
186
+
187
+ function mergeRecords(existing, added, replace) {
188
+ const records = new Map(existing.map((record) => [record.id, record]));
189
+ for (const record of added) {
190
+ if (replace || !records.has(record.id)) records.set(record.id, record);
191
+ }
192
+ return [...records.values()].sort(byId);
193
+ }
194
+
195
+ function parseExistingInventory(text) {
196
+ if (text === null || text === undefined || text.trim() === "") {
197
+ return { skills: [], install_units: [] };
198
+ }
199
+ const value = JSON.parse(text);
200
+ if (value?.format_version !== FORMAT_VERSION || value.generated !== true || value.authoritative_for_availability !== false) {
201
+ throw new Error("Existing generated inventory has an unsupported format");
202
+ }
203
+ if (!Array.isArray(value.skills) || (value.install_units !== undefined && !Array.isArray(value.install_units))) {
204
+ throw new Error("Existing generated inventory records must be arrays");
205
+ }
206
+ return value;
207
+ }
208
+
209
+ function countPortablePaths(skills, installUnits) {
210
+ return [
211
+ ...skills.map((skill) => skill.source),
212
+ ...installUnits.flatMap((unit) => [
213
+ ...(unit.source_observations ?? [unit.source]),
214
+ ...(unit.manifest_path_observations ?? [unit.manifest_path]),
215
+ ...(unit.cache_path_observations ?? [unit.cache_path])
216
+ ])
217
+ ].filter((value) => typeof value === "string" && /^(?:\$\{PROJECT\}|\$\{HOME\}|<external>)(?:\/|$)/u.test(value)).length;
218
+ }
219
+
220
+ function redactionContext(options) {
221
+ return {
222
+ root: options.root === undefined ? undefined : resolve(options.root),
223
+ home: options.home === undefined ? undefined : resolve(options.home),
224
+ pathCount: 0,
225
+ warnings: new Set()
226
+ };
227
+ }
228
+
229
+ function portableSource(value, context) {
230
+ return typeof value === "string" && (isAbsolute(value) || value.startsWith("~/"))
231
+ ? portablePath(value, context)
232
+ : value;
233
+ }
234
+
235
+ function portablePath(value, context) {
236
+ if (typeof value !== "string" || value.length === 0) return value;
237
+ if (value.startsWith("~/")) {
238
+ context.pathCount += 1;
239
+ return `\${HOME}/${value.slice(2)}`;
240
+ }
241
+ if (!isAbsolute(value)) return value;
242
+ context.pathCount += 1;
243
+ const projectPath = tokenizedPath(value, context.root, "PROJECT");
244
+ if (projectPath !== null) return projectPath;
245
+ const homePath = tokenizedPath(value, context.home, "HOME");
246
+ if (homePath !== null) return homePath;
247
+ context.warnings.add("Generated inventory redacted one or more external absolute paths.");
248
+ return `<external>/${basename(value)}`;
249
+ }
250
+
251
+ function tokenizedPath(path, base, token) {
252
+ if (base === undefined) return null;
253
+ const rel = relative(base, resolve(path));
254
+ if (rel.startsWith("..") || isAbsolute(rel)) return null;
255
+ return rel === "" ? `\${${token}}` : `\${${token}}/${rel.replace(/\\/g, "/")}`;
256
+ }
257
+
258
+ function compact(value) {
259
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
260
+ }
261
+
262
+ function nonEmptyString(value, label) {
263
+ if (typeof value !== "string" || value.trim() === "") {
264
+ throw new Error(`${label} must be a non-empty string`);
265
+ }
266
+ return value;
267
+ }
268
+
269
+ function byId(left, right) {
270
+ return String(left?.id ?? "").localeCompare(String(right?.id ?? ""));
271
+ }
272
+
273
+ function preserveLineEndings(text, original) {
274
+ return original.includes("\r\n") ? text.replace(/(?<!\r)\n/g, "\r\n") : text;
275
+ }
276
+
277
+ function errorMessage(error) {
278
+ return error instanceof Error ? error.message : String(error);
279
+ }