toolroll 0.9.102 → 0.9.104
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 +13 -0
- package/README.md +10 -8
- package/dist/assignment.d.ts +32 -0
- package/dist/assignment.js +140 -4
- package/dist/browser/workspace.js +25 -25
- package/dist/browser-workspace.d.ts +21 -5
- package/dist/contracts/console-api.d.ts +3 -2
- package/dist/contracts/console-api.js +4 -2
- package/dist/desktop-update.d.ts +2 -2
- package/dist/flow-engine.d.ts +3 -0
- package/dist/flow-engine.js +4 -1
- package/dist/flows-ui.d.ts +31 -1
- package/dist/flows-ui.js +40 -12
- package/dist/keymap.d.ts +3 -1
- package/dist/keymap.js +3 -0
- package/dist/local-owner-acts.d.ts +83 -0
- package/dist/local-owner-acts.js +221 -0
- package/dist/local-owner.d.ts +12 -1
- package/dist/local-owner.js +26 -0
- package/dist/operate-remote-arguments.js +4 -2
- package/dist/operate.js +76 -1
- package/dist/plan-auto.js +5 -0
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/public-access.d.ts +9 -0
- package/dist/public-access.js +15 -0
- package/dist/pull-request-flow.js +9 -0
- package/dist/release-approval.d.ts +9 -20
- package/dist/release-approval.js +14 -53
- package/dist/release-card.d.ts +15 -1
- package/dist/release-card.js +35 -12
- package/dist/release-refusal.d.ts +32 -0
- package/dist/release-refusal.js +73 -0
- package/dist/result-completion.d.ts +6 -2
- package/dist/result-completion.js +13 -7
- package/dist/scope.d.ts +32 -1
- package/dist/scope.js +116 -25
- package/dist/serve.js +13 -14
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +11 -1
- package/dist/server/home-asks.d.ts +45 -0
- package/dist/server/home-asks.js +108 -0
- package/dist/server/home.d.ts +15 -5
- package/dist/server/home.js +53 -18
- package/dist/server/pages.js +15 -3
- package/dist/server/remote.d.ts +1 -0
- package/dist/server/render-results.d.ts +4 -0
- package/dist/server/render-tasks.d.ts +9 -0
- package/dist/server/render-tasks.js +4 -4
- package/dist/server/route-table.js +4 -0
- package/dist/server/runtime.d.ts +1 -0
- package/dist/server/task-views.js +3 -0
- package/dist/server/tasks.d.ts +25 -0
- package/dist/server/tasks.js +46 -6
- package/dist/surface.js +12 -2
- package/dist/task-outcome-cli.d.ts +18 -0
- package/dist/task-outcome-cli.js +60 -2
- package/dist/tui-contract.d.ts +61 -3
- package/dist/tui-source.js +91 -20
- package/dist/tui.js +54 -54
- package/dist/work-index.d.ts +10 -5
- package/dist/work-index.js +23 -13
- package/package.json +2 -2
package/dist/server/tasks.js
CHANGED
|
@@ -30,6 +30,7 @@ import { codingShippingHtml } from '../coding-shipping-ui.js';
|
|
|
30
30
|
import { codingWorkspaceHtml, codingWorkspaceScript } from '../coding-ui.js';
|
|
31
31
|
import { CodingActionError } from '../coding-workspace.js';
|
|
32
32
|
import { checkResponse, CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
|
|
33
|
+
import { LOCAL_TASK_CHANGED, requestPlanAsLocalOwner, retryAsLocalOwner, retryPinsOf } from "../local-owner-acts.js";
|
|
33
34
|
import { hasForbiddenControls, validateNote } from "../decision.js";
|
|
34
35
|
import { builderReadyFor, diagnoseTaskDispatch, firstRunBuilderRepo } from "../dispatch.js";
|
|
35
36
|
import { firstTaskSuggestions, withBuilderStep } from "../first-run.js";
|
|
@@ -276,7 +277,7 @@ export function createTasksHandlers(runtime) {
|
|
|
276
277
|
const latest = latestFinishedAttempt(runs);
|
|
277
278
|
// Retry from here only when the task failed, nothing holds it, and this is its latest attempt or a result it delivered.
|
|
278
279
|
const retry = failedTask?.state === "failed" && (latest?.id === selected.run.id || delivered) && csrf !== "" && who.role === "approver" && store.currentLiveLease(attemptRun.taskRef, now) === null
|
|
279
|
-
? { action: `${taskHref(selected.taskId)}/requeue
|
|
280
|
+
? { action: `${taskHref(selected.taskId)}/requeue`, pins: retryPinsOf(store, selected.taskId) } : null;
|
|
280
281
|
// Accept anyway: the same acceptance this task's latest result takes, never for an older one or one already accepted.
|
|
281
282
|
const family = delivered ? familyOf(selected.taskId) : null;
|
|
282
283
|
const acceptAnyway = delivered && csrf !== "" && who.role === "approver" && family?.current.id === selected.taskId && family.problem === null &&
|
|
@@ -1895,11 +1896,22 @@ export function createTasksHandlers(runtime) {
|
|
|
1895
1896
|
return refuse(response, who, 403, "steering is a browser session's act");
|
|
1896
1897
|
if (note !== "" && !validateNote(note).ok)
|
|
1897
1898
|
return taskScreen(response, who, taskId, "that note will not store, so nothing was retried", 400);
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1899
|
+
// Local-owner slice 3: the laptop's local owner retries its own approved build on its own road, bound to the
|
|
1900
|
+
// build and plan the page read. The same request again files no second note.
|
|
1901
|
+
let replayed = false;
|
|
1902
|
+
if (who.via === "cookie" && who.authority === "local-owner") {
|
|
1903
|
+
const retried = localOwnerRetry(store, taskId, body.get("run"), body.get("digest"), now);
|
|
1904
|
+
if (!retried.ok)
|
|
1905
|
+
return taskScreen(response, who, taskId, retried.message, retried.status);
|
|
1906
|
+
replayed = retried.replayed;
|
|
1907
|
+
}
|
|
1908
|
+
else {
|
|
1909
|
+
const requeued = store.requeueTask(taskId, who.name, now);
|
|
1910
|
+
if (!requeued.ok) {
|
|
1911
|
+
return taskScreen(response, who, taskId, `not requeued: ${requeued.reason}`, 409);
|
|
1912
|
+
}
|
|
1901
1913
|
}
|
|
1902
|
-
if (note !== "") {
|
|
1914
|
+
if (note !== "" && !replayed) {
|
|
1903
1915
|
const filed = store.fileSteerNote(taskId, verifiedAuthor(who.name), note, now);
|
|
1904
1916
|
if (!filed.ok)
|
|
1905
1917
|
return taskScreen(response, who, taskId, "Retried. The note wasn't saved; add it under Steering.", 409);
|
|
@@ -1954,6 +1966,11 @@ export function createTasksHandlers(runtime) {
|
|
|
1954
1966
|
return redirect(response, taskHref(filed.id));
|
|
1955
1967
|
}
|
|
1956
1968
|
case "plan": {
|
|
1969
|
+
// Local-owner slice 2: the laptop's local owner asks on its own road, bound to the planning source it read.
|
|
1970
|
+
if (who.via === "cookie" && who.authority === "local-owner") {
|
|
1971
|
+
const asked = localOwnerPlan(store, taskId, readForm(posted, CONSOLE_FORMS.taskPlan).get("source"), now);
|
|
1972
|
+
return asked.ok ? redirect(response, taskHref(taskId)) : taskScreen(response, who, taskId, asked.message, asked.status);
|
|
1973
|
+
}
|
|
1957
1974
|
// The operator's explicit ask, refused transactionally when the
|
|
1958
1975
|
// moment has passed — approved scope, live claim, or a plan
|
|
1959
1976
|
// already under way (Codex planning review's requestPlan guard).
|
|
@@ -1961,7 +1978,8 @@ export function createTasksHandlers(runtime) {
|
|
|
1961
1978
|
if (!asked.ok) {
|
|
1962
1979
|
return taskScreen(response, who, taskId, `not planned: ${asked.reason}`, 409);
|
|
1963
1980
|
}
|
|
1964
|
-
|
|
1981
|
+
// Only a full session: a local owner never pre-authorizes (plan-auto.ts refuses it there too).
|
|
1982
|
+
if (who.via === "cookie" && who.authority === "full")
|
|
1965
1983
|
authorizePlanUnderMode(store, taskId, who.name, now);
|
|
1966
1984
|
return redirect(response, taskHref(taskId));
|
|
1967
1985
|
}
|
|
@@ -3339,3 +3357,25 @@ export function createReleaseSheetHandlers(runtime) {
|
|
|
3339
3357
|
export function answerApprovalInPlace(response, outcome, words, status) {
|
|
3340
3358
|
respond(response, status, "application/json; charset=utf-8", JSON.stringify({ outcome, words }));
|
|
3341
3359
|
}
|
|
3360
|
+
/**
|
|
3361
|
+
* The task page's plan button for the laptop's local owner (local-owner slice 2): bound to the planning source the page
|
|
3362
|
+
* read (the form's hidden `source`), never the task as it is now; the writer (local-owner-acts.ts) proves the rest in one
|
|
3363
|
+
* transaction. No mode pre-authorization and no pins: the full session's road above is never reached from here.
|
|
3364
|
+
*/
|
|
3365
|
+
export function localOwnerPlan(store, taskId, source, now) {
|
|
3366
|
+
if (typeof source !== "string" || !/^[a-f0-9]{64}$/.test(source))
|
|
3367
|
+
return { ok: false, status: 409, message: LOCAL_TASK_CHANGED };
|
|
3368
|
+
const done = requestPlanAsLocalOwner(store, taskId, source, now);
|
|
3369
|
+
return done.ok ? { ok: true } : { ok: false, status: done.reason === "local-owner" || done.reason === "refused" ? 403 : 409, message: done.message };
|
|
3370
|
+
}
|
|
3371
|
+
/**
|
|
3372
|
+
* Retry for the laptop's local owner (local-owner slice 3): bound to the build and the approved plan the page read (the
|
|
3373
|
+
* form's hidden `run` and `digest`), never the task as it is now; the writer proves the rest in one transaction. A form
|
|
3374
|
+
* that carries neither (an older page) is refused, never bound to whatever is current.
|
|
3375
|
+
*/
|
|
3376
|
+
export function localOwnerRetry(store, taskId, run, digest, now) {
|
|
3377
|
+
if (typeof run !== "string" || !/^[1-9][0-9]{0,14}$/.test(run) || typeof digest !== "string" || !/^[a-f0-9]{32}$/.test(digest))
|
|
3378
|
+
return { ok: false, status: 409, message: LOCAL_TASK_CHANGED };
|
|
3379
|
+
const done = retryAsLocalOwner(store, taskId, { run: Number(run), digest }, now);
|
|
3380
|
+
return done.ok ? { ok: true, replayed: done.replayed } : { ok: false, status: done.reason === "local-owner" || done.reason === "refused" || done.reason === "limit" ? 403 : 409, message: done.message };
|
|
3381
|
+
}
|
package/dist/surface.js
CHANGED
|
@@ -222,7 +222,11 @@ const GUIDE_ROWS = [
|
|
|
222
222
|
{ name: 'token-env', takesValue: true, meaning: 'scoped coordinator credential environment variable' },
|
|
223
223
|
{ name: 'token-file', takesValue: true, meaning: 'scoped coordinator credential file; defaults to selected project reference' },
|
|
224
224
|
...(action === 'complete' ? [{ name: 'digest', takesValue: true, meaning: 'exact receipt from assignment show; required for JSON or scoped agents' },
|
|
225
|
-
{ name: 'pull-request', takesValue: false, meaning: "also open the result's pull request from that commit, as the console's Complete does; an approver's sign-in (--as/--token), never a scoped credential; refuses with pull-requests-off and the setup command" }
|
|
225
|
+
{ name: 'pull-request', takesValue: false, meaning: "also open the result's pull request from that commit, as the console's Complete does; an approver's sign-in (--as/--token), never a scoped credential; refuses with pull-requests-off and the setup command" },
|
|
226
|
+
// Local-owner slice 1: the terminal UI's Accept, for the laptop's local owner only (no password).
|
|
227
|
+
{ name: 'run', takesValue: true, meaning: "the terminal's local owner: the exact build of the result read; required there with --digest" },
|
|
228
|
+
{ name: 'checked', takesValue: false, meaning: "the terminal's local owner: you checked the result yourself; required when it owes your own check" },
|
|
229
|
+
{ name: 'reason', takesValue: true, meaning: "the terminal's local owner: why you accept a result whose requirement isn't shown; required for an exception" }]
|
|
226
230
|
: [{ name: 'feedback', takesValue: true, meaning: 'specific requested change' }, { name: 'run', takesValue: true, meaning: 'exact result run; defaults to current result' }, { name: 'source', takesValue: true, meaning: 'exact scope digest; defaults to current terms' }, { name: 'key', takesValue: true, meaning: '32 hexadecimal characters for exact replay; defaults to a stable feedback identity' }]),
|
|
227
231
|
],
|
|
228
232
|
})),
|
|
@@ -297,7 +301,9 @@ const GUIDE_ROWS = [
|
|
|
297
301
|
positionals: [{ name: "id", required: true, meaning: "the task" }],
|
|
298
302
|
flags: [jsonFlag, dbFlag, keyFlag,
|
|
299
303
|
{ name: "provider", takesValue: true, meaning: "pin which provider plans THIS task (plan phase only)" },
|
|
300
|
-
{ name: "model", takesValue: true, meaning: "the pinned plan model, riding --provider" }
|
|
304
|
+
{ name: "model", takesValue: true, meaning: "the pinned plan model, riding --provider" },
|
|
305
|
+
// Local-owner slice 2: the terminal UI's plan request, for the laptop's local owner only (no password, no pins).
|
|
306
|
+
{ name: "digest", takesValue: true, meaning: "the terminal's local owner: the exact planning source read; required there, and --provider/--model are refused" }],
|
|
301
307
|
notableReasons: ["unknown-task"] },
|
|
302
308
|
{ invocation: "task hold", synopsis: "park a task with a reason", audience: "agent", agentMayInvoke: true, mutation: "keyed",
|
|
303
309
|
positionals: [{ name: "id", required: true, meaning: "the task" }],
|
|
@@ -592,6 +598,10 @@ const LOCAL_ALLOW = {
|
|
|
592
598
|
"task continue": "resume-run",
|
|
593
599
|
"task unhold": "resume-run",
|
|
594
600
|
"task complete": "complete-task",
|
|
601
|
+
// Local-owner slice 2: its own road (operate.ts localPlanCommand); the writer checks this exact act.
|
|
602
|
+
"task plan": "request-plan",
|
|
603
|
+
// Local-owner slice 3: its own road (operate.ts localRetryCommand); the writer checks this exact act.
|
|
604
|
+
"task requeue": "retry-build",
|
|
595
605
|
"task revise": "request-revision",
|
|
596
606
|
"task archive": "archive-task",
|
|
597
607
|
"task restore": "archive-task",
|
|
@@ -28,3 +28,21 @@ export declare function runTaskMergeCommand(positional: readonly string[], flags
|
|
|
28
28
|
}): Promise<number>;
|
|
29
29
|
/** The lines `task show` prints for a task's pull request. */
|
|
30
30
|
export declare function pullRequestLines(view: PullRequestView | null): string[];
|
|
31
|
+
/** Toolroll's own stop proof for every finished build of a task's family whose stop it can prove here. Best effort: a
|
|
32
|
+
* failure leaves the completion's own refusal to say what is still unproven. */
|
|
33
|
+
export declare function proveTaskBuildsStopped(store: Store, task: string, clock: () => Date): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* `task complete` for the laptop's local owner (local-owner slice 1; the terminal UI's Accept): the exact result the
|
|
36
|
+
* screen read, accepted and completed with no password. Its own road: it never reaches a password check (so it never
|
|
37
|
+
* moves the sign-in guard), and is chosen only inside the local owner's chain; there, an owner who no longer stands is
|
|
38
|
+
* refused, never handed to another road. Everything is pinned: `--run` and `--digest` are required (never defaulted
|
|
39
|
+
* to the current result), `--checked` is the person's explicit yes to a check they made, and `--reason` (trimmed, not
|
|
40
|
+
* empty) is an exception's why. No pull request: that needs a password.
|
|
41
|
+
*/
|
|
42
|
+
export declare function runLocalCompletion(positional: readonly string[], flags: Map<string, string | true>, context: {
|
|
43
|
+
store: Store;
|
|
44
|
+
evidenceRoot: string;
|
|
45
|
+
json: boolean;
|
|
46
|
+
write: (line: string) => void;
|
|
47
|
+
now: Date;
|
|
48
|
+
}): Promise<number>;
|
package/dist/task-outcome-cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Everyday task verbs over the existing completion and revision boundaries. */
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
|
|
3
|
+
import { assignmentOf, checkAssignmentAsOperator, completeAsLocalOwner } from './assignment.js';
|
|
4
|
+
import { currentLocalOwner } from './local-owner.js';
|
|
5
|
+
import { validateNote } from './decision.js';
|
|
4
6
|
import { assignmentForCoordinator, coordinatorToken } from './assignment-adapters.js';
|
|
5
7
|
import { authenticateCoordinator } from './coordinator.js';
|
|
6
8
|
import { reproveApprover } from './principal.js';
|
|
@@ -179,7 +181,7 @@ export function pullRequestLines(view) {
|
|
|
179
181
|
}
|
|
180
182
|
/** Toolroll's own stop proof for every finished build of a task's family whose stop it can prove here. Best effort: a
|
|
181
183
|
* failure leaves the completion's own refusal to say what is still unproven. */
|
|
182
|
-
async function proveTaskBuildsStopped(store, task, clock) {
|
|
184
|
+
export async function proveTaskBuildsStopped(store, task, clock) {
|
|
183
185
|
try {
|
|
184
186
|
const family = store.taskFamilyOf(task, null, true);
|
|
185
187
|
const refs = family === null ? [store.lookupRef(task)?.id].filter((one) => one !== undefined) : family.versions.map(version => version.refId);
|
|
@@ -191,3 +193,59 @@ async function proveTaskBuildsStopped(store, task, clock) {
|
|
|
191
193
|
}
|
|
192
194
|
catch { /* the completion below refuses with what is still unproven */ }
|
|
193
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* `task complete` for the laptop's local owner (local-owner slice 1; the terminal UI's Accept): the exact result the
|
|
198
|
+
* screen read, accepted and completed with no password. Its own road: it never reaches a password check (so it never
|
|
199
|
+
* moves the sign-in guard), and is chosen only inside the local owner's chain; there, an owner who no longer stands is
|
|
200
|
+
* refused, never handed to another road. Everything is pinned: `--run` and `--digest` are required (never defaulted
|
|
201
|
+
* to the current result), `--checked` is the person's explicit yes to a check they made, and `--reason` (trimmed, not
|
|
202
|
+
* empty) is an exception's why. No pull request: that needs a password.
|
|
203
|
+
*/
|
|
204
|
+
export async function runLocalCompletion(positional, flags, context) {
|
|
205
|
+
const command = 'task complete';
|
|
206
|
+
const emit = (result, lines) => {
|
|
207
|
+
context.write(context.json ? envelopeJson({ ...result, command }) : result.ok ? (lines ?? []).join('\n') : result.message ?? 'The action was refused.');
|
|
208
|
+
return result.ok ? 0 : result.reason === 'usage' ? 2 : 3;
|
|
209
|
+
};
|
|
210
|
+
const fail = (reason, message) => emit({ ok: false, reason, message });
|
|
211
|
+
const usage = 'Use task complete <task> --run <build> --digest <result> [--checked] [--reason "why"].';
|
|
212
|
+
const allowed = new Set(['json', 'db', 'run', 'digest', 'checked', 'reason', 'pull-request']);
|
|
213
|
+
for (const key of flags.keys())
|
|
214
|
+
if (!allowed.has(key))
|
|
215
|
+
return fail('usage', `--${key} is not a ${command} option.`);
|
|
216
|
+
if (flags.has('pull-request'))
|
|
217
|
+
return fail('local-owner', `Opening a pull request needs your password. ${context.store.accountOf(currentLocalOwner()?.account ?? '')?.passwordSet === true ? 'Sign in with your password to do this.' : 'Set a password first: run `toolroll password set`.'}`);
|
|
218
|
+
const task = positional[0];
|
|
219
|
+
if (positional.length !== 1 || !task || task.length > 64 || /[\x00-\x1f]/.test(task))
|
|
220
|
+
return fail('usage', usage);
|
|
221
|
+
const run = flags.get('run'), digest = flags.get('digest'), checked = flags.get('checked'), reason = flags.get('reason');
|
|
222
|
+
if (typeof run !== 'string' || !/^[1-9]\d{0,14}$/.test(run))
|
|
223
|
+
return fail('usage', `--run needs the exact build number of the result you read. ${usage}`);
|
|
224
|
+
if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest))
|
|
225
|
+
return fail('usage', `--digest needs the exact result you read. ${usage}`);
|
|
226
|
+
if (checked !== undefined && checked !== true)
|
|
227
|
+
return fail('usage', '--checked takes no value.');
|
|
228
|
+
let why = null;
|
|
229
|
+
if (reason !== undefined) {
|
|
230
|
+
// An exception's reason says something: trimmed here, at the command's edge, and never empty or only spaces.
|
|
231
|
+
if (typeof reason !== 'string' || reason.trim() === '')
|
|
232
|
+
return fail('usage', 'Say why you accept it anyway: --reason "<why>".');
|
|
233
|
+
const validated = validateNote(reason);
|
|
234
|
+
if (!validated.ok)
|
|
235
|
+
return fail('usage', `--reason: ${validated.problem}.`);
|
|
236
|
+
why = validated.note;
|
|
237
|
+
}
|
|
238
|
+
const { store, now, evidenceRoot } = context;
|
|
239
|
+
// Toolroll's own stop proof first, as every completion (a build it hasn't proven stopped is refused below).
|
|
240
|
+
await proveTaskBuildsStopped(store, task, () => now);
|
|
241
|
+
const done = completeAsLocalOwner(store, task, { runId: Number(run), receiptDigest: digest, checked: checked === true, reason: why }, now, evidenceRoot);
|
|
242
|
+
if (!done.ok)
|
|
243
|
+
return emit(done);
|
|
244
|
+
const { assignment } = done;
|
|
245
|
+
const receipt = assignment.receipt;
|
|
246
|
+
return emit({ ok: true, replayed: done.replayed, accepted: done.accepted, result: assignment }, [
|
|
247
|
+
`${assignment.rootId} · ${done.replayed ? 'Already accepted' : 'Accepted'}`,
|
|
248
|
+
`Result: ${receipt.taskId} · build #${receipt.runId} · ${receipt.head?.slice(0, 7) ?? 'no commit'}`,
|
|
249
|
+
...(assignment.readiness?.checksOff ? [] : [receipt.checks.detail]),
|
|
250
|
+
]);
|
|
251
|
+
}
|
package/dist/tui-contract.d.ts
CHANGED
|
@@ -46,6 +46,16 @@ export type TuiTask = {
|
|
|
46
46
|
readonly decisions: readonly TuiDecision[];
|
|
47
47
|
/** "Can I merge?" (merge-checklist.ts, `toolroll task ready`'s lines): the answer, then each check. Empty: no result. */
|
|
48
48
|
readonly ready?: readonly string[];
|
|
49
|
+
/** `p` (local-owner slice 2): the planning source read now, which the request binds to; null when no plan can be asked. */
|
|
50
|
+
readonly planAsk?: {
|
|
51
|
+
readonly source: string;
|
|
52
|
+
} | null;
|
|
53
|
+
/** `r` (local-owner slice 3): the latest finished build and the approved plan read now, which a retry binds to; null
|
|
54
|
+
* when there is nothing to retry. */
|
|
55
|
+
readonly retry?: {
|
|
56
|
+
readonly runId: number;
|
|
57
|
+
readonly digest: string;
|
|
58
|
+
} | null;
|
|
49
59
|
};
|
|
50
60
|
/** The welcome's steps, as lines (welcome-facts.ts). */
|
|
51
61
|
export type TuiSetup = {
|
|
@@ -85,12 +95,29 @@ export type TuiAct = {
|
|
|
85
95
|
readonly kind: "revise";
|
|
86
96
|
readonly id: string;
|
|
87
97
|
readonly text: string;
|
|
88
|
-
}
|
|
98
|
+
}
|
|
99
|
+
/** Accept: exactly the result the Accept screen read (its build and receipt, pinned when it opened), with the person's
|
|
100
|
+
* explicit yes to their own check, or an exception's reason, when the result owes one. */
|
|
101
|
+
| {
|
|
89
102
|
readonly kind: "complete";
|
|
90
103
|
readonly id: string;
|
|
91
|
-
|
|
104
|
+
readonly runId: number;
|
|
105
|
+
readonly digest: string;
|
|
106
|
+
readonly checked: boolean;
|
|
107
|
+
readonly reason: string | null;
|
|
108
|
+
}
|
|
109
|
+
/** Retry: exactly the build and approved plan the screen read (the command refuses a task that changed meanwhile). */
|
|
110
|
+
| {
|
|
92
111
|
readonly kind: "retry";
|
|
93
112
|
readonly id: string;
|
|
113
|
+
readonly runId: number;
|
|
114
|
+
readonly digest: string;
|
|
115
|
+
}
|
|
116
|
+
/** Plan it: exactly the planning source the screen read (the command refuses a task that changed meanwhile). */
|
|
117
|
+
| {
|
|
118
|
+
readonly kind: "plan";
|
|
119
|
+
readonly id: string;
|
|
120
|
+
readonly source: string;
|
|
94
121
|
} | {
|
|
95
122
|
readonly kind: "answer";
|
|
96
123
|
readonly decision: number;
|
|
@@ -105,7 +132,8 @@ export type TuiFastTerms = {
|
|
|
105
132
|
readonly words: string;
|
|
106
133
|
}[];
|
|
107
134
|
};
|
|
108
|
-
/** A write's answer: its words. `needsPassword`: refused because the local owner may not do it.
|
|
135
|
+
/** A write's answer: its words. `needsPassword`: refused because the local owner may not do it. `changed`: refused
|
|
136
|
+
* because what it named (a result) changed since the screen read it. */
|
|
109
137
|
export type TuiActResult = {
|
|
110
138
|
readonly ok: true;
|
|
111
139
|
readonly message: string;
|
|
@@ -114,6 +142,34 @@ export type TuiActResult = {
|
|
|
114
142
|
readonly ok: false;
|
|
115
143
|
readonly message: string;
|
|
116
144
|
readonly needsPassword: boolean;
|
|
145
|
+
readonly changed?: boolean;
|
|
146
|
+
};
|
|
147
|
+
/** A finished result as the Accept screen reads it once and pins: which build and receipt, and what it shows. */
|
|
148
|
+
export type TuiResult = {
|
|
149
|
+
/** The task Accept names, and the exact result: what the answer submits, never read again before it is sent. */
|
|
150
|
+
readonly id: string;
|
|
151
|
+
readonly runId: number;
|
|
152
|
+
readonly digest: string;
|
|
153
|
+
readonly title: string;
|
|
154
|
+
/** Ready to accept now; when not, why not (a failed check, a build not yet proven stopped), and no Accept is offered. */
|
|
155
|
+
readonly ready: boolean;
|
|
156
|
+
readonly blockers: readonly string[];
|
|
157
|
+
/** The agent's own summary of what it did (empty when it left none). */
|
|
158
|
+
readonly summary: readonly string[];
|
|
159
|
+
/** The files the result changed (the first ten), how many more, or null when they can't be read. */
|
|
160
|
+
readonly files: {
|
|
161
|
+
readonly names: readonly string[];
|
|
162
|
+
readonly more: number;
|
|
163
|
+
} | null;
|
|
164
|
+
/** Each requirement and how it is shown ("Met", "You check", "Not shown yet"…). */
|
|
165
|
+
readonly requirements: readonly {
|
|
166
|
+
readonly word: string;
|
|
167
|
+
readonly statement: string;
|
|
168
|
+
}[];
|
|
169
|
+
/** "Can I merge?": the actual check status, the same lines as `toolroll task ready`. */
|
|
170
|
+
readonly checks: readonly string[];
|
|
171
|
+
/** What accepting it needs from the person: their own check, a reason, or nothing more. */
|
|
172
|
+
readonly owed: "person-check" | "exception" | null;
|
|
117
173
|
};
|
|
118
174
|
/** The plane, as the screen sees it. */
|
|
119
175
|
export interface TuiSource {
|
|
@@ -124,6 +180,8 @@ export interface TuiSource {
|
|
|
124
180
|
/** A counter that moves when the plane's work changes (the store's wake sequence). */
|
|
125
181
|
changed(): number;
|
|
126
182
|
act(act: TuiAct): Promise<TuiActResult>;
|
|
183
|
+
/** The task's current finished result, read once for the Accept screen; null when it has none. */
|
|
184
|
+
result(id: string): Promise<TuiResult | null>;
|
|
127
185
|
/** A small task's exact terms, or null when it takes the ordinary road (New task then files it). */
|
|
128
186
|
fastTerms(text: string, repo: string | null): Promise<TuiFastTerms | null>;
|
|
129
187
|
/** The welcome's steps, or null once setup is done. */
|
package/dist/tui-source.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* nothing ever prompts on the terminal the screen owns.
|
|
9
9
|
*/
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
|
-
import { assignmentOf } from "./assignment.js";
|
|
11
|
+
import { assignmentOf, owedAcceptance } from "./assignment.js";
|
|
12
|
+
import { familyChangedFiles } from "./result-completion.js";
|
|
13
|
+
import { checkBackingOf, receiptChecksOf, requirementWordOf } from "./task-status.js";
|
|
12
14
|
import { checklistLines, mergeChecklistOf } from "./merge-checklist.js";
|
|
13
15
|
import { runAsLocalOwner } from "./local-owner.js";
|
|
14
16
|
import { snapshotLiveRuns } from "./peek-cli.js";
|
|
@@ -21,14 +23,18 @@ import { memoryWaiting } from "./lesson-notes.js";
|
|
|
21
23
|
import { homeGroupOf } from "./home-groups.js";
|
|
22
24
|
import { terminalText } from "./terminal-text.js";
|
|
23
25
|
import { welcomeHere, welcomeLines } from "./welcome-facts.js";
|
|
26
|
+
import { planSourceOf, retryPinsOf } from "./local-owner-acts.js";
|
|
24
27
|
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
25
28
|
const words = (value, fallback = "") => typeof value === "string" ? value : fallback;
|
|
26
29
|
/** Text for the screen: whatever wrote it (an agent, a person, a project's name), escapes and controls never reach the
|
|
27
30
|
* terminal (terminal-text.ts). Every displayed string below goes through here; ids and digests are only acted on. */
|
|
28
31
|
const shown = (value, fallback = "") => terminalText(words(value, fallback));
|
|
29
|
-
/** What a refused write says, and whether it is a local-owner refusal (the screen then leads to a password)
|
|
32
|
+
/** What a refused write says, and whether it is a local-owner refusal (the screen then leads to a password), or a
|
|
33
|
+
* result that changed since the screen read it. */
|
|
30
34
|
function refusalOf(envelope) {
|
|
31
35
|
const message = shown(envelope.message, "That was refused.");
|
|
36
|
+
if (envelope.reason === "stale")
|
|
37
|
+
return { ok: false, message, needsPassword: false, changed: true };
|
|
32
38
|
return { ok: false, message, needsPassword: envelope.reason === "refused" || envelope.reason === "machine-proof" || envelope.reason === "person-required" || /password/i.test(message) };
|
|
33
39
|
}
|
|
34
40
|
const routeLines = (store, id) => { const words = routeChangeOf(store, id); return words === null ? [] : [words]; };
|
|
@@ -116,6 +122,8 @@ export function createTuiSource(options) {
|
|
|
116
122
|
return {
|
|
117
123
|
id, title: shown(task["title"], id), status: shown(status["label"], words(work["state"])), detail: shown(status["detail"]),
|
|
118
124
|
plan, liveRunId: typeof work["liveRunId"] === "number" ? work["liveRunId"] : null, hasResult, decisions, ready,
|
|
125
|
+
planAsk: store === null ? null : planAskOf(store, id),
|
|
126
|
+
retry: (() => { const pins = store === null ? null : retryPinsOf(store, id); return pins === null ? null : { runId: pins.run, digest: pins.digest }; })(),
|
|
119
127
|
};
|
|
120
128
|
},
|
|
121
129
|
live(id) {
|
|
@@ -141,9 +149,14 @@ export function createTuiSource(options) {
|
|
|
141
149
|
let argv;
|
|
142
150
|
let done;
|
|
143
151
|
switch (act.kind) {
|
|
152
|
+
// A task too big to build straight away: filed, then its plan asked for at once (local-owner slice 2), below.
|
|
144
153
|
case "new":
|
|
145
154
|
argv = ["task", "add", act.text, ...(act.repo === null ? [] : ["--repo", act.repo])];
|
|
146
|
-
done = "Filed.
|
|
155
|
+
done = "Filed.";
|
|
156
|
+
break;
|
|
157
|
+
case "plan":
|
|
158
|
+
argv = ["task", "plan", act.id, "--digest", act.source];
|
|
159
|
+
done = "Asked for a plan. You'll approve it before anything is built.";
|
|
147
160
|
break;
|
|
148
161
|
case "build":
|
|
149
162
|
argv = ["task", "new", "--build", act.preview, "--digest", act.digest];
|
|
@@ -165,33 +178,49 @@ export function createTuiSource(options) {
|
|
|
165
178
|
argv = ["decide", String(act.decision), "--choose", act.option];
|
|
166
179
|
done = "Answered.";
|
|
167
180
|
break;
|
|
168
|
-
// Retry
|
|
181
|
+
// Retry: exactly the build and approved plan the screen read (local-owner slice 3), never the current ones.
|
|
169
182
|
case "retry":
|
|
170
|
-
argv = ["task", "
|
|
171
|
-
done = "
|
|
183
|
+
argv = ["task", "requeue", act.id, "--run", String(act.runId), "--digest", act.digest];
|
|
184
|
+
done = "Retrying. A builder starts it next.";
|
|
172
185
|
break;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
digest = undefined;
|
|
181
|
-
}
|
|
182
|
-
if (digest === undefined)
|
|
183
|
-
return { ok: false, message: "This task has no finished result to accept.", needsPassword: false };
|
|
184
|
-
argv = ["task", "complete", act.id, "--digest", digest];
|
|
185
|
-
done = "Accepted.";
|
|
186
|
+
// Accept: exactly the result the Accept screen read, never the one current when this is sent (the command
|
|
187
|
+
// refuses a result that changed meanwhile). The person's yes to their own check, or a reason, only when given.
|
|
188
|
+
case "complete":
|
|
189
|
+
argv = ["task", "complete", act.id, "--run", String(act.runId), "--digest", act.digest, ...(act.checked ? ["--checked"] : []), ...(act.reason === null ? [] : ["--reason", act.reason])];
|
|
190
|
+
done = "Accepted. The task is done.";
|
|
186
191
|
break;
|
|
187
|
-
}
|
|
188
192
|
}
|
|
189
193
|
const answer = await command(argv);
|
|
190
194
|
if (answer.ok !== true)
|
|
191
195
|
return refusalOf(answer);
|
|
196
|
+
if (act.kind === "complete" && answer["replayed"] === true)
|
|
197
|
+
return { ok: true, message: "Already accepted. The task is done." };
|
|
198
|
+
if (act.kind === "plan" && answer["replayed"] === true)
|
|
199
|
+
return { ok: true, message: "Already asked for a plan." };
|
|
200
|
+
if (act.kind === "retry" && answer["replayed"] === true)
|
|
201
|
+
return { ok: true, message: "Already retrying. A builder starts it next." };
|
|
192
202
|
const filed = act.kind === "new" ? words(record(answer["task"])["id"], words(answer["id"])) : act.kind === "build" ? words(answer["task"]) : "";
|
|
203
|
+
// Filed in a project: ask for its plan now, bound to the task just filed (the screen promised a plan).
|
|
204
|
+
if (act.kind === "new" && filed !== "" && act.repo !== null) {
|
|
205
|
+
const store = read();
|
|
206
|
+
const ask = store === null ? null : planAskOf(store, filed);
|
|
207
|
+
const asked = ask === null ? null : await command(["task", "plan", filed, "--digest", ask.source]);
|
|
208
|
+
const message = asked?.ok === true ? "Filed. Planning: you'll approve the plan before anything is built." : `Filed. It wasn't sent for a plan: ${shown(asked?.message, "it can't be planned now.")}`;
|
|
209
|
+
return { ok: true, message, id: filed };
|
|
210
|
+
}
|
|
193
211
|
return { ok: true, message: done, ...(filed === "" ? {} : { id: filed }) };
|
|
194
212
|
},
|
|
213
|
+
async result(id) {
|
|
214
|
+
const store = read();
|
|
215
|
+
if (store === null)
|
|
216
|
+
return null;
|
|
217
|
+
try {
|
|
218
|
+
return resultOf(store, id, clock(), evidenceRoot);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
195
224
|
async fastTerms(text, repo) {
|
|
196
225
|
if (repo === null)
|
|
197
226
|
return null;
|
|
@@ -242,3 +271,45 @@ function learningLine(store, repos, now) {
|
|
|
242
271
|
const parts = [flaky === 0 ? "" : `${flaky} flaky test${flaky === 1 ? "" : "s"}: open the project's Flaky tests in the browser`, memory === 0 ? "" : `${memory} project memory suggestion${memory === 1 ? "" : "s"}: open Settings → Knowledge`].filter(Boolean);
|
|
243
272
|
return parts.length === 0 ? null : parts.join(" · ");
|
|
244
273
|
}
|
|
274
|
+
/** The Accept screen's one read of a task's current result (local-owner slice 1): which build and receipt it pins, and
|
|
275
|
+
* what the result page would show about it — the agent's summary, the files changed, each requirement and how it is
|
|
276
|
+
* shown, the actual check status, and what accepting it needs from the person. Text from outside is cleaned here. */
|
|
277
|
+
function resultOf(store, id, now, evidenceRoot) {
|
|
278
|
+
const assignment = assignmentOf(store, id, now, { principal: "operator", repos: null }, evidenceRoot);
|
|
279
|
+
const receipt = assignment?.receipt ?? null;
|
|
280
|
+
if (assignment === null || receipt === null)
|
|
281
|
+
return null;
|
|
282
|
+
const blockers = assignment.readiness?.blockers ?? [];
|
|
283
|
+
const owed = assignment.state === "ready-to-check" ? owedAcceptance(receipt, blockers) : null;
|
|
284
|
+
// An exception's own blocker is what its reason answers; any other one stands in the way of accepting.
|
|
285
|
+
const standing = blockers.filter(one => !(owed === "exception" && one.key === "criteria")).map(one => terminalText(one.message));
|
|
286
|
+
const notNow = assignment.state === "complete" ? "This result is already accepted." : assignment.state === "ready-to-check" ? null : terminalText(assignment.detail || "This result isn't ready to accept.");
|
|
287
|
+
const changed = familyChangedFiles(store, receipt.taskId, receipt.runId, evidenceRoot);
|
|
288
|
+
const backing = checkBackingOf(receiptChecksOf(receipt));
|
|
289
|
+
let checks = [];
|
|
290
|
+
try {
|
|
291
|
+
const list = mergeChecklistOf(store, id, now, { principal: "operator", repos: null }, evidenceRoot);
|
|
292
|
+
checks = list === null ? [] : checklistLines(list).map(line => terminalText(line));
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
checks = [];
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
id, runId: receipt.runId, digest: receipt.digest, title: terminalText(assignment.title),
|
|
299
|
+
ready: notNow === null && standing.length === 0, blockers: notNow === null ? standing : [notNow],
|
|
300
|
+
summary: (receipt.agentReport ?? "").split("\n").map(line => terminalText(line).trimEnd()).filter(line => line.trim() !== "").slice(0, 8),
|
|
301
|
+
files: changed === null ? null : { names: changed.slice(0, 10).map(name => terminalText(name)), more: Math.max(0, changed.length - 10) },
|
|
302
|
+
requirements: (receipt.proof?.matrix ?? []).map(row => ({ word: requirementWordOf(row, receipt.proof?.verdict ?? null, backing), statement: terminalText(row.statement ?? row.id ?? "") })),
|
|
303
|
+
checks, owed,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/** Whether a plan can be asked for this task now, and the planning source a request binds to (local-owner slice 2): a
|
|
307
|
+
* queued task with no plan asked or drafted and no approved plan. What the screen offers only; the command decides. */
|
|
308
|
+
function planAskOf(store, id) {
|
|
309
|
+
const ref = store.lookupRef(id);
|
|
310
|
+
const scope = store.getScope(id);
|
|
311
|
+
if (ref === null || ref.plan !== null || store.getTask(id)?.state !== "queued" || (scope !== null && scope.approvedAt !== null && scope.approvedDigest === scope.digest))
|
|
312
|
+
return null;
|
|
313
|
+
const source = planSourceOf(store, id);
|
|
314
|
+
return source === null ? null : { source };
|
|
315
|
+
}
|