@multileaf/ai-workflow 0.1.0 → 0.1.2

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,27 +1,30 @@
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 { loadBundledResources } from "./bundled-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";
7
9
  import { mergeFacts } from "./intelligence.js";
8
10
  import { createInterpretationRequest } from "./interpreter.js";
9
- import { recommendCapabilities, serializeRecommendations } from "./recommendations.js";
11
+ import { recommendCapabilities, resourcesForCapabilities, serializeRecommendations, } from "./recommendations.js";
10
12
  import { selectRecommendations } from "./selection.js";
11
13
  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,86 @@ 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 aiInitPath = join(root, renderAiInitPath(target));
128
+ const neutralAiInitPath = join(aiw, "resources/skills/ai-init.md");
129
+ const aiInitContent = renderAiInit(target);
130
+ const desiredResources = new Map([[aiInitPath, aiInitContent]]);
131
+ const ownershipMetadata = new Map();
132
+ ownershipMetadata.set(aiInitPath, {
133
+ kind: "ai-init",
134
+ target,
135
+ resourceType: "skills",
136
+ resourceId: "ai-init",
137
+ });
138
+ if (neutralAiInitPath !== aiInitPath) {
139
+ desiredResources.set(neutralAiInitPath, aiInitContent);
140
+ ownershipMetadata.set(neutralAiInitPath, {
141
+ kind: "neutral-resource",
142
+ target: "universal",
143
+ resourceType: "skills",
144
+ resourceId: "ai-init",
145
+ });
146
+ }
147
+ const conflicts = [];
148
+ const pendingWrites = new Map();
149
+ const inspectDesiredFile = (path, content, allowExisting = false) => {
150
+ assertSafeProjectPath(fs, root, path);
151
+ const type = fs.pathType?.(path);
152
+ if (type === "missing") {
153
+ pendingWrites.set(path, content);
154
+ return;
155
+ }
156
+ if (type === "file" && (allowExisting || fs.read(path) === content))
157
+ return;
158
+ conflicts.push(path);
159
+ };
160
+ for (const [path, content] of desiredResources)
161
+ inspectDesiredFile(path, content);
162
+ if (conflicts.length)
163
+ return {
164
+ exitCode: 1,
165
+ error: `Installation would overwrite an existing file: ${conflicts.join(", ")}. Move it or merge its contents, then retry.`,
166
+ };
167
+ inspectDesiredFile(join(aiw, "profile.yml"), "schema: 1\nstatus: pending-scan\n");
168
+ inspectDesiredFile(join(aiw, "lock.yml"), "schema: 1\npackages:\n");
169
+ inspectDesiredFile(manifestPath, "");
170
+ const adrIndex = join(root, ".context/adrs/INDEX.md");
171
+ assertSafeProjectPath(fs, root, adrIndex);
172
+ if (fs.pathType?.(adrIndex) === "missing")
173
+ pendingWrites.set(adrIndex, "# Architecture Decision Records\n\n");
174
+ const ownershipPath = join(aiw, "ownership.yml");
175
+ assertSafeProjectPath(fs, root, ownershipPath);
176
+ if (fs.exists(ownershipPath))
177
+ conflicts.push(ownershipPath);
178
+ if (conflicts.length)
179
+ return {
180
+ exitCode: 1,
181
+ error: `Installation found conflicting project state: ${conflicts.join(", ")}. Review and move it before retrying.`,
182
+ };
183
+ const ownedPaths = new Set(desiredResources.keys());
184
+ if (pendingWrites.has(adrIndex))
185
+ ownedPaths.add(adrIndex);
186
+ const ownedFiles = [...ownedPaths]
187
+ .filter((path) => pendingWrites.has(path))
188
+ .map((path) => createOwnedFile(relative(root, path).split(sep).join("/"), pendingWrites.get(path), ownershipMetadata.get(path) ?? { kind: "supporting-file" }));
189
+ pendingWrites.set(ownershipPath, serializeOwnership(ownedFiles));
190
+ pendingWrites.delete(manifestPath);
191
+ pendingWrites.set(manifestPath, `schema: 1\nproject:\n name: ${root.split("/").pop()}\ntarget:\n active: ${target}\npolicies:\n artifact_language: en\n`);
192
+ writeProjectFilesAtomically(fs, root, [...pendingWrites].map(([path, content]) => ({ path, content })), [...BASE_DIRECTORIES.map((directory) => join(aiw, directory)), join(root, ".context/adrs")]);
193
+ return {
194
+ exitCode: 0,
195
+ output: `AI Workflow initialized for target: ${target} with only the ai-init skill. Run /ai-init to scan the project and choose which resources to install.`,
196
+ };
130
197
  }
131
198
  if (command === "telemetry") {
132
199
  if (!fs.exists(join(aiw, "manifest.yml")))
@@ -232,131 +299,332 @@ export async function runCommand(args, root, fs, services = {}) {
232
299
  return { exitCode: 1, error: "Usage: aiw target <target>" };
233
300
  if (!isTarget(target))
234
301
  return { exitCode: 1, error: `Unsupported target: ${target}` };
235
- if (!fs.exists(join(aiw, "manifest.yml")))
302
+ const manifestPath = join(aiw, "manifest.yml");
303
+ if (!fs.exists(manifestPath))
236
304
  return { exitCode: 1, error: "Run `aiw install` first." };
237
305
  const dryRun = args.includes("--dry-run");
238
306
  const targetPath = join(root, renderAiInitPath(target));
239
307
  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));
308
+ assertSafeProjectPath(fs, root, manifestPath);
309
+ const manifest = parseManifest(fs.read(manifestPath));
310
+ const previousTarget = manifest.target.active;
311
+ const previousTargetPath = join(root, renderAiInitPath(previousTarget));
312
+ const inventoryPath = join(aiw, "ownership.yml");
313
+ if (!fs.exists(inventoryPath))
267
314
  return {
268
- exitCode: 0,
269
- output: `${serializeMigrationPreview(target, resourceMigrations, existing)}${conflictPaths.map((path) => `conflict: ${path}\n`).join("")}`,
315
+ exitCode: 1,
316
+ error: "AIW ownership inventory is required for safe target migration.",
270
317
  };
