toolroll 0.9.97 → 0.9.98

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 (78) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/approval-authority.d.ts +10 -1
  3. package/dist/approval-authority.js +18 -2
  4. package/dist/browser/workspace.js +1 -1
  5. package/dist/browser-workspace.d.ts +4 -3
  6. package/dist/chat-actions.d.ts +40 -5
  7. package/dist/chat-actions.js +124 -13
  8. package/dist/chat-card.js +4 -1
  9. package/dist/chat-channel.d.ts +3 -0
  10. package/dist/chat-channel.js +24 -7
  11. package/dist/chat-decide.js +4 -2
  12. package/dist/chat-task-actions.js +1 -1
  13. package/dist/chat-terms.d.ts +8 -0
  14. package/dist/chat-terms.js +14 -0
  15. package/dist/chat-voice.js +1 -1
  16. package/dist/contracts/chat-actions.d.ts +6 -0
  17. package/dist/contracts/chat-actions.js +2 -0
  18. package/dist/contracts/cli.d.ts +6 -0
  19. package/dist/contracts/cli.js +3 -0
  20. package/dist/contracts/lead-tools.d.ts +17 -0
  21. package/dist/contracts/lead-tools.js +4 -1
  22. package/dist/contracts/store-json.d.ts +1 -0
  23. package/dist/desktop-update.d.ts +1 -1
  24. package/dist/fast-start.d.ts +83 -6
  25. package/dist/fast-start.js +112 -15
  26. package/dist/home-groups.d.ts +39 -0
  27. package/dist/home-groups.js +33 -0
  28. package/dist/lead-answer-eval.d.ts +7 -0
  29. package/dist/lead-answer-eval.js +6 -2
  30. package/dist/lead-capabilities.js +1 -1
  31. package/dist/lead-context.js +2 -2
  32. package/dist/lead-contract.d.ts +1 -1
  33. package/dist/lead-contract.js +10 -7
  34. package/dist/lead-doors.d.ts +32 -0
  35. package/dist/lead-doors.js +80 -14
  36. package/dist/lead-status.d.ts +3 -0
  37. package/dist/lead-status.js +8 -3
  38. package/dist/lead-tools.d.ts +20 -0
  39. package/dist/lead-tools.js +66 -3
  40. package/dist/lead-voice.js +1 -1
  41. package/dist/lead.js +18 -2
  42. package/dist/local-owner.js +2 -0
  43. package/dist/one-step-chat-schema.d.ts +22 -0
  44. package/dist/one-step-chat-schema.js +37 -0
  45. package/dist/operate.d.ts +27 -1
  46. package/dist/operate.js +206 -32
  47. package/dist/project-delete.js +9 -0
  48. package/dist/project-knowledge.d.ts +7 -7
  49. package/dist/publish.d.ts +2 -0
  50. package/dist/publish.js +1 -1
  51. package/dist/pull-request-flow.d.ts +4 -0
  52. package/dist/pull-request-flow.js +16 -1
  53. package/dist/reply-voice.d.ts +23 -0
  54. package/dist/reply-voice.js +37 -0
  55. package/dist/scope.d.ts +6 -1
  56. package/dist/scope.js +3 -2
  57. package/dist/server/chat.js +4 -4
  58. package/dist/server/chrome.js +2 -2
  59. package/dist/server/home.d.ts +4 -3
  60. package/dist/server/home.js +33 -28
  61. package/dist/server/render-chat.js +11 -6
  62. package/dist/server/render-pages.js +2 -2
  63. package/dist/setup-guide.d.ts +2 -2
  64. package/dist/store.d.ts +4 -2
  65. package/dist/store.js +35 -12
  66. package/dist/telegram-lead.js +69 -5
  67. package/dist/telegram-status.js +9 -4
  68. package/dist/telegram.d.ts +10 -0
  69. package/dist/telegram.js +92 -8
  70. package/dist/toolroll-update.js +3 -1
  71. package/dist/tui-contract.d.ts +2 -0
  72. package/dist/tui-source.js +3 -0
  73. package/dist/tui.js +41 -41
  74. package/dist/typed-start.d.ts +51 -0
  75. package/dist/typed-start.js +98 -0
  76. package/dist/wake-hub.d.ts +72 -0
  77. package/dist/wake-hub.js +151 -0
  78. package/package.json +1 -1
