@multileaf/ai-workflow 0.10.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.
- package/CHANGELOG.md +11 -0
- package/dist/blocking-reasons.d.ts +1 -1
- package/dist/blocking-reasons.js +18 -0
- package/dist/human-approval.d.ts +3 -1
- package/dist/human-approval.js +13 -4
- package/dist/workflow.js +47 -6
- package/docs/architecture.md +1 -1
- package/docs/continuation-inventory.md +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ All notable changes to this project are documented in this file. The format foll
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.11.0] - 2026-10-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `aiw workflow abandon --reason=<why>` archives an obsolete active workflow: the session (with `status: abandoned` and the recorded reason) and the approval ledger move under `.aiw/checkpoints/`, the active slot is freed, and a new workflow can start immediately. Reason is required and audited.
|
|
12
|
+
- Typed blocking reasons for workflow lifecycle: `workflow_still_active` (starting over an active workflow now names `aiw workflow complete` **or** `aiw workflow abandon --reason=...` as the way out — previously a prose-only dead end found during field testing), `workflow_not_active`, and `workflow_complete`.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Release automation now actually releases: the Auto-tag workflow creates the version tag **and invokes the Release workflow directly** (reusable-workflow call). Previously the tag was created through the API with the default `GITHUB_TOKEN`, whose events do not trigger other workflows — so merging a version bump silently published nothing. This swallowed the v0.9.1 release (never published to npm) and delayed v0.10.0 until a manual tag re-push.
|
|
17
|
+
|
|
7
18
|
## [0.10.0] - 2026-10-30
|
|
8
19
|
|
|
9
20
|
### Added
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* command resolves the block it was printed for. See ADR-001 and
|
|
8
8
|
* docs/opportunities/OPP-004-exact-continuation-commands.md.
|
|
9
9
|
*/
|
|
10
|
-
export declare const BLOCKING_REASON_CODES: readonly ["gate_evidence_missing", "gate_incomplete", "stage_prerequisite_pending", "stage_prerequisite_unresolved", "stage_awaiting_approval", "stage_already_approved", "approval_evidence_missing", "approval_stale", "approval_gate_missing", "lane_escalation_required", "light_evidence_missing"];
|
|
10
|
+
export declare const BLOCKING_REASON_CODES: readonly ["gate_evidence_missing", "gate_incomplete", "stage_prerequisite_pending", "stage_prerequisite_unresolved", "stage_awaiting_approval", "stage_already_approved", "approval_evidence_missing", "approval_stale", "approval_gate_missing", "lane_escalation_required", "light_evidence_missing", "workflow_still_active", "workflow_not_active", "workflow_complete"];
|
|
11
11
|
export type BlockingReasonCode = (typeof BLOCKING_REASON_CODES)[number];
|
|
12
12
|
/** How the user or agent gets past a block. Exactly one of three kinds. */
|
|
13
13
|
export type BlockingContinuation =
|
package/dist/blocking-reasons.js
CHANGED
|
@@ -19,6 +19,9 @@ export const BLOCKING_REASON_CODES = [
|
|
|
19
19
|
"approval_gate_missing",
|
|
20
20
|
"lane_escalation_required",
|
|
21
21
|
"light_evidence_missing",
|
|
22
|
+
"workflow_still_active",
|
|
23
|
+
"workflow_not_active",
|
|
24
|
+
"workflow_complete",
|
|
22
25
|
];
|
|
23
26
|
function command(commandText) {
|
|
24
27
|
return { kind: "command", command: commandText };
|
|
@@ -112,6 +115,21 @@ export const BLOCKING_REASON_INVENTORY = {
|
|
|
112
115
|
description: "A light-lane workflow cannot complete without at least one recorded validation entry.",
|
|
113
116
|
continuation: () => action("Run `aiw task evidence --validation=<command and result>` at least once, then `aiw workflow complete`."),
|
|
114
117
|
},
|
|
118
|
+
workflow_still_active: {
|
|
119
|
+
code: "workflow_still_active",
|
|
120
|
+
description: "A workflow is already active; finish or archive it before starting another.",
|
|
121
|
+
continuation: () => action("Run `aiw workflow complete` once every stage resolves, or archive an obsolete one with `aiw workflow abandon --reason=<why>`."),
|
|
122
|
+
},
|
|
123
|
+
workflow_not_active: {
|
|
124
|
+
code: "workflow_not_active",
|
|
125
|
+
description: "No SDD workflow is active for this command to operate on.",
|
|
126
|
+
continuation: () => command("aiw workflow start"),
|
|
127
|
+
},
|
|
128
|
+
workflow_complete: {
|
|
129
|
+
code: "workflow_complete",
|
|
130
|
+
description: "This workflow is already complete; start a new workflow to continue working.",
|
|
131
|
+
continuation: () => command("aiw workflow start"),
|
|
132
|
+
},
|
|
115
133
|
};
|
|
116
134
|
export function getBlockingReasonDefinition(code) {
|
|
117
135
|
const definition = BLOCKING_REASON_INVENTORY[code];
|
package/dist/human-approval.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type ApprovalLedger = {
|
|
|
20
20
|
export type WorkflowSession = {
|
|
21
21
|
schema: 1;
|
|
22
22
|
workflowId: string;
|
|
23
|
-
status: "active" | "complete";
|
|
23
|
+
status: "active" | "complete" | "abandoned";
|
|
24
24
|
startedAt: string;
|
|
25
25
|
updatedAt: string;
|
|
26
26
|
ticket?: string;
|
|
@@ -32,6 +32,8 @@ export type WorkflowSession = {
|
|
|
32
32
|
/** Presence means the workflow escalated to the full lane (effective lane: full). */
|
|
33
33
|
laneEscalatedAt?: string;
|
|
34
34
|
laneEscalationReason?: string;
|
|
35
|
+
/** Set when the workflow is archived via `aiw workflow abandon`. */
|
|
36
|
+
abandonedReason?: string;
|
|
35
37
|
};
|
|
36
38
|
/** The lane that currently governs stage commands: escalation moves light → full. */
|
|
37
39
|
export declare function effectiveLane(session: WorkflowSession): "full" | "light";
|
package/dist/human-approval.js
CHANGED
|
@@ -42,6 +42,7 @@ const SESSION_KEYS = new Set([
|
|
|
42
42
|
"laneReason",
|
|
43
43
|
"laneEscalatedAt",
|
|
44
44
|
"laneEscalationReason",
|
|
45
|
+
"abandonedReason",
|
|
45
46
|
]);
|
|
46
47
|
export function createApprovalLedger(workflowId, options = {}) {
|
|
47
48
|
assertWorkflowId(workflowId);
|
|
@@ -72,8 +73,8 @@ export function parseWorkflowSession(content) {
|
|
|
72
73
|
throw new Error(`Workflow session has unknown field: ${key}.`);
|
|
73
74
|
if (typeof value.workflowId !== "string")
|
|
74
75
|
throw new Error("Workflow session requires a workflowId.");
|
|
75
|
-
if (value.status !== "active" && value.status !== "complete")
|
|
76
|
-
throw new Error("Workflow session status must be active or
|
|
76
|
+
if (value.status !== "active" && value.status !== "complete" && value.status !== "abandoned")
|
|
77
|
+
throw new Error("Workflow session status must be active, complete, or abandoned.");
|
|
77
78
|
if (typeof value.startedAt !== "string" || typeof value.updatedAt !== "string")
|
|
78
79
|
throw new Error("Workflow session requires start and update timestamps.");
|
|
79
80
|
if (value.ticket !== undefined && typeof value.ticket !== "string")
|
|
@@ -96,6 +97,9 @@ export function parseWorkflowSession(content) {
|
|
|
96
97
|
const laneEscalationReason = value.laneEscalationReason;
|
|
97
98
|
if (laneEscalationReason !== undefined && typeof laneEscalationReason !== "string")
|
|
98
99
|
throw new Error("Workflow session lane escalation reason must be a string.");
|
|
100
|
+
const abandonedReason = value.abandonedReason;
|
|
101
|
+
if (abandonedReason !== undefined && typeof abandonedReason !== "string")
|
|
102
|
+
throw new Error("Workflow session abandonment reason must be a string.");
|
|
99
103
|
const session = {
|
|
100
104
|
schema: 1,
|
|
101
105
|
workflowId: value.workflowId,
|
|
@@ -108,6 +112,7 @@ export function parseWorkflowSession(content) {
|
|
|
108
112
|
...(laneReason === undefined ? {} : { laneReason }),
|
|
109
113
|
...(laneEscalatedAt === undefined ? {} : { laneEscalatedAt }),
|
|
110
114
|
...(laneEscalationReason === undefined ? {} : { laneEscalationReason }),
|
|
115
|
+
...(abandonedReason === undefined ? {} : { abandonedReason }),
|
|
111
116
|
};
|
|
112
117
|
validateWorkflowSession(session);
|
|
113
118
|
return session;
|
|
@@ -266,8 +271,10 @@ function validateWorkflowSession(session) {
|
|
|
266
271
|
if (session.schema !== 1)
|
|
267
272
|
throw new Error("Workflow session schema must be version 1.");
|
|
268
273
|
assertWorkflowId(session.workflowId);
|
|
269
|
-
if (session.status !== "active" &&
|
|
270
|
-
|
|
274
|
+
if (session.status !== "active" &&
|
|
275
|
+
session.status !== "complete" &&
|
|
276
|
+
session.status !== "abandoned")
|
|
277
|
+
throw new Error("Workflow session status must be active, complete, or abandoned.");
|
|
271
278
|
assertTimestamp(session.startedAt);
|
|
272
279
|
assertTimestamp(session.updatedAt);
|
|
273
280
|
if (session.lane !== undefined && session.lane !== "light")
|
|
@@ -281,6 +288,8 @@ function validateWorkflowSession(session) {
|
|
|
281
288
|
if (!session.laneEscalationReason)
|
|
282
289
|
throw new Error("Lane escalation requires a reason.");
|
|
283
290
|
}
|
|
291
|
+
if (session.status === "abandoned" && !session.abandonedReason)
|
|
292
|
+
throw new Error("Abandoned sessions require an abandonment reason.");
|
|
284
293
|
}
|
|
285
294
|
function withStage(ledger, stage, record) {
|
|
286
295
|
return {
|
package/dist/workflow.js
CHANGED
|
@@ -79,10 +79,9 @@ function beginWorkflowSession(fs, aiw, options) {
|
|
|
79
79
|
if (fs.exists(sessionPath)) {
|
|
80
80
|
const previous = parseWorkflowSession(fs.read(sessionPath));
|
|
81
81
|
if (previous.status === "active")
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
};
|
|
82
|
+
throw new BlockingError(describeBlockingReason("workflow_still_active", {
|
|
83
|
+
detail: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
|
|
84
|
+
}));
|
|
86
85
|
if (!fs.exists(ledgerPath))
|
|
87
86
|
return { exitCode: 1, error: "Completed workflow is missing its approval ledger." };
|
|
88
87
|
const archive = join(aiw, `checkpoints/workflow-${previous.workflowId}-approvals.yml`);
|
|
@@ -307,7 +306,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
307
306
|
const action = args[1];
|
|
308
307
|
const sessionPath = join(aiw, "workflow.yml");
|
|
309
308
|
if (action === "start") {
|
|
310
|
-
const options = parseNamedOptions(args.slice(2), ["id"]);
|
|
309
|
+
const options = parseNamedOptions(args.slice(2).filter((argument) => argument !== "--json"), ["id"]);
|
|
311
310
|
if (!options)
|
|
312
311
|
return { exitCode: 1, error: "Usage: aiw workflow start [--id=workflow-id]" };
|
|
313
312
|
return beginWorkflowSession(fs, aiw, { id: options.id });
|
|
@@ -322,6 +321,46 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
322
321
|
output: `Workflow ${session.workflowId} (${session.status})\n${stages.join("\n")}\n${workflowNextAction(ledger)}`,
|
|
323
322
|
};
|
|
324
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
|
+
}
|
|
325
364
|
if (action === "complete") {
|
|
326
365
|
const { session, ledger } = readActiveWorkflow(aiw, fs);
|
|
327
366
|
if (effectiveLane(session) === "light") {
|
|
@@ -2704,7 +2743,9 @@ function readWorkflowState(aiw, fs, requireActive = true) {
|
|
|
2704
2743
|
if (session.workflowId !== ledger.workflowId)
|
|
2705
2744
|
throw new Error("Workflow session and approval ledger IDs do not match; run doctor.");
|
|
2706
2745
|
if (requireActive && session.status !== "active")
|
|
2707
|
-
throw new
|
|
2746
|
+
throw new BlockingError(describeBlockingReason("workflow_complete", {
|
|
2747
|
+
detail: `Workflow ${session.workflowId} is complete; start a new workflow first.`,
|
|
2748
|
+
}));
|
|
2708
2749
|
const changed = APPROVAL_STAGES.find((stage) => {
|
|
2709
2750
|
const record = ledger.stages[stage];
|
|
2710
2751
|
if (!record?.artifactHash || !["pending-human", "approved"].includes(record.status))
|
package/docs/architecture.md
CHANGED
|
@@ -45,7 +45,7 @@ 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.
|
|
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
49
|
|
|
50
50
|
## Current flow
|
|
51
51
|
|
|
@@ -19,3 +19,6 @@ Every blocking `aiw` exit carries a stable reason code plus either an exact runn
|
|
|
19
19
|
| `approval_gate_missing` | No passing quality gate is recorded for this stage; gate it before approving. | runnable command |
|
|
20
20
|
| `lane_escalation_required` | Light-lane workflows do not author stage artifacts; escalate to the full lane to continue. | explicit action |
|
|
21
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 |
|