@multileaf/ai-workflow 0.10.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 (45) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/blocking-reasons.d.ts +1 -1
  3. package/dist/blocking-reasons.js +42 -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 +16 -3
  10. package/dist/human-approval.js +56 -12
  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 +472 -62
  23. package/docs/architecture.md +1 -1
  24. package/docs/backlog.md +10 -3
  25. package/docs/continuation-inventory.md +20 -13
  26. package/docs/opportunities/OPP-009-process-entry-points.md +56 -0
  27. package/docs/opportunities/OPP-010-orchestration-worktree-isolation.md +52 -0
  28. package/docs/opportunities/OPP-011-evals-redteam-generated-artifacts.md +56 -0
  29. package/docs/opportunities/OPP-012-standards-discovery.md +51 -0
  30. package/docs/opportunities/OPP-013-read-optimized-artifacts-digest-layer.md +52 -0
  31. package/docs/opportunities/OPP-014-paired-verification-contract.md +54 -0
  32. package/docs/opportunities/OPP-015-token-recovery-governance.md +52 -0
  33. package/docs/opportunities/README.md +25 -10
  34. package/package.json +1 -1
  35. package/resources/skills/implementation-planning.md +1 -1
  36. package/resources/templates/brainstorm.md +0 -1
  37. package/resources/templates/decision-record.md +0 -1
  38. package/resources/templates/implementation-evidence.md +1 -1
  39. package/resources/templates/implementation-plan.md +2 -1
  40. package/resources/templates/migration-report.md +0 -1
  41. package/resources/templates/security-review.md +0 -1
  42. package/resources/templates/task.md +0 -1
  43. package/resources/templates/technical-design.md +0 -1
  44. package/resources/templates/test-plan.md +0 -1
  45. 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,26 +65,55 @@ 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");
79
109
  if (fs.exists(sessionPath)) {
80
110
  const previous = parseWorkflowSession(fs.read(sessionPath));
81
111
  if (previous.status === "active")
82
- return {
83
- exitCode: 1,
84
- error: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
85
- };
112
+ throw new BlockingError(describeBlockingReason("workflow_still_active", {
113
+ detail: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
114
+ }));
86
115
  if (!fs.exists(ledgerPath))
87
- return { exitCode: 1, error: "Completed workflow is missing its approval ledger." };
116
+ return stateMismatch("Completed workflow is missing its approval ledger.");
88
117
  const archive = join(aiw, `checkpoints/workflow-${previous.workflowId}-approvals.yml`);
89
118
  if (fs.exists(archive))
90
119
  return { exitCode: 1, error: `Workflow approval archive already exists: ${archive}` };
@@ -94,7 +123,7 @@ function beginWorkflowSession(fs, aiw, options) {
94
123
  else if (fs.exists(ledgerPath)) {
95
124
  return {
96
125
  exitCode: 1,
97
- error: "Approval ledger exists without a workflow session; run doctor.",
126
+ error: stateMismatch("Approval ledger exists without a workflow session; run doctor.").error,
98
127
  };
99
128
  }
100
129
  const workflowId = options.id ?? randomUUID();
@@ -121,6 +150,7 @@ function beginWorkflowSession(fs, aiw, options) {
121
150
  ...(options.lane === "light" && options.laneReason
122
151
  ? { lane: "light", laneReason: options.laneReason }
123
152
  : {}),
153
+ ...(options.process && options.process !== "feature" ? { process: options.process } : {}),
124
154
  });
125
155
  fs.write(ledgerPath, serializeApprovalLedger(ledger));
126
156
  fs.write(sessionPath, serializeWorkflowSession(session));
@@ -133,7 +163,7 @@ export async function runCommand(args, root, fs, services = {}) {
133
163
  const command = args[0] ?? "help";
134
164
  if (command === "task") {
135
165
  if (!fs.exists(join(aiw, "manifest.yml")))
136
- return { exitCode: 1, error: "Run `aiw install` first." };
166
+ return installationRequired();
137
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";
138
168
  const action = args[1];
139
169
  const sessionPath = join(aiw, "workflow.yml");
@@ -144,10 +174,24 @@ export async function runCommand(args, root, fs, services = {}) {
144
174
  "id",
145
175
  "lane",
146
176
  "lane-reason",
177
+ "process",
147
178
  ]);
148
179
  if (!options?.ticket)
149
180
  return { exitCode: 1, error: usage };
150
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
+ }
151
195
  if (options.lane !== undefined && options.lane !== "light")
152
196
  return { exitCode: 1, error: "Only --lane=light exists; omit it for the full lane." };
153
197
  const laneReason = options["lane-reason"];
@@ -177,14 +221,17 @@ export async function runCommand(args, root, fs, services = {}) {
177
221
  ticket: options.ticket,
178
222
  ...(options.title ? { title: options.title } : {}),
179
223
  ...(lane ? { lane, laneReason: laneReason } : {}),
224
+ ...(processName ? { process: processName } : {}),
180
225
  });
