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.
Files changed (57) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +92 -14
  5. package/src/commands/clarify.js +1 -204
  6. package/src/commands/context.js +33 -2
  7. package/src/commands/contract.js +1 -0
  8. package/src/commands/delivery.js +135 -17
  9. package/src/commands/doctor.js +21 -1
  10. package/src/commands/handoff.js +11 -2
  11. package/src/commands/init.js +13 -1
  12. package/src/commands/intake.js +16 -5
  13. package/src/commands/metrics.js +15 -0
  14. package/src/commands/next.js +41 -6
  15. package/src/commands/prime.js +53 -4
  16. package/src/commands/review.js +39 -3
  17. package/src/commands/skill.js +2 -1
  18. package/src/commands/status.js +28 -0
  19. package/src/commands/upgrade.js +18 -0
  20. package/src/commands/validate.js +18 -0
  21. package/src/commands/verify.js +32 -4
  22. package/src/commands/work.js +43 -8
  23. package/src/commands/workspace.js +73 -63
  24. package/src/index.js +98 -62
  25. package/src/lib/actions.js +43 -3
  26. package/src/lib/adapters.js +63 -1
  27. package/src/lib/args.js +33 -1
  28. package/src/lib/clarify-scan.js +221 -0
  29. package/src/lib/code-inventory.js +576 -103
  30. package/src/lib/commands.js +8 -0
  31. package/src/lib/context-slice.js +34 -3
  32. package/src/lib/delivery.js +88 -10
  33. package/src/lib/docs-impact.js +36 -1
  34. package/src/lib/fs-ops.js +23 -0
  35. package/src/lib/intake-model.js +1 -1
  36. package/src/lib/json-out.js +9 -1
  37. package/src/lib/shell.js +45 -0
  38. package/src/lib/snapshot.js +5 -1
  39. package/src/lib/task-card.js +100 -7
  40. package/src/lib/views.js +3 -3
  41. package/src/lib/work-model.js +8 -2
  42. package/src/lib/workspace-agents.js +65 -18
  43. package/src/lib/workspace-guard.js +65 -0
  44. package/src/lib/workspace-locate.js +141 -0
  45. package/src/lib/workspace-tools.js +63 -16
  46. package/src/lib/workspace-view.js +391 -0
  47. package/src/lib/workspace.js +82 -130
  48. package/templates/AGENTS.md.template +1 -1
  49. package/templates/README.md +2 -1
  50. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  51. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  52. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  53. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  54. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  55. package/templates/playbooks/absorb.md.template +39 -16
  56. package/templates/playbooks/bootstrap.md.template +18 -6
  57. 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, e.g.");
134
- L.push("`doctrina workspace tool jira` · `doctrina workspace tool datadog`.");
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. Fill the delivery's Release and Observability, then");
155
- L.push(" `doctrina delivery check <id>` → `doctrina delivery close <id>`.");
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.filter((p) => projectState(wsDir, p).state === "pending");
199
- if (pending.length) L.push(` absorb ${pending[0].name} before routing work to it: cd ${toPosix(pending[0].path)} && doctrina intake --from-code`);
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
- if (!isFile(file)) return JSON.stringify(ours, null, 2) + "\n";
230
- let theirs;
231
- try {
232
- theirs = JSON.parse(read(file));
233
- } catch {
234
- return null; // an unreadable file the operator owns is left alone
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
- return JSON.stringify(mergeJson(theirs, ours), null, 2) + "\n";
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
- put(".cursor/hooks.json", mergedJson(path.join(wsDir, ".cursor", "hooks.json"), CURSOR_HOOKS));
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
- const MUTATING_SQL = /\b(insert|update|delete|merge|drop|alter|create|truncate|grant|revoke|replace)\b/i;
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 sql = sqlIn(toolInput);
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 = String(toolName).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z]+/).filter(Boolean);
68
- return words.some((w) => WRITE_VERBS.has(w));
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 sqlIn(input) {
72
- let obj = input;
73
- if (typeof obj === "string") {
74
- try {
75
- obj = JSON.parse(obj);
76
- } catch {
77
- return /^\s*(select|with|insert|update|delete|merge|drop|alter|create|truncate|show|describe)\b/i.test(obj) ? obj : null;
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
- if (!obj || typeof obj !== "object") return null;
81
- for (const key of ["sql", "query", "statement", "sql_query"]) {
82
- if (typeof obj[key] === "string") return obj[key];
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 null;
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
  /**