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,446 @@
|
|
|
1
|
+
// projectstore — band-shared.mjs: what the band above the prompt and the
|
|
2
|
+
// status line agree on, in one place both can load.
|
|
3
|
+
//
|
|
4
|
+
// The band is a Claude Code hooks module (hooks/band.tsx). A module runs with
|
|
5
|
+
// no Node: it may import this file only because this file imports nothing
|
|
6
|
+
// but other Node-free files of the plugin. Keep it that way — a `node:`
|
|
7
|
+
// import here, or in anything it imports, stops the module from loading.
|
|
8
|
+
// Everything here is pure, so the node tests reach every rule the band draws
|
|
9
|
+
// by without a host.
|
|
10
|
+
//
|
|
11
|
+
// Spec: the band above the prompt — rows, heartbeat, command and the status
|
|
12
|
+
// line's yield (the contract numbers below are that spec's).
|
|
13
|
+
|
|
14
|
+
import { stageOf } from "./stages.mjs";
|
|
15
|
+
|
|
16
|
+
// ─── The heartbeat (contracts 10, 11) ──────────────────────────────────
|
|
17
|
+
// While a session's band draws, the module keeps a small file fresh in the
|
|
18
|
+
// session-state directory; the status line yields its own line only while
|
|
19
|
+
// that file says it is drawing and is younger than the window. Anything else
|
|
20
|
+
// — missing, unreadable, stale, from the future, or `drawn: false` — is no
|
|
21
|
+
// band, so a band that cannot draw always gives the line back.
|
|
22
|
+
|
|
23
|
+
export const HEARTBEAT_WINDOW_MS = 30_000;
|
|
24
|
+
|
|
25
|
+
// The heartbeat's file name for a session: dot-prefixed, like the renderer's
|
|
26
|
+
// own breadcrumb, so doctor's listing of hook-side session ids never reads it
|
|
27
|
+
// as one. Null for an id that cannot name a file.
|
|
28
|
+
export function heartbeatFile(sessionId) {
|
|
29
|
+
if (typeof sessionId !== "string" || !sessionId) return null;
|
|
30
|
+
if (/[/\\\0]/.test(sessionId) || sessionId === "." || sessionId === "..") return null;
|
|
31
|
+
return `.band-${sessionId}.json`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function heartbeatBody(drawn, now) {
|
|
35
|
+
return { drawn: drawn === true, drawn_at: now };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `drawing` | `stopped` | `stale` | `none`, for a parsed heartbeat (or
|
|
39
|
+
// whatever JSON.parse returned) at `now`.
|
|
40
|
+
export function heartbeatState(value, now) {
|
|
41
|
+
if (!value || typeof value !== "object" || typeof value.drawn !== "boolean") return "none";
|
|
42
|
+
if (value.drawn === false) return "stopped";
|
|
43
|
+
const at = value.drawn_at;
|
|
44
|
+
if (typeof at !== "number" || !Number.isFinite(at)) return "none";
|
|
45
|
+
const age = now - at;
|
|
46
|
+
return age >= 0 && age < HEARTBEAT_WINDOW_MS ? "drawing" : "stale";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function heartbeatYields(value, now) {
|
|
50
|
+
return heartbeatState(value, now) === "drawing";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// What a tick writes, from what the band's last render did: `drew` and
|
|
54
|
+
// `survey` (held back only by a question) keep the heartbeat fresh; anything
|
|
55
|
+
// else writes `drawn: false` once — `wasDrawing` says whether the last write
|
|
56
|
+
// was a drawing one — and then nothing.
|
|
57
|
+
export function heartbeatNext(lastRender, wasDrawing, now) {
|
|
58
|
+
if (lastRender === "drew" || lastRender === "survey") return heartbeatBody(true, now);
|
|
59
|
+
return wasDrawing ? heartbeatBody(false, now) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Words (contract 14) ───────────────────────────────────────────────
|
|
63
|
+
// Every word the band draws that is not a title, an id, a path, a model id
|
|
64
|
+
// or a command token: one `band_` key per word, in every bundled
|
|
65
|
+
// templates/<lang>/strings.json, the English file the fallback per key.
|
|
66
|
+
|
|
67
|
+
export const BAND_STRING_KEYS = Object.freeze([
|
|
68
|
+
"band_this_session",
|
|
69
|
+
"band_pinned",
|
|
70
|
+
"band_goal",
|
|
71
|
+
"band_story",
|
|
72
|
+
"band_agent",
|
|
73
|
+
"band_done",
|
|
74
|
+
"band_in_work",
|
|
75
|
+
"band_backlog",
|
|
76
|
+
"band_stories",
|
|
77
|
+
"band_no_stories",
|
|
78
|
+
"band_more",
|
|
79
|
+
"band_hint",
|
|
80
|
+
"band_ended_done",
|
|
81
|
+
"band_ended_stopped",
|
|
82
|
+
"band_verb_critic",
|
|
83
|
+
"band_verb_reviewer",
|
|
84
|
+
"band_verb_planner",
|
|
85
|
+
"band_verb_clerk",
|
|
86
|
+
"band_verb_librarian",
|
|
87
|
+
"band_verb_archaeologist",
|
|
88
|
+
"band_verb_other",
|
|
89
|
+
"band_step_reads",
|
|
90
|
+
"band_step_writes",
|
|
91
|
+
"band_step_edits",
|
|
92
|
+
"band_step_searches",
|
|
93
|
+
"band_step_runs",
|
|
94
|
+
"band_step_web",
|
|
95
|
+
"band_step_vault",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
// The band's words for a language: the English file's, each overlaid by the
|
|
99
|
+
// language's own where it has one. Null when the English file lacks one of
|
|
100
|
+
// them — the band then draws nothing rather than invent words.
|
|
101
|
+
export function mergeWords(en, local) {
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const k of BAND_STRING_KEYS) {
|
|
104
|
+
const v = (local && typeof local[k] === "string" && local[k]) || (en && typeof en[k] === "string" && en[k]) || null;
|
|
105
|
+
if (!v) return null;
|
|
106
|
+
out[k] = v;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── The goal (contract 7) ─────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
// The goal heading's spellings for the bound language, from the heading
|
|
114
|
+
// registry (scaffold/headings.json); English when the language has none.
|
|
115
|
+
export function goalForms(registry, lang) {
|
|
116
|
+
const g = registry && registry.headings && registry.headings.goal;
|
|
117
|
+
if (!g) return [];
|
|
118
|
+
return (lang && Array.isArray(g[lang]) && g[lang].length ? g[lang] : g.en) || [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Wiki links and markdown links to their text, emphasis and code marks off,
|
|
122
|
+
// whitespace collapsed.
|
|
123
|
+
export function inlineText(md) {
|
|
124
|
+
return String(md)
|
|
125
|
+
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2")
|
|
126
|
+
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
|
127
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
128
|
+
.replace(/\*\*|`/g, "")
|
|
129
|
+
.replace(/\s+/g, " ")
|
|
130
|
+
.trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The first paragraph under the epic's goal heading, on one line; "" when
|
|
134
|
+
// there is no such heading or the paragraph is empty. A template comment
|
|
135
|
+
// standing alone is not a paragraph.
|
|
136
|
+
export function goalOf(epicText, forms) {
|
|
137
|
+
if (!forms || !forms.length) return "";
|
|
138
|
+
const lines = String(epicText).replace(/\r\n/g, "\n").split("\n");
|
|
139
|
+
const want = new Set(forms.map((f) => f.trim()));
|
|
140
|
+
const at = lines.findIndex((l) => /^##\s/.test(l) && want.has(l.replace(/^##\s+/, "").trim()));
|
|
141
|
+
if (at < 0) return "";
|
|
142
|
+
const para = [];
|
|
143
|
+
for (const line of lines.slice(at + 1)) {
|
|
144
|
+
if (/^#{1,6}\s/.test(line)) break;
|
|
145
|
+
if (!line.trim()) {
|
|
146
|
+
if (para.length) break;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (/^<!--.*-->$/.test(line.trim())) continue;
|
|
150
|
+
para.push(line);
|
|
151
|
+
}
|
|
152
|
+
return inlineText(para.join(" "));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Story files (contract 9) ──────────────────────────────────────────
|
|
156
|
+
// The files the board lists for one epic folder (lib.mjs listEpicStories):
|
|
157
|
+
// stories/*.md, stories/<slug>/README.md, and a story-*.md beside epic.md.
|
|
158
|
+
// `fs` is `{ list(dir) → [{ name, kind }], exists(path) → boolean }`, each
|
|
159
|
+
// possibly async, so the module passes its own and a test passes node's.
|
|
160
|
+
|
|
161
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
162
|
+
|
|
163
|
+
export async function storyFiles(fs, epicDir) {
|
|
164
|
+
const out = [];
|
|
165
|
+
const stories = `${epicDir}/stories`;
|
|
166
|
+
let entries;
|
|
167
|
+
try {
|
|
168
|
+
entries = await fs.list(stories);
|
|
169
|
+
} catch {
|
|
170
|
+
entries = [];
|
|
171
|
+
}
|
|
172
|
+
for (const e of [...entries].sort(byName)) {
|
|
173
|
+
if (e.kind === "file" && e.name.endsWith(".md")) out.push(`${stories}/${e.name}`);
|
|
174
|
+
else if (e.kind === "dir" && (await fs.exists(`${stories}/${e.name}/README.md`)))
|
|
175
|
+
out.push(`${stories}/${e.name}/README.md`);
|
|
176
|
+
}
|
|
177
|
+
let beside;
|
|
178
|
+
try {
|
|
179
|
+
beside = await fs.list(epicDir);
|
|
180
|
+
} catch {
|
|
181
|
+
beside = [];
|
|
182
|
+
}
|
|
183
|
+
for (const e of [...beside].sort(byName)) {
|
|
184
|
+
if (e.kind === "file" && e.name.startsWith("story-") && e.name.endsWith(".md")) out.push(`${epicDir}/${e.name}`);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── This session's story (contract 8) ─────────────────────────────────
|
|
190
|
+
// The session pointer names its story by a key; the band finds that key among
|
|
191
|
+
// the epic's story files. One function for both, so a folder story and a
|
|
192
|
+
// story beside epic.md are named the way they are found.
|
|
193
|
+
|
|
194
|
+
// The key of a story file from its path below the epic folder:
|
|
195
|
+
// `stories/<x>.md` and `stories/<x>/README.md` are `<x>`, `story-<x>.md`
|
|
196
|
+
// beside epic.md is `story-<x>`; anything else is not a story (null).
|
|
197
|
+
export function storyKeyOf(relToEpic) {
|
|
198
|
+
const rel = String(relToEpic || "");
|
|
199
|
+
let m = rel.match(/^stories\/([^/]+)\.md$/);
|
|
200
|
+
if (m) return m[1];
|
|
201
|
+
m = rel.match(/^stories\/([^/]+)\/README\.md$/);
|
|
202
|
+
if (m) return m[1];
|
|
203
|
+
m = rel.match(/^(story-[^/]+)\.md$/);
|
|
204
|
+
return m ? m[1] : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Whether a story with this status is work the session is moving: in work or
|
|
208
|
+
// backlog. Done, closed, parked, superseded, deferred or no status — not.
|
|
209
|
+
export function storyIsCurrent(status) {
|
|
210
|
+
const stage = stageOf(status);
|
|
211
|
+
return stage === "active" || stage === "backlog";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// An epic id the band may put in a path: one segment the board would list
|
|
215
|
+
// (not hidden, not a `_templates`-style folder).
|
|
216
|
+
export function isEpicName(id) {
|
|
217
|
+
return typeof id === "string" && /^[^/\\\0]+$/.test(id) && id !== ".." && !id.startsWith(".") && !id.startsWith("_");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Display width ─────────────────────────────────────────────────────
|
|
221
|
+
// Terminal cells: wide (East Asian, emoji) characters take two, combining
|
|
222
|
+
// marks and variation selectors none.
|
|
223
|
+
|
|
224
|
+
const WIDE = [
|
|
225
|
+
[0x1100, 0x115f],
|
|
226
|
+
[0x2e80, 0x303e],
|
|
227
|
+
[0x3041, 0x33ff],
|
|
228
|
+
[0x3400, 0x4dbf],
|
|
229
|
+
[0x4e00, 0x9fff],
|
|
230
|
+
[0xa000, 0xa4cf],
|
|
231
|
+
[0xac00, 0xd7a3],
|
|
232
|
+
[0xf900, 0xfaff],
|
|
233
|
+
[0xfe30, 0xfe4f],
|
|
234
|
+
[0xff00, 0xff60],
|
|
235
|
+
[0xffe0, 0xffe6],
|
|
236
|
+
[0x1f300, 0x1f64f],
|
|
237
|
+
[0x1f900, 0x1f9ff],
|
|
238
|
+
[0x20000, 0x3fffd],
|
|
239
|
+
];
|
|
240
|
+
const ZERO = [
|
|
241
|
+
[0x0300, 0x036f],
|
|
242
|
+
[0x200b, 0x200f],
|
|
243
|
+
[0xfe00, 0xfe0f],
|
|
244
|
+
];
|
|
245
|
+
const within = (cp, ranges) => ranges.some(([a, b]) => cp >= a && cp <= b);
|
|
246
|
+
|
|
247
|
+
export function cellWidth(text) {
|
|
248
|
+
let w = 0;
|
|
249
|
+
for (const ch of String(text)) {
|
|
250
|
+
const cp = ch.codePointAt(0);
|
|
251
|
+
if (within(cp, ZERO)) continue;
|
|
252
|
+
w += within(cp, WIDE) ? 2 : 1;
|
|
253
|
+
}
|
|
254
|
+
return w;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── Rows (contracts 3, 5) ─────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
export const ROW_PRIORITY = Object.freeze(["head", "progress", "goal", "story", "agent"]);
|
|
260
|
+
const DRAW_ORDER = Object.freeze(["head", "goal", "progress", "story", "agent"]);
|
|
261
|
+
|
|
262
|
+
// Which rows to draw for `maxRows`: the rows that have content, taken in
|
|
263
|
+
// priority order until maxRows are taken (a row without content frees its
|
|
264
|
+
// slot), returned in drawing order.
|
|
265
|
+
export function planRows(has, maxRows) {
|
|
266
|
+
const taken = new Set();
|
|
267
|
+
for (const row of ROW_PRIORITY) {
|
|
268
|
+
if (taken.size >= maxRows) break;
|
|
269
|
+
if (has[row]) taken.add(row);
|
|
270
|
+
}
|
|
271
|
+
return DRAW_ORDER.filter((r) => taken.has(r));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const totalOf = (c) => c.done + c.active + c.backlog;
|
|
275
|
+
export const pctOf = (c) => (totalOf(c) ? Math.round((100 * c.done) / totalOf(c)) : 0);
|
|
276
|
+
|
|
277
|
+
export function countsText(c, w) {
|
|
278
|
+
return `${pctOf(c)}% ${c.done} ${w.band_done} · ${c.active} ${w.band_in_work} · ${c.backlog} ${w.band_backlog} · ${totalOf(c)} ${w.band_stories}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function segments(c, width) {
|
|
282
|
+
const t = totalOf(c) || 1;
|
|
283
|
+
const d = Math.round((c.done / t) * width);
|
|
284
|
+
const a = Math.round(((c.done + c.active) / t) * width) - d;
|
|
285
|
+
return { d, a, b: Math.max(0, width - d - a) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const BAR_MIN = 6;
|
|
289
|
+
export const BAR_MAX = 40;
|
|
290
|
+
|
|
291
|
+
// The terminal's progress row: ` <bar> <counts>[ <hint>]`, inside a box
|
|
292
|
+
// padded by one column each side. The counts are never cut: when the row
|
|
293
|
+
// cannot hold the minimum bar, the counts and the hint, the hint goes first,
|
|
294
|
+
// then the bar (width 0).
|
|
295
|
+
export function progressLayout(counts, hint, bodyColumns) {
|
|
296
|
+
const room = bodyColumns - 2 - 3 - 2 - cellWidth(counts) - 1;
|
|
297
|
+
const hintCells = hint ? cellWidth(` ${hint}`) : 0;
|
|
298
|
+
if (hint && room - hintCells >= BAR_MIN) return { bar: Math.min(BAR_MAX, room - hintCells), hint: true };
|
|
299
|
+
return { bar: room >= BAR_MIN ? Math.min(BAR_MAX, room) : 0, hint: false };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The first characters of `text` that fit `cells`, with `…` when it was cut.
|
|
303
|
+
export function cutCells(text, cells) {
|
|
304
|
+
const s = String(text);
|
|
305
|
+
if (cellWidth(s) <= cells) return s;
|
|
306
|
+
let out = "";
|
|
307
|
+
let w = 0;
|
|
308
|
+
for (const ch of s) {
|
|
309
|
+
const cw = cellWidth(ch);
|
|
310
|
+
if (w + cw > cells - 1) break;
|
|
311
|
+
out += ch;
|
|
312
|
+
w += cw;
|
|
313
|
+
}
|
|
314
|
+
return `${out}…`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ` · <status>`, `_` and `-` shown as spaces; "" with no status.
|
|
318
|
+
export function storyTail(status) {
|
|
319
|
+
return status ? ` · ${String(status).replace(/[_-]/g, " ")}` : "";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The story row: ` <label> <title> · <status>`, at most bodyColumns − 3
|
|
323
|
+
// cells — the room the progress row's formula leaves (the box's padding, then
|
|
324
|
+
// the strict fit). The status is never cut: when the row is longer, the title
|
|
325
|
+
// is cut with `…` (to `…` alone at worst).
|
|
326
|
+
export function storyLayout(label, title, status, bodyColumns) {
|
|
327
|
+
const tail = storyTail(status);
|
|
328
|
+
// No width from the host: draw the title whole and let the host truncate.
|
|
329
|
+
if (!Number.isFinite(bodyColumns)) return { title: String(title), tail };
|
|
330
|
+
const room = bodyColumns - 2 - 3 - cellWidth(`${label} `) - cellWidth(tail) - 1;
|
|
331
|
+
return { title: cutCells(title, Math.max(1, room)), tail };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── The agent row (contract 12) ───────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
export const AGENT_PREFIX = "projectstore:";
|
|
337
|
+
export const LINGER_MS = 90_000;
|
|
338
|
+
|
|
339
|
+
// `claude-opus-5-5[1m]` reads `opus 5.5`; any other id as given.
|
|
340
|
+
export function modelLabel(id) {
|
|
341
|
+
if (typeof id !== "string" || !id) return null;
|
|
342
|
+
const m = id.match(/^claude-([a-z]+)-(\d+)-(\d+)/);
|
|
343
|
+
return m ? `${m[1]} ${m[2]}.${m[3]}` : id;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const DERIVED = /(^|\/)(README|kanban|graph|code-map)\.md$/;
|
|
347
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
348
|
+
|
|
349
|
+
// The first vault document the prompt names: a path under the vault, or one
|
|
350
|
+
// that starts with one of the layout's kind folders; vault-relative, or null.
|
|
351
|
+
export function subjectPath(prompt, vault, kindFolders) {
|
|
352
|
+
const text = String(prompt || "");
|
|
353
|
+
const found = [];
|
|
354
|
+
if (vault) {
|
|
355
|
+
for (const m of text.matchAll(new RegExp(`${escRe(vault)}/([^\\s\`'")\\]]+?\\.md)`, "g")))
|
|
356
|
+
found.push({ at: m.index, rel: m[1] });
|
|
357
|
+
}
|
|
358
|
+
const kinds = (kindFolders || []).filter(Boolean).map(escRe);
|
|
359
|
+
if (kinds.length) {
|
|
360
|
+
const re = new RegExp(`(?:^|[\\s\`'"(\\[])((?:${kinds.join("|")})\\/[^\\s\`'")\\]]+?\\.md)`, "g");
|
|
361
|
+
for (const m of text.matchAll(re)) found.push({ at: m.index + m[0].length - m[1].length, rel: m[1] });
|
|
362
|
+
}
|
|
363
|
+
found.sort((a, b) => a.at - b.at);
|
|
364
|
+
const hit = found.find((f) => !DERIVED.test(f.rel));
|
|
365
|
+
return hit ? hit.rel : null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const stem = (rel) => rel.split("/").pop().replace(/\.md$/, "");
|
|
369
|
+
|
|
370
|
+
// The subject as shown: its title in «», else the file's stem.
|
|
371
|
+
export function subjectLabel(rel, title) {
|
|
372
|
+
return title ? `«${title}»` : stem(rel);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const ROLE_VERBS = ["critic", "reviewer", "planner", "clerk", "librarian", "archaeologist"];
|
|
376
|
+
|
|
377
|
+
// `<verb> <subject>`, the verb by role; the task's description as given when
|
|
378
|
+
// the prompt named no document.
|
|
379
|
+
export function phraseOf(role, subject, description, w) {
|
|
380
|
+
if (!subject) return description || "";
|
|
381
|
+
const verb = ROLE_VERBS.includes(role) ? w[`band_verb_${role}`] : w.band_verb_other;
|
|
382
|
+
return `${verb} ${subject}`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const lastSegment = (p) => String(p).replace(/\/+$/, "").split("/").pop();
|
|
386
|
+
const vaultRel = (vault, p) => (vault && p.startsWith(`${vault}/`) ? p.slice(vault.length + 1) : lastSegment(p));
|
|
387
|
+
|
|
388
|
+
// `node /x/reconcile.mjs --a` reads `reconcile`, `git diff --stat` reads `git diff`.
|
|
389
|
+
export function commandWord(command) {
|
|
390
|
+
const words = String(command)
|
|
391
|
+
.replace(/^(\s*cd\s+("[^"]*"|'[^']*'|\S+)\s*&&\s*)+/, "")
|
|
392
|
+
.trim()
|
|
393
|
+
.split(/\s+/)
|
|
394
|
+
.filter((x) => !/^\w+=/.test(x));
|
|
395
|
+
const [cmd = "", arg = ""] = words.map((x) => x.replace(/^["']|["']$/g, ""));
|
|
396
|
+
if (/^(node|python3?|bash|sh|npx|bun|deno|tsx)$/.test(lastSegment(cmd)) && arg && !arg.startsWith("-"))
|
|
397
|
+
return lastSegment(arg).replace(/\.(m?js|cjs|py|sh|ts)$/, "");
|
|
398
|
+
return /^[a-z][\w:-]*$/.test(arg) ? `${lastSegment(cmd)} ${arg}` : lastSegment(cmd);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const QUIET = /^(echo|printf|true|sleep|:)(\s|$)/;
|
|
402
|
+
|
|
403
|
+
// An agent's last step from its latest tool call, or null when the call says
|
|
404
|
+
// nothing worth a row (an echo, a tool the band does not name).
|
|
405
|
+
export function stepOf(call, vault, w) {
|
|
406
|
+
const tool = typeof call.tool === "string" ? call.tool : "";
|
|
407
|
+
const path =
|
|
408
|
+
typeof call.file_path === "string"
|
|
409
|
+
? call.file_path
|
|
410
|
+
: typeof call.notebook_path === "string"
|
|
411
|
+
? call.notebook_path
|
|
412
|
+
: null;
|
|
413
|
+
if (tool === "Read" && path) return `${w.band_step_reads} ${vaultRel(vault, path)}`;
|
|
414
|
+
if (tool === "Write" && path) return `${w.band_step_writes} ${vaultRel(vault, path)}`;
|
|
415
|
+
if ((tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") && path)
|
|
416
|
+
return `${w.band_step_edits} ${vaultRel(vault, path)}`;
|
|
417
|
+
if (tool === "Grep" || tool === "Glob") {
|
|
418
|
+
if (typeof call.pattern !== "string") return w.band_step_searches;
|
|
419
|
+
return `${w.band_step_searches} ${call.pattern.length > 32 ? call.pattern.slice(0, 31) + "…" : call.pattern}`;
|
|
420
|
+
}
|
|
421
|
+
if (tool === "Bash" && typeof call.command === "string") {
|
|
422
|
+
const said = typeof call.description === "string" ? call.description.trim() : "";
|
|
423
|
+
if (said) return said.replace(/^\p{Lu}(?!\p{Lu})/u, (c) => c.toLowerCase());
|
|
424
|
+
const cmd = commandWord(call.command);
|
|
425
|
+
return QUIET.test(cmd) ? null : `${w.band_step_runs} ${cmd}`;
|
|
426
|
+
}
|
|
427
|
+
if (tool === "WebFetch" || tool === "WebSearch") return w.band_step_web;
|
|
428
|
+
const mcp = tool.match(/^mcp__.*projectstore__(\w+)$/);
|
|
429
|
+
if (mcp) return `${w.band_step_vault} ${mcp[1].replace(/_/g, " ")}`;
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// The agent the row shows: the most recently started running one, else the
|
|
434
|
+
// most recently ended one within the linger; and how many others linger.
|
|
435
|
+
// `agents` is in spawn order, so on a tie the later one leads.
|
|
436
|
+
export function leadAgent(agents, now) {
|
|
437
|
+
const live = agents.filter((a) => a.status === "running" || now - (a.endedAt ?? now) < LINGER_MS);
|
|
438
|
+
const order = new Map(live.map((a, i) => [a, i]));
|
|
439
|
+
const later = (a, b) => order.get(b) - order.get(a);
|
|
440
|
+
const running = live.filter((a) => a.status === "running").sort((a, b) => b.startedAt - a.startedAt || later(a, b));
|
|
441
|
+
const ended = live
|
|
442
|
+
.filter((a) => a.status !== "running")
|
|
443
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || later(a, b));
|
|
444
|
+
const lead = running[0] || ended[0] || null;
|
|
445
|
+
return { lead, others: lead ? live.length - 1 : 0, live };
|
|
446
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// projectstore — band.mjs: the `band` verb, the one writer of the binding's
|
|
2
|
+
// `band` keys, and the reader that says why the band is or is not drawing.
|
|
3
|
+
//
|
|
4
|
+
// The band above the prompt is a Claude Code hooks module (hooks/band.tsx);
|
|
5
|
+
// it only READS the binding. Everything that changes what it shows — on, off,
|
|
6
|
+
// a pinned epic, the hint's memory — is written here, atomically, so the one
|
|
7
|
+
// file every hook reads is never torn (a torn binding reads as "not bound").
|
|
8
|
+
//
|
|
9
|
+
// Spec: the band above the prompt — rows, heartbeat, command and the status
|
|
10
|
+
// line's yield, contract 13.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { writeFileAtomic, loadLayout, folderByKind, sessionStatePath, stateDir } from "./lib.mjs";
|
|
15
|
+
import { configPath as harnessConfigPath, activeHarness, hasBand, hostSessionId } from "./harness.mjs";
|
|
16
|
+
import { heartbeatFile, heartbeatState, isEpicName } from "./band-shared.mjs";
|
|
17
|
+
|
|
18
|
+
export const SUBCOMMANDS = Object.freeze(["on", "off", "status", "epic"]);
|
|
19
|
+
|
|
20
|
+
// The band keys as the module reads them: absent `enabled` means on.
|
|
21
|
+
export function bandKeys(cfg) {
|
|
22
|
+
const b = cfg && cfg.band && typeof cfg.band === "object" ? cfg.band : {};
|
|
23
|
+
return {
|
|
24
|
+
enabled: b.enabled !== false,
|
|
25
|
+
epic: typeof b.epic === "string" && b.epic ? b.epic : null,
|
|
26
|
+
hint_seen: b.hint_seen === true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The epic folders of the bound vault, as the board lists them.
|
|
31
|
+
export function epicIds(cfg, root) {
|
|
32
|
+
if (!cfg || !cfg.vault_path) return [];
|
|
33
|
+
let folder;
|
|
34
|
+
try {
|
|
35
|
+
folder = folderByKind(loadLayout(cfg.layout || "engineering", root), "epic");
|
|
36
|
+
} catch {
|
|
37
|
+
folder = null;
|
|
38
|
+
}
|
|
39
|
+
if (!folder) return [];
|
|
40
|
+
const dir = join(cfg.vault_path, folder.path);
|
|
41
|
+
let names;
|
|
42
|
+
try {
|
|
43
|
+
names = readdirSync(dir);
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return names
|
|
48
|
+
.filter((n) => {
|
|
49
|
+
if (!isEpicName(n)) return false;
|
|
50
|
+
try {
|
|
51
|
+
return statSync(join(dir, n)).isDirectory();
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
.sort();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readBinding(path) {
|
|
60
|
+
if (!existsSync(path)) return { raw: null, corrupt: false };
|
|
61
|
+
try {
|
|
62
|
+
return { raw: JSON.parse(readFileSync(path, "utf8")), corrupt: false };
|
|
63
|
+
} catch {
|
|
64
|
+
return { raw: null, corrupt: true };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// What a write would do: the binding's path, its band keys before and after,
|
|
69
|
+
// and whether anything changes. Refusals carry a code and a message; a
|
|
70
|
+
// refused plan writes nothing.
|
|
71
|
+
export function planBand(projectDir, sub, arg, { env = process.env, root } = {}) {
|
|
72
|
+
const path = harnessConfigPath(projectDir, env);
|
|
73
|
+
const { raw, corrupt } = readBinding(path);
|
|
74
|
+
if (corrupt)
|
|
75
|
+
return {
|
|
76
|
+
path,
|
|
77
|
+
refusal: {
|
|
78
|
+
code: "UNREADABLE",
|
|
79
|
+
message: `${path} is not valid JSON — fix or remove it first; nothing is written`,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
// A bare `{ band: {} }` file would read as a binding: never create one.
|
|
83
|
+
if (!raw)
|
|
84
|
+
return {
|
|
85
|
+
path,
|
|
86
|
+
refusal: { code: "UNBOUND", message: `${projectDir} is not bound to a vault; there is no band to switch` },
|
|
87
|
+
};
|
|
88
|
+
const before = bandKeys(raw);
|
|
89
|
+
const next = { ...(raw.band && typeof raw.band === "object" ? raw.band : {}) };
|
|
90
|
+
if (sub === "on") next.enabled = true;
|
|
91
|
+
else if (sub === "off") next.enabled = false;
|
|
92
|
+
else if (sub === "epic" && arg) {
|
|
93
|
+
const ids = epicIds(raw, root);
|
|
94
|
+
const hit = ids.find((id) => id.toLowerCase() === String(arg).toLowerCase());
|
|
95
|
+
if (!hit)
|
|
96
|
+
return {
|
|
97
|
+
path,
|
|
98
|
+
refusal: {
|
|
99
|
+
code: "UNKNOWN_EPIC",
|
|
100
|
+
message: `no epic ${arg}${ids.length ? ` — epics: ${ids.join(", ")}` : " — the vault has no epics"}`,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
next.epic = hit;
|
|
104
|
+
} else if (sub === "epic") delete next.epic;
|
|
105
|
+
else return { path, refusal: { code: "USAGE", message: `band takes ${SUBCOMMANDS.join(", ")}` } };
|
|
106
|
+
// Every write remembers that the person has used the command: the hint goes.
|
|
107
|
+
next.hint_seen = true;
|
|
108
|
+
const after = { ...raw, band: next };
|
|
109
|
+
const changed = JSON.stringify(raw.band ?? null) !== JSON.stringify(next);
|
|
110
|
+
return { path, before, after: bandKeys(after), changed, content: JSON.stringify(after, null, 2) + "\n" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function applyBand(plan) {
|
|
114
|
+
if (plan.refusal || !plan.changed) return { wrote: false };
|
|
115
|
+
writeFileAtomic(plan.path, plan.content);
|
|
116
|
+
return { wrote: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const REASONS = Object.freeze({
|
|
120
|
+
harness: "this harness has no band",
|
|
121
|
+
off: "switched off (projectstore band on)",
|
|
122
|
+
session_unknown:
|
|
123
|
+
"this session is unknown here (no session id from the host), so neither its epic nor its heartbeat can be read",
|
|
124
|
+
pin_missing: "the pinned epic no longer exists",
|
|
125
|
+
no_epic: "this session has no epic, and none is pinned",
|
|
126
|
+
not_drawing:
|
|
127
|
+
"the module is not drawing (modules switched off by the host, an older Claude Code, or a crashed module)",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Why the band is or is not drawing in this session, read-only. The session
|
|
131
|
+
// comes from the host's variable; without it the session is unknown and the
|
|
132
|
+
// pointer and heartbeat cannot be read.
|
|
133
|
+
export function bandStatus(projectDir, cfg, { env = process.env, now = Date.now(), root } = {}) {
|
|
134
|
+
const harness = activeHarness(env);
|
|
135
|
+
const keys = bandKeys(cfg);
|
|
136
|
+
const sessionId = hostSessionId(env, harness);
|
|
137
|
+
let sessionEpic = null;
|
|
138
|
+
let heartbeat = "none";
|
|
139
|
+
if (sessionId) {
|
|
140
|
+
try {
|
|
141
|
+
const p = JSON.parse(readFileSync(sessionStatePath(projectDir, sessionId), "utf8"));
|
|
142
|
+
if (p && isEpicName(p.active_epic)) sessionEpic = p.active_epic;
|
|
143
|
+
} catch {}
|
|
144
|
+
const name = heartbeatFile(sessionId);
|
|
145
|
+
if (name) {
|
|
146
|
+
try {
|
|
147
|
+
heartbeat = heartbeatState(JSON.parse(readFileSync(join(stateDir(projectDir), name), "utf8")), now);
|
|
148
|
+
} catch {
|
|
149
|
+
heartbeat = "none";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const ids = epicIds(cfg, root);
|
|
154
|
+
const pinExists = keys.epic ? ids.includes(keys.epic) : null;
|
|
155
|
+
const drawing = heartbeat === "drawing";
|
|
156
|
+
let reason = null;
|
|
157
|
+
if (!drawing) {
|
|
158
|
+
if (!hasBand(harness)) reason = "harness";
|
|
159
|
+
else if (!keys.enabled) reason = "off";
|
|
160
|
+
else if (!sessionId) reason = "session_unknown";
|
|
161
|
+
else if (!sessionEpic && keys.epic && !pinExists) reason = "pin_missing";
|
|
162
|
+
else if (!sessionEpic && !keys.epic) reason = "no_epic";
|
|
163
|
+
else reason = "not_drawing";
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
harness: harness?.id ?? null,
|
|
167
|
+
enabled: keys.enabled,
|
|
168
|
+
pin: keys.epic,
|
|
169
|
+
pin_exists: pinExists,
|
|
170
|
+
session_id: sessionId,
|
|
171
|
+
session_epic: sessionEpic,
|
|
172
|
+
heartbeat,
|
|
173
|
+
drawing,
|
|
174
|
+
yielding: drawing,
|
|
175
|
+
reason,
|
|
176
|
+
reason_text: reason ? REASONS[reason] : null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function renderBandStatus(s) {
|
|
181
|
+
const lines = [
|
|
182
|
+
`band: ${s.enabled ? "on" : "off"}${s.pin ? ` · pinned ${s.pin}${s.pin_exists ? "" : " (missing)"}` : ""}`,
|
|
183
|
+
`session: ${s.session_id ? `${s.session_id.slice(0, 8)}… · ${s.session_epic ? `epic ${s.session_epic}` : "no epic of its own"}` : "session unknown"}`,
|
|
184
|
+
`heartbeat: ${s.heartbeat} · the status line ${s.yielding ? "is giving way to the band" : "shows its own line"}`,
|
|
185
|
+
];
|
|
186
|
+
if (s.reason_text) lines.push(`not drawing: ${s.reason_text}`);
|
|
187
|
+
return lines.join("\n") + "\n";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function renderBandPlan(plan, sub, arg) {
|
|
191
|
+
const what =
|
|
192
|
+
sub === "on"
|
|
193
|
+
? "switch the band on"
|
|
194
|
+
: sub === "off"
|
|
195
|
+
? "switch the band off"
|
|
196
|
+
: arg
|
|
197
|
+
? `pin the band to ${plan.after.epic}`
|
|
198
|
+
: "release the pinned epic";
|
|
199
|
+
return (
|
|
200
|
+
[
|
|
201
|
+
`band ${sub}${arg ? ` ${arg}` : ""} — ${plan.path}`,
|
|
202
|
+
` ${what}`,
|
|
203
|
+
` enabled: ${plan.before.enabled ? "on" : "off"} → ${plan.after.enabled ? "on" : "off"}`,
|
|
204
|
+
` pinned: ${plan.before.epic || "—"} → ${plan.after.epic || "—"}`,
|
|
205
|
+
].join("\n") + "\n"
|
|
206
|
+
);
|
|
207
|
+
}
|