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.
Files changed (76) hide show
  1. package/README.md +1 -1
  2. package/bin/projectstore-claude.mjs +1 -1
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  5. package/node_modules/projectstore/README.md +44 -7
  6. package/node_modules/projectstore/commands/bar.md +30 -0
  7. package/node_modules/projectstore/commands/bind.md +16 -20
  8. package/node_modules/projectstore/commands/doctor.md +12 -7
  9. package/node_modules/projectstore/commands/scaffold.md +24 -16
  10. package/node_modules/projectstore/commands/status.md +1 -1
  11. package/node_modules/projectstore/commands/statusline.md +2 -2
  12. package/node_modules/projectstore/commands/story.md +15 -2
  13. package/node_modules/projectstore/docs/alongside.md +167 -0
  14. package/node_modules/projectstore/docs/extending.md +17 -3
  15. package/node_modules/projectstore/docs/getting-started.md +18 -9
  16. package/node_modules/projectstore/docs/harnesses.md +1 -1
  17. package/node_modules/projectstore/docs/how-it-works.md +12 -3
  18. package/node_modules/projectstore/harnesses/claude-code.json +23 -8
  19. package/node_modules/projectstore/harnesses/codex.json +16 -6
  20. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  21. package/node_modules/projectstore/hooks/band.tsx +622 -0
  22. package/node_modules/projectstore/hooks/hooks.json +1 -0
  23. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  24. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  25. package/node_modules/projectstore/hooks/session-start.mjs +56 -20
  26. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  27. package/node_modules/projectstore/package.json +12 -2
  28. package/node_modules/projectstore/scaffold/headings.json +8 -0
  29. package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
  30. package/node_modules/projectstore/scripts/band.mjs +207 -0
  31. package/node_modules/projectstore/scripts/binding.mjs +733 -62
  32. package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
  33. package/node_modules/projectstore/scripts/cli.mjs +1463 -267
  34. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  35. package/node_modules/projectstore/scripts/convergence.mjs +638 -0
  36. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  37. package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
  38. package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
  39. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  40. package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
  41. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  42. package/node_modules/projectstore/scripts/harness.mjs +283 -37
  43. package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
  44. package/node_modules/projectstore/scripts/kanban.mjs +6 -21
  45. package/node_modules/projectstore/scripts/lib.mjs +933 -199
  46. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  47. package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
  48. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  49. package/node_modules/projectstore/scripts/query.mjs +441 -72
  50. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  51. package/node_modules/projectstore/scripts/run-host.mjs +139 -0
  52. package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
  53. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  54. package/node_modules/projectstore/scripts/stages.mjs +84 -0
  55. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  56. package/node_modules/projectstore/scripts/statusline.mjs +55 -14
  57. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  58. package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
  59. package/node_modules/projectstore/scripts/term.mjs +1027 -52
  60. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  61. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  62. package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
  63. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  64. package/node_modules/projectstore/templates/de/strings.json +63 -1
  65. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  66. package/node_modules/projectstore/templates/en/strings.json +63 -1
  67. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  68. package/node_modules/projectstore/templates/es/strings.json +63 -1
  69. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  70. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  71. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  72. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  73. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  74. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  75. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  76. package/package.json +2 -2
