loadout-ai 0.1.2 → 0.2.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.
@@ -1,5 +1,5 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { fetchRepositorySnapshot } from "./source.js";
4
4
  import { repositoryCachePath } from "./source.js";
5
5
  import { diffRepositorySnapshots } from "./diff.js";
@@ -7,7 +7,83 @@ import { hashDirectory, readInstallState } from "./state.js";
7
7
  import { analyzeUpdateSafety } from "./safety.js";
8
8
  import { detectAgents, loadoutHome } from "./paths.js";
9
9
  import { applySkillInstall, buildSkillPlan, installedAgents, } from "./install.js";
10
- import { validateSkillDirectory } from "./skills.js";
10
+ import { discoverSkillDirectories, validateSkillDirectory, } from "./skills.js";
11
+ function managedUnitIds(state, packageId) {
12
+ return [
13
+ ...new Set((state.activations ?? [])
14
+ .filter((activation) => activation.packageId === packageId &&
15
+ activation.installationState === "installed")
16
+ .map((activation) => activation.unitId ??
17
+ basename(activation.targets[0]?.activePath ?? packageId))
18
+ .filter(Boolean)),
19
+ ].sort();
20
+ }
21
+ async function locateManagedSkills(root, unitIds) {
22
+ const normalized = new Map(unitIds.map((unitId) => [unitId.toLowerCase(), unitId]));
23
+ const located = new Map();
24
+ await discoverSkillDirectories(root, {
25
+ validate: false,
26
+ include: (skill) => {
27
+ const unitId = (skill.name ? normalized.get(skill.name.toLowerCase()) : undefined) ??
28
+ normalized.get(skill.targetName.toLowerCase());
29
+ if (!unitId)
30
+ return false;
31
+ located.set(unitId, skill.path);
32
+ return true;
33
+ },
34
+ });
35
+ return located;
36
+ }
37
+ async function analyzeManagedUpdate(oldRoot, newRoot, unitIds) {
38
+ if (!unitIds.length) {
39
+ const [diff, safety] = await Promise.all([
40
+ diffRepositorySnapshots(oldRoot, newRoot),
41
+ analyzeUpdateSafety(oldRoot, newRoot),
42
+ ]);
43
+ return {
44
+ diff,
45
+ safetyFindings: safety.findings,
46
+ approvalRequired: safety.approvalRequired,
47
+ };
48
+ }
49
+ const [oldSkills, newSkills] = await Promise.all([
50
+ locateManagedSkills(oldRoot, unitIds),
51
+ locateManagedSkills(newRoot, unitIds),
52
+ ]);
53
+ const diff = [];
54
+ const findings = [];
55
+ for (const unitId of unitIds) {
56
+ const oldPath = oldSkills.get(unitId);
57
+ const newPath = newSkills.get(unitId);
58
+ if (!oldPath || !newPath) {
59
+ findings.push({
60
+ severity: "blocking",
61
+ category: "instruction",
62
+ message: `Managed skill '${unitId}' is missing from one update revision.`,
63
+ paths: [unitId],
64
+ });
65
+ continue;
66
+ }
67
+ diff.push(...(await diffRepositorySnapshots(oldPath, newPath)).map((item) => ({
68
+ ...item,
69
+ path: `${unitId}/${item.path}`,
70
+ })));
71
+ const safety = await analyzeUpdateSafety(oldPath, newPath);
72
+ findings.push(...safety.findings.map((finding) => ({
73
+ ...finding,
74
+ paths: finding.paths.map((path) => `${unitId}/${path}`),
75
+ })));
76
+ }
77
+ const unique = new Map();
78
+ for (const finding of findings)
79
+ unique.set(`${finding.severity}:${finding.category}:${finding.message}:${finding.paths.join(",")}:${finding.names?.join(",") ?? ""}`, finding);
80
+ const safetyFindings = [...unique.values()];
81
+ return {
82
+ diff,
83
+ safetyFindings,
84
+ approvalRequired: safetyFindings.some((finding) => finding.severity === "blocking"),
85
+ };
86
+ }
11
87
  /** Builds a read-only update plan from persisted installs and live GitHub snapshots. */
