@multileaf/ai-workflow 0.1.0 → 0.1.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.
package/dist/workflow.js CHANGED
@@ -1,6 +1,8 @@
1
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
1
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
2
  import { BASE_DIRECTORIES, WORKFLOW_DIRECTORY } from "./constants.js";
3
- import { detectTarget, generateAiInit, isTarget, renderAiInitPath } from "./adapter.js";
3
+ import { detectTarget, isTarget, renderAiInit, renderAiInitPath, renderResourcePath, } from "./adapter.js";
4
+ import { loadBootstrapResources } from "./bootstrap-resources.js";
5
+ import { assertSafeProjectPath, withSafeProjectWrites, writeProjectFilesAtomically, } from "./files.js";
4
6
  import { parseProjectProfile, profileProject } from "./profile.js";
5
7
  import { serializeOverrides } from "./confirmation.js";
6
8
  import { applyOverrides, parseOverrides } from "./confirmation.js";
@@ -12,16 +14,17 @@ import { createInterface } from "node:readline/promises";
12
14
  import { stdin, stdout } from "node:process";
13
15
  import { generateProjectResources } from "./generator.js";
14
16
  import { detectDrift } from "./drift.js";
15
- import { parseManifest } from "./manifest.js";
17
+ import { parseManifest, serializeManifest } from "./manifest.js";
18
+ import { createOwnedFile, parseOwnership, serializeOwnership, } from "./ownership.js";
16
19
  import { validatePackageContract } from "./package-contract.js";
17
20
  import { parseLock, resolvePackage, serializeLock } from "./lockfile.js";
18
21
  import { executeVercelSkills, installVercelSkill, nodeCommandExecutor } from "./vercel-skills.js";
19
22
  import { scanProject } from "./scanner.js";
20
- import { assertKnownPermissions, assertPackagePermissions, serializePermissionReview, } from "./package-audit.js";
23
+ import { assertKnownPermissions, assertPermissions, assertPackagePermissions, serializePermissionReview, } from "./package-audit.js";
21
24
  import { planPackageUpdate } from "./package-updates.js";
22
25
  import { searchRegistry, serializeRegistry } from "./registry.js";
23
- import { checksumPackage, verifyPackageProvenance } from "./package-integrity.js";
24
- import { nodePackageSourceLoader, resolveProvider } from "./providers.js";
26
+ import { checksumPackage, checksumPackageSnapshot, verifyPackageProvenance, } from "./package-integrity.js";
27
+ import { nodePackageSourceLoader, requiresExternalNetwork, resolveProvider } from "./providers.js";
25
28
  import { serializeSelfValidationEvidence, validateTicketId } from "./self-validation.js";
26
29
  import { enforceOrganizationPolicy, parseOrganizationPolicy, serializeOrganizationPolicy, } from "./organization-policy.js";
27
30
  import { composeContext, CONTEXT_LAYERS, readContextStore, writeContextLayer, } from "./context-store.js";
@@ -32,7 +35,7 @@ import { measureContextQuality, serializeContextQuality } from "./context-qualit
32
35
  import { serializeAdapterCapabilities } from "./adapter-contract.js";
33
36
  import { buildTraceabilityGraph, serializeTraceabilityGraph } from "./traceability.js";
34
37
  import { evaluateQualityGate } from "./quality-gates.js";
35
- import { parseMigrationSnapshots, planNeutralResourceMigration, readResourceBytes, serializeMigrationPreview, serializeMigrationSnapshots, writeResourceBytes, } from "./migration.js";
38
+ import { parseMigrationCheckpoint, planNeutralResourceMigration, readResourceBytes, serializeMigrationPreview, serializeMigrationCheckpoint, validateNeutralResourceDocument, writeResourceBytes, } from "./migration.js";
36
39
  import { parsePrivateRegistries, parseTeamPreset, serializePrivateRegistries, serializeTeamPreset, fetchPrivateRegistryClient, } from "./team-configuration.js";
37
40
  import { enforcePolicyGate, gitPolicyTracking } from "./policy-gate.js";
38
41
  import { DEFAULT_TELEMETRY_CONFIG, parseTelemetryConfig, serializeTelemetryConfig, } from "./telemetry.js";
