toolroll 0.9.84 → 0.9.86
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 +66 -0
- package/README.md +94 -1193
- package/dist/agent-fence.d.ts +9 -5
- package/dist/agent-fence.js +6 -5
- package/dist/agent-onboard.d.ts +2 -0
- package/dist/agent-onboard.js +3 -0
- package/dist/assignment.js +4 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +33 -31
- package/dist/browser-workspace.d.ts +45 -0
- package/dist/chat-actions.d.ts +11 -1
- package/dist/chat-actions.js +53 -1
- package/dist/chat-channel.d.ts +10 -1
- package/dist/chat-channel.js +36 -5
- package/dist/chat-decide.d.ts +16 -4
- package/dist/chat-decide.js +239 -31
- package/dist/chat-messages.d.ts +5 -0
- package/dist/chat-messages.js +1 -1
- package/dist/chat-quiet.js +6 -4
- package/dist/chat-review.d.ts +4 -0
- package/dist/chat-review.js +13 -4
- package/dist/chat-terms.d.ts +9 -0
- package/dist/chat-terms.js +46 -0
- package/dist/claim.d.ts +3 -0
- package/dist/claim.js +4 -1
- package/dist/contracts/chat-actions.d.ts +9 -0
- package/dist/contracts/chat-actions.js +3 -0
- package/dist/contracts/console-api.d.ts +6 -2
- package/dist/contracts/console-api.js +6 -2
- package/dist/contracts/flow-propose.d.ts +4 -1
- package/dist/contracts/flow-propose.js +1 -1
- package/dist/contracts/flow.d.ts +41 -0
- package/dist/contracts/flow.js +4 -2
- package/dist/contracts/lead-tools.d.ts +51 -0
- package/dist/contracts/lead-tools.js +5 -0
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/contracts/trigger-payloads.d.ts +20 -0
- package/dist/contracts/trigger-payloads.js +4 -0
- package/dist/contracts/youiyoux-request.d.ts +248 -0
- package/dist/contracts/youiyoux-request.js +152 -0
- package/dist/coordinator.js +1 -0
- package/dist/deploy-gate.js +4 -0
- package/dist/dispatch.d.ts +3 -0
- package/dist/dispatch.js +4 -1
- package/dist/flow-browser.d.ts +103 -0
- package/dist/flow-browser.js +204 -0
- package/dist/flow-cadence.d.ts +8 -2
- package/dist/flow-cadence.js +16 -3
- package/dist/flow-card-task.d.ts +9 -0
- package/dist/flow-card-task.js +15 -6
- package/dist/flow-code.d.ts +5 -1
- package/dist/flow-code.js +4 -1
- package/dist/flow-engine.js +16 -0
- package/dist/flow-live.js +9 -1
- package/dist/flow-pull-request.js +15 -7
- package/dist/flow-share.js +3 -1
- package/dist/flow-steps.d.ts +9 -1
- package/dist/flow-steps.js +55 -5
- package/dist/flow-triggers.js +55 -0
- package/dist/flows-cli.d.ts +19 -0
- package/dist/flows-cli.js +47 -2
- package/dist/flows-ui.js +11 -2
- package/dist/flows.js +5 -2
- package/dist/integrations-ui.js +9 -4
- package/dist/integrations.d.ts +9 -1
- package/dist/integrations.js +43 -2
- package/dist/landed-cleanup.d.ts +4 -0
- package/dist/landed-cleanup.js +3 -2
- package/dist/lead-capabilities.js +2 -1
- package/dist/lead-cli.js +3 -1
- package/dist/lead-tools.d.ts +2 -0
- package/dist/lead-tools.js +65 -10
- package/dist/lead-voice.js +16 -5
- package/dist/lead.d.ts +2 -0
- package/dist/lead.js +3 -3
- package/dist/operate.d.ts +2 -3
- package/dist/operate.js +226 -21
- package/dist/project-activity.d.ts +20 -0
- package/dist/project-activity.js +23 -0
- package/dist/project-cli.d.ts +1 -0
- package/dist/project-cli.js +36 -19
- package/dist/project-delete.js +7 -3
- package/dist/project-knowledge.d.ts +3 -3
- package/dist/project-outcomes.d.ts +119 -0
- package/dist/project-outcomes.js +79 -0
- package/dist/pull-request-flow.js +35 -4
- package/dist/result-actions.d.ts +4 -0
- package/dist/result-actions.js +41 -9
- package/dist/result-follow-ups.js +4 -0
- package/dist/result-review.d.ts +9 -0
- package/dist/result-review.js +45 -5
- package/dist/scope.d.ts +5 -0
- package/dist/scope.js +7 -0
- package/dist/server/chat.js +4 -2
- package/dist/server/console.css +41 -0
- package/dist/server/flows.js +15 -0
- package/dist/server/pages.js +11 -1
- package/dist/server/render-chat.js +3 -2
- package/dist/server/render-pages.d.ts +15 -2
- package/dist/server/render-pages.js +52 -4
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +20 -7
- package/dist/server/render-tasks.d.ts +9 -0
- package/dist/server/render-tasks.js +32 -11
- package/dist/server/route-table.js +12 -0
- package/dist/server/settings.js +18 -1
- package/dist/server/task-views.js +11 -0
- package/dist/server/tasks.d.ts +10 -0
- package/dist/server/tasks.js +95 -7
- package/dist/settings-proofs.js +1 -0
- package/dist/store.d.ts +116 -5
- package/dist/store.js +385 -24
- package/dist/surface.js +18 -2
- package/dist/task-archive.d.ts +10 -0
- package/dist/task-archive.js +16 -0
- package/dist/task-control.d.ts +1 -1
- package/dist/task-source.d.ts +13 -1
- package/dist/task-source.js +17 -1
- package/dist/telegram-lead.js +7 -5
- package/dist/telegram.js +1 -1
- package/dist/tidy.d.ts +130 -0
- package/dist/tidy.js +503 -0
- package/dist/toolroll-update.js +2 -2
- package/dist/webhooks.d.ts +6 -0
- package/dist/webhooks.js +25 -0
- package/dist/work-index.d.ts +28 -0
- package/dist/work-index.js +82 -28
- package/dist/workspace-ui.d.ts +1 -1
- package/dist/workspace-ui.js +3 -2
- package/dist/youiyoux-intake.d.ts +234 -0
- package/dist/youiyoux-intake.js +908 -0
- package/package.json +11 -8
package/dist/chat-decide.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Approving a plan or merging needs the password, which never goes in chat, so they act in chat only where the owner
|
|
8
8
|
* turned approving from chat on (their lasting setting, chat-approval.ts, or a signed mode's chatApprove term), for
|
|
9
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
|
+
* card keeps its link. The change requests one youiyoux pass filed come as one card that shows every word of each
|
|
11
|
+
* task's terms, with Approve all and each one's own Approve (the requests card, below).
|
|
11
12
|
*
|
|
12
13
|
* The same in every chat app: Telegram's bridge and the shared engine Slack, Discord and Teams run on (chat-delivery.ts)
|
|
13
14
|
* offer, mint and answer these through the same tokens (the `channel` column). Every act runs through the door the
|
|
@@ -20,7 +21,7 @@ import { acceptAndCompleteAsOperator, assignmentOf, owedAcceptance } from "./ass
|
|
|
20
21
|
import { chatControlHref, chatResultHref } from "./chat-controls.js";
|
|
21
22
|
import { applyChatTaskAction, chatTaskStamp } from "./chat-task-actions.js";
|
|
22
23
|
import { chatTitle } from "./chat-voice.js";
|
|
23
|
-
import { readVerifiedArtifact } from "./evidence.js";
|
|
24
|
+
import { readVerifiedArtifact, scanForSecrets } from "./evidence.js";
|
|
24
25
|
import { effectiveChatApproval } from "./chat-approval.js";
|
|
25
26
|
import { chatApproveChatsOf, chatNames, modeTermsFromJson } from "./modes.js";
|
|
26
27
|
import { parseExecutionPlanDocument } from "./plan.js";
|
|
@@ -31,7 +32,13 @@ import { revisionSourceOf } from "./result-review.js";
|
|
|
31
32
|
import { APPROVAL_PROOF, approvalOf, approvalProofDetail, deployGateScope, quarantinedAuthor } from "./scope.js";
|
|
32
33
|
import { releaseCardOf } from "./release-card.js";
|
|
33
34
|
import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
|
|
34
|
-
import { phoneText } from "./telegram-status.js";
|
|
35
|
+
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
36
|
+
import { permissionPlainWords, scopeTermsText, shownTermsDigest } from "./chat-terms.js";
|
|
37
|
+
import { YOUIYOUX_CARD_KIND, YOUIYOUX_FILED_VIA, YOUIYOUX_HOME_PATH } from "./contracts/youiyoux-request.js";
|
|
38
|
+
import { FILED_PER_PASS, requestKindWord, requestPassOf } from "./youiyoux-intake.js";
|
|
39
|
+
import { discordPlain } from "./reply-shape.js";
|
|
40
|
+
import { PLATFORM_LIMITS } from "./text-limits.js";
|
|
41
|
+
export { permissionPlainWords };
|
|
35
42
|
/** The words, said once. */
|
|
36
43
|
export const DECIDE_LABELS = {
|
|
37
44
|
accept: "Accept and finish",
|
|
@@ -75,22 +82,6 @@ export function chatApproveMode(store, repo, approver, now, channel) {
|
|
|
75
82
|
return { ok: false, why: `Your mode approves from ${chatNames(chats)} only. Sign it again to approve from ${chatNames([channel])}.` };
|
|
76
83
|
return { ok: true, digest: mode.digest, limits: { fullAccess: terms.permissionDefault === "escalated", capMicrousd: terms.perAttemptBudgetMicrousd }, source: "mode" };
|
|
77
84
|
}
|
|
78
|
-
/** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
|
|
79
|
-
export function permissionPlainWords(profile) {
|
|
80
|
-
if (profile === null || profile === undefined)
|
|
81
|
-
return null;
|
|
82
|
-
const full = "full access, nothing asks first";
|
|
83
|
-
if (profile.provider === "claude") {
|
|
84
|
-
return profile.permissionArgv === "bypassPermissions" ? full
|
|
85
|
-
: profile.permissionArgv === "auto" ? "file edits and routine commands; anything risky stops"
|
|
86
|
-
: "file edits only; commands are refused";
|
|
87
|
-
}
|
|
88
|
-
if (profile.provider === "gemini")
|
|
89
|
-
return profile.approvalArgv === "yolo" ? full : "file edits only; other tools are refused";
|
|
90
|
-
if (isAcpProfile(profile))
|
|
91
|
-
return "full access inside the OS fence, nothing asks first";
|
|
92
|
-
return profile.sandboxMode === "danger-full-access" ? full : "file edits and commands inside the project only";
|
|
93
|
-
}
|
|
94
85
|
function fullAccess(profile) {
|
|
95
86
|
if (profile === null || profile === undefined)
|
|
96
87
|
return false;
|
|
@@ -100,7 +91,12 @@ function fullAccess(profile) {
|
|
|
100
91
|
const money = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
|
|
101
92
|
/** A plan this person's chat may approve now: the term, the plan's exact bytes, and none of the reasons it must open
|
|
102
93
|
* Toolroll instead. `digest` binds the scope and the plan document the card shows. */
|
|
103
|
-
export function planInChat(store, taskId, approver, now, channel, root
|
|
94
|
+
export function planInChat(store, taskId, approver, now, channel, root,
|
|
95
|
+
/** A youiyoux card asking (requestsOffer): its own words are checked there, every one shown. */
|
|
96
|
+
from) {
|
|
97
|
+
// A change request's terms are outside text: they approve in chat only on its own card, which shows every word.
|
|
98
|
+
if (from !== "requests" && store.filedViaOf(taskId) === YOUIYOUX_FILED_VIA)
|
|
99
|
+
return { ok: false, why: OUTSIDE_TERMS };
|
|
104
100
|
const ref = store.lookupRef(taskId);
|
|
105
101
|
const scope = store.getScope(taskId);
|
|
106
102
|
if (ref === null || ref.repo === null || scope === null)
|
|
@@ -184,6 +180,194 @@ export function mergeInChat(store, taskId, run, approver, now, channel) {
|
|
|
184
180
|
return mode;
|
|
185
181
|
return { ok: true, modeDigest: mode.digest, source: mode.source, head: publication.headSha };
|
|
186
182
|
}
|
|
183
|
+
// ---- a youiyoux pass's card -----------------------------------------------------------------
|
|
184
|
+
/**
|
|
185
|
+
* What one youiyoux pass filed (youiyoux-intake.ts), as one card: every task still waiting on this person, Approve all,
|
|
186
|
+
* and each one's own Approve and Open. Its terms are outside text (anyone who can write the project's folder wrote
|
|
187
|
+
* them), so a task is approved here only when this card shows every word of its terms (chat-terms.ts), the whole card
|
|
188
|
+
* arrives as one message every chat app shows as written, and every plan rule holds (planInChat). Its button carries
|
|
189
|
+
* each task with the digest of the words shown; the seal recomputes that digest from the live scope (store.ts). A task
|
|
190
|
+
* that can't be shown whole, or may not be approved here, is named with its link to Toolroll instead.
|
|
191
|
+
*/
|
|
192
|
+
const REQUESTS_DIGEST = "youiyoux:";
|
|
193
|
+
const OPEN_IN_TOOLROLL = "Open in Toolroll";
|
|
194
|
+
const OUTSIDE_TERMS = "A youiyoux request is approved from its own youiyoux card, which shows every word, or in Toolroll.";
|
|
195
|
+
const TOO_LONG = "its full terms don't fit in one chat message";
|
|
196
|
+
/** Room left under each app's limit for the question a tap adds ("Approve 10 tasks?") and the names it leaves out. */
|
|
197
|
+
const ROOM = 400;
|
|
198
|
+
const listedDigest = (list) => `${REQUESTS_DIGEST}${list.map(one => `${one.taskId}=${one.digest}`).join(",")}`;
|
|
199
|
+
const LISTABLE_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
200
|
+
function listedOf(digest) {
|
|
201
|
+
if (!digest.startsWith(REQUESTS_DIGEST))
|
|
202
|
+
return null;
|
|
203
|
+
const list = [];
|
|
204
|
+
for (const one of digest.slice(REQUESTS_DIGEST.length).split(",")) {
|
|
205
|
+
const match = /^([A-Za-z0-9._-]{1,64})=([0-9a-f]{64})$/.exec(one);
|
|
206
|
+
if (match === null)
|
|
207
|
+
return null;
|
|
208
|
+
list.push({ taskId: match[1], digest: match[2] });
|
|
209
|
+
}
|
|
210
|
+
return list.length === 0 || list.length > FILED_PER_PASS ? null : list;
|
|
211
|
+
}
|
|
212
|
+
/** Whether a card's words arrive as one message that shows each of them as written: within Telegram's part, Slack's and
|
|
213
|
+
* (after its escapes) Discord's limits, with room for the question a tap adds. Teams draws card text as Markdown, where
|
|
214
|
+
* a link's label hides its address, and Slack turns `:name:` into an emoji: there such words never approve. */
|
|
215
|
+
export function shownWhole(text, channel) {
|
|
216
|
+
switch (channel) {
|
|
217
|
+
case "telegram": return text.length + ROOM <= 3_900;
|
|
218
|
+
case "slack": return text.length + ROOM <= PLATFORM_LIMITS.slack && !/:[a-z0-9_+'-]+:/i.test(text);
|
|
219
|
+
case "discord": return discordPlain(text).length + ROOM <= PLATFORM_LIMITS.discord;
|
|
220
|
+
case "teams": return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Words a chat message never carries (chat-display.ts hides them elsewhere): a key, or an absolute folder on this
|
|
224
|
+
* computer (a home folder, a drive). A project's own relative paths and a page's route are fine. */
|
|
225
|
+
function privateWords(text) {
|
|
226
|
+
return scanForSecrets(text).length > 0 || /\b\d{5,}:[A-Za-z0-9_-]{20,}\b/.test(text) || /(?:^|[\s"'`(<[=:,])(?:\/Users\/|\/home\/|\/root\/|~\/)|(?:[A-Za-z]:[\\/]|\\\\)/.test(text);
|
|
227
|
+
}
|
|
228
|
+
/** Still waiting on a person's yes: not finished, and no approval on its current terms. */
|
|
229
|
+
function waitsForApproval(store, taskId) {
|
|
230
|
+
const state = store.getTask(taskId)?.state;
|
|
231
|
+
const scope = store.getScope(taskId);
|
|
232
|
+
return state !== undefined && state !== "done" && state !== "cancelled" && scope !== null && !(scope.approvedDigest != null && scope.approvedDigest === scope.digest);
|
|
233
|
+
}
|
|
234
|
+
/** The card for the pass `taskId` was filed in, as it stands for this person now; null when nothing waits on them. */
|
|
235
|
+
function requestsOffer(store, taskId, who, now, channel, root, said) {
|
|
236
|
+
const waiting = requestPassOf(store, taskId).filter(id => {
|
|
237
|
+
const ref = store.lookupRef(id);
|
|
238
|
+
return ref !== null && ref.repo !== null && who.repos.includes(ref.repo) && store.filedViaOf(id) === YOUIYOUX_FILED_VIA && waitsForApproval(store, id);
|
|
239
|
+
});
|
|
240
|
+
if (waiting.length === 0)
|
|
241
|
+
return null;
|
|
242
|
+
const repo = store.lookupRef(waiting[0]).repo;
|
|
243
|
+
const what = requestKindWord(store, waiting);
|
|
244
|
+
const head = `${waiting.length === 1 ? `A youiyoux ${what} waits` : `${waiting.length} youiyoux ${what}s wait`} for you · ${projectLabel(repo)}`;
|
|
245
|
+
const mode = chatApproveMode(store, repo, who.name, now, channel);
|
|
246
|
+
const items = waiting.map((id, index) => ({ n: index + 1, taskId: id, name: title(store, id), listed: null, why: null }));
|
|
247
|
+
const block = (item) => `${item.n}. ${item.name}\n${scopeTermsText(store.getScope(item.taskId))}`;
|
|
248
|
+
const compose = () => {
|
|
249
|
+
const shown = items.filter(one => one.listed !== null);
|
|
250
|
+
const open = items.filter(one => one.listed === null);
|
|
251
|
+
return [...(said === undefined ? [] : [said]), head,
|
|
252
|
+
...shown.map(block),
|
|
253
|
+
...(open.length === 0 ? [] : [[...(shown.length === 0 ? [] : ["Open in Toolroll to approve:"]), ...open.map(one => `${one.n}. ${one.name}${one.why === null ? "" : `: ${one.why}`}`)].join("\n")])].join("\n\n");
|
|
254
|
+
};
|
|
255
|
+
if (!mode.ok) {
|
|
256
|
+
// Nothing approves here: each task's title, files and goal in short, and its link.
|
|
257
|
+
const summary = items.map(one => {
|
|
258
|
+
const scope = store.getScope(one.taskId);
|
|
259
|
+
// The request's own goal, without the line the intake puts first (which request, on which route).
|
|
260
|
+
const goal = scope.goal.startsWith("From youiyoux request ") && scope.goal.includes("\n\n") ? scope.goal.slice(scope.goal.indexOf("\n\n") + 2) : scope.goal;
|
|
261
|
+
return [`${one.n}. ${one.name}`, `Files: ${scope.touches.length === 0 ? "any file (no file list)" : phoneText(scope.touches.join(", "), 160)}`, phoneText(goal, 200)].join("\n");
|
|
262
|
+
});
|
|
263
|
+
return { text: [head, `${mode.why} Open each to approve it in Toolroll.`, ...summary].join("\n\n"),
|
|
264
|
+
rows: items.map(one => [{ label: waiting.length === 1 ? OPEN_IN_TOOLROLL : `Open ${one.n}`, path: chatControlHref("approval", one.taskId) }]) };
|
|
265
|
+
}
|
|
266
|
+
for (const item of items) {
|
|
267
|
+
const plan = planInChat(store, item.taskId, who.name, now, channel, root, "requests");
|
|
268
|
+
if (!plan.ok) {
|
|
269
|
+
item.why = plan.why;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (passwordOnlySeal(plan.scope) || deployGateScope(plan.scope) || !LISTABLE_ID.test(item.taskId)) {
|
|
273
|
+
item.why = "it needs your password in Toolroll";
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// Shown whole means shown raw: words chat text otherwise hides (a key, a folder on this computer) stay in Toolroll.
|
|
277
|
+
if (privateWords(scopeTermsText(plan.scope))) {
|
|
278
|
+
item.why = "its terms name a key or a folder on this computer";
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// In order, each whole or not at all: one that doesn't fit beside the ones before it is named instead.
|
|
282
|
+
item.listed = { taskId: item.taskId, digest: shownTermsDigest(plan.scope) };
|
|
283
|
+
if (!shownWhole(compose(), channel)) {
|
|
284
|
+
item.listed = null;
|
|
285
|
+
item.why = TOO_LONG;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const shown = items.filter(one => one.listed !== null);
|
|
289
|
+
const rows = [];
|
|
290
|
+
if (shown.length > 0)
|
|
291
|
+
rows.push([{ act: "approve", run: null, digest: listedDigest(shown.map(one => one.listed)), taskId: shown[0].taskId,
|
|
292
|
+
label: shown.length > 1 ? `Approve all (${shown.length})` : items.length === 1 ? DECIDE_LABELS.approve : `Approve ${shown[0].n}` }]);
|
|
293
|
+
for (const item of items) {
|
|
294
|
+
rows.push([...(item.listed !== null && shown.length > 1 ? [{ act: "approve", run: null, digest: listedDigest([item.listed]), taskId: item.taskId, label: `Approve ${item.n}` }] : []),
|
|
295
|
+
{ label: waiting.length === 1 ? OPEN_IN_TOOLROLL : `Open ${item.n}`, path: chatControlHref("approval", item.taskId) }]);
|
|
296
|
+
}
|
|
297
|
+
return { text: compose(), rows };
|
|
298
|
+
}
|
|
299
|
+
/** The tasks a youiyoux card's button names, as they stand now: each still waiting, within every rule, on exactly the
|
|
300
|
+
* words the card showed (ready), or named with why not (skipped). */
|
|
301
|
+
function requestsNow(store, tokenTask, digest, who, now, channel, root) {
|
|
302
|
+
const listed = listedOf(digest);
|
|
303
|
+
const repo = store.lookupRef(tokenTask)?.repo ?? null;
|
|
304
|
+
if (listed === null || repo === null)
|
|
305
|
+
return { ok: false, why: "That button can't be read." };
|
|
306
|
+
const mode = chatApproveMode(store, repo, who.name, now, channel);
|
|
307
|
+
if (!mode.ok)
|
|
308
|
+
return mode;
|
|
309
|
+
const ready = [];
|
|
310
|
+
const skipped = [];
|
|
311
|
+
for (const one of listed) {
|
|
312
|
+
const ref = store.lookupRef(one.taskId);
|
|
313
|
+
const name = title(store, one.taskId);
|
|
314
|
+
if (ref === null || ref.repo !== repo || !who.repos.includes(repo) || store.filedViaOf(one.taskId) !== YOUIYOUX_FILED_VIA) {
|
|
315
|
+
skipped.push({ name, why: "it isn't available here now" });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const plan = planInChat(store, one.taskId, who.name, now, channel, root, "requests");
|
|
319
|
+
if (!plan.ok) {
|
|
320
|
+
skipped.push({ name, why: plan.why });
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (passwordOnlySeal(plan.scope) || deployGateScope(plan.scope)) {
|
|
324
|
+
skipped.push({ name, why: "it needs your password in Toolroll" });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (shownTermsDigest(plan.scope) !== one.digest) {
|
|
328
|
+
skipped.push({ name, why: "its terms changed since this card was sent" });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
ready.push({ taskId: one.taskId, name, digest: one.digest, plan });
|
|
332
|
+
}
|
|
333
|
+
return { ok: true, ready, skipped };
|
|
334
|
+
}
|
|
335
|
+
/** The owner's own yes from chat, sealed as theirs ("chat"), never as a mode's: it stands like the console's after the
|
|
336
|
+
* mode or setting ends. `terms`: the digest of every word a youiyoux card showed, which the seal checks. */
|
|
337
|
+
function sealPlanInChat(store, who, seat, repo, taskId, plan, now, terms) {
|
|
338
|
+
if (!store.sealScopeApproval(taskId, who.name, now, {}, { kind: "chat", modeDigest: plan.modeDigest, ...(terms === undefined ? {} : { card: "terms", cardDigest: terms }) }))
|
|
339
|
+
return false;
|
|
340
|
+
// The person's proof beside the seal, bound to these exact words (approval policy §1.2): a paired-chat tap, never a
|
|
341
|
+
// password. Release, deploy and the protected counters read it as a person's.
|
|
342
|
+
const sealedDigest = store.getScope(taskId)?.approvedDigest ?? "";
|
|
343
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId, runId: null, action: APPROVAL_PROOF, outcome: "chat",
|
|
344
|
+
source: "request", detail: approvalProofDetail(now.getTime(), store.accountOf(who.name)?.generation ?? 0, sealedDigest) });
|
|
345
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo, taskId, runId: null, action: "plan approved in chat", outcome: "approved",
|
|
346
|
+
source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · ${plan.source} ${plan.modeDigest}${terms === undefined ? "" : ` · youiyoux card, terms ${terms}`}` });
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
/** A youiyoux card's yes: each task it names that still stands as shown is sealed, each with its own proof; the rest are
|
|
350
|
+
* named, never approved. The card then offers what still waits, so one can be approved after another. */
|
|
351
|
+
function approveRequests(store, seat, who, tokenTask, digest, target, body, message, root, now) {
|
|
352
|
+
const read = requestsNow(store, tokenTask, digest, who, now, seat.channel, root);
|
|
353
|
+
if (!read.ok)
|
|
354
|
+
return { ack: "Not done.", edit: { text: `${body}\n\n✗ Not done: ${read.why}`, rows: linksFor(target), tokens: [] } };
|
|
355
|
+
const approved = [];
|
|
356
|
+
const skipped = [...read.skipped];
|
|
357
|
+
for (const one of read.ready) {
|
|
358
|
+
if (sealPlanInChat(store, who, seat, store.lookupRef(one.taskId).repo, one.taskId, one.plan, now, one.digest))
|
|
359
|
+
approved.push(one.name);
|
|
360
|
+
else
|
|
361
|
+
skipped.push({ name: one.name, why: "it couldn't be approved from chat. Open it in Toolroll." });
|
|
362
|
+
}
|
|
363
|
+
const said = [...(approved.length === 0 ? [] : [`✓ Approved ${approved.map(name => `"${name}"`).join(", ")}. Work starts when a worker is free.`]),
|
|
364
|
+
...skipped.map(one => `✗ Not approved: "${one.name}": ${one.why}`)].join("\n");
|
|
365
|
+
const ack = approved.length === 0 ? "Not done." : "Approved.";
|
|
366
|
+
const again = requestsOffer(store, tokenTask, who, now, seat.channel, root, said);
|
|
367
|
+
if (again === null)
|
|
368
|
+
return { ack, edit: { text: `${body}\n\n${said}`, rows: skipped.length === 0 ? [] : linksFor(target), tokens: [] } };
|
|
369
|
+
return { ack, edit: { text: again.text ?? said, ...mintDecideButtons(store, seat, target, again, now, message) } };
|
|
370
|
+
}
|
|
187
371
|
/** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. On Telegram the yes's
|
|
188
372
|
* terms come second, under the title, and the steps are fewer: the full plan is behind Edit. */
|
|
189
373
|
export function planCardText(store, taskId, scope, root, channel) {
|
|
@@ -292,6 +476,7 @@ export function decideOffer(store, target, who, now, channel, root) {
|
|
|
292
476
|
return { text: planCardText(store, target.taskId, plan.scope, root, channel),
|
|
293
477
|
rows: [[{ act: "approve", run: null, digest: plan.digest }], [{ label: DECIDE_LABELS.edit, path: chatControlHref("approval", target.taskId) }, { act: "not-now", run: null, digest: plan.digest }]] };
|
|
294
478
|
}
|
|
479
|
+
case "requests": return requestsOffer(store, target.taskId, who, now, channel, root);
|
|
295
480
|
case "merge": {
|
|
296
481
|
const merge = mergeInChat(store, target.taskId, target.run, who.name, now, channel);
|
|
297
482
|
if (!merge.ok)
|
|
@@ -345,7 +530,7 @@ export function mintDecideButtons(store, seat, target, offer, now, message = nul
|
|
|
345
530
|
const rows = offer.rows.map(row => row.map((one) => {
|
|
346
531
|
if (!isAct(one))
|
|
347
532
|
return { label: one.label, link: one };
|
|
348
|
-
const token = mint(store, seat, message, one, target.taskId, "offer", now);
|
|
533
|
+
const token = mint(store, seat, message, one, one.taskId ?? target.taskId, "offer", now);
|
|
349
534
|
tokens.push(token);
|
|
350
535
|
return { label: one.label ?? DECIDE_LABELS[one.act], token };
|
|
351
536
|
}));
|
|
@@ -444,6 +629,8 @@ const QUESTIONS = {
|
|
|
444
629
|
merge: name => `Merge "${name}"?`,
|
|
445
630
|
};
|
|
446
631
|
function targetOf(row) {
|
|
632
|
+
if (row.digest.startsWith(REQUESTS_DIGEST))
|
|
633
|
+
return { kind: "requests", taskId: row.taskId };
|
|
447
634
|
switch (row.act) {
|
|
448
635
|
case "accept":
|
|
449
636
|
case "changes": return { kind: "result", taskId: row.taskId, run: row.run ?? 0 };
|
|
@@ -496,6 +683,14 @@ function staleWhy(store, row, who, now, channel, root) {
|
|
|
496
683
|
return release.why;
|
|
497
684
|
return releaseDigest(release.card) === row.digest ? null : "This release changed while you were reading.";
|
|
498
685
|
}
|
|
686
|
+
if (row.digest.startsWith(REQUESTS_DIGEST)) {
|
|
687
|
+
const read = requestsNow(store, row.taskId, row.digest, who, now, channel, root);
|
|
688
|
+
if (!read.ok)
|
|
689
|
+
return read.why;
|
|
690
|
+
if (read.ready.length > 0)
|
|
691
|
+
return null;
|
|
692
|
+
return read.skipped.length === 1 ? `"${read.skipped[0].name}": ${read.skipped[0].why}` : "None of these wait for your approval as this card showed them.";
|
|
693
|
+
}
|
|
499
694
|
const plan = planInChat(store, row.taskId, who.name, now, channel, root);
|
|
500
695
|
if (!plan.ok)
|
|
501
696
|
return plan.why;
|
|
@@ -521,6 +716,7 @@ export function linksFor(target) {
|
|
|
521
716
|
case "merge": return [[{ label: "Open task", link: { label: "Open task", path: chatControlHref("task", target.taskId) } }]];
|
|
522
717
|
case "plan": return [[{ label: "Review & start", link: { label: "Review & start", path: chatControlHref("approval", target.taskId) } }]];
|
|
523
718
|
case "failed": return [[{ label: "Open task", link: { label: "Open task", path: chatControlHref("task", target.taskId) } }]];
|
|
719
|
+
case "requests": return [[{ label: OPEN_IN_TOOLROLL, link: { label: OPEN_IN_TOOLROLL, path: YOUIYOUX_HOME_PATH } }]];
|
|
524
720
|
}
|
|
525
721
|
}
|
|
526
722
|
/** Where a plan card's task is now, for a card whose buttons no longer act: the family's current version, named only
|
|
@@ -548,7 +744,7 @@ const QUESTION_STARTS = ["Accept and finish \"", "Retry \"", "Approve and start
|
|
|
548
744
|
/** The card's words without a question this module appended. */
|
|
549
745
|
function cardBody(shown) {
|
|
550
746
|
const at = shown.lastIndexOf("\n\n");
|
|
551
|
-
return at >= 0 && QUESTION_STARTS.some(start => shown.startsWith(start, at + 2)) ? shown.slice(0, at) : shown;
|
|
747
|
+
return at >= 0 && (QUESTION_STARTS.some(start => shown.startsWith(start, at + 2)) || /^Approve \d+ tasks\?/.test(shown.slice(at + 2))) ? shown.slice(0, at) : shown;
|
|
552
748
|
}
|
|
553
749
|
/**
|
|
554
750
|
* One tap on a decide button, inside the caller's transaction. Null: not this module's token. The seat must be the
|
|
@@ -637,6 +833,20 @@ export function applyDecideTap(store, seat, input) {
|
|
|
637
833
|
spend();
|
|
638
834
|
return releaseContinue(store, seat, input.message, row.taskId, name, release.card, now, "Read the terms, then approve.", null);
|
|
639
835
|
}
|
|
836
|
+
// A youiyoux card's Approve: how many still stand as the card showed them, and which don't (named, never approved).
|
|
837
|
+
if (row.act === "approve" && row.digest.startsWith(REQUESTS_DIGEST)) {
|
|
838
|
+
const read = requestsNow(store, row.taskId, row.digest, who, now, seat.channel, input.root);
|
|
839
|
+
if (!read.ok)
|
|
840
|
+
return stale(read.why);
|
|
841
|
+
spend();
|
|
842
|
+
const spec = { act: row.act, run: row.run, digest: row.digest };
|
|
843
|
+
const yes = mint(store, seat, input.message, spec, row.taskId, "yes", now);
|
|
844
|
+
const cancel = mint(store, seat, input.message, spec, row.taskId, "cancel", now);
|
|
845
|
+
const asked = [read.ready.length === 1 ? QUESTIONS.approve(read.ready[0].name) : `Approve ${read.ready.length} tasks?`,
|
|
846
|
+
...read.skipped.map(one => `"${one.name}" isn't included: ${one.why}`)].join("\n");
|
|
847
|
+
return { ack: "Confirm below.", edit: { text: `${body}\n\n${asked}`,
|
|
848
|
+
rows: [[{ label: DECIDE_LABELS.yes, token: yes }, { label: DECIDE_LABELS.cancel, token: cancel }]], tokens: [yes, cancel] } };
|
|
849
|
+
}
|
|
640
850
|
// The first tap asks; only the yes acts, bound to exactly what this button was.
|
|
641
851
|
spend();
|
|
642
852
|
const spec = { act: row.act, run: row.run, digest: row.digest };
|
|
@@ -687,6 +897,8 @@ export function applyDecideTap(store, seat, input) {
|
|
|
687
897
|
link: chatControlHref("task", row.taskId), source: { taskRef: ref.id } }, now);
|
|
688
898
|
return done("✓ Release approved. It deploys when its check passes and someone runs the release.");
|
|
689
899
|
}
|
|
900
|
+
if (row.digest.startsWith(REQUESTS_DIGEST))
|
|
901
|
+
return approveRequests(store, seat, who, row.taskId, row.digest, target, body, input.message, input.root, now);
|
|
690
902
|
const plan = planInChat(store, row.taskId, who.name, now, seat.channel, input.root);
|
|
691
903
|
if (!plan.ok)
|
|
692
904
|
return notDone(plan.why);
|
|
@@ -694,16 +906,8 @@ export function applyDecideTap(store, seat, input) {
|
|
|
694
906
|
return notDone("This is a release: approve it from its release card.");
|
|
695
907
|
if (passwordOnlySeal(plan.scope))
|
|
696
908
|
return notDone(needsPassword(plan.scope));
|
|
697
|
-
|
|
698
|
-
if (!store.sealScopeApproval(row.taskId, who.name, now, {}, { kind: "chat", modeDigest: plan.modeDigest }))
|
|
909
|
+
if (!sealPlanInChat(store, who, seat, ref.repo, row.taskId, plan, now))
|
|
699
910
|
return notDone("the plan couldn't be approved from chat. Open it in Toolroll.");
|
|
700
|
-
// The person's proof beside the seal, bound to these exact words (approval policy §1.2): a paired-chat tap, never a
|
|
701
|
-
// password. Release, deploy and the protected counters read it as a person's.
|
|
702
|
-
const sealedDigest = store.getScope(row.taskId)?.approvedDigest ?? "";
|
|
703
|
-
store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: APPROVAL_PROOF, outcome: "chat",
|
|
704
|
-
source: "request", detail: approvalProofDetail(now.getTime(), store.accountOf(who.name)?.generation ?? 0, sealedDigest) });
|
|
705
|
-
store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: "plan approved in chat", outcome: "approved",
|
|
706
|
-
source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · ${plan.source} ${plan.modeDigest}` });
|
|
707
911
|
return done("✓ Approved. Work starts when a worker is free.");
|
|
708
912
|
}
|
|
709
913
|
case "merge": {
|
|
@@ -781,6 +985,8 @@ export function applyDecideFeedback(store, seat, prompt, text, repos, root, now)
|
|
|
781
985
|
}
|
|
782
986
|
/** The link a card keeps when it can't act in chat: a plan's is Review & start, where the password step lives. */
|
|
783
987
|
export function decideFallbackLink(target) {
|
|
988
|
+
if (target?.kind === "requests")
|
|
989
|
+
return { label: OPEN_IN_TOOLROLL, path: YOUIYOUX_HOME_PATH };
|
|
784
990
|
return target?.kind === "plan" ? { label: "Review & start", path: chatControlHref("approval", target.taskId) } : null;
|
|
785
991
|
}
|
|
786
992
|
/** Which card a pushed notification is, from its recorded kind and task — never from its words. */
|
|
@@ -791,6 +997,8 @@ export function decideTargetOf(row) {
|
|
|
791
997
|
return { kind: "result", taskId: row.taskId, run: row.run };
|
|
792
998
|
if (row.kind === "plan-ready")
|
|
793
999
|
return { kind: "plan", taskId: row.taskId };
|
|
1000
|
+
if (row.kind === YOUIYOUX_CARD_KIND)
|
|
1001
|
+
return { kind: "requests", taskId: row.taskId };
|
|
794
1002
|
if (row.kind === "pull-request-ready" && row.run !== null)
|
|
795
1003
|
return { kind: "merge", taskId: row.taskId, run: row.run };
|
|
796
1004
|
if (/fail|exhausted|stalled|fenced/.test(row.kind))
|
package/dist/chat-messages.d.ts
CHANGED
|
@@ -267,6 +267,11 @@ export declare class ChatMessages extends ChatCore {
|
|
|
267
267
|
taskRef: number | null;
|
|
268
268
|
run: number | null;
|
|
269
269
|
project: string | null;
|
|
270
|
+
image?: {
|
|
271
|
+
run: number;
|
|
272
|
+
artifact: number;
|
|
273
|
+
sha256: string;
|
|
274
|
+
};
|
|
270
275
|
}[];
|
|
271
276
|
createProposalAction(action: {
|
|
272
277
|
token: string;
|
package/dist/chat-messages.js
CHANGED
|
@@ -609,7 +609,7 @@ export class ChatMessages extends ChatCore {
|
|
|
609
609
|
const images = parts.filter(one => one.part.kind === "image").map(({ part }) => {
|
|
610
610
|
const taskId = part.taskId;
|
|
611
611
|
const ref = this.store.lookupRef(taskId);
|
|
612
|
-
return { taskId, taskRef: ref?.id ?? null, run: part.run, project: ref?.repo ?? null };
|
|
612
|
+
return { taskId, taskRef: ref?.id ?? null, run: part.run, project: ref?.repo ?? null, ...(part.run === null || part.artifact === null || part.sha256 === null ? {} : { image: { run: part.run, artifact: part.artifact, sha256: part.sha256 } }) };
|
|
613
613
|
});
|
|
614
614
|
// The lead's own messages: a reply from a turn that was about one task,
|
|
615
615
|
// and a card, which names its task (or, once it filed one, the new task).
|
package/dist/chat-quiet.js
CHANGED
|
@@ -10,9 +10,10 @@ import { telegramProgressCard } from "./telegram-progress.js";
|
|
|
10
10
|
import { assignmentStatusFacts, headlineEmoji, taskStatusOf } from "./task-status.js";
|
|
11
11
|
import { asksToFinish, batchLine, chatText, chatTitle, factLinkLabel, mentions, READY_ACTIONS } from "./chat-voice.js";
|
|
12
12
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
13
|
-
import { isLifecycleNotification } from "./store.js";
|
|
13
|
+
import { isLifecycleNotification, taskArchived } from "./store.js";
|
|
14
14
|
import { leadSubjectOf } from "./lead-voice.js";
|
|
15
15
|
import { decideFallbackLink, decideTargetOf } from "./chat-decide.js";
|
|
16
|
+
import { YOUIYOUX_CARD_KIND } from "./contracts/youiyoux-request.js";
|
|
16
17
|
/** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
|
|
17
18
|
* attention fact, a result Ready for review, a security alert or release, and anything addressed to one person
|
|
18
19
|
* (a sign-in or plan-limit pause, an evening digest). */
|
|
@@ -104,8 +105,8 @@ export function quietCardView(store, taskRefs, now, root, viewer) {
|
|
|
104
105
|
return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/work" } };
|
|
105
106
|
}
|
|
106
107
|
/** Facts that carry their own controls or must stand alone (a security alert, a decision, a flow card, a
|
|
107
|
-
* screenshot, an acceptance packet) are never folded into a batch. */
|
|
108
|
-
const OWN_MESSAGE_KINDS = new Set(["security-release", "secret-detected", "flow-decision", "flow-card", "acceptance-ready", "acceptance-evidence"]);
|
|
108
|
+
* screenshot, an acceptance packet, a youiyoux pass's card) are never folded into a batch. */
|
|
109
|
+
const OWN_MESSAGE_KINDS = new Set(["security-release", "secret-detected", "flow-decision", "flow-card", "acceptance-ready", "acceptance-evidence", YOUIYOUX_CARD_KIND]);
|
|
109
110
|
/** Whether this update for a person joins their two-minute batch: anything about one task that needs them,
|
|
110
111
|
* except a fact that must stand alone. */
|
|
111
112
|
export function joinsBatch(row) {
|
|
@@ -198,7 +199,8 @@ export function eveningDigestText(store, account, now, root) {
|
|
|
198
199
|
const visible = (row) => row.project !== null && store.accountCanAccess(account, row.project);
|
|
199
200
|
const title = (taskId) => chatTitle(store, taskId);
|
|
200
201
|
const finished = new Map(), failed = new Map(), waits = new Map();
|
|
201
|
-
|
|
202
|
+
// A put-away task (v145) is out of the digest too: nothing about it waits, finished or failed for anyone now.
|
|
203
|
+
const facts = store.taskFactsSince(recent).filter(visible).filter(row => row.taskId === null || !taskArchived(store, row.taskId));
|
|
202
204
|
for (const row of facts) {
|
|
203
205
|
if (row.createdAt < since || row.taskId === null)
|
|
204
206
|
continue;
|
package/dist/chat-review.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Store } from "./store.js";
|
|
2
2
|
import type { VerifiedApprover } from "./principal.js";
|
|
3
|
+
import { type FeedbackImage } from "./result-review.js";
|
|
3
4
|
export type ReviewSnapshot = {
|
|
4
5
|
task: string;
|
|
5
6
|
root: string;
|
|
@@ -9,11 +10,13 @@ export type ReviewSnapshot = {
|
|
|
9
10
|
sha: string;
|
|
10
11
|
source: string;
|
|
11
12
|
title: string;
|
|
13
|
+
images?: FeedbackImage[];
|
|
12
14
|
notes: {
|
|
13
15
|
id: number;
|
|
14
16
|
note: string;
|
|
15
17
|
path: string | null;
|
|
16
18
|
line: number | null;
|
|
19
|
+
image?: FeedbackImage | null;
|
|
17
20
|
}[];
|
|
18
21
|
};
|
|
19
22
|
type Problem = {
|
|
@@ -32,6 +35,7 @@ export type ReviewRequest = {
|
|
|
32
35
|
path: string | null;
|
|
33
36
|
line: number | null;
|
|
34
37
|
notes: number[];
|
|
38
|
+
image?: FeedbackImage | null;
|
|
35
39
|
};
|
|
36
40
|
export declare function reviewInputProblem(note: string | null, path: string | null, line: number | null): string | null;
|
|
37
41
|
/** Chat and the result form write the same comments and call the same revision service.
|
package/dist/chat-review.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { selectResultImages } from "./chat-evidence.js";
|
|
1
2
|
import { readVerifiedArtifact } from "./evidence.js";
|
|
2
3
|
import { hasForbiddenControls, validateNote } from "./decision.js";
|
|
3
|
-
import { isRevisionFeedback, revisionSourceOf } from "./result-review.js";
|
|
4
|
-
import { createResultRevision } from "./result-actions.js";
|
|
4
|
+
import { parseFeedbackImage, isRevisionFeedback, revisionSourceOf } from "./result-review.js";
|
|
5
|
+
import { createResultRevision, feedbackImageProblem } from "./result-actions.js";
|
|
5
6
|
/** Read precisely the result named, never silently substitute a newer version. */
|
|
6
7
|
export function readChatResult(store, who, evidenceRoot, task, run) {
|
|
7
8
|
const ref = store.lookupRef(task);
|
|
@@ -21,11 +22,13 @@ export function readChatResult(store, who, evidenceRoot, task, run) {
|
|
|
21
22
|
const proven = readVerifiedArtifact(evidenceRoot, artifact);
|
|
22
23
|
if (!proven.ok)
|
|
23
24
|
return { ok: false, message: "The saved changes could not be verified. Restore the result before adding feedback." };
|
|
25
|
+
const images = selectResultImages(store, who, evidenceRoot, task, found.id);
|
|
24
26
|
return { ok: true, snapshot: {
|
|
25
27
|
task, root: family.root.id, execution: family.current.id, run: found.id,
|
|
26
28
|
artifact: artifact.id, sha: artifact.sha256, source: revisionSourceOf(store.getScope(task)?.digest ?? null),
|
|
27
29
|
title: store.getTask(task)?.title ?? task,
|
|
28
|
-
|
|
30
|
+
images: images.ok ? images.selection.images.map(one => ({ run: found.id, artifact: one.artifact, sha256: one.sha256 })) : [],
|
|
31
|
+
notes: store.liveDiffComments(found.id).filter(isRevisionFeedback).map(one => ({ id: one.id, note: one.note, path: one.path, line: one.line, image: one.image ?? null })),
|
|
29
32
|
} };
|
|
30
33
|
}
|
|
31
34
|
export function reviewInputProblem(note, path, line) {
|
|
@@ -65,9 +68,15 @@ export function applyChatReview(store, who, evidenceRoot, request, now, allowMod
|
|
|
65
68
|
return fail("Some feedback changed or was already used. Review it again.");
|
|
66
69
|
if (operation === "note" && (note === null || notes.length > 0))
|
|
67
70
|
return fail("A saved note needs feedback text and cannot consume other notes.");
|
|
71
|
+
const image = parseFeedbackImage(request.image);
|
|
72
|
+
if (image === false || (image !== null && note === null))
|
|
73
|
+
return fail("Choose a screenshot and write its feedback again.");
|
|
74
|
+
const imageProblem = feedbackImageProblem(store, evidenceRoot, live.run, image);
|
|
75
|
+
if (imageProblem !== null)
|
|
76
|
+
return fail(imageProblem);
|
|
68
77
|
const batch = [...notes].sort((a, b) => a - b);
|
|
69
78
|
if (note !== null) {
|
|
70
|
-
const id = store.addDiffComment({ artifactId: live.artifact, runId: live.run, path, line, note, author: who.name }, now);
|
|
79
|
+
const id = store.addDiffComment({ artifactId: live.artifact, runId: live.run, path, line, note, author: who.name, image }, now);
|
|
71
80
|
if (id === null)
|
|
72
81
|
return fail("The note could not be saved.");
|
|
73
82
|
batch.push(id);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Scope } from "./scope.js";
|
|
2
|
+
/** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
|
|
3
|
+
export declare function permissionPlainWords(profile: Scope["profile"] | null | undefined): string | null;
|
|
4
|
+
export type ShownScope = Pick<Scope, "goal" | "outOfScope" | "touches" | "acceptance" | "profile" | "budgetMicrousd">;
|
|
5
|
+
/** Every word of the terms a yes agrees to, as the card shows them: the goal, what's excluded, the files (or plainly any
|
|
6
|
+
* file), each requirement with the evidence it needs, and what the agent may do and spend. Nothing is shortened. */
|
|
7
|
+
export declare function scopeTermsText(scope: ShownScope): string;
|
|
8
|
+
/** The digest of the terms shown, bound to the scope's own digest: a changed word, limit or route is another digest. */
|
|
9
|
+
export declare function shownTermsDigest(scope: ShownScope & Pick<Scope, "digest">): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scope's terms as a chat card shows them whole, and the digest of exactly those words. A task filed from outside
|
|
3
|
+
* text (a youiyoux change request) approves from chat only on a card that showed every word of its terms, nothing cut:
|
|
4
|
+
* the card binds that digest to its button, and the seal (store.ts, the outside-request quarantine) recomputes it from
|
|
5
|
+
* the live scope and refuses a tap that names anything else. A leaf: the store and the chat road both read it.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { isAcpProfile } from "./acp-agents.js";
|
|
9
|
+
/** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
|
|
10
|
+
export function permissionPlainWords(profile) {
|
|
11
|
+
if (profile === null || profile === undefined)
|
|
12
|
+
return null;
|
|
13
|
+
const full = "full access, nothing asks first";
|
|
14
|
+
if (profile.provider === "claude") {
|
|
15
|
+
return profile.permissionArgv === "bypassPermissions" ? full
|
|
16
|
+
: profile.permissionArgv === "auto" ? "file edits and routine commands; anything risky stops"
|
|
17
|
+
: "file edits only; commands are refused";
|
|
18
|
+
}
|
|
19
|
+
if (profile.provider === "gemini")
|
|
20
|
+
return profile.approvalArgv === "yolo" ? full : "file edits only; other tools are refused";
|
|
21
|
+
if (isAcpProfile(profile))
|
|
22
|
+
return "full access inside the OS fence, nothing asks first";
|
|
23
|
+
return profile.sandboxMode === "danger-full-access" ? full : "file edits and commands inside the project only";
|
|
24
|
+
}
|
|
25
|
+
const money = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
|
|
26
|
+
/** Every word of the terms a yes agrees to, as the card shows them: the goal, what's excluded, the files (or plainly any
|
|
27
|
+
* file), each requirement with the evidence it needs, and what the agent may do and spend. Nothing is shortened. */
|
|
28
|
+
export function scopeTermsText(scope) {
|
|
29
|
+
const allows = [permissionPlainWords(scope.profile) ?? "agent permissions not known", scope.budgetMicrousd === null ? "no attempt limit" : `up to ${money(scope.budgetMicrousd)} per attempt`];
|
|
30
|
+
return [
|
|
31
|
+
"Goal:", scope.goal,
|
|
32
|
+
"",
|
|
33
|
+
"Not this:", scope.outOfScope ?? "Nothing excluded.",
|
|
34
|
+
"",
|
|
35
|
+
`Files: ${scope.touches.length === 0 ? "any file (no file list)" : scope.touches.join(", ")}`,
|
|
36
|
+
"",
|
|
37
|
+
"Done when:",
|
|
38
|
+
...(scope.acceptance.length === 0 ? ["You decide when you review the result."] : scope.acceptance.map(one => `• ${one.statement} (${one.evidence.join(", ")})`)),
|
|
39
|
+
"",
|
|
40
|
+
`Starting allows: ${allows.join(" · ")}`,
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
/** The digest of the terms shown, bound to the scope's own digest: a changed word, limit or route is another digest. */
|
|
44
|
+
export function shownTermsDigest(scope) {
|
|
45
|
+
return createHash("sha256").update(JSON.stringify({ v: 1, scope: scope.digest, shown: scopeTermsText(scope) })).digest("hex");
|
|
46
|
+
}
|
package/dist/claim.d.ts
CHANGED
package/dist/claim.js
CHANGED
|
@@ -31,7 +31,7 @@ import { randomUUID } from "node:crypto";
|
|
|
31
31
|
import { authenticate, drainOf } from "./runner.js";
|
|
32
32
|
import { pauseForAuth } from "./provider-auth.js";
|
|
33
33
|
import { DEFAULT_MAX_OPEN_DECISIONS, missingCapability, scopeApprovedForDispatch, taskReadinessBlocker, } from "./dispatch.js";
|
|
34
|
-
import { BUILT_IN, } from "./store.js";
|
|
34
|
+
import { BUILT_IN, refArchived, } from "./store.js";
|
|
35
35
|
import { digestOf } from "./scope.js";
|
|
36
36
|
import { parseExecutionPlanDocument } from "./plan.js";
|
|
37
37
|
import { sealUnchangedPlanUnderMode } from "./plan-auto.js";
|
|
@@ -109,6 +109,9 @@ function acquireLocked(store, taskRef, runner, options) {
|
|
|
109
109
|
if (placedRef !== null && placedRef.coordinatorCid !== null && !store.scopeSealed(placedRef.externalId)) {
|
|
110
110
|
return { ok: false, reason: "coordinator-filed" };
|
|
111
111
|
}
|
|
112
|
+
// A PUT-AWAY TASK (v145, task-tidy.md) claims for nobody, on every road: restore it first.
|
|
113
|
+
if (refArchived(store, taskRef))
|
|
114
|
+
return { ok: false, reason: "archived" };
|
|
112
115
|
// The reservation gate lives HERE, in the one primitive every
|
|
113
116
|
// acquisition path shares (queue-columns review, finding 1): tick's
|
|
114
117
|
// acquireIfReady and the raw CLI claim both
|
|
@@ -160,6 +160,13 @@ export declare const chatActionRequestSchemas: {
|
|
|
160
160
|
readonly flow_trigger_remove: z.ZodObject<{
|
|
161
161
|
trigger: z.ZodInt;
|
|
162
162
|
}, z.core.$strict>;
|
|
163
|
+
/** Browser use in the flow's script zones (flow-browser.ts): allowing it takes the person's own password in the secure screen. */
|
|
164
|
+
readonly flow_browser: z.ZodObject<{
|
|
165
|
+
flow: z.ZodInt;
|
|
166
|
+
}, z.core.$strict>;
|
|
167
|
+
readonly flow_browser_off: z.ZodObject<{
|
|
168
|
+
flow: z.ZodInt;
|
|
169
|
+
}, z.core.$strict>;
|
|
163
170
|
readonly subagent_create: z.ZodObject<{
|
|
164
171
|
repo: z.ZodString;
|
|
165
172
|
template: z.ZodOptional<z.ZodUnknown>;
|
|
@@ -282,6 +289,8 @@ export declare const sharedActionSchema: z.ZodObject<{
|
|
|
282
289
|
operation: z.ZodEnum<{
|
|
283
290
|
decision_record: "decision_record";
|
|
284
291
|
decision_retire: "decision_retire";
|
|
292
|
+
flow_browser: "flow_browser";
|
|
293
|
+
flow_browser_off: "flow_browser_off";
|
|
285
294
|
flow_card_add: "flow_card_add";
|
|
286
295
|
flow_card_approve: "flow_card_approve";
|
|
287
296
|
flow_card_assign: "flow_card_assign";
|
|
@@ -61,6 +61,9 @@ export const chatActionRequestSchemas = {
|
|
|
61
61
|
flow_trigger_pause: z.strictObject({ trigger: id }),
|
|
62
62
|
flow_trigger_resume: z.strictObject({ trigger: id }),
|
|
63
63
|
flow_trigger_remove: z.strictObject({ trigger: id }),
|
|
64
|
+
/** Browser use in the flow's script zones (flow-browser.ts): allowing it takes the person's own password in the secure screen. */
|
|
65
|
+
flow_browser: z.strictObject({ flow: id }),
|
|
66
|
+
flow_browser_off: z.strictObject({ flow: id }),
|
|
64
67
|
subagent_create: z.strictObject({ repo: words, template: z.unknown().optional(), name: z.unknown().optional(), soul: z.unknown().optional() }),
|
|
65
68
|
subagent_soul: z.strictObject({ subagent: id, soul: words }),
|
|
66
69
|
subagent_state: z.strictObject({ subagent: id, state: z.enum(["active", "paused", "removed"]) }),
|