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
@@ -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
@@ -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.` };
@@ -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");
@@ -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';
package/dist/flows-cli.js CHANGED
@@ -10,7 +10,8 @@
10
10
  import { readFileSync, statSync, writeFileSync } from 'node:fs';
11
11
  import { resolve } from 'node:path';
12
12
  import { envelopeJson } from './envelope.js';
13
- import { addCardToFlow, advanceFlows, crossProjectProblem, flowDefinitionOf } from './flow-engine.js';
13
+ import { addCardToFlow, advanceFlows, crossProjectProblem, decideFlowCard, flowDefinitionOf } from './flow-engine.js';
14
+ import { chooseFlowCard } from './flow-send.js';
14
15
  import { saveScript } from './flow-scripts.js';
15
16
  import { exportFlow, fetchFlowFile, FLOW_FILE_MAX_BYTES, FlowFileError, importFlow, parseFlowFile, planFlowImport } from './flow-share.js';
16
17
  import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, switchOnStarter } from './flow-starters.js';
@@ -51,6 +52,10 @@ export const FLOWS_DESCRIPTORS = [
51
52
  { name: 'title', takesValue: true, meaning: "the card's title" },
52
53
  { name: 'description', takesValue: true, meaning: 'its details' },
53
54
  { name: 'zone', takesValue: true, meaning: 'a zone id or name' }] },
55
+ { action: 'card approve', synopsis: "approve a card waiting at a Person decides step (or pick --option at a Person chooses step), as the console's Approve does", mutation: 'identity-idempotent', positionals: ['flow', 'card'], flags: [...write,
56
+ { name: 'option', takesValue: true, meaning: "at a Person chooses step: the option's words or its number" }] },
57
+ { action: 'card send-back', synopsis: "send a waiting card back with a note, as the console's Send back (or a reply at a Person chooses step) does", mutation: 'identity-idempotent', positionals: ['flow', 'card'], flags: [...write,
58
+ { name: 'note', takesValue: true, meaning: 'what should change' }] },
54
59
  { action: 'export', synopsis: 'write a flow as a *.toolroll-flow.json file: zones, paths, trigger settings and scripts; never secrets, webhook addresses, names or cards', mutation: 'none', positionals: ['flow'], flags: [...read,
55
60
  { name: 'out', takesValue: true, meaning: 'write the file here (default: print it)' }] },
56
61
  { action: 'import', synopsis: 'make a flow from a flow file or a gist/GitHub https address; previews in plain words until --yes; triggers arrive off and scripts wait for approval', mutation: 'unkeyed', positionals: ['file'], flags: [...write, yes, repo,
@@ -407,6 +412,43 @@ export async function runFlowsCommand(positional, flags, context) {
407
412
  const card = store.getFlowCard(added.card);
408
413
  return ok({ applied: true, card: card === null ? { id: added.card } : cardOf(definition, card), said: added.said }, [`${added.said} Card #${added.card}.`]);
409
414
  }
