toolroll 0.9.84 → 0.9.85

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 (81) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +94 -1193
  3. package/dist/agent-fence.d.ts +3 -3
  4. package/dist/agent-fence.js +3 -3
  5. package/dist/agent-onboard.d.ts +2 -0
  6. package/dist/agent-onboard.js +3 -0
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +31 -31
  9. package/dist/browser-workspace.d.ts +5 -0
  10. package/dist/chat-channel.d.ts +10 -1
  11. package/dist/chat-channel.js +35 -5
  12. package/dist/chat-decide.d.ts +16 -4
  13. package/dist/chat-decide.js +239 -31
  14. package/dist/chat-messages.d.ts +5 -0
  15. package/dist/chat-messages.js +1 -1
  16. package/dist/chat-quiet.js +3 -2
  17. package/dist/chat-review.d.ts +4 -0
  18. package/dist/chat-review.js +13 -4
  19. package/dist/chat-terms.d.ts +9 -0
  20. package/dist/chat-terms.js +46 -0
  21. package/dist/contracts/console-api.d.ts +2 -1
  22. package/dist/contracts/console-api.js +2 -1
  23. package/dist/contracts/lead-tools.d.ts +30 -0
  24. package/dist/contracts/lead-tools.js +3 -0
  25. package/dist/contracts/youiyoux-request.d.ts +248 -0
  26. package/dist/contracts/youiyoux-request.js +152 -0
  27. package/dist/flow-cadence.d.ts +8 -2
  28. package/dist/flow-cadence.js +16 -3
  29. package/dist/flow-card-task.d.ts +9 -0
  30. package/dist/flow-card-task.js +15 -6
  31. package/dist/integrations-ui.js +9 -4
  32. package/dist/integrations.d.ts +9 -1
  33. package/dist/integrations.js +43 -2
  34. package/dist/landed-cleanup.d.ts +4 -0
  35. package/dist/landed-cleanup.js +3 -2
  36. package/dist/lead-capabilities.js +2 -1
  37. package/dist/lead-cli.js +3 -1
  38. package/dist/lead-tools.d.ts +2 -0
  39. package/dist/lead-tools.js +30 -4
  40. package/dist/lead.d.ts +2 -0
  41. package/dist/lead.js +3 -3
  42. package/dist/operate.d.ts +1 -2
  43. package/dist/operate.js +11 -15
  44. package/dist/project-activity.d.ts +20 -0
  45. package/dist/project-activity.js +23 -0
  46. package/dist/project-cli.d.ts +1 -0
  47. package/dist/project-cli.js +36 -19
  48. package/dist/project-knowledge.d.ts +3 -3
  49. package/dist/project-outcomes.d.ts +119 -0
  50. package/dist/project-outcomes.js +79 -0
  51. package/dist/result-actions.d.ts +4 -0
  52. package/dist/result-actions.js +41 -9
  53. package/dist/result-review.d.ts +9 -0
  54. package/dist/result-review.js +45 -5
  55. package/dist/server/console.css +12 -0
  56. package/dist/server/pages.js +11 -1
  57. package/dist/server/render-chat.js +3 -2
  58. package/dist/server/render-pages.d.ts +2 -2
  59. package/dist/server/render-pages.js +7 -2
  60. package/dist/server/render-results.d.ts +2 -0
  61. package/dist/server/render-results.js +20 -7
  62. package/dist/server/render-tasks.d.ts +1 -0
  63. package/dist/server/render-tasks.js +15 -5
  64. package/dist/server/route-table.js +1 -0
  65. package/dist/server/settings.js +18 -1
  66. package/dist/server/task-views.js +6 -0
  67. package/dist/server/tasks.js +42 -6
  68. package/dist/store.d.ts +46 -3
  69. package/dist/store.js +165 -6
  70. package/dist/task-source.d.ts +13 -1
  71. package/dist/task-source.js +17 -1
  72. package/dist/telegram-lead.js +7 -5
  73. package/dist/telegram.js +1 -1
  74. package/dist/toolroll-update.js +2 -2
  75. package/dist/webhooks.d.ts +6 -0
  76. package/dist/webhooks.js +25 -0
  77. package/dist/work-index.d.ts +4 -0
  78. package/dist/work-index.js +32 -0
  79. package/dist/youiyoux-intake.d.ts +234 -0
  80. package/dist/youiyoux-intake.js +908 -0
  81. 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";
@@ -107,6 +109,8 @@ import { configBase, envValue, namedPath } from "./names.js";
107
109
  import { PULL_REQUEST_SCHEMA } from "./pull-request-schema.js";
108
110
  import { chatTurnAmountOf, combineReportedModels, providerTurnsOf, reportedCostUsdOf, reportedModelOf, runAmountOf } from "./reported-usage.js";
109
111
  import { usageOf } from "./run-efficiency.js";
112
+ import { YOUIYOUX_FILED_VIA } from "./contracts/youiyoux-request.js";
113
+ import { shownTermsDigest } from "./chat-terms.js";
110
114
  // PR #263 follow-up: the built-ins the Store money seal uses, captured as this module is evaluated, so code that later
