@bli-cockpit/mcp 0.1.3 → 0.1.5

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 CHANGED
@@ -34,7 +34,7 @@ about what an agent can reach.
34
34
 
35
35
  <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
36
36
 
37
- **39 of 57 Tower verbs have an MCP twin.**
37
+ **56 of 57 Tower verbs have an MCP twin.**
38
38
  Each tool goes through the SAME door its CLI verb calls, with the same
39
39
  collector device token — never a second route and never a service-role
40
40
  reader. `src/verb-census.test.ts` fails when a verb is in none of the
@@ -44,7 +44,9 @@ three tables below.
44
44
  | --- | --- | --- |
45
45
  | `cockpit brief history` | `brief_history` | `GET /api/jarvis/brief?history=1` |
46
46
  | `cockpit brief read` | `brief_read` | `GET /api/jarvis/brief` |
47
+ | `cockpit brief rewrite` | `brief_rewrite` | `POST /api/jarvis/recompile` |
47
48
  | `cockpit brief status` | `brief_status` | `GET /api/ops/brief-status` |
49
+ | `cockpit correct` | `brief_correct` | `POST /api/jarvis/corrections` |
48
50
  | `cockpit docs create` | `docs_create` | `POST /api/docs/documents` |
49
51
  | `cockpit docs list` | `docs_list` | `GET /api/docs/documents` |
50
52
  | `cockpit docs read` | `docs_read` | `GET /api/docs/documents/[id]` |
@@ -59,6 +61,7 @@ three tables below.
59
61
  | `cockpit issue update` | `work_update_issue` | `PATCH /api/work/issues/[id]` |
60
62
  | `cockpit jarvis` | `jarvis_ask` | `POST /api/jarvis/cli` |
61
63
  | `cockpit jarvis --trace` | `jarvis_trace` | `GET /api/ops/trace/[id]` |
64
+ | `cockpit model set` | `model_set` | `POST /api/settings/jarvis-model` |
62
65
  | `cockpit model show` | `model_show` | `GET /api/settings/jarvis-model` |
63
66
  | `cockpit msg channels` | `msg_channels` | `GET /api/msg/channels` |
64
67
  | `cockpit msg create` | `msg_create_channel` | `POST /api/msg/channels` |
@@ -67,44 +70,40 @@ three tables below.
67
70
  | `cockpit msg send` | `msg_send` | `POST /api/msg/channels/[id]/messages` |
68
71
  | `cockpit msg thread` | `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` |
69
72
  | `cockpit notes list` | `notes_list` | `GET /api/notes/library` |
73
+ | `cockpit notes move` | `notes_move` | `POST /api/notes/move` |
74
+ | `cockpit notes paste` | `notes_paste` | `POST /api/notes/upload (text)` |
75
+ | `cockpit notes share` | `notes_share` | `POST /api/notes/share` |
70
76
  | `cockpit notes shelf` | `notes_shelf` | `GET /api/notes/shelf` |
71
77
  | `cockpit notes shelves` | `notes_shelves` | `GET /api/notes/library` |
72
78
  | `cockpit notes show` | `notes_show` | `GET /api/notes/library/[id]` |
79
+ | `cockpit notes unshare` | `notes_unshare` | `POST /api/notes/share (share=false)` |
80
+ | `cockpit notes upload` | `notes_upload` | `POST /api/notes/upload` |
81
+ | `cockpit ops recompile` | `ops_recompile` | `POST /api/ops/recompile` |
73
82
  | `cockpit ops status` | `ops_status` | `GET /api/ops/status` |
74
83
  | `cockpit project list` | `work_list_projects` | `GET /api/work/projects` |
75
84
  | `cockpit scout board` | `scout_board` | `GET /api/cockpit/scout` |
85
+ | `cockpit scout dismiss` | `scout_dismiss` | `POST /api/cockpit/scout (dismiss)` |
86
+ | `cockpit scout start` | `scout_start` | `POST /api/cockpit/scout (start)` |
87
+ | `cockpit scout undo` | `scout_undo` | `POST /api/cockpit/scout (undo_dismiss)` |
76
88
  | `cockpit search` | `tower_search` | `GET /api/search` |
89
+ | `cockpit settings delete` | `settings_delete` | `DELETE /api/settings/env-blobs` |
77
90
  | `cockpit settings list` | `settings_list` | `GET /api/settings/env-blobs` |
91
+ | `cockpit settings set` | `settings_set` | `POST /api/settings/{jarvis-model,switches,model-routing,env-blobs} + PUT /api/settings/cli-floor` |
78
92
  | `cockpit settings show` | `settings_show` | `GET /api/settings/* + /api/team/members` |
79
93
  | `cockpit slack coverage` | `slack_coverage` | `GET /api/ops/slack/coverage` |
80
94
  | `cockpit slack read` | `slack_read` | `POST /api/ops/slack/read` |
