doctrina-cli 0.21.0 → 0.22.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 +6 -6
- package/package.json +1 -1
- package/src/commands/change.js +111 -14
- package/src/commands/clarify.js +2 -2
- package/src/commands/close.js +1 -1
- package/src/commands/coverage.js +69 -6
- package/src/commands/doctor.js +62 -4
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +51 -7
- package/src/commands/metrics.js +1 -1
- package/src/commands/onboard.js +113 -0
- package/src/commands/review.js +1 -1
- package/src/commands/search.js +2 -2
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +46 -6
- package/src/commands/verify.js +3 -3
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +53 -40
- package/src/commands/workspace.js +16 -3
- package/src/index.js +9 -0
- package/src/lib/actions.js +19 -0
- package/src/lib/adapters.js +23 -3
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +1 -1
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +3 -3
- package/src/lib/code-inventory.js +22 -5
- package/src/lib/commands.js +50 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +2 -2
- package/src/lib/docs-impact.js +1 -1
- package/src/lib/fs-ops.js +80 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +4 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +1 -1
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +2 -1
- package/src/lib/workspace-agents.js +2 -2
- package/src/lib/workspace-tools.js +41 -1
- package/src/lib/workspace.js +1 -1
- package/templates/AGENTS.md.template +8 -4
- package/templates/README.md +2 -1
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/kiro/README.md +22 -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.22.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
|
@@ -23,14 +23,14 @@ read `AGENTS.md` and run `doctrina next` — it drives the rest. The
|
|
|
23
23
|
surface, by the moment you reach for it:
|
|
24
24
|
|
|
25
25
|
```
|
|
26
|
-
Bootstrap init · intake · adapter list|add|remove
|
|
26
|
+
Bootstrap init · ui · onboard · 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|delta|card
|
|
28
|
+
Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon|renumber|delta|card
|
|
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
|
|
32
32
|
Orchestrate workspace init|add|attach|detach|list|tool|guard · delivery new|route|list|status|tick|check|close
|
|
33
|
-
Maintain upgrade · index rebuild · hooks install · watch · metrics · completion · ci · templates list
|
|
33
|
+
Maintain upgrade · index rebuild|merge · hooks install · watch · metrics · completion · ci · templates list
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
`doctrina --help` prints the same grouping with one line per command, and
|
|
@@ -44,7 +44,7 @@ code is in the
|
|
|
44
44
|
|------|---------|
|
|
45
45
|
| `--project-name <name>` | Override the project name (defaults to cwd basename) |
|
|
46
46
|
| `--project-description <text>` | One-sentence description, with no intake. Without it on a terminal, `init` asks you to describe the project once and keeps the answer as the intake |
|
|
47
|
-
| `--agent <name>` | Install the adapter for one of the
|
|
47
|
+
| `--agent <name>` | Install the adapter for one of the 13 supported agents (`claude`, `codex`, `cursor`, `copilot`, `gemini`, `aider`, `windsurf`, `continue`, `amp`, `devin`, `factory`, `jules`, `kiro`) or `all` |
|
|
48
48
|
| `--date <YYYY-MM-DD>` | Override the system date in artifacts |
|
|
49
49
|
| `--force` | Overwrite existing files |
|
|
50
50
|
| `--non-interactive` | Fail instead of prompting for missing required values |
|
|
@@ -57,7 +57,7 @@ code is in the
|
|
|
57
57
|
`alwaysApply: true` pointing at `AGENTS.md`. `copilot`, `gemini`,
|
|
58
58
|
`aider`, `windsurf`, and `continue` follow the same thin-pointer
|
|
59
59
|
pattern at each agent's native path.
|
|
60
|
-
- `--agent codex` (and `amp`, `devin`, `factory`, `jules`) installs
|
|
60
|
+
- `--agent codex` (and `amp`, `devin`, `factory`, `jules`, `kiro`) installs
|
|
61
61
|
nothing: these agents read `AGENTS.md` natively.
|
|
62
62
|
- `--agent all` installs every adapter.
|
|
63
63
|
|
package/package.json
CHANGED
package/src/commands/change.js
CHANGED
|
@@ -19,16 +19,19 @@ import { confirm, isInteractive } from "../lib/prompt.js";
|
|
|
19
19
|
import { taskCard } from "../lib/task-card.js";
|
|
20
20
|
import { EXIT } from "../lib/exit-codes.js";
|
|
21
21
|
import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
|
|
22
|
+
import { renumberChange } from "../lib/change-numbers.js";
|
|
23
|
+
import { appliedDeltas, deltaFingerprint, deltaKey, pendingOps, recordAppliedDeltas } from "../lib/applied-deltas.js";
|
|
22
24
|
import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
|
|
23
25
|
import { changeNew, addDeltaOps } from "../lib/change-ops.js";
|
|
26
|
+
import { propertyShape } from "../lib/properties.js";
|
|
24
27
|
import { scanText } from "../lib/clarify-scan.js";
|
|
25
28
|
|
|
26
|
-
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta", "card"];
|
|
29
|
+
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "renumber", "delta", "card"];
|
|
27
30
|
|
|
28
31
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
29
32
|
// adding a command never requires editing the entrypoint — the gap that
|
|
30
33
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
31
|
-
export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason", "requirement", "criterion", "bump", "op"] };
|
|
34
|
+
export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason", "requirement", "criterion", "property", "bump", "op"] };
|
|
32
35
|
|
|
33
36
|
export async function run(positional, flags) {
|
|
34
37
|
const sub = positional[0];
|
|
@@ -46,6 +49,8 @@ export async function run(positional, flags) {
|
|
|
46
49
|
return changeTick(positional.slice(1), flags);
|
|
47
50
|
case "abandon":
|
|
48
51
|
return await changeAbandon(positional.slice(1), flags);
|
|
52
|
+
case "renumber":
|
|
53
|
+
return changeRenumber(positional.slice(1));
|
|
49
54
|
case "delta":
|
|
50
55
|
return changeDelta(positional.slice(1), flags);
|
|
51
56
|
case "card":
|
|
@@ -89,7 +94,7 @@ function changeCard(args) {
|
|
|
89
94
|
const { card, reason } = taskCard(projectRoot, id, ordinal);
|
|
90
95
|
if (!card) {
|
|
91
96
|
console.log(c.gray(reason));
|
|
92
|
-
return reason.startsWith("every task") ? EXIT.OK : EXIT.PRECONDITION;
|
|
97
|
+
return (reason ?? "").startsWith("every task") ? EXIT.OK : EXIT.PRECONDITION;
|
|
93
98
|
}
|
|
94
99
|
const L = [];
|
|
95
100
|
L.push(`# Task card — ${card.change}, task ${card.ordinal} of ${card.total}` + (card.checked ? " (already ticked)" : ""));
|
|
@@ -242,21 +247,53 @@ function changeApply(args, flags) {
|
|
|
242
247
|
const proposalPath = path.join(changeDir, "proposal.md");
|
|
243
248
|
const alreadyApplied = isFile(proposalPath)
|
|
244
249
|
&& (getHeader(read(proposalPath), "Status") ?? "").trim().toLowerCase() === "applied";
|
|
250
|
+
const allDeltas = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
251
|
+
// WHICH deltas the stamp covers (change 0247). The stamp is the change's,
|
|
252
|
+
// and a change is not finished when it is stamped: a close that applied
|
|
253
|
+
// and then stopped at a later gate leaves it `applied`, and the fix for
|
|
254
|
+
// that gate is often one more delta. The stamp said "nothing to do", the
|
|
255
|
+
// close went on, and the change was archived naming a spec it had never
|
|
256
|
+
// touched. The record names each delta that was applied, with what it held.
|
|
257
|
+
/** @type {Map<string, Set<number> | null> | null} delta → the ops still to apply (null: the whole delta) */
|
|
258
|
+
let lateOnly = null;
|
|
245
259
|
if (alreadyApplied && !flagBool(flags, "force", false)) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
260
|
+
const record = appliedDeltas(read(proposalPath));
|
|
261
|
+
const late = new Map();
|
|
262
|
+
const edited = [];
|
|
263
|
+
for (const p of record ? allDeltas : []) {
|
|
264
|
+
const recorded = record?.get(deltaKey(changeDir, p));
|
|
265
|
+
if (!recorded) {
|
|
266
|
+
late.set(p, null);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const { fresh, gone } = pendingOps(recorded, deltaFingerprint(read(p)));
|
|
270
|
+
if (gone) edited.push(p);
|
|
271
|
+
else if (fresh.length > 0) late.set(p, new Set(fresh));
|
|
272
|
+
}
|
|
273
|
+
if (edited.length > 0) {
|
|
274
|
+
console.error(c.red("error:") + ` ${edited.map((p) => relPath(changeDir, p)).join(", ")}: an op that was already applied changed or was removed — the spec holds what it wrote, and applying the delta again would add to that, not replace it`);
|
|
275
|
+
console.error(c.gray("hint: ") + "restore the op as it was applied and add the correction as a new op (`replace-requirement`, `replace-criterion`, `doctrina change delta`), or revert the spec to its pre-apply state and run `doctrina change apply " + id + " --force`");
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
if (late.size === 0) {
|
|
279
|
+
console.log(c.gray("skip ") + `change "${id}" is already applied — nothing to do`);
|
|
280
|
+
console.log(c.gray("The ops verbs are additive: applying twice duplicates every"));
|
|
281
|
+
console.log(c.gray("requirement this delta carries. Re-apply anyway with --force"));
|
|
282
|
+
console.log(c.gray("only after reverting the specs to their pre-apply state."));
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
const what = [...late].map(([p, ops]) => `${relPath(changeDir, p)}${ops ? ` (${ops.size} new op${ops.size === 1 ? "" : "s"})` : ""}`).join(", ");
|
|
286
|
+
console.log(c.gray("note: ") + `change "${id}" was applied before this was added — applying only ${what}`);
|
|
287
|
+
lateOnly = late;
|
|
251
288
|
}
|
|
252
289
|
|
|
253
290
|
// The skip above comes FIRST (change 0221): the apply gate is a pre-apply
|
|
254
291
|
// question, and on an applied change its ADDED check reads the content the
|
|
255
292
|
// apply wrote as a conflict — which refused the resume of a killed close.
|
|
256
|
-
const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
|
|
293
|
+
const applyGate = lateOnly ? { ok: true, forced: null } : enforceTransition(projectRoot, id, changeDir, "apply", flags);
|
|
257
294
|
if (!applyGate.ok) return 1;
|
|
258
295
|
|
|
259
|
-
const deltaFiles =
|
|
296
|
+
const deltaFiles = lateOnly ? [...lateOnly.keys()] : allDeltas;
|
|
260
297
|
const date = today();
|
|
261
298
|
let errors = 0;
|
|
262
299
|
let writes = 0;
|
|
@@ -310,7 +347,8 @@ function changeApply(args, flags) {
|
|
|
310
347
|
// `ops` block (set-header / bump-version / set-criterion / ...; F3);
|
|
311
348
|
// otherwise it falls back to the historical manual-merge pointer, so
|
|
312
349
|
// prose deltas keep working unchanged.
|
|
313
|
-
const
|
|
350
|
+
const only = lateOnly?.get(deltaPath) ?? null;
|
|
351
|
+
const ops = extractOps(text).filter((_op, i) => only === null || only.has(i));
|
|
314
352
|
if (ops.length === 0) {
|
|
315
353
|
console.log(c.yellow("manual[MODIFIED]") + ` merge ${relPath(projectRoot, deltaPath)} into ${relPath(projectRoot, targetPath)} by hand (no ops block)`);
|
|
316
354
|
manual += 1;
|
|
@@ -353,6 +391,10 @@ function changeApply(args, flags) {
|
|
|
353
391
|
console.log(c.green("status") + " proposal.md → applied");
|
|
354
392
|
flippedToApplied = true;
|
|
355
393
|
}
|
|
394
|
+
// Every delta the proposal's stamp now covers, with what it held.
|
|
395
|
+
const stamped = read(proposalPath);
|
|
396
|
+
const withRecord = recordAppliedDeltas(stamped, allDeltas.map((p) => [deltaKey(changeDir, p), deltaFingerprint(read(p))]));
|
|
397
|
+
if (withRecord !== stamped) write(proposalPath, withRecord, { force: true });
|
|
356
398
|
}
|
|
357
399
|
}
|
|
358
400
|
|
|
@@ -520,6 +562,9 @@ function changeDelta(args, flags) {
|
|
|
520
562
|
const bump = flagString(flags, "bump");
|
|
521
563
|
for (const r of flagAll(flags, "requirement")) lines.push(`append-requirement ${r}`);
|
|
522
564
|
for (const r of flagAll(flags, "criterion")) lines.push(`append-criterion ${r}`);
|
|
565
|
+
// A criterion that claims something of every input (change 0254): the
|
|
566
|
+
// mark is what makes coverage ask the cited test to be a property.
|
|
567
|
+
for (const r of flagAll(flags, "property")) lines.push(`append-criterion [property] ${r.replace(/^\[property\]\s*/i, "")}`);
|
|
523
568
|
lines.push(...raw);
|
|
524
569
|
if (bump) lines.push(`bump-version ${bump}`);
|
|
525
570
|
|
|
@@ -531,7 +576,23 @@ function changeDelta(args, flags) {
|
|
|
531
576
|
return r.code;
|
|
532
577
|
}
|
|
533
578
|
console.log(c.green(r.created ? "created" : "updated") + ` ${r.deltaRel}` +
|
|
534
|
-
c.gray(` — ${lines.length} op${lines.length === 1 ? "" : "s"} added; the block now holds ${r.block.length} (${r.block.join(", ")}), and applies cleanly`));
|
|
579
|
+
c.gray(` — ${lines.length} op${lines.length === 1 ? "" : "s"} added; the block now holds ${(r.block ?? []).length} (${(r.block ?? []).join(", ")}), and applies cleanly`));
|
|
580
|
+
// The moment the requirement is written is the moment its proof is
|
|
581
|
+
// chosen (change 0254): one that claims something of every input is told
|
|
582
|
+
// so here, while the test for it does not exist yet.
|
|
583
|
+
if (flagAll(flags, "property").length === 0) {
|
|
584
|
+
let noted = 0;
|
|
585
|
+
for (const req of flagAll(flags, "requirement")) {
|
|
586
|
+
const shape = propertyShape(req.replace(/^[a-z-]+:\s*/i, ""));
|
|
587
|
+
if (!shape) continue;
|
|
588
|
+
noted++;
|
|
589
|
+
console.log(c.yellow("note: ") + `"${req.length > 70 ? req.slice(0, 69) + "…" : req}" reads as ${shape.id === "invariant" ? "an invariant" : shape.id} — ${shape.property}.`);
|
|
590
|
+
}
|
|
591
|
+
if (noted > 0) {
|
|
592
|
+
console.log(c.gray(" An example test proves the cases somebody thought of. If a failure here would be a bug, write a property test and claim it: ") +
|
|
593
|
+
c.cyan(`doctrina change delta ${id} ${capability} --property "<what holds for every input> — verified by \`<the property test>\`"`));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
535
596
|
console.log(c.gray("Preview the merge: ") + c.cyan(`doctrina change check ${id} --verbose`));
|
|
536
597
|
return EXIT.OK;
|
|
537
598
|
}
|
|
@@ -827,6 +888,34 @@ async function changeAbandon(args, flags) {
|
|
|
827
888
|
return 0;
|
|
828
889
|
}
|
|
829
890
|
|
|
891
|
+
// Give an open change the next free number (change 0242): two branches that
|
|
892
|
+
// each opened a change took the same one, and the merge holds both.
|
|
893
|
+
function changeRenumber(args) {
|
|
894
|
+
const id = args[0];
|
|
895
|
+
if (!id) {
|
|
896
|
+
console.error(c.red("error:") + " change renumber requires <id>");
|
|
897
|
+
return 2;
|
|
898
|
+
}
|
|
899
|
+
const refused = refuseChangeRef(id);
|
|
900
|
+
if (refused !== null) return refused;
|
|
901
|
+
const projectRoot = process.cwd();
|
|
902
|
+
ensureDoctrinaProject(projectRoot);
|
|
903
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
|
|
904
|
+
if (!isDir(changeDir)) {
|
|
905
|
+
console.error(c.red("error:") + ` change "${id}" is not an open change (${relPath(projectRoot, changeDir)} not found) — an archived change keeps its number`);
|
|
906
|
+
return EXIT.USAGE;
|
|
907
|
+
}
|
|
908
|
+
const { to, rewritten } = renumberChange(projectRoot, id);
|
|
909
|
+
console.log(c.green("renumbered") + ` ${id} → ${to}`);
|
|
910
|
+
if (rewritten.length > 0) console.log(c.gray(` the id was rewritten in ${rewritten.join(", ")}`));
|
|
911
|
+
const derived = deriveIndex(projectRoot, idx.load(projectRoot));
|
|
912
|
+
derived.last_updated = today();
|
|
913
|
+
idx.save(projectRoot, derived);
|
|
914
|
+
console.log(c.green("indexed") + ` change "${to}" in index.json`);
|
|
915
|
+
console.log(c.gray("next: ") + `name ${to} wherever the old number was written outside the change — a commit message, a branch, a task card already handed out`);
|
|
916
|
+
return 0;
|
|
917
|
+
}
|
|
918
|
+
|
|
830
919
|
// The per-delta preview: what applying this delta would do to its target.
|
|
831
920
|
// ADDED reports the body it would write, REMOVED the spec it would delete,
|
|
832
921
|
// MODIFIED a line diff against the current spec.
|
|
@@ -953,15 +1042,23 @@ Subcommands:
|
|
|
953
1042
|
or every one with --all. No args = list only.
|
|
954
1043
|
abandon <id> Delete an open change folder and its index entry, and
|
|
955
1044
|
record the abandonment in the ledger ([--reason "..."]).
|
|
1045
|
+
renumber <id> Give an open change the next free number: the folder
|
|
1046
|
+
moves and the id is rewritten in its own files. For
|
|
1047
|
+
the merge that brought together two changes opened
|
|
1048
|
+
on two branches under one number — \`validate\` names it.
|
|
956
1049
|
delta <id> <capability> [--requirement "<section>: <text>"] [--criterion "<text>"]
|
|
957
|
-
[--bump major|minor|patch] [--op "<op line>" | <op line>]
|
|
1050
|
+
[--property "<text>"] [--bump major|minor|patch] [--op "<op line>" | <op line>]
|
|
958
1051
|
Write ops into the change's MODIFIED delta for the
|
|
959
1052
|
capability — created from the template when absent —
|
|
960
1053
|
parsed by the grammar apply reads and dry-run against
|
|
961
1054
|
the spec first: nothing is written unless the whole
|
|
962
1055
|
block applies. --requirement, --criterion and --op
|
|
963
1056
|
repeat, one op each, in order; a second --bump
|
|
964
|
-
replaces the first.
|
|
1057
|
+
replaces the first. --property writes a criterion
|
|
1058
|
+
marked [property]: a claim about every input, which
|
|
1059
|
+
coverage counts only when the test it cites is
|
|
1060
|
+
property-based (\`doctrina coverage --properties\`
|
|
1061
|
+
lists the requirements that read as one).
|
|
965
1062
|
card <id> [<n>] Print task <n> (default: the next open one) as a
|
|
966
1063
|
self-contained card for an executor: the task and the
|
|
967
1064
|
lines indented under it (Files:, Requirement:, Verify:),
|
package/src/commands/clarify.js
CHANGED
|
@@ -15,7 +15,7 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
|
15
15
|
// guessing; persist it with .doctrina/config.json { "language": "pt-BR" }.
|
|
16
16
|
function forcedLang(flags) {
|
|
17
17
|
const raw = flagString(flags, "lang");
|
|
18
|
-
if (!raw
|
|
18
|
+
if (!raw) return null;
|
|
19
19
|
const lang = String(raw).toLowerCase();
|
|
20
20
|
if (lang.startsWith("pt")) return "pt";
|
|
21
21
|
if (lang.startsWith("en")) return "en";
|
|
@@ -73,7 +73,7 @@ export async function run(positional, flags) {
|
|
|
73
73
|
// and clarify each. ADRs are immutable and the archive is history, so
|
|
74
74
|
// neither is included; AGENTS.md is dense command text, a different
|
|
75
75
|
// register from prose specs.
|
|
76
|
-
function clarifyAll(projectRoot, lang = null) {
|
|
76
|
+
function clarifyAll(projectRoot, lang = /** @type {string | null} */ (null)) {
|
|
77
77
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
78
78
|
throw notADoctrinaProject();
|
|
79
79
|
}
|
package/src/commands/close.js
CHANGED
|
@@ -463,7 +463,7 @@ async function closeOne(projectRoot, id, flags) {
|
|
|
463
463
|
if (isFile(ledgerPath)) {
|
|
464
464
|
// Written in the ledger's own entry grammar (change 0046): a waived
|
|
465
465
|
// gate no reader can find is the same as an unrecorded one.
|
|
466
|
-
appendLedgerLine(projectRoot, docsGapLine(id, docsGap.signals));
|
|
466
|
+
appendLedgerLine(projectRoot, docsGapLine(id, /** @type {{ signals: string[] }} */ (/** @type {unknown} */ (docsGap)).signals));
|
|
467
467
|
console.log(c.yellow("ledger") + " recorded the docs gap");
|
|
468
468
|
}
|
|
469
469
|
}
|
package/src/commands/coverage.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { exists, isFile, read, relPath } from "../lib/fs-ops.js";
|
|
6
|
+
import { propertyCandidates, projectPropertyLibrary } from "../lib/properties.js";
|
|
6
7
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
8
|
import { c } from "../lib/colors.js";
|
|
8
9
|
import { emitJson } from "../lib/json-out.js";
|
|
@@ -39,7 +40,7 @@ export { collect, summarize, deriveImplementation, derivedImplementations, imple
|
|
|
39
40
|
// wrap it in the generic envelope.
|
|
40
41
|
export const jsonNative = true;
|
|
41
42
|
|
|
42
|
-
export const flags = { boolean: ["json", "run", "strict"], string: ["only"] };
|
|
43
|
+
export const flags = { boolean: ["json", "run", "strict", "properties"], string: ["only"] };
|
|
43
44
|
|
|
44
45
|
export async function run(_positional, flags) {
|
|
45
46
|
const projectRoot = process.cwd();
|
|
@@ -77,6 +78,11 @@ export async function run(_positional, flags) {
|
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// --properties: which requirements read as claims about EVERY input, and
|
|
82
|
+
// so ask for a property-based test (change 0254). A readout, exit 0: it
|
|
83
|
+
// proposes; the `[property]` mark on a criterion is what the gate enforces.
|
|
84
|
+
if (flagBool(flags, "properties", false)) return listProperties(projectRoot, only, json);
|
|
85
|
+
|
|
80
86
|
const reports = collect(projectRoot, { only });
|
|
81
87
|
|
|
82
88
|
// --run: execute the cited evidence instead of only checking it exists —
|
|
@@ -153,8 +159,8 @@ export async function run(_positional, flags) {
|
|
|
153
159
|
if (r.unverified) {
|
|
154
160
|
console.log(` ${c.gray("○")} #${r.n} proof resolves but the criterion is still marked [unverified] — flip the mark when the test proves it (spec set <cap> --criterion ${r.n}:verified)`);
|
|
155
161
|
}
|
|
156
|
-
if (r.missing
|
|
157
|
-
console.log(` ${c.yellow("!")} #${r.n} also cites evidence that does not resolve: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
|
|
162
|
+
if ((r.missing ?? []).length > 0) {
|
|
163
|
+
console.log(` ${c.yellow("!")} #${r.n} also cites evidence that does not resolve: ${(r.missing ?? []).map((m) => `\`${m}\``).join(", ")}`);
|
|
158
164
|
}
|
|
159
165
|
continue;
|
|
160
166
|
}
|
|
@@ -165,9 +171,9 @@ export async function run(_positional, flags) {
|
|
|
165
171
|
} else if (r.kind === "conditional") {
|
|
166
172
|
console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
|
|
167
173
|
} else if (r.kind === "unguarded") {
|
|
168
|
-
console.log(` ${c.red("✗")} #${r.n} orchestration claim is not fail-closed: ${r.reason}`);
|
|
174
|
+
console.log(` ${c.red("✗")} #${r.n} ${r.label ?? "orchestration claim is not fail-closed"}: ${r.reason}`);
|
|
169
175
|
} else {
|
|
170
|
-
console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
|
|
176
|
+
console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${(r.missing ?? []).map((m) => `\`${m}\``).join(", ")}`);
|
|
171
177
|
}
|
|
172
178
|
}
|
|
173
179
|
}
|
|
@@ -250,8 +256,49 @@ function runEvidence(projectRoot, reports, strict) {
|
|
|
250
256
|
}
|
|
251
257
|
|
|
252
258
|
|
|
259
|
+
const CANDIDATES_SHOWN = 5;
|
|
260
|
+
|
|
261
|
+
function listProperties(projectRoot, only, json) {
|
|
262
|
+
const caps = propertyCandidates(projectRoot, { only });
|
|
263
|
+
const lib = projectPropertyLibrary(projectRoot);
|
|
264
|
+
if (json) {
|
|
265
|
+
emitJson("coverage", { properties: caps, library: lib });
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
const total = caps.reduce((n, cap) => n + cap.candidates.length, 0);
|
|
269
|
+
const proven = caps.reduce((n, cap) => n + cap.properties, 0);
|
|
270
|
+
console.log(c.bold("Property candidates") + c.gray(" — requirements that claim something of every input"));
|
|
271
|
+
console.log("");
|
|
272
|
+
if (total === 0) {
|
|
273
|
+
console.log(c.gray("no requirement here reads as an invariant (always/never, at most, unique, round trip, twice, in order)"));
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
for (const cap of caps) {
|
|
277
|
+
if (cap.candidates.length === 0) continue;
|
|
278
|
+
console.log(`${c.cyan(cap.cap)} ${cap.candidates.length} requirement${cap.candidates.length === 1 ? "" : "s"} read${cap.candidates.length === 1 ? "s" : ""} as invariant${cap.candidates.length === 1 ? "" : "s"}, ` +
|
|
279
|
+
`${cap.properties} criteri${cap.properties === 1 ? "on is a property" : "a are properties"}`);
|
|
280
|
+
for (const cand of cap.candidates.slice(0, CANDIDATES_SHOWN)) {
|
|
281
|
+
const text = cand.text.length > 110 ? `${cand.text.slice(0, 107)}…` : cand.text;
|
|
282
|
+
console.log(` ${cand.ref.padEnd(13)} ${text}`);
|
|
283
|
+
console.log(c.gray(` ${"".padEnd(13)} ${cand.shape}: ${cand.property}`));
|
|
284
|
+
}
|
|
285
|
+
if (cap.candidates.length > CANDIDATES_SHOWN) {
|
|
286
|
+
console.log(c.gray(` … and ${cap.candidates.length - CANDIDATES_SHOWN} more — doctrina coverage --properties --only ${cap.cap} --json`));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
console.log("");
|
|
290
|
+
console.log(`${total} candidate${total === 1 ? "" : "s"}, ${proven} propert${proven === 1 ? "y" : "ies"} claimed. A candidate is a reading of the words, not a verdict: choose the ones whose failure would be a bug.`);
|
|
291
|
+
const where = lib
|
|
292
|
+
? `${lib.name}${lib.declared ? "" : " (not declared by this project yet — add it as a test dependency)"}`
|
|
293
|
+
: "the property library of the project's language";
|
|
294
|
+
console.log(`Write each as a test that generates its inputs with ${where}, then claim it:`);
|
|
295
|
+
console.log(c.cyan(" doctrina change delta <id> <capability> --property \"<what holds for every input> — verified by \`<the property test>\`\""));
|
|
296
|
+
console.log(c.gray("A criterion marked [property] counts as proven only when the test it cites is property-based."));
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
253
300
|
export const help = `
|
|
254
|
-
Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--json]
|
|
301
|
+
Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--properties] [--json]
|
|
255
302
|
|
|
256
303
|
Report how many acceptance criteria across .doctrina/specs/ cite an
|
|
257
304
|
artifact or test that exists on disk. Each numbered criterion may cite
|
|
@@ -277,6 +324,22 @@ That check must declare an "expect" guard (see \`doctrina verify --help\`).
|
|
|
277
324
|
A cited check with no guard is reported UNGUARDED and fails --strict: a
|
|
278
325
|
check that exits 0 having run nothing would otherwise satisfy the claim.
|
|
279
326
|
|
|
327
|
+
PROPERTY criteria. A requirement that claims something of EVERY input —
|
|
328
|
+
"never returns a code already in use", "at most 100 items", "decoding
|
|
329
|
+
gives back what was encoded" — is not proven by three examples. Mark the
|
|
330
|
+
criterion [property] and cite a property-based test:
|
|
331
|
+
|
|
332
|
+
4. [property] no two codes collide, whatever the URLs —
|
|
333
|
+
verified by \`tests/test_codes_property.py\`
|
|
334
|
+
|
|
335
|
+
It counts as covered only when that test generates its inputs
|
|
336
|
+
(Hypothesis @given, fast-check fc.property, jqwik @Property, proptest,
|
|
337
|
+
rapid, FsCheck, ScalaCheck); cited to an example test it is reported and
|
|
338
|
+
fails --strict. \`doctrina coverage --properties\` lists, per capability,
|
|
339
|
+
the requirements whose words read as an invariant, the property each
|
|
340
|
+
suggests and the library this project would write it with — a readout,
|
|
341
|
+
never a gate.
|
|
342
|
+
|
|
280
343
|
A spec that declares a deliberate deferral —
|
|
281
344
|
\`Implementation: planned — <why>\`, the same escape hatch validate
|
|
282
345
|
honours — has its unproven criteria reported as DEFERRED: visible, but
|
package/src/commands/doctor.js
CHANGED
|
@@ -19,6 +19,11 @@ import { OPERATIONS } from "../lib/commands.js";
|
|
|
19
19
|
import { coverageLabel } from "../lib/views.js";
|
|
20
20
|
import { workspaceFindings, workspaceRootAt, workspaceSnapshot } from "../lib/workspace-view.js";
|
|
21
21
|
import { cdThen } from "../lib/shell.js";
|
|
22
|
+
import { cliVersion } from "../lib/version.js";
|
|
23
|
+
import { installsOnPath, runningPackageDir } from "../lib/install.js";
|
|
24
|
+
import { missingAttributes, configuredDriver } from "../lib/merge-setup.js";
|
|
25
|
+
import { isRepo } from "../lib/git.js";
|
|
26
|
+
import { shadowRootOf } from "../lib/workspace.js";
|
|
22
27
|
|
|
23
28
|
// How many never-invoked operations the usage row names before pointing at
|
|
24
29
|
// `metrics --commands` for the rest. The spec states this number, so it is
|
|
@@ -276,9 +281,9 @@ export async function run(_positional, _flags) {
|
|
|
276
281
|
" (run `doctrina verify` to execute)";
|
|
277
282
|
if (stale > 0) {
|
|
278
283
|
const parts = [];
|
|
279
|
-
if (so
|
|
280
|
-
if (so
|
|
281
|
-
if (so
|
|
284
|
+
if (so?.expired) parts.push(`${so.expired} expired`);
|
|
285
|
+
if (so?.unverifiable) parts.push(`${so.unverifiable} unverifiable`);
|
|
286
|
+
if (so?.pending) parts.push(`${so.pending} unsigned`);
|
|
282
287
|
row("warn", "verify config", `${detail} — ${parts.join(", ")}`,
|
|
283
288
|
"doctrina verify (each names the re-sign command)");
|
|
284
289
|
warningsTotal += 1;
|
|
@@ -306,7 +311,7 @@ export async function run(_positional, _flags) {
|
|
|
306
311
|
if (cfg.errors.length > 0) {
|
|
307
312
|
row("fail", "config", cfg.errors[0], "fix .doctrina/config.json, then `doctrina validate`");
|
|
308
313
|
} else if (cfg.unknown.length > 0) {
|
|
309
|
-
row("warn", "config", `${summary}; unknown key${cfg.unknown.length === 1 ? "" : "s"} ${cfg.unknown.map((k) => `"${k}"`).join(", ")} ignored`, "the keys are language, context_budget, rules");
|
|
314
|
+
row("warn", "config", `${summary}; unknown key${cfg.unknown.length === 1 ? "" : "s"} ${cfg.unknown.map((k) => `"${k}"`).join(", ")} ignored`, "the keys are language, context_budget, rules, surface");
|
|
310
315
|
warningsTotal += 1;
|
|
311
316
|
} else {
|
|
312
317
|
row("ok", "config", summary);
|
|
@@ -316,6 +321,45 @@ export async function run(_positional, _flags) {
|
|
|
316
321
|
}
|
|
317
322
|
},
|
|
318
323
|
|
|
324
|
+
// 9b. The CLI that is running, and the one a bare `doctrina` finds.
|
|
325
|
+
cli: () => {
|
|
326
|
+
const version = cliVersion();
|
|
327
|
+
const installs = installsOnPath();
|
|
328
|
+
const first = installs[0];
|
|
329
|
+
const here = runningPackageDir();
|
|
330
|
+
const others = installs.filter((i) => !i.running);
|
|
331
|
+
const label = (/** @type {{ bin: string, version: string | null }} */ i) => `${i.bin} (${i.version ?? "version unknown"})`;
|
|
332
|
+
if (first && !first.running) {
|
|
333
|
+
row("warn", "cli", `${version} is running from ${here}, but \`doctrina\` on the PATH is ${label(first)} — the hooks and every bare \`doctrina\` run that one`,
|
|
334
|
+
`remove or update the other install (npm uninstall -g doctrina-cli under the Node that owns it), or put this one first on the PATH`);
|
|
335
|
+
warningsTotal += 1;
|
|
336
|
+
} else if (others.length > 0) {
|
|
337
|
+
row("warn", "cli", `${version} at ${first.bin}; ${others.length} more on the PATH: ${others.map(label).join(", ")}`,
|
|
338
|
+
"remove the copies you do not use — a shell whose PATH orders them differently runs another version");
|
|
339
|
+
warningsTotal += 1;
|
|
340
|
+
} else if (!first && callsDoctrinaByName(projectRoot)) {
|
|
341
|
+
row("warn", "cli", `${version} is running from ${here}, and no \`doctrina\` is on the PATH — this project's agent hooks call it by name`,
|
|
342
|
+
"npm install -g doctrina-cli (or add its bin directory to the PATH)");
|
|
343
|
+
warningsTotal += 1;
|
|
344
|
+
} else {
|
|
345
|
+
row("ok", "cli", first ? `${version} at ${first.bin} — the only one on the PATH` : `${version}, run from ${here} (no \`doctrina\` on the PATH)`);
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
// 9c. Whether two branches that each close a change merge by themselves.
|
|
350
|
+
merges: () => {
|
|
351
|
+
if (!isRepo(projectRoot) || shadowRootOf(projectRoot) !== null) return;
|
|
352
|
+
const attributes = missingAttributes(projectRoot).length === 0;
|
|
353
|
+
const driver = configuredDriver(projectRoot) !== null;
|
|
354
|
+
if (attributes && driver) {
|
|
355
|
+
row("ok", "merges", "index.json and the ledger merge by themselves (driver and .gitattributes in place)");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const missing = !attributes && !driver ? "nothing is set up" : !attributes ? ".gitattributes lacks the two lines" : "this clone has no merge driver";
|
|
359
|
+
row("warn", "merges", `${missing} — two branches that each close a change stop on index.json and the ledger`, "doctrina hooks install (once per clone; commit .gitattributes)");
|
|
360
|
+
warningsTotal += 1;
|
|
361
|
+
},
|
|
362
|
+
|
|
319
363
|
// 10. Which of the catalog's operations have never been reached for —
|
|
320
364
|
// but ONLY when the operator turned the log on. `lib/usage.js` was
|
|
321
365
|
// built to instrument the surface before shrinking it, and the only
|
|
@@ -377,6 +421,14 @@ export async function run(_positional, _flags) {
|
|
|
377
421
|
return 0;
|
|
378
422
|
}
|
|
379
423
|
|
|
424
|
+
/** Does an agent hook in this project call `doctrina` by its bare name? */
|
|
425
|
+
function callsDoctrinaByName(projectRoot) {
|
|
426
|
+
return [".cursor/hooks.json", ".claude/settings.json"].some((rel) => {
|
|
427
|
+
const file = path.join(projectRoot, rel);
|
|
428
|
+
return isFile(file) && /"(command)":\s*"doctrina /.test(read(file));
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
380
432
|
export const help = `
|
|
381
433
|
Usage: doctrina doctor
|
|
382
434
|
|
|
@@ -391,6 +443,12 @@ exact remediation command.
|
|
|
391
443
|
enums. Reports membership only; a rejected value is never
|
|
392
444
|
printed, so this is safe to run and to paste.
|
|
393
445
|
|
|
446
|
+
Two rows read the machine and the clone, not the artifacts: \`cli\` names
|
|
447
|
+
the install that is running and any other \`doctrina\` the PATH holds — the
|
|
448
|
+
agent hooks call it by name, so the first one found is the one they run —
|
|
449
|
+
and \`merges\` says whether \`doctrina hooks install\` has set this clone up
|
|
450
|
+
to merge index.json and the ledger without a manual step.
|
|
451
|
+
|
|
394
452
|
A driver over the same collections the gates themselves render; it adds
|
|
395
453
|
no checks of its own, so it can never disagree with the gates it fronts,
|
|
396
454
|
and it runs entirely in this process — no subprocess, no re-parsing of
|
package/src/commands/hooks.js
CHANGED
|
@@ -11,6 +11,9 @@ import { flagBool } from "../lib/args.js";
|
|
|
11
11
|
import { c } from "../lib/colors.js";
|
|
12
12
|
import { suggest } from "../lib/suggest.js";
|
|
13
13
|
import { EXIT } from "../lib/exit-codes.js";
|
|
14
|
+
import { writeAttributes, configureDriver, configuredDriver } from "../lib/merge-setup.js";
|
|
15
|
+
import { shadowRootOf } from "../lib/workspace.js";
|
|
16
|
+
import { gitPathOf, repoPrefix } from "../lib/git.js";
|
|
14
17
|
|
|
15
18
|
const SUBCOMMANDS = ["install"];
|
|
16
19
|
|
|
@@ -33,16 +36,49 @@ export async function run(positional, flags) {
|
|
|
33
36
|
const force = flagBool(flags, "force", false);
|
|
34
37
|
const projectRoot = process.cwd();
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
// Asked of git, not of `.git/` (seventh audit, change 0250). In a worktree
|
|
40
|
+
// and in a submodule `.git` is a file, so `mkdir .git/hooks` crashed with
|
|
41
|
+
// ENOTDIR after the attributes were already written; and a project in a
|
|
42
|
+
// subfolder of its repository has no `.git` beside it at all, so it was
|
|
43
|
+
// told to `git init` inside a repository — while `doctor` kept sending it
|
|
44
|
+
// back here.
|
|
45
|
+
const prefix = repoPrefix(projectRoot);
|
|
46
|
+
const hooksDir = prefix === null ? null : gitPathOf(projectRoot, "hooks");
|
|
47
|
+
if (prefix === null || hooksDir === null) {
|
|
37
48
|
// A PRECONDITION, not a gate: nothing was measured and found wanting,
|
|
38
49
|
// the place to put the hook simply does not exist yet. Class 1 told an
|
|
39
50
|
// agent to fix its work and retry the same command, which never clears.
|
|
40
|
-
console.error(c.red("error:") + " not a git repository
|
|
51
|
+
console.error(c.red("error:") + " not a git repository — git does not answer for this folder");
|
|
41
52
|
console.error(c.gray("hint: ") + "run `git init` here, then `doctrina hooks install`");
|
|
42
53
|
return EXIT.PRECONDITION;
|
|
43
54
|
}
|
|
44
55
|
|
|
45
|
-
|
|
56
|
+
// The CLI that installed this, pinned by path (change 0116). `.git/` is
|
|
57
|
+
// local to the clone, so an absolute path is the right thing to write;
|
|
58
|
+
// a bare `doctrina` ran whichever release was on the PATH — a global
|
|
59
|
+
// 0.15.0 under a 0.15.1 tree rewrote the stamp backwards on every commit.
|
|
60
|
+
// `DOCTRINA=` in the environment overrides it for the hook.
|
|
61
|
+
const entry = path.resolve(here, "..", "index.js").replaceAll("\\", "/");
|
|
62
|
+
const bin = `node \"${entry}\"`;
|
|
63
|
+
|
|
64
|
+
// How the artifacts merge (change 0242). Before the hook, and whatever
|
|
65
|
+
// becomes of it: a pre-commit hook somebody else owns is no reason for
|
|
66
|
+
// two branches to stop on index.json. A shadowed repository keeps its
|
|
67
|
+
// `.doctrina/` out of its own git, so there is nothing of it to merge here.
|
|
68
|
+
if (shadowRootOf(projectRoot) === null) {
|
|
69
|
+
const added = writeAttributes(projectRoot);
|
|
70
|
+
if (added > 0) console.log(c.green("wrote") + ` .gitattributes — ${added} line${added === 1 ? "" : "s"}: index.json merges as data, the ledger as a list (commit it)`);
|
|
71
|
+
const before = configuredDriver(projectRoot);
|
|
72
|
+
const command = `node "${entry}"`;
|
|
73
|
+
if (before !== `${command} index merge %O %A %B`) {
|
|
74
|
+
if (configureDriver(projectRoot, command)) {
|
|
75
|
+
console.log(c.green("set") + " the index merge driver in this clone's git config (every clone runs `doctrina hooks install` once)");
|
|
76
|
+
} else {
|
|
77
|
+
console.log(c.yellow("warn:") + " git refused the merge driver; two branches that close changes will stop on index.json — `doctrina index rebuild` resolves it");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
46
82
|
mkdirp(hooksDir);
|
|
47
83
|
|
|
48
84
|
const target = path.join(hooksDir, "pre-commit");
|
|
@@ -53,13 +89,13 @@ export async function run(positional, flags) {
|
|
|
53
89
|
|
|
54
90
|
const templatesDir = locateTemplatesDir();
|
|
55
91
|
const tplPath = path.join(templatesDir, "hooks", "pre-commit.sample");
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
let body = substitute(read(tplPath), { DOCTRINA_BIN: bin });
|
|
93
|
+
// git runs a hook from the top of the repository. A project that lives
|
|
94
|
+
// below it is entered first, or `validate` would answer for a folder that
|
|
95
|
+
// is not a Doctrina project.
|
|
96
|
+
if (prefix !== "") {
|
|
97
|
+
body = body.replace(/^DOCTRINA=/m, `# This Doctrina project lives below the repository root.\ncd "${prefix}" || exit 1\n\nDOCTRINA=`);
|
|
98
|
+
}
|
|
63
99
|
write(target, body, { force: true });
|
|
64
100
|
chmodSync(target, 0o755);
|
|
65
101
|
console.log(c.green("installed") + ` ${relPath(projectRoot, target)}`);
|
|
@@ -72,7 +108,16 @@ export async function run(positional, flags) {
|
|
|
72
108
|
export const help = `
|
|
73
109
|
Usage: doctrina hooks install [--force]
|
|
74
110
|
|
|
75
|
-
|
|
111
|
+
Set the repository up for commits and merges:
|
|
112
|
+
|
|
113
|
+
- .gitattributes gains two lines (commit them): index.json merges through
|
|
114
|
+
\`doctrina index merge\`, as a list of artifacts, and the ledger through
|
|
115
|
+
git's own \`union\`, so two branches that each closed a change merge
|
|
116
|
+
without conflict markers in either file;
|
|
117
|
+
- this clone's git config gains the index merge driver, pinned to the CLI
|
|
118
|
+
that installed it — every clone runs \`doctrina hooks install\` once;
|
|
119
|
+
- .git/hooks/pre-commit gains the Doctrina hook.
|
|
120
|
+
|
|
76
121
|
The hook runs \`doctrina validate --fix\`: it regenerates index.json
|
|
77
122
|
from the tree (healing the most common gate failure — a hand-edited
|
|
78
123
|
header that drifted the index — and re-staging the repaired index)
|