toolroll 0.9.15 → 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 (67) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/assignment-brief.d.ts +10 -0
  3. package/dist/assignment-brief.js +49 -26
  4. package/dist/assignment.d.ts +8 -7
  5. package/dist/assignment.js +13 -8
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +22 -22
  8. package/dist/browser-crew.js +1 -0
  9. package/dist/browser-workspace.d.ts +5 -0
  10. package/dist/chat-ask.d.ts +28 -0
  11. package/dist/chat-ask.js +77 -0
  12. package/dist/chat-channel.js +6 -1
  13. package/dist/chat-controls.d.ts +8 -0
  14. package/dist/chat-controls.js +3 -0
  15. package/dist/chat-delivery-state.d.ts +6 -1
  16. package/dist/chat-delivery-state.js +15 -2
  17. package/dist/chat-delivery.js +20 -3
  18. package/dist/chat-quiet.js +4 -2
  19. package/dist/chat-voice.d.ts +3 -6
  20. package/dist/chat-voice.js +8 -2
  21. package/dist/discord-chat.js +10 -0
  22. package/dist/lead-commitments.d.ts +84 -0
  23. package/dist/lead-commitments.js +208 -0
  24. package/dist/lead-context.d.ts +32 -3
  25. package/dist/lead-context.js +133 -19
  26. package/dist/lead-follow.js +3 -0
  27. package/dist/lead-identity.d.ts +23 -0
  28. package/dist/lead-identity.js +33 -0
  29. package/dist/lead-voice.d.ts +12 -9
  30. package/dist/lead-voice.js +25 -16
  31. package/dist/ledger-view.js +3 -2
  32. package/dist/mate-cli.d.ts +1 -1
  33. package/dist/mate-cli.js +8 -2
  34. package/dist/mate-contract.d.ts +1 -1
  35. package/dist/mate-contract.js +11 -4
  36. package/dist/mate-progress.js +5 -0
  37. package/dist/mate-tools.d.ts +18 -1
  38. package/dist/mate-tools.js +250 -6
  39. package/dist/mate.d.ts +9 -0
  40. package/dist/mate.js +34 -38
  41. package/dist/operate.js +2 -1
  42. package/dist/project-delete.js +3 -1
  43. package/dist/result-acts.d.ts +14 -0
  44. package/dist/result-acts.js +19 -0
  45. package/dist/retention.js +6 -0
  46. package/dist/serve.js +111 -29
  47. package/dist/slack-chat.js +10 -0
  48. package/dist/store.d.ts +51 -0
  49. package/dist/store.js +115 -1
  50. package/dist/surface.js +2 -2
  51. package/dist/task-status.d.ts +5 -15
  52. package/dist/task-status.js +18 -5
  53. package/dist/team-runtime.js +2 -1
  54. package/dist/teams-chat.js +4 -1
  55. package/dist/telegram-mate.d.ts +4 -1
  56. package/dist/telegram-mate.js +17 -1
  57. package/dist/telegram-progress.js +3 -2
  58. package/dist/telegram.js +43 -3
  59. package/dist/test-temp.js +1 -1
  60. package/dist/when-html.d.ts +8 -3
  61. package/dist/when-html.js +23 -3
  62. package/dist/work-index.d.ts +2 -0
  63. package/dist/work-index.js +9 -6
  64. package/dist/work-summary.d.ts +1 -1
  65. package/dist/workspace-ui.d.ts +2 -14
  66. package/dist/workspace-ui.js +4 -22
  67. package/package.json +1 -1
