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/close.js
CHANGED
|
@@ -2,26 +2,28 @@
|
|
|
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
|
-
import { docsRemedy, checkDocsImpact } from "../lib/docs-impact.js";
|
|
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";
|
|
14
|
-
import { sequence, stepRerun } from "../lib/gates.js";
|
|
12
|
+
import { specHeader, dependentsOf, deltaCapabilities } from "../lib/scan.js";
|
|
13
|
+
import { sequence, sequenceLabels, stepRerun } from "../lib/gates.js";
|
|
15
14
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
15
|
+
import { refuseChangeRef } from "../lib/project.js";
|
|
16
|
+
import { unfinishedChanges } from "../lib/snapshot.js";
|
|
16
17
|
import * as analyze from "./analyze.js";
|
|
17
18
|
import * as change from "./change.js";
|
|
18
19
|
import * as verify from "./verify.js";
|
|
19
20
|
import * as coverage from "./coverage.js";
|
|
20
21
|
import * as trace from "./trace.js";
|
|
21
22
|
import * as validate from "./validate.js";
|
|
23
|
+
import * as indexRebuild from "./index-rebuild.js";
|
|
22
24
|
import { appendLedgerLine, docsGapLine, ledgerPath as ledgerFile } from "../lib/ledger.js";
|
|
23
25
|
import * as review from "./review.js";
|
|
24
|
-
import
|
|
26
|
+
import { skillCandidates } from "../lib/skill-candidates.js";
|
|
25
27
|
|
|
26
28
|
// One-command close (review 2026-06-27 passive-user feature #2). The work
|
|
27
29
|
// playbook lists the closing sequence — analyze → apply → runtime → verify →
|
|
@@ -63,10 +65,65 @@ export async function run(positional, flags) {
|
|
|
63
65
|
? c.green("ok") + ` all ${positional.length} changes closed`
|
|
64
66
|
: c.red("fail") + " at least one close stopped — see above");
|
|
65
67
|
}
|
|
68
|
+
reportSkills(projectRoot, positional);
|
|
69
|
+
reportUnfinished(projectRoot, positional);
|
|
66
70
|
return worst;
|
|
67
71
|
}
|
|
68
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
|
+
|
|
69
124
|
async function closeOne(projectRoot, id, flags) {
|
|
125
|
+
const refused = refuseChangeRef(id);
|
|
126
|
+
if (refused !== null) return refused;
|
|
70
127
|
// A change id that does not resolve is the USAGE class, before any step
|
|
71
128
|
// is sequenced (change 0114) — every sibling that takes a change id
|
|
72
129
|
// answers 2; `close` answered 1 through the analyze step it ran first.
|
|
@@ -134,13 +191,17 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
134
191
|
},
|
|
135
192
|
|
|
136
193
|
// The review runs against the working tree, which for a close IS the
|
|
137
|
-
// 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
|
|
138
199
|
// discarded on purpose — the step is declared advisory, and an advisory
|
|
139
200
|
// step that could still move the close's result would be advisory in name
|
|
140
201
|
// only. A crash is caught by the loop and reported the same way.
|
|
141
202
|
review: {
|
|
142
203
|
run: async () => {
|
|
143
|
-
await review.run([], new Map());
|
|
204
|
+
await review.run([], new Map([["change", id]]));
|
|
144
205
|
return 0;
|
|
145
206
|
},
|
|
146
207
|
},
|
|
@@ -233,7 +294,10 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
233
294
|
// verify is conditional: skipped with a loud note when no verify.json is
|
|
234
295
|
// declared, because a project may not have wired the real gate yet.
|
|
235
296
|
verify: verifyConfigured
|
|
236
|
-
|
|
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]])) }
|
|
237
301
|
: { skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
|
|
238
302
|
|
|
239
303
|
coverage: {
|
|
@@ -257,10 +321,26 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
257
321
|
// escape hatch archive offers, and records the gap in the ledger.
|
|
258
322
|
docs: {
|
|
259
323
|
run: async () => {
|
|
260
|
-
const
|
|
324
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
325
|
+
const r = checkDocsImpact(projectRoot, changeDir);
|
|
261
326
|
if (r.ok) {
|
|
262
327
|
console.log(c.green("ok") + ` ${r.reason}`);
|
|
263
|
-
|
|
328
|
+
// Documentation says how it works; the changelog says that it
|
|
329
|
+
// changed. Asking only the first is how thirteen changes closed in
|
|
330
|
+
// one session with an empty `## [Unreleased]` behind them.
|
|
331
|
+
const log = checkChangelogImpact(projectRoot, changeDir);
|
|
332
|
+
if (log.ok) {
|
|
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}`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
console.error(c.red("error:") + ` this change ${log.reason}:`);
|
|
339
|
+
for (const s of log.signals) console.error(` - ${s}`);
|
|
340
|
+
console.error(c.gray("hint: ") + "add an entry under the changelog's unreleased heading" +
|
|
341
|
+
", or pass --force to close anyway (records the gap)");
|
|
342
|
+
docsGap = log;
|
|
343
|
+
return 1;
|
|
264
344
|
}
|
|
265
345
|
console.error(c.red("error:") + ` this change ${r.reason}:`);
|
|
266
346
|
for (const s of r.signals) console.error(` - ${s}`);
|
|
@@ -268,8 +348,21 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
268
348
|
// Doctrina's own repository (change 0058): an adopting project with
|
|
269
349
|
// no `docs/` was being told to write English AND Portuguese and to
|
|
270
350
|
// read a skill it does not have.
|
|
271
|
-
console.error(c.gray("hint: ") + docsRemedy(projectRoot) +
|
|
272
|
-
|
|
351
|
+
console.error(c.gray("hint: ") + docsRemedy(projectRoot) + ".");
|
|
352
|
+
// THE SECOND ANSWER, NAMED WHERE IT IS NEEDED.
|
|
353
|
+
//
|
|
354
|
+
// The gate reads names, and a name is all it can see: a change that
|
|
355
|
+
// explains an effect, or a Scope boundaries line saying what it does
|
|
356
|
+
// NOT touch, reads exactly like one that alters the command. When the
|
|
357
|
+
// only remedy on offer was `--force`, an author looking at a false
|
|
358
|
+
// positive took the worst of the two exits — the ledger fills with
|
|
359
|
+
// gaps that were never gaps, and a real one stops standing out.
|
|
360
|
+
//
|
|
361
|
+
// The declaration exists for precisely this (change 0139) and was
|
|
362
|
+
// named nowhere the author would be looking.
|
|
363
|
+
console.error(c.gray(" ") + "if the names above are only MENTIONED, say so on the record: " +
|
|
364
|
+
"add `- **Documented surface:** n/a — <why>` to the proposal.");
|
|
365
|
+
console.error(c.gray(" ") + "or pass --force to close anyway (records the gap).");
|
|
273
366
|
docsGap = r;
|
|
274
367
|
return 1;
|
|
275
368
|
},
|
|
@@ -280,6 +373,10 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
280
373
|
run: () => change.run(["archive", id], archiveFlags),
|
|
281
374
|
},
|
|
282
375
|
|
|
376
|
+
// In-process, like every other runner here: the close starts no subprocess
|
|
377
|
+
// of its own binary, and a criterion holds it to that.
|
|
378
|
+
"index-drift": { run: () => indexRebuild.run(["rebuild"], new Map([["check", true]])) },
|
|
379
|
+
|
|
283
380
|
validate: { run: () => validate.run([], new Map()) },
|
|
284
381
|
};
|
|
285
382
|
|
|
@@ -369,16 +466,6 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
369
466
|
}
|
|
370
467
|
}
|
|
371
468
|
|
|
372
|
-
// Capture the lesson while it is fresh (operator review §4.7): surface the
|
|
373
|
-
// fix-shaped lessons not yet written as skills, right where the operator
|
|
374
|
-
// just proved they finish what they start. Advisory — a listing, never a
|
|
375
|
-
// gate, and a suggest failure never un-closes the change.
|
|
376
|
-
console.log("");
|
|
377
|
-
console.log(c.gray("──── skills (advisory)"));
|
|
378
|
-
try {
|
|
379
|
-
await skill.run(["suggest"], new Map());
|
|
380
|
-
} catch { /* advisory only */ }
|
|
381
|
-
|
|
382
469
|
console.log("");
|
|
383
470
|
console.log(c.green(`✓ change ${id} closed`) + c.gray(closingClaim(ran, skippedSteps)));
|
|
384
471
|
console.log(c.gray("Next: ") + c.cyan("doctrina next"));
|
|
@@ -449,14 +536,27 @@ function reportDependentCoverage(projectRoot, touched) {
|
|
|
449
536
|
}
|
|
450
537
|
|
|
451
538
|
function touchedCapabilities(projectRoot, id) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
539
|
+
return deltaCapabilities(path.join(projectRoot, ".doctrina", "changes", id));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// The sequence is DECLARED once, in lib/gates.js, and rendered here. It used
|
|
543
|
+
// to be typed out in this string, which is how the help came to name ten
|
|
544
|
+
// steps of thirteen — review, implementation, docs and the index-drift check
|
|
545
|
+
// were added over time and the paragraph was not.
|
|
546
|
+
const SEQUENCE = wrapArrows(sequenceLabels("close"), 70, " ");
|
|
547
|
+
|
|
548
|
+
function wrapArrows(labels, width, indent) {
|
|
549
|
+
const lines = [];
|
|
550
|
+
let line = "";
|
|
551
|
+
for (const label of labels) {
|
|
552
|
+
const piece = line === "" ? label : `${line} → ${label}`;
|
|
553
|
+
if (piece.length + indent.length > width && line !== "") {
|
|
554
|
+
lines.push(indent + line + " →");
|
|
555
|
+
line = label;
|
|
556
|
+
} else line = piece;
|
|
458
557
|
}
|
|
459
|
-
|
|
558
|
+
if (line !== "") lines.push(indent + line);
|
|
559
|
+
return lines.join("\n");
|
|
460
560
|
}
|
|
461
561
|
|
|
462
562
|
export const help = `
|
|
@@ -465,9 +565,11 @@ Usage: doctrina close <id...> [--force]
|
|
|
465
565
|
Run the whole closing sequence for a change in one pass, stopping at the
|
|
466
566
|
first failure with the exact command to rerun:
|
|
467
567
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
568
|
+
${SEQUENCE}
|
|
569
|
+
|
|
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.
|
|
471
573
|
|
|
472
574
|
The coverage gate is scoped to the capabilities the change's deltas touch,
|
|
473
575
|
so a deliberately deferred spec elsewhere cannot block an unrelated close;
|
|
@@ -481,7 +583,7 @@ trace, the ADR checkpoint
|
|
|
481
583
|
(accepted ADRs citing the touched capabilities — amend via \`decision
|
|
482
584
|
supersede\`, not silence), and the closing skill-suggest listing are
|
|
483
585
|
advisory (reports, never blockers). This is a driver over the existing
|
|
484
|
-
commands so the agent makes one call instead of
|
|
586
|
+
commands so the agent makes one call instead of thirteen and the human
|
|
485
587
|
approves once.
|
|
486
588
|
|
|
487
589
|
Multiple ids close in sequence, each independently; the exit code is the
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import {
|
|
2
|
+
import { DEPRECATED, OPERATIONS } from "../lib/commands.js";
|
|
3
3
|
import { c } from "../lib/colors.js";
|
|
4
4
|
|
|
5
5
|
// Shell completions, generated from the same OPERATIONS catalog that feeds
|
|
6
6
|
// `--help` and the docs drift tests — so the completion script can never
|
|
7
7
|
// know a different surface than the CLI ships. Static output by design:
|
|
8
8
|
// paste it into your shell config once; regenerate after upgrading.
|
|
9
|
+
//
|
|
10
|
+
// Deprecated operations are left out (change 0188): they still run, with a
|
|
11
|
+
// notice, for a script that already types them — but tab completion is how
|
|
12
|
+
// a person discovers the surface, and it should offer the replacement only.
|
|
13
|
+
const LIVE = OPERATIONS.filter(([op]) => !DEPRECATED[op]);
|
|
14
|
+
const COMMAND_NAMES = [...new Set(LIVE.map(([op]) => op.split(" ")[0]))];
|
|
9
15
|
|
|
10
16
|
function subcommandMap() {
|
|
11
17
|
const map = new Map();
|
|
12
|
-
for (const [op] of
|
|
18
|
+
for (const [op] of LIVE) {
|
|
13
19
|
const [cmd, sub] = op.split(" ");
|
|
14
20
|
if (!sub) continue;
|
|
15
21
|
if (!map.has(cmd)) map.set(cmd, []);
|
package/src/commands/context.js
CHANGED
|
@@ -7,7 +7,7 @@ import { listHeader, parseDependsOn, parseAdrScope, adrSummary, knownCapabilitie
|
|
|
7
7
|
import { suggest } from "../lib/suggest.js";
|
|
8
8
|
import { checklistProgress, getTitle, getSectionParagraph } from "../lib/doc-model.js";
|
|
9
9
|
import { parseFrontmatter } from "../lib/doc-model.js";
|
|
10
|
-
import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
10
|
+
import { flagBool, flagString, flagGivenWithoutValue, parsePositiveInt } from "../lib/args.js";
|
|
11
11
|
import { c } from "../lib/colors.js";
|
|
12
12
|
import { loadConfig, DEFAULTS } from "../lib/config.js";
|
|
13
13
|
import { GIT_STATE, historyState, changedFiles } from "../lib/git.js";
|
|
@@ -466,16 +466,18 @@ function capabilitiesInPlay(projectRoot) {
|
|
|
466
466
|
return [...caps];
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
+
// Line endings are not content: a Windows checkout stores every line with a
|
|
470
|
+
// CR, which added ~2% to the same pack, so the budget gate — run on every OS
|
|
471
|
+
// in CI — could pass on Linux and fail on Windows for the same tree.
|
|
469
472
|
function estimateTokens(text) {
|
|
470
|
-
return Math.round(text.length / TOKEN_DIVISOR);
|
|
473
|
+
return Math.round(text.replace(/\r\n/g, "\n").length / TOKEN_DIVISOR);
|
|
471
474
|
}
|
|
472
475
|
|
|
473
476
|
// Flag beats project config beats the built-in default. Returns null for a
|
|
474
477
|
// malformed --budget so the caller can report a usage error.
|
|
475
478
|
function resolveBudget(projectRoot, budgetRaw) {
|
|
476
479
|
if (budgetRaw !== undefined) {
|
|
477
|
-
|
|
478
|
-
return Number.isFinite(n) && n > 0 ? n : null;
|
|
480
|
+
return parsePositiveInt(budgetRaw);
|
|
479
481
|
}
|
|
480
482
|
// One configuration reader (lib/config.js, change 0047): `.doctrina/
|
|
481
483
|
// config.json` is the declared home and index.json's `config` block is
|
package/src/commands/decision.js
CHANGED
|
@@ -152,8 +152,20 @@ function decisionSupersede(args) {
|
|
|
152
152
|
});
|
|
153
153
|
// Inject Supersedes link
|
|
154
154
|
body = body.replace(/(\*\*Supersedes:\*\*)\s+—/, `$1 ${padded}`);
|
|
155
|
+
// Carry the predecessor's Scope. A successor built from the bare template
|
|
156
|
+
// had none, so it was GLOBAL: refining a decision that governed one
|
|
157
|
+
// capability, then accepting the refinement, loaded it into every
|
|
158
|
+
// capability's pack — ones the original never governed — and nothing said
|
|
159
|
+
// so. A replacement decides the same subject; its scope starts where the
|
|
160
|
+
// predecessor's was, written into the file where the author can widen it
|
|
161
|
+
// on purpose (ADR 0022: a pack is retrieval, not a dump).
|
|
162
|
+
const inheritedScope = parseAdrScope(oldText);
|
|
163
|
+
if (inheritedScope.length > 0) body = insertScopeHeader(body, inheritedScope) ?? body;
|
|
155
164
|
write(newPath, body, { force: false });
|
|
156
165
|
console.log(c.green("created") + ` ${relPath(projectRoot, newPath)}`);
|
|
166
|
+
if (inheritedScope.length > 0) {
|
|
167
|
+
console.log(c.gray("scope: ") + `inherited from ${padded} — ${inheritedScope.join(", ")} (edit the Scope: header to change it)`);
|
|
168
|
+
}
|
|
157
169
|
|
|
158
170
|
// Mutate ONLY the Status: and Superseded by: headers of the old ADR.
|
|
159
171
|
// Both go through the document model, so an ADR written with a slightly
|
package/src/commands/doctor.js
CHANGED
|
@@ -188,7 +188,7 @@ export async function run(_positional, _flags) {
|
|
|
188
188
|
? tmplFindings[0].message
|
|
189
189
|
: `${tmplFindings.length} template findings`;
|
|
190
190
|
const remedy = [...byRemedy.keys()].filter((k) => k !== "(manual repair)");
|
|
191
|
-
row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina
|
|
191
|
+
row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina upgrade (each finding names its own fix)");
|
|
192
192
|
warningsTotal += 1;
|
|
193
193
|
},
|
|
194
194
|
|
package/src/commands/hooks.js
CHANGED
|
@@ -10,6 +10,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
10
10
|
import { flagBool } from "../lib/args.js";
|
|
11
11
|
import { c } from "../lib/colors.js";
|
|
12
12
|
import { suggest } from "../lib/suggest.js";
|
|
13
|
+
import { EXIT } from "../lib/exit-codes.js";
|
|
13
14
|
|
|
14
15
|
const SUBCOMMANDS = ["install"];
|
|
15
16
|
|
|
@@ -33,8 +34,12 @@ export async function run(positional, flags) {
|
|
|
33
34
|
const projectRoot = process.cwd();
|
|
34
35
|
|
|
35
36
|
if (!exists(path.join(projectRoot, ".git"))) {
|
|
37
|
+
// A PRECONDITION, not a gate: nothing was measured and found wanting,
|
|
38
|
+
// the place to put the hook simply does not exist yet. Class 1 told an
|
|
39
|
+
// agent to fix its work and retry the same command, which never clears.
|
|
36
40
|
console.error(c.red("error:") + " not a git repository (no .git/ in cwd)");
|
|
37
|
-
|
|
41
|
+
console.error(c.gray("hint: ") + "run `git init` here, then `doctrina hooks install`");
|
|
42
|
+
return EXIT.PRECONDITION;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
const hooksDir = path.join(projectRoot, ".git", "hooks");
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists } from "../lib/fs-ops.js";
|
|
5
5
|
import * as idx from "../lib/index-json.js";
|
|
6
|
-
import { collectIndexDrift } from "../lib/scan.js";
|
|
6
|
+
import { collectIndexDrift, collectStagedIndexDrift } from "../lib/scan.js";
|
|
7
7
|
import { today } from "../lib/dates.js";
|
|
8
8
|
import { flagBool } from "../lib/args.js";
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
@@ -15,7 +15,7 @@ const SUBCOMMANDS = ["rebuild"];
|
|
|
15
15
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
16
16
|
// adding a command never requires editing the entrypoint — the gap that
|
|
17
17
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
18
|
-
export const flags = { boolean: ["json", "check"], string: [] };
|
|
18
|
+
export const flags = { boolean: ["json", "check", "staged"], string: [] };
|
|
19
19
|
|
|
20
20
|
export async function run(positional, flags) {
|
|
21
21
|
const sub = positional[0];
|
|
@@ -32,11 +32,21 @@ export async function run(positional, flags) {
|
|
|
32
32
|
|
|
33
33
|
function rebuild(flags) {
|
|
34
34
|
const check = flagBool(flags, "check", false);
|
|
35
|
+
const staged = flagBool(flags, "staged", false);
|
|
35
36
|
const projectRoot = process.cwd();
|
|
36
37
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
37
38
|
throw notADoctrinaProject();
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
if (staged) {
|
|
42
|
+
if (!check) {
|
|
43
|
+
console.error(c.red("error:") + " --staged reads the commit, so it can only check; it never writes");
|
|
44
|
+
console.error(c.gray("hint: ") + "run `doctrina index rebuild --check --staged`");
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
return checkStaged(projectRoot);
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
const state = collectIndexDrift(projectRoot);
|
|
41
51
|
if (state.unreadable) {
|
|
42
52
|
if (check) {
|
|
@@ -70,6 +80,39 @@ function rebuild(flags) {
|
|
|
70
80
|
return 0;
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
// The drift check, asked of what git will record. `index.json` is derived
|
|
84
|
+
// from the WHOLE tree, so it cannot describe a subset of it: stage part of
|
|
85
|
+
// `.doctrina/` and the index riding along names artifacts the commit does
|
|
86
|
+
// not carry. Every other gate reads the working tree, where those artifacts
|
|
87
|
+
// are still sitting, so none of them can see it.
|
|
88
|
+
function checkStaged(projectRoot) {
|
|
89
|
+
const state = collectStagedIndexDrift(projectRoot);
|
|
90
|
+
if (!state.applicable) {
|
|
91
|
+
console.log(c.green("ok") + ` nothing to check — ${state.reason}`);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
if (state.ok) {
|
|
95
|
+
console.log(c.green("ok") + " the staged index matches the staged tree");
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (state.reason) {
|
|
99
|
+
console.error(c.red("error:") + ` ${state.reason}`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
for (const p of state.missing) {
|
|
103
|
+
console.log(c.yellow("staged: ") + `${p} — named by the staged index, absent from the commit`);
|
|
104
|
+
}
|
|
105
|
+
for (const p of state.unindexed) {
|
|
106
|
+
console.log(c.yellow("staged: ") + `${p} — in the commit, absent from the staged index`);
|
|
107
|
+
}
|
|
108
|
+
const n = state.missing.length + state.unindexed.length;
|
|
109
|
+
console.log("");
|
|
110
|
+
console.log(c.red("fail") + ` the staged index does not describe this commit (${n} difference${n === 1 ? "" : "s"})`);
|
|
111
|
+
console.log(c.gray("hint: ") + "stage the artifacts above with the index, or unstage the index — "
|
|
112
|
+
+ "index.json is derived from the whole tree, so a partial commit of .doctrina/ cannot carry a matching one");
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
73
116
|
export const help = `
|
|
74
117
|
Usage: doctrina index <subcommand>
|
|
75
118
|
|
|
@@ -80,6 +123,13 @@ Subcommands:
|
|
|
80
123
|
source of truth; the index is derived.
|
|
81
124
|
rebuild --check Write nothing; exit 1 with a drift summary when the
|
|
82
125
|
index no longer matches the tree. CI-friendly.
|
|
126
|
+
rebuild --check --staged
|
|
127
|
+
Ask the same question of the COMMIT rather than the
|
|
128
|
+
disk: every artifact the staged index names must be in
|
|
129
|
+
the commit, and every staged artifact must be in the
|
|
130
|
+
index. A partial commit of .doctrina/ cannot satisfy
|
|
131
|
+
both, because the index is derived from the whole tree.
|
|
132
|
+
Exits 0 when nothing relevant is staged.
|
|
83
133
|
|
|
84
134
|
Fields with no on-disk source (project name, framework_version, product
|
|
85
135
|
metadata) are carried over from the existing index.
|
package/src/commands/init.js
CHANGED
|
@@ -14,7 +14,7 @@ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
|
14
14
|
import { c } from "../lib/colors.js";
|
|
15
15
|
import { EXIT } from "../lib/exit-codes.js";
|
|
16
16
|
import { ask, isInteractive } from "../lib/prompt.js";
|
|
17
|
-
import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "../lib/intake-model.js";
|
|
17
|
+
import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake, nextStepAfterInit } from "../lib/intake-model.js";
|
|
18
18
|
|
|
19
19
|
const SUPPORTED_AGENTS = [
|
|
20
20
|
"claude",
|
|
@@ -107,9 +107,20 @@ export async function run(_positional, flags) {
|
|
|
107
107
|
// isTTY check the adapter prompt already had. A non-interactive caller
|
|
108
108
|
// that forgot --non-interactive read EOF, got an empty answer, and
|
|
109
109
|
// scaffolded a project with a blank description — silently (C9).
|
|
110
|
+
// At a terminal, ONE question, and its answer is the intake (change 0173):
|
|
111
|
+
// it was asked as a one-line description, written into product.md, and
|
|
112
|
+
// then `next` asked for the same thing again as an intake. A person
|
|
113
|
+
// describes the project once.
|
|
114
|
+
let intakeFromTty = false;
|
|
110
115
|
if (!description && !nonInteractive) {
|
|
111
116
|
if (isInteractive()) {
|
|
112
|
-
|
|
117
|
+
const answer = await ask("Describe the project — what it is, who it is for, what it must do:");
|
|
118
|
+
if (answer && answer.trim() !== "") {
|
|
119
|
+
intakeBody = answer.trim();
|
|
120
|
+
intakeSource = "typed at `doctrina init`";
|
|
121
|
+
intakeFromTty = true;
|
|
122
|
+
description = firstLineSummary(intakeBody);
|
|
123
|
+
}
|
|
113
124
|
}
|
|
114
125
|
if (!description) {
|
|
115
126
|
console.error(c.red("error:") + " no project description given and no terminal to ask on");
|
|
@@ -293,7 +304,11 @@ export async function run(_positional, flags) {
|
|
|
293
304
|
|
|
294
305
|
console.log("");
|
|
295
306
|
console.log(c.bold("Doctrina initialised."));
|
|
296
|
-
if (
|
|
307
|
+
if (intakeFromTty) {
|
|
308
|
+
// A person at a terminal gets the one instruction, not the agent's
|
|
309
|
+
// playbook: the agent reprints it with `doctrina intake`.
|
|
310
|
+
for (const line of nextStepAfterInit("tty")) console.log(line);
|
|
311
|
+
} else if (intakeBody) {
|
|
297
312
|
console.log(`Next: execute the bootstrap playbook below — it converts the intake into`);
|
|
298
313
|
console.log(`${c.cyan(".doctrina/product.md")} and capability specs. Any AGENTS.md-aware agent`);
|
|
299
314
|
console.log(`runs it on its own; reprint anytime with ${c.cyan("doctrina intake")}.`);
|
|
@@ -301,7 +316,7 @@ export async function run(_positional, flags) {
|
|
|
301
316
|
warnIfThinIntake(intakeBody);
|
|
302
317
|
printBootstrapPlaybook(projectRoot);
|
|
303
318
|
} else {
|
|
304
|
-
|
|
319
|
+
for (const line of nextStepAfterInit(null)) console.log(line);
|
|
305
320
|
}
|
|
306
321
|
return 0;
|
|
307
322
|
}
|
package/src/commands/intake.js
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
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 { ensureDoctrinaProject, projectName } from "../lib/project.js";
|
|
5
6
|
import { listHeader } from "../lib/scan.js";
|
|
6
7
|
import { assessBrief } from "../lib/clarity.js";
|
|
7
8
|
import * as idx from "../lib/index-json.js";
|
|
8
9
|
import { today } from "../lib/dates.js";
|
|
9
10
|
import { flagBool, flagString } from "../lib/args.js";
|
|
10
11
|
import { c } from "../lib/colors.js";
|
|
11
|
-
import { looksLikePath, warnIfThinIntake, writeIntakeFile
|
|
12
|
+
import { looksLikePath, markIntakeConverted, printBootstrapPlaybook, warnIfThinIntake, writeIntakeFile } from "../lib/intake-model.js";
|
|
12
13
|
|
|
13
14
|
// `init --intake` performs the same operations; they live in
|
|
14
15
|
// lib/intake-model.js so neither command imports out of the other (F7).
|
|
15
16
|
export { warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
|
|
17
|
+
import { collectValidation } from "../lib/validation-model.js";
|
|
16
18
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
17
19
|
|
|
18
20
|
// `intake` is the first half of the no-ceremony path (ADR 0005): store the
|
|
@@ -24,10 +26,47 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
|
24
26
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
25
27
|
// adding a command never requires editing the entrypoint — the gap that
|
|
26
28
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
27
|
-
export const flags = { boolean: ["json", "force"], string: ["text"] };
|
|
29
|
+
export const flags = { boolean: ["json", "force", "converted"], string: ["text"] };
|
|
28
30
|
|
|
29
31
|
export async function run(positional, flags) {
|
|
30
32
|
const projectRoot = process.cwd();
|
|
33
|
+
|
|
34
|
+
// Close the bootstrap through the CLI, not by hand.
|
|
35
|
+
//
|
|
36
|
+
// Step 7 of the playbook used to read "flip Status to converted in
|
|
37
|
+
// .doctrina/intake.md" — the one place this framework told an agent to
|
|
38
|
+
// hand-author a metadata header, against its own standing rule, and the one
|
|
39
|
+
// header nothing read back. A mistyped word left `next` asking forever for
|
|
40
|
+
// a bootstrap that was already done.
|
|
41
|
+
if (flagBool(flags, "converted", false)) {
|
|
42
|
+
ensureDoctrinaProject(projectRoot);
|
|
43
|
+
// Converting is the claim that the bootstrap is done — step 6 of the
|
|
44
|
+
// playbook, "fix everything the gates report", made checkable (change
|
|
45
|
+
// 0194). It used to convert over a tree `validate` failed, so the specs
|
|
46
|
+
// became "the source of truth" while an error still said they were not
|
|
47
|
+
// well-formed. --force converts anyway, for a tree whose errors are not
|
|
48
|
+
// the bootstrap's to fix.
|
|
49
|
+
if (isFile(path.join(projectRoot, ".doctrina", "intake.md")) && !flagBool(flags, "force", false)) {
|
|
50
|
+
const { errors } = collectValidation(projectRoot);
|
|
51
|
+
if (errors.length > 0) {
|
|
52
|
+
console.error(c.red("error:") + ` the bootstrap is not done — \`doctrina validate\` reports ${errors.length} error${errors.length === 1 ? "" : "s"}:`);
|
|
53
|
+
for (const e of errors.slice(0, 5)) console.error(" - " + e);
|
|
54
|
+
if (errors.length > 5) console.error(` … and ${errors.length - 5} more`);
|
|
55
|
+
console.error(c.gray("hint: ") + "fix them (`doctrina validate --fix` heals index drift), then rerun; `--force` converts anyway");
|
|
56
|
+
return EXIT.GATE;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const written = markIntakeConverted(projectRoot);
|
|
60
|
+
if (!written) {
|
|
61
|
+
console.error(c.red("error:") + " no intake to mark converted at .doctrina/intake.md");
|
|
62
|
+
console.error(c.gray("hint: ") + 'store one first: `doctrina intake --text "<description>"`');
|
|
63
|
+
return EXIT.PRECONDITION;
|
|
64
|
+
}
|
|
65
|
+
console.log(c.green("converted") + ` ${relPath(projectRoot, written)} — specs are the source of truth now`);
|
|
66
|
+
console.log(c.gray("next: ") + 'doctrina next');
|
|
67
|
+
return EXIT.OK;
|
|
68
|
+
}
|
|
69
|
+
|
|
31
70
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
32
71
|
throw notADoctrinaProject();
|
|
33
72
|
}
|
|
@@ -102,8 +141,8 @@ export async function run(positional, flags) {
|
|
|
102
141
|
return EXIT.PRECONDITION;
|
|
103
142
|
}
|
|
104
143
|
|
|
105
|
-
const
|
|
106
|
-
writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
|
|
144
|
+
const name = projectName(projectRoot);
|
|
145
|
+
writeIntakeFile(projectRoot, { body, source, projectName: name, date: today(), force });
|
|
107
146
|
console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)}`);
|
|
108
147
|
console.log("");
|
|
109
148
|
warnIfThinIntake(body);
|
|
@@ -127,11 +166,20 @@ Forms:
|
|
|
127
166
|
doctrina intake --text "<text>" Ingest an inline description
|
|
128
167
|
doctrina intake Reprint the playbook for the
|
|
129
168
|
existing pending intake
|
|
169
|
+
doctrina intake --converted Close the bootstrap: flip the stored
|
|
170
|
+
intake's Status to converted
|
|
130
171
|
|
|
131
172
|
Options:
|
|
132
173
|
--text "<description>" Inline description instead of a file
|
|
133
|
-
--force Overwrite an existing .doctrina/intake.md
|
|
174
|
+
--force Overwrite an existing .doctrina/intake.md; with
|
|
175
|
+
--converted, convert although validate fails
|
|
176
|
+
--converted Mark the stored intake converted (ends the
|
|
177
|
+
bootstrap; nothing else writes that header).
|
|
178
|
+
Refuses (exit 1) while validate reports an error
|
|
134
179
|
|
|
135
|
-
|
|
136
|
-
|
|
180
|
+
Close the bootstrap with \`doctrina intake --converted\` rather than editing
|
|
181
|
+
the header by hand: \`next\` branches on that value, and \`validate\` reports a
|
|
182
|
+
Status that is neither pending nor converted, because every other word read
|
|
183
|
+
as pending in silence. After conversion the specs are the only source of
|
|
184
|
+
truth.
|
|
137
185
|
`;
|