SLOPSHOPPER

crewsade

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…

newpanebandguardcommandtoast
v0.5.0no licenseupdated 2026-10-09aaapwn/skills/plugins/claude/crewsade
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crewsade
│ ┃ crewsade-tasks ✕ › fix the failing auth test and add an audit log call │ ┃ ยังไม่มี mission — เริ่มด้วย │ ┃ /crewsade:foreman ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /crewsade-band │ ⎿ crewsade: crewsade band hidden — /crewsade-band show to bring it │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · crewsade-tasks
ยังไม่มี mission — เริ่มด้วย /crewsade:foreman
Pane · crewsade-log
ยังไม่มี mission — เริ่มด้วย /crewsade:foreman
README

Skills

Plugin และ skill ส่วนตัวสำหรับ Claude Code repo นี้เป็น plugin marketplace ด้วย (aaapwn-skills)

Plugins

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

Skills

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 ที่ต้องรู้ว่าจะสร้างอะไรและขอบอยู่ตรงไหน กับ คนตรวจ ที่ต้องตัดสินว่า "เสร็จหรือยัง" ได้ทีละข้อโดยไม่ต้องถามใคร

  • Summary รูปแบบ [<ฟีเจอร์>] <กริยา> <สิ่งที่ส่งมอบ> ไม่เกินราว 80 ตัวอักษร
  • Description มี 3 ส่วน:
  • เป้าหมาย — งานนี้ทำให้เกิดอะไร และเพื่ออะไร (บอกสภาพตอนจบ ไม่ใช่ขั้นตอน)
  • ขอบเขตและเกณฑ์ตรวจรับ — แบ่งเป็นหมวดมีเลขกำกับ (อ้างถึงได้แบบ "2.3") และปิดท้ายด้วยหมวดการทดสอบเสมอ
  • หมายเหตุ — ของเดิมที่ให้ใช้ต่อ, สิ่งที่อยู่นอกขอบเขตโดยตั้งใจ, ข้อจำกัดที่ยอมรับ
  • เกณฑ์ตรวจรับแต่ละข้อต้องตอบได้ว่าใช่หรือไม่ใช่ — บอกสภาพที่มองเห็นได้ ไม่ใช่ขั้นตอน, ระบุชื่อ field / endpoint / error code ที่จะถูกตรวจ, ห้ามใช้คำกว้างๆ อย่าง "เร็ว" หรือ "ปลอดภัย" โดยไม่มีตัวเลขหรือกฎกำกับ, บอกว่าถ้าถูกปฏิเสธผู้เรียกจะได้อะไรกลับ, หนึ่งข้อหนึ่งเรื่อง

ขั้นตอน: อ่านของที่มีอยู่ (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>
  • บอกว่าทำอะไร ไม่บอกว่าทำยังไงหรือทำไม ข้อละบรรทัดเดียว หมวดละ 3–7 ข้อ
  • ไม่ใส่หลักฐาน ตัวเลขพิสูจน์ เหตุผลการออกแบบ หรือบั๊กที่เจอแล้วแก้ในงานเดียวกัน
  • หมวด tested จัดกลุ่มตามส่วน เช่น Header: token ว่าง, ยาวเกิน และใช้ลูกศรบอกผล เช่น เลือกสาขาซ้ำ -> ได้ BRANCH_CONFLICT ไม่ใช่ 500
  • ผู้อ่าน 3 แบบ: pr (default — ใช้ชื่อ package / endpoint ได้), pm (ไม่มีชื่อในโค้ด เล่าเป็นผลต่อผู้ใช้), jira (แบบ pm แต่เก็บ error code / endpoint ที่คนอาจค้นหา)

หาเนื้อหาจาก: สิ่งที่ผู้ใช้ให้มาในแชท → diff กับ base branch → commit message → ไฟล์เทส → Jira key จากชื่อ branch ถ้าหา URL ของ ticket ไม่เจอจะถาม ไม่เดา ไม่สร้างหรือแก้ PR / MR / ticket เอง ให้แค่ข้อความ

Source 4 files
hooks/register.tsx 588 lines
1import 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}
588
hooks/logic.ts 421 lines
1// 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.'
421
hooks/logcard.ts 140 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
140
hooks/taskscard.ts 215 lines
1// 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