@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.
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/agents-block.js +70 -0
- package/dist/bin.js +14 -0
- package/dist/check/markdown.js +47 -0
- package/dist/check/parse.js +113 -0
- package/dist/check/rules.js +245 -0
- package/dist/cli.js +109 -0
- package/dist/commands/check.js +41 -0
- package/dist/commands/install.js +76 -0
- package/dist/commands/standards-add.js +145 -0
- package/dist/commands/update.js +214 -0
- package/dist/layout.js +115 -0
- package/dist/log.js +20 -0
- package/dist/manifest.js +53 -0
- package/dist/paths.js +41 -0
- package/package.json +37 -0
- package/templates/blocks/agents-block.md +30 -0
- package/templates/claude/agents/planner.agent.md +60 -0
- package/templates/claude/agents/reviewer.agent.md +77 -0
- package/templates/context/README.md +81 -0
- package/templates/context/plan-template.md +69 -0
- package/templates/context/plan-template.notes.md +55 -0
- package/templates/context/roles/coder.md +76 -0
- package/templates/context/workflow.md +143 -0
- package/templates/skills/feature-close/SKILL.md +102 -0
- package/templates/skills/feature-implement/SKILL.md +142 -0
- package/templates/skills/feature-plan/SKILL.md +138 -0
- package/templates/skills/feature-status/SKILL.md +86 -0
- package/templates/skills/onboard/SKILL.md +122 -0
- package/templates/skills/orchestrate/SKILL.md +92 -0
- package/templates/skills/roadmap/SKILL.md +97 -0
- package/templates/standards/.source +8 -0
- package/templates/standards/README.md +104 -0
- package/templates/standards/architecture/api-design.md +212 -0
- package/templates/standards/architecture/dependency-boundaries.md +104 -0
- package/templates/standards/architecture/feature-driven.md +102 -0
- package/templates/standards/architecture/folder-structure.md +108 -0
- package/templates/standards/architecture/monorepos.md +107 -0
- package/templates/standards/architecture/refactoring.md +97 -0
- package/templates/standards/architecture/shared-code.md +81 -0
- package/templates/standards/docs/PHP-SPEC.md +1195 -0
- package/templates/standards/docs/SPEC.md +540 -0
- package/templates/standards/examples/bad/data-fetching.tsx +52 -0
- package/templates/standards/examples/bad/derived-state.tsx +60 -0
- package/templates/standards/examples/bad/feature-component.tsx +80 -0
- package/templates/standards/examples/bad/prop-drilling.tsx +70 -0
- package/templates/standards/examples/good/data-fetching.tsx +53 -0
- package/templates/standards/examples/good/derived-state.tsx +52 -0
- package/templates/standards/examples/good/feature-component.tsx +141 -0
- package/templates/standards/examples/good/invoice.schema.ts +20 -0
- package/templates/standards/examples/good/prop-drilling.tsx +83 -0
- package/templates/standards/philosophy/ai-agent-behavior.md +93 -0
- package/templates/standards/philosophy/core-principles.md +49 -0
- package/templates/standards/philosophy/incremental-abstraction.md +56 -0
- package/templates/standards/philosophy/maintainability.md +66 -0
- package/templates/standards/philosophy/readability.md +87 -0
- package/templates/standards/php/README.md +62 -0
- package/templates/standards/php/anti-patterns.md +259 -0
- package/templates/standards/php/architecture.md +241 -0
- package/templates/standards/php/frameworks.md +197 -0
- package/templates/standards/php/rules.md +205 -0
- package/templates/standards/php/security.md +190 -0
- package/templates/standards/php/testing.md +139 -0
- package/templates/standards/php/tooling.md +245 -0
- package/templates/standards/react/accessibility.md +130 -0
- package/templates/standards/react/anti-patterns.md +187 -0
- package/templates/standards/react/component-design.md +139 -0
- package/templates/standards/react/error-boundaries.md +195 -0
- package/templates/standards/react/forms.md +147 -0
- package/templates/standards/react/hooks.md +88 -0
- package/templates/standards/react/memoization.md +87 -0
- package/templates/standards/react/state-management.md +123 -0
- package/templates/standards/react/testing.md +118 -0
- package/templates/standards/react/use-effect.md +110 -0
- package/templates/standards/security/api-security.md +136 -0
- package/templates/standards/security/auth.md +112 -0
- package/templates/standards/security/secrets.md +105 -0
- package/templates/standards/security/validation.md +89 -0
- package/templates/standards/templates/.editorconfig +16 -0
- package/templates/standards/templates/.nvmrc +1 -0
- package/templates/standards/templates/AGENTS.md +46 -0
- package/templates/standards/templates/CLAUDE.md +3 -0
- package/templates/standards/templates/_dot_gitignore +50 -0
- package/templates/standards/templates/biome.json +53 -0
- package/templates/standards/templates/eslint.config.js +61 -0
- package/templates/standards/templates/project-readme.md +76 -0
- package/templates/standards/templates/pull-request-template.md +46 -0
- package/templates/standards/templates/tsconfig.json +35 -0
- package/templates/standards/tooling/biome.md +92 -0
- package/templates/standards/tooling/ci.md +128 -0
- package/templates/standards/tooling/dates.md +194 -0
- package/templates/standards/tooling/dependencies.md +90 -0
- package/templates/standards/tooling/nx.md +113 -0
- package/templates/standards/tooling/observability.md +183 -0
- package/templates/standards/tooling/prisma.md +152 -0
- package/templates/standards/tooling/shadcn.md +115 -0
- package/templates/standards/tooling/tailwind.md +135 -0
- package/templates/standards/tooling/tanstack-query.md +214 -0
- package/templates/standards/tooling/tanstack-router.md +365 -0
- package/templates/standards/tooling/tanstack.md +31 -0
- package/templates/standards/tooling/vite.md +135 -0
- package/templates/standards/typescript/anti-patterns.md +107 -0
- package/templates/standards/typescript/error-handling.md +216 -0
- package/templates/standards/typescript/naming.md +78 -0
- package/templates/standards/typescript/rules.md +159 -0
- package/templates/standards/typescript/tsconfig/base.json +32 -0
- package/templates/standards/typescript/tsconfig/nextjs.json +12 -0
- package/templates/standards/typescript/tsconfig/react.json +9 -0
- package/templates/standards/typescript/validation.md +104 -0
- package/templates/stubs/executors.md +49 -0
- package/templates/stubs/findings.md +40 -0
- package/templates/stubs/history.md +13 -0
- package/templates/stubs/roadmap.md +29 -0
- package/templates/stubs/stack.md +35 -0
- 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
|
+
}
|