SLOPSHOPPER

teams

A sibling of graph for requests too big for one pass: a TaskManager MCP (task-manager) sizes the request; size L is planned into a PRD and user stories, split…

newpanebandguardcommandtoast
★ 3v0.51.0MITupdated 2026-10-08newkayak12/claude-skills/teams
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · teams
│ ┃ Teams ✕ › fix the failing auth test and add an audit log call │ ┃ No teams run in this session. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /teams-live │ ⎿ teams: pane opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Teams
No teams run in this session.
README

teams

English · 한국어

teams runs a request the way a small software team would. A request that is too big for one pass is sized, planned, split into packages (one per piece of work), and each package is handed to its own worker in its own git worktree. Every result is judged before it is accepted, the accepted branches are merged into one integration tree, QA exercises the merged result, and a final goal gate decides whether the request was actually met. You get a report either way. A large (size L) request gets a PRD and user stories first. A small (size S) request skips all of that and is handed to the development harness, which plans, gates and reports it with its own stages.

Use teams when the work splits into parts, is not only code (a design doc, a PRD, a QA pass), or should keep running after you close the session. Use graph for a single code change you want to drive in one session.

graphteams
Good forone code request, one runsized/split requests, documents, PRDs, QA, backlogs
MCP serversgraph-engineering (graph_*)teams-engineering (team_*) + task-manager (tm_*) + teams-wiki (wiki_*)
Who drivesyour sessiona background daemon; your session only watches
Run files.harness-run/broker/.teams_output/broker/ (runs), ~/.harness/tasks/ (tasks)
Version line1.x0.x

Both can be enabled in the same project: the tool prefixes and run directories are distinct.

How it fits together

flowchart TB
  U["You, in a Claude Code session"] --> SK["Entry skill: orchestrate, develop, document, plan, qa, sprint"]
  SK -->|"tm_open / tm_run"| TM["task-manager MCP server, tm_* tools"]
  CLI["scripts/run.mjs, headless"] -->|"same open and wait"| TM
  TM -->|"spawns, detached"| DM["Task daemon"]
  DM <--> TJ[("task.json and ledger in ~/.harness/tasks")]
  DM -->|"one claude -p call per judging step"| J["Judge: size, areas, accept, plan-integrate, shape, critique, integrate, gate, report"]
  DM -->|"per package: worktree + driver"| P1
  DM -->|"per package: worktree + driver"| P2
  subgraph P1["Package P1: own worktree and branch"]
    D1["claude -p driver"] -->|"team_* tools"| B1["teams-engineering broker"]
    B1 --> R1["child graph run"]
  end
  subgraph P2["Package P2: own worktree and branch"]
    D2["claude -p driver"] -->|"team_* tools"| B2["teams-engineering broker"]
    B2 --> R2["child graph run"]
  end
  P1 -->|"accepted branch"| IT["Integration worktree"]
  P2 -->|"accepted branch"| IT
  IT --> RP["Report, retro.json, phase docs"]
  • task-manager (mcp/taskmanager.mjs) owns a task: its node graph, packages, worktrees and verdicts. It never does the work itself, and it reads child run files without ever writing them.
  • The daemon (mcp/daemon.mjs) is spawned by tm_open and outlives your session. It moves the task forward, calls a one-shot claude -p judge whenever a step needs judgment, and respawns dead or stalled drivers.
  • A driver is a headless claude -p session that drives one package's child run through the teams-engineering broker (mcp/broker.mjs, team_* tools). The broker is the same engine lineage as graph.
  • Integration merges accepted package branches into a separate integration worktree. teams never merges into your own branch; that tree is where the delivered work lives.

One task, start to finish

flowchart TD
  OPEN["tm_open"] --> SIZE{"size"}
  SIZE -->|"S"| SRUN["development harness run in the project directory, its own six stages"]
  SRUN --> SREP["report: the harness run's own goal gate and report, relayed"]
  SIZE -->|"L"| BS["brainstorm"]
  BS --> AREAS["areas: split the request by feature"]
  AREAS --> ACRIT{"areas-critique"}
  ACRIT -->|"coverage, overlap, criterion, granularity"| AREAS
  ACRIT -->|"sound"| PCARDS["planning cards PLAN-F1, PLAN-F2, ...<br/>one per feature area, full run each, in parallel"]
  PCARDS --> PACC{"accept, per card"}
  PACC -->|"rejected"| PCARDS
  PACC -->|"all accepted"| PI{"plan-integrate: merge into 10-prd.md and judge"}
  PI -->|"id collision, contradiction, missing feature"| PCARDS
  PI -->|"resplit: the split itself is wrong"| AREAS
  PI -->|"accepted"| SHAPE["shape: split the stories by ownership into packages"]
  SHAPE --> CRIT{"critique"}
  CRIT -->|"unsound"| SHAPE
  CRIT -->|"sound"| DISP["develop: each package runs the full harness in its own worktree<br/>plan → setgoal → critique → implement → test → gate → gate:goal → report<br/>in parallel where deps allow"]
  DISP --> ACC{"accept, per package"}
  ACC -->|"rejected"| DISP
  ACC -->|"all accepted"| INT{"integrate"}
  INT -->|"not verified"| REPAIR["repair package on the merged tree"]
  REPAIR --> INT
  INT -->|"verified"| QA{"QA cards QA-F1, QA-F2, ...<br/>one per feature area, in parallel"}
  QA -->|"defects found"| FIX["fix STORYs, dispatched like packages"]
  FIX --> INT
  QA -->|"clean"| AUDIT{"planning audit"}
  AUDIT -->|"unmet user stories"| FIX
  AUDIT -->|"all met"| GOAL{"gate:goal"}
  GOAL --> REPORT["report"]

What each step does:

StepWhat happensSwitch
sizeA judge decides S (one run is enough) or L (split it). You can pin it with size. A size-S task stops here in teams: it is handed to the development harness (the graph MCP, or the harness skill's Agent Team fallback), claude and codex taking part, which plans, sets goals, critiques, implements, tests and gates it with its own stages. No planning card, split, shape, worktree or QA card. The run is tagged with the task id; its own goal gate decides complete or partial, and its report is relayed.—
brainstormRestates intent, scope and approach; asks you questions only when interactive. Skipped when your session already passed decisions.brainstorm
areasThe EPIC's plan stage splits the request by feature: what a user must be able to do, grouped into feature areas. Each area becomes a planning card.—
areas-critiqueNew. A judge attacks the split before any card runs: a feature in no area, two areas planning the same feature, an area cut by module or layer instead of by what a user does, padded or merged areas. A refusal splits again with the defects as feedback.max_retries, human_gates
planning cardsOne STORY card per feature area (PLAN-F1, PLAN-F2, ...), each a full run in its own worktree, in parallel. Each writes its PRD section - goal, scope and non-goals, user stories with acceptance criteria (ids prefixed by the area, F1-US-1), open questions - and returns its user stories. A card is rejected when it wrote no PRD, a section is missing, it has no user stories, a story has no acceptance criteria, or a story id lacks the card's prefix; a rejected card is retried with the reasons. With acceptance already declared in the request each card runs a lighter chain.roles.planning (true, "light", "auto"; false is refused)
plan-integrateMerges every card's section into one 10-prd.md, then a judge checks it: story ids that collide, areas that contradict each other, a feature the request names that no card covers. A rejection sends the offending cards back with the gaps (or opens a card for the missing feature). When the split itself is wrong it returns resplit: the cards are retired (kept as history, out of the PRD) and areas splits again.max_retries, human_gates
shape → critiqueshape splits the merged user stories again, this time by ownership, into packages with touches[] and deps; every story must be implemented by some package. critique checks the split. An unsound shape is reshaped.—
develop (dispatch)The actual development. Each package gets its own git worktree and a driver session that runs the package's child run through the full harness: plan → setgoal → critique → implement → test → gate → gate:goal → report. plan is a build plan for that one package (files, interfaces, order of work, test plan, risks), not a re-split; setgoal carries the package's acceptance verbatim and critique checks the plan against it. A document package uses draft → review → gate in place of implement → test → gate. Packages whose deps are met run in parallel. See Inside one package.max_parallel_teams, vendor
acceptWhen a package's run finishes, a judge accepts or rejects its result. A rejection retries the package with the reasons attached.max_retries
integrateMerges the accepted branches and runs the checks. A seam no single package can see gets a repair package that works on the merged tree.—
QAOne QA card per feature area (QA-F1, QA-F2, ...), each a full run on the merged tree, in parallel, exercising its area's user stories. Once every card of the round has settled, their defects are filed together as fix STORYs, and the loop re-integrates and reopens every QA card. A card that runs out of retries is dropped from the round, not the round with it: its siblings' defects are still filed and the goal gate is told which area QA did not verify.roles.qa, qa_rounds
auditPlanning checks the merged result against the merged PRD. Unmet stories are filed like QA defects.roles.audit
gate:goalJudges the whole result against the original request (goal_threshold, default 90%).goal_threshold
reportAlways runs once the goal gate settles, pass or fail. Also writes retro.json for the next Sprint, including the user stories that did not ship - a story ships only when every package implementing it is in the final integration, and that integrate passed; there is no sub-EPIC, so unfinished work carries into the next Sprint (tm_open({context_from}) returns them as carryover_candidates). Its open questions include the ones a headless task decided for nobody, a package's contradicts_decision first, so the next Sprint's planning takes up a spec a package found could not hold. The session that opened the Sprint picks the carry-over and opens the next one; that is the long loop.—

Every loop has a budget (max_retries, qa_rounds, upstream_fix_rounds). When one runs out, the failure is settled: what depends on it is marked unreachable and the task goes on to its report instead of hanging. That holds before shape too: a split, a planning card or a planning integrate out of retries closes the task to a report and retro.json (with whatever PRD the cards wrote) instead of leaving it blocked with neither. A budget or timebox stop works the same way: nothing new is dispatched, the accepted packages are integrated, and the rest is listed as "Next backlog".

Two more loops exist but are left out of the diagram: an integrate conflict (two packages editing the same thing) needs tm_retry({repackage}), and a package that finds a bug in a package it depends on files an upstream fix there and waits for it (upstream_fix_rounds).

Inside one package

Every package runs the full harness inside its own child run, the same four steps the task runs around it (plan, set a goal, check it, build and judge). Planning cards (PLAN-F1, ...), QA cards (QA-F1, ...), the audit, and repair packages run the same shape:

flowchart LR
  PLN["plan"] --> SG["setgoal"] --> CR["critique"] --> CH["one chain per subgoal"] --> RD["reduce, if more than one subgoal"] --> GG["gate:goal"] --> RE["report"]

For a develop package, shape has already split the EPIC and critique has already checked that split, so the package's own plan is a build plan for this package: the files and modules to touch, the interfaces and data shapes, the order of work, the test plan and the risks. It does not split the EPIC again. setgoal usually keeps one subgoal and copies the package's acceptance into the spec word for word; critique judges the plan and spec against the package's brief and acceptance. The package's gate:goal verdict and report handoff are what the manager's accept reads. A human pin on the STORY (tm_assign, or assignee in the shape) applies to every subgoal the package's setgoal produces.

Each subgoal expands into a node chain chosen by its kind (KINDS in mcp/graph.mjs). The author of a stage is never the one who judges it.

flowchart LR
  subgraph code["subgoal: code"]
    C1["implement"] --> C2["test"] --> C3["gate"]
  end
  subgraph doc["document"]
    D1["draft"] --> D2["review"] --> D3["gate"]
  end
  subgraph pl["planning"]
    P1["investigate"] --> P2["draft"] --> P3["revise"] --> P4["gate"]
  end
  subgraph pll["planning-light"]
    L1["investigate"] --> L2["template-fill"] --> L3["gate"]
  end
  subgraph qa["qa"]
    Q1["cases"] --> Q2["execute"] --> Q3["gate"]
  end
  subgraph au["planning-audit"]
    A1["audit"] --> A2["gate"]
  end

A rejected gate retries its subgoal with the gate's gaps as feedback. An interactive planning run can stop after investigate with an ask card for you.

Which chain a subgoal gets depends on the run's flow: develop → code, document → document, plan → planning (or planning-light), qa → qa, and the post-QA audit → planning-audit.

Planning and QA run on cards, and every card is a full run like this one: a planning card (PLAN-F1, ...) in a worktree of its own, a QA card (QA-F1, ...) on the integration tree. The task itself runs the same six stages around its cards - areas is its plan, plan-integrate, accept and gate:goal are its gates - so no team runs a chain without a plan before it and a gate after it (design: _repo/docs/plans/2026-09-28-teams-cards-everywhere.md).

How a task ends

stateDiagram-v2
  [*] --> running: tm_open
  running --> waiting_human: a card needs a person
  waiting_human --> running: tm_submit or ask_timeout
  running --> blocked: stopped with work left and nothing runnable
  blocked --> running: tm_retry or resume-on-limit
  running --> complete: report written, everything delivered
  running --> partial: report written, something missing
  complete --> [*]
  partial --> [*]
StateMeaning
runningThe daemon is working on it.
waiting_humanA card needs you: an ask question, a stage you took with teams:take, or a human_gates verdict. See teams:inbox.
blockedIt stopped with work left and nothing it can run on its own, for example a spent retry budget, a driver that kept dying, or a usage limit. tm_retry opens a fresh attempt.
completeThe report is written and everything was delivered.
partialNew. The report is written but something was not delivered: a package not accepted or never dispatched, integrate failed, QA or audit gave no verdict, gate:goal did not pass, or nodes were settled/unreachable. partial_reasons: [...] says what.

Quick start

trophy rides along. From this version, the first interactive session after you install or update this plugin installs trophy (achievements) once, in user scope, if you don't have it. Nothing is sent until you say yes; uninstalling trophy is respected (it is never reinstalled). To opt out beforehand: mkdir -p ~/.claude/plugins/.newkayak12-trophy-ride.done. Needs sh (Windows without one is not covered).

Install. Install teams@newkayak12-claude-skills; that registers its three MCP servers (reload Claude Code if the tm_*/team_*/wiki_* tools do not show). teams:install is optional. It pins project defaults in .claude/team.json, adds a dispatch gate, and adds .claude/conventions/ (optionally filled from a reference project via develop:like-my-code). Without it, the built-in defaults apply. Details: docs/configuration.md#install.

Run. Pick the entry skill that matches the work. All of them open the same kind of task.

SkillUse for
teams:orchestrateAnything; the engine picks the flow.
teams:developCode.
teams:documentA design doc, guide or other written artifact.
teams:planA PRD.
teams:qaWriting and running test cases against existing work.
teams:sprintA prioritized backlog under a budget or timebox, ending in a retro.

Maintenance skills: teams:install and teams:remove (project setup), teams:patch (release bump for this repository's source).

Watch. You do not need to drive anything. Watch with:

To seeUse
Every EPIC, or one EPIC's STORY boardteams:board (tm_board)
One ticket (E-xxxxxxxx, E-xxxxxxxx/P2)teams:ticket (tm_ticket)
What a ticket is doing right nowteams:log (tm_log)
What is waiting on youteams:inbox; answer with teams:submit; claim a card with teams:take
A live page in the browsernode teams/scripts/view.mjs (pipeline, tickets and resources views; --once prints text)

Tickets follow Initiative (optional) > EPIC > STORY > TASK: I-<slug>, E-xxxxxxxx (the task), E-xxxxxxxx/Pn (a package; also E-xxxxxxxx/PLAN-F1 for a planning card and E-xxxxxxxx/QA-F1 for a QA card), E-xxxxxxxx/Pn/<subgoal> (a node chain).

Headless / CI. scripts/run.mjs opens a task and waits for it with no session in the loop:

node teams/scripts/run.mjs "<request>" [--kind auto|develop|document] [--size S|L]
  [--budget-usd <n>] [--timebox-minutes <n>] [--json] [--resume-on-limit]
node teams/scripts/run.mjs --resume <task_id>
ExitMeaning
0complete
3partial: report written, not everything delivered
2waiting_human: the question is printed; answer it with tm_submit, then --resume
1anything else that stopped (blocked, a usage limit it could not wait out)
64bad arguments
130Ctrl-C: the task keeps running in its daemon; the CLI only stops watching

All flags, the --resume-on-limit behaviour and the --json event stream: docs/configuration.md#headless.

Configuration

Put project defaults in .claude/team.json. The order is built-in default < team.json < an argument of the same name on tm_open/team_open. An unknown key or a bad value is ignored and noted in tm_status. The schema is TEAM_DEFAULTS in mcp/teamconfig.mjs. The full explanation of every key is in docs/configuration.md.

KeyDefaultWhat it does
vendor"auto"Which model vendor runs the nodes.
allocation"ordered"How nodes are routed across vendors ("ordered" or "balanced").
goal_threshold90Minimum match % for a goal gate to accept.
max_retries2Retries per package, shape and subgoal before the failure is settled.
retry_policy"continue"Whether a retry builds on the failed attempt's worktree or rolls it back first.
roles{planning:"auto", qa:true, audit:true}Which chain the planning cards run (true full, "light", "auto" light when acceptance is declared), and whether the QA cards and the audit run. planning: false is refused with a note: planning always runs.
brainstormtrueRuns the engine's own brainstorm step when no decisions were passed.
interactivefalseWhether questions, pins and human gates wait for a person or are decided by default.
human_gates[]Judging stages ("critique", "accept", "gate:goal", "areas-critique", "plan-integrate", ...) that a person decides instead of a model. A person refusing plan-integrate may pass resplit: true.
ask_timeoutnullMilliseconds before an unanswered ask card takes its default answer; null waits forever.
max_parallel_teams"auto"How many develop packages run at once; "auto" adapts to rate limits and crashes.
max_parallel_ceilingnullUpper limit for the "auto" controller; null derives one from the CPU count.
driver_restarts2How many times a dead driver is respawned before its dispatch is marked blocked.
restart_period_minutes0Counts driver_restarts in a sliding window of this many minutes; 0 counts forever.
stall_minutes20A driver idle this long is flagged, and after 3x this long it is killed and respawned; 0 disables this.
budget_usdnullSpend cap for the whole task; at 100% nothing new is dispatched and the task closes to its report.
timebox_minutesnullThe same stop, measured in minutes since tm_open.
budget_grace_usdnullExtra spend a running dispatch may use after the stop before it is killed (10% of budget_usd when unset).
budget_grace_minutes5Extra time a running dispatch may use after the stop before it is killed.
qa_rounds2How many QA or audit rounds may file fix STORYs; further defects go to unresolved_defects.
upstream_fix_rounds2How many upstream fixes may be filed against one package.
docs_dir.teams_output/teamWhere the phase documents (INDEX.md, per-STORY pages, report) are written.
plugin_dirs[]Extra --plugin-dir paths for every driver and judge session.
initiativenullA label that groups several EPICs on the board; display only.

goal_judges and auto_reassign are call arguments only, not team.json keys.

Wiki memory (teams-wiki)

teams-wiki is a third MCP server in this plugin: the place where subagents (claude and codex node workers and drivers) communicate through documents in the main project's .teams_wiki, and project memory that outlives a session. The server works without the task engine; the engine uses it as described under "In the task engine".

flowchart LR
  subgraph PRJ["Main project (task.cwd)"]
    W[(".teams_wiki/&lt;space&gt;/&lt;slug&gt;.md")]
  end
  subgraph WORK["Workers: investigate, plan, implement, draft ... (claude and codex)"]
    N1["node in package worktree P1"]
    N2["node in package worktree P2"]
    N3["node of a later task"]
  end
  N1 -->|"wiki_write: findings, decisions, contracts, pitfalls"| W
  N2 -->|"wiki_search / wiki_get when it needs context"| W
  W -->|"read on their own"| N3
  subgraph JUDGE["Judging: review, gate, accept, critique, test, audit, qa execute, manager judges"]
    G["no wiki tools, prompt: the wiki is not evidence"]
  end
  JUDGE -.-x W
  W -->|"pages modified while the task ran"| RP["Report: Wiki 변경"]

What goes in. Only what someone will need later and cannot get from the code or git: a fact with its source, a decision with its reason, an interface or contract another package relies on, a pitfall. Pages link with [[space/slug]], so one page leads to the next. Progress logs, task status and verdicts do not go in; the report and the ledger already hold those.

Pages are markdown under .teams_wiki/<space>/<slug>.md, git-tracked and hand-editable; that is the source. .teams_wiki/.index.sqlite (FTS5 search plus the [[link]] graph) is disposable: delete it and the next call rebuilds it from the md files. Search has two modes: fts5 when node:sqlite with FTS5 exists, otherwise scan (it reads the md files directly; no index file). Same results either way. Node 18+ is enough; wiki_status, tm_status and the report show the mode.

ToolWhat it does
wiki_searchSearch pages (Korean and English).
wiki_getOne page with its links and backlinks.
wiki_resumeThe most recent log/* pages and the pages they link to. The engine no longer writes log/*; pages there are ones people or workers wrote.
wiki_listPages grouped by space.
wiki_writeSave a page directly, no approval. Same space/slug again updates it.

|

Source 2 files
hooks/mod.tsx 457 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mark, ReportPayload, StatusInfo, SummaryTask, TeamsEvent } from '../types'
5
6const cursor = atom({ plugin: 'teams', key: 'cursor' } as const, 0)
7const status = atom({ plugin: 'teams', key: 'status' } as const, null as StatusInfo | null)
8const summary = atom({ plugin: 'teams', key: 'summary' } as const, null as SummaryTask | null)
9const watch = atom({ plugin: 'teams', key: 'watch' } as const, [] as string[])
10const view = atom({ plugin: 'teams', key: 'view' } as const, 'summary' as 'summary' | 'work' | 'log' | 'report')
11// the report of one task: keyed to the task it was fetched for, so a card or tab never draws another's
12const report = atom({ plugin: 'teams', key: 'report' } as const, null as { task_id: string; payload: ReportPayload } | null)
13// the report behind the end-of-run card: its own atom, so an open Report tab on another task never
14// replaces it (nor it the tab)
15const cardReport = atom({ plugin: 'teams', key: 'cardReport' } as const, null as { task_id: string; payload: ReportPayload } | null)
16const lang = atom({ plugin: 'teams', key: 'lang' } as const, 'en' as 'en' | 'ko')
17
18// the end-of-run card per ended task: 'card' while it is shown, 'seen' once its report was opened,
19// 'dismissed' on the person's say-so
20type Ended = Record<string, 'card' | 'seen' | 'dismissed'>
21const ended = atom({ plugin: 'teams', key: 'ended' } as const, {} as Ended)
22const mark = (m: Ended, id: string, state: Ended[string]): Ended => ({ ...m, [id]: state })
23
24const PANE = 'teams-live'
25
26const en = {
27  tabSummary: 'Summary', tabWork: 'Work', tabLog: 'Log', tabReport: 'Report',
28  cardFinished: 'finished', cardBlocked: 'blocked', cardFailed: 'failed {n}', cardMore: '+{n} more', reportButton: 'report', dismiss: '×',
29  reportMissing: 'No report yet: {path}', reportTruncated: 'Shortened: {n} more lines in the file.',
30  now: 'Now', you: 'You', stages: 'Stages', work: 'Work', cost: 'Cost',
31  youNone: 'Nothing needed', youOthers: '{n} more in other runs', costLine: '{usd} · {turns} turns',
32  headLine: '{state} · day {day} · {done}/{total}', bandDone: '{done}/{total} done',
33  stateRunning: 'running', stateStalled: 'stalled', stateComplete: 'finished', stateFailed: 'failed',
34  stagePlan: 'Plan', stageBuild: 'Build', stageIntegrate: 'Integrate', stageQa: 'QA', stageGate: 'Final gate', stageReport: 'Report',
35  nowFix: 'Fixing defect', nowFixnext: 'Waiting to fix defect', nowBuild: 'Building', nowPlan: 'Planning', nowQa: 'QA', nowAnswer: 'Waiting for your answer',
36  nowIntegrate: 'Integrating', nowGate: 'Final gate', nowReport: 'Writing the report', nowDone: 'All done', nowIdle: 'Idle',
37  logPassed: '{s} passed', logFailed: '{s} failed', logFiled: '{s} filed', logIntegrated: '{s} integrated',
38  logDispatched: '{s} started', logWaiting: '{s} waiting for you', logFinished: '{s} finished',
39  noRun: 'No teams run in this session.', loading: 'Loading...', noLog: 'Nothing logged yet.',
40  workMore: '+{n} more', cmdDesc: 'Open the teams live pane', paneOpened: 'pane opened',
41  board: 'board', inbox: 'needs you {n}', statusWaiting: 'needs you: {n} waiting - open /teams-live',
42  colTodo: 'To do', colDoing: 'Doing', colDone: 'Done',
43}
44
45// one table, two languages; the type keeps the keys identical
46export const STRINGS: Record<'en' | 'ko', Record<keyof typeof en, string>> = {
47  en,
48  ko: {
49    tabSummary: '요약', tabWork: '작업', tabLog: '기록', tabReport: '보고서',
50    cardFinished: '끝남', cardBlocked: '막힘', cardFailed: '실패 {n}', cardMore: '+{n}건 더', reportButton: '보고서', dismiss: '×',
51    reportMissing: '아직 보고서가 없습니다: {path}', reportTruncated: '일부만 표시: 파일에 {n}줄 더 있습니다.',
52    now: '지금', you: '확인', stages: '단계', work: '작업', cost: '비용',
53    youNone: '필요한 조치 없음', youOthers: '다른 실행에 {n}건 더', costLine: '{usd} · {turns}턴',
54    headLine: '{state} · {day}일째 · {done}/{total}', bandDone: '{done}/{total} 완료',
55    stateRunning: '진행 중', stateStalled: '멈춤', stateComplete: '완료', stateFailed: '실패',
56    stagePlan: '계획', stageBuild: '구현', stageIntegrate: '통합', stageQa: 'QA', stageGate: '최종 관문', stageReport: '보고',
57    nowFix: '결함 수정 중', nowFixnext: '결함 수정 대기', nowBuild: '구현 중', nowPlan: '계획 중', nowQa: 'QA 중', nowAnswer: '답변 대기 중',
58    nowIntegrate: '통합 중', nowGate: '최종 관문', nowReport: '보고서 작성 중', nowDone: '모두 끝남', nowIdle: '대기 중',
59    logPassed: '{s} 통과', logFailed: '{s} 실패', logFiled: '{s} 등록', logIntegrated: '{s} 반영',
60    logDispatched: '{s} 시작', logWaiting: '{s} 답변 대기', logFinished: '{s} 끝남',
61    noRun: '이 세션에 팀 실행이 없습니다.', loading: '불러오는 중...', noLog: '아직 기록이 없습니다.',
62    workMore: '+{n}건 더', cmdDesc: '팀 실행 현황 창 열기', paneOpened: '창을 열었습니다',
63    board: '보드', inbox: '확인 필요 {n}', statusWaiting: '확인 필요: {n}건 대기 - /teams-live 열기',
64    colTodo: '대기', colDoing: '진행', colDone: '완료',
65  },
66}
67
68// a key the table lacks (a state or kind this mod does not know) formats to ''
69const fmt = (text: string | undefined, vars: Record<string, string | number> = {}) =>
70  (text ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k] ?? ''))
71
72const MARK: Record<Mark, string> = { done: '✔', running: '●', pending: '○', failed: '✘' }
73// the stage rail: a dot per stage, a solid rail up to where the run is, dotted after
74const DOT: Record<Mark, string> = { done: '●', running: '◉', pending: '○', failed: '✘' }
75const TINT: Record<Mark, string> = { done: 'success', running: 'claude', pending: 'inactive', failed: 'error' }
76const BAR = 24
77const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
78
79const asRecord = (v: unknown): Record<string, unknown> | undefined =>
80  typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : undefined
81
82// task_id of a tm_open/tm_run result: structuredContent first, then JSON in content[0].text
83function taskIdOf(r: { result?: unknown; text?: unknown }): string | undefined {
84  const res = asRecord(r.result)
85  const fromStructured = asRecord(res?.structuredContent)?.task_id
86  if (typeof fromStructured === 'string' && fromStructured !== '') return fromStructured
87  const content = res?.content
88  const first = Array.isArray(content) ? asRecord(content[0])?.text : r.text
89  if (typeof first !== 'string') return undefined
90  try {
91    const id = asRecord(JSON.parse(first))?.task_id
92    return typeof id === 'string' && id !== '' ? id : undefined
93  } catch {
94    return undefined
95  }
96}
97
98// the pane's task: the last watched one, else this cwd's newest running one (from the tick's status)
99async function paneTask($: EngineInterface): Promise<string | undefined> {
100  return (await read($, watch)).at(-1) ?? (await read($, status))?.latest ?? undefined
101}
102
103// a person's words for the card the task is on now
104function nowSentence(s: (key: keyof typeof en) => string, task: SummaryTask): string {
105  const label = s(`now${cap(task.now.kind)}` as keyof typeof en) || s('nowIdle')
106  return task.now.subject ? `${label}: ${task.now.subject}` : label
107}
108
109// One report call for a task: the payload is stored under that task's id. A failed call or
110// unreadable output leaves what was stored.
111async function fetchReport($: EngineInterface, taskId: string, forCard = false): Promise<void> {
112  try {
113    const cwd = await $.session.cwd()
114    const r = await $.process.run(['node', `${$.plugin.root}/scripts/view.mjs`, '--once', '--format', 'report', '--task', taskId, '--cwd', cwd])
115    if (r.exitCode !== 0) return
116    const payload = JSON.parse(r.stdout.trim().split('\n').pop() ?? '') as ReportPayload
117    if (forCard) await update($, cardReport, () => ({ task_id: taskId, payload }))
118    else await update($, report, () => ({ task_id: taskId, payload }))
119  } catch {
120    // a failed call keeps the last payload
121  }
122}
123
124// the task whose card is shown now: the latest one still marked 'card'
125const cardTask = (m: Record<string, string>) => Object.keys(m).filter(k => m[k] === 'card').at(-1)
126
127const TICK_MS = 3000
128const WORK_MAX = 6
129
130export const register: Register = on => {
131  let isRunning = false
132
133  on('session.start', async ($, e, next) => {
134    if ((await $.session.surfaces()).length === 0) return next(e)
135
136    const VIEW = `${$.plugin.root}/scripts/view.mjs`
137
138    async function tick() {
139      if (isRunning) return
140      isRunning = true
141      try {
142        const cwd = await $.session.cwd()
143        const since = await read($, cursor)
144
145        const fetched = new Set<string>()
146        const cardFetched = new Set<string>()
147        const ev = await $.process.run([
148          'node', VIEW, '--once', '--format', 'events', '--since', String(since), '--cwd', cwd,
149        ])
150        if (ev.exitCode === 0) {
151          const fresh: TeamsEvent[] = []
152          for (const line of ev.stdout.split('\n')) {
153            if (!line.trim()) continue
154            try {
155              const one = JSON.parse(line) as TeamsEvent
156              if (one.ts > since) fresh.push(one)
157            } catch {
158              // a torn line: skip it
159            }
160          }
161          if (fresh.length > 0) {
162            for (const one of fresh) $.ui.toast(one.text)
163            // a run that ended after this session began gets a card and one report call
164            for (const one of fresh.filter(f => f.kind === 'daemon_done')) {
165              await update($, ended, m => mark(m, one.task_id, 'card'))
166              cardFetched.add(one.task_id)
167              await fetchReport($, one.task_id, true)
168            }
169            const latest = Math.max(...fresh.map(one => one.ts))
170            await update($, cursor, () => latest)
171          }
172        }
173
174        // one data call: the summary while the pane is open or a task of this cwd is running, else the status
175        const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
176        const isSummary = isOpen || (await read($, status))?.latest != null
177        const id = isOpen ? await paneTask($) : undefined
178        const data = await $.process.run([
179          'node', VIEW, '--once', '--format', isSummary ? 'summary' : 'status', '--cwd', cwd,
180          ...(id === undefined ? [] : ['--task', id]),
181        ])
182        if (data.exitCode === 0) {
183          const parsed = JSON.parse(data.stdout) as { status: StatusInfo; task: SummaryTask | null } & StatusInfo
184          const info: StatusInfo = isSummary ? parsed.status : parsed
185          await update($, status, () => info)
186          await update($, summary, () => (isSummary ? parsed.task ?? null : null))
187          // pinned only while a person must act (the engine draws it as a warning)
188          $.ui.status(info.waiting > 0 ? fmt(STRINGS[await read($, lang)].statusWaiting, { n: info.waiting }) : undefined)
189        }
190        // the Report tab refetches only while it is open, for the pane's task
191        const isTab = id !== undefined && (await read($, view)) === 'report'
192        if (isTab && (await read($, ended))[id] === 'card') await update($, ended, m => mark(m, id, 'seen'))
193        if (isTab && !fetched.has(id)) {
194          fetched.add(id)
195          await fetchReport($, id)
196        }
197        // a card waits for its report (none yet, or another task's in the atom)
198        const waiting = cardTask(await read($, ended))
199        if (waiting !== undefined && !cardFetched.has(waiting)) {
200          const held = await read($, cardReport)
201          if (held?.task_id !== waiting || held.payload.report === null) await fetchReport($, waiting, true)
202        }
203      } catch {
204        // a failed run or unreadable output leaves the last status as it was
205      } finally {
206        isRunning = false
207      }
208    }
209
210    // Claude Code's own language setting, read once per session
211    try {
212      const language = (await $.settings.read()).language
213      await update($, lang, () => (typeof language === 'string' && /^(ko|korean|한국어)/i.test(language) ? 'ko' : 'en'))
214    } catch {
215      // unreadable settings: English
216    }
217    await $.command.register({ name: 'teams-live', description: STRINGS[await read($, lang)].cmdDesc })
218    // events older than this session's start are not toasted
219    const now = await $.clock.now()
220    await update($, cursor, since => (since === 0 ? now : since))
221    $.clock.every(TICK_MS, tick)
222
223    return next(e)
224  })
225
226  // asked for by the person: the pane seats at any width
227  on('command.run', { command: 'teams-live' }, async $ => {
228    await $.ui.open({ id: PANE, title: 'Teams' })
229    return { text: STRINGS[await read($, lang)].paneOpened }
230  })
231
232  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
234    const kind = await read($, view)
235    const lang$ = await read($, lang)
236    const s = (key: keyof typeof en, vars?: Record<string, string | number>) => fmt(STRINGS[lang$][key], vars)
237    const id = await paneTask($)
238    const task = await read($, summary)
239    const info = await read($, status)
240
241    // plain tabs with hotkeys 1-3: the selected one in full strength with a dot, the rest dim
242    const tabs = (
243      <Box columnGap={3}>
244        {(['summary', 'work', 'log', 'report'] as const).map((one, i) => (
245          <Button key={one} plain hotkey={String(i + 1)} dimColor={kind !== one}
246            label={`${kind === one ? '● ' : ''}${s(`tab${cap(one)}` as keyof typeof en)}`}
247            onPress={async () => {
248              await update($, view, () => one)
249              if (one === 'report' && id !== undefined) {
250                await update($, ended, m => (m[id] === 'card' ? mark(m, id, 'seen') : m))
251                await fetchReport($, id)
252              }
253            }} />
254        ))}
255      </Box>
256    )
257    if (id === undefined || (task === null && kind !== 'report')) {
258      return (
259        <Box flexDirection="column">
260          <Text>{id === undefined ? s('noRun') : s('loading')}</Text>
261        </Box>
262      )
263    }
264    // the task's report, drawn only when it was fetched for the pane's task: its path first, then
265    // (when capped) the truncated notice, then the text
266    const rp = await read($, report)
267    const mine = rp !== null && rp.task_id === id ? rp.payload : null
268    const reportBody = mine === null ? <Text dimColor>{s('loading')}</Text>
269      : mine.report === null ? <Text>{s('reportMissing', { path: mine.path })}</Text>
270      : (
271        <Box flexDirection="column">
272          <Text>{mine.path}</Text>
273          {mine.report.truncated && <Text color="warning">{s('reportTruncated', { n: mine.report.more_lines })}</Text>}
274          <Markdown text={mine.report.text} />
275        </Box>
276      )
277    if (task === null) {
278      return (
279        <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
280          <Box marginY={1}>{tabs}</Box>
281          {reportBody}
282        </Box>
283      )
284    }
285
286    const stateWord = s(`state${cap(task.state)}` as keyof typeof en) || task.state
287    const filled = task.total > 0 ? Math.round((BAR * task.done) / task.total) : 0
288    const bar = (
289      <Box>
290        <Text color="success">{'━'.repeat(filled)}</Text>
291        <Text color="inactive">{'─'.repeat(BAR - filled)}</Text>
292        <Text bold>{`  ${task.done}/${task.total}`}</Text>
293      </Box>
294    )
295    const card = (c: SummaryTask['work'][number], i: number) => (
296      <Box key={`c${i}`} flexDirection="column">
297        <Text wrap="truncate-end"><Text color={TINT[c.state]}>{MARK[c.state]}</Text>{` ${c.title}`}</Text>
298        {c.state === 'failed' && c.reason && <Text color="error" wrap="truncate-end">{`  ${c.reason}`}</Text>}
299      </Box>
300    )
301
302    let body
303    if (kind === 'summary') {
304      const you = task.you.items.length > 0 ? task.you.items.join('; ') : task.you.count > 0 ? String(task.you.count) : s('youNone')
305      const others = (info?.waiting ?? 0) - task.you.count
306      body = (
307        <Box flexDirection="column">
308          <Box flexWrap="wrap">
309            {task.stages.map((g, i) => {
310              const next = task.stages[i + 1]
311              const solid = next !== undefined && next.state !== 'pending'
312              return (
313                <Box key={g.key}>
314                  <Text color={TINT[g.state]} bold={g.state === 'running'}>{`${DOT[g.state]} ${s(`stage${cap(g.key)}` as keyof typeof en)}`}</Text>
315                  {next !== undefined && <Text color={solid ? 'success' : 'inactive'}>{solid ? ' ━━ ' : ' ┄┄ '}</Text>}
316                </Box>
317              )
318            })}
319          </Box>
320          <Box flexDirection="column" marginTop={1}>
321            <Text color="claude" bold wrap="truncate-end">{`▶ ${s('now')} · ${nowSentence(s, task)}`}</Text>
322            <Text color={task.you.count > 0 ? 'warning' : 'inactive'} wrap="truncate-end">{`⚑ ${s('you')} · ${you}`}</Text>
323            {others > 0 && <Text color="warning">{`  ${s('youOthers', { n: others })}`}</Text>}
324          </Box>
325          <Box flexDirection="column" marginY={1}>
326            {task.work.slice(0, WORK_MAX).map(card)}
327            {task.work.length > WORK_MAX && <Text dimColor>{`  ${s('workMore', { n: task.work.length - WORK_MAX })}`}</Text>}
328          </Box>
329          {bar}
330        </Box>
331      )
332    } else if (kind === 'work') {
333      const cols = [
334        { key: 'colTodo', tint: 'inactive', cards: task.work.filter(c => c.state === 'pending' || c.state === 'failed') },
335        { key: 'colDoing', tint: 'claude', cards: task.work.filter(c => c.state === 'running') },
336        { key: 'colDone', tint: 'success', cards: task.work.filter(c => c.state === 'done') },
337      ] as const
338      body = (
339        <Box flexDirection="column">
340          <Box>
341            {cols.map(col => (
342              <Box key={col.key} flexDirection="column" width="33%" borderStyle="round" borderColor={col.tint} paddingX={1}>
343                <Text bold color={col.tint}>{`${s(col.key)} ${col.cards.length}`}</Text>
344                {col.cards.map(card)}
345              </Box>
346            ))}
347          </Box>
348          {bar}
349        </Box>
350      )
351    } else if (kind === 'log') {
352      const lines = task.log.map(one => `${one.time} ${s(`log${cap(one.kind)}` as keyof typeof en, { s: one.subject ?? '' }).trim()}`)
353      body = (
354        <Box flexDirection="column">
355          {lines.length === 0 ? <Text dimColor>{s('noLog')}</Text> : lines.map((line, i) => <Text key={`l${i}`}>{line}</Text>)}
356        </Box>
357      )
358    } else {
359      body = reportBody
360    }
361    return (
362      <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
363        <Box>
364          <Box flexShrink={1}><Text bold wrap="truncate-end">{task.title}</Text></Box>
365          <Box flexShrink={0} marginLeft={2}>
366            <Text color="claude">{s('headLine', { state: stateWord, day: task.day, done: task.done, total: task.total })}</Text>
367          </Box>
368        </Box>
369        <Box marginY={1} justifyContent="space-between">
370          {tabs}
371          <Text dimColor>{`${task.key} · ${s('costLine', { usd: `$${task.cost.usd.toFixed(2)}`, turns: task.cost.turns })}`}</Text>
372        </Box>
373        {body}
374      </Box>
375    )
376  })
377
378  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
379    const info = await read($, status)
380    const task = await read($, summary)
381    const lang$ = await read($, lang)
382    const s = (key: keyof typeof en, vars?: Record<string, string | number>) => fmt(STRINGS[lang$][key], vars)
383    const open = () => $.ui.open({ id: PANE, title: 'Teams' })
384
385    // an ended run: one card in place of the running row, until the person opens the report or dismisses it
386    const cardId = cardTask(await read($, ended))
387    const held = await read($, cardReport)
388    if (!e.props.hasSurvey && cardId !== undefined && held?.task_id === cardId && held.payload.verdict !== 'running') {
389      const { Box, Button, Text } = $.ui.resolve(e)
390      const p = held.payload
391      const set = (state: 'seen' | 'dismissed') => update($, ended, m => mark(m, cardId, state))
392      return (
393        <Box flexDirection="column">
394          <Box>
395            <Box flexShrink={0}><Text bold color={p.verdict === 'finished' ? 'success' : 'warning'}>{`teams · ${s(p.verdict === 'finished' ? 'cardFinished' : 'cardBlocked')}`}</Text></Box>
396            {p.failed > 0 && <Box flexShrink={0} marginLeft={1}><Text color="error">{s('cardFailed', { n: p.failed })}</Text></Box>}
397            {p.needs.items.length > 0 && <Box flexShrink={1} marginLeft={1}><Text dimColor wrap="truncate-end">{`— ${p.needs.items.join('; ')}`}</Text></Box>}
398            {p.needs.more > 0 && <Box flexShrink={0} marginLeft={1}><Text dimColor>{s('cardMore', { n: p.needs.more })}</Text></Box>}
399            {p.report !== null && (
400              <Box flexShrink={0} marginLeft={1}>
401                <Button key="report" label={s('reportButton')} onPress={async () => {
402                  await update($, watch, l => [...l.filter(x => x !== cardId), cardId])
403                  await update($, summary, () => null)
404                  await update($, report, () => held)
405                  await update($, view, () => 'report')
406                  await set('seen')
407                  await open()
408                }} />
409              </Box>
410            )}
411            <Box flexShrink={0} marginLeft={1}><Button key="dismiss" label={s('dismiss')} onPress={() => set('dismissed')} /></Box>
412          </Box>
413          {await next(e)}
414        </Box>
415      )
416    }
417
418    if (e.props.hasSurvey || info?.latest == null || task?.state !== 'running') return next(e)
419    const { Box, Button, Text } = $.ui.resolve(e)
420    return (
421      <Box flexDirection="column">
422        <Box>
423          <Box flexShrink={1}><Text dimColor wrap="truncate-end">{`teams · ${task.title}`}</Text></Box>
424          <Box flexShrink={1}><Text dimColor wrap="truncate-end">{` — ${nowSentence(s, task)}`}</Text></Box>
425          <Box flexShrink={0} marginLeft={1}>
426            <Text color="success">{'━'.repeat(task.total > 0 ? Math.round((10 * task.done) / task.total) : 0)}</Text>
427            <Text color="inactive">{'─'.repeat(10 - (task.total > 0 ? Math.round((10 * task.done) / task.total) : 0))}</Text>
428            <Text dimColor>{` ${s('bandDone', { done: task.done, total: task.total })}`}</Text>
429          </Box>
430          <Box flexShrink={0} marginLeft={1}><Button key="board" label={s('board')} onPress={open} /></Box>
431          {info.waiting > 0 && <Box flexShrink={0} marginLeft={1}><Button key="inbox" label={s('inbox', { n: info.waiting })} onPress={open} /></Box>}
432        </Box>
433        {await next(e)}
434      </Box>
435    )
436  })
437
438  // react only: the result goes back unchanged; interactive sessions only
439  on('tool.call', { tool: /__(tm_open|tm_run)$/ }, async ($, e, next) => {
440    const r = await next(e)
441    if ((await $.session.surfaces()).length === 0) return r
442    if (r.deny !== undefined) return r
443    const id = taskIdOf(r)
444    if (id !== undefined) await update($, watch, list => (list.includes(id) ? list : [...list, id]))
445    return r
446  }).catch(($, e, next) => next(e))
447
448  // guard: runs headless too
449  on('tool.call', { tool: /__team_status$/ }, ($, e, next) => {
450    const args = e as unknown as { full?: unknown; node_id?: unknown }
451    if (args.full === true && !args.node_id) {
452      return { deny: 'team_status full:true dumps every node; pass node_id or read detail_path (teams:orchestrate NEVER rule)' }
453    }
454    return next(e)
455  }).catch(($, e, next) => next(e))
456}
457
types/index.d.ts 49 lines
1export type TeamsEvent = { ts: number; task_id: string; kind: string; text: string }
2
3// `view.mjs --once --format status`: the one-line status of this cwd
4export type StatusInfo = { line: string; waiting: number; latest?: string | null }
5
6// `view.mjs --once --format summary` task part (teams/scripts/lib/view-summary.mjs)
7export type Mark = 'done' | 'running' | 'pending' | 'failed'
8export type SummaryTask = {
9  key: string
10  title: string
11  state: string
12  day: number
13  done: number
14  total: number
15  now: { kind: string; subject: string | null; detail: string | null }
16  you: { count: number; items: string[] }
17  stages: { key: string; state: Mark }[]
18  work: { title: string; kind: string; id: string | null; state: Mark; filed_by: string | null; reason: string | null }[]
19  cost: { usd: number; turns: number }
20  log: { time: string; kind: string; subject: string | null }[]
21}
22
23// `view.mjs --once --format report --task <id>` (teams/scripts/lib/view-report.mjs)
24export type ReportPayload = {
25  task_id: string
26  verdict: 'finished' | 'blocked' | 'running'
27  failed: number
28  needs: { items: string[]; more: number }
29  path: string
30  report: { text: string; truncated: boolean; more_lines: number; mtime: number } | null
31  retro: object | null
32}
33
34declare module 'claude-code' {
35  interface PluginState {
36    teams: {
37      cursor: number
38      watch: string[]
39      status: StatusInfo | null
40      summary: SummaryTask | null
41      view: 'summary' | 'work' | 'log' | 'report'
42      report: { task_id: string; payload: ReportPayload } | null
43      cardReport: { task_id: string; payload: ReportPayload } | null
44      ended: Record<string, 'card' | 'seen' | 'dismissed'>
45      lang: 'en' | 'ko'
46    }
47  }
48}
49