toolroll 0.9.83 → 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 (137) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +94 -1192
  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/assignment-ui.js +5 -1
  8. package/dist/browser/workspace.css +2 -2
  9. package/dist/browser/workspace.js +31 -31
  10. package/dist/browser-crew.js +5 -1
  11. package/dist/browser-workspace.d.ts +12 -0
  12. package/dist/chat-channel.d.ts +10 -1
  13. package/dist/chat-channel.js +35 -5
  14. package/dist/chat-decide.d.ts +16 -4
  15. package/dist/chat-decide.js +239 -31
  16. package/dist/chat-messages.d.ts +5 -0
  17. package/dist/chat-messages.js +1 -1
  18. package/dist/chat-quiet.js +3 -2
  19. package/dist/chat-review.d.ts +4 -0
  20. package/dist/chat-review.js +13 -4
  21. package/dist/chat-terms.d.ts +9 -0
  22. package/dist/chat-terms.js +46 -0
  23. package/dist/contracts/console-api.d.ts +4 -2
  24. package/dist/contracts/console-api.js +7 -2
  25. package/dist/contracts/lead-tools.d.ts +30 -0
  26. package/dist/contracts/lead-tools.js +3 -0
  27. package/dist/contracts/youiyoux-request.d.ts +248 -0
  28. package/dist/contracts/youiyoux-request.js +152 -0
  29. package/dist/control-setup.d.ts +9 -0
  30. package/dist/control-setup.js +31 -4
  31. package/dist/control-ui.d.ts +7 -1
  32. package/dist/control-ui.js +3 -2
  33. package/dist/desktop-update.d.ts +1 -1
  34. package/dist/dispatch.d.ts +15 -0
  35. package/dist/dispatch.js +26 -4
  36. package/dist/first-run.d.ts +5 -2
  37. package/dist/first-run.js +13 -2
  38. package/dist/flow-cadence.d.ts +8 -2
  39. package/dist/flow-cadence.js +16 -3
  40. package/dist/flow-card-task.d.ts +9 -0
  41. package/dist/flow-card-task.js +15 -6
  42. package/dist/folder-look.d.ts +3 -0
  43. package/dist/folder-look.js +8 -4
  44. package/dist/guides.js +4 -3
  45. package/dist/integrations-ui.js +9 -4
  46. package/dist/integrations.d.ts +9 -1
  47. package/dist/integrations.js +43 -2
  48. package/dist/landed-cleanup.d.ts +4 -0
  49. package/dist/landed-cleanup.js +3 -2
  50. package/dist/lead-capabilities.js +2 -1
  51. package/dist/lead-cli.js +3 -1
  52. package/dist/lead-tools.d.ts +2 -0
  53. package/dist/lead-tools.js +30 -4
  54. package/dist/lead.d.ts +2 -0
  55. package/dist/lead.js +3 -3
  56. package/dist/model-catalog.d.ts +5 -0
  57. package/dist/model-catalog.js +11 -2
  58. package/dist/model-choices.d.ts +188 -0
  59. package/dist/model-choices.js +405 -0
  60. package/dist/model-names.d.ts +6 -0
  61. package/dist/model-names.js +34 -0
  62. package/dist/models-ui.d.ts +69 -5
  63. package/dist/models-ui.js +53 -10
  64. package/dist/needs-you.d.ts +8 -0
  65. package/dist/needs-you.js +8 -0
  66. package/dist/operate.d.ts +22 -2
  67. package/dist/operate.js +107 -41
  68. package/dist/project-activity.d.ts +20 -0
  69. package/dist/project-activity.js +23 -0
  70. package/dist/project-admission.d.ts +4 -1
  71. package/dist/project-admission.js +23 -2
  72. package/dist/project-cli.d.ts +1 -0
  73. package/dist/project-cli.js +36 -19
  74. package/dist/project-identity.d.ts +9 -2
  75. package/dist/project-identity.js +80 -33
  76. package/dist/project-knowledge.d.ts +9 -9
  77. package/dist/project-outcomes.d.ts +119 -0
  78. package/dist/project-outcomes.js +79 -0
  79. package/dist/result-actions.d.ts +4 -0
  80. package/dist/result-actions.js +41 -9
  81. package/dist/result-review.d.ts +9 -0
  82. package/dist/result-review.js +45 -5
  83. package/dist/serve.js +60 -13
  84. package/dist/server/chat.js +5 -4
  85. package/dist/server/chrome.d.ts +3 -0
  86. package/dist/server/chrome.js +5 -2
  87. package/dist/server/console.css +17 -2
  88. package/dist/server/flows.js +4 -0
  89. package/dist/server/http.d.ts +1 -0
  90. package/dist/server/http.js +6 -0
  91. package/dist/server/pages.js +53 -7
  92. package/dist/server/people-tokens.js +40 -20
  93. package/dist/server/render-chat.js +3 -2
  94. package/dist/server/render-pages.d.ts +12 -2
  95. package/dist/server/render-pages.js +36 -16
  96. package/dist/server/render-people.d.ts +14 -2
  97. package/dist/server/render-people.js +35 -12
  98. package/dist/server/render-results.d.ts +2 -0
  99. package/dist/server/render-results.js +20 -7
  100. package/dist/server/render-settings.js +1 -0
  101. package/dist/server/render-tasks.d.ts +1 -0
  102. package/dist/server/render-tasks.js +22 -19
  103. package/dist/server/request-context.d.ts +2 -0
  104. package/dist/server/route-table.js +9 -1
  105. package/dist/server/settings.d.ts +58 -1
  106. package/dist/server/settings.js +216 -70
  107. package/dist/server/signup-gate.d.ts +9 -0
  108. package/dist/server/signup-gate.js +40 -0
  109. package/dist/server/task-views.js +6 -0
  110. package/dist/server/tasks.js +54 -8
  111. package/dist/settings-kit-script.d.ts +21 -0
  112. package/dist/settings-kit-script.js +67 -0
  113. package/dist/settings-kit.d.ts +137 -0
  114. package/dist/settings-kit.js +186 -0
  115. package/dist/settings-proofs.d.ts +64 -0
  116. package/dist/settings-proofs.js +101 -0
  117. package/dist/setup-guide.js +4 -2
  118. package/dist/spend-report.d.ts +87 -0
  119. package/dist/spend-report.js +265 -0
  120. package/dist/spend-ui.d.ts +29 -8
  121. package/dist/spend-ui.js +74 -39
  122. package/dist/store.d.ts +46 -3
  123. package/dist/store.js +165 -6
  124. package/dist/task-sizing.d.ts +5 -3
  125. package/dist/task-sizing.js +4 -4
  126. package/dist/task-source.d.ts +13 -1
  127. package/dist/task-source.js +17 -1
  128. package/dist/telegram-lead.js +7 -5
  129. package/dist/telegram.js +1 -1
  130. package/dist/toolroll-update.js +2 -2
  131. package/dist/webhooks.d.ts +6 -0
  132. package/dist/webhooks.js +25 -0
  133. package/dist/work-index.d.ts +4 -0
  134. package/dist/work-index.js +32 -0
  135. package/dist/youiyoux-intake.d.ts +234 -0
  136. package/dist/youiyoux-intake.js +908 -0
  137. package/package.json +11 -8
