doctrina-cli 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +92 -14
- package/src/commands/clarify.js +1 -204
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +21 -1
- package/src/commands/handoff.js +11 -2
- package/src/commands/init.js +13 -1
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +15 -0
- package/src/commands/next.js +41 -6
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +39 -3
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/upgrade.js +18 -0
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +32 -4
- package/src/commands/work.js +43 -8
- package/src/commands/workspace.js +73 -63
- package/src/index.js +98 -62
- package/src/lib/actions.js +43 -3
- package/src/lib/adapters.js +63 -1
- package/src/lib/args.js +33 -1
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +576 -103
- package/src/lib/commands.js +8 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/delivery.js +88 -10
- package/src/lib/docs-impact.js +36 -1
- package/src/lib/fs-ops.js +23 -0
- package/src/lib/intake-model.js +1 -1
- package/src/lib/json-out.js +9 -1
- package/src/lib/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/task-card.js +100 -7
- package/src/lib/views.js +3 -3
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +65 -18
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +63 -16
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +82 -130
- package/templates/AGENTS.md.template +1 -1
- package/templates/README.md +2 -1
- package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +12 -6
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { isFile, read, relPath, toPosix, write } from "./fs-ops.js";
|
|
4
4
|
import { mergeJson } from "./adapters.js";
|
|
5
|
-
import { projectState } from "./workspace.js";
|
|
5
|
+
import { absorbCommand, projectState, shadowDirOf } from "./workspace.js";
|
|
6
|
+
import { isDir } from "./fs-ops.js";
|
|
6
7
|
|
|
7
8
|
// The ORCHESTRATOR's files, generated at the workspace root from
|
|
8
9
|
// workspace.json (change 0223, ADR 0031) — what Cursor or Claude Code read
|
|
@@ -116,7 +117,9 @@ export function workspaceHub(ws, wsDir, states) {
|
|
|
116
117
|
L.push("the analyst: they bring the item of work and approve; you read it, plan it,");
|
|
117
118
|
L.push("delegate it, review it and close it (ADR 0030).", "");
|
|
118
119
|
L.push("## Session start", "");
|
|
119
|
-
L.push("`doctrina prime` here: the projects, their state and the open deliveries."
|
|
120
|
+
L.push("`doctrina prime` here: the projects, their state and the open deliveries.");
|
|
121
|
+
L.push("Unsure what comes next: `doctrina next`. Before a compaction or a handover:");
|
|
122
|
+
L.push("`doctrina handoff`. Both answer for the whole workspace from here.", "");
|
|
120
123
|
L.push("## Projects", "");
|
|
121
124
|
L.push("| Project | Mode | State | Role | Depends on | Path | What it is |");
|
|
122
125
|
L.push("|---|---|---|---|---|---|---|");
|
|
@@ -126,12 +129,12 @@ export function workspaceHub(ws, wsDir, states) {
|
|
|
126
129
|
}
|
|
127
130
|
if (ws.projects.length === 0) L.push("| — | | | | | | register one: `doctrina workspace add <path>` |");
|
|
128
131
|
L.push("");
|
|
129
|
-
L.push("A `pending` project has no specs yet: `cd <path> && doctrina intake --from-code`");
|
|
132
|
+
L.push("A `pending` project has no specs yet: `cd <path> && doctrina intake --from-code` (once its inventory is stored, a bare `doctrina intake` reprints the playbook)");
|
|
130
133
|
L.push("and run the absorb playbook before routing work to it.", "");
|
|
131
134
|
L.push("## Tools", "");
|
|
132
135
|
if (ws.tools.length === 0) {
|
|
133
|
-
L.push("None declared. Declare each MCP server the work uses
|
|
134
|
-
L.push("`doctrina workspace tool
|
|
136
|
+
L.push("None declared. Declare each MCP server the work uses:");
|
|
137
|
+
L.push("`doctrina workspace tool <name> --server <mcp-server> --for <roles>`.");
|
|
135
138
|
} else {
|
|
136
139
|
L.push("| Tool | MCP server | Access | Used by | For |");
|
|
137
140
|
L.push("|---|---|---|---|---|");
|
|
@@ -151,8 +154,9 @@ export function workspaceHub(ws, wsDir, states) {
|
|
|
151
154
|
L.push(" repository at a time.");
|
|
152
155
|
L.push("4. Review with `doctrina-reviewer`, then close each change yourself:");
|
|
153
156
|
L.push(" `doctrina close <id>`. Executors never close.");
|
|
154
|
-
L.push("5.
|
|
155
|
-
L.push(" `doctrina delivery
|
|
157
|
+
L.push("5. Record the release under the delivery's Release and tick each claim");
|
|
158
|
+
L.push(" (`doctrina delivery tick <id> <n>`), record what was watched under");
|
|
159
|
+
L.push(" Observability, then `doctrina delivery check <id>` → `doctrina delivery close <id>`.");
|
|
156
160
|
L.push("6. Name the delivery id in your first sentence to the human, repeat the");
|
|
157
161
|
L.push(" `Tell the human:` lines, and commit the workspace when a delivery closes.", "");
|
|
158
162
|
L.push("## Rules", "");
|
|
@@ -195,8 +199,8 @@ export function workspacePrimer(ws, wsDir, open) {
|
|
|
195
199
|
if (open.length === 0) L.push(" none");
|
|
196
200
|
L.push("", `Tools ${ws.tools.length ? ws.tools.map((t) => `${t.name} (${t.access})`).join(" · ") : "none declared — doctrina workspace tool <name>"}`);
|
|
197
201
|
L.push("", "Next");
|
|
198
|
-
const pending = ws.projects.
|
|
199
|
-
if (pending.length) L.push(` absorb ${pending[0].name} before routing work to it:
|
|
202
|
+
const pending = ws.projects.map((p) => ({ p, s: projectState(wsDir, p) })).filter((x) => x.s.state === "pending");
|
|
203
|
+
if (pending.length) L.push(` absorb ${pending[0].p.name} before routing work to it: ${absorbCommand({ path: toPosix(pending[0].p.path), name: pending[0].p.name }, pending[0].s.reason)}`);
|
|
200
204
|
if (open.length) L.push(` continue ${open[0].id}: doctrina delivery status ${open[0].id}`);
|
|
201
205
|
L.push(" new work across repositories: doctrina delivery new \"<title>\" --ref <tracker-id>");
|
|
202
206
|
return L;
|
|
@@ -225,15 +229,41 @@ const CLAUDE_SETTINGS = {
|
|
|
225
229
|
},
|
|
226
230
|
};
|
|
227
231
|
|
|
228
|
-
function mergedJson(file, ours) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
function mergedJson(file, ours, failClosed = false) {
|
|
233
|
+
let theirs = null;
|
|
234
|
+
if (isFile(file)) {
|
|
235
|
+
try {
|
|
236
|
+
theirs = JSON.parse(read(file));
|
|
237
|
+
} catch {
|
|
238
|
+
return null; // an unreadable file the operator owns is left alone
|
|
239
|
+
}
|
|
235
240
|
}
|
|
236
|
-
|
|
241
|
+
const merged = theirs === null ? structuredClone(ours) : mergeJson(theirs, ours);
|
|
242
|
+
// The guard's Cursor entry is OURS to shape (change 0238): replaced, never
|
|
243
|
+
// appended beside an older copy — a second entry would run it twice.
|
|
244
|
+
const mcp = merged?.hooks?.beforeMCPExecution;
|
|
245
|
+
if (Array.isArray(mcp)) {
|
|
246
|
+
const others = mcp.filter((h) => !(h && typeof h === "object" && h.command === GUARD_COMMAND));
|
|
247
|
+
if (others.length !== mcp.length || ours?.hooks?.beforeMCPExecution) {
|
|
248
|
+
merged.hooks.beforeMCPExecution = [...others, guardEntry(failClosed)];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return JSON.stringify(merged, null, 2) + "\n";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const GUARD_COMMAND = "doctrina workspace guard --hook cursor";
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The Cursor guard hook. With tools declared it fails CLOSED (change 0238):
|
|
258
|
+
* Cursor lets a call through when a hook crashes, times out or cannot start
|
|
259
|
+
* — `doctrina` missing from the PATH Cursor runs hooks with is enough — so a
|
|
260
|
+
* read-only tool would write with no guard, in silence. Failing closed turns
|
|
261
|
+
* a broken install into a refused call the human sees. With nothing
|
|
262
|
+
* declared, the guard has no opinion to enforce, and a broken install must
|
|
263
|
+
* not block the operator's other MCP servers.
|
|
264
|
+
*/
|
|
265
|
+
function guardEntry(failClosed) {
|
|
266
|
+
return failClosed ? { command: GUARD_COMMAND, failClosed: true } : { command: GUARD_COMMAND };
|
|
237
267
|
}
|
|
238
268
|
|
|
239
269
|
/**
|
|
@@ -251,9 +281,26 @@ export function writeOrchestratorFiles(ws, wsDir, states) {
|
|
|
251
281
|
};
|
|
252
282
|
put("AGENTS.md", workspaceHub(ws, wsDir, states));
|
|
253
283
|
put(".cursor/rules/00-doctrina-workspace.mdc", CURSOR_RULE(ws));
|
|
254
|
-
|
|
284
|
+
const failClosed = ws.tools.some((x) => x.enabled !== false);
|
|
285
|
+
put(".cursor/hooks.json", mergedJson(path.join(wsDir, ".cursor", "hooks.json"), CURSOR_HOOKS, failClosed));
|
|
255
286
|
put(".claude/settings.json", mergedJson(path.join(wsDir, ".claude", "settings.json"), CLAUDE_SETTINGS));
|
|
256
287
|
for (const role of agentRoles(ws)) put(`.claude/agents/doctrina-${role}.md`, agentFile(ws, role));
|
|
257
288
|
put(`${ws.name}.code-workspace`, codeWorkspace(ws, wsDir));
|
|
289
|
+
// The guard inside every shadow too (third audit, change 0234). It was
|
|
290
|
+
// installed at the root only, and an executor works INSIDE the repository
|
|
291
|
+
// (ADR 0030): Cursor and Claude Code read that folder's own hook files, so
|
|
292
|
+
// every MCP call an executor made there went through no guard at all.
|
|
293
|
+
for (const p of ws.projects.filter((x) => x.mode === "shadow")) {
|
|
294
|
+
const shadow = shadowDirOf(wsDir, p.name);
|
|
295
|
+
const rel = relPath(wsDir, shadow);
|
|
296
|
+
if (isDir(path.join(shadow, ".cursor"))) put(`${rel}/.cursor/hooks.json`, mergedJson(path.join(shadow, ".cursor", "hooks.json"), GUARD_HOOKS.cursor, failClosed));
|
|
297
|
+
if (isDir(path.join(shadow, ".claude"))) put(`${rel}/.claude/settings.json`, mergedJson(path.join(shadow, ".claude", "settings.json"), GUARD_HOOKS.claude));
|
|
298
|
+
}
|
|
258
299
|
return out;
|
|
259
300
|
}
|
|
301
|
+
|
|
302
|
+
/** The guard's own hook entries — what a shadow's hook files need beside their session hook. */
|
|
303
|
+
export const GUARD_HOOKS = {
|
|
304
|
+
cursor: { version: 1, hooks: { beforeMCPExecution: CURSOR_HOOKS.hooks.beforeMCPExecution } },
|
|
305
|
+
claude: { hooks: { PreToolUse: CLAUDE_SETTINGS.hooks.PreToolUse } },
|
|
306
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { c } from "./colors.js";
|
|
5
|
+
import { EXIT } from "./exit-codes.js";
|
|
6
|
+
import { findWorkspace, readWorkspace } from "./workspace-locate.js";
|
|
7
|
+
import { guardDecision, splitClaudeToolName } from "./workspace-tools.js";
|
|
8
|
+
|
|
9
|
+
// `doctrina workspace guard --hook cursor|claude` — the pre-call hook an
|
|
10
|
+
// agent runs before EVERY MCP call (ADR 0031). It lives apart from the rest
|
|
11
|
+
// of `workspace`, in lib/ so the entrypoint and the `workspace` command share
|
|
12
|
+
// one door, and the entrypoint routes to it directly (fifth audit, change
|
|
13
|
+
// 0236), so a call pays for the guard and not for the whole CLI. It is an
|
|
14
|
+
// I/O adapter — stdin in, the agent's JSON out — around guardDecision.
|
|
15
|
+
|
|
16
|
+
/** Run the guard from the current directory. */
|
|
17
|
+
export function runGuard(hook) {
|
|
18
|
+
return workspaceGuard(findWorkspace(process.cwd())?.dir ?? null, hook);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function workspaceGuard(wsDir, hook) {
|
|
22
|
+
if (hook !== "cursor" && hook !== "claude") {
|
|
23
|
+
console.error(c.red("error:") + ` --hook needs cursor or claude (got "${hook ?? ""}")`);
|
|
24
|
+
return EXIT.USAGE;
|
|
25
|
+
}
|
|
26
|
+
let call = {};
|
|
27
|
+
try {
|
|
28
|
+
call = JSON.parse(readFileSync(0, "utf8") || "{}");
|
|
29
|
+
} catch {
|
|
30
|
+
call = {};
|
|
31
|
+
}
|
|
32
|
+
// Cursor sets these for every hook it runs, its imported Claude ones included.
|
|
33
|
+
const cursorRuns = Boolean(process.env.CURSOR_VERSION && process.env.CURSOR_PROJECT_DIR);
|
|
34
|
+
let server = null;
|
|
35
|
+
let tool = null;
|
|
36
|
+
if (hook === "cursor") {
|
|
37
|
+
server = call.mcp_server_name ?? null;
|
|
38
|
+
tool = call.tool_name ?? null;
|
|
39
|
+
} else {
|
|
40
|
+
const split = splitClaudeToolName(call.tool_name ?? "");
|
|
41
|
+
if (split) ({ server, tool } = split);
|
|
42
|
+
// Cursor runs this Claude-format hook too, as its preToolUse (sixth
|
|
43
|
+
// audit, change 0237), with its own payload: read the server from it.
|
|
44
|
+
else if (cursorRuns && call.mcp_server_name) ({ mcp_server_name: server, tool_name: tool } = call);
|
|
45
|
+
}
|
|
46
|
+
const tools = wsDir ? readWorkspace(wsDir).tools : [];
|
|
47
|
+
const verdict = server && tool ? guardDecision(tools, server, tool, call.tool_input) : { decision: "none", reason: "" };
|
|
48
|
+
if (hook === "cursor") {
|
|
49
|
+
const permission = verdict.decision === "none" ? "allow" : verdict.decision;
|
|
50
|
+
const out = { permission };
|
|
51
|
+
if (permission !== "allow") Object.assign(out, { user_message: verdict.reason, agent_message: verdict.reason });
|
|
52
|
+
console.log(JSON.stringify(out));
|
|
53
|
+
} else if (verdict.decision !== "none") {
|
|
54
|
+
console.log(JSON.stringify({ hookSpecificOutput: {
|
|
55
|
+
hookEventName: "PreToolUse", permissionDecision: verdict.decision, permissionDecisionReason: verdict.reason,
|
|
56
|
+
} }));
|
|
57
|
+
} else if (cursorRuns) {
|
|
58
|
+
// preToolUse is a permission hook in Cursor: an answer that is not JSON
|
|
59
|
+
// blocks the call. With no opinion, the Claude-format allow — which
|
|
60
|
+
// Cursor translates — and never silence. (In Claude Code itself "allow"
|
|
61
|
+
// would skip the human's prompt, so it is printed only under Cursor.)
|
|
62
|
+
console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: "no Doctrina opinion" } }));
|
|
63
|
+
}
|
|
64
|
+
return EXIT.OK;
|
|
65
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { isFile, read, write } from "./fs-ops.js";
|
|
7
|
+
|
|
8
|
+
// WHERE A WORKSPACE IS, and nothing else (fifth audit, change 0236). The
|
|
9
|
+
// guard runs on every MCP call an agent makes, and it needs only this: find
|
|
10
|
+
// the workspace, read its registry. It used to load lib/workspace.js, which
|
|
11
|
+
// pulls the project snapshot and git in with it — ~90 ms a call. Everything
|
|
12
|
+
// here is re-exported by lib/workspace.js, so callers keep one door.
|
|
13
|
+
|
|
14
|
+
export const WORKSPACE_FILE = "workspace.json";
|
|
15
|
+
export const WORKSPACE_SCHEMA = 1;
|
|
16
|
+
export const PROJECT_MODES = ["in-repo", "shadow"];
|
|
17
|
+
export const PROJECT_NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
18
|
+
export const EXCLUDE_BEGIN = "# doctrina:workspace:begin — managed by `doctrina workspace attach`; edits inside are overwritten";
|
|
19
|
+
export const EXCLUDE_END = "# doctrina:workspace:end";
|
|
20
|
+
|
|
21
|
+
/** A fresh workspace document. */
|
|
22
|
+
export function blankWorkspace(name) {
|
|
23
|
+
return {
|
|
24
|
+
doctrina_workspace: WORKSPACE_SCHEMA,
|
|
25
|
+
name,
|
|
26
|
+
projects: [],
|
|
27
|
+
tools: [],
|
|
28
|
+
agents: { planner: { model: "inherit" }, executor: { model: "inherit" } },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isWorkspaceDir(dir) {
|
|
33
|
+
const p = path.join(dir, WORKSPACE_FILE);
|
|
34
|
+
if (!isFile(p)) return false;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(read(p)).doctrina_workspace !== undefined;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The workspace document, with every optional list present. */
|
|
43
|
+
export function readWorkspace(dir) {
|
|
44
|
+
const ws = JSON.parse(read(path.join(dir, WORKSPACE_FILE)));
|
|
45
|
+
ws.projects = Array.isArray(ws.projects) ? ws.projects : [];
|
|
46
|
+
ws.tools = Array.isArray(ws.tools) ? ws.tools : [];
|
|
47
|
+
ws.agents = ws.agents && typeof ws.agents === "object" ? ws.agents : {};
|
|
48
|
+
return ws;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function writeWorkspace(dir, ws) {
|
|
52
|
+
write(path.join(dir, WORKSPACE_FILE), JSON.stringify(ws, null, 2) + "\n", { force: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Where the CLI keeps its per-user state: `$DOCTRINA_HOME`, else `~/.doctrina`. */
|
|
56
|
+
export function doctrinaHome(env = process.env) {
|
|
57
|
+
return env.DOCTRINA_HOME && env.DOCTRINA_HOME.trim() !== ""
|
|
58
|
+
? path.resolve(env.DOCTRINA_HOME)
|
|
59
|
+
: path.join(os.homedir(), ".doctrina");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The pointer `workspace init` leaves, so an in-repo project can find its workspace. */
|
|
63
|
+
export function pointerPath(env = process.env) {
|
|
64
|
+
return path.join(doctrinaHome(env), "workspace");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function writePointer(dir, env = process.env) {
|
|
68
|
+
write(pointerPath(env), path.resolve(dir) + "\n", { force: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ancestors(start) {
|
|
72
|
+
const out = [];
|
|
73
|
+
let cur = path.resolve(start);
|
|
74
|
+
for (;;) {
|
|
75
|
+
out.push(cur);
|
|
76
|
+
const up = path.dirname(cur);
|
|
77
|
+
if (up === cur) return out;
|
|
78
|
+
cur = up;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The workspace this directory belongs to, or null. In order:
|
|
84
|
+
* 1. `$DOCTRINA_WORKSPACE`;
|
|
85
|
+
* 2. the directory or an ancestor holding `workspace.json` (the orchestrator
|
|
86
|
+
* runs at the workspace root);
|
|
87
|
+
* 3. the real path of `./.doctrina` — a shadow project's link points into
|
|
88
|
+
* its workspace;
|
|
89
|
+
* 4. the pointer `workspace init` wrote, when the workspace lists this
|
|
90
|
+
* directory as a project.
|
|
91
|
+
* @returns {{ dir: string, via: string } | null}
|
|
92
|
+
*/
|
|
93
|
+
export function findWorkspace(cwd, env = process.env) {
|
|
94
|
+
if (env.DOCTRINA_WORKSPACE && env.DOCTRINA_WORKSPACE.trim() !== "") {
|
|
95
|
+
const dir = path.resolve(env.DOCTRINA_WORKSPACE);
|
|
96
|
+
return isWorkspaceDir(dir) ? { dir, via: "DOCTRINA_WORKSPACE" } : null;
|
|
97
|
+
}
|
|
98
|
+
for (const dir of ancestors(cwd)) {
|
|
99
|
+
if (isWorkspaceDir(dir)) return { dir, via: "directory" };
|
|
100
|
+
}
|
|
101
|
+
const local = path.join(cwd, ".doctrina");
|
|
102
|
+
if (existsSync(local)) {
|
|
103
|
+
try {
|
|
104
|
+
const real = realpathSync(local);
|
|
105
|
+
if (real !== path.resolve(local)) {
|
|
106
|
+
for (const dir of ancestors(path.dirname(real))) {
|
|
107
|
+
if (isWorkspaceDir(dir)) return { dir, via: "shadow link" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch { /* an unreadable link is no workspace */ }
|
|
111
|
+
}
|
|
112
|
+
const pointer = pointerPath(env);
|
|
113
|
+
if (isFile(pointer)) {
|
|
114
|
+
const dir = read(pointer).trim();
|
|
115
|
+
if (dir && isWorkspaceDir(dir) && projectAt(readWorkspace(dir), cwd)) return { dir, via: "pointer" };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The registered project whose path is `dir`, or null. */
|
|
121
|
+
export function projectAt(ws, dir) {
|
|
122
|
+
const want = safeReal(dir);
|
|
123
|
+
return ws.projects.find((p) => safeReal(p.path) === want) ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function safeReal(p) {
|
|
127
|
+
try {
|
|
128
|
+
return realpathSync(p);
|
|
129
|
+
} catch {
|
|
130
|
+
return path.resolve(p);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function shadowDirOf(wsDir, name) {
|
|
135
|
+
return path.join(wsDir, "projects", name);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The directory whose `.doctrina/` holds this project's artifacts. */
|
|
139
|
+
export function artifactRootOf(wsDir, project) {
|
|
140
|
+
return project.mode === "shadow" ? shadowDirOf(wsDir, project.name) : project.path;
|
|
141
|
+
}
|
|
@@ -53,35 +53,82 @@ const WRITE_VERBS = new Set([
|
|
|
53
53
|
"transition", "assign", "comment", "publish", "upload", "deploy", "trigger", "set", "move",
|
|
54
54
|
"merge", "close", "archive", "insert", "drop", "alter", "truncate", "mutate", "rollback", "restart",
|
|
55
55
|
"approve", "reject", "resolve", "mute", "unmute", "schedule", "cancel", "import",
|
|
56
|
+
// The fourth audit (change 0235): verbs real MCP servers use that passed as reads.
|
|
57
|
+
"upsert", "link", "unlink", "reopen", "rename", "replace", "save", "submit", "sync", "release",
|
|
58
|
+
"rotate", "grant", "revoke", "reset", "clear", "purge", "destroy", "terminate", "kill", "pause",
|
|
59
|
+
"enable", "disable", "snooze", "escalate", "notify", "reply", "react", "vote", "fork", "push",
|
|
60
|
+
"commit", "lock", "unlock", "ack", "acknowledge", "install", "uninstall", "provision", "scale",
|
|
61
|
+
"rerun", "retry", "dispatch", "copy", "duplicate", "invite", "share", "subscribe", "unsubscribe",
|
|
62
|
+
"block", "unblock", "revert", "restore", "empty",
|
|
56
63
|
]);
|
|
57
|
-
|
|
64
|
+
// Statements that change the database, a procedure that may, and a SELECT
|
|
65
|
+
// that writes a table (`SELECT … INTO`). A keyword in a comment counts: the
|
|
66
|
+
// guard errs on the side of asking.
|
|
67
|
+
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;
|
|
68
|
+
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;
|
|
69
|
+
// An argument that names the operation a generic tool performs.
|
|
70
|
+
const ACTION_KEYS = new Set(["action", "operation", "op", "method", "verb", "mutation"]);
|
|
58
71
|
|
|
59
72
|
/**
|
|
60
73
|
* Whether a tool call would change state. A SQL statement in the input
|
|
61
74
|
* decides for itself; otherwise the tool's name does.
|
|
62
75
|
*/
|
|
63
76
|
export function isWriteCall(toolName, toolInput) {
|
|
64
|
-
const
|
|
77
|
+
const input = parsed(toolInput);
|
|
78
|
+
// An argument that says what a generic tool does: `{ action: "delete" }`,
|
|
79
|
+
// `{ method: "POST" }`, a GraphQL `mutation` (change 0235).
|
|
80
|
+
if (actionWrites(input)) return true;
|
|
81
|
+
const sql = sqlIn(input);
|
|
65
82
|
if (sql !== null) return MUTATING_SQL.test(sql.replace(/'[^']*'/g, "''"));
|
|
66
83
|
// camelCase and snake_case alike: `transitionJiraIssue`, `transition_issue`.
|
|
67
|
-
const words =
|
|
68
|
-
|
|
84
|
+
const words = nameWords(toolName);
|
|
85
|
+
if (words.some((w) => WRITE_VERBS.has(w))) return true;
|
|
86
|
+
// A name with no separator at all — `createissue`, `CREATEISSUE`,
|
|
87
|
+
// `getandremove` — hides its verbs inside one word (change 0235).
|
|
88
|
+
if (words.length === 1 && words[0].length > 6) {
|
|
89
|
+
return [...WRITE_VERBS].some((v) => v.length >= 5 && words[0].includes(v));
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
69
92
|
}
|
|
70
93
|
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
function nameWords(name) {
|
|
95
|
+
return String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z]+/).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parsed(input) {
|
|
99
|
+
if (typeof input !== "string") return input;
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(input);
|
|
102
|
+
} catch {
|
|
103
|
+
return input;
|
|
79
104
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Every string inside an input, to a bounded depth. */
|
|
108
|
+
function stringsIn(value, depth = 0, out = /** @type {Array<{ key: string, value: string }>} */ ([]), key = "") {
|
|
109
|
+
if (depth > 5 || out.length > 200) return out;
|
|
110
|
+
if (typeof value === "string") out.push({ key, value });
|
|
111
|
+
else if (Array.isArray(value)) for (const v of value) stringsIn(v, depth + 1, out, key);
|
|
112
|
+
else if (value && typeof value === "object") for (const [k, v] of Object.entries(value)) stringsIn(v, depth + 1, out, k);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function actionWrites(input) {
|
|
117
|
+
for (const { key, value } of stringsIn(input)) {
|
|
118
|
+
if (/^\s*mutation\b/i.test(value)) return true;
|
|
119
|
+
if (!ACTION_KEYS.has(key.toLowerCase())) continue;
|
|
120
|
+
const words = nameWords(value);
|
|
121
|
+
if (words.some((w) => WRITE_VERBS.has(w) || ["post", "put", "patch", "delete"].includes(w))) return true;
|
|
83
122
|
}
|
|
84
|
-
return
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// SQL wherever the input carries it — `sql`, `query`, `q`, a `statements`
|
|
127
|
+
// list, a nested `{ input: { sql } }` (change 0235): only four top-level
|
|
128
|
+
// keys were read, and a DELETE under any other passed as no SQL at all.
|
|
129
|
+
function sqlIn(input) {
|
|
130
|
+
const found = stringsIn(input).map((s) => s.value).filter((s) => SQL_START.test(s));
|
|
131
|
+
return found.length ? found.join(";\n") : null;
|
|
85
132
|
}
|
|
86
133
|
|
|
87
134
|
/**
|