The Wire Framework — AI-accelerated data platform delivery for the full lifecycle: requirements, design, development, testing, deployment, enablement

<img src="docs/images/wire_logo_transparent.png" alt="Wire Framework" width="220">
Wire is a structured delivery system for data platform engagements, built on top of Claude Code and Gemini CLI. It encodes analytics engineering methodology as workflow specifications that the AI reads before generating anything so that output follows consistent patterns, traces back to requirements and can be validated automatically rather than having to be manually eyeballed.
Instead of prompting an AI to write a dbt model and hoping it follows your conventions, you run /wire:dbt-generate and the AI receives a specification that tells it exactly which upstream design decisions to read, which naming patterns to apply, which tests to include, and how to update the project status tracker when it's done.
Full documentation at docs.rittmananalytics.com
AI code generation can produce syntactically valid SQL. Where it falls down is methodology: consistent naming conventions across 15+ models, correct surrogate key patterns, relationship test coverage on every foreign key, traceability from business requirement to warehouse column. These failures are not knowledge failures, as the models typically do know the conventions. They are, however, context and control failures as without a structured methodology constraining generation, LLMs improvise and the accumulated inconsistencies across a project erode the value of using AI at all.
Wire closes this gap by encoding the methodology as workflow specifications that the AI reads before generating anything. Each specification tells the AI which upstream artifacts to read, which templates to follow, which validation checks to apply, and how to update the project state tracker. The result is of typically of an equivalent level of quality of a senior analytics engineer who has been on the project for months, because it was generated by an AI that read every design decision and requirement that a senior analytics engineer would have absorbed.
Wire does not replace consultants or developers. It gives them an AI that works quickly and consistently, freeing them to focus on client relationships, design decisions and the judgement calls that automation cannot make.
status.md, the execution log, the precondition gate and the artifacts on disk are identical to typing it. Reviews are never run without your ruling; parked decisions are listed at the start of every session; and orchestration.mode: manual in the engagement context restores the pre-4.0 behaviour exactly. Three tiers: one human directs, one session orchestrates, and the specialist agents run as flat lanes with their own state fileswire-process-registry; an optional, automatically-detected canonical data model registry (wire-data-model-registry) proposes industry-standard entity structures without ever bundling proprietary content into this public plugin — see docs.rittmananalytics.com/en/latest/docs/advanced/registriesunknown rather than left out, and each rule with a legacy definition generates a reconciliation query that runs immediately rather than surfacing as a mismatch in testing/wire:utils-modality-link points the release at it and the conceptual model, logical model and pipeline design read entities, sources and cardinality from the existing .mml files rather than deriving them. The requirements are still read, and the difference between the two is raised as a finding rather than resolved silentlysop_discovery now offers two routes through the same three pillars. diagnostic is the canonical playbook; modelling_led replaces the three analyses with a current-state appraisal and a signed-off conceptual and logical model, and produces the roadmap before the playback because it is one of the five things the sponsor signs off. Release types can now declare profiles that enable or disable phases and override a gate, so the ordering is enforced rather than requesteddata_model-generate and arriving already expressed as dbt models. Optional in full_platform, standard in a modelling-led discoveryPlain Language output style that activates automatically while Wire is enabled, so every response is written in simple, concise, jargon-free English. Generated artifacts are unaffected (they follow their own templates and the reference-legibility convention); override per project in .claude/settings.local.json or by editing the style/wire:status-sync) for work done outside command runs: diffs recorded state against git history, files on disk, and the execution log, then repairs status files, sprint-plan story states, and session history with consultant confirmation/wire:delegate computes a full parallel/sequential execution plan across all pending work, with fan-out parallelism for large model sets (layers stay sequential; agents within each layer run in parallel). Under the director model they run as lanes: own tree, own state file rewritten after each completed item, and no writes to status.md — the orchestrating session is the single writer of the recordstatus.md, reported first thing every sessionBy and Session (typed, orchestrator, a lane label, or autopilot), and telemetry carries the same as invoked_by. Older four-column log rows stay valid and are never rewrittenWire is distributed as a Claude Code plugin and a Gemini CLI extension. Installing the plugin embeds every Wire command inline — no framework files need to exist in your project repository.
Plugins provide the 261 /wire:* commands. Each command file contains its full workflow specification, so the AI receives complete instructions as context at invocation time.
Skills sit alongside commands but work differently. They activate automatically during ad-hoc coding work without any explicit invocation. When you start writing a dbt model, the dbt development skill provides naming conventions, SQL style rules, and testing patterns as background context. The following skills are included:
| Skill | Activates when… |
|---|---|
dbt-development | Writing dbt models, tests, or documentation |
dbt-migration | Migrating dbt projects across platforms |
dbt-fusion | Resolving dbt Core to Fusion migration errors |
dbt-mcp-server | Configuring the dbt MCP server |
dbt-analytics-qa | Answering business questions from dbt data |
dbt-dag | Generating lineage diagrams |
dbt-unit-testing | Writing dbt unit tests |
dbt-semantic-layer | Working with the dbt Semantic Layer |
dbt-troubleshooting | Diagnosing dbt errors |
lookml-content-authoring | Writing LookML views, explores, and dashboards |
looker-dashboard-mockup | Generating HTML dashboard mockups |
dagster | Writing Dagster asset definitions and pipelines |
dignified-python | Writing production-quality Python |
fivetran | Configuring Fivetran connectors via MCP |
airbyte | Managing Airbyte connections and ingestion via the Airbyte Agent MCP server |
coupler-io | Managing Coupler.io dataflows (ingestion and reverse ETL) via MCP |
rudderstack | Managing RudderStack sources, destinations, and tracking plans via MCP |
segment | Working with Twilio Segment sources, destinations, and tracking plans |
snowflake-development | Writing queries, designing objects, auditing, and migrating Snowflake via MCP |
hightouch | Auditing and migrating Hightouch reverse ETL syncs via the Hightouch REST API |
bigquery-basics | Managing BigQuery datasets, tables, jobs, SQL, and BigQuery ML |
cloud-run-basics | Deploying Cloud Run services, jobs, and worker pools for pipelines |
gcloud | Running gcloud CLI commands safely, with validation and a safety denylist |
google-cloud-recipe-auth | Authenticating to Google Cloud (ADC, service identities, secure access) |
google-cloud-waf-cost-optimization | Cost-optimization review against the Google Cloud Well-Architected Framework |
google-cloud-waf-security | Security-posture review against the Google Cloud Well-Architected Framework |
research | Conducting technical research (findings auto-saved to .wire/research/) |
MCP servers connect Wire to external systems. Configure them once and all commands that need them use them automatically:
| MCP Server | Purpose |
|---|---|
| Atlassian | Jira issue tracking and Confluence document store |
| Linear | Linear issue tracking |
| Fathom | Meeting transcript search during reviews |
| Notion | Notion document store |
| Context7 | Up-to-date library documentation |
| Fivetran | Create, configure, and monitor Fivetran connectors and destinations |
| Airbyte | AI agent connector queries via the Airbyte Agent MCP server |
| Coupler.io | Dataflow management, dataset inspection, and reverse ETL |
| RudderStack | Event tracking, tracking plans, and data catalog management |
| Snowflake | Direct SQL execution against Snowflake via the Snowflake MCP server |
| Amplitude | Product analytics — charts, dashboards, experiments, session replay, instrumentation, and taxonomy |
Amplitude product analytics skills. Wire bundles the official Amplitude AI skills (MIT licence) for administering and working with an Amplitude instance through the Amplitude MCP server. They activate automatically when relevant and cover seven areas:
| Area | Skills |
|---|---|
| Core analytics | create-chart, create-dashboard, analyze-chart, analyze-dashboard |
| Product insights | analyze-experiment, monitor-experiments, analyze-feedback, analyze-account-health, discover-opportunities, compare-user-journeys |
| Session replay & debugging | debug-replay, replay-ux-audit, diagnose-errors, monitor-reliability |
| AI agent analytics | analyze-ai-topics, investigate-ai-session, monitor-ai-quality, review-agent-insights |
| Instrumentation | diff-intake, discover-event-surfaces, discover-analytics-patterns, instrument-events, add-analytics-instrumentation, taxonomy |
| Briefings | daily-brief, weekly-brief |
A typical instrumentation flow chains diff-intake → discover-event-surfaces → instrument-events, with discover-analytics-patterns keeping new tracking consistent with existing conventions. The taxonomy skill aligns naturally with Wire's existing CDP work (Segment, RudderStack).
/plugin marketplace add rittmananalytics/wire-plugin
/plugin install wire@rittman-analytics
/reload-plugins
The /reload-plugins step activates the plugin in the current session — no Claude Code restart needed. All /wire:* commands are then available.
gemini extensions install https://github.com/rittmananalytics/wire-extension
Commands are available as /wire * with spaces rather than colons.
MCP servers are optional but enable issue tracking, document store sync, and meeting transcript context. Add whichever you need:
# Atlassian (Jira + Confluence)
claude mcp add --transport http atlassian https://mcp.atlassian.com/v1/mcp
# Linear
claude mcp add --transport http linear https://mcp.linear.app/sse
# Fathom (meeting transcripts — requires a self-hosted or managed Fathom MCP server)
claude mcp add --transport http fathom https://your-fathom-mcp-server/mcp
# Notion
claude mcp add --transport http notion https://mcp.notion.com/mcp
# Fivetran (requires API key and secret from Fivetran dashboard → Account → Settings → API Config)
claude mcp add --transport http fivetran https://fivetran-mcp-server-r6jhgfswwa-nw.a.run.app/mcp \
-H "X-Fivetran-API-Key: YOUR_API_KEY" \
-H "X-Fivetran-API-Secret: YOUR_API_SECRET"
# Airbyte Agent MCP (OAuth — browser sign-in on first connect)
claude mcp add --transport http airbyte-agent https://mcp.airbyte.ai/mcp
# Coupler.io (Personal Access Token from Coupler.io app → Settings → MCP)
claude mcp add --transport http coupler-io https://mcp.coupler.io/mcp/ \
-H "Authorization: Bearer YOUR_COUPLER_TOKEN"
# RudderStack (OAuth via mcp-remote — requires Node.js / npx on PATH)
claude mcp add rudderstack --command "npx -y mcp-remote https://mcp.rudderstack.com/mcp"
# Snowflake (available via Claude.ai native connector, or self-hosted — see USER_GUIDE §MCP Tunnels)
claude mcp add snowflake --command "npx -y mcp-remote https://mcp.snowflake.com/mcp"
Run /wire:mcp at any time to check connection status, update endpoints, or force re-authentication.
/wire:new
Wire asks for a client name, engagement type, first release type, and an optional Statement of Work path. It creates the .wire/ folder structure and, if you chose a discovery release, begins the scoping workflow.
Every Wire engagement uses a two-tier layout in .wire/:
.wire/
engagement/
context.md # client objectives, stakeholders, current-state architecture
sow.md # Statement of Work (copied at setup)
calls/ # meeting notes and call transcripts
releases/
01-discovery/ # problem definition, pitch, release brief, sprint plan
02-pipeline/ # data pipeline and dbt transformation
03-dashboards/ # client-facing reporting layer
research/
sessions/ # technical research findings (auto-saved by the research skill)
The engagement folder holds everything that spans the whole client relationship. Releases are scoped, time-boxed units of delivery, each with its own status.md tracking file and execution_log.md recording every command run against it.
Direct it. Say what you want done — "new engagement from this SOW", "run what's next", "approve it and carry on" — and Wire computes what is runnable from the release type's definition, runs it, tells you what it did (naming the command in the closing line), and stops at every review gate for your decision. This is the default on Claude Code from v4.0.0.
Type it. Every command still works exactly as before, and the command name is printed before each directed run so you learn them as you go. Set orchestration.mode: manual in .wire/engagement/context.md for a whole engagement, or say "you drive" for one session. Gemini CLI stays command-driven throughout.
Either way the same command files run and the record on disk is identical.
Every artifact follows the same three-step lifecycle:
Generate reads upstream artifacts (requirements, design decisions, prior models), applies Wire methodology templates, and produces the artifact. Output is written to the release folder and the status tracker is updated.
Validate runs automated checks against the generated artifact. For a dbt model this covers naming convention compliance, test coverage, and relationship validation. For a requirements document it checks completeness against the SOW. The result is a structured PASS/FAIL report with specific issues identified.
Review presents the artifact for stakeholder sign-off. Wire surfaces relevant meeting transcript context from Fathom, document store comments from Confluence or Notion, and any prior reviewer feedback. The reviewer approves, requests changes, or rejects. Approval gates the next phase.
Each command has a matching validate and review counterpart: /wire:requirements-generate, /wire:requirements-validate, /wire:requirements-review.
| Type | release_type | Scope | Typical duration |
|---|---|---|---|
| Discovery (Shape Up) | discovery | Problem definition, pitch, release brief, sprint plan | 1–2 weeks |
| Discovery (SOP / Canonical) | sop_discovery | Two profiles. diagnostic: stakeholder interviews, three analyses, sponsor Findings Playback. modelling_led: current-state appraisal plus a signed-off conceptual and logical model in place of the analyses, with the roadmap signed off at the playback | 3–6 weeks |
| Full Platform | full_platform | Pipeline through dbt, semantic layer, and dashboards | 2–3 weeks |
| Dashboard-First | dashboard_first | Visual mocks drive the data model; seed data enables early dbt work | 1–2 weeks |
| Pipeline + dbt | pipeline_only | New data pipeline and transformation layer | 1–2 weeks |
| dbt Development | dbt_development | Analytics engineering on existing infrastructure | 1 week |
| Dashboard Extension | dashboard_extension | New dashboards on an existing semantic layer | 3–5 days |
| Enablement | enablement | Training and documentation for an existing platform | 2–3 days |
| Agentic Data Stack | agentic_data_stack | Overlay for an existing data platform (warehouse + dbt + BI tool) — audits governance maturity, extends the semantic layer, generates per-domain knowledge skills and a CI-wired eval suite, delivers an installable agentic data stack skill. Requires an existing dbt project; not a platform build. | 4–6 weeks |
| Platform Migration | platform_migration | Warehouse-to-warehouse migration (BigQuery ↔ Snowflake) with source audits, batched dbt translation, equivalency validation and a gated cutover. Also covers tenant carve-outs. | 6–12 weeks |
| Droughty | droughty | Schema introspection against a live warehouse: entity-relationship diagrams, field documentation, data-quality reporting, and base LookML or dbt test generation. Standalone, or an optional phase inside another release. | 2–5 days |
| Custom | custom | Bespoke deliverables derived from SoW — Wire generates project-scoped specs | Varies |
The following shows a typical command sequence for delivering a new data pipeline and dbt transformation layer.
1. Create the engagement and release
/wire:new
Select pipeline_only as the release type. Wire creates .wire/engagement/ and .wire/releases/01-pipeline/.
2. Begin work — context loads automatically
The engagement-context skill fires on your first message, reads the release status, surfaces any prior research, and outputs a brief context summary. No session command needed. Use /wire:plan for an optional structured planning ritual.
3. Extract requirements
/wire:requirements-generate
Wire reads the SOW and any call transcripts in engagement/calls/ and produces a structured requirements specification. Run /wire:requirements-validate to check it, then /wire:requirements-review for sign-off.
4. Design the pipeline architecture
/wire:pipeline_design-generate
Produces a pipeline architecture document covering source systems, replication strategy, and data flow. Validate and review as above before proceeding.
5. Generate the pipeline
/wire:pipeline-generate
For a Fivetran engagement, this configures connectors via the Fivetran MCP server and produces a pipeline_connections.md record. For a Python pipeline it generates the pipeline code.
6. Generate dbt models
/wire:dbt-generate
Wire reads the pipeline design and requirements and generates staging, integration, and warehouse dbt models following the three-layer naming convention, with tests and documentation.
7. Archive and complete
/wire:archive
Archives the completed release. Status and execution log are updated automatically throughout — no session-close command required.
Autopilot runs the full delivery lifecycle without step-by-step prompting.
/wire:auhooks/register.ts 517 lines1// The Wire mod, 4.1.0: telemetry and execution-log metrics as Claude Code
2// function hooks (wire#270, release 1, sections D3 and D4). Replaces the
3// UserPromptExpansion hook (wire-telemetry.sh) and the Stop hook
4// (wire-metrics.sh + wire_metrics.py).
5//
6// - Telemetry: one `wire_command` event per Wire command, from the event that
7// ran it, so `invoked_by` comes from the engine (typed, orchestrator, lane,
8// autopilot, or studio for sessions Wire Studio opened), not from a scraped
9// prompt. Sent after the hook returns, so it never delays a command.
10// - Metrics: each Wire command opens a run; every model request in its loop
11// adds its measured usage; when the loop's turn ends the run closes and the
12// mod fills that command's Duration / Tokens / Cost cells in
13// execution_log.md. A row an orchestrator writes later is filled when it
14// is written.
15// - /wire-usage prints this session's runs, with no model turn.
16// - /wire-studio start|restart|stop|status runs Wire Studio for the session's
17// repository as a detached local process (one per repository, ports
18// 4800-4820), with no model turn.
19//
20// Off switches: the plugin's `telemetry` and `metrics` options, or
21// WIRE_TELEMETRY=false / WIRE_METRICS=false. The mod refuses nothing and
22// rewrites no tool call.
23
24import type { Register } from 'claude-code'
25
26import type { WireIdentity, WireRun } from '../types'
27import { SEGMENT_IDENTIFY, SEGMENT_TRACK, SEGMENT_WRITE_KEY, WIRE_VERSION } from './wire/constants'
28import {
29 addUsage,
30 backfillLog,
31 computeCost,
32 emptyUsage,
33 identifyBody,
34 invokedBy,
35 isOn,
36 logCommandOf,
37 logStamp,
38 parseStudioArgs,
39 parseStudioRecord,
40 releaseOf,
41 STUDIO_PORT_FIRST,
42 STUDIO_PORT_LAST,
43 studioStateName,
44 type StudioRecord,
45 totalTokens,
46 trackBody,
47 usageReport,
48} from './wire/logic'
49
50const RUNS = { plugin: 'wire', key: 'runs' } as const
51const AUTOPILOT = { plugin: 'wire', key: 'autopilot' } as const
52const IDENTITY = { plugin: 'wire', key: 'identity' } as const
53const STEP = { plugin: 'wire', key: 'stepUsage' } as const
54const MAX_RUNS = 200
55const LOG_WINDOW_MS = 30 * 60_000
56
57type Options = { telemetry?: boolean; metrics?: boolean }
58
59async function runsOf($: any): Promise<WireRun[]> {
60 const { value } = await $.state.get(RUNS)
61 return Array.isArray(value) ? value : []
62}
63
64async function saveRuns($: any, runs: WireRun[]): Promise<void> {
65 await $.state.set(RUNS, runs.slice(-MAX_RUNS))
66}
67
68async function run1($: any, argv: string[], cwd: string): Promise<string> {
69 try {
70 const r = await $.process.run(argv, { cwd, timeoutMs: 3000 })
71 return r.exitCode === 0 ? String(r.stdout ?? '').trim() : ''
72 } catch {
73 return ''
74 }
75}
76
77async function identityOf($: any): Promise<{ identity: WireIdentity; isNew: boolean }> {
78 const { value } = await $.state.get(IDENTITY)
79 if (value) {
80 return { identity: value, isNew: false }
81 }
82 const cwd = await $.session.cwd()
83 const home = (await $.env.get('HOME')) ?? ''
84 const idFile = `${home}/.wire/telemetry_id`
85 let userId = ''
86 let isNew = false
87 try {
88 userId = String(await $.fs.read(idFile)).trim()
89 } catch {
90 userId = ''
91 }
92 if (!userId) {
93 userId = crypto.randomUUID()
94 isNew = true
95 try {
96 await $.fs.write(idFile, userId)
97 } catch {
98 // No home folder to write to: the id lasts this session only.
99 }
100 }
101 const identity: WireIdentity = {
102 userId,
103 username: (await $.env.get('USER')) ?? (await $.env.get('USERNAME')) ?? '',
104 hostname: await run1($, ['hostname'], cwd),
105 os: await run1($, ['uname', '-s'], cwd),
106 gitRepo: (await run1($, ['git', 'config', '--get', 'remote.origin.url'], cwd)) || 'unknown',
107 gitBranch: (await run1($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd)) || 'unknown',
108 }
109 await $.state.set(IDENTITY, identity)
110 return { identity, isNew }
111}
112
113async function post($: any, url: string, body: string): Promise<void> {
114 try {
115 await $.http.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
116 } catch {
117 // Telemetry never fails or blocks a command.
118 }
119}
120
121async function sendTrack($: any, run: WireRun): Promise<void> {
122 const { identity, isNew } = await identityOf($)
123 const timestamp = new Date(run.startedAt).toISOString().replace(/\.\d{3}Z$/, 'Z')
124 if (isNew) {
125 await post($, SEGMENT_IDENTIFY, identifyBody(SEGMENT_WRITE_KEY, identity.userId, {
126 username: identity.username, hostname: identity.hostname, os: identity.os, version: WIRE_VERSION, timestamp,
127 }))
128 }
129 let sessionId: string | null = null
130 try {
131 sessionId = await $.session.id()
132 } catch {
133 sessionId = null
134 }
135 await post($, SEGMENT_TRACK, trackBody(SEGMENT_WRITE_KEY, identity.userId, {
136 command: run.command.replace(/^\/wire:/, ''),
137 invokedBy: run.invokedBy,
138 timestamp,
139 release: run.release,
140 sessionId,
141 agentId: run.agentId,
142 gitRepo: identity.gitRepo,
143 gitBranch: identity.gitBranch,
144 username: identity.username,
145 hostname: identity.hostname,
146 os: identity.os,
147 version: WIRE_VERSION,
148 }))
149}
150
151async function startRun($: any, options: Options, run: WireRun): Promise<void> {
152 if (isOn(options.metrics, await $.env.get('WIRE_METRICS'))) {
153 await saveRuns($, [...(await runsOf($)), run])
154 }
155 if (isOn(options.telemetry, await $.env.get('WIRE_TELEMETRY'))) {
156 $.clock.after(0, () => {
157 void sendTrack($, run)
158 })
159 }
160}
161
162// Usage goes to the run's own stepUsage member, never into `runs`: a request
163// whose hook finishes after the turn has closed the run cannot overwrite it.
164// A request made while no Wire run is open in its loop (an orchestrator
165// planning before its first Skill call) is kept under `loose:<turnId>`, so the
166// turn-end settlement does not charge it to a command.
167async function addStepUsage($: any, agentId: string | null, turnId: string, usage: any): Promise<void> {
168 if (!usage) {
169 return
170 }
171 const runs = await runsOf($)
172 const run = [...runs].reverse().find(r => r.endedAt === null && r.agentId === agentId)
173 const id = run ? run.id : `loose:${turnId}`
174 const { value } = await $.state.get({ ...STEP, id })
175 const held = value ?? { usage: emptyUsage(), model: null }
176 await $.state.set({ ...STEP, id }, {
177 usage: addUsage(held.usage, usage),
178 model: typeof usage.model === 'string' ? usage.model : held.model,
179 })
180}
181
182// The loop's turn ended. Its open runs close with their recorded usage; the
183// turn's own total (turn.complete's usage, every request of the turn) settles
184// what the per-request records have not caught yet, such as the last request,
185// whose hook may finish after this one: the remainder goes to the newest run.
186async function closeRuns($: any, agentId: string | null, turnId: string, turnUsage: any): Promise<void> {
187 const now = await $.clock.now()
188 const runs = await runsOf($)
189 const open = runs.map((r, i) => ({ r, i })).filter(({ r }) => r.endedAt === null && r.agentId === agentId)
190 if (open.length === 0) {
191 return
192 }
193 const recorded = []
194 for (const { r } of open) {
195 const { value } = await $.state.get({ ...STEP, id: r.id })
196 recorded.push(value ?? { usage: emptyUsage(), model: null })
197 }
198 const { value: loose } = await $.state.get({ ...STEP, id: `loose:${turnId}` })
199 const counted = recorded.reduce((sum, acc) => addUsage(sum, acc.usage), addUsage(emptyUsage(), loose?.usage))
200 const total = turnUsage ? addUsage(emptyUsage(), turnUsage) : counted
201 const remainder = {
202 input_tokens: Math.max(0, total.input_tokens - counted.input_tokens),
203 output_tokens: Math.max(0, total.output_tokens - counted.output_tokens),
204 cache_creation_input_tokens: Math.max(0, total.cache_creation_input_tokens - counted.cache_creation_input_tokens),
205 cache_read_input_tokens: Math.max(0, total.cache_read_input_tokens - counted.cache_read_input_tokens),
206 }
207 open.forEach(({ r, i }, k) => {
208 const isNewest = k === open.length - 1
209 const usage = isNewest ? addUsage(recorded[k]?.usage ?? emptyUsage(), remainder) : recorded[k]?.usage ?? emptyUsage()
210 const model = recorded[k]?.model ?? (typeof turnUsage?.model === 'string' ? turnUsage.model : r.model)
211 runs[i] = { ...r, endedAt: now, usage, model }
212 })
213 await saveRuns($, runs)
214}
215
216async function logFor($: any, cwd: string, release: string | null): Promise<string | null> {
217 if (release) {
218 const named = `${cwd}/.wire/releases/${release}/execution_log.md`
219 try {
220 if (await $.fs.exists(named)) {
221 return named
222 }
223 } catch {
224 // fall through to the newest log
225 }
226 }
227 let best: { path: string; mtime: number } | null = null
228 const now = await $.clock.now()
229 try {
230 for (const dir of await $.fs.list(`${cwd}/.wire/releases`)) {
231 if (dir.kind !== 'dir') {
232 continue
233 }
234 const path = `${cwd}/.wire/releases/${dir.name}/execution_log.md`
235 try {
236 const st = await $.fs.stat(path)
237 if (now - st.mtimeMs <= LOG_WINDOW_MS && (!best || st.mtimeMs > best.mtime)) {
238 best = { path, mtime: st.mtimeMs }
239 }
240 } catch {
241 // no log in this release
242 }
243 }
244 } catch {
245 return null
246 }
247 return best?.path ?? null
248}
249
250async function backfillPending($: any, options: Options): Promise<void> {
251 if (!isOn(options.metrics, await $.env.get('WIRE_METRICS'))) {
252 return
253 }
254 const runs = await runsOf($)
255 const cwd = await $.session.cwd()
256 let changed = false
257 for (let i = 0; i < runs.length; i++) {
258 const r = runs[i]
259 if (!r || r.filled || r.endedAt === null || totalTokens(r.usage) === 0) {
260 continue
261 }
262 const path = await logFor($, cwd, r.release)
263 if (!path) {
264 continue
265 }
266 let text: string
267 try {
268 text = String(await $.fs.read(path))
269 } catch {
270 continue
271 }
272 const updated = backfillLog(text, {
273 command: r.command,
274 usage: r.usage,
275 model: r.model,
276 durationSeconds: Math.round((r.endedAt - r.startedAt) / 1000),
277 notBefore: logStamp(r.startedAt, 24 * 60).slice(0, 10),
278 })
279 if (updated !== null && updated !== text) {
280 await $.fs.write(path, updated)
281 runs[i] = { ...r, filled: true }
282 changed = true
283 }
284 }
285 if (changed) {
286 await saveRuns($, runs)
287 }
288}
289
290async function newRun($: any, id: string, command: string, args: string, agentId: string | null, by: WireRun['invokedBy']): Promise<WireRun> {
291 return {
292 id, command, release: releaseOf(args), agentId, invokedBy: by,
293 startedAt: await $.clock.now(), endedAt: null, usage: emptyUsage(), model: null, filled: false,
294 }
295}
296
297async function studioScript($: any): Promise<string | null> {
298 // Built plugin: studio/ at the plugin root. Source tree: wire/studio/.
299 for (const path of [`${$.plugin.root}/studio/studio.py`, `${$.plugin.root}/../../studio/studio.py`]) {
300 try {
301 if (await $.fs.exists(path)) {
302 return path
303 }
304 } catch {
305 // try the next place
306 }
307 }
308 return null
309}
310
311async function exitsZero($: any, argv: string[]): Promise<boolean> {
312 try {
313 return (await $.process.run(argv, { timeoutMs: 3000 })).exitCode === 0
314 } catch {
315 return false
316 }
317}
318
319async function isServing($: any, port: number): Promise<boolean> {
320 try {
321 return (await $.http.fetch(`http://127.0.0.1:${port}/api/summary`)).ok === true
322 } catch {
323 return false
324 }
325}
326
327async function studioPaths($: any, cwd: string): Promise<{ dir: string; state: string; log: string }> {
328 const home = (await $.env.get('HOME')) ?? ''
329 const dir = `${home}/.wire/studio`
330 const name = studioStateName(cwd)
331 return { dir, state: `${dir}/${name}`, log: `${dir}/${name.replace(/\.json$/, '.log')}` }
332}
333
334async function readStudio($: any, statePath: string): Promise<StudioRecord | null> {
335 try {
336 return parseStudioRecord(String(await $.fs.read(statePath)))
337 } catch {
338 return null
339 }
340}
341
342async function studioUp($: any, rec: StudioRecord | null): Promise<boolean> {
343 return !!rec && (await exitsZero($, ['kill', '-0', String(rec.pid)])) && (await isServing($, rec.port))
344}
345
346async function freePort($: any, wanted: number | null): Promise<number | null> {
347 const ports = wanted ? [wanted] : Array.from({ length: STUDIO_PORT_LAST - STUDIO_PORT_FIRST + 1 }, (_, i) => STUDIO_PORT_FIRST + i)
348 for (const port of ports) {
349 const listening = await exitsZero($, ['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN'])
350 if (!listening && !(await isServing($, port))) {
351 return port
352 }
353 }
354 return null
355}
356
357async function startStudio($: any, cwd: string, wanted: number | null): Promise<string> {
358 const paths = await studioPaths($, cwd)
359 const running = await readStudio($, paths.state)
360 if (await studioUp($, running)) {
361 return `Wire Studio is already running for this repository: http://127.0.0.1:${running?.port}/`
362 }
363 if (!(await $.fs.exists(`${cwd}/.wire`))) {
364 return `No .wire/ folder in ${cwd}. Start Claude Code in a Wire engagement repository, then run /wire-studio.`
365 }
366 const script = await studioScript($)
367 if (!script) {
368 return 'Wire Studio is not in this copy of the plugin (studio/studio.py). It ships from Wire 4.1.0.'
369 }
370 const port = await freePort($, wanted)
371 if (!port) {
372 return wanted
373 ? `Port ${wanted} is in use. Run /wire-studio start --port <another port>.`
374 : `Ports ${STUDIO_PORT_FIRST}-${STUDIO_PORT_LAST} are all in use. Run /wire-studio start --port <n>.`
375 }
376 await $.process.run(['mkdir', '-p', paths.dir], { timeoutMs: 3000 })
377 // Arguments reach the shell as positional parameters, never spliced into the script.
378 const spawned = await $.process.run([
379 'sh', '-c', 'nohup python3 "$1" --repo "$2" --port "$3" --no-browser >"$4" 2>&1 & echo $!',
380 'sh', script, cwd, String(port), paths.log,
381 ], { timeoutMs: 5000 })
382 const pid = Number(String(spawned.stdout ?? '').trim())
383 if (!Number.isInteger(pid) || pid <= 0) {
384 return `Wire Studio did not start: ${String(spawned.stderr ?? '').trim() || 'no process id returned'}.`
385 }
386 const rec: StudioRecord = { pid, port, repo: cwd, startedAt: new Date(await $.clock.now()).toISOString() }
387 await $.fs.write(paths.state, JSON.stringify(rec))
388 for (let i = 0; i < 16 && !(await isServing($, port)); i++) {
389 await $.clock.sleep(250)
390 }
391 const url = `http://127.0.0.1:${port}/`
392 if (!(await isServing($, port))) {
393 if (!(await exitsZero($, ['kill', '-0', String(pid)]))) {
394 let tail = ''
395 try {
396 tail = String(await $.fs.read(paths.log)).trim().split('\n').slice(-3).join(' ')
397 } catch {
398 tail = ''
399 }
400 return `Wire Studio stopped as it started.${tail ? ` ${tail}` : ''} Log: ${paths.log}`
401 }
402 return `Wire Studio is starting at ${url} (still loading). Log: ${paths.log}`
403 }
404 const os = await run1($, ['uname', '-s'], cwd)
405 await exitsZero($, [os === 'Darwin' ? 'open' : 'xdg-open', url])
406 return `Wire Studio is running for this repository at ${url} (stop it with /wire-studio stop).`
407}
408
409async function stopStudio($: any, cwd: string): Promise<string> {
410 const paths = await studioPaths($, cwd)
411 const rec = await readStudio($, paths.state)
412 if (!rec || !(await exitsZero($, ['kill', '-0', String(rec.pid)]))) {
413 await $.fs.write(paths.state, '')
414 return 'Wire Studio is not running for this repository.'
415 }
416 await exitsZero($, ['kill', String(rec.pid)])
417 for (let i = 0; i < 8 && (await exitsZero($, ['kill', '-0', String(rec.pid)])); i++) {
418 await $.clock.sleep(250)
419 }
420 await $.fs.write(paths.state, '')
421 return `Wire Studio stopped (it was at http://127.0.0.1:${rec.port}/).`
422}
423
424async function studioStatus($: any, cwd: string): Promise<string> {
425 const paths = await studioPaths($, cwd)
426 const rec = await readStudio($, paths.state)
427 if (await studioUp($, rec)) {
428 return `Wire Studio is running for this repository at http://127.0.0.1:${rec?.port}/ (started ${rec?.startedAt}).`
429 }
430 return 'Wire Studio is not running for this repository. Start it with /wire-studio.'
431}
432
433export const register: Register = (on, options: Options) => {
434 on('session.start', async ($, e, next) => {
435 await $.command.register({ name: 'wire-usage', description: "Wire: this session's Wire commands with duration, tokens and cost" })
436 await $.command.register({ name: 'wire-studio', description: 'Wire: start, restart, stop or check Wire Studio for this repository', argumentHint: '[start|restart|stop|status] [--port <n>]' })
437 return next(e)
438 })
439
440 on('command.run', { command: 'wire-usage' }, async $ => {
441 const rows = (await runsOf($)).map(r => ({
442 command: r.command, release: r.release, invokedBy: r.invokedBy, tokens: totalTokens(r.usage),
443 cost: computeCost(r.model, r.usage), seconds: r.endedAt === null ? null : Math.round((r.endedAt - r.startedAt) / 1000), filled: r.filled,
444 }))
445 return { text: usageReport(rows) }
446 })
447
448 on('command.run', { command: 'wire-studio' }, async ($, e) => {
449 const parsed = parseStudioArgs(e.args)
450 if ('error' in parsed) {
451 return { text: parsed.error }
452 }
453 const cwd = await $.session.cwd()
454 if (parsed.action === 'stop') {
455 return { text: await stopStudio($, cwd) }
456 }
457 if (parsed.action === 'status') {
458 return { text: await studioStatus($, cwd) }
459 }
460 if (parsed.action === 'restart') {
461 await stopStudio($, cwd)
462 }
463 return { text: await startStudio($, cwd, parsed.port) }
464 })
465
466 // A typed Wire command (also the first prompt of a session Studio opened).
467 on('command.run', async ($, e, next) => {
468 const command = logCommandOf(e.command)
469 if (command) {
470 if (command === '/wire:autopilot') {
471 await $.state.set(AUTOPILOT, true)
472 }
473 const by = invokedBy('command', null, false, await $.env.get('WIRE_INVOKED_BY'))
474 await startRun($, options, await newRun($, `cmd-${crypto.randomUUID()}`, command, e.args, null, by))
475 }
476 return next(e)
477 })
478
479 // A Wire command run through the Skill tool: the orchestrator, Autopilot, or a lane.
480 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
481 const command = logCommandOf(String(e.skill ?? ''))
482 if (command) {
483 const agentId = e.agentId ?? null
484 const { value: autopilot } = await $.state.get(AUTOPILOT)
485 const by = invokedBy('skill', agentId, autopilot === true, undefined)
486 await startRun($, options, await newRun($, e.tool_use_id ?? `skill-${crypto.randomUUID()}`, command, String(e.args ?? ''), agentId, by))
487 }
488 return next(e)
489 })
490
491 // Each model request's measured usage goes to the Wire run open in its loop.
492 on('turn.step', async function* ($, e, next) {
493 const result = yield* next(e)
494 await addStepUsage($, e.agentId ?? null, e.turnId, result?.usage ?? null)
495 return result
496 })
497
498 // The loop's turn ended: its runs close and their log rows are filled.
499 on('turn.complete', async ($, e, next) => {
500 const result = await next(e)
501 await closeRuns($, e.agentId ?? null, e.turnId, e.usage ?? result?.usage ?? null)
502 await backfillPending($, options)
503 return result
504 })
505
506 // An orchestrator writing a lane's row after the lane's turn ended.
507 for (const tool of ['Write', 'Edit', 'MultiEdit'] as const) {
508 on('tool.call', { tool }, async ($, e, next) => {
509 const result = await next(e)
510 if (String(e.file_path ?? '').endsWith('execution_log.md')) {
511 await backfillPending($, options)
512 }
513 return result
514 })
515 }
516}
517hooks/wire/constants.ts 8 lines1// Substituted by wire/scripts/build-packages.sh, as the 4.0 shell hook's
2// placeholders were. wire/tests/schema/validate_telemetry_version.py checks
3// that no built package ships either placeholder raw.
4export const SEGMENT_WRITE_KEY = 'DxXwrT6ucDMRmouCsYDwthdChwDLsNYL'
5export const WIRE_VERSION = '4.1.2'
6export const SEGMENT_TRACK = 'https://api.segment.io/v1/track'
7export const SEGMENT_IDENTIFY = 'https://api.segment.io/v1/identify'
8hooks/wire/logic.ts 325 lines1// Pure logic for the Wire mod's telemetry and execution-log metrics.
2// No `$` here: everything takes plain values and returns plain values, so
3// tests/logic.test.ts exercises it directly. The contract it implements is
4// specs/utils/execution_log.md ("Metrics Backfill") and specs/utils/telemetry.md.
5
6export type Usage = {
7 input_tokens: number
8 output_tokens: number
9 cache_creation_input_tokens: number
10 cache_read_input_tokens: number
11}
12
13export const NA = 'n/a'
14
15// Anthropic API prices, USD per million tokens, as of 2026-09:
16// (input, output, cache_read). Cache writes are charged at 1.25x input.
17// Matched by substring against the model id, first match wins, so more
18// specific ids come before their prefixes. Carried over unchanged from the
19// retired hooks/wire_metrics.py.
20export const PRICING: ReadonlyArray<readonly [string, readonly [number, number, number]]> = [
21 ['claude-haiku-4-5', [1.0, 5.0, 0.1]],
22 ['claude-sonnet-4-6', [3.0, 15.0, 0.3]],
23 ['claude-sonnet-5', [2.0, 10.0, 0.2]],
24 ['claude-opus-4', [5.0, 25.0, 0.5]],
25 ['claude-opus-5', [5.0, 25.0, 0.5]],
26 ['claude-fable-5-1', [10.0, 50.0, 0.25]],
27 ['claude-fable-5', [10.0, 50.0, 1.0]],
28 ['claude-mythos-5', [10.0, 50.0, 1.0]],
29]
30export const CACHE_WRITE_MULTIPLIER = 1.25
31
32export const INVOKED_BY = ['typed', 'orchestrator', 'lane', 'autopilot', 'studio'] as const
33export type InvokedBy = (typeof INVOKED_BY)[number]
34
35export function emptyUsage(): Usage {
36 return { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
37}
38
39/** Adds a usage record (any object with the four counts) to a running total. */
40export function addUsage(total: Usage, more: Partial<Record<keyof Usage, unknown>> | null | undefined): Usage {
41 if (!more) {
42 return total
43 }
44 const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.trunc(v) : 0)
45 return {
46 input_tokens: total.input_tokens + n(more.input_tokens),
47 output_tokens: total.output_tokens + n(more.output_tokens),
48 cache_creation_input_tokens: total.cache_creation_input_tokens + n(more.cache_creation_input_tokens),
49 cache_read_input_tokens: total.cache_read_input_tokens + n(more.cache_read_input_tokens),
50 }
51}
52
53export function totalTokens(u: Usage): number {
54 return u.input_tokens + u.output_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
55}
56
57/** Estimated USD cost, or null for an unknown model: never a guessed price. */
58export function computeCost(model: string | null | undefined, u: Usage): number | null {
59 if (!model) {
60 return null
61 }
62 const hit = PRICING.find(([key]) => model.includes(key))
63 if (!hit) {
64 return null
65 }
66 const [inRate, outRate, readRate] = hit[1]
67 const cost =
68 (u.input_tokens * inRate +
69 u.output_tokens * outRate +
70 u.cache_read_input_tokens * readRate +
71 u.cache_creation_input_tokens * inRate * CACHE_WRITE_MULTIPLIER) /
72 1_000_000
73 return Math.round(cost * 100) / 100
74}
75
76export function formatDuration(seconds: number | null | undefined): string {
77 if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) {
78 return NA
79 }
80 const s = Math.max(0, Math.trunc(seconds))
81 const pad = (n: number) => String(n).padStart(2, '0')
82 if (s < 60) {
83 return `${s}s`
84 }
85 if (s < 3600) {
86 return `${Math.floor(s / 60)}m ${pad(s % 60)}s`
87 }
88 return `${Math.floor(s / 3600)}h ${pad(Math.floor((s % 3600) / 60))}m`
89}
90
91export type Measured = {
92 command: string
93 usage: Usage
94 model: string | null
95 durationSeconds: number | null
96 /**
97 * Rows dated before this day (`YYYY-MM-DD`, local) are never filled: they
98 * belong to an earlier run. A day, not a time, because commands write the
99 * row's time themselves and it is often rough (a model writing `00:00`).
100 */
101 notBefore?: string
102}
103
104function cellsOf(line: string): string[] | null {
105 const t = line.trim()
106 if (!(t.startsWith('|') && t.endsWith('|'))) {
107 return null
108 }
109 return t.slice(1, -1).split('|').map(c => c.trim())
110}
111
112/**
113 * Backfills the Duration, Tokens and Cost cells of the newest row that logs
114 * this command, whose Tokens cell is still `n/a` and whose date is not
115 * before `notBefore`. Returns the new text, or null when nothing may change:
116 * no such row, or only legacy rows without the nine metric-bearing columns.
117 * Only those three cells of that one row change; Duration only when still
118 * `n/a`. Searching upward, not only the last row, lets concurrent lanes'
119 * rows each be filled by their own run.
120 */
121export function backfillLog(text: string, run: Measured): string | null {
122 const lines = text.split('\n')
123 for (let i = lines.length - 1; i >= 0; i--) {
124 const cells = cellsOf(lines[i] ?? '')
125 if (!cells) {
126 continue
127 }
128 if (cells[0] === 'Timestamp' || /^[-:\s]+$/.test(cells[0] ?? '')) {
129 return null
130 }
131 if (cells.length < 9) {
132 continue
133 }
134 const command = (cells[1] ?? '').split(/\s+/)[0]
135 if (command !== run.command || cells[cells.length - 2] !== NA) {
136 continue
137 }
138 if (run.notBefore && (cells[0] ?? '').slice(0, 10) < run.notBefore.slice(0, 10)) {
139 return null
140 }
141 const n = cells.length
142 if (cells[n - 3] === NA) {
143 cells[n - 3] = formatDuration(run.durationSeconds)
144 }
145 cells[n - 2] = String(totalTokens(run.usage))
146 const cost = computeCost(run.model, run.usage)
147 cells[n - 1] = cost === null ? NA : `$${cost.toFixed(2)}`
148 lines[i] = `| ${cells.join(' | ')} |`
149 return lines.join('\n')
150 }
151 return null
152}
153
154/** `YYYY-MM-DD HH:MM` in local time, the execution log's timestamp form, `minutes` before `ms`. */
155export function logStamp(ms: number, minutes = 0): string {
156 const d = new Date(ms - minutes * 60_000)
157 const p = (n: number) => String(n).padStart(2, '0')
158 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
159}
160
161/** The release folder a command names as its first argument, if it looks like one. */
162export function releaseOf(args: string): string | null {
163 const first = (args ?? '').trim().split(/\s+/)[0] ?? ''
164 return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,120}$/.test(first) && !first.startsWith('-') ? first : null
165}
166
167/** `wire:dbt-generate` (command.run) or `wire:dbt-generate` (Skill) to the log's `/wire:dbt-generate`. */
168export function logCommandOf(name: string): string | null {
169 const m = /^wire:([A-Za-z0-9_-]+)$/.exec((name ?? '').trim())
170 return m ? `/wire:${m[1]}` : null
171}
172
173/**
174 * Who started a run (specs/utils/telemetry.md, invoked_by):
175 * - a typed command is `typed`, or `studio` when the session was opened by
176 * Wire Studio's "Run in Claude Code" (WIRE_INVOKED_BY=studio in its env);
177 * - a Skill call on the main loop is `orchestrator`, or `autopilot` while
178 * /wire:autopilot runs in this session;
179 * - a Skill call inside a subagent's loop is `lane`.
180 */
181export function invokedBy(path: 'command' | 'skill', agentId: string | null, autopilot: boolean, envValue: string | undefined): InvokedBy {
182 if (path === 'command') {
183 return envValue === 'studio' ? 'studio' : 'typed'
184 }
185 if (agentId) {
186 return 'lane'
187 }
188 return autopilot ? 'autopilot' : 'orchestrator'
189}
190
191export type TrackFields = {
192 command: string
193 invokedBy: InvokedBy
194 timestamp: string
195 release: string | null
196 sessionId: string | null
197 agentId: string | null
198 gitRepo: string
199 gitBranch: string
200 username: string
201 hostname: string
202 os: string
203 version: string
204}
205
206/**
207 * The Segment track body. Keeps the properties the 4.0 hook sent and adds
208 * release, session_id and agent_id. Deliberately leaves out the command's
209 * arguments: they can hold free text a consultant typed, and telemetry has
210 * never carried prompt text.
211 */
212export function trackBody(writeKey: string, userId: string, f: TrackFields): string {
213 return JSON.stringify({
214 writeKey,
215 userId,
216 event: 'wire_command',
217 properties: {
218 command: f.command,
219 timestamp: f.timestamp,
220 git_repo: f.gitRepo,
221 git_branch: f.gitBranch,
222 username: f.username,
223 hostname: f.hostname,
224 plugin_version: f.version,
225 os: f.os,
226 runtime: 'claude',
227 invoked_by: f.invokedBy,
228 release: f.release,
229 session_id: f.sessionId,
230 agent_id: f.agentId,
231 },
232 })
233}
234
235export function identifyBody(writeKey: string, userId: string, f: Pick<TrackFields, 'username' | 'hostname' | 'os' | 'version' | 'timestamp'>): string {
236 return JSON.stringify({
237 writeKey,
238 userId,
239 traits: { username: f.username, hostname: f.hostname, os: f.os, plugin_version: f.version, first_seen: f.timestamp },
240 })
241}
242
243/** Off when the option is false or the matching environment variable is "false". */
244export function isOn(option: boolean | undefined, envValue: string | undefined): boolean {
245 return option !== false && envValue !== 'false'
246}
247
248export type RunRow = { command: string; release: string | null; invokedBy: InvokedBy; tokens: number; cost: number | null; seconds: number | null; filled: boolean }
249
250/** The text /wire-usage prints: this session's Wire runs with tokens and cost. */
251export function usageReport(rows: readonly RunRow[]): string {
252 if (rows.length === 0) {
253 return 'No Wire commands have run in this session yet.'
254 }
255 const lines = ['| Command | Release | Started by | Duration | Tokens | Cost (USD) | In log |', '|---|---|---|---|---|---|---|']
256 let tokens = 0
257 let cost = 0
258 let costKnown = true
259 for (const r of rows) {
260 tokens += r.tokens
261 if (r.cost === null) {
262 costKnown = false
263 } else {
264 cost += r.cost
265 }
266 lines.push(`| ${r.command} | ${r.release ?? '-'} | ${r.invokedBy} | ${formatDuration(r.seconds)} | ${r.tokens} | ${r.cost === null ? NA : `$${r.cost.toFixed(2)}`} | ${r.filled ? 'yes' : 'not yet'} |`)
267 }
268 lines.push('', `Total: ${tokens} tokens, ${costKnown ? `$${cost.toFixed(2)}` : `at least $${cost.toFixed(2)} (some models unpriced)`}.`)
269 return lines.join('\n')
270}
271
272// ---------------------------------------------------------------------------
273// /wire-studio: start, restart, stop and report Wire Studio for this repository
274// ---------------------------------------------------------------------------
275
276export type StudioAction = 'start' | 'restart' | 'stop' | 'status'
277export type StudioArgs = { action: StudioAction; port: number | null } | { error: string }
278
279export const STUDIO_USAGE = 'Usage: /wire-studio [start|restart|stop|status] [--port <n>]'
280export const STUDIO_PORT_FIRST = 4800
281export const STUDIO_PORT_LAST = 4820
282
283/** `start` when nothing is given; `--port` between 1024 and 65535. */
284export function parseStudioArgs(args: string): StudioArgs {
285 const words = (args ?? '').trim().split(/\s+/).filter(Boolean)
286 let action: StudioAction = 'start'
287 let port: number | null = null
288 for (let i = 0; i < words.length; i++) {
289 const w = words[i] ?? ''
290 if (w === 'start' || w === 'restart' || w === 'stop' || w === 'status') {
291 action = w
292 } else if (w === '--port' || w.startsWith('--port=')) {
293 const raw = w.includes('=') ? w.split('=')[1] : words[++i]
294 const n = Number(raw)
295 if (!Number.isInteger(n) || n < 1024 || n > 65535) {
296 return { error: `--port needs a number between 1024 and 65535. ${STUDIO_USAGE}` }
297 }
298 port = n
299 } else {
300 return { error: `Unknown argument "${w}". ${STUDIO_USAGE}` }
301 }
302 }
303 return { action, port }
304}
305
306/** One state file per repository, named from its path. */
307export function studioStateName(repo: string): string {
308 const slug = repo.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(-80)
309 return `${slug || 'repo'}.json`
310}
311
312export type StudioRecord = { pid: number; port: number; repo: string; startedAt: string }
313
314export function parseStudioRecord(text: string | null): StudioRecord | null {
315 if (!text) {
316 return null
317 }
318 try {
319 const r = JSON.parse(text)
320 return Number.isInteger(r?.pid) && Number.isInteger(r?.port) && typeof r?.repo === 'string' ? r : null
321 } catch {
322 return null
323 }
324}
325types/index.d.ts 53 lines1// The Wire mod's $.state contract: every value it keeps for the session.
2// `claude plugin validate` holds hooks/register.ts to it.
3
4export type WireRun = {
5 /** Run id: tool_use_id for a Skill call, or a generated id for a typed command. */
6 id: string
7 /** As the execution log writes it: `/wire:dbt-generate`. */
8 command: string
9 release: string | null
10 /** The loop the run is in: a subagent's id, null on the main loop. */
11 agentId: string | null
12 invokedBy: 'typed' | 'orchestrator' | 'lane' | 'autopilot' | 'studio'
13 startedAt: number
14 endedAt: number | null
15 /** Final usage, set when the run closes (zero while it is open; see stepUsage). */
16 usage: {
17 input_tokens: number
18 output_tokens: number
19 cache_creation_input_tokens: number
20 cache_read_input_tokens: number
21 }
22 model: string | null
23 /** True once its execution_log.md row carries the measured cells. */
24 filled: boolean
25}
26
27/** Usage recorded per model request for one run, while it is open. */
28export type WireStepUsage = {
29 usage: WireRun['usage']
30 model: string | null
31}
32
33export type WireIdentity = {
34 userId: string
35 username: string
36 hostname: string
37 os: string
38 gitRepo: string
39 gitBranch: string
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 wire: {
45 runs: WireRun[]
46 /** One member per run id. Kept apart from `runs` so a request's late write can never overwrite a closed run. */
47 stepUsage: StateFamily<WireStepUsage>
48 autopilot: boolean
49 identity: WireIdentity | null
50 }
51 }
52}
53