package/dist/spend-ui.js CHANGED
@@ -1,64 +1,99 @@
1
1
  /**
2
- * The Spend page (v105): what agent work cost this month, where it went, and
3
- * the monthly budgets that alert at 50/80/100 % and (unless set to alert
4
- * only) stop new work at 100 %. An instance operator's page; budgets change
5
- * with a step-up.
2
+ * The Spend page (docs/plans/spend-dashboard.md, slice 1): what agent work cost this month, with API dollars, plan
3
+ * usage and unknown cost kept apart and never added together, broken down by project, model, role and person, the
4
+ * runs that cost most, and what isn't recorded here. An instance operator's page. Monthly limits show read-only here
5
+ * and change on Settings → Spend, with a step-up.
6
6
  */
7
7
  import { html, postForm, reauth } from "./html.js";
8
8
  import { budgetLabel, usd } from "./spend.js";
9
+ import { runHref, taskHref } from "./server/http.js";
9
10
  const projectName = (repo) => repo.split("/").filter(Boolean).pop() ?? repo;
10
11
  export const SPEND_CSS = `.spend{max-width:960px;min-width:0}.spend-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.spend-head h1{margin:0}` +
11
- `.spend-month{display:flex;gap:10px;align-items:baseline;font-size:.875rem}.spend-total{font-size:1.5rem;font-weight:600;margin:12px 0 2px;font-variant-numeric:tabular-nums}` +
12
+ `.spend-month{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;font-size:.875rem;margin:0}` +
12
13
  `.spend h2{font-size:.9375rem;margin:24px 0 8px}.spend table{width:100%;border-collapse:collapse;font-size:.8125rem}.spend th,.spend td{text-align:left;padding:6px 8px 6px 0;border-bottom:1px solid var(--border);overflow-wrap:anywhere}` +
13
- `.spend th{font-weight:500;color:var(--muted-foreground)}.spend td.n{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}.spend-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:0 24px}` +
14
+ `.spend th{font-weight:500;color:var(--muted-foreground)}.spend td.n,.spend th.n{text-align:right;font-variant-numeric:tabular-nums}.spend td.n,.spend th.n{white-space:nowrap}.spend table.lines td:first-child{overflow-wrap:break-word;width:40%}` +
15
+ `.spend-figures{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:16px 0 4px}.spend-figure{border:1px solid var(--border);border-radius:10px;padding:12px 14px;min-width:0}` +
16
+ `.spend-figure h2{margin:0;font-size:.8125rem;font-weight:500;color:var(--muted-foreground)}.spend-figure .value{margin:6px 0 0;font-size:1.5rem;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.01em}` +
17
+ `.spend-figure .meta{margin:4px 0 0}.spend-not-counted ul{margin:0;padding-left:20px;font-size:.8125rem}.spend-not-counted li{margin:2px 0}` +
14
18
  `.budget{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;padding:10px 0;border-bottom:1px solid var(--border)}.budget .name{font-weight:500;font-size:.875rem}` +
15
19
  `.budget .figures{font-size:.8125rem;font-variant-numeric:tabular-nums;text-align:right}.budget .bar{grid-column:1/-1;height:6px;border-radius:3px;background:var(--muted);overflow:hidden}` +