81
95
  | `cockpit team device-list` | `team_device_list` | `GET /api/team/devices` |
96
+ | `cockpit team device-revoke` | `team_device_revoke` | `POST /api/ambient/devices/[deviceId]/revoke` |
97
+ | `cockpit team invite` | `team_invite` | `POST /api/team/invite` |
82
98
  | `cockpit team members` | `team_members` | `GET /api/team/members` |
99
+ | `cockpit team role` | `team_role` | `PATCH /api/team/members/[userId]/role` |
83
100
  | `cockpit workbook` | `workbook_read` | `GET /api/cockpit/workbook` |
84
101
 
85
102
  ### Owed a twin
86
103
 
87
104
  This table should only ever shrink.
88
105
 
89
- | CLI verb | Who owes it, and why it is not built yet |
90
- | --- | --- |
91
- | `cockpit brief rewrite` | BLI-3756 batch 2 — spends a model call; wants its own gate on this surface |
92
- | `cockpit correct` | BLI-3756 batch 2 — filing a correction against a claim has no tool |
93
- | `cockpit model set` | BLI-3756 batch 2 — a shorthand over settings personal; follows settings |
94
- | `cockpit notes move` | BLI-3756 batch 2 — needs a signed-in session server-side; reachable, just unwritten |
95
- | `cockpit notes paste` | BLI-3756 batch 2 — the CLI takes the body on stdin; a tool would take it as an argument |
96
- | `cockpit notes share` | BLI-3756 batch 2 — a deliberate act; wants an explicit confirmation on this surface |
97
- | `cockpit notes unshare` | BLI-3756 batch 2 — same gate as notes share |
98
- | `cockpit notes upload` | BLI-3756 batch 2 — takes local file paths, which an MCP server on the same machine can also read |
99
- | `cockpit ops recompile` | BLI-3756 batch 2 — spends a model call; wants its own gate on this surface |
100
- | `cockpit scout dismiss` | BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide |
101
- | `cockpit scout start` | BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide |
102
- | `cockpit scout undo` | BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide |
103
- | `cockpit settings delete` | BLI-3756 batch 2 — destructive; wants an explicit confirmation on this surface |
104
- | `cockpit settings set` | BLI-3756 batch 2 — mostly reachable, but env CONTENT is stdin-only by rule; a tool must keep that |
105
- | `cockpit team device-revoke` | BLI-3756 batch 2 — destructive; wants an explicit confirmation on this surface |
106
- | `cockpit team invite` | BLI-3756 batch 2 — super_admin; a tool would relay, not decide |
107
- | `cockpit team role` | BLI-3756 batch 2 — super_admin; a tool would relay, not decide |
106
+ _Nothing. Every Tower verb an agent could want has a door (BLI-3756)._
108
107
 
109
108
  ### Terminal-only
110
109
 
@@ -128,6 +127,14 @@ JARVIS itself — the same assistant Tower web chat, the Slack DM and
128
127
  | `jarvis_dispatch` | `POST /api/jarvis/cli` | The coding arm (BLI-2981). Two calls: once for the plan, once with the person's approval code. |
129
128
  | `jarvis_check` | `POST /api/jarvis/cli` | Where a dispatched coding task got to. Spends no approval. |
130
129
 
130
+ **One conversation per call (BLI-3786).** A thread is single-writer: two turns
131
+ in the same conversation at the same time read each other's messages, and one
132
+ can answer the other's question. So every `jarvis_*` tool sends NO thread
133
+ unless the caller named one, the door mints a conversation for that call alone,
134
+ and the answer's `thread_id` is the name it used — pass that back as `thread`
135
+ to continue deliberately. `"main"` is the shared terminal default nobody chose
136
+ and is never sent from here.
137
+
131
138
  `jarvis_ask` answers with the SAME object `cockpit jarvis --json` prints —
132
139
  `answer`, `sources`, `turn_id`, `thread_id`, `trace_thread_id`, `degraded`,
133
140
  `degraded_reasons` — so a script and an agent read one contract. A worked
@@ -137,7 +144,7 @@ example:
137
144
  jarvis_ask { "question": "what did Brandon ship yesterday?" }
138
145
  -> { "answer": "Two pull requests went in…\n\nSource: BLI-3654 — …",
139
146
  "sources": ["Source: BLI-3654 — …"],
140
- "turn_id": "0f3c…", "thread_id": "main",
147
+ "turn_id": "0f3c…", "thread_id": "agent-6b1e…",
141
148
  "degraded": false, "degraded_reasons": [] }
142
149
 
143
150
  jarvis_trace { "turn_id": "0f3c…" } # or "last"
@@ -192,10 +199,52 @@ generated table above; what is worth knowing before you call one:
192
199
  standing sentences, and a truncated read saying so) and the per-project
193
200
  document library, rendered by the same walker the page uses.
194
201
 
