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,261 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — draft.mjs
3
+ // Pure renderer. Given a kind declared in the bound layout (epic/story are
4
+ // structural special cases; every other kind comes from the layout's folders)
5
+ // and arguments, produces a JSON draft on stdout describing the target file
6
+ // and its rendered content. Does NOT touch the disk — no writes AND no
7
+ // mkdir: declining the approval gate must leave the vault byte-for-byte
8
+ // unchanged (ADR-001 review / PS-IMPROVE story-006). Directory creation is
9
+ // the caller's job after approval (the Write tool creates parents).
10
+ //
11
+ // Output schema:
12
+ // {
13
+ // "kind": "adr",
14
+ // "path": "/abs/path/to/vault/adr/foo.md",
15
+ // "content": "...rendered markdown...",
16
+ // "index": { // optional, when folder has a README index
17
+ // "path": "/abs/path/to/vault/adr/README.md",
18
+ // "folder": "adr", // selector for `reconcile.mjs --write
19
+ // // --only indexes=<folder>` — the command
20
+ // // applies the row by regenerating the
21
+ // // table, never by an Edit append, and
22
+ // // prose must not derive this path itself
23
+ // // (ADR-009: no logic in prose). Note the
24
+ // // folder is NOT the kind: runbook lives
25
+ // // in ops/.
26
+ // "line": "| [foo](./foo.md) | Foo | proposed | 2026-05-19 |"
27
+ // // preview only — the row is written by
28
+ // // reconcile's rebuildIndexRows. Rendered
29
+ // // by the same rules, so for a newly
30
+ // // created artifact it is byte-identical
31
+ // // to the row that lands.
32
+ // },
33
+ // "collision": { // null, or the normalized-identity clash
34
+ // "with": "ADR-003-foo.md", // (SPEC-002 contract 4 — computed in
35
+ // "identity": "foo", // lib.mjs; command prose only renders
36
+ // "selfMatch": false, // it, an exact `test -e` cannot see
37
+ // "digitLeading": false // cross-era collisions)
38
+ // },
39
+ // "warnings": ["..."], // e.g. digit-leading slug advisory
40
+ // "vars": { ... } // template vars used (for debugging)
41
+ // }
42
+ //
43
+ // Errors are written to stderr as plain text and exit code 1.
44
+
45
+ import { existsSync, readdirSync } from "node:fs";
46
+ import { join } from "node:path";
47
+ import {
48
+ readConfig,
49
+ loadLayout,
50
+ folderByKind,
51
+ loadTemplate,
52
+ renderTemplate,
53
+ parseFrontmatter,
54
+ slugify,
55
+ findSlugCollision,
56
+ displayNumberOf,
57
+ today, isMain
58
+ } from "./lib.mjs";
59
+
60
+ function die(msg, code = 1) {
61
+ process.stderr.write(`projectstore/draft: ${msg}\n`);
62
+ process.exit(code);
63
+ }
64
+
65
+ function commonVars(cfg) {
66
+ return {
67
+ date: today(),
68
+ author: cfg.default_author || process.env.USER || "anonymous",
69
+ tags: JSON.stringify(cfg.tags || []),
70
+ };
71
+ }
72
+
73
+ // Preview of the row the regeneration will land. Every cell is read from the
74
+ // RENDERED template's own frontmatter, by the same rules reconcile's
75
+ // rebuildIndexRows applies to an artifact on disk — so for a newly created
76
+ // artifact the preview is byte-identical to the written row, and a new kind's
77
+ // template stays the single source of its initial status. The label in
78
+ // particular must not come from `vars.id`: for date-prefixed kinds that is the
79
+ // bare slug while the regeneration labels by filename stem, and the two rows
80
+ // visibly disagreed.
81
+ function makeIndexLine(kind, fileName, vars, content, folder) {
82
+ const fm = parseFrontmatter(content).data;
83
+ // Empty, not today's date, when a template carries neither field — the
84
+ // regeneration renders "" there, and byte-identity is a contract that must
85
+ // hold for a custom kind too, not just the bundled ones.
86
+ const date = fm.date || fm.created || "";
87
+ if (kind === "epic") {
88
+ return `| [${vars.id}](./${vars.id}/epic.md) | ${fm.title || vars.id} | ${fm.status || "planned"} | ${date} |`;
89
+ }
90
+ const status = fm.status || (folder.numbered ? "proposed" : "draft");
91
+ const number = displayNumberOf(fm, fileName, { prefix: folder.prefix || null });
92
+ const label = number && folder.prefix ? `${folder.prefix}${number}` : fileName.replace(/\.md$/, "");
93
+ return `| [${label}](./${fileName}) | ${fm.title || label} | ${status} | ${date} |`;
94
+ }
95
+
96
+ function indexPath(vault, folderPath) {
97
+ return join(vault, folderPath, "README.md");
98
+ }
99
+
100
+ // ─── Builders (layout-driven — PS-SPEC story-001) ──────────────────────
101
+ //
102
+ // Any kind declared in the layout with a folder builds here with a slug-only
103
+ // filename (ADR-010: identity lives in the slug; sequential numbering is
104
+ // removed from creation — collision-free by construction, no read-then-write
105
+ // race between concurrent writers). Layout keys `numbered`/`prefix`/`pad`
106
+ // remain declared for grandfathered labels and legacy-strip matching, but
107
+ // creation no longer reads a next number. epic/story remain structural
108
+ // special cases (subfolder-per-id, stories/ subdirectory).
109
+
110
+ // Read-only normalized-identity scan of the target directory (SPEC-002
111
+ // contract 4). Directory entries include folder-shape stories (dirs).
112
+ function scanCollision(dir, fileName, opts) {
113
+ if (!existsSync(dir)) return null;
114
+ return findSlugCollision(fileName, readdirSync(dir).sort(), opts);
115
+ }
116
+
117
+ function digitLeadingWarnings(slug) {
118
+ return /^\d/.test(slug)
119
+ ? [`Slug "${slug}" is digit-leading — the filename visually resembles a numbered-era artifact. Machine resolution keys on id:, so this is safe, but a word-leading title avoids the ambiguity.`]
120
+ : [];
121
+ }
122
+
123
+ function buildEpic(cfg, layout, args) {
124
+ const id = args[0];
125
+ const title = args.slice(1).join(" ").trim();
126
+ if (!id || !title) die("Epic requires <id> and <title>");
127
+ const folder = folderByKind(layout, "epic");
128
+ if (!folder) die("Layout has no folder of kind=epic");
129
+ const vault = cfg.vault_path;
130
+ const epicDir = join(vault, folder.path, id);
131
+ const vars = { ...commonVars(cfg), id, title };
132
+ const tpl = loadTemplate(cfg.language || "en", "epic");
133
+ const content = renderTemplate(tpl, vars);
134
+ return {
135
+ kind: "epic",
136
+ path: join(epicDir, "epic.md"),
137
+ content,
138
+ index: existsSync(indexPath(vault, folder.path))
139
+ ? {
140
+ path: indexPath(vault, folder.path),
141
+ folder: folder.path,
142
+ line: makeIndexLine("epic", "epic.md", vars, content, folder),
143
+ }
144
+ : null,
145
+ collision: null, // epic ids are user-chosen; the command's own folder check gates them
146
+ warnings: [],
147
+ vars,
148
+ };
149
+ }
150
+
151
+ function buildStory(cfg, layout, args) {
152
+ const epicId = args[0];
153
+ const title = args.slice(1).join(" ").trim();
154
+ if (!epicId || !title) die("Story requires <epic_id> and <title>");
155
+ const folder = folderByKind(layout, "epic");
156
+ if (!folder) die("Layout has no folder of kind=epic");
157
+ const vault = cfg.vault_path;
158
+ const storiesDir = join(vault, folder.path, epicId, "stories");
159
+ if (!existsSync(join(vault, folder.path, epicId))) {
160
+ die(`Epic folder not found: ${folder.path}/${epicId}. Create the epic first via /projectstore:epic.`);
161
+ }
162
+ const storyPrefix = folder.story_prefix || "story-";
163
+ const slug = slugify(title);
164
+ const id = `${storyPrefix}${slug}`;
165
+ const fileName = `${id}.md`;
166
+ const vars = {
167
+ ...commonVars(cfg),
168
+ id,
169
+ epic_id: epicId,
170
+ title,
171
+ slug,
172
+ };
173
+ const tpl = loadTemplate(cfg.language || "en", "story");
174
+ // The identity scope is the EPIC, not just stories/: standalone
175
+ // epics/<id>/story-<slug>.md files share it (doctor scopes them together).
176
+ const epicDir = join(vault, folder.path, epicId);
177
+ const scopeNames = [
178
+ ...(existsSync(storiesDir) ? readdirSync(storiesDir) : []),
179
+ ...readdirSync(epicDir).filter((n) => n.startsWith("story-") && n.endsWith(".md")),
180
+ ].sort();
181
+ return {
182
+ kind: "story",
183
+ path: join(storiesDir, fileName),
184
+ content: renderTemplate(tpl, vars),
185
+ index: null,
186
+ collision: findSlugCollision(fileName, scopeNames, { story: true }),
187
+ warnings: digitLeadingWarnings(slug),
188
+ vars,
189
+ };
190
+ }
191
+
192
+ function buildSimple(kind, cfg, layout, args) {
193
+ const title = args.join(" ").trim();
194
+ if (!title) die(`${kind} requires a title`);
195
+ const folder = folderByKind(layout, kind);
196
+ const vault = cfg.vault_path;
197
+ const dir = join(vault, folder.path);
198
+ const slug = slugify(title);
199
+ const date = today();
200
+ const fileName = folder.date_prefix ? `${date}-${slug}.md` : `${slug}.md`;
201
+ const vars = {
202
+ ...commonVars(cfg),
203
+ id: slug, // exact machine id (ADR-010): the slug itself, no allocated number
204
+ slug,
205
+ title,
206
+ };
207
+ const tpl = loadTemplate(cfg.language || "en", kind);
208
+ const content = renderTemplate(tpl, vars);
209
+ return {
210
+ kind,
211
+ path: join(dir, fileName),
212
+ content,
213
+ index: existsSync(indexPath(vault, folder.path))
214
+ ? {
215
+ path: indexPath(vault, folder.path),
216
+ folder: folder.path,
217
+ line: makeIndexLine(kind, fileName, vars, content, folder),
218
+ }
219
+ : null,
220
+ collision: scanCollision(dir, fileName, { prefix: folder.prefix || null }),
221
+ // A date-prefixed filename (meetings) is digit-leading by design — the
222
+ // slug itself is what must not resemble the numbered era.
223
+ warnings: folder.date_prefix ? [] : digitLeadingWarnings(slug),
224
+ vars,
225
+ };
226
+ }
227
+
228
+ // ─── Main ──────────────────────────────────────────────────────────────
229
+
230
+ function main() {
231
+ const argv = process.argv.slice(2);
232
+ if (argv.length < 2) die("usage: draft.mjs <kind> <args...>");
233
+ const kind = argv[0];
234
+ const rest = argv.slice(1);
235
+
236
+ const cfg = readConfig();
237
+ if (!cfg) die("No projectstore config. Run /projectstore:bind <vault-path> first.");
238
+ const layout = loadLayout(cfg.layout);
239
+
240
+ let result;
241
+ if (kind === "epic") {
242
+ result = buildEpic(cfg, layout, rest);
243
+ } else if (kind === "story") {
244
+ result = buildStory(cfg, layout, rest);
245
+ } else {
246
+ const folder = folderByKind(layout, kind);
247
+ if (!folder) {
248
+ const known = layout.folders.map((f) => f.kind).filter((k) => k !== "epic");
249
+ die(`Unknown kind: ${kind}. This layout (${cfg.layout}) declares: epic, story, ${known.join(", ")}.`);
250
+ }
251
+ // `numbered` folders route through buildSimple too (ADR-010): the key
252
+ // stays declared in layouts for grandfathered labels, creation ignores it.
253
+ result = buildSimple(kind, cfg, layout, rest);
254
+ }
255
+
256
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
257
+ }
258
+
259
+ if (isMain(import.meta.url)) {
260
+ main();
261
+ }
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ // projectstore — graph.mjs
3
+ // Derives graph.md — the vault link graph — from body links and typed
4
+ // frontmatter relations (source of truth). Third root-level derived view
5
+ // beside kanban.md and code-map.md (spec:
6
+ // vault-link-graph-derived-view-and-shared-link-resolver; the accepted
7
+ // link-graph ADR decides the shape). Nodes are layout artifacts keyed by
8
+ // full vault-relative path; edges are typed; nothing resolves silently —
9
+ // dead, ambiguous and out-of-scope targets stay visible as edges.
10
+ //
11
+ // Output: JSON { path, content, stats } — compute only. The applier is
12
+ // reconcile.mjs --write (atomic replace, recompute-at-write), not the
13
+ // harness: no Write-tool step remains in the derived-view flows.
14
+
15
+ import { existsSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import {
18
+ readConfig,
19
+ loadLayout,
20
+ folderByKind,
21
+ listOf,
22
+ nowIso,
23
+ buildNodeIndex,
24
+ extractLinks,
25
+ resolveLinkTarget,
26
+ storyMatchesEntry, isMain
27
+ } from "./lib.mjs";
28
+ import { walkVaultFiles } from "./doctor.mjs";
29
+
30
+ function die(msg) {
31
+ process.stderr.write(`projectstore/graph: ${msg}\n`);
32
+ process.exit(1);
33
+ }
34
+
35
+ // Frontmatter refs arrive as inline-flow lists (listOf) or, for
36
+ // supersedes/superseded_by, as a bare scalar — normalize both to a list.
37
+ function refsOf(fm, key) {
38
+ const viaList = listOf(fm, key);
39
+ if (viaList.length) return viaList;
40
+ const raw = fm[key];
41
+ return typeof raw === "string" && raw && raw !== "[]" && !raw.trim().startsWith("[")
42
+ ? [raw.trim()]
43
+ : [];
44
+ }
45
+
46
+ // Resolve a qualified "<epic-id>/<story-id>" spec entry to a story node —
47
+ // the same tiered control flow as doctor's resolveSpecStory, on the same
48
+ // SPEC-002 primitive (storyMatchesEntry): strongest tier wins across the
49
+ // epic's stories, a tie within it is ambiguity, never a silent first match.
50
+ function resolveStoryEntry(entry, index, epicsPath) {
51
+ const m = String(entry).match(/^([^/]+)\/(.+)$/);
52
+ if (!m) return { outcome: "dead" };
53
+ const [, epicId, storyId] = m;
54
+ let best = 0;
55
+ let hits = [];
56
+ for (const n of index.nodes) {
57
+ if (n.type !== "story" || !n.path.startsWith(`${epicsPath}/${epicId}/`)) continue;
58
+ const tier = storyMatchesEntry(storyId, { id: n.identity, stem: n.stem });
59
+ if (!tier) continue;
60
+ if (best === 0 || tier < best) { best = tier; hits = [n]; }
61
+ else if (tier === best) hits.push(n);
62
+ }
63
+ if (hits.length === 1) return { outcome: "node", node: hits[0] };
64
+ if (hits.length > 1) return { outcome: "ambiguous", candidates: hits.map((n) => n.path).sort() };
65
+ return { outcome: "dead" };
66
+ }
67
+
68
+ export function buildGraph(cfg, layout, { files = null } = {}) {
69
+ const index = buildNodeIndex(cfg, layout);
70
+ const vaultFiles = files ?? walkVaultFiles(cfg.vault_path);
71
+ const epicsPath = folderByKind(layout, "epic")?.path ?? null;
72
+
73
+ // Dedup on the (from, kind, to) triple (spec contract 4). The To cell for
74
+ // dead targets is the raw text as written; ambiguous additionally lists
75
+ // every candidate path in the row.
76
+ const edges = new Map();
77
+ const addEdge = (from, kind, to) => {
78
+ const k = `${from}\u0000${kind}\u0000${to}`;
79
+ if (!edges.has(k)) edges.set(k, { from, kind, to });
80
+ };
81
+ const addResolved = (from, r, raw, kinds = {}) => {
82
+ if (r.outcome === "node") addEdge(from, kinds.node ?? "wikilink", r.node.path);
83
+ else if (r.outcome === "dead") addEdge(from, "dead", raw);
84
+ else if (r.outcome === "ambiguous") addEdge(from, "ambiguous", `${raw} (matches: ${r.candidates.join(", ")})`);
85
+ else addEdge(from, "out-of-scope", r.path ?? raw);
86
+ };
87
+
88
+ const exists = (rel) => existsSync(join(cfg.vault_path, rel));
89
+ for (const n of index.nodes) {
90
+ // Body links — kind-unscoped, extracted ONLY from node files: derived
91
+ // views and READMEs are never link sources, so graph.md can never feed
92
+ // back into itself (spec contract 2). One ctx per node: the resolver
93
+ // caches its file set on the ctx object, and rebuilding it per link
94
+ // would be O(links × files) — the growth curve this view exists for.
95
+ const ctx = { sourceRel: n.path, index, files: vaultFiles, exists };
96
+ for (const link of extractLinks(n.body)) {
97
+ addResolved(n.path, resolveLinkTarget(link.target, link.type, ctx), link.target, { node: link.type });
98
+ }
99
+
100
+ // Typed frontmatter relations — kind-scoped by their field (spec
101
+ // contract 3). The two-sided spec↔story declaration is read as a union
102
+ // and normalized to spec→story before dedup (contract 4); asymmetry is
103
+ // doctor's existing checkSpecLinks warning, not a graph finding.
104
+ const refCtx = (kinds) => ({ ...ctx, kinds });
105
+ if (n.type === "spec") {
106
+ for (const entry of refsOf(n.fm, "stories")) {
107
+ const r = epicsPath ? resolveStoryEntry(entry, index, epicsPath) : { outcome: "dead" };
108
+ if (r.outcome === "node") addEdge(n.path, "spec-covers", r.node.path);
109
+ else addResolved(n.path, r, entry);
110
+ }
111
+ for (const entry of refsOf(n.fm, "adr")) {
112
+ const r = resolveLinkTarget(entry, "ref", refCtx(["adr"]));
113
+ if (r.outcome === "node") addEdge(n.path, "spec-implements-adr", r.node.path);
114
+ else addResolved(n.path, r, entry);
115
+ }
116
+ }
117
+ if (n.type === "story") {
118
+ for (const entry of refsOf(n.fm, "specs")) {
119
+ const r = resolveLinkTarget(entry, "ref", refCtx(["spec"]));
120
+ if (r.outcome === "node") addEdge(r.node.path, "spec-covers", n.path);
121
+ else addResolved(n.path, r, entry);
122
+ }
123
+ }
124
+ if (n.type === "adr") {
125
+ for (const entry of refsOf(n.fm, "supersedes")) {
126
+ const r = resolveLinkTarget(entry, "ref", refCtx(["adr"]));
127
+ if (r.outcome === "node") addEdge(n.path, "supersedes", r.node.path);
128
+ else addResolved(n.path, r, entry);
129
+ }
130
+ for (const entry of refsOf(n.fm, "superseded_by")) {
131
+ // Normalized to the superseding ADR as From — one direction, dedup
132
+ // then collapses the two-sided declaration.
133
+ const r = resolveLinkTarget(entry, "ref", refCtx(["adr"]));
134
+ if (r.outcome === "node") addEdge(r.node.path, "supersedes", n.path);
135
+ else addResolved(n.path, r, entry);
136
+ }
137
+ }
138
+ // Folder structure: epic.md contains its stories.
139
+ if (n.type === "epic") {
140
+ const epicDirPrefix = n.path.replace(/\/epic\.md$/, "/");
141
+ for (const s of index.nodes) {
142
+ if (s.type === "story" && s.path.startsWith(epicDirPrefix)) {
143
+ addEdge(n.path, "epic-contains", s.path);
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ // Stable plain-string sort (from, kind, to) — deterministic across
150
+ // machines and locales; minimal diffs on a synced root file.
151
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
152
+ const nodeRows = [...index.nodes].sort((a, b) => cmp(a.path, b.path));
153
+ const edgeRows = [...edges.values()].sort(
154
+ (a, b) => cmp(a.from, b.from) || cmp(a.kind, b.kind) || cmp(a.to, b.to));
155
+
156
+ const esc = (s) => String(s).replace(/\|/g, "\\|");
157
+ const lines = [
158
+ "---",
159
+ "",
160
+ "projectstore: derived",
161
+ `generated_at: ${nowIso()}`,
162
+ "",
163
+ "---",
164
+ "",
165
+ "# Vault link graph",
166
+ "",
167
+ "Nodes (layout artifacts, keyed by vault-relative path) and typed edges,",
168
+ "derived from body links and frontmatter relations (source of truth).",
169
+ "Regenerate via `/projectstore:graph`; grep a path to see an artifact's",
170
+ "full typed neighborhood, both directions, in one call.",
171
+ "",
172
+ "## Nodes",
173
+ "",
174
+ "| Path | Title | Type | Status |",
175
+ "|------|-------|------|--------|",
176
+ ];
177
+ for (const n of nodeRows) {
178
+ lines.push(`| ${n.path} | ${esc(n.title)} | ${n.type} | ${n.status ?? "-"} |`);
179
+ }
180
+ lines.push("", "## Edges", "", "| From | Kind | To |", "|------|------|----|");
181
+ for (const e of edgeRows) {
182
+ lines.push(`| ${e.from} | ${e.kind} | ${e.to} |`);
183
+ }
184
+ lines.push("");
185
+
186
+ const byKind = {};
187
+ for (const e of edgeRows) byKind[e.kind] = (byKind[e.kind] || 0) + 1;
188
+
189
+ return {
190
+ content: lines.join("\n"),
191
+ stats: {
192
+ nodes: nodeRows.length,
193
+ edges: edgeRows.length,
194
+ by_kind: Object.fromEntries(Object.entries(byKind).sort(([a], [b]) => cmp(a, b))),
195
+ },
196
+ // The facts the view is rendered from, for the query verbs (neighbors,
197
+ // lineage): the same edges, so `graph neighbors` and `grep graph.md`
198
+ // cannot disagree. main() prints content and stats only — reconcile's
199
+ // parse surface and the golden tests are unchanged.
200
+ nodes: index.nodes.map((n) => ({ path: n.path, type: n.type, title: n.title, status: n.status })),
201
+ edges: edgeRows.map((e) => ({ from: e.from, kind: e.kind, to: e.to })),
202
+ };
203
+ }
204
+
205
+ function main() {
206
+ const cfg = readConfig();
207
+ if (!cfg) die("No projectstore config. Run /projectstore:bind first.");
208
+ const layout = loadLayout(cfg.layout);
209
+ const g = buildGraph(cfg, layout);
210
+ process.stdout.write(JSON.stringify({
211
+ path: join(cfg.vault_path, "graph.md"),
212
+ content: g.content,
213
+ stats: g.stats,
214
+ }, null, 2) + "\n");
215
+ }
216
+
217
+ if (isMain(import.meta.url)) {
218
+ main();
219
+ }