toolroll 0.9.84 → 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 (132) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +94 -1193
  3. package/dist/agent-fence.d.ts +9 -5
  4. package/dist/agent-fence.js +6 -5
  5. package/dist/agent-onboard.d.ts +2 -0
  6. package/dist/agent-onboard.js +3 -0
  7. package/dist/assignment.js +4 -3
  8. package/dist/browser/workspace.css +1 -1
  9. package/dist/browser/workspace.js +33 -31
  10. package/dist/browser-workspace.d.ts +45 -0
  11. package/dist/chat-actions.d.ts +11 -1
  12. package/dist/chat-actions.js +53 -1
  13. package/dist/chat-channel.d.ts +10 -1
  14. package/dist/chat-channel.js +36 -5
  15. package/dist/chat-decide.d.ts +16 -4
  16. package/dist/chat-decide.js +239 -31
  17. package/dist/chat-messages.d.ts +5 -0
  18. package/dist/chat-messages.js +1 -1
  19. package/dist/chat-quiet.js +6 -4
  20. package/dist/chat-review.d.ts +4 -0
  21. package/dist/chat-review.js +13 -4
  22. package/dist/chat-terms.d.ts +9 -0
  23. package/dist/chat-terms.js +46 -0
  24. package/dist/claim.d.ts +3 -0
  25. package/dist/claim.js +4 -1
  26. package/dist/contracts/chat-actions.d.ts +9 -0
  27. package/dist/contracts/chat-actions.js +3 -0
  28. package/dist/contracts/console-api.d.ts +6 -2
  29. package/dist/contracts/console-api.js +6 -2
  30. package/dist/contracts/flow-propose.d.ts +4 -1
  31. package/dist/contracts/flow-propose.js +1 -1
  32. package/dist/contracts/flow.d.ts +41 -0
  33. package/dist/contracts/flow.js +4 -2
  34. package/dist/contracts/lead-tools.d.ts +51 -0
  35. package/dist/contracts/lead-tools.js +5 -0
  36. package/dist/contracts/store-json.d.ts +2 -0
  37. package/dist/contracts/trigger-payloads.d.ts +20 -0
  38. package/dist/contracts/trigger-payloads.js +4 -0
  39. package/dist/contracts/youiyoux-request.d.ts +248 -0
  40. package/dist/contracts/youiyoux-request.js +152 -0
  41. package/dist/coordinator.js +1 -0
  42. package/dist/deploy-gate.js +4 -0
  43. package/dist/dispatch.d.ts +3 -0
  44. package/dist/dispatch.js +4 -1
  45. package/dist/flow-browser.d.ts +103 -0
  46. package/dist/flow-browser.js +204 -0
  47. package/dist/flow-cadence.d.ts +8 -2
  48. package/dist/flow-cadence.js +16 -3
  49. package/dist/flow-card-task.d.ts +9 -0
  50. package/dist/flow-card-task.js +15 -6
  51. package/dist/flow-code.d.ts +5 -1
  52. package/dist/flow-code.js +4 -1
  53. package/dist/flow-engine.js +16 -0
  54. package/dist/flow-live.js +9 -1
  55. package/dist/flow-pull-request.js +15 -7
  56. package/dist/flow-share.js +3 -1
  57. package/dist/flow-steps.d.ts +9 -1
  58. package/dist/flow-steps.js +55 -5
  59. package/dist/flow-triggers.js +55 -0
  60. package/dist/flows-cli.d.ts +19 -0
  61. package/dist/flows-cli.js +47 -2
  62. package/dist/flows-ui.js +11 -2
  63. package/dist/flows.js +5 -2
  64. package/dist/integrations-ui.js +9 -4
  65. package/dist/integrations.d.ts +9 -1
  66. package/dist/integrations.js +43 -2
  67. package/dist/landed-cleanup.d.ts +4 -0
  68. package/dist/landed-cleanup.js +3 -2
  69. package/dist/lead-capabilities.js +2 -1
  70. package/dist/lead-cli.js +3 -1
  71. package/dist/lead-tools.d.ts +2 -0
  72. package/dist/lead-tools.js +65 -10
  73. package/dist/lead-voice.js +16 -5
  74. package/dist/lead.d.ts +2 -0
  75. package/dist/lead.js +3 -3
  76. package/dist/operate.d.ts +2 -3
  77. package/dist/operate.js +226 -21
  78. package/dist/project-activity.d.ts +20 -0
  79. package/dist/project-activity.js +23 -0
  80. package/dist/project-cli.d.ts +1 -0
  81. package/dist/project-cli.js +36 -19
  82. package/dist/project-delete.js +7 -3
  83. package/dist/project-knowledge.d.ts +3 -3
  84. package/dist/project-outcomes.d.ts +119 -0
  85. package/dist/project-outcomes.js +79 -0
  86. package/dist/pull-request-flow.js +35 -4
  87. package/dist/result-actions.d.ts +4 -0
  88. package/dist/result-actions.js +41 -9
  89. package/dist/result-follow-ups.js +4 -0
  90. package/dist/result-review.d.ts +9 -0
  91. package/dist/result-review.js +45 -5
  92. package/dist/scope.d.ts +5 -0
  93. package/dist/scope.js +7 -0
  94. package/dist/server/chat.js +4 -2
  95. package/dist/server/console.css +41 -0
  96. package/dist/server/flows.js +15 -0
  97. package/dist/server/pages.js +11 -1
  98. package/dist/server/render-chat.js +3 -2
  99. package/dist/server/render-pages.d.ts +15 -2
  100. package/dist/server/render-pages.js +52 -4
  101. package/dist/server/render-results.d.ts +2 -0
  102. package/dist/server/render-results.js +20 -7
  103. package/dist/server/render-tasks.d.ts +9 -0
  104. package/dist/server/render-tasks.js +32 -11
  105. package/dist/server/route-table.js +12 -0
  106. package/dist/server/settings.js +18 -1
  107. package/dist/server/task-views.js +11 -0
  108. package/dist/server/tasks.d.ts +10 -0
  109. package/dist/server/tasks.js +95 -7
  110. package/dist/settings-proofs.js +1 -0
  111. package/dist/store.d.ts +116 -5
  112. package/dist/store.js +385 -24
  113. package/dist/surface.js +18 -2
  114. package/dist/task-archive.d.ts +10 -0
  115. package/dist/task-archive.js +16 -0
  116. package/dist/task-control.d.ts +1 -1
  117. package/dist/task-source.d.ts +13 -1
  118. package/dist/task-source.js +17 -1
  119. package/dist/telegram-lead.js +7 -5
  120. package/dist/telegram.js +1 -1
  121. package/dist/tidy.d.ts +130 -0
  122. package/dist/tidy.js +503 -0
  123. package/dist/toolroll-update.js +2 -2
  124. package/dist/webhooks.d.ts +6 -0
  125. package/dist/webhooks.js +25 -0
  126. package/dist/work-index.d.ts +28 -0
  127. package/dist/work-index.js +82 -28
  128. package/dist/workspace-ui.d.ts +1 -1
  129. package/dist/workspace-ui.js +3 -2
  130. package/dist/youiyoux-intake.d.ts +234 -0
  131. package/dist/youiyoux-intake.js +908 -0
  132. package/package.json +11 -8
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";
@@ -54,6 +56,7 @@ import { packageVersion, startMonitoring, targetOf } from "./monitoring.js";
54
56
  import { origin, readMonitoring } from "./monitoring-settings.js";
