doctrina-cli 0.15.1 → 0.16.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/adapter.js +24 -1
- package/src/commands/analyze.js +8 -212
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- 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 +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -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/change-ops.js +131 -0
- package/src/lib/commands.js +56 -4
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +181 -23
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -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 +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
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 `constitution` printed
|
|
33
|
+
// (change 0049). 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,14 @@ 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. The same lines
|
|
53
|
+
\`doctrina constitution\` printed, from the same collection —
|
|
54
|
+
that command is deprecated and delegates here.
|
|
55
|
+
|
|
123
56
|
Read-only; always exits 0. Deeper reads: \`doctrina context\`,
|
|
124
57
|
\`doctrina why <cap>\`, \`doctrina show <ref>\`.
|
|
125
58
|
`;
|
package/src/commands/report.js
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
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 } from "../lib/args.js";
|
|
11
6
|
import { c } from "../lib/colors.js";
|
|
12
7
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
8
|
+
import { collectSnapshot } from "../lib/snapshot.js";
|
|
9
|
+
import { renderView } from "../lib/views.js";
|
|
10
|
+
import { gitWindow, historyState, windowCutoff } from "../lib/git.js";
|
|
11
|
+
import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
|
|
12
|
+
import { collectMetrics } from "../lib/metrics-model.js";
|
|
13
|
+
import { cliVersion } from "../lib/version.js";
|
|
13
14
|
|
|
14
15
|
// Periodic digest in Markdown — the standup/PR-description view: what was
|
|
15
16
|
// archived, where the gates stand, and what local git says about the period.
|
|
16
17
|
// Read-only, no network; the git section degrades silently outside a repo.
|
|
18
|
+
//
|
|
19
|
+
// Since change 0037 it is a VIEW over the shared snapshot (lib/snapshot.js →
|
|
20
|
+
// lib/views.js), also reachable as `doctrina status --view report --since N`,
|
|
21
|
+
// and its two git probes go through lib/git.js rather than a private
|
|
22
|
+
// spawnSync that could read a refusal as an empty answer.
|
|
17
23
|
|
|
18
24
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
19
25
|
// adding a command never requires editing the entrypoint — the gap that
|
|
20
26
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
21
|
-
export const flags = { boolean: ["json"], string: ["since"] };
|
|
27
|
+
export const flags = { boolean: ["json", "agent-changelog"], string: ["since"] };
|
|
22
28
|
|
|
23
29
|
export async function run(_positional, flags) {
|
|
24
30
|
const projectRoot = process.cwd();
|
|
@@ -26,117 +32,42 @@ export async function run(_positional, flags) {
|
|
|
26
32
|
throw notADoctrinaProject();
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
const sinceRaw = flagString(flags, "since")
|
|
30
|
-
const days = Number.parseInt(sinceRaw, 10);
|
|
35
|
+
const sinceRaw = flagString(flags, "since");
|
|
36
|
+
const days = Number.parseInt(sinceRaw ?? "7", 10);
|
|
31
37
|
if (!Number.isFinite(days) || days <= 0) {
|
|
32
38
|
console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
|
|
33
39
|
return 2;
|
|
34
40
|
}
|
|
35
|
-
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
36
|
-
const cutoffIso = cutoff.toISOString().slice(0, 10);
|
|
37
41
|
|
|
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
|
-
}
|
|
42
|
+
// --agent-changelog: a DRAFT of the block `upgrade --write` writes into
|
|
43
|
+
// AGENTS.md, proposed from what the archived changes said they touched.
|
|
44
|
+
// A different report for a different audience — five bullets for an
|
|
45
|
+
// arriving agent, not a digest for a standup — so it replaces the view
|
|
46
|
+
// rather than appending to it. Authorship stays human (ADR 0005): the
|
|
47
|
+
// command proposes, a person cuts and rewrites.
|
|
48
|
+
if (flagBool(flags, "agent-changelog", false)) {
|
|
49
|
+
const draft = draftAgentChangelog(projectRoot, { days: sinceRaw === undefined ? null : days });
|
|
50
|
+
for (const line of renderDraft(draft, cliVersion())) console.log(line);
|
|
51
|
+
return 0;
|
|
73
52
|
}
|
|
74
53
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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\`.*`);
|
|
54
|
+
const options = {
|
|
55
|
+
days,
|
|
56
|
+
cutoffIso: windowCutoff(days),
|
|
57
|
+
git: gitWindow(projectRoot, days),
|
|
58
|
+
// Why there is no window, when there is none — the same door `metrics`
|
|
59
|
+
// and `context` already ask, so the three cannot disagree about the
|
|
60
|
+
// state of one repository (change 0080).
|
|
61
|
+
gitState: historyState(projectRoot),
|
|
62
|
+
// The same snapshot `metrics` renders, for the same window (change
|
|
63
|
+
// 0050). One definition of "revert rate", so the digest and the metrics
|
|
64
|
+
// command cannot report two different numbers for one period.
|
|
65
|
+
metrics: collectMetrics(projectRoot, `${days} days ago`),
|
|
66
|
+
};
|
|
67
|
+
for (const line of renderView("report", collectSnapshot(projectRoot), options)) console.log(line);
|
|
100
68
|
return 0;
|
|
101
69
|
}
|
|
102
70
|
|
|
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
71
|
export const help = `
|
|
141
72
|
Usage: doctrina report [--since <days>]
|
|
142
73
|
|
|
@@ -146,4 +77,15 @@ work with task progress, artifact counts, and a local-git summary
|
|
|
146
77
|
(commits, fix share, top-churn files). Made for standups and PR
|
|
147
78
|
descriptions. Read-only; no network. \`doctrina metrics\` has the
|
|
148
79
|
deeper git-derived numbers.
|
|
80
|
+
|
|
81
|
+
The same view as \`doctrina status --view report --since <days>\`.
|
|
82
|
+
|
|
83
|
+
--agent-changelog Draft the AGENTS.md "What changed" block instead of
|
|
84
|
+
the digest: one candidate bullet per archived change
|
|
85
|
+
that touched a documented surface (a command, a flag,
|
|
86
|
+
an exit code), newest first, capped at the block's
|
|
87
|
+
five bullets. The window is "since the last tag"
|
|
88
|
+
unless --since names one. It PROPOSES; a person cuts
|
|
89
|
+
and rewrites, because what an agent must do
|
|
90
|
+
differently is a judgement the CLI does not make.
|
|
149
91
|
`;
|
package/src/commands/review.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
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 { readdirSync } from "node:fs";
|
|
6
5
|
import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
7
6
|
import { flagBool, flagString } from "../lib/args.js";
|
|
8
7
|
import { c } from "../lib/colors.js";
|
|
9
|
-
import { rankCapabilitiesByDiff } from "
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
8
|
+
import { rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
9
|
+
import { readLedger, churnByCapability } from "../lib/ledger.js";
|
|
10
|
+
import { changedFiles, windowCutoff, historyState, refExists } from "../lib/git.js";
|
|
11
|
+
import { dependentsOf } from "../lib/scan.js";
|
|
12
|
+
import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
|
|
13
|
+
import { summarize as coverageSummary } from "../lib/coverage-model.js";
|
|
14
|
+
import { summarize as traceSummary } from "../lib/trace-model.js";
|
|
14
15
|
|
|
15
16
|
// Deterministic conformance review (review 2026-06-27 passive-user feature #3).
|
|
16
17
|
// Given the working tree (or a diff against a ref), report STRUCTURAL breaks
|
|
@@ -25,6 +26,17 @@ import { summarize as traceSummary } from "./trace.js";
|
|
|
25
26
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
26
27
|
// adding a command never requires editing the entrypoint — the gap that
|
|
27
28
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
29
|
+
// How far back the churn note looks, and how many landed changes make it
|
|
30
|
+
// worth saying at all. A window rather than "all time": a capability that
|
|
31
|
+
// moved nine times two years ago is history, not news.
|
|
32
|
+
const CHURN_WINDOW_DAYS = 60;
|
|
33
|
+
const CHURN_NOTABLE = 3;
|
|
34
|
+
|
|
35
|
+
// How many unclaimed files the orphan note names before it summarises the
|
|
36
|
+
// rest. Long enough to act on, short enough that a wide refactor does not
|
|
37
|
+
// bury every other finding under a file listing.
|
|
38
|
+
const ORPHAN_LIST_LIMIT = 5;
|
|
39
|
+
|
|
28
40
|
export const flags = { boolean: ["json", "strict"], string: ["diff"] };
|
|
29
41
|
|
|
30
42
|
export async function run(_positional, flags) {
|
|
@@ -35,7 +47,20 @@ export async function run(_positional, flags) {
|
|
|
35
47
|
const strict = flagBool(flags, "strict", false);
|
|
36
48
|
const against = flagString(flags, "diff"); // optional git ref to diff against
|
|
37
49
|
|
|
38
|
-
|
|
50
|
+
// A ref the git cannot resolve is a filter that matches nothing, and the
|
|
51
|
+
// review used to read it as "nothing changed" — exit 0, `--strict`
|
|
52
|
+
// included, on a tree where a valid ref reported breaks. A CI job running
|
|
53
|
+
// `doctrina review --diff main --strict` stayed green forever on a shallow
|
|
54
|
+
// clone with no local `main` (change 0092). Only asked inside a usable
|
|
55
|
+
// repository: outside one the command still stays silent rather than
|
|
56
|
+
// accusing.
|
|
57
|
+
if (against && historyState(projectRoot).usable && !refExists(projectRoot, against)) {
|
|
58
|
+
console.error(c.red("error:") + ` --diff names a ref this repository cannot resolve: "${against}"`);
|
|
59
|
+
console.error(c.gray("hint: ") + "check the branch or commit name — a ref that resolves to nothing is not an empty diff");
|
|
60
|
+
return EXIT.USAGE;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { all, sourceFiles } = reviewScope(projectRoot, against);
|
|
39
64
|
console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes"));
|
|
40
65
|
console.log("");
|
|
41
66
|
|
|
@@ -69,23 +94,39 @@ export async function run(_positional, flags) {
|
|
|
69
94
|
// 1b. Dependents of touched capabilities (the machine-readable
|
|
70
95
|
// **Depends on:** header): a spec that builds on something you changed
|
|
71
96
|
// may silently no longer hold. Advisory — a pointer, not a verdict.
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
for (const dep of dependentsOf(projectRoot, touched)) {
|
|
98
|
+
notes.push(`capability "${dep.capability}" depends on touched ${dep.dependsOn.map((d) => `"${d}"`).join(", ")} — confirm it still holds (\`doctrina why ${dep.capability}\`)`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 1c. How often each touched capability has moved lately, from the archive
|
|
102
|
+
// ledger (change 0046). Reported as a NUMBER and never as a verdict:
|
|
103
|
+
// frequent change can mean a spec that was drawn badly or a spec that
|
|
104
|
+
// is simply where the work is, and nothing here can tell those apart
|
|
105
|
+
// (ADR 0005). It is context for the human reading the review, not a
|
|
106
|
+
// finding — so it goes in the notes even when the count is high.
|
|
107
|
+
if (touched.size > 0) {
|
|
108
|
+
const since = windowCutoff(CHURN_WINDOW_DAYS);
|
|
109
|
+
const churn = churnByCapability(readLedger(projectRoot).entries, { since })
|
|
110
|
+
.filter((row) => touched.has(row.capability) && row.changes >= CHURN_NOTABLE);
|
|
111
|
+
for (const row of churn) {
|
|
112
|
+
notes.push(`capability "${row.capability}" landed ${row.changes} changes in the last ${CHURN_WINDOW_DAYS} days (last ${row.last}) — history, not a verdict: read it as "this area is moving", not "this area is wrong"`);
|
|
82
113
|
}
|
|
83
114
|
}
|
|
84
115
|
|
|
85
|
-
// 2.
|
|
86
|
-
// in any spec (
|
|
87
|
-
|
|
88
|
-
|
|
116
|
+
// 2. Changed source files that map to no capability at all — code with no
|
|
117
|
+
// home in any spec. Reported PER FILE (change 0077): the old form fired
|
|
118
|
+
// only when the WHOLE diff matched nothing, so one incidental match —
|
|
119
|
+
// any file under `docs/`, which matches the `docs` capability because
|
|
120
|
+
// the directory is named after it — silenced the finding for every other
|
|
121
|
+
// file in the change. A review of an adapter change reported
|
|
122
|
+
// "Capabilities touched: docs" and said nothing about the adapter.
|
|
123
|
+
const orphans = sourceFiles.filter(
|
|
124
|
+
(f) => rankCapabilitiesByDiff(projectRoot, [f], { limit: 1 }).length === 0,
|
|
125
|
+
);
|
|
126
|
+
if (orphans.length > 0) {
|
|
127
|
+
const shown = orphans.slice(0, ORPHAN_LIST_LIMIT).map((f) => `\`${f}\``).join(", ");
|
|
128
|
+
const more = orphans.length > ORPHAN_LIST_LIMIT ? `, and ${orphans.length - ORPHAN_LIST_LIMIT} more` : "";
|
|
129
|
+
notes.push(`${orphans.length} changed file(s) belong to no capability: ${shown}${more} — claim them with a \`**Source:**\` header on the owning spec, or scaffold the capability they are (\`doctrina spec new <capability>\`)`);
|
|
89
130
|
}
|
|
90
131
|
|
|
91
132
|
// 3. Coverage — acceptance criteria whose cited proof is missing/skipped.
|
|
@@ -133,21 +174,12 @@ export async function run(_positional, flags) {
|
|
|
133
174
|
}
|
|
134
175
|
|
|
135
176
|
// Changed paths between a base (a git ref, or HEAD + untracked for the working
|
|
136
|
-
// tree) and now
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
};
|
|
143
|
-
const set = new Set();
|
|
144
|
-
if (against) {
|
|
145
|
-
for (const f of run(["diff", "--name-only", against])) set.add(f);
|
|
146
|
-
} else {
|
|
147
|
-
for (const f of run(["diff", "--name-only", "HEAD"])) set.add(f);
|
|
148
|
-
for (const f of run(["ls-files", "--others", "--exclude-standard"])) set.add(f);
|
|
149
|
-
}
|
|
150
|
-
const all = [...set];
|
|
177
|
+
// tree) and now, split into the full list and the subset outside .doctrina/.
|
|
178
|
+
// The git question itself goes through the one door (lib/git.js).
|
|
179
|
+
function reviewScope(projectRoot, against) {
|
|
180
|
+
// Against a named ref, untracked files are noise: the question is what this
|
|
181
|
+
// branch changed, not what is lying around uncommitted.
|
|
182
|
+
const all = changedFiles(projectRoot, { since: against, untracked: !against }).files;
|
|
151
183
|
const sourceFiles = all.filter((f) => !f.replace(/\\/g, "/").startsWith(".doctrina/"));
|
|
152
184
|
return { all, sourceFiles };
|
|
153
185
|
}
|
package/src/commands/search.js
CHANGED
|
@@ -4,6 +4,7 @@ import process from "node:process";
|
|
|
4
4
|
import { exists, isDir, read, relPath, walk } from "../lib/fs-ops.js";
|
|
5
5
|
import { flagBool } from "../lib/args.js";
|
|
6
6
|
import { c } from "../lib/colors.js";
|
|
7
|
+
import { fold } from "../lib/lexicon.js";
|
|
7
8
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
8
9
|
|
|
9
10
|
// Category-aware full-text search across the artifact tree. Answers
|
|
@@ -25,7 +26,13 @@ const MAX_MATCHES_PER_FILE = 5;
|
|
|
25
26
|
export const flags = { boolean: ["json", "archive"], string: [] };
|
|
26
27
|
|
|
27
28
|
export async function run(positional, flags) {
|
|
28
|
-
|
|
29
|
+
// Folded, not merely lowercased (third audit, finding 8). `search` matched
|
|
30
|
+
// raw substrings, so an accent decided the answer: `search patrimonio`
|
|
31
|
+
// found nothing in a spec that says "patrimônio" — and typing without the
|
|
32
|
+
// accent is the common case, not the rare one. The shared lexicon (ADR
|
|
33
|
+
// 0040) already folds for `work` and `context --for`; `search` was the
|
|
34
|
+
// one retrieval surface still deciding on its own.
|
|
35
|
+
const terms = positional.map((t) => fold(t)).filter(Boolean);
|
|
29
36
|
if (terms.length === 0) {
|
|
30
37
|
console.error(c.red("error:") + " search requires at least one term");
|
|
31
38
|
return 2;
|
|
@@ -64,7 +71,7 @@ export async function run(positional, flags) {
|
|
|
64
71
|
const hits = [];
|
|
65
72
|
for (let i = 0; i < lines.length; i++) {
|
|
66
73
|
const raw = lines[i];
|
|
67
|
-
const low = raw
|
|
74
|
+
const low = fold(raw);
|
|
68
75
|
if (terms.every((t) => low.includes(t))) {
|
|
69
76
|
hits.push({ line: i + 1, text: raw.trim(), score: lineScore(raw, low, phrase) });
|
|
70
77
|
}
|
|
@@ -92,7 +99,14 @@ export async function run(positional, flags) {
|
|
|
92
99
|
|
|
93
100
|
if (totalMatches === 0) {
|
|
94
101
|
console.log(c.gray(`no matches for "${positional.join(" ")}"${includeArchive ? "" : " (archive excluded; pass --archive to include it)"}`));
|
|
95
|
-
|
|
102
|
+
// Not finding is the answer, not a failure. Exit 1 is GATE in the
|
|
103
|
+
// five-class contract — "fix the work, retry the same command" — and
|
|
104
|
+
// this command fixes nothing. The `insight` spec says it outright: a
|
|
105
|
+
// VIEW assembles what is there and never refuses anything, and the
|
|
106
|
+
// other six listings (spec, decision, skill, contract, templates,
|
|
107
|
+
// intent) all exit 0 on an empty result. The grep convention is a
|
|
108
|
+
// shell habit; this CLI declared its own contract (change 0093).
|
|
109
|
+
return 0;
|
|
96
110
|
}
|
|
97
111
|
console.log(c.gray(`${totalMatches} match${totalMatches === 1 ? "" : "es"} in ${filesWithMatches} file${filesWithMatches === 1 ? "" : "s"}`));
|
|
98
112
|
return 0;
|
|
@@ -116,7 +130,7 @@ function lineScore(raw, low, phrase) {
|
|
|
116
130
|
// A file whose name carries the query is almost always the right one;
|
|
117
131
|
// reward a full filename match more than a partial one.
|
|
118
132
|
function filenameBonus(rel, terms) {
|
|
119
|
-
const base = rel
|
|
133
|
+
const base = fold(rel);
|
|
120
134
|
if (terms.every((t) => base.includes(t))) return 3;
|
|
121
135
|
return terms.some((t) => base.includes(t)) ? 1 : 0;
|
|
122
136
|
}
|
package/src/commands/show.js
CHANGED
|
@@ -6,7 +6,8 @@ import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
|
6
6
|
import { parseAcceptanceCriteria } from "../lib/criteria.js";
|
|
7
7
|
import { suggest } from "../lib/suggest.js";
|
|
8
8
|
import { c } from "../lib/colors.js";
|
|
9
|
-
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
|
+
import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
|
|
10
|
+
import { maskComments } from "../lib/doc-model.js";
|
|
10
11
|
|
|
11
12
|
// Point reads. An agent that needs ONE requirement re-reads a whole spec —
|
|
12
13
|
// hundreds of lines for a two-line fact. `show` resolves a compact reference
|
|
@@ -57,7 +58,7 @@ function showAdr(projectRoot, num) {
|
|
|
57
58
|
const file = isDir(dir) ? readdirSync(dir).find((f) => f.startsWith(`${num}-`) && f.endsWith(".md")) : null;
|
|
58
59
|
if (!file) {
|
|
59
60
|
console.error(c.red("error:") + ` no ADR ${num} under .doctrina/decisions/`);
|
|
60
|
-
return
|
|
61
|
+
return EXIT.USAGE;
|
|
61
62
|
}
|
|
62
63
|
const full = path.join(dir, file);
|
|
63
64
|
console.log(c.gray(`— ${relPath(projectRoot, full)} —`));
|
|
@@ -79,7 +80,7 @@ function loadSpec(projectRoot, cap) {
|
|
|
79
80
|
|
|
80
81
|
function showSpecHead(projectRoot, cap) {
|
|
81
82
|
const spec = loadSpec(projectRoot, cap);
|
|
82
|
-
if (!spec) return
|
|
83
|
+
if (!spec) return EXIT.USAGE;
|
|
83
84
|
const lines = spec.text.split(/\r?\n/);
|
|
84
85
|
const out = [];
|
|
85
86
|
let mode = "head"; // header block -> seek Purpose -> purpose body -> stop
|
|
@@ -98,14 +99,14 @@ function showSpecHead(projectRoot, cap) {
|
|
|
98
99
|
|
|
99
100
|
function showSpecItem(projectRoot, cap, kind, n) {
|
|
100
101
|
const spec = loadSpec(projectRoot, cap);
|
|
101
|
-
if (!spec) return
|
|
102
|
+
if (!spec) return EXIT.USAGE;
|
|
102
103
|
|
|
103
104
|
if (kind === "C") {
|
|
104
105
|
const criteria = parseAcceptanceCriteria(spec.text);
|
|
105
106
|
const crit = criteria.find((r) => r.n === n);
|
|
106
107
|
if (!crit) {
|
|
107
108
|
console.error(c.red("error:") + ` ${cap} has no acceptance criterion #${n} (it declares ${criteria.length})`);
|
|
108
|
-
return
|
|
109
|
+
return EXIT.USAGE;
|
|
109
110
|
}
|
|
110
111
|
console.log(c.gray(`— ${cap}-C${n} · ${relPath(projectRoot, spec.specPath)} · ## Acceptance criteria —`));
|
|
111
112
|
console.log(`${n}. ${crit.body}`);
|
|
@@ -119,7 +120,7 @@ function showSpecItem(projectRoot, cap, kind, n) {
|
|
|
119
120
|
const req = reqs[n - 1];
|
|
120
121
|
if (!req) {
|
|
121
122
|
console.error(c.red("error:") + ` ${cap} has no requirement R${n} (it declares ${reqs.length})`);
|
|
122
|
-
return
|
|
123
|
+
return EXIT.USAGE;
|
|
123
124
|
}
|
|
124
125
|
console.log(c.gray(`— ${cap}-R${n} · ${relPath(projectRoot, spec.specPath)} · ${req.section} —`));
|
|
125
126
|
console.log(req.text);
|
|
@@ -129,7 +130,13 @@ function showSpecItem(projectRoot, cap, kind, n) {
|
|
|
129
130
|
// Top-level bullets of the "## Requirements (EARS)" section, in file order,
|
|
130
131
|
// each with its `### <grammar>` sub-section name and continuation lines.
|
|
131
132
|
export function parseRequirements(text) {
|
|
132
|
-
|
|
133
|
+
// The scaffolded spec writes the five-line EARS legend as bullets inside
|
|
134
|
+
// an HTML comment under `## Requirements (EARS)`. Counted as content, the
|
|
135
|
+
// legend became R1..R5 and the capability's first real requirement was R6
|
|
136
|
+
// — on every spec `doctrina spec new` had ever created. Comments are
|
|
137
|
+
// blanked by position (same offsets, same line count), so the numbering
|
|
138
|
+
// below is over authored bullets only.
|
|
139
|
+
const lines = maskComments(text).split(/\r?\n/);
|
|
133
140
|
const out = [];
|
|
134
141
|
let inSection = false;
|
|
135
142
|
let sub = "";
|
|
@@ -175,6 +182,14 @@ Point-read one artifact fragment instead of a whole file:
|
|
|
175
182
|
doctrina show 0007 ADR 0007 (the whole decision)
|
|
176
183
|
doctrina show cli the spec's header block + Purpose only
|
|
177
184
|
|
|
178
|
-
R-refs are positional
|
|
179
|
-
C-refs use the criteria's own numbers.
|
|
185
|
+
R-refs are positional across the WHOLE spec, in file order, and they shift
|
|
186
|
+
when a requirement is inserted above. C-refs use the criteria's own numbers.
|
|
187
|
+
Both count authored content only — a bullet inside an HTML comment (the
|
|
188
|
+
scaffold's EARS legend, for one) is annotation and is never numbered.
|
|
189
|
+
|
|
190
|
+
A spec delta's replace-requirement <section> <n> numbers differently, and
|
|
191
|
+
on purpose: it counts WITHIN one "### <section>", so the number stays put
|
|
192
|
+
when another section grows. doctrina show <cap>-RN prints the section it
|
|
193
|
+
landed in, which is what turns an R-ref into the delta's pair of coordinates.
|
|
194
|
+
Read-only.
|
|
180
195
|
`;
|