111
115
  // replaces `Object.defineProperty` and the like can't fool the seal or the lock (lockStoreMoneyMethods refuses when
112
116
  // they've been replaced). The four sealed method references are recorded once, at the seal, in SEALED_MONEY_METHODS.
@@ -717,6 +721,60 @@ export const V141_ADDED_COLUMNS = Object.freeze({
717
721
  },
718
722
  chat_turn: { billing_reported: { definition: "TEXT CHECK (billing_reported IS NULL OR billing_reported IN ('subscription','api-key'))", saved: "NULL" } },
719
723
  });
724
+ /** v142 (youiyoux-intake.ts): youiyoux change requests as tasks. `youiyoux_intake` is one row per project the intake
725
+ * has looked at: a person's on/off choice (`enabled` NULL: automatic, on while the project uses youiyoux) and the last
726
+ * pass's answer (whether the project uses youiyoux, how many request files couldn't be read, and the problem, if any).
727
+ * `youiyoux_request` is one row per request taken, keyed by project and request id: the task it became (NULL: left
728
+ * out, `note` says why), never filed again, the status last written beside it, with the console address its links
729
+ * named ('' for none), and where the person pointed (route, component, file:line), cleaned, for the task page. An
730
+ * options request (`kind`) also keeps the options file its agent writes (`proposal`, p-<its hex>), set when filed and
731
+ * never read from the request again: whether it went to the project's main checkout from which result (`proposal_state`,
732
+ * `proposal_run`, and why not in `proposal_note`; `taken`: a file was already there, so it was left and is never read
733
+ * for a decision), the digest of what was copied apart from what a decision changes (`proposal_digest`), and what a
734
+ * person decided on it in youiyoux's hub (`decided`, `decided_note`: what Toolroll did about it). New tables only. */
735
+ export const V142_SCHEMA = `
736
+ CREATE TABLE IF NOT EXISTS youiyoux_intake (
737
+ repo TEXT PRIMARY KEY,
738
+ enabled INTEGER CHECK (enabled IS NULL OR enabled IN (0, 1)),
739
+ uses INTEGER NOT NULL DEFAULT 0 CHECK (uses IN (0, 1)),
740
+ changed_by TEXT,
741
+ changed_at TEXT,
742
+ checked_at TEXT,
743
+ invalid INTEGER NOT NULL DEFAULT 0 CHECK (invalid >= 0),
744
+ problem TEXT
745
+ );
746
+ CREATE TABLE IF NOT EXISTS youiyoux_request (
747
+ repo TEXT NOT NULL,
748
+ 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]'),
749
+ task_id TEXT,
750
+ note TEXT,
751
+ filed_at TEXT NOT NULL,
752
+ status TEXT CHECK (status IS NULL OR status IN ('queued','needs-you','building','ready','done','failed','stopped')),
753
+ detail TEXT,
754
+ status_at TEXT,
755
+ written_at TEXT,
756
+ written_base TEXT,
757
+ write_problem TEXT,
758
+ route TEXT,
759
+ component TEXT,
760
+ source_at TEXT,
761
+ kind TEXT NOT NULL DEFAULT 'change' CHECK (kind IN ('change','options')),
762
+ 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]')),
763
+ proposal_state TEXT CHECK (proposal_state IS NULL OR proposal_state IN ('copied','taken','missing','unsafe','retry')),
764
+ proposal_digest TEXT CHECK (proposal_digest IS NULL OR (length(proposal_digest) = 64 AND proposal_digest NOT GLOB '*[^0-9a-f]*')),
765
+ proposal_run INTEGER,
766
+ proposal_note TEXT,
767
+ proposal_at TEXT,
768
+ decided TEXT CHECK (decided IS NULL OR decided IN ('chosen','dismissed')),
769
+ decided_at TEXT,
770
+ decided_note TEXT,
771
+ PRIMARY KEY (repo, request),
772
+ UNIQUE (task_id),
773
+ CHECK ((kind = 'options') = (proposal IS NOT NULL)),
774
+ CHECK (proposal_state IS NULL OR kind = 'options'),
775
+ CHECK (decided IS NULL OR coalesce(proposal_state = 'copied' AND proposal_digest IS NOT NULL, 0))
776
+ );
777
+ `;
720
778
  /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
