Team mode for Claude Code: the foreman skill turns the main session into a lead that plans and decides, while scout, developer, auditor and worker agents do…

Plugin และ skill ส่วนตัวสำหรับ Claude Code repo นี้เป็น plugin marketplace ด้วย (aaapwn-skills)
| Plugin | ใช้เมื่อ |
|---|---|
crewsade | ทำงานเป็นทีม — /crewsade:foreman เปลี่ยน session หลักเป็นหัวหน้างาน แจกงานอ่าน เขียน และตรวจให้ agent 4 ตัว (แนะนำให้ติดตั้ง karpathy-guidelines และ scrutinize ด้วย ถ้าไม่มี agent จะใช้กฎสำรองในตัว) |
รายละเอียดการใช้งานอยู่ที่ plugins/README.md
ติดตั้งจาก GitHub:
claude plugin marketplace add aaapwn/skills
claude plugin install crewsade@aaapwn-skills
ระหว่างพัฒนา ให้โหลดจาก disk ตรงๆ แทน (แก้ไฟล์แล้วใช้ /reload-plugins ได้เลย):
claude --plugin-dir ./plugins/claude/crewsade
เช็คทุกครั้งหลังแก้:
claude plugin validate . && claude plugin validate ./plugins/claude/crewsade
Skill เดี่ยวที่ไม่ได้อยู่ใน plugin อยู่ที่ skills/<name>/SKILL.md
| Skill | ใช้เมื่อ |
|---|---|
gojira | เขียน Jira task ที่มีเป้าหมาย ขอบเขต และเกณฑ์ตรวจรับ ให้ dev หยิบไปทำได้และคนตรวจเช็คได้ทีละข้อ |
bulletin | สรุปงานที่ทำเสร็จแล้วสั้นๆ สำหรับ PR/MR description, รายงาน PM หรือ comment ใน Jira |
saykenya | พูดบอกด้วยเสียงภาษาไทย (say -v Kanya บน Mac) ทุกครั้งที่ทำงานเสร็จ ไม่ต้องคอยเฝ้าหน้าจอ |
สองตัวนี้ใช้คู่กันได้ตั้งแต่ต้นจนจบ task: gojira ตอนเปิดงาน → ทำงาน (เองหรือผ่าน crewsade) → bulletin ตอนส่งงาน
ติดตั้งด้วย vercel-labs/skills (ต้องมี Node ≥20) เครื่องมือจะให้เลือกว่าจะติดตั้ง skill ไหน:
npx skills@latest add aaapwn/skills
gojira — เขียน Jira taskเรียกได้ทั้งพิมพ์ /gojira หรือพูดว่า "สร้าง task", "เขียน ticket", "เขียน requirement"
ticket ที่ได้เขียนให้คนอ่าน 2 กลุ่มพร้อมกัน: dev ที่ต้องรู้ว่าจะสร้างอะไรและขอบอยู่ตรงไหน กับ คนตรวจ ที่ต้องตัดสินว่า "เสร็จหรือยัง" ได้ทีละข้อโดยไม่ต้องถามใคร
[<ฟีเจอร์>] <กริยา> <สิ่งที่ส่งมอบ> ไม่เกินราว 80 ตัวอักษรเป้าหมาย — งานนี้ทำให้เกิดอะไร และเพื่ออะไร (บอกสภาพตอนจบ ไม่ใช่ขั้นตอน)ขอบเขตและเกณฑ์ตรวจรับ — แบ่งเป็นหมวดมีเลขกำกับ (อ้างถึงได้แบบ "2.3") และปิดท้ายด้วยหมวดการทดสอบเสมอหมายเหตุ — ของเดิมที่ให้ใช้ต่อ, สิ่งที่อยู่นอกขอบเขตโดยตั้งใจ, ข้อจำกัดที่ยอมรับขั้นตอน: อ่านของที่มีอยู่ (Confluence, ticket เดิม, โค้ด — อ่านผ่าน Atlassian connector ถ้ามี) → ถามสิ่งที่ยังขาดในข้อความเดียว → ร่าง → ตรวจตัวเองว่าเป้าหมายทุกส่วนมีเกณฑ์รองรับ → ให้ดูแล้วแก้ → สร้าง ticket จริงเมื่อผู้ใช้สั่งเท่านั้น (ถ้าไม่มี connector จะให้เป็น ข้อความไปวางเอง)
ticket จาก gojira ใช้เป็นแหล่งข้อมูลให้ crewsade ได้ตรงๆ — เกณฑ์ตรวจรับที่ตอบได้ว่า ใช่หรือไม่ใช่ คือสิ่งที่ auditor ต้องการพอดี
bulletin — สรุปงานที่ทำเสร็จเรียกได้ทั้งพิมพ์ /bulletin หรือพูดว่า "สรุปงาน", "เขียน PR description", "รายงาน PM" หรือวางสรุปยาวๆ จาก AI มาให้ย่อ
ผลลัพธ์เป็นรูปแบบตายตัว อ่านจบได้ในไม่ถึง 30 วินาที อยู่ใน code block เดียวพร้อม copy:
Jira: <ticket URL>
What was done:
- <item>
What has been tested:
- <item>
Notes: ← ใส่เฉพาะเมื่อมีสิ่งที่ผู้อ่านต้องรู้หรือทำก่อน merge
- <item>
Header: token ว่าง, ยาวเกิน และใช้ลูกศรบอกผล เช่น เลือกสาขาซ้ำ -> ได้ BRANCH_CONFLICT ไม่ใช่ 500pr (default — ใช้ชื่อ package / endpoint ได้), pm (ไม่มีชื่อในโค้ด เล่าเป็นผลต่อผู้ใช้), jira (แบบ pm แต่เก็บ error code / endpoint ที่คนอาจค้นหา)หาเนื้อหาจาก: สิ่งที่ผู้ใช้ให้มาในแชท → diff กับ base branch → commit message → ไฟล์เทส → Jira key จากชื่อ branch ถ้าหา URL ของ ticket ไม่เจอจะถาม ไม่เดา ไม่สร้างหรือแก้ PR / MR / ticket เอง ให้แค่ข้อความ
hooks/register.tsx 588 lines1import type { Color, Engine, Register } from 'claude-code'
2
3import {
4 ICON,
5 COMPACT_INSTRUCTIONS,
6 bandModel,
7 followUp,
8 logRows,
9 memoryContext,
10 parseDescription,
11 parsePlan,
12 parseReport,
13 planEvents,
14 roleOf,
15 subTaskStats,
16 tasksModel,
17 type Crew,
18 type LogEntry,
19 type Plan,
20 type Role,
21 type Step,
22} from './logic'
23import { logCard } from './logcard'
24import { tasksSvg } from './taskscard'
25
26const PLAN = '.crewsade/plan.md'
27const LOG = '.crewsade/log.jsonl'
28const STANDING = '.crewsade/standing.md'
29const COMMAND = 'crewsade-band'
30const TASKS_PANE = 'crewsade-tasks'
31const LOG_PANE = 'crewsade-log'
32
33// Module state: reset when the mod reloads, rebuilt from plan.md and later events
34let plan: Plan | null = null
35// Crew agents this session has seen, by agent id
36const crew = new Map<string, Crew>()
37
38const hasRunning = () => [...crew.values()].some(c => c.isRunning)
39
40// The mission log, kept in .crewsade/log.jsonl so it outlives a reload or a restart
41let log: LogEntry[] = []
42// Each agent's runs, and the log entry its current run writes its result into
43const runs = new Map<string, number>()
44const openRun = new Map<string, string>()
45
46// Put the crewsade memory back into context on the next prompt: at the start of a
47// session, and after every compaction of the main conversation
48let isRestoreDue = true
49
50// Hidden by the person, until they show it again or start the foreman again
51let isHidden = false
52
53// Spinner frame, advanced only while an agent works
54let frame = 0
55const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
56
57// Theme keys, so the colours follow light and dark themes
58const ROLE_COLOR: Record<Role, Color> = {
59 scout: 'suggestion',
60 developer: 'claude',
61 auditor: 'warning',
62 worker: 'merged',
63}
64const BAR = 16
65const STAGE_COLOR: Record<string, Color> = { กำลังทำ: 'claude', กำลังตรวจ: 'warning', ติดอยู่: 'error' }
66
67const STEP_MARK: Record<Step, { glyph: string; color: Color }> = {
68 done: { glyph: '✓', color: 'success' },
69 current: { glyph: '▶', color: 'warning' },
70 blocked: { glyph: '⛔', color: 'error' },
71 todo: { glyph: '○', color: 'inactive' },
72}
73
74const ALERT = {
75 approval: { glyph: '⏸', color: 'warning' },
76 blocked: { glyph: '⛔', color: 'error' },
77 failed: { glyph: '✗', color: 'error' },
78 done: { glyph: '✓', color: 'success' },
79} as const
80
81// A label on a coloured ground, for a stage or an outcome
82function chip(Text: (props: Record<string, unknown>) => unknown, label: string, color: Color) {
83 return (
84 <Text backgroundColor={color} color="inverseText" bold>
85 {` ${label} `}
86 </Text>
87 )
88}
89
90// Re-reads plan.md; logs an approval or a finished mission, redraws when the plan changed
91async function refresh($: Engine) {
92 let next: Plan | null = null
93 try {
94 if (await $.fs.exists(PLAN)) next = parsePlan(await $.fs.read(PLAN))
95 } catch {
96 next = null
97 }
98 if (JSON.stringify(next) !== JSON.stringify(plan)) {
99 const events = planEvents(plan, next, await $.clock.now())
100 plan = next
101 if (events.length) {
102 log.push(...events)
103 await saveLog($)
104 }
105 $.ui.invalidate('ui.render')
106 }
107}
108
109async function readIfThere($: Engine, path: string): Promise<string | null> {
110 try {
111 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
112 } catch {
113 return null
114 }
115}
116
117async function loadLog($: Engine) {
118 try {
119 if (!(await $.fs.exists(LOG))) return
120 const text = await $.fs.read(LOG)
121 log = text
122 .split('\n')
123 .filter(line => line.trim())
124 .map(line => JSON.parse(line) as LogEntry)
125 } catch {
126 log = []
127 }
128}
129
130async function saveLog($: Engine) {
131 try {
132 await $.fs.write(LOG, log.map(entry => JSON.stringify(entry)).join('\n') + '\n')
133 } catch {
134 // The band keeps working from memory; the log is only lost on a reload
135 }
136}
137
138// A crew agent starts a job: from its dispatch, or from a SendMessage that gives it a new one
139async function startJob($: Engine, agentId: string, member: Crew) {
140 if (!plan) return
141 const run = (runs.get(agentId) ?? 0) + 1
142 runs.set(agentId, run)
143 const id = `${agentId}#${run}`
144 openRun.set(agentId, id)
145 log.push({
146 kind: 'job',
147 id,
148 at: await $.clock.now(),
149 mission: plan.title,
150 role: member.role,
151 job: member.verb,
152 subject: member.subject,
153 })
154 await saveLog($)
155}
156
157// The agent's report ends its run: its verdict or status, and an audit's FIX titles
158async function finishJob($: Engine, agentId: string, answer: string) {
159 const id = openRun.get(agentId)
160 const entry = log.find(one => one.kind === 'job' && one.id === id)
161 if (!entry || entry.kind !== 'job') return
162 const { result, fixes } = parseReport(answer)
163 if (result) entry.result = result
164 entry.fixes = fixes
165 entry.endedAt = await $.clock.now()
166 await saveLog($)
167 $.ui.invalidate('ui.render')
168}
169
170export const register: Register = on => {
171 on('session.start', async ($, e, next) => {
172 await $.command.register({
173 name: COMMAND,
174 description: 'Show or hide the crewsade status band',
175 argumentHint: 'show | hide | tasks | log',
176 immediate: true,
177 })
178 await loadLog($)
179 await refresh($)
180 // The plan changes on disk as the foreman works, and the elapsed time ticks
181 $.clock.every(2000, () => refresh($))
182 // The spinner and the elapsed time move while the crew works
183 $.clock.every(250, () => {
184 if (!hasRunning()) return
185 frame = (frame + 1) % SPIN.length
186 $.ui.invalidate('ui.render')
187 })
188 return next(e)
189 })
190
191 on('command.run', { command: COMMAND }, async ($, e) => {
192 const arg = e.args.trim().toLowerCase()
193 if (arg === 'tasks' || arg === 'task') {
194 await $.ui.open({ id: TASKS_PANE, title: 'Tasks' })
195 return { text: 'crewsade tasks opened' }
196 }
197 if (arg === 'log' || arg === 'logs') {
198 await $.ui.open({ id: LOG_PANE, title: 'Log' })
199 return { text: 'crewsade log opened' }
200 }
201 isHidden = arg === 'hide' ? true : arg === 'show' ? false : !isHidden
202 $.ui.invalidate('ui.render')
203 return { text: isHidden ? `crewsade band hidden — /${COMMAND} show to bring it back` : 'crewsade band shown' }
204 })
205
206 // A compaction loses what lived only in the conversation: tell the summariser what to
207 // keep, and put the files back on the next prompt
208 on('session.compact', async ($, e, next) => {
209 if (e.agentId) return next(e)
210 isRestoreDue = true
211 const instructions = [e.instructions, COMPACT_INSTRUCTIONS].filter(Boolean).join('\n')
212 return next({ ...e, instructions })
213 })
214
215 on('prompt.submit', async ($, e, next) => {
216 if (!isRestoreDue) return next(e)
217 isRestoreDue = false
218 const memory = memoryContext(await readIfThere($, STANDING), await readIfThere($, PLAN))
219 if (!memory) return next(e)
220 return next({ ...e, context: [...(e.context ?? []), memory] })
221 })
222
223 // Using crewsade again brings the band back, whatever was hidden before
224 on('skill.prompt', async ($, e, next) => {
225 if (e.skill === 'crewsade:foreman' && isHidden) {
226 isHidden = false
227 $.ui.invalidate('ui.render')
228 }
229 return next(e)
230 })
231
232 on('agent.spawn', async ($, e, next) => {
233 const result = await next(e)
234 const role = roleOf(e.subagentType)
235 if (role && result.agentId) {
236 const { verb, subject } = parseDescription(role, e.description)
237 const member: Crew = {
238 role,
239 name: e.name,
240 verb,
241 subject,
242 startedAt: await $.clock.now(),
243 isRunning: true,
244 }
245 crew.set(result.agentId, member)
246 await startJob($, result.agentId, member)
247 $.ui.invalidate('ui.render')
248 }
249 return result
250 })
251
252 on('tool.call', async ($, e, next) => {
253 const member = e.agentId ? crew.get(e.agentId) : undefined
254 if (member) {
255 // A finished agent that a SendMessage woke is working again
256 if (!member.isRunning) {
257 member.isRunning = true
258 member.startedAt = await $.clock.now()
259 $.ui.invalidate('ui.render')
260 }
261 return next(e)
262 }
263
264 // The foreman sends an agent back to work: its summary, if shaped like a
265 // description, is the agent's new job
266 if (!e.agentId && e.tool === 'SendMessage' && typeof e.to === 'string') {
267 const to = e.to
268 const found = [...crew.entries()].find(([id, c]) => id === to || c.name === to)
269 if (found) {
270 const [agentId, target] = found
271 const job = followUp(target, typeof e.summary === 'string' ? e.summary : undefined)
272 const isNewJob = job.verb !== target.verb || job.subject !== target.subject
273 Object.assign(target, job)
274 // A new job is a new line in the log; a plain follow-up continues the last one
275 if (isNewJob) await startJob($, agentId, target)
276 $.ui.invalidate('ui.render')
277 }
278 }
279
280 const result = await next(e)
281 // The foreman just wrote the plan: show it now, not at the next poll
282 const path = typeof e.file_path === 'string' ? e.file_path : ''
283 if (!e.agentId && path.endsWith(PLAN)) await refresh($)
284 return result
285 })
286
287 on('turn.complete', async ($, e, next) => {
288 const member = e.agentId ? crew.get(e.agentId) : undefined
289 if (member && e.agentId) {
290 member.isRunning = false
291 await finishJob($, e.agentId, e.answer)
292 $.ui.invalidate('ui.render')
293 } else if (!e.agentId) {
294 await refresh($)
295 }
296 return next(e)
297 })
298
299 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
300 if (e.props.hasSurvey || isHidden) return next(e)
301
302 const band = bandModel(plan, [...crew.values()], await $.clock.now())
303 if (!band) return next(e)
304
305 const { Box, Button, Text } = $.ui.resolve(e)
306 const hide = (
307 <Button
308 key="hide"
309 label="✕"
310 plain
311 dimColor
312 onPress={() => {
313 isHidden = true
314 $.ui.invalidate('ui.render')
315 $.ui.toast(`crewsade band hidden — /${COMMAND} show to bring it back`)
316 }}
317 />
318 )
319
320 // Every card has the same frame: a label with its summary on the right, a bold
321 // main line, and a quiet second line; equal widths keep the band symmetric
322 const card = (key: string, label: string, meta: unknown, main: unknown, sub: unknown) => (
323 <Box
324 key={key}
325 flexDirection="column"
326 flexGrow={1}
327 flexShrink={1}
328 width={0}
329 borderStyle="round"
330 borderColor="subtle"
331 paddingX={1}
332 >
333 <Box flexDirection="row" justifyContent="space-between" gap={1}>
334 <Text color="subtle">{label}</Text>
335 {meta}
336 </Box>
337 {main}
338 {sub}
339 </Box>
340 )
341
342 const filled = band.total ? Math.round((band.done / band.total) * BAR) : 0
343 const missionCard =
344 band.title === null
345 ? null
346 : card(
347 'mission',
348 'Mission',
349 <Box flexDirection="row" gap={1}>
350 <Text bold>
351 {band.done}/{band.total}
352 </Text>
353 <Button key="tasks" label="Tasks" plain onPress={() => $.ui.open({ id: TASKS_PANE, title: 'Tasks' })} />
354 <Button key="log" label="Log" plain onPress={() => $.ui.open({ id: LOG_PANE, title: 'Log' })} />
355 {hide}
356 </Box>,
357 <Text bold wrap="truncate-end">
358 {band.title}
359 </Text>,
360 <Box flexDirection="row">
361 <Text backgroundColor="success">{' '.repeat(filled)}</Text>
362 <Text backgroundColor="subtle">{' '.repeat(BAR - filled)}</Text>
363 </Box>,
364 )
365
366 const alert = band.alert ? ALERT[band.alert.kind] : null
367 const nowCard =
368 band.title === null
369 ? null
370 : card(
371 'now',
372 band.current?.isNext ? 'Next' : 'Current',
373 band.current && band.current.stage !== '' ? (
374 <Text bold color={STAGE_COLOR[band.current.stage] ?? 'text'}>
375 {band.current.stage}
376 </Text>
377 ) : null,
378 <Text bold wrap="truncate-end">
379 {band.current?.title ?? (band.alert ? band.alert.text : '—')}
380 </Text>,
381 alert && band.alert && band.current ? (
382 <Text color={alert.color} wrap="truncate-end">
383 {alert.glyph} {band.alert.text}
384 </Text>
385 ) : band.current && band.current.failed > 0 ? (
386 <Text color="error">ตรวจไม่ผ่านไปแล้ว {band.current.failed} รอบ</Text>
387 ) : (
388 <Text color="subtle">ยังไม่มีปัญหา</Text>
389 ),
390 )
391
392 const lead = band.crew[0]
393 const crewCard = lead
394 ? card(
395 'crew',
396 'Team',
397 band.title === null ? (
398 <Box flexDirection="row" gap={1}>
399 <Text color="subtle">{lead.elapsed}</Text>
400 {hide}
401 </Box>
402 ) : (
403 <Text color="subtle">{lead.elapsed}</Text>
404 ),
405 <Box flexDirection="row" gap={1}>
406 <Text color={ROLE_COLOR[lead.role]}>{SPIN[frame]}</Text>
407 <Text>{ICON[lead.role]}</Text>
408 <Text bold color={ROLE_COLOR[lead.role]}>
409 {lead.count > 1 ? `${lead.role} ${lead.count} ตัว` : lead.role}
410 </Text>
411 {band.crew.length > 1 && <Text color="subtle">และอีก {band.crew.length - 1} งาน</Text>}
412 </Box>,
413 // The job on a line of its own, so it shows long; the sub-task is already on Current
414 <Text wrap="truncate-end">{lead.count > 1 ? `${lead.job} · ${lead.subject}` : lead.job}</Text>,
415 )
416 : card(
417 'crew',
418 'Team',
419 null,
420 <Text color="subtle">ไม่มีใครทำงานอยู่</Text>,
421 <Text color="subtle"> </Text>,
422 )
423
424 return (
425 <Box flexDirection="row" gap={1}>
426 {missionCard}
427 {nowCard}
428 {crewCard}
429 </Box>
430 )
431 })
432
433 on('ui.render', { component: 'Pane', requestId: TASKS_PANE }, async ($, e) => {
434 const { Box, Text } = $.ui.resolve(e)
435 const tasks = tasksModel(plan, [...crew.values()], await $.clock.now())
436 if (!tasks) return <Text color="subtle">ยังไม่มี mission — เริ่มด้วย /crewsade:foreman</Text>
437
438 // The desktop draws it as one SVG image; it holds nothing that ticks, so it only
439 // changes when the plan or the crew does
440 if (e.surface === 'desktop') {
441 const { Svg } = $.ui.resolve(e)
442 const px = Math.max(240, Math.floor((e.props.bodyColumns ?? 40) * 7.6))
443 const stats = Object.fromEntries(tasks.rows.map(r => [r.title, subTaskStats(log, tasks.title, r.title)]))
444 const drawn = tasksSvg(tasks, px, stats)
445 return (
446 <Svg
447 source={drawn.source}
448 alt={`${tasks.title}: ${tasks.done}/${tasks.total} — ${tasks.rows.map(r => `${r.id}. ${r.title} (${r.step})`).join(', ')}`}
449 width={px}
450 height={drawn.height}
451 />
452 )
453 }
454
455 const width = Math.max(10, Math.min(24, (e.props.bodyColumns ?? 30) - 8))
456 const filled = tasks.total ? Math.round((tasks.done / tasks.total) * width) : 0
457 return (
458 <Box flexDirection="column" gap={1}>
459 <Box flexDirection="column">
460 <Text bold wrap="wrap">
461 {tasks.title}
462 </Text>
463 {tasks.doneWhen && (
464 <Text color="subtle" wrap="wrap">
465 {tasks.doneWhen}
466 </Text>
467 )}
468 </Box>
469 <Box flexDirection="row" gap={1}>
470 <Box flexDirection="row">
471 <Text backgroundColor="success">{' '.repeat(filled)}</Text>
472 <Text backgroundColor="subtle">{' '.repeat(width - filled)}</Text>
473 </Box>
474 <Text bold>
475 {tasks.done}/{tasks.total}
476 </Text>
477 </Box>
478 <Box flexDirection="column" gap={1}>
479 {tasks.rows.map(row =>
480 row.step === 'current' || row.step === 'blocked' ? (
481 <Box
482 key={`task-${row.id}`}
483 flexDirection="column"
484 borderStyle="round"
485 borderColor={row.step === 'blocked' ? 'error' : 'warning'}
486 paddingX={1}
487 >
488 <Text bold wrap="wrap">
489 {STEP_MARK[row.step].glyph} {row.id}. {row.title}
490 </Text>
491 <Box flexDirection="row" gap={1} flexWrap="wrap">
492 {row.stage !== '' && chip(Text, row.stage, STAGE_COLOR[row.stage] ?? 'subtle')}
493 {row.failed > 0 && chip(Text, `ไม่ผ่าน ${row.failed} รอบ`, 'error')}
494 </Box>
495 {row.crew.map((member, i) => (
496 <Box key={`task-${row.id}-crew-${i}`} flexDirection="column" marginTop={1}>
497 <Box flexDirection="row" gap={1}>
498 <Text color={ROLE_COLOR[member.role]}>{SPIN[frame]}</Text>
499 <Text>{ICON[member.role]}</Text>
500 <Text bold color={ROLE_COLOR[member.role]}>
501 {member.role}
502 </Text>
503 <Text color="subtle">· {member.elapsed}</Text>
504 </Box>
505 <Box paddingLeft={2}>
506 <Text wrap="wrap">{member.job}</Text>
507 </Box>
508 </Box>
509 ))}
510 </Box>
511 ) : (
512 <Box key={`task-${row.id}`} flexDirection="row" gap={1} paddingX={1}>
513 <Text color={STEP_MARK[row.step].color}>{STEP_MARK[row.step].glyph}</Text>
514 <Text color={row.step === 'done' ? 'text' : 'subtle'} wrap="wrap">
515 {row.id}. {row.title}
516 </Text>
517 {row.failed > 0 && <Text color="subtle">· ไม่ผ่าน {row.failed} รอบ</Text>}
518 </Box>
519 ),
520 )}
521 </Box>
522 </Box>
523 )
524 })
525
526 on('ui.render', { component: 'Pane', requestId: LOG_PANE }, async ($, e) => {
527 const { Box, Text } = $.ui.resolve(e)
528 if (!plan) return <Text color="subtle">ยังไม่มี mission — เริ่มด้วย /crewsade:foreman</Text>
529 const rows = logRows(log, plan.title)
530 if (!rows.length) return <Text color="subtle">ยังไม่มีอะไรใน log ของ mission นี้</Text>
531
532 // The desktop draws Svg: real cards, pills and type sizes. One cell is about 7.6 px
533 // there; a little narrow is better than wide, which would scale the text down
534 if (e.surface === 'desktop') {
535 const { Svg } = $.ui.resolve(e)
536 const width = Math.max(240, Math.floor((e.props.bodyColumns ?? 40) * 7.6))
537 return (
538 <Box flexDirection="column" gap={1}>
539 {rows.map((row, i) => {
540 const card = logCard(row, width)
541 return (
542 <Box key={`log-${i}`}>
543 <Svg source={card.source} alt={`${row.who} ${row.time} ${row.text} ${row.outcome?.text ?? ''}`} width={width} height={card.height} />
544 </Box>
545 )
546 })}
547 </Box>
548 )
549 }
550
551 // A timeline: each entry carries its agent's colour down the left, and wraps
552 // instead of cutting off, so a narrow pane still shows everything
553 return (
554 <Box flexDirection="column" gap={1}>
555 {rows.map((row, i) => (
556 <Box
557 key={`log-${i}`}
558 flexDirection="column"
559 borderStyle="quote"
560 borderColor={row.role ? ROLE_COLOR[row.role] : 'subtle'}
561 paddingLeft={1}
562 >
563 <Box flexDirection="row" justifyContent="space-between" gap={1}>
564 <Box flexDirection="row" gap={1} flexShrink={1}>
565 <Text>{row.icon}</Text>
566 <Text bold color={row.role ? ROLE_COLOR[row.role] : 'text'}>
567 {row.who}
568 </Text>
569 <Text color="subtle">{row.time}</Text>
570 </Box>
571 {row.outcome && <Box flexShrink={0}>{chip(Text, row.outcome.text, row.outcome.tone)}</Box>}
572 </Box>
573 <Text wrap="wrap">{row.text}</Text>
574 {row.details.map((detail, j) => (
575 <Box key={`log-${i}-fix-${j}`} flexDirection="row" gap={1}>
576 <Text color="error">•</Text>
577 <Text color="error" wrap="wrap">
578 {detail}
579 </Text>
580 </Box>
581 ))}
582 </Box>
583 ))}
584 </Box>
585 )
586 })
587}
588hooks/logic.ts 421 lines1// Pure logic for the crewsade band: parsing plan.md, reading the job the foreman gave each
2// agent, and composing what the band shows. No `$` here, so tests can call it.
3//
4// The band shows only what it is told: the job from the Agent call's description or a
5// SendMessage summary. It never guesses what an agent is doing from its tool calls.
6
7export type Role = 'scout' | 'developer' | 'auditor' | 'worker'
8
9export type SubTask = { id: string; title: string; status: string; failed: number }
10
11export type Plan = {
12 title: string
13 status: string
14 doneWhen: string | null
15 subTasks: SubTask[]
16}
17
18export type Crew = {
19 role: Role
20 // The address SendMessage reaches it by, when the Agent call named it
21 name?: string
22 // The job the foreman gave it: the Agent call's description, or the latest
23 // SendMessage summary that follows the same `role · verb · subject` shape
24 verb: string
25 subject: string
26 startedAt: number
27 isRunning: boolean
28}
29
30export const ICON: Record<Role, string> = {
31 scout: '🔍',
32 developer: '🛠',
33 auditor: '🧪',
34 worker: '📝',
35}
36
37const DEFAULT_VERB: Record<Role, string> = {
38 scout: 'สำรวจ',
39 developer: 'เขียนโค้ด',
40 auditor: 'ตรวจงาน',
41 worker: 'ทำงาน',
42}
43
44const cells = (line: string) =>
45 line
46 .trim()
47 .replace(/^\|/, '')
48 .replace(/\|$/, '')
49 .split('|')
50 .map(c => c.trim())
51
52// Reads the shape references/plan-template.md defines: `# Mission:`, `Status:`, and the
53// sub-task table under `## Sub-tasks` with columns # | Sub-task | Agent | Status | Failed rounds.
54export function parsePlan(text: string): Plan | null {
55 const title = text.match(/^#\s*Mission:\s*(.+)$/m)?.[1]?.trim()
56 if (!title) return null
57
58 const status = text.match(/^Status:\s*([\w-]+)/m)?.[1]?.trim() ?? 'planning'
59 const doneWhen = text.match(/^Done when:\s*(.+)$/m)?.[1]?.trim() ?? null
60
61 const subTasks: SubTask[] = []
62 const section = text.split(/^##\s+Sub-tasks\s*$/m)[1]
63 if (section) {
64 const lines = section.split('\n')
65 let header: string[] | null = null
66 for (const line of lines) {
67 if (/^##\s/.test(line)) break
68 if (!line.trim().startsWith('|')) {
69 if (header) break
70 continue
71 }
72 const row = cells(line)
73 if (!header) {
74 header = row.map(h => h.toLowerCase())
75 continue
76 }
77 if (row.every(c => /^:?-+:?$/.test(c))) continue
78
79 const col = (name: string) => row[header!.findIndex(h => h.startsWith(name))] ?? ''
80 const title = col('sub-task')
81 if (!title || title.startsWith('<')) continue
82 subTasks.push({
83 id: col('#'),
84 title,
85 status: col('status').toLowerCase(),
86 failed: Number.parseInt(col('failed'), 10) || 0,
87 })
88 }
89 }
90
91 return { title, status: status.toLowerCase(), doneWhen: doneWhen && !doneWhen.startsWith('<') ? doneWhen : null, subTasks }
92}
93
94export function roleOf(subagentType: string): Role | null {
95 const m = subagentType.match(/^crewsade:(scout|developer|auditor|worker)$/)
96 return (m?.[1] as Role) ?? null
97}
98
99// The foreman writes descriptions as `role · verb · subject`. Anything else still shows:
100// the role's default verb, with the whole description as the subject.
101export function parseDescription(role: Role, description: string): { verb: string; subject: string } {
102 const parts = description.split('·').map(p => p.trim()).filter(Boolean)
103 if (parts.length >= 3) return { verb: parts[1], subject: parts.slice(2).join(' · ') }
104 if (parts.length === 2) return { verb: parts[0] === role ? DEFAULT_VERB[role] : parts[0], subject: parts[1] }
105 return { verb: DEFAULT_VERB[role], subject: description.trim() }
106}
107
108// A SendMessage summary in the `role · verb · subject` shape gives the agent a new job
109// (a developer sent back to fix audit findings); any other summary leaves the job as it was.
110export function followUp(member: Crew, summary: string | undefined): Pick<Crew, 'verb' | 'subject'> {
111 const parts = (summary ?? '').split('·').map(p => p.trim()).filter(Boolean)
112 // Only a summary that names this agent's own role is a job for it
113 if (parts.length < 3 || parts[0] !== member.role) {
114 return { verb: member.verb, subject: member.subject }
115 }
116 return { verb: parts[1], subject: parts.slice(2).join(' · ') }
117}
118
119export function elapsed(ms: number): string {
120 const s = Math.max(0, Math.floor(ms / 1000))
121 if (s < 60) return `${s}s`
122 const m = Math.floor(s / 60)
123 return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
124}
125
126export type Step = 'done' | 'current' | 'blocked' | 'todo'
127
128export type CrewRow = { role: Role; count: number; job: string; subject: string; elapsed: string; startedAt: number }
129
130export type Alert = { kind: 'approval' | 'blocked' | 'failed' | 'done'; text: string }
131
132// The sub-task the mission is on now, or the next one to start, in words
133export type Current = { title: string; isNext: boolean; stage: string; failed: number }
134
135export type Band = {
136 title: string | null
137 steps: Step[]
138 done: number
139 total: number
140 current: Current | null
141 crew: CrewRow[]
142 alert: Alert | null
143}
144
145const STAGE: Record<string, string> = {
146 'in-progress': 'กำลังทำ',
147 auditing: 'กำลังตรวจ',
148 blocked: 'ติดอยู่',
149}
150
151function crewRows(running: Crew[], now: number): CrewRow[] {
152 // Parallel scouts on one job read as one row; agents doing different jobs keep their own
153 const isOneJob = running.every(c => c.role === running[0]?.role && c.verb === running[0]?.verb)
154 if (running.length > 1 && isOneJob) {
155 const first = running.reduce((a, b) => (a.startedAt <= b.startedAt ? a : b))
156 return [
157 {
158 role: first.role,
159 count: running.length,
160 job: first.verb,
161 subject: running.map(c => c.subject).join(', '),
162 elapsed: elapsed(now - first.startedAt),
163 startedAt: first.startedAt,
164 },
165 ]
166 }
167 return running.map(c => ({
168 role: c.role,
169 count: 1,
170 job: c.verb,
171 subject: c.subject,
172 elapsed: elapsed(now - c.startedAt),
173 startedAt: c.startedAt,
174 }))
175}
176
177// What the band shows, or null to show nothing.
178export function bandModel(plan: Plan | null, crew: Crew[], now: number): Band | null {
179 const running = crew.filter(c => c.isRunning)
180 const rows = crewRows(running, now)
181
182 if (!plan || plan.status === 'aborted') {
183 if (!rows.length) return null
184 return { title: null, steps: [], done: 0, total: 0, current: null, crew: rows, alert: null }
185 }
186
187 const steps: Step[] = plan.subTasks.map(t =>
188 t.status === 'done' ? 'done' : t.status === 'blocked' ? 'blocked' : t.status === 'todo' ? 'todo' : 'current',
189 )
190 const total = steps.length
191 const done = steps.filter(s => s === 'done').length
192 const active = plan.subTasks.find(t => t.status !== 'done' && t.status !== 'todo')
193 const upcoming = plan.subTasks.find(t => t.status === 'todo')
194 let current: Current | null = null
195 if (active) current = { title: active.title, isNext: false, stage: STAGE[active.status] ?? active.status, failed: active.failed }
196 else if (upcoming && plan.status !== 'planning') current = { title: upcoming.title, isNext: true, stage: '', failed: 0 }
197
198 let alert: Alert | null = null
199 if (plan.status === 'done') alert = { kind: 'done', text: 'mission เสร็จแล้ว' }
200 else if (steps.includes('blocked')) alert = { kind: 'blocked', text: 'ติดอยู่ — รอคุณตัดสิน' }
201 else if (plan.status === 'planning' && total > 0 && !rows.length)
202 alert = { kind: 'approval', text: `รอคุณอนุมัติแผน — ${total} sub-task` }
203
204 return { title: plan.title, steps, done, total, current, crew: rows, alert }
205}
206
207// ---- Tasks pane ----
208
209export type TaskRow = {
210 id: string
211 title: string
212 step: Step
213 // The stage in words for the sub-task in hand ('' for done and todo)
214 stage: string
215 failed: number
216 // Agents working now, shown on the sub-task in hand
217 crew: CrewRow[]
218}
219
220export type Tasks = { title: string; doneWhen: string | null; done: number; total: number; rows: TaskRow[] }
221
222export function tasksModel(plan: Plan | null, crew: Crew[], now: number): Tasks | null {
223 if (!plan) return null
224 const rows = crewRows(crew.filter(c => c.isRunning), now)
225 const active = plan.subTasks.find(t => t.status !== 'done' && t.status !== 'todo')
226 return {
227 title: plan.title,
228 doneWhen: plan.doneWhen,
229 done: plan.subTasks.filter(t => t.status === 'done').length,
230 total: plan.subTasks.length,
231 rows: plan.subTasks.map(t => ({
232 id: t.id,
233 title: t.title,
234 step: t.status === 'done' ? 'done' : t.status === 'blocked' ? 'blocked' : t.status === 'todo' ? 'todo' : 'current',
235 stage: t === active ? (STAGE[t.status] ?? t.status) : '',
236 failed: t.failed,
237 crew: t === active ? rows : [],
238 })),
239 }
240}
241
242// ---- Log ----
243//
244// Every line comes from something the crew or the foreman stated: a dispatch description,
245// a SendMessage summary, the VERDICT / STATUS line of a report, or plan.md's Status.
246
247export type LogEntry =
248 | {
249 kind: 'job'
250 id: string // agent id and run, so a finish lands on the run it ends
251 at: number
252 mission: string
253 role: Role
254 job: string
255 subject: string
256 result?: string // VERDICT for an auditor, STATUS for the rest
257 fixes?: string[]
258 endedAt?: number
259 }
260 | { kind: 'approved' | 'done'; at: number; mission: string; count: number }
261
262// The verdict or status line of a crew report, and the short titles of an audit's FIX items.
263// A FIX item is `- <title>: <detail>`; one with no short title shows its first words.
264export function parseReport(answer: string): { result: string | null; fixes: string[] } {
265 const result = answer.match(/^\s*(?:VERDICT|STATUS):\s*([A-Za-z-]+)/m)?.[1]?.toUpperCase() ?? null
266 const fixes: string[] = []
267 const add = (raw: string) => {
268 const item = raw.replace(/`/g, '').trim()
269 if (!item || /^none\b/i.test(item)) return
270 const colon = item.indexOf(': ')
271 if (colon > 0 && colon <= 60) fixes.push(item.slice(0, colon))
272 else {
273 const words = item.split(/\s+/)
274 fixes.push(words.length > 8 ? `${words.slice(0, 8).join(' ')}…` : item)
275 }
276 }
277 const section = answer.split(/^\s*FIX:/m)[1]
278 if (section) {
279 const [first, ...rest] = section.split('\n')
280 add(first ?? '')
281 // Items at the first item's indent are fixes; deeper ones are that fix's detail
282 let indent: number | null = null
283 for (const line of rest) {
284 // The next section of the report ends the list
285 if (/^\s*[A-Z][A-Z -]+:/.test(line)) break
286 const m = line.match(/^(\s*)(?:[-*]|\d+[.)])\s+(.+)$/)
287 if (!m) continue
288 indent ??= m[1].length
289 if (m[1].length <= indent) add(m[2])
290 }
291 }
292 return { result, fixes }
293}
294
295// What a change in plan.md adds to the log: the plan approved, the mission done.
296export function planEvents(prev: Plan | null, next: Plan | null, at: number): LogEntry[] {
297 if (!prev || !next || prev.title !== next.title) return []
298 const count = next.subTasks.length
299 const out: LogEntry[] = []
300 if (prev.status === 'planning' && next.status !== 'planning' && next.status !== 'aborted')
301 out.push({ kind: 'approved', at, mission: next.title, count })
302 if (prev.status !== 'done' && next.status === 'done') out.push({ kind: 'done', at, mission: next.title, count })
303 return out
304}
305
306export type LogRow = {
307 time: string
308 icon: string
309 who: string
310 role: Role | null
311 text: string
312 outcome: { text: string; tone: 'success' | 'error' | 'warning' | 'subtle' } | null
313 details: string[]
314}
315
316const OUTCOME: Record<string, LogRow['outcome']> = {
317 PASS: { text: '✓ ผ่าน', tone: 'success' },
318 FAIL: { text: '✗ ไม่ผ่าน', tone: 'error' },
319 'NEEDS-HUMAN': { text: '? รอคุณตรวจ', tone: 'warning' },
320 SOUND: { text: '✓ แผนโอเค', tone: 'success' },
321 REVISE: { text: '✗ ให้แก้แผน', tone: 'error' },
322 DONE: { text: 'เสร็จ', tone: 'subtle' },
323 PARTIAL: { text: 'ทำได้บางส่วน', tone: 'warning' },
324 'NEEDS-INPUT': { text: 'มีคำถาม', tone: 'warning' },
325}
326
327export const clock = (at: number) => {
328 const d = new Date(at)
329 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
330}
331
332export function logRows(entries: LogEntry[], mission: string): LogRow[] {
333 return entries
334 .filter(e => e.mission === mission)
335 .map(e => {
336 if (e.kind === 'approved')
337 return { time: clock(e.at), icon: '📋', who: 'แผน', role: null, text: `อนุมัติแล้ว · ${e.count} sub-task`, outcome: null, details: [] }
338 if (e.kind === 'done')
339 return { time: clock(e.at), icon: '🏁', who: 'mission', role: null, text: 'เสร็จแล้ว', outcome: null, details: [] }
340 return {
341 time: clock(e.at),
342 icon: ICON[e.role],
343 who: e.role,
344 role: e.role,
345 text: `${e.job} · ${e.subject}`,
346 outcome: e.result ? (OUTCOME[e.result] ?? { text: e.result, tone: 'subtle' }) : null,
347 details: e.fixes ?? [],
348 }
349 })
350}
351
352// ---- Per sub-task history, from the log ----
353
354export type SubTaskStats = { maker: Role | null; audits: number; minutes: number | null }
355
356// Who built a sub-task, how many audits it took, and how long from first dispatch to last
357// report. Jobs are matched to the sub-task by the subject the foreman wrote: its title.
358export function subTaskStats(entries: LogEntry[], mission: string, title: string): SubTaskStats {
359 const jobs = entries.filter(
360 (e): e is Extract<LogEntry, { kind: 'job' }> => e.kind === 'job' && e.mission === mission && e.subject === title,
361 )
362 if (!jobs.length) return { maker: null, audits: 0, minutes: null }
363 const maker = jobs.find(j => j.role === 'developer' || j.role === 'worker')?.role ?? null
364 const audits = jobs.filter(j => j.role === 'auditor').length
365 const start = Math.min(...jobs.map(j => j.at))
366 const end = Math.max(...jobs.map(j => j.endedAt ?? j.at))
367 return { maker, audits, minutes: Math.round((end - start) / 60_000) }
368}
369
370export function duration(minutes: number): string {
371 if (minutes < 1) return 'ไม่ถึง 1 นาที'
372 if (minutes < 60) return `${minutes} นาที`
373 return `${Math.floor(minutes / 60)} ชม. ${minutes % 60} นาที`
374}
375
376// ---- Memory after a compaction ----
377
378// The body of a `## <heading>` section, up to the next `## `, or null when it is absent
379// or still holds only the template's placeholders.
380export function sectionOf(text: string, heading: string): string | null {
381 const parts = text.split(new RegExp(`^##\\s+${heading}\\s*$`, 'm'))
382 if (parts.length < 2) return null
383 const body = parts[1].split(/^##\s/m)[0].trim()
384 const real = body
385 .split('\n')
386 .filter(line => line.trim() && !/<[^>]+>/.test(line))
387 .join('\n')
388 return real || null
389}
390
391// What goes back into the model's context after the conversation is summarised: the
392// user's standing instructions, and where the mission stands. Null when there is none.
393export function memoryContext(standing: string | null, planText: string | null): string | null {
394 const rules = standing
395 ?.split('\n')
396 .map(line => line.trim())
397 .filter(line => line && !line.startsWith('#'))
398 .join('\n')
399 const plan = planText ? parsePlan(planText) : null
400 const isActive = plan !== null && plan.status !== 'done' && plan.status !== 'aborted'
401 if (!rules && !isActive) return null
402
403 const out = ['crewsade memory, put back after the conversation was summarised. Treat it as current.']
404 if (rules) out.push('', 'Standing instructions from the user (.crewsade/standing.md) — follow them:', rules)
405 if (isActive && plan && planText) {
406 out.push('', `Mission: ${plan.title} (${plan.status})`)
407 if (plan.doneWhen) out.push(`Done when: ${plan.doneWhen}`)
408 const now = sectionOf(planText, 'Now')
409 if (now) out.push('', 'Now:', now)
410 const notes = sectionOf(planText, 'Notes')
411 if (notes) out.push('', 'Notes:', notes)
412 out.push('', 'Sub-tasks, AC and the decision log are in .crewsade/plan.md — read it before the next step.')
413 }
414 return out.join('\n')
415}
416
417// What the summariser is told to keep, so the summary itself carries the mission too
418export const COMPACT_INSTRUCTIONS =
419 'Keep the crewsade mission state: the goal, the sub-task in hand and what comes next, ' +
420 'every standing instruction the user gave (also in .crewsade/standing.md), and open questions.'
421hooks/logcard.ts 140 lines1// The Log pane's cards as SVG, for the desktop: rounded cards, pills and real type
2// sizes that Box and Text cannot draw. The desktop shows an Svg as an image, so the
3// text is laid out here: measured, wrapped, and placed line by line.
4
5import type { LogRow } from './logic'
6
7export const ROLE_HEX: Record<string, string> = {
8 scout: '#3b82f6',
9 developer: '#c96442',
10 auditor: '#b7791f',
11 worker: '#8b5cf6',
12}
13export const PLAN_HEX = '#6b7280'
14export const TONE_HEX: Record<string, string> = {
15 success: '#2f855a',
16 error: '#c53030',
17 warning: '#b7791f',
18 subtle: '#6b7280',
19}
20
21export const FONT = `-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans Thai', 'Leelawadee UI', sans-serif`
22
23// Characters that sit on the one before and take no width: Thai vowels and tone marks
24// above and below the line, and Latin combining accents
25const ZERO_WIDTH = /[̀-ͯัิ-ฺ็-๎]/
26const WIDE = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆\u{1f300}-\u{1faff}]/u
27
28// An estimate of how wide `text` draws at `size` px; close enough to wrap by
29export function textWidth(text: string, size: number): number {
30 let width = 0
31 for (const ch of text) {
32 if (ZERO_WIDTH.test(ch)) continue
33 if (WIDE.test(ch)) width += size
34 else if (ch === ' ') width += size * 0.28
35 else if (/[-]/.test(ch)) width += size * 0.6
36 else if (/[A-Z]/.test(ch)) width += size * 0.66
37 else if (/[ilj.,'’:;|!]/.test(ch)) width += size * 0.28
38 else width += size * 0.55
39 }
40 return width
41}
42
43// Lines of at most `max` px. Breaks at spaces where it can; Thai has no spaces, so
44// a run too long for a line breaks between characters. A mark never starts a line:
45// it has no width, so adding one can never be what overflows the line.
46export function wrap(text: string, max: number, size: number): string[] {
47 const lines: string[] = []
48 let line = ''
49 const push = () => {
50 if (line.trim()) lines.push(line.trimEnd())
51 line = ''
52 }
53 for (const word of text.split(/(?<= )/)) {
54 if (textWidth(line + word, size) <= max) {
55 line += word
56 continue
57 }
58 if (line) push()
59 if (textWidth(word, size) <= max) {
60 line = word.trimStart()
61 continue
62 }
63 // A run with no room on one line: split it by character
64 for (const ch of word) {
65 if (line && textWidth(line + ch, size) > max) push()
66 line += ch
67 }
68 }
69 push()
70 return lines.length ? lines : ['']
71}
72
73export const esc = (s: string) =>
74 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
75
76const PAD = 14
77const BODY = 13
78const DETAIL = 12.5
79const LINE = 19
80
81// One log entry as an SVG card `width` px wide, with the height it needs
82export function logCard(row: LogRow, width: number): { source: string; height: number } {
83 const accent = row.role ? ROLE_HEX[row.role] : PLAN_HEX
84 const inner = width - PAD * 2 - 4
85 const parts: string[] = []
86 let y = PAD + 14
87
88 // Header: icon, who, time; the outcome as a pill on the right
89 parts.push(`<text x="${PAD + 4}" y="${y}" class="who" fill="${accent}">${esc(`${row.icon} ${row.who}`)}</text>`)
90 const whoWidth = textWidth(`${row.icon} ${row.who}`, 13.5) + 10
91 parts.push(`<text x="${PAD + 4 + whoWidth}" y="${y}" class="muted">${esc(row.time)}</text>`)
92 if (row.outcome) {
93 const pillWidth = textWidth(row.outcome.text, 12) + 18
94 const x = width - PAD - pillWidth
95 parts.push(
96 `<rect x="${x}" y="${y - 14}" width="${pillWidth}" height="20" rx="10" fill="${TONE_HEX[row.outcome.tone]}"/>`,
97 `<text x="${x + pillWidth / 2}" y="${y}" class="pill" text-anchor="middle">${esc(row.outcome.text)}</text>`,
98 )
99 }
100
101 // Body: the job, then what it was on
102 y += 8
103 for (const line of wrap(row.text, inner, BODY)) {
104 y += LINE
105 parts.push(`<text x="${PAD + 4}" y="${y}" class="body">${esc(line)}</text>`)
106 }
107
108 // An audit's FIX titles, one bullet each
109 if (row.details.length) y += 6
110 for (const detail of row.details) {
111 wrap(detail, inner - 14, DETAIL).forEach((line, i) => {
112 y += LINE - 1
113 if (i === 0) parts.push(`<circle cx="${PAD + 8}" cy="${y - 4}" r="2.5" fill="${TONE_HEX.error}"/>`)
114 parts.push(`<text x="${PAD + 18}" y="${y}" class="fix">${esc(line)}</text>`)
115 })
116 }
117
118 const height = y + PAD
119 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
120<style>
121 .card { fill: #ffffff; stroke: #e7e5df; }
122 .who { font: 600 13.5px ${FONT}; }
123 .muted { font: 12px ${FONT}; fill: #8a877f; }
124 .body { font: 13px ${FONT}; fill: #2b2a27; }
125 .fix { font: ${DETAIL}px ${FONT}; fill: #b42f2f; }
126 .pill { font: 600 12px ${FONT}; fill: #ffffff; }
127 @media (prefers-color-scheme: dark) {
128 .card { fill: #2a2a28; stroke: #3a3936; }
129 .muted { fill: #9a978f; }
130 .body { fill: #ecebe7; }
131 .fix { fill: #f08a8a; }
132 }
133</style>
134<rect class="card" x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="12"/>
135<rect x="0.5" y="12" width="4" height="${height - 24}" rx="2" fill="${accent}"/>
136${parts.join('\n')}
137</svg>`
138 return { source, height }
139}
140hooks/taskscard.ts 215 lines1// The Tasks pane as one SVG for the desktop:
2// a header card (label, count, title, a bar with one segment per sub-task),
3// the goal in its own box, and the sub-tasks as a connected timeline, each with a
4// status pill on the right and a second line from the log (who, audits, time).
5
6import { FONT, ROLE_HEX, TONE_HEX, esc, textWidth, wrap } from './logcard'
7import { ICON, clock, duration, type Step, type SubTaskStats, type Tasks } from './logic'
8
9const STAGE_HEX: Record<string, string> = {
10 กำลังทำ: '#c96442',
11 กำลังตรวจ: '#b7791f',
12 ติดอยู่: '#c53030',
13}
14const STEP_HEX: Record<Step, string> = {
15 done: TONE_HEX.success,
16 current: '#d69e2e',
17 blocked: TONE_HEX.error,
18 todo: '',
19}
20
21const PAD = 4
22const NODE_X = PAD + 13 // centre of the timeline nodes
23const TEXT_X = PAD + 34 // where a step's text starts
24
25// A soft pill: tinted ground, coloured text; returns its markup and width
26function pill(x: number, y: number, label: string, color: string | null): { svg: string; width: number } {
27 const width = textWidth(label, 12) + 20
28 const svg = color
29 ? `<rect x="${x}" y="${y}" width="${width}" height="22" rx="11" fill="${color}" fill-opacity="0.14"/>` +
30 `<text x="${x + width / 2}" y="${y + 15}" class="pill" fill="${color}" text-anchor="middle">${esc(label)}</text>`
31 : `<rect x="${x + 0.5}" y="${y + 0.5}" width="${width - 1}" height="21" rx="10.5" class="pill-wait"/>` +
32 `<text x="${x + width / 2}" y="${y + 15}" class="pill wait" text-anchor="middle">${esc(label)}</text>`
33 return { svg, width }
34}
35
36// The whole pane `width` px wide, with the height it needs. `stats` holds each
37// sub-task's history from the log, by title.
38export function tasksSvg(tasks: Tasks, width: number, stats: Record<string, SubTaskStats> = {}): { source: string; height: number } {
39 const inner = width - PAD * 2
40 const parts: string[] = []
41
42 // ---- Header card ----
43 let y = PAD
44 const head: string[] = []
45 let hy = y + 24
46 head.push(`<text x="${PAD + 16}" y="${hy}" class="label">MISSION</text>`)
47 const count = `${tasks.done} / ${tasks.total} งาน`
48 head.push(`<text x="${width - PAD - 16}" y="${hy}" class="count" text-anchor="end">${esc(count)}</text>`)
49 for (const line of wrap(tasks.title, inner - 32, 17)) {
50 hy += 25
51 head.push(`<text x="${PAD + 16}" y="${hy}" class="title">${esc(line)}</text>`)
52 }
53 // One segment per sub-task, coloured by its state
54 hy += 16
55 const gap = 4
56 const n = Math.max(1, tasks.rows.length)
57 const seg = (inner - 32 - gap * (n - 1)) / n
58 tasks.rows.forEach((row, i) => {
59 const x = PAD + 16 + i * (seg + gap)
60 head.push(
61 row.step === 'todo'
62 ? `<rect x="${x}" y="${hy}" width="${seg}" height="6" rx="3" class="seg-empty"/>`
63 : `<rect x="${x}" y="${hy}" width="${seg}" height="6" rx="3" fill="${STEP_HEX[row.step]}"/>`,
64 )
65 })
66 hy += 6 + 16
67 parts.push(`<rect x="${PAD}" y="${y}" width="${inner}" height="${hy - y}" rx="14" class="head"/>`, ...head)
68 y = hy
69
70 // ---- Goal ----
71 if (tasks.doneWhen) {
72 y += 14
73 const goalTop = y
74 const lines = wrap(tasks.doneWhen, inner - 44, 13)
75 lines.forEach((line, i) => {
76 y += 19
77 parts.push(`<text x="${PAD + (i === 0 ? 16 : 37)}" y="${y}" class="goal">${esc(i === 0 ? `🎯 ${line}` : line)}</text>`)
78 })
79 parts.push(`<rect x="${PAD}" y="${goalTop + 3}" width="3" height="${y - goalTop + 2}" rx="1.5" class="goal-bar"/>`)
80 }
81
82 // ---- Timeline ----
83 y += 22
84 const nodes: number[] = []
85 for (const row of tasks.rows) {
86 const s = stats[row.title]
87 const isActive = row.step === 'current' || row.step === 'blocked'
88 const top = y
89 const label = isActive ? row.stage || 'กำลังทำ' : row.step === 'done' ? 'เสร็จ' : 'รอ'
90 const color = isActive ? (STAGE_HEX[row.stage] ?? STEP_HEX[row.step]) : row.step === 'done' ? TONE_HEX.success : null
91 const p = pill(0, 0, label, color)
92
93 // Title, wrapped to leave room for the pill
94 const titleLines = wrap(`${row.id}. ${row.title}`, inner - (TEXT_X - PAD) - p.width - 12, 13.5)
95 let ty = y + 16
96 titleLines.forEach((line, i) => {
97 if (i > 0) ty += 19
98 parts.push(`<text x="${TEXT_X}" y="${ty}" class="${row.step === 'todo' ? 'step-todo' : 'step'}">${esc(line)}</text>`)
99 })
100 parts.push(pill(width - PAD - p.width, y, label, color).svg)
101 nodes.push(y + 11)
102
103 // Second line(s)
104 let sy = ty
105 const sub = (text: string, cls = 'sub') => {
106 for (const line of wrap(text, inner - (TEXT_X - PAD), 12.5)) {
107 sy += 19
108 parts.push(`<text x="${TEXT_X}" y="${sy}" class="${cls}">${esc(line)}</text>`)
109 }
110 }
111 if (row.step === 'done') {
112 const bits = [
113 s?.maker ? `${ICON[s.maker]} ${s.maker}` : null,
114 s && s.audits > 0 ? `ตรวจ ${s.audits} รอบ` : null,
115 s && s.minutes !== null ? duration(s.minutes) : null,
116 ].filter(Boolean)
117 if (bits.length) sub(bits.join(' · '))
118 else if (row.failed > 0) sub(`ไม่ผ่าน ${row.failed} รอบ`)
119 } else if (row.step === 'todo') {
120 sub('ยังไม่เริ่ม', 'sub-faint')
121 } else {
122 if (row.failed > 0) sub(`ตรวจไม่ผ่านไปแล้ว ${row.failed} รอบ`, 'sub-error')
123 // The crew on it: a soft card per agent, with a pulsing dot
124 for (const member of row.crew) {
125 sy += 12
126 const cardTop = sy
127 const color2 = ROLE_HEX[member.role] ?? TONE_HEX.subtle
128 const who = `${ICON[member.role]} ${member.role}`
129 sy += 22
130 const job = wrap(member.job, inner - (TEXT_X - PAD) - 28, 13)
131 const card = [
132 `<circle cx="${TEXT_X + 14}" cy="${sy - 4.5}" r="4" fill="${color2}" class="pulse"/>`,
133 `<text x="${TEXT_X + 26}" y="${sy}" class="who" fill="${color2}">${esc(who)}</text>`,
134 // A start time, not a ticking count: the drawing then only changes when the work does
135 `<text x="${width - PAD - 12}" y="${sy}" class="sub" text-anchor="end">${esc(`เริ่ม ${clock(member.startedAt)}`)}</text>`,
136 ]
137 for (const line of job) {
138 sy += 19
139 card.push(`<text x="${TEXT_X + 26}" y="${sy}" class="body">${esc(line)}</text>`)
140 }
141 sy += 12
142 parts.push(`<rect x="${TEXT_X}" y="${cardTop}" width="${width - PAD - TEXT_X}" height="${sy - cardTop}" rx="10" class="crew"/>`, ...card)
143 }
144 }
145
146 // Node on the timeline
147 const cy = top + 11
148 if (row.step === 'done') {
149 parts.push(
150 `<circle cx="${NODE_X}" cy="${cy}" r="9" fill="${TONE_HEX.success}"/>`,
151 `<path d="M${NODE_X - 4} ${cy} l2.8 2.8 l5.2 -5.6" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`,
152 )
153 } else if (row.step === 'todo') {
154 parts.push(`<circle cx="${NODE_X}" cy="${cy}" r="8" class="node-todo"/>`)
155 } else {
156 const c = STEP_HEX[row.step]
157 parts.push(
158 `<circle cx="${NODE_X}" cy="${cy}" r="9" fill="${c}" fill-opacity="0.18"/>`,
159 `<circle cx="${NODE_X}" cy="${cy}" r="4.5" fill="${c}" class="pulse"/>`,
160 )
161 }
162 y = Math.max(sy, ty) + 26
163 }
164
165 // Lines joining the nodes, drawn under them
166 const links = nodes
167 .slice(1)
168 .map((to, i) => `<line x1="${NODE_X}" y1="${nodes[i] + 12}" x2="${NODE_X}" y2="${to - 12}" class="link"/>`)
169
170 const height = Math.ceil(y - 10)
171 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
172<style>
173 .head { fill: #f5f3ee; }
174 .label { font: 600 10.5px ${FONT}; letter-spacing: 1.2px; fill: #9a968c; }
175 .count { font: 600 12.5px ${FONT}; fill: #5f5c55; }
176 .title { font: 650 17px ${FONT}; fill: #1f1e1c; }
177 .seg-empty { fill: #e3e0d8; }
178 .goal { font: 13px ${FONT}; fill: #5f5c55; }
179 .goal-bar { fill: #d8d4ca; }
180 .step { font: 600 13.5px ${FONT}; fill: #1f1e1c; }
181 .step-todo { font: 600 13.5px ${FONT}; fill: #a29f97; }
182 .sub { font: 12.5px ${FONT}; fill: #8a877f; }
183 .sub-faint { font: 12.5px ${FONT}; fill: #b5b2aa; }
184 .sub-error { font: 12.5px ${FONT}; fill: #c53030; }
185 .body { font: 13px ${FONT}; fill: #2b2a27; }
186 .who { font: 600 13px ${FONT}; }
187 .pill { font: 600 12px ${FONT}; }
188 .pill.wait { fill: #9a968c; }
189 .pill-wait { fill: none; stroke: #d8d4ca; }
190 .crew { fill: #f8f6f2; stroke: #ebe8e1; }
191 .node-todo { fill: #ffffff; stroke: #cfcbc1; stroke-width: 1.8; }
192 .link { stroke: #e3e0d8; stroke-width: 2; stroke-linecap: round; }
193 .pulse { animation: pulse 1.2s ease-in-out infinite; }
194 @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
195 @media (prefers-color-scheme: dark) {
196 .head { fill: #2c2b28; }
197 .count, .goal { fill: #b9b6ae; }
198 .title, .step { fill: #f1f0ec; }
199 .seg-empty, .link { fill: #3d3c38; stroke: #3d3c38; }
200 .goal-bar { fill: #4a4844; }
201 .step-todo, .sub-faint { fill: #6f6c66; }
202 .sub { fill: #9a978f; }
203 .sub-error { fill: #f08a8a; }
204 .body { fill: #ecebe7; }
205 .pill-wait { stroke: #4a4844; }
206 .crew { fill: #262523; stroke: #383733; }
207 .node-todo { fill: #1f1e1c; stroke: #5a5853; }
208 }
209</style>
210${links.join('\n')}
211${parts.join('\n')}
212</svg>`
213 return { source, height }
214}
215