doctrina-cli 0.16.0 → 0.18.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 +31 -73
- package/package.json +3 -3
- package/src/commands/adapter.js +3 -2
- package/src/commands/analyze.js +8 -0
- package/src/commands/change.js +86 -80
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +136 -34
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +6 -4
- package/src/commands/decision.js +12 -0
- package/src/commands/doctor.js +1 -1
- package/src/commands/hooks.js +6 -1
- package/src/commands/index-rebuild.js +52 -2
- package/src/commands/init.js +19 -4
- package/src/commands/intake.js +55 -7
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +13 -5
- package/src/commands/next.js +2 -1
- package/src/commands/prime.js +4 -5
- package/src/commands/report.js +9 -20
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +8 -139
- package/src/commands/spec.js +13 -7
- package/src/commands/status.js +39 -20
- package/src/commands/templates.js +4 -10
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +32 -8
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +63 -8
- package/src/index.js +34 -6
- package/src/lib/actions.js +38 -28
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +95 -1
- package/src/lib/commands.js +96 -15
- package/src/lib/coverage-model.js +40 -1
- package/src/lib/doc-model.js +47 -6
- package/src/lib/docs-impact.js +74 -0
- package/src/lib/ears.js +24 -1
- package/src/lib/gates.js +53 -9
- package/src/lib/git.js +59 -2
- package/src/lib/index-json.js +40 -5
- package/src/lib/intake-model.js +71 -2
- package/src/lib/lexicon.js +38 -1
- package/src/lib/metrics-model.js +30 -0
- package/src/lib/playbook.js +1 -1
- package/src/lib/project.js +49 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +139 -14
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/snapshot.js +61 -2
- package/src/lib/spec-ops.js +141 -6
- package/src/lib/templates-model.js +35 -8
- package/src/lib/validation-model.js +117 -35
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +10 -2
- package/src/lib/work-model.js +57 -12
- package/templates/AGENTS.md.template +3 -4
- package/templates/README.md +2 -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 +15 -0
- package/templates/change/spec-delta.md.template +2 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +10 -0
- package/templates/playbooks/bootstrap.md.template +6 -2
- package/templates/playbooks/chore.md.template +9 -4
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/work.md.template +13 -3
- package/src/commands/constitution.js +0 -68
package/src/commands/intent.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { intentKey } from "../lib/validation-model.js";
|
|
5
6
|
import { c } from "../lib/colors.js";
|
|
6
7
|
import { suggest } from "../lib/suggest.js";
|
|
7
8
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
@@ -102,9 +103,21 @@ function intentAdd(projectRoot, textArg) {
|
|
|
102
103
|
let id = null;
|
|
103
104
|
let text = textArg;
|
|
104
105
|
const pinned = textArg.match(/^([A-Z]+)(\d+)\s*:\s*(.+)$/);
|
|
106
|
+
if (pinned) text = pinned[3].trim();
|
|
107
|
+
|
|
108
|
+
// The same intent twice is two anchors for one goal: realize either and the
|
|
109
|
+
// other stays "dropped", so `trace --strict` fails on a gap nobody can
|
|
110
|
+
// close. An agent re-running a playbook step is enough to do it. Compared
|
|
111
|
+
// folded, so case, accents and spacing do not slip a twin past.
|
|
112
|
+
const twin = anchors.find((a) => intentKey(a.text) === intentKey(text));
|
|
113
|
+
if (twin) {
|
|
114
|
+
console.error(c.red("error:") + ` [${twin.id}] already states this intent in product.md`);
|
|
115
|
+
console.error(c.gray("hint: ") + `realize [${twin.id}] with a spec's \`Realizes:\` header, or reword this one if it is a different goal`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
105
119
|
if (pinned) {
|
|
106
120
|
id = pinned[1] + pinned[2];
|
|
107
|
-
text = pinned[3].trim();
|
|
108
121
|
if (anchors.some((a) => a.id === id)) {
|
|
109
122
|
console.error(c.red("error:") + ` anchor [${id}] already exists in product.md`);
|
|
110
123
|
return 1;
|
package/src/commands/metrics.js
CHANGED
|
@@ -11,7 +11,7 @@ import { GIT_STATE, historyState, git } from "../lib/git.js";
|
|
|
11
11
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
12
|
import { USAGE_ENV, summarise } from "../lib/usage.js";
|
|
13
13
|
import { OPERATIONS } from "../lib/commands.js";
|
|
14
|
-
import { REEDIT_WINDOW_DAYS, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.js";
|
|
14
|
+
import { REEDIT_WINDOW_DAYS, sinceWindow, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.js";
|
|
15
15
|
|
|
16
16
|
// Local-only adoption metrics derived from git history. No network calls,
|
|
17
17
|
// no telemetry: the numbers stay in the repository, versioned like any
|
|
@@ -37,6 +37,14 @@ export async function run(_positional, flags) {
|
|
|
37
37
|
// recent one (audit finding F15) — a year of measurement answering one
|
|
38
38
|
// question. Reading the series is what makes the saving worth doing.
|
|
39
39
|
if (flagBool(flags, "trend", false)) return printTrend(projectRoot);
|
|
40
|
+
// The window is the invocation's: refused the same with or without history.
|
|
41
|
+
const sinceRaw = flagString(flags, "since", "90");
|
|
42
|
+
const since = sinceWindow(sinceRaw);
|
|
43
|
+
if (since === null) {
|
|
44
|
+
console.error(c.red("error:") + ` --since expects a day count, a date (YYYY-MM-DD) or "<n> <unit>s ago", got "${sinceRaw}"`);
|
|
45
|
+
console.error(c.gray("hint: ") + "examples: --since 30 · --since 2026-01-01 · --since \"3 months ago\"");
|
|
46
|
+
return EXIT.USAGE;
|
|
47
|
+
}
|
|
40
48
|
const probe = git(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
|
|
41
49
|
// "No history yet" is a VALID state, not a failure — a brand-new project
|
|
42
50
|
// is the most common state in which someone explores this command, and it
|
|
@@ -55,8 +63,6 @@ export async function run(_positional, flags) {
|
|
|
55
63
|
return EXIT.OK;
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
const sinceRaw = flagString(flags, "since", "90");
|
|
59
|
-
const since = /^\d+$/.test(sinceRaw) ? `${sinceRaw} days ago` : sinceRaw;
|
|
60
66
|
const save = flagBool(flags, "save", false);
|
|
61
67
|
|
|
62
68
|
const log = git(projectRoot, [
|
|
@@ -218,8 +224,10 @@ Flags:
|
|
|
218
224
|
opt-in usage log. Recording is OFF unless DOCTRINA_USAGE_LOG
|
|
219
225
|
names a file; only the operation is recorded, never
|
|
220
226
|
arguments, and nothing leaves the machine.
|
|
221
|
-
--since <n|date> Window: a day count (default 90)
|
|
222
|
-
|
|
227
|
+
--since <n|date> Window: a day count (default 90), a calendar date
|
|
228
|
+
("2026-01-01") or "<n> <unit>s ago" ("3 months ago").
|
|
229
|
+
Anything else is refused: git reads any text as a
|
|
230
|
+
date, so a typo would measure the wrong window.
|
|
223
231
|
--save Write .doctrina/metrics/YYYY-MM-DD.json and print the
|
|
224
232
|
deltas against the most recent prior snapshot.
|
|
225
233
|
--trend Read the saved snapshots instead of git: every snapshot
|
package/src/commands/next.js
CHANGED
|
@@ -79,9 +79,10 @@ export async function run(_positional, flags) {
|
|
|
79
79
|
export const RUNNERS = {
|
|
80
80
|
triage: () => import("./triage.js"),
|
|
81
81
|
intake: () => import("./intake.js"),
|
|
82
|
-
|
|
82
|
+
"change check": () => import("./change.js"),
|
|
83
83
|
"change apply": () => import("./change.js"),
|
|
84
84
|
"change archive": () => import("./change.js"),
|
|
85
|
+
close: () => import("./close.js"),
|
|
85
86
|
"index rebuild": () => import("./index-rebuild.js"),
|
|
86
87
|
};
|
|
87
88
|
|
package/src/commands/prime.js
CHANGED
|
@@ -29,8 +29,8 @@ export async function run(_positional, flags) {
|
|
|
29
29
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
30
30
|
throw notADoctrinaProject();
|
|
31
31
|
}
|
|
32
|
-
// --rules: the standing rules in full, which is what
|
|
33
|
-
// (
|
|
32
|
+
// --rules: the standing rules in full, which is what the removed
|
|
33
|
+
// `constitution` printed (changes 0049, 0175). The primer names the ADRs and counts the non-goals; this
|
|
34
34
|
// prints both lists, from the same collection, so the two cannot disagree.
|
|
35
35
|
const view = flagBool(flags, "rules", false) ? "rules" : "prime";
|
|
36
36
|
for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
|
|
@@ -49,9 +49,8 @@ The same view as \`doctrina status --view prime\`; both render one
|
|
|
49
49
|
collection of the tree, so they cannot disagree.
|
|
50
50
|
|
|
51
51
|
--rules Print the standing rules in full instead of the primer: every
|
|
52
|
-
accepted ADR and every declared non-goal
|
|
53
|
-
\`doctrina constitution\` printed, from the same collection
|
|
54
|
-
that command is deprecated and delegates here.
|
|
52
|
+
accepted ADR and every declared non-goal — what the removed
|
|
53
|
+
\`doctrina constitution\` printed, from the same collection.
|
|
55
54
|
|
|
56
55
|
Read-only; always exits 0. Deeper reads: \`doctrina context\`,
|
|
57
56
|
\`doctrina why <cap>\`, \`doctrina show <ref>\`.
|
package/src/commands/report.js
CHANGED
|
@@ -2,14 +2,12 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists } from "../lib/fs-ops.js";
|
|
5
|
-
import { flagBool, flagString } from "../lib/args.js";
|
|
5
|
+
import { flagBool, flagString, parsePositiveInt } from "../lib/args.js";
|
|
6
6
|
import { c } from "../lib/colors.js";
|
|
7
7
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
8
|
-
import { collectSnapshot } from "../lib/snapshot.js";
|
|
8
|
+
import { collectSnapshot, collectReportWindow } from "../lib/snapshot.js";
|
|
9
9
|
import { renderView } from "../lib/views.js";
|
|
10
|
-
import { gitWindow, historyState, windowCutoff } from "../lib/git.js";
|
|
11
10
|
import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
|
|
12
|
-
import { collectMetrics } from "../lib/metrics-model.js";
|
|
13
11
|
import { cliVersion } from "../lib/version.js";
|
|
14
12
|
|
|
15
13
|
// Periodic digest in Markdown — the standup/PR-description view: what was
|
|
@@ -33,8 +31,8 @@ export async function run(_positional, flags) {
|
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
const sinceRaw = flagString(flags, "since");
|
|
36
|
-
const days =
|
|
37
|
-
if (
|
|
34
|
+
const days = parsePositiveInt(sinceRaw ?? "7");
|
|
35
|
+
if (days === null) {
|
|
38
36
|
console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
|
|
39
37
|
return 2;
|
|
40
38
|
}
|
|
@@ -51,19 +49,9 @@ export async function run(_positional, flags) {
|
|
|
51
49
|
return 0;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
git: gitWindow(projectRoot, days),
|
|
58
|
-
// Why there is no window, when there is none — the same door `metrics`
|
|
59
|
-
// and `context` already ask, so the three cannot disagree about the
|
|
60
|
-
// state of one repository (change 0080).
|
|
61
|
-
gitState: historyState(projectRoot),
|
|
62
|
-
// The same snapshot `metrics` renders, for the same window (change
|
|
63
|
-
// 0050). One definition of "revert rate", so the digest and the metrics
|
|
64
|
-
// command cannot report two different numbers for one period.
|
|
65
|
-
metrics: collectMetrics(projectRoot, `${days} days ago`),
|
|
66
|
-
};
|
|
52
|
+
// One collector with `status --view report` (change 0176), so the
|
|
53
|
+
// deprecated name and its survivor cannot print different digests.
|
|
54
|
+
const options = collectReportWindow(projectRoot, days);
|
|
67
55
|
for (const line of renderView("report", collectSnapshot(projectRoot), options)) console.log(line);
|
|
68
56
|
return 0;
|
|
69
57
|
}
|
|
@@ -78,7 +66,8 @@ work with task progress, artifact counts, and a local-git summary
|
|
|
78
66
|
descriptions. Read-only; no network. \`doctrina metrics\` has the
|
|
79
67
|
deeper git-derived numbers.
|
|
80
68
|
|
|
81
|
-
|
|
69
|
+
Deprecated: this is \`doctrina status --view report --since <days>\`, and
|
|
70
|
+
\`--agent-changelog\` is \`doctrina status --view agent-changelog\`.
|
|
82
71
|
|
|
83
72
|
--agent-changelog Draft the AGENTS.md "What changed" block instead of
|
|
84
73
|
the digest: one candidate bullet per archived change
|
package/src/commands/review.js
CHANGED
|
@@ -8,7 +8,8 @@ import { c } from "../lib/colors.js";
|
|
|
8
8
|
import { rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
9
9
|
import { readLedger, churnByCapability } from "../lib/ledger.js";
|
|
10
10
|
import { changedFiles, windowCutoff, historyState, refExists } from "../lib/git.js";
|
|
11
|
-
import { dependentsOf } from "../lib/scan.js";
|
|
11
|
+
import { dependentsOf, deltaCapabilities } from "../lib/scan.js";
|
|
12
|
+
import { refuseChangeRef } from "../lib/project.js";
|
|
12
13
|
import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
|
|
13
14
|
import { summarize as coverageSummary } from "../lib/coverage-model.js";
|
|
14
15
|
import { summarize as traceSummary } from "../lib/trace-model.js";
|
|
@@ -37,7 +38,7 @@ const CHURN_NOTABLE = 3;
|
|
|
37
38
|
// bury every other finding under a file listing.
|
|
38
39
|
const ORPHAN_LIST_LIMIT = 5;
|
|
39
40
|
|
|
40
|
-
export const flags = { boolean: ["json", "strict"], string: ["diff"] };
|
|
41
|
+
export const flags = { boolean: ["json", "strict"], string: ["diff", "change"] };
|
|
41
42
|
|
|
42
43
|
export async function run(_positional, flags) {
|
|
43
44
|
const projectRoot = process.cwd();
|
|
@@ -60,8 +61,26 @@ export async function run(_positional, flags) {
|
|
|
60
61
|
return EXIT.USAGE;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// --change <id>: review the tree AS that change's close sees it (change
|
|
65
|
+
// 0200). Its deltas are the spec updates — merged one step later, by
|
|
66
|
+
// `apply` — so the capabilities they target count as updated.
|
|
67
|
+
const changeId = flagString(flags, "change");
|
|
68
|
+
/** @type {Set<string> | null} */
|
|
69
|
+
let changeCaps = null;
|
|
70
|
+
if (changeId !== undefined) {
|
|
71
|
+
const refused = refuseChangeRef(changeId);
|
|
72
|
+
if (refused !== null) return refused;
|
|
73
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", changeId);
|
|
74
|
+
if (!isDir(changeDir)) {
|
|
75
|
+
console.error(c.red("error:") + ` change "${changeId}" not found at .doctrina/changes/${changeId}`);
|
|
76
|
+
return EXIT.USAGE;
|
|
77
|
+
}
|
|
78
|
+
changeCaps = new Set(deltaCapabilities(changeDir));
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
const { all, sourceFiles } = reviewScope(projectRoot, against);
|
|
64
|
-
console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes")
|
|
82
|
+
console.log(c.bold("Review") + c.gray(against ? ` — vs ${against}` : " — working-tree changes") +
|
|
83
|
+
(changeId ? c.gray(` · change ${changeId}`) : ""));
|
|
65
84
|
console.log("");
|
|
66
85
|
|
|
67
86
|
if (all.length === 0) {
|
|
@@ -85,10 +104,18 @@ export async function run(_positional, flags) {
|
|
|
85
104
|
);
|
|
86
105
|
const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
|
|
87
106
|
const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
107
|
+
const stale = ranked.map((m) => m.id).filter((id) => !specChanged.has(id) && !changeCaps?.has(id));
|
|
108
|
+
if (changeCaps === null) {
|
|
109
|
+
for (const id of stale) {
|
|
110
|
+
breaks.push(`code under capability "${id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${id}/spec.md\` still describes the code (update it, or note why not)`);
|
|
91
111
|
}
|
|
112
|
+
} else if (stale.length > 0) {
|
|
113
|
+
// Reviewed as a change: code under capabilities it carries no delta for
|
|
114
|
+
// is either this change forgetting a delta or other work lying in the
|
|
115
|
+
// tree — the review cannot tell which, so it says both, ONCE. One break
|
|
116
|
+
// per capability, repeated on every close, is how the warning became
|
|
117
|
+
// noise nobody read (field review, 0.17).
|
|
118
|
+
breaks.push(`code changed under ${stale.length === 1 ? "a capability" : `${stale.length} capabilities`} this change carries no delta for: ${stale.map((id) => `"${id}"`).join(", ")} — if this change made those edits, add a delta (or note why the spec still holds); if other work did, it is not this change's (commit or stash it)`);
|
|
92
119
|
}
|
|
93
120
|
|
|
94
121
|
// 1b. Dependents of touched capabilities (the machine-readable
|
|
@@ -107,7 +134,10 @@ export async function run(_positional, flags) {
|
|
|
107
134
|
if (touched.size > 0) {
|
|
108
135
|
const since = windowCutoff(CHURN_WINDOW_DAYS);
|
|
109
136
|
const churn = churnByCapability(readLedger(projectRoot).entries, { since })
|
|
110
|
-
|
|
137
|
+
// Reviewed as a change, only the capabilities it updates: churn is
|
|
138
|
+
// context for the spec being changed, and four lines of it about
|
|
139
|
+
// ground the change merely stands near was more noise per close.
|
|
140
|
+
.filter((row) => (changeCaps ?? touched).has(row.capability) && row.changes >= CHURN_NOTABLE);
|
|
111
141
|
for (const row of churn) {
|
|
112
142
|
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"`);
|
|
113
143
|
}
|
|
@@ -219,7 +249,7 @@ function runContractCheck(projectRoot) {
|
|
|
219
249
|
}
|
|
220
250
|
|
|
221
251
|
export const help = `
|
|
222
|
-
Usage: doctrina review [--diff <ref>] [--strict]
|
|
252
|
+
Usage: doctrina review [--diff <ref>] [--change <id>] [--strict]
|
|
223
253
|
|
|
224
254
|
Deterministic conformance review of your changes against the spec / ADR /
|
|
225
255
|
contract tree. By default it reviews the working-tree changes (tracked +
|
|
@@ -232,6 +262,15 @@ citing missing proof, product intent realized by no spec, and contract
|
|
|
232
262
|
port/reference collisions. It checks conformance shape, NOT semantic
|
|
233
263
|
fidelity (whether the code does what the spec says stays a human/LLM call).
|
|
234
264
|
|
|
265
|
+
--change <id> reviews the tree as that change's close does: the capabilities
|
|
266
|
+
its deltas target count as updated (the close merges them one step later),
|
|
267
|
+
and code under capabilities it carries no delta for is reported in one line —
|
|
268
|
+
this change forgot a delta, or other work is lying in the tree.
|
|
269
|
+
|
|
270
|
+
A file's owner is the spec whose \`**Source:**\` claims it; failing that, the
|
|
271
|
+
spec citing its exact path; failing both, the path and basename inference,
|
|
272
|
+
from specs that declare no Source.
|
|
273
|
+
|
|
235
274
|
Read-only. Exits 0 as a report; with --strict, exits 1 when any hard break
|
|
236
275
|
exists (CI gate). Notes (advisory) never fail the gate.
|
|
237
276
|
`;
|
package/src/commands/skill.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { readdirSync } from "node:fs";
|
|
5
4
|
import { exists, isDir, isFile, mkdirp, read, relPath, walk, write } from "../lib/fs-ops.js";
|
|
6
5
|
import { readTemplate, locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
7
6
|
import * as idx from "../lib/index-json.js";
|
|
@@ -9,10 +8,10 @@ import { today } from "../lib/dates.js";
|
|
|
9
8
|
import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
10
9
|
import { c } from "../lib/colors.js";
|
|
11
10
|
import { suggest } from "../lib/suggest.js";
|
|
11
|
+
import { skillCandidates } from "../lib/skill-candidates.js";
|
|
12
12
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
13
|
import { parseFrontmatter } from "../lib/doc-model.js";
|
|
14
|
-
import {
|
|
15
|
-
import { FIX_SHAPED, FIX_SHAPED_SUBJECT } from "../lib/lexicon.js";
|
|
14
|
+
import { fold, skillSlug } from "../lib/lexicon.js";
|
|
16
15
|
import { artifactNameError } from "../lib/names.js";
|
|
17
16
|
|
|
18
17
|
const SUBCOMMANDS = ["new", "list", "sync", "suggest"];
|
|
@@ -104,7 +103,7 @@ export function draftFromError(text) {
|
|
|
104
103
|
// Distinctive words, most frequent first — the fallback when an error
|
|
105
104
|
// carries no structure at all.
|
|
106
105
|
const counts = new Map();
|
|
107
|
-
for (const w of raw
|
|
106
|
+
for (const w of fold(raw).match(/[a-z][a-z0-9_-]{3,}/g) ?? []) {
|
|
108
107
|
if (ERROR_STOP.has(w)) continue;
|
|
109
108
|
counts.set(w, (counts.get(w) ?? 0) + 1);
|
|
110
109
|
}
|
|
@@ -125,8 +124,8 @@ export function draftFromError(text) {
|
|
|
125
124
|
// also mentions do not both land in the name.
|
|
126
125
|
const seedTokens = [];
|
|
127
126
|
const seenToken = new Set();
|
|
128
|
-
for (const token of [...identifiers.map((i) => i
|
|
129
|
-
const norm = token.replace(/[^a-z0-9]/g, "");
|
|
127
|
+
for (const token of [...identifiers.map((i) => fold(i)), ...keywords]) {
|
|
128
|
+
const norm = fold(token).replace(/[^a-z0-9]/g, "");
|
|
130
129
|
if (norm === "" || seenToken.has(norm)) continue;
|
|
131
130
|
seenToken.add(norm);
|
|
132
131
|
seedTokens.push(token);
|
|
@@ -237,57 +236,8 @@ function skillSuggest(args, flags) {
|
|
|
237
236
|
return writeErrorDraft(projectRoot, draft, doWrite);
|
|
238
237
|
}
|
|
239
238
|
|
|
239
|
+
const candidates = skillCandidates(projectRoot, { since, limit: GIT_LOG_LIMIT });
|
|
240
240
|
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
241
|
-
const existingFiles = isDir(skillsDir) ? walk(skillsDir).filter((f) => f.endsWith(".md")) : [];
|
|
242
|
-
const existing = new Set(existingFiles.map((f) => path.basename(f, ".md")));
|
|
243
|
-
// A lesson may already be captured under a DIFFERENT slug (0.11.0 field
|
|
244
|
-
// review item 11: the human names the skill after the lesson, not after the
|
|
245
|
-
// change id — "tela-preta-crash-de-render..." for change 0049). Two extra
|
|
246
|
-
// dedup signals: an existing skill whose body cites the candidate's change
|
|
247
|
-
// id / commit hash, and token-overlap similarity between the slugs.
|
|
248
|
-
const existingBodies = existingFiles.map((f) => ({ slug: path.basename(f, ".md"), body: read(f) }));
|
|
249
|
-
const captured = (cand) => {
|
|
250
|
-
if (existing.has(cand.slug)) return true;
|
|
251
|
-
for (const sk of existingBodies) {
|
|
252
|
-
if (cand.id && sk.body.includes(cand.id)) return true;
|
|
253
|
-
if (cand.from && sk.body.includes(cand.from)) return true;
|
|
254
|
-
if (slugSimilarity(cand.slug, sk.slug) >= 0.5) return true;
|
|
255
|
-
}
|
|
256
|
-
return false;
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
// Two deterministic sources, deduped by slug. `seen` guards against a lesson
|
|
260
|
-
// appearing twice (e.g. a fix committed and later archived as a change).
|
|
261
|
-
/** @type {SkillCandidate[]} */
|
|
262
|
-
const candidates = [];
|
|
263
|
-
const seen = new Set();
|
|
264
|
-
|
|
265
|
-
// Source 1 — archived change proposals with a fix-shaped folder name.
|
|
266
|
-
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
267
|
-
if (isDir(archiveDir)) {
|
|
268
|
-
for (const name of readdirSync(archiveDir).sort()) {
|
|
269
|
-
if (!isDir(path.join(archiveDir, name))) continue;
|
|
270
|
-
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
271
|
-
if (!FIX_SHAPED.test(id)) continue;
|
|
272
|
-
const slug = skillSlug(id);
|
|
273
|
-
if (seen.has(slug)) continue;
|
|
274
|
-
/** @type {SkillCandidate} */
|
|
275
|
-
const cand = { id, slug, source: "change", from: name };
|
|
276
|
-
if (captured(cand)) continue; // captured (any slug) or cited by an existing skill
|
|
277
|
-
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
278
|
-
cand.why = isFile(proposal) ? firstWhyLine(read(proposal)) : "";
|
|
279
|
-
seen.add(slug);
|
|
280
|
-
candidates.push(cand);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Source 2 — fix-shaped commits in the git history (ADR 0013). Archive wins a
|
|
285
|
-
// slug collision: a `## Why` paragraph is a richer seed than a subject line.
|
|
286
|
-
for (const cand of gitFixCommits(projectRoot, { since, limit: GIT_LOG_LIMIT })) {
|
|
287
|
-
if (seen.has(cand.slug) || captured(cand)) continue;
|
|
288
|
-
seen.add(cand.slug);
|
|
289
|
-
candidates.push(cand);
|
|
290
|
-
}
|
|
291
241
|
|
|
292
242
|
if (candidates.length === 0) {
|
|
293
243
|
console.log(c.gray("no uncaptured fix-shaped lessons found in .doctrina/changes/archive/ or the git history"));
|
|
@@ -344,7 +294,7 @@ function skillSuggest(args, flags) {
|
|
|
344
294
|
idx.save(projectRoot, index);
|
|
345
295
|
}
|
|
346
296
|
console.log("");
|
|
347
|
-
console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina
|
|
297
|
+
console.log(c.green("ok") + ` scaffolded ${created} skill stub${created === 1 ? "" : "s"} — fill the description/when/body, then \`doctrina index rebuild\``);
|
|
348
298
|
return 0;
|
|
349
299
|
}
|
|
350
300
|
|
|
@@ -361,87 +311,6 @@ function seedLabel(cand) {
|
|
|
361
311
|
: `Candidate from change "${cand.id}"`;
|
|
362
312
|
}
|
|
363
313
|
|
|
364
|
-
// Source 2 for `skill suggest`: fix-shaped commits in the git history (ADR
|
|
365
|
-
// 0013). Deterministic — a regex on the subject, never a read of the diff
|
|
366
|
-
// (ADR 0005 semantic-fidelity ceiling). Returns [] silently when this is not a
|
|
367
|
-
// git repo, git is absent, or the --since ref is unknown, so the command
|
|
368
|
-
// degrades to archive-only rather than erroring.
|
|
369
|
-
function gitFixCommits(projectRoot, { since, limit }) {
|
|
370
|
-
// US (0x1f) between fields, RS (0x1e) between records — robust against any
|
|
371
|
-
// newline or punctuation inside a subject or body.
|
|
372
|
-
const format = "%H%x1f%s%x1f%b%x1e";
|
|
373
|
-
const args = ["log", "--no-merges", `--format=${format}`];
|
|
374
|
-
if (since) args.push(`${since}..HEAD`);
|
|
375
|
-
else args.push("-n", String(limit));
|
|
376
|
-
|
|
377
|
-
const r = git(projectRoot, args);
|
|
378
|
-
if (r.state !== GIT_STATE.OK || !r.stdout) return [];
|
|
379
|
-
|
|
380
|
-
/** @type {SkillCandidate[]} */
|
|
381
|
-
const out = [];
|
|
382
|
-
const seen = new Set();
|
|
383
|
-
for (const record of r.stdout.split("\x1e")) {
|
|
384
|
-
const [sha, subject = "", body = ""] = record.replace(/^\r?\n/, "").split("\x1f");
|
|
385
|
-
if (!sha || !sha.trim()) continue;
|
|
386
|
-
if (!FIX_SHAPED_SUBJECT.test(subject)) continue;
|
|
387
|
-
const slug = commitSlug(subject);
|
|
388
|
-
if (!slug || seen.has(slug)) continue; // first commit wins a duplicate lesson
|
|
389
|
-
seen.add(slug);
|
|
390
|
-
const firstBodyLine = body.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
|
|
391
|
-
out.push({
|
|
392
|
-
id: slug,
|
|
393
|
-
slug,
|
|
394
|
-
source: "commit",
|
|
395
|
-
ref: sha.trim().slice(0, 9),
|
|
396
|
-
why: subject.trim() + (firstBodyLine ? ` — ${firstBodyLine}` : ""),
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
return out;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Derive a skill slug from a commit subject: drop a conventional-commit
|
|
403
|
-
// "type(scope): " prefix, then reuse skillSlug for the lowercase/hyphenate/cap.
|
|
404
|
-
function commitSlug(subject) {
|
|
405
|
-
const stripped = subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, "");
|
|
406
|
-
return skillSlug(stripped.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function skillSlug(id) {
|
|
410
|
-
// Reuse the change id as the skill slug, trimmed to a sane length.
|
|
411
|
-
const slug = id.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
412
|
-
return slug.length > 40 ? slug.slice(0, 40).replace(/-+$/g, "") : (slug || "lesson");
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Token-overlap similarity between two kebab slugs (Jaccard over meaningful
|
|
416
|
-
// tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
|
|
417
|
-
// error-boundary" share tela/preta/render → high overlap even though the
|
|
418
|
-
// human renamed the skill after the lesson. Deterministic; never semantic.
|
|
419
|
-
function slugSimilarity(a, b) {
|
|
420
|
-
const toks = (s) => new Set(
|
|
421
|
-
String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
|
|
422
|
-
);
|
|
423
|
-
const A = toks(a);
|
|
424
|
-
const B = toks(b);
|
|
425
|
-
if (A.size === 0 || B.size === 0) return 0;
|
|
426
|
-
let inter = 0;
|
|
427
|
-
for (const t of A) if (B.has(t)) inter += 1;
|
|
428
|
-
return inter / (A.size + B.size - inter);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// Fix-shaped verbs and connectives carry no lesson identity — without this,
|
|
432
|
-
// every "fix-..." candidate looks 30% similar to every "fix-..." skill.
|
|
433
|
-
const SLUG_NOISE = new Set([
|
|
434
|
-
"fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
|
|
435
|
-
"que", "nao", "por", "para", "una", "uma", "dos", "das",
|
|
436
|
-
]);
|
|
437
|
-
|
|
438
|
-
function firstWhyLine(proposalText) {
|
|
439
|
-
const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
|
|
440
|
-
if (!m) return "";
|
|
441
|
-
const line = m[1].trim();
|
|
442
|
-
return /^<!--/.test(line) ? "" : line;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
314
|
function skillNew(args, flags) {
|
|
446
315
|
const name = args[0];
|
|
447
316
|
const nameError = artifactNameError(name, "skill name");
|
|
@@ -482,7 +351,7 @@ function skillNew(args, flags) {
|
|
|
482
351
|
idx.save(projectRoot, index);
|
|
483
352
|
console.log(c.green("indexed") + ` skill "${name}"`);
|
|
484
353
|
console.log("");
|
|
485
|
-
console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina
|
|
354
|
+
console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then run `doctrina index rebuild` to update index.json");
|
|
486
355
|
return 0;
|
|
487
356
|
}
|
|
488
357
|
|
package/src/commands/spec.js
CHANGED
|
@@ -155,8 +155,12 @@ function specSet(args, flags) {
|
|
|
155
155
|
let text = read(specPath);
|
|
156
156
|
const applied = [];
|
|
157
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 });
|
|
158
162
|
const apply = (res) => {
|
|
159
|
-
if (res.error) errors.push(res.error);
|
|
163
|
+
if (res.error) errors.push({ msg: res.error, argument: res.cause === "argument" });
|
|
160
164
|
else { text = res.text; applied.push(res.summary); }
|
|
161
165
|
};
|
|
162
166
|
// Order: headers, then version, then criterion — independent edits, all or
|
|
@@ -173,7 +177,7 @@ function specSet(args, flags) {
|
|
|
173
177
|
const derivedValue = derivedImplementations(projectRoot, { only: new Set([capability]) })
|
|
174
178
|
.get(capability)?.derived;
|
|
175
179
|
if (!derivedValue) {
|
|
176
|
-
errors.push(`--implementation auto needs acceptance criteria to derive from; ${capability} declares none
|
|
180
|
+
errors.push({ msg: `--implementation auto needs acceptance criteria to derive from; ${capability} declares none`, argument: false });
|
|
177
181
|
} else {
|
|
178
182
|
apply(setHeader(text, "Implementation", derivedValue));
|
|
179
183
|
}
|
|
@@ -182,23 +186,25 @@ function specSet(args, flags) {
|
|
|
182
186
|
}
|
|
183
187
|
}
|
|
184
188
|
if (bump !== undefined) {
|
|
185
|
-
if (!["major", "minor", "patch"].includes(bump))
|
|
189
|
+
if (!["major", "minor", "patch"].includes(bump)) refused(`--bump needs major|minor|patch (got "${bump}")`);
|
|
186
190
|
else apply(bumpVersion(text, bump));
|
|
187
191
|
}
|
|
188
192
|
if (versionExplicit !== undefined) {
|
|
189
|
-
if (!/^\d+\.\d+\.\d+$/.test(versionExplicit))
|
|
193
|
+
if (!/^\d+\.\d+\.\d+$/.test(versionExplicit)) refused(`--version needs semver X.Y.Z (got "${versionExplicit}")`);
|
|
190
194
|
else apply(setHeader(text, "Version", versionExplicit));
|
|
191
195
|
}
|
|
192
196
|
if (criterion !== undefined) {
|
|
193
197
|
const m = String(criterion).match(/^(\d+)\s*:\s*(.+)$/);
|
|
194
|
-
if (!m)
|
|
198
|
+
if (!m) refused(`--criterion needs "<n>:<mark>" (got "${criterion}")`);
|
|
195
199
|
else apply(setCriterionMark(text, Number(m[1]), m[2].trim()));
|
|
196
200
|
}
|
|
197
201
|
|
|
198
202
|
if (errors.length > 0) {
|
|
199
203
|
console.error(c.red("error:") + ` ${errors.length} operation error${errors.length === 1 ? "" : "s"} — spec left untouched:`);
|
|
200
|
-
for (const e of errors) console.error(` - ${e}`);
|
|
201
|
-
|
|
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;
|
|
202
208
|
}
|
|
203
209
|
|
|
204
210
|
// Stamp Last updated when the header exists (best-effort, never an error).
|
package/src/commands/status.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists } from "../lib/fs-ops.js";
|
|
5
|
-
import { flagBool, flagString } from "../lib/args.js";
|
|
5
|
+
import { flagBool, flagString, parsePositiveInt } from "../lib/args.js";
|
|
6
6
|
import { c } from "../lib/colors.js";
|
|
7
7
|
import { emitJson } from "../lib/json-out.js";
|
|
8
8
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
9
|
-
import { collectSnapshot, collectStatus } from "../lib/snapshot.js";
|
|
9
|
+
import { collectSnapshot, collectStatus, collectReportWindow } from "../lib/snapshot.js";
|
|
10
10
|
import { renderView, VIEWS } from "../lib/views.js";
|
|
11
|
-
import {
|
|
11
|
+
import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
|
|
12
|
+
import { cliVersion } from "../lib/version.js";
|
|
12
13
|
import { suggest } from "../lib/suggest.js";
|
|
13
14
|
|
|
14
15
|
// One-glance project health (review 2026-06-27 passive-user feature #1): a
|
|
@@ -32,6 +33,9 @@ export const jsonNative = true;
|
|
|
32
33
|
|
|
33
34
|
export const flags = { boolean: ["json"], string: ["view", "since"] };
|
|
34
35
|
|
|
36
|
+
// The snapshot's views, plus the one draft that is not a view of it.
|
|
37
|
+
const STATUS_VIEWS = [...VIEWS, "agent-changelog"];
|
|
38
|
+
|
|
35
39
|
export async function run(_positional, flags) {
|
|
36
40
|
const projectRoot = process.cwd();
|
|
37
41
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
@@ -39,10 +43,10 @@ export async function run(_positional, flags) {
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
const view = flagString(flags, "view") ?? "dashboard";
|
|
42
|
-
if (!
|
|
46
|
+
if (!STATUS_VIEWS.includes(view)) {
|
|
43
47
|
console.error(c.red("error:") + ` unknown view "${view}"`);
|
|
44
|
-
const guess = suggest(view,
|
|
45
|
-
console.error(c.gray("hint: ") + (guess ? `did you mean --view ${guess}?` : `available: ${
|
|
48
|
+
const guess = suggest(view, STATUS_VIEWS);
|
|
49
|
+
console.error(c.gray("hint: ") + (guess ? `did you mean --view ${guess}?` : `available: ${STATUS_VIEWS.join(", ")}`));
|
|
46
50
|
return 2;
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -53,18 +57,24 @@ export async function run(_positional, flags) {
|
|
|
53
57
|
return 0;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
if (view === "report") {
|
|
58
|
-
const sinceRaw = flagString(flags, "since")
|
|
59
|
-
const days =
|
|
60
|
-
if (
|
|
60
|
+
let options = {};
|
|
61
|
+
if (view === "report" || view === "agent-changelog") {
|
|
62
|
+
const sinceRaw = flagString(flags, "since");
|
|
63
|
+
const days = parsePositiveInt(sinceRaw ?? "7");
|
|
64
|
+
if (days === null) {
|
|
61
65
|
console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
|
|
62
66
|
return 2;
|
|
63
67
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
// A DRAFT of the block `upgrade --write` writes into AGENTS.md, proposed
|
|
69
|
+
// from what the archived changes said they touched. Not a view of the
|
|
70
|
+
// snapshot, so it renders here; authorship stays human (ADR 0005). With
|
|
71
|
+
// no --since the window is "since the last tag".
|
|
72
|
+
if (view === "agent-changelog") {
|
|
73
|
+
const draft = draftAgentChangelog(projectRoot, { days: sinceRaw === undefined ? null : days });
|
|
74
|
+
for (const line of renderDraft(draft, cliVersion())) console.log(line);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
options = collectReportWindow(projectRoot, days);
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
const snapshot = collectSnapshot(projectRoot);
|
|
@@ -85,13 +95,22 @@ Flags:
|
|
|
85
95
|
dashboard the default one-glance health board
|
|
86
96
|
prime the session primer (\`doctrina prime\`)
|
|
87
97
|
handoff the Markdown resume note (\`doctrina handoff\`)
|
|
88
|
-
report the period digest
|
|
89
|
-
|
|
98
|
+
report the period digest: gates, archived changes,
|
|
99
|
+
open work, and local git numbers
|
|
100
|
+
rules the standing rules (\`doctrina prime --rules\`)
|
|
101
|
+
agent-changelog
|
|
102
|
+
a DRAFT of the AGENTS.md "What changed" block:
|
|
103
|
+
one bullet per archived change that touched a
|
|
104
|
+
documented surface, newest first, at most five.
|
|
105
|
+
It proposes; a person cuts and rewrites.
|
|
106
|
+
--since <days> With --view report: the window (default 7). With
|
|
107
|
+
--view agent-changelog: the window (default: since the
|
|
108
|
+
last tag).
|
|
90
109
|
--json Emit the snapshot as JSON (stable shape for agents and CI).
|
|
91
110
|
|
|
92
|
-
|
|
93
|
-
different numbers; \`prime
|
|
94
|
-
|
|
111
|
+
The views read ONE collection of the tree, so they can never report
|
|
112
|
+
different numbers; \`prime\` and \`handoff\` are the same views under their
|
|
113
|
+
own names. \`doctrina report\` is deprecated: it is \`--view report\`.
|
|
95
114
|
|
|
96
115
|
It is a fast summary, not the authoritative gate: run \`doctrina validate\`
|
|
97
116
|
and \`doctrina verify\` for the full structural and build checks, and
|