@multileaf/ai-workflow 0.11.0 → 0.12.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 (44) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/blocking-reasons.d.ts +1 -1
  3. package/dist/blocking-reasons.js +24 -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/orchestration.d.ts +38 -1
  12. package/dist/orchestration.js +82 -3
  13. package/dist/quality-gates.d.ts +1 -1
  14. package/dist/quality-gates.js +23 -0
  15. package/dist/risk-assess.d.ts +4 -0
  16. package/dist/risk-assess.js +5 -1
  17. package/dist/standards.d.ts +34 -0
  18. package/dist/standards.js +159 -0
  19. package/dist/types.d.ts +15 -1
  20. package/dist/types.js +24 -0
  21. package/dist/workflow.d.ts +7 -0
  22. package/dist/workflow.js +426 -57
  23. package/docs/backlog.md +10 -3
  24. package/docs/continuation-inventory.md +20 -16
  25. package/docs/opportunities/OPP-009-process-entry-points.md +56 -0
  26. package/docs/opportunities/OPP-010-orchestration-worktree-isolation.md +52 -0
  27. package/docs/opportunities/OPP-011-evals-redteam-generated-artifacts.md +56 -0
  28. package/docs/opportunities/OPP-012-standards-discovery.md +51 -0
  29. package/docs/opportunities/OPP-013-read-optimized-artifacts-digest-layer.md +52 -0
  30. package/docs/opportunities/OPP-014-paired-verification-contract.md +54 -0
  31. package/docs/opportunities/OPP-015-token-recovery-governance.md +52 -0
  32. package/docs/opportunities/README.md +25 -10
  33. package/package.json +1 -1
  34. package/resources/skills/implementation-planning.md +1 -1
  35. package/resources/templates/brainstorm.md +0 -1
  36. package/resources/templates/decision-record.md +0 -1
  37. package/resources/templates/implementation-evidence.md +1 -1
  38. package/resources/templates/implementation-plan.md +2 -1
  39. package/resources/templates/migration-report.md +0 -1
  40. package/resources/templates/security-review.md +0 -1
  41. package/resources/templates/task.md +0 -1
  42. package/resources/templates/technical-design.md +0 -1
  43. package/resources/templates/test-plan.md +0 -1
  44. 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,44 @@ 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 { extractStandards, parseStandardsIndex } from "./standards.js";
71
+ import { digestPath, digestFreshness, extractDigest, formatDigestLine, serializeDigest, } from "./digest.js";
72
+ function installationRequired() {
73
+ return {
74
+ exitCode: 1,
75
+ error: formatBlockingOutput(describeBlockingReason("installation_required", {
76
+ detail: "Run `aiw install` first.",
77
+ })),
78
+ };
79
+ }
80
+ function stateMismatch(detail) {
81
+ return {
82
+ exitCode: 1,
83
+ error: formatBlockingOutput(describeBlockingReason("state_mismatch", { detail })),
84
+ };
85
+ }
70
86
  import { parseMigrationCheckpoint, planNeutralResourceMigration, readResourceBytes, serializeMigrationPreview, serializeMigrationCheckpoint, validateNeutralResourceDocument, writeResourceBytes, } from "./migration.js";
71
87
  import { parsePrivateRegistries, parseTeamPreset, serializePrivateRegistries, serializeTeamPreset, fetchPrivateRegistryClient, } from "./team-configuration.js";
72
88
  import { enforcePolicyGate, gitPolicyTracking } from "./policy-gate.js";
73
89
  import { DEFAULT_TELEMETRY_CONFIG, loadTelemetryConfig, offerTelemetryConsent, parseTelemetryConfig, purgeTelemetryBuffer, readTelemetryBuffer, serializeTelemetryMapping, TELEMETRY_BUFFER_CAP, TELEMETRY_BUFFER_FILE, } from "./telemetry.js";
74
90
  import { createPluginScaffold, validatePluginDirectory, writePluginScaffold, } from "./plugin-sdk.js";
75
- import { executeOrchestrationPlan, parseOrchestrationPlan, serializeOrchestrationPreview, serializeOrchestrationResults, validateOrchestrationParallelism, } from "./orchestration.js";
91
+ import { createTaskWorktrees, executeOrchestrationPlan, listManagedWorktrees, removeWorktrees, parseOrchestrationPlan, serializeOrchestrationPreview, serializeOrchestrationResults, validateOrchestrationParallelism, } from "./orchestration.js";
92
+ function laneAdvisory(root, fs, lightChosen) {
93
+ const paths = collectWorkingTreeChanges(root);
94
+ if (!paths || paths.length === 0)
95
+ return undefined;
96
+ const assessment = assessRisk(paths.map((path) => ({ path })), { hasCoveringTest: hasCoveringTest(root, fs) });
97
+ if (lightChosen && assessment.tier === "high")
98
+ return `advisory: risk assess reports high for the current changes (${assessment.reasons
99
+ .slice(0, 2)
100
+ .map((reason) => reason.code)
101
+ .join(", ")}); consider the full lane, or escalate later with \`aiw task escalate --reason=...\`.`;
102
+ if (!lightChosen && assessment.tier === "passive")
103
+ return "advisory: current changes look passive; the light lane (--lane=light --lane-reason=...) may be viable for small work.";
104
+ return undefined;
105
+ }
76
106
  function beginWorkflowSession(fs, aiw, options) {
77
107
  const sessionPath = join(aiw, "workflow.yml");
78
108
  const ledgerPath = join(aiw, "approvals.yml");
@@ -83,7 +113,7 @@ function beginWorkflowSession(fs, aiw, options) {
83
113
  detail: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
84
114
  }));
85
115
  if (!fs.exists(ledgerPath))
86
- return { exitCode: 1, error: "Completed workflow is missing its approval ledger." };
116
+ return stateMismatch("Completed workflow is missing its approval ledger.");
87
117
  const archive = join(aiw, `checkpoints/workflow-${previous.workflowId}-approvals.yml`);
88
118
  if (fs.exists(archive))
89
119
  return { exitCode: 1, error: `Workflow approval archive already exists: ${archive}` };
@@ -93,7 +123,7 @@ function beginWorkflowSession(fs, aiw, options) {
93
123
  else if (fs.exists(ledgerPath)) {
94
124
  return {
95
125
  exitCode: 1,
96
- error: "Approval ledger exists without a workflow session; run doctor.",
126
+ error: stateMismatch("Approval ledger exists without a workflow session; run doctor.").error,
97
127
  };
98
128
  }
99
129
  const workflowId = options.id ?? randomUUID();
@@ -120,6 +150,7 @@ function beginWorkflowSession(fs, aiw, options) {
120
150
  ...(options.lane === "light" && options.laneReason
121
151
  ? { lane: "light", laneReason: options.laneReason }
122
152
  : {}),
153
+ ...(options.process && options.process !== "feature" ? { process: options.process } : {}),
123
154
  });
