doctrina-cli 0.10.0 → 0.11.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/completion.js +116 -0
- package/src/commands/constitution.js +6 -4
- package/src/commands/context.js +108 -24
- package/src/commands/coverage.js +41 -16
- package/src/commands/doctor.js +135 -0
- package/src/commands/handoff.js +94 -0
- package/src/commands/init.js +12 -1
- package/src/commands/next.js +32 -16
- package/src/commands/prime.js +118 -0
- package/src/commands/report.js +142 -0
- package/src/commands/show.js +173 -0
- package/src/commands/status.js +70 -40
- package/src/commands/trace.js +15 -2
- package/src/commands/validate.js +33 -1
- package/src/commands/why.js +72 -9
- package/src/index.js +10 -41
- package/src/lib/commands.js +72 -3
- package/templates/README.md +7 -1
- package/templates/adapters/aider/CONVENTIONS.md.template +4 -3
|
@@ -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");
|
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
|
+
`;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { exists } from "../lib/fs-ops.js";
|
|
5
|
+
import * as idx from "../lib/index-json.js";
|
|
6
|
+
import { collectStatus } from "./status.js";
|
|
7
|
+
import { openChanges } from "./prime.js";
|
|
8
|
+
import { flagString } from "../lib/args.js";
|
|
9
|
+
import { today } from "../lib/dates.js";
|
|
10
|
+
import { c } from "../lib/colors.js";
|
|
11
|
+
|
|
12
|
+
// Periodic digest in Markdown — the standup/PR-description view: what was
|
|
13
|
+
// archived, where the gates stand, and what local git says about the period.
|
|
14
|
+
// Read-only, no network; the git section degrades silently outside a repo.
|
|
15
|
+
|
|
16
|
+
export async function run(_positional, flags) {
|
|
17
|
+
const projectRoot = process.cwd();
|
|
18
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
19
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sinceRaw = flagString(flags, "since") ?? "7";
|
|
23
|
+
const days = Number.parseInt(sinceRaw, 10);
|
|
24
|
+
if (!Number.isFinite(days) || days <= 0) {
|
|
25
|
+
console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
|
|
26
|
+
return 2;
|
|
27
|
+
}
|
|
28
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
29
|
+
const cutoffIso = cutoff.toISOString().slice(0, 10);
|
|
30
|
+
|
|
31
|
+
const s = collectStatus(projectRoot);
|
|
32
|
+
|
|
33
|
+
console.log(`# Doctrina report — ${s.project} (${cutoffIso} → ${today()})`);
|
|
34
|
+
console.log("");
|
|
35
|
+
|
|
36
|
+
// Gates.
|
|
37
|
+
console.log("## Gates");
|
|
38
|
+
console.log("");
|
|
39
|
+
console.log(`- index: ${s.indexState} · framework stamp: ${s.stamp ?? "—"} (CLI ${s.cli})`);
|
|
40
|
+
console.log(`- coverage: ${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria} criteria)` +
|
|
41
|
+
(s.coverage.totalDangling ? ` — ${s.coverage.totalDangling} dangling` : "") +
|
|
42
|
+
(s.coverage.totalConditional ? ` — ${s.coverage.totalConditional} conditional` : ""));
|
|
43
|
+
console.log(`- trace: ${s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`}`);
|
|
44
|
+
console.log(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared` : "not configured"}`);
|
|
45
|
+
|
|
46
|
+
// Work shipped in the window (from the index ledger) + work in flight.
|
|
47
|
+
console.log("");
|
|
48
|
+
console.log("## Changes");
|
|
49
|
+
console.log("");
|
|
50
|
+
const archived = archivedSince(projectRoot, cutoffIso);
|
|
51
|
+
if (archived.length === 0) {
|
|
52
|
+
console.log(`- archived in period: none`);
|
|
53
|
+
} else {
|
|
54
|
+
console.log(`- archived in period: ${archived.length}`);
|
|
55
|
+
for (const ch of archived) console.log(` - ${ch.applied} — \`${ch.id}\` ${ch.title}`);
|
|
56
|
+
}
|
|
57
|
+
const open = openChanges(projectRoot);
|
|
58
|
+
if (open.length === 0) {
|
|
59
|
+
console.log("- open now: none");
|
|
60
|
+
} else {
|
|
61
|
+
console.log(`- open now: ${open.length}`);
|
|
62
|
+
for (const ch of open) {
|
|
63
|
+
const tasks = ch.tasksTotal > 0 ? ` (tasks ${ch.tasksDone}/${ch.tasksTotal})` : "";
|
|
64
|
+
console.log(` - \`${ch.id}\`${ch.title ? ` ${ch.title}` : ""}${tasks}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Artifact counts, for trend-watching between reports.
|
|
69
|
+
console.log("");
|
|
70
|
+
console.log("## Artifacts");
|
|
71
|
+
console.log("");
|
|
72
|
+
console.log(`- specs: ${s.specs.total} · decisions: ${s.decisions.total}` +
|
|
73
|
+
(s.decisions.proposed ? ` (${s.decisions.proposed} proposed)` : "") +
|
|
74
|
+
` · skills: ${s.skills}`);
|
|
75
|
+
|
|
76
|
+
// Local git window — commits, fix share, top churn. No network.
|
|
77
|
+
console.log("");
|
|
78
|
+
console.log("## Git (local, last " + days + " days)");
|
|
79
|
+
console.log("");
|
|
80
|
+
const git = gitWindow(projectRoot, days);
|
|
81
|
+
if (!git) {
|
|
82
|
+
console.log("- no git history available (not a repository, or git not installed)");
|
|
83
|
+
} else {
|
|
84
|
+
console.log(`- commits: ${git.commits} (${git.fixes} fix-shaped, ${git.commits ? Math.round((git.fixes / git.commits) * 100) : 0}%)`);
|
|
85
|
+
if (git.churn.length > 0) {
|
|
86
|
+
console.log(`- top-churn files:`);
|
|
87
|
+
for (const [file, n] of git.churn) console.log(` - ${file} (${n} touches)`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(`*Generated read-only by \`doctrina report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function archivedSince(projectRoot, cutoffIso) {
|
|
97
|
+
let index;
|
|
98
|
+
try {
|
|
99
|
+
index = idx.load(projectRoot);
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
return (index.artifacts?.changes_archive ?? [])
|
|
104
|
+
.filter((ch) => typeof ch.applied === "string" && ch.applied >= cutoffIso)
|
|
105
|
+
.sort((a, b) => String(a.applied).localeCompare(String(b.applied)));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function gitWindow(projectRoot, days) {
|
|
109
|
+
const log = spawnSync("git", ["log", `--since=${days} days ago`, "--pretty=%s"], {
|
|
110
|
+
cwd: projectRoot,
|
|
111
|
+
encoding: "utf8",
|
|
112
|
+
});
|
|
113
|
+
if (log.status !== 0) return null;
|
|
114
|
+
const subjects = log.stdout.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
115
|
+
const fixes = subjects.filter((sub) => /^(fix|bug|hotfix|patch)(\(|:|!)/i.test(sub)).length;
|
|
116
|
+
|
|
117
|
+
const files = spawnSync("git", ["log", `--since=${days} days ago`, "--name-only", "--pretty=format:"], {
|
|
118
|
+
cwd: projectRoot,
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
});
|
|
121
|
+
const counts = new Map();
|
|
122
|
+
if (files.status === 0) {
|
|
123
|
+
for (const line of files.stdout.split(/\r?\n/)) {
|
|
124
|
+
const f = line.trim();
|
|
125
|
+
if (!f) continue;
|
|
126
|
+
counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const churn = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
130
|
+
return { commits: subjects.length, fixes, churn };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const help = `
|
|
134
|
+
Usage: doctrina report [--since <days>]
|
|
135
|
+
|
|
136
|
+
Print a Markdown digest for the period (default: last 7 days): gate
|
|
137
|
+
state, changes archived in the window (from the index ledger), open
|
|
138
|
+
work with task progress, artifact counts, and a local-git summary
|
|
139
|
+
(commits, fix share, top-churn files). Made for standups and PR
|
|
140
|
+
descriptions. Read-only; no network. \`doctrina metrics\` has the
|
|
141
|
+
deeper git-derived numbers.
|
|
142
|
+
`;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
5
|
+
import { parseAcceptanceCriteria } from "../lib/criteria.js";
|
|
6
|
+
import { suggest } from "../lib/suggest.js";
|
|
7
|
+
import { c } from "../lib/colors.js";
|
|
8
|
+
|
|
9
|
+
// Point reads. An agent that needs ONE requirement re-reads a whole spec —
|
|
10
|
+
// hundreds of lines for a two-line fact. `show` resolves a compact reference
|
|
11
|
+
// to exactly the lines that own it:
|
|
12
|
+
//
|
|
13
|
+
// doctrina show cli-R12 twelfth requirement of the cli spec (file order)
|
|
14
|
+
// doctrina show gates-C3 acceptance criterion 3 (the spec's own numbering)
|
|
15
|
+
// doctrina show 0007 ADR 0007, whole decision (already a bounded read)
|
|
16
|
+
// doctrina show cli the spec's header block + Purpose only
|
|
17
|
+
//
|
|
18
|
+
// R-refs are positional (file order across the EARS sections), so they shift
|
|
19
|
+
// when a requirement is inserted above — cite them for point reads and
|
|
20
|
+
// conversation, not as immutable identifiers. C-refs use the criteria's own
|
|
21
|
+
// explicit numbers and are as stable as the spec keeps them.
|
|
22
|
+
|
|
23
|
+
export async function run(positional, _flags) {
|
|
24
|
+
const ref = positional[0];
|
|
25
|
+
const projectRoot = process.cwd();
|
|
26
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
27
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
28
|
+
}
|
|
29
|
+
if (!ref) {
|
|
30
|
+
console.error(c.red("error:") + " show requires a reference");
|
|
31
|
+
console.error(c.gray("hint: ") + "`doctrina show cli-R12` (requirement), `doctrina show cli-C3` (criterion), `doctrina show 0007` (ADR), `doctrina show cli` (spec header + purpose)");
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ADR by number.
|
|
36
|
+
if (/^\d{4}$/.test(ref)) return showAdr(projectRoot, ref);
|
|
37
|
+
|
|
38
|
+
const m = ref.match(/^([a-z][a-z0-9-]*)-([RC])(\d+)$/);
|
|
39
|
+
if (m) return showSpecItem(projectRoot, m[1], m[2], Number(m[3]));
|
|
40
|
+
|
|
41
|
+
if (/^[a-z][a-z0-9-]*$/.test(ref)) return showSpecHead(projectRoot, ref);
|
|
42
|
+
|
|
43
|
+
console.error(c.red("error:") + ` unrecognised reference "${ref}"`);
|
|
44
|
+
console.error(c.gray("hint: ") + "use <cap>-R<n>, <cap>-C<n>, an ADR number (0007), or a capability name");
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function showAdr(projectRoot, num) {
|
|
49
|
+
const dir = path.join(projectRoot, ".doctrina", "decisions");
|
|
50
|
+
const file = isDir(dir) ? readdirSync(dir).find((f) => f.startsWith(`${num}-`) && f.endsWith(".md")) : null;
|
|
51
|
+
if (!file) {
|
|
52
|
+
console.error(c.red("error:") + ` no ADR ${num} under .doctrina/decisions/`);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const full = path.join(dir, file);
|
|
56
|
+
console.log(c.gray(`— ${relPath(projectRoot, full)} —`));
|
|
57
|
+
console.log(read(full).trimEnd());
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadSpec(projectRoot, cap) {
|
|
62
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
63
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
64
|
+
if (isFile(specPath)) return { specPath, text: read(specPath) };
|
|
65
|
+
const known = isDir(specsDir) ? readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md"))) : [];
|
|
66
|
+
console.error(c.red("error:") + ` no spec for capability "${cap}"`);
|
|
67
|
+
const guess = suggest(cap, known);
|
|
68
|
+
if (guess) console.error(c.gray("hint: ") + `did you mean "${guess}"?`);
|
|
69
|
+
else if (known.length) console.error(c.gray("known: ") + known.sort().join(", "));
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function showSpecHead(projectRoot, cap) {
|
|
74
|
+
const spec = loadSpec(projectRoot, cap);
|
|
75
|
+
if (!spec) return 1;
|
|
76
|
+
const lines = spec.text.split(/\r?\n/);
|
|
77
|
+
const out = [];
|
|
78
|
+
let mode = "head"; // header block -> seek Purpose -> purpose body -> stop
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const isH2 = /^##\s+/.test(line);
|
|
81
|
+
if (mode === "head" && isH2) mode = /^##\s+Purpose\b/i.test(line) ? "purpose" : "seek";
|
|
82
|
+
else if (mode === "seek" && isH2 && /^##\s+Purpose\b/i.test(line)) mode = "purpose";
|
|
83
|
+
else if (mode === "purpose" && isH2) break;
|
|
84
|
+
|
|
85
|
+
if (mode === "head" || (mode === "purpose")) out.push(line);
|
|
86
|
+
}
|
|
87
|
+
console.log(c.gray(`— ${relPath(projectRoot, spec.specPath)} (header + purpose; \`doctrina context ${cap}\` for the full spec) —`));
|
|
88
|
+
console.log(out.join("\n").trimEnd());
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function showSpecItem(projectRoot, cap, kind, n) {
|
|
93
|
+
const spec = loadSpec(projectRoot, cap);
|
|
94
|
+
if (!spec) return 1;
|
|
95
|
+
|
|
96
|
+
if (kind === "C") {
|
|
97
|
+
const criteria = parseAcceptanceCriteria(spec.text);
|
|
98
|
+
const crit = criteria.find((r) => r.n === n);
|
|
99
|
+
if (!crit) {
|
|
100
|
+
console.error(c.red("error:") + ` ${cap} has no acceptance criterion #${n} (it declares ${criteria.length})`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
console.log(c.gray(`— ${cap}-C${n} · ${relPath(projectRoot, spec.specPath)} · ## Acceptance criteria —`));
|
|
104
|
+
console.log(`${n}. ${crit.body}`);
|
|
105
|
+
if (crit.proofPaths.length > 0) {
|
|
106
|
+
console.log(c.gray(` evidence: ${crit.proofPaths.join(", ")}`));
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const reqs = parseRequirements(spec.text);
|
|
112
|
+
const req = reqs[n - 1];
|
|
113
|
+
if (!req) {
|
|
114
|
+
console.error(c.red("error:") + ` ${cap} has no requirement R${n} (it declares ${reqs.length})`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
console.log(c.gray(`— ${cap}-R${n} · ${relPath(projectRoot, spec.specPath)} · ${req.section} —`));
|
|
118
|
+
console.log(req.text);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Top-level bullets of the "## Requirements (EARS)" section, in file order,
|
|
123
|
+
// each with its `### <grammar>` sub-section name and continuation lines.
|
|
124
|
+
export function parseRequirements(text) {
|
|
125
|
+
const lines = text.split(/\r?\n/);
|
|
126
|
+
const out = [];
|
|
127
|
+
let inSection = false;
|
|
128
|
+
let sub = "";
|
|
129
|
+
let cur = null;
|
|
130
|
+
const flush = () => {
|
|
131
|
+
if (cur) out.push({ section: cur.section, text: cur.lines.join("\n").trimEnd() });
|
|
132
|
+
cur = null;
|
|
133
|
+
};
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
if (/^##\s+/.test(line)) {
|
|
136
|
+
flush();
|
|
137
|
+
inSection = /^##\s+Requirements\b/i.test(line);
|
|
138
|
+
sub = "";
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!inSection) continue;
|
|
142
|
+
const subMatch = line.match(/^###\s+(.+)$/);
|
|
143
|
+
if (subMatch) {
|
|
144
|
+
flush();
|
|
145
|
+
sub = subMatch[1].trim();
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (/^-\s+/.test(line)) {
|
|
149
|
+
flush();
|
|
150
|
+
cur = { section: sub || "Requirements", lines: [line] };
|
|
151
|
+
} else if (cur && (line.startsWith(" ") || line.trim() === "")) {
|
|
152
|
+
cur.lines.push(line);
|
|
153
|
+
} else {
|
|
154
|
+
flush();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
flush();
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const help = `
|
|
162
|
+
Usage: doctrina show <ref>
|
|
163
|
+
|
|
164
|
+
Point-read one artifact fragment instead of a whole file:
|
|
165
|
+
|
|
166
|
+
doctrina show cli-R12 requirement 12 of the cli spec (file order)
|
|
167
|
+
doctrina show cli-C3 acceptance criterion 3 (the spec's numbering)
|
|
168
|
+
doctrina show 0007 ADR 0007 (the whole decision)
|
|
169
|
+
doctrina show cli the spec's header block + Purpose only
|
|
170
|
+
|
|
171
|
+
R-refs are positional (they shift when a requirement is inserted above);
|
|
172
|
+
C-refs use the criteria's own numbers. Read-only.
|
|
173
|
+
`;
|