doctrina-cli 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/analyze.js +8 -2
- package/src/commands/change.js +78 -7
- package/src/commands/clarify.js +76 -4
- package/src/commands/close.js +42 -9
- package/src/commands/completion.js +116 -0
- package/src/commands/constitution.js +6 -4
- package/src/commands/context.js +118 -25
- package/src/commands/coverage.js +185 -44
- package/src/commands/doctor.js +135 -0
- package/src/commands/handoff.js +94 -0
- package/src/commands/init.js +12 -1
- package/src/commands/intent.js +173 -0
- package/src/commands/next.js +32 -16
- package/src/commands/prime.js +118 -0
- package/src/commands/report.js +142 -0
- package/src/commands/review.js +25 -4
- package/src/commands/show.js +173 -0
- package/src/commands/skill.js +46 -7
- package/src/commands/spec.js +13 -3
- package/src/commands/status.js +70 -40
- package/src/commands/trace.js +15 -2
- package/src/commands/upgrade.js +129 -0
- package/src/commands/validate.js +150 -3
- package/src/commands/why.js +83 -10
- package/src/commands/work.js +45 -16
- package/src/index.js +20 -42
- package/src/lib/commands.js +76 -4
- package/src/lib/scan.js +16 -0
- package/templates/AGENTS.md.template +8 -4
- package/templates/README.md +7 -1
- package/templates/adapters/aider/CONVENTIONS.md.template +4 -3
- package/templates/spec.md.template +9 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { exists } from "../lib/fs-ops.js";
|
|
6
|
+
import { collectStatus } from "./status.js";
|
|
7
|
+
import { c } from "../lib/colors.js";
|
|
8
|
+
|
|
9
|
+
// Aggregate diagnostic: the one command to run when "something looks wrong"
|
|
10
|
+
// and you do not know which gate to ask. It sequences the existing checks —
|
|
11
|
+
// validate, index drift, clean-checkout lint, template shape — and reports
|
|
12
|
+
// each with its exact remediation command. A DRIVER over existing commands
|
|
13
|
+
// (like `close`): it adds no checks of its own, so it can never disagree
|
|
14
|
+
// with the authoritative gates it fronts.
|
|
15
|
+
|
|
16
|
+
const cliEntry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "index.js");
|
|
17
|
+
|
|
18
|
+
export async function run(_positional, _flags) {
|
|
19
|
+
const projectRoot = process.cwd();
|
|
20
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
21
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(c.bold("Doctrina doctor") + c.gray(" — aggregate diagnostic (read-only):"));
|
|
25
|
+
console.log("");
|
|
26
|
+
|
|
27
|
+
let failures = 0;
|
|
28
|
+
let warningsTotal = 0;
|
|
29
|
+
const row = (state, name, detail, fix) => {
|
|
30
|
+
const mark = state === "ok" ? c.green("ok ") : state === "warn" ? c.yellow("warn") : c.red("FAIL");
|
|
31
|
+
console.log(` ${mark} ${name.padEnd(16)} ${detail}`);
|
|
32
|
+
if (fix) console.log(` ${" ".repeat(16)} ${c.gray(`fix: ${fix}`)}`);
|
|
33
|
+
if (state === "fail") failures += 1;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// 1. Structural gate (validate), machine-read so the counts are exact.
|
|
37
|
+
const v = runSelf(["validate", "--json"], projectRoot);
|
|
38
|
+
let vParsed = null;
|
|
39
|
+
try {
|
|
40
|
+
vParsed = JSON.parse(v.stdout);
|
|
41
|
+
} catch {
|
|
42
|
+
vParsed = null;
|
|
43
|
+
}
|
|
44
|
+
if (!vParsed) {
|
|
45
|
+
row("fail", "validate", "did not produce a report", "doctrina validate");
|
|
46
|
+
} else if (!vParsed.ok) {
|
|
47
|
+
row("fail", "validate", `${vParsed.errors.length} error${vParsed.errors.length === 1 ? "" : "s"}, ${vParsed.warnings.length} warning${vParsed.warnings.length === 1 ? "" : "s"}`, "doctrina validate (then `doctrina validate --fix` for index drift)");
|
|
48
|
+
for (const e of vParsed.errors.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e}`);
|
|
49
|
+
} else if (vParsed.warnings.length > 0) {
|
|
50
|
+
warningsTotal += vParsed.warnings.length;
|
|
51
|
+
row("warn", "validate", `0 errors, ${vParsed.warnings.length} warning${vParsed.warnings.length === 1 ? "" : "s"}`, "doctrina validate (warnings listed there)");
|
|
52
|
+
} else {
|
|
53
|
+
row("ok", "validate", "all structural checks pass");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. Index ↔ tree drift.
|
|
57
|
+
const drift = runSelf(["index", "rebuild", "--check"], projectRoot);
|
|
58
|
+
if (drift.status === 0) row("ok", "index", "index.json matches the tree");
|
|
59
|
+
else row("fail", "index", "index.json has drifted from the tree", "doctrina validate --fix (or `doctrina index rebuild`)");
|
|
60
|
+
|
|
61
|
+
// 3. Evidence and provenance ratios (cheap summaries; strict gates confirm).
|
|
62
|
+
const s = collectStatus(projectRoot);
|
|
63
|
+
if (s.coverage.totalCriteria === 0) {
|
|
64
|
+
row("warn", "coverage", "no acceptance criteria declared yet", "write criteria with evidence — see `doctrina coverage`");
|
|
65
|
+
warningsTotal += 1;
|
|
66
|
+
} else if (s.coverage.pct === 100 && s.coverage.totalDangling === 0 && s.coverage.totalConditional === 0) {
|
|
67
|
+
row("ok", "coverage", `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria} criteria)`);
|
|
68
|
+
} else {
|
|
69
|
+
row("warn", "coverage", `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria}${s.coverage.totalDangling ? `, ${s.coverage.totalDangling} dangling` : ""}${s.coverage.totalConditional ? `, ${s.coverage.totalConditional} conditional` : ""})`, "doctrina coverage (cite the missing evidence)");
|
|
70
|
+
warningsTotal += 1;
|
|
71
|
+
}
|
|
72
|
+
if (s.trace.anchors === 0) {
|
|
73
|
+
row("warn", "trace", "no intent anchors declared in product.md", "tag bullets `- [SC1] ...`, add `**Realizes:**` to specs");
|
|
74
|
+
warningsTotal += 1;
|
|
75
|
+
} else if (s.trace.realized === s.trace.anchors && s.trace.untraceable === 0 && s.trace.dangling === 0) {
|
|
76
|
+
row("ok", "trace", `${s.trace.realized}/${s.trace.anchors} anchors realized`);
|
|
77
|
+
} else {
|
|
78
|
+
row("warn", "trace", `${s.trace.realized}/${s.trace.anchors} realized, ${s.trace.dropped} dropped, ${s.trace.untraceable} untraceable`, "doctrina trace");
|
|
79
|
+
warningsTotal += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4. Clean-checkout reproducibility lint (ADR 0008).
|
|
83
|
+
const clean = runSelf(["verify", "--clean"], projectRoot);
|
|
84
|
+
if (clean.status === 0) row("ok", "clean-checkout", "no reproducibility footguns detected");
|
|
85
|
+
else row("fail", "clean-checkout", "a fresh clone would not build/run as-is", "doctrina verify --clean (fix the listed package.json footguns)");
|
|
86
|
+
|
|
87
|
+
// 5. Template shape vs the running CLI's recommendation.
|
|
88
|
+
const tmpl = runSelf(["templates", "check"], projectRoot);
|
|
89
|
+
if (tmpl.status === 0) row("ok", "templates", "project follows the current template shape");
|
|
90
|
+
else {
|
|
91
|
+
row("warn", "templates", "recommended sections/fields are missing", "doctrina templates update (--write applies)");
|
|
92
|
+
warningsTotal += 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 6. Verify configuration (the real build gate must exist to be run).
|
|
96
|
+
if (s.verify.invalid) row("fail", "verify config", ".doctrina/verify.json is invalid JSON", "fix the JSON, then `doctrina verify`");
|
|
97
|
+
else if (!s.verify.configured) {
|
|
98
|
+
row("warn", "verify config", "no verify.json — the build gate is undeclared", "doctrina verify --init");
|
|
99
|
+
warningsTotal += 1;
|
|
100
|
+
} else {
|
|
101
|
+
row("ok", "verify config", `${s.verify.checks} check${s.verify.checks === 1 ? "" : "s"} declared (run \`doctrina verify\` to execute)`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log("");
|
|
105
|
+
if (failures > 0) {
|
|
106
|
+
console.log(c.red("fail") + ` ${failures} failing area${failures === 1 ? "" : "s"} — fix commands above, then re-run \`doctrina doctor\``);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
if (warningsTotal > 0) {
|
|
110
|
+
console.log(c.yellow("warn") + ` no failures; ${warningsTotal} advisory item${warningsTotal === 1 ? "" : "s"} above`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
console.log(c.green("ok") + " every diagnostic is green — `doctrina verify` remains the build gate to execute");
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function runSelf(args, cwd) {
|
|
118
|
+
return spawnSync(process.execPath, [cliEntry, ...args], {
|
|
119
|
+
cwd,
|
|
120
|
+
encoding: "utf8",
|
|
121
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const help = `
|
|
126
|
+
Usage: doctrina doctor
|
|
127
|
+
|
|
128
|
+
Aggregate diagnostic: run the structural gate (validate), the index
|
|
129
|
+
drift check, the coverage/trace ratios, the clean-checkout lint
|
|
130
|
+
(verify --clean), the template-shape check, and the verify-config
|
|
131
|
+
presence — each reported with its exact remediation command.
|
|
132
|
+
|
|
133
|
+
A driver over the existing commands; it adds no checks of its own.
|
|
134
|
+
Read-only. Exits 1 when any area fails, 0 otherwise (warnings allowed).
|
|
135
|
+
`;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists } from "../lib/fs-ops.js";
|
|
4
|
+
import { collectStatus } from "./status.js";
|
|
5
|
+
import { computeActions } from "./next.js";
|
|
6
|
+
import { openChanges } from "./prime.js";
|
|
7
|
+
import { today } from "../lib/dates.js";
|
|
8
|
+
import { c } from "../lib/colors.js";
|
|
9
|
+
|
|
10
|
+
// Session handoff note: everything the NEXT session (a fresh agent, a
|
|
11
|
+
// teammate, tomorrow's you) needs to resume without re-reading the tree —
|
|
12
|
+
// where the open work stands task-by-task, which gates are red, and the
|
|
13
|
+
// exact command to continue with. Doctrina's thesis is that context must
|
|
14
|
+
// survive across sessions; this command is that thesis for the in-flight
|
|
15
|
+
// change. It is a VIEW derived from the tree at call time, deliberately not
|
|
16
|
+
// a stored file: a saved note goes stale the moment work continues, the
|
|
17
|
+
// tree never does (no new home for facts).
|
|
18
|
+
|
|
19
|
+
export async function run(_positional, _flags) {
|
|
20
|
+
const projectRoot = process.cwd();
|
|
21
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
22
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const s = collectStatus(projectRoot);
|
|
26
|
+
const open = openChanges(projectRoot);
|
|
27
|
+
const actions = computeActions(projectRoot);
|
|
28
|
+
|
|
29
|
+
console.log(`# Doctrina handoff — ${s.project} (${today()})`);
|
|
30
|
+
console.log("");
|
|
31
|
+
|
|
32
|
+
// Gate digest — the red ones are what the resuming session fixes first.
|
|
33
|
+
console.log("## Where things stand");
|
|
34
|
+
console.log("");
|
|
35
|
+
console.log(`- index: ${s.indexState} · framework stamp: ${s.stamp ?? "—"} (CLI ${s.cli})`);
|
|
36
|
+
console.log(`- coverage: ${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria} criteria` +
|
|
37
|
+
(s.coverage.totalDangling ? `, ${s.coverage.totalDangling} dangling` : "") +
|
|
38
|
+
(s.coverage.totalConditional ? `, ${s.coverage.totalConditional} conditional` : "") + ")");
|
|
39
|
+
const tr = s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`;
|
|
40
|
+
console.log(`- trace: ${tr}` + (s.trace.untraceable ? ` (${s.trace.untraceable} untraceable)` : ""));
|
|
41
|
+
console.log(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared — run \`doctrina verify\`` : "not configured"}`);
|
|
42
|
+
|
|
43
|
+
// The in-flight work, task by task.
|
|
44
|
+
console.log("");
|
|
45
|
+
if (open.length === 0) {
|
|
46
|
+
console.log("## Open work");
|
|
47
|
+
console.log("");
|
|
48
|
+
console.log("- none — the tree is at rest; start with `doctrina work \"<prompt>\"`");
|
|
49
|
+
} else {
|
|
50
|
+
for (const ch of open) {
|
|
51
|
+
console.log(`## Open change \`${ch.id}\`${ch.title ? ` — ${ch.title}` : ""}`);
|
|
52
|
+
console.log("");
|
|
53
|
+
console.log(`- proposal status: ${ch.status}`);
|
|
54
|
+
if (ch.tasksTotal > 0) {
|
|
55
|
+
console.log(`- tasks: ${ch.tasksDone}/${ch.tasksTotal} checked`);
|
|
56
|
+
for (const t of ch.unchecked.slice(0, 8)) console.log(` - [ ] ${t}`);
|
|
57
|
+
if (ch.unchecked.length > 8) console.log(` - … ${ch.unchecked.length - 8} more in tasks.md`);
|
|
58
|
+
} else {
|
|
59
|
+
console.log("- tasks: no tasks.md checklist found");
|
|
60
|
+
}
|
|
61
|
+
console.log(`- resume with: \`doctrina work --resume ${ch.id}\` · close with: \`doctrina close ${ch.id}\``);
|
|
62
|
+
console.log("");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// What to do, in priority order.
|
|
67
|
+
console.log("## Next actions");
|
|
68
|
+
console.log("");
|
|
69
|
+
if (actions.length === 0) {
|
|
70
|
+
console.log("1. nothing pending — `doctrina next` will confirm; pick up new work");
|
|
71
|
+
} else {
|
|
72
|
+
actions.forEach((a, i) => console.log(`${i + 1}. ${a}`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log("");
|
|
76
|
+
console.log("*Generated read-only from the tree — regenerate anytime with `doctrina handoff`.*");
|
|
77
|
+
if (process.stdout.isTTY) {
|
|
78
|
+
console.log("");
|
|
79
|
+
console.log(c.gray("tip: pipe it — `doctrina handoff > note.md` — or paste it as the first message of the next session"));
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const help = `
|
|
85
|
+
Usage: doctrina handoff
|
|
86
|
+
|
|
87
|
+
Print a session handoff note in Markdown: gate digest, each open change
|
|
88
|
+
with its task-by-task progress and the exact resume command, and the
|
|
89
|
+
prioritised next actions. Made to be pasted as the first message of the
|
|
90
|
+
next agent session (or piped to a file).
|
|
91
|
+
|
|
92
|
+
Deliberately a derived view, not a stored file — the tree is the truth
|
|
93
|
+
and never goes stale; regenerate on demand. Read-only; always exits 0.
|
|
94
|
+
`;
|
package/src/commands/init.js
CHANGED
|
@@ -56,7 +56,18 @@ export async function run(_positional, flags) {
|
|
|
56
56
|
description = await ask("One-sentence project description:");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
let agentSelector = flagString(flags, "agent");
|
|
60
|
+
// Interactive wizard step: when no --agent was given and we are on a real
|
|
61
|
+
// terminal, offer the adapter install instead of silently skipping it —
|
|
62
|
+
// first-run users rarely know the flag exists. Never fires in pipes/CI
|
|
63
|
+
// (no TTY) or under --non-interactive, so scripted init is unchanged.
|
|
64
|
+
if (agentSelector === undefined && !nonInteractive && process.stdin.isTTY && process.stdout.isTTY) {
|
|
65
|
+
const answer = await ask(
|
|
66
|
+
`Install an agent adapter? (${SUPPORTED_AGENTS.join("/")}/all/none)`,
|
|
67
|
+
{ defaultValue: "none" },
|
|
68
|
+
);
|
|
69
|
+
if (answer && answer !== "none") agentSelector = answer;
|
|
70
|
+
}
|
|
60
71
|
const fromPath = flagString(flags, "from");
|
|
61
72
|
const agentsMdPath = path.join(projectRoot, "AGENTS.md");
|
|
62
73
|
const doctrinaDir = path.join(projectRoot, ".doctrina");
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { c } from "../lib/colors.js";
|
|
5
|
+
import { suggest } from "../lib/suggest.js";
|
|
6
|
+
|
|
7
|
+
// Post-intake intent evolution (0.11.0 field review item 8). The intake is
|
|
8
|
+
// converted once; capabilities born later — brainstorms, pivots, new asks —
|
|
9
|
+
// had no way to gain a product anchor, so they inevitably ended at
|
|
10
|
+
// `Realizes: n/a` and `trace` reported "14/14 ok" while blind to 40% of the
|
|
11
|
+
// system. `intent add` appends a new [SC*] anchor bullet to product.md's
|
|
12
|
+
// Success criteria (allocating the next number), so growth keeps provenance:
|
|
13
|
+
// new intent gets an anchor, the new spec Realizes it, `trace` sees it.
|
|
14
|
+
//
|
|
15
|
+
// The CLI allocates and appends; WHAT the intent says is the human/agent's
|
|
16
|
+
// words verbatim (ADR 0005).
|
|
17
|
+
|
|
18
|
+
const SUBCOMMANDS = ["add", "list"];
|
|
19
|
+
|
|
20
|
+
export async function run(positional, _flags) {
|
|
21
|
+
const sub = positional[0];
|
|
22
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
23
|
+
console.error(c.red("error:") + ` unknown intent subcommand "${sub ?? ""}"`);
|
|
24
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
25
|
+
console.error(c.gray("hint: ") + (guess
|
|
26
|
+
? `did you mean \`doctrina intent ${guess}\`?`
|
|
27
|
+
: `available: ${SUBCOMMANDS.join(", ")}`));
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
const projectRoot = process.cwd();
|
|
31
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
32
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
33
|
+
}
|
|
34
|
+
if (sub === "list") return intentList(projectRoot);
|
|
35
|
+
return intentAdd(projectRoot, positional.slice(1).join(" ").trim());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function productPathOf(projectRoot) {
|
|
39
|
+
return path.join(projectRoot, ".doctrina", "product.md");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Every "[A-Z]+\d+" anchor at the head of a bullet, in document order —
|
|
43
|
+
// the same convention `trace` reads.
|
|
44
|
+
function collectAnchors(text) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const lines = text.split(/\r?\n/);
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+)(\d+)\]\s+(.*)$/);
|
|
50
|
+
if (m && !seen.has(m[1] + m[2])) {
|
|
51
|
+
seen.add(m[1] + m[2]);
|
|
52
|
+
out.push({ prefix: m[1], num: Number(m[2]), id: m[1] + m[2], text: m[3].trim(), line: i });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function intentList(projectRoot) {
|
|
59
|
+
const p = productPathOf(projectRoot);
|
|
60
|
+
if (!isFile(p)) {
|
|
61
|
+
console.error(c.red("error:") + " no .doctrina/product.md");
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
const anchors = collectAnchors(read(p));
|
|
65
|
+
if (anchors.length === 0) {
|
|
66
|
+
console.log(c.gray("no intent anchors in product.md — tag bullets with `- [SC1] ...`, or add one: doctrina intent add \"<text>\""));
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
console.log(c.bold("Intent anchors") + c.gray(" — product.md, document order:"));
|
|
70
|
+
console.log("");
|
|
71
|
+
for (const a of anchors) {
|
|
72
|
+
console.log(` ${c.cyan(a.id.padEnd(6))} ${a.text}`);
|
|
73
|
+
}
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log(c.gray(`${anchors.length} anchor${anchors.length === 1 ? "" : "s"}. Provenance: doctrina trace · doctrina why <id>`));
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function intentAdd(projectRoot, textArg) {
|
|
80
|
+
if (!textArg) {
|
|
81
|
+
console.error(c.red("error:") + " intent add requires the intent text (quote it)");
|
|
82
|
+
console.error(c.gray("hint: ") + `example: doctrina intent add "Risk map answers 'what should we test next?' in one read"`);
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
const p = productPathOf(projectRoot);
|
|
86
|
+
if (!isFile(p)) {
|
|
87
|
+
console.error(c.red("error:") + " no .doctrina/product.md — run `doctrina init` / `doctrina intake` first");
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
const original = read(p);
|
|
91
|
+
const anchors = collectAnchors(original);
|
|
92
|
+
|
|
93
|
+
// Accept "SC15: text" to pin an explicit id; otherwise allocate the next
|
|
94
|
+
// number for the dominant prefix (SC by default).
|
|
95
|
+
let id = null;
|
|
96
|
+
let text = textArg;
|
|
97
|
+
const pinned = textArg.match(/^([A-Z]+)(\d+)\s*:\s*(.+)$/);
|
|
98
|
+
if (pinned) {
|
|
99
|
+
id = pinned[1] + pinned[2];
|
|
100
|
+
text = pinned[3].trim();
|
|
101
|
+
if (anchors.some((a) => a.id === id)) {
|
|
102
|
+
console.error(c.red("error:") + ` anchor [${id}] already exists in product.md`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
const prefix = anchors.length > 0 ? dominantPrefix(anchors) : "SC";
|
|
107
|
+
const maxNum = anchors.filter((a) => a.prefix === prefix).reduce((m, a) => Math.max(m, a.num), 0);
|
|
108
|
+
id = `${prefix}${maxNum + 1}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines = original.split(/\r?\n/);
|
|
112
|
+
const bullet = `- [${id}] ${text}`;
|
|
113
|
+
|
|
114
|
+
// Insertion point: after the LAST existing anchor bullet (keeps them
|
|
115
|
+
// contiguous); else at the end of "## Success criteria"; else appended as a
|
|
116
|
+
// new Success criteria section (a product.md without one gains it).
|
|
117
|
+
const samePrefix = anchors.length > 0;
|
|
118
|
+
let inserted = false;
|
|
119
|
+
if (samePrefix) {
|
|
120
|
+
const last = anchors[anchors.length - 1];
|
|
121
|
+
lines.splice(last.line + 1, 0, bullet);
|
|
122
|
+
inserted = true;
|
|
123
|
+
} else {
|
|
124
|
+
for (let i = 0; i < lines.length; i++) {
|
|
125
|
+
if (/^##\s+Success criteria\b/i.test(lines[i])) {
|
|
126
|
+
// Insert before the next "## " heading (or at EOF).
|
|
127
|
+
let j = i + 1;
|
|
128
|
+
while (j < lines.length && !/^##\s+/.test(lines[j])) j += 1;
|
|
129
|
+
// Trim trailing blank lines inside the section for a tidy append.
|
|
130
|
+
let k = j;
|
|
131
|
+
while (k > i + 1 && lines[k - 1].trim() === "") k -= 1;
|
|
132
|
+
lines.splice(k, 0, bullet);
|
|
133
|
+
inserted = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!inserted) {
|
|
139
|
+
lines.push("", "## Success criteria", "", bullet);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
write(p, lines.join("\n"), { force: true });
|
|
143
|
+
console.log(c.green("added") + ` [${id}] to ${relPath(projectRoot, p)}`);
|
|
144
|
+
console.log("");
|
|
145
|
+
console.log(` ${c.cyan(id.padEnd(6))} ${text}`);
|
|
146
|
+
console.log("");
|
|
147
|
+
console.log(c.gray("Next: the spec that delivers this intent declares it —"));
|
|
148
|
+
console.log(` ${c.cyan(`**Realizes:** ${id}`)} ${c.gray("(in .doctrina/specs/<capability>/spec.md; `doctrina trace` closes the loop)")}`);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function dominantPrefix(anchors) {
|
|
153
|
+
const counts = new Map();
|
|
154
|
+
for (const a of anchors) counts.set(a.prefix, (counts.get(a.prefix) ?? 0) + 1);
|
|
155
|
+
return [...counts.entries()].sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const help = `
|
|
159
|
+
Usage: doctrina intent add "<text>"
|
|
160
|
+
doctrina intent add "SC15: <text>"
|
|
161
|
+
doctrina intent list
|
|
162
|
+
|
|
163
|
+
Post-intake intent evolution. \`add\` appends a new intent anchor bullet
|
|
164
|
+
(\`- [SC5] <text>\`) to product.md's Success criteria, allocating the next
|
|
165
|
+
number automatically (or pin one with the "SC15: ..." form). New
|
|
166
|
+
capabilities born after the intake — brainstorms, pivots — get an anchor
|
|
167
|
+
to Realize instead of inevitably landing at "Realizes: n/a", so
|
|
168
|
+
\`doctrina trace\` keeps seeing the whole system as it grows.
|
|
169
|
+
|
|
170
|
+
\`list\` prints every anchor in document order (the same convention trace
|
|
171
|
+
reads). The CLI allocates and appends; the intent's wording is yours,
|
|
172
|
+
verbatim (ADR 0005).
|
|
173
|
+
`;
|
package/src/commands/next.js
CHANGED
|
@@ -4,15 +4,43 @@ import { readdirSync } from "node:fs";
|
|
|
4
4
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
5
|
import * as idx from "../lib/index-json.js";
|
|
6
6
|
import { deriveIndex, indexesMatch, listHeader } from "../lib/scan.js";
|
|
7
|
+
import { flagBool } from "../lib/args.js";
|
|
7
8
|
import { c } from "../lib/colors.js";
|
|
8
9
|
|
|
9
|
-
export async function run(_positional,
|
|
10
|
+
export async function run(_positional, flags) {
|
|
10
11
|
const projectRoot = process.cwd();
|
|
12
|
+
const json = flagBool(flags, "json", false);
|
|
11
13
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
12
|
-
|
|
14
|
+
const first = "doctrina init — this directory is not a Doctrina project yet";
|
|
15
|
+
console.log(json ? JSON.stringify({ actions: [first] }, null, 2) : `1. ${first}`);
|
|
13
16
|
return 0;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
const actions = computeActions(projectRoot);
|
|
20
|
+
|
|
21
|
+
if (json) {
|
|
22
|
+
console.log(JSON.stringify({ actions }, null, 2));
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (actions.length === 0) {
|
|
27
|
+
console.log(c.green("ok") + " no open work.");
|
|
28
|
+
console.log("");
|
|
29
|
+
console.log("Start something:");
|
|
30
|
+
console.log(` doctrina change new <id> "<title>" open a change proposal`);
|
|
31
|
+
console.log(` doctrina spec new <capability> spec a new capability`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(c.bold("Next actions") + c.gray(" (in priority order):"));
|
|
36
|
+
console.log("");
|
|
37
|
+
actions.forEach((a, i) => console.log(`${i + 1}. ${a}`));
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The priority-ordered action list, render-free — shared with `prime`,
|
|
42
|
+
// `handoff`, and the --json output.
|
|
43
|
+
export function computeActions(projectRoot) {
|
|
16
44
|
const actions = [];
|
|
17
45
|
|
|
18
46
|
// A pending intake is the very first thing to resolve: until it is
|
|
@@ -106,19 +134,7 @@ export async function run(_positional, _flags) {
|
|
|
106
134
|
actions.push("doctrina index rebuild — index.json is missing or unreadable");
|
|
107
135
|
}
|
|
108
136
|
|
|
109
|
-
|
|
110
|
-
console.log(c.green("ok") + " no open work.");
|
|
111
|
-
console.log("");
|
|
112
|
-
console.log("Start something:");
|
|
113
|
-
console.log(` doctrina change new <id> "<title>" open a change proposal`);
|
|
114
|
-
console.log(` doctrina spec new <capability> spec a new capability`);
|
|
115
|
-
return 0;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
console.log(c.bold("Next actions") + c.gray(" (in priority order):"));
|
|
119
|
-
console.log("");
|
|
120
|
-
actions.forEach((a, i) => console.log(`${i + 1}. ${a}`));
|
|
121
|
-
return 0;
|
|
137
|
+
return actions;
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
// Return a single skill-capture nudge, or null. Fires only when no skill has
|
|
@@ -147,7 +163,7 @@ function suggestSkillCapture(projectRoot) {
|
|
|
147
163
|
}
|
|
148
164
|
|
|
149
165
|
export const help = `
|
|
150
|
-
Usage: doctrina next
|
|
166
|
+
Usage: doctrina next [--json]
|
|
151
167
|
|
|
152
168
|
Inspect the .doctrina/ tree and print the recommended next workflow
|
|
153
169
|
actions in priority order: open changes (missing proposal, unchecked
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
5
|
+
import { listHeader } from "../lib/scan.js";
|
|
6
|
+
import { collectStatus } from "./status.js";
|
|
7
|
+
import { computeActions } from "./next.js";
|
|
8
|
+
import { acceptedDecisions, productSection } from "./constitution.js";
|
|
9
|
+
import { c } from "../lib/colors.js";
|
|
10
|
+
|
|
11
|
+
// Session primer: the ~40-line read that orients an agent at the start of a
|
|
12
|
+
// session — where things stand, what the standing rules are, what work is
|
|
13
|
+
// open, and what to do next — without paying for the full context pack.
|
|
14
|
+
// It sits between `status` (numbers only) and `context --concat` (everything):
|
|
15
|
+
// enough to act, cheap enough to run every session. Strictly read-only; every
|
|
16
|
+
// line is assembled from artifacts that already own the fact (no new home).
|
|
17
|
+
|
|
18
|
+
export async function run(_positional, _flags) {
|
|
19
|
+
const projectRoot = process.cwd();
|
|
20
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
21
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const s = collectStatus(projectRoot);
|
|
25
|
+
|
|
26
|
+
console.log(c.bold("Doctrina prime") + c.gray(` — ${s.project} (framework ${s.stamp ?? "—"} / CLI ${s.cli})`));
|
|
27
|
+
console.log("");
|
|
28
|
+
|
|
29
|
+
// One-line gate digest.
|
|
30
|
+
const cov = `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria})`;
|
|
31
|
+
const tr = s.trace.anchors === 0 ? "no anchors" : `${s.trace.realized}/${s.trace.anchors}`;
|
|
32
|
+
const verify = s.verify.configured ? `${s.verify.checks} verify checks` : "verify not configured";
|
|
33
|
+
console.log(c.bold("Gates ") + `index ${s.indexState} · coverage ${cov} · trace ${tr} · ${verify}`);
|
|
34
|
+
const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
|
|
35
|
+
console.log(
|
|
36
|
+
c.bold("Work ") +
|
|
37
|
+
`${s.specs.total} specs${implBreak ? ` (${implBreak})` : ""} · ${s.specs.openChanges} open change${s.specs.openChanges === 1 ? "" : "s"} · ` +
|
|
38
|
+
`${s.decisions.total} decisions · ${s.skills} skills`,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Standing rules, titles only — `constitution` has the full text.
|
|
42
|
+
const adrs = acceptedDecisions(projectRoot);
|
|
43
|
+
console.log("");
|
|
44
|
+
console.log(c.bold("Rules") + c.gray(` (${adrs.length} accepted ADRs — \`doctrina constitution\` for detail)`));
|
|
45
|
+
for (const a of adrs) console.log(` ${c.cyan(a.id)} ${a.title}`);
|
|
46
|
+
const nonGoals = productSection(projectRoot, "Non-goals");
|
|
47
|
+
if (nonGoals.length > 0) {
|
|
48
|
+
console.log(c.gray(` + ${nonGoals.length} non-goal${nonGoals.length === 1 ? "" : "s"} declared in product.md`));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Open work, with unchecked-task counts — the resume points.
|
|
52
|
+
console.log("");
|
|
53
|
+
console.log(c.bold("Open work"));
|
|
54
|
+
const open = openChanges(projectRoot);
|
|
55
|
+
if (open.length === 0) {
|
|
56
|
+
console.log(c.gray(" none — `doctrina work \"<prompt>\"` opens the next change"));
|
|
57
|
+
} else {
|
|
58
|
+
for (const ch of open) {
|
|
59
|
+
const tasks = ch.tasksTotal > 0 ? ` · tasks ${ch.tasksDone}/${ch.tasksTotal}` : "";
|
|
60
|
+
console.log(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks})`)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Next actions (top 5 — `doctrina next` for the full list).
|
|
65
|
+
const actions = computeActions(projectRoot);
|
|
66
|
+
console.log("");
|
|
67
|
+
console.log(c.bold("Next"));
|
|
68
|
+
if (actions.length === 0) {
|
|
69
|
+
console.log(c.gray(" nothing pending — pick up new work"));
|
|
70
|
+
} else {
|
|
71
|
+
actions.slice(0, 5).forEach((a, i) => console.log(` ${i + 1}. ${a}`));
|
|
72
|
+
if (actions.length > 5) console.log(c.gray(` … ${actions.length - 5} more — \`doctrina next\``));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log("");
|
|
76
|
+
console.log(c.gray("Read deeper: `doctrina context [<cap>] --concat` · `doctrina why <cap>` · `doctrina show <ref>`"));
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Open changes with their proposal status/title and task progress.
|
|
81
|
+
export function openChanges(projectRoot) {
|
|
82
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
83
|
+
const out = [];
|
|
84
|
+
if (!isDir(changesDir)) return out;
|
|
85
|
+
for (const id of readdirSync(changesDir).sort()) {
|
|
86
|
+
if (id === "archive" || id.startsWith(".")) continue;
|
|
87
|
+
if (!isDir(path.join(changesDir, id))) continue;
|
|
88
|
+
const proposalPath = path.join(changesDir, id, "proposal.md");
|
|
89
|
+
const proposal = isFile(proposalPath) ? read(proposalPath) : null;
|
|
90
|
+
const title = proposal?.match(/^#\s+(?:Change\s+[^—-]*[—-]\s*)?(.+)$/m)?.[1]?.trim() ?? null;
|
|
91
|
+
const status = proposal ? (listHeader(proposal, "Status") ?? "proposed") : "no proposal.md";
|
|
92
|
+
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
93
|
+
let tasksDone = 0, tasksTotal = 0, unchecked = [];
|
|
94
|
+
if (isFile(tasksPath)) {
|
|
95
|
+
for (const line of read(tasksPath).split(/\r?\n/)) {
|
|
96
|
+
const m = line.match(/^-\s+\[([ xX])\]\s+(.*)$/);
|
|
97
|
+
if (!m) continue;
|
|
98
|
+
tasksTotal += 1;
|
|
99
|
+
if (m[1] === " ") unchecked.push(m[2].trim());
|
|
100
|
+
else tasksDone += 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
out.push({ id, title, status, tasksDone, tasksTotal, unchecked });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const help = `
|
|
109
|
+
Usage: doctrina prime
|
|
110
|
+
|
|
111
|
+
Print the session primer: gate digest, standing rules (accepted ADR
|
|
112
|
+
titles + non-goal count), open work with task progress, and the top
|
|
113
|
+
next actions — the ~40-line read that orients an agent at session
|
|
114
|
+
start without paying for the full context pack.
|
|
115
|
+
|
|
116
|
+
Read-only; always exits 0. Deeper reads: \`doctrina context\`,
|
|
117
|
+
\`doctrina why <cap>\`, \`doctrina show <ref>\`.
|
|
118
|
+
`;
|