toolroll 0.9.42 → 0.9.44

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 (68) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/action-ledger.d.ts +5 -2
  3. package/dist/action-ledger.js +13 -3
  4. package/dist/chat-actions.js +1 -1
  5. package/dist/chat-approval.d.ts +45 -0
  6. package/dist/chat-approval.js +81 -0
  7. package/dist/chat-channel.js +5 -0
  8. package/dist/chat-decide.d.ts +18 -6
  9. package/dist/chat-decide.js +54 -20
  10. package/dist/chat-flow.js +2 -2
  11. package/dist/cli-http.d.ts +64 -0
  12. package/dist/cli-http.js +143 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +8 -1
  15. package/dist/contracts/cli.d.ts +13 -0
  16. package/dist/contracts/cli.js +7 -0
  17. package/dist/contracts/console-api.d.ts +1 -0
  18. package/dist/contracts/console-api.js +1 -0
  19. package/dist/contracts/gateway-tools.d.ts +82 -0
  20. package/dist/contracts/gateway-tools.js +33 -0
  21. package/dist/contracts/lead-tools.d.ts +11 -0
  22. package/dist/contracts/lead-tools.js +2 -0
  23. package/dist/desktop-update.d.ts +3 -1
  24. package/dist/desktop-update.js +7 -6
  25. package/dist/flow-engine.d.ts +2 -0
  26. package/dist/flow-engine.js +10 -2
  27. package/dist/flows-cli.d.ts +28 -0
  28. package/dist/flows-cli.js +43 -1
  29. package/dist/guides.js +2 -0
  30. package/dist/mate-contract.js +1 -1
  31. package/dist/mate-tools.js +43 -1
  32. package/dist/mcp-core.d.ts +110 -0
  33. package/dist/mcp-core.js +311 -0
  34. package/dist/mcp-http.d.ts +26 -0
  35. package/dist/mcp-http.js +151 -0
  36. package/dist/mcp-person.d.ts +31 -0
  37. package/dist/mcp-person.js +96 -0
  38. package/dist/mcp.d.ts +1 -8
  39. package/dist/mcp.js +21 -322
  40. package/dist/operate-remote-arguments.d.ts +36 -0
  41. package/dist/operate-remote-arguments.js +185 -0
  42. package/dist/operate-remote.d.ts +86 -0
  43. package/dist/operate-remote.js +125 -0
  44. package/dist/operate.d.ts +21 -1
  45. package/dist/operate.js +229 -15
  46. package/dist/process-recovery-eligibility.d.ts +1 -1
  47. package/dist/remote-command.d.ts +45 -0
  48. package/dist/remote-command.js +124 -0
  49. package/dist/remote-exec.d.ts +18 -0
  50. package/dist/remote-exec.js +241 -0
  51. package/dist/remote-run.d.ts +16 -0
  52. package/dist/remote-run.js +13 -0
  53. package/dist/scope.d.ts +4 -2
  54. package/dist/scope.js +10 -0
  55. package/dist/serve.d.ts +7 -0
  56. package/dist/serve.js +75 -2
  57. package/dist/store.d.ts +23 -2
  58. package/dist/store.js +76 -9
  59. package/dist/surface.d.ts +10 -3
  60. package/dist/surface.js +71 -3
  61. package/dist/team-cli.d.ts +19 -0
  62. package/dist/team-cli.js +20 -3
  63. package/dist/teammate-work.js +1 -1
  64. package/dist/telegram-flow.d.ts +41 -1
  65. package/dist/telegram-flow.js +96 -12
  66. package/dist/telegram.js +27 -3
  67. package/dist/toolroll-update.js +4 -1
  68. package/package.json +1 -1
