toolroll 0.9.41 → 0.9.43

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 (89) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/chat-actions.js +1 -1
  3. package/dist/chat-approval.d.ts +45 -0
  4. package/dist/chat-approval.js +81 -0
  5. package/dist/chat-channel.js +5 -0
  6. package/dist/chat-decide.d.ts +18 -6
  7. package/dist/chat-decide.js +54 -20
  8. package/dist/chat-flow.js +2 -2
  9. package/dist/cli.js +4 -2
  10. package/dist/coding-types.d.ts +2 -24
  11. package/dist/coding-workspace.js +12 -11
  12. package/dist/contracts/cli.d.ts +736 -0
  13. package/dist/contracts/cli.js +223 -0
  14. package/dist/contracts/coding-activity.d.ts +64 -0
  15. package/dist/contracts/coding-activity.js +58 -0
  16. package/dist/contracts/console-api.d.ts +484 -0
  17. package/dist/contracts/console-api.js +274 -0
  18. package/dist/contracts/diff-stat.d.ts +58 -0
  19. package/dist/contracts/diff-stat.js +59 -0
  20. package/dist/contracts/integration-metadata.d.ts +99 -0
  21. package/dist/contracts/integration-metadata.js +101 -0
  22. package/dist/contracts/lead-tools.d.ts +11 -0
  23. package/dist/contracts/lead-tools.js +2 -0
  24. package/dist/contracts/model-catalog.d.ts +21 -0
  25. package/dist/contracts/model-catalog.js +14 -0
  26. package/dist/contracts/native-census.d.ts +42 -0
  27. package/dist/contracts/native-census.js +36 -0
  28. package/dist/contracts/observation-request.d.ts +37 -0
  29. package/dist/contracts/observation-request.js +25 -0
  30. package/dist/contracts/provider.d.ts +30 -0
  31. package/dist/contracts/provider.js +15 -0
  32. package/dist/contracts/recipes.d.ts +166 -0
  33. package/dist/contracts/recipes.js +52 -0
  34. package/dist/contracts/retention.d.ts +42 -0
  35. package/dist/contracts/retention.js +24 -0
  36. package/dist/contracts/storage.d.ts +23 -0
  37. package/dist/contracts/storage.js +10 -0
  38. package/dist/contracts/store-json.d.ts +315 -0
  39. package/dist/contracts/store-json.js +196 -0
  40. package/dist/contracts/trigger-payloads.d.ts +369 -0
  41. package/dist/contracts/trigger-payloads.js +144 -0
  42. package/dist/contracts/update-journal.d.ts +254 -0
  43. package/dist/contracts/update-journal.js +155 -0
  44. package/dist/contracts/verification-view.d.ts +93 -0
  45. package/dist/contracts/verification-view.js +43 -0
  46. package/dist/control-setup.d.ts +1 -1
  47. package/dist/desktop-update.d.ts +7 -40
  48. package/dist/desktop-update.js +36 -8
  49. package/dist/evidence.d.ts +3 -19
  50. package/dist/flow-engine.d.ts +2 -0
  51. package/dist/flow-engine.js +10 -2
  52. package/dist/flow-triggers.js +61 -60
  53. package/dist/flows-cli.d.ts +28 -0
  54. package/dist/flows-cli.js +43 -1
  55. package/dist/guides.js +2 -0
  56. package/dist/mate-contract.js +1 -1
  57. package/dist/mate-tools.js +43 -1
  58. package/dist/mcp-connect.js +37 -22
  59. package/dist/model-catalog.js +8 -6
  60. package/dist/observations.d.ts +2 -6
  61. package/dist/observations.js +13 -9
  62. package/dist/operate.js +55 -4
  63. package/dist/process-recovery-eligibility.d.ts +1 -1
  64. package/dist/process-recovery-native.d.ts +2 -13
  65. package/dist/process-recovery-native.js +8 -5
  66. package/dist/project-tools.d.ts +6 -24
  67. package/dist/project-tools.js +22 -18
  68. package/dist/provider.d.ts +3 -8
  69. package/dist/provider.js +15 -11
  70. package/dist/recipes.d.ts +3 -28
  71. package/dist/recipes.js +79 -52
  72. package/dist/retention.d.ts +4 -5
  73. package/dist/retention.js +5 -4
  74. package/dist/scope.d.ts +4 -2
  75. package/dist/serve.js +193 -30
  76. package/dist/storage.d.ts +2 -1
  77. package/dist/storage.js +12 -9
  78. package/dist/store.d.ts +11 -16
  79. package/dist/store.js +122 -160
  80. package/dist/surface.d.ts +6 -54
  81. package/dist/surface.js +10 -2
  82. package/dist/teammate-work.js +1 -1
  83. package/dist/telegram-flow.d.ts +41 -1
  84. package/dist/telegram-flow.js +96 -12
  85. package/dist/telegram.js +27 -3
  86. package/dist/toolroll-update.d.ts +8 -99
  87. package/dist/toolroll-update.js +28 -18
  88. package/dist/verification-evidence.js +27 -12
  89. package/package.json +1 -1
@@ -48,7 +48,9 @@ import { TOOL_CATALOG, discoverTools, projectToolsOf, secretsSetFor, toolCommand
48
48
  import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowFromSteps, stepsFor, withKeptSteps } from "./flows.js";
49
49
  import { parseContract, toModelSchema } from "./contracts/contract.js";
50
50
  import { LEAD_TOOL_INPUTS, LEAD_TOOL_READ_INPUTS, LEAD_TOOL_OPTIONS, LEAD_TOOL_OUTPUTS, reportToolOutput } from "./contracts/lead-tools.js";
