toolroll 0.9.58 → 0.9.59
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 +7 -0
- package/dist/action-ledger.js +10 -1
- package/dist/browser/workspace.js +1 -1
- package/dist/builder.js +4 -2
- package/dist/chat-channel.d.ts +1 -0
- package/dist/chat-channel.js +3 -0
- package/dist/chat-delivery.js +2 -0
- package/dist/contract-changes.d.ts +52 -0
- package/dist/contract-changes.js +61 -0
- package/dist/decision.d.ts +4 -0
- package/dist/decision.js +8 -0
- package/dist/deploy-lock.d.ts +8 -0
- package/dist/deploy-lock.js +45 -0
- package/dist/dispatch.js +1 -1
- package/dist/lead-cli.js +3 -0
- package/dist/lead-doors.js +3 -0
- package/dist/lead-tools.js +2 -1
- package/dist/needs-you.js +3 -1
- package/dist/operate.js +21 -10
- package/dist/password-bearer.d.ts +4 -4
- package/dist/password-bearer.js +2 -2
- package/dist/planner-source.d.ts +23 -53
- package/dist/planner-source.js +43 -68
- package/dist/release-gate.d.ts +2 -3
- package/dist/release-gate.js +44 -16
- package/dist/scope.js +14 -0
- package/dist/server/render-chat.js +10 -0
- package/dist/server/render-tasks.js +19 -9
- package/dist/server/tasks.js +3 -1
- package/dist/store.d.ts +46 -8
- package/dist/store.js +111 -15
- package/dist/task-status.js +3 -1
- package/dist/telegram-lead.js +6 -0
- package/dist/telegram.js +9 -7
- package/dist/toolroll-update.js +8 -1
- package/package.json +1 -1
package/dist/planner-source.js
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
import { createHash } from "node:crypto";
|
|
39
39
|
import { EVIDENCE_CAPS, readVerifiedArtifact } from "./evidence.js";
|
|
40
40
|
import { TEXT_LIMITS } from "./text-limits.js";
|
|
41
|
+
import { PLAN_TOUCHES } from "./contracts/plan.js";
|
|
42
|
+
import { contractChangesOf } from "./contract-changes.js";
|
|
41
43
|
export const PLANNER_SOURCE_VERSION = 1;
|
|
42
44
|
export const PLANNER_SOURCE_LIMITS = {
|
|
43
45
|
/** The whole recorded source before lossless prompt fencing. Equal to
|
|
@@ -48,6 +50,9 @@ export const PLANNER_SOURCE_LIMITS = {
|
|
|
48
50
|
amendment: TEXT_LIMITS.planAmendment,
|
|
49
51
|
/** Earlier answers quoted into the brief. */
|
|
50
52
|
answers: 5,
|
|
53
|
+
/** Terms that exceed the entire handoff cap cannot fit regardless of the plan's size. Do not reserve a maximum-
|
|
54
|
+
* sized plan here: a shorter document can carry larger valid terms. The final payload still enforces its byte cap. */
|
|
55
|
+
handoffTerms: TEXT_LIMITS.planPayloadBytes,
|
|
51
56
|
};
|
|
52
57
|
/** Stable JSON for hashing and equality only — no filling, no trimming. */
|
|
53
58
|
export function canonicalContractJson(value) {
|
|
@@ -192,26 +197,55 @@ export function plannerSourceOf(store, root, taskId, answers) {
|
|
|
192
197
|
`(scope ${scopeBytes}, revision brief ${briefBytes}); nothing is trimmed silently — shorten the scope or brief, then plan again`,
|
|
193
198
|
};
|
|
194
199
|
}
|
|
200
|
+
const handoff = source.contract.scope === null ? null : plannerHandoffProblemOf(source.contract.scope);
|
|
201
|
+
if (handoff !== null)
|
|
202
|
+
return { ok: false, reason: "too-long", message: handoff };
|
|
195
203
|
return { ok: true, source, bytes };
|
|
196
204
|
}
|
|
197
205
|
/**
|
|
198
206
|
* The dispatch diagnosis's read of the same gate, from the store alone:
|
|
199
207
|
* why the next pass would refuse to plan this task before spending — the
|
|
200
|
-
* revision brief unreadable,
|
|
201
|
-
* size is a floor (the brief's
|
|
202
|
-
* "yes" here is certain and the
|
|
203
|
-
* word. Null when nothing here
|
|
208
|
+
* revision brief unreadable, the filed request over the byte cap, or terms
|
|
209
|
+
* the planner's handoff cannot carry. The size is a floor (the brief's
|
|
210
|
+
* stored bytes, before JSON quoting), so a "yes" here is certain and the
|
|
211
|
+
* dispatch refusal itself stays the exact word. Null when nothing here
|
|
212
|
+
* stands in the way.
|
|
204
213
|
*/
|
|
205
214
|
export function plannerSourceProblemOf(store, taskId) {
|
|
215
|
+
const filed = (detail) => ({ title: "The filed request cannot be planned as filed", detail });
|
|
206
216
|
if (store.answeredDecisionsFor(taskId, PLANNER_SOURCE_LIMITS.answers + 1).length > PLANNER_SOURCE_LIMITS.answers)
|
|
207
|
-
return `more than ${PLANNER_SOURCE_LIMITS.answers} answered decisions exceed the planner history bound; file a follow-up task with the consolidated decisions
|
|
217
|
+
return filed(`more than ${PLANNER_SOURCE_LIMITS.answers} answered decisions exceed the planner history bound; file a follow-up task with the consolidated decisions`);
|
|
208
218
|
const contract = plannerContractOf(store, taskId);
|
|
209
219
|
if (!contract.ok)
|
|
210
|
-
return contract.message;
|
|
220
|
+
return filed(contract.message);
|
|
211
221
|
const briefBytes = contract.contract.revision === null ? 0 : (store.getArtifact(contract.contract.revision.briefArtifact)?.bytesStored ?? 0);
|
|
212
222
|
const floor = Buffer.byteLength(JSON.stringify(contract.contract, null, 2), "utf8") + briefBytes;
|
|
213
223
|
if (floor > PLANNER_SOURCE_LIMITS.bytes) {
|
|
214
|
-
return `the filed request is at least ${floor} bytes — over the ${PLANNER_SOURCE_LIMITS.bytes}-byte planner source cap; shorten the scope or brief before planning
|
|
224
|
+
return filed(`the filed request is at least ${floor} bytes — over the ${PLANNER_SOURCE_LIMITS.bytes}-byte planner source cap; shorten the scope or brief before planning`);
|
|
225
|
+
}
|
|
226
|
+
const handoff = contract.contract.scope === null ? null : plannerHandoffProblemOf(contract.contract.scope);
|
|
227
|
+
return handoff === null ? null : { title: "Scope too long to plan", detail: handoff };
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Whether the planner can hand the filed terms back (contracts/plan.ts): it must reproduce the goal, exclusions,
|
|
231
|
+
* touches and criteria in its handoff, so terms its handoff cannot carry would end every attempt as a malformed plan.
|
|
232
|
+
* This names the problem before any workspace, run or provider spend instead: the actual size, the limit, and what to
|
|
233
|
+
* shorten. Null when the terms fit.
|
|
234
|
+
*/
|
|
235
|
+
export function plannerHandoffProblemOf(terms) {
|
|
236
|
+
const n = (value) => value.toLocaleString("en-US");
|
|
237
|
+
for (const [label, is, text] of [["goal", "is", terms.goal], ["exclusions", "are", terms.outOfScope ?? ""]]) {
|
|
238
|
+
if (text.length > TEXT_LIMITS.goal)
|
|
239
|
+
return `The ${label} ${is} ${n(text.length)} characters; the planner can carry at most ${n(TEXT_LIMITS.goal)}. Shorten the ${label} and planning starts again.`;
|
|
240
|
+
}
|
|
241
|
+
if (terms.touches.length > PLAN_TOUCHES)
|
|
242
|
+
return `The scope lists ${n(terms.touches.length)} paths; the planner can carry at most ${PLAN_TOUCHES}. List fewer paths and planning starts again.`;
|
|
243
|
+
const path = terms.touches.find(one => one.length > TEXT_LIMITS.planTouch);
|
|
244
|
+
if (path !== undefined)
|
|
245
|
+
return `A path is ${n(path.length)} characters; the planner can carry at most ${n(TEXT_LIMITS.planTouch)}. Shorten it and planning starts again.`;
|
|
246
|
+
const bytes = Buffer.byteLength(JSON.stringify({ goal: terms.goal, outOfScope: terms.outOfScope, touches: terms.touches, acceptance: terms.acceptance }), "utf8");
|
|
247
|
+
if (bytes > PLANNER_SOURCE_LIMITS.handoffTerms) {
|
|
248
|
+
return `The goal, exclusions, paths and criteria are ${n(bytes)} bytes together; the planner can carry at most ${n(PLANNER_SOURCE_LIMITS.handoffTerms)} bytes in its whole handoff. Shorten the scope and planning starts again.`;
|
|
215
249
|
}
|
|
216
250
|
return null;
|
|
217
251
|
}
|
|
@@ -257,67 +291,8 @@ export function decodePlannerSource(content) {
|
|
|
257
291
|
return null;
|
|
258
292
|
}
|
|
259
293
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return null;
|
|
263
|
-
const t = text.trim();
|
|
264
|
-
return t === "" ? null : t;
|
|
265
|
-
};
|
|
266
|
-
/**
|
|
267
|
-
* Every addition, change, and removal between the filed contract and a
|
|
268
|
-
* proposed one. Whitespace at the ends and touch/evidence order do not
|
|
269
|
-
* count (the scope digest ignores them too); everything else does,
|
|
270
|
-
* including the advisory `how`, because the operator wrote it.
|
|
271
|
-
*/
|
|
272
|
-
export function contractChangesOf(filed, proposed) {
|
|
273
|
-
const changes = [];
|
|
274
|
-
if (filed.goal.trim() !== proposed.goal.trim()) {
|
|
275
|
-
changes.push({ field: "goal", kind: "changed", before: filed.goal, after: proposed.goal });
|
|
276
|
-
}
|
|
277
|
-
const filedOut = trimmed(filed.outOfScope);
|
|
278
|
-
const proposedOut = trimmed(proposed.outOfScope);
|
|
279
|
-
if (filedOut !== proposedOut) {
|
|
280
|
-
changes.push({
|
|
281
|
-
field: "outOfScope",
|
|
282
|
-
kind: filedOut === null ? "added" : proposedOut === null ? "removed" : "changed",
|
|
283
|
-
before: filed.outOfScope,
|
|
284
|
-
after: proposed.outOfScope,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
const filedTouches = new Set(filed.touches.map(one => one.trim()).filter(one => one !== ""));
|
|
288
|
-
const proposedTouches = new Set(proposed.touches.map(one => one.trim()).filter(one => one !== ""));
|
|
289
|
-
for (const path of filedTouches)
|
|
290
|
-
if (!proposedTouches.has(path))
|
|
291
|
-
changes.push({ field: "touches", kind: "removed", path });
|
|
292
|
-
for (const path of proposedTouches)
|
|
293
|
-
if (!filedTouches.has(path))
|
|
294
|
-
changes.push({ field: "touches", kind: "added", path });
|
|
295
|
-
const criterionOf = (one) => ({ statement: one.statement, evidence: [...one.evidence].sort(), how: one.how });
|
|
296
|
-
const filedById = new Map(filed.acceptance.map(one => [one.id, one]));
|
|
297
|
-
const proposedById = new Map(proposed.acceptance.map(one => [one.id, one]));
|
|
298
|
-
for (const [id, before] of filedById) {
|
|
299
|
-
const after = proposedById.get(id);
|
|
300
|
-
if (after === undefined) {
|
|
301
|
-
changes.push({ field: "acceptance", kind: "removed", id, before: criterionOf(before), after: null, moved: [] });
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
const moved = [];
|
|
305
|
-
if (before.statement.trim() !== after.statement.trim())
|
|
306
|
-
moved.push("statement");
|
|
307
|
-
if ([...before.evidence].sort().join(",") !== [...after.evidence].sort().join(","))
|
|
308
|
-
moved.push("evidence");
|
|
309
|
-
if (trimmed(before.how) !== trimmed(after.how))
|
|
310
|
-
moved.push("how");
|
|
311
|
-
if (moved.length > 0) {
|
|
312
|
-
changes.push({ field: "acceptance", kind: "changed", id, before: criterionOf(before), after: criterionOf(after), moved });
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
for (const [id, after] of proposedById) {
|
|
316
|
-
if (!filedById.has(id))
|
|
317
|
-
changes.push({ field: "acceptance", kind: "added", id, before: null, after: criterionOf(after), moved: [] });
|
|
318
|
-
}
|
|
319
|
-
return changes;
|
|
320
|
-
}
|
|
294
|
+
// ---- amendments -----------------------------------------------------------
|
|
295
|
+
export { contractChangesOf } from "./contract-changes.js";
|
|
321
296
|
const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
322
297
|
/** One line per change, for terminals, notifications, and validation messages. */
|
|
323
298
|
export function describeContractChanges(changes) {
|
package/dist/release-gate.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `toolroll release --release-gate <id>`: lift one update pause that a browser deployment (scripts/deploy-browser.mjs)
|
|
3
|
-
* left behind when it stopped before the swap and its own recovery
|
|
4
|
-
* killed
|
|
5
|
-
* `--phase recover` leaves the pause alone, and new work stays paused.
|
|
3
|
+
* left behind when it stopped before the swap and its own exit recovery never ran. The typical case is a deployment
|
|
4
|
+
* killed outright (SIGKILL, a crash or power loss) while paused: nothing lifted the pause, and new work stays paused.
|
|
6
5
|
*
|
|
7
6
|
* The id alone proves nothing, and neither does the journal's phase. The pause is lifted only when every piece of
|
|
8
7
|
* evidence agrees that the service was never swapped:
|
package/dist/release-gate.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `toolroll release --release-gate <id>`: lift one update pause that a browser deployment (scripts/deploy-browser.mjs)
|
|
3
|
-
* left behind when it stopped before the swap and its own recovery
|
|
4
|
-
* killed
|
|
5
|
-
* `--phase recover` leaves the pause alone, and new work stays paused.
|
|
3
|
+
* left behind when it stopped before the swap and its own exit recovery never ran. The typical case is a deployment
|
|
4
|
+
* killed outright (SIGKILL, a crash or power loss) while paused: nothing lifted the pause, and new work stays paused.
|
|
6
5
|
*
|
|
7
6
|
* The id alone proves nothing, and neither does the journal's phase. The pause is lifted only when every piece of
|
|
8
7
|
* evidence agrees that the service was never swapped:
|
|
@@ -23,6 +22,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
23
22
|
import { join } from "node:path";
|
|
24
23
|
import { durableJson } from "./desktop-update.js";
|
|
25
24
|
import { removeUpdateGate, updateGateOwned } from "./desktop-update-gate.js";
|
|
25
|
+
import { acquireDeploymentLock } from "./deploy-lock.js";
|
|
26
26
|
/** The journal phases a deployment passes before it stops the old service (deploy-phases.mjs). */
|
|
27
27
|
export const BEFORE_SWAP = Object.freeze(["preparing", "admission-paused", "frozen", "backup-verified", "rehearsed"]);
|
|
28
28
|
/** Journal fields that only the swap and later phases write. */
|
|
@@ -129,22 +129,50 @@ export function proveBeforeSwap(id, world) {
|
|
|
129
129
|
}
|
|
130
130
|
/** Prove, then (with `yes`) lift exactly that pause and journal the release in the deployment's own journal. */
|
|
131
131
|
export function releaseGate(id, world, yes) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
// Discovery is not authority to remove anything. Dry-run remains read-only, even for legacy stages.
|
|
133
|
+
const discovered = proveBeforeSwap(id, world);
|
|
134
|
+
if (!discovered.ok)
|
|
135
|
+
return discovered;
|
|
135
136
|
if (!yes)
|
|
136
|
-
return { ok: true, released: false, id, stage:
|
|
137
|
-
const
|
|
138
|
-
|
|
137
|
+
return { ok: true, released: false, id, stage: discovered.stage, proof: discovered.proof };
|
|
138
|
+
const journalFile = join(discovered.stage, "deployment.json");
|
|
139
|
+
let lock;
|
|
139
140
|
try {
|
|
140
|
-
|
|
141
|
+
lock = acquireDeploymentLock(journalFile);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
return refuse("deploying", error.message);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const proved = proveBeforeSwap(id, world);
|
|
148
|
+
if (!proved.ok)
|
|
149
|
+
return proved;
|
|
150
|
+
if (proved.stage !== discovered.stage)
|
|
151
|
+
return refuse("conflict", "The deployment stage changed during release.");
|
|
152
|
+
const db = world.open();
|
|
153
|
+
try {
|
|
154
|
+
// Repeat the entire proof at the mutation boundary, under the same exclusion as deploy/resume/recover.
|
|
155
|
+
const fresh = proveBeforeSwap(id, world);
|
|
156
|
+
if (!fresh.ok)
|
|
157
|
+
return fresh;
|
|
158
|
+
if (fresh.stage !== proved.stage || JSON.stringify(fresh.journal) !== JSON.stringify(proved.journal) ||
|
|
159
|
+
JSON.stringify(JSON.parse(readFileSync(journalFile, "utf8"))) !== JSON.stringify(fresh.journal)) {
|
|
160
|
+
return refuse("conflict", "The deployment journal changed during release.");
|
|
161
|
+
}
|
|
162
|
+
lock.assertHeld(journalFile);
|
|
163
|
+
// The owner is checked again within removeUpdateGate's short write transaction.
|
|
164
|
+
removeUpdateGate(db, id);
|
|
165
|
+
durableJson(journalFile, {
|
|
166
|
+
...fresh.journal, phase: "released", updatedAt: world.now().toISOString(),
|
|
167
|
+
releasedBeforeSwap: { at: world.now().toISOString(), by: "toolroll release --release-gate", from: fresh.journal.phase, proof: fresh.proof },
|
|
168
|
+
});
|
|
169
|
+
return { ok: true, released: true, id, stage: fresh.stage, proof: fresh.proof };
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
db.close();
|
|
173
|
+
}
|
|
141
174
|
}
|
|
142
175
|
finally {
|
|
143
|
-
|
|
176
|
+
lock.release();
|
|
144
177
|
}
|
|
145
|
-
durableJson(join(proved.stage, "deployment.json"), {
|
|
146
|
-
...proved.journal, phase: "released", updatedAt: world.now().toISOString(),
|
|
147
|
-
releasedBeforeSwap: { at: world.now().toISOString(), by: "toolroll release --release-gate", from: proved.journal.phase, proof: proved.proof },
|
|
148
|
-
});
|
|
149
|
-
return { ok: true, released: true, id, stage: proved.stage, proof: proved.proof };
|
|
150
178
|
}
|
package/dist/scope.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "./contracts/acceptance-terms.js";
|
|
3
3
|
import { currentPasswordSource, passwordGuardOf, provenPasswordAccount } from "./sign-in-guard.js";
|
|
4
4
|
import { validateScopeText } from "./task-text.js";
|
|
5
|
+
import { contractChangesOf } from "./contract-changes.js";
|
|
5
6
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
7
|
import { projectAuthority } from "./project-access.js";
|
|
7
8
|
import { activeRemote } from "./remote-run.js";
|
|
@@ -558,6 +559,19 @@ route) {
|
|
|
558
559
|
.slice(0, 32);
|
|
559
560
|
}
|
|
560
561
|
export function propose(store, input) {
|
|
562
|
+
return store.transact(() => {
|
|
563
|
+
const previous = store.getScope(input.taskId);
|
|
564
|
+
const saved = proposeLocked(store, input);
|
|
565
|
+
// A rewritten goal, exclusions, touches or rubric on a task that is planning starts its planning again (v119), in
|
|
566
|
+
// the same transaction: the next planner plans for the new terms, never from the old plan or its answers. An
|
|
567
|
+
// identical refile changes nothing; the planner's own handoff writes its draft without coming through here.
|
|
568
|
+
const ref = store.lookupRef(input.taskId);
|
|
569
|
+
if (ref !== null && ref.plan !== null && (previous === null || contractChangesOf(previous, saved).length > 0))
|
|
570
|
+
store.restartPlanning(ref.id, input.now);
|
|
571
|
+
return saved;
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
function proposeLocked(store, input) {
|
|
561
575
|
const { taskId, goal, outOfScope = null, touches = [], budgetMicrousd = null, acceptance = [], qualityMode = "default", now, mutation = {}, profile, permissionMode, posture, proposedVia = null } = input;
|
|
562
576
|
const draft = { goal, outOfScope, touches: [...touches], budgetMicrousd, acceptance: [...acceptance], qualityMode, candidate: input.candidate ?? null };
|
|
563
577
|
const previous = store.getScope(taskId);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { assignmentSummaryHtml } from "../assignment-ui.js";
|
|
3
3
|
import {} from "../assignment.js";
|
|
4
4
|
import {} from "../browser-workspace.js";
|
|
5
|
+
import { closedQuestionReason } from "../decision.js";
|
|
5
6
|
import { CHAT_ACTIONS, sharedActionNeedsReview, sharedActionPayload, sharedActionReviewPath } from "../chat-actions.js";
|
|
6
7
|
import { CHAT_CONTINUITY_SCRIPT } from "../chat-continuity.js";
|
|
7
8
|
import { CHAT_CONTROLS, chatControlHref, isChatControl } from "../chat-controls.js";
|
|
@@ -478,6 +479,15 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
|
|
|
478
479
|
const text = (key) => (typeof payload[key] === "string" ? payload[key] : "");
|
|
479
480
|
const task = text("task");
|
|
480
481
|
const repoId = text("repoId");
|
|
482
|
+
if (view.kind === "answer" && decision?.supersededReason != null) {
|
|
483
|
+
// The proposal remains in history, but its question no longer needs an answer.
|
|
484
|
+
// Both the server page and browser card use the same closed view, without stale consent controls.
|
|
485
|
+
const body = html `<p><a href="/d/${decision.id}">${decision.question}</a></p><p class="meta">${closedQuestionReason(decision.supersededReason)}</p>`;
|
|
486
|
+
return {
|
|
487
|
+
html: html `<article class="card proposal proposal-answer" data-card-kind="answer"><header class="proposal-head"><strong>Question closed</strong></header><div class="proposal-body">${body}</div></article>`,
|
|
488
|
+
card: { id: view.id, kind: view.kind, label: "Question closed", state: view.state, body: htmlString(body), said: null, links: [], primary: null, dismissable: false, note: null },
|
|
489
|
+
};
|
|
490
|
+
}
|
|
481
491
|
const presentations = {
|
|
482
492
|
task: { label: payload["report"] === true ? "Scout investigation" : "New task", action: "file task", icon: html `<path d="M12 5v14"/><path d="M5 12h14"/>` },
|
|
483
493
|
next: { label: "Queue priority", action: "move to front", icon: html `<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>` },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Task pages: approval, agents, plans, controls, decisions and the task body. */
|
|
2
2
|
import { html, htmlString, joinHtml, postForm, replaceMarkup } from "../html.js";
|
|
3
|
+
import { closedQuestionReason, closedQuestionWords } from "../decision.js";
|
|
3
4
|
import {} from "../activity-line.js";
|
|
4
5
|
import {} from "../agentconfig.js";
|
|
5
6
|
import { gateWords } from "../approval-policy.js";
|
|
@@ -1469,9 +1470,12 @@ export function taskBodyParts(data) {
|
|
|
1469
1470
|
!(data.revision != null && "problem" in data.revision) && scope.profileState !== "unresolved" && consentDoorOf(scope, data.route).open;
|
|
1470
1471
|
const planCard = data.planDocument === null
|
|
1471
1472
|
? data.plan === "requested"
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1473
|
+
// A request the planner cannot be given waits on a person; the status card above already says what to shorten.
|
|
1474
|
+
? data.dispatch?.code === "planner-source"
|
|
1475
|
+
? ""
|
|
1476
|
+
: data.revision != null && !("problem" in data.revision)
|
|
1477
|
+
? html `<div class="card planner-status"><span class="planner-orb" aria-hidden="true"></span><p><strong>Updating the plan</strong><span class="meta">Adding your notes to the plan. You approve any change before it builds.</span></p></div>`
|
|
1478
|
+
: html `<div class="card planner-status"><span class="planner-orb" aria-hidden="true"></span><p><strong>Planning requested</strong><span class="meta">${data.planAuto ? "Automatic approval is enabled for a verified plan that preserves your filed contract. Amendments and unanswered questions still pause." : "The agent is inspecting the repository and drafting the goal, acceptance criteria, and approach. It will ask only if a missing answer changes the work."}</span></p></div>`
|
|
1475
1479
|
: ""
|
|
1476
1480
|
: joinHtml([
|
|
1477
1481
|
approval.approved ? html `<details class="card planner-plan planner-plan-collapsed"><summary class="execution-plan-head">` : html `<section class="card planner-plan"><div class="execution-plan-head">`,
|
|
@@ -1788,7 +1792,7 @@ export function taskBodyParts(data) {
|
|
|
1788
1792
|
})();
|
|
1789
1793
|
const decisions = data.decisions.length === 0
|
|
1790
1794
|
? ""
|
|
1791
|
-
: html `<h2>Decisions</h2>${joinHtml(data.decisions.map(decision => html `<p class="row"><a href="/d/${decision.id}">${decision.question}</a> <span class="meta">${decision.state}${isOverdue(decision, data.now) ? " · overdue" : ""}</span></p>`), "\n")}`;
|
|
1795
|
+
: html `<h2>Decisions</h2>${joinHtml(data.decisions.map(decision => html `<p class="row"><a href="/d/${decision.id}">${decision.question}</a> <span class="meta">${decision.supersededReason !== null ? closedQuestionWords(decision.supersededReason) : decision.state}${isOverdue(decision, data.now) ? " · overdue" : ""}</span></p>`), "\n")}`;
|
|
1792
1796
|
const incidents = data.incidents.length === 0
|
|
1793
1797
|
? ""
|
|
1794
1798
|
: html `<h2>Incidents</h2>${joinHtml(data.incidents.map(one => one.resolvedAt === null
|
|
@@ -2068,7 +2072,11 @@ export function taskBodyParts(data) {
|
|
|
2068
2072
|
continue;
|
|
2069
2073
|
thread.push({ key: `question-${decision.id}`, at: decision.createdAt, kind: "question", who: "agent", author: "", title: "Asked",
|
|
2070
2074
|
text: decision.question, link: { label: "Question", href: `/d/${decision.id}` }, html: "", more: null });
|
|
2071
|
-
if (decision.
|
|
2075
|
+
if (decision.supersededReason !== null) {
|
|
2076
|
+
thread.push({ key: `answer-${decision.id}`, at: decision.answeredAt ?? decision.createdAt, kind: "progress", who: "agent", author: "", title: "Closed without an answer",
|
|
2077
|
+
text: closedQuestionReason(decision.supersededReason), link: null, html: "", more: null });
|
|
2078
|
+
}
|
|
2079
|
+
else if (decision.answeredAt !== null) {
|
|
2072
2080
|
const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice;
|
|
2073
2081
|
thread.push({ key: `answer-${decision.id}`, at: decision.answeredAt, kind: "reply", who: "person", author: decision.answeredBy ?? "You", title: "Answered",
|
|
2074
2082
|
text: [chosen, decision.note].filter((one) => one !== null && one !== "").join(" — ") || null, link: null, html: "", more: null });
|
|
@@ -2320,9 +2328,11 @@ export function decisionOptionForms(decision, returnTo) {
|
|
|
2320
2328
|
}
|
|
2321
2329
|
export function decisionPage(chrome, decision, taskId, artifacts, who, now, returnTo = null) {
|
|
2322
2330
|
const options = decisionOptionForms(decision, returnTo);
|
|
2323
|
-
const answered = decision.
|
|
2324
|
-
? html `<
|
|
2325
|
-
: ""
|
|
2331
|
+
const answered = decision.supersededReason !== null
|
|
2332
|
+
? html `<p class="meta">${closedQuestionReason(decision.supersededReason)}</p>`
|
|
2333
|
+
: decision.state === "answered"
|
|
2334
|
+
? html `<div class="answered">Answered: <strong>${decision.choice ?? ""}</strong> by ${decision.answeredBy ?? ""}${decision.note === null ? "" : ` — ${decision.note}`}</div>`
|
|
2335
|
+
: "";
|
|
2326
2336
|
const evidence = artifacts.length === 0
|
|
2327
2337
|
? ""
|
|
2328
2338
|
: joinHtml([
|
|
@@ -2331,7 +2341,7 @@ export function decisionPage(chrome, decision, taskId, artifacts, who, now, retu
|
|
|
2331
2341
|
html `</div>`,
|
|
2332
2342
|
]);
|
|
2333
2343
|
return screen(`decide · ${taskId}`, joinHtml([
|
|
2334
|
-
html `<h1>${taskId} <span class="badge badge-${decision.state}">${decision.state}</span>${isOverdue(decision, now) ? html ` <span class="badge badge-overdue">Overdue</span>` : ""}${decision.deadline === null ? "" : html ` <span class="meta">deadline ${decision.deadline}</span>`}</h1>`,
|
|
2344
|
+
html `<h1>${taskId} <span class="badge badge-${decision.state}">${decision.supersededReason !== null ? "closed" : decision.state}</span>${isOverdue(decision, now) ? html ` <span class="badge badge-overdue">Overdue</span>` : ""}${decision.deadline === null ? "" : html ` <span class="meta">deadline ${decision.deadline}</span>`}</h1>`,
|
|
2335
2345
|
html `<div class="recap">${decision.recap}</div>`,
|
|
2336
2346
|
html `<div class="question">${decision.question}</div>`,
|
|
2337
2347
|
decision.state === "answered" ? answered : options,
|
package/dist/server/tasks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { handlersOf } from './handler-registry.js';
|
|
2
|
+
import { closedQuestionWords } from "../decision.js";
|
|
2
3
|
import { DEPRECATED_PAGE } from "../deprecations.js";
|
|
3
4
|
/** tasks handlers, moved without changing their route bodies. */
|
|
4
5
|
import { html, htmlString, joinHtml, postForm, textHtml } from "../html.js";
|
|
@@ -1118,7 +1119,8 @@ export function createTasksHandlers(runtime) {
|
|
|
1118
1119
|
}, now);
|
|
1119
1120
|
if (!answered.ok) {
|
|
1120
1121
|
const status = answered.reason === "bad-option" || answered.reason === "bad-note" ? 400 : 409;
|
|
1121
|
-
const
|
|
1122
|
+
const closed = answered.reason === "closed" ? store.getDecision(id)?.supersededReason ?? null : null;
|
|
1123
|
+
const why = closed !== null ? closedQuestionWords(closed) : answered.reason === "already-answered" ? "already answered — somebody got there first" : answered.reason;
|
|
1122
1124
|
return refuse(response, who, status, why, requestedReturn === null ? `/d/${id}` : decisionBack);
|
|
1123
1125
|
}
|
|
1124
1126
|
return redirect(response, decisionBack);
|
package/dist/store.d.ts
CHANGED
|
@@ -152,7 +152,7 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
152
152
|
hash: string;
|
|
153
153
|
} | null;
|
|
154
154
|
};
|
|
155
|
-
export declare const SCHEMA_VERSION =
|
|
155
|
+
export declare const SCHEMA_VERSION = 119;
|
|
156
156
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
157
157
|
export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
|
|
158
158
|
/**
|
|
@@ -207,9 +207,16 @@ export declare const V115_DROPPED_TABLES: readonly string[];
|
|
|
207
207
|
* teammates' to its subagents' (SQLite's RENAME, every row, id and link carried), renames their teammate column, and rewrites four internal kinds from 'teammate' to 'subagent' in place; the rehearsal checks
|
|
208
208
|
* each renamed table's rows arrived under its new name. v118 (D7) is checked the same way: every row of api_token,
|
|
209
209
|
* coordinator_credential and lead_credential moves into credential under its kind in one transaction that counts them
|
|
210
|
-
* before the old tables drop, and the references that named them name credential in place, their rows untouched.
|
|
210
|
+
* before the old tables drop, and the references that named them name credential in place, their rows untouched. v119 only adds four columns
|
|
211
|
+
* (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
|
|
212
|
+
* the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns).
|
|
211
213
|
*/
|
|
212
214
|
export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
|
|
215
|
+
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
216
|
+
export declare const V119_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
|
|
217
|
+
definition: string;
|
|
218
|
+
saved: string;
|
|
219
|
+
}>>>>;
|
|
213
220
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
214
221
|
export declare function updateSafeSchema(version: number | null): boolean;
|
|
215
222
|
export type OAuthClientRow = {
|
|
@@ -330,6 +337,9 @@ export type TaskRef = {
|
|
|
330
337
|
plan: "requested" | "drafted" | null;
|
|
331
338
|
/** Planning failures, counted apart from build strikes by design. */
|
|
332
339
|
planStrikes: number;
|
|
340
|
+
/** v119: which planning context is active. A rewritten scope advances it; plans, revisions and answers from an
|
|
341
|
+
* earlier generation stay as history, never as the next planner's context. */
|
|
342
|
+
planningGeneration: number;
|
|
333
343
|
/** What comes back (v34): a branch to publish, or a report to read.
|
|
334
344
|
* Set at filing and never rewritten — a scout's strikes are the task's. */
|
|
335
345
|
deliverable: "branch" | "report";
|
|
@@ -1050,6 +1060,8 @@ export type Run = {
|
|
|
1050
1060
|
taskRef: number;
|
|
1051
1061
|
leaseId: string;
|
|
1052
1062
|
runner: string;
|
|
1063
|
+
/** v119: the task's planning generation when this run opened (0 for every run before it). */
|
|
1064
|
+
planningGeneration: number;
|
|
1053
1065
|
/** Explicit watch ownership for claimless review runs; null on cron/legacy reviews. */
|
|
1054
1066
|
watchIncarnation?: string | null;
|
|
1055
1067
|
/** v50: a ROOT reviewer's attempt ordinal for its source run (1..3);
|
|
@@ -1194,7 +1206,12 @@ export type Decision = {
|
|
|
1194
1206
|
answeredVia: "cli" | "web" | "telegram" | "slack" | "discord" | "teams" | null;
|
|
1195
1207
|
choice: string | null;
|
|
1196
1208
|
note: string | null;
|
|
1209
|
+
/** v119: closed without an answer (state 'answered', no choice) because its task's scope changed or the task was
|
|
1210
|
+
* cancelled. null is every real answer and every question still waiting. */
|
|
1211
|
+
supersededReason: DecisionClosure | null;
|
|
1197
1212
|
};
|
|
1213
|
+
/** Why a question closed without an answer (v119). */
|
|
1214
|
+
export type DecisionClosure = "scope-changed" | "cancelled";
|
|
1198
1215
|
/** Evidence, by reference. `key` is relative to the evidence root, never absolute. */
|
|
1199
1216
|
export type Artifact = {
|
|
1200
1217
|
id: number;
|
|
@@ -3446,6 +3463,24 @@ export declare class Store {
|
|
|
3446
3463
|
ok: false;
|
|
3447
3464
|
reason: "live-claim";
|
|
3448
3465
|
};
|
|
3466
|
+
/**
|
|
3467
|
+
* A rewritten scope starts planning again (v119), in one transaction: the task's planning generation advances, a
|
|
3468
|
+
* requested or drafted plan becomes requested with no strikes, and what the old goal produced leaves the active
|
|
3469
|
+
* planning context — its open questions close (typed 'scope-changed', never a fake answer, their holds and pages
|
|
3470
|
+
* with them) and a revision still waiting for a person is rejected with its hold. The old runs, plans, revisions,
|
|
3471
|
+
* answers and ledger rows all stay: generation-bound reads simply stop offering them to the next planner. Only a
|
|
3472
|
+
* task already planning restarts; false (nothing changed) for any other.
|
|
3473
|
+
*/
|
|
3474
|
+
restartPlanning(taskRef: number, now: Date): boolean;
|
|
3475
|
+
/**
|
|
3476
|
+
* Close a task's open and expired questions without answering them (v119): typed with why, no choice, their
|
|
3477
|
+
* holds lifted and their pages resolved, so they leave every decision queue. Answered questions are never touched.
|
|
3478
|
+
* `belowGeneration` limits it to questions asked in earlier planning generations. Caller holds the transaction.
|
|
3479
|
+
*/
|
|
3480
|
+
private closeQuestionsLocked;
|
|
3481
|
+
/** The number the next plan revision of a task takes: one past every revision it ever had, in any planning
|
|
3482
|
+
* generation, so a restarted plan's revisions never collide with the history they follow. */
|
|
3483
|
+
nextPlanRevisionNumber(taskRef: number): number;
|
|
3449
3484
|
/** Move the planning state; null clears it. Caller owns the transaction story. */
|
|
3450
3485
|
setPlanState(taskRef: number, state: "requested" | "drafted" | null): void;
|
|
3451
3486
|
addPlanStrike(taskRef: number): number;
|
|
@@ -4475,7 +4510,8 @@ export declare class Store {
|
|
|
4475
4510
|
* reads in the order the questions arrived. "unanswered" is open + expired:
|
|
4476
4511
|
* expiry makes a decision louder, never gone.
|
|
4477
4512
|
*/
|
|
4478
|
-
/** The newest planner run's plan document for a task, when one exists
|
|
4513
|
+
/** The newest planner run's plan document for a task, when one exists in its current planning generation (v119:
|
|
4514
|
+
* a plan drafted for a scope since rewritten is history, read by its artifact id). */
|
|
4479
4515
|
latestPlanArtifact(taskRef: number): Artifact | null;
|
|
4480
4516
|
/** The newest plan-contract INGESTION record on one of this task's
|
|
4481
4517
|
* planner runs (contract handoff, task 1): what the draft proposed
|
|
@@ -4508,8 +4544,9 @@ export declare class Store {
|
|
|
4508
4544
|
changedFields: readonly string[];
|
|
4509
4545
|
status: PlanRevisionStatus;
|
|
4510
4546
|
}, now: Date): number;
|
|
4511
|
-
/** Every revision for a task
|
|
4512
|
-
* reader can show without a second table
|
|
4547
|
+
/** Every revision for a task in its current planning generation, newest
|
|
4548
|
+
* first — the immutable history a reader can show without a second table
|
|
4549
|
+
* (v119: an earlier generation's rows stay, read by id). */
|
|
4513
4550
|
listPlanRevisions(taskRef: number): PlanRevision[];
|
|
4514
4551
|
/** The current plan revision: the newest row an operator (or an
|
|
4515
4552
|
* auto-applied plan-only proposal) has actually put in force. Null for
|
|
@@ -4566,7 +4603,8 @@ export declare class Store {
|
|
|
4566
4603
|
checkpointHistory(run: number): RunCheckpoint[];
|
|
4567
4604
|
/** The newest scout run's report for a task (v34), when one exists. */
|
|
4568
4605
|
latestReportArtifact(taskRef: number): Artifact | null;
|
|
4569
|
-
/** Answered questions for one task, newest first — the planner's memory.
|
|
4606
|
+
/** Answered questions for one task, newest first — the planner's memory. Only real answers from its current
|
|
4607
|
+
* planning generation (v119): a question closed unanswered, or asked about a scope since rewritten, is history. */
|
|
4570
4608
|
answeredDecisionsFor(taskId: string, limit?: number): {
|
|
4571
4609
|
question: string;
|
|
4572
4610
|
choice: string | null;
|
|
@@ -4668,7 +4706,7 @@ export declare class Store {
|
|
|
4668
4706
|
duplicate?: boolean;
|
|
4669
4707
|
} | {
|
|
4670
4708
|
ok: false;
|
|
4671
|
-
reason: "unknown-decision" | "bad-option" | "already-answered" | "bad-note";
|
|
4709
|
+
reason: "unknown-decision" | "bad-option" | "already-answered" | "closed" | "bad-note";
|
|
4672
4710
|
};
|
|
4673
4711
|
/**
|
|
4674
4712
|
* The answer's body, for callers already holding the transaction — the
|
|
@@ -4689,7 +4727,7 @@ export declare class Store {
|
|
|
4689
4727
|
duplicate?: boolean;
|
|
4690
4728
|
} | {
|
|
4691
4729
|
ok: false;
|
|
4692
|
-
reason: "unknown-decision" | "bad-option" | "already-answered" | "bad-note";
|
|
4730
|
+
reason: "unknown-decision" | "bad-option" | "already-answered" | "closed" | "bad-note";
|
|
4693
4731
|
};
|
|
4694
4732
|
saveArtifact(artifact: {
|
|
4695
4733
|
run: number;
|