doctrina-cli 0.20.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 +2 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +92 -14
- package/src/commands/clarify.js +1 -204
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +21 -1
- package/src/commands/handoff.js +11 -2
- package/src/commands/init.js +13 -1
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +15 -0
- package/src/commands/next.js +41 -6
- package/src/commands/prime.js +53 -4
- 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 +18 -0
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +32 -4
- package/src/commands/work.js +43 -8
- package/src/commands/workspace.js +73 -63
- package/src/index.js +98 -62
- package/src/lib/actions.js +43 -3
- package/src/lib/adapters.js +63 -1
- package/src/lib/args.js +33 -1
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +576 -103
- package/src/lib/commands.js +8 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/delivery.js +88 -10
- 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/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/task-card.js +100 -7
- package/src/lib/views.js +3 -3
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +65 -18
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +63 -16
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +82 -130
- package/templates/AGENTS.md.template +1 -1
- package/templates/README.md +2 -1
- 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/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +12 -6
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
5
|
+
import { git, GIT_STATE } from "./git.js";
|
|
6
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
7
|
+
import { listHeader } from "./scan.js";
|
|
8
|
+
import { absorbCommand, findWorkspace, pointerPath, projectAt, projectState, readWorkspace, linkPlan, shadowDirOf } from "./workspace.js";
|
|
9
|
+
import { deliveryBlockers, deliveryChanges, listDeliveries, rankProjects, readDelivery, releaseBoxes } from "./delivery.js";
|
|
10
|
+
import { cdThen, qarg, qpath } from "./shell.js";
|
|
11
|
+
|
|
12
|
+
// THE WORKSPACE ROOT IS WHERE THE PLANNER WORKS (change 0230).
|
|
13
|
+
//
|
|
14
|
+
// `prime` knew it: at a workspace root it printed the orchestrator's primer.
|
|
15
|
+
// Every other orientation command answered as if the directory were an empty
|
|
16
|
+
// project — `next` told the planner to run `doctrina init` there, which
|
|
17
|
+
// would have created a project inside the workspace, and `handoff`, `status`,
|
|
18
|
+
// `validate` and `context` refused with "not a Doctrina project". The planner
|
|
19
|
+
// lost exactly the commands AGENTS.md says to reach for before compaction and
|
|
20
|
+
// when unsure what comes next. This module decides what each of them says at
|
|
21
|
+
// the root; the commands render it (ADR 0025).
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The workspace this directory is the ROOT of — reached by the directory or
|
|
25
|
+
* $DOCTRINA_WORKSPACE, with no `.doctrina/` of its own. A project inside a
|
|
26
|
+
* workspace keeps its own answers; only the pointer route is never a root.
|
|
27
|
+
* @returns {string | null}
|
|
28
|
+
*/
|
|
29
|
+
export function workspaceRootAt(cwd) {
|
|
30
|
+
if (existsSync(path.join(cwd, ".doctrina"))) return null;
|
|
31
|
+
const found = findWorkspace(cwd);
|
|
32
|
+
return found && found.via !== "pointer" ? found.dir : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where the workspace stands: every project with its state, every open
|
|
37
|
+
* delivery with its changes and what keeps it open.
|
|
38
|
+
*/
|
|
39
|
+
export function workspaceSnapshot(wsDir) {
|
|
40
|
+
const ws = readWorkspace(wsDir);
|
|
41
|
+
const projects = ws.projects.map((p) => ({ project: p, ...projectState(wsDir, p) }));
|
|
42
|
+
const deliveries = listDeliveries(wsDir).map((d) => readDelivery(wsDir, d.id));
|
|
43
|
+
const open = deliveries.filter((d) => d.status !== "closed").map((d) => ({
|
|
44
|
+
delivery: d,
|
|
45
|
+
rows: deliveryChanges(wsDir, ws, d),
|
|
46
|
+
blockers: deliveryBlockers(wsDir, ws, d),
|
|
47
|
+
release: releaseBoxes(d.text),
|
|
48
|
+
}));
|
|
49
|
+
return { ws, wsDir, projects, deliveries, open };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The next actions at the workspace root, in the order they unblock work:
|
|
54
|
+
* a missing repository first, then a project that cannot be routed to, then
|
|
55
|
+
* each open delivery's next step, then — with nothing open — a new delivery.
|
|
56
|
+
* @returns {Array<{ text: string, command: string }>}
|
|
57
|
+
*/
|
|
58
|
+
export function workspaceActions(snap) {
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const p of snap.projects.filter((x) => x.state === "missing")) {
|
|
61
|
+
out.push({ text: `${p.project.name}'s repository is not at its path — re-link it`, command: `doctrina workspace attach ${p.project.name} --path <dir>` });
|
|
62
|
+
}
|
|
63
|
+
for (const p of snap.projects.filter((x) => x.state === "pending")) {
|
|
64
|
+
if (p.reason === "its shadow folder in the workspace is gone") {
|
|
65
|
+
out.push({ text: `${p.project.name}'s shadow projects/${p.project.name}/ is gone — restore it from the workspace's git, or scaffold it again`, command: absorbCommand(p.project, p.reason) });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
out.push(p.reason === "intake not converted"
|
|
69
|
+
? { text: `${p.project.name}'s absorb is under way (${p.reason}) — finish its playbook, then \`doctrina intake --converted\` there`, command: absorbCommand(p.project, p.reason) }
|
|
70
|
+
: { text: `${p.project.name} has no specs yet (${p.reason}) — absorb it before routing work to it`, command: absorbCommand(p.project, p.reason) });
|
|
71
|
+
}
|
|
72
|
+
for (const { delivery: d, rows, blockers } of snap.open) {
|
|
73
|
+
if (d.projects.length === 0) {
|
|
74
|
+
out.push({ text: `delivery ${d.id} is routed nowhere yet`, command: `doctrina delivery route ${d.id} <a,b>` });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let moved = false;
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
if (row.missing || row.absent) continue;
|
|
80
|
+
if (row.changes.length === 0 && row.abandoned?.length) {
|
|
81
|
+
const a = row.abandoned[row.abandoned.length - 1];
|
|
82
|
+
out.push({ text: `${row.project}'s change ${a.id} for ${d.id} was abandoned${a.reason ? ` (${a.reason})` : ""} — re-route the delivery without ${row.project}, or open a new change there`, command: `doctrina delivery route ${d.id} ${d.projects.filter((n) => n !== row.project).join(",") || "<a,b>"}` });
|
|
83
|
+
moved = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (row.changes.length === 0) {
|
|
87
|
+
out.push({ text: `${row.project} carries no change for ${d.id}`, command: cdThen(row.path, `doctrina work ${qarg(d.title)} --delivery ${d.id}`) });
|
|
88
|
+
moved = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
for (const ch of row.changes.filter((x) => !x.archived)) {
|
|
92
|
+
if (ch.placeholders > 0) {
|
|
93
|
+
out.push({ text: `${row.project}: ${ch.id} still holds the scaffold's placeholder tasks — write its delta and its tasks as cards before handing one out`, command: cdThen(row.path, `doctrina work --resume ${ch.id}`) });
|
|
94
|
+
moved = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
out.push(ch.tasksDone < ch.tasksTotal
|
|
98
|
+
? { text: `${row.project}: ${ch.id} has ${ch.tasksTotal - ch.tasksDone} task${ch.tasksTotal - ch.tasksDone === 1 ? "" : "s"} left — hand the next card to an executor`, command: cdThen(row.path, `doctrina change card ${ch.id}`) }
|
|
99
|
+
: { text: `${row.project}: ${ch.id} has every task ticked — review and close it`, command: cdThen(row.path, `doctrina close ${ch.id}`) });
|
|
100
|
+
moved = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (moved) continue;
|
|
104
|
+
out.push(blockers.length === 0
|
|
105
|
+
? { text: `delivery ${d.id} is ready to close`, command: `doctrina delivery close ${d.id}` }
|
|
106
|
+
: recordAction(d, blockers[0]));
|
|
107
|
+
}
|
|
108
|
+
if (snap.open.length === 0 && snap.projects.length > 0 && out.length === 0) {
|
|
109
|
+
out.push({ text: "no delivery is open — an item of work across repositories opens one", command: 'doctrina delivery new "<title>" --ref <id>' });
|
|
110
|
+
}
|
|
111
|
+
if (snap.projects.length === 0) {
|
|
112
|
+
out.push({ text: "the workspace registers no repository yet", command: "doctrina workspace add <path> --role <r> --about \"<what it is>\"" });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The step that clears a delivery's first record gap — the command for it, not the check. */
|
|
118
|
+
function recordAction(d, blocker) {
|
|
119
|
+
const file = `deliveries/${d.id}/delivery.md`;
|
|
120
|
+
if (/^## Story/.test(blocker)) return { text: `delivery ${d.id}: copy the item's story and acceptance criteria, verbatim, under ## Story in ${file}`, command: `doctrina delivery check ${d.id}` };
|
|
121
|
+
if (/^## Release/.test(blocker)) return { text: `delivery ${d.id}: record the release evidence under ## Release and tick each claim`, command: `doctrina delivery tick ${d.id}` };
|
|
122
|
+
if (/^## Observability/.test(blocker)) return { text: `delivery ${d.id}: record what was watched after the release under ## Observability in ${file}`, command: `doctrina delivery check ${d.id}` };
|
|
123
|
+
return { text: `delivery ${d.id}: ${blocker}`, command: `doctrina delivery check ${d.id}` };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Structural findings about the workspace itself — the integrity
|
|
128
|
+
* `doctrina validate` checks at its root. Errors break routing or a close;
|
|
129
|
+
* warnings are worth knowing.
|
|
130
|
+
* @returns {{ errors: string[], warnings: string[] }}
|
|
131
|
+
*/
|
|
132
|
+
export function workspaceFindings(snap) {
|
|
133
|
+
const errors = [];
|
|
134
|
+
const warnings = [];
|
|
135
|
+
const names = new Set(snap.ws.projects.map((p) => p.name));
|
|
136
|
+
for (const p of snap.ws.projects) {
|
|
137
|
+
for (const dep of p.dependsOn ?? []) if (!names.has(dep)) errors.push(`project ${p.name} depends on "${dep}", which the workspace does not register`);
|
|
138
|
+
}
|
|
139
|
+
for (const p of snap.projects) {
|
|
140
|
+
if (p.state === "missing") errors.push(`project ${p.project.name}: ${p.reason} — \`doctrina workspace attach ${p.project.name} --path <dir>\``);
|
|
141
|
+
if (p.project.mode !== "shadow" || p.state === "missing") continue;
|
|
142
|
+
const shadow = shadowDirOf(snap.wsDir, p.project.name);
|
|
143
|
+
if (!isDir(shadow)) {
|
|
144
|
+
errors.push(`shadow ${p.project.name}: projects/${p.project.name}/ is gone — restore it from the workspace's git, or \`doctrina workspace attach ${p.project.name}\` scaffolds it again`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const plan = linkPlan(shadow, p.project.path);
|
|
148
|
+
const unlinked = plan.filter((l) => l.action === "link");
|
|
149
|
+
const conflicts = plan.filter((l) => l.action === "conflict");
|
|
150
|
+
if (unlinked.length > 0) errors.push(`shadow ${p.project.name}: ${unlinked.length} link${unlinked.length === 1 ? "" : "s"} missing from the repository — \`doctrina workspace attach ${p.project.name}\``);
|
|
151
|
+
if (conflicts.length > 0) warnings.push(`shadow ${p.project.name}: the repository has its own ${conflicts.map((l) => l.rel).slice(0, 3).join(", ")} where a link would go`);
|
|
152
|
+
}
|
|
153
|
+
// An in-repo project owns its hook files, so the workspace does not write
|
|
154
|
+
// them (third audit, change 0234): say when they leave the declared tools
|
|
155
|
+
// unguarded for a session opened in that repository.
|
|
156
|
+
if (snap.ws.tools.length > 0) {
|
|
157
|
+
for (const p of snap.projects) {
|
|
158
|
+
if (p.project.mode !== "in-repo" || p.state === "missing") continue;
|
|
159
|
+
for (const [dir, file] of [[".cursor", "hooks.json"], [".claude", "settings.json"]]) {
|
|
160
|
+
if (!isDir(path.join(p.project.path, dir))) continue;
|
|
161
|
+
const f = path.join(p.project.path, dir, file);
|
|
162
|
+
if (!isFile(f) || !read(f).includes("doctrina workspace guard")) {
|
|
163
|
+
warnings.push(`project ${p.project.name} is in-repo and its ${dir}/${file} runs no \`doctrina workspace guard\` — the tools the workspace declares are unguarded in a session opened there; copy the guard entry from the workspace root's ${dir}/${file}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// A declared tool whose server no MCP configuration names (fourth audit,
|
|
169
|
+
// change 0235). The guard matches on the server's KEY in the agent's
|
|
170
|
+
// config; the preset guesses "atlassian", and a server the operator named
|
|
171
|
+
// "jira-cloud" made every call through it an undeclared one — allowed.
|
|
172
|
+
const servers = mcpServerNames([snap.wsDir, ...snap.ws.projects.map((p) => p.path)]);
|
|
173
|
+
if (servers.found) {
|
|
174
|
+
for (const tool of snap.ws.tools.filter((x) => x.enabled !== false)) {
|
|
175
|
+
if (servers.names.has(tool.server)) continue;
|
|
176
|
+
const near = [...servers.names].find((n) => n.toLowerCase() === String(tool.server).toLowerCase());
|
|
177
|
+
warnings.push(near
|
|
178
|
+
? `tool ${tool.name} declares server "${tool.server}", but the MCP configuration names it "${near}" — the guard matches the exact key: \`doctrina workspace tool ${tool.name} --server ${near}\``
|
|
179
|
+
: `tool ${tool.name} declares server "${tool.server}", which no MCP configuration here names (${[...servers.names].sort().join(", ") || "none"}) — the guard never sees its calls: \`doctrina workspace tool ${tool.name} --server <the key in mcp.json>\``);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// A dependency the manifests declare and the registry does not (second
|
|
183
|
+
// audit, change 0233): `delivery route` orders projects by --depends-on, so
|
|
184
|
+
// a front end that imports the shared library's package but was registered
|
|
185
|
+
// without the edge could be changed before the library it builds on.
|
|
186
|
+
const pkgs = new Map(snap.ws.projects.map((p) => [p.name, packageFacts(p.path)]));
|
|
187
|
+
for (const p of snap.ws.projects) {
|
|
188
|
+
const mine = pkgs.get(p.name);
|
|
189
|
+
if (!mine) continue;
|
|
190
|
+
for (const other of snap.ws.projects) {
|
|
191
|
+
const theirs = pkgs.get(other.name);
|
|
192
|
+
if (other === p || !theirs?.name || !mine.deps.has(theirs.name) || (p.dependsOn ?? []).includes(other.name)) continue;
|
|
193
|
+
warnings.push(`project ${p.name}'s package.json depends on ${theirs.name}, which is ${other.name} — declare it so routing orders them: \`doctrina workspace attach ${p.name} --depends-on ${[...(p.dependsOn ?? []), other.name].join(",")}\``);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const d of snap.deliveries) {
|
|
197
|
+
const status = (listHeader(d.text, "Status") ?? "").trim().toLowerCase();
|
|
198
|
+
if (!["open", "closed"].includes(status)) errors.push(`delivery ${d.id}: Status must be open or closed (got "${status}")`);
|
|
199
|
+
for (const name of d.projects) if (!names.has(name)) errors.push(`delivery ${d.id} routes "${name}", which the workspace does not register`);
|
|
200
|
+
if (status === "closed" && !listHeader(d.text, "Closed")) warnings.push(`delivery ${d.id} is closed with no Closed date`);
|
|
201
|
+
// A change that names this delivery from a project it does not route
|
|
202
|
+
// (change 0234): no status, check or close of the delivery counts it.
|
|
203
|
+
const outside = snap.ws.projects.map((p) => p.name).filter((n) => !d.projects.includes(n));
|
|
204
|
+
for (const row of deliveryChanges(snap.wsDir, snap.ws, { ...d, projects: outside })) {
|
|
205
|
+
for (const ch of row.changes.filter((x) => !x.archived)) {
|
|
206
|
+
warnings.push(`${row.project}'s change ${ch.id} names delivery ${d.id}, which does not route ${row.project} — route it back (\`doctrina delivery route ${d.id} ${[...d.projects, row.project].join(",")}\`) or close the change outside it`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!isFile(path.join(snap.wsDir, "AGENTS.md"))) warnings.push("the workspace has no AGENTS.md hub — any workspace write regenerates it");
|
|
211
|
+
return { errors, warnings };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The resume note for the next planner session — `handoff` at the root. */
|
|
215
|
+
export function workspaceHandoff(snap, date) {
|
|
216
|
+
const L = [`# Doctrina workspace handoff — ${snap.ws.name} (${date})`, ""];
|
|
217
|
+
L.push("## Projects", "");
|
|
218
|
+
for (const p of snap.projects) {
|
|
219
|
+
const open = p.open.length ? ` · open: ${p.open.map((o) => o.id ?? o).join(", ")}` : "";
|
|
220
|
+
L.push(`- **${p.project.name}** (${p.project.mode}, ${p.state}${p.project.role ? `, ${p.project.role}` : ""}) — ${p.reason}${open} · \`${p.project.path}\``);
|
|
221
|
+
}
|
|
222
|
+
if (snap.projects.length === 0) L.push("- none registered");
|
|
223
|
+
L.push("", "## Open deliveries", "");
|
|
224
|
+
for (const { delivery: d, rows, blockers, release } of snap.open) {
|
|
225
|
+
L.push(`### ${d.id} — ${d.title}${d.ref && d.ref !== "—" ? ` (${d.ref})` : ""}`, "");
|
|
226
|
+
for (const row of rows) {
|
|
227
|
+
if (row.missing) { L.push(`- ${row.project}: ${row.missing}`); continue; }
|
|
228
|
+
if (row.changes.length === 0 && row.abandoned?.length) { L.push(`- ${row.project}: ${row.abandoned.map((a) => `${a.id} abandoned${a.reason ? ` (${a.reason})` : ""}`).join(", ")} — no change left`); continue; }
|
|
229
|
+
if (row.changes.length === 0) { L.push(`- ${row.project}: no change yet`); continue; }
|
|
230
|
+
for (const ch of row.changes) L.push(`- ${row.project}: ${ch.id} — ${ch.status}, tasks ${ch.tasksDone}/${ch.tasksTotal}`);
|
|
231
|
+
}
|
|
232
|
+
if (release.length) L.push(`- Release: ${release.filter((b) => b.checked).length}/${release.length} claims ticked`);
|
|
233
|
+
if (blockers.length) L.push(`- Before the close: ${blockers.length} — \`doctrina delivery check ${d.id}\``);
|
|
234
|
+
L.push("");
|
|
235
|
+
}
|
|
236
|
+
if (snap.open.length === 0) L.push("- none", "");
|
|
237
|
+
L.push("## Next actions", "");
|
|
238
|
+
workspaceActions(snap).slice(0, 8).forEach((a, i) => L.push(`${i + 1}. ${a.text} — \`${a.command}\``));
|
|
239
|
+
L.push("", "*Commit the workspace: it is the version history of every shadow's context. Regenerate with `doctrina handoff`.*");
|
|
240
|
+
return L;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The projects a task is most likely about — `context --for` at the root,
|
|
245
|
+
* which cannot read one repository's specs for all of them.
|
|
246
|
+
*/
|
|
247
|
+
export function contextRouting(snap, task) {
|
|
248
|
+
return rankProjects(snap.wsDir, snap.ws, task).map((r) => ({
|
|
249
|
+
name: r.project.name, path: r.project.path, score: r.score, shared: r.shared,
|
|
250
|
+
state: snap.projects.find((p) => p.project.name === r.project.name)?.state ?? "missing",
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One-glance status at the root — `status`. */
|
|
255
|
+
export function workspaceStatus(snap) {
|
|
256
|
+
const ready = snap.projects.filter((p) => p.state === "ready").length;
|
|
257
|
+
const openChanges = snap.projects.reduce((n, p) => n + p.open.length, 0);
|
|
258
|
+
return {
|
|
259
|
+
name: snap.ws.name,
|
|
260
|
+
projects: snap.projects.length, ready,
|
|
261
|
+
pending: snap.projects.filter((p) => p.state === "pending").map((p) => p.project.name),
|
|
262
|
+
missing: snap.projects.filter((p) => p.state === "missing").map((p) => p.project.name),
|
|
263
|
+
openChanges,
|
|
264
|
+
deliveries: { open: snap.open.length, closed: snap.deliveries.length - snap.open.length },
|
|
265
|
+
tools: snap.ws.tools.length,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* For `prime` inside a project of a workspace: the workspace it belongs to
|
|
271
|
+
* and the open deliveries routed to it — an executor or a session opened in
|
|
272
|
+
* the repository otherwise never learns a delivery is waiting on it.
|
|
273
|
+
* @returns {{ name: string, dir: string, project: string, deliveries: Array<{ id: string, title: string, changes: string[], open: number }> } | null}
|
|
274
|
+
*/
|
|
275
|
+
export function projectWorkspaceLine(projectRoot) {
|
|
276
|
+
const found = findWorkspace(projectRoot);
|
|
277
|
+
if (!found) return null;
|
|
278
|
+
let ws;
|
|
279
|
+
try {
|
|
280
|
+
ws = readWorkspace(found.dir);
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
const project = projectAt(ws, projectRoot);
|
|
285
|
+
if (!project) return null;
|
|
286
|
+
const deliveries = [];
|
|
287
|
+
for (const d of listDeliveries(found.dir)) {
|
|
288
|
+
const full = readDelivery(found.dir, d.id);
|
|
289
|
+
if (full.status === "closed" || !full.projects.includes(project.name)) continue;
|
|
290
|
+
const row = deliveryChanges(found.dir, ws, full).find((r) => r.project === project.name);
|
|
291
|
+
const changes = row?.changes ?? [];
|
|
292
|
+
deliveries.push({ id: full.id, title: full.title, changes: changes.map((ch) => `${ch.id} ${ch.status}`), open: changes.filter((ch) => !ch.archived).length });
|
|
293
|
+
}
|
|
294
|
+
return { name: ws.name, dir: found.dir, project: project.name, deliveries };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The package name a repository publishes and the names it depends on — from its root package.json. */
|
|
298
|
+
function packageFacts(dir) {
|
|
299
|
+
const file = path.join(dir, "package.json");
|
|
300
|
+
if (!isFile(file)) return null;
|
|
301
|
+
try {
|
|
302
|
+
const pkg = JSON.parse(read(file));
|
|
303
|
+
const deps = new Set(["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]
|
|
304
|
+
.flatMap((k) => Object.keys(pkg?.[k] ?? {})));
|
|
305
|
+
return { name: typeof pkg?.name === "string" ? pkg.name : null, deps };
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A clone of a SHADOWED repository at another path (third audit, change
|
|
313
|
+
* 0234): it has no `.doctrina/` — the links live in the registered clone —
|
|
314
|
+
* so every command answered "run `doctrina init`", which would plant a
|
|
315
|
+
* `.doctrina/` in a repository that may not carry one. Matched by the git
|
|
316
|
+
* remote: the clone's origin is the registered clone, or both share an origin.
|
|
317
|
+
* The registered repository itself, with its links gone, is returned with
|
|
318
|
+
* `same: true`: `attach` alone links it again.
|
|
319
|
+
* @returns {{ wsDir: string, name: string, path: string, same?: boolean } | null}
|
|
320
|
+
*/
|
|
321
|
+
export function shadowedCloneAt(cwd) {
|
|
322
|
+
if (existsSync(path.join(cwd, ".doctrina"))) return null;
|
|
323
|
+
// The pointer route finds a workspace only for a REGISTERED path; a clone
|
|
324
|
+
// elsewhere is exactly what it cannot match, so the pointer is read here.
|
|
325
|
+
const pointed = isFile(pointerPath()) ? read(pointerPath()).trim() : "";
|
|
326
|
+
const found = findWorkspace(cwd) ?? (pointed && isFile(path.join(pointed, "workspace.json")) ? { dir: pointed } : null);
|
|
327
|
+
if (!found) return null;
|
|
328
|
+
const origin = (/** @type {string} */ dir) => {
|
|
329
|
+
const r = git(dir, ["remote", "get-url", "origin"]);
|
|
330
|
+
return r.state === GIT_STATE.OK ? r.stdout.trim().replace(/\.git$/, "").replace(/\/+$/, "") : null;
|
|
331
|
+
};
|
|
332
|
+
const real = (/** @type {string} */ p) => {
|
|
333
|
+
try {
|
|
334
|
+
return realpathSync(p);
|
|
335
|
+
} catch {
|
|
336
|
+
return path.resolve(p);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
let ws;
|
|
340
|
+
try {
|
|
341
|
+
ws = readWorkspace(found.dir);
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
// The registered repository itself, its links gone (change 0235): attach.
|
|
346
|
+
const same = ws.projects.find((x) => x.mode === "shadow" && real(x.path) === real(cwd));
|
|
347
|
+
if (same) return { wsDir: found.dir, name: same.name, path: same.path, same: true };
|
|
348
|
+
const mine = origin(cwd);
|
|
349
|
+
if (!mine) return null;
|
|
350
|
+
for (const p of ws.projects.filter((x) => x.mode === "shadow")) {
|
|
351
|
+
const theirs = isDir(p.path) ? origin(p.path) : null;
|
|
352
|
+
const local = !/^[a-z][a-z0-9+.-]*:\/\/|^[^/\\]+@[^:]+:/i.test(mine) && isDir(path.resolve(cwd, mine)) && real(path.resolve(cwd, mine)) === real(p.path);
|
|
353
|
+
if (local || (theirs && theirs === mine)) return { wsDir: found.dir, name: p.name, path: p.path };
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The MCP server keys the agents' configurations declare: each folder's
|
|
360
|
+
* `.cursor/mcp.json` and `.mcp.json`, the user's `~/.cursor/mcp.json`, and
|
|
361
|
+
* `~/.claude.json` (its own servers and each project's). `found` is false
|
|
362
|
+
* when no configuration could be read — then nothing can be said.
|
|
363
|
+
* @param {string[]} dirs
|
|
364
|
+
*/
|
|
365
|
+
export function mcpServerNames(dirs, home = os.homedir()) {
|
|
366
|
+
const names = new Set();
|
|
367
|
+
let found = false;
|
|
368
|
+
const take = (/** @type {unknown} */ servers) => {
|
|
369
|
+
if (servers && typeof servers === "object") for (const k of Object.keys(servers)) names.add(k);
|
|
370
|
+
};
|
|
371
|
+
const files = [...dirs.flatMap((d) => [path.join(d, ".cursor", "mcp.json"), path.join(d, ".mcp.json")]),
|
|
372
|
+
path.join(home, ".cursor", "mcp.json"), path.join(home, ".claude.json")];
|
|
373
|
+
for (const file of files) {
|
|
374
|
+
if (!isFile(file)) continue;
|
|
375
|
+
let json;
|
|
376
|
+
try {
|
|
377
|
+
json = JSON.parse(read(file));
|
|
378
|
+
} catch {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
found = true;
|
|
382
|
+
take(json?.mcpServers);
|
|
383
|
+
if (json?.projects && typeof json.projects === "object") for (const p of Object.values(json.projects)) take(p?.mcpServers);
|
|
384
|
+
}
|
|
385
|
+
return { found, names };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The command that links a shadowed repository's context into `cwd`. */
|
|
389
|
+
export function attachCommand(clone, cwd) {
|
|
390
|
+
return cdThen(clone.wsDir, clone.same ? `doctrina workspace attach ${clone.name}` : `doctrina workspace attach ${clone.name} --path ${qpath(cwd)}`);
|
|
391
|
+
}
|
package/src/lib/workspace.js
CHANGED
|
@@ -4,11 +4,11 @@ import {
|
|
|
4
4
|
realpathSync, rmdirSync, statSync, symlinkSync, unlinkSync, copyFileSync,
|
|
5
5
|
} from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import os from "node:os";
|
|
8
7
|
import { isDir, isFile, read, toPosix, write } from "./fs-ops.js";
|
|
9
8
|
import { listHeader } from "./scan.js";
|
|
10
9
|
import { openChanges } from "./snapshot.js";
|
|
11
10
|
import { git as runGit, GIT_STATE } from "./git.js";
|
|
11
|
+
import { cdThen } from "./shell.js";
|
|
12
12
|
|
|
13
13
|
// A WORKSPACE holds the context of several repositories in one place (ADR
|
|
14
14
|
// 0029): which projects exist, what each one is, how they depend on each
|
|
@@ -24,148 +24,49 @@ import { git as runGit, GIT_STATE } from "./git.js";
|
|
|
24
24
|
// the repository, because from its working directory `.doctrina/` is simply
|
|
25
25
|
// there.
|
|
26
26
|
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return {
|
|
37
|
-
doctrina_workspace: WORKSPACE_SCHEMA,
|
|
38
|
-
name,
|
|
39
|
-
projects: [],
|
|
40
|
-
tools: [],
|
|
41
|
-
agents: { planner: { model: "inherit" }, executor: { model: "inherit" } },
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function isWorkspaceDir(dir) {
|
|
46
|
-
const p = path.join(dir, WORKSPACE_FILE);
|
|
47
|
-
if (!isFile(p)) return false;
|
|
48
|
-
try {
|
|
49
|
-
return JSON.parse(read(p)).doctrina_workspace !== undefined;
|
|
50
|
-
} catch {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** The workspace document, with every optional list present. */
|
|
56
|
-
export function readWorkspace(dir) {
|
|
57
|
-
const ws = JSON.parse(read(path.join(dir, WORKSPACE_FILE)));
|
|
58
|
-
ws.projects = Array.isArray(ws.projects) ? ws.projects : [];
|
|
59
|
-
ws.tools = Array.isArray(ws.tools) ? ws.tools : [];
|
|
60
|
-
ws.agents = ws.agents && typeof ws.agents === "object" ? ws.agents : {};
|
|
61
|
-
return ws;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function writeWorkspace(dir, ws) {
|
|
65
|
-
write(path.join(dir, WORKSPACE_FILE), JSON.stringify(ws, null, 2) + "\n", { force: true });
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Where the CLI keeps its per-user state: `$DOCTRINA_HOME`, else `~/.doctrina`. */
|
|
69
|
-
export function doctrinaHome(env = process.env) {
|
|
70
|
-
return env.DOCTRINA_HOME && env.DOCTRINA_HOME.trim() !== ""
|
|
71
|
-
? path.resolve(env.DOCTRINA_HOME)
|
|
72
|
-
: path.join(os.homedir(), ".doctrina");
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** The pointer `workspace init` leaves, so an in-repo project can find its workspace. */
|
|
76
|
-
export function pointerPath(env = process.env) {
|
|
77
|
-
return path.join(doctrinaHome(env), "workspace");
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function writePointer(dir, env = process.env) {
|
|
81
|
-
write(pointerPath(env), path.resolve(dir) + "\n", { force: true });
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function ancestors(start) {
|
|
85
|
-
const out = [];
|
|
86
|
-
let cur = path.resolve(start);
|
|
87
|
-
for (;;) {
|
|
88
|
-
out.push(cur);
|
|
89
|
-
const up = path.dirname(cur);
|
|
90
|
-
if (up === cur) return out;
|
|
91
|
-
cur = up;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The workspace this directory belongs to, or null. In order:
|
|
97
|
-
* 1. `$DOCTRINA_WORKSPACE`;
|
|
98
|
-
* 2. the directory or an ancestor holding `workspace.json` (the orchestrator
|
|
99
|
-
* runs at the workspace root);
|
|
100
|
-
* 3. the real path of `./.doctrina` — a shadow project's link points into
|
|
101
|
-
* its workspace;
|
|
102
|
-
* 4. the pointer `workspace init` wrote, when the workspace lists this
|
|
103
|
-
* directory as a project.
|
|
104
|
-
* @returns {{ dir: string, via: string } | null}
|
|
105
|
-
*/
|
|
106
|
-
export function findWorkspace(cwd, env = process.env) {
|
|
107
|
-
if (env.DOCTRINA_WORKSPACE && env.DOCTRINA_WORKSPACE.trim() !== "") {
|
|
108
|
-
const dir = path.resolve(env.DOCTRINA_WORKSPACE);
|
|
109
|
-
return isWorkspaceDir(dir) ? { dir, via: "DOCTRINA_WORKSPACE" } : null;
|
|
110
|
-
}
|
|
111
|
-
for (const dir of ancestors(cwd)) {
|
|
112
|
-
if (isWorkspaceDir(dir)) return { dir, via: "directory" };
|
|
113
|
-
}
|
|
114
|
-
const local = path.join(cwd, ".doctrina");
|
|
115
|
-
if (existsSync(local)) {
|
|
116
|
-
try {
|
|
117
|
-
const real = realpathSync(local);
|
|
118
|
-
if (real !== path.resolve(local)) {
|
|
119
|
-
for (const dir of ancestors(path.dirname(real))) {
|
|
120
|
-
if (isWorkspaceDir(dir)) return { dir, via: "shadow link" };
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
} catch { /* an unreadable link is no workspace */ }
|
|
124
|
-
}
|
|
125
|
-
const pointer = pointerPath(env);
|
|
126
|
-
if (isFile(pointer)) {
|
|
127
|
-
const dir = read(pointer).trim();
|
|
128
|
-
if (dir && isWorkspaceDir(dir) && projectAt(readWorkspace(dir), cwd)) return { dir, via: "pointer" };
|
|
129
|
-
}
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** The registered project whose path is `dir`, or null. */
|
|
134
|
-
export function projectAt(ws, dir) {
|
|
135
|
-
const want = safeReal(dir);
|
|
136
|
-
return ws.projects.find((p) => safeReal(p.path) === want) ?? null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function safeReal(p) {
|
|
140
|
-
try {
|
|
141
|
-
return realpathSync(p);
|
|
142
|
-
} catch {
|
|
143
|
-
return path.resolve(p);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function shadowDirOf(wsDir, name) {
|
|
148
|
-
return path.join(wsDir, "projects", name);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** The directory whose `.doctrina/` holds this project's artifacts. */
|
|
152
|
-
export function artifactRootOf(wsDir, project) {
|
|
153
|
-
return project.mode === "shadow" ? shadowDirOf(wsDir, project.name) : project.path;
|
|
154
|
-
}
|
|
27
|
+
export {
|
|
28
|
+
WORKSPACE_FILE, WORKSPACE_SCHEMA, PROJECT_MODES, PROJECT_NAME, EXCLUDE_BEGIN, EXCLUDE_END,
|
|
29
|
+
blankWorkspace, isWorkspaceDir, readWorkspace, writeWorkspace, doctrinaHome, pointerPath, writePointer,
|
|
30
|
+
findWorkspace, projectAt, shadowDirOf, artifactRootOf,
|
|
31
|
+
} from "./workspace-locate.js";
|
|
32
|
+
import {
|
|
33
|
+
EXCLUDE_BEGIN, EXCLUDE_END, isWorkspaceDir, readWorkspace, writeWorkspace, findWorkspace, projectAt,
|
|
34
|
+
shadowDirOf, artifactRootOf, WORKSPACE_FILE, doctrinaHome, pointerPath, blankWorkspace, writePointer, PROJECT_NAME, PROJECT_MODES, WORKSPACE_SCHEMA, safeReal,
|
|
35
|
+
} from "./workspace-locate.js";
|
|
155
36
|
|
|
156
37
|
/**
|
|
157
38
|
* Where a project stands. `missing` when its repository path is gone;
|
|
158
39
|
* `pending` while it has no spec or its intake is unconverted — the
|
|
159
40
|
* project still has to be absorbed; `ready` otherwise.
|
|
160
41
|
*/
|
|
42
|
+
const INTAKE_PENDING = "intake not converted";
|
|
43
|
+
// A shadow's folder in the workspace is gone (change 0235): `attach` restores it.
|
|
44
|
+
const SHADOW_GONE = "its shadow folder in the workspace is gone";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The command that moves a pending project on (second audit, change 0233).
|
|
48
|
+
* `--from-code` starts an absorb and refuses once the inventory is stored —
|
|
49
|
+
* every surface still named it for a project whose absorb was under way. A
|
|
50
|
+
* stored, pending intake reprints its playbook with a bare `doctrina intake`.
|
|
51
|
+
* @param {{ path: string, name?: string }} project
|
|
52
|
+
* @param {string} reason projectState's reason
|
|
53
|
+
*/
|
|
54
|
+
export function absorbCommand(project, reason) {
|
|
55
|
+
if (reason === SHADOW_GONE) return `doctrina workspace attach ${project.name ?? "<name>"}`;
|
|
56
|
+
return reason === INTAKE_PENDING
|
|
57
|
+
? cdThen(project.path, "doctrina intake")
|
|
58
|
+
: cdThen(project.path, "doctrina intake --from-code");
|
|
59
|
+
}
|
|
60
|
+
|
|
161
61
|
export function projectState(wsDir, project) {
|
|
162
62
|
if (!isDir(project.path)) return { state: "missing", reason: `path not found: ${project.path}`, open: [] };
|
|
163
63
|
const root = artifactRootOf(wsDir, project);
|
|
164
64
|
const doctrina = path.join(root, ".doctrina");
|
|
65
|
+
if (project.mode === "shadow" && !isDir(doctrina)) return { state: "pending", reason: SHADOW_GONE, open: [] };
|
|
165
66
|
if (!isDir(doctrina)) return { state: "pending", reason: "no .doctrina/ yet", open: [] };
|
|
166
67
|
const intake = path.join(doctrina, "intake.md");
|
|
167
68
|
if (isFile(intake) && /pending/i.test(listHeader(read(intake), "Status") ?? "")) {
|
|
168
|
-
return { state: "pending", reason:
|
|
69
|
+
return { state: "pending", reason: INTAKE_PENDING, open: openChanges(root) };
|
|
169
70
|
}
|
|
170
71
|
const specs = path.join(doctrina, "specs");
|
|
171
72
|
const count = isDir(specs) ? readdirSync(specs).filter((d) => isFile(path.join(specs, d, "spec.md"))).length : 0;
|
|
@@ -274,6 +175,57 @@ export function applyLinks(plan, shadowDir, repoDir) {
|
|
|
274
175
|
return done;
|
|
275
176
|
}
|
|
276
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The shadow folder a repository's `.doctrina/` links to, or null when the
|
|
180
|
+
* repository owns its `.doctrina/` (change 0238).
|
|
181
|
+
* @param {string} projectRoot
|
|
182
|
+
*/
|
|
183
|
+
export function shadowRootOf(projectRoot) {
|
|
184
|
+
const link = path.join(projectRoot, ".doctrina");
|
|
185
|
+
try {
|
|
186
|
+
if (!lstatSync(link).isSymbolicLink()) return null;
|
|
187
|
+
return path.dirname(realpathSync(link));
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Re-link a shadowed repository after its shadow gained or lost files
|
|
195
|
+
* (change 0238): a link for each new file, the links to removed files taken
|
|
196
|
+
* out, and the exclude block rewritten. A command that writes an adapter
|
|
197
|
+
* file for a shadowed repository writes it in the shadow and calls this —
|
|
198
|
+
* written in the repository it was a stray, untracked file.
|
|
199
|
+
* @param {string} projectRoot
|
|
200
|
+
* @param {Array<{ rel: string, content: string }>} gone the files the shadow lost, with what they held
|
|
201
|
+
* @returns {{ linked: string[], unlinked: string[] } | null} null for a repository that is not a shadow
|
|
202
|
+
*/
|
|
203
|
+
export function relinkShadow(projectRoot, gone = []) {
|
|
204
|
+
const shadow = shadowRootOf(projectRoot);
|
|
205
|
+
if (!shadow) return null;
|
|
206
|
+
const unlinked = [];
|
|
207
|
+
for (const { rel, content } of gone) {
|
|
208
|
+
const at = path.join(projectRoot, rel);
|
|
209
|
+
let st;
|
|
210
|
+
try {
|
|
211
|
+
st = lstatSync(at);
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// A symlink to the removed file, or the hard link or copy a platform
|
|
216
|
+
// without symlinks got instead — still holding what the shadow held.
|
|
217
|
+
if (st.isSymbolicLink() || (st.isFile() && readFileSync(at, "utf8").replace(/\r\n/g, "\n").trim() === content.replace(/\r\n/g, "\n").trim())) {
|
|
218
|
+
unlinkSync(at);
|
|
219
|
+
unlinked.push(toPosix(rel));
|
|
220
|
+
pruneEmpty(path.dirname(at), projectRoot);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const plan = linkPlan(shadow, projectRoot);
|
|
224
|
+
const linked = applyLinks(plan, shadow, projectRoot).map((d) => d.rel);
|
|
225
|
+
writeExclude(projectRoot, excludeEntries(plan, projectRoot));
|
|
226
|
+
return { linked, unlinked };
|
|
227
|
+
}
|
|
228
|
+
|
|
277
229
|
/**
|
|
278
230
|
* Remove every link the shadow put in the repository — the link, never what
|
|
279
231
|
* it points at: `fs-ops.remove` follows a directory link and would empty
|
|
@@ -64,7 +64,7 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
|
|
|
64
64
|
- `doctrina verify` — the project's declared typecheck/test/build checks. *When:* the real build gate must run.
|
|
65
65
|
- `doctrina close <id...>` — the whole closing sequence in one attested pass. *When:* a change is implemented and ready to finish.
|
|
66
66
|
- `doctrina doctor` — aggregate diagnostic with per-finding remedies. *When:* something looks wrong and you do not know which gate to ask.
|
|
67
|
-
**Orchestrate** (triggers: `doctrina <command> --help`) — `doctrina workspace init|add|attach|detach|list|tool|guard` · `doctrina delivery new|route|list|status|check|close`
|
|
67
|
+
**Orchestrate** (triggers: `doctrina <command> --help`) — `doctrina workspace init|add|attach|detach|list|tool|guard` · `doctrina delivery new|route|list|status|tick|check|close`
|
|
68
68
|
**Maintain** (triggers: `doctrina <command> --help`) — `doctrina templates list` · `doctrina hooks install` · `doctrina index rebuild` · `doctrina watch` · `doctrina metrics` · `doctrina completion` · `doctrina upgrade --write` · `doctrina ci --emit github`
|
|
69
69
|
<!-- doctrina:surface:end -->
|
|
70
70
|
|
package/templates/README.md
CHANGED
|
@@ -27,6 +27,7 @@ replace. No conditionals, loops, or includes.
|
|
|
27
27
|
| `{{AGENTS_MD_PATH}}` | Path to root `AGENTS.md` from the adapter location | per adapter |
|
|
28
28
|
| `{{DELIVERY_ID}}` | Delivery folder identifier (`NNNN-slug`) | required in `delivery` |
|
|
29
29
|
| `{{DELIVERY_TITLE}}` | The delivery's title | required in `delivery` |
|
|
30
|
+
| `{{DELIVERY_TITLE_ARG}}` | The delivery's title quoted as one shell argument, for a command line | optional in the `delivery` playbook |
|
|
30
31
|
| `{{DELIVERY_REF}}` | The tracker item it comes from | `—` |
|
|
31
32
|
| `{{DELIVERY_PROJECTS}}` | The routed projects, dependencies first | `—` |
|
|
32
33
|
|
|
@@ -107,7 +108,7 @@ adapters/
|
|
|
107
108
|
claude/.claude/commands/doctrina-*.md.template native slash commands (core loop)
|
|
108
109
|
codex/README.md
|
|
109
110
|
cursor/.cursor/rules/00-doctrina.mdc.template
|
|
110
|
-
cursor/.cursor/
|
|
111
|
+
cursor/.cursor/skills/doctrina-*/SKILL.md.template the core loop as skills (explicit `/` invocation)
|
|
111
112
|
copilot/.github/copilot-instructions.md.template
|
|
112
113
|
gemini/GEMINI.md.template
|
|
113
114
|
aider/CONVENTIONS.md.template
|