toolroll 0.9.0 → 0.9.1

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 (43) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/agent-onboard.d.ts +21 -1
  3. package/dist/agent-onboard.js +50 -1
  4. package/dist/backup-ui.js +4 -2
  5. package/dist/browser/workspace.css +2 -2
  6. package/dist/browser/workspace.js +31 -31
  7. package/dist/browser-workspace.d.ts +12 -2
  8. package/dist/chat-actions.d.ts +5 -0
  9. package/dist/chat-actions.js +43 -3
  10. package/dist/cli.d.ts +1 -1
  11. package/dist/cli.js +1 -1
  12. package/dist/desktop-update.d.ts +1 -1
  13. package/dist/flow-engine.js +13 -1
  14. package/dist/flow-pull-request.d.ts +11 -0
  15. package/dist/flow-pull-request.js +123 -0
  16. package/dist/flow-starters-ui.d.ts +20 -0
  17. package/dist/flow-starters-ui.js +35 -0
  18. package/dist/flow-starters.d.ts +55 -0
  19. package/dist/flow-starters.js +104 -0
  20. package/dist/flow-steps.js +14 -0
  21. package/dist/flows-ui.d.ts +1 -1
  22. package/dist/flows-ui.js +4 -3
  23. package/dist/flows.d.ts +30 -6
  24. package/dist/flows.js +110 -6
  25. package/dist/integrations-ui.js +9 -4
  26. package/dist/lead-context.d.ts +1 -1
  27. package/dist/lead-context.js +1 -1
  28. package/dist/mate-contract.js +1 -1
  29. package/dist/mate-tools.js +18 -5
  30. package/dist/models-ui.js +3 -2
  31. package/dist/monitoring-ui.js +2 -1
  32. package/dist/operate.js +18 -0
  33. package/dist/policy-ui.js +2 -1
  34. package/dist/pull-request-flow.d.ts +32 -0
  35. package/dist/pull-request-flow.js +43 -8
  36. package/dist/pull-request-ui.js +2 -2
  37. package/dist/serve.js +108 -43
  38. package/dist/skills-ui.js +2 -1
  39. package/dist/store.js +2 -0
  40. package/dist/surface.js +1 -1
  41. package/dist/when-html.d.ts +8 -0
  42. package/dist/when-html.js +36 -0
  43. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -25,6 +25,8 @@ import { grantTool, revokeTool, rulesFromForm, setToolRules } from "./teammate-t
25
25
  import { addKitGithubTrigger, addKitSample, kitInstalled, kitOf, setUpKit } from "./kits.js";
26
26
  import { CONNECT_CALLBACK, connectionsOf, finishConnect, oneClickOf, startConnect } from "./mcp-connect.js";
27
27
  import { KITS_CSS, kitPageHtml, kitsGalleryHtml } from "./kits-ui.js";
28
+ import { STARTERS_CSS, startersHtml } from "./flow-starters-ui.js";
29
+ import { starterForWork, starterOf, startersFor, switchOnStarter } from "./flow-starters.js";
28
30
  import { createTeammateFrom, labelOf, nameOf, saveSoul, setTeammateState, teammateSettings, sendTeammateSummaries } from "./teammate-admin.js";
29
31
  import { editMemory, forgetMemory, tellTeammate } from "./teammate-memory.js";
30
32
  import { addRoutine, removeRoutine, runRoutine } from "./teammate-desk.js";
