toolroll 0.9.85 → 0.9.87

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 (220) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/agent-fence.d.ts +6 -2
  3. package/dist/agent-fence.js +3 -2
  4. package/dist/agents-board.d.ts +12 -0
  5. package/dist/agents-board.js +26 -0
  6. package/dist/approval-rules-ui.d.ts +4 -1
  7. package/dist/approval-rules-ui.js +31 -15
  8. package/dist/assignment-ui.js +1 -1
  9. package/dist/assignment.js +4 -3
  10. package/dist/backup-ui.d.ts +5 -4
  11. package/dist/backup-ui.js +65 -32
  12. package/dist/browser/terminal.css +1 -1
  13. package/dist/browser/terminal.js +57 -54
  14. package/dist/browser/workspace.css +2 -2
  15. package/dist/browser/workspace.js +35 -68
  16. package/dist/browser-crew.js +1 -1
  17. package/dist/browser-workspace.d.ts +363 -177
  18. package/dist/browser-workspace.js +10 -9
  19. package/dist/chat-actions.d.ts +11 -1
  20. package/dist/chat-actions.js +53 -1
  21. package/dist/chat-channel.js +1 -0
  22. package/dist/chat-controls.d.ts +1 -1
  23. package/dist/chat-controls.js +1 -1
  24. package/dist/chat-quiet.js +3 -2
  25. package/dist/chat-settings-page.d.ts +29 -9
  26. package/dist/chat-settings-page.js +59 -28
  27. package/dist/check-levels-ui.d.ts +2 -1
  28. package/dist/check-levels-ui.js +67 -58
  29. package/dist/claim.d.ts +3 -0
  30. package/dist/claim.js +4 -1
  31. package/dist/contracts/chat-actions.d.ts +9 -0
  32. package/dist/contracts/chat-actions.js +3 -0
  33. package/dist/contracts/cli.d.ts +43 -0
  34. package/dist/contracts/cli.js +4 -0
  35. package/dist/contracts/console-api.d.ts +4 -1
  36. package/dist/contracts/console-api.js +4 -1
  37. package/dist/contracts/flow-propose.d.ts +4 -1
  38. package/dist/contracts/flow-propose.js +1 -1
  39. package/dist/contracts/flow.d.ts +41 -0
  40. package/dist/contracts/flow.js +4 -2
  41. package/dist/contracts/lead-tools.d.ts +21 -0
  42. package/dist/contracts/lead-tools.js +2 -0
  43. package/dist/contracts/store-json.d.ts +2 -0
  44. package/dist/contracts/trigger-payloads.d.ts +20 -0
  45. package/dist/contracts/trigger-payloads.js +4 -0
  46. package/dist/coordinator.js +1 -0
  47. package/dist/credentials-ui.d.ts +5 -3
  48. package/dist/credentials-ui.js +58 -31
  49. package/dist/deploy-gate.js +4 -0
  50. package/dist/design/kit.css +685 -0
  51. package/dist/design/kit.d.ts +208 -0
  52. package/dist/design/kit.js +177 -0
  53. package/dist/design/tokens.css +19 -1
  54. package/dist/discord-settings.js +8 -4
  55. package/dist/dispatch.d.ts +3 -0
  56. package/dist/dispatch.js +4 -1
  57. package/dist/export-ui.d.ts +6 -4
  58. package/dist/export-ui.js +18 -8
  59. package/dist/flow-browser.d.ts +103 -0
  60. package/dist/flow-browser.js +204 -0
  61. package/dist/flow-changed.d.ts +14 -0
  62. package/dist/flow-changed.js +53 -0
  63. package/dist/flow-code.d.ts +5 -1
  64. package/dist/flow-code.js +4 -1
  65. package/dist/flow-engine.js +16 -0
  66. package/dist/flow-gallery-ui.d.ts +41 -4
  67. package/dist/flow-gallery-ui.js +95 -63
  68. package/dist/flow-live.js +9 -1
  69. package/dist/flow-pull-request.js +15 -7
  70. package/dist/flow-share.js +3 -1
  71. package/dist/flow-starters-ui.d.ts +3 -9
  72. package/dist/flow-starters-ui.js +12 -23
  73. package/dist/flow-steps.d.ts +9 -1
  74. package/dist/flow-steps.js +55 -5
  75. package/dist/flow-triggers.js +55 -0
  76. package/dist/flow-unattended-words.d.ts +20 -0
  77. package/dist/flow-unattended-words.js +24 -0
  78. package/dist/flow-unattended.d.ts +9 -5
  79. package/dist/flow-unattended.js +45 -23
  80. package/dist/flows-cli.d.ts +19 -0
  81. package/dist/flows-cli.js +54 -5
  82. package/dist/flows-ui.d.ts +12 -6
  83. package/dist/flows-ui.js +152 -32
  84. package/dist/flows.js +5 -2
  85. package/dist/integrations-ui.d.ts +6 -4
  86. package/dist/integrations-ui.js +34 -40
  87. package/dist/integrations.d.ts +2 -2
  88. package/dist/integrations.js +4 -4
  89. package/dist/kits-ui.d.ts +8 -10
  90. package/dist/kits-ui.js +29 -33
  91. package/dist/kits.js +1 -1
  92. package/dist/knowledge-ui.js +76 -19
  93. package/dist/lead-tools.js +35 -6
  94. package/dist/lead-voice.js +16 -5
  95. package/dist/ledger-view.d.ts +2 -0
  96. package/dist/ledger-view.js +41 -38
  97. package/dist/models-ui.d.ts +8 -3
  98. package/dist/models-ui.js +82 -59
  99. package/dist/monitoring-ui.d.ts +4 -2
  100. package/dist/monitoring-ui.js +44 -13
  101. package/dist/needs-you.d.ts +1 -1
  102. package/dist/needs-you.js +8 -0
  103. package/dist/operate-remote-arguments.js +1 -0
  104. package/dist/operate-remote.js +1 -1
  105. package/dist/operate.d.ts +1 -1
  106. package/dist/operate.js +249 -6
  107. package/dist/passwordless.d.ts +1 -1
  108. package/dist/passwordless.js +1 -1
  109. package/dist/people-audit-ui.d.ts +2 -1
  110. package/dist/people-audit-ui.js +25 -23
  111. package/dist/policy-ui.d.ts +3 -2
  112. package/dist/policy-ui.js +27 -21
  113. package/dist/policy.d.ts +3 -0
  114. package/dist/policy.js +14 -1
  115. package/dist/project-delete-ui.d.ts +4 -2
  116. package/dist/project-delete-ui.js +135 -126
  117. package/dist/project-delete.js +7 -3
  118. package/dist/project-knowledge.d.ts +10 -10
  119. package/dist/publish.d.ts +11 -0
  120. package/dist/publish.js +16 -3
  121. package/dist/pull-request-flow.js +35 -4
  122. package/dist/pull-request-ui.d.ts +2 -1
  123. package/dist/pull-request-ui.js +39 -28
  124. package/dist/recipe-ui.d.ts +4 -2
  125. package/dist/recipe-ui.js +14 -4
  126. package/dist/release-adapters.d.ts +3 -0
  127. package/dist/release-adapters.js +22 -2
  128. package/dist/release-cli.js +81 -9
  129. package/dist/release.d.ts +21 -0
  130. package/dist/release.js +40 -4
  131. package/dist/releases.js +1 -1
  132. package/dist/request-budget-ui.d.ts +4 -2
  133. package/dist/request-budget-ui.js +30 -19
  134. package/dist/result-follow-ups.js +4 -0
  135. package/dist/retention-ui.d.ts +4 -3
  136. package/dist/retention-ui.js +15 -12
  137. package/dist/scope.d.ts +8 -0
  138. package/dist/scope.js +13 -0
  139. package/dist/serve.d.ts +0 -1
  140. package/dist/serve.js +51 -7
  141. package/dist/server/agents-computers.d.ts +9 -0
  142. package/dist/server/agents-computers.js +67 -0
  143. package/dist/server/chat.js +20 -18
  144. package/dist/server/chrome.d.ts +1 -1
  145. package/dist/server/chrome.js +19 -9
  146. package/dist/server/console.css +31 -2
  147. package/dist/server/flows.js +79 -36
  148. package/dist/server/home.d.ts +35 -0
  149. package/dist/server/home.js +104 -0
  150. package/dist/server/http.d.ts +3 -0
  151. package/dist/server/http.js +5 -0
  152. package/dist/server/kit-standalone.d.ts +22 -0
  153. package/dist/server/kit-standalone.js +69 -0
  154. package/dist/server/kit.d.ts +405 -0
  155. package/dist/server/kit.js +361 -0
  156. package/dist/server/pages.d.ts +2 -1
  157. package/dist/server/pages.js +213 -174
  158. package/dist/server/people-tokens.js +26 -85
  159. package/dist/server/render-history.d.ts +51 -0
  160. package/dist/server/render-history.js +228 -0
  161. package/dist/server/render-new-task.d.ts +29 -0
  162. package/dist/server/render-new-task.js +96 -0
  163. package/dist/server/render-next.d.ts +63 -0
  164. package/dist/server/render-next.js +109 -0
  165. package/dist/server/render-pages.d.ts +35 -190
  166. package/dist/server/render-pages.js +154 -563
  167. package/dist/server/render-people.d.ts +24 -3
  168. package/dist/server/render-people.js +93 -72
  169. package/dist/server/render-projects.d.ts +103 -0
  170. package/dist/server/render-projects.js +247 -0
  171. package/dist/server/render-results.d.ts +5 -24
  172. package/dist/server/render-results.js +48 -185
  173. package/dist/server/render-settings.d.ts +5 -74
  174. package/dist/server/render-settings.js +102 -493
  175. package/dist/server/render-tasks.d.ts +43 -5
  176. package/dist/server/render-tasks.js +131 -91
  177. package/dist/server/route-table.js +31 -5
  178. package/dist/server/settings.js +276 -255
  179. package/dist/server/task-views.d.ts +8 -0
  180. package/dist/server/task-views.js +90 -2
  181. package/dist/server/tasks.d.ts +18 -0
  182. package/dist/server/tasks.js +148 -41
  183. package/dist/server/terminals.js +1 -1
  184. package/dist/server/welcome.d.ts +22 -0
  185. package/dist/server/welcome.js +69 -0
  186. package/dist/settings-groups-ui.d.ts +279 -0
  187. package/dist/settings-groups-ui.js +615 -0
  188. package/dist/settings-kit.d.ts +42 -6
  189. package/dist/settings-kit.js +69 -7
  190. package/dist/settings-proofs.js +1 -0
  191. package/dist/setup-guide.d.ts +2 -2
  192. package/dist/skills-ui.js +59 -7
  193. package/dist/slack-settings.js +12 -5
  194. package/dist/spend-ui.d.ts +45 -26
  195. package/dist/spend-ui.js +124 -56
  196. package/dist/sso-ui.d.ts +5 -2
  197. package/dist/sso-ui.js +63 -31
  198. package/dist/storage-ui.d.ts +3 -2
  199. package/dist/storage-ui.js +56 -50
  200. package/dist/store.d.ts +182 -5
  201. package/dist/store.js +512 -38
  202. package/dist/surface.js +23 -3
  203. package/dist/task-archive.d.ts +10 -0
  204. package/dist/task-archive.js +16 -0
  205. package/dist/task-control.d.ts +1 -1
  206. package/dist/teams-settings.d.ts +2 -1
  207. package/dist/teams-settings.js +24 -41
  208. package/dist/telegram-settings.d.ts +0 -4
  209. package/dist/telegram-settings.js +52 -25
  210. package/dist/tidy.d.ts +130 -0
  211. package/dist/tidy.js +503 -0
  212. package/dist/toolroll-update-ui.d.ts +8 -4
  213. package/dist/toolroll-update-ui.js +55 -45
  214. package/dist/tools-ui.d.ts +5 -3
  215. package/dist/tools-ui.js +107 -42
  216. package/dist/work-index.d.ts +24 -0
  217. package/dist/work-index.js +56 -30
  218. package/dist/workspace-ui.d.ts +1 -1
  219. package/dist/workspace-ui.js +8 -4
  220. package/package.json +1 -1
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,8 @@ 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 { dur } from "./design/kit.js";
111
+ import { openStore, openStoreNoMigrate, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
109
112
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
110
113
  import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
111
114
  import { authorizePlanUnderMode } from "./plan-auto.js";
@@ -273,7 +276,11 @@ export const OPERATE_HELP = `toolroll — operating the queue
273
276
  the lead passes it as --token, or TOOLROLL_LEAD_TOKEN, and its work pings nobody
274
277
  toolroll task ask <id> --person <name> --why "<text>"
275
278
  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
279
+ toolroll task list [--view <v>] [--limit <n>] [--cursor <c>] paginated saved task status (--view archived: put away)
280
+ toolroll task archive <id...> [--reason <why>] put tasks away: out of every list, history kept (a person's act)
281
+ toolroll task restore <id...> bring put-away tasks back exactly as they were
282
+ toolroll task tidy --preview [--days N] what Put away old tasks would put away, and why (read-only)
283
+ toolroll task tidy --apply --digest <d> [--except <id,...>] put away exactly that list
277
284
  toolroll task show <id>
278
285
  toolroll task wait <id> [--timeout <seconds>]
279
286
  wait for this attempt to finish or need a person
@@ -338,6 +345,7 @@ export const OPERATE_HELP = `toolroll — operating the queue
338
345
  [--anyway] merge without waiting for the full check
339
346
  toolroll task checks <id> [--level quick|full]
340
347
  run checks on the result's exact commit now
348
+ toolroll task ship <id> its pull request's checks, each with its state and how long it took
341
349
  toolroll task add-tests <id> file a small task to write tests for that change
342
350
  toolroll task revise <id> --feedback "requested change"
343
351
  toolroll assignment show <task> root, current work and exact handoff
@@ -663,7 +671,7 @@ export const TASK_ACTIONS = [
663
671
  "add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign",
664
672
  "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
665
673
  "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask",
666
- "checks", "add-tests",
674
+ "checks", "add-tests", "archive", "restore", "tidy", "ship",
667
675
  ];
668
676
  export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
669
677
  export const CONFIG_ACTIONS = ["show", "set", "clear"];
@@ -707,6 +715,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
707
715
  "starter",
708
716
  // flows: templates, steps, scripts and cards.
709
717
  "template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone", "option",
718
+ // task tidy --apply: the previewed tasks to leave out.
719
+ "except",
710
720
  "token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
711
721
  // pings follow responsibility: replacements.
712
722
  "replaced-by", "replaces",
@@ -757,6 +767,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
757
767
  "password-stdin",
758
768
  // task steer: pause the running Claude build, then send the note.
759
769
  "now",
770
+ // task tidy: look first (--preview), then put away exactly what was shown (--apply --digest).
771
+ "preview", "apply",
760
772
  ]);
