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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.43 — 2026-10-06
4
+
5
+ - **Approve from chat and the CLI.** A plan waiting on you and a flow's
6
+ approval or choose step get **Approve** and **Send back** buttons in your
7
+ paired Telegram chat, two taps each, recorded like a console decision. Ask
8
+ the lead to "approve X" and it answers with those buttons; your tap is
9
+ still the approval. `toolroll flows card approve <flow> <card>` and
10
+ `toolroll flows card send-back <flow> <card> --note …` do the same from the
11
+ terminal with your login.
12
+ - **Chat approval can stay on.** `toolroll chat-approval on|off [--repo <p>]`
13
+ turns approving from chat on for one project or all of them, with your
14
+ password to turn it on; off is instant and off for all really is all. It no
15
+ longer lapses with a weekly mode. Plans that widen permissions, go over a
16
+ cap or touch protected paths still go to the console.
17
+ - **Old cards point onward.** A card in a past conversation says where the
18
+ item went and links to it when you can open it.
19
+ - Still to come: Slack, Discord and Teams buttons, a Settings screen for chat
20
+ approval, and sending a plan back from chat.
21
+
22
+ ## 0.9.42 — 2026-10-06
23
+
24
+ - **Zod revamp, waves 3 and 4.** What comes into Toolroll from outside and
25
+ what it reads back from disk now goes through one schema each in
26
+ `src/contracts/`: console forms and JSON, CLI `--json` output, webhook,
27
+ GitHub, Linear, email and form payloads, OAuth replies and tool specs,
28
+ settings, every JSON column in the database, update and recovery journals,
29
+ and evidence files. All of it is read as leniently as before. Replayed
30
+ against real data with 0 differences: 655 runs and 984 evidence folders,
31
+ 5,158 database rows, 7 tool connections. Console JSON and CLI `--json` bytes
32
+ are unchanged. Console API and CLI input are partly done; what remains is
33
+ listed in `docs/plans/zod-revamp.md`.
34
+ - **Changed on purpose.** Recipe refusals start with the field's path. An
35
+ unknown provider is refused as `provider: unknown provider`, without
36
+ repeating what was typed. A sign-in that answers with an empty token says
37
+ it couldn't be reached instead of crashing. An update journal missing its
38
+ core fields (no release since 0.8.0 writes one) is refused rather than read.
39
+
3
40
  ## 0.9.41 — 2026-10-06
4
41
 
5
42
  - **A read-only PostHog connection works for research.** PostHog adds
@@ -1347,7 +1347,7 @@ function runFlowAction(store, payload, actor, repos, root, now) {
1347
1347
  return chosen.ok ? { ok: true, said: chosen.said, card: card.id } : { ok: false, message: chosen.message };
1348
1348
  })()
1349
1349
  : (() => {
1350
- const decided = decideFlowCard(store, { card: card.id, decision: payload.operation === "flow_card_approve" ? "approve" : "send-back", note: typeof req["note"] === "string" ? req["note"] : null, actor, repos, ...(root === undefined ? {} : { evidenceRoot: root }) }, now);
1350
+ const decided = decideFlowCard(store, { card: card.id, decision: payload.operation === "flow_card_approve" ? "approve" : "send-back", note: typeof req["note"] === "string" ? req["note"] : null, actor, repos, where: "Chat", ...(root === undefined ? {} : { evidenceRoot: root }) }, now);
1351
1351
  return decided.ok ? { ok: true, said: decided.said, card: card.id } : { ok: false, message: decided.message };
1352
1352
  })();
1353
1353
  if (!acted.ok)
