@baldurpan/create-ai-workflow 0.2.0

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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/dist/agents-block.js +70 -0
  4. package/dist/bin.js +14 -0
  5. package/dist/check/markdown.js +47 -0
  6. package/dist/check/parse.js +113 -0
  7. package/dist/check/rules.js +245 -0
  8. package/dist/cli.js +109 -0
  9. package/dist/commands/check.js +41 -0
  10. package/dist/commands/install.js +76 -0
  11. package/dist/commands/standards-add.js +145 -0
  12. package/dist/commands/update.js +214 -0
  13. package/dist/layout.js +115 -0
  14. package/dist/log.js +20 -0
  15. package/dist/manifest.js +53 -0
  16. package/dist/paths.js +41 -0
  17. package/package.json +37 -0
  18. package/templates/blocks/agents-block.md +30 -0
  19. package/templates/claude/agents/planner.agent.md +60 -0
  20. package/templates/claude/agents/reviewer.agent.md +77 -0
  21. package/templates/context/README.md +81 -0
  22. package/templates/context/plan-template.md +69 -0
  23. package/templates/context/plan-template.notes.md +55 -0
  24. package/templates/context/roles/coder.md +76 -0
  25. package/templates/context/workflow.md +143 -0
  26. package/templates/skills/feature-close/SKILL.md +102 -0
  27. package/templates/skills/feature-implement/SKILL.md +142 -0
  28. package/templates/skills/feature-plan/SKILL.md +138 -0
  29. package/templates/skills/feature-status/SKILL.md +86 -0
  30. package/templates/skills/onboard/SKILL.md +122 -0
  31. package/templates/skills/orchestrate/SKILL.md +92 -0
  32. package/templates/skills/roadmap/SKILL.md +97 -0
  33. package/templates/standards/.source +8 -0
  34. package/templates/standards/README.md +104 -0
  35. package/templates/standards/architecture/api-design.md +212 -0
  36. package/templates/standards/architecture/dependency-boundaries.md +104 -0
  37. package/templates/standards/architecture/feature-driven.md +102 -0
  38. package/templates/standards/architecture/folder-structure.md +108 -0
  39. package/templates/standards/architecture/monorepos.md +107 -0
  40. package/templates/standards/architecture/refactoring.md +97 -0
  41. package/templates/standards/architecture/shared-code.md +81 -0
  42. package/templates/standards/docs/PHP-SPEC.md +1195 -0
  43. package/templates/standards/docs/SPEC.md +540 -0
  44. package/templates/standards/examples/bad/data-fetching.tsx +52 -0
  45. package/templates/standards/examples/bad/derived-state.tsx +60 -0
  46. package/templates/standards/examples/bad/feature-component.tsx +80 -0
  47. package/templates/standards/examples/bad/prop-drilling.tsx +70 -0
  48. package/templates/standards/examples/good/data-fetching.tsx +53 -0
  49. package/templates/standards/examples/good/derived-state.tsx +52 -0
  50. package/templates/standards/examples/good/feature-component.tsx +141 -0
  51. package/templates/standards/examples/good/invoice.schema.ts +20 -0
  52. package/templates/standards/examples/good/prop-drilling.tsx +83 -0
  53. package/templates/standards/philosophy/ai-agent-behavior.md +93 -0
  54. package/templates/standards/philosophy/core-principles.md +49 -0
  55. package/templates/standards/philosophy/incremental-abstraction.md +56 -0
  56. package/templates/standards/philosophy/maintainability.md +66 -0
  57. package/templates/standards/philosophy/readability.md +87 -0
  58. package/templates/standards/php/README.md +62 -0
  59. package/templates/standards/php/anti-patterns.md +259 -0
  60. package/templates/standards/php/architecture.md +241 -0
  61. package/templates/standards/php/frameworks.md +197 -0
  62. package/templates/standards/php/rules.md +205 -0
  63. package/templates/standards/php/security.md +190 -0
  64. package/templates/standards/php/testing.md +139 -0
  65. package/templates/standards/php/tooling.md +245 -0
  66. package/templates/standards/react/accessibility.md +130 -0
  67. package/templates/standards/react/anti-patterns.md +187 -0
  68. package/templates/standards/react/component-design.md +139 -0
  69. package/templates/standards/react/error-boundaries.md +195 -0
  70. package/templates/standards/react/forms.md +147 -0
  71. package/templates/standards/react/hooks.md +88 -0
  72. package/templates/standards/react/memoization.md +87 -0
  73. package/templates/standards/react/state-management.md +123 -0
  74. package/templates/standards/react/testing.md +118 -0
  75. package/templates/standards/react/use-effect.md +110 -0
  76. package/templates/standards/security/api-security.md +136 -0
  77. package/templates/standards/security/auth.md +112 -0
  78. package/templates/standards/security/secrets.md +105 -0
  79. package/templates/standards/security/validation.md +89 -0
  80. package/templates/standards/templates/.editorconfig +16 -0
  81. package/templates/standards/templates/.nvmrc +1 -0
  82. package/templates/standards/templates/AGENTS.md +46 -0
  83. package/templates/standards/templates/CLAUDE.md +3 -0
  84. package/templates/standards/templates/_dot_gitignore +50 -0
  85. package/templates/standards/templates/biome.json +53 -0
  86. package/templates/standards/templates/eslint.config.js +61 -0
  87. package/templates/standards/templates/project-readme.md +76 -0
  88. package/templates/standards/templates/pull-request-template.md +46 -0
  89. package/templates/standards/templates/tsconfig.json +35 -0
  90. package/templates/standards/tooling/biome.md +92 -0
  91. package/templates/standards/tooling/ci.md +128 -0
  92. package/templates/standards/tooling/dates.md +194 -0
  93. package/templates/standards/tooling/dependencies.md +90 -0
  94. package/templates/standards/tooling/nx.md +113 -0
  95. package/templates/standards/tooling/observability.md +183 -0
  96. package/templates/standards/tooling/prisma.md +152 -0
  97. package/templates/standards/tooling/shadcn.md +115 -0
  98. package/templates/standards/tooling/tailwind.md +135 -0
  99. package/templates/standards/tooling/tanstack-query.md +214 -0
  100. package/templates/standards/tooling/tanstack-router.md +365 -0
  101. package/templates/standards/tooling/tanstack.md +31 -0
  102. package/templates/standards/tooling/vite.md +135 -0
  103. package/templates/standards/typescript/anti-patterns.md +107 -0
  104. package/templates/standards/typescript/error-handling.md +216 -0
  105. package/templates/standards/typescript/naming.md +78 -0
  106. package/templates/standards/typescript/rules.md +159 -0
  107. package/templates/standards/typescript/tsconfig/base.json +32 -0
  108. package/templates/standards/typescript/tsconfig/nextjs.json +12 -0
  109. package/templates/standards/typescript/tsconfig/react.json +9 -0
  110. package/templates/standards/typescript/validation.md +104 -0
  111. package/templates/stubs/executors.md +49 -0
  112. package/templates/stubs/findings.md +40 -0
  113. package/templates/stubs/history.md +13 -0
  114. package/templates/stubs/roadmap.md +29 -0
  115. package/templates/stubs/stack.md +35 -0
  116. package/templates/stubs/verify.md +48 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Baldur Páll Hólmgeirsson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # `@baldurpan/create-ai-workflow`
