doctrina-cli 0.14.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 +1 -1
- package/src/commands/analyze.js +56 -0
- package/src/commands/context.js +32 -5
- package/src/commands/contract.js +59 -19
- package/src/commands/coverage.js +110 -8
- package/src/commands/doctor.js +55 -5
- package/src/commands/next.js +24 -1
- package/src/commands/skill.js +186 -3
- package/src/commands/triage.js +248 -0
- package/src/commands/validate.js +91 -4
- package/src/commands/verify.js +114 -3
- package/src/commands/work.js +37 -3
- package/src/index.js +2 -1
- package/src/lib/commands.js +16 -4
- package/src/lib/pipeline.js +161 -0
- package/src/lib/runtime.js +683 -0
- package/templates/AGENTS.md.template +3 -3
- package/templates/contract.md.template +74 -3
- package/templates/spec.md.template +22 -0
package/src/commands/skill.js
CHANGED
|
@@ -7,7 +7,7 @@ import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../li
|
|
|
7
7
|
import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
8
8
|
import * as idx from "../lib/index-json.js";
|
|
9
9
|
import { today } from "../lib/dates.js";
|
|
10
|
-
import { flagBool, flagString } from "../lib/args.js";
|
|
10
|
+
import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
11
11
|
import { c } from "../lib/colors.js";
|
|
12
12
|
import { suggest } from "../lib/suggest.js";
|
|
13
13
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
@@ -17,7 +17,7 @@ const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
|
|
|
17
17
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
18
18
|
// adding a command never requires editing the entrypoint — the gap that
|
|
19
19
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
20
|
-
export const flags = { boolean: ["json", "force", "write"], string: ["since"] };
|
|
20
|
+
export const flags = { boolean: ["json", "force", "write"], string: ["since", "from-error"] };
|
|
21
21
|
|
|
22
22
|
export async function run(positional, flags) {
|
|
23
23
|
const sub = positional[0];
|
|
@@ -61,12 +61,187 @@ const FIX_SHAPED_SUBJECT =
|
|
|
61
61
|
const GIT_LOG_LIMIT = 200;
|
|
62
62
|
const MAX_SHOWN = 20;
|
|
63
63
|
|
|
64
|
+
// A skill drafted from the ERROR that taught the lesson (change 0029).
|
|
65
|
+
//
|
|
66
|
+
// `suggest` used to fire only after the fact — a skill was born when the
|
|
67
|
+
// agent remembered to write one, which is to say after the incident had
|
|
68
|
+
// already cost a session. The moment the lesson actually exists is the
|
|
69
|
+
// moment the error is on screen, and that text carries exactly what a
|
|
70
|
+
// trigger needs: the paths, the identifiers, the message itself.
|
|
71
|
+
//
|
|
72
|
+
// The draft is a SEED, not a skill. It fills the trigger — the field a
|
|
73
|
+
// human is least likely to write in a matchable form — and leaves the
|
|
74
|
+
// procedure to the person who just solved it.
|
|
75
|
+
const ERROR_STOP = new Set([
|
|
76
|
+
"the", "and", "for", "with", "that", "this", "from", "have", "has", "was",
|
|
77
|
+
"were", "not", "but", "you", "your", "are", "its", "it's", "can", "could",
|
|
78
|
+
"would", "should", "will", "when", "then", "than", "there", "their", "been",
|
|
79
|
+
"error", "warning", "failed", "failure", "exception", "traceback", "at",
|
|
80
|
+
"line", "file", "in", "on", "of", "to", "is", "a", "an", "no", "if",
|
|
81
|
+
// Stack-trace furniture and modal verbs: present in almost every error,
|
|
82
|
+
// distinctive in none, and they crowd out the words that identify one.
|
|
83
|
+
"most", "recent", "call", "calls", "last", "raise", "raised", "throw",
|
|
84
|
+
"thrown", "must", "one", "none", "self", "return", "returns", "def",
|
|
85
|
+
"module", "stack", "trace", "during", "handling", "above", "occurred",
|
|
86
|
+
"caused", "unexpected", "invalid", "expected", "got", "value", "values",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
export function draftFromError(text) {
|
|
90
|
+
const raw = String(text ?? "").trim();
|
|
91
|
+
if (raw === "") return null;
|
|
92
|
+
|
|
93
|
+
// Paths and file references: the most matchable thing an error carries.
|
|
94
|
+
const paths = [...new Set(
|
|
95
|
+
(raw.match(/[\w.@-]+(?:[/\\][\w.@-]+)+(?:\.\w{1,6})?|[\w-]+\.(?:py|js|mjs|cjs|ts|tsx|yml|yaml|json|feature|go|rb|java|rs|sh|toml|ini|env)\b/g) ?? [])
|
|
96
|
+
.map((p) => p.replace(/[.,;:)\]]+$/, "")),
|
|
97
|
+
)].slice(0, 4);
|
|
98
|
+
|
|
99
|
+
// ALL_CAPS identifiers: env vars, error codes, constants.
|
|
100
|
+
const identifiers = [...new Set(raw.match(/\b[A-Z][A-Z0-9_]{2,}\b/g) ?? [])].slice(0, 4);
|
|
101
|
+
|
|
102
|
+
// Quoted fragments are usually the operative part of the message — but a
|
|
103
|
+
// traceback quotes its own filenames, and repeating a path already
|
|
104
|
+
// captured above buys nothing.
|
|
105
|
+
const quoted = [...new Set(
|
|
106
|
+
[...raw.matchAll(/["'`]([^"'`\n]{4,60})["'`]/g)].map((m) => m[1].trim()),
|
|
107
|
+
)].filter((q) => !paths.includes(q)).slice(0, 2);
|
|
108
|
+
|
|
109
|
+
// Distinctive words, most frequent first — the fallback when an error
|
|
110
|
+
// carries no structure at all.
|
|
111
|
+
const counts = new Map();
|
|
112
|
+
for (const w of raw.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) ?? []) {
|
|
113
|
+
if (ERROR_STOP.has(w)) continue;
|
|
114
|
+
counts.set(w, (counts.get(w) ?? 0) + 1);
|
|
115
|
+
}
|
|
116
|
+
const keywords = [...counts.entries()]
|
|
117
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
118
|
+
.map(([w]) => w)
|
|
119
|
+
.slice(0, 6);
|
|
120
|
+
|
|
121
|
+
// The trigger, built to be MATCHED: concrete tokens first, prose second.
|
|
122
|
+
const anchors = [...quoted, ...identifiers, ...keywords.slice(0, 3)].slice(0, 4);
|
|
123
|
+
const wherePart = paths.length > 0 ? `, or work touches ${paths.slice(0, 2).map((p) => `\`${p}\``).join(" / ")}` : "";
|
|
124
|
+
const when = anchors.length > 0
|
|
125
|
+
? `The task hits ${anchors.map((a) => `"${a}"`).join(" / ")}${wherePart}.`
|
|
126
|
+
: `The task reproduces: ${firstLine(raw).slice(0, 90)}.`;
|
|
127
|
+
|
|
128
|
+
// The slug seeds from identifiers first, then keywords — deduped on the
|
|
129
|
+
// NORMALISED token, so AXE_SEVERITY and the "axe_severity" the same error
|
|
130
|
+
// also mentions do not both land in the name.
|
|
131
|
+
const seedTokens = [];
|
|
132
|
+
const seenToken = new Set();
|
|
133
|
+
for (const token of [...identifiers.map((i) => i.toLowerCase()), ...keywords]) {
|
|
134
|
+
const norm = token.replace(/[^a-z0-9]/g, "");
|
|
135
|
+
if (norm === "" || seenToken.has(norm)) continue;
|
|
136
|
+
seenToken.add(norm);
|
|
137
|
+
seedTokens.push(token);
|
|
138
|
+
if (seedTokens.length === 4) break;
|
|
139
|
+
}
|
|
140
|
+
const slug = skillSlug(seedTokens.join("-") || firstLine(raw)) || "captured-error";
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
slug,
|
|
144
|
+
when,
|
|
145
|
+
keywords,
|
|
146
|
+
paths,
|
|
147
|
+
identifiers,
|
|
148
|
+
excerpt: raw.split(/\r?\n/).slice(0, 12).join("\n"),
|
|
149
|
+
source: "error",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function firstLine(text) {
|
|
154
|
+
return String(text).split(/\r?\n/).find((l) => l.trim() !== "")?.trim() ?? "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Preview or scaffold one error-seeded draft.
|
|
158
|
+
function writeErrorDraft(projectRoot, draft, doWrite) {
|
|
159
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
160
|
+
const target = path.join(skillsDir, `${draft.slug}.md`);
|
|
161
|
+
|
|
162
|
+
if (!doWrite) {
|
|
163
|
+
console.log(c.bold("Skill draft") + c.gray(" — seeded from the error you just hit:"));
|
|
164
|
+
console.log("");
|
|
165
|
+
console.log(` ${c.cyan("slug")} ${draft.slug}`);
|
|
166
|
+
console.log(` ${c.cyan("when")} ${draft.when}`);
|
|
167
|
+
if (draft.paths.length > 0) console.log(` ${c.cyan("paths")} ${draft.paths.join(", ")}`);
|
|
168
|
+
console.log("");
|
|
169
|
+
if (exists(target)) {
|
|
170
|
+
console.log(c.yellow("note: ") + `${relPath(projectRoot, target)} already exists — writing would skip it`);
|
|
171
|
+
}
|
|
172
|
+
console.log(c.gray("Scaffold it: ") + c.cyan("doctrina skill suggest --from-error <text|file> --write"));
|
|
173
|
+
console.log(c.gray("The trigger is drafted; the procedure is yours to write while you still remember it."));
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (exists(target)) {
|
|
178
|
+
console.log(c.yellow("skip ") + ` ${draft.slug} (already exists)`);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const tpl = read(path.join(locateTemplatesDir(), "skill.md.template"));
|
|
183
|
+
let body = substitute(tpl, { SKILL_NAME: draft.slug });
|
|
184
|
+
// Fill the trigger — the field a human is least likely to write in a form
|
|
185
|
+
// anything can match, and the one `context` ranks on.
|
|
186
|
+
body = body.replace(
|
|
187
|
+
/^when: .*$/m,
|
|
188
|
+
`when: ${draft.when.replace(/\r?\n/g, " ")}`,
|
|
189
|
+
);
|
|
190
|
+
body += `\n<!-- Seeded from an error. The failure to capture:\n\n${draft.excerpt}\n-->\n`;
|
|
191
|
+
|
|
192
|
+
mkdirp(skillsDir);
|
|
193
|
+
write(target, body, { force: false });
|
|
194
|
+
|
|
195
|
+
const index = idx.load(projectRoot);
|
|
196
|
+
const date = today();
|
|
197
|
+
idx.addSkill(index, {
|
|
198
|
+
id: draft.slug,
|
|
199
|
+
path: `.doctrina/skills/${draft.slug}.md`,
|
|
200
|
+
description: parseFrontmatter(body, "description") ?? "<edit me — one-sentence summary>",
|
|
201
|
+
last_updated: date,
|
|
202
|
+
});
|
|
203
|
+
idx.touch(index, date);
|
|
204
|
+
idx.save(projectRoot, index);
|
|
205
|
+
|
|
206
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, target)}`);
|
|
207
|
+
console.log(c.gray("The trigger is drafted from the error. Write the procedure while you still remember it,"));
|
|
208
|
+
console.log(c.gray("then fill `description:` — `doctrina validate` warns on a trigger nothing can match."));
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
64
212
|
function skillSuggest(args, flags) {
|
|
65
213
|
const projectRoot = process.cwd();
|
|
66
214
|
ensureDoctrinaProject(projectRoot);
|
|
67
215
|
const doWrite = flagBool(flags, "write", false);
|
|
68
216
|
const since = flagString(flags, "since");
|
|
69
217
|
|
|
218
|
+
// --from-error: draft from the failure that is on screen RIGHT NOW,
|
|
219
|
+
// rather than waiting for the lesson to be remembered later. When given,
|
|
220
|
+
// it is the only source — this is a specific request, not a survey.
|
|
221
|
+
// A value-taking flag written without a value is a usage error, not a
|
|
222
|
+
// silent fall-through to the survey — `--from-error` with nothing after
|
|
223
|
+
// it would otherwise run the ordinary scan and look like it worked.
|
|
224
|
+
if (flagGivenWithoutValue(flags, "from-error")) {
|
|
225
|
+
console.error(c.red("error:") + " --from-error needs the error text, or a path to a file holding it");
|
|
226
|
+
return 2;
|
|
227
|
+
}
|
|
228
|
+
const fromError = flagString(flags, "from-error");
|
|
229
|
+
if (fromError !== undefined) {
|
|
230
|
+
if (fromError === "" || fromError === null) {
|
|
231
|
+
console.error(c.red("error:") + " --from-error needs the error text, or a path to a file holding it");
|
|
232
|
+
return 2;
|
|
233
|
+
}
|
|
234
|
+
// A path is read; anything else is taken as the text itself.
|
|
235
|
+
const asPath = path.resolve(projectRoot, fromError);
|
|
236
|
+
const text = isFile(asPath) ? read(asPath) : fromError;
|
|
237
|
+
const draft = draftFromError(text);
|
|
238
|
+
if (!draft) {
|
|
239
|
+
console.error(c.red("error:") + " --from-error was given nothing to draft from");
|
|
240
|
+
return 2;
|
|
241
|
+
}
|
|
242
|
+
return writeErrorDraft(projectRoot, draft, doWrite);
|
|
243
|
+
}
|
|
244
|
+
|
|
70
245
|
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
71
246
|
const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
|
|
72
247
|
const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
|
|
@@ -423,7 +598,7 @@ Subcommands:
|
|
|
423
598
|
sync Copy each skill's frontmatter description into
|
|
424
599
|
.doctrina/index.json so the index never drifts
|
|
425
600
|
from the frontmatter (single source of truth).
|
|
426
|
-
suggest [--write] [--since <ref>]
|
|
601
|
+
suggest [--write] [--since <ref>] [--from-error <text|file>]
|
|
427
602
|
Surface fix-shaped lessons not yet captured as skills,
|
|
428
603
|
from two sources: archived change proposals and the git
|
|
429
604
|
commit history (ADR 0013). --write scaffolds a stub per
|
|
@@ -431,6 +606,14 @@ Subcommands:
|
|
|
431
606
|
--since <ref> scans commits in <ref>..HEAD instead of the
|
|
432
607
|
last 200 (e.g. --since v0.7.0).
|
|
433
608
|
|
|
609
|
+
--from-error <text|file> drafts ONE skill from the failure
|
|
610
|
+
on screen right now, instead of waiting for the lesson to
|
|
611
|
+
be remembered later. It fills the \`when:\` trigger from the
|
|
612
|
+
error's own paths, identifiers and message — the field a
|
|
613
|
+
human is least likely to write in a form anything can
|
|
614
|
+
match, and the one \`context\` ranks on. The procedure stays
|
|
615
|
+
yours to write. Add --write to scaffold it.
|
|
616
|
+
|
|
434
617
|
Skills are written by humans, not generated. See docs/en/skills.md
|
|
435
618
|
for the design rationale and the distinction from specs / AGENTS.md /
|
|
436
619
|
the rejected memory/ folder.
|
|
@@ -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/validate.js
CHANGED
|
@@ -17,6 +17,8 @@ import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
|
17
17
|
import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
18
18
|
import { COMMAND_NAMES, referencedCommands } from "../lib/commands.js";
|
|
19
19
|
import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
20
|
+
import { parsePipeline, checkPipeline } from "../lib/pipeline.js";
|
|
21
|
+
import { collectRuntimeFindings } from "../lib/runtime.js";
|
|
20
22
|
|
|
21
23
|
// AGENTS.md is treated as a maintained doctrina-command catalog only once it
|
|
22
24
|
// documents at least this many real commands; below it, the file defers to
|
|
@@ -30,7 +32,7 @@ const CATALOG_THRESHOLD = 8;
|
|
|
30
32
|
// wrap it in the generic envelope.
|
|
31
33
|
export const jsonNative = true;
|
|
32
34
|
|
|
33
|
-
export const flags = { boolean: ["fix", "json"], string: [] };
|
|
35
|
+
export const flags = { boolean: ["fix", "json", "runtime"], string: [] };
|
|
34
36
|
|
|
35
37
|
export async function run(_positional, flags) {
|
|
36
38
|
const projectRoot = process.cwd();
|
|
@@ -379,6 +381,17 @@ export async function run(_positional, flags) {
|
|
|
379
381
|
}
|
|
380
382
|
}
|
|
381
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
|
+
|
|
382
395
|
// 8c. Capability-state honesty (two-axis status). An "active"
|
|
383
396
|
// document that records no built capability is an inventory
|
|
384
397
|
// claim — the gap the framework is meant to make visible.
|
|
@@ -581,6 +594,22 @@ export async function run(_positional, flags) {
|
|
|
581
594
|
if (nameField && nameField !== baseName) {
|
|
582
595
|
warnings.push(`${rel} name "${nameField}" does not match filename slug "${baseName}"`);
|
|
583
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
|
+
}
|
|
584
613
|
const lines = lineCount(f);
|
|
585
614
|
if (lines > 200) warnings.push(`${rel} is ${lines} lines (>200 soft cap)`);
|
|
586
615
|
else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
|
|
@@ -628,6 +657,22 @@ export async function run(_positional, flags) {
|
|
|
628
657
|
// survives sessions because it is an artifact, not a memory.
|
|
629
658
|
for (const line of checkProjectRules(projectRoot)) errors.push(line);
|
|
630
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
|
+
|
|
631
676
|
// Output
|
|
632
677
|
if (flagBool(flags, "json", false)) {
|
|
633
678
|
emitJson("validate", { ok: errors.length === 0, errors, warnings });
|
|
@@ -646,6 +691,28 @@ export async function run(_positional, flags) {
|
|
|
646
691
|
return errors.length === 0 ? 0 : 1;
|
|
647
692
|
}
|
|
648
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
|
+
|
|
649
716
|
// Enforce .doctrina/rules.json — permanent project constraints as
|
|
650
717
|
// forbid-regexes over glob-scoped paths:
|
|
651
718
|
// { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
|
|
@@ -846,13 +913,33 @@ function isLikelyPath(s) {
|
|
|
846
913
|
}
|
|
847
914
|
|
|
848
915
|
export const help = `
|
|
849
|
-
Usage: doctrina validate [--fix] [--json]
|
|
916
|
+
Usage: doctrina validate [--fix] [--runtime] [--json]
|
|
850
917
|
|
|
851
918
|
Run schema, artifact-existence, and structural checks against the
|
|
852
919
|
.doctrina/ tree in the current working directory. Exits 0 if no errors
|
|
853
920
|
(warnings allowed), 1 otherwise.
|
|
854
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
|
+
|
|
855
938
|
Flags:
|
|
856
|
-
--fix
|
|
857
|
-
--
|
|
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).
|
|
858
945
|
`;
|