toolroll 0.9.83 → 0.9.85
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 +69 -0
- package/README.md +94 -1192
- package/dist/agent-fence.d.ts +3 -3
- package/dist/agent-fence.js +3 -3
- package/dist/agent-onboard.d.ts +2 -0
- package/dist/agent-onboard.js +3 -0
- package/dist/assignment-ui.js +5 -1
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/browser-crew.js +5 -1
- package/dist/browser-workspace.d.ts +12 -0
- package/dist/chat-channel.d.ts +10 -1
- package/dist/chat-channel.js +35 -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 +3 -2
- 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/contracts/console-api.d.ts +4 -2
- package/dist/contracts/console-api.js +7 -2
- package/dist/contracts/lead-tools.d.ts +30 -0
- package/dist/contracts/lead-tools.js +3 -0
- package/dist/contracts/youiyoux-request.d.ts +248 -0
- package/dist/contracts/youiyoux-request.js +152 -0
- package/dist/control-setup.d.ts +9 -0
- package/dist/control-setup.js +31 -4
- package/dist/control-ui.d.ts +7 -1
- package/dist/control-ui.js +3 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +26 -4
- package/dist/first-run.d.ts +5 -2
- package/dist/first-run.js +13 -2
- 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/folder-look.d.ts +3 -0
- package/dist/folder-look.js +8 -4
- package/dist/guides.js +4 -3
- 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 +30 -4
- package/dist/lead.d.ts +2 -0
- package/dist/lead.js +3 -3
- package/dist/model-catalog.d.ts +5 -0
- package/dist/model-catalog.js +11 -2
- package/dist/model-choices.d.ts +188 -0
- package/dist/model-choices.js +405 -0
- package/dist/model-names.d.ts +6 -0
- package/dist/model-names.js +34 -0
- package/dist/models-ui.d.ts +69 -5
- package/dist/models-ui.js +53 -10
- package/dist/needs-you.d.ts +8 -0
- package/dist/needs-you.js +8 -0
- package/dist/operate.d.ts +22 -2
- package/dist/operate.js +107 -41
- package/dist/project-activity.d.ts +20 -0
- package/dist/project-activity.js +23 -0
- package/dist/project-admission.d.ts +4 -1
- package/dist/project-admission.js +23 -2
- package/dist/project-cli.d.ts +1 -0
- package/dist/project-cli.js +36 -19
- package/dist/project-identity.d.ts +9 -2
- package/dist/project-identity.js +80 -33
- package/dist/project-knowledge.d.ts +9 -9
- package/dist/project-outcomes.d.ts +119 -0
- package/dist/project-outcomes.js +79 -0
- package/dist/result-actions.d.ts +4 -0
- package/dist/result-actions.js +41 -9
- package/dist/result-review.d.ts +9 -0
- package/dist/result-review.js +45 -5
- package/dist/serve.js +60 -13
- package/dist/server/chat.js +5 -4
- package/dist/server/chrome.d.ts +3 -0
- package/dist/server/chrome.js +5 -2
- package/dist/server/console.css +17 -2
- package/dist/server/flows.js +4 -0
- package/dist/server/http.d.ts +1 -0
- package/dist/server/http.js +6 -0
- package/dist/server/pages.js +53 -7
- package/dist/server/people-tokens.js +40 -20
- package/dist/server/render-chat.js +3 -2
- package/dist/server/render-pages.d.ts +12 -2
- package/dist/server/render-pages.js +36 -16
- package/dist/server/render-people.d.ts +14 -2
- package/dist/server/render-people.js +35 -12
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +20 -7
- package/dist/server/render-settings.js +1 -0
- package/dist/server/render-tasks.d.ts +1 -0
- package/dist/server/render-tasks.js +22 -19
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/route-table.js +9 -1
- package/dist/server/settings.d.ts +58 -1
- package/dist/server/settings.js +216 -70
- package/dist/server/signup-gate.d.ts +9 -0
- package/dist/server/signup-gate.js +40 -0
- package/dist/server/task-views.js +6 -0
- package/dist/server/tasks.js +54 -8
- package/dist/settings-kit-script.d.ts +21 -0
- package/dist/settings-kit-script.js +67 -0
- package/dist/settings-kit.d.ts +137 -0
- package/dist/settings-kit.js +186 -0
- package/dist/settings-proofs.d.ts +64 -0
- package/dist/settings-proofs.js +101 -0
- package/dist/setup-guide.js +4 -2
- package/dist/spend-report.d.ts +87 -0
- package/dist/spend-report.js +265 -0
- package/dist/spend-ui.d.ts +29 -8
- package/dist/spend-ui.js +74 -39
- package/dist/store.d.ts +46 -3
- package/dist/store.js +165 -6
- package/dist/task-sizing.d.ts +5 -3
- package/dist/task-sizing.js +4 -4
- 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/toolroll-update.js +2 -2
- package/dist/webhooks.d.ts +6 -0
- package/dist/webhooks.js +25 -0
- package/dist/work-index.d.ts +4 -0
- package/dist/work-index.js +32 -0
- package/dist/youiyoux-intake.d.ts +234 -0
- package/dist/youiyoux-intake.js +908 -0
- package/package.json +11 -8
package/dist/browser-crew.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { chatControlHref, chatResultHref } from './chat-controls.js';
|
|
2
|
+
import { startBuilderHref } from './needs-you.js';
|
|
2
3
|
/** The index is navigation only. Controls retain their exact owning task, run,
|
|
3
4
|
* decision and approval ceremony; opening a result keeps its saved execution. */
|
|
4
5
|
export function browserWorkActionHref(item) {
|
|
@@ -25,13 +26,16 @@ export function browserWorkActionHref(item) {
|
|
|
25
26
|
}
|
|
26
27
|
if (runId !== null && (action.code === 'inspect-run' || action.code === 'reconcile-run'))
|
|
27
28
|
return `/r/${runId}`;
|
|
29
|
+
// The task page only repeats that no builder is connected: lead to the command that connects one.
|
|
30
|
+
if (action.code === 'start-worker')
|
|
31
|
+
return startBuilderHref(taskId);
|
|
28
32
|
const anchor = action.code === 'approve-scope' ? '#approve'
|
|
29
33
|
: action.code === 'inspect-stop' || action.code === 'resume-run' ? '#task-control'
|
|
30
34
|
// Retry (and Build again) is the task's status card itself, at the top: no anchor to its options.
|
|
31
35
|
: action.code === 'unhold' ? '#task-actions'
|
|
32
36
|
: action.code === 'write-scope' || action.code === 'select-agent' ? '#scope'
|
|
33
37
|
: action.code === 'inspect-hold' ? '#holds'
|
|
34
|
-
: action.code === '
|
|
38
|
+
: action.code === 'repair-dependency' ? '#run-status' : '';
|
|
35
39
|
return `/t/${encodeURIComponent(item.rootId)}?version=${encodeURIComponent(taskId)}` + anchor;
|
|
36
40
|
}
|
|
37
41
|
/** Render an already-admitted page without repeating its database query. Every task reads its one headline, the
|
|
@@ -624,6 +624,10 @@ export type BrowserProjectRow = {
|
|
|
624
624
|
};
|
|
625
625
|
export type BrowserProjectsView = {
|
|
626
626
|
kind: 'projects';
|
|
627
|
+
activity?: {
|
|
628
|
+
project: string;
|
|
629
|
+
report: import('./project-outcomes.js').ProjectOutcomes | null;
|
|
630
|
+
};
|
|
627
631
|
/** Arrived from New task: choosing where the task belongs. */
|
|
628
632
|
choosing: boolean;
|
|
629
633
|
problem: string | null;
|
|
@@ -711,6 +715,7 @@ export type BrowserCheckItem = {
|
|
|
711
715
|
src: string;
|
|
712
716
|
href: string;
|
|
713
717
|
caption: string;
|
|
718
|
+
image?: import("./result-review.js").FeedbackImage;
|
|
714
719
|
}[];
|
|
715
720
|
};
|
|
716
721
|
/** The result's one decision: what Accept is called, why it isn't plain
|
|
@@ -1464,6 +1469,13 @@ export type BrowserWorkspace = {
|
|
|
1464
1469
|
sensitive: boolean;
|
|
1465
1470
|
/** Passwordless is on for this signed-in browser: ceremony password fields are left out (docs/plans/passwordless.md). */
|
|
1466
1471
|
passwordless?: boolean;
|
|
1472
|
+
/** The session's open project (its path), or null: the sidebar's project picker names it. */
|
|
1473
|
+
openProject?: string | null;
|
|
1474
|
+
/** The settings frame's breadcrumb ("Settings / <page>"; settings-kit.ts): the header shows it as links. */
|
|
1475
|
+
crumbs?: {
|
|
1476
|
+
label: string;
|
|
1477
|
+
href: string | null;
|
|
1478
|
+
}[];
|
|
1467
1479
|
/** What this person named their lead (Settings → Lead): the chat header and every lead message say it. */
|
|
1468
1480
|
leadName?: string;
|
|
1469
1481
|
refreshUrl: string;
|
package/dist/chat-channel.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FeedbackImage } from "./result-review.js";
|
|
1
2
|
import type { DoorOutcome } from "./lead-doors.js";
|
|
2
3
|
import { type VerifiedApprover } from "./principal.js";
|
|
3
4
|
import type { ChatConfig, LeadProposal, LeadSession, LeadThread, Store, SubscriptionChatProviderId } from "./store.js";
|
|
@@ -40,7 +41,7 @@ export declare function tooLongText(length: number): string;
|
|
|
40
41
|
/** A reply to a message about several tasks is never guessed: it names them and says how to choose. */
|
|
41
42
|
export declare function whichTaskText(titles: readonly string[]): string;
|
|
42
43
|
/** The server-authored context a reply to a result message carries into the turn — the exact execution and run, never a guess. */
|
|
43
|
-
export declare function replyContextFor(taskId: string, run: number | null): string;
|
|
44
|
+
export declare function replyContextFor(taskId: string, run: number | null, image?: FeedbackImage): string;
|
|
44
45
|
/** The context a chosen task (/tasks, /task <name>) carries into each turn from that chat. */
|
|
45
46
|
export declare function focusContextFor(taskId: string): string;
|
|
46
47
|
/** No trusted https origin is configured: one honest line, no localhost, no promise. */
|
|
@@ -69,6 +70,14 @@ export declare function armedCardText(proposal: LeadProposal, previewText: strin
|
|
|
69
70
|
/** The "yes" button's label for an armed card. */
|
|
70
71
|
export declare function armedYesLabel(proposal: LeadProposal): string;
|
|
71
72
|
export declare function proposalLink(store: Store, proposal: LeadProposal, repos: readonly string[], channel?: ChatChannelName | null): PhoneLink | null;
|
|
73
|
+
/** The personal Telegram card: the shared preview, or a handoff to the lead conversation when it cannot be shown in full. */
|
|
74
|
+
export declare function phoneProposalPreview(store: Store, proposal: LeadProposal, repos: readonly string[]): {
|
|
75
|
+
text: string;
|
|
76
|
+
buttons: boolean;
|
|
77
|
+
closed?: true;
|
|
78
|
+
};
|
|
79
|
+
/** The personal Telegram card's link: the lead conversation holding a card too long to show, else the shared link. */
|
|
80
|
+
export declare function phoneProposalLink(store: Store, proposal: LeadProposal, repos: readonly string[], channel?: ChatChannelName | null): PhoneLink | null;
|
|
72
81
|
/**
|
|
73
82
|
* The link a CONFIRMED card carries: the exact recorded task the outcome
|
|
74
83
|
* names, where its remaining step lives — the approval control while the
|
package/dist/chat-channel.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { parseFeedbackImage } from "./result-review.js";
|
|
1
2
|
/** Shared membership-chat identity, proposal previews and result links. Transport adapters never grant authority. */
|
|
2
3
|
import { FLOW_HREF } from "./flow-engine.js";
|
|
3
|
-
import { closedQuestionWords } from "./decision.js";
|
|
4
|
+
import { closedQuestionWords, LIMITS } from "./decision.js";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
6
|
import { sharedActionPayload, sharedActionNeedsReview, sharedActionReviewPath, sharedActionAllowsChallenge, CHAT_ACTIONS, } from "./chat-actions.js";
|
|
6
7
|
import { isDirectChatProvider, subscriptionCredentialKey } from "./converse.js";
|
|
@@ -132,12 +133,13 @@ export function whichTaskText(titles) {
|
|
|
132
133
|
].join("\n");
|
|
133
134
|
}
|
|
134
135
|
/** The server-authored context a reply to a result message carries into the turn — the exact execution and run, never a guess. */
|
|
135
|
-
export function replyContextFor(taskId, run) {
|
|
136
|
+
export function replyContextFor(taskId, run, image) {
|
|
136
137
|
return [
|
|
137
138
|
`Current task: ${taskId}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.`,
|
|
138
139
|
run === null
|
|
139
140
|
? `The operator is replying to a message about execution ${taskId}.`
|
|
140
141
|
: `The operator is replying to result #${run} from execution ${taskId}. Use get_result for that exact execution and run when responding to feedback; do not substitute another result. If the operator asks for changes, use propose_review revise; use note only when they explicitly ask to save feedback without starting work.`,
|
|
142
|
+
...(image === undefined ? [] : [`The reply targets screenshot ${image.artifact} on result #${image.run}. Retain image: ${image.artifact} in propose_review so the exact screenshot stays with new feedback; pass image: null only when the feedback is not about it. Read get_result_images for this result first.`]),
|
|
141
143
|
].join(" ");
|
|
142
144
|
}
|
|
143
145
|
/** The context a chosen task (/tasks, /task <name>) carries into each turn from that chat. */
|
|
@@ -146,6 +148,10 @@ export function focusContextFor(taskId) {
|
|
|
146
148
|
}
|
|
147
149
|
/** A step the phone cannot take itself: said once; the button (or its absence) says where. */
|
|
148
150
|
const HANDOFF = "This step finishes in Toolroll.";
|
|
151
|
+
/** One Telegram message; a card that cannot fit is never confirmed from a shortened copy. */
|
|
152
|
+
const TELEGRAM_TEXT_MAX = 4_096;
|
|
153
|
+
const PHONE_TOO_LONG = "This is too long to show in full here. It is waiting in your Toolroll chat to review and confirm.";
|
|
154
|
+
const LEAD_CHAT_LINK = { label: "Open chat", path: "/chat" };
|
|
149
155
|
/** No trusted https origin is configured: one honest line, no localhost, no promise. */
|
|
150
156
|
export const NO_PHONE_LINK = "Phone access isn't configured. Open Toolroll on your computer.";
|
|
151
157
|
/** An origin exists but the card's task is not one this phone may reach now: said as that, never as missing setup. */
|
|
@@ -229,6 +235,23 @@ export function proposalLink(store, proposal, repos, channel = null) {
|
|
|
229
235
|
}
|
|
230
236
|
return null;
|
|
231
237
|
}
|
|
238
|
+
/** A personal Telegram card fits one message, or it waits, whole, in the person's lead conversation in Toolroll. */
|
|
239
|
+
function fitsPhone(preview) {
|
|
240
|
+
return !preview.buttons || preview.text.length <= TELEGRAM_TEXT_MAX;
|
|
241
|
+
}
|
|
242
|
+
/** The personal Telegram card: the shared preview, or a handoff to the lead conversation when it cannot be shown in full. */
|
|
243
|
+
export function phoneProposalPreview(store, proposal, repos) {
|
|
244
|
+
const preview = proposalPreview(store, proposal, repos, "telegram");
|
|
245
|
+
return fitsPhone(preview)
|
|
246
|
+
? preview
|
|
247
|
+
: { text: [preview.text.split("\n")[0], PHONE_TOO_LONG, "", HANDOFF].join("\n"), buttons: false };
|
|
248
|
+
}
|
|
249
|
+
/** The personal Telegram card's link: the lead conversation holding a card too long to show, else the shared link. */
|
|
250
|
+
export function phoneProposalLink(store, proposal, repos, channel = null) {
|
|
251
|
+
return fitsPhone(proposalPreview(store, proposal, repos, "telegram"))
|
|
252
|
+
? proposalLink(store, proposal, repos, channel)
|
|
253
|
+
: LEAD_CHAT_LINK;
|
|
254
|
+
}
|
|
232
255
|
/**
|
|
233
256
|
* The link a CONFIRMED card carries: the exact recorded task the outcome
|
|
234
257
|
* names, where its remaining step lives — the approval control while the
|
|
@@ -410,20 +433,22 @@ export function proposalPreview(store, proposal, repos, channel = null) {
|
|
|
410
433
|
? payload["notes"]
|
|
411
434
|
: [];
|
|
412
435
|
const saved = (snapshot?.notes ?? []).filter((one) => selected.includes(one.id));
|
|
413
|
-
const note = t("note",
|
|
436
|
+
const note = t("note", LIMITS.note);
|
|
437
|
+
const image = parseFeedbackImage(payload["image"]);
|
|
414
438
|
const path = t("path", 300);
|
|
415
439
|
return card(`${revise ? "Request changes to" : "Save feedback on"} result #${String(payload["run"] ?? "?")} of ${taskName}`, [
|
|
416
440
|
...(note === ""
|
|
417
441
|
? []
|
|
418
442
|
: [
|
|
419
443
|
`Your feedback${path === "" ? "" : ` (${path}${typeof payload["line"] === "number" ? `:${payload["line"]}` : ""})`}:`,
|
|
420
|
-
...
|
|
444
|
+
...(image ? [`Screenshot ${image.artifact} · result #${image.run}`] : []),
|
|
445
|
+
...lines(note, LIMITS.note),
|
|
421
446
|
]),
|
|
422
447
|
...(saved.length === 0
|
|
423
448
|
? []
|
|
424
449
|
: [
|
|
425
450
|
"Saved notes included:",
|
|
426
|
-
...saved.map((one) => `• ${one.path === null ? "" : `${phoneText(one.path, 120)}${one.line === null ? "" : `:${one.line}`}: `}${phoneText(one.note,
|
|
451
|
+
...saved.map((one) => `• ${one.image ? `Screenshot ${one.image.artifact} · result #${one.image.run}: ` : ""}${one.path === null ? "" : `${phoneText(one.path, 120)}${one.line === null ? "" : `:${one.line}`}: `}${phoneText(one.note, LIMITS.note)}`),
|
|
427
452
|
]),
|
|
428
453
|
], revise
|
|
429
454
|
? "Creates a revision of the same task. Its approval follows your settings."
|
|
@@ -484,6 +509,11 @@ export const CHAT_ACTION_PARITY = {
|
|
|
484
509
|
how: "Reads current tasks, decisions, results and project knowledge from the local database through the shared engine, within the enrolled projects.",
|
|
485
510
|
gap: "Dedicated Telegram and Slack journeys for this tool and live channel rendering remain unverified.",
|
|
486
511
|
},
|
|
512
|
+
get_project_activity: {
|
|
513
|
+
support: "direct",
|
|
514
|
+
how: "Reads the project's Ready backlog, finished-to-decision elapsed time, explicit revisions and recorded cost for completed families in an accessible enrolled project through the shared engine.",
|
|
515
|
+
gap: "Dedicated Telegram and Slack journeys for this tool and live channel rendering remain unverified.",
|
|
516
|
+
},
|
|
487
517
|
get_project_context: {
|
|
488
518
|
support: "direct",
|
|
489
519
|
how: "Reads bounded source excerpts or advisory import impact in an accessible enrolled project through the shared engine, with source-search fallback when its index is unavailable.",
|
package/dist/chat-decide.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { type VerifiedApprover } from "./principal.js";
|
|
|
4
4
|
import type { Scope } from "./scope.js";
|
|
5
5
|
import { type ReleaseCard } from "./release-card.js";
|
|
6
6
|
import type { Store } from "./store.js";
|
|
7
|
+
import { permissionPlainWords } from "./chat-terms.js";
|
|
8
|
+
export { permissionPlainWords };
|
|
7
9
|
/** The tokens live in chat_decide_action and the open "What should change?" in chat_decide_prompt (store.ts). */
|
|
8
10
|
export type DecideAct = (typeof DECIDE_ACTS)[number];
|
|
9
11
|
export type DecideTarget = {
|
|
@@ -20,6 +22,11 @@ export type DecideTarget = {
|
|
|
20
22
|
kind: "merge";
|
|
21
23
|
taskId: string;
|
|
22
24
|
run: number;
|
|
25
|
+
}
|
|
26
|
+
/** The tasks one youiyoux pass filed (youiyoux-intake.ts), named by any one of them. */
|
|
27
|
+
| {
|
|
28
|
+
kind: "requests";
|
|
29
|
+
taskId: string;
|
|
23
30
|
};
|
|
24
31
|
export type DecideChannel = "telegram" | "slack" | "discord" | "teams";
|
|
25
32
|
/** Who is tapping, where: one paired person's binding on one channel, and the chat the card is in. */
|
|
@@ -56,11 +63,13 @@ export declare const DECIDE_LABELS: {
|
|
|
56
63
|
readonly cancel: "Cancel";
|
|
57
64
|
};
|
|
58
65
|
export declare const WHAT_SHOULD_CHANGE = "What should change?";
|
|
66
|
+
/** `taskId`: the task the button acts on, when it isn't the card's own (a youiyoux card's one-task Approve). */
|
|
59
67
|
type ActSpec = {
|
|
60
68
|
act: DecideAct;
|
|
61
69
|
run: number | null;
|
|
62
70
|
digest: string;
|
|
63
71
|
label?: string;
|
|
72
|
+
taskId?: string;
|
|
64
73
|
};
|
|
65
74
|
/** What a card offers: an optional replacement text (the plan card) and rows of acts and links; a release card also
|
|
66
75
|
* sends its full terms as a file with it (`file`), where the chat app can. */
|
|
@@ -85,11 +94,11 @@ export declare function chatApproveMode(store: Store, repo: string | null, appro
|
|
|
85
94
|
ok: false;
|
|
86
95
|
why: string;
|
|
87
96
|
};
|
|
88
|
-
/** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
|
|
89
|
-
export declare function permissionPlainWords(profile: Scope["profile"] | null | undefined): string | null;
|
|
90
97
|
/** A plan this person's chat may approve now: the term, the plan's exact bytes, and none of the reasons it must open
|
|
91
98
|
* Toolroll instead. `digest` binds the scope and the plan document the card shows. */
|
|
92
|
-
export declare function planInChat(store: Store, taskId: string, approver: string, now: Date, channel: DecideChannel, root?: string
|
|
99
|
+
export declare function planInChat(store: Store, taskId: string, approver: string, now: Date, channel: DecideChannel, root?: string,
|
|
100
|
+
/** A youiyoux card asking (requestsOffer): its own words are checked there, every one shown. */
|
|
101
|
+
from?: "requests"): {
|
|
93
102
|
ok: true;
|
|
94
103
|
modeDigest: string;
|
|
95
104
|
source: "setting" | "mode";
|
|
@@ -109,6 +118,10 @@ export declare function mergeInChat(store: Store, taskId: string, run: number, a
|
|
|
109
118
|
ok: false;
|
|
110
119
|
why: string;
|
|
111
120
|
};
|
|
121
|
+
/** Whether a card's words arrive as one message that shows each of them as written: within Telegram's part, Slack's and
|
|
122
|
+
* (after its escapes) Discord's limits, with room for the question a tap adds. Teams draws card text as Markdown, where
|
|
123
|
+
* a link's label hides its address, and Slack turns `:name:` into an emoji: there such words never approve. */
|
|
124
|
+
export declare function shownWhole(text: string, channel: DecideChannel): boolean;
|
|
112
125
|
/** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. On Telegram the yes's
|
|
113
126
|
* terms come second, under the title, and the steps are fewer: the full plan is behind Edit. */
|
|
114
127
|
export declare function planCardText(store: Store, taskId: string, scope: Scope, root?: string, channel?: DecideChannel): string;
|
|
@@ -260,4 +273,3 @@ export declare function decideTargetOf(row: {
|
|
|
260
273
|
taskId: string | null;
|
|
261
274
|
run: number | null;
|
|
262
275
|
}): DecideTarget | null;
|
|
263
|
-
export {};
|
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))
|