55
57
  import { logEvent } from "./log.js";
56
58
  import { FLOW_EVERY_MS, flowHousekeeping, moveCards, moveCardsAfter } from "./flow-cadence.js";
59
+ import { NO_YOUIYOUX_PASS, youiyouxOnboardLine } from "./youiyoux-intake.js";
57
60
  import { readHooksBase, scheduleFromWords } from "./flow-triggers.js";
58
61
  import { sendSubagentSummaries } from "./subagent-admin.js";
59
62
  import { runRequestedUndos, sendSubagentWeeklies } from "./subagent-week.js";
@@ -104,7 +107,7 @@ import { taskReviewBrief, renderReviewBrief } from "./task-review-brief.js";
104
107
  import { homedir, hostname } from "node:os";
105
108
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
106
109
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
107
- import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
110
+ import { openStore, openStoreNoMigrate, archiveTaskFamily, restoreTaskFamily, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
108
111
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
109
112
  import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
110
113
  import { authorizePlanUnderMode } from "./plan-auto.js";
@@ -227,7 +230,7 @@ import { isTaskSize, legOf, projectRoute, routeDigestOf, routeWords, TASK_SIZES,
227
230
  import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
228
231
  import { parseDemoUrl, projectDemoUrl, saveProjectDemo } from "./project-demo.js";
229
232
  import { effectiveConcurrency, maySlotTake, parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, ProjectPasses, projectConcurrency, saveProjectConcurrency, savedProjectConcurrency } from "./project-concurrency.js";
230
- import { activeWebhookTargets, webhookPass, LEGACY_WEBHOOK_WARNING, loadWebhookTargets, effectivePrimary, isMessagingChannel, loadConsoleUrl, phoneOrigin, saveConsoleUrl, savePrimary } from "./webhooks.js";
233
+ import { activeWebhookTargets, webhookPass, LEGACY_WEBHOOK_WARNING, loadWebhookTargets, effectivePrimary, isMessagingChannel, loadConsoleUrl, phoneOrigin, readUpConsoleUrl, saveConsoleUrl, savePrimary, UP_CONSOLE_FILE } from "./webhooks.js";
231
234
  import { auditOf, inspectionOf, isProviderId, MONEY_CAPABILITIES, PROVIDER_IDS, validModelId, validateSpec, ALL_CREDENTIAL_ENV } from "./provider.js";
232
235
  import { attestProvider, attestationOf, versionInRange } from "./attest.js";
233
236
  import { build, proveApprovedProfile, } from "./builder.js";
@@ -272,12 +275,17 @@ export const OPERATE_HELP = `toolroll — operating the queue
272
275
  the lead passes it as --token, or TOOLROLL_LEAD_TOKEN, and its work pings nobody
273
276
  toolroll task ask <id> --person <name> --why "<text>"
274
277
  hand a task to a person: it pings them, and it is theirs
275
- toolroll task list [--view <v>] [--limit <n>] [--cursor <c>] paginated saved task status
278
+ toolroll task list [--view <v>] [--limit <n>] [--cursor <c>] paginated saved task status (--view archived: put away)
279
+ toolroll task archive <id...> [--reason <why>] put tasks away: out of every list, history kept (a person's act)
280
+ toolroll task restore <id...> bring put-away tasks back exactly as they were
281
+ toolroll task tidy --preview [--days N] what Put away old tasks would put away, and why (read-only)
282
+ toolroll task tidy --apply --digest <d> [--except <id,...>] put away exactly that list
276
283
  toolroll task show <id>
277
284
  toolroll task wait <id> [--timeout <seconds>]
278
285
  wait for this attempt to finish or need a person
279
286
  toolroll project use <path> remember a saved project (optional --token-file)
280
287
  toolroll project show show the current project and credential reference
288
+ toolroll project activity --json read the current project supervision report
281
289
  toolroll project rules --repo <p> a project's approval rules; an instance operator changes them with
282
290
  --not-requester on|off, --protect none|project, --protect-paths "a/**,b/**"
283
291
  toolroll project network --repo <p> [--allow h,…] [--remove h,…] [--open|--allowlist] [--local any|none]
@@ -661,7 +669,7 @@ export const TASK_ACTIONS = [
661
669
  "add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "pause", "continue", "assign",
662
670
  "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan",
663
671
  "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask",
664
- "checks", "add-tests",
672
+ "checks", "add-tests", "archive", "restore", "tidy",
665
673
  ];
666
674
  export const PUBLISH_ACTIONS = ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
667
675
  export const CONFIG_ACTIONS = ["show", "set", "clear"];
@@ -705,6 +713,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
705
713
  "starter",
706
714
  // flows: templates, steps, scripts and cards.
707
715
  "template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone", "option",
716
+ // task tidy --apply: the previewed tasks to leave out.
717
+ "except",
708
718
  "token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
709
719
  // pings follow responsibility: replacements.
710
720
  "replaced-by", "replaces",
@@ -755,6 +765,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
755
765
  "password-stdin",
756
766
  // task steer: pause the running Claude build, then send the note.
757
767
  "now",
768
+ // task tidy: look first (--preview), then put away exactly what was shown (--apply --digest).
769
+ "preview", "apply",
758
770
  ]);
759
771
  /** `project network` reads --remove <hosts> and --local any|none as values, where every other verb's --remove and
760
772
  * --local are switches. */
@@ -1562,6 +1574,8 @@ function claimCommand(positional, flags, context) {
1562
1574
  return fail(write, json, "claim", "sweep-limit", `its sweep already has ${result.limit} running at once — it starts when one finishes`, EXIT.refused, { sweep: result.sweep, limit: result.limit });
1563
1575
  if (result.reason === "watch-custody")
1564
1576
  return fail(write, json, "claim", "watch-custody", "This worker no longer holds the project; restart the watch", EXIT.refused);
1577
+ if (result.reason === "archived")
1578
+ return fail(write, json, "claim", "archived", `${id} is put away — restore it with \`toolroll task restore ${id}\` first`, EXIT.refused);
1565
1579
  if (result.reason === "draining")
1566
1580
  return fail(write, json, "claim", "draining", `Toolroll is restarting (draining): ${runner} takes no new work until it is back (by ${result.until})`, EXIT.refused, { until: result.until });
1567
1581
  return fail(write, json, "claim", result.reason, `held by ${result.by} until ${result.until}`, EXIT.refused, { holder: result.by, until: result.until });
@@ -2259,6 +2273,7 @@ function flowIoOf(context, pool, git, base) {
2259
2273
  dir: context.flowStepIo?.dir ?? dir, scratch: context.flowStepIo?.scratch ?? join(pool, "flow-checks"), base,
2260
2274
  ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }) },
