A budgeted plan-to-review delivery workflow for .NET teams: start, plan, implement, test, review and next, with plan and review pages as Claude Artifacts…

A Claude Code plugin for .NET teams that takes work from a ticket through planning, implementation, review and QA hand-off. It builds plan and review pages with enforced word budgets, so you can read the proposed work and the results before approving each.
One project profile sets your architecture, tests, tracker and QA workflow. Use the six skills, start, plan, implement, test, review and next, together or on their own, with or without a ticket tracker or the Artifact tool.
Install | Workflow | Skills | Progress bar | /goal | Branding | Profile reference | dotnet-claude-kit | Jev | Contributing
Run in a terminal:
claude plugin marketplace add chrisjainsley/claude-dotnet-workflow-kit
claude plugin install dotnet-workflow-kit@dotnet-workflow-kit
Then run setup in Claude Code from your project:
/dotnet-workflow-kit:setup
Setup asks about your team and writes .claude/dotnet-workflow-kit.json. Commit that file to share the settings. Run setup again when your workflow changes.
The scripts require Python 3.11 or later. Pillow is optional; without it, the plan builder embeds full-size images instead of downscaling them.
For terminal setup from a clone of this repository:
python scripts/setup.py
Start with a ticket ID, or a short slug when tracker is none:
/dotnet-workflow-kit:start 1234
Use the pipeline driver to continue, or wrap it in a goal so it keeps going until the next gate:
/dotnet-workflow-kit:next
/goal /next has stopped at a gate
The driver checks the branch, PR and saved state, then runs the earliest unfinished stage. It pauses for plan answers or approval, the review decision, and blockers. Invoking it authorizes commits, pushes, a draft PR and configured deployment labels before the review checkpoint. The kit never merges; a person approves every merge.
| Stage | Who | Result |
|---|---|---|
| 0. Start | Agent | Create and push a branch; assign and activate the item when a tracker is configured. |
| 1. Plan | Agent, then you | Build the plan page; you answer the open questions or approve. |
| 2. Implement | Agent | Implement the plan one layer per commit, then run the reviewer sweep, fix findings and verify. |
| 3. Test | Agent | Run the suites, then the plan's scenarios in the configured environment; fix failures and rerun. |
| 4. Review | Agent, then you | Present the final diff, findings and QA evidence on one page; you approve or send it back. |
| 5. Pull request | Agent, then you | Resolve threads and get checks green. After approval, post the QA report, apply labels and mark the PR ready. You merge. |
Requesting changes returns the pipeline to implementation with your notes and per-finding fix or accept choices. The draft stays a draft until approval.
Use to start work from an ID, issue URL or untracked slug. It creates and pushes a branch from base_branch using branch_pattern. With a tracker, it assigns the item to user and moves it to the configured active state. It then renames the session when supported and hands off to plan.
Use for an implementation plan. It reads the ticket and codebase, then writes plans/<id>-<slug>/plan.md and builds plan.html. Sections follow your architecture, with test scenarios, decisions, risks and open questions. A script checks section budgets before publication.
When stack.frontend is set and a ticket changes a screen that came with no design, the skill draws the screens first, with Claude Code's /design command or the Design canvas Artifact type, and embeds the artboards in a Designs section of the plan page next to a link to the editable canvas. Designs supplied by the tracker are used as they are and no canvas is made.
On an Artifact page, choose answers, pick Approve or Revise the plan and press Send answers. The page passes the choices to the session, which folds them into the plan; on Approve it goes straight on to Implement. If the page says to tell Claude "decided", the session could not be reached; say it in chat and the skill reads the stored choices.
With optional.walkthrough on, the plan page also has Walk me through it: a narrated tour of what gets built, the riskiest decision and each open question with its recommendation, ending on the form. It works as the review's does, described below.

Use to build an approved plan. It walks the plan's layers in order, one commit each, tests first when testing.tdd is strict, then runs the reviewer sweep: read-only reviewers in parallel, cleanup and verification in sequence. The built-in reviewers are bug-hunt and conventions; the profile can enable companion reviewers and a Codex second opinion. Findings are fixed on the branch before anything is tested, and missing tools appear in the skipped list with a reason. This skill edits files.
Use to run the Test stage and write up what was tested. It runs the build and the suites, then the approved plan's scenarios in qa.environment, opening the draft PR and applying qa.deploy_label when that environment needs a deployment. It then writes the QA report, or testing notes for a QA team.
| Mode | Content | Posting |
|---|---|---|
| Report | Given/When/Then scenarios, Pass/Fail/Blocked results, evidence (API request and response pairs, query results folded under the step each proves; screenshots and videos in a visible grid per scenario), summary and untested criteria. | After approval, post according to qa.evidence. |
| Notes | What changed, steps per persona, test data, edge cases, scope, environment and flags. | After approval, post for qa-team according to qa.evidence; otherwise print in chat. |
Reports cover acceptance runs against a real environment and manual checks. Unit and integration suites do not count as QA evidence. Notes come from the approved plan's Specs, or the diff when no plan exists. Select notes mode with:
/dotnet-workflow-kit:test notes
Use to review a branch or PR. It combines the approved plan, actual diff, sweep findings and QA evidence into review.md and review.html. When no sweep is fresh for the branch, it runs one first, so a branch nobody implemented in this session can still be reviewed.
The page includes verdict counts, plan versus delivered, a change diagram (click it to open full size), file links to full diffs, findings, the QA report and a rollout checklist. Choose Approve or Request changes and press Send decision. The page passes the decision to the session, which reads it with each open finding's fix or accept choice; on Approve it goes straight on to the pull request stage. Say "decided" in chat if the page asks.
With optional.walkthrough on, the review (and the plan, see above) ends with a short narrated tour and the page gets a Walk me through it button under the header. It scrolls to each scene, highlights the row or flowmap box, animates the box's connections and plays the line with its caption. The voice is recorded when the review is built, by Kokoro, an open-source voice model that runs on your machine (pip install kokoro-onnx; the model downloads once). The text is never sent anywhere, and every reader hears the same voice in any browser. Without Kokoro installed, the tour plays as captions only.
The same switch records the tour as an overview video: a 1080p MP4 with a title card, the voice and captions, which the page plays from Watch overview and which can be shared on its own. scripts/overview.py plays the page in headless Chromium, so it needs pip install playwright, python -m playwright install chromium and ffmpeg; without them the page keeps the tour and skips the video.