721
779
  export const LIVE_DETAIL_SCHEMA = `
722
780
  CREATE TABLE IF NOT EXISTS live_detail_setting (
@@ -1030,7 +1088,11 @@ const REGISTERED_AT_FILING = "again, as it was filed";
1030
1088
  // v141 adds, and changes no saved row: the eval set's records (docs/plans/evals-nightly.md, part C) — campaigns,
1031
1089
  // attempts, each attempt's provider dispatches, the baseline pin and eval debt (V141_SCHEMA) — and how a run's or chat
1032
1090
  // request's billing was known (V141_ADDED_COLUMNS; saved rows NULL: unknown).
1033
- export const SCHEMA_VERSION = 141;
1091
+ // v142 adds, and changes no saved row: youiyoux change requests as tasks (V142_SCHEMA, new tables only) — each
1092
+ // project's intake choice and last answer, and each request taken, with the task it became.
1093
+ // v143 adds, and changes no saved row: optional exact-screenshot context on result feedback
1094
+ // (diff_comment.image_json, V143_ADDED_COLUMNS; saved notes read NULL and keep their file/line target).
1095
+ export const SCHEMA_VERSION = 143;
1034
1096
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
1035
1097
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
1036
1098
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -1163,7 +1225,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
1163
1225
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
1164
1226
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
1165
1227
  */
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]);
1228
+ 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]);
1167
1229
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
1168
1230
  export const V119_ADDED_COLUMNS = Object.freeze({
1169
1231
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -1299,6 +1361,10 @@ export const V140_ADDED_COLUMNS = Object.freeze({
1299
1361
  reported_cost_usd: { definition: "REAL CHECK (reported_cost_usd IS NULL OR reported_cost_usd >= 0)", saved: "NULL" },
1300
1362
  },
1301
1363
  });
1364
+ /** v143: optional screenshot context on result feedback; existing feedback stays unchanged (saved rows read NULL). */
1365
+ export const V143_ADDED_COLUMNS = Object.freeze({
1366
+ diff_comment: { image_json: { definition: "TEXT", saved: "NULL" } },
1367
+ });
1302
1368
  /**
1303
1369
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
1304
1370
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -4549,6 +4615,13 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
4549
4615
  for (const [column, { definition }] of Object.entries(columns))
4550
4616
  addColumn(db, table, column, definition);
4551
4617
  }
4618
+ // v142: youiyoux change requests as tasks (new tables only).
4619
+ db.exec(V142_SCHEMA);
4620
+ // v143: optional exact-screenshot context on result feedback (V143_ADDED_COLUMNS: saved notes read NULL).
4621
+ for (const [table, columns] of Object.entries(V143_ADDED_COLUMNS)) {
4622
+ for (const [column, { definition }] of Object.entries(columns))
4623
+ addColumn(db, table, column, definition);
4624
+ }
4552
4625
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
4553
4626
  // nothing to reclaim.
4554
4627
  if (!fresh)
@@ -7282,6 +7355,14 @@ export class Store {
7282
7355
  .get(taskId);
7283
7356
  if (writtenByLead !== undefined)
7284
7357
  return false;
7358
+ // THE OUTSIDE-REQUEST QUARANTINE (youiyoux-intake.ts): a task filed from a youiyoux change request is text
7359
+ // anyone who can write the project's folder wrote, so only a person who read every word of it approves it.
7360
+ // Its provenance is immutable, so no road around this exists. A mode, a grant, a schedule, auto-check and
7361
+ // auto-green (approvals no person read) never seal it. A person's own ceremony does. So does their paired-
7362
+ // chat tap, only with the digest of every word the card showed (`terms`, chat-terms.ts), recomputed below
7363
+ // from the live scope: a card that cut a word, or terms that changed since, seal nothing.
7364
+ if (basis.kind !== "chat" && this.filedViaOf(taskId) === YOUIYOUX_FILED_VIA)
7365
+ return false;
7285
7366
  // CHECK ACCESS (v132, check-access.ts): a scope whose checks reach beyond the fence (sign-ins, or no sandbox at
7286
7367
  // all) seals only on a person's password ceremony — never a mode, a grant, a chat confirmation or a carry.
7287
7368
  const profileText = this.db.prepare("SELECT profile_json FROM task_scope WHERE task_id = ?").get(taskId)?.["profile_json"];
@@ -7348,6 +7429,10 @@ export class Store {
7348
7429
  return false;
7349
7430
  if (deployGate && basis?.kind === "chat" && !(basis.card === "release" && typeof basis.cardDigest === "string" && /^[0-9a-f]{64}$/.test(basis.cardDigest)))
7350
7431
  return false;
7432
+ // The outside-request quarantine's chat road (above): the card showed every word of these live terms.
7433
+ if (basis?.kind === "chat" && this.filedViaOf(taskId) === YOUIYOUX_FILED_VIA
7434
+ && !(basis.card === "terms" && typeof basis.cardDigest === "string" && basis.cardDigest === shownTermsDigest(readScope(working))))
7435
+ return false;
7351
7436
  // AUTOMATIC RELEASE SEALS (auto-approve-green.md): only on a release task (a candidate and release terms signed
7352
7437
  // in), only naming the evidence row the plane wrote for exactly this task, kind, digest and candidate.
7353
7438
  const automatic = basis?.kind === "auto-check" || basis?.kind === "auto-green";
@@ -9535,13 +9620,21 @@ export class Store {
9535
9620
  if (artifact === undefined || artifact.kind !== "terminal-diff") {
9536
9621
  throw new Error(`artifact ${args.artifactId} is not run ${args.runId}'s terminal diff — comments bind to the exact bytes reviewed`);
9537
9622
  }
