doctrina-cli 0.14.0 → 0.15.1

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.
@@ -2,7 +2,7 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { readdirSync } from "node:fs";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
6
  import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
7
7
  import { flagBool, flagString } from "../lib/args.js";
8
8
  import { today } from "../lib/dates.js";
@@ -25,7 +25,10 @@ const STARTER = {
25
25
  "doctrina verify runs each check below, in order, through your shell. " +
26
26
  "Replace the example with your real typecheck/test/build commands. A " +
27
27
  "non-zero exit fails the gate. This file is the project's executable " +
28
- "definition of 'the code actually works'.",
28
+ "definition of 'the code actually works'. A check may add an \"expect\" " +
29
+ "block — {\"fail_if_output_matches\": \"<regex>\"} and/or " +
30
+ "{\"require_output_matches\": \"<regex>\"} — so that a run which exits 0 " +
31
+ "having executed NOTHING fails the gate instead of passing it.",
29
32
  checks: [
30
33
  { name: "example", run: "exit 1" },
31
34
  ],
@@ -99,6 +102,15 @@ export async function run(_positional, flags) {
99
102
  console.error(c.red("error:") + ` every check in ${CONFIG_REL} needs a non-empty "run" string (or "type": "manual")`);
100
103
  return 1;
101
104
  }
105
+ // An uncompilable expectation must fail LOUDLY at config time. Skipping
106
+ // it would leave a check that looks fail-closed and is not — the
107
+ // failure mode this whole feature exists to remove.
108
+ try {
109
+ parseExpectation(ch);
110
+ } catch (err) {
111
+ console.error(c.red("error:") + ` check "${ch.name ?? "?"}" declares an "expect" pattern that is not a valid regular expression: ${err.message}`);
112
+ return EXIT.USAGE;
113
+ }
102
114
  }
