toolroll 0.9.42 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
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
+
3
22
  ## 0.9.42 — 2026-10-06
4
23
 
5
24
  - **Zod revamp, waves 3 and 4.** What comes into Toolroll from outside and
@@ -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
@@ -253,7 +253,7 @@ export const OPERATE_COMMANDS = new Set([
253
253
  "run",
254
254
  "routine",
255
255
  "flows",
256
- "config", "chat", "proposals", "mode", "people", "keys",
256
+ "config", "chat", "proposals", "mode", "chat-approval", "people", "keys",
257
257
  "setup",
258
258
  "verify",
259
259
  "intake",
@@ -102,6 +102,7 @@ export declare const CONSOLE_FORMS: {
102
102
  readonly learningChange: FormContract<"action" | "identity" | "lesson" | "repo" | "revision" | "sha" | "version">;
103
103
  readonly projectSetup: FormContract<"command" | "fingerprint" | "model" | "nonce" | "provider" | "repo" | "seconds" | "token">;
104
104
  readonly slack: FormContract<"app-token" | "bot-token" | "password">;
105
+ readonly chatApproval: FormContract<"cap-usd" | "digest" | "full-access" | "nonce" | "scope" | "token">;
105
106
  readonly telegramPair: FormContract<"password">;
106
107
  readonly teams: FormContract<"app-id" | "password" | "secret" | "tenant">;
107
108
  readonly discord: FormContract<"bot-token" | "password">;
@@ -111,6 +111,7 @@ export const CONSOLE_FORMS = {
111
111
  learningChange: formContract("POST /settings/learning/change", ["repo", "action", "identity", "revision", "lesson", "version", "sha"]),
112
112
  projectSetup: formContract("POST /control/setup-preview, /control/setup-approve, /control/instructions-preview, /control/instructions-approve", ["repo", "nonce", "fingerprint", "token", "provider", "model", "command", "seconds"]),
113
113
  slack: formContract("POST /settings/slack/<connect|pair|unpair|disconnect|alerts>", ["password", "app-token", "bot-token"]),
114
+ chatApproval: formContract("POST /settings/chat-approval/confirm, /settings/chat-approval/save, /settings/chat-approval/off", ["scope", "full-access", "cap-usd", "nonce", "digest", "token"]),
114
115
  telegramPair: formContract("POST /settings/telegram/pair, /settings/telegram/unpair", ["password"]),
115
116
  teams: formContract("POST /settings/teams/<connect|pair|unpair|disconnect|alerts>", ["password", "app-id", "tenant", "secret"]),
116
117
  discord: formContract("POST /settings/discord/<connect|pair|unpair|disconnect|alerts>", ["password", "bot-token"]),
@@ -88,6 +88,10 @@ export declare const LEAD_TOOL_INPUTS: {
88
88
  repo: z.ZodOptional<z.ZodString>;
89
89
  run: z.ZodOptional<z.ZodInt>;
90
90
  }, z.core.$strict>;
91
+ readonly offer_approval: z.ZodObject<{
92
+ task: z.ZodOptional<z.ZodString>;
93
+ card: z.ZodOptional<z.ZodInt>;
94
+ }, z.core.$strict>;
91
95
  readonly get_result: z.ZodObject<{
92
96
  task: z.ZodString;
93
97
  run: z.ZodOptional<z.ZodInt>;
@@ -777,6 +781,10 @@ export declare const LEAD_TOOL_READ_INPUTS: {
777
781
  repo: z.ZodOptional<z.ZodString>;
778
782
  run: z.ZodOptional<z.ZodInt>;
779
783
  }, z.core.$strict>;
784
+ readonly offer_approval: z.ZodObject<{
785
+ task: z.ZodOptional<z.ZodString>;
786
+ card: z.ZodOptional<z.ZodInt>;
787
+ }, z.core.$strict>;
780
788
  readonly get_result: z.ZodObject<{
781
789
  task: z.ZodString;
782
790
  run: z.ZodOptional<z.ZodInt>;
@@ -1535,6 +1543,9 @@ export declare const LEAD_TOOL_OUTPUTS: {
1535
1543
  label: z.ZodString;
1536
1544
  action: z.ZodString;
1537
1545
  }, z.core.$loose>;
1546
+ readonly offer_approval: z.ZodObject<{
1547
+ offered: z.ZodBoolean;
1548
+ }, z.core.$loose>;
1538
1549
  readonly get_result: z.ZodObject<{
1539
1550
  task: z.ZodString;
1540
1551
  root: z.ZodString;
@@ -65,6 +65,7 @@ export const LEAD_TOOL_INPUTS = {
65
65
  propose_task_action: z.strictObject({ task: taskRef, operation: enumOf(Object.keys(CHAT_TASK_ACTIONS)), dependency: taskRef.optional(), run: id.optional() }),
66
66
  get_controls: z.strictObject({}),
67
67
  show_control: z.strictObject({ control: enumOf(Object.keys(CHAT_CONTROLS)), task: taskRef.optional(), repo: repoId.optional(), run: id.optional() }),
68
+ offer_approval: z.strictObject({ task: taskRef.optional(), card: id.optional() }),
68
69
  get_result: z.strictObject({ task: taskRef, run: id.optional(), feedback_offset: offset.optional() }),
69
70
  get_diff: z.strictObject({ task: taskRef, run: id.optional(), file: text("diffPath").min(1).optional(), offset: offset.optional() }),
70
71
  get_check_log: z.strictObject({ task: taskRef, run: id.optional(), search: text("logSearch").min(2).optional(), offset: offset.optional() }),
@@ -189,6 +190,7 @@ export const LEAD_TOOL_OUTPUTS = {
189
190
  propose_task_action: z.looseObject({ proposal: int, action: str, awaiting: str }),
190
191
  get_controls: z.looseObject({ confirmedInChat: z.array(str), existingControls: rows({ id: str, label: str, needsTask: z.boolean(), needsProject: z.boolean() }), rule: str }),
191
192
  show_control: z.looseObject({ card: int, label: str, action: str }),
193
+ offer_approval: z.looseObject({ offered: z.boolean() }),
192
194
  get_result: z.looseObject({
193
195
  task: str, root: str, currentExecution: str, run: int, title: str, feedback: list, feedbackTotal: int, nextFeedbackOffset: nullable(int),
194
196
  changes: str, changesShortened: z.boolean(), verification: str, accepted: z.boolean(), evidenceTool: str, canRevise: z.boolean(),
@@ -74,4 +74,6 @@ export declare function decideFlowCard(store: Store, input: {
74
74
  entry?: number;
75
75
  /** v92: the AI teammate (by handle) deciding a zone it staffs; `actor` is then how it reads in history. */
76
76
  teammate?: string;
77
+ /** Where the decision was made, for its ledger line: "the console", "the command line", "Telegram". */
78
+ where?: string;
77
79
  }, now: Date): FlowDecision;
@@ -485,6 +485,9 @@ export function decideFlowCard(store, input, now) {
485
485
  // v102: a protected project's decisions are a person's, never an AI teammate's.
486
486
  if (input.teammate !== undefined && store.approvalRules(flow.repo).protectProject)
487
487
  return { ok: false, message: "This project is protected: a person decides here." };
488
+ // Every decision is one ledger line, the same wherever it was made; only `via` says where.
489
+ const ledger = (outcome) => store.recordAction({ at: now.toISOString(), actor: input.actor, repo: flow.repo, taskId: card.primaryTask, runId: null,
490
+ action: "flow decision", outcome, source: "request", detail: `${flow.name} · card ${card.id} · ${stage.title}${input.teammate === undefined ? "" : ` · teammate ${input.teammate}`} · via ${input.where ?? "Toolroll"}` });
488
491
  if (input.decision === "approve") {
489
492
  // An edited draft replaces the one Claude wrote, so the steps after this send what the person approved.
490
493
  const draft = draftFor(definition, stage);
@@ -498,9 +501,12 @@ export function decideFlowCard(store, input, now) {
498
501
  }
499
502
  if (stage.next === null) {
500
503
  store.updateFlowCard(card.id, { state: "done", waiting: null }, now);
504
+ ledger("approved");
501
505
  return { ok: true, said: "Approved. The card is done." };
502
506
  }
503
- store.moveFlowCard(card.id, { to: stage.next, outcome: "approved", actor: input.actor, note: input.note, expectEntry: card.entry }, now);
507
+ if (!store.moveFlowCard(card.id, { to: stage.next, outcome: "approved", actor: input.actor, note: input.note, expectEntry: card.entry }, now))
508
+ return { ok: false, message: "That card has moved on since; nothing was changed." };
509
+ ledger("approved");
504
510
  const title = definition.stages.find(one => one.id === stage.next)?.title ?? stage.next;
505
511
  if (card.owner !== null)
506
512
  notifyPeople(store, card, [card.owner], input.actor, { key: `approved:${card.entry}`, subject: `${input.actor} approved “${card.title}”`, body: `${stage.title}: approved. It moves to ${title}.${input.note === null || input.note.trim() === "" ? "" : `\n\n${input.note.trim()}`}` }, now);
@@ -512,7 +518,9 @@ export function decideFlowCard(store, input, now) {
512
518
  return { ok: false, message: "Say what should change." };
513
519
  const target = definition.stages.find(one => one.id === stage.onFail);
514
520
  const revision = revisionWithNote(store, card, target, input.note.trim(), input.actor, input.repos, input.evidenceRoot, now);
515
- store.moveFlowCard(card.id, { to: stage.onFail, outcome: "sent-back", actor: input.actor, note: input.note.trim(), ...(revision === null ? {} : { task: revision }), expectEntry: card.entry }, now);
521
+ if (!store.moveFlowCard(card.id, { to: stage.onFail, outcome: "sent-back", actor: input.actor, note: input.note.trim(), ...(revision === null ? {} : { task: revision }), expectEntry: card.entry }, now))
522
+ return { ok: false, message: "That card has moved on since; nothing was changed." };
523
+ ledger("sent-back");
516
524
  if (card.owner !== null)
517
525
  notifyPeople(store, card, [card.owner], input.actor, { key: `sent-back:${card.entry}`, subject: `${input.actor} sent “${card.title}” back`, body: `Sent back to ${target?.title ?? stage.onFail}:\n\n${input.note.trim()}`, attention: true }, now);
518
526
  return { ok: true, said: revision === null ? `Sent back to ${target?.title ?? stage.onFail} with your note.` : `Sent back to ${target?.title ?? stage.onFail}: a revision was made with your note.` };
@@ -188,6 +188,34 @@ export declare const FLOWS_DESCRIPTORS: readonly [{
188
188
  readonly takesValue: true;
189
189
  readonly meaning: 'a zone id or name';
190
190
  }];
191
+ }, {
192
+ readonly action: 'card approve';
193
+ readonly synopsis: "approve a card waiting at a Person decides step (or pick --option at a Person chooses step), as the console's Approve does";
194
+ readonly mutation: 'identity-idempotent';
195
+ readonly positionals: readonly ['flow', 'card'];
196
+ readonly flags: readonly [...{
197
+ name: string;
198
+ takesValue: boolean;
199
+ meaning: string;
200
+ }[], {
201
+ readonly name: 'option';
202
+ readonly takesValue: true;
203
+ readonly meaning: "at a Person chooses step: the option's words or its number";
204
+ }];
205
+ }, {
206
+ readonly action: 'card send-back';
207
+ readonly synopsis: "send a waiting card back with a note, as the console's Send back (or a reply at a Person chooses step) does";
208
+ readonly mutation: 'identity-idempotent';
209
+ readonly positionals: readonly ['flow', 'card'];
210
+ readonly flags: readonly [...{
211
+ name: string;
212
+ takesValue: boolean;
213
+ meaning: string;
214
+ }[], {
215
+ readonly name: 'note';
216
+ readonly takesValue: true;
217
+ readonly meaning: 'what should change';
218
+ }];
191
219
  }, {
192
220
  readonly action: 'export';
193
221
  readonly synopsis: 'write a flow as a *.toolroll-flow.json file: zones, paths, trigger settings and scripts; never secrets, webhook addresses, names or cards';