doctrina-cli 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +202 -27
- package/src/commands/clarify.js +3 -206
- package/src/commands/close.js +1 -1
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/coverage.js +69 -6
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +83 -5
- package/src/commands/handoff.js +11 -2
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +64 -8
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +16 -1
- package/src/commands/next.js +41 -6
- package/src/commands/onboard.js +113 -0
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +40 -4
- package/src/commands/search.js +2 -2
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +62 -4
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +35 -7
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +90 -42
- package/src/commands/workspace.js +89 -66
- package/src/index.js +107 -62
- package/src/lib/actions.js +62 -3
- package/src/lib/adapters.js +83 -1
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +34 -2
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +594 -104
- package/src/lib/commands.js +58 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +90 -12
- package/src/lib/docs-impact.js +37 -2
- package/src/lib/fs-ops.js +103 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +5 -2
- package/src/lib/json-out.js +9 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +101 -8
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +5 -4
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +67 -20
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +104 -17
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +83 -131
- package/templates/AGENTS.md.template +9 -5
- package/templates/README.md +4 -2
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/adapters/kiro/README.md +22 -0
- package/templates/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +12 -6
|
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { exists, isDir, mkdirp, relPath, write } from "../lib/fs-ops.js";
|
|
8
|
-
import { flagString } from "../lib/args.js";
|
|
8
|
+
import { flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
9
9
|
import { c } from "../lib/colors.js";
|
|
10
10
|
import { suggest } from "../lib/suggest.js";
|
|
11
11
|
import { EXIT, PreconditionError } from "../lib/exit-codes.js";
|
|
@@ -14,8 +14,11 @@ import {
|
|
|
14
14
|
findWorkspace, isWorkspaceDir, linkPlan, projectAt, projectState, readWorkspace, removeLinks,
|
|
15
15
|
shadowDirOf, writeExclude, writePointer, writeWorkspace, gitInit,
|
|
16
16
|
} from "../lib/workspace.js";
|
|
17
|
-
import { TOOL_PRESETS,
|
|
17
|
+
import { TOOL_PRESETS, toolEntry } from "../lib/workspace-tools.js";
|
|
18
|
+
import { workspaceGuard } from "../lib/workspace-guard.js";
|
|
18
19
|
import { writeOrchestratorFiles } from "../lib/workspace-agents.js";
|
|
20
|
+
import { cdThen } from "../lib/shell.js";
|
|
21
|
+
import { mcpServerNames } from "../lib/workspace-view.js";
|
|
19
22
|
|
|
20
23
|
// `doctrina workspace` — the context of several repositories in one place
|
|
21
24
|
// (ADR 0029). The decisions live in lib/workspace.js; this module renders
|
|
@@ -26,7 +29,7 @@ const CLI_ENTRY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
|
|
|
26
29
|
|
|
27
30
|
export const flags = {
|
|
28
31
|
boolean: ["json", "no-git", "remove"],
|
|
29
|
-
string: ["name", "mode", "role", "about", "depends-on", "agent", "path", "server", "access", "for", "purpose", "hook"],
|
|
32
|
+
string: ["name", "mode", "role", "about", "depends-on", "agent", "path", "server", "access", "for", "purpose", "only", "hook"],
|
|
30
33
|
};
|
|
31
34
|
|
|
32
35
|
export async function run(positional, flags) {
|
|
@@ -140,22 +143,7 @@ function workspaceAdd(wsDir, pathArg, flags) {
|
|
|
140
143
|
about: flagString(flags, "about") ?? "",
|
|
141
144
|
dependsOn,
|
|
142
145
|
};
|
|
143
|
-
if (mode === "shadow")
|
|
144
|
-
const shadow = shadowDirOf(wsDir, name);
|
|
145
|
-
if (!isDir(path.join(shadow, ".doctrina"))) {
|
|
146
|
-
mkdirp(shadow);
|
|
147
|
-
const agent = flagString(flags, "agent") ?? "cursor";
|
|
148
|
-
const r = spawnSync(process.execPath, [CLI_ENTRY, "init", "--non-interactive",
|
|
149
|
-
"--project-name", name, "--project-description", project.about || `${name} (absorbed into a workspace)`,
|
|
150
|
-
"--agent", agent], { cwd: shadow, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } });
|
|
151
|
-
if (r.status !== 0) {
|
|
152
|
-
console.error(c.red("error:") + ` could not scaffold the shadow at ${shadow}`);
|
|
153
|
-
console.error(r.stderr.trim());
|
|
154
|
-
return EXIT.GATE;
|
|
155
|
-
}
|
|
156
|
-
console.log(c.green("created") + ` shadow ${relPath(wsDir, shadow)}/ (AGENTS.md, .doctrina/, ${agent} adapter)`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
146
|
+
if (mode === "shadow" && !scaffoldShadow(wsDir, project, flagString(flags, "agent") ?? "cursor")) return EXIT.GATE;
|
|
159
147
|
ws.projects.push(project);
|
|
160
148
|
writeWorkspace(wsDir, ws);
|
|
161
149
|
console.log(c.green("registered") + ` ${name} (${mode}) → ${repo}`);
|
|
@@ -185,7 +173,7 @@ function printProjectNext(wsDir, project) {
|
|
|
185
173
|
if (st.state === "pending") {
|
|
186
174
|
console.log("");
|
|
187
175
|
console.log(`Next: ${project.name} has no specs yet (${st.reason}) — absorb it from its code:`);
|
|
188
|
-
console.log(" " + c.cyan(
|
|
176
|
+
console.log(" " + c.cyan(cdThen(project.path, "doctrina intake --from-code")));
|
|
189
177
|
}
|
|
190
178
|
}
|
|
191
179
|
|
|
@@ -196,6 +184,23 @@ function findProject(ws, name) {
|
|
|
196
184
|
throw Object.assign(new Error(`no project named "${name ?? ""}" in this workspace` + (guess ? ` — did you mean "${guess}"?` : "")), { exitCode: EXIT.USAGE });
|
|
197
185
|
}
|
|
198
186
|
|
|
187
|
+
/** Scaffold a shadow's AGENTS.md, .doctrina/ and adapter when they are not there. */
|
|
188
|
+
function scaffoldShadow(wsDir, project, agent) {
|
|
189
|
+
const shadow = shadowDirOf(wsDir, project.name);
|
|
190
|
+
if (isDir(path.join(shadow, ".doctrina"))) return true;
|
|
191
|
+
mkdirp(shadow);
|
|
192
|
+
const r = spawnSync(process.execPath, [CLI_ENTRY, "init", "--non-interactive",
|
|
193
|
+
"--project-name", project.name, "--project-description", project.about || `${project.name} (absorbed into a workspace)`,
|
|
194
|
+
"--agent", agent], { cwd: shadow, encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } });
|
|
195
|
+
if (r.status !== 0) {
|
|
196
|
+
console.error(c.red("error:") + ` could not scaffold the shadow at ${shadow}`);
|
|
197
|
+
console.error(r.stderr.trim());
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
console.log(c.green("created") + ` shadow ${relPath(wsDir, shadow)}/ (AGENTS.md, .doctrina/, ${agent} adapter)`);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
199
204
|
function workspaceAttach(wsDir, name, flags) {
|
|
200
205
|
const ws = readWorkspace(wsDir);
|
|
201
206
|
const project = findProject(ws, name);
|
|
@@ -210,6 +215,20 @@ function workspaceAttach(wsDir, name, flags) {
|
|
|
210
215
|
writeWorkspace(wsDir, ws);
|
|
211
216
|
console.log(c.green("moved") + ` ${project.name} → ${repo}`);
|
|
212
217
|
}
|
|
218
|
+
// --depends-on on a registered project (second audit, change 0233): the
|
|
219
|
+
// edge `delivery route` orders by could only be given at `add`, before the
|
|
220
|
+
// project it names was always registered.
|
|
221
|
+
if (flags.has("depends-on")) {
|
|
222
|
+
const dependsOn = (flagString(flags, "depends-on") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
223
|
+
const unknown = dependsOn.filter((d) => d === project.name || !ws.projects.some((p) => p.name === d));
|
|
224
|
+
if (unknown.length > 0) {
|
|
225
|
+
console.error(c.red("error:") + ` --depends-on names no other registered project: ${unknown.join(", ")}`);
|
|
226
|
+
return EXIT.USAGE;
|
|
227
|
+
}
|
|
228
|
+
project.dependsOn = dependsOn;
|
|
229
|
+
writeWorkspace(wsDir, ws);
|
|
230
|
+
console.log(c.green("depends on") + ` ${project.name} → ${dependsOn.join(", ") || "nothing"}`);
|
|
231
|
+
}
|
|
213
232
|
if (project.mode !== "shadow") {
|
|
214
233
|
console.log(c.gray(`${project.name} is in-repo — its .doctrina/ is its own; nothing to link`));
|
|
215
234
|
return EXIT.OK;
|
|
@@ -219,6 +238,14 @@ function workspaceAttach(wsDir, name, flags) {
|
|
|
219
238
|
console.error(c.gray("hint: ") + `point it at the clone: \`doctrina workspace attach ${project.name} --path <dir>\``);
|
|
220
239
|
return EXIT.PRECONDITION;
|
|
221
240
|
}
|
|
241
|
+
// A shadow whose folder is gone (fourth audit, change 0235): attach
|
|
242
|
+
// crashed on ENOENT. A fresh, empty shadow is scaffolded and linked — the
|
|
243
|
+
// project is pending again, and says so; its history is the workspace's
|
|
244
|
+
// git, where a lost folder is restored from.
|
|
245
|
+
if (!isDir(path.join(shadowDirOf(wsDir, project.name), ".doctrina"))) {
|
|
246
|
+
console.log(c.yellow("warn") + ` ${project.name}'s shadow projects/${project.name}/ is gone — if the workspace's git has it, restore it (\`git checkout -- projects/${project.name}\`) before this; scaffolding an empty one`);
|
|
247
|
+
if (!scaffoldShadow(wsDir, project, flagString(flags, "agent") ?? "cursor")) return EXIT.GATE;
|
|
248
|
+
}
|
|
222
249
|
attachShadow(wsDir, project);
|
|
223
250
|
return EXIT.OK;
|
|
224
251
|
}
|
|
@@ -263,11 +290,18 @@ function workspaceTool(wsDir, name, flags) {
|
|
|
263
290
|
console.log(c.green("removed") + ` tool ${name}`);
|
|
264
291
|
return EXIT.OK;
|
|
265
292
|
}
|
|
293
|
+
// `--only` with nothing after it is not "no list": taking it for one
|
|
294
|
+
// removed the list a strict tool had, with exit 0 (change 0250).
|
|
295
|
+
if (flagGivenWithoutValue(flags, "only")) {
|
|
296
|
+
console.error(c.red("error:") + " --only needs the calls it allows, e.g. --only \"getJiraIssue,search*\" — or --only none to take the list away");
|
|
297
|
+
return EXIT.USAGE;
|
|
298
|
+
}
|
|
266
299
|
let entry;
|
|
267
300
|
try {
|
|
268
301
|
entry = toolEntry(name, {
|
|
269
302
|
server: flagString(flags, "server"), access: flagString(flags, "access"),
|
|
270
303
|
for: flagString(flags, "for"), purpose: flagString(flags, "purpose"),
|
|
304
|
+
only: flags.has("only") ? flagString(flags, "only") ?? "" : undefined,
|
|
271
305
|
}, at >= 0 ? ws.tools[at] : null);
|
|
272
306
|
} catch (err) {
|
|
273
307
|
console.error(c.red("error:") + ` ${err.message}`);
|
|
@@ -277,47 +311,20 @@ function workspaceTool(wsDir, name, flags) {
|
|
|
277
311
|
else ws.tools.push(entry);
|
|
278
312
|
writeWorkspace(wsDir, ws);
|
|
279
313
|
console.log(c.green(at >= 0 ? "updated" : "declared") + ` ${entry.name} → MCP server "${entry.server}", ${entry.access === "write" ? "read/write (each write asks the human)" : "read-only"}, for ${(entry.for ?? []).join(", ") || "the planner"}`);
|
|
314
|
+
if (entry.only) console.log(c.gray(`only these calls pass through "${entry.server}": ${entry.only.join(", ")} — every other call is denied`));
|
|
280
315
|
console.log(c.gray(`the server itself is configured in your agent (Cursor: .cursor/mcp.json; Claude Code: .mcp.json) under the name "${entry.server}"`));
|
|
316
|
+
// Said now, not only at the next validate (change 0235): a key that matches
|
|
317
|
+
// no configuration is a guard that never fires.
|
|
318
|
+
const servers = mcpServerNames([wsDir, ...ws.projects.map((p) => p.path)]);
|
|
319
|
+
if (servers.found && !servers.names.has(entry.server)) {
|
|
320
|
+
console.log(c.yellow("warn") + ` no MCP configuration here names a server "${entry.server}" (found: ${[...servers.names].sort().join(", ") || "none"}) — the guard only sees calls through the exact key; rerun with --server <that key>`);
|
|
321
|
+
}
|
|
281
322
|
return EXIT.OK;
|
|
282
323
|
}
|
|
283
324
|
|
|
284
325
|
// Reads the agent's pre-call payload on stdin and answers in the agent's
|
|
285
326
|
// own format. Never exits non-zero and never blocks on bad input: a broken
|
|
286
327
|
// guard must not take the agent's tools away.
|
|
287
|
-
function workspaceGuard(wsDir, hook) {
|
|
288
|
-
if (hook !== "cursor" && hook !== "claude") {
|
|
289
|
-
console.error(c.red("error:") + ` --hook needs cursor or claude (got "${hook ?? ""}")`);
|
|
290
|
-
return EXIT.USAGE;
|
|
291
|
-
}
|
|
292
|
-
let call = {};
|
|
293
|
-
try {
|
|
294
|
-
call = JSON.parse(readFileSync(0, "utf8") || "{}");
|
|
295
|
-
} catch {
|
|
296
|
-
call = {};
|
|
297
|
-
}
|
|
298
|
-
let server = null;
|
|
299
|
-
let tool = null;
|
|
300
|
-
if (hook === "cursor") {
|
|
301
|
-
server = call.mcp_server_name ?? null;
|
|
302
|
-
tool = call.tool_name ?? null;
|
|
303
|
-
} else {
|
|
304
|
-
const split = splitClaudeToolName(call.tool_name ?? "");
|
|
305
|
-
if (split) ({ server, tool } = split);
|
|
306
|
-
}
|
|
307
|
-
const tools = wsDir ? readWorkspace(wsDir).tools : [];
|
|
308
|
-
const verdict = server && tool ? guardDecision(tools, server, tool, call.tool_input) : { decision: "none", reason: "" };
|
|
309
|
-
if (hook === "cursor") {
|
|
310
|
-
const permission = verdict.decision === "none" ? "allow" : verdict.decision;
|
|
311
|
-
const out = { permission };
|
|
312
|
-
if (permission !== "allow") Object.assign(out, { user_message: verdict.reason, agent_message: verdict.reason });
|
|
313
|
-
console.log(JSON.stringify(out));
|
|
314
|
-
} else if (verdict.decision !== "none") {
|
|
315
|
-
console.log(JSON.stringify({ hookSpecificOutput: {
|
|
316
|
-
hookEventName: "PreToolUse", permissionDecision: verdict.decision, permissionDecisionReason: verdict.reason,
|
|
317
|
-
} }));
|
|
318
|
-
}
|
|
319
|
-
return EXIT.OK;
|
|
320
|
-
}
|
|
321
328
|
|
|
322
329
|
function workspaceList(wsDir) {
|
|
323
330
|
const ws = readWorkspace(wsDir);
|
|
@@ -340,22 +347,31 @@ function workspaceList(wsDir) {
|
|
|
340
347
|
const pending = ws.projects.filter((p) => projectState(wsDir, p).state === "pending");
|
|
341
348
|
if (pending.length > 0) {
|
|
342
349
|
console.log("");
|
|
343
|
-
console.log(`Next: absorb ${pending.map((p) => p.name).join(", ")} — ` + c.cyan(
|
|
350
|
+
console.log(`Next: absorb ${pending.map((p) => p.name).join(", ")} — ` + c.cyan(cdThen(pending[0].path, "doctrina intake --from-code")));
|
|
344
351
|
}
|
|
345
352
|
console.log("");
|
|
346
353
|
if (ws.tools.length === 0) {
|
|
347
|
-
|
|
354
|
+
// Named generically (change 0228): which tools a team connects is the
|
|
355
|
+
// team's choice, and a hint that names one product reads as the default.
|
|
356
|
+
console.log(c.gray("no tool declared — `doctrina workspace tool <name> --server <mcp-server> --for <roles>` (presets: " + Object.keys(TOOL_PRESETS).join(", ") + ")"));
|
|
348
357
|
} else {
|
|
349
358
|
console.log(c.bold("Tools"));
|
|
350
359
|
for (const t of ws.tools) {
|
|
351
360
|
console.log(` ${c.cyan(t.name.padEnd(12))} ${t.server.padEnd(12)} ${t.access.padEnd(6)} ${(t.for ?? []).join(", ").padEnd(16)} ${c.gray(t.purpose ?? "")}`);
|
|
361
|
+
if (t.only?.length) console.log(c.gray(` ${"".padEnd(12)} only: ${t.only.join(", ")}`));
|
|
352
362
|
}
|
|
353
363
|
}
|
|
364
|
+
// The orchestrator files carry what changes without a workspace write — a
|
|
365
|
+
// project absorbed in its own repository turns ready, an executor model
|
|
366
|
+
// edited into workspace.json (change 0228). The hub kept saying "pending"
|
|
367
|
+
// and the subagent kept `model: inherit` until the next add or tool.
|
|
368
|
+
// Listing refreshes them; only a file whose content differs is written.
|
|
369
|
+
regenerate(wsDir);
|
|
354
370
|
return EXIT.OK;
|
|
355
371
|
}
|
|
356
372
|
|
|
357
373
|
export const help = `
|
|
358
|
-
Usage: doctrina workspace <init|add|attach|detach|list> [options]
|
|
374
|
+
Usage: doctrina workspace <init|add|attach|detach|list|tool|guard> [options]
|
|
359
375
|
|
|
360
376
|
The context of several repositories in one place (ADR 0029). Keep the
|
|
361
377
|
workspace directory as a PRIVATE git repository: it versions the
|
|
@@ -373,35 +389,42 @@ artifacts of repositories that cannot carry .doctrina/ themselves.
|
|
|
373
389
|
scaffolded under projects/<name>/, linked into the repository, and
|
|
374
390
|
hidden from its git through .git/info/exclude.
|
|
375
391
|
|
|
376
|
-
attach <name> [--path <dir>]
|
|
392
|
+
attach <name> [--path <dir>] [--depends-on <a,b>]
|
|
377
393
|
Re-create a shadow's links (after cloning the workspace on another
|
|
378
|
-
machine, or with --path after the repository moved).
|
|
394
|
+
machine, or with --path after the repository moved). --depends-on
|
|
395
|
+
replaces the projects it builds on — the order \`delivery route\` uses.
|
|
379
396
|
|
|
380
397
|
detach <name>
|
|
381
398
|
Remove a shadow's links and its exclude block; the shadow stays.
|
|
382
399
|
|
|
383
400
|
list
|
|
384
401
|
Every project with its mode, state (ready / pending / missing) and
|
|
385
|
-
open changes, then the declared tools
|
|
402
|
+
open changes, then the declared tools; refreshes the orchestrator
|
|
403
|
+
files when a project's state or workspace.json changed since.
|
|
386
404
|
|
|
387
405
|
tool <name> [--server <mcp-name>] [--access read|write] [--for <roles>]
|
|
388
|
-
[--purpose "<what for>"] [--remove]
|
|
406
|
+
[--purpose "<what for>"] [--only "<calls>"] [--remove]
|
|
389
407
|
Declare (or update, or --remove) a tool the work uses. Presets:
|
|
390
408
|
jira, confluence, figma, playwright, datadog, databricks, sql.
|
|
391
409
|
Roles: planner, executor, qa, observer, reviewer. Access defaults
|
|
392
410
|
to read: the guard refuses a write through a read-only tool and
|
|
393
411
|
makes a declared write wait for the human.
|
|
412
|
+
--only "getJiraIssue,search*" lists the calls the tool may make,
|
|
413
|
+
by MCP tool name (\`*\` for any run of characters): every other
|
|
414
|
+
call through its server is denied, instead of being judged by the
|
|
415
|
+
verb in its name. --only none takes the list away.
|
|
394
416
|
|
|
395
417
|
guard --hook cursor|claude
|
|
396
418
|
The pre-call hook the workspace installs (Cursor beforeMCPExecution,
|
|
397
419
|
Claude Code PreToolUse): reads the call on stdin and answers allow,
|
|
398
420
|
deny or ask from the declared access. Never blocks on bad input.
|
|
399
421
|
|
|
400
|
-
Every write (init, add, attach, detach, tool)
|
|
401
|
-
files at the workspace root: AGENTS.md,
|
|
402
|
-
workspace.mdc, .cursor/hooks.json and
|
|
403
|
-
.claude/agents/doctrina-*.md (read by
|
|
404
|
-
<name>.code-workspace, which opens every
|
|
422
|
+
Every write (init, add, attach, detach, tool) and every list regenerates
|
|
423
|
+
the orchestrator files at the workspace root: AGENTS.md,
|
|
424
|
+
.cursor/rules/00-doctrina-workspace.mdc, .cursor/hooks.json and
|
|
425
|
+
.claude/settings.json (merged), .claude/agents/doctrina-*.md (read by
|
|
426
|
+
Claude Code and Cursor) and <name>.code-workspace, which opens every
|
|
427
|
+
repository in one window.
|
|
405
428
|
|
|
406
429
|
The workspace is found from $DOCTRINA_WORKSPACE, the current directory
|
|
407
430
|
or an ancestor holding ${WORKSPACE_FILE}, a shadow's .doctrina link, or
|
package/src/index.js
CHANGED
|
@@ -11,54 +11,54 @@ import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
|
|
|
11
11
|
import { recordUsage, operationOf } from "./lib/usage.js";
|
|
12
12
|
import { wantsJson, emitJson, captureOutput, stripAnsi, setDeprecation, deferJson, flushJson } from "./lib/json-out.js";
|
|
13
13
|
|
|
14
|
-
import * as init from "./commands/init.js";
|
|
15
|
-
import * as spec from "./commands/spec.js";
|
|
16
|
-
import * as change from "./commands/change.js";
|
|
17
|
-
import * as decision from "./commands/decision.js";
|
|
18
|
-
import * as validate from "./commands/validate.js";
|
|
19
|
-
import * as hooks from "./commands/hooks.js";
|
|
20
|
-
import * as analyze from "./commands/analyze.js";
|
|
21
|
-
import * as clarify from "./commands/clarify.js";
|
|
22
|
-
import * as templates from "./commands/templates.js";
|
|
23
|
-
import * as skill from "./commands/skill.js";
|
|
24
|
-
import * as indexCmd from "./commands/index-rebuild.js";
|
|
25
|
-
import * as next from "./commands/next.js";
|
|
26
|
-
import * as metrics from "./commands/metrics.js";
|
|
27
|
-
import * as context from "./commands/context.js";
|
|
28
|
-
import * as search from "./commands/search.js";
|
|
29
|
-
import * as intake from "./commands/intake.js";
|
|
30
|
-
import * as work from "./commands/work.js";
|
|
31
|
-
import * as coverage from "./commands/coverage.js";
|
|
32
|
-
import * as verify from "./commands/verify.js";
|
|
33
|
-
import * as contract from "./commands/contract.js";
|
|
34
|
-
import * as trace from "./commands/trace.js";
|
|
35
|
-
import * as status from "./commands/status.js";
|
|
36
|
-
import * as close from "./commands/close.js";
|
|
37
|
-
import * as review from "./commands/review.js";
|
|
38
|
-
import * as watch from "./commands/watch.js";
|
|
39
|
-
import * as why from "./commands/why.js";
|
|
40
|
-
import * as prime from "./commands/prime.js";
|
|
41
|
-
import * as handoff from "./commands/handoff.js";
|
|
42
|
-
import * as show from "./commands/show.js";
|
|
43
|
-
import * as doctor from "./commands/doctor.js";
|
|
44
|
-
import * as report from "./commands/report.js";
|
|
45
|
-
import * as completion from "./commands/completion.js";
|
|
46
|
-
import * as intent from "./commands/intent.js";
|
|
47
|
-
import * as upgrade from "./commands/upgrade.js";
|
|
48
|
-
import * as adapter from "./commands/adapter.js";
|
|
49
|
-
import * as triage from "./commands/triage.js";
|
|
50
|
-
import * as ci from "./commands/ci.js";
|
|
51
|
-
import * as workspace from "./commands/workspace.js";
|
|
52
|
-
import * as delivery from "./commands/delivery.js";
|
|
53
14
|
|
|
15
|
+
// Each command is loaded when it is the one invoked (fifth audit, change
|
|
16
|
+
// 0236): the 39 static imports cost ~150 ms before any command ran, and
|
|
17
|
+
// `workspace guard` runs on EVERY MCP call an agent makes — a Playwright
|
|
18
|
+
// session's hundred clicks paid it a hundred times.
|
|
19
|
+
/** @type {Record<string, () => Promise<any>>} */
|
|
54
20
|
const COMMANDS = {
|
|
55
|
-
init
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
21
|
+
init: () => import("./commands/init.js"),
|
|
22
|
+
spec: () => import("./commands/spec.js"),
|
|
23
|
+
change: () => import("./commands/change.js"),
|
|
24
|
+
decision: () => import("./commands/decision.js"),
|
|
25
|
+
validate: () => import("./commands/validate.js"),
|
|
26
|
+
hooks: () => import("./commands/hooks.js"),
|
|
27
|
+
analyze: () => import("./commands/analyze.js"),
|
|
28
|
+
clarify: () => import("./commands/clarify.js"),
|
|
29
|
+
templates: () => import("./commands/templates.js"),
|
|
30
|
+
skill: () => import("./commands/skill.js"),
|
|
31
|
+
index: () => import("./commands/index-rebuild.js"),
|
|
32
|
+
next: () => import("./commands/next.js"),
|
|
33
|
+
metrics: () => import("./commands/metrics.js"),
|
|
34
|
+
context: () => import("./commands/context.js"),
|
|
35
|
+
search: () => import("./commands/search.js"),
|
|
36
|
+
intake: () => import("./commands/intake.js"),
|
|
37
|
+
onboard: () => import("./commands/onboard.js"),
|
|
38
|
+
ui: () => import("./commands/ui.js"),
|
|
39
|
+
work: () => import("./commands/work.js"),
|
|
40
|
+
coverage: () => import("./commands/coverage.js"),
|
|
41
|
+
verify: () => import("./commands/verify.js"),
|
|
42
|
+
contract: () => import("./commands/contract.js"),
|
|
43
|
+
trace: () => import("./commands/trace.js"),
|
|
44
|
+
status: () => import("./commands/status.js"),
|
|
45
|
+
close: () => import("./commands/close.js"),
|
|
46
|
+
review: () => import("./commands/review.js"),
|
|
47
|
+
watch: () => import("./commands/watch.js"),
|
|
48
|
+
why: () => import("./commands/why.js"),
|
|
49
|
+
prime: () => import("./commands/prime.js"),
|
|
50
|
+
handoff: () => import("./commands/handoff.js"),
|
|
51
|
+
show: () => import("./commands/show.js"),
|
|
52
|
+
doctor: () => import("./commands/doctor.js"),
|
|
53
|
+
report: () => import("./commands/report.js"),
|
|
54
|
+
completion: () => import("./commands/completion.js"),
|
|
55
|
+
intent: () => import("./commands/intent.js"),
|
|
56
|
+
upgrade: () => import("./commands/upgrade.js"),
|
|
57
|
+
adapter: () => import("./commands/adapter.js"),
|
|
58
|
+
triage: () => import("./commands/triage.js"),
|
|
59
|
+
ci: () => import("./commands/ci.js"),
|
|
60
|
+
workspace: () => import("./commands/workspace.js"),
|
|
61
|
+
delivery: () => import("./commands/delivery.js"),
|
|
62
62
|
};
|
|
63
63
|
|
|
64
64
|
const TOP_HELP = `Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
|
|
@@ -71,6 +71,15 @@ ${exitCodeHelp()}
|
|
|
71
71
|
`;
|
|
72
72
|
|
|
73
73
|
async function main(argv) {
|
|
74
|
+
// The MCP guard's own door (fifth audit, change 0236): it answers before
|
|
75
|
+
// every tool call an agent makes, so it loads the guard and its registry
|
|
76
|
+
// reader only. `--help` and `--json` take the ordinary path.
|
|
77
|
+
if (argv[0] === "workspace" && argv[1] === "guard" && !argv.some((a) => /^(--help|-h|--json|--debug)$/.test(a))) {
|
|
78
|
+
const at = argv.indexOf("--hook");
|
|
79
|
+
const hook = at >= 0 ? argv[at + 1] : argv.find((a) => a.startsWith("--hook="))?.slice("--hook=".length);
|
|
80
|
+
const { runGuard } = await import("./lib/workspace-guard.js");
|
|
81
|
+
return runGuard(hook);
|
|
82
|
+
}
|
|
74
83
|
// `--version` is only "print the CLI version" when NO command is given.
|
|
75
84
|
// With a command it is that command's flag to consume — the old global
|
|
76
85
|
// intercept made `doctrina spec set x --version 0.10.0` print the CLI
|
|
@@ -89,7 +98,8 @@ async function main(argv) {
|
|
|
89
98
|
// Declarations now live with the command, so a new command cannot
|
|
90
99
|
// reintroduce the gap by forgetting to edit this file.
|
|
91
100
|
const bootstrap = parseArgs(argv, { boolean: [...GLOBAL_FLAGS.boolean] });
|
|
92
|
-
const
|
|
101
|
+
const loader = Object.hasOwn(COMMANDS, bootstrap.positional[0] ?? "") ? COMMANDS[bootstrap.positional[0]] : null;
|
|
102
|
+
const commandModule = loader ? await loader() : null;
|
|
93
103
|
const spec = commandModule?.flags;
|
|
94
104
|
const { positional, flags } = parseArgs(argv, {
|
|
95
105
|
boolean: [...GLOBAL_FLAGS.boolean, ...(spec?.boolean ?? [])],
|
|
@@ -102,12 +112,20 @@ async function main(argv) {
|
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
if (positional.length === 0) {
|
|
115
|
+
// In a terminal, `doctrina` alone opens the console (change 0257): a
|
|
116
|
+
// person who typed the name and nothing else is asking where to start.
|
|
117
|
+
// A pipe, a script or an agent still gets the help it always got.
|
|
118
|
+
if (argv.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
|
|
119
|
+
const ui = await COMMANDS.ui();
|
|
120
|
+
return ui.run([], new Map());
|
|
121
|
+
}
|
|
105
122
|
process.stdout.write(TOP_HELP);
|
|
106
123
|
return 0;
|
|
107
124
|
}
|
|
108
125
|
|
|
109
126
|
const commandName = positional[0];
|
|
110
|
-
const command =
|
|
127
|
+
const command = commandName === bootstrap.positional[0] ? commandModule
|
|
128
|
+
: (Object.hasOwn(COMMANDS, commandName) ? await COMMANDS[commandName]() : null);
|
|
111
129
|
const removed = removalFor(positional);
|
|
112
130
|
if (removed) {
|
|
113
131
|
const name = removed === removalFor([commandName]) ? commandName : positional.slice(0, 2).join(" ");
|
|
@@ -207,12 +225,13 @@ async function main(argv) {
|
|
|
207
225
|
? command.jsonNative(positional.slice(1))
|
|
208
226
|
: command.jsonNative === true;
|
|
209
227
|
if (wantsJson(flags) && !jsonNative) {
|
|
210
|
-
const { code, stdout, stderr } = await captureOutput(
|
|
228
|
+
const { code, stdout, stderr, error } = await captureOutput(
|
|
211
229
|
() => command.run(positional.slice(1), flags),
|
|
212
230
|
);
|
|
213
231
|
emitJson(operationName(positional), {
|
|
214
232
|
stdout: stdout.map(stripAnsi),
|
|
215
233
|
stderr: stderr.map(stripAnsi),
|
|
234
|
+
...(error ? { error: String(error.message ?? error) } : {}),
|
|
216
235
|
}, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
|
|
217
236
|
return code;
|
|
218
237
|
}
|
|
@@ -222,27 +241,53 @@ async function main(argv) {
|
|
|
222
241
|
if (wantsJson(flags)) {
|
|
223
242
|
deferJson();
|
|
224
243
|
let code = EXIT.OK;
|
|
244
|
+
let thrown = null;
|
|
225
245
|
try {
|
|
226
246
|
code = (await command.run(positional.slice(1), flags)) ?? EXIT.OK;
|
|
227
|
-
}
|
|
228
|
-
|
|
247
|
+
} catch (err) {
|
|
248
|
+
thrown = err;
|
|
249
|
+
code = err.exitCode ?? EXIT.GATE;
|
|
250
|
+
}
|
|
251
|
+
// A native command that failed before building its payload still
|
|
252
|
+
// answers in JSON (third audit, change 0234): "not a Doctrina project"
|
|
253
|
+
// or an unknown delivery printed English only, and a loop parsing the
|
|
254
|
+
// payload read nothing at all.
|
|
255
|
+
if (!flushJson(code)) {
|
|
256
|
+
emitJson(operationName(positional), {
|
|
257
|
+
error: thrown ? String(thrown.message) : "the command stopped before it built its payload — stderr says why",
|
|
258
|
+
...(thrown?.remedy ? { remedy: thrown.remedy } : {}),
|
|
259
|
+
}, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
|
|
229
260
|
}
|
|
261
|
+
if (thrown) await reportError(thrown, flags);
|
|
230
262
|
return code;
|
|
231
263
|
}
|
|
232
264
|
return await command.run(positional.slice(1), flags);
|
|
233
265
|
} catch (err) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
266
|
+
return await reportError(err, flags);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The human report of a thrown error, and the exit class it carries. */
|
|
271
|
+
async function reportError(err, flags) {
|
|
272
|
+
console.error(c.red("error:") + ` ${err.message}`);
|
|
273
|
+
// A clone of a shadowed repository is not a project to init (change 0234).
|
|
274
|
+
// Loaded only on this error path, like the commands (change 0236).
|
|
275
|
+
const view = /^not a Doctrina project/.test(String(err.message)) ? await import("./lib/workspace-view.js") : null;
|
|
276
|
+
const clone = view ? view.shadowedCloneAt(process.cwd()) : null;
|
|
277
|
+
if (clone) {
|
|
278
|
+
console.error(c.gray("hint: ") + (clone.same
|
|
279
|
+
? `this repository is the workspace's shadowed "${clone.name}" and its links are missing; never \`doctrina init\` here — link them again: ${c.cyan(view?.attachCommand(clone, process.cwd()))}`
|
|
280
|
+
: `this repository is the workspace's shadowed "${clone.name}" (registered at ${clone.path}); never \`doctrina init\` here — to work from this clone, link its context from the workspace: ${c.cyan(view?.attachCommand(clone, process.cwd()))}`));
|
|
281
|
+
} else if (err.remedy) {
|
|
282
|
+
console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
|
|
283
|
+
}
|
|
284
|
+
if (flags.get("debug") && err.stack) {
|
|
285
|
+
console.error(c.gray(err.stack));
|
|
245
286
|
}
|
|
287
|
+
// A typed error carries its own class (precondition vs environment).
|
|
288
|
+
// An untyped throw is an unexpected failure of the work itself, which
|
|
289
|
+
// is the GATE class: something is wrong here, fix it and retry (C7).
|
|
290
|
+
return err.exitCode ?? EXIT.GATE;
|
|
246
291
|
}
|
|
247
292
|
|
|
248
293
|
// The OPERATION an invocation names, and the arguments it carries — the two
|
package/src/lib/actions.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { conflictedArtifacts } from "./conflict-markers.js";
|
|
3
4
|
import { readdirSync } from "node:fs";
|
|
4
5
|
import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
|
|
5
6
|
import * as idx from "./index-json.js";
|
|
6
7
|
import { deriveIndex, indexesMatch, listHeader, specHeader } from "./scan.js";
|
|
8
|
+
import { checklistProgress } from "./doc-model.js";
|
|
7
9
|
import { collectRuntimeFindings } from "./runtime.js";
|
|
8
10
|
import { FIX_SHAPED } from "./lexicon.js";
|
|
9
11
|
import { summarize as coverageSummary } from "./coverage-model.js";
|
|
10
12
|
import { summarize as traceSummary } from "./trace-model.js";
|
|
13
|
+
import { projectWorkspaceLine } from "./workspace-view.js";
|
|
14
|
+
import { cdThen, qarg } from "./shell.js";
|
|
11
15
|
|
|
12
16
|
// What comes next, as DATA (change 0032).
|
|
13
17
|
//
|
|
@@ -95,6 +99,24 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
95
99
|
/** @type {Action[]} */
|
|
96
100
|
const actions = [];
|
|
97
101
|
|
|
102
|
+
// A MERGE THAT STOPPED INSIDE AN ARTIFACT comes before everything (change
|
|
103
|
+
// 0250): every action below is computed from files that hold both halves
|
|
104
|
+
// of a conflict, and the next thing an agent does with a spec is read it.
|
|
105
|
+
try {
|
|
106
|
+
const conflicted = conflictedArtifacts(projectRoot);
|
|
107
|
+
if (conflicted.length > 0) {
|
|
108
|
+
const first = path.relative(projectRoot, conflicted[0].file).split(path.sep).join("/");
|
|
109
|
+
actions.push(action({
|
|
110
|
+
id: "merge-conflict",
|
|
111
|
+
command: "validate",
|
|
112
|
+
gate: "structure",
|
|
113
|
+
runnable: false,
|
|
114
|
+
why: `${conflicted.length} artifact${conflicted.length === 1 ? " carries" : "s carry"} git's merge conflict markers (first: ${first}:${conflicted[0].line})`,
|
|
115
|
+
text: `resolve the merge conflict in ${first} (line ${conflicted[0].line})${conflicted.length > 1 ? ` and ${conflicted.length - 1} more` : ""} — keep what both branches meant and delete the marker lines; \`doctrina validate\` names each file`,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
} catch { /* an unreadable tree is reported by the checks below */ }
|
|
119
|
+
|
|
98
120
|
// RUNTIME FIRST (change 0029). A declared wiring that does not hold is
|
|
99
121
|
// not a queue item — it is the reason the last run lied. After a job goes
|
|
100
122
|
// green having executed nothing, "open a change on the observability
|
|
@@ -214,9 +236,19 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
214
236
|
continue;
|
|
215
237
|
}
|
|
216
238
|
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
239
|
+
const progress = isFile(tasksPath) ? checklistProgress(read(tasksPath)) : { total: 0, done: 0, open: [], placeholders: 0 };
|
|
240
|
+
const unchecked = progress.total - progress.done;
|
|
241
|
+
if (progress.placeholders > 0) {
|
|
242
|
+
// Scaffold boxes are not open work (third audit, change 0234): "complete 3
|
|
243
|
+
// open tasks" sent an agent to do work no one had written down.
|
|
244
|
+
actions.push(action({
|
|
245
|
+
id: "change-tasks-unwritten",
|
|
246
|
+
command: "work",
|
|
247
|
+
args: ["--resume", id],
|
|
248
|
+
why: `write the ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md first — ${progress.placeholders === unchecked ? (unchecked === 1 ? "it is" : "all are") : `${progress.placeholders} are`} still the scaffold's placeholder${progress.placeholders === 1 ? "" : "s"}, and the delta with them`,
|
|
249
|
+
}));
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
220
252
|
if (unchecked > 0) {
|
|
221
253
|
// Deliberately NOT runnable, and deliberately without a command:
|
|
222
254
|
// the only thing that clears this is doing the work. `change tick`
|
|
@@ -253,6 +285,33 @@ export function computeActions(projectRoot, gates = {}) {
|
|
|
253
285
|
}
|
|
254
286
|
}
|
|
255
287
|
|
|
288
|
+
// A delivery open on this repository (second audit, change 0233). `prime`
|
|
289
|
+
// named it, `next` did not: after the close of this repository's part,
|
|
290
|
+
// "Next: doctrina next" answered with this project's own chores, and the
|
|
291
|
+
// planner lost the delivery that was still going on at the workspace.
|
|
292
|
+
let inWorkspace = null;
|
|
293
|
+
try {
|
|
294
|
+
inWorkspace = projectWorkspaceLine(projectRoot);
|
|
295
|
+
} catch {
|
|
296
|
+
inWorkspace = null;
|
|
297
|
+
}
|
|
298
|
+
for (const d of inWorkspace?.deliveries ?? []) {
|
|
299
|
+
if (d.changes.length === 0) {
|
|
300
|
+
actions.push(action({
|
|
301
|
+
id: "delivery-change-missing",
|
|
302
|
+
why: `delivery ${d.id} is routed to this repository and has no change here yet`,
|
|
303
|
+
text: `doctrina work ${qarg(d.title)} --delivery ${d.id} — delivery ${d.id} is routed to this repository and has no change here yet`,
|
|
304
|
+
}));
|
|
305
|
+
} else if (d.open === 0) {
|
|
306
|
+
actions.push(action({
|
|
307
|
+
id: "delivery-continues-at-workspace",
|
|
308
|
+
severity: "advisory",
|
|
309
|
+
why: `this repository's part of delivery ${d.id} is closed; the delivery goes on at the workspace`,
|
|
310
|
+
text: `${cdThen(inWorkspace?.dir ?? "", "doctrina next")} — this repository's part of delivery ${d.id} is closed; the delivery goes on at the workspace`,
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
256
315
|
// ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
|
|
257
316
|
// the rot the review flagged — a decision with nothing behind it (no Evidence,
|
|
258
317
|
// no Landed) is drift waiting to be superseded. Surface the proposed ones
|