16
20
  `.budget .bar span{display:block;height:100%;background:var(--foreground)}.budget.over .bar span{background:var(--danger)}.budget.over .figures{color:var(--danger)}.budget .meta{grid-column:1/-1}` +
17
21
  `.budget details{grid-column:1/-1}.spend form.budget-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px 12px;align-items:end;margin:8px 0}` +
18
- `.spend form.budget-form label{display:grid;gap:4px;font-size:.8125rem}.spend form.budget-form .choice{display:flex;gap:8px;align-items:center}.spend form.budget-form button{justify-self:start}`;
19
- /** The rows of a breakdown: spend and count by a key, biggest first. */
20
- function breakdown(items, keyOf, top = 12) {
21
- const rows = new Map();
22
- for (const item of items) {
23
- const key = keyOf(item);
24
- if (key === null)
25
- continue;
26
- const row = rows.get(key) ?? { key, microusd: 0, count: 0, unpriced: 0 };
27
- row.microusd += item.microusd ?? 0;
28
- row.count++;
29
- if (item.microusd === null && item.source === "unpriced")
30
- row.unpriced++;
31
- rows.set(key, row);
32
- }
33
- return [...rows.values()].sort((a, b) => b.microusd - a.microusd || b.count - a.count).slice(0, top);
34
- }
35
- function table(title, rows, name) {
36
- if (rows.length === 0)
22
+ `.spend form.budget-form label{display:grid;gap:4px;font-size:.8125rem}.spend form.budget-form .choice{display:flex;gap:8px;align-items:center}.spend form.budget-form button{justify-self:start}` +
23
+ `.spend-models dl{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 16px;margin:0 0 8px;font-size:.875rem}.spend-models dt{color:var(--muted-foreground)}.spend-models dd{margin:0;overflow-wrap:anywhere}` +
24
+ `@media(max-width:640px){.spend-figures{grid-template-columns:minmax(0,1fr)}.spend table.lines thead{display:none}.spend table.lines tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:2px 12px;padding:8px 0;border-bottom:1px solid var(--border)}` +
25
+ `.spend table.lines td{border:0;padding:0;white-space:normal}.spend table.lines td:first-child{grid-column:1/-1;font-weight:500;width:auto}.spend table.lines td.n{text-align:left;color:var(--muted-foreground)}.spend table.lines td.n::before{content:attr(data-label);display:block;font-size:.6875rem}}`;
26
+ const ROLE_WORDS = {
27
+ builder: "Builds", repair: "Repairs", planner: "Plans", reviewer: "Reviews", scout: "Scouting",
28
+ lead: "Lead chat", chat: "Chat", subagent: "Subagents", flow: "Flows", evals: "Evals",
29
+ };
30
+ /** What this page doesn't record, in plain words (docs/plans/spend-dashboard.md §2.2). */
31
+ export const NOT_COUNTED = [
32
+ "Sizing a new task (Claude or OpenRouter)",
33
+ "Memory checks that read finished chat sessions",
34
+ "Coding sessions in Code",
35
+ "A flow draft that failed",
36
+ "A Sort step that failed before saving its answer",
37
+ ];
38
+ /** Dollars for the page: a positive amount under a cent reads "<$0.01", never a false $0. */
39
+ export const money = (microusd) => microusd > 0 && microusd < 10_000 ? "<$0.01" : usd(microusd);
40
+ const each = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
41
+ /** A line's plan usage in words: the reported usage, or that none was reported. Never $0 for unreported usage. */
42
+ const usageWords = (line) => line.usageCount === 0 ? "—" : line.usageMicrousd > 0 ? `${money(line.usageMicrousd)}${line.usageUnreported > 0 ? ` + ${line.usageUnreported} not reported` : ""}` : "not reported";
43
+ /** A line's unknown cost in words: how many, and a saved charge as an estimate. Never $0. */
44
+ const unknownWords = (line) => {
45
+ const counted = line.estimatedMicrousd > 0 ? `${money(line.estimatedMicrousd)} estimated` : "";
46
+ return line.unknown > 0 ? `${line.unknown}${counted === "" ? "" : ` · ${counted}`}` : counted === "" ? "—" : counted;
47
+ };
48
+ const apiWords = (line) => line.apiMicrousd > 0 ? money(line.apiMicrousd) : "—";
49
+ function linesTable(title, lines, name, top = 12, detail = () => "") {
50
+ if (lines.length === 0)
37
51
  return "";
38
- return html `<section><h2>${title}</h2><table><tbody>${rows.map(row => html `<tr><td>${name(row.key)}</td><td class="n">${row.count}</td><td class="n">${usd(row.microusd)}${row.unpriced > 0 ? html ` <span class="meta">+${row.unpriced} unpriced</span>` : ""}</td></tr>`)}</tbody></table></section>`;
52
+ return html `<section><h2>${title}</h2><table class="lines"><thead><tr><th></th><th class="n">API dollars</th><th class="n">Plan usage</th><th class="n">Unknown</th></tr></thead><tbody>${lines.slice(0, top).map(line => html `<tr><td>${name(line.key)}${detail(line) === "" ? "" : html ` <span class="meta">${detail(line)}</span>`}</td><td class="n" data-label="API dollars" data-amount-kind="api">${apiWords(line)}</td><td class="n" data-label="Plan usage" data-amount-kind="subscription">${usageWords(line)}</td><td class="n" data-label="Unknown" data-amount-kind="unknown">${unknownWords(line)}</td></tr>`)}</tbody></table></section>`;
53
+ }
54
+ /** A monthly limit's row; `editor` is Settings → Spend's change form, nothing on the Spend page. */
55
+ function limitRow(budget, nameOfSubagent, editor) {
56
+ const width = Math.min(100, Math.max(0, budget.percent));
57
+ const over = budget.spentMicrousd >= budget.limitMicrousd;
58
+ const label = budgetLabel(budget, budget.scope === "subagent" ? nameOfSubagent(budget.key) : undefined).replace(/'s$/, "");
59
+ return html `<div class="budget${over ? " over" : ""}" data-budget="${budget.id}"><span class="name">${label}</span><span class="figures">${money(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%</span><span class="bar" aria-hidden="true"><span style="width:${width}%"></span></span><span class="meta">${over && budget.hardStop ? "Used up: new API work waits until next month or a higher limit." : budget.hardStop ? "Pauses new API work at 100%." : "Alerts only."}${budget.unpriced > 0 ? ` ${budget.unpriced} without a price.` : ""}</span>${editor}</div>`;
39
60
  }
40
61
  export function spendHtml(view, notice) {
41
62
  const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
42
- const total = view.items.reduce((sum, item) => sum + (item.microusd ?? 0), 0);
43
- const unpriced = view.items.filter(item => item.microusd === null && (item.tokensIn !== null || item.kind !== "run")).length;
44
- const counts = { run: view.items.filter(item => item.kind === "run").length, subagent: view.items.filter(item => item.kind === "subagent").length, chat: view.items.filter(item => item.kind === "chat").length, sort: view.items.filter(item => item.kind === "sort").length };
45
- const parts = [`${counts.run} ${counts.run === 1 ? "run" : "runs"}`, ...(counts.subagent > 0 ? [`${counts.subagent} subagent turns`] : []), ...(counts.chat > 0 ? [`${counts.chat} chat turns`] : []), ...(counts.sort > 0 ? [`${counts.sort} ${counts.sort === 1 ? "sort" : "sorts"}`] : [])];
63
+ const { totals } = view.report;
46
64
  const nameOfSubagent = (key) => view.subagentNames.get(Number(key)) ?? `Subagent ${key}`;
47
- const budgets = view.budgets.map(budget => {
48
- const width = Math.min(100, Math.max(0, budget.percent));
49
- const over = budget.spentMicrousd >= budget.limitMicrousd;
50
- const label = budgetLabel(budget, budget.scope === "subagent" ? nameOfSubagent(budget.key) : undefined).replace(/'s$/, "");
51
- return html `<div class="budget${over ? " over" : ""}" data-budget="${budget.id}"><span class="name">${label}</span><span class="figures">${usd(budget.spentMicrousd)} of ${usd(budget.limitMicrousd)} · ${budget.percent}%</span><span class="bar" aria-hidden="true"><span style="width:${width}%"></span></span><span class="meta">${over && budget.hardStop ? "Used up: new API work waits until next month or a higher budget." : budget.hardStop ? "Stops API work at 100%." : "Alerts only."}${budget.unpriced > 0 ? ` ${budget.unpriced} unpriced` : ""}</span><details><summary>Change</summary>${postForm("/spend/budget", html `<label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" value="${Math.round(budget.limitMicrousd / 1_000_000)}" required></label><label class="choice"><input type="checkbox" name="stop" value="1"${budget.hardStop ? html ` checked` : ""}> Stop new work at 100%</label>${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit" name="action" value="save">Save</button> <button type="submit" name="action" value="remove">Remove</button>`, { attrs: { class: "budget-form" }, hidden: { target: `${budget.scope}:${budget.key}` } })}</details></div>`;
52
- });
65
+ const usageValue = totals.usageCount === 0 ? "None" : totals.usageMicrousd > 0 ? money(totals.usageMicrousd) : "Not reported";
66
+ const usageMeta = totals.usageCount === 0 ? "No plan usage recorded"
67
+ : totals.usageMicrousd > 0 ? `of usage at API prices${totals.usageUnreported > 0 ? ` · ${each(totals.usageUnreported, "piece")} of work not reported` : ""}` : `${each(totals.usageCount, "piece")} of work`;
68
+ const unknownValue = totals.unknown === 0 ? "None" : String(totals.unknown);
69
+ const unknownMeta = [totals.unknown > 0 ? `${totals.unknown === 1 ? "piece" : "pieces"} of work with billing not proved or cost not recorded` : "Nothing with an unknown cost",
70
+ ...(totals.estimatedMicrousd > 0 ? [`counted as ${money(totals.estimatedMicrousd)} (estimated)`] : [])].join(" · ");
71
+ const figures = html `<section class="spend-figures" aria-label="This month">\
72
+ <div class="spend-figure" data-amount-kind="api" data-microusd="${totals.apiMicrousd}"><h2>API dollars</h2><p class="value">${money(totals.apiMicrousd)}</p><p class="meta">Recorded charges to API keys</p></div>\
73
+ <div class="spend-figure" data-amount-kind="subscription" data-microusd="${totals.usageMicrousd}" data-unreported="${totals.usageUnreported}"><h2>Plan usage</h2><p class="value">${usageValue}</p><p class="meta">${usageMeta}</p></div>\
74
+ <div class="spend-figure" data-amount-kind="unknown" data-count="${totals.unknown}" data-estimated-microusd="${totals.estimatedMicrousd}"><h2>Unknown</h2><p class="value">${unknownValue}</p><p class="meta">${unknownMeta}</p></div></section>`;
75
+ const limits = view.budgets === null ? "" : html `<section><h2>Monthly limits</h2>${view.budgets.length === 0 ? html `<p class="meta">No monthly limits. <a href="/settings/spend">Set a limit</a></p>`
76
+ : html `${view.budgets.map(budget => limitRow(budget, nameOfSubagent, ""))}<p class="meta"><a href="/settings/spend">Change limits</a> · Evals never count toward a limit.</p>`}${view.windows}</section>`;
77
+ const runs = view.report.topRuns.length === 0 ? "" : html `<section><h2>Most expensive runs</h2><table><thead><tr><th>Task</th><th>Run</th><th>Model</th><th class="n">API dollars</th></tr></thead><tbody>${view.report.topRuns.map(run => html `<tr data-run="${run.runId}"><td><a href="${taskHref(run.taskId)}">${run.taskId}</a>${run.project === null ? "" : html ` <span class="meta">${projectName(run.project)}</span>`}</td><td><a href="${runHref(run.runId)}">${ROLE_WORDS[run.role]} ${run.runId}</a></td><td>${run.provider} · ${run.model}</td><td class="n">${money(run.microusd)}</td></tr>`)}</tbody></table></section>`;
78
+ const notCounted = html `<section class="spend-not-counted" data-not-counted><h2>Not counted here</h2><ul>${NOT_COUNTED.map(one => html `<li>${one}</li>`)}</ul></section>`;
79
+ return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend?month=${view.previous}">← ${view.previous}</a><strong>${view.month}</strong>${view.next === null ? "" : html `<a href="/spend?month=${view.next}">${view.next} →</a>`}<span class="meta">UTC month</span><a href="/spend?month=${view.month}&amp;format=csv" download>CSV</a></p></div>${note}${figures}${limits}${linesTable("By project", view.report.byProject, projectName)}${linesTable("By model", view.report.byModel, key => key, 12, line => line.chosen.length === 0 ? "" : `chosen as ${line.chosen.join(", ")}`)}${linesTable("By role", view.report.byRole, key => ROLE_WORDS[key] ?? key)}${linesTable("By person", view.report.byPerson, key => key)}${runs}${notCounted}</article>`;
80
+ }
81
+ /** Settings → Spend: the monthly limits editor (moved from the Spend page) and a summary of the models in use. */
82
+ export function spendSettingsHtml(view, notice) {
83
+ const note = notice.problem ? html `<p class="problem" role="alert">${notice.problem}</p>` : notice.said ? html `<p role="status">${notice.said}</p>` : "";
84
+ const nameOfSubagent = (key) => view.subagentNames.get(Number(key)) ?? `Subagent ${key}`;
85
+ const password = reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`);
86
+ const budgets = view.budgets.map(budget => limitRow(budget, nameOfSubagent, html `<details><summary>Change</summary>${postForm("/spend/budget", html `<label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" value="${Math.round(budget.limitMicrousd / 1_000_000)}" required></label><label class="choice"><input type="checkbox" name="stop" value="1"${budget.hardStop ? html ` checked` : ""}> Pause new API work at 100%</label>${password}<button type="submit" name="action" value="save">Save</button> <button type="submit" name="action" value="remove">Remove</button>`, { attrs: { class: "budget-form" }, hidden: { target: `${budget.scope}:${budget.key}` } })}</details>`));
53
87
  const groups = ["Everything", "Projects", "People", "Subagents"];
54
88
  const options = groups.map(group => {
55
89
  const members = view.targets.filter(one => one.group === group);
56
90
  return members.length === 0 ? "" : html `<optgroup label="${group}">${members.map(one => html `<option value="${one.value}">${one.label}</option>`)}</optgroup>`;
57
91
  });
58
- const add = html `<details${view.budgets.length === 0 ? html ` open` : ""}><summary>Add a budget</summary>${postForm("/spend/budget", html `<label>For<select name="target" required>${options}</select></label><label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" required></label><label class="choice"><input type="checkbox" name="stop" value="1" checked> Stop new work at 100%</label>${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit" name="action" value="save">Add budget</button>`, { attrs: { class: "budget-form" } })}</details>`;
59
- return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend?month=${view.previous}">← ${view.previous}</a><strong>${view.month}</strong>${view.next === null ? "" : html `<a href="/spend?month=${view.next}">${view.next} →</a>`}<a href="/spend?month=${view.month}&amp;format=csv" download>CSV</a></p></div>${note}<p class="spend-total" data-spend-total="${total}">${usd(total)}</p><p class="meta">${parts.join(" · ")}${unpriced > 0 ? ` · ${unpriced} unpriced (no reported cost or catalogue price)` : ""} · UTC month</p><section><h2>Budgets</h2>${budgets.length === 0 ? html `<p class="meta">No budgets yet.</p>` : budgets}${add}</section><div class="spend-grid">${table("By project", breakdown(view.items, item => item.project), projectName)}${table("By person", breakdown(view.items, item => item.person), key => key)}${table("By subagent", breakdown(view.items, item => item.subagent === null ? null : String(item.subagent)), nameOfSubagent)}${table("By model", breakdown(view.items, item => `${item.provider}${item.model === null ? "" : ` · ${item.model}`}`), key => key)}</div><p class="meta">Subscription work is $0; its limits are on Tasks. API work is what the provider reported, or its tokens at the prices in Settings → Models.</p></article>`;
92
+ const add = html `<details${view.budgets.length === 0 ? html ` open` : ""}><summary>Add a limit</summary>${postForm("/spend/budget", html `<label>For<select name="target" required>${options}</select></label><label>Monthly limit (US dollars)<input type="number" name="usd" min="1" step="1" required></label><label class="choice"><input type="checkbox" name="stop" value="1" checked> Pause new API work at 100%</label>${password}<button type="submit" name="action" value="save">Add limit</button>`, { attrs: { class: "budget-form" } })}</details>`;
93
+ const models = html `<section class="spend-models"><h2>Models</h2>${view.models.length === 0 ? "" : html `<dl>${view.models.map(one => html `<dt>${one.role}</dt><dd>${one.model}</dd>`)}</dl>`}<p><a href="/settings/models">Change models</a></p></section>`;
94
+ return html `<article class="spend"><div class="spend-head"><h1>Spend</h1><p class="spend-month"><a href="/spend">See this month's spend</a></p></div>${note}<section><h2>Monthly limits</h2>${budgets.length === 0 ? html `<p class="meta">No monthly limits yet.</p>` : budgets}${add}</section>${models}</article>`;
60
95
  }
61
- /** One row per piece of spend, for a spreadsheet. */
96
+ /** One row per piece of spend, for a spreadsheet (the command line's `spend --csv`). */
62
97
  export function spendCsv(items, subagentNames) {
63
98
  const cell = (value) => {
64
99
  const text = value === null || value === undefined ? "" : String(value);
package/dist/store.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type FeedbackImage } from "./result-review.js";
1
2
  import { type WritesMode } from "./writes-mode.js";
2
3
  import { type CheckAccess } from "./check-access.js";
3
4
  import { type ProjectSandbox } from "./project-sandbox.js";
@@ -139,6 +140,18 @@ export declare const V141_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
139
140
  definition: string;
140
141
  saved: string;
141
142
  }>>>>;
143
+ /** v142 (youiyoux-intake.ts): youiyoux change requests as tasks. `youiyoux_intake` is one row per project the intake
144
+ * has looked at: a person's on/off choice (`enabled` NULL: automatic, on while the project uses youiyoux) and the last
145
+ * pass's answer (whether the project uses youiyoux, how many request files couldn't be read, and the problem, if any).
146
+ * `youiyoux_request` is one row per request taken, keyed by project and request id: the task it became (NULL: left
147
+ * out, `note` says why), never filed again, the status last written beside it, with the console address its links
148
+ * named ('' for none), and where the person pointed (route, component, file:line), cleaned, for the task page. An
149
+ * options request (`kind`) also keeps the options file its agent writes (`proposal`, p-<its hex>), set when filed and
150
+ * never read from the request again: whether it went to the project's main checkout from which result (`proposal_state`,
151
+ * `proposal_run`, and why not in `proposal_note`; `taken`: a file was already there, so it was left and is never read
152
+ * for a decision), the digest of what was copied apart from what a decision changes (`proposal_digest`), and what a
153
+ * person decided on it in youiyoux's hub (`decided`, `decided_note`: what Toolroll did about it). New tables only. */
154
+ export declare const V142_SCHEMA = "\nCREATE TABLE IF NOT EXISTS youiyoux_intake (\n repo TEXT PRIMARY KEY,\n enabled INTEGER CHECK (enabled IS NULL OR enabled IN (0, 1)),\n uses INTEGER NOT NULL DEFAULT 0 CHECK (uses IN (0, 1)),\n changed_by TEXT,\n changed_at TEXT,\n checked_at TEXT,\n invalid INTEGER NOT NULL DEFAULT 0 CHECK (invalid >= 0),\n problem TEXT\n);\nCREATE TABLE IF NOT EXISTS youiyoux_request (\n repo TEXT NOT NULL,\n 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]'),\n task_id TEXT,\n note TEXT,\n filed_at TEXT NOT NULL,\n status TEXT CHECK (status IS NULL OR status IN ('queued','needs-you','building','ready','done','failed','stopped')),\n detail TEXT,\n status_at TEXT,\n written_at TEXT,\n written_base TEXT,\n write_problem TEXT,\n route TEXT,\n component TEXT,\n source_at TEXT,\n kind TEXT NOT NULL DEFAULT 'change' CHECK (kind IN ('change','options')),\n 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]')),\n proposal_state TEXT CHECK (proposal_state IS NULL OR proposal_state IN ('copied','taken','missing','unsafe','retry')),\n proposal_digest TEXT CHECK (proposal_digest IS NULL OR (length(proposal_digest) = 64 AND proposal_digest NOT GLOB '*[^0-9a-f]*')),\n proposal_run INTEGER,\n proposal_note TEXT,\n proposal_at TEXT,\n decided TEXT CHECK (decided IS NULL OR decided IN ('chosen','dismissed')),\n decided_at TEXT,\n decided_note TEXT,\n PRIMARY KEY (repo, request),\n UNIQUE (task_id),\n CHECK ((kind = 'options') = (proposal IS NOT NULL)),\n CHECK (proposal_state IS NULL OR kind = 'options'),\n CHECK (decided IS NULL OR coalesce(proposal_state = 'copied' AND proposal_digest IS NOT NULL, 0))\n);\n";
142
155
  /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
143
156
  export declare const LIVE_DETAIL_SCHEMA = "\nCREATE TABLE IF NOT EXISTS live_detail_setting (\n repo TEXT PRIMARY KEY,\n commands INTEGER NOT NULL CHECK (commands IN (0,1)),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n-- Every run a steering note reached, kept as it happens (live-notes.ts): a\n-- note's own attached/mailed fields move to the next attempt, this does not.\nCREATE TABLE IF NOT EXISTS steer_note_run (\n note INTEGER NOT NULL REFERENCES task_steer(id) ON DELETE CASCADE,\n run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,\n how TEXT NOT NULL CHECK (how IN ('attached','mailed','read')),\n at TEXT NOT NULL,\n PRIMARY KEY (note, run, how)\n);\nCREATE INDEX IF NOT EXISTS steer_note_run_by_run ON steer_note_run (run, note);\n";
144
157
  export declare const CHAT_APPROVAL_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_approval_setting (\n approver TEXT NOT NULL,\n scope TEXT NOT NULL,\n enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),\n full_access INTEGER NOT NULL DEFAULT 0 CHECK (full_access IN (0,1)),\n attempt_cap_microusd INTEGER CHECK (attempt_cap_microusd IS NULL OR attempt_cap_microusd >= 0),\n generation INTEGER NOT NULL,\n digest TEXT NOT NULL,\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (approver, scope)\n);\n";
@@ -219,7 +232,7 @@ export type SealedLedgerEntry = LedgerEntry & {
219
232
  hash: string;
220
233
  } | null;
221
234
  };
222
- export declare const SCHEMA_VERSION = 141;
235
+ export declare const SCHEMA_VERSION = 143;
223
236
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
224
237
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
225
238
  /**
@@ -376,6 +389,11 @@ export declare const V140_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
376
389
  definition: string;
377
390
  saved: string;
378
391
  }>>>>;
392
+ /** v143: optional screenshot context on result feedback; existing feedback stays unchanged (saved rows read NULL). */
393
+ export declare const V143_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
394
+ definition: string;
395
+ saved: string;
396
+ }>>>>;
379
397
  /**
380
398
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
381
399
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -2406,8 +2424,9 @@ export declare class Store {
2406
2424
  sealScopeApproval(taskId: string, by: string, now: Date, mutation?: Mutation, basis?: {
2407
2425
  kind: "mode" | "chat" | "grant" | "schedule" | "auto-check" | "auto-green";
2408
2426
  modeDigest: string;
2409
- /** A chat seal of a deploy gate: the release card it came from (chat-management.md §0a.5). */
2410
- card?: "release";
2427
+ /** A chat seal of a deploy gate: the release card it came from (chat-management.md §0a.5). A chat seal of a task
2428
+ * filed from outside text: `terms`, and the digest of every word the card showed (chat-terms.ts). */
2429
+ card?: "release" | "terms";
2411
2430
  cardDigest?: string;
