projectstore-claude 0.29.3 → 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/bin/projectstore-claude.mjs +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/README.md +17 -0
- package/node_modules/projectstore/commands/bind.md +6 -17
- package/node_modules/projectstore/commands/doctor.md +12 -7
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/commands/story.md +15 -2
- package/node_modules/projectstore/docs/how-it-works.md +5 -3
- package/node_modules/projectstore/harnesses/claude-code.json +5 -5
- package/node_modules/projectstore/harnesses/codex.json +4 -5
- package/node_modules/projectstore/hooks/band.tsx +48 -20
- 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 +50 -18
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +11 -2
- package/node_modules/projectstore/scripts/band-shared.mjs +141 -24
- package/node_modules/projectstore/scripts/band.mjs +72 -21
- package/node_modules/projectstore/scripts/binding.mjs +616 -85
- package/node_modules/projectstore/scripts/build-adapters.mjs +73 -29
- package/node_modules/projectstore/scripts/cli.mjs +1015 -185
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +253 -83
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +91 -23
- package/node_modules/projectstore/scripts/doctor.mjs +1317 -371
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +106 -20
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +123 -48
- package/node_modules/projectstore/scripts/install-harness.mjs +3511 -616
- package/node_modules/projectstore/scripts/kanban.mjs +2 -1
- package/node_modules/projectstore/scripts/lib.mjs +694 -159
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +224 -51
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +378 -80
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +62 -17
- package/node_modules/projectstore/scripts/scaffold.mjs +89 -28
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +5 -1
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +32 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +416 -95
- package/node_modules/projectstore/scripts/term.mjs +382 -97
- 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 +74 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- package/package.json +2 -2
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// open. Bounded and O(1) in vault size by construction.
|
|
14
14
|
|
|
15
15
|
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
|
|
16
|
-
import {
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
17
|
import {
|
|
18
18
|
readConfig,
|
|
19
19
|
gatherVaultFacts,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
ensureStateRoot,
|
|
31
31
|
syncStatusLine,
|
|
32
32
|
cleanupStaleSessionState,
|
|
33
|
+
refreshStoryPointer,
|
|
33
34
|
armReminder,
|
|
34
35
|
truncEnd,
|
|
35
36
|
truncFront,
|
|
@@ -64,9 +65,10 @@ function welcomedMarkerWritePath(proj) {
|
|
|
64
65
|
// the one instruction it carries branches on the binding; everything else is
|
|
65
66
|
// the same message.
|
|
66
67
|
function buildWelcome(cfg = null) {
|
|
67
|
-
const start =
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const start =
|
|
69
|
+
cfg && cfg.vault_path
|
|
70
|
+
? `**Already bound**: this project's vault is \`${truncFront(String(cfg.vault_path), PATH_CELL)}\`. Ask for what you want — the agent picks up commands like \`${commandForm("adr")}\` and \`${commandForm("epic")}\` from the conversation, and you approve every write. If the vault has no folders yet, \`${commandForm("scaffold")}\` lays them out.`
|
|
71
|
+
: `**To start using it**: run \`${commandForm("bind", { args: "<vault-path>" })}\` and point it at an Obsidian vault (or any folder). After that, the agent will pick up commands like \`${commandForm("adr")}\` and \`${commandForm("epic")}\` from the conversation; you only approve the writes.`;
|
|
70
72
|
return [
|
|
71
73
|
"# 👋 projectstore is loaded for the first time in this project",
|
|
72
74
|
"",
|
|
@@ -178,15 +180,25 @@ async function main() {
|
|
|
178
180
|
// an unbound one that is not a worktree pays a single ~10 ms probe.
|
|
179
181
|
let binding = null;
|
|
180
182
|
if (!cfg) {
|
|
181
|
-
try {
|
|
183
|
+
try {
|
|
184
|
+
binding = resolveBinding(proj);
|
|
185
|
+
} catch {}
|
|
182
186
|
}
|
|
183
187
|
const welcome = showWelcomeOnce(proj, cfg);
|
|
184
188
|
// The person's channel carries one instruction, and it has to be the right
|
|
185
|
-
// one: a bound project is told what it is bound to, not to bind again.
|
|
189
|
+
// one: a bound project is told what it is bound to, not to bind again. A
|
|
190
|
+
// command stands alone on its line, the update hint on lines of its own
|
|
191
|
+
// after it (the story "User-facing text says where to run…", rules 4 and 5).
|
|
186
192
|
const welcomeSystemMessage = welcome
|
|
187
|
-
?
|
|
193
|
+
? cfg && cfg.vault_path
|
|
188
194
|
? `👋 projectstore: first-run welcome shown. Bound to ${cfg.vault_path}. ${updateHint().line}`
|
|
189
|
-
:
|
|
195
|
+
: [
|
|
196
|
+
"👋 projectstore: first-run welcome shown. Start with:",
|
|
197
|
+
commandForm("bind", { args: "<vault-path>" }),
|
|
198
|
+
updateHint().line,
|
|
199
|
+
]
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join("\n")
|
|
190
202
|
: null;
|
|
191
203
|
|
|
192
204
|
if (!cfg) {
|
|
@@ -196,8 +208,10 @@ async function main() {
|
|
|
196
208
|
// whose parent is already bound. Ordering is the cheap fix; the small
|
|
197
209
|
// redundancy is accepted rather than papered over with copy that will rot.
|
|
198
210
|
const offer = binding && binding.state === "inheritable" ? bindingOfferText(binding) : "";
|
|
211
|
+
// What binding does first, then the command alone on its line: an
|
|
212
|
+
// in-session form that can carry the parent's vault path (rule 4).
|
|
199
213
|
const offerSystemMessage = offer
|
|
200
|
-
? `projectstore: this worktree is unbound
|
|
214
|
+
? `projectstore: this worktree is unbound. ${bindInherits() ? "Adopt the binding of" : "Bind it to the vault, layout and language of"} the checkout it was forked from, in the session:\n${inheritForm(binding.vaultPath, { layout: binding.layout, language: binding.language })}`
|
|
201
215
|
: null;
|
|
202
216
|
const body = offer + (welcome || "");
|
|
203
217
|
if (body) {
|
|
@@ -213,10 +227,23 @@ async function main() {
|
|
|
213
227
|
// Opt-in status line: keep settings.local.json pointed at this plugin
|
|
214
228
|
// version's statusline.mjs (self-heals on update). Best-effort; a settings
|
|
215
229
|
// write must never break session-context injection.
|
|
216
|
-
try {
|
|
230
|
+
try {
|
|
231
|
+
syncStatusLine(cfg, proj);
|
|
232
|
+
} catch {}
|
|
217
233
|
// Statusline-feature housekeeping, like syncStatusLine — must run even when
|
|
218
234
|
// auto_inject=false (touch-session writes pointers regardless of it).
|
|
219
|
-
try {
|
|
235
|
+
try {
|
|
236
|
+
cleanupStaleSessionState(proj);
|
|
237
|
+
} catch {}
|
|
238
|
+
// This session's story fields from the story file, once (ADR-006, amended
|
|
239
|
+
// 2026-10-12): a pointer written before a close, or a story closed elsewhere
|
|
240
|
+
// since, stops naming finished work. A bounded read; a lost race writes
|
|
241
|
+
// nothing.
|
|
242
|
+
if (sid) {
|
|
243
|
+
try {
|
|
244
|
+
await refreshStoryPointer(proj, cfg.vault_path, sid);
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
220
247
|
|
|
221
248
|
// Arm BEFORE the auto_inject gate. The entry reminder's markers must be
|
|
222
249
|
// re-armed after a compaction whether or not this session injects context —
|
|
@@ -231,7 +258,9 @@ async function main() {
|
|
|
231
258
|
// context while its marker persists on disk. Arming lets it fire once more;
|
|
232
259
|
// the cap of two is enforced by the election, not here.
|
|
233
260
|
if (sid && (input?.source === "compact" || input?.source === "clear")) {
|
|
234
|
-
try {
|
|
261
|
+
try {
|
|
262
|
+
armReminder(proj, sid);
|
|
263
|
+
} catch {}
|
|
235
264
|
}
|
|
236
265
|
|
|
237
266
|
if (cfg.auto_inject === false) {
|
|
@@ -272,23 +301,26 @@ async function main() {
|
|
|
272
301
|
}
|
|
273
302
|
}
|
|
274
303
|
|
|
275
|
-
// Cheap install-only doctor subset (ADR-005): one
|
|
276
|
-
// aborted past its budget rather than reporting a false "clean".
|
|
304
|
+
// Cheap install-only doctor subset (ADR-005): one part, only when N > 0;
|
|
305
|
+
// aborted past its budget rather than reporting a false "clean". Its doctor
|
|
306
|
+
// form stands alone on its line, as every command of this line does.
|
|
277
307
|
let doctorMsg = null;
|
|
278
308
|
let offers = [];
|
|
279
309
|
try {
|
|
280
310
|
const r = runStartupChecks(cfg, proj);
|
|
281
311
|
if (r.skipped) {
|
|
282
|
-
doctorMsg = `projectstore doctor: startup checks skipped
|
|
312
|
+
doctorMsg = `projectstore doctor: startup checks skipped. Run them in the session:\n${commandForm("doctor")}`;
|
|
283
313
|
} else if (r.count > 0) {
|
|
284
|
-
doctorMsg = `projectstore doctor: ${r.count} install issue(s)
|
|
314
|
+
doctorMsg = `projectstore doctor: ${r.count} install issue(s). See them in the session:\n${commandForm("doctor")}`;
|
|
285
315
|
}
|
|
286
316
|
// Offers (doctor's OFFER_CHECKS): one-time steps a user should see once,
|
|
287
317
|
// e.g. the re-stamp after a plugin update — not issues, not silent.
|
|
288
318
|
offers = (r.offers || []).map((m) => `projectstore: ${m}`);
|
|
289
319
|
} catch {}
|
|
290
|
-
|
|
291
|
-
|
|
320
|
+
// Each part on lines of its own: an offer ends with its command alone on
|
|
321
|
+
// its line, and a part joined after it with ` · ` would put text after the
|
|
322
|
+
// command (the story "User-facing text says where to run…", rule 4).
|
|
323
|
+
const systemMessage = [welcomeSystemMessage, doctorMsg, ...offers].filter(Boolean).join("\n") || null;
|
|
292
324
|
|
|
293
325
|
if (gatherError) {
|
|
294
326
|
emit(
|
|
@@ -71,12 +71,14 @@ async function main() {
|
|
|
71
71
|
|
|
72
72
|
if (!electEmitter(proj, sid)) return;
|
|
73
73
|
appendEntryLog(proj, { at: new Date().toISOString(), session_id: sid, score, via: "Stop" });
|
|
74
|
-
process.stdout.write(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
process.stdout.write(
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
hookSpecificOutput: {
|
|
77
|
+
hookEventName: "Stop",
|
|
78
|
+
additionalContext: entryReminderText(score),
|
|
79
|
+
},
|
|
80
|
+
}) + "\n",
|
|
81
|
+
);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
main().catch(() => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.4",
|
|
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",
|
|
@@ -66,6 +66,15 @@
|
|
|
66
66
|
"shells": "node packaging/shells.mjs --write",
|
|
67
67
|
"shells:check": "node packaging/shells.mjs --check",
|
|
68
68
|
"shells:build": "node packaging/shells.mjs --build --out dist",
|
|
69
|
-
"release:check": "npm run adapters:check && npm run guard && npm run shells:check && npm test && npm pack --dry-run"
|
|
69
|
+
"release:check": "npm run adapters:check && npm run guard && npm run shells:check && npm run lint && npm run format:check && npm test && npm pack --dry-run",
|
|
70
|
+
"lint": "eslint --max-warnings 0 bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs",
|
|
71
|
+
"format": "prettier --write bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs",
|
|
72
|
+
"format:check": "prettier --check bin scripts \"hooks/*.mjs\" \"packaging/**/*.mjs\" \"tests/**/*.mjs\" eslint.config.mjs"
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@eslint/js": "10.0.1",
|
|
76
|
+
"eslint": "10.12.0",
|
|
77
|
+
"eslint-plugin-n": "18.4.1",
|
|
78
|
+
"prettier": "3.9.10"
|
|
70
79
|
}
|
|
71
80
|
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// Spec: the band above the prompt — rows, heartbeat, command and the status
|
|
12
12
|
// line's yield (the contract numbers below are that spec's).
|
|
13
13
|
|
|
14
|
+
import { stageOf } from "./stages.mjs";
|
|
15
|
+
|
|
14
16
|
// ─── The heartbeat (contracts 10, 11) ──────────────────────────────────
|
|
15
17
|
// While a session's band draws, the module keeps a small file fresh in the
|
|
16
18
|
// session-state directory; the status line yields its own line only while
|
|
@@ -25,7 +27,7 @@ export const HEARTBEAT_WINDOW_MS = 30_000;
|
|
|
25
27
|
// as one. Null for an id that cannot name a file.
|
|
26
28
|
export function heartbeatFile(sessionId) {
|
|
27
29
|
if (typeof sessionId !== "string" || !sessionId) return null;
|
|
28
|
-
if (/[
|
|
30
|
+
if (/[/\\\0]/.test(sessionId) || sessionId === "." || sessionId === "..") return null;
|
|
29
31
|
return `.band-${sessionId}.json`;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -63,13 +65,34 @@ export function heartbeatNext(lastRender, wasDrawing, now) {
|
|
|
63
65
|
// templates/<lang>/strings.json, the English file the fallback per key.
|
|
64
66
|
|
|
65
67
|
export const BAND_STRING_KEYS = Object.freeze([
|
|
66
|
-
"band_this_session",
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
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",
|
|
73
96
|
]);
|
|
74
97
|
|
|
75
98
|
// The band's words for a language: the English file's, each overlaid by the
|
|
@@ -140,20 +163,54 @@ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
|
140
163
|
export async function storyFiles(fs, epicDir) {
|
|
141
164
|
const out = [];
|
|
142
165
|
const stories = `${epicDir}/stories`;
|
|
143
|
-
let entries
|
|
144
|
-
try {
|
|
166
|
+
let entries;
|
|
167
|
+
try {
|
|
168
|
+
entries = await fs.list(stories);
|
|
169
|
+
} catch {
|
|
170
|
+
entries = [];
|
|
171
|
+
}
|
|
145
172
|
for (const e of [...entries].sort(byName)) {
|
|
146
173
|
if (e.kind === "file" && e.name.endsWith(".md")) out.push(`${stories}/${e.name}`);
|
|
147
|
-
else if (e.kind === "dir" && (await fs.exists(`${stories}/${e.name}/README.md`)))
|
|
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 = [];
|
|
148
182
|
}
|
|
149
|
-
let beside = [];
|
|
150
|
-
try { beside = await fs.list(epicDir); } catch { beside = []; }
|
|
151
183
|
for (const e of [...beside].sort(byName)) {
|
|
152
184
|
if (e.kind === "file" && e.name.startsWith("story-") && e.name.endsWith(".md")) out.push(`${epicDir}/${e.name}`);
|
|
153
185
|
}
|
|
154
186
|
return out;
|
|
155
187
|
}
|
|
156
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
|
+
|
|
157
214
|
// An epic id the band may put in a path: one segment the board would list
|
|
158
215
|
// (not hidden, not a `_templates`-style folder).
|
|
159
216
|
export function isEpicName(id) {
|
|
@@ -165,12 +222,26 @@ export function isEpicName(id) {
|
|
|
165
222
|
// marks and variation selectors none.
|
|
166
223
|
|
|
167
224
|
const WIDE = [
|
|
168
|
-
[0x1100, 0x115f],
|
|
169
|
-
[
|
|
170
|
-
[
|
|
171
|
-
[
|
|
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],
|
|
172
244
|
];
|
|
173
|
-
const ZERO = [[0x0300, 0x036f], [0x200b, 0x200f], [0xfe00, 0xfe0f]];
|
|
174
245
|
const within = (cp, ranges) => ranges.some(([a, b]) => cp >= a && cp <= b);
|
|
175
246
|
|
|
176
247
|
export function cellWidth(text) {
|
|
@@ -228,6 +299,38 @@ export function progressLayout(counts, hint, bodyColumns) {
|
|
|
228
299
|
return { bar: room >= BAR_MIN ? Math.min(BAR_MAX, room) : 0, hint: false };
|
|
229
300
|
}
|
|
230
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
|
+
|
|
231
334
|
// ─── The agent row (contract 12) ───────────────────────────────────────
|
|
232
335
|
|
|
233
336
|
export const AGENT_PREFIX = "projectstore:";
|
|
@@ -249,7 +352,8 @@ export function subjectPath(prompt, vault, kindFolders) {
|
|
|
249
352
|
const text = String(prompt || "");
|
|
250
353
|
const found = [];
|
|
251
354
|
if (vault) {
|
|
252
|
-
for (const m of text.matchAll(new RegExp(`${escRe(vault)}/([^\\s\`'")\\]]+?\\.md)`, "g")))
|
|
355
|
+
for (const m of text.matchAll(new RegExp(`${escRe(vault)}/([^\\s\`'")\\]]+?\\.md)`, "g")))
|
|
356
|
+
found.push({ at: m.index, rel: m[1] });
|
|
253
357
|
}
|
|
254
358
|
const kinds = (kindFolders || []).filter(Boolean).map(escRe);
|
|
255
359
|
if (kinds.length) {
|
|
@@ -283,9 +387,14 @@ const vaultRel = (vault, p) => (vault && p.startsWith(`${vault}/`) ? p.slice(vau
|
|
|
283
387
|
|
|
284
388
|
// `node /x/reconcile.mjs --a` reads `reconcile`, `git diff --stat` reads `git diff`.
|
|
285
389
|
export function commandWord(command) {
|
|
286
|
-
const words = String(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));
|
|
287
395
|
const [cmd = "", arg = ""] = words.map((x) => x.replace(/^["']|["']$/g, ""));
|
|
288
|
-
if (/^(node|python3?|bash|sh|npx|bun|deno|tsx)$/.test(lastSegment(cmd)) && arg && !arg.startsWith("-"))
|
|
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)$/, "");
|
|
289
398
|
return /^[a-z][\w:-]*$/.test(arg) ? `${lastSegment(cmd)} ${arg}` : lastSegment(cmd);
|
|
290
399
|
}
|
|
291
400
|
|
|
@@ -295,10 +404,16 @@ const QUIET = /^(echo|printf|true|sleep|:)(\s|$)/;
|
|
|
295
404
|
// nothing worth a row (an echo, a tool the band does not name).
|
|
296
405
|
export function stepOf(call, vault, w) {
|
|
297
406
|
const tool = typeof call.tool === "string" ? call.tool : "";
|
|
298
|
-
const path =
|
|
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;
|
|
299
413
|
if (tool === "Read" && path) return `${w.band_step_reads} ${vaultRel(vault, path)}`;
|
|
300
414
|
if (tool === "Write" && path) return `${w.band_step_writes} ${vaultRel(vault, path)}`;
|
|
301
|
-
if ((tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") && path)
|
|
415
|
+
if ((tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") && path)
|
|
416
|
+
return `${w.band_step_edits} ${vaultRel(vault, path)}`;
|
|
302
417
|
if (tool === "Grep" || tool === "Glob") {
|
|
303
418
|
if (typeof call.pattern !== "string") return w.band_step_searches;
|
|
304
419
|
return `${w.band_step_searches} ${call.pattern.length > 32 ? call.pattern.slice(0, 31) + "…" : call.pattern}`;
|
|
@@ -323,7 +438,9 @@ export function leadAgent(agents, now) {
|
|
|
323
438
|
const order = new Map(live.map((a, i) => [a, i]));
|
|
324
439
|
const later = (a, b) => order.get(b) - order.get(a);
|
|
325
440
|
const running = live.filter((a) => a.status === "running").sort((a, b) => b.startedAt - a.startedAt || later(a, b));
|
|
326
|
-
const ended = live
|
|
441
|
+
const ended = live
|
|
442
|
+
.filter((a) => a.status !== "running")
|
|
443
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || later(a, b));
|
|
327
444
|
const lead = running[0] || ended[0] || null;
|
|
328
445
|
return { lead, others: lead ? live.length - 1 : 0, live };
|
|
329
446
|
}
|
|
@@ -31,20 +31,38 @@ export function bandKeys(cfg) {
|
|
|
31
31
|
export function epicIds(cfg, root) {
|
|
32
32
|
if (!cfg || !cfg.vault_path) return [];
|
|
33
33
|
let folder;
|
|
34
|
-
try {
|
|
34
|
+
try {
|
|
35
|
+
folder = folderByKind(loadLayout(cfg.layout || "engineering", root), "epic");
|
|
36
|
+
} catch {
|
|
37
|
+
folder = null;
|
|
38
|
+
}
|
|
35
39
|
if (!folder) return [];
|
|
36
40
|
const dir = join(cfg.vault_path, folder.path);
|
|
37
|
-
let names
|
|
38
|
-
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
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();
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
function readBinding(path) {
|
|
46
60
|
if (!existsSync(path)) return { raw: null, corrupt: false };
|
|
47
|
-
try {
|
|
61
|
+
try {
|
|
62
|
+
return { raw: JSON.parse(readFileSync(path, "utf8")), corrupt: false };
|
|
63
|
+
} catch {
|
|
64
|
+
return { raw: null, corrupt: true };
|
|
65
|
+
}
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
// What a write would do: the binding's path, its band keys before and after,
|
|
@@ -53,9 +71,20 @@ function readBinding(path) {
|
|
|
53
71
|
export function planBand(projectDir, sub, arg, { env = process.env, root } = {}) {
|
|
54
72
|
const path = harnessConfigPath(projectDir, env);
|
|
55
73
|
const { raw, corrupt } = readBinding(path);
|
|
56
|
-
if (corrupt)
|
|
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
|
+
};
|
|
57
82
|
// A bare `{ band: {} }` file would read as a binding: never create one.
|
|
58
|
-
if (!raw)
|
|
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
|
+
};
|
|
59
88
|
const before = bandKeys(raw);
|
|
60
89
|
const next = { ...(raw.band && typeof raw.band === "object" ? raw.band : {}) };
|
|
61
90
|
if (sub === "on") next.enabled = true;
|
|
@@ -63,7 +92,14 @@ export function planBand(projectDir, sub, arg, { env = process.env, root } = {})
|
|
|
63
92
|
else if (sub === "epic" && arg) {
|
|
64
93
|
const ids = epicIds(raw, root);
|
|
65
94
|
const hit = ids.find((id) => id.toLowerCase() === String(arg).toLowerCase());
|
|
66
|
-
if (!hit)
|
|
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
|
+
};
|
|
67
103
|
next.epic = hit;
|
|
68
104
|
} else if (sub === "epic") delete next.epic;
|
|
69
105
|
else return { path, refusal: { code: "USAGE", message: `band takes ${SUBCOMMANDS.join(", ")}` } };
|
|
@@ -83,10 +119,12 @@ export function applyBand(plan) {
|
|
|
83
119
|
const REASONS = Object.freeze({
|
|
84
120
|
harness: "this harness has no band",
|
|
85
121
|
off: "switched off (projectstore band on)",
|
|
86
|
-
session_unknown:
|
|
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",
|
|
87
124
|
pin_missing: "the pinned epic no longer exists",
|
|
88
125
|
no_epic: "this session has no epic, and none is pinned",
|
|
89
|
-
not_drawing:
|
|
126
|
+
not_drawing:
|
|
127
|
+
"the module is not drawing (modules switched off by the host, an older Claude Code, or a crashed module)",
|
|
90
128
|
});
|
|
91
129
|
|
|
92
130
|
// Why the band is or is not drawing in this session, read-only. The session
|
|
@@ -105,7 +143,11 @@ export function bandStatus(projectDir, cfg, { env = process.env, now = Date.now(
|
|
|
105
143
|
} catch {}
|
|
106
144
|
const name = heartbeatFile(sessionId);
|
|
107
145
|
if (name) {
|
|
108
|
-
try {
|
|
146
|
+
try {
|
|
147
|
+
heartbeat = heartbeatState(JSON.parse(readFileSync(join(stateDir(projectDir), name), "utf8")), now);
|
|
148
|
+
} catch {
|
|
149
|
+
heartbeat = "none";
|
|
150
|
+
}
|
|
109
151
|
}
|
|
110
152
|
}
|
|
111
153
|
const ids = epicIds(cfg, root);
|
|
@@ -146,11 +188,20 @@ export function renderBandStatus(s) {
|
|
|
146
188
|
}
|
|
147
189
|
|
|
148
190
|
export function renderBandPlan(plan, sub, arg) {
|
|
149
|
-
const what =
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
);
|
|
156
207
|
}
|