@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/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to this project are documented in this file. The format foll
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.10.0] - 2026-10-30
8
+
9
+ ### Added
10
+
11
+ - Typed blocking reasons with exact continuations (OPP-004): every workflow, gate, and approval blocking exit emits a stable reason code plus either an exact runnable `aiw` continuation or an explicit terminal statement. With `--json`, blocks return machine-readable `reason_code` and `next_command`/`next_action`/`terminal` fields. `docs/continuation-inventory.md` maps every code and is kept in parity with the registry by a CI test.
12
+ - `aiw risk assess` (OPP-002): read-only, content-based risk classification for working-tree or `--base-ref` changes. Six high-risk categories (data/irreversible, security, consumed contracts, concurrency, delivery/environment, untested regression) classify by what changed, never by size: documentation-only diffs are `passive`; size never decides. Deterministic and offline by default; `--escalate-item 1-6 --escalate-reason` raises and never lowers a tier; `risk_due: yes` names an exact runnable next command (`aiw.risk-assessment/v1` JSON envelope).
13
+ - Light lane (OPP-001): a human-chosen `aiw task start --lane=light --lane-reason=...` for small, understood work — the resume test decides (resumable from the request plus `git diff`). No durable spec/plan artifacts; validation evidence is recorded via `aiw task evidence` and completion requires at least one entry; the lane and rationale are written into the session and approval ledger for audit. Stage commands block with `lane_escalation_required`; `aiw task escalate --reason` moves the workflow to the full lane (one-way, evidence preserved). The full lane remains the default and unchanged.
14
+ - Emission ≠ usability policy and command ratchet (OPP-006): messages naming an `aiw` command must have a test that executes it. A ratchet test executes every command named by the generated skills and READMEs and fails on unknown commands.
15
+ - Friction benchmark (OPP-005): `scripts/friction-bench.mjs` drives packaged-CLI journeys and classifies every non-success exit as in-band / out-of-band / dead-end, executing printed continuations to verify they resolve. CI enforces a zero-dead-end budget; the release gate re-verifies budget and determinism. Baseline: in-band 5, out-of-band 5, dead-end 0.
16
+ - Adversarial review skills (OPP-003): `review-judge-a`, `review-judge-b`, and `review-refuter` ship in the self-hosting package. Two blind judges review independently; severe findings require a reproducing command or self-proving `file:line` citations and otherwise degrade to advisory; only corroborated severe findings block. Refuter command execution is permission-gated (default: evidence reading with an explicit no-command-executed marker, isolated directory, no network). Human acceptance of the review outcome is unchanged.
17
+ - Workflow proportionality documentation: [ADR-001](.context/adrs/ADR-001-odd-rdd-proportionality-scope.md) records the ODD/RDD analysis — six implemented opportunities plus advisory-only model-integration opportunities (OPP-007/008) deliberately deferred until field exposure.
18
+
19
+ ### Fixed
20
+
21
+ - `aiw risk assess` no longer truncates paths of tracked-modified files (the porcelain fixed-width column was destroyed by line trimming) and no longer flags `.aiw/` managed state (for example `lock.yml`) as concurrency risk.
22
+ - `aiw risk` without a subcommand now prints its own typed usage instead of the general command dump.
23
+
24
+ ## [0.9.1] - 2026-10-24
25
+
26
+ ### Fixed
27
+
28
+ - Workflow capabilities are always recommended: the seven SDD capabilities (brainstorming, ticket-driven-development, requirements-specification, implementation-planning, technical-design, code-review, verification) are marked universal and are recommended for any project, including scans with no detected signals. Previously a project without languages, frameworks, package manager, tests, linter, or CI received zero recommendations; stack-dependent capabilities still require their evidence.
29
+
30
+ ## [0.9.0] - 2026-10-21
31
+
7
32
  ## [0.8.0] - 2026-10-20
8
33
 
9
34
  ### Added
@@ -14,6 +39,8 @@ All notable changes to this project are documented in this file. The format foll
14
39
  - Cost attribution: `aiw cost-report` joins the usage sidecar with the traceability graph (`aiw trace`) to report tokens per verified requirement — the outcome-denominated cost metric — with attributed and unattributed tokens shown side by side. Sidecar entries accept `--requirement=REQ-###`, and `parseTraceabilityGraph` round-trips the serialized graph.
15
40
  - Model routing table: `aiw routing init|show|resolve --category=name` maintains a static, evidence-linked table at `.aiw/routing.yml`. Rules map task categories to models deterministically; each rule names its evidence, and the resolver reports its advisory level (`evidenced`, `unevidenced-rule`, `default-fallback`, `default-unset`). Routing is advisory only and never invokes a model.
