toolroll 0.9.14 → 0.9.16

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 (84) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/assignment-brief.d.ts +10 -0
  3. package/dist/assignment-brief.js +49 -26
  4. package/dist/assignment-ui.d.ts +1 -0
  5. package/dist/assignment-ui.js +6 -4
  6. package/dist/assignment.d.ts +34 -1
  7. package/dist/assignment.js +86 -7
  8. package/dist/browser/workspace.css +2 -2
  9. package/dist/browser/workspace.js +38 -38
  10. package/dist/browser-crew.d.ts +2 -1
  11. package/dist/browser-crew.js +3 -1
  12. package/dist/browser-workspace.d.ts +93 -18
  13. package/dist/chat-actions.d.ts +1 -1
  14. package/dist/chat-actions.js +6 -4
  15. package/dist/chat-ask.d.ts +28 -0
  16. package/dist/chat-ask.js +77 -0
  17. package/dist/chat-channel.js +6 -1
  18. package/dist/chat-controls.d.ts +8 -0
  19. package/dist/chat-controls.js +3 -0
  20. package/dist/chat-delivery-state.d.ts +7 -2
  21. package/dist/chat-delivery-state.js +15 -2
  22. package/dist/chat-delivery.js +20 -3
  23. package/dist/chat-quiet.js +4 -2
  24. package/dist/chat-voice.d.ts +6 -9
  25. package/dist/chat-voice.js +11 -5
  26. package/dist/demo.js +82 -2
  27. package/dist/disclosure.d.ts +6 -0
  28. package/dist/disclosure.js +13 -0
  29. package/dist/discord-chat.js +10 -0
  30. package/dist/lead-commitments.d.ts +84 -0
  31. package/dist/lead-commitments.js +208 -0
  32. package/dist/lead-context.d.ts +32 -3
  33. package/dist/lead-context.js +133 -19
  34. package/dist/lead-follow.js +3 -0
  35. package/dist/lead-identity.d.ts +23 -0
  36. package/dist/lead-identity.js +33 -0
  37. package/dist/lead-status.js +2 -2
  38. package/dist/lead-voice.d.ts +12 -9
  39. package/dist/lead-voice.js +25 -16
  40. package/dist/ledger-view.js +3 -2
  41. package/dist/mate-cli.d.ts +1 -1
  42. package/dist/mate-cli.js +8 -2
  43. package/dist/mate-contract.d.ts +1 -1
  44. package/dist/mate-contract.js +11 -4
  45. package/dist/mate-progress.js +5 -0
  46. package/dist/mate-tools.d.ts +18 -1
  47. package/dist/mate-tools.js +250 -6
  48. package/dist/mate.d.ts +9 -0
  49. package/dist/mate.js +34 -38
  50. package/dist/needs-you.d.ts +48 -0
  51. package/dist/needs-you.js +93 -1
  52. package/dist/operate.js +2 -1
  53. package/dist/project-delete.js +3 -1
  54. package/dist/result-acts.d.ts +63 -5
  55. package/dist/result-acts.js +53 -3
  56. package/dist/result-review.d.ts +4 -3
  57. package/dist/result-review.js +7 -3
  58. package/dist/retention.js +6 -0
  59. package/dist/serve.d.ts +3 -0
  60. package/dist/serve.js +1220 -694
  61. package/dist/slack-chat.js +11 -1
  62. package/dist/store.d.ts +51 -0
  63. package/dist/store.js +115 -1
  64. package/dist/surface.js +2 -2
  65. package/dist/task-status.d.ts +26 -19
  66. package/dist/task-status.js +59 -21
  67. package/dist/team-runtime.js +2 -1
  68. package/dist/teams-chat.js +4 -1
  69. package/dist/telegram-mate.d.ts +4 -1
  70. package/dist/telegram-mate.js +17 -1
  71. package/dist/telegram-progress.js +3 -2
  72. package/dist/telegram.js +43 -3
  73. package/dist/test-temp.js +1 -1
  74. package/dist/transitions-recipes.d.ts +1 -1
  75. package/dist/transitions-recipes.js +5 -9
  76. package/dist/when-html.d.ts +24 -2
  77. package/dist/when-html.js +98 -11
  78. package/dist/work-index.d.ts +5 -1
  79. package/dist/work-index.js +22 -9
  80. package/dist/work-summary.d.ts +1 -1
  81. package/dist/workspace-motion.js +3 -3
  82. package/dist/workspace-ui.d.ts +44 -20
  83. package/dist/workspace-ui.js +63 -37
  84. package/package.json +1 -1
@@ -4,7 +4,8 @@ import type { WorkIndexItem, WorkIndexPage } from './work-index.js';
4
4
  /** The index is navigation only. Controls retain their exact owning task, run,
5
5
  * decision and approval ceremony; opening a result keeps its saved execution. */
6
6
  export declare function browserWorkActionHref(item: WorkIndexItem): string | null;
7
- /** Render an already-admitted page without repeating its database query. */
7
+ /** Render an already-admitted page without repeating its database query. Every task reads its one headline, the
8
+ * same words as the Tasks list, task page and result page. */
8
9
  export declare function browserCrewFromIndex(page: WorkIndexPage, conversationId?: string): {
9
10
  crew: BrowserCrewItem[];
10
11
  crewTruncated: boolean;
@@ -32,7 +32,8 @@ export function browserWorkActionHref(item) {
32
32
  : action.code === 'start-worker' || action.code === 'repair-dependency' ? '#run-status' : '';
33
33
  return `/t/${encodeURIComponent(item.rootId)}?version=${encodeURIComponent(taskId)}` + anchor;
34
34
  }
35
- /** Render an already-admitted page without repeating its database query. */
35
+ /** Render an already-admitted page without repeating its database query. Every task reads its one headline, the
36
+ * same words as the Tasks list, task page and result page. */
36
37
  export function browserCrewFromIndex(page, conversationId) {
37
38
  const link = (href) => conversationId && href.startsWith('/chat?') ? href + '&conversation=' + encodeURIComponent(conversationId) : href;
38
39
  const rows = page.items.map(summary => {
@@ -46,6 +47,7 @@ export function browserCrewFromIndex(page, conversationId) {
46
47
  id: summary.rootId, title: summary.title, project: summary.repo,
47
48
  state: summary.assignmentState, label: status.label, tone: status.tone, href: link(href), resultHref: resultHref === null ? null : link(resultHref),
48
49
  action: action === null || actionHref === null ? null : { label: action.label, href: link(actionHref) },
50
+ ...(summary.lead === undefined ? {} : { lead: summary.lead }),
49
51
  };
50
52
  return { item, rank: status.rank };
51
53
  });
@@ -7,11 +7,11 @@ export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
7
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
- import type { Ask } from './needs-you.js';
10
+ import type { Ask, AskChip } from './needs-you.js';
11
11
  import type { WorkIndexGroup } from './work-index.js';
12
12
  import type { AssignmentCard } from './assignment-ui.js';
13
13
  import type { TaskStatus } from './task-status.js';
14
- import type { ResultActs } from './result-acts.js';
14
+ import type { AcceptLabel, ResultActFacts, ResultActs } from './result-acts.js';
15
15
  import type { FirstRunStep, FirstTaskSuggestion, JourneyStep } from './first-run.js';
16
16
  export type BrowserProject = {
17
17
  name: string;
@@ -46,6 +46,8 @@ export type BrowserCrewItem = {
46
46
  label: string;
47
47
  href: string;
48
48
  } | null;
49
+ /** "<name> is on it.": the person's own lead took it on, by the name they gave it. */
50
+ lead?: string;
49
51
  };
50
52
  export type BrowserMessage = {
51
53
  id: number;
@@ -125,8 +127,10 @@ export type BrowserTasksView = {
125
127
  tone: StatusTone;
126
128
  token: string;
127
129
  };
128
- /** What a Needs you row asks (its chip); null for every other row. */
130
+ /** What a Needs you row asks: its group. */
129
131
  ask: Ask | null;
132
+ /** The row's chip under its group: the specific ask (Plan, Result, Mismatch, Plan changed, Failed, Builder offline), or none. */
133
+ chip: AskChip | null;
130
134
  group: BrowserTaskGroup;
131
135
  action: BrowserLink | null;
132
136
  detail: string | null;
@@ -367,17 +371,41 @@ export type BrowserTaskView = {
367
371
  rebuild?: {
368
372
  action: string;
369
373
  } | null;
370
- /** A failed task's recorded reason in one line (the run's reason, the failing check's last error line, or that none was
371
- * recorded) and where its log lines are; and Retry itself, with an optional note. */
372
- failure?: {
374
+ /** What a failed task missed, in one line (the requirement it missed, the failing check's error line, or the stop
375
+ * reason), the evidence line behind it, one suggestion of what to change, and where to see it (its build's result
376
+ * page, or that exact check-log line); and Retry itself, its note starting with the suggestion. */
377
+ failure?: BrowserFailure | null;
378
+ retry?: {
379
+ action: string;
380
+ note?: string;
381
+ } | null;
382
+ /** Run checks in place, on a status row that offers it: posts and comes back to this page. */
383
+ runChecks?: BrowserRunChecks | null;
384
+ /** A live build: the step it is on (or stuck on, with the act that helps), and the earlier attempts that stopped
385
+ * before it, folded into one quiet line. */
386
+ progress?: {
373
387
  line: string;
374
- link: {
375
- label: string;
376
- href: string;
388
+ stuck: {
389
+ step: number;
390
+ why: string | null;
391
+ line: string;
392
+ action: BrowserLink | null;
377
393
  } | null;
378
394
  } | null;
379
- retry?: {
395
+ /** Stop, on the Building card: the exact live build's stop form (posts its run id). */
396
+ stop?: {
380
397
  action: string;
398
+ run: number;
399
+ } | null;
400
+ /** The Building card's link to the live build's own record ("Build #N record", /r/<id>). */
401
+ record?: BrowserLink | null;
402
+ earlier?: {
403
+ summary: string;
404
+ attempts: {
405
+ label: string;
406
+ href: string;
407
+ text: string | null;
408
+ }[];
381
409
  } | null;
382
410
  /** Server cards that may need a person now (stop/resume, scope prompt, plan, live attempt). */
383
411
  lead: {
@@ -470,6 +498,12 @@ export type BrowserResultPanel = {
470
498
  returnTo: string;
471
499
  } | null;
472
500
  } | null;
501
+ /** The Requirements row's counts, so it updates as the person answers their checks (null when it doesn't count them). */
502
+ requirements?: {
503
+ met: number;
504
+ total: number;
505
+ yours: number;
506
+ } | null;
473
507
  /** Storage limits on saved output (shortened logs or diffs): shown on request. */
474
508
  limits: string[];
475
509
  tabs: {
@@ -518,13 +552,19 @@ export type BrowserCheckItem = {
518
552
  }[];
519
553
  };
520
554
  /** The result's one decision: what Accept is called, why it isn't plain
521
- * Accept, and what pressing it does. */
555
+ * Accept and finish, and what pressing it does. `base`: the same words before
556
+ * the person's own checks, which the page adds as they answer them. */
522
557
  export type BrowserResultDecision = {
523
- label: "Accept" | "Accept without checks";
558
+ label: AcceptLabel;
524
559
  ready: boolean;
525
560
  why: string | null;
526
561
  effect: string;
527
562
  sentence: string;
563
+ base?: {
564
+ label: AcceptLabel;
565
+ ready: boolean;
566
+ why: string | null;
567
+ };
528
568
  };
529
569
  /** A Needs you action: a link, (Confirm it stopped) a form behind the password, (Build again) one
530
570
  * button, or (on the result itself) Accept, which records the person's acceptance; `note` asks why
@@ -587,12 +627,17 @@ export type BrowserResultView = {
587
627
  detail: string;
588
628
  control: string;
589
629
  } | null;
630
+ /** Accept and finish: the exact receipt read; `accept` when the one request also records the person's acceptance
631
+ * (`note`: the words over the reason field it asks for, or null for none). */
590
632
  complete: {
591
633
  action: string;
592
634
  receipt: string;
593
635
  run: number;
636
+ accept?: {
637
+ note: string | null;
638
+ } | null;
594
639
  } | null;
595
- /** Words for the result's Accept: Mark complete when offered, else the Needs you acceptance. */
640
+ /** Words for the result's Accept: Accept and finish when offered, else the Needs you acceptance. */
596
641
  decision: BrowserResultDecision | null;
597
642
  checks: {
598
643
  detail: string;
@@ -617,12 +662,24 @@ export type BrowserResultView = {
617
662
  } | null;
618
663
  /** The one ink act that resolves the result, the one outline act beside it, and why it can't be accepted yet (result-acts.ts). */
619
664
  acts: ResultActs;
665
+ /** The facts the acts were chosen from, so the page chooses again as the person answers their checks. */
666
+ actFacts?: ResultActFacts;
620
667
  /** Run checks on this result's commit: the project's check, when it didn't run. */
621
- runChecks: {
622
- action: string;
623
- level: "quick" | "full";
624
- returnTo: string;
625
- } | null;
668
+ runChecks: BrowserRunChecks | null;
669
+ /** A failed build's result: what it missed and the suggestion Retry's note starts with; Retry itself when the task
670
+ * can be retried from here. Null for every other result. */
671
+ failure?: (BrowserFailure & {
672
+ retry: {
673
+ action: string;
674
+ note: string;
675
+ } | null;
676
+ /** A failed task's delivered result, accepted only in outline with a reason (the task's accept-proof). */
677
+ acceptAnyway?: {
678
+ action: string;
679
+ run: number;
680
+ returnTo: string;
681
+ } | null;
682
+ }) | null;
626
683
  /** The raw run record, under Details: its facts and the full record. */
627
684
  record: {
628
685
  build: number;
@@ -635,6 +692,7 @@ export type BrowserResultView = {
635
692
  /** The signed scope; null when none was filed. */
636
693
  intent: {
637
694
  approval: string;
695
+ approvedAt: string | null;
638
696
  html: string;
639
697
  } | null;
640
698
  noRun: string | null;
@@ -647,6 +705,20 @@ export type BrowserResultView = {
647
705
  }[];
648
706
  } | null;
649
707
  };
708
+ export type BrowserFailure = {
709
+ line: string;
710
+ evidence: string | null;
711
+ suggestion: string;
712
+ link: {
713
+ label: string;
714
+ href: string;
715
+ } | null;
716
+ };
717
+ export type BrowserRunChecks = {
718
+ action: string;
719
+ level: "quick" | "full";
720
+ returnTo: string;
721
+ };
650
722
  /** One zone on a flow's canvas: its step, where it leads, and where it sits. */
651
723
  export type BrowserFlowStage = {
652
724
  id: string;
@@ -954,6 +1026,8 @@ export type BrowserWorkspace = {
954
1026
  user: string;
955
1027
  csrf: string;
956
1028
  sensitive: boolean;
1029
+ /** What this person named their lead (Settings → Lead): the chat header and every lead message say it. */
1030
+ leadName?: string;
957
1031
  refreshUrl: string;
958
1032
  receipt: {
959
1033
  request: string;
@@ -1048,6 +1122,7 @@ export type BrowserHome = {
1048
1122
  allHref: string;
1049
1123
  /** What this person's lead is doing now and when it last acted (lead-voice.ts); its task when it named one. */
1050
1124
  lead?: {
1125
+ name?: string;
1051
1126
  doing: string;
1052
1127
  at: string;
1053
1128
  href: string | null;
@@ -202,7 +202,7 @@ export declare const CHAT_ACTIONS: {
202
202
  readonly password: true;
203
203
  };
204
204
  readonly result_accept: {
205
- readonly label: "Mark complete";
205
+ readonly label: "Accept and finish";
206
206
  readonly protected: true;
207
207
  readonly password: false;
208
208
  };
@@ -12,7 +12,7 @@ import { approve, authenticateApprover, describeScope, scopeAuthorityOf, } from
12
12
  import { readVerifiedArtifact, scanForSecrets } from "./evidence.js";
13
13
  import { readAuthModeStrict } from "./keys.js";
14
14
  import { parseProof } from "./proof.js";
15
- import { assignmentOf, checkAssignmentAsOperator } from "./assignment.js";
15
+ import { acceptAndCompleteAsOperator, assignmentOf } from "./assignment.js";
16
16
  import { getDecision, recordDecision, retireDecision } from "./project-memory.js";
17
17
  import { resumeTaskStop, taskControlOf } from "./task-control.js";
18
18
  import { addToolTo, catalogTool, projectToolsOf, removeToolFrom, toolCommandLine, validateToolSpec } from "./project-tools.js";
@@ -108,7 +108,7 @@ export const CHAT_ACTIONS = {
108
108
  decision_record: { label: "Record decision", protected: false, password: false },
109
109
  decision_retire: { label: "Retire decision", protected: false, password: false },
110
110
  scope_approve: { label: "Approve work", protected: true, password: true },
111
- result_accept: { label: "Mark complete", protected: true, password: false },
111
+ result_accept: { label: "Accept and finish", protected: true, password: false },
112
112
  task_cancel: { label: "Cancel task", protected: true, password: false },
113
113
  task_resume: { label: "Resume task", protected: true, password: true },
114
114
  };
@@ -1098,7 +1098,9 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1098
1098
  throw Error("The comparison terms changed. Nothing was approved.");
1099
1099
  }
1100
1100
  else if (payload.operation === "result_accept") {
1101
- const completed = checkAssignmentAsOperator(store, task, String(payload.state["receipt"] ?? ""), who, now, options.root);
1101
+ // Accept and finish, as on the result page: whether a check only the person makes is accepted
1102
+ // too is decided inside the same transaction, against the receipt as it stands.
1103
+ const completed = acceptAndCompleteAsOperator(store, task, { runId: null, receiptDigest: String(payload.state["receipt"] ?? ""), note: null }, who, now, options.root);
1102
1104
  if (!completed.ok)
1103
1105
  throw Error(completed.message);
1104
1106
  }
@@ -1125,7 +1127,7 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1125
1127
  const said = payload.operation === "skill_test"
1126
1128
  ? "Skill test created. Existing approval rules apply."
1127
1129
  : payload.operation === "result_accept"
1128
- ? "Marked complete. The recorded checks are unchanged."
1130
+ ? "Accepted and finished. The recorded checks are unchanged."
1129
1131
  : payload.operation === "scope_approve"
1130
1132
  ? "The exact work is approved."
1131
1133
  : payload.operation === "task_cancel"
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The lead's question to its owner (ask_owner) in Slack, Discord and Teams.
3
+ *
4
+ * The reply's last part carries the question with one button per option and
5
+ * one for "Something else", minted when the part is planned (chat_ask_action).
6
+ * A tapped option is sent as the owner's next message in their own chat with
7
+ * Toolroll, so the lead reads it like anything they typed; "Something else"
8
+ * asks them to type it. Buttons are drawn only in that own chat (any thread of
9
+ * it): in a room the question goes out as text, since a tap there is not the
10
+ * owner's own message. A button works once, only for the person the lead
11
+ * asked, and only while nobody has written in that thread since.
12
+ */
13
+ import type { ChatBinding, ChatEvent, ChatState } from "./chat-delivery-state.js";
14
+ import { type MateAsk, type Store } from "./store.js";
15
+ /** The question as text with its options, where no buttons are drawn. */
16
+ export declare function chatAskText(ask: Pick<MateAsk, "question" | "options">): string;
17
+ /** The live buttons on one part, in the order they were minted: the options, then "Something else". None when the part
18
+ * is not in its owner's own chat with Toolroll (a room), where a tap could not be their message. */
19
+ export declare function chatAskButtons(state: ChatState, part: number, now: Date): Array<{
20
+ token: string;
21
+ label: string;
22
+ words: boolean;
23
+ }>;
24
+ /** A tapped ask button, inside the action's transaction; false when the token isn't one. */
25
+ export declare function applyChatAskTap(options: {
26
+ store: Store;
27
+ state: ChatState;
28
+ }, event: ChatEvent, binding: ChatBinding, token: string, now: Date): boolean;
@@ -0,0 +1,77 @@
1
+ import { chatHash } from "./chat-delivery-state.js";
2
+ import { MATE_ASK_OTHER } from "./store.js";
3
+ /** The question as text with its options, where no buttons are drawn. */
4
+ export function chatAskText(ask) {
5
+ return [ask.question, ...ask.options.map(one => `• ${one}`), `• ${MATE_ASK_OTHER}: say it in your own words`].join("\n");
6
+ }
7
+ /** The live buttons on one part, in the order they were minted: the options, then "Something else". None when the part
8
+ * is not in its owner's own chat with Toolroll (a room), where a tap could not be their message. */
9
+ export function chatAskButtons(state, part, now) {
10
+ const row = state.prepare("SELECT p.payload,e.channel,b.channel AS own FROM chat_part p JOIN chat_event e ON e.id=p.event JOIN chat_binding b ON b.id=e.binding WHERE p.id=?").get(part);
11
+ if (row === undefined)
12
+ return [];
13
+ const content = JSON.parse(String(row["payload"]));
14
+ if ((content.channel ?? row["channel"]) !== row["own"])
15
+ return [];
16
+ const options = content.ask?.options ?? [];
17
+ return state.prepare("SELECT token,choice FROM chat_ask_action WHERE part=? AND consumed IS NULL AND expires>? ORDER BY rowid").all(part, now.toISOString())
18
+ .map(one => {
19
+ const label = one.choice === null ? MATE_ASK_OTHER : options[Number(one.choice)] ?? null;
20
+ return label === null ? null : { token: String(one.token), label: label.slice(0, 75), words: one.choice === null };
21
+ })
22
+ .filter((one) => one !== null);
23
+ }
24
+ /** Show the question again with what happened, and without its buttons. */
25
+ function repaint(state, part, event, line) {
26
+ const row = state.prepare("SELECT payload FROM chat_part WHERE id=?").get(part);
27
+ const before = row === undefined ? { text: "" } : JSON.parse(String(row["payload"]));
28
+ const content = { text: `${before.text}\n\n${line}`.slice(0, 3400), edit: event.ts, ...(before.channel === undefined ? {} : { channel: before.channel }) };
29
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL WHERE id=?").run(JSON.stringify(content), part);
30
+ }
31
+ /** A tapped ask button, inside the action's transaction; false when the token isn't one. */
32
+ export function applyChatAskTap(options, event, binding, token, now) {
33
+ const { store, state } = options;
34
+ const action = state.prepare("SELECT a.*,p.message,e.binding AS owner,e.channel FROM chat_ask_action a JOIN chat_part p ON p.id=a.part JOIN chat_event e ON e.id=p.event WHERE a.token=?").get(token);
35
+ if (action === undefined)
36
+ return false;
37
+ const say = (text) => state.plan(event.id, [{ text }], now);
38
+ // Bound to the person, their own chat and the exact message the button rode.
39
+ if (Number(action["owner"]) !== binding.id || action["channel"] !== event.channel || event.channel !== binding.channel || action["message"] !== event.ts) {
40
+ say("That button expired or was already used.");
41
+ return true;
42
+ }
43
+ const turn = Number(action["turn"]), part = Number(action["part"]);
44
+ const found = store.getMateTurn(turn)?.approver === binding.approver ? store.mateAskState(turn, now) : { state: "expired" };
45
+ const expired = found.state === "expired" || String(action["expires"]) <= now.toISOString();
46
+ if (action["consumed"] !== null || found.state !== "open" || expired) {
47
+ state.prepare("UPDATE chat_ask_action SET consumed=? WHERE part=? AND consumed IS NULL").run(now.toISOString(), part);
48
+ if (found.state === "answered" || (action["consumed"] !== null && !expired)) {
49
+ repaint(state, part, event, "This question was already answered.");
50
+ state.finish(event.id);
51
+ }
52
+ // A question this old can no longer be edited in place: the answer to the tap is a new message.
53
+ else
54
+ say("This question expired. If it still matters, send your answer as a message.");
55
+ return true;
56
+ }
57
+ const ask = found.ask;
58
+ if (action["choice"] === null) {
59
+ say("Type your answer here as your next message.");
60
+ return true;
61
+ }
62
+ const option = ask.options[Number(action["choice"])];
63
+ if (option === undefined) {
64
+ say("That option is no longer there.");
65
+ return true;
66
+ }
67
+ state.prepare("UPDATE chat_ask_action SET consumed=? WHERE part=? AND consumed IS NULL").run(now.toISOString(), part);
68
+ // The option is the owner's next message, answered like one they typed.
69
+ state.enqueue({
70
+ id: chatHash(`${state.channel}:ask:${event.id}`).slice(0, 32), installation: event.installation, binding: binding.id, kind: "message",
71
+ channel: event.channel, member: event.member, ts: event.ts, thread: event.thread,
72
+ payload: JSON.stringify({ text: option, originalLength: option.length }), created: now.toISOString(),
73
+ });
74
+ repaint(state, part, event, `You chose: ${option}`);
75
+ state.finish(event.id);
76
+ return true;
77
+ }
@@ -117,7 +117,7 @@ export function mirrorToTaskChat(store, who, taskId, surface, message, reply, no
117
117
  }
118
118
  /** The one short message a person gets when their question could not be answered. */
119
119
  export function couldNotAnswerText(reason) {
120
- return `I couldn't answer that just now: ${reason}. Ask again, or open the console.`;
120
+ return `I couldn't answer that just now: ${reason.replace(/\.$/, "")}. Ask again, or open the console.`;
121
121
  }
122
122
  export function tooLongText(length) {
123
123
  return `That message is ${length.toLocaleString("en-US")} characters; chat takes up to ${MATE_MESSAGE_MAX_CHARS.toLocaleString("en-US")}. Nothing was sent to the assistant. Send it in shorter parts, or say which part matters most.`;
@@ -511,6 +511,11 @@ export const CHAT_ACTION_PARITY = {
511
511
  propose_teammate: { support: "direct", how: "Adds a teammate from a template or a soul file, changes one section of its soul file (or the whole short file), pauses, resumes or removes it, passes it a note, or answers its question for the person asked, through the shared confirm door.", gap: "Soul files longer than one message are edited on the console's teammate page; the phone changes one section at a time." },
512
512
  get_flow_insights: { support: "direct", how: "Read during a turn: where each flow's cards pass, fail or are sent back, how long they wait, how its scripts did, and a run's log.", gap: null },
513
513
  propose_flow: { support: "direct", how: "Creates or changes a flow, adds, moves, approves, sends back or cancels its cards, comments on them (@name pings that person), sets their owner, follows them, saves the project's scripts, and adds, pauses or removes its triggers, through the shared confirm door; a long drawing opens the secure review. Work a card files is an ordinary task under the usual approvals.", gap: "The flow canvas itself is on the console; the phone confirms cards but draws nothing. Webhook addresses and the Linear key are set on the console's Triggers panel." },
514
+ commit_to: { support: "direct", how: "Records what the lead promised to follow up on (a task, attempt, check or time; 7 days at most). The follow pass says one line when it is met, in the shared conversation and on the chat the promise was made on.", gap: null },
515
+ release_commitment: { support: "direct", how: "Stops following up on one of the person's open promises, with the reason.", gap: null },
516
+ remember: { support: "direct", how: "Proposes a correction or lasting preference as a decision or project instruction card, through the shared confirm door.", gap: null },
517
+ get_integrations: { support: "direct", how: "Read during a turn: which chat apps, email, GitHub, project tools and monitoring are Connected, Not set up or Broken, as Settings → Integrations shows them.", gap: null },
518
+ ask_owner: { support: "direct", how: "One question with 2-4 options and Something else, drawn as buttons under the reply; the tapped option is sent as the person's next message.", gap: null },
514
519
  search_project_memory: { support: "direct", how: "Read during a turn: one search over decisions, references, lessons and the conversations the person may read.", gap: null },
515
520
  list_tasks: { support: "direct", how: "Read during a turn.", gap: null },
516
521
  get_task: {
@@ -66,6 +66,14 @@ export declare const CHAT_CONTROLS: {
66
66
  readonly label: "Manage Discord";
67
67
  readonly href: "/settings/discord";
68
68
  };
69
+ readonly lead: {
70
+ readonly label: "Name your lead";
71
+ readonly href: "/settings/lead";
72
+ };
73
+ readonly integrations: {
74
+ readonly label: "Set up integrations";
75
+ readonly href: "/settings/integrations";
76
+ };
69
77
  readonly settings: {
70
78
  readonly label: "Open settings";
71
79
  readonly href: "/settings";
@@ -18,6 +18,9 @@ export const CHAT_CONTROLS = {
18
18
  workers: { label: "Manage workers", href: "/fleet" },
19
19
  slack: { label: "Manage Slack", href: "/settings/slack" },
20
20
  discord: { label: "Manage Discord", href: "/settings/discord" },
21
+ // Settings → Lead: the lead's name and persona, changed there by its person.
22
+ lead: { label: "Name your lead", href: "/settings/lead" },
23
+ integrations: { label: "Set up integrations", href: "/settings/integrations" },
21
24
  settings: { label: "Open settings", href: "/settings" },
22
25
  permissions: { label: "Review permissions", href: "/settings" },
23
26
  providers: { label: "Connect an agent", href: "/settings#providers" },
@@ -1,4 +1,4 @@
1
- import type { Store } from "./store.js";
1
+ import { type Store } from "./store.js";
2
2
  export type ChatIdentity = {
3
3
  installation: string;
4
4
  team?: string;
@@ -76,7 +76,7 @@ export type ChatContent = {
76
76
  label: string;
77
77
  path: string;
78
78
  };
79
- /** More link buttons after `link`: [Look first] beside [Merge] or [Mark complete]. */
79
+ /** More link buttons after `link`: [Look first] beside [Merge] or [Accept and finish]. */
80
80
  also?: Array<{
81
81
  label: string;
82
82
  path: string;
@@ -95,6 +95,11 @@ export type ChatContent = {
95
95
  label: string;
96
96
  }>;
97
97
  };
98
+ /** The lead's question to its owner rides this part: its options, then "Something else". */
99
+ ask?: {
100
+ turn: number;
101
+ options: string[];
102
+ };
98
103
  };
99
104
  export declare const chatHash: (text: string) => string;
100
105
  export declare class ChatState {
@@ -1,5 +1,6 @@
1
1
  /** Durable private-chat receipts. Each transport has its own tables and lease. */
2
2
  import { createHash, randomBytes } from "node:crypto";
3
+ import { MATE_ASK_TTL_MS } from "./store.js";
3
4
  const CHAT_SCHEMA = `
4
5
  CREATE TABLE IF NOT EXISTS chat_binding (
5
6
  id INTEGER PRIMARY KEY AUTOINCREMENT, installation TEXT NOT NULL,
@@ -93,6 +94,15 @@ CREATE TABLE IF NOT EXISTS chat_question_prompt (
93
94
  );
94
95
  CREATE INDEX IF NOT EXISTS chat_question_prompt_open ON chat_question_prompt(binding, consumed);
95
96
  `;
97
+ /** The lead's question to its owner (ask_owner) in the chat app: one button per option (choice is its index) and one for
98
+ * "Something else" (choice NULL). A tap sends the option as the owner's next message. Never in CHAT_TABLES. */
99
+ const CHAT_ASK_SCHEMA = `
100
+ CREATE TABLE IF NOT EXISTS chat_ask_action (
101
+ token TEXT PRIMARY KEY, part INTEGER NOT NULL REFERENCES chat_part(id) ON DELETE CASCADE,
102
+ turn INTEGER NOT NULL, choice INTEGER, expires TEXT NOT NULL, consumed TEXT
103
+ );
104
+ CREATE INDEX IF NOT EXISTS chat_ask_action_turn ON chat_ask_action(turn);
105
+ `;
96
106
  const CHAT_TABLES = [
97
107
  "chat_binding",
98
108
  "chat_pair",
@@ -111,7 +121,7 @@ export class ChatState {
111
121
  this.channel = channel;
112
122
  }
113
123
  prepare(sql) {
114
- return this.db.prepare(sql.replace(/\bchat_(binding|pair|event|part|action|progress|runtime|room|meta|flow_action|flow_prompt|question_action|question_prompt)\b/g, `${this.channel}_$1`));
124
+ return this.db.prepare(sql.replace(/\bchat_(binding|pair|event|part|action|progress|runtime|room|meta|flow_action|flow_prompt|question_action|question_prompt|ask_action)\b/g, `${this.channel}_$1`));
115
125
  }
116
126
  /** Small transport facts a channel must remember per chat (a Teams service URL). Never credentials. */
117
127
  meta(installation, key) {
@@ -273,6 +283,9 @@ export class ChatState {
273
283
  if (Number(inserted.changes) && part.question)
274
284
  for (const one of part.question.choices)
275
285
  this.prepare("INSERT INTO chat_question_action(token,part,question,choice,expires) VALUES(?,?,?,?,?)").run(randomBytes(16).toString("hex"), Number(inserted.lastInsertRowid), part.question.id, one.choice, new Date(now.getTime() + 7 * 86_400_000).toISOString());
286
+ if (Number(inserted.changes) && part.ask)
287
+ for (const choice of [...part.ask.options.map((_, index) => index), null])
288
+ this.prepare("INSERT INTO chat_ask_action(token,part,turn,choice,expires) VALUES(?,?,?,?,?)").run(randomBytes(16).toString("hex"), Number(inserted.lastInsertRowid), part.ask.turn, choice, new Date(now.getTime() + MATE_ASK_TTL_MS).toISOString());
276
289
  if (Number(inserted.changes) && part.flow)
277
290
  for (const action of part.flow.actions)
278
291
  this.prepare("INSERT INTO chat_flow_action(token,part,card,entry,action,expires) VALUES(?,?,?,?,?,?)").run(randomBytes(16).toString("hex"), Number(inserted.lastInsertRowid), part.flow.card, part.flow.entry, action, new Date(now.getTime() + 7 * 86_400_000).toISOString());
@@ -294,7 +307,7 @@ export class ChatState {
294
307
  return !!this.prepare("SELECT 1 FROM chat_runtime WHERE installation=? AND owner=? AND lease_until>?").get(installation, owner, now.toISOString());
295
308
  }
296
309
  }
297
- export const chatSchema = (channel) => (CHAT_SCHEMA + CHAT_ROOM_SCHEMA + CHAT_FLOW_SCHEMA + CHAT_QUESTION_SCHEMA).replaceAll("chat_", `${channel}_`);
310
+ export const chatSchema = (channel) => (CHAT_SCHEMA + CHAT_ROOM_SCHEMA + CHAT_FLOW_SCHEMA + CHAT_QUESTION_SCHEMA + CHAT_ASK_SCHEMA).replaceAll("chat_", `${channel}_`);
298
311
  export const chatTables = (channel) => CHAT_TABLES.map((name) => name.replace("chat_", `${channel}_`));
299
312
  export class ChatDeliveryError extends Error {
300
313
  code;
@@ -1,5 +1,6 @@
1
1
  /** Shared saved replies, explicit confirmations and progress for private chat transports. */
2
2
  import { answerChatQuestionPrompt, applyChatQuestionTap, questionParts } from "./teammate-question.js";
3
+ import { applyChatAskTap, chatAskText } from "./chat-ask.js";
3
4
  import { channelRepos, resolveChannelMate, proposalPreview, proposalOutcomeText, confirmedLink, confirmedCardText, replyContextFor, mirrorToTaskChat, focusContextFor, taskInCeiling, tooLongText, } from "./chat-channel.js";
4
5
  import { MATE_MESSAGE_MAX_CHARS, runMateTurn } from "./mate.js";
5
6
  import { confirmMateProposal, dismissMateProposal, } from "./mate-doors.js";
@@ -11,7 +12,9 @@ import { triggerConfigOf } from "./flow-triggers.js";
11
12
  import { telegramProgressCard } from "./telegram-progress.js";
12
13
  import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
13
14
  import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
14
- import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText } from "./lead-voice.js";
15
+ import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
16
+ import { leadChannelOf } from "./lead-context.js";
17
+ import { promiseChannelOf } from "./lead-commitments.js";
15
18
  import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
16
19
  import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
17
20
  import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
@@ -258,6 +261,8 @@ export async function processChatEvent(options) {
258
261
  .prepare("UPDATE chat_event SET payload=? WHERE id=? AND state='queued'")
259
262
  .run(JSON.stringify({ ...input, about }), event.id);
260
263
  }
264
+ // An unknown surface names no channel rather than a guess.
265
+ const channel = leadChannelOf(options.label);
261
266
  const outcome = await runMateTurn({
262
267
  store,
263
268
  who: resolved.who,
@@ -276,6 +281,7 @@ export async function processChatEvent(options) {
276
281
  clock: () => nowOf(options),
277
282
  evidenceRoot: options.evidenceRoot,
278
283
  mediaDelivery: "documents",
284
+ ...(channel === undefined ? {} : { channel }),
279
285
  revalidate: async () => {
280
286
  try {
281
287
  await channelAccess(options, binding, resolved.who.ceilingDigest);
@@ -337,6 +343,12 @@ export async function processChatEvent(options) {
337
343
  .find((message) => message.turn === turn.id && message.role === "assistant")?.text ?? "The reply is no longer in the saved thread.";
338
344
  // The lead's answer about one task names it: a reply to it stays on that task.
339
345
  const parts = splitChatText(reply, options.partSize).map((text) => (about === null ? { text } : { text, task: about.task, ...(about.run === null ? {} : { run: about.run }) }));
346
+ // The lead's question to its owner: its options as buttons, then "Something else", in their own chat (any thread);
347
+ // in a room a tap is not their message, so the question and its options go out as text.
348
+ const ask = store.mateAsk(turn.id);
349
+ if (ask !== null)
350
+ parts.push({ ...(event.channel === binding.channel ? { text: phoneText(ask.question, 1000), ask: { turn: ask.turn, options: ask.options } } : { text: phoneText(chatAskText(ask), 1000) }),
351
+ ...(about === null ? {} : { task: about.task, ...(about.run === null ? {} : { run: about.run }) }) });
340
352
  for (const image of store.listMateTurnEvidence(turn.id))
341
353
  parts.push({
342
354
  text: image.caption,
@@ -432,6 +444,9 @@ export function applyChatAction(options, event, binding, repos) {
432
444
  // A teammate's question's button (v93) is answered by the question's own door.
433
445
  if (applyChatQuestionTap({ store, state, label: options.label }, event, binding, token, now))
434
446
  return;
447
+ // The lead's question to its owner: the tapped option becomes their next message.
448
+ if (applyChatAskTap({ store, state }, event, binding, token, now))
449
+ return;
435
450
  const action = state
436
451
  .prepare("SELECT a.*,p.message,e.binding,e.channel,e.thread FROM chat_action a JOIN chat_part p ON p.id=a.part JOIN chat_event e ON e.id=p.event WHERE token=?")
437
452
  .get(token);
@@ -665,6 +680,8 @@ export async function planChatNotifications(options) {
665
680
  const personal = notification.recipient !== null;
666
681
  if (notification.createdAt >= binding.created &&
667
682
  notification.resolvedAt === null &&
683
+ // A promise the lead made on another chat is reported there (lead-commitments.ts).
684
+ (promiseChannelOf(notification) ?? state.channel) === state.channel &&
668
685
  // Pings follow responsibility: the lead's work, this person's own act and a muted project stay in the console.
669
686
  store.pingAllowed(notification, binding.approver) &&
670
687
  // A flow decision for "anyone who approves" reaches every approver who can see the project.
@@ -680,7 +697,7 @@ export async function planChatNotifications(options) {
680
697
  const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
681
698
  if (notification.kind === LEAD_SAY_KIND) {
682
699
  // The lead's words (lead-voice.ts): one message, repainted in place when a later say joins it.
683
- const content = { text: leadSayText(notification),
700
+ const content = { text: leadSayText(store, notification),
684
701
  ...(notification.link ? { link: { label: "Open", path: notification.link } } : {}) };
685
702
  const earlier = leadSayEarlier(store, notification).map(one => state.prepare("SELECT id FROM chat_part WHERE event=?")
686
703
  .get(chatHash(`${options.state.channel}:notice:${binding.id}:${one}`))).find(one => one !== undefined);
@@ -784,7 +801,7 @@ export async function planChatNotifications(options) {
784
801
  state.plan(id, [
785
802
  {
786
803
  // Never the task's id or a "— revision" suffix (chat-voice.ts).
787
- text: chatText(phoneText(notification.body === "" ? notification.subject : `${notification.subject}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
804
+ text: chatText(phoneText(notification.body === "" ? leadSubjectOf(store, notification, binding.approver) : `${leadSubjectOf(store, notification, binding.approver)}\n\n${notification.body}`, 2500), notification.taskId === null ? [] : [{ id: notification.taskId, title: chatTitle(store, notification.taskId) }]),
788
805
  ...(notification.taskId ? { task: notification.taskId } : {}),
789
806
  ...(run ? { run: run.id } : {}),
790
807
  ...(notification.link
@@ -11,6 +11,7 @@ import { assignmentStatusFacts, headlineEmoji, taskStatusOf } from "./task-statu
11
11
  import { asksToFinish, batchLine, chatText, chatTitle, factLinkLabel, mentions, READY_ACTIONS } from "./chat-voice.js";
12
12
  import { phoneText, projectLabel } from "./telegram-status.js";
13
13
  import { isLifecycleNotification } from "./store.js";
14
+ import { leadSubjectOf } from "./lead-voice.js";
14
15
  /** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
15
16
  * attention fact, a result Ready for review, a security alert or release, and anything addressed to one person
16
17
  * (a sign-in or plan-limit pause, an evening digest). */
@@ -128,7 +129,8 @@ function lineOf(store, item, now, root, viewer) {
128
129
  const row = item.notification === null ? null : store.notificationById(item.notification);
129
130
  if (row !== null && row.kind !== "run-finished") {
130
131
  // Never the task's id or a "— revision" suffix; the title once, in front unless the words already name it.
131
- const words = chatText(phoneText(row.body === "" ? row.subject : `${row.subject}\n\n${row.body}`, 2500), [{ id: ref.externalId, title: summary }]);
132
+ const subject = leadSubjectOf(store, row, viewer);
133
+ const words = chatText(phoneText(row.body === "" ? subject : `${subject}\n\n${row.body}`, 2500), [{ id: ref.externalId, title: summary }]);
132
134
  const { phrase, headline } = updatePhrase(row);
133
135
  return { also: [], link: row.link === null ? { label: "Open task", path: chatControlHref("task", ref.externalId) } : { label: factLinkLabel(row.link), path: row.link },
134
136
  fact: { summary, headline, checks: null, report: false, completedBy: null, update: { words: mentions(words, summary) ? words : `${summary} · ${words}`, phrase } } };
@@ -149,7 +151,7 @@ function lineOf(store, item, now, root, viewer) {
149
151
  pullRequest,
150
152
  // The lead speaks as "I" for what it did: the work it filed or approved, or the completion it recorded.
151
153
  lead: card.status.headline === "Complete" ? card.completedByLead : store.leadWorkOf(item.taskRef) !== null,
152
- ...(card.facts.lead === "on-it" ? { leadOnIt: true } : {}),
154
+ ...(card.facts.lead === "on-it" ? { leadOnIt: true, ...(card.facts.leadName === undefined ? {} : { leadName: card.facts.leadName }) } : {}),
153
155
  };
154
156
  // A result ready for a person offers its real next step, then a look first.
155
157
  if (asksToFinish(fact))