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,336 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — touch-session.mjs
3
+ //
4
+ // Called by BOTH the PreToolUse and PostToolUse hooks, and gated on which:
5
+ // a write inside the vault fires both events, so without the gate the pointer
6
+ // patch below would run twice per call — and that patch is the unguarded
7
+ // read-modify-write the entry-rule state deliberately avoids.
8
+ //
9
+ // Responsibilities:
10
+ // 1. Liveness — touches this session's registration file (mtime) so other
11
+ // sessions can see we are active. Bootstraps the record on first call
12
+ // when SessionStart did not run (plugin installed mid-session via
13
+ // /reload-plugins, or rare path where SessionStart was skipped).
14
+ // 2. Activity log — extracts the target file path from the tool input
15
+ // and, if it lives inside the vault, appends an entry to
16
+ // `recent_activity` in the session file (capped at 50, deduped).
17
+ // The PreCompact hook reads this list to build a survival packet.
18
+ // 3. Entry-rule detection (PostToolUse only) — counts distinct SOURCE paths
19
+ // written this session and, once the threshold is reached with no story
20
+ // open, emits the one advisory reminder. PostToolUse rather than
21
+ // PreToolUse because it fires only after a call succeeds, so declined
22
+ // edits are not counted as work that happened. Spec: "Entry-rule
23
+ // detection: the score, the open-story predicate, and the delivery seams".
24
+ //
25
+ // Session identity comes from Claude's own `session_id` field in the
26
+ // hook input JSON (stdin), so two Claude Code instances open on the
27
+ // same project get distinct session files.
28
+ //
29
+ // Silent no-op when there is no projectstore config, when stdin lacks
30
+ // session_id, or on any error — a PreToolUse hook must never crash the
31
+ // user's tool call.
32
+
33
+ import { existsSync, readFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import {
36
+ readConfig,
37
+ readStdinJson,
38
+ adoptHookInput,
39
+ touchSession,
40
+ writeSession,
41
+ cleanupStaleSessions,
42
+ sessionFilePath,
43
+ projectRoot,
44
+ appendActivity,
45
+ isInsideVault,
46
+ parseFrontmatter,
47
+ readSessionState,
48
+ writeSessionState,
49
+ isSourcePath,
50
+ registerSourcePath,
51
+ entryScore,
52
+ resolveOpenStory,
53
+ readOpenStoryCache,
54
+ writeOpenStoryCache,
55
+ mayRemind,
56
+ electEmitter,
57
+ appendEntryLog,
58
+ entryReminderText,
59
+ ENTRY_THRESHOLD,
60
+ isWriteTool,
61
+ loadLayout,
62
+ anchorKeyOf,
63
+ foldAnchor,
64
+ composeAnchorName,
65
+ bumpAnchor,
66
+ readAnchorState,
67
+ readAnchorOffer,
68
+ writeAnchorOffer,
69
+ liveSessionNames,
70
+ ownSessionName,
71
+ sessionNameOffer,
72
+ sessionNameOfferText,
73
+ toolPaths,
74
+ } from "./lib.mjs";
75
+
76
+ const NUDGE_INTERVAL_MS = 10 * 60 * 1000;
77
+
78
+ // Per-session active pointer (ADR-006) — denormalized titles/status captured
79
+ // at write time so the statusline renders with zero vault reads — plus the
80
+ // raw-edit nudge (PS-IMPROVE story-003): throttled, never blocking.
81
+ // The session-name offer (ADR: the settled-anchor offer). Returns a line or
82
+ // null. Separate from the pointer patch because the two answer different
83
+ // questions — the pointer is "what is current", this is "has the session
84
+ // settled" — and because this one must be gated where the pointer is not.
85
+ let LAYOUT = null;
86
+ function layoutOnce(cfg) {
87
+ if (LAYOUT === null) LAYOUT = loadLayout(cfg.layout);
88
+ return LAYOUT;
89
+ }
90
+
91
+ function anchorOffer(cfg, proj, sid, filePath, toolName, isSubagent, sessionsDir = null) {
92
+ // The two gates. The pointer patch above deliberately has neither: it answers
93
+ // "what am I looking at", for which a Read is a fine answer. A NAME is a
94
+ // claim about what the session is DOING, so a review session that greps
95
+ // thirty files must not be named after them, and a subagent — which shares
96
+ // this session id and cannot accept a name — must not vote on one.
97
+ if (!isWriteTool(toolName || "")) return null;
98
+ if (isSubagent) return null;
99
+
100
+ const rel = filePath.slice(cfg.vault_path.length + 1);
101
+ let hit = null;
102
+ try { hit = anchorKeyOf(rel, layoutOnce(cfg)); } catch { return null; }
103
+ if (!hit) return null;
104
+
105
+ // Both slots: the key tally is what settles an anchor, the leaf tally only
106
+ // picks which story names it. Bumping the leaf alone leaves the anchor
107
+ // permanently unsettled — silently, since a rule that never fires looks
108
+ // exactly like a quiet one.
109
+ bumpAnchor(proj, sid, hit.key, null);
110
+ if (hit.leaf) bumpAnchor(proj, sid, hit.key, hit.leaf);
111
+
112
+ const prev = readAnchorState(proj, sid);
113
+ // The tally on disk already includes this event, so replay it against the
114
+ // state MINUS this event: fold is what decides an offer, and it must see the
115
+ // increment happen rather than find it already there.
116
+ const counts = { ...prev.counts };
117
+ if (counts[hit.key]) counts[hit.key] -= 1;
118
+ const leaves = { ...prev.leaves };
119
+ if (hit.leaf && leaves[hit.key] && leaves[hit.key][hit.leaf]) {
120
+ leaves[hit.key] = { ...leaves[hit.key], [hit.leaf]: leaves[hit.key][hit.leaf] - 1 };
121
+ }
122
+ const { state, offer } = foldAnchor(
123
+ { counts, leaves, incumbent: prev.incumbent, offered: prev.offered }, hit);
124
+ if (!offer) return null;
125
+
126
+ const rec = readAnchorOffer(proj, sid);
127
+ const current = ownSessionName(sid, sessionsDir);
128
+ // A session almost always arrives already wearing a name the harness assigned
129
+ // — so "current is not our last offer" does NOT mean the person chose it.
130
+ // Reading it that way silenced the feature permanently for every real
131
+ // session: on the first offer `offered` is null, so any pre-existing name was
132
+ // classified as a deliberate choice and nothing was ever spoken again.
133
+ // A name is the person's only if we have spoken at least once and it is none
134
+ // of the names we composed.
135
+ const declined = current && rec.offers.length > 0 && !rec.offers.includes(current)
136
+ && !rec.declined.includes(current)
137
+ ? [...rec.declined, current]
138
+ : rec.declined;
139
+
140
+ const chosen = sessionNameOffer(offer.name, {
141
+ peers: liveSessionNames(sid, sessionsDir),
142
+ current,
143
+ declined,
144
+ });
145
+ // The incumbent moved whether or not we speak, or the same anchor would
146
+ // re-arm and offer again on the next write. But `offered` records what was
147
+ // actually SAID: a name suppressed by the collision ladder was never heard,
148
+ // and burning it would keep this session silent after the peer exits.
149
+ writeAnchorOffer(proj, sid, {
150
+ incumbent: state.incumbent,
151
+ offered: chosen ? chosen.name : rec.offered,
152
+ offers: chosen ? [...new Set([...rec.offers, chosen.name])] : rec.offers,
153
+ declined,
154
+ });
155
+ if (!chosen) return null;
156
+ try {
157
+ appendEntryLog(proj, {
158
+ at: new Date().toISOString(), session_id: sid, kind: "name-offer", name: chosen.name,
159
+ });
160
+ } catch {}
161
+ return sessionNameOfferText(chosen);
162
+ }
163
+
164
+ function updatePointerAndNudge(cfg, proj, sid, filePath, toolName) {
165
+ const rel = filePath.slice(cfg.vault_path.length + 1);
166
+ let patch = null;
167
+
168
+ const m = rel.match(/^epics\/([^/]+)\//);
169
+ if (m) {
170
+ const epicId = m[1];
171
+ patch = { active_epic: epicId };
172
+ try {
173
+ const { data } = parseFrontmatter(
174
+ readFileSync(join(cfg.vault_path, "epics", epicId, "epic.md"), "utf8"));
175
+ if (data.title) patch.epic_title = String(data.title);
176
+ } catch {}
177
+ const sm = rel.match(/^epics\/[^/]+\/stories\/([^/]+\.md)$/);
178
+ if (sm) {
179
+ patch.active_story = sm[1].replace(/\.md$/, "");
180
+ try {
181
+ const { data } = parseFrontmatter(readFileSync(filePath, "utf8"));
182
+ if (data.title) patch.story_title = String(data.title);
183
+ if (data.status) patch.story_status = String(data.status);
184
+ } catch {}
185
+ }
186
+ }
187
+
188
+ let nudge = null;
189
+ if (isWriteTool(toolName || "") && cfg.guard !== "off") {
190
+ const st = readSessionState(proj, sid);
191
+ const last = st && st.nudged_at ? Date.parse(st.nudged_at) : 0;
192
+ if (Date.now() - last > NUDGE_INTERVAL_MS) {
193
+ nudge =
194
+ "projectstore: vault file edited directly — if this bypassed a /projectstore:* command, run /projectstore:reconcile (or doctor) afterwards so the board/indexes stay in sync.";
195
+ patch = { ...(patch || {}), nudged_at: new Date().toISOString() };
196
+ }
197
+ }
198
+
199
+ if (patch) writeSessionState(proj, sid, patch);
200
+ return nudge;
201
+ }
202
+
203
+ // The source-side branch (PostToolUse). Never throws: every failure here must
204
+ // leave the user's tool call untouched.
205
+ async function entryBranch(cfg, proj, sid, filePath, input) {
206
+ // Writes only. extractToolPath happily yields a path for Read, Grep, Glob and
207
+ // LS — all of which carry file_path or path — so without this gate three
208
+ // read-only calls would trip the threshold and the reminder would announce
209
+ // work that never happened. (Grep's `path` is often a directory, which would
210
+ // then be counted as a "source file".) The event tells us the call succeeded;
211
+ // only the tool name tells us it wrote.
212
+ if (!isWriteTool(input.tool_name || "")) return;
213
+ if (!isSourcePath(filePath, proj, cfg.vault_path)) return;
214
+
215
+ // Belt and braces. PostToolUse only fires after success — failures raise
216
+ // PostToolUseFailure, which this script is not registered on — so the event
217
+ // itself is the discrimination. Nothing may DEPEND on this field's shape, and
218
+ // the shape varies by BOTH harness and tool: Claude Code sends an object;
219
+ // Codex sent a string for 368 of 371 PostToolUse firings and a content-block
220
+ // array for the other 3, all of them `webrun` (measured 2026-09-07). So the
221
+ // guard is written for "an object with success === false", and every other
222
+ // shape is a pass-through. Narrowed explicitly rather than relying on
223
+ // `"…".success` being undefined — that is an accident of JavaScript, not a
224
+ // decision, and it does not hold for every shape a tool may invent.
225
+ const response = input.tool_response;
226
+ if (response && typeof response === "object" && response.success === false) return;
227
+
228
+ registerSourcePath(proj, sid, filePath);
229
+
230
+ // Subagents count toward the score but are never the audience: a reminder
231
+ // delivered there reaches an actor mid-implementation under explicit
232
+ // instructions, who cannot open a story.
233
+ if (input.agent_id || input.agent_type) return;
234
+ if (cfg.guard === "off") return;
235
+
236
+ const score = entryScore(proj, sid);
237
+ if (score < ENTRY_THRESHOLD) return;
238
+ if (!mayRemind(proj, sid)) return;
239
+
240
+ let verdict = readOpenStoryCache(proj, sid);
241
+ if (verdict === null) {
242
+ verdict = await resolveOpenStory(cfg.vault_path);
243
+ writeOpenStoryCache(proj, sid, verdict);
244
+ }
245
+ if (verdict === "unknown") {
246
+ // Reached either from a fresh sweep that hit its budget with reads still
247
+ // outstanding (the event loop would keep this hook alive until they settle)
248
+ // or from a cached unknown, where nothing is outstanding and the exit is
249
+ // merely redundant. Exiting is safe here and only here: an unknown verdict suppresses the reminder, so stdout is
250
+ // untouched — process.exit does not flush pending pipe writes, and exiting
251
+ // after emitting would truncate the reminder.
252
+ process.exit(0);
253
+ }
254
+ if (verdict !== false) return;
255
+
256
+ if (!electEmitter(proj, sid)) return;
257
+ appendEntryLog(proj, { at: new Date().toISOString(), session_id: sid, score });
258
+ process.stdout.write(JSON.stringify({
259
+ hookSpecificOutput: {
260
+ hookEventName: "PostToolUse",
261
+ additionalContext: entryReminderText(score),
262
+ },
263
+ }) + "\n");
264
+ }
265
+
266
+ async function main() {
267
+ // The payload before the project. On a harness that exports no project-dir
268
+ // variable the payload's cwd is the only answer better than "whatever
269
+ // directory this process started in", and readConfig() resolves through
270
+ // projectRoot() — so anything read before this line answers for the wrong
271
+ // project, silently. Read once: fd 0 is empty on a second read.
272
+ const input = adoptHookInput(readStdinJson());
273
+ if (!input) return;
274
+
275
+ const cfg = readConfig();
276
+ if (!cfg) return;
277
+
278
+ const sid = input.session_id;
279
+ if (!sid) return;
280
+
281
+ const proj = projectRoot();
282
+ const event = input.hook_event_name;
283
+
284
+ // Liveness and the vault-side branch stay on PreToolUse, exactly as before.
285
+ // PreToolUse always precedes PostToolUse for the same call, so pinning them
286
+ // here changes nothing about when they run — it only stops them running twice.
287
+ if (event !== "PostToolUse") {
288
+ if (existsSync(sessionFilePath(cfg.vault_path, sid))) {
289
+ touchSession(cfg.vault_path, sid);
290
+ } else {
291
+ // The exemption (contract 23) is passed for agreement with the other
292
+ // caller, not for effect: this branch runs only when our own session file
293
+ // does NOT exist, so there is nothing here to exempt. Two callers of one
294
+ // function disagreeing about its signature is how the next reader
295
+ // concludes the argument is optional.
296
+ cleanupStaleSessions(cfg.vault_path, 24, sid);
297
+ writeSession(cfg.vault_path, sid, proj);
298
+ }
299
+ }
300
+
301
+ if (!input.tool_name) return;
302
+ const filePaths = toolPaths(input);
303
+ if (!filePaths.length) return;
304
+
305
+ if (event === "PostToolUse") {
306
+ for (const filePath of filePaths) await entryBranch(cfg, proj, sid, filePath, input);
307
+ return;
308
+ }
309
+
310
+ const messages = [];
311
+ for (const filePath of filePaths) if (isInsideVault(filePath, cfg.vault_path)) {
312
+ try { appendActivity(cfg.vault_path, sid, filePath, input.tool_name); } catch {}
313
+ let nudge = null, offer = null;
314
+ try { nudge = updatePointerAndNudge(cfg, proj, sid, filePath, input.tool_name); } catch {}
315
+ if (cfg.guard !== "off") {
316
+ try {
317
+ offer = anchorOffer(cfg, proj, sid, filePath, input.tool_name,
318
+ Boolean(input.agent_id || input.agent_type),
319
+ process.env.PROJECTSTORE_SESSIONS_DIR || null);
320
+ } catch {}
321
+ }
322
+ // One invocation carries one systemMessage, so the two compose rather than
323
+ // race: emitting twice would silently drop whichever went second, and the
324
+ // offer fires once or twice a session against a nudge that fires every ten
325
+ // minutes — the rare one would be the one lost.
326
+ messages.push(...[nudge, offer].filter(Boolean));
327
+ }
328
+ if (messages.length) {
329
+ process.stdout.write(JSON.stringify({ systemMessage: [...new Set(messages)].join("\n") }) + "\n");
330
+ }
331
+ }
332
+
333
+ main().catch(() => {
334
+ // A hook must never crash the user's tool call — sync throws and rejected
335
+ // promises alike.
336
+ });
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — version-guard.mjs (PS-HARNESS: "Claim the npm name and ship
3
+ // the first release")
4
+ //
5
+ // The release-time guard. It did not exist before this story: ADR-009 and the
6
+ // harness-landscape research note both describe "the version-check guard" as
7
+ // something already in place, and neither was true — there was no script, no
8
+ // test and no CI step. Doctor's version checks are a different guard entirely
9
+ // (runtime: an installed plugin against the marketplace), and they stay.
10
+ //
11
+ // node scripts/version-guard.mjs [--tag vX.Y.Z]
12
+ // node scripts/version-guard.mjs --write-packlist
13
+ //
14
+ // Checks that every version-bearing manifest agrees, and — when --tag is
15
+ // given — that the release tag agrees with them. CI passes the tag; a human
16
+ // running it before a manual publish does not, because publish #1 is not
17
+ // tag-triggered and nothing else covers it.
18
+ //
19
+ // Exit 0 when everything agrees, 1 on disagreement or a malformed manifest.
20
+ // Output is JSON on stdout, always, so a workflow step can quote it.
21
+
22
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
23
+ import { spawnSync } from "node:child_process";
24
+ import { resolve, dirname } from "node:path";
25
+ import { fileURLToPath, pathToFileURL } from "node:url";
26
+ // Contract 2 of the atomic-regeneration work: nothing under scripts/ writes
27
+ // directly, and tests/scripts.test.mjs enforces it by globbing this directory.
28
+ import { writeFileAtomic } from "./lib.mjs";
29
+ import { LAYOUT } from "./harness.mjs";
30
+
31
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
32
+ export const PACKLIST = "tests/fixtures/packlist.json";
33
+
34
+ // [path, how to read the version, required]. Required sites must exist: with
35
+ // everything optional, a checkout missing package.json reported agreement,
36
+ // because "no site disagrees" is trivially true when there are no sites.
37
+ const VERSION_SITES = [
38
+ ["package.json", (j) => j.version, true],
39
+ [".claude-plugin/plugin.json", (j) => j.version, true],
40
+ // Found by name, not by position: `plugins[0]` reads a sibling plugin's
41
+ // version the moment the marketplace lists more than one, and reports the
42
+ // disagreement against the wrong file.
43
+ [
44
+ ".claude-plugin/marketplace.json",
45
+ (j) => j.plugins?.find((p) => p.name === "projectstore")?.version,
46
+ true,
47
+ ],
48
+ ];
49
+
50
+ // The distribution shells (the shells ADR; layout spec contract 11): one
51
+ // package.json per shell under packaging/shells/, each at the core's version
52
+ // with an exact pin on it. Read directly — the directory is in the repository
53
+ // but not in the tarball, and the guard runs from the repository.
54
+ export const SHELLS_DIR = "packaging/shells";
55
+
56
+ export function collectShells(root = ROOT) {
57
+ const dir = resolve(root, SHELLS_DIR);
58
+ if (!existsSync(dir)) return { shells: [] };
59
+ const shells = [];
60
+ // Directories only: a .DS_Store Finder drops on a browse is not a shell.
61
+ for (const name of readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort()) {
62
+ const rel = `${SHELLS_DIR}/${name}/package.json`;
63
+ const abs = resolve(root, rel);
64
+ if (!existsSync(abs)) return { error: `${rel}: missing — every directory under ${SHELLS_DIR}/ is a shell` };
65
+ let json;
66
+ try { json = JSON.parse(readFileSync(abs, "utf8")); } catch (e) { return { error: `${rel}: ${e.message}` }; }
67
+ if (json.name !== name) return { error: `${rel}: name "${json.name}" is not its directory's` };
68
+ if (typeof json.version !== "string" || !json.version) return { error: `${rel}: no version found where one is required` };
69
+ const pluginVersions = [];
70
+ if (name === "projectstore-codex") {
71
+ for (const manifest of ["plugin.json", ".codex-plugin/plugin.json"]) {
72
+ const manifestRel = `${SHELLS_DIR}/${name}/${manifest}`;
73
+ const manifestAbs = resolve(root, manifestRel);
74
+ if (!existsSync(manifestAbs)) return { error: `${manifestRel}: missing — the Codex shell carries both canonical and compatibility manifests` };
75
+ let parsed;
76
+ try { parsed = JSON.parse(readFileSync(manifestAbs, "utf8")); } catch (e) { return { error: `${manifestRel}: ${e.message}` }; }
77
+ if (typeof parsed.version !== "string" || !parsed.version) return { error: `${manifestRel}: no version found where one is required` };
78
+ pluginVersions.push({ file: manifestRel, version: parsed.version });
79
+ }
80
+ }
81
+ shells.push({
82
+ name, file: rel, version: json.version,
83
+ pin: json.dependencies?.projectstore ?? null,
84
+ private: json.private === true,
85
+ bin: json.bin?.[name] ?? null,
86
+ binExists: typeof json.bin?.[name] === "string" && existsSync(resolve(root, SHELLS_DIR, name, json.bin[name])),
87
+ bundled: Array.isArray(json.bundleDependencies) && json.bundleDependencies.includes("projectstore"),
88
+ pluginVersions,
89
+ });
90
+ }
91
+ return { shells };
92
+ }
93
+
94
+ function die(msg) {
95
+ process.stdout.write(JSON.stringify({ ok: false, error: msg }) + "\n");
96
+ process.exit(1);
97
+ }
98
+
99
+ export function collectVersions(root = ROOT) {
100
+ if (existsSync(resolve(root, ".codex-plugin", "plugin.json"))) {
101
+ return { error: ".codex-plugin/plugin.json must live in the projectstore-codex shell, not at the core package root" };
102
+ }
103
+ const found = [];
104
+ for (const [rel, pick, required] of VERSION_SITES) {
105
+ if (!existsSync(resolve(root, rel))) {
106
+ if (required) return { error: `${rel}: missing, and it carries the release version` };
107
+ continue;
108
+ }
109
+ let json;
110
+ try {
111
+ json = JSON.parse(readFileSync(resolve(root, rel), "utf8"));
112
+ } catch (e) {
113
+ return { error: `${rel}: ${e.message}` };
114
+ }
115
+ const version = pick(json);
116
+ if (typeof version !== "string" || !version) {
117
+ return { error: `${rel}: no version found where one is required` };
118
+ }
119
+ found.push({ file: rel, version });
120
+ }
121
+ return { found };
122
+ }
123
+
124
+ // `v0.25.0` and `0.25.0` are the same release; the tag carries the prefix by
125
+ // this repository's convention and the manifests never do.
126
+ const stripV = (t) => (t.startsWith("v") ? t.slice(1) : t);
127
+
128
+ export function checkVersions({ root = ROOT, tag = null } = {}) {
129
+ const { found, error } = collectVersions(root);
130
+ if (error) return { ok: false, error };
131
+ const sh = collectShells(root);
132
+ if (sh.error) return { ok: false, error: sh.error };
133
+
134
+ // A shell's version is one more site under the same rule.
135
+ const sites = [...found, ...sh.shells.flatMap((s) => [{ file: s.file, version: s.version }, ...(s.pluginVersions || [])])];
136
+ if (tag) sites.push({ file: "<tag>", version: stripV(tag) });
137
+ const distinct = [...new Set(sites.map((s) => s.version))];
138
+ if (distinct.length !== 1) return { ok: false, error: "version mismatch", versions: distinct, checked: sites };
139
+
140
+ // Its pin is exact and equal to that version; its core is bundled; its bin
141
+ // is the one file it ships. A range here is the unverified pairing the
142
+ // shells ADR rejects; a missing bundle ships an installer with no core.
143
+ for (const s of sh.shells) {
144
+ if (s.pin !== `=${s.version}`) return { ok: false, error: "shell pin mismatch", shell: s.name, pin: s.pin, expected: `=${s.version}`, checked: sites };
145
+ if (!s.bundled) return { ok: false, error: "shell does not bundle the core", shell: s.name, checked: sites };
146
+ if (s.bin !== `bin/${s.name}.mjs` || !s.binExists) return { ok: false, error: "shell bin missing or misnamed", shell: s.name, bin: s.bin, expected: `bin/${s.name}.mjs`, checked: sites };
147
+ }
148
+ return { ok: true, version: distinct[0], checked: sites, shells: sh.shells.map((s) => ({ name: s.name, private: s.private })) };
149
+ }
150
+
151
+ // Everything at the repository root that is deliberately NOT in the tarball.
152
+ // This list is the whole point of the tree check below: `npm pack` can only
153
+ // report what `files[]` already allows, so comparing pack output against the
154
+ // fixture is blind to a directory missing from `files[]` — it is absent from
155
+ // both sides and they agree. The tree, not the pack output, is the only place
156
+ // a forgotten directory is visible.
157
+ //
158
+ // Adding a name here is a decision that it must never ship. Anything else new
159
+ // at the root fails the check until it is added to `files[]`.
160
+ export const NOT_SHIPPED = new Set([
161
+ ".git",
162
+ ".github", // release/test workflows — not part of the plugin payload
163
+ ".claude", // local worktrees and the harness's own files
164
+ LAYOUT.root, // this project's own binding, overlays and state (the layout ADR)
165
+ ".omc",
166
+ ".gitignore",
167
+ ".DS_Store",
168
+ "node_modules",
169
+ // `packaging/shells.mjs --build --out dist` writes here, and the release
170
+ // workflow does exactly that. Gitignored, so it is invisible to git status —
171
+ // and the tree check is the only place an unlisted root directory shows up.
172
+ "dist",
173
+ "CLAUDE.md", // a pointer to AGENTS.md, which does ship
174
+ "tests", // 240 kB of fixtures nobody installing the plugin needs
175
+ "packaging", // reserved-name stubs; see packaging/README.md
176
+ "adapters", // generated harness inputs copied into their distribution shells at build time
177
+ "package-lock.json",
178
+ ]);
179
+
180
+ // Top-level entries that are neither shipped nor deliberately excluded. Kept
181
+ // pure — it takes the tree listing rather than reading it — so a test can hand
182
+ // it a directory that does not exist and watch it fail.
183
+ export function unshippedTopLevel(entries, packlist) {
184
+ const shipped = new Set(
185
+ packlist.map((p) => (p.includes("/") ? p.slice(0, p.indexOf("/")) : p)),
186
+ );
187
+ return entries
188
+ .filter((e) => e !== "." && e !== ".." && !shipped.has(e) && !NOT_SHIPPED.has(e))
189
+ .sort();
190
+ }
191
+
192
+ // The packlist is the second release-time invariant: what npm would actually
193
+ // ship. Stored as sorted paths only — sizes churn on every content edit and
194
+ // would make the fixture unreviewable.
195
+ export function currentPacklist(root = ROOT) {
196
+ const r = spawnSync("npm", ["pack", "--dry-run", "--json"], {
197
+ cwd: root,
198
+ encoding: "utf8",
199
+ timeout: 120000,
200
+ });
201
+ if (r.status !== 0) return { error: `npm pack failed: ${r.stderr?.trim()}` };
202
+ try {
203
+ const [pkg] = JSON.parse(r.stdout);
204
+ return { files: pkg.files.map((f) => f.path).sort() };
205
+ } catch (e) {
206
+ return { error: `npm pack output unparseable: ${e.message}` };
207
+ }
208
+ }
209
+
210
+ async function main(argv) {
211
+ const tagIdx = argv.indexOf("--tag");
212
+ const tag = tagIdx !== -1 ? argv[tagIdx + 1] : null;
213
+ if (tagIdx !== -1 && !tag) die("--tag requires a value");
214
+
215
+ if (argv.includes("--write-packlist")) {
216
+ const { files, error } = currentPacklist();
217
+ if (error) die(error);
218
+ try {
219
+ writeFileAtomic(resolve(ROOT, PACKLIST), JSON.stringify(files, null, 2) + "\n");
220
+ } catch (e) {
221
+ // The header promises JSON on stdout always; an escaping stack trace
222
+ // would break the one caller that parses this.
223
+ die(`${PACKLIST}: ${e.message}`);
224
+ }
225
+ // The shells' fixtures, from the core's CURRENT pack — after the core's
226
+ // fixture, since each lists the core's files under node_modules/. A lazy
227
+ // import: scripts/ ships and packaging/ does not, so a static specifier
228
+ // here would ship a module that cannot load.
229
+ const shellsModule = resolve(ROOT, "packaging", "shells.mjs");
230
+ const shells = [];
231
+ if (existsSync(shellsModule)) {
232
+ const m = await import(pathToFileURL(shellsModule).href);
233
+ const built = m.buildShells({ root: ROOT });
234
+ if (built.error) die(built.error);
235
+ for (const b of built.shells) {
236
+ if (b.error) die(b.error);
237
+ const rel = m.shellPacklistPath(b.name);
238
+ try { writeFileAtomic(resolve(ROOT, rel), JSON.stringify(b.files, null, 2) + "\n"); } catch (e) { die(`${rel}: ${e.message}`); }
239
+ shells.push({ wrote: rel, count: b.files.length });
240
+ }
241
+ }
242
+ process.stdout.write(
243
+ JSON.stringify({ ok: true, wrote: PACKLIST, count: files.length, shells }) + "\n",
244
+ );
245
+ return;
246
+ }
247
+
248
+ const result = checkVersions({ tag });
249
+ process.stdout.write(JSON.stringify(result) + "\n");
250
+ if (!result.ok) process.exit(1);
251
+ }
252
+
253
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
254
+ await main(process.argv.slice(2));
255
+ }