@multileaf/ai-workflow 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/blocking-reasons.d.ts +1 -1
  3. package/dist/blocking-reasons.js +30 -0
  4. package/dist/digest.d.ts +48 -0
  5. package/dist/digest.js +106 -0
  6. package/dist/eval/artifact-evals.d.ts +42 -0
  7. package/dist/eval/artifact-evals.js +141 -0
  8. package/dist/generator.js +1 -1
  9. package/dist/human-approval.d.ts +13 -2
  10. package/dist/human-approval.js +43 -8
  11. package/dist/legacy-migration.d.ts +45 -0
  12. package/dist/legacy-migration.js +175 -0
  13. package/dist/orchestration.d.ts +38 -1
  14. package/dist/orchestration.js +82 -3
  15. package/dist/quality-gates.d.ts +1 -1
  16. package/dist/quality-gates.js +23 -0
  17. package/dist/risk-assess.d.ts +4 -0
  18. package/dist/risk-assess.js +5 -1
  19. package/dist/standards.d.ts +34 -0
  20. package/dist/standards.js +159 -0
  21. package/dist/types.d.ts +15 -1
  22. package/dist/types.js +24 -0
  23. package/dist/workflow.d.ts +7 -0
  24. package/dist/workflow.js +504 -59
  25. package/docs/backlog.md +13 -3
  26. package/docs/continuation-inventory.md +21 -16
  27. package/docs/opportunities/OPP-009-process-entry-points.md +56 -0
  28. package/docs/opportunities/OPP-010-orchestration-worktree-isolation.md +52 -0
  29. package/docs/opportunities/OPP-011-evals-redteam-generated-artifacts.md +56 -0
  30. package/docs/opportunities/OPP-012-standards-discovery.md +51 -0
  31. package/docs/opportunities/OPP-013-read-optimized-artifacts-digest-layer.md +52 -0
  32. package/docs/opportunities/OPP-014-paired-verification-contract.md +54 -0
  33. package/docs/opportunities/OPP-015-token-recovery-governance.md +52 -0
  34. package/docs/opportunities/OPP-016-behavioral-eval-scenarios.md +44 -0
  35. package/docs/opportunities/OPP-017-orchestration-depth.md +50 -0
  36. package/docs/opportunities/OPP-018-legacy-migration.md +59 -0
  37. package/docs/opportunities/README.md +32 -10
  38. package/docs/release-process.md +1 -1
  39. package/package.json +1 -1
  40. package/resources/skills/implementation-planning.md +1 -1
  41. package/resources/templates/brainstorm.md +0 -1
  42. package/resources/templates/decision-record.md +0 -1
  43. package/resources/templates/implementation-evidence.md +1 -1
  44. package/resources/templates/implementation-plan.md +2 -1
  45. package/resources/templates/migration-report.md +0 -1
  46. package/resources/templates/security-review.md +0 -1
  47. package/resources/templates/task.md +0 -1
  48. package/resources/templates/technical-design.md +0 -1
  49. package/resources/templates/test-plan.md +0 -1
  50. package/resources/templates/verification-report.md +2 -1
package/dist/workflow.js CHANGED
@@ -50,7 +50,7 @@ import { composeContext, CONTEXT_LAYERS, readContextStore, writeContextLayer, }
50
50
  import { loadContextDelta, retrieveTaskContext } from "./context-retrieval.js";
51
51
  import { createSummaryCache, isSummaryFresh, parseSummaryCache, serializeSummaryCache, } from "./context-cache.js";
52
52
  import { parseTokenUsage, recordTokenUsage, serializeTokenUsage, summarizeUsageByModel, } from "./token-usage.js";
53
- import { APPROVAL_STAGES, assertWorkflowId, validateTaskTicket, approveStage, beginStageApproval, createApprovalLedger, getUnresolvedPrerequisites, invalidateStageAndDescendants, parseApprovalLedger, parseWorkflowSession, rejectStage, serializeApprovalLedger, serializeWorkflowSession, skipOptionalStage, stageDefinition, stageFingerprint, effectiveLane, } from "./human-approval.js";
53
+ import { APPROVAL_STAGES, PROCESS_IDS, processStages, isWorkflowProcess, assertWorkflowId, validateTaskTicket, approveStage, beginStageApproval, createApprovalLedger, getUnresolvedPrerequisites, invalidateStageAndDescendants, parseApprovalLedger, parseWorkflowSession, rejectStage, serializeApprovalLedger, serializeWorkflowSession, skipOptionalStage, stageDefinition, stageFingerprint, effectiveLane, } from "./human-approval.js";
54
54
  import { parse as parseYaml, stringify } from "yaml";
55
55
  import { measureContextQuality, serializeContextQuality } from "./context-quality.js";
56
56
  import { collectContextBench, compareContextBench, parseContextBench, serializeContextBench, } from "./context-benchmark.js";
@@ -65,14 +65,45 @@ import { parseTraceabilityGraph } from "./traceability.js";
65
65
  import { serializeAdapterCapabilities } from "./adapter-contract.js";
66
66
  import { buildTraceabilityGraph, serializeTraceabilityGraph } from "./traceability.js";
67
67
  import { evaluateQualityGate } from "./quality-gates.js";
68
- import { BlockingError, blockingResultToJson, describeBlockingReason } from "./blocking-reasons.js";
69
- import { runRiskAssess } from "./risk-assess.js";
68
+ import { BlockingError, blockingResultToJson, describeBlockingReason, formatBlockingOutput, } from "./blocking-reasons.js";
69
+ import { assessRisk, collectWorkingTreeChanges, hasCoveringTest, runRiskAssess, } from "./risk-assess.js";
70
+ import { applyMigration, detectLegacyState, planLegacyMigration, rollbackMigration, } from "./legacy-migration.js";
71
+ import { extractStandards, parseStandardsIndex } from "./standards.js";
72
+ import { digestPath, digestFreshness, extractDigest, formatDigestLine, serializeDigest, } from "./digest.js";
73
+ function installationRequired() {
74
+ return {
75
+ exitCode: 1,
76
+ error: formatBlockingOutput(describeBlockingReason("installation_required", {
77
+ detail: "Run `aiw install` first.",
78
+ })),
79
+ };
80
+ }
81
+ function stateMismatch(detail) {
82
+ return {
83
+ exitCode: 1,
84
+ error: formatBlockingOutput(describeBlockingReason("state_mismatch", { detail })),
85
+ };
86
+ }
70
87
  import { parseMigrationCheckpoint, planNeutralResourceMigration, readResourceBytes, serializeMigrationPreview, serializeMigrationCheckpoint, validateNeutralResourceDocument, writeResourceBytes, } from "./migration.js";
71
88
  import { parsePrivateRegistries, parseTeamPreset, serializePrivateRegistries, serializeTeamPreset, fetchPrivateRegistryClient, } from "./team-configuration.js";
72
89
  import { enforcePolicyGate, gitPolicyTracking } from "./policy-gate.js";
73
90
  import { DEFAULT_TELEMETRY_CONFIG, loadTelemetryConfig, offerTelemetryConsent, parseTelemetryConfig, purgeTelemetryBuffer, readTelemetryBuffer, serializeTelemetryMapping, TELEMETRY_BUFFER_CAP, TELEMETRY_BUFFER_FILE, } from "./telemetry.js";
74
91
  import { createPluginScaffold, validatePluginDirectory, writePluginScaffold, } from "./plugin-sdk.js";
75
- import { executeOrchestrationPlan, parseOrchestrationPlan, serializeOrchestrationPreview, serializeOrchestrationResults, validateOrchestrationParallelism, } from "./orchestration.js";
92
+ import { createTaskWorktrees, executeOrchestrationPlan, listManagedWorktrees, removeWorktrees, parseOrchestrationPlan, serializeOrchestrationPreview, serializeOrchestrationResults, validateOrchestrationParallelism, } from "./orchestration.js";
93
+ function laneAdvisory(root, fs, lightChosen) {
94
+ const paths = collectWorkingTreeChanges(root);
95
+ if (!paths || paths.length === 0)
96
+ return undefined;
97
+ const assessment = assessRisk(paths.map((path) => ({ path })), { hasCoveringTest: hasCoveringTest(root, fs) });
98
+ if (lightChosen && assessment.tier === "high")
99
+ return `advisory: risk assess reports high for the current changes (${assessment.reasons
100
+ .slice(0, 2)
101
+ .map((reason) => reason.code)
102
+ .join(", ")}); consider the full lane, or escalate later with \`aiw task escalate --reason=...\`.`;
103
+ if (!lightChosen && assessment.tier === "passive")
104
+ return "advisory: current changes look passive; the light lane (--lane=light --lane-reason=...) may be viable for small work.";
105
+ return undefined;
106
+ }
76
107
  function beginWorkflowSession(fs, aiw, options) {
77
108
  const sessionPath = join(aiw, "workflow.yml");
78
109
  const ledgerPath = join(aiw, "approvals.yml");
@@ -83,7 +114,7 @@ function beginWorkflowSession(fs, aiw, options) {
83
114
  detail: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
84
115
  }));
