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…

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.
graph | teams | |
|---|---|---|
| Good for | one code request, one run | sized/split requests, documents, PRDs, QA, backlogs |
| MCP servers | graph-engineering (graph_*) | teams-engineering (team_*) + task-manager (tm_*) + teams-wiki (wiki_*) |
| Who drives | your session | a background daemon; your session only watches |
| Run files | .harness-run/broker/ | .teams_output/broker/ (runs), ~/.harness/tasks/ (tasks) |
| Version line | 1.x | 0.x |
Both can be enabled in the same project: the tool prefixes and run directories are distinct.
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"]
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.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.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.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:
| Step | What happens | Switch |
|---|---|---|
size | A 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. | — |
brainstorm | Restates intent, scope and approach; asks you questions only when interactive. Skipped when your session already passed decisions. | brainstorm |
areas | The 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-critique | New. 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 cards | One 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-integrate | Merges 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 → critique | shape 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 |
| accept | When a package's run finishes, a judge accepts or rejects its result. A rejection retries the package with the reasons attached. | max_retries |
integrate | Merges the accepted branches and runs the checks. A seam no single package can see gets a repair package that works on the merged tree. | — |
| QA | One 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 |
| audit | Planning checks the merged result against the merged PRD. Unmet stories are filed like QA defects. | roles.audit |
gate:goal | Judges the whole result against the original request (goal_threshold, default 90%). | goal_threshold |
report | Always 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).
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).
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 --> [*]
| State | Meaning |
|---|---|
running | The daemon is working on it. |
waiting_human | A card needs you: an ask question, a stage you took with teams:take, or a human_gates verdict. See teams:inbox. |
blocked | It 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. |
complete | The report is written and everything was delivered. |
partial | New. 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. |
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. Needssh(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.
| Skill | Use for |
|---|---|
teams:orchestrate | Anything; the engine picks the flow. |
teams:develop | Code. |
teams:document | A design doc, guide or other written artifact. |
teams:plan | A PRD. |
teams:qa | Writing and running test cases against existing work. |
teams:sprint | A 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 see | Use |
|---|---|
| Every EPIC, or one EPIC's STORY board | teams:board (tm_board) |
One ticket (E-xxxxxxxx, E-xxxxxxxx/P2) | teams:ticket (tm_ticket) |
| What a ticket is doing right now | teams:log (tm_log) |
| What is waiting on you | teams:inbox; answer with teams:submit; claim a card with teams:take |
| A live page in the browser | node 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>
| Exit | Meaning |
|---|---|
0 | complete |
3 | partial: report written, not everything delivered |
2 | waiting_human: the question is printed; answer it with tm_submit, then --resume |
1 | anything else that stopped (blocked, a usage limit it could not wait out) |
64 | bad arguments |
130 | Ctrl-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.
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.
| Key | Default | What it does |
|---|---|---|
vendor | "auto" | Which model vendor runs the nodes. |
allocation | "ordered" | How nodes are routed across vendors ("ordered" or "balanced"). |
goal_threshold | 90 | Minimum match % for a goal gate to accept. |
max_retries | 2 | Retries 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. |
brainstorm | true | Runs the engine's own brainstorm step when no decisions were passed. |
interactive | false | Whether 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_timeout | null | Milliseconds 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_ceiling | null | Upper limit for the "auto" controller; null derives one from the CPU count. |
driver_restarts | 2 | How many times a dead driver is respawned before its dispatch is marked blocked. |
restart_period_minutes | 0 | Counts driver_restarts in a sliding window of this many minutes; 0 counts forever. |
stall_minutes | 20 | A driver idle this long is flagged, and after 3x this long it is killed and respawned; 0 disables this. |
budget_usd | null | Spend cap for the whole task; at 100% nothing new is dispatched and the task closes to its report. |
timebox_minutes | null | The same stop, measured in minutes since tm_open. |
budget_grace_usd | null | Extra spend a running dispatch may use after the stop before it is killed (10% of budget_usd when unset). |
budget_grace_minutes | 5 | Extra time a running dispatch may use after the stop before it is killed. |
qa_rounds | 2 | How many QA or audit rounds may file fix STORYs; further defects go to unresolved_defects. |
upstream_fix_rounds | 2 | How many upstream fixes may be filed against one package. |
docs_dir | .teams_output/team | Where the phase documents (INDEX.md, per-STORY pages, report) are written. |
plugin_dirs | [] | Extra --plugin-dir paths for every driver and judge session. |
initiative | null | A label that groups several EPICs on the board; display only. |
goal_judges and auto_reassign are call arguments only, not team.json keys.
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/<space>/<slug>.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.
| Tool | What it does |
|---|---|
wiki_search | Search pages (Korean and English). |
wiki_get | One page with its links and backlinks. |
wiki_resume | The 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_list | Pages grouped by space. |
wiki_write | Save a page directly, no approval. Same space/slug again updates it. |
|
hooks/mod.tsx 457 lines1import { 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}
457types/index.d.ts 49 lines1export 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