761
773
  /** `project network` reads --remove <hosts> and --local any|none as values, where every other verb's --remove and
762
774
  * --local are switches. */
@@ -1564,6 +1576,8 @@ function claimCommand(positional, flags, context) {
1564
1576
  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
1577
  if (result.reason === "watch-custody")
1566
1578
  return fail(write, json, "claim", "watch-custody", "This worker no longer holds the project; restart the watch", EXIT.refused);
1579
+ if (result.reason === "archived")
1580
+ return fail(write, json, "claim", "archived", `${id} is put away — restore it with \`toolroll task restore ${id}\` first`, EXIT.refused);
1567
1581
  if (result.reason === "draining")
1568
1582
  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
1583
  return fail(write, json, "claim", result.reason, `held by ${result.by} until ${result.until}`, EXIT.refused, { holder: result.by, until: result.until });
@@ -3562,6 +3576,13 @@ async function reconcileCommand(flags, context) {
3562
3576
  remotes.close();
3563
3577
  }
3564
3578
  const landedGone = landed.steps.filter(one => one.outcome === "removed");
3579
+ // Tidy (task-tidy.md): old routine runs a newer run replaces, release tasks already published or replaced, and CI
3580
+ // notices main has fixed are put away, at most a bounded number a pass, each with its reason in the ledger.
3581
+ let tidied = [];
3582
+ try {
3583
+ tidied = runTidy(store, clock(), releaseFactsFor(context.databaseFile), TIDY_PASS_LIMIT, { principal: "operator", repos: [repo], includeUnplaced: false });
3584
+ }
3585
+ catch { /* the next pass tries again */ }
3565
3586
  // Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
3566
3587
  // the task is complete or cancelled); their branches stay. Each removal is in the ledger.
3567
3588
  // Completing or cancelling a task stops anything still running from its checkout first.
@@ -3605,6 +3626,7 @@ async function reconcileCommand(flags, context) {
3605
3626
  slimmed.slimmed.length === 0 &&
3606
3627
  landed.completed.length === 0 &&
3607
3628
  landedGone.length === 0 &&
3629
+ tidied.length === 0 &&
3608
3630
  liveSwept.removed.length === 0;
3609
3631
  return succeed(write, json, "reconcile", {
3610
3632
  recovered,
@@ -3616,6 +3638,7 @@ async function reconcileCommand(flags, context) {
3616
3638
  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
3639
  checkoutsSlimmed: slimmed.slimmed.map(one => one.path),
3618
3640
  landed: { completed: landed.completed, steps: landed.steps },
3641
+ tidied: tidied.map(one => ({ id: one.taskId, rule: one.rule, reason: one.reason })),
3619
3642
  liveViewsSwept: liveSwept.removed.length,
3620
3643
  retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
3621
3644
  storageSweep: storageSwept,
@@ -3630,6 +3653,7 @@ async function reconcileCommand(flags, context) {
3630
3653
  ...closedRuns.map(one => `Run #${one.runId} ended; stopped ${stoppedWords(one.done)}.`),
3631
3654
  ...closedCheckouts.map(one => `${one.path}: its task finished; stopped ${stoppedWords(one.done)}.`),
3632
3655
  ...(landed.completed.length === 0 ? [] : [`Marked ${landed.completed.length} landed result(s) complete: ${landed.completed.join(", ")}.`]),
3656
+ ...(tidied.length === 0 ? [] : [`Put away ${tidied.length} old task(s): ${tidied.map(one => one.taskId).join(", ")}.`]),
3633
3657
  ...(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
3658
  ...(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
3659
  ...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
@@ -4112,6 +4136,7 @@ async function requeueTask(positional, flags, context) {
4112
4136
  published: `${id}'s last result is published; revise it through its pull request`,
4113
4137
  // Only an Approve & build again asks for an exact build; the CLI never does.
4114
4138
  moved: `${id} changed since its page was read; read it again`,
4139
+ archived: `${id} is put away — restore it with \`toolroll task restore ${id}\` first`,
4115
4140
  };
4116
4141
  return fail(write, json, "task requeue", result.reason, why[result.reason], EXIT.refused);
4117
4142
  }
@@ -8563,10 +8588,18 @@ function taskAction(action, rest, flags, context) {
8563
8588
  return addTask(rest, flags, context);
8564
8589
  case "checks":
8565
8590
  return runTaskChecks(rest, flags, context);
8591
+ case "ship":
8592
+ return taskShipCommand(rest, context);
8566
8593
  case "add-tests":
8567
8594
  return addTestsTask(rest, flags, context);
8568
8595
  case "list":
8569
8596
  return listTasks(flags, context);
8597
+ case "archive":
8598
+ return archiveTasksCommand(rest, flags, context);
8599
+ case "restore":
8600
+ return restoreTasksCommand(rest, flags, context);
8601
+ case "tidy":
8602
+ return tidyTasksCommand(rest, flags, context);
8570
8603
  case "show":
8571
8604
  return showTask(rest, context);
8572
8605
  case "wait":
@@ -8988,9 +9021,9 @@ function listTasks(flags, context) {
8988
9021
  const limit = rawLimit === undefined ? 40 : Number(rawLimit);
8989
9022
  if (wanted !== undefined && !STATES.includes(wanted))
8990
9023
  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) ||
9024
+ if (rawView !== undefined && !['all', 'needs-you', 'running', 'queued', 'completed', 'archived'].includes(rawView) ||
8992
9025
  !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);
9026
+ return fail(write, json, 'task list', 'usage', '--view takes all, needs-you, running, queued, completed or archived; --limit takes 1–100', EXIT.usage);
8994
9027
  }
8995
9028
  // A remote person limited to some projects (remoteLensOf) pages and counts only theirs.
8996
9029
  const lens = context.principal?.lens ?? null;
@@ -9017,7 +9050,8 @@ function listTasks(flags, context) {
9017
9050
  write(wanted === undefined ? 'The task list is empty.' : `Nothing is ${wanted}.`);
9018
9051
  const width = Math.max(0, ...tasks.map(task => task.id.length));
9019
9052
  for (const task of tasks)
9020
- write(` ${task.id.padEnd(width)} ${task.status.label.padEnd(16)} ${task.title}`);
9053
+ write(task.archived === undefined ? ` ${task.id.padEnd(width)} ${task.status.label.padEnd(16)} ${task.title}`
9054
+ : ` ${task.id.padEnd(width)} ${'Archived'.padEnd(16)} ${task.title} — by ${task.archived.byKind === 'tidy' ? 'tidy' : task.archived.by}: ${task.archived.reason}`);
9021
9055
  if (page.nextCursor !== null)
9022
9056
  write(`Next page: --cursor ${page.nextCursor}`);
9023
9057
  return EXIT.ok;
@@ -10149,6 +10183,8 @@ function describeApproveFailure(reason, id) {
10149
10183
  // Approval policy, phase 1: the lead's token (or a remote API token) proves a credential, not a person.
10150
10184
  if (reason === "machine-proof")
10151
10185
  return "only a person can approve this — approve it on the task page or from your paired chat";
10186
+ if (reason === "archived")
10187
+ return `${id} is put away — restore it with \`toolroll task restore ${id}\` first`;
10152
10188
  if (reason === "second-approver")
10153
10189
  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
10190
  if (reason === "no-approvers") {
@@ -12754,3 +12790,210 @@ export function consoleIdentitiesInBackground(identities, paths) {
12754
12790
  export function writeModelRow(store, proved, scope, knownProject, slot, to, now, extra = []) {
12755
12791
  return applyModelChoices(store, { scope, change: slot === null ? [] : [{ slot, to }], expected: null, strict: false, knownProject, extra }, { ...proved, live: () => true }, now);
12756
12792
  }
12793
+ /** Putting away and restoring are a person's acts (docs/plans/task-tidy.md): never the lead's token, never a remote
12794
+ * API token, never a machine proof. Null when a person is acting. */
12795
+ function refuseMachineTidy(context, command, method) {
12796
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || (method !== undefined && !personProof(method))) {
12797
+ 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);
12798
+ }
12799
+ return null;
12800
+ }
12801
+ /** `toolroll task archive <id...> [--reason <text>]`: put tasks away by hand (v145). Each keeps its history, results and
12802
+ * approvals; nothing can run, claim or approve it until it is restored. Credentialed, a person's act. */
12803
+ async function archiveTasksCommand(positional, flags, context) {
12804
+ const { store, write, json, clock } = context;
12805
+ const command = "task archive";
12806
+ const ids = [...new Set(positional)];
12807
+ if (ids.length === 0 || ids.length > 100)
12808
+ return fail(write, json, command, "usage", "`toolroll task archive <id...> [--reason <why>] --as <you> --token <t>` — 1 to 100 tasks", EXIT.usage);
12809
+ const machine = refuseMachineTidy(context, command);
12810
+ if (machine !== null)
12811
+ return machine;
12812
+ const demo = refuseDemo(context, command);
12813
+ if (demo !== null)
12814
+ return demo;
12815
+ const acting = await askCredentials(flags, context);
12816
+ // The password is proved against a project the person can see (the first task's); each task's own project is
12817
+ // re-proved inside its put-away transaction.
12818
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, ids));
12819
+ if (acting === null || !verified?.ok)
12820
+ return refuseApprover(context, command, verified, "putting a task away takes `--as <you> --token <t>` — it is recorded against your name", ids[0]);
12821
+ const personal = refuseMachineTidy(context, command, verified.method);
12822
+ if (personal !== null)
12823
+ return personal;
12824
+ const reason = text(flags, "reason") ?? "Put away by hand";
12825
+ const archived = [], refused = [];
12826
+ for (const id of ids) {
12827
+ const ref = store.lookupRef(id);
12828
+ if (ref === null || store.getTask(id) === null || !store.accountCanAccess(acting.name, ref.repo)) {
12829
+ refused.push({ id, reason: "unknown-task", message: `No task ${id}.` });
12830
+ continue;
12831
+ }
12832
+ const done = archiveTaskFamily(store, id, { by: acting.name, byKind: "person", rule: null, reason }, clock(), { guard: refs => approverStandsNow(store, acting.name, familyRepoOf(store, refs)) });
12833
+ if (done.ok)
12834
+ archived.push({ id, versions: done.refs.length });
12835
+ else
12836
+ refused.push({ id, reason: done.reason, message: done.message });
12837
+ }
12838
+ const lines = () => [
12839
+ ...archived.map(one => `Put away ${one.id}${one.versions > 1 ? ` (${one.versions} versions)` : ""}.`),
12840
+ ...refused.map(one => `Not put away: ${one.message}`),
12841
+ ...(archived.length === 0 ? [] : [`Restore with \`toolroll task restore ${archived.map(one => one.id).join(" ")}\`.`]),
12842
+ ];
12843
+ if (refused.length === 0)
12844
+ return succeed(write, json, command, { archived, refused }, lines);
12845
+ if (json)
12846
+ return fail(write, json, command, refused[0].reason, refused[0].message, EXIT.refused, { archived, refused });
12847
+ write(lines().join("\n"));
12848
+ return EXIT.refused;
12849
+ }
12850
+ /** `toolroll task restore <id...>`: bring put-away tasks back exactly as they were (v145). Credentialed, a person's act. */
12851
+ async function restoreTasksCommand(positional, flags, context) {
12852
+ const { store, write, json, clock } = context;
12853
+ const command = "task restore";
12854
+ const ids = [...new Set(positional)];
12855
+ if (ids.length === 0 || ids.length > 100)
12856
+ return fail(write, json, command, "usage", "`toolroll task restore <id...> --as <you> --token <t>` — 1 to 100 tasks", EXIT.usage);
12857
+ const machine = refuseMachineTidy(context, command);
12858
+ if (machine !== null)
12859
+ return machine;
12860
+ const demo = refuseDemo(context, command);
12861
+ if (demo !== null)
12862
+ return demo;
12863
+ const acting = await askCredentials(flags, context);
12864
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, ids));
12865
+ if (acting === null || !verified?.ok)
12866
+ return refuseApprover(context, command, verified, "restoring takes `--as <you> --token <t>` — it is recorded against your name", ids[0]);
12867
+ const personal = refuseMachineTidy(context, command, verified.method);
12868
+ if (personal !== null)
12869
+ return personal;
12870
+ const restored = [], refused = [];
12871
+ for (const id of ids) {
12872
+ const ref = store.lookupRef(id);
12873
+ if (ref === null || store.getTask(id) === null || !store.accountCanAccess(acting.name, ref.repo)) {
12874
+ refused.push({ id, reason: "unknown-task", message: `No task ${id}.` });
12875
+ continue;
12876
+ }
12877
+ const done = restoreTaskFamily(store, id, acting.name, clock(), refs => approverStandsNow(store, acting.name, familyRepoOf(store, refs)));
12878
+ if (done.ok)
12879
+ restored.push(id);
12880
+ else
12881
+ refused.push({ id, reason: done.reason, message: done.message });
12882
+ }
12883
+ const lines = () => [...restored.map(id => `Restored ${id}.`), ...refused.map(one => `Not restored: ${one.message}`)];
12884
+ if (refused.length === 0)
12885
+ return succeed(write, json, command, { restored, refused }, lines);
12886
+ if (json)
12887
+ return fail(write, json, command, refused[0].reason, refused[0].message, EXIT.refused, { restored, refused });
12888
+ write(lines().join("\n"));
12889
+ return EXIT.refused;
12890
+ }
12891
+ /** `toolroll task tidy --preview [--days N]` (read-only, no credential) and `toolroll task tidy --apply --digest <d>
12892
+ * [--days N] [--except <id,...>]` (credentialed, a person's act): the console's Put away old tasks, word for word,
12893
+ * with the same preview and digest. Apply puts away exactly what the preview with that digest showed. */
12894
+ async function tidyTasksCommand(positional, flags, context) {
12895
+ const { store, write, json, clock } = context;
12896
+ const command = "task tidy";
12897
+ const preview = flag(flags, "preview"), apply = flag(flags, "apply");
12898
+ const days = tidyDays(text(flags, "days"));
12899
+ const digest = text(flags, "digest");
12900
+ const except = (text(flags, "except") ?? "").split(",").map(one => one.trim()).filter(one => one !== "");
12901
+ const usage = "`toolroll task tidy --preview [--days N]`, then `toolroll task tidy --apply --digest <d> [--days N] [--except <id,...>]`";
12902
+ if (positional.length > 0 || preview === apply || days === null || (preview && (digest !== undefined || except.length > 0)) || (apply && digest === undefined))
12903
+ return fail(write, json, command, "usage", usage, EXIT.usage);
12904
+ const releases = releaseFactsFor(context.databaseFile);
12905
+ // One scope for the preview and the apply: `--as <you>` previews exactly your projects (no password: it reads), and
12906
+ // the apply reads the same person's projects, so an unchanged list keeps its digest.
12907
+ // Whose lists: `--as <you>`, else this machine's saved login (`toolroll up`), else everything (an unrestricted
12908
+ // person's list; a person limited to some projects previews with --as).
12909
+ const previewAs = text(flags, "as") ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name;
12910
+ const daysArg = `${days === TIDY_DEFAULT_DAYS ? "" : ` --days ${days}`}${previewAs === undefined ? "" : ` --as ${previewAs}`}`;
12911
+ // `--as` only ever narrows: the named person's projects within the caller's own lens, never beyond it.
12912
+ const lens = context.principal?.lens ?? null;
12913
+ const own = { principal: "operator", repos: lens, includeUnplaced: lens === null };
12914
+ const access = previewAs !== undefined ? withinLens(accessOf(store, previewAs), lens) : own;
12915
+ if (preview) {
12916
+ const items = tidyPreview(store, clock(), { releases, days, access });
12917
+ const shown = tidyDigest(items, days);
12918
+ const groups = tidyGroups(items, days);
12919
+ const next = `toolroll task tidy --apply --digest ${shown}${daysArg}`;
12920
+ return succeed(write, json, command, { preview: true, days, digest: shown, count: items.length, groups, next: items.length === 0 ? null : next,
12921
+ ...(releases.problem === null ? {} : { releasesSkipped: releases.problem }) }, () => items.length === 0
12922
+ ? ["Nothing to put away."]
12923
+ : [`Put away old tasks: ${items.length} task${items.length === 1 ? "" : "s"}.`,
12924
+ ...groups.flatMap(group => ["", `${group.label} (${group.items.length})`, ...group.items.map(one => ` ${one.taskId} ${one.reason}${one.members.length > 1 ? ` (${one.members.length} versions)` : ""}`)]),
12925
+ ...(releases.problem === null ? [] : ["", `Release tasks were left out: ${releases.problem}.`]),
12926
+ "", `To put these away, run: ${next}`, "Leave some out with --except <id,...>.",
12927
+ ...(previewAs === undefined ? ["Limited to some projects? Preview with --as <you> so the list is yours."] : [])]);
12928
+ }
12929
+ const machine = refuseMachineTidy(context, command);
12930
+ if (machine !== null)
12931
+ return machine;
12932
+ const demo = refuseDemo(context, command);
12933
+ if (demo !== null)
12934
+ return demo;
12935
+ const acting = await askCredentials(flags, context);
12936
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, []));
12937
+ if (acting === null || !verified?.ok)
12938
+ return refuseApprover(context, command, verified, "putting tasks away takes `--as <you> --token <t>` — it is recorded against your name", undefined);
12939
+ const personal = refuseMachineTidy(context, command, verified.method);
12940
+ if (personal !== null)
12941
+ return personal;
12942
+ // The person's own view of the lists, from the verified credential (never a flag), within the caller's lens: the
12943
+ // same scope `--preview --as <them>` read.
12944
+ const mine = withinLens(accessOf(store, acting.name), lens);
12945
+ const shownItems = tidyPreview(store, clock(), { releases, days, access: mine });
12946
+ const only = except.length === 0 ? null : shownItems.map(one => one.taskId).filter(id => !except.includes(id));
12947
+ if (except.some(id => !shownItems.some(one => one.taskId === id)))
12948
+ return fail(write, json, command, "outside", "--except names a task that isn't in the list. Check it again with --preview.", EXIT.refused);
12949
+ const done = applyTidy(store, clock(), { by: acting.name, days, digest: digest, only, releases, access: mine });
12950
+ if (!done.ok) {
12951
+ const again = `toolroll task tidy --apply --digest ${done.digest}${daysArg}`;
12952
+ 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 } : {}) });
12953
+ }
12954
+ return succeed(write, json, command, { archived: done.archived.map(one => ({ id: one.taskId, rule: one.rule, reason: one.reason })), skipped: done.skipped }, () => [
12955
+ `Put away ${done.archived.length} task${done.archived.length === 1 ? "" : "s"}.`,
12956
+ ...done.skipped.map(one => `Not put away: ${one.message}`),
12957
+ "Find them with `toolroll task list --view archived`; bring one back with `toolroll task restore <id>`.",
12958
+ ]);
12959
+ }
12960
+ /** The project a put-away or restore proves the person's password against: the first named task's, when they can see
12961
+ * it; otherwise the first project they can see at all (null when none: an unrestricted person, or none to prove). */
12962
+ function firstVisibleRepo(store, name, ids) {
12963
+ for (const id of ids) {
12964
+ const repo = store.lookupRef(id)?.repo ?? null;
12965
+ if (repo !== null && store.accountCanAccess(name, repo))
12966
+ return repo;
12967
+ }
12968
+ return store.accountCanAccess(name, null) ? null : store.knownRepos().find(repo => store.accountCanAccess(name, repo)) ?? null;
12969
+ }
12970
+ /** The check states as `task ship` says them. */
12971
+ const SHIP_CHECK_WORDS = { queued: "queued", running: "running", passed: "passed", failed: "failed", skipped: "skipped", cancelled: "cancelled", neutral: "neutral" };
12972
+ /**
12973
+ * `toolroll task ship <task>` (read-only) — the task's pull request checks as the plane last observed them for that
12974
+ * head (store.ts ci_check, v146): each check's state and how long it took, the Ship view's own facts.
12975
+ */
12976
+ function taskShipCommand(positional, context) {
12977
+ const { store, write, json } = context;
12978
+ const command = "task ship";
12979
+ const id = positional[0];
12980
+ if (id === undefined || positional.length > 1)
12981
+ return fail(write, json, command, "usage", "`toolroll task ship <task>`", EXIT.usage);
12982
+ const ref = store.lookupRef(id);
12983
+ const lens = context.principal?.lens ?? null;
12984
+ // A task outside the caller's projects is no task here, the same answer as one that doesn't exist.
12985
+ if (ref === null || store.getTask(id) === null || (lens !== null && (ref.repo === null || !lens.includes(ref.repo))))
12986
+ return fail(write, json, command, "unknown-task", `no task \`${id}\``, EXIT.refused);
12987
+ const ship = shipChecksForTask(store, id);
12988
+ if (ship === null)
12989
+ return fail(write, json, command, "no-pull-request", `${id} has no pull request with observed checks yet`, EXIT.refused);
12990
+ const now = (context.clock ?? (() => context.now))();
12991
+ const checks = ship.checks.map(one => ({ ...one, seconds: elapsedSeconds(one.startedAt, one.completedAt, now) }));
12992
+ const passed = checks.filter(one => one.state === "passed").length;
12993
+ // The overall answer is the observation's own (the one merges read), never a sum of the rows.
12994
+ const overall = ship.observed === "failing" ? "failing" : ship.observed === "passing" ? "passing" : ship.observed === "running" ? "running" : "not read";
12995
+ return succeed(write, json, command, { task: id, pullRequest: ship.prNumber, githubRepo: ship.githubRepo, head: ship.headSha, observedAt: ship.observedAt, status: ship.observed, checks }, () => [
12996
+ `Pull request #${ship.prNumber} (${ship.githubRepo}) at ${ship.headSha.slice(0, 7)}: checks ${overall} · ${passed} of ${checks.length} passed`,
12997
+ ...checks.map(one => ` ${(one.workflow === null ? one.name : `${one.workflow} / ${one.name}`).padEnd(32)} ${(SHIP_CHECK_WORDS[one.state] ?? one.state).padEnd(9)} ${one.seconds === null ? "–" : dur(one.seconds)}`),
12998
+ ]);
12999
+ }
@@ -20,4 +20,4 @@ export declare const passwordlessOn: (store: Store) => boolean;
20
20
  export declare function setPasswordless(store: Store, on: boolean, by: string, now: Date): {
21
21
  changed: boolean;
22
22
  };
