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
@@ -2742,3 +2742,32 @@ html.so-swap::view-transition-old(*):only-child { animation: so-card-out 160ms e
2742
2742
 
2743
2743
  .result-feedback-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
2744
2744
  .result-feedback-actions button { min-height: 44px; white-space: nowrap; }
2745
+
2746
+ /* Put away old tasks (docs/plans/task-tidy.md): grouped checklist on the kit's column. */
2747
+ .put-away { max-width: var(--so-settings-width, 760px); }
2748
+ .put-away h1 { margin: 0 0 4px; }
2749
+ .put-away-days { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 16px; }
2750
+ .put-away-days label { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 14px; }
2751
+ .put-away-days input[type=number] { width: 5rem; margin: 0; }
2752
+ .put-away-days button { width: auto; }
2753
+ .put-away-group { margin: 0 0 20px; padding: 0; border: 0; }
2754
+ .put-away-group legend { display: flex; align-items: center; gap: 8px; padding: 0; margin: 0 0 6px; font-weight: 600; font-size: 15px; }
2755
+ .put-away-group legend .count { font-family: var(--so-mono, ui-monospace, monospace); font-size: 11px; font-weight: 500; color: var(--so-muted); }
2756
+ .put-away-group ul { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--so-line); }
2757
+ .put-away-group li { border-bottom: 1px solid var(--so-line); }
2758
+ .put-away-item { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 10px; align-items: start; margin: 0; padding: 10px 2px; cursor: pointer; }
2759
+ .put-away-item input { margin: 3px 0 0; width: auto; min-height: 0; align-self: start; }
2760
+ .put-away-title { font-size: 13.5px; font-weight: 500; overflow-wrap: anywhere; }
2761
+ .put-away-why { grid-column: 2; font-size: 12.5px; color: var(--so-muted); }
2762
+ .put-away-id { grid-column: 2; font-family: var(--so-mono, ui-monospace, monospace); font-size: 11.5px; color: var(--so-muted); overflow-wrap: anywhere; }
2763
+ .put-away-cli { margin: 12px 0 0; font-size: 13px; color: var(--so-muted); overflow-wrap: anywhere; }
2764
+ .put-away-cli code { font-size: 12px; }
2765
+ .task-archived { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; margin: 0 0 12px; padding: 10px 12px; border-radius: 8px; background: var(--so-raised); }
2766
+ .task-archived p { margin: 0; font-size: 13.5px; }
2767
+ .task-archived form { margin: 0; }
2768
+ .task-archived button { width: auto; }
2769
+ .work-row-status form.inline { margin: 0; }
2770
+ @media (max-width: 900px) {
2771
+ .put-away-item { min-height: 44px; }
2772
+ .put-away-days button, .task-archived button, .work-row-status form.inline button { min-height: 44px; }
2773
+ }
@@ -21,6 +21,7 @@ import { exportFlow, fetchFlowFile, FlowFileError, importFlow, parseFlowFile, pl
21
21
  import { addFlowTriggerTo, checkFlowTriggerNow, pressFlowButton, removeFlowTrigger, removeLinearKey, setFlowTriggerOn, renewFlowHook, saveHooksBase, saveLinearKey, saveSigningSecret, setUpFlowHook, changeFlowHookSender, setFlowHookScope, shareFlowButton, stopSharingFlowButton, rawTakesDeliveries, rawAdmitsAnyone } from "../flow-triggers.js";
22
22
  import { FLOW_IMPORT_SCRIPT, flowFallbackHtml, flowImportHtml, flowsListHtml, flowView } from "../flows-ui.js";
23
23
  import { FLOW_TEMPLATES, FlowContractError, validateFlowDefinition, withZoneNames } from "../flows.js";
24
+ import { revokeFlowBrowser, signFlowBrowser } from "../flow-browser.js";
24
25
  import { prepareUnattendedCandidates, parseUnattendedAsk, revokeUnattended, signUnattended, untilWords } from "../flow-unattended.js";
25
26
  import { keyStatus } from "../keys.js";
26
27
  import { kitPageHtml, kitsGalleryHtml } from "../kits-ui.js";
@@ -656,6 +657,18 @@ export function createFlowsHandlers(runtime) {
656
657
  if (action === "unattended-off") {
657
658
  return revokeUnattended(store, flow.id, who.name, now) ? settle("It no longer runs on its own. New cards wait for you.") : answer(409, { ok: false, said: "It wasn't running on its own." });
658
659
  }
660
+ if (action === "browser") {
661
+ // A person's own step-up (a lead's or remote run's token never gets here: route table), then signFlowBrowser
662
+ // re-reads the flow, its zones and scripts in its one write and refuses unless they are the digest the panel showed.
663
+ const proved = authenticateApprover(who, body.get("password") ?? "", flow.repo);
664
+ if (!proved.ok)
665
+ return answer(403, { ok: false, said: "That password isn't right. Nothing was approved." });
666
+ const signed = signFlowBrowser(store, { flow: flow.id, signer: who.name, proof: proved.method, sawDigest: body.get("digest") ?? "", console: { generation: who.via === "cookie" ? who.session.generation : -1 } }, now);
667
+ return signed.ok ? settle(`Browser allowed. ${signed.grant.zones.length === 1 ? "Its script" : "Its scripts"} can start Chromium now.`) : answer(409, { ok: false, said: signed.message });
668
+ }
669
+ if (action === "browser-off") {
670
+ return revokeFlowBrowser(store, flow.id, who.name, now) ? settle("Browser turned off. Its browser zones wait until someone allows it again.") : answer(409, { ok: false, said: "Browser use wasn't allowed for this flow." });
671
+ }
659
672
  if (action === "save") {
660
673
  let saved, drawn;
661
674
  try {
@@ -975,6 +988,8 @@ export function createFlowsHandlers(runtime) {
975
988
  "flow.secrets": flowAct("secrets"),
976
989
  "flow.unattended": flowAct("unattended"),
977
990
  "flow.unattended-off": flowAct("unattended-off"),
991
+ "flow.browser": flowAct("browser"),
992
+ "flow.browser-off": flowAct("browser-off"),
978
993
  "flow.triggers": flowAct("triggers"),
979
994
  "flow.card.move": cardAct("move"),
980
995
  "flow.card.decide": cardAct("decide"),
@@ -1,4 +1,5 @@
1
1
  /** Everyday console pages: home, inbox, board, work, projects, fleet and the queue. */
2
+ import { type TidyItem } from "../tidy.js";
2
3
  import { type TaskOrigin } from "../task-source.js";
3
4
  import { type Html } from "../html.js";
4
5
  import { type AssignmentSnapshot } from "../assignment.js";
@@ -295,6 +296,8 @@ export declare function workPage(chrome: Chrome, data: {
295
296
  now: Date;
296
297
  limits?: BrowserLimits | null;
297
298
  state?: TaskState | null;
299
+ /** An approver in a browser session: rows offer Put away (or Restore, archived), and the page offers Put away old tasks. */
300
+ canPutAway?: boolean;
298
301
  }): Screen;
299
302
  export declare function browsePage(chrome: Chrome, data: {
300
303
  at: string;
@@ -562,3 +565,13 @@ export type PeekAdmission = {
562
565
  epoch: string;
563
566
  entries: ReturnType<typeof parseBaseTreeSnapshot>;
564
567
  };
568
+ /** Put away old tasks (docs/plans/task-tidy.md): exactly what would be put away, grouped, each with why, every one
569
+ * ticked; one step-up puts away the ticked ones, bound to this preview's digest. The CLI twin is named beside it. */
570
+ export declare function putAwayPage(chrome: Chrome, data: {
571
+ items: readonly TidyItem[];
572
+ days: number;
573
+ digest: string;
574
+ now: Date;
575
+ problem: string | null;
576
+ releasesSkipped: string | null;
577
+ }): Screen;
@@ -1,5 +1,7 @@
1
1
  import { activityMetrics, activityScope, activityLimit } from '../project-activity.js';
2
2
  /** Everyday console pages: home, inbox, board, work, projects, fleet and the queue. */
3
+ import { archivedWords, tidyGroups, TIDY_DEFAULT_DAYS } from "../tidy.js";
4
+ import { stepUpForm } from "../settings-kit.js";
3
5
  import { originNote } from "../task-source.js";
4
6
  import { html, htmlString, joinHtml, postForm, replaceMarkup, reauth } from "../html.js";
5
7
  import {} from "../assignment.js";
@@ -632,14 +634,25 @@ export function workPage(chrome, data) {
632
634
  return `/work${query.size ? `?${query}` : ''}`;
633
635
  };
634
636
  const tabs = html `<nav class="work-views" aria-label="Task views">${WORK_VIEWS.map(one => html `<a href="${href(one.key)}"${one.key === data.view ? html ` class="active" aria-current="page"` : null}>${one.label}<span class="count">${data.work.totals[one.key]}</span></a>`)}</nav>`;
637
+ // Put away (or Restore, in Archived): one quiet act per row, never on a task with work going on.
638
+ const tidyAct = (row) => data.canPutAway !== true ? null : row.archived !== undefined ? { action: `${taskHref(row.rootId)}/restore`, label: 'Restore' }
639
+ : row.liveRunId !== null || row.unfinishedRunId !== null || row.status.views.includes('running') ? null : { action: `${taskHref(row.rootId)}/archive`, label: 'Put away' };
640
+ const returnTo = href(data.view, undefined);
635
641
  const rowHtml = (row) => {
642
+ if (row.archived !== undefined) {
643
+ const act = tidyAct(row);
644
+ const project = data.multiProject || row.repo === null ? html `<span class="project-label">${row.repo === null ? 'Unplaced' : projectName(row.repo)}</span>` : null;
645
+ return html `<article class="work-row" data-task="${row.rootId}" data-work-status="archived" data-work-views="archived"><div class="work-row-main"><a class="work-title" href="${taskHref(row.rootId)}">${row.title}</a><p class="work-meta">${project}<span>${relativeAge(row.archived.at, data.now)}</span><span class="mono">${row.rootId}</span></p><p class="work-detail" data-archived-by>${archivedWords(row.archived)}</p></div><div class="work-row-status">${act === null ? null : postForm(act.action, html `<button type="submit" class="secondary">${act.label}</button>`, { hidden: { return: returnTo }, attrs: { class: 'inline', 'data-tidy-act': 'restore' } })}</div></article>`;
646
+ }
636
647
  const target = row.primaryAction?.target;
637
648
  const actionHref = row.primaryAction?.code === 'open-result' && target?.runId != null
638
649
  ? `/review?result=${encodeURIComponent(target.taskId)}&run=${target.runId}`
639
650
  : browserWorkActionHref(row);
640
651
  const action = actionHref === null || row.primaryAction === null ? null : html `<a class="work-action" data-primary-action href="${actionHref}">${row.primaryAction.label} →</a>`;
641
652
  const project = data.multiProject || row.repo === null ? html `<span class="project-label">${row.repo === null ? 'Unplaced' : projectName(row.repo)}</span>` : null;
642
- return html `<article class="work-row" data-task="${row.rootId}" data-work-status="${row.status.token}" data-work-views="${row.status.views.join(' ')}"><div class="work-row-main"><a class="work-title" href="${taskHref(row.rootId)}">${row.title}</a><p class="work-meta">${project}<span>${relativeAge(row.updatedAt, data.now)}</span><span class="mono">${row.rootId}</span></p></div><div class="work-row-status">${statusLineHtml(row.status)}${action}${row.familyProblem === null ? null : html `<p class="problem">${row.familyProblem}</p>`}${row.status.views.includes('needs-you') && !['write-scope', 'approve-scope'].includes(row.primaryAction?.code ?? '') && row.status.detail !== row.status.label && row.status.detail !== row.familyProblem ? html `<p class="work-detail">${row.status.detail}</p>` : null}${workDiagnosticsHtml(row.status.diagnostics)}</div></article>`;
653
+ const act = tidyAct(row);
654
+ const putAway = act === null ? null : postForm(act.action, html `<button type="submit" class="quiet">${act.label}</button>`, { hidden: { return: returnTo }, attrs: { class: 'inline', 'data-tidy-act': 'put-away' } });
655
+ return html `<article class="work-row" data-task="${row.rootId}" data-work-status="${row.status.token}" data-work-views="${row.status.views.join(' ')}"><div class="work-row-main"><a class="work-title" href="${taskHref(row.rootId)}">${row.title}</a><p class="work-meta">${project}<span>${relativeAge(row.updatedAt, data.now)}</span><span class="mono">${row.rootId}</span></p></div><div class="work-row-status">${statusLineHtml(row.status)}${action}${putAway}${row.familyProblem === null ? null : html `<p class="problem">${row.familyProblem}</p>`}${row.status.views.includes('needs-you') && !['write-scope', 'approve-scope'].includes(row.primaryAction?.code ?? '') && row.status.detail !== row.status.label && row.status.detail !== row.familyProblem ? html `<p class="work-detail">${row.status.detail}</p>` : null}${workDiagnosticsHtml(row.status.diagnostics)}</div></article>`;
643
656
  };
644
657
  const current = WORK_VIEWS.find(one => one.key === data.view);
645
658
  const filtered = (data.state ?? null) !== null;
@@ -654,6 +667,7 @@ export function workPage(chrome, data) {
654
667
  const pages = !data.previous && data.work.nextCursor === null ? null : html `<nav class="row work-pagination" aria-label="Task pages">${data.previous ? html `<a class="button-link" href="${href(data.view)}">First page</a>` : null}${data.work.nextCursor === null ? null : html `<a class="button-link" rel="next" href="${href(data.view, data.work.nextCursor)}">Next page</a>`}</nav>`;
655
668
  const tools = html `<details class="work-tools"><summary>Work tools${CHEVRON_ICON}</summary><nav class="work-tools-menu">${[['/inbox', 'Inbox'], ['/board', 'Board'], ['/board?view=order', 'Order'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/workbench', 'Portfolio']]), ['/ledger', 'Action ledger']]
656
669
  .map(([path, label]) => html `<a href="${path}">${label}</a>`)}</nav></details>`;
670
+ const putAwayLink = data.canPutAway === true && data.view !== 'archived' ? { label: 'Put away old tasks', href: '/work/put-away' } : null;
657
671
  const toolLinks = [['/inbox', 'Inbox'], ['/board', 'Board'], ['/board?view=order', 'Order'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/workbench', 'Portfolio']])];
658
672
  const view = {
659
673
  kind: 'tasks',
@@ -667,6 +681,15 @@ export function workPage(chrome, data) {
667
681
  const actionHref = row.primaryAction?.code === 'open-result' && target?.runId != null
668
682
  ? `/review?result=${encodeURIComponent(target.taskId)}&run=${target.runId}`
669
683
  : browserWorkActionHref(row);
684
+ const act = tidyAct(row);
685
+ const tidy = act === null ? null : { action: act.action, label: act.label, returnTo };
686
+ if (row.archived !== undefined)
687
+ return {
688
+ id: row.rootId, title: row.title, href: taskHref(row.rootId),
689
+ project: data.multiProject || row.repo === null ? (row.repo === null ? 'Unplaced' : projectName(row.repo)) : null,
690
+ age: relativeAge(row.archived.at, data.now), status: { label: 'Archived', tone: 'muted', token: 'archived' },
691
+ ask: null, chip: null, group: 'rest', action: null, detail: archivedWords(row.archived), problem: null, notes: [], tidy,
692
+ };
670
693
  const needsYouDetail = (row.status.views.includes('needs-you') || row.status.label === 'Failed') && !['write-scope', 'approve-scope'].includes(row.primaryAction?.code ?? '') && row.status.label !== 'Ready for review' && row.status.detail !== row.status.label && row.status.detail !== row.familyProblem;
671
694
  return {
672
695
  id: row.rootId, title: row.title, href: taskHref(row.rootId),
@@ -678,6 +701,7 @@ export function workPage(chrome, data) {
678
701
  detail: needsYouDetail ? row.status.detail : row.progress ?? null,
679
702
  problem: row.familyProblem,
680
703
  notes: (row.status.diagnostics ?? []).map(one => `${one.label} · ${one.detail}`),
704
+ tidy,
681
705
  };
682
706
  }),
683
707
  empty: data.work.items.length > 0 ? null : filtered && !data.previous ? { text: `No tasks are ${data.state} in this view.`, action: { label: "Show every state", href: href(data.view, undefined, null) } } : {
@@ -687,11 +711,12 @@ export function workPage(chrome, data) {
687
711
  pages: { first: data.previous ? href(data.view) : null, next: data.work.nextCursor === null ? null : href(data.view, data.work.nextCursor) },
688
712
  tools: toolLinks.map(([path, label]) => ({ label: label, href: path })),
689
713
  newTask: { label: 'New task', href: '/tasks/new' },
714
+ putAway: putAwayLink,
690
715
  limits: data.limits ?? null,
691
716
  states: [{ label: 'Any state', href: href(data.view, undefined, null), active: (data.state ?? null) === null },
692
717
  ...TASK_STATES.map(one => ({ label: one.charAt(0).toUpperCase() + one.slice(1), href: href(data.view, undefined, one), active: data.state === one }))],
693
718
  };
694
- return screen('work', html `<div class="work-head"><h1>Tasks</h1>${tools}</div>${limitsHtml(data.limits ?? null)}${tabs}${stateLinks}${list}${pages}`, { chrome, workspace: { view } });
719
+ return screen('work', html `<div class="work-head"><h1>Tasks</h1>${putAwayLink === null ? null : html `<a class="button-link" href="${putAwayLink.href}" data-put-away-old>${putAwayLink.label}</a>`}${tools}</div>${limitsHtml(data.limits ?? null)}${tabs}${stateLinks}${list}${pages}`, { chrome, workspace: { view } });
695
720
  }
696
721
  export function browsePage(chrome, data) {
697
722
  const crumb = data.at === data.root ? projectName(data.root) : `${projectName(data.root)}${data.at.slice(data.root.length)}`;
@@ -1320,3 +1345,21 @@ export function nextPage(chrome, data) {
1320
1345
  }
1321
1346
  return screen("next", joinHtml([header, card], "\n"), { chrome });
1322
1347
  }
1348
+ /** Put away old tasks (docs/plans/task-tidy.md): exactly what would be put away, grouped, each with why, every one
1349
+ * ticked; one step-up puts away the ticked ones, bound to this preview's digest. The CLI twin is named beside it. */
1350
+ export function putAwayPage(chrome, data) {
1351
+ const groups = tidyGroups(data.items, data.days);
1352
+ const count = data.items.length;
1353
+ const daysArg = data.days === TIDY_DEFAULT_DAYS ? "" : ` --days ${data.days}`;
1354
+ const crumbs = html `<nav class="kit-crumbs" aria-label="Breadcrumb"><ol><li><a href="/work">Tasks</a></li><li><span aria-current="page">Put away old tasks</span></li></ol></nav>`;
1355
+ const daysForm = html `<form method="get" action="/work/put-away" class="put-away-days"><label>Also finished tasks untouched for <input type="number" name="days" min="1" max="365" value="${String(data.days)}" inputmode="numeric"> days</label><button type="submit" class="secondary">Update list</button></form>`;
1356
+ const list = joinHtml(groups.map(group => html `<fieldset class="put-away-group" data-tidy-group="${group.rule}"><legend>${group.label}<span class="count">${String(group.items.length)}</span></legend><ul>${group.items.map(item => html `<li><label class="put-away-item" data-tidy-task="${item.taskId}"><input type="checkbox" name="task" value="${item.taskId}" checked><span class="put-away-title">${item.title}</span><span class="put-away-why">${item.reason}</span><span class="put-away-id">${item.taskId}${item.repo === null ? "" : ` · ${projectName(item.repo)}`}${item.members.length > 1 ? ` · ${item.members.length} versions` : ""}</span></label></li>`)}</ul></fieldset>`));
1357
+ const cli = html `<p class="put-away-cli" data-tidy-cli>Or run: <code>toolroll task tidy --apply --digest ${data.digest}${daysArg}</code></p>`;
1358
+ const body = count === 0
1359
+ ? html `<p class="kit-state">Nothing to put away right now.</p><p><a href="/work?view=archived">See archived tasks →</a></p>`
1360
+ : stepUpForm({ proof: "tasks-put-away", action: "/work/put-away", id: "put-away", hidden: { digest: data.digest, days: data.days },
1361
+ body: html `${list}${cli}`, save: { label: "Put away selected", idle: `${count} ${count === 1 ? "task" : "tasks"} selected` }, confirm: `Put away the selected tasks. You can restore any of them from Archived.` });
1362
+ return screen("Put away old tasks", html `<div class="put-away">${crumbs}<h1>Put away old tasks</h1>\
1363
+ <p class="kit-state">${count === 0 ? "Old releases, routine runs and fixed CI notices are put away automatically." : `${count} ${count === 1 ? "task" : "tasks"} can be put away. They leave every list and keep their history.`}</p>\
1364
+ ${data.problem ? html `<p class="kit-problem" role="alert">${data.problem}</p>` : ""}${data.releasesSkipped === null ? "" : html `<p class="kit-hint">Release tasks were left out: ${data.releasesSkipped}.</p>`}${daysForm}${body}</div>`, { chrome });
1365
+ }
@@ -438,6 +438,14 @@ export declare function taskBodyParts(data: {
438
438
  /** Markup the caller rendered (the version history). */
439
439
  history?: Html;
440
440
  versionLabel?: string | null;
441
+ /** Put away (v145): by whom and why; and whether the viewer can put it away or restore it (an approver's browser). */
442
+ archived?: {
443
+ by: string;
444
+ byKind: "person" | "tidy";
445
+ reason: string;
446
+ at: string;
447
+ } | null;
448
+ canPutAway?: boolean;
441
449
  status?: DisplayStatus;
442
450
  task: Task;
443
451
  /** Shared read-side lifecycle answer; the atomic claim still re-proves it. */
@@ -1,4 +1,6 @@
1
1
  /** Task pages: approval, agents, plans, controls, decisions and the task body. */
2
+ import { archivedWords } from "../tidy.js";
3
+ import { whenUtc } from "../when-html.js";
2
4
  import { networkWords } from "../egress.js";
3
5
  import { releaseAsksAgainWords, releaseTermsSentence } from "../scope.js";
4
6
  import { RELEASE_CI_RUNNING_WORDS } from "../release-ci-hold.js";
@@ -774,6 +776,8 @@ export function approveRefusalWords(reason) {
774
776
  return gateWords({ verdict: "refuse", reason });
775
777
  if (reason === "machine-proof")
776
778
  return "not approved: only a person can approve this. Approve it on the task page or from your paired chat";
779
+ if (reason === "archived")
780
+ return "not approved: this task is put away. Restore it first";
777
781
  if (reason === "unrouted")
778
782
  return "not approved: this scope predates agent routing and its old approval no longer stands — edit and re-file the scope so it is routed under today’s agents, then approve it";
779
783
  if (reason === "profile-unresolved")
@@ -2068,8 +2072,11 @@ export function taskBodyParts(data) {
2068
2072
  // The title is bare; the receipt or shared task status leads once.
2069
2073
  html `<div class="task-title-row"><h1 class="task-main-title">${data.rootTitle ?? task.title}</h1>${data.csrf === "" ? "" : taskViewSwitch(data.rootId ?? task.id, "overview")}</div>`,
2070
2074
  data.versionLabel == null ? "" : html `<p class="meta">${data.versionLabel} · <a href="${taskHref(data.rootId ?? task.id)}">Current work</a></p>`,
2075
+ data.archived == null ? "" : html `<div class="task-archived" data-archived><p>${archivedWords(data.archived)} · ${whenUtc(data.archived.at)}</p>${data.canPutAway === true && data.csrf !== ""
2076
+ ? postForm(`${taskHref(task.id)}/restore`, html `<button type="submit" class="secondary">Restore</button>`, { hidden: { return: taskHref(task.id) }, attrs: { class: "inline" } }) : ""}</div>`,
2071
2077
  (data.history ?? ""),
2072
- statusHtml,
2078
+ // A put-away task's banner is its status: no Needs you card, no act, no approval form (each would be refused).
2079
+ data.archived == null ? statusHtml : "",
2073
2080
  controlHtml,
2074
2081
  previousResult === null ? "" : html `<details class="task-previous-result"><summary>${previousResult.title}</summary>${previousResult.html}</details>`,
2075
2082
  progressCard,
@@ -2078,7 +2085,7 @@ export function taskBodyParts(data) {
2078
2085
  mirrorCard,
2079
2086
  problemHtml,
2080
2087
  needsScopeCard,
2081
- approvalHtml,
2088
+ data.archived == null ? approvalHtml : "",
2082
2089
  html `<details class="task-status-details" id="task-diagnostics"${optionsOpen ? html ` open` : ""}><summary>Task options</summary>${optionsHtml}</details>`,
2083
2090
  // Evidence-first (M5.5): what needs you, then what happened — decisions
2084
2091
  // and incidents above the attempt ledger and spend, the mechanics
@@ -2209,7 +2216,8 @@ export function taskBodyParts(data) {
2209
2216
  // The Details panel: the same facts, grouped and said once.
2210
2217
  const lastRunForAgent = liveRun ?? familyRuns.filter(run => run.role !== "reviewer").sort((a, b) => b.id - a.id)[0];
2211
2218
  const work = [];
2212
- work.push({ label: "Status", parts: [data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
2219
+ // A put-away task's one status is Archived (its banner says by whom and why).
2220
+ work.push({ label: "Status", parts: [data.archived != null ? "Archived" : data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
2213
2221
  if (data.repo !== null)
2214
2222
  work.push({ label: "Project", parts: [projectName(data.repo)] });
2215
2223
  if (data.base != null)
@@ -2320,6 +2328,7 @@ export function taskBodyParts(data) {
2320
2328
  ...(sectionIds.has("waits-for") ? [{ group: "work", label: "Dependencies", kind: "goto", tab: "plan", target: "waits-for" }] : []),
2321
2329
  ...(sectionIds.has("holds") ? [{ group: "work", label: "Holds", kind: "goto", tab: "plan", target: "holds" }] : []),
2322
2330
  ...(data.csrf !== "" ? [{ group: "work", label: "Task options", kind: "goto", tab: "plan", target: "task-diagnostics" }] : []),
2331
+ ...(data.canPutAway === true && data.csrf !== "" && data.archived == null && !building ? [{ group: "work", label: "Put away", kind: "post", action: `${taskHref(task.id)}/archive`, fields: { return: taskHref(task.id) } }] : []),
2323
2332
  { group: "share", label: "Copy link", kind: "copy" },
2324
2333
  ...(facts.some(one => one.label === "Audit") ? [{ group: "share", label: "Evidence pack", kind: "link", href: `${taskHref(rootOf)}/evidence` }] : []),
2325
2334
  ...(data.repo !== null && data.scope !== null ? [{ group: "share", label: "Reuse as recipe", kind: "link", href: `/recipes/from-task?task=${encodeURIComponent(task.id)}` }] : []),
@@ -2336,10 +2345,12 @@ export function taskBodyParts(data) {
2336
2345
  { label: "Ask", href: taskChatHref(data.rootId ?? task.id), active: false },
2337
2346
  ],
2338
2347
  version: data.versionLabel == null ? null : { label: data.versionLabel, current: { label: "Current work", href: taskHref(data.rootId ?? task.id) } },
2339
- status: statusCard === null ? null : inPlaceChecks(failedCard ? { ...statusCard, action: null, status: { ...statusCard.status, sentence: data.failure?.line ?? statusCard.status.sentence } }
2348
+ archived: data.archived == null ? null : { words: archivedWords(data.archived), at: data.archived.at,
2349
+ restore: data.canPutAway === true && data.csrf !== "" ? { action: `${taskHref(task.id)}/restore`, returnTo: taskHref(task.id) } : null },
2350
+ status: statusCard === null || data.archived != null ? null : inPlaceChecks(failedCard ? { ...statusCard, action: null, status: { ...statusCard.status, sentence: data.failure?.line ?? statusCard.status.sentence } }
2340
2351
  : progress !== null || record !== null ? { ...statusCard, action: statusCard.action?.href === record?.href ? null : statusCard.action, status: { ...statusCard.status, sentence: progress?.line ?? statusCard.status.sentence } }
2341
2352
  : statusCard.action !== null && rebuild === null && bareSelfLink(statusCard.action.href) ? { ...statusCard, action: null } : statusCard),
2342
- statusHtml: htmlString(statusHtml),
2353
+ statusHtml: data.archived != null ? "" : htmlString(statusHtml),
2343
2354
  failure: failedCard && data.failure != null ? { line: data.failure.line, evidence: data.failure.evidence, suggestion: data.failure.suggestion, link: data.failure.link } : null,
2344
2355
  retry,
2345
2356
  runChecks,
@@ -2352,7 +2363,7 @@ export function taskBodyParts(data) {
2352
2363
  ...(data.csrf !== "" && (data.steerRecent?.length ?? 0) > 0 ? { steerRecent: data.steerRecent } : {}),
2353
2364
  record,
2354
2365
  earlier,
2355
- approval: fragmentString(approvalHtml),
2366
+ approval: data.archived != null ? "" : fragmentString(approvalHtml),
2356
2367
  approvalPlacement: approvalLeads ? "lead" : "plan",
2357
2368
  more,
2358
2369
  confirmStopped: data.csrf !== "" && data.assignment?.primaryAction?.code === "confirm-stopped" && data.assignment.primaryAction.target.runId !== null
@@ -127,6 +127,9 @@ get("pages", "projects.page", "/projects", { limited: "unscoped", ...free });
127
127
  get("pages", "home", "/", { limited: "collection", ...free });
128
128
  get("tasks", "inbox", "/inbox", { limited: "collection", ...free });
129
129
  get("tasks", "work", "/work", { limited: "collection", ...free });
130
+ // Put away old tasks (task-tidy.md): the preview reads only; putting away is an approver's step-up in a browser session.
131
+ get("tasks", "work.put-away", "/work/put-away", { limited: "collection", ...free, callers: ["cookie"], ...approver("Only an approver can put tasks away.", "/work") });
132
+ post("tasks", "work.put-away.apply", "/work/put-away", { scope: "step-up", limited: "collection", ...approver("Only an approver can put tasks away.", "/work") });
130
133
  get("tasks", "next", "/next", { limited: "collection" });
131
134
  get("pages", "morning", "/morning");
132
135
  get("pages", "workbench", "/workbench", free);
@@ -270,6 +273,11 @@ for (const one of ["linear-key", "hooks-address", "secrets"])
270
273
  // "Runs on its own": signing raises authority (the owner's password, a step-up); revoking is one click for any approver.
271
274
  post("flows", "flow.unattended", `^\\/flows\\/${FLOW}\\/unattended$`, { scope: "step-up", project: "flow", sample: "/flows/1/unattended", ...EDIT_FLOWS });
272
275
  post("flows", "flow.unattended-off", `^\\/flows\\/${FLOW}\\/unattended-off$`, { project: "flow", sample: "/flows/1/unattended-off", ...EDIT_FLOWS });
276
+ // Browser use in a flow's script zones (flow-browser.ts): allowing it widens the sandbox, so a person's own step-up from
277
+ // their browser, carrying the approval digest they read; turning it off lowers authority, one click. Browser only: a
278
+ // lead's or remote run's token never reaches either (the CLI says the same).
279
+ post("flows", "flow.browser", `^\\/flows\\/${FLOW}\\/browser$`, { scope: "step-up", project: "flow", sample: "/flows/1/browser", ...EDIT_FLOWS });
280
+ post("flows", "flow.browser-off", `^\\/flows\\/${FLOW}\\/browser-off$`, { callers: ["cookie"], project: "flow", limited: "resource", sample: "/flows/1/browser-off", ...EDIT_FLOWS });
273
281
  post("flows", "flow.triggers", `^\\/flows\\/${FLOW}\\/triggers$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers", ...EDIT_FLOWS });
274
282
  for (const one of ["move", "decide", "cancel", "comment", "assign", "watch"])
275
283
  post("flows", `flow.card.${one}`, `^\\/flows\\/${FLOW}\\/cards\\/${FLOW}\\/${one}$`, { project: "flow", limited: "resource", sample: `/flows/1/cards/2/${one}`, ...EDIT_FLOWS });
@@ -408,6 +416,9 @@ post("tasks", "task.act.merge", `^\\/t\\/${TASK}\\/merge$`, { project: "task", l
408
416
  post("tasks", "task.act.confirm-stopped", `^\\/t\\/${TASK}\\/confirm-stopped$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/confirm-stopped", ...approver("Only an approver can confirm a build stopped.") });
409
417
  post("tasks", "task.act.stop-carrying", `^\\/t\\/${TASK}\\/stop-carrying$`, { project: "task", limited: "resource", sample: "/t/one/stop-carrying", ...approver("Only an approver can stop carrying a release.") });
410
418
  post("tasks", "task.act.stop", `^\\/t\\/${TASK}\\/stop$`, { project: "task", limited: "resource", sample: "/t/one/stop" });
419
+ // Putting one task away and bringing it back (task-tidy.md): a person's act, so only a browser session.
420
+ post("tasks", "task.act.archive", `^\\/t\\/${TASK}\\/archive$`, { project: "task", limited: "resource", sample: "/t/one/archive", callers: ["cookie"], ...approver("Only an approver can put a task away.") });
421
+ post("tasks", "task.act.restore", `^\\/t\\/${TASK}\\/restore$`, { project: "task", limited: "resource", sample: "/t/one/restore", callers: ["cookie"], ...approver("Only an approver can restore a task.") });
411
422
  post("tasks", "task.act.pause", `^\\/t\\/${TASK}\\/pause$`, { project: "task", limited: "resource", sample: "/t/one/pause" });
412
423
  post("tasks", "task.act.resume-arm", `^\\/t\\/${TASK}\\/resume-arm$`, { project: "task", limited: "resource", sample: "/t/one/resume-arm" });
413
424
  post("tasks", "task.act.resume", `^\\/t\\/${TASK}\\/resume$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/resume" });
@@ -21,6 +21,7 @@ import { newestPullRequestOf, publishingOf, pullRequestBlocker } from '../pull-r
21
21
  import { structuredHandoffView, terminalDiffView } from "../result-evidence-readers.js";
22
22
  import { resultCheckLevel } from '../result-follow-ups.js';
23
23
  import { skillsSnapshotHtml, skillTestFeedbackHtml } from "../skills-ui.js";
24
+ import { archiveOf } from "../store.js";
24
25
  import { runActivityOf } from "../task-activity.js";
25
26
  import { taskFingerprint } from "../task-live.js";
26
27
  import { pullRequestFactOf } from '../task-status.js';
@@ -792,6 +793,10 @@ export function createTaskViews(runtime) {
792
793
  const presentedData = { ...data, rootId: family?.root.id ?? taskId, rootTitle: family?.root.title ?? data.task.title,
793
794
  ...(family === null || data.assignment != null ? {} : { history: familyHistory(family) }),
794
795
  versionLabel: family !== null && family.current.id !== taskId ? `Viewing ${family.versions.findIndex(one => one.id === taskId) === 0 ? "Original" : `Revision ${family.versions.findIndex(one => one.id === taskId)}`} · ${family.current.state === "running" ? "A newer revision is running" : "A newer revision is current"}` : null };
796
+ // Put away (v145, task-tidy.md): who or what put it away and why, and whether this person can put it away or restore it.
797
+ const archived = archiveOf(runtime.store, taskId);
798
+ presentedData.archived = archived;
799
+ presentedData.canPutAway = who.via === "cookie" && who.role === "approver";
795
800
  if (scopeDraft !== undefined)
796
801
  presentedData.scopeDraft = scopeDraft;
797
802
  if (cancelDraft !== undefined)
@@ -2,6 +2,7 @@
2
2
  import { type Html } from "../html.js";
3
3
  import { type ServerResponse } from "node:http";
4
4
  import { type Artifact, type Run, type Store } from "../store.js";
5
+ import type { HandlerContext } from './handler-context.js';
5
6
  import type { ServerRuntime } from './runtime.js';
6
7
  import { type PeekAdmission } from "./render-pages.js";
7
8
  import { type CompletedWorkRow, type RankedReviewRow, type ReviewCockpitView } from "./render-results.js";
@@ -36,3 +37,12 @@ export declare function createTasksHandlers(runtime: ServerRuntime): {
36
37
  };
37
38
  liveRunIds: (rows: readonly (Pick<Run, "id" | "outcome" | "leaseId" | "taskRef">)[]) => Set<number>;
38
39
  };
40
+ /** Put away and Restore one task, and Put away old tasks (docs/plans/task-tidy.md): the preview reads only (GET-quiet);
41
+ * putting away many is an approver's step-up bound to the digest of exactly what the preview showed. Each write
42
+ * re-proves the person's standing inside its own transaction. */
43
+ export declare function createTidyHandlers(runtime: ServerRuntime): {
44
+ "work.put-away": (ctx: HandlerContext) => Promise<void>;
45
+ "work.put-away.apply": (ctx: HandlerContext) => Promise<void>;
46
+ "task.act.archive": (ctx: HandlerContext) => Promise<void>;
47
+ "task.act.restore": (ctx: HandlerContext) => Promise<void>;
48
+ };
@@ -1,5 +1,6 @@
1
1
  import { parseFeedbackImage, sameFeedbackImage } from "../result-review.js";
2
- import { dropFeedbackImage, releaseAuthorityOfTask, revokeRelease, runLiveCommands } from "../store.js";
2
+ import { archiveTaskFamily, dropFeedbackImage, releaseAuthorityOfTask, restoreTaskFamily, revokeRelease, runLiveCommands } from "../store.js";
3
+ import { applyTidy, approverStandsNow, familyRepoOf, releaseFactsOf, tidyDays, tidyDigest, tidyPreview, TIDY_DEFAULT_DAYS } from "../tidy.js";
3
4
  import { steerCapabilityOf } from "../steer-capabilities.js";
4
5
  import { canReveal, localCheckoutOf, revealInFinder } from "../checkout-proof.js";
5
6
  /** A form's way back to a flow card ("flow:<flow>:<card>"), read strictly: numbers only, never a path. */
@@ -67,7 +68,7 @@ import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
67
68
  import { requestContext } from "./request-context.js";
68
69
  import { boardTickScript, oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, transcriptScript, whenTime } from "./chrome.js";
69
70
  import { chatReturnWithSaid, matchTaskPath, redirect, respond, reviewHref, safeChatReturn, safeReturn, SAFETY, taskChatHref, taskHref } from "./http.js";
70
- import { boardBody, boardWorkers, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, workPage } from "./render-pages.js";
71
+ import { putAwayPage, boardBody, boardWorkers, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, workPage } from "./render-pages.js";
71
72
  import { runEfficiencyOf } from "../run-efficiency.js";
72
73
  import { timingFor } from "../latency-timeline.js";
73
74
  import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReviewQueue, RESULT_REFUSALS, REVIEW_QUEUE_CAP, reviewCockpitDetailParts, reviewCockpitPage, reviewPriorityOf, runFactsFragment, runOutcomeBadge, runPage, RUNS_PAGE, runsPage, withRefusal } from "./render-results.js";
@@ -278,6 +279,7 @@ export function createTasksHandlers(runtime) {
278
279
  multiProject: new Set(work.items.map(task => task.repo ?? "")).size > 1,
279
280
  now,
280
281
  state,
282
+ canPutAway: who.via === "cookie" && who.role === "approver",
281
283
  // v105: subscription windows and monthly budgets, for whoever runs the installation.
282
284
  limits: store.isInstanceOperator(who.name)
283
285
  ? limitsView(store.providerLimits(), store.budgets().length === 0 ? [] : store.monthSpendCached(now).budgets, { project: projectName, subagent: id => subagentNamesOf(store.handle).get(id) ?? `Subagent ${id}` }, now)
@@ -3057,7 +3059,9 @@ export function createTasksHandlers(runtime) {
3057
3059
  live.add(run.id);
3058
3060
  return live;
3059
3061
  }
3062
+ const tidyHandlers = createTidyHandlers(runtime);
3060
3063
  const registrations = handlersOf("tasks", {
3064
+ ...tidyHandlers,
3061
3065
  "code.page": showCode("workspace"),
3062
3066
  "code.session": showCode("session"),
3063
3067
  "code.state": showCode("state"),
@@ -3154,3 +3158,51 @@ export function createTasksHandlers(runtime) {
3154
3158
  }
3155
3159
  return { registrations, runListPane, peekFragment, taskMutation, queueRegionFor, ciFailingFor, completedRowFor, attemptRowFor, resultRowOf, reviewCockpitViewOf, decisionEvidence, runEvidence, sendArtifact, peekGuards, liveRunIds };
3156
3160
  }
3161
+ /** Put away and Restore one task, and Put away old tasks (docs/plans/task-tidy.md): the preview reads only (GET-quiet);
3162
+ * putting away many is an approver's step-up bound to the digest of exactly what the preview showed. Each write
3163
+ * re-proves the person's standing inside its own transaction. */
3164
+ export function createTidyHandlers(runtime) {
3165
+ const { store, sendScreen, chromeFor, authenticateApprover, workAccess, bustBadge } = runtime;
3166
+ async function showPutAway(ctx) {
3167
+ const { url, who, response, now, project } = ctx;
3168
+ const days = tidyDays(url.searchParams.get("days"));
3169
+ if (days === null)
3170
+ return refuse(response, who, 400, "Choose a number of days from 1 to 365.", "/work/put-away");
3171
+ const releases = releaseFactsOf(store);
3172
+ const items = tidyPreview(store, now, { releases, days, access: workAccess() });
3173
+ return sendScreen(response, 200, putAwayPage(chromeFor(project, "work"), { items, days, digest: tidyDigest(items, days), now,
3174
+ problem: url.searchParams.get("problem"), releasesSkipped: releases.problem }));
3175
+ }
3176
+ async function applyPutAway(ctx) {
3177
+ const { who, response, now, posted } = ctx;
3178
+ const body = readForm(posted, CONSOLE_FORMS.tasksPutAway);
3179
+ const days = tidyDays(body.get("days"));
3180
+ const back = (problem) => redirect(response, `/work/put-away?${new URLSearchParams({ ...(days === null || days === TIDY_DEFAULT_DAYS ? {} : { days: String(days) }), problem })}`);
3181
+ if (days === null)
3182
+ return back("Choose a number of days from 1 to 365.");
3183
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
3184
+ return back("Enter your Toolroll password to put these away.");
3185
+ const done = applyTidy(store, now, { by: who.name, days, digest: body.get("digest") ?? "", only: body.getAll("task"), releases: releaseFactsOf(store), access: workAccess() });
3186
+ if (!done.ok)
3187
+ return back(done.message);
3188
+ bustBadge();
3189
+ return redirect(response, "/work?view=archived");
3190
+ }
3191
+ const oneTask = (kind) => async (ctx) => {
3192
+ const { url, who, response, now, posted } = ctx;
3193
+ const act = matchTaskPath(url.pathname, `/${kind}$`);
3194
+ if (act === null)
3195
+ return refuse(response, who, 404, "There's no page at this address.", "/work");
3196
+ const body = readForm(posted, kind === "archive" ? CONSOLE_FORMS.taskArchive : CONSOLE_FORMS.taskRestore);
3197
+ const standing = (refs) => approverStandsNow(store, who.name, familyRepoOf(store, refs));
3198
+ const done = kind === "archive"
3199
+ ? archiveTaskFamily(store, act.taskId, { by: who.name, byKind: "person", rule: null, reason: (body.get("reason") ?? "").trim() || "Put away by hand" }, now, { guard: standing })
3200
+ : restoreTaskFamily(store, act.taskId, who.name, now, standing);
3201
+ if (!done.ok)
3202
+ return refuse(response, who, done.reason === "unknown-task" ? 404 : 409, done.message, taskHref(act.taskId));
3203
+ bustBadge();
3204
+ const to = safeReturn(body.get("return"));
3205
+ return redirect(response, to === "/" ? taskHref(act.taskId) : to);
3206
+ };
3207
+ return { "work.put-away": showPutAway, "work.put-away.apply": applyPutAway, "task.act.archive": oneTask("archive"), "task.act.restore": oneTask("restore") };
3208
+ }
@@ -56,6 +56,7 @@ export const PROOF_INVENTORY = [
56
56
  { id: "updates", action: "Update Toolroll", routes: ["/settings/updates", "/settings/updates/seen", "/settings/updates/cancel"], form: "updates", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: [] },
57
57
  { id: "storage-cleanup", action: "Choose when checkouts are removed", routes: ["/settings/storage"], form: "storage", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: [] },
58
58
  { id: "storage-clean", action: "Clean up checkouts", routes: ["/settings/storage/clean"], form: "storage", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["preview"], note: "`preview` is the digest of what the page showed; a changed list is refused" },
59
+ { id: "tasks-put-away", action: "Put away old tasks", routes: ["/work/put-away"], form: "tasksPutAway", field: "password", proof: "step-up", ...ACCEPT, role: "approver", secrets: [], terms: ["digest", "days", "task"], note: "`digest` is the preview the page showed; a changed list, or a ticked task outside it, is refused" },
59
60
  { id: "storage-discard", action: "Discard a checkout", routes: ["/settings/storage/discard"], form: "storage", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["path"] },
60
61
  { id: "project-live-view", action: "Show a project's commands in the live view", routes: ["/settings/project/live-view"], form: "projectLiveView", field: "password", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "nonce"] },
61
62
  { id: "project-delete", action: "Delete a project", routes: ["/settings/project/delete"], form: "projectDelete", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["repo", "step"], note: "the project's name typed too" },
package/dist/store.d.ts CHANGED
@@ -5,6 +5,7 @@ import { type ProjectSandbox } from "./project-sandbox.js";
5
5
  import type { FencePolicy } from "./agent-fence.js";
6
6
  import { type NetworkProfile, type ProjectNetwork, type RunNetworkRecord } from "./egress.js";
7
7
  import { type AcpLaunch } from "./acp-attest.js";
8
+ export { refArchived, taskArchived } from "./task-archive.js";
8
9
  import { type ChatBinding } from "./chat-core.js";
9
10
  import { ChatMessages, TELEGRAM_HOLD_REASONS, TELEGRAM_SKIPPED_ELSEWHERE, TELEGRAM_SKIPPED_OTHER_CHAT, TELEGRAM_SKIPPED_QUIET } from "./chat-messages.js";
10
11
  import { isLifecycleNotification, isTelegramProgressNotification, LIFECYCLE_KEY_PREFIX, LIFECYCLE_KINDS, proposalTaskOf, type LifecycleKind } from "./notification-rows.js";
@@ -232,7 +233,7 @@ export type SealedLedgerEntry = LedgerEntry & {
232
233
  hash: string;
233
234
  } | null;
234
235
  };
235
- export declare const SCHEMA_VERSION = 143;
236
+ export declare const SCHEMA_VERSION = 145;
236
237
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
237
238
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
238
239
  /**
@@ -394,6 +395,16 @@ export declare const V143_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
394
395
  definition: string;
395
396
  saved: string;
396
397
  }>>>>;
398
+ /** v145 (docs/plans/task-tidy.md): `task_archive` is a put-away task version. A row hides it from every list and count
399
+ * and refuses every door that would dispatch, claim or approve it; restore deletes the row. Its history, results,
400
+ * approvals and ledger stay exactly as they were. `merge_in_flight` is a direct merge between its last check and GitHub's
401
+ * answer, owned by its token: a task can't be put away under it (a row older than 15 minutes is a crash, and no
402
+ * longer counts). `deploy_in_flight` is a deployment whose candidate passed its gate (30 minutes, refreshed by every
403
+ * phase that proves it again). `flow_step_task` is the tasks a flow step was admitted for, kept whatever the card does
404
+ * next, so a step still running holds them. `branch_check_run` keeps each completed GitHub workflow run (pushed to
405
+ * the watched branch of the watched repository) the "checks failed" trigger saw, green or red, so tidy can tell a
406
+ * failure notice was fixed by a later commit. */
407
+ export declare const V145_SCHEMA = "\nCREATE TABLE IF NOT EXISTS task_archive (\n task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id),\n by TEXT NOT NULL,\n by_kind TEXT NOT NULL CHECK (by_kind IN ('person','tidy')),\n rule TEXT,\n reason TEXT NOT NULL,\n at TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS branch_check_run (\n repo TEXT NOT NULL,\n run_id INTEGER NOT NULL,\n branch TEXT NOT NULL,\n workflow TEXT NOT NULL,\n head_sha TEXT NOT NULL,\n conclusion TEXT NOT NULL CHECK (conclusion IN ('success','failure')),\n run_created_at TEXT NOT NULL,\n observed_at TEXT NOT NULL,\n PRIMARY KEY (repo, run_id)\n);\nCREATE INDEX IF NOT EXISTS branch_check_run_by_workflow ON branch_check_run (repo, branch, workflow, run_created_at);\nCREATE TABLE IF NOT EXISTS merge_in_flight (\n publication INTEGER PRIMARY KEY REFERENCES publication(id),\n task_ref INTEGER NOT NULL REFERENCES task_ref(id),\n token TEXT NOT NULL,\n by TEXT NOT NULL,\n at TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS deploy_in_flight (\n task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id),\n run INTEGER NOT NULL,\n at TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS flow_step_task (\n card INTEGER NOT NULL,\n entry INTEGER NOT NULL,\n task_id TEXT NOT NULL,\n PRIMARY KEY (card, entry, task_id)\n);\n";
397
408
  /**
398
409
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
399
410
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -3745,7 +3756,7 @@ export declare class Store {
3745
3756
  rejectedRun: number | null;
3746
3757
  } | {
3747
3758
  ok: false;
3748
- reason: "unknown-task" | "not-stalled" | "claimed" | "accepted-result" | "published" | "moved";
3759
+ reason: "unknown-task" | "not-stalled" | "claimed" | "accepted-result" | "published" | "moved" | "archived";
3749
3760
  };
3750
3761
  /**
3751
3762
  * Cancel, aware of who might be building right now. A live claim wins:
@@ -6892,7 +6903,7 @@ export declare class Store {
6892
6903
  stop: RunStop;
6893
6904
  } | {
6894
6905
  ok: false;
6895
- reason: "no-stop" | "wrong-task" | "stopping" | "already-resumed" | "superseded" | "busy" | "review";
6906
+ reason: "no-stop" | "wrong-task" | "stopping" | "already-resumed" | "superseded" | "busy" | "review" | "archived";
6896
6907
  detail: string;
6897
6908
  };
6898
6909
  /** Every stop recorded on a task, newest request first. */
@@ -8064,3 +8075,60 @@ export declare function dropFeedbackImage(store: Store, runId: number, commentId
8064
8075
  ok: false;
8065
8076
  reason: "not-found" | "image-usable" | "not-authorized";
8066
8077
  };
8078
+ /** A put-away task version (v145, docs/plans/task-tidy.md). */
8079
+ export type TaskArchive = {
8080
+ taskRef: number;
8081
+ taskId: string;
8082
+ by: string;
8083
+ byKind: "person" | "tidy";
8084
+ rule: string | null;
8085
+ reason: string;
8086
+ at: string;
8087
+ };
8088
+ /** Who put a task away, and why: a person by name, or tidy by its rule. */
8089
+ export type ArchiveBy = {
8090
+ by: string;
8091
+ byKind: "person" | "tidy";
8092
+ rule: string | null;
8093
+ reason: string;
8094
+ };
8095
+ export type ArchiveRefusal = "unknown-task" | "live" | "archived" | "not-archived" | "blocked";
8096
+ /** The put-away record of this task, or null when it is not put away. */
8097
+ export declare function archiveOf(store: Store, taskId: string): TaskArchive | null;
8098
+ /** Every version of a task's family, by the same proven lineage the task page reads (`taskFamilyOf`: same project, each
8099
+ * revision bound to its parent's own run by its brief). A family whose lineage doesn't prove is just this task. */
8100
+ export declare function taskFamilyRefs(store: Store, taskId: string): {
8101
+ id: number;
8102
+ taskId: string;
8103
+ }[];
8104
+ /** The first family version with something still going on, or null: running, a live newest claim, an unfinished run,
8105
+ * a process Toolroll hasn't seen exit, a build whose processes were never recorded, a stop still settling, a follow-up
8106
+ * check asked for and not finished, a flow step running for its card, a release in flight (a live release approval
8107
+ * that names it, or a deploy or merge reserved, running or unknown), or a pull request merging (an intent claimed or
8108
+ * firing, or a person's merge between its last check and GitHub's answer). */
8109
+ export declare function liveFamilyAttempt(store: Store, refs: readonly number[], now: Date): string | null;
8110
+ /** Puts a task away (v145): every version of its family, in one write transaction that re-checks it has no live
8111
+ * attempt, that its versions are exactly `expect` when the caller showed a person a family (a preview), and that
8112
+ * `guard` (the caller's own rule, re-read under the lock) still allows it. Its state, history, results, approvals and
8113
+ * ledger are untouched; its open notifications are resolved; one ledger row records who and why. */
8114
+ export declare function archiveTaskFamily(store: Store, taskId: string, by: ArchiveBy, now: Date, options?: {
8115
+ expect?: readonly number[];
8116
+ guard?: (refs: readonly number[]) => string | null;
8117
+ }): {
8118
+ ok: true;
8119
+ refs: number[];
8120
+ } | {
8121
+ ok: false;
8122
+ reason: ArchiveRefusal;
8123
+ message: string;
8124
+ };
8125
+ /** Brings a put-away task back (v145): every version of its family, with a ledger row. Tidy never puts a restored
8126
+ * task away again (tidy.ts reads the `task restored` row). */
8127
+ export declare function restoreTaskFamily(store: Store, taskId: string, by: string, now: Date, guard?: (refs: readonly number[]) => string | null): {
8128
+ ok: true;
8129
+ refs: number[];
8130
+ } | {
8131
+ ok: false;
8132
+ reason: ArchiveRefusal;
8133
+ message: string;
8134
+ };