projectstore-claude 0.29.2 → 0.29.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +1 -1
  2. package/bin/projectstore-claude.mjs +1 -1
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  5. package/node_modules/projectstore/README.md +44 -7
  6. package/node_modules/projectstore/commands/bar.md +30 -0
  7. package/node_modules/projectstore/commands/bind.md +16 -20
  8. package/node_modules/projectstore/commands/doctor.md +12 -7
  9. package/node_modules/projectstore/commands/scaffold.md +24 -16
  10. package/node_modules/projectstore/commands/status.md +1 -1
  11. package/node_modules/projectstore/commands/statusline.md +2 -2
  12. package/node_modules/projectstore/commands/story.md +15 -2
  13. package/node_modules/projectstore/docs/alongside.md +167 -0
  14. package/node_modules/projectstore/docs/extending.md +17 -3
  15. package/node_modules/projectstore/docs/getting-started.md +18 -9
  16. package/node_modules/projectstore/docs/harnesses.md +1 -1
  17. package/node_modules/projectstore/docs/how-it-works.md +12 -3
  18. package/node_modules/projectstore/harnesses/claude-code.json +23 -8
  19. package/node_modules/projectstore/harnesses/codex.json +16 -6
  20. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  21. package/node_modules/projectstore/hooks/band.tsx +622 -0
  22. package/node_modules/projectstore/hooks/hooks.json +1 -0
  23. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  24. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  25. package/node_modules/projectstore/hooks/session-start.mjs +56 -20
  26. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  27. package/node_modules/projectstore/package.json +12 -2
  28. package/node_modules/projectstore/scaffold/headings.json +8 -0
  29. package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
  30. package/node_modules/projectstore/scripts/band.mjs +207 -0
  31. package/node_modules/projectstore/scripts/binding.mjs +733 -62
  32. package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
  33. package/node_modules/projectstore/scripts/cli.mjs +1463 -267
  34. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  35. package/node_modules/projectstore/scripts/convergence.mjs +638 -0
  36. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  37. package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
  38. package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
  39. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  40. package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
  41. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  42. package/node_modules/projectstore/scripts/harness.mjs +283 -37
  43. package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
  44. package/node_modules/projectstore/scripts/kanban.mjs +6 -21
  45. package/node_modules/projectstore/scripts/lib.mjs +933 -199
  46. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  47. package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
  48. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  49. package/node_modules/projectstore/scripts/query.mjs +441 -72
  50. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  51. package/node_modules/projectstore/scripts/run-host.mjs +139 -0
  52. package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
  53. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  54. package/node_modules/projectstore/scripts/stages.mjs +84 -0
  55. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  56. package/node_modules/projectstore/scripts/statusline.mjs +55 -14
  57. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  58. package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
  59. package/node_modules/projectstore/scripts/term.mjs +1027 -52
  60. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  61. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  62. package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
  63. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  64. package/node_modules/projectstore/templates/de/strings.json +63 -1
  65. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  66. package/node_modules/projectstore/templates/en/strings.json +63 -1
  67. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  68. package/node_modules/projectstore/templates/es/strings.json +63 -1
  69. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  70. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  71. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  72. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  73. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  74. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  75. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  76. package/package.json +2 -2