124
155
  fs.write(ledgerPath, serializeApprovalLedger(ledger));
125
156
  fs.write(sessionPath, serializeWorkflowSession(session));
@@ -132,7 +163,7 @@ export async function runCommand(args, root, fs, services = {}) {
132
163
  const command = args[0] ?? "help";
133
164
  if (command === "task") {
134
165
  if (!fs.exists(join(aiw, "manifest.yml")))
135
- return { exitCode: 1, error: "Run `aiw install` first." };
166
+ return installationRequired();
136
167
  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
168
  const action = args[1];
138
169
  const sessionPath = join(aiw, "workflow.yml");
@@ -143,10 +174,24 @@ export async function runCommand(args, root, fs, services = {}) {
143
174
  "id",
144
175
  "lane",
145
176
  "lane-reason",
177
+ "process",
146
178
  ]);
147
179
  if (!options?.ticket)
148
180
  return { exitCode: 1, error: usage };
149
181
  validateTaskTicket(options.ticket);
182
+ const processName = options.process;
183
+ if (processName !== undefined) {
184
+ if (!isWorkflowProcess(processName))
185
+ return {
186
+ exitCode: 1,
187
+ error: `Unknown process: ${processName}. Known processes: ${PROCESS_IDS.join(", ")}.`,
188
+ };
189
+ if (processName === "feature")
190
+ return {
191
+ exitCode: 1,
192
+ error: "The feature process is the default; omit --process for it.",
193
+ };
194
+ }
150
195
  if (options.lane !== undefined && options.lane !== "light")
151
196
  return { exitCode: 1, error: "Only --lane=light exists; omit it for the full lane." };
152
197
  const laneReason = options["lane-reason"];
@@ -176,14 +221,17 @@ export async function runCommand(args, root, fs, services = {}) {
176
221
  ticket: options.ticket,
177
222
  ...(options.title ? { title: options.title } : {}),
178
223
  ...(lane ? { lane, laneReason: laneReason } : {}),
224
+ ...(processName ? { process: processName } : {}),
179
225
  });
180
226
  if (result.exitCode !== 0)
181
227
  return result;
228
+ const advisory = laneAdvisory(root, fs, lane === "light");
182
229
  const title = options.title ? ` titled "${options.title}"` : "";
183
230
  if (lane)