12
88
  export async function buildUpdatePlan(resolver = async (repository) => fetchRepositorySnapshot(repository)) {
13
89
  const state = await readInstallState();
@@ -39,10 +115,10 @@ export async function buildUpdatePlan(resolver = async (repository) => fetchRepo
39
115
  let approvalRequired = false;
40
116
  if (!same && current.path) {
41
117
  const oldPath = repositoryCachePath(record.repository, record.resolvedCommit);
42
- diff = await diffRepositorySnapshots(oldPath, current.path);
43
- const safety = await analyzeUpdateSafety(oldPath, current.path);
44
- safetyFindings = safety.findings;
45
- approvalRequired = safety.approvalRequired;
118
+ const analysis = await analyzeManagedUpdate(oldPath, current.path, managedUnitIds(state, record.packageId));
119
+ diff = analysis.diff;
120
+ safetyFindings = analysis.safetyFindings;
121
+ approvalRequired = analysis.approvalRequired;
46
122
  }
47
123
  return {
48
124
  ...base,
@@ -148,18 +224,32 @@ export async function applyPackageUpdate(packageId, options = {}, runtime = {})
148
224
  if (current.commit.toLowerCase() === record.resolvedCommit.toLowerCase())
149
225
  throw new Error(`Package '${packageId}' is already up to date`);
150
226
  const oldPath = repositoryCachePath(record.repository, record.resolvedCommit);
151
- const safety = await analyzeUpdateSafety(oldPath, current.path);
227
+ const units = managedUnitIds(state, packageId);
228
+ const safety = await analyzeManagedUpdate(oldPath, current.path, units);
152
229
  if (safety.approvalRequired && !options.approveRisk) {
153
230
  const quarantinePath = options.quarantineOnBlock === false
154
231
  ? undefined
155
- : await quarantineUpdate(packageId, current.repository, current.commit, safety.findings);
156
- throw new Error(`Update is blocked pending explicit risk approval${quarantinePath ? `; quarantined at ${quarantinePath}` : ""}: ${safety.findings
232
+ : await quarantineUpdate(packageId, current.repository, current.commit, safety.safetyFindings);
233
+ throw new Error(`Update is blocked pending explicit risk approval${quarantinePath ? `; quarantined at ${quarantinePath}` : ""}: ${safety.safetyFindings
157
234
  .filter((finding) => finding.severity === "blocking")
158
235
  .map((finding) => finding.message)
159
236
  .join(" ")}`);
160
237
  }
161
238
  const agents = installedAgents(await (runtime.detectAgents ?? detectAgents)(), record.targetAgents);
162
- const plan = await (runtime.buildPlan ?? buildSkillPlan)(current.path, record.packageId, agents);
239
+ const plan = await (runtime.buildPlan ?? buildSkillPlan)(current.path, record.packageId, agents, units.length
240
+ ? {
241
+ include: (skill) => units.some((unitId) => unitId.toLowerCase() === skill.name?.toLowerCase() ||
242
+ unitId.toLowerCase() === skill.targetName.toLowerCase()),
243
+ }
244
+ : {});
245
+ if (units.length) {
246
+ const plannedUnits = [
247
+ ...new Set(plan.files.map((file) => basename(file.target))),
248
+ ].sort();
249
+ if (plannedUnits.length !== units.length ||
250
+ plannedUnits.some((unitId, index) => unitId.toLowerCase() !== units[index].toLowerCase()))
251
+ throw new Error(`Update plan changed the managed skill set for '${packageId}'; expected ${units.join(", ")}, received ${plannedUnits.join(", ") || "none"}.`);
252
+ }
163
253
  const verifier = runtime.verify ?? verifyInstalledSkills;
164
254
  let verificationFailure;
165
255
  let verificationSnapshotId;
@@ -167,6 +257,17 @@ export async function applyPackageUpdate(packageId, options = {}, runtime = {})
167
257
  const snapshotId = await applySkillInstall(plan, {
168
258
  repository: current.repository,
169
259
  resolvedCommit: current.commit,
260
+ reviewed: true,
261
+ staticAssessment: {
262
+ status: safety.approvalRequired
263
+ ? "blocking"
264
+ : safety.safetyFindings.length
265
+ ? "warning"
266
+ : "clear",
267
+ findingCount: safety.safetyFindings.length,
268
+ assessedAt: new Date().toISOString(),
269
+ policy: "install-safety-v1",
270
+ },
170
271
  }, {
171
272
  allowManagedReplacement: true,
172
273
  replaceManagedTargets: true,
@@ -33,6 +33,7 @@ export async function planUpgrade(selection, options = {}) {
33
33
  ? { fetchSnapshot: options.fetchSnapshot }
34
34
  : {}),
35
35
  ...(options.onProgress ? { onProgress: options.onProgress } : {}),
36
+ ...(options.access ? { access: options.access } : {}),
36
37
  }),
37
38
  outcomes(),
38
39
  ]);
@@ -257,6 +257,14 @@ export const installRecordSchema = z
257
257
  files: z.array(fileHashSchema),
258
258
  snapshotId: text,
259
259
  installedAt: text,
260
+ staticAssessment: z
261
+ .object({
262
+ status: z.enum(["clear", "warning", "blocking"]),
263
+ findingCount: z.number().int().nonnegative(),
264
+ assessedAt: z.iso.datetime(),
265
+ policy: text,
266
+ })
267
+ .optional(),
260
268
  })
261
269
  .passthrough();
262
270
  export const mcpInstallRecordSchema = z