@@ -0,0 +1,37 @@
1
+ /**
2
+ * How a lead reply reads (docs/plans/lead-voice.md, V3): its length, its sentences, the internal words it says and
3
+ * whether it opens by describing its own steps. Measured on the shaped text a person sees, never the raw reply.
4
+ * A measure for the lead-answer eval's report; nothing gates on it and no reply is rewritten from it.
5
+ */
6
+ import { PLAIN_WORDS } from "./plain-words.js";
7
+ import { shapeReply } from "./reply-shape.js";
8
+ /** A reply longer than this is counted as long in the eval's report. */
9
+ export const LONG_REPLY_CHARACTERS = 320;
10
+ const CODE = /```[\s\S]*?(?:```|$)|`[^`\n]+`/g;
11
+ const OWNER_AS_ADDRESS = /(?<![\w-])the\s+owner(?![\w-])/i;
12
+ const NARRATED_OPENING = /^(?:i\s+checked|i\s+looked\s+at|let\s+me|certainly|sure,)/i;
13
+ export function replyVoice(reply) {
14
+ const shaped = shapeReply(reply);
15
+ const prose = shaped.replace(CODE, " ");
16
+ const internalWords = PLAIN_WORDS.filter(one => one.pattern.test(prose)).map(one => one.never);
17
+ if (OWNER_AS_ADDRESS.test(prose))
18
+ internalWords.push("owner");
19
+ return {
20
+ characters: shaped.length,
21
+ sentences: prose.split(/(?<=[.!?])\s+|\n+/).filter(part => /[\p{L}\p{N}]/u.test(part)).length,
22
+ internalWords,
23
+ narratedOpening: NARRATED_OPENING.test(shaped.trimStart()),
24
+ };
25
+ }
26
+ /** One model's answers, summed up for the report. */
27
+ export function summarizeVoice(voices) {
28
+ const sorted = voices.map(one => one.characters).sort((a, b) => a - b);
29
+ const mid = Math.floor(sorted.length / 2);
30
+ return {
31
+ answers: voices.length,
32
+ medianCharacters: sorted.length === 0 ? 0 : sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2,
33
+ longShare: voices.length === 0 ? 0 : voices.filter(one => one.characters > LONG_REPLY_CHARACTERS).length / voices.length,
34
+ internalWords: voices.reduce((sum, one) => sum + one.internalWords.length, 0),
35
+ narratedOpenings: voices.filter(one => one.narratedOpening).length,
36
+ };
37
+ }
package/dist/scope.d.ts CHANGED
@@ -796,10 +796,15 @@ export declare function fileAndSealUnderMode(store: Store, input: ScopeInput & {
796
796
  detail?: string;
797
797
  };
798
798
  export declare function approve(store: Store, taskId: string, by: string, now: Date, sawDigest: string, token: string, mutation?: Mutation,
799
- /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it. */
799
+ /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it.
800
+ * `typed`: a typed yes to a Start card (one-step-chat.md C5), the message that was the yes, recorded beside the marker. */
800
801
  fastStart?: {
801
802
  rule: string;
802
803
  at: string;
804
+ typed?: {
805
+ provider: string;
806
+ event: string;
807
+ };
803
808
  },
804
809
  /** A person's own yes in chat (owner's decision, docs/plans/toolroll-cards.md): their tap or signed-in click stands in
805
810
  * for a password, so `token` is unused. Every rule below still applies. The caller has proved the person is the one
package/dist/scope.js CHANGED
@@ -1075,7 +1075,8 @@ export function fileAndSealUnderMode(store, input) {
1075
1075
  });
1076
1076
  }
1077
1077
  export function approve(store, taskId, by, now, sawDigest, token, mutation = {},
1078
- /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it. */
1078
+ /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it.
1079
+ * `typed`: a typed yes to a Start card (one-step-chat.md C5), the message that was the yes, recorded beside the marker. */
1079
1080
  fastStart,
1080
1081
  /** A person's own yes in chat (owner's decision, docs/plans/toolroll-cards.md): their tap or signed-in click stands in
1081
1082
  * for a password, so `token` is unused. Every rule below still applies. The caller has proved the person is the one
@@ -1216,7 +1217,7 @@ export const APPROVAL_PROOF = "approval proved";
1216
1217
  export const approvalProofDetail = (at, generation, digest, local) => `${new Date(at).toISOString()} · generation ${generation}${local === undefined ? "" : ` · local ${local.generation} ${local.transport}`} · digest ${digest}`;
1217
1218
  /** A fast start's proof: the v1 record proofDetailOf reads, with the marker. `local` keeps the legacy words
1218
1219
  * localSealStandsSql reads, so a local owner's seal stands on the same rule. */
1219
- const fastProofDetail = (fastStart) => (at, generation, digest, local) => JSON.stringify({ v: 1, at: new Date(at).toISOString(), generation, digest, fastStart, ...(local === undefined ? {} : { local: `proof · local ${local.generation} ${local.transport} · seal` }) });
1220
+ const fastProofDetail = ({ typed, ...fastStart }) => (at, generation, digest, local) => JSON.stringify({ v: 1, at: new Date(at).toISOString(), generation, digest, fastStart, ...(typed === undefined ? {} : { typed: { provider: typed.provider, event: typed.event } }), ...(local === undefined ? {} : { local: `proof · local ${local.generation} ${local.transport} · seal` }) });
1220
1221
  /** The proof of THIS approval: the approver's own row for these exact bytes (never another approval's words). */
1221
1222
  export function approvalProofOf(store, taskId, by, digest) {
1222
1223
  const rows = store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? ORDER BY id DESC LIMIT 50").all(taskId, APPROVAL_PROOF, by);
@@ -780,7 +780,7 @@ export function createChatHandlers(runtime) {
780
780
  const cardJson = String(request.headers["accept"] ?? "").includes("application/json") && !body.has("nonce");
781
781
  const cardAnswer = (status, ok, said, taskId = null) => respond(response, status, "application/json", JSON.stringify({ ok, said, taskId }));
782
782
  if (principal === null)
783
- return cardJson ? cardAnswer(403, false, "Your approver standing changed. Sign in again.") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
783
+ return cardJson ? cardAnswer(403, false, "Your sign-in changed. Sign in again.") : refuse(response, who, 403, "Your sign-in changed. Sign in again.", back);
784
784
  if (op === "dismiss") {
785
785
  const dismissed = dismissLeadProposal(store, principal, id, now);
786
786
  if (cardJson)
@@ -849,7 +849,7 @@ export function createChatHandlers(runtime) {
849
849
  return refuse(response, who, 403, "proposals are confirmed from the browser or the CLI");
850
850
  const principal = leadPrincipal(who);
851
851
  if (principal === null)
852
- return refuse(response, who, 403, "your approver standing changed — sign in again", "/chat");
852
+ return refuse(response, who, 403, "Your sign-in changed. Sign in again.", "/chat");
853
853
  const id = Number(url.pathname.split("/")[2]);
854
854
  store.sweepCoordinatorProposals(now);
855
855
  const back = body.get("return") === null ? "/chat" : safeReturn(body.get("return"));
@@ -919,7 +919,7 @@ export function createChatHandlers(runtime) {
919
919
  if (who.role === "approver" && enabled.billing === "subscription" && unbound) {
920
920
  const principal = leadPrincipal(who);
921
921
  if (principal === null)
922
- return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
922
+ return wantsJson ? said(403, "Your sign-in changed. Sign in again.") : refuse(response, who, 403, "Your sign-in changed. Sign in again.", back);
923
923
  const sentRequest = body.get("request");
924
924
  if (sentRequest !== null && (body.getAll("request").length !== 1 || !/^[a-f0-9]{32}$/.test(sentRequest)))
925
925
  return said(409, "This conversation changed. Reload it before sending your message.");
@@ -961,7 +961,7 @@ export function createChatHandlers(runtime) {
961
961
  const requestId = sentRequest ?? randomBytes(16).toString("hex");
962
962
  const principal = leadPrincipal(who);
963
963
  if (principal === null)
964
- return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
964
+ return wantsJson ? said(403, "Your sign-in changed. Sign in again.") : refuse(response, who, 403, "Your sign-in changed. Sign in again.", back);
965
965
  const message = (body.get("message") ?? "").trim();
966
966
  if (message === "" || message.length > LEAD_MESSAGE_MAX_CHARS) {
967
967
  return said(400, `a message is 1 to ${LEAD_MESSAGE_MAX_CHARS} characters`, leadSession.id);
@@ -726,7 +726,7 @@ export function workToolRows(scoped = false, _offersChat = false, offersCode = f
726
726
  { key: "tasks", href: "/tasks", label: "Task list", hint: "everything, filterable by state" },
727
727
  { key: "recipes", href: "/recipes", label: "Recipes", hint: "choose, customize, and reuse a workflow" },
728
728
  { key: "workbench", href: "/tasks?scope=all", label: "Every project", hint: "every project's tasks in one list" },
729
- { key: "ledger", href: "/ledger", label: "Action ledger", hint: "who acted, what happened, and the result" },
729
+ { key: "ledger", href: "/ledger", label: "Audit log", hint: "who acted, what happened, and the result" },
730
730
  { key: "spend", href: "/spend", label: "Spend", hint: "what agent work cost, and monthly budgets" },
731
731
  ];
732
732
  return scoped ? rows.filter(row => row.key !== "workbench" && row.key !== "spend") : rows;
@@ -737,7 +737,7 @@ export function settingsRows(scoped = false, offersSettings = false) {
737
737
  { key: "fleet", href: "/fleet", label: "Fleet", hint: "who is working, and on what" },
738
738
  { key: "caps", href: "/caps", label: "Requirements", hint: "tools and credentials builds need" },
739
739
  { key: "people", href: "/people", label: "People", hint: "who can sign in, and what they have done" },
740
- { key: "mode", href: "/mode", label: "Operating mode", hint: "the signed posture this repository runs under" },
740
+ { key: "mode", href: "/mode", label: "Operating mode", hint: "how much this project may do without asking" },
741
741
  { key: "system", href: "/system", label: "System", hint: "workers, providers, and grants" },
742
742
  ];
743
743
  return scoped ? rows.filter(row => row.key === "people" || row.key === "settings") : rows;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Home ("/", UI revamp wave B): one sentence that answers "what needs me?", four counts read from the same work index
3
- * the Tasks list filters (so a count and the list it opens always agree), what waits on this person grouped by what it
4
- * asks, what is running, and the lead's message box. Reads only: every act keeps its own route and rules (an agent's
3
+ * the Tasks list filters (so a count and the list it opens always agree), the work in three groups (Waiting on you,
4
+ * Working, Done; home-groups.ts), and the lead's message box. Reads only: every act keeps its own route and rules (an agent's
5
5
  * question is answered through POST /d/<id>/answer, the same one-click act as its own page).
6
6
  */
7
7
  import type { BrowserHomeView } from "../browser-workspace.js";
@@ -37,5 +37,6 @@ export declare function homeSentence(needs: number, perProject: readonly {
37
37
  name: string;
38
38
  needs: number;
39
39
  }[], scope: string | null, any: boolean): string;
40
- /** Home's view for one person and scope, from two reads of the work index (what waits, what runs). */
40
+ /** Home's view for one person and scope, from four reads of the work index (what waits on you, what runs, what waits on
41
+ * Toolroll, what was done), grouped by the one rule every surface shares (home-groups.ts). */
41
42
  export declare function homeView(facts: HomeFacts): BrowserHomeView;
@@ -1,15 +1,16 @@
1
1
  import { browserWorkActionHref } from "../browser-crew.js";
2
- import { ASK_LABEL } from "../needs-you.js";
2
+ import { HOME_GROUP_LABEL, homeGroupOf } from "../home-groups.js";
3
3
  import { projectName } from "../project.js";
4
4
  import { workIndexPage } from "../work-index.js";
5
5
  import { relativeAge } from "./chrome.js";
6
6
  import { taskHref } from "./http.js";
7
7
  const TONE = { attention: "needs", ready: "needs", live: "live", done: "done", problem: "failed", muted: "", neutral: "" };
8
- const ASKS = ["decide", "review", "unblock"];
9
8
  const ASK_WORD = { decide: "Decision", review: "Result", unblock: "Blocked" };
10
9
  /** How many waiting rows Home lists; the rest open in Tasks. */
11
10
  export const HOME_NEEDS_SHOWN = 30;
12
11
  const HOME_RUNNING_SHOWN = 8;
12
+ /** How many rows finished in the last 7 days Home lists; the rest open in Tasks. */
13
+ const HOME_DONE_SHOWN = 8;
13
14
  /** "7 need you across 2 projects." — the sentence and the figures read the same totals. */
14
15
  export function homeSentence(needs, perProject, scope, any) {
15
16
  if (!any)
@@ -51,50 +52,54 @@ function rowOf(store, item, now, multi, canAnswer, closedDoor) {
51
52
  : href === null || action === null ? null : { label: action.label, href }, question,
52
53
  };
53
54
  }
54
- /** Home's view for one person and scope, from two reads of the work index (what waits, what runs). */
55
+ /** Home's view for one person and scope, from four reads of the work index (what waits on you, what runs, what waits on
56
+ * Toolroll, what was done), grouped by the one rule every surface shares (home-groups.ts). */
55
57
  export function homeView(facts) {
56
58
  const { store, now, access, project } = facts;
57
59
  const read = (view, limit) => workIndexPage(store, now, access, { view, limit, project, ...(facts.evidenceRoot === undefined ? {} : { root: facts.evidenceRoot }) });
58
60
  const needs = read("needs-you", HOME_NEEDS_SHOWN);
59
61
  const running = read("running", HOME_RUNNING_SHOWN);
60
- // Waiting (docs/plans/plain-toolroll.md P1.4): what Toolroll or another task moves on, with no person asked.
61
- const waiting = workIndexPage(store, now, access, { view: "queued", limit: HOME_RUNNING_SHOWN, project, ...(facts.evidenceRoot === undefined ? {} : { root: facts.evidenceRoot }) });
62
+ // Queued: what waits its turn, or what Toolroll or another task moves on, with no person asked.
63
+ const waiting = read("queued", HOME_RUNNING_SHOWN);
64
+ // Done: the Completed list is ordered by completion time, newest first, so its first rows are the newest finished;
65
+ // only those finished in the last 7 days are listed, and that filter runs before the cap.
66
+ const completed = read("completed", HOME_DONE_SHOWN);
62
67
  const totals = needs.totals;
63
68
  // Queued: the Tasks list's own Queued count (the same predicate its view=queued filter lists).
64
69
  const queued = totals.queued;
65
70
  // Every project is said by name (scope=all): /tasks alone would narrow to the session's open project.
66
71
  const scopeQuery = project === null ? "scope=all&" : `project=${encodeURIComponent(project)}&`;
72
+ // Each figure keeps the name of the list it opens; Done is the whole Completed list, not just this week.
67
73
  const counts = [
68
- { key: "needs", label: "Needs you", value: totals["needs-you"], href: `/tasks?${scopeQuery}view=needs-you` },
74
+ { key: "needs", label: HOME_GROUP_LABEL.you, value: totals["needs-you"], href: `/tasks?${scopeQuery}view=needs-you` },
69
75
  { key: "running", label: "Running", value: totals.running, href: `/tasks?${scopeQuery}view=running` },
70
76
  { key: "queued", label: "Queued", value: queued, href: `/tasks?${scopeQuery}view=queued` },
71
- { key: "finished", label: "Finished", value: totals.completed, href: `/tasks?${scopeQuery}view=completed` },
77
+ { key: "finished", label: HOME_GROUP_LABEL.done, value: totals.completed, href: `/tasks?${scopeQuery}view=completed` },
72
78
  ];
73
- const multi = project === null && new Set([...needs.items, ...running.items].map(one => one.repo ?? "")).size > 1;
79
+ // One row per family, in the first group that claims it.
80
+ const seen = new Set();
81
+ const take = (items, group) => items.filter(one => {
82
+ if (homeGroupOf(one) !== group || seen.has(one.rootId))
83
+ return false;
84
+ seen.add(one.rootId);
85
+ return true;
86
+ });
87
+ // Waiting on you, in today's order of what it asks: decide (with any row whose ask the index couldn't name, which
88
+ // still waits on this person and is never lost), then review, then unblock. Each row's chip names its ask.
89
+ const yours = take(["decide", null, "review", "unblock"].flatMap(ask => needs.items.filter(one => one.ask === ask)), "you");
90
+ // Working: what runs, then what is queued (waits its turn, or on Toolroll or another task), each read with its own bound.
91
+ const working = [...take(running.items, "working"), ...take(waiting.items, "working")];
92
+ const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
93
+ const done = take(completed.items.filter(one => (one.completion?.at ?? one.updatedAt) >= weekAgo), "done").slice(0, HOME_DONE_SHOWN);
94
+ const multi = project === null && new Set([...yours, ...working, ...done].map(one => one.repo ?? "")).size > 1;
74
95
  const groups = [];
75
- for (const ask of ASKS) {
76
- const rows = needs.items.filter(one => one.ask === ask).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
77
- if (rows.length > 0)
78
- groups.push({ key: ask, label: ASK_LABEL[ask], rows });
96
+ for (const [key, items] of [["you", yours], ["working", working], ["done", done]]) {
97
+ if (items.length > 0)
98
+ groups.push({ key, label: HOME_GROUP_LABEL[key], rows: items.map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor)) });
79
99
  }
80
- // A waiting row whose ask the index couldn't name still waits on this person: it goes under Decide, never lost.
81
- const unnamed = needs.items.filter(one => one.ask === null).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
82
- if (unnamed.length > 0) {
83
- const decide = groups.find(one => one.key === "decide");
84
- if (decide)
85
- decide.rows.push(...unnamed);
86
- else
87
- groups.unshift({ key: "decide", label: ASK_LABEL.decide, rows: unnamed });
88
- }
89
- const live = running.items.filter(one => !one.status.views.includes("needs-you")).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
90
- if (live.length > 0)
91
- groups.push({ key: "running", label: "Running", rows: live });
92
- const waits = waiting.items.filter(one => one.status.label === "Waiting" && !one.status.views.includes("needs-you")).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
93
- if (waits.length > 0)
94
- groups.push({ key: "waiting", label: "Waiting", rows: waits });
95
100
  const perProject = needs.projects.map(one => ({ name: one.repo === null ? "Unplaced" : projectName(one.repo), needs: one.totals["needs-you"] }));
96
101
  const any = totals.all > 0 || totals.completed > 0 || (totals.archived ?? 0) > 0;
97
- const shown = groups.filter(one => one.key !== "running" && one.key !== "waiting").reduce((sum, one) => sum + one.rows.length, 0);
102
+ const shown = yours.length;
98
103
  return {
99
104
  kind: "home",
100
105
  say: homeSentence(totals["needs-you"], perProject, project === null ? null : projectName(project), any),
@@ -396,7 +396,7 @@ export function chatLimitsHtml(facts) {
396
396
  html `<span>${facts.turnsToday} / ${facts.dailyTurns} turns today</span>`,
397
397
  ];
398
398
  if (facts.subscription)
399
- rows.push(html `<span>membership login · no dollar ceiling</span>`);
399
+ rows.push(html `<span>membership login · no dollar limit</span>`);
400
400
  else if (facts.weekly !== null)
401
401
  rows.push(html `<span>this week ${chatMoney(facts.weekly.spent)} of ${chatMoney(facts.weekly.ceiling)}</span>`);
402
402
  return html `<details class="chat-limits"><summary>Model &amp; limits<span class="meta">${facts.subscription ? "membership" : facts.provider}</span></summary><div class="chat-budget">${rows}</div></details>`;
@@ -564,7 +564,7 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
564
564
  hold: { label: "Pause work", action: "hold", icon: html `<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>` },
565
565
  unhold: { label: "Resume work", action: "release hold", icon: html `<path d="m7 4 13 8-13 8z"/>` },
566
566
  steer: { label: "Guidance for the agent", action: "add guidance", icon: html `<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>` },
567
- scope: { label: "Scope revision", action: "save scope", icon: html `<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4z"/>` },
567
+ scope: { label: "Plan change", action: "save plan", icon: html `<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4z"/>` },
568
568
  answer: { label: "Decision answer", action: "confirm answer", icon: html `<path d="M9.1 9a3 3 0 1 1 5.8 1c0 2-3 2-3 4"/><path d="M12 18h.01"/><circle cx="12" cy="12" r="9"/>` },