181
226
  if (result.exitCode !== 0)
182
227
  return result;
228
+ const advisory = laneAdvisory(root, fs, lane === "light");
183
229
  const title = options.title ? ` titled "${options.title}"` : "";
184
230
  if (lane)
185
231
  return {
186
232
  exitCode: 0,
187
233
  output: `Task started for ticket ${options.ticket}${title} (light lane): ${result.output}\n` +
234
+ (advisory ? `${advisory}\n` : "") +
188
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" +
189
236
  `Rationale recorded: ${laneReason}\n` +
190
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=...`.',
@@ -192,6 +239,7 @@ export async function runCommand(args, root, fs, services = {}) {
192
239
  return {
193
240
  exitCode: 0,
194
241
  output: `Task started for ticket ${options.ticket}${title}: ${result.output}\n` +
242
+ (advisory ? `${advisory}\n` : "") +
195
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=...\`.`,
196
244
  };
197
245
  }
@@ -288,7 +336,7 @@ export async function runCommand(args, root, fs, services = {}) {
288
336
  ...lane,
289
337
  `Workflow: ${session.workflowId} (${session.status})`,
290
338
  "Stages:",
291
- ...APPROVAL_STAGES.map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`),
339
+ ...processStages(ledger).map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`),
292
340
  ];
293
341
  if (session.status === "active" && effectiveLane(session) === "light") {
294
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=...`.');
@@ -303,25 +351,77 @@ export async function runCommand(args, root, fs, services = {}) {
303
351
  }
304
352
  if (command === "workflow") {
305
353
  if (!fs.exists(join(aiw, "manifest.yml")))
306
- return { exitCode: 1, error: "Run `aiw install` first." };
354
+ return installationRequired();
307
355
  const action = args[1];
308
356
  const sessionPath = join(aiw, "workflow.yml");
309
357
  if (action === "start") {
310
- const options = parseNamedOptions(args.slice(2), ["id"]);
358
+ const options = parseNamedOptions(args.slice(2).filter((argument) => argument !== "--json"), ["id", "process"]);
311
359
  if (!options)
312
- return { exitCode: 1, error: "Usage: aiw workflow start [--id=workflow-id]" };
313
- 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
+ });
314
374
  }
315
375
  if (action === "status") {
316
376
  if (!fs.exists(sessionPath))
317
377
  return { exitCode: 0, output: "No SDD workflow is active." };
318
378
  const { session, ledger } = readWorkflowState(aiw, fs, false);
319
- 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"}`);
320
380
  return {
321
381
  exitCode: 0,
322
382
  output: `Workflow ${session.workflowId} (${session.status})\n${stages.join("\n")}\n${workflowNextAction(ledger)}`,
323
383
  };
324
384
  }
