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.
- package/README.md +1 -1
- package/bin/projectstore-claude.mjs +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 +44 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +16 -20
- package/node_modules/projectstore/commands/doctor.md +12 -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 +2 -2
- package/node_modules/projectstore/commands/story.md +15 -2
- 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 +12 -3
- package/node_modules/projectstore/harnesses/claude-code.json +23 -8
- package/node_modules/projectstore/harnesses/codex.json +16 -6
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +622 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +56 -20
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +12 -2
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
- package/node_modules/projectstore/scripts/band.mjs +207 -0
- package/node_modules/projectstore/scripts/binding.mjs +733 -62
- package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
- package/node_modules/projectstore/scripts/cli.mjs +1463 -267
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +638 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
- package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +283 -37
- package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
- package/node_modules/projectstore/scripts/kanban.mjs +6 -21
- package/node_modules/projectstore/scripts/lib.mjs +933 -199
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +441 -72
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +139 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +84 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +55 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
- package/node_modules/projectstore/scripts/term.mjs +1027 -52
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- 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,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
|
+
}
|
|
@@ -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(
|
|
109
|
-
|
|
110
|
-
|
|
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.
|