@multileaf/ai-workflow 0.8.0 → 0.10.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 (47) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +12 -1
  3. package/dist/blocking-reasons.d.ts +67 -0
  4. package/dist/blocking-reasons.js +181 -0
  5. package/dist/command-evidence.d.ts +32 -0
  6. package/dist/command-evidence.js +61 -0
  7. package/dist/heartbeat.d.ts +61 -0
  8. package/dist/heartbeat.js +158 -0
  9. package/dist/hook-installer.d.ts +30 -0
  10. package/dist/hook-installer.js +52 -0
  11. package/dist/human-approval.d.ts +16 -1
  12. package/dist/human-approval.js +69 -5
  13. package/dist/recommendations.js +16 -1
  14. package/dist/risk-assess.d.ts +50 -0
  15. package/dist/risk-assess.js +273 -0
  16. package/dist/usage-import.d.ts +51 -0
  17. package/dist/usage-import.js +190 -0
  18. package/dist/usage-sidecar.d.ts +6 -0
  19. package/dist/usage-sidecar.js +14 -1
  20. package/dist/workflow.js +448 -24
  21. package/docs/README.md +2 -0
  22. package/docs/architecture.md +50 -0
  23. package/docs/backlog.md +13 -0
  24. package/docs/continuation-inventory.md +21 -0
  25. package/docs/decision-model-evaluation.md +4 -0
  26. package/docs/opportunities/OPP-001-proportional-ceremony-light-lane.md +62 -0
  27. package/docs/opportunities/OPP-002-risk-assess-command.md +70 -0
  28. package/docs/opportunities/OPP-003-adversarial-review-package.md +63 -0
  29. package/docs/opportunities/OPP-004-exact-continuation-commands.md +59 -0
  30. package/docs/opportunities/OPP-005-friction-benchmark.md +62 -0
  31. package/docs/opportunities/OPP-006-emission-usability-test-policy.md +57 -0
  32. package/docs/opportunities/OPP-007-decision-findings-triage-scope-watch.md +54 -0
  33. package/docs/opportunities/OPP-008-embedding-covering-tests-judge-context.md +52 -0
  34. package/docs/opportunities/README.md +33 -0
  35. package/docs/product-requirements.md +3 -0
  36. package/docs/product-vision.md +1 -0
  37. package/docs/quality.md +10 -0
  38. package/docs/release-process.md +9 -1
  39. package/docs/semantic-mapping.md +4 -0
  40. package/docs/token-efficiency.md +53 -0
  41. package/package.json +1 -1
  42. package/resources/package.yaml +3 -0
  43. package/resources/skills/code-review.md +2 -0
  44. package/resources/skills/review-judge-a.md +24 -0
  45. package/resources/skills/review-judge-b.md +24 -0
  46. package/resources/skills/review-refuter.md +28 -0
  47. package/resources/skills/ticket.md +2 -0
package/dist/workflow.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
+ import { homedir } from "node:os";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { BASE_DIRECTORIES, WORKFLOW_DIRECTORY } from "./constants.js";
4
5
  import { detectTarget, isTarget, renderAiInit, describeResource, renderAiInitPath, renderResourcePath, } from "./adapter.js";