16
41
  - Task-lifecycle token usage: `aiw task-usage [--workflow=id-or-ticket]` aggregates sidecar entries correlated by `--workflow` into one task's totals — input tokens (all billing tiers), output tokens, cache hit rate, per-stage breakdown with models, requirements touched, and harness attribution. Tokens are the model-invariant metric, so lifecycle totals stay comparable across model routing changes; `usage-sidecar record` accepts `--workflow` for correlation.
42
+ - Harness log import: `aiw usage-sidecar import --from=claude-code|codex|gemini|pi` turns local harness session logs into measured ledger entries with all billing tiers, model, and harness attribution. Claude Code imports deduplicate stream chunks by message id; Codex sessions are scoped to the project via `session_meta.cwd` with cached input separated from fresh; Gemini planner responses are imported without project scoping (transcripts record no working directory); Pi imports per-message usage with provider attribution and dedicated usage entries (for example cache warming), scoped by project directory. Import is idempotent via stable per-event identities, so reruns never double-count. Sidecar entries and reports now attribute tokens by provider alongside model and harness, and the docs ship a harness support matrix declaring per-harness capabilities (with Pi recommended as the fullest experience).
43
+ - Session heartbeat: `aiw heartbeat` records beats from harness hooks (`aiw hooks install --target=claude-code` wires Stop and UserPromptSubmit) and decides by idle gap — inside the warn window (default 4 min) it saves a compact checkpoint while the provider cache is still warm; past the TTL it saves one so the next gap resumes cheap. No timers or keep-alive pings: the gap is measured on the next beat, guaranteeing a session never pays the full-price prefix rebuild twice in a row. Thresholds are configurable, state lives in `.aiw/heartbeats.yml`, and hook installation is project-scoped, merge-safe, and idempotent.
17
44
 
18
45
  ## [0.7.0] - 2026-10-13
19
46
 
package/README.md CHANGED
@@ -14,6 +14,7 @@ AI-assisted development often relies on implicit prompts, duplicated configurati
14
14
  - skills, rules, agents, hooks, and templates are versioned resources;
15
15
  - target-specific files are generated through adapters;
16
16
  - quality gates and checkpoints provide completion evidence;
17
+ - ceremony scales with the consequence of the change — risk-based lanes instead of uniform ceremony;
17
18
  - context is filtered and loaded only when needed to reduce token usage;
18
19
  - package-declared sensitive capabilities are evaluated by policy boundaries.
19
20
 
@@ -114,7 +115,7 @@ flowchart TD
114
115
 
115
116
  ## The SDD workflow
116
117
 
