Styled Agent Card workflow with live lifecycle, task, worker and gate UI

Claude Code Mod for a requirements → plan → parallel build → review → ship workflow with a live side pane.
/requirement <text> captures a requirement, asks Claude to inspect the repo/skills, and creates a plan./plan-open opens the plan/card pane./plan-updates <changes> asks Claude to revise the approved plan without coding./build starts dependency-aware development; two worker streams are used only when safe./card opens/focuses the live pane./ship runs final gates, commits, pushes, and opens a PR. It does not merge.From the plugin directory:
claude plugin validate .
claude plugin test .
claude --plugin-dir .
Then try:
/requirement Add search filters to the orders list with backend filtering and frontend filter chips. Include tests.
Wait for planning to finish, then /plan-open, optionally /plan-updates ..., then /build, and finally /ship.
This first milestone implements the real pane, persistent card, slash commands, model-facing plan/task/stage tools, and the orchestration prompts. It intentionally excludes Environment. CI watching and stronger write-set locking are next hardening steps after validation on Claude Code 2.1.292.
/requirement now starts a dedicated background planning agent and consumes its turn.complete result directly. It no longer relies on a detached $.prompt.submit() to create the plan.
hooks/register.ts 375 lines1import type { Register, EngineInterface } from "claude-code";
2
3type Stage = "requirements" | "plan" | "build" | "review" | "ship";
4type TaskStatus = "pending" | "running" | "done" | "blocked";
5type Task = { id: string; title: string; group: string; status: TaskStatus; weight?: number; worker?: "A" | "B"; message?: string };
6type Card = {
7 id: string; title: string; requirement: string; stage: Stage; planVersion: number;
8 tasks: Task[]; updatedAt: string; message?: string;
9};
10
11const STORE_KEY = "active-card";
12const PANE_ID = "agent-card";
13let activeCard: Card | undefined;
14const builders = new Map<string, { cardId: string; version: number }>();
15const planners = new Map<string, { card: Card; version: number }>();
16
17const now = () => new Date().toISOString();
18const makeId = () => `CARD-${String(Date.now() % 10000).padStart(4, "0")}`;
19
20async function load($: any): Promise<Card | undefined> {
21 if (activeCard) return activeCard;
22 const value = await $.store.get(STORE_KEY);
23 if (value && typeof value === "object") activeCard = value as Card;
24 return activeCard;
25}
26async function save($: any, card: Card) {
27 activeCard = { ...card, updatedAt: now() };
28 await $.store.set(STORE_KEY, activeCard);
29 $.ui.invalidate("ui.render");
30 updateStatus($, activeCard);
31}
32function updateStatus($: any, card?: Card) {
33 if (!card) return $.ui.status(undefined);
34 const done = card.tasks.filter(t => t.status === "done").length;
35 const total = card.tasks.length;
36 const pct = progress(card);
37 $.ui.status(`${card.id} • ${card.stage.toUpperCase()} • ${pct}% • ${done}/${total || 0}`);
38}
39function progress(card: Card) {
40 if (!card.tasks.length) return 0;
41 const total = card.tasks.reduce((n,t) => n + (t.weight ?? 1), 0);
42 const done = card.tasks.reduce((n,t) => n + (t.status === "done" ? (t.weight ?? 1) : t.status === "running" ? (t.weight ?? 1) * .5 : 0), 0);
43 return Math.min(100, Math.round(done / total * 100));
44}
45function stageGlyph(card: Card, stage: Stage) {
46 const order: Stage[] = ["requirements","plan","build","review","ship"];
47 const current = order.indexOf(card.stage), pos = order.indexOf(stage);
48 return pos < current ? "✓" : pos === current ? "●" : "○";
49}
50function taskGlyph(s: TaskStatus) { return s === "done" ? "✓" : s === "running" ? "●" : s === "blocked" ? "⚠" : "□"; }
51
52// agent.spawn resolves at start; only its matching turn.complete supplies the plan.
53async function plan($: EngineInterface, card: Card, changes?: string): Promise<string> {
54 try {
55 const started = await $.agent.spawn({
56 subagentType: "general-purpose",
57 description: "Analyse repository and plan Agent Card",
58 prompt: `Agent Card requirement: ${card.requirement}
59${changes ? `Revise the existing plan ${JSON.stringify(card.tasks)} with: ${changes}` : ""}
60Inspect the actual repository using read/search tools before planning. Discover relevant installed skills. Do not implement or modify files. Do not use /plan or enter built-in plan mode.
61Return ONLY a JSON object with title (string), analysis (string describing repository findings with actual file paths), and tasks (nonempty array of {id,title,group,weight?,worker?}). Make tasks dependency-aware and order prerequisites first. Assign worker A/B only for independent file write sets; otherwise omit worker. Do not call set_plan; the plugin consumes your final JSON report.`
62 });
63 if (started.deny || !started.agentId) throw new Error(started.deny ?? "Planner did not start");
64 planners.set(started.agentId, { card, version: card.planVersion });
65 return `${card.id}: Analysing requirement and repository…`;
66 } catch (error) {
67 card.message = `Planning failed: ${String(error)}`;
68 if (activeCard === card || activeCard?.id === card.id) await save($, card);
69 return card.message;
70 }
71}
72
73export const register: Register = (on) => {
74 on("session.start", async ($, e, next) => {
75 await $.command.register({ name: "requirement", description: "Capture a requirement and create an implementation plan", argumentHint: "<requirement>" });
76 await $.command.register({ name: "plan-open", description: "Open the current implementation plan" });
77 await $.command.register({ name: "plan-updates", description: "Update the current implementation plan", argumentHint: "<changes>" });
78 await $.command.register({ name: "build", description: "Start dependency-aware parallel development" });
79 await $.command.register({ name: "card", description: "Open the live Agent Card pane", immediate: true });
80 await $.command.register({ name: "ship", description: "Review, commit, push, open a PR, and watch CI" });
81
82 await $.tool.register({
83 name: "set_plan",
84 description: "Set the Agent Card implementation plan after repository analysis. Use this when Agent Card asks you to plan. Tasks must be dependency-aware and split into two parallel feature streams when safely possible.",
85 inputSchema: { type:"object", required:["title","tasks"], properties:{
86 title:{type:"string"},
87 tasks:{type:"array", items:{type:"object", required:["id","title","group"], properties:{ id:{type:"string"}, title:{type:"string"}, group:{type:"string"}, weight:{type:"number"}, worker:{type:"string",enum:["A","B"]} }}}
88 }}
89 });
90 await $.tool.register({
91 name: "task_update",
92 description: "Update one Agent Card task as work progresses. Call when starting, completing, or blocking a planned task.",
93 inputSchema: { type:"object", required:["id","status"], properties:{ id:{type:"string"}, status:{type:"string",enum:["pending","running","done","blocked"]}, message:{type:"string"} } }
94 });
95 await $.tool.register({
96 name: "stage_update",
97 description: "Update Agent Card lifecycle stage. Valid stages are plan, build, review, ship.",
98 inputSchema: { type:"object", required:["stage"], properties:{ stage:{type:"string",enum:["plan","build","review","ship"]}, message:{type:"string"} } }
99 });
100
101 await $.agent.register({
102 name: "builder",
103 description: "Implement the saved Agent Card plan and report live task progress",
104 permissionMode: "default",
105 disallowedTools: ["EnterPlanMode", "ExitPlanMode"],
106 prompt: "You implement an approved Agent Card plan. Inspect the repository and supplied tasks, implement only the requirement, verify the result, and update Agent Card tasks through its tools. Do not enter built-in plan mode. Do not return while delegated work is still running. If existing files already satisfy a task, verify them before marking it done. If permission or verification blocks work, mark the affected task blocked and explain why. Never mark unverified work done."
107 });
108
109 const card = await load($); updateStatus($, card);
110 return next(e);
111 });
112
113 on("turn.complete", async ($, e, next) => {
114 const build = e.agentId ? builders.get(e.agentId) : undefined;
115 if (build) {
116 builders.delete(e.agentId!);
117 const card = await load($);
118 if (!card || card.id !== build.cardId || card.planVersion !== build.version) return next(e);
119 const complete = e.reason === "answer" && card.tasks.every(t => t.status === "done");
120 if (complete) {
121 card.stage = "review"; card.message = "Build complete; ready for review";
122 } else {
123 for (const task of card.tasks) if (task.status !== "done") task.status = "blocked";
124 card.message = card.tasks.find(t => t.status === "blocked" && t.message)?.message ?? `Build stopped (${e.reason}); unfinished tasks need attention`;
125 }
126 await save($, card);
127 await next(e);
128 return { text: `${card.message}\n${e.answer}` };
129 }
130 const pending = e.agentId ? planners.get(e.agentId) : undefined;
131 if (!pending) return next(e);
132 planners.delete(e.agentId!);
133 const { card, version } = pending;
134 let text: string;
135 try {
136 if (activeCard?.id !== card.id || activeCard.planVersion !== version) {
137 return next(e);
138 }
139 if (e.reason !== "answer") throw new Error(`Planner ended: ${e.reason}`);
140 const report = JSON.parse(e.answer.trim().replace(/^```(?:json)?\s*|\s*```$/g, ""));
141 if (typeof report.title !== "string" || typeof report.analysis !== "string" || !report.analysis.trim() || !Array.isArray(report.tasks) || !report.tasks.length) throw new Error("Planner returned no repository analysis or tasks");
142 const ids = new Set<string>();
143 card.tasks = report.tasks.map((t: any) => {
144 if (![t.id, t.title, t.group].every(v => typeof v === "string" && v.trim()) || ids.has(t.id)) throw new Error("Planner returned invalid or duplicate tasks");
145 ids.add(t.id);
146 return { id:t.id, title:t.title, group:t.group, status:"pending" as const, weight:typeof t.weight === "number" && t.weight > 0 ? t.weight : 1, worker:t.worker === "A" || t.worker === "B" ? t.worker : undefined };
147 });
148 card.title = report.title; card.stage = "plan"; card.message = "Plan ready";
149 await save($, card);
150 text = `${card.id}: Plan v${card.planVersion} ready with ${card.tasks.length} tasks.\n${report.analysis}\n${card.tasks.map(t => `${t.id}: ${t.title}`).join("\n")}`;
151 } catch (error) {
152 card.message = `Planning failed: ${String(error)}`;
153 await save($, card);
154 text = card.message;
155 }
156 await next(e);
157 return { text };
158 });
159
160 on("command.run", { command: "card" }, async ($) => {
161 const card = await load($);
162 if (!card) return { text: "No active card. Start with /requirement <your requirement>." };
163 const opened = await $.ui.open({ id: PANE_ID, title: card.id, focus: true, columns: 54 });
164 return opened.isPlaced === true ? {} : { text: `Card opened but is waiting for pane space: ${opened.reason}` };
165 });
166
167 on("command.run", { command: "requirement" }, async ($, e) => {
168 const requirement = e.args.trim();
169 if (!requirement) {
170 await $.prompt.fill({ text: "/requirement ", mode: "replace" });
171 return { text: "Type the requirement after /requirement and submit it." };
172 }
173 const card: Card = { id: makeId(), title: requirement.split(/[.!?\n]/)[0].slice(0,72), requirement, stage:"requirements", planVersion:1, tasks:[], updatedAt:now(), message:"Analysing requirement and repository" };
174 await save($, card);
175 await $.ui.open({ id:PANE_ID, title:card.id, columns:54 });
176 return { text: await plan($, card) };
177 });
178
179 on("command.run", { command: "plan-open" }, async ($) => {
180 const card = await load($); if (!card) return { text:"No active card." };
181 await $.ui.open({ id:PANE_ID, title:card.id, focus:true, columns:54 });
182 return { text: card.tasks.length ? `Plan v${card.planVersion}: ${card.tasks.length} tasks. Card opened.` : "Plan is still being prepared." };
183 });
184
185 on("command.run", { command: "plan-updates" }, async ($, e) => {
186 const card = await load($); if (!card) return { text:"No active card." };
187 const changes = e.args.trim();
188 if (!changes) { await $.prompt.fill({ text:"/plan-updates ", mode:"replace" }); return { text:"Type the requested plan changes after /plan-updates." }; }
189 card.planVersion += 1; card.stage = "plan"; card.message = "Updating plan"; await save($, card);
190 return { text: await plan($, card, changes) };
191 });
192
193 on("command.run", { command: "build" }, async ($) => {
194 const card = await load($); if (!card) return { text:"No active card." }; if (!card.tasks.length) return { text:"Plan is not ready yet." };
195 if ([...builders.values()].some(b => b.cardId === card.id)) return { text:"Build is already running." };
196 const previousStage = card.stage;
197 card.stage="build"; card.message="Starting build agent"; await save($,card); await $.ui.open({id:PANE_ID,title:card.id,columns:54});
198 try {
199 const started = await $.agent.spawn({
200 subagentType:"agent-card:builder",
201 description:`Build ${card.id}`,
202 prompt:`Implement Agent Card ${card.id}, plan v${card.planVersion}.
203Requirement: ${card.requirement}
204Complete saved task list: ${JSON.stringify(card.tasks)}
205Follow the plan's dependencies and verify likely file write overlap before parallel work. Parallelise only independent tasks assigned worker A/B; otherwise work sequentially. Do not add optional scope without approval. Keep integration here and wait for all delegated work to finish.
206Call mcp__agent-card__task_update with each exact saved task id when starting, completing, or blocking it. Check existing implementation first: verify and complete tasks already satisfied. Run appropriate verification and review the combined diff. Do not commit, push, ship, or use built-in /plan. Return a concise report of implementation and checks; the plugin advances to review only when all saved tasks are done.`
207 });
208 if (started.deny || !started.agentId) throw new Error(started.deny ?? "Build agent did not start");
209 builders.set(started.agentId, {cardId:card.id, version:card.planVersion});
210 card.message="Build agent running"; await save($,card);
211 return {text:"Build agent started with the saved plan. Task progress will update live."};
212 } catch (error) {
213 card.stage=previousStage; card.message=`Build failed to start: ${String(error)}`; await save($,card);
214 return {text:card.message};
215 }
216 });
217
218 on("command.run", { command: "ship" }, async ($) => {
219 const card = await load($); if (!card) return { text:"No active card." };
220 card.stage="ship"; card.message="Shipping"; await save($,card); await $.ui.open({id:PANE_ID,title:card.id,columns:54});
221 void $.prompt.submit({ text:`Ship Agent Card ${card.id}. First perform a final requirement and diff review and run the relevant test/lint/typecheck gates. If any gate fails, fix it before shipping. Then create an appropriate commit without Co-Authored-By trailers, push the current feature branch, and open a pull request using the repository's normal GitHub workflow. Do not merge the PR. Report the PR and CI state, and call mcp__agent-card__stage_update with stage ship and a concise status.`, asUser:true });
222 return { text:"Ship workflow started: final gate → commit → push → PR. The PR will not be merged automatically." };
223 });
224
225 on("tool.call", { tool:"mcp__agent-card__set_plan" }, async ($, e:any) => {
226 const card = await load($); if (!card) return { result:JSON.stringify({ok:false,error:"No active card"}) };
227 card.title = String(e.title ?? card.title); card.stage="plan";
228 card.tasks = Array.isArray(e.tasks) ? e.tasks.map((t:any) => ({ id:String(t.id), title:String(t.title), group:String(t.group), status:"pending" as const, weight:typeof t.weight === "number" ? t.weight : 1, worker:t.worker === "A" || t.worker === "B" ? t.worker : undefined })) : [];
229 card.message="Plan ready"; await save($,card); return { result:JSON.stringify({ok:true,cardId:card.id,tasks:card.tasks.length}) };
230 });
231
232 on("tool.call", { tool:"mcp__agent-card__task_update" }, async ($, e:any) => {
233 const card = await load($); if (!card) return { result:JSON.stringify({ok:false,error:"No active card"}) };
234 const task = card.tasks.find(t => t.id === String(e.id)); if (!task) return { result:JSON.stringify({ok:false,error:`Unknown task ${e.id}`}) };
235 task.status=e.status; if (e.message) { task.message=String(e.message); card.message=task.message; } await save($,card); return { result:JSON.stringify({ok:true}) };
236 });
237
238 on("tool.call", { tool:"mcp__agent-card__stage_update" }, async ($, e:any) => {
239 const card = await load($); if (!card) return { result:JSON.stringify({ok:false,error:"No active card"}) };
240 card.stage=e.stage; if(e.message) card.message=String(e.message); await save($,card); return { result:JSON.stringify({ok:true}) };
241 });
242
243 on("ui.render", { component:"Pane", requestId:PANE_ID }, async ($, e:any) => {
244 const card = await load($);
245 const { Box, Text } = $.ui.resolve(e);
246
247 if (!card) {
248 return Box({
249 flexDirection:"column", paddingX:2, paddingY:1,
250 borderStyle:"round", borderColor:"subtle",
251 children:[
252 Text({bold:true, color:"claude", children:"AGENT CARD"}),
253 Text({dimColor:true, children:"No active card"}),
254 Text({dimColor:true, children:"Use /requirement <requirement> to begin."})
255 ]
256 });
257 }
258
259 const groups = [...new Set(card.tasks.map(t => t.group))];
260 const pct = progress(card);
261 const done = card.tasks.filter(t => t.status === "done").length;
262 const running = card.tasks.filter(t => t.status === "running");
263 const blocked = card.tasks.filter(t => t.status === "blocked").length;
264 const stages: Stage[] = ["requirements","plan","build","review","ship"];
265 const stageLabel: Record<Stage,string> = {requirements:"REQUIREMENT",plan:"PLAN",build:"BUILD",review:"REVIEW",ship:"SHIP"};
266 const stageColor = card.stage === "build" ? "warning" : card.stage === "review" ? "planMode" : card.stage === "ship" ? "claude" : "success";
267 const barWidth = Math.max(12, Math.min(30, Math.floor((e.props?.bodyColumns ?? 54) - 18)));
268 const filled = Math.round((pct / 100) * barWidth);
269 const blockedTask = card.tasks.find(t => t.status === "blocked");
270 const nextTask = blockedTask ?? running[0] ?? card.tasks.find(t => t.status === "pending");
271 const allDone = card.tasks.length > 0 && done === card.tasks.length;
272 const nextColor = blockedTask ? "error" : nextTask ? "claude" : allDone ? "success" : "subtle";
273 const nextLabel = blockedTask ? "BLOCKED ⚠" : nextTask ? "UP NEXT →" : allDone ? "READY ✓" : "PLANNING ●";
274
275 const stageNodes:any[] = [];
276 stages.forEach((s, i) => {
277 const order = stages.indexOf(card.stage), pos = stages.indexOf(s);
278 const complete = pos < order, current = pos === order;
279 stageNodes.push(Text({
280 bold: current || complete,
281 color: complete ? "success" : current ? stageColor : "subtle",
282 children: `${complete ? "✓" : current ? "●" : "○"} ${s === "requirements" ? "Req" : s[0].toUpperCase()+s.slice(1)}`
283 }));
284 if (i < stages.length - 1) stageNodes.push(Text({dimColor:true, children:" ─ "}));
285 });
286
287 const children:any[] = [
288 Box({justifyContent:"space-between", alignItems:"center", children:[
289 Text({bold:true, color:"subtle", children:card.id}),
290 Box({borderStyle:"round", borderColor:stageColor, paddingX:1, children:
291 Text({bold:true, color:stageColor, children:`● ${stageLabel[card.stage]}`})
292 })
293 ]}),
294 Text({bold:true, wrap:"truncate", children:card.title}),
295 Box({marginTop:1, flexDirection:"row", flexWrap:"wrap", children:stageNodes}),
296 Box({marginTop:1, borderStyle:"round", borderColor:"subtle", paddingX:1, paddingY:0, flexDirection:"column", children:[
297 Box({justifyContent:"space-between", children:[
298 Text({dimColor:true, children:"PROGRESS"}),
299 Text({bold:true, color:pct >= 100 ? "success" : stageColor, children:`${pct}%`})
300 ]}),
301 Text({color:pct >= 100 ? "success" : "claude", children:`${"█".repeat(filled)}${"░".repeat(Math.max(0,barWidth-filled))}`}),
302 Box({justifyContent:"space-between", children:[
303 Text({children:`TASKS ${done}/${card.tasks.length}`}),
304 Text({children:`PLAN v${card.planVersion}`}),
305 Text({color:blocked ? "error" : "success", children:blocked ? `BLOCKED ${blocked}` : "GATE Fresh"})
306 ]})
307 ]})
308 ];
309
310 for (const g of groups) {
311 const ts = card.tasks.filter(t => t.group === g);
312 const gd = ts.filter(t => t.status === "done").length;
313 const gr = ts.some(t => t.status === "running");
314 const gb = ts.some(t => t.status === "blocked");
315 children.push(Box({
316 marginTop:1, borderStyle:"round",
317 borderColor: gb ? "error" : gr ? "warning" : gd === ts.length && ts.length ? "success" : "subtle",
318 paddingX:1, flexDirection:"column",
319 children:[
320 Box({justifyContent:"space-between", children:[
321 Text({bold:true, color:gb?"error":gr?"warning":"text", children:`${gr ? "●" : gd === ts.length && ts.length ? "✓" : "▾"} ${g}`}),
322 Text({bold:true, color:gd === ts.length && ts.length ? "success" : "subtle", children:`${gd}/${ts.length}`})
323 ]}),
324 ...ts.map(t => Box({
325 key:`task-${t.id}`,
326 backgroundColor:t.status === "running" ? "suggestion" : undefined,
327 paddingX:t.status === "running" ? 1 : 0,
328 justifyContent:"space-between",
329 children:[
330 Text({
331 bold:t.status === "running",
332 color:t.status === "done" ? "success" : t.status === "blocked" ? "error" : t.status === "running" ? "warning" : "subtle",
333 wrap:"truncate",
334 children:`${taskGlyph(t.status)} ${t.id} ${t.title}`
335 }),
336 t.worker ? Text({bold:true, color:t.worker === "A" ? "claude" : "planMode", children:`[${t.worker}]`}) : Text({children:""})
337 ]
338 }))
339 ]
340 }));
341 }
342
343 if (card.stage === "build" && card.tasks.some(t => t.worker)) {
344 const workerNodes:any[] = [];
345 for (const worker of ["A","B"] as const) {
346 const wt = card.tasks.filter(t => t.worker === worker);
347 if (!wt.length) continue;
348 const wr = wt.find(t => t.status === "running");
349 const wd = wt.filter(t => t.status === "done").length;
350 workerNodes.push(Box({justifyContent:"space-between", children:[
351 Text({color:wr ? "warning" : wd === wt.length ? "success" : "subtle", children:`${wr ? "●" : wd === wt.length ? "✓" : "○"} Worker ${worker}`}),
352 Text({dimColor:true, children:`${wd}/${wt.length}`})
353 ]}));
354 workerNodes.push(Text({dimColor:!wr, color:wr?"warning":"subtle", wrap:"truncate", children:` ${wr ? "↳ "+wr.title : wd === wt.length ? "Complete" : "Waiting"}`}));
355 }
356 children.push(Box({marginTop:1, borderStyle:"round", borderColor:"subtle", paddingX:1, flexDirection:"column", children:[
357 Text({bold:true, color:"subtle", children:"WORKERS"}), ...workerNodes
358 ]}));
359 }
360
361 children.push(Box({
362 marginTop:1, borderStyle:"round", borderColor:nextColor, paddingX:1, flexDirection:"column",
363 children:[
364 Text({bold:true, color:nextColor, children:nextLabel}),
365 Text({bold:!!nextTask, children:nextTask ? `${nextTask.id} ${nextTask.title}` : (card.message ?? "Preparing the implementation plan")}),
366 ...(blockedTask ? [Text({color:"error", children:blockedTask.message ?? card.message ?? "This task needs attention before work can continue."})] : [])
367 ]
368 }));
369
370 if (card.message) children.push(Text({dimColor:true, wrap:"truncate", children:`${card.message} • ${new Date(card.updatedAt).toLocaleTimeString()}`}));
371
372 return Box({flexDirection:"column", paddingX:1, paddingY:1, children});
373 });
374};
375