doctrina-cli 0.18.0 → 0.21.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 +3 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +84 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +164 -17
- package/src/commands/clarify.js +1 -204
- package/src/commands/close.js +18 -1
- package/src/commands/context.js +81 -9
- package/src/commands/contract.js +1 -0
- package/src/commands/delivery.js +356 -0
- package/src/commands/doctor.js +21 -1
- package/src/commands/handoff.js +11 -2
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +28 -1
- package/src/commands/intake.js +60 -3
- package/src/commands/metrics.js +43 -3
- package/src/commands/next.js +41 -6
- package/src/commands/prime.js +125 -7
- package/src/commands/review.js +39 -3
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/upgrade.js +33 -0
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +32 -4
- package/src/commands/work.js +126 -7
- package/src/commands/workspace.js +419 -0
- package/src/index.js +100 -66
- package/src/lib/actions.js +44 -4
- package/src/lib/adapters.js +143 -1
- package/src/lib/args.js +33 -1
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +770 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +149 -0
- package/src/lib/delivery.js +286 -0
- package/src/lib/docs-impact.js +36 -1
- package/src/lib/fs-ops.js +23 -0
- package/src/lib/intake-model.js +1 -1
- package/src/lib/json-out.js +9 -1
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/shell.js +45 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +43 -2
- package/src/lib/task-card.js +195 -0
- package/src/lib/views.js +52 -3
- package/src/lib/work-model.js +66 -2
- package/src/lib/workspace-agents.js +306 -0
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +160 -0
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +323 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +11 -1
- package/templates/adapters/claude/.claude/settings.json.template +18 -0
- package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
- 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/{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/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +63 -0
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +44 -0
- package/templates/playbooks/work.md.template +1 -1
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { eolOf, read, relPath, withEol, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
5
|
+
import { c } from "../lib/colors.js";
|
|
6
|
+
import { emitJson } from "../lib/json-out.js";
|
|
7
|
+
import { suggest } from "../lib/suggest.js";
|
|
8
|
+
import { today } from "../lib/dates.js";
|
|
9
|
+
import { EXIT, PreconditionError } from "../lib/exit-codes.js";
|
|
10
|
+
import { readTemplate, substitute } from "../lib/templates.js";
|
|
11
|
+
import { renderPlaybook } from "../lib/playbook.js";
|
|
12
|
+
import { absorbCommand, findWorkspace, readWorkspace } from "../lib/workspace.js";
|
|
13
|
+
import {
|
|
14
|
+
deliveryBlockers, deliveryChanges, deliveryPath, dependencyOrder, listDeliveries, nextDeliveryId,
|
|
15
|
+
rankProjects, readDelivery, resolveDeliveryId, setDeliveryHeader, daysBetween, recordBlockers,
|
|
16
|
+
releaseBoxes, tickRelease, unroutable, otherWriters,
|
|
17
|
+
} from "../lib/delivery.js";
|
|
18
|
+
import { cdThen, qarg } from "../lib/shell.js";
|
|
19
|
+
|
|
20
|
+
// `doctrina delivery` — one item of work across repositories (ADR 0030).
|
|
21
|
+
// Decisions in lib/delivery.js; this module renders them.
|
|
22
|
+
|
|
23
|
+
const SUBCOMMANDS = ["new", "route", "list", "status", "tick", "check", "close"];
|
|
24
|
+
|
|
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";
|
|
28
|
+
|
|
29
|
+
export async function run(positional, flags) {
|
|
30
|
+
const sub = positional[0];
|
|
31
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
32
|
+
console.error(c.red("error:") + ` unknown delivery subcommand "${sub ?? ""}"`);
|
|
33
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
34
|
+
console.error(c.gray("hint: ") + (guess ? `did you mean \`doctrina delivery ${guess}\`?` : `available: ${SUBCOMMANDS.join(", ")}`));
|
|
35
|
+
return EXIT.USAGE;
|
|
36
|
+
}
|
|
37
|
+
const found = findWorkspace(process.cwd());
|
|
38
|
+
if (!found) {
|
|
39
|
+
throw new PreconditionError("a delivery lives in a workspace, and none was found here", "doctrina workspace init <dir>");
|
|
40
|
+
}
|
|
41
|
+
const wsDir = found.dir;
|
|
42
|
+
const ws = readWorkspace(wsDir);
|
|
43
|
+
if (sub === "new") return deliveryNew(wsDir, ws, positional.slice(1).join(" ").trim(), flags);
|
|
44
|
+
if (sub === "list") return deliveryList(wsDir, ws);
|
|
45
|
+
const id = resolveDeliveryId(wsDir, positional[1]);
|
|
46
|
+
if (!id) {
|
|
47
|
+
console.error(c.red("error:") + ` no delivery "${positional[1] ?? ""}" in this workspace`);
|
|
48
|
+
console.error(c.gray("hint: ") + "`doctrina delivery list` shows them");
|
|
49
|
+
return EXIT.USAGE;
|
|
50
|
+
}
|
|
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));
|
|
54
|
+
if (sub === "check") return deliveryCheck(wsDir, ws, id);
|
|
55
|
+
return deliveryClose(wsDir, ws, id, flags);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseProjects(ws, raw) {
|
|
59
|
+
const names = String(raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
60
|
+
const unknown = names.filter((n) => !ws.projects.some((p) => p.name === n));
|
|
61
|
+
return { names, unknown };
|
|
62
|
+
}
|
|
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
|
+
|
|
78
|
+
function deliveryNew(wsDir, ws, title, flags) {
|
|
79
|
+
if (!title) {
|
|
80
|
+
console.error(c.red("error:") + " delivery new needs a title");
|
|
81
|
+
console.error(c.gray("hint: ") + 'doctrina delivery new "Login screen" --ref SHOP-123');
|
|
82
|
+
return EXIT.USAGE;
|
|
83
|
+
}
|
|
84
|
+
if (ws.projects.length === 0) {
|
|
85
|
+
throw new PreconditionError("the workspace has no project to deliver into", "doctrina workspace add <path>");
|
|
86
|
+
}
|
|
87
|
+
const { names, unknown } = parseProjects(ws, flagString(flags, "projects"));
|
|
88
|
+
if (unknown.length > 0) {
|
|
89
|
+
console.error(c.red("error:") + ` --projects names no registered project: ${unknown.join(", ")}`);
|
|
90
|
+
return EXIT.USAGE;
|
|
91
|
+
}
|
|
92
|
+
if (refuseUnroutable(wsDir, ws, names, flags)) return EXIT.PRECONDITION;
|
|
93
|
+
const id = nextDeliveryId(wsDir, title);
|
|
94
|
+
const ref = flagString(flags, "ref") ?? "—";
|
|
95
|
+
const ordered = dependencyOrder(ws, names);
|
|
96
|
+
const tpl = readTemplate(wsDir, "delivery.md.template");
|
|
97
|
+
const file = deliveryPath(wsDir, id);
|
|
98
|
+
write(file, substitute(tpl.body, {
|
|
99
|
+
DELIVERY_ID: id, DELIVERY_TITLE: title, DATE: flagString(flags, "date") ?? today(),
|
|
100
|
+
DELIVERY_REF: ref, DELIVERY_PROJECTS: ordered.length ? ordered.join(", ") : "—",
|
|
101
|
+
}));
|
|
102
|
+
console.log(c.green("created") + ` ${relPath(wsDir, file)}`);
|
|
103
|
+
console.log("");
|
|
104
|
+
console.log(renderPlaybook(wsDir, "delivery", playbookTokens(wsDir, ws, readDelivery(wsDir, id))));
|
|
105
|
+
return EXIT.OK;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function playbookTokens(wsDir, ws, delivery) {
|
|
109
|
+
const ranked = rankProjects(wsDir, ws, `${delivery.title} ${delivery.ref}`);
|
|
110
|
+
const routing = delivery.projects.length > 0
|
|
111
|
+
? ` routed: ${dependencyOrder(ws, delivery.projects).join(" → ")} — route again to change it`
|
|
112
|
+
: ranked.map(({ project, score, shared }) =>
|
|
113
|
+
` ${c.cyan(project.name.padEnd(16))} ${score > 0 ? `shares ${shared.join(", ")}` : c.gray("no shared term")}`
|
|
114
|
+
+ c.gray(` · ${project.role || "no role"}${project.dependsOn?.length ? ` · depends on ${project.dependsOn.join(", ")}` : ""}`)).join("\n");
|
|
115
|
+
const tools = ws.tools.filter((t) => t.enabled !== false);
|
|
116
|
+
return {
|
|
117
|
+
DELIVERY_ID: delivery.id,
|
|
118
|
+
DELIVERY_TITLE: delivery.title,
|
|
119
|
+
DELIVERY_TITLE_ARG: qarg(delivery.title),
|
|
120
|
+
DELIVERY_REF: delivery.ref === "—" ? "(no --ref given: ask the human which item this is)" : delivery.ref,
|
|
121
|
+
DELIVERY_FILE: relPath(wsDir, deliveryPath(wsDir, delivery.id)),
|
|
122
|
+
ROUTING: routing,
|
|
123
|
+
TOOLS: tools.length === 0 ? "" : " Tools this workspace declares:\n" + tools.map((t) =>
|
|
124
|
+
` ${c.cyan(t.name.padEnd(12))} ${t.access === "write" ? "read/write (writes need the human)" : "read-only"} — ${t.purpose || t.server}`).join("\n"),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function deliveryRoute(wsDir, ws, id, raw, flags) {
|
|
129
|
+
const { names, unknown } = parseProjects(ws, raw);
|
|
130
|
+
if (names.length === 0) {
|
|
131
|
+
console.error(c.red("error:") + " delivery route needs the projects, comma-separated");
|
|
132
|
+
console.error(c.gray("hint: ") + `doctrina delivery route ${id} back-end,front-core`);
|
|
133
|
+
return EXIT.USAGE;
|
|
134
|
+
}
|
|
135
|
+
if (unknown.length > 0) {
|
|
136
|
+
console.error(c.red("error:") + ` no registered project named ${unknown.join(", ")}`);
|
|
137
|
+
return EXIT.USAGE;
|
|
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
|
+
}
|
|
155
|
+
const ordered = dependencyOrder(ws, names);
|
|
156
|
+
setDeliveryHeader(wsDir, id, "Projects", ordered.join(", "));
|
|
157
|
+
const d = readDelivery(wsDir, id);
|
|
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`);
|
|
160
|
+
console.log("");
|
|
161
|
+
const rows = deliveryChanges(wsDir, ws, d);
|
|
162
|
+
console.log("Open one change in each, in this order:");
|
|
163
|
+
for (const name of ordered) {
|
|
164
|
+
const p = ws.projects.find((x) => x.name === name);
|
|
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}`)));
|
|
168
|
+
}
|
|
169
|
+
return EXIT.OK;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function deliveryList(wsDir, ws) {
|
|
173
|
+
const all = listDeliveries(wsDir);
|
|
174
|
+
if (all.length === 0) {
|
|
175
|
+
console.log(c.gray("no delivery yet — `doctrina delivery new \"<title>\" --ref <tracker-id>`"));
|
|
176
|
+
return EXIT.OK;
|
|
177
|
+
}
|
|
178
|
+
for (const d of all) {
|
|
179
|
+
const rows = deliveryChanges(wsDir, ws, d);
|
|
180
|
+
const changes = rows.flatMap((r) => r.changes);
|
|
181
|
+
const closed = changes.filter((ch) => ch.archived).length;
|
|
182
|
+
const colour = d.status === "closed" ? c.green : c.yellow;
|
|
183
|
+
const days = daysBetween(d.date, d.status === "closed" ? d.closed : today());
|
|
184
|
+
const age = days === null ? "" : d.status === "closed" ? `, lead time ${days}d` : `, open ${days}d`;
|
|
185
|
+
console.log(` ${c.cyan(d.id.padEnd(30))} ${colour(d.status.padEnd(7))} ${d.ref.padEnd(12)} ${d.projects.join(", ") || "—"}` +
|
|
186
|
+
c.gray(` ${closed}/${changes.length} changes closed${age}`));
|
|
187
|
+
}
|
|
188
|
+
const leads = all.filter((d) => d.status === "closed").map((d) => daysBetween(d.date, d.closed)).filter((n) => n !== null).sort((a, b) => a - b);
|
|
189
|
+
if (leads.length > 0) {
|
|
190
|
+
console.log("");
|
|
191
|
+
console.log(c.gray(`${leads.length} closed · median lead time ${leads[Math.floor((leads.length - 1) / 2)]}d · longest ${leads[leads.length - 1]}d`));
|
|
192
|
+
}
|
|
193
|
+
return EXIT.OK;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function deliveryStatus(wsDir, ws, id, json = false) {
|
|
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
|
+
}
|
|
213
|
+
console.log(c.bold(`Delivery ${d.id}`) + ` — ${d.title}` + c.gray(` (${d.status}, ref ${d.ref}, opened ${d.date ?? "?"})`));
|
|
214
|
+
console.log("");
|
|
215
|
+
for (const row of deliveryChanges(wsDir, ws, d)) {
|
|
216
|
+
console.log(` ${c.cyan(row.project)}${row.path ? c.gray(" " + row.path) : ""}`);
|
|
217
|
+
if (row.missing) console.log(c.red(` ${row.missing}`));
|
|
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}`)}`));
|
|
222
|
+
for (const ch of row.changes) {
|
|
223
|
+
const colour = ch.archived ? c.green : c.yellow;
|
|
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}`)}`));
|
|
229
|
+
}
|
|
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
|
+
}
|
|
237
|
+
const record = recordBlockers(d);
|
|
238
|
+
if (record.length > 0) {
|
|
239
|
+
console.log(` ${c.cyan("record")}`);
|
|
240
|
+
for (const r of record) console.log(c.yellow(" ") + r);
|
|
241
|
+
}
|
|
242
|
+
const blockers = deliveryBlockers(wsDir, ws, d);
|
|
243
|
+
console.log("");
|
|
244
|
+
console.log(blockers.length === 0
|
|
245
|
+
? c.green("ready to close") + ` — doctrina delivery close ${d.id}`
|
|
246
|
+
: c.gray(`${blockers.length} thing${blockers.length === 1 ? "" : "s"} before the close — doctrina delivery check ${d.id}`));
|
|
247
|
+
return EXIT.OK;
|
|
248
|
+
}
|
|
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
|
+
|
|
287
|
+
function deliveryCheck(wsDir, ws, id) {
|
|
288
|
+
const d = readDelivery(wsDir, id);
|
|
289
|
+
const blockers = deliveryBlockers(wsDir, ws, d);
|
|
290
|
+
if (blockers.length === 0) {
|
|
291
|
+
console.log(c.green("ok") + ` ${id} is ready to close: doctrina delivery close ${id}`);
|
|
292
|
+
return EXIT.OK;
|
|
293
|
+
}
|
|
294
|
+
for (const b of blockers) console.log(c.red("✗ ") + b);
|
|
295
|
+
console.log("");
|
|
296
|
+
console.log(c.red("fail") + ` ${blockers.length} blocker${blockers.length === 1 ? "" : "s"}`);
|
|
297
|
+
return EXIT.GATE;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function deliveryClose(wsDir, ws, id, flags) {
|
|
301
|
+
const d = readDelivery(wsDir, id);
|
|
302
|
+
if (d.status === "closed") {
|
|
303
|
+
console.log(c.gray(`${id} is already closed (${d.closed ?? "?"})`));
|
|
304
|
+
return EXIT.OK;
|
|
305
|
+
}
|
|
306
|
+
if (deliveryCheck(wsDir, ws, id) !== EXIT.OK) return EXIT.GATE;
|
|
307
|
+
const date = flagString(flags, "date") ?? today();
|
|
308
|
+
setDeliveryHeader(wsDir, id, "Status", "closed");
|
|
309
|
+
setDeliveryHeader(wsDir, id, "Closed", date);
|
|
310
|
+
console.log(c.green("closed") + ` ${id}`);
|
|
311
|
+
console.log(`Tell the human: delivery ${id} is closed — ${d.projects.length} project${d.projects.length === 1 ? "" : "s"} (${d.projects.join(", ")}); record at ${relPath(wsDir, deliveryPath(wsDir, id))}. Commit the workspace.`);
|
|
312
|
+
return EXIT.OK;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export const help = `
|
|
316
|
+
Usage: doctrina delivery <new|route|list|status|tick|check|close> [options]
|
|
317
|
+
|
|
318
|
+
One item of work across repositories (ADR 0030) — a story, a screen, a fix.
|
|
319
|
+
It lives in the workspace's deliveries/ and holds what no single repository
|
|
320
|
+
owns. Each project it touches gets ONE change, opened there with
|
|
321
|
+
\`doctrina work "<title>" --delivery <id>\`; the delivery's state is read
|
|
322
|
+
from those changes, never copied into it.
|
|
323
|
+
|
|
324
|
+
new "<title>" [--ref <tracker-id>] [--projects <a,b>] [--force]
|
|
325
|
+
Create deliveries/<NNNN-slug>/delivery.md and print the orchestration
|
|
326
|
+
playbook: read the story, route, open one change per repository,
|
|
327
|
+
delegate the task cards, close each change, release, watch, close.
|
|
328
|
+
Without --projects it ranks the workspace's projects against the title.
|
|
329
|
+
route <id> <a,b> [--force]
|
|
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.
|
|
335
|
+
list
|
|
336
|
+
Every delivery with its status, ref, closed/total changes and age —
|
|
337
|
+
lead time once closed, with the median over the closed ones.
|
|
338
|
+
status <id>
|
|
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.
|
|
346
|
+
check <id>
|
|
347
|
+
Exit 1 while a routed project has no change for it or one still open,
|
|
348
|
+
while ## Story is empty, a ## Release box is unticked, or
|
|
349
|
+
## Observability records nothing.
|
|
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).
|
|
353
|
+
|
|
354
|
+
<id> is the folder name or its number (\`1\`, \`0001\`).
|
|
355
|
+
`;
|
|
356
|
+
|
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,8 @@ 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";
|
|
20
22
|
|
|
21
23
|
// How many never-invoked operations the usage row names before pointing at
|
|
22
24
|
// `metrics --commands` for the rest. The spec states this number, so it is
|
|
@@ -43,6 +45,24 @@ export const flags = { boolean: ["json", "env"], string: [] };
|
|
|
43
45
|
|
|
44
46
|
export async function run(_positional, _flags) {
|
|
45
47
|
const projectRoot = process.cwd();
|
|
48
|
+
// At the workspace root (fourth audit, change 0235): the planner reaches
|
|
49
|
+
// for doctor when something looks wrong, and was told to `doctrina init`
|
|
50
|
+
// — a project inside the workspace. The root's diagnosis is the
|
|
51
|
+
// workspace's own integrity; each repository diagnoses itself.
|
|
52
|
+
const wsRoot = workspaceRootAt(projectRoot);
|
|
53
|
+
if (wsRoot) {
|
|
54
|
+
const snap = workspaceSnapshot(wsRoot);
|
|
55
|
+
const { errors, warnings } = workspaceFindings(snap);
|
|
56
|
+
console.log(c.bold("Doctrina doctor") + c.gray(` — workspace "${snap.ws.name}" (read-only):`));
|
|
57
|
+
console.log("");
|
|
58
|
+
for (const e of errors) console.log(` ${c.red("error")} ${e}`);
|
|
59
|
+
for (const w of warnings) console.log(` ${c.yellow("warn ")} ${w}`);
|
|
60
|
+
if (errors.length + warnings.length === 0) console.log(` ${c.green("ok ")} the workspace is consistent`);
|
|
61
|
+
console.log("");
|
|
62
|
+
console.log("Each repository diagnoses itself:");
|
|
63
|
+
for (const p of snap.projects.filter((x) => x.state !== "missing")) console.log(" " + c.cyan(cdThen(p.project.path, "doctrina doctor")));
|
|
64
|
+
return errors.length > 0 ? EXIT.GATE : EXIT.OK;
|
|
65
|
+
}
|
|
46
66
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
47
67
|
throw notADoctrinaProject();
|
|
48
68
|
}
|
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
|
|
|
@@ -9,6 +9,7 @@ import { flagBool } from "../lib/args.js";
|
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
10
10
|
import { suggest } from "../lib/suggest.js";
|
|
11
11
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
|
+
import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
|
|
12
13
|
|
|
13
14
|
const SUBCOMMANDS = ["rebuild"];
|
|
14
15
|
|
|
@@ -56,6 +57,23 @@ function rebuild(flags) {
|
|
|
56
57
|
console.log(c.yellow("warn:") + ` ${state.unreadable} — rebuilding from scratch`);
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
// The skills, where the agents look for them (change 0212). Exported on
|
|
61
|
+
// every rebuild; under --check a stale export is a NOTE, never a failure —
|
|
62
|
+
// the index gate is about the index, and a project upgrading Doctrina must
|
|
63
|
+
// not go red in CI before its first rebuild.
|
|
64
|
+
const exportPlan = planSkillExport(projectRoot);
|
|
65
|
+
const exportChanges = exportPlan.writes.length + exportPlan.removes.length;
|
|
66
|
+
if (exportChanges > 0) {
|
|
67
|
+
if (check) {
|
|
68
|
+
console.log(c.gray("note: ") + `${exportChanges} skill export${exportChanges === 1 ? " is" : "s are"} out of date — ` +
|
|
69
|
+
"`doctrina index rebuild` regenerates them");
|
|
70
|
+
} else {
|
|
71
|
+
applySkillExport(projectRoot, exportPlan);
|
|
72
|
+
for (const w of exportPlan.writes) console.log(c.green("export ") + ` ${w.rel}`);
|
|
73
|
+
for (const r of exportPlan.removes) console.log(c.green("removed") + ` ${r} (its skill is gone)`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
if (state.ok) {
|
|
60
78
|
console.log(c.green("ok") + " index.json matches the tree (nothing to do)");
|
|
61
79
|
return 0;
|
|
@@ -131,6 +149,11 @@ Subcommands:
|
|
|
131
149
|
both, because the index is derived from the whole tree.
|
|
132
150
|
Exits 0 when nothing relevant is staged.
|
|
133
151
|
|
|
152
|
+
Every rebuild also exports .doctrina/skills/ as SKILL.md folders where the
|
|
153
|
+
agents discover skills on their own: .agents/skills/ (Cursor, Codex) and,
|
|
154
|
+
with the claude adapter installed, .claude/skills/. The exports are marked
|
|
155
|
+
as generated; edit the source. Under --check a stale export is a note only.
|
|
156
|
+
|
|
134
157
|
Fields with no on-disk source (project name, framework_version, product
|
|
135
158
|
metadata) are carried over from the existing index.
|
|
136
159
|
`;
|
package/src/commands/init.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { attachCommand, shadowedCloneAt } from "../lib/workspace-view.js";
|
|
3
4
|
import process from "node:process";
|
|
4
5
|
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
6
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
|
|
@@ -14,6 +15,7 @@ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
|
14
15
|
import { c } from "../lib/colors.js";
|
|
15
16
|
import { EXIT } from "../lib/exit-codes.js";
|
|
16
17
|
import { ask, isInteractive } from "../lib/prompt.js";
|
|
18
|
+
import { mergeJson } from "../lib/adapters.js";
|
|
17
19
|
import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake, nextStepAfterInit } from "../lib/intake-model.js";
|
|
18
20
|
|
|
19
21
|
const SUPPORTED_AGENTS = [
|
|
@@ -40,6 +42,15 @@ export async function run(_positional, flags) {
|
|
|
40
42
|
const force = flagBool(flags, "force", false);
|
|
41
43
|
const nonInteractive = flagBool(flags, "non-interactive", false);
|
|
42
44
|
const projectRoot = process.cwd();
|
|
45
|
+
// A clone of a shadowed repository (third audit, change 0234): its context
|
|
46
|
+
// lives in the workspace, and a .doctrina/ planted here is one the
|
|
47
|
+
// repository may not carry — and one the workspace would never see.
|
|
48
|
+
const clone = force ? null : shadowedCloneAt(projectRoot);
|
|
49
|
+
if (clone && !path.resolve(projectRoot).startsWith(path.resolve(clone.wsDir) + path.sep)) {
|
|
50
|
+
console.error(c.red("error:") + ` this repository is the workspace's shadowed "${clone.name}" (registered at ${clone.path}) — its context lives in the workspace, not here`);
|
|
51
|
+
console.error(c.gray("hint: ") + `link it here instead: \`${attachCommand(clone, projectRoot)}\` — or pass --force to init a separate project anyway`);
|
|
52
|
+
return EXIT.PRECONDITION;
|
|
53
|
+
}
|
|
43
54
|
const projectName = flagString(flags, "project-name") ?? path.basename(projectRoot);
|
|
44
55
|
const date = flagString(flags, "date") ?? today();
|
|
45
56
|
|
|
@@ -409,6 +420,20 @@ function installAdapter(templatesDir, projectRoot, agent, tokens, opts) {
|
|
|
409
420
|
...tokens,
|
|
410
421
|
AGENTS_MD_PATH: adapterAgentsMdRelativePath(entry.relativePath),
|
|
411
422
|
};
|
|
423
|
+
// A shared settings file the project already has (`.claude/settings.json`)
|
|
424
|
+
// is merged, never overwritten (change 0211) — the same rule `adapter add`
|
|
425
|
+
// follows, so `init` in an existing repository keeps the user's keys.
|
|
426
|
+
const dest = path.join(projectRoot, entry.relativePath.replace(/\.template$/, ""));
|
|
427
|
+
if (dest.endsWith(".json") && isFile(dest)) {
|
|
428
|
+
try {
|
|
429
|
+
const merged = mergeJson(JSON.parse(read(dest)), JSON.parse(substitute(entry.body, adapterTokens)));
|
|
430
|
+
write(dest, JSON.stringify(merged, null, 2) + "\n", { force: true });
|
|
431
|
+
console.log(c.green(`adapter[${agent}]`) + ` ${relPath(projectRoot, dest)} (merged)`);
|
|
432
|
+
} catch {
|
|
433
|
+
console.log(c.yellow(`adapter[${agent}]`) + ` ${relPath(projectRoot, dest)} left alone (not valid JSON)`);
|
|
434
|
+
}
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
412
437
|
const written = materialiseEntry(entry, projectRoot, adapterTokens, opts);
|
|
413
438
|
console.log(c.green(`adapter[${agent}]`) + ` ${relPath(projectRoot, written)}`);
|
|
414
439
|
}
|
|
@@ -439,6 +464,8 @@ Options:
|
|
|
439
464
|
--from <path> Local conventions directory; if it contains AGENTS.md and/or
|
|
440
465
|
.doctrina/product.md, the content is folded into the new project
|
|
441
466
|
--date <YYYY-MM-DD> Override the system date
|
|
442
|
-
--force Overwrite existing files
|
|
467
|
+
--force Overwrite existing files that are still untouched scaffolds;
|
|
468
|
+
also init a second clone of a repository a workspace shadows
|
|
469
|
+
--overwrite-content With --force, also overwrite an authored AGENTS.md or product.md
|
|
443
470
|
--non-interactive Fail instead of prompting for missing values
|
|
444
471
|
`;
|
package/src/commands/intake.js
CHANGED
|
@@ -10,6 +10,9 @@ import { today } from "../lib/dates.js";
|
|
|
10
10
|
import { flagBool, flagString } from "../lib/args.js";
|
|
11
11
|
import { c } from "../lib/colors.js";
|
|
12
12
|
import { looksLikePath, markIntakeConverted, printBootstrapPlaybook, warnIfThinIntake, writeIntakeFile } from "../lib/intake-model.js";
|
|
13
|
+
import { inventory, inventoryMarkdown } from "../lib/code-inventory.js";
|
|
14
|
+
import { printPlaybookTemplate } from "../lib/playbook.js";
|
|
15
|
+
import { findWorkspace } from "../lib/workspace.js";
|
|
13
16
|
|
|
14
17
|
// `init --intake` performs the same operations; they live in
|
|
15
18
|
// lib/intake-model.js so neither command imports out of the other (F7).
|
|
@@ -26,7 +29,7 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
|
26
29
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
27
30
|
// adding a command never requires editing the entrypoint — the gap that
|
|
28
31
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
29
|
-
export const flags = { boolean: ["json", "force", "converted"], string: ["text"] };
|
|
32
|
+
export const flags = { boolean: ["json", "force", "converted", "from-code"], string: ["text"] };
|
|
30
33
|
|
|
31
34
|
export async function run(positional, flags) {
|
|
32
35
|
const projectRoot = process.cwd();
|
|
@@ -40,7 +43,7 @@ export async function run(positional, flags) {
|
|
|
40
43
|
// a bootstrap that was already done.
|
|
41
44
|
if (flagBool(flags, "converted", false)) {
|
|
42
45
|
ensureDoctrinaProject(projectRoot);
|
|
43
|
-
// Converting is the claim that the bootstrap is done — step
|
|
46
|
+
// Converting is the claim that the bootstrap is done — step 8 of the
|
|
44
47
|
// playbook, "fix everything the gates report", made checkable (change
|
|
45
48
|
// 0194). It used to convert over a tree `validate` failed, so the specs
|
|
46
49
|
// became "the source of truth" while an error still said they were not
|
|
@@ -72,6 +75,7 @@ export async function run(positional, flags) {
|
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
const force = flagBool(flags, "force", false);
|
|
78
|
+
if (flagBool(flags, "from-code", false)) return intakeFromCode(projectRoot, force);
|
|
75
79
|
const inlineText = flagString(flags, "text");
|
|
76
80
|
const sourceFile = positional[0];
|
|
77
81
|
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
@@ -91,7 +95,12 @@ export async function run(positional, flags) {
|
|
|
91
95
|
console.log(c.gray("hint: ") + "start implementation with `doctrina work \"<brief prompt>\"`");
|
|
92
96
|
return 0;
|
|
93
97
|
}
|
|
94
|
-
|
|
98
|
+
// An intake written by `--from-code` is a code inventory, and the
|
|
99
|
+
// playbook that reads it is the absorb (second audit, change 0233). The
|
|
100
|
+
// bootstrap one — "fill every product.md section from the intake" —
|
|
101
|
+
// handed a resuming agent the inventory as if it were the product's intent.
|
|
102
|
+
if (/code inventory/i.test(listHeader(read(intakePath), "Source") ?? "")) printAbsorbPlaybook(projectRoot);
|
|
103
|
+
else printBootstrapPlaybook(projectRoot);
|
|
95
104
|
return 0;
|
|
96
105
|
}
|
|
97
106
|
|
|
@@ -151,6 +160,48 @@ export async function run(positional, flags) {
|
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
|
|
163
|
+
// An existing codebase becomes specs (change 0221). The intake is the code
|
|
164
|
+
// INVENTORY — facts read from manifests and file counts — and the playbook
|
|
165
|
+
// asks the agent to describe what the system does today, leaving what the
|
|
166
|
+
// code cannot tell to the human as open questions.
|
|
167
|
+
function intakeFromCode(projectRoot, force) {
|
|
168
|
+
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
169
|
+
if (exists(intakePath)) {
|
|
170
|
+
const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
|
|
171
|
+
if (status === "converted") {
|
|
172
|
+
console.error(c.red("error:") + " the intake is already converted — the specs are the source of truth now");
|
|
173
|
+
console.error(c.gray("hint: ") + "a change of behaviour: `doctrina work \"<prompt>\"`");
|
|
174
|
+
return EXIT.PRECONDITION;
|
|
175
|
+
}
|
|
176
|
+
if (!force) {
|
|
177
|
+
console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} already exists (pass --force to replace it with the code inventory)`);
|
|
178
|
+
return EXIT.PRECONDITION;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const name = projectName(projectRoot);
|
|
182
|
+
const inv = inventory(projectRoot);
|
|
183
|
+
writeIntakeFile(projectRoot, {
|
|
184
|
+
body: inventoryMarkdown(inv, name),
|
|
185
|
+
source: "code inventory (`doctrina intake --from-code`)",
|
|
186
|
+
projectName: name, date: today(), force: true,
|
|
187
|
+
});
|
|
188
|
+
const plural = (/** @type {number} */ n, /** @type {string} */ word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
189
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)} — ${plural(inv.files, "file")} read: ${inv.stack.join(", ") || "no manifest"}; ${plural(inv.tests.files, "test file")}; ${plural(inv.areas.length, "candidate area")}`);
|
|
190
|
+
console.log("");
|
|
191
|
+
printAbsorbPlaybook(projectRoot);
|
|
192
|
+
return EXIT.OK;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The absorb playbook — printed by `--from-code`, and again by a bare `intake` while it is pending. */
|
|
196
|
+
function printAbsorbPlaybook(projectRoot) {
|
|
197
|
+
const ws = findWorkspace(projectRoot);
|
|
198
|
+
printPlaybookTemplate(projectRoot, "absorb", {
|
|
199
|
+
WORKSPACE_STEP: ws
|
|
200
|
+
? `\n10. This project belongs to the workspace at ${ws.dir} — confirm it reads\n ready: ${c.cyan("doctrina workspace list")}, and commit the workspace.`
|
|
201
|
+
: "",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
154
205
|
export const help = `
|
|
155
206
|
Usage: doctrina intake [<file>] [--text "<description>"] [--force]
|
|
156
207
|
|
|
@@ -168,11 +219,17 @@ Forms:
|
|
|
168
219
|
existing pending intake
|
|
169
220
|
doctrina intake --converted Close the bootstrap: flip the stored
|
|
170
221
|
intake's Status to converted
|
|
222
|
+
doctrina intake --from-code An EXISTING codebase: store its code
|
|
223
|
+
inventory as the intake and print the
|
|
224
|
+
absorb playbook
|
|
171
225
|
|
|
172
226
|
Options:
|
|
173
227
|
--text "<description>" Inline description instead of a file
|
|
174
228
|
--force Overwrite an existing .doctrina/intake.md; with
|
|
175
229
|
--converted, convert although validate fails
|
|
230
|
+
--from-code Inventory the code (stack, tests, CI, env names,
|
|
231
|
+
candidate areas, build commands) instead of a
|
|
232
|
+
description; --force replaces a pending intake
|
|
176
233
|
--converted Mark the stored intake converted (ends the
|
|
177
234
|
bootstrap; nothing else writes that header).
|
|
178
235
|
Refuses (exit 1) while validate reports an error
|