toolroll 0.9.15 → 0.9.17

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 +36 -0
  2. package/dist/assignment-brief.d.ts +10 -0
  3. package/dist/assignment-brief.js +56 -26
  4. package/dist/assignment.d.ts +8 -7
  5. package/dist/assignment.js +13 -8
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +22 -22
  8. package/dist/browser-crew.js +1 -0
  9. package/dist/browser-workspace.d.ts +5 -0
  10. package/dist/chat-actions.d.ts +7 -0
  11. package/dist/chat-actions.js +40 -4
  12. package/dist/chat-ask.d.ts +28 -0
  13. package/dist/chat-ask.js +91 -0
  14. package/dist/chat-channel.js +8 -1
  15. package/dist/chat-controls.d.ts +8 -0
  16. package/dist/chat-controls.js +3 -0
  17. package/dist/chat-delivery-state.d.ts +8 -1
  18. package/dist/chat-delivery-state.js +15 -2
  19. package/dist/chat-delivery.d.ts +3 -0
  20. package/dist/chat-delivery.js +49 -17
  21. package/dist/chat-quiet.js +4 -2
  22. package/dist/chat-voice.d.ts +3 -6
  23. package/dist/chat-voice.js +8 -2
  24. package/dist/chat-warmth.d.ts +21 -0
  25. package/dist/chat-warmth.js +36 -0
  26. package/dist/discord-api.d.ts +1 -1
  27. package/dist/discord-api.js +1 -1
  28. package/dist/discord-chat.d.ts +3 -2
  29. package/dist/discord-chat.js +28 -3
  30. package/dist/lead-about.d.ts +35 -0
  31. package/dist/lead-about.js +62 -0
  32. package/dist/lead-capabilities.d.ts +64 -0
  33. package/dist/lead-capabilities.js +199 -0
  34. package/dist/lead-commitments.d.ts +84 -0
  35. package/dist/lead-commitments.js +214 -0
  36. package/dist/lead-context.d.ts +34 -3
  37. package/dist/lead-context.js +155 -19
  38. package/dist/lead-follow.d.ts +1 -1
  39. package/dist/lead-follow.js +6 -2
  40. package/dist/lead-identity.d.ts +23 -0
  41. package/dist/lead-identity.js +33 -0
  42. package/dist/lead-people.d.ts +55 -0
  43. package/dist/lead-people.js +128 -0
  44. package/dist/lead-voice.d.ts +13 -9
  45. package/dist/lead-voice.js +26 -16
  46. package/dist/ledger-view.js +3 -2
  47. package/dist/mate-cli.d.ts +4 -1
  48. package/dist/mate-cli.js +23 -16
  49. package/dist/mate-contract.d.ts +1 -1
  50. package/dist/mate-contract.js +13 -4
  51. package/dist/mate-progress.js +7 -0
  52. package/dist/mate-tools.d.ts +26 -1
  53. package/dist/mate-tools.js +357 -7
  54. package/dist/mate.d.ts +35 -1
  55. package/dist/mate.js +121 -61
  56. package/dist/operate.js +4 -1
  57. package/dist/phase-routing.d.ts +1 -1
  58. package/dist/project-delete.js +3 -1
  59. package/dist/provider-auth.d.ts +4 -0
  60. package/dist/provider-auth.js +1 -1
  61. package/dist/reply-shape.d.ts +96 -0
  62. package/dist/reply-shape.js +452 -0
  63. package/dist/result-acts.d.ts +14 -0
  64. package/dist/result-acts.js +19 -0
  65. package/dist/retention.js +6 -0
  66. package/dist/serve.js +162 -55
  67. package/dist/slack-chat.d.ts +2 -0
  68. package/dist/slack-chat.js +30 -3
  69. package/dist/store.d.ts +60 -0
  70. package/dist/store.js +152 -3
  71. package/dist/surface.js +2 -2
  72. package/dist/task-status.d.ts +5 -15
  73. package/dist/task-status.js +18 -5
  74. package/dist/team-runtime.js +3 -2
  75. package/dist/teams-chat.js +16 -2
  76. package/dist/telegram-mate.d.ts +6 -1
  77. package/dist/telegram-mate.js +43 -11
  78. package/dist/telegram-progress.js +3 -2
  79. package/dist/telegram-team.js +6 -3
  80. package/dist/telegram.js +43 -3
  81. package/dist/test-temp.js +1 -1
  82. package/dist/when-html.d.ts +8 -3
  83. package/dist/when-html.js +23 -3
  84. package/dist/work-index.d.ts +2 -0
  85. package/dist/work-index.js +9 -6
  86. package/dist/work-summary.d.ts +1 -1
  87. package/dist/workspace-ui.d.ts +2 -14
  88. package/dist/workspace-ui.js +4 -22
  89. package/package.json +1 -1
package/dist/mate.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { type MateProgress } from "./mate-progress.js";
2
2
  import type { ChatConfig, MateSession, MateThread, Store } from "./store.js";
3
+ import type { Integration } from "./integrations.js";
3
4
  import type { VerifiedApprover } from "./principal.js";
4
5
  import { type MateHistoryMessage } from "./converse.js";
5
6
  import { type SubscriptionMateRunner } from "./subscription-chat.js";
7
+ import { type LeadChannel } from './lead-context.js';
6
8
  export declare const MATE_MESSAGE_MAX_CHARS = 2000;
7
9
  /** The thread's recent history the model sees, most recent first until the cap. */
8
10
  export declare const MATE_HISTORY_CAP_BYTES = 16384;