@@ -41,6 +44,7 @@ import { executeOrchestrationPlan, parseOrchestrationPlan, serializeOrchestratio
41
44
  export async function runCommand(args, root, fs, services = {}) {
42
45
  const aiw = join(root, WORKFLOW_DIRECTORY);
43
46
  try {
47
+ fs = withSafeProjectWrites(fs, root);
44
48
  const command = args[0] ?? "help";
45
49
  if (command === "ui") {
46
50
  if (!fs.exists(join(aiw, "manifest.yml")))
@@ -110,23 +114,101 @@ export async function runCommand(args, root, fs, services = {}) {
110
114
  const target = requestedTarget || detectTarget(root, fs) || "codex";
111
115
  if (!isTarget(target))
112
116
  throw new Error(`Unsupported target: ${target}`);
113
- if (fs.exists(join(aiw, "manifest.yml"))) {
114
- const manifest = fs.read(join(aiw, "manifest.yml"));
117
+ const manifestPath = join(aiw, "manifest.yml");
118
+ assertSafeProjectPath(fs, root, manifestPath);
119
+ if (fs.pathType?.(manifestPath) === "file") {
120
+ const manifest = fs.read(manifestPath);
115
121
  const active = manifest.match(/^\s*active:\s*(.+)$/m)?.[1]?.trim() ?? "unknown";
116
122
  return {
117
123
  exitCode: 0,
118
124
  output: `AI Workflow is already installed for target: ${active}. Existing state preserved.`,
119
125
  };
120
126
  }
121
- fs.mkdir(aiw);
122
- BASE_DIRECTORIES.forEach((dir) => fs.mkdir(join(aiw, dir)));
123
- fs.mkdir(join(root, ".context/adrs"));
124
- fs.write(join(root, ".context/adrs/INDEX.md"), "# Architecture Decision Records\n\n");
125
- fs.write(join(aiw, "manifest.yml"), `schema: 1\nproject:\n name: ${root.split("/").pop()}\ntarget:\n active: ${target}\npolicies:\n artifact_language: en\n`);
126
- fs.write(join(aiw, "profile.yml"), "schema: 1\nstatus: pending-scan\n");
127
- fs.write(join(aiw, "lock.yml"), "schema: 1\npackages:\n");
128
- generateAiInit(root, target, fs);
129
- return { exitCode: 0, output: `AI Workflow installed for target: ${target}` };
127
+ const bootstrapResources = loadBootstrapResources(target);
128
+ const desiredResources = new Map();
129
+ const ownershipMetadata = new Map();
130
+ bootstrapResources.forEach(({ type, id, path, content }) => {
131
+ const targetPath = join(root, path);
132
+ const neutralPath = join(root, renderResourcePath("universal", type, id));
133
+ desiredResources.set(targetPath, content);
134
+ desiredResources.set(neutralPath, content);
135
+ ownershipMetadata.set(neutralPath, {
136
+ kind: "neutral-resource",
137
+ target: "universal",
138
+ resourceType: type,
139
+ resourceId: id,
140
+ });
141
+ if (targetPath !== neutralPath)
142
+ ownershipMetadata.set(targetPath, {
143
+ kind: "target-resource",
144
+ target,
145
+ resourceType: type,
146
+ resourceId: id,
147
+ });
148
+ });
149
+ const conflicts = [];
150
+ const pendingWrites = new Map();
151
+ const inspectDesiredFile = (path, content, allowExisting = false) => {
152
+ assertSafeProjectPath(fs, root, path);
153
+ const type = fs.pathType?.(path);
154
+ if (type === "missing") {
155
+ pendingWrites.set(path, content);
156
+ return;
157
+ }
158
+ if (type === "file" && (allowExisting || fs.read(path) === content))
159
+ return;
160
+ conflicts.push(path);
161
+ };
162
+ for (const [path, content] of desiredResources)
163
+ inspectDesiredFile(path, content);
164
+ if (conflicts.length)
165
+ return {
166
+ exitCode: 1,
167
+ error: `Installation found existing target resources: ${conflicts.join(", ")}. Review and move them before retrying.`,
168
+ };
169
+ const aiInitPath = join(root, renderAiInitPath(target));
170
+ ownershipMetadata.set(aiInitPath, {
171
+ kind: "ai-init",
172
+ target,
173
+ resourceType: "skills",
174
+ resourceId: "ai-init",
175
+ });
176
+ inspectDesiredFile(aiInitPath, renderAiInit(target));
177
+ if (conflicts.length)
178
+ return {
179
+ exitCode: 1,
180
+ error: `Installation would overwrite an existing file: ${conflicts.join(", ")}. Move it or merge its contents, then retry.`,
181
+ };
182
+ inspectDesiredFile(join(aiw, "profile.yml"), "schema: 1\nstatus: pending-scan\n");
183
+ inspectDesiredFile(join(aiw, "lock.yml"), "schema: 1\npackages:\n");
184
+ inspectDesiredFile(manifestPath, "");
185
+ const adrIndex = join(root, ".context/adrs/INDEX.md");
186
+ assertSafeProjectPath(fs, root, adrIndex);
187
+ if (fs.pathType?.(adrIndex) === "missing")
188
+ pendingWrites.set(adrIndex, "# Architecture Decision Records\n\n");
189
+ const ownershipPath = join(aiw, "ownership.yml");
190
+ assertSafeProjectPath(fs, root, ownershipPath);
191
+ if (fs.exists(ownershipPath))
192
+ conflicts.push(ownershipPath);
193
+ if (conflicts.length)
194
+ return {
195
+ exitCode: 1,
196
+ error: `Installation found conflicting project state: ${conflicts.join(", ")}. Review and move it before retrying.`,
197
+ };
198
+ const ownedPaths = new Set([...desiredResources.keys(), aiInitPath]);
199
+ if (pendingWrites.has(adrIndex))
200
+ ownedPaths.add(adrIndex);
201
+ const ownedFiles = [...ownedPaths]
202
+ .filter((path) => pendingWrites.has(path))
203
+ .map((path) => createOwnedFile(relative(root, path).split(sep).join("/"), pendingWrites.get(path), ownershipMetadata.get(path) ?? { kind: "supporting-file" }));
204
+ pendingWrites.set(ownershipPath, serializeOwnership(ownedFiles));
205
+ pendingWrites.delete(manifestPath);
206
+ pendingWrites.set(manifestPath, `schema: 1\nproject:\n name: ${root.split("/").pop()}\ntarget:\n active: ${target}\npolicies:\n artifact_language: en\n`);
207
+ writeProjectFilesAtomically(fs, root, [...pendingWrites].map(([path, content]) => ({ path, content })), [...BASE_DIRECTORIES.map((directory) => join(aiw, directory)), join(root, ".context/adrs")]);
208
+ return {
209
+ exitCode: 0,
210
+ output: `AI Workflow installed for target: ${target}; activated ${bootstrapResources.length} bundled workflow resources. Run the ai-init skill to scan the project, review recommendations, and begin the SDD gates.`,
211
+ };
130
212
  }
131
213
  if (command === "telemetry") {
132
214
  if (!fs.exists(join(aiw, "manifest.yml")))
@@ -232,131 +314,329 @@ export async function runCommand(args, root, fs, services = {}) {
232
314
  return { exitCode: 1, error: "Usage: aiw target <target>" };
233
315
  if (!isTarget(target))
234
316
  return { exitCode: 1, error: `Unsupported target: ${target}` };
235
- if (!fs.exists(join(aiw, "manifest.yml")))
317
+ const manifestPath = join(aiw, "manifest.yml");
318
+ if (!fs.exists(manifestPath))
236
319
  return { exitCode: 1, error: "Run `aiw install` first." };
237
320
  const dryRun = args.includes("--dry-run");
238
321
  const targetPath = join(root, renderAiInitPath(target));
239
322
  const neutralAiInit = join(aiw, "resources/skills/ai-init.md");
240
- const manifestPath = join(aiw, "manifest.yml");
241
- const previousTarget = fs
242
- .read(manifestPath)
243
- .match(/^\s*active:\s*(.+)$/m)?.[1]
244
- ?.trim() ?? "universal";
245
- const previousTargetPath = isTarget(previousTarget)
246
- ? join(root, renderAiInitPath(previousTarget))
247
- : undefined;
248
- const universalConflict = target === "universal" &&
249
- previousTargetPath !== undefined &&
250
- fs.exists(previousTargetPath) &&
251
- fs.exists(neutralAiInit) &&
252
- fs.read(previousTargetPath) !== fs.read(neutralAiInit);
253
- const resourceMigrations = planNeutralResourceMigration(join(aiw, "resources"), fs.exists(join(aiw, "resources")) ? (fs.listFiles?.(join(aiw, "resources")) ?? []) : [], target);
254
- const resourceConflicts = resourceMigrations.filter(({ source, destination }) => {
255
- const absoluteDestination = join(root, destination);
256
- return (source !== absoluteDestination &&
257
- fs.exists(absoluteDestination) &&
258
- Buffer.compare(Buffer.from(readResourceBytes(fs, source)), Buffer.from(readResourceBytes(fs, absoluteDestination))) !== 0);
259
- });
260
- if (dryRun) {
261
- const existing = resourceMigrations
262
- .filter(({ destination }) => fs.exists(join(root, destination)))
263
- .map(({ destination }) => destination);
264
- const conflictPaths = resourceConflicts.map(({ destination }) => destination);
265
- if (universalConflict)
266
- conflictPaths.push(renderAiInitPath(target));
323
+ assertSafeProjectPath(fs, root, manifestPath);
324
+ const manifest = parseManifest(fs.read(manifestPath));
325
+ const previousTarget = manifest.target.active;
326
+ const previousTargetPath = join(root, renderAiInitPath(previousTarget));
327
+ const inventoryPath = join(aiw, "ownership.yml");
328
+ if (!fs.exists(inventoryPath))
267
329
  return {
268
- exitCode: 0,
269
- output: `${serializeMigrationPreview(target, resourceMigrations, existing)}${conflictPaths.map((path) => `conflict: ${path}\n`).join("")}`,
330
+ exitCode: 1,
331
+ error: "AIW ownership inventory is required for safe target migration.",
270
332
  };
333
+ assertSafeProjectPath(fs, root, inventoryPath);
334
+ if (fs.pathType?.(inventoryPath) !== "file")
335
+ return { exitCode: 1, error: "AIW ownership inventory is not a regular project file." };
336
+ const inventory = parseOwnership(fs.read(inventoryPath));
337
+ const ownedByPath = new Map(inventory.map((entry) => [entry.path, entry]));
338
+ const resourceRoot = join(aiw, "resources");
339
+ if (fs.exists(resourceRoot))
340
+ assertSafeProjectPath(fs, root, resourceRoot);
341
+ const relativeFiles = fs.exists(resourceRoot)
342
+ ? (fs.listFiles?.(resourceRoot) ?? []).filter((path) => path !== "skills/ai-init.md")
343
+ : [];
344
+ const plan = planNeutralResourceMigration(resourceRoot, relativeFiles ?? [], target);
345
+ const diagnostics = [...plan.diagnostics];
346
+ if (fs.exists(resourceRoot) && !fs.listFiles)
347
+ diagnostics.push({
348
+ path: ".aiw/resources",
349
+ reason: "filesystem cannot enumerate neutral resources safely",
350
+ });
351
+ const migrations = [];
352
+ const migrationBytes = new Map();
353
+ for (const migration of plan.migrations) {
354
+ const relativeSource = relative(resourceRoot, migration.source).split(sep).join("/");
355
+ try {
356
+ assertSafeProjectPath(fs, root, migration.source);
357
+ if (!fs.exists(migration.source) || fs.pathType?.(migration.source) !== "file") {
358
+ diagnostics.push({
359
+ path: relativeSource,
360
+ reason: "resource is missing or is not a regular file",
361
+ });
362
+ continue;
363
+ }
364
+ const bytes = readResourceBytes(fs, migration.source);
365
+ const reason = validateNeutralResourceDocument(relativeSource, bytes);
366
+ if (reason) {
367
+ diagnostics.push({ path: relativeSource, reason });
368
+ continue;
369
+ }
370
+ migrations.push(migration);
371
+ migrationBytes.set(migration.source, bytes);
372
+ }
373
+ catch {
374
+ diagnostics.push({ path: relativeSource, reason: "resource cannot be read safely" });
375
+ }
376
+ }
377
+ const conflicts = new Set();
378
+ const removals = new Set();
379
+ const fileChanges = new Map();
380
+ const resourceDestinations = new Set();
381
+ for (const migration of migrations) {
382
+ const destination = join(root, migration.destination);
383
+ resourceDestinations.add(destination);
384
+ const desiredBytes = migrationBytes.get(migration.source);
385
+ inspectMigrationDestination(destination, desiredBytes);
386
+ }
387
+ const hasNeutralAiInit = fs.exists(neutralAiInit);
388
+ let aiInitBytes = Buffer.from(renderAiInit(target), "utf8");
389
+ if (hasNeutralAiInit) {
390
+ try {
391
+ assertSafeProjectPath(fs, root, neutralAiInit);
392
+ if (fs.pathType?.(neutralAiInit) !== "file")
393
+ diagnostics.push({
394
+ path: ".aiw/resources/skills/ai-init.md",
395
+ reason: "resource is not a regular file",
396
+ });
397
+ else
398
+ aiInitBytes = readResourceBytes(fs, neutralAiInit);
399
+ }
400
+ catch {
401
+ diagnostics.push({
402
+ path: ".aiw/resources/skills/ai-init.md",
403
+ reason: "resource cannot be read safely",
404
+ });
405
+ }
406
+ }
407
+ const aiInitDiagnostic = validateNeutralResourceDocument("skills/ai-init/SKILL.md", aiInitBytes);
408
+ if (hasNeutralAiInit && aiInitDiagnostic)
409
+ diagnostics.push({ path: ".aiw/resources/skills/ai-init.md", reason: aiInitDiagnostic });
410
+ const targetAiPath = targetPath;
411
+ resourceDestinations.add(targetAiPath);
412
+ inspectMigrationDestination(targetAiPath, aiInitBytes);
413
+ const previousTargetEntries = previousTarget === "universal"
414
+ ? []
415
+ : inventory.filter((entry) => (entry.kind === "target-resource" || entry.kind === "ai-init") &&
416
+ entry.target === previousTarget);
417
+ const oldTargetPaths = new Set(previousTargetEntries.map(({ path }) => resolve(root, path)));
418
+ if (previousTarget !== "universal") {
419
+ for (const migration of migrations) {
420
+ const oldPath = join(root, renderResourcePath(previousTarget, migration.type, migration.id));
421
+ oldTargetPaths.add(oldPath);
422
+ if (oldPath !== join(root, migration.destination))
423
+ inspectOwnedRemoval(oldPath);
424
+ }
425
+ if (previousTargetPath !== targetPath)
426
+ inspectOwnedRemoval(previousTargetPath);
427
+ }
428
+ for (const oldPath of oldTargetPaths) {
429
+ if (resourceDestinations.has(oldPath))
430
+ continue;
431
+ if (fs.exists(oldPath))
432
+ inspectOwnedRemoval(oldPath);
433
+ else
434
+ removals.add(relative(root, oldPath).split(sep).join("/"));
271
435
  }
272
- if (universalConflict ||
273
- (target !== "universal" &&
274
- fs.exists(neutralAiInit) &&
275
- fs.exists(targetPath) &&
276
- fs.read(neutralAiInit) !== fs.read(targetPath)) ||
277
- resourceConflicts.length > 0)
436
+ const existingDestinations = [...resourceDestinations]
437
+ .filter((path) => fs.exists(path))
438
+ .map((path) => relative(root, path).split(sep).join("/"));
439
+ const conflictPaths = [...conflicts].map((path) => relative(root, path).split(sep).join("/"));
440
+ const removalPaths = [...removals];
441
+ const previewActions = [
442
+ target === "universal" && hasNeutralAiInit
443
+ ? "keep: .aiw/resources/skills/ai-init.md (neutral source)"
444
+ : `${fs.exists(targetAiPath) ? "update" : "add"}: ${relative(root, targetAiPath).split(sep).join("/")}`,
445
+ ...(hasNeutralAiInit && target !== "universal"
446
+ ? ["keep: .aiw/resources/skills/ai-init.md (neutral source)"]
447
+ : []),
448
+ ];
449
+ const preview = serializeMigrationPreview(target, migrations, existingDestinations, removalPaths, diagnostics, conflictPaths, previewActions);
450
+ if (dryRun)
451
+ return { exitCode: diagnostics.length || conflicts.size ? 1 : 0, output: preview };
452
+ if (diagnostics.length || conflicts.size)
278
453
  return {
279
454
  exitCode: 1,
280
- error: `Migration conflict at ${resourceConflicts[0] ? join(root, resourceConflicts[0].destination) : targetPath}. Use --dry-run to inspect.`,
281
- };
282
- const removePrevious = previousTarget !== "universal" &&
283
- previousTargetPath !== undefined &&
284
- previousTargetPath !== targetPath &&
285
- fs.exists(previousTargetPath);
286
- if (removePrevious && !fs.remove)
287
- return { exitCode: 1, error: "Filesystem cannot remove the previous target resource." };
288
- const destinationExisted = fs.exists(targetPath);
289
- const resourceSnapshots = resourceMigrations
290
- .filter(({ source, destination }) => source !== join(root, destination))
291
- .map(({ destination }) => {
292
- const path = join(root, destination);
293
- return {
294
- path,
295
- existed: fs.exists(path),
296
- contentBase64: fs.exists(path)
297
- ? Buffer.from(readResourceBytes(fs, path)).toString("base64")
298
- : "",
455
+ error: `Migration blocked. Review the dry-run diagnostics:\n${preview}`,
299
456
  };
300
- });
301
- fs.write(join(aiw, "checkpoints/migration-backup.yml"), `previous_target: ${previousTarget}\nprevious_path: ${previousTargetPath ?? "none"}\nprevious_content_base64: ${previousTargetPath && fs.exists(previousTargetPath) ? Buffer.from(fs.read(previousTargetPath)).toString("base64") : ""}\npath: ${targetPath}\ndestination_existed: ${destinationExisted}\ncontent_base64: ${destinationExisted ? Buffer.from(fs.read(targetPath)).toString("base64") : ""}\nresources_base64: ${serializeMigrationSnapshots(resourceSnapshots)}\n`);
302
- if (target !== "universal" || !fs.exists(neutralAiInit))
303
- generateAiInit(root, target, fs);
304
- if (fs.exists(neutralAiInit))
305
- fs.write(targetPath, fs.read(neutralAiInit));
306
- for (const migration of resourceMigrations) {
307
- const destination = join(root, migration.destination);
308
- if (migration.source !== destination)
309
- writeResourceBytes(fs, destination, readResourceBytes(fs, migration.source));
457
+ if ([...fileChanges.values()].some((value) => value === undefined) && !fs.remove)
458
+ return { exitCode: 1, error: "Filesystem cannot remove obsolete target resources." };
459
+ const nextInventory = new Map(inventory.map((entry) => [entry.path, entry]));
460
+ for (const relativePath of removals)
461
+ nextInventory.delete(relativePath);
462
+ for (const [path, bytes] of fileChanges) {
463
+ if (bytes === undefined)
464
+ continue;
465
+ const relativePath = relative(root, path).split(sep).join("/");
466
+ const matchingMigration = migrations.find((entry) => join(root, entry.destination) === path);
467
+ const metadata = path === targetAiPath
468
+ ? {
469
+ kind: "ai-init",
470
+ target,
471
+ resourceType: "skills",
472
+ resourceId: "ai-init",
473
+ }
474
+ : matchingMigration
475
+ ? {
476
+ kind: "target-resource",
477
+ target,
478
+ resourceType: matchingMigration.type,
479
+ resourceId: matchingMigration.id,
480
+ }
481
+ : {};
482
+ nextInventory.set(relativePath, createOwnedFile(relativePath, bytes, metadata));
483
+ }
484
+ const inventoryBytes = Buffer.from(serializeOwnership([...nextInventory.values()]), "utf8");
485
+ fileChanges.set(inventoryPath, inventoryBytes);
486
+ fileChanges.set(manifestPath, Buffer.from(serializeManifest({ ...manifest, target: { active: target } }), "utf8"));
487
+ const changes = [...fileChanges].map(([path, content]) => ({ path, content }));
488
+ const snapshots = snapshotProjectFiles(fs, root, changes.map(({ path }) => path));
489
+ const backupPath = join(aiw, "checkpoints/migration-backup.yml");
490
+ assertSafeProjectPath(fs, root, backupPath);
491
+ const previousBackup = fs.exists(backupPath) ? readResourceBytes(fs, backupPath) : undefined;
492
+ const postMigrationSnapshots = expectedMigrationSnapshots(root, changes);
493
+ const backupContent = serializeMigrationCheckpoint(snapshots, postMigrationSnapshots);
494
+ fs.write(backupPath, backupContent);
495
+ try {
496
+ applyProjectFileChanges(fs, root, changes);
497
+ }
498
+ catch (error) {
499
+ if (error instanceof Error && error.message.includes("automatic rollback was incomplete"))
500
+ throw error;
501
+ try {
502
+ if (previousBackup)
503
+ writeResourceBytes(fs, backupPath, previousBackup);
504
+ else if (fs.exists(backupPath))
505
+ fs.remove?.(backupPath);
506
+ }
507
+ catch {
508
+ throw new Error("Migration failed and its prior checkpoint could not be restored.", {
509
+ cause: error,
510
+ });
511
+ }
512
+ throw error;
310
513
  }
311
- if (removePrevious && previousTargetPath)
312
- fs.remove?.(previousTargetPath);
313
- fs.write(manifestPath, fs.read(manifestPath).replace(/active: .*\n/, `active: ${target}\n`));
314
514
  return {
315
515
  exitCode: 0,
316
- output: `Target changed to ${target}; rendered ${resourceMigrations.length} neutral resources.`,
516
+ output: `Target changed to ${target}; rendered ${migrations.length} neutral resources and removed ${removals.size} obsolete resources.`,
317
517
  };
518
+ function inspectMigrationDestination(destination, desired) {
519
+ try {
520
+ assertSafeProjectPath(fs, root, destination);
521
+ if (!fs.exists(destination)) {
522
+ fileChanges.set(destination, desired);
523
+ return;
524
+ }
525
+ if (fs.pathType?.(destination) !== "file") {
526
+ conflicts.add(destination);
527
+ return;
528
+ }
529
+ const currentBytes = readResourceBytes(fs, destination);
530
+ const currentChecksum = `sha256-${checksumPackage(currentBytes)}`;
531
+ const relativeDestination = relative(root, destination).split(sep).join("/");
532
+ const owner = ownedByPath.get(relativeDestination);
533
+ if (Buffer.compare(Buffer.from(currentBytes), Buffer.from(desired)) === 0) {
534
+ if (owner && owner.checksum !== currentChecksum)
535
+ conflicts.add(destination);
536
+ return;
537
+ }
538
+ if (!owner || owner.checksum !== currentChecksum) {
539
+ conflicts.add(destination);
540
+ return;
541
+ }
542
+ fileChanges.set(destination, desired);
543
+ }
544
+ catch {
545
+ conflicts.add(destination);
546
+ }
547
+ }
548
+ function inspectOwnedRemoval(path) {
549
+ try {
550
+ assertSafeProjectPath(fs, root, path);
551
+ if (!fs.exists(path)) {
552
+ removals.add(relative(root, path).split(sep).join("/"));
553
+ return;
554
+ }
555
+ if (fs.pathType?.(path) !== "file") {
556
+ conflicts.add(path);
557
+ return;
558
+ }
559
+ const relativePath = relative(root, path).split(sep).join("/");
560
+ const owner = ownedByPath.get(relativePath);
561
+ const actual = `sha256-${checksumPackage(readResourceBytes(fs, path))}`;
562
+ if (!owner || owner.checksum !== actual) {
563
+ conflicts.add(path);
564
+ return;
565
+ }
566
+ removals.add(relativePath);
567
+ fileChanges.set(path, undefined);
568
+ }
569
+ catch {
570
+ conflicts.add(path);
571
+ }
572
+ }
318
573
  }
319
574
  if (command === "rollback") {
320
575
  const backup = join(aiw, "checkpoints/migration-backup.yml");
576
+ assertSafeProjectPath(fs, root, backup);
321
577
  if (!fs.exists(backup))
322
578
  return { exitCode: 1, error: "No migration backup is available." };
323
579
  const content = fs.read(backup);
324
- const path = content.match(/^path: (.+)$/m)?.[1];
325
- const encoded = content.match(/^content_base64: (.*)$/m)?.[1];
326
- const previousTarget = content.match(/^previous_target: (.+)$/m)?.[1];
327
- const previousPath = content.match(/^previous_path: (.+)$/m)?.[1];
328
- const previousEncoded = content.match(/^previous_content_base64: (.*)$/m)?.[1];
329
- const destinationExisted = content.match(/^destination_existed: (true|false)$/m)?.[1];
330
- const resourcesEncoded = content.match(/^resources_base64: (.+)$/m)?.[1];
331
- if (!path || encoded === undefined)
332
- return { exitCode: 1, error: "Migration backup is invalid." };
333
- if (!fs.remove)
334
- return { exitCode: 1, error: "Filesystem cannot consume the migration checkpoint." };
335
- if (destinationExisted === "false") {
336
- if (fs.exists(path) && !fs.remove)
337
- return { exitCode: 1, error: "Filesystem cannot remove the migrated target resource." };
338
- if (fs.exists(path))
339
- fs.remove?.(path);
340
- }
341
- else
342
- fs.write(path, Buffer.from(encoded, "base64").toString("utf8"));
343
- if (previousPath && previousPath !== "none" && previousEncoded)
344
- fs.write(previousPath, Buffer.from(previousEncoded, "base64").toString("utf8"));
345
- for (const snapshot of resourcesEncoded ? parseMigrationSnapshots(resourcesEncoded) : []) {
346
- if (snapshot.existed)
347
- writeResourceBytes(fs, snapshot.path, Buffer.from(snapshot.contentBase64, "base64"));
348
- else {
349
- if (fs.exists(snapshot.path) && !fs.remove)
350
- return { exitCode: 1, error: "Filesystem cannot remove migrated resources." };
351
- if (fs.exists(snapshot.path))
352
- fs.remove?.(snapshot.path);
580
+ if (fs.pathType?.(backup) !== "file")
581
+ return { exitCode: 1, error: "Migration backup is not a regular project file." };
582
+ const checkpoint = parseMigrationCheckpoint(content);
583
+ const { snapshots, postMigrationSnapshots } = checkpoint;
584
+ const backupRelative = relative(root, backup).split(sep).join("/");
585
+ if (snapshots.some(({ path }) => path === backupRelative))
586
+ return { exitCode: 1, error: "Migration backup cannot contain itself as a snapshot." };
587
+ if (!postMigrationSnapshots)
588
+ return {
589
+ exitCode: 1,
590
+ error: "Migration backup does not include validated post-migration state; refusing unsafe rollback.",
591
+ };
592
+ if (postMigrationSnapshots.length !== snapshots.length ||
593
+ postMigrationSnapshots.some((snapshot, index) => snapshot.path !== snapshots[index]?.path))
594
+ return { exitCode: 1, error: "Migration backup post-migration state is invalid." };
595
+ const conflicts = [];
596
+ const restorable = [];
597
+ for (const [index, snapshot] of snapshots.entries()) {
598
+ const path = resolve(root, snapshot.path);
599
+ try {
600
+ assertSafeProjectPath(fs, root, path);
601
+ const expected = postMigrationSnapshots?.[index];
602
+ if (!expected || migrationSnapshotMatches(fs, path, expected))
603
+ restorable.push(snapshot);
604
+ else
605
+ conflicts.push(snapshot.path);
606
+ }
607
+ catch {
608
+ conflicts.push(snapshot.path);
353
609
  }
354
610
  }
355
- const manifestPath = join(aiw, "manifest.yml");
356
- if (previousTarget && fs.exists(manifestPath))
357
- fs.write(manifestPath, fs.read(manifestPath).replace(/active: .*\n/, `active: ${previousTarget}\n`));
358
- fs.remove(backup);
359
- return { exitCode: 0, output: `Migration rolled back: ${path}` };
611
+ if (restorable.some((snapshot) => !snapshot.existed && fs.exists(resolve(root, snapshot.path))) &&
612
+ !fs.remove)
613
+ return { exitCode: 1, error: "Filesystem cannot remove obsolete migration resources." };
614
+ const rollbackChanges = restorable.map((snapshot) => ({
615
+ path: resolve(root, snapshot.path),
616
+ content: snapshot.existed ? Buffer.from(snapshot.contentBase64, "base64") : undefined,
617
+ }));
618
+ if (conflicts.length) {
619
+ const remaining = snapshots.filter((snapshot) => conflicts.includes(snapshot.path));
620
+ const remainingPostMigration = postMigrationSnapshots.filter((snapshot) => conflicts.includes(snapshot.path));
621
+ rollbackChanges.push({
622
+ path: backup,
623
+ content: Buffer.from(serializeMigrationCheckpoint(remaining, remainingPostMigration), "utf8"),
624
+ });
625
+ }
626
+ else {
627
+ rollbackChanges.push({ path: backup, content: undefined });
628
+ }
629
+ applyProjectFileChanges(fs, root, rollbackChanges);
630
+ if (conflicts.length) {
631
+ return {
632
+ exitCode: 1,
633
+ error: `Migration rollback preserved modified/conflicting files: ${[...new Set(conflicts)].sort().join(", ")}`,
634
+ };
635
+ }
636
+ return {
637
+ exitCode: 0,
638
+ output: "Migration rolled back; prior resources, ownership, and target state were restored.",
639
+ };
360
640
  }
361
641
  if (command === "confirm") {
362
642
  if (!fs.exists(join(aiw, "manifest.yml")))
@@ -520,12 +800,13 @@ export async function runCommand(args, root, fs, services = {}) {
520
800
  }
521
801
  }
522
802
  if (command === "doctor") {
523
- const required = ["manifest.yml", "profile.yml", "lock.yml"];
803
+ const required = ["manifest.yml", "profile.yml", "lock.yml", "ownership.yml"];
524
804
  const missing = required.filter((file) => !fs.exists(join(aiw, file)));
525
805
  if (missing.length)
526
806
  return { exitCode: 1, error: `Missing AI Workflow files: ${missing.join(", ")}` };
527
807
  try {
528
808
  parseManifest(fs.read(join(aiw, "manifest.yml")));
809
+ parseOwnership(fs.read(join(aiw, "ownership.yml")));
529
810
  if (fs.exists(join(aiw, "team-preset.yml")))
530
811
  parseTeamPreset(fs.read(join(aiw, "team-preset.yml")));
531
812
  if (fs.exists(join(aiw, "registries.yml")))
@@ -547,9 +828,21 @@ export async function runCommand(args, root, fs, services = {}) {
547
828
  return { exitCode: 0, output: "AI Workflow state repaired." };
548
829
  }
549
830
  if (command === "uninstall") {
550
- if (!fs.exists(join(aiw, "manifest.yml")))
831
+ const manifestPath = join(aiw, "manifest.yml");
832
+ assertSafeProjectPath(fs, root, manifestPath);
833
+ if (!fs.exists(manifestPath))
551
834
  return { exitCode: 1, error: "AI Workflow is not installed." };
552
- const files = [
835
+ const ownershipPath = join(aiw, "ownership.yml");
836
+ assertSafeProjectPath(fs, root, ownershipPath);
837
+ if (!fs.exists(ownershipPath))
838
+ return {
839
+ exitCode: 1,
840
+ error: "AIW ownership inventory is missing; refusing to remove resources whose ownership cannot be verified.",
841
+ };
842
+ if (fs.pathType?.(ownershipPath) !== "file")
843
+ throw new Error("AIW ownership inventory is not a regular in-project file.");
844
+ const ownedFiles = parseOwnership(fs.read(ownershipPath));
845
+ const stateFiles = [
553
846
  "manifest.yml",
554
847
  "profile.yml",
555
848
  "lock.yml",
@@ -558,17 +851,71 @@ export async function runCommand(args, root, fs, services = {}) {
558
851
  "team-preset.yml",
559
852
  "registries.yml",
560
853
  "telemetry.yml",
854
+ "checkpoints/migration-backup.yml",
561
855
  ];
562
856
  const dryRun = args.includes("--dry-run");
563
857
  if (!dryRun && !fs.remove)
564
858
  return { exitCode: 1, error: "Filesystem cannot remove AIW-owned files." };
565
- if (!dryRun)
566
- files
567
- .filter((file) => fs.exists(join(aiw, file)))
568
- .forEach((file) => fs.remove?.(join(aiw, file)));
859
+ const removable = [];
860
+ const conflicts = [];
861
+ for (const file of ownedFiles) {
862
+ const path = resolve(root, file.path);
863
+ try {
864
+ assertSafeProjectPath(fs, root, path);
865
+ if (!fs.exists(path))
866
+ continue;
867
+ if (fs.pathType?.(path) !== "file") {
868
+ conflicts.push(file.path);
869
+ continue;
870
+ }
871
+ const actual = `sha256-${checksumPackage(fs.readBytes?.(path) ?? fs.read(path))}`;
872
+ if (actual === file.checksum)
873
+ removable.push(path);
874
+ else
875
+ conflicts.push(file.path);
876
+ }
877
+ catch {
878
+ conflicts.push(file.path);
879
+ }
880
+ }
881
+ const statePaths = [];
882
+ for (const file of stateFiles) {
883
+ const path = join(aiw, file);
884
+ if (!fs.exists(path))
885
+ continue;
886
+ assertSafeProjectPath(fs, root, path);
887
+ if (fs.pathType?.(path) !== "file")
888
+ conflicts.push(`${WORKFLOW_DIRECTORY}/${file}`);
889
+ else
890
+ statePaths.push(path);
891
+ }
892
+ const uniqueConflicts = [...new Set(conflicts)].sort();
893
+ const allRemovable = [...new Set([...removable, ...statePaths, ownershipPath])];
894
+ const relativeRemovable = allRemovable
895
+ .map((path) => relative(root, path).split(sep).join("/"))
896
+ .sort();
897
+ if (dryRun) {
898
+ const report = [`Would remove: ${relativeRemovable.join(", ") || "none"}`];
899
+ if (uniqueConflicts.length)
900
+ report.push(`Would preserve modified/conflicting files: ${uniqueConflicts.join(", ")}`);
901
+ return { exitCode: uniqueConflicts.length ? 1 : 0, output: report.join("\n") };
902
+ }
903
+ removable.forEach((path) => fs.remove?.(path));
904
+ statePaths
905
+ .filter((path) => !path.endsWith(`${sep}manifest.yml`))
906
+ .forEach((path) => fs.remove?.(path));
907
+ if (statePaths.includes(manifestPath))
908
+ fs.remove?.(manifestPath);
909
+ fs.remove?.(ownershipPath);
910
+ const removed = relativeRemovable.filter((path) => !uniqueConflicts.includes(path));
569
911
  return {
570
- exitCode: 0,
571
- output: dryRun ? `Would remove: ${files.join(", ")}` : "AI Workflow-owned files removed.",
912
+ exitCode: uniqueConflicts.length ? 1 : 0,
913
+ output: [
914
+ `AI Workflow-owned files removed: ${removed.join(", ") || "none"}.`,
915
+ ...(uniqueConflicts.length
916
+ ? [`Preserved modified/conflicting files: ${uniqueConflicts.join(", ")}`]
917
+ : []),
918
+ ].join("\n"),
572
919
  };
573
920
  }
574
921
  if (command === "brainstorm") {
@@ -707,15 +1054,27 @@ export async function runCommand(args, root, fs, services = {}) {
707
1054
  const sourceArg = args.find((arg) => arg.startsWith("--source="));
708
1055
  if (!packageArg && !sourceArg)
709
1056
  return { exitCode: 1, error: "Usage: aiw resolve --package=path | --source=source" };
1057
+ const approvedPermissions = parseApprovedPermissions(args);
710
1058
  if (sourceArg) {
711
1059
  const source = resolveProvider(sourceArg.slice(9), root);
712
- enforceConfiguredOrganizationPolicy(fs, aiw, { ...source, permissions: [] });
1060
+ const networkPermissions = requiresExternalNetwork(source) ? ["network:external"] : [];
1061
+ enforceConfiguredOrganizationPolicy(fs, aiw, {
1062
+ ...source,
1063
+ permissions: networkPermissions,
1064
+ });
1065
+ assertPermissions(networkPermissions, approvedPermissions);
713
1066
  }
714
1067
  const loaded = sourceArg
715
1068
  ? (services.packageSources ?? nodePackageSourceLoader).load(sourceArg.slice(9), root)
716
1069
  : undefined;
717
1070
  try {
718
- const pkg = validatePackageContract(loaded?.manifest ?? fs.read(join(root, packageArg.slice(10))), loaded ? (path) => fs.exists(join(loaded.root, path)) : undefined);
1071
+ const manifestPath = packageArg ? resolve(root, packageArg.slice(10)) : undefined;
1072
+ const directManifestBytes = loaded ? undefined : fs.readBytes?.(manifestPath);
1073
+ const manifestContent = loaded?.manifest ??
1074
+ (directManifestBytes
1075
+ ? Buffer.from(directManifestBytes).toString("utf8")
1076
+ : fs.read(manifestPath));
1077
+ const pkg = validatePackageContract(manifestContent, loaded ? (path) => fs.exists(join(loaded.root, path)) : undefined);
719
1078
  const resolved = loaded
720
1079
  ? {
721
1080
  ...pkg,
@@ -725,8 +1084,18 @@ export async function runCommand(args, root, fs, services = {}) {
725
1084
  }
726
1085
  : pkg;
727
1086
  enforceConfiguredOrganizationPolicy(fs, aiw, resolved);
728
- assertPackagePermissions(resolved, parseApprovedPermissions(args));
729
- fs.write(join(aiw, "lock.yml"), serializeLock([resolvePackage(resolved)]));
1087
+ assertPackagePermissions(resolved, approvedPermissions);
1088
+ const packageRoot = loaded?.root ?? dirname(manifestPath);
1089
+ const manifestBytes = loaded
1090
+ ? (loaded.manifestBytes ?? Buffer.from(loaded.manifest, "utf8"))
1091
+ : (directManifestBytes ?? Buffer.from(manifestContent, "utf8"));
1092
+ const integrity = checksumPackageSnapshot(manifestBytes, resolved, packageRoot, fs, loaded ? "package.yaml" : basename(manifestPath));
1093
+ const lockPath = join(aiw, "lock.yml");
1094
+ const locked = fs.exists(lockPath) ? parseLock(fs.read(lockPath)) : [];
1095
+ fs.write(lockPath, serializeLock([
1096
+ ...locked.filter(({ id }) => id !== resolved.id),
1097
+ resolvePackage(resolved, integrity),
1098
+ ]));
730
1099
  return { exitCode: 0, output: `Package resolved: ${resolved.id}@${resolved.version}` };
731
1100
  }
732
1101
  finally {
@@ -816,7 +1185,12 @@ export async function runCommand(args, root, fs, services = {}) {
816
1185
  const packageArg = args.find((arg) => arg.startsWith("--package="));
817
1186
  if (!packageArg)
818
1187
  return { exitCode: 1, error: "Usage: aiw update --package=path --target=target" };
819
- const pkg = validatePackageContract(fs.read(join(root, packageArg.slice(10))));
1188
+ const manifestPath = resolve(root, packageArg.slice(10));
1189
+ const manifestBytes = fs.readBytes?.(manifestPath);
1190
+ const manifestContent = manifestBytes
1191
+ ? Buffer.from(manifestBytes).toString("utf8")
1192
+ : fs.read(manifestPath);
1193
+ const pkg = validatePackageContract(manifestContent);
820
1194
  enforceConfiguredOrganizationPolicy(fs, aiw, pkg);
821
1195
  assertPackagePermissions(pkg, parseApprovedPermissions(args));
822
1196
  const target = args.find((arg) => arg.startsWith("--target="))?.slice(9) ?? "universal";
@@ -825,11 +1199,14 @@ export async function runCommand(args, root, fs, services = {}) {
825
1199
  const lock = fs.exists(join(aiw, "lock.yml")) ? fs.read(join(aiw, "lock.yml")) : "";
826
1200
  const lockedPackages = lock ? parseLock(lock) : [];
827
1201
  const current = lockedPackages.find(({ id }) => id === pkg.id);
1202
+ const integrity = checksumPackageSnapshot(manifestBytes ?? Buffer.from(manifestContent, "utf8"), pkg, dirname(manifestPath), fs, basename(manifestPath));
1203
+ if (current?.version === pkg.version && current.integrity !== integrity)
1204
+ throw new Error(`Package integrity mismatch for ${pkg.id}@${pkg.version}; package bytes changed.`);
828
1205
  const plan = planPackageUpdate(current ? { id: pkg.id, version: current.version } : undefined, pkg, target, new Map(lockedPackages.filter(({ id }) => id !== pkg.id).map(({ id, version }) => [id, version])));
829
1206
  if (plan.status !== "update")
830
1207
  return { exitCode: 1, error: plan.reason ?? `Update blocked: ${plan.status}.` };
831
1208
  const existingPackages = lockedPackages.filter(({ id }) => id !== pkg.id);
832
- fs.write(join(aiw, "lock.yml"), serializeLock([...existingPackages, resolvePackage(pkg)]));
1209
+ fs.write(join(aiw, "lock.yml"), serializeLock([...existingPackages, resolvePackage(pkg, integrity)]));
833
1210
  return { exitCode: 0, output: `Package updated: ${pkg.id}@${pkg.version}` };
834
1211
  }
835
1212
  if (command === "registry") {
@@ -1043,6 +1420,81 @@ function isInsidePath(root, destination) {
1043
1420
  const path = relative(root, destination);
1044
1421
  return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
1045
1422
  }
1423
+ function snapshotProjectFiles(fs, root, paths) {
1424
+ const snapshots = [];
1425
+ for (const path of [...new Set(paths)]) {
1426
+ assertSafeProjectPath(fs, root, path);
1427
+ const type = fs.pathType?.(path);
1428
+ if (type === "missing") {
1429
+ snapshots.push({
1430
+ path: relative(root, path).split(sep).join("/"),
1431
+ existed: false,
1432
+ contentBase64: "",
1433
+ });
1434
+ continue;
1435
+ }
1436
+ if (type !== "file")
1437
+ throw new Error(`Migration path is not a regular project file: ${relative(root, path)}`);
1438
+ snapshots.push({
1439
+ path: relative(root, path).split(sep).join("/"),
1440
+ existed: true,
1441
+ contentBase64: Buffer.from(readResourceBytes(fs, path)).toString("base64"),
1442
+ });
1443
+ }
1444
+ return snapshots;
1445
+ }
1446
+ function expectedMigrationSnapshots(root, changes) {
1447
+ return [...new Map(changes.map((change) => [change.path, change])).values()].map(({ path, content }) => ({
1448
+ path: relative(root, path).split(sep).join("/"),
1449
+ existed: content !== undefined,
1450
+ contentBase64: content ? Buffer.from(content).toString("base64") : "",
1451
+ }));
1452
+ }
1453
+ function migrationSnapshotMatches(fs, path, expected) {
1454
+ const type = fs.pathType?.(path);
1455
+ if (!expected.existed)
1456
+ return type === "missing";
1457
+ if (type !== "file")
1458
+ return false;
1459
+ return Buffer.from(readResourceBytes(fs, path)).equals(Buffer.from(expected.contentBase64, "base64"));
1460
+ }
1461
+ function applyProjectFileChanges(fs, root, changes) {
1462
+ const ordered = [...new Map(changes.map((change) => [change.path, change])).values()];
1463
+ if (ordered.some(({ content }) => content === undefined) && !fs.remove)
1464
+ throw new Error("Filesystem cannot remove obsolete migration resources.");
1465
+ const snapshots = snapshotProjectFiles(fs, root, ordered.map(({ path }) => path));
1466
+ try {
1467
+ for (const change of ordered) {
1468
+ if (change.content === undefined) {
1469
+ if (fs.exists(change.path))
1470
+ fs.remove?.(change.path);
1471
+ }
1472
+ else {
1473
+ writeResourceBytes(fs, change.path, change.content);
1474
+ }
1475
+ }
1476
+ }
1477
+ catch (error) {
1478
+ let rollbackFailed = false;
1479
+ for (const snapshot of [...snapshots].reverse()) {
1480
+ const path = resolve(root, snapshot.path);
1481
+ try {
1482
+ if (snapshot.existed)
1483
+ writeResourceBytes(fs, path, Buffer.from(snapshot.contentBase64, "base64"));
1484
+ else if (fs.exists(path))
1485
+ fs.remove?.(path);
1486
+ }
1487
+ catch {
1488
+ rollbackFailed = true;
1489
+ }
1490
+ }
1491
+ if (rollbackFailed)
1492
+ throw new Error("Migration failed and automatic rollback was incomplete; use aiw rollback.", {
1493
+ cause: error,
1494
+ });
1495
+ throw error;
1496
+ }
1497
+ }
1046
1498
  function parsePrivateRegistryPackages(payload, registryUrl) {
1047
1499
  if (!Array.isArray(payload))
1048
1500
  throw new Error("Private registry response must be an array.");