doctrina-cli 0.18.0 → 0.20.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 +74 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +73 -4
- package/src/commands/close.js +18 -1
- package/src/commands/context.js +49 -8
- package/src/commands/delivery.js +238 -0
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +15 -0
- package/src/commands/intake.js +47 -1
- package/src/commands/metrics.js +28 -3
- package/src/commands/prime.js +76 -7
- package/src/commands/upgrade.js +15 -0
- package/src/commands/work.js +86 -2
- package/src/commands/workspace.js +409 -0
- package/src/index.js +5 -7
- package/src/lib/actions.js +1 -1
- package/src/lib/adapters.js +80 -0
- package/src/lib/code-inventory.js +297 -0
- package/src/lib/commands.js +44 -5
- package/src/lib/context-slice.js +118 -0
- package/src/lib/delivery.js +208 -0
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +39 -2
- package/src/lib/task-card.js +102 -0
- package/src/lib/views.js +51 -2
- package/src/lib/work-model.js +58 -0
- package/src/lib/workspace-agents.js +259 -0
- package/src/lib/workspace-tools.js +113 -0
- package/src/lib/workspace.js +371 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +9 -0
- 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/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +40 -0
- package/templates/playbooks/delivery.md.template +38 -0
- package/templates/playbooks/work.md.template +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { relPath, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { flagString } from "../lib/args.js";
|
|
5
|
+
import { c } from "../lib/colors.js";
|
|
6
|
+
import { suggest } from "../lib/suggest.js";
|
|
7
|
+
import { today } from "../lib/dates.js";
|
|
8
|
+
import { EXIT, PreconditionError } from "../lib/exit-codes.js";
|
|
9
|
+
import { readTemplate, substitute } from "../lib/templates.js";
|
|
10
|
+
import { renderPlaybook } from "../lib/playbook.js";
|
|
11
|
+
import { findWorkspace, readWorkspace } from "../lib/workspace.js";
|
|
12
|
+
import {
|
|
13
|
+
deliveryBlockers, deliveryChanges, deliveryPath, dependencyOrder, listDeliveries, nextDeliveryId,
|
|
14
|
+
rankProjects, readDelivery, resolveDeliveryId, setDeliveryHeader, daysBetween, recordBlockers,
|
|
15
|
+
} from "../lib/delivery.js";
|
|
16
|
+
|
|
17
|
+
// `doctrina delivery` — one item of work across repositories (ADR 0030).
|
|
18
|
+
// Decisions in lib/delivery.js; this module renders them.
|
|
19
|
+
|
|
20
|
+
const SUBCOMMANDS = ["new", "route", "list", "status", "check", "close"];
|
|
21
|
+
|
|
22
|
+
export const flags = { boolean: ["json"], string: ["ref", "projects", "date"] };
|
|
23
|
+
|
|
24
|
+
export async function run(positional, flags) {
|
|
25
|
+
const sub = positional[0];
|
|
26
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
27
|
+
console.error(c.red("error:") + ` unknown delivery subcommand "${sub ?? ""}"`);
|
|
28
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
29
|
+
console.error(c.gray("hint: ") + (guess ? `did you mean \`doctrina delivery ${guess}\`?` : `available: ${SUBCOMMANDS.join(", ")}`));
|
|
30
|
+
return EXIT.USAGE;
|
|
31
|
+
}
|
|
32
|
+
const found = findWorkspace(process.cwd());
|
|
33
|
+
if (!found) {
|
|
34
|
+
throw new PreconditionError("a delivery lives in a workspace, and none was found here", "doctrina workspace init <dir>");
|
|
35
|
+
}
|
|
36
|
+
const wsDir = found.dir;
|
|
37
|
+
const ws = readWorkspace(wsDir);
|
|
38
|
+
if (sub === "new") return deliveryNew(wsDir, ws, positional.slice(1).join(" ").trim(), flags);
|
|
39
|
+
if (sub === "list") return deliveryList(wsDir, ws);
|
|
40
|
+
const id = resolveDeliveryId(wsDir, positional[1]);
|
|
41
|
+
if (!id) {
|
|
42
|
+
console.error(c.red("error:") + ` no delivery "${positional[1] ?? ""}" in this workspace`);
|
|
43
|
+
console.error(c.gray("hint: ") + "`doctrina delivery list` shows them");
|
|
44
|
+
return EXIT.USAGE;
|
|
45
|
+
}
|
|
46
|
+
if (sub === "route") return deliveryRoute(wsDir, ws, id, positional[2] ?? flagString(flags, "projects"));
|
|
47
|
+
if (sub === "status") return deliveryStatus(wsDir, ws, id);
|
|
48
|
+
if (sub === "check") return deliveryCheck(wsDir, ws, id);
|
|
49
|
+
return deliveryClose(wsDir, ws, id, flags);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseProjects(ws, raw) {
|
|
53
|
+
const names = String(raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
54
|
+
const unknown = names.filter((n) => !ws.projects.some((p) => p.name === n));
|
|
55
|
+
return { names, unknown };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function deliveryNew(wsDir, ws, title, flags) {
|
|
59
|
+
if (!title) {
|
|
60
|
+
console.error(c.red("error:") + " delivery new needs a title");
|
|
61
|
+
console.error(c.gray("hint: ") + 'doctrina delivery new "Login screen" --ref SHOP-123');
|
|
62
|
+
return EXIT.USAGE;
|
|
63
|
+
}
|
|
64
|
+
if (ws.projects.length === 0) {
|
|
65
|
+
throw new PreconditionError("the workspace has no project to deliver into", "doctrina workspace add <path>");
|
|
66
|
+
}
|
|
67
|
+
const { names, unknown } = parseProjects(ws, flagString(flags, "projects"));
|
|
68
|
+
if (unknown.length > 0) {
|
|
69
|
+
console.error(c.red("error:") + ` --projects names no registered project: ${unknown.join(", ")}`);
|
|
70
|
+
return EXIT.USAGE;
|
|
71
|
+
}
|
|
72
|
+
const id = nextDeliveryId(wsDir, title);
|
|
73
|
+
const ref = flagString(flags, "ref") ?? "—";
|
|
74
|
+
const ordered = dependencyOrder(ws, names);
|
|
75
|
+
const tpl = readTemplate(wsDir, "delivery.md.template");
|
|
76
|
+
const file = deliveryPath(wsDir, id);
|
|
77
|
+
write(file, substitute(tpl.body, {
|
|
78
|
+
DELIVERY_ID: id, DELIVERY_TITLE: title, DATE: flagString(flags, "date") ?? today(),
|
|
79
|
+
DELIVERY_REF: ref, DELIVERY_PROJECTS: ordered.length ? ordered.join(", ") : "—",
|
|
80
|
+
}));
|
|
81
|
+
console.log(c.green("created") + ` ${relPath(wsDir, file)}`);
|
|
82
|
+
console.log("");
|
|
83
|
+
console.log(renderPlaybook(wsDir, "delivery", playbookTokens(wsDir, ws, readDelivery(wsDir, id))));
|
|
84
|
+
return EXIT.OK;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function playbookTokens(wsDir, ws, delivery) {
|
|
88
|
+
const ranked = rankProjects(wsDir, ws, `${delivery.title} ${delivery.ref}`);
|
|
89
|
+
const routing = delivery.projects.length > 0
|
|
90
|
+
? ` routed: ${dependencyOrder(ws, delivery.projects).join(" → ")} — route again to change it`
|
|
91
|
+
: ranked.map(({ project, score, shared }) =>
|
|
92
|
+
` ${c.cyan(project.name.padEnd(16))} ${score > 0 ? `shares ${shared.join(", ")}` : c.gray("no shared term")}`
|
|
93
|
+
+ c.gray(` · ${project.role || "no role"}${project.dependsOn?.length ? ` · depends on ${project.dependsOn.join(", ")}` : ""}`)).join("\n");
|
|
94
|
+
const tools = ws.tools.filter((t) => t.enabled !== false);
|
|
95
|
+
return {
|
|
96
|
+
DELIVERY_ID: delivery.id,
|
|
97
|
+
DELIVERY_TITLE: delivery.title,
|
|
98
|
+
DELIVERY_REF: delivery.ref === "—" ? "(no --ref given: ask the human which item this is)" : delivery.ref,
|
|
99
|
+
DELIVERY_FILE: relPath(wsDir, deliveryPath(wsDir, delivery.id)),
|
|
100
|
+
ROUTING: routing,
|
|
101
|
+
TOOLS: tools.length === 0 ? "" : " Tools this workspace declares:\n" + tools.map((t) =>
|
|
102
|
+
` ${c.cyan(t.name.padEnd(12))} ${t.access === "write" ? "read/write (writes need the human)" : "read-only"} — ${t.purpose || t.server}`).join("\n"),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function deliveryRoute(wsDir, ws, id, raw) {
|
|
107
|
+
const { names, unknown } = parseProjects(ws, raw);
|
|
108
|
+
if (names.length === 0) {
|
|
109
|
+
console.error(c.red("error:") + " delivery route needs the projects, comma-separated");
|
|
110
|
+
console.error(c.gray("hint: ") + `doctrina delivery route ${id} back-end,front-core`);
|
|
111
|
+
return EXIT.USAGE;
|
|
112
|
+
}
|
|
113
|
+
if (unknown.length > 0) {
|
|
114
|
+
console.error(c.red("error:") + ` no registered project named ${unknown.join(", ")}`);
|
|
115
|
+
return EXIT.USAGE;
|
|
116
|
+
}
|
|
117
|
+
const ordered = dependencyOrder(ws, names);
|
|
118
|
+
setDeliveryHeader(wsDir, id, "Projects", ordered.join(", "));
|
|
119
|
+
const d = readDelivery(wsDir, id);
|
|
120
|
+
console.log(c.green("routed") + ` ${id} → ${ordered.join(" → ")}` + c.gray(" (dependencies first)"));
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log("Open one change in each, in this order:");
|
|
123
|
+
for (const name of ordered) {
|
|
124
|
+
const p = ws.projects.find((x) => x.name === name);
|
|
125
|
+
console.log(" " + c.cyan(`cd ${p?.path} && doctrina work "${d.title}" --delivery ${id}`));
|
|
126
|
+
}
|
|
127
|
+
return EXIT.OK;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function deliveryList(wsDir, ws) {
|
|
131
|
+
const all = listDeliveries(wsDir);
|
|
132
|
+
if (all.length === 0) {
|
|
133
|
+
console.log(c.gray("no delivery yet — `doctrina delivery new \"<title>\" --ref <tracker-id>`"));
|
|
134
|
+
return EXIT.OK;
|
|
135
|
+
}
|
|
136
|
+
for (const d of all) {
|
|
137
|
+
const rows = deliveryChanges(wsDir, ws, d);
|
|
138
|
+
const changes = rows.flatMap((r) => r.changes);
|
|
139
|
+
const closed = changes.filter((ch) => ch.archived).length;
|
|
140
|
+
const colour = d.status === "closed" ? c.green : c.yellow;
|
|
141
|
+
const days = daysBetween(d.date, d.status === "closed" ? d.closed : today());
|
|
142
|
+
const age = days === null ? "" : d.status === "closed" ? `, lead time ${days}d` : `, open ${days}d`;
|
|
143
|
+
console.log(` ${c.cyan(d.id.padEnd(30))} ${colour(d.status.padEnd(7))} ${d.ref.padEnd(12)} ${d.projects.join(", ") || "—"}` +
|
|
144
|
+
c.gray(` ${closed}/${changes.length} changes closed${age}`));
|
|
145
|
+
}
|
|
146
|
+
const leads = all.filter((d) => d.status === "closed").map((d) => daysBetween(d.date, d.closed)).filter((n) => n !== null).sort((a, b) => a - b);
|
|
147
|
+
if (leads.length > 0) {
|
|
148
|
+
console.log("");
|
|
149
|
+
console.log(c.gray(`${leads.length} closed · median lead time ${leads[Math.floor((leads.length - 1) / 2)]}d · longest ${leads[leads.length - 1]}d`));
|
|
150
|
+
}
|
|
151
|
+
return EXIT.OK;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function deliveryStatus(wsDir, ws, id) {
|
|
155
|
+
const d = readDelivery(wsDir, id);
|
|
156
|
+
console.log(c.bold(`Delivery ${d.id}`) + ` — ${d.title}` + c.gray(` (${d.status}, ref ${d.ref}, opened ${d.date ?? "?"})`));
|
|
157
|
+
console.log("");
|
|
158
|
+
for (const row of deliveryChanges(wsDir, ws, d)) {
|
|
159
|
+
console.log(` ${c.cyan(row.project)}${row.path ? c.gray(" " + row.path) : ""}`);
|
|
160
|
+
if (row.missing) console.log(c.red(` ${row.missing}`));
|
|
161
|
+
else if (row.changes.length === 0) console.log(c.yellow(" no change yet") + c.gray(` — cd ${row.path} && doctrina work "${d.title}" --delivery ${d.id}`));
|
|
162
|
+
for (const ch of row.changes) {
|
|
163
|
+
const colour = ch.archived ? c.green : c.yellow;
|
|
164
|
+
console.log(` ${colour(ch.status.padEnd(9))} ${ch.id}` + c.gray(` tasks ${ch.tasksDone}/${ch.tasksTotal}`));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const record = recordBlockers(d);
|
|
168
|
+
if (record.length > 0) {
|
|
169
|
+
console.log(` ${c.cyan("record")}`);
|
|
170
|
+
for (const r of record) console.log(c.yellow(" ") + r);
|
|
171
|
+
}
|
|
172
|
+
const blockers = deliveryBlockers(wsDir, ws, d);
|
|
173
|
+
console.log("");
|
|
174
|
+
console.log(blockers.length === 0
|
|
175
|
+
? c.green("ready to close") + ` — doctrina delivery close ${d.id}`
|
|
176
|
+
: c.gray(`${blockers.length} thing${blockers.length === 1 ? "" : "s"} before the close — doctrina delivery check ${d.id}`));
|
|
177
|
+
return EXIT.OK;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function deliveryCheck(wsDir, ws, id) {
|
|
181
|
+
const d = readDelivery(wsDir, id);
|
|
182
|
+
const blockers = deliveryBlockers(wsDir, ws, d);
|
|
183
|
+
if (blockers.length === 0) {
|
|
184
|
+
console.log(c.green("ok") + ` ${id} is ready to close: doctrina delivery close ${id}`);
|
|
185
|
+
return EXIT.OK;
|
|
186
|
+
}
|
|
187
|
+
for (const b of blockers) console.log(c.red("✗ ") + b);
|
|
188
|
+
console.log("");
|
|
189
|
+
console.log(c.red("fail") + ` ${blockers.length} blocker${blockers.length === 1 ? "" : "s"}`);
|
|
190
|
+
return EXIT.GATE;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function deliveryClose(wsDir, ws, id, flags) {
|
|
194
|
+
const d = readDelivery(wsDir, id);
|
|
195
|
+
if (d.status === "closed") {
|
|
196
|
+
console.log(c.gray(`${id} is already closed (${d.closed ?? "?"})`));
|
|
197
|
+
return EXIT.OK;
|
|
198
|
+
}
|
|
199
|
+
if (deliveryCheck(wsDir, ws, id) !== EXIT.OK) return EXIT.GATE;
|
|
200
|
+
const date = flagString(flags, "date") ?? today();
|
|
201
|
+
setDeliveryHeader(wsDir, id, "Status", "closed");
|
|
202
|
+
setDeliveryHeader(wsDir, id, "Closed", date);
|
|
203
|
+
console.log(c.green("closed") + ` ${id}`);
|
|
204
|
+
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.`);
|
|
205
|
+
return EXIT.OK;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const help = `
|
|
209
|
+
Usage: doctrina delivery <new|route|list|status|check|close> [options]
|
|
210
|
+
|
|
211
|
+
One item of work across repositories (ADR 0030) — a story, a screen, a fix.
|
|
212
|
+
It lives in the workspace's deliveries/ and holds what no single repository
|
|
213
|
+
owns. Each project it touches gets ONE change, opened there with
|
|
214
|
+
\`doctrina work "<title>" --delivery <id>\`; the delivery's state is read
|
|
215
|
+
from those changes, never copied into it.
|
|
216
|
+
|
|
217
|
+
new "<title>" [--ref <tracker-id>] [--projects <a,b>]
|
|
218
|
+
Create deliveries/<NNNN-slug>/delivery.md and print the orchestration
|
|
219
|
+
playbook: read the story, route, open one change per repository,
|
|
220
|
+
delegate the task cards, close each change, release, watch, close.
|
|
221
|
+
Without --projects it ranks the workspace's projects against the title.
|
|
222
|
+
route <id> <a,b>
|
|
223
|
+
Set the projects (dependencies first) and print the \`work\` line for each.
|
|
224
|
+
list
|
|
225
|
+
Every delivery with its status, ref, closed/total changes and age —
|
|
226
|
+
lead time once closed, with the median over the closed ones.
|
|
227
|
+
status <id>
|
|
228
|
+
Per project: the changes carrying this delivery, their state and tasks.
|
|
229
|
+
check <id>
|
|
230
|
+
Exit 1 while a routed project has no change for it or one still open,
|
|
231
|
+
while ## Story is empty, a ## Release box is unticked, or
|
|
232
|
+
## Observability records nothing.
|
|
233
|
+
close <id>
|
|
234
|
+
The check, then Status: closed and the Closed date.
|
|
235
|
+
|
|
236
|
+
<id> is the folder name or its number (\`1\`, \`0001\`).
|
|
237
|
+
`;
|
|
238
|
+
|
|
@@ -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
|
@@ -14,6 +14,7 @@ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
|
14
14
|
import { c } from "../lib/colors.js";
|
|
15
15
|
import { EXIT } from "../lib/exit-codes.js";
|
|
16
16
|
import { ask, isInteractive } from "../lib/prompt.js";
|
|
17
|
+
import { mergeJson } from "../lib/adapters.js";
|
|
17
18
|
import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake, nextStepAfterInit } from "../lib/intake-model.js";
|
|
18
19
|
|
|
19
20
|
const SUPPORTED_AGENTS = [
|
|
@@ -409,6 +410,20 @@ function installAdapter(templatesDir, projectRoot, agent, tokens, opts) {
|
|
|
409
410
|
...tokens,
|
|
410
411
|
AGENTS_MD_PATH: adapterAgentsMdRelativePath(entry.relativePath),
|
|
411
412
|
};
|
|
413
|
+
// A shared settings file the project already has (`.claude/settings.json`)
|
|
414
|
+
// is merged, never overwritten (change 0211) — the same rule `adapter add`
|
|
415
|
+
// follows, so `init` in an existing repository keeps the user's keys.
|
|
416
|
+
const dest = path.join(projectRoot, entry.relativePath.replace(/\.template$/, ""));
|
|
417
|
+
if (dest.endsWith(".json") && isFile(dest)) {
|
|
418
|
+
try {
|
|
419
|
+
const merged = mergeJson(JSON.parse(read(dest)), JSON.parse(substitute(entry.body, adapterTokens)));
|
|
420
|
+
write(dest, JSON.stringify(merged, null, 2) + "\n", { force: true });
|
|
421
|
+
console.log(c.green(`adapter[${agent}]`) + ` ${relPath(projectRoot, dest)} (merged)`);
|
|
422
|
+
} catch {
|
|
423
|
+
console.log(c.yellow(`adapter[${agent}]`) + ` ${relPath(projectRoot, dest)} left alone (not valid JSON)`);
|
|
424
|
+
}
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
412
427
|
const written = materialiseEntry(entry, projectRoot, adapterTokens, opts);
|
|
413
428
|
console.log(c.green(`adapter[${agent}]`) + ` ${relPath(projectRoot, written)}`);
|
|
414
429
|
}
|
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();
|
|
@@ -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");
|
|
@@ -151,6 +155,42 @@ export async function run(positional, flags) {
|
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
|
|
158
|
+
// An existing codebase becomes specs (change 0221). The intake is the code
|
|
159
|
+
// INVENTORY — facts read from manifests and file counts — and the playbook
|
|
160
|
+
// asks the agent to describe what the system does today, leaving what the
|
|
161
|
+
// code cannot tell to the human as open questions.
|
|
162
|
+
function intakeFromCode(projectRoot, force) {
|
|
163
|
+
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
164
|
+
if (exists(intakePath)) {
|
|
165
|
+
const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
|
|
166
|
+
if (status === "converted") {
|
|
167
|
+
console.error(c.red("error:") + " the intake is already converted — the specs are the source of truth now");
|
|
168
|
+
console.error(c.gray("hint: ") + "a change of behaviour: `doctrina work \"<prompt>\"`");
|
|
169
|
+
return EXIT.PRECONDITION;
|
|
170
|
+
}
|
|
171
|
+
if (!force) {
|
|
172
|
+
console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} already exists (pass --force to replace it with the code inventory)`);
|
|
173
|
+
return EXIT.PRECONDITION;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const name = projectName(projectRoot);
|
|
177
|
+
const inv = inventory(projectRoot);
|
|
178
|
+
writeIntakeFile(projectRoot, {
|
|
179
|
+
body: inventoryMarkdown(inv, name),
|
|
180
|
+
source: "code inventory (`doctrina intake --from-code`)",
|
|
181
|
+
projectName: name, date: today(), force: true,
|
|
182
|
+
});
|
|
183
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)} — ${inv.files} files read: ${inv.stack.join(", ") || "no manifest"}; ${inv.tests.files} test files; ${inv.areas.length} candidate areas`);
|
|
184
|
+
console.log("");
|
|
185
|
+
const ws = findWorkspace(projectRoot);
|
|
186
|
+
printPlaybookTemplate(projectRoot, "absorb", {
|
|
187
|
+
WORKSPACE_STEP: ws
|
|
188
|
+
? `\n8. This project belongs to the workspace at ${ws.dir} — confirm it reads\n ready: ${c.cyan("doctrina workspace list")}, and commit the workspace.`
|
|
189
|
+
: "",
|
|
190
|
+
});
|
|
191
|
+
return EXIT.OK;
|
|
192
|
+
}
|
|
193
|
+
|
|
154
194
|
export const help = `
|
|
155
195
|
Usage: doctrina intake [<file>] [--text "<description>"] [--force]
|
|
156
196
|
|
|
@@ -168,11 +208,17 @@ Forms:
|
|
|
168
208
|
existing pending intake
|
|
169
209
|
doctrina intake --converted Close the bootstrap: flip the stored
|
|
170
210
|
intake's Status to converted
|
|
211
|
+
doctrina intake --from-code An EXISTING codebase: store its code
|
|
212
|
+
inventory as the intake and print the
|
|
213
|
+
absorb playbook
|
|
171
214
|
|
|
172
215
|
Options:
|
|
173
216
|
--text "<description>" Inline description instead of a file
|
|
174
217
|
--force Overwrite an existing .doctrina/intake.md; with
|
|
175
218
|
--converted, convert although validate fails
|
|
219
|
+
--from-code Inventory the code (stack, tests, CI, env names,
|
|
220
|
+
candidate areas, build commands) instead of a
|
|
221
|
+
description; --force replaces a pending intake
|
|
176
222
|
--converted Mark the stored intake converted (ends the
|
|
177
223
|
bootstrap; nothing else writes that header).
|
|
178
224
|
Refuses (exit 1) while validate reports an error
|
package/src/commands/metrics.js
CHANGED
|
@@ -98,6 +98,9 @@ export async function run(_positional, flags) {
|
|
|
98
98
|
printDelta("revert rate", previous.revert_rate, snapshot.revert_rate);
|
|
99
99
|
printDelta("fix share", previous.fix_share, snapshot.fix_share);
|
|
100
100
|
printDelta(`${REEDIT_WINDOW_DAYS}-day re-edit rate`, previous.reedit_rate, snapshot.reedit_rate);
|
|
101
|
+
if (typeof previous.change_adherence === "number" && typeof snapshot.change_adherence === "number") {
|
|
102
|
+
printDelta("commits with a change", previous.change_adherence, snapshot.change_adherence);
|
|
103
|
+
}
|
|
101
104
|
}
|
|
102
105
|
}
|
|
103
106
|
return 0;
|
|
@@ -140,6 +143,7 @@ const LABELS = {
|
|
|
140
143
|
revert_rate: "revert rate",
|
|
141
144
|
fix_share: "fix share",
|
|
142
145
|
reedit_rate: `${REEDIT_WINDOW_DAYS}-day re-edit rate`,
|
|
146
|
+
change_adherence: "commits with a change",
|
|
143
147
|
};
|
|
144
148
|
|
|
145
149
|
function printReport(s) {
|
|
@@ -148,6 +152,10 @@ function printReport(s) {
|
|
|
148
152
|
console.log(`fix-typed commits ${pct(s.fix_share)} of commits (Conventional Commits "fix")`);
|
|
149
153
|
console.log(`${s.reedit_window_days}-day re-edit rate ${pct(s.reedit_rate)} of commits touch a file edited in the prior ${s.reedit_window_days} days`);
|
|
150
154
|
console.log(c.gray(" (proxy for rework — iterative work also counts)"));
|
|
155
|
+
if (s.change_adherence !== null && s.change_adherence !== undefined) {
|
|
156
|
+
console.log(`commits with a change ${pct(s.change_adherence)} of ${s.code_commits} code commits touched .doctrina/changes/ or named a change`);
|
|
157
|
+
console.log(c.gray(" (workflow adherence — a one-line fix may rightly need none)"));
|
|
158
|
+
}
|
|
151
159
|
if (s.top_churn.length > 0) {
|
|
152
160
|
console.log("");
|
|
153
161
|
console.log("top churn:");
|
|
@@ -200,6 +208,20 @@ function commandUsage() {
|
|
|
200
208
|
for (const [op, n] of used) {
|
|
201
209
|
console.log(` ${String(n).padStart(5)} ${c.cyan(op)}`);
|
|
202
210
|
}
|
|
211
|
+
// The cycle, read off the sample (change 0218): did a change get its read
|
|
212
|
+
// and its close? A field review found `prime` and `work` used, `context`
|
|
213
|
+
// and `triage` skipped. Ratios, not verdicts — one context can serve two
|
|
214
|
+
// works, and a close can be retried.
|
|
215
|
+
const count = (op) => used.find(([o]) => o === op)?.[1] ?? 0;
|
|
216
|
+
const works = count("work");
|
|
217
|
+
if (works > 0) {
|
|
218
|
+
console.log("");
|
|
219
|
+
console.log(c.bold("Workflow") + c.gray(` — per \`work\` (${works})`));
|
|
220
|
+
console.log(` context ${(count("context") / works).toFixed(1)} close ${(count("close") / works).toFixed(1)} prime ${(count("prime") / works).toFixed(1)}`);
|
|
221
|
+
if (count("context") < works) {
|
|
222
|
+
console.log(c.gray(" fewer reads than changes: the pack is the spec, `prime` is only the state"));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
203
225
|
console.log("");
|
|
204
226
|
console.log(c.gray(`${unused.length} of ${catalog.length} operations never invoked in this sample:`));
|
|
205
227
|
for (let i = 0; i < unused.length; i += 4) {
|
|
@@ -216,12 +238,15 @@ export const help = `
|
|
|
216
238
|
Usage: doctrina metrics [--since <days|date>] [--save] [--commands]
|
|
217
239
|
|
|
218
240
|
Derive adoption metrics from LOCAL git history: commit count, revert
|
|
219
|
-
count and rate, Conventional-Commit fix share, top-churn files,
|
|
220
|
-
${REEDIT_WINDOW_DAYS}-day re-edit proxy rate
|
|
241
|
+
count and rate, Conventional-Commit fix share, top-churn files, a
|
|
242
|
+
${REEDIT_WINDOW_DAYS}-day re-edit proxy rate, and workflow adherence — the share of
|
|
243
|
+
code commits that carried a change (touched .doctrina/changes/ or named
|
|
244
|
+
one). No network calls; nothing leaves the repo.
|
|
221
245
|
|
|
222
246
|
Flags:
|
|
223
247
|
--commands Report which operations were actually invoked, from the
|
|
224
|
-
opt-in usage log
|
|
248
|
+
opt-in usage log, and how many context, close and prime
|
|
249
|
+
runs there were per work. Recording is OFF unless DOCTRINA_USAGE_LOG
|
|
225
250
|
names a file; only the operation is recorded, never
|
|
226
251
|
arguments, and nothing leaves the machine.
|
|
227
252
|
--since <n|date> Window: a day count (default 90), a calendar date
|
package/src/commands/prime.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { exists } from "../lib/fs-ops.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
4
|
+
import { exists, read } from "../lib/fs-ops.js";
|
|
5
|
+
import { findWorkspace, readWorkspace } from "../lib/workspace.js";
|
|
6
|
+
import { listDeliveries } from "../lib/delivery.js";
|
|
7
|
+
import { workspacePrimer } from "../lib/workspace-agents.js";
|
|
8
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
9
|
+
import { c } from "../lib/colors.js";
|
|
10
|
+
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
|
+
import { collectSnapshot, openChanges, recentlyClosed } from "../lib/snapshot.js";
|
|
12
|
+
import { renderView, pulse } from "../lib/views.js";
|
|
9
13
|
|
|
10
14
|
// Session primer: the ~40-line read that orients an agent at the start of a
|
|
11
15
|
// session — where things stand, what the standing rules are, what work is
|
|
@@ -22,23 +26,77 @@ import { renderView } from "../lib/views.js";
|
|
|
22
26
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
23
27
|
// adding a command never requires editing the entrypoint — the gap that
|
|
24
28
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
25
|
-
export const flags = { boolean: ["json", "rules"], string: [] };
|
|
29
|
+
export const flags = { boolean: ["json", "rules", "pulse"], string: ["hook"] };
|
|
30
|
+
|
|
31
|
+
// What an agent hook accepts on stdout (change 0211). Claude Code adds the
|
|
32
|
+
// plain text of a SessionStart hook to the conversation; Cursor's
|
|
33
|
+
// sessionStart reads `additional_context` from a JSON object. Both cap what
|
|
34
|
+
// they keep — Claude at 10,000 characters — so the primer is cut short of
|
|
35
|
+
// that rather than replaced by a file path the agent is never asked to read.
|
|
36
|
+
const HOOK_AGENTS = ["claude", "cursor"];
|
|
37
|
+
const HOOK_MAX_CHARS = 9000;
|
|
26
38
|
|
|
27
39
|
export async function run(_positional, flags) {
|
|
28
40
|
const projectRoot = process.cwd();
|
|
29
41
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
42
|
+
// At a workspace root the session belongs to the orchestrator (change
|
|
43
|
+
// 0223): the primer is the workspace's.
|
|
44
|
+
const found = findWorkspace(projectRoot);
|
|
45
|
+
if (found && found.via !== "pointer") return primeWorkspace(found.dir, flags);
|
|
30
46
|
throw notADoctrinaProject();
|
|
31
47
|
}
|
|
32
48
|
// --rules: the standing rules in full, which is what the removed
|
|
33
49
|
// `constitution` printed (changes 0049, 0175). The primer names the ADRs and counts the non-goals; this
|
|
34
50
|
// prints both lists, from the same collection, so the two cannot disagree.
|
|
51
|
+
// --pulse: one line for a per-prompt hook — the open changes only, read
|
|
52
|
+
// without collecting the gates, because it runs before every prompt.
|
|
53
|
+
if (flagBool(flags, "pulse", false)) {
|
|
54
|
+
for (const line of pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))) console.log(line);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (flags.has("hook")) {
|
|
58
|
+
const agent = flagString(flags, "hook");
|
|
59
|
+
if (!agent || !HOOK_AGENTS.includes(agent)) {
|
|
60
|
+
console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
|
|
61
|
+
return EXIT.USAGE;
|
|
62
|
+
}
|
|
63
|
+
const text = ["Doctrina session primer (`doctrina prime`) — the project's gates, rules and open work:", "",
|
|
64
|
+
...renderView("prime", collectSnapshot(projectRoot)), "", ...pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))]
|
|
65
|
+
.join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
|
|
66
|
+
console.log(agent === "cursor" ? JSON.stringify({ additional_context: text }) : text);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
35
69
|
const view = flagBool(flags, "rules", false) ? "rules" : "prime";
|
|
36
70
|
for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
|
|
37
71
|
return 0;
|
|
38
72
|
}
|
|
39
73
|
|
|
74
|
+
function primeWorkspace(wsDir, flags) {
|
|
75
|
+
const ws = readWorkspace(wsDir);
|
|
76
|
+
const open = listDeliveries(wsDir).filter((d) => d.status !== "closed");
|
|
77
|
+
if (flagBool(flags, "pulse", false)) {
|
|
78
|
+
console.log(open.length === 0
|
|
79
|
+
? "Doctrina workspace: no delivery is open — an item of work across repositories opens one with `doctrina delivery new \"<title>\" --ref <id>`."
|
|
80
|
+
: open.map((d) => `open delivery: ${d.id} — "${d.title}" (${d.projects.join(", ") || "not routed"}). Name it in your first sentence to the human; \`doctrina delivery status ${d.id}\`.`).join(" "));
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
const lines = workspacePrimer(ws, wsDir, open);
|
|
84
|
+
if (flags.has("hook")) {
|
|
85
|
+
const agent = flagString(flags, "hook");
|
|
86
|
+
if (!agent || !HOOK_AGENTS.includes(agent)) {
|
|
87
|
+
console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
|
|
88
|
+
return EXIT.USAGE;
|
|
89
|
+
}
|
|
90
|
+
const text = [...lines, "", read(path.join(wsDir, "AGENTS.md"))].join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
|
|
91
|
+
console.log(agent === "cursor" ? JSON.stringify({ additional_context: text }) : text);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
for (const line of lines) console.log(line);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
40
98
|
export const help = `
|
|
41
|
-
Usage: doctrina prime
|
|
99
|
+
Usage: doctrina prime [--rules | --pulse | --hook claude|cursor]
|
|
42
100
|
|
|
43
101
|
Print the session primer: gate digest, standing rules (accepted ADR
|
|
44
102
|
titles + non-goal count), open work with task progress, and the top
|
|
@@ -51,6 +109,17 @@ collection of the tree, so they cannot disagree.
|
|
|
51
109
|
--rules Print the standing rules in full instead of the primer: every
|
|
52
110
|
accepted ADR and every declared non-goal — what the removed
|
|
53
111
|
\`doctrina constitution\` printed, from the same collection.
|
|
112
|
+
--pulse One line for a per-prompt agent hook: the open change(s), or
|
|
113
|
+
that a request changing behaviour opens one with \`work\`.
|
|
114
|
+
--hook <agent>
|
|
115
|
+
The primer plus the pulse, as the agent's session-start hook
|
|
116
|
+
reads it: plain text for claude, {"additional_context"} JSON
|
|
117
|
+
for cursor; cut at 9,000 characters. \`doctrina adapter add
|
|
118
|
+
claude|cursor\` installs the hooks that call these.
|
|
119
|
+
|
|
120
|
+
At a workspace root (no .doctrina/, a workspace.json) it is the
|
|
121
|
+
orchestrator's primer: the projects and their state, the open deliveries,
|
|
122
|
+
the tools — and with --hook, the workspace's AGENTS.md after it.
|
|
54
123
|
|
|
55
124
|
Read-only; always exits 0. Deeper reads: \`doctrina context\`,
|
|
56
125
|
\`doctrina why <cap>\`, \`doctrina show <ref>\`.
|
package/src/commands/upgrade.js
CHANGED
|
@@ -9,6 +9,7 @@ import { flagBool } from "../lib/args.js";
|
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
10
10
|
import { today } from "../lib/dates.js";
|
|
11
11
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
|
+
import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
|
|
12
13
|
import { collectFindings } from "../lib/templates-model.js";
|
|
13
14
|
import * as templates from "./templates.js";
|
|
14
15
|
import * as validate from "./validate.js";
|
|
@@ -74,6 +75,11 @@ export async function run(_positional, flags) {
|
|
|
74
75
|
console.log(c.gray(" fix: ") + (f.remedy ? c.cyan(f.remedy) : c.gray("manual repair — no command can fix this")));
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
// The skill exports (change 0212) follow the index: regenerated by the
|
|
79
|
+
// write, reported by the preview.
|
|
80
|
+
const exportPlan = planSkillExport(projectRoot);
|
|
81
|
+
const exportChanges = exportPlan.writes.length + exportPlan.removes.length;
|
|
82
|
+
|
|
77
83
|
// 3. Index: rebuild from the tree, which also migrates the framework stamp.
|
|
78
84
|
console.log("");
|
|
79
85
|
console.log(c.gray("──── 2/3 index + framework stamp"));
|
|
@@ -81,6 +87,15 @@ export async function run(_positional, flags) {
|
|
|
81
87
|
const current = idx.load(projectRoot);
|
|
82
88
|
const drifted = !indexesMatch(deriveIndex(projectRoot, current), current);
|
|
83
89
|
const staleStamp = (current.framework_version ?? null) !== running;
|
|
90
|
+
if (exportChanges > 0) {
|
|
91
|
+
if (writeMode) {
|
|
92
|
+
applySkillExport(projectRoot, exportPlan);
|
|
93
|
+
console.log(c.green("export ") + ` ${exportChanges} skill file${exportChanges === 1 ? "" : "s"} where the agents discover skills`);
|
|
94
|
+
} else {
|
|
95
|
+
console.log(c.yellow("would ") + `export ${exportChanges} skill file${exportChanges === 1 ? "" : "s"} where the agents discover skills`);
|
|
96
|
+
pending += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
84
99
|
if (!drifted && !staleStamp) {
|
|
85
100
|
console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
|
|
86
101
|
} else if (writeMode) {
|