9623
+ const image = parseFeedbackImage(args.image);
9624
+ if (image === false)
9625
+ throw new Error("Invalid feedback image.");
9626
+ if (image !== null) {
9627
+ const shot = this.artifactForRun(args.runId, image.artifact);
9628
+ if (image.run !== args.runId || shot?.kind !== "screenshot" || shot.sha256 !== image.sha256)
9629
+ throw new Error("The feedback image does not belong to this result.");
9630
+ }
9538
9631
  // The unique index is the real concurrent guard (audit C-9): a race on
9539
9632
  // the same source key lands in DO NOTHING, not in a thrown constraint.
9540
9633
  const inserted = this.db
9541
- .prepare(`INSERT INTO diff_comment (artifact, artifact_sha, run, path, line, note, author, created_at, source_key)
9542
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
9634
+ .prepare(`INSERT INTO diff_comment (artifact, artifact_sha, run, path, line, note, author, created_at, source_key, image_json)
9635
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
9543
9636
  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);
9637
+ .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
9638
  return Number(inserted.changes) === 0 ? null : Number(inserted.lastInsertRowid);
9546
9639
  }
9547
9640
  /** Live (unsuperseded, unconsumed) comments on a run's terminal diff. */
@@ -10229,6 +10322,19 @@ export class Store {
10229
10322
  const row = this.db.prepare("SELECT flow, card, card_entry, source_json FROM task_source WHERE task_id = ?").get(taskId);
10230
10323
  return row === undefined ? null : { flow: Number(row["flow"]), card: Number(row["card"]), entry: Number(row["card_entry"]), sourceJson: String(row["source_json"]) };
10231
10324
  }
10325
+ /** v142: where a task filed from a youiyoux change or options request came from, as kept when it was filed
10326
+ * (youiyoux-intake.ts; a revision reads its first version's): the request, its kind, and where the person pointed. Null for every other task. */
10327
+ youiyouxOriginOf(taskId) {
10328
+ const text = (value) => typeof value === "string" && value !== "" ? value : null;
10329
+ let id = taskId;
10330
+ for (let hop = 0; id !== null && hop < 20; hop++) {
10331
+ const row = this.db.prepare("SELECT request, kind, route, component, source_at FROM youiyoux_request WHERE task_id = ?").get(id);
10332
+ if (row !== undefined)
10333
+ return { request: String(row["request"]), kind: row["kind"] === "options" ? "options" : "change", route: text(row["route"]), component: text(row["component"]), at: text(row["source_at"]) };
10334
+ id = this.lookupRef(id)?.revisionOf ?? null;
10335
+ }
10336
+ return null;
10337
+ }
10232
10338
  // ---- the revision boundary: ONE field-by-field policy -------------------
