doctrina-cli 0.15.1 → 0.17.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 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> CLI for the Doctrina framework. The package is published as
|
|
4
4
|
> `doctrina-cli`; the executable it installs is `doctrina`.
|
|
5
5
|
|
|
6
|
-
**Status:** v0.
|
|
6
|
+
**Status:** v0.17.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
|
@@ -18,59 +18,31 @@ Requires Node.js 20.12 or later.
|
|
|
18
18
|
|
|
19
19
|
## Commands
|
|
20
20
|
|
|
21
|
+
Start with `doctrina init` in your repository, then tell your AI agent to
|
|
22
|
+
read `AGENTS.md` and run `doctrina next` — it drives the rest. The
|
|
23
|
+
surface, by the moment you reach for it:
|
|
24
|
+
|
|
21
25
|
```
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
doctrina spec list list specs with version, status, size
|
|
30
|
-
doctrina spec set <cap> [--bump ...] edit spec headers/criterion + resync index (no lockstep)
|
|
31
|
-
doctrina change new <id> "<title>" open a change proposal
|
|
32
|
-
doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto; MODIFIED ops block applies)
|
|
33
|
-
doctrina change archive <id> archive an applied change
|
|
34
|
-
doctrina change abandon <id> delete an open change + its index entry (ledgered)
|
|
35
|
-
doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
|
|
36
|
-
doctrina contract new <name> own the integration surface (ports, env, interfaces)
|
|
37
|
-
doctrina contract check verify port collisions, env drift, referenced specs
|
|
38
|
-
doctrina decision new "<title>" create the next sequential ADR
|
|
39
|
-
doctrina decision accept <num> flip a proposed ADR to accepted
|
|
40
|
-
doctrina decision land <num> [path...] stamp an accepted ADR as implemented (non-mutating Landed:)
|
|
41
|
-
doctrina decision supersede <num> "<t>" supersede an existing ADR
|
|
42
|
-
doctrina decision list list ADRs with status, date, title
|
|
43
|
-
doctrina skill new <name> scaffold an on-demand procedural-memory skill
|
|
44
|
-
doctrina skill list list skills with descriptions (read-only)
|
|
45
|
-
doctrina skill sync mirror skill frontmatter descriptions into index.json
|
|
46
|
-
doctrina analyze <change-id> inspect a change folder before applying
|
|
47
|
-
doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
|
|
48
|
-
doctrina context [<capability>] print the context pack in read order (--concat)
|
|
49
|
-
doctrina search <term> [...] search artifacts, grouped by category (ranked)
|
|
50
|
-
doctrina validate schema + artifact-existence + index-drift checks (--fix rebuilds)
|
|
51
|
-
doctrina coverage acceptance criteria with linked evidence (--strict gates; skips = conditional)
|
|
52
|
-
doctrina trace intent provenance: product intent -> specs (--strict gates)
|
|
53
|
-
doctrina verify run project typecheck/test/build checks (the real gate)
|
|
54
|
-
doctrina verify --clean reproducibility lint: clean-checkout footguns (dist/codegen)
|
|
55
|
-
doctrina templates list enumerate shipped templates
|
|
56
|
-
doctrina templates check compare project against recommended template shape
|
|
57
|
-
doctrina templates update additive fixer for check findings (preview; --write applies)
|
|
58
|
-
doctrina hooks install install pre-commit hook
|
|
59
|
-
doctrina index rebuild regenerate index.json from the files (--check for CI)
|
|
60
|
-
doctrina next print the recommended next workflow actions
|
|
61
|
-
doctrina metrics local git-derived adoption metrics (no network)
|
|
62
|
-
doctrina --help | -h print the command surface
|
|
63
|
-
doctrina --version | -v print the package version
|
|
26
|
+
Bootstrap init · intake · adapter list|add|remove
|
|
27
|
+
Orient prime · next · status · context · show · search · why · handoff
|
|
28
|
+
Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon
|
|
29
|
+
contract new|list|check · decision new|accept|land|supersede|list|scope
|
|
30
|
+
skill new|list|suggest · intent add|list
|
|
31
|
+
Gate clarify · validate · coverage · trace · review · verify · close · doctor
|
|
32
|
+
Maintain upgrade · index rebuild · hooks install · watch · metrics · completion · ci · templates list
|
|
64
33
|
```
|
|
65
34
|
|
|
66
|
-
|
|
35
|
+
`doctrina --help` prints the same grouping with one line per command, and
|
|
36
|
+
`doctrina <command> --help` the flags of each. Every command, flag and exit
|
|
37
|
+
code is in the
|
|
38
|
+
[CLI reference](https://github.com/GCarin1/Doctrina/blob/main/docs/en/cli-reference.md).
|
|
67
39
|
|
|
68
40
|
## `init` flags
|
|
69
41
|
|
|
70
42
|
| Flag | Purpose |
|
|
71
43
|
|------|---------|
|
|
72
44
|
| `--project-name <name>` | Override the project name (defaults to cwd basename) |
|
|
73
|
-
| `--project-description <text>` | One-sentence description
|
|
45
|
+
| `--project-description <text>` | One-sentence description, with no intake. Without it on a terminal, `init` asks you to describe the project once and keeps the answer as the intake |
|
|
74
46
|
| `--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` |
|
|
75
47
|
| `--date <YYYY-MM-DD>` | Override the system date in artifacts |
|
|
76
48
|
| `--force` | Overwrite existing files |
|
|
@@ -100,7 +72,8 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
|
|
|
100
72
|
- **REMOVED:** deletes the target spec.
|
|
101
73
|
- **MODIFIED:** when the delta carries a fenced ` ```ops ` block
|
|
102
74
|
(`set-header` / `bump-version` / `set-criterion` / `replace-criterion`
|
|
103
|
-
/ `append-criterion`
|
|
75
|
+
/ `append-criterion` / `append-requirement` / `replace-requirement`),
|
|
76
|
+
applies it mechanically to the target spec — all
|
|
104
77
|
ops or none — and regenerates the index (ADR 0007). Without an ops
|
|
105
78
|
block it prints a manual-merge pointer and does not write: auto-merging
|
|
106
79
|
arbitrary prose is the kind of implicit decision Doctrina avoids.
|
|
@@ -108,35 +81,17 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
|
|
|
108
81
|
On any spec write, the index is rebuilt from the tree so it never drifts
|
|
109
82
|
from the applied spec. `doctrina change abandon <id>` is the inverse of
|
|
110
83
|
`change new`: it deletes an open change and its index entry and ledgers
|
|
111
|
-
the abandonment.
|
|
84
|
+
the abandonment. `doctrina close <id>` runs apply together with verify,
|
|
85
|
+
coverage, archive and validate in one attested pass — the definition of
|
|
86
|
+
done.
|
|
112
87
|
|
|
113
88
|
## `validate` checks
|
|
114
89
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
6. Every adapter template under `.doctrina/templates/adapters/` is ≤ 30 lines.
|
|
121
|
-
7. Every open change has a `proposal.md`.
|
|
122
|
-
8. Capability specs are ≤ 400 lines (warns above).
|
|
123
|
-
9. ADRs are ≤ 300 lines (warns above).
|
|
124
|
-
10. Spec/ADR files on disk are present in `index.json` (orphan warn).
|
|
125
|
-
11. Markdown links inside specs/ADRs resolve to real files (stale ref warn).
|
|
126
|
-
12. Skills carry the required frontmatter triple (`name`, `description`, `when`).
|
|
127
|
-
13. Skills are ≤ 200 lines (warns at > 150).
|
|
128
|
-
14. Each skill's `name:` matches its filename slug.
|
|
129
|
-
15. Indexed artifact metadata (spec/decision/change/contract) matches its
|
|
130
|
-
file — a mismatch is an **error** so a green `validate` can't hide the
|
|
131
|
-
drift `index rebuild --check` would catch (G5; `validate --fix` rebuilds).
|
|
132
|
-
16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
|
|
133
|
-
17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
|
|
134
|
-
18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
|
|
135
|
-
19. No two ADR files share the same `NNNN` number (merge-collision error).
|
|
136
|
-
20. `index.json` records a `framework_version` matching the running CLI
|
|
137
|
-
(stamp-divergence warn; `index rebuild` migrates it).
|
|
138
|
-
21. Accepted ADRs anchor to reality via `Evidence:` and/or `Landed:`; cited
|
|
139
|
-
paths must exist on disk (drift warn).
|
|
90
|
+
Structure, schema, EARS grammar, index drift, size caps, links, skills,
|
|
91
|
+
ADR anchoring and more — the full, numbered list with the reason for each
|
|
92
|
+
check is in the
|
|
93
|
+
[CLI reference](https://github.com/GCarin1/Doctrina/blob/main/docs/en/cli-reference.md#doctrina-validate).
|
|
94
|
+
A second copy here fell behind it once; there is now one list.
|
|
140
95
|
|
|
141
96
|
Exit code: 0 if no errors (warnings allowed), 1 otherwise. `--fix`
|
|
142
97
|
regenerates `index.json` from the tree before validating.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doctrina-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"type": "module",
|
|
17
17
|
"bin": {
|
|
18
|
-
"doctrina": "
|
|
18
|
+
"doctrina": "src/index.js"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"test": "node --test",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
35
|
-
"url": "https://github.com/
|
|
35
|
+
"url": "git+https://github.com/GCarin1/Doctrina.git",
|
|
36
36
|
"directory": "packages/doctrina-cli"
|
|
37
37
|
},
|
|
38
38
|
"publishConfig": {
|
package/src/commands/adapter.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { exists, isFile, mkdirp, read, relPath, remove, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { exists, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, write } from "../lib/fs-ops.js";
|
|
5
5
|
import {
|
|
6
6
|
ADAPTER_STATES, adapterFiles, describeAdapter, listAdapterNames, renderAdapterFile,
|
|
7
7
|
} from "../lib/adapters.js";
|
|
@@ -10,6 +10,7 @@ import { today } from "../lib/dates.js";
|
|
|
10
10
|
import { c } from "../lib/colors.js";
|
|
11
11
|
import { suggest } from "../lib/suggest.js";
|
|
12
12
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
|
+
import { projectName } from "../lib/project.js";
|
|
13
14
|
|
|
14
15
|
// `doctrina adapter` — add, remove, and inventory agent adapters (audit
|
|
15
16
|
// item C1).
|
|
@@ -96,7 +97,7 @@ function adapterAdd(projectRoot, name, cmdFlags) {
|
|
|
96
97
|
|
|
97
98
|
const force = flagBool(cmdFlags, "force", false);
|
|
98
99
|
const tokens = {
|
|
99
|
-
PROJECT_NAME:
|
|
100
|
+
PROJECT_NAME: projectName(projectRoot),
|
|
100
101
|
PROJECT_DESCRIPTION: "",
|
|
101
102
|
DATE: today(),
|
|
102
103
|
};
|
|
@@ -145,9 +146,13 @@ function adapterRemove(projectRoot, name, cmdFlags) {
|
|
|
145
146
|
// Only delete files this adapter created AND that still match what it
|
|
146
147
|
// would write. A file the user edited is theirs now; --force overrides.
|
|
147
148
|
const force = flagBool(cmdFlags, "force", false);
|
|
148
|
-
const tokens = { PROJECT_NAME:
|
|
149
|
+
const tokens = { PROJECT_NAME: projectName(projectRoot), PROJECT_DESCRIPTION: "", DATE: today() };
|
|
149
150
|
let removed = 0;
|
|
150
151
|
let kept = 0;
|
|
152
|
+
// Directories a removed file lived in, innermost first. Only these are
|
|
153
|
+
// considered for pruning: a directory the adapter never wrote into is not
|
|
154
|
+
// this command's to tidy.
|
|
155
|
+
const vacated = new Set();
|
|
151
156
|
for (const file of spec.files) {
|
|
152
157
|
const dest = path.join(projectRoot, file.relativePath);
|
|
153
158
|
if (!isFile(dest)) continue;
|
|
@@ -160,9 +165,28 @@ function adapterRemove(projectRoot, name, cmdFlags) {
|
|
|
160
165
|
remove(dest);
|
|
161
166
|
console.log(c.green("removed") + ` ${file.relativePath}`);
|
|
162
167
|
removed += 1;
|
|
168
|
+
vacated.add(path.dirname(dest));
|
|
163
169
|
}
|
|
170
|
+
|
|
171
|
+
// `add` calls mkdirp on the way in, so `remove` unmakes what it made on
|
|
172
|
+
// the way out. Without this the six files went and `.claude/` plus
|
|
173
|
+
// `.claude/commands/` stayed — and an empty `.claude/` is not neutral: it
|
|
174
|
+
// is a configuration root the next agent finds and treats as present.
|
|
175
|
+
// Only EMPTY directories are pruned, walking up and stopping at the first
|
|
176
|
+
// one that still holds anything, so a kept (edited) file or anything of
|
|
177
|
+
// the user's keeps its whole directory chain alive.
|
|
178
|
+
const pruned = [];
|
|
179
|
+
for (const dir of [...vacated].sort((a, b) => b.length - a.length)) {
|
|
180
|
+
for (const gone of pruneEmptyDirs(dir, projectRoot)) {
|
|
181
|
+
pruned.push(relPath(projectRoot, gone).replaceAll("\\", "/"));
|
|
182
|
+
console.log(c.green("removed") + ` ${relPath(projectRoot, gone).replaceAll("\\", "/")}/ ` +
|
|
183
|
+
c.gray("(empty)"));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
console.log("");
|
|
165
188
|
console.log(`${c.bold("Adapter " + name)} — ${removed} file${removed === 1 ? "" : "s"} removed` +
|
|
189
|
+
(pruned.length > 0 ? `, ${pruned.length} empty director${pruned.length === 1 ? "y" : "ies"} pruned` : "") +
|
|
166
190
|
(kept > 0 ? `, ${kept} kept (edited)` : ""));
|
|
167
191
|
return 0;
|
|
168
192
|
}
|
package/src/commands/analyze.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import {
|
|
4
|
+
import { isDir, relPath } from "../lib/fs-ops.js";
|
|
5
5
|
import { c } from "../lib/colors.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { collectAnalysis } from "../lib/analysis.js";
|
|
7
|
+
import { EXIT } from "../lib/exit-codes.js";
|
|
8
|
+
import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
|
|
9
|
+
|
|
10
|
+
// The findings themselves live in lib/analysis.js, where the gate map reads
|
|
11
|
+
// them; this command is their renderer (audit finding F7).
|
|
12
|
+
export { collectAnalysis } from "../lib/analysis.js";
|
|
8
13
|
|
|
9
14
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
10
15
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -19,10 +24,17 @@ export async function run(positional, _flags) {
|
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
const projectRoot = process.cwd();
|
|
27
|
+
// The project first, then the reference. Asked the other way round, a
|
|
28
|
+
// directory that is not a project at all answered `change "x" not found`
|
|
29
|
+
// — which points at creating a change somewhere there is nothing to
|
|
30
|
+
// create it in.
|
|
31
|
+
ensureDoctrinaProject(projectRoot);
|
|
32
|
+
const refused = refuseChangeRef(id);
|
|
33
|
+
if (refused !== null) return refused;
|
|
22
34
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
23
35
|
if (!isDir(changeDir)) {
|
|
24
36
|
console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
|
|
25
|
-
return
|
|
37
|
+
return EXIT.USAGE;
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
console.log(`analyzing ${relPath(projectRoot, changeDir)}/`);
|
|
@@ -41,214 +53,6 @@ export async function run(positional, _flags) {
|
|
|
41
53
|
return failed === 0 ? 0 : 1;
|
|
42
54
|
}
|
|
43
55
|
|
|
44
|
-
// The structural findings for a change, as data. Exported so the shared
|
|
45
|
-
// gate map (`lib/gates.js`) can enforce the SAME checks on every
|
|
46
|
-
// transition that needs them, instead of each command deciding for itself
|
|
47
|
-
// — `change apply` used to mutate a change that `analyze` had just
|
|
48
|
-
// refused (audit item C6).
|
|
49
|
-
export function collectAnalysis(projectRoot, changeDir) {
|
|
50
|
-
const results = [];
|
|
51
|
-
|
|
52
|
-
// proposal.md
|
|
53
|
-
const proposalPath = path.join(changeDir, "proposal.md");
|
|
54
|
-
if (!isFile(proposalPath)) {
|
|
55
|
-
results.push(fail("proposal.md missing"));
|
|
56
|
-
} else {
|
|
57
|
-
results.push(pass("proposal.md present"));
|
|
58
|
-
const text = read(proposalPath);
|
|
59
|
-
if (/^##\s+Why\b/m.test(text)) results.push(pass(`proposal.md has a "## Why" section`));
|
|
60
|
-
else results.push(fail(`proposal.md missing "## Why" section`));
|
|
61
|
-
|
|
62
|
-
// A section that still holds only its scaffold comment was never
|
|
63
|
-
// written. The check above only proved the HEADING survived, which is
|
|
64
|
-
// why six changes closed in one session with every rationale section
|
|
65
|
-
// empty: the planning step wrote to the file with a pattern that did
|
|
66
|
-
// not match its line endings, and nothing downstream looked inside.
|
|
67
|
-
// For a framework whose whole premise is recoverable provenance, an
|
|
68
|
-
// archived change that cannot say why it happened is the defect.
|
|
69
|
-
// Only `Why` and `What` are required. They are what makes a change
|
|
70
|
-
// recoverable a year later — the reason it exists and the shape it
|
|
71
|
-
// took. `Scope boundaries` and `Open questions` are legitimately empty
|
|
72
|
-
// on a change that has neither, and demanding the word "None." there
|
|
73
|
-
// is friction that buys nothing.
|
|
74
|
-
const hollow = [];
|
|
75
|
-
for (const m of text.matchAll(/^##\s+(.+?)[ \t]*\r?$/gm)) {
|
|
76
|
-
const heading = m[1].trim();
|
|
77
|
-
if (!/^(Why|What)$/i.test(heading)) continue;
|
|
78
|
-
const start = m.index + m[0].length;
|
|
79
|
-
const next = text.slice(start).search(/^##\s+/m);
|
|
80
|
-
const body = (next < 0 ? text.slice(start) : text.slice(start, start + next));
|
|
81
|
-
// Strip comments; anything left that is not whitespace is real prose.
|
|
82
|
-
if (body.replace(/<!--[\s\S]*?-->/g, "").trim() === "") hollow.push(heading);
|
|
83
|
-
}
|
|
84
|
-
if (hollow.length > 0) {
|
|
85
|
-
results.push(fail(
|
|
86
|
-
`proposal.md has ${hollow.length} unwritten section${hollow.length === 1 ? "" : "s"} ` +
|
|
87
|
-
`(${hollow.join(", ")}) — a heading that survived is not a section that was written`,
|
|
88
|
-
));
|
|
89
|
-
} else {
|
|
90
|
-
results.push(pass("proposal.md states why and what"));
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// tasks.md
|
|
95
|
-
const tasksPath = path.join(changeDir, "tasks.md");
|
|
96
|
-
if (!isFile(tasksPath)) {
|
|
97
|
-
results.push(fail("tasks.md missing"));
|
|
98
|
-
} else {
|
|
99
|
-
results.push(pass("tasks.md present"));
|
|
100
|
-
const text = read(tasksPath);
|
|
101
|
-
// Scaffold placeholders never replaced ("- [ ]" with no text, checked or
|
|
102
|
-
// not) mean the change was OPENED but never PLANNED — the agent went
|
|
103
|
-
// straight to implementing on a hollow change and nothing barked
|
|
104
|
-
// (operator report 2026-07-19). A hard failure here blocks `change
|
|
105
|
-
// check` and `close` until the plan is real; ticking an empty box does
|
|
106
|
-
// not help, `change tick` refuses those too.
|
|
107
|
-
const placeholders = (text.match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
|
|
108
|
-
if (placeholders > 0) {
|
|
109
|
-
results.push(fail(`tasks.md still carries ${placeholders} scaffold placeholder task${placeholders === 1 ? "" : "s"} ("- [ ]" with no text) — plan the change before implementing: replace them with real tasks (or delete the lines)`));
|
|
110
|
-
}
|
|
111
|
-
if (/^\s*-\s*\[\s\]/m.test(text)) results.push(pass("tasks.md has at least one unchecked task"));
|
|
112
|
-
else results.push(info("tasks.md has no unchecked tasks (already done?)"));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// design.md (optional)
|
|
116
|
-
const designPath = path.join(changeDir, "design.md");
|
|
117
|
-
if (isFile(designPath)) results.push(info("design.md present"));
|
|
118
|
-
else results.push(info("design.md absent (optional)"));
|
|
119
|
-
|
|
120
|
-
// Spec deltas
|
|
121
|
-
const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
122
|
-
if (deltaFiles.length === 0) {
|
|
123
|
-
results.push(info("0 spec deltas (metadata-only change)"));
|
|
124
|
-
} else {
|
|
125
|
-
results.push(pass(`${deltaFiles.length} spec delta${deltaFiles.length === 1 ? "" : "s"}:`));
|
|
126
|
-
for (const deltaPath of deltaFiles) {
|
|
127
|
-
const rel = relPath(projectRoot, deltaPath);
|
|
128
|
-
const text = read(deltaPath);
|
|
129
|
-
const opMatch = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
|
|
130
|
-
const op = opMatch ? opMatch[1] : null;
|
|
131
|
-
const capMatch = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
|
|
132
|
-
const cap = capMatch ? capMatch[1] : path.basename(path.dirname(deltaPath));
|
|
133
|
-
|
|
134
|
-
if (!op) {
|
|
135
|
-
results.push(fail(` ${rel}: Operation header missing or malformed`));
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
if (!["ADDED", "MODIFIED", "REMOVED"].includes(op)) {
|
|
139
|
-
results.push(fail(` ${rel}: Operation "${op}" is not one of ADDED|MODIFIED|REMOVED`));
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
|
|
143
|
-
const targetRel = relPath(projectRoot, targetSpec);
|
|
144
|
-
if (op === "ADDED") {
|
|
145
|
-
// Mirrors `change apply`: an existing target that is still the
|
|
146
|
-
// untouched `spec new` scaffold is the canonical flow (spec new →
|
|
147
|
-
// ADDED delta), so it passes as a replacement; only real content fails.
|
|
148
|
-
if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
|
|
149
|
-
results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`, "pre-apply"));
|
|
150
|
-
} else if (exists(targetSpec)) {
|
|
151
|
-
results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`));
|
|
152
|
-
} else {
|
|
153
|
-
results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`));
|
|
154
|
-
}
|
|
155
|
-
} else if (op === "MODIFIED") {
|
|
156
|
-
if (!exists(targetSpec)) {
|
|
157
|
-
results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`, "pre-apply"));
|
|
158
|
-
} else {
|
|
159
|
-
results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`));
|
|
160
|
-
}
|
|
161
|
-
} else {
|
|
162
|
-
if (!exists(targetSpec)) {
|
|
163
|
-
results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`, "pre-apply"));
|
|
164
|
-
} else {
|
|
165
|
-
results.push(pass(` ${cap} (REMOVED) → ${targetRel}`));
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// BUDGET DISCIPLINE (change 0029). A declared ceiling is a contract, not
|
|
172
|
-
// a preference — and the tempting fix for "the output blew the limit" is
|
|
173
|
-
// to raise the limit. That buys headroom by truncating what mattered
|
|
174
|
-
// instead of sending less, and it is the discussion that comes back every
|
|
175
|
-
// quarter because nothing ever recorded that it had been settled. Raising
|
|
176
|
-
// an INPUT ceiling is a normal trade-off; raising an OUTPUT ceiling to
|
|
177
|
-
// resolve an overflow is the move this refuses.
|
|
178
|
-
for (const r of checkBudgetRaises(projectRoot, changeDir)) results.push(r);
|
|
179
|
-
|
|
180
|
-
return results;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Compare the ceilings a change's proposal/deltas propose against the ones
|
|
184
|
-
// the contracts declare today. Text-scanned: a change states a new value in
|
|
185
|
-
// prose long before any code moves, which is exactly when it should be
|
|
186
|
-
// argued about.
|
|
187
|
-
function checkBudgetRaises(projectRoot, changeDir) {
|
|
188
|
-
const results = [];
|
|
189
|
-
let declared;
|
|
190
|
-
try {
|
|
191
|
-
declared = collectBudgets(projectRoot);
|
|
192
|
-
} catch {
|
|
193
|
-
return results;
|
|
194
|
-
}
|
|
195
|
-
if (declared.size === 0) return results;
|
|
196
|
-
|
|
197
|
-
const texts = [];
|
|
198
|
-
for (const f of walk(changeDir)) {
|
|
199
|
-
if (f.endsWith(".md")) texts.push({ rel: relPath(projectRoot, f), text: read(f) });
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
for (const [name, budget] of declared) {
|
|
203
|
-
if (budget.direction !== "output") continue;
|
|
204
|
-
for (const { rel, text } of texts) {
|
|
205
|
-
// "<limit> ... <number>" on one line: the shape a proposal uses when
|
|
206
|
-
// it restates a ceiling ("raise ai-summary to 2000").
|
|
207
|
-
const re = new RegExp(`${escapeRe(name)}[^\\n]{0,60}?(\\d{2,})`, "gi");
|
|
208
|
-
for (const m of text.matchAll(re)) {
|
|
209
|
-
const proposed = Number.parseInt(m[1], 10);
|
|
210
|
-
if (!Number.isFinite(proposed) || proposed <= budget.value) continue;
|
|
211
|
-
results.push(fail(
|
|
212
|
-
`${rel} raises the OUTPUT ceiling "${name}" from ${budget.value} to ${proposed} ` +
|
|
213
|
-
`(declared in ${budget.contract}) — raising an output budget buys headroom by ` +
|
|
214
|
-
`truncating what mattered. Send less, or supersede the budget deliberately in the contract`,
|
|
215
|
-
));
|
|
216
|
-
break;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return results;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// A limit name comes from a contract table cell, so it is user text and
|
|
224
|
-
// must not be spliced into a pattern unescaped.
|
|
225
|
-
function escapeRe(s) {
|
|
226
|
-
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function pass(msg) {
|
|
230
|
-
return { kind: "pass", line: c.green("✓ ") + msg };
|
|
231
|
-
}
|
|
232
|
-
// `scope` says WHEN a failure is a real answer.
|
|
233
|
-
//
|
|
234
|
-
// Most of these questions are true at every point in a change's life: a
|
|
235
|
-
// proposal with an unwritten `## What` is hollow before an apply and just as
|
|
236
|
-
// hollow after one. A few are only meaningful BEFORE the apply — "an ADDED
|
|
237
|
-
// delta's target must not already hold real content" is proof of a problem
|
|
238
|
-
// beforehand and proof the apply WORKED afterwards.
|
|
239
|
-
//
|
|
240
|
-
// The gate map used to exclude the whole structure gate from `archive` to
|
|
241
|
-
// dodge that handful, which also dropped the hollow-proposal check — the
|
|
242
|
-
// exact defect this repository's own history records as having "shipped six
|
|
243
|
-
// hollow proposals past every gate". Tagging the pre-apply ones lets
|
|
244
|
-
// `archive` keep the rest.
|
|
245
|
-
function fail(msg, scope = "always") {
|
|
246
|
-
return { kind: "fail", scope, line: c.red("✗ ") + msg };
|
|
247
|
-
}
|
|
248
|
-
function info(msg) {
|
|
249
|
-
return { kind: "info", line: c.gray("- ") + msg };
|
|
250
|
-
}
|
|
251
|
-
|
|
252
56
|
export const help = `
|
|
253
57
|
Usage: doctrina analyze <change-id>
|
|
254
58
|
|