51
- import { flowDefinitionOf } from "./flow-engine.js";
51
+ import { flowCardHref, flowDefinitionOf } from "./flow-engine.js";
52
+ import { planInChat } from "./chat-decide.js";
53
+ import { flowDecisionAt } from "./telegram-flow.js";
52
54
  import { flowPersonOf } from "./flow-send.js";
53
55
  import { flowInsights } from "./flow-insights.js";
54
56
  import { describeTrigger, FLOW_TRIGGER_KINDS, triggerConfigOf } from "./flow-triggers.js";
@@ -491,6 +493,46 @@ const MATE_TOOL_HANDLERS = {
491
493
  return id === null ? tooMany() : { ok: true, body: { card: id, label: entry.label, action: "open existing control; nothing changed" } };
492
494
  },
493
495
  },
496
+ offer_approval: {
497
+ description: "When the owner asks to approve one thing (a plan waiting on them, or a flow card waiting at a Person decides step), send that item's own Approve and Send back buttons to their paired chat right away. Their tap there is the approval (two taps, as on any decision card); you never approve. Give task for a plan, or card for a flow card (its number from get_flows). When it can't be approved in chat, the answer says why: say that in one line and open show_control approval (a plan) or the flow, never that you can't approve.",
498
+ handle: (ctx, args) => {
499
+ const pairedHere = ctx.channel === "telegram";
500
+ const paired = ctx.store.handle.prepare("SELECT 1 FROM telegram_binding WHERE approver = ? AND approver_generation = ? AND revoked_at IS NULL LIMIT 1")
501
+ .get(ctx.who.name, ctx.store.accountOf(ctx.who.name)?.generation ?? -1) !== undefined;
502
+ const stamp = ctx.now.getTime();
503
+ if (args["card"] !== undefined) {
504
+ const cardId = Number(args["card"]);
505
+ const card = Number.isSafeInteger(cardId) ? ctx.store.getFlowCard(cardId) : null;
506
+ const flow = card === null ? null : ctx.store.getFlow(card.flow);
507
+ if (card === null || flow === null || !ctx.who.repos.includes(flow.repo))
508
+ return { ok: false, message: "not-found: no such flow card in your projects" };
509
+ const waiting = flowDecisionAt(ctx.store, card.id, card.entry);
510
+ const href = flowCardHref(flow.id, card.id);
511
+ if (waiting === null)
512
+ return { ok: true, body: { offered: false, why: card.state === "active" ? "That card isn't waiting for a decision now." : "That card isn't waiting any more.", open: href } };
513
+ const decider = deciderOf(waiting.stage, waiting.flow);
514
+ if (decider !== null && decider !== ctx.who.name)
515
+ return { ok: true, body: { offered: false, why: `Only ${decider} decides here.`, open: href } };
516
+ if (!pairedHere || !paired)
517
+ return { ok: true, body: { offered: false, why: pairedHere ? "Your chat isn't paired for decisions." : "Buttons go to your paired chat; here, approve it on the card.", open: href } };
518
+ ctx.store.enqueueNotification({ dedupeKey: `flow-decide:${card.id}:${card.entry}:ask:${stamp}`, kind: "flow-decision", pushClass: "attention", recipient: ctx.who.name,
519
+ subject: `${flow.name}: ${card.title} needs your decision`.slice(0, 200), body: `${waiting.stage.title}: approve it, or send it back with a note.`, link: href, source: { project: flow.repo } }, ctx.now);
520
+ return { ok: true, body: { offered: true, item: `flow card ${card.id}`, shown: "Approve and Send back buttons follow your reply in this chat; the owner's tap decides it" } };
521
+ }
522
+ const task = taskIdOf(args);
523
+ if (task === null || admittedRef(ctx, task) === null)
524
+ return notFound();
525
+ const current = ctx.store.taskFamilyOf(task, ctx.who.repos, false)?.current.id ?? task;
526
+ const plan = planInChat(ctx.store, current, ctx.who.name, ctx.now, ctx.evidenceRoot);
527
+ if (!plan.ok)
528
+ return { ok: true, body: { offered: false, task: current, why: plan.why, control: "approval" } };
529
+ if (!pairedHere || !paired)
530
+ return { ok: true, body: { offered: false, task: current, why: pairedHere ? "Your chat isn't paired for decisions." : "Buttons go to your paired chat; here, approve it on its card.", control: "approval" } };
531
+ ctx.store.enqueueNotification({ dedupeKey: `plan-ready:${current}:ask:${stamp}`, kind: "plan-ready", recipient: ctx.who.name, subject: `${current}: plan ready for review`,
532
+ body: "Approve it here, or open it to edit first.", source: { taskRef: ctx.store.lookupRef(current).id } }, ctx.now);
533
+ return { ok: true, body: { offered: true, item: `plan for ${current}`, task: current, shown: "Approve & start follows your reply in this chat; the owner's two taps approve it" } };
534
+ },
535
+ },
494
536
  get_result: {
495
537
  description: "Read exact execution/run and feedback. Use get_task currentExecution; page nextFeedbackOffset.",
496
538
  handle: (ctx, args) => {
@@ -16,6 +16,7 @@ import { homedir } from "node:os";
16
16
  import { basename } from "node:path";
17
17
  import { LOCAL_APPS, addToolTo, catalogTool, localAppOf, projectToolsOf, readToolSecrets, setToolSecrets, testToolOf } from "./project-tools.js";
18
18
  import { envValue } from "./names.js";
19
+ import { SCOPE_TOKEN, readAuthorizationServer, readProtectedResource, readRegistration, readTokenResponse } from "./contracts/integration-metadata.js";
19
20
  /**
20
21
  * Services that connect this way: their servers speak streamable HTTP and
21
22
  * advertise registering clients on the spot. Advertising isn't accepting:
@@ -202,8 +203,7 @@ const GRANTED = "OAUTH_GRANTED_SCOPE";
202
203
  export const CONNECT_CALLBACK = "/settings/tools/connected";
203
204
  const VISIT_MS = 15 * 60_000, RENEW_RETRY_MS = 5 * 60_000;
204
205
  const renewTried = new Map();
205
- /** RFC 6749 scope tokens; and the most scope text a sign-in address carries (addresses stay well under 8 KB). */
206
- const SCOPE_TOKEN = /^[\x21\x23-\x5b\x5d-\x7e]{1,128}$/;
206
+ /** The most scope text a sign-in address carries (addresses stay well under 8 KB). */
207
207
  const SCOPE_PARAM_LIMIT = 6_000;
208
208
  const json = async (fetcher, url) => {
209
209
  try {
@@ -238,25 +238,29 @@ export async function discoverSignIn(mcpUrl, fetcher = fetch) {
238
238
  catch {
239
239
  pointer = null;
240
240
  }
241
- const resource = (pointer !== null && https(pointer) ? await json(fetcher, pointer) : null)
241
+ const found = (pointer !== null && https(pointer) ? await json(fetcher, pointer) : null)
242
242
  ?? await json(fetcher, `${mcp.origin}/.well-known/oauth-protected-resource${mcp.pathname === "/" ? "" : mcp.pathname}`)
243
243
  ?? await json(fetcher, `${mcp.origin}/.well-known/oauth-protected-resource`);
244
- const issuers = Array.isArray(resource?.["authorization_servers"]) ? resource["authorization_servers"].filter(https) : [];
244
+ const resource = found === null ? {} : readProtectedResource(found);
245
+ const issuers = (resource.authorization_servers ?? []).filter(https);
245
246
  const issuer = new URL(issuers[0] ?? mcp.origin);
246
247
  const path = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/$/, "");
247
- const meta = await json(fetcher, `${issuer.origin}/.well-known/oauth-authorization-server${path}`)
248
+ const answered = await json(fetcher, `${issuer.origin}/.well-known/oauth-authorization-server${path}`)
248
249
  ?? await json(fetcher, `${issuer.origin}/.well-known/oauth-authorization-server`)
249
250
  ?? (path === "" ? null : await json(fetcher, `${issuer.origin}${path}/.well-known/openid-configuration`))
250
251
  ?? await json(fetcher, `${issuer.origin}/.well-known/openid-configuration`);
251
- if (meta === null || !https(meta["authorization_endpoint"]) || !https(meta["token_endpoint"]) || !https(meta["registration_endpoint"]))
252
+ if (answered === null)
252
253
  return null;
253
- const methods = Array.isArray(meta["code_challenge_methods_supported"]) ? meta["code_challenge_methods_supported"] : ["S256"];
254
- if (!methods.includes("S256"))
254
+ const meta = readAuthorizationServer(answered);
255
+ const { authorization_endpoint: authorize, token_endpoint: token, registration_endpoint: register } = meta;
256
+ if (!https(authorize) || !https(token) || !https(register))
257
+ return null;
258
+ if (!(meta.code_challenge_methods_supported ?? ["S256"]).includes("S256"))
255
259
  return null;
256
260
  // Ask for every scope the server lists for this resource: a token missing one the server itself requires is
257
261
  // refused after a successful sign-in (PostHog lists 155 and needs user:read, the 140th). Only well-formed scope
258
262
  // tokens, and at most what fits a sign-in address.
259
- const listed = Array.isArray(resource?.["scopes_supported"]) ? resource["scopes_supported"].filter((one) => typeof one === "string" && SCOPE_TOKEN.test(one)) : [];
263
+ const listed = resource.scopes_supported ?? [];
260
264
  const scopes = [];
261
265
  for (const one of listed) {
262
266
  if (scopes.join(" ").length + one.length + 1 > SCOPE_PARAM_LIMIT)
@@ -264,8 +268,8 @@ export async function discoverSignIn(mcpUrl, fetcher = fetch) {
264
268
  scopes.push(one);
265
269
  }
266
270
  // A token is for the resource the server names (Stripe's has no trailing slash), when it names one.
267
- const named = resource?.["resource"];
268
- return { authorize: meta["authorization_endpoint"], token: meta["token_endpoint"], register: meta["registration_endpoint"], scopes, resource: https(named) ? named : null };
271
+ const named = resource.resource;
272
+ return { authorize, token, register, scopes, resource: https(named) ? named : null };
269
273
  }
270
274
  /**
271
275
  * Start connecting a service: find its sign-in, register this console as a
@@ -287,11 +291,14 @@ export async function startConnect(input, fetcher = fetch, now = Date.now()) {
287
291
  // Figma's answer (Oct 2026): it registers only apps it has approved. Said before reading a body that may not be JSON.
288
292
  if (registered.status === 401 || registered.status === 403)
289
293
  return { ok: false, said: `${service.label} doesn't let other apps sign in this way yet.${INSTEAD[service.id] === undefined ? "" : ` ${INSTEAD[service.id]}`}` };
290
- const body = await registered.json();
291
- if (!registered.ok || typeof body.client_id !== "string")
294
+ const body = readRegistration(await registered.json());
295
+ // A `null` answer that says it registered is no answer, as it always read.
296
+ if (registered.ok && body === null)
297
+ return { ok: false, said: `${service.label} couldn't be reached.` };
298
+ if (!registered.ok || body?.client_id === undefined)
292
299
  return { ok: false, said: `${service.label} didn't let Toolroll register (HTTP ${registered.status}).` };
293
300
  clientId = body.client_id;
294
- clientSecret = typeof body.client_secret === "string" ? body.client_secret : null;
301
+ clientSecret = body.client_secret ?? null;
295
302
  }
296
303
  catch {
297
304
  return { ok: false, said: `${service.label} couldn't be reached.` };
@@ -327,9 +334,9 @@ export function connectedSpec(serviceId) {
327
334
  secrets: [ACCESS, REFRESH, CLIENT, CLIENT_SECRET, TOKEN_URL, EXPIRES, RESOURCE].map(name => ({ name, optional: name !== ACCESS })), about: `${service.label}: ${service.about} Connected by signing in.` };
328
335
  }
329
336
  const tokensFrom = (body, now) => ({
330
- access: typeof body["access_token"] === "string" ? body["access_token"] : null,
331
- refresh: typeof body["refresh_token"] === "string" ? body["refresh_token"] : null,
332
- expires: typeof body["expires_in"] === "number" && body["expires_in"] > 0 ? new Date(now + body["expires_in"] * 1000).toISOString() : null,
337
+ access: body.access_token ?? null,
338
+ refresh: body.refresh_token ?? null,
339
+ expires: body.expires_in === undefined ? null : new Date(now + body.expires_in * 1000).toISOString(),
333
340
  });
334
341
  /**
335
342
  * Finish connecting: trade the code for tokens, add the tool to the project
@@ -347,9 +354,13 @@ export async function finishConnect(store, visit, code, now, options = {}) {
347
354
  try {
348
355
  const answer = await fetcher(visit.token, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, signal: AbortSignal.timeout(15_000),
349
356
  body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: visit.redirect, client_id: visit.clientId, code_verifier: visit.verifier, resource: visit.resource, ...(visit.clientSecret === null ? {} : { client_secret: visit.clientSecret }) }) });
350
- body = await answer.json();
357
+ const read = readTokenResponse(await answer.json());
358
+ // An answer of `null` reads as none, like one that isn't JSON.
359
+ if (read === null)
360
+ return { ok: false, said: `${service.label} couldn't be reached to finish the sign-in.` };
361
+ body = read;
351
362
  if (!answer.ok)
352
- return { ok: false, said: `${service.label} didn't finish the sign-in (${typeof body["error"] === "string" ? body["error"] : `HTTP ${answer.status}`}).` };
363
+ return { ok: false, said: `${service.label} didn't finish the sign-in (${body.error ?? `HTTP ${answer.status}`}).` };
353
364
  }
354
365
  catch {
355
366
  return { ok: false, said: `${service.label} couldn't be reached to finish the sign-in.` };
@@ -363,7 +374,7 @@ export async function finishConnect(store, visit, code, now, options = {}) {
363
374
  return { ok: false, said: added.message };
364
375
  }
365
376
  // The grant: what the answer says, or what was asked for when it says nothing; never one from an earlier sign-in.
366
- const granted = "scope" in body ? scopeList(body["scope"]) : scopeList(visit.scope ?? null);
377
+ const granted = body.scopeSaid ? scopeList(body.scope) : scopeList(visit.scope ?? null);
367
378
  setToolSecrets(visit.repo, spec.name, { [ACCESS]: tokens.access, [CLIENT]: visit.clientId, [TOKEN_URL]: visit.token, [RESOURCE]: visit.resource, [GRANTED]: granted?.join(" ") ?? "",
368
379
  ...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }), ...(visit.clientSecret === null ? {} : { [CLIENT_SECRET]: visit.clientSecret }) }, home);
369
380
  // The success line names the project it went to: the page and chat may be looking at another.
@@ -394,7 +405,11 @@ export async function refreshConnections(store, repos, now, options = {}) {
394
405
  try {
395
406
  const answer = await fetcher(values[TOKEN_URL], { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, signal: AbortSignal.timeout(15_000),
396
407
  body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: values[REFRESH], client_id: values[CLIENT], ...(values[CLIENT_SECRET] === undefined ? {} : { client_secret: values[CLIENT_SECRET] }), ...(values[RESOURCE] === undefined ? {} : { resource: values[RESOURCE] }) }) });
397
- const body = await answer.json();
408
+ const body = readTokenResponse(await answer.json());
409
+ if (body === null) {
410
+ report.problems.push(`${tool.name}: couldn't reach it to renew its sign-in`);
411
+ continue;
412
+ }
398
413
  const tokens = tokensFrom(body, now.getTime());
399
414
  if (!answer.ok || tokens.access === null) {
400
415
  report.problems.push(`${tool.name}: its sign-in ran out; connect it again on the Tools page`);
@@ -402,7 +417,7 @@ export async function refreshConnections(store, repos, now, options = {}) {
402
417
  }
403
418
  // A refresh carries the same grant or a narrower one, and says so when it changes; said badly, it is unknown.
404
419
  setToolSecrets(repo, tool.name, { [ACCESS]: tokens.access, ...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }),
405
- ...("scope" in body ? { [GRANTED]: scopeList(body["scope"])?.join(" ") ?? "" } : {}) }, home);
420
+ ...(body.scopeSaid ? { [GRANTED]: scopeList(body.scope)?.join(" ") ?? "" } : {}) }, home);
406
421
  renewTried.delete(key);
407
422
  report.refreshed++;
408
423
  }
@@ -11,6 +11,7 @@ import { accessSync, constants, lstatSync, readFileSync, realpathSync } from "no
11
11
  import { homedir } from "node:os";
12
12
  import { delimiter, join } from "node:path";
13
13
  import { fetchOpenRouterModels } from "./openrouter-models.js";
14
+ import { codexModelSchema, codexModelsCacheSchema, npmLatestSchema } from "./contracts/model-catalog.js";
14
15
  import { readCappedBody } from "./converse.js";
15
16
  import { validModelId } from "./provider.js";
16
17
  import { activeUpdateWork } from "./desktop-update-gate.js";
@@ -73,10 +74,11 @@ export function codexCatalog(home = homedir()) {
73
74
  const path = join(home, ".codex", "models_cache.json");
74
75
  if (lstatSync(path).size >= 2_000_000)
75
76
  return [];
76
- const data = JSON.parse(readFileSync(path, "utf8"));
77
- return (Array.isArray(data.models) ? data.models : [])
78
- .filter(one => one != null && one.visibility === "list" && typeof one.slug === "string" && validModelId(one.slug))
79
- .slice(0, 30).map(one => ({ id: one.slug, name: typeof one.display_name === "string" ? one.display_name.slice(0, 80) : one.slug }));
77
+ const data = codexModelsCacheSchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
78
+ return (data.success ? data.data.models : []).flatMap(entry => {
79
+ const one = codexModelSchema.safeParse(entry);
80
+ return one.success ? [one.data] : [];
81
+ }).slice(0, 30).map(one => ({ id: one.slug, name: typeof one.display_name === "string" ? one.display_name.slice(0, 80) : one.slug }));
80
82
  }
81
83
  catch {
82
84
  return [];
@@ -255,8 +257,8 @@ async function latestVersion(pkg, fetcher) {
255
257
  const body = await readCappedBody(response, 2_000_000);
256
258
  if (body === null)
257
259
  return null;
258
- const version = JSON.parse(new TextDecoder().decode(body)).version;
259
- return typeof version === "string" && /^\d+\.\d+\.\d+/.test(version) ? version : null;
260
+ const latest = npmLatestSchema.safeParse(JSON.parse(new TextDecoder().decode(body)));
261
+ return latest.success ? latest.data.version : null;
260
262
  }
261
263
  catch {
262
264
  return null;
@@ -1,5 +1,6 @@
1
1
  import type { Runner } from "./builder.js";
2
2
  import type { Store } from "./store.js";
3
+ import { type ObservationCase } from "./contracts/observation-request.js";
3
4
  export declare const OBSERVATION_MAILBOX = "STANDING-ORDERS-OBSERVATIONS.json";
4
5
  export declare const OBSERVATION_CAPTURE = "machine focused observations v1";
5
6
  export declare const OBSERVATION_FILE = "REVIEW-OBSERVATIONS.json";
@@ -18,12 +19,7 @@ export type ObservationBrief = {
18
19
  gateDigest: string;
19
20
  };
20
21
  export declare function focusedTestCommandSupported(command: string): boolean;
21
- export type ObservationCase = {
22
- criterion: string;
23
- at: "base" | "head";
24
- testPath: string;
25
- testName: string;
26
- };
22
+ export type { ObservationCase };
27
23
  export declare function originalTaskBase(store: Store, taskId: string): string | null;
28
24
  /** The existing sealed revision brief identifies this narrower kind of work.
29
25
  * A title, agent-authored mailbox, or handoff can never elect gate reuse. */
@@ -6,6 +6,7 @@ import { dirname, join } from "node:path";
6
6
  import { stripVTControlCharacters } from "node:util";
7
7
  import { canonicalAcceptance } from "./scope.js";
8
8
  import { readVerifiedArtifact, scanForSecrets, storeEvidence } from "./evidence.js";
9
+ import { readObservationRequest } from "./contracts/observation-request.js";
9
10
  export const OBSERVATION_MAILBOX = "STANDING-ORDERS-OBSERVATIONS.json";
10
11
  export const OBSERVATION_CAPTURE = "machine focused observations v1";
11
12
  export const OBSERVATION_FILE = "REVIEW-OBSERVATIONS.json";
@@ -62,17 +63,20 @@ export function parseObservationCases(raw, ids) {
62
63
  if (Buffer.byteLength(raw) > 16 * 1024)
63
64
  throw Error("The observation request is too large.");
64
65
  const parsed = JSON.parse(raw);
65
- if (parsed?.version !== 1 || !Array.isArray(parsed.observations) || parsed.observations.length < 1 || parsed.observations.length > 4 ||
66
- Object.keys(parsed).some(k => k !== "version" && k !== "observations"))
66
+ const read = readObservationRequest(parsed);
67
+ // A problem inside one observation is that observation's; anything else is the request's shape.
68
+ if (!read.ok && read.issues.some(issue => !/^observations\[\d+\]/.test(issue.path)))
67
69
  throw Error("Use one to four focused test observations.");
68
- const cases = parsed.observations;
69
- for (const item of cases) {
70
- if (!item || Object.keys(item).some(k => !["criterion", "at", "testPath", "testName"].includes(k)) || !ids.includes(item.criterion) ||
71
- !["base", "head"].includes(item.at) || !pathOkay(item.testPath) || !/\.(test|spec)\.[cm]?[jt]sx?$/.test(item.testPath) ||
72
- typeof item.testName !== "string" || item.testName.trim() === "" || item.testName.length > 500 || /[\u0000-\u001f\u007f]/.test(item.testName)) {
73
- throw Error("An observation must name a requested criterion, base/head, one test file and one test name. Shell commands are not accepted.");
74
- }
70
+ const bad = () => Error("An observation must name a requested criterion, base/head, one test file and one test name. Shell commands are not accepted.");
71
+ if (!read.ok)
72
+ throw bad();
73
+ for (const item of read.value.observations) {
74
+ if (!ids.includes(item.criterion) || !pathOkay(item.testPath) || !/\.(test|spec)\.[cm]?[jt]sx?$/.test(item.testPath) ||
75
+ item.testName.trim() === "" || item.testName.length > 500 || /[\u0000-\u001f\u007f]/.test(item.testName))
76
+ throw bad();
75
77
  }
78
+ // The cases as written: their key order is the receipt's, and two written in different orders are distinct, as before.
79
+ const cases = parsed.observations;
76
80
  if (new Set(cases.map(c => JSON.stringify(c))).size !== cases.length || ids.some(id => !cases.some(c => c.criterion === id)))
77
81
  throw Error("Every requested criterion needs a distinct observation.");
78
82
  return cases;
package/dist/operate.js CHANGED
@@ -98,7 +98,8 @@ import { admissionRecorded, admitProject, commandLineActor } from "./project-adm
98
98
  import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
99
99
  import { createServer as createNetServer } from "node:net";
100
100
  import { spawn as spawnChild } from "node:child_process";
101
- import { envelopeJson } from "./envelope.js";
101
+ // Every envelope is checked against its command's schema (logged, never refused), then serialized unchanged.
102
+ import { checkedEnvelopeJson as envelopeJson } from "./contracts/cli.js";
102
103
  import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
103
104
  import { readVerifiedArtifact, readVerifiedReport, storeEvidence } from "./evidence.js";
104
105
  import { contractChangesOf, decodePlanContractRecord, describeContractChanges, encodePlannerSource, plannerSourceOf } from "./planner-source.js";
@@ -203,6 +204,7 @@ import { readPulls } from "./pulls.js";
203
204
  import { startMaintenance } from "./maintenance.js";
204
205
  import { livePin, modelWatchPass } from "./model-catalog.js";
205
206
  import { runModelsCommand } from "./models-cli.js";
207
+ import { ALL_PROJECTS, chatApprovalSettings, chatApprovalWords, setChatApproval } from "./chat-approval.js";
206
208
  import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
207
209
  import { beads } from "./beads.js";
208
210
  import { githubIssues } from "./issues.js";
@@ -469,6 +471,8 @@ Flows — processes cards move through; the console's rules
469
471
  toolroll flows script save --repo <path> --name <name> (--file <path in project> | --body <file>)
470
472
  --about "<one line>" [--language shell|python|node] [--timeout-minutes <n>]
471
473
  toolroll flows card add <id> --title <t> [--description <d>] [--zone <zone>]
474
+ toolroll flows card approve <id> <card> [--option <words|number>]
475
+ toolroll flows card send-back <id> <card> --note <text>
472
476
  toolroll flows archive <id>
473
477
  writes take --as <you> --token <t> (or the remembered login); create,
474
478
  edit, archive and trigger add preview until --yes
@@ -638,7 +642,7 @@ export const OPERATE_VALUE_FLAGS = new Set([
638
642
  "max", "cap", "probe", "kind", "expires", "cmd", "since", "repair-model",
639
643
  "choose", "note", "max-open-decisions", "max-held-sessions", "name", "days", "publication", "auto-approve", "review-auto", "entries", "port", "host", "allow-host",
640
644
  "for", "tick-every", "bridge-every", "reconcile-every", "incarnation",
641
- "say", "ceiling-usd",
645
+ "say", "ceiling-usd", "cap-usd",
642
646
  "token-file", "bin", "poll", "github", "remote", "head-prefix", "password",
643
647
  // v102: project rules.
644
648
  "not-requester", "protect", "protect-paths",
@@ -657,7 +661,7 @@ export const OPERATE_VALUE_FLAGS = new Set([
657
661
  // onboard: the starter flows to switch on.
658
662
  "starter",
659
663
  // flows: templates, steps, scripts and cards.
660
- "template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone",
664
+ "template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone", "option",
661
665
  "token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
662
666
  // pings follow responsibility: replacements.
663
667
  "replaced-by", "replaces",
@@ -670,7 +674,7 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
670
674
  "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
671
675
  "clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
672
676
  "no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
673
- "self-heal", "plan-auto", "chat-approve", "repair-auto", "review-retry-auto", "no-local",
677
+ "self-heal", "plan-auto", "chat-approve", "full-access", "repair-auto", "review-retry-auto", "no-local",
674
678
  "html", "csv", "alerts-only",
675
679
  // v105: the full export.
676
680
  "zip",
@@ -1087,6 +1091,8 @@ async function dispatch(command, positional, flags, context) {
1087
1091
  return proposalsCommand(positional, flags, context);
1088
1092
  case "mode":
1089
1093
  return modeCommand(positional, flags, context);
1094
+ case "chat-approval":
1095
+ return chatApprovalCommand(positional, flags, context);
1090
1096
  case "people":
1091
1097
  return peopleCommand(positional, flags, context);
1092
1098
  case "keys":
@@ -5754,6 +5760,51 @@ async function peopleCommand(positional, flags, context) {
5754
5760
  ]);
5755
5761
  }
5756
5762
  const MODE_ACTIONS = ["set", "show", "revoke"];
5763
+ /** `chat-approval show | on | off [--repo <path>]`: the person's lasting choice to approve plans and merges from their
5764
+ * paired chat (chat-approval.ts), for all their projects or one. On (or wider limits) previews until --yes and takes
5765
+ * the password, like Settings; off is one step. Either way a ledger line. */
5766
+ async function chatApprovalCommand(positional, flags, context) {
5767
+ const { store, write, json, clock } = context;
5768
+ const action = positional[0] ?? "show";
5769
+ const command = `chat-approval ${action}`;
5770
+ if (!["show", "on", "off"].includes(action))
5771
+ return fail(write, json, "chat-approval", "usage", "Use chat-approval show | on | off [--repo <path>] [--full-access] [--cap-usd <n>] [--yes].", EXIT.usage);
5772
+ const acting = await askCredentials(flags, context);
5773
+ if (acting === null)
5774
+ return fail(write, json, command, "unauthenticated", "Approving from chat is your own setting: pass --as <you> --token <your password> (or sign in once with toolroll up).", EXIT.refused);
5775
+ const authed = authenticateApprover(store, acting.name, acting.token);
5776
+ if (!authed.ok)
5777
+ return fail(write, json, command, "unauthenticated", "That name and password aren't an approver's. Nothing was changed.", EXIT.refused);
5778
+ const given = text(flags, "repo");
5779
+ const scope = given === undefined ? ALL_PROJECTS : canonicalProject(given) ?? resolve(given);
5780
+ if (scope !== ALL_PROJECTS && !store.accountCanAccess(acting.name, scope))
5781
+ return fail(write, json, command, "unknown-project", "That isn't one of your projects.", EXIT.refused);
5782
+ const where = scope === ALL_PROJECTS ? "all your projects" : projectName(scope);
5783
+ if (action === "show") {
5784
+ const rows = chatApprovalSettings(store, acting.name).map(one => ({ scope: one.scope === ALL_PROJECTS ? "all" : one.scope, enabled: one.enabled, fullAccess: one.fullAccess, capMicrousd: one.capMicrousd, updatedAt: one.updatedAt }));
5785
+ return succeed(write, json, command, { settings: rows }, () => rows.length === 0 ? ["Approving from chat is off everywhere. Turn it on: toolroll chat-approval on [--repo <path>]"]
5786
+ : rows.map(one => `${one.scope === "all" ? "All projects" : projectName(one.scope)}: ${one.enabled ? `on · ${chatApprovalWords(one)}` : "off"}`));
5787
+ }
5788
+ const now = clock();
5789
+ if (action === "off") {
5790
+ const saved = setChatApproval(store, { approver: acting.name, scope, enabled: false, via: "the command line" }, now);
5791
+ return saved.ok ? succeed(write, json, command, { applied: true, scope: scope === ALL_PROJECTS ? "all" : scope }, () => [saved.said]) : fail(write, json, command, "refused", saved.message, EXIT.refused);
5792
+ }
5793
+ const usd = text(flags, "cap-usd");
5794
+ const cap = usd === undefined ? null : Number(usd);
5795
+ if (cap !== null && !(Number.isFinite(cap) && cap >= 0 && cap <= 10_000))
5796
+ return fail(write, json, command, "usage", "Give --cap-usd in dollars, like 5.", EXIT.usage);
5797
+ const limits = { fullAccess: flag(flags, "full-access"), capMicrousd: cap === null ? null : Math.round(cap * 1_000_000) };
5798
+ const terms = [
5799
+ `Your own taps in your paired chat approve plans and merges in ${where}, until you turn it off: it doesn't expire.`,
5800
+ `Limits: ${chatApprovalWords(limits)}.`,
5801
+ "Plans written for you, protected paths, two-person rules and your organisation's policy still open in Toolroll.",
5802
+ ];
5803
+ if (!flag(flags, "yes"))
5804
+ return succeed(write, json, command, { applied: false, scope: scope === ALL_PROJECTS ? "all" : scope, terms }, () => [`Turn on approving from chat for ${where}`, ...terms.map(one => ` ${one}`), "", "Nothing has changed. Add --yes to make this change."]);
5805
+ const saved = setChatApproval(store, { approver: acting.name, scope, enabled: true, limits, via: "the command line" }, now);
5806
+ return saved.ok ? succeed(write, json, command, { applied: true, scope: scope === ALL_PROJECTS ? "all" : scope, terms }, () => [saved.said]) : fail(write, json, command, "refused", saved.message, EXIT.refused);
5807
+ }
5757
5808
  async function modeCommand(positional, flags, context) {
5758
5809
  const { store, write, json, clock } = context;
5759
5810
  const [action] = positional;
@@ -50,7 +50,7 @@ export declare function preparedCandidateObserverGapEligibility(store: Store, in
50
50
  digest: string;
51
51
  approvedAt: string;
52
52
  approvedBy: string;
53
- approvalBasis: "mode" | "password";
53
+ approvalBasis: "chat" | "mode" | "password";
54
54
  modeDigest: string | null;
55
55
  };
56
56
  readonly command: {
@@ -1,21 +1,11 @@
1
+ import { type DarwinProcessIdentity } from "./contracts/native-census.js";
1
2
  export type PreparedDarwinProcessCensus = {
2
3
  sourcePath: string;
3
4
  executablePath: string;
4
5
  sourceSha256: string;
5
6
  executableSha256: string;
6
7
  };
7
- export type DarwinProcessIdentity = {
8
- pid: number;
9
- ppid: number | null;
10
- uid: number | null;
11
- birthMs: number | null;
12
- uniqueId: string | null;
13
- parentUniqueId: string | null;
14
- traced: boolean | null;
15
- executable: string | null;
16
- originalParentVersion: number | null;
17
- pidVersion?: number;
18
- };
8
+ export type { DarwinProcessIdentity } from "./contracts/native-census.js";
19
9
  export type DarwinManagedService = {
20
10
  domain: string;
21
11
  label: string;
@@ -169,4 +159,3 @@ export declare function collectDarwinCoalitionSnapshot(args: {
169
159
  maxAttempts?: number;
170
160
  anchorPids?: number[];
171
161
  }): Promise<DarwinCoalitionRecoverySnapshot>;
172
- export {};
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
4
4
  import { hostname, release } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { darwinNativeCensusSchema, darwinProcessIdentitySchema, kernelIdInRange } from "./contracts/native-census.js";
6
7
  const digest = (data) => createHash("sha256").update(data).digest("hex");
7
8
  const cleanEnv = { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LC_ALL: "C", TZ: "UTC" };
8
9
  /** Facts only. Public KERN_PROC_ALL supplies birth/UID/trace metadata without
@@ -189,15 +190,17 @@ const nullable = (value, test) => value === null || test(value);
189
190
  const safeText = (value) => typeof value === "string" && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value);
190
191
  /** Strict parser also strips every field not in the facts contract. */
191
192
  export function parseDarwinNativeSnapshot(text) {
192
- const v = JSON.parse(text);
193
- if (!v || v.schema !== 1 || !nullable(v.bootId, (x) => typeof x === "string" && /^[a-fA-F0-9-]{36}$/.test(x)) || !isInt(v.collectorPid, 2) || typeof v.complete !== "boolean" || !Array.isArray(v.processes) || v.processes.length > 100_000 || !Array.isArray(v.errors) || !v.errors.every(x => ["kernel-process-table-unreadable", "process-membership-unreadable", "process-identity-unreadable", "boot-identity-unreadable", "coalition-counters-unreadable", "coalition-counters-changed", "coalition-count-mismatch", "anchor-identity-unreadable"].includes(x)))
193
+ const read = darwinNativeCensusSchema.safeParse(JSON.parse(text));
194
+ if (!read.success)
194
195
  throw new Error("malformed-native-census");
196
+ const v = read.data;
195
197
  const pids = new Set(), ids = new Set();
196
198
  const processes = v.processes.map((r) => {
197
- if (!r || typeof r !== "object")
199
+ const row = darwinProcessIdentitySchema.safeParse(r);
200
+ if (!row.success)
198
201
  throw new Error("malformed-process-identity");
199
- const p = r;
200
- if (!isInt(p.pid, 1) || pids.has(p.pid) || !nullable(p.ppid, isInt) || !nullable(p.uid, isInt) || !nullable(p.birthMs, (n) => typeof n === "number" && Number.isFinite(n) && n > 0) || !nullable(p.uniqueId, isId) || p.uniqueId === "0" || (p.uniqueId !== null && ids.has(p.uniqueId)) || !nullable(p.parentUniqueId, isId) || (p.traced !== null && typeof p.traced !== "boolean") || !nullable(p.executable, safeText) || !nullable(p.originalParentVersion, (n) => isInt(n) && n <= 0xffffffff) || (p.pidVersion !== undefined && (!isInt(p.pidVersion) || p.pidVersion > 0xffffffff)))
202
+ const p = row.data;
203
+ if (pids.has(p.pid) || (p.uniqueId !== null && !kernelIdInRange(p.uniqueId)) || p.uniqueId === "0" || (p.uniqueId !== null && ids.has(p.uniqueId)) || (p.parentUniqueId !== null && !kernelIdInRange(p.parentUniqueId)))
201
204
  throw new Error("malformed-process-identity");
202
205
  pids.add(p.pid);
203
206
  if (p.uniqueId !== null)
@@ -1,26 +1,6 @@
1
1
  import type { Store, ToolActionInfo } from "./store.js";
2
- /** One secret a tool needs: an env name for a local server, or the value behind an http header. */
3
- export type ToolSecret = {
4
- name: string;
5
- optional: boolean;
6
- };
7
- export type ToolSpec = {
8
- name: string;
9
- transport: "stdio" | "http";
10
- /** stdio: the program and its arguments (no secrets in either). */
11
- command: string | null;
12
- args: string[];
13
- /** http: the server's address. */
14
- url: string | null;
15
- /** Every secret the tool needs. stdio: each is an env variable of the server process. */
16
- secrets: ToolSecret[];
17
- /** http: the secret sent as `Authorization: Bearer <value>`. */
18
- bearer: string | null;
19
- /** http: header name → the secret sent as its raw value. */
20
- headerSecrets: Record<string, string>;
21
- /** What it does, in plain words. */
22
- about: string;
23
- };
2
+ import { type ToolSecret, type ToolSpec } from "./contracts/integration-metadata.js";
3
+ export type { ToolSecret, ToolSpec };
24
4
  export type ToolTest = {
25
5
  at: string;
26
6
  ok: boolean;
@@ -68,7 +48,10 @@ export declare function catalogTool(id: string): (ToolSpec & {
68
48
  }) | null;
69
49
  /** What a build is held to: how the server starts and which secrets it names — never their values or its description. */
70
50
  export declare function toolDigest(spec: ToolSpec): string;
71
- /** A tool definition from any source (catalog, console form, the lead, an import), checked whole. Throws in plain words. */
51
+ /**
52
+ * A tool definition from any source (catalog, console form, the lead, an import, a saved row), checked whole and made
53
+ * the spec its schema (src/contracts/integration-metadata.ts) says. Throws in plain words.
54
+ */
72
55
  export declare function validateToolSpec(input: Record<string, unknown>): ToolSpec;
73
56
  /** How the tool starts, in one line a person can check. Never a secret value. */
74
57
  export declare function toolCommandLine(spec: ToolSpec): string;
@@ -234,4 +217,3 @@ export declare function toolStanding(tool: ProjectTool, secretsSet: readonly str
234
217
  export declare function secretsSetFor(repo: string, tool: ToolSpec, home?: string): string[];
235
218
  /** A command line split into program and arguments, honouring "double" and 'single' quotes. */
236
219
  export declare function splitCommandLine(line: string): string[];
237
- export {};