SLOPSHOPPER

dotnet-workflow-kit

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…

newpanebandguardcommandtoast
★ 1v0.14.0MITupdated 2026-10-09chrisjainsley/claude-dotnet-workflow-kit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dotnet-workflow-kit
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ No work items in this repository yet. /start │ ┃ begins one. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Notify me when another session needs me: Off ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /progress │ ⎿ dotnet-workflow-kit: Workflow progress bar hidden. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
No work items in this repository yet. /start begins one. Notify me when another session needs me: Off
README

dotnet-workflow-kit

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

Install

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

Workflow

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.

StageWhoResult
0. StartAgentCreate and push a branch; assign and activate the item when a tracker is configured.
1. PlanAgent, then youBuild the plan page; you answer the open questions or approve.
2. ImplementAgentImplement the plan one layer per commit, then run the reviewer sweep, fix findings and verify.
3. TestAgentRun the suites, then the plan's scenarios in the configured environment; fix failures and rerun.
4. ReviewAgent, then youPresent the final diff, findings and QA evidence on one page; you approve or send it back.
5. Pull requestAgent, then youResolve 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.

Skills

start

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.

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.

Plan page showing Context, Specs and the Open questions form

implement

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.

test

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.

ModeContentPosting
ReportGiven/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.
NotesWhat 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

review

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.

Review page showing verdict tiles, the change diagram and decision form

next

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

Renamed in 0.6.0

The skills now carry the names of the stages they run. Old names are not aliased; update any saved prompts or /goal text.

BeforeNow
start-ticketstart
visual-planplan
Execute stage inside next, plus mega-reviewimplement (the sweep lives at skills/review/sweep.md)
qa-report, plus the QA stage inside nexttest
visual-reviewreview
Draft PR, Resolve comments and Publish and hand off stagesnext stage 5, Pull request

Progress bar

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.

Aspire across sessions

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.

  • The band marks this session's own AppHost after the stage, with its dashboard port.
  • The Sessions pane shows each card's AppHost with its uptime, process id, ports and a dashboard link, and lists every AppHost on the machine at the bottom. Another session's card has a button that asks that session to stop its AppHost.
  • Claude is told in its system prompt which AppHost is its own and which belong to other sessions, and a toast says when another session's AppHost stops and its ports are free.
  • Commands are refused when they would stop another session's AppHost (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.

Running with /goal

/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:

WhenThe 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.

Without the Artifact tool

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.

Branding

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.

Profile reference

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 "".

FieldAllowed valuesDefaultPurpose
schemaInteger1Profile schema version.
userFree text""Assignee and name used in page prose.
architectureclean, vertical, ddd-clean, modular-monolith"clean"Plan section order and review rules.
testing.tddstrict, encouraged, none"encouraged"TDD expectation; strict means tests first during execution.
testing.unitxunit, nunit, mstest"xunit"Unit test framework.
testing.integrationwebapplicationfactory, testcontainers, none"webapplicationfactory"Integration test approach.
testing.acceptancereqnroll, specflow, none"none"Acceptance test runner; none keeps scenarios without a BDD runner.
qa.ownerqa-team, self, none"self"Who tests and signs off.
qa.evidencework-item, pr-comment, none"none"Where approved QA reports go.
qa.handoff_labelFree text""PR label for QA hand-off.
qa.deploy_labelFree text""PR label to deploy to QA.
qa.environmentFree text"local"Environment named in QA evidence.
trackerazure-boards, github-issues, jira, none"none"Ticket adapter; none uses your description.
tracker_projectFree text""Project or organization identifier for tracker calls.
scmgithub, azure-repos"github"PR and diff adapter.
base_branchFree text"main"Starting branch and fallback PR base; stacked work uses its parent.
branch_patternFree text containing {slug}; supports {kind} and {id}"{kind}/{id}-{slug}"Branch naming template.
branch_kinds.featureNon-empty text"feat"Feature value for the kind token.
branch_kinds.bugNon-empty text"bug"Bug value for the kind token.
tracker_states.activeFree text""State when work starts; blank uses the adapter default.
tracker_states.qa_readyFree text""QA hand-off state; blank uses the adapter default.
artifactstrue, falsetruePublish Artifacts, or build local HTML and take answers in chat.
brandingtrue, falsetrueDelivery Labs colours and an attribution footer on the plan and review pages; false renders the neutral palette with no footer.
stack.dataef-core, dapper, cosmos, other"ef-core"Data access conventions.
stack.apiminimal-api, controllers, graphql, grpc"minimal-api"API contract style.
stack.messagingmasstransit, wolverine, service-bus, none"none"Messaging conventions.
stack.errorsresult, exceptions"exceptions"Error handling conventions.
stack.local_runaspire, docker, plain"plain"How to start the system for local QA.
stack.frontendnone, blazor, razor, react, angular, vue, javascript"none"Whether the repo has a frontend and which kind; see the plan skill.
reviewersbug-hunt, conventions, kit, security-scan, convention-learner, code-review-workflow["bug-hunt", "conventions"]Reviewer sweep passes; the two built-ins always run.
pipeline.executeFree text""Execution command; blank implements the plan directly.
pipeline.resolve_commentsFree text""Comment-resolution command; blank uses the SCM adapter.
pipeline.qaFree text""QA command; blank runs plan Specs manually per the QA adapter.
pipeline.open_pr_in_browsertrue, falsetrueIn Claude desktop, open a newly created PR in the Claude browser pane. Set false to only report the link.
pipeline.auto_fix_prtrue, falsetrueIn 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-kittrue, falsefalseCompanion plugin availability.
optional.codextrue, falsefalseEnable the Codex second-opinion reviewer.
optional.roslyn-mcptrue, falsefalseRoslyn MCP availability for code-review-workflow.
optional.walkthroughtrue, falsefalseNarrated 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.jevtrue, falsefalseJev availability: a TYPESAFE_API_KEY or a jev MCP server. Detected by setup. See Jev.
jev.flag_atNumber from 0 to 10.75Probability at or above which a scored check becomes a finding at the rule's severity.
jev.review_atNumber from 0 to 1, at most flag_at0.4Probability at or above which a scored check is listed as low with "confirm by reading".
checksList 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_stagesList 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_checksObject 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.

dotnet-claude-kit

Setup recommends the companion plugin when your answers need its skills, and asks before installing it. These mappings come from scripts/kit_profile.py:

Answerdotnet-claude-kit skills it needs
architecture: cleanclean-architecture
architecture: ddd-cleanclean-architecture, ddd
architecture: verticalvertical-slice
testing.tdd: stricttdd
stack.data: ef-coreef-core, migration-workflow
stack.api: minimal-apiminimal-api, openapi, api-versioning
stack.messaging: masstransitmessaging
stack.messaging: wolverinemessaging
stack.errors: resulterror-handling
stack.local_run: aspireaspire
reviewers: kitcode-review, 80-20-review, de-sloppify, verification-loop
reviewers: security-scansecurity-scan
reviewers: convention-learnerconvention-learner
reviewers: code-review-workflowcode-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

Jev is TypeSafe's System One model: a fast, calibrated judge that returns probabilities, never text. With

Source 2 files
hooks/register.tsx 1624 lines
1// 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 lines
1export 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