doctrina-cli 0.13.0 → 0.15.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 +1 -1
- package/package.json +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +120 -17
- package/src/commands/change.js +100 -42
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +57 -2
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +391 -58
- package/src/commands/contract.js +76 -43
- package/src/commands/coverage.js +126 -12
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +92 -11
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +139 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +38 -3
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +201 -8
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +193 -59
- package/src/commands/trace.js +15 -3
- package/src/commands/triage.js +248 -0
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +148 -5
- package/src/commands/verify.js +126 -5
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +44 -3
- package/src/index.js +61 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +1 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +252 -27
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/pipeline.js +161 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/runtime.js +683 -0
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +94 -9
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/AGENTS.md.template +59 -53
- package/templates/contract.md.template +74 -3
- package/templates/spec.md.template +22 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { exists } from "../lib/fs-ops.js";
|
|
5
|
+
import { flagBool } from "../lib/args.js";
|
|
6
|
+
import { c } from "../lib/colors.js";
|
|
7
|
+
import { emitJson } from "../lib/json-out.js";
|
|
8
|
+
import { collectRuntimeFindings, checkLocalEnv } from "../lib/runtime.js";
|
|
9
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
10
|
+
|
|
11
|
+
// Classify BEFORE scaffolding.
|
|
12
|
+
//
|
|
13
|
+
// `doctrina work` was the default answer to every request, so a broken
|
|
14
|
+
// workflow, an invalid env value, and a suite that ran nothing all became
|
|
15
|
+
// changes with a proposal, tasks and a spec delta — half an hour of
|
|
16
|
+
// artifact for a bug the YAML already explained, and a close that was
|
|
17
|
+
// theatre on top of a diagnosis. Meanwhile the actual cause sat in a file
|
|
18
|
+
// no gate read.
|
|
19
|
+
//
|
|
20
|
+
// Three lanes, because three kinds of request fail differently:
|
|
21
|
+
//
|
|
22
|
+
// PRODUCT behaviour changes -> `doctrina work` earns its ceremony: a
|
|
23
|
+
// spec delta is the point, because the truth is changing.
|
|
24
|
+
// RUNTIME something is wired wrong, empty, or ran nothing. There is no
|
|
25
|
+
// spec delta to write — the requirement already exists and the
|
|
26
|
+
// implementation does not honour it. Diagnose first.
|
|
27
|
+
// CHORE implementation-only work whose requirement is already
|
|
28
|
+
// specified: `doctrina work --chore`, no EARS delta.
|
|
29
|
+
//
|
|
30
|
+
// The classifier is deterministic term matching, and it is a HINT, not a
|
|
31
|
+
// verdict — the same contract `work` already makes about its capability
|
|
32
|
+
// guess. It never refuses; it names the lane and lets the agent decide.
|
|
33
|
+
|
|
34
|
+
export const jsonNative = true;
|
|
35
|
+
|
|
36
|
+
export const flags = { boolean: ["json", "env"], string: [] };
|
|
37
|
+
|
|
38
|
+
// Signals that a request is about the running system rather than about what
|
|
39
|
+
// the system should do. Weighted: a phrase that can only mean runtime counts
|
|
40
|
+
// for more than a word that merely leans that way.
|
|
41
|
+
const RUNTIME_SIGNALS = [
|
|
42
|
+
[3, /\b(workflow|pipeline|ci|github actions|actions run|job|runner)\b/i],
|
|
43
|
+
[3, /\b(env var|environment variable|dotenv|\.env|secret|secrets|vars)\b/i],
|
|
44
|
+
[3, /\b(0 (tests?|scenarios?|cases?)|no (tests?|scenarios?) ran|ran nothing|empty (run|suite|summary))\b/i],
|
|
45
|
+
[2, /\b(exit code|stack ?trace|traceback|logs?|step summary|artifact missing)\b/i],
|
|
46
|
+
[2, /\b(green but|passes but|silently|never (fires|applies|reaches|ran))\b/i],
|
|
47
|
+
[2, /\b(flaky|timeout|timed out|hangs?|crash(es|ed)?|broken build|failing build)\b/i],
|
|
48
|
+
[2, /\b(tag|selector|filter|dispatch)\b/i],
|
|
49
|
+
[1, /\b(config|configuration|wiring|wired|deploy(ment)?|docker|compose)\b/i],
|
|
50
|
+
[1, /\b(debug|diagnos(e|is)|investigate|why (is|does|did)|not working|doesn't work)\b/i],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const CHORE_SIGNALS = [
|
|
54
|
+
[3, /\b(bump|upgrade|pin|lockfile|dependency|dependencies|devdependenc)\b/i],
|
|
55
|
+
[2, /\b(rename|move|reorganis|reorganiz|refactor|tidy|cleanup|clean up|format(ting)?|lint)\b/i],
|
|
56
|
+
[2, /\b(typo|comment|readme|changelog|docs? only)\b/i],
|
|
57
|
+
[2, /\b(already (specified|in the spec)|wiring only|no behaviour change|no behavior change)\b/i],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const PRODUCT_SIGNALS = [
|
|
61
|
+
[3, /\b(add|introduce|support|allow|enable|implement|build)\b/i],
|
|
62
|
+
[3, /\b(should|shall|must|requirement|acceptance|criteri)\b/i],
|
|
63
|
+
[2, /\b(new (feature|capability|command|endpoint|screen|flow))\b/i],
|
|
64
|
+
[2, /\b(users? (can|should|want)|so that|in order to)\b/i],
|
|
65
|
+
[1, /\b(change|behaviour|behavior|spec)\b/i],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Score a prompt against each lane. Deterministic, explainable, and cheap:
|
|
70
|
+
* the caller gets the winning lane AND the signals that produced it, so a
|
|
71
|
+
* wrong guess is arguable rather than mysterious.
|
|
72
|
+
*/
|
|
73
|
+
export function classify(prompt) {
|
|
74
|
+
const text = String(prompt ?? "");
|
|
75
|
+
const score = (signals) => {
|
|
76
|
+
let total = 0;
|
|
77
|
+
const hits = [];
|
|
78
|
+
for (const [weight, re] of signals) {
|
|
79
|
+
const m = text.match(re);
|
|
80
|
+
if (m) {
|
|
81
|
+
total += weight;
|
|
82
|
+
hits.push(m[0].toLowerCase());
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { total, hits };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const lanes = {
|
|
89
|
+
runtime: score(RUNTIME_SIGNALS),
|
|
90
|
+
chore: score(CHORE_SIGNALS),
|
|
91
|
+
product: score(PRODUCT_SIGNALS),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Product is the default: when nothing distinguishes the request, the
|
|
95
|
+
// ceremony that writes a spec delta is the safe one to fall into. Runtime
|
|
96
|
+
// must BEAT product to win, so an ordinary feature request mentioning a
|
|
97
|
+
// "job" does not get diverted into a diagnosis.
|
|
98
|
+
let lane = "product";
|
|
99
|
+
let best = lanes.product.total;
|
|
100
|
+
for (const name of ["runtime", "chore"]) {
|
|
101
|
+
if (lanes[name].total > best) {
|
|
102
|
+
lane = name;
|
|
103
|
+
best = lanes[name].total;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (best === 0) lane = "product";
|
|
107
|
+
|
|
108
|
+
// Confidence is the MARGIN over the runner-up, not the winner's own score:
|
|
109
|
+
// a prompt that scores 5 for runtime and 4 for product is a coin toss the
|
|
110
|
+
// agent should see as one, and a clear product request must be able to read
|
|
111
|
+
// as confident too (comparing a lane against itself never could).
|
|
112
|
+
const runnerUp = Object.entries(lanes)
|
|
113
|
+
.filter(([name]) => name !== lane)
|
|
114
|
+
.reduce((max, [, v]) => Math.max(max, v.total), 0);
|
|
115
|
+
|
|
116
|
+
return { lane, scores: lanes, confident: best > 0 && best - runnerUp >= 2 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function run(positional, cmdFlags) {
|
|
120
|
+
const projectRoot = process.cwd();
|
|
121
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
122
|
+
throw notADoctrinaProject();
|
|
123
|
+
}
|
|
124
|
+
const json = flagBool(cmdFlags, "json", false);
|
|
125
|
+
const withEnv = flagBool(cmdFlags, "env", false);
|
|
126
|
+
const prompt = positional.join(" ").trim();
|
|
127
|
+
|
|
128
|
+
const runtime = collectRuntimeFindings(projectRoot);
|
|
129
|
+
const envFindings = withEnv ? checkLocalEnv(projectRoot) : [];
|
|
130
|
+
const findings = [...runtime.findings, ...envFindings];
|
|
131
|
+
const errors = findings.filter((f) => f.level === "error");
|
|
132
|
+
const verdict = prompt ? classify(prompt) : null;
|
|
133
|
+
|
|
134
|
+
if (json) {
|
|
135
|
+
emitJson("triage", {
|
|
136
|
+
lane: verdict?.lane ?? null,
|
|
137
|
+
confident: verdict?.confident ?? null,
|
|
138
|
+
contracts: runtime.contracts,
|
|
139
|
+
declared: runtime.declared,
|
|
140
|
+
findings,
|
|
141
|
+
errors: errors.length,
|
|
142
|
+
});
|
|
143
|
+
return errors.length > 0 ? 1 : 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (verdict) renderLane(prompt, verdict);
|
|
147
|
+
renderRuntime(runtime, envFindings, withEnv, Boolean(verdict));
|
|
148
|
+
|
|
149
|
+
return errors.length > 0 ? 1 : 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderLane(prompt, verdict) {
|
|
153
|
+
const LABEL = {
|
|
154
|
+
product: c.green("PRODUCT"),
|
|
155
|
+
runtime: c.yellow("RUNTIME"),
|
|
156
|
+
chore: c.cyan("CHORE"),
|
|
157
|
+
};
|
|
158
|
+
console.log(c.bold("Lane") + c.gray(" — deterministic term match; a hint, not a decision"));
|
|
159
|
+
console.log("");
|
|
160
|
+
console.log(` ${LABEL[verdict.lane]}${verdict.confident ? "" : c.gray(" (weak signal — read the playbook and override if it is wrong)")}`);
|
|
161
|
+
const hits = verdict.scores[verdict.lane].hits;
|
|
162
|
+
if (hits.length > 0) console.log(c.gray(` signals: ${[...new Set(hits)].slice(0, 6).join(", ")}`));
|
|
163
|
+
console.log("");
|
|
164
|
+
|
|
165
|
+
if (verdict.lane === "product") {
|
|
166
|
+
console.log("This changes what the system should DO — the ceremony is the point:");
|
|
167
|
+
console.log(` ${c.cyan(`doctrina work "${truncate(prompt)}"`)}`);
|
|
168
|
+
console.log(c.gray(" spec delta -> tasks -> implement -> close"));
|
|
169
|
+
} else if (verdict.lane === "chore") {
|
|
170
|
+
console.log("The requirement already exists; only the implementation moves:");
|
|
171
|
+
console.log(` ${c.cyan(`doctrina work --chore "${truncate(prompt)}"`)}`);
|
|
172
|
+
console.log(c.gray(" short proposal + patch + verify, no EARS delta"));
|
|
173
|
+
console.log(c.gray(" needing a spec delta means it was never a chore — reopen without --chore"));
|
|
174
|
+
} else {
|
|
175
|
+
console.log("Nothing here says the SPEC is wrong — the running system is.");
|
|
176
|
+
console.log("Diagnose before scaffolding anything:");
|
|
177
|
+
console.log(` ${c.cyan("doctrina contract check")} the declared wiring vs the workflow`);
|
|
178
|
+
console.log(` ${c.cyan("doctrina triage --env")} local .env vs the declared enums (names only)`);
|
|
179
|
+
console.log(` ${c.cyan("doctrina verify")} the real build gate, with its output expectations`);
|
|
180
|
+
console.log(c.gray(" Open a change only once the diagnosis shows the requirement itself is missing."));
|
|
181
|
+
}
|
|
182
|
+
console.log("");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function renderRuntime(runtime, envFindings, withEnv, hadLane) {
|
|
186
|
+
if (hadLane) console.log(c.bold("Runtime") + c.gray(" — the declared surface, checked"));
|
|
187
|
+
else console.log(c.bold("Runtime triage") + c.gray(" — the declared surface, checked"));
|
|
188
|
+
console.log("");
|
|
189
|
+
|
|
190
|
+
if (runtime.contracts === 0) {
|
|
191
|
+
console.log(c.gray(" no contracts in .doctrina/contracts/ — nothing declares the runtime surface"));
|
|
192
|
+
console.log(c.gray(" start one: ") + c.cyan("doctrina contract new system"));
|
|
193
|
+
console.log("");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (runtime.declared === 0) {
|
|
197
|
+
console.log(c.gray(` ${runtime.contracts} contract${runtime.contracts === 1 ? "" : "s"}, 0 Wiring/Selectors rows — the runtime surface is UNCHECKED, not verified`));
|
|
198
|
+
console.log(c.gray(" declare what CI injects and what your selectors are, and this becomes a gate"));
|
|
199
|
+
console.log("");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const all = [...runtime.findings, ...envFindings];
|
|
204
|
+
if (all.length === 0) {
|
|
205
|
+
console.log(c.green(" ok") + ` ${runtime.declared} declared row${runtime.declared === 1 ? "" : "s"} across ${runtime.contracts} contract${runtime.contracts === 1 ? "" : "s"} hold` + (withEnv ? " (including local .env)" : ""));
|
|
206
|
+
console.log("");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const f of all) {
|
|
211
|
+
const mark = f.level === "error" ? c.red(" ✗") : c.yellow(" !");
|
|
212
|
+
console.log(`${mark} ${f.message} ${c.gray(`[${f.code}]`)}`);
|
|
213
|
+
console.log(` ${c.gray(`fix: ${f.remedy}`)}`);
|
|
214
|
+
}
|
|
215
|
+
console.log("");
|
|
216
|
+
const errors = all.filter((f) => f.level === "error").length;
|
|
217
|
+
const warns = all.length - errors;
|
|
218
|
+
if (errors > 0) console.log(c.red("fail") + ` ${errors} runtime error${errors === 1 ? "" : "s"}, ${warns} warning${warns === 1 ? "" : "s"} — fix the wiring, not the spec`);
|
|
219
|
+
else console.log(c.yellow("warn") + ` ${warns} advisory finding${warns === 1 ? "" : "s"}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function truncate(s, max = 60) {
|
|
223
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const help = `
|
|
227
|
+
Usage: doctrina triage ["<prompt>"] [--env]
|
|
228
|
+
|
|
229
|
+
Classify a request BEFORE scaffolding, and diagnose the running system.
|
|
230
|
+
|
|
231
|
+
With a prompt, name the lane the request belongs to:
|
|
232
|
+
PRODUCT behaviour changes -> doctrina work (the spec delta is the point)
|
|
233
|
+
RUNTIME wired wrong, empty, or ran nothing -> diagnose; no delta to write
|
|
234
|
+
CHORE implementation-only, already specified -> doctrina work --chore
|
|
235
|
+
|
|
236
|
+
The classifier is deterministic term matching and prints the signals it
|
|
237
|
+
matched: a HINT, like the capability guess in \`work\`. It never refuses.
|
|
238
|
+
|
|
239
|
+
With or without a prompt, it also runs the runtime checks over every
|
|
240
|
+
contract (the same checks as \`contract check\`, and the same verdict):
|
|
241
|
+
declared wiring vs the workflow, empty-vs-unset consumer defaults,
|
|
242
|
+
declared enums, and selectors that would match nothing.
|
|
243
|
+
|
|
244
|
+
--env also check the local .env against the declared enums. Reports
|
|
245
|
+
NAMES and membership only — a rejected value is never printed.
|
|
246
|
+
|
|
247
|
+
Exit 0 when no runtime errors (warnings allowed), 1 when any error stands.
|
|
248
|
+
`;
|
package/src/commands/upgrade.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { exists } from "../lib/fs-ops.js";
|
|
@@ -7,6 +8,7 @@ import { cliVersion } from "../lib/version.js";
|
|
|
7
8
|
import { flagBool } from "../lib/args.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
9
10
|
import { today } from "../lib/dates.js";
|
|
11
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
10
12
|
import * as templates from "./templates.js";
|
|
11
13
|
import * as validate from "./validate.js";
|
|
12
14
|
|
|
@@ -20,10 +22,15 @@ import * as validate from "./validate.js";
|
|
|
20
22
|
// step. Preview by default; --write applies. Never rewrites user content
|
|
21
23
|
// (the same additive-only bound as `templates update`).
|
|
22
24
|
|
|
25
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
26
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
27
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
28
|
+
export const flags = { boolean: ["json", "write"], string: [] };
|
|
29
|
+
|
|
23
30
|
export async function run(_positional, flags) {
|
|
24
31
|
const projectRoot = process.cwd();
|
|
25
32
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
26
|
-
throw
|
|
33
|
+
throw notADoctrinaProject();
|
|
27
34
|
}
|
|
28
35
|
const writeMode = flagBool(flags, "write", false);
|
|
29
36
|
const running = cliVersion();
|
package/src/commands/validate.js
CHANGED
|
@@ -1,26 +1,45 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
4
|
-
import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
|
+
import { exists, isDir, isFile, lineCount, read, relPath, walk , write} from "../lib/fs-ops.js";
|
|
5
6
|
import * as idx from "../lib/index-json.js";
|
|
6
7
|
import { SCHEMA_VERSION } from "../lib/index-json.js";
|
|
7
8
|
import { cliVersion } from "../lib/version.js";
|
|
8
9
|
import { today } from "../lib/dates.js";
|
|
9
10
|
import { flagBool } from "../lib/args.js";
|
|
10
11
|
import { c } from "../lib/colors.js";
|
|
12
|
+
import { kindFromPath, nonConformingHeaders, repairHeaders } from "../lib/doc-model.js";
|
|
13
|
+
import { emitJson } from "../lib/json-out.js";
|
|
14
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
15
|
import { parseFrontmatter } from "./skill.js";
|
|
12
16
|
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
13
17
|
import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
14
18
|
import { COMMAND_NAMES, referencedCommands } from "../lib/commands.js";
|
|
15
19
|
import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
20
|
+
import { parsePipeline, checkPipeline } from "../lib/pipeline.js";
|
|
21
|
+
import { collectRuntimeFindings } from "../lib/runtime.js";
|
|
16
22
|
|
|
17
23
|
// AGENTS.md is treated as a maintained doctrina-command catalog only once it
|
|
18
24
|
// documents at least this many real commands; below it, the file defers to
|
|
19
25
|
// `doctrina --help` and is never nagged about omissions.
|
|
20
26
|
const CATALOG_THRESHOLD = 8;
|
|
21
27
|
|
|
28
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
29
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
30
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
31
|
+
// This command builds its own JSON payload; the entrypoint must not
|
|
32
|
+
// wrap it in the generic envelope.
|
|
33
|
+
export const jsonNative = true;
|
|
34
|
+
|
|
35
|
+
export const flags = { boolean: ["fix", "json", "runtime"], string: [] };
|
|
36
|
+
|
|
22
37
|
export async function run(_positional, flags) {
|
|
23
38
|
const projectRoot = process.cwd();
|
|
39
|
+
// Outside a Doctrina project this is a PRECONDITION, not a pile of
|
|
40
|
+
// gate failures about missing files: the remedy is `doctrina init`,
|
|
41
|
+
// not editing artifacts that do not exist yet (C7).
|
|
42
|
+
if (!isDir(path.join(projectRoot, ".doctrina"))) throw notADoctrinaProject();
|
|
24
43
|
const errors = [];
|
|
25
44
|
const warnings = [];
|
|
26
45
|
|
|
@@ -87,6 +106,45 @@ export async function run(_positional, flags) {
|
|
|
87
106
|
const productMd = path.join(projectRoot, ".doctrina", "product.md");
|
|
88
107
|
if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
|
|
89
108
|
|
|
109
|
+
// Header repair (M3). One grammar means a non-canonical header can be
|
|
110
|
+
// REWRITTEN, not just reported: `- **Status**: x` becomes
|
|
111
|
+
// `- **Status:** x`, and a spec written with list-style headers is
|
|
112
|
+
// normalised to the bare-bold form its kind uses. Content is never
|
|
113
|
+
// touched — only the header's own punctuation and style.
|
|
114
|
+
const doFix = flagBool(flags, "fix", false);
|
|
115
|
+
{
|
|
116
|
+
const artifacts = [];
|
|
117
|
+
const dot = path.join(projectRoot, ".doctrina");
|
|
118
|
+
for (const f of walk(dot)) {
|
|
119
|
+
if (!f.endsWith(".md")) continue;
|
|
120
|
+
const rel = relPath(projectRoot, f).replace(/\\/g, "/");
|
|
121
|
+
if (rel.includes("/changes/archive/")) continue; // history is immutable
|
|
122
|
+
artifacts.push({ file: f, rel, kind: kindFromPath(rel) });
|
|
123
|
+
}
|
|
124
|
+
let repairedFiles = 0;
|
|
125
|
+
for (const a of artifacts) {
|
|
126
|
+
const text = read(a.file);
|
|
127
|
+
const bad = nonConformingHeaders(text, a.kind);
|
|
128
|
+
if (bad.length === 0) continue;
|
|
129
|
+
if (doFix) {
|
|
130
|
+
const { text: fixed, repaired } = repairHeaders(text, a.kind);
|
|
131
|
+
if (repaired > 0 && fixed !== text) {
|
|
132
|
+
write(a.file, fixed, { force: true });
|
|
133
|
+
repairedFiles += 1;
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
for (const h of bad) {
|
|
137
|
+
warnings.push(
|
|
138
|
+
`${a.rel}:${h.line} header "${h.name}" is ${h.wrongStyle ? `in ${h.style} style but a ${a.kind} uses ${h.expectedStyle} style` : "not in canonical form"} — run \`doctrina validate --fix\``,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (doFix && repairedFiles > 0) {
|
|
144
|
+
console.log(c.green("fixed") + ` normalised headers in ${repairedFiles} artifact${repairedFiles === 1 ? "" : "s"}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
90
148
|
// 3. index.json
|
|
91
149
|
let index = null;
|
|
92
150
|
try {
|
|
@@ -323,6 +381,17 @@ export async function run(_positional, flags) {
|
|
|
323
381
|
}
|
|
324
382
|
}
|
|
325
383
|
|
|
384
|
+
// 8b-ii. ORDERED requirements (change 0029). EARS states each
|
|
385
|
+
// event-driven requirement independently and says nothing about
|
|
386
|
+
// order, so a consumer step and its producer both pass while the
|
|
387
|
+
// consumer reads last run's file. A `### Pipeline` block declares
|
|
388
|
+
// the order and the artifact each step hands on; the invariant is
|
|
389
|
+
// that a step may only require what an EARLIER step produced.
|
|
390
|
+
// Opt-in: a spec without the block is never checked.
|
|
391
|
+
for (const f of checkPipeline(parsePipeline(text))) {
|
|
392
|
+
errors.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message} [${f.code}] — ${f.remedy}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
326
395
|
// 8c. Capability-state honesty (two-axis status). An "active"
|
|
327
396
|
// document that records no built capability is an inventory
|
|
328
397
|
// claim — the gap the framework is meant to make visible.
|
|
@@ -525,6 +594,22 @@ export async function run(_positional, flags) {
|
|
|
525
594
|
if (nameField && nameField !== baseName) {
|
|
526
595
|
warnings.push(`${rel} name "${nameField}" does not match filename slug "${baseName}"`);
|
|
527
596
|
}
|
|
597
|
+
|
|
598
|
+
// 14b. A skill nothing can TRIGGER is a file, not a memory (change
|
|
599
|
+
// 0029). `context` lists a skill's `when:` so an agent can fire
|
|
600
|
+
// the right one without loading any of them — but a trigger
|
|
601
|
+
// written as pure prose ("when it makes sense", "as needed")
|
|
602
|
+
// gives it nothing to match on, and the skill is never loaded by
|
|
603
|
+
// anyone who did not already know it existed. A usable trigger
|
|
604
|
+
// names something concrete: a keyword, a path, a command, an
|
|
605
|
+
// error string.
|
|
606
|
+
if (whenField && !hasDetectableTrigger(whenField)) {
|
|
607
|
+
warnings.push(
|
|
608
|
+
`${rel} frontmatter "when" has no detectable trigger — name a concrete ` +
|
|
609
|
+
`keyword, path, command or error string, or nothing can match it ` +
|
|
610
|
+
`(got: "${whenField.length > 60 ? `${whenField.slice(0, 59)}…` : whenField}")`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
528
613
|
const lines = lineCount(f);
|
|
529
614
|
if (lines > 200) warnings.push(`${rel} is ${lines} lines (>200 soft cap)`);
|
|
530
615
|
else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
|
|
@@ -572,9 +657,25 @@ export async function run(_positional, flags) {
|
|
|
572
657
|
// survives sessions because it is an artifact, not a memory.
|
|
573
658
|
for (const line of checkProjectRules(projectRoot)) errors.push(line);
|
|
574
659
|
|
|
660
|
+
// 18. --runtime folds the RUNTIME gate into the structural one, so a
|
|
661
|
+
// single call covers both halves of the truth: the shape of the
|
|
662
|
+
// artifacts, and whether what they declare about the running system
|
|
663
|
+
// still holds. Opt-in because it reads files outside .doctrina/
|
|
664
|
+
// (workflows, consumers, test sources) and a structural validate
|
|
665
|
+
// should stay cheap. Delegates to lib/runtime.js — the same checks
|
|
666
|
+
// `contract check`, `triage` and `doctor` render, so the four can
|
|
667
|
+
// never disagree.
|
|
668
|
+
if (flagBool(flags, "runtime", false)) {
|
|
669
|
+
for (const f of collectRuntimeFindings(projectRoot).findings) {
|
|
670
|
+
const line = `${f.contract}: ${f.message} [${f.code}] — ${f.remedy}`;
|
|
671
|
+
if (f.level === "error") errors.push(line);
|
|
672
|
+
else warnings.push(line);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
575
676
|
// Output
|
|
576
677
|
if (flagBool(flags, "json", false)) {
|
|
577
|
-
|
|
678
|
+
emitJson("validate", { ok: errors.length === 0, errors, warnings });
|
|
578
679
|
return errors.length === 0 ? 0 : 1;
|
|
579
680
|
}
|
|
580
681
|
for (const w of warnings) console.log(c.yellow("warn: ") + w);
|
|
@@ -590,6 +691,28 @@ export async function run(_positional, flags) {
|
|
|
590
691
|
return errors.length === 0 ? 0 : 1;
|
|
591
692
|
}
|
|
592
693
|
|
|
694
|
+
// Does a skill's `when:` give anything to MATCH on? A trigger written as
|
|
695
|
+
// pure prose ("when it seems relevant", "as needed") reads fine and can
|
|
696
|
+
// never fire: `context` ranks skills by comparing the trigger against the
|
|
697
|
+
// task, and there is nothing there to compare. A usable trigger names
|
|
698
|
+
// something concrete — a path or glob, a command, a quoted error string, a
|
|
699
|
+
// file extension, an ALL_CAPS identifier, or simply enough distinctive
|
|
700
|
+
// keywords to match on.
|
|
701
|
+
const VAGUE_TRIGGER = /^(?:when(?:ever)?\s+)?(?:it|this|you|the agent)?\s*(?:is\s+)?(?:seems?|feels?|looks?)?\s*(?:relevant|appropriate|needed|necessary|useful|applicable|as needed|if needed)\.?$/i;
|
|
702
|
+
|
|
703
|
+
export function hasDetectableTrigger(when) {
|
|
704
|
+
const text = String(when ?? "").trim();
|
|
705
|
+
if (text === "" || VAGUE_TRIGGER.test(text)) return false;
|
|
706
|
+
// Anything structural is inherently matchable.
|
|
707
|
+
if (/[\/\]|\*|`|"|'|\.\w{2,4}|[A-Z][A-Z0-9_]{2,}/.test(text)) return true;
|
|
708
|
+
// Otherwise: enough distinctive words to rank on. Stopwords do not count.
|
|
709
|
+
const STOP = new Set(["when", "whenever", "the", "a", "an", "is", "are", "you", "your",
|
|
710
|
+
"it", "its", "this", "that", "and", "or", "to", "of", "in", "on", "for", "with",
|
|
711
|
+
"any", "some", "need", "needs", "needed", "should", "must", "at", "as", "by", "be"]);
|
|
712
|
+
const words = text.toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) ?? [];
|
|
713
|
+
return words.filter((w) => !STOP.has(w)).length >= 2;
|
|
714
|
+
}
|
|
715
|
+
|
|
593
716
|
// Enforce .doctrina/rules.json — permanent project constraints as
|
|
594
717
|
// forbid-regexes over glob-scoped paths:
|
|
595
718
|
// { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
|
|
@@ -790,13 +913,33 @@ function isLikelyPath(s) {
|
|
|
790
913
|
}
|
|
791
914
|
|
|
792
915
|
export const help = `
|
|
793
|
-
Usage: doctrina validate [--fix] [--json]
|
|
916
|
+
Usage: doctrina validate [--fix] [--runtime] [--json]
|
|
794
917
|
|
|
795
918
|
Run schema, artifact-existence, and structural checks against the
|
|
796
919
|
.doctrina/ tree in the current working directory. Exits 0 if no errors
|
|
797
920
|
(warnings allowed), 1 otherwise.
|
|
798
921
|
|
|
922
|
+
Two structural checks worth naming:
|
|
923
|
+
|
|
924
|
+
Pipeline a spec's optional "### Pipeline" block declares ordered
|
|
925
|
+
steps and the artifact each hands on:
|
|
926
|
+
1. run-suite — produces \`results.json\`
|
|
927
|
+
2. analyse — requires \`results.json\`, produces \`analysis.md\`
|
|
928
|
+
A step may only require what an EARLIER step produced.
|
|
929
|
+
EARS states each event-driven requirement independently, so
|
|
930
|
+
without this a consumer and its producer both pass while the
|
|
931
|
+
consumer reads the previous run's file. Mark inputs from
|
|
932
|
+
outside the pipeline \`(external)\`.
|
|
933
|
+
|
|
934
|
+
Triggers a skill whose frontmatter "when:" names nothing concrete
|
|
935
|
+
(no keyword, path, command or error string) can never be
|
|
936
|
+
matched, so nothing ever loads it.
|
|
937
|
+
|
|
799
938
|
Flags:
|
|
800
|
-
--fix
|
|
801
|
-
--
|
|
939
|
+
--fix Rebuild index.json from the tree before checking (heals drift).
|
|
940
|
+
--runtime Also run the RUNTIME gate: the declared wiring, enums and
|
|
941
|
+
selectors checked against the workflows and code that are
|
|
942
|
+
supposed to honour them (the same checks as \`contract check\`).
|
|
943
|
+
Opt-in because it reads files outside .doctrina/.
|
|
944
|
+
--json Emit { ok, errors, warnings } as JSON (stable shape for agents/CI).
|
|
802
945
|
`;
|