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/scope.js
CHANGED
|
@@ -571,6 +571,7 @@ export function canonicalAcceptance(criteria) {
|
|
|
571
571
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
572
572
|
.map(c => ({ id: c.id, statement: c.statement.trim(), evidence: [...c.evidence].sort() }));
|
|
573
573
|
}
|
|
574
|
+
export const APPROVAL_BASES = ["password", "mode", "chat", "grant", "schedule", "lead", "carried"];
|
|
574
575
|
/**
|
|
575
576
|
* The exact bytes an operator agreed to.
|
|
576
577
|
*
|
|
@@ -615,6 +616,7 @@ route) {
|
|
|
615
616
|
// like an absent v41 field. Strict is an explicit signed promise.
|
|
616
617
|
...(scope.qualityMode === "strict" ? { qualityMode: "strict" } : {}),
|
|
617
618
|
...(scope.candidate ? { candidate: scope.candidate } : {}),
|
|
619
|
+
...(scope.releaseTerms == null ? {} : { releaseTerms: releaseTermsDigest(scope.releaseTerms) }),
|
|
618
620
|
// The single outer key, whichever target produced its inner value:
|
|
619
621
|
// absent => the golden and every profileless approval are untouched;
|
|
620
622
|
// a legacy profile => its exact profileDigestOf; an explicit chain
|
|
@@ -667,6 +669,7 @@ function proposeLocked(store, input) {
|
|
|
667
669
|
...(permissionMode === undefined ? {} : { permissionMode }),
|
|
668
670
|
...(input.qualityMode === undefined ? {} : { qualityMode: input.qualityMode }),
|
|
669
671
|
...(posture === undefined ? {} : { posture }),
|
|
672
|
+
...(input.releaseTerms === undefined ? {} : { releaseTerms: input.releaseTerms }),
|
|
670
673
|
proposedVia,
|
|
671
674
|
});
|
|
672
675
|
// The store may have RECOMPUTED the digest to bind the resolved profile
|
|
@@ -1060,6 +1063,10 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
|
|
|
1060
1063
|
const authenticated = authenticateApprover(store, by, token, store.lookupRef(taskId)?.repo ?? null);
|
|
1061
1064
|
if (!authenticated.ok)
|
|
1062
1065
|
return authenticated;
|
|
1066
|
+
// ONLY A PERSON APPROVES (docs/plans/approval-policy.md, phase 1): a lead token or a remote API token proves the
|
|
1067
|
+
// credential, not a person at a ceremony. Refused before any vote or proof row is written.
|
|
1068
|
+
if (!personProof(authenticated.method))
|
|
1069
|
+
return { ok: false, reason: "machine-proof" };
|
|
1063
1070
|
const scope = store.getScope(taskId);
|
|
1064
1071
|
if (scope === null)
|
|
1065
1072
|
return { ok: false, reason: "no-scope" };
|
|
@@ -1087,6 +1094,10 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
|
|
|
1087
1094
|
const policy = store.scopePolicyRefusal(taskId);
|
|
1088
1095
|
if (policy !== null)
|
|
1089
1096
|
return { ok: false, reason: "policy", message: policy };
|
|
1097
|
+
// A release scope's yes opens its authority record (release-once.md); say first why it can't.
|
|
1098
|
+
const releaseProblem = scope.releaseTerms == null ? null : releaseSealProblem(store, scope.releaseTerms);
|
|
1099
|
+
if (releaseProblem !== null)
|
|
1100
|
+
return { ok: false, reason: "policy", message: releaseProblem };
|
|
1090
1101
|
const gate = store.approvalGate(taskId, by, "person");
|
|
1091
1102
|
if (gate.verdict === "refuse")
|
|
1092
1103
|
return { ok: false, reason: gate.reason };
|
|
@@ -1103,7 +1114,7 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
|
|
|
1103
1114
|
// The same person again adds nothing: no new vote, no proof row (and the approval roads refund a re-check).
|
|
1104
1115
|
if (gate.already)
|
|
1105
1116
|
return { ok: false, reason: "second-approver", have: gate.have, need: gate.need, already: true };
|
|
1106
|
-
store.recordApprovalVote(taskId, scope.digest, by, now);
|
|
1117
|
+
store.recordApprovalVote(taskId, scope.digest, by, now, authenticated.method);
|
|
1107
1118
|
proved();
|
|
1108
1119
|
return { ok: false, reason: "second-approver", have: gate.have, need: gate.need, already: gate.already };
|
|
1109
1120
|
}
|
|
@@ -1113,7 +1124,7 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
|
|
|
1113
1124
|
try {
|
|
1114
1125
|
return store.savepoint(() => {
|
|
1115
1126
|
if (gate.protectedWork)
|
|
1116
|
-
store.recordApprovalVote(taskId, scope.digest, by, now);
|
|
1127
|
+
store.recordApprovalVote(taskId, scope.digest, by, now, authenticated.method);
|
|
1117
1128
|
// SEAL, never re-resolve: the approval snapshots the stored working
|
|
1118
1129
|
// profile — the exact bytes the digest the approver signed was bound
|
|
1119
1130
|
// to. Routing saveScope here would re-run resolution and could sign a
|
|
@@ -1143,10 +1154,10 @@ export const approvalProofDetail = (at, generation, digest) => `${new Date(at).t
|
|
|
1143
1154
|
export function approvalProofOf(store, taskId, by, digest) {
|
|
1144
1155
|
const rows = store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? ORDER BY id DESC LIMIT 50").all(taskId, APPROVAL_PROOF, by);
|
|
1145
1156
|
for (const row of rows) {
|
|
1146
|
-
const
|
|
1147
|
-
if (
|
|
1157
|
+
const read = proofDetailOf(row["detail"]);
|
|
1158
|
+
if (read === null || read.digest !== digest)
|
|
1148
1159
|
continue;
|
|
1149
|
-
return { method: String(row["outcome"]), at:
|
|
1160
|
+
return { method: String(row["outcome"]), at: read.at };
|
|
1150
1161
|
}
|
|
1151
1162
|
return null;
|
|
1152
1163
|
}
|
|
@@ -1161,7 +1172,7 @@ export function freshlyApprovable(store, taskId, scope, singleApprover) {
|
|
|
1161
1172
|
if (ref === undefined || ref["coordinator_cid"] != null)
|
|
1162
1173
|
return false;
|
|
1163
1174
|
const via = store.handle.prepare("SELECT proposed_via FROM task_scope WHERE task_id = ?").get(taskId)?.["proposed_via"];
|
|
1164
|
-
return !(
|
|
1175
|
+
return !quarantinedAuthor(typeof via === "string" ? via : null);
|
|
1165
1176
|
}
|
|
1166
1177
|
/**
|
|
1167
1178
|
* Whether this task may be built.
|
|
@@ -1179,6 +1190,15 @@ export function approvalOf(scope) {
|
|
|
1179
1190
|
}
|
|
1180
1191
|
if (scope.approvedDigest !== scope.digest)
|
|
1181
1192
|
return { approved: false, reason: "changed" };
|
|
1193
|
+
// A seal stands on a known authority (approval-policy plan §1.5): one left out or undefined, null (a row from before
|
|
1194
|
+
// the basis column) or a word this code doesn't write is no approval.
|
|
1195
|
+
if (scope.approvalBasis === undefined || scope.approvalBasis === null || !APPROVAL_BASES.includes(scope.approvalBasis)) {
|
|
1196
|
+
return { approved: false, reason: "none" };
|
|
1197
|
+
}
|
|
1198
|
+
// Phase 1: only people approve, so a lead's seal (including an old one a lead token made through the person's road,
|
|
1199
|
+
// which Store.getScope reads as `lead`) is no approval, and the person sees the Approve control again.
|
|
1200
|
+
if (scope.approvalBasis === "lead")
|
|
1201
|
+
return { approved: false, reason: "none" };
|
|
1182
1202
|
return { approved: true, at: scope.approvedAt, by: scope.approvedBy };
|
|
1183
1203
|
}
|
|
1184
1204
|
/**
|
|
@@ -1239,7 +1259,7 @@ export function scopeAuthorityOf(scope, env = {}) {
|
|
|
1239
1259
|
const parity = routeParityProblem(route, profile, { riskLevel: scope.riskLevel ?? "routine", qualityMode: scope.qualityMode ?? "default" });
|
|
1240
1260
|
if (parity !== null)
|
|
1241
1261
|
return { ok: false, reason: "parity", problem: parity };
|
|
1242
|
-
const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default" }, profile, route);
|
|
1262
|
+
const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null }, profile, route);
|
|
1243
1263
|
if (digest !== scope.digest) {
|
|
1244
1264
|
return { ok: false, reason: "digest", problem: "the scope's reference does not re-derive from its own terms, agents, and route — file it again" };
|
|
1245
1265
|
}
|
|
@@ -1298,10 +1318,35 @@ export function describeScope(scope, readiness = NO_READINESS) {
|
|
|
1298
1318
|
...(scope.profile?.checkAccess === undefined ? [] : [` checks ${checkAccessWords(scope.profile.checkAccess)}`]),
|
|
1299
1319
|
// Its sandbox terms (project-sandbox.ts), part of what the yes agrees to: what it reads, and any widening.
|
|
1300
1320
|
...(scope.profile === null || scope.profile === undefined ? [] : ((terms) => [` reads ${terms.reads}`, ...(terms.mach === null ? [] : [` services ${terms.mach}`])])(sandboxTermsWords(scope.profile))),
|
|
1321
|
+
...releaseTermsWords(scope.releaseTerms ?? null),
|
|
1301
1322
|
` reference ${scope.digest}`,
|
|
1302
1323
|
` approved ${describeApproval(approval)}`,
|
|
1303
1324
|
];
|
|
1304
1325
|
}
|
|
1326
|
+
/** The approval sheet's release lines (release-once.md): the release in one sentence, then what asks again. */
|
|
1327
|
+
export function releaseTermsSentence(terms) {
|
|
1328
|
+
if (!terms.policy.carry)
|
|
1329
|
+
return `Release ${terms.packageVersion} from pull request #${terms.pr} at ${terms.approvedSha.slice(0, 7)}. Every later commit asks for your approval again.`;
|
|
1330
|
+
const outside = terms.access === "sandboxed" ? "" : " This project's checks don't run in the sandbox: those later test-only commits will run outside the sandbox without being shown to you first.";
|
|
1331
|
+
return `Release ${terms.packageVersion} from pull request #${terms.pr} at ${terms.approvedSha.slice(0, 7)}. Later commits on this pull request that only change tests, docs or the changelog ship without asking you again, until it merges or for 3 days. You'll see each one in the release summary.${outside}`;
|
|
1332
|
+
}
|
|
1333
|
+
/** What asks again, in full: the terms behind the sentence. */
|
|
1334
|
+
export function releaseAsksAgainWords(terms) {
|
|
1335
|
+
const p = terms.policy;
|
|
1336
|
+
return [
|
|
1337
|
+
"A later commit asks again when it changes anything but unit tests, the journey scripts, docs under docs/, the README or the changelog;",
|
|
1338
|
+
"when it touches AGENTS.md, CLAUDE.md, SECURITY.md, agent or CI settings, the test harness, helpers or fixtures;",
|
|
1339
|
+
`when it deletes, renames or changes the mode of a file, adds a binary, a symlink or a submodule; when it changes the production build;`,
|
|
1340
|
+
`when more than ${p.limits.lines} lines or ${p.limits.files} files change after this approval, or ${p.limits.churn} lines of churn;`,
|
|
1341
|
+
...(p.protectProject ? ["when it changes anything: this project is protected;"] : p.protectedPaths.length === 0 ? [] : [`when it changes a protected path: ${p.protectedPaths.join(", ")};`]),
|
|
1342
|
+
`and always once the deploy starts. Repository ${terms.repo}, branch ${terms.branch} into ${terms.base}, check grant ${terms.grant.id}, ${terms.access === "sandboxed" ? "checks in the sandbox" : `checks outside the sandbox (${terms.access}), carried because the project allows it`}, until ${terms.expiresAt}.`,
|
|
1343
|
+
];
|
|
1344
|
+
}
|
|
1345
|
+
function releaseTermsWords(terms) {
|
|
1346
|
+
if (terms === null)
|
|
1347
|
+
return [];
|
|
1348
|
+
return [` release ${releaseTermsSentence(terms)}`, ...(terms.policy.carry ? releaseAsksAgainWords(terms).map(line => ` ${line}`) : [])];
|
|
1349
|
+
}
|
|
1305
1350
|
function describeApproval(approval) {
|
|
1306
1351
|
if (approval.approved)
|
|
1307
1352
|
return `yes, by ${approval.by} at ${approval.at}`;
|
|
@@ -1310,3 +1355,187 @@ function describeApproval(approval) {
|
|
|
1310
1355
|
}
|
|
1311
1356
|
return "no — nothing will build this until somebody agrees to it";
|
|
1312
1357
|
}
|
|
1358
|
+
// ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
|
|
1359
|
+
export { personProof } from "./person-proof.js";
|
|
1360
|
+
import { personProof } from "./person-proof.js";
|
|
1361
|
+
import { releaseTermsDigest } from "./contracts/release-terms.js";
|
|
1362
|
+
import { CHECK_ACCESS_WIDTH, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseSealProblem } from "./release-authority.js";
|
|
1363
|
+
/** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
|
|
1364
|
+
export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
|
|
1365
|
+
/** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
|
|
1366
|
+
* (`{"v":1,"at":…,"digest":…}`, more fields allowed). Any other shape is no proof. */
|
|
1367
|
+
export function proofDetailOf(detail) {
|
|
1368
|
+
if (typeof detail !== "string")
|
|
1369
|
+
return null;
|
|
1370
|
+
if (detail.startsWith("{")) {
|
|
1371
|
+
try {
|
|
1372
|
+
const parsed = JSON.parse(detail);
|
|
1373
|
+
if (parsed === null || typeof parsed !== "object" || parsed["v"] !== 1 || typeof parsed["at"] !== "string" || typeof parsed["digest"] !== "string")
|
|
1374
|
+
return null;
|
|
1375
|
+
if (Number.isNaN(Date.parse(parsed["at"])) || parsed["digest"] === "")
|
|
1376
|
+
return null;
|
|
1377
|
+
return { at: parsed["at"], digest: parsed["digest"] };
|
|
1378
|
+
}
|
|
1379
|
+
catch {
|
|
1380
|
+
return null;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
const parts = detail.split(" · ");
|
|
1384
|
+
if (parts.length !== 3 || !parts[2].startsWith("digest ") || !/^generation \d+$/.test(parts[1]) || Number.isNaN(Date.parse(parts[0])))
|
|
1385
|
+
return null;
|
|
1386
|
+
return { at: parts[0], digest: parts[2].slice("digest ".length) };
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* A person's approval of this task as it stands: approved, on the current digest, by the ceremony (`password`) or a
|
|
1390
|
+
* paired-chat tap (`chat`), with a person's proof recorded for that approver and those exact bytes. Release, deploy
|
|
1391
|
+
* and the protected-work counters ask this; a lead, a mode, a grant, a schedule or an approval with no proof is not
|
|
1392
|
+
* a person's.
|
|
1393
|
+
*/
|
|
1394
|
+
export function personApprovalOf(store, taskId) {
|
|
1395
|
+
const scope = store.getScope(taskId);
|
|
1396
|
+
if (scope === null || scope.approvedAt === null || scope.approvedBy === null || scope.approvedDigest === null)
|
|
1397
|
+
return { ok: false, why: "not approved yet" };
|
|
1398
|
+
if (scope.approvedDigest !== scope.digest)
|
|
1399
|
+
return { ok: false, why: "the approval is for different scope text" };
|
|
1400
|
+
const basis = scope.approvalBasis ?? null;
|
|
1401
|
+
if (basis === "lead")
|
|
1402
|
+
return { ok: false, why: "approved by the lead, not by a person" };
|
|
1403
|
+
if (basis !== "password" && basis !== "chat")
|
|
1404
|
+
return { ok: false, why: `approved by ${basis === null ? "an unknown road" : basis === "grant" ? "a flow" : basis}, not by a person` };
|
|
1405
|
+
const origin = sealOriginOf(store, taskId, scope.approvedBy, scope.approvedDigest, scope.approvedAt);
|
|
1406
|
+
if (origin.origin === "machine")
|
|
1407
|
+
return { ok: false, why: origin.lead ? "approved by the lead, not by a person" : "approved with a machine credential, not by a person" };
|
|
1408
|
+
if (origin.origin !== "person")
|
|
1409
|
+
return { ok: false, why: "no record shows a person approved these exact words; approve it again" };
|
|
1410
|
+
return { ok: true, by: scope.approvedBy, at: scope.approvedAt, method: origin.method };
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Who made a seal that says it came through a person's road (`password`, `chat`), from the acts recorded AT THAT SEAL
|
|
1414
|
+
* (approval policy §1.5): the proof rows its approver left at the seal's own instant, and the lead's task act then.
|
|
1415
|
+
* - `machine`: any proof there that isn't a person's (a lead or remote token, or a word this code doesn't know), or the
|
|
1416
|
+
* lead's act — fail closed, whatever its digest says;
|
|
1417
|
+
* - `person`: a person's proof there for these exact bytes (parsed, either form);
|
|
1418
|
+
* - `automation`: a standing order's firing that no person's proof stands behind;
|
|
1419
|
+
* - `unproven`: none of these (a seal from before proof rows): it dispatches as before, and is never a person's yes.
|
|
1420
|
+
* An older proof for the same bytes never vouches for a later seal. The dispatch belts ask the same in SQL
|
|
1421
|
+
* (approval-authority.ts).
|
|
1422
|
+
*/
|
|
1423
|
+
export function sealOriginOf(store, taskId, by, digest, at) {
|
|
1424
|
+
const rows = store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? AND at = ? ORDER BY id DESC").all(taskId, APPROVAL_PROOF, by, at);
|
|
1425
|
+
const leadAct = store.handle.prepare(`SELECT 1 AS hit FROM task_act a JOIN task_ref r ON r.id = a.task_ref AND r.backend = 'built-in'
|
|
1426
|
+
WHERE r.external_id = ? AND a.act = 'approved' AND a.lead = 1 AND a.account = ? AND a.at = ?`).get(taskId, by, at) !== undefined;
|
|
1427
|
+
const machine = rows.filter(row => !personProof(String(row["outcome"])));
|
|
1428
|
+
if (leadAct || machine.length > 0)
|
|
1429
|
+
return { origin: "machine", lead: leadAct || machine.some(row => row["outcome"] === "lead-token") };
|
|
1430
|
+
const person = rows.find(row => proofDetailOf(row["detail"])?.digest === digest);
|
|
1431
|
+
if (person !== undefined)
|
|
1432
|
+
return { origin: "person", method: String(person["outcome"]) };
|
|
1433
|
+
if (store.handle.prepare("SELECT 1 AS hit FROM flow_trigger_event WHERE task = ? LIMIT 1").get(taskId) !== undefined)
|
|
1434
|
+
return { origin: "automation" };
|
|
1435
|
+
return { origin: "unproven" };
|
|
1436
|
+
}
|
|
1437
|
+
/** Text another machine wrote (a confirmed chat proposal, a coordinator, a scout): no mode, grant, remembered password
|
|
1438
|
+
* or chat tap seals it. The lead's own CLI text (`lead`) is not quarantined. */
|
|
1439
|
+
export const quarantinedAuthor = (via) => via === "mate" || via === "coordinator" || via === "scout";
|
|
1440
|
+
/** Which states each action may start from (deploy and merge also resume their own reservation). */
|
|
1441
|
+
const RELEASE_ACTION_STATES = {
|
|
1442
|
+
dispatch: ["open"], complete: ["open"], deploy: ["open", "deploying"], merge: ["deploying", "merge-reserved"],
|
|
1443
|
+
};
|
|
1444
|
+
/**
|
|
1445
|
+
* Whether a task that belongs to a release authority may do `action` now. It applies to EVERY such task, whether its
|
|
1446
|
+
* seal is the person's own or `carried`, and requires all of: the authority's current generation (never superseded,
|
|
1447
|
+
* revoked or expired before it started); a state that fits the action; the task is the authority's current tip; the
|
|
1448
|
+
* anchor's seal is a person's (personApprovalOf, unchanged) on the exact sealed anchor digest; the anchor's approver is
|
|
1449
|
+
* still an active approver with access to the project; for a carried task, a carry row for exactly this task, scope
|
|
1450
|
+
* digest, candidate and generation; the release check grant and check access as sealed (access only ever the fence);
|
|
1451
|
+
* a sealed policy this runtime knows; and, for deploy and merge, no open main-check failure blocking a composed check.
|
|
1452
|
+
* A task that belongs to no release reads `applies: false` — the ordinary rules decide it.
|
|
1453
|
+
*/
|
|
1454
|
+
export function liveReleaseOf(store, taskId, action, now = new Date()) {
|
|
1455
|
+
const found = releaseAuthorityOfTask(store, taskId);
|
|
1456
|
+
if (found === null)
|
|
1457
|
+
return { applies: false };
|
|
1458
|
+
const { authority: a, carry } = found;
|
|
1459
|
+
const refuse = (why) => ({ applies: true, ok: false, why, authority: a });
|
|
1460
|
+
const newest = newestReleaseGeneration(store, a.repo, a.version, a.pr);
|
|
1461
|
+
if (newest === null || newest.id !== a.id)
|
|
1462
|
+
return refuse("a newer approval of this release replaced this one");
|
|
1463
|
+
if (a.state === "superseded")
|
|
1464
|
+
return refuse("a newer approval of this release replaced this one");
|
|
1465
|
+
if (a.state === "revoked")
|
|
1466
|
+
return refuse(`${a.endedBy ?? "an approver"} stopped carrying commits for this release`);
|
|
1467
|
+
// Expiry stops every live state (security review): an operation already reserved finishes only through reconciliation.
|
|
1468
|
+
if (a.state === "expired" || ((a.state === "open" || a.state === "deploying" || a.state === "merge-reserved") && a.expiresAt <= now.toISOString()))
|
|
1469
|
+
return refuse("the release approval expired");
|
|
1470
|
+
if (a.state === "merged")
|
|
1471
|
+
return refuse("the release was already merged");
|
|
1472
|
+
if (!RELEASE_ACTION_STATES[action].includes(a.state))
|
|
1473
|
+
return refuse(a.state === "open" ? "the release hasn't started deploying" : `the release is already ${a.state === "deploying" ? "deploying" : "merging"}`);
|
|
1474
|
+
if (a.tipTask !== taskId)
|
|
1475
|
+
return refuse("this is not the release's current commit");
|
|
1476
|
+
const anchor = store.getScope(a.anchorTask);
|
|
1477
|
+
const person = personApprovalOf(store, a.anchorTask);
|
|
1478
|
+
if (!person.ok)
|
|
1479
|
+
return refuse(`the release approval no longer stands: ${person.why}`);
|
|
1480
|
+
if (anchor === null || anchor.approvedDigest !== a.anchorDigest || anchor.digest !== a.anchorDigest)
|
|
1481
|
+
return refuse("the release approval is for different words");
|
|
1482
|
+
const account = store.accountOf(person.by);
|
|
1483
|
+
if (account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(person.by, a.repo))
|
|
1484
|
+
return refuse(`${person.by} can no longer approve in this project`);
|
|
1485
|
+
const scope = store.getScope(taskId);
|
|
1486
|
+
if (scope === null || scope.approvedDigest !== scope.digest)
|
|
1487
|
+
return refuse("this task's approved words changed");
|
|
1488
|
+
if (carry !== null) {
|
|
1489
|
+
if (carry.authority !== a.id || scope.approvalBasis !== "carried" || carry.scopeDigest !== scope.digest || carry.toSha !== scope.candidate || scope.modeDigest !== `${a.id}:${carry.id}`) {
|
|
1490
|
+
return refuse("this commit's carry doesn't match its record");
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
else if (scope.candidate !== a.approvedSha)
|
|
1494
|
+
return refuse("this task isn't the approved commit");
|
|
1495
|
+
const grant = store.liveVerifyCommand(a.repo);
|
|
1496
|
+
if (grant === null || grant.id !== a.verifyCommand || grant.digest !== a.verifyDigest)
|
|
1497
|
+
return refuse("the release check command changed since the approval");
|
|
1498
|
+
// The checks reach no further than at the approval. Wider than the sandbox, a carried commit stands only while both
|
|
1499
|
+
// the sealed and the current "Carry release approvals when checks aren't sandboxed" allow it (the stricter of the two).
|
|
1500
|
+
const access = store.projectCheckAccess(a.repo);
|
|
1501
|
+
const sealedWidth = CHECK_ACCESS_WIDTH[a.accessDigest], nowWidth = CHECK_ACCESS_WIDTH[access];
|
|
1502
|
+
if (sealedWidth === undefined || nowWidth === undefined || nowWidth > sealedWidth)
|
|
1503
|
+
return refuse(a.accessDigest === "sandboxed" ? "the project's checks no longer run in the sandbox" : "the project's checks reach further than when the release was approved");
|
|
1504
|
+
if (carry !== null && (a.accessDigest !== "sandboxed" || access !== "sandboxed") && (a.policy?.unsandboxedCarry !== true || !carriesUnsandboxed(store, a.repo))) {
|
|
1505
|
+
return refuse("carrying release approvals when checks aren't sandboxed is off");
|
|
1506
|
+
}
|
|
1507
|
+
if (a.policy === null)
|
|
1508
|
+
return refuse("this Toolroll doesn't know the release's carry rules; update Toolroll first");
|
|
1509
|
+
if (action === "deploy" || action === "merge") {
|
|
1510
|
+
const debt = mainDebtProblem(store, a.repo, taskId);
|
|
1511
|
+
if (debt !== null)
|
|
1512
|
+
return refuse(debt);
|
|
1513
|
+
}
|
|
1514
|
+
return { applies: true, ok: true, authority: a, carry };
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Open main-check failures (the nightly full check, part 3) block the deploy and the merge of a release whose gate
|
|
1518
|
+
* check was composed from a targeted run: a full pass is needed first. Null when nothing blocks.
|
|
1519
|
+
*/
|
|
1520
|
+
export function mainDebtProblem(store, repo, taskId) {
|
|
1521
|
+
const open = Number(store.handle.prepare("SELECT COUNT(*) AS n FROM main_check_failure WHERE repo = ? AND cleared_at IS NULL").get(repo)?.["n"] ?? 0);
|
|
1522
|
+
if (open === 0)
|
|
1523
|
+
return null;
|
|
1524
|
+
const ref = store.lookupRef(taskId);
|
|
1525
|
+
if (ref === null)
|
|
1526
|
+
return null;
|
|
1527
|
+
const newest = store.handle.prepare(`SELECT a.mode FROM run r JOIN release_check_attempt a ON a.run = r.id
|
|
1528
|
+
WHERE r.task_ref = ? AND r.role = 'builder' ORDER BY r.id DESC LIMIT 1`).get(ref.id);
|
|
1529
|
+
if (newest === undefined || newest["mode"] !== "targeted")
|
|
1530
|
+
return null;
|
|
1531
|
+
return `the nightly full check on main has ${open} open failure${open === 1 ? "" : "s"}, so a check made from a re-run of what failed can't deploy; a full check has to pass first`;
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* A deploy gate (docs/plans/chat-management.md §0a.5): a scope whose signed terms name a prepared candidate or carry
|
|
1535
|
+
* release terms. Both are in the scope digest and written at filing, before any release authority exists, so this is
|
|
1536
|
+
* read from the scope alone: a first approval and a fallback gate filed without --release are deploy gates too. From
|
|
1537
|
+
* chat it is approved only through the release card.
|
|
1538
|
+
*/
|
|
1539
|
+
export function deployGateScope(scope) {
|
|
1540
|
+
return (scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null;
|
|
1541
|
+
}
|
package/dist/server/flows.js
CHANGED
|
@@ -12,13 +12,13 @@ import { run as execRun } from "../exec.js";
|
|
|
12
12
|
import { setFlowSecret } from "../flow-actions.js";
|
|
13
13
|
import { addCardToFlow, advanceFlows, cancelFlowCard, crossProjectProblem, decideFlowCard, flowDefinitionOf, moveCardInFlow } from "../flow-engine.js";
|
|
14
14
|
import { galleryHtml, galleryUseHtml } from "../flow-gallery-ui.js";
|
|
15
|
-
import { galleryDefaults, galleryDiagram, galleryTemplateOf, galleryToolsOf, previewGallery, useGalleryTemplate } from "../flow-gallery.js";
|
|
15
|
+
import { galleryAdmitsAnyone, galleryDefaults, galleryDiagram, galleryTemplateOf, galleryToolsOf, previewGallery, useGalleryTemplate } from "../flow-gallery.js";
|
|
16
16
|
import { flowInsights } from "../flow-insights.js";
|
|
17
17
|
import { assignFlowCard, commentOnFlowCard, watchFlowCard } from "../flow-people.js";
|
|
18
18
|
import { saveScript } from "../flow-scripts.js";
|
|
19
19
|
import { chooseFlowCard } from "../flow-send.js";
|
|
20
20
|
import { exportFlow, fetchFlowFile, FlowFileError, importFlow, parseFlowFile, planFlowImport } from "../flow-share.js";
|
|
21
|
-
import { addFlowTriggerTo, checkFlowTriggerNow, pressFlowButton, removeFlowTrigger, removeLinearKey, setFlowTriggerOn, renewFlowHook, saveHooksBase, saveLinearKey, saveSigningSecret, setUpFlowHook, changeFlowHookSender, setFlowHookScope, shareFlowButton, stopSharingFlowButton, rawTakesDeliveries } from "../flow-triggers.js";
|
|
21
|
+
import { addFlowTriggerTo, checkFlowTriggerNow, pressFlowButton, removeFlowTrigger, removeLinearKey, setFlowTriggerOn, renewFlowHook, saveHooksBase, saveLinearKey, saveSigningSecret, setUpFlowHook, changeFlowHookSender, setFlowHookScope, shareFlowButton, stopSharingFlowButton, rawTakesDeliveries, rawAdmitsAnyone } from "../flow-triggers.js";
|
|
22
22
|
import { FLOW_IMPORT_SCRIPT, flowFallbackHtml, flowImportHtml, flowsListHtml, flowView } from "../flows-ui.js";
|
|
23
23
|
import { FLOW_TEMPLATES, FlowContractError, validateFlowDefinition, withZoneNames } from "../flows.js";
|
|
24
24
|
import { prepareUnattendedCandidates, parseUnattendedAsk, revokeUnattended, signUnattended, untilWords } from "../flow-unattended.js";
|
|
@@ -32,7 +32,7 @@ import { createWorkflowPreview, exportRecipe, findRecipe, importRecipe, launchWo
|
|
|
32
32
|
import { isAlive as runnerAlive } from "../runner.js";
|
|
33
33
|
import { createSubagentFrom, labelOf, nameOf, saveSoul, sendSubagentSummaries, setSubagentState, subagentSettings } from "../subagent-admin.js";
|
|
34
34
|
import { addRoutine, removeRoutine, runRoutine } from "../subagent-desk.js";
|
|
35
|
-
import { editMemory, forgetMemory, tellSubagent } from "../subagent-memory.js";
|
|
35
|
+
import { ACCEPT as ACCEPT_SUGGESTION, editMemory, forgetMemory, tellSubagent } from "../subagent-memory.js";
|
|
36
36
|
import { grantTool, revokeTool, rulesFromForm, setToolRules } from "../subagent-tools.js";
|
|
37
37
|
import { sendSubagentWeeklies, undoCall } from "../subagent-week.js";
|
|
38
38
|
import { answerSubagentQuestion } from "../subagent-work.js";
|
|
@@ -301,11 +301,16 @@ export function createFlowsHandlers(runtime) {
|
|
|
301
301
|
return redirect(response, `/kits?problem=${encodeURIComponent("Choose one of your projects.")}`);
|
|
302
302
|
const page = (key, words) => redirect(response, `/kits/${kit.id}?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
|
|
303
303
|
if (step === "setup") {
|
|
304
|
-
|
|
304
|
+
// Its tools come with it only on the person's proof (a password, or the session under Passwordless): a grant widens.
|
|
305
|
+
const proved = authenticateApprover(who, body.get("password") ?? "", repo).ok;
|
|
306
|
+
const made = await setUpKit(store, kit, repo, who.name, now, options.configDir ?? null, { toolHome }, proved ? { widening: "proved" } : {});
|
|
305
307
|
return page(made.ok ? "said" : "problem", made.said);
|
|
306
308
|
}
|
|
307
309
|
if (step === "github") {
|
|
308
|
-
|
|
310
|
+
// Issues from anyone on GitHub come into a flow: a widening, so it takes a password (chat-management.md §0a.3).
|
|
311
|
+
if (!authenticateApprover(who, body.get("password") ?? "", repo).ok)
|
|
312
|
+
return page("problem", "Enter your Toolroll password to bring in GitHub issues. Nothing changed.");
|
|
313
|
+
const made = addKitGithubTrigger(store, kit, repo, who.name, now, options.configDir ?? null, "proved");
|
|
309
314
|
return page(made.ok ? "said" : "problem", made.said);
|
|
310
315
|
}
|
|
311
316
|
const tried = addKitSample(store, kit, repo, who.name, now);
|
|
@@ -330,7 +335,10 @@ export function createFlowsHandlers(runtime) {
|
|
|
330
335
|
const card = question === null ? null : store.getFlowCard(question.card);
|
|
331
336
|
const flow = card === null ? null : store.getFlow(card.flow);
|
|
332
337
|
const done = question === null || flow === null || !visible(flow.repo) ? { ok: false, said: "No such question." }
|
|
333
|
-
|
|
338
|
+
// Accepting a looser rule it suggested takes the password (chat-management.md §0a.3); every other answer doesn't.
|
|
339
|
+
: question.suggestion !== null && body.get("choice") === ACCEPT_SUGGESTION && !authenticateApprover(who, body.get("password") ?? "").ok
|
|
340
|
+
? { ok: false, said: "Enter your Toolroll password to change the rule." }
|
|
341
|
+
: answerSubagentQuestion(store, question.id, { choice: body.get("choice"), text: body.get("text"), by: who.name, via: "web", ...(question.suggestion !== null ? { widening: "proved" } : {}) }, now);
|
|
334
342
|
if (wantsJson)
|
|
335
343
|
return respond(response, done.ok ? 200 : 409, "application/json; charset=utf-8", JSON.stringify(done));
|
|
336
344
|
const back = question === null ? "/settings/lead/subagents" : `/settings/lead/subagents/${question.subagent}`;
|
|
@@ -403,9 +411,23 @@ export function createFlowsHandlers(runtime) {
|
|
|
403
411
|
// v94: which project tools it may use, and its rule for each action.
|
|
404
412
|
const tool = body.get("tool") ?? "", op = body.get("op");
|
|
405
413
|
const grant = store.subagentGrant(mate.id, tool);
|
|
406
|
-
|
|
414
|
+
// Giving a subagent a tool widens what it does unasked: a password (chat-management.md §0a.3). Revoking and
|
|
415
|
+
// tightening a rule don't need one.
|
|
416
|
+
if (op === "grant" && !authenticateApprover(who, body.get("password") ?? "", mate.repo).ok) {
|
|
417
|
+
return redirect(response, `${back}?problem=${encodeURIComponent(`Enter your Toolroll password to let ${nameOf(mate)} use ${tool}. Nothing changed.`)}#tools`);
|
|
418
|
+
}
|
|
419
|
+
const changed = op === "grant" ? await grantTool(store, mate, tool, who.name, now, { toolHome }, { widening: "proved" })
|
|
407
420
|
: op === "revoke" ? revokeTool(store, mate, tool, who.name, now)
|
|
408
|
-
: grant === null ? { ok: false, said: `${nameOf(mate)} doesn't use ${tool}.` } :
|
|
421
|
+
: grant === null ? { ok: false, said: `${nameOf(mate)} doesn't use ${tool}.` } : (() => {
|
|
422
|
+
// Loosening a rule widens what it does unasked: a password (chat-management.md §0a.3); tightening needs none.
|
|
423
|
+
const changes = rulesFromForm(grant, key => body.sent.get(key));
|
|
424
|
+
const tried = setToolRules(store, mate, tool, changes, who.name, now);
|
|
425
|
+
if (tried.ok || !/takes your password/.test(tried.said))
|
|
426
|
+
return tried;
|
|
427
|
+
if (!authenticateApprover(who, body.get("password") ?? "", mate.repo).ok)
|
|
428
|
+
return { ok: false, said: `Enter your Toolroll password to let ${nameOf(mate)} do more on its own. Nothing changed.` };
|
|
429
|
+
return setToolRules(store, mate, tool, changes, who.name, now, { widening: "proved" });
|
|
430
|
+
})();
|
|
409
431
|
return redirect(response, `${back}?${changed.ok ? "said" : "problem"}=${encodeURIComponent(changed.said)}#tools`);
|
|
410
432
|
}
|
|
411
433
|
const done = part === "state" ? setSubagentState(store, mate, body.get("state") === "removed" ? "removed" : body.get("state") === "paused" ? "paused" : "active", who.name, now)
|
|
@@ -445,7 +467,11 @@ export function createFlowsHandlers(runtime) {
|
|
|
445
467
|
}
|
|
446
468
|
if (digest === null || digest !== body.get("previewed"))
|
|
447
469
|
return sendGalleryUse(response, who, template, projects, repo, answers, name, digest === null ? null : "Your answers changed. Check the preview, then create the flow.");
|
|
448
|
-
|
|
470
|
+
// A template whose trigger brings in items from anyone takes the password (chat-management.md §0a.3).
|
|
471
|
+
const anyone = galleryAdmitsAnyone(template);
|
|
472
|
+
if (anyone && !authenticateApprover(who, body.get("password") ?? "", repo).ok)
|
|
473
|
+
return sendGalleryUse(response, who, template, projects, repo, answers, name, "Enter your Toolroll password: this flow brings in items from anyone.");
|
|
474
|
+
const used = useGalleryTemplate(store, template, repo, answers, { name, by: who.name, now: clock(), dir: options.configDir ?? null, ...(anyone ? { widening: "proved" } : {}) });
|
|
449
475
|
if (!used.ok)
|
|
450
476
|
return sendGalleryUse(response, who, template, projects, repo, answers, name, used.said);
|
|
451
477
|
return redirect(response, `/flows/${used.flow}`);
|
|
@@ -576,7 +602,11 @@ export function createFlowsHandlers(runtime) {
|
|
|
576
602
|
const ingress = rawTakesDeliveries(raw);
|
|
577
603
|
if (ingress && !authenticateCredential(who, body.get("password") ?? "").ok)
|
|
578
604
|
return answer(403, { ok: false, said: "Enter your Toolroll password to make a webhook address." });
|
|
579
|
-
|
|
605
|
+
// Items from anyone: a widening, an ordinary step-up (chat-management.md §0a.3).
|
|
606
|
+
const anyone = !ingress && rawAdmitsAnyone(raw);
|
|
607
|
+
if (anyone && !authenticateApprover(who, body.get("password") ?? "", flow.repo).ok)
|
|
608
|
+
return answer(403, { ok: false, said: "Enter your Toolroll password to bring in items from anyone." });
|
|
609
|
+
const made = addFlowTriggerTo(store, flow, raw, who.name, now, dir, ingress ? { ingress: "proved" } : anyone ? { widening: "proved" } : {});
|
|
580
610
|
if (made.ok)
|
|
581
611
|
await prepareUnattendedCandidates(store, flow.id);
|
|
582
612
|
return made.ok ? settle(made.said, made.reveal === null ? {} : { reveal: made.reveal }) : answer(400, { ok: false, said: made.message });
|
|
@@ -170,7 +170,7 @@ export function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging
|
|
|
170
170
|
: joinHtml([
|
|
171
171
|
html `<h2>Unattended permissions</h2>`,
|
|
172
172
|
permissionDefault.canManage && csrf !== ""
|
|
173
|
-
? postForm("/settings/permission-default", html `${permissionModeChoices("permission-mode", permissionDefault.mode)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy"
|
|
173
|
+
? postForm("/settings/permission-default", html `${permissionModeChoices("permission-mode", permissionDefault.mode)}${permissionDefault.mode === "bypassPermissions" ? "" : reauth(html `<label>Your password <span class="meta">(needed for Full access)</span><input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy" } })
|
|
174
174
|
: html `<div class="card"><p><strong>${permissionDefault.mode === "bypassPermissions" ? "Full access" : "Auto"}</strong></p><p class="meta">An approver can change this default</p></div>`,
|
|
175
175
|
permissionDefault.updatedAt === null
|
|
176
176
|
? ""
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReleaseView } from "../release-view.js";
|
|
1
2
|
import { type Html } from "../html.js";
|
|
2
3
|
import { type RunActivity } from "../activity-line.js";
|
|
3
4
|
import { type AgentChoice } from "../agentconfig.js";
|
|
@@ -522,6 +523,9 @@ export declare function taskBodyParts(data: {
|
|
|
522
523
|
method: string;
|
|
523
524
|
at: string;
|
|
524
525
|
} | null;
|
|
526
|
+
/** One approval per release (release-once.md): the release this task belongs to, and whether this reader may stop it. */
|
|
527
|
+
release?: ReleaseView | null;
|
|
528
|
+
canStopRelease?: boolean;
|
|
525
529
|
/** Workers that are both alive and authorized for this task's project. */
|
|
526
530
|
worker?: {
|
|
527
531
|
answering: number;
|
|
@@ -670,3 +674,7 @@ export declare function qualityModeChoices(name: string, selected: QualityMode):
|
|
|
670
674
|
export declare function decisionOptionForms(decision: Decision, returnTo: string | null): Html;
|
|
671
675
|
export declare function decisionPage(chrome: Chrome, decision: Decision, taskId: string, artifacts: Artifact[], who: Who, now: Date, returnTo?: string | null): Screen;
|
|
672
676
|
export declare function taskOf(store: Store, decision: Decision): string;
|
|
677
|
+
/** A release task's own card (release-once.md, "What people see"): the release it's approved for, the later commits
|
|
678
|
+
* carried under it (on demand), what a later check must run again, the last nightly check of main, and one action to
|
|
679
|
+
* stop carrying. A carried task says whose approval it was carried from. */
|
|
680
|
+
export declare function releaseCardHtml(taskId: string, release: ReleaseView | null, canStop: boolean): Html;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Task pages: approval, agents, plans, controls, decisions and the task body. */
|
|
2
2
|
import { networkWords } from "../egress.js";
|
|
3
|
+
import { releaseAsksAgainWords, releaseTermsSentence } from "../scope.js";
|
|
3
4
|
import { writesWords } from "../writes-mode.js";
|
|
4
5
|
import { checkAccessWords } from "../check-access.js";
|
|
5
6
|
import { sandboxTermsWords } from "../project-sandbox.js";
|
|
@@ -757,18 +758,28 @@ export function approvalProofWords(proof) {
|
|
|
757
758
|
return " (passwordless: your signed-in session)";
|
|
758
759
|
if (proof.method === "cli-login")
|
|
759
760
|
return " (passwordless: this computer's remembered login)";
|
|
761
|
+
if (proof.method === "chat")
|
|
762
|
+
return " (from your paired chat)";
|
|
763
|
+
if (proof.method === "carried")
|
|
764
|
+
return " (carried from the release approval)";
|
|
760
765
|
return "";
|
|
761
766
|
}
|
|
762
767
|
const whenText = (iso) => new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" }).format(new Date(iso));
|
|
763
768
|
export function approveRefusalWords(reason) {
|
|
764
769
|
if (reason === "requester" || reason === "person-required")
|
|
765
770
|
return gateWords({ verdict: "refuse", reason });
|
|
771
|
+
if (reason === "machine-proof")
|
|
772
|
+
return "not approved: only a person can approve this. Approve it on the task page or from your paired chat";
|
|
766
773
|
if (reason === "unrouted")
|
|
767
774
|
return "not approved: this scope predates agent routing and its old approval no longer stands — edit and re-file the scope so it is routed under today’s agents, then approve it";
|
|
768
775
|
if (reason === "profile-unresolved")
|
|
769
776
|
return "not approved: the scope cannot name an exact agent for every role — fix the agent setup and re-file it";
|
|
770
777
|
if (reason === "password-now")
|
|
771
778
|
return "not approved: this needs your password typed now — protected work, raised permissions, or a plan an agent wrote can't use a remembered one";
|
|
779
|
+
// A wrong password, someone who isn't an approver (or not for this project), a revoked login: one answer for all, so
|
|
780
|
+
// the refusal never tells a guesser which it was.
|
|
781
|
+
if (reason === "not-an-approver")
|
|
782
|
+
return "That password didn't match.";
|
|
772
783
|
return `not approved: ${reason}`;
|
|
773
784
|
}
|
|
774
785
|
/** The browser's one-click approval binds not only the signed scope but the
|
|
@@ -1480,9 +1491,12 @@ export function taskBodyParts(data) {
|
|
|
1480
1491
|
scope.touches.length === 0 ? "" : html `<p class="scope-paths"><strong>Touches</strong> ${scope.touches.join(", ")}</p>`,
|
|
1481
1492
|
html `<p><strong>Quality</strong> ${qualityModeTitle(scope.qualityMode ?? "default")}</p>`,
|
|
1482
1493
|
acceptanceCeremonyHtml(scope.acceptance),
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1494
|
+
scope.releaseTerms == null ? "" : html `<p><strong>Release</strong></p><p class="recap">${releaseTermsSentence(scope.releaseTerms)}</p>${scope.releaseTerms.policy.carry ? html `<details><summary>What asks again</summary><ul>${joinHtml(releaseAsksAgainWords(scope.releaseTerms).map(line => html `<li>${line}</li>`), "")}</ul></details>` : ""}`,
|
|
1495
|
+
approval.approved && data.release?.kind === "carried"
|
|
1496
|
+
? html `<p class="meta scope-seal">Carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a> · <span class="seal">signs ${shortDigest(scope.digest)}</span></p>`
|
|
1497
|
+
: approval.approved
|
|
1498
|
+
? html `<p class="meta scope-seal">Approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
|
|
1499
|
+
: html `<p class="meta"><span class="seal">signs ${shortDigest(scope.digest)}</span> — approval binds to this exact wording</p><p class="meta">Not approved${approval.reason === "changed" ? " — approved once, then rewritten" : ""}</p>`,
|
|
1486
1500
|
html `</div>`,
|
|
1487
1501
|
], "\n");
|
|
1488
1502
|
// The scout's report (mate arc §10): title, summary, the document inert,
|
|
@@ -1773,9 +1787,11 @@ export function taskBodyParts(data) {
|
|
|
1773
1787
|
: "";
|
|
1774
1788
|
const scopeRow = scope === null
|
|
1775
1789
|
? prop("scope", "none yet")
|
|
1776
|
-
: approval.approved
|
|
1777
|
-
? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${
|
|
1778
|
-
:
|
|
1790
|
+
: approval.approved && data.release?.kind === "carried"
|
|
1791
|
+
? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a>`)
|
|
1792
|
+
: approval.approved
|
|
1793
|
+
? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)}`)
|
|
1794
|
+
: prop("scope", approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved");
|
|
1779
1795
|
const strikesRow = data.strikes > 0 ? prop("strikes", `${data.strikes} failed attempt(s)`) : "";
|
|
1780
1796
|
const propsCard = html `<div class="card props">${workerRow}${queueRow}${scopeRow}${publishesRow}${economics}${strikesRow}</div>`;
|
|
1781
1797
|
// The same facts for the rebuilt page, row for row.
|
|
@@ -1790,8 +1806,11 @@ export function taskBodyParts(data) {
|
|
|
1790
1806
|
facts.push({ label: "Queue", parts: [`${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : ` in ${data.position.column}'s queue`} · `, { label: "Reorder", href: "/board?view=order" }] });
|
|
1791
1807
|
}
|
|
1792
1808
|
facts.push(scope === null ? { label: "Scope", parts: ["none yet"] }
|
|
1793
|
-
: approval.approved ? { label: "Approved scope", parts: [
|
|
1794
|
-
: { label: "
|
|
1809
|
+
: approval.approved && data.release?.kind === "carried" ? { label: "Approved scope", parts: [`Carried from ${data.release.approver ?? "a person"}'s release approval · `, { label: `Release ${data.release.version}`, href: taskHref(data.release.anchorTask) }] }
|
|
1810
|
+
: approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
|
|
1811
|
+
: { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
|
|
1812
|
+
if (data.release?.kind === "anchor")
|
|
1813
|
+
facts.push({ label: "Release", parts: [`Approved for release ${data.release.version}`, ...(data.release.landed.length === 0 ? [] : [` · later commits: ${data.release.landed.map(one => one.sha.slice(0, 7)).join(", ")}`]), ...(data.release.carrying ? [] : [` · later commits ask again`])] });
|
|
1795
1814
|
facts.push({ label: "Publishes as", parts: [publishesAs] });
|
|
1796
1815
|
// v102: who asked for it, and what the project's approval rules need.
|
|
1797
1816
|
if (data.filer != null)
|
|
@@ -2068,7 +2087,7 @@ export function taskBodyParts(data) {
|
|
|
2068
2087
|
section("scope",
|
|
2069
2088
|
// The recipe road rides with the scope it reuses (UI polish
|
|
2070
2089
|
// 2026-09-13), off the title-to-action path.
|
|
2071
|
-
joinHtml([html `<h2>Scope</h2>`, scopeCard, data.repo !== null && data.scope !== null ? html `<p class="meta"><a href="/recipes/from-task?task=${encodeURIComponent(task.id)}">Reuse this scope as a recipe →</a></p>` : "", agentsCardHtml(task.id, data.route, data.canEditRoute === true), revisionInApproval ? "" : revisionCard, data.completion != null ? "" : repairChainHtml(data.repairChain ?? null), scopeForm], "\n"), scopeDraft !== undefined || (data.plan !== "requested" && approveForm === "" && !(scope === null && canPlan))),
|
|
2090
|
+
joinHtml([html `<h2>Scope</h2>`, scopeCard, releaseCardHtml(task.id, data.release ?? null, data.canStopRelease === true), data.repo !== null && data.scope !== null ? html `<p class="meta"><a href="/recipes/from-task?task=${encodeURIComponent(task.id)}">Reuse this scope as a recipe →</a></p>` : "", agentsCardHtml(task.id, data.route, data.canEditRoute === true), revisionInApproval ? "" : revisionCard, data.completion != null ? "" : repairChainHtml(data.repairChain ?? null), scopeForm], "\n"), scopeDraft !== undefined || (data.plan !== "requested" && approveForm === "" && !(scope === null && canPlan))),
|
|
2072
2091
|
dependencyChoiceNeeded ? "" : section("waits for", waitsForCard, (data.waitsFor ?? []).length > 0, (data.waitsFor ?? []).length),
|
|
2073
2092
|
section("holds", holds, true, data.holds.length),
|
|
2074
2093
|
cancelAct,
|
|
@@ -2461,3 +2480,25 @@ export function taskOf(store, decision) {
|
|
|
2461
2480
|
const run = store.getRun(decision.run);
|
|
2462
2481
|
return run === null ? "?" : store.externalIdFor(run.taskRef) ?? "?";
|
|
2463
2482
|
}
|
|
2483
|
+
/** A release task's own card (release-once.md, "What people see"): the release it's approved for, the later commits
|
|
2484
|
+
* carried under it (on demand), what a later check must run again, the last nightly check of main, and one action to
|
|
2485
|
+
* stop carrying. A carried task says whose approval it was carried from. */
|
|
2486
|
+
export function releaseCardHtml(taskId, release, canStop) {
|
|
2487
|
+
if (release === null)
|
|
2488
|
+
return html ``;
|
|
2489
|
+
if (release.kind === "carried")
|
|
2490
|
+
return html `<div class="card" id="release"><p><strong>Carried from ${release.approver ?? "a person"}'s release approval</strong></p><p class="meta"><a href="${taskHref(release.anchorTask)}">Release ${release.version}</a> · pull request #${release.pr}</p></div>`;
|
|
2491
|
+
const until = new Intl.DateTimeFormat("en-US", { weekday: "short", hour: "numeric", minute: "2-digit" }).format(new Date(release.expiresAt));
|
|
2492
|
+
const landed = release.landed.length === 0 ? html `<p class="meta">No later commits yet.</p>`
|
|
2493
|
+
: html `<ul>${joinHtml(release.landed.map(one => html `<li><a href="${taskHref(one.task)}">${one.sha.slice(0, 7)}</a> · ${one.files.map(file => `${file.path} (+${file.added} −${file.deleted})`).join(", ")}</li>`), "")}</ul>`;
|
|
2494
|
+
return joinHtml([
|
|
2495
|
+
html `<div class="card" id="release">`,
|
|
2496
|
+
html `<p><strong>Approved for release ${release.version}</strong></p>`,
|
|
2497
|
+
html `<p class="meta">${release.carrying ? `Later commits on pull request #${release.pr} that only change tests, docs or the changelog ship without asking again, until it merges or ${until}.` : `Later commits ask for approval again: ${release.why ?? `the release is ${release.state}`}.`}</p>`,
|
|
2498
|
+
html `<details><summary>Later commits (${release.landed.length})</summary>${landed}</details>`,
|
|
2499
|
+
release.obligations.length === 0 ? "" : html `<details><summary>Still to pass (${release.obligations.length})</summary><ul>${joinHtml(release.obligations.map(one => html `<li>${one}</li>`), "")}</ul></details>`,
|
|
2500
|
+
release.nightly === null ? "" : html `<p class="meta">Nightly check of main: ${release.nightly.outcome ?? "running"} at ${release.nightly.sha.slice(0, 7)}${release.openMainFailures > 0 ? ` · ${release.openMainFailures} open failure${release.openMainFailures === 1 ? "" : "s"}` : ""}</p>`,
|
|
2501
|
+
release.carrying && canStop ? postForm(`${taskHref(taskId)}/stop-carrying`, html `<button type="submit" class="secondary">Stop carrying later commits</button>`, { attrs: { class: "inline" } }) : "",
|
|
2502
|
+
html `</div>`,
|
|
2503
|
+
], "\n");
|
|
2504
|
+
}
|