toolroll 0.9.85 → 0.9.86

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/agent-fence.d.ts +6 -2
  3. package/dist/agent-fence.js +3 -2
  4. package/dist/assignment.js +4 -3
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +31 -29
  7. package/dist/browser-workspace.d.ts +40 -0
  8. package/dist/chat-actions.d.ts +11 -1
  9. package/dist/chat-actions.js +53 -1
  10. package/dist/chat-channel.js +1 -0
  11. package/dist/chat-quiet.js +3 -2
  12. package/dist/claim.d.ts +3 -0
  13. package/dist/claim.js +4 -1
  14. package/dist/contracts/chat-actions.d.ts +9 -0
  15. package/dist/contracts/chat-actions.js +3 -0
  16. package/dist/contracts/console-api.d.ts +4 -1
  17. package/dist/contracts/console-api.js +4 -1
  18. package/dist/contracts/flow-propose.d.ts +4 -1
  19. package/dist/contracts/flow-propose.js +1 -1
  20. package/dist/contracts/flow.d.ts +41 -0
  21. package/dist/contracts/flow.js +4 -2
  22. package/dist/contracts/lead-tools.d.ts +21 -0
  23. package/dist/contracts/lead-tools.js +2 -0
  24. package/dist/contracts/store-json.d.ts +2 -0
  25. package/dist/contracts/trigger-payloads.d.ts +20 -0
  26. package/dist/contracts/trigger-payloads.js +4 -0
  27. package/dist/coordinator.js +1 -0
  28. package/dist/deploy-gate.js +4 -0
  29. package/dist/dispatch.d.ts +3 -0
  30. package/dist/dispatch.js +4 -1
  31. package/dist/flow-browser.d.ts +103 -0
  32. package/dist/flow-browser.js +204 -0
  33. package/dist/flow-code.d.ts +5 -1
  34. package/dist/flow-code.js +4 -1
  35. package/dist/flow-engine.js +16 -0
  36. package/dist/flow-live.js +9 -1
  37. package/dist/flow-pull-request.js +15 -7
  38. package/dist/flow-share.js +3 -1
  39. package/dist/flow-steps.d.ts +9 -1
  40. package/dist/flow-steps.js +55 -5
  41. package/dist/flow-triggers.js +55 -0
  42. package/dist/flows-cli.d.ts +19 -0
  43. package/dist/flows-cli.js +47 -2
  44. package/dist/flows-ui.js +11 -2
  45. package/dist/flows.js +5 -2
  46. package/dist/lead-tools.js +35 -6
  47. package/dist/lead-voice.js +16 -5
  48. package/dist/operate.d.ts +1 -1
  49. package/dist/operate.js +215 -6
  50. package/dist/project-delete.js +7 -3
  51. package/dist/pull-request-flow.js +35 -4
  52. package/dist/result-follow-ups.js +4 -0
  53. package/dist/scope.d.ts +5 -0
  54. package/dist/scope.js +7 -0
  55. package/dist/server/chat.js +4 -2
  56. package/dist/server/console.css +29 -0
  57. package/dist/server/flows.js +15 -0
  58. package/dist/server/render-pages.d.ts +13 -0
  59. package/dist/server/render-pages.js +45 -2
  60. package/dist/server/render-tasks.d.ts +8 -0
  61. package/dist/server/render-tasks.js +17 -6
  62. package/dist/server/route-table.js +11 -0
  63. package/dist/server/task-views.js +5 -0
  64. package/dist/server/tasks.d.ts +10 -0
  65. package/dist/server/tasks.js +54 -2
  66. package/dist/settings-proofs.js +1 -0
  67. package/dist/store.d.ts +71 -3
  68. package/dist/store.js +222 -20
  69. package/dist/surface.js +18 -2
  70. package/dist/task-archive.d.ts +10 -0
  71. package/dist/task-archive.js +16 -0
  72. package/dist/task-control.d.ts +1 -1
  73. package/dist/tidy.d.ts +130 -0
  74. package/dist/tidy.js +503 -0
  75. package/dist/work-index.d.ts +24 -0
  76. package/dist/work-index.js +52 -30
  77. package/dist/workspace-ui.d.ts +1 -1
  78. package/dist/workspace-ui.js +3 -2
  79. package/package.json +1 -1
@@ -8,7 +8,7 @@
8
8
  import { actorLabel, withActor } from "./actor.js";
9
9
  import { chatControlHref } from "./chat-controls.js";
10
10
  import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
11
- import { BUILT_IN } from "./store.js";
11
+ import { BUILT_IN, refArchived } from "./store.js";
12
12
  import { leadNameOf } from "./lead-identity.js";
13
13
  import { leadLapsed } from "./task-status.js";
14
14
  import { phoneText as plain } from "./telegram-status.js";
@@ -85,19 +85,24 @@ export function leadSay(store, actor, text, taskId, now) {
85
85
  // The task's own id never reaches a chat: it reads as the task's short title (chat-voice.ts).
86
86
  const said = taskId === null ? text.trim() : chatText(text.trim(), [{ id: taskId, title: chatTitle(store, taskId) }]);
87
87
  return store.transact(() => {
88
+ // A put-away task (v145) gets no messages: the lead's words about it would nudge a person about nothing.
89
+ if (task !== null && refArchived(store, task.refId))
90
+ return { ok: false, reason: "refused", message: "This task is put away. A person can restore it first." };
88
91
  const stamp = now.toISOString();
89
92
  const ledger = store.recordAction({ at: stamp, actor: actorLabel(actor), repo: task?.repo ?? null, taskId, runId: null,
90
93
  action: LEAD_SAID, outcome: "recorded", source: "work", detail: text.trim() });
91
- const open = store.handle.prepare(`SELECT id, dedupe_key, body FROM notification WHERE kind = ? AND recipient = ? ORDER BY id DESC LIMIT 1`)
94
+ const open = store.handle.prepare(`SELECT id, dedupe_key, body, task_ref FROM notification WHERE kind = ? AND recipient = ? ORDER BY id DESC LIMIT 1`)
92
95
  .get(LEAD_SAY_KIND, actor.account);
93
96
  const opened = open === undefined ? null : /^lead-say:(.+):\d+$/.exec(String(open["dedupe_key"]))?.[1] ?? null;
94
- const joined = open !== undefined && opened !== null && Date.parse(opened) > now.getTime() - BATCH_MS && Date.parse(opened) <= now.getTime();
97
+ // A batch is about one task (or none): words about another start a row of their own, so each row keeps its task.
98
+ const sameTask = open !== undefined && (open["task_ref"] ?? null) === (task === null ? null : task.refId);
99
+ const joined = sameTask && opened !== null && Date.parse(opened) > now.getTime() - BATCH_MS && Date.parse(opened) <= now.getTime();
95
100
  const lines = [...(joined ? String(open["body"]).split("\n") : []), said].slice(-BATCH_LINES);
96
101
  if (joined)
97
102
  store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL").run(stamp, Number(open["id"]));
98
103
  const key = `lead-say:${joined ? opened : stamp}:${ledger}`;
99
104
  store.enqueueNotification({ dedupeKey: key, kind: LEAD_SAY_KIND, subject: leadNameOf(store, actor.account), body: lines.join("\n"),
100
- recipient: actor.account, source: { installation: true }, ...(taskId === null ? {} : { link: chatControlHref("task", taskId) }) }, now);
105
+ recipient: actor.account, source: task === null ? { installation: true } : { taskRef: task.refId }, ...(taskId === null ? {} : { link: chatControlHref("task", taskId) }) }, now);
101
106
  const row = store.handle.prepare("SELECT id FROM notification WHERE dedupe_key = ?").get(key);
102
107
  return { ok: true, joined, notification: row === undefined ? null : Number(row["id"]) };
103
108
  });