2261
2275
  ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
2276
+ youiyouxGit: gitOf(context.gitRunner),
2262
2277
  };
2263
2278
  }
2264
2279
  /**
@@ -3384,8 +3399,10 @@ async function tickCommand(flags, context) {
3384
3399
  syncAssignmentHandoffs(store, clock(), auth.runner.repos, context.evidenceRoot);
3385
3400
  settleFinished();
3386
3401
  const replied = replyPass.taken > 0 || replyPass.problem !== null;
3387
- const flows = flowPass.moved + flowPass.filed.length + flowPass.problems.length + triggerPass.added + triggerPass.problems.length + stepPass.ran + stepPass.problems.length === 0 && !replied ? {} : { flows: { ...flowPass,
3388
- ...(triggerPass.added + triggerPass.problems.length === 0 ? {} : { triggers: triggerPass }), ...(stepPass.ran + stepPass.problems.length === 0 ? {} : { steps: stepPass }), ...(replied ? { replies: replyPass } : {}) } };
3402
+ const requestPass = housekeeping?.youiyoux ?? NO_YOUIYOUX_PASS;
3403
+ const requested = requestPass.filed + requestPass.copied + requestPass.decided + requestPass.problems.length > 0;
3404
+ const flows = flowPass.moved + flowPass.filed.length + flowPass.problems.length + triggerPass.added + triggerPass.problems.length + stepPass.ran + stepPass.problems.length === 0 && !replied && !requested ? {} : { flows: { ...flowPass,
3405
+ ...(triggerPass.added + triggerPass.problems.length === 0 ? {} : { triggers: triggerPass }), ...(requested ? { youiyoux: requestPass } : {}), ...(stepPass.ran + stepPass.problems.length === 0 ? {} : { steps: stepPass }), ...(replied ? { replies: replyPass } : {}) } };
3389
3406
  const summary = () => {
3390
3407
  const lines = [`Considered ${considered}, built ${built}, parked ${parked}, broke ${broke}.`];
3391
3408
  for (const entry of dispatched) {
@@ -3557,6 +3574,13 @@ async function reconcileCommand(flags, context) {
3557
3574
  remotes.close();
3558
3575
  }
3559
3576
  const landedGone = landed.steps.filter(one => one.outcome === "removed");
3577
+ // Tidy (task-tidy.md): old routine runs a newer run replaces, release tasks already published or replaced, and CI
3578
+ // notices main has fixed are put away, at most a bounded number a pass, each with its reason in the ledger.
3579
+ let tidied = [];
3580
+ try {
3581
+ tidied = runTidy(store, clock(), releaseFactsFor(context.databaseFile), TIDY_PASS_LIMIT, { principal: "operator", repos: [repo], includeUnplaced: false });
3582
+ }
3583
+ catch { /* the next pass tries again */ }
3560
3584
  // Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
3561
3585
  // the task is complete or cancelled); their branches stay. Each removal is in the ledger.
3562
3586
  // Completing or cancelling a task stops anything still running from its checkout first.
