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,375 @@
1
+ // projectstore — the provenance line: one emitter, one parser, one state ladder.
2
+ //
3
+ // Every file projectstore places into a harness (a Codex prompt, an opencode
4
+ // agent, a generated hook wrapper) carries one line that says what produced
5
+ // it and lets a later run decide whether it may touch the file again:
6
+ //
7
+ // projectstore: v1 src=agents/critic.md@1a2b3c4d5e6f pkg=0.27.2 project="/abs/path" render=9f8e7d6c5b4a — generated; edit the source, then reinstall
8
+ //
9
+ // Two hashes. `src=` is the source bytes, so a stale copy of a changed source
10
+ // is detectable. `render=` is the rendered file with THIS WHOLE LINE replaced
11
+ // by the literal token %PROJECTSTORE-PROVENANCE%, so the hash never covers
12
+ // the hash it is about and no field of the line — `project=` included — can
13
+ // churn it. Rendering is therefore two-pass: render with the token line, hash,
14
+ // substitute. A file two projects rendered identically differs only in the
15
+ // line, which is what makes "current, last written by <project>" reportable.
16
+ //
17
+ // This module is deliberately a LEAF. It imports node:crypto and nothing else;
18
+ // it opens no file, reads no environment variable and has no clock. Every
19
+ // "current" input the state ladder needs — the source hash, the package
20
+ // version, the hash of what we would render now — is handed in by the
21
+ // caller, so the ladder is a pure function testable with string literals.
22
+ // It is not part of scripts/lib.mjs on purpose: lib.mjs is parsed on every
23
+ // SessionStart, and the installer and doctor are the only consumers here.
24
+ // The direction is installer → provenance ← doctor; nothing in this file may
25
+ // ever import the installer, harness.mjs or lib.mjs.
26
+ //
27
+ // Normative: the spec "Installing, refreshing and disowning a harness
28
+ // surface" (contract 1 — the grammar; 3 and 4 — the four states and the
29
+ // ordered derivation) and "Generated harness surfaces" (contract 15 — every
30
+ // generated non-JSON file is stamped, and the banner says to edit the source).
31
+ // The two-hash line, the two-pass render and the four-state model are
32
+ // contributed by Ivan Morozov (MultiProjectStore, scripts/agents.mjs:
33
+ // provenance(), renderHashOf(), status()); the generated-file banner and its
34
+ // three per-format renderers are contributed by Maxim Podreshetnikov (PR #13,
35
+ // scripts/build-adapters.mjs: BANNER_LINES, mdBanner, hashBanner,
36
+ // slashBanner). `v1`, `pkg=`, `project=` and the four-reason stale split are
37
+ // ours.
38
+
39
+ import { createHash } from "node:crypto";
40
+
41
+ // ─── Constants ─────────────────────────────────────────────────────────
42
+
43
+ export const GRAMMAR_VERSION = 1;
44
+ export const PROVENANCE_TOKEN = "%PROJECTSTORE-PROVENANCE%";
45
+ // The project path is provenance data (the line's project= field) and may also
46
+ // appear in the body — the status-line launcher names its project since
47
+ // 2026-09-06. It is neutralised before the render hash, so a file another
48
+ // project wrote at our path still reads "current, last written by <project>"
49
+ // (install spec, contract 12) instead of "edited by hand".
50
+ export const PROJECT_TOKEN = "%PROJECTSTORE-PROJECT%";
51
+ export function neutralizeProject(text, project) {
52
+ if (typeof text !== "string" || typeof project !== "string" || !project) return text;
53
+ return text.split(JSON.stringify(project)).join(PROJECT_TOKEN);
54
+ }
55
+ export const HASH_LEN = 12;
56
+ export const DEFAULT_GENERATOR = "scripts/build-adapters.mjs";
57
+
58
+ // Per-format delimiters around the provenance line. `json` is null on
59
+ // purpose: a format that cannot carry a comment is a SHARED surface (install
60
+ // spec contracts 2 and 6), owned per entry, and is never stamped — emitting
61
+ // an empty banner for it would let a caller ship an unowned file in silence.
62
+ export const DELIMITERS = Object.freeze({
63
+ markdown: Object.freeze(["<!-- ", " -->"]),
64
+ toml: Object.freeze(["# ", ""]),
65
+ // The hook wrappers are JavaScript, where `#` is a syntax error rather than
66
+ // a comment. PR #13 records that emitting the hash banner into the wrapper
67
+ // produced a file that parsed nowhere — hence its third renderer.
68
+ mjs: Object.freeze(["// ", ""]),
69
+ json: null,
70
+ });
71
+
72
+ export const STALE = Object.freeze({
73
+ EDITED: "edited-by-hand",
74
+ SOURCE: "source-changed",
75
+ PLUGIN: "plugin-updated",
76
+ CONFIG: "configuration-changed",
77
+ });
78
+
79
+ // Doctor's exact wording, in one place, so the report and the tests read the
80
+ // same source. A stale finding with no reason is a bug report the user cannot
81
+ // act on (install spec, contract 4).
82
+ export const STALE_TEXT = Object.freeze({
83
+ [STALE.EDITED]: "edited by hand",
84
+ [STALE.SOURCE]: "source changed",
85
+ [STALE.PLUGIN]: "plugin updated",
86
+ [STALE.CONFIG]: "configuration changed",
87
+ });
88
+
89
+ // Contract 5's resolution wording (Ivan Morozov's), in one place for the
90
+ // verbs that refuse and the report that names.
91
+ export const FOREIGN_TEXT = "a file we did not write sits at our path — rename it if it is yours, or delete it to let install take the name";
92
+
93
+ const normalizeEol = (s) => s.replace(/\r\n/g, "\n");
94
+
95
+ function unsupportedFormat(format) {
96
+ return new Error(
97
+ `format "${format}" cannot carry a provenance line — a JSON surface is a ` +
98
+ `SHARED surface (install spec contracts 2 and 6), owned per entry, not stamped; ` +
99
+ `known stampable formats: ${Object.keys(DELIMITERS).filter((k) => DELIMITERS[k]).join(", ")}`,
100
+ );
101
+ }
102
+
103
+ function delimitersFor(format) {
104
+ const d = DELIMITERS[format];
105
+ if (!d) throw unsupportedFormat(format);
106
+ return d;
107
+ }
108
+
109
+ // ─── Hashing ───────────────────────────────────────────────────────────
110
+
111
+ // The one truncation point. `src=` and `render=` must never truncate
112
+ // differently, and the parser's `[0-9a-f]{12}` is this length.
113
+ export function hash12(text) {
114
+ if (typeof text !== "string") {
115
+ // A caller bug (an unread file, an undefined field) must not produce a
116
+ // well-formed hash that looks exactly like a legitimate one.
117
+ throw new TypeError(`hash12: expected a string, got ${text === null ? "null" : typeof text}`);
118
+ }
119
+ return createHash("sha256").update(text).digest("hex").slice(0, HASH_LEN);
120
+ }
121
+
122
+ // The source bytes, as read by the caller. EOL-normalised like renderHash,
123
+ // for the same reason: a CRLF checkout must not report every installed file
124
+ // as "source changed" one ladder step after "edited by hand" was ruled out.
125
+ export function sourceHash(text) {
126
+ return hash12(normalizeEol(String(text)));
127
+ }
128
+
129
+ // The ONLY function that computes `render=`. Both the emitting pass and the
130
+ // checking pass go through it, so they cannot disagree — and the EOL
131
+ // normalisation lives here, once, so a Windows checkout with autocrlf does not
132
+ // report every installed file as "edited by hand".
133
+ export function renderHash(tokenizedText) {
134
+ return hash12(normalizeEol(String(tokenizedText)));
135
+ }
136
+
137
+ // ─── Emit ──────────────────────────────────────────────────────────────
138
+
139
+ // The bare line, no delimiters. One emitter (install spec, contract 1).
140
+ // `project` is JSON-quoted because real vault and project paths carry spaces
141
+ // (and the maintainer's own does); the parser JSON-decodes it back.
142
+ export function provenanceLine({ src, srcHash, pkg, project, render }) {
143
+ for (const [k, v] of Object.entries({ src, srcHash, pkg, project, render })) {
144
+ if (typeof v !== "string" || v.length === 0) {
145
+ throw new Error(`provenanceLine: field "${k}" must be a non-empty string`);
146
+ }
147
+ }
148
+ // `src` and `pkg` are bare tokens in the grammar (`\S+`). A value with
149
+ // whitespace would be emitted, fail to parse back, derive `foreign`, and
150
+ // make our own file immune to uninstall — refuse it here instead.
151
+ for (const [k, v] of Object.entries({ src, pkg })) {
152
+ if (/\s/.test(v)) throw new Error(`provenanceLine: field "${k}" may not contain whitespace (got ${JSON.stringify(v)})`);
153
+ }
154
+ if (!/^[0-9a-f]{12}$/.test(srcHash) || !/^[0-9a-f]{12}$/.test(render)) {
155
+ throw new Error("provenanceLine: srcHash and render must be 12 lowercase hex characters");
156
+ }
157
+ return (
158
+ `projectstore: v${GRAMMAR_VERSION} src=${src}@${srcHash} pkg=${pkg} ` +
159
+ `project=${JSON.stringify(project)} render=${render} — generated; edit the source, then reinstall`
160
+ );
161
+ }
162
+
163
+ export const DEFAULT_REMEDY = "tests/portability.test.mjs fails while this file is out of date.";
164
+
165
+ // The third line is the remedy, and it differs by who wrote the file: a
166
+ // committed generated tree is caught by the portability suite, an installed
167
+ // file on a user's machine is caught by doctor and refreshed by install.
168
+ export const BANNER_LINES = (harness, source, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY) => [
169
+ `GENERATED by ${generator} from ${source} for the ${harness} harness.`,
170
+ "Do not edit this file — edit the source and re-run the generator.",
171
+ remedy,
172
+ ];
173
+
174
+ // The generated-file banner for one format. Markdown gets a block comment,
175
+ // the line formats get one comment per line.
176
+ export function banner(format, { harness, source, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY }) {
177
+ const lines = BANNER_LINES(harness, source, generator, remedy);
178
+ if (format === "markdown") {
179
+ return "<!--\n" + lines.map((l) => " " + l).join("\n") + "\n-->\n";
180
+ }
181
+ const [open] = delimitersFor(format);
182
+ return lines.map((l) => open + l).join("\n") + "\n";
183
+ }
184
+
185
+ // Where the stamp goes: after a leading frontmatter block for markdown (the
186
+ // same `---` shape lib.mjs parseFrontmatter recognises — a close with or
187
+ // without a trailing newline; this detector must agree with it, not improve on
188
+ // it), after a shebang for `.mjs`, else line 1. `body` is already
189
+ // EOL-normalised by the caller. A markdown body that opens a frontmatter block
190
+ // and never closes it is refused rather than stamped at line 1: a banner
191
+ // before the `---` is a file the harness silently stops loading.
192
+ function insertionOffset(body, format) {
193
+ if (format === "markdown") {
194
+ // The optional group admits an empty block (`---\n---`), which
195
+ // parseFrontmatter does not match at all — that is the one place this
196
+ // detector is wider, so that a harness reading it as frontmatter never
197
+ // sees our banner inside it.
198
+ const m = /^---\n(?:[\s\S]*?\n)?---(?:\n|$)/.exec(body);
199
+ if (m) return m[0].length;
200
+ if (body.startsWith("---\n") || body === "---") {
201
+ throw new Error("insertStamp: markdown body opens a frontmatter block that never closes — refusing to stamp before it");
202
+ }
203
+ }
204
+ if (format === "mjs" && body.startsWith("#!")) {
205
+ const nl = body.indexOf("\n");
206
+ return nl === -1 ? body.length : nl + 1;
207
+ }
208
+ return 0;
209
+ }
210
+
211
+ const bareTokenRe = () => new RegExp(`^${PROVENANCE_TOKEN.replace(/[%]/g, "\\$&")}$`, "m");
212
+
213
+ // Pass 1: banner plus the bare token ALONE on its own physical line, with no
214
+ // delimiter and no indentation — because the checker replaces the entire
215
+ // physical line, delimiters included, with the bare token, and the two
216
+ // must hash the same. The body is EOL-normalised here, once: what we render
217
+ // is ours to normalise, and every hash path already assumes LF.
218
+ export function insertStamp(body, { format, harness, source, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY }) {
219
+ delimitersFor(format);
220
+ const text = normalizeEol(String(body));
221
+ if (bareTokenRe().test(text)) {
222
+ throw new Error(`insertStamp: the body already carries a bare ${PROVENANCE_TOKEN} line — it would survive into the installed file`);
223
+ }
224
+ const at = insertionOffset(text, format);
225
+ const block = banner(format, { harness, source, generator, remedy }) + PROVENANCE_TOKEN + "\n";
226
+ // A frontmatter close with no trailing newline (which parseFrontmatter
227
+ // accepts) still needs the banner on its own line.
228
+ const glue = at > 0 && text[at - 1] !== "\n" ? "\n" : "";
229
+ return text.slice(0, at) + glue + block + text.slice(at);
230
+ }
231
+
232
+ // Pass 2: the token line becomes the delimited real line.
233
+ export function substituteProvenance(tokenized, { format, src, srcHash, pkg, project, render }) {
234
+ const [open, close] = delimitersFor(format);
235
+ const line = open + provenanceLine({ src, srcHash, pkg, project, render }) + close;
236
+ const re = bareTokenRe();
237
+ if (!re.test(tokenized)) {
238
+ throw new Error("substituteProvenance: no bare token line to substitute — run insertStamp first");
239
+ }
240
+ return String(tokenized).replace(re, () => line);
241
+ }
242
+
243
+ // The whole two-pass render. Returns the stamped text, the hash that went into
244
+ // `render=`, and the tokenized pass-1 text — so a generator that also needs
245
+ // "what would we render now" has it without rendering twice.
246
+ //
247
+ // The hash input is the pass-1 text run through the SAME tokenizer the checker
248
+ // uses. A body that quotes a grammar-shaped line (commands/doctor.md will,
249
+ // per install contract 5) would otherwise hash differently on write and on
250
+ // read and be born "edited by hand" — a false accusation reinstalling cannot
251
+ // clear. The cost: an edit confined to such a quoted line does not move
252
+ // `render=`; strictly narrower harm than a permanent false positive.
253
+ export function stamp(body, { format, src, srcHash, pkg, project, harness, generator = DEFAULT_GENERATOR, remedy = DEFAULT_REMEDY }) {
254
+ const tokenized = insertStamp(body, { format, harness, source: src, generator, remedy });
255
+ const render = renderHash(neutralizeProject(tokenizeProvenance(tokenized) ?? tokenized, project));
256
+ const text = substituteProvenance(tokenized, { format, src, srcHash, pkg, project, render });
257
+ return { text, render, tokenized };
258
+ }
259
+
260
+ // ─── Parse ─────────────────────────────────────────────────────────────
261
+
262
+ // One grammar string, two RegExp constructors — never one shared /g literal,
263
+ // whose lastIndex survives between calls. `[^\n]*?` … `[^\n]*$` make the match
264
+ // cover the whole physical line, whatever delimiter wraps it; `src=(\S+?)` is
265
+ // lazy because a greedy `\S+` swallows `@<hash>`.
266
+ const GRAMMAR =
267
+ String.raw`^[^\n]*?projectstore: v(\d+) src=(\S+?)@([0-9a-f]{12}) pkg=(\S+) ` +
268
+ String.raw`project="((?:[^"\\]|\\.)*)" render=([0-9a-f]{12})[^\n]*$`;
269
+ const parseRe = () => new RegExp(GRAMMAR, "m");
270
+ const tokenizeRe = () => new RegExp(GRAMMAR, "gm");
271
+
272
+ // The first provenance line in `text`, decoded; null when there is none.
273
+ export function parseProvenance(text) {
274
+ if (typeof text !== "string") return null;
275
+ const m = parseRe().exec(normalizeEol(text));
276
+ if (!m) return null;
277
+ let project;
278
+ try {
279
+ project = JSON.parse(`"${m[5]}"`);
280
+ } catch {
281
+ return null;
282
+ }
283
+ return {
284
+ v: Number(m[1]),
285
+ src: m[2],
286
+ srcHash: m[3],
287
+ pkg: m[4],
288
+ project,
289
+ render: m[6],
290
+ line: m[0],
291
+ index: m.index,
292
+ };
293
+ }
294
+
295
+ // Every provenance line replaced by the bare token (all of them, so a pasted
296
+ // duplicate still hashes deterministically and reports "edited by hand"
297
+ // rather than depending on which line won). Null when there is none.
298
+ export function tokenizeProvenance(text) {
299
+ if (typeof text !== "string") return null;
300
+ const normalized = normalizeEol(text);
301
+ if (!parseRe().test(normalized)) return null;
302
+ const parsed = parseProvenance(normalized);
303
+ return neutralizeProject(normalized.replace(tokenizeRe(), PROVENANCE_TOKEN), parsed && parsed.project);
304
+ }
305
+
306
+ // The claimed `render=` against what the file hashes to now.
307
+ export function renderHashOf(text) {
308
+ const parsed = parseProvenance(text);
309
+ if (!parsed) return null;
310
+ return { claimed: parsed.render, actual: renderHash(tokenizeProvenance(text)) };
311
+ }
312
+
313
+ // ─── Derive (install spec, contracts 3 and 4) ──────────────────────────
314
+
315
+ const stripTrailingSlash = (p) => (p.length > 1 ? p.replace(/[\\/]+$/, "") : p);
316
+
317
+ // The four states as a total, ordered function over explicit inputs:
318
+ //
319
+ // file { present: boolean, text: string | null } — null text = unreadable
320
+ // sourceHash hash12 of the current source bytes (caller reads them)
321
+ // pkg the current package version (caller resolves it)
322
+ // renderNowHash renderHash of what we would render now — stamp().render
323
+ // project this project's absolute path
324
+ //
325
+ // Step 5 compares HASHES, never bytes. Comparing bytes (as MultiProjectStore's
326
+ // status() does) makes step 6 unreachable: on a shared user-level path the
327
+ // `project=` field differs, so the bytes differ, and every file another
328
+ // project wrote would report "configuration changed" instead of "current,
329
+ // last written by <project>". The leaf never decides whether a path is shared
330
+ // — that is manifest data (contract 10) — so it always returns `writtenBy` and
331
+ // `sameProject`, and doctor composes the sentence.
332
+ export function deriveState({ file, sourceHash: srcNow, pkg, renderNowHash, project }) {
333
+ const result = (state, reason = null, provenance = null) => ({
334
+ state,
335
+ reason,
336
+ provenance,
337
+ writtenBy: provenance ? provenance.project : null,
338
+ sameProject: Boolean(
339
+ provenance && typeof project === "string" &&
340
+ stripTrailingSlash(provenance.project) === stripTrailingSlash(project),
341
+ ),
342
+ });
343
+
344
+ if (!file || !file.present) return result("absent");
345
+ if (typeof file.text !== "string") return result("foreign");
346
+
347
+ // 1. no parseable line → foreign
348
+ const parsed = parseProvenance(file.text);
349
+ if (!parsed) return result("foreign");
350
+ const provenance = {
351
+ v: parsed.v, src: parsed.src, srcHash: parsed.srcHash,
352
+ pkg: parsed.pkg, project: parsed.project, render: parsed.render,
353
+ };
354
+
355
+ // A newer grammar than this parser knows. The spec does not say; reporting
356
+ // it as foreign would make our own files immune to uninstall after a
357
+ // downgrade (contract 13), so it is the plugin that changed.
358
+ if (parsed.v !== GRAMMAR_VERSION) return result("stale", STALE.PLUGIN, provenance);
359
+
360
+ // 2. claimed render ≠ recomputed(file, line→token) → stale: edited by hand
361
+ const actual = renderHash(tokenizeProvenance(file.text));
362
+ if (parsed.render !== actual) return result("stale", STALE.EDITED, provenance);
363
+
364
+ // 3. parsed src hash ≠ current source hash → stale: source changed
365
+ if (parsed.srcHash !== srcNow) return result("stale", STALE.SOURCE, provenance);
366
+
367
+ // 4. parsed pkg ≠ current package version → stale: plugin updated
368
+ if (parsed.pkg !== pkg) return result("stale", STALE.PLUGIN, provenance);
369
+
370
+ // 5. render-now ≠ file → stale: configuration changed
371
+ if (actual !== renderNowHash) return result("stale", STALE.CONFIG, provenance);
372
+
373
+ // 6. current — and `writtenBy` says who, for a shared path
374
+ return result("current", null, provenance);
375
+ }