@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.
- package/CHANGELOG.md +27 -0
- package/README.md +12 -1
- package/dist/blocking-reasons.d.ts +67 -0
- package/dist/blocking-reasons.js +181 -0
- package/dist/command-evidence.d.ts +32 -0
- package/dist/command-evidence.js +61 -0
- package/dist/heartbeat.d.ts +61 -0
- package/dist/heartbeat.js +158 -0
- package/dist/hook-installer.d.ts +30 -0
- package/dist/hook-installer.js +52 -0
- package/dist/human-approval.d.ts +16 -1
- package/dist/human-approval.js +69 -5
- package/dist/recommendations.js +16 -1
- package/dist/risk-assess.d.ts +50 -0
- package/dist/risk-assess.js +273 -0
- package/dist/usage-import.d.ts +51 -0
- package/dist/usage-import.js +190 -0
- package/dist/usage-sidecar.d.ts +6 -0
- package/dist/usage-sidecar.js +14 -1
- package/dist/workflow.js +448 -24
- package/docs/README.md +2 -0
- package/docs/architecture.md +50 -0
- package/docs/backlog.md +13 -0
- package/docs/continuation-inventory.md +21 -0
- package/docs/decision-model-evaluation.md +4 -0
- package/docs/opportunities/OPP-001-proportional-ceremony-light-lane.md +62 -0
- package/docs/opportunities/OPP-002-risk-assess-command.md +70 -0
- package/docs/opportunities/OPP-003-adversarial-review-package.md +63 -0
- package/docs/opportunities/OPP-004-exact-continuation-commands.md +59 -0
- package/docs/opportunities/OPP-005-friction-benchmark.md +62 -0
- package/docs/opportunities/OPP-006-emission-usability-test-policy.md +57 -0
- package/docs/opportunities/OPP-007-decision-findings-triage-scope-watch.md +54 -0
- package/docs/opportunities/OPP-008-embedding-covering-tests-judge-context.md +52 -0
- package/docs/opportunities/README.md +33 -0
- package/docs/product-requirements.md +3 -0
- package/docs/product-vision.md +1 -0
- package/docs/quality.md +10 -0
- package/docs/release-process.md +9 -1
- package/docs/semantic-mapping.md +4 -0
- package/docs/token-efficiency.md +53 -0
- package/package.json +1 -1
- package/resources/package.yaml +3 -0
- package/resources/skills/code-review.md +2 -0
- package/resources/skills/review-judge-a.md +24 -0
- package/resources/skills/review-judge-b.md +24 -0
- package/resources/skills/review-refuter.md +28 -0
- 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), [
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
: `
|
|
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(
|
|
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
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
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
|
-
|
|
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
|
|
2356
|
-
|
|
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
|
|
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
|
|
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.
|