@multileaf/ai-workflow 0.6.1 → 0.7.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,14 @@ All notable changes to this project are documented in this file. The format foll
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.0] - 2026-10-13
8
+
9
+ ### Added
10
+
11
+ - Ticket-driven task entry point: `aiw task start --ticket=TEAM-123 [--title=text]` starts an SDD session that records the ticket, and `aiw task status` reports the ticket, stage statuses, and the next pending step with ticket-aware guidance. `aiw spec --ticket=TEAM-123` links the specification scaffold to the ticket. A new `ticket` skill (recommended as the `ticket-driven-development` capability) drives the flow in the host agent, keeping the brainstorming skip an explicit human decision.
12
+ - `aiw telemetry show` summarizes buffered events by command and outcome (fill level against the retention cap, per-command success/failure counts); `--verbose` adds each event verbatim. `telemetry status` keeps reporting configuration only.
13
+ - Per-model token usage: `usage.yml` records carry model attribution (`stage|budget|used|model`; legacy three-column rows still parse) and `aiw token-usage` summarizes total consumption by model. The OpenAI-compatible provider reports the response usage block, so interpreter usage is measured (marked `(measured)`) when the endpoint provides it and estimated otherwise; `scan.embedding`, `scan.interpreter`, and `recommend.decision` all record their model.
14
+
7
15
  ## [0.6.1] - 2026-10-13
8
16
 
9
17
  ### Added
package/README.md CHANGED
@@ -204,6 +204,13 @@ npx @multileaf/ai-workflow recommend --select=typescript-quality,verification
204
204
 
205
205
  # Track a feature from its first artifact through final human approval.
206
206
  npx @multileaf/ai-workflow workflow start
207
+
208
+ # Or start directly from a ticket / already-clear requirement (no brainstorm needed).
209
+ npx @multileaf/ai-workflow task start --ticket=PROJ-142 --title="Add export endpoint"
210
+ npx @multileaf/ai-workflow task status
211
+ # Record an accepted brainstorm skip, then link the specification to the ticket:
212
+ npx @multileaf/ai-workflow skip brainstorming --reason="Requirement provided by ticket PROJ-142"
213
+ npx @multileaf/ai-workflow spec --ticket=PROJ-142 --title="Add export endpoint"
207
214
  npx @multileaf/ai-workflow brainstorm --title="Feature name"
208
215
  npx @multileaf/ai-workflow gate brainstorming
209
216
  # Present evidence and wait for a human reply before each approve command.
@@ -343,7 +350,8 @@ The typed SDK is available from `@multileaf/ai-workflow/plugin-sdk`. It exports
343
350
  Telemetry is disabled by default and requires explicit consent — offered once during interactive installation with an explicit default of No. Each event contains only the command name and its outcome; never arguments, file paths, or file contents. When telemetry is enabled but no host transport is configured, events accumulate in a rotated local buffer (`.aiw/telemetry-buffer.jsonl`, 200-event cap) that `aiw telemetry status --verbose` can inspect and `aiw telemetry disable` purges.
344
351
 
345
352
  ```bash
346
- npx @multileaf/ai-workflow telemetry status --verbose
353
+ npx @multileaf/ai-workflow telemetry show # summarized by command and outcome
354
+ npx @multileaf/ai-workflow telemetry show --verbose # events verbatim
347
355
  npx @multileaf/ai-workflow telemetry enable
348
356
  npx @multileaf/ai-workflow telemetry disable
349
357
  ```
@@ -20,7 +20,10 @@ export type WorkflowSession = {
20
20
  status: "active" | "complete";
21
21
  startedAt: string;
22
22
  updatedAt: string;
23
+ ticket?: string;
24
+ title?: string;
23
25
  };
