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/store.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { verifyResultImage } from "./chat-evidence.js";
2
+ import { parseFeedbackImage } from "./result-review.js";
1
3
  import { mintSecret } from "./secret-mint.js";
2
4
  import { WRITES_DEFAULT } from "./writes-mode.js";
3
5
  import { checkAccessOf, widerThan } from "./check-access.js";
@@ -7,6 +9,8 @@ import { isAcpProfile, isAcpProvider } from "./acp-agents.js";
7
9
  import { ACP_CONFORMANCE_VERSION, launchFromJson } from "./acp-attest.js";
8
10
  import { reclaimDatabase } from "./database-reclaim.js";
9
11
  import { BASELINE_SCHEMA, BASELINE_SCHEMA_VERSION } from "./store-baseline.js";
12
+ import { refArchived, taskArchived } from "./task-archive.js";
13
+ export { refArchived, taskArchived } from "./task-archive.js";
10
14
  import { CHAT_PROVIDERS, CHAT_SCHEMA } from "./contracts/chat-tables.js";
11
15
  import {} from "./chat-core.js";
12
16
  import { ChatMessages, TELEGRAM_HOLD_REASONS, TELEGRAM_SKIPPED_ELSEWHERE, TELEGRAM_SKIPPED_OTHER_CHAT, TELEGRAM_SKIPPED_QUIET, TELEGRAM_UNSETTLED } from "./chat-messages.js";
@@ -92,6 +96,7 @@ import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installL
92
96
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
93
97
  import { CONSOLE_INSTANCE_SCHEMA } from "./console-instance.js";
94
98
  import { endUnattendedGrants, FLOW_UNATTENDED_SCHEMA, grantStandsSql } from "./flow-unattended.js";
99
+ import { FLOW_BROWSER_SCHEMA } from "./flow-browser.js";
95
100
  import { RECIPE_SCHEMA } from "./recipes.js";
96
101
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
97
102
  import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
@@ -107,6 +112,8 @@ import { configBase, envValue, namedPath } from "./names.js";
107
112
  import { PULL_REQUEST_SCHEMA } from "./pull-request-schema.js";
108
113
  import { chatTurnAmountOf, combineReportedModels, providerTurnsOf, reportedCostUsdOf, reportedModelOf, runAmountOf } from "./reported-usage.js";
109
114
  import { usageOf } from "./run-efficiency.js";
115
+ import { YOUIYOUX_FILED_VIA } from "./contracts/youiyoux-request.js";
116
+ import { shownTermsDigest } from "./chat-terms.js";
110
117
  // PR #263 follow-up: the built-ins the Store money seal uses, captured as this module is evaluated, so code that later
111
118
  // replaces `Object.defineProperty` and the like can't fool the seal or the lock (lockStoreMoneyMethods refuses when
112
119
  // they've been replaced). The four sealed method references are recorded once, at the seal, in SEALED_MONEY_METHODS.
@@ -717,6 +724,60 @@ export const V141_ADDED_COLUMNS = Object.freeze({
717
724
  },
718
725
  chat_turn: { billing_reported: { definition: "TEXT CHECK (billing_reported IS NULL OR billing_reported IN ('subscription','api-key'))", saved: "NULL" } },
719
726
  });
727
+ /** v142 (youiyoux-intake.ts): youiyoux change requests as tasks. `youiyoux_intake` is one row per project the intake
728
+ * has looked at: a person's on/off choice (`enabled` NULL: automatic, on while the project uses youiyoux) and the last
729
+ * pass's answer (whether the project uses youiyoux, how many request files couldn't be read, and the problem, if any).
730
+ * `youiyoux_request` is one row per request taken, keyed by project and request id: the task it became (NULL: left
731
+ * out, `note` says why), never filed again, the status last written beside it, with the console address its links
732
+ * named ('' for none), and where the person pointed (route, component, file:line), cleaned, for the task page. An
733
+ * options request (`kind`) also keeps the options file its agent writes (`proposal`, p-<its hex>), set when filed and
734
+ * never read from the request again: whether it went to the project's main checkout from which result (`proposal_state`,
735
+ * `proposal_run`, and why not in `proposal_note`; `taken`: a file was already there, so it was left and is never read
736
+ * for a decision), the digest of what was copied apart from what a decision changes (`proposal_digest`), and what a
737
+ * person decided on it in youiyoux's hub (`decided`, `decided_note`: what Toolroll did about it). New tables only. */
738
+ export const V142_SCHEMA = `
739
+ CREATE TABLE IF NOT EXISTS youiyoux_intake (
740
+ repo TEXT PRIMARY KEY,
741
+ enabled INTEGER CHECK (enabled IS NULL OR enabled IN (0, 1)),
742
+ uses INTEGER NOT NULL DEFAULT 0 CHECK (uses IN (0, 1)),
743
+ changed_by TEXT,
744
+ changed_at TEXT,
745
+ checked_at TEXT,
746
+ invalid INTEGER NOT NULL DEFAULT 0 CHECK (invalid >= 0),
747
+ problem TEXT
748
+ );
749
+ CREATE TABLE IF NOT EXISTS youiyoux_request (
750
+ repo TEXT NOT NULL,
751
+ request TEXT NOT NULL CHECK (length(request) = 12 AND request GLOB 'r-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'),
752
+ task_id TEXT,
753
+ note TEXT,
754
+ filed_at TEXT NOT NULL,
755
+ status TEXT CHECK (status IS NULL OR status IN ('queued','needs-you','building','ready','done','failed','stopped')),
756
+ detail TEXT,
757
+ status_at TEXT,
758
+ written_at TEXT,
759
+ written_base TEXT,
760
+ write_problem TEXT,
761
+ route TEXT,
762
+ component TEXT,
763
+ source_at TEXT,
764
+ kind TEXT NOT NULL DEFAULT 'change' CHECK (kind IN ('change','options')),
765
+ proposal TEXT CHECK (proposal IS NULL OR (length(proposal) = 12 AND proposal GLOB 'p-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]')),
766
+ proposal_state TEXT CHECK (proposal_state IS NULL OR proposal_state IN ('copied','taken','missing','unsafe','retry')),
767
+ proposal_digest TEXT CHECK (proposal_digest IS NULL OR (length(proposal_digest) = 64 AND proposal_digest NOT GLOB '*[^0-9a-f]*')),
768
+ proposal_run INTEGER,
769
+ proposal_note TEXT,
770
+ proposal_at TEXT,
771
+ decided TEXT CHECK (decided IS NULL OR decided IN ('chosen','dismissed')),
772
+ decided_at TEXT,
773
+ decided_note TEXT,
774
+ PRIMARY KEY (repo, request),
775
+ UNIQUE (task_id),
776
+ CHECK ((kind = 'options') = (proposal IS NOT NULL)),
777
+ CHECK (proposal_state IS NULL OR kind = 'options'),
778
+ CHECK (decided IS NULL OR coalesce(proposal_state = 'copied' AND proposal_digest IS NOT NULL, 0))
779
+ );
780
+ `;
720
781
  /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
