doctrina-cli 0.1.0 → 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/README.md +27 -5
- package/package.json +4 -2
- package/src/commands/analyze.js +5 -5
- package/src/commands/change.js +113 -16
- package/src/commands/clarify.js +81 -18
- package/src/commands/context.js +129 -0
- package/src/commands/decision.js +91 -8
- package/src/commands/hooks.js +3 -3
- package/src/commands/index-rebuild.js +109 -0
- package/src/commands/init.js +58 -7
- package/src/commands/intake.js +166 -0
- package/src/commands/metrics.js +190 -0
- package/src/commands/next.js +108 -0
- package/src/commands/search.js +93 -0
- package/src/commands/skill.js +58 -5
- package/src/commands/spec.js +49 -7
- package/src/commands/templates.js +103 -4
- package/src/commands/validate.js +76 -9
- package/src/commands/work.js +215 -0
- package/src/index.js +26 -3
- package/src/lib/diff.js +103 -0
- package/src/lib/ears.js +108 -0
- package/src/lib/fs-ops.js +12 -0
- package/src/lib/scan.js +185 -0
- package/src/lib/templates.js +2 -2
- package/templates/AGENTS.md.template +83 -0
- package/templates/README.md +85 -0
- package/templates/adapters/aider/CONVENTIONS.md.template +11 -0
- package/templates/adapters/amp/README.md +13 -0
- package/templates/adapters/claude/CLAUDE.md.template +11 -0
- package/templates/adapters/codex/README.md +12 -0
- package/templates/adapters/continue/.continue/rules/00-doctrina.md.template +11 -0
- package/templates/adapters/copilot/.github/copilot-instructions.md.template +11 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +16 -0
- package/templates/adapters/devin/README.md +14 -0
- package/templates/adapters/factory/README.md +13 -0
- package/templates/adapters/gemini/GEMINI.md.template +11 -0
- package/templates/adapters/jules/README.md +13 -0
- package/templates/adapters/windsurf/.windsurfrules.template +11 -0
- package/templates/change/design.md.template +24 -0
- package/templates/change/proposal.md.template +26 -0
- package/templates/change/spec-delta.md.template +20 -0
- package/templates/change/tasks.md.template +17 -0
- package/templates/decision.md.template +56 -0
- package/templates/doctrina/changes/.gitkeep +0 -0
- package/templates/doctrina/changes/archive/.gitkeep +0 -0
- package/templates/doctrina/decisions/.gitkeep +0 -0
- package/templates/doctrina/index.json.template +19 -0
- package/templates/doctrina/product.md.template +31 -0
- package/templates/doctrina/skills/.gitkeep +0 -0
- package/templates/doctrina/specs/.gitkeep +0 -0
- package/templates/doctrina/templates/.gitkeep +0 -0
- package/templates/hooks/pre-commit.sample +7 -0
- package/templates/hooks/watch.sample +21 -0
- package/templates/skill.md.template +49 -0
- package/templates/spec-bug.md.template +40 -0
- package/templates/spec.md.template +52 -0
package/README.md
CHANGED
|
@@ -21,17 +21,30 @@ Requires Node.js 20.12 or later.
|
|
|
21
21
|
```
|
|
22
22
|
doctrina init scaffold AGENTS.md + .doctrina/ in cwd
|
|
23
23
|
doctrina spec new <capability> create a capability spec
|
|
24
|
+
doctrina spec list list specs with version, status, size
|
|
24
25
|
doctrina change new <id> "<title>" open a change proposal
|
|
25
26
|
doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto)
|
|
26
27
|
doctrina change archive <id> archive an applied change
|
|
28
|
+
doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
|
|
27
29
|
doctrina decision new "<title>" create the next sequential ADR
|
|
30
|
+
doctrina decision accept <num> flip a proposed ADR to accepted
|
|
28
31
|
doctrina decision supersede <num> "<t>" supersede an existing ADR
|
|
32
|
+
doctrina decision list list ADRs with status, date, title
|
|
33
|
+
doctrina skill new <name> scaffold an on-demand procedural-memory skill
|
|
34
|
+
doctrina skill list list skills with descriptions (read-only)
|
|
35
|
+
doctrina skill sync mirror skill frontmatter descriptions into index.json
|
|
29
36
|
doctrina analyze <change-id> inspect a change folder before applying
|
|
30
|
-
doctrina clarify <path> smell-test a Markdown file for ambiguity
|
|
37
|
+
doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
|
|
38
|
+
doctrina context [<capability>] print the context pack in read order (--concat)
|
|
39
|
+
doctrina search <term> [...] search artifacts, grouped by category
|
|
31
40
|
doctrina validate schema + artifact-existence checks
|
|
32
41
|
doctrina templates list enumerate shipped templates
|
|
33
42
|
doctrina templates check compare project against recommended template shape
|
|
43
|
+
doctrina templates update additive fixer for check findings (preview; --write applies)
|
|
34
44
|
doctrina hooks install install pre-commit hook
|
|
45
|
+
doctrina index rebuild regenerate index.json from the files (--check for CI)
|
|
46
|
+
doctrina next print the recommended next workflow actions
|
|
47
|
+
doctrina metrics local git-derived adoption metrics (no network)
|
|
35
48
|
doctrina --help | -h print the command surface
|
|
36
49
|
doctrina --version | -v print the package version
|
|
37
50
|
```
|
|
@@ -44,7 +57,7 @@ Per-command help is available with `doctrina <command> --help`.
|
|
|
44
57
|
|------|---------|
|
|
45
58
|
| `--project-name <name>` | Override the project name (defaults to cwd basename) |
|
|
46
59
|
| `--project-description <text>` | One-sentence description (prompted if omitted) |
|
|
47
|
-
| `--agent <name>` | Install adapter for `claude`, `codex`, `cursor`, or `all` |
|
|
60
|
+
| `--agent <name>` | Install the adapter for one of the twelve supported agents (`claude`, `codex`, `cursor`, `copilot`, `gemini`, `aider`, `windsurf`, `continue`, `amp`, `devin`, `factory`, `jules`) or `all` |
|
|
48
61
|
| `--date <YYYY-MM-DD>` | Override the system date in artifacts |
|
|
49
62
|
| `--force` | Overwrite existing files |
|
|
50
63
|
| `--non-interactive` | Fail instead of prompting for missing required values |
|
|
@@ -53,10 +66,12 @@ Per-command help is available with `doctrina <command> --help`.
|
|
|
53
66
|
|
|
54
67
|
- `--agent claude` writes a `CLAUDE.md` at the project root that
|
|
55
68
|
`@`-imports `AGENTS.md`. Claude Code picks it up automatically.
|
|
56
|
-
- `--agent codex` installs nothing. OpenAI Codex CLI reads `AGENTS.md`
|
|
57
|
-
natively.
|
|
58
69
|
- `--agent cursor` writes `.cursor/rules/00-doctrina.mdc` with
|
|
59
|
-
`alwaysApply: true` pointing at `AGENTS.md`.
|
|
70
|
+
`alwaysApply: true` pointing at `AGENTS.md`. `copilot`, `gemini`,
|
|
71
|
+
`aider`, `windsurf`, and `continue` follow the same thin-pointer
|
|
72
|
+
pattern at each agent's native path.
|
|
73
|
+
- `--agent codex` (and `amp`, `devin`, `factory`, `jules`) installs
|
|
74
|
+
nothing: these agents read `AGENTS.md` natively.
|
|
60
75
|
- `--agent all` installs every adapter.
|
|
61
76
|
|
|
62
77
|
All adapter files are kept under 30 lines to avoid per-turn token tax
|
|
@@ -87,6 +102,13 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
|
|
|
87
102
|
9. ADRs are ≤ 300 lines (warns above).
|
|
88
103
|
10. Spec/ADR files on disk are present in `index.json` (orphan warn).
|
|
89
104
|
11. Markdown links inside specs/ADRs resolve to real files (stale ref warn).
|
|
105
|
+
12. Skills carry the required frontmatter triple (`name`, `description`, `when`).
|
|
106
|
+
13. Skills are ≤ 200 lines (warns at > 150).
|
|
107
|
+
14. Each skill's `name:` matches its filename slug.
|
|
108
|
+
15. Each spec's `Version:` header matches the version in `index.json` (drift warn).
|
|
109
|
+
16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
|
|
110
|
+
17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
|
|
111
|
+
18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
|
|
90
112
|
|
|
91
113
|
Exit code: 0 if no errors (warnings allowed), 1 otherwise.
|
|
92
114
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doctrina-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
"doctrina": "./src/index.js"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node --test"
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"prepack": "node scripts/copy-templates.js"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
24
25
|
"src",
|
|
26
|
+
"templates",
|
|
25
27
|
"README.md"
|
|
26
28
|
],
|
|
27
29
|
"engines": {
|
package/src/commands/analyze.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
|
|
3
|
+
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
4
4
|
import { c } from "../lib/colors.js";
|
|
5
5
|
|
|
6
6
|
export async function run(positional, _flags) {
|
|
@@ -13,11 +13,11 @@ export async function run(positional, _flags) {
|
|
|
13
13
|
const projectRoot = process.cwd();
|
|
14
14
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
15
15
|
if (!isDir(changeDir)) {
|
|
16
|
-
console.error(c.red("error:") + ` change "${id}" not found at ${
|
|
16
|
+
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
17
17
|
return 1;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
console.log(`analyzing ${
|
|
20
|
+
console.log(`analyzing ${relPath(projectRoot, changeDir)}/`);
|
|
21
21
|
console.log("");
|
|
22
22
|
|
|
23
23
|
const results = [];
|
|
@@ -56,7 +56,7 @@ export async function run(positional, _flags) {
|
|
|
56
56
|
} else {
|
|
57
57
|
results.push(pass(`${deltaFiles.length} spec delta${deltaFiles.length === 1 ? "" : "s"}:`));
|
|
58
58
|
for (const deltaPath of deltaFiles) {
|
|
59
|
-
const rel =
|
|
59
|
+
const rel = relPath(projectRoot, deltaPath);
|
|
60
60
|
const text = read(deltaPath);
|
|
61
61
|
const opMatch = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
|
|
62
62
|
const op = opMatch ? opMatch[1] : null;
|
|
@@ -72,7 +72,7 @@ export async function run(positional, _flags) {
|
|
|
72
72
|
continue;
|
|
73
73
|
}
|
|
74
74
|
const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
|
|
75
|
-
const targetRel =
|
|
75
|
+
const targetRel = relPath(projectRoot, targetSpec);
|
|
76
76
|
if (op === "ADDED") {
|
|
77
77
|
if (exists(targetSpec)) {
|
|
78
78
|
results.push(fail(` ${cap} (ADDED) but target ${targetRel} already exists`, " "));
|
package/src/commands/change.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { exists, isDir, mkdirp, move, read, remove, walk, write } from "../lib/fs-ops.js";
|
|
3
|
+
import { exists, isDir, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { diffLines, formatUnified } from "../lib/diff.js";
|
|
4
5
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
|
|
5
6
|
import * as idx from "../lib/index-json.js";
|
|
6
7
|
import { today } from "../lib/dates.js";
|
|
@@ -8,7 +9,7 @@ import { flagBool } from "../lib/args.js";
|
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
9
10
|
import { suggest } from "../lib/suggest.js";
|
|
10
11
|
|
|
11
|
-
const SUBCOMMANDS = ["new", "apply", "archive"];
|
|
12
|
+
const SUBCOMMANDS = ["new", "apply", "archive", "diff"];
|
|
12
13
|
|
|
13
14
|
export async function run(positional, flags) {
|
|
14
15
|
const sub = positional[0];
|
|
@@ -19,6 +20,8 @@ export async function run(positional, flags) {
|
|
|
19
20
|
return changeApply(positional.slice(1), flags);
|
|
20
21
|
case "archive":
|
|
21
22
|
return changeArchive(positional.slice(1), flags);
|
|
23
|
+
case "diff":
|
|
24
|
+
return changeDiff(positional.slice(1), flags);
|
|
22
25
|
default:
|
|
23
26
|
console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
|
|
24
27
|
const guess = suggest(sub, SUBCOMMANDS);
|
|
@@ -47,7 +50,7 @@ function changeNew(args, flags) {
|
|
|
47
50
|
|
|
48
51
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
49
52
|
if (exists(changeDir) && !force) {
|
|
50
|
-
console.error(c.red("error:") + ` change "${id}" already exists at ${
|
|
53
|
+
console.error(c.red("error:") + ` change "${id}" already exists at ${relPath(projectRoot, changeDir)}`);
|
|
51
54
|
return 1;
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -59,7 +62,7 @@ function changeNew(args, flags) {
|
|
|
59
62
|
for (const entry of tree) {
|
|
60
63
|
if (entry.relativePath === "spec-delta.md.template") continue;
|
|
61
64
|
const written = materialiseEntry(entry, changeDir, tokens, { force });
|
|
62
|
-
console.log(c.green("created") + ` ${
|
|
65
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
|
|
63
66
|
}
|
|
64
67
|
mkdirp(path.join(changeDir, "specs"));
|
|
65
68
|
|
|
@@ -85,7 +88,7 @@ function changeApply(args, _flags) {
|
|
|
85
88
|
|
|
86
89
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
87
90
|
if (!isDir(changeDir)) {
|
|
88
|
-
console.error(c.red("error:") + ` change "${id}" not found at ${
|
|
91
|
+
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
89
92
|
return 1;
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -100,7 +103,7 @@ function changeApply(args, _flags) {
|
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
for (const deltaPath of deltaFiles) {
|
|
103
|
-
const rel =
|
|
106
|
+
const rel = relPath(changeDir, deltaPath);
|
|
104
107
|
const text = read(deltaPath);
|
|
105
108
|
const op = parseOperation(text);
|
|
106
109
|
const capability = parseCapabilityFromDelta(text, deltaPath);
|
|
@@ -113,24 +116,24 @@ function changeApply(args, _flags) {
|
|
|
113
116
|
|
|
114
117
|
if (op === "ADDED") {
|
|
115
118
|
if (exists(targetPath)) {
|
|
116
|
-
console.error(c.red("error:") + ` ADDED delta targets existing spec ${
|
|
119
|
+
console.error(c.red("error:") + ` ADDED delta targets existing spec ${relPath(projectRoot, targetPath)} — refusing to overwrite`);
|
|
117
120
|
errors += 1;
|
|
118
121
|
continue;
|
|
119
122
|
}
|
|
120
123
|
const body = extractDeltaBody(text);
|
|
121
124
|
write(targetPath, body, { force: false });
|
|
122
125
|
writes += 1;
|
|
123
|
-
console.log(c.green("applied[ADDED]") + ` ${
|
|
126
|
+
console.log(c.green("applied[ADDED]") + ` ${relPath(projectRoot, targetPath)}`);
|
|
124
127
|
} else if (op === "REMOVED") {
|
|
125
128
|
if (!exists(targetPath)) {
|
|
126
|
-
console.log(c.yellow("warn:") + ` REMOVED delta targets missing spec ${
|
|
129
|
+
console.log(c.yellow("warn:") + ` REMOVED delta targets missing spec ${relPath(projectRoot, targetPath)} — skipping`);
|
|
127
130
|
continue;
|
|
128
131
|
}
|
|
129
132
|
remove(targetPath);
|
|
130
133
|
writes += 1;
|
|
131
|
-
console.log(c.green("applied[REMOVED]") + ` ${
|
|
134
|
+
console.log(c.green("applied[REMOVED]") + ` ${relPath(projectRoot, targetPath)}`);
|
|
132
135
|
} else if (op === "MODIFIED") {
|
|
133
|
-
console.log(c.yellow("manual[MODIFIED]") + ` merge ${
|
|
136
|
+
console.log(c.yellow("manual[MODIFIED]") + ` merge ${relPath(projectRoot, deltaPath)} into ${relPath(projectRoot, targetPath)} by hand`);
|
|
134
137
|
manual += 1;
|
|
135
138
|
} else {
|
|
136
139
|
console.error(c.red("error:") + ` unknown Operation "${op}" in ${rel}`);
|
|
@@ -201,7 +204,7 @@ function changeArchive(args, _flags) {
|
|
|
201
204
|
|
|
202
205
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
203
206
|
if (!isDir(changeDir)) {
|
|
204
|
-
console.error(c.red("error:") + ` change "${id}" not found at ${
|
|
207
|
+
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
205
208
|
return 1;
|
|
206
209
|
}
|
|
207
210
|
|
|
@@ -209,11 +212,11 @@ function changeArchive(args, _flags) {
|
|
|
209
212
|
const archiveName = `${date}-${id}`;
|
|
210
213
|
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive", archiveName);
|
|
211
214
|
if (exists(archiveDir)) {
|
|
212
|
-
console.error(c.red("error:") + ` archive target already exists: ${
|
|
215
|
+
console.error(c.red("error:") + ` archive target already exists: ${relPath(projectRoot, archiveDir)}`);
|
|
213
216
|
return 1;
|
|
214
217
|
}
|
|
215
218
|
move(changeDir, archiveDir);
|
|
216
|
-
console.log(c.green("archived") + ` ${
|
|
219
|
+
console.log(c.green("archived") + ` ${relPath(projectRoot, archiveDir)}`);
|
|
217
220
|
|
|
218
221
|
// Collect affected specs from delta files for the index entry
|
|
219
222
|
const deltaFiles = walk(path.join(archiveDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
@@ -228,11 +231,31 @@ function changeArchive(args, _flags) {
|
|
|
228
231
|
// Title from proposal
|
|
229
232
|
const proposal = path.join(archiveDir, "proposal.md");
|
|
230
233
|
if (exists(proposal)) {
|
|
231
|
-
|
|
232
|
-
|
|
234
|
+
// First line may end in \r on Windows checkouts (autocrlf); split on
|
|
235
|
+
// either ending so the title regex is not defeated by a stray \r.
|
|
236
|
+
const firstLine = read(proposal).split(/\r?\n/, 1)[0] ?? "";
|
|
237
|
+
// The id itself may contain hyphens (NNNN-slug), so match it as \S+
|
|
238
|
+
// and split on the em-dash/hyphen separator that follows whitespace.
|
|
239
|
+
const m = firstLine.match(/^#\s+Change\s+\S+\s*[—-]\s*(.+)$/);
|
|
233
240
|
if (m) title = m[1].trim();
|
|
234
241
|
}
|
|
235
242
|
|
|
243
|
+
// Append a one-line summary to the archive ledger so history stays
|
|
244
|
+
// scannable without opening folders (the archive is out of the default
|
|
245
|
+
// read path; the ledger is the cheap way back in).
|
|
246
|
+
const ledgerPath = path.join(projectRoot, ".doctrina", "changes", "archive", "LEDGER.md");
|
|
247
|
+
if (!exists(ledgerPath)) {
|
|
248
|
+
write(ledgerPath,
|
|
249
|
+
"# Change ledger\n\n" +
|
|
250
|
+
"One line per archived change, newest last. Appended by\n" +
|
|
251
|
+
"`doctrina change archive`; edit freely, the CLI only appends.\n\n");
|
|
252
|
+
}
|
|
253
|
+
const specsSummary = specsAffected.length > 0
|
|
254
|
+
? ` (specs: ${specsAffected.map((s) => `${s.capability} ${s.operation}`).join(", ")})`
|
|
255
|
+
: "";
|
|
256
|
+
write(ledgerPath, read(ledgerPath) + `- ${date} — ${id} — ${title}${specsSummary}\n`, { force: true });
|
|
257
|
+
console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerPath)}`);
|
|
258
|
+
|
|
236
259
|
const index = idx.load(projectRoot);
|
|
237
260
|
idx.moveChangeToArchive(index, id, {
|
|
238
261
|
id,
|
|
@@ -248,6 +271,74 @@ function changeArchive(args, _flags) {
|
|
|
248
271
|
return 0;
|
|
249
272
|
}
|
|
250
273
|
|
|
274
|
+
function changeDiff(args, _flags) {
|
|
275
|
+
const id = args[0];
|
|
276
|
+
if (!id) {
|
|
277
|
+
console.error(c.red("error:") + " change diff requires <id>");
|
|
278
|
+
return 2;
|
|
279
|
+
}
|
|
280
|
+
const projectRoot = process.cwd();
|
|
281
|
+
ensureDoctrinaProject(projectRoot);
|
|
282
|
+
|
|
283
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
284
|
+
if (!isDir(changeDir)) {
|
|
285
|
+
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
290
|
+
if (deltaFiles.length === 0) {
|
|
291
|
+
console.log(c.gray("no spec deltas in this change; nothing to diff"));
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let errors = 0;
|
|
296
|
+
for (const deltaPath of deltaFiles) {
|
|
297
|
+
const rel = relPath(changeDir, deltaPath);
|
|
298
|
+
const text = read(deltaPath);
|
|
299
|
+
const op = parseOperation(text);
|
|
300
|
+
const capability = parseCapabilityFromDelta(text, deltaPath);
|
|
301
|
+
if (!op || !capability) {
|
|
302
|
+
console.error(c.red("error:") + ` cannot parse Operation or capability in ${rel}`);
|
|
303
|
+
errors += 1;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const targetPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
|
|
307
|
+
const targetRel = relPath(projectRoot, targetPath);
|
|
308
|
+
const body = extractDeltaBody(text);
|
|
309
|
+
const bodyLines = body.split("\n").filter((l, i, a) => !(i === a.length - 1 && l === "")).length;
|
|
310
|
+
|
|
311
|
+
console.log("");
|
|
312
|
+
if (op === "ADDED") {
|
|
313
|
+
const conflict = exists(targetPath) ? c.red(" (conflict: target already exists)") : "";
|
|
314
|
+
console.log(c.green(`ADDED`) + ` ${targetRel} (+${bodyLines} lines)${conflict}`);
|
|
315
|
+
} else if (op === "REMOVED") {
|
|
316
|
+
if (!exists(targetPath)) {
|
|
317
|
+
console.log(c.yellow("REMOVED") + ` ${targetRel} (target already missing)`);
|
|
318
|
+
} else {
|
|
319
|
+
console.log(c.red("REMOVED") + ` ${targetRel} (-${lineCount(targetPath)} lines)`);
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
if (!exists(targetPath)) {
|
|
323
|
+
console.error(c.red("error:") + ` MODIFIED delta targets missing spec ${targetRel}`);
|
|
324
|
+
errors += 1;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
console.log(c.yellow("MODIFIED") + ` ${targetRel}`);
|
|
328
|
+
console.log(c.gray("note: the delta body is a fragment to merge — '-' lines are current"));
|
|
329
|
+
console.log(c.gray(" spec content absent from the delta, not necessarily removals."));
|
|
330
|
+
const out = formatUnified(diffLines(read(targetPath), body), {
|
|
331
|
+
aLabel: targetRel,
|
|
332
|
+
bLabel: relPath(projectRoot, deltaPath),
|
|
333
|
+
});
|
|
334
|
+
console.log(out);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log("");
|
|
339
|
+
return errors > 0 ? 1 : 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
251
342
|
function parseOperation(text) {
|
|
252
343
|
const m = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
|
|
253
344
|
if (!m) return null;
|
|
@@ -286,7 +377,13 @@ Subcommands:
|
|
|
286
377
|
new <id> "<title>" Open a new change proposal at .doctrina/changes/<id>/
|
|
287
378
|
apply <id> Apply spec deltas (ADDED writes, REMOVED deletes, MODIFIED is manual)
|
|
288
379
|
archive <id> Move the change to .doctrina/changes/archive/YYYY-MM-DD-<id>/
|
|
380
|
+
diff <id> Preview every spec delta: line diff for MODIFIED,
|
|
381
|
+
summary for ADDED/REMOVED. Read-only.
|
|
289
382
|
|
|
290
383
|
Options:
|
|
291
384
|
--force Overwrite existing files (where applicable)
|
|
292
385
|
`;
|
|
386
|
+
|
|
387
|
+
// Re-export parsers so scan.js (index rebuild) can reuse them, and the
|
|
388
|
+
// scaffold so `work` can open a change without duplicating the logic.
|
|
389
|
+
export { parseOperation, parseCapabilityFromDelta, changeNew };
|
package/src/commands/clarify.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import {
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
|
+
import { flagBool } from "../lib/args.js";
|
|
4
6
|
import { c } from "../lib/colors.js";
|
|
5
7
|
|
|
6
8
|
const RULES = [
|
|
@@ -25,22 +27,90 @@ const RULES = [
|
|
|
25
27
|
},
|
|
26
28
|
];
|
|
27
29
|
|
|
28
|
-
export async function run(positional,
|
|
30
|
+
export async function run(positional, flags) {
|
|
31
|
+
const projectRoot = process.cwd();
|
|
32
|
+
|
|
33
|
+
if (flagBool(flags, "all", false)) return clarifyAll(projectRoot);
|
|
34
|
+
|
|
29
35
|
const target = positional[0];
|
|
30
36
|
if (!target) {
|
|
31
|
-
console.error(c.red("error:") + " clarify requires a path to a Markdown file");
|
|
37
|
+
console.error(c.red("error:") + " clarify requires a path to a Markdown file (or --all)");
|
|
32
38
|
return 2;
|
|
33
39
|
}
|
|
34
|
-
const projectRoot = process.cwd();
|
|
35
40
|
const fullPath = path.resolve(projectRoot, target);
|
|
36
41
|
if (!isFile(fullPath)) {
|
|
37
42
|
console.error(c.red("error:") + ` file not found: ${target}`);
|
|
38
43
|
return 1;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
console.log(`clarify ${
|
|
46
|
+
console.log(`clarify ${relPath(projectRoot, fullPath)}`);
|
|
47
|
+
console.log("");
|
|
48
|
+
|
|
49
|
+
const smells = scanFile(fullPath);
|
|
50
|
+
for (const s of smells) {
|
|
51
|
+
console.log(`${c.yellow("⚠")} line ${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log("");
|
|
55
|
+
if (smells.length === 0) {
|
|
56
|
+
console.log(c.green("ok") + " no smells found");
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
const word = smells.length === 1 ? "smell" : "smells";
|
|
60
|
+
console.log(c.red("fail") + ` ${smells.length} ${word}`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Walk every living document — product, specs, open changes, skills —
|
|
65
|
+
// and clarify each. ADRs are immutable and the archive is history, so
|
|
66
|
+
// neither is included; AGENTS.md is dense command text, a different
|
|
67
|
+
// register from prose specs.
|
|
68
|
+
function clarifyAll(projectRoot) {
|
|
69
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
70
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
71
|
+
}
|
|
72
|
+
const files = [];
|
|
73
|
+
const productPath = path.join(projectRoot, ".doctrina", "product.md");
|
|
74
|
+
if (isFile(productPath)) files.push(productPath);
|
|
75
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
76
|
+
if (isDir(specsDir)) {
|
|
77
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
78
|
+
const p = path.join(specsDir, cap, "spec.md");
|
|
79
|
+
if (isFile(p)) files.push(p);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
83
|
+
if (isDir(changesDir)) {
|
|
84
|
+
for (const id of readdirSync(changesDir).sort()) {
|
|
85
|
+
if (id === "archive" || id.startsWith(".")) continue;
|
|
86
|
+
if (!isDir(path.join(changesDir, id))) continue;
|
|
87
|
+
files.push(...walk(path.join(changesDir, id)).filter((f) => f.endsWith(".md")));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
91
|
+
files.push(...walk(skillsDir).filter((f) => f.endsWith(".md")));
|
|
92
|
+
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const f of files) {
|
|
95
|
+
const smells = scanFile(f);
|
|
96
|
+
if (smells.length === 0) continue;
|
|
97
|
+
total += smells.length;
|
|
98
|
+
const rel = relPath(projectRoot, f);
|
|
99
|
+
for (const s of smells) {
|
|
100
|
+
console.log(`${c.yellow("⚠")} ${rel}:${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
42
104
|
console.log("");
|
|
105
|
+
if (total === 0) {
|
|
106
|
+
console.log(c.green("ok") + ` no smells in ${files.length} living document${files.length === 1 ? "" : "s"}`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
console.log(c.red("fail") + ` ${total} smell${total === 1 ? "" : "s"} across ${files.length} documents`);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
43
112
|
|
|
113
|
+
function scanFile(fullPath) {
|
|
44
114
|
const text = read(fullPath);
|
|
45
115
|
const skippedRanges = collectSkippedRanges(text);
|
|
46
116
|
const smells = [];
|
|
@@ -80,19 +150,7 @@ export async function run(positional, _flags) {
|
|
|
80
150
|
}
|
|
81
151
|
|
|
82
152
|
smells.sort((a, b) => a.line - b.line);
|
|
83
|
-
|
|
84
|
-
for (const s of smells) {
|
|
85
|
-
console.log(`${c.yellow("⚠")} line ${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
console.log("");
|
|
89
|
-
if (smells.length === 0) {
|
|
90
|
-
console.log(c.green("ok") + " no smells found");
|
|
91
|
-
return 0;
|
|
92
|
-
}
|
|
93
|
-
const word = smells.length === 1 ? "smell" : "smells";
|
|
94
|
-
console.log(c.red("fail") + ` ${smells.length} ${word}`);
|
|
95
|
-
return 1;
|
|
153
|
+
return smells;
|
|
96
154
|
}
|
|
97
155
|
|
|
98
156
|
// Build ranges to skip: fenced code blocks, HTML comments, inline backticks.
|
|
@@ -135,12 +193,17 @@ function findAcceptanceLine(text) {
|
|
|
135
193
|
|
|
136
194
|
export const help = `
|
|
137
195
|
Usage: doctrina clarify <path>
|
|
196
|
+
doctrina clarify --all
|
|
138
197
|
|
|
139
198
|
Read a Markdown file and report weasel words, vague quantifiers,
|
|
140
199
|
placeholders, and empty Acceptance criteria sections. Exits 0 when
|
|
141
200
|
no smells are found, 1 otherwise. Matches inside fenced code
|
|
142
201
|
blocks, HTML comments, and inline backtick spans are skipped.
|
|
143
202
|
|
|
203
|
+
With --all, every living document is scanned in one pass:
|
|
204
|
+
product.md, capability specs, open changes, and skills. ADRs
|
|
205
|
+
(immutable) and the archive (history) are excluded. CI-friendly.
|
|
206
|
+
|
|
144
207
|
Intended use: run before \`doctrina change apply\` or before
|
|
145
208
|
opening a PR. The command never modifies files.
|
|
146
209
|
`;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
|
+
import { listHeader } from "../lib/scan.js";
|
|
6
|
+
import { parseFrontmatter } from "./skill.js";
|
|
7
|
+
import { flagBool } from "../lib/args.js";
|
|
8
|
+
import { c } from "../lib/colors.js";
|
|
9
|
+
|
|
10
|
+
// Materialise the AGENTS.md read order as a command: print the exact
|
|
11
|
+
// context pack for a task, in the order an agent should read it. This is
|
|
12
|
+
// the context-engineering thesis turned into tooling — selection over
|
|
13
|
+
// dumping, ranked by the documented read order.
|
|
14
|
+
|
|
15
|
+
export async function run(positional, flags) {
|
|
16
|
+
const capability = positional[0] ?? null;
|
|
17
|
+
const concat = flagBool(flags, "concat", false);
|
|
18
|
+
const projectRoot = process.cwd();
|
|
19
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
20
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (capability && !/^[a-z][a-z0-9-]*$/.test(capability)) {
|
|
24
|
+
console.error(c.red("error:") + ` invalid capability "${capability}" (lowercase letters, digits, hyphens)`);
|
|
25
|
+
return 2;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pack = [];
|
|
29
|
+
const pushFile = (rel, note) => {
|
|
30
|
+
const full = path.join(projectRoot, rel);
|
|
31
|
+
if (isFile(full)) pack.push({ rel, lines: lineCount(full), note });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// 1-2. Root rules and product truth — always first. Displayed paths are
|
|
35
|
+
// POSIX strings (path.join would emit backslashes on Windows).
|
|
36
|
+
pushFile("AGENTS.md", "root rules");
|
|
37
|
+
pushFile(".doctrina/product.md", "product truth");
|
|
38
|
+
|
|
39
|
+
// 3. The capability spec under work.
|
|
40
|
+
let specMissing = false;
|
|
41
|
+
if (capability) {
|
|
42
|
+
const specRel = `.doctrina/specs/${capability}/spec.md`;
|
|
43
|
+
if (isFile(path.join(projectRoot, specRel))) {
|
|
44
|
+
pushFile(specRel, `spec: ${capability}`);
|
|
45
|
+
} else {
|
|
46
|
+
specMissing = true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 4. Open changes (their proposal, tasks, and deltas).
|
|
51
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
52
|
+
if (isDir(changesDir)) {
|
|
53
|
+
for (const id of readdirSync(changesDir).sort()) {
|
|
54
|
+
if (id === "archive" || id.startsWith(".")) continue;
|
|
55
|
+
if (!isDir(path.join(changesDir, id))) continue;
|
|
56
|
+
for (const f of walk(path.join(changesDir, id))) {
|
|
57
|
+
if (!f.endsWith(".md")) continue;
|
|
58
|
+
pushFile(relPath(projectRoot, f), `open change: ${id}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 5. Accepted ADRs only — superseded/withdrawn stay out of the pack.
|
|
64
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
65
|
+
for (const f of walk(adrDir)) {
|
|
66
|
+
if (!f.endsWith(".md")) continue;
|
|
67
|
+
const status = (listHeader(read(f), "Status") ?? "").toLowerCase();
|
|
68
|
+
if (status === "accepted") pushFile(relPath(projectRoot, f), "accepted ADR");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 6. Skills are on-demand: list name + description, never the body.
|
|
72
|
+
const onDemand = [];
|
|
73
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
74
|
+
for (const f of walk(skillsDir)) {
|
|
75
|
+
if (!f.endsWith(".md")) continue;
|
|
76
|
+
const text = read(f);
|
|
77
|
+
onDemand.push({
|
|
78
|
+
rel: relPath(projectRoot, f),
|
|
79
|
+
name: parseFrontmatter(text, "name") ?? path.basename(f, ".md"),
|
|
80
|
+
description: parseFrontmatter(text, "description") ?? "<missing description>",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (concat) {
|
|
85
|
+
for (const item of pack) {
|
|
86
|
+
console.log(`===== ${item.rel} (${item.note}) =====`);
|
|
87
|
+
console.log("");
|
|
88
|
+
console.log(read(path.join(projectRoot, item.rel)).trimEnd());
|
|
89
|
+
console.log("");
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + c.gray(" — read in this order:"));
|
|
93
|
+
console.log("");
|
|
94
|
+
pack.forEach((item, i) => {
|
|
95
|
+
console.log(`${i + 1}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(item.note)}`);
|
|
96
|
+
});
|
|
97
|
+
const total = pack.reduce((sum, item) => sum + item.lines, 0);
|
|
98
|
+
console.log("");
|
|
99
|
+
console.log(c.gray(`${pack.length} files, ${total} lines total · archive excluded by design`));
|
|
100
|
+
if (onDemand.length > 0) {
|
|
101
|
+
console.log("");
|
|
102
|
+
console.log(c.bold("On-demand skills") + c.gray(" (load the body only when the task matches):"));
|
|
103
|
+
for (const s of onDemand) {
|
|
104
|
+
console.log(` ${c.cyan(s.name.padEnd(24))} ${s.description} ${c.gray(s.rel)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (specMissing) {
|
|
110
|
+
console.error("");
|
|
111
|
+
console.error(c.yellow("warn:") + ` no spec for capability "${capability}" — create one with \`doctrina spec new ${capability}\``);
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const help = `
|
|
117
|
+
Usage: doctrina context [<capability>] [--concat]
|
|
118
|
+
|
|
119
|
+
Print the exact context pack for a task, in the documented read
|
|
120
|
+
order: AGENTS.md, product.md, the capability spec (when given), open
|
|
121
|
+
changes, accepted ADRs. Skills are listed name + description only —
|
|
122
|
+
they are on-demand by design. The change archive is excluded.
|
|
123
|
+
|
|
124
|
+
Flags:
|
|
125
|
+
--concat Print the file contents (with separators) instead of the
|
|
126
|
+
file list — ready to pipe into an agent.
|
|
127
|
+
|
|
128
|
+
Read-only; always exits 0.
|
|
129
|
+
`;
|