@@ -136,14 +141,20 @@ export function leadClaim(store, actor, taskId, now) {
136
141
  if ("ok" in task)
137
142
  return task;
138
143
  const { root } = familyOf(store, taskId);
139
- store.transact(() => {
144
+ const taken = store.transact(() => {
145
+ // A put-away task (v145) waits on nobody, so the lead takes nothing on there: checked with the claim's own write.
146
+ if (refArchived(store, task.refId))
147
+ return false;
140
148
  const ledger = store.recordAction({ at: now.toISOString(), actor: actorLabel(actor), repo: task.repo, taskId: root, runId: null,
141
149
  action: LEAD_CLAIMED, outcome: "recorded", source: "work" });
142
150
  // The owner's card for this task repaints to "<name> is on it": quiet chat edits its card, and every-update
143
151
  // chat edits the attempt's own card (the Failed alert), or follows it up when that message is gone.
144
152
  store.enqueueNotification({ dedupeKey: `lead-on-it:${ledger}`, kind: LEAD_ON_IT_KIND, subject: `${leadNameOf(store, actor.account)} is on it`, body: "",
145
153
  link: chatControlHref("task", taskId), source: cardSource(store, root) ?? { taskRef: task.refId } }, now);
154
+ return true;
146
155
  });
156
+ if (!taken)
157
+ return { ok: false, reason: "refused", message: "This task is put away. A person can restore it first." };
147
158
  return { ok: true, root, claim: leadClaimOf(store, taskId, now, actor.account) };
148
159
  }
149
160
  /** Where a task's chat card lives: the family's newest attempt (the card every-update chat edits), else its newest
package/dist/operate.d.ts CHANGED
@@ -115,7 +115,7 @@ type Args = {
115
115
  * exports are behavior, not commentary — and the declared command guide
116
116
  * (surface.ts) is tested for exact equality against them.
117
117
  */
118
- export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask", "checks", "add-tests"];
118
+ export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask", "checks", "add-tests", "archive", "restore", "tidy"];
119
119
  export declare const PUBLISH_ACTIONS: readonly ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
120
120
  export declare const CONFIG_ACTIONS: readonly ["show", "set", "clear"];
121
121
  export declare const APPROVER_ACTIONS: readonly ["list", "add"];
package/dist/operate.js CHANGED
@@ -28,6 +28,8 @@ import { idleStorageSweep, sweepSchedule, sweepScheduleWords } from "./storage-s
28
28
  import { lastSweep, saveSweep, sweepDetails, sweepWords as storageSweepWords } from "./storage-sweep.js";
29
29
  import { removeShared, sharedDepsRoot, sharedUse, unusedShared } from "./shared-deps.js";
30
30
  import { cleanLandedBacklog, gitOf, landedBacklog, reconcileLanded, BACKLOG_DAYS } from "./landed-cleanup.js";
31
+ import { accessOf, applyTidy, approverStandsNow, withinLens, familyRepoOf, releaseFactsFor, runTidy, tidyDays, tidyDigest, tidyGroups, tidyPreview, TIDY_DEFAULT_DAYS, TIDY_PASS_LIMIT } from "./tidy.js";
32
+ import { personProof } from "./person-proof.js";
31
33
  import { RemoteMains } from "./remote-main.js";
32
34
  import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
33
35
  import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
@@ -105,7 +107,7 @@ import { taskReviewBrief, renderReviewBrief } from "./task-review-brief.js";
105
107
  import { homedir, hostname } from "node:os";
106
108
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
107
109
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
108
- import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
110
+ import { openStore, openStoreNoMigrate, archiveTaskFamily, restoreTaskFamily, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
109
111
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
110
112
  import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
111
113
  import { authorizePlanUnderMode } from "./plan-auto.js";
@@ -273,7 +275,11 @@ export const OPERATE_HELP = `toolroll — operating the queue
273
275
  the lead passes it as --token, or TOOLROLL_LEAD_TOKEN, and its work pings nobody
274
276
  toolroll task ask <id> --person <name> --why "<text>"
275
277
  hand a task to a person: it pings them, and it is theirs
276
- toolroll task list [--view <v>] [--limit <n>] [--cursor <c>] paginated saved task status
278
+ toolroll task list [--view <v>] [--limit <n>] [--cursor <c>] paginated saved task status (--view archived: put away)
279
+ toolroll task archive <id...> [--reason <why>] put tasks away: out of every list, history kept (a person's act)
280
+ toolroll task restore <id...> bring put-away tasks back exactly as they were
281
+ toolroll task tidy --preview [--days N] what Put away old tasks would put away, and why (read-only)
282
+ toolroll task tidy --apply --digest <d> [--except <id,...>] put away exactly that list
277
283
  toolroll task show <id>
278
284
  toolroll task wait <id> [--timeout <seconds>]
279
285
  wait for this attempt to finish or need a person
@@ -663,7 +669,7 @@ export const TASK_ACTIONS = [
663
669
  "add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign",
664
670
  "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
665
671
  "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask",
666
- "checks", "add-tests",
672
+ "checks", "add-tests", "archive", "restore", "tidy",
667
673
  ];
668
674
  export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
669
675
  export const CONFIG_ACTIONS = ["show", "set", "clear"];
@@ -707,6 +713,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
707
713
  "starter",
708
714
  // flows: templates, steps, scripts and cards.
709
715
  "template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone", "option",
716
+ // task tidy --apply: the previewed tasks to leave out.
717
+ "except",
710
718
  "token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
711
719
  // pings follow responsibility: replacements.
712
720
  "replaced-by", "replaces",
@@ -757,6 +765,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
757
765
  "password-stdin",
758
766
  // task steer: pause the running Claude build, then send the note.
759
767
  "now",
768
+ // task tidy: look first (--preview), then put away exactly what was shown (--apply --digest).
769
+ "preview", "apply",
760
770
  ]);
761
771
  /** `project network` reads --remove <hosts> and --local any|none as values, where every other verb's --remove and
762
772
  * --local are switches. */
@@ -1564,6 +1574,8 @@ function claimCommand(positional, flags, context) {
1564
1574
  return fail(write, json, "claim", "sweep-limit", `its sweep already has ${result.limit} running at once — it starts when one finishes`, EXIT.refused, { sweep: result.sweep, limit: result.limit });
1565
1575
  if (result.reason === "watch-custody")
1566
1576
  return fail(write, json, "claim", "watch-custody", "This worker no longer holds the project; restart the watch", EXIT.refused);
1577
+ if (result.reason === "archived")
1578
+ return fail(write, json, "claim", "archived", `${id} is put away — restore it with \`toolroll task restore ${id}\` first`, EXIT.refused);
1567
1579
  if (result.reason === "draining")
1568
1580
  return fail(write, json, "claim", "draining", `Toolroll is restarting (draining): ${runner} takes no new work until it is back (by ${result.until})`, EXIT.refused, { until: result.until });
1569
1581
  return fail(write, json, "claim", result.reason, `held by ${result.by} until ${result.until}`, EXIT.refused, { holder: result.by, until: result.until });
@@ -3562,6 +3574,13 @@ async function reconcileCommand(flags, context) {
3562
3574
  remotes.close();
3563
3575
  }
3564
3576
  const landedGone = landed.steps.filter(one => one.outcome === "removed");
3577
+ // Tidy (task-tidy.md): old routine runs a newer run replaces, release tasks already published or replaced, and CI
3578
+ // notices main has fixed are put away, at most a bounded number a pass, each with its reason in the ledger.
3579
+ let tidied = [];
3580
+ try {
3581
+ tidied = runTidy(store, clock(), releaseFactsFor(context.databaseFile), TIDY_PASS_LIMIT, { principal: "operator", repos: [repo], includeUnplaced: false });
3582
+ }
3583
+ catch { /* the next pass tries again */ }
3565
3584
  // Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
3566
3585
  // the task is complete or cancelled); their branches stay. Each removal is in the ledger.
3567
3586
  // Completing or cancelling a task stops anything still running from its checkout first.
@@ -3605,6 +3624,7 @@ async function reconcileCommand(flags, context) {
3605
3624
  slimmed.slimmed.length === 0 &&
3606
3625
  landed.completed.length === 0 &&
3607
3626
  landedGone.length === 0 &&
3627
+ tidied.length === 0 &&
3608
3628
  liveSwept.removed.length === 0;
3609
3629
  return succeed(write, json, "reconcile", {
3610
3630
  recovered,
@@ -3616,6 +3636,7 @@ async function reconcileCommand(flags, context) {
3616
3636
  processesStopped: [...closedRuns.map(one => ({ run: one.runId, pids: one.done.stopped.map(row => row.pid) })), ...closedCheckouts.map(one => ({ checkout: one.path, pids: one.done.stopped.map(row => row.pid) }))],
3617
3637
  checkoutsSlimmed: slimmed.slimmed.map(one => one.path),
3618
3638
  landed: { completed: landed.completed, steps: landed.steps },
3639
+ tidied: tidied.map(one => ({ id: one.taskId, rule: one.rule, reason: one.reason })),
3619
3640
  liveViewsSwept: liveSwept.removed.length,
3620
3641
  retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
3621
3642
  storageSweep: storageSwept,
@@ -3630,6 +3651,7 @@ async function reconcileCommand(flags, context) {
3630
3651
  ...closedRuns.map(one => `Run #${one.runId} ended; stopped ${stoppedWords(one.done)}.`),
3631
3652
  ...closedCheckouts.map(one => `${one.path}: its task finished; stopped ${stoppedWords(one.done)}.`),
3632
3653
  ...(landed.completed.length === 0 ? [] : [`Marked ${landed.completed.length} landed result(s) complete: ${landed.completed.join(", ")}.`]),
3654
+ ...(tidied.length === 0 ? [] : [`Put away ${tidied.length} old task(s): ${tidied.map(one => one.taskId).join(", ")}.`]),
3633
3655
  ...(landedGone.length === 0 ? [] : [`Removed landed work: ${landedGone.filter(one => one.kind === "checkout").length} checkout(s), ${landedGone.filter(one => one.kind === "branch").length} local branch(es).`]),
3634
3656
  ...(slimmed.slimmed.length === 0 ? [] : [`Dropped dependencies and build output from ${slimmed.slimmed.length} kept checkout(s), about ${bytesWords(slimmed.slimmed.reduce((sum, one) => sum + one.bytes, 0))}.`]),
3635
3657
  ...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
@@ -4112,6 +4134,7 @@ async function requeueTask(positional, flags, context) {
4112
4134
  published: `${id}'s last result is published; revise it through its pull request`,
4113
4135
  // Only an Approve & build again asks for an exact build; the CLI never does.
4114
4136
  moved: `${id} changed since its page was read; read it again`,
4137
+ archived: `${id} is put away — restore it with \`toolroll task restore ${id}\` first`,
4115
4138
  };
4116
4139
  return fail(write, json, "task requeue", result.reason, why[result.reason], EXIT.refused);
4117
4140
  }
@@ -8567,6 +8590,12 @@ function taskAction(action, rest, flags, context) {
8567
8590
  return addTestsTask(rest, flags, context);
8568
8591
  case "list":
8569
8592
  return listTasks(flags, context);
8593
+ case "archive":
8594
+ return archiveTasksCommand(rest, flags, context);
8595
+ case "restore":
8596
+ return restoreTasksCommand(rest, flags, context);
8597
+ case "tidy":
8598
+ return tidyTasksCommand(rest, flags, context);
8570
8599
  case "show":
8571
8600
  return showTask(rest, context);
8572
8601
  case "wait":
@@ -8988,9 +9017,9 @@ function listTasks(flags, context) {
8988
9017
  const limit = rawLimit === undefined ? 40 : Number(rawLimit);
8989
9018
  if (wanted !== undefined && !STATES.includes(wanted))
8990
9019
  return fail(write, json, 'task list', 'usage', `--state takes one of ${STATES.join(', ')}`, EXIT.usage);
8991
- if (rawView !== undefined && !['all', 'needs-you', 'running', 'completed'].includes(rawView) ||
9020
+ if (rawView !== undefined && !['all', 'needs-you', 'running', 'completed', 'archived'].includes(rawView) ||
8992
9021
  !Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
8993
- return fail(write, json, 'task list', 'usage', '--view takes all, needs-you, running or completed; --limit takes 1–100', EXIT.usage);
9022
+ return fail(write, json, 'task list', 'usage', '--view takes all, needs-you, running, completed or archived; --limit takes 1–100', EXIT.usage);
8994
9023
  }
8995
9024
  // A remote person limited to some projects (remoteLensOf) pages and counts only theirs.
8996
9025
  const lens = context.principal?.lens ?? null;
@@ -9017,7 +9046,8 @@ function listTasks(flags, context) {
9017
9046
  write(wanted === undefined ? 'The task list is empty.' : `Nothing is ${wanted}.`);
9018
9047
  const width = Math.max(0, ...tasks.map(task => task.id.length));
9019
9048
  for (const task of tasks)
9020
- write(` ${task.id.padEnd(width)} ${task.status.label.padEnd(16)} ${task.title}`);
9049
+ write(task.archived === undefined ? ` ${task.id.padEnd(width)} ${task.status.label.padEnd(16)} ${task.title}`
9050
+ : ` ${task.id.padEnd(width)} ${'Archived'.padEnd(16)} ${task.title} — by ${task.archived.byKind === 'tidy' ? 'tidy' : task.archived.by}: ${task.archived.reason}`);
9021
9051
  if (page.nextCursor !== null)
9022
9052
  write(`Next page: --cursor ${page.nextCursor}`);
9023
9053
  return EXIT.ok;
@@ -10149,6 +10179,8 @@ function describeApproveFailure(reason, id) {
10149
10179
  // Approval policy, phase 1: the lead's token (or a remote API token) proves a credential, not a person.
10150
10180
  if (reason === "machine-proof")
10151
10181
  return "only a person can approve this — approve it on the task page or from your paired chat";
10182
+ if (reason === "archived")
10183
+ return `${id} is put away — restore it with \`toolroll task restore ${id}\` first`;
10152
10184
  if (reason === "second-approver")
10153
10185
  return `your approval of ${id} is recorded; it is protected work, so a second person needs to approve it (toolroll task approve ${id} as them)`;
10154
10186
  if (reason === "no-approvers") {
@@ -12754,3 +12786,180 @@ export function consoleIdentitiesInBackground(identities, paths) {
12754
12786
  export function writeModelRow(store, proved, scope, knownProject, slot, to, now, extra = []) {
12755
12787
  return applyModelChoices(store, { scope, change: slot === null ? [] : [{ slot, to }], expected: null, strict: false, knownProject, extra }, { ...proved, live: () => true }, now);
12756
12788
  }
12789
+ /** Putting away and restoring are a person's acts (docs/plans/task-tidy.md): never the lead's token, never a remote
12790
+ * API token, never a machine proof. Null when a person is acting. */
12791
+ function refuseMachineTidy(context, command, method) {
12792
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || (method !== undefined && !personProof(method))) {
12793
+ return fail(context.write, context.json, command, "person-only", "Only a person puts tasks away or restores them. Use the console or your own login.", EXIT.refused);
12794
+ }
12795
+ return null;
12796
+ }
12797
+ /** `toolroll task archive <id...> [--reason <text>]`: put tasks away by hand (v145). Each keeps its history, results and
12798
+ * approvals; nothing can run, claim or approve it until it is restored. Credentialed, a person's act. */
12799
+ async function archiveTasksCommand(positional, flags, context) {
12800
+ const { store, write, json, clock } = context;
12801
+ const command = "task archive";
12802
+ const ids = [...new Set(positional)];
12803
+ if (ids.length === 0 || ids.length > 100)
12804
+ return fail(write, json, command, "usage", "`toolroll task archive <id...> [--reason <why>] --as <you> --token <t>` — 1 to 100 tasks", EXIT.usage);
12805
+ const machine = refuseMachineTidy(context, command);
12806
+ if (machine !== null)
12807
+ return machine;
12808
+ const demo = refuseDemo(context, command);
12809
+ if (demo !== null)
12810
+ return demo;
12811
+ const acting = await askCredentials(flags, context);
12812
+ // The password is proved against a project the person can see (the first task's); each task's own project is
12813
+ // re-proved inside its put-away transaction.
12814
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, ids));
12815
+ if (acting === null || !verified?.ok)
12816
+ return refuseApprover(context, command, verified, "putting a task away takes `--as <you> --token <t>` — it is recorded against your name", ids[0]);
12817
+ const personal = refuseMachineTidy(context, command, verified.method);
12818
+ if (personal !== null)
12819
+ return personal;
12820
+ const reason = text(flags, "reason") ?? "Put away by hand";
12821
+ const archived = [], refused = [];
12822
+ for (const id of ids) {
12823
+ const ref = store.lookupRef(id);
12824
+ if (ref === null || store.getTask(id) === null || !store.accountCanAccess(acting.name, ref.repo)) {
12825
+ refused.push({ id, reason: "unknown-task", message: `No task ${id}.` });
12826
+ continue;
12827
+ }
12828
+ const done = archiveTaskFamily(store, id, { by: acting.name, byKind: "person", rule: null, reason }, clock(), { guard: refs => approverStandsNow(store, acting.name, familyRepoOf(store, refs)) });
12829
+ if (done.ok)
12830
+ archived.push({ id, versions: done.refs.length });
12831
+ else
12832
+ refused.push({ id, reason: done.reason, message: done.message });
12833
+ }
12834
+ const lines = () => [
12835
+ ...archived.map(one => `Put away ${one.id}${one.versions > 1 ? ` (${one.versions} versions)` : ""}.`),
12836
+ ...refused.map(one => `Not put away: ${one.message}`),
12837
+ ...(archived.length === 0 ? [] : [`Restore with \`toolroll task restore ${archived.map(one => one.id).join(" ")}\`.`]),
12838
+ ];
12839
+ if (refused.length === 0)
12840
+ return succeed(write, json, command, { archived, refused }, lines);
12841
+ if (json)
12842
+ return fail(write, json, command, refused[0].reason, refused[0].message, EXIT.refused, { archived, refused });
12843
+ write(lines().join("\n"));
12844
+ return EXIT.refused;
12845
+ }
12846
+ /** `toolroll task restore <id...>`: bring put-away tasks back exactly as they were (v145). Credentialed, a person's act. */
12847
+ async function restoreTasksCommand(positional, flags, context) {
12848
+ const { store, write, json, clock } = context;
12849
+ const command = "task restore";
12850
+ const ids = [...new Set(positional)];
12851
+ if (ids.length === 0 || ids.length > 100)
12852
+ return fail(write, json, command, "usage", "`toolroll task restore <id...> --as <you> --token <t>` — 1 to 100 tasks", EXIT.usage);
12853
+ const machine = refuseMachineTidy(context, command);
12854
+ if (machine !== null)
12855
+ return machine;
12856
+ const demo = refuseDemo(context, command);
12857
+ if (demo !== null)
12858
+ return demo;
12859
+ const acting = await askCredentials(flags, context);
12860
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, ids));
12861
+ if (acting === null || !verified?.ok)
12862
+ return refuseApprover(context, command, verified, "restoring takes `--as <you> --token <t>` — it is recorded against your name", ids[0]);
12863
+ const personal = refuseMachineTidy(context, command, verified.method);
12864
+ if (personal !== null)
12865
+ return personal;
12866
+ const restored = [], refused = [];
12867
+ for (const id of ids) {
12868
+ const ref = store.lookupRef(id);
12869
+ if (ref === null || store.getTask(id) === null || !store.accountCanAccess(acting.name, ref.repo)) {
12870
+ refused.push({ id, reason: "unknown-task", message: `No task ${id}.` });
12871
+ continue;
12872
+ }
12873
+ const done = restoreTaskFamily(store, id, acting.name, clock(), refs => approverStandsNow(store, acting.name, familyRepoOf(store, refs)));
12874
+ if (done.ok)
12875
+ restored.push(id);
12876
+ else
12877
+ refused.push({ id, reason: done.reason, message: done.message });
12878
+ }
12879
+ const lines = () => [...restored.map(id => `Restored ${id}.`), ...refused.map(one => `Not restored: ${one.message}`)];
12880
+ if (refused.length === 0)
12881
+ return succeed(write, json, command, { restored, refused }, lines);
12882
+ if (json)
12883
+ return fail(write, json, command, refused[0].reason, refused[0].message, EXIT.refused, { restored, refused });
12884
+ write(lines().join("\n"));
12885
+ return EXIT.refused;
12886
+ }
12887
+ /** `toolroll task tidy --preview [--days N]` (read-only, no credential) and `toolroll task tidy --apply --digest <d>
12888
+ * [--days N] [--except <id,...>]` (credentialed, a person's act): the console's Put away old tasks, word for word,
12889
+ * with the same preview and digest. Apply puts away exactly what the preview with that digest showed. */
12890
+ async function tidyTasksCommand(positional, flags, context) {
12891
+ const { store, write, json, clock } = context;
12892
+ const command = "task tidy";
12893
+ const preview = flag(flags, "preview"), apply = flag(flags, "apply");
12894
+ const days = tidyDays(text(flags, "days"));
12895
+ const digest = text(flags, "digest");
12896
+ const except = (text(flags, "except") ?? "").split(",").map(one => one.trim()).filter(one => one !== "");
12897
+ const usage = "`toolroll task tidy --preview [--days N]`, then `toolroll task tidy --apply --digest <d> [--days N] [--except <id,...>]`";
12898
+ if (positional.length > 0 || preview === apply || days === null || (preview && (digest !== undefined || except.length > 0)) || (apply && digest === undefined))
12899
+ return fail(write, json, command, "usage", usage, EXIT.usage);
12900
+ const releases = releaseFactsFor(context.databaseFile);
12901
+ // One scope for the preview and the apply: `--as <you>` previews exactly your projects (no password: it reads), and
12902
+ // the apply reads the same person's projects, so an unchanged list keeps its digest.
12903
+ // Whose lists: `--as <you>`, else this machine's saved login (`toolroll up`), else everything (an unrestricted
12904
+ // person's list; a person limited to some projects previews with --as).
12905
+ const previewAs = text(flags, "as") ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name;
12906
+ const daysArg = `${days === TIDY_DEFAULT_DAYS ? "" : ` --days ${days}`}${previewAs === undefined ? "" : ` --as ${previewAs}`}`;
12907
+ // `--as` only ever narrows: the named person's projects within the caller's own lens, never beyond it.
12908
+ const lens = context.principal?.lens ?? null;
12909
+ const own = { principal: "operator", repos: lens, includeUnplaced: lens === null };
12910
+ const access = previewAs !== undefined ? withinLens(accessOf(store, previewAs), lens) : own;
12911
+ if (preview) {
12912
+ const items = tidyPreview(store, clock(), { releases, days, access });
12913
+ const shown = tidyDigest(items, days);
12914
+ const groups = tidyGroups(items, days);
12915
+ const next = `toolroll task tidy --apply --digest ${shown}${daysArg}`;
12916
+ return succeed(write, json, command, { preview: true, days, digest: shown, count: items.length, groups, next: items.length === 0 ? null : next,
12917
+ ...(releases.problem === null ? {} : { releasesSkipped: releases.problem }) }, () => items.length === 0
12918
+ ? ["Nothing to put away."]
12919
+ : [`Put away old tasks: ${items.length} task${items.length === 1 ? "" : "s"}.`,
12920
+ ...groups.flatMap(group => ["", `${group.label} (${group.items.length})`, ...group.items.map(one => ` ${one.taskId} ${one.reason}${one.members.length > 1 ? ` (${one.members.length} versions)` : ""}`)]),
12921
+ ...(releases.problem === null ? [] : ["", `Release tasks were left out: ${releases.problem}.`]),
12922
+ "", `To put these away, run: ${next}`, "Leave some out with --except <id,...>.",
12923
+ ...(previewAs === undefined ? ["Limited to some projects? Preview with --as <you> so the list is yours."] : [])]);
12924
+ }
12925
+ const machine = refuseMachineTidy(context, command);
12926
+ if (machine !== null)
12927
+ return machine;
12928
+ const demo = refuseDemo(context, command);
12929
+ if (demo !== null)
12930
+ return demo;
12931
+ const acting = await askCredentials(flags, context);
12932
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, []));
12933
+ if (acting === null || !verified?.ok)
12934
+ return refuseApprover(context, command, verified, "putting tasks away takes `--as <you> --token <t>` — it is recorded against your name", undefined);
12935
+ const personal = refuseMachineTidy(context, command, verified.method);
12936
+ if (personal !== null)
12937
+ return personal;
12938
+ // The person's own view of the lists, from the verified credential (never a flag), within the caller's lens: the
12939
+ // same scope `--preview --as <them>` read.
12940
+ const mine = withinLens(accessOf(store, acting.name), lens);
12941
+ const shownItems = tidyPreview(store, clock(), { releases, days, access: mine });
12942
+ const only = except.length === 0 ? null : shownItems.map(one => one.taskId).filter(id => !except.includes(id));
12943
+ if (except.some(id => !shownItems.some(one => one.taskId === id)))
12944
+ return fail(write, json, command, "outside", "--except names a task that isn't in the list. Check it again with --preview.", EXIT.refused);
12945
+ const done = applyTidy(store, clock(), { by: acting.name, days, digest: digest, only, releases, access: mine });
12946
+ if (!done.ok) {
12947
+ const again = `toolroll task tidy --apply --digest ${done.digest}${daysArg}`;
12948
+ return fail(write, json, command, done.reason, done.reason === "changed" ? `${done.message} Look again with \`toolroll task tidy --preview${daysArg}\`.` : done.message, EXIT.refused, { digest: done.digest, count: done.items.length, ...(done.reason === "changed" ? { next: done.items.length === 0 ? null : again } : {}) });
12949
+ }
12950
+ return succeed(write, json, command, { archived: done.archived.map(one => ({ id: one.taskId, rule: one.rule, reason: one.reason })), skipped: done.skipped }, () => [
12951
+ `Put away ${done.archived.length} task${done.archived.length === 1 ? "" : "s"}.`,
12952
+ ...done.skipped.map(one => `Not put away: ${one.message}`),
12953
+ "Find them with `toolroll task list --view archived`; bring one back with `toolroll task restore <id>`.",
12954
+ ]);
12955
+ }
12956
+ /** The project a put-away or restore proves the person's password against: the first named task's, when they can see
12957
+ * it; otherwise the first project they can see at all (null when none: an unrestricted person, or none to prove). */
12958
+ function firstVisibleRepo(store, name, ids) {
12959
+ for (const id of ids) {
12960
+ const repo = store.lookupRef(id)?.repo ?? null;
12961
+ if (repo !== null && store.accountCanAccess(name, repo))
12962
+ return repo;
12963
+ }
12964
+ return store.accountCanAccess(name, null) ? null : store.knownRepos().find(repo => store.accountCanAccess(name, repo)) ?? null;
12965
+ }
@@ -86,7 +86,9 @@ export function projectRunning(store, repo, now) {
86
86
  const chats = count("SELECT COUNT(*) AS n FROM mate_turn WHERE state IN ('queued','running') AND id IN (SELECT value FROM json_each(?))", json(d.turns));
87
87
  const steps = count("SELECT COUNT(*) AS n FROM flow_step_run WHERE state = 'running' AND card IN (SELECT value FROM json_each(?))", json(d.cards));
88
88
  const calls = count("SELECT COUNT(*) AS n FROM subagent_call WHERE state = 'running' AND subagent IN (SELECT value FROM json_each(?))", json(d.subagents));
89
- const merges = count(`SELECT COUNT(*) AS n FROM merge_intent WHERE state IN ('claimed','firing') AND publication IN (SELECT id FROM publication WHERE task_ref IN (SELECT value FROM json_each(?)))`, json(d.refs));
89
+ const merges = count(`SELECT COUNT(*) AS n FROM merge_intent WHERE state IN ('claimed','firing') AND publication IN (SELECT id FROM publication WHERE task_ref IN (SELECT value FROM json_each(?)))`, json(d.refs))
90
+ // A person's merge between its last check and GitHub's answer (v145); a row older than 15 minutes is a crash.
91
+ + count("SELECT COUNT(*) AS n FROM merge_in_flight WHERE at > ? AND task_ref IN (SELECT value FROM json_each(?))", new Date(now.getTime() - 15 * 60_000).toISOString(), json(d.refs));
90
92
  const checkouts = count("SELECT COUNT(*) AS n FROM worktree WHERE repo = ? AND runner IS NOT NULL AND released_at IS NULL", repo);
91
93
  return [
92
94
  ...(building > 0 ? [`${plural(building, "task")} ${building === 1 ? "is" : "are"} being built`] : []),
@@ -267,6 +269,8 @@ function deleteRows(store, repo, d, now) {
267
269
  del("skill_test", `${IN("task_ref")} OR ${IN("source_run")}`, T, R);
268
270
  del("merge_blocker", `${IN("publication")} OR ${IN("task_id")}`, publications, K);
269
271
  del("merge_intent", IN("publication"), publications);
272
+ del("merge_in_flight", IN("publication"), publications);
273
+ del("deploy_in_flight", IN("task_ref"), T);
270
274
  del("publication", IN("id"), publications);
271
275
  del("artifact", IN("id"), A);
272
276
  // Tasks and their versions.
@@ -282,7 +286,7 @@ function deleteRows(store, repo, d, now) {
282
286
  del("workflow_preview", "repo = ?", repo);
283
287
  del("coding_handoff_scope", `id IN (SELECT id FROM coding_handoff WHERE json_extract(payload, '$.repo') = ?)`, repo);
284
288
  del("coding_handoff", "json_extract(payload, '$.repo') = ?", repo);
285
- for (const table of ["task_act", "task_replacement"])
289
+ for (const table of ["task_act", "task_replacement", "task_archive"])
286
290
  del(table, IN("task_ref"), T);
287
291
  del("task_ref", IN("id"), T);
288
292
  del("task_scope", IN("task_id"), K);
@@ -293,7 +297,7 @@ function deleteRows(store, repo, d, now) {
293
297
  del("flow_trigger", IN("flow"), F);
294
298
  del("flow_unattended_run", `grant_id IN (SELECT id FROM flow_unattended_grant WHERE ${IN("flow")})`, F);
295
299
  del("flow_unattended_grant", IN("flow"), F);
296
- for (const table of ["flow_card_watcher", "flow_comment", "flow_event", "flow_mail", "flow_step_run"])
300
+ for (const table of ["flow_card_watcher", "flow_comment", "flow_event", "flow_mail", "flow_step_run", "flow_step_task"])
297
301
  del(table, IN("card"), C);
298
302
  del("subagent_question", IN("id"), questions);
299
303
  del("subagent_call", `${IN("subagent")} OR ${IN("card")}`, M, C);
@@ -14,7 +14,7 @@
14
14
  * read must show the PR open, on the accepted commit, with checks passing; GitHub's head-commit match guards
15
15
  * the race; the branch is deleted; the merge commit and who merged are recorded in the ledger.
16
16
  */
17
- import { createHash } from "node:crypto";
17
+ import { createHash, randomUUID } from "node:crypto";
18
18
  import { run as execRun } from "./exec.js";
19
19
  import { bodyHashOf, publicationBody, publicationGrantOf } from "./publish.js";
20
20
  import { failingChecks, summarizeChecks } from "./pulls.js";
@@ -23,7 +23,7 @@ import { requestResultChanges } from "./result-actions.js";
23
23
  import { revisionSourceOf } from "./result-review.js";
24
24
  import { authenticateApprover, personProof } from "./scope.js";
25
25
  import { BRANCH_PREFIX, headWithin } from "./names.js";
26
- import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, releaseTaskRef } from "./store.js";
26
+ import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, refArchived, releaseTaskRef } from "./store.js";
27
27
  import { belongsToRelease } from "./release-authority.js";
28
28
  import { autoReleaseSealed } from "./release-auto.js";
29
29
  import { fullCheckGate, requestFollowUpChecks } from "./result-follow-ups.js";
@@ -292,6 +292,9 @@ export async function followPullRequests(store, seen, options) {
292
292
  continue;
293
293
  const pr = publication.prNumber;
294
294
  const taskId = taskIdOf(store, publication);
295
+ // A put-away task (v145) gets no automatic revision, merge or ready notice: restoring it resumes following.
296
+ if (refArchived(store, publication.taskRef))
297
+ continue;
295
298
  if (one.remoteState === "MERGED") {
296
299
  updateFollow(store, publication.id, { merge_commit: one.mergeCommit ?? "", merged_by: "GitHub", merged_at: clock().toISOString() }, clock());
297
300
  continue;
@@ -397,6 +400,8 @@ export async function mergePullRequest(store, input) {
397
400
  // The nightly check of main (release-once.md, part 3) is a check only: it never merges.
398
401
  const mergedRun = store.getRun(input.runId);
399
402
  const mergedTask = mergedRun === null ? null : store.refById(mergedRun.taskRef)?.externalId ?? null;
403
+ if (mergedRun !== null && refArchived(store, mergedRun.taskRef))
404
+ return refuse("archived", "This task is put away. Restore it before merging.");
400
405
  if (mergedTask !== null && store.handle.prepare("SELECT 1 AS hit FROM nightly_check WHERE task = ?").get(mergedTask) !== undefined)
401
406
  return refuse("check-only", "The nightly check of main is only a check; it never merges.");
402
407
  // A release's task merges only through `toolroll release` (release-once.md), which proves the live release, reserves
@@ -470,8 +475,34 @@ export async function mergePullRequest(store, input) {
470
475
  return fail("moved", "The pull request has commits that weren't part of the completed result, so it won't be merged from here.");
471
476
  if (before.checks !== "passing")
472
477
  return fail("checks", before.checks === "failing" ? "Checks are failing, so it can't merge." : before.checks === "running" ? "Checks are still running. Merge once they pass." : "No checks have reported yet. Merge once they pass.");
473
- const merged = await exec("gh", ["pr", "merge", pr, "--repo", publication.githubRepo, `--${method}`, "--match-head-commit", publication.headSha, "--delete-branch"], { timeoutMs: EXEC_TIMEOUT_MS, env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" } });
474
- const after = await view();
478
+ // The merge is reserved with the put-away check in one write (v145): a put away that committed first refuses it, and
479
+ // one that comes after waits for GitHub's answer (the reservation counts as work going on).
480
+ // One merge at a time, each owning its reservation by its token: a second caller is refused, and each releases only its own.
481
+ const token = randomUUID();
482
+ const reserved = store.transact(() => {
483
+ if (refArchived(store, publication.taskRef))
484
+ return "archived";
485
+ if (store.handle.prepare("SELECT 1 FROM hold WHERE task_ref = ? AND owner_id LIKE 'project-delete:%' AND (until IS NULL OR until > ?)").get(publication.taskRef, clock().toISOString()) !== undefined)
486
+ return "deleting";
487
+ if (store.handle.prepare("SELECT 1 FROM merge_in_flight WHERE publication = ? AND at > ?").get(publication.id, new Date(clock().getTime() - 15 * 60_000).toISOString()) !== undefined)
488
+ return "merging";
489
+ store.handle.prepare("INSERT OR REPLACE INTO merge_in_flight (publication, task_ref, token, by, at) VALUES (?, ?, ?, ?, ?)").run(publication.id, publication.taskRef, token, input.by, clock().toISOString());
490
+ return "ok";
491
+ });
492
+ if (reserved === "archived")
493
+ return refuse("archived", "This task is put away. Restore it before merging.");
494
+ if (reserved === "deleting")
495
+ return refuse("deleting", "This task's project is being deleted, so nothing merges.");
496
+ if (reserved === "merging")
497
+ return refuse("merging", "This pull request is already being merged. Wait for that to finish.");
498
+ let merged, after;
499
+ try {
500
+ merged = await exec("gh", ["pr", "merge", pr, "--repo", publication.githubRepo, `--${method}`, "--match-head-commit", publication.headSha, "--delete-branch"], { timeoutMs: EXEC_TIMEOUT_MS, env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" } });
501
+ after = await view();
502
+ }
503
+ finally {
504
+ store.handle.prepare("DELETE FROM merge_in_flight WHERE publication = ? AND token = ?").run(publication.id, token);
505
+ }
475
506
  if (after?.state === "MERGED") {
476
507
  record(after.commit, input.by);
477
508
  return { ok: true, commit: after.commit };
@@ -2,6 +2,7 @@ import { checkoutRunner, ranAtLine, widestRan } from "./invoke.js";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { refArchived } from "./task-archive.js";
5
6
  import { belongsToRelease } from "./release-authority.js";
6
7
  import { run as execRun } from "./exec.js";
7
8
  import { hardenedGit } from "./teardown-git.js";
@@ -126,6 +127,9 @@ function checkTarget(store, runId, level) {
126
127
  return refuse("no-commit", "This result has no finished commit to check.");
127
128
  if (ref.repo === null)
128
129
  return refuse("unplaced", "This result isn't in a project, so there is no check to run.");
130
+ // A put-away task (v145) runs no checks: asked for or already waiting, it is refused until restored.
131
+ if (refArchived(store, ref.id))
132
+ return refuse("archived", "This task is put away. Restore it first.");
129
133
  const chosen = checkCommandFor(store, ref.repo, level);
130
134
  if (chosen.command === null)
131
135
  return refuse("no-check", `${ref.repo} has no approved ${level === "quick" ? "quick or full" : "full"} check. Set one in Settings → Projects → Checks.`);
package/dist/scope.d.ts CHANGED
@@ -597,6 +597,11 @@ export type ApproveResult = {
597
597
  | {
598
598
  ok: false;
599
599
  reason: "machine-proof";
600
+ }
601
+ /** v145 (task-tidy.md): the task is put away; it is restored before anyone can approve it. */
602
+ | {
603
+ ok: false;
604
+ reason: "archived";
600
605
  };
601
606
  /**
602
607
  * Whether this name-and-token pair is a person the store knows. Shared by
package/dist/scope.js CHANGED
@@ -48,6 +48,7 @@ import { z } from "zod";
48
48
  import {} from "./contracts/acceptance-terms.js";
49
49
  import { parseContract } from "./contracts/contract.js";
50
50
  import { readAcceptance, readScopeTerms, storedRubricSchema } from "./contracts/scope.js";
51
+ import { taskArchived } from "./task-archive.js";
51
52
  import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
52
53
  import { NO_READINESS, exactModelId, legOf, projectRoute, routeDigestOf, routeFromJson, routeJsonProblem, routeWords, } from "./phase-routing.js";
53
54
  /** Same shape as a runner's credential, for the same reasons. */
@@ -1074,6 +1075,9 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1074
1075
  const scope = store.getScope(taskId);
1075
1076
  if (scope === null)
1076
1077
  return { ok: false, reason: "no-scope" };
1078
+ // A put-away task (v145) is restored first; sealScopeApproval refuses it too, on every road.
1079
+ if (taskArchived(store, taskId))
1080
+ return { ok: false, reason: "archived" };
1077
1081
  // The digest is required, not optional. An operator reads a scope, somebody
1078
1082
  // rewrites it, and an approval that did not name what it saw would agree to
1079
1083
  // the new one in silence — which is the whole failure this guards.
@@ -1472,6 +1476,9 @@ export function liveReleaseOf(store, taskId, action, now = new Date()) {
1472
1476
  return { applies: false };
1473
1477
  const { authority: a, carry } = found;
1474
1478
  const refuse = (why) => ({ applies: true, ok: false, why, authority: a });
1479
+ // A put-away task (v145, task-tidy.md), or a put-away anchor, deploys, merges and carries nothing: restore it first.
1480
+ if (taskArchived(store, taskId) || taskArchived(store, a.anchorTask))
1481
+ return refuse("the release task is put away");
1475
1482
  const newest = newestReleaseGeneration(store, a.repo, a.version, a.pr);
1476
1483
  if (newest === null || newest.id !== a.id)
1477
1484
  return refuse("a newer approval of this release replaced this one");
@@ -389,9 +389,11 @@ export function createChatHandlers(runtime) {
389
389
  const id = proposalId, saved = store.getLeadProposal(id), savedAction = saved?.kind === 'action' ? sharedActionPayload(saved.payload) : null;
390
390
  if (saved && savedAction && saved.state !== 'pending' && (sharedConversation !== null || store.getLeadThread(saved.thread)?.approver === principal.name) && principal.repos.includes(savedAction.repo) && store.accountCanAccess(principal.name, savedAction.repo)) {
391
391
  const task = typeof saved.outcome?.['taskId'] === 'string' ? saved.outcome['taskId'] : typeof savedAction.request['task'] === 'string' ? savedAction.request['task'] : null;
392
- const destination = task ? taskHref(task) : `/settings/${savedAction.operation.startsWith('skill_') ? 'skills' : 'knowledge'}?repo=${encodeURIComponent(savedAction.repo)}`;
392
+ // A flow action leads back to its flow (a page path of our own making, never the proposal's words).
393
+ const flowId = savedAction.operation.startsWith('flow_') && Number.isSafeInteger(savedAction.request['flow']) ? Number(savedAction.request['flow']) : null;
394
+ const destination = task ? taskHref(task) : flowId !== null ? `/flows/${flowId}` : `/settings/${savedAction.operation.startsWith('skill_') ? 'skills' : 'knowledge'}?repo=${encodeURIComponent(savedAction.repo)}`;
393
395
  const said = typeof saved.outcome?.['said'] === 'string' ? saved.outcome['said'] : saved.state === 'dismissed' ? 'Action dismissed.' : saved.state === 'expired' ? 'This proposal expired. Ask for a fresh proposal.' : 'This action has no completed outcome yet.';
394
- return sendScreen(response, 200, screen('Action outcome', html `<h1>${savedAction.title}</h1><p>${said}</p><a class="button-link" href="${destination}">${task ? 'Open task' : 'Open project settings'}</a>`, { chrome: chromeFor(savedAction.repo, 'chat') }));
396
+ return sendScreen(response, 200, screen('Action outcome', html `<h1>${savedAction.title}</h1><p>${said}</p><a class="button-link" href="${destination}">${task ? 'Open task' : flowId !== null ? 'Open flow' : 'Open project settings'}</a>`, { chrome: chromeFor(savedAction.repo, 'chat') }));
395
397
  }
396
398
  const review = mintSharedActionReview(store, principal, id, evidenceRoot, clock()), action = review.payload;
397
399
  const terms = action.terms.map(term => html `<p style="white-space:pre-wrap;overflow-wrap:anywhere">${term}</p>`);