doctrina-cli 0.17.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 +7 -4
- package/package.json +1 -1
- package/src/commands/change.js +59 -4
- package/src/commands/close.js +74 -27
- package/src/commands/next.js +1 -0
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +3 -127
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +54 -4
- package/src/lib/actions.js +8 -4
- package/src/lib/change-ops.js +92 -0
- package/src/lib/commands.js +8 -0
- package/src/lib/git.js +59 -2
- package/src/lib/playbook.js +1 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +13 -0
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/snapshot.js +38 -1
- package/src/lib/spec-ops.js +117 -1
- package/src/lib/validation-model.js +1 -1
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +6 -1
- package/src/lib/work-model.js +32 -12
- package/templates/AGENTS.md.template +1 -1
- package/templates/README.md +2 -0
- package/templates/change/spec-delta.md.template +2 -0
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/work.md.template +3 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> CLI for the Doctrina framework. The package is published as
|
|
4
4
|
> `doctrina-cli`; the executable it installs is `doctrina`.
|
|
5
5
|
|
|
6
|
-
**Status:** v0.
|
|
6
|
+
**Status:** v0.18.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
|
@@ -25,7 +25,7 @@ surface, by the moment you reach for it:
|
|
|
25
25
|
```
|
|
26
26
|
Bootstrap init · intake · adapter list|add|remove
|
|
27
27
|
Orient prime · next · status · context · show · search · why · handoff
|
|
28
|
-
Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon
|
|
28
|
+
Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon|delta
|
|
29
29
|
contract new|list|check · decision new|accept|land|supersede|list|scope
|
|
30
30
|
skill new|list|suggest · intent add|list
|
|
31
31
|
Gate clarify · validate · coverage · trace · review · verify · close · doctor
|
|
@@ -72,14 +72,17 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
|
|
|
72
72
|
- **REMOVED:** deletes the target spec.
|
|
73
73
|
- **MODIFIED:** when the delta carries a fenced ` ```ops ` block
|
|
74
74
|
(`set-header` / `bump-version` / `set-criterion` / `replace-criterion`
|
|
75
|
-
/ `append-criterion` / `append-requirement` / `replace-requirement`
|
|
75
|
+
/ `append-criterion` / `append-requirement` / `replace-requirement` /
|
|
76
|
+
`remove-requirement` / `remove-criterion`),
|
|
76
77
|
applies it mechanically to the target spec — all
|
|
77
78
|
ops or none — and regenerates the index (ADR 0007). Without an ops
|
|
78
79
|
block it prints a manual-merge pointer and does not write: auto-merging
|
|
79
80
|
arbitrary prose is the kind of implicit decision Doctrina avoids.
|
|
80
81
|
|
|
81
82
|
On any spec write, the index is rebuilt from the tree so it never drifts
|
|
82
|
-
from the applied spec. `doctrina change
|
|
83
|
+
from the applied spec. `doctrina change delta <id> <capability>` writes the
|
|
84
|
+
ops lines for you (`--requirement`, `--criterion`, `--bump`), dry-run
|
|
85
|
+
against the spec before anything is written. `doctrina change abandon <id>` is the inverse of
|
|
83
86
|
`change new`: it deletes an open change and its index entry and ledgers
|
|
84
87
|
the abandonment. `doctrina close <id>` runs apply together with verify,
|
|
85
88
|
coverage, archive and validate in one attested pass — the definition of
|
package/package.json
CHANGED
package/src/commands/change.js
CHANGED
|
@@ -19,14 +19,14 @@ import { confirm, isInteractive } from "../lib/prompt.js";
|
|
|
19
19
|
import { EXIT } from "../lib/exit-codes.js";
|
|
20
20
|
import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
|
|
21
21
|
import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
|
|
22
|
-
import { changeNew } from "../lib/change-ops.js";
|
|
22
|
+
import { changeNew, addDeltaOps } from "../lib/change-ops.js";
|
|
23
23
|
|
|
24
|
-
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon"];
|
|
24
|
+
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta"];
|
|
25
25
|
|
|
26
26
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
27
27
|
// adding a command never requires editing the entrypoint — the gap that
|
|
28
28
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
29
|
-
export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason"] };
|
|
29
|
+
export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason", "requirement", "criterion", "bump", "op"] };
|
|
30
30
|
|
|
31
31
|
export async function run(positional, flags) {
|
|
32
32
|
const sub = positional[0];
|
|
@@ -44,6 +44,8 @@ export async function run(positional, flags) {
|
|
|
44
44
|
return changeTick(positional.slice(1), flags);
|
|
45
45
|
case "abandon":
|
|
46
46
|
return await changeAbandon(positional.slice(1), flags);
|
|
47
|
+
case "delta":
|
|
48
|
+
return changeDelta(positional.slice(1), flags);
|
|
47
49
|
default:
|
|
48
50
|
console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
|
|
49
51
|
const guess = suggest(sub, SUBCOMMANDS);
|
|
@@ -398,6 +400,49 @@ async function changeCheck(id, { verbose = false } = {}) {
|
|
|
398
400
|
// `tick <id> --all` checks everything (tasks.md + the proposal's
|
|
399
401
|
// ## Verification). Ticking is a claim of completion — the honest gate is
|
|
400
402
|
// still archive/verify; this only removes the mechanical friction.
|
|
403
|
+
// `change delta <id> <capability> [ops]` — write ops lines into the change's
|
|
404
|
+
// MODIFIED delta through the grammar `apply` reads (change 0205). Rendering
|
|
405
|
+
// only; lib/change-ops.js decides.
|
|
406
|
+
function changeDelta(args, flags) {
|
|
407
|
+
const [id, capability, ...rest] = args;
|
|
408
|
+
if (!id || !capability) {
|
|
409
|
+
console.error(c.red("error:") + " change delta requires <id> <capability> and at least one operation");
|
|
410
|
+
console.error(c.gray("hint: ") + "doctrina change delta <id> <capability> --requirement \"event: When <trigger>, the system shall <action>.\" --criterion \"[unverified] <signal> — verified by `<test>`\" --bump minor");
|
|
411
|
+
return EXIT.USAGE;
|
|
412
|
+
}
|
|
413
|
+
const refused = refuseChangeRef(id);
|
|
414
|
+
if (refused !== null) return refused;
|
|
415
|
+
const projectRoot = process.cwd();
|
|
416
|
+
ensureDoctrinaProject(projectRoot);
|
|
417
|
+
if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
|
|
418
|
+
console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
|
|
419
|
+
return EXIT.USAGE;
|
|
420
|
+
}
|
|
421
|
+
// In the order a reader expects them: requirement, criterion, any raw op,
|
|
422
|
+
// and the bump last.
|
|
423
|
+
const lines = [];
|
|
424
|
+
const requirement = flagString(flags, "requirement");
|
|
425
|
+
const criterion = flagString(flags, "criterion");
|
|
426
|
+
const raw = flagString(flags, "op") ?? (rest.length > 0 ? rest.join(" ") : undefined);
|
|
427
|
+
const bump = flagString(flags, "bump");
|
|
428
|
+
if (requirement) lines.push(`append-requirement ${requirement}`);
|
|
429
|
+
if (criterion) lines.push(`append-criterion ${criterion}`);
|
|
430
|
+
if (raw) lines.push(raw);
|
|
431
|
+
if (bump) lines.push(`bump-version ${bump}`);
|
|
432
|
+
|
|
433
|
+
const r = addDeltaOps(projectRoot, id, capability, lines);
|
|
434
|
+
if (r.code !== EXIT.OK) {
|
|
435
|
+
console.error(c.red("error:") + ` ${r.error}`);
|
|
436
|
+
for (const d of r.details ?? []) console.error(` - ${d}`);
|
|
437
|
+
if (r.hint) console.error(c.gray("hint: ") + r.hint);
|
|
438
|
+
return r.code;
|
|
439
|
+
}
|
|
440
|
+
console.log(c.green(r.created ? "created" : "updated") + ` ${r.deltaRel}` +
|
|
441
|
+
c.gray(` — ${lines.length} op${lines.length === 1 ? "" : "s"} added; the block now holds ${r.block.length} (${r.block.join(", ")}), and applies cleanly`));
|
|
442
|
+
console.log(c.gray("Preview the merge: ") + c.cyan(`doctrina change check ${id} --verbose`));
|
|
443
|
+
return EXIT.OK;
|
|
444
|
+
}
|
|
445
|
+
|
|
401
446
|
function changeTick(args, flags) {
|
|
402
447
|
const id = args[0];
|
|
403
448
|
if (!id) {
|
|
@@ -768,6 +813,13 @@ Subcommands:
|
|
|
768
813
|
or every one with --all. No args = list only.
|
|
769
814
|
abandon <id> Delete an open change folder and its index entry, and
|
|
770
815
|
record the abandonment in the ledger ([--reason "..."]).
|
|
816
|
+
delta <id> <capability> [--requirement "<section>: <text>"] [--criterion "<text>"]
|
|
817
|
+
[--bump major|minor|patch] [--op "<op line>" | <op line>]
|
|
818
|
+
Write ops into the change's MODIFIED delta for the
|
|
819
|
+
capability — created from the template when absent —
|
|
820
|
+
parsed by the grammar apply reads and dry-run against
|
|
821
|
+
the spec first: nothing is written unless the whole
|
|
822
|
+
block applies. A second --bump replaces the first.
|
|
771
823
|
|
|
772
824
|
apply / archive / check accept multiple ids (batch close of a backlog); the
|
|
773
825
|
exit code is the worst per-id result.
|
|
@@ -780,9 +832,12 @@ A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
|
|
|
780
832
|
append-criterion [unverified] new signal — verified by \`test/x.test.ts\`
|
|
781
833
|
append-requirement event: When <trigger>, the system shall <action>.
|
|
782
834
|
replace-requirement ubiquitous 2: The system shall <action>.
|
|
835
|
+
remove-requirement state 1: superseded by event 3
|
|
836
|
+
remove-criterion 4: proven by criterion 2 since 0.9
|
|
783
837
|
\`\`\`
|
|
784
838
|
append-* ops resolve numbering/position at apply time, so concurrent open
|
|
785
|
-
changes appending to the same spec cannot collide.
|
|
839
|
+
changes appending to the same spec cannot collide. \`change delta\` writes
|
|
840
|
+
these lines for you, one call per addition.
|
|
786
841
|
|
|
787
842
|
Options:
|
|
788
843
|
--force Overwrite existing files (where applicable)
|
package/src/commands/close.js
CHANGED
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { appendFileSync } from "node:fs";
|
|
5
|
-
import { exists, isDir, isFile, read
|
|
5
|
+
import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
|
|
6
6
|
import { flagBool } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
|
-
import { parseCapabilityFromDelta } from "../lib/doc-model.js";
|
|
9
8
|
import { printAdrCheckpoint, acceptedDecisionCount } from "../lib/adr-guard.js";
|
|
10
9
|
import { docsRemedy, checkDocsImpact, checkChangelogImpact } from "../lib/docs-impact.js";
|
|
11
10
|
import { collectRuntimeFindings } from "../lib/runtime.js";
|
|
12
11
|
import { derivedImplementations, implementationMismatch, summarize } from "../lib/coverage-model.js";
|
|
13
|
-
import { specHeader, dependentsOf } from "../lib/scan.js";
|
|
12
|
+
import { specHeader, dependentsOf, deltaCapabilities } from "../lib/scan.js";
|
|
14
13
|
import { sequence, sequenceLabels, stepRerun } from "../lib/gates.js";
|
|
15
14
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
16
15
|
import { refuseChangeRef } from "../lib/project.js";
|
|
16
|
+
import { unfinishedChanges } from "../lib/snapshot.js";
|
|
17
17
|
import * as analyze from "./analyze.js";
|
|
18
18
|
import * as change from "./change.js";
|
|
19
19
|
import * as verify from "./verify.js";
|
|
@@ -23,7 +23,7 @@ import * as validate from "./validate.js";
|
|
|
23
23
|
import * as indexRebuild from "./index-rebuild.js";
|
|
24
24
|
import { appendLedgerLine, docsGapLine, ledgerPath as ledgerFile } from "../lib/ledger.js";
|
|
25
25
|
import * as review from "./review.js";
|
|
26
|
-
import
|
|
26
|
+
import { skillCandidates } from "../lib/skill-candidates.js";
|
|
27
27
|
|
|
28
28
|
// One-command close (review 2026-06-27 passive-user feature #2). The work
|
|
29
29
|
// playbook lists the closing sequence — analyze → apply → runtime → verify →
|
|
@@ -65,9 +65,62 @@ export async function run(positional, flags) {
|
|
|
65
65
|
? c.green("ok") + ` all ${positional.length} changes closed`
|
|
66
66
|
: c.red("fail") + " at least one close stopped — see above");
|
|
67
67
|
}
|
|
68
|
+
reportSkills(projectRoot, positional);
|
|
69
|
+
reportUnfinished(projectRoot, positional);
|
|
68
70
|
return worst;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
// Capture the lesson while it is fresh (operator review §4.7) — the lesson
|
|
74
|
+
// THIS close produced (change 0202). The close used to print the whole
|
|
75
|
+
// `skill suggest` listing, per id: the same thirteen candidates after every
|
|
76
|
+
// close, which a field review of 0.17 named as noise nobody read. Now a
|
|
77
|
+
// candidate born from a change just closed gets its line; every other one is
|
|
78
|
+
// a count pointing at the full listing. Once per run, batch or not.
|
|
79
|
+
// Advisory — a listing, never a gate, and a failure never un-closes anything.
|
|
80
|
+
function reportSkills(projectRoot, closing) {
|
|
81
|
+
let candidates;
|
|
82
|
+
try {
|
|
83
|
+
candidates = skillCandidates(projectRoot);
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (candidates.length === 0) return;
|
|
88
|
+
const ids = new Set(closing);
|
|
89
|
+
const fresh = candidates.filter((cand) => cand.source === "change" && ids.has(cand.id));
|
|
90
|
+
const waiting = candidates.length - fresh.length;
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(c.gray("──── skills (advisory)"));
|
|
93
|
+
for (const cand of fresh) {
|
|
94
|
+
console.log(c.yellow(" ! ") + `${cand.id} is fix-shaped — capture the lesson while it is fresh: ` +
|
|
95
|
+
c.cyan(`doctrina skill new ${cand.slug}`));
|
|
96
|
+
}
|
|
97
|
+
if (waiting > 0) {
|
|
98
|
+
console.log(c.gray(` ${waiting} ${fresh.length > 0 ? "older " : ""}candidate${waiting === 1 ? "" : "s"} waiting — \`doctrina skill suggest\``));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The other open changes, said at the one moment someone is looking (change
|
|
103
|
+
// 0201). A field review of 0.17: change 0078 shipped, stayed open, and no
|
|
104
|
+
// close after it said a word. A change that looks finished — applied, or
|
|
105
|
+
// every task ticked — and one open a week or more get a line each; the rest
|
|
106
|
+
// are a count. Advisory: it never moves the exit code.
|
|
107
|
+
function reportUnfinished(projectRoot, closing) {
|
|
108
|
+
const others = unfinishedChanges(projectRoot, { exclude: closing });
|
|
109
|
+
if (others.length === 0) return;
|
|
110
|
+
const flagged = others.filter((ch) => ch.reason !== null);
|
|
111
|
+
console.log("");
|
|
112
|
+
console.log(c.gray(`──── other open changes (advisory) — ${others.length}`));
|
|
113
|
+
for (const ch of flagged) {
|
|
114
|
+
const age = ch.ageDays !== null ? `, opened ${ch.ageDays}d ago` : "";
|
|
115
|
+
const how = ch.finished ? ` — ${c.cyan(`doctrina close ${ch.id}`)}` : "";
|
|
116
|
+
console.log(c.yellow(" ! ") + `${ch.id}: ${ch.reason}${age}${how}`);
|
|
117
|
+
}
|
|
118
|
+
const quiet = others.length - flagged.length;
|
|
119
|
+
if (quiet > 0) {
|
|
120
|
+
console.log(c.gray(` ${quiet} more open and recent — \`doctrina next\``));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
71
124
|
async function closeOne(projectRoot, id, flags) {
|
|
72
125
|
const refused = refuseChangeRef(id);
|
|
73
126
|
if (refused !== null) return refused;
|
|
@@ -138,13 +191,17 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
138
191
|
},
|
|
139
192
|
|
|
140
193
|
// The review runs against the working tree, which for a close IS the
|
|
141
|
-
// change's diff: the work is done and not yet archived.
|
|
194
|
+
// change's diff: the work is done and not yet archived. It is told which
|
|
195
|
+
// change it is reviewing (change 0200): the deltas it carries are the
|
|
196
|
+
// spec updates, merged one step later by `apply`, and code under a
|
|
197
|
+
// capability the change carries no delta for is one line, not one
|
|
198
|
+
// break per capability. Its exit code is
|
|
142
199
|
// discarded on purpose — the step is declared advisory, and an advisory
|
|
143
200
|
// step that could still move the close's result would be advisory in name
|
|
144
201
|
// only. A crash is caught by the loop and reported the same way.
|
|
145
202
|
review: {
|
|
146
203
|
run: async () => {
|
|
147
|
-
await review.run([], new Map());
|
|
204
|
+
await review.run([], new Map([["change", id]]));
|
|
148
205
|
return 0;
|
|
149
206
|
},
|
|
150
207
|
},
|
|
@@ -237,7 +294,10 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
237
294
|
// verify is conditional: skipped with a loud note when no verify.json is
|
|
238
295
|
// declared, because a project may not have wired the real gate yet.
|
|
239
296
|
verify: verifyConfigured
|
|
240
|
-
|
|
297
|
+
// --reuse (change 0203): the suite the agent just ran by hand, or the
|
|
298
|
+
// one the previous id of this batch ran, on byte-identical code, is
|
|
299
|
+
// not run a second time. Anything that differs runs everything.
|
|
300
|
+
? { run: () => verify.run([], new Map([["reuse", true]])) }
|
|
241
301
|
: { skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
|
|
242
302
|
|
|
243
303
|
coverage: {
|
|
@@ -270,7 +330,9 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
270
330
|
// one session with an empty `## [Unreleased]` behind them.
|
|
271
331
|
const log = checkChangelogImpact(projectRoot, changeDir);
|
|
272
332
|
if (log.ok) {
|
|
273
|
-
|
|
333
|
+
// One line when both answers are the same sentence (change 0202):
|
|
334
|
+
// "touches no documented surface" twice read as two checks.
|
|
335
|
+
if (log.reason !== r.reason) console.log(c.green("ok") + ` ${log.reason}`);
|
|
274
336
|
return 0;
|
|
275
337
|
}
|
|
276
338
|
console.error(c.red("error:") + ` this change ${log.reason}:`);
|
|
@@ -404,16 +466,6 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
404
466
|
}
|
|
405
467
|
}
|
|
406
468
|
|
|
407
|
-
// Capture the lesson while it is fresh (operator review §4.7): surface the
|
|
408
|
-
// fix-shaped lessons not yet written as skills, right where the operator
|
|
409
|
-
// just proved they finish what they start. Advisory — a listing, never a
|
|
410
|
-
// gate, and a suggest failure never un-closes the change.
|
|
411
|
-
console.log("");
|
|
412
|
-
console.log(c.gray("──── skills (advisory)"));
|
|
413
|
-
try {
|
|
414
|
-
await skill.run(["suggest"], new Map());
|
|
415
|
-
} catch { /* advisory only */ }
|
|
416
|
-
|
|
417
469
|
console.log("");
|
|
418
470
|
console.log(c.green(`✓ change ${id} closed`) + c.gray(closingClaim(ran, skippedSteps)));
|
|
419
471
|
console.log(c.gray("Next: ") + c.cyan("doctrina next"));
|
|
@@ -484,14 +536,7 @@ function reportDependentCoverage(projectRoot, touched) {
|
|
|
484
536
|
}
|
|
485
537
|
|
|
486
538
|
function touchedCapabilities(projectRoot, id) {
|
|
487
|
-
|
|
488
|
-
const caps = new Set();
|
|
489
|
-
for (const deltaPath of walk(specsDir)) {
|
|
490
|
-
if (!deltaPath.endsWith("delta.md")) continue;
|
|
491
|
-
const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
|
|
492
|
-
if (cap) caps.add(cap);
|
|
493
|
-
}
|
|
494
|
-
return [...caps].sort();
|
|
539
|
+
return deltaCapabilities(path.join(projectRoot, ".doctrina", "changes", id));
|
|
495
540
|
}
|
|
496
541
|
|
|
497
542
|
// The sequence is DECLARED once, in lib/gates.js, and rendered here. It used
|
|
@@ -522,7 +567,9 @@ first failure with the exact command to rerun:
|
|
|
522
567
|
|
|
523
568
|
${SEQUENCE}
|
|
524
569
|
|
|
525
|
-
then
|
|
570
|
+
then two advisory notes, once per run: the skill candidate a just-closed
|
|
571
|
+
fix-shaped change produced (the others are a count), and the other open
|
|
572
|
+
changes that look finished or are a week old.
|
|
526
573
|
|
|
527
574
|
The coverage gate is scoped to the capabilities the change's deltas touch,
|
|
528
575
|
so a deliberately deferred spec elsewhere cannot block an unrelated close;
|
package/src/commands/next.js
CHANGED
|
@@ -82,6 +82,7 @@ export const RUNNERS = {
|
|
|
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/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, fold, skillSlug } 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"];
|
|
@@ -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"));
|
|
@@ -361,80 +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 and hand the rest to the shared slug, which folds.
|
|
404
|
-
function commitSlug(subject) {
|
|
405
|
-
return skillSlug(subject.replace(/^[a-z]+(?:\([^)]*\))?!?\s*:\s*/i, ""));
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Token-overlap similarity between two kebab slugs (Jaccard over meaningful
|
|
409
|
-
// tokens). "0049-fix-tela-preta-render" vs "tela-preta-crash-de-render-sem-
|
|
410
|
-
// error-boundary" share tela/preta/render → high overlap even though the
|
|
411
|
-
// human renamed the skill after the lesson. Deterministic; never semantic.
|
|
412
|
-
function slugSimilarity(a, b) {
|
|
413
|
-
const toks = (s) => new Set(
|
|
414
|
-
String(s).split("-").filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !SLUG_NOISE.has(t)),
|
|
415
|
-
);
|
|
416
|
-
const A = toks(a);
|
|
417
|
-
const B = toks(b);
|
|
418
|
-
if (A.size === 0 || B.size === 0) return 0;
|
|
419
|
-
let inter = 0;
|
|
420
|
-
for (const t of A) if (B.has(t)) inter += 1;
|
|
421
|
-
return inter / (A.size + B.size - inter);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Fix-shaped verbs and connectives carry no lesson identity — without this,
|
|
425
|
-
// every "fix-..." candidate looks 30% similar to every "fix-..." skill.
|
|
426
|
-
const SLUG_NOISE = new Set([
|
|
427
|
-
"fix", "bug", "hotfix", "patch", "the", "and", "for", "with", "sem", "com",
|
|
428
|
-
"que", "nao", "por", "para", "una", "uma", "dos", "das",
|
|
429
|
-
]);
|
|
430
|
-
|
|
431
|
-
function firstWhyLine(proposalText) {
|
|
432
|
-
const m = proposalText.match(/##\s*Why\s*\r?\n+([^\r\n][^\r\n]*)/i);
|
|
433
|
-
if (!m) return "";
|
|
434
|
-
const line = m[1].trim();
|
|
435
|
-
return /^<!--/.test(line) ? "" : line;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
314
|
function skillNew(args, flags) {
|
|
439
315
|
const name = args[0];
|
|
440
316
|
const nameError = artifactNameError(name, "skill name");
|