85
116
  if (!fs.exists(ledgerPath))
86
- return { exitCode: 1, error: "Completed workflow is missing its approval ledger." };
117
+ return stateMismatch("Completed workflow is missing its approval ledger.");
87
118
  const archive = join(aiw, `checkpoints/workflow-${previous.workflowId}-approvals.yml`);
88
119
  if (fs.exists(archive))
89
120
  return { exitCode: 1, error: `Workflow approval archive already exists: ${archive}` };
@@ -93,7 +124,7 @@ function beginWorkflowSession(fs, aiw, options) {
93
124
  else if (fs.exists(ledgerPath)) {
94
125
  return {
95
126
  exitCode: 1,
96
- error: "Approval ledger exists without a workflow session; run doctor.",
127
+ error: stateMismatch("Approval ledger exists without a workflow session; run doctor.").error,
97
128
  };
98
129
  }
99
130
  const workflowId = options.id ?? randomUUID();
@@ -120,6 +151,7 @@ function beginWorkflowSession(fs, aiw, options) {
120
151
  ...(options.lane === "light" && options.laneReason
121
152
  ? { lane: "light", laneReason: options.laneReason }
122
153
  : {}),
154
+ ...(options.process && options.process !== "feature" ? { process: options.process } : {}),
123
155
  });
124
156
  fs.write(ledgerPath, serializeApprovalLedger(ledger));
125
157
  fs.write(sessionPath, serializeWorkflowSession(session));
@@ -132,7 +164,7 @@ export async function runCommand(args, root, fs, services = {}) {
132
164
  const command = args[0] ?? "help";
133
165
  if (command === "task") {
134
166
  if (!fs.exists(join(aiw, "manifest.yml")))
135
- return { exitCode: 1, error: "Run `aiw install` first." };
167
+ return installationRequired();
136
168
  const usage = "Usage: aiw task start --ticket=TEAM-123 [--title=text] [--id=workflow-id] [--lane=light --lane-reason=text] | aiw task status | aiw task escalate --reason=text | aiw task evidence --validation=text";
137
169
  const action = args[1];
138
170
  const sessionPath = join(aiw, "workflow.yml");
@@ -143,10 +175,24 @@ export async function runCommand(args, root, fs, services = {}) {
143
175
  "id",
144
176
  "lane",
145
177
  "lane-reason",
178
+ "process",
146
179
  ]);
147
180
  if (!options?.ticket)
148
181
  return { exitCode: 1, error: usage };
149
182
  validateTaskTicket(options.ticket);
183
+ const processName = options.process;
184
+ if (processName !== undefined) {
185
+ if (!isWorkflowProcess(processName))
186
+ return {
187
+ exitCode: 1,
188
+ error: `Unknown process: ${processName}. Known processes: ${PROCESS_IDS.join(", ")}.`,
189
+ };
190
+ if (processName === "feature")
191
+ return {
192
+ exitCode: 1,
193
+ error: "The feature process is the default; omit --process for it.",
194
+ };
195
+ }
150
196
  if (options.lane !== undefined && options.lane !== "light")
151
197
  return { exitCode: 1, error: "Only --lane=light exists; omit it for the full lane." };
152
198
  const laneReason = options["lane-reason"];
@@ -176,14 +222,17 @@ export async function runCommand(args, root, fs, services = {}) {
176
222
  ticket: options.ticket,
177
223
  ...(options.title ? { title: options.title } : {}),
178
224
  ...(lane ? { lane, laneReason: laneReason } : {}),
225
+ ...(processName ? { process: processName } : {}),
179
226
  });
180
227
  if (result.exitCode !== 0)
181
228
  return result;
229
+ const advisory = laneAdvisory(root, fs, lane === "light");
182
230
  const title = options.title ? ` titled "${options.title}"` : "";
183
231
  if (lane)
184
232
  return {
185
233
  exitCode: 0,
186
234
  output: `Task started for ticket ${options.ticket}${title} (light lane): ${result.output}\n` +
235
+ (advisory ? `${advisory}\n` : "") +
187
236
  "Resume test: this work is small when it is understood, risk is contained, and the work could be resumed from your request plus `git diff`.\n" +
188
237
  `Rationale recorded: ${laneReason}\n` +
189
238
  'No durable spec/plan artifacts are required. Record validation with `aiw task evidence --validation="<command and result>"`, then complete with `aiw workflow complete`. If scope grows, escalate with `aiw task escalate --reason=...`.',
@@ -191,6 +240,7 @@ export async function runCommand(args, root, fs, services = {}) {
191
240
  return {
192
241
  exitCode: 0,
193
242
  output: `Task started for ticket ${options.ticket}${title}: ${result.output}\n` +
243
+ (advisory ? `${advisory}\n` : "") +
194
244
  `Next steps: ask the user whether the requirement is clear enough to skip brainstorming (record an accepted skip with \`aiw skip brainstorming --reason="Requirement provided by ticket ${options.ticket}"\`), then create the specification with \`aiw spec --ticket=${options.ticket} --title=...\`.`,
195
245
  };
196
246
  }
@@ -287,7 +337,7 @@ export async function runCommand(args, root, fs, services = {}) {
287
337
  ...lane,
288
338
  `Workflow: ${session.workflowId} (${session.status})`,
289
339
  "Stages:",
290
- ...APPROVAL_STAGES.map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`),
340
+ ...processStages(ledger).map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`),
291
341
  ];