@@ -246,6 +248,7 @@ import { cachedRelease, isNewer, runnerVersions, setUpdateChecks, updateChecksOf
246
248
  import { installMethod } from "./install-method.js";
247
249
  import { PACKAGE_VERSION } from "./version.js";
248
250
  import { updateNoticeWords } from "./update-notice.js";
251
+ import { whenHtml } from "./when-html.js";
249
252
  const SESSION_COOKIE = "standing-orders_session";
250
253
  const BODY_CAP = 16 * 1024;
251
254
  // URL encoding can triple UTF-8 bytes. Admit the existing bounded task
@@ -1582,8 +1585,8 @@ export function createDecisionServer(options) {
1582
1585
  refuse(response, who, 404, 'No such action in your projects.', '/projects');
1583
1586
  return false;
1584
1587
  }
1585
- const read = new Set(["/settings/skills", "/settings/knowledge", "/settings", "/settings/learning", "/recipes", "/recipes/run", "/recipes/start", "/recipes/new", "/recipes/edit", "/recipes/from-task", "/recipes/preview", "/recipes/export", "/", "/inbox", "/work", "/projects", "/people", "/ledger", "/ledger/export", "/next", "/board", "/tasks", "/tasks/new", "/runs", "/review", "/done", "/routines", "/menu"]);
1586
- const write = new Set(["/settings/skills/import", "/settings/skills/change", "/settings/skills/revise", "/settings/knowledge/change", "/settings/knowledge/refresh", "/settings/learning/change", "/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/projects/select", "/tasks/add", "/routines/add"]);
1588
+ const read = new Set(["/settings/flows", "/settings/skills", "/settings/knowledge", "/settings", "/settings/learning", "/recipes", "/recipes/run", "/recipes/start", "/recipes/new", "/recipes/edit", "/recipes/from-task", "/recipes/preview", "/recipes/export", "/", "/inbox", "/work", "/projects", "/people", "/ledger", "/ledger/export", "/next", "/board", "/tasks", "/tasks/new", "/runs", "/review", "/done", "/routines", "/menu"]);
1589
+ const write = new Set(["/settings/flows/on", "/settings/skills/import", "/settings/skills/change", "/settings/skills/revise", "/settings/knowledge/change", "/settings/knowledge/refresh", "/settings/learning/change", "/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/projects/select", "/tasks/add", "/routines/add"]);
1587
1590
  const task = matchTaskPath(path, request.method === "GET" ? "(/evidence)?$" : "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|next|reopen|steer|accept-proof|accept-revision|reject-revision|route|retry-review|complete|merge|stop|resume-arm|resume)$");
1588
1591
  const resource = request.method === "GET"
1589
1592
  ? /^\/(?:r|d)\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(path) || /^\/routines\/[0-9]{1,15}$/.test(path) || path === "/flows" || /^\/flows\/[0-9]{1,15}(\/insights|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(path)
@@ -3639,6 +3642,20 @@ export function createDecisionServer(options) {
3639
3642
  sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name) };
3640
3643
  return sendScreen(response, 200, screen("Sessions & tokens", `<p><a href="/settings">Settings</a></p><h1>Sessions &amp; tokens</h1>${credentialsHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3641
3644
  }
3645
+ // Settings → Flows: the starter flows, each switched on with one yes. ?starter= marks the one a task's
3646
+ // "Do this every time…" asked about.
3647
+ if (url.pathname === "/settings/flows") {
3648
+ const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3649
+ const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3650
+ if (chosen && !projects.includes(chosen))
3651
+ return refuse(response, who, 403, "That project is outside your access.", "/settings");
3652
+ const content = chosen === "" ? "<p>Add a project to switch on starter flows.</p>" : startersHtml({
3653
+ repo: chosen, projects: projects.map(path => ({ path, name: projectName(path) })), starters: startersFor(store, chosen), csrf: who.via === "cookie" ? who.session.csrf : "",
3654
+ canSwitch: who.via === "cookie" && who.role === "approver" && !store.isDemo(), suggested: starterOf(url.searchParams.get("starter") ?? "")?.id ?? null,
3655
+ said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
3656
+ });
3657
+ return sendScreen(response, 200, screen("Flows", `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
3658
+ }
3642
3659
  // Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
3643
3660
  if (url.pathname === "/settings/integrations") {
3644
3661
  if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
@@ -4890,7 +4907,7 @@ export function createDecisionServer(options) {
4890
4907
  .map(run => `<a class="item${run.id === currentId ? " current" : ""}" href="/r/${run.id}">` +
4891
4908
  `<span class="t">#${run.id} \u00b7 ${escape(run.taskId)}</span>` +
4892
4909
  `<span class="m">${runOutcomeBadge(run, live.has(run.id))}` +
4893
- `<span class="mono">${escape(when(run.startedAt))}</span></span></a>`)
4910
+ `<span class="mono">${whenTime(run.startedAt)}</span></span></a>`)
4894
4911
  .join("\n");
4895
4912
  return `<h2>builds</h2>\n${items === "" ? `<p class="meta">none yet</p>` : items}`;
4896
4913
  }
@@ -6274,7 +6291,12 @@ export function createDecisionServer(options) {
6274
6291
  return redirect(response, `/flows?problem=${encodeURIComponent("Choose one of your projects.")}`);
6275
6292
  const template = FLOW_TEMPLATES.find(one => one.id === body.get("template")) ?? FLOW_TEMPLATES[0];
6276
6293
  const name = (body.get("name") ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80) || template.label;
6277
- return redirect(response, `/flows/${store.createFlow({ repo, name, definitionJson: JSON.stringify(template.definition), by: who.name }, now)}`);
6294
+ const made = store.createFlow({ repo, name, definitionJson: JSON.stringify(template.definition), by: who.name }, now);
6295
+ // A template that starts from something (Issues to PRs: labelled issues) comes with its trigger.
6296
+ const trigger = template.trigger === undefined ? null : addFlowTriggerTo(store, store.getFlow(made), template.trigger, who.name, now, options.configDir ?? null);
6297
+ if (trigger !== null && !trigger.ok)
6298
+ return redirect(response, `/flows?problem=${encodeURIComponent(`${name} was made, but its trigger wasn't: ${trigger.message} Add one on its Triggers panel.`)}`);
6299
+ return redirect(response, `/flows/${made}`);
6278
6300
  }
6279
6301
  const flow = store.getFlow(Number((flowPost ?? triggerPost)[1]));
6280
6302
  if (flow === null || flow.state !== "active" || !visible(flow.repo))
@@ -6831,6 +6853,21 @@ export function createDecisionServer(options) {
6831
6853
  "cache-control": "no-store", "x-content-type-options": "nosniff" });
6832
6854
  return void response.end(zip);
6833
6855
  }
6856
+ // Settings → Flows → Switch on: a starter flow's zones and trigger, made together.
6857
+ if (url.pathname === "/settings/flows/on") {
6858
+ const repo = body.get("repo") ?? "";
6859
+ const back = (key, words) => redirect(response, `/settings/flows?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
6860
+ const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])];
6861
+ if (who.via !== "cookie" || who.role !== "approver" || store.isDemo())
6862
+ return refuse(response, who, 403, "An approver switches starter flows on.", "/settings/flows");
6863
+ if (!known.includes(repo) || !visible(repo))
6864
+ return refuse(response, who, 404, "No such project.", "/settings/flows");
6865
+ const starter = starterOf(body.get("starter") ?? "");
6866
+ if (starter === null)
6867
+ return back("problem", "Choose a starter flow.");
6868
+ const switched = switchOnStarter(store, starter, repo, who.name, clock(), options.configDir ?? null);
6869
+ return switched.ok ? back("said", switched.said) : back("problem", switched.said);
6870
+ }
6834
6871
  // Settings → Integrations → Send test: one harmless read-only check of one integration, now.
6835
6872
  if (url.pathname === "/settings/integrations/test") {
6836
6873
  if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
@@ -9394,7 +9431,7 @@ export function createDecisionServer(options) {
9394
9431
  `<p class="meta">worker</p><p class="recap" style="margin-top:0">${escape(terms.runner)} (this machine)</p>` +
9395
9432
  `<p class="meta">spending</p><p class="recap" style="margin-top:0">up to about $${(terms.budgetMicrousd / 1_000_000).toFixed(2)} — the agent stops as soon as its total crosses this; the final step may run a little past it</p>` +
9396
9433
  `<p class="meta">conversation</p><p class="recap" style="margin-top:0">at most ${terms.maxSessionTurns} messages to the agent, this whole session; each may work up to ${Math.round(terms.turnTimeoutSeconds / 60)} minutes — one that runs past that ends the whole session. If it needs a repair, the repair uses the same session, model, and clock.</p>` +
9397
- `<p class="meta">while you watch — a signed term</p><p class="recap" style="margin-top:0">your console being open is what keeps it running — any page of it, this one included; close the console and the session winds down within a minute. Everything ends by ${escape(when(terms.absoluteExpiry))} regardless. One attempt; it never converts into unattended work.</p>` +
9434
+ `<p class="meta">while you watch — a signed term</p><p class="recap" style="margin-top:0">your console being open is what keeps it running — any page of it, this one included; close the console and the session winds down within a minute. Everything ends by ${whenTime(terms.absoluteExpiry)} regardless. One attempt; it never converts into unattended work.</p>` +
9398
9435
  (quick
9399
9436
  ? `<p class="meta">your signed mode covers this mint — no password; the mode is re-proved as you confirm, and the session is stamped with its signature</p>`
9400
9437
  : `<label>your password, typed again — a signed-in session alone cannot authorize work<input type="password" name="token" autocomplete="current-password"></label>`) +
@@ -10977,6 +11014,10 @@ function consoleSpend(summary) {
10977
11014
  function when(iso) {
10978
11015
  return iso === null ? "" : iso.slice(0, 16).replace("T", " ");
10979
11016
  }
11017
+ /** The same minute as a `<time>`: the full stamp on a desk, "16:39" / "Yesterday 16:39" / "Sep 28" on a phone. */
11018
+ function whenTime(iso) {
11019
+ return iso === null ? "" : whenHtml(iso, when(iso));
11020
+ }
10980
11021
  /** Overdue is derived at render — display never writes. */
10981
11022
  function isOverdue(decision, now) {
10982
11023
  if (decision.state === "expired")
@@ -11464,7 +11505,7 @@ function agentsAvailabilityHtml(projection) {
11464
11505
  const items = [...seen.values()].map(leg => {
11465
11506
  const state = leg.readiness === "ready" ? "ready" : leg.readiness === "unavailable" ? "unavailable" : "not yet checked";
11466
11507
  const detail = leg.readiness === "unavailable" && leg.readinessReason !== null ? ` (${escape(leg.readinessReason)})` : "";
11467
- return `<li class="agents-availability-${escape(leg.readiness)}"><span class="mono">${escape(leg.provider)}</span> ${state}${detail}${leg.readinessRunner === null ? "" : ` <span class="meta">— ${escape(leg.readinessRunner)}${leg.observedAt === null ? "" : `, ${escape(when(leg.observedAt))}`}</span>`}</li>`;
11508
+ return `<li class="agents-availability-${escape(leg.readiness)}"><span class="mono">${escape(leg.provider)}</span> ${state}${detail}${leg.readinessRunner === null ? "" : ` <span class="meta">— ${escape(leg.readinessRunner)}${leg.observedAt === null ? "" : `, ${whenTime(leg.observedAt)}`}</span>`}</li>`;
11468
11509
  });
11469
11510
  return `<p class="meta agents-availability-label">Availability right now</p><ul class="agents-availability">${items.join("")}</ul>`;
11470
11511
  }
@@ -11554,7 +11595,7 @@ function agentsCardHtml(taskId, view, csrf, canEdit) {
11554
11595
  const overrides = view.overrides.length === 0
11555
11596
  ? ""
11556
11597
  : `<ul class="agents-overrides">${view.overrides
11557
- .map(one => `<li><span>${escape(ROLE_NOUN[one.phase])} → <span class="mono">${escape(one.provider)} · ${escape(one.model)}</span> <span class="meta">by ${escape(one.by)} ${escape(when(one.at))}</span></span>` +
11598
+ .map(one => `<li><span>${escape(ROLE_NOUN[one.phase])} → <span class="mono">${escape(one.provider)} · ${escape(one.model)}</span> <span class="meta">by ${escape(one.by)} ${whenTime(one.at)}</span></span>` +
11558
11599
  (canEdit && view.editable ? `<form method="post" action="${taskHref(taskId)}/route" class="agents-clear">${hidden}<input type="hidden" name="clear-phase" value="${escape(one.phase)}"><button type="submit" class="secondary" aria-label="clear the ${escape(ROLE_NOUN[one.phase].toLowerCase())} choice">clear</button></form>` : "") +
11559
11600
  `</li>`)
11560
11601
  .join("")}</ul>`;
@@ -11727,6 +11768,9 @@ ${THEME_MAPPING}
11727
11768
  --so-ease-out: cubic-bezier(.23, 1, .32, 1);
11728
11769
  --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
11729
11770
  --font-mono: "Geist Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
11771
+ /* Phone spacing steps (760px and narrower): side gutter, gap under the
11772
+ header, gap between page blocks, between rows, and inside a card. */
11773
+ --so-phone-gutter: 14px; --so-phone-gap: 12px; --so-phone-block: 14px; --so-phone-row: 8px; --so-phone-card: 12px;
11730
11774
  }
11731
11775
  @media (prefers-color-scheme: dark) {
11732
11776
  :root:not([data-theme="light"]) {
@@ -14297,12 +14341,27 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
14297
14341
  }
14298
14342
  .sticky-actions button { margin: 0; }
14299
14343
  }
14344
+
14345
+ /* A time a person reads (when-html.ts): the full stamp on a desk, the short
14346
+ one on a phone; the full stamp stays in the title. */
14347
+ .so-when-short { display: none; }
14348
+ @media (max-width: 760px) {
14349
+ .so-when-full { display: none; }
14350
+ .so-when-short { display: inline; }
14351
+ /* Server pages outside the workspace keep the same phone rhythm. */
14352
+ main { padding: var(--so-phone-gap) var(--so-phone-gutter) 3rem; }
14353
+ h2 { margin: 1.25rem 0 .375rem; }
14354
+ .card { padding: var(--so-phone-card); margin: var(--so-phone-row) 0; }
14355
+ .problem { padding: .5rem .625rem; margin: var(--so-phone-row) 0; }
14356
+ .meta, .hint { line-height: 1.35; }
14357
+ details { margin: var(--so-phone-row) 0; }
14358
+ }
14300
14359
  `;
14301
14360
  /** Appearance: a three-way segmented switch, one tap per choice. */
14302
14361
  const THEME_CONTROLS_CSS = `.task-repo select{width:100%;min-height:2.75rem;font-size:1rem}.task-repo-add{margin:.35rem .1rem .5rem}.task-repo-add a{display:inline-flex;align-items:center;min-height:2.25rem}details.result-request-open.result-request-form>summary{border:0;background:transparent;padding:.5rem 0;min-height:2.75rem;font-weight:600;display:list-item;list-style:revert}details.result-request-open.result-request-form>summary::-webkit-details-marker{display:revert}form.js-autosave button[type=submit]{display:none}.provider-row{border-bottom:1px solid var(--so-line);padding:.35rem 0}.provider-row:first-of-type{border-top:1px solid var(--so-line)}.provider-head{display:flex;align-items:center;gap:.75rem;margin:.4rem 0 0}.provider-status{display:inline-flex;align-items:center;gap:.4rem;color:var(--so-muted);font-size:.875rem}.provider-status i{width:.5rem;height:.5rem;border-radius:50%;background:var(--so-muted)}.provider-status--ok i{background:var(--so-success)}.provider-status--warn i{background:var(--so-attention)}.provider-status--off i{background:transparent;border:1.5px solid var(--so-muted)}details.provider-manage>summary{cursor:pointer;color:var(--so-accent-text);font-size:.875rem;min-height:2.5rem;display:list-item;padding-block:.5rem}.card.props .row{display:grid;gap:.1rem;margin:0 0 .75rem}.card.props .row>.meta{display:block;font-size:.75rem}.card.props .row>.meta::first-letter{text-transform:uppercase}.card.props .row>.mono{font-family:var(--font-sans);font-size:.875rem}.card.props .row>.mono .seal{font-family:var(--font-mono);font-size:.8125rem}details.evidence-files{margin:1rem 0}details.evidence-files>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:600}details.evidence-files ul{list-style:none;margin:0;padding:0}details.evidence-files li{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--so-line)}.result-action .result-feedback-link{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;text-decoration:none}@media(hover:hover) and (pointer:fine){.result-action .result-feedback-link:hover{background:var(--so-raised)}}.so-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.verdict{margin:.5rem 0 .75rem}.verdict-chips{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:0}.verdict-chip{display:inline-flex;align-items:center;gap:.3rem;min-height:1.75rem;padding:.2rem .65rem;border-radius:999px;font-size:.8125rem;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.verdict-chip svg{width:.9rem;height:.9rem}.verdict-chip--success{background:var(--so-success-soft);color:var(--so-success)}.verdict-chip--danger{background:var(--so-danger-soft);color:var(--so-danger)}.verdict-chip--warning{background:var(--so-warning-soft);color:var(--so-warning)}.verdict-chip--info{background:var(--so-info-soft);color:var(--so-info)}.verdict-by{margin:.4rem 0 0}details.result-request-open{margin:.5rem 0}details.result-request-open>summary{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;cursor:pointer;list-style:none}details.result-request-open>summary::-webkit-details-marker{display:none}details.result-request-open[open]>summary{margin-bottom:.75rem}.settings-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.5rem;margin:0 0 2rem}.settings-tiles a{display:flex;align-items:center;gap:.6rem;min-height:3rem;padding:.65rem .8rem;border:1px solid var(--so-line);border-radius:.625rem;background:var(--so-paper);color:var(--so-ink);text-decoration:none;font-weight:550;font-size:.875rem}@media(hover:hover) and (pointer:fine){.settings-tiles a:hover{border-color:var(--so-input-line);background:var(--so-raised)}}.settings-tiles svg{width:1.1rem;height:1.1rem;flex-shrink:0;color:var(--so-accent-text)}details.settings-more{margin:.25rem 0 1.25rem}details.settings-more>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:550}details.settings-more>summary .meta{font-weight:400;margin-left:.35rem}.settings-changed{margin-top:-.25rem}.appearance{margin:0 0 28px}.appearance h2{margin:0 0 10px}.theme-switch{display:inline-flex;flex-wrap:nowrap;max-width:100%;gap:4px;padding:4px;margin:0;border:1px solid var(--so-line);border-radius:10px;background:var(--so-raised)}.theme-switch .theme-choice,.so-native-region .theme-switch .theme-choice{flex:1 1 0;width:auto;white-space:nowrap;min-height:40px;padding:8px 16px;border:0;border-radius:7px;background:transparent;color:var(--so-muted);font:inherit;font-weight:550;box-shadow:none;cursor:pointer}@media(hover:hover) and (pointer:fine){.theme-switch .theme-choice:hover{color:var(--so-ink)}}.theme-switch .theme-choice[aria-pressed="true"]{background:var(--so-paper);color:var(--so-ink);box-shadow:0 1px 2px rgb(0 0 0 / .1)}.appearance .meta{margin:8px 0 0}@media(max-width:600px){.theme-switch .theme-choice{min-height:44px}}.update-notes{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;max-height:18rem;overflow:auto}`;
14303
14362
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
14304
14363
  export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
14305
- const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
14364
+ const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + STARTERS_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
14306
14365
  /**
14307
14366
  * The board's liveness: fetch this page's own fragment on a timer and swap
14308
14367
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -14972,7 +15031,7 @@ function inboxPage(chrome, data) {
14972
15031
  : `<div class="card builder-notice" data-builder-status="${data.worker.registered === 0 ? "not-connected" : "disconnected"}">` +
14973
15032
  (data.worker.registered === 0
14974
15033
  ? `<strong>No builder is connected yet.</strong> Toolroll is open, but no machine is connected to do project work. On the machine where the project lives, open that folder and run <span class="mono">toolroll up</span>. Keep Toolroll running; approved work starts automatically.`
14975
- : `<strong>Builder disconnected.</strong> ${data.worker.registered} builder${data.worker.registered === 1 ? " is" : "s are"} configured, last checked in ${data.worker.lastHeard === null ? "never" : escape(when(data.worker.lastHeard))}. Reopen Toolroll on that machine. Queued work starts automatically when a builder reconnects.`) +
15034
+ : `<strong>Builder disconnected.</strong> ${data.worker.registered} builder${data.worker.registered === 1 ? " is" : "s are"} configured, last checked in ${data.worker.lastHeard === null ? "never" : whenTime(data.worker.lastHeard)}. Reopen Toolroll on that machine. Queued work starts automatically when a builder reconnects.`) +
14976
15035
  `</div>`;
14977
15036
  return screen("inbox", [
14978
15037
  `<h1>Inbox</h1>`,
@@ -15040,8 +15099,8 @@ function systemPage(chrome, data) {
15040
15099
  ? ""
15041
15100
  : `<div class="stat-card"><span class="k"><span class="dot ${data.episode.endedAt === null ? "dot-ok pulse" : "dot-off"}"></span>Toolroll</span>` +
15042
15101
  `<span class="v">${data.episode.endedAt === null
15043
- ? `running since ${escape(when(data.episode.startedAt))}`
15044
- : `last run: ${data.episode.built} built, ${data.episode.broke} broke \u00b7 ended ${escape(when(data.episode.endedAt))}`}</span></div>`;
15102
+ ? `running since ${whenTime(data.episode.startedAt)}`
15103
+ : `last run: ${data.episode.built} built, ${data.episode.broke} broke \u00b7 ended ${whenTime(data.episode.endedAt)}`}</span></div>`;
15045
15104
  const cards = [...runnerCards, watchCard, ...worktreeCards].filter(one => one !== "");
15046
15105
  const PHASE_SAID = {
15047
15106
  plan: "planning sessions ask questions and draft the plan you approve",
@@ -15311,7 +15370,7 @@ function donePage(chrome, rows, ciRed) {
15311
15370
  return (`<div class="card"><p><a href="${taskHref(row.taskId)}"><strong>${escape(row.title)}</strong></a>` +
15312
15371
  `${row.outcome === "no-change" ? ` <span class="badge">no change needed</span>` : ""}${pr}${needsVerification}${criterionMatrixSummary(row.proofMatrix)}</p>` +
15313
15372
  `${row.handoff === null ? "" : `<p class="meta">${escape(row.handoff.length > 200 ? row.handoff.slice(0, 200) + "\u2026" : row.handoff)}</p>`}` +
15314
- `<p class="meta mono">${escape(row.taskId)} \u00b7 ${escape(when(row.completedAt))}${row.ranMinutes === null ? "" : ` \u00b7 ran ${row.ranMinutes}m`}${row.provider === null ? "" : ` \u00b7 ${escape(runCostWords({ authMode: row.authMode, costUsd: row.costUsd, tokensIn: null, tokensOut: null }))}`}` +
15373
+ `<p class="meta mono">${escape(row.taskId)} \u00b7 ${whenTime(row.completedAt)}${row.ranMinutes === null ? "" : ` \u00b7 ran ${row.ranMinutes}m`}${row.provider === null ? "" : ` \u00b7 ${escape(runCostWords({ authMode: row.authMode, costUsd: row.costUsd, tokensIn: null, tokensOut: null }))}`}` +
15315
15374
  ` \u00b7 <a href="${reviewHref(row.taskId)}">review \u2192</a></p></div>`);
15316
15375
  })
15317
15376
  .join("\n");
@@ -16748,7 +16807,7 @@ function routineScreenPage(chrome, data) {
16748
16807
  `<p class="meta">${routine.paused ? "resuming fires again at the next due slot" : "pausing stops firing instantly; a running instance finishes"}${runNowForm === "" ? "" : " · run now spawns an extra instance without touching the schedule — spend outside the schedule takes your password again"}</p>` +
16749
16808
  `</div>`;
16750
16809
  const ledger = fires.length === 0
16751
- ? `<p class="meta">No firings yet${approved && routine.nextFireAt !== null ? ` — first at ${escape(when(routine.nextFireAt))}` : ""}.</p>`
16810
+ ? `<p class="meta">No firings yet${approved && routine.nextFireAt !== null ? ` — first at ${whenTime(routine.nextFireAt)}` : ""}.</p>`
16752
16811
  : fires
16753
16812
  .map(fire => {
16754
16813
  const said = fire.outcome === "fired"
@@ -16757,7 +16816,7 @@ function routineScreenPage(chrome, data) {
16757
16816
  : `<a href="${taskHref(fire.instanceTaskId)}" class="mono">${escape(fire.instanceTaskId)}</a>${fire.instanceState === null ? "" : ` <span class="badge badge-${escape(fire.instanceState)}">${escape(fire.instanceState)}</span>`}`
16758
16817
  : `<span class="meta">skipped — ${escape(fire.reason ?? "")}</span>`;
16759
16818
  const slot = fire.scheduledFor.replace(/^manual:/, "");
16760
- return `<p class="row">${said}<span class="right meta mono">${fire.reason === "manual" ? "run now · " : ""}${escape(when(slot))}</span></p>`;
16819
+ return `<p class="row">${said}<span class="right meta mono">${fire.reason === "manual" ? "run now · " : ""}${whenTime(slot)}</span></p>`;
16761
16820
  })
16762
16821
  .join("\n");
16763
16822
  return screen(`routine · ${routine.name}`, [
@@ -16769,7 +16828,7 @@ function routineScreenPage(chrome, data) {
16769
16828
  ? `<div class="problem">stopped behind <a href="${taskHref(data.blocker.taskId)}" class="mono">${escape(data.blocker.taskId)}</a> (${escape(data.blocker.state)}) — the track resumes when it finishes or is cancelled</div>`
16770
16829
  : "",
16771
16830
  approved && !routine.paused && routine.nextFireAt !== null
16772
- ? `<p class="meta">next fire ${escape(when(routine.nextFireAt))}</p>`
16831
+ ? `<p class="meta">next fire ${whenTime(routine.nextFireAt)}</p>`
16773
16832
  : "",
16774
16833
  "<h2>the standing order</h2>",
16775
16834
  terms,
@@ -16863,8 +16922,8 @@ function homePage(chrome, data) {
16863
16922
  ? ""
16864
16923
  : `<div class="stat-card"><span class="k"><span class="dot ${data.episode.endedAt === null ? "dot-ok pulse" : "dot-off"}"></span>Toolroll</span>` +
16865
16924
  `<span class="v">${data.episode.endedAt === null
16866
- ? `running since ${escape(when(data.episode.startedAt))}`
16867
- : `last window: ${data.episode.built} built, ${data.episode.broke} broke \u00b7 ended ${escape(when(data.episode.endedAt))}`}</span></div>`;
16925
+ ? `running since ${whenTime(data.episode.startedAt)}`
16926
+ : `last window: ${data.episode.built} built, ${data.episode.broke} broke \u00b7 ended ${whenTime(data.episode.endedAt)}`}</span></div>`;
16868
16927
  const fleetCards = [...runnerCards, watchCard, ...worktreeCards].filter(one => one !== "");
16869
16928
  const fleet = fleetCards.length === 0
16870
16929
  ? `<h2>system status</h2><p class="hint">No builder is connected yet. On the machine where the project lives, open that folder and run <code>toolroll up</code>.</p>`
@@ -17545,7 +17604,7 @@ function contestPage(chrome, data) {
17545
17604
  `only one result will be kept as the task's outcome; the rest stay archived with their evidence</p>`,
17546
17605
  data.problem === null ? "" : `<div class="problem">${escape(data.problem)}</div>`,
17547
17606
  `<p class="row"><strong>${escape((CONTEST_STATE_WORDS[contest.state] ?? "the tournament is in an unexpected state — the records have the detail").replace(/tournament/g, contestNoun(contest.kind)))}</strong></p>`,
17548
- contest.pickedBy === null ? "" : `<p class="meta">picked by ${escape(contest.pickedBy)} at ${escape(when(contest.pickedAt ?? ""))}</p>`,
17607
+ contest.pickedBy === null ? "" : `<p class="meta">picked by ${escape(contest.pickedBy)} at ${whenTime(contest.pickedAt ?? "")}</p>`,
17549
17608
  contest.kind === "comparison"
17550
17609
  ? `<p class="row"><strong>spend</strong> ${escape(contestDollars(data.totalMicrousd))} measured on the lanes that report dollars` +
17551
17610
  `${data.anyUnknown ? ` <span class="meta">— the rest report tokens only</span>` : ""}</p>`
@@ -17742,7 +17801,7 @@ function githubReposPage(chrome, data) {
17742
17801
  `<div class="row"><strong>${escape(repo.nameWithOwner)}</strong>${repo.isPrivate ? ` <span class="badge">private</span>` : ""}`,
17743
17802
  `<span class="right">${action}</span></div>`,
17744
17803
  localPath === null
17745
- ? `<p class="meta">not on this machine yet${/^\d{4}-\d{2}-\d{2}T/.test(repo.updatedAt) ? ` · pushed ${escape(when(repo.updatedAt))}` : ""}</p>`
17804
+ ? `<p class="meta">not on this machine yet${/^\d{4}-\d{2}-\d{2}T/.test(repo.updatedAt) ? ` · pushed ${whenTime(repo.updatedAt)}` : ""}</p>`
17746
17805
  : `<p class="meta mono" style="overflow-wrap:anywhere;margin:.2rem 0">${escape(localPath)}</p>`,
17747
17806
  repo.description === "" ? "" : `<p class="meta">${escape(repo.description)}</p>`,
17748
17807
  `</div>`,
@@ -18443,7 +18502,7 @@ function planRevisionLedgerHtml(ledger, taskId, csrf) {
18443
18502
  : `<details class="plan-revision-history"><summary>revision history (${ledger.history.length})</summary>` +
18444
18503
  ledger.history
18445
18504
  .map(one => `<p class="row"><span class="mono">rev ${one.revision}</span> <span class="badge">${escape(one.status)}</span> ` +
18446
- `<span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span> — ${escape(one.reason)}</p>`)
18505
+ `<span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span> — ${escape(one.reason)}</p>`)
18447
18506
  .join("\n") +
18448
18507
  `</details>`;
18449
18508
  return (`<div class="card plan-revision"><span class="eyebrow">plan updated</span><h2>Using plan revision ${current.revision}</h2>` +
@@ -18780,7 +18839,7 @@ function taskBodyParts(data) {
18780
18839
  ? `attached to build #${one.attachedRun} — delivery not yet proven`
18781
18840
  : "waiting for the next attempt";
18782
18841
  const steerRows = steering
18783
- .map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))} · ${escape(steerState(one))}</span> ` +
18842
+ .map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)} · ${escape(steerState(one))}</span> ` +
18784
18843
  `${escape(one.note)}</p>`)
18785
18844
  .join("\n");
18786
18845
  const steerForm = data.csrf === "" || task.state === "done" || task.state === "cancelled" || (data.contest ?? null) !== null
@@ -18796,7 +18855,7 @@ function taskBodyParts(data) {
18796
18855
  : `<h2>holds</h2>` +
18797
18856
  data.holds
18798
18857
  .map(hold => `<p class="row">${escape(holdOwnerWords(hold.ownerKind))} — ${escape(hold.reason)}` +
18799
- `${hold.until === null ? "" : ` <span class="meta">until ${escape(when(hold.until))}</span>`}</p>`)
18858
+ `${hold.until === null ? "" : ` <span class="meta">until ${whenTime(hold.until)}</span>`}</p>`)
18800
18859
  .join("\n") +
18801
18860
  `<p class="meta">only your hold can be lifted here — waits caused by questions, incidents, or retry delays clear on their own</p>`;
18802
18861
  const approval = approvalOf(scope);
@@ -18822,7 +18881,7 @@ function taskBodyParts(data) {
18822
18881
  })(),
18823
18882
  acceptanceCeremonyHtml(scope.acceptance),
18824
18883
  approval.approved
18825
- ? `<p class="meta scope-seal">Approved by ${escape(approval.by)} · ${escape(when(approval.at))} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
18884
+ ? `<p class="meta scope-seal">Approved by ${escape(approval.by)} · ${whenTime(approval.at)} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
18826
18885
  : `<p class="meta"><span class="seal">signs ${shortDigest(scope.digest)}</span> — approval binds to this exact wording</p>` +
18827
18886
  `<p class="meta">Not approved${approval.reason === "changed" ? " — approved once, then rewritten" : ""}</p>`,
18828
18887
  `</div>`,
@@ -19028,7 +19087,7 @@ function taskBodyParts(data) {
19028
19087
  ? [
19029
19088
  `<div class="card" data-attended="${escape(attended.open.id)}">`,
19030
19089
  `<p><strong>attended session</strong> <span class="meta">${escape(attended.open.state)}</span></p>`,
19031
- `<p class="meta">${attended.open.running ? "the agent is running — your open console keeps it live" : "waiting to dispatch to this machine"} · ${attended.open.turnsUsed}/${attended.open.cap} messages · $${(attended.open.spentMicrousd / 1_000_000).toFixed(2)} of $${(attended.open.budgetMicrousd / 1_000_000).toFixed(2)} · everything ends by ${escape(when(attended.open.expiresAt))}</p>`,
19090
+ `<p class="meta">${attended.open.running ? "the agent is running — your open console keeps it live" : "waiting to dispatch to this machine"} · ${attended.open.turnsUsed}/${attended.open.cap} messages · $${(attended.open.spentMicrousd / 1_000_000).toFixed(2)} of $${(attended.open.budgetMicrousd / 1_000_000).toFixed(2)} · everything ends by ${whenTime(attended.open.expiresAt)}</p>`,
19032
19091
  `<p class="meta">this page being open keeps it alive — close it and the session winds down within a minute</p>`,
19033
19092
  `<form method="post" action="${taskHref(task.id)}/attend-revoke" class="inline">`,
19034
19093
  `<input type="hidden" name="csrf" value="${escape(data.csrf)}">`,
@@ -19181,7 +19240,7 @@ function taskBodyParts(data) {
19181
19240
  runOutcomeBadge(run, run.id === liveHistoryRunId) +
19182
19241
  `${run.reason === null ? "" : ` <span class="meta">${escape(reasonWords(run.reason))}</span>`}` +
19183
19242
  ` <span class="meta mono">${escape(bits.join(" · "))}</span>` +
19184
- `<span class="right meta mono">${escape(when(run.startedAt))}</span></p>`);
19243
+ `<span class="right meta mono">${whenTime(run.startedAt)}</span></p>`);
19185
19244
  })
19186
19245
  .join("\n");
19187
19246
  // ---- the right rail (slice 1c) ------------------------------------------
@@ -19260,7 +19319,7 @@ function taskBodyParts(data) {
19260
19319
  const scopeRow = scope === null
19261
19320
  ? prop("scope", "none yet")
19262
19321
  : approval.approved
19263
- ? prop("approved scope", `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${escape(qualityModeTitle(scope.qualityMode ?? "default"))} · approved by ${escape(approval.by)} · ${escape(when(approval.at))}`)
19322
+ ? prop("approved scope", `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${escape(qualityModeTitle(scope.qualityMode ?? "default"))} · approved by ${escape(approval.by)} · ${whenTime(approval.at)}`)
19264
19323
  : prop("scope", approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved");
19265
19324
  const strikesRow = data.strikes > 0 ? prop("strikes", `${data.strikes} failed attempt(s)`) : "";
19266
19325
  const propsCard = `<div class="card props">${workerRow}${queueRow}${scopeRow}${publishesRow}${economics}${strikesRow}</div>`;
@@ -19624,6 +19683,11 @@ function taskBodyParts(data) {
19624
19683
  { id: "task-diagnostics", title: "Task options", html: optionsHtml, open: optionsOpen, count: null },
19625
19684
  ],
19626
19685
  cancel: cancelForm === "" ? null : { html: cancelForm, open: data.cancelDraft !== undefined },
19686
+ // "Do this every time…": the starter flow that does this kind of work on its own, one yes away.
19687
+ everyTime: data.repo === null || data.csrf === "" ? null : (() => {
19688
+ const starter = starterForWork(`${task.title}\n${scope?.goal ?? ""}`);
19689
+ return { href: `/settings/flows?repo=${encodeURIComponent(data.repo)}&starter=${starter.id}#starter-${starter.id}`, starter: starter.name };
19690
+ })(),
19627
19691
  };
19628
19692
  return { html, view };
19629
19693
  }
@@ -19966,7 +20030,7 @@ function reviewCockpitPage(chrome, data) {
19966
20030
  : `<span class="cockpit-why">${statusLineHtml(status)}</span>`;
19967
20031
  return (`<li data-review-priority="${row.priority.band}"><a class="cockpit-row${current ? " current" : ""}" href="${escape(reviewHref(row.taskId, row.runId, row.repo))}"${current ? ` aria-current="page"` : ""}>` +
19968
20032
  `<span class="cockpit-row-head"><strong>${escape(row.title)}</strong></span>` +
19969
- `<span class="cockpit-row-meta">${escape(when(row.completedAt))}${row.runId === null ? " · no build" : row.outcome === "no-change" ? " · no change" : ""}${row.prNumber === null ? "" : ` · PR #${row.prNumber}`}</span>` +
20033
+ `<span class="cockpit-row-meta">${whenTime(row.completedAt)}${row.runId === null ? " · no build" : row.outcome === "no-change" ? " · no change" : ""}${row.prNumber === null ? "" : ` · PR #${row.prNumber}`}</span>` +
19970
20034
  (row.historyProblem ? `<span class="cockpit-why" data-history-problem>History unavailable</span>` : why) +
19971
20035
  (status !== null && row.ciFailing ? `<span class="cockpit-why">CI is failing</span>` : "") +
19972
20036
  `</a></li>`);
@@ -20054,7 +20118,7 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
20054
20118
  ? "the scope changed after approval — the words below are the current text, not the signed one"
20055
20119
  : "never approved — the result was built without a signed scope";
20056
20120
  const approval = intent.approval.approved
20057
- ? `approved by ${escape(intent.approvedBy ?? "an operator")} · ${escape(when(intent.approval.at))}`
20121
+ ? `approved by ${escape(intent.approvedBy ?? "an operator")} · ${whenTime(intent.approval.at)}`
20058
20122
  : approvalWords;
20059
20123
  const body = `<p class="recap" style="margin-top:.25rem"><strong>goal</strong> ${escape(intent.goal)}</p>` +
20060
20124
  (intent.outOfScope === null ? `<p class="meta">no boundary was stated</p>` : `<p class="recap"><strong>not this</strong> ${escape(intent.outOfScope)}</p>`) +
@@ -20116,7 +20180,7 @@ function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab, user)
20116
20180
  parts.push(completionForm(view.taskId, run.id, complete.receipt, csrf, view.detail?.pullRequestTo ?? null));
20117
20181
  if (view.notes.length > 0) {
20118
20182
  parts.push(`<section class="card cockpit-section" data-cockpit-section="notes"><h3>operator notes</h3>` +
20119
- view.notes.map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span> ${escape(one.note)}</p>`).join("\n") +
20183
+ view.notes.map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span> ${escape(one.note)}</p>`).join("\n") +
20120
20184
  `</section>`);
20121
20185
  }
20122
20186
  return {
@@ -20312,7 +20376,7 @@ function runsPage(chrome, rows, liveIds, nextCursor, verdicts = new Map(), accep
20312
20376
  needsVerification +
20313
20377
  criterionMatrixSummary(verdicts.get(run.id)?.matrix ?? []) +
20314
20378
  `${run.provider === "claude" ? "" : ` <span class="meta mono">${escape(run.provider)}</span>`}` +
20315
- `<span class="right meta mono">${escape(when(run.startedAt))}` +
20379
+ `<span class="right meta mono">${whenTime(run.startedAt)}` +
20316
20380
  `${run.providerStartedAt === null && run.tokensIn === null && run.tokensOut === null && run.costUsd === null ? "" : ` \u00b7 ${escape(runCostWords(run, liveIds.has(run.id)))}`}</span></p>`);
20317
20381
  })
20318
20382
  .join("\n");
@@ -20809,7 +20873,7 @@ function evidenceBundleCard(view, run, publication = null, review = null) {
20809
20873
  parts.push(`<p class="row" data-proof-verdict="${escape(dispatchStatusToken(view.verdict))}">${statusLineHtml(status)} <span class="meta">${escape(verificationExplanation(view.verdict, view.reasons))}</span></p>`);
20810
20874
  }
20811
20875
  if (view.accepted !== null) {
20812
- parts.push(`<p class="meta">Accepted with an exception by <span class="mono">${escape(view.accepted.by)}</span> · ${escape(when(view.accepted.at))}${view.accepted.note === null ? "" : ` — ${escape(view.accepted.note)}`}</p>`);
20876
+ parts.push(`<p class="meta">Accepted with an exception by <span class="mono">${escape(view.accepted.by)}</span> · ${whenTime(view.accepted.at)}${view.accepted.note === null ? "" : ` — ${escape(view.accepted.note)}`}</p>`);
20813
20877
  }
20814
20878
  parts.push(criterionMatrixHtml(view.matrix, { runId, links: view.matrixLinks }));
20815
20879
  parts.push(semanticCoverageHtml(view.matrix, view.qualityMode));
@@ -21085,7 +21149,7 @@ function resultPanelParts(detail, o) {
21085
21149
  (publication === null || publication.prNumber === null
21086
21150
  ? ""
21087
21151
  : ` ${prUrl === null ? `<span class="mono">PR #${publication.prNumber}</span>` : `<a href="${escape(prUrl)}">PR #${publication.prNumber}</a>`}` +
21088
- `<span class="meta" data-ci-observed="${escape(detail.ciFailing ? "failing" : (publication.lastCheckState ?? "none"))}"> · ${detail.ciFailing ? "CI failing at the last check" : publication.lastCheckState === "passing" ? `CI passing, observed ${escape(when(publication.lastCheckAt))}` : publication.lastCheckState === "running" ? "CI still running at the last check" : "no CI checks found — verify on GitHub"}</span>`) +
21152
+ `<span class="meta" data-ci-observed="${escape(detail.ciFailing ? "failing" : (publication.lastCheckState ?? "none"))}"> · ${detail.ciFailing ? "CI failing at the last check" : publication.lastCheckState === "passing" ? `CI passing, observed ${whenTime(publication.lastCheckAt)}` : publication.lastCheckState === "running" ? "CI still running at the last check" : "no CI checks found — verify on GitHub"}</span>`) +
21089
21153
  `</p>`;
21090
21154
  const agent = runAgentWords(detail.route);
21091
21155
  const links = [];
@@ -21101,7 +21165,7 @@ function resultPanelParts(detail, o) {
21101
21165
  summaryParts.push(`<details class="result-details" data-result-details><summary>Build details</summary>` +
21102
21166
  (unavailableShots.length === 0 ? "" : `<ul class="result-unavailable meta" data-result-unavailable="${unavailableShots.length}">${unavailableShots.map(one => `<li>Screenshot unavailable — <span class="mono">${escape(one.path)}</span>: ${escape(one.problem ?? "")}</li>`).join("")}</ul>`) +
21103
21167
  `<dl class="result-facts">` +
21104
- `<div><dt>Build</dt><dd>#${runId} · ${escape(run.runner)}${agent === null ? ` · ${escape(run.provider)}` : ""}${run.finishedAt === null ? "" : ` · ${escape(when(run.finishedAt))}`}</dd></div>` +
21168
+ `<div><dt>Build</dt><dd>#${runId} · ${escape(run.runner)}${agent === null ? ` · ${escape(run.provider)}` : ""}${run.finishedAt === null ? "" : ` · ${whenTime(run.finishedAt)}`}</dd></div>` +
21105
21169
  (agent === null ? "" : `<div><dt>Agent</dt><dd>${escape(agent)}</dd></div>`) +
21106
21170
  `<div><dt>Commits</dt><dd>${facts.head === null
21107
21171
  ? facts.base === null ? "no commit recorded" : `<span class="mono">${escape(facts.base.slice(0, 12))}</span> <span class="meta">base · no head recorded</span>`
@@ -21180,7 +21244,7 @@ function resultPanelParts(detail, o) {
21180
21244
  checkParts.push(`<p class="meta" data-proof-verdict="none">No verification result is available for this build.</p>`);
21181
21245
  }
21182
21246
  if (proof.accepted !== null)
21183
- checkParts.push(`<p class="meta">${humanReview ? "Accepted after human review" : "Accepted with an exception"} by <span class="mono">${escape(proof.accepted.by)}</span> · ${escape(when(proof.accepted.at))}${proof.accepted.note === null ? "" : ` — ${escape(proof.accepted.note)}`}</p>`);
21247
+ checkParts.push(`<p class="meta">${humanReview ? "Accepted after human review" : "Accepted with an exception"} by <span class="mono">${escape(proof.accepted.by)}</span> · ${whenTime(proof.accepted.at)}${proof.accepted.note === null ? "" : ` — ${escape(proof.accepted.note)}`}</p>`);
21184
21248
  if (proof.matrix.length === 0) {
21185
21249
  checkParts.push(detail.signedCriteria > 0 ? `<p class="meta">The approved scope has ${detail.signedCriteria} requirement${detail.signedCriteria === 1 ? "" : "s"}, but this build has no requirement-by-requirement verification.</p>` : `<p class="meta">This scope signed no acceptance checks.</p>`);
21186
21250
  }
@@ -21231,7 +21295,7 @@ function resultPanelParts(detail, o) {
21231
21295
  };
21232
21296
  if (detail.comments.length > 0) {
21233
21297
  requestParts.push(`<p class="meta">Saved for later · ${detail.comments.length}</p><div class="diff-comments" data-result-notes="${detail.comments.length}">` +
21234
- detail.comments.map(one => `<div class="diff-comment"><span class="diff-comment-pin" aria-hidden="true"></span><p>${one.path === null ? "" : pathWords(one.path, one.line)}${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span></div>`).join("") +
21298
+ detail.comments.map(one => `<div class="diff-comment"><span class="diff-comment-pin" aria-hidden="true"></span><p>${one.path === null ? "" : pathWords(one.path, one.line)}${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span></div>`).join("") +
21235
21299
  `</div>`);
21236
21300
  if (o.csrf !== "" && !detail.canAnnotate) {
21237
21301
  requestParts.push(`<form method="post" action="/r/${runId}/revise" class="card revision-from-comments"><input type="hidden" name="csrf" value="${escape(o.csrf)}"><input type="hidden" name="return" value="${escape(o.returnTo)}">${revisionSealFields(detail.comments, detail.sourceDigest)}` +
@@ -21240,7 +21304,7 @@ function resultPanelParts(detail, o) {
21240
21304
  }
21241
21305
  }
21242
21306
  if ((detail.pastComments?.length ?? 0) > 0) {
21243
- requestParts.push(`<details class="result-feedback-history"><summary>Earlier feedback</summary><div class="diff-comments" data-past-feedback>${detail.pastComments.map(one => `<div class="diff-comment"><p>${one.path === null ? "" : pathWords(one.path, one.line)}${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span></div>`).join("")}</div></details>`);
21307
+ requestParts.push(`<details class="result-feedback-history"><summary>Earlier feedback</summary><div class="diff-comments" data-past-feedback>${detail.pastComments.map(one => `<div class="diff-comment"><p>${one.path === null ? "" : pathWords(one.path, one.line)}${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span></div>`).join("")}</div></details>`);
21244
21308
  }
21245
21309
  for (const revision of detail.revisions) {
21246
21310
  // The child's standing is the shared status projection's own words
@@ -21459,7 +21523,7 @@ function runPage(chrome, run, taskId, artifacts, terminal = null, notes = [], cs
21459
21523
  ? "the brief"
21460
21524
  : turn.sourceKind === "answer"
21461
21525
  ? "your answer"
21462
- : "repair (machine)"} · ${escape(when(turn.recordedAt))}${turnWords(turn)}</span> ${escape(oneLineOf(turn.text, 240))}</p>`)
21526
+ : "repair (machine)"} · ${whenTime(turn.recordedAt)}${turnWords(turn)}</span> ${escape(oneLineOf(turn.text, 240))}</p>`)
21463
21527
  .join("\n")) +
21464
21528
  (heldTurns.open && csrf !== ""
21465
21529
  ? `<form method="post" action="/r/${run.id}/turn" class="row">` +
@@ -21534,7 +21598,7 @@ function runPage(chrome, run, taskId, artifacts, terminal = null, notes = [], cs
21534
21598
  const commentRows = comments
21535
21599
  .map(one => `<div class="diff-comment"><span class="diff-comment-pin" aria-hidden="true"></span><p>` +
21536
21600
  `${one.path === null ? "" : commentPathWords(one.path, one.line)}` +
21537
- `${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span></div>`)
21601
+ `${escape(one.note)}</p><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span></div>`)
21538
21602
  .join("\n");
21539
21603
  const commentForm = csrf === "" || !hasTerminalDiff
21540
21604
  ? ""
@@ -21578,7 +21642,7 @@ function runPage(chrome, run, taskId, artifacts, terminal = null, notes = [], cs
21578
21642
  const noteRows = notes.length === 0
21579
21643
  ? ""
21580
21644
  : notes
21581
- .map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${escape(when(one.createdAt))}</span> ` +
21645
+ .map(one => `<p class="row"><span class="meta">${escape(one.author)} · ${whenTime(one.createdAt)}</span> ` +
21582
21646
  `${escape(one.note)}</p>`)
21583
21647
  .join("\n");
21584
21648
  const noteForm = csrf === ""
@@ -21779,7 +21843,7 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21779
21843
  : `<div class="card"><p><strong>${permissionDefault.mode === "bypassPermissions" ? "Full access" : "Auto"}</strong></p><p class="meta">an approver can change this default</p></div>`,
21780
21844
  permissionDefault.updatedAt === null
21781
21845
  ? ""
21782
- : `<p class="meta settings-changed">Changed ${escape(when(permissionDefault.updatedAt))}${permissionDefault.updatedBy === null ? "" : ` by ${escape(permissionDefault.updatedBy)}`}</p>`,
21846
+ : `<p class="meta settings-changed">Changed ${whenTime(permissionDefault.updatedAt)}${permissionDefault.updatedBy === null ? "" : ` by ${escape(permissionDefault.updatedBy)}`}</p>`,
21783
21847
  ].join("\n");
21784
21848
  const qualityCard = qualityDefault === null
21785
21849
  ? ""
@@ -21793,7 +21857,7 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21793
21857
  : `<div class="card"><p><strong>${escape(qualityModeTitle(qualityDefault.mode))}</strong></p><p class="meta">an approver can change this default</p></div>`,
21794
21858
  qualityDefault.updatedAt === null
21795
21859
  ? ""
21796
- : `<p class="meta settings-changed">Changed ${escape(when(qualityDefault.updatedAt))}${qualityDefault.updatedBy === null ? "" : ` by ${escape(qualityDefault.updatedBy)}`}</p>`,
21860
+ : `<p class="meta settings-changed">Changed ${whenTime(qualityDefault.updatedAt)}${qualityDefault.updatedBy === null ? "" : ` by ${escape(qualityDefault.updatedBy)}`}</p>`,
21797
21861
  ].join("\n");
21798
21862
  // Quiet chat: this person's own choice. The installation's Telegram cadence only bundles Every step updates.
21799
21863
  const chatCard = chatNotices === null || csrf === ""
@@ -21896,7 +21960,7 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21896
21960
  : `<p class="meta">Alerts need a secure (https) address for this app.</p>`,
21897
21961
  ...push.devices
21898
21962
  .filter(one => one.retiredAt === null || one.retiredReason === "gone")
21899
- .map(one => `<p class="row">${escape(one.uaWords)} · since ${escape(when(one.createdAt))}` +
21963
+ .map(one => `<p class="row">${escape(one.uaWords)} · since ${whenTime(one.createdAt)}` +
21900
21964
  `${one.retiredAt !== null ? ` · <span class="meta">expired</span>` : one.consecutiveFailures >= 20 ? ` · <span class="meta">failing</span>` : ""}` +
21901
21965
  (one.retiredAt === null
21902
21966
  ? ` <form method="post" action="/push/remove" class="inline"><input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="id" value="${one.id}"><button type="submit">Remove</button></form>`
@@ -22089,6 +22153,7 @@ function settingsTiles() {
22089
22153
  return `<nav class="settings-tiles" aria-label="Settings sections">${tiles.map(([href, label, icon]) => `<a href="${href}">${strokeIcon(icon)}<span>${label}</span></a>`).join("")}</nav>`;
22090
22154
  }
22091
22155
  const SETTINGS_TILE_ICONS = [
22156
+ ["/settings/flows", "Flows", `<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><path d="M10 6.5h4a3 3 0 0 1 3 3V14"/>`],
22092
22157
  ["/settings/integrations", "Integrations", `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
22093
22158
  ["/settings/models", "Models", `<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>`],
22094
22159
  ["/settings/skills", "Skills", `<path d="m12 3 1.9 5.8L20 10l-5 3.6L16.8 20 12 16.4 7.2 20 9 13.6 4 10l6.1-1.2z"/>`],
@@ -22150,7 +22215,7 @@ function nextPage(chrome, data) {
22150
22215
  if (item.kind === "decision") {
22151
22216
  const { decision } = item;
22152
22217
  card =
22153
- `<h1>${escape(decision.taskId)} <span class="meta">asked ${escape(when(decision.createdAt))}</span></h1>` +
22218
+ `<h1>${escape(decision.taskId)} <span class="meta">asked ${whenTime(decision.createdAt)}</span></h1>` +
22154
22219
  `<div class="recap">${escape(decision.recap)}</div>` +
22155
22220
  `<div class="question">${escape(decision.question)}</div>` +
22156
22221
  decisionOptionForms(decision, data.csrf, "next");
package/dist/skills-ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { whenUtc } from "./when-html.js";
2
3
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3
4
  const hidden = (data) => Object.entries(data)
4
5
  .map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`)
@@ -41,7 +42,7 @@ export function skillsHtml(view, csrf, canManage, options = {}) {
41
42
  ? `<details class="card"${options.error && options.draft?.["method"] ? " open" : ""}><summary>Add skill</summary><form method="post" action="/settings/skills/import" data-skill-import>${hidden(base)}<label>Add from<select name="method" data-skill-method><option value="paste"${d["method"] === "paste" ? " selected" : ""}>Paste SKILL.md</option><option value="github"${d["method"] === "github" ? " selected" : ""}>GitHub folder</option><option value="folder"${d["method"] === "folder" ? " selected" : ""}>Local folder</option></select></label><div data-skill-input="paste"><label>SKILL.md<textarea name="content" rows="8" maxlength="24576" placeholder="---&#10;name: design-review&#10;description: Review interface copy and layout.&#10;---&#10;Write your instructions here.">${e(d["content"])}</textarea></label></div><div data-skill-input="github"><label>Public GitHub folder<input name="url" type="url" value="${e(d["url"])}" placeholder="https://github.com/owner/repo/tree/main/skills/review"></label></div><div data-skill-input="folder"><label>Skill folder<input type="file" webkitdirectory multiple data-skill-folder></label><input type="hidden" name="files" value=""><p class="meta">Choose the folder containing SKILL.md. Up to 64 files, 1 MB total.</p><noscript>Folder uploads require JavaScript. You can paste SKILL.md or use a GitHub folder.</noscript></div><p class="meta">Added to your library first. Review its source and requirements before enabling it.</p><p data-skill-error role="alert" hidden></p><button>Add to library</button></form></details>`
42
43
  : "";
43
44
  const history = view.history.length
44
- ? `<details><summary>Change history</summary>${view.history.map((h) => `<div class="card"><p>Selection ${h.revision} · ${e(h.actor)} · ${e(h.at.slice(0, 16).replace("T", " "))} UTC</p><ul>${h.enabled.length ? h.enabled.map((s) => `<li>${e(s.name)} · ${e(s.version)}</li>`).join("") : "<li>No skills enabled</li>"}</ul>${manage && h.revision !== view.revision ? form("restore", "<button>Restore selection</button>", { restore: h.revision }) : '<span class="meta">Current selection</span>'}</div>`).join("")}</details>`
45
+ ? `<details><summary>Change history</summary>${view.history.map((h) => `<div class="card"><p>Selection ${h.revision} · ${e(h.actor)} · ${whenUtc(h.at)}</p><ul>${h.enabled.length ? h.enabled.map((s) => `<li>${e(s.name)} · ${e(s.version)}</li>`).join("") : "<li>No skills enabled</li>"}</ul>${manage && h.revision !== view.revision ? form("restore", "<button>Restore selection</button>", { restore: h.revision }) : '<span class="meta">Current selection</span>'}</div>`).join("")}</details>`
45
46
  : "";
46
47
  return `<section class="skills">${options.error ? `<p class="problem" role="alert">${e(options.error)}</p>` : ""}<p>${active ? `${active} skill${active === 1 ? "" : "s"} enabled for new tasks.` : "No skills enabled for this project."} Active tasks keep their saved versions.</p>${add}${search}${cards || "<p>Your library is empty. Add a skill to review and enable it here.</p>"}${history}</section>`;
47
48
  }
package/dist/store.js CHANGED
@@ -3770,6 +3770,8 @@ function initializeStore(db, file) {
3770
3770
  addColumn(db, "publication_grant", "publish_on", "TEXT NOT NULL DEFAULT 'build' CHECK (publish_on IN ('build', 'complete'))");
3771
3771
  addColumn(db, "publication_grant", "merge_when_green", "INTEGER NOT NULL DEFAULT 0 CHECK (merge_when_green IN (0, 1))");
3772
3772
  db.exec(PULL_REQUEST_SCHEMA);
3773
+ // A pull request a flow's Pull request zone opened: the zone follows its CI, so Complete's follower leaves it alone.
3774
+ addColumn(db, "pull_request_follow", "flow_card", "INTEGER REFERENCES flow_card(id)");
3773
3775
  addColumn(db, "flow_card", "source_json", "TEXT");
3774
3776
  addColumn(db, "flow_card", "owner", "TEXT");
3775
3777
  addColumn(db, "flow", "owner", "TEXT");
package/dist/surface.js CHANGED
@@ -351,6 +351,6 @@ export const COMMAND_GUIDE = [
351
351
  operator("serve", "the operations console (HTTP)"),
352
352
  operator("watch", "the unattended loop, kept running"),
353
353
  operator("up", "console + worker + browser, one command"),
354
- operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), and print the handoff"),
354
+ operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), offer the starter flows (--starter <ids>), and print the handoff"),
355
355
  operator("daemon", "install the loop under the OS service manager"),
356
356
  ];
@@ -0,0 +1,8 @@
1
+ /** "16:39" today, "Yesterday 16:39" / "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year). */
2
+ export declare function shortWhen(iso: string, now?: Date): string;
3
+ /** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
4
+ export declare function whenHtml(iso: string | null, full: string, now?: Date): string;
5
+ /** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
6
+ export declare const utcMinute: (iso: string) => string;
7
+ /** Both the desk and phone forms of a UTC stamp. */
8
+ export declare const whenUtc: (iso: string | null, now?: Date) => string;