toolroll 0.9.41 → 0.9.42

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 (67) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cli.js +3 -1
  3. package/dist/coding-types.d.ts +2 -24
  4. package/dist/coding-workspace.js +12 -11
  5. package/dist/contracts/cli.d.ts +736 -0
  6. package/dist/contracts/cli.js +223 -0
  7. package/dist/contracts/coding-activity.d.ts +64 -0
  8. package/dist/contracts/coding-activity.js +58 -0
  9. package/dist/contracts/console-api.d.ts +483 -0
  10. package/dist/contracts/console-api.js +273 -0
  11. package/dist/contracts/diff-stat.d.ts +58 -0
  12. package/dist/contracts/diff-stat.js +59 -0
  13. package/dist/contracts/integration-metadata.d.ts +99 -0
  14. package/dist/contracts/integration-metadata.js +101 -0
  15. package/dist/contracts/model-catalog.d.ts +21 -0
  16. package/dist/contracts/model-catalog.js +14 -0
  17. package/dist/contracts/native-census.d.ts +42 -0
  18. package/dist/contracts/native-census.js +36 -0
  19. package/dist/contracts/observation-request.d.ts +37 -0
  20. package/dist/contracts/observation-request.js +25 -0
  21. package/dist/contracts/provider.d.ts +30 -0
  22. package/dist/contracts/provider.js +15 -0
  23. package/dist/contracts/recipes.d.ts +166 -0
  24. package/dist/contracts/recipes.js +52 -0
  25. package/dist/contracts/retention.d.ts +42 -0
  26. package/dist/contracts/retention.js +24 -0
  27. package/dist/contracts/storage.d.ts +23 -0
  28. package/dist/contracts/storage.js +10 -0
  29. package/dist/contracts/store-json.d.ts +315 -0
  30. package/dist/contracts/store-json.js +196 -0
  31. package/dist/contracts/trigger-payloads.d.ts +369 -0
  32. package/dist/contracts/trigger-payloads.js +144 -0
  33. package/dist/contracts/update-journal.d.ts +254 -0
  34. package/dist/contracts/update-journal.js +155 -0
  35. package/dist/contracts/verification-view.d.ts +93 -0
  36. package/dist/contracts/verification-view.js +43 -0
  37. package/dist/control-setup.d.ts +1 -1
  38. package/dist/desktop-update.d.ts +7 -40
  39. package/dist/desktop-update.js +36 -8
  40. package/dist/evidence.d.ts +3 -19
  41. package/dist/flow-triggers.js +61 -60
  42. package/dist/mcp-connect.js +37 -22
  43. package/dist/model-catalog.js +8 -6
  44. package/dist/observations.d.ts +2 -6
  45. package/dist/observations.js +13 -9
  46. package/dist/operate.js +2 -1
  47. package/dist/process-recovery-native.d.ts +2 -13
  48. package/dist/process-recovery-native.js +8 -5
  49. package/dist/project-tools.d.ts +6 -24
  50. package/dist/project-tools.js +22 -18
  51. package/dist/provider.d.ts +3 -8
  52. package/dist/provider.js +15 -11
  53. package/dist/recipes.d.ts +3 -28
  54. package/dist/recipes.js +79 -52
  55. package/dist/retention.d.ts +4 -5
  56. package/dist/retention.js +5 -4
  57. package/dist/serve.js +142 -28
  58. package/dist/storage.d.ts +2 -1
  59. package/dist/storage.js +12 -9
  60. package/dist/store.d.ts +3 -15
  61. package/dist/store.js +80 -158
  62. package/dist/surface.d.ts +6 -54
  63. package/dist/surface.js +6 -1
  64. package/dist/toolroll-update.d.ts +8 -99
  65. package/dist/toolroll-update.js +28 -18
  66. package/dist/verification-evidence.js +27 -12
  67. package/package.json +1 -1
@@ -12,6 +12,7 @@ import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate
12
12
  import { currentDesktopAccess } from "./desktop-access.js";
13
13
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner } from "./coding-update.js";
14
14
  import { processMayBeAlive } from "./process-liveness.js";