26
+ export declare function validateTaskTicket(ticket: string): void;
24
27
  export type ApprovalStageDefinition = {
25
28
  optional: boolean;
26
29
  requiresQualityGate: boolean;
@@ -49,3 +52,4 @@ export declare function skipOptionalStage(ledger: ApprovalLedger, stage: HumanAp
49
52
  export declare function invalidateStageAndDescendants(ledger: ApprovalLedger, stage: HumanApprovalStage, updatedAt: string): ApprovalLedger;
50
53
  export declare function serializeApprovalLedger(ledger: ApprovalLedger): string;
51
54
  export declare function parseApprovalLedger(content: string): ApprovalLedger;
55
+ export declare function assertWorkflowId(value: string): void;
@@ -2,6 +2,10 @@ import { createHash } from "node:crypto";
2
2
  import { parseDocument, stringify } from "yaml";
3
3
  import { HUMAN_APPROVAL_STAGES } from "./types.js";
4
4
  export const APPROVAL_STAGES = HUMAN_APPROVAL_STAGES;
5
+ export function validateTaskTicket(ticket) {
6
+ if (!/^[A-Z][A-Z0-9_]*-\d+$/.test(ticket))
7
+ throw new Error("Task ticket must match TEAM-123, for example PROJ-142.");
8
+ }
5
9
  const STAGE_DEFINITIONS = {
6
10
  brainstorming: { optional: true, requiresQualityGate: true },
7
11
  specification: { optional: false, requiresQualityGate: true },
@@ -22,7 +26,15 @@ const APPROVAL_RECORD_KEYS = new Set([
22
26
  "updatedAt",
23
27
  ]);
24
28
  const LEDGER_KEYS = new Set(["schema", "workflowId", "stages"]);
25
- const SESSION_KEYS = new Set(["schema", "workflowId", "status", "startedAt", "updatedAt"]);
29
+ const SESSION_KEYS = new Set([
30
+ "schema",
31
+ "workflowId",
32
+ "status",
33
+ "startedAt",
34
+ "updatedAt",
35
+ "ticket",
36
+ "title",
37
+ ]);
26
38
  export function createApprovalLedger(workflowId) {
27
39
  assertWorkflowId(workflowId);
28
40
  return { schema: 1, workflowId, stages: {} };
@@ -47,12 +59,20 @@ export function parseWorkflowSession(content) {
47
59
  throw new Error("Workflow session status must be active or complete.");
48
60
  if (typeof value.startedAt !== "string" || typeof value.updatedAt !== "string")
49
61
  throw new Error("Workflow session requires start and update timestamps.");
62
+ if (value.ticket !== undefined && typeof value.ticket !== "string")
63
+ throw new Error("Workflow session ticket must be a string.");
64
+ if (value.title !== undefined && typeof value.title !== "string")
65
+ throw new Error("Workflow session title must be a string.");
66
+ if (typeof value.ticket === "string")
67
+ validateTaskTicket(value.ticket);
50
68
  const session = {
51
69
  schema: 1,
52
70
  workflowId: value.workflowId,
53
71
  status: value.status,
54
72
  startedAt: value.startedAt,
55
73
  updatedAt: value.updatedAt,
74
+ ...(value.ticket === undefined ? {} : { ticket: value.ticket }),
75
+ ...(value.title === undefined ? {} : { title: value.title }),
56
76
  };
57
77
  validateWorkflowSession(session);
58
78
  return session;
@@ -189,7 +209,7 @@ function assertTimestamp(value) {
189
209
  !Number.isFinite(Date.parse(value)))
190
210
  throw new Error("Approval timestamp must be a valid date-time.");
191
211
  }
192
- function assertWorkflowId(value) {
212
+ export function assertWorkflowId(value) {
193
213
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value))
194
214
  throw new Error("Workflow ID must be 1–64 characters using letters, numbers, dot, underscore, or hyphen.");
195
215
  }
@@ -10,11 +10,16 @@ export type InterpretationRequest = {
10
10
  export type Interpreter = {
11
11
  interpret(request: InterpretationRequest): Promise<EvidenceFact[]>;
12
12
  };
13
+ export type AiProviderUsage = {
14
+ promptTokens: number;
15
+ completionTokens: number;
16
+ };
13
17
  export type AiProviderRequest = {
14
18
  system: string;
15
19
  prompt: string;
16
20
  maxTokens: number;
17
21
  responseFormat: "json";
22
+ onUsage?: (usage: AiProviderUsage) => void;
18
23
  };
19
24
  export type AiProvider = {
20
25
  generate(request: AiProviderRequest): Promise<string>;
@@ -35,5 +40,7 @@ export declare function buildInterpreterQuery(profile: ProjectProfile): string;
35
40
  type AiFetchLike = (url: string, init?: RequestInit) => Promise<Response>;
36
41
  export declare function loadAiProviderConfig(fs: FileSystem, aiwPath: string): AiProviderConfig | undefined;
37
42
  export declare function createOpenAiCompatibleProvider(config: AiProviderConfig, fetchImpl?: AiFetchLike, env?: Readonly<Record<string, string | undefined>>): AiProvider;
38
- export declare function createProjectInterpreter(root: string, provider: AiProvider, contextReader?: ContextReader): Interpreter;
43
+ export declare function createProjectInterpreter(root: string, provider: AiProvider, contextReader?: ContextReader, options?: {
44
+ onUsage?: (usage: AiProviderUsage) => void;
45
+ }): Interpreter;
39
46
  export {};
@@ -212,6 +212,12 @@ export function createOpenAiCompatibleProvider(config, fetchImpl = (url, init) =
212
212
  if (!response.ok)
213
213
  throw new Error(`AI provider request failed for model ${config.model}: ${await response.text()}`);
214
214
  const body = (await response.json());
215
+ if (typeof body.usage?.prompt_tokens === "number" &&
216
+ typeof body.usage?.completion_tokens === "number")
217
+ request.onUsage?.({
218
+ promptTokens: body.usage.prompt_tokens,
219
+ completionTokens: body.usage.completion_tokens,
220
+ });
215
221
  const content = body.choices?.[0]?.message?.content;
216
222
  if (typeof content !== "string")
217
223
  throw new Error(`AI provider ${config.model} returned no message content`);
@@ -219,7 +225,7 @@ export function createOpenAiCompatibleProvider(config, fetchImpl = (url, init) =
219
225
  },
220
226
  };
221
227
  }
222
- export function createProjectInterpreter(root, provider, contextReader = async (path) => new TextDecoder("utf-8", { fatal: true }).decode(await readFile(path))) {
228
+ export function createProjectInterpreter(root, provider, contextReader = async (path) => new TextDecoder("utf-8", { fatal: true }).decode(await readFile(path)), options = {}) {
223
229
  const projectRoot = resolve(root);
224
230
  return {
225
231
  async interpret(request) {
@@ -261,6 +267,7 @@ export function createProjectInterpreter(root, provider, contextReader = async (
261
267
  prompt: `Detected profile:\n${safeProfile}\n\nScoped project context:\n${sections.join("\n\n")}`,
262
268
  maxTokens: scoped.maxTokens,
263
269
  responseFormat: "json",
270
+ ...(options.onUsage ? { onUsage: options.onUsage } : {}),
264
271
  });
265
272
  return parseFacts(output, new Set(scoped.files));
266
273
  },
@@ -14,6 +14,19 @@ const CATALOG = [
14
14
  ? { evidence: projectEvidence(profile), confidence: 0.92 }
15
15
  : undefined,
16
16
  },
17
+ {
18
+ id: "ticket-driven-development",
19
+ provider: "multileaf",
20
+ rationale: "Work often arrives as a ticket or an already-clear requirement; this entry point starts the SDD flow from the ticket without a brainstorming phase.",
21
+ permissions: [],
22
+ resources: local([
23
+ { type: "skills", id: "ticket" },
24
+ { type: "templates", id: "task" },
25
+ ]),
26
+ matches: (profile) => hasProjectSignals(profile)
27
+ ? { evidence: projectEvidence(profile), confidence: 0.9 }
28
+ : undefined,
29
+ },
17
30
  {
18
31
  id: "requirements-specification",
19
32
  provider: "multileaf",
@@ -2,8 +2,14 @@ export type TokenUsage = {
2
2
  stage: string;
3
3
  budget: number;
4
4
  used: number;
5
+ model?: string;
5
6
  };
6
7
  export declare function validateTokenUsage(usage: TokenUsage): TokenUsage;
7
8
  export declare function parseTokenUsage(content: string): TokenUsage[];
8
9
  export declare function serializeTokenUsage(entries: TokenUsage[]): string;
9
10
  export declare function recordTokenUsage(entries: TokenUsage[], usage: TokenUsage): TokenUsage[];
11
+ export declare function summarizeUsageByModel(entries: TokenUsage[]): {
12
+ model: string;
13
+ tokens: number;
14
+ stages: string[];
15
+ }[];
@@ -10,6 +10,8 @@ export function validateTokenUsage(usage) {
10
10
  }
11
11
  if (usage.used > usage.budget)
12
12
  throw new Error(`Token budget exceeded for stage: ${usage.stage}`);
13
+ if (usage.model !== undefined && (typeof usage.model !== "string" || !usage.model.trim()))
14
+ throw new Error("Token usage model must be a non-empty string when present.");
13
15
  return usage;
14
16
  }
15
17
  export function parseTokenUsage(content) {
@@ -20,19 +22,44 @@ export function parseTokenUsage(content) {
20
22
  .slice(1)
21
23
  .filter(Boolean)
22
24
  .map((line) => {
23
- const [stage, budget, used] = line.split("|");
24
- return validateTokenUsage({ stage, budget: Number(budget), used: Number(used) });
25
+ const [stage, budget, used, model] = line.split("|");
26
+ return validateTokenUsage({
27
+ stage,
28
+ budget: Number(budget),
29
+ used: Number(used),
30
+ ...(model ? { model } : {}),
31
+ });
25
32
  });
26
33
  }
27
34
  export function serializeTokenUsage(entries) {
28
35
  return ([
29
36
  "schema: 1",
30
37
  ...entries
31
- .sort((a, b) => a.stage.localeCompare(b.stage))
32
- .map((entry) => `${entry.stage}|${entry.budget}|${entry.used}`),
38
+ .sort((a, b) => a.stage < b.stage
39
+ ? -1
40
+ : a.stage > b.stage
41
+ ? 1
42
+ : (a.model ?? "").localeCompare(b.model ?? ""))
43
+ .map((entry) => `${entry.stage}|${entry.budget}|${entry.used}${entry.model ? `|${entry.model}` : ""}`),
33
44
  ].join("\n") + "\n");
34
45
  }
35
46
  export function recordTokenUsage(entries, usage) {
36
47
  validateTokenUsage(usage);
37
- return [...entries.filter((entry) => entry.stage !== usage.stage), usage];
48
+ return [
49
+ ...entries.filter((entry) => entry.stage !== usage.stage || entry.model !== usage.model),
50
+ usage,
51
+ ];
52
+ }
53
+ export function summarizeUsageByModel(entries) {
54
+ const byModel = new Map();
55
+ for (const entry of entries) {
56
+ const model = entry.model ?? "unattributed";
57
+ const bucket = byModel.get(model) ?? { tokens: 0, stages: new Set() };
58
+ bucket.tokens += entry.used;
59
+ bucket.stages.add(entry.stage);
60
+ byModel.set(model, bucket);
61
+ }
62
+ return [...byModel]
63
+ .map(([model, { tokens, stages }]) => ({ model, tokens, stages: [...stages].sort() }))
64
+ .sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model));
38
65
  }
package/dist/workflow.js CHANGED
@@ -34,8 +34,8 @@ 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
40
  import { serializeAdapterCapabilities } from "./adapter-contract.js";
41
41
  import { buildTraceabilityGraph, serializeTraceabilityGraph } from "./traceability.js";
@@ -46,61 +46,122 @@ import { enforcePolicyGate, gitPolicyTracking } from "./policy-gate.js";
46
46
  import { DEFAULT_TELEMETRY_CONFIG, loadTelemetryConfig, offerTelemetryConsent, parseTelemetryConfig, purgeTelemetryBuffer, readTelemetryBuffer, serializeTelemetryMapping, TELEMETRY_BUFFER_CAP, TELEMETRY_BUFFER_FILE, } from "./telemetry.js";
47
47
  import { createPluginScaffold, validatePluginDirectory, writePluginScaffold, } from "./plugin-sdk.js";
48
48
  import { executeOrchestrationPlan, parseOrchestrationPlan, serializeOrchestrationPreview, serializeOrchestrationResults, validateOrchestrationParallelism, } from "./orchestration.js";
49
+ function beginWorkflowSession(fs, aiw, options) {
50
+ const sessionPath = join(aiw, "workflow.yml");
51
+ const ledgerPath = join(aiw, "approvals.yml");
52
+ if (fs.exists(sessionPath)) {
53
+ const previous = parseWorkflowSession(fs.read(sessionPath));
54
+ if (previous.status === "active")
55
+ return {
56
+ exitCode: 1,
57
+ error: `Workflow ${previous.workflowId} is still active. Complete it before starting another.`,
58
+ };
59
+ if (!fs.exists(ledgerPath))
60
+ return { exitCode: 1, error: "Completed workflow is missing its approval ledger." };
61
+ const archive = join(aiw, `checkpoints/workflow-${previous.workflowId}-approvals.yml`);
62
+ if (fs.exists(archive))
63
+ return { exitCode: 1, error: `Workflow approval archive already exists: ${archive}` };
64
+ fs.mkdir(join(aiw, "checkpoints"));
65
+ fs.write(archive, fs.read(ledgerPath));
66
+ }
67
+ else if (fs.exists(ledgerPath)) {
68
+ return {
69
+ exitCode: 1,
70
+ error: "Approval ledger exists without a workflow session; run doctor.",
71
+ };
72
+ }
73
+ const workflowId = options.id ?? randomUUID();
74
+ const priorRun = join(aiw, `checkpoints/workflow-${workflowId}-approvals.yml`);
75
+ if (fs.exists(priorRun))
76
+ return {
77
+ exitCode: 1,
78
+ error: `Workflow ID has already been used: ${workflowId}. Choose a new ID.`,
79
+ };
80
+ const now = new Date().toISOString();
81
+ const session = {
82
+ schema: 1,
83
+ workflowId,
84
+ status: "active",
85
+ startedAt: now,
86
+ updatedAt: now,
87
+ ...(options.ticket ? { ticket: options.ticket } : {}),
88
+ ...(options.title ? { title: options.title } : {}),
89
+ };
90
+ const ledger = createApprovalLedger(workflowId);
91
+ fs.write(ledgerPath, serializeApprovalLedger(ledger));
92
+ fs.write(sessionPath, serializeWorkflowSession(session));
93
+ return { exitCode: 0, output: `Workflow started: ${workflowId}.` };
94
+ }
49
95
  export async function runCommand(args, root, fs, services = {}) {
50
96
  const aiw = join(root, WORKFLOW_DIRECTORY);
51
97
  try {
52
98
  fs = withSafeProjectWrites(fs, root);
53
99
  const command = args[0] ?? "help";
100
+ if (command === "task") {
101
+ if (!fs.exists(join(aiw, "manifest.yml")))
102
+ return { exitCode: 1, error: "Run `aiw install` first." };
103
+ const usage = "Usage: aiw task start --ticket=TEAM-123 [--title=text] [--id=workflow-id] | aiw task status";
104
+ const action = args[1];
105
+ const sessionPath = join(aiw, "workflow.yml");
106
+ if (action === "start") {
107
+ const options = parseNamedOptions(args.slice(2), ["ticket", "title", "id"]);
108
+ if (!options?.ticket)
109
+ return { exitCode: 1, error: usage };
110
+ validateTaskTicket(options.ticket);
111
+ if (options.id !== undefined) {
112
+ try {
113
+ assertWorkflowId(options.id);
114
+ }
115
+ catch {
116
+ return { exitCode: 1, error: "Workflow ID must be a UUID." };
117
+ }
118
+ }
119
+ const result = beginWorkflowSession(fs, aiw, {
120
+ ...(options.id ? { id: options.id } : {}),
121
+ ticket: options.ticket,
122
+ ...(options.title ? { title: options.title } : {}),
123
+ });
124
+ if (result.exitCode !== 0)
125
+ return result;
126
+ const title = options.title ? ` titled "${options.title}"` : "";
127
+ return {
128
+ exitCode: 0,
129
+ output: `Task started for ticket ${options.ticket}${title}: ${result.output}\n` +
130
+ `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=...\`.`,
131
+ };
132
+ }
133
+ if (action === "status") {
134
+ if (!fs.exists(sessionPath))
135
+ return {
136
+ exitCode: 0,
137
+ output: "No SDD workflow is active. Start one with `aiw task start --ticket=TEAM-123`.",
138
+ };
139
+ const { session, ledger } = readWorkflowState(aiw, fs, false);
140
+ const lines = [
141
+ ...(session.ticket
142
+ ? [`Ticket: ${session.ticket}${session.title ? ` — ${session.title}` : ""}`]
143
+ : []),
144
+ `Workflow: ${session.workflowId} (${session.status})`,
145
+ "Stages:",
146
+ ...APPROVAL_STAGES.map((stage) => `- ${stage}: ${ledger.stages[stage]?.status ?? "not-started"}`),
147
+ ];
148
+ const next = APPROVAL_STAGES.find((stage) => (ledger.stages[stage]?.status ?? "not-started") === "not-started");
149
+ if (session.status === "active" && next)
150
+ 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)`}.`);
151
+ return { exitCode: 0, output: lines.join("\n") };
152
+ }
153
+ return { exitCode: 1, error: usage };
154
+ }
54
155
  if (command === "workflow") {
55
156
  if (!fs.exists(join(aiw, "manifest.yml")))
56
157
  return { exitCode: 1, error: "Run `aiw install` first." };
57
158
  const action = args[1];
58
159
  const sessionPath = join(aiw, "workflow.yml");
59
- const ledgerPath = join(aiw, "approvals.yml");
60
160
  if (action === "start") {
61
161
  const options = parseNamedOptions(args.slice(2), ["id"]);
62
162
  if (!options)
63
163
  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}.` };
164
+ return beginWorkflowSession(fs, aiw, { id: options.id });
104
165
  }
105
166
  if (action === "status") {
106
167
  if (!fs.exists(sessionPath))
@@ -359,13 +420,44 @@ export async function runCommand(args, root, fs, services = {}) {
359
420
  if (command === "telemetry") {
360
421
  if (!fs.exists(join(aiw, "manifest.yml")))
361
422
  return { exitCode: 1, error: "Run `aiw install` first." };
362
- const usage = "Usage: aiw telemetry <status|enable|disable> [--verbose] [--commands=include|exclude] [--outcomes=include|exclude]";
423
+ const usage = "Usage: aiw telemetry <status|show|enable|disable> [--verbose] [--commands=include|exclude] [--outcomes=include|exclude]";
363
424
  const action = args[1];
364
425
  if (!action)
365
426
  return { exitCode: 1, error: usage };
366
427
  const telemetryConfig = loadTelemetryConfig(fs, aiw);
367
428
  const actionArgs = args.slice(2).filter((arg) => arg !== "--verbose");
368
429
  const verbose = args.includes("--verbose");
430
+ if (action === "show") {
431
+ if (actionArgs.length !== 0)
432
+ return { exitCode: 1, error: usage };
433
+ const buffered = readTelemetryBuffer(fs, aiw);
434
+ const byCommand = new Map();
435
+ for (const event of buffered) {
436
+ const key = event.command ?? "unknown";
437
+ const entry = byCommand.get(key) ?? { success: 0, failure: 0 };
438
+ if (event.outcome === "failure")
439
+ entry.failure += 1;
440
+ else
441
+ entry.success += 1;
442
+ byCommand.set(key, entry);
443
+ }
444
+ const lines = [
445
+ `Telemetry is ${telemetryConfig.enabled ? "enabled" : "disabled"}.`,
446
+ `Buffered events: ${buffered.length}/${TELEMETRY_BUFFER_CAP} (.aiw/${TELEMETRY_BUFFER_FILE}).`,
447
+ ];
448
+ if (buffered.length > 0) {
449
+ lines.push("By command:");
450
+ for (const [command, { success, failure }] of [...byCommand].sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
451
+ lines.push(`- ${command}: ${success} success, ${failure} failure`);
452
+ }
453
+ if (verbose && buffered.length > 0) {
454
+ lines.push("Events:");
455
+ lines.push(...buffered.map((event) => `- ${JSON.stringify(event)}`));
456
+ }
457
+ if (!telemetryConfig.enabled)
458
+ lines.push("Note: telemetry is disabled, so no new events are recorded; the buffer keeps previously collected events until purged.");
459
+ return { exitCode: 0, output: lines.join("\n") };
460
+ }
369
461
  if (action === "status") {
370
462
  if (actionArgs.length !== 0)
371
463
  return { exitCode: 1, error: usage };
@@ -444,10 +536,15 @@ export async function runCommand(args, root, fs, services = {}) {
444
536
  const profile = await (services.profiler?.profile(root) ?? profileProject(root));
445
537
  let interpreter = services.interpreter;
446
538
  let interpreterModel;
539
+ let interpreterUsage;
447
540
  if (!interpreter) {
448
541
  const aiConfig = loadAiProviderConfig(fs, aiw);
449
542
  if (aiConfig?.enabled) {
450
- interpreter = createProjectInterpreter(root, services.aiProvider ?? createOpenAiCompatibleProvider(aiConfig));
543
+ interpreter = createProjectInterpreter(root, services.aiProvider ?? createOpenAiCompatibleProvider(aiConfig), undefined, {
544
+ onUsage: (usage) => {
545
+ interpreterUsage = usage;
546
+ },
547
+ });
451
548
  interpreterModel = aiConfig.model;
452
549
  }
453
550
  }
@@ -477,6 +574,7 @@ export async function runCommand(args, root, fs, services = {}) {
477
574
  label: "embedding",
478
575
  tokens: Math.ceil(buildInterpreterQuery(profile).length / 4),
479
576
  detail: `${contextConfig.model} — scoped interpreter context to ${matches.length} semantic matches`,
577
+ model: contextConfig.model,
480
578
  });
481
579
  }
482
580
  }
@@ -488,10 +586,14 @@ export async function runCommand(args, root, fs, services = {}) {
488
586
  const interpretationRequest = createInterpretationRequest(profile, scopedFiles, 4000, priorityFiles);
489
587
  const inferred = await interpreter.interpret(interpretationRequest);
490
588
  profile.facts = mergeFacts(profile.facts, inferred);
589
+ const actualTokens = interpreterUsage
590
+ ? interpreterUsage.promptTokens + interpreterUsage.completionTokens
591
+ : undefined;
491
592
  modelUsage.push({
492
593
  label: "interpreter",
493
- tokens: interpretationRequest.estimatedTokens,
494
- detail: `${interpreterModel} — ${inferred.length} facts from ${interpretationRequest.files.length} files`,
594
+ tokens: actualTokens ?? interpretationRequest.estimatedTokens,
595
+ detail: `${interpreterModel} — ${inferred.length} facts from ${interpretationRequest.files.length} files${actualTokens !== undefined ? " (measured)" : ""}`,
596
+ model: interpreterModel ?? "unknown",
495
597
  });
496
598
  }
497
599
  const overrides = readFactOverrides(fs, aiw);
@@ -530,6 +632,7 @@ export async function runCommand(args, root, fs, services = {}) {
530
632
  label: "embedding",
531
633
  tokens: Math.ceil(indexedChars / 4),
532
634
  detail: `${semanticConfig.model} — indexed ${index.chunks.length} chunks in ${Math.ceil(index.chunks.length / 64)} batch requests`,
635
+ model: semanticConfig.model,
533
636
  });
534
637
  }
535
638
  if (modelUsage.length > 0) {
@@ -541,6 +644,7 @@ export async function runCommand(args, root, fs, services = {}) {
541
644
  stage: `scan.${usage.label}`,
542
645
  budget: usage.tokens,
543
646
  used: usage.tokens,
647
+ model: usage.model,
544
648
  });
545
649
  fs.write(usagePath, serializeTokenUsage(next));
546
650
  }
@@ -1076,6 +1180,7 @@ export async function runCommand(args, root, fs, services = {}) {
1076
1180
  stage: "recommend.decision",
1077
1181
  budget: decisionTokens,
1078
1182
  used: decisionTokens,
1183
+ model: rankingConfig.model,
1079
1184
  })));
1080
1185
  ordered = ranked.items;
1081
1186
  }
@@ -1409,8 +1514,11 @@ export async function runCommand(args, root, fs, services = {}) {
1409
1514
  return { exitCode: 1, error: "Run `aiw install` first." };
1410
1515
  assertStageReady(aiw, fs, "specification");
1411
1516
  const title = args.find((arg) => arg.startsWith("--title="))?.slice(8) || "Untitled Specification";
1517
+ const ticket = args.find((arg) => arg.startsWith("--ticket="))?.slice(9);
1518
+ if (ticket !== undefined)
1519
+ validateTaskTicket(ticket);
1412
1520
  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`);
1521
+ 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
1522
  return { exitCode: 0, output: `Specification artifact created: ${path}` };
1415
1523
  }
1416
1524
  if (command === "adr") {
@@ -1834,7 +1942,15 @@ export async function runCommand(args, root, fs, services = {}) {
1834
1942
  fs.write(usagePath, serializeTokenUsage(next));
1835
1943
  return { exitCode: 0, output: `Token usage recorded: ${stage} (${used}/${budget})` };
1836
1944
  }
1837
- return { exitCode: 0, output: serializeTokenUsage(entries) };
1945
+ if (entries.length === 0)
1946
+ return { exitCode: 0, output: "No token usage recorded yet." };
1947
+ const summary = summarizeUsageByModel(entries);
1948
+ return {
1949
+ exitCode: 0,
1950
+ output: `Token usage by model:\n${summary
1951
+ .map((entry) => `- ${entry.model}: ${entry.tokens.toLocaleString("en-US")} tokens (${entry.stages.join(", ")})`)
1952
+ .join("\n")}\n\nRaw records:\n${serializeTokenUsage(entries)}`,
1953
+ };
1838
1954
  }
1839
1955
  if (command === "context-quality") {
1840
1956
  const metrics = measureContextQuality(readContextStore(root, fs));
@@ -1880,7 +1996,7 @@ export async function runCommand(args, root, fs, services = {}) {
1880
1996
  }
1881
1997
  return {
1882
1998
  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]",
1999
+ 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
2000
  };
1885
2001
  }
1886
2002
  catch (error) {
package/docs/telemetry.md CHANGED
@@ -9,7 +9,9 @@ During an interactive `aiw install`, after the optional model questions, the ins
9
9
  ## Controls
10
10
 
11
11
  ```bash
12
- aiw telemetry status
12
+ aiw telemetry show # buffered events summarized by command and outcome
13
+ aiw telemetry show --verbose # ...plus each event verbatim
14
+ aiw telemetry status # configuration only
13
15
  aiw telemetry enable --commands=include --outcomes=include
14
16
  aiw telemetry enable --commands=exclude --outcomes=include
15
17
  aiw telemetry disable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multileaf/ai-workflow",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Portable spec-driven development workflow for AI coding agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,6 +31,7 @@ resources:
31
31
  - { id: nestjs-development, version: 0.1.0, path: skills/nestjs-development.md }
32
32
  - { id: prisma-data-access, version: 0.1.0, path: skills/prisma-data-access.md }
33
33
  - { id: vite-frontend, version: 0.1.0, path: skills/vite-frontend.md }
34
+ - { id: ticket, version: 0.1.0, path: skills/ticket.md }
34
35
  rules:
35
36
  - { id: language-policy, version: 0.1.0, path: rules/language-policy.md }
36
37
  - { id: tdd-policy, version: 0.1.0, path: rules/tdd-policy.md }
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: ticket
3
+ description: Start a feature directly from a ticket or specific requirement without a brainstorming phase.
4
+ ---
5
+
6
+ # Ticket-Driven Task
7
+
8
+ For every approval or optional skip, use the host's native blocking question widget, not a prose-only prompt when the widget is available. Leave choices unselected and offer "Accept skip" / "Do not skip" (or "Approve and continue" / "Request changes" / "Reject/stop" at gates). Wait for an actual submission before running any `aiw` approval or skip command. The CLI records the decision but cannot verify that a widget was used.
9
+
10
+ Use this skill when the user provides a ticket ID (for example `PROJ-142`) or an already-clear requirement and wants to start work immediately.
11
+
12
+ 1. Extract the ticket ID and a short title from the user's request. Start the task with `npx --yes --package=@multileaf/ai-workflow -- aiw task start --ticket=<TICKET> --title="<short title>"` (add `--id=<uuid>` only if the user provides one). If a session is already active, report it with `aiw task status` and stop.
13
+ 2. Brainstorming decision: the ticket already provides the requirement, but skipping is still a human decision. Ask with the blocking widget whether to skip brainstorming ("Accept skip" / "Do not skip"). Only after an explicit acceptance, record it with `npx --yes --package=@multileaf/ai-workflow -- aiw skip brainstorming --reason="Requirement provided by ticket <TICKET>"`. If the user prefers to brainstorm after all, run the brainstorming skill instead.
14
+ 3. Create the specification linked to the ticket with `npx --yes --package=@multileaf/ai-workflow -- aiw spec --ticket=<TICKET> --title="<title>"`, fill in requirements with stable IDs and Given/When/Then acceptance criteria derived from the ticket, then pass `aiw gate specification`, present the artifact, wait, and record the human reply with `aiw approve specification` (or `aiw reject specification --reason=<feedback>` and revise).
15
+ 4. Continue the standard SDD sequence: technical design when the change has architecture impact (`aiw adr`, gate, approval), implementation plan (`aiw plan`, gate, approval), TDD implementation, verification (`aiw verify`, gate, approval), code review, and traceability (`aiw trace`, gate, approval). Check progress at any point with `npx --yes --package=@multileaf/ai-workflow -- aiw task status`, which reports the ticket and the next pending stage.
16
+ 5. Complete with `npx --yes --package=@multileaf/ai-workflow -- aiw workflow complete` only after all stages resolve; the ticket reference stays in the archived session and the specification.
17
+
18
+ Never invent a ticket ID: if the user did not provide one, ask for it (or offer the plain `aiw workflow start` flow). Never skip brainstorming without an explicit human acceptance recorded through `aiw skip brainstorming`. Use `npx --yes --package=@multileaf/ai-workflow -- aiw <command>` when no global CLI is available.