toolroll 0.9.15 → 0.9.17
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.
- package/CHANGELOG.md +36 -0
- package/dist/assignment-brief.d.ts +10 -0
- package/dist/assignment-brief.js +56 -26
- package/dist/assignment.d.ts +8 -7
- package/dist/assignment.js +13 -8
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -22
- package/dist/browser-crew.js +1 -0
- package/dist/browser-workspace.d.ts +5 -0
- package/dist/chat-actions.d.ts +7 -0
- package/dist/chat-actions.js +40 -4
- package/dist/chat-ask.d.ts +28 -0
- package/dist/chat-ask.js +91 -0
- package/dist/chat-channel.js +8 -1
- package/dist/chat-controls.d.ts +8 -0
- package/dist/chat-controls.js +3 -0
- package/dist/chat-delivery-state.d.ts +8 -1
- package/dist/chat-delivery-state.js +15 -2
- package/dist/chat-delivery.d.ts +3 -0
- package/dist/chat-delivery.js +49 -17
- package/dist/chat-quiet.js +4 -2
- package/dist/chat-voice.d.ts +3 -6
- package/dist/chat-voice.js +8 -2
- package/dist/chat-warmth.d.ts +21 -0
- package/dist/chat-warmth.js +36 -0
- package/dist/discord-api.d.ts +1 -1
- package/dist/discord-api.js +1 -1
- package/dist/discord-chat.d.ts +3 -2
- package/dist/discord-chat.js +28 -3
- package/dist/lead-about.d.ts +35 -0
- package/dist/lead-about.js +62 -0
- package/dist/lead-capabilities.d.ts +64 -0
- package/dist/lead-capabilities.js +199 -0
- package/dist/lead-commitments.d.ts +84 -0
- package/dist/lead-commitments.js +214 -0
- package/dist/lead-context.d.ts +34 -3
- package/dist/lead-context.js +155 -19
- package/dist/lead-follow.d.ts +1 -1
- package/dist/lead-follow.js +6 -2
- package/dist/lead-identity.d.ts +23 -0
- package/dist/lead-identity.js +33 -0
- package/dist/lead-people.d.ts +55 -0
- package/dist/lead-people.js +128 -0
- package/dist/lead-voice.d.ts +13 -9
- package/dist/lead-voice.js +26 -16
- package/dist/ledger-view.js +3 -2
- package/dist/mate-cli.d.ts +4 -1
- package/dist/mate-cli.js +23 -16
- package/dist/mate-contract.d.ts +1 -1
- package/dist/mate-contract.js +13 -4
- package/dist/mate-progress.js +7 -0
- package/dist/mate-tools.d.ts +26 -1
- package/dist/mate-tools.js +357 -7
- package/dist/mate.d.ts +35 -1
- package/dist/mate.js +121 -61
- package/dist/operate.js +4 -1
- package/dist/phase-routing.d.ts +1 -1
- package/dist/project-delete.js +3 -1
- package/dist/provider-auth.d.ts +4 -0
- package/dist/provider-auth.js +1 -1
- package/dist/reply-shape.d.ts +96 -0
- package/dist/reply-shape.js +452 -0
- package/dist/result-acts.d.ts +14 -0
- package/dist/result-acts.js +19 -0
- package/dist/retention.js +6 -0
- package/dist/serve.js +162 -55
- package/dist/slack-chat.d.ts +2 -0
- package/dist/slack-chat.js +30 -3
- package/dist/store.d.ts +60 -0
- package/dist/store.js +152 -3
- package/dist/surface.js +2 -2
- package/dist/task-status.d.ts +5 -15
- package/dist/task-status.js +18 -5
- package/dist/team-runtime.js +3 -2
- package/dist/teams-chat.js +16 -2
- package/dist/telegram-mate.d.ts +6 -1
- package/dist/telegram-mate.js +43 -11
- package/dist/telegram-progress.js +3 -2
- package/dist/telegram-team.js +6 -3
- package/dist/telegram.js +43 -3
- package/dist/test-temp.js +1 -1
- package/dist/when-html.d.ts +8 -3
- package/dist/when-html.js +23 -3
- package/dist/work-index.d.ts +2 -0
- package/dist/work-index.js +9 -6
- package/dist/work-summary.d.ts +1 -1
- package/dist/workspace-ui.d.ts +2 -14
- package/dist/workspace-ui.js +4 -22
- package/package.json +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** What the lead promised to follow up on. A commitment records the promise in the lead's words, the condition
|
|
2
|
+
* that completes it, when to look next, where to report and when it lapses. The follow pass reads open ones
|
|
3
|
+
* without a model and says one short line when the condition is met, on the channel the promise was made on;
|
|
4
|
+
* nothing otherwise.
|
|
5
|
+
* The table is additive (no schema version bump), so a build that predates it still opens the store. */
|
|
6
|
+
import type { Notification, Store } from './store.js';
|
|
7
|
+
import { type AssignmentSnapshot } from './assignment.js';
|
|
8
|
+
/** The table: `LEAD_COMMITMENT_SCHEMA` in store.ts (additive, no version bump). */
|
|
9
|
+
export declare const COMMITMENT_DAYS = 7;
|
|
10
|
+
export type TaskState = AssignmentSnapshot['state'];
|
|
11
|
+
export declare const TASK_STATES: readonly TaskState[];
|
|
12
|
+
export declare const RUN_OUTCOMES: readonly ['finished', 'built', 'failed'];
|
|
13
|
+
export declare const CHECK_RESULTS: readonly ['passed', 'failed', 'either'];
|
|
14
|
+
/** Where a promise was made, and so where it is reported: `chat` is the console or terminal conversation; a phone
|
|
15
|
+
* or team chat also gets the line as the lead's own message, on that chat only. */
|
|
16
|
+
export declare const COMMITMENT_CHANNELS: readonly ['chat', 'telegram', 'slack', 'discord', 'teams'];
|
|
17
|
+
export type CommitmentChannel = typeof COMMITMENT_CHANNELS[number];
|
|
18
|
+
/** `afterRun`: only a result newer than the one there was when the promise was made counts. */
|
|
19
|
+
export type CommitmentCondition = {
|
|
20
|
+
kind: 'task';
|
|
21
|
+
task: string;
|
|
22
|
+
states: TaskState[];
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'run';
|
|
25
|
+
run: number;
|
|
26
|
+
outcome: typeof RUN_OUTCOMES[number];
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'check';
|
|
29
|
+
task: string;
|
|
30
|
+
result: typeof CHECK_RESULTS[number];
|
|
31
|
+
afterRun: number;
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'check';
|
|
34
|
+
run: number;
|
|
35
|
+
result: typeof CHECK_RESULTS[number];
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'time';
|
|
38
|
+
at: string;
|
|
39
|
+
};
|
|
40
|
+
export type Commitment = {
|
|
41
|
+
id: number;
|
|
42
|
+
owner: string;
|
|
43
|
+
repo: string | null;
|
|
44
|
+
thread: number;
|
|
45
|
+
turn: number | null;
|
|
46
|
+
channel: string;
|
|
47
|
+
what: string;
|
|
48
|
+
condition: CommitmentCondition;
|
|
49
|
+
checkAt: string;
|
|
50
|
+
expiresAt: string;
|
|
51
|
+
state: 'open' | 'done' | 'cancelled' | 'expired';
|
|
52
|
+
createdAt: string;
|
|
53
|
+
checkedAt: string | null;
|
|
54
|
+
closedAt: string | null;
|
|
55
|
+
closedBy: string | null;
|
|
56
|
+
outcome: string | null;
|
|
57
|
+
};
|
|
58
|
+
/** The condition in a few words, for Settings and for the lead's own catch-up. */
|
|
59
|
+
export declare function conditionWords(store: Store, condition: CommitmentCondition): string;
|
|
60
|
+
export type CommitInput = {
|
|
61
|
+
owner: string;
|
|
62
|
+
repo: string | null;
|
|
63
|
+
thread: number;
|
|
64
|
+
turn: number | null;
|
|
65
|
+
channel?: string;
|
|
66
|
+
what: string;
|
|
67
|
+
condition: CommitmentCondition;
|
|
68
|
+
checkAt?: string | null;
|
|
69
|
+
};
|
|
70
|
+
/** Record a promise. The caller has already proved the task, attempt or project is within the owner's access. */
|
|
71
|
+
export declare function recordCommitment(store: Store, input: CommitInput, now: Date): Commitment;
|
|
72
|
+
export declare function getCommitment(store: Store, id: number): Commitment | null;
|
|
73
|
+
/** The owner's open promises that they heard, oldest first. */
|
|
74
|
+
export declare function openCommitments(store: Store, owner: string, limit?: number): Commitment[];
|
|
75
|
+
/** The phone or team chat a lead message is for, when it is a met promise (`lead-promise:<channel>:<id>`); null for
|
|
76
|
+
* every other notification, which goes wherever it always went. */
|
|
77
|
+
export declare function promiseChannelOf(row: Pick<Notification, 'dedupeKey'>): string | null;
|
|
78
|
+
/** Stop following up. Only the owner (from Settings or through their lead) cancels; a closed one stays as it was. */
|
|
79
|
+
export declare function cancelCommitment(store: Store, owner: string, id: number, by: string, reason: string, now: Date): boolean;
|
|
80
|
+
/** The newest result run for a task, for a check condition's baseline. */
|
|
81
|
+
export declare function latestResultRun(store: Store, task: string, repos: readonly string[], now: Date): number;
|
|
82
|
+
/** One deterministic pass over due promises (no model): expire, drop what can no longer be delivered or seen,
|
|
83
|
+
* and report a met condition once — the state change and the message share one transaction. Returns how many reported. */
|
|
84
|
+
export declare function checkLeadCommitments(store: Store, now: Date, root?: string): number;
|
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
/** The reply that made a promise was shown: its turn is over, and it was answered or its text reached the conversation
|
|
91
|
+
* before it failed. A turn still running has shown nothing yet, whatever it has written so far. */
|
|
92
|
+
const SHOWN = (turn) => `EXISTS (SELECT 1 FROM mate_turn t WHERE t.id = ${turn} AND (t.state = 'answered'
|
|
93
|
+
OR t.state = 'failed' AND EXISTS (SELECT 1 FROM mate_message m WHERE m.turn = t.id AND m.role = 'assistant')))`;
|
|
94
|
+
/** A promise only counts once the reply that made it was shown: one from a reply still being written, or one that
|
|
95
|
+
* failed without reaching the owner, was never heard. */
|
|
96
|
+
const HEARD = `(c.turn IS NULL OR ${SHOWN('c.turn')})`;
|
|
97
|
+
/** The owner's open promises that they heard, oldest first. */
|
|
98
|
+
export function openCommitments(store, owner, limit = 20) {
|
|
99
|
+
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)
|
|
100
|
+
.map(readCommitment).filter((one) => one !== null);
|
|
101
|
+
}
|
|
102
|
+
/** The phone or team chat a lead message is for, when it is a met promise (`lead-promise:<channel>:<id>`); null for
|
|
103
|
+
* every other notification, which goes wherever it always went. */
|
|
104
|
+
export function promiseChannelOf(row) {
|
|
105
|
+
return row.dedupeKey.startsWith(PROMISE_KEY) ? row.dedupeKey.slice(PROMISE_KEY.length).split(':')[0] ?? null : null;
|
|
106
|
+
}
|
|
107
|
+
/** Stop following up. Only the owner (from Settings or through their lead) cancels; a closed one stays as it was. */
|
|
108
|
+
export function cancelCommitment(store, owner, id, by, reason, now) {
|
|
109
|
+
const changed = store.handle.prepare("UPDATE lead_commitment SET state = 'cancelled', closed_at = ?, closed_by = ?, outcome = ? WHERE id = ? AND owner = ? AND state = 'open'")
|
|
110
|
+
.run(now.toISOString(), by, reason.trim().slice(0, 240) || 'Cancelled.', id, owner);
|
|
111
|
+
return Number(changed.changes) === 1;
|
|
112
|
+
}
|
|
113
|
+
/** The newest result run for a task, for a check condition's baseline. */
|
|
114
|
+
export function latestResultRun(store, task, repos, now) {
|
|
115
|
+
return assignmentOf(store, task, now, { principal: 'operator', repos })?.receipt?.runId ?? 0;
|
|
116
|
+
}
|
|
117
|
+
/** What was observed when the condition is met, in one sentence; null while it is not. */
|
|
118
|
+
function observe(store, one, now, root) {
|
|
119
|
+
const c = one.condition, repos = one.repo === null ? [] : [one.repo];
|
|
120
|
+
const title = (task) => `“${store.getTask(task)?.title ?? task}”`;
|
|
121
|
+
if (c.kind === 'time')
|
|
122
|
+
return now.toISOString() >= c.at ? 'It is time.' : null;
|
|
123
|
+
if (c.kind === 'task') {
|
|
124
|
+
const a = assignmentOf(store, c.task, now, { principal: 'operator', repos }, root);
|
|
125
|
+
return a !== null && c.states.includes(a.state) ? `${title(a.rootId)} is ${STATE_WORDS[a.state]}.` : null;
|
|
126
|
+
}
|
|
127
|
+
if (c.kind === 'run') {
|
|
128
|
+
const run = store.getRun(c.run);
|
|
129
|
+
if (run === null || run.outcome === null)
|
|
130
|
+
return null;
|
|
131
|
+
const ended = run.outcome === 'built' ? 'finished with a result' : run.outcome === 'failed' ? 'failed' : `ended (${run.outcome})`;
|
|
132
|
+
if (c.outcome === 'built' && run.outcome !== 'built' || c.outcome === 'failed' && run.outcome !== 'failed')
|
|
133
|
+
return null;
|
|
134
|
+
return `The attempt on ${attemptOf(store, run.id)} ${ended}.`;
|
|
135
|
+
}
|
|
136
|
+
const matches = (status) => (status === 'passed' || status === 'failed') && (c.result === 'either' || c.result === status);
|
|
137
|
+
if ('task' in c) {
|
|
138
|
+
const a = assignmentOf(store, c.task, now, { principal: 'operator', repos }, root);
|
|
139
|
+
const receipt = a?.receipt;
|
|
140
|
+
if (!a || !receipt || receipt.runId <= c.afterRun || !matches(receipt.checks.status))
|
|
141
|
+
return null;
|
|
142
|
+
return `The checks on ${title(a.rootId)} ${receipt.checks.status}.`;
|
|
143
|
+
}
|
|
144
|
+
const check = store.runCheckFor(c.run);
|
|
145
|
+
if (check === null || !matches(check.status))
|
|
146
|
+
return null;
|
|
147
|
+
return `The checks on ${attemptOf(store, c.run)} ${check.status}.`;
|
|
148
|
+
}
|
|
149
|
+
/** The promise as the end of "I said I would …": "I'll tell you when…" and "Tell you when…" both read "tell you when…". */
|
|
150
|
+
function promised(what) {
|
|
151
|
+
const bare = what.replace(/^I(?:'ll| will)\s+/i, '').replace(/[.!]+$/, '');
|
|
152
|
+
return /^[A-Z][a-z]/.test(bare) ? bare.charAt(0).toLowerCase() + bare.slice(1) : bare;
|
|
153
|
+
}
|
|
154
|
+
/** One deterministic pass over due promises (no model): expire, drop what can no longer be delivered or seen,
|
|
155
|
+
* and report a met condition once — the state change and the message share one transaction. Returns how many reported. */
|
|
156
|
+
export function checkLeadCommitments(store, now, root) {
|
|
157
|
+
const at = now.toISOString();
|
|
158
|
+
let reported = 0;
|
|
159
|
+
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);
|
|
160
|
+
// A promise made in a reply that was not delivered (it failed before any text was shown, or its turn is gone) was
|
|
161
|
+
// never heard: drop it now, whenever it was next due, so nothing waits on it. An interrupted reply whose text was
|
|
162
|
+
// shown keeps its promises.
|
|
163
|
+
store.handle.prepare(`UPDATE lead_commitment SET state = 'cancelled', closed_at = ?, closed_by = 'lead', outcome = 'The reply that made this promise was not delivered.'
|
|
164
|
+
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'))
|
|
165
|
+
AND NOT ${SHOWN('lead_commitment.turn')}`).run(at);
|
|
166
|
+
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);
|
|
167
|
+
for (const row of due) {
|
|
168
|
+
const one = readCommitment(row);
|
|
169
|
+
const id = Number(row['id']);
|
|
170
|
+
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;
|
|
171
|
+
if (one === null) {
|
|
172
|
+
close('cancelled', 'Its condition could not be read.');
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
// A reply still being written may yet be delivered; one that was not is dropped above.
|
|
176
|
+
const turn = one.turn === null ? null : store.getMateTurn(one.turn);
|
|
177
|
+
if (turn !== null && (turn.state === 'queued' || turn.state === 'running'))
|
|
178
|
+
continue;
|
|
179
|
+
const account = store.accountOf(one.owner);
|
|
180
|
+
if (account === null || account.revokedAt !== null || one.repo !== null && !store.accountCanAccess(one.owner, one.repo)) {
|
|
181
|
+
close('cancelled', 'Access to this project ended.');
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const thread = store.getMateThread(one.thread);
|
|
185
|
+
if (thread === null || thread.approver !== one.owner || thread.closedAt !== null) {
|
|
186
|
+
close('cancelled', 'The conversation it was promised in was closed.');
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
let seen;
|
|
190
|
+
try {
|
|
191
|
+
seen = observe(store, one, now, root);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
seen = null;
|
|
195
|
+
}
|
|
196
|
+
if (seen === null) {
|
|
197
|
+
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);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const line = `${seen} (I said I would ${promised(one.what)}.)`;
|
|
201
|
+
store.transact(() => {
|
|
202
|
+
if (!close('done', seen))
|
|
203
|
+
return;
|
|
204
|
+
// The shared conversation always keeps the line; a promise made on a phone or team chat is also said there, as
|
|
205
|
+
// the lead's own message to its owner, on that chat alone (the usual delivery receipts apply).
|
|
206
|
+
store.appendMateMessage({ thread: one.thread, turn: null, role: 'assistant', text: line }, now);
|
|
207
|
+
if (one.channel !== 'chat')
|
|
208
|
+
store.enqueueNotification({ dedupeKey: `${PROMISE_KEY}${one.channel}:${one.id}`, kind: LEAD_SAY_KIND, subject: leadNameOf(store, one.owner),
|
|
209
|
+
body: line.slice(0, 300), recipient: one.owner, source: { installation: true } }, now);
|
|
210
|
+
reported++;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return reported;
|
|
214
|
+
}
|
package/dist/lead-context.d.ts
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
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), what
|
|
6
|
+
* it knows about them (their confirmed lines), the people, AI teammates and team
|
|
7
|
+
* chats it works with (one line each), the channel, what needs them now,
|
|
8
|
+
* projects by name with their active decisions, then the rest. Over 8 KB, the
|
|
9
|
+
* least important goes first: the rest, then people, then decisions. */
|
|
3
10
|
import type { Store } from './store.js';
|
|
4
11
|
import { type AssignmentCatchUp } from './assignment-brief.js';
|
|
5
|
-
export declare
|
|
12
|
+
export declare const LEAD_CONTEXT_MAX_BYTES = 8000;
|
|
13
|
+
/** Where this turn's conversation happens. */
|
|
14
|
+
export type LeadChannel = 'console' | 'terminal' | 'telegram' | 'slack' | 'discord' | 'teams';
|
|
15
|
+
export type LeadContextOptions = {
|
|
16
|
+
evidenceRoot?: string;
|
|
17
|
+
/** The person this turn talks to (their account name). */
|
|
18
|
+
owner?: string;
|
|
19
|
+
channel?: LeadChannel;
|
|
20
|
+
/** A shared team conversation's own lead, in place of the person's own name for it. */
|
|
21
|
+
leadName?: string;
|
|
22
|
+
/** This conversation: its open promises and the corrections confirmed since the lead's last reply. */
|
|
23
|
+
thread?: number;
|
|
24
|
+
/** Their time zone; this computer's when absent. */
|
|
25
|
+
timeZone?: string;
|
|
26
|
+
/** Scrubs every string in the bundle (paths, digests, account names); the person's first name and the project
|
|
27
|
+
* labels are put back afterwards, on purpose. */
|
|
28
|
+
redact?: (text: string) => string;
|
|
29
|
+
/** A project's display name, already safe to show; the bundle never carries paths. */
|
|
30
|
+
projectName?: (path: string, index: number) => string;
|
|
31
|
+
};
|
|
32
|
+
/** "alex.pelletier@example.com" → "Alex". */
|
|
33
|
+
export declare function firstNameOf(account: string): string;
|
|
34
|
+
/** The channel a chat surface's label names; an unknown label names none rather than a guess. */
|
|
35
|
+
export declare function leadChannelOf(label: string): LeadChannel | undefined;
|
|
36
|
+
export declare function leadContext(store: Store, repos: readonly string[], now: Date, options?: LeadContextOptions): string;
|
|
6
37
|
/** The same saved brief is useful before chat spending is authorized. */
|
|
7
38
|
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}}";
|
|
39
|
+
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}}";
|
package/dist/lead-context.js
CHANGED
|
@@ -1,30 +1,166 @@
|
|
|
1
|
-
import { assignmentCatchUp } from './assignment-brief.js';
|
|
1
|
+
import { activeDecisionsOf, assignmentCatchUp } from './assignment-brief.js';
|
|
2
2
|
import { publicChatText } from './chat-display.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import { leadIdentityOf } from './lead-identity.js';
|
|
4
|
+
import { conditionWords, openCommitments } from './lead-commitments.js';
|
|
5
|
+
import { aboutYouOf } from './lead-about.js';
|
|
6
|
+
import { peopleIndexOf, peopleLines, teamRoomOf } from './lead-people.js';
|
|
7
|
+
const REMEMBERED = new Set(['decision_record', 'knowledge_instructions', 'lead_about_you']);
|
|
8
|
+
/** The lead's own follow-through for one conversation: its open promises, and corrections the operator confirmed
|
|
9
|
+
* since its last reply (with the cards still open in this conversation, which a correction may affect). */
|
|
10
|
+
function followThrough(store, owner, thread) {
|
|
11
|
+
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 }));
|
|
12
|
+
// The lead's last reply is its last turn's; a turn-less line (a promise report, a follow update) is not a reply.
|
|
13
|
+
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'];
|
|
14
|
+
const corrections = [];
|
|
15
|
+
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 ?? ''))) {
|
|
16
|
+
try {
|
|
17
|
+
const payload = JSON.parse(String(row['payload_json']));
|
|
18
|
+
// A changed instruction is added at the end, so that is the part to show.
|
|
19
|
+
const instructions = payload?.request?.instructions;
|
|
20
|
+
const change = payload?.operation === 'knowledge_instructions' && typeof instructions === 'string'
|
|
21
|
+
? `Project instructions now end: ${instructions.slice(-240)}`
|
|
22
|
+
: payload?.operation === 'lead_about_you' && typeof payload?.request?.line === 'string' ? `About you: ${payload.request.line}`
|
|
23
|
+
: Array.isArray(payload?.terms) ? payload.terms[0] : null;
|
|
24
|
+
if (REMEMBERED.has(payload?.operation) && typeof change === 'string')
|
|
25
|
+
corrections.push({ proposal: Number(row['id']), change: change.slice(0, 300) });
|
|
26
|
+
}
|
|
27
|
+
catch { /* an unreadable card is not a correction */ }
|
|
28
|
+
if (corrections.length === 5)
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
const openProposals = corrections.length === 0 ? [] : store.listMateProposals(thread, ['pending']).slice(-5).map(one => {
|
|
32
|
+
const payload = one.payload;
|
|
33
|
+
const title = [payload['title'], payload['taskTitle'], payload['task']].find(value => typeof value === 'string');
|
|
34
|
+
return { proposal: one.id, kind: one.kind, about: typeof title === 'string' ? title.slice(0, 120) : null };
|
|
35
|
+
});
|
|
36
|
+
return { commitments, corrections, openProposals,
|
|
37
|
+
...(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.' }) };
|
|
38
|
+
}
|
|
39
|
+
export const LEAD_CONTEXT_MAX_BYTES = 8_000;
|
|
40
|
+
const CHANNEL_WORDS = {
|
|
41
|
+
console: 'The Toolroll console in a browser: cards and links show beside your reply.',
|
|
42
|
+
terminal: 'The Toolroll CLI in a terminal: plain text only.',
|
|
43
|
+
telegram: 'Telegram on their phone: a few short lines, the most important first.',
|
|
44
|
+
slack: 'A Slack thread: a few short lines; teammates may read it.',
|
|
45
|
+
discord: 'A Discord thread: a few short lines; teammates may read it.',
|
|
46
|
+
teams: 'A Microsoft Teams thread: a few short lines; teammates may read it.',
|
|
47
|
+
};
|
|
48
|
+
/** "alex.pelletier@example.com" → "Alex". */
|
|
49
|
+
export function firstNameOf(account) {
|
|
50
|
+
const first = account.split('@')[0].split(/[\s._-]+/).find(one => one !== '') ?? '';
|
|
51
|
+
return first === '' ? '' : first[0].toUpperCase() + first.slice(1);
|
|
52
|
+
}
|
|
53
|
+
/** Today in the person's own time zone: "Friday 2026-10-02 14:05". An unknown zone reads as UTC. */
|
|
54
|
+
function localNow(now, timeZone) {
|
|
55
|
+
let zone = timeZone;
|
|
56
|
+
try {
|
|
57
|
+
new Intl.DateTimeFormat('en-GB', { timeZone: zone });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
zone = 'UTC';
|
|
61
|
+
}
|
|
62
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: zone, weekday: 'long', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
63
|
+
hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(now).map(part => [part.type, part.value]));
|
|
64
|
+
return { timeZone: zone, today: `${parts['weekday']} ${parts['year']}-${parts['month']}-${parts['day']} ${parts['hour']}:${parts['minute']}` };
|
|
65
|
+
}
|
|
66
|
+
/** The channel a chat surface's label names; an unknown label names none rather than a guess. */
|
|
67
|
+
export function leadChannelOf(label) {
|
|
68
|
+
const id = label.trim().toLowerCase();
|
|
69
|
+
return id === 'microsoft teams' ? 'teams' : Object.hasOwn(CHANNEL_WORDS, id) ? id : undefined;
|
|
70
|
+
}
|
|
71
|
+
/** Every string in the bundle, scrubbed. */
|
|
72
|
+
function scrubbed(value, redact) {
|
|
73
|
+
const walk = (node) => typeof node === 'string' ? redact(node)
|
|
74
|
+
: Array.isArray(node) ? node.map(walk)
|
|
75
|
+
: typeof node === 'object' && node !== null ? Object.fromEntries(Object.entries(node).map(([key, inner]) => [key, walk(inner)])) : node;
|
|
76
|
+
return walk(value);
|
|
77
|
+
}
|
|
78
|
+
export function leadContext(store, repos, now, options = {}) {
|
|
79
|
+
const name = options.projectName ?? ((_path, index) => `Project ${index + 1}`);
|
|
80
|
+
// The owner's view: a task their own lead is on waits on nobody; one it let lapse is back with them.
|
|
81
|
+
const brief = assignmentCatchUp(store, now, { principal: 'operator', repos, ...(options.owner === undefined ? {} : { viewer: options.owner }) }, { limit: 8 }, options.evidenceRoot);
|
|
82
|
+
const repoId = (repo) => `r${repos.indexOf(repo ?? '') + 1}`;
|
|
83
|
+
const task = (one) => ({
|
|
84
|
+
repo: repoId(one.repo), id: one.rootId, currentExecution: one.taskId,
|
|
7
85
|
title: one.title, state: one.state, goal: one.goal, outcome: one.outcome,
|
|
8
86
|
checks: one.checks?.status ?? null, next: one.nextAction?.label ?? null,
|
|
9
87
|
decisions: one.decisions.filter(decision => decision.state !== 'answered').map(decision => ({ id: decision.id, question: decision.question })),
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
|
|
88
|
+
});
|
|
89
|
+
// Waiting on the person: a question, a result to check, or a failed or stopped task nobody else has taken on.
|
|
90
|
+
const needs = (one) => one.state === 'needs-decision' || one.state === 'ready-to-check';
|
|
91
|
+
const identity = leadIdentityOf(store, options.owner);
|
|
92
|
+
const known = new Map(brief.projects.map(one => [one.repo, one.knowledge]));
|
|
93
|
+
const shown = repos.slice(0, 8);
|
|
94
|
+
const projects = shown.map((repo, index) => ({ repo: `r${index + 1}`, name: name(repo, index),
|
|
95
|
+
decisions: (known.get(repo)?.decisions ?? activeDecisionsOf(store, repo)).map(one => ({ id: one.id, title: one.claim, why: one.why })) }));
|
|
96
|
+
const knowledge = brief.projects.map(one => ({ repo: repoId(one.repo),
|
|
97
|
+
status: one.knowledge.status, revision: one.knowledge.revision, instructions: one.knowledge.instructions,
|
|
13
98
|
sources: one.knowledge.sources.map(source => ({ id: source.id, title: source.title })) }));
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
99
|
+
const omissions = { ...brief.omissions, projects: Math.max(brief.omissions.projects, repos.length - 8), notes: [...brief.omissions.notes] };
|
|
100
|
+
const firstName = options.owner === undefined ? null : firstNameOf(options.owner);
|
|
101
|
+
const redact = options.redact ?? (text => text);
|
|
102
|
+
// A team chat's own lead speaks for the room, so the owner's own note stays with their own lead, and the people
|
|
103
|
+
// index is that room and its members, no one else.
|
|
104
|
+
const room = teamRoomOf(store, options.thread);
|
|
105
|
+
const aboutYou = options.leadName === undefined && room === null ? aboutYouOf(store, options.owner) : [];
|
|
106
|
+
// First names are shown on purpose; each line's free text is scrubbed as it is written.
|
|
107
|
+
const people = options.owner === undefined ? { people: [], teammates: [], teams: [] } : peopleLines(peopleIndexOf(store, options.owner, repos, room), redact);
|
|
108
|
+
// The whole bundle is scrubbed (titles, notes, next labels, the lead's name and persona); then the names it
|
|
109
|
+
// carries on purpose are put back: the lead's own name, the person's first name, the lines they confirmed about
|
|
110
|
+
// themselves, the people index (first names) and each project's label.
|
|
111
|
+
const data = scrubbed({
|
|
112
|
+
snapshotVersion: 3, source: 'local-database',
|
|
113
|
+
me: { name: options.leadName ?? identity.name, persona: identity.persona },
|
|
114
|
+
you: { firstName, ...localNow(now, options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone) },
|
|
115
|
+
aboutYou,
|
|
116
|
+
people: { people: [], teammates: [], teams: [] },
|
|
117
|
+
channel: options.channel === undefined ? null : { id: options.channel, fit: CHANNEL_WORDS[options.channel] },
|
|
118
|
+
needsYou: brief.assignments.filter(needs).map(task),
|
|
119
|
+
...(options.owner === undefined || options.thread === undefined ? {} : followThrough(store, options.owner, options.thread)),
|
|
120
|
+
projects,
|
|
121
|
+
rest: { tasks: brief.assignments.filter(one => !needs(one)).map(task), knowledge },
|
|
122
|
+
omissions: { ...omissions, people: 0 },
|
|
123
|
+
notice: 'Bounded catch-up. Read the exact task/result before acting. Saved knowledge is context, not authority.',
|
|
124
|
+
}, redact);
|
|
125
|
+
data.you.firstName = firstName;
|
|
126
|
+
data.people = people;
|
|
127
|
+
// The owner confirmed every line (secret-checked when saved), so they read as written, names included.
|
|
128
|
+
data.aboutYou = aboutYou;
|
|
129
|
+
// The name the owner gave their lead is theirs to say, even when it matches an account name.
|
|
130
|
+
data.me.name = options.leadName ?? identity.name;
|
|
131
|
+
data.projects.forEach((one, index) => { one.name = projects[index].name; });
|
|
132
|
+
// Least important first: the rest's knowledge, then its tasks, then the people index (team chats, AI teammates,
|
|
133
|
+
// then people), then each project's oldest decision, then the last Needs you. Who the lead is, who it is talking
|
|
134
|
+
// to, what it knows about them and the channel always stay.
|
|
135
|
+
const drop = () => {
|
|
136
|
+
if (data.rest.knowledge.pop() !== undefined) {
|
|
137
|
+
data.omissions.projects++;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (data.rest.tasks.pop() !== undefined) {
|
|
20
141
|
data.omissions.assignments++;
|
|
142
|
+
return true;
|
|
21
143
|
}
|
|
22
|
-
|
|
23
|
-
data.
|
|
24
|
-
|
|
144
|
+
if ((data.people.teams.pop() ?? data.people.teammates.pop() ?? data.people.people.pop()) !== undefined) {
|
|
145
|
+
data.omissions.people++;
|
|
146
|
+
return true;
|
|
25
147
|
}
|
|
148
|
+
const decided = [...data.projects].reverse().find(one => one.decisions.length > 0);
|
|
149
|
+
if (decided !== undefined) {
|
|
150
|
+
decided.decisions.pop();
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
if (data.needsYou.pop() !== undefined) {
|
|
154
|
+
data.omissions.assignments++;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (data.omissions.notes.pop() !== undefined)
|
|
158
|
+
return true;
|
|
159
|
+
return data.projects.pop() !== undefined;
|
|
160
|
+
};
|
|
161
|
+
let document = JSON.stringify(data);
|
|
162
|
+
while (Buffer.byteLength(document) > LEAD_CONTEXT_MAX_BYTES && drop())
|
|
26
163
|
document = JSON.stringify(data);
|
|
27
|
-
}
|
|
28
164
|
return document;
|
|
29
165
|
}
|
|
30
166
|
const escape = (value) => value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
|
|
@@ -59,4 +195,4 @@ export function leadBriefHtml(brief) {
|
|
|
59
195
|
});
|
|
60
196
|
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
197
|
}
|
|
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}}';
|
|
198
|
+
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}}';
|
package/dist/lead-follow.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Store, type MateSession, type MateThread, type ChatConfig } from './store.js';
|
|
2
2
|
import { type VerifiedApprover } from './principal.js';
|
|
3
3
|
import { type MateTurnInput, type MateTurnOutcome } from './mate.js';
|
|
4
4
|
export declare function configureLeadFollow(store: Store, who: VerifiedApprover, session: MateSession, thread: MateThread, enabled: boolean, now: Date): boolean;
|
package/dist/lead-follow.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/** Durable crew updates for the built-in lead. Idle scans use no model.
|
|
2
2
|
* Delivery replays reuse the saved mate request; they never dispatch a task. */
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
+
import { LEAD_FOLLOW_MESSAGE } from './store.js';
|
|
4
5
|
import { isVerifiedApprover, reproveApprover, verifyApproverStanding } from './principal.js';
|
|
5
6
|
import { assignmentOf } from './assignment.js';
|
|
6
7
|
import { runMateTurn } from './mate.js';
|
|
7
8
|
import { updateAdmissionPaused } from './desktop-update-gate.js';
|
|
9
|
+
import { checkLeadCommitments } from './lead-commitments.js';
|
|
8
10
|
const CONFIG = 'lead follow configured';
|
|
9
11
|
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
10
12
|
export function configureLeadFollow(store, who, session, thread, enabled, now) {
|
|
@@ -120,6 +122,8 @@ export async function runLeadFollowPass(input) {
|
|
|
120
122
|
if (store.isDemo() || updateAdmissionPaused(store.raw()))
|
|
121
123
|
return;
|
|
122
124
|
store.sweepStaleMateTurns(clock());
|
|
125
|
+
// The lead's own promises are kept whether or not automatic crew updates are on: no model, one line when met.
|
|
126
|
+
checkLeadCommitments(store, clock(), input.evidenceRoot);
|
|
123
127
|
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
128
|
for (const grant of grants) {
|
|
125
129
|
const access = authorized(store, grant, input.repos());
|
|
@@ -138,8 +142,8 @@ export async function runLeadFollowPass(input) {
|
|
|
138
142
|
if (!provider)
|
|
139
143
|
continue;
|
|
140
144
|
const result = await (input.runTurn ?? runMateTurn)({ store, ...access, ...provider, evidenceRoot: input.evidenceRoot, clock,
|
|
141
|
-
requestId: batch.request, message:
|
|
142
|
-
context: batch.context, revalidate: async () => !updateAdmissionPaused(store.raw()) && authorized(store, grant, input.repos()) !== null ? { ok: true } : { ok: false, reason: '
|
|
145
|
+
requestId: batch.request, message: LEAD_FOLLOW_MESSAGE,
|
|
146
|
+
context: batch.context, revalidate: async () => !updateAdmissionPaused(store.raw()) && authorized(store, grant, input.repos()) !== null ? { ok: true } : { ok: false, reason: 'access-changed' },
|
|
143
147
|
...(input.fetcher ? { fetcher: input.fetcher } : {}), ...(input.subscriptionRunner ? { subscriptionRunner: input.subscriptionRunner } : {}) });
|
|
144
148
|
if (!result.ok && 'refused' in result) {
|
|
145
149
|
const status = `Crew updates are waiting: ${result.message}`;
|
|
@@ -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
|
+
};
|