@@ -0,0 +1,143 @@
1
+ import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
2
+ /** Inlined file contents, summed as UTF-8 bytes. */
3
+ export const CLI_FILES_BYTES = 256 * 1024;
4
+ /** The whole JSON body: the files plus argv and JSON escaping headroom. */
5
+ export const CLI_REQUEST_BYTES = 2 * CLI_FILES_BYTES;
6
+ const ARGV_MAX = 256;
7
+ const ARG_BYTES = 16 * 1024;
8
+ const FILES_MAX = 16;
9
+ function send(response, status, value) {
10
+ if (response.destroyed || response.writableEnded)
11
+ return;
12
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
13
+ response.end(JSON.stringify(value));
14
+ }
15
+ async function body(request) {
16
+ const chunks = [];
17
+ let size = 0;
18
+ for await (const chunk of request) {
19
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
20
+ size += bytes.length;
21
+ if (size > CLI_REQUEST_BYTES)
22
+ throw new Error('too-large');
23
+ chunks.push(bytes);
24
+ }
25
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
26
+ }
27
+ /** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
28
+ export function cliRequest(value) {
29
+ if (!value || typeof value !== 'object' || Array.isArray(value))
30
+ return { problem: 'Send {argv, files}.' };
31
+ const row = value;
32
+ if (!Object.keys(row).every(key => key === 'argv' || key === 'files'))
33
+ return { problem: 'Send only argv and files.' };
34
+ const argv = row.argv;
35
+ if (!Array.isArray(argv) || argv.length === 0 || argv.length > ARGV_MAX || !argv.every(one => typeof one === 'string' && Buffer.byteLength(one) <= ARG_BYTES && !one.includes('\0')))
36
+ return { problem: 'argv must be 1–256 strings.' };
37
+ const files = row.files ?? {};
38
+ if (!files || typeof files !== 'object' || Array.isArray(files))
39
+ return { problem: 'files must map an argument to its contents.' };
40
+ const entries = Object.entries(files);
41
+ if (entries.length > FILES_MAX)
42
+ return { problem: 'Too many files.' };
43
+ let size = 0;
44
+ const kept = Object.create(null);
45
+ for (const [key, content] of entries) {
46
+ if (typeof content !== 'string' || key === '' || key.includes('\0'))
47
+ return { problem: 'files must map file paths to text contents.' };
48
+ size += Buffer.byteLength(content);
49
+ if (size > CLI_FILES_BYTES)
50
+ return { problem: 'Files are limited to 256 KiB in total.', tooLarge: true };
51
+ kept[key] = content;
52
+ }
53
+ return { argv: argv, files: kept };
54
+ }
55
+ /** The longest one request may hold the server: the client polls again for longer waits. */
56
+ export const CLI_WAIT_SECONDS = 25;
57
+ function longPollBounded(argv) {
58
+ const words = argv.filter(one => !one.startsWith('-'));
59
+ if (words[0] !== 'task' || words[1] !== 'wait')
60
+ return true;
61
+ const at = argv.indexOf('--timeout'), given = at < 0 ? NaN : Number(argv[at + 1]);
62
+ return argv.filter(one => one === '--timeout').length === 1 && Number.isFinite(given) && given >= 0 && given <= CLI_WAIT_SECONDS;
63
+ }
64
+ /** Handles exactly `/api/cli`; false for any other path. */
65
+ export async function handleCliHttp(request, response, options) {
66
+ const url = new URL(request.url ?? '/', 'http://standing-orders.local');
67
+ if (url.pathname !== '/api/cli')
68
+ return false;
69
+ const reject = (status, code, message) => { send(response, status, { ok: false, code, message }); request.resume(); return true; };
70
+ if (url.search !== '' || url.username !== '' || url.password !== '')
71
+ return reject(400, 'credentials-in-url', 'Send the command in the request body; nothing travels in the URL.');
72
+ if (request.method !== 'POST')
73
+ return reject(405, 'method-not-allowed', 'Use POST.');
74
+ // A browser always names its page. The remote CLI never does, so a page can never drive this endpoint.
75
+ if (request.headers.origin !== undefined)
76
+ return reject(403, 'browser-origin', 'Remote commands come from the CLI, not a browser.');
77
+ const names = request.rawHeaders.filter((_value, index) => index % 2 === 0).map(name => name.toLowerCase());
78
+ const count = names.filter(name => name === 'authorization').length;
79
+ if (count > 1)
80
+ return reject(400, 'ambiguous-credentials', 'Send one authorization header.');
81
+ if (names.includes('cookie'))
82
+ return reject(400, 'cookie-refused', 'Remote commands sign in with an API token only.');
83
+ if (!/^Bearer so_\S+$/.test(request.headers.authorization ?? ''))
84
+ return reject(401, 'unauthenticated', 'Sign in with your API token: toolroll connect <origin> --token-stdin.');
85
+ if (request.headers['content-type']?.split(';')[0]?.trim() !== 'application/json')
86
+ return reject(415, 'invalid-content-type', 'Send application/json.');
87
+ if (Number(request.headers['content-length'] ?? 0) > CLI_REQUEST_BYTES)
88
+ return reject(413, 'request-too-large', 'Files are limited to 256 KiB in total.');
89
+ let principal;
90
+ try {
91
+ principal = options.authenticate(request);
92
+ }
93
+ catch {
94
+ return reject(503, 'authentication-unavailable', 'Sign-in could not be checked.');
95
+ }
96
+ if (!principal)
97
+ return reject(401, 'unauthenticated', 'This API token is not valid (expired, revoked or its person removed).');
98
+ let value;
99
+ try {
100
+ value = await body(request);
101
+ }
102
+ catch (error) {
103
+ return reject(error instanceof Error && error.message === 'too-large' ? 413 : 400, 'invalid-body', 'Send a valid JSON request; files are limited to 256 KiB in total.');
104
+ }
105
+ const parsed = cliRequest(value);
106
+ if ('problem' in parsed)
107
+ return reject(parsed.tooLarge ? 413 : 400, 'invalid-request', parsed.problem);
108
+ const row = (options.modeOf ?? contractRow)(parsed.argv);
109
+ if (row?.mode === 'step-up')
110
+ return reject(403, 'step-up', STEP_UP_MESSAGE);
111
+ if (row?.mode !== 'yes')
112
+ return reject(403, 'remote-refused', 'This command cannot run remotely.');
113
+ const paths = fileArguments(parsed.argv, row);
114
+ if ('problem' in paths)
115
+ return reject(403, paths.code, paths.problem);
116
+ const missing = paths.find(path => !Object.hasOwn(parsed.files, path));
117
+ if (missing !== undefined)
118
+ return reject(403, 'missing-file', 'Send every input file\'s contents in files; remote commands cannot read a server file.');
119
+ if (Object.keys(parsed.files).some(path => !paths.includes(path)))
120
+ return reject(400, 'invalid-request', 'Each file must name a declared input file argument.');
121
+ if (!longPollBounded(parsed.argv))
122
+ return reject(400, 'wait-too-long', `A remote task wait holds the server for at most ${CLI_WAIT_SECONDS} seconds; give --timeout ${CLI_WAIT_SECONDS} or less.`);
123
+ let run;
124
+ try {
125
+ run = await options.run();
126
+ }
127
+ catch {
128
+ run = null;
129
+ }
130
+ if (run === null)
131
+ return reject(501, 'remote-unavailable', 'This server cannot run remote commands yet. Update it, or use --local.');
132
+ // Files stay in memory: runOperateAs reads them by their argument, so nothing is written to disk here.
133
+ const out = [];
134
+ let exitCode;
135
+ try {
136
+ exitCode = await run([...parsed.argv], { principal, store: options.store, write: line => { out.push(`${line}\n`); }, files: parsed.files, source: 'api' });
137
+ }
138
+ catch {
139
+ return reject(500, 'command-failed', 'The command stopped unexpectedly on the server. Check its state before running it again.');
140
+ }
141
+ send(response, 200, { exitCode, stdout: out.join(''), stderr: '' });
142
+ return true;
143
+ }
package/dist/cli.d.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import { previewGithubRepo, cloneGithubRepo } from "./onboard.js";
14
14
  import { type SessionCliOptions } from "./session-cli.js";
