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.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -2
  3. package/src/commands/adapter.js +205 -0
  4. package/src/commands/analyze.js +120 -17
  5. package/src/commands/change.js +100 -42
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +57 -2
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +391 -58
  11. package/src/commands/contract.js +76 -43
  12. package/src/commands/coverage.js +126 -12
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +92 -11
  15. package/src/commands/handoff.js +8 -1
  16. package/src/commands/hooks.js +6 -0
  17. package/src/commands/index-rebuild.js +8 -1
  18. package/src/commands/init.js +139 -6
  19. package/src/commands/intake.js +11 -2
  20. package/src/commands/intent.js +8 -1
  21. package/src/commands/metrics.js +80 -7
  22. package/src/commands/next.js +38 -3
  23. package/src/commands/prime.js +8 -1
  24. package/src/commands/report.js +8 -1
  25. package/src/commands/review.js +8 -1
  26. package/src/commands/search.js +8 -1
  27. package/src/commands/show.js +8 -1
  28. package/src/commands/skill.js +201 -8
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +193 -59
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/triage.js +248 -0
  34. package/src/commands/upgrade.js +8 -1
  35. package/src/commands/validate.js +148 -5
  36. package/src/commands/verify.js +126 -5
  37. package/src/commands/watch.js +8 -1
  38. package/src/commands/why.js +10 -24
  39. package/src/commands/work.js +44 -3
  40. package/src/index.js +61 -6
  41. package/src/lib/adapters.js +140 -0
  42. package/src/lib/adr-guard.js +1 -0
  43. package/src/lib/args.js +10 -0
  44. package/src/lib/clarity.js +1 -0
  45. package/src/lib/colors.js +1 -0
  46. package/src/lib/commands.js +252 -27
  47. package/src/lib/criteria.js +1 -0
  48. package/src/lib/dates.js +1 -0
  49. package/src/lib/diff.js +1 -0
  50. package/src/lib/doc-model.js +287 -0
  51. package/src/lib/docs-impact.js +167 -0
  52. package/src/lib/ears.js +1 -0
  53. package/src/lib/exit-codes.js +103 -0
  54. package/src/lib/flag-catalog.js +106 -0
  55. package/src/lib/fs-ops.js +1 -0
  56. package/src/lib/gates.js +123 -0
  57. package/src/lib/git.js +97 -0
  58. package/src/lib/index-json.js +14 -6
  59. package/src/lib/json-out.js +83 -0
  60. package/src/lib/pipeline.js +161 -0
  61. package/src/lib/prompt.js +18 -1
  62. package/src/lib/runtime.js +683 -0
  63. package/src/lib/scan.js +81 -33
  64. package/src/lib/spec-ops.js +94 -9
  65. package/src/lib/suggest.js +1 -0
  66. package/src/lib/templates.js +89 -0
  67. package/src/lib/usage.js +89 -0
  68. package/src/lib/version.js +1 -0
  69. package/templates/AGENTS.md.template +59 -53
  70. package/templates/contract.md.template +74 -3
  71. 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
+ `;
@@ -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 new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
33
+ throw notADoctrinaProject();
27
34
  }
28
35
  const writeMode = flagBool(flags, "write", false);
29
36
  const running = cliVersion();
@@ -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
- console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }, null, 2));
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 Rebuild index.json from the tree before checking (heals drift).
801
- --json Emit { ok, errors, warnings } as JSON (stable shape for agents/CI).
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
  `;