184
231
  return {
185
232
  exitCode: 0,
186
233
  output: `Task started for ticket ${options.ticket}${title} (light lane): ${result.output}\n` +
234
+ (advisory ? `${advisory}\n` : "") +
187
235
  "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
236
  `Rationale recorded: ${laneReason}\n` +
189
237
  '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 +239,7 @@ export async function runCommand(args, root, fs, services = {}) {
191
239
  return {
192
240
  exitCode: 0,
193
241
  output: `Task started for ticket ${options.ticket}${title}: ${result.output}\n` +
242
+ (advisory ? `${advisory}\n` : "") +
194
243
  `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
244
  };
196
245
  }
@@ -287,7 +336,7 @@ export async function runCommand(args, root, fs, services = {}) {
287
336
  ...lane,
288
337
  `Workflow: ${session.workflowId} (${session.status})`,
289
338
  "Stages:",
290
- ...APPROVAL_STAGES.map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`),
339
+ ...processStages(ledger).map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`),
291
340
  ];
292
341
  if (session.status === "active" && effectiveLane(session) === "light") {
293
342
  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 +351,32 @@ export async function runCommand(args, root, fs, services = {}) {
302
351
  }
303
352
  if (command === "workflow") {
304
353
  if (!fs.exists(join(aiw, "manifest.yml")))
305
- return { exitCode: 1, error: "Run `aiw install` first." };
354
+ return installationRequired();
306
355
  const action = args[1];
307
356
  const sessionPath = join(aiw, "workflow.yml");
308
357
  if (action === "start") {
309
- const options = parseNamedOptions(args.slice(2).filter((argument) => argument !== "--json"), ["id"]);
358
+ const options = parseNamedOptions(args.slice(2).filter((argument) => argument !== "--json"), ["id", "process"]);
310
359
  if (!options)
311
- return { exitCode: 1, error: "Usage: aiw workflow start [--id=workflow-id]" };
312
- return beginWorkflowSession(fs, aiw, { id: options.id });
360
+ return {
361
+ exitCode: 1,
362
+ error: "Usage: aiw workflow start [--id=workflow-id] [--process=bugfix|idea-assessment]",
363
+ };
364
+ const processName = options.process;
365
+ if (processName !== undefined && !isWorkflowProcess(processName))
366
+ return {
367
+ exitCode: 1,
368
+ error: `Unknown process: ${processName}. Known processes: ${PROCESS_IDS.join(", ")}.`,
369
+ };
370
+ return beginWorkflowSession(fs, aiw, {
371
+ id: options.id,
372
+ ...(processName ? { process: processName } : {}),
373
+ });
313
374
  }
314
375
  if (action === "status") {
315
376
  if (!fs.exists(sessionPath))
316
377
  return { exitCode: 0, output: "No SDD workflow is active." };
317
378
  const { session, ledger } = readWorkflowState(aiw, fs, false);
318
- const stages = APPROVAL_STAGES.map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`);
379
+ const stages = processStages(ledger).map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`);
319
380
  return {
320
381
  exitCode: 0,
321
382
  output: `Workflow ${session.workflowId} (${session.status})\n${stages.join("\n")}\n${workflowNextAction(ledger)}`,
@@ -382,7 +443,7 @@ export async function runCommand(args, root, fs, services = {}) {
382
443
  output: `Workflow completed (light lane): ${session.workflowId}. Evidence: ${evidencePath}`,
383
444
  };
384
445
  }
385
- const unresolved = APPROVAL_STAGES.filter((stage) => ledger.stages[stage]?.status !== "approved" &&
446
+ const unresolved = processStages(ledger).filter((stage) => ledger.stages[stage]?.status !== "approved" &&
386
447
  ledger.stages[stage]?.status !== "skipped");
387
448
  if (unresolved.length)
388
449
  return {
@@ -457,7 +518,12 @@ export async function runCommand(args, root, fs, services = {}) {
457
518
  updatedAt: new Date().toISOString(),
458
519
  });
459
520
  fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(updated));
460
- return { exitCode: 0, output: `Human approval recorded for ${workflowStage}.` };
521
+ fs.mkdir(join(aiw, "digests"));
522
+ fs.write(digestPath(aiw, workflowStage), serializeDigest(extractDigest(workflowStage, fs.read(artifact), artifactHash)));
523
+ return {
524
+ exitCode: 0,
525
+ output: `Human approval recorded for ${workflowStage}. Digest available: aiw digest --stage=${workflowStage}`,
526
+ };
461
527
  }
462
528
  const options = parseNamedOptions(rest, ["reason"]);
463
529
  if (!options?.reason)
@@ -471,7 +537,7 @@ export async function runCommand(args, root, fs, services = {}) {
471
537
  }
472
538
  if (command === "ui") {
473
539
  if (!fs.exists(join(aiw, "manifest.yml")))
474
- return { exitCode: 1, error: "Run `aiw install` first." };
540
+ return installationRequired();
475
541
  const parsed = parseNamedOptions(args.slice(1), ["port"]);
476
542
  if (!parsed || (parsed.port !== undefined && !/^\d+$/.test(parsed.port)))
477
543
  return { exitCode: 1, error: "Usage: aiw ui [--port=number]" };
@@ -485,20 +551,36 @@ export async function runCommand(args, root, fs, services = {}) {
485
551
  }
486
552
  if (command === "orchestrate") {
487
553
  if (!fs.exists(join(aiw, "manifest.yml")))
488
- return { exitCode: 1, error: "Run `aiw install` first." };
554
+ return installationRequired();
489
555
  const executeCount = args.filter((arg) => arg === "--execute").length;
490
556
  if (executeCount > 1)
491
557
  return {
492
558
  exitCode: 1,
493
- error: "Usage: aiw orchestrate --plan=path [--execute] [--max-parallel=number]",
559
+ error: "Usage: aiw orchestrate --plan=path [--execute] [--isolate] [--cleanup] [--max-parallel=number]",
494
560
  };
495
561
  const execute = executeCount === 1;
496
- const named = args.slice(1).filter((arg) => arg !== "--execute");
562
+ const isolate = args.includes("--isolate");
563
+ const cleanup = args.includes("--cleanup");
564
+ if (cleanup) {
565
+ const leaked = listManagedWorktrees(root, aiw);
566
+ if (leaked.length === 0)
567
+ return { exitCode: 0, output: "No managed worktrees to clean." };
568
+ if (!execute)
569
+ return {
570
+ exitCode: 0,
571
+ output: `Managed worktrees found:\n${leaked.map((path) => `- ${path}`).join("\n")}\nRe-run with --execute to remove them.`,
572
+ };
573
+ removeWorktrees(root, leaked, { force: true });
574
+ return { exitCode: 0, output: `Removed ${leaked.length} managed worktree(s).` };
575
+ }
576
+ const named = args
577
+ .slice(1)
578
+ .filter((arg) => arg !== "--execute" && arg !== "--isolate" && arg !== "--cleanup");
497
579
  const parsed = parseNamedOptions(named, ["plan", "max-parallel"]);
498
580
  if (!parsed?.plan)
499
581
  return {
500
582
  exitCode: 1,
501
- error: "Usage: aiw orchestrate --plan=path [--execute] [--max-parallel=number]",
583
+ error: "Usage: aiw orchestrate --plan=path [--execute] [--isolate] [--cleanup] [--max-parallel=number]",
502
584
  };
503
585
  const planPath = safeProjectFile(root, parsed.plan, fs);
504
586
  const plan = parseOrchestrationPlan(fs.read(planPath));
@@ -517,7 +599,19 @@ export async function runCommand(args, root, fs, services = {}) {
517
599
  error: `Orchestration checkpoint already exists; use a new plan id: ${checkpoint}`,
518
600
  };
519
601
  try {
520
- const results = await executeOrchestrationPlan(plan, services.orchestrator, maxParallel);
602
+ const isolation = isolate
603
+ ? {
604
+ options: { root, aiw, planId: plan.id },
605
+ worktrees: createTaskWorktrees(root, aiw, plan),
606
+ }
607
+ : undefined;
608
+ const results = await executeOrchestrationPlan(plan, services.orchestrator, maxParallel, isolation);
609
+ if (isolation) {
610
+ const passedPaths = results
611
+ .filter(({ status, worktree }) => status === "passed" && worktree)
612
+ .map(({ worktree }) => worktree);
613
+ removeWorktrees(root, passedPaths);
614
+ }
521
615
  fs.write(checkpoint, serializeOrchestrationResults(plan, results));
522
616
  const failed = results.filter(({ status }) => status !== "passed");
523
617
  return failed.length
@@ -640,7 +734,7 @@ export async function runCommand(args, root, fs, services = {}) {
640
734
  }
641
735
  if (command === "telemetry") {
642
736
  if (!fs.exists(join(aiw, "manifest.yml")))
643
- return { exitCode: 1, error: "Run `aiw install` first." };
737
+ return installationRequired();
644
738
  const usage = "Usage: aiw telemetry <status|show|enable|disable> [--verbose] [--commands=include|exclude] [--outcomes=include|exclude]";
645
739
  const action = args[1];
646
740
  if (!action)
@@ -753,7 +847,7 @@ export async function runCommand(args, root, fs, services = {}) {
753
847
  }
754
848
  if (command === "context-bench") {
755
849
  if (!fs.exists(join(aiw, "manifest.yml")))
756
- return { exitCode: 1, error: "Run `aiw install` first." };
850
+ return installationRequired();
757
851
  const saveCount = args.filter((arg) => arg === "--save").length;
758
852
  if (saveCount > 1)
759
853
  return { exitCode: 1, error: "Usage: aiw context-bench [--baseline=path] [--save]" };
@@ -798,7 +892,7 @@ export async function runCommand(args, root, fs, services = {}) {
798
892
  }
799
893
  if (command === "parity") {
800
894
  if (!fs.exists(join(aiw, "manifest.yml")))
801
- return { exitCode: 1, error: "Run `aiw install` first." };
895
+ return installationRequired();
802
896
  const parsed = parseNamedOptions(args.slice(1), ["tasks"]);
803
897
  if (!parsed)
804
898
  return { exitCode: 1, error: "Usage: aiw parity [--tasks=directory]" };
@@ -852,7 +946,7 @@ export async function runCommand(args, root, fs, services = {}) {
852
946
  }
853
947
  if (command === "scan") {
854
948
  if (!fs.exists(join(aiw, "manifest.yml")))
855
- return { exitCode: 1, error: "Run `aiw install` first." };
949
+ return installationRequired();
856
950
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
857
951
  let interpreter = services.interpreter;
858
952
  let interpreterModel;
@@ -1132,7 +1226,7 @@ export async function runCommand(args, root, fs, services = {}) {
1132
1226
  return { exitCode: 1, error: `Unsupported target: ${target}` };
1133
1227
  const manifestPath = join(aiw, "manifest.yml");
1134
1228
  if (!fs.exists(manifestPath))
1135
- return { exitCode: 1, error: "Run `aiw install` first." };
1229
+ return installationRequired();
1136
1230
  const dryRun = args.includes("--dry-run");
1137
1231
  const targetPath = join(root, renderAiInitPath(target));
1138
1232
  const neutralAiInit = join(aiw, "resources/skills/ai-init.md");
@@ -1459,7 +1553,7 @@ export async function runCommand(args, root, fs, services = {}) {
1459
1553
  }
1460
1554
  if (command === "confirm") {
1461
1555
  if (!fs.exists(join(aiw, "manifest.yml")))
1462
- return { exitCode: 1, error: "Run `aiw install` first." };
1556
+ return installationRequired();
1463
1557
  const overrides = [];
1464
1558
  const accept = args.indexOf("--accept");
1465
1559
  if (accept >= 0 && args[accept + 1])
@@ -1483,7 +1577,7 @@ export async function runCommand(args, root, fs, services = {}) {
1483
1577
  }
1484
1578
  if (command === "recommend") {
1485
1579
  if (!fs.exists(join(aiw, "manifest.yml")))
1486
- return { exitCode: 1, error: "Run `aiw install` first." };
1580
+ return installationRequired();
1487
1581
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
1488
1582
  const recommendations = services.recommender?.recommend(profile) ?? recommendCapabilities(profile);
1489
1583
  let ordered = recommendations;
@@ -1551,7 +1645,7 @@ export async function runCommand(args, root, fs, services = {}) {
1551
1645
  }
1552
1646
  if (command === "skills") {
1553
1647
  if (!fs.exists(join(aiw, "manifest.yml")))
1554
- return { exitCode: 1, error: "Run `aiw install` first." };
1648
+ return installationRequired();
1555
1649
  const action = args[1];
1556
1650
  const executor = services.externalSkills ?? nodeCommandExecutor;
1557
1651
  const approvedPermissions = parseApprovedPermissions(args);
@@ -1615,7 +1709,7 @@ export async function runCommand(args, root, fs, services = {}) {
1615
1709
  }
1616
1710
  if (command === "generate" || command === "sync") {
1617
1711
  if (!fs.exists(join(aiw, "manifest.yml")))
1618
- return { exitCode: 1, error: "Run `aiw install` first." };
1712
+ return installationRequired();
1619
1713
  const profilePath = join(aiw, "profile.yml");
1620
1714
  const persistedProfile = fs.exists(profilePath) ? fs.read(profilePath) : "";
1621
1715
  const profile = /^status:\s*scanned$/m.test(persistedProfile)
@@ -1844,7 +1938,7 @@ export async function runCommand(args, root, fs, services = {}) {
1844
1938
  }
1845
1939
  if (command === "brainstorm") {
1846
1940
  if (!fs.exists(join(aiw, "manifest.yml")))
1847
- return { exitCode: 1, error: "Run `aiw install` first." };
1941
+ return installationRequired();
1848
1942
  assertStageReady(aiw, fs, "brainstorming");
1849
1943
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Brainstorm";
1850
1944
  const path = join(aiw, "generated/specs/brainstorm.md");
@@ -1853,7 +1947,7 @@ export async function runCommand(args, root, fs, services = {}) {
1853
1947
  }
1854
1948
  if (command === "spec") {
1855
1949
  if (!fs.exists(join(aiw, "manifest.yml")))
1856
- return { exitCode: 1, error: "Run `aiw install` first." };
1950
+ return installationRequired();
1857
1951
  assertStageReady(aiw, fs, "specification");
1858
1952
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Specification";
1859
1953
  const ticket = args.find((arg) => arg.startsWith("--ticket="))?.slice(9);
@@ -1865,7 +1959,7 @@ export async function runCommand(args, root, fs, services = {}) {
1865
1959
  }
1866
1960
  if (command === "adr") {
1867
1961
  if (!fs.exists(join(aiw, "manifest.yml")))
1868
- return { exitCode: 1, error: "Run `aiw install` first." };
1962
+ return installationRequired();
1869
1963
  assertStageReady(aiw, fs, "technical-design");
1870
1964
  const id = args.find((arg) => arg.startsWith("--id="))?.slice(5) || "001";
1871
1965
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Decision";
@@ -1875,26 +1969,27 @@ export async function runCommand(args, root, fs, services = {}) {
1875
1969
  }
1876
1970
  if (command === "plan") {
1877
1971
  if (!fs.exists(join(aiw, "manifest.yml")))
1878
- return { exitCode: 1, error: "Run `aiw install` first." };
1972
+ return installationRequired();
1879
1973
  assertStageReady(aiw, fs, "plan");
1880
1974
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Implementation Plan";
1881
1975
  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`);
