@multileaf/ai-workflow 0.9.0 → 0.11.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 (38) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +12 -1
  3. package/dist/blocking-reasons.d.ts +67 -0
  4. package/dist/blocking-reasons.js +199 -0
  5. package/dist/command-evidence.d.ts +32 -0
  6. package/dist/command-evidence.js +61 -0
  7. package/dist/human-approval.d.ts +19 -2
  8. package/dist/human-approval.js +82 -9
  9. package/dist/recommendations.js +16 -1
  10. package/dist/risk-assess.d.ts +50 -0
  11. package/dist/risk-assess.js +273 -0
  12. package/dist/workflow.js +280 -30
  13. package/docs/README.md +2 -0
  14. package/docs/architecture.md +50 -0
  15. package/docs/backlog.md +13 -0
  16. package/docs/continuation-inventory.md +24 -0
  17. package/docs/decision-model-evaluation.md +4 -0
  18. package/docs/opportunities/OPP-001-proportional-ceremony-light-lane.md +62 -0
  19. package/docs/opportunities/OPP-002-risk-assess-command.md +70 -0
  20. package/docs/opportunities/OPP-003-adversarial-review-package.md +63 -0
  21. package/docs/opportunities/OPP-004-exact-continuation-commands.md +59 -0
  22. package/docs/opportunities/OPP-005-friction-benchmark.md +62 -0
  23. package/docs/opportunities/OPP-006-emission-usability-test-policy.md +57 -0
  24. package/docs/opportunities/OPP-007-decision-findings-triage-scope-watch.md +54 -0
  25. package/docs/opportunities/OPP-008-embedding-covering-tests-judge-context.md +52 -0
  26. package/docs/opportunities/README.md +33 -0
  27. package/docs/product-requirements.md +3 -0
  28. package/docs/product-vision.md +1 -0
  29. package/docs/quality.md +10 -0
  30. package/docs/release-process.md +9 -1
  31. package/docs/semantic-mapping.md +4 -0
  32. package/package.json +1 -1
  33. package/resources/package.yaml +3 -0
  34. package/resources/skills/code-review.md +2 -0
  35. package/resources/skills/review-judge-a.md +24 -0
  36. package/resources/skills/review-judge-b.md +24 -0
  37. package/resources/skills/review-refuter.md +28 -0
  38. package/resources/skills/ticket.md +2 -0
package/dist/workflow.js CHANGED
@@ -50,7 +50,8 @@ 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, } from "./human-approval.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";
54
+ import { parse as parseYaml, stringify } from "yaml";
54
55
  import { measureContextQuality, serializeContextQuality } from "./context-quality.js";
55
56
  import { collectContextBench, compareContextBench, parseContextBench, serializeContextBench, } from "./context-benchmark.js";
56
57
  import { materializeParityTask, parseParityTask, runParitySuite, serializeParitySuite, TASK_ASSEMBLER, } from "./task-parity.js";
@@ -64,6 +65,8 @@ import { parseTraceabilityGraph } from "./traceability.js";
64
65
  import { serializeAdapterCapabilities } from "./adapter-contract.js";
65
66
  import { buildTraceabilityGraph, serializeTraceabilityGraph } from "./traceability.js";
66
67
  import { evaluateQualityGate } from "./quality-gates.js";
68
+ import { BlockingError, blockingResultToJson, describeBlockingReason } from "./blocking-reasons.js";
69
+ import { runRiskAssess } from "./risk-assess.js";
67
70
  import { parseMigrationCheckpoint, planNeutralResourceMigration, readResourceBytes, serializeMigrationPreview, serializeMigrationCheckpoint, validateNeutralResourceDocument, writeResourceBytes, } from "./migration.js";
68
71
  import { parsePrivateRegistries, parseTeamPreset, serializePrivateRegistries, serializeTeamPreset, fetchPrivateRegistryClient, } from "./team-configuration.js";
69
72
  import { enforcePolicyGate, gitPolicyTracking } from "./policy-gate.js";