195
- Writes `brief rewrite`, `correct`, `notes move|share|unshare|paste|upload`,
196
- `ops recompile`, `scout start|dismiss|undo`, `settings set|delete`,
197
- `team invite|role|device-revoke`, `model set` are batch 2, each with the
198
- reason it waited in the generated "Owed a twin" table.
202
+ ## The write tools (BLI-3756 batch 2)
203
+
204
+ The seventeen things a person can DO from `cockpit` and could not do from an
205
+ agent. Same doors, same device token, same refusal words — plus one rule the
206
+ reads never needed:
207
+
208
+ **An act that cannot be undone is refused without `confirm: true`.** The CLI's
209
+ gate is `--yes` or a person at a keyboard; a client has no keyboard, so the
210
+ explicit flag is the whole of it here, and the reason label is the CLI's own
211
+ `confirmation_required`. It guards `notes_share` (sharing lets everyone signed
212
+ in read the note), `settings_delete`, `team_role` and `team_device_revoke`.
213
+ Taking a share BACK asks for nothing — nobody needs to be talked out of
214
+ narrowing who can read something.
215
+
216
+ - **`brief_rewrite` / `brief_correct`** — ask for a page to be written again,
217
+ and say that one line on it is wrong. `brief_rewrite` QUEUES and returns the
218
+ request id immediately; it does not wait, because an 800-second compile
219
+ outlives any client deadline and "the tool timed out" would be
220
+ indistinguishable from "the compile failed". `brief_correct` refuses a claim
221
+ id the page does not carry BEFORE writing, and Tower's push-back ("the record
222
+ disagrees") is a normal result — the correction is filed either way.
223
+ - **`notes_upload` / `notes_paste` / `notes_share` / `notes_unshare` /
224
+ `notes_move`** — put a note in from this machine's disk or from text, share
225
+ it, take it back, move it to another shelf. A path whose NAME looks like a
226
+ key or credential file is refused without being opened; so is a missing,
227
+ empty or over-20-MB file. A large note is one model call server-side and can
228
+ take minutes — your client may give up first, and the upload keeps going.
229
+ - **`ops_recompile`** — write one person's page again. It spends a model call;
230
+ `dry_run: true` resolves the person and reads the current page without
231
+ compiling. If Tower's own 800-second budget runs out the effect is UNKNOWN,
232
+ not failed, and it says so rather than inviting you to pay twice.
233
+ - **`scout_start` / `scout_dismiss` / `scout_undo`** — move one card. Each
234
+ reads the board first, matches the id EXACTLY (no prefixes on this surface —
235
+ a near-miss would settle a card nobody decided on), and a 403 names the gate
236
+ without costing you the read.
237
+ - **`settings_set` / `settings_delete` / `model_set`** — write one settings
238
+ section, delete an env file, or just change which model answers you. Env
239
+ content is an argument here rather than stdin (a tool call has no argv, no
240
+ shell history and no process list), and the rule that survives is the one
241
+ that mattered: it is **never echoed back** — not in the answer, not in the
242
+ structured payload, not in a log.
243
+ - **`team_invite` / `team_role` / `team_device_revoke`** — invite somebody,
244
+ change a role, end a machine's pairing. All three relay a super_admin
245
+ decision made on the server. `team_device_revoke` looks the device up and
246
+ names it back to you first; without `confirm: true` that naming IS the
247
+ answer and nothing is revoked.
199
248
 
200
249
  ## `docs_*` / `msg_*` tools (BLI-3706)
201
250
 
@@ -205,7 +254,7 @@ reason it waited in the generated "Owed a twin" table.
205
254
  | `docs_tree` | `GET /api/docs/tree` | The library as its nesting rather than a flat list. Never a body. |
206
255
  | `docs_read` | `GET /api/docs/documents/[id]` | One document's title and full body, by id or slug (resolved against `docs_list` first). |
207
256
  | `docs_create` | `POST /api/docs/documents` | Creates a document. Omitting `body_markdown` creates an empty one. |