292
342
  if (session.status === "active" && effectiveLane(session) === "light") {
293
343
  lines.push('Next: record validation with `aiw task evidence --validation="<command and result>"`, then `aiw workflow complete`. If scope grows: `aiw task escalate --reason=...`.');
@@ -302,20 +352,32 @@ export async function runCommand(args, root, fs, services = {}) {
302
352
  }
303
353
  if (command === "workflow") {
304
354
  if (!fs.exists(join(aiw, "manifest.yml")))
305
- return { exitCode: 1, error: "Run `aiw install` first." };
355
+ return installationRequired();
306
356
  const action = args[1];
307
357
  const sessionPath = join(aiw, "workflow.yml");
308
358
  if (action === "start") {
309
- const options = parseNamedOptions(args.slice(2).filter((argument) => argument !== "--json"), ["id"]);
359
+ const options = parseNamedOptions(args.slice(2).filter((argument) => argument !== "--json"), ["id", "process"]);
310
360
  if (!options)
311
- return { exitCode: 1, error: "Usage: aiw workflow start [--id=workflow-id]" };
312
- return beginWorkflowSession(fs, aiw, { id: options.id });
361
+ return {
362
+ exitCode: 1,
363
+ error: "Usage: aiw workflow start [--id=workflow-id] [--process=bugfix|idea-assessment]",
364
+ };
365
+ const processName = options.process;
366
+ if (processName !== undefined && !isWorkflowProcess(processName))
367
+ return {
368
+ exitCode: 1,
369
+ error: `Unknown process: ${processName}. Known processes: ${PROCESS_IDS.join(", ")}.`,
370
+ };
371
+ return beginWorkflowSession(fs, aiw, {
372
+ id: options.id,
373
+ ...(processName ? { process: processName } : {}),
374
+ });
313
375
  }
314
376
  if (action === "status") {
315
377
  if (!fs.exists(sessionPath))
316
378
  return { exitCode: 0, output: "No SDD workflow is active." };
317
379
  const { session, ledger } = readWorkflowState(aiw, fs, false);
318
- const stages = APPROVAL_STAGES.map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`);
380
+ const stages = processStages(ledger).map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`);
319
381
  return {
320
382
  exitCode: 0,
321
383
  output: `Workflow ${session.workflowId} (${session.status})\n${stages.join("\n")}\n${workflowNextAction(ledger)}`,
@@ -382,7 +444,7 @@ export async function runCommand(args, root, fs, services = {}) {
382
444
  output: `Workflow completed (light lane): ${session.workflowId}. Evidence: ${evidencePath}`,
383
445
  };
384
446
  }
385
- const unresolved = APPROVAL_STAGES.filter((stage) => ledger.stages[stage]?.status !== "approved" &&
447
+ const unresolved = processStages(ledger).filter((stage) => ledger.stages[stage]?.status !== "approved" &&
386
448
  ledger.stages[stage]?.status !== "skipped");
387
449
  if (unresolved.length)
388
450
  return {
@@ -457,7 +519,12 @@ export async function runCommand(args, root, fs, services = {}) {
457
519
  updatedAt: new Date().toISOString(),
458
520
  });
459
521
  fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(updated));
460
- return { exitCode: 0, output: `Human approval recorded for ${workflowStage}.` };
522
+ fs.mkdir(join(aiw, "digests"));
523
+ fs.write(digestPath(aiw, workflowStage), serializeDigest(extractDigest(workflowStage, fs.read(artifact), artifactHash)));
524
+ return {
525
+ exitCode: 0,
526
+ output: `Human approval recorded for ${workflowStage}. Digest available: aiw digest --stage=${workflowStage}`,
527
+ };
461
528
  }
462
529
  const options = parseNamedOptions(rest, ["reason"]);
463
530
  if (!options?.reason)
@@ -471,7 +538,7 @@ export async function runCommand(args, root, fs, services = {}) {
471
538
  }
472
539
  if (command === "ui") {
473
540
  if (!fs.exists(join(aiw, "manifest.yml")))
474
- return { exitCode: 1, error: "Run `aiw install` first." };
541
+ return installationRequired();
475
542
  const parsed = parseNamedOptions(args.slice(1), ["port"]);
476
543
  if (!parsed || (parsed.port !== undefined && !/^\d+$/.test(parsed.port)))
477
544
  return { exitCode: 1, error: "Usage: aiw ui [--port=number]" };
@@ -485,20 +552,36 @@ export async function runCommand(args, root, fs, services = {}) {
485
552
  }
486
553
  if (command === "orchestrate") {
487
554
  if (!fs.exists(join(aiw, "manifest.yml")))
488
- return { exitCode: 1, error: "Run `aiw install` first." };
555
+ return installationRequired();
489
556
  const executeCount = args.filter((arg) => arg === "--execute").length;
490
557
  if (executeCount > 1)
491
558
  return {
492
559
  exitCode: 1,
493
- error: "Usage: aiw orchestrate --plan=path [--execute] [--max-parallel=number]",
560
+ error: "Usage: aiw orchestrate --plan=path [--execute] [--isolate] [--cleanup] [--max-parallel=number]",
494
561
  };
495
562
  const execute = executeCount === 1;
496
- const named = args.slice(1).filter((arg) => arg !== "--execute");
563
+ const isolate = args.includes("--isolate");
564
+ const cleanup = args.includes("--cleanup");
565
+ if (cleanup) {
566
+ const leaked = listManagedWorktrees(root, aiw);
567
+ if (leaked.length === 0)
568
+ return { exitCode: 0, output: "No managed worktrees to clean." };
569
+ if (!execute)
570
+ return {
571
+ exitCode: 0,
572
+ output: `Managed worktrees found:\n${leaked.map((path) => `- ${path}`).join("\n")}\nRe-run with --execute to remove them.`,
573
+ };
574
+ removeWorktrees(root, leaked, { force: true });
575
+ return { exitCode: 0, output: `Removed ${leaked.length} managed worktree(s).` };
576
+ }
577
+ const named = args
578
+ .slice(1)
579
+ .filter((arg) => arg !== "--execute" && arg !== "--isolate" && arg !== "--cleanup");
497
580
  const parsed = parseNamedOptions(named, ["plan", "max-parallel"]);
498
581
  if (!parsed?.plan)
499
582
  return {
500
583
  exitCode: 1,
501
- error: "Usage: aiw orchestrate --plan=path [--execute] [--max-parallel=number]",
584
+ error: "Usage: aiw orchestrate --plan=path [--execute] [--isolate] [--cleanup] [--max-parallel=number]",
502
585
  };
503
586
  const planPath = safeProjectFile(root, parsed.plan, fs);
504
587
  const plan = parseOrchestrationPlan(fs.read(planPath));
@@ -517,7 +600,19 @@ export async function runCommand(args, root, fs, services = {}) {
517
600
  error: `Orchestration checkpoint already exists; use a new plan id: ${checkpoint}`,
518
601
  };
519
602
  try {
520
- const results = await executeOrchestrationPlan(plan, services.orchestrator, maxParallel);
603
+ const isolation = isolate
604
+ ? {
605
+ options: { root, aiw, planId: plan.id },
606
+ worktrees: createTaskWorktrees(root, aiw, plan),
607
+ }
608
+ : undefined;
609
+ const results = await executeOrchestrationPlan(plan, services.orchestrator, maxParallel, isolation);
610
+ if (isolation) {
611
+ const passedPaths = results
612
+ .filter(({ status, worktree }) => status === "passed" && worktree)
613
+ .map(({ worktree }) => worktree);
614
+ removeWorktrees(root, passedPaths);
615
+ }
521
616
  fs.write(checkpoint, serializeOrchestrationResults(plan, results));
522
617
  const failed = results.filter(({ status }) => status !== "passed");
523
618
  return failed.length
@@ -542,9 +637,12 @@ export async function runCommand(args, root, fs, services = {}) {
542
637
  if (fs.pathType?.(manifestPath) === "file") {
543
638
  const manifest = fs.read(manifestPath);
544
639
  const active = manifest.match(/^\s*active:\s*(.+)$/m)?.[1]?.trim() ?? "unknown";
640
+ const legacyHint = detectLegacyState(aiw, fs)
641
+ ? "\nLegacy pre-ownership state detected. Run `aiw migrate --dry-run` to review, then `aiw migrate` to upgrade to the current state layout."
642
+ : "";
545
643
  return {
546
644
  exitCode: 0,
547
- output: `AI Workflow is already installed for target: ${active}. Existing state preserved.`,
645
+ output: `AI Workflow is already installed for target: ${active}. Existing state preserved.${legacyHint}`,
548
646
  };
549
647
  }
550
648
  const aiInitPath = join(root, renderAiInitPath(target));
@@ -640,7 +738,7 @@ export async function runCommand(args, root, fs, services = {}) {
640
738
  }
641
739
  if (command === "telemetry") {
642
740
  if (!fs.exists(join(aiw, "manifest.yml")))
643
- return { exitCode: 1, error: "Run `aiw install` first." };
741
+ return installationRequired();
644
742
  const usage = "Usage: aiw telemetry <status|show|enable|disable> [--verbose] [--commands=include|exclude] [--outcomes=include|exclude]";
645
743
  const action = args[1];
646
744
  if (!action)
@@ -753,7 +851,7 @@ export async function runCommand(args, root, fs, services = {}) {
753
851
  }
754
852
  if (command === "context-bench") {
755
853
  if (!fs.exists(join(aiw, "manifest.yml")))
756
- return { exitCode: 1, error: "Run `aiw install` first." };
854
+ return installationRequired();
757
855
  const saveCount = args.filter((arg) => arg === "--save").length;
758
856
  if (saveCount > 1)
759
857
  return { exitCode: 1, error: "Usage: aiw context-bench [--baseline=path] [--save]" };
@@ -798,7 +896,7 @@ export async function runCommand(args, root, fs, services = {}) {
798
896
  }
799
897
  if (command === "parity") {
800
898
  if (!fs.exists(join(aiw, "manifest.yml")))
801
- return { exitCode: 1, error: "Run `aiw install` first." };
899
+ return installationRequired();
802
900
  const parsed = parseNamedOptions(args.slice(1), ["tasks"]);
803
901
  if (!parsed)
804
902
  return { exitCode: 1, error: "Usage: aiw parity [--tasks=directory]" };
@@ -852,7 +950,7 @@ export async function runCommand(args, root, fs, services = {}) {
852
950
  }
853
951
  if (command === "scan") {
854
952
  if (!fs.exists(join(aiw, "manifest.yml")))
855
- return { exitCode: 1, error: "Run `aiw install` first." };
953
+ return installationRequired();
856
954
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
857
955
  let interpreter = services.interpreter;
858
956
  let interpreterModel;
@@ -1132,7 +1230,7 @@ export async function runCommand(args, root, fs, services = {}) {
1132
1230
  return { exitCode: 1, error: `Unsupported target: ${target}` };
1133
1231
  const manifestPath = join(aiw, "manifest.yml");
1134
1232
  if (!fs.exists(manifestPath))
1135
- return { exitCode: 1, error: "Run `aiw install` first." };
1233
+ return installationRequired();
1136
1234
  const dryRun = args.includes("--dry-run");
1137
1235
  const targetPath = join(root, renderAiInitPath(target));
1138
1236
  const neutralAiInit = join(aiw, "resources/skills/ai-init.md");
@@ -1459,7 +1557,7 @@ export async function runCommand(args, root, fs, services = {}) {
1459
1557
  }
1460
1558
  if (command === "confirm") {
1461
1559
  if (!fs.exists(join(aiw, "manifest.yml")))
1462
- return { exitCode: 1, error: "Run `aiw install` first." };
1560
+ return installationRequired();
1463
1561
  const overrides = [];
1464
1562
  const accept = args.indexOf("--accept");
1465
1563
  if (accept >= 0 && args[accept + 1])
@@ -1483,7 +1581,7 @@ export async function runCommand(args, root, fs, services = {}) {
1483
1581
  }
1484
1582
  if (command === "recommend") {
1485
1583
  if (!fs.exists(join(aiw, "manifest.yml")))
1486
- return { exitCode: 1, error: "Run `aiw install` first." };
1584
+ return installationRequired();
1487
1585
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
1488
1586
  const recommendations = services.recommender?.recommend(profile) ?? recommendCapabilities(profile);
1489
1587
  let ordered = recommendations;
@@ -1551,7 +1649,7 @@ export async function runCommand(args, root, fs, services = {}) {
1551
1649
  }
1552
1650
  if (command === "skills") {
1553
1651
  if (!fs.exists(join(aiw, "manifest.yml")))
1554
- return { exitCode: 1, error: "Run `aiw install` first." };
1652
+ return installationRequired();
1555
1653
  const action = args[1];
1556
1654
  const executor = services.externalSkills ?? nodeCommandExecutor;
1557
1655
  const approvedPermissions = parseApprovedPermissions(args);
@@ -1615,7 +1713,7 @@ export async function runCommand(args, root, fs, services = {}) {
1615
1713
  }
1616
1714
  if (command === "generate" || command === "sync") {
1617
1715
  if (!fs.exists(join(aiw, "manifest.yml")))
1618
- return { exitCode: 1, error: "Run `aiw install` first." };
1716
+ return installationRequired();
1619
1717
  const profilePath = join(aiw, "profile.yml");
1620
1718
  const persistedProfile = fs.exists(profilePath) ? fs.read(profilePath) : "";
1621
1719
  const profile = /^status:\s*scanned$/m.test(persistedProfile)
@@ -1724,8 +1822,16 @@ export async function runCommand(args, root, fs, services = {}) {
1724
1822
  if (command === "doctor") {
1725
1823
  const required = ["manifest.yml", "profile.yml", "lock.yml", "ownership.yml"];
1726
1824
  const missing = required.filter((file) => !fs.exists(join(aiw, file)));
1727
- if (missing.length)
1825
+ if (missing.length) {
1826
+ if (detectLegacyState(aiw, fs))
1827
+ return {
1828
+ exitCode: 1,
1829
+ error: formatBlockingOutput(describeBlockingReason("migration_required", {
1830
+ detail: "Legacy pre-ownership state detected; review the migration plan first.",
1831
+ })) + "\nNext: run `aiw migrate --dry-run`, then `aiw migrate`.",
1832
+ };
1728
1833
  return { exitCode: 1, error: `Missing AI Workflow files: ${missing.join(", ")}` };
1834
+ }
1729
1835
  try {
1730
1836
  parseManifest(fs.read(join(aiw, "manifest.yml")));
1731
1837
  parseOwnership(fs.read(join(aiw, "ownership.yml")));
@@ -1844,7 +1950,7 @@ export async function runCommand(args, root, fs, services = {}) {
1844
1950
  }
1845
1951
  if (command === "brainstorm") {
1846
1952
  if (!fs.exists(join(aiw, "manifest.yml")))
1847
- return { exitCode: 1, error: "Run `aiw install` first." };
1953
+ return installationRequired();
1848
1954
  assertStageReady(aiw, fs, "brainstorming");
1849
1955
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Brainstorm";
1850
1956
  const path = join(aiw, "generated/specs/brainstorm.md");
@@ -1853,7 +1959,7 @@ export async function runCommand(args, root, fs, services = {}) {
1853
1959
  }
1854
1960
  if (command === "spec") {
1855
1961
  if (!fs.exists(join(aiw, "manifest.yml")))
1856
- return { exitCode: 1, error: "Run `aiw install` first." };
1962
+ return installationRequired();
1857
1963
  assertStageReady(aiw, fs, "specification");
1858
1964
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Specification";
1859
1965
  const ticket = args.find((arg) => arg.startsWith("--ticket="))?.slice(9);
@@ -1865,7 +1971,7 @@ export async function runCommand(args, root, fs, services = {}) {
1865
1971
  }
1866
1972
  if (command === "adr") {
1867
1973
  if (!fs.exists(join(aiw, "manifest.yml")))
1868
- return { exitCode: 1, error: "Run `aiw install` first." };
1974
+ return installationRequired();
1869
1975
  assertStageReady(aiw, fs, "technical-design");
1870
1976
  const id = args.find((arg) => arg.startsWith("--id="))?.slice(5) || "001";
1871
1977
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Decision";
@@ -1875,26 +1981,27 @@ export async function runCommand(args, root, fs, services = {}) {
1875
1981
  }
1876
1982
  if (command === "plan") {
1877
1983
  if (!fs.exists(join(aiw, "manifest.yml")))
1878
- return { exitCode: 1, error: "Run `aiw install` first." };
1984
+ return installationRequired();
1879
1985
  assertStageReady(aiw, fs, "plan");
1880
1986
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Implementation Plan";
1881
1987
  const path = join(aiw, "generated/plans/implementation-plan.md");
1882
- fs.write(path, `# ${title}\n\n## Linked Requirements\n\n- REQ-001: Link to the specification requirement.\n\n## Tasks\n\n- [ ] TASK-001: Implement the first increment.\n - Requirement: REQ-001\n - Code: src/\n - Tests: src/**/*.test.ts\n - Validation: npm test\n - Evidence: Record the passing command output and changed files.\n\n## Validation Commands\n\n- npm run check\n\n## Risks and Dependencies\n\n- Dependencies: List prerequisite tasks, packages, or decisions.\n- Risks: Record delivery, compatibility, and validation risks with mitigations.\n\n## Completion Evidence\n\n- TASK-001: Link code changes, test results, and validation output.\n\n`);
1988
+ fs.write(path, `# ${title}\n\n## Linked Requirements\n\n- REQ-001: Link to the specification requirement.\n\n## Tasks\n\n- [ ] TASK-001: Implement the first increment.\n - Requirement: REQ-001\n - Code: src/\n - Tests: src/**/*.test.ts\n - Validation: npm test → exit 0\n - Regression: <optional — touched existing behavior → the test proving it holds>\n - Evidence: Record the passing command output and changed files.\n\n## Validation Commands\n\n- npm run check\n\n## Risks and Dependencies\n\n- Dependencies: List prerequisite tasks, packages, or decisions.\n- Risks: Record delivery, compatibility, and validation risks with mitigations.\n\n## Completion Evidence\n\n- TASK-001: Link code changes, test results, and validation output.\n\n`);
1883
1989
  return { exitCode: 0, output: `Implementation plan created: ${path}` };
1884
1990
  }
1885
1991
  if (command === "verify") {
1886
1992
  if (!fs.exists(join(aiw, "manifest.yml")))
1887
- return { exitCode: 1, error: "Run `aiw install` first." };
1993
+ return installationRequired();
1888
1994
  assertStageReady(aiw, fs, "verification");
1889
1995
  const specPath = join(aiw, "generated/specs/specification.md");
1890
1996
  const planPath = join(aiw, "generated/plans/implementation-plan.md");
1891
1997
  const issues = [];
1998
+ let regressionNotice;
1892
1999
  let specification = "";
1893
2000
  if (!fs.exists(specPath))
1894
2001
  issues.push("Specification artifact is missing.");
1895
2002
  else {
1896
2003
  specification = fs.read(specPath);
1897
- if (!specification.includes("Acceptance criteria"))
2004
+ if (!specification.toLowerCase().includes("acceptance criteria"))
1898
2005
  issues.push("Requirements have no acceptance criteria.");
1899
2006
  }
1900
2007
  if (!fs.exists(planPath))
@@ -1907,17 +2014,24 @@ export async function runCommand(args, root, fs, services = {}) {
1907
2014
  const uncovered = requirements.filter((requirement) => !plan.includes(requirement));
1908
2015
  if (uncovered.length > 0)
1909
2016
  issues.push(`Untested requirements: ${uncovered.join(", ")}.`);
2017
+ const validationCount = (plan.match(/Validation:/g) ?? []).length;
2018
+ const regressionCount = (plan.match(/Regression:/g) ?? []).length;
2019
+ regressionNotice =
2020
+ validationCount > regressionCount
2021
+ ? `${validationCount - regressionCount} of ${validationCount} plan tasks declare no regression coverage for touched existing behavior.`
2022
+ : undefined;
1910
2023
  }
1911
2024
  return issues.length
1912
2025
  ? { exitCode: 1, error: `Verification failed: ${issues.join(" ")}` }
1913
2026
  : {
1914
2027
  exitCode: 0,
1915
- output: "Verification passed: requirements have acceptance criteria and validation coverage.",
2028
+ output: "Verification passed: requirements have acceptance criteria and validation coverage." +
2029
+ (regressionNotice ? ` Advisory: ${regressionNotice}` : ""),
1916
2030
  };
1917
2031
  }
1918
2032
  if (command === "trace") {
1919
2033
  if (!fs.exists(join(aiw, "manifest.yml")))
1920
- return { exitCode: 1, error: "Run `aiw install` first." };
2034
+ return installationRequired();
1921
2035
  assertStageReady(aiw, fs, "traceability");
1922
2036
  const specPath = join(aiw, "generated/specs/specification.md");
1923
2037
  const planPath = join(aiw, "generated/plans/implementation-plan.md");
@@ -1946,6 +2060,208 @@ export async function runCommand(args, root, fs, services = {}) {
1946
2060
  fs.write(path, serialized);
1947
2061
  return { exitCode: 0, output: `Traceability graph created: ${path}` };
1948
2062
  }
2063
+ if (command === "standards") {
2064
+ if (!fs.exists(join(aiw, "manifest.yml")))
2065
+ return installationRequired();
2066
+ const action = args[1] ?? "list";
2067
+ const index = fs.exists(join(aiw, "standards/index.yml"))
2068
+ ? parseStandardsIndex(fs.read(join(aiw, "standards/index.yml")))
2069
+ : { schema: 1, standards: {} };
2070
+ if (action === "extract") {
2071
+ fs.mkdir(join(aiw, "standards"));
2072
+ const documents = extractStandards(root, fs);
2073
+ for (const document of documents) {
2074
+ const body = [
2075
+ `# Standard: ${document.topic}`,
2076
+ "",
2077
+ `Status: ${document.status}`,
2078
+ "",
2079
+ ...document.statements.flatMap((statement) => [
2080
+ statement.convention,
2081
+ ...(statement.occurrences > 0
2082
+ ? statement.evidence.map((evidence) => `- Evidence: ${evidence.path}:${evidence.line}`)
2083
+ : ["- Evidence: none (deterministic scan found no instances)"]),
2084
+ ]),
2085
+ "",
2086
+ ].join("\n");
2087
+ fs.write(join(aiw, `standards/${document.topic}.md`), body);
2088
+ if (index.standards[document.topic] === undefined)
2089
+ index.standards[document.topic] = {
2090
+ status: "inferred",
2091
+ updatedAt: new Date().toISOString(),
2092
+ };
2093
+ }
2094
+ const serialized = [
2095
+ "schema: 1",
2096
+ "standards:",
2097
+ ...Object.entries(index.standards).map(([topic, entry]) => ` ${topic}:\n status: ${entry.status}\n updatedAt: ${entry.updatedAt}`),
2098
+ "",
2099
+ ].join("\n");
2100
+ fs.write(join(aiw, "standards/index.yml"), serialized);
2101
+ const names = documents.map((document) => document.topic).join(", ");
2102
+ return {
2103
+ exitCode: 0,
2104
+ output: `Standards extracted (${names}). All are inferred — review them, then confirm with \`aiw standards confirm <topic>\`. Rejected or confirmed standards never change until you say so.`,
2105
+ };
2106
+ }
2107
+ if (action === "list") {
2108
+ const entries = Object.entries(index.standards);
2109
+ if (entries.length === 0)
2110
+ return {
2111
+ exitCode: 0,
2112
+ output: "No standards recorded. Run `aiw standards extract` first.",
2113
+ };
2114
+ return {
2115
+ exitCode: 0,
2116
+ output: [
2117
+ "Standards:",
2118
+ ...entries.map(([topic, entry]) => `- ${topic}: ${entry.status}`),
2119
+ "Confirm or reject with `aiw standards confirm|reject <topic>`.",
2120
+ ].join("\n"),
2121
+ };
2122
+ }
2123
+ if (action === "confirm" || action === "reject") {
2124
+ const topic = args[2];
2125
+ if (!topic)
2126
+ return { exitCode: 1, error: `Usage: aiw standards ${action} <topic>` };
2127
+ const path = join(aiw, `standards/${topic}.md`);
2128
+ if (!fs.exists(path))
2129
+ return {
2130
+ exitCode: 1,
2131
+ error: `No standard document for ${topic}; run \`aiw standards extract\` first.`,
2132
+ };
2133
+ const status = action === "confirm" ? "confirmed" : "rejected";
2134
+ index.standards[topic] = { status, updatedAt: new Date().toISOString() };
2135
+ const existing = fs.read(path).replace(/^Status: .*$/m, `Status: ${status}`);
2136
+ fs.write(path, existing);
2137
+ const serialized = [
2138
+ "schema: 1",
2139
+ "standards:",
2140
+ ...Object.entries(index.standards).map(([topicEntry, entry]) => ` ${topicEntry}:\n status: ${entry.status}\n updatedAt: ${entry.updatedAt}`),
2141
+ "",
2142
+ ].join("\n");
2143
+ fs.write(join(aiw, "standards/index.yml"), serialized);
2144
+ return {
2145
+ exitCode: 0,
2146
+ output: `Standard ${topic} ${status}. ${status === "confirmed"
2147
+ ? "Confirmed standards are referenced by generated rules."
2148
+ : "Rejected standards are never rendered."}`,
2149
+ };
2150
+ }
2151
+ return {
2152
+ exitCode: 1,
2153
+ error: "Usage: aiw standards <extract|list|confirm|reject> [topic] — standards are inferred until a human confirms them.",
2154
+ };
2155
+ }
2156
+ if (command === "migrate") {
2157
+ if (!fs.exists(join(aiw, "manifest.yml")))
2158
+ return installationRequired();
2159
+ const dry = args.includes("--dry-run");
2160
+ const doRollback = args.includes("--rollback");
2161
+ if (doRollback) {
2162
+ const restored = rollbackMigration(root, aiw, fs);
2163
+ return { exitCode: 0, output: `Legacy state restored from snapshot: ${restored}` };
2164
+ }
2165
+ if (!detectLegacyState(aiw, fs))
2166
+ return {
2167
+ exitCode: 0,
2168
+ output: "Nothing to migrate: state already includes the ownership inventory.",
2169
+ };
2170
+ const manifestPath = join(aiw, "manifest.yml");
2171
+ const manifestTarget = /^\s*active:\s*(.+)$/m.exec(fs.read(manifestPath))?.[1]?.trim() ||
2172
+ detectTarget(root, fs) ||
2173
+ "codex";
2174
+ if (!isTarget(manifestTarget))
2175
+ throw new Error(`Unsupported target: ${manifestTarget}`);
2176
+ const managedCandidates = [];
2177
+ for (const resource of loadBundledResources(manifestTarget)) {
2178
+ managedCandidates.push({
2179
+ path: join(root, renderResourcePath(manifestTarget, resource.type, resource.id)),
2180
+ bundled: resource.content,
2181
+ metadata: {
2182
+ target: manifestTarget,
2183
+ resourceType: resource.type,
2184
+ resourceId: resource.id,
2185
+ kind: "target-resource",
2186
+ },
2187
+ });
2188
+ }
2189
+ managedCandidates.push({
2190
+ path: join(root, describeResource(manifestTarget, "context", "project").path),
2191
+ metadata: {
2192
+ target: manifestTarget,
2193
+ resourceType: "context",
2194
+ resourceId: "project",
2195
+ kind: "target-resource",
2196
+ },
2197
+ });
2198
+ const plan = planLegacyMigration(root, aiw, fs, managedCandidates);
2199
+ const report = [
2200
+ `Target: ${manifestTarget}`,
2201
+ plan.archived.length
2202
+ ? `Archived (superseded machine state): ${plan.archived.join(", ")}`
2203
+ : "",
2204
+ plan.preserved.length ? `Preserved byte-identical: ${plan.preserved.join(", ")}` : "",
2205
+ `Rebuilt: ownership inventory (${plan.rebuilt.length} managed paths)`,
2206
+ ...plan.userModified.map((entry) => `Legacy-user-modified (sync preserves for review): ${entry}`),
2207
+ ...plan.manualActions.map((entry) => `MANUAL ACTION REQUIRED: ${entry}`),
2208
+ dry ? "Mode: dry-run — nothing written." : `Rollback snapshot: ${plan.rollbackSnapshot}`,
2209
+ ].filter((line) => line.length > 0);
2210
+ if (dry)
2211
+ return { exitCode: 0, output: ["Migration plan (dry-run):", ...report].join("\n") };
2212
+ try {
2213
+ applyMigration(root, aiw, fs, managedCandidates, plan);
2214
+ }
2215
+ catch (error) {
2216
+ return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2217
+ }
2218
+ return { exitCode: 0, output: ["Migration complete.", ...report].join("\n") };
2219
+ }
2220
+ if (command === "digest") {
2221
+ if (!fs.exists(join(aiw, "manifest.yml")))
2222
+ return installationRequired();
2223
+ const stageArg = args.find((argument) => argument.startsWith("--stage="))?.slice(8);
2224
+ const stages = stageArg
2225
+ ? [stageArg]
2226
+ : fs.exists(join(aiw, "workflow.yml"))
2227
+ ? processStages(readWorkflowState(aiw, fs, false).ledger)
2228
+ : [...APPROVAL_STAGES];
2229
+ const lines = [];
2230
+ let served = 0;
2231
+ for (const stage of stages) {
2232
+ const artifactPath = approvalArtifactPath(aiw, stage);
2233
+ const freshness = digestFreshness(aiw, fs, stage, artifactPath ?? undefined);
2234
+ if (freshness.state === "missing")
2235
+ continue;
2236
+ lines.push(formatDigestLine(freshness));
2237
+ served += 1;
2238
+ }
2239
+ if (lines.length === 0)
2240
+ return {
2241
+ exitCode: 0,
2242
+ output: "No digests yet: digests appear when stages pass their gate and receive human approval.",
2243
+ };
2244
+ lines.push(`served: ${served} digest${served === 1 ? "" : "s"} (full reads on demand)`);
2245
+ return { exitCode: 0, output: lines.join("\n") };
2246
+ }
2247
+ if (command === "diagnosis") {
2248
+ if (!fs.exists(join(aiw, "manifest.yml")))
2249
+ return installationRequired();
2250
+ assertStageReady(aiw, fs, "diagnosis");
2251
+ const title = args.find((argument) => argument.startsWith("--title="))?.slice(8) || "Untitled Diagnosis";
2252
+ const path = join(aiw, "generated/reports/diagnosis.md");
2253
+ fs.write(path, `# ${title}\n\n## Reproduction\n\n- Command: \`<command that reproduces the bug>\`\n- Observed: <what actually happens>\n\n## Expected\n\n- <the correct behavior>\n\n## Root Cause\n\n- <evidence-backed cause, file:line when known>\n\n## Scope\n\n- In scope: <files and behavior this fix touches>\n- Out of scope: <explicitly excluded>\n`);
2254
+ return { exitCode: 0, output: `Diagnosis artifact created: ${path}` };
2255
+ }
2256
+ if (command === "assess") {
2257
+ if (!fs.exists(join(aiw, "manifest.yml")))
2258
+ return installationRequired();
2259
+ assertStageReady(aiw, fs, "assessment");
2260
+ const title = args.find((argument) => argument.startsWith("--title="))?.slice(8) || "Untitled Assessment";
2261
+ const path = join(aiw, "generated/reports/assessment.md");
2262
+ fs.write(path, `# ${title}\n\n## Evidence\n\n- <attributed evidence: source, finding>\n\n## Outcome\n\ngo | clarify | stop\n\n## Rationale\n\n- <why this outcome follows from the evidence>\n\n## Next Step\n\n- <go: recommended feature scope · clarify: the open questions · stop: conditions that would reopen>\n`);
2263
+ return { exitCode: 0, output: `Assessment artifact created: ${path}` };
2264
+ }
1949
2265
  if (command === "risk") {
1950
2266
  if (args[1] !== "assess")
1951
2267
  return { exitCode: 1, error: "Usage: aiw risk assess [--base-ref=ref] [--json]" };
@@ -1953,7 +2269,7 @@ export async function runCommand(args, root, fs, services = {}) {
1953
2269
  }
1954
2270
  if (command === "gate") {
1955
2271
  if (!fs.exists(join(aiw, "manifest.yml")))
1956
- return { exitCode: 1, error: "Run `aiw install` first." };
2272
+ return installationRequired();
1957
2273
  const stage = args[1];
1958
2274
  if (!stage || !APPROVAL_STAGES.includes(stage))
1959
2275
  return {
@@ -1980,6 +2296,17 @@ export async function runCommand(args, root, fs, services = {}) {
1980
2296
  detail: `Quality gate blocked: ${issues.join(" ")}`,
1981
2297
  }));
1982
2298
  }
2299
+ if (hasQualityGate &&
2300
+ (workflowStage === "implementation" || workflowStage === "verification")) {
2301
+ const pairIssues = compareVerificationPairs(aiw, fs, workflowStage);
2302
+ if (pairIssues.length)
2303
+ throw new BlockingError(describeBlockingReason("gate_incomplete", {
2304
+ stage,
2305
+ artifactPath,
2306
+ gateIssues: pairIssues,
2307
+ detail: `Quality gate blocked: ${pairIssues.join(" ")}`,
2308
+ }));
2309
+ }
1983
2310
  const { ledger } = readActiveWorkflow(aiw, fs);
1984
2311
  const updated = beginStageApproval(ledger, workflowStage, {
1985
2312
  artifactHash: stageFingerprint(fs.read(artifactPath)),
@@ -2008,7 +2335,7 @@ export async function runCommand(args, root, fs, services = {}) {
2008
2335
  }
2009
2336
  if (command === "resolve") {
2010
2337
  if (!fs.exists(join(aiw, "manifest.yml")))
2011
- return { exitCode: 1, error: "Run `aiw install` first." };
2338
+ return installationRequired();
2012
2339
  const packageArg = args.find((arg) => arg.startsWith("--package="));
2013
2340
  const sourceArg = args.find((arg) => arg.startsWith("--source="));
2014
2341
  if (!packageArg && !sourceArg)
@@ -2070,7 +2397,7 @@ export async function runCommand(args, root, fs, services = {}) {
2070
2397
  }
2071
2398
  if (command === "organization-policy") {
2072
2399
  if (!fs.exists(join(aiw, "manifest.yml")))
2073
- return { exitCode: 1, error: "Run `aiw install` first." };
2400
+ return installationRequired();
2074
2401
  const fileArg = args.find((arg) => arg.startsWith("--file="));
2075
2402
  if (!fileArg)
2076
2403
  return { exitCode: 1, error: "Usage: aiw organization-policy --file=path [--replace]" };
@@ -2086,7 +2413,7 @@ export async function runCommand(args, root, fs, services = {}) {
2086
2413
  }
2087
2414
  if (command === "policy-check") {
2088
2415
  if (!fs.exists(join(aiw, "manifest.yml")))
2089
- return { exitCode: 1, error: "Run `aiw install` first." };
2416
+ return installationRequired();
2090
2417
  const policyPath = join(aiw, "organization.yml");
2091
2418
  if (!fs.exists(policyPath))
2092
2419
  return { exitCode: 1, error: "Organization policy is required for the CI policy gate." };
@@ -2124,7 +2451,7 @@ export async function runCommand(args, root, fs, services = {}) {
2124
2451
  }
2125
2452
  if (command === "preset") {
2126
2453
  if (!fs.exists(join(aiw, "manifest.yml")))
2127
- return { exitCode: 1, error: "Run `aiw install` first." };
2454
+ return installationRequired();
2128
2455
  const fileArg = args.find((arg) => arg.startsWith("--file="));
2129
2456
  if (!fileArg)
2130
2457
  return { exitCode: 1, error: "Usage: aiw preset --file=path [--replace]" };
@@ -2140,7 +2467,7 @@ export async function runCommand(args, root, fs, services = {}) {
2140
2467
  }
2141
2468
  if (command === "update") {
2142
2469
  if (!fs.exists(join(aiw, "manifest.yml")))
2143
- return { exitCode: 1, error: "Run `aiw install` first." };
2470
+ return installationRequired();
2144
2471
  const packageArg = args.find((arg) => arg.startsWith("--package="));
2145
2472
  if (!packageArg)
2146
2473
  return { exitCode: 1, error: "Usage: aiw update --package=path --target=target" };
@@ -2171,7 +2498,7 @@ export async function runCommand(args, root, fs, services = {}) {
2171
2498
  if (command === "registry") {
2172
2499
  if (args[1] === "configure") {
2173
2500
  if (!fs.exists(join(aiw, "manifest.yml")))
2174
- return { exitCode: 1, error: "Run `aiw install` first." };
2501
+ return installationRequired();
2175
2502
  const fileArg = args.find((arg) => arg.startsWith("--file="));
2176
2503
  if (!fileArg)
2177
2504
  return {
@@ -2257,9 +2584,39 @@ export async function runCommand(args, root, fs, services = {}) {
2257
2584
  };
2258
2585
  }
2259
2586
  const output = args.includes("--task") ? retrieveTaskContext(store) : composeContext(store);
2587
+ const stageArg = args.find((argument) => argument.startsWith("--stage="))?.slice(8);
2588
+ const digestLines = [];
2589
+ if (fs.exists(join(aiw, "workflow.yml"))) {
2590
+ for (const stage of processStages(readWorkflowState(aiw, fs, false).ledger)) {
2591
+ const artifactPath = approvalArtifactPath(aiw, stage);
2592
+ const freshness = digestFreshness(aiw, fs, stage, artifactPath ?? undefined);
2593
+ if (freshness.state !== "missing")
2594
+ digestLines.push(formatDigestLine(freshness));
2595
+ }
2596
+ }
2597
+ const budgetArg = args.find((argument) => argument.startsWith("--budget="))?.slice(9);
2598
+ if (stageArg) {
2599
+ const budget = Number(budgetArg ?? parseContextBudgets(aiw, fs)[stageArg]);
2600
+ if (Number.isFinite(budget) && budget > 0) {
2601
+ const estimated = Math.ceil(output.length / 4);
2602
+ if (estimated > budget)
2603
+ throw new BlockingError(describeBlockingReason("context_budget_exceeded", {
2604
+ stage: stageArg,
2605
+ detail: `Stage ${stageArg} context is ~${estimated} tokens, over the ${budget}-token ceiling.`,
2606
+ }));
2607
+ }
2608
+ const digestBlock = digestLines.length > 0 ? `\n${digestLines.join("\n")}` : "";
2609
+ return {
2610
+ exitCode: 0,
2611
+ output: `${output}${digestBlock}\n\nstage: ${stageArg} · ~${Math.ceil(output.length / 4)} tokens${Number.isFinite(budget) && budget > 0 ? ` · ceiling ${budget}` : " · no ceiling declared"}`,
2612
+ };
2613
+ }
2260
2614
  return { exitCode: 0, output };
2261
2615
  }
2262
- return { exitCode: 1, error: "Usage: aiw context [--layer=project --content=text]" };
2616
+ return {
2617
+ exitCode: 1,
2618
+ error: "Usage: aiw context [--layer=project --content=text] | aiw context --stage=<name> [--budget=tokens]",
2619
+ };
2263
2620
  }
2264
2621
  if (command === "context-summary") {
2265
2622
  const summaryArg = args.find((arg) => arg.startsWith("--summary="))?.slice(10);
@@ -2277,7 +2634,7 @@ export async function runCommand(args, root, fs, services = {}) {
2277
2634
  }
2278
2635
  if (command === "routing") {
2279
2636
  if (!fs.exists(join(aiw, "manifest.yml")))
2280
- return { exitCode: 1, error: "Run `aiw install` first." };
2637
+ return installationRequired();
2281
2638
  const usage = "Usage: aiw routing init | show | resolve --category=name";
2282
2639
  const action = args[1];
2283
2640
  const routingPath = join(aiw, ROUTING_TABLE_PATH);
@@ -2333,7 +2690,7 @@ export async function runCommand(args, root, fs, services = {}) {
2333
2690
  }
2334
2691
  if (command === "task-usage") {
2335
2692
  if (!fs.exists(join(aiw, "manifest.yml")))
2336
- return { exitCode: 1, error: "Run `aiw install` first." };
2693
+ return installationRequired();
2337
2694
  const parsed = parseNamedOptions(args.slice(1), ["workflow"]);
2338
2695
  if (!parsed)
2339
2696
  return { exitCode: 1, error: "Usage: aiw task-usage [--workflow=id-or-ticket]" };
@@ -2362,7 +2719,7 @@ export async function runCommand(args, root, fs, services = {}) {
2362
2719
  }
2363
2720
  if (command === "cost-report") {
2364
2721
  if (!fs.exists(join(aiw, "manifest.yml")))
2365
- return { exitCode: 1, error: "Run `aiw install` first." };
2722
+ return installationRequired();
2366
2723
  const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
2367
2724
  if (!fs.exists(sidecarPath))
2368
2725
  return {
@@ -2384,7 +2741,7 @@ export async function runCommand(args, root, fs, services = {}) {
2384
2741
  }
2385
2742
  if (command === "heartbeat") {
2386
2743
  if (!fs.exists(join(aiw, "manifest.yml")))
2387
- return { exitCode: 1, error: "Run `aiw install` first." };
2744
+ return installationRequired();
2388
2745
  const parsed = parseNamedOptions(args.slice(1), ["session", "warn", "ttl"]);
2389
2746
  if (!parsed)
2390
2747
  return {
@@ -2434,7 +2791,7 @@ export async function runCommand(args, root, fs, services = {}) {
2434
2791
  }
2435
2792
  if (command === "hooks") {
2436
2793
  if (!fs.exists(join(aiw, "manifest.yml")))
2437
- return { exitCode: 1, error: "Run `aiw install` first." };
2794
+ return installationRequired();
2438
2795
  const action = args[1];
2439
2796
  if (action !== "install")
2440
2797
  return {
@@ -2478,7 +2835,7 @@ export async function runCommand(args, root, fs, services = {}) {
2478
2835
  }
2479
2836
  if (command === "usage-sidecar") {
2480
2837
  if (!fs.exists(join(aiw, "manifest.yml")))
2481
- return { exitCode: 1, error: "Run `aiw install` first." };
2838
+ return installationRequired();
2482
2839
  const usage = "Usage: aiw usage-sidecar record --command=name (--cache-read=N | --fresh-input=N | --cache-write=N | --output=N) [--model=name] [--harness=name] [--harness-version=text] [--estimated] | aiw usage-sidecar report";
2483
2840
  const action = args[1];
2484
2841
  const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
@@ -2687,7 +3044,7 @@ export async function runCommand(args, root, fs, services = {}) {
2687
3044
  }
2688
3045
  if (command === "self-validate") {
2689
3046
  if (!fs.exists(join(aiw, "manifest.yml")))
2690
- return { exitCode: 1, error: "Run `aiw install` first." };
3047
+ return installationRequired();
2691
3048
  const ticket = args.find((arg) => arg.startsWith("--ticket="))?.slice(9);
2692
3049
  if (!ticket)
2693
3050
  return { exitCode: 1, error: "Usage: aiw self-validate --ticket=TYPE-000" };
@@ -2723,7 +3080,7 @@ export async function runCommand(args, root, fs, services = {}) {
2723
3080
  }
2724
3081
  return {
2725
3082
  exitCode: 0,
2726
- output: "aiw install [--target target] | workflow <start|status|complete> [--id=id] | task <start|status> --ticket=TEAM-123 [--title=text] | approve <stage> | reject <stage> --reason=text | skip <optional-stage> --reason=text | ui [--port=number] | orchestrate --plan=path [--execute] [--max-parallel=number] | telemetry <status|show|enable|disable> [privacy options] | plugin <create|validate> [options] | scan | semantic <status|query> | decision <status|triage> | self-validate --ticket=TYPE-000 | organization-policy --file=path [--replace] | policy-check --package=path [--package=path] | preset --file=path [--replace] | context | context-summary | context-quality | capabilities [--target=target] | token-usage [--stage=name --budget=number --used=number] | registry [--search=query] [--private=name] | registry configure --file=path [--replace] | skills <search|inspect|install|check|update> [options] | audit-package --package=path | verify-package --package=path --checksum=sha256 | brainstorm [--title=title] | spec [--title=title] | adr [--id=id --title=title] | plan [--title=title] | verify | trace | gate <stage> | recommend [--select=id,id] | status | doctor | repair | uninstall [--dry-run] | target <target> | rollback | confirm | resolve --package=path | update --package=path --target=target | validate [--package=path]",
3083
+ output: "aiw install [--target target] | workflow <start|status|complete> [--id=id] | task <start|status> --ticket=TEAM-123 [--title=text] | approve <stage> | reject <stage> --reason=text | skip <optional-stage> --reason=text | ui [--port=number] | orchestrate --plan=path [--execute] [--max-parallel=number] | telemetry <status|show|enable|disable> [privacy options] | plugin <create|validate> [options] | scan | semantic <status|query> | decision <status|triage> | self-validate --ticket=TYPE-000 | organization-policy --file=path [--replace] | policy-check --package=path [--package=path] | preset --file=path [--replace] | context | context-summary | context-quality | capabilities [--target=target] | token-usage [--stage=name --budget=number --used=number] | registry [--search=query] [--private=name] | registry configure --file=path [--replace] | skills <search|inspect|install|check|update> [options] | audit-package --package=path | verify-package --package=path --checksum=sha256 | brainstorm [--title=title] | spec [--title=title] | adr [--id=id --title=title] | plan [--title=title] | verify | trace | gate <stage> | standards <extract|list|confirm|reject> | recommend [--select=id,id] | status | doctor | repair | uninstall [--dry-run] | target <target> | rollback | confirm | resolve --package=path | update --package=path --target=target | validate [--package=path]",
2727
3084
  };
2728
3085
  }
2729
3086
  catch (error) {
@@ -2741,12 +3098,14 @@ function readWorkflowState(aiw, fs, requireActive = true) {
2741
3098
  const session = parseWorkflowSession(fs.read(sessionPath));
2742
3099
  let ledger = parseApprovalLedger(fs.read(ledgerPath));
2743
3100
  if (session.workflowId !== ledger.workflowId)
2744
- throw new Error("Workflow session and approval ledger IDs do not match; run doctor.");
3101
+ throw new BlockingError(describeBlockingReason("state_mismatch", {
3102
+ detail: "Workflow session and approval ledger IDs do not match; run doctor.",
3103
+ }));
2745
3104
  if (requireActive && session.status !== "active")
2746
3105
  throw new BlockingError(describeBlockingReason("workflow_complete", {
2747
3106
  detail: `Workflow ${session.workflowId} is complete; start a new workflow first.`,
2748
3107
  }));
2749
- const changed = APPROVAL_STAGES.find((stage) => {
3108
+ const changed = processStages(ledger).find((stage) => {
2750
3109
  const record = ledger.stages[stage];
2751
3110
  if (!record?.artifactHash || !["pending-human", "approved"].includes(record.status))
2752
3111
  return false;
@@ -2774,6 +3133,85 @@ function workflowNextAction(ledger) {
2774
3133
  return `Next action: revise ${next}, rerun its gate, and request human approval.`;
2775
3134
  return `Next action: prepare ${next} evidence.`;
2776
3135
  }
3136
+ function parseContextBudgets(aiw, fs) {
3137
+ const path = join(aiw, "budgets.yml");
3138
+ if (!fs.exists(path))
3139
+ return {};
3140
+ const value = parseYaml(fs.read(path));
3141
+ if (!value || typeof value !== "object")
3142
+ return {};
3143
+ const stages = value.stages;
3144
+ if (!stages || typeof stages !== "object")
3145
+ return {};
3146
+ const budgets = {};
3147
+ for (const [stage, ceiling] of Object.entries(stages)) {
3148
+ const n = Number(ceiling);
3149
+ if (Number.isFinite(n) && n > 0)
3150
+ budgets[stage] = n;
3151
+ }
3152
+ return budgets;
3153
+ }
3154
+ const PLAN_PAIR_PATTERN = /Validation:\s*([^→\n]+)→\s*([^\n]+)/g;
3155
+ const EXIT_PATTERN = /exit\s+(\d+)/i;
3156
+ function normalizeCommand(command) {
3157
+ return command.trim().replace(/\s+/g, " ").toLowerCase();
3158
+ }
3159
+ /**
3160
+ * OPP-014 paired verification: plan tasks declare `Validation: <command> →
3161
+ * <expected>`; reports record `Validation: <command> → <observed>`. The gate
3162
+ * compares the pairs — command match, observed presence, exit-code agreement.
3163
+ * Legacy plans without pair arrows are accepted unchanged (ratchet).
3164
+ */
3165
+ export function compareVerificationPairs(aiw, fs, stage) {
3166
+ const planPath = join(aiw, "generated/plans/implementation-plan.md");
3167
+ if (!fs.exists(planPath))
3168
+ return [];
3169
+ const plan = fs.read(planPath);
3170
+ if (!plan.includes("→"))
3171
+ return [];
3172
+ const declared = [];
3173
+ for (const blockMatch of plan.matchAll(/TASK-\d+[\s\S]*?(?=TASK-\d+|$)/g)) {
3174
+ const block = blockMatch[0];
3175
+ const taskId = /^\s*(TASK-\d+)/.exec(block)?.[1] ?? "TASK-?";
3176
+ for (const pair of block.matchAll(PLAN_PAIR_PATTERN)) {
3177
+ const command = pair[1].trim();
3178
+ if (!command)
3179
+ continue;
3180
+ declared.push({
3181
+ task: taskId,
3182
+ command,
3183
+ expectedExitText: EXIT_PATTERN.exec(pair[2])?.[1],
3184
+ raw: pair[2].trim(),
3185
+ });
3186
+ }
3187
+ }
3188
+ if (declared.length === 0)
3189
+ return [];
3190
+ const reportPath = stage === "implementation"
3191
+ ? join(aiw, "generated/reports/implementation-report.md")
3192
+ : join(aiw, "generated/reports/verification-report.md");
3193
+ const issues = [];
3194
+ const observed = new Map();
3195
+ if (fs.exists(reportPath)) {
3196
+ for (const line of fs.read(reportPath).split("\n")) {
3197
+ const match = /Validation:\s*([^→\n]+)→\s*([^\n]+)/.exec(line);
3198
+ if (match)
3199
+ observed.set(normalizeCommand(match[1]), match[2].trim());
3200
+ }
3201
+ }
3202
+ for (const pair of declared) {
3203
+ const key = normalizeCommand(pair.command);
3204
+ if (!observed.has(key)) {
3205
+ issues.push(`[verification_pair_missing] ${pair.task}: \`${pair.command}\``);
3206
+ continue;
3207
+ }
3208
+ const observedExit = EXIT_PATTERN.exec(observed.get(key) ?? "")?.[1];
3209
+ if (pair.expectedExitText && observedExit && pair.expectedExitText !== observedExit) {
3210
+ issues.push(`[verification_pair_mismatch] ${pair.task}: expected exit ${pair.expectedExitText}, observed exit ${observedExit}`);
3211
+ }
3212
+ }
3213
+ return issues;
3214
+ }
2777
3215
  function parseLightEvidence(content) {
2778
3216
  const value = parseYaml(content);
2779
3217
  if (!value || typeof value !== "object" || value.schema !== 1)
@@ -2794,6 +3232,11 @@ function parseLightEvidence(content) {
2794
3232
  }
2795
3233
  function assertStageReady(aiw, fs, stage) {
2796
3234
  const { session, ledger } = readActiveWorkflow(aiw, fs);
3235
+ if (!processStages(ledger).includes(stage))
3236
+ throw new BlockingError(describeBlockingReason("stage_not_in_process", {
3237
+ stage,
3238
+ detail: `Stage ${stage} is not part of the ${ledger.process ?? "feature"} process for workflow ${session.workflowId}.`,
3239
+ }));
2797
3240
  if (effectiveLane(session) === "light")
2798
3241
  throw new BlockingError(describeBlockingReason("lane_escalation_required", {
2799
3242
  stage,
@@ -2837,6 +3280,8 @@ function approvalArtifactPath(aiw, stage) {
2837
3280
  verification: "generated/reports/verification-report.md",
2838
3281
  review: "generated/reports/code-review.md",
2839
3282
  traceability: "generated/artifacts/traceability.yml",
3283
+ diagnosis: "generated/reports/diagnosis.md",
3284
+ assessment: "generated/reports/assessment.md",
2840
3285
  };
2841
3286
  return resolve(aiw, paths[stage]);
2842
3287
  }