569
569
  cancel: { label: "Cancel task", action: "open task", icon: html `<path d="m15 9-6 6"/><path d="m9 9 6 6"/><circle cx="12" cy="12" r="9"/>` },
570
570
  repair: {
@@ -599,7 +599,8 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
599
599
  }
600
600
  else if (view.kind === "action") {
601
601
  const action = sharedActionPayload(payload);
602
- what = action === null ? html `<p>This action is unavailable.</p>` : (sharedActionNeedsReview(action) ? html `` : html `${action.terms.map(term => html `<p style="white-space:pre-wrap;overflow-wrap:anywhere">${term}</p>`)}`);
602
+ // A Start card (one-step-chat.md C2) names what starts and where; its whole terms and the confirm are on the review page.
603
+ what = action === null ? html `<p>This action is unavailable.</p>` : action.operation === "task_start" ? html `<h3>${action.title}</h3>${facts(["project", mono(action.repo.split(/[\\/]/).filter(Boolean).at(-1) ?? action.repo)])}` : (sharedActionNeedsReview(action) ? html `` : html `${action.terms.map(term => html `<p style="white-space:pre-wrap;overflow-wrap:anywhere">${term}</p>`)}`);
603
604
  }
604
605
  else if (view.kind === "task_action") {
605
606
  const operation = payload["operation"];
@@ -723,7 +724,7 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
723
724
  if (view.state === "pending" && !inert) {
724
725
  acts =
725
726
  view.kind === "action" && sharedActionPayload(payload) !== null && sharedActionNeedsReview(sharedActionPayload(payload))
726
- ? html `<a class="button-link" href="${sharedActionReviewPath(view.id)}">Review action</a>${dismissForm(html `<button class="quiet">Dismiss</button>`)}`
727
+ ? html `<a class="button-link" href="${sharedActionReviewPath(view.id)}">${reviewWords(sharedActionPayload(payload))}</a>${dismissForm(html `<button class="quiet">Dismiss</button>`)}`
727
728
  : view.kind === "control"
728
729
  ? (isChatControl(payload["control"]) ? html `<a class="button-link" href="${chatControlHref(payload["control"], task, payload["run"], payload["project"])}">${CHAT_CONTROLS[payload["control"]].label}</a>` : html `<p>Control unavailable.</p>`)
729
730
  : view.kind === "cancel"
@@ -758,7 +759,7 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
758
759
  const sentence = (words) => words.charAt(0).toUpperCase() + words.slice(1);
759
760
  const pending = view.state === "pending" && !inert;
760
761
  const primary = !pending ? null
761
- : reviewAction !== null && sharedActionNeedsReview(reviewAction) ? { kind: "link", label: "Review action", href: sharedActionReviewPath(view.id) }
762
+ : reviewAction !== null && sharedActionNeedsReview(reviewAction) ? { kind: "link", label: reviewWords(reviewAction), href: sharedActionReviewPath(view.id) }
762
763
  : view.kind === "control" ? (isChatControl(payload["control"]) ? { kind: "link", label: CHAT_CONTROLS[payload["control"]].label, href: chatControlHref(payload["control"], task, payload["run"], payload["project"]) } : null)
763
764
  : view.kind === "cancel" ? { kind: "link", label: "Open task", href: taskHref(task) }
764
765
  : { kind: "confirm", label: sentence(presentation.action), irreversible, native: view.kind === "task_action" && payload["operation"] === "resume" };
@@ -777,6 +778,10 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
777
778
  };
778
779
  return { html: cardHtml, card };
779
780
  }
781
+ /** The review link's words: a Start card's is "Review and start". */
782
+ function reviewWords(action) {
783
+ return action?.operation === "task_start" ? "Review and start" : "Review action";
784
+ }
780
785
  export function leadProposalCard(proposal, inert, decision, returnTo = null) {
781
786
  return leadProposalCardParts(proposal, inert, decision, returnTo).html;
782
787
  }
@@ -981,7 +986,7 @@ export function leadPage(chrome, data) {
981
986
  data.session === null ? "" : html `<details class="lead-follow"><summary>Automatic crew updates${data.follow?.enabled ? ' · On' : ''}</summary><p class="meta">${data.follow?.detail ?? 'Automatic crew updates are off.'}</p>${postForm("/chat/mate/follow", html `${data.follow?.enabled ? '' : html `<p>The lead responds when results or decisions arrive. Uses this conversation’s ${subscription ? 'membership usage, with no dollar maximum' : 'remaining spend allowance'} and daily turn limit. Existing task approvals still apply.</p>`}<button type="submit" class="quiet">${data.follow?.enabled ? 'Pause updates' : 'Enable updates'}</button>`, { returnTo, hidden: { enabled: data.follow?.enabled ? 'no' : 'yes' } })}</details>`,
982
987
  html `<details class="chat-limits chat-session-details"><summary>Conversation details<span class="meta">${subscription ? "membership" : data.config.provider}</span></summary>\n${joinHtml([
983
988
  html `<div class="chat-budget"><span class="mono">${data.config.provider} · ${data.config.model}</span><span>${data.turnsToday} / ${data.config.dailyTurns} turns today</span>${subscription
984
- ? html `<span>membership login · no dollar ceiling</span>`
989
+ ? html `<span>membership login · no dollar limit</span>`
985
990
  : html `<span>this conversation: ${chatMoney(data.session?.spentMicrousd ?? 0)} of ${chatMoney(data.session?.ceilingMicrousd ?? 0)}</span><span>this week ${chatMoney(data.weeklySpent)} of ${chatMoney(data.config.weeklyCeilingMicrousd)}</span>`}</div>`,
986
991
  data.session === null
987
992
  ? html `<p class="meta">No conversation yet. Send a message to start one.</p>`
@@ -439,7 +439,7 @@ export function workPage(chrome, data) {
439
439
  ['finished', 'Finished', data.board.finished, 'completed', 'Nothing has finished yet.'],
440
440
  ].map(([key, label, page, view, empty]) => ({ key, label, count: page.totals[view], href: query({ view, layout: 'list' }), empty, rows: page.items.map(rowOf) }));
441
441
  const putAwayLink = data.canPutAway === true && data.view !== 'archived' ? { label: 'Archive old tasks', href: '/tasks/archive' } : null;
442
- const toolLinks = [['/tasks?view=order', 'Order the queue'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/tasks?scope=all', 'Every project']]), ['/ledger', 'Action ledger']];
442
+ const toolLinks = [['/tasks?view=order', 'Order the queue'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/tasks?scope=all', 'Every project']]), ['/ledger', 'Audit log']];
443
443
  const newTaskHref = lens === null ? '/tasks/new' : `/tasks/new?project=${encodeURIComponent(lens)}`;
444
444
  const emptyNow = data.work.items.length > 0 ? null : filtered && !data.previous ? { text: `No tasks are ${data.state} in this view.`, action: { label: 'Show every state', href: href(data.view, undefined, null) } } : {
445
445
  text: data.previous ? 'There are no more tasks on this page.' : current.empty,
@@ -496,7 +496,7 @@ export function workPage(chrome, data) {
496
496
  ? html `<div class="work-empty" data-work-empty="${data.view}"><p>${data.previous ? 'There are no more tasks on this page.' : current.empty}</p>${data.view === 'all' && !data.previous ? html `<p><a class="button-link" href="${chrome.chat === true ? '/chat' : '/tasks/new'}">${chrome.chat === true ? 'Start in chat' : 'Add a task'}</a></p>` : html `<a href="${href('all')}">See all tasks →</a>`}</div>`
497
497
  : html `<div class="work-list">${data.work.items.map(rowHtml)}</div>`;
498
498
  const pages = !data.previous && data.work.nextCursor === null ? null : html `<nav class="row work-pagination" aria-label="Task pages">${data.previous ? html `<a class="button-link" href="${href(data.view)}">First page</a>` : null}${data.work.nextCursor === null ? null : html `<a class="button-link" rel="next" href="${href(data.view, data.work.nextCursor)}">Next page</a>`}</nav>`;
499
- const tools = html `<details class="work-tools"><summary>Work tools${CHEVRON_ICON}</summary><nav class="work-tools-menu">${[['/', 'Home'], ['/tasks?view=board', 'Board'], ['/tasks?view=order', 'Order'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/tasks?scope=all', 'Every project']]), ['/ledger', 'Action ledger']]
499
+ const tools = html `<details class="work-tools"><summary>Work tools${CHEVRON_ICON}</summary><nav class="work-tools-menu">${[['/', 'Home'], ['/tasks?view=board', 'Board'], ['/tasks?view=order', 'Order'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/tasks?scope=all', 'Every project']]), ['/ledger', 'Audit log']]
500
500
  .map(([path, label]) => html `<a href="${path}">${label}</a>`)}</nav></details>`;
501
501
  return screen('work', html `<div class="work-head"><h1>Tasks</h1>${putAwayLink === null ? null : html `<a class="button-link" href="${putAwayLink.href}" data-put-away-old>${putAwayLink.label}</a>`}${tools}</div>${limitsHtml(data.limits ?? null)}${legacyTabs}${stateLinks}${list}${pages}`, { chrome, workspace: { view } });
502
502
  }
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
+ content?: never;
18
19
  ok: false;
19
20
  message: string;
20
21
  plan?: never;
21
- content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
+ content?: never;
33
34
  ok: false;
34
35
  message: string;
35
36
  plan?: never;
36
- content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
package/dist/store.d.ts CHANGED
@@ -10,6 +10,7 @@ import { V155_STEPS } from "./fleet-schema.js";
10
10
  import { V156_STEPS } from "./fallback-schema.js";
11
11
  import { V158_STEPS, type LeadFailureDetail, type LeadRoute } from "./lead-latency-schema.js";
12
12
  import { V157_STEPS } from "./chat-live-schema.js";
13
+ import { V159_STEPS } from "./one-step-chat-schema.js";
13
14
  export { refArchived, taskArchived } from "./task-archive.js";
14
15
  import { type ChatBinding } from "./chat-core.js";
15
16
  import { ChatMessages, TELEGRAM_HOLD_REASONS, TELEGRAM_SKIPPED_ELSEWHERE, TELEGRAM_SKIPPED_OTHER_CHAT, TELEGRAM_SKIPPED_QUIET } from "./chat-messages.js";
@@ -250,7 +251,7 @@ export type SealedLedgerEntry = LedgerEntry & {
250
251
  hash: string;
251
252
  } | null;
252
253
  };
253
- export declare const SCHEMA_VERSION = 158;
254
+ export declare const SCHEMA_VERSION = 159;
254
255
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
255
256
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
256
257
  /**
@@ -442,7 +443,7 @@ export declare const V147_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
442
443
  }>>>>;
443
444
  /** The v147 migration's steps after the epoch, in order: a test seam (OpenOptions.migrationStep) may stop after each. */
444
445
  export declare const V147_STEPS: readonly ["epoch", "local-mode", "password-set", "web-session", "console-instance"];
445
- export type MigrationStep = (typeof V147_STEPS)[number] | (typeof V148_STEPS)[number] | (typeof V149_STEPS)[number] | (typeof V150_STEPS)[number] | (typeof V151_STEPS)[number] | (typeof V152_STEPS)[number] | (typeof V153_STEPS)[number] | (typeof V154_STEPS)[number] | (typeof V156_STEPS)[number] | (typeof V155_STEPS)[number] | (typeof V157_STEPS)[number] | (typeof V158_STEPS)[number];
446
+ export type MigrationStep = (typeof V147_STEPS)[number] | (typeof V148_STEPS)[number] | (typeof V149_STEPS)[number] | (typeof V150_STEPS)[number] | (typeof V151_STEPS)[number] | (typeof V152_STEPS)[number] | (typeof V153_STEPS)[number] | (typeof V154_STEPS)[number] | (typeof V156_STEPS)[number] | (typeof V155_STEPS)[number] | (typeof V157_STEPS)[number] | (typeof V158_STEPS)[number] | (typeof V159_STEPS)[number];
446
447
  /**
447
448
  * v148 (docs/plans/results-routing.md): every table the results-routing arc uses, landed at once because a current
448
449
  * store runs no DDL. Slice 1 writes run_cohort, route_record and models_generation; route_rule, route_exposure* and
@@ -532,6 +533,7 @@ export { V155_STEPS };
532
533
  export { V156_STEPS };
533
534
  export { V157_STEPS };
534
535
  export { V158_STEPS };
536
+ export { V159_STEPS };
535
537
  /**
536
538
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
537
539
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
package/dist/store.js CHANGED
@@ -15,6 +15,7 @@ import { FINDING_SOURCES, FINDING_SOURCES_BEFORE_LEAD, V155_SCHEMA, V155_STEPS }
15
15
  import { V156_SCHEMA, V156_STEPS } from "./fallback-schema.js";
16
16
  import { V158_ADDED_COLUMNS, V158_SCHEMA, V158_STEPS, LEAD_FAILURE_DETAILS, LEAD_ROUTES } from "./lead-latency-schema.js";
17
17
  import { V157_SCHEMA, V157_STEPS } from "./chat-live-schema.js";
18
+ import { V159_ADDED_COLUMNS, V159_SCHEMA, V159_STEPS } from "./one-step-chat-schema.js";
18
19
  import { refArchived, taskArchived } from "./task-archive.js";
19
20
  export { refArchived, taskArchived } from "./task-archive.js";
20
21
  import { CHAT_PROVIDERS, CHAT_SCHEMA } from "./contracts/chat-tables.js";
@@ -1148,7 +1149,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
1148
1149
  // v158 adds, and changes no saved row (docs/plans/responsive-chat.md, slice 5): the tools each lead step asked for, an
1149
1150
  // exact failure detail on a step and a reply, which model answered a reply (mate_turn.route), and the quick-answer
1150
1151
  // switch (lead_quick_setting). V158_SCHEMA, V158_ADDED_COLUMNS (lead-latency-schema.ts): saved rows read NULL.
1151
- export const SCHEMA_VERSION = 158;
1152
+ // v159 adds, and changes no saved row (docs/plans/one-step-chat.md): the lead turn a chat start's preview was prepared
1153
+ // in (fast_preview.turn) and a typed yes's record (chat_typed_start, a new table). V159_SCHEMA, V159_ADDED_COLUMNS
1154
+ // (one-step-chat-schema.ts): saved rows read NULL.
1155
+ export const SCHEMA_VERSION = 159;
1152
1156
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
1153
1157
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
1154
1158
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -1281,7 +1285,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
1281
1285
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
1282
1286
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
1283
1287
  */
1284
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158]);
1288
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159]);
1285
1289
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
1286
1290
  export const V119_ADDED_COLUMNS = Object.freeze({
1287
1291
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -1944,6 +1948,7 @@ export { V155_STEPS };
1944
1948
  export { V156_STEPS };
1945
1949
  export { V157_STEPS };
1946
1950
  export { V158_STEPS };
1951
+ export { V159_STEPS };
1947
1952
  /**
1948
1953
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
1949
1954
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -5293,6 +5298,13 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
5293
5298
  for (const [column, { definition }] of Object.entries(columns))
5294
5299
  addColumn(db, table, column, definition);
5295
5300
  step("lead-latency-columns");
5301
+ // v159: one-step start from chat (V159_SCHEMA, V159_ADDED_COLUMNS: saved rows read NULL). Idempotent, as v158's are.
5302
+ db.exec(V159_SCHEMA);
5303
+ step("one-step-chat-tables");
5304
+ for (const [table, columns] of Object.entries(V159_ADDED_COLUMNS))
5305
+ for (const [column, { definition }] of Object.entries(columns))
5306
+ addColumn(db, table, column, definition);
5307
+ step("one-step-chat-columns");
5296
5308
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
5297
5309
  // nothing to reclaim.
5298
5310
  if (!fresh)
@@ -8244,6 +8256,9 @@ export class Store {
8244
8256
  throw new ReleaseSealRefused();
8245
8257
  }
8246
8258
  if (changed.changes > 0) {
8259
+ // A real seal is work a builder can take now: wake the loops (live-wakeups.md). Inside the seal's own
8260
+ // transaction, so a seal that rolls back wakes nothing. The claim re-proves the approval regardless.
8261
+ this.bumpWake();
8247
8262
  // The project's tools this yes saw (v80): a later addition needs a later yes.
8248
8263
  this.sealProjectTools(taskId, String(working["digest"]), now);
8249
8264
  // A fresh yes lifts the stale-approval hold and closes its page
@@ -11754,17 +11769,23 @@ export class Store {
11754
11769
  * enough, and neither is a stale yes).
11755
11770
  */
11756
11771
  markCapability(repo, kind, name, outcome, now, expiresAt = null) {
11757
- const { changes } = this.db
11758
- .prepare(`UPDATE capability SET status = ?, last_verified_at = ?, verified_by = ?, last_result = ?, expires_at = COALESCE(?, expires_at)
11772
+ return this.transact(() => {
11773
+ // The status before this answer, read in the same transaction: only a gap that fills wakes the loop. A
11774
+ // repeated passing probe refreshes the timestamp and says nothing new, so it must not wake (live-wakeups.md).
11775
+ const prior = this.db.prepare("SELECT status FROM capability WHERE repo = ? AND kind = ? AND name = ?").get(repo, kind, name);
11776
+ const { changes } = this.db
11777
+ .prepare(`UPDATE capability SET status = ?, last_verified_at = ?, verified_by = ?, last_result = ?, expires_at = COALESCE(?, expires_at)
11759
11778
  WHERE repo = ? AND kind = ? AND name = ?`)
11760
- .run(outcome.status, outcome.status === "verified" ? now.toISOString() : null, outcome.by, outcome.detail ?? null, expiresAt, repo, kind, name);
11761
- // A gap that just filled ends its notification episode: the next failure
11762
- // is a new fact, and must be allowed to say so.
11763
- if (Number(changes) > 0 && outcome.status === "verified") {
11764
- this.clearGapEpisode(repo, kind, name);
11765
- this.bumpWake();
11766
- }
11767
- return Number(changes) > 0;
11779
+ .run(outcome.status, outcome.status === "verified" ? now.toISOString() : null, outcome.by, outcome.detail ?? null, expiresAt, repo, kind, name);
11780
+ // A gap that just filled ends its notification episode: the next failure
11781
+ // is a new fact, and must be allowed to say so.
11782
+ if (Number(changes) > 0 && outcome.status === "verified") {
11783
+ this.clearGapEpisode(repo, kind, name);
11784
+ if (prior?.["status"] !== "verified")
11785
+ this.bumpWake();
11786
+ }
11787
+ return Number(changes) > 0;
11788
+ });
11768
11789
  }
11769
11790
  /** Set what a task needs, as `kind:name` keys, replacing what it needed before. */
11770
11791
  setRequirements(taskRef, keys, mutation = {}) {
@@ -16452,6 +16473,8 @@ export class Store {
16452
16473
  generation = generation + 1, last_error = NULL
16453
16474
  WHERE id = ? AND state = 'waiting-human'`)
16454
16475
  .run(id);
16476
+ // The person's yes makes the merge actionable now: wake the loops (live-wakeups.md). The firing CAS re-proves it.
16477
+ this.bumpWake();
16455
16478
  return { ok: true };
16456
16479
  });
16457
16480
  }