721
782
  export const LIVE_DETAIL_SCHEMA = `
722
783
  CREATE TABLE IF NOT EXISTS live_detail_setting (
@@ -1030,7 +1091,17 @@ const REGISTERED_AT_FILING = "again, as it was filed";
1030
1091
  // v141 adds, and changes no saved row: the eval set's records (docs/plans/evals-nightly.md, part C) — campaigns,
1031
1092
  // attempts, each attempt's provider dispatches, the baseline pin and eval debt (V141_SCHEMA) — and how a run's or chat
1032
1093
  // request's billing was known (V141_ADDED_COLUMNS; saved rows NULL: unknown).
1033
- export const SCHEMA_VERSION = 141;
1094
+ // v142 adds, and changes no saved row: youiyoux change requests as tasks (V142_SCHEMA, new tables only) — each
1095
+ // project's intake choice and last answer, and each request taken, with the task it became.
1096
+ // v143 adds, and changes no saved row: optional exact-screenshot context on result feedback
1097
+ // (diff_comment.image_json, V143_ADDED_COLUMNS; saved notes read NULL and keep their file/line target).
1098
+ // v144 adds, and changes no saved row: a person's approval of browser use in a flow's script zones (flow_browser_grant;
1099
+ // flow-browser.ts), the one sandbox rule a project check gets for Chromium; saved flows have none and get nothing.
1100
+ // v145 adds, and changes no saved row (docs/plans/task-tidy.md): put-away tasks (task_archive, one row per archived task
1101
+ // version; no row is every saved task: not archived), the completed workflow runs on a watched branch the checks
1102
+ // trigger saw (branch_check_run), and the reservations that hold work back from being put away (merge_in_flight,
1103
+ // deploy_in_flight, flow_step_task; V145_SCHEMA, new tables only).
1104
+ export const SCHEMA_VERSION = 145;
1034
1105
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
1035
1106
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
1036
1107
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -1163,7 +1234,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
1163
1234
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
1164
1235
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
1165
1236
  */
1166
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141]);
1237
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145]);
1167
1238
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
1168
1239
  export const V119_ADDED_COLUMNS = Object.freeze({
1169
1240
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -1299,6 +1370,59 @@ export const V140_ADDED_COLUMNS = Object.freeze({
1299
1370
  reported_cost_usd: { definition: "REAL CHECK (reported_cost_usd IS NULL OR reported_cost_usd >= 0)", saved: "NULL" },
1300
1371
  },
1301
1372
  });
1373
+ /** v143: optional screenshot context on result feedback; existing feedback stays unchanged (saved rows read NULL). */
1374
+ export const V143_ADDED_COLUMNS = Object.freeze({
1375
+ diff_comment: { image_json: { definition: "TEXT", saved: "NULL" } },
1376
+ });
1377
+ /** v145 (docs/plans/task-tidy.md): `task_archive` is a put-away task version. A row hides it from every list and count
1378
+ * and refuses every door that would dispatch, claim or approve it; restore deletes the row. Its history, results,
1379
+ * approvals and ledger stay exactly as they were. `merge_in_flight` is a direct merge between its last check and GitHub's
1380
+ * answer, owned by its token: a task can't be put away under it (a row older than 15 minutes is a crash, and no
1381
+ * longer counts). `deploy_in_flight` is a deployment whose candidate passed its gate (30 minutes, refreshed by every
1382
+ * phase that proves it again). `flow_step_task` is the tasks a flow step was admitted for, kept whatever the card does
1383
+ * next, so a step still running holds them. `branch_check_run` keeps each completed GitHub workflow run (pushed to
1384
+ * the watched branch of the watched repository) the "checks failed" trigger saw, green or red, so tidy can tell a
1385
+ * failure notice was fixed by a later commit. */
1386
+ export const V145_SCHEMA = `
1387
+ CREATE TABLE IF NOT EXISTS task_archive (
1388
+ task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id),
1389
+ by TEXT NOT NULL,
1390
+ by_kind TEXT NOT NULL CHECK (by_kind IN ('person','tidy')),
1391
+ rule TEXT,
1392
+ reason TEXT NOT NULL,
1393
+ at TEXT NOT NULL
1394
+ );
1395
+ CREATE TABLE IF NOT EXISTS branch_check_run (
1396
+ repo TEXT NOT NULL,
1397
+ run_id INTEGER NOT NULL,
1398
+ branch TEXT NOT NULL,
1399
+ workflow TEXT NOT NULL,
1400
+ head_sha TEXT NOT NULL,
1401
+ conclusion TEXT NOT NULL CHECK (conclusion IN ('success','failure')),
1402
+ run_created_at TEXT NOT NULL,
1403
+ observed_at TEXT NOT NULL,
1404
+ PRIMARY KEY (repo, run_id)
1405
+ );
1406
+ CREATE INDEX IF NOT EXISTS branch_check_run_by_workflow ON branch_check_run (repo, branch, workflow, run_created_at);
1407
+ CREATE TABLE IF NOT EXISTS merge_in_flight (
1408
+ publication INTEGER PRIMARY KEY REFERENCES publication(id),
1409
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id),
1410
+ token TEXT NOT NULL,
1411
+ by TEXT NOT NULL,
1412
+ at TEXT NOT NULL
1413
+ );
1414
+ CREATE TABLE IF NOT EXISTS deploy_in_flight (
1415
+ task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id),
1416
+ run INTEGER NOT NULL,
1417
+ at TEXT NOT NULL
1418
+ );
1419
+ CREATE TABLE IF NOT EXISTS flow_step_task (
1420
+ card INTEGER NOT NULL,
1421
+ entry INTEGER NOT NULL,
1422
+ task_id TEXT NOT NULL,
1423
+ PRIMARY KEY (card, entry, task_id)
1424
+ );
1425
+ `;
1302
1426
  /**
1303
1427
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
1304
1428
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -4549,6 +4673,17 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
4549
4673
  for (const [column, { definition }] of Object.entries(columns))
4550
4674
  addColumn(db, table, column, definition);
4551
4675
  }
4676
+ // v142: youiyoux change requests as tasks (new tables only).
4677
+ db.exec(V142_SCHEMA);
4678
+ // v143: optional exact-screenshot context on result feedback (V143_ADDED_COLUMNS: saved notes read NULL).
4679
+ for (const [table, columns] of Object.entries(V143_ADDED_COLUMNS)) {
4680
+ for (const [column, { definition }] of Object.entries(columns))
4681
+ addColumn(db, table, column, definition);
4682
+ }
4683
+ // v144: approvals of browser use in flows' script zones (a new table only).
4684
+ db.exec(FLOW_BROWSER_SCHEMA);
4685
+ // v145: put-away tasks, green checks per branch and the reservations that hold work back (new tables only).
4686
+ db.exec(V145_SCHEMA);
4552
4687
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
4553
4688
  // nothing to reclaim.
4554
4689
  if (!fresh)
@@ -6288,6 +6423,7 @@ export class Store {
6288
6423
  FROM task JOIN task_ref ON task_ref.backend = ? AND task_ref.external_id = task.id
6289
6424
  WHERE task.state = 'queued'
6290
6425
  AND (? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)
6426
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)
6291
6427
  ORDER BY task.priority DESC, task.created_at, task_ref.id
6292
6428
  LIMIT 200`)
