doctrina-cli 0.15.0 → 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 -199
- 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 +7 -75
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +121 -160
- 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 +109 -6
- 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 +210 -24
- 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 +238 -11
- 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/contract.md.template +7 -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/skill.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { readdirSync } from "node:fs";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
6
5
|
import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
|
|
7
6
|
import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
8
7
|
import * as idx from "../lib/index-json.js";
|
|
@@ -11,6 +10,10 @@ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
|
11
10
|
import { c } from "../lib/colors.js";
|
|
12
11
|
import { suggest } from "../lib/suggest.js";
|
|
13
12
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
|
+
import { parseFrontmatter } from "../lib/doc-model.js";
|
|
14
|
+
import { git, GIT_STATE } from "../lib/git.js";
|
|
15
|
+
import { FIX_SHAPED, FIX_SHAPED_SUBJECT } from "../lib/lexicon.js";
|
|
16
|
+
import { artifactNameError } from "../lib/names.js";
|
|
14
17
|
|
|
15
18
|
const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
|
|
16
19
|
|
|
@@ -46,15 +49,7 @@ export async function run(positional, flags) {
|
|
|
46
49
|
// --write it scaffolds a stub per candidate, pre-seeded from the source so the
|
|
47
50
|
// human/agent only fills the body. Both sources are a pattern match — a hint,
|
|
48
51
|
// never a decision (ADR 0005); skills are still authored, never generated.
|
|
49
|
-
const FIX_SHAPED = /(?:^|-)(fix|bug|hotfix|patch|parse|parsing|tolerate|workaround|race|deadlock|flaky|retry|escape|sanitize|sanitise)(?:-|$)/;
|
|
50
52
|
|
|
51
|
-
// The same idea applied to a commit subject. Narrower than FIX_SHAPED on
|
|
52
|
-
// purpose: a deliberate folder slug tolerates more noise than a free-form
|
|
53
|
-
// subject line. Matches conventional fix-type prefixes (fix:, fix(scope):,
|
|
54
|
-
// bug:, hotfix:, patch:, and the free-form "Fix the…") plus the strongly
|
|
55
|
-
// fix-flavoured debugging keywords — never feat:/refactor:/docs:/chore:.
|
|
56
|
-
const FIX_SHAPED_SUBJECT =
|
|
57
|
-
/^(?:fix|bug|hotfix|patch)\b|\b(?:tolerate|workaround|deadlock|flaky|race condition|retry|sanitiz|sanitis)/i;
|
|
58
53
|
|
|
59
54
|
// How far back the git source looks when --since is not given, and how many
|
|
60
55
|
// candidates we print before collapsing the rest into a "+N more" line.
|
|
@@ -379,8 +374,8 @@ function gitFixCommits(projectRoot, { since, limit }) {
|
|
|
379
374
|
if (since) args.push(`${since}..HEAD`);
|
|
380
375
|
else args.push("-n", String(limit));
|
|
381
376
|
|
|
382
|
-
const r =
|
|
383
|
-
if (r.
|
|
377
|
+
const r = git(projectRoot, args);
|
|
378
|
+
if (r.state !== GIT_STATE.OK || !r.stdout) return [];
|
|
384
379
|
|
|
385
380
|
/** @type {SkillCandidate[]} */
|
|
386
381
|
const out = [];
|
|
@@ -449,8 +444,9 @@ function firstWhyLine(proposalText) {
|
|
|
449
444
|
|
|
450
445
|
function skillNew(args, flags) {
|
|
451
446
|
const name = args[0];
|
|
452
|
-
|
|
453
|
-
|
|
447
|
+
const nameError = artifactNameError(name, "skill name");
|
|
448
|
+
if (nameError) {
|
|
449
|
+
console.error(c.red("error:") + ` ${nameError} (e.g. "db-migration")`);
|
|
454
450
|
return 2;
|
|
455
451
|
}
|
|
456
452
|
const force = flagBool(flags, "force", false);
|
|
@@ -512,6 +508,13 @@ function skillSync() {
|
|
|
512
508
|
console.log(c.yellow("skip ") + ` ${id} (no description frontmatter)`);
|
|
513
509
|
continue;
|
|
514
510
|
}
|
|
511
|
+
// A description still in the template's `<...>` form is not "up to
|
|
512
|
+
// date" (change 0111): it is a file nobody wrote yet, and mirroring it
|
|
513
|
+
// into the index is what put the placeholder in every context pack.
|
|
514
|
+
if (/^<[^<>]*>$/.test(desc.trim())) {
|
|
515
|
+
console.log(c.yellow("scaffold") + ` ${id} — description is still the scaffold's placeholder; write description/when, then sync`);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
515
518
|
const entry = (index.artifacts.skills ?? []).find((s) => s.id === id);
|
|
516
519
|
if (!entry) {
|
|
517
520
|
idx.addSkill(index, {
|
|
@@ -568,15 +571,6 @@ function skillList() {
|
|
|
568
571
|
return 0;
|
|
569
572
|
}
|
|
570
573
|
|
|
571
|
-
function parseFrontmatter(text, key) {
|
|
572
|
-
// Match frontmatter blocks bounded by `---` lines at start of file.
|
|
573
|
-
const fmMatch = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
|
574
|
-
if (!fmMatch) return null;
|
|
575
|
-
const block = fmMatch[1];
|
|
576
|
-
const lineRe = new RegExp(`^${key}\\s*:\\s*(.+)$`, "m");
|
|
577
|
-
const m = block.match(lineRe);
|
|
578
|
-
return m ? m[1].trim() : null;
|
|
579
|
-
}
|
|
580
574
|
|
|
581
575
|
function ensureDoctrinaProject(projectRoot) {
|
|
582
576
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
@@ -618,6 +612,3 @@ Skills are written by humans, not generated. See docs/en/skills.md
|
|
|
618
612
|
for the design rationale and the distinction from specs / AGENTS.md /
|
|
619
613
|
the rejected memory/ folder.
|
|
620
614
|
`;
|
|
621
|
-
|
|
622
|
-
// Re-export the frontmatter parser so validate.js can reuse it.
|
|
623
|
-
export { parseFrontmatter };
|
package/src/commands/spec.js
CHANGED
|
@@ -11,7 +11,9 @@ import { today } from "../lib/dates.js";
|
|
|
11
11
|
import { flagBool, flagString } from "../lib/args.js";
|
|
12
12
|
import { c } from "../lib/colors.js";
|
|
13
13
|
import { suggest } from "../lib/suggest.js";
|
|
14
|
-
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
14
|
+
import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
|
|
15
|
+
import { derivedImplementations } from "../lib/coverage-model.js";
|
|
16
|
+
import { artifactNameError } from "../lib/names.js";
|
|
15
17
|
|
|
16
18
|
const SUBCOMMANDS = ["new", "list", "set"];
|
|
17
19
|
|
|
@@ -34,8 +36,9 @@ export async function run(positional, flags) {
|
|
|
34
36
|
if (sub === "set") return specSet(positional.slice(1), flags);
|
|
35
37
|
|
|
36
38
|
const capability = positional[1];
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
const nameError = artifactNameError(capability, "capability");
|
|
40
|
+
if (nameError) {
|
|
41
|
+
console.error(c.red("error:") + ` ${nameError} (e.g. "core", "templates")`);
|
|
39
42
|
return 2;
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -135,7 +138,7 @@ function specSet(args, flags) {
|
|
|
135
138
|
const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
|
|
136
139
|
if (!isFile(specPath)) {
|
|
137
140
|
console.error(c.red("error:") + ` no spec at ${relPath(projectRoot, specPath)} (create it with \`doctrina spec new ${capability}\`)`);
|
|
138
|
-
return
|
|
141
|
+
return EXIT.USAGE;
|
|
139
142
|
}
|
|
140
143
|
|
|
141
144
|
const impl = flagString(flags, "implementation");
|
|
@@ -159,7 +162,25 @@ function specSet(args, flags) {
|
|
|
159
162
|
// Order: headers, then version, then criterion — independent edits, all or
|
|
160
163
|
// none (a failed op leaves the spec untouched, like `change apply`).
|
|
161
164
|
if (status !== undefined) apply(setHeader(text, "Status", status));
|
|
162
|
-
if (impl !== undefined)
|
|
165
|
+
if (impl !== undefined) {
|
|
166
|
+
// `--implementation auto` reads the value off the coverage arithmetic
|
|
167
|
+
// instead of the author's memory (audit finding F10): coverage already
|
|
168
|
+
// knows how many of this spec's criteria cite proof that resolves, and
|
|
169
|
+
// that is the definition of "verified". A spec with no acceptance
|
|
170
|
+
// criteria has nothing to derive from, so `auto` refuses rather than
|
|
171
|
+
// guessing — the spec is left untouched, like any other failed op.
|
|
172
|
+
if (String(impl).trim().toLowerCase() === "auto") {
|
|
173
|
+
const derivedValue = derivedImplementations(projectRoot, { only: new Set([capability]) })
|
|
174
|
+
.get(capability)?.derived;
|
|
175
|
+
if (!derivedValue) {
|
|
176
|
+
errors.push(`--implementation auto needs acceptance criteria to derive from; ${capability} declares none`);
|
|
177
|
+
} else {
|
|
178
|
+
apply(setHeader(text, "Implementation", derivedValue));
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
apply(setHeader(text, "Implementation", impl));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
163
184
|
if (bump !== undefined) {
|
|
164
185
|
if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
|
|
165
186
|
else apply(bumpVersion(text, bump));
|
package/src/commands/status.js
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import * as idx from "../lib/index-json.js";
|
|
7
|
-
import { deriveIndex, indexesMatch, specHeader, listHeader } from "../lib/scan.js";
|
|
8
|
-
import { cliVersion } from "../lib/version.js";
|
|
9
|
-
import { summarize as coverageSummary } from "./coverage.js";
|
|
10
|
-
import { summarize as traceSummary } from "./trace.js";
|
|
11
|
-
import { flagBool } from "../lib/args.js";
|
|
4
|
+
import { exists } from "../lib/fs-ops.js";
|
|
5
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
12
6
|
import { c } from "../lib/colors.js";
|
|
13
7
|
import { emitJson } from "../lib/json-out.js";
|
|
14
8
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
|
+
import { collectSnapshot, collectStatus } from "../lib/snapshot.js";
|
|
10
|
+
import { renderView, VIEWS } from "../lib/views.js";
|
|
11
|
+
import { gitWindow, historyState, windowCutoff } from "../lib/git.js";
|
|
12
|
+
import { suggest } from "../lib/suggest.js";
|
|
15
13
|
|
|
16
14
|
// One-glance project health (review 2026-06-27 passive-user feature #1): a
|
|
17
15
|
// dashboard that answers "where do things stand?" in a single read, so neither
|
|
18
16
|
// the human nor the agent has to run validate + coverage + trace + next
|
|
19
|
-
// separately. Strictly read-only; never mutates and always exits 0.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
// separately. Strictly read-only; never mutates and always exits 0.
|
|
18
|
+
//
|
|
19
|
+
// Since change 0037 this is also the HOME of the other read-only views:
|
|
20
|
+
// `--view prime|handoff|report` renders the same snapshot in a different
|
|
21
|
+
// shape. `prime`, `handoff` and `report` remain as their own commands (they
|
|
22
|
+
// are what AGENTS.md tells an agent to run) and delegate straight here, so
|
|
23
|
+
// there is one collector, one set of formatters, and no way for the four to
|
|
24
|
+
// report different numbers (audit finding F7).
|
|
25
25
|
|
|
26
26
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
27
27
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -30,7 +30,7 @@ const CONFIG_REL = ".doctrina/verify.json";
|
|
|
30
30
|
// wrap it in the generic envelope.
|
|
31
31
|
export const jsonNative = true;
|
|
32
32
|
|
|
33
|
-
export const flags = { boolean: ["json"], string: [] };
|
|
33
|
+
export const flags = { boolean: ["json"], string: ["view", "since"] };
|
|
34
34
|
|
|
35
35
|
export async function run(_positional, flags) {
|
|
36
36
|
const projectRoot = process.cwd();
|
|
@@ -38,173 +38,42 @@ export async function run(_positional, flags) {
|
|
|
38
38
|
throw notADoctrinaProject();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const
|
|
41
|
+
const view = flagString(flags, "view") ?? "dashboard";
|
|
42
|
+
if (!VIEWS.includes(view)) {
|
|
43
|
+
console.error(c.red("error:") + ` unknown view "${view}"`);
|
|
44
|
+
const guess = suggest(view, VIEWS);
|
|
45
|
+
console.error(c.gray("hint: ") + (guess ? `did you mean --view ${guess}?` : `available: ${VIEWS.join(", ")}`));
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
42
48
|
|
|
49
|
+
// --json always answers with the status shape, whatever the view: the
|
|
50
|
+
// envelope is a machine contract and must not change with a formatting flag.
|
|
43
51
|
if (flagBool(flags, "json", false)) {
|
|
44
|
-
emitJson("status",
|
|
52
|
+
emitJson("status", collectStatus(projectRoot));
|
|
45
53
|
return 0;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
|
|
56
|
-
} else if (s.indexState === "in-sync") {
|
|
57
|
-
driftLine = c.green("in sync");
|
|
58
|
-
} else {
|
|
59
|
-
driftLine = c.yellow("drifted") + c.gray(" — run `doctrina validate --fix`");
|
|
60
|
-
}
|
|
61
|
-
console.log(` ${"index".padEnd(11)} ${driftLine}`);
|
|
62
|
-
|
|
63
|
-
const stampLine = s.stamp === s.cli
|
|
64
|
-
? c.green("current")
|
|
65
|
-
: c.yellow(`${s.stamp ?? "—"} (CLI ${s.cli})`) + c.gray(" — `doctrina index rebuild`");
|
|
66
|
-
console.log(` ${"stamp".padEnd(11)} ${stampLine}`);
|
|
67
|
-
|
|
68
|
-
const cov = s.coverage;
|
|
69
|
-
const covExtra = cov.totalDangling + cov.totalConditional > 0
|
|
70
|
-
? c.yellow(` ${cov.totalDangling} dangling, ${cov.totalConditional} conditional`)
|
|
71
|
-
: "";
|
|
72
|
-
const covColor = cov.pct === 100 ? c.green : cov.pct >= 50 ? c.yellow : c.red;
|
|
73
|
-
console.log(` ${"coverage".padEnd(11)} ${covColor(`${cov.pct}%`)} ${c.gray(`(${cov.totalCovered}/${cov.totalCriteria} criteria)`)}${covExtra}`);
|
|
74
|
-
|
|
75
|
-
const tr = s.trace;
|
|
76
|
-
const trExtra = tr.untraceable + tr.dropped + tr.dangling > 0
|
|
77
|
-
? c.yellow(` ${tr.dropped} dropped, ${tr.untraceable} untraceable`)
|
|
78
|
-
: "";
|
|
79
|
-
const trColor = tr.anchors === 0 ? c.gray : (tr.realized === tr.anchors && tr.untraceable === 0 ? c.green : c.yellow);
|
|
80
|
-
const trText = tr.anchors === 0 ? "no anchors" : `${tr.realized}/${tr.anchors} anchors`;
|
|
81
|
-
console.log(` ${"trace".padEnd(11)} ${trColor(trText)}${trExtra}`);
|
|
82
|
-
|
|
83
|
-
let verifyLine = c.gray("not configured") + c.gray(" — `doctrina verify --init`");
|
|
84
|
-
if (s.verify.invalid) {
|
|
85
|
-
verifyLine = c.red("invalid JSON");
|
|
86
|
-
} else if (s.verify.configured) {
|
|
87
|
-
const n = s.verify.checks;
|
|
88
|
-
verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
|
|
89
|
-
}
|
|
90
|
-
console.log(` ${"verify".padEnd(11)} ${verifyLine}`);
|
|
91
|
-
|
|
92
|
-
// --- Work ---
|
|
93
|
-
console.log("");
|
|
94
|
-
console.log(c.bold(" Work"));
|
|
95
|
-
const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
|
|
96
|
-
console.log(` ${"specs".padEnd(11)} ${s.specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
|
|
97
|
-
console.log(` ${"changes".padEnd(11)} ${s.specs.openChanges} open`);
|
|
98
|
-
const adrNotes = [];
|
|
99
|
-
if (s.decisions.proposed > 0) adrNotes.push(c.yellow(`${s.decisions.proposed} proposed`));
|
|
100
|
-
if (s.decisions.bare > 0) adrNotes.push(c.yellow(`${s.decisions.bare} unproven`));
|
|
101
|
-
console.log(` ${"decisions".padEnd(11)} ${s.decisions.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
|
|
102
|
-
console.log(` ${"skills".padEnd(11)} ${s.skills}`);
|
|
103
|
-
|
|
104
|
-
console.log("");
|
|
105
|
-
console.log(c.gray(" Full gates: `doctrina validate` · `doctrina verify`. Next step: `doctrina next`."));
|
|
106
|
-
return 0;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Pure, render-free status snapshot — the data behind the dashboard, shared
|
|
110
|
-
// with `prime`, `handoff`, `doctor`, and the `--json` output so every consumer
|
|
111
|
-
// reads the same numbers.
|
|
112
|
-
export function collectStatus(projectRoot) {
|
|
113
|
-
let index = null;
|
|
114
|
-
try {
|
|
115
|
-
index = idx.load(projectRoot);
|
|
116
|
-
} catch {
|
|
117
|
-
index = null;
|
|
118
|
-
}
|
|
119
|
-
let indexState = "missing";
|
|
120
|
-
if (index) {
|
|
121
|
-
indexState = indexesMatch(deriveIndex(projectRoot, index), index) ? "in-sync" : "drifted";
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const verifyCfg = path.join(projectRoot, CONFIG_REL);
|
|
125
|
-
let verify = { configured: false, checks: 0, invalid: false };
|
|
126
|
-
if (isFile(verifyCfg)) {
|
|
127
|
-
try {
|
|
128
|
-
const cfg = JSON.parse(read(verifyCfg));
|
|
129
|
-
verify = { configured: true, checks: Array.isArray(cfg?.checks) ? cfg.checks.length : 0, invalid: false };
|
|
130
|
-
} catch {
|
|
131
|
-
verify = { configured: true, checks: 0, invalid: true };
|
|
56
|
+
const options = {};
|
|
57
|
+
if (view === "report") {
|
|
58
|
+
const sinceRaw = flagString(flags, "since") ?? "7";
|
|
59
|
+
const days = Number.parseInt(sinceRaw, 10);
|
|
60
|
+
if (!Number.isFinite(days) || days <= 0) {
|
|
61
|
+
console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
|
|
62
|
+
return 2;
|
|
132
63
|
}
|
|
64
|
+
options.days = days;
|
|
65
|
+
options.cutoffIso = windowCutoff(days);
|
|
66
|
+
options.git = gitWindow(projectRoot, days);
|
|
67
|
+
options.gitState = historyState(projectRoot);
|
|
133
68
|
}
|
|
134
69
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
cli: cliVersion(),
|
|
139
|
-
indexState,
|
|
140
|
-
coverage: coverageSummary(projectRoot),
|
|
141
|
-
trace: traceSummary(projectRoot),
|
|
142
|
-
verify,
|
|
143
|
-
specs: countSpecs(projectRoot),
|
|
144
|
-
decisions: countDecisions(projectRoot),
|
|
145
|
-
skills: countSkills(projectRoot),
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function countSpecs(projectRoot) {
|
|
150
|
-
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
151
|
-
const impl = {};
|
|
152
|
-
let total = 0;
|
|
153
|
-
if (isDir(specsDir)) {
|
|
154
|
-
for (const cap of readdirSync(specsDir).sort()) {
|
|
155
|
-
const p = path.join(specsDir, cap, "spec.md");
|
|
156
|
-
if (!isFile(p)) continue;
|
|
157
|
-
total += 1;
|
|
158
|
-
const implRaw = specHeader(read(p), "Implementation");
|
|
159
|
-
if (implRaw) {
|
|
160
|
-
const word = implRaw.trim().split(/[\s—-]+/)[0].toLowerCase();
|
|
161
|
-
impl[word] = (impl[word] ?? 0) + 1;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
166
|
-
let openChanges = 0;
|
|
167
|
-
if (isDir(changesDir)) {
|
|
168
|
-
for (const e of readdirSync(changesDir)) {
|
|
169
|
-
if (e === "archive" || e.startsWith(".")) continue;
|
|
170
|
-
if (isDir(path.join(changesDir, e))) openChanges += 1;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return { total, impl, openChanges };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function countDecisions(projectRoot) {
|
|
177
|
-
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
178
|
-
let total = 0, proposed = 0, bare = 0;
|
|
179
|
-
const isBare = (v) => {
|
|
180
|
-
const t = (v ?? "").trim();
|
|
181
|
-
return t === "" || t === "—" || t === "-";
|
|
182
|
-
};
|
|
183
|
-
if (isDir(adrDir)) {
|
|
184
|
-
for (const f of walk(adrDir)) {
|
|
185
|
-
if (!/^\d{4}-.+\.md$/.test(path.basename(f))) continue;
|
|
186
|
-
total += 1;
|
|
187
|
-
const text = read(f);
|
|
188
|
-
const status = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
189
|
-
if (status === "proposed") proposed += 1;
|
|
190
|
-
if (status === "accepted") {
|
|
191
|
-
const evidence = listHeader(text, "Evidence");
|
|
192
|
-
const landed = listHeader(text, "Landed");
|
|
193
|
-
if (evidence !== null && isBare(evidence) && isBare(landed)) bare += 1;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return { total, proposed, bare };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function countSkills(projectRoot) {
|
|
201
|
-
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
202
|
-
if (!isDir(skillsDir)) return 0;
|
|
203
|
-
return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
|
|
70
|
+
const snapshot = collectSnapshot(projectRoot);
|
|
71
|
+
for (const line of renderView(view, snapshot, options)) console.log(line);
|
|
72
|
+
return 0;
|
|
204
73
|
}
|
|
205
74
|
|
|
206
75
|
export const help = `
|
|
207
|
-
Usage: doctrina status [--json]
|
|
76
|
+
Usage: doctrina status [--view <name>] [--since <days>] [--json]
|
|
208
77
|
|
|
209
78
|
Print a one-glance health dashboard for the .doctrina/ project: the gate
|
|
210
79
|
signals (index drift, framework stamp, coverage %, trace anchors, whether
|
|
@@ -212,7 +81,17 @@ verify is configured) and the artifact counts (specs by implementation
|
|
|
212
81
|
state, open changes, decisions, skills). Read-only; always exits 0.
|
|
213
82
|
|
|
214
83
|
Flags:
|
|
215
|
-
--
|
|
84
|
+
--view <name> Render a different shape of the same snapshot:
|
|
85
|
+
dashboard the default one-glance health board
|
|
86
|
+
prime the session primer (\`doctrina prime\`)
|
|
87
|
+
handoff the Markdown resume note (\`doctrina handoff\`)
|
|
88
|
+
report the period digest (\`doctrina report\`)
|
|
89
|
+
--since <days> With --view report: the window (default 7).
|
|
90
|
+
--json Emit the snapshot as JSON (stable shape for agents and CI).
|
|
91
|
+
|
|
92
|
+
All four views read ONE collection of the tree, so they can never report
|
|
93
|
+
different numbers; \`prime\`, \`handoff\` and \`report\` are the same views
|
|
94
|
+
under their own names.
|
|
216
95
|
|
|
217
96
|
It is a fast summary, not the authoritative gate: run \`doctrina validate\`
|
|
218
97
|
and \`doctrina verify\` for the full structural and build checks, and
|
|
@@ -13,28 +13,15 @@ import { cliVersion } from "../lib/version.js";
|
|
|
13
13
|
import { ARTIFACT_CATEGORIES } from "../lib/index-json.js";
|
|
14
14
|
import { flagBool } from "../lib/args.js";
|
|
15
15
|
import { c } from "../lib/colors.js";
|
|
16
|
+
import { collectFindings, agentsSectionCost, AGENTS_SECTIONS, PRODUCT_SECTIONS, normalizeBlock, hasHeading } from "../lib/templates-model.js";
|
|
17
|
+
|
|
18
|
+
// The findings are collected in lib/templates-model.js, which `doctor` reads
|
|
19
|
+
// too (audit finding F7); this command renders and fixes them.
|
|
20
|
+
export { collectFindings } from "../lib/templates-model.js";
|
|
16
21
|
import { suggest } from "../lib/suggest.js";
|
|
17
22
|
|
|
18
23
|
const SUBCOMMANDS = ["list", "check", "update"];
|
|
19
24
|
|
|
20
|
-
// Recommended sections per file kind. Adopters whose files lack these
|
|
21
|
-
// headings get a warning from `templates check`; they are recommendations,
|
|
22
|
-
// not hard requirements (validate handles the hard requirements).
|
|
23
|
-
const AGENTS_SECTIONS = [
|
|
24
|
-
"## Stack",
|
|
25
|
-
"## Commands",
|
|
26
|
-
"## Repository structure",
|
|
27
|
-
"## Conventions and boundaries",
|
|
28
|
-
"## How to read context",
|
|
29
|
-
];
|
|
30
|
-
const PRODUCT_SECTIONS = [
|
|
31
|
-
"## Vision",
|
|
32
|
-
"## Problem",
|
|
33
|
-
"## Target users",
|
|
34
|
-
"## Scope",
|
|
35
|
-
"## Success criteria",
|
|
36
|
-
];
|
|
37
|
-
const INDEX_FIELDS = ["$schema_version", "project", "artifacts"];
|
|
38
25
|
|
|
39
26
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
40
27
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -69,6 +56,9 @@ function updateTemplates(flags) {
|
|
|
69
56
|
}
|
|
70
57
|
|
|
71
58
|
const plan = [];
|
|
59
|
+
// Updates this run declines to make, each with the reason and the number.
|
|
60
|
+
// Standing down is a RESULT, not a silence (change 0078).
|
|
61
|
+
const held = [];
|
|
72
62
|
|
|
73
63
|
// AGENTS.md command-surface block: the one CLI-OWNED span of the file
|
|
74
64
|
// (marker-delimited, generated from the command catalog). Three cases:
|
|
@@ -165,6 +155,20 @@ function updateTemplates(flags) {
|
|
|
165
155
|
if (!isFile(filePath)) continue;
|
|
166
156
|
const text = read(filePath);
|
|
167
157
|
const missing = sections.filter((h) => !hasHeading(text, h));
|
|
158
|
+
// AGENTS.md has a declared line ceiling, and `analyze` refuses a change
|
|
159
|
+
// that resolves an overflow by raising it — so appending stubs that do
|
|
160
|
+
// not fit would trade one gate's recommendation for another's refusal,
|
|
161
|
+
// silently. The plan says what it would cost and stands down instead
|
|
162
|
+
// (change 0078); the check's remedy already names the cut to make first.
|
|
163
|
+
if (missing.length > 0 && rel === "AGENTS.md") {
|
|
164
|
+
const budget = agentsSectionCost(projectRoot, missing);
|
|
165
|
+
if (!budget.fits) {
|
|
166
|
+
held.push(`AGENTS.md: ${missing.length} stub section(s) not appended — they cost ` +
|
|
167
|
+
`${budget.cost} lines and only ${budget.slack} remain of the declared ceiling; ` +
|
|
168
|
+
`cut ${budget.over} line(s) of prose first`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
168
172
|
if (missing.length > 0) {
|
|
169
173
|
plan.push({
|
|
170
174
|
describe: missing.map((h) => `${rel}: append stub section "${h}"`),
|
|
@@ -215,7 +219,16 @@ function updateTemplates(flags) {
|
|
|
215
219
|
}
|
|
216
220
|
|
|
217
221
|
const steps = plan.flatMap((p) => p.describe);
|
|
222
|
+
for (const reason of held) {
|
|
223
|
+
console.log(c.yellow("held ") + reason);
|
|
224
|
+
}
|
|
218
225
|
if (steps.length === 0) {
|
|
226
|
+
if (held.length > 0) {
|
|
227
|
+
console.log("");
|
|
228
|
+
console.log(c.yellow("hold") + ` ${held.length} update${held.length === 1 ? "" : "s"} not applied — ` +
|
|
229
|
+
"make room first, then re-run");
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
219
232
|
console.log(c.green("ok") + " project already follows the current template shape");
|
|
220
233
|
return 0;
|
|
221
234
|
}
|
|
@@ -284,117 +297,6 @@ function checkTemplates() {
|
|
|
284
297
|
return report(findings, ok);
|
|
285
298
|
}
|
|
286
299
|
|
|
287
|
-
// The findings `templates check` reports, as structured records with an
|
|
288
|
-
// executable remedy. Exported so a test can seed each finding, run the
|
|
289
|
-
// remedy it names, and assert the finding clears — a remedy that cannot
|
|
290
|
-
// resolve its own finding is not a remedy (C2).
|
|
291
|
-
export function collectFindings(projectRoot) {
|
|
292
|
-
const findings = [];
|
|
293
|
-
const ok = [];
|
|
294
|
-
|
|
295
|
-
// AGENTS.md sections
|
|
296
|
-
const agentsPath = path.join(projectRoot, "AGENTS.md");
|
|
297
|
-
if (isFile(agentsPath)) {
|
|
298
|
-
const text = read(agentsPath);
|
|
299
|
-
for (const heading of AGENTS_SECTIONS) {
|
|
300
|
-
if (hasHeading(text, heading)) ok.push(`AGENTS.md: ${heading}`);
|
|
301
|
-
else findings.push({ message: `AGENTS.md missing recommended section "${heading}"`, remedy: "doctrina templates update --write" });
|
|
302
|
-
}
|
|
303
|
-
// Command-surface block: present and current vs the installed catalog.
|
|
304
|
-
const block = findSurfaceBlock(text);
|
|
305
|
-
if (!block) {
|
|
306
|
-
findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina templates update --write" });
|
|
307
|
-
} else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
|
|
308
|
-
findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina templates update --write" });
|
|
309
|
-
} else {
|
|
310
|
-
ok.push("AGENTS.md: doctrina:surface block current");
|
|
311
|
-
}
|
|
312
|
-
} else {
|
|
313
|
-
findings.push({ message: "AGENTS.md missing at project root", remedy: "doctrina init --force" });
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// product.md sections
|
|
317
|
-
const productPath = path.join(projectRoot, ".doctrina", "product.md");
|
|
318
|
-
if (isFile(productPath)) {
|
|
319
|
-
const text = read(productPath);
|
|
320
|
-
for (const heading of PRODUCT_SECTIONS) {
|
|
321
|
-
if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
|
|
322
|
-
else findings.push({ message: `.doctrina/product.md missing recommended section "${heading}"`, remedy: "doctrina templates update --write" });
|
|
323
|
-
}
|
|
324
|
-
} else {
|
|
325
|
-
findings.push({ message: ".doctrina/product.md missing", remedy: "doctrina init --force" });
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Installed agent adapters: a HUB POINTER file must still reference
|
|
329
|
-
// AGENTS.md (that is why one `upgrade --write` refresh of the hub reaches
|
|
330
|
-
// every installed agent). Only files whose template declares the
|
|
331
|
-
// {{AGENTS_MD_PATH}} token are pointers; a slash-command shim reaches the
|
|
332
|
-
// hub through its parent pointer file and is not checked (C2).
|
|
333
|
-
for (const name of listAdapterNames(projectRoot)) {
|
|
334
|
-
const spec = adapterFiles(projectRoot, name);
|
|
335
|
-
if (!spec) continue;
|
|
336
|
-
for (const file of spec.files) {
|
|
337
|
-
const installed = path.join(projectRoot, file.relativePath);
|
|
338
|
-
if (!isFile(installed)) continue; // not installed — nothing to check
|
|
339
|
-
if (!isHubPointer(file)) continue; // command shim, not a pointer
|
|
340
|
-
if (read(installed).includes("AGENTS.md")) {
|
|
341
|
-
ok.push(`adapter ${file.relativePath}: points at AGENTS.md`);
|
|
342
|
-
} else {
|
|
343
|
-
findings.push({
|
|
344
|
-
message: `adapter ${file.relativePath} no longer references AGENTS.md — agents loading it will miss the hub`,
|
|
345
|
-
// Executable and verified: `adapter add --force` rewrites exactly
|
|
346
|
-
// this file from its template. A remedy that cannot resolve the
|
|
347
|
-
// finding it is attached to is not a remedy (C2).
|
|
348
|
-
remedy: `doctrina adapter add ${name} --force`,
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// M2: a command that cannot state its trigger has not earned a place on
|
|
355
|
-
// the surface. The block is an agent's only discovery surface, so both
|
|
356
|
-
// fields are required and the block has a declared size budget.
|
|
357
|
-
for (const name of COMMAND_NAMES) {
|
|
358
|
-
const meta = COMMAND_META[name];
|
|
359
|
-
if (!meta || !meta.purpose || !meta.when) {
|
|
360
|
-
findings.push({
|
|
361
|
-
message: `command "${name}" declares no ${!meta ? "purpose or when" : (!meta.purpose ? "purpose" : "when")} — the surface block cannot say when to reach for it`,
|
|
362
|
-
remedy: null,
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
const surfaceLines = surfaceMarkdown().split("\n").length;
|
|
367
|
-
if (surfaceLines > SURFACE_LINE_BUDGET) {
|
|
368
|
-
findings.push({
|
|
369
|
-
message: `the generated surface block is ${surfaceLines} lines (budget ${SURFACE_LINE_BUDGET}) — cut commands rather than raising the budget`,
|
|
370
|
-
remedy: null,
|
|
371
|
-
});
|
|
372
|
-
} else {
|
|
373
|
-
ok.push(`surface block within budget (${surfaceLines}/${SURFACE_LINE_BUDGET} lines)`);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// index.json schema fields
|
|
377
|
-
const indexPath = path.join(projectRoot, ".doctrina", "index.json");
|
|
378
|
-
if (isFile(indexPath)) {
|
|
379
|
-
try {
|
|
380
|
-
const idx = JSON.parse(read(indexPath));
|
|
381
|
-
for (const field of INDEX_FIELDS) {
|
|
382
|
-
if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
|
|
383
|
-
else findings.push({ message: `.doctrina/index.json missing field "${field}"`, remedy: "doctrina templates update --write" });
|
|
384
|
-
}
|
|
385
|
-
if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
|
|
386
|
-
findings.push({ message: `.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`, remedy: null });
|
|
387
|
-
}
|
|
388
|
-
} catch (err) {
|
|
389
|
-
findings.push({ message: `.doctrina/index.json failed to parse: ${err.message}`, remedy: null });
|
|
390
|
-
}
|
|
391
|
-
} else {
|
|
392
|
-
findings.push({ message: ".doctrina/index.json missing", remedy: "doctrina index rebuild" });
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Output
|
|
396
|
-
return { findings, ok };
|
|
397
|
-
}
|
|
398
300
|
|
|
399
301
|
function report(findings, ok) {
|
|
400
302
|
for (const o of ok) console.log(c.green("✓ ") + o);
|
|
@@ -443,9 +345,6 @@ function templateAnchors() {
|
|
|
443
345
|
}
|
|
444
346
|
}
|
|
445
347
|
|
|
446
|
-
function normalizeBlock(s) {
|
|
447
|
-
return s.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "").trim();
|
|
448
|
-
}
|
|
449
348
|
|
|
450
349
|
// A pre-marker AGENTS.md's hand-written surface section: from the
|
|
451
350
|
// "## Doctrina command surface" heading up to (excluding) the next "## "
|
|
@@ -459,12 +358,6 @@ function findLegacySurfaceSection(text) {
|
|
|
459
358
|
return { start: head.index, end: next ? head.index + head[0].length + next.index : text.length };
|
|
460
359
|
}
|
|
461
360
|
|
|
462
|
-
function hasHeading(text, heading) {
|
|
463
|
-
// Match an exact h2 heading at line start (case-insensitive for the body
|
|
464
|
-
// after "## ", so "## Vision" matches "## VISION" or "## vision" too).
|
|
465
|
-
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
466
|
-
return new RegExp(`^${escaped}\\b`, "im").test(text);
|
|
467
|
-
}
|
|
468
361
|
|
|
469
362
|
export const help = `
|
|
470
363
|
Usage: doctrina templates <subcommand>
|