2412
2431
  }, approverKind?: ApproverKind): boolean;
2413
2432
  /** Whether this task's scope carries a LIVE seal: approved, and the
@@ -3207,6 +3226,7 @@ export declare class Store {
3207
3226
  author: string;
3208
3227
  /** Ingested comments carry their origin id — the idempotency key (M8.17). */
3209
3228
  sourceKey?: string;
3229
+ image?: FeedbackImage | null;
3210
3230
  }, now: Date): number | null;
3211
3231
  /** Live (unsuperseded, unconsumed) comments on a run's terminal diff. */
3212
3232
  liveDiffComments(runId: number): DiffComment[];
@@ -3405,6 +3425,15 @@ export declare class Store {
3405
3425
  entry: number;
3406
3426
  sourceJson: string;
3407
3427
  } | null;
3428
+ /** v142: where a task filed from a youiyoux change or options request came from, as kept when it was filed
3429
+ * (youiyoux-intake.ts; a revision reads its first version's): the request, its kind, and where the person pointed. Null for every other task. */
3430
+ youiyouxOriginOf(taskId: string): {
3431
+ request: string;
3432
+ kind: "change" | "options";
3433
+ route: string | null;
3434
+ component: string | null;
3435
+ at: string | null;
3436
+ } | null;
3408
3437
  /**
3409
3438
  * Seal a revision in ONE transaction (Codex M5-M8 audit, C-3; contract
3410
3439
  * handoff task 2): the SOURCE BINDING is proven here, against live rows,
@@ -7376,6 +7405,7 @@ export type DiffComment = {
7376
7405
  id: number;
7377
7406
  artifact: number;
7378
7407
  artifactSha: string;
7408
+ image?: FeedbackImage | null;
7379
7409
  run: number;
7380
7410
  path: string | null;
7381
7411
  line: number | null;
@@ -8021,3 +8051,16 @@ export declare function sealStoreMoneyMethods(prototype: object): void;
8021
8051
  * when the prototype's value is the very reference recorded at the seal. `prototype` is Store's own unless a test
8022
8052
  * says otherwise; either way nothing but the recorded references is ever installed. */
