toolroll 0.9.4 → 0.9.6

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.
@@ -8,6 +8,7 @@ import type { Store } from './store.js';
8
8
  import type { WorkSummaryAccess } from './work-summary.js';
9
9
  import type { StatusTone } from './workspace-ui.js';
10
10
  import type { AssignmentCard } from './assignment-ui.js';
11
+ import type { TaskStatus } from './task-status.js';
11
12
  import type { FirstRunStep, FirstTaskSuggestion } from './first-run.js';
12
13
  export type BrowserProject = {
13
14
  name: string;
@@ -365,12 +366,6 @@ export type BrowserProjectsView = {
365
366
  };
366
367
  };
367
368
  export type BrowserResultTab = 'summary' | 'changes' | 'checks';
368
- export type BrowserResultChip = {
369
- tone: 'success' | 'danger' | 'warning' | 'info' | 'neutral';
370
- label: string;
371
- icon: 'check' | 'x' | null;
372
- title: string | null;
373
- };
374
369
  /** The shared result panel in parts. Each tab's content, the feedback
375
370
  * section and the learning card are the server's own HTML; the page script
376
371
  * binds to the same data attributes and ids (tabs, drafts, line notes). */
@@ -378,12 +373,20 @@ export type BrowserResultPanel = {
378
373
  attributes: Record<string, string>;
379
374
  heading: string;
380
375
  outcome: string;
381
- verdict: {
382
- chips: BrowserResultChip[];
383
- by: string | null;
384
- } | null;
376
+ /** The shared task status (task-status.ts): headline, sentence and detail rows. */
377
+ status: TaskStatus | null;
385
378
  reviewHistory: string | null;
386
379
  attention: string[];
380
+ /** Requirements only a person can confirm, in plain words, and the one
381
+ * Accept that records the decision (null when it is not offered here). */
382
+ youCheck: {
383
+ lines: string[];
384
+ accept: {
385
+ action: string;
386
+ run: number;
387
+ returnTo: string;
388
+ } | null;
389
+ } | null;
387
390
  /** Storage limits on saved output (shortened logs or diffs): shown on request. */
388
391
  limits: string[];
389
392
  tabs: {
@@ -6,7 +6,7 @@ export function acceptanceStatus(proof, accepted) {
6
6
  if (accepted)
7
7
  return manualReviewOnly(proof) ? "Accepted by a person" : "Accepted with an exception";
8
8
  if (manualReviewOnly(proof))
9
- return "Ready to inspect";
9
+ return "You check the remaining requirements";
10
10
  return proof === null ? "No checks recorded" : ({ verified: "Checks passed", attested: "Checks reported by the agent", short: "Some required material is missing", refuted: "Saved result conflicts with the approved scope" }[proof.verdict] ?? "Some required material is missing");
11
11
  }
12
12
  export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0) {
@@ -75,7 +75,7 @@ export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0
75
75
  accepted: acceptance !== null, acceptance: acceptance === null ? null : { at: acceptance.acceptedAt, note: brief(acceptance.note) },
76
76
  reasons: (recorded?.reasons ?? []).map(one => brief(one)), gate,
77
77
  criteriaTotal: matrix.length, nextCriterionOffset: offset + page.length < matrix.length ? offset + page.length : null,
78
- criteria: page.map(row => ({ id: row.id, requirement: brief(row.statement), state: row.state === "manual-review" ? "Human review required" : row.state,
78
+ criteria: page.map(row => ({ id: row.id, requirement: brief(row.statement), state: row.state === "manual-review" ? "You check" : row.state,
79
79
  details: row.detail.map(one => brief(one)), requiredEvidence: row.requiredEvidence,
80
80
  reviewer: row.review == null ? null : { judgement: row.review.judgement, note: brief(row.review.note) },
81
81
  evidence: row.answered.map(one => ({ kind: one.kind, reference: brief(one.ref) })),
@@ -7,6 +7,7 @@
7
7
  import { assignmentOf } from "./assignment.js";
8
8
  import { chatControlHref } from "./chat-controls.js";
9
9
  import { telegramProgressCard } from "./telegram-progress.js";
10
+ import { assignmentStatusFacts, headlineEmoji, taskStatusOf } from "./task-status.js";
10
11
  import { phoneText, projectLabel } from "./telegram-status.js";
11
12
  import { isLifecycleNotification } from "./store.js";
12
13
  /** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
@@ -37,15 +38,16 @@ function cardRun(store, taskRef) {
37
38
  return store.runsFor(taskRef).find(run => run.role === "builder" && run.contestant === null) ?? null;
38
39
  }
39
40
  /** Before any attempt starts, the card says where the task stands in the words of its last lifecycle fact. */
41
+ const before = (headline) => ({ icon: headlineEmoji(headline), status: headline });
40
42
  const BEFORE_RUN = {
41
- "task-filed": { icon: "📝", status: "Filed" },
42
- "scope-approved": { icon: "✓", status: "Approved · Waiting to start" },
43
- "approval-withdrawn": { icon: "📝", status: "Approval withdrawn" },
44
- "task-held": { icon: "⏸", status: "Paused" },
45
- "task-released": { icon: "✓", status: "Waiting to start" },
46
- "task-queued": { icon: "✓", status: "Waiting to start" },
47
- "task-requeued": { icon: "✓", status: "Waiting to start" },
48
- "task-cancelled": { icon: "⏹", status: "Cancelled" },
43
+ "task-filed": before("Queued"),
44
+ "scope-approved": before("Queued"),
45
+ "approval-withdrawn": before("Needs you"),
46
+ "task-held": before("Stopped"),
47
+ "task-released": before("Queued"),
48
+ "task-queued": before("Queued"),
49
+ "task-requeued": before("Queued"),
50
+ "task-cancelled": before("Stopped"),
49
51
  };
50
52
  function taskLine(store, taskRef, now, root) {
51
53
  const ref = store.refById(taskRef);
@@ -85,10 +87,8 @@ export function quietCardView(store, taskRefs, now, root) {
85
87
  export function readyPingView(store, run, taskId, project, now, root) {
86
88
  const card = telegramProgressCard(store, run, taskId, project, now, root);
87
89
  const [title = "", shown = ""] = card.entities.map(one => card.text.slice(one.offset, one.offset + one.length));
88
- // A saved result is Ready even in the moment before its worker lets go of the task.
89
- const saved = shown.endsWith(" Result saved");
90
- const heading = saved ? "✅ Ready" : shown;
91
- const next = card.next !== "" ? card.next : saved ? "Next: open the result, then mark it complete or request changes." : "";
90
+ const heading = shown;
91
+ const next = card.next;
92
92
  const first = `${heading} · ${title}`;
93
93
  return { text: next === "" ? first : `${first}\n${next}`, entities: [{ type: "bold", offset: 0, length: heading.length }], link: card.link };
94
94
  }
@@ -120,18 +120,19 @@ export function eveningDigestText(store, account, now, root) {
120
120
  failed.set(row.taskId, title(row.taskId));
121
121
  }
122
122
  for (const taskId of new Set(facts.map(row => row.taskId).filter((one) => one !== null))) {
123
- let state = null;
123
+ let headline = null;
124
124
  try {
125
125
  const project = facts.find(row => row.taskId === taskId).project;
126
- state = assignmentOf(store, taskId, now, { principal: "operator", repos: [project] }, root)?.state ?? null;
126
+ const assignment = assignmentOf(store, taskId, now, { principal: "operator", repos: [project] }, root);
127
+ headline = assignment === null ? null : taskStatusOf(assignmentStatusFacts(assignment)).headline;
127
128
  }
128
129
  catch {
129
- state = null;
130
+ headline = null;
130
131
  }
131
- if (state === "ready-to-check")
132
- waits.set(taskId, `${title(taskId)} · Ready for review`);
133
- else if (state === "needs-decision")
134
- waits.set(taskId, `${title(taskId)} · Needs your decision`);
132
+ if (headline === "Ready for review" || headline === "Needs you")
133
+ waits.set(taskId, `${title(taskId)} · ${headline}`);
134
+ else if (headline === "Failed")
135
+ failed.set(taskId, title(taskId));
135
136
  }
136
137
  if (finished.size + failed.size + waits.size === 0)
137
138
  return null;
package/dist/cli.js CHANGED
@@ -1225,7 +1225,7 @@ async function runDemoCommand(argv, write) {
1225
1225
  const DEMO_USAGE = "`toolroll demo [--port <n>] [--host <addr>] [--allow-host name:port,…] [--keep]` — the port is a number under 65536; absent, a free one is picked";
1226
1226
  // Asking how it works never starts a sandbox.
1227
1227
  if (argv.includes("--help") || argv.includes("-h")) {
1228
- write(json ? envelopeJson({ ok: true, command: "demo", usage: DEMO_USAGE }) : `${DEMO_USAGE}\n\nA throwaway sandbox with a seeded fleet and flows mid-flight. It never spends and never reaches outside; Ctrl-C removes it unless --keep.`);
1228
+ write(json ? envelopeJson({ ok: true, command: "demo", usage: DEMO_USAGE }) : `${DEMO_USAGE}\n\nA throwaway sandbox: a scripted lead in Chat, sample projects and flows mid-flight. It never calls a model, spends or reaches outside; Ctrl-C removes it unless --keep.`);
1229
1229
  return 0;
1230
1230
  }
1231
1231
  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
@@ -1248,9 +1248,9 @@ async function runDemoCommand(argv, write) {
1248
1248
  const { createDemoSandbox } = await import("./demo.js");
1249
1249
  const { createDecisionServer } = await import("./serve.js");
1250
1250
  const now = new Date();
1251
- const { sandbox, store, seed, evidenceRoot, passwordFile } = createDemoSandbox(now);
1251
+ const { sandbox, store, seed, evidenceRoot, passwordFile, lead } = createDemoSandbox(now);
1252
1252
  const server = createDecisionServer({
1253
- store, evidenceRoot, clock: () => new Date(), repos: seed.repos,
1253
+ store, evidenceRoot, clock: () => new Date(), repos: seed.repos, demoLead: lead,
1254
1254
  ...(allowedHosts.length === 0 ? {} : { allowedHosts }),
1255
1255
  });
1256
1256
  await new Promise((ready, failed) => {
@@ -1268,14 +1268,13 @@ async function runDemoCommand(argv, write) {
1268
1268
  write(envelopeJson({ ok: true, command: "demo", url, sandbox, login: { name: seed.login.name, passwordFile }, keep }));
1269
1269
  }
1270
1270
  else {
1271
- write("Demo sandbox is up — seeded fleet, zero spend, zero remotes.");
1271
+ write("Demo sandbox is up. A scripted lead answers Chat: no model, no key, no spend, nothing leaves this machine.");
1272
1272
  write("");
1273
1273
  write(` open ${url}`);
1274
1274
  write(` login ${seed.login.name} / ${seed.login.password}`);
1275
1275
  write(` sandbox ${sandbox}`);
1276
1276
  write("");
1277
- write("Two projects are seeded. The inbox works project-free; the board and");
1278
- write("routines ask you to open one first — pick payments-api.");
1277
+ write("Open Chat and ask for a change, like \"fix the flaky refund test\".");
1279
1278
  write("");
1280
1279
  write(keep
1281
1280
  ? "Ctrl-C stops the server; the sandbox is KEPT. It stays a demo forever — every spending command refuses it."
@@ -1290,6 +1289,7 @@ async function runDemoCommand(argv, write) {
1290
1289
  process.on("SIGINT", stop);
1291
1290
  process.on("SIGTERM", stop);
1292
1291
  });
1292
+ lead.stop();
1293
1293
  await new Promise(closed => server.close(() => closed()));
1294
1294
  store.close();
1295
1295
  if (!keep) {
@@ -103,6 +103,11 @@ export type EffectiveContainment = {
103
103
  export declare function effectiveContainment(policy: ContainmentPolicy, capability: ContainmentCapability): EffectiveContainment;
104
104
  /** One-line status words for `daemon status`, `up`, and the certificate. */
105
105
  export declare function describeContainment(effective: EffectiveContainment): string;
106
+ /** The one sentence a person reads at start when agents on Linux run
107
+ * without native containment that the machine could offer once set up;
108
+ * null when there is nothing a person needs to read. The exact
109
+ * `describeContainment` line stays in --verbose and in a service log. */
110
+ export declare function containmentNotice(effective: EffectiveContainment): string | null;
106
111
  /** The status envelope's shape: no paths a person did not already know, no secrets. */
107
112
  export declare function containmentStatus(effective: EffectiveContainment): Record<string, unknown>;
108
113
  /**
@@ -191,6 +191,15 @@ export function describeContainment(effective) {
191
191
  return `containment: observed (preferred native, ${backend} unavailable) — ${effective.capability.detail}`;
192
192
  return `containment: observed (${effective.policy}) — ${effective.capability.detail}`;
193
193
  }
194
+ /** The one sentence a person reads at start when agents on Linux run
195
+ * without native containment that the machine could offer once set up;
196
+ * null when there is nothing a person needs to read. The exact
197
+ * `describeContainment` line stays in --verbose and in a service log. */
198
+ export function containmentNotice(effective) {
199
+ if (effective.refusal !== null || effective.mode !== "observed" || effective.capability.platform !== "linux" || effective.capability.available)
200
+ return null;
201
+ return "Agents run without Linux process containment on this machine (it needs a delegated cgroup). That's fine for trying Toolroll; see docs/guide/linux.md to turn it on.";
202
+ }
194
203
  /** The status envelope's shape: no paths a person did not already know, no secrets. */
195
204
  export function containmentStatus(effective) {
196
205
  return {
@@ -0,0 +1,32 @@
1
+ import type { DemoExchange } from "./demo.js";
2
+ /** What a finished exchange's stored result holds, read back from its evidence. */
3
+ export type DemoResultView = {
4
+ diff: string | null;
5
+ checkLog: string | null;
6
+ checks: {
7
+ status: string;
8
+ detail: string;
9
+ };
10
+ screenshot: {
11
+ href: string;
12
+ caption: string;
13
+ } | null;
14
+ additions: number;
15
+ deletions: number;
16
+ files: number;
17
+ taskHref: string;
18
+ };
19
+ /** The conversation itself; the live script swaps this region in place. */
20
+ export declare function demoThreadHtml(exchanges: readonly DemoExchange[], csrf: string, resultOf: (exchange: DemoExchange) => DemoResultView | null): string;
21
+ export declare function demoChatHtml(input: {
22
+ exchanges: readonly DemoExchange[];
23
+ csrf: string;
24
+ version: number;
25
+ problem: string | null;
26
+ resultOf: (exchange: DemoExchange) => DemoResultView | null;
27
+ }): string;
28
+ /**
29
+ * Keeps a running build live: polls while one is working and swaps the
30
+ * thread in place, unless the visitor is typing inside it. Enter sends.
31
+ */
32
+ export declare const DEMO_CHAT_SCRIPT = "(() => {\n const thread = () => document.getElementById(\"demo-thread\");\n let timer = 0;\n const editing = region => region.contains(document.activeElement) && document.activeElement.matches(\"textarea, input\") || region.querySelector(\"details[open] textarea:not(:placeholder-shown)\") !== null;\n const tick = async () => {\n const region = thread();\n if (!region) { timer = setTimeout(tick, 400); return; }\n if (region.dataset.working !== \"1\") return;\n try {\n const answer = await fetch(\"/chat/demo/live\", { headers: { accept: \"application/json\" }, credentials: \"same-origin\" });\n if (answer.ok) {\n const next = await answer.json();\n if (String(next.version) !== region.dataset.version && !editing(region)) {\n region.innerHTML = next.html;\n region.dataset.version = String(next.version);\n region.dataset.working = next.working ? \"1\" : \"0\";\n if (!next.working) {\n const last = region.querySelector(\".demo-turn:last-of-type .demo-result\");\n if (last) last.scrollIntoView({ block: \"nearest\", behavior: matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\" });\n }\n }\n }\n } catch { /* the next tick tries again */ }\n if (thread()?.dataset.working === \"1\") timer = setTimeout(tick, 700);\n };\n const start = () => { clearTimeout(timer); tick(); };\n document.addEventListener(\"keydown\", event => {\n const field = event.target;\n if (event.key !== \"Enter\" || event.shiftKey || event.isComposing || !(field instanceof HTMLTextAreaElement) || field.id !== \"demo-message\") return;\n if (field.value.trim() === \"\") return;\n event.preventDefault();\n field.form?.requestSubmit();\n });\n window.addEventListener(\"standing-orders:workspace-rendered\", start);\n if (document.readyState === \"loading\") document.addEventListener(\"DOMContentLoaded\", start); else start();\n})();";
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Chat in `toolroll demo`: the scripted lead's conversation (src/demo.ts),
3
+ * rendered as plain server HTML with native forms. One small script keeps a
4
+ * running build's progress live; without it the page still works by reload.
5
+ */
6
+ import { headlineOf } from "./task-status.js";
7
+ const SUGGESTIONS = ["Fix the flaky refund test", "Rewrite the empty Payouts page", "Put the new invoice view behind a flag"];
8
+ const esc = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
9
+ const hidden = (csrf) => `<input type="hidden" name="csrf" value="${esc(csrf)}">`;
10
+ function hint(csrf) {
11
+ return `<section class="demo-hint" aria-label="Get started">` +
12
+ `<p class="demo-hint-title">Ask for something, like “fix the flaky refund test”</p>` +
13
+ `<form method="post" action="/chat/demo/ask" class="demo-suggestions">${hidden(csrf)}` +
14
+ SUGGESTIONS.map(one => `<button type="submit" name="message" value="${esc(one)}">${esc(one)}</button>`).join("") +
15
+ `</form></section>`;
16
+ }
17
+ function planCard(exchange, csrf) {
18
+ const plan = exchange.plan;
19
+ const goal = exchange.note === null ? plan.goal : `${plan.goal} Also: ${exchange.note}`;
20
+ const actions = exchange.state === "proposed"
21
+ ? `<div class="demo-actions">` +
22
+ `<form method="post" action="/chat/demo/${exchange.id}/approve" class="approve-form">${hidden(csrf)}<button type="submit">Approve</button></form>` +
23
+ `<details class="demo-more"><summary>Change it</summary>` +
24
+ `<form method="post" action="/chat/demo/${exchange.id}/change">${hidden(csrf)}` +
25
+ `<label for="demo-change-${exchange.id}">What should change?</label>` +
26
+ `<textarea id="demo-change-${exchange.id}" name="note" rows="2" maxlength="500" required></textarea>` +
27
+ `<button type="submit">Update plan</button></form></details></div>`
28
+ : exchange.state === "replaced"
29
+ ? `<p class="meta">Replaced by the updated plan below.</p>`
30
+ : `<p class="meta">Approved.</p>`;
31
+ return `<section class="card demo-plan" aria-label="Plan">` +
32
+ `<p class="demo-kicker">Plan · ${esc(plan.project)}</p>` +
33
+ `<h2>${esc(plan.title)}</h2>` +
34
+ `<p>${esc(goal)}</p>` +
35
+ `<h3>Boundaries</h3><ul>${plan.boundaries.map(one => `<li>${esc(one)}</li>`).join("")}</ul>` +
36
+ `<h3>Checks</h3><ul>${plan.checks.map(one => `<li>${esc(one)}</li>`).join("")}</ul>` +
37
+ actions + `</section>`;
38
+ }
39
+ function buildCard(exchange) {
40
+ const stages = [["planning", "Planning"], ["building", "Building"], ["checking", "Running checks"]];
41
+ const at = stages.findIndex(([key]) => key === exchange.stage);
42
+ return `<section class="card demo-build" id="demo-${exchange.id}-work" aria-label="Build" aria-busy="true">` +
43
+ `<p class="demo-kicker">${esc(exchange.plan.project)}</p>` +
44
+ `<h2>${esc(stages[Math.max(0, at)][1])}…</h2>` +
45
+ `<ol class="demo-steps">${stages.map(([, label], index) => `<li class="${index < at ? "done" : index === at ? "now" : ""}"${index === at ? ` aria-current="step"` : ""}>${esc(label)}</li>`).join("")}</ol>` +
46
+ (exchange.progress === null ? "" : `<p class="meta demo-progress">${esc(exchange.progress)}</p>`) +
47
+ `</section>`;
48
+ }
49
+ function diffHtml(diff) {
50
+ return diff.replace(/\n$/, "").split("\n").map(line => {
51
+ const kind = line.startsWith("diff --git") || line.startsWith("new file") ? "file"
52
+ : line.startsWith("+++") || line.startsWith("---") ? "meta"
53
+ : line.startsWith("@@") ? "hunk"
54
+ : line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx";
55
+ return `<span class="demo-diff-${kind}">${esc(line) || " "}</span>`;
56
+ }).join("");
57
+ }
58
+ function resultCard(exchange, csrf, result) {
59
+ if (result === null)
60
+ return `<section class="card demo-result"><p class="meta">This result's saved evidence is unavailable.</p></section>`;
61
+ const passed = result.checks.status === "passed";
62
+ // The shared headline (task-status.ts): the same words as every real task.
63
+ const headline = headlineOf({ stage: exchange.state === "complete" ? "complete" : exchange.state === "sent-back" ? "building" : "finished",
64
+ checks: { status: passed ? "passed" : "failed", exitCode: null, head: null } });
65
+ const status = `<span class="badge${headline === "Complete" ? " demo-complete" : headline === "Ready for review" ? " demo-ready" : ""}" data-headline="${headline}">${headline}</span>`;
66
+ const summary = `${result.files} file${result.files === 1 ? "" : "s"} changed · +${result.additions} −${result.deletions} · ` +
67
+ `<span class="${passed ? "demo-pass" : "demo-fail"}">${esc(result.checks.detail)}</span>`;
68
+ const actions = exchange.state === "ready"
69
+ ? `<div class="demo-actions">` +
70
+ `<form method="post" action="/chat/demo/${exchange.id}/complete">${hidden(csrf)}<button type="submit" class="demo-primary">Complete</button></form>` +
71
+ `<details class="demo-more"><summary>Request changes</summary>` +
72
+ `<form method="post" action="/chat/demo/${exchange.id}/revise">${hidden(csrf)}` +
73
+ `<label for="demo-revise-${exchange.id}">What should change?</label>` +
74
+ `<textarea id="demo-revise-${exchange.id}" name="note" rows="2" maxlength="500" required></textarea>` +
75
+ `<button type="submit">Send back</button></form></details></div>`
76
+ : `<p class="meta"><a href="${esc(result.taskHref)}">Open in Tasks</a></p>`;
77
+ return `<section class="card demo-result" id="demo-${exchange.id}-work" aria-label="Result">` +
78
+ `<p class="demo-state">${status}<span class="meta">${esc(exchange.plan.project)}</span></p>` +
79
+ `<h2>${esc(exchange.plan.title)}</h2>` +
80
+ `<p>${esc(exchange.plan.conclusion)}</p>` +
81
+ `<p class="meta">${summary}</p>` +
82
+ actions +
83
+ (result.diff === null ? "" : `<details class="demo-evidence"${exchange.state === "ready" ? " open" : ""}><summary>Changes</summary><pre class="demo-diff">${diffHtml(result.diff)}</pre></details>`) +
84
+ (result.checkLog === null ? "" : `<details class="demo-evidence"><summary>Check log</summary><pre class="demo-log">${esc(result.checkLog)}</pre></details>`) +
85
+ (result.screenshot === null ? "" : `<details class="demo-evidence"><summary>Screenshot</summary><figure><img src="${esc(result.screenshot.href)}" alt="${esc(result.screenshot.caption)}" width="960" height="600"><figcaption class="meta">${esc(result.screenshot.caption)}</figcaption></figure></details>`) +
86
+ `</section>`;
87
+ }
88
+ /** The conversation itself; the live script swaps this region in place. */
89
+ export function demoThreadHtml(exchanges, csrf, resultOf) {
90
+ if (exchanges.length === 0)
91
+ return hint(csrf);
92
+ return exchanges.map(exchange => {
93
+ const parts = [
94
+ `<div class="demo-said demo-you"><p>${esc(exchange.asked)}</p></div>`,
95
+ `<div class="demo-said demo-lead"><p class="demo-who">Lead</p><p>${esc(exchange.reply)}</p></div>`,
96
+ planCard(exchange, csrf),
97
+ ];
98
+ if (exchange.state === "working")
99
+ parts.push(buildCard(exchange));
100
+ if (exchange.state === "ready" || exchange.state === "complete" || exchange.state === "sent-back")
101
+ parts.push(resultCard(exchange, csrf, resultOf(exchange)));
102
+ if (exchange.state === "complete") {
103
+ parts.push(`<div class="demo-said demo-lead"><p class="demo-who">Lead</p><p>Done. That's the whole loop: ask, approve, Ready, Complete. Ask for something else whenever you like.</p></div>`);
104
+ }
105
+ return `<article class="demo-turn" id="demo-${exchange.id}">${parts.join("")}</article>`;
106
+ }).join("");
107
+ }
108
+ export function demoChatHtml(input) {
109
+ const working = input.exchanges.some(one => one.state === "working");
110
+ return `<div class="demo-chat">` +
111
+ (input.problem === null ? "" : `<p class="problem" role="alert">${esc(input.problem)}</p>`) +
112
+ `<div id="demo-thread" class="demo-thread" aria-live="polite" data-version="${input.version}" data-working="${working ? "1" : "0"}">` +
113
+ demoThreadHtml(input.exchanges, input.csrf, input.resultOf) + `</div>` +
114
+ `<form method="post" action="/chat/demo/ask" class="demo-composer">${hidden(input.csrf)}` +
115
+ `<label for="demo-message" class="so-sr-only">Message the lead</label>` +
116
+ `<textarea id="demo-message" name="message" rows="2" maxlength="500" placeholder="Ask the lead for a change" required></textarea>` +
117
+ `<button type="submit" class="demo-primary">Send</button></form></div>`;
118
+ }
119
+ /**
120
+ * Keeps a running build live: polls while one is working and swaps the
121
+ * thread in place, unless the visitor is typing inside it. Enter sends.
122
+ */
123
+ export const DEMO_CHAT_SCRIPT = `(() => {
124
+ const thread = () => document.getElementById("demo-thread");
125
+ let timer = 0;
126
+ const editing = region => region.contains(document.activeElement) && document.activeElement.matches("textarea, input") || region.querySelector("details[open] textarea:not(:placeholder-shown)") !== null;
127
+ const tick = async () => {
128
+ const region = thread();
129
+ if (!region) { timer = setTimeout(tick, 400); return; }
130
+ if (region.dataset.working !== "1") return;
131
+ try {
132
+ const answer = await fetch("/chat/demo/live", { headers: { accept: "application/json" }, credentials: "same-origin" });
133
+ if (answer.ok) {
134
+ const next = await answer.json();
135
+ if (String(next.version) !== region.dataset.version && !editing(region)) {
136
+ region.innerHTML = next.html;
137
+ region.dataset.version = String(next.version);
138
+ region.dataset.working = next.working ? "1" : "0";
139
+ if (!next.working) {
140
+ const last = region.querySelector(".demo-turn:last-of-type .demo-result");
141
+ if (last) last.scrollIntoView({ block: "nearest", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
142
+ }
143
+ }
144
+ }
145
+ } catch { /* the next tick tries again */ }
146
+ if (thread()?.dataset.working === "1") timer = setTimeout(tick, 700);
147
+ };
148
+ const start = () => { clearTimeout(timer); tick(); };
149
+ document.addEventListener("keydown", event => {
150
+ const field = event.target;
151
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing || !(field instanceof HTMLTextAreaElement) || field.id !== "demo-message") return;
152
+ if (field.value.trim() === "") return;
153
+ event.preventDefault();
154
+ field.form?.requestSubmit();
155
+ });
156
+ window.addEventListener("standing-orders:workspace-rendered", start);
157
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();
158
+ })();`;
package/dist/demo.d.ts CHANGED
@@ -52,4 +52,102 @@ export declare function createDemoSandbox(now: Date): {
52
52
  seed: DemoSeed;
53
53
  evidenceRoot: string;
54
54
  passwordFile: string;
55
+ lead: DemoLead;
55
56
  };
57
+ type DemoProject = "payments-api" | "web-console";
58
+ type DemoFile = {
59
+ path: string;
60
+ before: string | null;
61
+ after: string;
62
+ };
63
+ export type DemoPlan = {
64
+ kind: "flaky-test" | "bug" | "copy" | "flag" | "default";
65
+ project: DemoProject;
66
+ title: string;
67
+ goal: string;
68
+ boundaries: string[];
69
+ checks: string[];
70
+ /** What the builder reports while it works, one line per beat. */
71
+ progress: string[];
72
+ conclusion: string;
73
+ checkOutput: string;
74
+ screenshot: {
75
+ caption: string;
76
+ accent: readonly [number, number, number];
77
+ };
78
+ files: DemoFile[];
79
+ };
80
+ /** The scripted lead's choice: a plan named by the request's words, else the default. */
81
+ export declare function pickDemoPlan(text: string): DemoPlan;
82
+ export type DemoExchange = {
83
+ id: number;
84
+ /** What the visitor typed, or the note that asked for this plan. */
85
+ asked: string;
86
+ reply: string;
87
+ plan: DemoPlan;
88
+ /** A visitor's extra instruction carried into the plan by Change it or Request changes. */
89
+ note: string | null;
90
+ state: "proposed" | "replaced" | "working" | "ready" | "complete" | "sent-back";
91
+ stage: "planning" | "building" | "checking" | null;
92
+ progress: string | null;
93
+ taskId: string | null;
94
+ runId: number | null;
95
+ approvedAt: number | null;
96
+ };
97
+ export type DemoLead = {
98
+ exchanges(): readonly DemoExchange[];
99
+ /** Changes whenever anything a reader would see changes. */
100
+ version(): number;
101
+ ask(text: string, now: Date): DemoExchange;
102
+ approve(id: number, now: Date): {
103
+ ok: true;
104
+ } | {
105
+ ok: false;
106
+ message: string;
107
+ };
108
+ change(id: number, note: string, now: Date): {
109
+ ok: true;
110
+ } | {
111
+ ok: false;
112
+ message: string;
113
+ };
114
+ requestChanges(id: number, note: string, now: Date): {
115
+ ok: true;
116
+ } | {
117
+ ok: false;
118
+ message: string;
119
+ };
120
+ /** Complete through the real completion act, then mark the exchange. */
121
+ complete(id: number, now: Date, act: (taskId: string, runId: number) => {
122
+ ok: true;
123
+ } | {
124
+ ok: false;
125
+ message: string;
126
+ }): {
127
+ ok: true;
128
+ } | {
129
+ ok: false;
130
+ message: string;
131
+ };
132
+ advance(now: Date): void;
133
+ stop(): void;
134
+ };
135
+ /**
136
+ * The scripted lead. `stepMs` is one beat: planning takes one, building two
137
+ * (one per progress line), checks one, so a build lands Ready in four beats.
138
+ * State advances on a timer AND whenever someone reads, so a page and the
139
+ * Tasks list agree without anyone polling.
140
+ */
141
+ export declare function createDemoLead(input: {
142
+ store: Store;
143
+ repos: {
144
+ api: string;
145
+ web: string;
146
+ };
147
+ evidenceRoot: string;
148
+ approver: string;
149
+ token: string;
150
+ stepMs?: number;
151
+ clock?: () => Date;
152
+ }): DemoLead;
153
+ export {};