1976
+ 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
1977
  return { exitCode: 0, output: `Implementation plan created: ${path}` };
1884
1978
  }
1885
1979
  if (command === "verify") {
1886
1980
  if (!fs.exists(join(aiw, "manifest.yml")))
1887
- return { exitCode: 1, error: "Run `aiw install` first." };
1981
+ return installationRequired();
1888
1982
  assertStageReady(aiw, fs, "verification");
1889
1983
  const specPath = join(aiw, "generated/specs/specification.md");
1890
1984
  const planPath = join(aiw, "generated/plans/implementation-plan.md");
1891
1985
  const issues = [];
1986
+ let regressionNotice;
1892
1987
  let specification = "";
1893
1988
  if (!fs.exists(specPath))
1894
1989
  issues.push("Specification artifact is missing.");
1895
1990
  else {
1896
1991
  specification = fs.read(specPath);
1897
- if (!specification.includes("Acceptance criteria"))
1992
+ if (!specification.toLowerCase().includes("acceptance criteria"))
1898
1993
  issues.push("Requirements have no acceptance criteria.");
1899
1994
  }
1900
1995
  if (!fs.exists(planPath))
@@ -1907,17 +2002,24 @@ export async function runCommand(args, root, fs, services = {}) {
1907
2002
  const uncovered = requirements.filter((requirement) => !plan.includes(requirement));
1908
2003
  if (uncovered.length > 0)
1909
2004
  issues.push(`Untested requirements: ${uncovered.join(", ")}.`);
2005
+ const validationCount = (plan.match(/Validation:/g) ?? []).length;
2006
+ const regressionCount = (plan.match(/Regression:/g) ?? []).length;
2007
+ regressionNotice =
2008
+ validationCount > regressionCount
2009
+ ? `${validationCount - regressionCount} of ${validationCount} plan tasks declare no regression coverage for touched existing behavior.`
2010
+ : undefined;
1910
2011
  }
1911
2012
  return issues.length
1912
2013
  ? { exitCode: 1, error: `Verification failed: ${issues.join(" ")}` }
1913
2014
  : {
1914
2015
  exitCode: 0,
1915
- output: "Verification passed: requirements have acceptance criteria and validation coverage.",
2016
+ output: "Verification passed: requirements have acceptance criteria and validation coverage." +
2017
+ (regressionNotice ? ` Advisory: ${regressionNotice}` : ""),
1916
2018
  };
1917
2019
  }