Use to run the workflow from the current stage. It reads live signals alongside ~/.claude/dotnet-workflow-kit/pipeline/<slug>.json and updates stage completion. Live evidence overrides saved state; sweeps, test runs and reviews older than new commits must run again. State files written by 0.5.0 are migrated on first read.
To inspect progress without running a stage:
/next status
The skills now carry the names of the stages they run. Old names are not aliased; update any saved prompts or /goal text.
| Before | Now |
|---|---|
start-ticket | start |
visual-plan | plan |
Execute stage inside next, plus mega-review | implement (the sweep lives at skills/review/sweep.md) |
qa-report, plus the QA stage inside next | test |
visual-review | review |
| Draft PR, Resolve comments and Publish and hand off stages | next stage 5, Pull request |
The kit ships a Claude Code mod that draws a progress bar above the prompt, in the terminal and the desktop Code tab. It shows the work item on the session's own branch: the ticket id and a short title, a bar with a segment per stage, the current stage, the percentage and what Claude is doing right now. A stage waiting on you turns amber. The bar reads the pipeline state file that start, plan and next write.
The skills write that file through a tool the mod adds, mcp__dotnet-workflow-kit__stage. It finds the branch's file, merges the change and redraws the bar at once. Where mods are blocked, the skills edit the file by hand. While subagents run, such as the reviewers in the sweep, the bar counts them instead of showing what each one reads. The bar follows the light or dark theme and stops moving when the system asks for reduced motion.
Every session on a branch other than main, master or the profile's base_branch shows, whether or not it ran a skill. For a branch no skill has touched, the mod writes the state file itself, marked adopted, with Start done and a title taken from the branch name; the first start, plan or next run on that branch takes the file over.
Other items on branches of the same repository, touched in the last day, appear as a +N chip at the end of the bar; it turns amber when one of them is waiting on you. Pressing the chip, or /sessions, opens the Sessions pane: every item grouped as Needs you, Running and Done. Open items are cards with their stages, status and a Show button that names the branch whose session to open; the item you are viewing has a purple edge and one waiting on you an amber one. A plugin cannot switch the app to another session, so Show points you to it instead. Finished items take one line each, with a cross to hide one and Dismiss all to hide the lot. A toast when another item reaches a gate is off by default, because the app's own notifications already cover a session waiting on you; the last row of the pane turns it on. /progress hides or shows the bar, and the cross on a finished item hides it.
An item closes by itself when its pull request merges or closes, or when the worktree it was started in is gone, as it is once a worktree chat is archived; /progress done closes the current branch's item by hand. An adopted item no skill took over also closes when its branch is deleted or after a week with no session working on it. A closed item shows every stage done, and a day later its row goes and scripts/close_items.py deletes its state file. The PR check asks gh (or az for Azure Repos) every ten minutes.
When several sessions run Aspire on one machine, the mod keeps track of which session owns each running AppHost, so they stop getting in each other's way. Every session scans for AppHosts with scripts/aspire_sessions.py: an AppHost belongs to the worktree it was built in, and the session working in that worktree owns it. The list lives in ~/.claude/dotnet-workflow-kit/aspire, one file per AppHost with its process, ports, dashboard URL and owner, and a file goes when its AppHost exits.
kill, pkill, killall, taskkill, Stop-Process, or freeing a port with lsof, fuser or Get-NetTCPConnection), or start an AppHost that another session already runs from the same folder, or on a port another session's AppHost holds. The refusal names the owner and what to do instead.A session that ends leaves its AppHosts free for the next session in that worktree, and one that stops scanning for two minutes counts as ended. Scanning runs when the profile's stack.local_run is aspire, when this session starts an AppHost, or when any session has one listed, and needs Python like the rest of the kit. AppHosts in two worktrees of one repository start on the same ports, so give each worktree a launch profile of its own; see adapters/stack/local_run.md.
The bar turns on by itself once the plugin is installed: mods load in every new session, with nothing to enable. It stays empty until a work item is started. An organisation's managed settings can block user-installed mods, for example with allowManagedModsOnly.
/next runs stage after stage inside one turn, but nothing restarts it if the turn ends early. The kit's hooks wrap each half of the run in a goal for you:
| When | The hooks set |
|---|---|
| The plan is approved | /goal /next has reached the review checkpoint |
| The review is approved | /goal complete /next: the pull request is ready |
Each goal is met by the message /next ends that half with, so it clears on its own instead of re-prompting while you decide. While /next is stopped on a blocker the hooks keep the goal quiet until you answer. Sending from the plan or review page wakes the session through Send to Claude, and an approval carries the run on by itself. When a page cannot reach the session, it says so; tell the session "decided" instead.
Without function hooks, set a goal yourself after each gate:
/goal /next has stopped at a gate
Tool permission prompts may still require input. When the pipeline reports a blocker, resolve it before continuing.
Set artifacts to false through setup when the Artifact tool is unavailable. The skills build the same HTML pages and give you local file paths to open.
Local forms cannot save answers or decisions. Reply in chat with question numbers and choices, or with "approve" or "changes" and your finding decisions. Rollout checklist ticks do not persist; track rollout outside the kit. See Running without the Artifact tool.
The plan and review pages use the Delivery Labs colours and carry an attribution footer linking there. Set branding to false through setup, or in the profile file, to render the neutral palette with no footer.
Setup writes .claude/dotnet-workflow-kit.json in the project. Profile resolution uses an explicit path first, then the project file, then the user file at ~/.claude/dotnet-workflow-kit.json, then defaults. Missing fields receive defaults.
Each row below names a field, including nested fields in dotted form. For example, testing.tdd lives inside the testing object. Empty strings appear as "".
| Field | Allowed values | Default | Purpose |
|---|---|---|---|
schema | Integer | 1 | Profile schema version. |
user | Free text | "" | Assignee and name used in page prose. |
architecture | clean, vertical, ddd-clean, modular-monolith | "clean" | Plan section order and review rules. |
testing.tdd | strict, encouraged, none | "encouraged" | TDD expectation; strict means tests first during execution. |
testing.unit | xunit, nunit, mstest | "xunit" | Unit test framework. |
testing.integration | webapplicationfactory, testcontainers, none | "webapplicationfactory" | Integration test approach. |
testing.acceptance | reqnroll, specflow, none | "none" | Acceptance test runner; none keeps scenarios without a BDD runner. |
qa.owner | qa-team, self, none | "self" | Who tests and signs off. |
qa.evidence | work-item, pr-comment, none | "none" | Where approved QA reports go. |
qa.handoff_label | Free text | "" | PR label for QA hand-off. |
qa.deploy_label | Free text | "" | PR label to deploy to QA. |
qa.environment | Free text | "local" | Environment named in QA evidence. |
tracker | azure-boards, github-issues, jira, none | "none" | Ticket adapter; none uses your description. |
tracker_project | Free text | "" | Project or organization identifier for tracker calls. |
scm | github, azure-repos | "github" | PR and diff adapter. |
base_branch | Free text | "main" | Starting branch and fallback PR base; stacked work uses its parent. |
branch_pattern | Free text containing {slug}; supports {kind} and {id} | "{kind}/{id}-{slug}" | Branch naming template. |
branch_kinds.feature | Non-empty text | "feat" | Feature value for the kind token. |
branch_kinds.bug | Non-empty text | "bug" | Bug value for the kind token. |
tracker_states.active | Free text | "" | State when work starts; blank uses the adapter default. |
tracker_states.qa_ready | Free text | "" | QA hand-off state; blank uses the adapter default. |
artifacts | true, false | true | Publish Artifacts, or build local HTML and take answers in chat. |
branding | true, false | true | Delivery Labs colours and an attribution footer on the plan and review pages; false renders the neutral palette with no footer. |
stack.data | ef-core, dapper, cosmos, other | "ef-core" | Data access conventions. |
stack.api | minimal-api, controllers, graphql, grpc | "minimal-api" | API contract style. |
stack.messaging | masstransit, wolverine, service-bus, none | "none" | Messaging conventions. |
stack.errors | result, exceptions | "exceptions" | Error handling conventions. |
stack.local_run | aspire, docker, plain | "plain" | How to start the system for local QA. |
stack.frontend | none, blazor, razor, react, angular, vue, javascript | "none" | Whether the repo has a frontend and which kind; see the plan skill. |
reviewers | bug-hunt, conventions, kit, security-scan, convention-learner, code-review-workflow | ["bug-hunt", "conventions"] | Reviewer sweep passes; the two built-ins always run. |
pipeline.execute | Free text | "" | Execution command; blank implements the plan directly. |
pipeline.resolve_comments | Free text | "" | Comment-resolution command; blank uses the SCM adapter. |
pipeline.qa | Free text | "" | QA command; blank runs plan Specs manually per the QA adapter. |
pipeline.open_pr_in_browser | true, false | true | In Claude desktop, open a newly created PR in the Claude browser pane. Set false to only report the link. |
pipeline.auto_fix_pr | true, false | true | In Claude desktop, turn on CI auto-fix and comment handling for a newly created PR, so the session wakes on CI failures, conflicts and review comments. Set false to leave it off. |
optional.dotnet-claude-kit | true, false | false | Companion plugin availability. |
optional.codex | true, false | false | Enable the Codex second-opinion reviewer. |
optional.roslyn-mcp | true, false | false | Roslyn MCP availability for code-review-workflow. |
optional.walkthrough | true, false | false | Narrated walkthrough and overview video on the plan and review pages, voiced by Kokoro on your machine (pip install kokoro-onnx; the video also needs Playwright and ffmpeg). See review. |
optional.jev | true, false | false | Jev availability: a TYPESAFE_API_KEY or a jev MCP server. Detected by setup. See Jev. |
jev.flag_at | Number from 0 to 1 | 0.75 | Probability at or above which a scored check becomes a finding at the rule's severity. |
jev.review_at | Number from 0 to 1, at most flag_at | 0.4 | Probability at or above which a scored check is listed as low with "confirm by reading". |
checks | List of {id, rule, severity, files} | [] | Review checks the conventions reviewer enforces; files is an optional glob. Edited in the file, validated by scripts/doctor.py. |
extra_stages | List of {id, label, after, run, done_when, gate} | [] | The team's own /next stages. after names a built-in stage other than pull_request, or an earlier extra stage; run is a slash command or an instruction; done_when is the yes/no question that marks it done; gate: true stops for you after it. label is at most 12 characters and shows on the progress bar. Edited in the file. |
stage_checks | Object of stage name to a list of {id, prompt, on_fail} | {} | Yes/no prompts a /next stage must pass before it is marked done. Stages: start, plan, implement, test, review, pull_request. on_fail is fix (default, keep working the stage) or stop (blocker). Edited in the file. |
Validation requires a tracker when qa.evidence is work-item. Both bug-hunt and conventions remain in the reviewer list.
The clean, ddd-clean and modular-monolith profiles share Domain, Application, Infrastructure, API and Tests slices. Their adapters define different review rules. The vertical profile uses Slice, Persistence, Integration, Endpoint and Tests. See Adapters for supported tools and extension points.
Setup recommends the companion plugin when your answers need its skills, and asks before installing it. These mappings come from scripts/kit_profile.py:
| Answer | dotnet-claude-kit skills it needs |
|---|---|
architecture: clean | clean-architecture |
architecture: ddd-clean | clean-architecture, ddd |
architecture: vertical | vertical-slice |
testing.tdd: strict | tdd |
stack.data: ef-core | ef-core, migration-workflow |
stack.api: minimal-api | minimal-api, openapi, api-versioning |
stack.messaging: masstransit | messaging |
stack.messaging: wolverine | messaging |
stack.errors: result | error-handling |
stack.local_run: aspire | aspire |
reviewers: kit | code-review, 80-20-review, de-sloppify, verification-loop |
reviewers: security-scan | security-scan |
reviewers: convention-learner | convention-learner |
reviewers: code-review-workflow | code-review-workflow (also needs a Roslyn MCP server) |
The workflow kit also runs without the companion. Built-in adapters still guide the pages; unavailable companion reviewers are recorded as skipped. The code-review-workflow reviewer also requires a Roslyn MCP server.
Jev is TypeSafe's System One model: a fast, calibrated judge that returns probabilities, never text. With
hooks/register.tsx 1624 lines1// The kit's progress bar: the current branch's work item above the prompt, drawn from the pipeline
2// state files `start`, `plan` and `next` write under ~/.claude/dotnet-workflow-kit/pipeline, and a
3// Sessions pane listing every item in this repository. A branch no skill has touched yet gets a
4// file of its own, marked `adopted`, so every session shows whether or not it used the kit. It also sets the run's /goal when the plan
5// and then the review are approved. With Aspire it tracks which session owns each running AppHost
6// through scripts/aspire_sessions.py, shows them, tells Claude, and refuses commands that would
7// stop or clash with another session's.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { Activity, AspireEntry, PipelineRow, RunningAgent, StageStatus } from '../types'
12
13const rows = atom({ plugin: 'dotnet-workflow-kit', key: 'rows' } as const, [])
14const isHidden = atom({ plugin: 'dotnet-workflow-kit', key: 'isHidden' } as const, false)
15const dismissed = atom({ plugin: 'dotnet-workflow-kit', key: 'dismissed' } as const, [])
16const activity = atom({ plugin: 'dotnet-workflow-kit', key: 'activity' } as const, null)
17// What the session still has running in the background when its turn ends, so the bar keeps
18// moving while Claude waits on a shell, subagent or workflow to wake it.
19const background = atom({ plugin: 'dotnet-workflow-kit', key: 'background' } as const, [])
20// Goals already set, per item: survives reloads so a phase's goal is set once.
21const goalsSet = atom({ plugin: 'dotnet-workflow-kit', key: 'goalsSet' } as const, {})
22// Whether this session toasts when another item reaches a gate; off, since the app notifies too.
23const notifyOthers = atom({ plugin: 'dotnet-workflow-kit', key: 'notifyOthers' } as const, false)
24// The subagents running now, so the band can count them without their tool calls taking it over.
25const agents = atom({ plugin: 'dotnet-workflow-kit', key: 'agents' } as const, [])
26// Every Aspire AppHost running on this machine, with the session that owns it.
27const aspire = atom({ plugin: 'dotnet-workflow-kit', key: 'aspire' } as const, [])
28
29const PANE = 'sessions'
30const AGENT_MS = 60 * 60 * 1000
31
32// The kit's own pipeline; a state file's `order` replaces it when a team adds stages.
33const DEFAULT_ORDER = ['start', 'plan', 'implement', 'test', 'review', 'pull_request']
34const DEFAULT_LABELS: Record<string, string> = {
35 start: 'Start',
36 plan: 'Plan',
37 implement: 'Implement',
38 test: 'Test',
39 review: 'Review',
40 pull_request: 'PR',
41 pullRequest: 'PR',
42}
43const MAX_ROWS = 12
44// Other items show in the Sessions pane only while someone touched them recently.
45const RECENT_MS = 24 * 60 * 60 * 1000
46const POLL_MS = 4000
47// A closed item's row goes a day after it closed; scripts/close_items.py deletes its file then.
48const REMOVE_MS = 24 * 60 * 60 * 1000
49// Asking GitHub or Azure about each PR is a network call, so it runs far less often than the poll.
50const CLOSE_MS = 10 * 60 * 1000
51const CLOSED_LABELS: Record<string, string> = {
52 merged: 'Merged',
53 closed: 'PR closed',
54 archived: 'Archived',
55 deleted: 'Branch deleted',
56 idle: 'Idle',
57 manual: 'Done',
58}
59// An adopted item's file is rewritten at most this often while its session works, so it stays recent.
60const TOUCH_MS = 10 * 60 * 1000
61const ADOPTED_DETAIL = 'Not run through the kit yet: /next picks it up'
62// A session that owns an AppHost rescans this often; any other session only every QUIET_MS.
63const ASPIRE_MS = 15 * 1000
64const QUIET_MS = 60 * 1000
65// Commands worth asking scripts/aspire_sessions.py about: ones that stop processes or start an AppHost.
66const ASPIRE_COMMAND =
67 /\b(kill|pkill|killall|taskkill|stop-process|spps|fuser)\b|\bdotnet(\.exe)?\s+(run|watch)\b|\baspire(\.exe)?\s+(run|start)\b/i
68// A start's AppHost builds before it listens, so the scans after one come at these delays.
69const START_SCANS_MS = [5000, 20000, 45000, 90000]
70
71// `todo` is a theme key, so the stages ahead read on a light terminal as on a dark one.
72const COLOR = { done: '#c4c0ff', active: '#8e86f8', waiting: '#f59e0b', todo: 'inactive' }
73// The Sessions pane's cards: a purple edge on the item being viewed, an amber one on an item
74// waiting on you, with a faint tint of the same where the surface can draw one.
75const CARD = {
76 // A theme key, so a plain card's edge reads on a light theme as on a dark one.
77 border: 'subtle',
78 currentBorder: '#5d52a3',
79 currentTint: '#9d8cff14',
80 currentText: '#8e86f8',
81 waitingBorder: '#8a6a33',
82 waitingTint: '#e9ae4f14',
83 check: '#6fbf73',
84}
85// A card's stage segments: done stages muted, the active one bright, those ahead a dark track.
86const SEGMENT = { done: '#5d52a3', active: '#9d8cff', waitingDone: '#8a6a33', waiting: '#e9ae4f', todo: '#3a3836' }
87
88type Stage = {
89 done?: boolean
90 at?: string
91 waiting?: boolean
92 label?: string
93 artifactUrl?: string
94 decision?: string
95 note?: string
96}
97type Closed = { reason: string; at: string }
98type StateFile = {
99 closed?: Closed
100 adopted?: boolean
101 worktree?: boolean | string
102 repo?: string
103 ticket?: string
104 title?: string
105 shortTitle?: string
106 branch?: string
107 current?: { stage?: string; detail?: string; blocked?: boolean }
108 order?: (string | { id: string; label?: string })[]
109 stages?: Record<string, Stage | undefined>
110}
111
112// One stage's done flag, reading the 0.5.0 keys for the built-in stages when the new ones are absent.
113function stageDone(s: Record<string, Stage | undefined>, id: string): boolean {
114 const d = (key: string) => s[key]?.done === true
115 const has = (key: string) => s[key] !== undefined
116
117 switch (id) {
118 case 'start':
119 return has('start') ? d('start') : d('startTicket')
120 case 'implement':
121 return has('implement') ? d('implement') : d('execute') && d('megaReview')
122 case 'test':
123 return has('test') ? d('test') : d('qa')
124 case 'review':
125 return d('review') || s.review?.decision === 'approve'
126 case 'pull_request':
127 case 'pullRequest':
128 return has('pullRequest') ? d('pullRequest') : has('pull_request') ? d('pull_request') : d('handoff')
129 default:
130 return d(id)
131 }
132}
133
134// A stage's entry in the file, under either spelling of the PR stage.
135const stageEntry = (s: Record<string, Stage | undefined>, id: string) =>
136 id === 'pull_request' || id === 'pullRequest' ? (s.pullRequest ?? s.pull_request) : s[id]
137
138// The stages in order with their labels: the file's `order` when present, else the kit's six.
139function stageList(data: StateFile): { id: string; label: string }[] {
140 const order = data.order?.length ? data.order : DEFAULT_ORDER
141 const stages = data.stages ?? {}
142
143 return order.map(entry => {
144 const id = typeof entry === 'string' ? entry : entry.id
145 const given = typeof entry === 'string' ? undefined : entry.label
146 const label = given || stages[id]?.label || DEFAULT_LABELS[id] || id.replace(/[-_]/g, ' ')
147
148 return { id, label: label.charAt(0).toUpperCase() + label.slice(1) }
149 })
150}
151
152// feat/391-jev-agent-router -> "jev agent router"; claude/foo-bar-7d2e6c -> "foo bar".
153function titleFromBranch(branch: string): string {
154 return branch
155 .replace(/^.*\//, '')
156 .replace(/^#?\d+[a-z]?-/, '')
157 .replace(/-[0-9a-f]{6}$/, '')
158 .replace(/-/g, ' ')
159}
160
161// "391" -> "#391"; "PROJ-12" stays; placeholders such as "none" or "untracked" show nothing.
162function ticketId(ticket: string | undefined): string {
163 const id = (ticket ?? '').trim()
164 if (!id || /^(none|untracked)$/i.test(id)) return ''
165
166 return /^\d+$/.test(id) ? `#${id}` : id
167}
168
169// A row's title in at most four words and 28 characters: the story at a glance, not its full name.
170function shortTitle(title: string): string {
171 const words = title.trim().split(/\s+/).slice(0, 4).join(' ')
172
173 return words.length > 28 ? `${words.slice(0, 27).trimEnd()}…` : words
174}
175
176function toRow(slug: string, data: StateFile, branch: string): PipelineRow {
177 const stages = data.stages ?? {}
178 const list = stageList(data)
179 // A stage a skill has written to means every stage before it is behind the item, even when a
180 // skill skipped marking them (a plan written without Start ticked is still at Plan).
181 // `/next` seeds stages it has not reached with blank placeholders, so only real progress counts.
182 const isReached = (id: string) => {
183 const s = stageEntry(stages, id)
184 return data.current?.stage === id || !!(s && (s.done || s.waiting || s.at || s.artifactUrl || s.decision))
185 }
186 const reached = list.reduce((last, stage, i) => (isReached(stage.id) ? i : last), -1)
187 // A closed item counts every stage as done, whatever the stage flags say.
188 const flags = list.map((stage, i) => !!data.closed || i < reached || stageDone(stages, stage.id))
189 const activeIndex = flags.indexOf(false)
190 const active = activeIndex === -1 ? undefined : list[activeIndex].id
191 const activeStage = active ? stageEntry(stages, active) : undefined
192 const isPlanGate = active === 'plan' && !!activeStage?.artifactUrl
193 const isCheckpoint = active === 'review' && !!activeStage?.artifactUrl && !activeStage?.decision
194 const isCustomGate = activeStage?.waiting === true
195 const isGate = isPlanGate || isCheckpoint || isCustomGate
196
197 const statuses: StageStatus[] = flags.map((done, i): StageStatus => {
198 if (done) return 'done'
199 if (i !== activeIndex) return 'todo'
200
201 return isGate ? 'waiting' : 'active'
202 })
203
204 const detail = data.closed
205 ? (CLOSED_LABELS[data.closed.reason] ?? 'Done')
206 : isPlanGate
207 ? 'Waiting on you: answer or approve the plan'
208 : isCheckpoint
209 ? 'Waiting on you: review checkpoint'
210 : isCustomGate
211 ? `Waiting on you${activeStage?.note ? `: ${activeStage.note}` : ''}`
212 : activeIndex === -1
213 ? 'Every stage done'
214 : (data.current?.detail ?? activeStage?.note ?? '')
215
216 return {
217 slug,
218 ticket: ticketId(data.ticket),
219 title: data.shortTitle || shortTitle(data.title || titleFromBranch(data.branch ?? '')),
220 branch: data.branch ?? '',
221 isCurrentBranch: !!branch && data.branch === branch,
222 labels: list.map(stage => stage.label),
223 isPlanApproved: stageDone(stages, 'plan'),
224 isReviewApproved: stages.review?.decision === 'approve',
225 isBlocked: data.current?.blocked === true,
226 statuses,
227 doneCount: flags.filter(Boolean).length,
228 activeIndex,
229 detail,
230 }
231}
232
233const basename = (path: unknown) => String(path ?? '').replace(/^.*[\\/]/, '')
234const clip = (text: string, max = 60) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
235
236// A slug-style ticket ("todo-by-id") already says what the title says; show it once.
237const same = (a: string, b: string) =>
238 a.toLowerCase().replace(/[^a-z0-9]/g, '') === b.toLowerCase().replace(/[^a-z0-9]/g, '')
239const rowName = (row: PipelineRow) =>
240 (row.ticket && same(row.ticket, row.title) ? row.ticket : [row.ticket, row.title].filter(Boolean).join(' ')) ||
241 row.slug
242// The ticket and title apart, for the pane's cards; a slug-style ticket stands in for the title.
243const nameParts = (row: PipelineRow) =>
244 row.ticket && same(row.ticket, row.title)
245 ? { ticket: '', title: row.ticket }
246 : { ticket: row.ticket, title: row.title || row.slug }
247const pct = (row: PipelineRow) => Math.round((row.doneCount / row.labels.length) * 100)
248const isComplete = (row: PipelineRow) => row.activeIndex === -1
249const isWaiting = (row: PipelineRow) => !isComplete(row) && row.statuses[row.activeIndex] === 'waiting'
250const stageLabel = (row: PipelineRow) =>
251 isComplete(row) ? 'Complete' : `${row.labels[row.activeIndex]} ${row.activeIndex + 1}/${row.labels.length}`
252const dotColor = (row: PipelineRow) =>
253 isComplete(row) ? COLOR.done : isWaiting(row) ? COLOR.waiting : COLOR.active
254
255// What a tool call says Claude is doing, in a few words; undefined for calls not worth showing.
256function describe(e: Record<string, unknown>): string | undefined {
257 const tool = String(e.tool ?? '')
258 const said = typeof e.description === 'string' && e.description ? e.description : undefined
259
260 switch (tool) {
261 case 'Skill':
262 return `Running /${String(e.skill ?? '').replace(/^.*:/, '')}`
263 case 'Agent':
264 return said ? `Agent: ${said}` : 'Running a subagent'
265 case 'Bash':
266 case 'PowerShell':
267 return said
268 case 'Edit':
269 case 'Write':
270 case 'NotebookEdit':
271 return `Editing ${basename(e.file_path ?? e.notebook_path)}`
272 case 'Read':
273 return `Reading ${basename(e.file_path)}`
274 case 'Grep':
275 case 'Glob':
276 return `Searching for ${clip(String(e.pattern ?? ''), 30)}`
277 case 'WebFetch':
278 case 'WebSearch':
279 return 'Researching on the web'
280 case 'Artifact':
281 return e.action === 'read' ? 'Reading a page' : 'Publishing a page'
282 default:
283 return tool.startsWith('mcp__') ? `Calling ${tool.split('__').pop()}` : undefined
284 }
285}
286
287// One goal per phase, each met at that phase's own gate: the review checkpoint, then a ready PR.
288const GOALS = {
289 review: '/next has reached the review checkpoint',
290 complete: 'complete /next: the pull request is ready',
291} as const
292
293// Phases seen on the previous refresh; a goal fires only on a change seen while watching.
294let lastPhases: Record<string, { plan: boolean; review: boolean }> | null = null
295// When this module loaded; an item first seen whose file was written later is a change seen live.
296let watchingSince = 0
297
298async function setGoals($: EngineInterface, rows: PipelineRow[], mtimes: Record<string, number>) {
299 const current = rows.find(row => row.isCurrentBranch)
300 const before = lastPhases
301 lastPhases = Object.fromEntries(
302 rows.map(row => [row.slug, { plan: row.isPlanApproved, review: row.isReviewApproved }]),
303 )
304 if (!current || !before) return
305
306 const fresh = (mtimes[current.slug] ?? 0) > watchingSince
307 const was = before[current.slug] ?? (fresh ? { plan: false, review: false } : undefined)
308 if (!was) return
309 const phase =
310 current.isReviewApproved && !was.review
311 ? 'complete'
312 : current.isPlanApproved && !was.plan
313 ? 'review'
314 : undefined
315 // A pipeline already finished has nothing left for the completion goal to drive.
316 if (!phase || (phase === 'complete' && current.activeIndex === -1)) return
317
318 const already = (await read($, goalsSet))[current.slug] ?? []
319 if (already.includes(phase)) return
320 await update($, goalsSet, all => ({ ...all, [current.slug]: [...already, phase] }))
321
322 // command.run is refused inside a hook the turn waits on, so it runs off a timer instead.
323 $.clock.after(50, () => {
324 $.command
325 .run({ command: 'goal', args: GOALS[phase] })
326 .then(() => $.ui.toast(`Goal set: ${GOALS[phase]}`))
327 .catch(err => $.ui.log(`dotnet-workflow-kit: could not set the goal: ${String(err)}`))
328 })
329}
330
331let lastJson = ''
332// The refresh under way, and the one queued behind it: a caller that asks during a refresh
333// waits for the next one, so what it just wrote is on the bar when its await returns.
334let running: Promise<void> | null = null
335let queued: Promise<void> | null = null
336// Parsed state files by name, reused while the file's mtime is unchanged.
337const cache = new Map<string, { mtime: number; data: StateFile | null }>()
338let lastDone: Record<string, boolean[]> | null = null
339let lastWaiting: Record<string, boolean> | null = null
340
341// A work item's state file, not a stage's checks or evidence file written beside it.
342const isStateFile = (entry: { name: unknown; kind: string }) =>
343 entry.kind === 'file' && String(entry.name).endsWith('.json') && !String(entry.name).includes('-checks')
344
345async function homeDir($: EngineInterface) {
346 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
347
348 return home.replace(/\\/g, '/')
349}
350
351// a/b/../c -> a/c: a worktree's `commondir` is relative (`../..`), and git prints the folded path.
352function resolveDots(path: string): string {
353 const out: string[] = []
354 for (const part of path.split('/')) {
355 if (part === '..' && out.length > 1) out.pop()
356 else if (part !== '.') out.push(part)
357 }
358
359 return out.join('/')
360}
361
362type Repo = { branch: string; commonDir: string; root: string; isWorktree: boolean }
363
364// The session's repository: its checked-out branch and the git dir that holds its refs
365// (a worktree's own git dir points at the shared one through `commondir`).
366async function currentRepo($: EngineInterface): Promise<Repo | undefined> {
367 try {
368 let dir = ((await $.session.cwd()) as string).replace(/\\/g, '/')
369 for (let i = 0; i < 12 && dir; i++) {
370 const dotGit = `${dir}/.git`
371 if (await $.fs.exists(dotGit)) {
372 const stat = await $.fs.stat(dotGit)
373 let gitDir = dotGit
374 if (stat.kind === 'file') {
375 const pointer = ((await $.fs.read(dotGit)) as string).trim()
376 gitDir = pointer.replace(/^gitdir:\s*/, '').replace(/\\/g, '/')
377 if (!/^([a-zA-Z]:)?\//.test(gitDir)) gitDir = `${dir}/${gitDir}`
378 }
379 const head = ((await $.fs.read(`${gitDir}/HEAD`)) as string).trim()
380 let commonDir = gitDir
381 if (await $.fs.exists(`${gitDir}/commondir`)) {
382 const common = ((await $.fs.read(`${gitDir}/commondir`)) as string).trim().replace(/\\/g, '/')
383 commonDir = resolveDots(/^([a-zA-Z]:)?\//.test(common) ? common : `${gitDir}/${common}`)
384 }
385
386 return {
387 branch: head.startsWith('ref: refs/heads/') ? head.slice(16) : '',
388 commonDir,
389 root: dir,
390 isWorktree: stat.kind === 'file',
391 }
392 }
393 const parent = dir.replace(/\/[^/]+$/, '')
394 if (parent === dir) break
395 dir = parent
396 }
397 } catch {
398 // Not a repository: no rows, since no item can be tied to it.
399 }
400
401 return undefined
402}
403
404// Whether the branch exists in this repository, loose or packed; items from other repos fail it.
405async function hasBranch($: EngineInterface, repo: Repo, branch: string, packed: string): Promise<boolean> {
406 if (!branch) return false
407 if (packed.includes(` refs/heads/${branch}\n`)) return true
408
409 return $.fs.exists(`${repo.commonDir}/refs/heads/${branch}`)
410}
411
412function refresh($: EngineInterface): Promise<void> {
413 if (running) {
414 queued ??= running.then(() => {
415 queued = null
416
417 return refresh($)
418 })
419
420 return queued
421 }
422 running = refreshNow($).finally(() => {
423 running = null
424 })
425
426 return running
427}
428
429async function refreshNow($: EngineInterface) {
430 try {
431 const dir = `${await homeDir($)}/.claude/dotnet-workflow-kit/pipeline`
432 const [entries, repo, now] = await Promise.all([
433 $.fs.exists(dir).then(has => (has ? $.fs.list(dir) : [])),
434 currentRepo($),
435 $.clock.now(),
436 ])
437 const branch = repo?.branch ?? ''
438 const packed = repo && (await $.fs.exists(`${repo.commonDir}/packed-refs`))
439 ? `${await $.fs.read(`${repo.commonDir}/packed-refs`)}\n`
440 : ''
441
442 const found: { row: PipelineRow; mtime: number; branch: string }[] = []
443 // Every branch some file names, closed or not, so a branch is adopted only when none does.
444 const named = new Set<string>()
445 for (const entry of entries) {
446 const name = entry.name as string
447 if (!isStateFile(entry)) continue
448 try {
449 let hit = cache.get(name)
450 if (!hit || hit.mtime !== entry.mtimeMs) {
451 hit = { mtime: entry.mtimeMs, data: JSON.parse(await $.fs.read(`${dir}/${name}`)) as StateFile }
452 cache.set(name, hit)
453 }
454 const data = hit.data
455 if (data?.branch) named.add(data.branch)
456 if (!data?.stages || Object.keys(data.stages).length === 0) continue
457 if (data.closed && now - Date.parse(data.closed.at) > REMOVE_MS) continue
458 const row = toRow(name.slice(0, -5), data, branch)
459 const isRecent = now - entry.mtimeMs < RECENT_MS
460 // Other items show only when recent and on a branch of this same repository.
461 const isHere =
462 row.isCurrentBranch || (isRecent && !!repo && (await hasBranch($, repo, data.branch ?? '', packed)))
463 if (isHere) found.push({ row, mtime: entry.mtimeMs, branch: data.branch ?? '' })
464 } catch {
465 // A half-written or foreign file: skip it this round.
466 }
467 }
468
469 if (repo && !named.has(repo.branch) && (await adopt($, dir, repo))) return
470
471 // The cap below must never push a live item out for a finished one, so unfinished rows sort
472 // ahead of complete ones; the viewing session's own row always stays first.
473 found.sort(
474 (a, b) =>
475 Number(b.row.isCurrentBranch) - Number(a.row.isCurrentBranch) ||
476 Number(isComplete(a.row)) - Number(isComplete(b.row)) ||
477 b.mtime - a.mtime,
478 )
479 // Two files for one branch (keyed differently by two writers) are one item: keep the newest.
480 const seen = new Set<string>()
481 const unique = found.filter(f => !f.branch || (!seen.has(f.branch) && !!seen.add(f.branch)))
482 const next = unique.slice(0, MAX_ROWS).map(f => f.row)
483
484 // Every session runs this module, so each toasts only its own item's stages; another item's
485 // gate toasts only when the person turned that on in the Sessions pane.
486 const shouldNotifyOthers = await read($, notifyOthers)
487 const done: Record<string, boolean[]> = {}
488 const waiting: Record<string, boolean> = {}
489 for (const row of next) {
490 done[row.slug] = row.statuses.map(s => s === 'done')
491 waiting[row.slug] = isWaiting(row)
492 const before = lastDone?.[row.slug]
493 if (row.isCurrentBranch && before) {
494 row.statuses.forEach((s, i) => {
495 if (s === 'done' && !before[i]) $.ui.toast(`${row.ticket || row.title}: ${row.labels[i]} done`)
496 })
497 }
498 const isNewGate = !!lastWaiting && waiting[row.slug] && !lastWaiting[row.slug]
499 if (!row.isCurrentBranch && shouldNotifyOthers && isNewGate) {
500 $.ui.toast(`${rowName(row)}: ${row.detail || 'waiting on you'}`)
501 }
502 }
503 lastDone = done
504 lastWaiting = waiting
505
506 await setGoals($, next, Object.fromEntries(found.map(f => [f.row.slug, f.mtime])))
507
508 const json = JSON.stringify(next)
509 if (json !== lastJson) {
510 lastJson = json
511 await update($, rows, () => next)
512 }
513 } catch (err) {
514 $.ui.log(`dotnet-workflow-kit: ${String(err)}`)
515 }
516}
517
518// The branches a team works from, per repository root; their sessions are not work items.
519const bases = new Map<string, string[]>()
520
521async function baseBranches($: EngineInterface, repo: Repo): Promise<string[]> {
522 const known = bases.get(repo.root)
523 if (known) return known
524 const found = ['main', 'master']
525 for (const path of [`${repo.root}/.claude/dotnet-workflow-kit.json`, `${await homeDir($)}/.claude/dotnet-workflow-kit.json`]) {
526 try {
527 if (!(await $.fs.exists(path))) continue
528 const base = (JSON.parse(await $.fs.read(path)) as { base_branch?: unknown }).base_branch
529 if (typeof base === 'string' && base) found.push(base)
530 break
531 } catch {
532 // An unreadable profile leaves the defaults.
533 }
534 }
535 bases.set(repo.root, found)
536
537 return found
538}
539
540// feat/391-foo -> feat-391-foo: the file name `/next` keys a branch with no ticket id by. A
541// "-checks" in it becomes "_checks", since refresh skips files named like a stage's checks.
542const branchSlug = (branch: string) =>
543 branch
544 .replace(/[^A-Za-z0-9._-]+/g, '-')
545 .replace(/^-+|-+$/g, '')
546 .replace(/-checks/g, '_checks')
547
548// Writes a state file for a branch no skill has written one for, with Start done; the skills
549// take it over on their first run. Resolves to whether a file was written.
550async function adopt($: EngineInterface, dir: string, repo: Repo): Promise<boolean> {
551 if (!repo.branch || (await baseBranches($, repo)).includes(repo.branch)) return false
552 const slug = branchSlug(repo.branch)
553 const path = `${dir}/${slug}.json`
554 // A file already there is one refresh could not read yet, such as a skill's half-written one.
555 if (!slug || (await $.fs.exists(path))) return false
556 const at = new Date(await $.clock.now()).toISOString().replace(/\.\d{3}Z$/, 'Z')
557 const data: StateFile = {
558 adopted: true,
559 branch: repo.branch,
560 repo: repo.commonDir,
561 ...(repo.isWorktree ? { worktree: repo.root } : {}),
562 current: { detail: ADOPTED_DETAIL },
563 stages: { start: { done: true, at } },
564 }
565 await $.fs.write(path, `${JSON.stringify(data, null, 2)}
566`)
567 // The next refresh reads the new file; refresh is idle again by the time this timer fires.
568 $.clock.after(50, () => void refresh($))
569
570 return true
571}
572
573// Rewrites the current branch's adopted file when it is older than TOUCH_MS, so other sessions
574// keep listing it and the closing script does not count a working branch as idle.
575async function touchAdopted($: EngineInterface) {
576 const current = (await read($, rows)).find(row => row.isCurrentBranch)
577 if (!current) return
578 const path = `${await homeDir($)}/.claude/dotnet-workflow-kit/pipeline/${current.slug}.json`
579 try {
580 const [stat, now] = await Promise.all([$.fs.stat(path), $.clock.now()])
581 if (now - stat.mtimeMs < TOUCH_MS) return
582 const data = JSON.parse(await $.fs.read(path)) as StateFile
583 if (data.adopted !== true || data.closed) return
584 await $.fs.write(path, `${JSON.stringify(data, null, 2)}
585`)
586 } catch {
587 // Gone or half written: the next turn tries again.
588 }
589}
590
591let isClosing = false
592
593// Runs scripts/close_items.py for this repository: closes merged, closed and archived items
594// and deletes those closed over a day ago. A missing Python or a failed run only logs.
595async function closeItems($: EngineInterface) {
596 if (isClosing) return
597 isClosing = true
598 try {
599 const repo = await currentRepo($)
600 if (!repo) return
601 const script = `${$.plugin.root.replace(/\\/g, '/')}/scripts/close_items.py`
602 const dir = `${await homeDir($)}/.claude/dotnet-workflow-kit/pipeline`
603 for (const python of ['python', 'python3']) {
604 try {
605 const result = await $.process.run([python, script, '--repo', repo.commonDir, '--dir', dir], { timeoutMs: 300000 })
606 if (result.exitCode !== 0) $.ui.log(`dotnet-workflow-kit: close_items: ${result.stderr.trim()}`)
607 for (const line of result.stdout.split('\n').filter(Boolean)) $.ui.toast(line)
608 break
609 } catch {
610 // This name is not on PATH; try the next.
611 }
612 }
613 await refresh($)
614 } finally {
615 isClosing = false
616 }
617}
618
619// `/progress done`: closes the current branch's item by hand, for work that ends without a PR.
620async function closeCurrent($: EngineInterface): Promise<string> {
621 const current = (await read($, rows)).find(row => row.isCurrentBranch)
622 if (!current) return 'No pipeline item for this branch.'
623 const path = `${await homeDir($)}/.claude/dotnet-workflow-kit/pipeline/${current.slug}.json`
624 let data: StateFile
625 try {
626 data = JSON.parse(await $.fs.read(path)) as StateFile
627 } catch {
628 return `Could not read ${current.slug}.json; nothing was changed.`
629 }
630 if (!data.closed) {
631 data.closed = { reason: 'manual', at: new Date(await $.clock.now()).toISOString().replace(/\.\d{3}Z$/, 'Z') }
632 await $.fs.write(path, `${JSON.stringify(data, null, 2)}\n`)
633 }
634 await refresh($)
635
636 return `${current.ticket || current.title} is done; its row goes in a day.`
637}
638
639const STAGE_TOOL = 'mcp__dotnet-workflow-kit__stage'
640
641type StageInput = {
642 stage?: string
643 fields?: Record<string, unknown>
644 current?: StateFile['current']
645 item?: Pick<StateFile, 'ticket' | 'title' | 'shortTitle' | 'order'>
646}
647
648const isoNow = async ($: EngineInterface) => new Date(await $.clock.now()).toISOString().replace(/\.\d{3}Z$/, 'Z')
649
650// The file for this branch: the one whose `branch` names it, whatever it is called, else a new
651// one keyed by the ticket id, else by the branch, as /next keys it.
652// The folder is shared by every repository, so a file is this item's only when its branch and
653// repository match and it is still open; of several, the newest, as refresh shows.
654async function stateFileFor($: EngineInterface, dir: string, repo: Repo, ticket?: string): Promise<string> {
655 const entries = (await $.fs.exists(dir)) ? await $.fs.list(dir) : []
656 const sameRepo = (path: string | undefined) => !path || samePath(path, repo.commonDir)
657 const taken = new Set<string>()
658 let best: { name: string; mtime: number } | undefined
659 for (const entry of entries) {
660 const name = entry.name as string
661 if (!isStateFile(entry)) continue
662 taken.add(name)
663 try {
664 const data = JSON.parse(await $.fs.read(`${dir}/${name}`)) as StateFile
665 const isMine = data.branch === repo.branch && sameRepo(data.repo) && !data.closed
666 if (isMine && (!best || entry.mtimeMs > best.mtime)) best = { name, mtime: entry.mtimeMs }
667 } catch {
668 // Unreadable: not this branch's file as far as anyone can tell.
669 }
670 }
671 if (best) return `${dir}/${best.name}`
672
673 // A new file never reuses the name of another repository's or a closed item's.
674 const slug = branchSlug(ticketId(ticket).replace(/^#/, '') || repo.branch)
675 let name = `${slug}.json`
676 for (let n = 2; taken.has(name); n++) name = `${slug}-${n}.json`
677
678 return `${dir}/${name}`
679}
680
681// Git and the skills spell one path with either slash and either case on Windows.
682const pathKey = (path: string) => resolveDots(path.replace(/\\/g, '/')).replace(/\/+$/, '').toLowerCase()
683const samePath = (a: string, b: string) => pathKey(a) === pathKey(b)
684
685// Stage calls run one at a time, so two in flight never read the same file and drop a write.
686let writing: Promise<unknown> = Promise.resolve()
687
688const writeStage = ($: EngineInterface, input: StageInput): Promise<string> => {
689 const next = writing.then(() => writeStageNow($, input))
690 writing = next.catch(() => undefined)
691
692 return next
693}
694
695// The stage tool: merges what a skill recorded into this branch's state file and redraws the bar.
696async function writeStageNow($: EngineInterface, input: StageInput): Promise<string> {
697 const repo = await currentRepo($)
698 if (!repo?.branch) return 'Not on a branch of a git repository; nothing written.'
699 if ((await baseBranches($, repo)).includes(repo.branch)) return `On the base branch ${repo.branch}; nothing written.`
700 const dir = `${await homeDir($)}/.claude/dotnet-workflow-kit/pipeline`
701 const path = await stateFileFor($, dir, repo, input.item?.ticket)
702 const name = basename(path)
703 let data: StateFile = {}
704 if (await $.fs.exists(path)) {
705 try {
706 data = JSON.parse(await $.fs.read(path)) as StateFile
707 } catch {
708 return `Could not parse ${name}; nothing changed.`
709 }
710 }
711
712 const at = await isoNow($)
713 delete data.adopted
714 data.branch = repo.branch
715 data.repo ??= repo.commonDir
716 if (repo.isWorktree) data.worktree ??= repo.root
717 for (const key of ['ticket', 'title', 'shortTitle', 'order'] as const) {
718 if (input.item?.[key] !== undefined) Object.assign(data, { [key]: input.item[key] })
719 }
720 if (input.current) data.current = { ...data.current, ...input.current }
721 if (input.stage) {
722 const key = input.stage === 'pull_request' ? 'pullRequest' : input.stage
723 const stages = (data.stages ??= {})
724 const before = stages[key] ?? {}
725 const after: Stage & Record<string, unknown> = { ...before, ...input.fields }
726 if (after.done === true && input.fields?.at === undefined && !(before.done && before.at)) after.at = at
727 stages[key] = after
728 }
729 await $.fs.write(path, `${JSON.stringify(data, null, 2)}
730`)
731 await refresh($)
732
733 const row = (await read($, rows)).find(one => one.isCurrentBranch)
734 return row ? `Wrote ${name}: ${stageLabel(row)}${row.detail ? `, ${row.detail}` : ''}` : `Wrote ${name}.`
735}
736
737// --- Aspire ---------------------------------------------------------------------------------
738
739let isScanning = false
740let lastQuietScan = 0
741// Whether this session ran a command that started an AppHost; until then a quiet machine is scanned slowly.
742let hasStarted = false
743let runsAspire: boolean | undefined
744let lastAspire: AspireEntry[] | null = null
745let pythonName: string | undefined
746
747const aspireDir = async ($: EngineInterface) => `${await homeDir($)}/.claude/dotnet-workflow-kit/aspire`
748const ownerName = (entry: AspireEntry) => entry.label || entry.branch || 'another session'
749const portsOf = (entry: AspireEntry) => [...new Set([...entry.ports, ...entry.launchPorts])].sort((a, b) => a - b)
750const portList = (entry: AspireEntry) => portsOf(entry).map(port => `:${port}`).join(' ')
751const dashboardPort = (entry: AspireEntry) => /:(\d+)/.exec(entry.dashboard.replace(/^[a-z]+:\/\//i, ''))?.[1]
752
753// Whether the profile says the system runs through Aspire: the project's profile, else the personal one.
754async function profileRunsAspire($: EngineInterface): Promise<boolean> {
755 if (runsAspire !== undefined) return runsAspire
756 runsAspire = false
757 const repo = await currentRepo($)
758 const paths = [...(repo ? [`${repo.root}/.claude/dotnet-workflow-kit.json`] : []), `${await homeDir($)}/.claude/dotnet-workflow-kit.json`]
759 for (const path of paths) {
760 try {
761 if (!(await $.fs.exists(path))) continue
762 const profile = JSON.parse(await $.fs.read(path)) as { stack?: { local_run?: unknown } }
763 runsAspire = profile.stack?.local_run === 'aspire'
764 break
765 } catch {
766 // An unreadable profile leaves Aspire off.
767 }
768 }
769
770 return runsAspire
771}
772
773// Whether any session has an AppHost in the registry, live or not yet cleared away.
774async function isListed($: EngineInterface): Promise<boolean> {
775 const dir = await aspireDir($)
776 if (!(await $.fs.exists(dir))) return false
777
778 return (await $.fs.list(dir)).some(entry => entry.kind === 'file' && String(entry.name).endsWith('.json'))
779}
780
781type AspireResult = { entries: AspireEntry[]; deny?: string; starts?: string }
782
783// Runs scripts/aspire_sessions.py as this session: its id, worktree, branch and item name.
784async function runAspire($: EngineInterface, action: 'scan' | 'check', extra: string[] = []): Promise<AspireResult | undefined> {
785 const repo = await currentRepo($)
786 const current = (await read($, rows)).find(row => row.isCurrentBranch)
787 const script = `${$.plugin.root.replace(/\\/g, '/')}/scripts/aspire_sessions.py`
788 const args = [
789 script, action,
790 '--dir', await aspireDir($),
791 '--session', await $.session.id(),
792 '--root', repo?.root ?? '',
793 '--repo', repo?.commonDir ?? '',
794 '--branch', repo?.branch ?? '',
795 '--label', current ? rowName(current) : (repo?.branch ?? ''),
796 ...extra,
797 ]
798 for (const python of pythonName ? [pythonName] : ['python', 'python3']) {
799 try {
800 const result = await $.process.run([python, ...args], { timeoutMs: 60000 })
801 if (result.exitCode !== 0) {
802 // A name that ran the script and failed is the right Python; a stub that is not goes on to the next.
803 if (pythonName || result.stderr.includes('aspire_sessions')) {
804 $.ui.log(`dotnet-workflow-kit: aspire_sessions: ${result.stderr.trim()}`)
805 return undefined
806 }
807 continue
808 }
809 pythonName = python
810
811 return JSON.parse(result.stdout) as AspireResult
812 } catch {
813 // This name is not on PATH, or printed no JSON; try the next.
814 }
815 }
816
817 return undefined
818}
819
820// Stores a scan's entries, with a toast when another session's AppHost goes and when this one's starts.
821async function applyAspire($: EngineInterface, entries: AspireEntry[]) {
822 const before = lastAspire
823 lastAspire = entries
824 if (before) {
825 const live = new Set(entries.map(entry => entry.pid))
826 for (const gone of before.filter(entry => !entry.isMine && !live.has(entry.pid))) {
827 const free = portList(gone)
828 $.ui.toast(`${ownerName(gone)} stopped ${gone.project}${free ? ` · ${free} free` : ''}`)
829 }
830 const had = new Set(before.filter(entry => entry.isMine).map(entry => entry.pid))
831 for (const fresh of entries.filter(entry => entry.isMine && !had.has(entry.pid))) {
832 $.ui.toast(`${fresh.project} is this session's${fresh.dashboard ? `: dashboard ${fresh.dashboard}` : ''}`)
833 }
834 }
835 // seenAt moves on every scan; only a change worth drawing updates the atom.
836 const key = (list: AspireEntry[]) => JSON.stringify(list.map(({ seenAt, ...rest }) => rest))
837 if (key(entries) !== key(await read($, aspire))) await update($, aspire, () => entries)
838}
839
840async function scanAspire($: EngineInterface) {
841 if (isScanning) return
842 isScanning = true
843 try {
844 // An owner rescans often, so its entries never look abandoned; any other session looks now and
845 // then, since a check scans afresh before judging a command anyway.
846 const isOwner = hasStarted || (await read($, aspire)).some(entry => entry.isMine)
847 if (!isOwner) {
848 // Nothing listed and no profile running Aspire: nothing to show or protect.
849 if (!(await isListed($)) && !(await profileRunsAspire($))) {
850 if ((await read($, aspire)).length > 0) await applyAspire($, [])
851 return
852 }
853 const now = await $.clock.now()
854 if (now - lastQuietScan < QUIET_MS) return
855 lastQuietScan = now
856 }
857 const result = await runAspire($, 'scan')
858 if (result) await applyAspire($, result.entries)
859 } catch (err) {
860 $.ui.log(`dotnet-workflow-kit: aspire: ${String(err)}`)
861 } finally {
862 isScanning = false
863 }
864}
865
866// Why a command must not run, as scripts/aspire_sessions.py judges it; undefined lets it through.
867async function checkAspire($: EngineInterface, command: string): Promise<string | undefined> {
868 const mayStart = /\b(run|watch|start)\b/i.test(command)
869 if (!mayStart && !(await isListed($))) return undefined
870 const result = await runAspire($, 'check', ['--cwd', await $.session.cwd(), '--command', command])
871 if (!result) return undefined
872 await applyAspire($, result.entries)
873 if (result.starts) {
874 hasStarted = true
875 for (const ms of START_SCANS_MS) $.clock.after(ms, () => void scanAspire($))
876 }
877
878 return result.deny
879}
880
881// What Claude is told about the AppHosts on this machine; empty when none run.
882function aspirePrompt(entries: AspireEntry[]): string {
883 const mine = entries.filter(entry => entry.isMine)
884 const others = entries.filter(entry => !entry.isMine && !entry.isStale)
885 if (mine.length === 0 && others.length === 0) return ''
886 const line = (entry: AspireEntry) =>
887 `- ${entry.project} · pid ${entry.pid}${entry.dashboard ? ` · dashboard ${entry.dashboard}` : ''}` +
888 `${portsOf(entry).length ? ` · ports ${portsOf(entry).join(' ')}` : ''}`
889
890 return [
891 '# Aspire on this machine',
892 'The dotnet-workflow-kit tracks which Claude session owns each running Aspire AppHost.',
893 'Yours:',
894 ...(mine.length ? mine.map(line) : ['- none running']),
895 ...(others.length
896 ? [
897 "Other sessions':",
898 ...others.map(entry => `${line(entry)} · ${ownerName(entry)}`),
899 "Never stop, restart or reuse another session's AppHost or its ports: commands that would are refused. " +
900 'Start yours on ports nobody else holds. To have one stopped, ask the person or message its session.',
901 ]
902 : []),
903 ].join('\n')
904}
905
906// The Sessions pane's "Ask its session to stop it": a message to the session that owns the AppHost.
907async function askToStop($: EngineInterface, entry: AspireEntry) {
908 try {
909 const sent = await $.session.send({
910 to: { sessionId: entry.session },
911 text:
912 `The person asked, from another session's Sessions pane, for ${entry.project} (pid ${entry.pid}) to be ` +
913 'stopped so its ports are free. Stop it if your work allows, and say so.',
914 })
915 $.ui.toast(sent.isDelivered ? `Asked ${ownerName(entry)} to stop ${entry.project}` : `Could not reach that session: ${sent.reason}`)
916 } catch (err) {
917 $.ui.toast(`Could not reach that session: ${String(err)}`)
918 }
919}
920
921// A session that ends leaves its AppHosts' entries marked as gone, for a session in the same worktree to take.
922async function endAspire($: EngineInterface) {
923 const dir = await aspireDir($)
924 for (const entry of (await read($, aspire)).filter(one => one.isMine)) {
925 try {
926 const path = `${dir}/${entry.pid}.json`
927 const data = JSON.parse(await $.fs.read(path)) as AspireEntry
928 if (data.session === entry.session) await $.fs.write(path, `${JSON.stringify({ ...data, seenAt: 0 }, null, 2)}\n`)
929 } catch {
930 // Gone already.
931 }
932 }
933}
934
935export const register: Register = on => {
936 on('session.start', async ($, e, next) => {
937 watchingSince = await $.clock.now()
938 await $.command.register({
939 name: 'progress',
940 description: 'Show or hide the workflow progress bar; "done" closes this branch\'s item',
941 })
942 await $.tool.register({
943 name: 'stage',
944 description:
945 "Record the dotnet-workflow-kit pipeline state for this branch: the progress bar's state file. " +
946 'Pass `stage` with `fields` to merge into stages.<stage> (done, artifactUrl, decision, note, waiting, ...; ' +
947 '`at` is filled when done is true), `current` for what the bar shows now, and `item` (ticket, title, ' +
948 'shortTitle, order) when the item starts. Fields left out keep their value. Finds or creates the file itself.',
949 inputSchema: {
950 type: 'object',
951 properties: {
952 stage: { type: 'string', description: 'Stage id: start, plan, implement, sweep, test, review, pull_request or a profile extra stage.' },
953 fields: { type: 'object', description: 'Keys merged into stages.<stage>.' },
954 current: {
955 type: 'object',
956 properties: { stage: { type: 'string' }, detail: { type: 'string' }, blocked: { type: 'boolean' } },
957 },
958 item: {
959 type: 'object',
960 properties: {
961 ticket: { type: 'string' },
962 title: { type: 'string' },
963 shortTitle: { type: 'string', description: 'At most four words.' },
964 order: { type: 'array', items: {}, description: 'Stage ids, or {id, label}, in pipeline order.' },
965 },
966 },
967 },
968 },
969 })
970 await $.command.register({
971 name: 'sessions',
972 description: 'Show or hide the pane listing every work item in this repository',
973 })
974 await refresh($)
975 $.clock.every(POLL_MS, () => void refresh($))
976 $.clock.after(1000, () => void closeItems($))
977 $.clock.every(CLOSE_MS, () => void closeItems($))
978 // The first prompt is composed right after this, so the AppHosts are looked up first, briefly.
979 await Promise.race([scanAspire($), $.clock.sleep(3000)])
980 $.clock.every(ASPIRE_MS, () => void scanAspire($))
981
982 return next(e)
983 })
984
985 on('session.end', async ($, e, next) => {
986 await endAspire($)
987
988 return next(e)
989 })
990
991 // The AppHosts on this machine and whose they are, last in the prompt, while any run.
992 on('prompt.compose', async ($, e, next) => {
993 const result = await next(e)
994 const text = aspirePrompt(await read($, aspire))
995 if (!text) return result
996
997 return { sections: [...result.sections, { id: 'dotnet-workflow-kit:aspire', text, scope: 'session' as const }] }
998 })
999
1000 on('command.run', { command: 'progress' }, async ($, e) => {
1001 if (e.args.trim() === 'done') return { text: await closeCurrent($) }
1002 const hidden = await update($, isHidden, h => !h)
1003 await refresh($)
1004
1005 return { text: hidden ? 'Workflow progress bar hidden.' : 'Workflow progress bar shown.' }
1006 })
1007
1008 on('command.run', { command: 'sessions' }, async $ => {
1009 const isOpen = await toggleSessions($)
1010
1011 return { text: isOpen ? 'Sessions pane opened.' : 'Sessions pane closed.' }
1012 })
1013
1014 on('tool.call', { tool: STAGE_TOOL }, async ($, e) => ({
1015 result: await writeStage($, e as unknown as StageInput).catch(err => `Could not write the state file: ${String(err)}`),
1016 }))
1017
1018 on('tool.call', async ($, e, next) => {
1019 const input = e as unknown as Record<string, unknown>
1020 // A subagent's calls are its own: the band names the subagent, not what it is reading.
1021 const isSubagent = e.agentId !== undefined
1022 const text = isSubagent ? undefined : describe(input)
1023 if (text) {
1024 const now = await $.clock.now()
1025 await update($, activity, (): Activity => ({ text: clip(text), at: now }))
1026 }
1027 // A shell command that stops processes or starts an AppHost is judged against the other sessions' first.
1028 const command =
1029 (input.tool === 'Bash' || input.tool === 'PowerShell') && typeof input.command === 'string' ? input.command : ''
1030 const isAspire = !!command && ASPIRE_COMMAND.test(command)
1031 if (isAspire) {
1032 // A failed check never holds the command up; the deny is a guard, not a gate.
1033 const deny = await checkAspire($, command).catch(err => void $.ui.log(`dotnet-workflow-kit: aspire: ${String(err)}`))
1034 if (deny) return { deny }
1035 }
1036 const result = await next(e)
1037 if (!isSubagent) void refresh($)
1038 if (isAspire) $.clock.after(2000, () => void scanAspire($))
1039
1040 return result
1041 })
1042
1043 // While /next is blocked, a goal re-prompt would only repeat the blocker; skip the goal's
1044 // Stop hook so the goal stays set but quiet until the person answers.
1045 on('classic.Stop', async ($, e, next) => {
1046 const pending = (e.background_tasks ?? []).filter(t => t.status === 'running' || t.status === 'pending')
1047 await update($, background, () => pending.map(t => clip(t.description || t.command || t.type)))
1048 await refresh($)
1049 const current = (await read($, rows)).find(row => row.isCurrentBranch)
1050 if (current?.isBlocked) return {}
1051
1052 return next(e)
1053 })
1054
1055 on('agent.spawn', async ($, e, next) => {
1056 const started = await next(e)
1057 const id = started.deny === undefined ? started.agentId : undefined
1058 if (id) {
1059 const at = await $.clock.now()
1060 await update($, agents, list => [...list.filter(a => a.id !== id), { id, description: e.description, at }]).catch(
1061 err => $.ui.log(`dotnet-workflow-kit: ${String(err)}`),
1062 )
1063 }
1064
1065 return started
1066 })
1067
1068 on('turn.complete', async ($, e, next) => {
1069 const agentId = e.agentId
1070 if (agentId === undefined) await update($, activity, () => null)
1071 else await update($, agents, list => list.filter(a => a.id !== agentId))
1072 await refresh($)
1073 await touchAdopted($)
1074
1075 return next(e)
1076 })
1077
1078 // The band shows only this session's item; a chip counts the others and opens the Sessions pane.
1079 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1080 const hiddenSlugs = await read($, dismissed)
1081 const all = (await read($, rows)).filter(one => !hiddenSlugs.includes(one.slug))
1082 const row = all.find(one => one.isCurrentBranch)
1083 const others = all.filter(one => !one.isCurrentBranch && !isComplete(one))
1084 const othersWaiting = others.filter(isWaiting).length
1085 if (e.props.hasSurvey || (!row && others.length === 0) || (await read($, isHidden))) {
1086 return next(e)
1087 }
1088
1089 // The row shows live tool activity while Claude works, otherwise the file's detail.
1090 const live = await read($, activity)
1091 const tasks = e.props.isWorking ? [] : await read($, background)
1092 const isBusy = e.props.isWorking || tasks.length > 0
1093 // An agent that never reports its end (a remote one, a killed one) stops counting after an hour.
1094 const now = await $.clock.now()
1095 const running = (await read($, agents)).filter(a => now - a.at < AGENT_MS).length
1096 const crew = running === 0 ? '' : ` · ${running} ${running === 1 ? 'agent' : 'agents'} running`
1097 // A finished item says how it ended; this session's own shells and agents are not its news.
1098 const doing = (one: PipelineRow) =>
1099 isWaiting(one) || isComplete(one)
1100 ? one.detail
1101 : e.props.isWorking && live
1102 ? `${live.text}${crew}`
1103 : tasks.length > 0
1104 ? `Waiting on ${tasks.length === 1 ? tasks[0] : `${tasks.length} background tasks`}`
1105 : one.detail
1106 const chipLabel =
1107 others.length === 0 ? '' : othersWaiting > 0 ? `+${others.length} · ${othersWaiting} needs you` : `+${others.length}`
1108 const openPane = () => void toggleSessions($)
1109 // This session's own AppHost, by its dashboard port.
1110 const host = (await read($, aspire)).find(one => one.isMine)
1111 const hostPort = host ? dashboardPort(host) ?? host.ports[0] : undefined
1112 const hostLabel = host ? `Aspire${hostPort ? ` :${hostPort}` : ''}` : ''
1113
1114 if (e.surface === 'terminal') {
1115 const { Box, Text, Button } = $.ui.resolve(e)
1116 const cols = e.props.bodyColumns
1117 const chip = chipLabel ? (
1118 <Box flexShrink={0} gap={1}>
1119 {othersWaiting > 0 ? <Text color={COLOR.waiting}>●</Text> : null}
1120 <Button key="sessions-chip" label={chipLabel} plain dimColor={othersWaiting === 0} onPress={openPane} />
1121 </Box>
1122 ) : null
1123 if (!row) {
1124 return (
1125 <Box width={cols} justifyContent="flex-end">
1126 {chip}
1127 </Box>
1128 )
1129 }
1130
1131 const nameWidth = Math.max(10, Math.min(28, Math.floor(cols * 0.22)))
1132 const stageWidth = stageLabel(row).length
1133 const chipWidth = chipLabel ? chipLabel.length + 4 : 0
1134 const doingMin = cols >= 90 ? 24 : 0
1135 // A narrow terminal keeps the AppHost's mark and drops its label.
1136 const isHostLabelled = cols >= 90
1137 const hostWidth = host ? (isHostLabelled ? hostLabel.length + 3 : 2) : 0
1138 // dot, name, bar, stage, percent, AppHost, chip and their one-cell gaps, then the activity text.
1139 const room = cols - 2 - nameWidth - stageWidth - 4 - 5 - hostWidth - chipWidth - doingMin
1140 const segment = Math.max(1, Math.min(6, Math.floor(room / row.labels.length)))
1141
1142 return (
1143 <Box gap={1} width={cols}>
1144 <Box flexShrink={0}>
1145 <Text color={dotColor(row)}>●</Text>
1146 </Box>
1147 <Box width={nameWidth} flexShrink={0}>
1148 <Text wrap="truncate-end" bold>
1149 {rowName(row)}
1150 </Text>
1151 </Box>
1152 <Box width={segment * row.labels.length} flexShrink={0}>
1153 <Text wrap="truncate-end">
1154 {row.statuses.map(s => (
1155 <Text color={s === 'todo' ? COLOR.todo : COLOR[s]} dimColor={s === 'todo'}>
1156 {(s === 'todo' ? '░' : s === 'done' ? '█' : '▓').repeat(segment)}
1157 </Text>
1158 ))}
1159 </Text>
1160 </Box>
1161 <Box width={stageWidth} flexShrink={0}>
1162 <Text color={dotColor(row)} wrap="truncate-end">
1163 {stageLabel(row)}
1164 </Text>
1165 </Box>
1166 <Box width={4} flexShrink={0}>
1167 <Text dimColor>{String(pct(row)).padStart(3)}%</Text>
1168 </Box>
1169 {host ? (
1170 <Box flexShrink={0} gap={1}>
1171 <Text color={CARD.check}>◆</Text>
1172 {isHostLabelled ? <Text dimColor>{hostLabel}</Text> : null}
1173 </Box>
1174 ) : null}
1175 {doingMin > 0 && doing(row) ? (
1176 <Box flexGrow={1} flexShrink={1} minWidth={0}>
1177 <Text dimColor wrap="truncate-end">
1178 {doing(row)}
1179 </Text>
1180 </Box>
1181 ) : null}
1182 {chip}
1183 </Box>
1184 )
1185 }
1186
1187 const { Box, Text, Svg, Button } = $.ui.resolve(e)
1188 const chip = chipLabel ? (
1189 <Box flexDirection="row" alignItems="center" gap={1} flexShrink={0}>
1190 {othersWaiting > 0 ? <Text color={COLOR.waiting}>●</Text> : null}
1191 <Button key="sessions-chip" label={`${chipLabel} ›`} plain dimColor={othersWaiting === 0} onPress={openPane} />
1192 </Box>
1193 ) : null
1194 if (!row) {
1195 return (
1196 <Box flexDirection="row" justifyContent="flex-end">
1197 {chip}
1198 </Box>
1199 )
1200 }types/index.d.ts 49 lines1export type Activity = { text: string; at: number }
2
3export type RunningAgent = { id: string; description: string; at: number }
4
5export type StageStatus = 'done' | 'active' | 'waiting' | 'todo'
6
7export type PipelineRow = {
8 slug: string
9 ticket: string
10 title: string
11 branch: string
12 isCurrentBranch: boolean
13 labels: string[]
14 isPlanApproved: boolean
15 isReviewApproved: boolean
16 isBlocked: boolean
17 statuses: StageStatus[]
18 doneCount: number
19 activeIndex: number
20 detail: string
21}
22
23// One running Aspire AppHost, as scripts/aspire_sessions.py reads the registry: the session
24// that owns it, the worktree it runs from, the processes it started and the ports they hold.
25export type AspireEntry = {
26 pid: number
27 project: string
28 projectDir: string
29 root: string
30 repo: string
31 branch: string
32 label: string
33 session: string
34 dashboard: string
35 ports: number[]
36 launchPorts: number[]
37 pids: number[]
38 firstSeen: number
39 seenAt: number
40 isMine: boolean
41 isStale: boolean
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 'dotnet-workflow-kit': { rows: PipelineRow[]; isHidden: boolean; dismissed: string[]; activity: Activity | null; background: string[]; goalsSet: Record<string, string[]>; notifyOthers: boolean; aspire: AspireEntry[]; agents: RunningAgent[] }
47 }
48}
49