@multileaf/ai-workflow 0.7.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 +15 -0
- package/dist/context-benchmark.d.ts +52 -0
- package/dist/context-benchmark.js +170 -0
- package/dist/cost-attribution.d.ts +65 -0
- package/dist/cost-attribution.js +119 -0
- package/dist/heartbeat.d.ts +61 -0
- package/dist/heartbeat.js +158 -0
- package/dist/hook-installer.d.ts +30 -0
- package/dist/hook-installer.js +52 -0
- package/dist/model-routing.d.ts +38 -0
- package/dist/model-routing.js +116 -0
- package/dist/task-parity.d.ts +67 -0
- package/dist/task-parity.js +202 -0
- package/dist/traceability.d.ts +2 -0
- package/dist/traceability.js +35 -0
- package/dist/usage-import.d.ts +51 -0
- package/dist/usage-import.js +190 -0
- package/dist/usage-sidecar.d.ts +62 -0
- package/dist/usage-sidecar.js +200 -0
- package/dist/workflow.js +519 -0
- package/docs/README.md +2 -0
- package/docs/token-efficiency-research.md +135 -0
- package/docs/token-efficiency-study-notes.md +128 -0
- package/docs/token-efficiency.md +157 -0
- package/package.json +1 -1
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";
|
|
@@ -37,6 +52,15 @@ import { createSummaryCache, isSummaryFresh, parseSummaryCache, serializeSummary
|
|
|
37
52
|
import { parseTokenUsage, recordTokenUsage, serializeTokenUsage, summarizeUsageByModel, } from "./token-usage.js";
|
|
38
53
|
import { APPROVAL_STAGES, assertWorkflowId, validateTaskTicket, approveStage, beginStageApproval, createApprovalLedger, getUnresolvedPrerequisites, invalidateStageAndDescendants, parseApprovalLedger, parseWorkflowSession, rejectStage, serializeApprovalLedger, serializeWorkflowSession, skipOptionalStage, stageDefinition, stageFingerprint, } from "./human-approval.js";
|
|
39
54
|
import { measureContextQuality, serializeContextQuality } from "./context-quality.js";
|
|
55
|
+
import { collectContextBench, compareContextBench, parseContextBench, serializeContextBench, } from "./context-benchmark.js";
|
|
56
|
+
import { materializeParityTask, parseParityTask, runParitySuite, serializeParitySuite, TASK_ASSEMBLER, } from "./task-parity.js";
|
|
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";
|
|
61
|
+
import { computeCostAttribution, serializeCostAttribution, serializeTaskUsage, summarizeTaskUsage, } from "./cost-attribution.js";
|
|
62
|
+
import { defaultRoutingTable, parseRoutingTable, resolveRoute, ROUTING_TABLE_PATH, serializeResolvedRoute, serializeRoutingTable, } from "./model-routing.js";
|
|
63
|
+
import { parseTraceabilityGraph } from "./traceability.js";
|
|
40
64
|
import { serializeAdapterCapabilities } from "./adapter-contract.js";
|
|
41
65
|
import { buildTraceabilityGraph, serializeTraceabilityGraph } from "./traceability.js";
|
|
42
66
|
import { evaluateQualityGate } from "./quality-gates.js";
|
|
@@ -530,6 +554,105 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
530
554
|
}
|
|
531
555
|
return { exitCode: 1, error: usage };
|
|
532
556
|
}
|
|
557
|
+
if (command === "context-bench") {
|
|
558
|
+
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
559
|
+
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
560
|
+
const saveCount = args.filter((arg) => arg === "--save").length;
|
|
561
|
+
if (saveCount > 1)
|
|
562
|
+
return { exitCode: 1, error: "Usage: aiw context-bench [--baseline=path] [--save]" };
|
|
563
|
+
const save = saveCount === 1;
|
|
564
|
+
const parsed = parseNamedOptions(args.slice(1).filter((arg) => arg !== "--save"), ["baseline"]);
|
|
565
|
+
if (!parsed)
|
|
566
|
+
return { exitCode: 1, error: "Usage: aiw context-bench [--baseline=path] [--save]" };
|
|
567
|
+
const report = collectContextBench(root, fs);
|
|
568
|
+
const serialized = serializeContextBench(report);
|
|
569
|
+
const lines = [serialized.trimEnd()];
|
|
570
|
+
if (parsed.baseline) {
|
|
571
|
+
const baselinePath = safeProjectFile(root, parsed.baseline, fs);
|
|
572
|
+
if (!fs.exists(baselinePath))
|
|
573
|
+
return { exitCode: 1, error: `Baseline not found: ${parsed.baseline}` };
|
|
574
|
+
const comparison = compareContextBench(parseContextBench(fs.read(baselinePath)), report);
|
|
575
|
+
lines.push(`Baseline: ${parsed.baseline} (bundle ${comparison.bundleChanged ? "changed" : "identical"}, token delta ${comparison.totalTokenDelta >= 0 ? "+" : ""}${comparison.totalTokenDelta})`);
|
|
576
|
+
if (comparison.regressions.length)
|
|
577
|
+
return {
|
|
578
|
+
exitCode: 1,
|
|
579
|
+
error: `Standing context regressed against baseline:\n${comparison.regressions.map((regression) => `- ${regression}`).join("\n")}`,
|
|
580
|
+
output: lines.join("\n"),
|
|
581
|
+
};
|
|
582
|
+
lines.push("No regressions against baseline.");
|
|
583
|
+
}
|
|
584
|
+
if (save) {
|
|
585
|
+
const checkpoint = join(aiw, `checkpoints/context-bench-${report.bundleHash}.yml`);
|
|
586
|
+
if (!fs.createExclusive)
|
|
587
|
+
return { exitCode: 1, error: "The filesystem does not support atomic checkpoints." };
|
|
588
|
+
if (!fs.createExclusive(checkpoint, serialized)) {
|
|
589
|
+
if (fs.read(checkpoint) !== serialized)
|
|
590
|
+
return {
|
|
591
|
+
exitCode: 1,
|
|
592
|
+
error: `Checkpoint already exists with different content: ${checkpoint}`,
|
|
593
|
+
};
|
|
594
|
+
lines.push(`Checkpoint already recorded: ${checkpoint}`);
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
lines.push(`Checkpoint saved: ${checkpoint}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return { exitCode: 0, output: lines.join("\n") };
|
|
601
|
+
}
|
|
602
|
+
if (command === "parity") {
|
|
603
|
+
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
604
|
+
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
605
|
+
const parsed = parseNamedOptions(args.slice(1), ["tasks"]);
|
|
606
|
+
if (!parsed)
|
|
607
|
+
return { exitCode: 1, error: "Usage: aiw parity [--tasks=directory]" };
|
|
608
|
+
const tasksPath = parsed.tasks;
|
|
609
|
+
if (tasksPath && (isAbsolute(tasksPath) || tasksPath.split(/[\\/]/).includes("..")))
|
|
610
|
+
return {
|
|
611
|
+
exitCode: 1,
|
|
612
|
+
error: "Parity tasks directory must be a safe project-relative path.",
|
|
613
|
+
};
|
|
614
|
+
const tasksDirectory = tasksPath ? join(root, tasksPath) : join(aiw, "parity");
|
|
615
|
+
if (fs.pathType?.(tasksDirectory) !== "directory" || !fs.list)
|
|
616
|
+
return {
|
|
617
|
+
exitCode: 1,
|
|
618
|
+
error: `Parity tasks directory not found: ${relative(root, tasksDirectory).split(sep).join("/")}. Add YAML tasks (see docs/token-efficiency.md).`,
|
|
619
|
+
};
|
|
620
|
+
const taskFiles = (fs.list(tasksDirectory) ?? [])
|
|
621
|
+
.filter((name) => name.endsWith(".yml"))
|
|
622
|
+
.sort();
|
|
623
|
+
if (!taskFiles.length)
|
|
624
|
+
return {
|
|
625
|
+
exitCode: 1,
|
|
626
|
+
error: `No parity tasks found in ${relative(root, tasksDirectory).split(sep).join("/")}. Each task is one YAML file with schema, id, layers, critical_facts, and completion_markers.`,
|
|
627
|
+
};
|
|
628
|
+
const tasks = taskFiles.map((name) => {
|
|
629
|
+
const path = join(tasksDirectory, name);
|
|
630
|
+
try {
|
|
631
|
+
return materializeParityTask(parseParityTask(fs.read(path)), (source) => {
|
|
632
|
+
const absolute = join(root, source);
|
|
633
|
+
if (fs.pathType?.(absolute) !== "file")
|
|
634
|
+
throw new Error("source is not a file");
|
|
635
|
+
return fs.read(absolute);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
throw new Error(`${name}: ${error.message}`, { cause: error });
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
const suite = runParitySuite(tasks, TASK_ASSEMBLER);
|
|
643
|
+
const serialized = serializeParitySuite(suite);
|
|
644
|
+
if (suite.failed)
|
|
645
|
+
return {
|
|
646
|
+
exitCode: 1,
|
|
647
|
+
output: serialized.trimEnd(),
|
|
648
|
+
error: `Task parity failed for ${suite.failed} of ${suite.total} task(s): critical facts or completion markers were lost by the ${TASK_ASSEMBLER.name} assembler. Fix context assembly or move the facts to a retrieved layer; do not delete the expectations.`,
|
|
649
|
+
};
|
|
650
|
+
return {
|
|
651
|
+
exitCode: 0,
|
|
652
|
+
output: serialized.trimEnd() +
|
|
653
|
+
`\nTask parity passed: every critical fact survived the ${TASK_ASSEMBLER.name} assembler.`,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
533
656
|
if (command === "scan") {
|
|
534
657
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
535
658
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
@@ -647,6 +770,28 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
647
770
|
model: usage.model,
|
|
648
771
|
});
|
|
649
772
|
fs.write(usagePath, serializeTokenUsage(next));
|
|
773
|
+
const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
|
|
774
|
+
const ledger = fs.exists(sidecarPath) ? parseUsageSidecar(fs.read(sidecarPath)) : [];
|
|
775
|
+
let sidecar = ledger;
|
|
776
|
+
for (const usage of modelUsage) {
|
|
777
|
+
const measured = usage.detail.includes("(measured)");
|
|
778
|
+
const isInterpreter = usage.label === "interpreter" && measured && interpreterUsage;
|
|
779
|
+
sidecar = appendUsageSidecarEntry(sidecar, {
|
|
780
|
+
command: `scan.${usage.label}`,
|
|
781
|
+
label: measured ? "measured" : "estimated",
|
|
782
|
+
tiers: isInterpreter
|
|
783
|
+
? {
|
|
784
|
+
"cache-read": 0,
|
|
785
|
+
"fresh-input": interpreterUsage.promptTokens,
|
|
786
|
+
"cache-write": 0,
|
|
787
|
+
output: interpreterUsage.completionTokens,
|
|
788
|
+
}
|
|
789
|
+
: { "cache-read": 0, "fresh-input": usage.tokens, "cache-write": 0, output: 0 },
|
|
790
|
+
model: usage.model,
|
|
791
|
+
harness: "aiw-cli",
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
fs.write(sidecarPath, serializeUsageSidecar(sidecar));
|
|
650
795
|
}
|
|
651
796
|
return {
|
|
652
797
|
exitCode: 0,
|
|
@@ -1922,6 +2067,380 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
1922
2067
|
fs.write(cachePath, serializeSummaryCache(createSummaryCache(context, summaryArg)));
|
|
1923
2068
|
return { exitCode: 0, output: summaryArg };
|
|
1924
2069
|
}
|
|
2070
|
+
if (command === "routing") {
|
|
2071
|
+
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
2072
|
+
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
2073
|
+
const usage = "Usage: aiw routing init | show | resolve --category=name";
|
|
2074
|
+
const action = args[1];
|
|
2075
|
+
const routingPath = join(aiw, ROUTING_TABLE_PATH);
|
|
2076
|
+
if (action === "init") {
|
|
2077
|
+
if (fs.exists(routingPath))
|
|
2078
|
+
return {
|
|
2079
|
+
exitCode: 1,
|
|
2080
|
+
error: `Routing table already exists: .aiw/${ROUTING_TABLE_PATH}. Edit it directly instead of re-initializing.`,
|
|
2081
|
+
};
|
|
2082
|
+
fs.write(routingPath, serializeRoutingTable(defaultRoutingTable()) +
|
|
2083
|
+
"# Add one rule per task category (dot-separated kebab-case, e.g. scan.interpreter).\n" +
|
|
2084
|
+
"# Name the evidence behind every rule — a benchmark, parity run, or ADR — before trusting it.\n" +
|
|
2085
|
+
"# Routing is advisory: humans can always override the resolved model.\n");
|
|
2086
|
+
return {
|
|
2087
|
+
exitCode: 0,
|
|
2088
|
+
output: `Routing table created: .aiw/${ROUTING_TABLE_PATH}. Add rules with named evidence, then resolve with \`aiw routing resolve --category=...\`.`,
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
if (action === "show") {
|
|
2092
|
+
if (!fs.exists(routingPath))
|
|
2093
|
+
return {
|
|
2094
|
+
exitCode: 1,
|
|
2095
|
+
error: `No routing table found. Run \`aiw routing init\` to create .aiw/${ROUTING_TABLE_PATH}.`,
|
|
2096
|
+
};
|
|
2097
|
+
return {
|
|
2098
|
+
exitCode: 0,
|
|
2099
|
+
output: serializeRoutingTable(parseRoutingTable(fs.read(routingPath))).trimEnd(),
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
if (action === "resolve") {
|
|
2103
|
+
const category = args.find((arg) => arg.startsWith("--category="))?.slice(11);
|
|
2104
|
+
if (!category)
|
|
2105
|
+
return { exitCode: 1, error: usage };
|
|
2106
|
+
if (!fs.exists(routingPath))
|
|
2107
|
+
return {
|
|
2108
|
+
exitCode: 1,
|
|
2109
|
+
error: `No routing table found. Run \`aiw routing init\` first.`,
|
|
2110
|
+
};
|
|
2111
|
+
let route;
|
|
2112
|
+
try {
|
|
2113
|
+
route = resolveRoute(parseRoutingTable(fs.read(routingPath)), category);
|
|
2114
|
+
}
|
|
2115
|
+
catch (error) {
|
|
2116
|
+
throw new Error(`Routing failed: ${error.message}`, { cause: error });
|
|
2117
|
+
}
|
|
2118
|
+
return {
|
|
2119
|
+
exitCode: 0,
|
|
2120
|
+
output: serializeResolvedRoute(route) +
|
|
2121
|
+
"\noverride: advisory only — a human may choose a different model at any time.",
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
return { exitCode: 1, error: usage };
|
|
2125
|
+
}
|
|
2126
|
+
if (command === "task-usage") {
|
|
2127
|
+
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
2128
|
+
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
2129
|
+
const parsed = parseNamedOptions(args.slice(1), ["workflow"]);
|
|
2130
|
+
if (!parsed)
|
|
2131
|
+
return { exitCode: 1, error: "Usage: aiw task-usage [--workflow=id-or-ticket]" };
|
|
2132
|
+
const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
|
|
2133
|
+
if (!fs.exists(sidecarPath))
|
|
2134
|
+
return {
|
|
2135
|
+
exitCode: 1,
|
|
2136
|
+
error: "No usage sidecar recorded yet. Correlate runs with `aiw usage-sidecar record --workflow=<id-or-ticket>` or run `aiw scan`.",
|
|
2137
|
+
};
|
|
2138
|
+
const sessionPath = join(aiw, "workflow.yml");
|
|
2139
|
+
const workflow = parsed.workflow ??
|
|
2140
|
+
(fs.exists(sessionPath)
|
|
2141
|
+
? (parseWorkflowSession(fs.read(sessionPath)).ticket ??
|
|
2142
|
+
parseWorkflowSession(fs.read(sessionPath)).workflowId)
|
|
2143
|
+
: undefined);
|
|
2144
|
+
if (!workflow)
|
|
2145
|
+
return {
|
|
2146
|
+
exitCode: 1,
|
|
2147
|
+
error: "No workflow specified and no active SDD task. Pass --workflow=<id-or-ticket>.",
|
|
2148
|
+
};
|
|
2149
|
+
const summary = summarizeTaskUsage(parseUsageSidecar(fs.read(sidecarPath)), workflow);
|
|
2150
|
+
return {
|
|
2151
|
+
exitCode: 0,
|
|
2152
|
+
output: serializeTaskUsage(summary),
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
if (command === "cost-report") {
|
|
2156
|
+
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
2157
|
+
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
2158
|
+
const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
|
|
2159
|
+
if (!fs.exists(sidecarPath))
|
|
2160
|
+
return {
|
|
2161
|
+
exitCode: 1,
|
|
2162
|
+
error: "No usage sidecar recorded yet. Record runs with `aiw usage-sidecar record` or run `aiw scan`.",
|
|
2163
|
+
};
|
|
2164
|
+
const tracePath = join(aiw, "generated/artifacts/traceability.yml");
|
|
2165
|
+
if (!fs.exists(tracePath))
|
|
2166
|
+
return {
|
|
2167
|
+
exitCode: 1,
|
|
2168
|
+
error: "Traceability graph is missing. Run `aiw trace` to build it from the approved specification and plan.",
|
|
2169
|
+
};
|
|
2170
|
+
const attribution = computeCostAttribution(parseUsageSidecar(fs.read(sidecarPath)), parseTraceabilityGraph(fs.read(tracePath)));
|
|
2171
|
+
return {
|
|
2172
|
+
exitCode: 0,
|
|
2173
|
+
output: serializeCostAttribution(attribution) +
|
|
2174
|
+
"\nSources: .aiw/checkpoints/usage-sidecar.yml + .aiw/generated/artifacts/traceability.yml (verified = linked evidence present).",
|
|
2175
|
+
};
|
|
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
|
+
}
|
|
2271
|
+
if (command === "usage-sidecar") {
|
|
2272
|
+
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
2273
|
+
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
2274
|
+
const usage = "Usage: aiw usage-sidecar record --command=name (--cache-read=N | --fresh-input=N | --cache-write=N | --output=N) [--model=name] [--harness=name] [--harness-version=text] [--estimated] | aiw usage-sidecar report";
|
|
2275
|
+
const action = args[1];
|
|
2276
|
+
const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
|
|
2277
|
+
if (action === "report") {
|
|
2278
|
+
const entries = fs.exists(sidecarPath) ? parseUsageSidecar(fs.read(sidecarPath)) : [];
|
|
2279
|
+
return {
|
|
2280
|
+
exitCode: 0,
|
|
2281
|
+
output: serializeUsageSidecarSummary(summarizeUsageSidecar(entries)) +
|
|
2282
|
+
(entries.length
|
|
2283
|
+
? `\nLedger: .aiw/${USAGE_SIDECAR_PATH} (${entries.length} entries, sequence-ordered, no timestamps)`
|
|
2284
|
+
: ""),
|
|
2285
|
+
};
|
|
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
|
+
}
|
|
2390
|
+
if (action === "record") {
|
|
2391
|
+
const recordArgs = args.slice(2).filter((arg) => arg !== "--estimated");
|
|
2392
|
+
if (recordArgs.some((arg) => arg !== "--estimated" && !arg.startsWith("--")))
|
|
2393
|
+
return { exitCode: 1, error: usage };
|
|
2394
|
+
const parsed = parseNamedOptions(recordArgs, [
|
|
2395
|
+
"command",
|
|
2396
|
+
"model",
|
|
2397
|
+
"harness",
|
|
2398
|
+
"harness-version",
|
|
2399
|
+
"requirement",
|
|
2400
|
+
"workflow",
|
|
2401
|
+
...USAGE_TIERS,
|
|
2402
|
+
]);
|
|
2403
|
+
if (!parsed?.command)
|
|
2404
|
+
return { exitCode: 1, error: usage };
|
|
2405
|
+
const tierValues = USAGE_TIERS.map((tier) => [tier, parsed[tier]]).filter(([, value]) => value !== undefined);
|
|
2406
|
+
if (!tierValues.length)
|
|
2407
|
+
return {
|
|
2408
|
+
exitCode: 1,
|
|
2409
|
+
error: "At least one token tier is required: --cache-read, --fresh-input, --cache-write, or --output.",
|
|
2410
|
+
};
|
|
2411
|
+
for (const [, value] of tierValues)
|
|
2412
|
+
if (!/^\d+$/.test(value))
|
|
2413
|
+
return { exitCode: 1, error: "Token tiers must be non-negative integers." };
|
|
2414
|
+
const entry = {
|
|
2415
|
+
command: parsed.command,
|
|
2416
|
+
label: (args.includes("--estimated") ? "estimated" : "measured"),
|
|
2417
|
+
tiers: Object.fromEntries(USAGE_TIERS.map((tier) => [tier, Number(parsed[tier] ?? 0)])),
|
|
2418
|
+
...(parsed.model ? { model: parsed.model } : {}),
|
|
2419
|
+
...(parsed.harness ? { harness: parsed.harness } : {}),
|
|
2420
|
+
...(parsed["harness-version"] ? { harnessVersion: parsed["harness-version"] } : {}),
|
|
2421
|
+
...(parsed.requirement ? { requirement: parsed.requirement } : {}),
|
|
2422
|
+
...(parsed.workflow ? { workflow: parsed.workflow } : {}),
|
|
2423
|
+
};
|
|
2424
|
+
let ledger;
|
|
2425
|
+
try {
|
|
2426
|
+
ledger = fs.exists(sidecarPath) ? parseUsageSidecar(fs.read(sidecarPath)) : [];
|
|
2427
|
+
}
|
|
2428
|
+
catch (error) {
|
|
2429
|
+
throw new Error(`Usage sidecar is unreadable: ${error.message}`, {
|
|
2430
|
+
cause: error,
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
const next = appendUsageSidecarEntry(ledger, entry);
|
|
2434
|
+
fs.write(sidecarPath, serializeUsageSidecar(next));
|
|
2435
|
+
return {
|
|
2436
|
+
exitCode: 0,
|
|
2437
|
+
output: `Usage recorded (seq ${next[next.length - 1].seq}, ${entry.label}): ${entry.command} — ${USAGE_TIERS.filter((tier) => entry.tiers[tier] > 0)
|
|
2438
|
+
.map((tier) => `${tier} ${entry.tiers[tier].toLocaleString("en-US")}`)
|
|
2439
|
+
.join(", ")}`,
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
return { exitCode: 1, error: usage };
|
|
2443
|
+
}
|
|
1925
2444
|
if (command === "token-usage") {
|
|
1926
2445
|
const usagePath = join(aiw, "usage.yml");
|
|
1927
2446
|
const entries = fs.exists(usagePath) ? parseTokenUsage(fs.read(usagePath)) : [];
|
package/docs/README.md
CHANGED
|
@@ -12,6 +12,8 @@ AI Workflow is a portable, spec-driven development system for AI coding agents.
|
|
|
12
12
|
- [Security and trust](security.md)
|
|
13
13
|
- [Quality and testing](quality.md)
|
|
14
14
|
- [Token efficiency](token-efficiency.md)
|
|
15
|
+
- [Token efficiency research](token-efficiency-research.md)
|
|
16
|
+
- [Token efficiency study notes](token-efficiency-study-notes.md)
|
|
15
17
|
- [Project intelligence](project-intelligence.md)
|
|
16
18
|
- [Semantic mapping](semantic-mapping.md)
|
|
17
19
|
- [Decision model evaluation](decision-model-evaluation.md)
|