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/work.js
CHANGED
|
@@ -8,13 +8,16 @@ import * as idx from "../lib/index-json.js";
|
|
|
8
8
|
import { c } from "../lib/colors.js";
|
|
9
9
|
import { assessBrief } from "../lib/clarity.js";
|
|
10
10
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
11
|
+
import { findWorkspace, projectAt, readWorkspace } from "../lib/workspace.js";
|
|
12
|
+
import { readDelivery, resolveDeliveryId } from "../lib/delivery.js";
|
|
11
13
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
|
-
import { changeNew, reindexChange } from "../lib/change-ops.js";
|
|
14
|
+
import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
|
|
13
15
|
import { classify } from "../lib/triage-model.js";
|
|
14
16
|
import { printPlaybookTemplate } from "../lib/playbook.js";
|
|
17
|
+
import { specShape } from "../lib/spec-ops.js";
|
|
15
18
|
import { changedFiles } from "../lib/git.js";
|
|
16
19
|
import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
|
|
17
|
-
import { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
20
|
+
import { rankCapabilities, rankCapabilitiesByDiff, similarChanges, SIMILAR_CHANGE_DAYS } from "../lib/work-model.js";
|
|
18
21
|
import { isChangeId, refuseChangeRef } from "../lib/project.js";
|
|
19
22
|
export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
20
23
|
|
|
@@ -29,14 +32,50 @@ export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
|
|
|
29
32
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
30
33
|
// adding a command never requires editing the entrypoint — the gap that
|
|
31
34
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
32
|
-
export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
|
|
35
|
+
export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "consolidate", "delivery", "id", "resume", "title"] };
|
|
33
36
|
|
|
34
37
|
export async function run(positional, flags) {
|
|
35
|
-
const prompt = positional.join(" ").trim();
|
|
36
38
|
const projectRoot = process.cwd();
|
|
37
39
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
38
40
|
throw notADoctrinaProject();
|
|
39
41
|
}
|
|
42
|
+
// --consolidate <cap> (change 0206): a spec only ever grew — every change
|
|
43
|
+
// appended, nothing took a requirement out — and a field review of 0.17
|
|
44
|
+
// asked for "a command to consolidate a spec from time to time". It is an
|
|
45
|
+
// ordinary change pinned to the capability, with its own playbook: the
|
|
46
|
+
// agent decides what is redundant or superseded (ADR 0005), and the ops
|
|
47
|
+
// take it out.
|
|
48
|
+
const consolidate = flags.has("consolidate") ? flagString(flags, "consolidate") : null;
|
|
49
|
+
if (consolidate !== null) {
|
|
50
|
+
if (!consolidate || /** @type {unknown} */ (consolidate) === true || !/^[a-z][a-z0-9-]*$/.test(consolidate)) {
|
|
51
|
+
console.error(c.red("error:") + " --consolidate needs the capability whose spec to consolidate");
|
|
52
|
+
return EXIT.USAGE;
|
|
53
|
+
}
|
|
54
|
+
if (!isFile(path.join(projectRoot, ".doctrina", "specs", consolidate, "spec.md"))) {
|
|
55
|
+
console.error(c.red("error:") + ` capability "${consolidate}" has no spec at .doctrina/specs/${consolidate}/spec.md`);
|
|
56
|
+
return EXIT.USAGE;
|
|
57
|
+
}
|
|
58
|
+
flags.set("capability", consolidate);
|
|
59
|
+
flags.set("force", true);
|
|
60
|
+
if (!flags.has("title")) flags.set("title", `consolidate ${consolidate}`);
|
|
61
|
+
}
|
|
62
|
+
const prompt = consolidate
|
|
63
|
+
? `Consolidate the ${consolidate} spec: merge requirements and criteria that say the same thing, drop what a later one superseded — no behaviour changes.`
|
|
64
|
+
: positional.join(" ").trim();
|
|
65
|
+
|
|
66
|
+
// --delivery <id> (change 0222): the change is this repository's part of a
|
|
67
|
+
// workspace delivery. Checked BEFORE anything is scaffolded, so a typo or a
|
|
68
|
+
// repository the delivery was never routed to opens nothing.
|
|
69
|
+
let delivery = null;
|
|
70
|
+
if (flags.has("delivery")) {
|
|
71
|
+
const resolved = resolveDeliveryFor(projectRoot, flagString(flags, "delivery"));
|
|
72
|
+
if (resolved.error) {
|
|
73
|
+
console.error(c.red("error:") + ` ${resolved.error}`);
|
|
74
|
+
if (resolved.hint) console.error(c.gray("hint: ") + resolved.hint);
|
|
75
|
+
return resolved.code;
|
|
76
|
+
}
|
|
77
|
+
delivery = resolved.id;
|
|
78
|
+
}
|
|
40
79
|
|
|
41
80
|
// --resume <id>: reprint the playbook for an existing open change rather than
|
|
42
81
|
// open a new one (review G1 — `work "continue"` used to slug the word and
|
|
@@ -78,6 +117,29 @@ export async function run(positional, flags) {
|
|
|
78
117
|
}
|
|
79
118
|
}
|
|
80
119
|
|
|
120
|
+
// DUPLICATE CHECK (change 0215). A request that mostly repeats a change
|
|
121
|
+
// already open, or archived in the last two weeks, is held before a second
|
|
122
|
+
// copy is scaffolded: an archived change is out of every default read, and
|
|
123
|
+
// a field review found the person asking for "the doctrina work" of work
|
|
124
|
+
// already closed. A term match can be wrong, so --force proceeds.
|
|
125
|
+
if (!fromDiff && !consolidate && prompt && !flagBool(flags, "force", false)) {
|
|
126
|
+
const similar = similarChanges(projectRoot, prompt);
|
|
127
|
+
if (similar.length > 0) {
|
|
128
|
+
console.error(c.yellow("hold:") + " this request reads like a change that already exists:");
|
|
129
|
+
for (const s of similar.slice(0, 3)) {
|
|
130
|
+
console.error(` ${c.cyan(s.id)} ${s.title}` + c.gray(` — ${s.where} (${s.path}); shared: ${s.shared.join(", ")}`));
|
|
131
|
+
}
|
|
132
|
+
console.error("");
|
|
133
|
+
const top = similar[0];
|
|
134
|
+
console.error(top.where === "archived"
|
|
135
|
+
? `If it is the same work, it is done: tell the human it is archived at ${top.path}.`
|
|
136
|
+
: `If it is the same work, continue it: ${c.cyan(`doctrina work --resume ${top.id}`)}`);
|
|
137
|
+
console.error(c.gray("If it is new work, open it anyway: ") + c.cyan(`doctrina work --force "<request>"`) +
|
|
138
|
+
c.gray(` (open changes and the last ${SIMILAR_CHANGE_DAYS} days of the archive are compared)`));
|
|
139
|
+
return EXIT.PRECONDITION;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
81
143
|
// LANE CHECK (change 0029). `work` was the default answer to every
|
|
82
144
|
// request, so a broken workflow or a suite that ran nothing became a
|
|
83
145
|
// change with a proposal, tasks and a spec delta — ceremony spent on a
|
|
@@ -172,6 +234,9 @@ export async function run(positional, flags) {
|
|
|
172
234
|
if (pinned) {
|
|
173
235
|
updated = updated.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, `$1 ${pinned}`);
|
|
174
236
|
}
|
|
237
|
+
if (delivery) {
|
|
238
|
+
updated = updated.replace(/^(-[ \t]+\*\*Lane:\*\*[^\r\n]*)/m, `$1\n- **Delivery:** ${delivery}`);
|
|
239
|
+
}
|
|
175
240
|
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
176
241
|
}
|
|
177
242
|
|
|
@@ -205,6 +270,12 @@ export async function run(positional, flags) {
|
|
|
205
270
|
? null
|
|
206
271
|
: scaffoldDelta(projectRoot, { id, capability, guess, runnerUp: promptMatches[1] ?? null });
|
|
207
272
|
|
|
273
|
+
// A guessed delta names the capability as surely as a pinned one: the
|
|
274
|
+
// header says what the folder says (change 0207). It stayed empty for
|
|
275
|
+
// every unpinned `work`, so the proposal claimed no spec while its delta
|
|
276
|
+
// targeted one.
|
|
277
|
+
if (scaffolded) addAffectedSpec(projectRoot, id, scaffolded.capability);
|
|
278
|
+
|
|
208
279
|
// The proposal is written in two passes — `changeNew` scaffolds it, the
|
|
209
280
|
// lines above stamp the lane and the pinned specs — so its index entry is
|
|
210
281
|
// re-derived HERE, from the finished file. Indexing at the end of the first
|
|
@@ -223,7 +294,9 @@ export async function run(positional, flags) {
|
|
|
223
294
|
}
|
|
224
295
|
|
|
225
296
|
console.log("");
|
|
226
|
-
if (
|
|
297
|
+
if (consolidate) {
|
|
298
|
+
printConsolidatePlaybook(projectRoot, { id, capability: consolidate });
|
|
299
|
+
} else if (chore) {
|
|
227
300
|
printChorePlaybook(projectRoot, { id, prompt: effPrompt });
|
|
228
301
|
} else {
|
|
229
302
|
printPlaybook(projectRoot, {
|
|
@@ -231,6 +304,12 @@ export async function run(positional, flags) {
|
|
|
231
304
|
fromDiff, diffMatches: fromDiff ? [] : diffMatches,
|
|
232
305
|
});
|
|
233
306
|
}
|
|
307
|
+
// The sentence the human needs (change 0214). A field review: the change
|
|
308
|
+
// existed only in the terminal, so the person asking saw code appear with
|
|
309
|
+
// no Doctrina trace — and later asked for "the doctrina work" of work
|
|
310
|
+
// already closed. The agent repeats this line; the CLI writes it.
|
|
311
|
+
console.log("");
|
|
312
|
+
console.log(c.bold("Tell the human: ") + `opened change ${id} — "${title}" (.doctrina/changes/${id}/)`);
|
|
234
313
|
return 0;
|
|
235
314
|
}
|
|
236
315
|
|
|
@@ -301,6 +380,28 @@ function laneRecord(verdict, { forced, chore, fromDiff }) {
|
|
|
301
380
|
return line;
|
|
302
381
|
}
|
|
303
382
|
|
|
383
|
+
// The delivery a `--delivery` names, from this repository's workspace: it
|
|
384
|
+
// must exist, and this repository must be one of the projects it was routed to.
|
|
385
|
+
function resolveDeliveryFor(projectRoot, ref) {
|
|
386
|
+
const found = findWorkspace(projectRoot);
|
|
387
|
+
if (!found) {
|
|
388
|
+
return { error: "--delivery needs a workspace, and this repository belongs to none", hint: "register it: `doctrina workspace add <path>` from the workspace", code: EXIT.PRECONDITION };
|
|
389
|
+
}
|
|
390
|
+
const id = resolveDeliveryId(found.dir, ref);
|
|
391
|
+
if (!id) return { error: `no delivery "${ref ?? ""}" in the workspace at ${found.dir}`, hint: "`doctrina delivery list` from the workspace", code: EXIT.USAGE };
|
|
392
|
+
const ws = readWorkspace(found.dir);
|
|
393
|
+
const project = projectAt(ws, projectRoot);
|
|
394
|
+
const d = readDelivery(found.dir, id);
|
|
395
|
+
if (!project || !d.projects.includes(project.name)) {
|
|
396
|
+
return {
|
|
397
|
+
error: `delivery ${id} is not routed to ${project ? project.name : "this repository"} (routed: ${d.projects.join(", ") || "none"})`,
|
|
398
|
+
hint: `route it first, from the workspace: \`doctrina delivery route ${id} ${[...d.projects, project?.name ?? "<name>"].join(",")}\``,
|
|
399
|
+
code: EXIT.PRECONDITION,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return { id };
|
|
403
|
+
}
|
|
404
|
+
|
|
304
405
|
function writeLane(projectRoot, id, verdict, opts) {
|
|
305
406
|
const line = laneRecord(verdict, opts);
|
|
306
407
|
if (!line) return;
|
|
@@ -440,6 +541,21 @@ export function slugify(text) {
|
|
|
440
541
|
// part that is not prose: computing the variable blocks. Each is PRE-RENDERED
|
|
441
542
|
// into a token, so `substitute` stays a plain string map and the template
|
|
442
543
|
// stays a document rather than a language with conditionals in it.
|
|
544
|
+
// The consolidation playbook: the spec's shape, measured, then the steps.
|
|
545
|
+
// Measuring is the CLI's part; deciding which requirement is redundant is
|
|
546
|
+
// the agent's (ADR 0005).
|
|
547
|
+
function printConsolidatePlaybook(projectRoot, { id, capability }) {
|
|
548
|
+
const text = read(path.join(projectRoot, ".doctrina", "specs", capability, "spec.md"));
|
|
549
|
+
const shape = specShape(text);
|
|
550
|
+
const sections = Object.entries(shape.sections).map(([k, n]) => `${k} ${n}`).join(" · ");
|
|
551
|
+
printPlaybookTemplate(projectRoot, "consolidate", {
|
|
552
|
+
CHANGE_ID: id,
|
|
553
|
+
CAPABILITY: capability,
|
|
554
|
+
SHAPE: `${shape.lines} lines · ${shape.requirements} requirements (${sections || "none"}) · ` +
|
|
555
|
+
`${shape.criteria} acceptance criteria`,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
443
559
|
function printChorePlaybook(projectRoot, { id, prompt }) {
|
|
444
560
|
printPlaybookTemplate(projectRoot, "chore", { CHANGE_ID: id, PROMPT: prompt });
|
|
445
561
|
}
|
|
@@ -450,6 +566,9 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
450
566
|
CHANGE_ID: id,
|
|
451
567
|
PROMPT: prompt,
|
|
452
568
|
CAPABILITY: capability ?? "<capability>",
|
|
569
|
+
// The read pack already cut to the task (change 0217): `--for` is what
|
|
570
|
+
// slices the spec, and an agent copying a step should copy the cut.
|
|
571
|
+
FOR: forQuery(prompt),
|
|
453
572
|
FROM_DIFF_NOTE: fromDiff
|
|
454
573
|
? c.gray("Code-first: write the spec that describes what the working tree already does.")
|
|
455
574
|
: "",
|
|
@@ -488,6 +607,13 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, c
|
|
|
488
607
|
});
|
|
489
608
|
}
|
|
490
609
|
|
|
610
|
+
// The prompt as a `context --for` argument: one line, no double quotes, short
|
|
611
|
+
// enough to read — the slicer only needs its content words.
|
|
612
|
+
export function forQuery(prompt) {
|
|
613
|
+
const flat = String(prompt ?? "").replace(/["`]/g, "").replace(/\s+/g, " ").trim();
|
|
614
|
+
return flat.length > 80 ? flat.slice(0, 80).replace(/\s+\S*$/, "") : flat;
|
|
615
|
+
}
|
|
616
|
+
|
|
491
617
|
// Which capability the change is about, as far as the CLI can tell: pinned by
|
|
492
618
|
// the operator, ranked by term overlap, or nothing — a hint in every case,
|
|
493
619
|
// never a decision (ADR 0005).
|
|
@@ -543,6 +669,10 @@ Options:
|
|
|
543
669
|
--resume <id> Reprint the playbook for an open change; create nothing
|
|
544
670
|
--from-diff Backfill: scaffold from the working-tree changes (no
|
|
545
671
|
prompt needed) and print a code-first playbook (F8)
|
|
672
|
+
--consolidate <cap> Open a change that consolidates <cap>'s spec — merge
|
|
673
|
+
what says the same thing, remove what was superseded
|
|
674
|
+
(remove-requirement / remove-criterion) — with its own
|
|
675
|
+
playbook; no prompt needed, no behaviour changes
|
|
546
676
|
--chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
|
|
547
677
|
playbook that skips the spec-delta steps (F9)
|
|
548
678
|
--design Also scaffold design.md, for a change with non-trivial
|
|
@@ -551,6 +681,10 @@ Options:
|
|
|
551
681
|
existing change folder, and proceed past the lane hold
|
|
552
682
|
below
|
|
553
683
|
|
|
684
|
+
A request that mostly repeats a change already open, or archived in the
|
|
685
|
+
last 14 days, is HELD the same way (exit 3), naming it: the same work is
|
|
686
|
+
resumed, or reported as done where it is archived; --force opens it anyway.
|
|
687
|
+
|
|
554
688
|
A prompt that reads as a RUNTIME problem — a workflow, an env value, a run
|
|
555
689
|
that executed nothing — is HELD before anything is scaffolded, and pointed
|
|
556
690
|
at \`doctrina triage\` instead (exit 3: a precondition, not a bad prompt).
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { exists, isDir, mkdirp, relPath, write } from "../lib/fs-ops.js";
|
|
8
|
+
import { flagString } from "../lib/args.js";
|
|
9
|
+
import { c } from "../lib/colors.js";
|
|
10
|
+
import { suggest } from "../lib/suggest.js";
|
|
11
|
+
import { EXIT, PreconditionError } from "../lib/exit-codes.js";
|
|
12
|
+
import {
|
|
13
|
+
PROJECT_MODES, PROJECT_NAME, WORKSPACE_FILE, applyLinks, blankWorkspace, excludeEntries,
|
|
14
|
+
findWorkspace, isWorkspaceDir, linkPlan, projectAt, projectState, readWorkspace, removeLinks,
|
|
15
|
+
shadowDirOf, writeExclude, writePointer, writeWorkspace, gitInit,
|
|
16
|
+
} from "../lib/workspace.js";
|
|
17
|
+
import { TOOL_PRESETS, guardDecision, splitClaudeToolName, toolEntry } from "../lib/workspace-tools.js";
|
|
18
|
+
import { writeOrchestratorFiles } from "../lib/workspace-agents.js";
|
|
19
|
+
|
|
20
|
+
// `doctrina workspace` — the context of several repositories in one place
|
|
21
|
+
// (ADR 0029). The decisions live in lib/workspace.js; this module renders
|
|
22
|
+
// them and runs the one child process it needs (`init` inside a shadow).
|
|
23
|
+
|
|
24
|
+
const SUBCOMMANDS = ["init", "add", "attach", "detach", "list", "tool", "guard"];
|
|
25
|
+
const CLI_ENTRY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "index.js");
|
|
26
|
+
|
|
27
|
+
export const flags = {
|
|
28
|
+
boolean: ["json", "no-git", "remove"],
|
|
29
|
+
string: ["name", "mode", "role", "about", "depends-on", "agent", "path", "server", "access", "for", "purpose", "hook"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function run(positional, flags) {
|
|
33
|
+
const sub = positional[0];
|
|
34
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
35
|
+
console.error(c.red("error:") + ` unknown workspace subcommand "${sub ?? ""}"`);
|
|
36
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
37
|
+
console.error(c.gray("hint: ") + (guess ? `did you mean \`doctrina workspace ${guess}\`?` : `available: ${SUBCOMMANDS.join(", ")}`));
|
|
38
|
+
return EXIT.USAGE;
|
|
39
|
+
}
|
|
40
|
+
if (sub === "init") return workspaceInit(positional[1], flags);
|
|
41
|
+
const found = findWorkspace(process.cwd());
|
|
42
|
+
// The guard answers an agent's pre-call hook, so it never fails the call
|
|
43
|
+
// for want of a workspace: no workspace, no opinion.
|
|
44
|
+
if (sub === "guard") return workspaceGuard(found?.dir ?? null, flagString(flags, "hook"));
|
|
45
|
+
if (!found) {
|
|
46
|
+
throw new PreconditionError(
|
|
47
|
+
"no workspace here (no workspace.json in this directory or above, and $DOCTRINA_WORKSPACE is unset)",
|
|
48
|
+
"doctrina workspace init <dir>",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
let code;
|
|
52
|
+
if (sub === "add") code = workspaceAdd(found.dir, positional[1], flags);
|
|
53
|
+
else if (sub === "attach") code = workspaceAttach(found.dir, positional[1], flags);
|
|
54
|
+
else if (sub === "detach") code = workspaceDetach(found.dir, positional[1]);
|
|
55
|
+
else if (sub === "tool") code = workspaceTool(found.dir, positional[1], flags);
|
|
56
|
+
else return workspaceList(found.dir);
|
|
57
|
+
if (code === EXIT.OK) regenerate(found.dir);
|
|
58
|
+
return code;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function workspaceInit(dirArg, flags) {
|
|
62
|
+
const dir = path.resolve(process.cwd(), dirArg ?? ".");
|
|
63
|
+
if (isWorkspaceDir(dir)) {
|
|
64
|
+
console.error(c.red("error:") + ` ${dir} is already a workspace`);
|
|
65
|
+
console.error(c.gray("hint: ") + "add a project to it: `doctrina workspace add <path>`");
|
|
66
|
+
return EXIT.PRECONDITION;
|
|
67
|
+
}
|
|
68
|
+
const name = flagString(flags, "name") ?? path.basename(dir);
|
|
69
|
+
mkdirp(path.join(dir, "projects"));
|
|
70
|
+
mkdirp(path.join(dir, "deliveries"));
|
|
71
|
+
writeWorkspace(dir, blankWorkspace(name));
|
|
72
|
+
if (!exists(path.join(dir, ".gitignore"))) {
|
|
73
|
+
write(path.join(dir, ".gitignore"), "node_modules/\n.env\n.env.*\n*.log\n");
|
|
74
|
+
}
|
|
75
|
+
for (const keep of ["projects", "deliveries"]) {
|
|
76
|
+
if (!exists(path.join(dir, keep, ".gitkeep"))) write(path.join(dir, keep, ".gitkeep"), "");
|
|
77
|
+
}
|
|
78
|
+
console.log(c.green("created") + ` workspace "${name}" at ${dir}`);
|
|
79
|
+
if (!flags.get("no-git") && !exists(path.join(dir, ".git"))) {
|
|
80
|
+
console.log(gitInit(dir)
|
|
81
|
+
? c.green("created") + " a git repository for it — keep it private: it versions the context of repositories that cannot carry their own"
|
|
82
|
+
: c.yellow("warn:") + " git is not available; the workspace is not versioned yet (`git init` it yourself)");
|
|
83
|
+
}
|
|
84
|
+
writePointer(dir);
|
|
85
|
+
regenerate(dir);
|
|
86
|
+
console.log("");
|
|
87
|
+
console.log("Next: register each repository the work touches —");
|
|
88
|
+
console.log(" " + c.cyan("doctrina workspace add <path> --role front|back|app|qa --about \"<what it is>\""));
|
|
89
|
+
console.log(c.gray(" A repository with its own .doctrina/ is registered in-repo; one without is shadowed: its"));
|
|
90
|
+
console.log(c.gray(" artifacts live here and are linked into it, invisible to its git."));
|
|
91
|
+
return EXIT.OK;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function workspaceAdd(wsDir, pathArg, flags) {
|
|
95
|
+
if (!pathArg) {
|
|
96
|
+
console.error(c.red("error:") + " workspace add needs the repository's path");
|
|
97
|
+
console.error(c.gray("hint: ") + "doctrina workspace add ../front-core --role front --about \"design system and shell\"");
|
|
98
|
+
return EXIT.USAGE;
|
|
99
|
+
}
|
|
100
|
+
const repo = path.resolve(process.cwd(), pathArg);
|
|
101
|
+
if (!isDir(repo)) {
|
|
102
|
+
console.error(c.red("error:") + ` not a directory: ${pathArg}`);
|
|
103
|
+
return EXIT.PRECONDITION;
|
|
104
|
+
}
|
|
105
|
+
const ws = readWorkspace(wsDir);
|
|
106
|
+
const name = flagString(flags, "name") ?? path.basename(repo).toLowerCase();
|
|
107
|
+
if (!PROJECT_NAME.test(name)) {
|
|
108
|
+
console.error(c.red("error:") + ` project name "${name}" must match ${PROJECT_NAME} — pass --name`);
|
|
109
|
+
return EXIT.USAGE;
|
|
110
|
+
}
|
|
111
|
+
if (ws.projects.some((p) => p.name === name)) {
|
|
112
|
+
console.error(c.red("error:") + ` a project named "${name}" is already registered`);
|
|
113
|
+
console.error(c.gray("hint: ") + `relink it with \`doctrina workspace attach ${name}\`, or pass another --name`);
|
|
114
|
+
return EXIT.PRECONDITION;
|
|
115
|
+
}
|
|
116
|
+
const already = projectAt(ws, repo);
|
|
117
|
+
if (already) {
|
|
118
|
+
console.error(c.red("error:") + ` ${repo} is already registered as "${already.name}"`);
|
|
119
|
+
return EXIT.PRECONDITION;
|
|
120
|
+
}
|
|
121
|
+
const hasOwn = isDir(path.join(repo, ".doctrina"));
|
|
122
|
+
const mode = flagString(flags, "mode") ?? (hasOwn ? "in-repo" : "shadow");
|
|
123
|
+
if (!PROJECT_MODES.includes(mode)) {
|
|
124
|
+
console.error(c.red("error:") + ` --mode must be one of ${PROJECT_MODES.join(", ")} (got "${mode}")`);
|
|
125
|
+
return EXIT.USAGE;
|
|
126
|
+
}
|
|
127
|
+
if (mode === "shadow" && hasOwn) {
|
|
128
|
+
console.error(c.red("error:") + " this repository already carries its own .doctrina/ — register it in-repo");
|
|
129
|
+
return EXIT.PRECONDITION;
|
|
130
|
+
}
|
|
131
|
+
const dependsOn = (flagString(flags, "depends-on") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
132
|
+
const unknown = dependsOn.filter((d) => !ws.projects.some((p) => p.name === d));
|
|
133
|
+
if (unknown.length > 0) {
|
|
134
|
+
console.error(c.red("error:") + ` --depends-on names no registered project: ${unknown.join(", ")}`);
|
|
135
|
+
return EXIT.USAGE;
|
|
136
|
+
}
|
|
137
|
+
const project = {
|
|
138
|
+
name, path: repo, mode,
|
|
139
|
+
role: flagString(flags, "role") ?? "",
|
|
140
|
+
about: flagString(flags, "about") ?? "",
|
|
141
|
+
dependsOn,
|
|
142
|
+
};
|
|
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
|
+
}
|
|
159
|
+
ws.projects.push(project);
|
|
160
|
+
writeWorkspace(wsDir, ws);
|
|
161
|
+
console.log(c.green("registered") + ` ${name} (${mode}) → ${repo}`);
|
|
162
|
+
if (mode === "shadow") attachShadow(wsDir, project);
|
|
163
|
+
printProjectNext(wsDir, project);
|
|
164
|
+
return EXIT.OK;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function attachShadow(wsDir, project) {
|
|
168
|
+
const shadow = shadowDirOf(wsDir, project.name);
|
|
169
|
+
const plan = linkPlan(shadow, project.path);
|
|
170
|
+
const done = applyLinks(plan, shadow, project.path);
|
|
171
|
+
for (const d of done) {
|
|
172
|
+
console.log(c.green(d.how.padEnd(11)) + ` ${d.rel}` + (d.how === "copied" ? c.yellow(" (a copy: rerun attach after the shadow changes)") : ""));
|
|
173
|
+
}
|
|
174
|
+
for (const e of plan.filter((p) => p.action === "conflict")) {
|
|
175
|
+
console.log(c.yellow("kept ") + ` ${e.rel} — the repository already has its own; the shadow's copy is not linked`);
|
|
176
|
+
}
|
|
177
|
+
const excludeFile = writeExclude(project.path, excludeEntries(plan, project.path));
|
|
178
|
+
console.log(excludeFile
|
|
179
|
+
? c.gray(`hidden from the repository's git by ${relPath(project.path, excludeFile)} (never committed)`)
|
|
180
|
+
: c.yellow("warn:") + " not a git repository — nothing hides the links from version control");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function printProjectNext(wsDir, project) {
|
|
184
|
+
const st = projectState(wsDir, project);
|
|
185
|
+
if (st.state === "pending") {
|
|
186
|
+
console.log("");
|
|
187
|
+
console.log(`Next: ${project.name} has no specs yet (${st.reason}) — absorb it from its code:`);
|
|
188
|
+
console.log(" " + c.cyan(`cd ${project.path} && doctrina intake --from-code`));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function findProject(ws, name) {
|
|
193
|
+
const project = ws.projects.find((p) => p.name === name);
|
|
194
|
+
if (project) return project;
|
|
195
|
+
const guess = suggest(name ?? "", ws.projects.map((p) => p.name));
|
|
196
|
+
throw Object.assign(new Error(`no project named "${name ?? ""}" in this workspace` + (guess ? ` — did you mean "${guess}"?` : "")), { exitCode: EXIT.USAGE });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function workspaceAttach(wsDir, name, flags) {
|
|
200
|
+
const ws = readWorkspace(wsDir);
|
|
201
|
+
const project = findProject(ws, name);
|
|
202
|
+
const newPath = flagString(flags, "path");
|
|
203
|
+
if (newPath) {
|
|
204
|
+
const repo = path.resolve(process.cwd(), newPath);
|
|
205
|
+
if (!isDir(repo)) {
|
|
206
|
+
console.error(c.red("error:") + ` not a directory: ${newPath}`);
|
|
207
|
+
return EXIT.PRECONDITION;
|
|
208
|
+
}
|
|
209
|
+
project.path = repo;
|
|
210
|
+
writeWorkspace(wsDir, ws);
|
|
211
|
+
console.log(c.green("moved") + ` ${project.name} → ${repo}`);
|
|
212
|
+
}
|
|
213
|
+
if (project.mode !== "shadow") {
|
|
214
|
+
console.log(c.gray(`${project.name} is in-repo — its .doctrina/ is its own; nothing to link`));
|
|
215
|
+
return EXIT.OK;
|
|
216
|
+
}
|
|
217
|
+
if (!isDir(project.path)) {
|
|
218
|
+
console.error(c.red("error:") + ` ${project.name}'s repository is not at ${project.path}`);
|
|
219
|
+
console.error(c.gray("hint: ") + `point it at the clone: \`doctrina workspace attach ${project.name} --path <dir>\``);
|
|
220
|
+
return EXIT.PRECONDITION;
|
|
221
|
+
}
|
|
222
|
+
attachShadow(wsDir, project);
|
|
223
|
+
return EXIT.OK;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function workspaceDetach(wsDir, name) {
|
|
227
|
+
const ws = readWorkspace(wsDir);
|
|
228
|
+
const project = findProject(ws, name);
|
|
229
|
+
if (project.mode !== "shadow") {
|
|
230
|
+
console.log(c.gray(`${project.name} is in-repo — nothing was linked`));
|
|
231
|
+
return EXIT.OK;
|
|
232
|
+
}
|
|
233
|
+
const removed = isDir(project.path) ? removeLinks(shadowDirOf(wsDir, project.name), project.path) : [];
|
|
234
|
+
for (const r of removed) console.log(c.green("unlinked") + ` ${r}`);
|
|
235
|
+
if (isDir(project.path)) writeExclude(project.path, []);
|
|
236
|
+
console.log(c.gray(`the shadow stays in ${relPath(wsDir, shadowDirOf(wsDir, project.name))}/ — \`doctrina workspace attach ${project.name}\` links it again`));
|
|
237
|
+
return EXIT.OK;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The orchestrator's files follow the registry on every write (change 0223).
|
|
241
|
+
function regenerate(wsDir) {
|
|
242
|
+
const ws = readWorkspace(wsDir);
|
|
243
|
+
const states = new Map(ws.projects.map((p) => [p.name, projectState(wsDir, p).state]));
|
|
244
|
+
const written = writeOrchestratorFiles(ws, wsDir, states);
|
|
245
|
+
if (written.length > 0) console.log(c.gray(`orchestrator files: ${written.join(", ")}`));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function workspaceTool(wsDir, name, flags) {
|
|
249
|
+
if (!name) {
|
|
250
|
+
console.error(c.red("error:") + " workspace tool needs the tool's name");
|
|
251
|
+
console.error(c.gray("hint: ") + `presets: ${Object.keys(TOOL_PRESETS).join(", ")} — or --server <name in your agent's MCP config>`);
|
|
252
|
+
return EXIT.USAGE;
|
|
253
|
+
}
|
|
254
|
+
const ws = readWorkspace(wsDir);
|
|
255
|
+
const at = ws.tools.findIndex((t) => t.name === name);
|
|
256
|
+
if (flags.get("remove")) {
|
|
257
|
+
if (at < 0) {
|
|
258
|
+
console.error(c.red("error:") + ` no tool named "${name}"`);
|
|
259
|
+
return EXIT.USAGE;
|
|
260
|
+
}
|
|
261
|
+
ws.tools.splice(at, 1);
|
|
262
|
+
writeWorkspace(wsDir, ws);
|
|
263
|
+
console.log(c.green("removed") + ` tool ${name}`);
|
|
264
|
+
return EXIT.OK;
|
|
265
|
+
}
|
|
266
|
+
let entry;
|
|
267
|
+
try {
|
|
268
|
+
entry = toolEntry(name, {
|
|
269
|
+
server: flagString(flags, "server"), access: flagString(flags, "access"),
|
|
270
|
+
for: flagString(flags, "for"), purpose: flagString(flags, "purpose"),
|
|
271
|
+
}, at >= 0 ? ws.tools[at] : null);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error(c.red("error:") + ` ${err.message}`);
|
|
274
|
+
return EXIT.USAGE;
|
|
275
|
+
}
|
|
276
|
+
if (at >= 0) ws.tools[at] = entry;
|
|
277
|
+
else ws.tools.push(entry);
|
|
278
|
+
writeWorkspace(wsDir, ws);
|
|
279
|
+
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"}`);
|
|
280
|
+
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}"`));
|
|
281
|
+
return EXIT.OK;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Reads the agent's pre-call payload on stdin and answers in the agent's
|
|
285
|
+
// own format. Never exits non-zero and never blocks on bad input: a broken
|
|
286
|
+
// 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
|
+
|
|
322
|
+
function workspaceList(wsDir) {
|
|
323
|
+
const ws = readWorkspace(wsDir);
|
|
324
|
+
console.log(c.bold(`Workspace "${ws.name}"`) + c.gray(` — ${wsDir}`));
|
|
325
|
+
if (ws.projects.length === 0) {
|
|
326
|
+
console.log("");
|
|
327
|
+
console.log(c.gray("no project yet — `doctrina workspace add <path>`"));
|
|
328
|
+
return EXIT.OK;
|
|
329
|
+
}
|
|
330
|
+
console.log("");
|
|
331
|
+
for (const p of ws.projects) {
|
|
332
|
+
const st = projectState(wsDir, p);
|
|
333
|
+
const colour = st.state === "ready" ? c.green : st.state === "pending" ? c.yellow : c.red;
|
|
334
|
+
const open = st.open.length ? `, ${st.open.length} open change${st.open.length === 1 ? "" : "s"}` : "";
|
|
335
|
+
console.log(` ${c.cyan(p.name.padEnd(16))} ${p.mode.padEnd(8)} ${colour(st.state.padEnd(8))} ${st.reason}${open}`);
|
|
336
|
+
const meta = [p.role && `role ${p.role}`, p.dependsOn?.length && `depends on ${p.dependsOn.join(", ")}`].filter(Boolean).join(" · ");
|
|
337
|
+
console.log(c.gray(` ${"".padEnd(16)} ${p.path}${meta ? " · " + meta : ""}`));
|
|
338
|
+
if (p.about) console.log(c.gray(` ${"".padEnd(16)} ${p.about}`));
|
|
339
|
+
}
|
|
340
|
+
const pending = ws.projects.filter((p) => projectState(wsDir, p).state === "pending");
|
|
341
|
+
if (pending.length > 0) {
|
|
342
|
+
console.log("");
|
|
343
|
+
console.log(`Next: absorb ${pending.map((p) => p.name).join(", ")} — ` + c.cyan(`cd ${pending[0].path} && doctrina intake --from-code`));
|
|
344
|
+
}
|
|
345
|
+
console.log("");
|
|
346
|
+
if (ws.tools.length === 0) {
|
|
347
|
+
console.log(c.gray("no tool declared — `doctrina workspace tool jira` (presets: " + Object.keys(TOOL_PRESETS).join(", ") + ")"));
|
|
348
|
+
} else {
|
|
349
|
+
console.log(c.bold("Tools"));
|
|
350
|
+
for (const t of ws.tools) {
|
|
351
|
+
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 ?? "")}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return EXIT.OK;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export const help = `
|
|
358
|
+
Usage: doctrina workspace <init|add|attach|detach|list> [options]
|
|
359
|
+
|
|
360
|
+
The context of several repositories in one place (ADR 0029). Keep the
|
|
361
|
+
workspace directory as a PRIVATE git repository: it versions the
|
|
362
|
+
artifacts of repositories that cannot carry .doctrina/ themselves.
|
|
363
|
+
|
|
364
|
+
init [<dir>] [--name <n>] [--no-git]
|
|
365
|
+
Create the workspace (workspace.json, projects/, deliveries/) and
|
|
366
|
+
\`git init\` it. Records it in $DOCTRINA_HOME (default ~/.doctrina)
|
|
367
|
+
so an in-repo project can find it.
|
|
368
|
+
|
|
369
|
+
add <path> [--name <n>] [--mode in-repo|shadow] [--role <r>]
|
|
370
|
+
[--about "<what it is>"] [--depends-on <a,b>] [--agent cursor]
|
|
371
|
+
Register a repository. One with its own .doctrina/ is in-repo. One
|
|
372
|
+
without is a SHADOW: its AGENTS.md, .doctrina/ and agent adapter are
|
|
373
|
+
scaffolded under projects/<name>/, linked into the repository, and
|
|
374
|
+
hidden from its git through .git/info/exclude.
|
|
375
|
+
|
|
376
|
+
attach <name> [--path <dir>]
|
|
377
|
+
Re-create a shadow's links (after cloning the workspace on another
|
|
378
|
+
machine, or with --path after the repository moved).
|
|
379
|
+
|
|
380
|
+
detach <name>
|
|
381
|
+
Remove a shadow's links and its exclude block; the shadow stays.
|
|
382
|
+
|
|
383
|
+
list
|
|
384
|
+
Every project with its mode, state (ready / pending / missing) and
|
|
385
|
+
open changes, then the declared tools.
|
|
386
|
+
|
|
387
|
+
tool <name> [--server <mcp-name>] [--access read|write] [--for <roles>]
|
|
388
|
+
[--purpose "<what for>"] [--remove]
|
|
389
|
+
Declare (or update, or --remove) a tool the work uses. Presets:
|
|
390
|
+
jira, confluence, figma, playwright, datadog, databricks, sql.
|
|
391
|
+
Roles: planner, executor, qa, observer, reviewer. Access defaults
|
|
392
|
+
to read: the guard refuses a write through a read-only tool and
|
|
393
|
+
makes a declared write wait for the human.
|
|
394
|
+
|
|
395
|
+
guard --hook cursor|claude
|
|
396
|
+
The pre-call hook the workspace installs (Cursor beforeMCPExecution,
|
|
397
|
+
Claude Code PreToolUse): reads the call on stdin and answers allow,
|
|
398
|
+
deny or ask from the declared access. Never blocks on bad input.
|
|
399
|
+
|
|
400
|
+
Every write (init, add, attach, detach, tool) regenerates the orchestrator
|
|
401
|
+
files at the workspace root: AGENTS.md, .cursor/rules/00-doctrina-
|
|
402
|
+
workspace.mdc, .cursor/hooks.json and .claude/settings.json (merged),
|
|
403
|
+
.claude/agents/doctrina-*.md (read by Claude Code and Cursor) and
|
|
404
|
+
<name>.code-workspace, which opens every repository in one window.
|
|
405
|
+
|
|
406
|
+
The workspace is found from $DOCTRINA_WORKSPACE, the current directory
|
|
407
|
+
or an ancestor holding ${WORKSPACE_FILE}, a shadow's .doctrina link, or
|
|
408
|
+
the pointer \`init\` wrote.
|
|
409
|
+
`;
|