23
- export declare const PASSWORDLESS_TRADE = "Anyone holding a signed-in browser \u2014 an unlocked laptop, a phone, a stolen session \u2014 can then do everything you can without a second check. Signing in, session limits (12 hours idle, 7 days), signing out and ending sessions remain your protection. Creating or widening a way in (passwords, sign-in methods, API tokens, connected apps, people, workers, chat pairing, webhook secrets) still asks for your password.";
23
+ export declare const PASSWORDLESS_TRADE = "Anyone holding a signed-in browser \u2014 an unlocked laptop, a phone, a stolen session \u2014 can then do everything you can without a second check. Signing in, session limits (12 hours idle, 7 days), signing out and ending sessions remain your protection. Creating or widening a way in (passwords, sign-in methods, API tokens, connected apps, people, workers, chat pairing, webhook secrets), or widening what a project's builds and checks reach (its network, where builds write, its home folder and its check access), still asks for your password.";
@@ -23,4 +23,4 @@ export function setPasswordless(store, on, by, now) {
23
23
  store.bumpWake();
24
24
  return { changed: true };
25
25
  }
26
- export const PASSWORDLESS_TRADE = "Anyone holding a signed-in browser — an unlocked laptop, a phone, a stolen session — can then do everything you can without a second check. Signing in, session limits (12 hours idle, 7 days), signing out and ending sessions remain your protection. Creating or widening a way in (passwords, sign-in methods, API tokens, connected apps, people, workers, chat pairing, webhook secrets) still asks for your password.";
26
+ export const PASSWORDLESS_TRADE = "Anyone holding a signed-in browser — an unlocked laptop, a phone, a stolen session — can then do everything you can without a second check. Signing in, session limits (12 hours idle, 7 days), signing out and ending sessions remain your protection. Creating or widening a way in (passwords, sign-in methods, API tokens, connected apps, people, workers, chat pairing, webhook secrets), or widening what a project's builds and checks reach (its network, where builds write, its home folder and its check access), still asks for your password.";
@@ -1,7 +1,8 @@
1
1
  import { type AuditAction } from "./remote-audit.js";