103
115
  if (checks.length === 0) {
104
116
  console.error(c.red("error:") + ` ${CONFIG_REL} declares zero checks — a verify gate with no checks proves nothing`);
@@ -141,6 +153,15 @@ export async function run(_positional, flags) {
141
153
  checks.forEach((ch, i) => {
142
154
  const what = ch.type === "manual" ? c.gray(`manual${ch.rubric ? " — " + ch.rubric : ""}`) : c.gray(ch.run);
143
155
  console.log(` ${i + 1}. ${c.cyan((ch.name ?? "check").padEnd(16))} ${what}`);
156
+ // A fail-closed check must be visible as one: a reader auditing the
157
+ // gate needs to see which checks judge their own output.
158
+ const exp = parseExpectation(ch);
159
+ if (exp) {
160
+ const parts = [];
161
+ if (exp.failIfSource) parts.push(`fail if output matches /${exp.failIfSource}/`);
162
+ if (exp.requireSource) parts.push(`require output matches /${exp.requireSource}/`);
163
+ console.log(` ${" ".repeat(16)} ${c.gray(parts.join("; "))}`);
164
+ }
144
165
  });
145
166
  return 0;
146
167
  }
@@ -177,14 +198,44 @@ export async function run(_positional, flags) {
177
198
  // monorepo target a sub-package, e.g. { run: "node --test", cwd:
178
199
  // "packages/api" } — the multi-service shape the contract artifact
179
200
  // also serves.
180
- const res = spawnSync(ch.run, {
181
- cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
182
- shell: true,
183
- stdio: "inherit",
184
- });
185
- const ok = !res.error && res.status === 0;
201
+ // A check that declares OUTPUT EXPECTATIONS must have its output READ,
202
+ // which is not the same as withholding it. The first cut ran such a
203
+ // check piped and echoed the whole buffer at the end — forty seconds of
204
+ // blank terminal on this project's own suite, and worse on anything
205
+ // slower. Reading and showing are independent: the tee streams every
206
+ // chunk as it arrives AND accumulates it for the match.
207
+ const expectation = parseExpectation(ch);
208
+ const res = expectation
209
+ ? await runTee(ch.run, ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot)
210
+ : spawnSync(ch.run, {
211
+ cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
212
+ shell: true,
213
+ stdio: "inherit",
214
+ });
215
+
216
+ let ok = !res.error && res.status === 0;
217
+ /** @type {string | null} */
218
+ let expectationNote = null;
219
+ if (ok && expectation) {
220
+ // THE FAIL-CLOSED RULE. A green exit code answers "did the runner
221
+ // crash", never "did the runner do anything". A suite whose filter
222
+ // matched nothing prints "0 scenarios" and exits 0, and every gate
223
+ // above this one calls that a pass. An expectation makes the run's
224
+ // own output part of the verdict.
225
+ // The tee already interleaves both streams in the order they arrived,
226
+ // which is the order a human read them in.
227
+ const output = res.output ?? `${res.stdout ?? ""}\n${res.stderr ?? ""}`;
228
+ const verdict = judgeOutput(expectation, output);
229
+ if (!verdict.ok) {
230
+ ok = false;
231
+ expectationNote = verdict.reason;
232
+ }
233
+ }
234
+
186
235
  if (res.error) {
187
236
  console.log(c.red(`✗ ${name}`) + c.gray(` — could not run: ${res.error.message}`));
237
+ } else if (expectationNote) {
238
+ console.log(c.red(`✗ ${name}`) + c.gray(` — exit 0, but ${expectationNote}`));
188
239
  } else {
189
240
  console.log(ok ? c.green(`✓ ${name}`) : c.red(`✗ ${name} (exit ${res.status})`));
190
241
  }
@@ -213,6 +264,88 @@ export async function run(_positional, flags) {
213
264
  return 1;
214
265
  }
215
266
 
267
+ // Run a command, streaming its output to the terminal AS IT ARRIVES while
268
+ // accumulating a copy for an expectation to match against.
269
+ //
270
+ // `spawnSync` cannot do this: it either inherits the streams (visible,
271
+ // unreadable) or pipes them (readable, invisible until it exits). The async
272
+ // spawn gives both, at the cost of this function existing.
273
+ //
274
+ // Both streams accumulate into ONE buffer in arrival order, so a pattern
275
+ // spanning them matches the same text the operator saw, and stderr is
276
+ // echoed to stderr so redirection still behaves.
277
+ //
278
+ // @returns {Promise<{status: number|null, output: string, error?: Error}>}
279
+ function runTee(command, cwd) {
280
+ return new Promise((resolve) => {
281
+ let child;
282
+ try {
283
+ child = spawn(command, { cwd, shell: true, stdio: ["inherit", "pipe", "pipe"] });
284
+ } catch (err) {
285
+ resolve({ status: null, output: "", error: /** @type {Error} */ (err) });
286
+ return;
287
+ }
288
+
289
+ let output = "";
290
+ const tee = (stream, sink) => {
291
+ if (!stream) return;
292
+ stream.setEncoding("utf8");
293
+ stream.on("data", (chunk) => {
294
+ output += chunk;
295
+ sink.write(chunk);
296
+ });
297
+ };
298
+ tee(child.stdout, process.stdout);
299
+ tee(child.stderr, process.stderr);
300
+
301
+ child.on("error", (err) => resolve({ status: null, output, error: err }));
302
+ // "close" rather than "exit": it fires once the streams are drained, so
303
+ // no trailing output is dropped from the match or the terminal.
304
+ child.on("close", (status) => resolve({ status, output }));
305
+ });
306
+ }
307
+
308
+ // An `expect` block, normalised and compiled, or null when the check does
309
+ // not declare one. Runner-agnostic by construction: Doctrina supplies no
310
+ // patterns of its own and knows nothing about what the output means — the
311
+ // project declares the line that proves its run was real.
312
+ //
313
+ // "expect": {
314
+ // "fail_if_output_matches": "0 scenarios|0 features",
315
+ // "require_output_matches": "\\d+ scenarios? passed"
316
+ // }
317
+ export function parseExpectation(check) {
318
+ const raw = check?.expect;
319
+ if (!raw || typeof raw !== "object") return null;
320
+ const out = {};
321
+ if (typeof raw.fail_if_output_matches === "string" && raw.fail_if_output_matches !== "") {
322
+ out.failIf = new RegExp(raw.fail_if_output_matches, "im");
323
+ out.failIfSource = raw.fail_if_output_matches;
324
+ }
325
+ if (typeof raw.require_output_matches === "string" && raw.require_output_matches !== "") {
326
+ out.requireMatch = new RegExp(raw.require_output_matches, "im");
327
+ out.requireSource = raw.require_output_matches;
328
+ }
329
+ return out.failIf || out.requireMatch ? out : null;
330
+ }
331
+
332
+ /** @returns {{ok: boolean, reason?: string}} */
333
+ export function judgeOutput(expectation, output) {
334
+ if (expectation.failIf && expectation.failIf.test(output)) {
335
+ return {
336
+ ok: false,
337
+ reason: `its output matched /${expectation.failIfSource}/ — the check declares that shape a failure (a run that executed nothing exits 0 too)`,
338
+ };
339
+ }
340
+ if (expectation.requireMatch && !expectation.requireMatch.test(output)) {
341
+ return {
342
+ ok: false,
343
+ reason: `its output never matched /${expectation.requireSource}/ — the check requires that proof of a real run`,
344
+ };
345
+ }
346
+ return { ok: true };
347
+ }
348
+
216
349
  // Static reproducibility lint: walk the project's package.json files and
217
350
  // report the two "works on my machine" footguns the review hit — a package
218
351
  // whose entry points live in a build-output dir with nothing that builds it
@@ -370,6 +503,23 @@ A manual check passes when it has been signed off and is otherwise reported
370
503
  as "pending" — non-blocking by default, failing only under --strict (so CI
371
504
  can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
372
505
 
506
+ OUTPUT EXPECTATIONS — fail-closed on a run that did nothing. An exit code
507
+ answers "did the runner crash", never "did the runner run anything": a suite
508
+ whose filter matched no cases prints "0 scenarios" and exits 0, and every
509
+ gate calls it a pass. Add an "expect" block to make the output part of the
510
+ verdict:
511
+
512
+ { "name": "e2e", "run": "behave --tags @smoke",
513
+ "expect": { "fail_if_output_matches": "0 scenarios",
514
+ "require_output_matches": "\\\\d+ scenarios? passed" } }
515
+
516
+ Doctrina supplies no patterns of its own and knows nothing about what the
517
+ output means — the project declares the line that proves its run was real,
518
+ so this works for any runner in any language. An expect-carrying check
519
+ still streams: its output is teed to the terminal as it arrives while a
520
+ copy accumulates for the match. An "expect" pattern that is not a valid
521
+ regular expression fails at config time, not silently.
522
+
373
523
  Flags:
374
524
  --init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
375
525
  --list Print the configured checks without running them
@@ -9,8 +9,9 @@ import * as idx from "../lib/index-json.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { assessBrief } from "../lib/clarity.js";
11
11
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
12
- import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
13
13
  import { changeNew } from "./change.js";
14
+ import { classify } from "./triage.js";
14
15
 
15
16
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
16
17
  // prompt ("add login") becomes a fully scaffolded change plus a playbook
@@ -72,6 +73,31 @@ export async function run(positional, flags) {
72
73
  }
73
74
  }
74
75
 
76
+ // LANE CHECK (change 0029). `work` was the default answer to every
77
+ // request, so a broken workflow or a suite that ran nothing became a
78
+ // change with a proposal, tasks and a spec delta — ceremony spent on a
79
+ // diagnosis, and a close that attested to nothing. A runtime-shaped
80
+ // prompt is stopped here, once, with the diagnosis path named. It is a
81
+ // deterministic term match and it can be wrong, so --force proceeds and
82
+ // the message says so.
83
+ if (!fromDiff && !chore && !flagBool(flags, "force", false)) {
84
+ const verdict = classify(prompt);
85
+ if (verdict.lane === "runtime" && verdict.confident) {
86
+ console.error(c.yellow("hold:") + " this reads as a RUNTIME problem, not a change of behaviour");
87
+ console.error(c.gray(`signals: ${[...new Set(verdict.scores.runtime.hits)].slice(0, 6).join(", ")}`));
88
+ console.error("");
89
+ console.error("Nothing here says the spec is wrong — diagnose the running system first:");
90
+ console.error(` ${c.cyan(`doctrina triage "${prompt.length > 60 ? `${prompt.slice(0, 59)}…` : prompt}"`)}`);
91
+ console.error("");
92
+ console.error(c.gray("If the requirement really is missing, open the change anyway:"));
93
+ console.error(c.gray(" doctrina work --force \"<prompt>\" · or --chore for wiring already specified"));
94
+ // PRECONDITION: the work may be perfectly valid; this project has
95
+ // simply not been diagnosed yet. A gate code (1) would read as
96
+ // "the prompt is bad" and invite rewording it forever.
97
+ return EXIT.PRECONDITION;
98
+ }
99
+ }
100
+
75
101
  const pinned = flagString(flags, "capability");
76
102
  if (pinned && !/^[a-z][a-z0-9-]*$/.test(pinned)) {
77
103
  console.error(c.red("error:") + ` invalid capability "${pinned}" (lowercase letters, digits, hyphens)`);
@@ -545,6 +571,14 @@ Options:
545
571
  prompt needed) and print a code-first playbook (F8)
546
572
  --chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
547
573
  playbook that skips the spec-delta steps (F9)
548
- --force Open a new change even when one is open, and overwrite
549
- an existing change folder
574
+ --force Open a new change even when one is open, overwrite an
575
+ existing change folder, and proceed past the lane hold
576
+ below
577
+
578
+ A prompt that reads as a RUNTIME problem — a workflow, an env value, a run
579
+ that executed nothing — is HELD before anything is scaffolded, and pointed
580
+ at \`doctrina triage\` instead (exit 3: a precondition, not a bad prompt).
581
+ The classifier is deterministic term matching and can be wrong; --force
582
+ opens the change anyway, and --chore is the lane for wiring the spec
583
+ already covers.
550
584
  `;
package/src/index.js CHANGED
@@ -47,6 +47,7 @@ import * as completion from "./commands/completion.js";
47
47
  import * as intent from "./commands/intent.js";
48
48
  import * as upgrade from "./commands/upgrade.js";
49
49
  import * as adapter from "./commands/adapter.js";
50
+ import * as triage from "./commands/triage.js";
50
51
 
51
52
  const COMMANDS = {
52
53
  init, spec, change, decision, validate, hooks, analyze, clarify,
@@ -54,7 +55,7 @@ const COMMANDS = {
54
55
  intake, work, coverage, verify, contract, trace,
55
56
  status, close, review, watch, why, constitution,
56
57
  prime, handoff, show, doctor, report, completion,
57
- intent, upgrade, adapter,
58
+ intent, upgrade, adapter, triage,
58
59
  };
59
60
 
60
61
  const TOP_HELP = `
@@ -12,7 +12,7 @@
12
12
  // Keep this sorted by the workflow order used in `--help`, not alphabetically.
13
13
  export const COMMAND_NAMES = [
14
14
  // bootstrap / day-to-day
15
- "init", "intake", "work",
15
+ "init", "intake", "triage", "work",
16
16
  // authoring
17
17
  "spec", "change", "contract", "decision", "skill", "intent", "adapter",
18
18
  // read / orient
@@ -36,6 +36,7 @@ export const COMMAND_NAMES = [
36
36
  export const OPERATIONS = [
37
37
  ["init", "Scaffold AGENTS.md and .doctrina/ in the current directory"],
38
38
  ["intake", "Store the full project description; print the bootstrap playbook"],
39
+ ["triage", "Classify a request into a lane, and check the runtime surface"],
39
40
  ["work", "Brief prompt -> scaffolded change + guided work playbook"],
40
41
  ["spec new", "Create a new capability spec (--bug for bug-shape)"],
41
42
  ["spec list", "List specs with version, status, and size"],
@@ -127,7 +128,8 @@ export const COMMAND_META = {
127
128
  constitution: { moment: "Orient", when: "you need the standing rules before deciding something", purpose: "accepted ADRs and product non-goals" },
128
129
  handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
129
130
 
130
- work: { moment: "Change", when: "any request arrives that changes behaviour", purpose: "scaffold a change and print the playbook to execute" },
131
+ triage: { moment: "Change", when: "a request arrives — BEFORE scaffolding, especially if it smells like an incident", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
132
+ work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
131
133
  spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
132
134
  change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "new / apply / archive / check / tick / diff / abandon" },
133
135
  decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
@@ -232,9 +234,12 @@ export function surfaceMarkdown() {
232
234
  const lines = [
233
235
  "## Doctrina command surface (generated — reach for these, don't hand-author)",
234
236
  "",
237
+ // Two lines, not three. This block is always-loaded context inside a
238
+ // file with a hard 150-line budget, so its own preamble competes with
239
+ // the commands it introduces — and a command an agent never discovers
240
+ // costs more than a sentence of prose ever saves.
235
241
  "Every command, with the moment you reach for it. The CLI scaffolds from",
236
- "canonical templates and keeps `index.json` in sync — prefer it over",
237
- "hand-authoring. Flags and detail: `doctrina <command> --help`.",
242
+ "canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.",
238
243
  ];
239
244
  for (const moment of MOMENTS) {
240
245
  const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment);
@@ -397,6 +402,19 @@ export const AGENT_CHANGELOG_END = "<!-- doctrina:changed:end -->";
397
402
  // introduced it. Only entries that alter what an agent should DO belong
398
403
  // here — a bug fix nobody's behaviour depends on does not.
399
404
  export const AGENT_CHANGELOG = {
405
+ // Each entry lists only ITS OWN delta. The series carries the rest
406
+ // forward (see agentChangelogEntries), so a patch never has to restate
407
+ // the minor that introduced the commands, and never silently erases it.
408
+ "0.15.1": [
409
+ "Exporting a variable under a name that is not its source's is routine — declare it in the Wiring row's Origin cell as `<origin>:<source>` (e.g. `secrets:NPM_TOKEN`) and RT02 stays silent until either side moves.",
410
+ ],
411
+ "0.15.0": [
412
+ "`doctrina triage \"<prompt>\"` — classify a request as PRODUCT / RUNTIME / CHORE BEFORE scaffolding. `work` now holds a runtime-shaped prompt with exit 3 and points here; `--force` opens the change anyway.",
413
+ "`contract check` now holds the declared wiring to the implementation: a `vars`/`secrets` variable no workflow exports, a default an empty CI value never triggers, an unvalidated enum, a selector matching zero targets (RT01-RT05).",
414
+ "A `verify` check may declare `expect`, so a run that exits 0 having executed NOTHING fails the gate; an `[orchestration]` acceptance criterion is proven by citing such a guarded check (`verify:<name>`), not by a citation that merely resolves.",
415
+ "A spec may declare an ordered `### Pipeline`; `validate` refuses a step that requires what a later step produces. `validate --runtime` adds the runtime gate.",
416
+ "`skill suggest --from-error <text|file>` drafts a skill from the failure on screen; `context --for` now ranks skills by their trigger.",
417
+ ],
400
418
  "0.14.0": [
401
419
  "`doctrina adapter add|remove|list` — add an agent without re-scaffolding; `init --force` no longer overwrites authored AGENTS.md/product.md.",
402
420
  "`doctrina change apply` now refuses what `analyze` refuses; `change check` previews a close, `change tick` checks boxes in bulk.",
@@ -408,9 +426,54 @@ export const AGENT_CHANGELOG = {
408
426
 
409
427
  // The block written into AGENTS.md for a given version. Empty string when
410
428
  // that version has no agent-visible changes.
429
+ // How many bullets the block may carry. Not a style preference: AGENTS.md
430
+ // is always-loaded context under a hard 150-line budget, and at the current
431
+ // template size five bullets is exactly what fits. Raising this means
432
+ // cutting elsewhere — see the `agents-md-lines` budget in the contract.
433
+ export const AGENT_CHANGELOG_MAX_BULLETS = 5;
434
+
435
+ /**
436
+ * The bullets an agent arriving at `version` needs, newest first.
437
+ *
438
+ * Keyed per version, but rendered per MINOR SERIES. The block REPLACES its
439
+ * predecessor in AGENTS.md rather than accumulating, so rendering only the
440
+ * current version's own entry meant every patch silently erased the minor
441
+ * that introduced the commands: an agent upgrading 0.14.0 -> 0.15.1 would
442
+ * read one bug fix and never learn `triage` exists. The alternative —
443
+ * making each patch author restate the whole minor by hand — worked once
444
+ * and does not scale, because the restating is invisible and the third
445
+ * patch quietly drops a line to fit the cap.
446
+ *
447
+ * So the series carries itself, newest first, and the cap truncates the
448
+ * OLDEST. A fact that must outlive its series does not belong here: it
449
+ * belongs in the surface block or the docs, which is the honest place for
450
+ * something permanent.
451
+ */
452
+ export function agentChangelogEntries(version) {
453
+ const series = String(version).split(".").slice(0, 2).join(".");
454
+ const versions = Object.keys(AGENT_CHANGELOG)
455
+ .filter((v) => v.split(".").slice(0, 2).join(".") === series && compareVersions(v, version) <= 0)
456
+ .sort((a, b) => compareVersions(b, a));
457
+ const out = [];
458
+ for (const v of versions) out.push(...(AGENT_CHANGELOG[v] ?? []));
459
+ return out.slice(0, AGENT_CHANGELOG_MAX_BULLETS);
460
+ }
461
+
462
+ // Numeric semver comparison — "0.15.10" must sort above "0.15.9", which a
463
+ // string compare gets wrong.
464
+ function compareVersions(a, b) {
465
+ const pa = String(a).split(".").map((n) => Number.parseInt(n, 10) || 0);
466
+ const pb = String(b).split(".").map((n) => Number.parseInt(n, 10) || 0);
467
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
468
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
469
+ if (d !== 0) return d;
470
+ }
471
+ return 0;
472
+ }
473
+
411
474
  export function agentChangelogMarkdown(version) {
412
- const entries = AGENT_CHANGELOG[version];
413
- if (!entries || entries.length === 0) return "";
475
+ const entries = agentChangelogEntries(version);
476
+ if (entries.length === 0) return "";
414
477
  return [
415
478
  `## What changed in ${version}`,
416
479
  "",
package/src/lib/gates.js CHANGED
@@ -34,6 +34,28 @@ export const GATES = {
34
34
  },
35
35
  },
36
36
 
37
+ // Integrity: the subset of the structural questions that are true at
38
+ // EVERY point in a change's life — the proposal says why and what, the
39
+ // tasks are real, the deltas parse. Everything `structure` asks except
40
+ // the handful that only mean something before an apply.
41
+ //
42
+ // This exists because excluding `structure` wholesale from `archive` (to
43
+ // dodge the post-apply false positive documented below) also excluded the
44
+ // hollow-proposal check, which has no such problem. A change could then be
45
+ // archived — and stamped "applied" — with `## What` still holding nothing
46
+ // but its scaffold comment, after `apply` had refused it for exactly that.
47
+ // Observed on change 0030 of this repository, one release after the
48
+ // history recorded six hollow proposals reaching the archive.
49
+ integrity: {
50
+ label: "integrity",
51
+ rerun: (id) => `doctrina analyze ${id}`,
52
+ blockers(projectRoot, changeDir) {
53
+ return collectAnalysis(projectRoot, changeDir)
54
+ .filter((r) => r.kind === "fail" && r.scope !== "pre-apply")
55
+ .map((r) => stripAnsi(r.line).replace(/^✗\s*/, "").trim());
56
+ },
57
+ },
58
+
37
59
  // Verification: the work is claimed complete. Every checkbox in
38
60
  // tasks.md (closing steps included) and in the proposal's
39
61
  // "## Verification" section.
@@ -72,9 +94,15 @@ export const GATES = {
72
94
  // content the delta just wrote, so re-asking at archive time reports a
73
95
  // conflict that is the proof the apply worked. A gate must be asked at the
74
96
  // point its question is meaningful.
97
+ //
98
+ // `archive` therefore requires INTEGRITY — that same structural question
99
+ // set minus the pre-apply-only checks — rather than nothing structural at
100
+ // all. Excluding the whole gate to dodge three checks also excluded the
101
+ // hollow-proposal check, and a change refused by `apply` for an unwritten
102
+ // `## What` could still be archived and stamped "applied".
75
103
  export const TRANSITIONS = {
76
104
  apply: { label: "change apply", gates: ["structure"] },
77
- archive: { label: "change archive", gates: ["verification"] },
105
+ archive: { label: "change archive", gates: ["integrity", "verification"] },
78
106
  };
79
107
 
80
108
  // Evaluate a transition's gates. Returns { ok, blockers, gates } where
@@ -0,0 +1,161 @@
1
+ // @ts-check
2
+ import { getSection } from "./doc-model.js";
3
+
4
+ // ORDERED requirements: what EARS cannot say.
5
+ //
6
+ // EARS event-driven requirements are individually true and mutually
7
+ // unordered. "When the run finishes, the system shall append a summary" and
8
+ // "When the analysis completes, the system shall publish the dashboard" both
9
+ // pass every gate, and neither says the dashboard needs the analysis to have
10
+ // run first. So "the dashboard includes this run's analysis" gets read as
11
+ // "call the renderer at the end" without anyone checking whether the thing
12
+ // it renders exists yet — and the failure is a published dashboard that is
13
+ // silently one run stale, which no structural check can see.
14
+ //
15
+ // A Pipeline block states the order and the artifact each step hands on:
16
+ //
17
+ // ### Pipeline
18
+ //
19
+ // 1. run-suite — produces `reports/results.json`
20
+ // 2. analyse — requires `reports/results.json`, produces `reports/analysis.md`
21
+ // 3. publish — requires `reports/analysis.md`
22
+ //
23
+ // The invariant is the one an ordered list cannot enforce on its own: a step
24
+ // may only require what an EARLIER step produced. Requiring something a
25
+ // later step produces is the inversion; requiring something no step produces
26
+ // is the missing producer. Both are errors, and both are exactly the bug
27
+ // that ships as "green, and a run behind".
28
+ //
29
+ // Artifacts that genuinely come from outside are marked `(external)`.
30
+
31
+ /**
32
+ * @typedef {object} PipelineStep
33
+ * @property {number} number The declared ordinal.
34
+ * @property {string} name
35
+ * @property {string[]} requires
36
+ * @property {string[]} produces
37
+ * @property {string[]} external Requirements declared as coming from outside.
38
+ * @property {number} line 1-based line within the spec.
39
+ */
40
+
41
+ /**
42
+ * Parse the `### Pipeline` block of a spec. Returns null when the spec
43
+ * declares none — the block is opt-in, so every spec written before it
44
+ * existed parses to null and is never checked.
45
+ *
46
+ * @returns {PipelineStep[] | null}
47
+ */
48
+ export function parsePipeline(text) {
49
+ const section = getSection(text, "Pipeline", { level: 3 });
50
+ if (!section || !section.trim()) return null;
51
+
52
+ const all = String(text).split(/\r?\n/);
53
+ const offset = all.findIndex((l) => /^###\s+Pipeline\s*$/.test(l));
54
+
55
+ const steps = [];
56
+ const lines = section.split(/\r?\n/);
57
+ for (let i = 0; i < lines.length; i++) {
58
+ const m = lines[i].match(/^\s*(\d+)\.\s+(.*\S)\s*$/);
59
+ if (!m) continue;
60
+ const number = Number.parseInt(m[1], 10);
61
+ const body = m[2];
62
+
63
+ // The step name is everything up to the first clause separator.
64
+ const name = body.split(/\s+[—-]\s+|\s*,\s*(?=requires|produces)/)[0].trim();
65
+
66
+ steps.push({
67
+ number,
68
+ name,
69
+ requires: clause(body, "requires").filter((a) => !a.external).map((a) => a.name),
70
+ external: clause(body, "requires").filter((a) => a.external).map((a) => a.name),
71
+ produces: clause(body, "produces").map((a) => a.name),
72
+ line: offset >= 0 ? offset + 1 + i + 1 : i + 1,
73
+ });
74
+ }
75
+ return steps;
76
+ }
77
+
78
+ // The artifacts named by one clause: `requires \`a\`, \`b\` (external)`.
79
+ // Backticks are how a spec already marks a path, so they are the anchor.
80
+ function clause(body, keyword) {
81
+ const re = new RegExp(`${keyword}\\s+([^;]*?)(?=(?:\\brequires\\b|\\bproduces\\b|$))`, "i");
82
+ const m = body.match(re);
83
+ if (!m) return [];
84
+ const out = [];
85
+ for (const artifact of m[1].matchAll(/`([^`]+)`(\s*\(external\))?/g)) {
86
+ out.push({ name: artifact[1].trim(), external: Boolean(artifact[2]) });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Check the ordering invariant.
93
+ * @param {PipelineStep[]} steps
94
+ * @returns {Array<{code: string, level: "error"|"warn", message: string, remedy: string, line: number}>}
95
+ */
96
+ export function checkPipeline(steps) {
97
+ /** @type {Array<{code: string, level: "error"|"warn", message: string, remedy: string, line: number}>} */
98
+ const findings = [];
99
+ if (!steps || steps.length === 0) return findings;
100
+
101
+ // Numbering first: an out-of-order list is not a pipeline, and every
102
+ // check below reads the declared order as the real one.
103
+ for (let i = 0; i < steps.length; i++) {
104
+ if (steps[i].number !== i + 1) {
105
+ findings.push({
106
+ code: "PL01",
107
+ level: "error",
108
+ message: `pipeline step ${i + 1} is numbered ${steps[i].number} ("${steps[i].name}") — the order a pipeline declares must be the order it reads in`,
109
+ remedy: "renumber the Pipeline steps 1..n in execution order",
110
+ line: steps[i].line,
111
+ });
112
+ }
113
+ }
114
+
115
+ // Everything produced at or before each index, which is what "earlier"
116
+ // means. Built as we walk, so a step can never satisfy itself.
117
+ const producedBefore = new Set();
118
+ const producedBy = new Map();
119
+ for (const step of steps) {
120
+ for (const artifact of step.produces) {
121
+ if (!producedBy.has(artifact)) producedBy.set(artifact, step);
122
+ }
123
+ }
124
+
125
+ for (const step of steps) {
126
+ for (const artifact of step.requires) {
127
+ if (producedBefore.has(artifact)) continue;
128
+
129
+ const producer = producedBy.get(artifact);
130
+ if (producer && producer.number > step.number) {
131
+ // The inversion: the consumer runs first and reads last run's file.
132
+ findings.push({
133
+ code: "PL02",
134
+ level: "error",
135
+ message: `step ${step.number} ("${step.name}") requires \`${artifact}\`, which step ${producer.number} ("${producer.name}") produces LATER — this step can only ever read the previous run's copy`,
136
+ remedy: `move "${producer.name}" before "${step.name}", or make the dependency explicit and drop the requirement`,
137
+ line: step.line,
138
+ });
139
+ } else if (producer && producer.number === step.number) {
140
+ findings.push({
141
+ code: "PL02",
142
+ level: "error",
143
+ message: `step ${step.number} ("${step.name}") both requires and produces \`${artifact}\``,
144
+ remedy: `split the step, or mark the input \`${artifact}\` (external) if it comes from outside the pipeline`,
145
+ line: step.line,
146
+ });
147
+ } else {
148
+ findings.push({
149
+ code: "PL03",
150
+ level: "error",
151
+ message: `step ${step.number} ("${step.name}") requires \`${artifact}\`, which no step produces — the step runs against a file nothing in this pipeline creates`,
152
+ remedy: `add the producing step, or mark it \`${artifact}\` (external) to declare it comes from outside`,
153
+ line: step.line,
154
+ });
155
+ }
156
+ }
157
+ for (const artifact of step.produces) producedBefore.add(artifact);
158
+ }
159
+
160
+ return findings;
161
+ }