318
+ assertSafeProjectPath(fs, root, inventoryPath);
319
+ if (fs.pathType?.(inventoryPath) !== "file")
320
+ return { exitCode: 1, error: "AIW ownership inventory is not a regular project file." };
321
+ const inventory = parseOwnership(fs.read(inventoryPath));
322
+ const ownedByPath = new Map(inventory.map((entry) => [entry.path, entry]));
323
+ const resourceRoot = join(aiw, "resources");
324
+ if (fs.exists(resourceRoot))
325
+ assertSafeProjectPath(fs, root, resourceRoot);
326
+ const relativeFiles = fs.exists(resourceRoot)
327
+ ? (fs.listFiles?.(resourceRoot) ?? []).filter((path) => path !== "skills/ai-init.md")
328
+ : [];
329
+ const plan = planNeutralResourceMigration(resourceRoot, relativeFiles ?? [], target);
330
+ const diagnostics = [...plan.diagnostics];
331
+ if (fs.exists(resourceRoot) && !fs.listFiles)
332
+ diagnostics.push({
333
+ path: ".aiw/resources",
334
+ reason: "filesystem cannot enumerate neutral resources safely",
335
+ });
336
+ const migrations = [];
337
+ const migrationBytes = new Map();
338
+ for (const migration of plan.migrations) {
339
+ const relativeSource = relative(resourceRoot, migration.source).split(sep).join("/");
340
+ try {
341
+ assertSafeProjectPath(fs, root, migration.source);
342
+ if (!fs.exists(migration.source) || fs.pathType?.(migration.source) !== "file") {
343
+ diagnostics.push({
344
+ path: relativeSource,
345
+ reason: "resource is missing or is not a regular file",
346
+ });
347
+ continue;
348
+ }
349
+ const bytes = readResourceBytes(fs, migration.source);
350
+ const reason = validateNeutralResourceDocument(relativeSource, bytes);
351
+ if (reason) {
352
+ diagnostics.push({ path: relativeSource, reason });
353
+ continue;
354
+ }
355
+ migrations.push(migration);
356
+ migrationBytes.set(migration.source, bytes);
357
+ }
358
+ catch {
359
+ diagnostics.push({ path: relativeSource, reason: "resource cannot be read safely" });
360
+ }
361
+ }
362
+ const conflicts = new Set();
363
+ const removals = new Set();
364
+ const fileChanges = new Map();
365
+ const resourceDestinations = new Set();
366
+ for (const migration of migrations) {
367
+ const destination = join(root, migration.destination);
368
+ resourceDestinations.add(destination);
369
+ const desiredBytes = migrationBytes.get(migration.source);
370
+ inspectMigrationDestination(destination, desiredBytes);
371
+ }
372
+ const hasNeutralAiInit = fs.exists(neutralAiInit);
373
+ let aiInitBytes = Buffer.from(renderAiInit(target), "utf8");
374
+ if (hasNeutralAiInit) {
375
+ try {
376
+ assertSafeProjectPath(fs, root, neutralAiInit);
377
+ if (fs.pathType?.(neutralAiInit) !== "file")
378
+ diagnostics.push({
379
+ path: ".aiw/resources/skills/ai-init.md",
380
+ reason: "resource is not a regular file",
381
+ });
382
+ else
383
+ aiInitBytes = readResourceBytes(fs, neutralAiInit);
384
+ }
385
+ catch {
386
+ diagnostics.push({
387
+ path: ".aiw/resources/skills/ai-init.md",
388
+ reason: "resource cannot be read safely",
389
+ });
390
+ }
271
391
  }
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)
392
+ const aiInitDiagnostic = validateNeutralResourceDocument("skills/ai-init/SKILL.md", aiInitBytes);
393
+ if (hasNeutralAiInit && aiInitDiagnostic)
394
+ diagnostics.push({ path: ".aiw/resources/skills/ai-init.md", reason: aiInitDiagnostic });
395
+ const targetAiPath = targetPath;
396
+ resourceDestinations.add(targetAiPath);
397
+ if (targetAiPath === neutralAiInit)
398
+ fileChanges.set(targetAiPath, aiInitBytes);
399
+ else
400
+ inspectMigrationDestination(targetAiPath, aiInitBytes);
401
+ const previousTargetEntries = previousTarget === "universal"
402
+ ? []
403
+ : inventory.filter((entry) => (entry.kind === "target-resource" || entry.kind === "ai-init") &&
404
+ entry.target === previousTarget);
405
+ const oldTargetPaths = new Set(previousTargetEntries.map(({ path }) => resolve(root, path)));
406
+ if (previousTarget !== "universal") {
407
+ for (const migration of migrations) {
408
+ const oldPath = join(root, renderResourcePath(previousTarget, migration.type, migration.id));
409
+ oldTargetPaths.add(oldPath);
410
+ if (oldPath !== join(root, migration.destination))
411
+ inspectOwnedRemoval(oldPath);
412
+ }
413
+ if (previousTargetPath !== targetPath)
414
+ inspectOwnedRemoval(previousTargetPath);
415
+ }
416
+ for (const oldPath of oldTargetPaths) {
417
+ if (resourceDestinations.has(oldPath))
418
+ continue;
419
+ if (fs.exists(oldPath))
420
+ inspectOwnedRemoval(oldPath);
421
+ else
422
+ removals.add(relative(root, oldPath).split(sep).join("/"));
423
+ }
424
+ const existingDestinations = [...resourceDestinations]
425
+ .filter((path) => fs.exists(path))
426
+ .map((path) => relative(root, path).split(sep).join("/"));
427
+ const conflictPaths = [...conflicts].map((path) => relative(root, path).split(sep).join("/"));
428
+ const removalPaths = [...removals];
429
+ const previewActions = [
430
+ target === "universal" && hasNeutralAiInit
431
+ ? "keep: .aiw/resources/skills/ai-init.md (neutral source)"
432
+ : `${fs.exists(targetAiPath) ? "update" : "add"}: ${relative(root, targetAiPath).split(sep).join("/")}`,
433
+ ...(hasNeutralAiInit && target !== "universal"
434
+ ? ["keep: .aiw/resources/skills/ai-init.md (neutral source)"]
435
+ : []),
436
+ ];
437
+ const preview = serializeMigrationPreview(target, migrations, existingDestinations, removalPaths, diagnostics, conflictPaths, previewActions);
438
+ if (dryRun)
439
+ return { exitCode: diagnostics.length || conflicts.size ? 1 : 0, output: preview };
440
+ if (diagnostics.length || conflicts.size)
278
441
  return {
279
442
  exitCode: 1,
280
- error: `Migration conflict at ${resourceConflicts[0] ? join(root, resourceConflicts[0].destination) : targetPath}. Use --dry-run to inspect.`,
443
+ error: `Migration blocked. Review the dry-run diagnostics:\n${preview}`,
281
444
  };
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
- : "",
299
- };
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));
445
+ if ([...fileChanges.values()].some((value) => value === undefined) && !fs.remove)
446
+ return { exitCode: 1, error: "Filesystem cannot remove obsolete target resources." };
447
+ const nextInventory = new Map(inventory.map((entry) => [entry.path, entry]));
448
+ for (const relativePath of removals)
449
+ nextInventory.delete(relativePath);
450
+ for (const [path, bytes] of fileChanges) {
451
+ if (bytes === undefined)
452
+ continue;
453
+ const relativePath = relative(root, path).split(sep).join("/");
454
+ const matchingMigration = migrations.find((entry) => join(root, entry.destination) === path);
455
+ const metadata = path === targetAiPath
456
+ ? {
457
+ kind: "ai-init",
458
+ target,
459
+ resourceType: "skills",
460
+ resourceId: "ai-init",
461
+ }
462
+ : matchingMigration
463
+ ? {
464
+ kind: "target-resource",
465
+ target,
466
+ resourceType: matchingMigration.type,
467
+ resourceId: matchingMigration.id,
468
+ }
469
+ : {};
470
+ nextInventory.set(relativePath, createOwnedFile(relativePath, bytes, metadata));
471
+ }
472
+ const inventoryBytes = Buffer.from(serializeOwnership([...nextInventory.values()]), "utf8");
473
+ fileChanges.set(inventoryPath, inventoryBytes);
474
+ fileChanges.set(manifestPath, Buffer.from(serializeManifest({ ...manifest, target: { active: target } }), "utf8"));
475
+ const changes = [...fileChanges].map(([path, content]) => ({ path, content }));
476
+ const snapshots = snapshotProjectFiles(fs, root, changes.map(({ path }) => path));
477
+ const backupPath = join(aiw, "checkpoints/migration-backup.yml");
478
+ assertSafeProjectPath(fs, root, backupPath);
479
+ const previousBackup = fs.exists(backupPath) ? readResourceBytes(fs, backupPath) : undefined;
480
+ const postMigrationSnapshots = expectedMigrationSnapshots(root, changes);
481
+ const backupContent = serializeMigrationCheckpoint(snapshots, postMigrationSnapshots);
482
+ fs.write(backupPath, backupContent);
483
+ try {
484
+ applyProjectFileChanges(fs, root, changes);
485
+ }
486
+ catch (error) {
487
+ if (error instanceof Error && error.message.includes("automatic rollback was incomplete"))
488
+ throw error;
489
+ try {
490
+ if (previousBackup)
491
+ writeResourceBytes(fs, backupPath, previousBackup);
492
+ else if (fs.exists(backupPath))
493
+ fs.remove?.(backupPath);
494
+ }
495
+ catch {
496
+ throw new Error("Migration failed and its prior checkpoint could not be restored.", {
497
+ cause: error,
498
+ });
499
+ }
500
+ throw error;
310
501
  }
