@multileaf/ai-workflow 0.6.1 → 0.8.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/dist/workflow.js CHANGED
@@ -34,9 +34,15 @@ import { enforceOrganizationPolicy, parseOrganizationPolicy, serializeOrganizati
34
34
  import { composeContext, CONTEXT_LAYERS, readContextStore, writeContextLayer, } from "./context-store.js";
35
35
  import { loadContextDelta, retrieveTaskContext } from "./context-retrieval.js";
36
36
  import { createSummaryCache, isSummaryFresh, parseSummaryCache, serializeSummaryCache, } from "./context-cache.js";
37
- import { parseTokenUsage, recordTokenUsage, serializeTokenUsage } from "./token-usage.js";
38
- import { APPROVAL_STAGES, approveStage, beginStageApproval, createApprovalLedger, getUnresolvedPrerequisites, invalidateStageAndDescendants, parseApprovalLedger, parseWorkflowSession, rejectStage, serializeApprovalLedger, serializeWorkflowSession, skipOptionalStage, stageDefinition, stageFingerprint, } from "./human-approval.js";
37
+ import { parseTokenUsage, recordTokenUsage, serializeTokenUsage, summarizeUsageByModel, } from "./token-usage.js";
38
+ import { APPROVAL_STAGES, assertWorkflowId, validateTaskTicket, approveStage, beginStageApproval, createApprovalLedger, getUnresolvedPrerequisites, invalidateStageAndDescendants, parseApprovalLedger, parseWorkflowSession, rejectStage, serializeApprovalLedger, serializeWorkflowSession, skipOptionalStage, stageDefinition, stageFingerprint, } from "./human-approval.js";
39
39
  import { measureContextQuality, serializeContextQuality } from "./context-quality.js";
40
+ import { collectContextBench, compareContextBench, parseContextBench, serializeContextBench, } from "./context-benchmark.js";
41
+ import { materializeParityTask, parseParityTask, runParitySuite, serializeParitySuite, TASK_ASSEMBLER, } from "./task-parity.js";
42
+ import { appendUsageSidecarEntry, parseUsageSidecar, serializeUsageSidecar, serializeUsageSidecarSummary, summarizeUsageSidecar, USAGE_SIDECAR_PATH, USAGE_TIERS, } from "./usage-sidecar.js";
43
+ import { computeCostAttribution, serializeCostAttribution, serializeTaskUsage, summarizeTaskUsage, } from "./cost-attribution.js";
44
+ import { defaultRoutingTable, parseRoutingTable, resolveRoute, ROUTING_TABLE_PATH, serializeResolvedRoute, serializeRoutingTable, } from "./model-routing.js";
45
+ import { parseTraceabilityGraph } from "./traceability.js";
40
46
  import { serializeAdapterCapabilities } from "./adapter-contract.js";
41
47
  import { buildTraceabilityGraph, serializeTraceabilityGraph } from "./traceability.js";
42
48
  import { evaluateQualityGate } from "./quality-gates.js";
@@ -46,61 +52,122 @@ import { enforcePolicyGate, gitPolicyTracking } from "./policy-gate.js";
46
52
  import { DEFAULT_TELEMETRY_CONFIG, loadTelemetryConfig, offerTelemetryConsent, parseTelemetryConfig, purgeTelemetryBuffer, readTelemetryBuffer, serializeTelemetryMapping, TELEMETRY_BUFFER_CAP, TELEMETRY_BUFFER_FILE, } from "./telemetry.js";
47
53
  import { createPluginScaffold, validatePluginDirectory, writePluginScaffold, } from "./plugin-sdk.js";
48
54
  import { executeOrchestrationPlan, parseOrchestrationPlan, serializeOrchestrationPreview, serializeOrchestrationResults, validateOrchestrationParallelism, } from "./orchestration.js";
55
+ function beginWorkflowSession(fs, aiw, options) {
56
+ const sessionPath = join(aiw, "workflow.yml");
57
+ const ledgerPath = join(aiw, "approvals.yml");
58
+ if (fs.exists(sessionPath)) {
59
+ const previous = parseWorkflowSession(fs.read(sessionPath));
60
+ if (previous.status === "active")
61
+ return {
62
+ exitCode: 1,
63
+ error: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
64
+ };
65
+ if (!fs.exists(ledgerPath))
66
+ return { exitCode: 1, error: "Completed workflow is missing its approval ledger." };
67
+ const archive = join(aiw, `checkpoints/workflow-${previous.workflowId}-approvals.yml`);
68
+ if (fs.exists(archive))
69
+ return { exitCode: 1, error: `Workflow approval archive already exists: ${archive}` };
70
+ fs.mkdir(join(aiw, "checkpoints"));
71
+ fs.write(archive, fs.read(ledgerPath));
72
+ }
73
+ else if (fs.exists(ledgerPath)) {
74
+ return {
75
+ exitCode: 1,
76
+ error: "Approval ledger exists without a workflow session; run doctor.",
77
+ };
78
+ }
79
+ const workflowId = options.id ?? randomUUID();
80
+ const priorRun = join(aiw, `checkpoints/workflow-${workflowId}-approvals.yml`);
81
+ if (fs.exists(priorRun))
82
+ return {
83
+ exitCode: 1,
84
+ error: `Workflow ID has already been used: ${workflowId}. Choose a new ID.`,
85
+ };
86
+ const now = new Date().toISOString();
87
+ const session = {
88
+ schema: 1,
89
+ workflowId,
90
+ status: "active",
91
+ startedAt: now,
92
+ updatedAt: now,
93
+ ...(options.ticket ? { ticket: options.ticket } : {}),
94
+ ...(options.title ? { title: options.title } : {}),
95
+ };
96
+ const ledger = createApprovalLedger(workflowId);
97
+ fs.write(ledgerPath, serializeApprovalLedger(ledger));
98
+ fs.write(sessionPath, serializeWorkflowSession(session));
99
+ return { exitCode: 0, output: `Workflow started: ${workflowId}.` };
100
+ }
49
101
  export async function runCommand(args, root, fs, services = {}) {
50
102
  const aiw = join(root, WORKFLOW_DIRECTORY);
51
103
  try {
52
104
  fs = withSafeProjectWrites(fs, root);
53
105
  const command = args[0] ?? "help";
106
+ if (command === "task") {
107
+ if (!fs.exists(join(aiw, "manifest.yml")))
108
+ return { exitCode: 1, error: "Run `aiw install` first." };
109
+ const usage = "Usage: aiw task start --ticket=TEAM-123 [--title=text] [--id=workflow-id] | aiw task status";
110
+ const action = args[1];
111
+ const sessionPath = join(aiw, "workflow.yml");
112
+ if (action === "start") {
113
+ const options = parseNamedOptions(args.slice(2), ["ticket", "title", "id"]);
114
+ if (!options?.ticket)
115
+ return { exitCode: 1, error: usage };
116
+ validateTaskTicket(options.ticket);
117
+ if (options.id !== undefined) {
118
+ try {
119
+ assertWorkflowId(options.id);
120
+ }
121
+ catch {
122
+ return { exitCode: 1, error: "Workflow ID must be a UUID." };
123
+ }
124
+ }
125
+ const result = beginWorkflowSession(fs, aiw, {
126
+ ...(options.id ? { id: options.id } : {}),
127
+ ticket: options.ticket,
128
+ ...(options.title ? { title: options.title } : {}),
129
+ });
130
+ if (result.exitCode !== 0)
131
+ return result;
132
+ const title = options.title ? ` titled "${options.title}"` : "";
133
+ return {
134
+ exitCode: 0,
135
+ output: `Task started for ticket ${options.ticket}${title}: ${result.output}\n` +
136
+ `Next steps: ask the user whether the requirement is clear enough to skip brainstorming (record an accepted skip with \`aiw skip brainstorming --reason="Requirement provided by ticket ${options.ticket}"\`), then create the specification with \`aiw spec --ticket=${options.ticket} --title=...\`.`,
137
+ };
138
+ }
139
+ if (action === "status") {
140
+ if (!fs.exists(sessionPath))
141
+ return {
142
+ exitCode: 0,
143
+ output: "No SDD workflow is active. Start one with `aiw task start --ticket=TEAM-123`.",
144
+ };
145
+ const { session, ledger } = readWorkflowState(aiw, fs, false);
146
+ const lines = [
147
+ ...(session.ticket
148
+ ? [`Ticket: ${session.ticket}${session.title ? ` — ${session.title}` : ""}`]
149
+ : []),
150
+ `Workflow: ${session.workflowId} (${session.status})`,
151
+ "Stages:",
152
+ ...APPROVAL_STAGES.map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`),
153
+ ];
154
+ const next = APPROVAL_STAGES.find((stage) => (ledger.stages[stage]?.status ?? "not-started") === "not-started");
155
+ if (session.status === "active" && next)
156
+ lines.push(`Next: ${next === "brainstorming" && session.ticket ? `decide brainstorm or record an accepted skip (ticket ${session.ticket} already provides the requirement)` : `advance ${next} (gate, approval)`}.`);
157
+ return { exitCode: 0, output: lines.join("\n") };
158
+ }
159
+ return { exitCode: 1, error: usage };
160
+ }
54
161
  if (command === "workflow") {
55
162
  if (!fs.exists(join(aiw, "manifest.yml")))
56
163
  return { exitCode: 1, error: "Run `aiw install` first." };
57
164
  const action = args[1];
58
165
  const sessionPath = join(aiw, "workflow.yml");
59
- const ledgerPath = join(aiw, "approvals.yml");
60
166
  if (action === "start") {
61
167
  const options = parseNamedOptions(args.slice(2), ["id"]);
62
168
  if (!options)
63
169
  return { exitCode: 1, error: "Usage: aiw workflow start [--id=workflow-id]" };
64
- if (fs.exists(sessionPath)) {
65
- const previous = parseWorkflowSession(fs.read(sessionPath));
66
- if (previous.status === "active")
67
- return {
68
- exitCode: 1,
69
- error: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
70
- };
71
- if (!fs.exists(ledgerPath))
72
- return { exitCode: 1, error: "Completed workflow is missing its approval ledger." };
73
- const archive = join(aiw, `checkpoints/workflow-${previous.workflowId}-approvals.yml`);
74
- if (fs.exists(archive))
75
- return { exitCode: 1, error: `Workflow approval archive already exists: ${archive}` };
76
- fs.mkdir(join(aiw, "checkpoints"));
77
- fs.write(archive, fs.read(ledgerPath));
78
- }
79
- else if (fs.exists(ledgerPath)) {
80
- return {
81
- exitCode: 1,
82
- error: "Approval ledger exists without a workflow session; run doctor.",
83
- };
84
- }
85
- const workflowId = options.id ?? randomUUID();
86
- const priorRun = join(aiw, `checkpoints/workflow-${workflowId}-approvals.yml`);
87
- if (fs.exists(priorRun))
88
- return {
89
- exitCode: 1,
90
- error: `Workflow ID has already been used: ${workflowId}. Choose a new ID.`,
91
- };
92
- const now = new Date().toISOString();
93
- const session = {
94
- schema: 1,
95
- workflowId,
96
- status: "active",
97
- startedAt: now,
98
- updatedAt: now,
99
- };
100
- const ledger = createApprovalLedger(workflowId);
101
- fs.write(ledgerPath, serializeApprovalLedger(ledger));
102
- fs.write(sessionPath, serializeWorkflowSession(session));
103
- return { exitCode: 0, output: `Workflow started: ${workflowId}.` };
170
+ return beginWorkflowSession(fs, aiw, { id: options.id });
104
171
  }
105
172
  if (action === "status") {
106
173
  if (!fs.exists(sessionPath))
@@ -359,13 +426,44 @@ export async function runCommand(args, root, fs, services = {}) {
359
426
  if (command === "telemetry") {
360
427
  if (!fs.exists(join(aiw, "manifest.yml")))
361
428
  return { exitCode: 1, error: "Run `aiw install` first." };
362
- const usage = "Usage: aiw telemetry <status|enable|disable> [--verbose] [--commands=include|exclude] [--outcomes=include|exclude]";
429
+ const usage = "Usage: aiw telemetry <status|show|enable|disable> [--verbose] [--commands=include|exclude] [--outcomes=include|exclude]";
363
430
  const action = args[1];
364
431
  if (!action)
365
432
  return { exitCode: 1, error: usage };
366
433
  const telemetryConfig = loadTelemetryConfig(fs, aiw);
367
434
  const actionArgs = args.slice(2).filter((arg) => arg !== "--verbose");
368
435
  const verbose = args.includes("--verbose");
436
+ if (action === "show") {
437
+ if (actionArgs.length !== 0)
438
+ return { exitCode: 1, error: usage };
439
+ const buffered = readTelemetryBuffer(fs, aiw);
440
+ const byCommand = new Map();
441
+ for (const event of buffered) {
442
+ const key = event.command ?? "unknown";
443
+ const entry = byCommand.get(key) ?? { success: 0, failure: 0 };
444
+ if (event.outcome === "failure")
445
+ entry.failure += 1;
446
+ else
447
+ entry.success += 1;
448
+ byCommand.set(key, entry);
449
+ }
450
+ const lines = [
451
+ `Telemetry is ${telemetryConfig.enabled ? "enabled" : "disabled"}.`,
452
+ `Buffered events: ${buffered.length}/${TELEMETRY_BUFFER_CAP} (.aiw/${TELEMETRY_BUFFER_FILE}).`,
453
+ ];
454
+ if (buffered.length > 0) {
455
+ lines.push("By command:");
456
+ for (const [command, { success, failure }] of [...byCommand].sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
457
+ lines.push(`- ${command}: ${success} success, ${failure} failure`);
458
+ }
459
+ if (verbose && buffered.length > 0) {
460
+ lines.push("Events:");
461
+ lines.push(...buffered.map((event) => `- ${JSON.stringify(event)}`));
462
+ }
463
+ if (!telemetryConfig.enabled)
464
+ lines.push("Note: telemetry is disabled, so no new events are recorded; the buffer keeps previously collected events until purged.");
465
+ return { exitCode: 0, output: lines.join("\n") };
466
+ }
369
467
  if (action === "status") {
370
468
  if (actionArgs.length !== 0)
371
469
  return { exitCode: 1, error: usage };
@@ -438,16 +536,120 @@ export async function runCommand(args, root, fs, services = {}) {
438
536
  }
439
537
  return { exitCode: 1, error: usage };
440
538
  }
539
+ if (command === "context-bench") {
540
+ if (!fs.exists(join(aiw, "manifest.yml")))
541
+ return { exitCode: 1, error: "Run `aiw install` first." };
542
+ const saveCount = args.filter((arg) => arg === "--save").length;
543
+ if (saveCount > 1)
544
+ return { exitCode: 1, error: "Usage: aiw context-bench [--baseline=path] [--save]" };
545
+ const save = saveCount === 1;
546
+ const parsed = parseNamedOptions(args.slice(1).filter((arg) => arg !== "--save"), ["baseline"]);
547
+ if (!parsed)
548
+ return { exitCode: 1, error: "Usage: aiw context-bench [--baseline=path] [--save]" };
549
+ const report = collectContextBench(root, fs);
550
+ const serialized = serializeContextBench(report);
551
+ const lines = [serialized.trimEnd()];
552
+ if (parsed.baseline) {
553
+ const baselinePath = safeProjectFile(root, parsed.baseline, fs);
554
+ if (!fs.exists(baselinePath))
555
+ return { exitCode: 1, error: `Baseline not found: ${parsed.baseline}` };
556
+ const comparison = compareContextBench(parseContextBench(fs.read(baselinePath)), report);
557
+ lines.push(`Baseline: ${parsed.baseline} (bundle ${comparison.bundleChanged ? "changed" : "identical"}, token delta ${comparison.totalTokenDelta >= 0 ? "+" : ""}${comparison.totalTokenDelta})`);
558
+ if (comparison.regressions.length)
559
+ return {
560
+ exitCode: 1,
561
+ error: `Standing context regressed against baseline:\n${comparison.regressions.map((regression) => `- ${regression}`).join("\n")}`,
562
+ output: lines.join("\n"),
563
+ };
564
+ lines.push("No regressions against baseline.");
565
+ }
566
+ if (save) {
567
+ const checkpoint = join(aiw, `checkpoints/context-bench-${report.bundleHash}.yml`);
568
+ if (!fs.createExclusive)
569
+ return { exitCode: 1, error: "The filesystem does not support atomic checkpoints." };
570
+ if (!fs.createExclusive(checkpoint, serialized)) {
571
+ if (fs.read(checkpoint) !== serialized)
572
+ return {
573
+ exitCode: 1,
574
+ error: `Checkpoint already exists with different content: ${checkpoint}`,
575
+ };
576
+ lines.push(`Checkpoint already recorded: ${checkpoint}`);
577
+ }
578
+ else {
579
+ lines.push(`Checkpoint saved: ${checkpoint}`);
580
+ }
581
+ }
582
+ return { exitCode: 0, output: lines.join("\n") };
583
+ }
584
+ if (command === "parity") {
585
+ if (!fs.exists(join(aiw, "manifest.yml")))
586
+ return { exitCode: 1, error: "Run `aiw install` first." };
587
+ const parsed = parseNamedOptions(args.slice(1), ["tasks"]);
588
+ if (!parsed)
589
+ return { exitCode: 1, error: "Usage: aiw parity [--tasks=directory]" };
590
+ const tasksPath = parsed.tasks;
591
+ if (tasksPath && (isAbsolute(tasksPath) || tasksPath.split(/[\\/]/).includes("..")))
592
+ return {
593
+ exitCode: 1,
594
+ error: "Parity tasks directory must be a safe project-relative path.",
595
+ };
596
+ const tasksDirectory = tasksPath ? join(root, tasksPath) : join(aiw, "parity");
597
+ if (fs.pathType?.(tasksDirectory) !== "directory" || !fs.list)
598
+ return {
599
+ exitCode: 1,
600
+ error: `Parity tasks directory not found: ${relative(root, tasksDirectory).split(sep).join("/")}. Add YAML tasks (see docs/token-efficiency.md).`,
601
+ };
602
+ const taskFiles = (fs.list(tasksDirectory) ?? [])
603
+ .filter((name) => name.endsWith(".yml"))
604
+ .sort();
605
+ if (!taskFiles.length)
606
+ return {
607
+ exitCode: 1,
608
+ 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.`,
609
+ };
610
+ const tasks = taskFiles.map((name) => {
611
+ const path = join(tasksDirectory, name);
612
+ try {
613
+ return materializeParityTask(parseParityTask(fs.read(path)), (source) => {
614
+ const absolute = join(root, source);
615
+ if (fs.pathType?.(absolute) !== "file")
616
+ throw new Error("source is not a file");
617
+ return fs.read(absolute);
618
+ });
619
+ }
620
+ catch (error) {
621
+ throw new Error(`${name}: ${error.message}`, { cause: error });
622
+ }
623
+ });
624
+ const suite = runParitySuite(tasks, TASK_ASSEMBLER);
625
+ const serialized = serializeParitySuite(suite);
626
+ if (suite.failed)
627
+ return {
628
+ exitCode: 1,
629
+ output: serialized.trimEnd(),
630
+ 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.`,
631
+ };
632
+ return {
633
+ exitCode: 0,
634
+ output: serialized.trimEnd() +
635
+ `\nTask parity passed: every critical fact survived the ${TASK_ASSEMBLER.name} assembler.`,
636
+ };
637
+ }
441
638
  if (command === "scan") {
442
639
  if (!fs.exists(join(aiw, "manifest.yml")))
443
640
  return { exitCode: 1, error: "Run `aiw install` first." };
444
641
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
445
642
  let interpreter = services.interpreter;
446
643
  let interpreterModel;
644
+ let interpreterUsage;
447
645
  if (!interpreter) {
448
646
  const aiConfig = loadAiProviderConfig(fs, aiw);
449
647
  if (aiConfig?.enabled) {
450
- interpreter = createProjectInterpreter(root, services.aiProvider ?? createOpenAiCompatibleProvider(aiConfig));
648
+ interpreter = createProjectInterpreter(root, services.aiProvider ?? createOpenAiCompatibleProvider(aiConfig), undefined, {
649
+ onUsage: (usage) => {
650
+ interpreterUsage = usage;
651
+ },
652
+ });
451
653
  interpreterModel = aiConfig.model;
452
654
  }
453
655
  }
@@ -477,6 +679,7 @@ export async function runCommand(args, root, fs, services = {}) {
477
679
  label: "embedding",
478
680
  tokens: Math.ceil(buildInterpreterQuery(profile).length / 4),
479
681
  detail: `${contextConfig.model} — scoped interpreter context to ${matches.length} semantic matches`,
682
+ model: contextConfig.model,
480
683
  });
481
684
  }
482
685
  }
@@ -488,10 +691,14 @@ export async function runCommand(args, root, fs, services = {}) {
488
691
  const interpretationRequest = createInterpretationRequest(profile, scopedFiles, 4000, priorityFiles);
489
692
  const inferred = await interpreter.interpret(interpretationRequest);
490
693
  profile.facts = mergeFacts(profile.facts, inferred);
694
+ const actualTokens = interpreterUsage
695
+ ? interpreterUsage.promptTokens + interpreterUsage.completionTokens
696
+ : undefined;
491
697
  modelUsage.push({
492
698
  label: "interpreter",
493
- tokens: interpretationRequest.estimatedTokens,
494
- detail: `${interpreterModel} — ${inferred.length} facts from ${interpretationRequest.files.length} files`,
699
+ tokens: actualTokens ?? interpretationRequest.estimatedTokens,
700
+ detail: `${interpreterModel} — ${inferred.length} facts from ${interpretationRequest.files.length} files${actualTokens !== undefined ? " (measured)" : ""}`,
701
+ model: interpreterModel ?? "unknown",
495
702
  });
496
703
  }
497
704
  const overrides = readFactOverrides(fs, aiw);
@@ -530,6 +737,7 @@ export async function runCommand(args, root, fs, services = {}) {
530
737
  label: "embedding",
531
738
  tokens: Math.ceil(indexedChars / 4),
532
739
  detail: `${semanticConfig.model} — indexed ${index.chunks.length} chunks in ${Math.ceil(index.chunks.length / 64)} batch requests`,
740
+ model: semanticConfig.model,
533
741
  });
534
742
  }
535
743
  if (modelUsage.length > 0) {
@@ -541,8 +749,31 @@ export async function runCommand(args, root, fs, services = {}) {
541
749
  stage: `scan.${usage.label}`,
542
750
  budget: usage.tokens,
543
751
  used: usage.tokens,
752
+ model: usage.model,
544
753
  });
545
754
  fs.write(usagePath, serializeTokenUsage(next));
755
+ const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
756
+ const ledger = fs.exists(sidecarPath) ? parseUsageSidecar(fs.read(sidecarPath)) : [];
757
+ let sidecar = ledger;
758
+ for (const usage of modelUsage) {
759
+ const measured = usage.detail.includes("(measured)");
760
+ const isInterpreter = usage.label === "interpreter" && measured && interpreterUsage;
761
+ sidecar = appendUsageSidecarEntry(sidecar, {
762
+ command: `scan.${usage.label}`,
763
+ label: measured ? "measured" : "estimated",
764
+ tiers: isInterpreter
765
+ ? {
766
+ "cache-read": 0,
767
+ "fresh-input": interpreterUsage.promptTokens,
768
+ "cache-write": 0,
769
+ output: interpreterUsage.completionTokens,
770
+ }
771
+ : { "cache-read": 0, "fresh-input": usage.tokens, "cache-write": 0, output: 0 },
772
+ model: usage.model,
773
+ harness: "aiw-cli",
774
+ });
775
+ }
776
+ fs.write(sidecarPath, serializeUsageSidecar(sidecar));
546
777
  }
547
778
  return {
548
779
  exitCode: 0,
@@ -1076,6 +1307,7 @@ export async function runCommand(args, root, fs, services = {}) {
1076
1307
  stage: "recommend.decision",
1077
1308
  budget: decisionTokens,
1078
1309
  used: decisionTokens,
1310
+ model: rankingConfig.model,
1079
1311
  })));
1080
1312
  ordered = ranked.items;
1081
1313
  }
@@ -1409,8 +1641,11 @@ export async function runCommand(args, root, fs, services = {}) {
1409
1641
  return { exitCode: 1, error: "Run `aiw install` first." };
1410
1642
  assertStageReady(aiw, fs, "specification");
1411
1643
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Specification";
1644
+ const ticket = args.find((arg) => arg.startsWith("--ticket="))?.slice(9);
1645
+ if (ticket !== undefined)
1646
+ validateTaskTicket(ticket);
1412
1647
  const path = join(aiw, "generated/specs/specification.md");
1413
- fs.write(path, `# ${title}\n\n## Context\n\n## Requirements\n\n### REQ-001: Requirement title\n\n- **Description:**\n- **Acceptance criteria:**\n - Given\n - When\n - Then\n\n## Non-functional Requirements\n\n## Constraints\n\n## Open Questions\n\n`);
1648
+ fs.write(path, `# ${title}\n${ticket ? `\n> Ticket: ${ticket}\n` : ""}\n## Context\n\n## Requirements\n\n### REQ-001: Requirement title\n\n- **Description:**\n- **Acceptance criteria:**\n - Given\n - When\n - Then\n\n## Non-functional Requirements\n\n## Constraints\n\n## Open Questions\n\n`);
1414
1649
  return { exitCode: 0, output: `Specification artifact created: ${path}` };
1415
1650
  }
1416
1651
  if (command === "adr") {
@@ -1814,6 +2049,183 @@ export async function runCommand(args, root, fs, services = {}) {
1814
2049
  fs.write(cachePath, serializeSummaryCache(createSummaryCache(context, summaryArg)));
1815
2050
  return { exitCode: 0, output: summaryArg };
1816
2051
  }
2052
+ if (command === "routing") {
2053
+ if (!fs.exists(join(aiw, "manifest.yml")))
2054
+ return { exitCode: 1, error: "Run `aiw install` first." };
2055
+ const usage = "Usage: aiw routing init | show | resolve --category=name";
2056
+ const action = args[1];
2057
+ const routingPath = join(aiw, ROUTING_TABLE_PATH);
2058
+ if (action === "init") {
2059
+ if (fs.exists(routingPath))
2060
+ return {
2061
+ exitCode: 1,
2062
+ error: `Routing table already exists: .aiw/${ROUTING_TABLE_PATH}. Edit it directly instead of re-initializing.`,
2063
+ };
2064
+ fs.write(routingPath, serializeRoutingTable(defaultRoutingTable()) +
2065
+ "# Add one rule per task category (dot-separated kebab-case, e.g. scan.interpreter).\n" +
2066
+ "# Name the evidence behind every rule — a benchmark, parity run, or ADR — before trusting it.\n" +
2067
+ "# Routing is advisory: humans can always override the resolved model.\n");
2068
+ return {
2069
+ exitCode: 0,
2070
+ output: `Routing table created: .aiw/${ROUTING_TABLE_PATH}. Add rules with named evidence, then resolve with \`aiw routing resolve --category=...\`.`,
2071
+ };
2072
+ }
2073
+ if (action === "show") {
2074
+ if (!fs.exists(routingPath))
2075
+ return {
2076
+ exitCode: 1,
2077
+ error: `No routing table found. Run \`aiw routing init\` to create .aiw/${ROUTING_TABLE_PATH}.`,
2078
+ };
2079
+ return {
2080
+ exitCode: 0,
2081
+ output: serializeRoutingTable(parseRoutingTable(fs.read(routingPath))).trimEnd(),
2082
+ };
2083
+ }
2084
+ if (action === "resolve") {
2085
+ const category = args.find((arg) => arg.startsWith("--category="))?.slice(11);
2086
+ if (!category)
2087
+ return { exitCode: 1, error: usage };
2088
+ if (!fs.exists(routingPath))
2089
+ return {
2090
+ exitCode: 1,
2091
+ error: `No routing table found. Run \`aiw routing init\` first.`,
2092
+ };
2093
+ let route;
2094
+ try {
2095
+ route = resolveRoute(parseRoutingTable(fs.read(routingPath)), category);
2096
+ }
2097
+ catch (error) {
2098
+ throw new Error(`Routing failed: ${error.message}`, { cause: error });
2099
+ }
2100
+ return {
2101
+ exitCode: 0,
2102
+ output: serializeResolvedRoute(route) +
2103
+ "\noverride: advisory only — a human may choose a different model at any time.",
2104
+ };
2105
+ }
2106
+ return { exitCode: 1, error: usage };
2107
+ }
2108
+ if (command === "task-usage") {
2109
+ if (!fs.exists(join(aiw, "manifest.yml")))
2110
+ return { exitCode: 1, error: "Run `aiw install` first." };
2111
+ const parsed = parseNamedOptions(args.slice(1), ["workflow"]);
2112
+ if (!parsed)
2113
+ return { exitCode: 1, error: "Usage: aiw task-usage [--workflow=id-or-ticket]" };
2114
+ const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
2115
+ if (!fs.exists(sidecarPath))
2116
+ return {
2117
+ exitCode: 1,
2118
+ error: "No usage sidecar recorded yet. Correlate runs with `aiw usage-sidecar record --workflow=<id-or-ticket>` or run `aiw scan`.",
2119
+ };
2120
+ const sessionPath = join(aiw, "workflow.yml");
2121
+ const workflow = parsed.workflow ??
2122
+ (fs.exists(sessionPath)
2123
+ ? (parseWorkflowSession(fs.read(sessionPath)).ticket ??
2124
+ parseWorkflowSession(fs.read(sessionPath)).workflowId)
2125
+ : undefined);
2126
+ if (!workflow)
2127
+ return {
2128
+ exitCode: 1,
2129
+ error: "No workflow specified and no active SDD task. Pass --workflow=<id-or-ticket>.",
2130
+ };
2131
+ const summary = summarizeTaskUsage(parseUsageSidecar(fs.read(sidecarPath)), workflow);
2132
+ return {
2133
+ exitCode: 0,
2134
+ output: serializeTaskUsage(summary),
2135
+ };
2136
+ }
2137
+ if (command === "cost-report") {
2138
+ if (!fs.exists(join(aiw, "manifest.yml")))
2139
+ return { exitCode: 1, error: "Run `aiw install` first." };
2140
+ const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
2141
+ if (!fs.exists(sidecarPath))
2142
+ return {
2143
+ exitCode: 1,
2144
+ error: "No usage sidecar recorded yet. Record runs with `aiw usage-sidecar record` or run `aiw scan`.",
2145
+ };
2146
+ const tracePath = join(aiw, "generated/artifacts/traceability.yml");
2147
+ if (!fs.exists(tracePath))
2148
+ return {
2149
+ exitCode: 1,
2150
+ error: "Traceability graph is missing. Run `aiw trace` to build it from the approved specification and plan.",
2151
+ };
2152
+ const attribution = computeCostAttribution(parseUsageSidecar(fs.read(sidecarPath)), parseTraceabilityGraph(fs.read(tracePath)));
2153
+ return {
2154
+ exitCode: 0,
2155
+ output: serializeCostAttribution(attribution) +
2156
+ "\nSources: .aiw/checkpoints/usage-sidecar.yml + .aiw/generated/artifacts/traceability.yml (verified = linked evidence present).",
2157
+ };
2158
+ }
2159
+ if (command === "usage-sidecar") {
2160
+ if (!fs.exists(join(aiw, "manifest.yml")))
2161
+ return { exitCode: 1, error: "Run `aiw install` first." };
2162
+ 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";
2163
+ const action = args[1];
2164
+ const sidecarPath = join(aiw, USAGE_SIDECAR_PATH);
2165
+ if (action === "report") {
2166
+ const entries = fs.exists(sidecarPath) ? parseUsageSidecar(fs.read(sidecarPath)) : [];
2167
+ return {
2168
+ exitCode: 0,
2169
+ output: serializeUsageSidecarSummary(summarizeUsageSidecar(entries)) +
2170
+ (entries.length
2171
+ ? `\nLedger: .aiw/${USAGE_SIDECAR_PATH} (${entries.length} entries, sequence-ordered, no timestamps)`
2172
+ : ""),
2173
+ };
2174
+ }
2175
+ if (action === "record") {
2176
+ const recordArgs = args.slice(2).filter((arg) => arg !== "--estimated");
2177
+ if (recordArgs.some((arg) => arg !== "--estimated" && !arg.startsWith("--")))
2178
+ return { exitCode: 1, error: usage };
2179
+ const parsed = parseNamedOptions(recordArgs, [
2180
+ "command",
2181
+ "model",
2182
+ "harness",
2183
+ "harness-version",
2184
+ "requirement",
2185
+ "workflow",
2186
+ ...USAGE_TIERS,
2187
+ ]);
2188
+ if (!parsed?.command)
2189
+ return { exitCode: 1, error: usage };
2190
+ const tierValues = USAGE_TIERS.map((tier) => [tier, parsed[tier]]).filter(([, value]) => value !== undefined);
2191
+ if (!tierValues.length)
2192
+ return {
2193
+ exitCode: 1,
2194
+ error: "At least one token tier is required: --cache-read, --fresh-input, --cache-write, or --output.",
2195
+ };
2196
+ for (const [, value] of tierValues)
2197
+ if (!/^\d+$/.test(value))
2198
+ return { exitCode: 1, error: "Token tiers must be non-negative integers." };
2199
+ const entry = {
2200
+ command: parsed.command,
2201
+ label: (args.includes("--estimated") ? "estimated" : "measured"),
2202
+ tiers: Object.fromEntries(USAGE_TIERS.map((tier) => [tier, Number(parsed[tier] ?? 0)])),
2203
+ ...(parsed.model ? { model: parsed.model } : {}),
2204
+ ...(parsed.harness ? { harness: parsed.harness } : {}),
2205
+ ...(parsed["harness-version"] ? { harnessVersion: parsed["harness-version"] } : {}),
2206
+ ...(parsed.requirement ? { requirement: parsed.requirement } : {}),
2207
+ ...(parsed.workflow ? { workflow: parsed.workflow } : {}),
2208
+ };
2209
+ let ledger;
2210
+ try {
2211
+ ledger = fs.exists(sidecarPath) ? parseUsageSidecar(fs.read(sidecarPath)) : [];
2212
+ }
2213
+ catch (error) {
2214
+ throw new Error(`Usage sidecar is unreadable: ${error.message}`, {
2215
+ cause: error,
2216
+ });
2217
+ }
2218
+ const next = appendUsageSidecarEntry(ledger, entry);
2219
+ fs.write(sidecarPath, serializeUsageSidecar(next));
2220
+ return {
2221
+ exitCode: 0,
2222
+ output: `Usage recorded (seq ${next[next.length - 1].seq}, ${entry.label}): ${entry.command} — ${USAGE_TIERS.filter((tier) => entry.tiers[tier] > 0)
2223
+ .map((tier) => `${tier} ${entry.tiers[tier].toLocaleString("en-US")}`)
2224
+ .join(", ")}`,
2225
+ };
2226
+ }
2227
+ return { exitCode: 1, error: usage };
2228
+ }
1817
2229
  if (command === "token-usage") {
1818
2230
  const usagePath = join(aiw, "usage.yml");
1819
2231
  const entries = fs.exists(usagePath) ? parseTokenUsage(fs.read(usagePath)) : [];
@@ -1834,7 +2246,15 @@ export async function runCommand(args, root, fs, services = {}) {
1834
2246
  fs.write(usagePath, serializeTokenUsage(next));
1835
2247
  return { exitCode: 0, output: `Token usage recorded: ${stage} (${used}/${budget})` };
1836
2248
  }
1837
- return { exitCode: 0, output: serializeTokenUsage(entries) };
2249
+ if (entries.length === 0)
2250
+ return { exitCode: 0, output: "No token usage recorded yet." };
2251
+ const summary = summarizeUsageByModel(entries);
2252
+ return {
2253
+ exitCode: 0,
2254
+ output: `Token usage by model:\n${summary
2255
+ .map((entry) => `- ${entry.model}: ${entry.tokens.toLocaleString("en-US")} tokens (${entry.stages.join(", ")})`)
2256
+ .join("\n")}\n\nRaw records:\n${serializeTokenUsage(entries)}`,
2257
+ };
1838
2258
  }
1839
2259
  if (command === "context-quality") {
1840
2260
  const metrics = measureContextQuality(readContextStore(root, fs));
@@ -1880,7 +2300,7 @@ export async function runCommand(args, root, fs, services = {}) {
1880
2300
  }
1881
2301
  return {
1882
2302
  exitCode: 0,
1883
- output: "aiw install [--target target] | workflow <start|status|complete> [--id=id] | approve <stage> | reject <stage> --reason=text | skip <optional-stage> --reason=text | ui [--port=number] | orchestrate --plan=path [--execute] [--max-parallel=number] | telemetry <status|enable|disable> [privacy options] | plugin <create|validate> [options] | scan | semantic <status|query> | decision <status|triage> | self-validate --ticket=TYPE-000 | organization-policy --file=path [--replace] | policy-check --package=path [--package=path] | preset --file=path [--replace] | context | context-summary | context-quality | capabilities [--target=target] | token-usage [--stage=name --budget=number --used=number] | registry [--search=query] [--private=name] | registry configure --file=path [--replace] | skills <search|inspect|install|check|update> [options] | audit-package --package=path | verify-package --package=path --checksum=sha256 | brainstorm [--title=title] | spec [--title=title] | adr [--id=id --title=title] | plan [--title=title] | verify | trace | gate <stage> | recommend [--select=id,id] | status | doctor | repair | uninstall [--dry-run] | target <target> | rollback | confirm | resolve --package=path | update --package=path --target=target | validate [--package=path]",
2303
+ output: "aiw install [--target target] | workflow <start|status|complete> [--id=id] | task <start|status> --ticket=TEAM-123 [--title=text] | approve <stage> | reject <stage> --reason=text | skip <optional-stage> --reason=text | ui [--port=number] | orchestrate --plan=path [--execute] [--max-parallel=number] | telemetry <status|show|enable|disable> [privacy options] | plugin <create|validate> [options] | scan | semantic <status|query> | decision <status|triage> | self-validate --ticket=TYPE-000 | organization-policy --file=path [--replace] | policy-check --package=path [--package=path] | preset --file=path [--replace] | context | context-summary | context-quality | capabilities [--target=target] | token-usage [--stage=name --budget=number --used=number] | registry [--search=query] [--private=name] | registry configure --file=path [--replace] | skills <search|inspect|install|check|update> [options] | audit-package --package=path | verify-package --package=path --checksum=sha256 | brainstorm [--title=title] | spec [--title=title] | adr [--id=id --title=title] | plan [--title=title] | verify | trace | gate <stage> | recommend [--select=id,id] | status | doctor | repair | uninstall [--dry-run] | target <target> | rollback | confirm | resolve --package=path | update --package=path --target=target | validate [--package=path]",
1884
2304
  };
1885
2305
  }
1886
2306
  catch (error) {
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)