6293
6429
  .all(stamp, BUILT_IN, repo, repo)
@@ -6312,7 +6448,8 @@ export class Store {
6312
6448
  .prepare(`SELECT task.priority AS priority, task.created_at AS created_at, task_ref.id AS ref,
6313
6449
  task_ref.repo AS repo, task_ref.assigned_runner AS assigned
6314
6450
  FROM task JOIN task_ref ON task_ref.backend = ? AND task_ref.external_id = task.id
6315
- WHERE task.id = ? AND task.state = 'queued'`)
6451
+ WHERE task.id = ? AND task.state = 'queued'
6452
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)`)
6316
6453
  .get(BUILT_IN, taskId);
6317
6454
  if (me === undefined)
6318
6455
  return null;
@@ -6324,7 +6461,8 @@ export class Store {
6324
6461
  OR (task.priority = ? AND task.created_at = ? AND task_ref.id < ?)
6325
6462
  THEN 1 ELSE 0 END) AS ahead
6326
6463
  FROM task JOIN task_ref ON task_ref.backend = ? AND task_ref.external_id = task.id
6327
- WHERE task.state = 'queued' AND (task_ref.assigned_runner IS ?) AND (task_ref.repo IS ?)`)
6464
+ WHERE task.state = 'queued' AND (task_ref.assigned_runner IS ?) AND (task_ref.repo IS ?)
6465
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)`)
6328
6466
  .get(Number(me["priority"]), Number(me["priority"]), String(me["created_at"]), Number(me["priority"]), String(me["created_at"]), Number(me["ref"]), BUILT_IN, me["assigned"], me["repo"]);
6329
6467
  return {
6330
6468
  position: Number(row?.["ahead"] ?? 0) + 1,
@@ -6474,6 +6612,8 @@ export class Store {
6474
6612
  JOIN task_ref ON task_ref.backend = ? AND task_ref.external_id = task.id
6475
6613
  WHERE task.state = 'queued'
6476
6614
  ${reservation}
6615
+ -- A put-away task (v145) never dispatches.
6616
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)
6477
6617
  AND NOT EXISTS (
6478
6618
  SELECT 1 FROM hold
6479
6619
  WHERE hold.task_ref = task_ref.id
@@ -7255,6 +7395,9 @@ export class Store {
7255
7395
  // road it came by.
7256
7396
  if (basis === undefined && machineActing())
7257
7397
  return false;
7398
+ // A PUT-AWAY TASK (v145, task-tidy.md) is never approved, by any road: restore it first.
7399
+ if (taskArchived(this, taskId))
7400
+ return false;
7258
7401
  // A RELEASE WAITING FOR CI (Codex review round 1, finding 4): filed while its pull request's checks still ran,
7259
7402
  // only the automatic road (which proves CI itself) seals it until the plane read exact-head CI passing.
7260
7403
  if (basis?.kind !== "auto-check" && basis?.kind !== "auto-green" && releaseCiHoldOf(this, taskId) !== null)
@@ -7282,6 +7425,14 @@ export class Store {
7282
7425
  .get(taskId);
7283
7426
  if (writtenByLead !== undefined)
7284
7427
  return false;
7428
+ // THE OUTSIDE-REQUEST QUARANTINE (youiyoux-intake.ts): a task filed from a youiyoux change request is text
7429
+ // anyone who can write the project's folder wrote, so only a person who read every word of it approves it.
7430
+ // Its provenance is immutable, so no road around this exists. A mode, a grant, a schedule, auto-check and
7431
+ // auto-green (approvals no person read) never seal it. A person's own ceremony does. So does their paired-
7432
+ // chat tap, only with the digest of every word the card showed (`terms`, chat-terms.ts), recomputed below
7433
+ // from the live scope: a card that cut a word, or terms that changed since, seal nothing.
7434
+ if (basis.kind !== "chat" && this.filedViaOf(taskId) === YOUIYOUX_FILED_VIA)
7435
+ return false;
7285
7436
  // CHECK ACCESS (v132, check-access.ts): a scope whose checks reach beyond the fence (sign-ins, or no sandbox at
7286
7437
  // all) seals only on a person's password ceremony — never a mode, a grant, a chat confirmation or a carry.
7287
7438
  const profileText = this.db.prepare("SELECT profile_json FROM task_scope WHERE task_id = ?").get(taskId)?.["profile_json"];
@@ -7348,6 +7499,10 @@ export class Store {
7348
7499
  return false;
7349
7500
  if (deployGate && basis?.kind === "chat" && !(basis.card === "release" && typeof basis.cardDigest === "string" && /^[0-9a-f]{64}$/.test(basis.cardDigest)))
7350
7501
  return false;
7502
+ // The outside-request quarantine's chat road (above): the card showed every word of these live terms.
7503
+ if (basis?.kind === "chat" && this.filedViaOf(taskId) === YOUIYOUX_FILED_VIA
7504
+ && !(basis.card === "terms" && typeof basis.cardDigest === "string" && basis.cardDigest === shownTermsDigest(readScope(working))))
7505
+ return false;
7351
7506
  // AUTOMATIC RELEASE SEALS (auto-approve-green.md): only on a release task (a candidate and release terms signed
7352
7507
  // in), only naming the evidence row the plane wrote for exactly this task, kind, digest and candidate.
7353
7508
  const automatic = basis?.kind === "auto-check" || basis?.kind === "auto-green";
@@ -9535,13 +9690,21 @@ export class Store {
9535
9690
  if (artifact === undefined || artifact.kind !== "terminal-diff") {
9536
9691
  throw new Error(`artifact ${args.artifactId} is not run ${args.runId}'s terminal diff — comments bind to the exact bytes reviewed`);
