doctrina-cli 0.20.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 +7 -7
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +202 -27
- package/src/commands/clarify.js +3 -206
- package/src/commands/close.js +1 -1
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/coverage.js +69 -6
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +83 -5
- package/src/commands/handoff.js +11 -2
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +64 -8
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +16 -1
- package/src/commands/next.js +41 -6
- package/src/commands/onboard.js +113 -0
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +40 -4
- package/src/commands/search.js +2 -2
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- 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 +62 -4
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +35 -7
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +90 -42
- package/src/commands/workspace.js +89 -66
- package/src/index.js +107 -62
- package/src/lib/actions.js +62 -3
- package/src/lib/adapters.js +83 -1
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +34 -2
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +594 -104
- package/src/lib/commands.js +58 -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/context-slice.js +34 -3
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +90 -12
- package/src/lib/docs-impact.js +37 -2
- package/src/lib/fs-ops.js +103 -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 +5 -2
- package/src/lib/json-out.js +9 -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/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +101 -8
- 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 +5 -4
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +67 -20
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +104 -17
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +83 -131
- package/templates/AGENTS.md.template +9 -5
- package/templates/README.md +4 -2
- 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/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/adapters/kiro/README.md +22 -0
- package/templates/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +12 -6
package/src/commands/delivery.js
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { relPath, write } from "../lib/fs-ops.js";
|
|
4
|
-
import { flagString } from "../lib/args.js";
|
|
3
|
+
import { eolOf, read, relPath, withEol, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
5
5
|
import { c } from "../lib/colors.js";
|
|
6
|
+
import { emitJson } from "../lib/json-out.js";
|
|
6
7
|
import { suggest } from "../lib/suggest.js";
|
|
7
8
|
import { today } from "../lib/dates.js";
|
|
8
9
|
import { EXIT, PreconditionError } from "../lib/exit-codes.js";
|
|
9
10
|
import { readTemplate, substitute } from "../lib/templates.js";
|
|
10
11
|
import { renderPlaybook } from "../lib/playbook.js";
|
|
11
|
-
import { findWorkspace, readWorkspace } from "../lib/workspace.js";
|
|
12
|
+
import { absorbCommand, findWorkspace, readWorkspace } from "../lib/workspace.js";
|
|
12
13
|
import {
|
|
13
14
|
deliveryBlockers, deliveryChanges, deliveryPath, dependencyOrder, listDeliveries, nextDeliveryId,
|
|
14
15
|
rankProjects, readDelivery, resolveDeliveryId, setDeliveryHeader, daysBetween, recordBlockers,
|
|
16
|
+
releaseBoxes, tickRelease, unroutable, otherWriters,
|
|
15
17
|
} from "../lib/delivery.js";
|
|
18
|
+
import { cdThen, qarg } from "../lib/shell.js";
|
|
16
19
|
|
|
17
20
|
// `doctrina delivery` — one item of work across repositories (ADR 0030).
|
|
18
21
|
// Decisions in lib/delivery.js; this module renders them.
|
|
19
22
|
|
|
20
|
-
const SUBCOMMANDS = ["new", "route", "list", "status", "check", "close"];
|
|
23
|
+
const SUBCOMMANDS = ["new", "route", "list", "status", "tick", "check", "close"];
|
|
21
24
|
|
|
22
|
-
export const flags = { boolean: ["json"], string: ["ref", "projects", "date"] };
|
|
25
|
+
export const flags = { boolean: ["json", "all", "force"], string: ["ref", "projects", "date"] };
|
|
26
|
+
// `delivery status` builds its own payload (change 0234); the rest answer in the envelope.
|
|
27
|
+
export const jsonNative = (/** @type {string[]} */ args) => args[0] === "status";
|
|
23
28
|
|
|
24
29
|
export async function run(positional, flags) {
|
|
25
30
|
const sub = positional[0];
|
|
@@ -43,8 +48,9 @@ export async function run(positional, flags) {
|
|
|
43
48
|
console.error(c.gray("hint: ") + "`doctrina delivery list` shows them");
|
|
44
49
|
return EXIT.USAGE;
|
|
45
50
|
}
|
|
46
|
-
if (sub === "route") return deliveryRoute(wsDir, ws, id, positional[2] ?? flagString(flags, "projects"));
|
|
47
|
-
if (sub === "
|
|
51
|
+
if (sub === "route") return deliveryRoute(wsDir, ws, id, positional[2] ?? flagString(flags, "projects"), flags);
|
|
52
|
+
if (sub === "tick") return deliveryTick(wsDir, id, positional.slice(2), flags);
|
|
53
|
+
if (sub === "status") return deliveryStatus(wsDir, ws, id, flagBool(flags, "json", false));
|
|
48
54
|
if (sub === "check") return deliveryCheck(wsDir, ws, id);
|
|
49
55
|
return deliveryClose(wsDir, ws, id, flags);
|
|
50
56
|
}
|
|
@@ -55,6 +61,20 @@ function parseProjects(ws, raw) {
|
|
|
55
61
|
return { names, unknown };
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
// A project with no spec, or whose repository is gone, cannot take a change
|
|
65
|
+
// for the delivery yet (change 0226). Refused unless --force, naming the fix.
|
|
66
|
+
function refuseUnroutable(wsDir, ws, names, flags) {
|
|
67
|
+
const blocked = unroutable(wsDir, ws, names);
|
|
68
|
+
if (blocked.length === 0 || flagBool(flags, "force", false)) return false;
|
|
69
|
+
for (const b of blocked) {
|
|
70
|
+
console.error(c.red("error:") + ` ${b.name} is ${b.state} (${b.reason}) — a change there would have no spec to change`);
|
|
71
|
+
console.error(c.gray("hint: ") + (b.state === "missing"
|
|
72
|
+
? `point the workspace at the clone: \`doctrina workspace attach ${b.name} --path <dir>\``
|
|
73
|
+
: `absorb it first: \`${absorbCommand(b, b.reason)}\` — or route anyway with --force`));
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
function deliveryNew(wsDir, ws, title, flags) {
|
|
59
79
|
if (!title) {
|
|
60
80
|
console.error(c.red("error:") + " delivery new needs a title");
|
|
@@ -69,6 +89,7 @@ function deliveryNew(wsDir, ws, title, flags) {
|
|
|
69
89
|
console.error(c.red("error:") + ` --projects names no registered project: ${unknown.join(", ")}`);
|
|
70
90
|
return EXIT.USAGE;
|
|
71
91
|
}
|
|
92
|
+
if (refuseUnroutable(wsDir, ws, names, flags)) return EXIT.PRECONDITION;
|
|
72
93
|
const id = nextDeliveryId(wsDir, title);
|
|
73
94
|
const ref = flagString(flags, "ref") ?? "—";
|
|
74
95
|
const ordered = dependencyOrder(ws, names);
|
|
@@ -95,6 +116,7 @@ function playbookTokens(wsDir, ws, delivery) {
|
|
|
95
116
|
return {
|
|
96
117
|
DELIVERY_ID: delivery.id,
|
|
97
118
|
DELIVERY_TITLE: delivery.title,
|
|
119
|
+
DELIVERY_TITLE_ARG: qarg(delivery.title),
|
|
98
120
|
DELIVERY_REF: delivery.ref === "—" ? "(no --ref given: ask the human which item this is)" : delivery.ref,
|
|
99
121
|
DELIVERY_FILE: relPath(wsDir, deliveryPath(wsDir, delivery.id)),
|
|
100
122
|
ROUTING: routing,
|
|
@@ -103,7 +125,7 @@ function playbookTokens(wsDir, ws, delivery) {
|
|
|
103
125
|
};
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
function deliveryRoute(wsDir, ws, id, raw) {
|
|
128
|
+
function deliveryRoute(wsDir, ws, id, raw, flags) {
|
|
107
129
|
const { names, unknown } = parseProjects(ws, raw);
|
|
108
130
|
if (names.length === 0) {
|
|
109
131
|
console.error(c.red("error:") + " delivery route needs the projects, comma-separated");
|
|
@@ -114,15 +136,35 @@ function deliveryRoute(wsDir, ws, id, raw) {
|
|
|
114
136
|
console.error(c.red("error:") + ` no registered project named ${unknown.join(", ")}`);
|
|
115
137
|
return EXIT.USAGE;
|
|
116
138
|
}
|
|
139
|
+
if (refuseUnroutable(wsDir, ws, names, flags)) return EXIT.PRECONDITION;
|
|
140
|
+
// Routing a project OUT while it carries an open change for the delivery
|
|
141
|
+
// (third audit, change 0234) orphaned the change: it kept its Delivery
|
|
142
|
+
// header, the delivery stopped seeing it, and the delivery could close
|
|
143
|
+
// around work still in flight.
|
|
144
|
+
const before = readDelivery(wsDir, id);
|
|
145
|
+
const dropped = before.projects.filter((n) => !names.includes(n));
|
|
146
|
+
const carrying = deliveryChanges(wsDir, ws, { ...before, projects: dropped })
|
|
147
|
+
.flatMap((row) => row.changes.filter((ch) => !ch.archived).map((ch) => ({ project: row.project, id: ch.id })));
|
|
148
|
+
if (carrying.length > 0 && !flagBool(flags, "force", false)) {
|
|
149
|
+
for (const x of carrying) {
|
|
150
|
+
console.error(c.red("error:") + ` ${x.project} carries ${x.id} for ${id} — routing it out would orphan the change`);
|
|
151
|
+
}
|
|
152
|
+
console.error(c.gray("hint: ") + `close or abandon it there first (\`doctrina change abandon <id> --reason "<why>" --force\`), or pass --force to route it out anyway`);
|
|
153
|
+
return EXIT.PRECONDITION;
|
|
154
|
+
}
|
|
117
155
|
const ordered = dependencyOrder(ws, names);
|
|
118
156
|
setDeliveryHeader(wsDir, id, "Projects", ordered.join(", "));
|
|
119
157
|
const d = readDelivery(wsDir, id);
|
|
120
158
|
console.log(c.green("routed") + ` ${id} → ${ordered.join(" → ")}` + c.gray(" (dependencies first)"));
|
|
159
|
+
for (const x of carrying) console.log(c.yellow("warn") + ` ${x.project}'s ${x.id} still names ${id} in its Delivery header — routed out with --force`);
|
|
121
160
|
console.log("");
|
|
161
|
+
const rows = deliveryChanges(wsDir, ws, d);
|
|
122
162
|
console.log("Open one change in each, in this order:");
|
|
123
163
|
for (const name of ordered) {
|
|
124
164
|
const p = ws.projects.find((x) => x.name === name);
|
|
125
|
-
|
|
165
|
+
const has = rows.find((r) => r.project === name)?.changes ?? [];
|
|
166
|
+
if (has.length > 0) console.log(" " + c.gray(`${name} already carries ${has.map((ch) => ch.id).join(", ")}`));
|
|
167
|
+
else console.log(" " + c.cyan(cdThen(p?.path ?? "", `doctrina work ${qarg(d.title)} --delivery ${id}`)));
|
|
126
168
|
}
|
|
127
169
|
return EXIT.OK;
|
|
128
170
|
}
|
|
@@ -151,19 +193,47 @@ function deliveryList(wsDir, ws) {
|
|
|
151
193
|
return EXIT.OK;
|
|
152
194
|
}
|
|
153
195
|
|
|
154
|
-
function deliveryStatus(wsDir, ws, id) {
|
|
196
|
+
function deliveryStatus(wsDir, ws, id, json = false) {
|
|
155
197
|
const d = readDelivery(wsDir, id);
|
|
198
|
+
// A structured payload for the planner's loop (third audit, change 0234):
|
|
199
|
+
// the envelope's text lines were all an agent got to branch on.
|
|
200
|
+
if (json) {
|
|
201
|
+
emitJson("delivery status", {
|
|
202
|
+
id: d.id, title: d.title, ref: d.ref, status: d.status, date: d.date ?? null, projects: d.projects,
|
|
203
|
+
rows: deliveryChanges(wsDir, ws, d).map((r) => ({
|
|
204
|
+
project: r.project, path: r.path ?? null, missing: r.missing ?? null, absent: r.absent ?? false,
|
|
205
|
+
changes: r.changes, abandoned: r.abandoned ?? [],
|
|
206
|
+
})),
|
|
207
|
+
otherWriters: otherWriters(wsDir, ws, d),
|
|
208
|
+
release: releaseBoxes(d.text).map(({ ordinal, checked, text }) => ({ ordinal, checked, text })),
|
|
209
|
+
blockers: deliveryBlockers(wsDir, ws, d),
|
|
210
|
+
});
|
|
211
|
+
return EXIT.OK;
|
|
212
|
+
}
|
|
156
213
|
console.log(c.bold(`Delivery ${d.id}`) + ` — ${d.title}` + c.gray(` (${d.status}, ref ${d.ref}, opened ${d.date ?? "?"})`));
|
|
157
214
|
console.log("");
|
|
158
215
|
for (const row of deliveryChanges(wsDir, ws, d)) {
|
|
159
216
|
console.log(` ${c.cyan(row.project)}${row.path ? c.gray(" " + row.path) : ""}`);
|
|
160
217
|
if (row.missing) console.log(c.red(` ${row.missing}`));
|
|
161
|
-
|
|
218
|
+
// A repository that moved: a `cd` into its old path fails (change 0234).
|
|
219
|
+
else if (row.absent) console.log(c.red(" repository not at its path") + c.gray(` — doctrina workspace attach ${row.project} --path <dir>`));
|
|
220
|
+
else if (row.changes.length === 0 && row.abandoned?.length) console.log(c.yellow(` abandoned ${row.abandoned.map((a) => a.id).join(", ")}`) + c.gray(` — re-route without ${row.project}, or ${cdThen(row.path, `doctrina work ${qarg(d.title)} --delivery ${d.id}`)}`));
|
|
221
|
+
else if (row.changes.length === 0) console.log(c.yellow(" no change yet") + c.gray(` — ${cdThen(row.path, `doctrina work ${qarg(d.title)} --delivery ${d.id}`)}`));
|
|
162
222
|
for (const ch of row.changes) {
|
|
163
223
|
const colour = ch.archived ? c.green : c.yellow;
|
|
164
224
|
console.log(` ${colour(ch.status.padEnd(9))} ${ch.id}` + c.gray(` tasks ${ch.tasksDone}/${ch.tasksTotal}`));
|
|
225
|
+
// The card is read from inside the repository (change 0226): from the
|
|
226
|
+
// workspace root there is no .doctrina/ to read it from.
|
|
227
|
+
if (!ch.archived && ch.placeholders > 0) console.log(c.gray(` tasks not written yet (placeholders): ${cdThen(row.path, `doctrina work --resume ${ch.id}`)}`));
|
|
228
|
+
else if (!ch.archived && ch.tasksDone < ch.tasksTotal) console.log(c.gray(` next card: ${cdThen(row.path, `doctrina change card ${ch.id}`)}`));
|
|
165
229
|
}
|
|
166
230
|
}
|
|
231
|
+
const others = otherWriters(wsDir, ws, d);
|
|
232
|
+
if (others.length > 0) {
|
|
233
|
+
console.log("");
|
|
234
|
+
console.log(c.yellow(" one writer per repository:") + c.gray(" these changes are open in the same repositories — sequence them, never two writers at once"));
|
|
235
|
+
for (const o of others) console.log(c.gray(` ${o.project}: ${o.id}${o.delivery ? ` (delivery ${o.delivery})` : " (no delivery)"}`));
|
|
236
|
+
}
|
|
167
237
|
const record = recordBlockers(d);
|
|
168
238
|
if (record.length > 0) {
|
|
169
239
|
console.log(` ${c.cyan("record")}`);
|
|
@@ -177,6 +247,43 @@ function deliveryStatus(wsDir, ws, id) {
|
|
|
177
247
|
return EXIT.OK;
|
|
178
248
|
}
|
|
179
249
|
|
|
250
|
+
// The Release claims are ticked through the CLI, like a change's boxes
|
|
251
|
+
// (change 0226): Story, Sources, Plan and Observability are prose the planner
|
|
252
|
+
// writes; a Release box is a claim `delivery check` holds it to.
|
|
253
|
+
function deliveryTick(wsDir, id, args, flags) {
|
|
254
|
+
const file = deliveryPath(wsDir, id);
|
|
255
|
+
const text = read(file);
|
|
256
|
+
const boxes = releaseBoxes(text);
|
|
257
|
+
if (boxes.length === 0) {
|
|
258
|
+
console.log(c.gray(`${id} has no box under ## Release`));
|
|
259
|
+
return EXIT.OK;
|
|
260
|
+
}
|
|
261
|
+
const all = flagBool(flags, "all", false);
|
|
262
|
+
if (!all && args.length === 0) {
|
|
263
|
+
console.log(c.bold(`Release boxes in ${id}`) + c.gray(` (${boxes.filter((b) => !b.checked).length} of ${boxes.length} unticked):`));
|
|
264
|
+
for (const b of boxes) console.log(` ${String(b.ordinal).padStart(2)}. [${b.checked ? "x" : " "}] ${b.text}`);
|
|
265
|
+
console.log(c.gray("tick: ") + c.cyan(`doctrina delivery tick ${id} <n...>`) + c.gray(" · all: ") + c.cyan(`doctrina delivery tick ${id} --all`));
|
|
266
|
+
return EXIT.OK;
|
|
267
|
+
}
|
|
268
|
+
const ordinals = all ? "all" : args.map(Number);
|
|
269
|
+
if (ordinals !== "all") {
|
|
270
|
+
const bad = args.filter((a, i) => !Number.isInteger(ordinals[i]) || ordinals[i] < 1 || ordinals[i] > boxes.length);
|
|
271
|
+
if (bad.length > 0) {
|
|
272
|
+
console.error(c.red("error:") + ` no Release box ${bad.join(", ")} (1..${boxes.length} — \`doctrina delivery tick ${id}\` lists them)`);
|
|
273
|
+
return EXIT.USAGE;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Count what this call changes: a box already ticked, or named twice, is
|
|
277
|
+
// not news, and "ticked 2" for one new tick is a claim the file contradicts.
|
|
278
|
+
const fresh = boxes.filter((b) => !b.checked && (ordinals === "all" || ordinals.includes(b.ordinal)));
|
|
279
|
+
if (fresh.length > 0) write(file, withEol(tickRelease(text, ordinals), eolOf(text)), { force: true });
|
|
280
|
+
const n = fresh.length;
|
|
281
|
+
console.log(n === 0
|
|
282
|
+
? c.gray(`nothing to tick in ${id} — every named Release box is already ticked`)
|
|
283
|
+
: c.green("ticked") + ` ${n} Release box${n === 1 ? "" : "es"} in ${id}`);
|
|
284
|
+
return EXIT.OK;
|
|
285
|
+
}
|
|
286
|
+
|
|
180
287
|
function deliveryCheck(wsDir, ws, id) {
|
|
181
288
|
const d = readDelivery(wsDir, id);
|
|
182
289
|
const blockers = deliveryBlockers(wsDir, ws, d);
|
|
@@ -206,7 +313,7 @@ function deliveryClose(wsDir, ws, id, flags) {
|
|
|
206
313
|
}
|
|
207
314
|
|
|
208
315
|
export const help = `
|
|
209
|
-
Usage: doctrina delivery <new|route|list|status|check|close> [options]
|
|
316
|
+
Usage: doctrina delivery <new|route|list|status|tick|check|close> [options]
|
|
210
317
|
|
|
211
318
|
One item of work across repositories (ADR 0030) — a story, a screen, a fix.
|
|
212
319
|
It lives in the workspace's deliveries/ and holds what no single repository
|
|
@@ -214,24 +321,35 @@ owns. Each project it touches gets ONE change, opened there with
|
|
|
214
321
|
\`doctrina work "<title>" --delivery <id>\`; the delivery's state is read
|
|
215
322
|
from those changes, never copied into it.
|
|
216
323
|
|
|
217
|
-
new "<title>" [--ref <tracker-id>] [--projects <a,b>]
|
|
324
|
+
new "<title>" [--ref <tracker-id>] [--projects <a,b>] [--force]
|
|
218
325
|
Create deliveries/<NNNN-slug>/delivery.md and print the orchestration
|
|
219
326
|
playbook: read the story, route, open one change per repository,
|
|
220
327
|
delegate the task cards, close each change, release, watch, close.
|
|
221
328
|
Without --projects it ranks the workspace's projects against the title.
|
|
222
|
-
route <id> <a,b>
|
|
329
|
+
route <id> <a,b> [--force]
|
|
223
330
|
Set the projects (dependencies first) and print the \`work\` line for each.
|
|
331
|
+
A project still pending (no spec) or missing is refused — absorb it with
|
|
332
|
+
\`doctrina intake --from-code\` first, or pass --force.
|
|
333
|
+
Dropping a project that carries an open change for the delivery is
|
|
334
|
+
refused too — close or abandon the change there first, or --force.
|
|
224
335
|
list
|
|
225
336
|
Every delivery with its status, ref, closed/total changes and age —
|
|
226
337
|
lead time once closed, with the median over the closed ones.
|
|
227
338
|
status <id>
|
|
228
|
-
Per project: the changes carrying this delivery, their state and tasks
|
|
339
|
+
Per project: the changes carrying this delivery, their state and tasks,
|
|
340
|
+
the command that reads the next task card, and any other open change
|
|
341
|
+
in the same repository — one writer per repository at a time.
|
|
342
|
+
--json: the same as a payload (rows, release boxes, blockers).
|
|
343
|
+
tick <id> [<n>... | --all]
|
|
344
|
+
List the ## Release boxes, or tick them: they are claims the check
|
|
345
|
+
holds you to, so they are ticked through the CLI.
|
|
229
346
|
check <id>
|
|
230
347
|
Exit 1 while a routed project has no change for it or one still open,
|
|
231
348
|
while ## Story is empty, a ## Release box is unticked, or
|
|
232
349
|
## Observability records nothing.
|
|
233
|
-
close <id>
|
|
234
|
-
The check, then Status: closed and the Closed date
|
|
350
|
+
close <id> [--date <YYYY-MM-DD>]
|
|
351
|
+
The check, then Status: closed and the Closed date (--date overrides
|
|
352
|
+
today, as it does for the Date that \`new\` writes).
|
|
235
353
|
|
|
236
354
|
<id> is the folder name or its number (\`1\`, \`0001\`).
|
|
237
355
|
`;
|
package/src/commands/doctor.js
CHANGED
|
@@ -8,7 +8,7 @@ import { c } from "../lib/colors.js";
|
|
|
8
8
|
import { flagBool } from "../lib/args.js";
|
|
9
9
|
import { collectRuntimeFindings, checkLocalEnv } from "../lib/runtime.js";
|
|
10
10
|
import { sequence, stepRerun } from "../lib/gates.js";
|
|
11
|
-
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
|
+
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
12
|
import { agentsMdBudget, collectValidation } from "../lib/validation-model.js";
|
|
13
13
|
import { collectIndexDrift } from "../lib/scan.js";
|
|
14
14
|
import { collectReproducibility } from "../lib/reproducibility.js";
|
|
@@ -17,6 +17,13 @@ import { USAGE_ENV, summarise } from "../lib/usage.js";
|
|
|
17
17
|
import { OPERATIONS } from "../lib/commands.js";
|
|
18
18
|
|
|
19
19
|
import { coverageLabel } from "../lib/views.js";
|
|
20
|
+
import { workspaceFindings, workspaceRootAt, workspaceSnapshot } from "../lib/workspace-view.js";
|
|
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";
|
|
20
27
|
|
|
21
28
|
// How many never-invoked operations the usage row names before pointing at
|
|
22
29
|
// `metrics --commands` for the rest. The spec states this number, so it is
|
|
@@ -43,6 +50,24 @@ export const flags = { boolean: ["json", "env"], string: [] };
|
|
|
43
50
|
|
|
44
51
|
export async function run(_positional, _flags) {
|
|
45
52
|
const projectRoot = process.cwd();
|
|
53
|
+
// At the workspace root (fourth audit, change 0235): the planner reaches
|
|
54
|
+
// for doctor when something looks wrong, and was told to `doctrina init`
|
|
55
|
+
// — a project inside the workspace. The root's diagnosis is the
|
|
56
|
+
// workspace's own integrity; each repository diagnoses itself.
|
|
57
|
+
const wsRoot = workspaceRootAt(projectRoot);
|
|
58
|
+
if (wsRoot) {
|
|
59
|
+
const snap = workspaceSnapshot(wsRoot);
|
|
60
|
+
const { errors, warnings } = workspaceFindings(snap);
|
|
61
|
+
console.log(c.bold("Doctrina doctor") + c.gray(` — workspace "${snap.ws.name}" (read-only):`));
|
|
62
|
+
console.log("");
|
|
63
|
+
for (const e of errors) console.log(` ${c.red("error")} ${e}`);
|
|
64
|
+
for (const w of warnings) console.log(` ${c.yellow("warn ")} ${w}`);
|
|
65
|
+
if (errors.length + warnings.length === 0) console.log(` ${c.green("ok ")} the workspace is consistent`);
|
|
66
|
+
console.log("");
|
|
67
|
+
console.log("Each repository diagnoses itself:");
|
|
68
|
+
for (const p of snap.projects.filter((x) => x.state !== "missing")) console.log(" " + c.cyan(cdThen(p.project.path, "doctrina doctor")));
|
|
69
|
+
return errors.length > 0 ? EXIT.GATE : EXIT.OK;
|
|
70
|
+
}
|
|
46
71
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
47
72
|
throw notADoctrinaProject();
|
|
48
73
|
}
|
|
@@ -256,9 +281,9 @@ export async function run(_positional, _flags) {
|
|
|
256
281
|
" (run `doctrina verify` to execute)";
|
|
257
282
|
if (stale > 0) {
|
|
258
283
|
const parts = [];
|
|
259
|
-
if (so
|
|
260
|
-
if (so
|
|
261
|
-
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`);
|
|
262
287
|
row("warn", "verify config", `${detail} — ${parts.join(", ")}`,
|
|
263
288
|
"doctrina verify (each names the re-sign command)");
|
|
264
289
|
warningsTotal += 1;
|
|
@@ -286,7 +311,7 @@ export async function run(_positional, _flags) {
|
|
|
286
311
|
if (cfg.errors.length > 0) {
|
|
287
312
|
row("fail", "config", cfg.errors[0], "fix .doctrina/config.json, then `doctrina validate`");
|
|
288
313
|
} else if (cfg.unknown.length > 0) {
|
|
289
|
-
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");
|
|
290
315
|
warningsTotal += 1;
|
|
291
316
|
} else {
|
|
292
317
|
row("ok", "config", summary);
|
|
@@ -296,6 +321,45 @@ export async function run(_positional, _flags) {
|
|
|
296
321
|
}
|
|
297
322
|
},
|
|
298
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
|
+
|
|
299
363
|
// 10. Which of the catalog's operations have never been reached for —
|
|
300
364
|
// but ONLY when the operator turned the log on. `lib/usage.js` was
|
|
301
365
|
// built to instrument the surface before shrinking it, and the only
|
|
@@ -357,6 +421,14 @@ export async function run(_positional, _flags) {
|
|
|
357
421
|
return 0;
|
|
358
422
|
}
|
|
359
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
|
+
|
|
360
432
|
export const help = `
|
|
361
433
|
Usage: doctrina doctor
|
|
362
434
|
|
|
@@ -371,6 +443,12 @@ exact remediation command.
|
|
|
371
443
|
enums. Reports membership only; a rejected value is never
|
|
372
444
|
printed, so this is safe to run and to paste.
|
|
373
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
|
+
|
|
374
452
|
A driver over the same collections the gates themselves render; it adds
|
|
375
453
|
no checks of its own, so it can never disagree with the gates it fronts,
|
|
376
454
|
and it runs entirely in this process — no subprocess, no re-parsing of
|
package/src/commands/handoff.js
CHANGED
|
@@ -6,6 +6,8 @@ import { c } from "../lib/colors.js";
|
|
|
6
6
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
7
7
|
import { collectSnapshot } from "../lib/snapshot.js";
|
|
8
8
|
import { renderView } from "../lib/views.js";
|
|
9
|
+
import { contextRouting, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
|
|
10
|
+
import { today } from "../lib/dates.js";
|
|
9
11
|
|
|
10
12
|
// Session handoff note: everything the NEXT session (a fresh agent, a
|
|
11
13
|
// teammate, tomorrow's you) needs to resume without re-reading the tree —
|
|
@@ -27,10 +29,14 @@ export const flags = { boolean: ["json"], string: [] };
|
|
|
27
29
|
|
|
28
30
|
export async function run(_positional, _flags) {
|
|
29
31
|
const projectRoot = process.cwd();
|
|
30
|
-
|
|
32
|
+
const wsRoot = workspaceRootAt(projectRoot);
|
|
33
|
+
if (!wsRoot && !exists(path.join(projectRoot, ".doctrina"))) {
|
|
31
34
|
throw notADoctrinaProject();
|
|
32
35
|
}
|
|
33
|
-
|
|
36
|
+
// At a workspace root: the planner's resume note — projects, open
|
|
37
|
+
// deliveries and their changes, the next actions (change 0230).
|
|
38
|
+
const lines = wsRoot ? workspaceHandoff(workspaceSnapshot(wsRoot), today()) : renderView("handoff", collectSnapshot(projectRoot));
|
|
39
|
+
for (const line of lines) console.log(line);
|
|
34
40
|
if (process.stdout.isTTY) {
|
|
35
41
|
console.log("");
|
|
36
42
|
console.log(c.gray("tip: pipe it — `doctrina handoff > note.md` — or paste it as the first message of the next session"));
|
|
@@ -46,6 +52,9 @@ with its task-by-task progress and the exact resume command, and the
|
|
|
46
52
|
prioritised next actions. Made to be pasted as the first message of the
|
|
47
53
|
next agent session (or piped to a file).
|
|
48
54
|
|
|
55
|
+
At a workspace root it is the planner's note instead: projects and
|
|
56
|
+
their state, open deliveries with each repository's changes, next actions.
|
|
57
|
+
|
|
49
58
|
The same view as \`doctrina status --view handoff\`; both render one
|
|
50
59
|
collection of the tree.
|
|
51
60
|
|
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)
|
|
@@ -10,8 +10,10 @@ import { c } from "../lib/colors.js";
|
|
|
10
10
|
import { suggest } from "../lib/suggest.js";
|
|
11
11
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
12
|
import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
|
|
13
|
+
import { mergeIndex } from "../lib/index-merge.js";
|
|
14
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
13
15
|
|
|
14
|
-
const SUBCOMMANDS = ["rebuild"];
|
|
16
|
+
const SUBCOMMANDS = ["rebuild", "merge"];
|
|
15
17
|
|
|
16
18
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
17
19
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -20,6 +22,7 @@ export const flags = { boolean: ["json", "check", "staged"], string: [] };
|
|
|
20
22
|
|
|
21
23
|
export async function run(positional, flags) {
|
|
22
24
|
const sub = positional[0];
|
|
25
|
+
if (sub === "merge") return merge(positional.slice(1));
|
|
23
26
|
if (sub !== "rebuild") {
|
|
24
27
|
console.error(c.red("error:") + ` unknown index subcommand "${sub ?? ""}"`);
|
|
25
28
|
const guess = suggest(sub, SUBCOMMANDS);
|
|
@@ -98,6 +101,43 @@ function rebuild(flags) {
|
|
|
98
101
|
return 0;
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
// The merge driver git calls for `.doctrina/index.json` (change 0242):
|
|
105
|
+
// `doctrina index merge %O %A %B`. It writes the merged index over <ours>
|
|
106
|
+
// and exits 0, which is how a driver tells git there is nothing to resolve.
|
|
107
|
+
// A side that is not JSON is the one case it leaves to git, with exit 1.
|
|
108
|
+
function merge(args) {
|
|
109
|
+
const [basePath, oursPath, theirsPath] = args;
|
|
110
|
+
if (!basePath || !oursPath || !theirsPath) {
|
|
111
|
+
console.error(c.red("error:") + " index merge takes three files: <base> <ours> <theirs>");
|
|
112
|
+
console.error(c.gray("hint: ") + "git calls it as a merge driver — `doctrina hooks install` sets it up");
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
const load = (/** @type {string} */ file, /** @type {string} */ side) => {
|
|
116
|
+
let text = "";
|
|
117
|
+
try {
|
|
118
|
+
text = readFileSync(file, "utf8");
|
|
119
|
+
} catch {
|
|
120
|
+
if (side === "base") return {};
|
|
121
|
+
throw new Error(`cannot read the ${side} index at ${file}`);
|
|
122
|
+
}
|
|
123
|
+
if (text.trim() === "") return {};
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(text);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new Error(`the ${side} index is not valid JSON (${err.message})`);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
let merged;
|
|
131
|
+
try {
|
|
132
|
+
merged = mergeIndex(load(basePath, "base"), load(oursPath, "ours"), load(theirsPath, "theirs"));
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error(c.red("error:") + ` ${err.message} — left for a manual merge; \`doctrina index rebuild\` derives it from the tree afterwards`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
writeFileSync(oursPath, JSON.stringify(merged, null, 2) + "\n");
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
101
141
|
// The drift check, asked of what git will record. `index.json` is derived
|
|
102
142
|
// from the WHOLE tree, so it cannot describe a subset of it: stage part of
|
|
103
143
|
// `.doctrina/` and the index riding along names artifacts the commit does
|
|
@@ -135,6 +175,12 @@ export const help = `
|
|
|
135
175
|
Usage: doctrina index <subcommand>
|
|
136
176
|
|
|
137
177
|
Subcommands:
|
|
178
|
+
merge <base> <ours> <theirs>
|
|
179
|
+
The merge driver git runs for index.json once
|
|
180
|
+
\`doctrina hooks install\` has set it up: two branches
|
|
181
|
+
that each closed a change merge as lists of artifacts,
|
|
182
|
+
so the file never stops a merge with conflict markers.
|
|
183
|
+
Writes the result over <ours>.
|
|
138
184
|
rebuild Regenerate .doctrina/index.json from the artifacts on
|
|
139
185
|
disk (spec headers, ADR headers, change proposals,
|
|
140
186
|
archive folders, skill frontmatter). The files are the
|