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
|
@@ -25,10 +25,11 @@ export const TOOL_PRESETS = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
/** A tool entry from flags and an optional preset. Throws a message on bad input. */
|
|
28
|
-
export function toolEntry(name, opts, existing = null) {
|
|
28
|
+
export function toolEntry(name, opts, existing = /** @type {any} */ (null)) {
|
|
29
29
|
if (!TOOL_NAME.test(name)) throw new Error(`tool name "${name}" must match ${TOOL_NAME}`);
|
|
30
30
|
const preset = TOOL_PRESETS[name] ?? {};
|
|
31
31
|
const base = existing ?? { name, server: preset.server ?? null, access: "read", for: preset.for ?? [], purpose: preset.purpose ?? "" };
|
|
32
|
+
/** @type {any} */
|
|
32
33
|
const entry = { ...base };
|
|
33
34
|
if (opts.server) entry.server = opts.server;
|
|
34
35
|
if (opts.access) {
|
|
@@ -42,10 +43,40 @@ export function toolEntry(name, opts, existing = null) {
|
|
|
42
43
|
entry.for = roles;
|
|
43
44
|
}
|
|
44
45
|
if (opts.purpose) entry.purpose = opts.purpose;
|
|
46
|
+
// The calls this tool may make, by name (change 0244). `none` takes the
|
|
47
|
+
// list away; a pattern is a tool name, `*` standing for any run of it.
|
|
48
|
+
if (opts.only !== undefined && opts.only !== null) {
|
|
49
|
+
const patterns = String(opts.only).split(",").map((s) => s.trim()).filter(Boolean);
|
|
50
|
+
if (patterns.length === 0 || (patterns.length === 1 && patterns[0].toLowerCase() === "none")) {
|
|
51
|
+
delete entry.only;
|
|
52
|
+
} else {
|
|
53
|
+
const bad = patterns.filter((p) => !CALL_PATTERN.test(p));
|
|
54
|
+
if (bad.length) throw new Error(`--only takes MCP tool names, \`*\` for any run of characters (got ${bad.map((b) => `"${b}"`).join(", ")})`);
|
|
55
|
+
entry.only = patterns;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
45
58
|
if (!entry.server) throw new Error(`tool "${name}" has no preset — pass --server <the MCP server's name in your agent's config>`);
|
|
46
59
|
return entry;
|
|
47
60
|
}
|
|
48
61
|
|
|
62
|
+
// A LIST OF THE CALLS A TOOL MAY MAKE (change 0244).
|
|
63
|
+
//
|
|
64
|
+
// "Does this call write?" is answered from the call's name and input, which
|
|
65
|
+
// is a guess: a verb the list below has never seen reads as a read, and the
|
|
66
|
+
// call goes through. A tool that declares `only` turns the question around —
|
|
67
|
+
// the calls named pass on to that judgment, and every other call through
|
|
68
|
+
// its server is denied, whatever its name suggests.
|
|
69
|
+
const CALL_PATTERN = /^[A-Za-z0-9_.*:-]+$/;
|
|
70
|
+
|
|
71
|
+
/** Does `tool` match one of the patterns (case-insensitive, `*` = any run)? */
|
|
72
|
+
export function matchesCall(patterns, tool) {
|
|
73
|
+
const name = String(tool).toLowerCase();
|
|
74
|
+
return patterns.some((p) => {
|
|
75
|
+
const re = new RegExp("^" + String(p).toLowerCase().split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
|
|
76
|
+
return re.test(name);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
49
80
|
// Verbs that change state in the system a tool talks to. Matched on the
|
|
50
81
|
// tool's own name, word by word.
|
|
51
82
|
const WRITE_VERBS = new Set([
|
|
@@ -53,35 +84,82 @@ const WRITE_VERBS = new Set([
|
|
|
53
84
|
"transition", "assign", "comment", "publish", "upload", "deploy", "trigger", "set", "move",
|
|
54
85
|
"merge", "close", "archive", "insert", "drop", "alter", "truncate", "mutate", "rollback", "restart",
|
|
55
86
|
"approve", "reject", "resolve", "mute", "unmute", "schedule", "cancel", "import",
|
|
87
|
+
// The fourth audit (change 0235): verbs real MCP servers use that passed as reads.
|
|
88
|
+
"upsert", "link", "unlink", "reopen", "rename", "replace", "save", "submit", "sync", "release",
|
|
89
|
+
"rotate", "grant", "revoke", "reset", "clear", "purge", "destroy", "terminate", "kill", "pause",
|
|
90
|
+
"enable", "disable", "snooze", "escalate", "notify", "reply", "react", "vote", "fork", "push",
|
|
91
|
+
"commit", "lock", "unlock", "ack", "acknowledge", "install", "uninstall", "provision", "scale",
|
|
92
|
+
"rerun", "retry", "dispatch", "copy", "duplicate", "invite", "share", "subscribe", "unsubscribe",
|
|
93
|
+
"block", "unblock", "revert", "restore", "empty",
|
|
56
94
|
]);
|
|
57
|
-
|
|
95
|
+
// Statements that change the database, a procedure that may, and a SELECT
|
|
96
|
+
// that writes a table (`SELECT … INTO`). A keyword in a comment counts: the
|
|
97
|
+
// guard errs on the side of asking.
|
|
98
|
+
const MUTATING_SQL = /\b(insert|update|delete|merge|drop|alter|create|truncate|grant|revoke|replace|call|exec|execute|copy|do|vacuum|reindex|cluster|refresh|upsert|load|unload|optimize|restore|rename|comment|lock|into)\b/i;
|
|
99
|
+
const SQL_START = /^\s*(\(|\/\*|--)?\s*(select|with|insert|update|delete|merge|drop|alter|create|truncate|show|describe|explain|call|exec|execute|copy|grant|revoke|vacuum|refresh|optimize|do)\b/i;
|
|
100
|
+
// An argument that names the operation a generic tool performs.
|
|
101
|
+
const ACTION_KEYS = new Set(["action", "operation", "op", "method", "verb", "mutation"]);
|
|
58
102
|
|
|
59
103
|
/**
|
|
60
104
|
* Whether a tool call would change state. A SQL statement in the input
|
|
61
105
|
* decides for itself; otherwise the tool's name does.
|
|
62
106
|
*/
|
|
63
107
|
export function isWriteCall(toolName, toolInput) {
|
|
64
|
-
const
|
|
108
|
+
const input = parsed(toolInput);
|
|
109
|
+
// An argument that says what a generic tool does: `{ action: "delete" }`,
|
|
110
|
+
// `{ method: "POST" }`, a GraphQL `mutation` (change 0235).
|
|
111
|
+
if (actionWrites(input)) return true;
|
|
112
|
+
const sql = sqlIn(input);
|
|
65
113
|
if (sql !== null) return MUTATING_SQL.test(sql.replace(/'[^']*'/g, "''"));
|
|
66
114
|
// camelCase and snake_case alike: `transitionJiraIssue`, `transition_issue`.
|
|
67
|
-
const words =
|
|
68
|
-
|
|
115
|
+
const words = nameWords(toolName);
|
|
116
|
+
if (words.some((w) => WRITE_VERBS.has(w))) return true;
|
|
117
|
+
// A name with no separator at all — `createissue`, `CREATEISSUE`,
|
|
118
|
+
// `getandremove` — hides its verbs inside one word (change 0235).
|
|
119
|
+
if (words.length === 1 && words[0].length > 6) {
|
|
120
|
+
return [...WRITE_VERBS].some((v) => v.length >= 5 && words[0].includes(v));
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
69
123
|
}
|
|
70
124
|
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
125
|
+
function nameWords(name) {
|
|
126
|
+
return String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z]+/).filter(Boolean);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parsed(input) {
|
|
130
|
+
if (typeof input !== "string") return input;
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(input);
|
|
133
|
+
} catch {
|
|
134
|
+
return input;
|
|
79
135
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Every string inside an input, to a bounded depth. */
|
|
139
|
+
function stringsIn(value, depth = 0, out = /** @type {Array<{ key: string, value: string }>} */ ([]), key = "") {
|
|
140
|
+
if (depth > 5 || out.length > 200) return out;
|
|
141
|
+
if (typeof value === "string") out.push({ key, value });
|
|
142
|
+
else if (Array.isArray(value)) for (const v of value) stringsIn(v, depth + 1, out, key);
|
|
143
|
+
else if (value && typeof value === "object") for (const [k, v] of Object.entries(value)) stringsIn(v, depth + 1, out, k);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function actionWrites(input) {
|
|
148
|
+
for (const { key, value } of stringsIn(input)) {
|
|
149
|
+
if (/^\s*mutation\b/i.test(value)) return true;
|
|
150
|
+
if (!ACTION_KEYS.has(key.toLowerCase())) continue;
|
|
151
|
+
const words = nameWords(value);
|
|
152
|
+
if (words.some((w) => WRITE_VERBS.has(w) || ["post", "put", "patch", "delete"].includes(w))) return true;
|
|
83
153
|
}
|
|
84
|
-
return
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// SQL wherever the input carries it — `sql`, `query`, `q`, a `statements`
|
|
158
|
+
// list, a nested `{ input: { sql } }` (change 0235): only four top-level
|
|
159
|
+
// keys were read, and a DELETE under any other passed as no SQL at all.
|
|
160
|
+
function sqlIn(input) {
|
|
161
|
+
const found = stringsIn(input).map((s) => s.value).filter((s) => SQL_START.test(s));
|
|
162
|
+
return found.length ? found.join(";\n") : null;
|
|
85
163
|
}
|
|
86
164
|
|
|
87
165
|
/**
|
|
@@ -93,6 +171,15 @@ function sqlIn(input) {
|
|
|
93
171
|
export function guardDecision(tools, server, tool, input) {
|
|
94
172
|
const declared = tools.filter((t) => t.server === server && t.enabled !== false);
|
|
95
173
|
if (declared.length === 0) return { decision: "none", reason: `server "${server}" is not declared in the workspace` };
|
|
174
|
+
// A list on any tool of this server is the server's list: a call outside
|
|
175
|
+
// it is denied before its name is read for a verb.
|
|
176
|
+
const listed = declared.filter((t) => Array.isArray(t.only) && t.only.length > 0);
|
|
177
|
+
if (listed.length > 0 && !listed.some((t) => matchesCall(t.only, tool))) {
|
|
178
|
+
return {
|
|
179
|
+
decision: "deny",
|
|
180
|
+
reason: `${tool} is not among the calls the workspace allows through "${server}" (${listed.map((t) => `${t.name}: ${t.only.join(", ")}`).join("; ")}). Ask the human; to allow it: doctrina workspace tool ${listed[0].name} --only "${[...listed[0].only, tool].join(",")}"`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
96
183
|
const write = isWriteCall(tool, input);
|
|
97
184
|
if (!write) return { decision: "allow", reason: `${tool} reads` };
|
|
98
185
|
const writable = declared.some((t) => t.access === "write");
|
|
@@ -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
|
+
}
|