8023
8053
  export declare function lockStoreMoneyMethods(store: object, prototype?: object): void;
8054
+ /** Takes a saved note's screenshot off without losing the note or its history (v143). Fail closed, in one write
8055
+ * transaction: the note must be live and unconsumed on this run, its screenshot must be genuinely unusable right now
8056
+ * (`unusable` is result-actions.ts feedbackImageProblem, re-read inside the transaction, never the page's say-so), and
8057
+ * the change is recorded in the action ledger under the person who made it. The note is superseded by an exact
8058
+ * imageless copy; the original stays in history. The caller has already proved the person may request changes on
8059
+ * this run (the same authority that seals any saved note into a revision); that standing is re-proved here too. */
8060
+ export declare function dropFeedbackImage(store: Store, runId: number, commentId: number, actor: string, unusable: (image: FeedbackImage) => string | null, now: Date): {
8061
+ ok: true;
8062
+ id: number;
8063
+ } | {
8064
+ ok: false;
8065
+ reason: "not-found" | "image-usable" | "not-authorized";
8066
+ };
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
+ }
@@ -1,11 +1,11 @@
1
1
  import { type SizeAnswer } from "./contracts/task-sizing.js";
2
2
  import { type CommandRunner } from "./claude-print.js";
3
- import { type TaskSizing } from "./phase-routing.js";
3
+ import { type TaskSize, type TaskSizing } from "./phase-routing.js";
4
4
  import type { Store } from "./store.js";