15
+ import { readDesktopRecoveryView, readDesktopUpdateJournal, supervisorPidsOf, updateRequestIs } from "./contracts/update-journal.js";
15
16
  /** Loaded on first use (as backup.ts and store.ts do), so modules that only import this one (the console,
16
17
  * and tests that load it in a browser-like environment) never need `node:sqlite` itself. */
17
18
  function sqlite() {
@@ -60,13 +61,40 @@ function privateFile(file) {
60
61
  if (!stat.isFile() || stat.isSymbolicLink() || (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.())))
61
62
  throw Error("The update record must be an owner-only regular file.");
62
63
  }
64
+ /** The refusal the hand-written reader gave for the field a saved journal first gets wrong, so callers and people read
65
+ * the same words; fields it never checked read as an invalid record. */
66
+ function journalRefusal(issue) {
67
+ const [field, inner] = issue.path.split(/[.[]/);
68
+ switch (field) {
69
+ case "old":
70
+ if (inner === undefined || inner === "path")
71
+ return "The saved update record is invalid. Preserve it and the app backups; nothing was changed.";
72
+ return "The saved update paths or build identities are invalid. Nothing was changed.";
73
+ case "next":
74
+ case "databaseFile":
75
+ case "configHash":
76
+ case "wasRunning":
77
+ case "backupPath":
78
+ case "backupHash":
79
+ return "The saved update paths or build identities are invalid. Nothing was changed.";
80
+ case "replacementOccurred": return "The recorded replacement state is invalid. Nothing was changed.";
81
+ case "codingCatalogExpected": return "The recorded coding catalog presence is invalid. Nothing was changed.";
82
+ case "stoppedPids": return "The recorded service processes are invalid. Nothing was changed.";
83
+ case "codingBackupPath":
84
+ case "codingBackupHash": return "The retained coding backup paths or identity are invalid. Nothing was changed.";
85
+ default: return "The saved update record is invalid. Preserve it and the app backups; nothing was changed.";
86
+ }
87
+ }
63
88
  export function readUpdateJournal(stateDir, retainedReceipt) {
64
89
  const file = retainedReceipt ?? journalPath(stateDir);
65
90
  if (!existsSync(file))
66
91
  return null;
67
92
  privateFile(file);
68
- const j = JSON.parse(readFileSync(file, "utf8"));
69
- if (j.version !== 1 || !/^[a-f0-9-]{36}$/.test(j.id) || j.stateDir !== resolve(stateDir) || !isAbsolute(j.old?.path ?? "") || j.workDir !== join(dirname(j.old.path), ".standing-orders-updates", j.id) || !/^[a-zA-Z0-9.-]+$/.test(j.label) || !["install", "restore"].includes(j.intended) || !["prepared", "draining", "backing-up", "stopping", "installing", "verifying", "rolling-back", "releasing", "complete", "restored", "cancelled", "needs-attention"].includes(j.phase))
93
+ const read = readDesktopUpdateJournal(JSON.parse(readFileSync(file, "utf8")));
94
+ if (!read.ok)
95
+ throw Error(`${journalRefusal(read.issues[0])} (${read.issues.map(issue => issue.line).join("; ")})`);
96
+ const j = read.value;
97
+ if (!/^[a-f0-9-]{36}$/.test(j.id) || j.stateDir !== resolve(stateDir) || !isAbsolute(j.old?.path ?? "") || j.workDir !== join(dirname(j.old.path), ".standing-orders-updates", j.id) || !/^[a-zA-Z0-9.-]+$/.test(j.label) || !["install", "restore"].includes(j.intended) || !["prepared", "draining", "backing-up", "stopping", "installing", "verifying", "rolling-back", "releasing", "complete", "restored", "cancelled", "needs-attention"].includes(j.phase))
70
98
  throw Error("The saved update record is invalid. Preserve it and the app backups; nothing was changed.");
71
99
  if (retainedReceipt && retainedReceipt !== join(j.workDir, "receipt.json"))
72
100
  throw Error("The retained recovery receipt is at the wrong path.");
@@ -374,7 +402,7 @@ export function desktopUpdateStatus(stateDir) {
374
402
  let recovery = null;
375
403
  try {
376
404
  privateFile(join(j.workDir, "recovery.json"));
377
- recovery = JSON.parse(readFileSync(join(j.workDir, "recovery.json"), "utf8"));
405
+ recovery = readDesktopRecoveryView(JSON.parse(readFileSync(join(j.workDir, "recovery.json"), "utf8")));
378
406
  }
379
407
  catch { /* A legacy/manual update has no guardian. */ }
380
408
  const wantsAutomatic = recovery?.id === j.id && ["armed", "running", "backoff"].includes(recovery.state ?? "") && !terminal(j.phase);
@@ -389,8 +417,7 @@ export function updateStopRequested(j) {
389
417
  if (!existsSync(file))
390
418
  return false;
391
419
  privateFile(file);
392
- const request = JSON.parse(readFileSync(file, "utf8"));
393
- if (request.id !== j.id || request.action !== "stop")
420
+ if (!updateRequestIs(JSON.parse(readFileSync(file, "utf8")), j.id, "stop"))
394
421
  throw Error("The saved stop request is invalid. Automatic starts are refused.");
395
422
  return true;
396
423
  }
@@ -410,7 +437,9 @@ export function requestUpdateRestore(stateDir) {
410
437
  function restoreRequested(j) {
411
438
  try {
412
439
  const request = JSON.parse(readFileSync(join(j.workDir, "request.json"), "utf8"));
413
- return request.id === j.id && request.action === "restore";
440
+ if (request === null)
441
+ throw Error("The saved restore request is invalid.");
442
+ return updateRequestIs(request, j.id, "restore");
414
443
  }
415
444
  catch (error) {
416
445
  if (error.code === "ENOENT")
@@ -543,8 +572,7 @@ function assertDesktopCodingStopped(j, db) {
543
572
  /** The service's own processes, from its supervisor's status file: read before the stop, never after. */
544
573
  function servicePids(stateDir) {
545
574
  try {
546
- const status = JSON.parse(readFileSync(join(stateDir, "controller-supervisor.json"), "utf8"));
547
- return [status.supervisorPid, status.controllerPid].filter((pid) => Number.isSafeInteger(pid) && Number(pid) > 1);
575
+ return supervisorPidsOf(JSON.parse(readFileSync(join(stateDir, "controller-supervisor.json"), "utf8")));
548
576
  }
549
577
  catch {
550
578
  return [];
@@ -16,6 +16,7 @@
16
16
  import { type ParsedReport, type ReportImage } from "./scout-report.js";
17
17
  import { type ParsedProof } from "./proof.js";
18
18
  import { type HandoffArtifact } from "./contracts/handoff.js";
19
+ import type { DiffStat } from "./contracts/diff-stat.js";
19
20
  export type { HandoffArtifact } from "./contracts/handoff.js";
20
21
  import type { Artifact, Store } from "./store.js";
21
22
  import type { ExecResult } from "./exec.js";
@@ -229,25 +230,8 @@ export declare function quarantineMailboxes(worktree: string, root: string | nul
229
230
  export declare function captureParkEvidence(store: Store, git: (file: string, args: readonly string[], options?: {
230
231
  cwd?: string;
231
232
  }) => Promise<ExecResult>, worktree: string, baseRevision: string | null, root: string, runId: number, now: Date): Promise<number[]>;
232
- /** One file's row in the terminal diff-stat. null adds/dels = binary. */
233
- export type DiffStatFile = {
234
- path: string;
235
- additions: number | null;
236
- deletions: number | null;
237
- renamedFrom?: string;
238
- };
239
- export type DiffStat = {
240
- schema: 1;
241
- base: string;
242
- head: string;
243
- fileCount: number;
244
- additions: number;
245
- deletions: number;
246
- binaryCount: number;
247
- files: DiffStatFile[];
248
- /** True when the file list was cut to fit the cap — counts stay complete. */
249
- filesTruncated: boolean;
250
- };
233
+ /** The terminal diff-stat and its file rows: src/contracts/diff-stat.ts. */
234
+ export type { DiffStat, DiffStatFile } from "./contracts/diff-stat.js";
251
235
  /**
252
236
  * Parse `git diff --numstat -z` output. NUL-delimited, so filenames are never
253
237
  * guessed out of patch headers (Codex roadmap review, scope cut A.3): plain
@@ -42,6 +42,7 @@ import { mailboxAccess, mailCursorOf, mailCursorText, readThroughImap } from "./
42
42
  import { readFlowSecrets } from "./flow-secrets.js";
43
43
  import { tmpdir } from "node:os";
44
44
  import { envValue } from "./names.js";
45
+ import { linearLabelNames, readFormSubmission, readGithubEvent, readGithubIssue, readGithubIssueEventList, readGithubIssueList, readGithubRun, readGithubRunList, readInboundMail, readLinearAnswer, readLinearEvent, readLinearIssue, readWebhookPayload } from "./contracts/trigger-payloads.js";
45
46
  export { FLOW_TRIGGER_KINDS };
46
47
  export const CHAT_APP_NAMES = { slack: "Slack", discord: "Discord", teams: "Teams", telegram: "Telegram" };
47
48
  /** What each kind is called on the canvas. */
@@ -771,7 +772,7 @@ async function fetchMail(store, config, trigger, io, now) {
771
772
  if (!read.ok)
772
773
  return { ok: false, problem: read.said };
773
774
  const own = signed.address.toLowerCase();
774
- const items = read.mails.filter(mail => mailMatches(config, mail)).map(mail => {
775
+ const items = read.mails.map(readInboundMail).filter(mail => mailMatches(config, mail)).map(mail => {
775
776
  const key = `mail:${read.at.validity}:${mail.uid}`, at = mailCursorText({ validity: read.at.validity, uid: mail.uid });
776
777
  const label = `Email from ${mail.from || "an unknown sender"}`.slice(0, 120);
777
778
  // Machines and this account itself never start cards: a flow that answers mail mustn't answer them.
@@ -793,44 +794,42 @@ async function fetchMail(store, config, trigger, io, now) {
793
794
  : read.renumbered ? "The mail server renumbered this folder; watching it from now." : undefined;
794
795
  return { ok: true, items, cursor: mailCursorText(read.at), more: read.more, ...(said === undefined ? {} : { said }) };
795
796
  }
796
- const text = (value) => typeof value === "string" ? value : "";
797
- const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
798
797
  /** A GitHub issue or pull request as a card, or why it is left out; null when it doesn't match. */
799
798
  export function githubItem(config, raw, what) {
800
- const item = record(raw);
801
- const number = Number(item["number"]);
799
+ const item = readGithubIssue(raw);
800
+ const number = Number(item.number);
802
801
  if (!Number.isSafeInteger(number))
803
802
  return null;
804
- if (what === "issue" && item["pull_request"] !== undefined)
803
+ if (what === "issue" && item.pull_request !== undefined)
805
804
  return null;
806
- if (what === "pull" && item["draft"] === true)
805
+ if (what === "pull" && item.draft === true)
807
806
  return null;
808
- const labels = Array.isArray(item["labels"]) ? item["labels"].map(one => text(record(one)["name"]).toLowerCase()) : [];
807
+ const labels = (item.labels ?? []).map(one => (one.name ?? "").toLowerCase());
809
808
  if (config.label !== null && !labels.includes(config.label.toLowerCase()))
810
809
  return null;
811
810
  const label = what === "issue" ? `GitHub issue #${number}` : `Pull request #${number}`;
812
- const key = `${what}:${number}`, at = text(item["updated_at"]) || null;
813
- if (config.from === "team" && !TEAM.has(text(item["author_association"])))
811
+ const key = `${what}:${number}`, at = item.updated_at || null;
812
+ if (config.from === "team" && !TEAM.has(item.author_association ?? ""))
814
813
  return { key, skip: "opened by someone without write access", label, at };
815
- const login = text(record(item["user"])["login"]) || "someone";
816
- const branches = what === "pull" ? ` (${text(record(item["head"])["ref"])} → ${text(record(item["base"])["ref"])})` : "";
817
- return { key, at, title: text(item["title"]) || label,
818
- description: `From ${what === "issue" ? "GitHub issue" : "pull request"} #${number} by @${login}${branches}:\n\n${text(item["body"])}`.trim().slice(0, 4000),
819
- source: { kind: "github", label, url: text(item["html_url"]) || null } };
814
+ const login = item.user?.login || "someone";
815
+ const branches = what === "pull" ? ` (${item.head?.ref ?? ""} → ${item.base?.ref ?? ""})` : "";
816
+ return { key, at, title: item.title || label,
817
+ description: `From ${what === "issue" ? "GitHub issue" : "pull request"} #${number} by @${login}${branches}:\n\n${item.body ?? ""}`.trim().slice(0, 4000),
818
+ source: { kind: "github", label, url: item.html_url || null } };
820
819
  }
821
820
  /** A failed workflow run as a card: one card per failing commit on the watched branch. */
822
821
  export function githubRunItem(config, raw) {
823
- const run = record(raw);
824
- if (text(run["conclusion"]) !== "failure" || (config.branch !== null && text(run["head_branch"]) !== config.branch))
822
+ const run = readGithubRun(raw);
823
+ if (run.conclusion !== "failure" || (config.branch !== null && (run.head_branch ?? "") !== config.branch))
825
824
  return null;
826
- const sha = text(run["head_sha"]);
825
+ const sha = run.head_sha ?? "";
827
826
  if (sha === "")
828
827
  return null;
829
- const name = text(run["name"]) || "Checks";
830
- const commit = text(run["display_title"]);
831
- return { key: `checks:${sha}`, at: text(run["created_at"]) || null, title: `Checks failed on ${config.branch}: ${name}`,
832
- description: `From GitHub: “${name}” failed on ${config.branch} at ${sha.slice(0, 7)}${commit === "" ? "" : ` (${commit})`}.\nRun: ${text(run["html_url"])}`,
833
- source: { kind: "github", label: `Failed check on ${config.branch}`, url: text(run["html_url"]) || null } };
828
+ const name = run.name || "Checks";
829
+ const commit = run.display_title ?? "";
830
+ return { key: `checks:${sha}`, at: run.created_at || null, title: `Checks failed on ${config.branch}: ${name}`,
831
+ description: `From GitHub: “${name}” failed on ${config.branch} at ${sha.slice(0, 7)}${commit === "" ? "" : ` (${commit})`}.\nRun: ${run.html_url ?? ""}`,
832
+ source: { kind: "github", label: `Failed check on ${config.branch}`, url: run.html_url || null } };
834
833
  }
835
834
  async function fetchGitHub(config, trigger, gh) {
836
835
  const since = trigger.cursor ?? trigger.createdAt;
@@ -861,20 +860,19 @@ async function fetchGitHub(config, trigger, gh) {
861
860
  }
862
861
  const created = Date.parse(trigger.createdAt);
863
862
  if (config.watch === "checks") {
864
- const runs = (Array.isArray(record(body)["workflow_runs"]) ? record(body)["workflow_runs"] : []).filter(one => Date.parse(text(record(one)["created_at"])) > created);
863
+ const runs = (readGithubRunList(body).workflow_runs ?? []).filter(one => Date.parse(one.created_at ?? "") > created);
865
864
  const items = runs.reverse().map(one => githubRunItem(config, one)).filter((one) => one !== null);
866
865
  return { ok: true, items: unique(items), cursor: trigger.cursor };
867
866
  }
868
- const list = Array.isArray(body) ? body : [];
869
867
  if (labelEvents) {
870
- const labeled = list.filter(one => text(record(one)["event"]) === "labeled" && text(record(record(one)["label"])["name"]).toLowerCase() === config.label.toLowerCase()
871
- && Date.parse(text(record(one)["created_at"])) > Date.parse(since)).reverse();
872
- const items = labeled.map(one => { const item = githubItem(config, record(one)["issue"], "issue"); return item === null ? null : { ...item, at: text(record(one)["created_at"]) || null }; }).filter((one) => one !== null);
873
- return { ok: true, items: unique(items), cursor: labeled.map(one => text(record(one)["created_at"])).at(-1) ?? trigger.cursor };
868
+ const labeled = readGithubIssueEventList(body).filter(one => one.event === "labeled" && (one.label?.name ?? "").toLowerCase() === config.label.toLowerCase()
869
+ && Date.parse(one.created_at ?? "") > Date.parse(since)).reverse();
870
+ const items = labeled.map(one => { const item = githubItem(config, one.issue, "issue"); return item === null ? null : { ...item, at: one.created_at || null }; }).filter((one) => one !== null);
871
+ return { ok: true, items: unique(items), cursor: labeled.map(one => one.created_at ?? "").at(-1) ?? trigger.cursor };
874
872
  }
875
873
  // New issues and pull requests: opened after the trigger was added, oldest first; the key keeps each to one card.
876
874
  const what = config.watch === "pulls" ? "pull" : "issue";
877
- const items = list.filter(one => Date.parse(text(record(one)["created_at"])) > created).reverse().map(one => githubItem(config, one, what)).filter((one) => one !== null);
875
+ const items = readGithubIssueList(body).filter(one => Date.parse(one.created_at ?? "") > created).reverse().map(one => githubItem(config, one, what)).filter((one) => one !== null);
878
876
  return { ok: true, items, cursor: trigger.cursor };
879
877
  }
880
878
  const unique = (items) => items.filter((item, index) => items.findIndex(one => one.key === item.key) === index);
@@ -882,21 +880,20 @@ export const LINEAR_URL = "https://api.linear.app/graphql";
882
880
  export const LINEAR_QUERY = "query FlowTrigger($filter: IssueFilter) { issues(filter: $filter, first: 50, orderBy: updatedAt) { nodes { identifier title description url updatedAt state { name } team { key } labels { nodes { name } } } } }";
883
881
  /** A Linear issue as a card; null when it doesn't match the trigger. */
884
882
  export function linearItem(config, raw) {
885
- const issue = record(raw);
886
- const identifier = text(issue["identifier"]);
883
+ const issue = readLinearIssue(raw);
884
+ const identifier = issue.identifier ?? "";
887
885
  if (identifier === "")
888
886
  return null;
889
- const labelNodes = Array.isArray(issue["labels"]) ? issue["labels"] : Array.isArray(record(issue["labels"])["nodes"]) ? record(issue["labels"])["nodes"] : [];
890
- const labels = labelNodes.map(one => text(record(one)["name"]).toLowerCase());
891
- if (config.team !== null && text(record(issue["team"])["key"]).toUpperCase() !== config.team)
887
+ const labels = linearLabelNames(issue).map(one => one.toLowerCase());
888
+ if (config.team !== null && (issue.team?.key ?? "").toUpperCase() !== config.team)
892
889
  return null;
893
- if (config.state !== null && text(record(issue["state"])["name"]).toLowerCase() !== config.state.toLowerCase())
890
+ if (config.state !== null && (issue.state?.name ?? "").toLowerCase() !== config.state.toLowerCase())
894
891
  return null;
895
892
  if (config.label !== null && !labels.includes(config.label.toLowerCase()))
896
893
  return null;
897
- return { key: `linear:${identifier}`, at: text(issue["updatedAt"]) || null, title: text(issue["title"]) || identifier,
898
- description: `From Linear ${identifier}:\n\n${text(issue["description"])}`.trim().slice(0, 4000),
899
- source: { kind: "linear", label: `Linear ${identifier}`, url: text(issue["url"]) || null } };
894
+ return { key: `linear:${identifier}`, at: issue.updatedAt || null, title: issue.title || identifier,
895
+ description: `From Linear ${identifier}:\n\n${issue.description ?? ""}`.trim().slice(0, 4000),
896
+ source: { kind: "linear", label: `Linear ${identifier}`, url: issue.url || null } };
900
897
  }
901
898
  async function fetchLinear(config, trigger, io) {
902
899
  const key = readLinearKey(io.dir);
@@ -917,22 +914,22 @@ async function fetchLinear(config, trigger, io) {
917
914
  catch {
918
915
  return { ok: false, problem: "Couldn't reach Linear." };
919
916
  }
920
- let body;
917
+ let raw;
921
918
  try {
922
- body = record(await response.json());
919
+ raw = await response.json();
923
920
  }
924
921
  catch {
925
922
  return { ok: false, problem: `Linear answered ${response.status} and it couldn't be read.` };
926
923
  }
927
- const errors = Array.isArray(body["errors"]) ? body["errors"].map(one => text(record(one)["message"])) : [];
924
+ const body = readLinearAnswer(raw);
925
+ const errors = (body.errors ?? []).map(one => one.message ?? "");
928
926
  if (response.status === 401 || errors.some(one => /authenticat|api key/i.test(one)))
929
927
  return { ok: false, problem: "Linear didn't accept the key. Add a new one on this flow's Triggers panel." };
930
928
  if (!response.ok || errors.length > 0)
931
929
  return { ok: false, problem: `Linear said: ${(errors[0] ?? `status ${response.status}`).slice(0, 160)}` };
932
- const nodes = record(record(record(body["data"])["issues"]))["nodes"];
933
- const issues = (Array.isArray(nodes) ? nodes : []).sort((a, b) => text(record(a)["updatedAt"]).localeCompare(text(record(b)["updatedAt"])));
930
+ const issues = (body.data?.issues?.nodes ?? []).sort((a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? ""));
934
931
  const items = issues.map(one => linearItem(config, one)).filter((one) => one !== null);
935
- return { ok: true, items, cursor: issues.map(one => text(record(one)["updatedAt"])).filter(one => one !== "").at(-1) ?? trigger.cursor };
932
+ return { ok: true, items, cursor: issues.map(one => one.updatedAt ?? "").filter(one => one !== "").at(-1) ?? trigger.cursor };
936
933
  }
937
934
  const header = (delivery, name) => { const value = delivery.headers[name]; return Array.isArray(value) ? value[0] ?? "" : value ?? ""; };
938
935
  function sameHex(expected, given) {
@@ -963,8 +960,8 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
963
960
  const signature = header(delivery, "x-hub-signature-256");
964
961
  if (secret === null || !sameHex(`sha256=${createHmac("sha256", secret).update(delivery.body).digest("hex")}`, signature))
965
962
  return { status: 401, said: "Signature doesn't match." };
966
- const event = header(delivery, "x-github-event"), body = record(payload), action = text(body["action"]);
967
- const repository = text(record(body["repository"])["full_name"]);
963
+ const event = header(delivery, "x-github-event"), body = readGithubEvent(payload), action = body.action ?? "";
964
+ const repository = body.repository?.full_name ?? "";
968
965
  if (event === "ping") {
969
966
  note("GitHub connected.");
970
967
  return { status: 200, said: "Connected." };
@@ -972,10 +969,10 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
972
969
  if (repository.toLowerCase() !== config.repo.toLowerCase())
973
970
  return { status: 202, said: "Not this repository." };
974
971
  // A label trigger takes the moment its label is added (or an issue opened with it); a plain one takes new issues.
975
- const labeledNow = action === "labeled" && config.label !== null && text(record(body["label"])["name"]).toLowerCase() === config.label.toLowerCase();
976
- const found = config.watch === "issues" && event === "issues" && (action === "opened" || labeledNow) ? githubItem(config, body["issue"], "issue")
977
- : config.watch === "pulls" && event === "pull_request" && ["opened", "ready_for_review", "labeled", "reopened"].includes(action) ? githubItem(config, body["pull_request"], "pull")
978
- : config.watch === "checks" && event === "workflow_run" && action === "completed" ? githubRunItem(config, body["workflow_run"]) : null;
972
+ const labeledNow = action === "labeled" && config.label !== null && (body.label?.name ?? "").toLowerCase() === config.label.toLowerCase();
973
+ const found = config.watch === "issues" && event === "issues" && (action === "opened" || labeledNow) ? githubItem(config, body.issue, "issue")
974
+ : config.watch === "pulls" && event === "pull_request" && ["opened", "ready_for_review", "labeled", "reopened"].includes(action) ? githubItem(config, body.pull_request, "pull")
975
+ : config.watch === "checks" && event === "workflow_run" && action === "completed" ? githubRunItem(config, body.workflow_run) : null;
979
976
  items = found === null ? [] : [found];
980
977
  }
981
978
  else if (config.kind === "linear") {
@@ -984,23 +981,26 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
984
981
  return { status: 401, said: "Paste Linear's signing secret on the flow's Triggers panel first." };
985
982
  if (!sameHex(createHmac("sha256", secret).update(delivery.body).digest("hex"), header(delivery, "linear-signature")))
986
983
  return { status: 401, said: "Signature doesn't match." };
987
- const body = record(payload), sent = Number(body["webhookTimestamp"]);
984
+ const body = readLinearEvent(payload), sent = Number(body.webhookTimestamp);
988
985
  if (!Number.isFinite(sent) || Math.abs(now.getTime() - sent) > 5 * 60_000)
989
986
  return { status: 401, said: "Too old." };
990
- if (text(body["type"]) !== "Issue" || !["create", "update"].includes(text(body["action"])))
987
+ if (body.type !== "Issue" || !["create", "update"].includes(body.action ?? ""))
991
988
  return { status: 202, said: "Ignored." };
992
989
  // A state trigger fires when the issue arrives in that state, not on every later edit there.
993
- const moved = text(body["action"]) === "create" || record(body["updatedFrom"])["stateId"] !== undefined || config.state === null;
994
- const found = moved ? linearItem(config, body["data"]) : null;
990
+ const moved = body.action === "create" || body.updatedFrom?.stateId !== undefined || config.state === null;
991
+ const found = moved ? linearItem(config, body.data) : null;
995
992
  items = found === null ? [] : [found];
996
993
  }
997
994
  else if (config.kind === "webhook") {
998
- const pick = (path) => path === null ? null : path.split(".").reduce((node, part) => record(node)[part], payload);
995
+ // Any JSON, kept as sent: the trigger's own paths are picked from it, and it is shown whole when they find no text.
996
+ const body = readWebhookPayload(payload);
997
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
998
+ const pick = (path) => path === null ? null : path.split(".").reduce((node, part) => record(node)[part], body);
999
999
  const title = pick(config.titleField ?? "title") ?? pick("summary") ?? pick("message");
1000
1000
  const bodyText = pick(config.bodyField ?? "description") ?? pick("body") ?? pick("text");
1001
1001
  const id = header(delivery, "x-request-id") || header(delivery, "x-delivery-id") || header(delivery, "idempotency-key") || createHash("sha256").update(delivery.body).digest("hex");
1002
1002
  items = [{ key: `hook:${id.slice(0, 120)}`, at: null, title: typeof title === "string" && title.trim() !== "" ? title : config.title,
1003
- description: `From a webhook:\n\n${typeof bodyText === "string" ? bodyText : JSON.stringify(payload, null, 2)}`.slice(0, 4000),
1003
+ description: `From a webhook:\n\n${typeof bodyText === "string" ? bodyText : JSON.stringify(body, null, 2)}`.slice(0, 4000),
1004
1004
  source: { kind: "webhook", label: "Webhook", url: null } }];
1005
1005
  }
1006
1006
  if (items.length === 0)
@@ -1081,13 +1081,14 @@ export function receiveFlowForm(store, token, fields, now) {
1081
1081
  const again = `<p><a href="${esc(FORM_PATH + token)}">Send another</a></p>`;
1082
1082
  const thanks = formHtml(config.label, `<p>Thanks — it's been sent.</p>${again}`);
1083
1083
  // A filled trap, or a page posted faster than a person could, gets thanks and makes nothing.
1084
- const shown = Number(fields.get("t"));
1085
- if ((fields.get("website") ?? "") !== "" || !Number.isFinite(shown) || now.getTime() - shown < 2000 || now.getTime() - shown > 86_400_000)
1084
+ const form = readFormSubmission(fields, config.questions.length);
1085
+ const shown = Number(form.t);
1086
+ if ((form.website ?? "") !== "" || !Number.isFinite(shown) || now.getTime() - shown < 2000 || now.getTime() - shown > 86_400_000)
1086
1087
  return { status: 200, html: thanks };
1087
1088
  const recent = store.handle.prepare("SELECT COUNT(*) AS n FROM flow_trigger_event WHERE trigger = ? AND key LIKE 'form:%' AND at > ?").get(trigger.id, new Date(now.getTime() - 3_600_000).toISOString());
1088
1089
  if (Number(recent?.["n"] ?? 0) >= FORM_CARDS_PER_HOUR)
1089
1090
  return { status: 429, html: formHtml(config.label, `<p>Too many sent in the last hour. Try again later.</p>`) };
1090
- const answers = config.questions.map((_question, index) => (fields.get(`a${index}`) ?? "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim().slice(0, index === 0 ? 200 : 3000));
1091
+ const answers = form.answers.map((answer, index) => (answer ?? "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim().slice(0, index === 0 ? 200 : 3000));
1091
1092
  if (answers[0] === "")
1092
1093
  return { status: 400, html: formHtml(config.label, `<p>Answer “${esc(config.questions[0])}” first.</p>${again}`) };
1093
1094
  const details = config.questions.slice(1).map((question, index) => answers[index + 1] === "" ? null : `${question}\n${answers[index + 1]}`).filter(one => one !== null).join("\n\n");
@@ -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. */