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.
- package/.codex-plugin/plugin.json +48 -0
- package/README.md +15 -7
- package/bin/projectstore-codex.mjs +88 -0
- package/hooks/hooks.json +59 -0
- package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
- package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
- package/node_modules/projectstore/.mcp.json +14 -0
- package/node_modules/projectstore/AGENTS.md +26 -0
- package/node_modules/projectstore/LICENSE +21 -0
- package/node_modules/projectstore/README.md +284 -0
- package/node_modules/projectstore/agents/archaeologist.md +76 -0
- package/node_modules/projectstore/agents/clerk.md +93 -0
- package/node_modules/projectstore/agents/critic.md +94 -0
- package/node_modules/projectstore/agents/librarian.md +81 -0
- package/node_modules/projectstore/agents/planner.md +80 -0
- package/node_modules/projectstore/agents/reviewer.md +98 -0
- package/node_modules/projectstore/bin/projectstore.mjs +7 -0
- package/node_modules/projectstore/commands/adr.md +57 -0
- package/node_modules/projectstore/commands/agents.md +180 -0
- package/node_modules/projectstore/commands/bind.md +128 -0
- package/node_modules/projectstore/commands/codemap.md +50 -0
- package/node_modules/projectstore/commands/concept.md +17 -0
- package/node_modules/projectstore/commands/doctor.md +166 -0
- package/node_modules/projectstore/commands/epic.md +40 -0
- package/node_modules/projectstore/commands/graph.md +56 -0
- package/node_modules/projectstore/commands/kanban.md +40 -0
- package/node_modules/projectstore/commands/meeting.md +17 -0
- package/node_modules/projectstore/commands/reconcile.md +73 -0
- package/node_modules/projectstore/commands/research.md +17 -0
- package/node_modules/projectstore/commands/review.md +89 -0
- package/node_modules/projectstore/commands/runbook.md +17 -0
- package/node_modules/projectstore/commands/scaffold.md +23 -0
- package/node_modules/projectstore/commands/search.md +22 -0
- package/node_modules/projectstore/commands/spec.md +91 -0
- package/node_modules/projectstore/commands/status.md +27 -0
- package/node_modules/projectstore/commands/statusline.md +46 -0
- package/node_modules/projectstore/commands/story.md +113 -0
- package/node_modules/projectstore/docs/extending.md +172 -0
- package/node_modules/projectstore/docs/getting-started.md +133 -0
- package/node_modules/projectstore/docs/harnesses.md +163 -0
- package/node_modules/projectstore/docs/how-it-works.md +263 -0
- package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
- package/node_modules/projectstore/docs/images/loop.svg +93 -0
- package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
- package/node_modules/projectstore/docs/images/team-light.svg +79 -0
- package/node_modules/projectstore/docs/images/team.svg +79 -0
- package/node_modules/projectstore/harnesses/claude-code.json +483 -0
- package/node_modules/projectstore/harnesses/codex.json +332 -0
- package/node_modules/projectstore/hooks/hooks.json +59 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
- package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
- package/node_modules/projectstore/hooks/session-start.mjs +301 -0
- package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
- package/node_modules/projectstore/package.json +70 -0
- package/node_modules/projectstore/scaffold/checklists.json +88 -0
- package/node_modules/projectstore/scaffold/headings.json +171 -0
- package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
- package/node_modules/projectstore/scripts/binding.mjs +165 -0
- package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
- package/node_modules/projectstore/scripts/cli.mjs +595 -0
- package/node_modules/projectstore/scripts/codemap.mjs +99 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
- package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
- package/node_modules/projectstore/scripts/draft.mjs +261 -0
- package/node_modules/projectstore/scripts/graph.mjs +219 -0
- package/node_modules/projectstore/scripts/harness.mjs +608 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
- package/node_modules/projectstore/scripts/kanban.mjs +174 -0
- package/node_modules/projectstore/scripts/lib.mjs +3085 -0
- package/node_modules/projectstore/scripts/mcp.mjs +391 -0
- package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
- package/node_modules/projectstore/scripts/provenance.mjs +375 -0
- package/node_modules/projectstore/scripts/query.mjs +490 -0
- package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
- package/node_modules/projectstore/scripts/statusline.mjs +253 -0
- package/node_modules/projectstore/scripts/story-section.mjs +209 -0
- package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
- package/node_modules/projectstore/scripts/tokens.mjs +449 -0
- package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +255 -0
- package/node_modules/projectstore/scripts/worktree.mjs +109 -0
- package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
- package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
- package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
- package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
- package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
- package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/de/strings.json +6 -0
- package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/en/strings.json +6 -0
- package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/es/strings.json +6 -0
- package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/fr/strings.json +6 -0
- package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/ru/strings.json +6 -0
- package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/zh/strings.json +6 -0
- package/package.json +36 -14
- package/plugin.json +53 -0
- package/skills/projectstore-adr/SKILL.md +76 -0
- package/skills/projectstore-agents/SKILL.md +50 -0
- package/skills/projectstore-archaeologist/SKILL.md +109 -0
- package/skills/projectstore-bind/SKILL.md +44 -0
- package/skills/projectstore-clerk/SKILL.md +126 -0
- package/skills/projectstore-codemap/SKILL.md +69 -0
- package/skills/projectstore-concept/SKILL.md +36 -0
- package/skills/projectstore-critic/SKILL.md +127 -0
- package/skills/projectstore-decision-detector/SKILL.md +59 -0
- package/skills/projectstore-doctor/SKILL.md +33 -0
- package/skills/projectstore-epic/SKILL.md +59 -0
- package/skills/projectstore-graph/SKILL.md +75 -0
- package/skills/projectstore-kanban/SKILL.md +60 -0
- package/skills/projectstore-librarian/SKILL.md +114 -0
- package/skills/projectstore-meeting/SKILL.md +36 -0
- package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
- package/skills/projectstore-planner/SKILL.md +113 -0
- package/skills/projectstore-reconcile/SKILL.md +92 -0
- package/skills/projectstore-research/SKILL.md +36 -0
- package/skills/projectstore-review/SKILL.md +108 -0
- package/skills/projectstore-reviewer/SKILL.md +131 -0
- package/skills/projectstore-runbook/SKILL.md +36 -0
- package/skills/projectstore-scaffold/SKILL.md +42 -0
- package/skills/projectstore-search/SKILL.md +41 -0
- package/skills/projectstore-spec/SKILL.md +110 -0
- package/skills/projectstore-status/SKILL.md +47 -0
- package/skills/projectstore-statusline/SKILL.md +29 -0
- package/skills/projectstore-story/SKILL.md +132 -0
- package/skills/projectstore-story-completion/SKILL.md +69 -0
- 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
|
+
}
|