5
5
  /** The classifier's whole budget, from asking to answer. */
6
6
  export declare const SIZING_BUDGET_MS = 5000;
7
- /** The model `claude -p` sizes with: small and fast. */
8
- export declare const SIZING_CLAUDE_MODEL = "haiku";
7
+ /** The model `claude -p` sizes with: small and fast, by its exact id (an alias would float to whatever is newest). */
8
+ export declare const SIZING_CLAUDE_MODEL = "claude-haiku-5-5";
9
9
  export type SizingInput = {
10
10
  title: string;
11
11
  goal?: string | undefined;
@@ -22,6 +22,8 @@ export declare const SUBSTANTIAL_WORK: RegExp;
22
22
  export declare function planningSignals(input: SizingInput): boolean;
23
23
  /** The size the description alone gives — today's planning signals, never risky. */
24
24
  export declare function heuristicSizing(input: SizingInput, why?: string | null): TaskSizing;
25
+ export declare const SIZE_MEANS: Record<TaskSize, string>;
26
+ export declare const RISKY_MEANS = "Could a mistake here lose data, break security, sign-in, payments or permissions, change a database schema or migration, or be hard to undo?";
25
27
  /** What the classifier reads: the task as data, bounded, keys blanked. */
26
28
  export declare function sizingText(input: SizingInput): string;
27
29
  /** A small Claude model on this computer's sign-in: no tools, no MCP servers, no repository, credential keys stripped.
@@ -30,8 +30,8 @@ import {} from "./phase-routing.js";
30
30
  import { ALL_CREDENTIAL_ENV } from "./provider.js";
31
31
  /** The classifier's whole budget, from asking to answer. */
32
32
  export const SIZING_BUDGET_MS = 5_000;
33
- /** The model `claude -p` sizes with: small and fast. */
34
- export const SIZING_CLAUDE_MODEL = "haiku";
33
+ /** The model `claude -p` sizes with: small and fast, by its exact id (an alias would float to whatever is newest). */
34
+ export const SIZING_CLAUDE_MODEL = "claude-haiku-5-5";
35
35
  /** Structural or broad work — the description signal planning has always read. */
36
36
  export const SUBSTANTIAL_WORK = /\b(?:architecture|end[- ]to[- ]end|migrat(?:e|ion)|moderni[sz]e|redesign|refactor|rework|workflow|navigation|accessibility|responsive|performance|security|unif(?:y|ied)|multi[- ](?:step|project|service))\b/i;
37
37
  /** The description's own signals: several paths, a long description, or
@@ -55,12 +55,12 @@ function answerOf(value) {
55
55
  const read = readSizeAnswer(value);
56
56
  return read.ok ? read.value : null;
57
57
  }
58
- const SIZE_MEANS = {
58
+ export const SIZE_MEANS = {
59
59
  small: "a contained change a fast model can make directly: copy, a style, a config value, one small function, one or two files",
60
60
  medium: "ordinary feature or fix work across a few files that benefits from the everyday model",
61
61
  large: "broad or structural work: many files, a new subsystem, a redesign or refactor, cross-cutting behaviour",
62
62
  };
63
- const RISKY_MEANS = "Could a mistake here lose data, break security, sign-in, payments or permissions, change a database schema or migration, or be hard to undo?";
63
+ export const RISKY_MEANS = "Could a mistake here lose data, break security, sign-in, payments or permissions, change a database schema or migration, or be hard to undo?";
64
64
  /** What the classifier reads: the task as data, bounded, keys blanked. */
65
65
  export function sizingText(input) {
66
66
  return [