10233
10339
  /**
10234
10340
  * Seal a revision in ONE transaction (Codex M5-M8 audit, C-3; contract
@@ -10320,7 +10426,14 @@ export class Store {
10320
10426
  if (ids.size !== args.commentIds.length || live.length !== ids.size) {
10321
10427
  throw new RevisionRaced("this annotation batch is no longer whole — read the current comments before sealing it");
10322
10428
  }
10323
- const exactComment = (one) => ({ id: one["id"], path: one["path"], line: one["line"], note: one["note"], author: one["author"], createdAt: one["createdAt"] });
10429
+ for (const one of live) {
10430
+ if (one.image == null)
10431
+ continue;
10432
+ const image = sourceRef.repo === null ? null : verifyResultImage(this, args.brief.evidenceRoot, [sourceRef.repo], { taskId: args.source.task, ...one.image });
10433
+ if (one.image.run !== sourceRun.id || image === null || !image.ok)
10434
+ return refuse("brief-custody", "A feedback screenshot is unavailable or changed. The notes were not consumed.");
10435
+ }
10436
+ 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
10437
  const expected = live.map(one => exactComment({ ...one }));
10325
10438
  const described = Array.isArray(named.comments) && named.comments.every(one => one !== null && typeof one === "object" && !Array.isArray(one))
10326
10439
  ? named.comments.map(one => exactComment(one)) : null;
@@ -20632,6 +20745,7 @@ function readDiffComment(row) {
20632
20745
  id: Number(row["id"]),
20633
20746
  artifact: Number(row["artifact"]),
20634
20747
  artifactSha: String(row["artifact_sha"]),
20748
+ image: readFeedbackImage(row["image_json"]),
20635
20749
  run: Number(row["run"]),
20636
20750
  path: row["path"] === null ? null : String(row["path"]),
20637
20751
  line: row["line"] === null ? null : Number(row["line"]),
@@ -21510,3 +21624,48 @@ export function lockStoreMoneyMethods(store, prototype = Store.prototype) {
21510
21624
  throw new Error(`Store.${name} couldn't be locked`);
21511
21625
  }
21512
21626
  }
21627
+ /** A saved note's screenshot reference (v143), or null; a damaged one refuses rather than reading as none. */
21628
+ function readFeedbackImage(value) {
21629
+ const image = parseFeedbackImage(value);
21630
+ if (image === false)
21631
+ throw new Error("The saved feedback image is damaged.");
21632
+ return image;
21633
+ }
21634
+ /** Takes a saved note's screenshot off without losing the note or its history (v143). Fail closed, in one write
21635
+ * transaction: the note must be live and unconsumed on this run, its screenshot must be genuinely unusable right now
21636
+ * (`unusable` is result-actions.ts feedbackImageProblem, re-read inside the transaction, never the page's say-so), and
21637
+ * the change is recorded in the action ledger under the person who made it. The note is superseded by an exact
21638
+ * imageless copy; the original stays in history. The caller has already proved the person may request changes on
21639
+ * this run (the same authority that seals any saved note into a revision); that standing is re-proved here too. */
21640
+ export function dropFeedbackImage(store, runId, commentId, actor, unusable, now) {
21641
+ return store.transact(() => {
21642
+ // The person's standing is re-read under the write lock: a revocation, a downgrade or a lost project committed
21643
+ // after the request was admitted refuses here (check and write together).
21644
+ const run = store.getRun(runId);
21645
+ const repo = run === null ? null : store.refById(run.taskRef)?.repo ?? null;
21646
+ const account = store.accountOf(actor);
21647
+ if (run === null || account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(actor, repo)) {
21648
+ return { ok: false, reason: "not-authorized" };
21649
+ }
21650
+ 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
21651
+ AND reviewer_run IS NULL AND image_json IS NOT NULL`).get(commentId, runId);
21652
+ if (live === undefined)
21653
+ return { ok: false, reason: "not-found" };
21654
+ const image = readFeedbackImage(live["image_json"]);
21655
+ if (image === null)
21656
+ return { ok: false, reason: "not-found" };
21657
+ const problem = unusable(image);
21658
+ if (problem === null)
21659
+ return { ok: false, reason: "image-usable" };
21660
+ const copy = store.handle.prepare(`INSERT INTO diff_comment (artifact, artifact_sha, run, path, line, note, author, created_at, reviewer_run, severity)
21661
+ SELECT artifact, artifact_sha, run, path, line, note, author, ?, reviewer_run, severity FROM diff_comment WHERE id = ?`).run(now.toISOString(), commentId);
21662
+ const id = Number(copy.lastInsertRowid);
21663
+ 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);
21664
+ if (Number(moved.changes) !== 1)
21665
+ throw new Error("The saved note changed while its screenshot was being removed.");
21666
+ store.recordAction({ at: now.toISOString(), actor, repo,
21667
+ taskId: store.externalIdFor(run.taskRef) ?? null, runId, action: "feedback screenshot removed", outcome: "recorded", source: "work",
21668
+ detail: `note ${commentId} kept as note ${id} without screenshot ${image.artifact}: ${problem}` });
21669
+ return { ok: true, id };
21670
+ });
21671
+ }
@@ -51,8 +51,20 @@ export type TaskOrigin = {
51
51
  url: string | null;
52
52
  host: string | null;
53
53
  } | null;
54
+ /** A youiyoux change or options request (youiyoux-intake.ts): its id, its kind, and where the person pointed. */
55
+ request?: {
56
+ id: string;
57
+ kind: "change" | "options";
58
+ route: string | null;
59
+ component: string | null;
60
+ at: string | null;
61
+ } | null;
54
62
  };
55
- /** A task's origin, or null when it came from neither a flow nor an outside event. */
63
+ /** A task's origin, or null when it came from neither a flow, an outside event nor a youiyoux request. */
56
64
  export declare function originOf(store: Store, taskId: string, canSee: (repo: string) => boolean): TaskOrigin | null;
65
+ /** A youiyoux request by its kind: "youiyoux change request r-…" or "youiyoux options request r-…". */
66
+ export declare function requestName(request: NonNullable<TaskOrigin["request"]>): string;
67
+ /** Where a youiyoux request pointed, in words: "Button on /flows (src/button.tsx:12)". */
68
+ export declare function requestWords(request: NonNullable<TaskOrigin["request"]>): string;
57
69
  /** The one line an approval shows about an outside event that started its work: context, never a term agreed to. */
58
70
  export declare function originNote(origin: TaskOrigin | null | undefined): string | null;
@@ -71,8 +71,11 @@ export function cardReference(source) {
71
71
  const snapshot = snapshotOf(source ?? null);
72
72
  return snapshot === null ? "" : referenceWords(snapshot);
73
73
  }
74
- /** A task's origin, or null when it came from neither a flow nor an outside event. */
74
+ /** A task's origin, or null when it came from neither a flow, an outside event nor a youiyoux request. */
75
75
  export function originOf(store, taskId, canSee) {
76
+ const request = store.youiyouxOriginOf(taskId);
77
+ if (request !== null)
78
+ return { flow: null, source: null, request: { id: request.request, kind: request.kind, route: request.route, component: request.component, at: request.at } };
76
79
  const kept = store.taskSourceOf(taskId);
77
80
  const flowId = kept?.flow ?? Number(/^flow:(\d+)$/.exec(store.filedViaOf(taskId) ?? "")?.[1] ?? NaN);
78
81
  const snapshot = kept === null ? null : readSnapshot(kept.sourceJson);
@@ -84,8 +87,21 @@ export function originOf(store, taskId, canSee) {
84
87
  source: snapshot === null ? null : { label: snapshot.label, proved: provedWords(snapshot), url: snapshot.external.url, host: hostOf(snapshot.external.url) },
85
88
  };
86
89
  }
90
+ /** A youiyoux request by its kind: "youiyoux change request r-…" or "youiyoux options request r-…". */
91
+ export function requestName(request) {
92
+ return `youiyoux ${request.kind === "options" ? "options" : "change"} request ${request.id}`;
93
+ }
94
+ /** Where a youiyoux request pointed, in words: "Button on /flows (src/button.tsx:12)". */
95
+ export function requestWords(request) {
96
+ const what = [request.component, request.route === null ? null : `on ${request.route}`].filter(one => one !== null).join(" ");
97
+ return `${what === "" ? "" : what}${request.at === null ? "" : `${what === "" ? "" : " "}(${request.at})`}`;
98
+ }
87
99
  /** The one line an approval shows about an outside event that started its work: context, never a term agreed to. */
88
100
  export function originNote(origin) {
101
+ if (origin?.request != null) {
102
+ const where = requestWords(origin.request);
103
+ return `From ${requestName(origin.request)}${where === "" ? "" : `: ${where}`}. Whoever can write the project's folder wrote these words; read them all before you approve.`;
104
+ }
89
105
  if (origin?.source == null)
90
106
  return null;
91
107
  const where = origin.source.host === null ? "" : ` · ${origin.source.host}`;
@@ -1,5 +1,5 @@
1
1
  import { CHAT_ACTIONS, sharedActionAllowsChallenge, sharedActionPayload } from "./chat-actions.js";
2
- import { taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
2
+ import { taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, NO_PHONE_LINK, NO_TASK_LINK, phoneProposalLink, confirmedLink, phoneProposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
3
3
  export { channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps } from "./chat-channel.js";
4
4
  /**
5
5
  * The paired phone as one more way to use the same assistant.
@@ -207,7 +207,7 @@ export function applyProposalTap(store, binding, token, message, repos, options,
207
207
  return { effects, confirmed: false, ignored: true };
208
208
  }
209
209
  const who = verified.who;
210
- const preview = proposalPreview(store, proposal, actionRepos, "telegram");
210
+ const preview = phoneProposalPreview(store, proposal, actionRepos);
211
211
  const origin = options.phoneOrigin?.() ?? null;
212
212
  if (preview.closed) {
213
213
  store.consumeTelegramProposalActions(proposal.id, now);
@@ -239,7 +239,7 @@ export function applyProposalTap(store, binding, token, message, repos, options,
239
239
  // A forged keyboard on a handoff card: nothing acts; the card is
240
240
  // repainted with its current link (or the honest absence of one).
241
241
  store.consumeTelegramProposalActions(proposal.id, now);
242
- const wanted = proposalLink(store, proposal, actionRepos, "telegram");
242
+ const wanted = phoneProposalLink(store, proposal, actionRepos, "telegram");
243
243
  const link = phoneLinkButton(origin, wanted);
244
244
  ack(link === null ? "this step finishes on the computer" : "open the button below to finish this step");
245
245
  edit(handoffCardText(preview.text, linkNote(origin, wanted)), keyboardWith(null, link));
@@ -545,7 +545,7 @@ async function runTelegramConversation(row, args) {
545
545
  // or went away carries the current truth, never a stored URL.
546
546
  const proposal = part.kind === "card" && part.proposal !== null ? store.getLeadProposal(part.proposal) : null;
547
547
  const origin = proposal === null ? null : options.phoneOrigin?.() ?? null;
548
- const wanted = proposal === null ? null : proposalLink(store, proposal, repos);
548
+ const wanted = proposal === null ? null : phoneProposalLink(store, proposal, repos);
549
549
  const keyboard = keyboardWith(part.keyboard, phoneLinkButton(origin, wanted));
550
550
  // The lead's own words carry their bold anchors and labelled links as entities, never as Markdown characters.
551
551
  const voiced = part.kind === "reply" ? telegramReply(part.text) : null;
@@ -625,7 +625,7 @@ async function runTelegramConversation(row, args) {
625
625
  for (const image of selected)
626
626
  parts.push({ kind: "image", text: image.caption, taskId: image.taskId, run: image.run, artifact: image.artifact, sha256: image.sha256 });
627
627
  for (const proposal of proposals) {
628
- const preview = proposalPreview(store, proposal, repos, "telegram");
628
+ const preview = phoneProposalPreview(store, proposal, repos);
629
629
  const keyboard = preview.buttons ? mintCardTokens(store, binding, proposal.id, now).keyboard : null;
630
630
  parts.push({ kind: "card", text: preview.text, proposal: proposal.id, keyboard });
631
631
  }
@@ -763,11 +763,13 @@ async function runTelegramConversation(row, args) {
763
763
  react: () => transport("setMessageReaction", { chat_id: chatId, message_id: Number(row.messageId), reaction: [{ type: "emoji", emoji: WARM_EMOJI }] }),
764
764
  typing: () => transport("sendChatAction", { chat_id: chatId, action: "typing" }),
765
765
  });
766
+ const replyImage = row.replyTo === null ? undefined : store.telegramMessageBindings(binding, row.replyTo).find(one => one.taskId === row.taskId && one.run === row.sourceRun && one.image !== undefined)?.image;
766
767
  let outcome;
767
768
  try {
768
769
  outcome = await runLeadTurn({
769
770
  store, who, session, thread, config, key: null, message: row.text, requestId: row.request,
770
771
  ...(row.context === null ? {} : { context: row.context }),
772
+ ...(replyImage === undefined ? {} : { replyImage }),
771
773
  ...(options.subscriptionRunner === undefined ? {} : { subscriptionRunner: options.subscriptionRunner }),
772
774
  clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram", onProgress: warm.onProgress,
773
775
  });
package/dist/telegram.js CHANGED
@@ -1852,7 +1852,7 @@ function applyConversation(context, update, effects) {
1852
1852
  if (tasks.length === 1) {
1853
1853
  taskId = tasks[0];
1854
1854
  sourceRun = bindings.find(one => one.run !== null)?.run ?? null;
1855
- context_ = replyContextFor(taskId, sourceRun);
1855
+ context_ = replyContextFor(taskId, sourceRun, bindings.find(one => one.image !== undefined)?.image);
1856
1856
  }
1857
1857
  }
1858
1858
  // No reply target: the task this chat chose to talk about, if any. The
@@ -37,7 +37,7 @@ import { removeTerminalUpdateGate } from "./terminal-catalog.js";
37
37
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
38
38
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
39
39
  import { NAME } from "./names.js";
40
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS } from "./store.js";
40
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS } from "./store.js";
41
41
  import { isNewer, REGISTRY } from "./releases.js";
42
42
  import { markNeverIndex } from "./never-index.js";
43
43
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -409,7 +409,7 @@ const HISTORY_RULES = {
409
409
  // v126: a receipt's state, acceptance time and held item, a trigger's credential generation and turned-away count,
410
410
  // and who made a card read none, 0, or none. A table with an earlier rule (flow_trigger's v115 move) keeps it too.
411
411
  // v128: a lead message's pending flag reads waiting exactly for a team queue's unadmitted operator message.
412
- ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS))
412
+ ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS))
413
413
  .map(([table, rule]) => [table, { ...EARLIER_RULES[table], ...rule }])),
414
414
  };
415
415
  /** The tables a migration may remove whole (v115's removed features). */
@@ -35,6 +35,12 @@ export declare function effectivePrimary(env: Record<string, string | undefined>
35
35
  legacyWarning?: string;
36
36
  };
37
37
  export declare function loadConsoleUrl(env: Record<string, string | undefined>, dir: string): string | null;
38
+ /** Where `up` last served its console, kept beside the database so `onboard` names the same address (operate.ts). */
39
+ export declare const UP_CONSOLE_FILE = "up-console.json";
40
+ export declare function readUpConsoleUrl(dir: string): string | null;
41
+ /** This machine's own console, where `up` last served it: only a plain http address on a loopback name (127.0.0.1,
42
+ * localhost, ::1), never a host guessed from it; null otherwise. */
43
+ export declare function localConsoleUrl(dir: string): string | null;
38
44
  /**
39
45
  * The origin a phone link may open, or null. The SAME console-url setting
40
46
  * chat links use — read again on every call, never cached, so a changed or
package/dist/webhooks.js CHANGED
@@ -107,6 +107,31 @@ export function loadConsoleUrl(env, dir) {
107
107
  const configured = envValue(env, "CONSOLE_URL") ?? readTrimmed(join(dir, CONSOLE_FILE));
108
108
  return configured === undefined || configured === null || configured === "" ? null : configured.replace(/\/+$/, "");
109
109
  }
110
+ /** Where `up` last served its console, kept beside the database so `onboard` names the same address (operate.ts). */
111
+ export const UP_CONSOLE_FILE = "up-console.json";
112
+ export function readUpConsoleUrl(dir) {
113
+ try {
114
+ const parsed = JSON.parse(readFileSync(join(dir, UP_CONSOLE_FILE), "utf8"));
115
+ return typeof parsed.url === "string" && /^https?:\/\/[^\s]+$/.test(parsed.url) ? parsed.url.replace(/\/+$/, "") : null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ /** This machine's own console, where `up` last served it: only a plain http address on a loopback name (127.0.0.1,
122
+ * localhost, ::1), never a host guessed from it; null otherwise. */
123
+ export function localConsoleUrl(dir) {
124
+ const served = readUpConsoleUrl(dir);
125
+ if (served === null)
126
+ return null;
127
+ try {
128
+ const url = new URL(served);
129
+ return url.protocol === "http:" && isLoopbackHost(url.hostname) && url.hostname !== "0.0.0.0" && url.hostname !== "[::]" ? served : null;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
110
135
  /** A dotted IPv4 that names this machine or no machine: loopback, unspecified. */
111
136
  function isLocalIpv4(address) {
112
137
  return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(address) || address === "0.0.0.0";
@@ -114,6 +114,10 @@ export type WorkStateRow = {
114
114
  members: number[];
115
115
  };
116
116
  export declare function workStateRows(store: Store, now: Date, access: WorkSummaryAccess): WorkStateRow[];
117
+ /** Activity uses the Tasks list's exact family admission and saved completion rules.
118
+ * Only the requested project enters the projection. The extra row signals truncation;
119
+ * attempts are aggregated in SQLite, never loaded as an unbounded history. */
120
+ export declare function workOutcomeRows(store: Store, now: Date, access: WorkSummaryAccess, project: string, since: string, limit: number): Record<string, unknown>[];
117
121
  /** Families whose headline is Ready for review, newest first: the Tasks
118
122
  * list's own rows, so a completed family (root or any revision), a failed
119
123
  * check and a superseded version never count. */
@@ -453,6 +453,38 @@ export function workStateRows(store, now, access) {
453
453
  leadClaim: n(row, 'lead_claim') === 1, liveRun: row['live_run'] == null ? null : n(row, 'live_run'), unfinished: row['unfinished'] == null ? null : n(row, 'unfinished'),
454
454
  resultRun: row['result_id'] == null ? null : n(row, 'result_id'), waits: Number(row['rank']) === 2, version: String(row['version']), at: String(row['ask_at'] ?? ''), askRun: row['ask_run'] == null ? null : n(row, 'ask_run'), members: JSON.parse(String(row['members'] ?? '[]')).map(Number) }));
455
455
  }
456
+ /** Activity uses the Tasks list's exact family admission and saved completion rules.
457
+ * Only the requested project enters the projection. The extra row signals truncation;
458
+ * attempts are aggregated in SQLite, never loaded as an unbounded history. */
459
+ export function workOutcomeRows(store, now, access, project, since, limit) {
460
+ return store.handle.prepare(`${registerValidators(store)}, selected AS MATERIALIZED (
461
+ SELECT * FROM ranked WHERE code='ready-to-check' OR code='history-problem'
462
+ OR (code='complete' AND checked_at >= $since AND checked_at <= $now)
463
+ ORDER BY sort_at DESC,root_ref DESC LIMIT $outcomeLimit
464
+ ), amounts AS (
465
+ -- reported-usage.ts runAmountOf, row for row: only evidenced billing on a run whose cost was reported is known;
466
+ -- an API run counts its priced spend as dollars (a safe integer), a subscription run its reported cost as usage,
467
+ -- never dollars. TOTAL sums as a float, so no family's sum can overflow.
468
+ SELECT m.root_ref,r.cost_usd,s.microusd,
469
+ CASE WHEN r.billing_basis IS NOT 'evidence' OR COALESCE(r.cost_unknown,1)<>0 THEN 'unknown'
470
+ WHEN s.billing='subscription' THEN 'subscription'
471
+ WHEN s.billing='api-key' AND typeof(s.microusd)='integer' AND s.microusd>=0 AND s.microusd<=9007199254740991 THEN 'api'
472
+ ELSE 'unknown' END amount
473
+ FROM selected f JOIN members m ON m.root_ref=f.root_ref JOIN run r ON r.task_ref=m.ref_id LEFT JOIN run_spend s ON s.run=r.id
474
+ ), attempts AS (
475
+ SELECT root_ref,COUNT(*) runs,COUNT(CASE WHEN amount='api' THEN 1 END) api_runs,
476
+ TOTAL(CASE WHEN amount='api' THEN microusd END) api_microusd,
477
+ COUNT(CASE WHEN amount='subscription' THEN 1 END) subscription_runs,
478
+ SUM(CASE WHEN amount='subscription' AND typeof(cost_usd) IN ('integer','real') AND cost_usd>=0 THEN cost_usd END) subscription_usd
479
+ FROM amounts GROUP BY root_ref
480
+ ) SELECT f.root_id,f.id task_id,f.root_title title,f.code,f.finished_at,f.result_id,f.result_role,
481
+ f.version_count,f.checked_at,f.checked_actor,f.checked_detail,
482
+ (SELECT lead FROM task_act WHERE task_ref=f.root_ref AND act='completed' AND at=f.checked_at ORDER BY id DESC LIMIT 1) completed_by_lead,
483
+ (SELECT status FROM run_check WHERE run=f.result_id) check_status,
484
+ COALESCE(a.runs,0) runs,COALESCE(a.api_runs,0) api_runs,a.api_microusd,COALESCE(a.subscription_runs,0) subscription_runs,a.subscription_usd
485
+ FROM selected f LEFT JOIN attempts a ON a.root_ref=f.root_ref ORDER BY f.sort_at DESC,f.root_ref DESC`)
486
+ .all({ ...parameters(now, access, { project }, custodyReadings(store)), $since: since, $outcomeLimit: limit + 1 });
487
+ }
456
488
  /** Families whose headline is Ready for review, newest first: the Tasks
457
489
  * list's own rows, so a completed family (root or any revision), a failed
458
490
  * check and a superseded version never count. */