9537
9692
  }
9693
+ const image = parseFeedbackImage(args.image);
9694
+ if (image === false)
9695
+ throw new Error("Invalid feedback image.");
9696
+ if (image !== null) {
9697
+ const shot = this.artifactForRun(args.runId, image.artifact);
9698
+ if (image.run !== args.runId || shot?.kind !== "screenshot" || shot.sha256 !== image.sha256)
9699
+ throw new Error("The feedback image does not belong to this result.");
9700
+ }
9538
9701
  // The unique index is the real concurrent guard (audit C-9): a race on
9539
9702
  // the same source key lands in DO NOTHING, not in a thrown constraint.
9540
9703
  const inserted = this.db
9541
- .prepare(`INSERT INTO diff_comment (artifact, artifact_sha, run, path, line, note, author, created_at, source_key)
9542
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
9704
+ .prepare(`INSERT INTO diff_comment (artifact, artifact_sha, run, path, line, note, author, created_at, source_key, image_json)
9705
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
9543
9706
  ON CONFLICT (source_key) WHERE source_key IS NOT NULL DO NOTHING`)
9544
- .run(args.artifactId, artifact.sha256, args.runId, args.path, args.line, args.note, args.author, now.toISOString(), args.sourceKey ?? null);
9707
+ .run(args.artifactId, artifact.sha256, args.runId, args.path, args.line, args.note, args.author, now.toISOString(), args.sourceKey ?? null, image === null ? null : JSON.stringify(image));
9545
9708
  return Number(inserted.changes) === 0 ? null : Number(inserted.lastInsertRowid);
9546
9709
  }
9547
9710
  /** Live (unsuperseded, unconsumed) comments on a run's terminal diff. */
@@ -10229,6 +10392,19 @@ export class Store {
10229
10392
  const row = this.db.prepare("SELECT flow, card, card_entry, source_json FROM task_source WHERE task_id = ?").get(taskId);
10230
10393
  return row === undefined ? null : { flow: Number(row["flow"]), card: Number(row["card"]), entry: Number(row["card_entry"]), sourceJson: String(row["source_json"]) };
10231
10394
  }
10395
+ /** v142: where a task filed from a youiyoux change or options request came from, as kept when it was filed
10396
+ * (youiyoux-intake.ts; a revision reads its first version's): the request, its kind, and where the person pointed. Null for every other task. */
10397
+ youiyouxOriginOf(taskId) {
10398
+ const text = (value) => typeof value === "string" && value !== "" ? value : null;
10399
+ let id = taskId;
10400
+ for (let hop = 0; id !== null && hop < 20; hop++) {
10401
+ const row = this.db.prepare("SELECT request, kind, route, component, source_at FROM youiyoux_request WHERE task_id = ?").get(id);
10402
+ if (row !== undefined)
10403
+ return { request: String(row["request"]), kind: row["kind"] === "options" ? "options" : "change", route: text(row["route"]), component: text(row["component"]), at: text(row["source_at"]) };
10404
+ id = this.lookupRef(id)?.revisionOf ?? null;
10405
+ }
10406
+ return null;
10407
+ }
10232
10408
  // ---- the revision boundary: ONE field-by-field policy -------------------
10233
10409
  /**
10234
10410
  * Seal a revision in ONE transaction (Codex M5-M8 audit, C-3; contract
@@ -10320,7 +10496,14 @@ export class Store {
10320
10496
  if (ids.size !== args.commentIds.length || live.length !== ids.size) {
10321
10497
  throw new RevisionRaced("this annotation batch is no longer whole — read the current comments before sealing it");
10322
10498
  }
10323
- const exactComment = (one) => ({ id: one["id"], path: one["path"], line: one["line"], note: one["note"], author: one["author"], createdAt: one["createdAt"] });
10499
+ for (const one of live) {
10500
+ if (one.image == null)
10501
+ continue;
10502
+ const image = sourceRef.repo === null ? null : verifyResultImage(this, args.brief.evidenceRoot, [sourceRef.repo], { taskId: args.source.task, ...one.image });
10503
+ if (one.image.run !== sourceRun.id || image === null || !image.ok)
10504
+ return refuse("brief-custody", "A feedback screenshot is unavailable or changed. The notes were not consumed.");
10505
+ }
10506
+ const exactComment = (one) => ({ id: one["id"], path: one["path"], line: one["line"], note: one["note"], author: one["author"], createdAt: one["createdAt"], image: one["image"] ?? null });
10324
10507
  const expected = live.map(one => exactComment({ ...one }));
10325
10508
  const described = Array.isArray(named.comments) && named.comments.every(one => one !== null && typeof one === "object" && !Array.isArray(one))
10326
10509
  ? named.comments.map(one => exactComment(one)) : null;
@@ -10474,7 +10657,7 @@ export class Store {
10474
10657
  versions.sort((a, b) => a.id === root.id ? -1 : b.id === root.id ? 1 : a.createdAt.localeCompare(b.createdAt) || a.refId - b.refId);
10475
10658
  const current = versions.at(-1);
10476
10659
  return { root, current, versions,
10477
- otherActive: versions.filter(one => one.id !== current.id && (one.state === "queued" || one.state === "running")),
10660
+ otherActive: versions.filter(one => one.id !== current.id && (one.state === "queued" || one.state === "running") && !refArchived(this, one.refId)),
10478
10661
  problem: Number(rows[0]["broken"]) === 0 ? null : "Task history is unavailable or incomplete. This task is shown separately." };
10479
10662
  }
10480
10663
  /** Group and filter BEFORE the family limit; hydrate only that page's
@@ -10484,6 +10667,8 @@ export class Store {
10484
10667
  const order = options.order === "updated" ? "updated_at" : "created_at";
10485
10668
  const rows = this.db.prepare(`${projection.sql}, selected AS MATERIALIZED (
10486
10669
  SELECT * FROM ordered WHERE version_rank = 1
10670
+ -- A put-away family (v145) is out of every list, before the page is cut.
10671
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = ordered.ref_id)
10487
10672
  ${options.states === undefined ? "" : `AND state IN (${options.states.map(() => "?").join(",")})`}
10488
10673
  ORDER BY ${order} DESC, ref_id DESC LIMIT ?
10489
10674
  ) SELECT task.*, members.repo, members.ref_id, members.root_ref, members.broken
