projectstore-claude 0.29.2 → 0.29.3

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