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 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
@@ -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
- const filed = fileTaskProposal(store, {
245
- title: (report ? `${stage.title}: ${card.title}` : card.title).slice(0, 200),
246
- repo: flow.repo,
247
- goal,
248
- filedVia: `flow:${flow.id}`, filedBy: filerFor(card.createdBy), // who made the card asked for the work (an owner can be reassigned by anyone)
249
- deliverable: report ? "report" : "branch",
250
- planning: report ? "skip" : stage.planning ?? "auto",
251
- acceptance: report ? ACCEPTANCE.report : ACCEPTANCE.task,
252
- admittedRepos: [flow.repo],
253
- }, now);
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
  }
@@ -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.claimFlowStep({ card: card.id, entry: card.entry, stage: stage.id, kind: stage.kind, script: script?.name ?? null, scriptVersion: script?.version ?? null }, now))
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.claimFlowStep({ card: card.id, entry: card.entry, stage: stage.id, kind: "teammate", script: null, scriptVersion: null }, now))
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, pullRequestViewOf, publishingOf, saveMergeSettings, savePublishing, MERGE_METHODS } from './pull-request-flow.js';
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
- for (const id of ids) {
5441
- const ref = store.lookupRef(id);
5442
- if (ref === null)
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
- const card = this.getFlowCard(id);
18800
- if (card === null)
18801
- return;
18802
- 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);
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[];
@@ -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",
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"