15
15
  import { type TeamCliOptions } from "./team-cli.js";
16
+ import { type RemoteExecOptions } from "./remote-exec.js";
16
17
  import { type UpdateCliDeps } from "./toolroll-update-cli.js";
17
18
  import { type OperateOptions } from "./operate.js";
18
19
  export type CliOptions = {
@@ -54,6 +55,8 @@ export type MainOptions = {
54
55
  operate?: OperateOptions;
55
56
  session?: SessionCliOptions;
56
57
  team?: TeamCliOptions;
58
+ /** Injected by tests: the remote CLI's profile, fetch and output streams (defaults to `team`'s profile seams). */
59
+ remote?: RemoteExecOptions;
57
60
  /** Injected by tests: the gh-facing halves of `repos add-from-github` —
58
61
  * the verb's parsing, gating, and enrollment are what CLI tests prove;
59
62
  * gh itself is proved by onboard.test.ts. */
package/dist/cli.js CHANGED
@@ -27,6 +27,7 @@ import { run as execRun } from "./exec.js";
27
27
  import { COMMAND_GUIDE, SURFACE_NOTES, SURFACE_SCHEMA_VERSION } from "./surface.js";
28
28
  import { runSessionCommand, SESSION_CLI_ACTIONS } from "./session-cli.js";
29
29
  import { maybeRunTeamCommand, TEAM_CLI_ACTIONS } from "./team-cli.js";
30
+ import { maybeRunRemoteCommand } from "./remote-exec.js";
30
31
  import { discover, inspectAll } from "./discover.js";
31
32
  import { readPulls } from "./pulls.js";
32
33
  import { detectGraphs, chooseBackend, setupOptions, LABELS as GRAPH_LABELS, } from "./graph.js";
@@ -253,7 +254,7 @@ export const OPERATE_COMMANDS = new Set([
253
254
  "run",
254
255
  "routine",
255
256
  "flows",
256
- "config", "chat", "proposals", "mode", "people", "keys",
257
+ "config", "chat", "proposals", "mode", "chat-approval", "people", "keys",
257
258
  "setup",
258
259
  "verify",
259
260
  "intake",
@@ -375,6 +376,12 @@ export async function main(argv, write = line => console.log(line), mainOptions
375
376
  return code;
376
377
  }
377
378
  async function dispatch(argv, write, mainOptions) {
379
+ // A saved API token sends commands marked remote to the central server, before anything local opens.
380
+ const central = await maybeRunRemoteCommand(argv, mainOptions.remote ?? mainOptions.team);
381
+ if (typeof central === "number")
382
+ return central;
383
+ if (central !== null)
384
+ argv = central.local;
378
385
  const team = await maybeRunTeamCommand(argv, write, mainOptions.team);
379
386
  if (team !== null)
380
387
  return team;
@@ -25,6 +25,9 @@ export declare const commandFlagSchema: z.ZodReadonly<z.ZodObject<{
25
25
  meaning: z.ZodString;
26
26
  }, z.core.$loose>>;
27
27
  export type CommandFlag = z.infer<typeof commandFlagSchema>;
28
+ /** The remote policy every command row states; see `remote` below. */
29
+ export declare const REMOTE_POLICIES: readonly ["yes", "no", "step-up"];
30
+ export type RemotePolicy = typeof REMOTE_POLICIES[number];
28
31
  export declare const commandRowSchema: z.ZodReadonly<z.ZodObject<{
29
32
  invocation: z.ZodString;
30
33
  envelopeCommand: z.ZodOptional<z.ZodString>;
@@ -82,6 +85,11 @@ export declare const commandRowSchema: z.ZodReadonly<z.ZodObject<{
82
85
  const?: number;
83
86
  }>;
84
87
  }>>;
88
+ remote: z.ZodEnum<{
89
+ no: "no";
90
+ "step-up": "step-up";
91
+ yes: "yes";
92
+ }>;
85
93
  }, z.core.$loose>>;
86
94
  export type CommandRow = z.infer<typeof commandRowSchema>;
87
95
  /**
@@ -157,6 +165,11 @@ export declare const COMMAND_OUTPUTS: {
157
165
  const?: number;
158
166
  }>;
159
167
  }>>;
168
+ remote: z.ZodEnum<{
169
+ no: "no";
170
+ "step-up": "step-up";
171
+ yes: "yes";
172
+ }>;
160
173
  }, z.core.$loose>>>;
161
174
  }, z.core.$loose>, z.ZodObject<{
162
175
  envelopeVersion: z.ZodLiteral<1>;
@@ -35,6 +35,8 @@ function answers(command, shape) {
35
35
  }
36
36
  // ---- the declared command guide, as `contract --commands` dumps it ----
37
37
  export const commandFlagSchema = z.looseObject({ name: str, takesValue: z.boolean(), meaning: str }).readonly();
38
+ /** The remote policy every command row states; see `remote` below. */
39
+ export const REMOTE_POLICIES = ["yes", "no", "step-up"];
38
40
  /** A session operation's executable input schema, as session-contract.ts states it. */
39
41
  const sessionInputSchema = z.custom(value => typeof value === "object" && value !== null && !Array.isArray(value));
40
42
  export const commandRowSchema = z.looseObject({
@@ -61,6 +63,11 @@ export const commandRowSchema = z.looseObject({
61
63
  notableReasons: z.array(str).readonly().optional(),
62
64
  /** Session schemas are executable input contracts, not a grant of authority. */
63
65
  inputSchema: sessionInputSchema.optional(),
66
+ /** Whether a person signed in to a central server with their own API token may run it there (`runOperateAs`):
67
+ * yes — runs as that person, within their token's scope and project access.
68
+ * no — this machine's own infrastructure, credentials or files; never run for a remote caller.
69
+ * step-up — approvals, people and policy: a person's act in the console or chat, never a token's. */
70
+ remote: z.enum(REMOTE_POLICIES),
64
71
  }).readonly();
65
72
  // ---- the commands that describe their fields ----
66
73
  const taskSchema = z.looseObject({ id: str, title: str, state: z.enum(LISTED_TASK_STATES), createdAt: str, updatedAt: str, priority: z.number() });
@@ -102,6 +102,7 @@ export declare const CONSOLE_FORMS: {
102
102
  readonly learningChange: FormContract<"action" | "identity" | "lesson" | "repo" | "revision" | "sha" | "version">;
103
103
  readonly projectSetup: FormContract<"command" | "fingerprint" | "model" | "nonce" | "provider" | "repo" | "seconds" | "token">;
104
104
  readonly slack: FormContract<"app-token" | "bot-token" | "password">;
105
+ readonly chatApproval: FormContract<"cap-usd" | "digest" | "full-access" | "nonce" | "scope" | "token">;
105
106
  readonly telegramPair: FormContract<"password">;
106
107
  readonly teams: FormContract<"app-id" | "password" | "secret" | "tenant">;
107
108
  readonly discord: FormContract<"bot-token" | "password">;
@@ -111,6 +111,7 @@ export const CONSOLE_FORMS = {
111
111
  learningChange: formContract("POST /settings/learning/change", ["repo", "action", "identity", "revision", "lesson", "version", "sha"]),
112
112
  projectSetup: formContract("POST /control/setup-preview, /control/setup-approve, /control/instructions-preview, /control/instructions-approve", ["repo", "nonce", "fingerprint", "token", "provider", "model", "command", "seconds"]),
113
113
  slack: formContract("POST /settings/slack/<connect|pair|unpair|disconnect|alerts>", ["password", "app-token", "bot-token"]),
114
+ chatApproval: formContract("POST /settings/chat-approval/confirm, /settings/chat-approval/save, /settings/chat-approval/off", ["scope", "full-access", "cap-usd", "nonce", "digest", "token"]),
114
115
  telegramPair: formContract("POST /settings/telegram/pair, /settings/telegram/unpair", ["password"]),
115
116
  teams: formContract("POST /settings/teams/<connect|pair|unpair|disconnect|alerts>", ["password", "app-id", "tenant", "secret"]),
116
117
  discord: formContract("POST /settings/discord/<connect|pair|unpair|disconnect|alerts>", ["password", "bot-token"]),
@@ -372,3 +372,85 @@ export declare const GATEWAY_TOOL_OUTPUTS: {
372
372
  }, z.core.$loose>;
373
373
  };
374
374
  export type GatewayToolOutput<N extends GatewayToolName> = z.infer<(typeof GATEWAY_TOOL_OUTPUTS)[N]>;
375
+ export declare const PERSON_TOOL_INPUTS: {
376
+ readonly status: z.ZodObject<{}, z.core.$strict>;
377
+ readonly list_tasks: z.ZodObject<{
378
+ state: z.ZodOptional<z.ZodEnum<{
379
+ cancelled: "cancelled";
380
+ done: "done";
381
+ failed: "failed";
382
+ queued: "queued";
383
+ running: "running";
384
+ }>>;
385
+ repo: z.ZodOptional<z.ZodString>;
386
+ cursor: z.ZodOptional<z.ZodInt>;
387
+ limit: z.ZodOptional<z.ZodInt>;
388
+ }, z.core.$strict>;
389
+ readonly task_show: z.ZodObject<{
390
+ ref: z.ZodString;
391
+ }, z.core.$strict>;
392
+ readonly task_review: z.ZodObject<{
393
+ ref: z.ZodString;
394
+ run: z.ZodOptional<z.ZodInt>;
395
+ all: z.ZodOptional<z.ZodBoolean>;
396
+ }, z.core.$strict>;
397
+ readonly review_findings: z.ZodObject<{
398
+ run: z.ZodInt;
399
+ }, z.core.$strict>;
400
+ readonly file_task: z.ZodObject<{
401
+ repo: z.ZodString;
402
+ title: z.ZodString;
403
+ idempotency_key: z.ZodString;
404
+ deliverable: z.ZodOptional<z.ZodEnum<{
405
+ branch: "branch";
406
+ report: "report";
407
+ }>>;
408
+ }, z.core.$strict>;
409
+ };
410
+ export type PersonToolName = keyof typeof PERSON_TOOL_INPUTS;
411
+ export type PersonToolInput<N extends PersonToolName> = z.infer<(typeof PERSON_TOOL_INPUTS)[N]>;
412
+ export declare const PERSON_TOOL_OUTPUTS: {
413
+ readonly status: z.ZodObject<{
414
+ envelopeVersion: z.ZodLiteral<1>;
415
+ ok: z.ZodBoolean;
416
+ command: z.ZodString;
417
+ reason: z.ZodOptional<z.ZodString>;
418
+ message: z.ZodOptional<z.ZodString>;
419
+ }, z.core.$loose>;
420
+ readonly list_tasks: z.ZodObject<{
421
+ envelopeVersion: z.ZodLiteral<1>;
422
+ ok: z.ZodBoolean;
423
+ command: z.ZodString;
424
+ reason: z.ZodOptional<z.ZodString>;
425
+ message: z.ZodOptional<z.ZodString>;
426
+ }, z.core.$loose>;
427
+ readonly task_show: z.ZodObject<{
428
+ envelopeVersion: z.ZodLiteral<1>;
429
+ ok: z.ZodBoolean;
430
+ command: z.ZodString;
431
+ reason: z.ZodOptional<z.ZodString>;
432
+ message: z.ZodOptional<z.ZodString>;
433
+ }, z.core.$loose>;
434
+ readonly task_review: z.ZodObject<{
435
+ envelopeVersion: z.ZodLiteral<1>;
436
+ ok: z.ZodBoolean;
437
+ command: z.ZodString;
438
+ reason: z.ZodOptional<z.ZodString>;
439
+ message: z.ZodOptional<z.ZodString>;
440
+ }, z.core.$loose>;
441
+ readonly review_findings: z.ZodObject<{
442
+ envelopeVersion: z.ZodLiteral<1>;
443
+ ok: z.ZodBoolean;
444
+ command: z.ZodString;
445
+ reason: z.ZodOptional<z.ZodString>;
446
+ message: z.ZodOptional<z.ZodString>;
447
+ }, z.core.$loose>;
448
+ readonly file_task: z.ZodObject<{
449
+ envelopeVersion: z.ZodLiteral<1>;
450
+ ok: z.ZodBoolean;
451
+ command: z.ZodString;
452
+ reason: z.ZodOptional<z.ZodString>;
453
+ message: z.ZodOptional<z.ZodString>;
454
+ id: z.ZodOptional<z.ZodString>;
455
+ }, z.core.$loose>;
456
+ };
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { z } from "zod";
12
12
  import { TEXT_LIMITS } from "../text-limits.js";
13
+ import { envelopeSchema } from "./cli.js";
13
14
  import { catchUpOutput, decisionOutput, decisionsOutput, LISTED_TASK_STATES, queueColumnsOutput, recapOutput, repositoryContextOutput, scopeText, touches } from "./lead-tools.js";
14
15
  const text = (key) => z.string().max(TEXT_LIMITS[key]);
15
16
  const ref = z.string().min(1).max(TEXT_LIMITS.taskRef);
@@ -92,3 +93,35 @@ export const GATEWAY_TOOL_OUTPUTS = {
92
93
  get_contract: z.looseObject({ contract: str }),
93
94
  file_proposal: z.looseObject({ ref: str, replayed: z.boolean(), admission: str }),
94
95
  };
96
+ // ------------------------------------------------------------- a person's tools
97
+ /**
98
+ * The tools a PERSON's API token sees over the HTTP gateway (mcp-person.ts). Each call becomes the exact `toolroll`
99
+ * command line the same person could type, run on the server under their own principal (operate.ts `runOperateAs`):
100
+ * the command's own authorization, project grants and attribution apply, and its `--json` envelope is the answer.
101
+ * Two reads keep the coordinator's names and arguments; the rest are new. Every value that lands in a command line
102
+ * may not begin with "-", so an argument can never be read as a flag.
103
+ */
104
+ const notFlag = (schema) => schema.regex(/^[^-]/, { error: "must not begin with -" });
105
+ const taskRef = notFlag(ref);
106
+ const runId = z.int().min(1);
107
+ export const PERSON_TOOL_INPUTS = {
108
+ status: GATEWAY_TOOL_INPUTS.status,
109
+ list_tasks: GATEWAY_TOOL_INPUTS.list_tasks,
110
+ task_show: z.strictObject({ ref: taskRef }),
111
+ task_review: z.strictObject({ ref: taskRef, run: runId.optional(), all: z.boolean().optional() }),
112
+ review_findings: z.strictObject({ run: runId }),
113
+ file_task: z.strictObject({
114
+ repo: notFlag(gatewayRepo), title: notFlag(text("taskTitle").min(1)), idempotency_key: notFlag(text("idempotencyKey").min(8)),
115
+ deliverable: z.enum(["branch", "report"]).optional(),
116
+ }),
117
+ };
118
+ /** The command's `--json` envelope (contracts/cli.ts); a refusal is `ok: false` with its stable reason. */
119
+ const envelope = envelopeSchema;
120
+ export const PERSON_TOOL_OUTPUTS = {
121
+ status: envelope,
122
+ list_tasks: envelope,
123
+ task_show: envelope,
124
+ task_review: envelope,
125
+ review_findings: envelope,
126
+ file_task: envelope.extend({ id: str.optional() }),
127
+ };
@@ -88,6 +88,10 @@ export declare const LEAD_TOOL_INPUTS: {
88
88
  repo: z.ZodOptional<z.ZodString>;
89
89
  run: z.ZodOptional<z.ZodInt>;
90
90
  }, z.core.$strict>;
91
+ readonly offer_approval: z.ZodObject<{
92
+ task: z.ZodOptional<z.ZodString>;
93
+ card: z.ZodOptional<z.ZodInt>;
94
+ }, z.core.$strict>;
91
95
  readonly get_result: z.ZodObject<{
92
96
  task: z.ZodString;
93
97
  run: z.ZodOptional<z.ZodInt>;
@@ -777,6 +781,10 @@ export declare const LEAD_TOOL_READ_INPUTS: {
777
781
  repo: z.ZodOptional<z.ZodString>;
778
782
  run: z.ZodOptional<z.ZodInt>;
779
783
  }, z.core.$strict>;
784
+ readonly offer_approval: z.ZodObject<{
785
+ task: z.ZodOptional<z.ZodString>;
786
+ card: z.ZodOptional<z.ZodInt>;
787
+ }, z.core.$strict>;
780
788
  readonly get_result: z.ZodObject<{
781
789
  task: z.ZodString;
782
790
  run: z.ZodOptional<z.ZodInt>;
@@ -1535,6 +1543,9 @@ export declare const LEAD_TOOL_OUTPUTS: {
1535
1543
  label: z.ZodString;
1536
1544
  action: z.ZodString;
1537
1545
  }, z.core.$loose>;
1546
+ readonly offer_approval: z.ZodObject<{
1547
+ offered: z.ZodBoolean;
1548
+ }, z.core.$loose>;
1538
1549
  readonly get_result: z.ZodObject<{
1539
1550
  task: z.ZodString;
1540
1551
  root: z.ZodString;
@@ -65,6 +65,7 @@ export const LEAD_TOOL_INPUTS = {
65
65
  propose_task_action: z.strictObject({ task: taskRef, operation: enumOf(Object.keys(CHAT_TASK_ACTIONS)), dependency: taskRef.optional(), run: id.optional() }),
66
66
  get_controls: z.strictObject({}),
67
67
  show_control: z.strictObject({ control: enumOf(Object.keys(CHAT_CONTROLS)), task: taskRef.optional(), repo: repoId.optional(), run: id.optional() }),
68
+ offer_approval: z.strictObject({ task: taskRef.optional(), card: id.optional() }),
68
69
  get_result: z.strictObject({ task: taskRef, run: id.optional(), feedback_offset: offset.optional() }),
69
70
  get_diff: z.strictObject({ task: taskRef, run: id.optional(), file: text("diffPath").min(1).optional(), offset: offset.optional() }),
70
71
  get_check_log: z.strictObject({ task: taskRef, run: id.optional(), search: text("logSearch").min(2).optional(), offset: offset.optional() }),
@@ -189,6 +190,7 @@ export const LEAD_TOOL_OUTPUTS = {
189
190
  propose_task_action: z.looseObject({ proposal: int, action: str, awaiting: str }),
190
191
  get_controls: z.looseObject({ confirmedInChat: z.array(str), existingControls: rows({ id: str, label: str, needsTask: z.boolean(), needsProject: z.boolean() }), rule: str }),
191
192
  show_control: z.looseObject({ card: int, label: str, action: str }),
193
+ offer_approval: z.looseObject({ offered: z.boolean() }),
192
194
  get_result: z.looseObject({
193
195
  task: str, root: str, currentExecution: str, run: int, title: str, feedback: list, feedbackTotal: int, nextFeedbackOffset: nullable(int),
194
196
  changes: str, changesShortened: z.boolean(), verification: str, accepted: z.boolean(), evidenceTool: str, canRevise: z.boolean(),
@@ -28,6 +28,8 @@ export declare const updateTerminal: (phase: string) => boolean;
28
28
  /** Durable receipts are private and independent of the app being replaced. */
29
29
  export declare function durableJson(file: string, value: unknown): void;
30
30
  export declare function readUpdateJournal(stateDir: string, retainedReceipt?: string): UpdateJournal | null;
31
+ /** Which settled schema versions a caller works on: the desktop update, which never migrates, only this build's own. */
32
+ export type SchemaAccepted = (version: number | null) => boolean;
31
33
  /** A finished run whose end Toolroll cannot show yet: in plain words, with the one command that clears it when a
32
34
  * person can. */
33
35
  export type LingeringRun = {
@@ -135,7 +137,7 @@ export declare function pauseUpdateRecovery(stateDir: string, id: string, detail
135
137
  * reservation so no writer slips in between the snapshot and the copy. The
136
138
  * copy drops the update's own admission pause. `inside` runs before the
137
139
  * reservation is released, `after` once it is; returns the copy's hash. */
138
- export declare function verifiedDatabaseBackup(databaseFile: string, backupPath: string, gateId: string, inside?: () => Promise<void>, after?: () => void): Promise<string>;
140
+ export declare function verifiedDatabaseBackup(databaseFile: string, backupPath: string, gateId: string, inside?: () => Promise<void>, after?: () => void, accepts?: SchemaAccepted): Promise<string>;
139
141
  /** A SQLite OS lock releases on updater death. Each irreversible step has a
140
142
  * preceding durable intent; recovery determines the actual side by hashes. */
141
143
  export declare function runDesktopUpdate(stateDir: string, hooks?: UpdateHooks): Promise<void>;
@@ -116,13 +116,14 @@ export function readUpdateJournal(stateDir, retainedReceipt) {
116
116
  }
117
117
  return j;
118
118
  }
119
- function connect(file, readOnly = false) {
119
+ const currentSchemaOnly = version => version === SCHEMA_VERSION;
120
+ function connect(file, readOnly = false, accepts = currentSchemaOnly) {
120
121
  if (!existsSync(file) || lstatSync(file).isSymbolicLink())
121
122
  throw Error("The task database is missing or linked. It was not recreated.");
122
123
  const db = new (sqlite().DatabaseSync)(file, { readOnly });
123
124
  db.exec("PRAGMA busy_timeout=1000");
124
125
  const schema = readSchemaVersion(db);
125
- if (!schema.ok || schema.version !== SCHEMA_VERSION) {
126
+ if (!schema.ok || !accepts(schema.version)) {
126
127
  db.close();
127
128
  throw Error("This update needs the current database schema. Use the separate verified migration procedure; the installed app is unchanged.");
128
129
  }
@@ -515,13 +516,13 @@ async function verifiedBackup(j) {
515
516
  * reservation so no writer slips in between the snapshot and the copy. The
516
517
  * copy drops the update's own admission pause. `inside` runs before the
517
518
  * reservation is released, `after` once it is; returns the copy's hash. */
518
- export async function verifiedDatabaseBackup(databaseFile, backupPath, gateId, inside = async () => { }, after = () => { }) {
519
- const db = connect(databaseFile);
519
+ export async function verifiedDatabaseBackup(databaseFile, backupPath, gateId, inside = async () => { }, after = () => { }, accepts = currentSchemaOnly) {
520
+ const db = connect(databaseFile, false, accepts);
520
521
  try {
521
522
  db.exec("BEGIN IMMEDIATE");
522
523
  // The write reservation prevents a concurrent writer; a separate read
523
524
  // connection is required because SQLite cannot back up a write transaction.
524
- const source = connect(databaseFile, true);
525
+ const source = connect(databaseFile, true, accepts);
525
526
  let before;
526
527
  try {
527
528
  before = snapshot(source);
@@ -531,7 +532,7 @@ export async function verifiedDatabaseBackup(databaseFile, backupPath, gateId, i
531
532
  source.close();
532
533
  }
533
534
  chmodSync(backupPath, 0o600);
534
- const copied = connect(backupPath);
535
+ const copied = connect(backupPath, false, accepts);
535
536
  try {
536
537
  if (copied.prepare("PRAGMA integrity_check").get()?.integrity_check !== "ok" || copied.prepare("PRAGMA foreign_key_check").all().length !== 0 || snapshot(copied) !== before)
537
538
  throw Error("Backup verification failed. The installed app is unchanged.");
@@ -74,4 +74,6 @@ export declare function decideFlowCard(store: Store, input: {
74
74
  entry?: number;
75
75
  /** v92: the AI teammate (by handle) deciding a zone it staffs; `actor` is then how it reads in history. */
76
76
  teammate?: string;
77
+ /** Where the decision was made, for its ledger line: "the console", "the command line", "Telegram". */
78
+ where?: string;
77
79
  }, now: Date): FlowDecision;
@@ -485,6 +485,9 @@ export function decideFlowCard(store, input, now) {
485
485
  // v102: a protected project's decisions are a person's, never an AI teammate's.
486
486
  if (input.teammate !== undefined && store.approvalRules(flow.repo).protectProject)
487
487
  return { ok: false, message: "This project is protected: a person decides here." };
488
+ // Every decision is one ledger line, the same wherever it was made; only `via` says where.
489
+ const ledger = (outcome) => store.recordAction({ at: now.toISOString(), actor: input.actor, repo: flow.repo, taskId: card.primaryTask, runId: null,
490
+ action: "flow decision", outcome, source: "request", detail: `${flow.name} · card ${card.id} · ${stage.title}${input.teammate === undefined ? "" : ` · teammate ${input.teammate}`} · via ${input.where ?? "Toolroll"}` });
488
491
  if (input.decision === "approve") {
489
492
  // An edited draft replaces the one Claude wrote, so the steps after this send what the person approved.
490
493
  const draft = draftFor(definition, stage);
@@ -498,9 +501,12 @@ export function decideFlowCard(store, input, now) {
498
501
  }
499
502
  if (stage.next === null) {
500
503
  store.updateFlowCard(card.id, { state: "done", waiting: null }, now);
504
+ ledger("approved");
501
505
  return { ok: true, said: "Approved. The card is done." };
502
506
  }
503
- store.moveFlowCard(card.id, { to: stage.next, outcome: "approved", actor: input.actor, note: input.note, expectEntry: card.entry }, now);
507
+ if (!store.moveFlowCard(card.id, { to: stage.next, outcome: "approved", actor: input.actor, note: input.note, expectEntry: card.entry }, now))
508
+ return { ok: false, message: "That card has moved on since; nothing was changed." };
509
+ ledger("approved");
504
510
  const title = definition.stages.find(one => one.id === stage.next)?.title ?? stage.next;
505
511
  if (card.owner !== null)
506
512
  notifyPeople(store, card, [card.owner], input.actor, { key: `approved:${card.entry}`, subject: `${input.actor} approved “${card.title}”`, body: `${stage.title}: approved. It moves to ${title}.${input.note === null || input.note.trim() === "" ? "" : `\n\n${input.note.trim()}`}` }, now);
@@ -512,7 +518,9 @@ export function decideFlowCard(store, input, now) {
512
518
  return { ok: false, message: "Say what should change." };
513
519
  const target = definition.stages.find(one => one.id === stage.onFail);
514
520
  const revision = revisionWithNote(store, card, target, input.note.trim(), input.actor, input.repos, input.evidenceRoot, now);
515
- store.moveFlowCard(card.id, { to: stage.onFail, outcome: "sent-back", actor: input.actor, note: input.note.trim(), ...(revision === null ? {} : { task: revision }), expectEntry: card.entry }, now);
521
+ if (!store.moveFlowCard(card.id, { to: stage.onFail, outcome: "sent-back", actor: input.actor, note: input.note.trim(), ...(revision === null ? {} : { task: revision }), expectEntry: card.entry }, now))
522
+ return { ok: false, message: "That card has moved on since; nothing was changed." };
523
+ ledger("sent-back");
516
524
  if (card.owner !== null)
517
525
  notifyPeople(store, card, [card.owner], input.actor, { key: `sent-back:${card.entry}`, subject: `${input.actor} sent “${card.title}” back`, body: `Sent back to ${target?.title ?? stage.onFail}:\n\n${input.note.trim()}`, attention: true }, now);
518
526
  return { ok: true, said: revision === null ? `Sent back to ${target?.title ?? stage.onFail} with your note.` : `Sent back to ${target?.title ?? stage.onFail}: a revision was made with your note.` };
@@ -188,6 +188,34 @@ export declare const FLOWS_DESCRIPTORS: readonly [{
188
188
  readonly takesValue: true;
189
189
  readonly meaning: 'a zone id or name';
190
190
  }];
191
+ }, {
192
+ readonly action: 'card approve';
193
+ readonly synopsis: "approve a card waiting at a Person decides step (or pick --option at a Person chooses step), as the console's Approve does";
194
+ readonly mutation: 'identity-idempotent';
195
+ readonly positionals: readonly ['flow', 'card'];
196
+ readonly flags: readonly [...{
197
+ name: string;
198
+ takesValue: boolean;
199
+ meaning: string;
200
+ }[], {
201
+ readonly name: 'option';
202
+ readonly takesValue: true;
203
+ readonly meaning: "at a Person chooses step: the option's words or its number";
204
+ }];
205
+ }, {
206
+ readonly action: 'card send-back';
207
+ readonly synopsis: "send a waiting card back with a note, as the console's Send back (or a reply at a Person chooses step) does";
208
+ readonly mutation: 'identity-idempotent';
209
+ readonly positionals: readonly ['flow', 'card'];
210
+ readonly flags: readonly [...{
211
+ name: string;
212
+ takesValue: boolean;
213
+ meaning: string;
214
+ }[], {
215
+ readonly name: 'note';
216
+ readonly takesValue: true;
217
+ readonly meaning: 'what should change';
218
+ }];
191
219
  }, {
192
220
  readonly action: 'export';
193
221
  readonly synopsis: 'write a flow as a *.toolroll-flow.json file: zones, paths, trigger settings and scripts; never secrets, webhook addresses, names or cards';