@@ -0,0 +1,84 @@
1
+ // projectstore — stages.mjs: how a story's status reads on the board.
2
+ //
3
+ // One table for two readers: the kanban board (scripts/kanban.mjs) and the
4
+ // band above the prompt (hooks/band.tsx), which counts an epic's stories by
5
+ // the same rule so the band and kanban.md never disagree. The band is a Claude
6
+ // Code hooks module and runs with no Node, so this file imports nothing at
7
+ // all — keep it that way.
8
+
9
+ // Statuses that describe work which is not on the board by definition: it was
10
+ // replaced, postponed, or set aside. Putting them in a column would claim the
11
+ // work is queued, which is the opposite of what the status says.
12
+ export const NOT_ACTIONABLE = new Set(["superseded", "deferred", "parked"]);
13
+
14
+ // A lower-cased status to its board column. Exact match; anything the table
15
+ // does not name is Backlog.
16
+ export function statusToColumn(status) {
17
+ const m = {
18
+ planned: "Backlog",
19
+ todo: "ToDo",
20
+ "to-do": "ToDo",
21
+ "in-progress": "In Progress",
22
+ in_progress: "In Progress",
23
+ "in progress": "In Progress",
24
+ review: "Review",
25
+ done: "Done",
26
+ closed: "Done",
27
+ };
28
+ return m[status] || "Backlog";
29
+ }
30
+
31
+ // The band's three stages, folded from the board's columns.
32
+ export const COLUMN_STAGE = Object.freeze({
33
+ Done: "done",
34
+ "In Progress": "active",
35
+ Review: "active",
36
+ ToDo: "backlog",
37
+ Backlog: "backlog",
38
+ });
39
+
40
+ // A story's stage from its raw frontmatter status, as the board reads it:
41
+ // lower-cased; no status, or a status the board leaves off, counts nowhere
42
+ // (null).
43
+ export function stageOf(raw) {
44
+ if (raw === null || raw === undefined || raw === "") return null;
45
+ const status = String(raw).toLowerCase();
46
+ if (NOT_ACTIONABLE.has(status)) return null;
47
+ return COLUMN_STAGE[statusToColumn(status)] || "backlog";
48
+ }
49
+
50
+ // Raw statuses → { done, active, backlog }.
51
+ export function foldStages(statuses) {
52
+ const out = { done: 0, active: 0, backlog: 0 };
53
+ for (const raw of statuses) {
54
+ const stage = stageOf(raw);
55
+ if (stage) out[stage]++;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // Line-based frontmatter: `key: value` scalars between the opening `---` and
61
+ // the next one. Quoted values are JSON-decoded (titles that hold quotes),
62
+ // `null` is null; block sequences are invisible to it.
63
+ export function parseFrontmatter(md) {
64
+ const m = md.match(/^---\n([\s\S]*?)\n---/);
65
+ if (!m) return { data: {}, body: md };
66
+ const data = {};
67
+ for (const line of m[1].split("\n")) {
68
+ const kv = line.match(/^(\w+):\s*(.*)$/);
69
+ if (!kv) continue;
70
+ let v = kv[2].trim();
71
+ if (v === "null") v = null;
72
+ else if (v.startsWith('"') && v.endsWith('"')) {
73
+ // JSON.parse round-trips escaped scalars from renderTemplate's _json
74
+ // form (titles containing quotes); fall back to the bare strip.
75
+ try {
76
+ v = JSON.parse(v);
77
+ } catch {
78
+ v = v.slice(1, -1);
79
+ }
80
+ }
81
+ data[kv[1]] = v;
82
+ }
83
+ return { data, body: md.slice(m[0].length) };
84
+ }
@@ -20,7 +20,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
20
20
  import { join, dirname } from "node:path";
21
21
  import { homedir } from "node:os";
22
22
  import { spawnSync } from "node:child_process";
23
- import { pathToFileURL, fileURLToPath } from "node:url";
23
+ import { pathToFileURL } from "node:url";
24
24
 
25
25
  // Substituted by renderStatusLineLauncher() in scripts/lib.mjs — the fallback
26
26
  // root and the harness's own variable names (this file runs before it knows
@@ -35,7 +35,9 @@ const PLUGIN_ROOT_ENV = "__PROJECTSTORE_PLUGIN_ROOT_ENV__";
35
35
  const PROJECT_DIR = "__PROJECTSTORE_PROJECT__";
36
36
 
37
37
  function versionKey(v) {
38
- const p = String(v || "0").split(".").map((n) => parseInt(n, 10) || 0);
38
+ const p = String(v || "0")
39
+ .split(".")
40
+ .map((n) => parseInt(n, 10) || 0);
39
41
  return [p[0] || 0, p[1] || 0, p[2] || 0]; // pad: a missing component must compare as 0, not NaN
40
42
  }
41
43
 
@@ -46,16 +48,24 @@ function versionKey(v) {
46
48
  function installedRoot() {
47
49
  try {
48
50
  const claudeHome = (HOME_ENV && process.env[HOME_ENV]) || join(homedir(), ".claude");
49
- const reg = JSON.parse(
50
- readFileSync(join(claudeHome, "plugins", "installed_plugins.json"), "utf8"),
51
- );
51
+ const reg = JSON.parse(readFileSync(join(claudeHome, "plugins", "installed_plugins.json"), "utf8"));
52
52
  const family = dirname(FALLBACK_ROOT); // …/<marketplace>/projectstore
53
53
  // This launcher lives at <project>/<config dir>/<runtime dir>/…: a row the
54
54
  // host recorded for THIS checkout (local scope, one per checkout) comes
55
55
  // first; another checkout's newer install is the fallback, not the pick.
56
56
  let project = null;
57
- try { project = realpathSync(PROJECT_DIR); } catch { project = PROJECT_DIR || null; }
58
- const sameProject = (p) => { try { return Boolean(project && p && realpathSync(p) === project); } catch { return false; } };
57
+ try {
58
+ project = realpathSync(PROJECT_DIR);
59
+ } catch {
60
+ project = PROJECT_DIR || null;
61
+ }
62
+ const sameProject = (p) => {
63
+ try {
64
+ return Boolean(project && p && realpathSync(p) === project);
65
+ } catch {
66
+ return false;
67
+ }
68
+ };
59
69
  const found = [];
60
70
  for (const [key, list] of Object.entries((reg && reg.plugins) || {})) {
61
71
  if (key !== "projectstore" && !key.startsWith("projectstore@")) continue;
@@ -116,12 +126,20 @@ for (const root of [installedRoot(), FALLBACK_ROOT]) {
116
126
  if (!rendered) {
117
127
  let out = null;
118
128
  try {
119
- const stdin = (() => { try { return readFileSync(0, "utf8"); } catch { return ""; } })();
129
+ const stdin = (() => {
130
+ try {
131
+ return readFileSync(0, "utf8");
132
+ } catch {
133
+ return "";
134
+ }
135
+ })();
120
136
  const projectDir = (() => {
121
137
  try {
122
138
  const i = JSON.parse(stdin) || {};
123
139
  return (i.workspace && i.workspace.project_dir) || i.cwd || null;
124
- } catch { return null; }
140
+ } catch {
141
+ return null;
142
+ }
125
143
  })();
126
144
  const claudeHome = (HOME_ENV && process.env[HOME_ENV]) || join(homedir(), ".claude");
127
145
  const candidates = [
@@ -130,11 +148,18 @@ if (!rendered) {
130
148
  ].filter(Boolean);
131
149
  for (const p of candidates) {
132
150
  let cmd = null;
133
- try { cmd = JSON.parse(readFileSync(p, "utf8"))?.statusLine?.command; } catch { continue; }
151
+ try {
152
+ cmd = JSON.parse(readFileSync(p, "utf8"))?.statusLine?.command;
153
+ } catch {
154
+ continue;
155
+ }
134
156
  if (typeof cmd !== "string" || !cmd.trim()) continue;
135
157
  if (cmd.includes("statusline.mjs")) continue; // ours — would recurse
136
158
  const r = spawnSync(cmd, { shell: true, input: stdin, encoding: "utf8", timeout: 2000, maxBuffer: 1 << 20 });
137
- if (r.status === 0 && r.stdout && r.stdout.trim()) { out = r.stdout.replace(/\s+$/, ""); break; }
159
+ if (r.status === 0 && r.stdout && r.stdout.trim()) {
160
+ out = r.stdout.replace(/\s+$/, "");
161
+ break;
162
+ }
138
163
  }
139
164
  } catch {}
140
165
  process.stdout.write((out || "") + "\n");
@@ -18,13 +18,21 @@
18
18
  // vault reads (ADR-006):
19
19
  // 1. this session's pointer file
20
20
  // (<project>/.projectstore/state/sessions/<session_id>.json — written by
21
- // the PreToolUse hook with denormalized titles), else
21
+ // the tool hooks with denormalized titles, a write's after the write;
22
+ // by `session refresh` after a story close; and at SessionStart), else
22
23
  // 2. an explicit localized cold-start line ("no epic or story in this
23
24
  // session yet"). Never blank while enabled; a pointer that exists but
24
25
  // fails to parse renders an error-marked string, never the cheerful
25
26
  // cold-start line. The renderer drops a .last-render.json breadcrumb so
26
27
  // doctor can detect session_id divergence between hook and statusLine.
27
28
  //
29
+ // While this session's band above the prompt is drawing (hooks/band.tsx keeps
30
+ // a heartbeat fresh in the session-state directory), our line gives way to it:
31
+ // composed, the base HUD prints alone; standalone, the badge and the 📚
32
+ // segment drop. A missing, unreadable or stale heartbeat changes nothing, so
33
+ // a band that cannot draw always gives the line back (the band ADR, decision
34
+ // 5; spec contract 11).
35
+ //
28
36
  // Everything is best-effort; on any error emit what we have and exit 0.
29
37
 
30
38
  import { readFileSync, existsSync } from "node:fs";
@@ -41,6 +49,7 @@ import {
41
49
  claudeHome,
42
50
  writeFileAtomic,
43
51
  } from "./lib.mjs";
52
+ import { heartbeatFile, heartbeatYields, storyIsCurrent } from "./band-shared.mjs";
44
53
 
45
54
  const SELF = fileURLToPath(import.meta.url);
46
55
  const SEP = " · ";
@@ -53,7 +62,11 @@ const ARROW = " › ";
53
62
  process.stdout.on("error", () => process.exit(0));
54
63
 
55
64
  function readRawStdin() {
56
- try { return readFileSync(0, "utf8"); } catch { return ""; }
65
+ try {
66
+ return readFileSync(0, "utf8");
67
+ } catch {
68
+ return "";
69
+ }
57
70
  }
58
71
 
59
72
  const FALLBACK_STRINGS = {
@@ -146,13 +159,29 @@ function bookSegment(cfg, proj, input, strings) {
146
159
  if (!state || !state.active_epic) return BOOK + strings.statusline_no_work;
147
160
 
148
161
  let seg = BOOK + (state.epic_title || state.active_epic);
149
- if (state.active_story) {
162
+ // The story only while it is work the session is moving — the band's rule
163
+ // (ADR-006, amended 2026-10-12): a done, closed, parked or status-less
164
+ // story leaves the epic alone.
165
+ if (state.active_story && storyIsCurrent(state.story_status)) {
150
166
  seg += ARROW + (state.story_title || state.active_story);
151
167
  if (state.story_status) seg += ` (${state.story_status})`;
152
168
  }
153
169
  return seg;
154
170
  }
155
171
 
172
+ // Whether this session's band is drawing now. Read after bookSegment has
173
+ // written the breadcrumb, so doctor keeps its evidence while the band is up;
174
+ // any failure reads as "no band" and the line stays.
175
+ function bandYields(proj, sid) {
176
+ try {
177
+ const name = heartbeatFile(sid);
178
+ if (!name) return false;
179
+ return heartbeatYields(JSON.parse(readFileSync(join(stateDir(proj), name), "utf8")), Date.now());
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
156
185
  // The base statusLine command we compose with — highest-precedence entry BELOW
157
186
  // our own local one: project settings.json, else user settings.json.
158
187
  function discoverBaseCommand(projectDir) {
@@ -193,25 +222,38 @@ function runBase(cmd, rawStdin, env) {
193
222
 
194
223
  function main() {
195
224
  const raw = readRawStdin();
196
- let input = {};
197
- try { input = JSON.parse(raw) || {}; } catch { input = {}; }
225
+ let input;
226
+ try {
227
+ input = JSON.parse(raw) || {};
228
+ } catch {
229
+ input = {};
230
+ }
198
231
 
199
232
  // The status line gets no project variable from the harness; the project is
200
233
  // on stdin, and it deliberately beats any exported variable. Read the config
201
234
  // for that directory directly — never by mutating process.env, which the
202
235
  // composed base command would then inherit.
203
- const projectDir =
204
- (input.workspace && input.workspace.project_dir) || input.cwd || process.cwd();
236
+ const projectDir = (input.workspace && input.workspace.project_dir) || input.cwd || process.cwd();
205
237
 
206
- let cfg = null;
207
- try { cfg = readConfigAt(projectDir); } catch { cfg = null; }
238
+ let cfg;
239
+ try {
240
+ cfg = readConfigAt(projectDir);
241
+ } catch {
242
+ cfg = null;
243
+ }
208
244
 
209
245
  const strings = loadStrings(cfg && cfg.language);
210
- let book = null;
211
- try { book = cfg && cfg.vault_path ? bookSegment(cfg, projectDir, input, strings) : null; } catch { book = null; }
246
+ let book;
247
+ try {
248
+ book = cfg && cfg.vault_path ? bookSegment(cfg, projectDir, input, strings) : null;
249
+ } catch {
250
+ book = null;
251
+ }
252
+
253
+ const yielded = Boolean(cfg && cfg.vault_path && bandYields(projectDir, input.session_id));
212
254
 
213
255
  const showVersion = !(cfg && cfg.statusline && cfg.statusline.show_version === false);
214
- const ver = showVersion ? pluginVersion() : null;
256
+ const ver = showVersion && !yielded ? pluginVersion() : null;
215
257
  const badge = ver ? `[PS#${ver}] ` : "";
216
258
 
217
259
  const baseCmd = discoverBaseCommand(projectDir);
@@ -219,8 +261,7 @@ function main() {
219
261
 
220
262
  // "Never blank" must be total: even if the resolver threw (book === null on a
221
263
  // bound project), fall back to the cold-start line rather than vanishing.
222
- const bookOut =
223
- cfg && cfg.vault_path ? book || BOOK + strings.statusline_no_work : null;
264
+ const bookOut = cfg && cfg.vault_path && !yielded ? book || BOOK + strings.statusline_no_work : null;
224
265
 
225
266
  const lines = [];
226
267
  if (baseOut) {
@@ -132,9 +132,13 @@ function main() {
132
132
  const status = String(fm.status || "").toLowerCase();
133
133
 
134
134
  if (mode === "plan") {
135
- const r = insertSection(text, "implementation_plan", lang,
135
+ const r = insertSection(
136
+ text,
137
+ "implementation_plan",
138
+ lang,
136
139
  "<!-- Route through the covering spec's contracts: which contracts, in what order, which files. Filled at the gate. -->",
137
- ["decomposition", "description"]);
140
+ ["decomposition", "description"],
141
+ );
138
142
  text = r.text;
139
143
  if (r.inserted) notes.push("inserted Implementation Plan section");
140
144
  if (!["in-progress", "in_progress", "review", "done"].includes(status)) {
@@ -148,9 +152,13 @@ function main() {
148
152
  text = setFm(text, "plan_updated_at", JSON.stringify(now));
149
153
  notes.push("stamped plan_updated_at");
150
154
  } else {
151
- const r = insertSection(text, "final_summary", lang,
155
+ const r = insertSection(
156
+ text,
157
+ "final_summary",
158
+ lang,
152
159
  "<!-- What changed, why, tests executed, risks and follow-ups. Filled at the gate. -->",
153
- ["acceptance", "decomposition", "description"]);
160
+ ["acceptance", "decomposition", "description"],
161
+ );
154
162
  text = r.text;
155
163
  if (r.inserted) notes.push("inserted Final Summary section");
156
164
  if (status !== "done") {
@@ -181,28 +189,42 @@ function main() {
181
189
  // otherwise shifts every later pairing and the report drowns in
182
190
  // artifacts of the shift instead of naming the change. Line numbers are
183
191
  // post-normalization (volatile keys may have been inserted).
184
- const al = a.split("\n"), bl = b.split("\n");
192
+ const al = a.split("\n"),
193
+ bl = b.split("\n");
185
194
  let lo = 0;
186
195
  while (lo < al.length && lo < bl.length && al[lo] === bl[lo]) lo++;
187
- let hiA = al.length - 1, hiB = bl.length - 1;
188
- while (hiA > lo && hiB > lo && al[hiA] === bl[hiB]) { hiA--; hiB--; }
196
+ let hiA = al.length - 1,
197
+ hiB = bl.length - 1;
198
+ while (hiA > lo && hiB > lo && al[hiA] === bl[hiB]) {
199
+ hiA--;
200
+ hiB--;
201
+ }
189
202
  for (let i = 0; i <= Math.max(hiA, hiB) - lo && drift.length < 5; i++) {
190
- const x = al[lo + i], y = bl[lo + i];
203
+ const x = al[lo + i],
204
+ y = bl[lo + i];
191
205
  if (lo + i > hiA && lo + i > hiB) break;
192
- drift.push(`normalized line ${lo + i + 1}: ${JSON.stringify((x ?? "«absent»").slice(0, 120))} vs ${JSON.stringify((y ?? "«absent»").slice(0, 120))}`);
206
+ drift.push(
207
+ `normalized line ${lo + i + 1}: ${JSON.stringify((x ?? "«absent»").slice(0, 120))} vs ${JSON.stringify((y ?? "«absent»").slice(0, 120))}`,
208
+ );
193
209
  }
194
210
  }
195
211
  check = { baseline: resolve(checkPath), match: a === b, drift };
196
212
  }
197
213
 
198
- process.stdout.write(JSON.stringify({
199
- path: abs,
200
- mode,
201
- changed: text !== original,
202
- check,
203
- notes,
204
- content: text,
205
- }, null, 2) + "\n");
214
+ process.stdout.write(
215
+ JSON.stringify(
216
+ {
217
+ path: abs,
218
+ mode,
219
+ changed: text !== original,
220
+ check,
221
+ notes,
222
+ content: text,
223
+ },
224
+ null,
225
+ 2,
226
+ ) + "\n",
227
+ );
206
228
  }
207
229
 
208
230
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {