toolroll 0.9.101 → 0.9.103

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 (42) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/browser/terminal.js +9 -9
  3. package/dist/browser/workspace.css +1 -1
  4. package/dist/browser/workspace.js +38 -38
  5. package/dist/browser-workspace.d.ts +51 -14
  6. package/dist/browser-workspace.js +5 -3
  7. package/dist/flow-engine.d.ts +3 -0
  8. package/dist/flow-engine.js +4 -1
  9. package/dist/flows-ui.d.ts +31 -1
  10. package/dist/flows-ui.js +40 -12
  11. package/dist/page-work-contract.d.ts +21 -0
  12. package/dist/page-work-contract.js +5 -0
  13. package/dist/project-knowledge.d.ts +9 -9
  14. package/dist/release-approval.d.ts +9 -20
  15. package/dist/release-approval.js +14 -53
  16. package/dist/release-refusal.d.ts +32 -0
  17. package/dist/release-refusal.js +73 -0
  18. package/dist/run-efficiency.d.ts +3 -2
  19. package/dist/run-efficiency.js +4 -3
  20. package/dist/scope.js +6 -0
  21. package/dist/serve.js +51 -9
  22. package/dist/server/chrome.d.ts +10 -1
  23. package/dist/server/chrome.js +9 -0
  24. package/dist/server/console.css +19 -4
  25. package/dist/server/home-asks.d.ts +45 -0
  26. package/dist/server/home-asks.js +108 -0
  27. package/dist/server/home.d.ts +37 -14
  28. package/dist/server/home.js +113 -63
  29. package/dist/server/http.d.ts +6 -0
  30. package/dist/server/http.js +8 -0
  31. package/dist/server/in-place.d.ts +15 -0
  32. package/dist/server/in-place.js +32 -0
  33. package/dist/server/pages.js +36 -3
  34. package/dist/server/render-tasks.js +14 -3
  35. package/dist/server/request-context.d.ts +14 -0
  36. package/dist/server/request-context.js +3 -0
  37. package/dist/server/task-views.js +6 -3
  38. package/dist/server/tasks.d.ts +8 -2
  39. package/dist/server/tasks.js +31 -17
  40. package/dist/work-index.d.ts +19 -1
  41. package/dist/work-index.js +40 -14
  42. package/package.json +1 -1
@@ -9,6 +9,7 @@ export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
9
9
  import type { Store } from './store.js';
10
10
  import type { WorkSummaryAccess } from './work-summary.js';
11
11
  import type { StatusTone, StepRow } from './workspace-ui.js';
12
+ import type { PageWork } from './page-work-contract.js';
12
13
  import type { Ask, AskChip } from './needs-you.js';
13
14
  import type { WorkIndexGroup } from './work-index.js';
14
15
  import type { AssignmentCard } from './assignment-ui.js';
@@ -486,9 +487,12 @@ export type BrowserTaskView = {
486
487
  }[];
487
488
  } | null;
488
489
  /** Server cards that may need a person now (stop/resume, scope prompt, plan, live attempt). */
490
+ /** `instance`: what the block is about now (the attempt card: its build). The page installs a block's HTML once;
491
+ * another instance replaces it. */
489
492
  lead: {
490
493
  key: string;
491
494
  html: string;
495
+ instance?: string;
492
496
  }[];
493
497
  questions: string;
494
498
  facts: BrowserTaskFact[];
@@ -549,6 +553,8 @@ export type BrowserTaskView = {
549
553
  /** When a remembered password stops counting (ISO); the page words it in the viewer's zone. */
550
554
  rememberedUntil: string | null;
551
555
  planHtml: string;
556
+ /** The plan sheet alone (no editor, no form): what Home shows beside its Approve (live-pages-and-home.md B2.1). */
557
+ sheetHtml?: string;
552
558
  /** The terms line in words, when the rule isn't one of the kit's (easy-start C5: a laptop's own owner). */
553
559
  termsWords?: string;
554
560
  } | null;
@@ -1613,6 +1619,15 @@ export type BrowserWorkspace = {
1613
1619
  user: string;
1614
1620
  csrf: string;
1615
1621
  sensitive: boolean;
1622
+ /** True when this page may replace another without a document load (docs/plans/live-pages-and-home.md A1.1): the server
1623
+ * sets it on every workspace it sends; absent (an older server, a fixture) is false. */
1624
+ inPlace?: boolean;
1625
+ /** What the app runs for this page in place of a page script (docs/plans/task-page-in-app.md): data, a closed list.
1626
+ * Present only when the app covers the page's whole script; the document then ships no page script string. */
1627
+ work?: readonly PageWork[];
1628
+ /** This document ships a page script string (set on a document load only). Its listeners live as long as the
1629
+ * document, so the app never changes pages in place from it, and never runs `work` in it. */
1630
+ scripted?: boolean;
1616
1631
  /** Passwordless is on for this signed-in browser: ceremony password fields are left out (docs/plans/passwordless.md). */
1617
1632
  passwordless?: boolean;
1618
1633
  /** The session's open project (its path), or null: the sidebar's project picker names it. */
@@ -1688,13 +1703,14 @@ export type BrowserWorkspace = {
1688
1703
  command: string;
1689
1704
  };
1690
1705
  };
1691
- /** Home ("/"): one sentence that answers "what needs me?", four counts that agree with the Tasks filters they open,
1692
- * the work grouped as Waiting on you, Working and Done, and the selected item answered in place. */
1706
+ /** Home ("/", docs/plans/live-pages-and-home.md B1): the heading said once, one quiet line for what runs and waits, what
1707
+ * needs this person grouped by what is asked (each group with its exact count), the selected one open beside the list,
1708
+ * and the lead's last message above the message box. With nothing waiting: what runs and what finished today. */
1693
1709
  export type BrowserHomeView = {
1694
1710
  kind: 'home';
1695
- /** "7 need you across 2 projects.", derived from the same counts as the figures. */
1711
+ /** "19 tasks need you", "Nothing needs you", read from the same total as the rail's count. */
1696
1712
  say: string;
1697
- /** The project in view (a path) or every project (null), and the scope links (null with one project). */
1713
+ /** The project in view (a path) or every project (null), and the project filter's choices (null with one project). */
1698
1714
  project: string | null;
1699
1715
  scopes: {
1700
1716
  key: string;
@@ -1702,29 +1718,43 @@ export type BrowserHomeView = {
1702
1718
  href: string;
1703
1719
  active: boolean;
1704
1720
  }[] | null;
1721
+ /** The work index totals, each with the Tasks list it opens. */
1705
1722
  counts: {
1706
1723
  key: 'needs' | 'running' | 'queued' | 'finished';
1707
1724
  label: string;
1708
1725
  value: number;
1709
1726
  href: string;
1710
1727
  }[];
1711
- /** The work in three groups, in this order (home-groups.ts): Waiting on you, Working, Done (finished in the last 7 days). */
1728
+ /** What needs you, by what is asked, in this order: release, approve, answer, flow, wrong, review, setup, other
1729
+ * (anything none of the others fits; docs/plans/home-other-asks.md). `count` is the exact total for that ask, past the
1730
+ * rows listed. */
1712
1731
  groups: {
1713
- key: 'you' | 'working' | 'done';
1732
+ key: BrowserHomeAsk;
1714
1733
  label: string;
1734
+ count: number;
1715
1735
  rows: BrowserHomeRow[];
1716
1736
  }[];
1717
- /** More waits on you than the list shows: open them all in Tasks. */
1718
- more: {
1737
+ /** What runs, then what waits its turn. */
1738
+ working: BrowserHomeRow[];
1739
+ /** What was completed today: the exact figure, the newest rows, and the list that has them all. */
1740
+ finishedToday: {
1719
1741
  count: number;
1742
+ rows: BrowserHomeRow[];
1720
1743
  href: string;
1721
- } | null;
1744
+ };
1722
1745
  newTask: string;
1723
1746
  /** The lead's message box, where chat is allowed for this person: a POST to /chat in this scope. */
1724
1747
  ask: {
1725
1748
  project: string | null;
1726
1749
  placeholder: string;
1727
1750
  } | null;
1751
+ /** The lead's last message in this person's main thread, as one plain line that opens the chat; null when there is
1752
+ * none or this person may not read that thread. */
1753
+ lastLead: {
1754
+ text: string;
1755
+ age: string;
1756
+ href: string;
1757
+ } | null;
1728
1758
  empty: {
1729
1759
  title: string;
1730
1760
  text: string;
@@ -1732,19 +1762,25 @@ export type BrowserHomeView = {
1732
1762
  /** While the first run is open (no first result yet): the welcome's address, linked as Finish setup. */
1733
1763
  setup?: string | null;
1734
1764
  };
1765
+ /** What a waiting row asks (work-index.ts WorkAskGroup), plus a flow card waiting on you and a first-run step. */
1766
+ export type BrowserHomeAsk = 'release' | 'approve' | 'answer' | 'flow' | 'wrong' | 'review' | 'setup' | 'other';
1735
1767
  export type BrowserHomeRow = {
1768
+ /** A task row's family id; a row that isn't a task is namespaced (`release:<task>`, `flow:<card>`, `setup:<step>`). */
1736
1769
  id: string;
1737
1770
  title: string;
1738
1771
  href: string;
1739
1772
  project: string | null;
1740
1773
  age: string;
1741
- chip: {
1742
- word: string;
1743
- tone: '' | 'needs' | 'ask' | 'live' | 'done' | 'failed';
1744
- };
1774
+ /** Which list it is in: it needs you, it runs, it waits its turn, or it finished. */
1775
+ state: 'needs' | 'running' | 'queued' | 'done';
1776
+ /** What it asks of you (its group), or null when it doesn't wait on you. */
1777
+ ask: BrowserHomeAsk | null;
1778
+ /** Why, in one short line: only a row where something went wrong. */
1779
+ reason: string | null;
1745
1780
  /** One line: what it asks, or what the agent is doing now. */
1746
1781
  line: string | null;
1747
- /** The one act that resolves it (a link), or null. */
1782
+ /** The one act that resolves it (a link), or null. A release, a flow card or a setup step is never acted on at
1783
+ * Home: its one link opens its own page. */
1748
1784
  action: {
1749
1785
  label: string;
1750
1786
  href: string;
@@ -1933,3 +1969,4 @@ export type MergeReadyView = {
1933
1969
  act: MergeReadyAct | null;
1934
1970
  }[];
1935
1971
  };
1972
+ export type { PageWork } from './page-work-contract.js';
@@ -97,11 +97,13 @@ export function browserProjectsOf(projects) {
97
97
  export function browserNavigationOf(path, project = null, needsYou = 0, tasksProject = project, needsYouLabel) {
98
98
  const pathname = path.split('?')[0].split('#')[0];
99
99
  const under = (...roots) => roots.some(root => pathname === root || pathname.startsWith(`${root}/`));
100
- // The rail (UI revamp, wave B): Home carries the one Needs-you count; the lead chat and an agent's question live
101
- // under Home, a task's pages under Tasks, every computer and terminal under Agents, every setting under Settings.
100
+ // The rail (UI revamp, wave B; Chat under Home, live-pages-and-home.md B1): Home carries the one Needs-you count and
101
+ // an agent's question lives under it; the lead chat is its own item; a task's pages under Tasks, every computer and
102
+ // terminal under Agents, every setting under Settings.
102
103
  return [
103
- { label: 'Home', href: '/', active: pathname === '/' || pathname === '/chat' || under('/d', '/activity', '/morning'),
104
+ { label: 'Home', href: '/', active: pathname === '/' || under('/d', '/activity', '/morning'),
104
105
  ...(needsYou > 0 ? { count: needsYou, ...(needsYouLabel === undefined ? {} : { countLabel: needsYouLabel }) } : {}) },
106
+ { label: 'Chat', href: '/chat', active: under('/chat') },
105
107
  { label: 'Tasks', href: `/tasks${tasksProject === null ? '' : `?project=${encodeURIComponent(tasksProject)}`}`,
106
108
  active: under('/tasks', '/t', '/r', '/review') },
107
109
  { label: 'Flows', href: '/flows', active: under('/flows', '/recipes', '/kits', '/routines') },
@@ -9,6 +9,9 @@ export type FlowAdvance = {
9
9
  export declare function flowDefinitionOf(flow: FlowRow): FlowDefinition | null;
10
10
  /** Where a card's link opens: the flow's canvas with the card selected. */
11
11
  export declare const flowCardHref: (flow: number, card: number) => string;
12
+ /** A reply wait that timed out with nowhere to send the card asks its owner (or the flow's owner) to move it on: the
13
+ * card's waiting words end with this (flows-ui.ts flowCardWaitsOn reads it). */
14
+ export declare const NO_REPLY_MOVE_ON = "Move the card on when you're ready.";
12
15
  /** Where a confirmed flow action may point: a flow's canvas, maybe with one card open. */
13
16
  export declare const FLOW_HREF: RegExp;
14
17
  /** Advance every active card in one project's flows. */
@@ -36,6 +36,9 @@ export function flowDefinitionOf(flow) {
36
36
  }
37
37
  /** Where a card's link opens: the flow's canvas with the card selected. */
38
38
  export const flowCardHref = (flow, card) => `/flows/${flow}?card=${card}`;
39
+ /** A reply wait that timed out with nowhere to send the card asks its owner (or the flow's owner) to move it on: the
40
+ * card's waiting words end with this (flows-ui.ts flowCardWaitsOn reads it). */
41
+ export const NO_REPLY_MOVE_ON = "Move the card on when you're ready.";
39
42
  /** Where a confirmed flow action may point: a flow's canvas, maybe with one card open. */
40
43
  export const FLOW_HREF = /^\/flows\/[1-9][0-9]{0,9}(\?card=[1-9][0-9]{0,9})?$/;
41
44
  const ACCEPTANCE = FLOW_ACCEPTANCE;
@@ -173,7 +176,7 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
173
176
  outcome.moved++;
174
177
  return;
175
178
  }
176
- const waiting = `No reply after ${words}. Move the card on when you're ready.`;
179
+ const waiting = `No reply after ${words}. ${NO_REPLY_MOVE_ON}`;
177
180
  if (card.waiting !== waiting) {
178
181
  store.updateFlowCard(card.id, { waiting }, now);
179
182
  notifyPeople(store, card, [card.owner ?? flow.owner], null, { key: `no-reply:${card.entry}`, subject: `No reply to “${card.title}” after ${words}`, body: `It's waiting in ${stage.title}.`, attention: true }, now);
@@ -1,5 +1,6 @@
1
1
  import type { BrowserFlowView } from "./browser-workspace.js";
2
- import type { FlowRow, Store } from "./store.js";
2
+ import { type FlowStage } from "./flows.js";
3
+ import type { FlowCardRow, FlowRow, Store, SubagentQuestionRow } from "./store.js";
3
4
  import { type FlowFile, type FlowImportPlan } from "./flow-share.js";
4
5
  import { type Html } from "./html.js";
5
6
  /** The flow page's fallback rules, plus the Flows list and the import preview (kit pieces; only layout here). */
@@ -24,6 +25,35 @@ export declare function flowImportHtml(input: {
24
25
  previewed: string;
25
26
  problem: string | null;
26
27
  }): Html;
28
+ /** Why a card waits on this person now (docs/plans/home-other-asks.md part 4, v2.3): the one rule the flow page's
29
+ * Decide and Home's Flow decisions both read, so they never disagree. Each needs approver standing:
30
+ * - `decide`: an approval zone it is theirs to decide (the zone's decider, the flow's owner for "to owner", any approver
31
+ * when none is named), and no subagent of the zone deciding it first;
32
+ * - `choose`: a "Person chooses" zone waiting on them (flow-send.ts flowChoiceAt);
33
+ * - `answer`: its subagent's open question or tool-call request asked of them;
34
+ * - `browser`: a check zone waiting for browser use, which only an approver with every project may allow;
35
+ * - `reply`: a reply wait that ran out with nowhere to go, asking the card's owner (else the flow's) to move it on.
36
+ * `reads` passes what the caller already read (a missing key is read here). */
37
+ export type FlowCardWait = {
38
+ decide: boolean;
39
+ choose: boolean;
40
+ answer: boolean;
41
+ browser: boolean;
42
+ reply: boolean;
43
+ };
44
+ export declare function flowCardWaitsOn(store: Store, flow: FlowRow, card: FlowCardRow, viewer: {
45
+ name: string;
46
+ approver: boolean;
47
+ }, reads?: {
48
+ stage?: FlowStage | undefined;
49
+ asked?: SubagentQuestionRow | null;
50
+ choosing?: {
51
+ person: string;
52
+ } | null;
53
+ browser?: {
54
+ status: string;
55
+ } | null;
56
+ }): FlowCardWait;
27
57
  /** One flow's canvas for one person. */
28
58
  export declare function flowView(store: Store, flow: FlowRow, viewer: {
29
59
  name: string;
package/dist/flows-ui.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Flows in the console: the list of a person's flows, and one flow's canvas
2
2
  * (the React view's data, plus a plain fallback page the view replaces). */
3
3
  import { liveAttemptOf, steerControlsFor } from "./steer-controls.js";
4
- import { draftFor, flowDefinitionOf } from "./flow-engine.js";
4
+ import { draftFor, flowDefinitionOf, NO_REPLY_MOVE_ON } from "./flow-engine.js";
5
5
  import { describeStandingOrder } from "./flow-schedule.js";
6
6
  import { describeTrigger, triggerHeadline, FLOW_TRIGGER_KINDS, FLOW_TRIGGER_WORDS, githubRepoOf, HOOK_PATH, hookReady, planeStatusLink, readHooksBase, readLinearKey, senderOf, takesDeliveries, triggerConfigOf } from "./flow-triggers.js";
7
7
  import { SENDER_WORDS } from "./webhook-signing.js";
@@ -14,7 +14,7 @@ import { parseSortDecision, sortChip } from "./flow-sort.js";
14
14
  import { flowSecretNames, readEmailSettings, sendingReady } from "./flow-actions.js";
15
15
  import { projectToolsOf, secretsSetFor, toolStanding } from "./project-tools.js";
16
16
  import { flowFingerprint } from "./flow-live.js";
17
- import { flowChoiceAt, readFlowSend } from "./flow-send.js";
17
+ import { flowChoiceAt, flowPersonOf, readFlowSend } from "./flow-send.js";
18
18
  import { itemCaption, whyLines } from "./flow-items.js";
19
19
  import { FLOW_FILE_MAX_BYTES } from "./flow-share.js";
20
20
  import { googleConnected } from "./google-mail.js";
@@ -224,6 +224,37 @@ const historyText = (event, title, sorts = new Set()) => {
224
224
  default: return `Moved to ${title(event.toStage)}${who}${note}`;
225
225
  }
226
226
  };
227
+ export function flowCardWaitsOn(store, flow, card, viewer, reads = {}) {
228
+ const none = { decide: false, choose: false, answer: false, browser: false, reply: false };
229
+ if (!viewer.approver || card.state !== "active")
230
+ return none;
231
+ const stage = "stage" in reads ? reads.stage : flowDefinitionOf(flow)?.stages.find(one => one.id === card.stage);
232
+ // Its subagent's open question is the person's to answer whatever zone it is in, even one since removed from the flow
233
+ // (the flow page shows it and takes the answer).
234
+ const asked = "asked" in reads ? reads.asked ?? null : store.openSubagentQuestionOn(card.id, card.entry);
235
+ const answer = asked !== null && asked.state === "open" && asked.askedOf === viewer.name && store.getSubagent(asked.subagent) !== null;
236
+ if (stage === undefined)
237
+ return { ...none, answer };
238
+ const decider = stage.kind === "approval" ? deciderOf(stage, flow) : null;
239
+ // While its subagent is deciding (v92), the card isn't anyone else's to decide yet.
240
+ const subagentDeciding = stage.subagent !== undefined && card.waiting !== null && card.waiting.endsWith(" is deciding");
241
+ const choosing = stage.kind !== "choose" ? null : "choosing" in reads ? reads.choosing ?? null : flowChoiceAt(store, card.id, card.entry);
242
+ const browser = stage.kind !== "check" || stage.browser !== true ? null : "browser" in reads ? reads.browser ?? null : browserViewOf(store, flow);
243
+ const wait = stage.wait ?? { for: "reply" };
244
+ return {
245
+ decide: stage.kind === "approval" && (decider === null || decider === viewer.name) && !subagentDeciding,
246
+ choose: choosing !== null && choosing.person === viewer.name,
247
+ answer,
248
+ browser: browserWaitOn(card, stage, browser) && store.accountOf(viewer.name)?.projects === null,
249
+ reply: stage.kind === "wait" && wait.for === "reply" && card.waiting !== null && card.waiting.startsWith("No reply after ") && card.waiting.endsWith(NO_REPLY_MOVE_ON)
250
+ && flowPersonOf(card, flow) === viewer.name,
251
+ };
252
+ }
253
+ /** A check zone's card waiting for browser use (flow-browser.ts): the flow's browser isn't allowed and the card says so. */
254
+ function browserWaitOn(card, stage, browser) {
255
+ return card.state === "active" && stage?.kind === "check" && stage.browser === true && browser !== null && browser.status !== "allowed"
256
+ && card.waiting !== null && card.waiting.startsWith(`${stage.title} starts a browser`);
257
+ }
227
258
  /** One flow's canvas for one person. */
228
259
  export function flowView(store, flow, viewer, selectedCard, setup = { dir: null, repos: [] }) {
229
260
  const definition = flowDefinitionOf(flow);
@@ -249,10 +280,12 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
249
280
  const canReadTask = taskRepo !== null && (taskRepo === flow.repo || (setup.repos.includes(taskRepo) && store.accountCanAccess(viewer.name, taskRepo)));
250
281
  const discussion = store.flowComments(card.id);
251
282
  const watchers = store.flowCardWatchers(card.id);
252
- const decider = stage?.kind === "approval" ? deciderOf(stage, flow) : null;
253
- // While its subagent is deciding (v92), the card isn't anyone else's to decide yet.
254
- const subagentDeciding = stage?.subagent !== undefined && card.waiting !== null && card.waiting.endsWith(" is deciding");
255
- const canDecide = viewer.approver && card.state === "active" && stage?.kind === "approval" && (decider === null || decider === viewer.name) && !subagentDeciding;
283
+ // Its open question: one of its own, or (v94) a tool call waiting for approval.
284
+ const asked = card.state === "active" ? store.openSubagentQuestionOn(card.id, card.entry) : null;
285
+ // "Person chooses" (flow-send.ts): what was sent, and the options, for the person it waits on; the latest send otherwise.
286
+ const choosing = card.state === "active" && stage?.kind === "choose" ? flowChoiceAt(store, card.id, card.entry) : null;
287
+ // Who may decide it here: the one rule Home reads too (flowCardWaitsOn).
288
+ const canDecide = flowCardWaitsOn(store, flow, card, viewer, { stage, asked, choosing, browser: browserNow }).decide;
256
289
  // History reads moves and ownership together, newest first.
257
290
  const owned = discussion.filter(one => one.kind === "owner").map(one => ({ text: one.body === "" ? `${one.author} left it without an owner` : one.body === one.author ? `${one.author} took it on` : `${one.author} made ${one.body} the owner`, at: one.at }));
258
291
  const moves = store.flowEvents(card.id).map(event => ({ text: historyText(event, title, sortZones), at: event.at }));
@@ -266,8 +299,6 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
266
299
  at: until.toISOString(),
267
300
  label: stage.wait !== undefined && stage.wait.for !== "hours" ? (stage.wait.for === "reply" ? "No reply by" : "Moves on at") : stage.limit.to !== null ? `Moves to ${title(stage.limit.to)} at` : "Reminder at",
268
301
  };
269
- // Its open question: one of its own, or (v94) a tool call waiting for approval.
270
- const asked = card.state === "active" ? store.openSubagentQuestionOn(card.id, card.entry) : null;
271
302
  const pending = asked?.toolCall == null ? null : store.subagentCall(asked.toolCall);
272
303
  // A decision its subagent handed over carries what the subagent said (v92).
273
304
  const turn = card.state === "active" && stage?.kind === "approval" && stage.subagent !== undefined ? store.flowStepRun(card.id, card.entry) : null;
@@ -284,8 +315,6 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
284
315
  }
285
316
  }
286
317
  const asker = asked === null || asked.state !== "open" ? null : store.getSubagent(asked.subagent);
287
- // "Person chooses" (flow-send.ts): what was sent, and the options, for the person it waits on; the latest send otherwise.
288
- const choosing = card.state === "active" && stage?.kind === "choose" ? flowChoiceAt(store, card.id, card.entry) : null;
289
318
  const latest = store.flowSends(card.id)[0] ?? null;
290
319
  const sentContent = latest === null ? null : readFlowSend(latest.contentJson);
291
320
  // On the card itself: where it came from (the card's title is right above), and only the links the panel doesn't already have.
@@ -295,8 +324,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
295
324
  // After research: the same numbered items the message carried, each beside its screenshot.
296
325
  ...(content.items === undefined ? {} : { items: content.items.map((item, index) => ({ number: index + 1, title: item.title, lines: whyLines(item.why), source: item.source, url: item.url,
297
326
  image: item.shot === null || content.shots === null ? null : { src: `/r/${content.shots.run}/evidence/${item.shot}`, caption: itemCaption(index + 1, item) } })) }) });
298
- const browserWait = card.state === "active" && stage?.kind === "check" && stage.browser === true && browser !== null && browser.status !== "allowed"
299
- && card.waiting !== null && card.waiting.startsWith(`${stage.title} starts a browser`);
327
+ const browserWait = browserWaitOn(card, stage, browser);
300
328
  return {
301
329
  id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state,
302
330
  waiting: card.state === "active" && card.task !== null && (stage?.kind === "task" || stage?.kind === "report")
@@ -0,0 +1,21 @@
1
+ /**
2
+ * One behaviour the app runs for the page on screen instead of a page script string (docs/plans/task-page-in-app.md).
3
+ * `answer`: a question's inline answer (render-tasks.ts decisionAnswerScript). `region`: a server-drawn region kept
4
+ * current from a build's own fragment (chrome.ts regionScript), `/r/<run>?fragment=check|peek`. `result`: reviewing the
5
+ * result the page shows (result-review.ts RESULT_REVIEW_SCRIPT with render-results.ts reviewEvidenceScript). No code,
6
+ * selector or address travels here: the app runs only the kinds it knows.
7
+ */
8
+ export type PageWork = {
9
+ kind: "answer";
10
+ } | {
11
+ kind: "result";
12
+ } | {
13
+ kind: "region";
14
+ region: "check-progress" | "run-peek";
15
+ run: number;
16
+ };
17
+ /** The work the app and the server agree on. A workspace read says it with `x-toolroll-work`; a reader that does not
18
+ * say exactly this version (an older tab) gets the page as a document with its script string, as before. A hint, never
19
+ * authority. It moves whenever a kind is added: 2 added `result`. */
20
+ export declare const PAGE_WORK_VERSION = 2;
21
+ export declare const PAGE_WORK_HEADER = "x-toolroll-work";
@@ -0,0 +1,5 @@
1
+ /** The work the app and the server agree on. A workspace read says it with `x-toolroll-work`; a reader that does not
2
+ * say exactly this version (an older tab) gets the page as a document with its script string, as before. A hint, never
3
+ * authority. It moves whenever a kind is added: 2 added `result`. */
4
+ export const PAGE_WORK_VERSION = 2;
5
+ export const PAGE_WORK_HEADER = "x-toolroll-work";
@@ -22,19 +22,19 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
- history?: never;
25
+ title?: never;
26
+ content?: never;
26
27
  revision: number;
27
28
  stale: boolean;
28
29
  notice: string;
29
- title?: never;
30
- content?: never;
30
+ history?: never;
31
31
  instructions?: never;
32
32
  references?: never;
33
33
  decisions?: never;
34
34
  waiting?: never;
35
35
  } | {
36
- revision?: never;
37
- history?: never;
36
+ title?: never;
37
+ content?: never;
38
38
  stale?: never;
39
39
  decision: {
40
40
  repo: string;
@@ -52,27 +52,27 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
52
52
  };
53
53
  afterwards?: string;
54
54
  notice: string;
55
- title?: never;
56
- content?: never;
55
+ revision?: never;
56
+ history?: never;
57
57
  instructions?: never;
58
58
  references?: never;
59
59
  decisions?: never;
60
60
  waiting?: never;
61
61
  } | {
62
- history?: never;
63
62
  stale?: never;
64
63
  revision: number;
65
64
  title: string;
66
65
  content: string;
67
66
  notice: string;
67
+ history?: never;
68
68
  instructions?: never;
69
69
  references?: never;
70
70
  decisions?: never;
71
71
  waiting?: never;
72
72
  } | {
73
- stale?: never;
74
73
  title?: never;
75
74
  content?: never;
75
+ stale?: never;
76
76
  revision: number;
77
77
  history: {
78
78
  revision: number;
@@ -1,22 +1,8 @@
1
1
  import { type PullRequestReader, type ReleaseCard, type ReleaseSeal, type TermLine } from "./release-card.js";
2
2
  import { type ApproveResult, type Scope } from "./scope.js";
3
3
  import { type Notification, type Store } from "./store.js";
4
- /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
5
- export declare const RELEASE_CI_SEEN_ACTION = "release CI observed";
6
- /** One check's newest attempt, as the coordinator's own gh read it. A re-run is a new check run id. */
7
- export type ReleaseCiRun = {
8
- id: number | null;
9
- name: string;
10
- status: string;
11
- conclusion: string | null;
12
- };
13
- export type ReleaseCiSnapshot = {
14
- candidate: string;
15
- at: string;
16
- state: "passed" | "running" | "failed";
17
- failed: string[];
18
- runs: ReleaseCiRun[];
19
- };
4
+ import { type ReleaseCiSnapshot } from "./release-refusal.js";
5
+ export { ciWords, releaseApprovalRefusal, releaseCiOf, RELEASE_CI_SEEN_ACTION, type ReleaseCiRun, type ReleaseCiSnapshot } from "./release-refusal.js";
20
6
  /**
21
7
  * Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
22
8
  * attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
@@ -35,13 +21,9 @@ export declare function recordReleaseCi(store: Store, input: {
35
21
  }[];
36
22
  now: Date;
37
23
  }): boolean;
38
- /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
39
- export declare function releaseCiOf(store: Store, taskId: string, candidate: string): ReleaseCiSnapshot | null;
40
24
  /** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
41
25
  * in progress read alike (neither is finished); the time it was read is not bound. */
42
26
  export declare function ciBinding(ci: ReleaseCiSnapshot | null): unknown;
43
- /** GitHub CI in one line, as the sheet and the CLI say it. */
44
- export declare function ciWords(ci: ReleaseCiSnapshot | null, candidate: string): string;
45
27
  export type ReleaseApprovalCard = {
46
28
  taskId: string;
47
29
  taskRef: number;
@@ -189,3 +171,10 @@ export declare function waitingJournals(stateDir: string, expectedFile: (branch:
189
171
  journals: WaitingJournal[];
190
172
  unreadable: string[];
191
173
  };
174
+ /** A release as Home names it (docs/plans/home-other-asks.md, part 1): its version, and why it can't take a yes now
175
+ * in the sheet's own words (`refusalOf`, the one source), or null when this isn't a release. Reads only; Home never
176
+ * approves one, it links to the sheet. */
177
+ export declare function releaseAskOf(store: Store, taskId: string): {
178
+ version: string;
179
+ refusal: string | null;
180
+ } | null;
@@ -19,20 +19,19 @@ import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { permissionPlainWords } from "./chat-terms.js";
21
21
  import { approvalTermsOf, releaseAccessWords, releaseCardOf, releaseSealOf } from "./release-card.js";
22
- import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
23
22
  import { approvalOf } from "./scope.js";
24
23
  import { releaseProgressFor } from "./store.js";
25
24
  import { taskArchived } from "./task-archive.js";
26
25
  import { cleanTerminalLine } from "./terminal-text.js";
26
+ import { ciName, ciWords, refusalOf, releaseCiOf, RELEASE_CI_SEEN_ACTION } from "./release-refusal.js";
27
+ // The CI reading and the refusals live in release-refusal.ts, so scope.ts approve() can read them without a cycle.
28
+ export { ciWords, releaseApprovalRefusal, releaseCiOf, RELEASE_CI_SEEN_ACTION } from "./release-refusal.js";
27
29
  const SHA = /^[0-9a-f]{40}$/;
28
30
  const DIGEST = /^[0-9a-f]{64}$/;
29
31
  /** A scope digest as scope.ts writes it. */
30
32
  const SCOPE_DIGEST = /^[0-9a-f]{32,64}$/;
31
33
  const sha256 = (text) => createHash("sha256").update(text).digest("hex");
32
34
  // ---- GitHub CI as the coordinator read it -------------------------------------------------------------------------
33
- /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
34
- export const RELEASE_CI_SEEN_ACTION = "release CI observed";
35
- const ciName = (value) => cleanTerminalLine(String(value ?? "")).trim().slice(0, 120);
36
35
  /**
37
36
  * Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
38
37
  * attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
@@ -58,60 +57,11 @@ export function recordReleaseCi(store, input) {
58
57
  action: RELEASE_CI_SEEN_ACTION, outcome: input.state, source: "work", detail });
59
58
  return true;
60
59
  }
61
- /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
62
- export function releaseCiOf(store, taskId, candidate) {
63
- const row = store.handle.prepare("SELECT detail, at FROM action_ledger WHERE task_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(taskId, RELEASE_CI_SEEN_ACTION);
64
- if (row === undefined)
65
- return null;
66
- let parsed;
67
- try {
68
- parsed = JSON.parse(String(row["detail"]));
69
- }
70
- catch {
71
- return null;
72
- }
73
- const one = parsed;
74
- if (one.v !== 1 || one.candidate !== candidate || !["passed", "running", "failed"].includes(String(one.state)) || !Array.isArray(one.failed) || !Array.isArray(one.runs))
75
- return null;
76
- return { candidate, at: String(row["at"]), state: one.state, failed: one.failed.map(ciName),
77
- runs: one.runs.map(run => { const r = run; return { id: typeof r["id"] === "number" ? r["id"] : null, name: ciName(r["name"]), status: ciName(r["status"]), conclusion: r["conclusion"] == null ? null : ciName(r["conclusion"]) }; }) };
78
- }
79
60
  /** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
80
61
  * in progress read alike (neither is finished); the time it was read is not bound. */
81
62
  export function ciBinding(ci) {
82
63
  return ci === null ? null : { candidate: ci.candidate, state: ci.state, failed: ci.failed, runs: ci.runs.map(run => [run.id, run.name, run.status === "completed", run.conclusion]) };
83
64
  }
84
- /** GitHub CI in one line, as the sheet and the CLI say it. */
85
- export function ciWords(ci, candidate) {
86
- const short = candidate.slice(0, 7);
87
- if (ci === null)
88
- return `GitHub CI on ${short} hasn't been read yet.`;
89
- if (ci.state === "passed")
90
- return `GitHub CI passed on ${short}.`;
91
- if (ci.state === "failed")
92
- return `GitHub CI failed on ${short}: ${ci.failed.join(", ") || "a check"}.`;
93
- const finished = ci.runs.filter(run => run.status === "completed").length;
94
- return `GitHub CI is running on ${short}: ${finished} of ${ci.runs.length} checks finished.`;
95
- }
96
- /** The release check failed on this task already (the coordinator's record): nobody approves it again here. */
97
- function checkFailed(store, taskId) {
98
- return store.handle.prepare("SELECT 1 FROM release_step WHERE task_id = ? AND step = 'check' AND state = 'failed' LIMIT 1").get(taskId) !== undefined;
99
- }
100
- /** Why a release can't be approved now, from the plane's own state, or null. */
101
- function refusalOf(store, taskId, scope, ci) {
102
- if (taskArchived(store, taskId))
103
- return "This release is put away. Restore it before approving.";
104
- const hold = releaseCiHoldOf(store, taskId);
105
- if (hold !== null)
106
- return releaseCiHoldWords(hold);
107
- if (ci?.state === "failed")
108
- return `${ciWords(ci, scope.candidate)} Fix the branch or rerun the failed job on GitHub, then release again.`;
109
- if (checkFailed(store, taskId))
110
- return "The release check already failed on this commit. Fix the branch and release the new commit.";
111
- if (scope.approvedAt !== null && scope.approvedDigest !== scope.digest)
112
- return "The release's terms changed after an earlier approval. Read them again.";
113
- return null;
114
- }
115
65
  /** A release task from the plane's own state: a sealed form (terms or a release check's purpose) whose repository is the
116
66
  * task's project and whose approved commit is the scope's candidate. Never from a title or a task id. */
117
67
  export function releaseTaskOf(store, taskId) {
@@ -380,3 +330,14 @@ export function waitingJournals(stateDir, expectedFile) {
380
330
  }
381
331
  return { journals, unreadable };
382
332
  }
333
+ /** A release as Home names it (docs/plans/home-other-asks.md, part 1): its version, and why it can't take a yes now
334
+ * in the sheet's own words (`refusalOf`, the one source), or null when this isn't a release. Reads only; Home never
335
+ * approves one, it links to the sheet. */
336
+ export function releaseAskOf(store, taskId) {
337
+ const found = releaseTaskOf(store, taskId);
338
+ if (found === null)
339
+ return null;
340
+ const { scope, seal } = found;
341
+ const refusal = approvalOf(scope).approved ? null : refusalOf(store, taskId, scope, releaseCiOf(store, taskId, scope.candidate));
342
+ return { version: seal.identity.packageVersion, refusal };
343
+ }
@@ -0,0 +1,32 @@
1
+ import type { Scope } from "./scope.js";
2
+ import type { Store } from "./store.js";
3
+ /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
4
+ export declare const RELEASE_CI_SEEN_ACTION = "release CI observed";
5
+ /** One check's newest attempt, as the coordinator's own gh read it. A re-run is a new check run id. */
6
+ export type ReleaseCiRun = {
7
+ id: number | null;
8
+ name: string;
9
+ status: string;
10
+ conclusion: string | null;
11
+ };
12
+ export type ReleaseCiSnapshot = {
13
+ candidate: string;
14
+ at: string;
15
+ state: "passed" | "running" | "failed";
16
+ failed: string[];
17
+ runs: ReleaseCiRun[];
18
+ };
19
+ export declare const ciName: (value: unknown) => string;
20
+ /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
21
+ export declare function releaseCiOf(store: Store, taskId: string, candidate: string): ReleaseCiSnapshot | null;
22
+ /** GitHub CI in one line, as the sheet and the CLI say it. */
23
+ export declare function ciWords(ci: ReleaseCiSnapshot | null, candidate: string): string;
24
+ /** Why a release can't be approved now, from the plane's own state, or null. */
25
+ export declare function refusalOf(store: Store, taskId: string, scope: Scope, ci: ReleaseCiSnapshot | null): string | null;
26
+ /**
27
+ * The release refusals every approval road shares (scope.ts approve(), read inside its transaction): for a scope
28
+ * sealing release terms or a release check's purpose, the sheet's own reasons in the sheet's own words, so the task
29
+ * page, Home's in-place yes, chat and `toolroll task approve` refuse exactly what `/t/<id>/release` refuses. Null for
30
+ * every other scope, and for a release nothing stops. The sheet's card digest binding stays the sheet's.
31
+ */
32
+ export declare function releaseApprovalRefusal(store: Store, taskId: string, scope: Scope): string | null;