117
- AI Workflow supports an end-to-end specification-driven development cycle:
118
+ AI Workflow supports an end-to-end specification-driven development cycle. Ceremony is proportional: `aiw risk assess` classifies each change by content, and small, understood work may run in a human-chosen light lane (evidence entries instead of durable artifacts) while substantial work runs the staged sequence below. The current end-to-end flow, both lanes, and the CI enforcement layer are diagrammed in [Architecture — Current flow](docs/architecture.md#current-flow).
118
119
 
119
120
  ```mermaid
120
121
  flowchart TD
@@ -205,6 +206,16 @@ npx @multileaf/ai-workflow recommend --select=typescript-quality,verification
205
206
  # Track a feature from its first artifact through final human approval.
206
207
  npx @multileaf/ai-workflow workflow start
207
208
 
209
+ # Small, understood change? The light lane skips durable spec/plan artifacts
210
+ # (human-chosen via the resume test) and records validation evidence instead.
211
+ npx @multileaf/ai-workflow task start --ticket=TEAM-7 --lane=light --lane-reason="small fix, resumable from request"
212
+ npx @multileaf/ai-workflow task evidence --validation="npm test: passed"
213
+ npx @multileaf/ai-workflow workflow complete
214
+ # If scope grows: aiw task escalate --reason=...
215
+
216
+ # Classify the risk of current changes (read-only; content, not size).
217
+ npx @multileaf/ai-workflow risk assess [--base-ref=<ref>] [--escalate-item=1-6 --escalate-reason=text] [--json]
218
+
208
219
  # Or start directly from a ticket / already-clear requirement (no brainstorm needed).
209
220
  npx @multileaf/ai-workflow task start --ticket=PROJ-142 --title="Add export endpoint"
210
221
  npx @multileaf/ai-workflow task status
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Typed reason codes and exact continuations for every blocking CLI exit.
3
+ *
4
+ * Contract (OPP-004, REQ-OPP-004): a blocking output must carry a stable
5
+ * reason code plus either an exact runnable continuation or an explicit
6
+ * terminal statement. A message may name a command only when running that
7
+ * command resolves the block it was printed for. See ADR-001 and
8
+ * docs/opportunities/OPP-004-exact-continuation-commands.md.
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"];
11
+ export type BlockingReasonCode = (typeof BLOCKING_REASON_CODES)[number];
12
+ /** How the user or agent gets past a block. Exactly one of three kinds. */
13
+ export type BlockingContinuation =
14
+ /** An exact runnable `aiw` command with every argument resolved. */
15
+ {
16
+ kind: "command";
17
+ command: string;
18
+ }
19
+ /** A concrete non-command step (for example, authoring an artifact). */
20
+ | {
21
+ kind: "action";
22
+ description: string;
23
+ }
24
+ /** No in-line continuation exists; a human decision is required. */
25
+ | {
26
+ kind: "terminal";
27
+ humanDecision: string;
28
+ };
29
+ /** Resolved context used by continuation producers. */
30
+ export type BlockingContext = {
31
+ stage?: string;
32
+ pendingStages?: string[];
33
+ unresolvedStages?: string[];
34
+ artifactPath?: string;
35
+ gateIssues?: string[];
36
+ /** Context-specific human message preferred over the generic definition text. */
37
+ detail?: string;
38
+ };
39
+ export type BlockingReasonDefinition = {
40
+ code: BlockingReasonCode;
41
+ description: string;
42
+ continuation: (context: BlockingContext) => BlockingContinuation;
43
+ };
44
+ export declare const BLOCKING_REASON_INVENTORY: Readonly<Record<BlockingReasonCode, BlockingReasonDefinition>>;
45
+ export declare function getBlockingReasonDefinition(code: string): BlockingReasonDefinition;
46
+ export type BlockingResult = {
47
+ code: BlockingReasonCode;
48
+ /** Explanation of the block without continuation instructions. */
49
+ message: string;
50
+ continuation: BlockingContinuation;
51
+ };
52
+ export declare function describeBlockingReason(code: BlockingReasonCode, context: BlockingContext): BlockingResult;
53
+ /** Error thrown by blocking CLI paths; carries the typed result for `--json` output. */
54
+ export declare class BlockingError extends Error {
55
+ readonly result: BlockingResult;
56
+ constructor(result: BlockingResult);
57
+ }
58
+ export declare function formatBlockingOutput(result: BlockingResult): string;
59
+ export type BlockingJson = {
60
+ reason_code: BlockingReasonCode;
61
+ next_command?: string;
62
+ next_action?: string;
63
+ terminal?: boolean;
64
+ };
65
+ export declare function blockingResultToJson(result: BlockingResult): BlockingJson;
66
+ /** Markdown continuation inventory for docs generation and CI parity checks. */
67
+ export declare function exportContinuationInventoryMarkdown(): string;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Typed reason codes and exact continuations for every blocking CLI exit.
3
+ *
4
+ * Contract (OPP-004, REQ-OPP-004): a blocking output must carry a stable
5
+ * reason code plus either an exact runnable continuation or an explicit
6
+ * terminal statement. A message may name a command only when running that
7
+ * command resolves the block it was printed for. See ADR-001 and
8
+ * docs/opportunities/OPP-004-exact-continuation-commands.md.
9
+ */
10
+ export const BLOCKING_REASON_CODES = [
11
+ "gate_evidence_missing",
12
+ "gate_incomplete",
13
+ "stage_prerequisite_pending",
14
+ "stage_prerequisite_unresolved",
15
+ "stage_awaiting_approval",
16
+ "stage_already_approved",
17
+ "approval_evidence_missing",
18
+ "approval_stale",
19
+ "approval_gate_missing",
20
+ "lane_escalation_required",
21
+ "light_evidence_missing",
22
+ ];
23
+ function command(commandText) {
24
+ return { kind: "command", command: commandText };
25
+ }
26
+ function action(description) {
27
+ return { kind: "action", description };
28
+ }
29
+ function terminal(humanDecision) {
30
+ return { kind: "terminal", humanDecision };
31
+ }
32
+ function firstStage(stages) {
33
+ return stages && stages.length > 0 ? stages[0] : undefined;
34
+ }
35
+ export const BLOCKING_REASON_INVENTORY = {
36
+ gate_evidence_missing: {
37
+ code: "gate_evidence_missing",
38
+ description: "The quality gate is blocked because the required evidence artifact is missing.",
39
+ continuation: (context) => context.stage && context.artifactPath
40
+ ? action(`Create the required artifact at ${context.artifactPath}, then run \`aiw gate ${context.stage}\`.`)
41
+ : terminal("Decide which stage artifact to author before gating."),
42
+ },
43
+ gate_incomplete: {
44
+ code: "gate_incomplete",
45
+ description: "The quality gate found completeness issues in the stage artifact.",
46
+ continuation: (context) => context.stage
47
+ ? action(`Fix the gate findings in the stage artifact${context.artifactPath ? ` (${context.artifactPath})` : ""}, then run \`aiw gate ${context.stage}\` again.`)
48
+ : terminal("Fix the gate findings, then rerun the stage gate."),
49
+ },
50
+ stage_prerequisite_pending: {
51
+ code: "stage_prerequisite_pending",
52
+ description: "A previous stage is awaiting human approval before this stage can start.",
53
+ continuation: (context) => {
54
+ const pending = firstStage(context.pendingStages);
55
+ return pending
56
+ ? command(`aiw approve ${pending}`)
57
+ : terminal("Review the pending previous stage with a human.");
58
+ },
59
+ },
60
+ stage_prerequisite_unresolved: {
61
+ code: "stage_prerequisite_unresolved",
62
+ description: "Previous stages need human approval or an accepted optional skip before this stage can start.",
63
+ continuation: (context) => {
64
+ const unresolved = firstStage(context.unresolvedStages);
65
+ return unresolved
66
+ ? command(`aiw approve ${unresolved}`)
67
+ : terminal("Resolve the previous stages with a human before continuing.");
68
+ },
69
+ },
70
+ stage_awaiting_approval: {
71
+ code: "stage_awaiting_approval",
72
+ description: "This stage is awaiting human approval; approve or reject it before continuing.",
73
+ continuation: (context) => context.stage
74
+ ? command(`aiw approve ${context.stage}`)
75
+ : terminal("A human must approve or reject the pending stage."),
76
+ },
77
+ stage_already_approved: {
78
+ code: "stage_already_approved",
79
+ description: "This stage is already approved; changed evidence requires rerunning its quality gate.",
80
+ continuation: (context) => context.stage
81
+ ? command(`aiw gate ${context.stage}`)
82
+ : terminal("A human must decide whether to rerun the gate for the changed evidence."),
83
+ },
84
+ approval_evidence_missing: {
85
+ code: "approval_evidence_missing",
86
+ description: "Approval cannot be recorded because the stage evidence artifact is missing.",
87
+ continuation: (context) => context.stage && context.artifactPath
88
+ ? action(`Create the required artifact at ${context.artifactPath}, then run \`aiw approve ${context.stage}\`.`)
89
+ : terminal("Decide which stage artifact to author before approving."),
90
+ },
91
+ approval_stale: {
92
+ code: "approval_stale",
93
+ description: "The artifact changed after review; its approval is stale and the gate must run again.",
94
+ continuation: (context) => context.stage
95
+ ? command(`aiw gate ${context.stage}`)
96
+ : terminal("A human must decide whether to rerun the gate for the changed artifact."),
97
+ },
98
+ approval_gate_missing: {
99
+ code: "approval_gate_missing",
100
+ description: "No passing quality gate is recorded for this stage; gate it before approving.",
101
+ continuation: (context) => context.stage
102
+ ? command(`aiw gate ${context.stage}`)
103
+ : terminal("A human must decide whether to gate the stage before approving."),
104
+ },
105
+ lane_escalation_required: {
106
+ code: "lane_escalation_required",
107
+ description: "Light-lane workflows do not author stage artifacts; escalate to the full lane to continue.",
108
+ continuation: () => action("Run `aiw task escalate --reason=<why scope grew>` to move this workflow to the full lane; recorded light-lane evidence is preserved."),
109
+ },
110
+ light_evidence_missing: {
111
+ code: "light_evidence_missing",
112
+ description: "A light-lane workflow cannot complete without at least one recorded validation entry.",
113
+ continuation: () => action("Run `aiw task evidence --validation=<command and result>` at least once, then `aiw workflow complete`."),
114
+ },
115
+ };
116
+ export function getBlockingReasonDefinition(code) {
117
+ const definition = BLOCKING_REASON_INVENTORY[code];
118
+ if (!definition)
119
+ throw new Error(`Unknown blocking reason code: ${code}`);
120
+ return definition;
121
+ }
122
+ export function describeBlockingReason(code, context) {
123
+ const definition = getBlockingReasonDefinition(code);
124
+ return {
125
+ code,
126
+ message: context.detail ?? definition.description,
127
+ continuation: definition.continuation(context),
128
+ };
129
+ }
130
+ /** Error thrown by blocking CLI paths; carries the typed result for `--json` output. */
131
+ export class BlockingError extends Error {
132
+ result;
133
+ constructor(result) {
134
+ super(formatBlockingOutput(result));
135
+ this.name = "BlockingError";
136
+ this.result = result;
137
+ }
138
+ }
139
+ export function formatBlockingOutput(result) {
140
+ const prefix = `${result.message} [reason: ${result.code}]`;
141
+ switch (result.continuation.kind) {
142
+ case "command":
143
+ return `${prefix} Next: run \`${result.continuation.command}\`.`;
144
+ case "action":
145
+ return `${prefix} Next: ${result.continuation.description}`;
146
+ case "terminal":
147
+ return `${prefix} Terminal: no automatic continuation. ${result.continuation.humanDecision}`;
148
+ }
149
+ }
150
+ export function blockingResultToJson(result) {
151
+ switch (result.continuation.kind) {
152
+ case "command":
153
+ return { reason_code: result.code, next_command: result.continuation.command };
154
+ case "action":
155
+ return { reason_code: result.code, next_action: result.continuation.description };
156
+ case "terminal":
157
+ return { reason_code: result.code, terminal: true };
158
+ }
159
+ }
160
+ const CONTINUATION_KIND_LABEL = {
161
+ command: "runnable command",
162
+ action: "explicit action",
163
+ terminal: "terminal (human decision)",
164
+ };
165
+ /** Markdown continuation inventory for docs generation and CI parity checks. */
166
+ export function exportContinuationInventoryMarkdown() {
167
+ const rows = BLOCKING_REASON_CODES.map((code) => {
168
+ const definition = getBlockingReasonDefinition(code);
169
+ return `| \`${code}\` | ${definition.description} | ${CONTINUATION_KIND_LABEL[commandKind(code)]} |`;
170
+ });
171
+ return ["| Reason code | Description | Continuation |", "| --- | --- | --- |", ...rows].join("\n");
172
+ }
173
+ function commandKind(code) {
174
+ const sample = describeBlockingReason(code, {
175
+ stage: "specification",
176
+ pendingStages: ["brainstorming"],
177
+ unresolvedStages: ["brainstorming"],
178
+ artifactPath: ".aiw/generated/specs/specification.md",
179
+ });
180
+ return sample.continuation.kind;
181
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Emission ≠ usability enforcement (OPP-006, REQ-OPP-006).
3
+ *
4
+ * A test that verifies a message was emitted does not verify it is usable.
5
+ * Any user-facing artifact (docs, generated skills, blocking output) that
6
+ * names an `aiw` command must be covered by a test that proves the command
7
+ * is real — the source lesson: a shipped message named a command nobody had
8
+ * ever run, for months, with green coverage.
9
+ *
10
+ * This helper extracts `aiw ...` and `npx @multileaf/ai-workflow ...`
11
+ * invocations from artifact text so tests can execute them.
12
+ */
13
+ export type CommandMention = {
14
+ /** Normalized invocation: command and subcommand words only, no flags. */
15
+ command: string;
16
+ /** Source label for diagnostics. */
17
+ source: string;
18
+ };
19
+ /** Extract normalized command mentions (command + optional subcommand, no flags). */
20
+ export declare function extractAiwCommands(text: string, source?: string): CommandMention[];
21
+ /**
22
+ * Informational mentions excluded from execution evidence. Every entry needs
23
+ * a justification; entries are exact normalized commands.
24
+ */
25
+ export declare const INFORMATIONAL_ALLOWLIST: readonly {
26
+ command: string;
27
+ justification: string;
28
+ }[];
29
+ /** First token (the command word) used for recognition checks. */
30
+ export declare function commandWord(mention: CommandMention): string;
31
+ /** Signature of the general usage dump printed for unknown commands. */
32
+ export declare const UNKNOWN_COMMAND_SIGNATURE = "aiw install [--target target] | workflow";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Emission ≠ usability enforcement (OPP-006, REQ-OPP-006).
3
+ *
4
+ * A test that verifies a message was emitted does not verify it is usable.
5
+ * Any user-facing artifact (docs, generated skills, blocking output) that
6
+ * names an `aiw` command must be covered by a test that proves the command
7
+ * is real — the source lesson: a shipped message named a command nobody had
8
+ * ever run, for months, with green coverage.
9
+ *
10
+ * This helper extracts `aiw ...` and `npx @multileaf/ai-workflow ...`
11
+ * invocations from artifact text so tests can execute them.
12
+ */
13
+ const INVOCATION_PATTERN = /(^|`|\n)(?:aiw|npx @multileaf\/ai-workflow) ([a-z][a-z-]*)(?: ([a-z][a-z-]*))?/g;
14
+ /** English words that must never be captured as a subcommand. */
15
+ const STOP_WORDS = new Set([
16
+ "and",
17
+ "or",
18
+ "the",
19
+ "a",
20
+ "to",
21
+ "then",
22
+ "is",
23
+ "in",
24
+ "with",
25
+ "before",
26
+ "after",
27
+ "you",
28
+ "see",
29
+ "for",
30
+ "that",
31
+ "helps",
32
+ "tool",
33
+ ]);
34
+ /** Extract normalized command mentions (command + optional subcommand, no flags). */
35
+ export function extractAiwCommands(text, source = "text") {
36
+ const mentions = [];
37
+ for (const match of text.matchAll(INVOCATION_PATTERN)) {
38
+ const subcommand = match[3] && !STOP_WORDS.has(match[3]) ? match[3] : undefined;
39
+ const command = subcommand ? `${match[2]} ${subcommand}` : match[2];
40
+ if (!mentions.some((mention) => mention.command === command && mention.source === source)) {
41
+ mentions.push({ command, source });
42
+ }
43
+ }
44
+ return mentions;
45
+ }
46
+ /**
47
+ * Informational mentions excluded from execution evidence. Every entry needs
48
+ * a justification; entries are exact normalized commands.
49
+ */
50
+ export const INFORMATIONAL_ALLOWLIST = [
51
+ {
52
+ command: "aiw install",
53
+ justification: "Executed by the install e2e suite; README form includes --target variants.",
54
+ },
55
+ ];
56
+ /** First token (the command word) used for recognition checks. */
57
+ export function commandWord(mention) {
58
+ return mention.command.split(" ")[0];
59
+ }
60
+ /** Signature of the general usage dump printed for unknown commands. */
61
+ export const UNKNOWN_COMMAND_SIGNATURE = "aiw install [--target target] | workflow";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Session heartbeat: event-driven cache-TTL management.
3
+ *
4
+ * Harness hooks (Claude Code Stop/UserPromptSubmit, Gemini
5
+ * PostInvocation/Stop, Codex turn notifications) call `aiw heartbeat` on
6
+ * every event. Each beat measures the gap since the previous one:
7
+ *
8
+ * - gap < warn (default 4 min): active session, nothing to do;
9
+ * - warn <= gap < ttl (default 5 min): the provider cache is still warm —
10
+ * this request just read it at cache price — so a compact checkpoint is
11
+ * written now: if the session goes idle past the TTL, the next resume
12
+ * loads a small prefix instead of rebuilding the full one;
13
+ * - gap >= ttl: the cache already expired and this request paid the full
14
+ * rebuild — a compact checkpoint is written so the *next* gap hurts an
15
+ * order of magnitude less.
16
+ *
17
+ * The guarantee: a session never pays the full-price prefix rebuild twice
18
+ * in a row on the same context. No timers, no keep-alive pings: the gap is
19
+ * always measured on the next beat, which costs nothing locally.
20
+ *
21
+ * Timestamps are legitimate here: this is operational state (like workflow
22
+ * checkpoints), not a deterministic report.
23
+ */
24
+ export declare const HEARTBEAT_SCHEMA = 1;
25
+ export declare const HEARTBEAT_STATE_PATH = "heartbeats.yml";
26
+ export declare const HEARTBEAT_WARN_SECONDS = 240;
27
+ export declare const HEARTBEAT_TTL_SECONDS = 300;
28
+ export type HeartbeatDecision = "session-start" | "active" | "compact-before-expiry" | "cache-expired";
29
+ export type HeartbeatSession = {
30
+ id: string;
31
+ lastBeat: string;
32
+ beats: number;
33
+ status: "warm" | "cold";
34
+ actions: number;
35
+ };
36
+ export type HeartbeatState = {
37
+ schema: number;
38
+ sessions: HeartbeatSession[];
39
+ };
40
+ export type HeartbeatOutcome = {
41
+ decision: HeartbeatDecision;
42
+ gapSeconds: number | undefined;
43
+ session: HeartbeatSession;
44
+ /** Set when a compact checkpoint should be written (decision tier warn/ttl). */
45
+ checkpoint: {
46
+ session: string;
47
+ index: number;
48
+ decision: HeartbeatDecision;
49
+ } | undefined;
50
+ };
51
+ export declare function validateHeartbeatThresholds(warnSeconds: number, ttlSeconds: number): void;
52
+ export declare function emptyHeartbeatState(): HeartbeatState;
53
+ export declare function parseHeartbeatState(content: string): HeartbeatState;
54
+ export declare function serializeHeartbeatState(state: HeartbeatState): string;
55
+ /** Record a beat and decide. `now` is injectable for deterministic tests. */
56
+ export declare function recordBeat(state: HeartbeatState, sessionId: string, now: Date, warnSeconds?: number, ttlSeconds?: number): {
57
+ state: HeartbeatState;
58
+ outcome: HeartbeatOutcome;
59
+ };
60
+ export declare function serializeHeartbeatCheckpoint(checkpoint: NonNullable<HeartbeatOutcome["checkpoint"]>, gapSeconds: number, ttlSeconds: number): string;
61
+ export declare function describeHeartbeatOutcome(outcome: HeartbeatOutcome): string;
@@ -0,0 +1,158 @@
1
+ import { parseDocument } from "yaml";
2
+ /**
3
+ * Session heartbeat: event-driven cache-TTL management.
4
+ *
5
+ * Harness hooks (Claude Code Stop/UserPromptSubmit, Gemini
6
+ * PostInvocation/Stop, Codex turn notifications) call `aiw heartbeat` on
7
+ * every event. Each beat measures the gap since the previous one:
8
+ *
9
+ * - gap < warn (default 4 min): active session, nothing to do;
10
+ * - warn <= gap < ttl (default 5 min): the provider cache is still warm —
11
+ * this request just read it at cache price — so a compact checkpoint is
12
+ * written now: if the session goes idle past the TTL, the next resume
13
+ * loads a small prefix instead of rebuilding the full one;
14
+ * - gap >= ttl: the cache already expired and this request paid the full
15
+ * rebuild — a compact checkpoint is written so the *next* gap hurts an
16
+ * order of magnitude less.
17
+ *
18
+ * The guarantee: a session never pays the full-price prefix rebuild twice
19
+ * in a row on the same context. No timers, no keep-alive pings: the gap is
20
+ * always measured on the next beat, which costs nothing locally.
21
+ *
22
+ * Timestamps are legitimate here: this is operational state (like workflow
23
+ * checkpoints), not a deterministic report.
24
+ */
25
+ export const HEARTBEAT_SCHEMA = 1;
26
+ export const HEARTBEAT_STATE_PATH = "heartbeats.yml";
27
+ export const HEARTBEAT_WARN_SECONDS = 240;
28
+ export const HEARTBEAT_TTL_SECONDS = 300;
29
+ export function validateHeartbeatThresholds(warnSeconds, ttlSeconds) {
30
+ for (const [name, value] of [
31
+ ["warn", warnSeconds],
32
+ ["ttl", ttlSeconds],
33
+ ])
34
+ if (!Number.isInteger(value) || value <= 0)
35
+ throw new Error(`Heartbeat ${name} threshold must be a positive integer (seconds).`);
36
+ if (warnSeconds >= ttlSeconds)
37
+ throw new Error("Heartbeat warn threshold must be smaller than the TTL threshold.");
38
+ }
39
+ export function emptyHeartbeatState() {
40
+ return { schema: HEARTBEAT_SCHEMA, sessions: [] };
41
+ }
42
+ export function parseHeartbeatState(content) {
43
+ if (!content.trim())
44
+ return emptyHeartbeatState();
45
+ const document = parseDocument(content);
46
+ if (document.errors.length)
47
+ throw new Error("Heartbeat state is not valid YAML.");
48
+ const schema = document.get("schema");
49
+ if (schema !== HEARTBEAT_SCHEMA)
50
+ throw new Error(`Heartbeat state schema must be version ${HEARTBEAT_SCHEMA}.`);
51
+ const sessionsNode = document.get("sessions");
52
+ const sessions = [];
53
+ if (sessionsNode !== null && sessionsNode !== undefined) {
54
+ const items = Array.isArray(sessionsNode)
55
+ ? sessionsNode
56
+ : (sessionsNode.items ?? []);
57
+ for (const raw of items) {
58
+ const read = (key) => typeof raw.get === "function" ? raw.get(key) : raw[key];
59
+ const id = String(read("id"));
60
+ if (!id.trim())
61
+ throw new Error("Heartbeat session id must not be empty.");
62
+ const lastBeat = String(read("last_beat"));
63
+ if (Number.isNaN(Date.parse(lastBeat)))
64
+ throw new Error(`Heartbeat session ${id} has an invalid last_beat timestamp.`);
65
+ sessions.push({
66
+ id,
67
+ lastBeat,
68
+ beats: Number(read("beats")),
69
+ status: read("status") === "cold" ? "cold" : "warm",
70
+ actions: Number(read("actions") ?? 0),
71
+ });
72
+ }
73
+ }
74
+ return { schema: HEARTBEAT_SCHEMA, sessions };
75
+ }
76
+ export function serializeHeartbeatState(state) {
77
+ const lines = [`schema: ${state.schema}`, "sessions:"];
78
+ if (!state.sessions.length)
79
+ lines.push(" []");
80
+ for (const session of state.sessions)
81
+ lines.push(` - id: ${session.id}`, ` last_beat: ${session.lastBeat}`, ` beats: ${session.beats}`, ` status: ${session.status}`, ` actions: ${session.actions}`);
82
+ return lines.join("\n") + "\n";
83
+ }
84
+ /** Record a beat and decide. `now` is injectable for deterministic tests. */
85
+ export function recordBeat(state, sessionId, now, warnSeconds = HEARTBEAT_WARN_SECONDS, ttlSeconds = HEARTBEAT_TTL_SECONDS) {
86
+ if (!sessionId.trim())
87
+ throw new Error("Heartbeat session id must not be empty.");
88
+ validateHeartbeatThresholds(warnSeconds, ttlSeconds);
89
+ const iso = now.toISOString();
90
+ const previous = state.sessions.find((session) => session.id === sessionId);
91
+ let decision;
92
+ let gapSeconds;
93
+ if (!previous) {
94
+ decision = "session-start";
95
+ }
96
+ else {
97
+ gapSeconds = Math.max(0, Math.round((now.getTime() - Date.parse(previous.lastBeat)) / 1000));
98
+ decision =
99
+ gapSeconds < warnSeconds
100
+ ? "active"
101
+ : gapSeconds < ttlSeconds
102
+ ? "compact-before-expiry"
103
+ : "cache-expired";
104
+ }
105
+ const updated = {
106
+ id: sessionId,
107
+ lastBeat: iso,
108
+ beats: (previous?.beats ?? 0) + 1,
109
+ status: decision === "cache-expired" ? "cold" : "warm",
110
+ actions: previous?.actions ?? 0,
111
+ };
112
+ const shouldCheckpoint = decision === "compact-before-expiry" || decision === "cache-expired";
113
+ if (shouldCheckpoint)
114
+ updated.actions += 1;
115
+ const sessions = state.sessions.filter((session) => session.id !== sessionId).concat(updated);
116
+ sessions.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
117
+ return {
118
+ state: { schema: state.schema, sessions },
119
+ outcome: {
120
+ decision,
121
+ gapSeconds,
122
+ session: updated,
123
+ checkpoint: shouldCheckpoint
124
+ ? { session: sessionId, index: updated.actions, decision }
125
+ : undefined,
126
+ },
127
+ };
128
+ }
129
+ export function serializeHeartbeatCheckpoint(checkpoint, gapSeconds, ttlSeconds) {
130
+ return ([
131
+ "schema: 1",
132
+ `session: ${checkpoint.session}`,
133
+ `decision: ${checkpoint.decision}`,
134
+ `gap_seconds: ${gapSeconds}`,
135
+ `ttl_seconds: ${ttlSeconds}`,
136
+ "action: resume from this checkpoint; prefer harness compaction (/compact) before continuing the session",
137
+ ].join("\n") + "\n");
138
+ }
139
+ export function describeHeartbeatOutcome(outcome) {
140
+ const gap = outcome.gapSeconds === undefined ? "" : ` (gap ${formatGap(outcome.gapSeconds)})`;
141
+ switch (outcome.decision) {
142
+ case "session-start":
143
+ return `heartbeat: session ${outcome.session.id} started`;
144
+ case "active":
145
+ return `heartbeat: session ${outcome.session.id} active${gap}`;
146
+ case "compact-before-expiry":
147
+ return `heartbeat: session ${outcome.session.id} idle${gap} — cache still warm; compact checkpoint #${outcome.session.actions} saved for a cheap resume`;
148
+ case "cache-expired":
149
+ return `heartbeat: session ${outcome.session.id} idle${gap} — cache expired and this request paid the rebuild; compact checkpoint #${outcome.session.actions} saved so the next gap is cheap`;
150
+ }
151
+ }
152
+ function formatGap(seconds) {
153
+ if (seconds < 60)
154
+ return `${seconds}s`;
155
+ const minutes = Math.floor(seconds / 60);
156
+ const rest = seconds % 60;
157
+ return rest ? `${minutes}m${rest}s` : `${minutes}m`;
158
+ }