projectstore-codex 0.0.1 → 0.28.2

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 (184) 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 +176 -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 +261 -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 +35 -14
  155. package/skills/projectstore-adr/SKILL.md +76 -0
  156. package/skills/projectstore-agents/SKILL.md +50 -0
  157. package/skills/projectstore-archaeologist/SKILL.md +109 -0
  158. package/skills/projectstore-bind/SKILL.md +44 -0
  159. package/skills/projectstore-clerk/SKILL.md +126 -0
  160. package/skills/projectstore-codemap/SKILL.md +69 -0
  161. package/skills/projectstore-concept/SKILL.md +36 -0
  162. package/skills/projectstore-critic/SKILL.md +127 -0
  163. package/skills/projectstore-decision-detector/SKILL.md +59 -0
  164. package/skills/projectstore-doctor/SKILL.md +33 -0
  165. package/skills/projectstore-epic/SKILL.md +59 -0
  166. package/skills/projectstore-graph/SKILL.md +75 -0
  167. package/skills/projectstore-kanban/SKILL.md +60 -0
  168. package/skills/projectstore-librarian/SKILL.md +114 -0
  169. package/skills/projectstore-meeting/SKILL.md +36 -0
  170. package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
  171. package/skills/projectstore-planner/SKILL.md +113 -0
  172. package/skills/projectstore-reconcile/SKILL.md +92 -0
  173. package/skills/projectstore-research/SKILL.md +36 -0
  174. package/skills/projectstore-review/SKILL.md +108 -0
  175. package/skills/projectstore-reviewer/SKILL.md +131 -0
  176. package/skills/projectstore-runbook/SKILL.md +36 -0
  177. package/skills/projectstore-scaffold/SKILL.md +42 -0
  178. package/skills/projectstore-search/SKILL.md +41 -0
  179. package/skills/projectstore-spec/SKILL.md +110 -0
  180. package/skills/projectstore-status/SKILL.md +47 -0
  181. package/skills/projectstore-statusline/SKILL.md +29 -0
  182. package/skills/projectstore-story/SKILL.md +132 -0
  183. package/skills/projectstore-story-completion/SKILL.md +69 -0
  184. package/skills/projectstore-vault-communication/SKILL.md +115 -0
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — statusline.mjs (v2, ADR-006)
3
+ //
4
+ // Renders the Claude Code status line for a projectstore-bound project.
5
+ // statusLine is NOT a plugin-declarable capability, so it lives in settings
6
+ // with an absolute path: the SessionStart hook points .claude/settings.local.json
7
+ // at the generated .projectstore/state/claude-code/statusline.mjs launcher (when
8
+ // projectstore.json → statusline.enabled=true), and the launcher imports THIS
9
+ // file from whichever plugin version is installed at render time.
10
+ //
11
+ // COMPOSING, not clobbering: the statusLine slot is single. This script
12
+ // DELEGATES to the base statusLine command from the project's
13
+ // .claude/settings.json, else the user's settings.json, prints it verbatim, and adds
14
+ // ONE projectstore line above it (position configurable). With no base
15
+ // command it renders a standalone line: [PS#v] <model> · <dir> · ⎇ <branch> · 📚 …
16
+ //
17
+ // The 📚 segment is resolved PER SESSION with zero cross-session and zero
18
+ // vault reads (ADR-006):
19
+ // 1. this session's pointer file
20
+ // (<project>/.projectstore/state/sessions/<session_id>.json — written by
21
+ // the PreToolUse hook with denormalized titles), else
22
+ // 2. an explicit localized cold-start line ("no epic or story in this
23
+ // session yet"). Never blank while enabled; a pointer that exists but
24
+ // fails to parse renders an error-marked string, never the cheerful
25
+ // cold-start line. The renderer drops a .last-render.json breadcrumb so
26
+ // doctor can detect session_id divergence between hook and statusLine.
27
+ //
28
+ // Everything is best-effort; on any error emit what we have and exit 0.
29
+
30
+ import { readFileSync, existsSync } from "node:fs";
31
+ import { join, basename, resolve } from "node:path";
32
+ import { spawnSync } from "node:child_process";
33
+ import { fileURLToPath } from "node:url";
34
+ import {
35
+ readConfigAt,
36
+ pluginRoot,
37
+ sessionStatePath,
38
+ stateDir,
39
+ ensureStateDir,
40
+ statusLineIsOurs,
41
+ claudeHome,
42
+ writeFileAtomic,
43
+ } from "./lib.mjs";
44
+
45
+ const SELF = fileURLToPath(import.meta.url);
46
+ const SEP = " · ";
47
+ const BRANCH = "⎇ ";
48
+ const BOOK = "📚 ";
49
+ const ARROW = " › ";
50
+
51
+ // Claude Code cancels an in-flight statusLine by closing our stdout pipe when
52
+ // a newer update arrives; guard the pipe so we honour the never-crash contract.
53
+ process.stdout.on("error", () => process.exit(0));
54
+
55
+ function readRawStdin() {
56
+ try { return readFileSync(0, "utf8"); } catch { return ""; }
57
+ }
58
+
59
+ const FALLBACK_STRINGS = {
60
+ statusline_no_work: "No epic or story in this session yet",
61
+ statusline_state_error: "⚠ session state unreadable",
62
+ };
63
+
64
+ function loadStrings(lang) {
65
+ const read = (l) => {
66
+ try {
67
+ return JSON.parse(readFileSync(join(pluginRoot(), "templates", l, "strings.json"), "utf8"));
68
+ } catch {
69
+ return null;
70
+ }
71
+ };
72
+ return {
73
+ ...FALLBACK_STRINGS,
74
+ ...(read("en") || {}),
75
+ ...(lang && lang !== "en" ? read(lang) || {} : {}),
76
+ };
77
+ }
78
+
79
+ function pluginVersion() {
80
+ try {
81
+ return JSON.parse(readFileSync(join(pluginRoot(), ".claude-plugin", "plugin.json"), "utf8")).version;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ // Current git branch by reading .git/HEAD directly — no process spawn.
88
+ function gitBranch(projectDir) {
89
+ try {
90
+ const dotgit = join(projectDir, ".git");
91
+ let gitDir;
92
+ const st = readFileSync(dotgit, "utf8");
93
+ const m = st.trim().match(/^gitdir:\s*(.+)$/);
94
+ gitDir = m ? resolve(projectDir, m[1].trim()) : dotgit;
95
+ const head = readFileSync(join(gitDir, "HEAD"), "utf8").trim();
96
+ const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
97
+ if (ref) return ref[1];
98
+ if (/^[0-9a-f]{7,40}$/i.test(head)) return head.slice(0, 7);
99
+ return null;
100
+ } catch {
101
+ try {
102
+ const head = readFileSync(join(projectDir, ".git", "HEAD"), "utf8").trim();
103
+ const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
104
+ return ref ? ref[1] : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+ }
110
+
111
+ // The 📚 segment — strictly this-session (ADR-006). Returns a non-empty string
112
+ // whenever the feature is enabled; distinguishes "no work yet" from "state
113
+ // unreadable" so failures are visible, never masked.
114
+ function bookSegment(cfg, proj, input, strings) {
115
+ const sid = input.session_id;
116
+ // Breadcrumb for doctor's divergence check — best-effort, never fatal.
117
+ try {
118
+ ensureStateDir(proj);
119
+ // sweep=false: this is the hottest path in the plugin (every HUD frame),
120
+ // and the state dir holds one file per session — no readdir here.
121
+ writeFileAtomic(
122
+ join(stateDir(proj), ".last-render.json"),
123
+ JSON.stringify({
124
+ session_id: sid || null,
125
+ at: new Date().toISOString(),
126
+ // Which plugin actually rendered this line — doctor compares it with
127
+ // the installed version to explain a stale badge instead of leaving
128
+ // the user to wonder why an update did not show up.
129
+ version: pluginVersion(),
130
+ root: pluginRoot(),
131
+ }) + "\n",
132
+ { sweep: false },
133
+ );
134
+ } catch {}
135
+
136
+ if (!sid) return BOOK + strings.statusline_no_work;
137
+ const p = sessionStatePath(proj, sid);
138
+ if (!existsSync(p)) return BOOK + strings.statusline_no_work;
139
+
140
+ let state;
141
+ try {
142
+ state = JSON.parse(readFileSync(p, "utf8"));
143
+ } catch {
144
+ return BOOK + strings.statusline_state_error;
145
+ }
146
+ if (!state || !state.active_epic) return BOOK + strings.statusline_no_work;
147
+
148
+ let seg = BOOK + (state.epic_title || state.active_epic);
149
+ if (state.active_story) {
150
+ seg += ARROW + (state.story_title || state.active_story);
151
+ if (state.story_status) seg += ` (${state.story_status})`;
152
+ }
153
+ return seg;
154
+ }
155
+
156
+ // The base statusLine command we compose with — highest-precedence entry BELOW
157
+ // our own local one: project settings.json, else user settings.json.
158
+ function discoverBaseCommand(projectDir) {
159
+ const candidates = [
160
+ join(projectDir, ".claude", "settings.json"),
161
+ join(claudeHome(), "settings.json"), // config-dir-aware: else their HUD vanishes
162
+ ];
163
+ for (const p of candidates) {
164
+ try {
165
+ const cmd = JSON.parse(readFileSync(p, "utf8"))?.statusLine?.command;
166
+ // Never compose over ourselves: any of our own wirings (plugin script or
167
+ // generated launcher) would recurse.
168
+ if (typeof cmd === "string" && cmd.trim() && !cmd.includes(SELF) && !statusLineIsOurs(cmd)) {
169
+ return cmd;
170
+ }
171
+ } catch {}
172
+ }
173
+ return null;
174
+ }
175
+
176
+ function runBase(cmd, rawStdin, env) {
177
+ try {
178
+ const r = spawnSync(cmd, {
179
+ shell: true,
180
+ input: rawStdin,
181
+ encoding: "utf8",
182
+ timeout: 2000,
183
+ maxBuffer: 1 << 20,
184
+ env,
185
+ });
186
+ if (r.status !== 0) return null;
187
+ const out = (r.stdout || "").replace(/\s+$/, "");
188
+ return out || null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ function main() {
195
+ const raw = readRawStdin();
196
+ let input = {};
197
+ try { input = JSON.parse(raw) || {}; } catch { input = {}; }
198
+
199
+ // The status line gets no project variable from the harness; the project is
200
+ // on stdin, and it deliberately beats any exported variable. Read the config
201
+ // for that directory directly — never by mutating process.env, which the
202
+ // composed base command would then inherit.
203
+ const projectDir =
204
+ (input.workspace && input.workspace.project_dir) || input.cwd || process.cwd();
205
+
206
+ let cfg = null;
207
+ try { cfg = readConfigAt(projectDir); } catch { cfg = null; }
208
+
209
+ 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; }
212
+
213
+ const showVersion = !(cfg && cfg.statusline && cfg.statusline.show_version === false);
214
+ const ver = showVersion ? pluginVersion() : null;
215
+ const badge = ver ? `[PS#${ver}] ` : "";
216
+
217
+ const baseCmd = discoverBaseCommand(projectDir);
218
+ const baseOut = baseCmd ? runBase(baseCmd, raw, process.env) : null;
219
+
220
+ // "Never blank" must be total: even if the resolver threw (book === null on a
221
+ // 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;
224
+
225
+ const lines = [];
226
+ if (baseOut) {
227
+ // Compose: keep the base HUD intact; our line = badge + 📚 segment.
228
+ const position = (cfg && cfg.statusline && cfg.statusline.position) || "above";
229
+ const ours = bookOut ? badge + bookOut : null;
230
+ if (ours && position === "above") lines.push(ours);
231
+ lines.push(baseOut);
232
+ if (ours && position !== "above") lines.push(ours);
233
+ } else {
234
+ // Standalone: badge leads the whole line; the book value is its 📚 segment.
235
+ const parts = [];
236
+ const model = input.model && input.model.display_name;
237
+ if (model) parts.push(model);
238
+ if (projectDir) parts.push(basename(projectDir));
239
+ const branch = gitBranch(projectDir);
240
+ if (branch) parts.push(BRANCH + branch);
241
+ if (bookOut) parts.push(bookOut);
242
+ lines.push(badge + parts.join(SEP));
243
+ }
244
+
245
+ process.stdout.write(lines.join("\n") + "\n");
246
+ }
247
+
248
+ try {
249
+ main();
250
+ } catch {
251
+ // A status line must never crash. Emit nothing rather than an error.
252
+ process.stdout.write("\n");
253
+ }
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — story-section.mjs (PS-SPEC story-007)
3
+ // Computes the story lifecycle-gate mutations for /projectstore:story.
4
+ // --check <baseline> adds a deterministic drift verdict (volatile fields
5
+ // excluded) to the JSON as `check: {baseline, match, drift}` — exit 0 either
6
+ // way; drift is a computed fact, not a failure.
7
+ // plan|close. Pure compute, models reconcile.mjs rebuildIndex: reads the
8
+ // original, splices the managed pieces, returns {path, changed, content,
9
+ // notes} on stdout — the COMMAND writes after approval, never this script.
10
+ //
11
+ // node story-section.mjs plan <story-path>
12
+ // node story-section.mjs close <story-path>
13
+ //
14
+ // plan — ensures a `## Implementation Plan` section exists (inserted with a
15
+ // placeholder; the agent fills it in the preview before approval),
16
+ // status → in-progress (never downgrades review/done), stamps
17
+ // started_at (once) and plan_updated_at (every run), bumps updated.
18
+ // close — ensures a `## Final Summary` section exists, status → done,
19
+ // stamps closed_at (once), bumps updated.
20
+ //
21
+ // Timestamps are full ISO-8601 UTC (lib.mjs nowIso) — they anchor the legacy
22
+ // predicate (closed_at vs spec_policy_since) and diff-refs' git --since.
23
+ // Timestamps are written UNCONDITIONALLY of lifecycle_gates: the gates key
24
+ // controls checks and prompts, never the data that other predicates need.
25
+
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { resolve } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import {
30
+ readConfig,
31
+ parseFrontmatter,
32
+ headingLineRe,
33
+ heading,
34
+ footerDateRe,
35
+ nowIso,
36
+ today,
37
+ } from "./lib.mjs";
38
+
39
+ function die(msg) {
40
+ process.stderr.write(`projectstore/story-section: ${msg}\n`);
41
+ process.exit(1);
42
+ }
43
+
44
+ // Replace `key: ...` inside the frontmatter block, or insert the line before
45
+ // the closing --- when the key is absent. Returns the whole file text.
46
+ function setFm(text, key, value) {
47
+ const m = text.match(/^---\n([\s\S]*?)\n---/);
48
+ if (!m) die("story has no frontmatter block");
49
+ const fmLines = m[1].split("\n");
50
+ const idx = fmLines.findIndex((l) => l.startsWith(`${key}:`));
51
+ const line = `${key}: ${value}`;
52
+ if (idx >= 0) fmLines[idx] = line;
53
+ else fmLines.push(line);
54
+ return text.slice(0, m.index) + `---\n${fmLines.join("\n")}\n---` + text.slice(m.index + m[0].length);
55
+ }
56
+
57
+ // End index of section `id` (position right before the next `## ` heading
58
+ // after it), or null when the section is absent.
59
+ function sectionEnd(text, id) {
60
+ const m = text.match(headingLineRe(id));
61
+ if (!m) return null;
62
+ const rest = text.slice(m.index + m[0].length);
63
+ const next = rest.search(/^## /m);
64
+ return next === -1 ? text.length : m.index + m[0].length + next;
65
+ }
66
+
67
+ function insertSection(text, id, lang, placeholder, anchors) {
68
+ if (headingLineRe(id).test(text)) return { text, inserted: false };
69
+ const block = `## ${heading(id, lang)}\n\n${placeholder}\n\n`;
70
+ for (const anchor of anchors) {
71
+ const end = sectionEnd(text, anchor);
72
+ if (end !== null) {
73
+ return { text: text.slice(0, end) + block + text.slice(end), inserted: true };
74
+ }
75
+ }
76
+ // Fallback: before the trailing `---` footer, else EOF.
77
+ const footer = text.lastIndexOf("\n---\n");
78
+ const at = footer > 0 ? footer + 1 : text.length;
79
+ return { text: text.slice(0, at) + block + text.slice(at), inserted: true };
80
+ }
81
+
82
+ // The volatile frontmatter fields — the ones a re-run stamps fresh even when
83
+ // nothing else changed. --check must ignore exactly these, or a comparison
84
+ // against a moments-old baseline reports drift on every single run. started_at
85
+ // belongs here by the same mechanism as the rest: a `plan` re-run against a
86
+ // still-null field stamps a fresh nowIso() each time.
87
+ export const VOLATILE_FM = ["updated", "started_at", "closed_at", "plan_updated_at"];
88
+
89
+ // Both sides of a --check comparison, with the volatile fields and the footer
90
+ // date neutralised. setFm INSERTS an absent key at the END of the frontmatter,
91
+ // which is what makes a null closed_at compare equal to a fresh stamp. A
92
+ // hand-DELETED mid-frontmatter volatile key therefore reads as DRIFT (the
93
+ // insert lands at a different position) — erring safe. The masking that IS
94
+ // accepted: a hand-REWRITTEN volatile value (e.g. a bogus closed_at on a done
95
+ // story) compares equal, and closed_at anchors the legacy predicate and
96
+ // diff-refs --since — --check cannot guard those two consumers.
97
+ function checkNormalize(text) {
98
+ let t = text;
99
+ for (const k of VOLATILE_FM) t = setFm(t, k, '"«volatile»"');
100
+ return t.replace(footerDateRe(), (_m, prefix, suffix) => `${prefix}«volatile»${suffix}`);
101
+ }
102
+
103
+ function main() {
104
+ const argv = process.argv.slice(2);
105
+ let checkPath = null;
106
+ const positional = [];
107
+ for (let i = 0; i < argv.length; i++) {
108
+ const a = argv[i];
109
+ if (a === "--check") checkPath = argv[++i];
110
+ else if (a.startsWith("--check=")) checkPath = a.slice("--check=".length);
111
+ else positional.push(a);
112
+ }
113
+ if (checkPath === undefined || checkPath === "") die("--check requires a value");
114
+ const [mode, storyPath] = positional;
115
+ if (!["plan", "close"].includes(mode) || !storyPath) {
116
+ die("usage: story-section.mjs <plan|close> <story-path> [--check <baseline-file>]");
117
+ }
118
+ const cfg = readConfig();
119
+ if (!cfg) die("No projectstore config. Run /projectstore:bind first.");
120
+ const lang = cfg.language || "en";
121
+ const abs = resolve(storyPath);
122
+ if (!existsSync(abs)) die(`story not found: ${abs}`);
123
+
124
+ const original = readFileSync(abs, "utf8");
125
+ const fm = parseFrontmatter(original).data;
126
+ if (fm.type && fm.type !== "story") die(`not a story (type: ${fm.type})`);
127
+
128
+ let text = original;
129
+ const notes = [];
130
+ const now = nowIso();
131
+ const status = String(fm.status || "").toLowerCase();
132
+
133
+ if (mode === "plan") {
134
+ const r = insertSection(text, "implementation_plan", lang,
135
+ "<!-- Route through the covering spec's contracts: which contracts, in what order, which files. Filled at the gate. -->",
136
+ ["decomposition", "description"]);
137
+ text = r.text;
138
+ if (r.inserted) notes.push("inserted Implementation Plan section");
139
+ if (!["in-progress", "in_progress", "review", "done"].includes(status)) {
140
+ text = setFm(text, "status", "in-progress");
141
+ notes.push(`status: ${fm.status || "planned"} → in-progress`);
142
+ }
143
+ if (!fm.started_at || fm.started_at === "null") {
144
+ text = setFm(text, "started_at", JSON.stringify(now));
145
+ notes.push("stamped started_at");
146
+ }
147
+ text = setFm(text, "plan_updated_at", JSON.stringify(now));
148
+ notes.push("stamped plan_updated_at");
149
+ } else {
150
+ const r = insertSection(text, "final_summary", lang,
151
+ "<!-- What changed, why, tests executed, risks and follow-ups. Filled at the gate. -->",
152
+ ["acceptance", "decomposition", "description"]);
153
+ text = r.text;
154
+ if (r.inserted) notes.push("inserted Final Summary section");
155
+ if (status !== "done") {
156
+ text = setFm(text, "status", "done");
157
+ notes.push(`status: ${fm.status || "?"} → done`);
158
+ }
159
+ if (!fm.closed_at || fm.closed_at === "null") {
160
+ text = setFm(text, "closed_at", JSON.stringify(now));
161
+ notes.push("stamped closed_at");
162
+ }
163
+ }
164
+ text = setFm(text, "updated", today());
165
+ // Keep the body footer in step with frontmatter `updated:`. Registry-driven, so
166
+ // every bundled language is covered; the file's own label and punctuation are
167
+ // preserved and only the date is rewritten.
168
+ text = text.replace(footerDateRe(), (_m, prefix, suffix) => `${prefix}${today()}${suffix}`);
169
+
170
+ let check = null;
171
+ if (checkPath !== null) {
172
+ if (!existsSync(resolve(checkPath))) die(`--check baseline not found: ${checkPath}`);
173
+ const baseline = readFileSync(resolve(checkPath), "utf8").replace(/\r\n/g, "\n");
174
+ if (!/^---\n[\s\S]*?\n---/.test(baseline)) die(`--check baseline has no frontmatter block: ${checkPath}`);
175
+ const a = checkNormalize(text.replace(/\r\n/g, "\n"));
176
+ const b = checkNormalize(baseline);
177
+ const drift = [];
178
+ if (a !== b) {
179
+ // Trim the common prefix and suffix first: a single inserted line
180
+ // otherwise shifts every later pairing and the report drowns in
181
+ // artifacts of the shift instead of naming the change. Line numbers are
182
+ // post-normalization (volatile keys may have been inserted).
183
+ const al = a.split("\n"), bl = b.split("\n");
184
+ let lo = 0;
185
+ while (lo < al.length && lo < bl.length && al[lo] === bl[lo]) lo++;
186
+ let hiA = al.length - 1, hiB = bl.length - 1;
187
+ while (hiA > lo && hiB > lo && al[hiA] === bl[hiB]) { hiA--; hiB--; }
188
+ for (let i = 0; i <= Math.max(hiA, hiB) - lo && drift.length < 5; i++) {
189
+ const x = al[lo + i], y = bl[lo + i];
190
+ if (lo + i > hiA && lo + i > hiB) break;
191
+ drift.push(`normalized line ${lo + i + 1}: ${JSON.stringify((x ?? "«absent»").slice(0, 120))} vs ${JSON.stringify((y ?? "«absent»").slice(0, 120))}`);
192
+ }
193
+ }
194
+ check = { baseline: resolve(checkPath), match: a === b, drift };
195
+ }
196
+
197
+ process.stdout.write(JSON.stringify({
198
+ path: abs,
199
+ mode,
200
+ changed: text !== original,
201
+ check,
202
+ notes,
203
+ content: text,
204
+ }, null, 2) + "\n");
205
+ }
206
+
207
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
208
+ main();
209
+ }