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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/analyze.js +74 -5
- 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 +258 -0
- package/src/commands/validate.js +91 -4
- package/src/commands/verify.js +158 -8
- package/src/commands/work.js +37 -3
- package/src/index.js +2 -1
- package/src/lib/commands.js +69 -6
- package/src/lib/gates.js +29 -1
- package/src/lib/pipeline.js +161 -0
- package/src/lib/runtime.js +724 -0
- package/templates/AGENTS.md.template +3 -3
- package/templates/contract.md.template +81 -3
- package/templates/spec.md.template +22 -0
package/src/commands/verify.js
CHANGED
|
@@ -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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
package/src/commands/work.js
CHANGED
|
@@ -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,
|
|
549
|
-
|
|
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 = `
|
package/src/lib/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
413
|
-
if (
|
|
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
|
+
}
|