doctrina-cli 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +120 -17
- package/src/commands/change.js +100 -42
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +57 -2
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +391 -58
- package/src/commands/contract.js +76 -43
- package/src/commands/coverage.js +126 -12
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +92 -11
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +139 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +38 -3
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +201 -8
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +193 -59
- package/src/commands/trace.js +15 -3
- package/src/commands/triage.js +248 -0
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +148 -5
- package/src/commands/verify.js +126 -5
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +44 -3
- package/src/index.js +61 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +1 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +252 -27
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/pipeline.js +161 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/runtime.js +683 -0
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +94 -9
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/AGENTS.md.template +59 -53
- package/templates/contract.md.template +74 -3
- package/templates/spec.md.template +22 -0
package/src/commands/verify.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
@@ -6,6 +7,7 @@ import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
|
6
7
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
8
|
import { today } from "../lib/dates.js";
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
10
|
+
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
11
|
|
|
10
12
|
// The build/verify gate `validate` is not. `validate` checks the shape of
|
|
11
13
|
// the artifact tree; it never runs the project, so a repo that does not
|
|
@@ -23,16 +25,24 @@ const STARTER = {
|
|
|
23
25
|
"doctrina verify runs each check below, in order, through your shell. " +
|
|
24
26
|
"Replace the example with your real typecheck/test/build commands. A " +
|
|
25
27
|
"non-zero exit fails the gate. This file is the project's executable " +
|
|
26
|
-
"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.",
|
|
27
32
|
checks: [
|
|
28
33
|
{ name: "example", run: "exit 1" },
|
|
29
34
|
],
|
|
30
35
|
};
|
|
31
36
|
|
|
37
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
38
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
39
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
40
|
+
export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict"], string: ["signoff"] };
|
|
41
|
+
|
|
32
42
|
export async function run(_positional, flags) {
|
|
33
43
|
const projectRoot = process.cwd();
|
|
34
44
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
35
|
-
throw
|
|
45
|
+
throw notADoctrinaProject();
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
// --clean is the reproducibility lint (review G4): `verify` runs the
|
|
@@ -66,7 +76,10 @@ export async function run(_positional, flags) {
|
|
|
66
76
|
// the false confidence this command exists to prevent. Fail loudly.
|
|
67
77
|
console.error(c.red("error:") + ` no ${CONFIG_REL} — nothing to verify`);
|
|
68
78
|
console.error(c.gray("hint: ") + "scaffold one with `doctrina verify --init`, then declare your typecheck/test/build commands");
|
|
69
|
-
|
|
79
|
+
// PRECONDITION, not a gate failure: the work may be perfect; this
|
|
80
|
+
// project has simply never declared a build gate. An agent reading
|
|
81
|
+
// exit 1 would iterate on the code forever (C7).
|
|
82
|
+
return EXIT.PRECONDITION;
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
let config;
|
|
@@ -89,6 +102,15 @@ export async function run(_positional, flags) {
|
|
|
89
102
|
console.error(c.red("error:") + ` every check in ${CONFIG_REL} needs a non-empty "run" string (or "type": "manual")`);
|
|
90
103
|
return 1;
|
|
91
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
|
+
}
|
|
92
114
|
}
|
|
93
115
|
if (checks.length === 0) {
|
|
94
116
|
console.error(c.red("error:") + ` ${CONFIG_REL} declares zero checks — a verify gate with no checks proves nothing`);
|
|
@@ -131,6 +153,15 @@ export async function run(_positional, flags) {
|
|
|
131
153
|
checks.forEach((ch, i) => {
|
|
132
154
|
const what = ch.type === "manual" ? c.gray(`manual${ch.rubric ? " — " + ch.rubric : ""}`) : c.gray(ch.run);
|
|
133
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
|
+
}
|
|
134
165
|
});
|
|
135
166
|
return 0;
|
|
136
167
|
}
|
|
@@ -167,14 +198,46 @@ export async function run(_positional, flags) {
|
|
|
167
198
|
// monorepo target a sub-package, e.g. { run: "node --test", cwd:
|
|
168
199
|
// "packages/api" } — the multi-service shape the contract artifact
|
|
169
200
|
// also serves.
|
|
201
|
+
// A check that declares OUTPUT EXPECTATIONS must have its output read,
|
|
202
|
+
// so it runs piped and is echoed straight through — the operator still
|
|
203
|
+
// sees everything, just at the end of the check rather than streaming.
|
|
204
|
+
// Only checks that opt in pay that cost.
|
|
205
|
+
const expectation = parseExpectation(ch);
|
|
170
206
|
const res = spawnSync(ch.run, {
|
|
171
207
|
cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
|
|
172
208
|
shell: true,
|
|
173
|
-
stdio: "inherit",
|
|
209
|
+
stdio: expectation ? "pipe" : "inherit",
|
|
210
|
+
encoding: expectation ? "utf8" : undefined,
|
|
211
|
+
// A suite's output can be large; the default 1MB cap would truncate
|
|
212
|
+
// the very line an expectation is looking for.
|
|
213
|
+
maxBuffer: expectation ? 64 * 1024 * 1024 : undefined,
|
|
174
214
|
});
|
|
175
|
-
|
|
215
|
+
if (expectation) {
|
|
216
|
+
if (res.stdout) process.stdout.write(res.stdout);
|
|
217
|
+
if (res.stderr) process.stderr.write(res.stderr);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let ok = !res.error && res.status === 0;
|
|
221
|
+
/** @type {string | null} */
|
|
222
|
+
let expectationNote = null;
|
|
223
|
+
if (ok && expectation) {
|
|
224
|
+
// THE FAIL-CLOSED RULE. A green exit code answers "did the runner
|
|
225
|
+
// crash", never "did the runner do anything". A suite whose filter
|
|
226
|
+
// matched nothing prints "0 scenarios" and exits 0, and every gate
|
|
227
|
+
// above this one calls that a pass. An expectation makes the run's
|
|
228
|
+
// own output part of the verdict.
|
|
229
|
+
const output = `${res.stdout ?? ""}\n${res.stderr ?? ""}`;
|
|
230
|
+
const verdict = judgeOutput(expectation, output);
|
|
231
|
+
if (!verdict.ok) {
|
|
232
|
+
ok = false;
|
|
233
|
+
expectationNote = verdict.reason;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
176
237
|
if (res.error) {
|
|
177
238
|
console.log(c.red(`✗ ${name}`) + c.gray(` — could not run: ${res.error.message}`));
|
|
239
|
+
} else if (expectationNote) {
|
|
240
|
+
console.log(c.red(`✗ ${name}`) + c.gray(` — exit 0, but ${expectationNote}`));
|
|
178
241
|
} else {
|
|
179
242
|
console.log(ok ? c.green(`✓ ${name}`) : c.red(`✗ ${name} (exit ${res.status})`));
|
|
180
243
|
}
|
|
@@ -203,6 +266,47 @@ export async function run(_positional, flags) {
|
|
|
203
266
|
return 1;
|
|
204
267
|
}
|
|
205
268
|
|
|
269
|
+
// An `expect` block, normalised and compiled, or null when the check does
|
|
270
|
+
// not declare one. Runner-agnostic by construction: Doctrina supplies no
|
|
271
|
+
// patterns of its own and knows nothing about what the output means — the
|
|
272
|
+
// project declares the line that proves its run was real.
|
|
273
|
+
//
|
|
274
|
+
// "expect": {
|
|
275
|
+
// "fail_if_output_matches": "0 scenarios|0 features",
|
|
276
|
+
// "require_output_matches": "\\d+ scenarios? passed"
|
|
277
|
+
// }
|
|
278
|
+
export function parseExpectation(check) {
|
|
279
|
+
const raw = check?.expect;
|
|
280
|
+
if (!raw || typeof raw !== "object") return null;
|
|
281
|
+
const out = {};
|
|
282
|
+
if (typeof raw.fail_if_output_matches === "string" && raw.fail_if_output_matches !== "") {
|
|
283
|
+
out.failIf = new RegExp(raw.fail_if_output_matches, "im");
|
|
284
|
+
out.failIfSource = raw.fail_if_output_matches;
|
|
285
|
+
}
|
|
286
|
+
if (typeof raw.require_output_matches === "string" && raw.require_output_matches !== "") {
|
|
287
|
+
out.requireMatch = new RegExp(raw.require_output_matches, "im");
|
|
288
|
+
out.requireSource = raw.require_output_matches;
|
|
289
|
+
}
|
|
290
|
+
return out.failIf || out.requireMatch ? out : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** @returns {{ok: boolean, reason?: string}} */
|
|
294
|
+
export function judgeOutput(expectation, output) {
|
|
295
|
+
if (expectation.failIf && expectation.failIf.test(output)) {
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
reason: `its output matched /${expectation.failIfSource}/ — the check declares that shape a failure (a run that executed nothing exits 0 too)`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (expectation.requireMatch && !expectation.requireMatch.test(output)) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
reason: `its output never matched /${expectation.requireSource}/ — the check requires that proof of a real run`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return { ok: true };
|
|
308
|
+
}
|
|
309
|
+
|
|
206
310
|
// Static reproducibility lint: walk the project's package.json files and
|
|
207
311
|
// report the two "works on my machine" footguns the review hit — a package
|
|
208
312
|
// whose entry points live in a build-output dir with nothing that builds it
|
|
@@ -360,6 +464,23 @@ A manual check passes when it has been signed off and is otherwise reported
|
|
|
360
464
|
as "pending" — non-blocking by default, failing only under --strict (so CI
|
|
361
465
|
can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
|
|
362
466
|
|
|
467
|
+
OUTPUT EXPECTATIONS — fail-closed on a run that did nothing. An exit code
|
|
468
|
+
answers "did the runner crash", never "did the runner run anything": a suite
|
|
469
|
+
whose filter matched no cases prints "0 scenarios" and exits 0, and every
|
|
470
|
+
gate calls it a pass. Add an "expect" block to make the output part of the
|
|
471
|
+
verdict:
|
|
472
|
+
|
|
473
|
+
{ "name": "e2e", "run": "behave --tags @smoke",
|
|
474
|
+
"expect": { "fail_if_output_matches": "0 scenarios",
|
|
475
|
+
"require_output_matches": "\\\\d+ scenarios? passed" } }
|
|
476
|
+
|
|
477
|
+
Doctrina supplies no patterns of its own and knows nothing about what the
|
|
478
|
+
output means — the project declares the line that proves its run was real,
|
|
479
|
+
so this works for any runner in any language. A check with an "expect"
|
|
480
|
+
block runs piped (its output is echoed through when it finishes, rather
|
|
481
|
+
than streaming); every other check still streams. An "expect" pattern that
|
|
482
|
+
is not a valid regular expression fails at config time, not silently.
|
|
483
|
+
|
|
363
484
|
Flags:
|
|
364
485
|
--init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
|
|
365
486
|
--list Print the configured checks without running them
|
package/src/commands/watch.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { watch as fsWatch } from "node:fs";
|
|
4
5
|
import { exists } from "../lib/fs-ops.js";
|
|
5
6
|
import { flagBool } from "../lib/args.js";
|
|
6
7
|
import { c } from "../lib/colors.js";
|
|
8
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
7
9
|
import * as validate from "./validate.js";
|
|
8
10
|
import * as next from "./next.js";
|
|
9
11
|
|
|
@@ -15,11 +17,16 @@ import * as next from "./next.js";
|
|
|
15
17
|
// makes) so it never loops on its own output. `--once` runs a single pass and
|
|
16
18
|
// exits — the testable, scriptable form.
|
|
17
19
|
|
|
20
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
21
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
22
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
23
|
+
export const flags = { boolean: ["json", "once"], string: [] };
|
|
24
|
+
|
|
18
25
|
export async function run(_positional, flags) {
|
|
19
26
|
const projectRoot = process.cwd();
|
|
20
27
|
const dot = path.join(projectRoot, ".doctrina");
|
|
21
28
|
if (!exists(dot)) {
|
|
22
|
-
throw
|
|
29
|
+
throw notADoctrinaProject();
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
if (flagBool(flags, "once", false)) {
|
package/src/commands/why.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getSectionParagraph } from "../lib/doc-model.js";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
import { readdirSync } from "node:fs";
|
|
@@ -7,6 +9,7 @@ import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
|
7
9
|
import * as idx from "../lib/index-json.js";
|
|
8
10
|
import { c } from "../lib/colors.js";
|
|
9
11
|
import { suggest } from "../lib/suggest.js";
|
|
12
|
+
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
10
13
|
|
|
11
14
|
// Provenance explainer (review 2026-06-27 passive-user feature #5): answer
|
|
12
15
|
// "why does this capability exist / why was it built this way?" by assembling
|
|
@@ -16,11 +19,16 @@ import { suggest } from "../lib/suggest.js";
|
|
|
16
19
|
// asks; the tool answers from the artifacts, instead of grepping by hand.
|
|
17
20
|
// Read-only. Builds on the Realizes:/trace provenance (ADR 0006/0011).
|
|
18
21
|
|
|
22
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
23
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
24
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
25
|
+
export const flags = { boolean: ["json"], string: [] };
|
|
26
|
+
|
|
19
27
|
export async function run(positional, _flags) {
|
|
20
28
|
const cap = positional[0];
|
|
21
29
|
const projectRoot = process.cwd();
|
|
22
30
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
23
|
-
throw
|
|
31
|
+
throw notADoctrinaProject();
|
|
24
32
|
}
|
|
25
33
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
26
34
|
const known = isDir(specsDir) ? readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md"))) : [];
|
|
@@ -72,7 +80,7 @@ export async function run(positional, _flags) {
|
|
|
72
80
|
const status = specHeader(text, "Status") ?? "—";
|
|
73
81
|
const impl = specHeader(text, "Implementation") ?? "—";
|
|
74
82
|
console.log(` ${c.gray("status:")} ${status} ${c.gray("implementation:")} ${impl}`);
|
|
75
|
-
const purpose =
|
|
83
|
+
const purpose = getSectionParagraph(text, "Purpose");
|
|
76
84
|
if (purpose) console.log(" " + purpose.replace(/\s+/g, " ").trim());
|
|
77
85
|
|
|
78
86
|
// Dependency graph, both directions (the machine-readable **Depends on:**
|
|
@@ -229,28 +237,6 @@ function productAnchors(projectRoot) {
|
|
|
229
237
|
return out;
|
|
230
238
|
}
|
|
231
239
|
|
|
232
|
-
// First non-comment paragraph under a "## <name>" heading.
|
|
233
|
-
function sectionParagraph(text, name) {
|
|
234
|
-
const lines = text.split(/\r?\n/);
|
|
235
|
-
let inSection = false;
|
|
236
|
-
const buf = [];
|
|
237
|
-
for (const line of lines) {
|
|
238
|
-
if (/^##\s+/.test(line)) {
|
|
239
|
-
if (inSection) break;
|
|
240
|
-
if (new RegExp(`^##\\s+${name}\\b`, "i").test(line)) inSection = true;
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
if (!inSection) continue;
|
|
244
|
-
if (/^\s*<!--/.test(line) || /-->/.test(line)) continue;
|
|
245
|
-
if (line.trim() === "") {
|
|
246
|
-
if (buf.length) break;
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
buf.push(line.trim());
|
|
250
|
-
}
|
|
251
|
-
return buf.join(" ");
|
|
252
|
-
}
|
|
253
|
-
|
|
254
240
|
// Accepted ADRs whose body names the capability (word-ish boundary).
|
|
255
241
|
function decisionsMentioning(projectRoot, cap) {
|
|
256
242
|
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
package/src/commands/work.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
@@ -8,7 +9,9 @@ import * as idx from "../lib/index-json.js";
|
|
|
8
9
|
import { c } from "../lib/colors.js";
|
|
9
10
|
import { assessBrief } from "../lib/clarity.js";
|
|
10
11
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
12
|
+
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
13
|
import { changeNew } from "./change.js";
|
|
14
|
+
import { classify } from "./triage.js";
|
|
12
15
|
|
|
13
16
|
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
14
17
|
// prompt ("add login") becomes a fully scaffolded change plus a playbook
|
|
@@ -18,11 +21,16 @@ import { changeNew } from "./change.js";
|
|
|
18
21
|
// unproven verification). The CLI's own language processing stops at
|
|
19
22
|
// slugging and term counting; everything semantic is the agent's job.
|
|
20
23
|
|
|
24
|
+
// Flags this command accepts. Declared HERE, with the command, so
|
|
25
|
+
// adding a command never requires editing the entrypoint — the gap that
|
|
26
|
+
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
27
|
+
export const flags = { boolean: ["json", "chore", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
|
|
28
|
+
|
|
21
29
|
export async function run(positional, flags) {
|
|
22
30
|
const prompt = positional.join(" ").trim();
|
|
23
31
|
const projectRoot = process.cwd();
|
|
24
32
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
25
|
-
throw
|
|
33
|
+
throw notADoctrinaProject();
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
// --resume <id>: reprint the playbook for an existing open change rather than
|
|
@@ -65,6 +73,31 @@ export async function run(positional, flags) {
|
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
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
|
+
|
|
68
101
|
const pinned = flagString(flags, "capability");
|
|
69
102
|
if (pinned && !/^[a-z][a-z0-9-]*$/.test(pinned)) {
|
|
70
103
|
console.error(c.red("error:") + ` invalid capability "${pinned}" (lowercase letters, digits, hyphens)`);
|
|
@@ -538,6 +571,14 @@ Options:
|
|
|
538
571
|
prompt needed) and print a code-first playbook (F8)
|
|
539
572
|
--chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
|
|
540
573
|
playbook that skips the spec-delta steps (F9)
|
|
541
|
-
--force Open a new change even when one is open,
|
|
542
|
-
|
|
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.
|
|
543
584
|
`;
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { parseArgs } from "./lib/args.js";
|
|
4
5
|
import { c } from "./lib/colors.js";
|
|
5
6
|
import { suggest } from "./lib/suggest.js";
|
|
6
7
|
import { cliVersion } from "./lib/version.js";
|
|
7
|
-
import { surfaceHelp } from "./lib/commands.js";
|
|
8
|
+
import { surfaceHelp, OPERATIONS } from "./lib/commands.js";
|
|
9
|
+
import { GLOBAL_FLAGS } from "./lib/flag-catalog.js";
|
|
10
|
+
import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
|
|
11
|
+
import { recordUsage } from "./lib/usage.js";
|
|
12
|
+
import { wantsJson, emitJson, captureOutput, stripAnsi } from "./lib/json-out.js";
|
|
8
13
|
|
|
9
14
|
import * as init from "./commands/init.js";
|
|
10
15
|
import * as spec from "./commands/spec.js";
|
|
@@ -41,6 +46,8 @@ import * as report from "./commands/report.js";
|
|
|
41
46
|
import * as completion from "./commands/completion.js";
|
|
42
47
|
import * as intent from "./commands/intent.js";
|
|
43
48
|
import * as upgrade from "./commands/upgrade.js";
|
|
49
|
+
import * as adapter from "./commands/adapter.js";
|
|
50
|
+
import * as triage from "./commands/triage.js";
|
|
44
51
|
|
|
45
52
|
const COMMANDS = {
|
|
46
53
|
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
@@ -48,7 +55,7 @@ const COMMANDS = {
|
|
|
48
55
|
intake, work, coverage, verify, contract, trace,
|
|
49
56
|
status, close, review, watch, why, constitution,
|
|
50
57
|
prime, handoff, show, doctor, report, completion,
|
|
51
|
-
intent, upgrade,
|
|
58
|
+
intent, upgrade, adapter, triage,
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
const TOP_HELP = `
|
|
@@ -60,6 +67,10 @@ ${surfaceHelp()}
|
|
|
60
67
|
Global flags:
|
|
61
68
|
--help, -h Show this message (or per-command help if after a command)
|
|
62
69
|
--version, -v Print the version
|
|
70
|
+
--debug On an unexpected error, also print the stack trace
|
|
71
|
+
|
|
72
|
+
Exit codes (a contract — an agent reads these to decide what to do next):
|
|
73
|
+
${exitCodeHelp()}
|
|
63
74
|
`;
|
|
64
75
|
|
|
65
76
|
async function main(argv) {
|
|
@@ -69,8 +80,23 @@ async function main(argv) {
|
|
|
69
80
|
// version and exit, looking exactly like the spec's version had been set
|
|
70
81
|
// (0.11.0 field-review papercut). `-v` stays global either way.
|
|
71
82
|
const versionOnly = argv.every((t) => t === "--version" || t === "-v") && argv.length > 0;
|
|
83
|
+
|
|
84
|
+
// Two passes. The first resolves only the command name, using the global
|
|
85
|
+
// flags; the second re-parses with THAT command's declared flags merged in.
|
|
86
|
+
//
|
|
87
|
+
// One global boolean list used to serve every command, and six flags read
|
|
88
|
+
// via flagBool were missing from it. An undeclared flag whose next token
|
|
89
|
+
// does not start with "-" swallows that token as its value, so
|
|
90
|
+
// `change new --chore ajuste-ci "Ajustar CI"` lost the id AND silently
|
|
91
|
+
// ignored the flag, reporting "requires a title" for a quoted title (C3).
|
|
92
|
+
// Declarations now live with the command, so a new command cannot
|
|
93
|
+
// reintroduce the gap by forgetting to edit this file.
|
|
94
|
+
const bootstrap = parseArgs(argv, { boolean: [...GLOBAL_FLAGS.boolean] });
|
|
95
|
+
const commandModule = COMMANDS[bootstrap.positional[0]];
|
|
96
|
+
const spec = commandModule?.flags;
|
|
72
97
|
const { positional, flags } = parseArgs(argv, {
|
|
73
|
-
boolean: [
|
|
98
|
+
boolean: [...GLOBAL_FLAGS.boolean, ...(spec?.boolean ?? [])],
|
|
99
|
+
string: [...GLOBAL_FLAGS.string, ...(spec?.string ?? [])],
|
|
74
100
|
});
|
|
75
101
|
|
|
76
102
|
if (versionOnly || flags.get("v") === true) {
|
|
@@ -93,7 +119,7 @@ async function main(argv) {
|
|
|
93
119
|
} else {
|
|
94
120
|
console.error(c.gray("hint: ") + "try `doctrina --help` for the command surface");
|
|
95
121
|
}
|
|
96
|
-
return
|
|
122
|
+
return EXIT.USAGE;
|
|
97
123
|
}
|
|
98
124
|
|
|
99
125
|
if (flags.get("help") || flags.get("h")) {
|
|
@@ -102,14 +128,43 @@ async function main(argv) {
|
|
|
102
128
|
}
|
|
103
129
|
|
|
104
130
|
try {
|
|
131
|
+
// --json on a command that builds no payload of its own still answers in
|
|
132
|
+
// JSON: its output is captured into a versioned envelope beside `ok` and
|
|
133
|
+
// `exit_code`. Branch on those; the lines are for completeness (M7).
|
|
134
|
+
if (wantsJson(flags) && !command.jsonNative) {
|
|
135
|
+
const { code, stdout, stderr } = await captureOutput(
|
|
136
|
+
() => command.run(positional.slice(1), flags),
|
|
137
|
+
);
|
|
138
|
+
emitJson([commandName, ...positional.slice(1)].join(" "), {
|
|
139
|
+
stdout: stdout.map(stripAnsi),
|
|
140
|
+
stderr: stderr.map(stripAnsi),
|
|
141
|
+
}, { ok: code === EXIT.OK, exitCode: code });
|
|
142
|
+
return code;
|
|
143
|
+
}
|
|
105
144
|
return await command.run(positional.slice(1), flags);
|
|
106
145
|
} catch (err) {
|
|
107
146
|
console.error(c.red("error:") + ` ${err.message}`);
|
|
147
|
+
if (err.remedy) {
|
|
148
|
+
console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
|
|
149
|
+
}
|
|
108
150
|
if (flags.get("debug") && err.stack) {
|
|
109
151
|
console.error(c.gray(err.stack));
|
|
110
152
|
}
|
|
111
|
-
|
|
153
|
+
// A typed error carries its own class (precondition vs environment).
|
|
154
|
+
// An untyped throw is an unexpected failure of the work itself, which
|
|
155
|
+
// is the GATE class: something is wrong here, fix it and retry (C7).
|
|
156
|
+
return err.exitCode ?? EXIT.GATE;
|
|
112
157
|
}
|
|
113
158
|
}
|
|
114
159
|
|
|
115
|
-
main(process.argv.slice(2)).then((code) =>
|
|
160
|
+
main(process.argv.slice(2)).then((code) => {
|
|
161
|
+
// Record which operation ran, if and only if the operator asked for it
|
|
162
|
+
// by setting DOCTRINA_USAGE_LOG (M8). Off by default, local file only,
|
|
163
|
+
// no arguments captured, and never fatal. See lib/usage.js.
|
|
164
|
+
recordUsage(
|
|
165
|
+
process.argv.slice(2).filter((a) => !a.startsWith("-")),
|
|
166
|
+
code,
|
|
167
|
+
new Set(OPERATIONS.map((o) => o[0])),
|
|
168
|
+
);
|
|
169
|
+
process.exit(code ?? 0);
|
|
170
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
|
|
5
|
+
import { locateTemplatesDir, substitute } from "./templates.js";
|
|
6
|
+
|
|
7
|
+
// Agent adapters: the thin per-agent files that point at AGENTS.md.
|
|
8
|
+
//
|
|
9
|
+
// Installing one used to be reachable ONLY through a full `doctrina init`
|
|
10
|
+
// run, and `init` refuses to run twice — so `init --force` was the only
|
|
11
|
+
// path, and it regenerated AGENTS.md and product.md from blank templates,
|
|
12
|
+
// destroying every hand-authored rule and the entire product definition
|
|
13
|
+
// with no warning (audit item C1, data loss). This module owns adapters as
|
|
14
|
+
// their own concern so adding one can be strictly additive.
|
|
15
|
+
//
|
|
16
|
+
// Three states, which the old surface could not tell apart:
|
|
17
|
+
// installed — the adapter's files are present in the project
|
|
18
|
+
// available — the adapter ships files and none are installed
|
|
19
|
+
// native — the agent reads AGENTS.md directly and needs no file
|
|
20
|
+
// (its template dir carries only a README)
|
|
21
|
+
|
|
22
|
+
export const ADAPTER_STATES = Object.freeze({
|
|
23
|
+
INSTALLED: "installed",
|
|
24
|
+
AVAILABLE: "available",
|
|
25
|
+
NATIVE: "native",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Where a named adapter's templates live. A project-local adapter under
|
|
29
|
+
// `.doctrina/templates/adapters/<name>/` wins over the bundled one, so a
|
|
30
|
+
// team can ship an adapter for an agent Doctrina does not know about.
|
|
31
|
+
export function resolveAdapterDir(projectRoot, name) {
|
|
32
|
+
const local = path.join(projectRoot, ".doctrina", "templates", "adapters", name);
|
|
33
|
+
if (isDir(local) && walk(local).length > 0) return { dir: local, source: "project" };
|
|
34
|
+
const bundledRoot = path.join(locateTemplatesDir(), "adapters");
|
|
35
|
+
const bundled = path.join(bundledRoot, name);
|
|
36
|
+
if (isDir(bundled)) return { dir: bundled, source: "bundled" };
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Every adapter name known to this project: bundled plus project-local.
|
|
41
|
+
export function listAdapterNames(projectRoot) {
|
|
42
|
+
const names = new Set();
|
|
43
|
+
try {
|
|
44
|
+
const bundledRoot = path.join(locateTemplatesDir(), "adapters");
|
|
45
|
+
if (isDir(bundledRoot)) {
|
|
46
|
+
for (const e of readdirSync(bundledRoot)) {
|
|
47
|
+
if (isDir(path.join(bundledRoot, e))) names.add(e);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch { /* unusual install without a templates dir */ }
|
|
51
|
+
const localRoot = path.join(projectRoot, ".doctrina", "templates", "adapters");
|
|
52
|
+
if (isDir(localRoot)) {
|
|
53
|
+
for (const e of readdirSync(localRoot)) {
|
|
54
|
+
if (isDir(path.join(localRoot, e))) names.add(e);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...names].sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The files a named adapter would write, as project-relative POSIX paths
|
|
61
|
+
// paired with their template source. A README inside the adapter template
|
|
62
|
+
// dir documents the adapter and is never materialised.
|
|
63
|
+
export function adapterFiles(projectRoot, name) {
|
|
64
|
+
const resolved = resolveAdapterDir(projectRoot, name);
|
|
65
|
+
if (!resolved) return null;
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const templatePath of walk(resolved.dir)) {
|
|
68
|
+
const rel = relPath(resolved.dir, templatePath).replace(/\\/g, "/");
|
|
69
|
+
if (rel === "README.md" || rel.endsWith(".gitkeep")) continue;
|
|
70
|
+
out.push({
|
|
71
|
+
templatePath,
|
|
72
|
+
relativePath: rel.replace(/\.template$/, ""),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { source: resolved.source, files: out.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Adapter state plus the files backing it, for `adapter list`.
|
|
79
|
+
export function describeAdapter(projectRoot, name) {
|
|
80
|
+
const spec = adapterFiles(projectRoot, name);
|
|
81
|
+
if (!spec) return { name, state: ADAPTER_STATES.AVAILABLE, source: "unknown", files: [], installed: [] };
|
|
82
|
+
if (spec.files.length === 0) {
|
|
83
|
+
// Reads AGENTS.md natively — "no adapter installed" and "no adapter
|
|
84
|
+
// needed" were indistinguishable before this distinction existed.
|
|
85
|
+
return { name, state: ADAPTER_STATES.NATIVE, source: spec.source, files: [], installed: [] };
|
|
86
|
+
}
|
|
87
|
+
const installed = spec.files.filter((f) => isFile(path.join(projectRoot, f.relativePath)));
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
source: spec.source,
|
|
91
|
+
files: spec.files,
|
|
92
|
+
installed,
|
|
93
|
+
state: installed.length > 0 ? ADAPTER_STATES.INSTALLED : ADAPTER_STATES.AVAILABLE,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The AGENTS.md path an adapter file should point at, relative to its own
|
|
98
|
+
// location — a nested command file needs "../../AGENTS.md".
|
|
99
|
+
export function agentsMdPathFor(relativePath) {
|
|
100
|
+
const depth = relativePath.split("/").length - 1;
|
|
101
|
+
return depth === 0 ? "AGENTS.md" : "../".repeat(depth) + "AGENTS.md";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Is this adapter file a HUB POINTER — a file whose job is to route the
|
|
105
|
+
// agent at AGENTS.md?
|
|
106
|
+
//
|
|
107
|
+
// Declared by the template itself: a template that uses the
|
|
108
|
+
// {{AGENTS_MD_PATH}} token is saying "I point at the hub". Everything else
|
|
109
|
+
// is a command shim — a Claude/Cursor slash command like `/doctrina-status`
|
|
110
|
+
// whose job is to invoke the CLI, and which reaches the hub through its
|
|
111
|
+
// parent CLAUDE.md rather than by naming it.
|
|
112
|
+
//
|
|
113
|
+
// This distinction is the fix for audit item C2: the pointer check demanded
|
|
114
|
+
// the literal string "AGENTS.md" in EVERY adapter file, so a clean
|
|
115
|
+
// `init --agent claude` failed `templates check` on four command shims that
|
|
116
|
+
// were never pointers, and neither suggested remedy could clear it.
|
|
117
|
+
export function isHubPointer(file) {
|
|
118
|
+
return read(file.templatePath).includes("{{AGENTS_MD_PATH}}");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Render one adapter file's body for this project.
|
|
122
|
+
export function renderAdapterFile(file, tokens) {
|
|
123
|
+
return substitute(read(file.templatePath), {
|
|
124
|
+
...tokens,
|
|
125
|
+
AGENTS_MD_PATH: agentsMdPathFor(file.relativePath),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Is this project-relative path one that some adapter owns? Used by
|
|
130
|
+
// `adapter remove` so it deletes only what an adapter created, and by
|
|
131
|
+
// `init` so it never counts adapter files as user content.
|
|
132
|
+
export function isAdapterOwnedPath(projectRoot, relPathPosix) {
|
|
133
|
+
for (const name of listAdapterNames(projectRoot)) {
|
|
134
|
+
const spec = adapterFiles(projectRoot, name);
|
|
135
|
+
if (spec?.files.some((f) => f.relativePath === relPathPosix)) return true;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export { exists };
|
package/src/lib/adr-guard.js
CHANGED