@@ -3600,6 +3624,7 @@ async function reconcileCommand(flags, context) {
3600
3624
  slimmed.slimmed.length === 0 &&
3601
3625
  landed.completed.length === 0 &&
3602
3626
  landedGone.length === 0 &&
3627
+ tidied.length === 0 &&
3603
3628
  liveSwept.removed.length === 0;
3604
3629
  return succeed(write, json, "reconcile", {
3605
3630
  recovered,
@@ -3611,6 +3636,7 @@ async function reconcileCommand(flags, context) {
3611
3636
  processesStopped: [...closedRuns.map(one => ({ run: one.runId, pids: one.done.stopped.map(row => row.pid) })), ...closedCheckouts.map(one => ({ checkout: one.path, pids: one.done.stopped.map(row => row.pid) }))],
3612
3637
  checkoutsSlimmed: slimmed.slimmed.map(one => one.path),
3613
3638
  landed: { completed: landed.completed, steps: landed.steps },
3639
+ tidied: tidied.map(one => ({ id: one.taskId, rule: one.rule, reason: one.reason })),
3614
3640
  liveViewsSwept: liveSwept.removed.length,
3615
3641
  retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
3616
3642
  storageSweep: storageSwept,
@@ -3625,6 +3651,7 @@ async function reconcileCommand(flags, context) {
3625
3651
  ...closedRuns.map(one => `Run #${one.runId} ended; stopped ${stoppedWords(one.done)}.`),
3626
3652
  ...closedCheckouts.map(one => `${one.path}: its task finished; stopped ${stoppedWords(one.done)}.`),
3627
3653
  ...(landed.completed.length === 0 ? [] : [`Marked ${landed.completed.length} landed result(s) complete: ${landed.completed.join(", ")}.`]),
3654
+ ...(tidied.length === 0 ? [] : [`Put away ${tidied.length} old task(s): ${tidied.map(one => one.taskId).join(", ")}.`]),
3628
3655
  ...(landedGone.length === 0 ? [] : [`Removed landed work: ${landedGone.filter(one => one.kind === "checkout").length} checkout(s), ${landedGone.filter(one => one.kind === "branch").length} local branch(es).`]),
3629
3656
  ...(slimmed.slimmed.length === 0 ? [] : [`Dropped dependencies and build output from ${slimmed.slimmed.length} kept checkout(s), about ${bytesWords(slimmed.slimmed.reduce((sum, one) => sum + one.bytes, 0))}.`]),
3630
3657
  ...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
@@ -4107,6 +4134,7 @@ async function requeueTask(positional, flags, context) {
4107
4134
  published: `${id}'s last result is published; revise it through its pull request`,
4108
4135
  // Only an Approve & build again asks for an exact build; the CLI never does.
4109
4136
  moved: `${id} changed since its page was read; read it again`,
4137
+ archived: `${id} is put away — restore it with \`toolroll task restore ${id}\` first`,
4110
4138
  };
4111
4139
  return fail(write, json, "task requeue", result.reason, why[result.reason], EXIT.refused);
4112
4140
  }
@@ -6396,7 +6424,7 @@ async function runWatchLoop(args) {
6396
6424
  base = head.stdout.trim();
6397
6425
  }
6398
6426
  const pass = await flowHousekeeping(store, repo, context.clock, flowIoOf(context, flowPool, flowGit, base), admissionStopped);
6399
- flowsDid(pass.flows.moved + pass.triggers.added + pass.steps.ran + pass.replies.taken, pass.flows.filed.length, `flow pass — ${pass.triggers.added} card(s) added, ${pass.replies.taken} reply(ies) taken, ${pass.steps.ran} step(s) run, ${pass.flows.moved} moved`);
6427
+ flowsDid(pass.flows.moved + pass.triggers.added + pass.steps.ran + pass.replies.taken, pass.flows.filed.length + pass.youiyoux.filed, `flow pass — ${pass.triggers.added} card(s) added, ${pass.youiyoux.filed} youiyoux request(s) filed, ${pass.replies.taken} reply(ies) taken, ${pass.steps.ran} step(s) run, ${pass.flows.moved} moved`);
6400
6428
  },
6401
6429
  });
6402
6430
  const modelWatch = startMaintenance({
@@ -6936,8 +6964,6 @@ export function openBrowser(url) {
6936
6964
  // A browser that will not open is a URL the greeting already printed.
6937
6965
  }
6938
6966
  }
6939
- /** Where `up` last served its console, kept beside the database so `onboard` names the same address. */
6940
- const UP_CONSOLE_FILE = "up-console.json";
6941
6967
  function recordUpConsoleUrl(dir, url, now) {
6942
6968
  try {
6943
6969
  const file = join(dir, UP_CONSOLE_FILE), temporary = `${file}.${process.pid}.tmp`;
@@ -6948,15 +6974,6 @@ function recordUpConsoleUrl(dir, url, now) {
6948
6974
  // A display fact: a failed write only leaves onboard on the default port.
6949
6975
  }
6950
6976
  }
6951
- export function readUpConsoleUrl(dir) {
6952
- try {
6953
- const parsed = JSON.parse(readFileSync(join(dir, UP_CONSOLE_FILE), "utf8"));
6954
- return typeof parsed.url === "string" && /^https?:\/\/[^\s]+$/.test(parsed.url) ? parsed.url.replace(/\/+$/, "") : null;
6955
- }
6956
- catch {
6957
- return null;
6958
- }
6959
- }
6960
6977
  /** How onboard says to sign in: the saved login file only while it still works (as `up` advertises it), else the
6961
6978
  * account and its password; null only when there is no account yet. Never the password itself. */
6962
6979
  function onboardLogin(store, loginFile) {
@@ -7041,6 +7058,8 @@ async function onboardCommand(flags, context) {
7041
7058
  return `off: ${checked.message}`;
7042
7059
  return `off: turn on with toolroll publish setup --repo ${repo} --yes (asks your password), or in Settings → Projects`;
7043
7060
  }),
7061
+ // A project that uses youiyoux has its change requests filed as tasks with no step of its own (youiyoux-intake.ts).
7062
+ youiyoux: seams.youiyoux ?? (async (repo) => youiyouxOnboardLine(store, repo)),
7044
7063
  starterFlag: text(flags, "starter"),
7045
7064
  // Starter flows belong to a person: the flow's decisions ask them. With no account yet, none can be switched on.
7046
7065
  starters: {
@@ -8571,6 +8590,12 @@ function taskAction(action, rest, flags, context) {
8571
8590
  return addTestsTask(rest, flags, context);
8572
8591
  case "list":
8573
8592
  return listTasks(flags, context);
8593
+ case "archive":
8594
+ return archiveTasksCommand(rest, flags, context);
8595
+ case "restore":
8596
+ return restoreTasksCommand(rest, flags, context);
8597
+ case "tidy":
8598
+ return tidyTasksCommand(rest, flags, context);
8574
8599
  case "show":
8575
8600
  return showTask(rest, context);
8576
8601
  case "wait":
@@ -8992,9 +9017,9 @@ function listTasks(flags, context) {
8992
9017
  const limit = rawLimit === undefined ? 40 : Number(rawLimit);
8993
9018
  if (wanted !== undefined && !STATES.includes(wanted))
8994
9019
  return fail(write, json, 'task list', 'usage', `--state takes one of ${STATES.join(', ')}`, EXIT.usage);
8995
- if (rawView !== undefined && !['all', 'needs-you', 'running', 'completed'].includes(rawView) ||
9020
+ if (rawView !== undefined && !['all', 'needs-you', 'running', 'completed', 'archived'].includes(rawView) ||
8996
9021
  !Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
8997
- return fail(write, json, 'task list', 'usage', '--view takes all, needs-you, running or completed; --limit takes 1–100', EXIT.usage);
9022
+ return fail(write, json, 'task list', 'usage', '--view takes all, needs-you, running, completed or archived; --limit takes 1–100', EXIT.usage);
8998
9023
  }
8999
9024
  // A remote person limited to some projects (remoteLensOf) pages and counts only theirs.
9000
9025
  const lens = context.principal?.lens ?? null;
@@ -9021,7 +9046,8 @@ function listTasks(flags, context) {
9021
9046
  write(wanted === undefined ? 'The task list is empty.' : `Nothing is ${wanted}.`);
9022
9047
  const width = Math.max(0, ...tasks.map(task => task.id.length));
9023
9048
  for (const task of tasks)
9024
- write(` ${task.id.padEnd(width)} ${task.status.label.padEnd(16)} ${task.title}`);
9049
+ write(task.archived === undefined ? ` ${task.id.padEnd(width)} ${task.status.label.padEnd(16)} ${task.title}`
9050
+ : ` ${task.id.padEnd(width)} ${'Archived'.padEnd(16)} ${task.title} — by ${task.archived.byKind === 'tidy' ? 'tidy' : task.archived.by}: ${task.archived.reason}`);
9025
9051
  if (page.nextCursor !== null)
9026
9052
  write(`Next page: --cursor ${page.nextCursor}`);
9027
9053
  return EXIT.ok;
@@ -10153,6 +10179,8 @@ function describeApproveFailure(reason, id) {
10153
10179
  // Approval policy, phase 1: the lead's token (or a remote API token) proves a credential, not a person.
10154
10180
  if (reason === "machine-proof")
10155
10181
  return "only a person can approve this — approve it on the task page or from your paired chat";
10182
+ if (reason === "archived")
10183
+ return `${id} is put away — restore it with \`toolroll task restore ${id}\` first`;
10156
10184
  if (reason === "second-approver")
10157
10185
  return `your approval of ${id} is recorded; it is protected work, so a second person needs to approve it (toolroll task approve ${id} as them)`;
10158
10186
  if (reason === "no-approvers") {
@@ -12758,3 +12786,180 @@ export function consoleIdentitiesInBackground(identities, paths) {
12758
12786
  export function writeModelRow(store, proved, scope, knownProject, slot, to, now, extra = []) {
12759
12787
  return applyModelChoices(store, { scope, change: slot === null ? [] : [{ slot, to }], expected: null, strict: false, knownProject, extra }, { ...proved, live: () => true }, now);
12760
12788
  }
12789
+ /** Putting away and restoring are a person's acts (docs/plans/task-tidy.md): never the lead's token, never a remote
12790
+ * API token, never a machine proof. Null when a person is acting. */
12791
+ function refuseMachineTidy(context, command, method) {
12792
+ if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || (method !== undefined && !personProof(method))) {
12793
+ return fail(context.write, context.json, command, "person-only", "Only a person puts tasks away or restores them. Use the console or your own login.", EXIT.refused);
12794
+ }
12795
+ return null;
12796
+ }
12797
+ /** `toolroll task archive <id...> [--reason <text>]`: put tasks away by hand (v145). Each keeps its history, results and
12798
+ * approvals; nothing can run, claim or approve it until it is restored. Credentialed, a person's act. */
12799
+ async function archiveTasksCommand(positional, flags, context) {
12800
+ const { store, write, json, clock } = context;
12801
+ const command = "task archive";
12802
+ const ids = [...new Set(positional)];
12803
+ if (ids.length === 0 || ids.length > 100)
12804
+ return fail(write, json, command, "usage", "`toolroll task archive <id...> [--reason <why>] --as <you> --token <t>` — 1 to 100 tasks", EXIT.usage);
12805
+ const machine = refuseMachineTidy(context, command);
12806
+ if (machine !== null)
12807
+ return machine;
12808
+ const demo = refuseDemo(context, command);
12809
+ if (demo !== null)
12810
+ return demo;
12811
+ const acting = await askCredentials(flags, context);
12812
+ // The password is proved against a project the person can see (the first task's); each task's own project is
12813
+ // re-proved inside its put-away transaction.
12814
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, ids));
12815
+ if (acting === null || !verified?.ok)
12816
+ return refuseApprover(context, command, verified, "putting a task away takes `--as <you> --token <t>` — it is recorded against your name", ids[0]);
12817
+ const personal = refuseMachineTidy(context, command, verified.method);
12818
+ if (personal !== null)
12819
+ return personal;
12820
+ const reason = text(flags, "reason") ?? "Put away by hand";
12821
+ const archived = [], refused = [];
12822
+ for (const id of ids) {
12823
+ const ref = store.lookupRef(id);
12824
+ if (ref === null || store.getTask(id) === null || !store.accountCanAccess(acting.name, ref.repo)) {
12825
+ refused.push({ id, reason: "unknown-task", message: `No task ${id}.` });
12826
+ continue;
12827
+ }
12828
+ const done = archiveTaskFamily(store, id, { by: acting.name, byKind: "person", rule: null, reason }, clock(), { guard: refs => approverStandsNow(store, acting.name, familyRepoOf(store, refs)) });
12829
+ if (done.ok)
12830
+ archived.push({ id, versions: done.refs.length });
12831
+ else
12832
+ refused.push({ id, reason: done.reason, message: done.message });
12833
+ }
12834
+ const lines = () => [
12835
+ ...archived.map(one => `Put away ${one.id}${one.versions > 1 ? ` (${one.versions} versions)` : ""}.`),
12836
+ ...refused.map(one => `Not put away: ${one.message}`),
12837
+ ...(archived.length === 0 ? [] : [`Restore with \`toolroll task restore ${archived.map(one => one.id).join(" ")}\`.`]),
12838
+ ];
12839
+ if (refused.length === 0)
12840
+ return succeed(write, json, command, { archived, refused }, lines);
12841
+ if (json)
12842
+ return fail(write, json, command, refused[0].reason, refused[0].message, EXIT.refused, { archived, refused });
12843
+ write(lines().join("\n"));
12844
+ return EXIT.refused;
12845
+ }
12846
+ /** `toolroll task restore <id...>`: bring put-away tasks back exactly as they were (v145). Credentialed, a person's act. */
12847
+ async function restoreTasksCommand(positional, flags, context) {
12848
+ const { store, write, json, clock } = context;
12849
+ const command = "task restore";
12850
+ const ids = [...new Set(positional)];
12851
+ if (ids.length === 0 || ids.length > 100)
12852
+ return fail(write, json, command, "usage", "`toolroll task restore <id...> --as <you> --token <t>` — 1 to 100 tasks", EXIT.usage);
12853
+ const machine = refuseMachineTidy(context, command);
12854
+ if (machine !== null)
12855
+ return machine;
12856
+ const demo = refuseDemo(context, command);
12857
+ if (demo !== null)
12858
+ return demo;
12859
+ const acting = await askCredentials(flags, context);
12860
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, ids));
12861
+ if (acting === null || !verified?.ok)
12862
+ return refuseApprover(context, command, verified, "restoring takes `--as <you> --token <t>` — it is recorded against your name", ids[0]);
12863
+ const personal = refuseMachineTidy(context, command, verified.method);
12864
+ if (personal !== null)
12865
+ return personal;
12866
+ const restored = [], refused = [];
12867
+ for (const id of ids) {
12868
+ const ref = store.lookupRef(id);
12869
+ if (ref === null || store.getTask(id) === null || !store.accountCanAccess(acting.name, ref.repo)) {
12870
+ refused.push({ id, reason: "unknown-task", message: `No task ${id}.` });
12871
+ continue;
12872
+ }
12873
+ const done = restoreTaskFamily(store, id, acting.name, clock(), refs => approverStandsNow(store, acting.name, familyRepoOf(store, refs)));
12874
+ if (done.ok)
12875
+ restored.push(id);
12876
+ else
12877
+ refused.push({ id, reason: done.reason, message: done.message });
12878
+ }
12879
+ const lines = () => [...restored.map(id => `Restored ${id}.`), ...refused.map(one => `Not restored: ${one.message}`)];
12880
+ if (refused.length === 0)
12881
+ return succeed(write, json, command, { restored, refused }, lines);
12882
+ if (json)
12883
+ return fail(write, json, command, refused[0].reason, refused[0].message, EXIT.refused, { restored, refused });
12884
+ write(lines().join("\n"));
12885
+ return EXIT.refused;
12886
+ }
12887
+ /** `toolroll task tidy --preview [--days N]` (read-only, no credential) and `toolroll task tidy --apply --digest <d>
12888
+ * [--days N] [--except <id,...>]` (credentialed, a person's act): the console's Put away old tasks, word for word,
12889
+ * with the same preview and digest. Apply puts away exactly what the preview with that digest showed. */
12890
+ async function tidyTasksCommand(positional, flags, context) {
12891
+ const { store, write, json, clock } = context;
12892
+ const command = "task tidy";
12893
+ const preview = flag(flags, "preview"), apply = flag(flags, "apply");
12894
+ const days = tidyDays(text(flags, "days"));
12895
+ const digest = text(flags, "digest");
12896
+ const except = (text(flags, "except") ?? "").split(",").map(one => one.trim()).filter(one => one !== "");
12897
+ const usage = "`toolroll task tidy --preview [--days N]`, then `toolroll task tidy --apply --digest <d> [--days N] [--except <id,...>]`";
12898
+ if (positional.length > 0 || preview === apply || days === null || (preview && (digest !== undefined || except.length > 0)) || (apply && digest === undefined))
12899
+ return fail(write, json, command, "usage", usage, EXIT.usage);
12900
+ const releases = releaseFactsFor(context.databaseFile);
12901
+ // One scope for the preview and the apply: `--as <you>` previews exactly your projects (no password: it reads), and
12902
+ // the apply reads the same person's projects, so an unchanged list keeps its digest.
12903
+ // Whose lists: `--as <you>`, else this machine's saved login (`toolroll up`), else everything (an unrestricted
12904
+ // person's list; a person limited to some projects previews with --as).
12905
+ const previewAs = text(flags, "as") ?? readLoginFile(join(dirname(context.databaseFile), UP_LOGIN_FILE))?.name;
12906
+ const daysArg = `${days === TIDY_DEFAULT_DAYS ? "" : ` --days ${days}`}${previewAs === undefined ? "" : ` --as ${previewAs}`}`;
12907
+ // `--as` only ever narrows: the named person's projects within the caller's own lens, never beyond it.
12908
+ const lens = context.principal?.lens ?? null;
12909
+ const own = { principal: "operator", repos: lens, includeUnplaced: lens === null };
12910
+ const access = previewAs !== undefined ? withinLens(accessOf(store, previewAs), lens) : own;
12911
+ if (preview) {
12912
+ const items = tidyPreview(store, clock(), { releases, days, access });
12913
+ const shown = tidyDigest(items, days);
12914
+ const groups = tidyGroups(items, days);
12915
+ const next = `toolroll task tidy --apply --digest ${shown}${daysArg}`;
12916
+ return succeed(write, json, command, { preview: true, days, digest: shown, count: items.length, groups, next: items.length === 0 ? null : next,
12917
+ ...(releases.problem === null ? {} : { releasesSkipped: releases.problem }) }, () => items.length === 0
12918
+ ? ["Nothing to put away."]
12919
+ : [`Put away old tasks: ${items.length} task${items.length === 1 ? "" : "s"}.`,
12920
+ ...groups.flatMap(group => ["", `${group.label} (${group.items.length})`, ...group.items.map(one => ` ${one.taskId} ${one.reason}${one.members.length > 1 ? ` (${one.members.length} versions)` : ""}`)]),
12921
+ ...(releases.problem === null ? [] : ["", `Release tasks were left out: ${releases.problem}.`]),
12922
+ "", `To put these away, run: ${next}`, "Leave some out with --except <id,...>.",
12923
+ ...(previewAs === undefined ? ["Limited to some projects? Preview with --as <you> so the list is yours."] : [])]);
12924
+ }
12925
+ const machine = refuseMachineTidy(context, command);
12926
+ if (machine !== null)
12927
+ return machine;
12928
+ const demo = refuseDemo(context, command);
12929
+ if (demo !== null)
12930
+ return demo;
12931
+ const acting = await askCredentials(flags, context);
12932
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token, firstVisibleRepo(store, acting.name, []));
12933
+ if (acting === null || !verified?.ok)
12934
+ return refuseApprover(context, command, verified, "putting tasks away takes `--as <you> --token <t>` — it is recorded against your name", undefined);
12935
+ const personal = refuseMachineTidy(context, command, verified.method);
12936
+ if (personal !== null)
12937
+ return personal;
12938
+ // The person's own view of the lists, from the verified credential (never a flag), within the caller's lens: the
12939
+ // same scope `--preview --as <them>` read.
12940
+ const mine = withinLens(accessOf(store, acting.name), lens);
12941
+ const shownItems = tidyPreview(store, clock(), { releases, days, access: mine });
12942
+ const only = except.length === 0 ? null : shownItems.map(one => one.taskId).filter(id => !except.includes(id));
12943
+ if (except.some(id => !shownItems.some(one => one.taskId === id)))
12944
+ return fail(write, json, command, "outside", "--except names a task that isn't in the list. Check it again with --preview.", EXIT.refused);
12945
+ const done = applyTidy(store, clock(), { by: acting.name, days, digest: digest, only, releases, access: mine });
12946
+ if (!done.ok) {
12947
+ const again = `toolroll task tidy --apply --digest ${done.digest}${daysArg}`;
12948
+ return fail(write, json, command, done.reason, done.reason === "changed" ? `${done.message} Look again with \`toolroll task tidy --preview${daysArg}\`.` : done.message, EXIT.refused, { digest: done.digest, count: done.items.length, ...(done.reason === "changed" ? { next: done.items.length === 0 ? null : again } : {}) });
12949
+ }
12950
+ return succeed(write, json, command, { archived: done.archived.map(one => ({ id: one.taskId, rule: one.rule, reason: one.reason })), skipped: done.skipped }, () => [
12951
+ `Put away ${done.archived.length} task${done.archived.length === 1 ? "" : "s"}.`,
12952
+ ...done.skipped.map(one => `Not put away: ${one.message}`),
12953
+ "Find them with `toolroll task list --view archived`; bring one back with `toolroll task restore <id>`.",
12954
+ ]);
12955
+ }
12956
+ /** The project a put-away or restore proves the person's password against: the first named task's, when they can see
12957
+ * it; otherwise the first project they can see at all (null when none: an unrestricted person, or none to prove). */
12958
+ function firstVisibleRepo(store, name, ids) {
12959
+ for (const id of ids) {
12960
+ const repo = store.lookupRef(id)?.repo ?? null;
12961
+ if (repo !== null && store.accountCanAccess(name, repo))
12962
+ return repo;
12963
+ }
12964
+ return store.accountCanAccess(name, null) ? null : store.knownRepos().find(repo => store.accountCanAccess(name, repo)) ?? null;
12965
+ }
@@ -0,0 +1,20 @@
1
+ /** Shared presentation for browser and no-script Activity; facts come only
2
+ * from projectOutcomes. No alternate counting or status interpretation. */
3
+ import type { ActivityFamily, ProjectOutcomes } from './project-outcomes.js';
4
+ export declare const activityAge: (ms: number | null) => string;
5
+ export declare const activityMoney: (usd: number | null) => string;
6
+ export declare const ACTIVITY_ACTORS: {
7
+ human: string;
8
+ lead: string;
9
+ landed: string;
10
+ 'machine-or-unknown': string;
11
+ };
12
+ export type ActivityMetric = {
13
+ label: string;
14
+ value: string;
15
+ detail: string;
16
+ rows: ActivityFamily[];
17
+ };
18
+ export declare const activityScope: (report: ProjectOutcomes) => string;
19
+ export declare const activityLimit: (report: ProjectOutcomes) => string;
20
+ export declare function activityMetrics(report: ProjectOutcomes): ActivityMetric[];
@@ -0,0 +1,23 @@
1
+ export const activityAge = (ms) => ms === null ? 'Unknown' : ms < 3600000 ? `${Math.floor(ms / 60000)}m` : ms < 86400000 ? `${(ms / 3600000).toFixed(1)}h` : `${(ms / 86400000).toFixed(1)}d`;
2
+ export const activityMoney = (usd) => usd === null ? 'Unknown' : `$${usd.toFixed(2)}`;
3
+ export const ACTIVITY_ACTORS = { human: 'Human', lead: 'Lead', landed: 'Automatically landed', 'machine-or-unknown': 'Machine or unknown' };
4
+ export const activityScope = (report) => `Current Ready backlog; completions in the last ${report.days} days. Completion does not mean checks passed or work deployed.`;
5
+ export const activityLimit = (report) => `Partial report: only the ${report.limit} most recently updated eligible families are included. Counts and coverage below describe this sample.`;
6
+ export function activityMetrics(report) {
7
+ const ready = report.families.filter(one => one.state === 'ready');
8
+ const complete = report.families.filter(one => one.state === 'completed');
9
+ const metrics = [
10
+ { label: 'Ready', value: report.ready.count === 0 ? '0' : `${report.ready.count} · Oldest ${activityAge(report.ready.oldestFinishedAgeMs)}`,
11
+ detail: `Age is measured from the latest result finishing, a proxy for Ready entry. ${report.ready.dated}/${report.ready.count} have usable timestamps.`, rows: ready },
12
+ { label: 'Finished to decision', value: report.completed.count === 0 ? 'No completed families' : `${activityAge(report.finishedToDecision.meanMs)} average`,
13
+ detail: `${report.finishedToDecision.samples}/${report.completed.count} completed families have usable timestamps. Elapsed time to the saved completion decision, not human work time.`, rows: complete },
14
+ { label: 'Explicit revisions', value: report.revisions.perCompletedFamily === null ? 'No completed families' : `${report.revisions.perCompletedFamily.toFixed(1)} per completed family`,
15
+ detail: `${report.revisions.count} revisions across ${report.revisions.samples} completed families. Retries are attempts, not revisions.`, rows: complete },
16
+ { label: 'Recorded spend', value: report.completed.count === 0 ? 'No completed families' : `${activityMoney(report.cost.recordedPerCompletedFamilyUsd)} per completed family · ${report.cost.knownRuns}/${report.cost.runs} costs known`,
17
+ detail: `${activityMoney(report.cost.recordedUsd)} in API charges across ${report.cost.families} completed families; ${report.cost.fullyCostedFamilies}/${report.cost.families} fully costed.${report.cost.subscriptionRuns > 0 ? ` ${report.cost.subscriptionRuns} subscription runs are plan usage, not charges${report.cost.subscriptionReportedUsd === null ? '' : ` (${activityMoney(report.cost.subscriptionReportedUsd)} reported)`}.` : ''} Missing or unreported costs are unknown. All family attempts count once, including planning and retries.`, rows: complete },
18
+ { label: 'Completed families', value: String(report.completed.count),
19
+ detail: `${Object.entries(report.completed.actors).map(([key, count]) => `${ACTIVITY_ACTORS[key]}: ${count}`).join(' · ')}. Scouts: ${report.completed.scouts}. Failed checks: ${report.completed.failedChecks}.`, rows: complete },
20
+ ...(report.incompleteHistory > 0 ? [{ label: 'Incomplete history', value: String(report.incompleteHistory), detail: 'Excluded from completion averages; their family relationships cannot be established.', rows: report.families.filter(one => one.state === 'history-unavailable') }] : []),
21
+ ];
22
+ return report.completed.count === 0 ? metrics.filter(one => one.label === 'Ready' || one.label === 'Completed families' || one.label === 'Incomplete history') : metrics;
23
+ }
@@ -12,6 +12,7 @@ export declare function runProjectCommand(positional: readonly string[], flags:
12
12
  write: (line: string) => void;
13
13
  json: boolean;
14
14
  registryFile?: string;
15
+ now?: Date;
15
16
  }): Promise<number>;
16
17
  /** Only known credential-aware adapters consume the remembered reference.
17
18
  * Explicit credentials always win; no legacy action gains implicit authority. */
@@ -8,6 +8,7 @@ import { configPath, loadRepos } from './repos.js';
8
8
  import { authenticateCoordinator } from './coordinator.js';
9
9
  import { canonicalProject } from './project.js';
10
10
  import { envelopeJson } from './envelope.js';
11
+ import { projectOutcomes } from './project-outcomes.js';
11
12
  export const projectProfilePath = (databaseFile) => join(dirname(databaseFile), 'cli-project.json');
12
13
  export function readProjectProfile(databaseFile) {
13
14
  const file = projectProfilePath(databaseFile);
@@ -25,11 +26,29 @@ export function readProjectProfile(databaseFile) {
25
26
  throw new Error('The saved CLI project is invalid. Select it again with project use.');
26
27
  return { version: 1, repo: p['repo'], tokenFile: p['tokenFile'] };
27
28
  }
29
+ function scopedCredentialRefusal(store, tokenFile, repo) {
30
+ let token;
31
+ try {
32
+ const info = statSync(tokenFile);
33
+ if (!info.isFile() || info.size > 16_384)
34
+ return { reason: 'unauthenticated', message: 'The scoped credential file is unavailable.' };
35
+ token = readFileSync(tokenFile, 'utf8').trim();
36
+ }
37
+ catch {
38
+ return { reason: 'unauthenticated', message: 'The scoped credential file is unavailable.' };
39
+ }
40
+ const auth = authenticateCoordinator(store, token);
41
+ if (!auth.ok)
42
+ return { reason: 'unauthenticated', message: 'The scoped credential is invalid or revoked.' };
43
+ if (!auth.who.repos.includes(repo))
44
+ return { reason: 'not-found', message: 'That project is unavailable to this credential.' };
45
+ return null;
46
+ }
28
47
  export async function runProjectCommand(positional, flags, context) {
29
48
  const action = positional[0] ?? 'show', command = `project ${action}`;
30
49
  const fail = (reason, message) => { context.write(context.json ? envelopeJson({ ok: false, command, reason, message }) : message); return reason === 'usage' ? 2 : 3; };
31
- if (!['use', 'show'].includes(action) || positional.length > (action === 'use' ? 2 : 1))
32
- return fail('usage', 'Use project use <saved checkout path> [--token-file PATH], or project show.');
50
+ if (!['use', 'show', 'activity'].includes(action) || positional.length > (action === 'use' ? 2 : 1))
51
+ return fail('usage', 'Use project use <saved checkout path> [--token-file PATH], project show, or project activity [--json].');
33
52
  const allowed = new Set(['db', 'json', ...(action === 'use' ? ['token-file'] : [])]);
34
53
  for (const name of flags.keys())
35
54
  if (!allowed.has(name))
@@ -57,23 +76,9 @@ export async function runProjectCommand(positional, flags, context) {
57
76
  if (credential !== undefined && (typeof credential !== 'string' || !credential || /[\x00-\x1f]/.test(credential)))
58
77
  return fail('usage', '--token-file requires a path.');
59
78
  const tokenFile = typeof credential === 'string' ? resolve(credential) : null;
60
- if (tokenFile !== null) {
61
- let token;
62
- try {
63
- const info = statSync(tokenFile);
64
- if (!info.isFile() || info.size > 16_384)
65
- return fail('unauthenticated', 'The scoped credential file is unavailable.');
66
- token = readFileSync(tokenFile, 'utf8').trim();
67
- }
68
- catch {
69
- return fail('unauthenticated', 'The scoped credential file is unavailable.');
70
- }
71
- const auth = authenticateCoordinator(context.store, token);
72
- if (!auth.ok)
73
- return fail('unauthenticated', 'The scoped credential is invalid or revoked.');
74
- if (!auth.who.repos.includes(repo))
75
- return fail('not-found', 'That project is unavailable to this credential.');
76
- }
79
+ const refused = tokenFile === null ? null : scopedCredentialRefusal(context.store, tokenFile, repo);
80
+ if (refused)
81
+ return fail(refused.reason, refused.message);
77
82
  profile = { version: 1, repo, tokenFile };
78
83
  const file = projectProfilePath(context.databaseFile), temp = `${file}.${randomBytes(6).toString('hex')}.tmp`;
79
84
  mkdirSync(dirname(file), { recursive: true });
@@ -82,6 +87,18 @@ export async function runProjectCommand(positional, flags, context) {
82
87
  }
83
88
  else
84
89
  profile = readProjectProfile(context.databaseFile);
90
+ if (action === 'activity') {
91
+ if (profile === null || !repos.includes(profile.repo))
92
+ return fail('not-found', 'Select a saved project with project use first.');
93
+ const refused = profile.tokenFile === null ? null : scopedCredentialRefusal(context.store, profile.tokenFile, profile.repo);
94
+ if (refused)
95
+ return fail(refused.reason, refused.message);
96
+ // Like assignment brief, an uncredentialed local database read has the
97
+ // local operator's access. A saved scoped credential is always re-proved.
98
+ const result = projectOutcomes(context.store, profile.repo, context.now ?? new Date(), { principal: 'operator', repos: [profile.repo] });
99
+ context.write(context.json ? envelopeJson({ ok: true, command, result }) : JSON.stringify(result, null, 2));
100
+ return 0;
101
+ }
85
102
  const result = { profile, saved: profile !== null && repos.includes(profile.repo) };
86
103
  context.write(context.json ? envelopeJson({ ok: true, command, result }) : profile === null ? 'No project selected. Use project use <saved checkout path>.' : `${profile.repo}${result.saved ? '' : ' (no longer saved)'}\n${profile.tokenFile === null ? 'Uses your existing local sign-in for operator actions.' : `Scoped credential reference: ${profile.tokenFile}`}`);
87
104
  return 0;
@@ -86,7 +86,9 @@ export function projectRunning(store, repo, now) {
86
86
  const chats = count("SELECT COUNT(*) AS n FROM mate_turn WHERE state IN ('queued','running') AND id IN (SELECT value FROM json_each(?))", json(d.turns));
87
87
  const steps = count("SELECT COUNT(*) AS n FROM flow_step_run WHERE state = 'running' AND card IN (SELECT value FROM json_each(?))", json(d.cards));
88
88
  const calls = count("SELECT COUNT(*) AS n FROM subagent_call WHERE state = 'running' AND subagent IN (SELECT value FROM json_each(?))", json(d.subagents));
89
- const merges = count(`SELECT COUNT(*) AS n FROM merge_intent WHERE state IN ('claimed','firing') AND publication IN (SELECT id FROM publication WHERE task_ref IN (SELECT value FROM json_each(?)))`, json(d.refs));
89
+ const merges = count(`SELECT COUNT(*) AS n FROM merge_intent WHERE state IN ('claimed','firing') AND publication IN (SELECT id FROM publication WHERE task_ref IN (SELECT value FROM json_each(?)))`, json(d.refs))
90
+ // A person's merge between its last check and GitHub's answer (v145); a row older than 15 minutes is a crash.
91
+ + count("SELECT COUNT(*) AS n FROM merge_in_flight WHERE at > ? AND task_ref IN (SELECT value FROM json_each(?))", new Date(now.getTime() - 15 * 60_000).toISOString(), json(d.refs));
90
92
  const checkouts = count("SELECT COUNT(*) AS n FROM worktree WHERE repo = ? AND runner IS NOT NULL AND released_at IS NULL", repo);
91
93
  return [
92
94
  ...(building > 0 ? [`${plural(building, "task")} ${building === 1 ? "is" : "are"} being built`] : []),
@@ -267,6 +269,8 @@ function deleteRows(store, repo, d, now) {
267
269
  del("skill_test", `${IN("task_ref")} OR ${IN("source_run")}`, T, R);
268
270
  del("merge_blocker", `${IN("publication")} OR ${IN("task_id")}`, publications, K);
269
271
  del("merge_intent", IN("publication"), publications);
272
+ del("merge_in_flight", IN("publication"), publications);
273
+ del("deploy_in_flight", IN("task_ref"), T);
270
274
  del("publication", IN("id"), publications);
271
275
  del("artifact", IN("id"), A);
272
276
  // Tasks and their versions.
@@ -282,7 +286,7 @@ function deleteRows(store, repo, d, now) {
282
286
  del("workflow_preview", "repo = ?", repo);
283
287
  del("coding_handoff_scope", `id IN (SELECT id FROM coding_handoff WHERE json_extract(payload, '$.repo') = ?)`, repo);
284
288
  del("coding_handoff", "json_extract(payload, '$.repo') = ?", repo);
285
- for (const table of ["task_act", "task_replacement"])
289
+ for (const table of ["task_act", "task_replacement", "task_archive"])
286
290
  del(table, IN("task_ref"), T);
287
291
  del("task_ref", IN("id"), T);
288
292
  del("task_scope", IN("task_id"), K);
@@ -293,7 +297,7 @@ function deleteRows(store, repo, d, now) {
293
297
  del("flow_trigger", IN("flow"), F);
294
298
  del("flow_unattended_run", `grant_id IN (SELECT id FROM flow_unattended_grant WHERE ${IN("flow")})`, F);
295
299
  del("flow_unattended_grant", IN("flow"), F);
296
- for (const table of ["flow_card_watcher", "flow_comment", "flow_event", "flow_mail", "flow_step_run"])
300
+ for (const table of ["flow_card_watcher", "flow_comment", "flow_event", "flow_mail", "flow_step_run", "flow_step_task"])
297
301
  del(table, IN("card"), C);
298
302
  del("subagent_question", IN("id"), questions);
299
303
  del("subagent_call", `${IN("subagent")} OR ${IN("card")}`, M, C);