385
+ if (action === "abandon") {
386
+ const options = parseNamedOptions(args.slice(2), ["reason"]);
387
+ if (!options?.reason)
388
+ return { exitCode: 1, error: "Usage: aiw workflow abandon --reason=why-obsolete" };
389
+ if (options.reason.length > 500 || /[\r\n]/.test(options.reason))
390
+ return {
391
+ exitCode: 1,
392
+ error: "--reason must be a single line of at most 500 bytes.",
393
+ };
394
+ if (!fs.exists(sessionPath))
395
+ return {
396
+ exitCode: 1,
397
+ error: "No SDD workflow is active; start one with `aiw workflow start` or `aiw task start --ticket=TEAM-123`. [reason: workflow_not_active] Terminal: no automatic continuation beyond starting a workflow.",
398
+ };
399
+ const { session } = readActiveWorkflow(aiw, fs);
400
+ const ledgerPath = join(aiw, "approvals.yml");
401
+ const sessionArchive = join(aiw, "checkpoints", `workflow-${session.workflowId}-session.yml`);
402
+ const ledgerArchive = join(aiw, "checkpoints", `workflow-${session.workflowId}-approvals.yml`);
403
+ fs.mkdir(join(aiw, "checkpoints"));
404
+ const now = new Date().toISOString();
405
+ const archivedSession = {
406
+ ...session,
407
+ status: "abandoned",
408
+ abandonedReason: options.reason,
409
+ updatedAt: now,
410
+ };
411
+ fs.write(sessionArchive, serializeWorkflowSession(archivedSession));
412
+ if (fs.exists(ledgerPath))
413
+ fs.write(ledgerArchive, fs.read(ledgerPath));
414
+ fs.remove?.(sessionPath);
415
+ if (fs.exists(ledgerPath))
416
+ fs.remove?.(ledgerPath);
417
+ return {
418
+ exitCode: 0,
419
+ output: `Workflow ${session.workflowId} abandoned (${options.reason}).\n` +
420
+ `Session archived: ${sessionArchive}\n` +
421
+ (fs.exists(ledgerArchive) ? `Approval ledger archived: ${ledgerArchive}\n` : "") +
422
+ "Start a new one when ready.",
423
+ };
424
+ }
325
425
  if (action === "complete") {
326
426
  const { session, ledger } = readActiveWorkflow(aiw, fs);
327
427
  if (effectiveLane(session) === "light") {
@@ -343,7 +443,7 @@ export async function runCommand(args, root, fs, services = {}) {
343
443
  output: `Workflow completed (light lane): ${session.workflowId}. Evidence: ${evidencePath}`,
344
444
  };
345
445
  }
346
- const unresolved = APPROVAL_STAGES.filter((stage) => ledger.stages[stage]?.status !== "approved" &&
446
+ const unresolved = processStages(ledger).filter((stage) => ledger.stages[stage]?.status !== "approved" &&
347
447
  ledger.stages[stage]?.status !== "skipped");
348
448
  if (unresolved.length)
349
449
  return {
@@ -418,7 +518,12 @@ export async function runCommand(args, root, fs, services = {}) {
418
518
  updatedAt: new Date().toISOString(),
419
519
  });
420
520
  fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(updated));
421
- 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
+ };
422
527
  }
423
528
  const options = parseNamedOptions(rest, ["reason"]);
424
529
  if (!options?.reason)
@@ -432,7 +537,7 @@ export async function runCommand(args, root, fs, services = {}) {
432
537
  }