@@ -39,8 +41,14 @@ export type MateTurnInput = {
39
41
  clock?: () => Date;
40
42
  /** Where evidence lives — get_task reads a scout's report from here. */
41
43
  evidenceRoot?: string;
44
+ /** Where this conversation happens, so the lead fits its replies to it. */
45
+ channel?: LeadChannel;
46
+ /** A shared team conversation's own lead name, in place of this person's (Settings → Lead). */
47
+ leadName?: string;
42
48
  /** How this surface delivers the images a turn selects: Telegram sends them as documents after the reply; absent means identity only. */
43
49
  mediaDelivery?: "documents";
50
+ /** The integrations as Settings → Integrations shows them (get_integrations); absent: read from the files beside the database. */
51
+ integrations?: () => readonly Integration[];
44
52
  /**
45
53
  * A channel's own standing, re-proved where the approver's is: before
46
54
  * admission, before every provider dispatch, after every provider wait,
@@ -54,9 +62,11 @@ export type MateTurnInput = {
54
62
  ok: true;
55
63
  } | {
56
64
  ok: false;
57
- reason: string;
65
+ reason: MateChannelProblem;
58
66
  }>;
59
67
  };
68
+ /** Why a chat's standing changed under a turn: each has its own plain words (MATE_CHANNEL_COPY). */
69
+ export type MateChannelProblem = "unpaired" | "not-approver" | "projects-changed" | "projects-unreadable" | "member-changed" | "access-changed";
60
70
  export type MateRefusal = "empty-message" | "secret-in-message" | "secret-in-context" | "standing" | "unpriced" | "ceiling-changed" | "not-yours" | "thread-closed" | "latched" | "concurrent" | "daily-cap" | "session-exhausted" | "session-ended" | "over-budget" | "monthly-budget" | "policy" | "invalid-request" | "request-changed" | "channel";
61
71
  export type MateFailure = "provider-error" | "timeout" | "malformed-reply" | "secret-refused" | "latched" | "revoked" | "superseded";
62
72
  export type MateTurnOutcome = {
@@ -84,7 +94,31 @@ export type MateTurnOutcome = {
84
94
  message: string;
85
95
  unknownSpend: boolean;
86
96
  };
97
+ /** What happened, what it means, and one next step: plain words a person can act on, never internal terms. */
87
98
  export declare const MATE_REFUSAL_COPY: Record<MateRefusal, string>;
99
+ /** A turn stopped because its chat's standing changed: what happened, what it means, one next step. */
100
+ export declare const MATE_CHANNEL_COPY: Record<MateChannelProblem, string>;
101
+ /** Why a reply did not finish, in the same three parts: what happened, what it means, one next step. */
102
+ export declare const MATE_FAILURE_COPY: {
103
+ readonly stopped: "This reply was stopped before it finished, from another window or by a newer message. Nothing it proposed was kept. Send your message again if you still need an answer.";
104
+ readonly ended: "Your sign-in or this conversation ended while the lead was answering. Nothing it proposed was kept. Sign in again or start a new chat, then send your message again.";
105
+ readonly tooLong: "The reply took too long and was stopped. Nothing it proposed was kept. Send your message again, or ask for less at once.";
106
+ readonly secretRead: "Something the lead read looked like a password or key, so it stopped before sending it anywhere. Nothing was kept. Remove the password or key from that task or note, then ask again.";
107
+ readonly secretReply: "The lead's reply contained something that looked like a password or key, so it was thrown away. Nothing was kept. Ask again, without asking for a password or key.";
108
+ readonly paused: "An earlier reply stopped before its cost was known, so chat is paused. This message wasn't answered. Confirm that earlier reply on the Chat page, then send again.";
109
+ readonly notStarted: "The reply couldn't be started. Nothing was kept or charged. Send your message again.";
110
+ readonly notInstalled: "The chat app the lead uses isn't installed on this computer, so it couldn't answer. Nothing was kept. Install it, or choose another provider in Settings → Lead.";
111
+ readonly unreadable: "The chat provider sent back an answer that couldn't be read, so it was thrown away. Nothing was kept. Send your message again.";
112
+ readonly signIn: "The chat provider couldn't answer; its sign-in may have expired. Nothing was kept. Sign in to it again on this computer, then send your message again.";
113
+ readonly refused: "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute; if it keeps happening, check the provider in Settings → Lead.";
114
+ readonly tooLongUnknownCost: "The reply took too long and was stopped, and its cost isn't known yet, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
115
+ readonly lostUnknownCost: "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
116
+ readonly unreadableUnknownCost: "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
117
+ readonly empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.";
118
+ readonly unusable: "The lead's answer couldn't be used, so it was thrown away. Nothing was kept. Send your message again.";
119
+ };
120
+ /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
121
+ export declare function mateFailureText(reason: string | null): string;
88
122
  /** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
89
123
  export declare function historyFor(store: Store, thread: number, queuedMessageId?: number): MateHistoryMessage[];
90
124
  /** "read 3 · proposed 1 · 2 steps" — counts only, never results (ruling 11). */
package/dist/mate.js CHANGED
@@ -1,19 +1,3 @@
1
- /**
2
- * The mate's turn engine (mate arc §3): one reservation for the whole
3
- * loop, one `chat_turn` step per provider request, tools executed
4
- * in-process under the branded approver, results through `mateView`, and
5
- * only operator text and assistant text kept (ruling 11). The console
6
- * and the CLI drive this and render from the rows it writes.
7
- *
8
- * Slice-1 review, folded in: the session, thread, and credential are
9
- * bound to the principal inside the store's admission (finding 2); the
10
- * principal is re-proved after every network wait and the turn's own
11
- * row is re-read, so a revocation mid-flight ends the loop before any
12
- * tool runs (finding 5); a step whose cost is unknown charges the whole
13
- * reservation (finding 1); the latch is re-checked before every dispatch
14
- * (finding 11); usage that cannot be true is malformed, never a discount
15
- * (finding 8); and everything the model sees passed `mateView` (finding 9).
16
- */
17
1
  import { Buffer } from "node:buffer";
18
2
  import { mateToolLabel } from "./mate-progress.js";
19
3
  import { createHash } from "node:crypto";
@@ -23,35 +7,82 @@ import { MATE_MAX_CALLS_PER_STEP, MATE_MAX_STEPS, MATE_TOOL_RESULT_CAP_BYTES, TU
23
7
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
24
8
  import { redactSecretAssignments } from "./builder.js";
25
9
  import { MATE_CONTRACT } from "./mate-contract.js";
26
- import { MATE_MAX_PROPOSALS_PER_TURN, MATE_TOOL_SCHEMAS, executeMateTool, isMateTool, mateViewContextFor, redactForMate, toolResultBytes } from "./mate-tools.js";
10
+ import { MATE_MAX_PROPOSALS_PER_TURN, MATE_TOOL_SCHEMAS, executeMateTool, isMateTool, mateViewContextFor, projectLabelForMate, redactForMate, toolResultBytes } from "./mate-tools.js";
27
11
  import { composeSubscriptionMatePrompt, performSubscriptionMateRequest } from "./subscription-chat.js";
28
12
  import { leadContext } from './lead-context.js';
29
13
  import { envValue } from "./names.js";
14
+ import { deliverableClaim, deliverableRepair, dropDeliverableClaims, replyCarriesDeliverable } from "./reply-shape.js";
30
15
  export const MATE_MESSAGE_MAX_CHARS = 2_000;
16
+ /** Where a promise made this turn is reported: the chat app it arrived on, or this conversation for the console and terminal. */
17
+ function promiseChannelOfTurn(channel) {
18
+ return channel === undefined ? undefined : channel === "console" || channel === "terminal" ? "chat" : channel;
19
+ }
31
20
  /** The thread's recent history the model sees, most recent first until the cap. */
32
21
  export const MATE_HISTORY_CAP_BYTES = 16_384;
33
22
  export const MATE_HISTORY_MAX_MESSAGES = 40;
23
+ /** What happened, what it means, and one next step: plain words a person can act on, never internal terms. */
34
24
  export const MATE_REFUSAL_COPY = {
35
- "empty-message": `a message is 1 to ${MATE_MESSAGE_MAX_CHARS} characters`,
36
- "secret-in-message": "that looks like a credential — the mate never forwards or stores those",
37
- "secret-in-context": "fleet context contains something credential-shaped — the mate refuses to send it; find and remove it first",
38
- standing: "your approver standing changed — sign in again",
39
- unpriced: "no pinned price for the chat model — re-save the chat configuration to pin one",
40
- "ceiling-changed": "the admitted projects changed since this session was minted — mint a new one",
41
- "not-yours": "that mate session is not yours to continue",
42
- "thread-closed": "that thread is closed — start a new one",
43
- latched: "a turn with unknown cost blocks this credential — acknowledge it first",
44
- concurrent: "one turn at a time — the last one is still running",
45
- "daily-cap": "the daily turn cap is reached",
46
- "session-exhausted": "this mate session's spend ceiling would be exceeded — mint a new one to continue",
47
- "session-ended": "this mate session has ended — mint a new one to continue",
48
- "over-budget": "the weekly chat spend ceiling would be exceeded",
49
- "monthly-budget": "a monthly budget this counts toward holds it: used up, or its cost can't be priced yet (the Spend page says which)",
50
- policy: "the organisation policy doesn't allow this chat's provider or model (Settings → Policy says which)",
51
- "invalid-request": "This message could not be identified. Reload the conversation before sending it.",
25
+ "empty-message": `That message was empty or too long, so it wasn't sent. Write 1 to ${MATE_MESSAGE_MAX_CHARS} characters and send it again.`,
26
+ "secret-in-message": "That message looks like it contains a password or key, so it wasn't sent or saved. Remove it and send the message again.",
27
+ "secret-in-context": "Something saved in your projects looks like a password or key, so the lead can't read your projects safely. Remove it from the task or note it's in, then try again.",
28
+ standing: "Your sign-in changed, so the lead can't act for you right now. Sign in again.",
29
+ unpriced: "The chat model has no saved price, so its cost can't be tracked. Save the chat settings again to fix it.",
30
+ "ceiling-changed": "Your projects changed since this chat started, so it can't go on. Start a new chat.",
31
+ "not-yours": "This chat belongs to someone else, so you can't continue it. Start your own chat.",
32
+ "thread-closed": "This conversation is closed. Start a new one to keep going.",
33
+ latched: "An earlier reply stopped before its cost was known, so chat is paused. Acknowledge that reply on the Chat page, then send again.",
34
+ concurrent: "The lead is still answering your last message. Wait for that reply, then send this one.",
35
+ "daily-cap": "You've reached today's limit on chat replies, so this one wasn't sent. Try again tomorrow.",
36
+ "session-exhausted": "This chat has used its spending limit. Start a new chat to keep going.",
37
+ "session-ended": "This chat has ended. Start a new chat to keep going.",
38
+ "over-budget": "This reply would go over this week's chat spending limit, so it wasn't sent. Raise the weekly limit in chat settings to keep going.",
39
+ "monthly-budget": "A monthly budget this chat counts toward is used up, or its cost can't be worked out yet. Open the Spend page to see which.",
40
+ policy: "Your organisation's policy doesn't allow this chat's provider or model. Open Settings → Policy to see what's allowed.",
41
+ "invalid-request": "This message couldn't be matched to your conversation. Reload the conversation, then send it again.",
52
42
  "request-changed": "That send was already received with different text or task context. Reload the conversation before sending a new message.",
53
- channel: "this conversation's connection changed — reconnect it before sending again",
43
+ channel: "This conversation's connection changed, so the message wasn't sent. Reconnect it, then send again.",
44
+ };
45
+ /** A turn stopped because its chat's standing changed: what happened, what it means, one next step. */
46
+ export const MATE_CHANNEL_COPY = {
47
+ unpaired: "This chat is no longer connected to your account, so the reply was stopped. Nothing it proposed was kept. Connect the chat again in Settings, then send your message again.",
48
+ "not-approver": "Your account can no longer approve work here, so the reply was stopped. Nothing it proposed was kept. Ask an owner to restore your access, then send your message again.",
49
+ "projects-changed": "The projects this chat can see changed while the lead was answering, so the reply was stopped. Nothing it proposed was kept. Send your message again.",
50
+ "projects-unreadable": "Your project list couldn't be read just now, so the reply was stopped. Nothing it proposed was kept. Send your message again in a minute.",
51
+ "member-changed": "Your account in this chat app no longer has access, so the reply was stopped. Nothing it proposed was kept. Ask the app's admin to add you back, then send your message again.",
52
+ "access-changed": "This chat's access changed while the lead was answering, so the reply was stopped. Nothing it proposed was kept. Reconnect the chat, then send your message again.",
54
53
  };
54
+ /** Why a reply did not finish, in the same three parts: what happened, what it means, one next step. */
55
+ export const MATE_FAILURE_COPY = {
56
+ stopped: "This reply was stopped before it finished, from another window or by a newer message. Nothing it proposed was kept. Send your message again if you still need an answer.",
57
+ ended: "Your sign-in or this conversation ended while the lead was answering. Nothing it proposed was kept. Sign in again or start a new chat, then send your message again.",
58
+ tooLong: "The reply took too long and was stopped. Nothing it proposed was kept. Send your message again, or ask for less at once.",
59
+ secretRead: "Something the lead read looked like a password or key, so it stopped before sending it anywhere. Nothing was kept. Remove the password or key from that task or note, then ask again.",
60
+ secretReply: "The lead's reply contained something that looked like a password or key, so it was thrown away. Nothing was kept. Ask again, without asking for a password or key.",
61
+ paused: "An earlier reply stopped before its cost was known, so chat is paused. This message wasn't answered. Confirm that earlier reply on the Chat page, then send again.",
62
+ notStarted: "The reply couldn't be started. Nothing was kept or charged. Send your message again.",
63
+ notInstalled: "The chat app the lead uses isn't installed on this computer, so it couldn't answer. Nothing was kept. Install it, or choose another provider in Settings → Lead.",
64
+ unreadable: "The chat provider sent back an answer that couldn't be read, so it was thrown away. Nothing was kept. Send your message again.",
65
+ signIn: "The chat provider couldn't answer; its sign-in may have expired. Nothing was kept. Sign in to it again on this computer, then send your message again.",
66
+ refused: "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute; if it keeps happening, check the provider in Settings → Lead.",
67
+ tooLongUnknownCost: "The reply took too long and was stopped, and its cost isn't known yet, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
68
+ lostUnknownCost: "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
69
+ unreadableUnknownCost: "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
70
+ empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.",
71
+ unusable: "The lead's answer couldn't be used, so it was thrown away. Nothing was kept. Send your message again.",
72
+ };
73
+ /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
74
+ export function mateFailureText(reason) {
75
+ switch (reason) {
76
+ case "timeout": return MATE_FAILURE_COPY.tooLong;
77
+ case "malformed-reply": return MATE_FAILURE_COPY.unreadable;
78
+ case "secret-refused": return MATE_FAILURE_COPY.secretReply;
79
+ case "latched": return MATE_FAILURE_COPY.paused;
80
+ case "revoked": return MATE_FAILURE_COPY.ended;
81
+ case "superseded": return MATE_FAILURE_COPY.stopped;
82
+ case "provider-error": return MATE_FAILURE_COPY.refused;
83
+ default: return "The lead's reply didn't finish. Nothing it proposed was kept. Send your message again.";
84
+ }
85
+ }
55
86
  const READ_TOOLS = new Set(["get_brief", "get_project_context", "get_actions", "get_action_status", "get_skills", "get_acceptance_evidence", "recap", "list_repos", "list_tasks", "get_task", "get_result", "get_result_images", "get_controls", "get_agents", "get_project_knowledge", "get_task_conversation", "get_diff", "get_check_log", "get_project_tools", "get_flows", "get_flow_insights", "list_decisions", "get_decision", "queue"]);
56
87
  /** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
57
88
  export function historyFor(store, thread, queuedMessageId) {
@@ -149,7 +180,10 @@ export async function runMateTurn(input) {
149
180
  let now = clock();
150
181
  store.sweepStaleMateTurns(now);
151
182
  const view = mateViewContextFor(store, who);
152
- const document = redactForMate(leadContext(store, who.repos, now, input.evidenceRoot), view);
183
+ // Names in the bundle are deliberate (the lead's, the person's first name, project labels); everything else is scrubbed.
184
+ const document = leadContext(store, who.repos, now, { owner: who.name, thread: thread.id, redact: text => redactForMate(text, view),
185
+ projectName: (path, index) => projectLabelForMate(path, index, view.names),
186
+ ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.channel === undefined ? {} : { channel: input.channel }), ...(input.leadName === undefined ? {} : { leadName: input.leadName }) });
153
187
  const authoredMessage = input.queuedMessageId === undefined ? message : `From ${who.name}:\n${message}`;
154
188
  const historyMessage = input.context === undefined ? authoredMessage : `${redactForMate(input.context, view)}\n\n${authoredMessage}`;
155
189
  const history = [...historyFor(store, thread.id, input.queuedMessageId), { role: "operator", text: historyMessage }];
@@ -229,6 +263,10 @@ export async function runMateTurn(input) {
229
263
  let steps = 0;
230
264
  const readDecisions = new Map();
231
265
  const readResults = new Map();
266
+ const searchedMemory = new Map();
267
+ const checkedCapabilities = new Map();
268
+ /** The turn's one question to its owner: shown with the reply only once the turn answers; a failed turn drops it. */
269
+ const ask = (question, options) => store.recordMateAsk({ turn: turnId, thread: thread.id, question, options }, clock());
232
270
  let tokensIn = 0;
233
271
  let tokensOut = 0;
234
272
  let settled = 0;
@@ -249,6 +287,7 @@ export async function runMateTurn(input) {
249
287
  const fail = (failed, message, unknownSpend) => {
250
288
  now = clock();
251
289
  store.finalizeMateTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: failed }, now);
290
+ store.dropMateAsk(turnId);
252
291
  return { ok: false, turn: turnId, failed, message, unknownSpend };
253
292
  };
254
293
  /** The turn's own row, re-read: still running under our generation, or someone ended it under us. */
@@ -260,20 +299,30 @@ export async function runMateTurn(input) {
260
299
  // AFTER it resolves, immediately before sending context or using a tool.
261
300
  const guard = (channel) => {
262
301
  if (!stillOurs())
263
- return { ok: false, turn: turnId, failed: "superseded", message: "this turn was ended before its next action", unknownSpend: false };
302
+ return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
264
303
  if (!channel.ok)
265
- return fail("revoked", `this conversation's connection changed (${channel.reason}) — nothing it proposed was kept`, false);
304
+ return fail("revoked", MATE_CHANNEL_COPY[channel.reason] ?? MATE_CHANNEL_COPY["access-changed"], false);
266
305
  const standing = reproveApprover(store, who);
267
306
  const liveSession = store.getMateSession(session.id);
268
307
  const liveThread = store.getMateThread(thread.id);
269
308
  if (!standing.ok || liveSession === null || liveSession.endedAt !== null || liveThread === null || liveThread.closedAt !== null) {
270
- return fail("revoked", "your standing or this conversation ended — nothing it proposed was kept", false);
309
+ return fail("revoked", MATE_FAILURE_COPY.ended, false);
271
310
  }
272
311
  return null;
273
312
  };
274
313
  let reply = null;
275
314
  let stoppedAtCap = false;
276
315
  let lastText = "";
316
+ /** A page shown this turn (show_control): a reply may say it links one. */
317
+ let shownControl = false;
318
+ /** The turn's one repair step for a reply that claims an attachment it does not carry. */
319
+ let repaired = false;
320
+ const unbacked = (text) => {
321
+ const claim = deliverableClaim(text);
322
+ if (claim === null || shownControl || replyCarriesDeliverable(text) || store.listMateTurnEvidence(turnId).length > 0)
323
+ return null;
324
+ return claim;
325
+ };
277
326
  while (steps < maxSteps) {
278
327
  const blocked = guard(input.revalidate === undefined ? { ok: true } : await input.revalidate());
279
328
  if (blocked !== null)
@@ -281,22 +330,22 @@ export async function runMateTurn(input) {
281
330
  now = clock();
282
331
  const remainingMs = TURN_WALL_CLOCK_MS - (now.getTime() - turnStartedAt);
283
332
  if (remainingMs <= 0)
284
- return fail("timeout", "the turn ran out of time before the model finished", false);
333
+ return fail("timeout", MATE_FAILURE_COPY.tooLong, false);
285
334
  const request = direct ? composeDirect(input.key) : composeSubscription();
286
335
  // Tool results join the outbound body: scanned again before every dispatch.
287
336
  const outbound = typeof request === "string" ? request : request.body;
288
337
  if (scanForSecrets(outbound).length > 0) {
289
- return fail("secret-refused", "a tool result contained something credential-shaped — the turn stopped before sending it", false);
338
+ return fail("secret-refused", MATE_FAILURE_COPY.secretRead, false);
290
339
  }
291
340
  const step = store.openMateStep({ mateTurn: turnId, generation: started.generation, approver: who.name, credentialKey, provider: config.provider, model: config.model, deadlineMs: remainingMs + 10_000 }, now);
292
341
  if (!step.ok) {
293
342
  if (step.reason === "latched")
294
- return fail("latched", "a turn with unknown cost latched this credential mid-conversation — acknowledge it first", false);
295
- return { ok: false, turn: turnId, failed: "superseded", message: "this turn was ended before its next step", unknownSpend: false };
343
+ return fail("latched", MATE_FAILURE_COPY.paused, false);
344
+ return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
296
345
  }
297
346
  const stepStarted = store.startChatTurn(step.id, now);
298
347
  if (!stepStarted.ok)
299
- return fail("provider-error", "the step could not be dispatched", false);
348
+ return fail("provider-error", MATE_FAILURE_COPY.notStarted, false);
300
349
  steps++;
301
350
  progress({ kind: "step", turn: turnId, step: steps });
302
351
  const requestBytes = Buffer.byteLength(outbound, "utf8");
@@ -341,36 +390,36 @@ export async function runMateTurn(input) {
341
390
  if (!direct) {
342
391
  finishStep({ state: "failed", failureReason: result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", settledMicrousd: 0 });
343
392
  const message = result.problem === "not-found"
344
- ? "the selected subscription CLI is not installed on this machine"
393
+ ? MATE_FAILURE_COPY.notInstalled
345
394
  : result.problem === "timeout"
346
- ? "the subscription turn ran out of time"
395
+ ? MATE_FAILURE_COPY.tooLong
347
396
  : result.problem === "malformed-reply"
348
- ? "the subscription provider returned a malformed answer and it was discarded"
349
- : "the subscription provider refused or could not complete the turn — check its login in the terminal";
397
+ ? MATE_FAILURE_COPY.unreadable
398
+ : MATE_FAILURE_COPY.signIn;
350
399
  return fail(result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false);
351
400
  }
352
401
  if (result.problem.startsWith("status-")) {
353
402
  // The provider ANSWERED with an error: nothing billed for this step.
354
403
  finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: 0 });
355
- return fail("provider-error", "the provider refused the request — nothing was billed for that step", false);
404
+ return fail("provider-error", MATE_FAILURE_COPY.refused, false);
356
405
  }
357
406
  if (result.problem === "timeout") {
358
407
  finishStep({ state: "failed", failureReason: "timeout", settledMicrousd: null, unknownSpend: true });
359
- return fail("timeout", "the turn timed out; that step's cost is unknown — the whole reservation is charged and the credential is blocked until you acknowledge it", true);
408
+ return fail("timeout", MATE_FAILURE_COPY.tooLongUnknownCost, true);
360
409
  }
361
410
  if (result.problem === "network") {
362
411
  finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true });
363
- return fail("provider-error", "the provider could not be reached after dispatch; cost unknown — the whole reservation is charged; acknowledge to re-enable chat", true);
412
+ return fail("provider-error", MATE_FAILURE_COPY.lostUnknownCost, true);
364
413
  }
365
414
  finishStep({ state: "failed", failureReason: "malformed-reply", settledMicrousd: null, unknownSpend: true });
366
- return fail("malformed-reply", "the provider's response was malformed and was discarded; cost unknown — the whole reservation is charged; acknowledge to re-enable chat", true);
415
+ return fail("malformed-reply", MATE_FAILURE_COPY.unreadableUnknownCost, true);
367
416
  }
368
417
  const answer = result.answer;
369
418
  // Usage that cannot be true — more input tokens than bytes sent — is a
370
419
  // malformed reply with unknown cost, never a number to settle by.
371
420
  if (direct && answer.tokensIn > requestBytes) {
372
421
  finishStep({ state: "failed", failureReason: "malformed-reply", settledMicrousd: null, unknownSpend: true });
373
- return fail("malformed-reply", "the provider reported usage that cannot be true; cost unknown — the whole reservation is charged; acknowledge to re-enable chat", true);
422
+ return fail("malformed-reply", MATE_FAILURE_COPY.unreadableUnknownCost, true);
374
423
  }
375
424
  // The pinned math, or the provider's own reported charge when HIGHER.
376
425
  const stepSettled = direct
@@ -389,15 +438,23 @@ export async function runMateTurn(input) {
389
438
  return changed;
390
439
  if (answer.calls.length === 0) {
391
440
  if (answer.text.trim() === "")
392
- return fail("malformed-reply", "the model answered with nothing", false);
393
- reply = answer.text;
441
+ return fail("malformed-reply", MATE_FAILURE_COPY.empty, false);
442
+ // "Here's the screenshot" with nothing attached or linked: one repair step to attach it or drop the claim; never the claim alone.
443
+ const claim = unbacked(answer.text);
444
+ if (claim !== null && !repaired && steps < maxSteps) {
445
+ repaired = true;
446
+ history.push({ role: "assistant", text: answer.text, calls: [] });
447
+ history.push({ role: "operator", text: deliverableRepair(claim) });
448
+ continue;
449
+ }
450
+ reply = claim === null ? answer.text : dropDeliverableClaims(answer.text);
394
451
  break;
395
452
  }
396
453
  if (answer.calls.length > MATE_MAX_CALLS_PER_STEP)
397
- return fail("malformed-reply", "the model asked for more tool calls than one step allows", false);
454
+ return fail("malformed-reply", MATE_FAILURE_COPY.unusable, false);
398
455
  for (const call of answer.calls) {
399
456
  if (!isMateTool(call.name))
400
- return fail("malformed-reply", "the model called a tool that does not exist", false);
457
+ return fail("malformed-reply", MATE_FAILURE_COPY.unusable, false);
401
458
  }
402
459
  history.push({ role: "assistant", text: answer.text, calls: answer.calls });
403
460
  for (const [index, call] of answer.calls.entries()) {
@@ -407,9 +464,11 @@ export async function runMateTurn(input) {
407
464
  return changed;
408
465
  }
409
466
  progress({ kind: "tool", turn: turnId, step: steps, label: mateToolLabel(call.name) });
410
- const outcome = executeMateTool({ store, who, now: clock(), draft, selectEvidence, step: steps, readDecisions, readResults, ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
467
+ const outcome = executeMateTool({ store, who, now: clock(), draft, selectEvidence, step: steps, readDecisions, readResults, searchedMemory, checkedCapabilities, ask, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
411
468
  if (READ_TOOLS.has(call.name))
412
469
  reads++;
470
+ if (call.name === "show_control" && outcome.ok)
471
+ shownControl = true;
413
472
  // Opt-in, local diagnostics for end-to-end runs: what the lead asked of each tool and what came back (its start), keys
414
473
  // blanked. Each line stays whole JSON so a run can assert on what a tool returned rather than on the model's words.
415
474
  if (envValue(process.env, "MATE_TRACE") === "1") {
@@ -423,16 +482,17 @@ export async function runMateTurn(input) {
423
482
  }
424
483
  if (reply === null) {
425
484
  stoppedAtCap = true;
426
- reply = `${lastText.trim() === "" ? "" : `${lastText.trim()}\n\n`}(stopped after ${maxSteps} steps${maxSteps < MATE_MAX_STEPS ? " within this conversation’s remaining allowance" : ""})`;
485
+ const last = lastText.trim() === "" || unbacked(lastText) === null ? lastText.trim() : dropDeliverableClaims(lastText);
486
+ reply = `${last === "" ? "" : `${last}\n\n`}(I stopped here: this answer needed more steps than ${maxSteps < MATE_MAX_STEPS ? "this conversation's remaining spending allows" : "one reply allows"}. Ask me to carry on.)`;
427
487
  }
428
488
  // Ruling 11: model text is scanned before it becomes durable.
429
489
  if (scanForSecrets(reply).length > 0)
430
- return fail("secret-refused", "the model's reply contained something credential-shaped and was discarded", false);
490
+ return fail("secret-refused", MATE_FAILURE_COPY.secretReply, false);
431
491
  now = clock();
432
492
  const activity = activitySummary(reads, proposals, steps);
433
493
  // One write (finding 12): settle, debit, promote the drafts, append the assistant text.
434
494
  const finalized = store.finalizeMateTurn(turnId, started.generation, { state: "answered", settledMicrousd: settled, tokensIn, tokensOut, message: { text: reply, activity } }, now);
435
495
  if (!finalized)
436
- return { ok: false, turn: turnId, failed: "superseded", message: "this turn was ended before it could be kept", unknownSpend: false };
496
+ return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
437
497
  return { ok: true, turn: turnId, reply, activity, proposals, steps, stoppedAtCap, settledMicrousd: settled };
438
498
  }
package/dist/operate.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { UNSENT_REPLY_MS } from "./telegram-settings.js";
2
+ import { leadNameOf } from "./lead-identity.js";
2
3
  import { maybeTriggerRepair } from "./dispose.js";
3
4
  import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
4
5
  import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
@@ -10698,7 +10699,7 @@ function leadClaimCommand(positional, flags, context) {
10698
10699
  const claimed = leadClaim(store, actor, id, context.clock());
10699
10700
  if (!claimed.ok)
10700
10701
  return fail(write, json, command, claimed.reason, claimed.message, claimed.reason === "usage" ? EXIT.usage : EXIT.refused);
10701
- return succeed(write, json, command, { task: id, root: claimed.root, lead: claimed.claim }, () => [`${id} is yours: it reads "Your lead is on it" until you complete it, hand it on with task ask, or go two hours without acting on it.`]);
10702
+ return succeed(write, json, command, { task: id, root: claimed.root, lead: claimed.claim }, () => [`${id} is yours: it reads "${leadNameOf(store, actor.account)} is on it" until you complete it, hand it on with task ask, or go two hours without acting on it.`]);
10702
10703
  }
10703
10704
  function blockTask(positional, flags, context) {
10704
10705
  const { store, write, json } = context;
@@ -11642,6 +11643,8 @@ async function chatCommand(flags, context) {
11642
11643
  ceilingUsd: ceilingGiven === undefined ? undefined : Number(ceilingGiven),
11643
11644
  ...(context.mateSeams === undefined ? {} : { seams: { ...context.mateSeams, clock: context.mateSeams.clock ?? context.clock } }),
11644
11645
  ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
11646
+ // The console's own address (and its public one): links there read as "the task", "the result", "Settings → Lead".
11647
+ appOrigin: [loadConsoleUrl(process.env, dirname(context.databaseFile)), phoneOrigin(process.env, dirname(context.databaseFile))],
11645
11648
  });
11646
11649
  return result.code;
11647
11650
  }
@@ -234,7 +234,7 @@ export declare function parseOverrides(raw: unknown): RouteOverride[] | null;
234
234
  * nobody had overridden anything. */
235
235
  export declare function overridesFromJson(json: string | null): RouteOverride[] | null;
236
236
  export declare function canonicalOverridesJson(overrides: readonly RouteOverride[]): string | null;
237
- export type ReadinessLookup = (provider: ProviderId) => Pick<ReadinessObservation, "state" | "reason" | "runner" | "observedAt"> | null;
237
+ export type ReadinessLookup = (provider: ProviderId) => (Pick<ReadinessObservation, "state" | "reason" | "runner" | "observedAt"> & Partial<Pick<ReadinessObservation, "probe">>) | null;
238
238
  export type RouteLegProjection = RouteLeg & {
239
239
  readiness: ReadinessState;
240
240
  readinessReason: string | null;
@@ -218,6 +218,7 @@ function deleteRows(store, repo, d, now) {
218
218
  del(`${surface}_flow_prompt`, IN("card"), C);
219
219
  del(`${surface}_question_action`, IN("question"), questions);
220
220
  del(`${surface}_question_prompt`, IN("question"), questions);
221
+ del(`${surface}_ask_action`, IN("turn"), U);
221
222
  }
222
223
  del("telegram_action", IN("decision"), decisions);
223
224
  del("telegram_decision_message", IN("decision"), decisions);
@@ -242,6 +243,7 @@ function deleteRows(store, repo, d, now) {
242
243
  del("team_mate_session", IN("thread"), H);
243
244
  del("mate_turn_evidence", `${IN("turn")} OR ${IN("run")} OR ${IN("task_ref")} OR ${IN("artifact")}`, U, R, T, A);
244
245
  del("chat_turn", IN("mate_turn"), U);
246
+ del("mate_ask", IN("turn"), U);
245
247
  del("mate_turn", IN("id"), U);
246
248
  del("mate_proposal", IN("id"), proposals);
247
249
  del("mate_message", IN("thread"), H);
@@ -318,7 +320,7 @@ function deleteRows(store, repo, d, now) {
318
320
  for (const [table, column] of SETTINGS)
319
321
  del(table, `${column} = ?`, repo);
320
322
  del("project_mute", "repo = ?", repo);
321
- for (const table of ["knowledge_change", "project_skill_change", "memory_gap", "memory_proposal", "memory_rejection", "memory_session", "memory_search", "mode_rail", "side_spend", "watch_episode", "worktree", "coordinator_proposal"])
323
+ for (const table of ["knowledge_change", "project_skill_change", "memory_gap", "memory_proposal", "memory_rejection", "memory_session", "memory_search", "mode_rail", "side_spend", "watch_episode", "worktree", "coordinator_proposal", "lead_commitment"])
322
324
  del(table, "repo = ?", repo);
323
325
  // A builder watching the project keeps its lease until it stops; an ended one goes.
324
326
  del("watch_lease", "repo = ? AND expires_at <= ?", repo, now.toISOString());
@@ -34,6 +34,10 @@ export declare function providerName(provider: string): string;
34
34
  export declare function signInReason(pause: Pick<AuthPause, "provider" | "authMode">): string;
35
35
  /** What to run, as a command. */
36
36
  export declare function signInCommand(pause: Pick<AuthPause, "provider" | "authMode">): string;
37
+ /** The same command for a chat message: the phone scrub hides anything shaped
38
+ * like a path (`/login` included), so a message names Claude's equivalent
39
+ * `claude auth login`. */
40
+ export declare function messageCommand(pause: Pick<AuthPause, "provider" | "authMode">): string;
37
41
  /** One line for status, ready and the console. */
38
42
  export declare function signInWords(pause: AuthPause): string;
39
43
  /** The open pause for a provider, or null. */
@@ -44,7 +44,7 @@ export function signInCommand(pause) {
44
44
  /** The same command for a chat message: the phone scrub hides anything shaped
45
45
  * like a path (`/login` included), so a message names Claude's equivalent
46
46
  * `claude auth login`. */
47
- function messageCommand(pause) {
47
+ export function messageCommand(pause) {
48
48
  const command = signInCommand(pause);
49
49
  return command === "claude /login" ? "claude auth login" : command;
50
50
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The lead's voice, enforced: every chat surface (console, terminal, Telegram, Slack, Discord, Teams) runs a reply
3
+ * through here before it is sent. The contract asks for plain text; this makes it so, without changing what the reply
4
+ * says. Shaping is channel-neutral and idempotent — it yields a small canonical form (`**anchor**`, `[label](url)`,
5
+ * `code`) — and each channel renders that form in its own format.
6
+ *
7
+ * - Markdown headers become plain lines.
8
+ * - Bold stays on at most three short anchors; the rest is plain.
9
+ * - Bare URLs become labelled links, named from their target: "the task", "the result", "Settings → Lead", else the host.
10
+ * - Internal ids (run #12, digests, r1-style project ids) are removed unless the owner asked for them. When a reply names
11
+ * several of one kind (two runs, two projects), they stay: two items must never read the same.
12
+ * - Runs of blank lines collapse to one.
13
+ * Code (inline or fenced) is left exactly as written.
14
+ */
15
+ export type ReplyChannel = "console" | "terminal" | "telegram" | "slack" | "discord" | "teams";
16
+ export type ShapeOptions = {
17
+ /** The owner's message this reply answers: when it asks for ids, the ids stay. */
18
+ asked?: string;
19
+ /** This app's own origin (or origins: the console's address and its public one). Only a link there is named as the
20
+ * app's page ("the task"); every other link is named by its real host, whatever label the model wrote, so a reply can
21
+ * never dress a foreign link up as ours. */
22
+ appOrigin?: string | readonly (string | null)[] | null;
23
+ };
24
+ export declare const BOLD_ANCHORS = 3;
25
+ /** A bold anchor is a few words, not a sentence. */
26
+ export declare const BOLD_ANCHOR_CHARS = 40;
27
+ /** Did the owner ask for ids (an id, a digest, a hash, a run number or a project's r-number)? */
28
+ export declare function askedForIds(asked: string | undefined): boolean;
29
+ /** What a link is called, from where it goes: the app's own task, result and settings pages by name (only on `appOrigin`); anything else by host. */
30
+ export declare function linkLabel(url: string, appOrigin?: ShapeOptions["appOrigin"]): string;
31
+ /**
32
+ * The canonical shaped reply: plain lines, at most three bold anchors, labelled links, no internal ids unless asked
33
+ * (or unless several of a kind would otherwise read the same), single blank lines. Idempotent: shaping a shaped reply
34
+ * changes nothing.
35
+ */
36
+ export declare function shapeReply(text: string, options?: ShapeOptions): string;
37
+ /**
38
+ * A long reply split into parts of at most `size` characters, then each part shaped: the cut is made on the reply as
39
+ * written, at a paragraph or line where possible and never inside code, a link or a bold anchor, so no part carries
40
+ * half of one. The parts share one bold budget and one rule for ids, as if the reply were shaped whole. `measure` is
41
+ * how long a shaped part is once the channel renders it (its own link and escape format): a part that renders past
42
+ * `size` is cut again, smaller. Only a single link longer than a part can exceed it, and it is never cut.
43
+ */
44
+ export declare function shapeReplyParts(text: string, size: number, options?: ShapeOptions, measure?: (shaped: string) => number): string[];
45
+ export type ReplyPiece = {
46
+ kind: "text";
47
+ text: string;
48
+ } | {
49
+ kind: "bold";
50
+ text: string;
51
+ } | {
52
+ kind: "code";
53
+ text: string;
54
+ } | {
55
+ kind: "link";
56
+ label: string;
57
+ url: string;
58
+ };
59
+ /** A shaped reply as pieces: plain text, bold anchors, inline code and labelled links. Fenced code stays text. */
60
+ export declare function replyPieces(shaped: string): ReplyPiece[];
61
+ /** Discord's presentation syntax, escaped; mentions are broken so nothing pings. */
62
+ export declare const discordPlain: (text: string) => string;
63
+ /** One line of a shaped reply as console HTML (the console lays out paragraphs and lists itself). */
64
+ export declare function replyHtmlInline(shapedLine: string): string;
65
+ /** A shaped reply in a channel's own text format (Telegram's formatting rides entities: see `telegramReply`). */
66
+ export declare function renderReply(shaped: string, channel: ReplyChannel): string;
67
+ export type TelegramEntity = {
68
+ type: "bold" | "code";
69
+ offset: number;
70
+ length: number;
71
+ } | {
72
+ type: "text_link";
73
+ offset: number;
74
+ length: number;
75
+ url: string;
76
+ };
77
+ /** A shaped reply for Telegram: plain text plus entities (bold anchors, code, labelled links). Offsets are UTF-16, as JS strings are. */
78
+ export declare function telegramReply(shaped: string): {
79
+ text: string;
80
+ entities: TelegramEntity[];
81
+ };
82
+ /** Shape and render in one step, for a surface that sends the reply as text. */
83
+ export declare function voiceReply(text: string, channel: ReplyChannel, options?: ShapeOptions): string;
84
+ /**
85
+ * The words in a reply that say something is being sent or attached ("I've attached the log", "here's the
86
+ * screenshot"), or null. A claim needs an attachment noun and a send verb; one whose content the reply lists itself
87
+ * ("I've included the files I changed: a.ts") is not a claim.
88
+ */
89
+ export declare function deliverableClaim(text: string): string | null;
90
+ /** Does the reply carry the thing itself: a link, or content quoted in a code block? */
91
+ export declare function replyCarriesDeliverable(text: string): boolean;
92
+ export declare const NOTHING_ATTACHED = "I couldn't attach that to this reply.";
93
+ /** The reply without its claims of an attachment, said plainly once; whatever a claim listed stays; never the claim alone. */
94
+ export declare function dropDeliverableClaims(text: string): string;
95
+ /** The one repair step's words to the lead: attach it, or drop the claim. */
96
+ export declare function deliverableRepair(claim: string): string;