@@ -13,6 +14,20 @@ import { recommendCapabilities, rankRecommendations, resourcesForCapabilities, s
13
14
  import { selectRecommendations } from "./selection.js";
14
15
  import { createInterface } from "node:readline/promises";
15
16
  import { stdin, stdout } from "node:process";
17
+ async function readStdin() {
18
+ const terminal = createInterface({ input: stdin });
19
+ try {
20
+ return await new Promise((resolve, reject) => {
21
+ let data = "";
22
+ stdin.on("data", (chunk) => (data += chunk));
23
+ stdin.on("end", () => resolve(data));
24
+ stdin.on("error", reject);
25
+ });
26
+ }
27
+ finally {
28
+ terminal.close();
29
+ }
30
+ }
16
31
  import { generateProjectResources } from "./generator.js";
17
32
  import { detectDrift } from "./drift.js";
18
33
  import { parseManifest, serializeManifest } from "./manifest.js";
@@ -35,17 +50,23 @@ import { composeContext, CONTEXT_LAYERS, readContextStore, writeContextLayer, }
35
50
  import { loadContextDelta, retrieveTaskContext } from "./context-retrieval.js";
36
51
  import { createSummaryCache, isSummaryFresh, parseSummaryCache, serializeSummaryCache, } from "./context-cache.js";
37
52
  import { parseTokenUsage, recordTokenUsage, serializeTokenUsage, summarizeUsageByModel, } from "./token-usage.js";
38
- 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";
39
55
  import { measureContextQuality, serializeContextQuality } from "./context-quality.js";
40
56
  import { collectContextBench, compareContextBench, parseContextBench, serializeContextBench, } from "./context-benchmark.js";
41
57
  import { materializeParityTask, parseParityTask, runParitySuite, serializeParitySuite, TASK_ASSEMBLER, } from "./task-parity.js";
42
58
  import { appendUsageSidecarEntry, parseUsageSidecar, serializeUsageSidecar, serializeUsageSidecarSummary, summarizeUsageSidecar, USAGE_SIDECAR_PATH, USAGE_TIERS, } from "./usage-sidecar.js";
59
+ import { mergeImportedEvents, parseClaudeCodeTranscript, parseCodexSession, parseGeminiTranscript, parsePiSession, summarizeImport, } from "./usage-import.js";
60
+ import { describeHeartbeatOutcome, emptyHeartbeatState, HEARTBEAT_STATE_PATH, HEARTBEAT_TTL_SECONDS, HEARTBEAT_WARN_SECONDS, parseHeartbeatState, recordBeat, serializeHeartbeatCheckpoint, serializeHeartbeatState, validateHeartbeatThresholds, } from "./heartbeat.js";
61
+ import { installHeartbeatHooks, parseClaudeSettings, writeClaudeSettings, } from "./hook-installer.js";
43
62
  import { computeCostAttribution, serializeCostAttribution, serializeTaskUsage, summarizeTaskUsage, } from "./cost-attribution.js";
44
63
  import { defaultRoutingTable, parseRoutingTable, resolveRoute, ROUTING_TABLE_PATH, serializeResolvedRoute, serializeRoutingTable, } from "./model-routing.js";
45
64
  import { parseTraceabilityGraph } from "./traceability.js";
46
65
  import { serializeAdapterCapabilities } from "./adapter-contract.js";
47
66
  import { buildTraceabilityGraph, serializeTraceabilityGraph } from "./traceability.js";
48
67
  import { evaluateQualityGate } from "./quality-gates.js";
68
+ import { BlockingError, blockingResultToJson, describeBlockingReason } from "./blocking-reasons.js";
69
+ import { runRiskAssess } from "./risk-assess.js";
49
70
  import { parseMigrationCheckpoint, planNeutralResourceMigration, readResourceBytes, serializeMigrationPreview, serializeMigrationCheckpoint, validateNeutralResourceDocument, writeResourceBytes, } from "./migration.js";
50
71
  import { parsePrivateRegistries, parseTeamPreset, serializePrivateRegistries, serializeTeamPreset, fetchPrivateRegistryClient, } from "./team-configuration.js";
51
72
  import { enforcePolicyGate, gitPolicyTracking } from "./policy-gate.js";
@@ -92,8 +113,15 @@ function beginWorkflowSession(fs, aiw, options) {
92
113
  updatedAt: now,
93
114
  ...(options.ticket ? { ticket: options.ticket } : {}),
94
115
  ...(options.title ? { title: options.title } : {}),
116
+ ...(options.lane === "light" && options.laneReason
117
+ ? { lane: "light", laneReason: options.laneReason }
118
+ : {}),
95
119
  };
96
- const ledger = createApprovalLedger(workflowId);
120
+ const ledger = createApprovalLedger(workflowId, {
121
+ ...(options.lane === "light" && options.laneReason
122
+ ? { lane: "light", laneReason: options.laneReason }
123
+ : {}),
124
+ });
97
125
  fs.write(ledgerPath, serializeApprovalLedger(ledger));
98
126
  fs.write(sessionPath, serializeWorkflowSession(session));
99
127
  return { exitCode: 0, output: `Workflow started: ${workflowId}.` };
@@ -106,14 +134,35 @@ export async function runCommand(args, root, fs, services = {}) {
106
134
  if (command === "task") {
107
135
  if (!fs.exists(join(aiw, "manifest.yml")))
108
136
  return { exitCode: 1, error: "Run `aiw install` first." };
109
- const usage = "Usage: aiw task start --ticket=TEAM-123 [--title=text] [--id=workflow-id] | aiw task status";
137
+ 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";
110
138
  const action = args[1];
111
139
  const sessionPath = join(aiw, "workflow.yml");
112
140
  if (action === "start") {
113
- const options = parseNamedOptions(args.slice(2), ["ticket", "title", "id"]);
141
+ const options = parseNamedOptions(args.slice(2), [
142
+ "ticket",
143
+ "title",
144
+ "id",
145
+ "lane",
146
+ "lane-reason",
147
+ ]);
114
148
  if (!options?.ticket)
115
149
  return { exitCode: 1, error: usage };
116
150
  validateTaskTicket(options.ticket);
151
+ if (options.lane !== undefined && options.lane !== "light")
152
+ return { exitCode: 1, error: "Only --lane=light exists; omit it for the full lane." };
153
+ const laneReason = options["lane-reason"];
154
+ if (options.lane === "light") {
155
+ if (!laneReason)
156
+ return {
157
+ exitCode: 1,
158
+ error: "--lane=light requires --lane-reason: the resume-test call, recorded for audit.",
159
+ };
160
+ if (laneReason.length > 500 || /[\r\n]/.test(laneReason))
161
+ return {
162
+ exitCode: 1,
163
+ error: "--lane-reason must be a single line of at most 500 bytes.",
164
+ };
165
+ }
117
166
  if (options.id !== undefined) {
118
167
  try {
119
168
  assertWorkflowId(options.id);
@@ -122,20 +171,104 @@ export async function runCommand(args, root, fs, services = {}) {
122
171
  return { exitCode: 1, error: "Workflow ID must be a UUID." };
123
172
  }
124
173
  }
174
+ const lane = options.lane === "light" ? "light" : undefined;
125
175
  const result = beginWorkflowSession(fs, aiw, {
126
176
  ...(options.id ? { id: options.id } : {}),
127
177
  ticket: options.ticket,
128
178
  ...(options.title ? { title: options.title } : {}),
179
+ ...(lane ? { lane, laneReason: laneReason } : {}),
129
180
  });
130
181
  if (result.exitCode !== 0)
131
182
  return result;
132
183
  const title = options.title ? ` titled "${options.title}"` : "";
184
+ if (lane)
185
+ return {
186
+ exitCode: 0,
187
+ output: `Task started for ticket ${options.ticket}${title} (light lane): ${result.output}\n` +
188
+ "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
+ `Rationale recorded: ${laneReason}\n` +
190
+ 'No durable spec/plan artifacts are required. Record validation with `aiw task evidence --validation="<command and result>"`, then complete with `aiw workflow complete`. If scope grows, escalate with `aiw task escalate --reason=...`.',
191
+ };
133
192
  return {
134
193
  exitCode: 0,
135
194
  output: `Task started for ticket ${options.ticket}${title}: ${result.output}\n` +
136
195
  `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=...\`.`,
137
196
  };
138
197
  }
198
+ if (action === "escalate") {
199
+ const options = parseNamedOptions(args.slice(2), ["reason"]);
200
+ if (!options?.reason)
201
+ return { exitCode: 1, error: "Usage: aiw task escalate --reason=why-scope-grew" };
202
+ if (!fs.exists(sessionPath))
203
+ return { exitCode: 1, error: "No SDD workflow is active. Start one first." };
204
+ const { session } = readWorkflowState(aiw, fs);
205
+ if (session.lane !== "light")
206
+ return {
207
+ exitCode: 1,
208
+ error: "Only light-lane workflows escalate; this one already runs the full lane.",
209
+ };
210
+ if (session.laneEscalatedAt)
211
+ return {
212
+ exitCode: 1,
213
+ error: `Workflow ${session.workflowId} already escalated to the full lane.`,
214
+ };
215
+ const now = new Date().toISOString();
216
+ fs.write(sessionPath, serializeWorkflowSession({
217
+ ...session,
218
+ laneEscalatedAt: now,
219
+ laneEscalationReason: options.reason,
220
+ updatedAt: now,
221
+ }));
222
+ const evidencePath = join(aiw, `checkpoints/task-light-${session.workflowId}.yml`);
223
+ return {
224
+ exitCode: 0,
225
+ output: `Workflow ${session.workflowId} escalated to the full lane.\n` +
226
+ (fs.exists(evidencePath)
227
+ ? `Light-lane evidence preserved and linked: ${evidencePath}\n`
228
+ : "No light-lane evidence had been recorded yet.\n") +
229
+ "Stages now run the full approval sequence from brainstorming.",
230
+ };
231
+ }
232
+ if (action === "evidence") {
233
+ const options = parseNamedOptions(args.slice(2), ["validation"]);
234
+ if (!options?.validation)
235
+ return {
236
+ exitCode: 1,
237
+ error: 'Usage: aiw task evidence --validation="<command and its observed result>"',
238
+ };
239
+ if (!fs.exists(sessionPath))
240
+ return { exitCode: 1, error: "No SDD workflow is active. Start one first." };
241
+ const { session } = readWorkflowState(aiw, fs);
242
+ if (effectiveLane(session) !== "light")
243
+ return {
244
+ exitCode: 1,
245
+ error: "Evidence entries are a light-lane command; full-lane workflows record stage artifacts and gates.",
246
+ };
247
+ const evidencePath = join(aiw, `checkpoints/task-light-${session.workflowId}.yml`);
248
+ const existing = fs.exists(evidencePath)
249
+ ? parseLightEvidence(fs.read(evidencePath))
250
+ : {
251
+ schema: 1,
252
+ workflowId: session.workflowId,
253
+ ...(session.ticket ? { ticket: session.ticket } : {}),
254
+ lane: "light",
255
+ ...(session.laneReason ? { laneReason: session.laneReason } : {}),
256
+ entries: [],
257
+ };
258
+ const updated = {
259
+ ...existing,
260
+ entries: [
261
+ ...existing.entries,
262
+ { note: options.validation, recordedAt: new Date().toISOString() },
263
+ ],
264
+ };
265
+ fs.mkdir(join(aiw, "checkpoints"));
266
+ fs.write(evidencePath, stringify({ ...updated }, { lineWidth: 0 }));
267
+ return {
268
+ exitCode: 0,
269
+ output: `Validation evidence recorded (${updated.entries.length} entr${updated.entries.length === 1 ? "y" : "ies"}): ${options.validation}`,
270
+ };
271
+ }
139
272
  if (action === "status") {
140
273
  if (!fs.exists(sessionPath))
141
274
  return {
@@ -143,14 +276,24 @@ export async function runCommand(args, root, fs, services = {}) {
143
276
  output: "No SDD workflow is active. Start one with `aiw task start --ticket=TEAM-123`.",
144
277
  };
145
278
  const { session, ledger } = readWorkflowState(aiw, fs, false);
279
+ const lane = session.lane === "light"
280
+ ? [
281
+ `Lane: light — ${session.laneEscalatedAt ? `escalated to the full lane (${session.laneEscalationReason})` : session.laneReason}`,
282
+ ]
283
+ : [];
146
284
  const lines = [
147
285
  ...(session.ticket
148
286
  ? [`Ticket: ${session.ticket}${session.title ? ` — ${session.title}` : ""}`]
149
287
  : []),
288
+ ...lane,
150
289
  `Workflow: ${session.workflowId} (${session.status})`,
151
290
  "Stages:",
152
291
  ...APPROVAL_STAGES.map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`),
153
292
  ];
293
+ if (session.status === "active" && effectiveLane(session) === "light") {
294
+ lines.push('Next: record validation with `aiw task evidence --validation="<command and result>"`, then `aiw workflow complete`. If scope grows: `aiw task escalate --reason=...`.');
295
+ return { exitCode: 0, output: lines.join("\n") };
296
+ }
154
297
  const next = APPROVAL_STAGES.find((stage) => (ledger.stages[stage]?.status ?? "not-started") === "not-started");
155
298
  if (session.status === "active" && next)
156
299
  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)`}.`);
@@ -181,6 +324,25 @@ export async function runCommand(args, root, fs, services = {}) {
181
324
  }
182
325
  if (action === "complete") {
183
326
  const { session, ledger } = readActiveWorkflow(aiw, fs);
327
+ if (effectiveLane(session) === "light") {
328
+ const evidencePath = join(aiw, `checkpoints/task-light-${session.workflowId}.yml`);
329
+ const hasEntries = fs.exists(evidencePath)
330
+ ? parseLightEvidence(fs.read(evidencePath)).entries.length > 0
331
+ : false;
332
+ if (!hasEntries)
333
+ throw new BlockingError(describeBlockingReason("light_evidence_missing", {
334
+ detail: `Light-lane workflow ${session.workflowId} cannot complete without recorded validation evidence.`,
335
+ }));
336
+ fs.write(sessionPath, serializeWorkflowSession({
337
+ ...session,
338
+ status: "complete",
339
+ updatedAt: new Date().toISOString(),
340
+ }));
341
+ return {
342
+ exitCode: 0,
343
+ output: `Workflow completed (light lane): ${session.workflowId}. Evidence: ${evidencePath}`,
344
+ };
345
+ }
184
346
  const unresolved = APPROVAL_STAGES.filter((stage) => ledger.stages[stage]?.status !== "approved" &&
185
347
  ledger.stages[stage]?.status !== "skipped");
186
348
  if (unresolved.length)
@@ -204,10 +366,15 @@ export async function runCommand(args, root, fs, services = {}) {
204
366
  const stage = args[1];
205
367
  if (!stage || !APPROVAL_STAGES.includes(stage))
206
368
  return { exitCode: 1, error: `Usage: aiw ${command} <workflow-stage> [options]` };
207
- const { ledger } = readActiveWorkflow(aiw, fs);
369
+ const rest = args.slice(2).filter((argument) => argument !== "--json");
370
+ const { session, ledger } = readActiveWorkflow(aiw, fs);
371
+ if (effectiveLane(session) === "light")
372
+ throw new BlockingError(describeBlockingReason("lane_escalation_required", {
373
+ detail: `Light-lane workflow ${session.workflowId} does not approve stages; escalate to the full lane to continue.`,
374
+ }));
208
375
  const workflowStage = stage;
209
376
  if (command === "skip") {
210
- const options = parseNamedOptions(args.slice(2), ["reason"]);
377
+ const options = parseNamedOptions(rest, ["reason"]);
211
378
  if (!options?.reason)
212
379
  return {
213
380
  exitCode: 1,
@@ -223,19 +390,28 @@ export async function runCommand(args, root, fs, services = {}) {
223
390
  }
224
391
  const artifact = approvalArtifactPath(aiw, workflowStage);
225
392
  if (!artifact || !fs.exists(artifact))
226
- return { exitCode: 1, error: `Approval evidence is missing for ${workflowStage}.` };
393
+ throw new BlockingError(describeBlockingReason("approval_evidence_missing", {
394
+ stage: workflowStage,
395
+ artifactPath: artifact ?? undefined,
396
+ detail: `Approval evidence is missing for ${workflowStage}.`,
397
+ }));
227
398
  const artifactHash = stageFingerprint(fs.read(artifact));
228
399
  if (command === "approve") {
229
400
  if (ledger.stages[workflowStage]?.artifactHash !== artifactHash) {
230
401
  const invalidated = invalidateStageAndDescendants(ledger, workflowStage, new Date().toISOString());
231
402
  fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(invalidated));
232
403
  const existing = ledger.stages[workflowStage];
233
- return {
234
- exitCode: 1,
235
- error: existing
236
- ? `The ${workflowStage} artifact changed after review; its approval is stale. Rerun the quality gate and request approval again.`
237
- : `No passing quality gate recorded for ${workflowStage}. Run aiw gate ${workflowStage} before approving.`,
238
- };
404
+ if (existing)
405
+ throw new BlockingError(describeBlockingReason("approval_stale", {
406
+ stage: workflowStage,
407
+ artifactPath: artifact,
408
+ detail: `The ${workflowStage} artifact changed after review; its approval is stale. Rerun the quality gate and request approval again.`,
409
+ }));
410
+ throw new BlockingError(describeBlockingReason("approval_gate_missing", {
411
+ stage: workflowStage,
412
+ artifactPath: artifact,
413
+ detail: `No passing quality gate recorded for ${workflowStage}. Run aiw gate ${workflowStage} before approving.`,
414
+ }));
239
415
  }
240
416
  const updated = approveStage(ledger, workflowStage, {
241
417
  artifactHash,
@@ -244,7 +420,7 @@ export async function runCommand(args, root, fs, services = {}) {
244
420
  fs.write(join(aiw, "approvals.yml"), serializeApprovalLedger(updated));
245
421
  return { exitCode: 0, output: `Human approval recorded for ${workflowStage}.` };
246
422
  }
247
- const options = parseNamedOptions(args.slice(2), ["reason"]);
423
+ const options = parseNamedOptions(rest, ["reason"]);
248
424
  if (!options?.reason)
249
425
  return { exitCode: 1, error: "Usage: aiw reject <stage> --reason=human-feedback" };
250
426
  const updated = rejectStage(ledger, workflowStage, options.reason, new Date().toISOString());
@@ -1731,6 +1907,11 @@ export async function runCommand(args, root, fs, services = {}) {
1731
1907
  fs.write(path, serialized);
1732
1908
  return { exitCode: 0, output: `Traceability graph created: ${path}` };
1733
1909
  }
1910
+ if (command === "risk") {
1911
+ if (args[1] !== "assess")
1912
+ return { exitCode: 1, error: "Usage: aiw risk assess [--base-ref=ref] [--json]" };
1913
+ return runRiskAssess(args.slice(2), root, fs);
1914
+ }
1734
1915
  if (command === "gate") {
1735
1916
  if (!fs.exists(join(aiw, "manifest.yml")))
1736
1917
  return { exitCode: 1, error: "Run `aiw install` first." };
@@ -1744,15 +1925,21 @@ export async function runCommand(args, root, fs, services = {}) {
1744
1925
  assertStageReady(aiw, fs, workflowStage);
1745
1926
  const artifactPath = approvalArtifactPath(aiw, workflowStage);
1746
1927
  if (!artifactPath || !fs.exists(artifactPath))
1747
- return {
1748
- exitCode: 1,
1749
- error: `Quality gate blocked: required evidence is missing for ${stage}.`,
1750
- };
1928
+ throw new BlockingError(describeBlockingReason("gate_evidence_missing", {
1929
+ stage,
1930
+ artifactPath: artifactPath ?? undefined,
1931
+ detail: `Quality gate blocked: required evidence is missing for ${stage}.`,
1932
+ }));
1751
1933
  const hasQualityGate = stageDefinition(workflowStage).requiresQualityGate;
1752
1934
  if (hasQualityGate) {
1753
1935
  const issues = evaluateQualityGate(stage, fs.read(artifactPath));
1754
1936
  if (issues.length)
1755
- return { exitCode: 1, error: `Quality gate blocked: ${issues.join(" ")}` };
1937
+ throw new BlockingError(describeBlockingReason("gate_incomplete", {
1938
+ stage,
1939
+ artifactPath,
1940
+ gateIssues: issues,
1941
+ detail: `Quality gate blocked: ${issues.join(" ")}`,
1942
+ }));
1756
1943
  }
1757
1944
  const { ledger } = readActiveWorkflow(aiw, fs);
1758
1945
  const updated = beginStageApproval(ledger, workflowStage, {
@@ -2156,6 +2343,100 @@ export async function runCommand(args, root, fs, services = {}) {
2156
2343
  "\nSources: .aiw/checkpoints/usage-sidecar.yml + .aiw/generated/artifacts/traceability.yml (verified = linked evidence present).",
2157
2344
  };
2158
2345
  }
2346
+ if (command === "heartbeat") {
2347
+ if (!fs.exists(join(aiw, "manifest.yml")))
2348
+ return { exitCode: 1, error: "Run `aiw install` first." };
2349
+ const parsed = parseNamedOptions(args.slice(1), ["session", "warn", "ttl"]);
2350
+ if (!parsed)
2351
+ return {
2352
+ exitCode: 1,
2353
+ error: "Usage: aiw heartbeat --session=id [--warn=seconds] [--ttl=seconds]",
2354
+ };
2355
+ const warnSeconds = parsed.warn === undefined ? HEARTBEAT_WARN_SECONDS : Number(parsed.warn);
2356
+ const ttlSeconds = parsed.ttl === undefined ? HEARTBEAT_TTL_SECONDS : Number(parsed.ttl);
2357
+ try {
2358
+ validateHeartbeatThresholds(warnSeconds, ttlSeconds);
2359
+ }
2360
+ catch (error) {
2361
+ return { exitCode: 1, error: error.message };
2362
+ }
2363
+ let session = parsed.session;
2364
+ if (!session && !stdin.isTTY) {
2365
+ // Claude Code hooks deliver a JSON payload with session_id on stdin.
2366
+ try {
2367
+ const payload = JSON.parse(await readStdin());
2368
+ if (payload &&
2369
+ typeof payload === "object" &&
2370
+ typeof payload.session_id === "string")
2371
+ session = payload.session_id;
2372
+ }
2373
+ catch {
2374
+ // No parseable payload: fall through to the explicit error below.
2375
+ }
2376
+ }
2377
+ if (!session)
2378
+ return {
2379
+ exitCode: 1,
2380
+ error: "A session id is required: pass --session=id or pipe a harness hook JSON payload on stdin.",
2381
+ };
2382
+ const statePath = join(aiw, HEARTBEAT_STATE_PATH);
2383
+ const state = fs.exists(statePath)
2384
+ ? parseHeartbeatState(fs.read(statePath))
2385
+ : emptyHeartbeatState();
2386
+ const { state: nextState, outcome } = recordBeat(state, session, new Date(), warnSeconds, ttlSeconds);
2387
+ fs.write(statePath, serializeHeartbeatState(nextState));
2388
+ const lines = [describeHeartbeatOutcome(outcome)];
2389
+ if (outcome.checkpoint) {
2390
+ const checkpointPath = join(aiw, `checkpoints/heartbeat-${session}-${outcome.checkpoint.index}.yml`);
2391
+ fs.write(checkpointPath, serializeHeartbeatCheckpoint(outcome.checkpoint, outcome.gapSeconds ?? 0, ttlSeconds));
2392
+ lines.push(`checkpoint: ${relative(root, checkpointPath).split(sep).join("/")}`);
2393
+ }
2394
+ return { exitCode: 0, output: lines.join("\n") };
2395
+ }
2396
+ if (command === "hooks") {
2397
+ if (!fs.exists(join(aiw, "manifest.yml")))
2398
+ return { exitCode: 1, error: "Run `aiw install` first." };
2399
+ const action = args[1];
2400
+ if (action !== "install")
2401
+ return {
2402
+ exitCode: 1,
2403
+ error: "Usage: aiw hooks install --target=claude-code (codex and gemini adapters follow the same command)",
2404
+ };
2405
+ const parsed = parseNamedOptions(args.slice(2), ["target"]);
2406
+ if (!parsed?.target)
2407
+ return { exitCode: 1, error: "Usage: aiw hooks install --target=claude-code" };
2408
+ if (parsed.target !== "claude-code")
2409
+ return {
2410
+ exitCode: 1,
2411
+ error: `Harness hook adapter not available yet for: ${parsed.target}. Claude Code is wired; codex and gemini follow the same pattern.`,
2412
+ };
2413
+ const settingsPath = join(root, ".claude/settings.json");
2414
+ let settings;
2415
+ try {
2416
+ settings = parseClaudeSettings(fs.exists(settingsPath) ? fs.read(settingsPath) : "");
2417
+ }
2418
+ catch (error) {
2419
+ return {
2420
+ exitCode: 1,
2421
+ error: `Existing .claude/settings.json is not a valid hooks file; fix or move it first: ${error.message}`,
2422
+ };
2423
+ }
2424
+ const result = installHeartbeatHooks(settings);
2425
+ if (fs.pathType?.(settingsPath) === "symlink")
2426
+ return {
2427
+ exitCode: 1,
2428
+ error: ".claude/settings.json is a symbolic link; install hooks manually.",
2429
+ };
2430
+ writeClaudeSettings(fs, settingsPath, result.settings);
2431
+ return {
2432
+ exitCode: 0,
2433
+ output: `Heartbeat hooks installed into .claude/settings.json (project-scoped):\n` +
2434
+ [...result.added, ...result.alreadyPresent]
2435
+ .map((event) => `- ${event}: ${result.added.includes(event) ? "added" : "already present"} → aiw heartbeat on every event`)
2436
+ .join("\n") +
2437
+ "\nSessions now record beats; idle gaps inside the warn window save a compact checkpoint while the cache is still warm.",
2438
+ };
2439
+ }
2159
2440
  if (command === "usage-sidecar") {
2160
2441
  if (!fs.exists(join(aiw, "manifest.yml")))
2161
2442
  return { exitCode: 1, error: "Run `aiw install` first." };
@@ -2172,6 +2453,109 @@ export async function runCommand(args, root, fs, services = {}) {
2172
2453
  : ""),
2173
2454
  };
2174
2455
  }
2456
+ if (action === "import") {
2457
+ const parsed = parseNamedOptions(args.slice(2), ["from", "path"]);
2458
+ const source = parsed?.from;
2459
+ if (!parsed || !source || !["claude-code", "codex", "gemini", "pi"].includes(source))
2460
+ return {
2461
+ exitCode: 1,
2462
+ error: "Usage: aiw usage-sidecar import --from=claude-code|codex|gemini|pi [--path=logs-directory]",
2463
+ };
2464
+ if (!fs.list)
2465
+ return { exitCode: 1, error: "The filesystem cannot enumerate harness logs." };
2466
+ const home = homedir();
2467
+ const defaultRoot = source === "claude-code"
2468
+ ? join(home, ".claude/projects", root.split("/").join("-"))
2469
+ : source === "codex"
2470
+ ? join(home, ".codex/sessions")
2471
+ : source === "pi"
2472
+ ? join(home, ".pi/agent/sessions")
2473
+ : join(home, ".gemini/antigravity-cli/brain");
2474
+ const scanRoot = parsed.path ? resolve(parsed.path) : defaultRoot;
2475
+ if (fs.pathType?.(scanRoot) !== "directory")
2476
+ return {
2477
+ exitCode: 1,
2478
+ error: `Harness log directory not found: ${scanRoot}. Pass --path to point at the ${source} logs.`,
2479
+ };
2480
+ const wanted = (name) => source === "claude-code"
2481
+ ? name.endsWith(".jsonl")
2482
+ : source === "codex"
2483
+ ? /^rollout-.*\.jsonl$/.test(name)
2484
+ : name === "transcript_full.jsonl";
2485
+ const files = [];
2486
+ const listDirectory = fs.list;
2487
+ if (source === "pi") {
2488
+ // Pi groups sessions into directories named after the project path.
2489
+ const munged = root.split("/").join("-");
2490
+ for (const name of listDirectory(scanRoot) ?? []) {
2491
+ if (!name.includes(munged))
2492
+ continue;
2493
+ const directory = join(scanRoot, name);
2494
+ if (fs.pathType?.(directory) !== "directory")
2495
+ continue;
2496
+ for (const file of listDirectory(directory) ?? [])
2497
+ if (file.endsWith(".jsonl") && fs.pathType?.(join(directory, file)) === "file")
2498
+ files.push(join(directory, file));
2499
+ }
2500
+ }
2501
+ else {
2502
+ const walk = (directory, depth) => {
2503
+ if (depth > 6)
2504
+ return;
2505
+ for (const name of listDirectory(directory) ?? []) {
2506
+ const absolute = join(directory, name);
2507
+ const type = fs.pathType?.(absolute);
2508
+ if (type === "directory")
2509
+ walk(absolute, depth + 1);
2510
+ else if (type === "file" && wanted(name))
2511
+ files.push(absolute);
2512
+ }
2513
+ };
2514
+ walk(scanRoot, 0);
2515
+ }
2516
+ files.sort();
2517
+ const events = [];
2518
+ for (const file of files) {
2519
+ const content = fs.read(file);
2520
+ if (source === "claude-code")
2521
+ events.push(...parseClaudeCodeTranscript(content));
2522
+ else if (source === "codex") {
2523
+ const parsedEvents = parseCodexSession(content, root);
2524
+ if (parsedEvents)
2525
+ events.push(...parsedEvents);
2526
+ }
2527
+ else if (source === "pi")
2528
+ events.push(...parsePiSession(content));
2529
+ else
2530
+ events.push(...parseGeminiTranscript(content));
2531
+ }
2532
+ let ledger;
2533
+ try {
2534
+ ledger = fs.exists(sidecarPath) ? parseUsageSidecar(fs.read(sidecarPath)) : [];
2535
+ }
2536
+ catch (error) {
2537
+ throw new Error(`Usage sidecar is unreadable: ${error.message}`, {
2538
+ cause: error,
2539
+ });
2540
+ }
2541
+ const merged = mergeImportedEvents(ledger, events);
2542
+ fs.write(sidecarPath, serializeUsageSidecar(merged.ledger));
2543
+ const result = {
2544
+ source,
2545
+ files: files.length,
2546
+ events: events.length,
2547
+ imported: merged.imported,
2548
+ skipped: merged.skipped,
2549
+ };
2550
+ return {
2551
+ exitCode: 0,
2552
+ output: summarizeImport(result) +
2553
+ (source === "gemini"
2554
+ ? "\nnote: Gemini transcripts do not record a working directory, so imports cannot be scoped to this project."
2555
+ : "") +
2556
+ `\nLedger: .aiw/${USAGE_SIDECAR_PATH} (rerun safely: already-imported events are skipped).`,
2557
+ };
2558
+ }
2175
2559
  if (action === "record") {
2176
2560
  const recordArgs = args.slice(2).filter((arg) => arg !== "--estimated");
2177
2561
  if (recordArgs.some((arg) => arg !== "--estimated" && !arg.startsWith("--")))
@@ -2304,6 +2688,9 @@ export async function runCommand(args, root, fs, services = {}) {
2304
2688
  };
2305
2689
  }
2306
2690
  catch (error) {
2691
+ if (error instanceof BlockingError && args.includes("--json")) {
2692
+ return { exitCode: 1, output: JSON.stringify(blockingResultToJson(error.result)) };
2693
+ }
2307
2694
  return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2308
2695
  }
2309
2696
  }
@@ -2346,20 +2733,57 @@ function workflowNextAction(ledger) {
2346
2733
  return `Next action: revise ${next}, rerun its gate, and request human approval.`;
2347
2734
  return `Next action: prepare ${next} evidence.`;
2348
2735
  }
2736
+ function parseLightEvidence(content) {
2737
+ const value = parseYaml(content);
2738
+ if (!value || typeof value !== "object" || value.schema !== 1)
2739
+ throw new Error("Light-lane evidence checkpoint must be schema version 1.");
2740
+ const record = value;
2741
+ const entries = Array.isArray(record.entries) ? record.entries : [];
2742
+ return {
2743
+ schema: 1,
2744
+ workflowId: String(record.workflowId ?? ""),
2745
+ ...(record.ticket !== undefined ? { ticket: String(record.ticket) } : {}),
2746
+ lane: "light",
2747
+ ...(record.laneReason !== undefined ? { laneReason: String(record.laneReason) } : {}),
2748
+ entries: entries.map((entry) => ({
2749
+ note: String(entry.note ?? ""),
2750
+ recordedAt: String(entry.recordedAt ?? ""),
2751
+ })),
2752
+ };
2753
+ }
2349
2754
  function assertStageReady(aiw, fs, stage) {
2350
- const { ledger } = readActiveWorkflow(aiw, fs);
2755
+ const { session, ledger } = readActiveWorkflow(aiw, fs);
2756
+ if (effectiveLane(session) === "light")
2757
+ throw new BlockingError(describeBlockingReason("lane_escalation_required", {
2758
+ stage,
2759
+ detail: `Light-lane workflow ${session.workflowId} does not author stage artifacts; escalate to the full lane to continue.`,
2760
+ }));
2351
2761
  const unresolved = getUnresolvedPrerequisites(ledger, stage);
2352
2762
  if (unresolved.length) {
2353
2763
  const pending = unresolved.filter((prerequisite) => ledger.stages[prerequisite]?.status === "pending-human");
2354
2764
  if (pending.length)
2355
- throw new Error(`Cannot enter ${stage}; previous stage(s) are awaiting human approval: ${pending.join(", ")}.`);
2356
- throw new Error(`Cannot enter ${stage}; previous stages require human approval or an accepted optional skip: ${unresolved.join(", ")}.`);
2765
+ throw new BlockingError(describeBlockingReason("stage_prerequisite_pending", {
2766
+ stage,
2767
+ pendingStages: pending,
2768
+ detail: `Cannot enter ${stage}; previous stage(s) are awaiting human approval: ${pending.join(", ")}.`,
2769
+ }));
2770
+ throw new BlockingError(describeBlockingReason("stage_prerequisite_unresolved", {
2771
+ stage,
2772
+ unresolvedStages: unresolved,
2773
+ detail: `Cannot enter ${stage}; previous stages require human approval or an accepted optional skip: ${unresolved.join(", ")}.`,
2774
+ }));
2357
2775
  }
2358
2776
  const current = ledger.stages[stage];
2359
2777
  if (current?.status === "pending-human")
2360
- throw new Error(`${stage} is awaiting human approval. Approve or reject it before continuing.`);
2778
+ throw new BlockingError(describeBlockingReason("stage_awaiting_approval", {
2779
+ stage,
2780
+ detail: `${stage} is awaiting human approval. Approve or reject it before continuing.`,
2781
+ }));
2361
2782
  if (current?.status === "approved")
2362
- throw new Error(`${stage} is already approved. Evidence changes require rerunning its gate.`);
2783
+ throw new BlockingError(describeBlockingReason("stage_already_approved", {
2784
+ stage,
2785
+ detail: `${stage} is already approved. Evidence changes require rerunning its gate.`,
2786
+ }));
2363
2787
  }
2364
2788
  function approvalArtifactPath(aiw, stage) {
2365
2789
  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.