433
538
  if (command === "ui") {
434
539
  if (!fs.exists(join(aiw, "manifest.yml")))
435
- return { exitCode: 1, error: "Run `aiw install` first." };
540
+ return installationRequired();
436
541
  const parsed = parseNamedOptions(args.slice(1), ["port"]);
437
542
  if (!parsed || (parsed.port !== undefined && !/^\d+$/.test(parsed.port)))
438
543
  return { exitCode: 1, error: "Usage: aiw ui [--port=number]" };
@@ -446,20 +551,36 @@ export async function runCommand(args, root, fs, services = {}) {
446
551
  }
447
552
  if (command === "orchestrate") {
448
553
  if (!fs.exists(join(aiw, "manifest.yml")))
449
- return { exitCode: 1, error: "Run `aiw install` first." };
554
+ return installationRequired();
450
555
  const executeCount = args.filter((arg) => arg === "--execute").length;
451
556
  if (executeCount > 1)
452
557
  return {
453
558
  exitCode: 1,
454
- error: "Usage: aiw orchestrate --plan=path [--execute] [--max-parallel=number]",
559
+ error: "Usage: aiw orchestrate --plan=path [--execute] [--isolate] [--cleanup] [--max-parallel=number]",
455
560
  };
456
561
  const execute = executeCount === 1;
457
- 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");
458
579
  const parsed = parseNamedOptions(named, ["plan", "max-parallel"]);
459
580
  if (!parsed?.plan)
460
581
  return {
461
582
  exitCode: 1,
462
- error: "Usage: aiw orchestrate --plan=path [--execute] [--max-parallel=number]",
583
+ error: "Usage: aiw orchestrate --plan=path [--execute] [--isolate] [--cleanup] [--max-parallel=number]",
463
584
  };
464
585
  const planPath = safeProjectFile(root, parsed.plan, fs);
465
586
  const plan = parseOrchestrationPlan(fs.read(planPath));
@@ -478,7 +599,19 @@ export async function runCommand(args, root, fs, services = {}) {
478
599
  error: `Orchestration checkpoint already exists; use a new plan id: ${checkpoint}`,
479
600
  };
480
601
  try {
481
- 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
+ }
482
615
  fs.write(checkpoint, serializeOrchestrationResults(plan, results));
483
616
  const failed = results.filter(({ status }) => status !== "passed");
484
617
  return failed.length
@@ -601,7 +734,7 @@ export async function runCommand(args, root, fs, services = {}) {
601
734
  }
602
735
  if (command === "telemetry") {
603
736
  if (!fs.exists(join(aiw, "manifest.yml")))
604
- return { exitCode: 1, error: "Run `aiw install` first." };
737
+ return installationRequired();
605
738
  const usage = "Usage: aiw telemetry <status|show|enable|disable> [--verbose] [--commands=include|exclude] [--outcomes=include|exclude]";
606
739
  const action = args[1];
607
740
  if (!action)
@@ -714,7 +847,7 @@ export async function runCommand(args, root, fs, services = {}) {
714
847
  }
715
848
  if (command === "context-bench") {
716
849
  if (!fs.exists(join(aiw, "manifest.yml")))
717
- return { exitCode: 1, error: "Run `aiw install` first." };
850
+ return installationRequired();
718
851
  const saveCount = args.filter((arg) => arg === "--save").length;
719
852
  if (saveCount > 1)
720
853
  return { exitCode: 1, error: "Usage: aiw context-bench [--baseline=path] [--save]" };
@@ -759,7 +892,7 @@ export async function runCommand(args, root, fs, services = {}) {
759
892
  }
760
893
  if (command === "parity") {
761
894
  if (!fs.exists(join(aiw, "manifest.yml")))
762
- return { exitCode: 1, error: "Run `aiw install` first." };
895
+ return installationRequired();
763
896
  const parsed = parseNamedOptions(args.slice(1), ["tasks"]);
764
897
  if (!parsed)
765
898
  return { exitCode: 1, error: "Usage: aiw parity [--tasks=directory]" };
@@ -813,7 +946,7 @@ export async function runCommand(args, root, fs, services = {}) {
813
946
  }
814
947
  if (command === "scan") {
815
948
  if (!fs.exists(join(aiw, "manifest.yml")))
816
- return { exitCode: 1, error: "Run `aiw install` first." };
949
+ return installationRequired();
817
950
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
818
951
  let interpreter = services.interpreter;
819
952
  let interpreterModel;
@@ -1093,7 +1226,7 @@ export async function runCommand(args, root, fs, services = {}) {
1093
1226
  return { exitCode: 1, error: `Unsupported target: ${target}` };
1094
1227
  const manifestPath = join(aiw, "manifest.yml");
1095
1228
  if (!fs.exists(manifestPath))
1096
- return { exitCode: 1, error: "Run `aiw install` first." };
1229
+ return installationRequired();
1097
1230
  const dryRun = args.includes("--dry-run");
1098
1231
  const targetPath = join(root, renderAiInitPath(target));
1099
1232
  const neutralAiInit = join(aiw, "resources/skills/ai-init.md");
@@ -1420,7 +1553,7 @@ export async function runCommand(args, root, fs, services = {}) {
1420
1553
  }
1421
1554
  if (command === "confirm") {
1422
1555
  if (!fs.exists(join(aiw, "manifest.yml")))
1423
- return { exitCode: 1, error: "Run `aiw install` first." };
1556
+ return installationRequired();
1424
1557
  const overrides = [];
1425
1558
  const accept = args.indexOf("--accept");
1426
1559
  if (accept >= 0 && args[accept + 1])
@@ -1444,7 +1577,7 @@ export async function runCommand(args, root, fs, services = {}) {
1444
1577
  }
1445
1578
  if (command === "recommend") {
1446
1579
  if (!fs.exists(join(aiw, "manifest.yml")))
1447
- return { exitCode: 1, error: "Run `aiw install` first." };
1580
+ return installationRequired();
1448
1581
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
1449
1582
  const recommendations = services.recommender?.recommend(profile) ?? recommendCapabilities(profile);
1450
1583
  let ordered = recommendations;
@@ -1512,7 +1645,7 @@ export async function runCommand(args, root, fs, services = {}) {
1512
1645
  }
1513
1646
  if (command === "skills") {
1514
1647
  if (!fs.exists(join(aiw, "manifest.yml")))
1515
- return { exitCode: 1, error: "Run `aiw install` first." };
1648
+ return installationRequired();
1516
1649
  const action = args[1];
1517
1650
  const executor = services.externalSkills ?? nodeCommandExecutor;
1518
1651
  const approvedPermissions = parseApprovedPermissions(args);
@@ -1576,7 +1709,7 @@ export async function runCommand(args, root, fs, services = {}) {
1576
1709
  }
1577
1710
  if (command === "generate" || command === "sync") {
1578
1711
  if (!fs.exists(join(aiw, "manifest.yml")))
1579
- return { exitCode: 1, error: "Run `aiw install` first." };
1712
+ return installationRequired();
1580
1713
  const profilePath = join(aiw, "profile.yml");
1581
1714
  const persistedProfile = fs.exists(profilePath) ? fs.read(profilePath) : "";
1582
1715
  const profile = /^status:\s*scanned$/m.test(persistedProfile)
@@ -1805,7 +1938,7 @@ export async function runCommand(args, root, fs, services = {}) {
1805
1938
  }
1806
1939
  if (command === "brainstorm") {
1807
1940
  if (!fs.exists(join(aiw, "manifest.yml")))
1808
- return { exitCode: 1, error: "Run `aiw install` first." };
1941
+ return installationRequired();
1809
1942
  assertStageReady(aiw, fs, "brainstorming");
1810
1943
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Brainstorm";
1811
1944
  const path = join(aiw, "generated/specs/brainstorm.md");
@@ -1814,7 +1947,7 @@ export async function runCommand(args, root, fs, services = {}) {
1814
1947
  }
1815
1948
  if (command === "spec") {
1816
1949
  if (!fs.exists(join(aiw, "manifest.yml")))
1817
- return { exitCode: 1, error: "Run `aiw install` first." };
1950
+ return installationRequired();
1818
1951
  assertStageReady(aiw, fs, "specification");
1819
1952
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Specification";
1820
1953
  const ticket = args.find((arg) => arg.startsWith("--ticket="))?.slice(9);
@@ -1826,7 +1959,7 @@ export async function runCommand(args, root, fs, services = {}) {
1826
1959
  }
1827
1960
  if (command === "adr") {
1828
1961
  if (!fs.exists(join(aiw, "manifest.yml")))
1829
- return { exitCode: 1, error: "Run `aiw install` first." };
1962
+ return installationRequired();
1830
1963
  assertStageReady(aiw, fs, "technical-design");
1831
1964
  const id = args.find((arg) => arg.startsWith("--id="))?.slice(5) || "001";
1832
1965
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Decision";
@@ -1836,26 +1969,27 @@ export async function runCommand(args, root, fs, services = {}) {
1836
1969
  }
1837
1970
  if (command === "plan") {
1838
1971
  if (!fs.exists(join(aiw, "manifest.yml")))
1839
- return { exitCode: 1, error: "Run `aiw install` first." };
1972
+ return installationRequired();
1840
1973
  assertStageReady(aiw, fs, "plan");
1841
1974
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Implementation Plan";
1842
1975
  const path = join(aiw, "generated/plans/implementation-plan.md");
1843
- 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`);
1844
1977
  return { exitCode: 0, output: `Implementation plan created: ${path}` };
1845
1978
  }
1846
1979
  if (command === "verify") {
1847
1980
  if (!fs.exists(join(aiw, "manifest.yml")))
1848
- return { exitCode: 1, error: "Run `aiw install` first." };
1981
+ return installationRequired();
1849
1982
  assertStageReady(aiw, fs, "verification");
1850
1983
  const specPath = join(aiw, "generated/specs/specification.md");
1851
1984
  const planPath = join(aiw, "generated/plans/implementation-plan.md");
1852
1985
  const issues = [];
1986
+ let regressionNotice;
1853
1987
  let specification = "";
1854
1988
  if (!fs.exists(specPath))
1855
1989
  issues.push("Specification artifact is missing.");
1856
1990
  else {
1857
1991
  specification = fs.read(specPath);
1858
- if (!specification.includes("Acceptance criteria"))
1992
+ if (!specification.toLowerCase().includes("acceptance criteria"))
1859
1993
  issues.push("Requirements have no acceptance criteria.");
1860
1994
  }
1861
1995
  if (!fs.exists(planPath))
@@ -1868,17 +2002,24 @@ export async function runCommand(args, root, fs, services = {}) {
1868
2002
  const uncovered = requirements.filter((requirement) => !plan.includes(requirement));
1869
2003
  if (uncovered.length > 0)
1870
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;
1871
2011
  }
1872
2012
  return issues.length
1873
2013
  ? { exitCode: 1, error: `Verification failed: ${issues.join(" ")}` }
1874
2014
  : {
1875
2015
  exitCode: 0,
1876
- 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}` : ""),
1877
2018
  };
1878
2019
  }
1879
2020
  if (command === "trace") {
1880
2021
  if (!fs.exists(join(aiw, "manifest.yml")))
1881
- return { exitCode: 1, error: "Run `aiw install` first." };
2022
+ return installationRequired();
1882
2023
  assertStageReady(aiw, fs, "traceability");
1883
2024
  const specPath = join(aiw, "generated/specs/specification.md");
1884
2025
  const planPath = join(aiw, "generated/plans/implementation-plan.md");
@@ -1907,6 +2048,144 @@ export async function runCommand(args, root, fs, services = {}) {
1907
2048
  fs.write(path, serialized);
1908
2049
  return { exitCode: 0, output: `Traceability graph created: ${path}` };
1909
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
+ }
1910
2189
  if (command === "risk") {
1911
2190
  if (args[1] !== "assess")
1912
2191
  return { exitCode: 1, error: "Usage: aiw risk assess [--base-ref=ref] [--json]" };
@@ -1914,7 +2193,7 @@ export async function runCommand(args, root, fs, services = {}) {
1914
2193
  }
1915
2194
  if (command === "gate") {
1916
2195
  if (!fs.exists(join(aiw, "manifest.yml")))
1917
- return { exitCode: 1, error: "Run `aiw install` first." };
2196
+ return installationRequired();
1918
2197
  const stage = args[1];
1919
2198
  if (!stage || !APPROVAL_STAGES.includes(stage))
1920
2199
  return {
@@ -1941,6 +2220,17 @@ export async function runCommand(args, root, fs, services = {}) {
1941
2220
  detail: `Quality gate blocked: ${issues.join(" ")}`,
1942
2221
  }));
1943
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
+ }
1944
2234
  const { ledger } = readActiveWorkflow(aiw, fs);
1945
2235
  const updated = beginStageApproval(ledger, workflowStage, {
1946
2236
  artifactHash: stageFingerprint(fs.read(artifactPath)),
@@ -1969,7 +2259,7 @@ export async function runCommand(args, root, fs, services = {}) {
1969
2259
  }
1970
2260
  if (command === "resolve") {
1971
2261
  if (!fs.exists(join(aiw, "manifest.yml")))
1972
- return { exitCode: 1, error: "Run `aiw install` first." };
2262
+ return installationRequired();
1973
2263
  const packageArg = args.find((arg) => arg.startsWith("--package="));
1974
2264
  const sourceArg = args.find((arg) => arg.startsWith("--source="));
1975
2265
  if (!packageArg && !sourceArg)
@@ -2031,7 +2321,7 @@ export async function runCommand(args, root, fs, services = {}) {
2031
2321
  }
2032
2322
  if (command === "organization-policy") {
2033
2323
  if (!fs.exists(join(aiw, "manifest.yml")))
2034
- return { exitCode: 1, error: "Run `aiw install` first." };
2324
+ return installationRequired();
2035
2325
  const fileArg = args.find((arg) => arg.startsWith("--file="));
2036
2326
  if (!fileArg)
2037
2327
  return { exitCode: 1, error: "Usage: aiw organization-policy --file=path [--replace]" };
@@ -2047,7 +2337,7 @@ export async function runCommand(args, root, fs, services = {}) {
2047
2337
  }
2048
2338
  if (command === "policy-check") {
2049
2339
  if (!fs.exists(join(aiw, "manifest.yml")))
2050
- return { exitCode: 1, error: "Run `aiw install` first." };
2340
+ return installationRequired();
2051
2341
  const policyPath = join(aiw, "organization.yml");
2052
2342
  if (!fs.exists(policyPath))
2053
2343
  return { exitCode: 1, error: "Organization policy is required for the CI policy gate." };
@@ -2085,7 +2375,7 @@ export async function runCommand(args, root, fs, services = {}) {
2085
2375
  }
2086
2376
  if (command === "preset") {
2087
2377
  if (!fs.exists(join(aiw, "manifest.yml")))
2088
- return { exitCode: 1, error: "Run `aiw install` first." };
2378
+ return installationRequired();
2089
2379
  const fileArg = args.find((arg) => arg.startsWith("--file="));
2090
2380
  if (!fileArg)
2091
2381
  return { exitCode: 1, error: "Usage: aiw preset --file=path [--replace]" };
@@ -2101,7 +2391,7 @@ export async function runCommand(args, root, fs, services = {}) {
2101
2391
  }
2102
2392
  if (command === "update") {
2103
2393
  if (!fs.exists(join(aiw, "manifest.yml")))
2104
- return { exitCode: 1, error: "Run `aiw install` first." };
2394
+ return installationRequired();
2105
2395
  const packageArg = args.find((arg) => arg.startsWith("--package="));
2106
2396
  if (!packageArg)
2107
2397
  return { exitCode: 1, error: "Usage: aiw update --package=path --target=target" };
@@ -2132,7 +2422,7 @@ export async function runCommand(args, root, fs, services = {}) {
2132
2422
  if (command === "registry") {
2133
2423
  if (args[1] === "configure") {
2134
2424
  if (!fs.exists(join(aiw, "manifest.yml")))
2135
- return { exitCode: 1, error: "Run `aiw install` first." };
2425
+ return installationRequired();
2136
2426
  const fileArg = args.find((arg) => arg.startsWith("--file="));
2137
2427
  if (!fileArg)
2138
2428
  return {
@@ -2218,9 +2508,39 @@ export async function runCommand(args, root, fs, services = {}) {
2218
2508
  };
2219
2509
  }
2220
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
+ }
2221
2538
  return { exitCode: 0, output };
2222
2539
  }
2223
- 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
+ };
2224
2544
  }
2225
2545
  if (command === "context-summary") {
2226
2546
  const summaryArg = args.find((arg) => arg.startsWith("--summary="))?.slice(10);
@@ -2238,7 +2558,7 @@ export async function runCommand(args, root, fs, services = {}) {
2238
2558
  }
2239
2559
  if (command === "routing") {
2240
2560
  if (!fs.exists(join(aiw, "manifest.yml")))
2241
- return { exitCode: 1, error: "Run `aiw install` first." };
2561
+ return installationRequired();
2242
2562
  const usage = "Usage: aiw routing init | show | resolve --category=name";
2243
2563
  const action = args[1];
2244
2564
  const routingPath = join(aiw, ROUTING_TABLE_PATH);
@@ -2294,7 +2614,7 @@ export async function runCommand(args, root, fs, services = {}) {
2294
2614
  }
2295
2615
  if (command === "task-usage") {
2296
2616
  if (!fs.exists(join(aiw, "manifest.yml")))
2297
- return { exitCode: 1, error: "Run `aiw install` first." };
2617
+ return installationRequired();
2298
2618
  const parsed = parseNamedOptions(args.slice(1), ["workflow"]);
2299
2619
  if (!parsed)
2300
2620
  return { exitCode: 1, error: "Usage: aiw task-usage [--workflow=id-or-ticket]" };
@@ -2323,7 +2643,7 @@ export async function runCommand(args, root, fs, services = {}) {
2323
2643
  }
2324
2644
  if (command === "cost-report") {
2325
2645
  if (!fs.exists(join(aiw, "manifest.yml")))
2326
- return { exitCode: 1, error: "Run `aiw install` first." };
2646
+ return installationRequired();
2327
2647
  const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
2328
2648
  if (!fs.exists(sidecarPath))
2329
2649
  return {
@@ -2345,7 +2665,7 @@ export async function runCommand(args, root, fs, services = {}) {
2345
2665
  }
2346
2666
  if (command === "heartbeat") {
2347
2667
  if (!fs.exists(join(aiw, "manifest.yml")))
2348
- return { exitCode: 1, error: "Run `aiw install` first." };
2668
+ return installationRequired();
2349
2669
  const parsed = parseNamedOptions(args.slice(1), ["session", "warn", "ttl"]);
2350
2670
  if (!parsed)
2351
2671
  return {
@@ -2395,7 +2715,7 @@ export async function runCommand(args, root, fs, services = {}) {
2395
2715
  }
2396
2716
  if (command === "hooks") {
2397
2717
  if (!fs.exists(join(aiw, "manifest.yml")))
2398
- return { exitCode: 1, error: "Run `aiw install` first." };
2718
+ return installationRequired();
2399
2719
  const action = args[1];
2400
2720
  if (action !== "install")
2401
2721
  return {
@@ -2439,7 +2759,7 @@ export async function runCommand(args, root, fs, services = {}) {
2439
2759
  }
2440
2760
  if (command === "usage-sidecar") {
2441
2761
  if (!fs.exists(join(aiw, "manifest.yml")))
2442
- return { exitCode: 1, error: "Run `aiw install` first." };
2762
+ return installationRequired();
2443
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";
2444
2764
  const action = args[1];
2445
2765
  const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
@@ -2648,7 +2968,7 @@ export async function runCommand(args, root, fs, services = {}) {
2648
2968
  }
2649
2969
  if (command === "self-validate") {
2650
2970
  if (!fs.exists(join(aiw, "manifest.yml")))
2651
- return { exitCode: 1, error: "Run `aiw install` first." };
2971
+ return installationRequired();
2652
2972
  const ticket = args.find((arg) => arg.startsWith("--ticket="))?.slice(9);
2653
2973
  if (!ticket)
2654
2974
  return { exitCode: 1, error: "Usage: aiw self-validate --ticket=TYPE-000" };
@@ -2684,7 +3004,7 @@ export async function runCommand(args, root, fs, services = {}) {
2684
3004
  }
2685
3005
  return {
2686
3006
  exitCode: 0,
2687
- 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]",
2688
3008
  };
2689
3009
  }
2690
3010
  catch (error) {
@@ -2702,10 +3022,14 @@ function readWorkflowState(aiw, fs, requireActive = true) {
2702
3022
  const session = parseWorkflowSession(fs.read(sessionPath));
2703
3023
  let ledger = parseApprovalLedger(fs.read(ledgerPath));
2704
3024
  if (session.workflowId !== ledger.workflowId)
2705
- 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
+ }));
2706
3028
  if (requireActive && session.status !== "active")
2707
- throw new Error(`Workflow ${session.workflowId} is complete; start a new workflow first.`);
2708
- const changed = APPROVAL_STAGES.find((stage) => {
3029
+ throw new BlockingError(describeBlockingReason("workflow_complete", {
3030
+ detail: `Workflow ${session.workflowId} is complete; start a new workflow first.`,
3031
+ }));
3032
+ const changed = processStages(ledger).find((stage) => {
2709
3033
  const record = ledger.stages[stage];
2710
3034
  if (!record?.artifactHash || !["pending-human", "approved"].includes(record.status))
2711
3035
  return false;
@@ -2733,6 +3057,85 @@ function workflowNextAction(ledger) {
2733
3057
  return `Next action: revise ${next}, rerun its gate, and request human approval.`;
2734
3058
  return `Next action: prepare ${next} evidence.`;
2735
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
+ }
2736
3139
  function parseLightEvidence(content) {
2737
3140
  const value = parseYaml(content);
2738
3141
  if (!value || typeof value !== "object" || value.schema !== 1)
@@ -2753,6 +3156,11 @@ function parseLightEvidence(content) {
2753
3156
  }
2754
3157
  function assertStageReady(aiw, fs, stage) {
2755
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
+ }));
2756
3164
  if (effectiveLane(session) === "light")
2757
3165
  throw new BlockingError(describeBlockingReason("lane_escalation_required", {
2758
3166
  stage,
@@ -2796,6 +3204,8 @@ function approvalArtifactPath(aiw, stage) {
2796
3204
  verification: "generated/reports/verification-report.md",
2797
3205
  review: "generated/reports/code-review.md",
2798
3206
  traceability: "generated/artifacts/traceability.yml",
3207
+ diagnosis: "generated/reports/diagnosis.md",
3208
+ assessment: "generated/reports/assessment.md",
2799
3209
  };
2800
3210
  return resolve(aiw, paths[stage]);
2801
3211
  }