@@ -10574,7 +10759,8 @@ export class Store {
10574
10759
  COUNT(CASE WHEN live_families.root_ref IS NOT NULL THEN 1 END) AS running,
10575
10760
  COUNT(CASE WHEN live_families.root_ref IS NULL AND ordered.state = 'queued' THEN 1 END) AS queued,
10576
10761
  COUNT(CASE WHEN live_families.root_ref IS NULL AND ordered.state = 'done' AND ordered.updated_at >= ? THEN 1 END) AS doneRecently
10577
- FROM ordered LEFT JOIN live_families ON live_families.root_ref = ordered.root_ref WHERE version_rank = 1`)
10762
+ FROM ordered LEFT JOIN live_families ON live_families.root_ref = ordered.root_ref WHERE version_rank = 1
10763
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = ordered.ref_id)`)
10578
10764
  .get(...projection.params, JSON.stringify(liveTasks), new Date(now.getTime() - 24 * 60 * 60_000).toISOString());
10579
10765
  return { running: Number(row["running"]), queued: Number(row["queued"]), doneRecently: Number(row["doneRecently"]) };
10580
10766
  }
@@ -11008,6 +11194,9 @@ export class Store {
11008
11194
  if (ref === undefined)
11009
11195
  return { ok: false, reason: "unknown-task" };
11010
11196
  const taskRef = Number(ref["id"]);
11197
+ // A put-away task (v145) is restored before it runs again.
11198
+ if (refArchived(this, taskRef))
11199
+ return { ok: false, reason: "archived" };
11011
11200
  const stamp = now.toISOString();
11012
11201
  if (this.hasLiveClaim(taskRef, now))
11013
11202
  return { ok: false, reason: "claimed" };
@@ -12619,10 +12808,12 @@ export class Store {
12619
12808
  : source !== undefined && "project" in source && source.project !== "" ? "project"
12620
12809
  : source !== undefined && "installation" in source ? "installation" : "unknown";
12621
12810
  const project = scope === "task" ? String(ref["repo"]) : scope === "project" && source !== undefined && "project" in source ? source.project : null;
12811
+ // A put-away task (v145) nudges nobody: no notification about it is enqueued, whoever produced it. The check is
12812
+ // part of the insert itself, so a put away that commits first always wins.
12622
12813
  const { changes, lastInsertRowid } = this.db
12623
12814
  .prepare(`INSERT OR IGNORE INTO notification (dedupe_key, kind, subject, body, created_at, push_class, link, provenance_scope, project, task_ref, task_id, source_run, recipient)
12624
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
12625
- .run(notification.dedupeKey, notification.kind, notification.subject, notification.body, now.toISOString(), notification.pushClass ?? null, notification.link ?? null, scope, project, ref === undefined ? null : taskRef, ref === undefined ? null : String(ref["external_id"]), run?.id ?? null, notification.recipient ?? null);
12815
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = ?)`)
12816
+ .run(notification.dedupeKey, notification.kind, notification.subject, notification.body, now.toISOString(), notification.pushClass ?? null, notification.link ?? null, scope, project, ref === undefined ? null : taskRef, ref === undefined ? null : String(ref["external_id"]), run?.id ?? null, notification.recipient ?? null, ref === undefined ? null : Number(ref["id"]));
12626
12817
  // Whose act made this fact: pings follow it (see pingAllowed).
12627
12818
  const actor = currentActor();
12628
12819
  if (Number(changes) > 0 && actor !== null) {
@@ -14114,6 +14305,7 @@ export class Store {
14114
14305
  JOIN task_ref ON task_ref.backend = ? AND task_ref.external_id = task.id
14115
14306
  WHERE (? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)
14116
14307
  AND (? IS NULL OR task.state = ?)
14308
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)
14117
14309
  ORDER BY task.created_at DESC, task.id DESC LIMIT ?
14118
14310
  )
14119
14311
  UNION
@@ -14282,8 +14474,10 @@ export class Store {
14282
14474
  resolvePlanRevision(id, outcome, resolvedBy, now) {
14283
14475
  const { changes } = this.db
14284
14476
  .prepare(`UPDATE plan_revision SET status = ?, resolved_at = ?, resolved_by = ?
14285
- WHERE id = ? AND status = 'blocked'`)
14286
- .run(outcome, now.toISOString(), resolvedBy, id);
14477
+ WHERE id = ? AND status = 'blocked'
14478
+ -- A put-away task's (v145) plan change is never applied; rejecting it still is.
14479
+ AND (? = 'rejected' OR NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = plan_revision.task_ref))`)
14480
+ .run(outcome, now.toISOString(), resolvedBy, id, outcome);
14287
14481
  return Number(changes) > 0;
14288
14482
  }
14289
14483
  /** Stamp the exact plan revision and authority snapshot a run's brief was
@@ -15513,6 +15707,12 @@ export class Store {
15513
15707
  if (row === undefined || String(row["state"]) !== "claimed" || Number(row["generation"]) !== generation) {
15514
15708
  return { ok: false, outcome: "raced", why: "the claim moved" };
15515
15709
  }
15710
+ // A put-away task (v145) merges nothing: the intent waits for a person, who restores it first.
15711
+ const archivedTask = this.db.prepare("SELECT 1 FROM publication p JOIN task_archive a ON a.task_ref = p.task_ref WHERE p.id = ?").get(Number(row["publication"]));
15712
+ if (archivedTask !== undefined) {
15713
+ this.settleMergeIntent(id, generation, "waiting-human", now, { error: "the task is put away", from: "claimed", bumpGeneration: true });
15714
+ return { ok: false, outcome: "waiting-human", why: "the task is put away" };
15715
+ }
15516
15716
  const supersede = (why) => {
15517
15717
  this.settleMergeIntent(id, generation, "superseded", now, { error: why, from: "claimed" });
15518
15718
  return { ok: false, outcome: "superseded", why };
@@ -17271,9 +17471,10 @@ export class Store {
17271
17471
  const page = Math.max(1, Math.min(Math.floor(limit), 50));
17272
17472
  // Roll-up admission binds BEFORE the LIMIT (Codex roll-up review,
17273
17473
  // finding 2): a repo outside the ceiling must not consume the page.
17274
- const admission = admitted === null
17474
+ // A put-away task (v145, task-tidy.md) is left out here, as from every list and count.
17475
+ const admission = (admitted === null
17275
17476
  ? "(? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)"
17276
- : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`;
17477
+ : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`) + " AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)";
17277
17478
  const params = admitted === null ? [repo, repo] : admitted;
17278
17479
  return this.db
17279
17480
  .prepare(`SELECT task.id AS task_id, task.title, task_scope.goal, task_scope.digest, task_scope.proposed_at, task_ref.repo AS task_repo
@@ -17303,9 +17504,10 @@ export class Store {
17303
17504
  */
17304
17505
  listRequeueablesScoped(repo, now, limit = 10, admitted = null) {
17305
17506
  const page = Math.max(1, Math.min(Math.floor(limit), 50));
17306
- const admission = admitted === null
17507
+ // A put-away task (v145, task-tidy.md) is left out here, as from every list and count.
17508
+ const admission = (admitted === null
17307
17509
  ? "(? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)"
17308
- : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`;
17510
+ : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`) + " AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)";
17309
17511
  const params = admitted === null ? [repo, repo] : admitted;
17310
17512
  return this.db
17311
17513
  .prepare(`WITH open_incidents AS (
@@ -17369,6 +17571,8 @@ export class Store {
17369
17571
  JOIN task_ref AS blocker_ref ON blocker_ref.backend = ? AND blocker_ref.external_id = task_edge.blocker
17370
17572
  WHERE ${admissionOf("dependent_ref")}
17371
17573
  AND ${admissionOf("blocker_ref")}
17574
+ -- Put-away tasks (v145) need no repair card, on either side.
17575
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref IN (dependent_ref.id, blocker_ref.id))
17372
17576
  GROUP BY task_edge.blocker, dependent_ref.repo, blocker_ref.repo
17373
17577
  ORDER BY task_edge.blocker LIMIT ?`)
17374
17578
  .all(BUILT_IN, BUILT_IN, ...paramsOf(), ...paramsOf(), page)
@@ -17401,6 +17605,7 @@ export class Store {
17401
17605
  WHERE task.state = 'done'
17402
17606
  AND (? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)
17403
17607
  ${admission}
17608
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)
17404
17609
  )
17405
17610
  SELECT completed.*, run.id AS run_id, run.outcome, run.handoff, run.cost_usd, run.provider, run.auth_mode, run.started_at, run.finished_at,
17406
17611
  publication.state AS pub_state, publication.pr_number, publication.pr_url,
@@ -17507,9 +17712,10 @@ export class Store {
17507
17712
  // finding 11): in the roll-up, rows outside the ceiling must not
17508
17713
  // consume the page and crowd out admitted cards. Serve still filters
17509
17714
  // per-row afterward — this is the bound, that is the law.
17510
- const admission = admitted === null
17715
+ // A put-away task (v145, task-tidy.md) is left out here, as from every list and count.
17716
+ const admission = (admitted === null
17511
17717
  ? "(? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)"
17512
- : `(task_ref.repo IS NULL OR task_ref.repo IN (${admitted.map(() => "?").join(",")}))`;
17718
+ : `(task_ref.repo IS NULL OR task_ref.repo IN (${admitted.map(() => "?").join(",")}))`) + " AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)";
17513
17719
  const admissionParams = admitted === null ? [repo, repo] : admitted;
17514
17720
  const columns = `
17515
17721
  SELECT task.id, task.title, task.state, task.updated_at, task.priority, task_ref.strikes, task_ref.repo AS task_repo,
@@ -17713,14 +17919,15 @@ export class Store {
17713
17919
  SUM(CASE WHEN task.state = 'queued' THEN 1 ELSE 0 END) AS queued,
17714
17920
  SUM(CASE WHEN task.state = 'running' THEN 1 ELSE 0 END) AS running
17715
17921
  FROM task JOIN task_ref ON task_ref.backend = 'built-in' AND task_ref.external_id = task.id
17716
- WHERE task_ref.repo = ?`)
17922
+ WHERE task_ref.repo = ? AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)`)
17717
17923
  .get(repo);
17718
17924
  const since = new Date(now.getTime() - 24 * 60 * 60_000).toISOString();
17719
17925
  const done = this.db
17720
17926
  .prepare(`SELECT COUNT(*) AS n FROM run
17721
17927
  JOIN task_ref ON task_ref.id = run.task_ref
17722
17928
  WHERE task_ref.repo = ? AND run.role IN ('builder','scout')
17723
- AND run.outcome IN ('built','no-change') AND run.finished_at IS NOT NULL AND run.finished_at >= ?`)
17929
+ AND run.outcome IN ('built','no-change') AND run.finished_at IS NOT NULL AND run.finished_at >= ?
17930
+ AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)`)
17724
17931
  .get(repo, since);
17725
17932
  return { waiting: waiting, queued: Number(q.queued ?? 0), running: Number(q.running ?? 0), doneRecently: Number(done.n) };
17726
17933
  }
@@ -17729,9 +17936,10 @@ export class Store {
17729
17936
  // review, finding 11): with repo NULL the old predicate admitted every
17730
17937
  // stored project, ceiling or not. An empty admitted list reduces to
17731
17938
  // unplaced rows only — never IN ().
17732
- const admission = admitted === null
17939
+ // A put-away task (v145, task-tidy.md) is left out here, as from every list and count.
17940
+ const admission = (admitted === null
17733
17941
  ? "(? IS NULL OR task_ref.repo IS NULL OR task_ref.repo = ?)"
17734
- : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`;
17942
+ : `(task_ref.repo IS NULL${admitted.length === 0 ? "" : ` OR task_ref.repo IN (${admitted.map(() => "?").join(",")})`})`) + " AND NOT EXISTS (SELECT 1 FROM task_archive WHERE task_archive.task_ref = task_ref.id)";
17735
17943
  const params = () => (admitted === null ? [repo, repo] : [...admitted]);
17736
17944
  const row = this.db
17737
17945
  .prepare(`SELECT COUNT(*) AS n FROM (
@@ -18584,6 +18792,8 @@ export class Store {
18584
18792
  */
18585
18793
  resumeRunStop(args, now) {
18586
18794
  return this.transact(() => {
18795
+ if (refArchived(this, args.taskRef))
18796
+ return { ok: false, reason: "archived", detail: "this task is put away — restore it first" };
18587
18797
  const stop = this.stopOf(args.runId);
18588
18798
  const run = this.getRun(args.runId);
18589
18799
  if (stop === null || run === null)
@@ -20632,6 +20842,7 @@ function readDiffComment(row) {
20632
20842
  id: Number(row["id"]),
20633
20843
  artifact: Number(row["artifact"]),
20634
20844
  artifactSha: String(row["artifact_sha"]),
20845
+ image: readFeedbackImage(row["image_json"]),
20635
20846
  run: Number(row["run"]),
20636
20847
  path: row["path"] === null ? null : String(row["path"]),
20637
20848
  line: row["line"] === null ? null : Number(row["line"]),
@@ -21510,3 +21721,153 @@ export function lockStoreMoneyMethods(store, prototype = Store.prototype) {
21510
21721
  throw new Error(`Store.${name} couldn't be locked`);
21511
21722
  }
21512
21723
  }
21724
+ /** A saved note's screenshot reference (v143), or null; a damaged one refuses rather than reading as none. */
21725
+ function readFeedbackImage(value) {
21726
+ const image = parseFeedbackImage(value);
21727
+ if (image === false)
21728
+ throw new Error("The saved feedback image is damaged.");
21729
+ return image;
21730
+ }
21731
+ /** Takes a saved note's screenshot off without losing the note or its history (v143). Fail closed, in one write
21732
+ * transaction: the note must be live and unconsumed on this run, its screenshot must be genuinely unusable right now
21733
+ * (`unusable` is result-actions.ts feedbackImageProblem, re-read inside the transaction, never the page's say-so), and
21734
+ * the change is recorded in the action ledger under the person who made it. The note is superseded by an exact
21735
+ * imageless copy; the original stays in history. The caller has already proved the person may request changes on
21736
+ * this run (the same authority that seals any saved note into a revision); that standing is re-proved here too. */
21737
+ export function dropFeedbackImage(store, runId, commentId, actor, unusable, now) {
21738
+ return store.transact(() => {
21739
+ // The person's standing is re-read under the write lock: a revocation, a downgrade or a lost project committed
21740
+ // after the request was admitted refuses here (check and write together).
21741
+ const run = store.getRun(runId);
21742
+ const repo = run === null ? null : store.refById(run.taskRef)?.repo ?? null;
21743
+ const account = store.accountOf(actor);
21744
+ if (run === null || account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(actor, repo)) {
21745
+ return { ok: false, reason: "not-authorized" };
21746
+ }
21747
+ const live = store.handle.prepare(`SELECT id, image_json FROM diff_comment WHERE id = ? AND run = ? AND superseded_by IS NULL AND consumed_by IS NULL
21748
+ AND reviewer_run IS NULL AND image_json IS NOT NULL`).get(commentId, runId);
21749
+ if (live === undefined)
21750
+ return { ok: false, reason: "not-found" };
21751
+ const image = readFeedbackImage(live["image_json"]);
21752
+ if (image === null)
21753
+ return { ok: false, reason: "not-found" };
21754
+ const problem = unusable(image);
21755
+ if (problem === null)
21756
+ return { ok: false, reason: "image-usable" };
21757
+ const copy = store.handle.prepare(`INSERT INTO diff_comment (artifact, artifact_sha, run, path, line, note, author, created_at, reviewer_run, severity)
21758
+ SELECT artifact, artifact_sha, run, path, line, note, author, ?, reviewer_run, severity FROM diff_comment WHERE id = ?`).run(now.toISOString(), commentId);
21759
+ const id = Number(copy.lastInsertRowid);
21760
+ const moved = store.handle.prepare("UPDATE diff_comment SET superseded_by = ? WHERE id = ? AND superseded_by IS NULL AND consumed_by IS NULL").run(id, commentId);
21761
+ if (Number(moved.changes) !== 1)
21762
+ throw new Error("The saved note changed while its screenshot was being removed.");
21763
+ store.recordAction({ at: now.toISOString(), actor, repo,
21764
+ taskId: store.externalIdFor(run.taskRef) ?? null, runId, action: "feedback screenshot removed", outcome: "recorded", source: "work",
21765
+ detail: `note ${commentId} kept as note ${id} without screenshot ${image.artifact}: ${problem}` });
21766
+ return { ok: true, id };
21767
+ });
21768
+ }
21769
+ /** The put-away record of this task, or null when it is not put away. */
21770
+ export function archiveOf(store, taskId) {
21771
+ const row = store.handle.prepare(`SELECT a.*, r.external_id FROM task_archive a JOIN task_ref r ON r.id = a.task_ref
21772
+ WHERE r.backend = 'built-in' AND r.external_id = ?`).get(taskId);
21773
+ return row === undefined ? null : { taskRef: Number(row["task_ref"]), taskId: String(row["external_id"]), by: String(row["by"]),
21774
+ byKind: row["by_kind"] === "tidy" ? "tidy" : "person", rule: row["rule"] == null ? null : String(row["rule"]), reason: String(row["reason"]), at: String(row["at"]) };
21775
+ }
21776
+ /** Every version of a task's family, by the same proven lineage the task page reads (`taskFamilyOf`: same project, each
21777
+ * revision bound to its parent's own run by its brief). A family whose lineage doesn't prove is just this task. */
21778
+ export function taskFamilyRefs(store, taskId) {
21779
+ const family = store.taskFamilyOf(taskId, null, true);
21780
+ return family === null ? [] : family.versions.map(one => ({ id: one.refId, taskId: one.id })).sort((a, b) => a.id - b.id);
21781
+ }
21782
+ /** The first family version with something still going on, or null: running, a live newest claim, an unfinished run,
21783
+ * a process Toolroll hasn't seen exit, a build whose processes were never recorded, a stop still settling, a follow-up
21784
+ * check asked for and not finished, a flow step running for its card, a release in flight (a live release approval
21785
+ * that names it, or a deploy or merge reserved, running or unknown), or a pull request merging (an intent claimed or
21786
+ * firing, or a person's merge between its last check and GitHub's answer). */
21787
+ export function liveFamilyAttempt(store, refs, now) {
21788
+ if (refs.length === 0)
21789
+ return null;
21790
+ const row = store.handle.prepare(`SELECT r.external_id FROM task_ref r JOIN task t ON t.id = r.external_id
21791
+ WHERE r.id IN (SELECT value FROM json_each(?)) AND (t.state = 'running'
21792
+ OR EXISTS (SELECT 1 FROM claim c WHERE c.task_ref = r.id AND c.released_at IS NULL AND c.expires_at > ?
21793
+ AND c.lease_generation = (SELECT MAX(n.lease_generation) FROM claim n WHERE n.task_ref = r.id))
21794
+ OR EXISTS (SELECT 1 FROM run x WHERE x.task_ref = r.id AND x.outcome IS NULL)
21795
+ OR EXISTS (SELECT 1 FROM run_process p JOIN run x ON x.id = p.run WHERE x.task_ref = r.id AND p.exited_at IS NULL AND p.container_empty_at IS NULL)
21796
+ OR EXISTS (SELECT 1 FROM run x WHERE x.task_ref = r.id AND x.provider_started_at IS NOT NULL
21797
+ AND NOT EXISTS (SELECT 1 FROM run_process p WHERE p.run = x.id) AND NOT EXISTS (SELECT 1 FROM run_process_summary s WHERE s.run = x.id))
21798
+ OR EXISTS (SELECT 1 FROM run_stop s WHERE s.task_ref = r.id AND s.resumed_at IS NULL AND s.settled_at IS NULL)
21799
+ OR EXISTS (SELECT 1 FROM action_ledger q JOIN run x ON x.id = q.run_id WHERE x.task_ref = r.id AND q.action = 'checks requested'
21800
+ AND NOT EXISTS (SELECT 1 FROM action_ledger f WHERE f.run_id = q.run_id AND f.action = 'checks finished' AND f.outcome LIKE q.id || ':%'))
21801
+ OR EXISTS (SELECT 1 FROM flow_card c JOIN flow_step_run s ON s.card = c.id
21802
+ WHERE s.state = 'running' AND (c.task = r.external_id OR c.primary_task = r.external_id))
21803
+ OR EXISTS (SELECT 1 FROM flow_step_task t JOIN flow_step_run s ON s.card = t.card AND s.entry = t.entry
21804
+ WHERE s.state = 'running' AND t.task_id = r.external_id)
21805
+ OR EXISTS (SELECT 1 FROM deploy_in_flight d WHERE d.task_ref = r.id AND d.at > ?)
21806
+ OR EXISTS (SELECT 1 FROM release_authority a WHERE a.state IN ('open','deploying','merge-reserved') AND (a.anchor_task = r.external_id OR a.tip_task = r.external_id))
21807
+ OR EXISTS (SELECT 1 FROM release_operation o WHERE o.task_id = r.external_id AND o.state IN ('reserved','running','unknown'))
21808
+ OR EXISTS (SELECT 1 FROM merge_intent i JOIN publication p ON p.id = i.publication WHERE p.task_ref = r.id AND i.state IN ('claimed','firing'))
21809
+ OR EXISTS (SELECT 1 FROM merge_in_flight m WHERE m.task_ref = r.id AND m.at > ?))
21810
+ LIMIT 1`).get(JSON.stringify(refs), now.toISOString(), new Date(now.getTime() - 30 * 60_000).toISOString(), new Date(now.getTime() - 15 * 60_000).toISOString());
21811
+ return row === undefined ? null : String(row["external_id"]);
21812
+ }
21813
+ /** Puts a task away (v145): every version of its family, in one write transaction that re-checks it has no live
21814
+ * attempt, that its versions are exactly `expect` when the caller showed a person a family (a preview), and that
21815
+ * `guard` (the caller's own rule, re-read under the lock) still allows it. Its state, history, results, approvals and
21816
+ * ledger are untouched; its open notifications are resolved; one ledger row records who and why. */
21817
+ export function archiveTaskFamily(store, taskId, by, now, options = {}) {
21818
+ return store.transact(() => {
21819
+ const family = taskFamilyRefs(store, taskId);
21820
+ if (family.length === 0)
21821
+ return { ok: false, reason: "unknown-task", message: `No task ${taskId}.` };
21822
+ const refs = family.map(one => one.id);
21823
+ if (options.expect !== undefined && [...options.expect].sort((a, b) => a - b).join(",") !== refs.join(",")) {
21824
+ return { ok: false, reason: "blocked", message: `${taskId}'s versions changed since the list was shown.` };
21825
+ }
21826
+ // Already put away, every version: nothing to do. A family a person revived with a new revision puts away the rest.
21827
+ const remaining = refs.filter(ref => !refArchived(store, ref));
21828
+ if (remaining.length === 0)
21829
+ return { ok: false, reason: "archived", message: `${taskId} is already put away.` };
21830
+ const live = liveFamilyAttempt(store, refs, now);
21831
+ if (live !== null)
21832
+ return { ok: false, reason: "live", message: `${taskId} still has work going on. Stop it or let it finish, then put it away.` };
21833
+ const blocked = options.guard?.(refs) ?? null;
21834
+ if (blocked !== null)
21835
+ return { ok: false, reason: "blocked", message: blocked };
21836
+ const reason = by.reason.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 300) || "Put away";
21837
+ const at = now.toISOString();
21838
+ const insert = store.handle.prepare("INSERT INTO task_archive (task_ref, by, by_kind, rule, reason, at) VALUES (?, ?, ?, ?, ?, ?)");
21839
+ for (const ref of remaining)
21840
+ insert.run(ref, by.by, by.byKind, by.rule, reason, at);
21841
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE resolved_at IS NULL AND task_ref IN (SELECT value FROM json_each(?))")
21842
+ .run(at, JSON.stringify(refs));
21843
+ // The family's flow cards hold while it is put away, so what they queued for people is settled too.
21844
+ store.handle.prepare(`UPDATE notification SET resolved_at = ? WHERE resolved_at IS NULL AND EXISTS (SELECT 1 FROM flow_card c
21845
+ WHERE (c.task IN (SELECT value FROM json_each(?)) OR c.primary_task IN (SELECT value FROM json_each(?)))
21846
+ AND (notification.dedupe_key LIKE 'flow:' || c.id || ':%' OR notification.dedupe_key LIKE 'flow-card:' || c.id || ':%'))`)
21847
+ .run(at, JSON.stringify(family.map(one => one.taskId)), JSON.stringify(family.map(one => one.taskId)));
21848
+ const repo = store.refById(refs[0])?.repo ?? null;
21849
+ store.recordAction({ at, actor: by.byKind === "tidy" ? "tidy" : `operator:${by.by}`, repo, taskId, runId: null, action: "task archived",
21850
+ outcome: by.rule ?? "by hand", source: "work", detail: `${reason}${refs.length > 1 ? ` (${refs.length} versions)` : ""}` });
21851
+ return { ok: true, refs };
21852
+ });
21853
+ }
21854
+ /** Brings a put-away task back (v145): every version of its family, with a ledger row. Tidy never puts a restored
21855
+ * task away again (tidy.ts reads the `task restored` row). */
21856
+ export function restoreTaskFamily(store, taskId, by, now, guard) {
21857
+ return store.transact(() => {
21858
+ const family = taskFamilyRefs(store, taskId);
21859
+ if (family.length === 0)
21860
+ return { ok: false, reason: "unknown-task", message: `No task ${taskId}.` };
21861
+ const refs = family.map(one => one.id).filter(ref => refArchived(store, ref));
21862
+ if (refs.length === 0)
21863
+ return { ok: false, reason: "not-archived", message: `${taskId} isn't put away.` };
21864
+ const blocked = guard?.(refs) ?? null;
21865
+ if (blocked !== null)
21866
+ return { ok: false, reason: "blocked", message: blocked };
21867
+ store.handle.prepare("DELETE FROM task_archive WHERE task_ref IN (SELECT value FROM json_each(?))").run(JSON.stringify(refs));
21868
+ const at = now.toISOString();
21869
+ store.recordAction({ at, actor: `operator:${by}`, repo: store.refById(refs[0])?.repo ?? null, taskId, runId: null, action: "task restored",
21870
+ outcome: "recorded", source: "work", detail: refs.length > 1 ? `${refs.length} versions` : null });
21871
+ return { ok: true, refs };
21872
+ });
21873
+ }