2
+
3
+ **Overlays a planning workflow onto an existing repository.** It is not a scaffolder — despite the
4
+ `create-` name, it expects a repo you already have, and it refuses to run where `context/` exists.
5
+
6
+ ```bash
7
+ npx @baldurpan/create-ai-workflow # or: npm create @baldurpan/ai-workflow
8
+ ```
9
+
10
+ You get a backlog, plan documents with phase ledgers, two verification gates, and seven commands that
11
+ move work between them. Your coding agent runs the commands; you read and hand-edit the files.
12
+
13
+ ---
14
+
15
+ ## The three tiers
16
+
17
+ ```
18
+ /roadmap "idea" ──▶ pending Tier 1 — the backlog
19
+ │ context/roadmap.md, notes in context/drafts/
20
+ /feature-plan [--activate] writes context/plans/<NAME>-PLAN.md, then STOPS
21
+
22
+ a plan exists Tier 2 — one plan, with a phase status ledger
23
+
24
+ /feature-implement activates, then runs phases: code → verify → review
25
+
26
+ /feature-close ──▶ context/archive/ + a context/history.md row
27
+ ```
28
+
29
+ Every boundary is crossed by an explicit command, never as a side-effect. Every command finds its own
30
+ starting point — nothing has to be looked up first.
31
+
32
+ ## The commands
33
+
34
+ | Command | Does |
35
+ |---|---|
36
+ | `/roadmap` | prints the backlog, or appends one `pending` entry — capturing any material you supply as a draft |
37
+ | `/feature-plan` | turns an entry into a plan document and **stops**. Planning is not activation |
38
+ | `/feature-implement` | activates a planned feature and runs **one phase**, through both gates |
39
+ | `/feature-status` | read-only. Reconciles the ledger against the repo, then names **exactly one** next action |
40
+ | `/feature-close` | retires a feature: a `history.md` row, a `git mv` into `archive/`, and a reviewed reference sweep |
41
+ | `/orchestrate` | one ad-hoc, commit-sized change through the same gates — no entry, no ledger |
42
+ | `/onboard` | fills in your own stubs, running each verification command before writing it down |
43
+
44
+ ## What makes it different
45
+
46
+ **No document states its own status.** There is no `**Status:**` header anywhere. Whether a feature is
47
+ being worked is a marker in `roadmap.md`; whether it *has* a plan is whether its `Doc` field points into
48
+ `plans/`; where a phase stands is the plan's own ledger. Those facts are orthogonal, so none of them can go
49
+ stale against another.
50
+
51
+ **Nothing is cached, parsed, or generated.** An agent reads the hand-written ledger every time, so editing
52
+ a row by hand changes the answer immediately. There is no build step in the loop and no generated
53
+ "current state" file to disagree with its source.
54
+
55
+ **One file names a command.** `context/verify.md` holds this project's real lint, typecheck, build and
56
+ test commands. No skill, agent prompt or role file carries a copy — a hardcoded stack rots the moment the
57
+ project changes shape, and a second copy rots faster. `/onboard` **runs each candidate and writes only the
58
+ ones that exit 0.**
59
+
60
+ **A finding outlives the session that found it.** A reviewer `FAIL` or a capped gate is written to
61
+ `context/findings.md` *before* the loopback, so it survives the conversation ending. An open `P0`/`P1`
62
+ blocks its phase from being marked `done` and blocks `/feature-close`.
63
+
64
+ ## What gets installed
65
+
66
+ ```
67
+ context/
68
+ README.md workflow.md plan-template.md plan-template.notes.md roles/ standards/ tool-owned
69
+ stack.md verify.md executors.md roadmap.md history.md findings.md yours
70
+ drafts/ plans/ archive/ yours
71
+ .state/manifest.json
72
+ .claude/skills/<seven>/SKILL.md .claude/agents/*.agent.md tool-owned
73
+ .agents/skills/<seven>/SKILL.md the same seven bodies, for hosts that read that tree tool-owned
74
+ AGENTS.md a delimited block, merged into whatever is already there
75
+ CLAUDE.md a single @AGENTS.md line, and only when the file does not exist
76
+ ```
77
+
78
+ Nothing is committed. Review the diff yourself.
79
+
80
+ ### The ownership boundary is a data structure, not a rule
81
+
82
+ `context/.state/manifest.json` lists every tool-owned file with its hash. `update` walks that manifest —
83
+ and a project-owned file is not in it, so **no code path reaches it.** Anything else you add under
84
+ `context/` (`decisions.md`, `glossary.md`, `ops-notes.md`) survives by the same property, with no feature
85
+ required to protect it.
86
+
87
+ ```bash
88
+ npx @baldurpan/create-ai-workflow update --dry-run # print the plan, change nothing
89
+ npx @baldurpan/create-ai-workflow update # conflicts stop it; nothing is written
90
+ npx @baldurpan/create-ai-workflow update --force # back up edited files (.bak) and take ours
91
+ ```
92
+
93
+ | On disk | `update` does |
94
+ |---|---|
95
+ | matches the manifest | replaces silently |
96
+ | differs | reports a conflict; `--force` backs up and replaces |
97
+ | missing | restores |
98
+ | not in the manifest | nothing — it cannot reach it |
99
+
100
+ ## Standards
101
+
102
+ `context/standards/` ships a vendored default, and `context/standards/README.md` holds a
103
+ conditional-loading table that agents actually traverse — the skills say "consult the conditional loading
104
+ table", and that is how standards get loaded per task.
105
+
106
+ **A wrong set is not inert**, because it is loaded unprompted on every task. Swap it:
107
+
108
+ ```bash
109
+ npx @baldurpan/create-ai-workflow standards add <git-url>
110
+ ```
111
+
112
+ That command **refuses to install a tree the skills cannot navigate**: the source's README must carry a
113
+ usable conditional-loading table, or you are offered a generated one (`--generate-index`). Whatever lands
114
+ is project-owned from that point — it drops out of the manifest, so `update` never clobbers it. Editing
115
+ the bundled tree in place has the same effect.
116
+
117
+ ## `check`
118
+
119
+ ```bash
120
+ npx @baldurpan/create-ai-workflow check
121
+ ```
122
+
123
+ Reports structural breakage: an illegal status word, a `Depends on` naming a phase that does not exist or
124
+ a cycle, two entries marked `active`, a second phase table, a `**Status:**` header, a plan no entry points
125
+ at, a dead `Doc` or history link, a closed finding still in the file.
126
+
127
+ It **never writes** — there is no `--fix`, because the moment it can repair a ledger, a program's edit
128
+ competes with a hand edit. Nothing depends on it: no skill calls it and no git hook installs it. **Delete
129
+ it and every workflow answer is unchanged.** It reads `roadmap.md`, `plans/`, `history.md` and
130
+ `findings.md` — never `archive/`, because validating retired records against current rules is how
131
+ validators earn a reputation for crying wolf. Every message quotes the rule it enforces, so a false
132
+ positive points at the document that is out of step.
133
+
134
+ ## Scope
135
+
136
+ **Both skill trees ship.** Claude Code reads `.claude/skills/`; Codex reads `.agents/skills/` and never
137
+ looks at the other one. They get the same seven bodies — the only difference is one frontmatter line,
138
+ `disable-model-invocation: true`, which is Claude Code's key and means nothing elsewhere. The bodies are
139
+ written runtime-neutral, with no runtime primitive named in any of them, and a test enforces it.
140
+
141
+ Duplication is the cost, and it is contained by construction rather than by discipline: one canonical
142
+ source lives in the package, both trees are written at install, both are hashed in the manifest, and
143
+ neither is ever hand-edited. Editing one is a conflict, not a divergence.
144
+
145
+ `AGENTS.md` is the content home, so Cursor, Copilot and Gemini CLI get the command block for free without
146
+ a tree of their own. An install made before the second tree existed gains it on the next `update`, listed
147
+ as `add` in the plan.
148
+
149
+ **No host's review command is named anywhere in the package.** Which coder or reviewer serves Gate 2 is a
150
+ per-machine fact that hosts change underneath you, so `/onboard` asks and writes the chosen invocation into
151
+ `context/executors.md`. What ships is the contract — a review happens, blocking findings carry a `P0`–`P3`
152
+ severity, a `FAIL` writes a finding before the loopback — not the command.
153
+
154
+ Requires Node 20.10 or newer. One `context/` per repository.
155
+
156
+ ## Licence
157
+
158
+ The tool is MIT.
159
+
160
+ `context/standards/` is vendored from
161
+ [`baldurpan/ai-engineering-standards`](https://github.com/baldurpan/ai-engineering-standards) at the ref
162
+ recorded in `context/standards/.source`. **That repository carries no licence file**, so the terms under
163
+ which the vendored content may be redistributed are unsettled — it is bundled here on the author's own
164
+ authority and nothing more. If you are installing this into a repository where that matters, run
165
+ `standards add <git-url>` and point it at a tree whose terms you know.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The AGENTS.md block. The tool never owns that file — it owns a delimited region inside it, and
3
+ * everything outside the markers is the user's.
4
+ */
5
+ export const START_MARKER = '<!-- ai-workflow:start -->';
6
+ export const END_MARKER = '<!-- ai-workflow:end -->';
7
+ function allIndexesOf(haystack, needle) {
8
+ const out = [];
9
+ for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length)) {
10
+ out.push(i);
11
+ }
12
+ return out;
13
+ }
14
+ export function inspect(text) {
15
+ if (text === null)
16
+ return { kind: 'no-file' };
17
+ const starts = allIndexesOf(text, START_MARKER);
18
+ const ends = allIndexesOf(text, END_MARKER);
19
+ if (starts.length === 0 && ends.length === 0)
20
+ return { kind: 'no-markers' };
21
+ if (starts.length > 1 || ends.length > 1) {
22
+ return { kind: 'duplicate', count: Math.max(starts.length, ends.length) };
23
+ }
24
+ if (starts.length === 1 && ends.length === 0) {
25
+ return { kind: 'malformed', reason: 'an opening marker with no closing marker' };
26
+ }
27
+ if (starts.length === 0 && ends.length === 1) {
28
+ return { kind: 'malformed', reason: 'a closing marker with no opening marker' };
29
+ }
30
+ const start = starts[0];
31
+ const end = ends[0];
32
+ if (end < start) {
33
+ return { kind: 'malformed', reason: 'the closing marker appears before the opening marker' };
34
+ }
35
+ const body = text.slice(start + START_MARKER.length, end).trim();
36
+ return { kind: 'present', body, start, end: end + END_MARKER.length };
37
+ }
38
+ /** The full block, markers included, as it is written into a file. */
39
+ export function render(body) {
40
+ return `${START_MARKER}\n${body.trim()}\n${END_MARKER}`;
41
+ }
42
+ export class BlockConflictError extends Error {
43
+ }
44
+ /**
45
+ * The file text after writing `body` into it. Refuses on `duplicate` and `malformed` — those are the two
46
+ * states where guessing which region to replace could destroy the user's own content.
47
+ */
48
+ export function apply(text, body) {
49
+ const state = inspect(text);
50
+ const block = render(body);
51
+ switch (state.kind) {
52
+ case 'no-file':
53
+ return `# AGENTS.md\n\nThe agent-neutral entry point for this repository.\n\n${block}\n`;
54
+ case 'no-markers': {
55
+ const existing = text.replace(/\s+$/, '');
56
+ return `${existing}\n\n${block}\n`;
57
+ }
58
+ case 'present': {
59
+ const before = text.slice(0, state.start);
60
+ const after = text.slice(state.end);
61
+ return `${before}${block}${after}`;
62
+ }
63
+ case 'duplicate':
64
+ throw new BlockConflictError(`AGENTS.md contains ${state.count} ai-workflow blocks. Delete the extra one, then re-run — ` +
65
+ 'replacing one of two would be a coin flip.');
66
+ case 'malformed':
67
+ throw new BlockConflictError(`AGENTS.md has ${state.reason}. Fix the markers by hand, then re-run — re-appending blindly ` +
68
+ 'would leave two partial blocks.');
69
+ }
70
+ }
package/dist/bin.js ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./cli.js";
3
+ import { fail, UserError } from "./log.js";
4
+ main(process.argv.slice(2))
5
+ .then((code) => {
6
+ process.exitCode = code;
7
+ })
8
+ .catch((error) => {
9
+ if (error instanceof UserError)
10
+ fail(error.message);
11
+ else
12
+ fail(error.stack ?? String(error));
13
+ process.exitCode = 1;
14
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Blank out HTML comments, preserving line count so reported line numbers stay true. The stubs ship their
3
+ * example entries commented out, so a parser that reads them would find a phantom roadmap entry on a fresh
4
+ * install.
5
+ */
6
+ export function stripComments(text) {
7
+ return text.replace(/<!--[\s\S]*?-->/g, (match) => match.replace(/[^\n]/g, ' '));
8
+ }
9
+ function splitCells(line) {
10
+ return line
11
+ .trim()
12
+ .replace(/^\|/, '')
13
+ .replace(/\|$/, '')
14
+ .split('|')
15
+ .map((cell) => cell.trim());
16
+ }
17
+ const SEPARATOR = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/;
18
+ /** Every GitHub-flavoured pipe table in the text. */
19
+ export function parseTables(text) {
20
+ const lines = text.split('\n');
21
+ const tables = [];
22
+ for (let i = 0; i < lines.length - 1; i += 1) {
23
+ const header = lines[i];
24
+ const separator = lines[i + 1];
25
+ if (!header.trim().startsWith('|') || !SEPARATOR.test(separator))
26
+ continue;
27
+ const table = { line: i + 1, header: splitCells(header), rows: [] };
28
+ let j = i + 2;
29
+ for (; j < lines.length; j += 1) {
30
+ const row = lines[j];
31
+ if (!row.trim().startsWith('|'))
32
+ break;
33
+ table.rows.push({ line: j + 1, cells: splitCells(row) });
34
+ }
35
+ tables.push(table);
36
+ i = j - 1;
37
+ }
38
+ return tables;
39
+ }
40
+ /** The link targets in a line of markdown, in order. */
41
+ export function linkTargets(line) {
42
+ const out = [];
43
+ const pattern = /\]\(([^)\s]+)/g;
44
+ for (let m = pattern.exec(line); m !== null; m = pattern.exec(line))
45
+ out.push(m[1]);
46
+ return out;
47
+ }
@@ -0,0 +1,113 @@
1
+ import { linkTargets, parseTables, stripComments } from "./markdown.js";
2
+ export const PHASE_STATUSES = ['not started', 'in progress', 'blocked', 'done'];
3
+ export const FEATURE_MARKERS = ['pending', 'active'];
4
+ export function parseRoadmap(text) {
5
+ const lines = stripComments(text).split('\n');
6
+ const entries = [];
7
+ let current = null;
8
+ for (let i = 0; i < lines.length; i += 1) {
9
+ const line = lines[i];
10
+ const heading = /^###\s+(.*\S)\s*$/.exec(line);
11
+ if (heading) {
12
+ current = null;
13
+ const text = heading[1];
14
+ const marker = /`([^`]+)`\s*$/.exec(text);
15
+ if (!marker)
16
+ continue;
17
+ const name = text
18
+ .slice(0, marker.index)
19
+ .replace(/[\s—–-]+$/, '')
20
+ .trim();
21
+ if (!name)
22
+ continue;
23
+ current = { name, marker: marker[1].trim(), line: i + 1, doc: null, docLine: null };
24
+ entries.push(current);
25
+ continue;
26
+ }
27
+ if (/^##\s/.test(line))
28
+ current = null;
29
+ if (!current || current.doc !== null)
30
+ continue;
31
+ if (/^\s*[-*]\s*\*\*Doc:?\*\*/i.test(line)) {
32
+ current.docLine = i + 1;
33
+ current.doc = linkTargets(line)[0] ?? null;
34
+ }
35
+ }
36
+ return entries;
37
+ }
38
+ const isPhaseTable = (table) => {
39
+ const lower = table.header.map((c) => c.toLowerCase());
40
+ return lower.includes('phase') && lower.includes('status');
41
+ };
42
+ /** Every table in a plan that looks like a phase ledger. More than one is itself a finding. */
43
+ export function parseLedgers(text) {
44
+ return parseTables(stripComments(text))
45
+ .filter(isPhaseTable)
46
+ .map((table) => {
47
+ const index = (name) => table.header.findIndex((c) => c.toLowerCase() === name);
48
+ const numberAt = index('#');
49
+ const phaseAt = index('phase');
50
+ const statusAt = index('status');
51
+ const dependsAt = index('depends on');
52
+ const rows = table.rows.map((row) => {
53
+ const raw = dependsAt === -1 ? '' : (row.cells[dependsAt] ?? '');
54
+ const numberCell = numberAt === -1 ? '' : (row.cells[numberAt] ?? '');
55
+ const parsedNumber = Number.parseInt(numberCell.trim(), 10);
56
+ return {
57
+ line: row.line,
58
+ number: Number.isNaN(parsedNumber) ? null : parsedNumber,
59
+ name: phaseAt === -1 ? '' : (row.cells[phaseAt] ?? ''),
60
+ status: (statusAt === -1 ? '' : (row.cells[statusAt] ?? '')).replace(/`/g, '').trim(),
61
+ dependsRaw: raw,
62
+ dependsOn: parseDepends(raw),
63
+ };
64
+ });
65
+ return { table, columns: table.header, rows };
66
+ });
67
+ }
68
+ function parseDepends(raw) {
69
+ const cleaned = raw.replace(/[`*]/g, '').trim().toLowerCase();
70
+ if (cleaned === '' || cleaned === '—' || cleaned === '–' || cleaned === '-' || cleaned === 'none') {
71
+ return [];
72
+ }
73
+ return cleaned
74
+ .split(/[,\s]+/)
75
+ .map((part) => Number.parseInt(part.replace(/[^0-9]/g, ''), 10))
76
+ .filter((n) => !Number.isNaN(n));
77
+ }
78
+ export function parseFindings(text) {
79
+ const lines = stripComments(text).split('\n');
80
+ let section = null;
81
+ const out = [];
82
+ for (let i = 0; i < lines.length; i += 1) {
83
+ const line = lines[i];
84
+ const h2 = /^##\s+(\S+)/.exec(line);
85
+ if (h2) {
86
+ const name = h2[1].toLowerCase();
87
+ section = name === 'open' ? 'Open' : name === 'closed' ? 'Closed' : null;
88
+ continue;
89
+ }
90
+ const h3 = /^###\s+(F-\d+)\b(.*)$/.exec(line);
91
+ if (h3)
92
+ out.push({ id: h3[1], line: i + 1, section, heading: h3[2].trim() });
93
+ }
94
+ return out;
95
+ }
96
+ export function parseHistory(text) {
97
+ const table = parseTables(stripComments(text)).find((t) => t.header.map((c) => c.toLowerCase()).includes('feature'));
98
+ if (!table)
99
+ return [];
100
+ const featureAt = table.header.findIndex((c) => c.toLowerCase() === 'feature');
101
+ return table.rows.map((row) => ({
102
+ line: row.line,
103
+ feature: (featureAt === -1 ? '' : (row.cells[featureAt] ?? '')).trim(),
104
+ documents: row.cells.flatMap((cell) => linkTargets(cell)),
105
+ }));
106
+ }
107
+ /** Lines carrying a `**Status:**` header — the one thing no document under `context/` may state. */
108
+ export function statusHeaderLines(text) {
109
+ return stripComments(text)
110
+ .split('\n')
111
+ .map((line, i) => (/^\s*\*\*Status:?\*\*/i.test(line) ? i + 1 : 0))
112
+ .filter((n) => n > 0);
113
+ }
@@ -0,0 +1,245 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { exists, walk } from "../paths.js";
4
+ import { PHASE_STATUSES, parseFindings, parseHistory, parseLedgers, parseRoadmap, statusHeaderLines, } from "./parse.js";
5
+ /** Used only when `context/plan-template.md` is missing — the template is the source of truth. */
6
+ const FALLBACK_COLUMNS = ['#', 'Phase', 'Status', 'Depends on', 'Note'];
7
+ const RULES = {
8
+ columns: 'context/plan-template.notes.md — "the ledger\'s column set is fixed — `check` compares every plan\'s ledger against the template\'s"',
9
+ status: 'context/workflow.md — "Phase | the plan\'s ledger, Status column | `not started`, `in progress`, `blocked`, `done`"',
10
+ depends: 'context/workflow.md — "take the lowest-numbered phase that is not `done` and whose `Depends on` entries are all `done`"',
11
+ active: 'context/workflow.md — "At most one roadmap entry is `active`."',
12
+ statusHeader: 'context/README.md — "No document states its own status. There is no `**Status:**` header anywhere under `context/`."',
13
+ oneTable: 'context/plan-template.md §6.1 — "Exactly one table in this document has these columns. Do not add a second phase table"',
14
+ orphanPlan: 'context/workflow.md — "whether a feature has a plan | whether its **Doc** field points into `plans/`"',
15
+ deadLink: 'context/workflow.md — "Each entry\'s **Doc** field points at its document" · "a link into `context/archive/`"',
16
+ closedFinding: 'context/findings.md — "Closed findings leave this file … This file must not grow for the life of the project."',
17
+ };
18
+ function read(root, rel) {
19
+ const full = path.join(root, rel);
20
+ return exists(full) ? readFileSync(full, 'utf8') : null;
21
+ }
22
+ export function expectedColumns(root) {
23
+ const template = read(root, 'context/plan-template.md');
24
+ const ledger = template ? parseLedgers(template)[0] : undefined;
25
+ return ledger ? ledger.columns : FALLBACK_COLUMNS;
26
+ }
27
+ /**
28
+ * Reads `roadmap.md`, `plans/`, `history.md` and `findings.md` — never `archive/`. A retired plan encodes
29
+ * whatever format was current when it was written, and validating historical records against current rules
30
+ * is the false-positive machine that makes validators get ignored.
31
+ */
32
+ export function runChecks(root) {
33
+ const problems = [];
34
+ const columns = expectedColumns(root);
35
+ const roadmapText = read(root, 'context/roadmap.md');
36
+ const roadmap = roadmapText ? parseRoadmap(roadmapText) : [];
37
+ if (roadmapText === null) {
38
+ problems.push({
39
+ level: 'error',
40
+ file: 'context/roadmap.md',
41
+ message: 'missing — the Tier-1 backlog is where every feature status lives',
42
+ rule: RULES.active,
43
+ });
44
+ }
45
+ // More than one entry marked `active`.
46
+ const active = roadmap.filter((entry) => entry.marker === 'active');
47
+ if (active.length > 1) {
48
+ for (const entry of active) {
49
+ problems.push({
50
+ level: 'error',
51
+ file: 'context/roadmap.md',
52
+ line: entry.line,
53
+ message: `"${entry.name}" is marked \`active\`, and so ${active.length === 2 ? 'is' : 'are'} ${active
54
+ .filter((e) => e !== entry)
55
+ .map((e) => `"${e.name}"`)
56
+ .join(', ')}`,
57
+ rule: RULES.active,
58
+ });
59
+ }
60
+ }
61
+ // A `Doc` field pointing at a missing file. Targets are relative to `context/`.
62
+ for (const entry of roadmap) {
63
+ if (!entry.doc || /^[a-z]+:/i.test(entry.doc))
64
+ continue;
65
+ const target = path.join(root, 'context', decodeURI(entry.doc));
66
+ if (!exists(target)) {
67
+ problems.push({
68
+ level: 'error',
69
+ file: 'context/roadmap.md',
70
+ line: entry.docLine ?? entry.line,
71
+ message: `"${entry.name}" points its **Doc** at \`${entry.doc}\`, which does not exist`,
72
+ rule: RULES.deadLink,
73
+ });
74
+ }
75
+ }
76
+ // A `history.md` link pointing at a missing file.
77
+ const historyText = read(root, 'context/history.md');
78
+ for (const row of historyText ? parseHistory(historyText) : []) {
79
+ for (const target of row.documents) {
80
+ if (/^[a-z]+:/i.test(target))
81
+ continue;
82
+ if (!exists(path.join(root, 'context', decodeURI(target)))) {
83
+ problems.push({
84
+ level: 'error',
85
+ file: 'context/history.md',
86
+ line: row.line,
87
+ message: `the row for "${row.feature}" links \`${target}\`, which does not exist`,
88
+ rule: RULES.deadLink,
89
+ });
90
+ }
91
+ }
92
+ }
93
+ // A closed finding still sitting in findings.md.
94
+ const findingsText = read(root, 'context/findings.md');
95
+ for (const finding of findingsText ? parseFindings(findingsText) : []) {
96
+ if (finding.section === 'Closed') {
97
+ problems.push({
98
+ level: 'note',
99
+ file: 'context/findings.md',
100
+ line: finding.line,
101
+ message: `${finding.id} is closed and still in the file — it belongs in the retiring plan's log`,
102
+ rule: RULES.closedFinding,
103
+ });
104
+ }
105
+ }
106
+ // `**Status:**` headers, in the files this command reads.
107
+ const scanned = [
108
+ 'context/roadmap.md',
109
+ 'context/history.md',
110
+ 'context/findings.md',
111
+ ...planPaths(root),
112
+ ];
113
+ for (const rel of scanned) {
114
+ const text = read(root, rel);
115
+ if (text === null)
116
+ continue;
117
+ for (const line of statusHeaderLines(text)) {
118
+ problems.push({
119
+ level: 'error',
120
+ file: rel,
121
+ line,
122
+ message: 'a `**Status:**` header — a document that states its own status is a copy that goes stale',
123
+ rule: RULES.statusHeader,
124
+ });
125
+ }
126
+ }
127
+ // Every plan under plans/.
128
+ const pointedAt = new Set(roadmap
129
+ .filter((entry) => entry.doc?.startsWith('plans/'))
130
+ .map((entry) => path.posix.normalize(entry.doc)));
131
+ for (const rel of planPaths(root)) {
132
+ const text = read(root, rel);
133
+ const relFromContext = rel.slice('context/'.length);
134
+ if (!pointedAt.has(relFromContext)) {
135
+ problems.push({
136
+ level: 'error',
137
+ file: rel,
138
+ message: 'no roadmap entry points at this plan — nothing can reach it, and nothing retires it',
139
+ rule: RULES.orphanPlan,
140
+ });
141
+ }
142
+ const ledgers = parseLedgers(text);
143
+ if (ledgers.length === 0) {
144
+ problems.push({
145
+ level: 'error',
146
+ file: rel,
147
+ message: 'no phase ledger — a document in `plans/` is one that has an executable ledger',
148
+ rule: RULES.columns,
149
+ });
150
+ continue;
151
+ }
152
+ if (ledgers.length > 1) {
153
+ for (const ledger of ledgers.slice(1)) {
154
+ problems.push({
155
+ level: 'error',
156
+ file: rel,
157
+ line: ledger.table.line,
158
+ message: `a second phase table (the first is at line ${ledgers[0].table.line}) — a differently-shaped one nearby is a decoy that gets read by mistake`,
159
+ rule: RULES.oneTable,
160
+ });
161
+ }
162
+ }
163
+ const ledger = ledgers[0];
164
+ if (ledger.columns.join(' | ') !== columns.join(' | ')) {
165
+ problems.push({
166
+ level: 'error',
167
+ file: rel,
168
+ line: ledger.table.line,
169
+ message: `ledger columns are \`${ledger.columns.join(' | ')}\`; the template's are \`${columns.join(' | ')}\``,
170
+ rule: RULES.columns,
171
+ });
172
+ }
173
+ for (const row of ledger.rows) {
174
+ if (!PHASE_STATUSES.includes(row.status)) {
175
+ problems.push({
176
+ level: 'error',
177
+ file: rel,
178
+ line: row.line,
179
+ message: `phase ${row.number ?? '?'} has status \`${row.status}\`; it must be one of ${PHASE_STATUSES.map((s) => `\`${s}\``).join(', ')}`,
180
+ rule: RULES.status,
181
+ });
182
+ }
183
+ }
184
+ problems.push(...dependencyProblems(rel, ledger));
185
+ }
186
+ return problems;
187
+ }
188
+ function planPaths(root) {
189
+ const dir = path.join(root, 'context', 'plans');
190
+ if (!exists(dir))
191
+ return [];
192
+ return walk(dir)
193
+ .filter((rel) => rel.endsWith('.md'))
194
+ .map((rel) => path.posix.join('context/plans', rel.split(path.sep).join('/')));
195
+ }
196
+ function dependencyProblems(file, ledger) {
197
+ const problems = [];
198
+ const numbers = new Set(ledger.rows.map((row) => row.number).filter((n) => n !== null));
199
+ for (const row of ledger.rows) {
200
+ for (const dependency of row.dependsOn) {
201
+ if (!numbers.has(dependency)) {
202
+ problems.push({
203
+ level: 'error',
204
+ file,
205
+ line: row.line,
206
+ message: `phase ${row.number ?? '?'} depends on phase ${dependency}, which is not in the ledger`,
207
+ rule: RULES.depends,
208
+ });
209
+ }
210
+ }
211
+ }
212
+ const edges = new Map();
213
+ for (const row of ledger.rows) {
214
+ if (row.number !== null)
215
+ edges.set(row.number, row.dependsOn.filter((d) => numbers.has(d)));
216
+ }
217
+ const state = new Map();
218
+ const reported = new Set();
219
+ const visit = (node, stack) => {
220
+ if (state.get(node) === 'done')
221
+ return;
222
+ if (state.get(node) === 'visiting') {
223
+ const cycle = [...stack.slice(stack.indexOf(node)), node];
224
+ const key = [...cycle].sort((a, b) => a - b).join(',');
225
+ if (!reported.has(key)) {
226
+ reported.add(key);
227
+ problems.push({
228
+ level: 'error',
229
+ file,
230
+ line: ledger.rows.find((row) => row.number === node)?.line,
231
+ message: `the \`Depends on\` column forms a cycle: ${cycle.join(' → ')}`,
232
+ rule: RULES.depends,
233
+ });
234
+ }
235
+ return;
236
+ }
237
+ state.set(node, 'visiting');
238
+ for (const next of edges.get(node) ?? [])
239
+ visit(next, [...stack, node]);
240
+ state.set(node, 'done');
241
+ };
242
+ for (const node of edges.keys())
243
+ visit(node, []);
244
+ return problems;
245
+ }