doctrina-cli 0.15.1 → 0.17.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 +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
package/src/commands/next.js
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { readdirSync } from "node:fs";
|
|
5
|
-
import { exists, isDir
|
|
6
|
-
import * as idx from "../lib/index-json.js";
|
|
7
|
-
import { deriveIndex, indexesMatch, listHeader } from "../lib/scan.js";
|
|
5
|
+
import { exists, isDir } from "../lib/fs-ops.js";
|
|
8
6
|
import { flagBool } from "../lib/args.js";
|
|
9
7
|
import { c } from "../lib/colors.js";
|
|
10
8
|
import { emitJson } from "../lib/json-out.js";
|
|
11
|
-
import {
|
|
9
|
+
import { EXIT } from "../lib/exit-codes.js";
|
|
10
|
+
import { action, computeActions, invocation } from "../lib/actions.js";
|
|
11
|
+
|
|
12
|
+
// `next` answers "what now?". The ANSWER lives in `lib/actions.js` as
|
|
13
|
+
// records; this module renders them and, under `--run`, executes one
|
|
14
|
+
// (change 0032). Before that the answer WAS the sentence, so `--json`
|
|
15
|
+
// returned prose in quotes and an agent had to re-read English to re-issue
|
|
16
|
+
// a command the CLI had already assembled.
|
|
12
17
|
|
|
13
18
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
14
19
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -17,186 +22,127 @@ import { collectRuntimeFindings } from "../lib/runtime.js";
|
|
|
17
22
|
// wrap it in the generic envelope.
|
|
18
23
|
export const jsonNative = true;
|
|
19
24
|
|
|
20
|
-
export const flags = { boolean: ["json"], string: [] };
|
|
25
|
+
export const flags = { boolean: ["json", "run"], string: [] };
|
|
21
26
|
|
|
22
27
|
export async function run(_positional, flags) {
|
|
23
28
|
const projectRoot = process.cwd();
|
|
24
29
|
const json = flagBool(flags, "json", false);
|
|
25
30
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
26
|
-
const first =
|
|
31
|
+
const first = action({
|
|
32
|
+
id: "not-a-project",
|
|
33
|
+
command: "init",
|
|
34
|
+
runnable: false,
|
|
35
|
+
why: "this directory is not a Doctrina project yet",
|
|
36
|
+
});
|
|
27
37
|
if (json) emitJson("next", { actions: [first] });
|
|
28
|
-
else console.log(`1. ${first}`);
|
|
38
|
+
else console.log(`1. ${first.text}`);
|
|
29
39
|
return 0;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
const actions = computeActions(projectRoot);
|
|
33
43
|
|
|
44
|
+
if (flagBool(flags, "run", false)) {
|
|
45
|
+
return runFirst(actions);
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
if (json) {
|
|
35
49
|
emitJson("next", { actions });
|
|
36
50
|
return 0;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
if (actions.length === 0) {
|
|
40
|
-
|
|
54
|
+
// The two doors AGENTS.md tells an agent to use, not the two hand-authoring
|
|
55
|
+
// commands it tells them to avoid (second audit). `intake` first for a
|
|
56
|
+
// project with nothing specced yet, `work` for one that has.
|
|
57
|
+
const specced = isDir(path.join(projectRoot, ".doctrina", "specs"))
|
|
58
|
+
&& readdirSync(path.join(projectRoot, ".doctrina", "specs"), { withFileTypes: true })
|
|
59
|
+
.some((e) => e.isDirectory());
|
|
60
|
+
console.log(c.green("ok") + " every gate is satisfied and no change is open.");
|
|
41
61
|
console.log("");
|
|
42
62
|
console.log("Start something:");
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
if (!specced) {
|
|
64
|
+
console.log(` doctrina intake --text "<what this project is>" turn intent into specs`);
|
|
65
|
+
}
|
|
66
|
+
console.log(` doctrina work "<prompt>" open the next change`);
|
|
45
67
|
return 0;
|
|
46
68
|
}
|
|
47
69
|
|
|
48
70
|
console.log(c.bold("Next actions") + c.gray(" (in priority order):"));
|
|
49
71
|
console.log("");
|
|
50
|
-
actions.forEach((a, i) => console.log(`${i + 1}. ${a}`));
|
|
72
|
+
actions.forEach((a, i) => console.log(`${i + 1}. ${a.text}`));
|
|
51
73
|
return 0;
|
|
52
74
|
}
|
|
53
75
|
|
|
54
|
-
// The
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
// The command modules `--run` may dispatch to, loaded on demand. Static
|
|
77
|
+
// imports here would make every module that imports `next` (prime, handoff,
|
|
78
|
+
// watch) pay for the whole command tree just to render a list.
|
|
79
|
+
export const RUNNERS = {
|
|
80
|
+
triage: () => import("./triage.js"),
|
|
81
|
+
intake: () => import("./intake.js"),
|
|
82
|
+
"change check": () => import("./change.js"),
|
|
83
|
+
"change apply": () => import("./change.js"),
|
|
84
|
+
"change archive": () => import("./change.js"),
|
|
85
|
+
"index rebuild": () => import("./index-rebuild.js"),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Execute the first runnable action, and only that one.
|
|
90
|
+
*
|
|
91
|
+
* ONE action, not the queue: the list is recomputed from the tree after
|
|
92
|
+
* every change to it, so running two in a row would act on a list the
|
|
93
|
+
* second half of which was computed before the first half ran. Stopping
|
|
94
|
+
* also keeps the human's approval point where they put it — `close` is the
|
|
95
|
+
* command that deliberately runs a whole sequence.
|
|
96
|
+
*/
|
|
97
|
+
async function runFirst(actions) {
|
|
98
|
+
const next = actions.find((a) => a.runnable);
|
|
99
|
+
if (!next) {
|
|
100
|
+
const blocked = actions.find((a) => !a.runnable);
|
|
101
|
+
if (!blocked) {
|
|
102
|
+
console.log(c.green("ok") + " no open work to run.");
|
|
103
|
+
return EXIT.OK;
|
|
73
104
|
}
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
|
|
84
|
-
if (status !== "converted") {
|
|
85
|
-
actions.push("doctrina intake — a pending intake awaits conversion into product.md and specs");
|
|
105
|
+
// Everything left needs authorship, not execution. Say what, and say
|
|
106
|
+
// it is not a failure of the command.
|
|
107
|
+
console.log(c.yellow("nothing to run") + " — the next action needs a person, not a command:");
|
|
108
|
+
console.log("");
|
|
109
|
+
console.log(` ${blocked.text}`);
|
|
110
|
+
const inv = invocation(blocked);
|
|
111
|
+
if (inv) {
|
|
112
|
+
console.log("");
|
|
113
|
+
console.log(c.gray("When you have made the call, record it with: ") + c.cyan(inv));
|
|
86
114
|
}
|
|
115
|
+
return EXIT.OK;
|
|
87
116
|
}
|
|
88
117
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (!isFile(proposalPath)) {
|
|
97
|
-
actions.push(`add a proposal.md to .doctrina/changes/${id}/ (open changes need one)`);
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
const status = listHeader(read(proposalPath), "Status") ?? "proposed";
|
|
101
|
-
if (status === "applied") {
|
|
102
|
-
actions.push(`doctrina change archive ${id} — applied but not archived`);
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
106
|
-
const unchecked = isFile(tasksPath)
|
|
107
|
-
? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
|
|
108
|
-
: 0;
|
|
109
|
-
if (unchecked > 0) {
|
|
110
|
-
actions.push(`complete ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md`);
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
const deltas = walk(path.join(changesDir, id, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
114
|
-
if (deltas.length > 0) {
|
|
115
|
-
actions.push(`doctrina analyze ${id}, then doctrina change apply ${id} — tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`);
|
|
116
|
-
} else {
|
|
117
|
-
actions.push(`add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only: doctrina change apply ${id}`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
118
|
+
const loader = RUNNERS[next.command];
|
|
119
|
+
if (!loader) {
|
|
120
|
+
// A runnable action whose command has no runner is a bug in this map,
|
|
121
|
+
// not something to paper over by silently doing nothing.
|
|
122
|
+
console.error(c.red("error:") + ` no runner for "${next.command}"`);
|
|
123
|
+
console.error(c.gray("hint: ") + `run it yourself: ${invocation(next)}`);
|
|
124
|
+
return EXIT.GATE;
|
|
120
125
|
}
|
|
121
126
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
const t = (v ?? "").trim();
|
|
130
|
-
return t === "" || t === "—" || t === "-";
|
|
131
|
-
};
|
|
132
|
-
for (const f of walk(adrDir)) {
|
|
133
|
-
if (!f.endsWith(".md")) continue;
|
|
134
|
-
const m = path.basename(f).match(/^(\d{4})-/);
|
|
135
|
-
if (!m) continue;
|
|
136
|
-
const text = read(f);
|
|
137
|
-
const status = listHeader(text, "Status");
|
|
138
|
-
if (status && status.toLowerCase() === "proposed") {
|
|
139
|
-
actions.push(`review ADR ${m[1]} (${relPath(projectRoot, f)}): doctrina decision accept ${m[1]}, or supersede it`);
|
|
140
|
-
} else if (status && status.toLowerCase() === "accepted") {
|
|
141
|
-
// Bare only when BOTH anchors are empty (Evidence header present-but-bare
|
|
142
|
-
// and no Landed stamp). An ADR that opts out of Evidence entirely
|
|
143
|
-
// (header absent) is not nagged.
|
|
144
|
-
const evidence = listHeader(text, "Evidence");
|
|
145
|
-
const landed = listHeader(text, "Landed");
|
|
146
|
-
if (evidence !== null && isBareValue(evidence) && isBareValue(landed)) {
|
|
147
|
-
landNudges.push(`record what proves ADR ${m[1]}: cite its **Evidence:**, or once it ships, doctrina decision land ${m[1]}`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
for (const n of landNudges) actions.push(n);
|
|
152
|
-
|
|
153
|
-
// No procedural memory captured yet, but the history shows a fix-shaped
|
|
154
|
-
// change — exactly the lesson a skill exists to keep from being relearned
|
|
155
|
-
// (review §5: the change-0003 "tolerate LLM code fences" case). One gentle
|
|
156
|
-
// nudge, only when skills are empty, so it never nags a project that opted in.
|
|
157
|
-
const skillNudge = suggestSkillCapture(projectRoot);
|
|
158
|
-
if (skillNudge) actions.push(skillNudge);
|
|
159
|
-
|
|
160
|
-
// Index drift is silent rot; surface it last.
|
|
161
|
-
try {
|
|
162
|
-
const current = idx.load(projectRoot);
|
|
163
|
-
if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
|
|
164
|
-
actions.push("doctrina index rebuild — index.json has drifted from the tree");
|
|
165
|
-
}
|
|
166
|
-
} catch {
|
|
167
|
-
actions.push("doctrina index rebuild — index.json is missing or unreadable");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return actions;
|
|
171
|
-
}
|
|
127
|
+
console.log(c.bold("Running") + ` ${c.cyan(invocation(next))}` + c.gray(` — ${next.why}`));
|
|
128
|
+
console.log("");
|
|
129
|
+
const mod = await loader();
|
|
130
|
+
// A sub-operation ("change archive") is passed as a positional, exactly
|
|
131
|
+
// as the entrypoint would.
|
|
132
|
+
const [, ...sub] = next.command.split(" ");
|
|
133
|
+
const code = await mod.run([...sub, ...next.args], new Map());
|
|
172
134
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
function suggestSkillCapture(projectRoot) {
|
|
180
|
-
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
181
|
-
if (isDir(skillsDir)) {
|
|
182
|
-
const hasSkill = walk(skillsDir).some((f) => f.endsWith(".md"));
|
|
183
|
-
if (hasSkill) return null; // opted in already — never nag
|
|
184
|
-
}
|
|
185
|
-
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
186
|
-
if (!isDir(archiveDir)) return null;
|
|
187
|
-
for (const name of readdirSync(archiveDir).sort()) {
|
|
188
|
-
if (!isDir(path.join(archiveDir, name))) continue;
|
|
189
|
-
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
190
|
-
if (FIX_SHAPED.test(id)) {
|
|
191
|
-
return `capture a skill from past fixes (e.g. "${id}"): doctrina skill new <slug> — ` +
|
|
192
|
-
`on-demand procedural memory so the next agent does not relearn it (none exist yet)`;
|
|
193
|
-
}
|
|
135
|
+
console.log("");
|
|
136
|
+
if (code === EXIT.OK) {
|
|
137
|
+
console.log(c.green("✓ ran ") + c.cyan(invocation(next)) + c.gray(" — `doctrina next` for what follows"));
|
|
138
|
+
} else {
|
|
139
|
+
console.log(c.red("✗ ") + c.cyan(invocation(next)) + c.gray(` exited ${code} — fix it, then rerun`));
|
|
194
140
|
}
|
|
195
|
-
return
|
|
141
|
+
return code;
|
|
196
142
|
}
|
|
197
143
|
|
|
198
144
|
export const help = `
|
|
199
|
-
Usage: doctrina next [--json]
|
|
145
|
+
Usage: doctrina next [--json] [--run]
|
|
200
146
|
|
|
201
147
|
Inspect the .doctrina/ tree and print the recommended next workflow
|
|
202
148
|
actions in priority order: runtime declarations that no longer hold
|
|
@@ -205,8 +151,20 @@ run lied), open changes (missing proposal, unchecked
|
|
|
205
151
|
tasks, deltas ready to apply, applied-but-unarchived), ADRs still in
|
|
206
152
|
proposed status, accepted ADRs with nothing proving them (cite Evidence
|
|
207
153
|
or run "decision land"), a skill-capture nudge when a past fix went
|
|
208
|
-
uncaptured, and index drift last.
|
|
154
|
+
uncaptured, and index drift last.
|
|
209
155
|
|
|
210
156
|
Intended use: agents and humans run it to resume work without
|
|
211
157
|
re-reading the whole tree.
|
|
158
|
+
|
|
159
|
+
Options:
|
|
160
|
+
--json Emit the actions as records — { id, command, args, why, gate,
|
|
161
|
+
severity, runnable, text }. Branch on \`command\`/\`args\`; \`text\`
|
|
162
|
+
is the same line the terminal prints.
|
|
163
|
+
--run Execute the first RUNNABLE action in-process and stop. An action
|
|
164
|
+
that needs a person to decide — accept an ADR, write a proposal,
|
|
165
|
+
complete a task, capture a skill — is never runnable however
|
|
166
|
+
mechanical its edit would be, and \`--run\` names it instead.
|
|
167
|
+
Exits with the executed command's own code.
|
|
168
|
+
|
|
169
|
+
Read-only without --run, and always exits 0 then.
|
|
212
170
|
`;
|
package/src/commands/prime.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { listHeader } from "../lib/scan.js";
|
|
7
|
-
import { collectStatus } from "./status.js";
|
|
8
|
-
import { computeActions } from "./next.js";
|
|
9
|
-
import { acceptedDecisions, productSection } from "./constitution.js";
|
|
10
|
-
import { c } from "../lib/colors.js";
|
|
4
|
+
import { exists } from "../lib/fs-ops.js";
|
|
5
|
+
import { flagBool } from "../lib/args.js";
|
|
11
6
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
7
|
+
import { collectSnapshot } from "../lib/snapshot.js";
|
|
8
|
+
import { renderView } from "../lib/views.js";
|
|
12
9
|
|
|
13
10
|
// Session primer: the ~40-line read that orients an agent at the start of a
|
|
14
11
|
// session — where things stand, what the standing rules are, what work is
|
|
@@ -16,102 +13,30 @@ import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
|
16
13
|
// It sits between `status` (numbers only) and `context --concat` (everything):
|
|
17
14
|
// enough to act, cheap enough to run every session. Strictly read-only; every
|
|
18
15
|
// line is assembled from artifacts that already own the fact (no new home).
|
|
16
|
+
//
|
|
17
|
+
// Since change 0037 the primer is a VIEW over lib/snapshot.js, rendered by
|
|
18
|
+
// lib/views.js and reachable as `doctrina status --view prime`. This module
|
|
19
|
+
// is the name AGENTS.md tells agents to run, and nothing else: no collection,
|
|
20
|
+
// no formatting, and nothing for another command to import out of.
|
|
19
21
|
|
|
20
22
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
21
23
|
// adding a command never requires editing the entrypoint — the gap that
|
|
22
24
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
23
|
-
export const flags = { boolean: ["json"], string: [] };
|
|
25
|
+
export const flags = { boolean: ["json", "rules"], string: [] };
|
|
24
26
|
|
|
25
|
-
export async function run(_positional,
|
|
27
|
+
export async function run(_positional, flags) {
|
|
26
28
|
const projectRoot = process.cwd();
|
|
27
29
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
28
30
|
throw notADoctrinaProject();
|
|
29
31
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
console.log(
|
|
35
|
-
|
|
36
|
-
// One-line gate digest.
|
|
37
|
-
const cov = `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria})`;
|
|
38
|
-
const tr = s.trace.anchors === 0 ? "no anchors" : `${s.trace.realized}/${s.trace.anchors}`;
|
|
39
|
-
const verify = s.verify.configured ? `${s.verify.checks} verify checks` : "verify not configured";
|
|
40
|
-
console.log(c.bold("Gates ") + `index ${s.indexState} · coverage ${cov} · trace ${tr} · ${verify}`);
|
|
41
|
-
const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
|
|
42
|
-
console.log(
|
|
43
|
-
c.bold("Work ") +
|
|
44
|
-
`${s.specs.total} specs${implBreak ? ` (${implBreak})` : ""} · ${s.specs.openChanges} open change${s.specs.openChanges === 1 ? "" : "s"} · ` +
|
|
45
|
-
`${s.decisions.total} decisions · ${s.skills} skills`,
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
// Standing rules, titles only — `constitution` has the full text.
|
|
49
|
-
const adrs = acceptedDecisions(projectRoot);
|
|
50
|
-
console.log("");
|
|
51
|
-
console.log(c.bold("Rules") + c.gray(` (${adrs.length} accepted ADRs — \`doctrina constitution\` for detail)`));
|
|
52
|
-
for (const a of adrs) console.log(` ${c.cyan(a.id)} ${a.title}`);
|
|
53
|
-
const nonGoals = productSection(projectRoot, "Non-goals");
|
|
54
|
-
if (nonGoals.length > 0) {
|
|
55
|
-
console.log(c.gray(` + ${nonGoals.length} non-goal${nonGoals.length === 1 ? "" : "s"} declared in product.md`));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Open work, with unchecked-task counts — the resume points.
|
|
59
|
-
console.log("");
|
|
60
|
-
console.log(c.bold("Open work"));
|
|
61
|
-
const open = openChanges(projectRoot);
|
|
62
|
-
if (open.length === 0) {
|
|
63
|
-
console.log(c.gray(" none — `doctrina work \"<prompt>\"` opens the next change"));
|
|
64
|
-
} else {
|
|
65
|
-
for (const ch of open) {
|
|
66
|
-
const tasks = ch.tasksTotal > 0 ? ` · tasks ${ch.tasksDone}/${ch.tasksTotal}` : "";
|
|
67
|
-
console.log(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks})`)}`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Next actions (top 5 — `doctrina next` for the full list).
|
|
72
|
-
const actions = computeActions(projectRoot);
|
|
73
|
-
console.log("");
|
|
74
|
-
console.log(c.bold("Next"));
|
|
75
|
-
if (actions.length === 0) {
|
|
76
|
-
console.log(c.gray(" nothing pending — pick up new work"));
|
|
77
|
-
} else {
|
|
78
|
-
actions.slice(0, 5).forEach((a, i) => console.log(` ${i + 1}. ${a}`));
|
|
79
|
-
if (actions.length > 5) console.log(c.gray(` … ${actions.length - 5} more — \`doctrina next\``));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
console.log("");
|
|
83
|
-
console.log(c.gray("Read deeper: `doctrina context [<cap>] --concat` · `doctrina why <cap>` · `doctrina show <ref>`"));
|
|
32
|
+
// --rules: the standing rules in full, which is what the removed
|
|
33
|
+
// `constitution` printed (changes 0049, 0175). The primer names the ADRs and counts the non-goals; this
|
|
34
|
+
// prints both lists, from the same collection, so the two cannot disagree.
|
|
35
|
+
const view = flagBool(flags, "rules", false) ? "rules" : "prime";
|
|
36
|
+
for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
|
|
84
37
|
return 0;
|
|
85
38
|
}
|
|
86
39
|
|
|
87
|
-
// Open changes with their proposal status/title and task progress.
|
|
88
|
-
export function openChanges(projectRoot) {
|
|
89
|
-
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
90
|
-
const out = [];
|
|
91
|
-
if (!isDir(changesDir)) return out;
|
|
92
|
-
for (const id of readdirSync(changesDir).sort()) {
|
|
93
|
-
if (id === "archive" || id.startsWith(".")) continue;
|
|
94
|
-
if (!isDir(path.join(changesDir, id))) continue;
|
|
95
|
-
const proposalPath = path.join(changesDir, id, "proposal.md");
|
|
96
|
-
const proposal = isFile(proposalPath) ? read(proposalPath) : null;
|
|
97
|
-
const title = proposal?.match(/^#\s+(?:Change\s+[^—-]*[—-]\s*)?(.+)$/m)?.[1]?.trim() ?? null;
|
|
98
|
-
const status = proposal ? (listHeader(proposal, "Status") ?? "proposed") : "no proposal.md";
|
|
99
|
-
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
100
|
-
let tasksDone = 0, tasksTotal = 0, unchecked = [];
|
|
101
|
-
if (isFile(tasksPath)) {
|
|
102
|
-
for (const line of read(tasksPath).split(/\r?\n/)) {
|
|
103
|
-
const m = line.match(/^-\s+\[([ xX])\]\s+(.*)$/);
|
|
104
|
-
if (!m) continue;
|
|
105
|
-
tasksTotal += 1;
|
|
106
|
-
if (m[1] === " ") unchecked.push(m[2].trim());
|
|
107
|
-
else tasksDone += 1;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
out.push({ id, title, status, tasksDone, tasksTotal, unchecked });
|
|
111
|
-
}
|
|
112
|
-
return out;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
40
|
export const help = `
|
|
116
41
|
Usage: doctrina prime
|
|
117
42
|
|
|
@@ -120,6 +45,13 @@ titles + non-goal count), open work with task progress, and the top
|
|
|
120
45
|
next actions — the ~40-line read that orients an agent at session
|
|
121
46
|
start without paying for the full context pack.
|
|
122
47
|
|
|
48
|
+
The same view as \`doctrina status --view prime\`; both render one
|
|
49
|
+
collection of the tree, so they cannot disagree.
|
|
50
|
+
|
|
51
|
+
--rules Print the standing rules in full instead of the primer: every
|
|
52
|
+
accepted ADR and every declared non-goal — what the removed
|
|
53
|
+
\`doctrina constitution\` printed, from the same collection.
|
|
54
|
+
|
|
123
55
|
Read-only; always exits 0. Deeper reads: \`doctrina context\`,
|
|
124
56
|
\`doctrina why <cap>\`, \`doctrina show <ref>\`.
|
|
125
57
|
`;
|
package/src/commands/report.js
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
4
|
import { exists } from "../lib/fs-ops.js";
|
|
6
|
-
import
|
|
7
|
-
import { collectStatus } from "./status.js";
|
|
8
|
-
import { openChanges } from "./prime.js";
|
|
9
|
-
import { flagString } from "../lib/args.js";
|
|
10
|
-
import { today } from "../lib/dates.js";
|
|
5
|
+
import { flagBool, flagString, parsePositiveInt } from "../lib/args.js";
|
|
11
6
|
import { c } from "../lib/colors.js";
|
|
12
7
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
8
|
+
import { collectSnapshot, collectReportWindow } from "../lib/snapshot.js";
|
|
9
|
+
import { renderView } from "../lib/views.js";
|
|
10
|
+
import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
|
|
11
|
+
import { cliVersion } from "../lib/version.js";
|
|
13
12
|
|
|
14
13
|
// Periodic digest in Markdown — the standup/PR-description view: what was
|
|
15
14
|
// archived, where the gates stand, and what local git says about the period.
|
|
16
15
|
// Read-only, no network; the git section degrades silently outside a repo.
|
|
16
|
+
//
|
|
17
|
+
// Since change 0037 it is a VIEW over the shared snapshot (lib/snapshot.js →
|
|
18
|
+
// lib/views.js), also reachable as `doctrina status --view report --since N`,
|
|
19
|
+
// and its two git probes go through lib/git.js rather than a private
|
|
20
|
+
// spawnSync that could read a refusal as an empty answer.
|
|
17
21
|
|
|
18
22
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
19
23
|
// adding a command never requires editing the entrypoint — the gap that
|
|
20
24
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
21
|
-
export const flags = { boolean: ["json"], string: ["since"] };
|
|
25
|
+
export const flags = { boolean: ["json", "agent-changelog"], string: ["since"] };
|
|
22
26
|
|
|
23
27
|
export async function run(_positional, flags) {
|
|
24
28
|
const projectRoot = process.cwd();
|
|
@@ -26,117 +30,32 @@ export async function run(_positional, flags) {
|
|
|
26
30
|
throw notADoctrinaProject();
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
const sinceRaw = flagString(flags, "since")
|
|
30
|
-
const days =
|
|
31
|
-
if (
|
|
33
|
+
const sinceRaw = flagString(flags, "since");
|
|
34
|
+
const days = parsePositiveInt(sinceRaw ?? "7");
|
|
35
|
+
if (days === null) {
|
|
32
36
|
console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
|
|
33
37
|
return 2;
|
|
34
38
|
}
|
|
35
|
-
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
36
|
-
const cutoffIso = cutoff.toISOString().slice(0, 10);
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
(s.coverage.totalDangling ? ` — ${s.coverage.totalDangling} dangling` : "") +
|
|
49
|
-
(s.coverage.totalConditional ? ` — ${s.coverage.totalConditional} conditional` : ""));
|
|
50
|
-
console.log(`- trace: ${s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`}`);
|
|
51
|
-
console.log(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared` : "not configured"}`);
|
|
52
|
-
|
|
53
|
-
// Work shipped in the window (from the index ledger) + work in flight.
|
|
54
|
-
console.log("");
|
|
55
|
-
console.log("## Changes");
|
|
56
|
-
console.log("");
|
|
57
|
-
const archived = archivedSince(projectRoot, cutoffIso);
|
|
58
|
-
if (archived.length === 0) {
|
|
59
|
-
console.log(`- archived in period: none`);
|
|
60
|
-
} else {
|
|
61
|
-
console.log(`- archived in period: ${archived.length}`);
|
|
62
|
-
for (const ch of archived) console.log(` - ${ch.applied} — \`${ch.id}\` ${ch.title}`);
|
|
63
|
-
}
|
|
64
|
-
const open = openChanges(projectRoot);
|
|
65
|
-
if (open.length === 0) {
|
|
66
|
-
console.log("- open now: none");
|
|
67
|
-
} else {
|
|
68
|
-
console.log(`- open now: ${open.length}`);
|
|
69
|
-
for (const ch of open) {
|
|
70
|
-
const tasks = ch.tasksTotal > 0 ? ` (tasks ${ch.tasksDone}/${ch.tasksTotal})` : "";
|
|
71
|
-
console.log(` - \`${ch.id}\`${ch.title ? ` ${ch.title}` : ""}${tasks}`);
|
|
72
|
-
}
|
|
40
|
+
// --agent-changelog: a DRAFT of the block `upgrade --write` writes into
|
|
41
|
+
// AGENTS.md, proposed from what the archived changes said they touched.
|
|
42
|
+
// A different report for a different audience — five bullets for an
|
|
43
|
+
// arriving agent, not a digest for a standup — so it replaces the view
|
|
44
|
+
// rather than appending to it. Authorship stays human (ADR 0005): the
|
|
45
|
+
// command proposes, a person cuts and rewrites.
|
|
46
|
+
if (flagBool(flags, "agent-changelog", false)) {
|
|
47
|
+
const draft = draftAgentChangelog(projectRoot, { days: sinceRaw === undefined ? null : days });
|
|
48
|
+
for (const line of renderDraft(draft, cliVersion())) console.log(line);
|
|
49
|
+
return 0;
|
|
73
50
|
}
|
|
74
51
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
console.log(
|
|
79
|
-
console.log(`- specs: ${s.specs.total} · decisions: ${s.decisions.total}` +
|
|
80
|
-
(s.decisions.proposed ? ` (${s.decisions.proposed} proposed)` : "") +
|
|
81
|
-
` · skills: ${s.skills}`);
|
|
82
|
-
|
|
83
|
-
// Local git window — commits, fix share, top churn. No network.
|
|
84
|
-
console.log("");
|
|
85
|
-
console.log("## Git (local, last " + days + " days)");
|
|
86
|
-
console.log("");
|
|
87
|
-
const git = gitWindow(projectRoot, days);
|
|
88
|
-
if (!git) {
|
|
89
|
-
console.log("- no git history available (not a repository, or git not installed)");
|
|
90
|
-
} else {
|
|
91
|
-
console.log(`- commits: ${git.commits} (${git.fixes} fix-shaped, ${git.commits ? Math.round((git.fixes / git.commits) * 100) : 0}%)`);
|
|
92
|
-
if (git.churn.length > 0) {
|
|
93
|
-
console.log(`- top-churn files:`);
|
|
94
|
-
for (const [file, n] of git.churn) console.log(` - ${file} (${n} touches)`);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
console.log("");
|
|
99
|
-
console.log(`*Generated read-only by \`doctrina report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
|
|
52
|
+
// One collector with `status --view report` (change 0176), so the
|
|
53
|
+
// deprecated name and its survivor cannot print different digests.
|
|
54
|
+
const options = collectReportWindow(projectRoot, days);
|
|
55
|
+
for (const line of renderView("report", collectSnapshot(projectRoot), options)) console.log(line);
|
|
100
56
|
return 0;
|
|
101
57
|
}
|
|
102
58
|
|
|
103
|
-
function archivedSince(projectRoot, cutoffIso) {
|
|
104
|
-
let index;
|
|
105
|
-
try {
|
|
106
|
-
index = idx.load(projectRoot);
|
|
107
|
-
} catch {
|
|
108
|
-
return [];
|
|
109
|
-
}
|
|
110
|
-
return (index.artifacts?.changes_archive ?? [])
|
|
111
|
-
.filter((ch) => typeof ch.applied === "string" && ch.applied >= cutoffIso)
|
|
112
|
-
.sort((a, b) => String(a.applied).localeCompare(String(b.applied)));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function gitWindow(projectRoot, days) {
|
|
116
|
-
const log = spawnSync("git", ["log", `--since=${days} days ago`, "--pretty=%s"], {
|
|
117
|
-
cwd: projectRoot,
|
|
118
|
-
encoding: "utf8",
|
|
119
|
-
});
|
|
120
|
-
if (log.status !== 0) return null;
|
|
121
|
-
const subjects = log.stdout.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
122
|
-
const fixes = subjects.filter((sub) => /^(fix|bug|hotfix|patch)(\(|:|!)/i.test(sub)).length;
|
|
123
|
-
|
|
124
|
-
const files = spawnSync("git", ["log", `--since=${days} days ago`, "--name-only", "--pretty=format:"], {
|
|
125
|
-
cwd: projectRoot,
|
|
126
|
-
encoding: "utf8",
|
|
127
|
-
});
|
|
128
|
-
const counts = new Map();
|
|
129
|
-
if (files.status === 0) {
|
|
130
|
-
for (const line of files.stdout.split(/\r?\n/)) {
|
|
131
|
-
const f = line.trim();
|
|
132
|
-
if (!f) continue;
|
|
133
|
-
counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
const churn = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
137
|
-
return { commits: subjects.length, fixes, churn };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
59
|
export const help = `
|
|
141
60
|
Usage: doctrina report [--since <days>]
|
|
142
61
|
|
|
@@ -146,4 +65,16 @@ work with task progress, artifact counts, and a local-git summary
|
|
|
146
65
|
(commits, fix share, top-churn files). Made for standups and PR
|
|
147
66
|
descriptions. Read-only; no network. \`doctrina metrics\` has the
|
|
148
67
|
deeper git-derived numbers.
|
|
68
|
+
|
|
69
|
+
Deprecated: this is \`doctrina status --view report --since <days>\`, and
|
|
70
|
+
\`--agent-changelog\` is \`doctrina status --view agent-changelog\`.
|
|
71
|
+
|
|
72
|
+
--agent-changelog Draft the AGENTS.md "What changed" block instead of
|
|
73
|
+
the digest: one candidate bullet per archived change
|
|
74
|
+
that touched a documented surface (a command, a flag,
|
|
75
|
+
an exit code), newest first, capped at the block's
|
|
76
|
+
five bullets. The window is "since the last tag"
|
|
77
|
+
unless --since names one. It PROPOSES; a person cuts
|
|
78
|
+
and rewrites, because what an agent must do
|
|
79
|
+
differently is a judgement the CLI does not make.
|
|
149
80
|
`;
|