415
+ if (action === 'card approve' || action === 'card send-back') {
416
+ // The console's own doors (decideFlowCard, chooseFlowCard): the same decider, visit and project checks, and the same card history.
417
+ const approving = action === 'card approve';
418
+ const note = text('note')?.trim() ?? null;
419
+ if (args[1] === undefined || (!approving && (note === null || note === '')))
420
+ return fail('usage', approving ? 'Use flows card approve <flow> <card> [--option <words|number>].' : 'Use flows card send-back <flow> <card> --note "<what should change>".', EXIT.usage);
421
+ const card = /^[1-9][0-9]{0,9}$/.test(args[1]) ? store.getFlowCard(Number(args[1])) : null;
422
+ if (card === null || card.flow !== flow.id)
423
+ return refuse(flow.repo, 'unknown-card', `Flow #${flow.id} has no card #${args[1]}. toolroll flows show ${flow.id} lists its cards.`);
424
+ const stage = definition.stages.find(one => one.id === card.stage);
425
+ let decided;
426
+ if (stage?.kind === 'choose') {
427
+ if (approving) {
428
+ const given = text('option')?.trim() ?? '';
429
+ const options = stage.options ?? [];
430
+ const choice = /^[1-9]$/.test(given) && Number(given) <= options.length ? Number(given) - 1 : options.findIndex(one => one.label.toLowerCase() === given.toLowerCase());
431
+ if (given === '' || choice < 0)
432
+ return refuse(flow.repo, 'choose-option', `${stage.title} asks for a choice: give --option with one of ${options.map((one, at) => `${at + 1} “${one.label}”`).join(', ')}.`, EXIT.usage);
433
+ decided = chooseFlowCard(store, { card: card.id, entry: card.entry, choice, label: options[choice].label, note: null, actor: who, where: 'the command line', repos: reachable, evidenceRoot: context.evidenceRoot }, now);
434
+ }
435
+ else
436
+ decided = chooseFlowCard(store, { card: card.id, entry: card.entry, choice: null, note, actor: who, where: 'the command line', repos: reachable, evidenceRoot: context.evidenceRoot }, now);
437
+ }
438
+ else if (stage?.kind === 'approval') {
439
+ if (text('option') !== null)
440
+ return fail('usage', '--option is for a Person chooses step; this card waits for a decision.', EXIT.usage);
441
+ decided = decideFlowCard(store, { card: card.id, decision: approving ? 'approve' : 'send-back', note, actor: who, repos: reachable, evidenceRoot: context.evidenceRoot, entry: card.entry, where: 'the command line' }, now);
442
+ }
443
+ else
444
+ return refuse(flow.repo, 'not-waiting', card.state === 'active' ? `Card #${card.id} isn't waiting for a decision; it's in ${stage?.title ?? card.stage}.` : `Card #${card.id} is ${card.state}; nothing is waiting on it.`);
445
+ if (!decided.ok)
446
+ return refuse(flow.repo, 'refused', decided.message);
447
+ record(flow.repo, 'accepted', `flow #${flow.id} card #${card.id} · ${stage.title}`);
448
+ settle(flow.repo);
449
+ const after = store.getFlowCard(card.id);
450
+ return ok({ applied: true, card: after === null ? { id: card.id } : cardOf(definition, after), said: decided.said }, [decided.said]);
451
+ }
410
452
  if (action === 'trigger add') {
411
453
  if (args[1] === undefined)
412
454
  return fail('usage', 'Use flows trigger add <flow> <json|file|->, like \'{"kind":"schedule","schedule":"daily 09:00","title":"Standup"}\'.', EXIT.usage);
package/dist/guides.js CHANGED
@@ -141,6 +141,8 @@ applies the console's rules exactly.
141
141
  \`flows trigger pause|resume|remove|check <id> <trigger>\`; \`flows script
142
142
  save --repo PATH --name <n> (--file <path in project> | --body <file>)
143
143
  --about "<line>"\`; \`flows card add <id> --title <t> [--zone <z>]\`;
144
+ \`flows card approve <id> <card>\` and \`flows card send-back <id> <card>
145
+ --note <text>\` only when the person asked for that decision;
144
146
  \`flows archive <id>\`.
145
147
  - create, edit, archive and trigger add answer with a preview
146
148
  (\`applied: false\`, \`terms\`) until \`--yes\`. Show the person the terms
@@ -16,7 +16,7 @@ export const MATE_CONTRACT = [
16
16
  "Investigate before reporting a limitation: when a tool refuses or something looks unsupported, read get_capabilities and the relevant skill (get_skills, then that skill's instructions) before telling the owner it can't be done. Then say what you checked, what is missing, and the one next step.",
17
17
  "Ask rarely: only when the answer changes the work and you cannot settle it from tools or project memory. Then use ask_owner: one question, 2-4 short options (Something else is added), shown as buttons after your reply; the tapped option comes back as the owner's next message. Keep your reply to one short line leading into it, and do not repeat the options in your text.",
18
18
  "propose_* drafts, never completes. Confirmation and execution approvals still apply. Never request credentials in chat. Read get_task currentExecution before acting; revisions retain task identity and old cards retain exact targets.",
19
- "The operator talks plainly and names work by what it is about ('the login page', 'that payout fix'), rarely by id. Use the current task when there is one; otherwise find it with list_tasks search, and if several fit, ask which by title. When they want work moved forward, read get_task and draft its next step: changes to a result still waiting on them → propose_review revise; guidance for work in progress → propose_steer; stopped → resume and failed → retry with propose_task_action; waiting for approval → show_control approval; new work, or more on something already completed → propose_task in the same project, naming the earlier task in its goal.",
19
+ "The operator talks plainly and names work by what it is about ('the login page', 'that payout fix'), rarely by id. Use the current task when there is one; otherwise find it with list_tasks search, and if several fit, ask which by title. When they want work moved forward, read get_task and draft its next step: changes to a result still waiting on them → propose_review revise; guidance for work in progress → propose_steer; stopped → resume and failed → retry with propose_task_action; waiting for approval, or 'approve X' → offer_approval for exactly that plan or flow card, which sends its own Approve buttons to the owner's chat (their tap approves it; you never do), and only when it says why not, say that in one line and show_control approval; new work, or more on something already completed → propose_task in the same project, naming the earlier task in its goal.",
20
20
  "Talk like a colleague in everyday words: task titles, 'the changes', 'the checks', 'try again'. Never say execution, run, scope, digest, dispatch or lane to the operator; say what happens next and what they can do.",
21
21
  "Read get_result for the exact execution/run; page nextFeedbackOffset. propose_review revise requests changes; note saves feedback. Use read notes only; clarify ambiguous intent. Ready is a saved result, not a reviewer stage. Historical missing assessments never require rerunning work. Failed checks and missing files remain visible; completion never deploys or changes checks.",
22
22
  "For screenshots, call get_result_images for that exact execution and run. On Telegram say they follow, never that they were delivered; elsewhere name result and image count. Limit 8/reply. If nextImageOffset exists, name remaining count; call it again with that offset or with the image ids it listed when asked. Select a newer revision explicitly; never describe unselected images or expose paths.",