doctrina-cli 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
package/src/commands/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
|
`;
|
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, fold, skillSlug } 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.
|
|
@@ -109,7 +104,7 @@ export function draftFromError(text) {
|
|
|
109
104
|
// Distinctive words, most frequent first — the fallback when an error
|
|
110
105
|
// carries no structure at all.
|
|
111
106
|
const counts = new Map();
|
|
112
|
-
for (const w of raw
|
|
107
|
+
for (const w of fold(raw).match(/[a-z][a-z0-9_-]{3,}/g) ?? []) {
|
|
113
108
|
if (ERROR_STOP.has(w)) continue;
|
|
114
109
|
counts.set(w, (counts.get(w) ?? 0) + 1);
|
|
115
110
|
}
|
|
@@ -130,8 +125,8 @@ export function draftFromError(text) {
|
|
|
130
125
|
// also mentions do not both land in the name.
|
|
131
126
|
const seedTokens = [];
|
|
132
127
|
const seenToken = new Set();
|
|
133
|
-
for (const token of [...identifiers.map((i) => i
|
|
134
|
-
const norm = token.replace(/[^a-z0-9]/g, "");
|
|
128
|
+
for (const token of [...identifiers.map((i) => fold(i)), ...keywords]) {
|
|
129
|
+
const norm = fold(token).replace(/[^a-z0-9]/g, "");
|
|
135
130
|
if (norm === "" || seenToken.has(norm)) continue;
|
|
136
131
|
seenToken.add(norm);
|
|
137
132
|
seedTokens.push(token);
|
|
@@ -349,7 +344,7 @@ function skillSuggest(args, flags) {
|
|
|
349
344
|
idx.save(projectRoot, index);
|
|
350
345
|
}
|
|
351
346
|
console.log("");
|
|
352
|
-
console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina
|
|
347
|
+
console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina index rebuild\``);
|
|
353
348
|
return 0;
|
|
354
349
|
}
|
|
355
350
|
|
|
@@ -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 = [];
|
|
@@ -405,16 +400,9 @@ function gitFixCommits(projectRoot, { since, limit }) {
|
|
|
405
400
|
}
|
|
406
401
|
|
|
407
402
|
// Derive a skill slug from a commit subject: drop a conventional-commit
|
|
408
|
-
// "type(scope): " prefix
|
|
403
|
+
// "type(scope): " prefix and hand the rest to the shared slug, which folds.
|
|
409
404
|
function commitSlug(subject) {
|
|
410
|
-
|
|
411
|
-
return skillSlug(stripped.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function skillSlug(id) {
|
|
415
|
-
// Reuse the change id as the skill slug, trimmed to a sane length.
|
|
416
|
-
const slug = id.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
417
|
-
return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
|
|
405
|
+
return skillSlug(subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, ""));
|
|
418
406
|
}
|
|
419
407
|
|
|
420
408
|
// Token-overlap similarity between two kebab slugs (Jaccard over meaningful
|
|
@@ -449,8 +437,9 @@ function firstWhyLine(proposalText) {
|
|
|
449
437
|
|
|
450
438
|
function skillNew(args, flags) {
|
|
451
439
|
const name = args[0];
|
|
452
|
-
|
|
453
|
-
|
|
440
|
+
const nameError = artifactNameError(name, "skill name");
|
|
441
|
+
if (nameError) {
|
|
442
|
+
console.error(c.red("error:") + ` ${nameError} (e.g. "db-migration")`);
|
|
454
443
|
return 2;
|
|
455
444
|
}
|
|
456
445
|
const force = flagBool(flags, "force", false);
|
|
@@ -486,7 +475,7 @@ function skillNew(args, flags) {
|
|
|
486
475
|
idx.save(projectRoot, index);
|
|
487
476
|
console.log(c.green("indexed") + ` skill "${name}"`);
|
|
488
477
|
console.log("");
|
|
489
|
-
console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina
|
|
478
|
+
console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina index rebuild` to update index.json");
|
|
490
479
|
return 0;
|
|
491
480
|
}
|
|
492
481
|
|
|
@@ -512,6 +501,13 @@ function skillSync() {
|
|
|
512
501
|
console.log(c.yellow("skip ") + ` ${id} (no description frontmatter)`);
|
|
513
502
|
continue;
|
|
514
503
|
}
|
|
504
|
+
// A description still in the template's `<...>` form is not "up to
|
|
505
|
+
// date" (change 0111): it is a file nobody wrote yet, and mirroring it
|
|
506
|
+
// into the index is what put the placeholder in every context pack.
|
|
507
|
+
if (/^<[^<>]*>$/.test(desc.trim())) {
|
|
508
|
+
console.log(c.yellow("scaffold") + ` ${id} — description is still the scaffold's placeholder; write description/when, then sync`);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
515
511
|
const entry = (index.artifacts.skills ?? []).find((s) => s.id === id);
|
|
516
512
|
if (!entry) {
|
|
517
513
|
idx.addSkill(index, {
|
|
@@ -568,15 +564,6 @@ function skillList() {
|
|
|
568
564
|
return 0;
|
|
569
565
|
}
|
|
570
566
|
|
|
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
567
|
|
|
581
568
|
function ensureDoctrinaProject(projectRoot) {
|
|
582
569
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
@@ -618,6 +605,3 @@ Skills are written by humans, not generated. See docs/en/skills.md
|
|
|
618
605
|
for the design rationale and the distinction from specs / AGENTS.md /
|
|
619
606
|
the rejected memory/ folder.
|
|
620
607
|
`;
|
|
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");
|
|
@@ -152,32 +155,56 @@ function specSet(args, flags) {
|
|
|
152
155
|
let text = read(specPath);
|
|
153
156
|
const applied = [];
|
|
154
157
|
const errors = [];
|
|
158
|
+
// An error names what to correct: the INVOCATION (a value out of domain, a
|
|
159
|
+
// criterion that does not exist, a malformed flag) or the SPEC (a header
|
|
160
|
+
// or section it lacks). Only the second is fixed by editing the work.
|
|
161
|
+
const refused = (msg) => errors.push({ msg, argument: true });
|
|
155
162
|
const apply = (res) => {
|
|
156
|
-
if (res.error) errors.push(res.error);
|
|
163
|
+
if (res.error) errors.push({ msg: res.error, argument: res.cause === "argument" });
|
|
157
164
|
else { text = res.text; applied.push(res.summary); }
|
|
158
165
|
};
|
|
159
166
|
// Order: headers, then version, then criterion — independent edits, all or
|
|
160
167
|
// none (a failed op leaves the spec untouched, like `change apply`).
|
|
161
168
|
if (status !== undefined) apply(setHeader(text, "Status", status));
|
|
162
|
-
if (impl !== undefined)
|
|
169
|
+
if (impl !== undefined) {
|
|
170
|
+
// `--implementation auto` reads the value off the coverage arithmetic
|
|
171
|
+
// instead of the author's memory (audit finding F10): coverage already
|
|
172
|
+
// knows how many of this spec's criteria cite proof that resolves, and
|
|
173
|
+
// that is the definition of "verified". A spec with no acceptance
|
|
174
|
+
// criteria has nothing to derive from, so `auto` refuses rather than
|
|
175
|
+
// guessing — the spec is left untouched, like any other failed op.
|
|
176
|
+
if (String(impl).trim().toLowerCase() === "auto") {
|
|
177
|
+
const derivedValue = derivedImplementations(projectRoot, { only: new Set([capability]) })
|
|
178
|
+
.get(capability)?.derived;
|
|
179
|
+
if (!derivedValue) {
|
|
180
|
+
errors.push({ msg: `--implementation auto needs acceptance criteria to derive from; ${capability} declares none`, argument: false });
|
|
181
|
+
} else {
|
|
182
|
+
apply(setHeader(text, "Implementation", derivedValue));
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
apply(setHeader(text, "Implementation", impl));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
163
188
|
if (bump !== undefined) {
|
|
164
|
-
if (!["major", "minor", "patch"].includes(bump))
|
|
189
|
+
if (!["major", "minor", "patch"].includes(bump)) refused(`--bump needs major|minor|patch (got "${bump}")`);
|
|
165
190
|
else apply(bumpVersion(text, bump));
|
|
166
191
|
}
|
|
167
192
|
if (versionExplicit !== undefined) {
|
|
168
|
-
if (!/^\d+\.\d+\.\d+$/.test(versionExplicit))
|
|
193
|
+
if (!/^\d+\.\d+\.\d+$/.test(versionExplicit)) refused(`--version needs semver X.Y.Z (got "${versionExplicit}")`);
|
|
169
194
|
else apply(setHeader(text, "Version", versionExplicit));
|
|
170
195
|
}
|
|
171
196
|
if (criterion !== undefined) {
|
|
172
197
|
const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
|
|
173
|
-
if (!m)
|
|
198
|
+
if (!m) refused(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
|
|
174
199
|
else apply(setCriterionMark(text, Number(m[1]), m[2].trim()));
|
|
175
200
|
}
|
|
176
201
|
|
|
177
202
|
if (errors.length > 0) {
|
|
178
203
|
console.error(c.red("error:") + ` ${errors.length} operation error${errors.length === 1 ? "" : "s"} — spec left untouched:`);
|
|
179
|
-
for (const e of errors) console.error(` - ${e}`);
|
|
180
|
-
|
|
204
|
+
for (const e of errors) console.error(` - ${e.msg}`);
|
|
205
|
+
// Retrying an invocation error unchanged never succeeds (ADR 0018), so
|
|
206
|
+
// one in the list is enough to answer USAGE.
|
|
207
|
+
return errors.some((e) => e.argument) ? EXIT.USAGE : EXIT.GATE;
|
|
181
208
|
}
|
|
182
209
|
|
|
183
210
|
// Stamp Last updated when the header exists (best-effort, never an error).
|