@@ -0,0 +1,45 @@
1
+ import type { Store } from "./store.js";
2
+ /** The all-projects row's scope. */
3
+ export declare const ALL_PROJECTS = "*";
4
+ export type ChatApprovalLimits = {
5
+ fullAccess: boolean;
6
+ capMicrousd: number | null;
7
+ };
8
+ export type ChatApprovalSetting = ChatApprovalLimits & {
9
+ approver: string;
10
+ scope: string;
11
+ enabled: boolean;
12
+ generation: number;
13
+ digest: string;
14
+ updatedBy: string;
15
+ updatedAt: string;
16
+ };
17
+ /** This person's rows: all projects first, then each project's. */
18
+ export declare function chatApprovalSettings(store: Store, approver: string): ChatApprovalSetting[];
19
+ /** The limits in plain words, said once: what a yes in chat may start. */
20
+ export declare function chatApprovalWords(limits: ChatApprovalLimits): string;
21
+ /** Whether this person's chat may approve on this project now, under their lasting setting: which row, and its limits. */
22
+ export declare function effectiveChatApproval(store: Store, repo: string, approver: string): {
23
+ ok: true;
24
+ setting: ChatApprovalSetting;
25
+ limits: ChatApprovalLimits;
26
+ } | {
27
+ ok: false;
28
+ why: string;
29
+ };
30
+ export type ChatApprovalChange = {
31
+ approver: string;
32
+ scope: string;
33
+ enabled: boolean;
34
+ limits?: ChatApprovalLimits;
35
+ via: string;
36
+ };
37
+ /** Save and ledger the setting; all-projects off also disables every saved project row for this owner.
38
+ * The caller has proved who is asking, and for "on" the password. */
39
+ export declare function setChatApproval(store: Store, change: ChatApprovalChange, now: Date): {
40
+ ok: true;
41
+ said: string;
42
+ } | {
43
+ ok: false;
44
+ message: string;
45
+ };
@@ -0,0 +1,81 @@
1
+ /** Approving from chat as a lasting owner setting. An approver turns it on for all their projects or for one, with the
2
+ * limits they agree to (whether a plan may ask for full access, and the most one attempt may cost); a project's own
3
+ * row outranks the all-projects one, and an explicit "off" on a project keeps it off there. Turning it off for all
4
+ * projects also disables that owner's saved project rows. It is off until turned on.
5
+ *
6
+ * It never lapses by date, but it ends with the account: a reset password, revoked access or a lost project ends it.
7
+ * Turning it on or changing its limits takes the password; turning it off is one step. Every change is a ledger line.
8
+ *
9
+ * What the setting allows is only that the owner's own two taps in their paired chat count as their yes. Every other
10
+ * rule still applies at the offer and again at the tap (chat-decide.ts planInChat): protected paths, separation of
11
+ * duties, organisation policy, written-for-you plans, unresolved routes, and the limits here. */
12
+ import { createHash } from "node:crypto";
13
+ /** The all-projects row's scope. */
14
+ export const ALL_PROJECTS = "*";
15
+ const money = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
16
+ function digestOf(approver, scope, limits, generation) {
17
+ return createHash("sha256").update(JSON.stringify({ v: 1, approver, scope, fullAccess: limits.fullAccess, capMicrousd: limits.capMicrousd, generation })).digest("hex");
18
+ }
19
+ function readRow(row) {
20
+ return {
21
+ approver: String(row["approver"]), scope: String(row["scope"]), enabled: Number(row["enabled"]) === 1, fullAccess: Number(row["full_access"]) === 1,
22
+ capMicrousd: row["attempt_cap_microusd"] == null ? null : Number(row["attempt_cap_microusd"]), generation: Number(row["generation"]),
23
+ digest: String(row["digest"]), updatedBy: String(row["updated_by"]), updatedAt: String(row["updated_at"]),
24
+ };
25
+ }
26
+ /** This person's rows: all projects first, then each project's. */
27
+ export function chatApprovalSettings(store, approver) {
28
+ return store.handle.prepare("SELECT * FROM chat_approval_setting WHERE approver = ? ORDER BY scope = '*' DESC, scope").all(approver).map(row => readRow(row));
29
+ }
30
+ /** The limits in plain words, said once: what a yes in chat may start. */
31
+ export function chatApprovalWords(limits) {
32
+ return `${limits.fullAccess ? "plans may ask for full access" : "plans that ask for full access open in Toolroll"} · ${limits.capMicrousd === null ? "any attempt limit" : `attempts up to ${money(limits.capMicrousd)}`}`;
33
+ }
34
+ /** Whether this person's chat may approve on this project now, under their lasting setting: which row, and its limits. */
35
+ export function effectiveChatApproval(store, repo, approver) {
36
+ const project = store.handle.prepare("SELECT * FROM chat_approval_setting WHERE approver = ? AND scope = ?").get(approver, repo);
37
+ const all = store.handle.prepare("SELECT * FROM chat_approval_setting WHERE approver = ? AND scope = ?").get(approver, ALL_PROJECTS);
38
+ const row = project ?? all;
39
+ const setting = row === undefined ? null : readRow(row);
40
+ if (setting === null || !setting.enabled)
41
+ return { ok: false, why: "Approving from chat isn't turned on for this project." };
42
+ const account = store.accountOf(approver);
43
+ if (account === null || account.revokedAt !== null || account.role !== "approver" || account.generation !== setting.generation || !store.accountCanAccess(approver, repo)) {
44
+ return { ok: false, why: "Your access changed since you turned on approving from chat. Turn it on again in Settings." };
45
+ }
46
+ if (setting.digest !== digestOf(setting.approver, setting.scope, setting, setting.generation))
47
+ return { ok: false, why: "Your chat approval setting can't be read. Turn it on again in Settings." };
48
+ return { ok: true, setting, limits: { fullAccess: setting.fullAccess, capMicrousd: setting.capMicrousd } };
49
+ }
50
+ /** Save and ledger the setting; all-projects off also disables every saved project row for this owner.
51
+ * The caller has proved who is asking, and for "on" the password. */
52
+ export function setChatApproval(store, change, now) {
53
+ const account = store.accountOf(change.approver);
54
+ if (account === null || account.revokedAt !== null || account.role !== "approver")
55
+ return { ok: false, message: "Only an approver can approve from chat." };
56
+ if (change.scope !== ALL_PROJECTS && !store.accountCanAccess(change.approver, change.scope))
57
+ return { ok: false, message: "That isn't one of your projects." };
58
+ const limits = change.limits ?? { fullAccess: false, capMicrousd: null };
59
+ if (limits.capMicrousd !== null && (!Number.isSafeInteger(limits.capMicrousd) || limits.capMicrousd < 0))
60
+ return { ok: false, message: "Give the attempt limit in dollars, like 5." };
61
+ const where = change.scope === ALL_PROJECTS ? "all your projects" : change.scope;
62
+ store.transact(() => {
63
+ const settings = [{ scope: change.scope, limits }];
64
+ if (change.scope === ALL_PROJECTS && !change.enabled) {
65
+ for (const setting of chatApprovalSettings(store, change.approver)) {
66
+ if (setting.scope !== ALL_PROJECTS)
67
+ settings.push({ scope: setting.scope, limits: setting });
68
+ }
69
+ }
70
+ const save = store.handle.prepare(`INSERT INTO chat_approval_setting (approver, scope, enabled, full_access, attempt_cap_microusd, generation, digest, updated_by, updated_at)
71
+ VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT (approver, scope) DO UPDATE SET enabled = excluded.enabled, full_access = excluded.full_access,
72
+ attempt_cap_microusd = excluded.attempt_cap_microusd, generation = excluded.generation, digest = excluded.digest, updated_by = excluded.updated_by, updated_at = excluded.updated_at`);
73
+ for (const { scope, limits } of settings) {
74
+ save.run(change.approver, scope, change.enabled ? 1 : 0, limits.fullAccess ? 1 : 0, limits.capMicrousd, account.generation, digestOf(change.approver, scope, limits, account.generation), change.approver, now.toISOString());
75
+ store.recordAction({ at: now.toISOString(), actor: change.approver, repo: scope === ALL_PROJECTS ? null : scope, taskId: null, runId: null,
76
+ action: "chat approval setting", outcome: change.enabled ? "on" : "off", source: "request",
77
+ detail: `${scope === ALL_PROJECTS ? "all projects" : "this project"}${change.enabled ? ` · ${chatApprovalWords(limits)}` : ""} · via ${change.via}` });
78
+ }
79
+ });
80
+ return { ok: true, said: change.enabled ? `Approving from chat is on for ${where}: ${chatApprovalWords(limits)}.` : `Approving from chat is off for ${where}.` };
81
+ }
@@ -556,6 +556,11 @@ export const CHAT_ACTION_PARITY = {
556
556
  how: "Shared read-only acceptance packet: exact result, criterion states, gate, reviewer findings, caveats and recorded human acceptance. Screenshot files use get_result_images; marking complete is a propose_action result_accept confirmed behind the phone's own yes/cancel challenge or on the signed-in console.",
557
557
  gap: "A physical-phone completion has not been exercised.",
558
558
  },