208
- | `docs_update` | `PATCH /api/docs/documents/[id]` | Updates title/body/visibility/parent (a parent change IS a move). |
257
+ | `docs_update` | `PATCH /api/docs/documents/[id]` | Updates title/body/visibility/parent (a parent change IS a move). Emptying a document that holds text needs `allow_empty: true` (BLI-3757's guard). |
209
258
  | `msg_channels` | `GET /api/msg/channels` | Every channel you are a member of. |
210
259
  | `msg_read` | `GET /api/msg/channels/[id]/messages` | Recent messages of one channel, newest first. `channel` is an id or a `#name`. |
211
260
  | `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` | One thread's replies, oldest first, so it reads as a conversation. |
@@ -12,4 +12,11 @@ export interface AgentDoorResponse {
12
12
  /** Set only when no JSON answer was produced at all — DNS, TLS, timeout, non-JSON body. */
13
13
  transportError: string | null;
14
14
  }
15
- export declare function callAgentDoor(session: AgentDoorSession, fetchImpl: FetchImpl, method: "GET" | "POST" | "PATCH", path: string, body?: unknown, timeoutMs?: number): Promise<AgentDoorResponse>;
15
+ /**
16
+ * The four verbs Tower's agent doors answer to. `PUT` is the CLI floor
17
+ * (`PUT /api/settings/cli-floor`) and `DELETE` is an env blob — both arrived
18
+ * with BLI-3756 batch 2, and both are the door's own choice of verb, not this
19
+ * package's.
20
+ */
21
+ export type AgentDoorMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
22
+ export declare function callAgentDoor(session: AgentDoorSession, fetchImpl: FetchImpl, method: AgentDoorMethod, path: string, body?: unknown, timeoutMs?: number): Promise<AgentDoorResponse>;
@@ -9,16 +9,21 @@ export async function callAgentDoor(session, fetchImpl, method, path, body, time
9
9
  const controller = new AbortController();
10
10
  const timer = setTimeout(() => controller.abort(), timeoutMs);
11
11
  timer.unref?.();
12
+ // A note goes in as multipart, exactly as `cockpit notes upload` sends it,
13
+ // so `content-type` must be left to fetch: it is the only thing that knows
14
+ // the boundary it just generated, and a hand-set JSON header would make the
15
+ // route read an empty form.
16
+ const multipart = typeof FormData !== "undefined" && body instanceof FormData;
12
17
  let response;
13
18
  try {
14
19
  response = await fetchImpl(url, {
15
20
  method,
16
21
  headers: {
17
22
  authorization: `Bearer ${session.deviceToken}`,
18
- "content-type": "application/json",
23
+ ...(multipart ? {} : { "content-type": "application/json" }),
19
24
  accept: "application/json",
20
25
  },
21
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
26
+ ...(body === undefined ? {} : { body: multipart ? body : JSON.stringify(body) }),
22
27
  signal: controller.signal,
23
28
  });
24
29
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `brief_rewrite` / `brief_correct` MCP tools (BLI-3756 batch 2) — the two
3
+ * things a person can DO to the daily page, over the same doors
4
+ * `cockpit brief rewrite` and `cockpit correct` call with the same device
5
+ * token.
6
+ *
7
+ * Reads live in `brief-tools.ts`; these two write, and each carries one
8
+ * judgement the read side never has to make.
9
+ *
10
+ * **`brief_rewrite` asks and does not wait.** The CLI queues the compile and
11
+ * then watches the page for up to fifteen minutes, because a terminal can sit
12
+ * there. A tool call cannot: the client's own deadline would cut the call
13
+ * long before an 800-second compile finished, and "the tool timed out" is
14
+ * indistinguishable from "the compile failed" — the exact confusion this repo
15
+ * keeps paying for. So this tool sends `mode: "queue"`, reports the request id
16
+ * the moment it exists, and says in its own answer that the page is not
17
+ * written yet and `brief_read` is how to find out. That is the CLI's
18
+ * `--no-wait` shape, chosen deliberately and named out loud rather than
19
+ * silently.
20
+ *
21
+ * **`brief_correct` treats push-back as the answer, not an error.** The
22
+ * corrections door checks the claim against the live record BEFORE writing and
23
+ * can answer that the record disagrees. That sentence is what the caller came
24
+ * for: the correction WAS filed, with its outcome recorded, so it comes back
25
+ * as a normal result. Only a failure to file at all is an error.
26
+ *
27
+ * A claim id the page does not carry is refused HERE, before any write, in the
28
+ * CLI's own words — filing it anyway would land a correction on a clause
29
+ * nobody can find, which is how a ledger fills with rows that mean nothing.
30
+ */
31
+ import { type ToolDeps } from "./tool-result.js";
32
+ export type BriefWriteDeps = ToolDeps;
33
+ /** The same ceiling `cockpit correct` enforces before it sends anything. */
34
+ export declare const MAX_CORRECTION_LENGTH = 4000;
35
+ export declare function registerBriefWriteTools(server: {
36
+ registerTool: (...args: never[]) => unknown;
37
+ }, deps: BriefWriteDeps): void;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `brief_rewrite` / `brief_correct` MCP tools (BLI-3756 batch 2) — the two
3
+ * things a person can DO to the daily page, over the same doors
4
+ * `cockpit brief rewrite` and `cockpit correct` call with the same device
5
+ * token.
6
+ *
7
+ * Reads live in `brief-tools.ts`; these two write, and each carries one
8
+ * judgement the read side never has to make.
9
+ *
10
+ * **`brief_rewrite` asks and does not wait.** The CLI queues the compile and
11
+ * then watches the page for up to fifteen minutes, because a terminal can sit
12
+ * there. A tool call cannot: the client's own deadline would cut the call
13
+ * long before an 800-second compile finished, and "the tool timed out" is
14
+ * indistinguishable from "the compile failed" — the exact confusion this repo
15
+ * keeps paying for. So this tool sends `mode: "queue"`, reports the request id
16
+ * the moment it exists, and says in its own answer that the page is not
17
+ * written yet and `brief_read` is how to find out. That is the CLI's
18
+ * `--no-wait` shape, chosen deliberately and named out loud rather than
19
+ * silently.
20
+ *
21
+ * **`brief_correct` treats push-back as the answer, not an error.** The
22
+ * corrections door checks the claim against the live record BEFORE writing and
23
+ * can answer that the record disagrees. That sentence is what the caller came
24
+ * for: the correction WAS filed, with its outcome recorded, so it comes back
25
+ * as a normal result. Only a failure to file at all is an error.
26
+ *
27
+ * A claim id the page does not carry is refused HERE, before any write, in the
28
+ * CLI's own words — filing it anyway would land a correction on a clause
29
+ * nobody can find, which is how a ledger fills with rows that mean nothing.
30
+ */
31
+ import { z } from "zod";
32
+ import { callAgentDoor } from "./agent-door.js";
33
+ import { doorFailureText, errorResult, registrarFor, textResult, withSession, } from "./tool-result.js";
34
+ /** The same ceiling `cockpit correct` enforces before it sends anything. */
35
+ export const MAX_CORRECTION_LENGTH = 4000;
36
+ /** A rewrite queues in seconds; the compile it queues takes minutes. */
37
+ const QUEUE_DEADLINE_MS = 60_000;
38
+ const SUBJECT = z
39
+ .string()
40
+ .min(1)
41
+ .max(200)
42
+ .optional()
43
+ .describe("Whose page — a name, email or person id. Omit for your own. Changes the SUBJECT, never who is asking.");
44
+ /**
45
+ * Which page, and whose. The same read both verbs open with, and the only way
46
+ * either learns the person id the write doors need.
47
+ */
48
+ async function readPage(deps, session, params) {
49
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/jarvis/brief?${params.toString()}`, undefined, QUEUE_DEADLINE_MS);
50
+ if (!response.ok)
51
+ return { ok: false, text: doorFailureText("brief", response) };
52
+ const body = response.body;
53
+ if (!body.ok || !body.page?.personId) {
54
+ return {
55
+ ok: false,
56
+ text: body.reply ?? "There is no page here to work on yet. The first one is compiled by the daily job.",
57
+ };
58
+ }
59
+ return { ok: true, body };
60
+ }
61
+ export function registerBriefWriteTools(server, deps) {
62
+ const register = registrarFor(server);
63
+ register("brief_rewrite", {
64
+ title: "Ask for a Tower daily page to be written again",
65
+ description: "Queues a recompile of the TODAY page — the website's button and `cockpit brief rewrite`, same door. It spends "
66
+ + "a model call and takes several minutes, so this tool ASKS and returns the request id immediately; it does "
67
+ + "not wait. Read the result later with brief_read. An admin may ask about anybody; everyone else about their "
68
+ + "own page, decided on the server.",
69
+ inputSchema: { subject: SUBJECT },
70
+ }, async (args) => withSession(deps, async (session) => {
71
+ const params = new URLSearchParams({ tldr: "1" });
72
+ if (args.subject)
73
+ params.set("p", String(args.subject));
74
+ const page = await readPage(deps, session, params);
75
+ if (!page.ok)
76
+ return errorResult(page.text);
77
+ const personId = page.body.page?.personId;
78
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/jarvis/recompile", { personIds: [personId], mode: "queue" }, QUEUE_DEADLINE_MS);
79
+ if (!response.ok)
80
+ return errorResult(doorFailureText("brief_rewrite", response));
81
+ const answer = response.body;
82
+ const request = (answer.queued ?? [])[0];
83
+ if (!answer.ok || !request?.requestId) {
84
+ return errorResult(answer.reply ?? "Tower did not accept that ask.");
85
+ }
86
+ const whose = request.name ?? page.body.page?.displayName ?? "that page";
87
+ const scheduled = answer.compileScheduled !== false;
88
+ // Queue-only, said as queue-only. The ask is a real row; what is
89
+ // missing is somebody to run it in the next minute, and pretending
90
+ // otherwise would leave a caller watching a page that was never going
91
+ // to change.
92
+ const sentence = scheduled
93
+ ? `Asked. ${whose} is being written again — this takes a few minutes. Call brief_read then; nothing here waits for it.`
94
+ : `${whose} is on the list to be written again, but this Tower could not start the work now `
95
+ + `(${answer.compileReason ?? "no reason given"}). The daily compile will reach it. Nothing is lost.`;
96
+ return textResult(sentence, {
97
+ ok: true,
98
+ outcome: scheduled ? "queued" : "queued_only",
99
+ requestId: request.requestId,
100
+ personId,
101
+ compileScheduled: scheduled,
102
+ compileReason: answer.compileReason ?? null,
103
+ });
104
+ }));
105
+ register("brief_correct", {
106
+ title: "Say that one line on a Tower page is wrong",
107
+ description: "Files a correction against ONE claim on the daily page — `cockpit correct`, same door and same ledger. Get "
108
+ + "claim ids from brief_read with `claims: true`. Tower checks the claim against the live record before "
109
+ + "writing and may answer that the record disagrees; that push-back is the RESULT, not a failure — the "
110
+ + "correction is filed either way, with its outcome recorded.",
111
+ inputSchema: {
112
+ claim_id: z.string().min(1).max(200).describe("A claim id from brief_read({claims: true})."),
113
+ text: z
114
+ .string()
115
+ .min(1)
116
+ .max(MAX_CORRECTION_LENGTH)
117
+ .describe("What is wrong, and what is true instead."),
118
+ subject: SUBJECT,
119
+ version: z.string().min(1).max(100).optional().describe("Correct one exact stored page id instead of the newest."),
120
+ supersedes: z
121
+ .string()
122
+ .min(1)
123
+ .max(200)
124
+ .optional()
125
+ .describe("A correction on the same clause that this one takes back."),
126
+ },
127
+ }, async (args) => withSession(deps, async (session) => {
128
+ const claimId = String(args.claim_id ?? "");
129
+ const params = new URLSearchParams({ claims: "1" });
130
+ if (args.subject)
131
+ params.set("p", String(args.subject));
132
+ if (args.version)
133
+ params.set("v", String(args.version));
134
+ const page = await readPage(deps, session, params);
135
+ if (!page.ok)
136
+ return errorResult(page.text);
137
+ const claim = (page.body.claims ?? []).find((candidate) => candidate.claimId === claimId);
138
+ if (!claim) {
139
+ return errorResult(`Refused (unknown_claim): that page has no line called “${claimId}”. `
140
+ + "Call brief_read with `claims: true` to see the ids. Nothing was filed.");
141
+ }
142
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/jarvis/corrections", {
143
+ personId: page.body.page?.personId,
144
+ pageId: page.body.page?.pageId ?? null,
145
+ claimId,
146
+ // The line as it reads now, so the ledger records what was
147
+ // disputed and the live check has the sentence to work from.
148
+ quotedText: claim.text ?? null,
149
+ correctionText: String(args.text ?? ""),
150
+ contextLinks: claim.links ?? [],
151
+ clauseOnPage: true,
152
+ clauseIsObserved: Boolean(claim.observed),
153
+ ...(args.supersedes ? { supersedes: String(args.supersedes) } : {}),
154
+ }, QUEUE_DEADLINE_MS);
155
+ if (!response.ok)
156
+ return errorResult(doorFailureText("brief_correct", response));
157
+ const filed = response.body;
158
+ if (!filed.correctionId) {
159
+ return errorResult(filed.error ?? "Tower did not record that correction.");
160
+ }
161
+ const said = [
162
+ filed.reply ?? "Filed.",
163
+ filed.finding ? `The record says: ${filed.finding}` : "",
164
+ filed.link ?? "",
165
+ ]
166
+ .filter(Boolean)
167
+ .join("\n");
168
+ return textResult(said, {
169
+ ok: true,
170
+ correctionId: filed.correctionId,
171
+ tier: filed.tier ?? null,
172
+ outcome: filed.outcome ?? null,
173
+ pushBack: filed.pushBack ?? null,
174
+ finding: filed.finding ?? null,
175
+ link: filed.link ?? null,
176
+ });
177
+ }));
178
+ }
@@ -131,13 +131,20 @@ export function registerDocsMsgTools(server, deps) {
131
131
  }));
132
132
  register("docs_update", {
133
133
  title: "Update a Tower document",
134
- description: "Updates a document's title, body, visibility, or parent (a parent change IS a move). Only the fields you pass change.",
134
+ description: "Updates a document's title, body, visibility, or parent (a parent change IS a move). Only the fields you "
135
+ + "pass change. Emptying a document that holds text needs allow_empty: true.",
135
136
  inputSchema: {
136
137
  id: z.string().min(1).max(200).describe("A document id, or its slug."),
137
138
  title: z.string().min(1).max(200).optional(),
138
139
  body_markdown: z.string().max(200_000).optional(),
139
140
  visibility: z.enum(["org", "private"]).optional(),
140
141
  parent_id: z.string().uuid().nullable().optional(),
142
+ allow_empty: z
143
+ .boolean()
144
+ .optional()
145
+ .describe("Only when you mean to CLEAR the page. A body_markdown that would empty a document that holds "
146
+ + "text is refused with refused_empty_body unless this is true (BLI-3757 — a blank save is far more "
147
+ + "often a surface that lost the content than a person who meant it)."),
141
148
  },
142
149
  }, async (args) => withSession(deps, async (session) => {
143
150
  const ref = String(args.id ?? "");
@@ -151,6 +158,9 @@ export function registerDocsMsgTools(server, deps) {
151
158
  ...(args.body_markdown !== undefined ? { body_markdown: args.body_markdown } : {}),
152
159
  ...(args.visibility !== undefined ? { visibility: args.visibility } : {}),
153
160
  ...(args.parent_id !== undefined ? { parent_id: args.parent_id } : {}),
161
+ // BLI-3759: sent only when the caller asked for it; the door's own
162
+ // refusal comes back verbatim otherwise, never retried with the flag.
163
+ ...(args.allow_empty ? { allow_empty: true } : {}),
154
164
  });
155
165
  if (!response.ok)
156
166
  return errorResult(doorFailureText("docs_update", response));
@@ -34,7 +34,7 @@
34
34
  * Every key of the envelope, in the order it is written. The literal list IS
35
35
  * the contract: both copies assert against it, so a drift is a red suite.
36
36
  */
37
- export declare const JARVIS_ANSWER_ENVELOPE_KEYS: readonly ["ok", "answer", "sources", "turn_id", "thread_id", "trace_thread_id", "degraded", "degraded_reasons"];
37
+ export declare const JARVIS_ANSWER_ENVELOPE_KEYS: readonly ["ok", "answer", "sources", "turn_id", "thread_id", "trace_thread_id", "proposal_id", "degraded", "degraded_reasons"];
38
38
  /**
39
39
  * The closed set of ways an answer can be second-rate without being a failure.
40
40
  * A degraded answer is still an answer — it is printed, returned and counted —
@@ -77,6 +77,16 @@ export interface JarvisAnswerEnvelope {
77
77
  * `thread_id` above and deliberately named apart from it.
78
78
  */
79
79
  trace_thread_id: string | null;
80
+ /**
81
+ * BLI-3755: the coding-arm proposal this turn made, when it made one.
82
+ *
83
+ * It is what an AGENT surface is given INSTEAD of the eight-hex approval
84
+ * code, which is never sent here: a code a program can read is a code it
85
+ * could approve with, and that is not approval. The person fetches the code
86
+ * on a surface a person reads and hands it back. Null on every turn that
87
+ * proposed no coding task, which is nearly all of them.
88
+ */
89
+ proposal_id: string | null;
80
90
  degraded: boolean;
81
91
  degraded_reasons: JarvisDegradedReason[];
82
92
  }
@@ -103,5 +113,7 @@ export interface JarvisAnswerEnvelopeInput {
103
113
  trace?: ReadonlyArray<{
104
114
  status?: string;
105
115
  }> | null;
116
+ /** `body.proposalId` — BLI-3755's coding-arm proposal id, when the turn made one. */
117
+ proposalId?: string | null;
106
118
  }
107
119
  export declare function buildJarvisAnswerEnvelope(input: JarvisAnswerEnvelopeInput): JarvisAnswerEnvelope;
@@ -41,6 +41,7 @@ export const JARVIS_ANSWER_ENVELOPE_KEYS = [
41
41
  "turn_id",
42
42
  "thread_id",
43
43
  "trace_thread_id",
44
+ "proposal_id",
44
45
  "degraded",
45
46
  "degraded_reasons",
46
47
  ];
@@ -76,6 +77,7 @@ export function buildJarvisAnswerEnvelope(input) {
76
77
  turn_id: turnId,
77
78
  thread_id: input.thread ?? null,
78
79
  trace_thread_id: input.traceThread ?? null,
80
+ proposal_id: input.proposalId ?? null,
79
81
  degraded: reasons.length > 0,
80
82
  degraded_reasons: reasons,
81
83
  };
@@ -72,7 +72,14 @@ export async function takeTurn(deps, session, door, body, remember) {
72
72
  // `accept: application/json` (which `callAgentDoor` always sends) is what
73
73
  // makes the dashboard answer one JSON body instead of the NDJSON stream the
74
74
  // terminal takes — an MCP tool has nobody to show live trace lines to.
75
- const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/jarvis/cli", body, TURN_TIMEOUT_MS);
75
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/jarvis/cli",
76
+ // BLI-3755: every turn this server takes says who is reading the answer.
77
+ // An MCP tool result is read by a MODEL, so the coding arm withholds its
78
+ // approval code and answers with a `proposal_id` instead — the one thing
79
+ // this server could not prove about a code it relayed was that a person
80
+ // had ever seen it. Sent on every tool, not just the dispatch, because the
81
+ // arm is on the belt of every turn.
82
+ { ...body, surface: "agent" }, TURN_TIMEOUT_MS);
76
83
  if (!response.ok) {
77
84
  log(`${TAG} turn failed ${JSON.stringify({
78
85
  door,
@@ -102,6 +109,7 @@ export async function takeTurn(deps, session, door, body, remember) {
102
109
  modelFallback: reply.model?.fallback,
103
110
  revised: reply.revised,
104
111
  trace: reply.trace,
112
+ proposalId: reply.proposalId,
105
113
  });
106
114
  remember({ turnId: envelope.turn_id, traceThreadId: envelope.trace_thread_id });
107
115
  log(`${TAG} answered ${JSON.stringify({
@@ -109,6 +117,9 @@ export async function takeTurn(deps, session, door, body, remember) {
109
117
  reply_chars: envelope.answer.length,
110
118
  sources: envelope.sources.length,
111
119
  trace_steps: reply.trace?.length ?? 0,
120
+ // BLI-3755: whether this turn left a coding-arm proposal behind. The id
121
+ // itself is in the envelope; this line says one was made.
122
+ proposal_recorded: envelope.proposal_id !== null,
112
123
  degraded: envelope.degraded,
113
124
  degraded_reasons: envelope.degraded_reasons,
114
125
  has_turn_id: envelope.turn_id !== null,
@@ -117,6 +128,10 @@ export async function takeTurn(deps, session, door, body, remember) {
117
128
  const footer = [
118
129
  envelope.turn_id ? `turn_id: ${envelope.turn_id} (open it with jarvis_trace)` : null,
119
130
  envelope.thread_id ? `thread_id: ${envelope.thread_id}` : null,
131
+ envelope.proposal_id
132
+ ? `proposal_id: ${envelope.proposal_id} — the person fetches the approval code for it themself, `
133
+ + `in a Tower tab, their Slack DM, or their own \`cockpit jarvis\` terminal. You were not sent one.`
134
+ : null,
120
135
  envelope.degraded ? `degraded: ${envelope.degraded_reasons.join(", ")}` : null,
121
136
  ]
122
137
  .filter((line) => line !== null)
@@ -24,6 +24,15 @@
24
24
  * `jarvis_ask` answers with `jarvis-answer-envelope.ts` — the same object
25
25
  * `cockpit jarvis --json` prints. A script and an agent read one shape.
26
26
  *
27
+ * ## One conversation per call (BLI-3786)
28
+ *
29
+ * A thread is single-writer: two turns in one conversation at the same time
30
+ * read each other's messages, and one can answer the other's question. Every
31
+ * tool here therefore sends NO thread unless the caller named one, and the
32
+ * door mints a conversation for that call alone and reports it as `thread_id`.
33
+ * Passing that id back is how an agent continues deliberately. `"main"` is the
34
+ * shared terminal default nobody chose and is never sent from here.
35
+ *
27
36
  * ## The approval code, and what this surface can and cannot prove
28
37
  *
29
38
  * The coding arm's gate (BLI-2981) has two locks: the code is an HMAC over the
@@ -32,16 +41,24 @@
32
41
  * and cannot compute a code for any plan, so an invented code is refused by
33
42
  * the dashboard exactly as it always was.
34
43
  *
35
- * Lock 2 is weaker on THIS surface than it is in a terminal, and saying so is
36
- * the honest thing to do. In a terminal the person types the code themselves.
37
- * Through MCP the code arrives as a tool argument, and the dashboard cannot
38
- * tell a code a person handed their agent from one the agent lifted out of the
39
- * previous answer by itself. So: this tool takes the code as a parameter, it
40
- * NEVER derives, guesses or fabricates one, its description tells the model in
41
- * plain words that the code must come from the person, and every relay is
42
- * logged (presence and length only, never the code). Anything stronger a
43
- * per-code single use, an out-of-band confirmation is a server-side change
44
- * to the gate itself and belongs with the gate, not here.
44
+ * Lock 2 is weaker on THIS surface than it is in a terminal: in a terminal the
45
+ * person types the code themselves, while through MCP it arrives as a tool
46
+ * argument and nothing on the wire distinguishes a code a person handed their
47
+ * agent from one the agent lifted out of the previous answer. BLI-3755 closed
48
+ * the two halves of that gap where the gate lives, not here:
49
+ *
50
+ * * `jarvis_ask` and `jarvis_dispatch` tell the dashboard they are an AGENT
51
+ * surface (`jarvis-door.ts` sends it on every turn), so a proposal comes
52
+ * back with a `proposal_id` and NO code. There is nothing in the previous
53
+ * answer to lift. The person fetches the code where a person reads — a
54
+ * Tower tab, their Slack DM, their own `cockpit jarvis` — and hands it on.
55
+ * * A code is spendable ONCE. A replay is refused with `approval_code_spent`,
56
+ * so a code that did reach an agent buys at most the one run the person
57
+ * approved.
58
+ *
59
+ * What is unchanged here: this server holds no secret and cannot compute a code
60
+ * for any plan, it never derives, guesses or fabricates one, and every relay is
61
+ * logged (presence and length only, never the code).
45
62
  */
46
63
  import { type JarvisDeps } from "./jarvis-door.js";
47
64
  export type { JarvisDeps } from "./jarvis-door.js";