@@ -76,10 +79,9 @@ function beginWorkflowSession(fs, aiw, options) {
76
79
  if (fs.exists(sessionPath)) {
77
80
  const previous = parseWorkflowSession(fs.read(sessionPath));
78
81
  if (previous.status === "active")
79
- return {
80
- exitCode: 1,
81
- error: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
82
- };
82
+ throw new BlockingError(describeBlockingReason("workflow_still_active", {
83
+ detail: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
84
+ }));
83
85
  if (!fs.exists(ledgerPath))
84
86
  return { exitCode: 1, error: "Completed workflow is missing its approval ledger." };
85
87
  const archive = join(aiw, `checkpoints/workflow-${previous.workflowId}-approvals.yml`);
@@ -110,8 +112,15 @@ function beginWorkflowSession(fs, aiw, options) {
110
112
  updatedAt: now,
111
113
  ...(options.ticket ? { ticket: options.ticket } : {}),
112
114
  ...(options.title ? { title: options.title } : {}),
115
+ ...(options.lane === "light" && options.laneReason
116
+ ? { lane: "light", laneReason: options.laneReason }
117
+ : {}),
113
118
  };
114
- const ledger = createApprovalLedger(workflowId);
119
+ const ledger = createApprovalLedger(workflowId, {
120
+ ...(options.lane === "light" && options.laneReason
121
+ ? { lane: "light", laneReason: options.laneReason }
122
+ : {}),
123
+ });
115
124
  fs.write(ledgerPath, serializeApprovalLedger(ledger));
116
125
  fs.write(sessionPath, serializeWorkflowSession(session));
117
126
  return { exitCode: 0, output: `Workflow started: ${workflowId}.` };
@@ -124,14 +133,35 @@ export async function runCommand(args, root, fs, services = {}) {
124
133
  if (command === "task") {
125
134
  if (!fs.exists(join(aiw, "manifest.yml")))
126
135
  return { exitCode: 1, error: "Run `aiw install` first." };
127
- const usage = "Usage: aiw task start --ticket=TEAM-123 [--title=text] [--id=workflow-id] | aiw task status";
136
+ 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";
128
137
  const action = args[1];
129
138
  const sessionPath = join(aiw, "workflow.yml");
130
139
  if (action === "start") {
131
- const options = parseNamedOptions(args.slice(2), ["ticket", "title", "id"]);
140
+ const options = parseNamedOptions(args.slice(2), [
141
+ "ticket",
142
+ "title",
143
+ "id",
144
+ "lane",
145
+ "lane-reason",
146
+ ]);
132
147
  if (!options?.ticket)
133
148
  return { exitCode: 1, error: usage };
134
149
  validateTaskTicket(options.ticket);
150
+ if (options.lane !== undefined && options.lane !== "light")
151
+ return { exitCode: 1, error: "Only --lane=light exists; omit it for the full lane." };
152
+ const laneReason = options["lane-reason"];
153
+ if (options.lane === "light") {
154
+ if (!laneReason)
155
+ return {
156
+ exitCode: 1,
157
+ error: "--lane=light requires --lane-reason: the resume-test call, recorded for audit.",
158
+ };
159
+ if (laneReason.length > 500 || /[\r\n]/.test(laneReason))
160
+ return {
161
+ exitCode: 1,
162
+ error: "--lane-reason must be a single line of at most 500 bytes.",
163
+ };
164
+ }
135
165
  if (options.id !== undefined) {
136
166
  try {
137
167
  assertWorkflowId(options.id);
@@ -140,20 +170,104 @@ export async function runCommand(args, root, fs, services = {}) {
140
170
  return { exitCode: 1, error: "Workflow ID must be a UUID." };
141
171
  }
142
172
  }
173
+ const lane = options.lane === "light" ? "light" : undefined;
143
174
  const result = beginWorkflowSession(fs, aiw, {
144
175
  ...(options.id ? { id: options.id } : {}),
145
176
  ticket: options.ticket,
146
177
  ...(options.title ? { title: options.title } : {}),
178
+ ...(lane ? { lane, laneReason: laneReason } : {}),
147
179
  });
148
180
  if (result.exitCode !== 0)
149
181
  return result;
150
182
  const title = options.title ? ` titled "${options.title}"` : "";
183
+ if (lane)
184
+ return {
185
+ exitCode: 0,
186
+ output: `Task started for ticket ${options.ticket}${title} (light lane): ${result.output}\n` +
187
+ "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
+ `Rationale recorded: ${laneReason}\n` +
189
+ '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=...`.',
190
+ };
151
191
  return {
152
192
  exitCode: 0,
153
193
  output: `Task started for ticket ${options.ticket}${title}: ${result.output}\n` +
154
194
  `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=...\`.`,
155
195
  };
156
196
  }
197
+ if (action === "escalate") {
198
+ const options = parseNamedOptions(args.slice(2), ["reason"]);
199
+ if (!options?.reason)
200
+ return { exitCode: 1, error: "Usage: aiw task escalate --reason=why-scope-grew" };
201
+ if (!fs.exists(sessionPath))
202
+ return { exitCode: 1, error: "No SDD workflow is active. Start one first." };
203
+ const { session } = readWorkflowState(aiw, fs);
204
+ if (session.lane !== "light")
205
+ return {
206
+ exitCode: 1,
207
+ error: "Only light-lane workflows escalate; this one already runs the full lane.",
208
+ };
209
+ if (session.laneEscalatedAt)
210
+ return {
211
+ exitCode: 1,
212
+ error: `Workflow ${session.workflowId} already escalated to the full lane.`,
213
+ };
214
+ const now = new Date().toISOString();
215
+ fs.write(sessionPath, serializeWorkflowSession({
216
+ ...session,
217
+ laneEscalatedAt: now,
218
+ laneEscalationReason: options.reason,
219
+ updatedAt: now,
220
+ }));
221
+ const evidencePath = join(aiw, `checkpoints/task-light-${session.workflowId}.yml`);
222
+ return {
223
+ exitCode: 0,
224
+ output: `Workflow ${session.workflowId} escalated to the full lane.\n` +
225
+ (fs.exists(evidencePath)
226
+ ? `Light-lane evidence preserved and linked: ${evidencePath}\n`
227
+ : "No light-lane evidence had been recorded yet.\n") +
228
+ "Stages now run the full approval sequence from brainstorming.",
229
+ };
230
+ }
231
+ if (action === "evidence") {
232
+ const options = parseNamedOptions(args.slice(2), ["validation"]);
233
+ if (!options?.validation)
234
+ return {
235
+ exitCode: 1,
236
+ error: 'Usage: aiw task evidence --validation="<command and its observed result>"',
237
+ };
238
+ if (!fs.exists(sessionPath))
239
+ return { exitCode: 1, error: "No SDD workflow is active. Start one first." };
240
+ const { session } = readWorkflowState(aiw, fs);
241
+ if (effectiveLane(session) !== "light")
242
+ return {
243
+ exitCode: 1,
244
+ error: "Evidence entries are a light-lane command; full-lane workflows record stage artifacts and gates.",
245
+ };
246
+ const evidencePath = join(aiw, `checkpoints/task-light-${session.workflowId}.yml`);
247
+ const existing = fs.exists(evidencePath)
248
+ ? parseLightEvidence(fs.read(evidencePath))
249
+ : {
250
+ schema: 1,
251
+ workflowId: session.workflowId,
252
+ ...(session.ticket ? { ticket: session.ticket } : {}),
253
+ lane: "light",
254
+ ...(session.laneReason ? { laneReason: session.laneReason } : {}),
255
+ entries: [],
256
+ };
257
+ const updated = {
258
+ ...existing,
259
+ entries: [
260
+ ...existing.entries,
261
+ { note: options.validation, recordedAt: new Date().toISOString() },
262
+ ],
263
+ };
264
+ fs.mkdir(join(aiw, "checkpoints"));
265
+ fs.write(evidencePath, stringify({ ...updated }, { lineWidth: 0 }));
266
+ return {
267
+ exitCode: 0,
268
+ output: `Validation evidence recorded (${updated.entries.length} entr${updated.entries.length === 1 ? "y" : "ies"}): ${options.validation}`,
269
+ };
270
+ }
157
271
  if (action === "status") {
158
272
  if (!fs.exists(sessionPath))
159
273
  return {
@@ -161,14 +275,24 @@ export async function runCommand(args, root, fs, services = {}) {
161
275
  output: "No SDD workflow is active. Start one with `aiw task start --ticket=TEAM-123`.",
162
276
  };
163
277
  const { session, ledger } = readWorkflowState(aiw, fs, false);
278
+ const lane = session.lane === "light"
279
+ ? [
280
+ `Lane: light — ${session.laneEscalatedAt ? `escalated to the full lane (${session.laneEscalationReason})` : session.laneReason}`,
281
+ ]
282
+ : [];
164
283
  const lines = [
165
284
  ...(session.ticket
166
285
  ? [`Ticket: ${session.ticket}${session.title ? ` — ${session.title}` : ""}`]
167
286
  : []),
287
+ ...lane,
168
288
  `Workflow: ${session.workflowId} (${session.status})`,
169
289
  "Stages:",
170
290
  ...APPROVAL_STAGES.map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`),
171
291
  ];
292
+ if (session.status === "active" && effectiveLane(session) === "light") {
293
+ lines.push('Next: record validation with `aiw task evidence --validation="<command and result>"`, then `aiw workflow complete`. If scope grows: `aiw task escalate --reason=...`.');
294
+ return { exitCode: 0, output: lines.join("\n") };
295
+ }
172
296
  const next = APPROVAL_STAGES.find((stage) => (ledger.stages[stage]?.status ?? "not-started") === "not-started");
173
297
  if (session.status === "active" && next)
174
298
  lines.push(`Next: ${next === "brainstorming" && session.ticket ? `decide brainstorm or record an accepted skip (ticket ${session.ticket} already provides the requirement)` : `advance ${next} (gate, approval)`}.`);
@@ -182,7 +306,7 @@ export async function runCommand(args, root, fs, services = {}) {
182
306
  const action = args[1];
183
307
  const sessionPath = join(aiw, "workflow.yml");
184
308
  if (action === "start") {
185
- const options = parseNamedOptions(args.slice(2), ["id"]);
309
+ const options = parseNamedOptions(args.slice(2).filter((argument) => argument !== "--json"), ["id"]);
186
310
  if (!options)
187
311
  return { exitCode: 1, error: "Usage: aiw workflow start [--id=workflow-id]" };
188
312
  return beginWorkflowSession(fs, aiw, { id: options.id });
@@ -197,8 +321,67 @@ export async function runCommand(args, root, fs, services = {}) {
197
321
  output: `Workflow ${session.workflowId} (${session.status})\n${stages.join("\n")}\n${workflowNextAction(ledger)}`,
198
322
  };
199
323
  }
324
+ if (action === "abandon") {
325
+ const options = parseNamedOptions(args.slice(2), ["reason"]);
326
+ if (!options?.reason)
327
+ return { exitCode: 1, error: "Usage: aiw workflow abandon --reason=why-obsolete" };
328
+ if (options.reason.length > 500 || /[\r\n]/.test(options.reason))
329
+ return {
330
+ exitCode: 1,
331
+ error: "--reason must be a single line of at most 500 bytes.",
332
+ };
333
+ if (!fs.exists(sessionPath))
334
+ return {
335
+ exitCode: 1,
336
+ 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.",
337
+ };
338
+ const { session } = readActiveWorkflow(aiw, fs);
339
+ const ledgerPath = join(aiw, "approvals.yml");
340
+ const sessionArchive = join(aiw, "checkpoints", `workflow-${session.workflowId}-session.yml`);
341
+ const ledgerArchive = join(aiw, "checkpoints", `workflow-${session.workflowId}-approvals.yml`);
342
+ fs.mkdir(join(aiw, "checkpoints"));
343
+ const now = new Date().toISOString();
344
+ const archivedSession = {
345
+ ...session,
346
+ status: "abandoned",
347
+ abandonedReason: options.reason,
348
+ updatedAt: now,
349
+ };
350
+ fs.write(sessionArchive, serializeWorkflowSession(archivedSession));
351
+ if (fs.exists(ledgerPath))
352
+ fs.write(ledgerArchive, fs.read(ledgerPath));
353
+ fs.remove?.(sessionPath);
354
+ if (fs.exists(ledgerPath))
355
+ fs.remove?.(ledgerPath);
356
+ return {
357
+ exitCode: 0,
358
+ output: `Workflow ${session.workflowId} abandoned (${options.reason}).\n` +
359
+ `Session archived: ${sessionArchive}\n` +
360
+ (fs.exists(ledgerArchive) ? `Approval ledger archived: ${ledgerArchive}\n` : "") +
361
+ "Start a new one when ready.",
362
+ };
363
+ }
200
364
  if (action === "complete") {
201
365
  const { session, ledger } = readActiveWorkflow(aiw, fs);
366
+ if (effectiveLane(session) === "light") {
367
+ const evidencePath = join(aiw, `checkpoints/task-light-${session.workflowId}.yml`);
368
+ const hasEntries = fs.exists(evidencePath)
369
+ ? parseLightEvidence(fs.read(evidencePath)).entries.length > 0
370
+ : false;
371
+ if (!hasEntries)
372
+ throw new BlockingError(describeBlockingReason("light_evidence_missing", {
373
+ detail: `Light-lane workflow ${session.workflowId} cannot complete without recorded validation evidence.`,
374
+ }));
375
+ fs.write(sessionPath, serializeWorkflowSession({
376
+ ...session,
377
+ status: "complete",
378
+ updatedAt: new Date().toISOString(),
379
+ }));
380
+ return {
381
+ exitCode: 0,
382
+ output: `Workflow completed (light lane): ${session.workflowId}. Evidence: ${evidencePath}`,
383
+ };
384
+ }
202
385
  const unresolved = APPROVAL_STAGES.filter((stage) => ledger.stages[stage]?.status !== "approved" &&
203
386
  ledger.stages[stage]?.status !== "skipped");
204
387
  if (unresolved.length)
@@ -222,10 +405,15 @@ export async function runCommand(args, root, fs, services = {}) {
222
405
  const stage = args[1];
223
406
  if (!stage || !APPROVAL_STAGES.includes(stage))
224
407
  return { exitCode: 1, error: `Usage: aiw ${command} <workflow-stage> [options]` };
225
- const { ledger } = readActiveWorkflow(aiw, fs);
408
+ const rest = args.slice(2).filter((argument) => argument !== "--json");
409
+ const { session, ledger } = readActiveWorkflow(aiw, fs);
410
+ if (effectiveLane(session) === "light")
411
+ throw new BlockingError(describeBlockingReason("lane_escalation_required", {
412
+ detail: `Light-lane workflow ${session.workflowId} does not approve stages; escalate to the full lane to continue.`,
413
+ }));
226
414
  const workflowStage = stage;
227
415
  if (command === "skip") {
228
- const options = parseNamedOptions(args.slice(2), ["reason"]);
416
+ const options = parseNamedOptions(rest, ["reason"]);
229
417
  if (!options?.reason)
230
418
  return {
231
419
  exitCode: 1,
@@ -241,19 +429,28 @@ export async function runCommand(args, root, fs, services = {}) {
241
429
  }
242
430
  const artifact = approvalArtifactPath(aiw, workflowStage);
243
431
  if (!artifact || !fs.exists(artifact))
244
- return { exitCode: 1, error: `Approval evidence is missing for ${workflowStage}.` };
432
+ throw new BlockingError(describeBlockingReason("approval_evidence_missing", {
433
+ stage: workflowStage,
434
+ artifactPath: artifact ?? undefined,
435
+ detail: `Approval evidence is missing for ${workflowStage}.`,
436
+ }));
245
437
  const artifactHash = stageFingerprint(fs.read(artifact));
246
438
  if (command === "approve") {
247
439
  if (ledger.stages[workflowStage]?.artifactHash !== artifactHash) {
248
440
  const invalidated = invalidateStageAndDescendants(ledger, workflowStage, new Date().toISOString());
249
441
  fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(invalidated));
250
442
  const existing = ledger.stages[workflowStage];
251
- return {
252
- exitCode: 1,
253
- error: existing
254
- ? `The ${workflowStage} artifact changed after review; its approval is stale. Rerun the quality gate and request approval again.`
255
- : `No passing quality gate recorded for ${workflowStage}. Run aiw gate ${workflowStage} before approving.`,
256
- };
443
+ if (existing)
444
+ throw new BlockingError(describeBlockingReason("approval_stale", {
445
+ stage: workflowStage,
446
+ artifactPath: artifact,
447
+ detail: `The ${workflowStage} artifact changed after review; its approval is stale. Rerun the quality gate and request approval again.`,
448
+ }));
449
+ throw new BlockingError(describeBlockingReason("approval_gate_missing", {
450
+ stage: workflowStage,
451
+ artifactPath: artifact,
452
+ detail: `No passing quality gate recorded for ${workflowStage}. Run aiw gate ${workflowStage} before approving.`,
453
+ }));
257
454
  }
258
455
  const updated = approveStage(ledger, workflowStage, {
259
456
  artifactHash,
@@ -262,7 +459,7 @@ export async function runCommand(args, root, fs, services = {}) {
262
459
  fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(updated));
263
460
  return { exitCode: 0, output: `Human approval recorded for ${workflowStage}.` };
264
461
  }
265
- const options = parseNamedOptions(args.slice(2), ["reason"]);
462
+ const options = parseNamedOptions(rest, ["reason"]);
266
463
  if (!options?.reason)
267
464
  return { exitCode: 1, error: "Usage: aiw reject <stage> --reason=human-feedback" };
268
465
  const updated = rejectStage(ledger, workflowStage, options.reason, new Date().toISOString());
@@ -1749,6 +1946,11 @@ export async function runCommand(args, root, fs, services = {}) {
1749
1946
  fs.write(path, serialized);
1750
1947
  return { exitCode: 0, output: `Traceability graph created: ${path}` };
1751
1948
  }
1949
+ if (command === "risk") {
1950
+ if (args[1] !== "assess")
1951
+ return { exitCode: 1, error: "Usage: aiw risk assess [--base-ref=ref] [--json]" };
1952
+ return runRiskAssess(args.slice(2), root, fs);
1953
+ }
1752
1954
  if (command === "gate") {
1753
1955
  if (!fs.exists(join(aiw, "manifest.yml")))
1754
1956
  return { exitCode: 1, error: "Run `aiw install` first." };
@@ -1762,15 +1964,21 @@ export async function runCommand(args, root, fs, services = {}) {
1762
1964
  assertStageReady(aiw, fs, workflowStage);
1763
1965
  const artifactPath = approvalArtifactPath(aiw, workflowStage);
1764
1966
  if (!artifactPath || !fs.exists(artifactPath))
1765
- return {
1766
- exitCode: 1,
1767
- error: `Quality gate blocked: required evidence is missing for ${stage}.`,
1768
- };
1967
+ throw new BlockingError(describeBlockingReason("gate_evidence_missing", {
1968
+ stage,
1969
+ artifactPath: artifactPath ?? undefined,
1970
+ detail: `Quality gate blocked: required evidence is missing for ${stage}.`,
1971
+ }));
1769
1972
  const hasQualityGate = stageDefinition(workflowStage).requiresQualityGate;
1770
1973
  if (hasQualityGate) {
1771
1974
  const issues = evaluateQualityGate(stage, fs.read(artifactPath));
1772
1975
  if (issues.length)
1773
- return { exitCode: 1, error: `Quality gate blocked: ${issues.join(" ")}` };
1976
+ throw new BlockingError(describeBlockingReason("gate_incomplete", {
1977
+ stage,
1978
+ artifactPath,
1979
+ gateIssues: issues,
1980
+ detail: `Quality gate blocked: ${issues.join(" ")}`,
1981
+ }));
1774
1982
  }
1775
1983
  const { ledger } = readActiveWorkflow(aiw, fs);
1776
1984
  const updated = beginStageApproval(ledger, workflowStage, {
@@ -2519,6 +2727,9 @@ export async function runCommand(args, root, fs, services = {}) {
2519
2727
  };
2520
2728
  }
2521
2729
  catch (error) {
2730
+ if (error instanceof BlockingError && args.includes("--json")) {
2731
+ return { exitCode: 1, output: JSON.stringify(blockingResultToJson(error.result)) };
2732
+ }
2522
2733
  return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2523
2734
  }
2524
2735
  }
@@ -2532,7 +2743,9 @@ function readWorkflowState(aiw, fs, requireActive = true) {
2532
2743
  if (session.workflowId !== ledger.workflowId)
2533
2744
  throw new Error("Workflow session and approval ledger IDs do not match; run doctor.");
2534
2745
  if (requireActive && session.status !== "active")
2535
- throw new Error(`Workflow ${session.workflowId} is complete; start a new workflow first.`);
2746
+ throw new BlockingError(describeBlockingReason("workflow_complete", {
2747
+ detail: `Workflow ${session.workflowId} is complete; start a new workflow first.`,
2748
+ }));
2536
2749
  const changed = APPROVAL_STAGES.find((stage) => {
2537
2750
  const record = ledger.stages[stage];
2538
2751
  if (!record?.artifactHash || !["pending-human", "approved"].includes(record.status))
@@ -2561,20 +2774,57 @@ function workflowNextAction(ledger) {
2561
2774
  return `Next action: revise ${next}, rerun its gate, and request human approval.`;
2562
2775
  return `Next action: prepare ${next} evidence.`;
2563
2776
  }
2777
+ function parseLightEvidence(content) {
2778
+ const value = parseYaml(content);
2779
+ if (!value || typeof value !== "object" || value.schema !== 1)
2780
+ throw new Error("Light-lane evidence checkpoint must be schema version 1.");
2781
+ const record = value;
2782
+ const entries = Array.isArray(record.entries) ? record.entries : [];
2783
+ return {
2784
+ schema: 1,
2785
+ workflowId: String(record.workflowId ?? ""),
2786
+ ...(record.ticket !== undefined ? { ticket: String(record.ticket) } : {}),
2787
+ lane: "light",
2788
+ ...(record.laneReason !== undefined ? { laneReason: String(record.laneReason) } : {}),
2789
+ entries: entries.map((entry) => ({
2790
+ note: String(entry.note ?? ""),
2791
+ recordedAt: String(entry.recordedAt ?? ""),
2792
+ })),
2793
+ };
2794
+ }
2564
2795
  function assertStageReady(aiw, fs, stage) {
2565
- const { ledger } = readActiveWorkflow(aiw, fs);
2796
+ const { session, ledger } = readActiveWorkflow(aiw, fs);
2797
+ if (effectiveLane(session) === "light")
2798
+ throw new BlockingError(describeBlockingReason("lane_escalation_required", {
2799
+ stage,
2800
+ detail: `Light-lane workflow ${session.workflowId} does not author stage artifacts; escalate to the full lane to continue.`,
2801
+ }));
2566
2802
  const unresolved = getUnresolvedPrerequisites(ledger, stage);
2567
2803
  if (unresolved.length) {
2568
2804
  const pending = unresolved.filter((prerequisite) => ledger.stages[prerequisite]?.status === "pending-human");
2569
2805
  if (pending.length)
2570
- throw new Error(`Cannot enter ${stage}; previous stage(s) are awaiting human approval: ${pending.join(", ")}.`);
2571
- throw new Error(`Cannot enter ${stage}; previous stages require human approval or an accepted optional skip: ${unresolved.join(", ")}.`);
2806
+ throw new BlockingError(describeBlockingReason("stage_prerequisite_pending", {
2807
+ stage,
2808
+ pendingStages: pending,
2809
+ detail: `Cannot enter ${stage}; previous stage(s) are awaiting human approval: ${pending.join(", ")}.`,
2810
+ }));
2811
+ throw new BlockingError(describeBlockingReason("stage_prerequisite_unresolved", {
2812
+ stage,
2813
+ unresolvedStages: unresolved,
2814
+ detail: `Cannot enter ${stage}; previous stages require human approval or an accepted optional skip: ${unresolved.join(", ")}.`,
2815
+ }));
2572
2816
  }
2573
2817
  const current = ledger.stages[stage];
2574
2818
  if (current?.status === "pending-human")
2575
- throw new Error(`${stage} is awaiting human approval. Approve or reject it before continuing.`);
2819
+ throw new BlockingError(describeBlockingReason("stage_awaiting_approval", {
2820
+ stage,
2821
+ detail: `${stage} is awaiting human approval. Approve or reject it before continuing.`,
2822
+ }));
2576
2823
  if (current?.status === "approved")
2577
- throw new Error(`${stage} is already approved. Evidence changes require rerunning its gate.`);
2824
+ throw new BlockingError(describeBlockingReason("stage_already_approved", {
2825
+ stage,
2826
+ detail: `${stage} is already approved. Evidence changes require rerunning its gate.`,
2827
+ }));
2578
2828
  }
2579
2829
  function approvalArtifactPath(aiw, stage) {
2580
2830
  if (stage === "technical-design")
package/docs/README.md CHANGED
@@ -22,6 +22,8 @@ AI Workflow is a portable, spec-driven development system for AI coding agents.
22
22
  - [Plugin authoring](plugin-authoring.md)
23
23
  - [Release process](release-process.md)
24
24
  - [Release backlog](release-backlog.md)
25
+ - [Continuation inventory](continuation-inventory.md)
26
+ - [Workflow proportionality opportunities](opportunities/README.md) (ODD/RDD analysis, ADR-001)
25
27
  - [Backlog](backlog.md)
26
28
 
27
29
  All generated AI Workflow artifacts are written in English unless the user explicitly requests another language.
@@ -45,6 +45,56 @@ Project analysis is hybrid: deterministic detectors establish observable facts,
45
45
 
46
46
  ## Human workflow approvals
47
47
 
48
+ Workflows run in one of two lanes. The **full lane** (default, and the only non-interactive default) is the staged approval sequence below. The **light lane** (`aiw task start --lane=light --lane-reason=...`, human-chosen via the resume test — work is small when it could be resumed from the request plus `git diff`) skips durable spec/plan artifacts: validation evidence is recorded per entry (`aiw task evidence`), completion requires at least one entry, and the lane plus rationale are written into the session and approval ledger for audit. Stage commands in the light lane block with a `lane_escalation_required` reason until `aiw task escalate --reason=...` moves the workflow to the full lane; recorded evidence is preserved and linked. Escalation is one-way; the full lane then runs the complete approval sequence. An obsolete active workflow is archived with `aiw workflow abandon --reason=...`: session and ledger move under `.aiw/checkpoints/` with `status: abandoned`, freeing the active slot.
49
+
50
+ ## Current flow
51
+
52
+ ```mermaid
53
+ flowchart TD
54
+ REQ["Change request / ticket"] --> RA["aiw risk assess<br/>read-only · content-based · deterministic"]
55
+ RA -->|"passive — docs-only"| LANE
56
+ RA -->|"medium — plain code"| LANE
57
+ RA -->|"high — security · contracts · concurrency ·<br/>data · delivery · untested (risk due: yes)"| FS
58
+
59
+ LANE{"Human lane decision<br/>resume test: resumable from<br/>request + git diff?"}
60
+ LANE -->|"light — human chooses"| LS
61
+ LANE -->|"full — default"| FS
62
+
63
+ subgraph LIGHT["Light lane — no durable artifacts"]
64
+ LS["aiw task start --lane=light --lane-reason<br/>rationale into session + ledger"] --> LE["aiw task evidence --validation<br/>compact checkpoint"]
65
+ LE --> LC["aiw workflow complete<br/>requires at least one entry"]
66
+ end
67
+
68
+ subgraph FULL["Full lane — staged SDD, gate + human approve per stage"]
69
+ FS["workflow start"] --> BR["brainstorming"] --> SP["specification"] --> TD["technical-design<br/>optional, skip needs reason"] --> PL["plan"] --> IM["implementation<br/>TDD"] --> VE["verification"] --> RV["code review"] --> TR["traceability"] --> FC["workflow complete"]
70
+ end
71
+
72
+ LS -.->|"stage or approve attempted"| B1["Blocked<br/>reason: lane_escalation_required"]
73
+ B1 --> ESC["aiw task escalate --reason<br/>one-way · evidence preserved"]
74
+ ESC --> FS
75
+
76
+ RV -.->|"adversarial-review skills installed"| ADV["judge A + judge B — blind, independent<br/>refuter: corroborated severe blocks<br/>refuted / inconclusive → advisory<br/>unevidenced severe → advisory"]
77
+
78
+ LC --> AUD["Audit trail<br/>approvals ledger · checkpoints · light evidence"]
79
+ FC --> AUD
80
+
81
+ subgraph CI["CI enforcement layer"]
82
+ E1["OPP-004: every block prints a reason code<br/>plus an exact next_command — continuations<br/>are executed by tests before they ship"]
83
+ E2["OPP-006 ratchet: every aiw command named<br/>by skills and READMEs executes in tests"]
84
+ E3["OPP-005 friction benchmark:<br/>in-band / out-of-band / dead-end<br/>budget: zero dead ends"]
85
+ end
86
+
87
+ B1 -.-> E1
88
+ E1 -.-> E2 -.-> E3
89
+ ```
90
+
91
+ Reading notes:
92
+
93
+ - `aiw risk assess` is **advisory**: it informs the lane decision and flags risk due; it never forces a lane or bypasses approval.
94
+ - The light lane is never agent-chosen: `resources/skills/ticket.md` routes it through the host's blocking question widget with the resume test stated verbatim.
95
+ - Every blocking exit on the diagram prints `[reason: <code>]` and either `Next: run \`aiw ...\`` or an explicit terminal statement — the full mapping lives in [Continuation inventory](continuation-inventory.md).
96
+ - A `high` risk assessment with an active workflow points at continuing the workflow (gate and approve); without one it names `aiw workflow start`.
97
+
48
98
  The SDD approval contract models the ordered stages brainstorming, specification, technical design, plan, implementation, verification, review, and traceability. Brainstorming and technical design are optional; skipping either requires a human-accepted reason. Every other stage must be approved. Automated quality-gate results are stored separately from human decisions and can only move an artifact to pending review, never directly to approved.
49
99
 
50
100
  Each approval is scoped to one workflow ID and bound to a SHA-256 fingerprint of the exact artifact reviewed, so a later feature cannot reuse earlier approvals. A content change or rejection makes that stage and its descendants stale. The CLI persists the versioned ledger in `.aiw/approvals.yml`, blocks stage commands when prerequisites are unresolved, and keeps passing quality gates distinct from human approval. The generated bootstrap and stage skills require the host's native blocking question widget for human decisions when available, with a waiting chat prompt as fallback. Approval records provide an audit trail but cannot prove the identity of a respondent or independently verify that the host widget was used.
package/docs/backlog.md CHANGED
@@ -109,6 +109,19 @@ Priority levels: **P0** blocks the product or makes it unsafe; **P1** is require
109
109
 
110
110
  - [x] **ENG-001** Update GitHub Actions to Node 24-native versions. _Acceptance:_ `actions/checkout` and `actions/setup-node` upgraded past v4 so release workflows no longer emit Node.js 20 deprecation warnings; CI and Release workflows pass on `ubuntu-latest` before and after the October 19, 2026 Ubuntu 26 runner migration.
111
111
 
112
+ ## Workflow proportionality (ODD/RDD analysis) — P1–P3
113
+
114
+ Derived from the Gentle-AI ODD/RDD analysis; scope and rejected alternatives recorded in [ADR-001](../.context/adrs/ADR-001-odd-rdd-proportionality-scope.md). Each item links to a self-contained opportunity document under `docs/opportunities/` and can be implemented independently.
115
+
116
+ - [x] **OPP-001** Add a light lane for small, understood work (proportional ceremony). _Acceptance:_ see [OPP-001](../opportunities/OPP-001-proportional-ceremony-light-lane.md); no durable spec/plan artifacts for light tasks, evidence and lane rationale still recorded, explicit escalation to the full lane on scope growth, full lane unchanged.
117
+ - [x] **OPP-002** Add `aiw risk assess` — read-only, content-based risk classification with an exact runnable next command. _Acceptance:_ see [OPP-002](../opportunities/OPP-002-risk-assess-command.md); deterministic and offline by default, no `.aiw/` mutation, escalation raises and never lowers tier, `next_command` executes as printed.
118
+ - [x] **OPP-003** Ship an optional `adversarial-review` package (blind judges + refuter with proof-carrying findings). _Acceptance:_ see [OPP-003](../opportunities/OPP-003-adversarial-review-package.md); severe findings without reproducible evidence degrade to advisory; refutation outcomes are corroborated/refuted/inconclusive with proof references; human acceptance still required.
119
+ - [ ] **OPP-004** Emit typed reason codes and exact continuation commands on every blocking exit. _Acceptance:_ see [OPP-004](../opportunities/OPP-004-exact-continuation-commands.md); every block carries a reason code and either a tested runnable `next_command` or an explicit terminal statement; continuation inventory stays complete in CI.
120
+ - [x] **OPP-005** Add a friction benchmark classifying blocks as in-band / out-of-band / dead-end over packaged-CLI journeys. _Acceptance:_ see [OPP-005](../opportunities/OPP-005-friction-benchmark.md); deterministic classification, continuations actually executed, CI fails on any dead end.
121
+ - [x] **OPP-006** Adopt the emission ≠ usability test policy: messages naming a command must have a test executing it. _Acceptance:_ see [OPP-006](../opportunities/OPP-006-emission-usability-test-policy.md); extraction helper covers emitted and generated command mentions; generated `ai-init` skills' commands are executed in e2e tests.
122
+ - [ ] **007-decision-findings-triage-scope-watch** Decision model — findings-evidence triage and lane scope-watch (advisory-only). _Acceptance:_ see [OPP-007](../opportunities/OPP-007-decision-findings-triage-scope-watch.md); unconfigured = byte-identical offline behavior; advisory flags never block, never lower tiers, never choose lanes.
123
+ - [ ] **008-embedding-covering-tests-judge-context** Embedding retrieval — covering-test detection and judge context scoping. _Acceptance:_ see [OPP-008](../opportunities/OPP-008-embedding-covering-tests-judge-context.md); retrieval only prevents false `untested` flags (absence fails safe toward high); scoped judge prompts labeled as context aids within token ceilings.
124
+
112
125
  ## Backlog rules
113
126
 
114
127
  - [x] **FND-009** Add self-validation workflow. _Acceptance:_ changes affecting AIW behavior execute against this repository and record generated-output evidence.
@@ -0,0 +1,24 @@
1
+ # Continuation Inventory
2
+
3
+ Every blocking `aiw` exit carries a stable reason code plus either an exact runnable continuation or an explicit terminal statement. A message may name a command only when running that command resolves the block it was printed for (OPP-004, [ADR-001](../.context/adrs/ADR-001-odd-rdd-proportionality-scope.md)).
4
+
5
+ - Codes are declared in `src/blocking-reasons.ts` (`BLOCKING_REASON_CODES`); adding a code requires a definition with a continuation producer and a test.
6
+ - With `--json`, blocking exits return `{ "reason_code": ..., "next_command" | "next_action" | "terminal": ... }`.
7
+ - The table below is generated from the registry; a test in `src/blocking-reasons.test.ts` fails CI when this page drifts from the code.
8
+
9
+ | Reason code | Description | Continuation |
10
+ | ------------------------------- | --------------------------------------------------------------------------------------------- | ---------------- |
11
+ | `gate_evidence_missing` | The quality gate is blocked because the required evidence artifact is missing. | explicit action |
12
+ | `gate_incomplete` | The quality gate found completeness issues in the stage artifact. | explicit action |
13
+ | `stage_prerequisite_pending` | A previous stage is awaiting human approval before this stage can start. | runnable command |
14
+ | `stage_prerequisite_unresolved` | Previous stages need human approval or an accepted optional skip before this stage can start. | runnable command |
15
+ | `stage_awaiting_approval` | This stage is awaiting human approval; approve or reject it before continuing. | runnable command |
16
+ | `stage_already_approved` | This stage is already approved; changed evidence requires rerunning its quality gate. | runnable command |
17
+ | `approval_evidence_missing` | Approval cannot be recorded because the stage evidence artifact is missing. | explicit action |
18
+ | `approval_stale` | The artifact changed after review; its approval is stale and the gate must run again. | runnable command |
19
+ | `approval_gate_missing` | No passing quality gate is recorded for this stage; gate it before approving. | runnable command |
20
+ | `lane_escalation_required` | Light-lane workflows do not author stage artifacts; escalate to the full lane to continue. | explicit action |
21
+ | `light_evidence_missing` | A light-lane workflow cannot complete without at least one recorded validation entry. | explicit action |
22
+ | `workflow_still_active` | A workflow is already active; finish or archive it before starting another. | explicit action |
23
+ | `workflow_not_active` | No SDD workflow is active for this command to operate on. | runnable command |
24
+ | `workflow_complete` | This workflow is already complete; start a new workflow to continue working. | runnable command |
@@ -40,3 +40,7 @@ Observed behavior on real plans: an evidence-free plan scored `gate: ready` with
40
40
  ## Hard boundary (unchanged)
41
41
 
42
42
  Decision-model output is `inferred` evidence with confidence, never `confirmed`. It can move an artifact to _pending review_, never to _approved_, and cannot replace the fingerprinted human approval ledger. This matches the existing approval contract and keeps the audit trail human-owned.
43
+
44
+ ## Planned integration (OPP-007)
45
+
46
+ The next planned decision-model roles follow the same contracts: findings-evidence triage for severe review findings (does the cited proof support the claim — advisory flag, the refuter still decides) and a lane scope-watch that flags mismatch between a chosen light lane and deterministic risk signals. Both stay advisory-only with the deterministic floor unchanged; unconfigured behavior is byte-identical. See [OPP-007](opportunities/OPP-007-decision-findings-triage-scope-watch.md).