311
- if (removePrevious && previousTargetPath)
312
- fs.remove?.(previousTargetPath);
313
- fs.write(manifestPath, fs.read(manifestPath).replace(/active: .*\n/, `active: ${target}\n`));
314
502
  return {
315
503
  exitCode: 0,
316
- output: `Target changed to ${target}; rendered ${resourceMigrations.length} neutral resources.`,
504
+ output: `Target changed to ${target}; rendered ${migrations.length} neutral resources and removed ${removals.size} obsolete resources.`,
317
505
  };
506
+ function inspectMigrationDestination(destination, desired) {
507
+ try {
508
+ assertSafeProjectPath(fs, root, destination);
509
+ if (!fs.exists(destination)) {
510
+ fileChanges.set(destination, desired);
511
+ return;
512
+ }
513
+ if (fs.pathType?.(destination) !== "file") {
514
+ conflicts.add(destination);
515
+ return;
516
+ }
517
+ const currentBytes = readResourceBytes(fs, destination);
518
+ const currentChecksum = `sha256-${checksumPackage(currentBytes)}`;
519
+ const relativeDestination = relative(root, destination).split(sep).join("/");
520
+ const owner = ownedByPath.get(relativeDestination);
521
+ if (Buffer.compare(Buffer.from(currentBytes), Buffer.from(desired)) === 0) {
522
+ if (owner && owner.checksum !== currentChecksum)
523
+ conflicts.add(destination);
524
+ return;
525
+ }
526
+ if (!owner || owner.checksum !== currentChecksum) {
527
+ conflicts.add(destination);
528
+ return;
529
+ }
530
+ fileChanges.set(destination, desired);
531
+ }
532
+ catch {
533
+ conflicts.add(destination);
534
+ }
535
+ }
536
+ function inspectOwnedRemoval(path) {
537
+ try {
538
+ assertSafeProjectPath(fs, root, path);
539
+ if (!fs.exists(path)) {
540
+ removals.add(relative(root, path).split(sep).join("/"));
541
+ return;
542
+ }
543
+ if (fs.pathType?.(path) !== "file") {
544
+ conflicts.add(path);
545
+ return;
546
+ }
547
+ const relativePath = relative(root, path).split(sep).join("/");
548
+ const owner = ownedByPath.get(relativePath);
549
+ const actual = `sha256-${checksumPackage(readResourceBytes(fs, path))}`;
550
+ if (!owner || owner.checksum !== actual) {
551
+ conflicts.add(path);
552
+ return;
553
+ }
554
+ removals.add(relativePath);
555
+ fileChanges.set(path, undefined);
556
+ }
557
+ catch {
558
+ conflicts.add(path);
559
+ }
560
+ }
318
561
  }
