doctrina-cli 0.17.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 +8 -4
- package/package.json +1 -1
- package/src/commands/adapter.js +74 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +131 -7
- package/src/commands/close.js +92 -28
- 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/next.js +1 -0
- package/src/commands/prime.js +76 -7
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +3 -127
- package/src/commands/upgrade.js +15 -0
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +139 -5
- package/src/commands/workspace.js +409 -0
- package/src/index.js +5 -7
- package/src/lib/actions.js +9 -5
- package/src/lib/adapters.js +80 -0
- package/src/lib/change-ops.js +92 -0
- package/src/lib/code-inventory.js +297 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +118 -0
- package/src/lib/delivery.js +208 -0
- package/src/lib/git.js +59 -2
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +13 -0
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +76 -2
- package/src/lib/spec-ops.js +117 -1
- package/src/lib/task-card.js +102 -0
- package/src/lib/validation-model.js +1 -1
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +56 -2
- package/src/lib/work-model.js +90 -12
- 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 +11 -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/change/spec-delta.md.template +2 -0
- package/templates/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +40 -0
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/delivery.md.template +38 -0
- package/templates/playbooks/work.md.template +4 -1
package/src/commands/context.js
CHANGED
|
@@ -14,6 +14,7 @@ import { GIT_STATE, historyState, changedFiles } from "../lib/git.js";
|
|
|
14
14
|
import { terms as queryTerms, relevance } from "../lib/lexicon.js";
|
|
15
15
|
import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
|
|
16
16
|
import * as idx from "../lib/index-json.js";
|
|
17
|
+
import { adrDigest, sliceSpec } from "../lib/context-slice.js";
|
|
17
18
|
|
|
18
19
|
// Materialise the AGENTS.md read order as a command: print the exact
|
|
19
20
|
// context pack for a task, in the order an agent should read it. This is
|
|
@@ -72,11 +73,14 @@ export const TIER = { CORE: 0, SPEC: 1, CHANGE: 2, DEPENDENCY: 3, DECISION: 4 };
|
|
|
72
73
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
73
74
|
// adding a command never requires editing the entrypoint — the gap that
|
|
74
75
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
75
|
-
export const flags = { boolean: ["json", "concat"], string: ["budget", "diff", "for"] };
|
|
76
|
+
export const flags = { boolean: ["json", "concat", "full"], string: ["budget", "diff", "for"] };
|
|
76
77
|
|
|
77
78
|
export async function run(positional, cmdFlags) {
|
|
78
79
|
const capability = positional[0] ?? null;
|
|
79
80
|
const concat = flagBool(cmdFlags, "concat", false);
|
|
81
|
+
// --full: the whole text of everything, as before change 0216 — AGENTS.md
|
|
82
|
+
// repeated, every spec and ADR whole. The lean pack is the default.
|
|
83
|
+
const full = flagBool(cmdFlags, "full", false);
|
|
80
84
|
const budgetRaw = flagString(cmdFlags, "budget");
|
|
81
85
|
const diffRef = flagString(cmdFlags, "diff");
|
|
82
86
|
const query = flagString(cmdFlags, "for");
|
|
@@ -162,6 +166,8 @@ export async function run(positional, cmdFlags) {
|
|
|
162
166
|
tier: TIER.CORE,
|
|
163
167
|
rank: [],
|
|
164
168
|
degraded: false,
|
|
169
|
+
/** @type {string | null} */ slice: null,
|
|
170
|
+
/** @type {string | null} */ digest: null,
|
|
165
171
|
...extra,
|
|
166
172
|
};
|
|
167
173
|
pack.push(item);
|
|
@@ -170,7 +176,10 @@ export async function run(positional, cmdFlags) {
|
|
|
170
176
|
|
|
171
177
|
// 1-2. Root rules and product truth — always first. Displayed paths are
|
|
172
178
|
// POSIX strings (path.join would emit backslashes on Windows).
|
|
173
|
-
|
|
179
|
+
// AGENTS.md is not repeated by default (change 0216): every agent this
|
|
180
|
+
// pack is for loads it already — natively, or through its adapter — and
|
|
181
|
+
// it cost ~2.5k tokens per pack to say it twice.
|
|
182
|
+
if (wanted("AGENTS.md") && full) pushFile("AGENTS.md", "root rules");
|
|
174
183
|
if (wanted(".doctrina/product.md")) pushFile(".doctrina/product.md", "product truth");
|
|
175
184
|
|
|
176
185
|
// 3. The capability spec(s) — the current truth. With a named capability,
|
|
@@ -241,6 +250,21 @@ export async function run(positional, cmdFlags) {
|
|
|
241
250
|
}
|
|
242
251
|
}
|
|
243
252
|
|
|
253
|
+
// A spec read for a task — the task's own, and the ones it depends on — is
|
|
254
|
+
// cut to what the task is about (change
|
|
255
|
+
// 0216): headers, Purpose, and the requirements and criteria the query
|
|
256
|
+
// matches, the rest named by count. No query, or nothing matched: whole.
|
|
257
|
+
if (!full && terms.length > 0) {
|
|
258
|
+
for (const item of pack) {
|
|
259
|
+
if ((item.tier !== TIER.CORE && item.tier !== TIER.DEPENDENCY) || !item.rel.startsWith(".doctrina/specs/")) continue;
|
|
260
|
+
const sliced = sliceSpec(read(path.join(projectRoot, item.rel)), terms, item.rel);
|
|
261
|
+
if (!sliced) continue;
|
|
262
|
+
item.slice = sliced.text;
|
|
263
|
+
item.tokens = estimateTokens(sliced.text);
|
|
264
|
+
item.note = `${item.note} — sliced: ${sliced.kept} of ${sliced.total} items match`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
244
268
|
// 4. Open changes (their proposal, tasks, and deltas) — never
|
|
245
269
|
// diff-filtered: work in flight is the one thing a resuming agent
|
|
246
270
|
// cannot reconstruct from anywhere else, so it is always present.
|
|
@@ -347,7 +371,7 @@ export async function run(positional, cmdFlags) {
|
|
|
347
371
|
|
|
348
372
|
const id = path.basename(f).match(/^(\d{4})-/)?.[1] ?? "0000";
|
|
349
373
|
const title = getTitle(text) ?? path.basename(f, ".md");
|
|
350
|
-
pushFile(rel, global ? "accepted ADR" : `accepted ADR (${scope.join(", ")})`, {
|
|
374
|
+
const adrItem = pushFile(rel, global ? "accepted ADR" : `accepted ADR (${scope.join(", ")})`, {
|
|
351
375
|
tier: TIER.DECISION,
|
|
352
376
|
// Best-first, deterministic: an ADR that NAMES this capability outranks
|
|
353
377
|
// an unscoped one — which is DECLARED to belong in every pack — which in
|
|
@@ -360,6 +384,13 @@ export async function run(positional, cmdFlags) {
|
|
|
360
384
|
title,
|
|
361
385
|
summary: adrSummary(text),
|
|
362
386
|
});
|
|
387
|
+
// A decision as a digest by default (change 0216): its title and the
|
|
388
|
+
// opening of its Decision — what tells a reader whether it binds the
|
|
389
|
+
// task. Sixteen whole ADRs were ~22k tokens of the `gates` pack.
|
|
390
|
+
if (adrItem && !full) {
|
|
391
|
+
adrItem.digest = adrDigest(text, title, id);
|
|
392
|
+
adrItem.tokens = estimateTokens(adrItem.digest);
|
|
393
|
+
}
|
|
363
394
|
}
|
|
364
395
|
|
|
365
396
|
// 6. Skills are on-demand: list name + description + trigger, never the
|
|
@@ -406,7 +437,8 @@ export async function run(positional, cmdFlags) {
|
|
|
406
437
|
for (const item of kept) {
|
|
407
438
|
console.log(`===== ${item.rel} (${item.note}) =====`);
|
|
408
439
|
console.log("");
|
|
409
|
-
console.log(item.degraded ? degradedBody(item)
|
|
440
|
+
console.log(item.degraded ? degradedBody(item)
|
|
441
|
+
: item.slice ?? item.digest ?? read(path.join(projectRoot, item.rel)).trimEnd());
|
|
410
442
|
console.log("");
|
|
411
443
|
}
|
|
412
444
|
// Keep the pack pipeable: the budget verdict goes to stderr.
|
|
@@ -417,7 +449,7 @@ export async function run(positional, cmdFlags) {
|
|
|
417
449
|
console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + forNote + scope + c.gray(" — read in this order:"));
|
|
418
450
|
console.log("");
|
|
419
451
|
kept.forEach((item, i) => {
|
|
420
|
-
const mark = item.degraded ? c.yellow(" summary") : " ";
|
|
452
|
+
const mark = item.degraded ? c.yellow(" summary") : item.slice ? c.yellow(" sliced") : item.digest ? c.yellow(" digest") : " ";
|
|
421
453
|
console.log(`${String(i + 1).padStart(2)}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(`~${String(item.tokens).padStart(5)} tok`)}${mark} ${c.gray(item.note)}`);
|
|
422
454
|
});
|
|
423
455
|
console.log("");
|
|
@@ -709,14 +741,21 @@ function changedPaths(projectRoot, ref) {
|
|
|
709
741
|
|
|
710
742
|
export const help = `
|
|
711
743
|
Usage: doctrina context [<capability>] [--for "<task>"] [--concat]
|
|
712
|
-
[--budget <tokens>] [--diff <ref>]
|
|
744
|
+
[--budget <tokens>] [--diff <ref>] [--full]
|
|
713
745
|
|
|
714
746
|
Print the exact context pack for a task, in the documented read
|
|
715
|
-
order:
|
|
716
|
-
|
|
747
|
+
order: product.md, the capability spec (when given — otherwise every
|
|
748
|
+
active spec), open changes, accepted ADRs. Skills are
|
|
717
749
|
listed name + description + when-trigger only — they are on-demand by
|
|
718
750
|
design. The change archive is excluded.
|
|
719
751
|
|
|
752
|
+
The pack is LEAN by default: AGENTS.md is not repeated (your agent
|
|
753
|
+
loads it every turn), an accepted ADR is a digest — its title and the
|
|
754
|
+
opening of its Decision; \`doctrina show NNNN\` reads the rest — and
|
|
755
|
+
with --for the task's spec is sliced to its headers, Purpose and the
|
|
756
|
+
requirements and criteria the task matches, the others named by count.
|
|
757
|
+
--full restores every text whole.
|
|
758
|
+
|
|
720
759
|
Assembly is retrieval, not a dump. A token budget ALWAYS applies
|
|
721
760
|
(default ${DEFAULT_BUDGET}; set "context_budget": <n> in
|
|
722
761
|
.doctrina/config.json, or pass --budget). Over budget, accepted ADRs
|
|
@@ -731,6 +770,8 @@ Flags:
|
|
|
731
770
|
the list — ready to pipe into an agent. The budget
|
|
732
771
|
verdict goes to stderr, keeping stdout pure.
|
|
733
772
|
--budget <n> Token ceiling for this call, overriding the project's.
|
|
773
|
+
--full Every artifact whole: AGENTS.md included, specs not
|
|
774
|
+
sliced, ADRs not digested (the pre-0.19 pack).
|
|
734
775
|
--diff <ref> Scope the stable artifacts (AGENTS.md, product.md,
|
|
735
776
|
specs, ADRs) to those changed since the git ref; open
|
|
736
777
|
changes are always included. The resume-session read.
|
|
@@ -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/next.js
CHANGED
|
@@ -82,6 +82,7 @@ export const RUNNERS = {
|
|
|
82
82
|
"change check": () => import("./change.js"),
|
|
83
83
|
"change apply": () => import("./change.js"),
|
|
84
84
|
"change archive": () => import("./change.js"),
|
|
85
|
+
close: () => import("./close.js"),
|
|
85
86
|
"index rebuild": () => import("./index-rebuild.js"),
|
|
86
87
|
};
|
|
87
88
|
|