toolroll 0.9.3 → 0.9.4
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 +9 -0
- package/dist/flow-engine.js +22 -11
- package/dist/flow-steps.js +12 -2
- package/dist/guides.js +17 -0
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +15 -4
- package/dist/pull-request-flow.d.ts +2 -0
- package/dist/pull-request-flow.js +14 -0
- package/dist/serve.js +4 -12
- package/dist/store.d.ts +1 -0
- package/dist/store.js +7 -4
- package/dist/surface.js +7 -1
- package/dist/task-outcome-cli.d.ts +18 -0
- package/dist/task-outcome-cli.js +75 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.9.4 — 2026-10-01
|
|
6
|
+
|
|
7
|
+
- **Flow steps act exactly once.** However many workers advance a
|
|
8
|
+
project's flows at the same time, a card files its task, runs its script
|
|
9
|
+
and posts its update once; a retried card takes the same guarded path.
|
|
10
|
+
- **Pull requests from the terminal.** `toolroll task complete <id>
|
|
11
|
+
--pull-request` completes a result and opens its pull request, and
|
|
12
|
+
`toolroll task merge <id>` merges it once checks pass, behind your password.
|
|
13
|
+
|
|
5
14
|
## 0.9.3 — 2026-10-01
|
|
6
15
|
|
|
7
16
|
- **Flows never get stuck on a long step output.** A research or build step's
|
package/dist/flow-engine.js
CHANGED
|
@@ -241,21 +241,32 @@ function workStage(store, flow, stage, card, now, options, outcome, onward) {
|
|
|
241
241
|
wait(`${stage.title}'s instructions are too long for a task. Open the flow and shorten them.`);
|
|
242
242
|
return;
|
|
243
243
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
244
|
+
// Filed exactly once however many passes advance this project: the card is claimed (still this entry, in this zone,
|
|
245
|
+
// with no task) in the same transaction that files its task, so another pass finds it claimed and leaves it.
|
|
246
|
+
const filed = store.transact(() => {
|
|
247
|
+
const fresh = store.getFlowCard(card.id);
|
|
248
|
+
if (fresh === null || fresh.state !== "active" || fresh.entry !== card.entry || fresh.stage !== stage.id || fresh.task !== null)
|
|
249
|
+
return null;
|
|
250
|
+
const made = fileTaskProposal(store, {
|
|
251
|
+
title: (report ? `${stage.title}: ${card.title}` : card.title).slice(0, 200),
|
|
252
|
+
repo: flow.repo,
|
|
253
|
+
goal,
|
|
254
|
+
filedVia: `flow:${flow.id}`, filedBy: filerFor(card.createdBy), // who made the card asked for the work (an owner can be reassigned by anyone)
|
|
255
|
+
deliverable: report ? "report" : "branch",
|
|
256
|
+
planning: report ? "skip" : stage.planning ?? "auto",
|
|
257
|
+
acceptance: report ? ACCEPTANCE.report : ACCEPTANCE.task,
|
|
258
|
+
admittedRepos: [flow.repo],
|
|
259
|
+
}, now);
|
|
260
|
+
if (made.ok)
|
|
261
|
+
store.updateFlowCard(card.id, { task: made.id, ...(report || fresh.primaryTask !== null ? {} : { primaryTask: made.id }), waiting: "Filed as a task" }, now);
|
|
262
|
+
return made;
|
|
263
|
+
});
|
|
264
|
+
if (filed === null)
|
|
265
|
+
return;
|
|
254
266
|
if (!filed.ok) {
|
|
255
267
|
wait(/[.!?]$/.test(filed.message) ? filed.message : `${filed.message}.`);
|
|
256
268
|
return;
|
|
257
269
|
}
|
|
258
|
-
store.updateFlowCard(card.id, { task: filed.id, ...(report || card.primaryTask !== null ? {} : { primaryTask: filed.id }), waiting: "Filed as a task" }, now);
|
|
259
270
|
outcome.filed.push(filed.id);
|
|
260
271
|
return;
|
|
261
272
|
}
|
package/dist/flow-steps.js
CHANGED
|
@@ -137,7 +137,7 @@ export async function runFlowSteps(store, repo, now, io) {
|
|
|
137
137
|
store.updateFlowCard(card.id, { waiting }, now);
|
|
138
138
|
continue;
|
|
139
139
|
}
|
|
140
|
-
if (!store
|
|
140
|
+
if (!claimStep(store, card, { stage: stage.id, kind: stage.kind, script: script?.name ?? null, scriptVersion: script?.version ?? null }, now))
|
|
141
141
|
continue;
|
|
142
142
|
pass.ran++;
|
|
143
143
|
store.updateFlowCard(card.id, { waiting: { check: "Running its check…", sort: "Sorting…", draft: "Writing the draft…", request: "Calling the address…", email: "Sending the email…", tool: "Using the tool…" }[stage.kind] ?? "Updating the issue…" }, now);
|
|
@@ -164,6 +164,16 @@ export async function runFlowSteps(store, repo, now, io) {
|
|
|
164
164
|
}
|
|
165
165
|
return pass;
|
|
166
166
|
}
|
|
167
|
+
/** Claim this card's visit to its step zone, only while the card is still there on that visit: a pass that read the card
|
|
168
|
+
* before another pass (or a person) moved it never runs the step for a visit that's over. */
|
|
169
|
+
function claimStep(store, card, step, now) {
|
|
170
|
+
return store.transact(() => {
|
|
171
|
+
const fresh = store.getFlowCard(card.id);
|
|
172
|
+
if (fresh === null || fresh.state !== "active" || fresh.entry !== card.entry || fresh.stage !== step.stage)
|
|
173
|
+
return false;
|
|
174
|
+
return store.claimFlowStep({ card: card.id, entry: card.entry, ...step }, now);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
167
177
|
/** One teammate turn, when one is due for this card: claimed like any step, retried on failure, and waiting while its question is open. */
|
|
168
178
|
async function teammateStep(store, flow, definition, stage, card, now, io, pass) {
|
|
169
179
|
// v102: on a protected project an AI teammate never decides an approval; the engine asks the person.
|
|
@@ -183,7 +193,7 @@ async function teammateStep(store, flow, definition, stage, card, now, io, pass)
|
|
|
183
193
|
// Waiting on a person: its own question, or (v94) a tool call they approve first.
|
|
184
194
|
if (store.openTeammateQuestionOn(card.id, card.entry) !== null)
|
|
185
195
|
return true;
|
|
186
|
-
if (!store
|
|
196
|
+
if (!claimStep(store, card, { stage: stage.id, kind: "teammate", script: null, scriptVersion: null }, now))
|
|
187
197
|
return true;
|
|
188
198
|
pass.ran++;
|
|
189
199
|
const started = Date.now();
|
package/dist/guides.js
CHANGED
|
@@ -79,6 +79,23 @@ ${AUTHORITY_LINE}
|
|
|
79
79
|
detail says why; \`toolroll sync\` refreshes trackers.
|
|
80
80
|
- \`contest-open\`: a tournament is running on the task; a person picks.
|
|
81
81
|
|
|
82
|
+
## Complete, pull request, merge
|
|
83
|
+
|
|
84
|
+
The console's Complete and Merge, from a terminal. Both are a person's
|
|
85
|
+
acts behind their sign-in (\`--as <you> --token <t>\`, or the remembered
|
|
86
|
+
login); run them only when the person asked for exactly that.
|
|
87
|
+
|
|
88
|
+
- \`task complete <id> --digest <receipt> --pull-request\` marks the exact
|
|
89
|
+
inspected result complete and opens its pull request from that commit
|
|
90
|
+
under the project's publishing setup. \`pull-requests-off\` names the
|
|
91
|
+
setup command (\`publish setup --repo <path> --yes\`).
|
|
92
|
+
- CI is watched on GitHub. \`task show <id> --json\` carries
|
|
93
|
+
\`pullRequest\`: \`prUrl\`, \`state\` (opening, waiting, running, ready,
|
|
94
|
+
failing, merged, closed, failed) and \`mergeCommit\`.
|
|
95
|
+
- \`task merge <id>\` merges it once checks pass, by the project's method
|
|
96
|
+
(squash by default), deletes the branch and records who merged.
|
|
97
|
+
\`checks\` means they are running or failing; the message says which.
|
|
98
|
+
|
|
82
99
|
## Flows
|
|
83
100
|
|
|
84
101
|
A flow is a process cards move through, drawn as zones. The terminal
|
package/dist/operate.d.ts
CHANGED
|
@@ -91,7 +91,7 @@ type Args = {
|
|
|
91
91
|
* exports are behavior, not commentary — and the declared command guide
|
|
92
92
|
* (surface.ts) is tested for exact equality against them.
|
|
93
93
|
*/
|
|
94
|
-
export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise"];
|
|
94
|
+
export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge"];
|
|
95
95
|
export declare const PUBLISH_ACTIONS: readonly ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
|
|
96
96
|
export declare const CONFIG_ACTIONS: readonly ["show", "set", "clear"];
|
|
97
97
|
export declare const APPROVER_ACTIONS: readonly ["list", "add"];
|
package/dist/operate.js
CHANGED
|
@@ -40,7 +40,7 @@ import { runKnowledgeCommand } from "./knowledge-cli.js";
|
|
|
40
40
|
import { FLOWS_VALUE_FLAGS, runFlowsCommand } from "./flows-cli.js";
|
|
41
41
|
import { runAssignmentCommand } from "./assignment-adapters.js";
|
|
42
42
|
import { applyProjectProfile, runProjectCommand } from "./project-cli.js";
|
|
43
|
-
import { runTaskOutcomeCommand } from "./task-outcome-cli.js";
|
|
43
|
+
import { pullRequestLines, runTaskMergeCommand, runTaskOutcomeCommand } from "./task-outcome-cli.js";
|
|
44
44
|
/**
|
|
45
45
|
* The commands that actually move work: authoring tasks, and the claim loop.
|
|
46
46
|
*
|
|
@@ -138,7 +138,7 @@ import { verifyApproverByPassword } from "./principal.js";
|
|
|
138
138
|
import { authorizedProject, canonicalProject, projectName, resolveCeiling } from "./project.js";
|
|
139
139
|
import { tally, spendLine } from "./summary.js";
|
|
140
140
|
import { bodyHashOf, describePublicationGrant, observeChecks, publicationBody, publishPass, sweepMerges, } from "./publish.js";
|
|
141
|
-
import { checkPublishing, publishingOf, savePublishing, followPullRequests } from "./pull-request-flow.js";
|
|
141
|
+
import { checkPublishing, publishingOf, savePublishing, followPullRequests, newestPullRequestOf } from "./pull-request-flow.js";
|
|
142
142
|
import { starterOf, startersFor, switchOnStarter } from "./flow-starters.js";
|
|
143
143
|
import { acquire, acquireFallback, acquireIfReady, completeFenced, finalizeFailureFenced, finalizeMalformedFenced, finalizeParkFenced, finalizePlanFenced, finalizePlanFailureFenced, finalizeScoutFenced, finalizeScoutFailureFenced, heartbeat, release, reap, currentClaim, DEFAULT_LEASE_MS, SYNC_MAX_AGE_MS, acquireContinuation, } from "./claim.js";
|
|
144
144
|
import { disposeBuildOutcome, holdStaleApproval, regateTask } from "./dispose.js";
|
|
@@ -235,7 +235,10 @@ export const OPERATE_HELP = `toolroll — operating the queue
|
|
|
235
235
|
toolroll ledger checkpoint record the chain's head to copy off this machine (instance operator)
|
|
236
236
|
toolroll ledger export --from <YYYY-MM-DD> --to <YYYY-MM-DD> [--out <file>]
|
|
237
237
|
every sealed entry in the range, with an evidence pack per task
|
|
238
|
-
toolroll task complete <id> mark the current result complete (--digest for JSON/agents)
|
|
238
|
+
toolroll task complete <id> mark the current result complete (--digest for JSON/agents);
|
|
239
|
+
[--pull-request] --pull-request also opens its pull request
|
|
240
|
+
toolroll task merge <id> --as <you> --token <t>
|
|
241
|
+
merge the task's pull request once checks pass
|
|
239
242
|
toolroll task revise <id> --feedback "requested change"
|
|
240
243
|
toolroll assignment show <task> root, current work and exact handoff
|
|
241
244
|
toolroll assignment updates durable updates (--after <cursor>)
|
|
@@ -554,7 +557,7 @@ Exit codes
|
|
|
554
557
|
export const TASK_ACTIONS = [
|
|
555
558
|
"add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign",
|
|
556
559
|
"reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
|
|
557
|
-
"review", "accept", "repair", "route", "stop", "resume", "complete", "revise",
|
|
560
|
+
"review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge",
|
|
558
561
|
];
|
|
559
562
|
export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
|
|
560
563
|
export const CONFIG_ACTIONS = ["show", "set", "clear"];
|
|
@@ -607,6 +610,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
|
|
|
607
610
|
"zip",
|
|
608
611
|
// Settings → Integrations: the last checks, without checking again.
|
|
609
612
|
"saved",
|
|
613
|
+
// task complete: also open the result's pull request.
|
|
614
|
+
"pull-request",
|
|
610
615
|
]);
|
|
611
616
|
export function parseOperateArgs(argv, ownValues = new Set()) {
|
|
612
617
|
const positional = [];
|
|
@@ -9325,6 +9330,9 @@ function taskCommand(positional, flags, context) {
|
|
|
9325
9330
|
const authenticated = verifyApproverByPassword(context.store, acting.name, acting.token, context.store.knownRepos().filter(repo => context.store.accountCanAccess(acting.name, repo)));
|
|
9326
9331
|
return authenticated.ok ? authenticated.who : null;
|
|
9327
9332
|
} });
|
|
9333
|
+
case "merge":
|
|
9334
|
+
return runTaskMergeCommand(rest, flags, { store: context.store, json: context.json, write: context.write, clock: context.clock,
|
|
9335
|
+
demo: context.store.isDemo(), credentials: () => askCredentials(flags, context), ...(context.publishExec === undefined ? {} : { exec: context.publishExec }) });
|
|
9328
9336
|
case "add":
|
|
9329
9337
|
return addTask(rest, flags, context);
|
|
9330
9338
|
case "list":
|
|
@@ -9744,6 +9752,8 @@ function showTask(positional, context) {
|
|
|
9744
9752
|
// Paused (resume), or the review-retry door — and every stop on record.
|
|
9745
9753
|
control: taskControlOf(store, ref.id, now),
|
|
9746
9754
|
stops: store.stopsForTask(ref.id),
|
|
9755
|
+
// The pull request Complete opened (any version), with its CI state and merge commit — the console's view.
|
|
9756
|
+
pullRequest: newestPullRequestOf(store, store.taskFamilyOf(id, null, true)?.versions.map(one => one.id) ?? [id]),
|
|
9747
9757
|
};
|
|
9748
9758
|
return succeed(write, json, "task show", detail, () => [
|
|
9749
9759
|
`${task.id} ${task.state}${ref.deliverable === "report" ? " (scout — delivers a report)" : ""}`,
|
|
@@ -9772,6 +9782,7 @@ function showTask(positional, context) {
|
|
|
9772
9782
|
...detail.report.report.followUps.map((one, index) => ` follow-up ${index + 1}: ${one.title}`),
|
|
9773
9783
|
]
|
|
9774
9784
|
: [` report: ${detail.report.problem} (run ${detail.report.run})`]),
|
|
9785
|
+
...pullRequestLines(detail.pullRequest),
|
|
9775
9786
|
...(detail.blockedBy.length > 0 ? [` waits for ${detail.blockedBy.join(", ")}`] : []),
|
|
9776
9787
|
...(detail.position === null
|
|
9777
9788
|
? []
|
|
@@ -169,4 +169,6 @@ export type PullRequestView = {
|
|
|
169
169
|
};
|
|
170
170
|
/** The pull request a result opened through Complete, in plain words; null when it opened none. */
|
|
171
171
|
export declare function pullRequestViewOf(store: Store, runId: number): PullRequestView | null;
|
|
172
|
+
/** The newest pull request any version of a task opened through Complete (versions oldest first), or null. */
|
|
173
|
+
export declare function newestPullRequestOf(store: Store, versions: readonly string[]): PullRequestView | null;
|
|
172
174
|
export {};
|
|
@@ -473,3 +473,17 @@ export function pullRequestViewOf(store, runId) {
|
|
|
473
473
|
return { ...base, state: "waiting", label: "Waiting for checks", detail: "No CI checks have reported on GitHub yet." };
|
|
474
474
|
}
|
|
475
475
|
}
|
|
476
|
+
/** The newest pull request any version of a task opened through Complete (versions oldest first), or null. */
|
|
477
|
+
export function newestPullRequestOf(store, versions) {
|
|
478
|
+
for (const id of [...versions].reverse()) {
|
|
479
|
+
const ref = store.lookupRef(id);
|
|
480
|
+
if (ref === null)
|
|
481
|
+
continue;
|
|
482
|
+
for (const run of store.runsFor(ref.id)) {
|
|
483
|
+
const view = pullRequestViewOf(store, run.id);
|
|
484
|
+
if (view !== null)
|
|
485
|
+
return view;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -59,7 +59,7 @@ import { workIndexPage, workCountsByProject, WorkIndexCursorError } from "./work
|
|
|
59
59
|
import { openWorkDecisionOf } from "./work-summary.js";
|
|
60
60
|
import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
|
|
61
61
|
import { pullRequestSettingsHtml, PULL_REQUEST_SETTINGS_CSS } from './pull-request-ui.js';
|
|
62
|
-
import { checkPublishing, completeAndOpenPullRequest, mergeAsPerson, pullRequestBlocker,
|
|
62
|
+
import { checkPublishing, completeAndOpenPullRequest, mergeAsPerson, pullRequestBlocker, publishingOf, saveMergeSettings, savePublishing, newestPullRequestOf, MERGE_METHODS } from './pull-request-flow.js';
|
|
63
63
|
import { leadBriefHtml, LEAD_CONTEXT_CSS } from './lead-context.js';
|
|
64
64
|
import { assignmentCatchUp } from './assignment-brief.js';
|
|
65
65
|
import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml, assignmentWithEvidence, ASSIGNMENT_CSS } from './assignment-ui.js';
|
|
@@ -5433,20 +5433,12 @@ export function createDecisionServer(options) {
|
|
|
5433
5433
|
function taskPullRequestOf(taskId, who) {
|
|
5434
5434
|
const family = familyOf(taskId);
|
|
5435
5435
|
const rootId = family?.root.id ?? taskId;
|
|
5436
|
-
const ids = family === null ? [taskId] : [...family.versions.map(one => one.id)].reverse();
|
|
5437
5436
|
const repo = store.lookupRef(taskId)?.repo ?? null;
|
|
5438
5437
|
const publishing = publishingOf(store, repo);
|
|
5439
5438
|
const target = publishing.on ? publishing.base : null;
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
continue;
|
|
5444
|
-
for (const run of store.runsFor(ref.id)) {
|
|
5445
|
-
const view = pullRequestViewOf(store, run.id);
|
|
5446
|
-
if (view !== null)
|
|
5447
|
-
return { taskId: rootId, view: { ...view, canMerge: view.canMerge && who.role === "approver" && who.via === "cookie" }, offer: null, target };
|
|
5448
|
-
}
|
|
5449
|
-
}
|
|
5439
|
+
const view = newestPullRequestOf(store, family === null ? [taskId] : family.versions.map(one => one.id));
|
|
5440
|
+
if (view !== null)
|
|
5441
|
+
return { taskId: rootId, view: { ...view, canMerge: view.canMerge && who.role === "approver" && who.via === "cookie" }, offer: null, target };
|
|
5450
5442
|
if (!publishing.on || who.via !== "cookie" || who.role !== "approver")
|
|
5451
5443
|
return null;
|
|
5452
5444
|
const assignment = assignmentOf(store, taskId, clock(), { principal: "operator", repos: admissionList(), includeUnplaced: visible(null) }, evidenceRoot);
|
package/dist/store.d.ts
CHANGED
|
@@ -6188,6 +6188,7 @@ export declare class Store {
|
|
|
6188
6188
|
historyNote?: string;
|
|
6189
6189
|
}, now: Date): boolean;
|
|
6190
6190
|
/** What a card's current zone is doing: its task, the card's main work, what it waits on, what zones reported. */
|
|
6191
|
+
/** Read and written in one transaction, so a pass changing one field never writes back another pass's older task or waiting. */
|
|
6191
6192
|
updateFlowCard(id: number, change: {
|
|
6192
6193
|
task?: string | null;
|
|
6193
6194
|
primaryTask?: string;
|
package/dist/store.js
CHANGED
|
@@ -18795,11 +18795,14 @@ export class Store {
|
|
|
18795
18795
|
});
|
|
18796
18796
|
}
|
|
18797
18797
|
/** What a card's current zone is doing: its task, the card's main work, what it waits on, what zones reported. */
|
|
18798
|
+
/** Read and written in one transaction, so a pass changing one field never writes back another pass's older task or waiting. */
|
|
18798
18799
|
updateFlowCard(id, change, now) {
|
|
18799
|
-
|
|
18800
|
-
|
|
18801
|
-
|
|
18802
|
-
|
|
18800
|
+
this.transact(() => {
|
|
18801
|
+
const card = this.getFlowCard(id);
|
|
18802
|
+
if (card === null)
|
|
18803
|
+
return;
|
|
18804
|
+
this.db.prepare("UPDATE flow_card SET task = ?, primary_task = ?, waiting = ?, outputs_json = ?, state = ?, updated_at = ? WHERE id = ?").run(change.task === undefined ? card.task : change.task, change.primaryTask ?? card.primaryTask, change.waiting === undefined ? card.waiting : change.waiting, JSON.stringify(change.outputs ?? card.outputs), change.state ?? card.state, now.toISOString(), id);
|
|
18805
|
+
});
|
|
18803
18806
|
}
|
|
18804
18807
|
flowEvents(card) {
|
|
18805
18808
|
return this.db.prepare("SELECT * FROM flow_event WHERE card = ? ORDER BY id").all(card).map(row => ({
|
package/dist/surface.js
CHANGED
|
@@ -160,10 +160,16 @@ export const COMMAND_GUIDE = [
|
|
|
160
160
|
flags: [jsonFlag, dbFlag,
|
|
161
161
|
{ name: 'token-env', takesValue: true, meaning: 'scoped coordinator credential environment variable' },
|
|
162
162
|
{ name: 'token-file', takesValue: true, meaning: 'scoped coordinator credential file; defaults to selected project reference' },
|
|
163
|
-
...(action === 'complete' ? [{ name: 'digest', takesValue: true, meaning: 'exact receipt from assignment show; required for JSON or scoped agents' }
|
|
163
|
+
...(action === 'complete' ? [{ name: 'digest', takesValue: true, meaning: 'exact receipt from assignment show; required for JSON or scoped agents' },
|
|
164
|
+
{ 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" }]
|
|
164
165
|
: [{ 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' }]),
|
|
165
166
|
],
|
|
166
167
|
})),
|
|
168
|
+
{ invocation: "task merge", synopsis: "merge the task's pull request once its checks pass — the console's Merge, behind the approver's password; the project's method (squash by default), branch deleted, recorded in the ledger",
|
|
169
|
+
audience: "operator", agentMayInvoke: false, mutation: "identity-idempotent",
|
|
170
|
+
positionals: [{ name: "task", required: true, meaning: "any version of the task" }],
|
|
171
|
+
flags: [jsonFlag, dbFlag, { name: "as", takesValue: true, meaning: "the approver merging" }, { name: "token", takesValue: true, meaning: "their password" }],
|
|
172
|
+
notableReasons: ["usage", "not-an-approver"] },
|
|
167
173
|
...["show", "updates", "claim", "check", "brief", "inbox", "ack"].map(action => ({
|
|
168
174
|
invocation: `assignment ${action}`,
|
|
169
175
|
synopsis: action === "show" ? "read the root, current work, owner and exact receipt"
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type VerifiedApprover } from './principal.js';
|
|
2
|
+
import { type PullRequestView } from './pull-request-flow.js';
|
|
3
|
+
import type { PublishExec } from './publish.js';
|
|
2
4
|
import type { Store } from './store.js';
|
|
3
5
|
export declare function runTaskOutcomeCommand(action: 'complete' | 'revise', positional: readonly string[], flags: Map<string, string | true>, context: {
|
|
4
6
|
store: Store;
|
|
@@ -8,3 +10,19 @@ export declare function runTaskOutcomeCommand(action: 'complete' | 'revise', pos
|
|
|
8
10
|
now: Date;
|
|
9
11
|
operator: () => Promise<VerifiedApprover | null>;
|
|
10
12
|
}): Promise<number>;
|
|
13
|
+
/** `task merge <task>`: the console's Merge — the newest pull request any version of the task opened through
|
|
14
|
+
* Complete, merged behind the approver's password once a fresh read shows its checks passing. */
|
|
15
|
+
export declare function runTaskMergeCommand(positional: readonly string[], flags: Map<string, string | true>, context: {
|
|
16
|
+
store: Store;
|
|
17
|
+
json: boolean;
|
|
18
|
+
write: (line: string) => void;
|
|
19
|
+
clock: () => Date;
|
|
20
|
+
exec?: PublishExec;
|
|
21
|
+
demo: boolean;
|
|
22
|
+
credentials: () => Promise<{
|
|
23
|
+
name: string;
|
|
24
|
+
token: string;
|
|
25
|
+
} | null>;
|
|
26
|
+
}): Promise<number>;
|
|
27
|
+
/** The lines `task show` prints for a task's pull request. */
|
|
28
|
+
export declare function pullRequestLines(view: PullRequestView | null): string[];
|
package/dist/task-outcome-cli.js
CHANGED
|
@@ -4,9 +4,11 @@ import { assignmentOf, checkAssignmentAsOperator } from './assignment.js';
|
|
|
4
4
|
import { assignmentForCoordinator, coordinatorToken } from './assignment-adapters.js';
|
|
5
5
|
import { authenticateCoordinator } from './coordinator.js';
|
|
6
6
|
import { reproveApprover } from './principal.js';
|
|
7
|
+
import { authenticateApprover } from './scope.js';
|
|
7
8
|
import { requestResultChanges } from './result-actions.js';
|
|
8
9
|
import { commentSourceKey, revisionSourceOf } from './result-review.js';
|
|
9
10
|
import { envelopeJson } from './envelope.js';
|
|
11
|
+
import { completeAndOpenPullRequest, mergeAsPerson, newestPullRequestOf, publishingOf, pullRequestViewOf } from './pull-request-flow.js';
|
|
10
12
|
export async function runTaskOutcomeCommand(action, positional, flags, context) {
|
|
11
13
|
const command = `task ${action}`;
|
|
12
14
|
const emit = (result, lines) => {
|
|
@@ -14,18 +16,21 @@ export async function runTaskOutcomeCommand(action, positional, flags, context)
|
|
|
14
16
|
return result.ok ? 0 : result.reason === 'usage' ? 2 : 3;
|
|
15
17
|
};
|
|
16
18
|
const fail = (reason, message) => emit({ ok: false, reason, message });
|
|
17
|
-
const allowed = new Set(['json', 'db', 'as', 'token', 'token-env', 'token-file', ...(action === 'complete' ? ['digest'] : ['feedback', 'run', 'source', 'key'])]);
|
|
19
|
+
const allowed = new Set(['json', 'db', 'as', 'token', 'token-env', 'token-file', ...(action === 'complete' ? ['digest', 'pull-request'] : ['feedback', 'run', 'source', 'key'])]);
|
|
18
20
|
for (const key of flags.keys())
|
|
19
21
|
if (!allowed.has(key))
|
|
20
22
|
return fail('usage', `--${key} is not a task ${action} option.`);
|
|
21
23
|
const task = positional[0];
|
|
22
24
|
if (positional.length !== 1 || !task || task.length > 64 || /[\x00-\x1f]/.test(task))
|
|
23
|
-
return fail('usage', `Use task ${action} <task>${action === 'complete' ? ' --digest <receipt>' : ' --feedback "requested change"'}.`);
|
|
25
|
+
return fail('usage', `Use task ${action} <task>${action === 'complete' ? ' --digest <receipt> [--pull-request]' : ' --feedback "requested change"'}.`);
|
|
26
|
+
const pullRequest = flags.has('pull-request');
|
|
24
27
|
const credential = coordinatorToken(flags, process.env);
|
|
25
28
|
if ('ok' in credential)
|
|
26
29
|
return emit(credential);
|
|
27
30
|
if (credential.token !== null && (flags.has('as') || flags.has('token')))
|
|
28
31
|
return fail('usage', 'Choose a scoped credential or your operator sign-in.');
|
|
32
|
+
if (credential.token !== null && pullRequest)
|
|
33
|
+
return fail('unauthenticated', 'Opening a pull request is an approver\'s act. Use your operator sign-in (--as and --token), not a scoped credential.');
|
|
29
34
|
const operator = credential.token === null ? await context.operator() : null;
|
|
30
35
|
if (credential.token === null && operator === null)
|
|
31
36
|
return fail('unauthenticated', 'Sign in with up, or supply --as and --token. Agents can use --token-file or --token-env with a scoped credential.');
|
|
@@ -49,6 +54,28 @@ export async function runTaskOutcomeCommand(action, positional, flags, context)
|
|
|
49
54
|
const digest = typeof supplied === 'string' ? supplied : current.receipt?.digest;
|
|
50
55
|
if (!digest)
|
|
51
56
|
return fail('not-ready', 'This task has no finished result to mark complete.');
|
|
57
|
+
if (pullRequest) {
|
|
58
|
+
// The console's "Complete and open a pull request": the same exact-receipt completion and owed PR, one transaction.
|
|
59
|
+
const repo = store.lookupRef(current.activeTaskId)?.repo ?? null;
|
|
60
|
+
const publishing = publishingOf(store, repo);
|
|
61
|
+
if (!publishing.on)
|
|
62
|
+
return fail('pull-requests-off', `Pull requests aren't set up for this project. Turn them on with: toolroll publish setup --repo ${repo ?? '<path>'} --yes`);
|
|
63
|
+
if (publishing.legacy)
|
|
64
|
+
return fail('pull-requests-off', 'This project opens a pull request for every build. Complete without --pull-request.');
|
|
65
|
+
const runId = current.receipt?.runId;
|
|
66
|
+
if (runId === undefined)
|
|
67
|
+
return fail('not-ready', 'This task has no finished result to mark complete.');
|
|
68
|
+
const opened = completeAndOpenPullRequest(store, { taskId: task, digest, runId, who: operator, root: evidenceRoot }, now);
|
|
69
|
+
if (!opened.ok)
|
|
70
|
+
return emit(opened);
|
|
71
|
+
const link = opened.publication.prUrl;
|
|
72
|
+
return emit({ ok: true, result: assignmentOf(store, task, now, { principal: 'operator', repos }, evidenceRoot), pullRequest: pullRequestViewOf(store, runId) }, [
|
|
73
|
+
`${current.rootId} · Complete`,
|
|
74
|
+
`Result: ${current.activeTaskId} · run ${runId} · ${opened.publication.headSha}`,
|
|
75
|
+
link === null ? `Pull request: opening from ${opened.publication.headSha.slice(0, 12)} into ${publishing.githubRepo} ${publishing.base}. toolroll task show ${current.rootId} prints its link.` : `Pull request: ${link}`,
|
|
76
|
+
`Next: CI is watched on GitHub. Merge when green: toolroll task merge ${current.rootId}`,
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
52
79
|
const changed = credential.token !== null
|
|
53
80
|
? assignmentForCoordinator(store, credential.token, 'check', { ref: task, digest }, now, evidenceRoot)
|
|
54
81
|
: checkAssignmentAsOperator(store, task, digest, operator, now, evidenceRoot);
|
|
@@ -90,3 +117,49 @@ export async function runTaskOutcomeCommand(action, positional, flags, context)
|
|
|
90
117
|
return emit({ ok: true, result: { id: revised.id, rootId: current.rootId, sourceRun: run.id, source, key, approved, planning } }, [`${current.rootId} · Revision created`, `Execution: ${revised.id}`, approved ? 'Covered by your existing operating mode.' : planning ? 'Updating the plan with your notes; approve it when it is ready.' : 'The revision is waiting for approval.']);
|
|
91
118
|
});
|
|
92
119
|
}
|
|
120
|
+
/** `task merge <task>`: the console's Merge — the newest pull request any version of the task opened through
|
|
121
|
+
* Complete, merged behind the approver's password once a fresh read shows its checks passing. */
|
|
122
|
+
export async function runTaskMergeCommand(positional, flags, context) {
|
|
123
|
+
const command = 'task merge';
|
|
124
|
+
const emit = (result, lines) => {
|
|
125
|
+
context.write(context.json ? envelopeJson({ ...result, command }) : result.ok ? (lines ?? []).join('\n') : result.message ?? 'The merge was refused.');
|
|
126
|
+
return result.ok ? 0 : result.reason === 'usage' ? 2 : 3;
|
|
127
|
+
};
|
|
128
|
+
const fail = (reason, message) => emit({ ok: false, reason, message });
|
|
129
|
+
const allowed = new Set(['json', 'db', 'as', 'token']);
|
|
130
|
+
for (const key of flags.keys())
|
|
131
|
+
if (!allowed.has(key))
|
|
132
|
+
return fail('usage', `--${key} is not a task merge option.`);
|
|
133
|
+
const task = positional[0];
|
|
134
|
+
if (positional.length !== 1 || !task || task.length > 64 || /[\x00-\x1f]/.test(task))
|
|
135
|
+
return fail('usage', 'Use task merge <task> --as <you> --token <password>.');
|
|
136
|
+
if (context.demo)
|
|
137
|
+
return fail('demo', 'The demo merges nothing.');
|
|
138
|
+
const acting = await context.credentials();
|
|
139
|
+
if (acting === null)
|
|
140
|
+
return fail('unauthenticated', 'Merging takes your password: --as <you> --token <password>.');
|
|
141
|
+
const { store } = context;
|
|
142
|
+
if (!authenticateApprover(store, acting.name, acting.token).ok)
|
|
143
|
+
return fail('not-an-approver', 'That password didn\'t work, or you can\'t merge.');
|
|
144
|
+
const family = store.taskFamilyOf(task, store.knownRepos().filter(repo => store.accountCanAccess(acting.name, repo)), false);
|
|
145
|
+
if (family === null)
|
|
146
|
+
return fail('not-found', 'That task is unavailable in your projects.');
|
|
147
|
+
const view = newestPullRequestOf(store, family.versions.map(one => one.id));
|
|
148
|
+
if (view === null)
|
|
149
|
+
return fail('no-pr', 'This task has no pull request. Open one with task complete --pull-request.');
|
|
150
|
+
const merged = await mergeAsPerson(store, { runId: view.runId, name: acting.name, password: acting.token, ...(context.exec === undefined ? {} : { exec: context.exec }), clock: context.clock });
|
|
151
|
+
if (!merged.ok)
|
|
152
|
+
return emit({ ...merged, reason: merged.reason === 'password' ? 'not-an-approver' : merged.reason, pullRequest: view });
|
|
153
|
+
const after = newestPullRequestOf(store, family.versions.map(one => one.id));
|
|
154
|
+
return emit({ ok: true, pullRequest: after }, [
|
|
155
|
+
`${family.root.id} · Merged`,
|
|
156
|
+
`Pull request: ${after?.prUrl ?? view.prUrl ?? `#${view.prNumber}`}`,
|
|
157
|
+
`Merge commit: ${merged.commit ?? 'recorded by GitHub'} (${after?.mergeMethod ?? view.mergeMethod}, branch deleted)`,
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
/** The lines `task show` prints for a task's pull request. */
|
|
161
|
+
export function pullRequestLines(view) {
|
|
162
|
+
if (view === null)
|
|
163
|
+
return [];
|
|
164
|
+
return [` pull request: ${view.prUrl ?? 'opening'} · ${view.label}`, ` ${view.detail}`, ...(view.mergeCommit === null ? [] : [` merge commit: ${view.mergeCommit}`])];
|
|
165
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|