@@ -0,0 +1,622 @@
1
+ // projectstore — band.tsx: the band above the prompt, a Claude Code hooks
2
+ // module (hooks.json → "modules").
3
+ //
4
+ // It shows this session's epic — or a pinned one, in a session with none of
5
+ // its own — how far that epic is, its goal, this session's story and the
6
+ // ProjectStore agent at work, and nothing while a question is open. It reads
7
+ // the binding, this session's pointer and the focused epic's files, writes
8
+ // nothing under the vault, and keeps a heartbeat in the session-state
9
+ // directory while it draws so the status line can give way to it.
10
+ //
11
+ // The module runs with no Node: every rule it draws by lives in the Node-free
12
+ // scripts/stages.mjs and scripts/band-shared.mjs, which the node tests reach
13
+ // directly. This file is the plumbing between those rules and `$`. What it
14
+ // remembers between events lives in one Band, made by `register`, so a reload
15
+ // starts clean and two loads never share a clock.
16
+ //
17
+ // Spec: the band above the prompt — rows, heartbeat, command and the status
18
+ // line's yield (contracts 2–10, 12, 14).
19
+
20
+ import { atom, read, update } from 'claude-code'
21
+ import type { EngineInterface, Register } from 'claude-code'
22
+
23
+ import { foldStages, parseFrontmatter } from '../scripts/stages.mjs'
24
+ import {
25
+ AGENT_PREFIX,
26
+ LINGER_MS,
27
+ countsText,
28
+ goalForms,
29
+ goalOf,
30
+ heartbeatFile,
31
+ heartbeatNext,
32
+ isEpicName,
33
+ leadAgent,
34
+ mergeWords,
35
+ modelLabel,
36
+ pctOf,
37
+ phraseOf,
38
+ planRows,
39
+ progressLayout,
40
+ segments,
41
+ stepOf,
42
+ storyFiles,
43
+ storyIsCurrent,
44
+ storyKeyOf,
45
+ storyLayout,
46
+ subjectLabel,
47
+ subjectPath,
48
+ totalOf,
49
+ } from '../scripts/band-shared.mjs'
50
+ import type { BandAgent, BandCounts, BandView } from './band-state'
51
+
52
+ // Our own files in a project, as lib.mjs layoutPaths() names them (a node
53
+ // test pins these to it).
54
+ const BINDING = '.projectstore/projectstore.json'
55
+ const SESSIONS = '.projectstore/state/sessions'
56
+ // The hint's command token, never translated (a node test pins it to the
57
+ // manifest's invocation form).
58
+ const HINT_COMMAND = '/projectstore:bar off'
59
+
60
+ const TICK_MS = 6_000
61
+ const DEADLINE_MS = 2_000
62
+ // A scan still unsettled after this long is abandoned: its result is ignored
63
+ // and the next tick starts a fresh one, so a read that never returns cannot
64
+ // freeze the band (or hide a `band off`) for the rest of the session.
65
+ const ABANDON_MS = 30_000
66
+ // An agent whose end the band never heard (killed, a lost event) leaves the
67
+ // row after this long.
68
+ const AGENT_MAX_MS = 30 * 60_000
69
+
70
+ // The terminal's bar: a half-tone mesh, quieter than solid blocks.
71
+ const GRID = '▒'
72
+ const GRID_DONE = '#73c27c'
73
+ const GRID_WORK = '#e0bf55'
74
+ const ROLE_C = '#d19a66'
75
+ // Desktop's bar: a grid of small cells.
76
+ const DONE_C = '#5cc46a'
77
+ const WORK_C = '#e3c04a'
78
+ const LEFT_C = '#3a3556'
79
+
80
+ const view = atom({ plugin: 'projectstore', key: 'band' } as const, null as BandView | null)
81
+ const crew = atom({ plugin: 'projectstore', key: 'crew' } as const, [] as BandAgent[])
82
+ const steps = atom({ plugin: 'projectstore', key: 'steps' } as const, {} as Record<string, string>)
83
+
84
+ type Json = Record<string, any>
85
+ type Rendered = 'drew' | 'survey' | 'nothing' | 'unknown'
86
+ // One story file as the scan read it: its key (storyKeyOf), title and status.
87
+ type StoryItem = { key: string; title: string | null; status: string | null }
88
+ type Stories = { counts: BandCounts; items: StoryItem[] }
89
+
90
+ // What one load of the module remembers between events.
91
+ type Band = {
92
+ lastRender: Rendered
93
+ wasDrawing: boolean
94
+ lastSid: string | null
95
+ ticking: boolean
96
+ gen: number
97
+ scan: { gen: number; startedAt: number } | null
98
+ shownKey: string
99
+ stories: Map<string, { sig: string } & Stories>
100
+ shipped: Map<string, Json>
101
+ place: { vault: string | null; kinds: string[] }
102
+ }
103
+
104
+ function newBand(): Band {
105
+ return {
106
+ lastRender: 'unknown',
107
+ wasDrawing: false,
108
+ lastSid: null,
109
+ ticking: false,
110
+ gen: 0,
111
+ scan: null,
112
+ shownKey: '',
113
+ stories: new Map(),
114
+ shipped: new Map(),
115
+ place: { vault: null, kinds: [] },
116
+ }
117
+ }
118
+
119
+ const UNREADABLE = Symbol('unreadable')
120
+
121
+ // A JSON file: its value, null when absent, UNREADABLE when it is there and
122
+ // cannot be read or parsed — a pointer that exists but does not parse is not
123
+ // "no epic", and the band must not fall through to a pin on it.
124
+ async function readJsonFile($: EngineInterface, path: string): Promise<Json | null | typeof UNREADABLE> {
125
+ let exists = false
126
+ try {
127
+ exists = await $.fs.exists(path)
128
+ } catch {
129
+ return UNREADABLE
130
+ }
131
+ if (!exists) return null
132
+ try {
133
+ const v = JSON.parse(String(await $.fs.read(path)))
134
+ return v && typeof v === 'object' ? v : UNREADABLE
135
+ } catch {
136
+ return UNREADABLE
137
+ }
138
+ }
139
+
140
+ async function readText($: EngineInterface, path: string): Promise<string | null> {
141
+ try {
142
+ return String(await $.fs.read(path))
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+
148
+ async function isDir($: EngineInterface, path: string): Promise<boolean> {
149
+ try {
150
+ return (await $.fs.stat(path, { resolve: true })).kind === 'dir'
151
+ } catch {
152
+ return false
153
+ }
154
+ }
155
+
156
+ // ── What the plugin ships, read once it reads ─────────────────────────
157
+
158
+ async function shippedJson($: EngineInterface, b: Band, rel: string): Promise<Json | null> {
159
+ const hit = b.shipped.get(rel)
160
+ if (hit) return hit
161
+ const v = await readJsonFile($, `${$.plugin.root}/${rel}`)
162
+ if (!v || v === UNREADABLE) return null
163
+ b.shipped.set(rel, v)
164
+ return v
165
+ }
166
+
167
+ async function wordsFor($: EngineInterface, b: Band, lang: string) {
168
+ const en = await shippedJson($, b, 'templates/en/strings.json')
169
+ const local = lang && lang !== 'en' ? await shippedJson($, b, `templates/${lang}/strings.json`) : null
170
+ return mergeWords(en, local) as Record<string, string> | null
171
+ }
172
+
173
+ async function versionOf($: EngineInterface, b: Band): Promise<string | null> {
174
+ const m = await shippedJson($, b, '.claude-plugin/plugin.json')
175
+ return m && typeof m.version === 'string' ? m.version : null
176
+ }
177
+
178
+ // The bound layout's epic folder and its kind folders (a subject's path starts
179
+ // with one of them).
180
+ async function layoutOf($: EngineInterface, b: Band, name: string) {
181
+ const layout = await shippedJson($, b, `scaffold/layouts/${name}.json`)
182
+ const folders: Json[] = Array.isArray(layout?.folders) ? layout!.folders : []
183
+ const epics = folders.find(f => f.kind === 'epic')?.path
184
+ return { epics: typeof epics === 'string' ? epics : null, kinds: folders.map(f => f.path).filter((p): p is string => typeof p === 'string') }
185
+ }
186
+
187
+ // ── The scan (contracts 2, 4–9, 14) ───────────────────────────────────
188
+
189
+ // The epic's stories by stage, and each one's key, title and status (the
190
+ // story row's, contract 8), re-read only when a listing changed. A story that
191
+ // cannot be read keeps the last good result (none yet: no progress row and no
192
+ // story row), never a partial fold — the band draws less, never wrong.
193
+ async function storiesOf($: EngineInterface, b: Band, epicDir: string): Promise<Stories | null> {
194
+ const seen: string[] = []
195
+ const fs = {
196
+ list: async (dir: string) => {
197
+ const entries = await $.fs.list(dir)
198
+ const out = []
199
+ for (const e of entries) {
200
+ let kind = e.kind
201
+ if (kind === 'other') {
202
+ // A symbolic link: the board follows it, so the band does too.
203
+ try { kind = (await $.fs.stat(`${dir}/${e.name}`, { resolve: true })).kind } catch { kind = 'other' }
204
+ }
205
+ seen.push(`${dir}/${e.name}:${kind}:${e.mtimeMs}`)
206
+ out.push({ ...e, kind })
207
+ }
208
+ return out
209
+ },
210
+ exists: async (path: string) => {
211
+ try {
212
+ const st = await $.fs.stat(path, { resolve: true })
213
+ seen.push(`${path}:${st.mtimeMs}`)
214
+ return st.kind === 'file'
215
+ } catch {
216
+ return false
217
+ }
218
+ },
219
+ }
220
+ const files: string[] = await storyFiles(fs, epicDir)
221
+ const sig = seen.sort().join('|')
222
+ const hit = b.stories.get(epicDir)
223
+ if (hit && hit.sig === sig) return hit
224
+ const statuses: unknown[] = []
225
+ const items: StoryItem[] = []
226
+ for (const f of files) {
227
+ const text = await readText($, f)
228
+ // An unreadable story (an evicted iCloud file): the last good result, and
229
+ // nothing cached, so the next scan tries again.
230
+ if (text === null) return hit ?? null
231
+ const data = parseFrontmatter(text).data as Json
232
+ statuses.push(data.status)
233
+ const key = storyKeyOf(f.slice(epicDir.length + 1))
234
+ if (key) {
235
+ items.push({
236
+ key,
237
+ title: typeof data.title === 'string' && data.title ? data.title : null,
238
+ status: data.status === null || data.status === undefined || data.status === '' ? null : String(data.status),
239
+ })
240
+ }
241
+ }
242
+ const next = { sig, counts: foldStages(statuses) as BandCounts, items }
243
+ b.stories.set(epicDir, next)
244
+ return next
245
+ }
246
+
247
+ // This session's story, from its file (contract 8): the item the pointer's
248
+ // key names, while it is work the session is moving. Never the pointer's own
249
+ // title or status — the pointer can be older than the file.
250
+ function storyOf(pointer: Json | null, stories: Stories | null) {
251
+ const key = pointer && typeof pointer.active_story === 'string' ? (pointer.active_story as string) : null
252
+ const item = key && stories ? stories.items.find(i => i.key === key) : null
253
+ if (!item || !storyIsCurrent(item.status)) return null
254
+ return { title: item.title || item.key, status: item.status }
255
+ }
256
+
257
+ async function scan($: EngineInterface, b: Band, root: string, sid: string | null): Promise<BandView | null> {
258
+ const binding = await readJsonFile($, `${root}/${BINDING}`)
259
+ if (!binding || binding === UNREADABLE) return null
260
+ const vault = typeof binding.vault_path === 'string' ? binding.vault_path : null
261
+ if (!vault) return null
262
+ const lay = await layoutOf($, b, typeof binding.layout === 'string' ? binding.layout : 'engineering')
263
+ b.place = { vault, kinds: lay.kinds }
264
+ const band: Json = binding.band && typeof binding.band === 'object' ? binding.band : {}
265
+ if (band.enabled === false || !lay.epics) return null
266
+ const lang = typeof binding.language === 'string' ? binding.language : 'en'
267
+ const words = await wordsFor($, b, lang)
268
+ if (!words) return null
269
+
270
+ // This session's epic when its folder exists, else the pin; never another
271
+ // session's. A pointer that cannot be read draws nothing.
272
+ const pointer = sid ? await readJsonFile($, `${root}/${SESSIONS}/${sid}.json`) : null
273
+ if (pointer === UNREADABLE) return null
274
+ const own = pointer && isEpicName(pointer.active_epic) ? (pointer.active_epic as string) : null
275
+ const pin = isEpicName(band.epic) ? (band.epic as string) : null
276
+ let focus: { id: string; why: 'this' | 'pinned' } | null = null
277
+ if (own && (await isDir($, `${vault}/${lay.epics}/${own}`))) focus = { id: own, why: 'this' }
278
+ else if (pin && (await isDir($, `${vault}/${lay.epics}/${pin}`))) focus = { id: pin, why: 'pinned' }
279
+ if (!focus) return null
280
+
281
+ // The focused epic's epic.md that cannot be read draws nothing: never the
282
+ // pin in its place.
283
+ const epicDir = `${vault}/${lay.epics}/${focus.id}`
284
+ const text = await readText($, `${epicDir}/epic.md`)
285
+ if (text === null) return null
286
+ const fm = parseFrontmatter(text).data as Json
287
+ const registry = await shippedJson($, b, 'scaffold/headings.json')
288
+ const stories = await storiesOf($, b, epicDir)
289
+ return {
290
+ id: typeof fm.id === 'string' && fm.id ? fm.id : focus.id,
291
+ title: typeof fm.title === 'string' && fm.title ? fm.title : focus.id,
292
+ goal: goalOf(text, goalForms(registry, lang)),
293
+ counts: stories ? stories.counts : null,
294
+ why: focus.why,
295
+ story: focus.why === 'this' ? storyOf(pointer as Json | null, stories) : null,
296
+ version: binding.statusline?.show_version === false ? null : await versionOf($, b),
297
+ hint: band.hint_seen !== true,
298
+ words,
299
+ }
300
+ }
301
+
302
+ // ── The clock: scan, heartbeat, prune (contract 10) ───────────────────
303
+
304
+ async function writeHeartbeat($: EngineInterface, root: string, sid: string | null, body: unknown): Promise<boolean> {
305
+ const name = heartbeatFile(sid)
306
+ if (!name) return false
307
+ try {
308
+ await $.fs.write(`${root}/${SESSIONS}/${name}`, JSON.stringify(body) + '\n')
309
+ return true
310
+ } catch {
311
+ // A heartbeat missed: the status line keeps its own line, and the next
312
+ // tick tries again.
313
+ return false
314
+ }
315
+ }
316
+
317
+ async function heartbeat($: EngineInterface, b: Band, root: string, sid: string | null, now: number) {
318
+ // A /clear gives the session a new id: the old one's heartbeat stops.
319
+ if (b.lastSid && b.lastSid !== sid && b.wasDrawing) {
320
+ if (await writeHeartbeat($, root, b.lastSid, { drawn: false, drawn_at: now })) b.wasDrawing = false
321
+ }
322
+ b.lastSid = sid
323
+ const body = heartbeatNext(b.lastRender, b.wasDrawing, now)
324
+ if (body && (await writeHeartbeat($, root, sid, body))) b.wasDrawing = body.drawn
325
+ }
326
+
327
+ async function prune($: EngineInterface, now: number) {
328
+ const list = await read($, crew)
329
+ const live = list.filter(a => (a.status === 'running' ? now - a.startedAt < AGENT_MAX_MS : now - (a.endedAt ?? now) < LINGER_MS))
330
+ if (live.length !== list.length) await update($, crew, () => live)
331
+ }
332
+
333
+ async function show($: EngineInterface, b: Band, next: BandView | null) {
334
+ const key = JSON.stringify(next)
335
+ if (key === b.shownKey) return
336
+ b.shownKey = key
337
+ await update($, view, () => next)
338
+ }
339
+
340
+ async function tick($: EngineInterface, b: Band) {
341
+ const now = await $.clock.now()
342
+ const sid = (await $.session.id()) || null
343
+ const root = await $.session.root()
344
+ await heartbeat($, b, root, sid, now)
345
+ await prune($, now)
346
+ // One scan in flight: a scan stuck on an evicted file is not joined by
347
+ // another every tick. One that settles late still shows what it read; one
348
+ // unsettled past ABANDON_MS is let go.
349
+ if (b.scan && now - b.scan.startedAt < ABANDON_MS) return
350
+ const gen = ++b.gen
351
+ b.scan = { gen, startedAt: now }
352
+ const current = () => b.scan?.gen === gen
353
+ const scanning = scan($, b, root, sid)
354
+ .then(next => (current() ? show($, b, next) : undefined))
355
+ .catch(() => undefined)
356
+ .finally(() => {
357
+ if (current()) b.scan = null
358
+ })
359
+ await Promise.race([scanning, $.clock.sleep(DEADLINE_MS)])
360
+ }
361
+
362
+ // A tick that fails (a host refusal, a session that ended) is a tick missed:
363
+ // the next one tries again, and nothing it threw goes unhandled.
364
+ function safeTick($: EngineInterface, b: Band) {
365
+ return tick($, b).catch(() => undefined)
366
+ }
367
+
368
+ function startClock($: EngineInterface, b: Band) {
369
+ if (b.ticking) return
370
+ b.ticking = true
371
+ void safeTick($, b)
372
+ $.clock.every(TICK_MS, () => {
373
+ void safeTick($, b)
374
+ })
375
+ }
376
+
377
+ // ── Drawing (contracts 3–8, 12) ───────────────────────────────────────
378
+
379
+ function gridSvg(c: BandCounts, cols: number) {
380
+ const step = 4
381
+ const side = 3
382
+ const rows = 2
383
+ const s = segments(c, cols)
384
+ let cells = ''
385
+ for (let i = 0; i < cols; i++) {
386
+ const fill = i < s.d ? DONE_C : i < s.d + s.a ? WORK_C : LEFT_C
387
+ for (let j = 0; j < rows; j++) cells += `<rect x="${i * step}" y="${j * step + 3}" width="${side}" height="${side}" fill="${fill}"/>`
388
+ }
389
+ const width = cols * step
390
+ const height = rows * step + 4
391
+ return { source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges">${cells}</svg>`, width, height }
392
+ }
393
+
394
+ export const register: Register = on => {
395
+ const b = newBand()
396
+
397
+ on('session.start', async ($, e, next) => {
398
+ startClock($, b)
399
+ return next(e)
400
+ })
401
+
402
+ on('turn.start', async ($, e, next) => {
403
+ startClock($, b)
404
+ return next(e)
405
+ })
406
+
407
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
408
+ try {
409
+ if (e.props.hasSurvey) {
410
+ // A question is a pause, not an absence: the heartbeat stays as it was.
411
+ b.lastRender = b.lastRender === 'drew' || b.lastRender === 'survey' ? 'survey' : 'nothing'
412
+ return next(e)
413
+ }
414
+ const v = await read($, view)
415
+ if (!v) {
416
+ b.lastRender = 'nothing'
417
+ return next(e)
418
+ }
419
+ const els = $.ui.resolve(e)
420
+ const { Box, Text } = els
421
+ const w = v.words
422
+ const now = await $.clock.now()
423
+ const agents = await read($, crew)
424
+ const { lead, others } = leadAgent(agents, now)
425
+ const rows = planRows({ head: true, progress: Boolean(v.counts), goal: Boolean(v.goal), story: Boolean(v.story), agent: Boolean(lead) }, e.props.maxRows)
426
+ if (!rows.length) {
427
+ b.lastRender = 'nothing'
428
+ return next(e)
429
+ }
430
+
431
+ const head = (
432
+ <Text wrap="truncate-end">
433
+ {v.version ? <Text dimColor>[PS#{v.version}] </Text> : null}
434
+ <Text bold color="yellow">📚 {v.id}</Text>
435
+ <Text bold> {v.title}</Text>
436
+ <Text dimColor> · {v.why === 'this' ? w.band_this_session : w.band_pinned}</Text>
437
+ </Text>
438
+ )
439
+
440
+ const goal = rows.includes('goal') ? <Text dimColor wrap="truncate-end"> {w.band_goal} {v.goal}</Text> : null
441
+
442
+ const hint = v.hint ? `· ${w.band_hint} ${HINT_COMMAND}` : null
443
+ let progress = null
444
+ const c = v.counts
445
+ if (rows.includes('progress') && c) {
446
+ if (!totalOf(c)) {
447
+ progress = (
448
+ <Text wrap="truncate-end">
449
+ <Text dimColor> {w.band_no_stories}</Text>
450
+ {hint ? <Text dimColor> {hint}</Text> : null}
451
+ </Text>
452
+ )
453
+ } else {
454
+ const lay = progressLayout(countsText(c, w), hint, e.props.bodyColumns)
455
+ const numbers = (
456
+ <Text wrap="truncate-end">
457
+ <Text bold>{pctOf(c)}%</Text>
458
+ <Text> </Text>
459
+ <Text color="green">{c.done} {w.band_done}</Text>
460
+ <Text dimColor> · </Text>
461
+ <Text color="yellow">{c.active} {w.band_in_work}</Text>
462
+ <Text dimColor> · {c.backlog} {w.band_backlog} · {totalOf(c)} {w.band_stories}</Text>
463
+ {lay.hint && hint ? <Text dimColor> {hint}</Text> : null}
464
+ </Text>
465
+ )
466
+ if (e.surface !== 'terminal' && 'Svg' in els) {
467
+ const { Svg } = els
468
+ const cols = Math.max(20, Math.min(80, Math.floor(e.props.bodyColumns / 3)))
469
+ const bar = gridSvg(c, cols)
470
+ progress = (
471
+ <Box flexDirection="row">
472
+ <Text> </Text>
473
+ <Svg source={bar.source} width={bar.width} height={bar.height} alt={`${v.id}: ${pctOf(c)}%, ${c.done} ${w.band_done}, ${c.active} ${w.band_in_work}, ${c.backlog} ${w.band_backlog}, ${totalOf(c)} ${w.band_stories}`} />
474
+ <Text> </Text>
475
+ {numbers}
476
+ </Box>
477
+ )
478
+ } else {
479
+ const seg = segments(c, lay.bar)
480
+ progress = (
481
+ <Text wrap="truncate-end">
482
+ <Text> </Text>
483
+ {lay.bar ? <Text color={GRID_DONE}>{GRID.repeat(seg.d)}</Text> : null}
484
+ {lay.bar ? <Text color={GRID_WORK}>{GRID.repeat(seg.a)}</Text> : null}
485
+ {lay.bar ? <Text dimColor>{GRID.repeat(seg.b)}</Text> : null}
486
+ {lay.bar ? <Text> </Text> : null}
487
+ {numbers}
488
+ </Text>
489
+ )
490
+ }
491
+ }
492
+ }
493
+
494
+ // The title is cut here so the status is never the part the host's
495
+ // truncation takes (contract 8); truncate-end stays as a backstop.
496
+ const sl = v.story ? storyLayout(w.band_story, v.story.title, v.story.status, e.props.bodyColumns) : null
497
+ const story = rows.includes('story') && sl ? (
498
+ <Text wrap="truncate-end">
499
+ <Text dimColor> {w.band_story} </Text>
500
+ <Text color="cyan">{sl.title}</Text>
501
+ <Text dimColor>{sl.tail}</Text>
502
+ </Text>
503
+ ) : null
504
+
505
+ let agent = null
506
+ if (rows.includes('agent') && lead) {
507
+ // The last step on the terminal only: on Desktop each step would
508
+ // redraw the progress drawing, which shows as a blink.
509
+ const step = lead.status === 'running' && e.surface === 'terminal' ? (await read($, steps))[lead.id] : undefined
510
+ const model = modelLabel(lead.model)
511
+ const tail = lead.status === 'running' ? (step ? ` · ${step}` : '') : ` · ${lead.status === 'done' ? w.band_ended_done : w.band_ended_stopped}`
512
+ agent = (
513
+ <Text wrap="truncate-end">
514
+ <Text dimColor> {w.band_agent} </Text>
515
+ {lead.status === 'done' ? <Text color="green">✓ </Text> : lead.status === 'error' ? <Text color="red">✗ </Text> : null}
516
+ <Text bold color={ROLE_C}>{lead.role}</Text>
517
+ <Text dimColor>{model ? ` · ${model}${lead.effort ? ` ${lead.effort}` : ''}` : ''} · </Text>
518
+ <Text>{phraseOf(lead.role, lead.subject, lead.description, w)}</Text>
519
+ <Text dimColor>{tail}{others ? ` · +${others} ${w.band_more}` : ''}</Text>
520
+ </Text>
521
+ )
522
+ }
523
+
524
+ b.lastRender = 'drew'
525
+ return (
526
+ <Box flexDirection="column" paddingX={1}>
527
+ {head}
528
+ {goal}
529
+ {progress}
530
+ {story}
531
+ {agent}
532
+ </Box>
533
+ )
534
+ } catch {
535
+ // A band that cannot draw draws less, never an error: the host's own.
536
+ b.lastRender = 'nothing'
537
+ return next(e)
538
+ }
539
+ })
540
+
541
+ // ── ProjectStore's agents (contract 12) ─────────────────────────────
542
+
543
+ on('agent.spawn', async ($, e, next) => {
544
+ const ran = await next(e)
545
+ try {
546
+ if (ran.agentId && e.subagentType.startsWith(AGENT_PREFIX)) {
547
+ const now = await $.clock.now()
548
+ const rel = subjectPath(e.prompt, b.place.vault, b.place.kinds)
549
+ let title: string | null = null
550
+ if (rel && b.place.vault) {
551
+ const text = await readText($, `${b.place.vault}/${rel}`)
552
+ const t = text === null ? null : (parseFrontmatter(text).data as Json).title
553
+ title = typeof t === 'string' && t ? t : null
554
+ }
555
+ const agent: BandAgent = {
556
+ id: ran.agentId,
557
+ role: e.subagentType.slice(AGENT_PREFIX.length),
558
+ model: ran.model || e.model || '',
559
+ effort: null,
560
+ subject: rel ? subjectLabel(rel, title) : null,
561
+ description: e.description,
562
+ status: 'running',
563
+ startedAt: now,
564
+ }
565
+ await update($, crew, list => [...list.filter(a => a.id !== agent.id && (a.status === 'running' || now - (a.endedAt ?? now) < LINGER_MS)), agent].slice(-12))
566
+ }
567
+ } catch {
568
+ // A view: a spawn it failed to note never holds up the agent.
569
+ }
570
+ return ran
571
+ })
572
+
573
+ // The model and effort an agent's own requests name: its first request
574
+ // says, later ones only when they change.
575
+ on('turn.step', async function* ($, e, next) {
576
+ const id = e.agentId
577
+ if (id) {
578
+ try {
579
+ const effort = e.effort === undefined ? null : String(e.effort)
580
+ const agent = (await read($, crew)).find(a => a.id === id)
581
+ if (agent && (agent.model !== e.model || agent.effort !== effort)) {
582
+ await update($, crew, list => list.map(a => (a.id === id ? { ...a, model: e.model, effort } : a)))
583
+ }
584
+ } catch {
585
+ // Never in the way of the request.
586
+ }
587
+ }
588
+ return yield* next(e)
589
+ })
590
+
591
+ on('tool.call', async ($, e, next) => {
592
+ const id = e.agentId
593
+ if (id) {
594
+ try {
595
+ const agent = (await read($, crew)).find(a => a.id === id)
596
+ const v = agent?.status === 'running' ? await read($, view) : null
597
+ const step = agent?.status === 'running' && v ? stepOf(e as unknown as Json, b.place.vault, v.words) : null
598
+ if (step && (await read($, steps))[id] !== step) await update($, steps, d => ({ ...d, [id]: step }))
599
+ } catch {
600
+ // A step the band failed to note never holds up the tool.
601
+ }
602
+ }
603
+ return next(e)
604
+ })
605
+
606
+ on('turn.complete', async ($, e, next) => {
607
+ const id = e.agentId
608
+ if (id) {
609
+ try {
610
+ const now = await $.clock.now()
611
+ const status = e.reason === 'answer' ? ('done' as const) : ('error' as const)
612
+ if ((await read($, crew)).some(a => a.id === id)) {
613
+ await update($, crew, list => list.map(a => (a.id === id ? { ...a, status, endedAt: now } : a)))
614
+ }
615
+ if (id in (await read($, steps))) await update($, steps, d => Object.fromEntries(Object.entries(d).filter(([k]) => k !== id)))
616
+ } catch {
617
+ // Never in the way of the turn.
618
+ }
619
+ }
620
+ return next(e)
621
+ })
622
+ }
@@ -1,4 +1,5 @@
1
1
  {
2
+ "modules": ["./band.tsx"],
2
3
  "hooks": {
3
4
  "SessionStart": [
4
5
  {
@@ -57,10 +57,7 @@ async function readActivityBudgeted(vault, sid, budgetMs) {
57
57
  const deadline = new Promise((resolve) => {
58
58
  timer = setTimeout(() => resolve(null), budgetMs);
59
59
  });
60
- const out = await Promise.race([
61
- readActivityAsync(vault, sid).catch(() => []),
62
- deadline,
63
- ]);
60
+ const out = await Promise.race([readActivityAsync(vault, sid).catch(() => []), deadline]);
64
61
  clearTimeout(timer);
65
62
  return out;
66
63
  }
@@ -85,9 +82,7 @@ async function main() {
85
82
  try {
86
83
  layout = loadLayout(cfg.layout);
87
84
  } catch {}
88
- const artifact = activity && layout
89
- ? resolveInFlightArtifact(activity, layout, cfg.vault_path)
90
- : null;
85
+ const artifact = activity && layout ? resolveInFlightArtifact(activity, layout, cfg.vault_path) : null;
91
86
 
92
87
  const parts = [`vault ${basename(cfg.vault_path)}`];
93
88
  // Dropped rather than rendered empty when nothing resolves.
@@ -105,9 +100,11 @@ async function main() {
105
100
  // Recent files are a separate referent with a separate condition: an empty
106
101
  // log renders no continuity section, so the stronger wording would be false
107
102
  // on a path the activity log's last-tool-wins dedupe makes ordinary.
108
- parts.push(activity && activity.length > 0
109
- ? "orientation and this session's recent files return at session start"
110
- : "orientation returns at session start");
103
+ parts.push(
104
+ activity && activity.length > 0
105
+ ? "orientation and this session's recent files return at session start"
106
+ : "orientation returns at session start",
107
+ );
111
108
  } else {
112
109
  // True on every path: plugin commands are installation-scoped and not
113
110
  // gated by auto_inject.