doctrina-cli 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/analyze.js +8 -2
- package/src/commands/change.js +78 -7
- package/src/commands/clarify.js +76 -4
- package/src/commands/close.js +42 -9
- package/src/commands/completion.js +116 -0
- package/src/commands/constitution.js +6 -4
- package/src/commands/context.js +118 -25
- package/src/commands/coverage.js +185 -44
- package/src/commands/doctor.js +135 -0
- package/src/commands/handoff.js +94 -0
- package/src/commands/init.js +12 -1
- package/src/commands/intent.js +173 -0
- package/src/commands/next.js +32 -16
- package/src/commands/prime.js +118 -0
- package/src/commands/report.js +142 -0
- package/src/commands/review.js +25 -4
- package/src/commands/show.js +173 -0
- package/src/commands/skill.js +46 -7
- package/src/commands/spec.js +13 -3
- package/src/commands/status.js +70 -40
- package/src/commands/trace.js +15 -2
- package/src/commands/upgrade.js +129 -0
- package/src/commands/validate.js +150 -3
- package/src/commands/why.js +83 -10
- package/src/commands/work.js +45 -16
- package/src/index.js +20 -42
- package/src/lib/commands.js +76 -4
- package/src/lib/scan.js +16 -0
- package/templates/AGENTS.md.template +8 -4
- package/templates/README.md +7 -1
- package/templates/adapters/aider/CONVENTIONS.md.template +4 -3
- package/templates/spec.md.template +9 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { exists } from "../lib/fs-ops.js";
|
|
5
|
+
import * as idx from "../lib/index-json.js";
|
|
6
|
+
import { collectStatus } from "./status.js";
|
|
7
|
+
import { openChanges } from "./prime.js";
|
|
8
|
+
import { flagString } from "../lib/args.js";
|
|
9
|
+
import { today } from "../lib/dates.js";
|
|
10
|
+
import { c } from "../lib/colors.js";
|
|
11
|
+
|
|
12
|
+
// Periodic digest in Markdown — the standup/PR-description view: what was
|
|
13
|
+
// archived, where the gates stand, and what local git says about the period.
|
|
14
|
+
// Read-only, no network; the git section degrades silently outside a repo.
|
|
15
|
+
|
|
16
|
+
export async function run(_positional, flags) {
|
|
17
|
+
const projectRoot = process.cwd();
|
|
18
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
19
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sinceRaw = flagString(flags, "since") ?? "7";
|
|
23
|
+
const days = Number.parseInt(sinceRaw, 10);
|
|
24
|
+
if (!Number.isFinite(days) || days <= 0) {
|
|
25
|
+
console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
|
|
26
|
+
return 2;
|
|
27
|
+
}
|
|
28
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
29
|
+
const cutoffIso = cutoff.toISOString().slice(0, 10);
|
|
30
|
+
|
|
31
|
+
const s = collectStatus(projectRoot);
|
|
32
|
+
|
|
33
|
+
console.log(`# Doctrina report — ${s.project} (${cutoffIso} → ${today()})`);
|
|
34
|
+
console.log("");
|
|
35
|
+
|
|
36
|
+
// Gates.
|
|
37
|
+
console.log("## Gates");
|
|
38
|
+
console.log("");
|
|
39
|
+
console.log(`- index: ${s.indexState} · framework stamp: ${s.stamp ?? "—"} (CLI ${s.cli})`);
|
|
40
|
+
console.log(`- coverage: ${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria} criteria)` +
|
|
41
|
+
(s.coverage.totalDangling ? ` — ${s.coverage.totalDangling} dangling` : "") +
|
|
42
|
+
(s.coverage.totalConditional ? ` — ${s.coverage.totalConditional} conditional` : ""));
|
|
43
|
+
console.log(`- trace: ${s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`}`);
|
|
44
|
+
console.log(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared` : "not configured"}`);
|
|
45
|
+
|
|
46
|
+
// Work shipped in the window (from the index ledger) + work in flight.
|
|
47
|
+
console.log("");
|
|
48
|
+
console.log("## Changes");
|
|
49
|
+
console.log("");
|
|
50
|
+
const archived = archivedSince(projectRoot, cutoffIso);
|
|
51
|
+
if (archived.length === 0) {
|
|
52
|
+
console.log(`- archived in period: none`);
|
|
53
|
+
} else {
|
|
54
|
+
console.log(`- archived in period: ${archived.length}`);
|
|
55
|
+
for (const ch of archived) console.log(` - ${ch.applied} — \`${ch.id}\` ${ch.title}`);
|
|
56
|
+
}
|
|
57
|
+
const open = openChanges(projectRoot);
|
|
58
|
+
if (open.length === 0) {
|
|
59
|
+
console.log("- open now: none");
|
|
60
|
+
} else {
|
|
61
|
+
console.log(`- open now: ${open.length}`);
|
|
62
|
+
for (const ch of open) {
|
|
63
|
+
const tasks = ch.tasksTotal > 0 ? ` (tasks ${ch.tasksDone}/${ch.tasksTotal})` : "";
|
|
64
|
+
console.log(` - \`${ch.id}\`${ch.title ? ` ${ch.title}` : ""}${tasks}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Artifact counts, for trend-watching between reports.
|
|
69
|
+
console.log("");
|
|
70
|
+
console.log("## Artifacts");
|
|
71
|
+
console.log("");
|
|
72
|
+
console.log(`- specs: ${s.specs.total} · decisions: ${s.decisions.total}` +
|
|
73
|
+
(s.decisions.proposed ? ` (${s.decisions.proposed} proposed)` : "") +
|
|
74
|
+
` · skills: ${s.skills}`);
|
|
75
|
+
|
|
76
|
+
// Local git window — commits, fix share, top churn. No network.
|
|
77
|
+
console.log("");
|
|
78
|
+
console.log("## Git (local, last " + days + " days)");
|
|
79
|
+
console.log("");
|
|
80
|
+
const git = gitWindow(projectRoot, days);
|
|
81
|
+
if (!git) {
|
|
82
|
+
console.log("- no git history available (not a repository, or git not installed)");
|
|
83
|
+
} else {
|
|
84
|
+
console.log(`- commits: ${git.commits} (${git.fixes} fix-shaped, ${git.commits ? Math.round((git.fixes / git.commits) * 100) : 0}%)`);
|
|
85
|
+
if (git.churn.length > 0) {
|
|
86
|
+
console.log(`- top-churn files:`);
|
|
87
|
+
for (const [file, n] of git.churn) console.log(` - ${file} (${n} touches)`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(`*Generated read-only by \`doctrina report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function archivedSince(projectRoot, cutoffIso) {
|
|
97
|
+
let index;
|
|
98
|
+
try {
|
|
99
|
+
index = idx.load(projectRoot);
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
return (index.artifacts?.changes_archive ?? [])
|
|
104
|
+
.filter((ch) => typeof ch.applied === "string" && ch.applied >= cutoffIso)
|
|
105
|
+
.sort((a, b) => String(a.applied).localeCompare(String(b.applied)));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function gitWindow(projectRoot, days) {
|
|
109
|
+
const log = spawnSync("git", ["log", `--since=${days} days ago`, "--pretty=%s"], {
|
|
110
|
+
cwd: projectRoot,
|
|
111
|
+
encoding: "utf8",
|
|
112
|
+
});
|
|
113
|
+
if (log.status !== 0) return null;
|
|
114
|
+
const subjects = log.stdout.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
115
|
+
const fixes = subjects.filter((sub) => /^(fix|bug|hotfix|patch)(\(|:|!)/i.test(sub)).length;
|
|
116
|
+
|
|
117
|
+
const files = spawnSync("git", ["log", `--since=${days} days ago`, "--name-only", "--pretty=format:"], {
|
|
118
|
+
cwd: projectRoot,
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
});
|
|
121
|
+
const counts = new Map();
|
|
122
|
+
if (files.status === 0) {
|
|
123
|
+
for (const line of files.stdout.split(/\r?\n/)) {
|
|
124
|
+
const f = line.trim();
|
|
125
|
+
if (!f) continue;
|
|
126
|
+
counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const churn = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
130
|
+
return { commits: subjects.length, fixes, churn };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const help = `
|
|
134
|
+
Usage: doctrina report [--since <days>]
|
|
135
|
+
|
|
136
|
+
Print a Markdown digest for the period (default: last 7 days): gate
|
|
137
|
+
state, changes archived in the window (from the index ledger), open
|
|
138
|
+
work with task progress, artifact counts, and a local-git summary
|
|
139
|
+
(commits, fix share, top-churn files). Made for standups and PR
|
|
140
|
+
descriptions. Read-only; no network. \`doctrina metrics\` has the
|
|
141
|
+
deeper git-derived numbers.
|
|
142
|
+
`;
|
package/src/commands/review.js
CHANGED
|
@@ -6,6 +6,7 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
|
6
6
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
8
|
import { rankCapabilitiesByDiff } from "./work.js";
|
|
9
|
+
import { parseDependsOn } from "../lib/scan.js";
|
|
9
10
|
import { summarize as coverageSummary } from "./coverage.js";
|
|
10
11
|
import { summarize as traceSummary } from "./trace.js";
|
|
11
12
|
|
|
@@ -42,18 +43,38 @@ export async function run(_positional, flags) {
|
|
|
42
43
|
const notes = []; // advisory observations
|
|
43
44
|
|
|
44
45
|
// 1. Code changed under a capability whose spec was NOT touched — the spec
|
|
45
|
-
// may have drifted from the code it describes.
|
|
46
|
+
// may have drifted from the code it describes. Touched = every capability
|
|
47
|
+
// matched by the code diff (UNCAPPED — the top-3 hint truncation is how
|
|
48
|
+
// this missed 5 of 8 in the 0.11.0 field session) UNIONED with every
|
|
49
|
+
// capability whose spec.md itself changed.
|
|
46
50
|
const specChanged = new Set(
|
|
47
51
|
all.filter((f) => /(^|\/)\.doctrina\/specs\/[^/]+\/spec\.md$/.test(f.replace(/\\/g, "/")))
|
|
48
52
|
.map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)[1]),
|
|
49
53
|
);
|
|
50
|
-
const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles);
|
|
54
|
+
const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
|
|
55
|
+
const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
|
|
51
56
|
for (const m of ranked) {
|
|
52
57
|
if (!specChanged.has(m.id)) {
|
|
53
58
|
breaks.push(`code under capability "${m.id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${m.id}/spec.md\` still describes the code (update it, or note why not)`);
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
// 1b. Dependents of touched capabilities (the machine-readable
|
|
63
|
+
// **Depends on:** header): a spec that builds on something you changed
|
|
64
|
+
// may silently no longer hold. Advisory — a pointer, not a verdict.
|
|
65
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
66
|
+
if (isDir(specsDir) && touched.size > 0) {
|
|
67
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
68
|
+
if (touched.has(cap)) continue;
|
|
69
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
70
|
+
if (!isFile(specPath)) continue;
|
|
71
|
+
const deps = parseDependsOn(read(specPath)).filter((d) => touched.has(d));
|
|
72
|
+
if (deps.length > 0) {
|
|
73
|
+
notes.push(`capability "${cap}" depends on touched ${deps.map((d) => `"${d}"`).join(", ")} — confirm it still holds (\`doctrina why ${cap}\`)`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
// 2. New source files that map to no capability at all — code with no home
|
|
58
79
|
// in any spec (a likely new, unspecced capability).
|
|
59
80
|
if (ranked.length === 0 && sourceFiles.length > 0) {
|
|
@@ -83,8 +104,8 @@ export async function run(_positional, flags) {
|
|
|
83
104
|
|
|
84
105
|
// --- Output ---
|
|
85
106
|
console.log(c.gray(`${all.length} changed path(s); ${sourceFiles.length} source file(s) outside .doctrina/.`));
|
|
86
|
-
if (
|
|
87
|
-
console.log(c.gray("Capabilities touched: ") +
|
|
107
|
+
if (touched.size > 0) {
|
|
108
|
+
console.log(c.gray("Capabilities touched: ") + [...touched].sort().map((id) => c.cyan(id)).join(", "));
|
|
88
109
|
}
|
|
89
110
|
console.log("");
|
|
90
111
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
5
|
+
import { parseAcceptanceCriteria } from "../lib/criteria.js";
|
|
6
|
+
import { suggest } from "../lib/suggest.js";
|
|
7
|
+
import { c } from "../lib/colors.js";
|
|
8
|
+
|
|
9
|
+
// Point reads. An agent that needs ONE requirement re-reads a whole spec —
|
|
10
|
+
// hundreds of lines for a two-line fact. `show` resolves a compact reference
|
|
11
|
+
// to exactly the lines that own it:
|
|
12
|
+
//
|
|
13
|
+
// doctrina show cli-R12 twelfth requirement of the cli spec (file order)
|
|
14
|
+
// doctrina show gates-C3 acceptance criterion 3 (the spec's own numbering)
|
|
15
|
+
// doctrina show 0007 ADR 0007, whole decision (already a bounded read)
|
|
16
|
+
// doctrina show cli the spec's header block + Purpose only
|
|
17
|
+
//
|
|
18
|
+
// R-refs are positional (file order across the EARS sections), so they shift
|
|
19
|
+
// when a requirement is inserted above — cite them for point reads and
|
|
20
|
+
// conversation, not as immutable identifiers. C-refs use the criteria's own
|
|
21
|
+
// explicit numbers and are as stable as the spec keeps them.
|
|
22
|
+
|
|
23
|
+
export async function run(positional, _flags) {
|
|
24
|
+
const ref = positional[0];
|
|
25
|
+
const projectRoot = process.cwd();
|
|
26
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
27
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
28
|
+
}
|
|
29
|
+
if (!ref) {
|
|
30
|
+
console.error(c.red("error:") + " show requires a reference");
|
|
31
|
+
console.error(c.gray("hint: ") + "`doctrina show cli-R12` (requirement), `doctrina show cli-C3` (criterion), `doctrina show 0007` (ADR), `doctrina show cli` (spec header + purpose)");
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ADR by number.
|
|
36
|
+
if (/^\d{4}$/.test(ref)) return showAdr(projectRoot, ref);
|
|
37
|
+
|
|
38
|
+
const m = ref.match(/^([a-z][a-z0-9-]*)-([RC])(\d+)$/);
|
|
39
|
+
if (m) return showSpecItem(projectRoot, m[1], m[2], Number(m[3]));
|
|
40
|
+
|
|
41
|
+
if (/^[a-z][a-z0-9-]*$/.test(ref)) return showSpecHead(projectRoot, ref);
|
|
42
|
+
|
|
43
|
+
console.error(c.red("error:") + ` unrecognised reference "${ref}"`);
|
|
44
|
+
console.error(c.gray("hint: ") + "use <cap>-R<n>, <cap>-C<n>, an ADR number (0007), or a capability name");
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function showAdr(projectRoot, num) {
|
|
49
|
+
const dir = path.join(projectRoot, ".doctrina", "decisions");
|
|
50
|
+
const file = isDir(dir) ? readdirSync(dir).find((f) => f.startsWith(`${num}-`) && f.endsWith(".md")) : null;
|
|
51
|
+
if (!file) {
|
|
52
|
+
console.error(c.red("error:") + ` no ADR ${num} under .doctrina/decisions/`);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const full = path.join(dir, file);
|
|
56
|
+
console.log(c.gray(`— ${relPath(projectRoot, full)} —`));
|
|
57
|
+
console.log(read(full).trimEnd());
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadSpec(projectRoot, cap) {
|
|
62
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
63
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
64
|
+
if (isFile(specPath)) return { specPath, text: read(specPath) };
|
|
65
|
+
const known = isDir(specsDir) ? readdirSync(specsDir).filter((e) => isFile(path.join(specsDir, e, "spec.md"))) : [];
|
|
66
|
+
console.error(c.red("error:") + ` no spec for capability "${cap}"`);
|
|
67
|
+
const guess = suggest(cap, known);
|
|
68
|
+
if (guess) console.error(c.gray("hint: ") + `did you mean "${guess}"?`);
|
|
69
|
+
else if (known.length) console.error(c.gray("known: ") + known.sort().join(", "));
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function showSpecHead(projectRoot, cap) {
|
|
74
|
+
const spec = loadSpec(projectRoot, cap);
|
|
75
|
+
if (!spec) return 1;
|
|
76
|
+
const lines = spec.text.split(/\r?\n/);
|
|
77
|
+
const out = [];
|
|
78
|
+
let mode = "head"; // header block -> seek Purpose -> purpose body -> stop
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const isH2 = /^##\s+/.test(line);
|
|
81
|
+
if (mode === "head" && isH2) mode = /^##\s+Purpose\b/i.test(line) ? "purpose" : "seek";
|
|
82
|
+
else if (mode === "seek" && isH2 && /^##\s+Purpose\b/i.test(line)) mode = "purpose";
|
|
83
|
+
else if (mode === "purpose" && isH2) break;
|
|
84
|
+
|
|
85
|
+
if (mode === "head" || (mode === "purpose")) out.push(line);
|
|
86
|
+
}
|
|
87
|
+
console.log(c.gray(`— ${relPath(projectRoot, spec.specPath)} (header + purpose; \`doctrina context ${cap}\` for the full spec) —`));
|
|
88
|
+
console.log(out.join("\n").trimEnd());
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function showSpecItem(projectRoot, cap, kind, n) {
|
|
93
|
+
const spec = loadSpec(projectRoot, cap);
|
|
94
|
+
if (!spec) return 1;
|
|
95
|
+
|
|
96
|
+
if (kind === "C") {
|
|
97
|
+
const criteria = parseAcceptanceCriteria(spec.text);
|
|
98
|
+
const crit = criteria.find((r) => r.n === n);
|
|
99
|
+
if (!crit) {
|
|
100
|
+
console.error(c.red("error:") + ` ${cap} has no acceptance criterion #${n} (it declares ${criteria.length})`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
console.log(c.gray(`— ${cap}-C${n} · ${relPath(projectRoot, spec.specPath)} · ## Acceptance criteria —`));
|
|
104
|
+
console.log(`${n}. ${crit.body}`);
|
|
105
|
+
if (crit.proofPaths.length > 0) {
|
|
106
|
+
console.log(c.gray(` evidence: ${crit.proofPaths.join(", ")}`));
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const reqs = parseRequirements(spec.text);
|
|
112
|
+
const req = reqs[n - 1];
|
|
113
|
+
if (!req) {
|
|
114
|
+
console.error(c.red("error:") + ` ${cap} has no requirement R${n} (it declares ${reqs.length})`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
console.log(c.gray(`— ${cap}-R${n} · ${relPath(projectRoot, spec.specPath)} · ${req.section} —`));
|
|
118
|
+
console.log(req.text);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Top-level bullets of the "## Requirements (EARS)" section, in file order,
|
|
123
|
+
// each with its `### <grammar>` sub-section name and continuation lines.
|
|
124
|
+
export function parseRequirements(text) {
|
|
125
|
+
const lines = text.split(/\r?\n/);
|
|
126
|
+
const out = [];
|
|
127
|
+
let inSection = false;
|
|
128
|
+
let sub = "";
|
|
129
|
+
let cur = null;
|
|
130
|
+
const flush = () => {
|
|
131
|
+
if (cur) out.push({ section: cur.section, text: cur.lines.join("\n").trimEnd() });
|
|
132
|
+
cur = null;
|
|
133
|
+
};
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
if (/^##\s+/.test(line)) {
|
|
136
|
+
flush();
|
|
137
|
+
inSection = /^##\s+Requirements\b/i.test(line);
|
|
138
|
+
sub = "";
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!inSection) continue;
|
|
142
|
+
const subMatch = line.match(/^###\s+(.+)$/);
|
|
143
|
+
if (subMatch) {
|
|
144
|
+
flush();
|
|
145
|
+
sub = subMatch[1].trim();
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (/^-\s+/.test(line)) {
|
|
149
|
+
flush();
|
|
150
|
+
cur = { section: sub || "Requirements", lines: [line] };
|
|
151
|
+
} else if (cur && (line.startsWith(" ") || line.trim() === "")) {
|
|
152
|
+
cur.lines.push(line);
|
|
153
|
+
} else {
|
|
154
|
+
flush();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
flush();
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const help = `
|
|
162
|
+
Usage: doctrina show <ref>
|
|
163
|
+
|
|
164
|
+
Point-read one artifact fragment instead of a whole file:
|
|
165
|
+
|
|
166
|
+
doctrina show cli-R12 requirement 12 of the cli spec (file order)
|
|
167
|
+
doctrina show cli-C3 acceptance criterion 3 (the spec's numbering)
|
|
168
|
+
doctrina show 0007 ADR 0007 (the whole decision)
|
|
169
|
+
doctrina show cli the spec's header block + Purpose only
|
|
170
|
+
|
|
171
|
+
R-refs are positional (they shift when a requirement is inserted above);
|
|
172
|
+
C-refs use the criteria's own numbers. Read-only.
|
|
173
|
+
`;
|
package/src/commands/skill.js
CHANGED
|
@@ -61,9 +61,23 @@ function skillSuggest(args, flags) {
|
|
|
61
61
|
const since = flagString(flags, "since");
|
|
62
62
|
|
|
63
63
|
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
|
|
65
|
+
const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
|
|
66
|
+
// A lesson may already be captured under a DIFFERENT slug (0.11.0 field
|
|
67
|
+
// review item 11: the human names the skill after the lesson, not after the
|
|
68
|
+
// change id — "tela-preta-crash-de-render..." for change 0049). Two extra
|
|
69
|
+
// dedup signals: an existing skill whose body cites the candidate's change
|
|
70
|
+
// id / commit hash, and token-overlap similarity between the slugs.
|
|
71
|
+
const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
|
|
72
|
+
const captured = (cand) => {
|
|
73
|
+
if (existing.has(cand.slug)) return true;
|
|
74
|
+
for (const sk of existingBodies) {
|
|
75
|
+
if (cand.id && sk.body.includes(cand.id)) return true;
|
|
76
|
+
if (cand.from && sk.body.includes(cand.from)) return true;
|
|
77
|
+
if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
67
81
|
|
|
68
82
|
// Two deterministic sources, deduped by slug. `seen` guards against a lesson
|
|
69
83
|
// appearing twice (e.g. a fix committed and later archived as a change).
|
|
@@ -78,18 +92,20 @@ function skillSuggest(args, flags) {
|
|
|
78
92
|
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
79
93
|
if (!FIX_SHAPED.test(id)) continue;
|
|
80
94
|
const slug = skillSlug(id);
|
|
81
|
-
if (
|
|
95
|
+
if (seen.has(slug)) continue;
|
|
96
|
+
const cand = { id, slug, source: "change", from: name };
|
|
97
|
+
if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
|
|
82
98
|
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
83
|
-
|
|
99
|
+
cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
|
|
84
100
|
seen.add(slug);
|
|
85
|
-
candidates.push(
|
|
101
|
+
candidates.push(cand);
|
|
86
102
|
}
|
|
87
103
|
}
|
|
88
104
|
|
|
89
105
|
// Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
|
|
90
106
|
// slug collision: a `## Why` paragraph is a richer seed than a subject line.
|
|
91
107
|
for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
|
|
92
|
-
if (
|
|
108
|
+
if (seen.has(cand.slug) || captured(cand)) continue;
|
|
93
109
|
seen.add(cand.slug);
|
|
94
110
|
candidates.push(cand);
|
|
95
111
|
}
|
|
@@ -216,6 +232,29 @@ function skillSlug(id) {
|
|
|
216
232
|
return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
|
|
217
233
|
}
|
|
218
234
|
|
|
235
|
+
// Token-overlap similarity between two kebab slugs (Jaccard over meaningful
|
|
236
|
+
// tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
|
|
237
|
+
// error-boundary" share tela/preta/render → high overlap even though the
|
|
238
|
+
// human renamed the skill after the lesson. Deterministic; never semantic.
|
|
239
|
+
function slugSimilarity(a, b) {
|
|
240
|
+
const toks = (s) => new Set(
|
|
241
|
+
String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
|
|
242
|
+
);
|
|
243
|
+
const A = toks(a);
|
|
244
|
+
const B = toks(b);
|
|
245
|
+
if (A.size === 0 || B.size === 0) return 0;
|
|
246
|
+
let inter = 0;
|
|
247
|
+
for (const t of A) if (B.has(t)) inter += 1;
|
|
248
|
+
return inter / (A.size + B.size - inter);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Fix-shaped verbs and connectives carry no lesson identity — without this,
|
|
252
|
+
// every "fix-..." candidate looks 30% similar to every "fix-..." skill.
|
|
253
|
+
const SLUG_NOISE = new Set([
|
|
254
|
+
"fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
|
|
255
|
+
"que", "nao", "por", "para", "una", "uma", "dos", "das",
|
|
256
|
+
]);
|
|
257
|
+
|
|
219
258
|
function firstWhyLine(proposalText) {
|
|
220
259
|
const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
|
|
221
260
|
if (!m) return "";
|
package/src/commands/spec.js
CHANGED
|
@@ -134,9 +134,10 @@ function specSet(args, flags) {
|
|
|
134
134
|
const impl = flagString(flags, "implementation");
|
|
135
135
|
const status = flagString(flags, "status");
|
|
136
136
|
const bump = flagString(flags, "bump");
|
|
137
|
+
const versionExplicit = flagString(flags, "version");
|
|
137
138
|
const criterion = flagString(flags, "criterion");
|
|
138
|
-
if (impl === undefined && status === undefined && bump === undefined && criterion === undefined) {
|
|
139
|
-
console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --criterion");
|
|
139
|
+
if (impl === undefined && status === undefined && bump === undefined && versionExplicit === undefined && criterion === undefined) {
|
|
140
|
+
console.error(c.red("error:") + " spec set needs at least one of --implementation, --status, --bump, --version, --criterion");
|
|
140
141
|
console.error(c.gray("hint: ") + `example: doctrina spec set ${capability} --implementation "verified — \`src/x.js\`" --bump minor`);
|
|
141
142
|
return 2;
|
|
142
143
|
}
|
|
@@ -156,6 +157,10 @@ function specSet(args, flags) {
|
|
|
156
157
|
if (!["major", "minor", "patch"].includes(bump)) errors.push(`--bump needs major|minor|patch (got "${bump}")`);
|
|
157
158
|
else apply(bumpVersion(text, bump));
|
|
158
159
|
}
|
|
160
|
+
if (versionExplicit !== undefined) {
|
|
161
|
+
if (!/^\d+\.\d+\.\d+$/.test(versionExplicit)) errors.push(`--version needs semver X.Y.Z (got "${versionExplicit}")`);
|
|
162
|
+
else apply(setHeader(text, "Version", versionExplicit));
|
|
163
|
+
}
|
|
159
164
|
if (criterion !== undefined) {
|
|
160
165
|
const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
|
|
161
166
|
if (!m) errors.push(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
|
|
@@ -183,7 +188,10 @@ function specSet(args, flags) {
|
|
|
183
188
|
const derived = deriveIndex(projectRoot, index);
|
|
184
189
|
derived.last_updated = date;
|
|
185
190
|
idx.save(projectRoot, derived);
|
|
186
|
-
|
|
191
|
+
// Echo the SPEC's resulting version, not the framework's — the two look
|
|
192
|
+
// identical in output and the ambiguity was a field-review papercut.
|
|
193
|
+
const specVersion = specHeader(text, "Version") ?? "—";
|
|
194
|
+
console.log(c.green("indexed") + ` spec "${capability}" synced (spec version ${specVersion})`);
|
|
187
195
|
return 0;
|
|
188
196
|
}
|
|
189
197
|
|
|
@@ -214,6 +222,8 @@ Options:
|
|
|
214
222
|
--implementation "..." With \`set\`: set the Implementation header.
|
|
215
223
|
--status "..." With \`set\`: set the Status header.
|
|
216
224
|
--bump major|minor|patch With \`set\`: bump the spec Version.
|
|
225
|
+
--version X.Y.Z With \`set\`: set the spec Version explicitly
|
|
226
|
+
(the output echoes the SPEC version, not the CLI's).
|
|
217
227
|
--criterion "<n>:<mark>" With \`set\`: set criterion n's [mark]
|
|
218
228
|
(e.g. --criterion "2:verified").
|
|
219
229
|
`;
|