559
+ offer_approval: {
560
+ support: "direct",
561
+ how: "Pushes the plan's or flow card's own decision card to the owner's paired chat: Approve arms Yes and Cancel, and only the owner's Yes decides, through sealScopeApproval (as a chat-origin approval) or decideFlowCard. Outside the owner's chat-approval setting or a decision's rules it answers why, and the lead opens the console control.",
562
+ gap: "A flow card's Send back, Edit and a Person chooses step act from the pushed card; a plan's Send back stays in the console. Fixture proof only, no physical-phone rendering.",
563
+ },
559
564
  show_control: {
560
565
  support: "handoff",
561
566
  how: "The card names the control and the task, with one url button to that exact console control when a trusted https console-url is configured; the button opens the signed-in console and acts on nothing.",
@@ -4,14 +4,15 @@
4
4
  * and each button is one opaque token bound to the exact result, receipt, plan or commit the card showed: a card a
5
5
  * newer result, a finished task or a changed plan has overtaken says so and acts on nothing.
6
6
  *
7
- * Approving a plan or merging needs the password, which never goes in chat, so they act in chat only under a
8
- * repository's signed mode that carries the chatApprove term, for the signer's own chat; otherwise, and for a plan
9
- * that widens permissions, exceeds the mode's budget or touches protected paths, the card keeps its link.
7
+ * Approving a plan or merging needs the password, which never goes in chat, so they act in chat only where the owner
8
+ * turned approving from chat on (their lasting setting, chat-approval.ts, or a signed mode's chatApprove term), for
9
+ * their own chat; otherwise, and for a plan that widens permissions, exceeds the limit or touches protected paths, the
10
+ * card keeps its link.
10
11
  *
11
12
  * Channel-agnostic: Telegram renders these today, and Slack, Discord and Teams reach the same acts through the same
12
13
  * tokens (the `channel` column). Every act runs through the door the console uses. */
13
14
  import { DECIDE_ACTS } from "./contracts/chat-callback-rows.js";
14
- import { type ModeTerms } from "./modes.js";
15
+ import { type ChatApprovalLimits } from "./chat-approval.js";
15
16
  import { type VerifiedApprover } from "./principal.js";
16
17
  import type { Scope } from "./scope.js";
17
18
  import type { Store } from "./store.js";
@@ -77,11 +78,14 @@ export type DecideOffer = {
77
78
  text: string | null;
78
79
  rows: Array<Array<ActSpec | DecideLink>>;
79
80
  };
80
- /** The live mode on this repository, when it lets this person's chat approve: the term signed, by them. */
81
+ /** What lets this person's chat approve on this repository now: their lasting chat-approval setting (chat-approval.ts),
82
+ * or else a live signed mode carrying the chatApprove term, signed by them. `digest` names that authority for the
83
+ * ledger; the yes itself is the person's tap and is sealed as theirs, so it doesn't lapse when the authority does. */
81
84
  export declare function chatApproveMode(store: Store, repo: string | null, approver: string, now: Date): {
82
85
  ok: true;
83
86
  digest: string;
84
- terms: ModeTerms;
87
+ limits: ChatApprovalLimits;
88
+ source: "setting" | "mode";
85
89
  } | {
86
90
  ok: false;
87
91
  why: string;
@@ -93,6 +97,7 @@ export declare function permissionPlainWords(profile: Scope["profile"] | null |
93
97
  export declare function planInChat(store: Store, taskId: string, approver: string, now: Date, root?: string): {
94
98
  ok: true;
95
99
  modeDigest: string;
100
+ source: "setting" | "mode";
96
101
  digest: string;
97
102
  scope: Scope;
98
103
  } | {
@@ -103,6 +108,7 @@ export declare function planInChat(store: Store, taskId: string, approver: strin
103
108
  export declare function mergeInChat(store: Store, taskId: string, run: number, approver: string, now: Date): {
104
109
  ok: true;
105
110
  modeDigest: string;
111
+ source: "setting" | "mode";
106
112
  head: string;
107
113
  } | {
108
114
  ok: false;
@@ -160,6 +166,12 @@ export type ChatMerge = {
160
166
  };
161
167
  /** The links a card keeps once its acts are spent or stale: where to look, never a token. */
162
168
  export declare function linksFor(target: DecideTarget): DecideButton[][];
169
+ /** Where a plan card's task is now, for a card whose buttons no longer act: the family's current version, named only
170
+ * inside this person's projects, and linked only while it still waits on them. Never a live button. */
171
+ export declare function planWhereNow(store: Store, taskId: string, who: VerifiedApprover, now: Date, root?: string): {
172
+ said: string;
173
+ rows: DecideButton[][];
174
+ };
163
175
  /**
164
176
  * One tap on a decide button, inside the caller's transaction. Null: not this module's token. The seat must be the
165
177
  * token's own (person, chat, message); a spent, expired or foreign button acts on nothing.
@@ -4,9 +4,10 @@
4
4
  * and each button is one opaque token bound to the exact result, receipt, plan or commit the card showed: a card a
5
5
  * newer result, a finished task or a changed plan has overtaken says so and acts on nothing.
6
6
  *
7
- * Approving a plan or merging needs the password, which never goes in chat, so they act in chat only under a
8
- * repository's signed mode that carries the chatApprove term, for the signer's own chat; otherwise, and for a plan
9
- * that widens permissions, exceeds the mode's budget or touches protected paths, the card keeps its link.
7
+ * Approving a plan or merging needs the password, which never goes in chat, so they act in chat only where the owner
8
+ * turned approving from chat on (their lasting setting, chat-approval.ts, or a signed mode's chatApprove term), for
9
+ * their own chat; otherwise, and for a plan that widens permissions, exceeds the limit or touches protected paths, the
10
+ * card keeps its link.
10
11
  *
11
12
  * Channel-agnostic: Telegram renders these today, and Slack, Discord and Teams reach the same acts through the same
12
13
  * tokens (the `channel` column). Every act runs through the door the console uses. */
@@ -17,6 +18,7 @@ import { chatControlHref, chatResultHref } from "./chat-controls.js";
17
18
  import { applyChatTaskAction, chatTaskStamp } from "./chat-task-actions.js";
18
19
  import { chatTitle } from "./chat-voice.js";
19
20
  import { readVerifiedArtifact } from "./evidence.js";
21
+ import { effectiveChatApproval } from "./chat-approval.js";
20
22
  import { modeTermsFromJson } from "./modes.js";
21
23
  import { parseExecutionPlanDocument } from "./plan.js";
22
24
  import { verifyApproverStanding } from "./principal.js";
@@ -45,15 +47,22 @@ const sha = (text) => createHash("sha256").update(text).digest("hex");
45
47
  const isAct = (one) => "act" in one;
46
48
  const title = (store, taskId) => phoneText(chatTitle(store, taskId), 60);
47
49
  // ---- the chatApprove term ----------------------------------------------------------
48
- /** The live mode on this repository, when it lets this person's chat approve: the term signed, by them. */
50
+ /** What lets this person's chat approve on this repository now: their lasting chat-approval setting (chat-approval.ts),
51
+ * or else a live signed mode carrying the chatApprove term, signed by them. `digest` names that authority for the
52
+ * ledger; the yes itself is the person's tap and is sealed as theirs, so it doesn't lapse when the authority does. */
49
53
  export function chatApproveMode(store, repo, approver, now) {
50
- const mode = repo === null ? null : store.activeMode(repo, now);
54
+ if (repo === null)
55
+ return { ok: false, why: "Approving from chat isn't turned on for this project." };
56
+ const setting = effectiveChatApproval(store, repo, approver);
57
+ if (setting.ok)
58
+ return { ok: true, digest: setting.setting.digest, limits: setting.limits, source: "setting" };
59
+ const mode = store.activeMode(repo, now);
51
60
  const terms = mode === null ? null : modeTermsFromJson(mode.termsJson);
52
61
  if (mode === null || terms === null || !terms.chatApprove)
53
- return { ok: false, why: "Approving from chat isn't turned on for this project." };
62
+ return setting;
54
63
  if (mode.signedBy !== approver || !store.accountCanAccess(approver, repo))
55
64
  return { ok: false, why: "Only the person who signed this project's mode can approve from chat." };
56
- return { ok: true, digest: mode.digest, terms };
65
+ return { ok: true, digest: mode.digest, limits: { fullAccess: terms.permissionDefault === "escalated", capMicrousd: terms.perAttemptBudgetMicrousd }, source: "mode" };
57
66
  }
58
67
  /** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
59
68
  export function permissionPlainWords(profile) {
@@ -102,11 +111,12 @@ export function planInChat(store, taskId, approver, now, root) {
102
111
  return { ok: false, why: "Agents compete on this task, so you approve it in Toolroll." };
103
112
  if (scope.profileState === "unresolved" || scope.routeEra == null || (scope.termsProblem ?? null) !== null)
104
113
  return { ok: false, why: "This plan can't say exactly which agents run, so you approve it in Toolroll." };
105
- if (fullAccess(scope.profile) && mode.terms.permissionDefault !== "escalated")
106
- return { ok: false, why: "This plan asks for more access than your mode allows." };
107
- const cap = mode.terms.perAttemptBudgetMicrousd;
114
+ const yours = mode.source === "setting" ? "your chat approval setting" : "your mode";
115
+ if (fullAccess(scope.profile) && !mode.limits.fullAccess)
116
+ return { ok: false, why: `This plan asks for more access than ${yours} allows.` };
117
+ const cap = mode.limits.capMicrousd;
108
118
  if (cap !== null && (scope.budgetMicrousd === null || scope.budgetMicrousd > cap))
109
- return { ok: false, why: `This plan has ${scope.budgetMicrousd === null ? "no attempt limit" : `a ${money(scope.budgetMicrousd)} attempt limit`}, more than your mode's ${money(cap)}.` };
119
+ return { ok: false, why: `This plan has ${scope.budgetMicrousd === null ? "no attempt limit" : `a ${money(scope.budgetMicrousd)} attempt limit`}, more than ${yours === "your mode" ? "your mode's" : "your chat approval limit of"} ${money(cap)}.` };
110
120
  if (store.approvalGate(taskId, approver, "mode").verdict !== "seal")
111
121
  return { ok: false, why: "This plan touches protected work, so two people approve it in Toolroll." };
112
122
  if (store.scopePolicyRefusal(taskId) !== null)
@@ -137,7 +147,7 @@ export function planInChat(store, taskId, approver, now, root) {
137
147
  if (!verified)
138
148
  return { ok: false, why: "The saved plan can't be verified here, so you approve it in Toolroll." };
139
149
  }
140
- return { ok: true, modeDigest: mode.digest, scope, digest: sha(`${scope.digest}:${plan?.sha256 ?? ""}`) };
150
+ return { ok: true, modeDigest: mode.digest, source: mode.source, scope, digest: sha(`${scope.digest}:${plan?.sha256 ?? ""}`) };
141
151
  }
142
152
  /** A ready pull request this person's chat may merge now, bound to the exact commit. */
143
153
  export function mergeInChat(store, taskId, run, approver, now) {
@@ -153,7 +163,7 @@ export function mergeInChat(store, taskId, run, approver, now) {
153
163
  const mode = chatApproveMode(store, ref.repo, approver, now);
154
164
  if (!mode.ok)
155
165
  return mode;
156
- return { ok: true, modeDigest: mode.digest, head: publication.headSha };
166
+ return { ok: true, modeDigest: mode.digest, source: mode.source, head: publication.headSha };
157
167
  }
158
168
  /** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. On Telegram the yes's
159
169
  * terms come second, under the title, and the steps are fewer: the full plan is behind Edit. */
@@ -380,6 +390,27 @@ export function linksFor(target) {
380
390
  case "failed": return [[{ label: "Open task", link: { label: "Open task", path: chatControlHref("task", target.taskId) } }]];
381
391
  }
382
392
  }
393
+ /** Where a plan card's task is now, for a card whose buttons no longer act: the family's current version, named only
394
+ * inside this person's projects, and linked only while it still waits on them. Never a live button. */
395
+ export function planWhereNow(store, taskId, who, now, root) {
396
+ const family = store.taskFamilyOf(taskId, who.repos, false);
397
+ if (family === null)
398
+ return { said: "It has moved on.", rows: [] };
399
+ const current = family.current.id;
400
+ const name = title(store, current);
401
+ const state = store.getTask(current)?.state;
402
+ if (state === "done" || state === "cancelled") {
403
+ const assignment = assignmentOf(store, current, now, { principal: "operator", repos: who.repos }, root);
404
+ if (assignment?.state !== "ready-to-check")
405
+ return { said: state === "cancelled" ? `"${name}" was cancelled.` : `"${name}" is finished.`, rows: [] };
406
+ return { said: `"${name}" has a result waiting for you.`, rows: [[{ label: "Open the task", link: { label: "Open the task", path: chatControlHref("task", current) } }]] };
407
+ }
408
+ const scope = store.getScope(current);
409
+ if (scope !== null && !(scope.approvedDigest != null && scope.approvedDigest === scope.digest) && store.lookupRef(current)?.plan !== "requested") {
410
+ return { said: `"${name}" waits for your approval${current === taskId ? "" : " (a newer version)"}.`, rows: [[{ label: "Review & start", link: { label: "Review & start", path: chatControlHref("approval", current) } }]] };
411
+ }
412
+ return { said: `"${name}" is under way.`, rows: [] };
413
+ }
383
414
  const QUESTION_STARTS = ["Accept and finish \"", "Retry \"", "Approve and start \"", "Merge \""];
384
415
  /** The card's words without a question this module appended. */
385
416
  function cardBody(shown) {
@@ -417,14 +448,16 @@ export function applyDecideTap(store, seat, input) {
417
448
  store.handle.prepare("UPDATE chat_decide_action SET consumed_at = ? WHERE token = ? AND consumed_at IS NULL").run(now.toISOString(), row.token);
418
449
  retireDecideTokens(store, seat.channel, seat.chat, input.message, now);
419
450
  };
451
+ // A plan's card names where its task is now (its newest version, or finished), and links it only while it waits.
452
+ const here = target.kind === "plan" ? planWhereNow(store, row.taskId, who, now, input.root) : null;
420
453
  const stale = (why) => {
421
454
  spend();
422
- return { ack: why, edit: { text: `${body}\n\n${why} Nothing was done.`, rows: linksFor(target), tokens: [] } };
455
+ return { ack: why, edit: { text: `${body}\n\n${why} Nothing was done.${here === null || here.rows.length > 0 && why.startsWith("The plan changed") ? "" : ` ${here.said}`}`, rows: here?.rows ?? linksFor(target), tokens: [] } };
423
456
  };
424
- // An expired button: the card says so and keeps its links, so it is never a dead end.
457
+ // An expired button: the card says so and where the work is now, so it is never a dead end; the old button never acts.
425
458
  if (row.expiresAt <= now.toISOString()) {
426
459
  spend();
427
- return { ack: "That button expired.", edit: { text: `${body}\n\nThat button expired. Nothing was done.`, rows: linksFor(target), tokens: [] } };
460
+ return { ack: "That button expired.", edit: { text: `${body}\n\nThat button expired. Nothing was done.${here === null ? "" : ` ${here.said}`}`, rows: here?.rows ?? linksFor(target), tokens: [] } };
428
461
  }
429
462
  if (row.phase === "cancel") {
430
463
  spend();
@@ -475,17 +508,18 @@ export function applyDecideTap(store, seat, input) {
475
508
  const plan = planInChat(store, row.taskId, who.name, now, input.root);
476
509
  if (!plan.ok)
477
510
  return notDone(plan.why);
478
- if (!store.sealScopeApproval(row.taskId, who.name, now, {}, { kind: "mode", modeDigest: plan.modeDigest }))
511
+ // The owner's own yes, sealed as theirs ("chat"), never as a mode's: it stands like the console's after the mode or setting ends.
512
+ if (!store.sealScopeApproval(row.taskId, who.name, now, {}, { kind: "chat", modeDigest: plan.modeDigest }))
479
513
  return notDone("the plan couldn't be approved from chat. Open it in Toolroll.");
480
514
  store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: "plan approved in chat", outcome: "approved",
481
- source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · mode ${plan.modeDigest}` });
482
- return done("✓ Approved under your chat approval mode. Work starts when a worker is free.");
515
+ source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · ${plan.source} ${plan.modeDigest}` });
516
+ return done("✓ Approved. Work starts when a worker is free.");
483
517
  }
484
518
  case "merge": {
485
519
  const merge = mergeInChat(store, row.taskId, row.run ?? 0, who.name, now);
486
520
  if (!merge.ok)
487
521
  return notDone(merge.why);
488
- const via = `via ${seat.channel} · chat binding #${seat.binding} · mode ${merge.modeDigest} · ${merge.head.slice(0, 12)}`;
522
+ const via = `via ${seat.channel} · chat binding #${seat.binding} · ${merge.source} ${merge.modeDigest} · ${merge.head.slice(0, 12)}`;
489
523
  // The approval is ledgered with the tap, so a bridge that stops before GitHub answers still leaves it on record;
490
524
  // what GitHub then did is its own line (recordChatMerge).
491
525
  store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: row.run ?? null, action: "merge approved in chat", outcome: "approved",
package/dist/chat-flow.js CHANGED
@@ -190,7 +190,7 @@ export function applyChatFlowTap(options, event, binding, token, repos, now) {
190
190
  }
191
191
  const kind = String(action["action"]);
192
192
  if (kind === "approve") {
193
- const decided = decideFlowCard(store, { card, decision: "approve", note: null, actor: binding.approver, repos, entry }, now);
193
+ const decided = decideFlowCard(store, { card, decision: "approve", note: null, actor: binding.approver, repos, entry, where: options.label }, now);
194
194
  if (!decided.ok) {
195
195
  say(decided.message);
196
196
  return true;
@@ -441,7 +441,7 @@ export function answerChatFlowPrompt(options, event, binding, input, repos, now)
441
441
  state.plan(event.id, decisionParts(`${waiting.flow.name}: your version of the draft for “${waiting.card.title}”\n\n${kept}\n\nApprove to send it as written.`, { card, entry, actions: actionsFor(fresh) }, { label: "Open", path: flowCardHref(waiting.flow.id, card) }), now);
442
442
  return true;
443
443
  }
444
- const decided = decideFlowCard(store, { card, decision: "send-back", note: said, actor: binding.approver, repos, entry }, now);
444
+ const decided = decideFlowCard(store, { card, decision: "send-back", note: said, actor: binding.approver, repos, entry, where: options.label }, now);
445
445
  if (!decided.ok) {
446
446
  close();
447
447
  say(decided.message);
package/dist/cli.js CHANGED
@@ -17,7 +17,9 @@ import { fileURLToPath, pathToFileURL } from "node:url";
17
17
  import { configPath, loadRepos } from "./repos.js";
18
18
  import { addedWords, admitProject, commandLineActor, consoleRunning, proveProjectRoot, registryBeside, releaseProject } from "./project-admission.js";
19
19
  import { envValue } from "./names.js";
20
- import { CAPABILITIES, ENVELOPE_VERSION, capturedEnvelope, envelopeJson, onEnvelopeCaptured, resetCapturedEnvelope } from "./envelope.js";
20
+ import { CAPABILITIES, ENVELOPE_VERSION, capturedEnvelope, onEnvelopeCaptured, resetCapturedEnvelope } from "./envelope.js";
21
+ // Every envelope is checked against its command's schema (logged, never refused), then serialized unchanged.
22
+ import { checkedEnvelopeJson as envelopeJson } from "./contracts/cli.js";
21
23
  import { applyClaudeCodeInstall, applyInstall, contextBlock, defaultClaudeCodeSkillDir, planClaudeCodeInstall, planInstall, } from "./skills.js";
22
24
  import { GUIDES, guideNamed } from "./guides.js";
23
25
  import { parseGithubRepo, previewGithubRepo, cloneGithubRepo, isLargeRepo } from "./onboard.js";
@@ -251,7 +253,7 @@ export const OPERATE_COMMANDS = new Set([
251
253
  "run",
252
254
  "routine",
253
255
  "flows",
254
- "config", "chat", "proposals", "mode", "people", "keys",
256
+ "config", "chat", "proposals", "mode", "chat-approval", "people", "keys",
255
257
  "setup",
256
258
  "verify",
257
259
  "intake",
@@ -1,29 +1,7 @@
1
1
  export type { CodingSession, CodingStatus } from './contracts/coding-workspace.js';
2
2
  import type { CodingSession } from './contracts/coding-workspace.js';
3
- export type CodingItem = {
4
- id: string;
5
- type: string;
6
- text: string;
7
- status: string | null;
8
- clientId?: string;
9
- };
10
- export type CodingQuestion = {
11
- id: string;
12
- header: string;
13
- question: string;
14
- options: {
15
- label: string;
16
- description: string;
17
- }[];
18
- };
19
- export type CodingRequest = {
20
- id: string;
21
- kind: 'command' | 'files' | 'questions';
22
- method: string;
23
- title: string;
24
- detail: string;
25
- questions: CodingQuestion[];
26
- };
3
+ export type { CodingItem, CodingQuestion, CodingRequest } from './contracts/coding-activity.js';
4
+ import type { CodingItem, CodingRequest } from './contracts/coding-activity.js';
27
5
  export type CodingSnapshot = {
28
6
  session: CodingSession;
29
7
  items: CodingItem[];
@@ -10,6 +10,7 @@ import { processMayBeAlive } from './process-liveness.js';
10
10
  import { createCodexCodingProvider } from './coding-provider.js';
11
11
  import { verifyCodingContext } from './coding-context.js';
12
12
  import { codingSessionDocument, parseCodingSessionDocument } from './contracts/coding-workspace.js';
13
+ import { readCodingCustodyWitness, readCodingItem, readCodingRequest, readCodingRpcId } from './contracts/coding-activity.js';
13
14
  export class CodingActionError extends Error {
14
15
  delivery;
15
16
  sessionId;
@@ -163,8 +164,8 @@ export class CodingWorkspace {
163
164
  }
164
165
  snapshot(id, actor) {
165
166
  const session = this.get(id, actor);
166
- const items = this.db.prepare('SELECT payload FROM coding_item WHERE session=? ORDER BY position').all(id).map(row => JSON.parse(String(row['payload'])));
167
- const requests = this.db.prepare("SELECT payload FROM coding_request WHERE session=? AND status='pending' ORDER BY rowid").all(id).map(row => JSON.parse(String(row['payload'])));
167
+ const items = this.db.prepare('SELECT payload FROM coding_item WHERE session=? ORDER BY position').all(id).map(row => readCodingItem(String(row['payload'])));
168
+ const requests = this.db.prepare("SELECT payload FROM coding_request WHERE session=? AND status='pending' ORDER BY rowid").all(id).map(row => readCodingRequest(String(row['payload'])));
168
169
  const revision = Number(this.db.prepare('SELECT revision FROM coding_session WHERE id=?').get(id)?.['revision']);
169
170
  return { session, items, requests, revision };
170
171
  }
@@ -174,8 +175,8 @@ export class CodingWorkspace {
174
175
  const session = this.get(id, actor);
175
176
  const itemRows = this.db.prepare('SELECT payload FROM coding_item WHERE session=? ORDER BY position DESC LIMIT 101').all(id);
176
177
  const requestRows = this.db.prepare("SELECT payload FROM coding_request WHERE session=? AND status='pending' ORDER BY rowid LIMIT 51").all(id);
177
- const items = itemRows.slice(0, 100).reverse().map(row => JSON.parse(String(row['payload'])));
178
- const requests = requestRows.slice(0, 50).map(row => JSON.parse(String(row['payload'])));
178
+ const items = itemRows.slice(0, 100).reverse().map(row => readCodingItem(String(row['payload'])));
179
+ const requests = requestRows.slice(0, 50).map(row => readCodingRequest(String(row['payload'])));
179
180
  const revision = Number(this.db.prepare('SELECT revision FROM coding_session WHERE id=?').get(id)?.['revision']);
180
181
  return { session, items, requests, revision, truncated: itemRows.length > items.length || requestRows.length > requests.length };
181
182
  }
@@ -373,7 +374,7 @@ export class CodingWorkspace {
373
374
  }
374
375
  else if (event.method === 'item/agentMessage/delta') {
375
376
  const id = string(p['itemId']), key = `${session.id}:${id}`;
376
- const prior = this.pendingItems.get(key)?.item ?? (() => { const row = this.db.prepare('SELECT payload FROM coding_item WHERE session=? AND id=?').get(session.id, id); return row ? JSON.parse(String(row['payload'])) : { id, type: 'agentMessage', text: '', status: 'inProgress' }; })();
377
+ const prior = this.pendingItems.get(key)?.item ?? (() => { const row = this.db.prepare('SELECT payload FROM coding_item WHERE session=? AND id=?').get(session.id, id); return row ? readCodingItem(String(row['payload'])) : { id, type: 'agentMessage', text: '', status: 'inProgress' }; })();
377
378
  prior.text += string(p['delta']);
378
379
  if (prior.text.length > 100_000)
379
380
  prior.text = prior.text.slice(0, 100_000);
@@ -524,8 +525,8 @@ export class CodingWorkspace {
524
525
  if (!row)
525
526
  return false;
526
527
  try {
527
- const c = JSON.parse(String(row['payload']));
528
- if (c.host !== hostname() || !Array.isArray(c.descendants) || typeof c.observationUnknown !== 'boolean')
528
+ const c = readCodingCustodyWitness(String(row['payload']));
529
+ if (c === null || c.host !== hostname())
529
530
  return false;
530
531
  if (provenDeadByBootChange(c))
531
532
  return true;
@@ -688,8 +689,8 @@ export class CodingWorkspace {
688
689
  if (!session.nativeThreadId || !session.turnId || !busy.has(session.status))
689
690
  throw new CodingActionError('There is no running turn to stop.', 'rejected', id);
690
691
  for (const row of this.db.prepare("SELECT token,rpc_id,payload FROM coding_request WHERE session=? AND status='pending'").all(id)) {
691
- const request = JSON.parse(String(row['payload']));
692
- this.native().respond(JSON.parse(String(row['rpc_id'])), request.kind === 'questions' ? { answers: {} } : { decision: 'cancel' });
692
+ const request = readCodingRequest(String(row['payload']));
693
+ this.native().respond(readCodingRpcId(String(row['rpc_id'])), request.kind === 'questions' ? { answers: {} } : { decision: 'cancel' });
693
694
  this.db.prepare("UPDATE coding_request SET status='withdrawn' WHERE token=?").run(String(row['token']));
694
695
  }
695
696
  this.save({ ...session, status: 'stopping' });
@@ -700,11 +701,11 @@ export class CodingWorkspace {
700
701
  const row = this.db.prepare("SELECT * FROM coding_request WHERE session=? AND token=? AND status='pending'").get(id, token);
701
702
  if (!row || session.status !== 'needs-input' || !this.loaded.has(id))
702
703
  throw Error('This request is no longer waiting for an answer.');
703
- const request = JSON.parse(String(row['payload']));
704
+ const request = readCodingRequest(String(row['payload']));
704
705
  if (!['accept', 'decline', 'cancel'].includes(decision))
705
706
  throw Error('Choose an available decision.');
706
707
  const result = request.kind === 'questions' ? { answers: decision === 'accept' ? this.validateAnswers(request, answers) : {} } : { decision };
707
- this.native().respond(JSON.parse(String(row['rpc_id'])), result);
708
+ this.native().respond(readCodingRpcId(String(row['rpc_id'])), result);
708
709
  this.db.prepare("UPDATE coding_request SET status='answered' WHERE token=?").run(token);
709
710
  const remaining = this.db.prepare("SELECT 1 FROM coding_request WHERE session=? AND status='pending'").get(id);
710
711
  this.save({ ...session, status: remaining ? 'needs-input' : 'working' });