toolroll 0.9.71 → 0.9.73
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 +155 -0
- package/dist/agent-fence.d.ts +10 -2
- package/dist/agent-fence.js +47 -9
- package/dist/approval-authority.d.ts +20 -0
- package/dist/approval-authority.js +67 -0
- package/dist/assignment.js +26 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +28 -28
- package/dist/builder.js +48 -6
- package/dist/chat-actions.js +13 -8
- package/dist/chat-decide.d.ts +47 -1
- package/dist/chat-decide.js +186 -5
- package/dist/check-access.js +2 -2
- package/dist/check-report.d.ts +109 -0
- package/dist/check-report.js +205 -0
- package/dist/claim.js +1 -1
- package/dist/coding-provider.js +4 -2
- package/dist/commit-extract.d.ts +111 -0
- package/dist/commit-extract.js +514 -0
- package/dist/contracts/console-api.d.ts +4 -4
- package/dist/contracts/console-api.js +5 -4
- package/dist/contracts/flow.d.ts +16 -0
- package/dist/contracts/flow.js +3 -0
- package/dist/contracts/release-check.d.ts +335 -0
- package/dist/contracts/release-check.js +159 -0
- package/dist/contracts/release-terms.d.ts +66 -0
- package/dist/contracts/release-terms.js +74 -0
- package/dist/contracts/verification-receipt.d.ts +42 -8
- package/dist/contracts/verification-receipt.js +24 -7
- package/dist/contracts/verification-view.d.ts +1 -0
- package/dist/deploy-gate.d.ts +17 -0
- package/dist/deploy-gate.js +68 -0
- package/dist/dispatch.js +3 -10
- package/dist/dispose.js +4 -1
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +20 -2
- package/dist/evidence.d.ts +1 -0
- package/dist/evidence.js +2 -1
- package/dist/exec.d.ts +1 -1
- package/dist/exec.js +7 -3
- package/dist/flow-engine.js +5 -1
- package/dist/flow-gallery-ui.js +4 -2
- package/dist/flow-gallery.d.ts +3 -0
- package/dist/flow-gallery.js +6 -2
- package/dist/flow-pull-request.d.ts +5 -2
- package/dist/flow-pull-request.js +14 -5
- package/dist/flow-schedule.js +25 -6
- package/dist/flow-triggers.d.ts +8 -0
- package/dist/flow-triggers.js +15 -0
- package/dist/flows-cli.js +13 -3
- package/dist/flows-ui.js +7 -1
- package/dist/guides.js +10 -1
- package/dist/invoke.d.ts +17 -6
- package/dist/invoke.js +48 -18
- package/dist/kits-ui.js +4 -2
- package/dist/kits.d.ts +6 -4
- package/dist/kits.js +9 -11
- package/dist/lead-status.js +5 -2
- package/dist/live-terminal.d.ts +10 -6
- package/dist/live-terminal.js +55 -14
- package/dist/mcp-connect.d.ts +10 -0
- package/dist/mcp-connect.js +2 -0
- package/dist/nightly-check.d.ts +104 -0
- package/dist/nightly-check.js +362 -0
- package/dist/operate.js +39 -2
- package/dist/person-proof.d.ts +2 -0
- package/dist/person-proof.js +14 -0
- package/dist/plan-auto.js +4 -4
- package/dist/planner-source.d.ts +1 -1
- package/dist/project-delete-ui.js +1 -1
- package/dist/project-tools.js +3 -1
- package/dist/proof.js +2 -0
- package/dist/protected-storage.d.ts +67 -0
- package/dist/protected-storage.js +398 -0
- package/dist/publish.js +156 -144
- package/dist/pull-request-flow.d.ts +2 -0
- package/dist/pull-request-flow.js +24 -2
- package/dist/release-adapters.d.ts +16 -1
- package/dist/release-adapters.js +292 -29
- package/dist/release-authority.d.ts +118 -0
- package/dist/release-authority.js +135 -0
- package/dist/release-card.d.ts +76 -0
- package/dist/release-card.js +252 -0
- package/dist/release-carry.d.ts +236 -0
- package/dist/release-carry.js +664 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +158 -6
- package/dist/release-coverage.js +3 -0
- package/dist/release-git.d.ts +43 -0
- package/dist/release-git.js +106 -0
- package/dist/release-recheck.d.ts +146 -0
- package/dist/release-recheck.js +657 -0
- package/dist/release-view.d.ts +34 -0
- package/dist/release-view.js +27 -0
- package/dist/release.d.ts +139 -3
- package/dist/release.js +641 -250
- package/dist/remote-main.d.ts +2 -0
- package/dist/remote-main.js +1 -1
- package/dist/result-completion.d.ts +6 -0
- package/dist/result-completion.js +15 -1
- package/dist/result-follow-ups.js +29 -1
- package/dist/sandbox-runtime.d.ts +16 -5
- package/dist/sandbox-runtime.js +19 -6
- package/dist/scope.d.ts +122 -5
- package/dist/scope.js +236 -7
- package/dist/server/flows.js +40 -10
- package/dist/server/render-settings.js +1 -1
- package/dist/server/render-tasks.d.ts +8 -0
- package/dist/server/render-tasks.js +50 -9
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +26 -5
- package/dist/server/task-views.js +4 -0
- package/dist/server/tasks.js +12 -0
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +230 -5
- package/dist/store.js +733 -37
- package/dist/subagent-memory.d.ts +1 -0
- package/dist/subagent-memory.js +1 -1
- package/dist/subagent-tools.d.ts +22 -6
- package/dist/subagent-tools.js +44 -8
- package/dist/subagent-work.d.ts +2 -0
- package/dist/subagent-work.js +5 -2
- package/dist/subagents-ui.js +6 -4
- package/dist/task-outcome-cli.js +7 -3
- package/dist/telegram.d.ts +6 -1
- package/dist/telegram.js +58 -6
- package/dist/toolroll-update.js +2 -2
- package/dist/verification-evidence.d.ts +25 -1
- package/dist/verification-evidence.js +57 -2
- package/dist/work-index.js +3 -6
- package/package.json +1 -1
package/dist/builder.js
CHANGED
|
@@ -8,6 +8,9 @@ import { queueBatchCheck } from "./batch-checks.js";
|
|
|
8
8
|
import { batchesFullCheck } from "./batch-policy.js";
|
|
9
9
|
import { skillsContext } from "./project-skills.js";
|
|
10
10
|
import { failedVerificationEvidence, sealVerificationReceipt, verificationEvidence, reuseObservationVerification } from "./verification-evidence.js";
|
|
11
|
+
import { CHECK_INVENTORY_CAPTURE, CHECK_REPORT_CAPTURE, prepareCheckAttempt, sealCheckOutcome } from "./check-report.js";
|
|
12
|
+
import { planTargetedRecheck, settleCoverage } from "./release-recheck.js";
|
|
13
|
+
import { effectiveCheckFacts } from "./verification-evidence.js";
|
|
11
14
|
import { learningContext } from "./project-learning.js";
|
|
12
15
|
import { knowledgeContext } from "./project-knowledge.js";
|
|
13
16
|
import { flowGoalCuts } from "./flow-engine.js";
|
|
@@ -322,12 +325,13 @@ export function proveApprovedProfile(scope, given) {
|
|
|
322
325
|
budgetMicrousd: scope.budgetMicrousd,
|
|
323
326
|
acceptance: scope.acceptance, candidate: scope.candidate ?? null,
|
|
324
327
|
qualityMode: scope.qualityMode ?? "default",
|
|
328
|
+
releaseTerms: scope.releaseTerms ?? null,
|
|
325
329
|
}, snapshot,
|
|
326
330
|
// v47: the SEALED route is part of the signed bytes whenever it
|
|
327
331
|
// says more than the legacy resolution — re-derived here from
|
|
328
332
|
// the seal's own snapshot, never from mutable configuration.
|
|
329
333
|
routeFromJson(scope.approvedRouteJson ?? null))
|
|
330
|
-
: digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null });
|
|
334
|
+
: digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, releaseTerms: scope.releaseTerms ?? null });
|
|
331
335
|
if (rederived !== scope.approvedDigest) {
|
|
332
336
|
return { ok: false, message: "the approval record does not verify against the stored terms — re-approve (stale-approval)" };
|
|
333
337
|
}
|
|
@@ -416,7 +420,7 @@ export async function build(store, request) {
|
|
|
416
420
|
return {
|
|
417
421
|
ok: false,
|
|
418
422
|
reason: "mode-ended",
|
|
419
|
-
message: `${taskId}'s approval
|
|
423
|
+
message: `${taskId}'s approval no longer stands (an ended mode or flow grant, or an approval that wasn't a person's) — a person approves it again`,
|
|
420
424
|
};
|
|
421
425
|
}
|
|
422
426
|
// v24 DISPATCH PROOF (foundations rulings 10/12, findings 6/17): what is
|
|
@@ -2251,6 +2255,9 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2251
2255
|
const batched = repo !== null && chosenCheck !== null && batchesFullCheck(store, repo, chosenCheck.level, configured);
|
|
2252
2256
|
const gateCommand = batched ? null : configured;
|
|
2253
2257
|
let verifyCommand;
|
|
2258
|
+
let checkAttempt = null;
|
|
2259
|
+
let releaseFacts = null;
|
|
2260
|
+
const verification = { last: null };
|
|
2254
2261
|
const checkLog = [];
|
|
2255
2262
|
const checkOutcomes = [];
|
|
2256
2263
|
const checkSuites = [];
|
|
@@ -2296,6 +2303,20 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2296
2303
|
else {
|
|
2297
2304
|
const verifyRunner = request.verify ?? run;
|
|
2298
2305
|
const verifyShell = approvedCommandShell(configured.command);
|
|
2306
|
+
// A Full check of a prepared candidate (a release gate, the nightly check): its attempt is sealed before the command
|
|
2307
|
+
// runs — intended mode, nonce, inventory, context — and the handshake goes on its stdin (check-report.ts).
|
|
2308
|
+
// A release task's check is targeted when the release's last full check allows it (release-recheck.ts): its plan and
|
|
2309
|
+
// obligation snapshot are sealed with the attempt; otherwise it runs in full, told which tests it must still report.
|
|
2310
|
+
if (repo !== null && chosenCheck?.level === "full" && (store.getScope(request.taskId)?.candidate ?? null) !== null) {
|
|
2311
|
+
const access = checkLaunchOf(store, runId, captured.effective.profile).access;
|
|
2312
|
+
const decision = planTargetedRecheck(store, root, { runId, taskId: request.taskId, repo, head: sealedHead, command: configured, access, now: now() });
|
|
2313
|
+
checkAttempt = prepareCheckAttempt(store, root, { runId, repo, head: sealedHead, command: configured, access, authority: decision.authority,
|
|
2314
|
+
...(decision.mode === "targeted" ? { plan: decision.plan, snapshot: decision.snapshot } : {}), watch: decision.watch }, now());
|
|
2315
|
+
if (checkAttempt !== null && decision.mode === "targeted")
|
|
2316
|
+
store.addRunNote(runId, "Toolroll", `Re-running only what failed at run #${decision.plan.base}: ${decision.plan.units.length} unit files, ${decision.plan.groups.reduce((n, one) => n + one.groups.length, 0)} journey groups, ${decision.plan.versions.length} upgrade versions${decision.plan.parts.length > 0 ? `, and in full: ${decision.plan.parts.join(", ")}` : ""}.`, now());
|
|
2317
|
+
}
|
|
2318
|
+
else
|
|
2319
|
+
checkAttempt = null;
|
|
2299
2320
|
const checkProgress = new CheckProgressTracker(snapshot => {
|
|
2300
2321
|
store.saveCheckProgress(runId, snapshot, now());
|
|
2301
2322
|
});
|
|
@@ -2315,6 +2336,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2315
2336
|
},
|
|
2316
2337
|
envAllowlist: SETUP_ENV_ALLOWLIST,
|
|
2317
2338
|
omitEnv: SETUP_ENV_DENYLIST,
|
|
2339
|
+
...(checkAttempt === null ? {} : { stdin: checkAttempt.stdin }),
|
|
2318
2340
|
onStdout: chunk => { streamed.stdout = true; checkProgress.feed(chunk, "stdout"); },
|
|
2319
2341
|
onStderr: chunk => { streamed.stderr = true; checkProgress.feed(chunk, "stderr"); },
|
|
2320
2342
|
}));
|
|
@@ -2327,6 +2349,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2327
2349
|
checkOutcomes.push(attemptOutcome(label, result));
|
|
2328
2350
|
noteCheckSuite(label, result);
|
|
2329
2351
|
checkLog.push(attemptLog(label, configured.command, result));
|
|
2352
|
+
verification.last = result;
|
|
2330
2353
|
return result;
|
|
2331
2354
|
};
|
|
2332
2355
|
const ownInstallIfChanged = async () => {
|
|
@@ -2497,6 +2520,22 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2497
2520
|
}
|
|
2498
2521
|
}
|
|
2499
2522
|
checkProgress.finish();
|
|
2523
|
+
// The check's own report (the last line carrying the nonce), sealed beside what was sealed before it ran.
|
|
2524
|
+
const last = verification.last;
|
|
2525
|
+
if (checkAttempt !== null && last !== null && !last.notFound) {
|
|
2526
|
+
sealCheckOutcome(store, root, checkAttempt, { stdout: last.stdout, exitCode: last.timedOut ? null : last.code }, now());
|
|
2527
|
+
}
|
|
2528
|
+
// What the check covered (receipt version 3): full, composed from the release's last full check, partial, or
|
|
2529
|
+
// identity-removed (which stops the release carrying).
|
|
2530
|
+
if (checkAttempt !== null) {
|
|
2531
|
+
const settled = settleCoverage(store, root, { runId, taskId: request.taskId, exitCode: last === null || last.notFound || last.timedOut ? null : last.code, now: now() }, run => { const gate = verificationEvidence(store, root, run); return gate.ok ? gate.digest : null; });
|
|
2532
|
+
const sha = (capture) => { const found = store.artifactsFor(runId).filter(a => a.kind === "structured-output" && a.capture === capture); return found.length === 1 ? found[0].sha256 : null; };
|
|
2533
|
+
// The one place coverage meets the outcome: a partial or identity-removed check is a failed attempt for every
|
|
2534
|
+
// reader from here on (the verdict, the run's check, the receipt, reuse, the deploy gates).
|
|
2535
|
+
const effective = effectiveCheckFacts(verifyCommand, { coverage: settled.coverage, check: { attempt: checkAttempt.mode, report: sha(CHECK_REPORT_CAPTURE), inventory: sha(CHECK_INVENTORY_CAPTURE) }, composed: settled.composed });
|
|
2536
|
+
verifyCommand = effective.result;
|
|
2537
|
+
releaseFacts = effective.facts;
|
|
2538
|
+
}
|
|
2500
2539
|
}
|
|
2501
2540
|
if (configured !== null && checkLog.length > 0) {
|
|
2502
2541
|
const summary = `=== Attempt summary ===\n${checkOutcomes.length === 0 ? "No command started." : checkOutcomes.map(one => `- ${one}`).join("\n")}`;
|
|
@@ -2513,14 +2552,17 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2513
2552
|
});
|
|
2514
2553
|
}
|
|
2515
2554
|
if (configured !== null && checkLog.length > 0)
|
|
2516
|
-
sealVerificationReceipt(store, root, runId, sealedHead, configured, verifyCommand, now());
|
|
2555
|
+
sealVerificationReceipt(store, root, runId, sealedHead, configured, verifyCommand, now(), undefined, releaseFacts ?? undefined);
|
|
2517
2556
|
// The receipt above remains the evidence. This compact projection is what
|
|
2518
2557
|
// `status` and `task wait` can read without opening logs. A reused gate did
|
|
2519
2558
|
// not execute here, so its label says so instead of implying a fresh run.
|
|
2559
|
+
// A release check that didn't cover what it owed (partial) or lost a required test reads failed, whatever its exit.
|
|
2560
|
+
const uncovered = releaseFacts !== null && releaseFacts.coverage !== "full" && releaseFacts.coverage !== "composed";
|
|
2520
2561
|
const checkStatus = verifyCommand.configured === false ? "not-run"
|
|
2521
|
-
:
|
|
2522
|
-
:
|
|
2523
|
-
: "
|
|
2562
|
+
: uncovered ? "failed"
|
|
2563
|
+
: verifyCommand.ran ? verifyCommand.exitCode === 0 ? "passed" : "failed"
|
|
2564
|
+
: checkSuites.some(one => one.status === "failed") ? "failed"
|
|
2565
|
+
: "not-run";
|
|
2524
2566
|
const suites = checkSuites.length > 0 ? checkSuites : verifyCommand.configured === false ? [] : [{
|
|
2525
2567
|
name: reused === null ? chosenCheck?.level === "quick" ? "Quick check" : "Project check" : "Project check (reused)",
|
|
2526
2568
|
status: checkStatus,
|
package/dist/chat-actions.js
CHANGED
|
@@ -36,7 +36,7 @@ import { requestUndo, undoFor } from "./subagent-week.js";
|
|
|
36
36
|
import { kitInstalled, kitOf, KITS, setUpKitNow } from "./kits.js";
|
|
37
37
|
import { starterFlowOf, starterOf, startersFor, starterTerms, STARTER_FLOWS, switchOnStarter } from "./flow-starters.js";
|
|
38
38
|
import { answerSubagentQuestion } from "./subagent-work.js";
|
|
39
|
-
import { callWords, checkRule, defaultRule, grantListed, revokeTool, ruleWords, setToolRules } from "./subagent-tools.js";
|
|
39
|
+
import { callWords, checkRule, defaultRule, grantListed, revokeTool, ruleLoosens, ruleWords, setToolRules } from "./subagent-tools.js";
|
|
40
40
|
import { aboutYouOf, checkAboutYouLine, saveAboutYou, withAboutYouLine } from "./lead-about.js";
|
|
41
41
|
import { CHAT_ACTION_FIELDS, readChatActionRequest, readSharedAction, SHARED_ACTION_VERSION } from "./contracts/chat-actions.js";
|
|
42
42
|
export { CHAT_ACTION_FIELDS };
|
|
@@ -441,12 +441,10 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
|
|
|
441
441
|
throw Error(`${project} has no tool called ${tool}.${named.length === 0 ? " Add it on the Tools page first." : ` Its tools: ${named.join(", ")}.`}${uses}`);
|
|
442
442
|
if (grant !== null)
|
|
443
443
|
throw Error(`${name} can already use ${tool}.`);
|
|
444
|
-
|
|
445
|
-
if (actions.length === 0)
|
|
444
|
+
if ((found.lastTest?.tools ?? []).length === 0)
|
|
446
445
|
throw Error(`${tool} hasn't said what it can do yet. Test it on the Tools page first.`);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
terms.push(...actions.map(one => `${one}: ${defaultRule({ name: one, readOnly: false }).use === "free" ? "does it" : "asks you first"}`), `${name} asks for each call on its turn; Toolroll makes it by these rules, and every call is kept on the card. Change a rule any time.`);
|
|
446
|
+
// Giving a subagent a tool takes the person's password (chat-management.md §0a.3), which never goes in chat.
|
|
447
|
+
throw Error(`Giving ${name} a tool takes your password. Open ${name}'s page in Toolroll, then Tools → Let ${name} use ${tool}.`);
|
|
450
448
|
}
|
|
451
449
|
else if (change === "revoke") {
|
|
452
450
|
if (grant === null)
|
|
@@ -463,6 +461,10 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
|
|
|
463
461
|
const checked = checkRule(grant, action, { use: input["use"], limit, undo: input["undoWith"] ?? grant.rules[action]?.undo ?? "" });
|
|
464
462
|
if (!checked.ok)
|
|
465
463
|
throw Error(checked.said);
|
|
464
|
+
// Letting it do more on its own takes the person's password (chat-management.md §0a.3), never a card.
|
|
465
|
+
const info = grant.actions.find(one => one.name === action);
|
|
466
|
+
if (ruleLoosens(grant.rules[action] ?? defaultRule(info), checked.rule))
|
|
467
|
+
throw Error(`Letting ${name} do more with ${action} on its own takes your password. Change the rule on ${name}'s page in Toolroll.`);
|
|
466
468
|
state = { rules: JSON.stringify(grant.rules) };
|
|
467
469
|
title = `${name}: ${action} — ${ruleWords(checked.rule)}`;
|
|
468
470
|
terms.push(`${tool} → ${action}: ${ruleWords(checked.rule)}.`, `Was: ${ruleWords(grant.rules[action] ?? { use: "ask" })}.`, ...(checked.rule.undo === undefined ? [] : [`A person can undo it with ${checked.rule.undo}, called with the same input.`]));
|
|
@@ -1168,7 +1170,7 @@ export function executeSharedAction(store, who, id, payload, now, options) {
|
|
|
1168
1170
|
}
|
|
1169
1171
|
else if (payload.operation === "kit_setup") {
|
|
1170
1172
|
const kit = kitOf(String(req["kit"]));
|
|
1171
|
-
const made = setUpKitNow(store, kit, repo, actor, now, null
|
|
1173
|
+
const made = setUpKitNow(store, kit, repo, actor, now, null);
|
|
1172
1174
|
if (!made.ok)
|
|
1173
1175
|
throw Error(made.said);
|
|
1174
1176
|
return { ok: true, taskId: null, said: `${made.said} Try it with a sample card from its page.`, href: `/kits/${kit.id}?repo=${encodeURIComponent(repo)}` };
|
|
@@ -1431,7 +1433,10 @@ function runSubagentAction(store, payload, actor, now) {
|
|
|
1431
1433
|
if ((grant === null ? "" : JSON.stringify(grant.rules)) !== payload.state["rules"])
|
|
1432
1434
|
throw Error("Someone changed its tools since. Ask for a fresh proposal.");
|
|
1433
1435
|
const found = projectToolsOf(store, mate.repo).find(one => one.name === tool);
|
|
1434
|
-
|
|
1436
|
+
// A grant takes the person's password on the subagent's page (chat-management.md §0a.3): never from a card.
|
|
1437
|
+
if (req["change"] === "grant")
|
|
1438
|
+
throw Error("Giving a subagent a tool takes your password. Do it on its page in Toolroll.");
|
|
1439
|
+
const done = req["change"] === "grant" ? found === undefined ? { ok: false, said: `There's no tool called ${tool} any more.` } : grantListed(store, mate, found, null, actor, now, {})
|
|
1435
1440
|
: req["change"] === "revoke" ? revokeTool(store, mate, tool, actor, now)
|
|
1436
1441
|
: setToolRules(store, mate, tool, { [String(req["action"])]: { use: req["use"], limit: req["limitField"] === undefined || req["limitField"] === null || req["limitField"] === "" ? null : { field: req["limitField"], over: req["limitOver"] },
|
|
1437
1442
|
undo: req["undoWith"] ?? grant?.rules[String(req["action"])]?.undo ?? "" } }, actor, now);
|
package/dist/chat-decide.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { DECIDE_ACTS } from "./contracts/chat-callback-rows.js";
|
|
|
2
2
|
import { type ChatApprovalLimits } from "./chat-approval.js";
|
|
3
3
|
import { type VerifiedApprover } from "./principal.js";
|
|
4
4
|
import type { Scope } from "./scope.js";
|
|
5
|
+
import { type ReleaseCard } from "./release-card.js";
|
|
5
6
|
import type { Store } from "./store.js";
|
|
6
7
|
/** The tokens live in chat_decide_action and the open "What should change?" in chat_decide_prompt (store.ts). */
|
|
7
8
|
export type DecideAct = (typeof DECIDE_ACTS)[number];
|
|
@@ -59,11 +60,17 @@ type ActSpec = {
|
|
|
59
60
|
act: DecideAct;
|
|
60
61
|
run: number | null;
|
|
61
62
|
digest: string;
|
|
63
|
+
label?: string;
|
|
62
64
|
};
|
|
63
|
-
/** What a card offers: an optional replacement text (the plan card) and rows of acts and links
|
|
65
|
+
/** What a card offers: an optional replacement text (the plan card) and rows of acts and links; a release card also
|
|
66
|
+
* sends its full terms as a file with it (`file`), where the chat app can. */
|
|
64
67
|
export type DecideOffer = {
|
|
65
68
|
text: string | null;
|
|
66
69
|
rows: Array<Array<ActSpec | DecideLink>>;
|
|
70
|
+
file?: {
|
|
71
|
+
name: string;
|
|
72
|
+
text: string;
|
|
73
|
+
};
|
|
67
74
|
};
|
|
68
75
|
/** What lets this person's chat on `channel` approve on this repository now: their lasting chat-approval setting
|
|
69
76
|
* (chat-approval.ts), or else a live signed mode carrying the chatApprove term, signed by them, that names this chat
|
|
@@ -127,12 +134,24 @@ export declare function hasLiveDecideTokens(store: Store, channel: DecideChannel
|
|
|
127
134
|
export declare function isDecideToken(store: Store, token: string): boolean;
|
|
128
135
|
export type DecideTapOutcome = {
|
|
129
136
|
ack: string;
|
|
137
|
+
/** A release card's full terms, sent as a file before its Approve is offered (chat apps that limit a message). */
|
|
138
|
+
file?: {
|
|
139
|
+
name: string;
|
|
140
|
+
text: string;
|
|
141
|
+
};
|
|
130
142
|
/** The card repainted: its text and buttons now. */
|
|
131
143
|
edit?: {
|
|
132
144
|
text: string;
|
|
133
145
|
rows: DecideButton[][];
|
|
134
146
|
tokens: string[];
|
|
135
147
|
};
|
|
148
|
+
/** With `file`, where the chat app carries the terms only as a file: the card instead of `edit` when the file didn't
|
|
149
|
+
* arrive. No Approve, a fresh Continue to send it again; the channel drops whichever set it doesn't show. */
|
|
150
|
+
unsent?: {
|
|
151
|
+
text: string;
|
|
152
|
+
rows: DecideButton[][];
|
|
153
|
+
tokens: string[];
|
|
154
|
+
};
|
|
136
155
|
/** Ask "What should change?": the person's next message is the feedback. `id` names the prompt, so the channel can
|
|
137
156
|
* record the message it sent (a reply to anything else is not the feedback). */
|
|
138
157
|
prompt?: {
|
|
@@ -152,6 +171,33 @@ export type ChatMerge = {
|
|
|
152
171
|
repo: string;
|
|
153
172
|
via: string;
|
|
154
173
|
};
|
|
174
|
+
/** Where a release card is tapped: the person's binding and the chat the tap came from. */
|
|
175
|
+
export type ReleaseSeat = Pick<DecideSeat, "channel" | "binding" | "chat" | "approver">;
|
|
176
|
+
/**
|
|
177
|
+
* Whether a release card's tap comes from the person's own private chat with Toolroll (chat-management.md §0a.5), from
|
|
178
|
+
* the binding's own record, failing closed: Telegram, a private chat (a positive id, which Telegram makes equal to the
|
|
179
|
+
* user's own id) that is the binding's chat and its user's; Slack, a direct-message channel (D…) that is the binding's.
|
|
180
|
+
* Any other chat app, a missing chat or binding, or another person's binding is not.
|
|
181
|
+
*/
|
|
182
|
+
export declare function privateReleaseChat(store: Store, seat: ReleaseSeat): boolean;
|
|
183
|
+
/**
|
|
184
|
+
* A deploy gate this person may approve from this chat now, through its release card: every plain-plan rule
|
|
185
|
+
* (planInChat: their Approve from chat setting and limits, no coordinator-, lead- or scout-written text, organisation
|
|
186
|
+
* policy, protected work), never a nightly check, only in their private chat, and the card as the live records say
|
|
187
|
+
* it now (chat-management.md §0a.5).
|
|
188
|
+
*/
|
|
189
|
+
export declare function releaseInChat(store: Store, taskId: string, approver: string, now: Date, channel: DecideChannel,
|
|
190
|
+
/** The seat a tap came from, checked strictly; "offer" only where the card is drawn for the binding's own chat (no act). */
|
|
191
|
+
seat: ReleaseSeat | "offer", root?: string): {
|
|
192
|
+
ok: true;
|
|
193
|
+
card: ReleaseCard;
|
|
194
|
+
modeDigest: string;
|
|
195
|
+
source: "setting" | "mode";
|
|
196
|
+
scope: Scope;
|
|
197
|
+
} | {
|
|
198
|
+
ok: false;
|
|
199
|
+
why: string;
|
|
200
|
+
};
|
|
155
201
|
/** The links a card keeps once its acts are spent or stale: where to look, never a token. */
|
|
156
202
|
export declare function linksFor(target: DecideTarget): DecideButton[][];
|
|
157
203
|
/** Where a plan card's task is now, for a card whose buttons no longer act: the family's current version, named only
|
package/dist/chat-decide.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* The same in every chat app: Telegram's bridge and the shared engine Slack, Discord and Teams run on (chat-delivery.ts)
|
|
13
13
|
* offer, mint and answer these through the same tokens (the `channel` column). Every act runs through the door the
|
|
14
14
|
* console uses. */
|
|
15
|
+
import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, releaseTaskRef } from "./store.js";
|
|
15
16
|
import { isAcpProfile } from "./acp-agents.js";
|
|
16
17
|
import { DECIDE_ACTS, readDecideActionRow } from "./contracts/chat-callback-rows.js";
|
|
17
18
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -27,6 +28,8 @@ import { verifyApproverStanding } from "./principal.js";
|
|
|
27
28
|
import { pullRequestFollowOf } from "./pull-request-flow.js";
|
|
28
29
|
import { requestResultChanges } from "./result-actions.js";
|
|
29
30
|
import { revisionSourceOf } from "./result-review.js";
|
|
31
|
+
import { APPROVAL_PROOF, approvalOf, approvalProofDetail, deployGateScope, quarantinedAuthor } from "./scope.js";
|
|
32
|
+
import { releaseCardOf } from "./release-card.js";
|
|
30
33
|
import { phoneText } from "./telegram-status.js";
|
|
31
34
|
/** The words, said once. */
|
|
32
35
|
export const DECIDE_LABELS = {
|
|
@@ -42,6 +45,7 @@ export const DECIDE_LABELS = {
|
|
|
42
45
|
cancel: "Cancel",
|
|
43
46
|
};
|
|
44
47
|
const OFFER_TTL_MS = 7 * 86_400_000;
|
|
48
|
+
const CHANNEL_NAMES = { telegram: "Telegram", slack: "Slack", discord: "Discord", teams: "Teams" };
|
|
45
49
|
const CONFIRM_TTL_MS = 10 * 60_000;
|
|
46
50
|
const PROMPT_TTL_MS = 30 * 60_000;
|
|
47
51
|
export const WHAT_SHOULD_CHANGE = "What should change?";
|
|
@@ -100,7 +104,9 @@ export function planInChat(store, taskId, approver, now, channel, root) {
|
|
|
100
104
|
const scope = store.getScope(taskId);
|
|
101
105
|
if (ref === null || ref.repo === null || scope === null)
|
|
102
106
|
return { ok: false, why: "This plan isn't available now." };
|
|
103
|
-
|
|
107
|
+
// Approved only as dispatch reads it (approval policy): an old seal a lead token made, or one whose authority has
|
|
108
|
+
// ended, is no approval, so the person can approve it here.
|
|
109
|
+
if (approvalOf(scope).approved && store.modeApprovalLive(ref.id, now))
|
|
104
110
|
return { ok: false, why: "This plan is already approved." };
|
|
105
111
|
if (ref.plan === "requested")
|
|
106
112
|
return { ok: false, why: "The plan is still being written." };
|
|
@@ -113,7 +119,7 @@ export function planInChat(store, taskId, approver, now, channel, root) {
|
|
|
113
119
|
if (family === null || family.problem !== null || family.current.id !== taskId)
|
|
114
120
|
return { ok: false, why: "A newer version of this task is current." };
|
|
115
121
|
// What the mode's seal refuses anyway, said first: model- or coordinator-written terms, an unreadable route.
|
|
116
|
-
if (ref.coordinatorCid !== null || (scope.proposedVia ?? null)
|
|
122
|
+
if (ref.coordinatorCid !== null || quarantinedAuthor(scope.proposedVia ?? null))
|
|
117
123
|
return { ok: false, why: "This plan was written for you, so you approve it in Toolroll." };
|
|
118
124
|
if (scope.profileState === "unresolved" || scope.routeEra == null || (scope.termsProblem ?? null) !== null)
|
|
119
125
|
return { ok: false, why: "This plan can't say exactly which agents run, so you approve it in Toolroll." };
|
|
@@ -158,6 +164,12 @@ export function planInChat(store, taskId, approver, now, channel, root) {
|
|
|
158
164
|
/** A ready pull request this person's chat may merge now, bound to the exact commit. */
|
|
159
165
|
export function mergeInChat(store, taskId, run, approver, now, channel) {
|
|
160
166
|
const ref = store.lookupRef(taskId);
|
|
167
|
+
// The nightly check of main never merges (release-once.md, part 3).
|
|
168
|
+
if (ref !== null && nightlyTaskRef(store, ref.id))
|
|
169
|
+
return { ok: false, why: NIGHTLY_CHECK_ONLY };
|
|
170
|
+
// A release's result merges only through its coordinator.
|
|
171
|
+
if (ref !== null && releaseTaskRef(store, ref.id))
|
|
172
|
+
return { ok: false, why: RELEASE_MERGES_ONLY };
|
|
161
173
|
const publication = store.publicationForRun(run);
|
|
162
174
|
const follow = publication === null ? null : pullRequestFollowOf(store, publication.id);
|
|
163
175
|
if (ref === null || ref.repo === null || publication === null || follow === null || publication.prNumber === null)
|
|
@@ -252,6 +264,30 @@ export function decideOffer(store, target, who, now, channel, root) {
|
|
|
252
264
|
const plan = planInChat(store, target.taskId, who.name, now, channel, root);
|
|
253
265
|
if (!plan.ok)
|
|
254
266
|
return null;
|
|
267
|
+
const release = deployGateScope(plan.scope);
|
|
268
|
+
// Checks beyond the sandbox seal only on the person's password (store.ts sealScopeApproval): the card says so and
|
|
269
|
+
// links to the approval in Toolroll, and offers no Continue or Approve that would dead-end. A release or a plan.
|
|
270
|
+
if (passwordOnlySeal(plan.scope)) {
|
|
271
|
+
let head;
|
|
272
|
+
try {
|
|
273
|
+
head = release ? releaseCardOf(store, plan.scope, plan.digest, { profile: permissionPlainWords }).short.split("\n").slice(0, 3)
|
|
274
|
+
: planCardText(store, target.taskId, plan.scope, root, channel).split("\n").slice(0, 1);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
head = [title(store, target.taskId)];
|
|
278
|
+
}
|
|
279
|
+
return { text: [...head, "", needsPassword(plan.scope)].join("\n"), rows: [[{ label: "Approve in Toolroll", path: chatControlHref("approval", target.taskId) }]] };
|
|
280
|
+
}
|
|
281
|
+
// A deploy gate is approved from chat only through its release card (chat-management.md §0a.5).
|
|
282
|
+
if (release) {
|
|
283
|
+
const release = releaseInChat(store, target.taskId, who.name, now, channel, "offer", root);
|
|
284
|
+
if (!release.ok)
|
|
285
|
+
return null;
|
|
286
|
+
const card = release.card;
|
|
287
|
+
return { text: channel === "telegram" ? card.short : `${card.short}\n\n${card.full}`,
|
|
288
|
+
rows: [[{ act: "approve", run: null, digest: releaseDigest(card), label: "Continue" }], [{ label: DECIDE_LABELS.edit, path: chatControlHref("approval", target.taskId) }, { act: "not-now", run: null, digest: releaseDigest(card) }]],
|
|
289
|
+
...(channel === "telegram" ? { file: { name: card.fileName, text: card.full } } : {}) };
|
|
290
|
+
}
|
|
255
291
|
return { text: planCardText(store, target.taskId, plan.scope, root, channel),
|
|
256
292
|
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 }]] };
|
|
257
293
|
}
|
|
@@ -310,7 +346,7 @@ export function mintDecideButtons(store, seat, target, offer, now, message = nul
|
|
|
310
346
|
return { label: one.label, link: one };
|
|
311
347
|
const token = mint(store, seat, message, one, target.taskId, "offer", now);
|
|
312
348
|
tokens.push(token);
|
|
313
|
-
return { label: DECIDE_LABELS[one.act], token };
|
|
349
|
+
return { label: one.label ?? DECIDE_LABELS[one.act], token };
|
|
314
350
|
}));
|
|
315
351
|
return { rows, tokens };
|
|
316
352
|
}
|
|
@@ -340,6 +376,66 @@ export function hasLiveDecideTokens(store, channel, chat, message, now) {
|
|
|
340
376
|
export function isDecideToken(store, token) {
|
|
341
377
|
return token.startsWith("d:") && store.handle.prepare("SELECT 1 FROM chat_decide_action WHERE token = ?").get(token) !== undefined;
|
|
342
378
|
}
|
|
379
|
+
/** A release card's acts carry its card digest (release-card.ts), never a plan's. */
|
|
380
|
+
const RELEASE_DIGEST = "release:";
|
|
381
|
+
/** Why a password-only scope (passwordOnlySeal) isn't approved from chat, for a release or a plan. */
|
|
382
|
+
const needsPassword = (scope) => `This ${deployGateScope(scope) ? "release" : "plan"}'s checks run outside the sandbox, so it's approved in Toolroll with your password.`;
|
|
383
|
+
/** Whether only a person's password ceremony seals this scope (store.ts sealScopeApproval): checks that reach beyond the
|
|
384
|
+
* sandbox (sign-ins, or none at all) or extra macOS services, sealed into its profile. */
|
|
385
|
+
const passwordOnlySeal = (scope) => {
|
|
386
|
+
const profile = scope.profile;
|
|
387
|
+
return profile != null && (profile["checkAccess"] !== undefined || profile["machLookup"] !== undefined);
|
|
388
|
+
};
|
|
389
|
+
const releaseDigest = (card) => `${RELEASE_DIGEST}${card.digest}`;
|
|
390
|
+
/**
|
|
391
|
+
* Whether a release card's tap comes from the person's own private chat with Toolroll (chat-management.md §0a.5), from
|
|
392
|
+
* the binding's own record, failing closed: Telegram, a private chat (a positive id, which Telegram makes equal to the
|
|
393
|
+
* user's own id) that is the binding's chat and its user's; Slack, a direct-message channel (D…) that is the binding's.
|
|
394
|
+
* Any other chat app, a missing chat or binding, or another person's binding is not.
|
|
395
|
+
*/
|
|
396
|
+
export function privateReleaseChat(store, seat) {
|
|
397
|
+
const row = store.handle.prepare("SELECT member, channel, approver FROM chat_binding WHERE provider = ? AND id = ? AND revoked IS NULL").get(seat.channel, seat.binding);
|
|
398
|
+
if (row === undefined || String(row["approver"]) !== seat.approver || seat.chat !== String(row["channel"]))
|
|
399
|
+
return false;
|
|
400
|
+
if (seat.channel === "telegram")
|
|
401
|
+
return /^[1-9][0-9]*$/.test(seat.chat) && seat.chat === String(row["member"]);
|
|
402
|
+
if (seat.channel === "slack")
|
|
403
|
+
return /^D[A-Z0-9]+$/.test(seat.chat);
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* A deploy gate this person may approve from this chat now, through its release card: every plain-plan rule
|
|
408
|
+
* (planInChat: their Approve from chat setting and limits, no coordinator-, lead- or scout-written text, organisation
|
|
409
|
+
* policy, protected work), never a nightly check, only in their private chat, and the card as the live records say
|
|
410
|
+
* it now (chat-management.md §0a.5).
|
|
411
|
+
*/
|
|
412
|
+
export function releaseInChat(store, taskId, approver, now, channel,
|
|
413
|
+
/** The seat a tap came from, checked strictly; "offer" only where the card is drawn for the binding's own chat (no act). */
|
|
414
|
+
seat, root) {
|
|
415
|
+
const plan = planInChat(store, taskId, approver, now, channel, root);
|
|
416
|
+
if (!plan.ok)
|
|
417
|
+
return plan;
|
|
418
|
+
if (!deployGateScope(plan.scope))
|
|
419
|
+
return { ok: false, why: "This plan isn't a release." };
|
|
420
|
+
if (passwordOnlySeal(plan.scope))
|
|
421
|
+
return { ok: false, why: needsPassword(plan.scope) };
|
|
422
|
+
const ref = store.lookupRef(taskId);
|
|
423
|
+
if (ref === null || nightlyTaskRef(store, ref.id))
|
|
424
|
+
return { ok: false, why: NIGHTLY_CHECK_ONLY };
|
|
425
|
+
if (seat !== "offer" && (seat.channel !== channel || !privateReleaseChat(store, seat)))
|
|
426
|
+
return { ok: false, why: "A release is approved only in your private chat with Toolroll." };
|
|
427
|
+
let card;
|
|
428
|
+
try {
|
|
429
|
+
card = releaseCardOf(store, plan.scope, plan.digest, { profile: permissionPlainWords });
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
return { ok: false, why: "This release's terms can't be shown here, so you approve it in Toolroll." };
|
|
433
|
+
}
|
|
434
|
+
// What changed is part of what's approved: a list that couldn't be read (or was refused as rewritten history) is no card.
|
|
435
|
+
if (plan.scope.releaseTerms != null && card.changes.commits === null)
|
|
436
|
+
return { ok: false, why: "This release's list of changes couldn't be read, so you approve it in Toolroll." };
|
|
437
|
+
return { ok: true, card, modeDigest: plan.modeDigest, source: plan.source, scope: plan.scope };
|
|
438
|
+
}
|
|
343
439
|
const QUESTIONS = {
|
|
344
440
|
accept: name => `Accept and finish "${name}"?`,
|
|
345
441
|
retry: name => `Retry "${name}"? It queues another attempt.`,
|
|
@@ -356,6 +452,24 @@ function targetOf(row) {
|
|
|
356
452
|
}
|
|
357
453
|
}
|
|
358
454
|
/** Why the card's act no longer applies, or null while it still does — checked at the offer and again at the yes. */
|
|
455
|
+
/**
|
|
456
|
+
* A release card's terms, then its Approve (Continue, and a release card refreshed after it changed): the full terms
|
|
457
|
+
* (a file on Telegram, where a message is limited; inline elsewhere), Approve and Cancel bound to this card's digest,
|
|
458
|
+
* and on Telegram the card to show instead when the file didn't arrive: no Approve, a Continue to send it again.
|
|
459
|
+
*/
|
|
460
|
+
function releaseContinue(store, seat, message, taskId, name, card, now, ack, lead) {
|
|
461
|
+
const spec = { act: "approve", run: null, digest: releaseDigest(card) };
|
|
462
|
+
const yes = mint(store, seat, message, spec, taskId, "yes", now);
|
|
463
|
+
const cancel = mint(store, seat, message, spec, taskId, "cancel", now);
|
|
464
|
+
const text = `${seat.channel === "telegram" ? card.short : `${card.short}\n\n${card.full}`}${lead === null ? "" : `\n\n${lead}`}`;
|
|
465
|
+
// Telegram carries the terms only as the file: Approve shows only once that file has arrived (telegram.ts).
|
|
466
|
+
const again = seat.channel === "telegram" ? mint(store, seat, message, spec, taskId, "offer", now) : null;
|
|
467
|
+
return { ack, file: { name: card.fileName, text: card.full },
|
|
468
|
+
edit: { text: `${text}\n\nApprove release "${name}"? This approves exactly these terms${seat.channel === "telegram" ? `, every one in ${card.fileName}` : ""}.`,
|
|
469
|
+
rows: [[{ label: "Approve", token: yes }, { label: DECIDE_LABELS.cancel, token: cancel }]], tokens: [yes, cancel] },
|
|
470
|
+
...(again === null ? {} : { unsent: { text: `${text}\n\nThe terms file didn't arrive, so Approve isn't shown. Tap Continue to send it again.`,
|
|
471
|
+
rows: [[{ label: "Continue", token: again }]], tokens: [again] } }) };
|
|
472
|
+
}
|
|
359
473
|
function staleWhy(store, row, who, now, channel, root) {
|
|
360
474
|
switch (row.act) {
|
|
361
475
|
case "accept":
|
|
@@ -374,9 +488,21 @@ function staleWhy(store, row, who, now, channel, root) {
|
|
|
374
488
|
}
|
|
375
489
|
case "approve":
|
|
376
490
|
case "not-now": {
|
|
491
|
+
if (row.digest.startsWith(RELEASE_DIGEST)) {
|
|
492
|
+
// The button's own seat as it was minted: its binding's chat, checked against the binding's record.
|
|
493
|
+
const release = releaseInChat(store, row.taskId, who.name, now, channel, { channel, binding: row.binding ?? -1, chat: row.chat ?? "", approver: who.name }, root);
|
|
494
|
+
if (!release.ok)
|
|
495
|
+
return release.why;
|
|
496
|
+
return releaseDigest(release.card) === row.digest ? null : "This release changed while you were reading.";
|
|
497
|
+
}
|
|
377
498
|
const plan = planInChat(store, row.taskId, who.name, now, channel, root);
|
|
378
499
|
if (!plan.ok)
|
|
379
500
|
return plan.why;
|
|
501
|
+
// A deploy gate's plain plan card never approves it: only its release card does.
|
|
502
|
+
if (deployGateScope(plan.scope))
|
|
503
|
+
return "This is a release: approve it from its release card.";
|
|
504
|
+
if (passwordOnlySeal(plan.scope))
|
|
505
|
+
return needsPassword(plan.scope);
|
|
380
506
|
return plan.digest === row.digest ? null : "The plan changed since this card was sent.";
|
|
381
507
|
}
|
|
382
508
|
case "merge": {
|
|
@@ -417,7 +543,7 @@ export function planWhereNow(store, taskId, who, now, root) {
|
|
|
417
543
|
}
|
|
418
544
|
return { said: `"${name}" is under way.`, rows: [] };
|
|
419
545
|
}
|
|
420
|
-
const QUESTION_STARTS = ["Accept and finish \"", "Retry \"", "Approve and start \"", "Merge \""];
|
|
546
|
+
const QUESTION_STARTS = ["Accept and finish \"", "Retry \"", "Approve and start \"", "Merge \"", "Approve release "];
|
|
421
547
|
/** The card's words without a question this module appended. */
|
|
422
548
|
function cardBody(shown) {
|
|
423
549
|
const at = shown.lastIndexOf("\n\n");
|
|
@@ -474,8 +600,24 @@ export function applyDecideTap(store, seat, input) {
|
|
|
474
600
|
return { ack: "Cancelled.", edit: { text: offer.text ?? body, ...minted } };
|
|
475
601
|
}
|
|
476
602
|
const why = staleWhy(store, row, who, now, seat.channel, input.root);
|
|
477
|
-
if (why !== null)
|
|
603
|
+
if (why !== null) {
|
|
604
|
+
// A release that changed while it was read: the new card, with fresh buttons, so the person reads it again.
|
|
605
|
+
// Through the same gate as Continue: the new terms go first (as a file where the chat app limits a message), and
|
|
606
|
+
// Approve shows only once they arrived; else a Continue to send them again. A release that is now password-only
|
|
607
|
+
// gets its card with the link to Toolroll.
|
|
608
|
+
if (row.digest.startsWith(RELEASE_DIGEST) && row.act === "approve" && why === "This release changed while you were reading.") {
|
|
609
|
+
spend();
|
|
610
|
+
const release = releaseInChat(store, row.taskId, who.name, now, seat.channel, seat, input.root);
|
|
611
|
+
if (release.ok)
|
|
612
|
+
return releaseContinue(store, seat, input.message, row.taskId, name, release.card, now, why, `${why} Here it is now.`);
|
|
613
|
+
const offer = decideOffer(store, target, who, now, seat.channel, input.root);
|
|
614
|
+
if (offer !== null && offer.file === undefined) {
|
|
615
|
+
const minted = mintDecideButtons(store, seat, target, offer, now, input.message);
|
|
616
|
+
return { ack: why, edit: { text: `${offer.text ?? body}\n\n${why} Here it is now.`, ...minted } };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
478
619
|
return stale(why);
|
|
620
|
+
}
|
|
479
621
|
if (row.phase === "offer") {
|
|
480
622
|
if (row.act === "changes") {
|
|
481
623
|
const id = openDecidePrompt(store, seat, row.taskId, row.run ?? 0, row.digest, now);
|
|
@@ -486,6 +628,14 @@ export function applyDecideTap(store, seat, input) {
|
|
|
486
628
|
return { ack: "Okay, it waits.", edit: { text: `${body}\n\nNot now. It waits for you in Tasks.`,
|
|
487
629
|
rows: [[{ label: DECIDE_LABELS.edit, link: { label: DECIDE_LABELS.edit, path: chatControlHref("approval", row.taskId) } }]], tokens: [] } };
|
|
488
630
|
}
|
|
631
|
+
// A release card's Continue: the card again from the live records, its full terms as a file, then Approve.
|
|
632
|
+
if (row.act === "approve" && row.digest.startsWith(RELEASE_DIGEST)) {
|
|
633
|
+
const release = releaseInChat(store, row.taskId, who.name, now, seat.channel, seat, input.root);
|
|
634
|
+
if (!release.ok)
|
|
635
|
+
return stale(release.why);
|
|
636
|
+
spend();
|
|
637
|
+
return releaseContinue(store, seat, input.message, row.taskId, name, release.card, now, "Read the terms, then approve.", null);
|
|
638
|
+
}
|
|
489
639
|
// The first tap asks; only the yes acts, bound to exactly what this button was.
|
|
490
640
|
spend();
|
|
491
641
|
const spec = { act: row.act, run: row.run, digest: row.digest };
|
|
@@ -511,12 +661,43 @@ export function applyDecideTap(store, seat, input) {
|
|
|
511
661
|
return retried.ok ? done("✓ Queued again. Existing approvals and holds still apply.") : notDone(retried.message);
|
|
512
662
|
}
|
|
513
663
|
case "approve": {
|
|
664
|
+
if (row.digest.startsWith(RELEASE_DIGEST)) {
|
|
665
|
+
const release = releaseInChat(store, row.taskId, who.name, now, seat.channel, seat, input.root);
|
|
666
|
+
if (!release.ok)
|
|
667
|
+
return notDone(release.why);
|
|
668
|
+
const cardDigest = release.card.digest;
|
|
669
|
+
// The seal knows which card it came from: a chat seal of a deploy gate needs this evidence (store.ts).
|
|
670
|
+
if (!store.sealScopeApproval(row.taskId, who.name, now, {}, { kind: "chat", modeDigest: release.modeDigest, card: "release", cardDigest }))
|
|
671
|
+
return notDone("the release couldn't be approved from chat. Open it in Toolroll.");
|
|
672
|
+
const sealedDigest = store.getScope(row.taskId)?.approvedDigest ?? "";
|
|
673
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: APPROVAL_PROOF, outcome: "chat", source: "request",
|
|
674
|
+
detail: JSON.stringify({ v: 1, at: now.toISOString(), generation: store.accountOf(who.name)?.generation ?? 0, digest: sealedDigest, card: "release", cardDigest }) });
|
|
675
|
+
const where = `${CHANNEL_NAMES[seat.channel]} · chat binding #${seat.binding}`;
|
|
676
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: "release approved in chat", outcome: "approved",
|
|
677
|
+
source: "request", detail: `via ${where} · ${release.source} ${release.modeDigest} · card ${cardDigest}` });
|
|
678
|
+
// Said everywhere the person hears from Toolroll (their other chats and push), naming the chat it came from.
|
|
679
|
+
const terms = release.scope.releaseTerms ?? null;
|
|
680
|
+
store.enqueueNotification({ dedupeKey: `release-approved-chat:${row.taskId}:${cardDigest}`, kind: "release-approved", recipient: who.name, pushClass: "attention",
|
|
681
|
+
subject: `Release${terms === null ? "" : ` ${terms.packageVersion}`} approved from ${CHANNEL_NAMES[seat.channel]}`,
|
|
682
|
+
body: `You approved "${name}"${terms === null ? "" : ` (${terms.approvedSha.slice(0, 7)}, pull request #${terms.pr})`} from ${CHANNEL_NAMES[seat.channel]}. If that wasn't you, stop it in Toolroll now.`,
|
|
683
|
+
link: chatControlHref("task", row.taskId), source: { taskRef: ref.id } }, now);
|
|
684
|
+
return done("✓ Release approved. It deploys when its check passes and someone runs the release.");
|
|
685
|
+
}
|
|
514
686
|
const plan = planInChat(store, row.taskId, who.name, now, seat.channel, input.root);
|
|
515
687
|
if (!plan.ok)
|
|
516
688
|
return notDone(plan.why);
|
|
689
|
+
if (deployGateScope(plan.scope))
|
|
690
|
+
return notDone("This is a release: approve it from its release card.");
|
|
691
|
+
if (passwordOnlySeal(plan.scope))
|
|
692
|
+
return notDone(needsPassword(plan.scope));
|
|
517
693
|
// The owner's own yes, sealed as theirs ("chat"), never as a mode's: it stands like the console's after the mode or setting ends.
|
|
518
694
|
if (!store.sealScopeApproval(row.taskId, who.name, now, {}, { kind: "chat", modeDigest: plan.modeDigest }))
|
|
519
695
|
return notDone("the plan couldn't be approved from chat. Open it in Toolroll.");
|
|
696
|
+
// The person's proof beside the seal, bound to these exact words (approval policy §1.2): a paired-chat tap, never a
|
|
697
|
+
// password. Release, deploy and the protected counters read it as a person's.
|
|
698
|
+
const sealedDigest = store.getScope(row.taskId)?.approvedDigest ?? "";
|
|
699
|
+
store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: APPROVAL_PROOF, outcome: "chat",
|
|
700
|
+
source: "request", detail: approvalProofDetail(now.getTime(), store.accountOf(who.name)?.generation ?? 0, sealedDigest) });
|
|
520
701
|
store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: "plan approved in chat", outcome: "approved",
|
|
521
702
|
source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · ${plan.source} ${plan.modeDigest}` });
|
|
522
703
|
return done("✓ Approved. Work starts when a worker is free.");
|
package/dist/check-access.js
CHANGED
|
@@ -17,11 +17,11 @@ export const widerThan = (a, b) => RANK[a] > RANK[b];
|
|
|
17
17
|
export const narrowestAccess = (levels) => levels.reduce((low, one) => RANK[one] < RANK[low] ? one : low, levels.length === 0 ? CHECK_ACCESS_DEFAULT : "unsandboxed");
|
|
18
18
|
/** The level as a person reads it, in a sentence's middle ("Checks: …"). */
|
|
19
19
|
export function checkAccessWords(level) {
|
|
20
|
-
return level === "sandboxed" ? "sandboxed" : level === "sign-ins" ? "sandboxed, with model and GitHub sign-ins
|
|
20
|
+
return level === "sandboxed" ? "sandboxed" : level === "sign-ins" ? "sandboxed, with model and GitHub sign-ins" : "not sandboxed";
|
|
21
21
|
}
|
|
22
22
|
/** What each wider level means, said before anyone chooses it. */
|
|
23
23
|
export const CHECK_ACCESS_WARNINGS = {
|
|
24
|
-
"sign-ins": "This project's checks can read your Claude, Codex and GitHub sign-ins, even where the project hides your home folder's files from builds.
|
|
24
|
+
"sign-ins": "This project's checks can read your Claude, Codex and GitHub sign-ins, even where the project hides your home folder's files from builds. Its tests could use or send those credentials anywhere its network setting allows. Only choose this for projects you trust.",
|
|
25
25
|
unsandboxed: "This project's checks get your full access to this machine and to Toolroll itself, including its database and every sign-in. Only for a project whose checks must create sandboxes themselves, such as Toolroll's own.",
|
|
26
26
|
};
|
|
27
27
|
/** The services whose sign-ins a `sign-ins` check reads: Claude's and Codex's own hosts (egress.ts), and GitHub's. */
|