@@ -0,0 +1,208 @@
1
+ import { assignmentOf } from './assignment.js';
2
+ import { LEAD_SAY_KIND } from './lead-voice.js';
3
+ import { leadNameOf } from './lead-identity.js';
4
+ /** The table: `LEAD_COMMITMENT_SCHEMA` in store.ts (additive, no version bump). */
5
+ export const COMMITMENT_DAYS = 7;
6
+ const DAY_MS = 86_400_000;
7
+ /** How long an unmet condition waits before it is looked at again. */
8
+ const RECHECK_MS = 30_000;
9
+ const PASS_LIMIT = 50;
10
+ export const TASK_STATES = ['working', 'checking', 'needs-decision', 'ready-to-check', 'complete', 'cancelled'];
11
+ export const RUN_OUTCOMES = ['finished', 'built', 'failed'];
12
+ export const CHECK_RESULTS = ['passed', 'failed', 'either'];
13
+ /** Where a promise was made, and so where it is reported: `chat` is the console or terminal conversation; a phone
14
+ * or team chat also gets the line as the lead's own message, on that chat only. */
15
+ export const COMMITMENT_CHANNELS = ['chat', 'telegram', 'slack', 'discord', 'teams'];
16
+ const PROMISE_KEY = 'lead-promise:';
17
+ const STATE_WORDS = { working: 'being worked on', checking: 'being checked', 'needs-decision': 'waiting for you',
18
+ 'ready-to-check': 'ready for you to check', complete: 'complete', cancelled: 'cancelled' };
19
+ const STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
20
+ function readCondition(text) {
21
+ try {
22
+ const value = JSON.parse(text);
23
+ if (value?.kind === 'task' && typeof value.task === 'string' && Array.isArray(value.states) && value.states.every((one) => TASK_STATES.includes(one)))
24
+ return value;
25
+ if (value?.kind === 'run' && Number.isSafeInteger(value.run) && RUN_OUTCOMES.includes(value.outcome))
26
+ return value;
27
+ if (value?.kind === 'check' && CHECK_RESULTS.includes(value.result) && (typeof value.task === 'string' && Number.isSafeInteger(value.afterRun) || Number.isSafeInteger(value.run)))
28
+ return value;
29
+ if (value?.kind === 'time' && typeof value.at === 'string' && STAMP.test(value.at))
30
+ return value;
31
+ }
32
+ catch { /* unreadable: the row stays visible and is never acted on */ }
33
+ return null;
34
+ }
35
+ function readCommitment(row) {
36
+ const condition = readCondition(String(row['condition_json']));
37
+ if (condition === null)
38
+ return null;
39
+ const text = (key) => row[key] == null ? null : String(row[key]);
40
+ return { id: Number(row['id']), owner: String(row['owner']), repo: text('repo'), thread: Number(row['thread']), turn: row['turn'] == null ? null : Number(row['turn']),
41
+ channel: String(row['channel']), what: String(row['what']), condition, checkAt: String(row['check_at']), expiresAt: String(row['expires_at']),
42
+ state: String(row['state']), createdAt: String(row['created_at']), checkedAt: text('checked_at'),
43
+ closedAt: text('closed_at'), closedBy: text('closed_by'), outcome: text('outcome') };
44
+ }
45
+ /** The condition in a few words, for Settings and for the lead's own catch-up. */
46
+ export function conditionWords(store, condition) {
47
+ const title = (task) => `“${store.getTask(task)?.title ?? task}”`;
48
+ if (condition.kind === 'task')
49
+ return `when ${title(condition.task)} is ${condition.states.map(one => STATE_WORDS[one]).join(' or ')}`;
50
+ if (condition.kind === 'run')
51
+ return `when the attempt on ${attemptOf(store, condition.run)} ${condition.outcome === 'finished' ? 'finishes' : condition.outcome === 'built' ? 'finishes with a result' : 'fails'}`;
52
+ if (condition.kind === 'check') {
53
+ const on = `the checks on ${'task' in condition ? title(condition.task) : attemptOf(store, condition.run)}`;
54
+ return `when ${on} ${condition.result === 'either' ? 'finish' : condition.result === 'passed' ? 'pass' : 'fail'}`;
55
+ }
56
+ return `at ${condition.at.slice(0, 16).replace('T', ' ')} UTC`;
57
+ }
58
+ /** The task an attempt belongs to, by title. */
59
+ function attemptOf(store, run) {
60
+ const task = store.refById(store.getRun(run)?.taskRef ?? -1)?.externalId;
61
+ return task === undefined ? 'that attempt' : `“${store.getTask(task)?.title ?? task}”`;
62
+ }
63
+ /** Record a promise. The caller has already proved the task, attempt or project is within the owner's access. */
64
+ export function recordCommitment(store, input, now) {
65
+ const what = input.what.trim().replace(/\s+/g, ' ');
66
+ if (what.length < 3 || what.length > 240)
67
+ throw Error('Say what you promised in one short sentence.');
68
+ const channel = input.channel ?? 'chat';
69
+ if (!COMMITMENT_CHANNELS.includes(channel))
70
+ throw Error('Report on a chat this conversation uses.');
71
+ if (readCondition(JSON.stringify(input.condition)) === null)
72
+ throw Error('Choose a task, attempt, check or time to wait for.');
73
+ const expiresAt = new Date(now.getTime() + COMMITMENT_DAYS * DAY_MS).toISOString();
74
+ let checkAt = input.checkAt ?? now.toISOString();
75
+ if (input.condition.kind === 'time') {
76
+ if (input.condition.at > expiresAt)
77
+ throw Error(`A promise lasts ${COMMITMENT_DAYS} days; choose a time before then.`);
78
+ checkAt = input.condition.at > checkAt ? input.condition.at : checkAt;
79
+ }
80
+ if (!STAMP.test(checkAt) || checkAt > expiresAt)
81
+ throw Error(`Check within the next ${COMMITMENT_DAYS} days.`);
82
+ const id = Number(store.handle.prepare(`INSERT INTO lead_commitment (owner, repo, thread, turn, channel, what, condition_json, check_at, expires_at, created_at)
83
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.owner, input.repo, input.thread, input.turn, channel, what, JSON.stringify(input.condition), checkAt, expiresAt, now.toISOString()).lastInsertRowid);
84
+ return getCommitment(store, id);
85
+ }
86
+ export function getCommitment(store, id) {
87
+ const row = store.handle.prepare('SELECT * FROM lead_commitment WHERE id = ?').get(id);
88
+ return row === undefined ? null : readCommitment(row);
89
+ }
90
+ /** A promise only counts once the reply that made it was answered: one from a reply still being written, or one
91
+ * that never finished, was never heard. */
92
+ const HEARD = "(c.turn IS NULL OR EXISTS (SELECT 1 FROM mate_turn t WHERE t.id = c.turn AND t.state = 'answered'))";
93
+ /** The owner's open promises that they heard, oldest first. */
94
+ export function openCommitments(store, owner, limit = 20) {
95
+ return store.handle.prepare(`SELECT c.* FROM lead_commitment c WHERE c.owner = ? AND c.state = 'open' AND ${HEARD} ORDER BY c.id LIMIT ?`).all(owner, limit)
96
+ .map(readCommitment).filter((one) => one !== null);
97
+ }
98
+ /** The phone or team chat a lead message is for, when it is a met promise (`lead-promise:<channel>:<id>`); null for
99
+ * every other notification, which goes wherever it always went. */
100
+ export function promiseChannelOf(row) {
101
+ return row.dedupeKey.startsWith(PROMISE_KEY) ? row.dedupeKey.slice(PROMISE_KEY.length).split(':')[0] ?? null : null;
102
+ }
103
+ /** Stop following up. Only the owner (from Settings or through their lead) cancels; a closed one stays as it was. */
104
+ export function cancelCommitment(store, owner, id, by, reason, now) {
105
+ const changed = store.handle.prepare("UPDATE lead_commitment SET state = 'cancelled', closed_at = ?, closed_by = ?, outcome = ? WHERE id = ? AND owner = ? AND state = 'open'")
106
+ .run(now.toISOString(), by, reason.trim().slice(0, 240) || 'Cancelled.', id, owner);
107
+ return Number(changed.changes) === 1;
108
+ }
109
+ /** The newest result run for a task, for a check condition's baseline. */
110
+ export function latestResultRun(store, task, repos, now) {
111
+ return assignmentOf(store, task, now, { principal: 'operator', repos })?.receipt?.runId ?? 0;
112
+ }
113
+ /** What was observed when the condition is met, in one sentence; null while it is not. */
114
+ function observe(store, one, now, root) {
115
+ const c = one.condition, repos = one.repo === null ? [] : [one.repo];
116
+ const title = (task) => `“${store.getTask(task)?.title ?? task}”`;
117
+ if (c.kind === 'time')
118
+ return now.toISOString() >= c.at ? 'It is time.' : null;
119
+ if (c.kind === 'task') {
120
+ const a = assignmentOf(store, c.task, now, { principal: 'operator', repos }, root);
121
+ return a !== null && c.states.includes(a.state) ? `${title(a.rootId)} is ${STATE_WORDS[a.state]}.` : null;
122
+ }
123
+ if (c.kind === 'run') {
124
+ const run = store.getRun(c.run);
125
+ if (run === null || run.outcome === null)
126
+ return null;
127
+ const ended = run.outcome === 'built' ? 'finished with a result' : run.outcome === 'failed' ? 'failed' : `ended (${run.outcome})`;
128
+ if (c.outcome === 'built' && run.outcome !== 'built' || c.outcome === 'failed' && run.outcome !== 'failed')
129
+ return null;
130
+ return `The attempt on ${attemptOf(store, run.id)} ${ended}.`;
131
+ }
132
+ const matches = (status) => (status === 'passed' || status === 'failed') && (c.result === 'either' || c.result === status);
133
+ if ('task' in c) {
134
+ const a = assignmentOf(store, c.task, now, { principal: 'operator', repos }, root);
135
+ const receipt = a?.receipt;
136
+ if (!a || !receipt || receipt.runId <= c.afterRun || !matches(receipt.checks.status))
137
+ return null;
138
+ return `The checks on ${title(a.rootId)} ${receipt.checks.status}.`;
139
+ }
140
+ const check = store.runCheckFor(c.run);
141
+ if (check === null || !matches(check.status))
142
+ return null;
143
+ return `The checks on ${attemptOf(store, c.run)} ${check.status}.`;
144
+ }
145
+ /** The promise as the end of "I said I would …": "I'll tell you when…" and "Tell you when…" both read "tell you when…". */
146
+ function promised(what) {
147
+ const bare = what.replace(/^I(?:'ll| will)\s+/i, '').replace(/[.!]+$/, '');
148
+ return /^[A-Z][a-z]/.test(bare) ? bare.charAt(0).toLowerCase() + bare.slice(1) : bare;
149
+ }
150
+ /** One deterministic pass over due promises (no model): expire, drop what can no longer be delivered or seen,
151
+ * and report a met condition once — the state change and the message share one transaction. Returns how many reported. */
152
+ export function checkLeadCommitments(store, now, root) {
153
+ const at = now.toISOString();
154
+ let reported = 0;
155
+ store.handle.prepare("UPDATE lead_commitment SET state = 'expired', closed_at = ?, outcome = 'Expired after 7 days without the condition being met.' WHERE state = 'open' AND expires_at <= ?").run(at, at);
156
+ // A promise made in a reply that was not delivered (it failed, or its turn is gone) was never heard: drop it now,
157
+ // whenever it was next due, so nothing waits on it.
158
+ store.handle.prepare(`UPDATE lead_commitment SET state = 'cancelled', closed_at = ?, closed_by = 'lead', outcome = 'The reply that made this promise was not delivered.'
159
+ WHERE state = 'open' AND turn IS NOT NULL AND NOT EXISTS (SELECT 1 FROM mate_turn t WHERE t.id = lead_commitment.turn AND t.state IN ('queued', 'running', 'answered'))`).run(at);
160
+ const due = store.handle.prepare("SELECT * FROM lead_commitment WHERE state = 'open' AND check_at <= ? ORDER BY check_at, id LIMIT ?").all(at, PASS_LIMIT);
161
+ for (const row of due) {
162
+ const one = readCommitment(row);
163
+ const id = Number(row['id']);
164
+ const close = (state, outcome) => Number(store.handle.prepare("UPDATE lead_commitment SET state = ?, closed_at = ?, closed_by = 'lead', outcome = ?, checked_at = ? WHERE id = ? AND state = 'open'").run(state, at, outcome, at, id).changes) === 1;
165
+ if (one === null) {
166
+ close('cancelled', 'Its condition could not be read.');
167
+ continue;
168
+ }
169
+ // A reply still being written may yet be delivered; one that was not is dropped above.
170
+ const turn = one.turn === null ? null : store.getMateTurn(one.turn);
171
+ if (turn !== null && turn.state !== 'answered')
172
+ continue;
173
+ const account = store.accountOf(one.owner);
174
+ if (account === null || account.revokedAt !== null || one.repo !== null && !store.accountCanAccess(one.owner, one.repo)) {
175
+ close('cancelled', 'Access to this project ended.');
176
+ continue;
177
+ }
178
+ const thread = store.getMateThread(one.thread);
179
+ if (thread === null || thread.approver !== one.owner || thread.closedAt !== null) {
180
+ close('cancelled', 'The conversation it was promised in was closed.');
181
+ continue;
182
+ }
183
+ let seen;
184
+ try {
185
+ seen = observe(store, one, now, root);
186
+ }
187
+ catch {
188
+ seen = null;
189
+ }
190
+ if (seen === null) {
191
+ store.handle.prepare("UPDATE lead_commitment SET checked_at = ?, check_at = ? WHERE id = ? AND state = 'open'").run(at, new Date(now.getTime() + RECHECK_MS).toISOString(), id);
192
+ continue;
193
+ }
194
+ const line = `${seen} (I said I would ${promised(one.what)}.)`;
195
+ store.transact(() => {
196
+ if (!close('done', seen))
197
+ return;
198
+ // The shared conversation always keeps the line; a promise made on a phone or team chat is also said there, as
199
+ // the lead's own message to its owner, on that chat alone (the usual delivery receipts apply).
200
+ store.appendMateMessage({ thread: one.thread, turn: null, role: 'assistant', text: line }, now);
201
+ if (one.channel !== 'chat')
202
+ store.enqueueNotification({ dedupeKey: `${PROMISE_KEY}${one.channel}:${one.id}`, kind: LEAD_SAY_KIND, subject: leadNameOf(store, one.owner),
203
+ body: line.slice(0, 300), recipient: one.owner, source: { installation: true } }, now);
204
+ reported++;
205
+ });
206
+ }
207
+ return reported;
208
+ }
@@ -1,8 +1,37 @@
1
1
  /** Bounded DB memory shared by browser and terminal lead turns. No provider,
2
- * repository scan, mutation, or hidden approval happens while catching up. */
2
+ * repository scan, mutation, or hidden approval happens while catching up.
3
+ *
4
+ * The bundle is ordered by importance and built fresh each turn: who the lead
5
+ * is (name, persona), who it is talking to (first name, time zone, today), the
6
+ * channel, what needs them now, projects by name with their active decisions,
7
+ * then the rest. Over 8 KB, the least important goes first. */
3
8
  import type { Store } from './store.js';
4
9
  import { type AssignmentCatchUp } from './assignment-brief.js';
5
- export declare function leadContext(store: Store, repos: readonly string[], now: Date, evidenceRoot?: string): string;
10
+ export declare const LEAD_CONTEXT_MAX_BYTES = 8000;
11
+ /** Where this turn's conversation happens. */
12
+ export type LeadChannel = 'console' | 'terminal' | 'telegram' | 'slack' | 'discord' | 'teams';
13
+ export type LeadContextOptions = {
14
+ evidenceRoot?: string;
15
+ /** The person this turn talks to (their account name). */
16
+ owner?: string;
17
+ channel?: LeadChannel;
18
+ /** A shared team conversation's own lead, in place of the person's own name for it. */
19
+ leadName?: string;
20
+ /** This conversation: its open promises and the corrections confirmed since the lead's last reply. */
21
+ thread?: number;
22
+ /** Their time zone; this computer's when absent. */
23
+ timeZone?: string;
24
+ /** Scrubs every string in the bundle (paths, digests, account names); the person's first name and the project
25
+ * labels are put back afterwards, on purpose. */
26
+ redact?: (text: string) => string;
27
+ /** A project's display name, already safe to show; the bundle never carries paths. */
28
+ projectName?: (path: string, index: number) => string;
29
+ };
30
+ /** "alex.pelletier@example.com" → "Alex". */
31
+ export declare function firstNameOf(account: string): string;
32
+ /** The channel a chat surface's label names; an unknown label names none rather than a guess. */
33
+ export declare function leadChannelOf(label: string): LeadChannel | undefined;
34
+ export declare function leadContext(store: Store, repos: readonly string[], now: Date, options?: LeadContextOptions): string;
6
35
  /** The same saved brief is useful before chat spending is authorized. */
7
36
  export declare function leadBriefHtml(brief: AssignmentCatchUp): string;
8
- export declare const LEAD_CONTEXT_CSS = ".lead-brief{margin:1rem 0;min-width:0}.lead-brief h2{font-size:1.1rem;margin:0 0 .8rem}.repository-context input[name=q]{display:block;box-sizing:border-box;min-height:44px;width:100%;margin:.4rem 0 .75rem;padding:.6rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.repository-context form{margin:.5rem 0 1rem}.repository-context summary{min-height:44px;padding:.75rem 0;overflow-wrap:anywhere}.lead-brief h3{font-size:.85rem;margin:1rem 0 .4rem;color:var(--muted-foreground)}.lead-brief ul{list-style:none;margin:0;padding:0}.lead-brief li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-brief li a{display:flex;min-height:44px;align-items:center;font-weight:500;overflow-wrap:anywhere;text-decoration:none}.lead-brief li a:hover{text-decoration:underline}.lead-brief-state{font-size:.75rem;font-weight:600;padding:.15rem .5rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink);white-space:nowrap}.lead-brief-state--needs-decision,.lead-brief-state--ready-to-check{background:var(--so-attention-soft);color:var(--so-attention)}.lead-brief-state--working,.lead-brief-state--checking{background:var(--so-info-soft);color:var(--so-info)}.lead-brief-state--complete{background:var(--so-success-soft);color:var(--so-success)}.lead-brief li a.lead-brief-act{grid-column:1/-1;justify-self:start;display:inline-flex;min-height:40px;margin:.15rem 0 .4rem;font-weight:600}@media(max-width:760px){.lead-brief li a.lead-brief-act{justify-self:stretch;justify-content:center;min-height:44px}}.lead-brief-detail{grid-column:1/-1;font-size:.85rem;color:var(--muted-foreground);overflow-wrap:anywhere;padding-bottom:.35rem}@media(max-width:760px){.lead-brief{margin:.5rem 0}.lead-brief h2{margin:0 0 .25rem}.lead-brief h3{margin:.5rem 0 0}.lead-brief li{padding:0 0 .375rem}.lead-brief-detail{margin-top:-.375rem;padding-bottom:0;line-height:1.35;pointer-events:none}}";
37
+ export declare const LEAD_CONTEXT_CSS = ".lead-promise-list{list-style:none;margin:0;padding:0}.lead-promise-list li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.5rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-promise-list li:last-child{border-bottom:0}.lead-promise-list p{margin:0;overflow-wrap:anywhere}.lead-promise-list form{grid-column:2;grid-row:1/span 2;margin:0}.lead-promise-list button{min-height:44px;width:auto;white-space:nowrap}.lead-promise-list .nowrap{white-space:nowrap}.lead-brief{margin:1rem 0;min-width:0}.lead-brief h2{font-size:1.1rem;margin:0 0 .8rem}.repository-context input[name=q]{display:block;box-sizing:border-box;min-height:44px;width:100%;margin:.4rem 0 .75rem;padding:.6rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.repository-context form{margin:.5rem 0 1rem}.repository-context summary{min-height:44px;padding:.75rem 0;overflow-wrap:anywhere}.lead-brief h3{font-size:.85rem;margin:1rem 0 .4rem;color:var(--muted-foreground)}.lead-brief ul{list-style:none;margin:0;padding:0}.lead-brief li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-brief li a{display:flex;min-height:44px;align-items:center;font-weight:500;overflow-wrap:anywhere;text-decoration:none}.lead-brief li a:hover{text-decoration:underline}.lead-brief-state{font-size:.75rem;font-weight:600;padding:.15rem .5rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink);white-space:nowrap}.lead-brief-state--needs-decision,.lead-brief-state--ready-to-check{background:var(--so-attention-soft);color:var(--so-attention)}.lead-brief-state--working,.lead-brief-state--checking{background:var(--so-info-soft);color:var(--so-info)}.lead-brief-state--complete{background:var(--so-success-soft);color:var(--so-success)}.lead-brief li a.lead-brief-act{grid-column:1/-1;justify-self:start;display:inline-flex;min-height:40px;margin:.15rem 0 .4rem;font-weight:600}@media(max-width:760px){.lead-brief li a.lead-brief-act{justify-self:stretch;justify-content:center;min-height:44px}}.lead-brief-detail{grid-column:1/-1;font-size:.85rem;color:var(--muted-foreground);overflow-wrap:anywhere;padding-bottom:.35rem}@media(max-width:760px){.lead-brief{margin:.5rem 0}.lead-brief h2{margin:0 0 .25rem}.lead-brief h3{margin:.5rem 0 0}.lead-brief li{padding:0 0 .375rem}.lead-brief-detail{margin-top:-.375rem;padding-bottom:0;line-height:1.35;pointer-events:none}}";
@@ -1,30 +1,144 @@
1
- import { assignmentCatchUp } from './assignment-brief.js';
1
+ import { activeDecisionsOf, assignmentCatchUp } from './assignment-brief.js';
2
2
  import { publicChatText } from './chat-display.js';
3
- export function leadContext(store, repos, now, evidenceRoot) {
4
- const brief = assignmentCatchUp(store, now, { principal: 'operator', repos }, { limit: 8 }, evidenceRoot);
5
- const tasks = brief.assignments.map(one => ({
6
- repo: `r${repos.indexOf(one.repo ?? '') + 1}`, id: one.rootId, currentExecution: one.taskId,
3
+ import { leadIdentityOf } from './lead-identity.js';
4
+ import { conditionWords, openCommitments } from './lead-commitments.js';
5
+ const REMEMBERED = new Set(['decision_record', 'knowledge_instructions']);
6
+ /** The lead's own follow-through for one conversation: its open promises, and corrections the operator confirmed
7
+ * since its last reply (with the cards still open in this conversation, which a correction may affect). */
8
+ function followThrough(store, owner, thread) {
9
+ const commitments = openCommitments(store, owner, 10).map(one => ({ id: one.id, what: one.what.slice(0, 160), when: conditionWords(store, one.condition), expires: one.expiresAt }));
10
+ // The lead's last reply is its last turn's; a turn-less line (a promise report, a follow update) is not a reply.
11
+ const last = store.handle.prepare("SELECT MAX(created_at) AS at FROM mate_message WHERE thread=? AND role='assistant' AND turn IS NOT NULL").get(thread)?.['at'];
12
+ const corrections = [];
13
+ for (const row of store.handle.prepare("SELECT id,payload_json FROM mate_proposal WHERE thread=? AND kind='action' AND state='confirmed' AND resolved_at>? ORDER BY id DESC LIMIT 20").all(thread, String(last ?? ''))) {
14
+ try {
15
+ const payload = JSON.parse(String(row['payload_json']));
16
+ // A changed instruction is added at the end, so that is the part to show.
17
+ const instructions = payload?.request?.instructions;
18
+ const change = payload?.operation === 'knowledge_instructions' && typeof instructions === 'string'
19
+ ? `Project instructions now end: ${instructions.slice(-240)}` : Array.isArray(payload?.terms) ? payload.terms[0] : null;
20
+ if (REMEMBERED.has(payload?.operation) && typeof change === 'string')
21
+ corrections.push({ proposal: Number(row['id']), change: change.slice(0, 300) });
22
+ }
23
+ catch { /* an unreadable card is not a correction */ }
24
+ if (corrections.length === 5)
25
+ break;
26
+ }
27
+ const openProposals = corrections.length === 0 ? [] : store.listMateProposals(thread, ['pending']).slice(-5).map(one => {
28
+ const payload = one.payload;
29
+ const title = [payload['title'], payload['taskTitle'], payload['task']].find(value => typeof value === 'string');
30
+ return { proposal: one.id, kind: one.kind, about: typeof title === 'string' ? title.slice(0, 120) : null };
31
+ });
32
+ return { commitments, corrections, openProposals,
33
+ ...(corrections.length === 0 ? {} : { followThrough: 'The operator confirmed these corrections since your last reply. Re-check the open proposals and promises listed here; release or replace any they affect and say in one line what you changed.' }) };
34
+ }
35
+ export const LEAD_CONTEXT_MAX_BYTES = 8_000;
36
+ const CHANNEL_WORDS = {
37
+ console: 'The Toolroll console in a browser: cards and links show beside your reply.',
38
+ terminal: 'The Toolroll CLI in a terminal: plain text only.',
39
+ telegram: 'Telegram on their phone: a few short lines, the most important first.',
40
+ slack: 'A Slack thread: a few short lines; teammates may read it.',
41
+ discord: 'A Discord thread: a few short lines; teammates may read it.',
42
+ teams: 'A Microsoft Teams thread: a few short lines; teammates may read it.',
43
+ };
44
+ /** "alex.pelletier@example.com" → "Alex". */
45
+ export function firstNameOf(account) {
46
+ const first = account.split('@')[0].split(/[\s._-]+/).find(one => one !== '') ?? '';
47
+ return first === '' ? '' : first[0].toUpperCase() + first.slice(1);
48
+ }
49
+ /** Today in the person's own time zone: "Friday 2026-10-02 14:05". An unknown zone reads as UTC. */
50
+ function localNow(now, timeZone) {
51
+ let zone = timeZone;
52
+ try {
53
+ new Intl.DateTimeFormat('en-GB', { timeZone: zone });
54
+ }
55
+ catch {
56
+ zone = 'UTC';
57
+ }
58
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: zone, weekday: 'long', year: 'numeric', month: '2-digit', day: '2-digit',
59
+ hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(now).map(part => [part.type, part.value]));
60
+ return { timeZone: zone, today: `${parts['weekday']} ${parts['year']}-${parts['month']}-${parts['day']} ${parts['hour']}:${parts['minute']}` };
61
+ }
62
+ /** The channel a chat surface's label names; an unknown label names none rather than a guess. */
63
+ export function leadChannelOf(label) {
64
+ const id = label.trim().toLowerCase();
65
+ return id === 'microsoft teams' ? 'teams' : Object.hasOwn(CHANNEL_WORDS, id) ? id : undefined;
66
+ }
67
+ /** Every string in the bundle, scrubbed. */
68
+ function scrubbed(value, redact) {
69
+ const walk = (node) => typeof node === 'string' ? redact(node)
70
+ : Array.isArray(node) ? node.map(walk)
71
+ : typeof node === 'object' && node !== null ? Object.fromEntries(Object.entries(node).map(([key, inner]) => [key, walk(inner)])) : node;
72
+ return walk(value);
73
+ }
74
+ export function leadContext(store, repos, now, options = {}) {
75
+ const name = options.projectName ?? ((_path, index) => `Project ${index + 1}`);
76
+ // The owner's view: a task their own lead is on waits on nobody; one it let lapse is back with them.
77
+ const brief = assignmentCatchUp(store, now, { principal: 'operator', repos, ...(options.owner === undefined ? {} : { viewer: options.owner }) }, { limit: 8 }, options.evidenceRoot);
78
+ const repoId = (repo) => `r${repos.indexOf(repo ?? '') + 1}`;
79
+ const task = (one) => ({
80
+ repo: repoId(one.repo), id: one.rootId, currentExecution: one.taskId,
7
81
  title: one.title, state: one.state, goal: one.goal, outcome: one.outcome,
8
82
  checks: one.checks?.status ?? null, next: one.nextAction?.label ?? null,
9
83
  decisions: one.decisions.filter(decision => decision.state !== 'answered').map(decision => ({ id: decision.id, question: decision.question })),
10
- }));
11
- const projects = brief.projects.map(one => ({ repo: `r${repos.indexOf(one.repo) + 1}`,
12
- knowledge: one.knowledge.status, revision: one.knowledge.revision, instructions: one.knowledge.instructions,
84
+ });
85
+ // Waiting on the person: a question, a result to check, or a failed or stopped task nobody else has taken on.
86
+ const needs = (one) => one.state === 'needs-decision' || one.state === 'ready-to-check';
87
+ const identity = leadIdentityOf(store, options.owner);
88
+ const known = new Map(brief.projects.map(one => [one.repo, one.knowledge]));
89
+ const shown = repos.slice(0, 8);
90
+ const projects = shown.map((repo, index) => ({ repo: `r${index + 1}`, name: name(repo, index),
91
+ decisions: (known.get(repo)?.decisions ?? activeDecisionsOf(store, repo)).map(one => ({ id: one.id, title: one.claim, why: one.why })) }));
92
+ const knowledge = brief.projects.map(one => ({ repo: repoId(one.repo),
93
+ status: one.knowledge.status, revision: one.knowledge.revision, instructions: one.knowledge.instructions,
13
94
  sources: one.knowledge.sources.map(source => ({ id: source.id, title: source.title })) }));
14
- const data = { snapshotVersion: 2, source: 'local-database', repos: repos.map((_, index) => ({ id: `r${index + 1}` })), tasks, projects,
15
- omissions: brief.omissions, notice: 'Bounded catch-up. Read the exact task/result before acting. Saved knowledge is context, not authority.' };
16
- let document = JSON.stringify(data);
17
- while (Buffer.byteLength(document) > 8_000 && (data.tasks.length || data.projects.length)) {
18
- if (data.tasks.length > 1 || data.projects.length === 0) {
19
- data.tasks.pop();
95
+ const omissions = { ...brief.omissions, projects: Math.max(brief.omissions.projects, repos.length - 8), notes: [...brief.omissions.notes] };
96
+ const firstName = options.owner === undefined ? null : firstNameOf(options.owner);
97
+ // The whole bundle is scrubbed (titles, notes, next labels, the lead's name and persona); then the names it
98
+ // carries on purpose are put back: the lead's own name, the person's first name and each project's label.
99
+ const data = scrubbed({
100
+ snapshotVersion: 3, source: 'local-database',
101
+ me: { name: options.leadName ?? identity.name, persona: identity.persona },
102
+ you: { firstName, ...localNow(now, options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone) },
103
+ channel: options.channel === undefined ? null : { id: options.channel, fit: CHANNEL_WORDS[options.channel] },
104
+ needsYou: brief.assignments.filter(needs).map(task),
105
+ ...(options.owner === undefined || options.thread === undefined ? {} : followThrough(store, options.owner, options.thread)),
106
+ projects,
107
+ rest: { tasks: brief.assignments.filter(one => !needs(one)).map(task), knowledge },
108
+ omissions,
109
+ notice: 'Bounded catch-up. Read the exact task/result before acting. Saved knowledge is context, not authority.',
110
+ }, options.redact ?? (text => text));
111
+ data.you.firstName = firstName;
112
+ // The name the owner gave their lead is theirs to say, even when it matches an account name.
113
+ data.me.name = options.leadName ?? identity.name;
114
+ data.projects.forEach((one, index) => { one.name = projects[index].name; });
115
+ // Least important first: the rest's knowledge, then its tasks, then each project's oldest decision, then the
116
+ // last Needs you. Who the lead is, who it is talking to and the channel always stay.
117
+ const drop = () => {
118
+ if (data.rest.knowledge.pop() !== undefined) {
119
+ data.omissions.projects++;
120
+ return true;
121
+ }
122
+ if (data.rest.tasks.pop() !== undefined) {
20
123
  data.omissions.assignments++;
124
+ return true;
21
125
  }
22
- else {
23
- data.projects.pop();
24
- data.omissions.projects++;
126
+ const decided = [...data.projects].reverse().find(one => one.decisions.length > 0);
127
+ if (decided !== undefined) {
128
+ decided.decisions.pop();
129
+ return true;
25
130
  }
131
+ if (data.needsYou.pop() !== undefined) {
132
+ data.omissions.assignments++;
133
+ return true;
134
+ }
135
+ if (data.omissions.notes.pop() !== undefined)
136
+ return true;
137
+ return data.projects.pop() !== undefined;
138
+ };
139
+ let document = JSON.stringify(data);
140
+ while (Buffer.byteLength(document) > LEAD_CONTEXT_MAX_BYTES && drop())
26
141
  document = JSON.stringify(data);
27
- }
28
142
  return document;
29
143
  }
30
144
  const escape = (value) => value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
@@ -59,4 +173,4 @@ export function leadBriefHtml(brief) {
59
173
  });
60
174
  return `<section class="lead-brief" aria-label="Project catch-up"><h2>Catch up</h2>${sections.length ? sections.join('') : '<p class="meta">Nothing needs you right now.</p>'}${brief.assignments.length > 3 || brief.omissions.candidateScanLimited || brief.omissions.assignments > 0 ? '<a href="/work">See all tasks</a>' : ''}</section>`;
61
175
  }
62
- export const LEAD_CONTEXT_CSS = '.lead-brief{margin:1rem 0;min-width:0}.lead-brief h2{font-size:1.1rem;margin:0 0 .8rem}.repository-context input[name=q]{display:block;box-sizing:border-box;min-height:44px;width:100%;margin:.4rem 0 .75rem;padding:.6rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.repository-context form{margin:.5rem 0 1rem}.repository-context summary{min-height:44px;padding:.75rem 0;overflow-wrap:anywhere}.lead-brief h3{font-size:.85rem;margin:1rem 0 .4rem;color:var(--muted-foreground)}.lead-brief ul{list-style:none;margin:0;padding:0}.lead-brief li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-brief li a{display:flex;min-height:44px;align-items:center;font-weight:500;overflow-wrap:anywhere;text-decoration:none}.lead-brief li a:hover{text-decoration:underline}.lead-brief-state{font-size:.75rem;font-weight:600;padding:.15rem .5rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink);white-space:nowrap}.lead-brief-state--needs-decision,.lead-brief-state--ready-to-check{background:var(--so-attention-soft);color:var(--so-attention)}.lead-brief-state--working,.lead-brief-state--checking{background:var(--so-info-soft);color:var(--so-info)}.lead-brief-state--complete{background:var(--so-success-soft);color:var(--so-success)}.lead-brief li a.lead-brief-act{grid-column:1/-1;justify-self:start;display:inline-flex;min-height:40px;margin:.15rem 0 .4rem;font-weight:600}@media(max-width:760px){.lead-brief li a.lead-brief-act{justify-self:stretch;justify-content:center;min-height:44px}}.lead-brief-detail{grid-column:1/-1;font-size:.85rem;color:var(--muted-foreground);overflow-wrap:anywhere;padding-bottom:.35rem}@media(max-width:760px){.lead-brief{margin:.5rem 0}.lead-brief h2{margin:0 0 .25rem}.lead-brief h3{margin:.5rem 0 0}.lead-brief li{padding:0 0 .375rem}.lead-brief-detail{margin-top:-.375rem;padding-bottom:0;line-height:1.35;pointer-events:none}}';
176
+ export const LEAD_CONTEXT_CSS = '.lead-promise-list{list-style:none;margin:0;padding:0}.lead-promise-list li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.5rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-promise-list li:last-child{border-bottom:0}.lead-promise-list p{margin:0;overflow-wrap:anywhere}.lead-promise-list form{grid-column:2;grid-row:1/span 2;margin:0}.lead-promise-list button{min-height:44px;width:auto;white-space:nowrap}.lead-promise-list .nowrap{white-space:nowrap}.lead-brief{margin:1rem 0;min-width:0}.lead-brief h2{font-size:1.1rem;margin:0 0 .8rem}.repository-context input[name=q]{display:block;box-sizing:border-box;min-height:44px;width:100%;margin:.4rem 0 .75rem;padding:.6rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.repository-context form{margin:.5rem 0 1rem}.repository-context summary{min-height:44px;padding:.75rem 0;overflow-wrap:anywhere}.lead-brief h3{font-size:.85rem;margin:1rem 0 .4rem;color:var(--muted-foreground)}.lead-brief ul{list-style:none;margin:0;padding:0}.lead-brief li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-brief li a{display:flex;min-height:44px;align-items:center;font-weight:500;overflow-wrap:anywhere;text-decoration:none}.lead-brief li a:hover{text-decoration:underline}.lead-brief-state{font-size:.75rem;font-weight:600;padding:.15rem .5rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink);white-space:nowrap}.lead-brief-state--needs-decision,.lead-brief-state--ready-to-check{background:var(--so-attention-soft);color:var(--so-attention)}.lead-brief-state--working,.lead-brief-state--checking{background:var(--so-info-soft);color:var(--so-info)}.lead-brief-state--complete{background:var(--so-success-soft);color:var(--so-success)}.lead-brief li a.lead-brief-act{grid-column:1/-1;justify-self:start;display:inline-flex;min-height:40px;margin:.15rem 0 .4rem;font-weight:600}@media(max-width:760px){.lead-brief li a.lead-brief-act{justify-self:stretch;justify-content:center;min-height:44px}}.lead-brief-detail{grid-column:1/-1;font-size:.85rem;color:var(--muted-foreground);overflow-wrap:anywhere;padding-bottom:.35rem}@media(max-width:760px){.lead-brief{margin:.5rem 0}.lead-brief h2{margin:0 0 .25rem}.lead-brief h3{margin:.5rem 0 0}.lead-brief li{padding:0 0 .375rem}.lead-brief-detail{margin-top:-.375rem;padding-bottom:0;line-height:1.35;pointer-events:none}}';
@@ -5,6 +5,7 @@ import { isVerifiedApprover, reproveApprover, verifyApproverStanding } from './p
5
5
  import { assignmentOf } from './assignment.js';
6
6
  import { runMateTurn } from './mate.js';
7
7
  import { updateAdmissionPaused } from './desktop-update-gate.js';
8
+ import { checkLeadCommitments } from './lead-commitments.js';
8
9
  const CONFIG = 'lead follow configured';
9
10
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
10
11
  export function configureLeadFollow(store, who, session, thread, enabled, now) {
@@ -120,6 +121,8 @@ export async function runLeadFollowPass(input) {
120
121
  if (store.isDemo() || updateAdmissionPaused(store.raw()))
121
122
  return;
122
123
  store.sweepStaleMateTurns(clock());
124
+ // The lead's own promises are kept whether or not automatic crew updates are on: no model, one line when met.
125
+ checkLeadCommitments(store, clock(), input.evidenceRoot);
123
126
  const grants = store.handle.prepare("SELECT a.* FROM action_ledger a WHERE a.action=? AND a.source='work' AND a.id=(SELECT MAX(b.id) FROM action_ledger b WHERE b.actor=a.actor AND b.action=a.action AND b.source=a.source) ORDER BY a.id").all(CONFIG).map(readGrant).filter((one) => one !== null && one.enabled);
124
127
  for (const grant of grants) {
125
128
  const access = authorized(store, grant, input.repos());
@@ -0,0 +1,23 @@
1
+ /** Who the lead is (Settings → Lead): the name a person gives their lead and the short persona it speaks with. The
2
+ * name is what every chat, pushed update and status line calls the lead; the persona opens each turn's DATA. */
3
+ import type { Store } from "./store.js";
4
+ export declare const DEFAULT_LEAD_NAME = "Lead";
5
+ export declare const DEFAULT_LEAD_PERSONA = "Be genuinely helpful, not performative. Have opinions and say which you'd pick. Check before you claim. Be resourceful before asking. Keep it short.";
6
+ export declare const LEAD_NAME_MAX = 40;
7
+ export declare const LEAD_PERSONA_MAX = 600;
8
+ export type LeadIdentity = {
9
+ name: string;
10
+ persona: string;
11
+ };
12
+ /** This person's lead: their saved name and persona, else the defaults. An older store reads as the defaults. */
13
+ export declare function leadIdentityOf(store: Store, account: string | null | undefined): LeadIdentity;
14
+ /** The name alone, for the places the lead speaks. */
15
+ export declare function leadNameOf(store: Store, account: string | null | undefined): string;
16
+ /** The saved form of a name and persona, or the problem with them. An empty name or persona means the default. */
17
+ export declare function checkLeadIdentity(name: string, persona: string): {
18
+ ok: true;
19
+ identity: LeadIdentity;
20
+ } | {
21
+ ok: false;
22
+ message: string;
23
+ };
@@ -0,0 +1,33 @@
1
+ export const DEFAULT_LEAD_NAME = "Lead";
2
+ export const DEFAULT_LEAD_PERSONA = "Be genuinely helpful, not performative. Have opinions and say which you'd pick. Check before you claim. Be resourceful before asking. Keep it short.";
3
+ export const LEAD_NAME_MAX = 40;
4
+ export const LEAD_PERSONA_MAX = 600;
5
+ /** This person's lead: their saved name and persona, else the defaults. An older store reads as the defaults. */
6
+ export function leadIdentityOf(store, account) {
7
+ if (account == null || account === "")
8
+ return { name: DEFAULT_LEAD_NAME, persona: DEFAULT_LEAD_PERSONA };
9
+ try {
10
+ const saved = store.leadConfig(account);
11
+ if (saved !== null)
12
+ return { name: saved.name, persona: saved.persona };
13
+ }
14
+ catch { /* no lead_config yet: the defaults */ }
15
+ return { name: DEFAULT_LEAD_NAME, persona: DEFAULT_LEAD_PERSONA };
16
+ }
17
+ /** The name alone, for the places the lead speaks. */
18
+ export function leadNameOf(store, account) {
19
+ return leadIdentityOf(store, account).name;
20
+ }
21
+ /** The saved form of a name and persona, or the problem with them. An empty name or persona means the default. */
22
+ export function checkLeadIdentity(name, persona) {
23
+ const named = name.trim().replace(/\s+/g, " "), described = persona.trim().replace(/\r\n?/g, "\n");
24
+ if (named.length > LEAD_NAME_MAX)
25
+ return { ok: false, message: `Keep the name to ${LEAD_NAME_MAX} characters.` };
26
+ if (!/^[\p{L}\p{N}][\p{L}\p{N} .'’_-]*$/u.test(named) && named !== "")
27
+ return { ok: false, message: "Use letters, numbers and spaces in the name." };
28
+ if (described.length > LEAD_PERSONA_MAX)
29
+ return { ok: false, message: `Keep the persona to ${LEAD_PERSONA_MAX} characters.` };
30
+ if (/[\u0000-\u0009\u000b-\u001f\u007f]/.test(described))
31
+ return { ok: false, message: "Use plain text in the persona." };
32
+ return { ok: true, identity: { name: named || DEFAULT_LEAD_NAME, persona: described || DEFAULT_LEAD_PERSONA } };
33
+ }
@@ -1,14 +1,12 @@
1
1
  /** The lead's voice: what the person's lead (an agent under their lead token) is doing, in their chat and on the
2
- * console. `lead say` posts one short message from "Your lead"; several within two minutes are one message, edited
3
- * in place as it grows. `assignment claim` marks a task the lead's: it reads "Your lead is on it" and leaves Needs
2
+ * console. `lead say` posts one short message from the lead, by the name its person gave it (Settings → Lead); several within two minutes are one message, edited
3
+ * in place as it grows. `assignment claim` marks a task the lead's: it reads "<name> is on it" and leaves Needs
4
4
  * you until the lead completes it, hands it to a person, or two hours pass with no lead act on it.
5
5
  *
6
6
  * Every fact is an append-only ledger row under "lead for <owner>"; no table of its own. The chat message is an
7
7
  * ordinary notification addressed to the owner, so every chat delivers it with the usual receipts. */
8
8
  import { type Actor } from "./actor.js";
9
9
  import { type Notification, type Store } from "./store.js";
10
- /** Who the person's chat says the lead's messages are from. */
11
- export declare const LEAD_NAME = "Your lead";
12
10
  export declare const LEAD_SAY_KIND = "lead-say";
13
11
  /** Repaints the owner's task card when the lead takes a task on: it never makes a new message in quiet chat. */
14
12
  export declare const LEAD_ON_IT_KIND = "lead-on-it";
@@ -34,7 +32,8 @@ export type LeadActivity = {
34
32
  owner: string;
35
33
  doing: string;
36
34
  at: string;
37
- taskId: string | null;
35
+ taskId: string | null; /** What the owner calls their lead. */
36
+ name: string;
38
37
  };
39
38
  type Failure = {
40
39
  ok: false;
@@ -51,8 +50,12 @@ export declare function leadSay(store: Store, actor: Actor, text: string, taskId
51
50
  joined: boolean;
52
51
  notification: number | null;
53
52
  } | Failure;
54
- /** The words a chat shows for a lead-say row: "Your lead", then each line, plain, with no task id. */
55
- export declare function leadSayText(row: Pick<Notification, "subject" | "body">): string;
53
+ /** A lead row's subject in its owner's current name for their lead, read when it is shown, so a rename reaches
54
+ * earlier cards too; any other row's own subject. `reader`: who the chat belongs to, for a row with no recipient
55
+ * (only its owner ever receives a lead row). */
56
+ export declare function leadSubjectOf(store: Store, row: Pick<Notification, "kind" | "subject" | "recipient">, reader?: string | null): string;
57
+ /** The words a chat shows for a lead-say row: the lead's name, then each line, plain, with no task id. */
58
+ export declare function leadSayText(store: Store, row: Pick<Notification, "kind" | "subject" | "body" | "recipient">): string;
56
59
  /** The earlier rows of a lead-say message (newest first): a chat that showed one edits it rather than sending again. */
57
60
  export declare function leadSayEarlier(store: Store, row: Pick<Notification, "id" | "kind" | "dedupeKey" | "recipient">): number[];
58
61
  /** `assignment claim` under the lead token: the task is the lead's until it completes it, hands it on, or goes
@@ -72,10 +75,10 @@ export declare function enqueueLeadLapses(store: Store, now: Date): number;
72
75
  /** The lead's act on a task that a claim counts (a retry, a state change, a steer): one ledger row as the lead. */
73
76
  export declare function noteLeadWork(store: Store, actor: Actor, taskId: string, what: string, now: Date): void;
74
77
  /** What `owner`'s own lead is doing now and when it last acted, both from its one newest act: its words when that
75
- * was a say, else the act in words. Nobody (null) has no lead line: another person's lead is never "Your lead". */
78
+ * was a say, else the act in words. Nobody (null) has no lead line: another person's lead is never theirs. */
76
79
  export declare function leadActivity(store: Store, owner: string | null | undefined): LeadActivity | null;
77
80
  /** "just now", "3 min ago", "2 h ago", "yesterday", "4 days ago". */
78
81
  export declare function agoWords(at: string, now: Date): string;
79
- /** The one line `toolroll status` and the console say: "Your lead: fixing 0.9.12's release check · 3 min ago". */
82
+ /** The one line `toolroll status` and the console say: "Lead: fixing 0.9.12's release check · 3 min ago". */
80
83
  export declare function leadActivityLine(activity: LeadActivity, now: Date): string;
81
84
  export {};