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/src/commands/verify.js
CHANGED
|
@@ -8,9 +8,10 @@ import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
|
|
|
8
8
|
import { flagBool, flagString } from "../lib/args.js";
|
|
9
9
|
import { today } from "../lib/dates.js";
|
|
10
10
|
import { c } from "../lib/colors.js";
|
|
11
|
-
import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL } from "../lib/signoff.js";
|
|
11
|
+
import { loadSignoffs, saveSignoffs, recordSignoff, signoffState, short, SIGNOFF_REL, declaredPaths, workingChanges } from "../lib/signoff.js";
|
|
12
12
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
13
|
import { collectReproducibility } from "../lib/reproducibility.js";
|
|
14
|
+
import { verifyFingerprint, recordGreen, reusableGreen } from "../lib/verify-cache.js";
|
|
14
15
|
|
|
15
16
|
// The build/verify gate `validate` is not. `validate` checks the shape of
|
|
16
17
|
// the artifact tree; it never runs the project, so a repo that does not
|
|
@@ -40,7 +41,7 @@ const STARTER = {
|
|
|
40
41
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
41
42
|
// adding a command never requires editing the entrypoint — the gap that
|
|
42
43
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
43
|
-
export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict"], string: ["signoff"] };
|
|
44
|
+
export const flags = { boolean: ["json", "clean", "force", "init", "list", "strict", "reuse", "affected"], string: ["signoff"] };
|
|
44
45
|
|
|
45
46
|
export async function run(_positional, flags) {
|
|
46
47
|
const projectRoot = process.cwd();
|
|
@@ -182,6 +183,22 @@ export async function run(_positional, flags) {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
const strict = flagBool(flags, "strict", false);
|
|
186
|
+
const affected = flagBool(flags, "affected", false);
|
|
187
|
+
// The fingerprint of the code these checks are about to run on, taken
|
|
188
|
+
// BEFORE they run (change 0203): what a green run proves is the tree it
|
|
189
|
+
// started from. Null outside git — then nothing is remembered or reused.
|
|
190
|
+
const fingerprint = verifyFingerprint(projectRoot, read(configPath));
|
|
191
|
+
if (flagBool(flags, "reuse", false) && fingerprint !== null) {
|
|
192
|
+
const green = reusableGreen(projectRoot, fingerprint);
|
|
193
|
+
if (green) {
|
|
194
|
+
console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
|
|
195
|
+
console.log("");
|
|
196
|
+
console.log(c.green("ok") + ` reused the green run of ${green.at.replace("T", " ").slice(0, 16)} UTC — ` +
|
|
197
|
+
`the code and ${CONFIG_REL} are byte-identical to what it ran on (${green.checks.join(", ")})`);
|
|
198
|
+
console.log(c.gray(" `doctrina verify` runs the checks again"));
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
185
202
|
console.log(c.bold("doctrina verify") + c.gray(` — ${checks.length} check${checks.length === 1 ? "" : "s"} from ${CONFIG_REL}`));
|
|
186
203
|
const results = [];
|
|
187
204
|
for (const ch of checks) {
|
|
@@ -220,6 +237,20 @@ export async function run(_positional, flags) {
|
|
|
220
237
|
continue;
|
|
221
238
|
}
|
|
222
239
|
|
|
240
|
+
// --affected (change 0204): a check that declares the "paths" it covers
|
|
241
|
+
// runs only when the uncommitted work touches one of them. The inner
|
|
242
|
+
// loop of a change — not the close, which always runs every check. A
|
|
243
|
+
// check that declares no paths covers everything and always runs; a
|
|
244
|
+
// question git cannot answer runs the check.
|
|
245
|
+
if (affected && declaredPaths(ch).length > 0) {
|
|
246
|
+
const touched = workingChanges(projectRoot, declaredPaths(ch));
|
|
247
|
+
if (touched !== null && touched.length === 0) {
|
|
248
|
+
console.log(c.gray(`──── ${name}: `) + c.gray(`skip — the uncommitted work touches none of ${declaredPaths(ch).join(", ")}`));
|
|
249
|
+
results.push({ name, ok: true, kind: "command", skipped: true });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
223
254
|
const where = ch.cwd ? c.gray(` [${ch.cwd}]`) : "";
|
|
224
255
|
console.log(c.gray(`──── ${name}: `) + ch.run + where);
|
|
225
256
|
// Run through the shell so declared commands can use pipes, &&, env,
|
|
@@ -287,9 +318,18 @@ export async function run(_positional, flags) {
|
|
|
287
318
|
// manual check still awaiting sign-off. Pending manual checks are otherwise
|
|
288
319
|
// a non-blocking report.
|
|
289
320
|
const failGate = cmdFailed.length > 0 || (strict && pending.length > 0);
|
|
290
|
-
const
|
|
321
|
+
const skipped = results.filter((r) => r.skipped);
|
|
322
|
+
const pendNote = (pending.length > 0 ? c.gray(` (${pending.length} manual pending)`) : "") +
|
|
323
|
+
(skipped.length > 0 ? c.gray(` (${skipped.length} skipped by --affected: ${skipped.map((r) => r.name).join(", ")})`) : "");
|
|
291
324
|
if (!failGate) {
|
|
292
|
-
|
|
325
|
+
const ran = passed - skipped.length;
|
|
326
|
+
console.log(c.green("ok") + ` ${ran}/${results.length - skipped.length} checks passed${pendNote}`);
|
|
327
|
+
// Remembered only when EVERY check passed — a run green with a manual
|
|
328
|
+
// check still pending is a report, not something a close may stand on,
|
|
329
|
+
// and neither is a run --affected cut short.
|
|
330
|
+
if (fingerprint !== null && pending.length === 0 && skipped.length === 0) {
|
|
331
|
+
recordGreen(projectRoot, fingerprint, results.map((r) => r.name));
|
|
332
|
+
}
|
|
293
333
|
return 0;
|
|
294
334
|
}
|
|
295
335
|
const reasons = [];
|
|
@@ -403,7 +443,7 @@ function renderReproducibility(projectRoot) {
|
|
|
403
443
|
}
|
|
404
444
|
|
|
405
445
|
export const help = `
|
|
406
|
-
Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--signoff "<name>=<note>"] [--force]
|
|
446
|
+
Usage: doctrina verify [--list] [--init] [--clean] [--strict] [--reuse] [--affected] [--signoff "<name>=<note>"] [--force]
|
|
407
447
|
|
|
408
448
|
Run the project-declared build/verify checks from ${CONFIG_REL} in order,
|
|
409
449
|
streaming their output, and exit non-zero if any check fails. This is the
|
|
@@ -414,13 +454,18 @@ root, targets a sub-package in a monorepo; a "manual" check is the
|
|
|
414
454
|
qualitative gate — judged by a human/eval, recorded as a sign-off, not run):
|
|
415
455
|
{
|
|
416
456
|
"checks": [
|
|
417
|
-
{ "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api"
|
|
457
|
+
{ "name": "typecheck", "run": "tsc --noEmit", "cwd": "packages/api",
|
|
458
|
+
"paths": ["packages/api"] },
|
|
418
459
|
{ "name": "test", "run": "npm test" },
|
|
419
460
|
{ "name": "build", "run": "npm run build" },
|
|
420
461
|
{ "name": "chronicle", "type": "manual", "rubric": "is the chronicle enjoyable to read?" }
|
|
421
462
|
]
|
|
422
463
|
}
|
|
423
464
|
|
|
465
|
+
"paths" (git pathspecs, relative to the project root) says what a check
|
|
466
|
+
covers: --affected runs it only when the uncommitted work touches one, and a
|
|
467
|
+
manual check's sign-off expires when one changes.
|
|
468
|
+
|
|
424
469
|
A manual check passes when it has been signed off and is otherwise reported
|
|
425
470
|
as "pending" — non-blocking by default, failing only under --strict (so CI
|
|
426
471
|
can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
|
|
@@ -450,6 +495,14 @@ Flags:
|
|
|
450
495
|
prepare/prepack; a Prisma dep with no generate on install) so
|
|
451
496
|
"verify green" can't hide a clean checkout that won't build.
|
|
452
497
|
--strict Fail the gate when a manual check is still pending sign-off
|
|
498
|
+
--reuse Skip the checks when the last fully green run covered this exact
|
|
499
|
+
code: the working tree (tracked, untracked, edits) minus
|
|
500
|
+
.doctrina/, plus ${CONFIG_REL}, are byte-identical. \`close\` runs
|
|
501
|
+
verify this way; a plain \`doctrina verify\` always runs them.
|
|
502
|
+
--affected Run a check that declares "paths" only when the uncommitted work
|
|
503
|
+
(edits against HEAD, untracked files) touches one of them —
|
|
504
|
+
the inner loop of a change. Checks with no "paths" always run;
|
|
505
|
+
\`close\` always runs every check. Never remembered by --reuse.
|
|
453
506
|
--signoff "<name>=<note>"
|
|
454
507
|
Record today's sign-off for a manual check, then exit
|
|
455
508
|
--force With --init, overwrite an existing config
|
package/src/commands/work.js
CHANGED
|
@@ -9,9 +9,10 @@ import { c } from "../lib/colors.js";
|
|
|
9
9
|
import { assessBrief } from "../lib/clarity.js";
|
|
10
10
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
11
11
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
|
-
import { changeNew, reindexChange } from "../lib/change-ops.js";
|
|
12
|
+
import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
|
|
13
13
|
import { classify } from "../lib/triage-model.js";
|
|
14
14
|
import { printPlaybookTemplate } from "../lib/playbook.js";
|
|
15
|
+
import { specShape } from "../lib/spec-ops.js";
|
|
15
16
|
import { changedFiles } from "../lib/git.js";
|
|
16
17
|
import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
|
|
17
18
|
import { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
@@ -29,14 +30,36 @@ export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
|
29
30
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
30
31
|
// adding a command never requires editing the entrypoint — the gap that
|
|
31
32
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
32
|
-
export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
|
|
33
|
+
export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "consolidate", "id", "resume", "title"] };
|
|
33
34
|
|
|
34
35
|
export async function run(positional, flags) {
|
|
35
|
-
const prompt = positional.join(" ").trim();
|
|
36
36
|
const projectRoot = process.cwd();
|
|
37
37
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
38
38
|
throw notADoctrinaProject();
|
|
39
39
|
}
|
|
40
|
+
// --consolidate <cap> (change 0206): a spec only ever grew — every change
|
|
41
|
+
// appended, nothing took a requirement out — and a field review of 0.17
|
|
42
|
+
// asked for "a command to consolidate a spec from time to time". It is an
|
|
43
|
+
// ordinary change pinned to the capability, with its own playbook: the
|
|
44
|
+
// agent decides what is redundant or superseded (ADR 0005), and the ops
|
|
45
|
+
// take it out.
|
|
46
|
+
const consolidate = flags.has("consolidate") ? flagString(flags, "consolidate") : null;
|
|
47
|
+
if (consolidate !== null) {
|
|
48
|
+
if (!consolidate || /** @type {unknown} */ (consolidate) === true || !/^[a-z][a-z0-9-]*$/.test(consolidate)) {
|
|
49
|
+
console.error(c.red("error:") + " --consolidate needs the capability whose spec to consolidate");
|
|
50
|
+
return EXIT.USAGE;
|
|
51
|
+
}
|
|
52
|
+
if (!isFile(path.join(projectRoot, ".doctrina", "specs", consolidate, "spec.md"))) {
|
|
53
|
+
console.error(c.red("error:") + ` capability "${consolidate}" has no spec at .doctrina/specs/${consolidate}/spec.md`);
|
|
54
|
+
return EXIT.USAGE;
|
|
55
|
+
}
|
|
56
|
+
flags.set("capability", consolidate);
|
|
57
|
+
flags.set("force", true);
|
|
58
|
+
if (!flags.has("title")) flags.set("title", `consolidate ${consolidate}`);
|
|
59
|
+
}
|
|
60
|
+
const prompt = consolidate
|
|
61
|
+
? `Consolidate the ${consolidate} spec: merge requirements and criteria that say the same thing, drop what a later one superseded — no behaviour changes.`
|
|
62
|
+
: positional.join(" ").trim();
|
|
40
63
|
|
|
41
64
|
// --resume <id>: reprint the playbook for an existing open change rather than
|
|
42
65
|
// open a new one (review G1 — `work "continue"` used to slug the word and
|
|
@@ -205,6 +228,12 @@ export async function run(positional, flags) {
|
|
|
205
228
|
? null
|
|
206
229
|
: scaffoldDelta(projectRoot, { id, capability, guess, runnerUp: promptMatches[1] ?? null });
|
|
207
230
|
|
|
231
|
+
// A guessed delta names the capability as surely as a pinned one: the
|
|
232
|
+
// header says what the folder says (change 0207). It stayed empty for
|
|
233
|
+
// every unpinned `work`, so the proposal claimed no spec while its delta
|
|
234
|
+
// targeted one.
|
|
235
|
+
if (scaffolded) addAffectedSpec(projectRoot, id, scaffolded.capability);
|
|
236
|
+
|
|
208
237
|
// The proposal is written in two passes — `changeNew` scaffolds it, the
|
|
209
238
|
// lines above stamp the lane and the pinned specs — so its index entry is
|
|
210
239
|
// re-derived HERE, from the finished file. Indexing at the end of the first
|
|
@@ -223,7 +252,9 @@ export async function run(positional, flags) {
|
|
|
223
252
|
}
|
|
224
253
|
|
|
225
254
|
console.log("");
|
|
226
|
-
if (
|
|
255
|
+
if (consolidate) {
|
|
256
|
+
printConsolidatePlaybook(projectRoot, { id, capability: consolidate });
|
|
257
|
+
} else if (chore) {
|
|
227
258
|
printChorePlaybook(projectRoot, { id, prompt: effPrompt });
|
|
228
259
|
} else {
|
|
229
260
|
printPlaybook(projectRoot, {
|
|
@@ -440,6 +471,21 @@ export function slugify(text) {
|
|
|
440
471
|
// part that is not prose: computing the variable blocks. Each is PRE-RENDERED
|
|
441
472
|
// into a token, so `substitute` stays a plain string map and the template
|
|
442
473
|
// stays a document rather than a language with conditionals in it.
|
|
474
|
+
// The consolidation playbook: the spec's shape, measured, then the steps.
|
|
475
|
+
// Measuring is the CLI's part; deciding which requirement is redundant is
|
|
476
|
+
// the agent's (ADR 0005).
|
|
477
|
+
function printConsolidatePlaybook(projectRoot, { id, capability }) {
|
|
478
|
+
const text = read(path.join(projectRoot, ".doctrina", "specs", capability, "spec.md"));
|
|
479
|
+
const shape = specShape(text);
|
|
480
|
+
const sections = Object.entries(shape.sections).map(([k, n]) => `${k} ${n}`).join(" · ");
|
|
481
|
+
printPlaybookTemplate(projectRoot, "consolidate", {
|
|
482
|
+
CHANGE_ID: id,
|
|
483
|
+
CAPABILITY: capability,
|
|
484
|
+
SHAPE: `${shape.lines} lines · ${shape.requirements} requirements (${sections || "none"}) · ` +
|
|
485
|
+
`${shape.criteria} acceptance criteria`,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
443
489
|
function printChorePlaybook(projectRoot, { id, prompt }) {
|
|
444
490
|
printPlaybookTemplate(projectRoot, "chore", { CHANGE_ID: id, PROMPT: prompt });
|
|
445
491
|
}
|
|
@@ -543,6 +589,10 @@ Options:
|
|
|
543
589
|
--resume <id> Reprint the playbook for an open change; create nothing
|
|
544
590
|
--from-diff Backfill: scaffold from the working-tree changes (no
|
|
545
591
|
prompt needed) and print a code-first playbook (F8)
|
|
592
|
+
--consolidate <cap> Open a change that consolidates <cap>'s spec — merge
|
|
593
|
+
what says the same thing, remove what was superseded
|
|
594
|
+
(remove-requirement / remove-criterion) — with its own
|
|
595
|
+
playbook; no prompt needed, no behaviour changes
|
|
546
596
|
--chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
|
|
547
597
|
playbook that skips the spec-delta steps (F9)
|
|
548
598
|
--design Also scaffold design.md, for a change with non-trivial
|
package/src/lib/actions.js
CHANGED
|
@@ -199,9 +199,13 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
199
199
|
}
|
|
200
200
|
const status = listHeader(read(proposalPath), "Status") ?? "proposed";
|
|
201
201
|
if (status === "applied") {
|
|
202
|
+
// The close, not the archive (change 0201). 0.17 retired the
|
|
203
|
+
// hand-driven tail — `close` skips the apply it already did and
|
|
204
|
+
// still runs verify, coverage and the docs gate — but this line kept
|
|
205
|
+
// teaching `change archive`, the one path past every one of them.
|
|
202
206
|
actions.push(action({
|
|
203
207
|
id: "change-archive-pending",
|
|
204
|
-
command: "
|
|
208
|
+
command: "close",
|
|
205
209
|
args: [id],
|
|
206
210
|
gate: "archive",
|
|
207
211
|
runnable: true,
|
|
@@ -239,11 +243,11 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
239
243
|
} else {
|
|
240
244
|
actions.push(action({
|
|
241
245
|
id: "change-deltas-missing",
|
|
242
|
-
command: "
|
|
246
|
+
command: "close",
|
|
243
247
|
args: [id],
|
|
244
|
-
why: `add spec deltas under .doctrina/changes/${id}/specs/, or
|
|
248
|
+
why: `add spec deltas under .doctrina/changes/${id}/specs/, or close it as metadata-only`,
|
|
245
249
|
text: `add spec deltas under .doctrina/changes/${id}/specs/, ` +
|
|
246
|
-
`or
|
|
250
|
+
`or close it as metadata-only: doctrina close ${id}`,
|
|
247
251
|
}));
|
|
248
252
|
}
|
|
249
253
|
}
|
package/src/lib/change-ops.js
CHANGED
|
@@ -15,6 +15,10 @@ import { flagBool, flagString } from "./args.js";
|
|
|
15
15
|
import { c } from "./colors.js";
|
|
16
16
|
import { ensureDoctrinaProject, isChangeId, resolveWithinProject } from "./project.js";
|
|
17
17
|
import { changeEntry } from "./scan.js";
|
|
18
|
+
import { parseOp, insertOps, extractOps, applyOps } from "./spec-ops.js";
|
|
19
|
+
import { parseOperation } from "./doc-model.js";
|
|
20
|
+
import { substitute } from "./templates.js";
|
|
21
|
+
import { EXIT } from "./exit-codes.js";
|
|
18
22
|
export function changeNew(args, flags) {
|
|
19
23
|
const id = args[0];
|
|
20
24
|
const title = args.slice(1).join(" ").trim();
|
|
@@ -131,3 +135,91 @@ export function reindexChange(projectRoot, id, date = today()) {
|
|
|
131
135
|
idx.touch(index, date);
|
|
132
136
|
idx.save(projectRoot, index);
|
|
133
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Add op lines to a change's MODIFIED delta for one capability (change 0205).
|
|
141
|
+
*
|
|
142
|
+
* A field review of 0.17 asked for "a command to add a requirement and a
|
|
143
|
+
* criterion without writing the ops blocks by hand". The grammar is
|
|
144
|
+
* `apply`'s (ADR 0021): every line is parsed by the same parser, and the
|
|
145
|
+
* whole block — what was there plus what is added — is dry-run against the
|
|
146
|
+
* current spec before anything is written, so an op that would not apply is
|
|
147
|
+
* refused now rather than at the close. Creates the delta from the template
|
|
148
|
+
* when the change has none for the capability yet; a second `bump-version`
|
|
149
|
+
* replaces the first instead of bumping twice.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} projectRoot
|
|
152
|
+
* @param {string} id change id (already refused/resolved by the caller)
|
|
153
|
+
* @param {string} capability
|
|
154
|
+
* @param {string[]} lines op lines, one operation each
|
|
155
|
+
* @returns {{ code: number, error?: string, hint?: string, details?: string[],
|
|
156
|
+
* deltaRel?: string, created?: boolean, block?: string[] }}
|
|
157
|
+
*/
|
|
158
|
+
export function addDeltaOps(projectRoot, id, capability, lines) {
|
|
159
|
+
if (lines.length === 0) {
|
|
160
|
+
return { code: EXIT.USAGE, error: "nothing to add",
|
|
161
|
+
hint: "pass --requirement \"<section>: <text>\", --criterion \"<text>\", --bump <level> or an op line" };
|
|
162
|
+
}
|
|
163
|
+
const bad = lines.map((l) => ({ l, op: parseOp(l) })).filter((x) => x.op.error);
|
|
164
|
+
if (bad.length > 0) {
|
|
165
|
+
return { code: EXIT.USAGE, error: "not an operation `apply` can read", details: bad.map((x) => x.op.error) };
|
|
166
|
+
}
|
|
167
|
+
const specPath = path.join(projectRoot, ".doctrina", "specs", capability, "spec.md");
|
|
168
|
+
if (!isFile(specPath)) {
|
|
169
|
+
return { code: EXIT.USAGE, error: `capability "${capability}" has no spec at ${relPath(projectRoot, specPath)}`,
|
|
170
|
+
hint: `a NEW capability is an ADDED delta with the full body — \`doctrina spec new ${capability}\`, then write it` };
|
|
171
|
+
}
|
|
172
|
+
const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", capability, "delta.md");
|
|
173
|
+
const created = !isFile(deltaPath);
|
|
174
|
+
let text;
|
|
175
|
+
if (created) {
|
|
176
|
+
const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
|
|
177
|
+
text = substitute(tpl, { CAPABILITY: capability }).replace(/^\*\*Operation:\*\*.*$/m, "**Operation:** MODIFIED");
|
|
178
|
+
} else {
|
|
179
|
+
text = read(deltaPath);
|
|
180
|
+
const op = parseOperation(text);
|
|
181
|
+
if (op && op !== "MODIFIED") {
|
|
182
|
+
return { code: EXIT.PRECONDITION, error: `${relPath(projectRoot, deltaPath)} is an ${op} delta — ops apply only to a MODIFIED one`,
|
|
183
|
+
hint: op === "ADDED" ? "write the requirement into the ADDED body itself" : "a REMOVED delta deletes the spec; there is nothing to add to" };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const next = insertOps(text, lines);
|
|
187
|
+
const dry = applyOps(read(specPath), extractOps(next));
|
|
188
|
+
if (dry.errors.length > 0) {
|
|
189
|
+
return { code: EXIT.GATE, error: `the ops block would not apply to ${relPath(projectRoot, specPath)} — nothing written`, details: dry.errors };
|
|
190
|
+
}
|
|
191
|
+
mkdirp(path.dirname(deltaPath));
|
|
192
|
+
write(deltaPath, next, { force: true });
|
|
193
|
+
addAffectedSpec(projectRoot, id, capability);
|
|
194
|
+
return { code: EXIT.OK, deltaRel: relPath(projectRoot, deltaPath), created, block: extractOps(next).map((o) => o.verb) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Name `capability` in the proposal's `Affects specs:` header when the
|
|
199
|
+
* header does not already (change 0207). A change whose delta targets a
|
|
200
|
+
* capability affects that spec, and a header left empty while the delta
|
|
201
|
+
* folder said otherwise was the one fact `prime`, `status` and the review
|
|
202
|
+
* of the proposal disagreed on. A chore's "(none — chore)" is left alone:
|
|
203
|
+
* that is a declaration, and the structure gate is where a chore carrying a
|
|
204
|
+
* delta gets questioned.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} projectRoot
|
|
207
|
+
* @param {string} id
|
|
208
|
+
* @param {string} capability
|
|
209
|
+
* @returns {boolean} whether the header changed
|
|
210
|
+
*/
|
|
211
|
+
export function addAffectedSpec(projectRoot, id, capability) {
|
|
212
|
+
const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
|
|
213
|
+
if (!isFile(proposalPath)) return false;
|
|
214
|
+
const text = read(proposalPath);
|
|
215
|
+
const m = text.match(/^(-[ \t]+\*\*Affects specs:\*\*)([^\r\n]*)/m);
|
|
216
|
+
if (!m) return false;
|
|
217
|
+
const current = m[2].trim();
|
|
218
|
+
if (/^\(none/i.test(current)) return false;
|
|
219
|
+
const named = current.split(",").map((s) => s.trim().replace(/`/g, "")).filter(Boolean);
|
|
220
|
+
if (named.includes(capability)) return false;
|
|
221
|
+
const value = [...named, capability].join(", ");
|
|
222
|
+
write(proposalPath, text.replace(m[0], `${m[1]} ${value}`), { force: true });
|
|
223
|
+
reindexChange(projectRoot, id);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
package/src/lib/commands.js
CHANGED
|
@@ -47,6 +47,7 @@ export const OPERATIONS = [
|
|
|
47
47
|
["change check", "Pre-close dry-run: everything close would refuse, listed first"],
|
|
48
48
|
["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
|
|
49
49
|
["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
|
|
50
|
+
["change delta", "Add a requirement/criterion/bump to a change's delta (ops written for you)"],
|
|
50
51
|
["contract new", "Own the integration surface (ports, env, interfaces)"],
|
|
51
52
|
["contract list", "List contracts with status and last-updated date"],
|
|
52
53
|
["contract check", "Verify port collisions, env drift, referenced specs"],
|
|
@@ -516,6 +517,13 @@ export const AGENT_CHANGELOG = {
|
|
|
516
517
|
// Each entry lists only ITS OWN delta. The series carries the rest
|
|
517
518
|
// forward (see agentChangelogEntries), so a patch never has to restate
|
|
518
519
|
// the minor that introduced the commands, and never silently erases it.
|
|
520
|
+
"0.18.0": [
|
|
521
|
+
"`doctrina change delta <id> <capability> --requirement \"<section>: <text>\" --criterion \"<text>\" --bump minor` writes the ops lines for you and dry-runs them against the spec — stop hand-writing ops blocks.",
|
|
522
|
+
"`close` runs `verify --reuse`: running `doctrina verify` yourself right before `doctrina close` is no longer paid twice, and a batch close runs the suite once. Use `verify --affected` while working when checks declare `\"paths\"`.",
|
|
523
|
+
"The review inside `close` counts the change's own deltas as spec updates and reports code under other capabilities in ONE line — read it: either this change forgot a delta or other work is lying in the tree.",
|
|
524
|
+
"`close` ends by listing other open changes that look finished or are a week old; close them with the `doctrina close <id>` it prints. `next` no longer suggests `change archive`.",
|
|
525
|
+
"A spec can shrink: `remove-requirement <section> <n>` / `remove-criterion <n>`, and `doctrina work --consolidate <cap>` when a spec has grown past what it needs to say.",
|
|
526
|
+
],
|
|
519
527
|
"0.17.0": [
|
|
520
528
|
"Preview a close with `doctrina change check <id>` (it exits 1 while a Verification box is open), then run `doctrina close <id>` — do not run `analyze`, `change apply` or `change archive` by hand; a chore closes the same way.",
|
|
521
529
|
"Deprecated names still run with a notice — use the replacement: `analyze` → `change check`, `report` → `status --view report`, `skill sync` → `index rebuild`, `templates check|update` → `upgrade [--write]`. `constitution` and `change diff` are removed (exit 2).",
|
package/src/lib/git.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { copyFileSync, existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
3
8
|
|
|
4
9
|
// One place that knows how to ask git a question and how to interpret not
|
|
5
10
|
// getting an answer (audit item C8).
|
|
@@ -32,8 +37,13 @@ export const GIT_STATE = Object.freeze({
|
|
|
32
37
|
|
|
33
38
|
// Run a git command. Never throws. Returns
|
|
34
39
|
// { state, stdout, lines, stderr, code }.
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} cwd
|
|
42
|
+
* @param {string[]} args
|
|
43
|
+
* @param {{ env?: Record<string, string> }} [opts]
|
|
44
|
+
*/
|
|
45
|
+
export function git(cwd, args, { env } = {}) {
|
|
46
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
37
47
|
if (r.error) {
|
|
38
48
|
// ENOENT here means the binary is missing, not that the command failed.
|
|
39
49
|
return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
|
|
@@ -227,3 +237,50 @@ export function lastTagDate(cwd) {
|
|
|
227
237
|
const date = tags[0] ?? "";
|
|
228
238
|
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
|
|
229
239
|
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A content fingerprint of the working tree under `cwd` — tracked AND
|
|
243
|
+
* untracked files, .gitignore respected, uncommitted edits included — with
|
|
244
|
+
* the top-level entries named in `exclude` left out (change 0203). Null when
|
|
245
|
+
* git cannot answer.
|
|
246
|
+
*
|
|
247
|
+
* Built from git's own object model rather than by reading files: the real
|
|
248
|
+
* index is copied to a scratch one, `git add -A` stages the whole tree into
|
|
249
|
+
* the copy (reusing the stat cache, so only changed files are hashed), and
|
|
250
|
+
* `git write-tree` names the result. The real index, HEAD and the working
|
|
251
|
+
* tree are never touched; the only side effect is loose objects for changed
|
|
252
|
+
* files, which `git gc` reclaims.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} cwd
|
|
255
|
+
* @param {{ exclude?: string[] }} [opts]
|
|
256
|
+
* @returns {string | null}
|
|
257
|
+
*/
|
|
258
|
+
export function worktreeFingerprint(cwd, { exclude = [] } = {}) {
|
|
259
|
+
const top = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
260
|
+
const dir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
|
|
261
|
+
if (top.state !== GIT_STATE.OK || dir.state !== GIT_STATE.OK) return null;
|
|
262
|
+
const root = top.stdout.trim();
|
|
263
|
+
const scratch = path.join(os.tmpdir(), `doctrina-fingerprint-${process.pid}-${Date.now()}`);
|
|
264
|
+
try {
|
|
265
|
+
const real = path.join(dir.stdout.trim(), "index");
|
|
266
|
+
if (existsSync(real)) copyFileSync(real, scratch);
|
|
267
|
+
const env = { GIT_INDEX_FILE: scratch };
|
|
268
|
+
if (git(root, ["add", "-A", "--", "."], { env }).state !== GIT_STATE.OK) return null;
|
|
269
|
+
const tree = git(root, ["write-tree"], { env });
|
|
270
|
+
if (tree.state !== GIT_STATE.OK) return null;
|
|
271
|
+
// Asked of git, never computed with path.relative: on macOS the tmpdir
|
|
272
|
+
// is `/var/…`, a symlink to `/private/var/…`, and git answers with the
|
|
273
|
+
// resolved toplevel — the relative path between the two spellings is
|
|
274
|
+
// `../..`, which names nothing in the tree (the 0.18 macOS CI run).
|
|
275
|
+
const prefixed = git(cwd, ["rev-parse", "--show-prefix"]);
|
|
276
|
+
if (prefixed.state !== GIT_STATE.OK) return null;
|
|
277
|
+
const prefix = prefixed.stdout.trim().replace(/\/$/, "");
|
|
278
|
+
const listing = git(root, ["ls-tree", prefix ? `${tree.stdout.trim()}:${prefix}` : tree.stdout.trim()]);
|
|
279
|
+
if (listing.state !== GIT_STATE.OK) return null;
|
|
280
|
+
const skip = new Set(exclude);
|
|
281
|
+
const kept = listing.lines.filter((l) => !skip.has(l.split("\t")[1]));
|
|
282
|
+
return createHash("sha256").update(kept.join("\n")).digest("hex");
|
|
283
|
+
} finally {
|
|
284
|
+
rmSync(scratch, { force: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
package/src/lib/playbook.js
CHANGED
|
@@ -94,4 +94,4 @@ export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/** The playbooks the CLI ships and `templates check` expects to resolve. */
|
|
97
|
-
export const PLAYBOOKS = ["work", "chore", "bootstrap"];
|
|
97
|
+
export const PLAYBOOKS = ["work", "chore", "bootstrap", "consolidate"];
|
package/src/lib/runtime.js
CHANGED
|
@@ -631,6 +631,12 @@ export function expandBraces(glob) {
|
|
|
631
631
|
// `{a,b}` alternates. Enough for the "where do my selectors live" and "which
|
|
632
632
|
// code is mine" declarations, with no dependency and no surprises.
|
|
633
633
|
export function globToRegExp(glob) {
|
|
634
|
+
// A pattern ending in `/` names a DIRECTORY, and a directory claims what
|
|
635
|
+
// is under it (change 0199). Compiled as written it matched only the
|
|
636
|
+
// literal path `src/health/` — which no file has — so the most natural way
|
|
637
|
+
// to declare a module's ownership claimed nothing, and `review` reported
|
|
638
|
+
// every file in it as ownerless.
|
|
639
|
+
if (/\/$/.test(String(glob))) glob = `${glob}**`;
|
|
634
640
|
// Always compile the EXPANSION, never the original. Using it only when it
|
|
635
641
|
// produced more than one alternative left `src/{a}.js` compiling with its
|
|
636
642
|
// braces as literal characters, so a one-element group matched nothing
|
package/src/lib/scan.js
CHANGED
|
@@ -599,3 +599,16 @@ function artifactPathOf(file) {
|
|
|
599
599
|
if (m) return m[1];
|
|
600
600
|
return null;
|
|
601
601
|
}
|
|
602
|
+
|
|
603
|
+
// The capabilities a change's deltas target, sorted — the change's own scope.
|
|
604
|
+
// `close` gates coverage on it and `review --change` reads it as "these specs
|
|
605
|
+
// are being updated by this change", because the delta merges at apply.
|
|
606
|
+
export function deltaCapabilities(changeDir) {
|
|
607
|
+
const caps = new Set();
|
|
608
|
+
for (const deltaPath of walk(path.join(changeDir, "specs"))) {
|
|
609
|
+
if (!deltaPath.endsWith("delta.md")) continue;
|
|
610
|
+
const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
|
|
611
|
+
if (cap) caps.add(cap);
|
|
612
|
+
}
|
|
613
|
+
return [...caps].sort();
|
|
614
|
+
}
|
package/src/lib/signoff.js
CHANGED
|
@@ -96,6 +96,30 @@ export function changedSince(projectRoot, sha, paths) {
|
|
|
96
96
|
return [...out].sort();
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Which of `paths` the uncommitted work touches — edits against HEAD plus
|
|
101
|
+
* untracked files, `.gitignore` respected (change 0204). The same pathspec
|
|
102
|
+
* reading a sign-off's "paths" gets, so "what this check covers" means one
|
|
103
|
+
* thing in verify.json. Null when git cannot answer: a caller then runs the
|
|
104
|
+
* check rather than reading "nothing changed" out of a question it never
|
|
105
|
+
* got to ask.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} projectRoot
|
|
108
|
+
* @param {string[]} paths
|
|
109
|
+
* @returns {string[] | null}
|
|
110
|
+
*/
|
|
111
|
+
export function workingChanges(projectRoot, paths) {
|
|
112
|
+
const tracked = git(projectRoot, ["diff", "--name-only", "HEAD", "--", ...paths]);
|
|
113
|
+
// No commit yet: every file is new, so what is staged counts as changed.
|
|
114
|
+
const staged = tracked.state === GIT_STATE.EMPTY
|
|
115
|
+
? git(projectRoot, ["ls-files", "--cached", "--", ...paths])
|
|
116
|
+
: tracked;
|
|
117
|
+
if (staged.state !== GIT_STATE.OK) return null;
|
|
118
|
+
const untracked = git(projectRoot, ["ls-files", "--others", "--exclude-standard", "--", ...paths]);
|
|
119
|
+
if (untracked.state !== GIT_STATE.OK) return null;
|
|
120
|
+
return [...new Set([...staged.lines, ...untracked.lines].filter(Boolean))].sort();
|
|
121
|
+
}
|
|
122
|
+
|
|
99
123
|
/** The commit a signature is anchored to, or null outside a repository. */
|
|
100
124
|
export function currentSha(projectRoot) {
|
|
101
125
|
const r = git(projectRoot, ["rev-parse", "HEAD"]);
|