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.
- package/README.md +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +27 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +14 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/harnesses/claude-code.json +18 -3
- package/node_modules/projectstore/harnesses/codex.json +12 -1
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +594 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/session-start.mjs +6 -2
- package/node_modules/projectstore/package.json +2 -1
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
- package/node_modules/projectstore/scripts/band.mjs +156 -0
- package/node_modules/projectstore/scripts/binding.mjs +157 -17
- package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
- package/node_modules/projectstore/scripts/cli.mjs +545 -179
- package/node_modules/projectstore/scripts/convergence.mjs +468 -0
- package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
- package/node_modules/projectstore/scripts/doctor.mjs +80 -62
- package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
- package/node_modules/projectstore/scripts/harness.mjs +178 -7
- package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
- package/node_modules/projectstore/scripts/kanban.mjs +4 -20
- package/node_modules/projectstore/scripts/lib.mjs +257 -58
- package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
- package/node_modules/projectstore/scripts/query.mjs +83 -12
- package/node_modules/projectstore/scripts/run-host.mjs +94 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
- package/node_modules/projectstore/scripts/stages.mjs +80 -0
- package/node_modules/projectstore/scripts/statusline.mjs +25 -2
- package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
- package/node_modules/projectstore/scripts/term.mjs +729 -39
- package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- 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
|
+
}
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
layoutPaths,
|
|
28
28
|
pickExisting,
|
|
29
29
|
activeHarnessId,
|
|
30
|
-
|
|
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
|
-
|
|
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.
|
|
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": ["Описание"],
|