319
562
  if (command === "rollback") {
320
563
  const backup = join(aiw, "checkpoints/migration-backup.yml");
564
+ assertSafeProjectPath(fs, root, backup);
321
565
  if (!fs.exists(backup))
322
566
  return { exitCode: 1, error: "No migration backup is available." };
323
567
  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);
568
+ if (fs.pathType?.(backup) !== "file")
569
+ return { exitCode: 1, error: "Migration backup is not a regular project file." };
570
+ const checkpoint = parseMigrationCheckpoint(content);
571
+ const { snapshots, postMigrationSnapshots } = checkpoint;
572
+ const backupRelative = relative(root, backup).split(sep).join("/");
573
+ if (snapshots.some(({ path }) => path === backupRelative))
574
+ return { exitCode: 1, error: "Migration backup cannot contain itself as a snapshot." };
575
+ if (!postMigrationSnapshots)
576
+ return {
577
+ exitCode: 1,
578
+ error: "Migration backup does not include validated post-migration state; refusing unsafe rollback.",
579
+ };
580
+ if (postMigrationSnapshots.length !== snapshots.length ||
581
+ postMigrationSnapshots.some((snapshot, index) => snapshot.path !== snapshots[index]?.path))
582
+ return { exitCode: 1, error: "Migration backup post-migration state is invalid." };
583
+ const conflicts = [];
584
+ const restorable = [];
585
+ for (const [index, snapshot] of snapshots.entries()) {
586
+ const path = resolve(root, snapshot.path);
587
+ try {
588
+ assertSafeProjectPath(fs, root, path);
589
+ const expected = postMigrationSnapshots?.[index];
590
+ if (!expected || migrationSnapshotMatches(fs, path, expected))
591
+ restorable.push(snapshot);
592
+ else
593
+ conflicts.push(snapshot.path);
594
+ }
595
+ catch {
596
+ conflicts.push(snapshot.path);
353
597
  }
354
598
  }
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}` };
599
+ if (restorable.some((snapshot) => !snapshot.existed && fs.exists(resolve(root, snapshot.path))) &&
600
+ !fs.remove)
601
+ return { exitCode: 1, error: "Filesystem cannot remove obsolete migration resources." };
602
+ const rollbackChanges = restorable.map((snapshot) => ({
603
+ path: resolve(root, snapshot.path),
604
+ content: snapshot.existed ? Buffer.from(snapshot.contentBase64, "base64") : undefined,
605
+ }));
606
+ if (conflicts.length) {
607
+ const remaining = snapshots.filter((snapshot) => conflicts.includes(snapshot.path));
608
+ const remainingPostMigration = postMigrationSnapshots.filter((snapshot) => conflicts.includes(snapshot.path));
609
+ rollbackChanges.push({
610
+ path: backup,
611
+ content: Buffer.from(serializeMigrationCheckpoint(remaining, remainingPostMigration), "utf8"),
612
+ });
613
+ }
614
+ else {
615
+ rollbackChanges.push({ path: backup, content: undefined });
616
+ }
617
+ applyProjectFileChanges(fs, root, rollbackChanges);
618
+ if (conflicts.length) {
619
+ return {
620
+ exitCode: 1,
621
+ error: `Migration rollback preserved modified/conflicting files: ${[...new Set(conflicts)].sort().join(", ")}`,
622
+ };
623
+ }
624
+ return {
625
+ exitCode: 0,
626
+ output: "Migration rolled back; prior resources, ownership, and target state were restored.",
627
+ };
360
628
  }
361
629
  if (command === "confirm") {
362
630
  if (!fs.exists(join(aiw, "manifest.yml")))
@@ -387,18 +655,25 @@ export async function runCommand(args, root, fs, services = {}) {
387
655
  return { exitCode: 1, error: "Run `aiw install` first." };
388
656
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
389
657
  const recommendations = services.recommender?.recommend(profile) ?? recommendCapabilities(profile);
390
- const selectedArg = args.find((arg) => arg.startsWith("--select="))?.split("=", 2)[1];
391
- if (!selectedArg && !stdin.isTTY) {
392
- return {
393
- exitCode: 1,
394
- error: "Interactive selection requires a TTY. Use --select=id,id in non-interactive environments.",
395
- };
658
+ const selectedOption = args.find((arg) => arg.startsWith("--select="));
659
+ const selectedArg = selectedOption?.slice("--select=".length) ?? "";
660
+ let selection;
661
+ if (selectedOption !== undefined)
662
+ selection = await selectRecommendations(recommendations, async () => "n", selectedArg ? selectedArg.split(",") : []);
663
+ else if (!stdin.isTTY)
664
+ selection = [];
665
+ else {
666
+ const terminal = createInterface({ input: stdin, output: stdout });
667
+ selection = await selectRecommendations(recommendations, (question) => terminal.question(question));
668
+ terminal.close();
396
669
  }
397
- const terminal = createInterface({ input: stdin, output: stdout });
398
- const selection = await selectRecommendations(recommendations, (question) => terminal.question(question), selectedArg?.split(","));
399
- terminal.close();
400
670
  fs.write(join(aiw, "recommendations.yml"), serializeRecommendations(recommendations, selection));
401
- return { exitCode: 0, output: `${recommendations.length} recommendations generated.` };
671
+ return {
672
+ exitCode: 0,
673
+ output: selectedOption === undefined && !stdin.isTTY
674
+ ? `${recommendations.length} recommendations generated without selection; review .aiw/recommendations.yml, then record user choices with --select=id,id.`
675
+ : `${recommendations.length} recommendations generated.`,
676
+ };
402
677
  }
403
678
  if (command === "skills") {
404
679
  if (!fs.exists(join(aiw, "manifest.yml")))
@@ -490,6 +765,12 @@ export async function runCommand(args, root, fs, services = {}) {
490
765
  await installVercelSkill(services.externalSkills ?? nodeCommandExecutor, "vercel-labs/agent-skills", "vercel-react-best-practices", target);
491
766
  writeVercelSkillLock(fs, root, aiw, "vercel-labs/agent-skills", "vercel-react-best-practices");
492
767
  }
768
+ if (command === "sync") {
769
+ const result = syncSelectedResources(fs, root, aiw, profile, selected);
770
+ return result.error
771
+ ? { exitCode: 1, error: result.error }
772
+ : { exitCode: 0, output: result.output };
773
+ }
493
774
  const conflicts = [];
494
775
  generateProjectResources(profile, selected, (path, content) => {
495
776
  const destination = join(aiw, path);
@@ -520,12 +801,13 @@ export async function runCommand(args, root, fs, services = {}) {
520
801
  }
521
802
  }
522
803
  if (command === "doctor") {
523
- const required = ["manifest.yml", "profile.yml", "lock.yml"];
804
+ const required = ["manifest.yml", "profile.yml", "lock.yml", "ownership.yml"];
524
805
  const missing = required.filter((file) => !fs.exists(join(aiw, file)));
525
806
  if (missing.length)
526
807
  return { exitCode: 1, error: `Missing AI Workflow files: ${missing.join(", ")}` };
527
808
  try {
528
809
  parseManifest(fs.read(join(aiw, "manifest.yml")));
810
+ parseOwnership(fs.read(join(aiw, "ownership.yml")));
529
811
  if (fs.exists(join(aiw, "team-preset.yml")))
530
812
  parseTeamPreset(fs.read(join(aiw, "team-preset.yml")));
531
813
  if (fs.exists(join(aiw, "registries.yml")))
@@ -547,9 +829,21 @@ export async function runCommand(args, root, fs, services = {}) {
547
829
  return { exitCode: 0, output: "AI Workflow state repaired." };
548
830
  }
549
831
  if (command === "uninstall") {
550
- if (!fs.exists(join(aiw, "manifest.yml")))
832
+ const manifestPath = join(aiw, "manifest.yml");
833
+ assertSafeProjectPath(fs, root, manifestPath);
834
+ if (!fs.exists(manifestPath))
551
835
  return { exitCode: 1, error: "AI Workflow is not installed." };
552
- const files = [
836
+ const ownershipPath = join(aiw, "ownership.yml");
837
+ assertSafeProjectPath(fs, root, ownershipPath);
838
+ if (!fs.exists(ownershipPath))
839
+ return {
840
+ exitCode: 1,
841
+ error: "AIW ownership inventory is missing; refusing to remove resources whose ownership cannot be verified.",
842
+ };
843
+ if (fs.pathType?.(ownershipPath) !== "file")
844
+ throw new Error("AIW ownership inventory is not a regular in-project file.");
845
+ const ownedFiles = parseOwnership(fs.read(ownershipPath));
846
+ const stateFiles = [
553
847
  "manifest.yml",
554
848
  "profile.yml",
555
849
  "lock.yml",
@@ -558,17 +852,71 @@ export async function runCommand(args, root, fs, services = {}) {
558
852
  "team-preset.yml",
559
853
  "registries.yml",
560
854
  "telemetry.yml",
855
+ "checkpoints/migration-backup.yml",
561
856
  ];
562
857
  const dryRun = args.includes("--dry-run");
563
858
  if (!dryRun && !fs.remove)
564
859
  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)));
860
+ const removable = [];
861
+ const conflicts = [];
862
+ for (const file of ownedFiles) {
863
+ const path = resolve(root, file.path);
864
+ try {
865
+ assertSafeProjectPath(fs, root, path);
866
+ if (!fs.exists(path))
867
+ continue;
868
+ if (fs.pathType?.(path) !== "file") {
869
+ conflicts.push(file.path);
870
+ continue;
871
+ }
872
+ const actual = `sha256-${checksumPackage(fs.readBytes?.(path) ?? fs.read(path))}`;
873
+ if (actual === file.checksum)
874
+ removable.push(path);
875
+ else
876
+ conflicts.push(file.path);
877
+ }
878
+ catch {
879
+ conflicts.push(file.path);
880
+ }
881
+ }
882
+ const statePaths = [];
883
+ for (const file of stateFiles) {
884
+ const path = join(aiw, file);
885
+ if (!fs.exists(path))
886
+ continue;
887
+ assertSafeProjectPath(fs, root, path);
888
+ if (fs.pathType?.(path) !== "file")
889
+ conflicts.push(`${WORKFLOW_DIRECTORY}/${file}`);
890
+ else
891
+ statePaths.push(path);
892
+ }
893
+ const uniqueConflicts = [...new Set(conflicts)].sort();
894
+ const allRemovable = [...new Set([...removable, ...statePaths, ownershipPath])];
895
+ const relativeRemovable = allRemovable
896
+ .map((path) => relative(root, path).split(sep).join("/"))
897
+ .sort();
898
+ if (dryRun) {
899
+ const report = [`Would remove: ${relativeRemovable.join(", ") || "none"}`];
900
+ if (uniqueConflicts.length)
901
+ report.push(`Would preserve modified/conflicting files: ${uniqueConflicts.join(", ")}`);
902
+ return { exitCode: uniqueConflicts.length ? 1 : 0, output: report.join("\n") };
903
+ }
904
+ removable.forEach((path) => fs.remove?.(path));
905
+ statePaths
906
+ .filter((path) => !path.endsWith(`${sep}manifest.yml`))
907
+ .forEach((path) => fs.remove?.(path));
908
+ if (statePaths.includes(manifestPath))
909
+ fs.remove?.(manifestPath);
910
+ fs.remove?.(ownershipPath);
911
+ const removed = relativeRemovable.filter((path) => !uniqueConflicts.includes(path));
569
912
  return {
570
- exitCode: 0,
571
- output: dryRun ? `Would remove: ${files.join(", ")}` : "AI Workflow-owned files removed.",
913
+ exitCode: uniqueConflicts.length ? 1 : 0,
914
+ output: [
915
+ `AI Workflow-owned files removed: ${removed.join(", ") || "none"}.`,
916
+ ...(uniqueConflicts.length
917
+ ? [`Preserved modified/conflicting files: ${uniqueConflicts.join(", ")}`]
918
+ : []),
919
+ ].join("\n"),
572
920
  };
573
921
  }
574
922
  if (command === "brainstorm") {
@@ -707,15 +1055,27 @@ export async function runCommand(args, root, fs, services = {}) {
707
1055
  const sourceArg = args.find((arg) => arg.startsWith("--source="));
708
1056
  if (!packageArg && !sourceArg)
709
1057
  return { exitCode: 1, error: "Usage: aiw resolve --package=path | --source=source" };
1058
+ const approvedPermissions = parseApprovedPermissions(args);
710
1059
  if (sourceArg) {
711
1060
  const source = resolveProvider(sourceArg.slice(9), root);
712
- enforceConfiguredOrganizationPolicy(fs, aiw, { ...source, permissions: [] });
1061
+ const networkPermissions = requiresExternalNetwork(source) ? ["network:external"] : [];
1062
+ enforceConfiguredOrganizationPolicy(fs, aiw, {
1063
+ ...source,
1064
+ permissions: networkPermissions,
1065
+ });
1066
+ assertPermissions(networkPermissions, approvedPermissions);
713
1067
  }
714
1068
  const loaded = sourceArg
715
1069
  ? (services.packageSources ?? nodePackageSourceLoader).load(sourceArg.slice(9), root)
716
1070
  : undefined;
717
1071
  try {
718
- const pkg = validatePackageContract(loaded?.manifest ?? fs.read(join(root, packageArg.slice(10))), loaded ? (path) => fs.exists(join(loaded.root, path)) : undefined);
1072
+ const manifestPath = packageArg ? resolve(root, packageArg.slice(10)) : undefined;
1073
+ const directManifestBytes = loaded ? undefined : fs.readBytes?.(manifestPath);
1074
+ const manifestContent = loaded?.manifest ??
1075
+ (directManifestBytes
1076
+ ? Buffer.from(directManifestBytes).toString("utf8")
1077
+ : fs.read(manifestPath));
1078
+ const pkg = validatePackageContract(manifestContent, loaded ? (path) => fs.exists(join(loaded.root, path)) : undefined);
719
1079
  const resolved = loaded
720
1080
  ? {
721
1081
  ...pkg,
@@ -725,8 +1085,18 @@ export async function runCommand(args, root, fs, services = {}) {
725
1085
  }
726
1086
  : pkg;
727
1087
  enforceConfiguredOrganizationPolicy(fs, aiw, resolved);
728
- assertPackagePermissions(resolved, parseApprovedPermissions(args));
729
- fs.write(join(aiw, "lock.yml"), serializeLock([resolvePackage(resolved)]));
1088
+ assertPackagePermissions(resolved, approvedPermissions);
1089
+ const packageRoot = loaded?.root ?? dirname(manifestPath);
1090
+ const manifestBytes = loaded
1091
+ ? (loaded.manifestBytes ?? Buffer.from(loaded.manifest, "utf8"))
1092
+ : (directManifestBytes ?? Buffer.from(manifestContent, "utf8"));
1093
+ const integrity = checksumPackageSnapshot(manifestBytes, resolved, packageRoot, fs, loaded ? "package.yaml" : basename(manifestPath));
1094
+ const lockPath = join(aiw, "lock.yml");
1095
+ const locked = fs.exists(lockPath) ? parseLock(fs.read(lockPath)) : [];
1096
+ fs.write(lockPath, serializeLock([
1097
+ ...locked.filter(({ id }) => id !== resolved.id),
1098
+ resolvePackage(resolved, integrity),
1099
+ ]));
730
1100
  return { exitCode: 0, output: `Package resolved: ${resolved.id}@${resolved.version}` };
731
1101
  }
732
1102
  finally {
@@ -816,7 +1186,12 @@ export async function runCommand(args, root, fs, services = {}) {
816
1186
  const packageArg = args.find((arg) => arg.startsWith("--package="));
817
1187
  if (!packageArg)
818
1188
  return { exitCode: 1, error: "Usage: aiw update --package=path --target=target" };
819
- const pkg = validatePackageContract(fs.read(join(root, packageArg.slice(10))));
1189
+ const manifestPath = resolve(root, packageArg.slice(10));
1190
+ const manifestBytes = fs.readBytes?.(manifestPath);
1191
+ const manifestContent = manifestBytes
1192
+ ? Buffer.from(manifestBytes).toString("utf8")
1193
+ : fs.read(manifestPath);
1194
+ const pkg = validatePackageContract(manifestContent);
820
1195
  enforceConfiguredOrganizationPolicy(fs, aiw, pkg);
821
1196
  assertPackagePermissions(pkg, parseApprovedPermissions(args));
822
1197
  const target = args.find((arg) => arg.startsWith("--target="))?.slice(9) ?? "universal";
@@ -825,11 +1200,14 @@ export async function runCommand(args, root, fs, services = {}) {
825
1200
  const lock = fs.exists(join(aiw, "lock.yml")) ? fs.read(join(aiw, "lock.yml")) : "";
826
1201
  const lockedPackages = lock ? parseLock(lock) : [];
827
1202
  const current = lockedPackages.find(({ id }) => id === pkg.id);
1203
+ const integrity = checksumPackageSnapshot(manifestBytes ?? Buffer.from(manifestContent, "utf8"), pkg, dirname(manifestPath), fs, basename(manifestPath));
1204
+ if (current?.version === pkg.version && current.integrity !== integrity)
1205
+ throw new Error(`Package integrity mismatch for ${pkg.id}@${pkg.version}; package bytes changed.`);
828
1206
  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
1207
  if (plan.status !== "update")
830
1208
  return { exitCode: 1, error: plan.reason ?? `Update blocked: ${plan.status}.` };
831
1209
  const existingPackages = lockedPackages.filter(({ id }) => id !== pkg.id);
832
- fs.write(join(aiw, "lock.yml"), serializeLock([...existingPackages, resolvePackage(pkg)]));
1210
+ fs.write(join(aiw, "lock.yml"), serializeLock([...existingPackages, resolvePackage(pkg, integrity)]));
833
1211
  return { exitCode: 0, output: `Package updated: ${pkg.id}@${pkg.version}` };
834
1212
  }
835
1213
  if (command === "registry") {
@@ -1043,6 +1421,81 @@ function isInsidePath(root, destination) {
1043
1421
  const path = relative(root, destination);
1044
1422
  return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
1045
1423
  }
1424
+ function snapshotProjectFiles(fs, root, paths) {
1425
+ const snapshots = [];
1426
+ for (const path of [...new Set(paths)]) {
1427
+ assertSafeProjectPath(fs, root, path);
1428
+ const type = fs.pathType?.(path);
1429
+ if (type === "missing") {
1430
+ snapshots.push({
1431
+ path: relative(root, path).split(sep).join("/"),
1432
+ existed: false,
1433
+ contentBase64: "",
1434
+ });
1435
+ continue;
1436
+ }
1437
+ if (type !== "file")
1438
+ throw new Error(`Migration path is not a regular project file: ${relative(root, path)}`);
1439
+ snapshots.push({
1440
+ path: relative(root, path).split(sep).join("/"),
1441
+ existed: true,
1442
+ contentBase64: Buffer.from(readResourceBytes(fs, path)).toString("base64"),
1443
+ });
1444
+ }
1445
+ return snapshots;
1446
+ }
1447
+ function expectedMigrationSnapshots(root, changes) {
1448
+ return [...new Map(changes.map((change) => [change.path, change])).values()].map(({ path, content }) => ({
1449
+ path: relative(root, path).split(sep).join("/"),
1450
+ existed: content !== undefined,
1451
+ contentBase64: content ? Buffer.from(content).toString("base64") : "",
1452
+ }));
1453
+ }
1454
+ function migrationSnapshotMatches(fs, path, expected) {
1455
+ const type = fs.pathType?.(path);
1456
+ if (!expected.existed)
1457
+ return type === "missing";
1458
+ if (type !== "file")
1459
+ return false;
1460
+ return Buffer.from(readResourceBytes(fs, path)).equals(Buffer.from(expected.contentBase64, "base64"));
1461
+ }
1462
+ function applyProjectFileChanges(fs, root, changes) {
1463
+ const ordered = [...new Map(changes.map((change) => [change.path, change])).values()];
1464
+ if (ordered.some(({ content }) => content === undefined) && !fs.remove)
1465
+ throw new Error("Filesystem cannot remove obsolete migration resources.");
1466
+ const snapshots = snapshotProjectFiles(fs, root, ordered.map(({ path }) => path));
1467
+ try {
1468
+ for (const change of ordered) {
1469
+ if (change.content === undefined) {
1470
+ if (fs.exists(change.path))
1471
+ fs.remove?.(change.path);
1472
+ }
1473
+ else {
1474
+ writeResourceBytes(fs, change.path, change.content);
1475
+ }
1476
+ }
1477
+ }
1478
+ catch (error) {
1479
+ let rollbackFailed = false;
1480
+ for (const snapshot of [...snapshots].reverse()) {
1481
+ const path = resolve(root, snapshot.path);
1482
+ try {
1483
+ if (snapshot.existed)
1484
+ writeResourceBytes(fs, path, Buffer.from(snapshot.contentBase64, "base64"));
1485
+ else if (fs.exists(path))
1486
+ fs.remove?.(path);
1487
+ }
1488
+ catch {
1489
+ rollbackFailed = true;
1490
+ }
1491
+ }
1492
+ if (rollbackFailed)
1493
+ throw new Error("Migration failed and automatic rollback was incomplete; use aiw rollback.", {
1494
+ cause: error,
1495
+ });
1496
+ throw error;
1497
+ }
1498
+ }
1046
1499
  function parsePrivateRegistryPackages(payload, registryUrl) {
1047
1500
  if (!Array.isArray(payload))
1048
1501
  throw new Error("Private registry response must be an array.");
@@ -1080,6 +1533,135 @@ function containsCredential(value, credential, visited = new Set()) {
1080
1533
  visited.add(value);
1081
1534
  return Object.values(value).some((entry) => containsCredential(entry, credential, visited));
1082
1535
  }
1536
+ function syncSelectedResources(fs, root, aiw, profile, selected) {
1537
+ const target = parseManifest(fs.read(join(aiw, "manifest.yml"))).target.active;
1538
+ const resourceSelections = resourcesForCapabilities(selected);
1539
+ const bundled = loadBundledResources(target);
1540
+ const bundleByKey = new Map(bundled.map((resource) => [`${resource.type}/${resource.id}`, resource]));
1541
+ const desired = new Map();
1542
+ const generatedFiles = new Map();
1543
+ const addOwned = (path, content, metadata) => {
1544
+ const previous = desired.get(path);
1545
+ if (previous && previous.content !== content)
1546
+ throw new Error(`Selected resources render conflicting content at ${path}`);
1547
+ desired.set(path, { content, metadata });
1548
+ };
1549
+ const addResource = (type, id, content) => {
1550
+ const neutralPath = join(root, renderResourcePath("universal", type, id));
1551
+ const targetPath = join(root, renderResourcePath(target, type, id));
1552
+ addOwned(neutralPath, content, {
1553
+ kind: "neutral-resource",
1554
+ target: "universal",
1555
+ resourceType: type,
1556
+ resourceId: id,
1557
+ });
1558
+ if (targetPath !== neutralPath)
1559
+ addOwned(targetPath, content, {
1560
+ kind: "target-resource",
1561
+ target,
1562
+ resourceType: type,
1563
+ resourceId: id,
1564
+ });
1565
+ };
1566
+ for (const reference of resourceSelections) {
1567
+ const key = `${reference.type}/${reference.id}`;
1568
+ const resource = bundleByKey.get(key);
1569
+ if (!resource)
1570
+ return { error: `Bundled resource is unavailable: ${key}` };
1571
+ addResource(reference.type, reference.id, personalizeBundledResource(reference.type, reference.id, resource.content, profile));
1572
+ }
1573
+ generateProjectResources(profile, selected, (path, content) => {
1574
+ generatedFiles.set(join(aiw, path), content);
1575
+ const rule = path.match(/^generated\/rules\/(project-quality|tdd-project-policy)\.md$/);
1576
+ if (rule)
1577
+ addResource("rules", rule[1], content);
1578
+ });
1579
+ const ownershipPath = join(aiw, "ownership.yml");
1580
+ if (fs.pathType?.(ownershipPath) !== "file")
1581
+ return {
1582
+ error: "AI Workflow ownership inventory is missing or unsafe; reinstall before syncing.",
1583
+ };
1584
+ const inventory = parseOwnership(fs.read(ownershipPath));
1585
+ const nextInventory = new Map(inventory.map((entry) => [entry.path, entry]));
1586
+ const changes = [];
1587
+ const conflicts = [];
1588
+ for (const [path, { content, metadata }] of desired) {
1589
+ assertSafeProjectPath(fs, root, path);
1590
+ const relativePath = relative(root, path).split(sep).join("/");
1591
+ const type = fs.pathType?.(path);
1592
+ const owned = nextInventory.get(relativePath);
1593
+ if (type === "missing") {
1594
+ changes.push({ path, content: Buffer.from(content, "utf8") });
1595
+ nextInventory.set(relativePath, createOwnedFile(relativePath, content, metadata));
1596
+ continue;
1597
+ }
1598
+ if (type !== "file" || !owned) {
1599
+ conflicts.push(relativePath);
1600
+ continue;
1601
+ }
1602
+ const actual = fs.read(path);
1603
+ if (createOwnedFile(relativePath, actual).checksum !== owned.checksum) {
1604
+ conflicts.push(relativePath);
1605
+ continue;
1606
+ }
1607
+ if (actual !== content)
1608
+ changes.push({ path, content: Buffer.from(content, "utf8") });
1609
+ nextInventory.set(relativePath, createOwnedFile(relativePath, content, metadata));
1610
+ }
1611
+ for (const [path, content] of generatedFiles) {
1612
+ assertSafeProjectPath(fs, root, path);
1613
+ const type = fs.pathType?.(path);
1614
+ if (type === "missing")
1615
+ changes.push({ path, content: Buffer.from(content, "utf8") });
1616
+ else if (type !== "file" || fs.read(path) !== content)
1617
+ conflicts.push(relative(root, path).split(sep).join("/"));
1618
+ }
1619
+ if (conflicts.length)
1620
+ return {
1621
+ error: `Sync preserved existing or modified project resources: ${[...new Set(conflicts)].sort().join(", ")}`,
1622
+ };
1623
+ const nextOwnership = serializeOwnership([...nextInventory.values()]);
1624
+ if (fs.read(ownershipPath) !== nextOwnership)
1625
+ changes.push({ path: ownershipPath, content: Buffer.from(nextOwnership, "utf8") });
1626
+ if (changes.length)
1627
+ applyProjectFileChanges(fs, root, changes);
1628
+ return {
1629
+ output: `Synchronized ${resourceSelections.length} selected bundled resources for ${target} and generated ${generatedFiles.size} project-specific files.`,
1630
+ };
1631
+ }
1632
+ function personalizeBundledResource(type, id, content, profile) {
1633
+ if (type !== "rules" || !["quality-gates", "tdd-policy", "dependency-policy"].includes(id))
1634
+ return content;
1635
+ const signals = [
1636
+ profile.runtime.languages.length ? `- Languages: ${profile.runtime.languages.join(", ")}` : "",
1637
+ profile.frameworks.length ? `- Frameworks: ${profile.frameworks.join(", ")}` : "",
1638
+ profile.testing ? `- Test runner: ${profile.testing.name}` : "",
1639
+ profile.testing?.command
1640
+ ? `- Test command: \`${safeProjectSignal(profile.testing.command)}\``
1641
+ : "",
1642
+ profile.quality.linter ? `- Linter: ${profile.quality.linter.name}` : "",
1643
+ profile.quality.linter?.command
1644
+ ? `- Lint command: \`${safeProjectSignal(profile.quality.linter.command)}\``
1645
+ : "",
1646
+ profile.quality.formatter ? `- Formatter: ${profile.quality.formatter.name}` : "",
1647
+ profile.quality.formatter?.command
1648
+ ? `- Format command: \`${safeProjectSignal(profile.quality.formatter.command)}\``
1649
+ : "",
1650
+ ].filter(Boolean);
1651
+ return signals.length
1652
+ ? `${content.trimEnd()}\n\n## Detected project context\n\n${signals.join("\n")}\n`
1653
+ : content;
1654
+ }
1655
+ function safeProjectSignal(value) {
1656
+ return value
1657
+ .split("")
1658
+ .map((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 || character === "`"
1659
+ ? " "
1660
+ : character)
1661
+ .join("")
1662
+ .replace(/\s+/g, " ")
1663
+ .slice(0, 180);
1664
+ }
1083
1665
  function readSelectedRecommendations(fs, aiw) {
1084
1666
  if (!fs.exists(join(aiw, "recommendations.yml")))
1085
1667
  return [];