projectstore-codex 0.0.1 → 0.28.1

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 (185) hide show
  1. package/.codex-plugin/plugin.json +48 -0
  2. package/README.md +15 -7
  3. package/bin/projectstore-codex.mjs +88 -0
  4. package/hooks/hooks.json +59 -0
  5. package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
  6. package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
  7. package/node_modules/projectstore/.mcp.json +14 -0
  8. package/node_modules/projectstore/AGENTS.md +26 -0
  9. package/node_modules/projectstore/LICENSE +21 -0
  10. package/node_modules/projectstore/README.md +284 -0
  11. package/node_modules/projectstore/agents/archaeologist.md +76 -0
  12. package/node_modules/projectstore/agents/clerk.md +93 -0
  13. package/node_modules/projectstore/agents/critic.md +94 -0
  14. package/node_modules/projectstore/agents/librarian.md +81 -0
  15. package/node_modules/projectstore/agents/planner.md +80 -0
  16. package/node_modules/projectstore/agents/reviewer.md +98 -0
  17. package/node_modules/projectstore/bin/projectstore.mjs +7 -0
  18. package/node_modules/projectstore/commands/adr.md +57 -0
  19. package/node_modules/projectstore/commands/agents.md +180 -0
  20. package/node_modules/projectstore/commands/bind.md +128 -0
  21. package/node_modules/projectstore/commands/codemap.md +50 -0
  22. package/node_modules/projectstore/commands/concept.md +17 -0
  23. package/node_modules/projectstore/commands/doctor.md +166 -0
  24. package/node_modules/projectstore/commands/epic.md +40 -0
  25. package/node_modules/projectstore/commands/graph.md +56 -0
  26. package/node_modules/projectstore/commands/kanban.md +40 -0
  27. package/node_modules/projectstore/commands/meeting.md +17 -0
  28. package/node_modules/projectstore/commands/reconcile.md +73 -0
  29. package/node_modules/projectstore/commands/research.md +17 -0
  30. package/node_modules/projectstore/commands/review.md +89 -0
  31. package/node_modules/projectstore/commands/runbook.md +17 -0
  32. package/node_modules/projectstore/commands/scaffold.md +23 -0
  33. package/node_modules/projectstore/commands/search.md +22 -0
  34. package/node_modules/projectstore/commands/spec.md +91 -0
  35. package/node_modules/projectstore/commands/status.md +27 -0
  36. package/node_modules/projectstore/commands/statusline.md +46 -0
  37. package/node_modules/projectstore/commands/story.md +113 -0
  38. package/node_modules/projectstore/docs/extending.md +172 -0
  39. package/node_modules/projectstore/docs/getting-started.md +133 -0
  40. package/node_modules/projectstore/docs/harnesses.md +163 -0
  41. package/node_modules/projectstore/docs/how-it-works.md +263 -0
  42. package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
  43. package/node_modules/projectstore/docs/images/loop.svg +93 -0
  44. package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
  45. package/node_modules/projectstore/docs/images/team-light.svg +79 -0
  46. package/node_modules/projectstore/docs/images/team.svg +79 -0
  47. package/node_modules/projectstore/harnesses/claude-code.json +483 -0
  48. package/node_modules/projectstore/harnesses/codex.json +332 -0
  49. package/node_modules/projectstore/hooks/hooks.json +59 -0
  50. package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
  51. package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
  52. package/node_modules/projectstore/hooks/session-start.mjs +301 -0
  53. package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
  54. package/node_modules/projectstore/package.json +70 -0
  55. package/node_modules/projectstore/scaffold/checklists.json +88 -0
  56. package/node_modules/projectstore/scaffold/headings.json +171 -0
  57. package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
  58. package/node_modules/projectstore/scripts/binding.mjs +165 -0
  59. package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
  60. package/node_modules/projectstore/scripts/cli.mjs +595 -0
  61. package/node_modules/projectstore/scripts/codemap.mjs +99 -0
  62. package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
  63. package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
  64. package/node_modules/projectstore/scripts/draft.mjs +261 -0
  65. package/node_modules/projectstore/scripts/graph.mjs +219 -0
  66. package/node_modules/projectstore/scripts/harness.mjs +608 -0
  67. package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
  68. package/node_modules/projectstore/scripts/kanban.mjs +174 -0
  69. package/node_modules/projectstore/scripts/lib.mjs +3085 -0
  70. package/node_modules/projectstore/scripts/mcp.mjs +391 -0
  71. package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
  72. package/node_modules/projectstore/scripts/provenance.mjs +375 -0
  73. package/node_modules/projectstore/scripts/query.mjs +490 -0
  74. package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
  75. package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
  76. package/node_modules/projectstore/scripts/statusline.mjs +253 -0
  77. package/node_modules/projectstore/scripts/story-section.mjs +209 -0
  78. package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
  79. package/node_modules/projectstore/scripts/tokens.mjs +449 -0
  80. package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
  81. package/node_modules/projectstore/scripts/version-guard.mjs +255 -0
  82. package/node_modules/projectstore/scripts/worktree.mjs +109 -0
  83. package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
  84. package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
  85. package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
  86. package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
  87. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
  88. package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
  89. package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
  90. package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
  91. package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
  92. package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
  93. package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
  94. package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
  95. package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
  96. package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
  97. package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
  98. package/node_modules/projectstore/templates/de/strings.json +6 -0
  99. package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
  100. package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
  101. package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
  102. package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
  103. package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
  104. package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
  105. package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
  106. package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
  107. package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
  108. package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
  109. package/node_modules/projectstore/templates/en/strings.json +6 -0
  110. package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
  111. package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
  112. package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
  113. package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
  114. package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
  115. package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
  116. package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
  117. package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
  118. package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
  119. package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
  120. package/node_modules/projectstore/templates/es/strings.json +6 -0
  121. package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
  122. package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
  123. package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
  124. package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
  125. package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
  126. package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
  127. package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
  128. package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
  129. package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
  130. package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
  131. package/node_modules/projectstore/templates/fr/strings.json +6 -0
  132. package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
  133. package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
  134. package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
  135. package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
  136. package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
  137. package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
  138. package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
  139. package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
  140. package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
  141. package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
  142. package/node_modules/projectstore/templates/ru/strings.json +6 -0
  143. package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
  144. package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
  145. package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
  146. package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
  147. package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
  148. package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
  149. package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
  150. package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
  151. package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
  152. package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
  153. package/node_modules/projectstore/templates/zh/strings.json +6 -0
  154. package/package.json +36 -14
  155. package/plugin.json +53 -0
  156. package/skills/projectstore-adr/SKILL.md +76 -0
  157. package/skills/projectstore-agents/SKILL.md +50 -0
  158. package/skills/projectstore-archaeologist/SKILL.md +109 -0
  159. package/skills/projectstore-bind/SKILL.md +44 -0
  160. package/skills/projectstore-clerk/SKILL.md +126 -0
  161. package/skills/projectstore-codemap/SKILL.md +69 -0
  162. package/skills/projectstore-concept/SKILL.md +36 -0
  163. package/skills/projectstore-critic/SKILL.md +127 -0
  164. package/skills/projectstore-decision-detector/SKILL.md +59 -0
  165. package/skills/projectstore-doctor/SKILL.md +33 -0
  166. package/skills/projectstore-epic/SKILL.md +59 -0
  167. package/skills/projectstore-graph/SKILL.md +75 -0
  168. package/skills/projectstore-kanban/SKILL.md +60 -0
  169. package/skills/projectstore-librarian/SKILL.md +114 -0
  170. package/skills/projectstore-meeting/SKILL.md +36 -0
  171. package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
  172. package/skills/projectstore-planner/SKILL.md +113 -0
  173. package/skills/projectstore-reconcile/SKILL.md +92 -0
  174. package/skills/projectstore-research/SKILL.md +36 -0
  175. package/skills/projectstore-review/SKILL.md +108 -0
  176. package/skills/projectstore-reviewer/SKILL.md +131 -0
  177. package/skills/projectstore-runbook/SKILL.md +36 -0
  178. package/skills/projectstore-scaffold/SKILL.md +42 -0
  179. package/skills/projectstore-search/SKILL.md +41 -0
  180. package/skills/projectstore-spec/SKILL.md +110 -0
  181. package/skills/projectstore-status/SKILL.md +47 -0
  182. package/skills/projectstore-statusline/SKILL.md +29 -0
  183. package/skills/projectstore-story/SKILL.md +132 -0
  184. package/skills/projectstore-story-completion/SKILL.md +69 -0
  185. package/skills/projectstore-vault-communication/SKILL.md +115 -0
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — reconcile.mjs
3
+ // Re-derives every derived view from the vault's source of truth:
4
+ // kanban.md, folder-index README tables, code-map.md, graph.md. Hand-edits
5
+ // therefore can never *permanently* desync the views (PS-IMPROVE story-002,
6
+ // ADR-004/005: vault-side repairs belong to reconcile, not doctor --fix).
7
+ //
8
+ // Two modes (spec: atomic-regeneration-of-derived-views):
9
+ // compute (default) — read-only. Output JSON lists each target with
10
+ // {path, changed, content?, stats?} plus summary.{changed, failed}.
11
+ // Idempotent: a clean vault yields zero changes. Exit stays 0 (a
12
+ // reporting tool) — per-target errors surface via summary.failed and
13
+ // the command prose, not the exit code.
14
+ // --write — applies the regeneration itself: every selected target is
15
+ // recomputed from the vault state at write time (the approval covers
16
+ // the regeneration action, not a byte snapshot) and replaced atomically
17
+ // via lib.mjs writeFileAtomic. Report per target {path, changed,
18
+ // written, error?} — no content on stdout. Continue-on-error; exits 1
19
+ // iff a selected target failed, so a cron caller notices. Headless use
20
+ // is sanctioned by ADR-009 ("reconcile as a scheduled repair job").
21
+ //
22
+ // --only <sel>[,…] limits either mode to: kanban | codemap | graph |
23
+ // indexes | indexes=<folder-path>. Unknown selectors, and explicitly
24
+ // *named* targets the layout or vault cannot provide, die loudly — a
25
+ // typo'd cron job must not silently reconcile nothing. Bare invocation
26
+ // (and the class-wide `indexes`) keeps the silent-skip behaviour for
27
+ // absent targets.
28
+
29
+ import { existsSync, readFileSync } from "node:fs";
30
+ import { join, basename } from "node:path";
31
+ import { spawnSync } from "node:child_process";
32
+ import { resolve } from "node:path";
33
+ import {
34
+ readConfig,
35
+ readConfigAt,
36
+ loadLayout,
37
+ projectRoot,
38
+ pluginRoot,
39
+ indexHeaderRe,
40
+ slugIdentity,
41
+ displayNumberOf,
42
+ compareArtifactOrder,
43
+ writeFileAtomic, isMain
44
+ } from "./lib.mjs";
45
+ import { childEnv } from "./harness.mjs";
46
+
47
+ // Set by runReconcile when a caller names the project; the generators it
48
+ // spawns receive it. Null means "this process's own project".
49
+ let _projectDir = null;
50
+ let _env = null;
51
+ import { scanArtifacts } from "./doctor.mjs";
52
+
53
+ function die(msg) {
54
+ process.stderr.write(`projectstore/reconcile: ${msg}\n`);
55
+ process.exit(1);
56
+ }
57
+
58
+ function runGenerator(script) {
59
+ const r = spawnSync(process.execPath, [join(pluginRoot(), "scripts", script)], {
60
+ encoding: "utf8",
61
+ timeout: 10000,
62
+ env: _env || childEnv(process.env, { projectRoot: _projectDir || projectRoot() }),
63
+ });
64
+ if (r.status !== 0) return { error: (r.stderr || "generator failed").trim() };
65
+ try { return JSON.parse(r.stdout); } catch { return { error: "unparseable generator output" }; }
66
+ }
67
+
68
+ const normalize = (s) =>
69
+ s.split("\n").filter((l) => !l.startsWith("generated_at:")).join("\n").trimEnd();
70
+
71
+ const errMsg = (e) => String((e && e.message) || e);
72
+
73
+ // Which targets does this invocation cover? Pure over the layout so the
74
+ // kanban-less-layout branch is testable without a fake plugin root.
75
+ // Returns {error} on a selector the layout cannot satisfy. `named` marks an
76
+ // individually named index (indexes=adr) — those fail loudly when the vault
77
+ // cannot provide them; the class-wide selectors skip, like bare invocation.
78
+ export function resolveSelection(layout, onlyRaw) {
79
+ const validSelectors = () =>
80
+ [layout.kanban ? "kanban" : null, "codemap", "graph", "indexes",
81
+ ...layout.folders.map((f) => `indexes=${f.path}`)]
82
+ .filter(Boolean).join(", ");
83
+ if (onlyRaw == null) {
84
+ return {
85
+ explicit: false,
86
+ kanban: true,
87
+ codemap: true,
88
+ graph: true,
89
+ indexes: layout.folders.map((f) => ({ path: f.path, named: false })),
90
+ };
91
+ }
92
+ const sel = { explicit: true, kanban: false, codemap: false, graph: false, indexes: [] };
93
+ const parts = String(onlyRaw).split(",").map((s) => s.trim()).filter(Boolean);
94
+ if (!parts.length) return { error: `empty --only selection — valid: ${validSelectors()}` };
95
+ for (const raw of parts) {
96
+ if (raw === "kanban") {
97
+ if (!layout.kanban) return { error: `layout declares no kanban — valid: ${validSelectors()}` };
98
+ sel.kanban = true;
99
+ } else if (raw === "codemap") {
100
+ if (!layout.folders.some((f) => f.kind === "epic")) {
101
+ return { error: `layout has no epic folder, so there is no code map — valid: ${validSelectors()}` };
102
+ }
103
+ sel.codemap = true;
104
+ } else if (raw === "graph") {
105
+ // Valid on every layout: the graph's node universe is whatever
106
+ // artifact kinds the layout declares — there is no layout without one.
107
+ sel.graph = true;
108
+ } else if (raw === "indexes") {
109
+ // Merge, never assign: `indexes=adr,indexes` must keep adr's named
110
+ // loudness rather than silently demoting it to a skippable member.
111
+ for (const f of layout.folders) {
112
+ if (!sel.indexes.some((x) => x.path === f.path)) sel.indexes.push({ path: f.path, named: false });
113
+ }
114
+ } else if (raw.startsWith("indexes=")) {
115
+ const p = raw.slice("indexes=".length);
116
+ if (!layout.folders.some((f) => f.path === p)) {
117
+ return { error: `no folder "${p}" in this layout — valid: ${validSelectors()}` };
118
+ }
119
+ const existing = sel.indexes.find((x) => x.path === p);
120
+ if (existing) existing.named = true; // upgrade, whatever the order
121
+ else sel.indexes.push({ path: p, named: true });
122
+ } else {
123
+ return { error: `unknown selector "${raw}" — valid: ${validSelectors()}` };
124
+ }
125
+ }
126
+ return sel;
127
+ }
128
+
129
+ // Per-target politeness skips for bare invocation. `explicit` (the human
130
+ // named the target with --only) suppresses them wholesale — an explicitly
131
+ // requested target is always produced. Each returns a reason string or null.
132
+ //
133
+ // codemap: skip only a fresh vault (file absent AND zero code_refs) — a
134
+ // deleted code-map.md on a vault with refs IS re-minted by bare --write.
135
+ // graph: STRICTER — skip whenever graph.md is absent (spec contract 1): a
136
+ // routine `reconcile --write` after a plugin upgrade must not silently mint
137
+ // a new root file, and near-simultaneous first creation on two machines
138
+ // would mint an iCloud conflicted copy. Deletion therefore reads as first
139
+ // creation; the standing signal is doctor's missing-graph info.
140
+ const SKIP = {
141
+ "kanban.mjs": null,
142
+ "codemap.mjs": (g, onDisk) =>
143
+ onDisk === null && g.stats && g.stats.epics_with_refs === 0 && g.stats.story_rows === 0
144
+ ? "no code_refs anywhere and no existing file"
145
+ : null,
146
+ "graph.mjs": (g, onDisk) =>
147
+ onDisk === null
148
+ ? "graph.md does not exist yet — create it explicitly (--only graph or /projectstore:graph)"
149
+ : null,
150
+ };
151
+
152
+ // Compute one generator-backed target (kanban, code-map, graph).
153
+ // `fallbackPath` keeps the report shape ({path, …}) even when the generator
154
+ // itself failed and never told us where its target lives.
155
+ function derivedTarget(script, explicit = false, fallbackPath = null) {
156
+ const g = runGenerator(script);
157
+ if (g.error) return { ...(fallbackPath ? { path: fallbackPath } : {}), error: g.error };
158
+ let onDisk = null;
159
+ try {
160
+ onDisk = existsSync(g.path) ? readFileSync(g.path, "utf8") : null;
161
+ } catch (e) {
162
+ return { path: g.path, error: errMsg(e) };
163
+ }
164
+ const skipWhy = !explicit && SKIP[script] ? SKIP[script](g, onDisk) : null;
165
+ if (skipWhy) {
166
+ return { path: g.path, changed: false, skipped: skipWhy, stats: g.stats };
167
+ }
168
+ const changed = onDisk === null || normalize(onDisk) !== normalize(g.content);
169
+ const base = { path: g.path, changed, stats: g.stats };
170
+ return changed ? { ...base, content: g.content } : base;
171
+ }
172
+
173
+ // Rebuild a folder README's Index table rows from artifact frontmatter,
174
+ // preserving every byte outside the managed table. Pure over the bytes so
175
+ // the check-and-retry writer can recompute from a fresh read.
176
+ // Returns {content} (possibly === original) or {unusable: reason} when the
177
+ // README carries no managed table this script may touch.
178
+ export function rebuildIndexRows(original, folder, artifacts) {
179
+ const lines = original.split("\n");
180
+ // Header matched via the heading registry (PS-SPEC story-002) — ru vaults'
181
+ // localized index headers were unreconcilable while this was an English
182
+ // literal. Unrecognized headers surface as a doctor index-header finding.
183
+ const headerRe = indexHeaderRe();
184
+ const headIdx = lines.findIndex((l) => headerRe.test(l));
185
+ if (headIdx === -1) return { unusable: "no recognised index-table header" };
186
+ if (!/^\|[-\s|]+\|$/.test(lines[headIdx + 1] || "")) {
187
+ return { unusable: "malformed separator row under the index header" };
188
+ }
189
+
190
+ let end = headIdx + 2;
191
+ while (end < lines.length && /^\|/.test(lines[end])) end++;
192
+
193
+ const rows = [];
194
+ const inFolder = artifacts.filter((a) =>
195
+ folder.kind === "epic"
196
+ ? a.kind === "epic" && a.rel.startsWith(`${folder.path}/`)
197
+ : a.kind === folder.kind && a.rel === `${folder.path}/${basename(a.rel)}`);
198
+ // Ordering per SPEC-002 contract 8: date ascending (date:, else created:),
199
+ // display number then slug as tiebreak — the grandfathered ADR-001…N order
200
+ // survives because same-date groups tiebreak by number.
201
+ const decorated = inFolder.map((a) => {
202
+ const file = basename(a.rel);
203
+ const idOpts = { prefix: folder.prefix || null };
204
+ return {
205
+ a,
206
+ file,
207
+ date: String(a.fm.date || a.fm.created || ""),
208
+ number: folder.kind === "epic" ? null : displayNumberOf(a.fm, file, idOpts),
209
+ slug: folder.kind === "epic" ? a.rel.split("/")[1].toLowerCase() : slugIdentity(file, idOpts).primary,
210
+ };
211
+ });
212
+ for (const d of decorated.sort(compareArtifactOrder)) {
213
+ const { a, file, date, number } = d;
214
+ if (folder.kind === "epic") {
215
+ const id = a.rel.split("/")[1];
216
+ rows.push(`| [${id}](./${id}/epic.md) | ${a.fm.title || id} | ${a.fm.status || "planned"} | ${date} |`);
217
+ } else {
218
+ // The display number renders only when present — grandfathered
219
+ // SPEC-NNN rows keep their labels, slug-only rows are labelled by slug.
220
+ const label = number && folder.prefix ? `${folder.prefix}${number}` : file.replace(/\.md$/, "");
221
+ const status = a.fm.status || (folder.numbered ? "proposed" : "draft");
222
+ rows.push(`| [${label}](./${file}) | ${a.fm.title || label} | ${status} | ${date} |`);
223
+ }
224
+ }
225
+
226
+ return { content: [...lines.slice(0, headIdx + 2), ...rows, ...lines.slice(end)].join("\n") };
227
+ }
228
+
229
+ // Compute-mode wrapper: read + rebuild, today's {path, folder, changed,
230
+ // content?} shape. null = nothing to reconcile here (absent README or no
231
+ // managed table) — bare invocation filters those out silently.
232
+ export function rebuildIndex(cfg, folder, artifacts) {
233
+ const readmePath = join(cfg.vault_path, folder.path, "README.md");
234
+ let original;
235
+ try {
236
+ if (!existsSync(readmePath)) return null;
237
+ original = readFileSync(readmePath, "utf8");
238
+ } catch (e) {
239
+ return { path: readmePath, folder: folder.path, error: errMsg(e) };
240
+ }
241
+ const r = rebuildIndexRows(original, folder, artifacts);
242
+ if (r.unusable) return null;
243
+ return r.content === original
244
+ ? { path: readmePath, folder: folder.path, changed: false }
245
+ : { path: readmePath, folder: folder.path, changed: true, content: r.content };
246
+ }
247
+
248
+ // Apply an index regeneration with check-and-retry (spec contract 3): the
249
+ // README is a source+derived hybrid — its prose is content a human owns and
250
+ // nothing can recompute. Read, rebuild the managed rows, re-read immediately
251
+ // before the atomic replace; on any drift recompute from the fresh bytes.
252
+ // Never writes from stale bytes; after `attempts` collisions it reports an
253
+ // error instead. The microseconds between the final re-read and the rename,
254
+ // and a crash mid-sequence, are the residual risk the spec accepts (no
255
+ // locks, per ADR-010). `rebuild` is (bytes) => {content}|{unusable} —
256
+ // production passes rebuildIndexRows, tests inject a mutating hook.
257
+ export function writeIndexWithRetry(readmePath, rebuild, { attempts = 3 } = {}) {
258
+ let base;
259
+ try {
260
+ if (!existsSync(readmePath)) return { unusable: "README.md does not exist" };
261
+ base = readFileSync(readmePath, "utf8");
262
+ } catch (e) {
263
+ return { error: errMsg(e) };
264
+ }
265
+ for (let i = 0; i < attempts; i++) {
266
+ const r = rebuild(base);
267
+ if (r.unusable) return { unusable: r.unusable };
268
+ let now;
269
+ try {
270
+ now = readFileSync(readmePath, "utf8");
271
+ } catch (e) {
272
+ return { error: errMsg(e) };
273
+ }
274
+ if (now !== base) {
275
+ base = now; // drift during rebuild — recompute from what is there now
276
+ continue;
277
+ }
278
+ if (r.content === base) return { changed: false, written: false };
279
+ try {
280
+ writeFileAtomic(readmePath, r.content);
281
+ } catch (e) {
282
+ return { changed: true, written: false, error: errMsg(e) };
283
+ }
284
+ return { changed: true, written: true };
285
+ }
286
+ return { error: `concurrent edits persisted through ${attempts} attempts — re-run reconcile` };
287
+ }
288
+
289
+ // Apply a generator-backed target. The compute happens here, immediately
290
+ // before its own write — never compute-all-then-write-all — which is what
291
+ // shrinks the staleness window to microseconds (spec contract 1).
292
+ function applyDerived(script, explicit, fallbackPath) {
293
+ const t = derivedTarget(script, explicit, fallbackPath);
294
+ if (t.error) return { ...(t.path ? { path: t.path } : {}), changed: t.changed === true, written: false, error: t.error };
295
+ if (t.skipped) return { path: t.path, changed: false, written: false, skipped: t.skipped, stats: t.stats };
296
+ if (!t.changed) return { path: t.path, changed: false, written: false, stats: t.stats };
297
+ try {
298
+ writeFileAtomic(t.path, t.content);
299
+ return { path: t.path, changed: true, written: true, stats: t.stats };
300
+ } catch (e) {
301
+ return { path: t.path, changed: true, written: false, error: errMsg(e), stats: t.stats };
302
+ }
303
+ }
304
+
305
+ export function runReconcile({ write = false, only = null, projectDir = null, env = null } = {}) {
306
+ _projectDir = projectDir;
307
+ _env = env;
308
+ const cfg = projectDir ? readConfigAt(projectDir) : readConfig();
309
+ if (!cfg) { const e = new Error("No projectstore config. Run /projectstore:bind first."); e.code = "UNBOUND"; throw e; }
310
+ const layout = loadLayout(cfg.layout);
311
+ const sel = resolveSelection(layout, only);
312
+ if (sel.error) throw new Error(sel.error);
313
+ const artifacts = scanArtifacts(cfg, layout);
314
+
315
+ // Pre-flight every individually NAMED index before any side effect: a
316
+ // named target the vault cannot provide is a config error, and a config
317
+ // error must abort with nothing written. Throwing later, mid-loop, would
318
+ // discard the report while keeping mutations already applied — the one
319
+ // shape contract 1 forbids outright. A target that becomes unusable
320
+ // between this check and its write (a race) degrades to a per-target
321
+ // error below instead.
322
+ for (const { path: folderPath, named } of sel.indexes) {
323
+ if (!named) continue;
324
+ const folder = layout.folders.find((f) => f.path === folderPath);
325
+ const readmePath = join(cfg.vault_path, folder.path, "README.md");
326
+ if (!existsSync(readmePath)) throw new Error(`${folder.path}/README.md does not exist`);
327
+ const r = rebuildIndexRows(readFileSync(readmePath, "utf8"), folder, artifacts);
328
+ if (r.unusable) throw new Error(`${folder.path}/README.md: ${r.unusable}`);
329
+ }
330
+
331
+ const out = {};
332
+
333
+ const kanbanPath = layout.kanban ? join(cfg.vault_path, layout.kanban.file || "kanban.md") : null;
334
+ if (!sel.kanban) out.kanban = { skipped: "not selected" };
335
+ else if (!layout.kanban) out.kanban = { skipped: "layout has no kanban" };
336
+ else out.kanban = write ? applyDerived("kanban.mjs", sel.explicit, kanbanPath) : derivedTarget("kanban.mjs", sel.explicit, kanbanPath);
337
+
338
+ const codemapPath = join(cfg.vault_path, "code-map.md");
339
+ if (!sel.codemap) out.codemap = { skipped: "not selected" };
340
+ else out.codemap = write ? applyDerived("codemap.mjs", sel.explicit, codemapPath) : derivedTarget("codemap.mjs", sel.explicit, codemapPath);
341
+
342
+ const graphPath = join(cfg.vault_path, "graph.md");
343
+ if (!sel.graph) out.graph = { skipped: "not selected" };
344
+ else out.graph = write ? applyDerived("graph.mjs", sel.explicit, graphPath) : derivedTarget("graph.mjs", sel.explicit, graphPath);
345
+
346
+ out.indexes = [];
347
+ for (const { path: folderPath, named } of sel.indexes) {
348
+ const folder = layout.folders.find((f) => f.path === folderPath);
349
+ const readmePath = join(cfg.vault_path, folder.path, "README.md");
350
+ if (write) {
351
+ // Index rows are rebuilt from the artifact scan taken at the top of
352
+ // this invocation (already inside the apply call, i.e. after the human
353
+ // approval gap), while the README *bytes* are re-read per attempt.
354
+ // Kanban/code-map recompute fully inline — the asymmetry is deliberate:
355
+ // rescanning every artifact per index would be quadratic for a
356
+ // freshness gain of milliseconds that contract 4 convergence covers.
357
+ const r = writeIndexWithRetry(readmePath, (b) => rebuildIndexRows(b, folder, artifacts));
358
+ if (r.unusable) {
359
+ // Pre-flighted named targets can only reach this via a race.
360
+ if (named) {
361
+ out.indexes.push({ path: readmePath, folder: folder.path, changed: false, written: false, error: r.unusable });
362
+ }
363
+ continue;
364
+ }
365
+ out.indexes.push({
366
+ path: readmePath,
367
+ folder: folder.path,
368
+ changed: !!r.changed,
369
+ written: !!r.written,
370
+ ...(r.error ? { error: r.error } : {}),
371
+ });
372
+ } else {
373
+ const t = rebuildIndex(cfg, folder, artifacts);
374
+ if (t === null) {
375
+ if (named) {
376
+ out.indexes.push({ path: readmePath, folder: folder.path, error: "absent or no managed index table (changed since pre-flight)" });
377
+ }
378
+ continue;
379
+ }
380
+ out.indexes.push(t);
381
+ }
382
+ }
383
+
384
+ const all = [out.kanban, out.codemap, out.graph, ...out.indexes];
385
+ // `failed` is reported in BOTH modes — a compute-mode generator error must
386
+ // be visible in the summary, not buried in a target the prose's "changed
387
+ // targets" preview never lists. Exit-code policy stays write-scoped
388
+ // (compute remains a reporting tool; see main()).
389
+ out.summary = {
390
+ changed: all.filter((t) => t && t.changed).length,
391
+ failed: all.filter((t) => t && t.error).length,
392
+ };
393
+ if (write) out.summary.written = all.filter((t) => t && t.written).length;
394
+ return out;
395
+ }
396
+
397
+ function main() {
398
+ const argv = process.argv.slice(2);
399
+ let write = false;
400
+ let only = null;
401
+ for (let i = 0; i < argv.length; i++) {
402
+ const a = argv[i];
403
+ if (a === "--write") write = true;
404
+ else if (a === "--only") only = argv[++i];
405
+ else if (a.startsWith("--only=")) only = a.slice("--only=".length);
406
+ else die(`unknown argument: ${a}`);
407
+ }
408
+ if (only === undefined) die("--only requires a value");
409
+
410
+ let out;
411
+ try {
412
+ out = runReconcile({ write, only });
413
+ } catch (e) {
414
+ die(errMsg(e));
415
+ }
416
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
417
+ if (write && out.summary.failed > 0) process.exitCode = 1;
418
+ }
419
+
420
+ if (isMain(import.meta.url)) {
421
+ main();
422
+ }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — status line launcher. GENERATED FILE, edits are lost.
3
+ //
4
+ // Why this file exists: the Claude Code `statusLine` slot holds ONE absolute
5
+ // command, and the session reads it at startup. Plugin code lives under a
6
+ // VERSIONED cache path (…/plugins/cache/<marketplace>/projectstore/<version>/),
7
+ // so wiring that path directly meant the rendered line was always the version
8
+ // installed at the PREVIOUS session start — an update showed up one restart
9
+ // late, badge and behaviour both.
10
+ //
11
+ // This launcher is written once and stays valid: it resolves the CURRENTLY
12
+ // installed plugin from Claude Code's own registry on every render, so
13
+ // `/plugin update` + `/reload-plugins` (or any restart) is reflected
14
+ // immediately. The generating root is kept only as a fallback.
15
+ //
16
+ // Never crashes: a status line that throws blanks the user's HUD, so every
17
+ // failure path degrades to an empty line.
18
+
19
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
20
+ import { join, dirname } from "node:path";
21
+ import { homedir } from "node:os";
22
+ import { spawnSync } from "node:child_process";
23
+ import { pathToFileURL, fileURLToPath } from "node:url";
24
+
25
+ // Substituted by renderStatusLineLauncher() in scripts/lib.mjs — the fallback
26
+ // root and the harness's own variable names (this file runs before it knows
27
+ // which plugin root to load, so it cannot import harness.mjs to ask).
28
+ const FALLBACK_ROOT = "__PROJECTSTORE_ROOT__";
29
+ const HOME_ENV = "__PROJECTSTORE_HOME_ENV__";
30
+ const PLUGIN_ROOT_ENV = "__PROJECTSTORE_PLUGIN_ROOT_ENV__";
31
+ // The project this launcher was written for (2026-09-06): named at render
32
+ // time, not derived from this file's depth — the file lives under
33
+ // <project>/.projectstore/state/<harness>/ now, and a depth walk would name
34
+ // the state directory.
35
+ const PROJECT_DIR = "__PROJECTSTORE_PROJECT__";
36
+
37
+ function versionKey(v) {
38
+ const p = String(v || "0").split(".").map((n) => parseInt(n, 10) || 0);
39
+ return [p[0] || 0, p[1] || 0, p[2] || 0]; // pad: a missing component must compare as 0, not NaN
40
+ }
41
+
42
+ // Newest installed projectstore that is actually on disk. Deliberately a
43
+ // standalone copy of installedPluginRoot() in scripts/lib.mjs: importing that
44
+ // helper would mean naming a versioned path, which is the exact bug this file
45
+ // exists to remove.
46
+ function installedRoot() {
47
+ try {
48
+ 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
+ );
52
+ const family = dirname(FALLBACK_ROOT); // …/<marketplace>/projectstore
53
+ // This launcher lives at <project>/<config dir>/<runtime dir>/…: a row the
54
+ // host recorded for THIS checkout (local scope, one per checkout) comes
55
+ // first; another checkout's newer install is the fallback, not the pick.
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; } };
59
+ const found = [];
60
+ for (const [key, list] of Object.entries((reg && reg.plugins) || {})) {
61
+ if (key !== "projectstore" && !key.startsWith("projectstore@")) continue;
62
+ for (const e of Array.isArray(list) ? list : [list]) {
63
+ const p = e && e.installPath;
64
+ if (typeof p !== "string") continue;
65
+ if (!existsSync(join(p, "scripts", "statusline.mjs"))) continue;
66
+ found.push({
67
+ p,
68
+ same: dirname(p) === family ? 1 : 0, // stay on the marketplace we came from
69
+ mine: sameProject(e && e.projectPath) ? 1 : 0,
70
+ at: Date.parse((e && e.lastUpdated) || "") || 0,
71
+ v: versionKey(e && e.version),
72
+ });
73
+ }
74
+ }
75
+ // Family is a FILTER, not a tiebreak — an install from another marketplace
76
+ // is someone else's fork of projectstore, not a newer copy of ours, and
77
+ // this file imports whatever it picks. Same rule as installedPluginRoot().
78
+ const pool = found.filter((f) => f.same);
79
+ pool.sort((a, b) => b.mine - a.mine || b.at - a.at || b.v[0] - a.v[0] || b.v[1] - a.v[1] || b.v[2] - a.v[2]);
80
+ return pool.length ? pool[0].p : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ // Try the installed plugin, then the root that generated this file. The second
87
+ // attempt matters: existsSync only proves the script is there, and a truncated
88
+ // or mid-update file throws on import — without a retry a working fallback
89
+ // would sit unused while the user's whole HUD (ours AND the base one we
90
+ // compose over) goes blank. Safe to retry because statusline.mjs prints only
91
+ // at the end of its own main(), so a failed import has written nothing.
92
+ let rendered = false;
93
+ for (const root of [installedRoot(), FALLBACK_ROOT]) {
94
+ if (rendered || !root) continue;
95
+ const target = join(root, "scripts", "statusline.mjs");
96
+ try {
97
+ if (!existsSync(target)) continue;
98
+ // The renderer resolves its own root from the harness's plugin-root
99
+ // variable when set — point it at the install we actually loaded, or the
100
+ // badge, the strings and the breadcrumb would describe a different one.
101
+ if (PLUGIN_ROOT_ENV) process.env[PLUGIN_ROOT_ENV] = root;
102
+ // Renders on import (statusline.mjs runs main() at module load and reads
103
+ // its own stdin) — one process, no extra spawn.
104
+ await import(pathToFileURL(target).href);
105
+ rendered = true;
106
+ } catch {
107
+ // fall through to the next candidate
108
+ }
109
+ }
110
+
111
+ // Last resort: projectstore is gone (uninstalled, or its cache swept) but this
112
+ // launcher is still wired. Our entry outranks the user's own statusLine, so
113
+ // giving up here would blank the HUD they had before us — in every bound
114
+ // project, with no projectstore left to unwire it. Run their base command
115
+ // instead, exactly as the renderer would have composed over it.
116
+ if (!rendered) {
117
+ let out = null;
118
+ try {
119
+ const stdin = (() => { try { return readFileSync(0, "utf8"); } catch { return ""; } })();
120
+ const projectDir = (() => {
121
+ try {
122
+ const i = JSON.parse(stdin) || {};
123
+ return (i.workspace && i.workspace.project_dir) || i.cwd || null;
124
+ } catch { return null; }
125
+ })();
126
+ const claudeHome = (HOME_ENV && process.env[HOME_ENV]) || join(homedir(), ".claude");
127
+ const candidates = [
128
+ projectDir ? join(projectDir, ".claude", "settings.json") : null,
129
+ join(claudeHome, "settings.json"),
130
+ ].filter(Boolean);
131
+ for (const p of candidates) {
132
+ let cmd = null;
133
+ try { cmd = JSON.parse(readFileSync(p, "utf8"))?.statusLine?.command; } catch { continue; }
134
+ if (typeof cmd !== "string" || !cmd.trim()) continue;
135
+ if (cmd.includes("statusline.mjs")) continue; // ours — would recurse
136
+ 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; }
138
+ }
139
+ } catch {}
140
+ process.stdout.write((out || "") + "\n");
141
+ }