1918
2020
  if (command === "trace") {
1919
2021
  if (!fs.exists(join(aiw, "manifest.yml")))
1920
- return { exitCode: 1, error: "Run `aiw install` first." };
2022
+ return installationRequired();
1921
2023
  assertStageReady(aiw, fs, "traceability");
1922
2024
  const specPath = join(aiw, "generated/specs/specification.md");
1923
2025
  const planPath = join(aiw, "generated/plans/implementation-plan.md");
@@ -1946,6 +2048,144 @@ export async function runCommand(args, root, fs, services = {}) {
1946
2048
  fs.write(path, serialized);
1947
2049
  return { exitCode: 0, output: `Traceability graph created: ${path}` };
1948
2050
  }
2051
+ if (command === "standards") {
2052
+ if (!fs.exists(join(aiw, "manifest.yml")))
2053
+ return installationRequired();
2054
+ const action = args[1] ?? "list";
2055
+ const index = fs.exists(join(aiw, "standards/index.yml"))
2056
+ ? parseStandardsIndex(fs.read(join(aiw, "standards/index.yml")))
2057
+ : { schema: 1, standards: {} };
2058
+ if (action === "extract") {
2059
+ fs.mkdir(join(aiw, "standards"));
2060
+ const documents = extractStandards(root, fs);
2061
+ for (const document of documents) {
2062
+ const body = [
2063
+ `# Standard: ${document.topic}`,
2064
+ "",
2065
+ `Status: ${document.status}`,
2066
+ "",
2067
+ ...document.statements.flatMap((statement) => [
2068
+ statement.convention,
2069
+ ...(statement.occurrences > 0
2070
+ ? statement.evidence.map((evidence) => `- Evidence: ${evidence.path}:${evidence.line}`)
2071
+ : ["- Evidence: none (deterministic scan found no instances)"]),
2072
+ ]),
2073
+ "",
2074
+ ].join("\n");
2075
+ fs.write(join(aiw, `standards/${document.topic}.md`), body);
2076
+ if (index.standards[document.topic] === undefined)
2077
+ index.standards[document.topic] = {
2078
+ status: "inferred",
2079
+ updatedAt: new Date().toISOString(),
2080
+ };
2081
+ }
2082
+ const serialized = [
2083
+ "schema: 1",
2084
+ "standards:",
2085
+ ...Object.entries(index.standards).map(([topic, entry]) => ` ${topic}:\n status: ${entry.status}\n updatedAt: ${entry.updatedAt}`),
2086
+ "",
2087
+ ].join("\n");
2088
+ fs.write(join(aiw, "standards/index.yml"), serialized);
2089
+ const names = documents.map((document) => document.topic).join(", ");
2090
+ return {
2091
+ exitCode: 0,
2092
+ 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.`,
2093
+ };
2094
+ }
2095
+ if (action === "list") {
2096
+ const entries = Object.entries(index.standards);
2097
+ if (entries.length === 0)
2098
+ return {
2099
+ exitCode: 0,
2100
+ output: "No standards recorded. Run `aiw standards extract` first.",
2101
+ };
2102
+ return {
2103
+ exitCode: 0,
2104
+ output: [
2105
+ "Standards:",
2106
+ ...entries.map(([topic, entry]) => `- ${topic}: ${entry.status}`),
2107
+ "Confirm or reject with `aiw standards confirm|reject <topic>`.",
2108
+ ].join("\n"),
2109
+ };
2110
+ }
2111
+ if (action === "confirm" || action === "reject") {
2112
+ const topic = args[2];
2113
+ if (!topic)
2114
+ return { exitCode: 1, error: `Usage: aiw standards ${action} <topic>` };
2115
+ const path = join(aiw, `standards/${topic}.md`);
2116
+ if (!fs.exists(path))
2117
+ return {
2118
+ exitCode: 1,
2119
+ error: `No standard document for ${topic}; run \`aiw standards extract\` first.`,
2120
+ };
2121
+ const status = action === "confirm" ? "confirmed" : "rejected";
2122
+ index.standards[topic] = { status, updatedAt: new Date().toISOString() };
2123
+ const existing = fs.read(path).replace(/^Status: .*$/m, `Status: ${status}`);
2124
+ fs.write(path, existing);
2125
+ const serialized = [
2126
+ "schema: 1",
2127
+ "standards:",
2128
+ ...Object.entries(index.standards).map(([topicEntry, entry]) => ` ${topicEntry}:\n status: ${entry.status}\n updatedAt: ${entry.updatedAt}`),
2129
+ "",
2130
+ ].join("\n");
2131
+ fs.write(join(aiw, "standards/index.yml"), serialized);
2132
+ return {
2133
+ exitCode: 0,
2134
+ output: `Standard ${topic} ${status}. ${status === "confirmed"
2135
+ ? "Confirmed standards are referenced by generated rules."
2136
+ : "Rejected standards are never rendered."}`,
2137
+ };
2138
+ }
2139
+ return {
2140
+ exitCode: 1,
2141
+ error: "Usage: aiw standards <extract|list|confirm|reject> [topic] — standards are inferred until a human confirms them.",
2142
+ };
2143
+ }
2144
+ if (command === "digest") {
2145
+ if (!fs.exists(join(aiw, "manifest.yml")))
2146
+ return installationRequired();
2147
+ const stageArg = args.find((argument) => argument.startsWith("--stage="))?.slice(8);
2148
+ const stages = stageArg
2149
+ ? [stageArg]
2150
+ : fs.exists(join(aiw, "workflow.yml"))
2151
+ ? processStages(readWorkflowState(aiw, fs, false).ledger)
2152
+ : [...APPROVAL_STAGES];
2153
+ const lines = [];
2154
+ let served = 0;
2155
+ for (const stage of stages) {
2156
+ const artifactPath = approvalArtifactPath(aiw, stage);
2157
+ const freshness = digestFreshness(aiw, fs, stage, artifactPath ?? undefined);
2158
+ if (freshness.state === "missing")
2159
+ continue;
2160
+ lines.push(formatDigestLine(freshness));
2161
+ served += 1;
2162
+ }
2163
+ if (lines.length === 0)
2164
+ return {
2165
+ exitCode: 0,
2166
+ output: "No digests yet: digests appear when stages pass their gate and receive human approval.",
2167
+ };
2168
+ lines.push(`served: ${served} digest${served === 1 ? "" : "s"} (full reads on demand)`);
2169
+ return { exitCode: 0, output: lines.join("\n") };
2170
+ }
2171
+ if (command === "diagnosis") {
2172
+ if (!fs.exists(join(aiw, "manifest.yml")))
2173
+ return installationRequired();
2174
+ assertStageReady(aiw, fs, "diagnosis");
2175
+ const title = args.find((argument) => argument.startsWith("--title="))?.slice(8) || "Untitled Diagnosis";
2176
+ const path = join(aiw, "generated/reports/diagnosis.md");
2177
+ 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`);
2178
+ return { exitCode: 0, output: `Diagnosis artifact created: ${path}` };
2179
+ }
2180
+ if (command === "assess") {
2181
+ if (!fs.exists(join(aiw, "manifest.yml")))
2182
+ return installationRequired();
2183
+ assertStageReady(aiw, fs, "assessment");
2184
+ const title = args.find((argument) => argument.startsWith("--title="))?.slice(8) || "Untitled Assessment";
2185
+ const path = join(aiw, "generated/reports/assessment.md");
2186
+ 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`);
2187
+ return { exitCode: 0, output: `Assessment artifact created: ${path}` };
2188
+ }
1949
2189
  if (command === "risk") {
1950
2190
  if (args[1] !== "assess")
1951
2191
  return { exitCode: 1, error: "Usage: aiw risk assess [--base-ref=ref] [--json]" };
@@ -1953,7 +2193,7 @@ export async function runCommand(args, root, fs, services = {}) {
1953
2193
  }
1954
2194
  if (command === "gate") {
1955
2195
  if (!fs.exists(join(aiw, "manifest.yml")))
1956
- return { exitCode: 1, error: "Run `aiw install` first." };
2196
+ return installationRequired();
1957
2197
  const stage = args[1];
1958
2198
  if (!stage || !APPROVAL_STAGES.includes(stage))
1959
2199
  return {
@@ -1980,6 +2220,17 @@ export async function runCommand(args, root, fs, services = {}) {
1980
2220
  detail: `Quality gate blocked: ${issues.join(" ")}`,
1981
2221
  }));
1982
2222
  }
2223
+ if (hasQualityGate &&
2224
+ (workflowStage === "implementation" || workflowStage === "verification")) {
2225
+ const pairIssues = compareVerificationPairs(aiw, fs, workflowStage);
2226
+ if (pairIssues.length)
2227
+ throw new BlockingError(describeBlockingReason("gate_incomplete", {
2228
+ stage,
2229
+ artifactPath,
2230
+ gateIssues: pairIssues,
2231
+ detail: `Quality gate blocked: ${pairIssues.join(" ")}`,
2232
+ }));
2233
+ }
1983
2234
  const { ledger } = readActiveWorkflow(aiw, fs);
1984
2235
  const updated = beginStageApproval(ledger, workflowStage, {
1985
2236
  artifactHash: stageFingerprint(fs.read(artifactPath)),
@@ -2008,7 +2259,7 @@ export async function runCommand(args, root, fs, services = {}) {
2008
2259
  }
2009
2260
  if (command === "resolve") {
2010
2261
  if (!fs.exists(join(aiw, "manifest.yml")))
2011
- return { exitCode: 1, error: "Run `aiw install` first." };
2262
+ return installationRequired();
2012
2263
  const packageArg = args.find((arg) => arg.startsWith("--package="));
2013
2264
  const sourceArg = args.find((arg) => arg.startsWith("--source="));
2014
2265
  if (!packageArg && !sourceArg)
@@ -2070,7 +2321,7 @@ export async function runCommand(args, root, fs, services = {}) {
2070
2321
  }
2071
2322
  if (command === "organization-policy") {
2072
2323
  if (!fs.exists(join(aiw, "manifest.yml")))
2073
- return { exitCode: 1, error: "Run `aiw install` first." };
2324
+ return installationRequired();
2074
2325
  const fileArg = args.find((arg) => arg.startsWith("--file="));
2075
2326
  if (!fileArg)
2076
2327
  return { exitCode: 1, error: "Usage: aiw organization-policy --file=path [--replace]" };
@@ -2086,7 +2337,7 @@ export async function runCommand(args, root, fs, services = {}) {
2086
2337
  }
2087
2338
  if (command === "policy-check") {
2088
2339
  if (!fs.exists(join(aiw, "manifest.yml")))
2089
- return { exitCode: 1, error: "Run `aiw install` first." };
2340
+ return installationRequired();
2090
2341
  const policyPath = join(aiw, "organization.yml");
2091
2342
  if (!fs.exists(policyPath))
2092
2343
  return { exitCode: 1, error: "Organization policy is required for the CI policy gate." };
@@ -2124,7 +2375,7 @@ export async function runCommand(args, root, fs, services = {}) {
2124
2375
  }
2125
2376
  if (command === "preset") {
2126
2377
  if (!fs.exists(join(aiw, "manifest.yml")))
2127
- return { exitCode: 1, error: "Run `aiw install` first." };
2378
+ return installationRequired();
2128
2379
  const fileArg = args.find((arg) => arg.startsWith("--file="));
2129
2380
  if (!fileArg)
2130
2381
  return { exitCode: 1, error: "Usage: aiw preset --file=path [--replace]" };
@@ -2140,7 +2391,7 @@ export async function runCommand(args, root, fs, services = {}) {
2140
2391
  }
2141
2392
  if (command === "update") {
2142
2393
  if (!fs.exists(join(aiw, "manifest.yml")))
2143
- return { exitCode: 1, error: "Run `aiw install` first." };
2394
+ return installationRequired();
2144
2395
  const packageArg = args.find((arg) => arg.startsWith("--package="));
2145
2396
  if (!packageArg)
2146
2397
  return { exitCode: 1, error: "Usage: aiw update --package=path --target=target" };
@@ -2171,7 +2422,7 @@ export async function runCommand(args, root, fs, services = {}) {
2171
2422
  if (command === "registry") {
2172
2423
  if (args[1] === "configure") {
2173
2424
  if (!fs.exists(join(aiw, "manifest.yml")))
2174
- return { exitCode: 1, error: "Run `aiw install` first." };
2425
+ return installationRequired();
2175
2426
  const fileArg = args.find((arg) => arg.startsWith("--file="));
2176
2427
  if (!fileArg)
2177
2428
  return {
@@ -2257,9 +2508,39 @@ export async function runCommand(args, root, fs, services = {}) {
2257
2508
  };
2258
2509
  }
2259
2510
  const output = args.includes("--task") ? retrieveTaskContext(store) : composeContext(store);
2511
+ const stageArg = args.find((argument) => argument.startsWith("--stage="))?.slice(8);
2512
+ const digestLines = [];
2513
+ if (fs.exists(join(aiw, "workflow.yml"))) {
2514
+ for (const stage of processStages(readWorkflowState(aiw, fs, false).ledger)) {
2515
+ const artifactPath = approvalArtifactPath(aiw, stage);
2516
+ const freshness = digestFreshness(aiw, fs, stage, artifactPath ?? undefined);
2517
+ if (freshness.state !== "missing")
2518
+ digestLines.push(formatDigestLine(freshness));
2519
+ }
2520
+ }
2521
+ const budgetArg = args.find((argument) => argument.startsWith("--budget="))?.slice(9);
2522
+ if (stageArg) {
2523
+ const budget = Number(budgetArg ?? parseContextBudgets(aiw, fs)[stageArg]);
2524
+ if (Number.isFinite(budget) && budget > 0) {
2525
+ const estimated = Math.ceil(output.length / 4);
2526
+ if (estimated > budget)
2527
+ throw new BlockingError(describeBlockingReason("context_budget_exceeded", {
2528
+ stage: stageArg,
2529
+ detail: `Stage ${stageArg} context is ~${estimated} tokens, over the ${budget}-token ceiling.`,
2530
+ }));
2531
+ }
2532
+ const digestBlock = digestLines.length > 0 ? `\n${digestLines.join("\n")}` : "";
2533
+ return {
2534
+ exitCode: 0,
2535
+ output: `${output}${digestBlock}\n\nstage: ${stageArg} · ~${Math.ceil(output.length / 4)} tokens${Number.isFinite(budget) && budget > 0 ? ` · ceiling ${budget}` : " · no ceiling declared"}`,
2536
+ };
2537
+ }
2260
2538
  return { exitCode: 0, output };
2261
2539
  }
2262
- return { exitCode: 1, error: "Usage: aiw context [--layer=project --content=text]" };
2540
+ return {
2541
+ exitCode: 1,
2542
+ error: "Usage: aiw context [--layer=project --content=text] | aiw context --stage=<name> [--budget=tokens]",
2543
+ };
2263
2544
  }
2264
2545
  if (command === "context-summary") {
2265
2546
  const summaryArg = args.find((arg) => arg.startsWith("--summary="))?.slice(10);
@@ -2277,7 +2558,7 @@ export async function runCommand(args, root, fs, services = {}) {
2277
2558
  }
2278
2559
  if (command === "routing") {
2279
2560
  if (!fs.exists(join(aiw, "manifest.yml")))
2280
- return { exitCode: 1, error: "Run `aiw install` first." };
2561
+ return installationRequired();
2281
2562
  const usage = "Usage: aiw routing init | show | resolve --category=name";
2282
2563
  const action = args[1];
2283
2564
  const routingPath = join(aiw, ROUTING_TABLE_PATH);
@@ -2333,7 +2614,7 @@ export async function runCommand(args, root, fs, services = {}) {
2333
2614
  }
2334
2615
  if (command === "task-usage") {
2335
2616
  if (!fs.exists(join(aiw, "manifest.yml")))
2336
- return { exitCode: 1, error: "Run `aiw install` first." };
2617
+ return installationRequired();
2337
2618
  const parsed = parseNamedOptions(args.slice(1), ["workflow"]);
2338
2619
  if (!parsed)
2339
2620
  return { exitCode: 1, error: "Usage: aiw task-usage [--workflow=id-or-ticket]" };
@@ -2362,7 +2643,7 @@ export async function runCommand(args, root, fs, services = {}) {
2362
2643
  }
2363
2644
  if (command === "cost-report") {
2364
2645
  if (!fs.exists(join(aiw, "manifest.yml")))
2365
- return { exitCode: 1, error: "Run `aiw install` first." };
2646
+ return installationRequired();
2366
2647
  const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
2367
2648
  if (!fs.exists(sidecarPath))
2368
2649
  return {
@@ -2384,7 +2665,7 @@ export async function runCommand(args, root, fs, services = {}) {
2384
2665
  }
2385
2666
  if (command === "heartbeat") {
2386
2667
  if (!fs.exists(join(aiw, "manifest.yml")))
2387
- return { exitCode: 1, error: "Run `aiw install` first." };
2668
+ return installationRequired();
2388
2669
  const parsed = parseNamedOptions(args.slice(1), ["session", "warn", "ttl"]);
2389
2670
  if (!parsed)
2390
2671
  return {
@@ -2434,7 +2715,7 @@ export async function runCommand(args, root, fs, services = {}) {
2434
2715
  }
2435
2716
  if (command === "hooks") {
2436
2717
  if (!fs.exists(join(aiw, "manifest.yml")))
2437
- return { exitCode: 1, error: "Run `aiw install` first." };
2718
+ return installationRequired();
2438
2719
  const action = args[1];
2439
2720
  if (action !== "install")
2440
2721
  return {
@@ -2478,7 +2759,7 @@ export async function runCommand(args, root, fs, services = {}) {
2478
2759
  }
2479
2760
  if (command === "usage-sidecar") {
2480
2761
  if (!fs.exists(join(aiw, "manifest.yml")))
2481
- return { exitCode: 1, error: "Run `aiw install` first." };
2762
+ return installationRequired();
2482
2763
  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
2764
  const action = args[1];
2484
2765
  const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
@@ -2687,7 +2968,7 @@ export async function runCommand(args, root, fs, services = {}) {
2687
2968
  }
2688
2969
  if (command === "self-validate") {
2689
2970
  if (!fs.exists(join(aiw, "manifest.yml")))
2690
- return { exitCode: 1, error: "Run `aiw install` first." };
2971
+ return installationRequired();
2691
2972
  const ticket = args.find((arg) => arg.startsWith("--ticket="))?.slice(9);
2692
2973
  if (!ticket)
2693
2974
  return { exitCode: 1, error: "Usage: aiw self-validate --ticket=TYPE-000" };
@@ -2723,7 +3004,7 @@ export async function runCommand(args, root, fs, services = {}) {
2723
3004
  }
2724
3005
  return {
2725
3006
  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]",
3007
+ 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
3008
  };
2728
3009
  }
2729
3010
  catch (error) {
@@ -2741,12 +3022,14 @@ function readWorkflowState(aiw, fs, requireActive = true) {
2741
3022
  const session = parseWorkflowSession(fs.read(sessionPath));
2742
3023
  let ledger = parseApprovalLedger(fs.read(ledgerPath));
2743
3024
  if (session.workflowId !== ledger.workflowId)
2744
- throw new Error("Workflow session and approval ledger IDs do not match; run doctor.");
3025
+ throw new BlockingError(describeBlockingReason("state_mismatch", {
3026
+ detail: "Workflow session and approval ledger IDs do not match; run doctor.",
3027
+ }));
2745
3028
  if (requireActive && session.status !== "active")
2746
3029
  throw new BlockingError(describeBlockingReason("workflow_complete", {
2747
3030
  detail: `Workflow ${session.workflowId} is complete; start a new workflow first.`,
2748
3031
  }));
2749
- const changed = APPROVAL_STAGES.find((stage) => {
3032
+ const changed = processStages(ledger).find((stage) => {
2750
3033
  const record = ledger.stages[stage];
2751
3034
  if (!record?.artifactHash || !["pending-human", "approved"].includes(record.status))
2752
3035
  return false;
@@ -2774,6 +3057,85 @@ function workflowNextAction(ledger) {
2774
3057
  return `Next action: revise ${next}, rerun its gate, and request human approval.`;
2775
3058
  return `Next action: prepare ${next} evidence.`;
2776
3059
  }
3060
+ function parseContextBudgets(aiw, fs) {
3061
+ const path = join(aiw, "budgets.yml");
3062
+ if (!fs.exists(path))
3063
+ return {};
3064
+ const value = parseYaml(fs.read(path));
3065
+ if (!value || typeof value !== "object")
3066
+ return {};
3067
+ const stages = value.stages;
3068
+ if (!stages || typeof stages !== "object")
3069
+ return {};
3070
+ const budgets = {};
3071
+ for (const [stage, ceiling] of Object.entries(stages)) {
3072
+ const n = Number(ceiling);
3073
+ if (Number.isFinite(n) && n > 0)
3074
+ budgets[stage] = n;
3075
+ }
3076
+ return budgets;
3077
+ }
3078
+ const PLAN_PAIR_PATTERN = /Validation:\s*([^→\n]+)→\s*([^\n]+)/g;
3079
+ const EXIT_PATTERN = /exit\s+(\d+)/i;
3080
+ function normalizeCommand(command) {
3081
+ return command.trim().replace(/\s+/g, " ").toLowerCase();
3082
+ }
3083
+ /**
3084
+ * OPP-014 paired verification: plan tasks declare `Validation: <command> →
3085
+ * <expected>`; reports record `Validation: <command> → <observed>`. The gate
3086
+ * compares the pairs — command match, observed presence, exit-code agreement.
3087
+ * Legacy plans without pair arrows are accepted unchanged (ratchet).
3088
+ */
3089
+ export function compareVerificationPairs(aiw, fs, stage) {
3090
+ const planPath = join(aiw, "generated/plans/implementation-plan.md");
3091
+ if (!fs.exists(planPath))
3092
+ return [];
3093
+ const plan = fs.read(planPath);
3094
+ if (!plan.includes("→"))
3095
+ return [];
3096
+ const declared = [];
3097
+ for (const blockMatch of plan.matchAll(/TASK-\d+[\s\S]*?(?=TASK-\d+|$)/g)) {
3098
+ const block = blockMatch[0];
3099
+ const taskId = /^\s*(TASK-\d+)/.exec(block)?.[1] ?? "TASK-?";
3100
+ for (const pair of block.matchAll(PLAN_PAIR_PATTERN)) {
3101
+ const command = pair[1].trim();
3102
+ if (!command)
3103
+ continue;
3104
+ declared.push({
3105
+ task: taskId,
3106
+ command,
3107
+ expectedExitText: EXIT_PATTERN.exec(pair[2])?.[1],
3108
+ raw: pair[2].trim(),
3109
+ });
3110
+ }
3111
+ }
3112
+ if (declared.length === 0)
3113
+ return [];
3114
+ const reportPath = stage === "implementation"
3115
+ ? join(aiw, "generated/reports/implementation-report.md")
3116
+ : join(aiw, "generated/reports/verification-report.md");
3117
+ const issues = [];
3118
+ const observed = new Map();
3119
+ if (fs.exists(reportPath)) {
3120
+ for (const line of fs.read(reportPath).split("\n")) {
3121
+ const match = /Validation:\s*([^→\n]+)→\s*([^\n]+)/.exec(line);
3122
+ if (match)
3123
+ observed.set(normalizeCommand(match[1]), match[2].trim());
3124
+ }
3125
+ }
3126
+ for (const pair of declared) {
3127
+ const key = normalizeCommand(pair.command);
3128
+ if (!observed.has(key)) {
3129
+ issues.push(`[verification_pair_missing] ${pair.task}: \`${pair.command}\``);
3130
+ continue;
3131
+ }
3132
+ const observedExit = EXIT_PATTERN.exec(observed.get(key) ?? "")?.[1];
3133
+ if (pair.expectedExitText && observedExit && pair.expectedExitText !== observedExit) {
3134
+ issues.push(`[verification_pair_mismatch] ${pair.task}: expected exit ${pair.expectedExitText}, observed exit ${observedExit}`);
3135
+ }
3136
+ }
3137
+ return issues;
3138
+ }
2777
3139
  function parseLightEvidence(content) {
2778
3140
  const value = parseYaml(content);
2779
3141
  if (!value || typeof value !== "object" || value.schema !== 1)
@@ -2794,6 +3156,11 @@ function parseLightEvidence(content) {
2794
3156
  }
2795
3157
  function assertStageReady(aiw, fs, stage) {
2796
3158
  const { session, ledger } = readActiveWorkflow(aiw, fs);
3159
+ if (!processStages(ledger).includes(stage))
3160
+ throw new BlockingError(describeBlockingReason("stage_not_in_process", {
3161
+ stage,
3162
+ detail: `Stage ${stage} is not part of the ${ledger.process ?? "feature"} process for workflow ${session.workflowId}.`,
3163
+ }));
2797
3164
  if (effectiveLane(session) === "light")
2798
3165
  throw new BlockingError(describeBlockingReason("lane_escalation_required", {
2799
3166
  stage,
@@ -2837,6 +3204,8 @@ function approvalArtifactPath(aiw, stage) {
2837
3204
  verification: "generated/reports/verification-report.md",
2838
3205
  review: "generated/reports/code-review.md",
2839
3206
  traceability: "generated/artifacts/traceability.yml",
3207
+ diagnosis: "generated/reports/diagnosis.md",
3208
+ assessment: "generated/reports/assessment.md",
2840
3209
  };
2841
3210
  return resolve(aiw, paths[stage]);
2842
3211
  }