2
2
  import type { ApiTokenRow } from "./store.js";
3
3
  import { type Html } from "./html.js";
4
- export declare const PEOPLE_AUDIT_CSS: string;
4
+ /** The old look; the page is built from the kit now. Kept for chrome.ts, which still concatenates it. */
5
+ export declare const PEOPLE_AUDIT_CSS = "";
5
6
  export type PersonAuditView = {
6
7
  name: string;
7
8
  tokens: readonly ApiTokenRow[];
@@ -3,36 +3,38 @@
3
3
  * (remote-audit.ts). A token's name filters the actions to it (`token-name`: a `token` query is always refused). Read-only: making, revoking and rotating tokens stay
4
4
  * in Settings → Sessions & tokens. Never a token's secret or a ledger line's raw detail.
5
5
  */
6
+ import { settingsSubPage } from "./settings-groups-ui.js";
6
7
  import { basename } from "node:path";
7
8
  import { tokenStatus } from "./remote-audit.js";
8
- import { html } from "./html.js";
9
+ import { html, joinHtml } from "./html.js";
10
+ import { acts, btn, chip, note, settingsRecord, settingsSection } from "./server/kit.js";
9
11
  const stamp = (value) => `${value.slice(0, 16).replace("T", " ")} UTC`;
10
- export const PEOPLE_AUDIT_CSS = `.person-audit{max-width:820px;min-width:0;overflow-wrap:anywhere}.person-audit h2{font-size:1.05rem;margin:1.25rem 0 8px}` +
11
- `.person-audit .rows{display:grid;border:1px solid var(--so-line);border-radius:10px;overflow:hidden}.person-audit .row-item{padding:10px 14px;border-bottom:1px solid var(--so-line);min-width:0}` +
12
- `.person-audit .row-item:last-child{border-bottom:0}.person-audit .row-item p{margin:0}.person-audit .row-item .meta{font-size:.85rem}` +
13
- `.person-audit a{display:inline-flex;align-items:center;min-height:44px}.person-audit .row-item a{min-height:32px}.person-audit a:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px;border-radius:4px}` +
14
- `.person-audit .outcome{display:inline-block;font-size:.8rem;font-weight:600;padding:1px 8px;border-radius:999px;margin-right:8px}` +
15
- `.person-audit .outcome-ok{background:var(--so-success-soft);color:var(--so-success)}.person-audit .outcome-refused{background:var(--so-warning-soft);color:var(--so-ink)}` +
16
- `.person-audit .outcome-error{background:var(--so-danger-soft);color:var(--so-danger)}` +
17
- `@media(max-width:600px){.person-audit .row-item a{min-height:44px}}`;
12
+ /** The old look; the page is built from the kit now. Kept for chrome.ts, which still concatenates it. */
13
+ export const PEOPLE_AUDIT_CSS = "";
18
14
  export const personHref = (name, more = {}) => `/people?${new URLSearchParams({ person: name, ...more }).toString()}`;
19
15
  export function personAuditHtml(view) {
20
- const tokens = view.tokens.length === 0 ? html `<p class="meta">No API tokens.</p>` : html `<div class="rows">${view.tokens.map(one => {
21
- const status = tokenStatus(one, view.now);
22
- return html `<div class="row-item" data-token-name="${one.name}"><p><a href="${personHref(view.name, { "token-name": one.name })}">${one.name}</a></p>\
23
- <p class="meta">${status === "active" ? "Active" : status === "expired" ? "Expired" : "Revoked"} · ${one.access === "act" ? "Can act" : "Read only"} · \
24
- last used ${one.lastUsedAt === null ? "never" : stamp(one.lastUsedAt)}</p></div>`;
25
- })}</div>`;
26
- const filtered = view.token === null ? "" : html `<p class="meta">With ${view.token}</p><p><a href="${personHref(view.name)}">All tokens</a></p>`;
27
- const actions = view.actions.length === 0 ? html `<p class="meta">No remote actions${view.token === null ? "" : " with this token"}.</p>` : html `<div class="rows">${view.actions.map(actionHtml)}</div>`;
28
- const older = view.nextCursor === null ? "" : html `<p><a href="${personHref(view.name, { ...(view.token === null ? {} : { "token-name": view.token }), before: view.nextCursor })}">Older actions</a></p>`;
29
- return html `<p><a href="/people">People</a></p><h1>${view.name}</h1><section class="person-audit"><h2>API tokens</h2>${tokens}\
30
- <h2>Remote activity</h2>${filtered}${actions}${older}</section>`;
16
+ const tokens = settingsSection({ id: "person-tokens", title: "API tokens", lead: "Make, revoke and rotate tokens in Sessions and tokens.",
17
+ body: view.tokens.length === 0 ? note("No API tokens.") : joinHtml(view.tokens.map(one => {
18
+ const status = tokenStatus(one, view.now);
19
+ return settingsRecord({
20
+ title: html `<a class="k-link" href="${personHref(view.name, { "token-name": one.name })}">${one.name}</a>`,
21
+ sub: `${status === "active" ? "Active" : status === "expired" ? "Expired" : "Revoked"} · ${one.access === "act" ? "Can act" : "Read only"} · last used ${one.lastUsedAt === null ? "never" : stamp(one.lastUsedAt)}`,
22
+ attrs: { "data-token-name": one.name },
23
+ });
24
+ })) });
25
+ const filtered = view.token === null ? undefined : btn("All tokens", { sm: true, variant: "ghost", href: personHref(view.name) });
26
+ const older = view.nextCursor === null ? "" : acts(btn("Older actions", { sm: true, href: personHref(view.name, { ...(view.token === null ? {} : { "token-name": view.token }), before: view.nextCursor }) }));
27
+ const activity = settingsSection({ id: "person-activity", title: "Remote activity", ...(view.token === null ? {} : { state: ` · with ${view.token}` }), ...(filtered ? { right: filtered } : {}),
28
+ lead: "What this person did through the toolroll command and agents (MCP), newest first.",
29
+ body: joinHtml([view.actions.length === 0 ? note(`No remote actions${view.token === null ? "" : " with this token"}.`) : joinHtml(view.actions.map(actionHtml)), older]) });
30
+ return settingsSubPage("access", view.name, joinHtml([note(html `<a class="k-link" href="/people">All people</a>`), tokens, activity]));
31
31
  }
32
32
  function actionHtml(action) {
33
- const label = action.outcome === "ok" ? "OK" : action.outcome === "refused" ? "Refused" : "Error";
33
+ const outcome = action.outcome === "ok" ? chip("OK", "done") : action.outcome === "refused" ? chip("Refused", "") : chip("Error", "failed");
34
34
  const detail = [stamp(action.at), action.token ?? "unknown token", action.source === "mcp" ? "Agent (MCP)" : "CLI",
35
35
  ...(action.repo === null ? [] : [basename(action.repo)]), ...(action.reason === null ? [] : [action.reason])];
36
- return html `<div class="row-item" data-action="${action.id}"><p><span class="outcome outcome-${action.outcome}">${label}</span>${action.name}\
37
- ${action.taskId === null ? "" : html ` · <a href="/t/${encodeURIComponent(action.taskId)}">${action.taskId}</a>`}</p><p class="meta">${detail.join(" · ")}</p></div>`;
36
+ return settingsRecord({
37
+ title: html `${outcome} ${action.name}${action.taskId === null ? "" : html ` · <a class="k-link" href="/t/${encodeURIComponent(action.taskId)}">${action.taskId}</a>`}`,
38
+ sub: detail.join(" · "), attrs: { "data-action": action.id },
39
+ });
38
40
  }
@@ -7,7 +7,8 @@
7
7
  import { type Html } from "./html.js";
8
8
  import type { LedgerEntry } from "./action-ledger.js";
9
9
  import { type SavedPolicy } from "./policy.js";
10
- export declare const POLICY_CSS: string;
10
+ /** The old look; the page is built from the kit now. Kept for chrome.ts, which still concatenates it. */
11
+ export declare const POLICY_CSS = "";
11
12
  export type PolicyView = {
12
13
  policy: SavedPolicy;
13
14
  history: LedgerEntry[];
@@ -15,7 +16,7 @@ export type PolicyView = {
15
16
  /** The project tools here, as a hint for the tools list. */
16
17
  toolNames: string[];
17
18
  };
18
- export declare function policyHtml(view: PolicyView, notice: {
19
+ export declare function policyHtml(view: PolicyView, outcome: {
19
20
  said?: string | null;
20
21
  problem?: string | null;
21
22
  }): Html;
package/dist/policy-ui.js CHANGED
@@ -4,33 +4,39 @@
4
4
  * action ledger. Anyone signed in reads it; an instance operator changes it
5
5
  * with their password.
6
6
  */
7
- import { html, postForm, reauth } from "./html.js";
7
+ import { html, joinHtml } from "./html.js";
8
8
  import { whenUtc } from "./when-html.js";
9
9
  import { LEVEL_HINTS, LEVEL_NAMES, PERMISSION_LEVELS, POLICY_PROVIDERS, PROVIDER_NAMES, policyParts } from "./policy.js";
10
- export const POLICY_CSS = `.policy{max-width:720px;min-width:0}.policy dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 16px;margin:12px 0 0;font-size:.875rem}` +
11
- `.policy dt{color:var(--muted-foreground)}.policy dd{margin:0;overflow-wrap:anywhere}.policy fieldset{border:0;padding:0;margin:20px 0 0;min-width:0}` +
12
- `.policy legend{font-weight:600;font-size:.875rem;margin-bottom:8px}.policy legend{padding:0}.policy .providers{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:0 16px}.policy .providers label{display:flex;gap:8px;align-items:center;min-height:44px;margin:0;padding:0;font-weight:400}` +
13
- `.policy .choice{display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px;align-items:start;margin:0 0 10px;font-weight:400}.policy .choice input{margin:.2rem 0 0}` +
14
- `.policy .choice strong{display:block;font-size:.875rem;font-weight:500}.policy .choice small,.policy .hint{display:block;color:var(--muted-foreground);font-size:.8125rem;margin-top:2px}` +
15
- `.policy textarea{width:100%;min-height:72px;font-family:var(--font-mono);font-size:.8125rem;box-sizing:border-box}.policy .step-up{margin-top:18px}.policy button[type=submit]{margin-top:14px}` +
16
- `.policy details{margin-top:28px}.policy summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600;font-size:.875rem}` +
17
- `.policy ol{list-style:none;padding:0;margin:0}.policy li{padding:10px 0;border-bottom:1px solid var(--border);font-size:.8125rem;overflow-wrap:anywhere}.policy li .what{display:block;font-size:.875rem}`;
18
- /** A ledger entry as one line of history: when, who, what, before → after. */
10
+ import { disclose, facts, formRow, infoLine, note, notice, radios, settingsRecord, settingsSection, textarea } from "./server/kit.js";
11
+ import { kitSectionForm } from "./settings-kit.js";
12
+ /** The old look; the page is built from the kit now. Kept for chrome.ts, which still concatenates it. */
13
+ export const POLICY_CSS = "";
14
+ /** A ledger entry as one record of history: what, before → after; who; when. */
19
15
  function historyItem(entry) {
20
16
  const what = entry.action.replace(/^organisation policy: /, "");
21
17
  const [before, after] = (entry.detail ?? "").split(" → ");
22
- return html `<li><span class="what">${what.charAt(0).toUpperCase() + what.slice(1)}: ${before ?? ""} → <strong>${after ?? ""}</strong></span><span class="meta">${entry.actor} · ${whenUtc(entry.at)}</span></li>`;
18
+ return settingsRecord({ title: html `${what.charAt(0).toUpperCase() + what.slice(1)}: ${before ?? ""} → <b>${after ?? ""}</b>`, sub: entry.actor, when: whenUtc(entry.at) });
23
19
  }
24
- export function policyHtml(view, notice) {
25
- const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
20
+ export function policyHtml(view, outcome) {
21
+ const answer = outcome.problem ? notice(outcome.problem) : outcome.said ? infoLine(outcome.said) : "";
26
22
  const parts = policyParts(view.policy);
27
- const changed = view.policy.updatedBy === null ? "" : html `<p class="meta">Changed by ${view.policy.updatedBy} on ${(view.policy.updatedAt ?? "").slice(0, 10)}.</p>`;
28
- const current = html `<dl><dt>Providers</dt><dd>${parts.providers === "any" ? "Any" : parts.providers}</dd><dt>Models</dt><dd>${parts.models === "any" ? "Any" : parts.models}</dd><dt>Tools</dt><dd>${parts.tools === "any" ? "Any" : parts.tools}</dd><dt>Ceiling</dt><dd>${parts.ceiling}</dd></dl>${changed}`;
29
- const history = view.history.length === 0 ? "" : html `<details><summary>History (${view.history.length})</summary><ol>${view.history.map(historyItem)}</ol></details>`;
30
- if (!view.canChange)
31
- return html `<section class="policy">${note}${current}<p class="meta">An instance operator sets the policy.</p>${history}</section>`;
32
- const providers = POLICY_PROVIDERS.map(one => html `<label><input type="checkbox" name="provider" value="${one}"${view.policy.providers === null || view.policy.providers.includes(one) ? html ` checked` : ""}> ${PROVIDER_NAMES[one]}</label>`);
33
- const levels = [...PERMISSION_LEVELS].reverse().map(level => html `<label class="choice"><input type="radio" name="ceiling" value="${level}"${view.policy.ceiling === level ? html ` checked` : ""}><span><strong>${LEVEL_NAMES[level]}</strong><small>${LEVEL_HINTS[level]}</small></span></label>`);
23
+ const changed = view.policy.updatedBy === null ? "" : ` · changed by ${view.policy.updatedBy} on ${(view.policy.updatedAt ?? "").slice(0, 10)}`;
24
+ const history = view.history.length === 0 ? "" : settingsSection({ id: "policy-history", title: "History",
25
+ body: disclose(`${view.history.length} ${view.history.length === 1 ? "change" : "changes"}`, joinHtml(view.history.map(historyItem))) });
26
+ if (!view.canChange) {
27
+ return joinHtml([answer, settingsSection({ id: "policy", title: "Organisation policy", state: changed, lead: "What every task may use, in every project.",
28
+ body: joinHtml([facts([["Providers", parts.providers === "any" ? "Any" : parts.providers], ["Models", parts.models === "any" ? "Any" : parts.models],
29
+ ["Tools", parts.tools === "any" ? "Any" : parts.tools], ["Ceiling", parts.ceiling]]), note("An instance operator sets the policy.")]) }), history]);
30
+ }
31
+ const providers = html `<div class="k-checks" role="group" aria-label="Providers">${POLICY_PROVIDERS.map(one => html `<label for="policy-provider-${one}"><input type="checkbox" id="policy-provider-${one}" name="provider" value="${one}"${view.policy.providers === null || view.policy.providers.includes(one) ? html ` checked` : ""}> ${PROVIDER_NAMES[one]}</label>`)}</div>`;
34
32
  const toolHint = view.toolNames.length === 0 ? "" : ` Tools here: ${view.toolNames.slice(0, 12).join(", ")}${view.toolNames.length > 12 ? "…" : ""}.`;
35
- return html `<section class="policy">${note}${changed}${postForm("/settings/policy", html `<fieldset><legend>Providers</legend><div class="providers">${providers}</div></fieldset><fieldset><legend><label for="policy-models">Models</label></legend><textarea id="policy-models" name="models" spellcheck="false" placeholder="Any model" aria-describedby="policy-models-hint">${(view.policy.models ?? []).join("\n")}</textarea><span class="hint" id="policy-models-hint">One per line; end with * to allow a family. Blank allows any.</span></fieldset><fieldset><legend><label for="policy-tools">Project tools</label></legend><textarea id="policy-tools" name="tools" spellcheck="false" placeholder="Any tool" aria-describedby="policy-tools-hint">${(view.policy.tools ?? []).join("\n")}</textarea><span class="hint" id="policy-tools-hint">One per line. Blank allows any.${toolHint}</span></fieldset><fieldset><legend>Permission ceiling</legend>${levels}</fieldset><div class="step-up">${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}</div><button type="submit">Save policy</button>`)}${history}</section>`;
33
+ return joinHtml([answer, settingsSection({ id: "policy", title: "Organisation policy", state: changed, lead: "What every task may use, in every project.",
34
+ body: kitSectionForm({ proof: "policy", action: "/settings/policy", id: "policy-form", gate: true, save: { label: "Save policy" }, confirm: "Change the policy",
35
+ body: joinHtml([
36
+ formRow("Providers", "Work runs only on the ticked ones. All of them allows any.", providers, "wide"),
37
+ formRow("Models", "One per line; end with * to allow a family. Blank allows any.", textarea({ id: "policy-models", name: "models", value: (view.policy.models ?? []).join("\n"), rows: 3, attrs: { spellcheck: "false", placeholder: "Any model", "aria-label": "Models" } }), "top"),
38
+ formRow("Project tools", `One per line. Blank allows any.${toolHint}`, textarea({ id: "policy-tools", name: "tools", value: (view.policy.tools ?? []).join("\n"), rows: 3, attrs: { spellcheck: "false", placeholder: "Any tool", "aria-label": "Project tools" } }), "top"),
39
+ formRow("Permission ceiling", "The most any task may be allowed to do.", radios({ name: "ceiling", legend: "Permission ceiling", current: view.policy.ceiling,
40
+ items: [...PERMISSION_LEVELS].reverse().map(level => ({ value: level, label: LEVEL_NAMES[level], text: LEVEL_HINTS[level] })) }), "wide"),
41
+ ]) }) }), history]);
36
42
  }
package/dist/policy.d.ts CHANGED
@@ -87,3 +87,6 @@ export declare function checkPolicy(input: {
87
87
  ok: false;
88
88
  problem: string;
89
89
  };
90
+ /** The providers ticked on the policy form: every provider the policy knows ticked (and nothing unknown) means "any"
91
+ * (null), so a provider added later is allowed too; anything else is the list as ticked, for checkPolicy to check. */
92
+ export declare function chosenProviders(chosen: readonly string[]): string[] | null;
package/dist/policy.js CHANGED
@@ -224,7 +224,7 @@ export function checkPolicy(input) {
224
224
  if (input.providers !== null) {
225
225
  const unknown = input.providers.filter(one => policyProvider(one.toLowerCase()) === null);
226
226
  if (unknown.length > 0)
227
- return { ok: false, problem: `${unknown.join(", ")} isn't a provider here. Choose from Claude, Codex, Gemini and OpenRouter.` };
227
+ return { ok: false, problem: `${unknown.join(", ")} isn't a provider here. Choose from ${providerChoices()}.` };
228
228
  providers = POLICY_PROVIDERS.filter(one => input.providers.some(given => policyProvider(given.toLowerCase()) === one));
229
229
  if (providers.length === 0)
230
230
  return { ok: false, problem: "Allow at least one provider, or allow any." };
@@ -248,3 +248,16 @@ export function checkPolicy(input) {
248
248
  return { ok: false, problem: tools.problem };
249
249
  return { ok: true, policy: { providers, models, tools, ceiling: input.ceiling } };
250
250
  }
251
+ /** Every provider the policy knows, in plain names: "Claude, Codex, … and Goose". */
252
+ function providerChoices() {
253
+ const names = POLICY_PROVIDERS.map(one => PROVIDER_NAMES[one]);
254
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
255
+ }
256
+ /** The providers ticked on the policy form: every provider the policy knows ticked (and nothing unknown) means "any"
257
+ * (null), so a provider added later is allowed too; anything else is the list as ticked, for checkPolicy to check. */
258
+ export function chosenProviders(chosen) {
259
+ const known = new Set(chosen.map(one => policyProvider(one.toLowerCase())));
260
+ if (known.has(null))
261
+ return [...chosen];
262
+ return POLICY_PROVIDERS.every(one => known.has(one)) ? null : [...chosen];
263
+ }