@multileaf/ai-workflow 0.8.0 → 0.9.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 CHANGED
@@ -4,6 +4,8 @@ All notable changes to this project are documented in this file. The format foll
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.0] - 2026-10-21
8
+
7
9
  ## [0.8.0] - 2026-10-20
8
10
 
9
11
  ### Added
@@ -14,6 +16,8 @@ All notable changes to this project are documented in this file. The format foll
14
16
  - 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
17
  - 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
18
  - 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.
19
+ - 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).
20
+ - 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
21
 
18
22
  ## [0.7.0] - 2026-10-13
19
23
 
@@ -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
+ }
@@ -0,0 +1,30 @@
1
+ import type { FileSystem } from "./types.js";
2
+ /**
3
+ * Claude Code hook installer: wires harness events to `aiw heartbeat`
4
+ * without a scheduler. Project-scoped: writes `.claude/settings.json` in
5
+ * the repository, never the user's global settings.
6
+ *
7
+ * Merge policy: existing hooks the project already declares are preserved
8
+ * untouched; AIW only appends its own entries, identified by the
9
+ * `aiw heartbeat` command, and skips them when already present
10
+ * (idempotent). Any JSON that is not a settings object is a hard conflict.
11
+ */
12
+ export declare const CLAUDE_HOOK_COMMAND = "npx --yes --package=@multileaf/ai-workflow -- aiw heartbeat";
13
+ export type ClaudeHookEntry = {
14
+ hooks: {
15
+ type: string;
16
+ command: string;
17
+ timeout?: number;
18
+ }[];
19
+ };
20
+ export type ClaudeSettings = {
21
+ hooks?: Record<string, ClaudeHookEntry[]>;
22
+ } & Record<string, unknown>;
23
+ export declare const AIW_HEARTBEAT_EVENTS: readonly ["Stop", "UserPromptSubmit"];
24
+ export declare function parseClaudeSettings(content: string): ClaudeSettings;
25
+ export declare function installHeartbeatHooks(settings: ClaudeSettings): {
26
+ settings: ClaudeSettings;
27
+ added: string[];
28
+ alreadyPresent: string[];
29
+ };
30
+ export declare function writeClaudeSettings(fs: FileSystem, path: string, settings: ClaudeSettings): void;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Claude Code hook installer: wires harness events to `aiw heartbeat`
3
+ * without a scheduler. Project-scoped: writes `.claude/settings.json` in
4
+ * the repository, never the user's global settings.
5
+ *
6
+ * Merge policy: existing hooks the project already declares are preserved
7
+ * untouched; AIW only appends its own entries, identified by the
8
+ * `aiw heartbeat` command, and skips them when already present
9
+ * (idempotent). Any JSON that is not a settings object is a hard conflict.
10
+ */
11
+ export const CLAUDE_HOOK_COMMAND = "npx --yes --package=@multileaf/ai-workflow -- aiw heartbeat";
12
+ export const AIW_HEARTBEAT_EVENTS = ["Stop", "UserPromptSubmit"];
13
+ export function parseClaudeSettings(content) {
14
+ if (!content.trim())
15
+ return {};
16
+ const parsed = JSON.parse(content);
17
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
18
+ throw new Error(".claude/settings.json must be a JSON object.");
19
+ const settings = parsed;
20
+ if (settings.hooks !== undefined) {
21
+ if (typeof settings.hooks !== "object" ||
22
+ settings.hooks === null ||
23
+ Array.isArray(settings.hooks))
24
+ throw new Error(".claude/settings.json hooks must be an object keyed by event name.");
25
+ for (const [event, entries] of Object.entries(settings.hooks))
26
+ if (!Array.isArray(entries))
27
+ throw new Error(`.claude/settings.json hooks.${event} must be a list.`);
28
+ }
29
+ return settings;
30
+ }
31
+ export function installHeartbeatHooks(settings) {
32
+ const hooks = { ...(settings.hooks ?? {}) };
33
+ const added = [];
34
+ const alreadyPresent = [];
35
+ for (const event of AIW_HEARTBEAT_EVENTS) {
36
+ const entries = hooks[event] ? [...hooks[event]] : [];
37
+ const exists = entries.some((entry) => entry.hooks?.some((hook) => hook.command === CLAUDE_HOOK_COMMAND));
38
+ if (exists)
39
+ alreadyPresent.push(event);
40
+ else {
41
+ entries.push({
42
+ hooks: [{ type: "command", command: CLAUDE_HOOK_COMMAND, timeout: 15 }],
43
+ });
44
+ added.push(event);
45
+ }
46
+ hooks[event] = entries;
47
+ }
48
+ return { settings: { ...settings, hooks }, added, alreadyPresent };
49
+ }
50
+ export function writeClaudeSettings(fs, path, settings) {
51
+ fs.write(path, `${JSON.stringify(settings, null, 2)}\n`);
52
+ }
@@ -0,0 +1,51 @@
1
+ import type { UsageSidecarEntry } from "./usage-sidecar.js";
2
+ /**
3
+ * Deterministic importers that turn local harness session logs into
4
+ * measured sidecar entries (the switchboard pattern: raw logs -> canonical
5
+ * ledger via per-source converters; no LLM, no network, no timestamps).
6
+ *
7
+ * Formats were validated against real logs:
8
+ * - Claude Code: the .jsonl transcripts under ~/.claude/projects/<munged-root>
9
+ * carry message.usage with all four tiers; stream chunks
10
+ * duplicate per message.id, so entries are deduplicated by message id.
11
+ * - Codex: the recursive rollout-*.jsonl tree under ~/.codex/sessions — token_usage_record entries; input_tokens includes the cached subset; session_meta.cwd
12
+ * scopes a session to this project.
13
+ * - Gemini (antigravity-cli): the transcript_full.jsonl logs under the ~/.gemini/antigravity-cli/brain sessions — PLANNER_RESPONSE entries with
14
+ * input/cache_read/output. Sessions do not record their working
15
+ * directory, so Gemini imports cannot be scoped per project.
16
+ *
17
+ * - Pi (pi.dev): the session JSONL tree under ~/.pi/agent/sessions, grouped
18
+ * by project directory. Assistant messages carry per-message usage with
19
+ * all four tiers plus the provider and model, and dedicated usage entries
20
+ * (for example cache warming) record model-attributed consumption that
21
+ * never becomes a message.
22
+ *
23
+ * Import is idempotent: each event derives a stable command identity
24
+ * (`import.<source>.<hash>`), and events already present in the ledger
25
+ * are skipped, so reruns never double-count.
26
+ */
27
+ export type ImportSource = "claude-code" | "codex" | "gemini" | "pi";
28
+ /** An event ready to append: no seq (assigned by the ledger). */
29
+ export type ImportedUsageEvent = Omit<UsageSidecarEntry, "seq">;
30
+ export type ImportResult = {
31
+ source: ImportSource;
32
+ files: number;
33
+ events: number;
34
+ imported: number;
35
+ skipped: number;
36
+ };
37
+ /** Claude Code: one JSONL transcript per session; dedupe assistant chunks by message id. */
38
+ export declare function parseClaudeCodeTranscript(content: string): ImportedUsageEvent[];
39
+ /** Codex: rollout JSONL; returns undefined when the session belongs to another project. */
40
+ export declare function parseCodexSession(content: string, projectRoot: string): ImportedUsageEvent[] | undefined;
41
+ /** Gemini (antigravity-cli): transcript_full JSONL; PLANNER_RESPONSE steps carry token counts. */
42
+ export declare function parseGeminiTranscript(content: string): ImportedUsageEvent[];
43
+ /** Pi (pi.dev): session JSONL; assistant messages and dedicated usage entries both carry per-message tiers and provider. */
44
+ export declare function parsePiSession(content: string): ImportedUsageEvent[];
45
+ /** Merge imported events into a ledger idempotently (already-present command ids are skipped). */
46
+ export declare function mergeImportedEvents(ledger: UsageSidecarEntry[], events: ImportedUsageEvent[]): {
47
+ ledger: UsageSidecarEntry[];
48
+ imported: number;
49
+ skipped: number;
50
+ };
51
+ export declare function summarizeImport(result: ImportResult): string;
@@ -0,0 +1,190 @@
1
+ import { createHash } from "node:crypto";
2
+ const zeroTiers = () => ({
3
+ "cache-read": 0,
4
+ "fresh-input": 0,
5
+ "cache-write": 0,
6
+ output: 0,
7
+ });
8
+ const tiersOf = (values) => ({
9
+ ...zeroTiers(),
10
+ ...Object.fromEntries(Object.entries(values).filter(([, value]) => value > 0)),
11
+ });
12
+ function event(source, identity, tiers, extra = {}) {
13
+ if (!USAGE_TIERS_NONZERO(tiers))
14
+ return undefined;
15
+ const hash = createHash("sha256").update(identity).digest("hex").slice(0, 12);
16
+ const imported = {
17
+ command: `import.${source}.e${hash}`,
18
+ label: "measured",
19
+ tiers,
20
+ harness: source === "gemini" ? "gemini-cli" : source,
21
+ ...(extra.model ? { model: extra.model } : {}),
22
+ ...(extra.provider ? { provider: extra.provider } : {}),
23
+ ...(extra.harnessVersion ? { harnessVersion: extra.harnessVersion } : {}),
24
+ };
25
+ return imported;
26
+ }
27
+ function USAGE_TIERS_NONZERO(tiers) {
28
+ return Object.values(tiers).some((value) => value > 0);
29
+ }
30
+ function safeParse(line) {
31
+ try {
32
+ const parsed = JSON.parse(line);
33
+ return parsed && typeof parsed === "object" ? parsed : undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ /** Claude Code: one JSONL transcript per session; dedupe assistant chunks by message id. */
40
+ export function parseClaudeCodeTranscript(content) {
41
+ const events = [];
42
+ const seen = new Set();
43
+ for (const line of content.split("\n")) {
44
+ if (!line.includes('"usage"'))
45
+ continue;
46
+ const entry = safeParse(line);
47
+ if (!entry || entry.type !== "assistant")
48
+ continue;
49
+ const message = entry.message;
50
+ const usage = message?.usage;
51
+ if (!message || !usage)
52
+ continue;
53
+ const messageId = String(message.id ?? "");
54
+ if (!messageId || seen.has(messageId))
55
+ continue;
56
+ seen.add(messageId);
57
+ const parsed = event("claude-code", `${entry.sessionId ?? ""}:${messageId}`, tiersOf({
58
+ "fresh-input": usage.input_tokens ?? 0,
59
+ "cache-write": usage.cache_creation_input_tokens ?? 0,
60
+ "cache-read": usage.cache_read_input_tokens ?? 0,
61
+ output: usage.output_tokens ?? 0,
62
+ }), {
63
+ model: typeof message.model === "string" ? message.model : undefined,
64
+ harnessVersion: typeof entry.version === "string" ? entry.version : undefined,
65
+ });
66
+ if (parsed)
67
+ events.push(parsed);
68
+ }
69
+ return events;
70
+ }
71
+ /** Codex: rollout JSONL; returns undefined when the session belongs to another project. */
72
+ export function parseCodexSession(content, projectRoot) {
73
+ const events = [];
74
+ let sessionCwd;
75
+ let cliVersion;
76
+ let model;
77
+ for (const line of content.split("\n")) {
78
+ const entry = safeParse(line);
79
+ if (!entry)
80
+ continue;
81
+ const payload = (entry.payload ?? {});
82
+ if (entry.type === "session_meta") {
83
+ sessionCwd = typeof payload.cwd === "string" ? payload.cwd : undefined;
84
+ cliVersion = typeof payload.cli_version === "string" ? payload.cli_version : undefined;
85
+ continue;
86
+ }
87
+ if (entry.type === "turn_context" && typeof payload.model === "string")
88
+ model = payload.model;
89
+ if (entry.type !== "token_usage_record")
90
+ continue;
91
+ const usage = payload.usage;
92
+ if (!usage)
93
+ continue;
94
+ const cached = usage.cached_input_tokens ?? 0;
95
+ const cacheWrite = usage.cache_write_input_tokens ?? 0;
96
+ const input = usage.input_tokens ?? 0;
97
+ const parsed = event("codex", `${payload.response_id ?? createHash("sha256").update(line).digest("hex").slice(0, 16)}:${payload.turn_id ?? ""}:${entry.ordinal ?? ""}`, tiersOf({
98
+ "cache-read": cached,
99
+ "cache-write": cacheWrite,
100
+ "fresh-input": Math.max(0, input - cached - cacheWrite),
101
+ output: usage.output_tokens ?? 0,
102
+ }), { model, harnessVersion: cliVersion });
103
+ if (parsed)
104
+ events.push(parsed);
105
+ }
106
+ if (sessionCwd === undefined)
107
+ return undefined;
108
+ if (sessionCwd.replace(/\/+$/, "") !== projectRoot.replace(/\/+$/, ""))
109
+ return undefined;
110
+ return events;
111
+ }
112
+ /** Gemini (antigravity-cli): transcript_full JSONL; PLANNER_RESPONSE steps carry token counts. */
113
+ export function parseGeminiTranscript(content) {
114
+ const events = [];
115
+ for (const line of content.split("\n")) {
116
+ if (!line.includes("input_tokens"))
117
+ continue;
118
+ const entry = safeParse(line);
119
+ if (!entry || entry.type !== "PLANNER_RESPONSE")
120
+ continue;
121
+ const num = (value) => (typeof value === "number" ? value : 0);
122
+ const parsed = event("gemini", `${entry.created_at ?? ""}:${entry.step_index ?? ""}:${createHash("sha256").update(line).digest("hex").slice(0, 16)}`, tiersOf({
123
+ "fresh-input": num(entry.input_tokens),
124
+ "cache-read": num(entry.cache_read_tokens),
125
+ output: num(entry.output_tokens),
126
+ }));
127
+ if (parsed)
128
+ events.push(parsed);
129
+ }
130
+ return events;
131
+ }
132
+ /** Pi (pi.dev): session JSONL; assistant messages and dedicated usage entries both carry per-message tiers and provider. */
133
+ export function parsePiSession(content) {
134
+ const events = [];
135
+ for (const line of content.split("\n")) {
136
+ if (!line.includes('"usage"'))
137
+ continue;
138
+ const entry = safeParse(line);
139
+ if (!entry)
140
+ continue;
141
+ const message = entry.message;
142
+ const usage = (message?.usage ?? entry.usage);
143
+ if (!usage)
144
+ continue;
145
+ if (entry.type === "message" && message?.role !== "assistant")
146
+ continue;
147
+ const parsed = event("pi", String(entry.id ?? createHash("sha256").update(line).digest("hex").slice(0, 16)), tiersOf({
148
+ "fresh-input": usage.input ?? 0,
149
+ "cache-read": usage.cacheRead ?? 0,
150
+ "cache-write": usage.cacheWrite ?? 0,
151
+ output: usage.output ?? 0,
152
+ }), {
153
+ model: typeof message?.model === "string"
154
+ ? message.model
155
+ : typeof entry.model === "string"
156
+ ? entry.model
157
+ : undefined,
158
+ provider: typeof message?.provider === "string"
159
+ ? message.provider
160
+ : typeof entry.provider === "string"
161
+ ? entry.provider
162
+ : undefined,
163
+ });
164
+ if (parsed)
165
+ events.push(parsed);
166
+ }
167
+ return events;
168
+ }
169
+ /** Merge imported events into a ledger idempotently (already-present command ids are skipped). */
170
+ export function mergeImportedEvents(ledger, events) {
171
+ const existing = new Set(ledger.map((entry) => entry.command));
172
+ const fresh = events.filter((candidate) => !existing.has(candidate.command));
173
+ return {
174
+ ledger: [
175
+ ...ledger,
176
+ ...fresh.map((candidate, index) => ({ ...candidate, seq: ledger.length + index + 1 })),
177
+ ],
178
+ imported: fresh.length,
179
+ skipped: events.length - fresh.length,
180
+ };
181
+ }
182
+ export function summarizeImport(result) {
183
+ return [
184
+ `source: ${result.source}`,
185
+ `files scanned: ${result.files}`,
186
+ `usage events found: ${result.events}`,
187
+ `imported: ${result.imported} (label: measured)`,
188
+ `skipped (already in ledger): ${result.skipped}`,
189
+ ].join("\n");
190
+ }
@@ -25,6 +25,7 @@ export type UsageSidecarEntry = {
25
25
  label: UsageLabel;
26
26
  tiers: Record<UsageTier, number>;
27
27
  model?: string;
28
+ provider?: string;
28
29
  harness?: string;
29
30
  harnessVersion?: string;
30
31
  requirement?: string;
@@ -46,6 +47,11 @@ export type UsageSidecarSummary = {
46
47
  tokens: number;
47
48
  label: UsageLabel;
48
49
  }[];
50
+ byProvider: {
51
+ provider: string;
52
+ tokens: number;
53
+ label: UsageLabel;
54
+ }[];
49
55
  };
50
56
  export declare function validateUsageSidecarEntry(entry: UsageSidecarEntry): UsageSidecarEntry;
51
57
  export declare function parseUsageSidecar(content: string): UsageSidecarEntry[];
@@ -32,6 +32,8 @@ export function validateUsageSidecarEntry(entry) {
32
32
  throw new Error("Usage sidecar entry needs at least one non-zero tier.");
33
33
  if (entry.model !== undefined && !entry.model.trim())
34
34
  throw new Error("Usage sidecar model must be a non-empty string when present.");
35
+ if (entry.provider !== undefined && !entry.provider.trim())
36
+ throw new Error("Usage sidecar provider must be a non-empty string when present.");
35
37
  if (entry.requirement !== undefined && !/^REQ-\d+$/.test(entry.requirement))
36
38
  throw new Error("Usage sidecar requirement must use the REQ-### identifier format.");
37
39
  if (entry.workflow !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.workflow))
@@ -84,6 +86,7 @@ export function parseUsageSidecar(content) {
84
86
  label: pick("label"),
85
87
  tiers: Object.fromEntries(USAGE_TIERS.map((tier) => [tier, tiers[tier] ?? 0])),
86
88
  ...(optional("model") === undefined ? {} : { model: optional("model") }),
89
+ ...(optional("provider") === undefined ? {} : { provider: optional("provider") }),
87
90
  ...(optional("harness") === undefined ? {} : { harness: optional("harness") }),
88
91
  ...(optional("harness_version") === undefined
89
92
  ? {}
@@ -100,7 +103,7 @@ export function parseUsageSidecar(content) {
100
103
  export function serializeUsageSidecar(entries) {
101
104
  const lines = [`schema: ${USAGE_SIDECAR_SCHEMA}`, "entries:"];
102
105
  for (const entry of entries) {
103
- lines.push(` - seq: ${entry.seq}`, ` command: ${entry.command}`, ` label: ${entry.label}`, " tiers:", ...USAGE_TIERS.map((tier) => ` ${tier}: ${entry.tiers[tier]}`), ...(entry.model === undefined ? [] : [` model: ${entry.model}`]), ...(entry.harness === undefined ? [] : [` harness: ${entry.harness}`]), ...(entry.harnessVersion === undefined
106
+ lines.push(` - seq: ${entry.seq}`, ` command: ${entry.command}`, ` label: ${entry.label}`, " tiers:", ...USAGE_TIERS.map((tier) => ` ${tier}: ${entry.tiers[tier]}`), ...(entry.model === undefined ? [] : [` model: ${entry.model}`]), ...(entry.provider === undefined ? [] : [` provider: ${entry.provider}`]), ...(entry.harness === undefined ? [] : [` harness: ${entry.harness}`]), ...(entry.harnessVersion === undefined
104
107
  ? []
105
108
  : [` harness_version: ${entry.harnessVersion}`]), ...(entry.requirement === undefined ? [] : [` requirement: ${entry.requirement}`]), ...(entry.workflow === undefined ? [] : [` workflow: ${entry.workflow}`]));
106
109
  }
@@ -124,6 +127,7 @@ export function summarizeUsageSidecar(entries) {
124
127
  tiers: Object.fromEntries(USAGE_TIERS.map((tier) => [tier, 0])),
125
128
  cacheHitRate: undefined,
126
129
  byModel: [],
130
+ byProvider: [],
127
131
  byHarness: [],
128
132
  };
129
133
  const tiers = Object.fromEntries(USAGE_TIERS.map((tier) => [
@@ -154,6 +158,9 @@ export function summarizeUsageSidecar(entries) {
154
158
  tiers,
155
159
  ...(billableInput > 0 ? { cacheHitRate: tiers["cache-read"] / billableInput } : {}),
156
160
  byModel: group("model").map(({ name, tokens, label }) => ({ model: name, tokens, label })),
161
+ byProvider: group("provider")
162
+ .filter(({ name }) => name !== "unattributed")
163
+ .map(({ name, tokens, label }) => ({ provider: name, tokens, label })),
157
164
  byHarness: group("harness").map(({ name, tokens, label }) => ({
158
165
  harness: name,
159
166
  tokens,
@@ -183,5 +190,11 @@ export function serializeUsageSidecarSummary(summary) {
183
190
  ...summary.byHarness.map(({ harness, tokens, label }) => ` - ${harness}: ${tokens.toLocaleString("en-US")} (${label})`),
184
191
  ]
185
192
  : []),
193
+ ...(summary.byProvider.length
194
+ ? [
195
+ "by provider:",
196
+ ...summary.byProvider.map(({ provider, tokens, label }) => ` - ${provider}: ${tokens.toLocaleString("en-US")} (${label})`),
197
+ ]
198
+ : []),
186
199
  ].join("\n");
187
200
  }
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";
@@ -40,6 +55,9 @@ import { measureContextQuality, serializeContextQuality } from "./context-qualit
40
55
  import { collectContextBench, compareContextBench, parseContextBench, serializeContextBench, } from "./context-benchmark.js";
41
56
  import { materializeParityTask, parseParityTask, runParitySuite, serializeParitySuite, TASK_ASSEMBLER, } from "./task-parity.js";
42
57
  import { appendUsageSidecarEntry, parseUsageSidecar, serializeUsageSidecar, serializeUsageSidecarSummary, summarizeUsageSidecar, USAGE_SIDECAR_PATH, USAGE_TIERS, } from "./usage-sidecar.js";
58
+ import { mergeImportedEvents, parseClaudeCodeTranscript, parseCodexSession, parseGeminiTranscript, parsePiSession, summarizeImport, } from "./usage-import.js";
59
+ import { describeHeartbeatOutcome, emptyHeartbeatState, HEARTBEAT_STATE_PATH, HEARTBEAT_TTL_SECONDS, HEARTBEAT_WARN_SECONDS, parseHeartbeatState, recordBeat, serializeHeartbeatCheckpoint, serializeHeartbeatState, validateHeartbeatThresholds, } from "./heartbeat.js";
60
+ import { installHeartbeatHooks, parseClaudeSettings, writeClaudeSettings, } from "./hook-installer.js";
43
61
  import { computeCostAttribution, serializeCostAttribution, serializeTaskUsage, summarizeTaskUsage, } from "./cost-attribution.js";
44
62
  import { defaultRoutingTable, parseRoutingTable, resolveRoute, ROUTING_TABLE_PATH, serializeResolvedRoute, serializeRoutingTable, } from "./model-routing.js";
45
63
  import { parseTraceabilityGraph } from "./traceability.js";
@@ -2156,6 +2174,100 @@ export async function runCommand(args, root, fs, services = {}) {
2156
2174
  "\nSources: .aiw/checkpoints/usage-sidecar.yml + .aiw/generated/artifacts/traceability.yml (verified = linked evidence present).",
2157
2175
  };
2158
2176
  }
2177
+ if (command === "heartbeat") {
2178
+ if (!fs.exists(join(aiw, "manifest.yml")))
2179
+ return { exitCode: 1, error: "Run `aiw install` first." };
2180
+ const parsed = parseNamedOptions(args.slice(1), ["session", "warn", "ttl"]);
2181
+ if (!parsed)
2182
+ return {
2183
+ exitCode: 1,
2184
+ error: "Usage: aiw heartbeat --session=id [--warn=seconds] [--ttl=seconds]",
2185
+ };
2186
+ const warnSeconds = parsed.warn === undefined ? HEARTBEAT_WARN_SECONDS : Number(parsed.warn);
2187
+ const ttlSeconds = parsed.ttl === undefined ? HEARTBEAT_TTL_SECONDS : Number(parsed.ttl);
2188
+ try {
2189
+ validateHeartbeatThresholds(warnSeconds, ttlSeconds);
2190
+ }
2191
+ catch (error) {
2192
+ return { exitCode: 1, error: error.message };
2193
+ }
2194
+ let session = parsed.session;
2195
+ if (!session && !stdin.isTTY) {
2196
+ // Claude Code hooks deliver a JSON payload with session_id on stdin.
2197
+ try {
2198
+ const payload = JSON.parse(await readStdin());
2199
+ if (payload &&
2200
+ typeof payload === "object" &&
2201
+ typeof payload.session_id === "string")
2202
+ session = payload.session_id;
2203
+ }
2204
+ catch {
2205
+ // No parseable payload: fall through to the explicit error below.
2206
+ }
2207
+ }
2208
+ if (!session)
2209
+ return {
2210
+ exitCode: 1,
2211
+ error: "A session id is required: pass --session=id or pipe a harness hook JSON payload on stdin.",
2212
+ };
2213
+ const statePath = join(aiw, HEARTBEAT_STATE_PATH);
2214
+ const state = fs.exists(statePath)
2215
+ ? parseHeartbeatState(fs.read(statePath))
2216
+ : emptyHeartbeatState();
2217
+ const { state: nextState, outcome } = recordBeat(state, session, new Date(), warnSeconds, ttlSeconds);
2218
+ fs.write(statePath, serializeHeartbeatState(nextState));
2219
+ const lines = [describeHeartbeatOutcome(outcome)];
2220
+ if (outcome.checkpoint) {
2221
+ const checkpointPath = join(aiw, `checkpoints/heartbeat-${session}-${outcome.checkpoint.index}.yml`);
2222
+ fs.write(checkpointPath, serializeHeartbeatCheckpoint(outcome.checkpoint, outcome.gapSeconds ?? 0, ttlSeconds));
2223
+ lines.push(`checkpoint: ${relative(root, checkpointPath).split(sep).join("/")}`);
2224
+ }
2225
+ return { exitCode: 0, output: lines.join("\n") };
2226
+ }
2227
+ if (command === "hooks") {
2228
+ if (!fs.exists(join(aiw, "manifest.yml")))
2229
+ return { exitCode: 1, error: "Run `aiw install` first." };
2230
+ const action = args[1];
2231
+ if (action !== "install")
2232
+ return {
2233
+ exitCode: 1,
2234
+ error: "Usage: aiw hooks install --target=claude-code (codex and gemini adapters follow the same command)",
2235
+ };
2236
+ const parsed = parseNamedOptions(args.slice(2), ["target"]);
2237
+ if (!parsed?.target)
2238
+ return { exitCode: 1, error: "Usage: aiw hooks install --target=claude-code" };
2239
+ if (parsed.target !== "claude-code")
2240
+ return {
2241
+ exitCode: 1,
2242
+ error: `Harness hook adapter not available yet for: ${parsed.target}. Claude Code is wired; codex and gemini follow the same pattern.`,
2243
+ };
2244
+ const settingsPath = join(root, ".claude/settings.json");
2245
+ let settings;
2246
+ try {
2247
+ settings = parseClaudeSettings(fs.exists(settingsPath) ? fs.read(settingsPath) : "");
2248
+ }
2249
+ catch (error) {
2250
+ return {
2251
+ exitCode: 1,
2252
+ error: `Existing .claude/settings.json is not a valid hooks file; fix or move it first: ${error.message}`,
2253
+ };
2254
+ }
2255
+ const result = installHeartbeatHooks(settings);
2256
+ if (fs.pathType?.(settingsPath) === "symlink")
2257
+ return {
2258
+ exitCode: 1,
2259
+ error: ".claude/settings.json is a symbolic link; install hooks manually.",
2260
+ };
2261
+ writeClaudeSettings(fs, settingsPath, result.settings);
2262
+ return {
2263
+ exitCode: 0,
2264
+ output: `Heartbeat hooks installed into .claude/settings.json (project-scoped):\n` +
2265
+ [...result.added, ...result.alreadyPresent]
2266
+ .map((event) => `- ${event}: ${result.added.includes(event) ? "added" : "already present"} → aiw heartbeat on every event`)
2267
+ .join("\n") +
2268
+ "\nSessions now record beats; idle gaps inside the warn window save a compact checkpoint while the cache is still warm.",
2269
+ };
2270
+ }
2159
2271
  if (command === "usage-sidecar") {
2160
2272
  if (!fs.exists(join(aiw, "manifest.yml")))
2161
2273
  return { exitCode: 1, error: "Run `aiw install` first." };
@@ -2172,6 +2284,109 @@ export async function runCommand(args, root, fs, services = {}) {
2172
2284
  : ""),
2173
2285
  };
2174
2286
  }
2287
+ if (action === "import") {
2288
+ const parsed = parseNamedOptions(args.slice(2), ["from", "path"]);
2289
+ const source = parsed?.from;
2290
+ if (!parsed || !source || !["claude-code", "codex", "gemini", "pi"].includes(source))
2291
+ return {
2292
+ exitCode: 1,
2293
+ error: "Usage: aiw usage-sidecar import --from=claude-code|codex|gemini|pi [--path=logs-directory]",
2294
+ };
2295
+ if (!fs.list)
2296
+ return { exitCode: 1, error: "The filesystem cannot enumerate harness logs." };
2297
+ const home = homedir();
2298
+ const defaultRoot = source === "claude-code"
2299
+ ? join(home, ".claude/projects", root.split("/").join("-"))
2300
+ : source === "codex"
2301
+ ? join(home, ".codex/sessions")
2302
+ : source === "pi"
2303
+ ? join(home, ".pi/agent/sessions")
2304
+ : join(home, ".gemini/antigravity-cli/brain");
2305
+ const scanRoot = parsed.path ? resolve(parsed.path) : defaultRoot;
2306
+ if (fs.pathType?.(scanRoot) !== "directory")
2307
+ return {
2308
+ exitCode: 1,
2309
+ error: `Harness log directory not found: ${scanRoot}. Pass --path to point at the ${source} logs.`,
2310
+ };
2311
+ const wanted = (name) => source === "claude-code"
2312
+ ? name.endsWith(".jsonl")
2313
+ : source === "codex"
2314
+ ? /^rollout-.*\.jsonl$/.test(name)
2315
+ : name === "transcript_full.jsonl";
2316
+ const files = [];
2317
+ const listDirectory = fs.list;
2318
+ if (source === "pi") {
2319
+ // Pi groups sessions into directories named after the project path.
2320
+ const munged = root.split("/").join("-");
2321
+ for (const name of listDirectory(scanRoot) ?? []) {
2322
+ if (!name.includes(munged))
2323
+ continue;
2324
+ const directory = join(scanRoot, name);
2325
+ if (fs.pathType?.(directory) !== "directory")
2326
+ continue;
2327
+ for (const file of listDirectory(directory) ?? [])
2328
+ if (file.endsWith(".jsonl") && fs.pathType?.(join(directory, file)) === "file")
2329
+ files.push(join(directory, file));
2330
+ }
2331
+ }
2332
+ else {
2333
+ const walk = (directory, depth) => {
2334
+ if (depth > 6)
2335
+ return;
2336
+ for (const name of listDirectory(directory) ?? []) {
2337
+ const absolute = join(directory, name);
2338
+ const type = fs.pathType?.(absolute);
2339
+ if (type === "directory")
2340
+ walk(absolute, depth + 1);
2341
+ else if (type === "file" && wanted(name))
2342
+ files.push(absolute);
2343
+ }
2344
+ };
2345
+ walk(scanRoot, 0);
2346
+ }
2347
+ files.sort();
2348
+ const events = [];
2349
+ for (const file of files) {
2350
+ const content = fs.read(file);
2351
+ if (source === "claude-code")
2352
+ events.push(...parseClaudeCodeTranscript(content));
2353
+ else if (source === "codex") {
2354
+ const parsedEvents = parseCodexSession(content, root);
2355
+ if (parsedEvents)
2356
+ events.push(...parsedEvents);
2357
+ }
2358
+ else if (source === "pi")
2359
+ events.push(...parsePiSession(content));
2360
+ else
2361
+ events.push(...parseGeminiTranscript(content));
2362
+ }
2363
+ let ledger;
2364
+ try {
2365
+ ledger = fs.exists(sidecarPath) ? parseUsageSidecar(fs.read(sidecarPath)) : [];
2366
+ }
2367
+ catch (error) {
2368
+ throw new Error(`Usage sidecar is unreadable: ${error.message}`, {
2369
+ cause: error,
2370
+ });
2371
+ }
2372
+ const merged = mergeImportedEvents(ledger, events);
2373
+ fs.write(sidecarPath, serializeUsageSidecar(merged.ledger));
2374
+ const result = {
2375
+ source,
2376
+ files: files.length,
2377
+ events: events.length,
2378
+ imported: merged.imported,
2379
+ skipped: merged.skipped,
2380
+ };
2381
+ return {
2382
+ exitCode: 0,
2383
+ output: summarizeImport(result) +
2384
+ (source === "gemini"
2385
+ ? "\nnote: Gemini transcripts do not record a working directory, so imports cannot be scoped to this project."
2386
+ : "") +
2387
+ `\nLedger: .aiw/${USAGE_SIDECAR_PATH} (rerun safely: already-imported events are skipped).`,
2388
+ };
2389
+ }
2175
2390
  if (action === "record") {
2176
2391
  const recordArgs = args.slice(2).filter((arg) => arg !== "--estimated");
2177
2392
  if (recordArgs.some((arg) => arg !== "--estimated" && !arg.startsWith("--")))
@@ -83,6 +83,59 @@ Every entry carries `measured` or `estimated`, and aggregates propagate the labe
83
83
 
84
84
  `aiw scan` auto-records sidecar entries for interpreter and embedding usage with their real measurement label, so the ledger fills without manual effort.
85
85
 
86
+ ### Importing harness session logs
87
+
88
+ `aiw usage-sidecar import` turns local harness session logs into measured entries — the same lifecycle coverage as the manual `record`, without friction. Formats were validated against real logs:
89
+
90
+ ```bash
91
+ aiw usage-sidecar import --from=claude-code # ~/.claude/projects/<this-project>
92
+ aiw usage-sidecar import --from=codex # ~/.codex/sessions (sessions scoped by cwd)
93
+ aiw usage-sidecar import --from=gemini # ~/.gemini/antigravity-cli/brain transcripts
94
+ ```
95
+
96
+ - **Claude Code**: per-message usage with all four tiers (including cache), model and CLI version, deduplicated by message id.
97
+ - **Codex**: `token_usage_record` entries; cached input is separated from fresh; sessions are scoped to this project via `session_meta.cwd`.
98
+ - **Gemini**: planner responses with input/cache-read/output. Transcripts do not record a working directory, so Gemini imports cannot be scoped per project (the command says so).
99
+
100
+ Import is idempotent: every event derives a stable identity (`import.<source>.<hash>`) and already-present events are skipped, so reruns never double-count. Entries land with label `measured` and full harness attribution. Cursor and GitHub Copilot remain outside this coverage: they do not expose per-request usage to the user, so for them the estimate stays the ceiling.
101
+
102
+ ## Session heartbeat (cache-TTL management)
103
+
104
+ Provider prompt caches expire on a TTL (typically 5 minutes); the cost lands when a session is resumed after expiry, because the next request rebuilds the whole prefix at full input price. Keeping a cache alive with provider pings has its own economics and AIW is not in the call path — but the gap can be measured for free on every event:
105
+
106
+ ```bash
107
+ aiw hooks install --target=claude-code # wires Stop + UserPromptSubmit to `aiw heartbeat`
108
+ aiw heartbeat --session=<id> # what the hooks call; also accepts hook JSON on stdin
109
+ ```
110
+
111
+ Each beat measures the gap since the previous one and decides:
112
+
113
+ - gap < warn (default 4 min): active session, nothing to do;
114
+ - warn ≤ gap < TTL: the cache is still warm (this request just read it at cache price) — a compact checkpoint is saved now, so an idle past the TTL resumes cheap;
115
+ - gap ≥ TTL: the cache expired and this request paid the rebuild — a compact checkpoint is saved so the _next_ gap hurts an order of magnitude less.
116
+
117
+ No timers and no keep-alive pings: the decision always happens on the next beat, which costs nothing locally. The guarantee is that a session never pays the full-price prefix rebuild twice in a row on the same context. Thresholds are configurable (`--warn=seconds --ttl=seconds`), state lives in `.aiw/heartbeats.yml`, and every action writes an inspectable checkpoint under `.aiw/checkpoints/`. Hook installers are project-scoped, merge without touching foreign hook entries, and are idempotent; Codex (`notify`) and Gemini (`hooks.json`) adapters follow the same command.
118
+
119
+ ## Harness support matrix
120
+
121
+ AI Workflow's core — the SDD workflow, quality and cost gates, token ledger, cost attribution, task-lifecycle usage, and routing — is harness-agnostic and behaves identically everywhere. What varies per harness is the edge: measured log import and live beats. The matrix declares exactly what is and is not available, because silent degradation is worse than an explicit limitation.
122
+
123
+ | Capability | Pi | Claude Code | Codex | Gemini | Cursor | Copilot CLI |
124
+ | ----------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------ | ------------------------------------ | ----------------------------------- | -------------------------- | -------------------------- |
125
+ | Neutral core (gates, ledger, reports, routing) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
126
+ | Manual usage recording (`usage-sidecar record`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
127
+ | Measured log import | ✅ all four tiers + provider per message, project-scoped | ✅ all four tiers + model | ✅ four tiers, project-scoped by cwd | ✅ three tiers, no project scoping | ⚠️ local database, fragile | ❌ estimate is the ceiling |
128
+ | Live beats (heartbeat) | ✅✅ extensions run in-process (event handlers, commands, tools) | ✅ hooks (Stop, UserPromptSubmit, …) | ⚠️ `notify` per turn | ✅ hooks (Pre/PostInvocation, Stop) | ⚠️ limited | ❌ |
129
+ | Multi-provider sessions | ✅ provider recorded per message | — | — | — | — | — |
130
+
131
+ **Recommendation: use [Pi](https://pi.dev) for the best experience.** It is the only harness where every capability is native: per-message usage with all four billing tiers _and_ the provider, sessions grouped by project, compaction recorded with token counts, and an in-process extension API rich enough that AI Workflow can eventually run inside the harness itself. Claude Code is the strongest alternative; Codex and Gemini cover measurement fully with partial beat granularity. Where a capability is missing, AI Workflow degrades honestly: manual recording with `estimated` labels, and explicit errors instead of silent gaps.
132
+
133
+ Notes:
134
+
135
+ - Codex beats fire per turn (`notify`), not per event — enough for gap detection, coarser than Claude Code or Pi.
136
+ - Gemini transcripts do not record a working directory, so their imports cover all sessions on the machine.
137
+ - Cursor and Copilot do not expose per-request usage to the user; for them the estimate stays the ceiling, and every number derived from their sessions carries the `estimated` label.
138
+
86
139
  ## Cost per verified requirement
87
140
 
88
141
  `aiw cost-report` joins the usage sidecar with the traceability graph to produce the outcome-denominated cost metric — tokens per verified requirement, where verified means the graph links the requirement to evidence:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multileaf/ai-workflow",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Portable spec-driven development workflow for AI coding agents",
5
5
  "license": "MIT",
6
6
  "repository": {