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.
- package/CHANGELOG.md +19 -0
- package/dist/assignment-brief.d.ts +10 -0
- package/dist/assignment-brief.js +49 -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-ask.d.ts +28 -0
- package/dist/chat-ask.js +77 -0
- package/dist/chat-channel.js +6 -1
- package/dist/chat-controls.d.ts +8 -0
- package/dist/chat-controls.js +3 -0
- package/dist/chat-delivery-state.d.ts +6 -1
- package/dist/chat-delivery-state.js +15 -2
- package/dist/chat-delivery.js +20 -3
- package/dist/chat-quiet.js +4 -2
- package/dist/chat-voice.d.ts +3 -6
- package/dist/chat-voice.js +8 -2
- package/dist/discord-chat.js +10 -0
- package/dist/lead-commitments.d.ts +84 -0
- package/dist/lead-commitments.js +208 -0
- package/dist/lead-context.d.ts +32 -3
- package/dist/lead-context.js +133 -19
- package/dist/lead-follow.js +3 -0
- package/dist/lead-identity.d.ts +23 -0
- package/dist/lead-identity.js +33 -0
- package/dist/lead-voice.d.ts +12 -9
- package/dist/lead-voice.js +25 -16
- package/dist/ledger-view.js +3 -2
- package/dist/mate-cli.d.ts +1 -1
- package/dist/mate-cli.js +8 -2
- package/dist/mate-contract.d.ts +1 -1
- package/dist/mate-contract.js +11 -4
- package/dist/mate-progress.js +5 -0
- package/dist/mate-tools.d.ts +18 -1
- package/dist/mate-tools.js +250 -6
- package/dist/mate.d.ts +9 -0
- package/dist/mate.js +34 -38
- package/dist/operate.js +2 -1
- package/dist/project-delete.js +3 -1
- 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 +111 -29
- package/dist/slack-chat.js +10 -0
- package/dist/store.d.ts +51 -0
- package/dist/store.js +115 -1
- 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 +2 -1
- package/dist/teams-chat.js +4 -1
- package/dist/telegram-mate.d.ts +4 -1
- package/dist/telegram-mate.js +17 -1
- package/dist/telegram-progress.js +3 -2
- 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
package/dist/store.js
CHANGED
|
@@ -183,6 +183,16 @@ CREATE TABLE IF NOT EXISTS chat_batch_item (
|
|
|
183
183
|
PRIMARY KEY (batch, task_ref)
|
|
184
184
|
);
|
|
185
185
|
`;
|
|
186
|
+
/** Settings → Lead (no version bump: additive only): what one person calls their lead and the short persona it
|
|
187
|
+
* speaks with. No row reads as the defaults (lead-identity.ts). */
|
|
188
|
+
const LEAD_CONFIG_SCHEMA = `
|
|
189
|
+
CREATE TABLE IF NOT EXISTS lead_config (
|
|
190
|
+
account TEXT PRIMARY KEY,
|
|
191
|
+
name TEXT NOT NULL,
|
|
192
|
+
persona TEXT NOT NULL,
|
|
193
|
+
updated_at TEXT NOT NULL
|
|
194
|
+
);
|
|
195
|
+
`;
|
|
186
196
|
/** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
|
|
187
197
|
* for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
|
|
188
198
|
* (`lead` 1: the owner's lead; `person`/`why`: who the lead asked, and why). `notification_actor` is whose act made
|
|
@@ -227,6 +237,31 @@ CREATE TABLE IF NOT EXISTS project_mute (
|
|
|
227
237
|
PRIMARY KEY (account, repo)
|
|
228
238
|
);
|
|
229
239
|
`;
|
|
240
|
+
/** What the lead promised to follow up on (lead-commitments.ts; no version bump: additive only, so a build that
|
|
241
|
+
* predates it still opens the store). `condition_json` is a task, attempt, check or time; `check_at` is when to look
|
|
242
|
+
* next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
|
|
243
|
+
export const LEAD_COMMITMENT_SCHEMA = `
|
|
244
|
+
CREATE TABLE IF NOT EXISTS lead_commitment (
|
|
245
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
246
|
+
owner TEXT NOT NULL,
|
|
247
|
+
repo TEXT,
|
|
248
|
+
thread INTEGER NOT NULL,
|
|
249
|
+
turn INTEGER,
|
|
250
|
+
channel TEXT NOT NULL DEFAULT 'chat',
|
|
251
|
+
what TEXT NOT NULL,
|
|
252
|
+
condition_json TEXT NOT NULL,
|
|
253
|
+
check_at TEXT NOT NULL,
|
|
254
|
+
expires_at TEXT NOT NULL,
|
|
255
|
+
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'done', 'cancelled', 'expired')),
|
|
256
|
+
created_at TEXT NOT NULL,
|
|
257
|
+
checked_at TEXT,
|
|
258
|
+
closed_at TEXT,
|
|
259
|
+
closed_by TEXT,
|
|
260
|
+
outcome TEXT
|
|
261
|
+
);
|
|
262
|
+
CREATE INDEX IF NOT EXISTS lead_commitment_due ON lead_commitment (state, check_at);
|
|
263
|
+
CREATE INDEX IF NOT EXISTS lead_commitment_owner ON lead_commitment (owner, state, id);
|
|
264
|
+
`;
|
|
230
265
|
/** What still pings on the lead's own work: a security alert. */
|
|
231
266
|
const SECURITY_KINDS = new Set(["secret-detected", "security-release"]);
|
|
232
267
|
/** A failure that leaves nothing for the lead to try: the attempts are spent. */
|
|
@@ -245,6 +280,22 @@ export function stopFactOf(store, runId) {
|
|
|
245
280
|
: /cannot be proven|could not be established|incomplete native/.test(problem) ? "unknown" : "unprovable";
|
|
246
281
|
return { run: Number(/run #(\d+)/.exec(problem)?.[1] ?? runId), kind, problem };
|
|
247
282
|
}
|
|
283
|
+
/** The lead's one question to its owner (no version bump): at most one per answered turn, two to four short options;
|
|
284
|
+
* "Something else" is implied. The tapped option comes back as the owner's next message, so nothing here is an answer:
|
|
285
|
+
* a question is open while no later message from the owner sits in its thread. */
|
|
286
|
+
export const MATE_ASK_SCHEMA = `
|
|
287
|
+
CREATE TABLE IF NOT EXISTS mate_ask (
|
|
288
|
+
turn INTEGER PRIMARY KEY REFERENCES mate_turn(id) ON DELETE CASCADE,
|
|
289
|
+
thread INTEGER NOT NULL REFERENCES mate_thread(id) ON DELETE CASCADE,
|
|
290
|
+
question TEXT NOT NULL,
|
|
291
|
+
options_json TEXT NOT NULL,
|
|
292
|
+
created_at TEXT NOT NULL
|
|
293
|
+
);
|
|
294
|
+
CREATE INDEX IF NOT EXISTS mate_ask_thread ON mate_ask (thread, turn);
|
|
295
|
+
`;
|
|
296
|
+
export const MATE_ASK_OTHER = "Something else";
|
|
297
|
+
/** How long the question's buttons work, on every channel. */
|
|
298
|
+
export const MATE_ASK_TTL_MS = 7 * 86_400_000;
|
|
248
299
|
/** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
|
|
249
300
|
* nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
|
|
250
301
|
* failure across checks, so a row can say both. `account` is a display name only, never a credential. */
|
|
@@ -3830,6 +3881,7 @@ function initializeStore(db, file) {
|
|
|
3830
3881
|
db.exec(LEDGER_CHAIN_SCHEMA);
|
|
3831
3882
|
db.exec(MONITORING_SCHEMA);
|
|
3832
3883
|
db.exec(INTEGRATIONS_SCHEMA);
|
|
3884
|
+
db.exec(MATE_ASK_SCHEMA);
|
|
3833
3885
|
db.exec(SPEND_SCHEMA);
|
|
3834
3886
|
// Sign-in pauses: one row per incident of a provider's sign-in no longer working.
|
|
3835
3887
|
db.exec(PROVIDER_AUTH_SCHEMA);
|
|
@@ -3848,6 +3900,8 @@ function initializeStore(db, file) {
|
|
|
3848
3900
|
// The update a batch line speaks for: its newest fact about that task (null: a finished result).
|
|
3849
3901
|
addColumn(db, "chat_batch_item", "notification", "INTEGER");
|
|
3850
3902
|
db.exec(LEAD_QUIET_SCHEMA);
|
|
3903
|
+
db.exec(LEAD_CONFIG_SCHEMA);
|
|
3904
|
+
db.exec(LEAD_COMMITMENT_SCHEMA);
|
|
3851
3905
|
mergeCheckTables(db);
|
|
3852
3906
|
addColumn(db, "monitoring_status", "target", "TEXT");
|
|
3853
3907
|
// v105: how a teammate's turn was billed (this computer's Claude sign-in, as last seen).
|
|
@@ -19636,6 +19690,46 @@ export class Store {
|
|
|
19636
19690
|
.run(proposal.thread, proposal.turn, proposal.kind, JSON.stringify(proposal.payload), proposal.ceilingDigest, now.toISOString());
|
|
19637
19691
|
return Number(inserted.lastInsertRowid);
|
|
19638
19692
|
}
|
|
19693
|
+
// ---- the lead's question to its owner (ask_owner) -----------------------------
|
|
19694
|
+
/** Record this turn's one question; false when the turn already asked one. */
|
|
19695
|
+
recordMateAsk(ask, now) {
|
|
19696
|
+
const inserted = this.db.prepare("INSERT OR IGNORE INTO mate_ask (turn, thread, question, options_json, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
19697
|
+
.run(ask.turn, ask.thread, ask.question, JSON.stringify(ask.options), now.toISOString());
|
|
19698
|
+
return Number(inserted.changes) === 1;
|
|
19699
|
+
}
|
|
19700
|
+
/** A failed turn keeps no question, as it keeps no drafts. */
|
|
19701
|
+
dropMateAsk(turn) {
|
|
19702
|
+
this.db.prepare("DELETE FROM mate_ask WHERE turn = ?").run(turn);
|
|
19703
|
+
}
|
|
19704
|
+
/** The question an ANSWERED turn asked; null when it asked none or did not answer. */
|
|
19705
|
+
mateAsk(turn) {
|
|
19706
|
+
const row = this.db.prepare("SELECT a.* FROM mate_ask a JOIN mate_turn t ON t.id = a.turn WHERE a.turn = ? AND t.state = 'answered'").get(turn);
|
|
19707
|
+
if (row === undefined)
|
|
19708
|
+
return null;
|
|
19709
|
+
const options = JSON.parse(String(row["options_json"]));
|
|
19710
|
+
return { turn: Number(row["turn"]), thread: Number(row["thread"]), question: String(row["question"]), options: Array.isArray(options) ? options.map(String) : [], createdAt: String(row["created_at"]) };
|
|
19711
|
+
}
|
|
19712
|
+
/** What a tap on the question finds: open; answered (the owner has written in its thread since the reply that asked it);
|
|
19713
|
+
* or expired (older than MATE_ASK_TTL_MS, or the question or the reply that asked it is gone, as after a retention purge). */
|
|
19714
|
+
mateAskState(turn, now) {
|
|
19715
|
+
const ask = this.mateAsk(turn);
|
|
19716
|
+
if (ask === null)
|
|
19717
|
+
return { state: "expired" };
|
|
19718
|
+
const asking = this.db.prepare("SELECT MAX(id) AS id FROM mate_message WHERE thread = ? AND turn = ? AND role = 'assistant'").get(ask.thread, turn);
|
|
19719
|
+
if (asking === undefined || asking["id"] === null)
|
|
19720
|
+
return { state: "expired" };
|
|
19721
|
+
const later = this.db.prepare("SELECT 1 AS hit FROM mate_message WHERE thread = ? AND role = 'operator' AND id > ? LIMIT 1").get(ask.thread, Number(asking["id"]));
|
|
19722
|
+
if (later !== undefined)
|
|
19723
|
+
return { state: "answered" };
|
|
19724
|
+
if (new Date(ask.createdAt).getTime() + MATE_ASK_TTL_MS <= now.getTime())
|
|
19725
|
+
return { state: "expired" };
|
|
19726
|
+
return { state: "open", ask };
|
|
19727
|
+
}
|
|
19728
|
+
/** The question while it is open (see mateAskState); null otherwise. */
|
|
19729
|
+
mateAskOpen(turn, now) {
|
|
19730
|
+
const found = this.mateAskState(turn, now);
|
|
19731
|
+
return found.state === "open" ? found.ask : null;
|
|
19732
|
+
}
|
|
19639
19733
|
// ---- the screenshots a turn selected (v64) ------------------------------------
|
|
19640
19734
|
/**
|
|
19641
19735
|
* Record the screenshots one turn selected for an exact result, under the
|
|
@@ -21612,6 +21706,10 @@ export class Store {
|
|
|
21612
21706
|
.run(row.binding.id, row.binding.botId, row.binding.chatId, row.binding.userId, row.binding.approver, row.binding.approverGeneration, row.updateId, row.messageId, row.replyTo, row.request, row.text, row.context, row.taskId, row.sourceRun, now.toISOString());
|
|
21613
21707
|
return Number(inserted.lastInsertRowid);
|
|
21614
21708
|
}
|
|
21709
|
+
/** Whether a message from this chat is already waiting on, or being answered from, this bot message (a tapped question). */
|
|
21710
|
+
telegramConversationWaitingOn(binding, messageId) {
|
|
21711
|
+
return this.db.prepare("SELECT 1 AS hit FROM telegram_conversation WHERE binding = ? AND message_id = ? AND state IN ('queued', 'running') LIMIT 1").get(binding, messageId) !== undefined;
|
|
21712
|
+
}
|
|
21615
21713
|
getTelegramConversation(id) {
|
|
21616
21714
|
const row = this.db.prepare("SELECT * FROM telegram_conversation WHERE id = ?").get(id);
|
|
21617
21715
|
return row === undefined ? null : readTelegramConversation(row);
|
|
@@ -21742,7 +21840,7 @@ export class Store {
|
|
|
21742
21840
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`);
|
|
21743
21841
|
parts.forEach((part, ordinal) => {
|
|
21744
21842
|
const image = part.kind === "image" ? part : null;
|
|
21745
|
-
insert.run(id, ordinal, part.kind, part.text, part.kind === "reply" ? part.replyTo ?? null : null, part.kind === "card" ? part.proposal : null, part.kind === "card" && part.keyboard != null ? JSON.stringify(part.keyboard) : null, stamp, image?.taskId ?? null, image?.run ?? null, image?.artifact ?? null, image?.sha256 ?? null);
|
|
21843
|
+
insert.run(id, ordinal, part.kind, part.text, part.kind === "reply" ? part.replyTo ?? null : null, part.kind === "card" ? part.proposal : null, (part.kind === "card" || part.kind === "reply") && part.keyboard != null ? JSON.stringify(part.keyboard) : null, stamp, image?.taskId ?? null, image?.run ?? null, image?.artifact ?? null, image?.sha256 ?? null);
|
|
21746
21844
|
});
|
|
21747
21845
|
return true;
|
|
21748
21846
|
});
|
|
@@ -21921,6 +22019,10 @@ export class Store {
|
|
|
21921
22019
|
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_ELSEWHERE}'
|
|
21922
22020
|
WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
|
|
21923
22021
|
AND notification IN (SELECT id FROM notification WHERE recipient IS NOT NULL AND recipient <> ?)`).run(destination, binding.approver);
|
|
22022
|
+
// A promise the lead made on another chat is reported there (lead-commitments.ts), not here.
|
|
22023
|
+
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_OTHER_CHAT}'
|
|
22024
|
+
WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
|
|
22025
|
+
AND notification IN (SELECT id FROM notification WHERE dedupe_key LIKE 'lead-promise:%' AND dedupe_key NOT LIKE 'lead-promise:telegram:%')`).run(destination);
|
|
21924
22026
|
// Pings follow responsibility (the lead's work, this person's own act, a muted project): settled here, unsent.
|
|
21925
22027
|
for (const raw of this.db.prepare(`SELECT n.* FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
|
|
21926
22028
|
WHERE n.resolved_at IS NULL AND d.delivered_at IS NULL AND d.receipt IS NULL AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?)`).all(destination, now.toISOString())) {
|
|
@@ -22143,6 +22245,16 @@ export class Store {
|
|
|
22143
22245
|
.run(day, account, day).changes) === 1;
|
|
22144
22246
|
}
|
|
22145
22247
|
// ---- pings follow responsibility: the lead, who acted, replacements and muted projects ----
|
|
22248
|
+
/** What this person named their lead and its persona, or null when they never saved one. */
|
|
22249
|
+
leadConfig(account) {
|
|
22250
|
+
const row = this.db.prepare("SELECT name, persona FROM lead_config WHERE account = ?").get(account);
|
|
22251
|
+
return row === undefined ? null : { name: String(row["name"]), persona: String(row["persona"]) };
|
|
22252
|
+
}
|
|
22253
|
+
/** Save this person's lead name and persona (checked by the caller: lead-identity.ts). */
|
|
22254
|
+
setLeadConfig(account, name, persona, now) {
|
|
22255
|
+
this.db.prepare(`INSERT INTO lead_config (account, name, persona, updated_at) VALUES (?, ?, ?, ?)
|
|
22256
|
+
ON CONFLICT(account) DO UPDATE SET name = excluded.name, persona = excluded.persona, updated_at = excluded.updated_at`).run(account, name, persona, now.toISOString());
|
|
22257
|
+
}
|
|
22146
22258
|
/** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
|
|
22147
22259
|
mintLeadCredential(owner, by, now) {
|
|
22148
22260
|
return this.transact(() => {
|
|
@@ -22879,6 +22991,8 @@ function readHold(row) {
|
|
|
22879
22991
|
export const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
|
|
22880
22992
|
/** v83: a notification for another person, settled for this destination without sending. */
|
|
22881
22993
|
export const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
|
|
22994
|
+
/** A met promise the lead made on another chat: reported there, settled here without sending. */
|
|
22995
|
+
export const TELEGRAM_SKIPPED_OTHER_CHAT = "skipped:for-another-chat";
|
|
22882
22996
|
/** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
|
|
22883
22997
|
export const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
|
|
22884
22998
|
/** A destination receipt (`d` = notification_delivery) that still owes a
|
package/dist/surface.js
CHANGED
|
@@ -194,7 +194,7 @@ export const COMMAND_GUIDE = [
|
|
|
194
194
|
invocation: `assignment ${action}`,
|
|
195
195
|
synopsis: action === "show" ? "read the root, current work, owner and exact receipt"
|
|
196
196
|
: action === "updates" ? "read durable updates after a cursor; save nextCursor after processing"
|
|
197
|
-
: action === "claim" ? "record lead ownership; with the lead token the task reads \"
|
|
197
|
+
: action === "claim" ? "record lead ownership; with the lead token the task reads \"<the lead's name> is on it\" and leaves Needs you until done, handed over, or 2 hours idle; grants no approval or execution authority"
|
|
198
198
|
: action === "brief" ? "catch up from current assignments and project knowledge in the local database"
|
|
199
199
|
: action === "inbox" ? "receive or replay a saved batch of lead status updates"
|
|
200
200
|
: action === "ack" ? "acknowledge delivery of an inbox batch; leaves task completion unchanged"
|
|
@@ -215,7 +215,7 @@ export const COMMAND_GUIDE = [
|
|
|
215
215
|
})),
|
|
216
216
|
operator("task state", "set a task's state by hand — an operator correction, not a workflow step (--replaced-by <id> with cancelled)"),
|
|
217
217
|
operator("lead token", "mint the lead's credential behind your password (--revoke ends it): its own work pings nobody"),
|
|
218
|
-
{ invocation: "lead say", synopsis: "as the lead: one short message in its person's chat
|
|
218
|
+
{ invocation: "lead say", synopsis: "as the lead: one short message in its person's chat under the lead's name (Settings → Lead); several within two minutes are one message",
|
|
219
219
|
audience: "agent", agentMayInvoke: true, mutation: "unkeyed",
|
|
220
220
|
positionals: [{ name: "text", required: true, meaning: "one short line in plain words" }],
|
|
221
221
|
flags: [jsonFlag, dbFlag,
|
package/dist/task-status.d.ts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/** One answer per task, then the details (docs/design/task-status.md). Every
|
|
2
|
-
* surface — the Tasks list, the task page, the build page, Crew, the Chat
|
|
3
|
-
* result, the Telegram/Slack/Discord/Teams cards and `toolroll status` — reads
|
|
4
|
-
* its headline, sentence, tone and detail rows from `taskStatusOf`, so the
|
|
5
|
-
* words cannot drift apart again. Pure: it reads facts the existing
|
|
6
|
-
* projections already recorded and never changes a check, approval or
|
|
7
|
-
* completion.
|
|
8
|
-
*
|
|
9
|
-
* Severity: red belongs to the Failed headline alone. A detail problem that
|
|
10
|
-
* doesn't undo the outcome (a pull request that couldn't open, shortened
|
|
11
|
-
* output) is an amber note on its own row with one action; the exact
|
|
12
|
-
* technical reason stays one tap away in `why`. */
|
|
13
1
|
import type { AssignmentSnapshot } from "./assignment.js";
|
|
14
2
|
import { type NeedAction, type NeedContext, type NeedKey, type WaitKey } from "./needs-you.js";
|
|
15
3
|
export type { NeedKey, WaitKey } from "./needs-you.js";
|
|
@@ -116,14 +104,16 @@ export type TaskStatusFacts = {
|
|
|
116
104
|
};
|
|
117
105
|
/** Exact technical reasons, shown only on request. */
|
|
118
106
|
why?: readonly string[];
|
|
119
|
-
/** The person's lead took this on (lead-voice.ts): "on-it" reads "
|
|
107
|
+
/** The person's lead took this on (lead-voice.ts): "on-it" reads "<name> is on it" and needs nobody; "lapsed"
|
|
120
108
|
* (two hours without a lead act) is back with the person, and says so. */
|
|
121
109
|
lead?: "on-it" | "lapsed" | null;
|
|
110
|
+
/** What the person calls their lead (Settings → Lead); "Lead" when unnamed. */
|
|
111
|
+
leadName?: string;
|
|
122
112
|
};
|
|
123
113
|
export declare function headlineOf(facts: Pick<TaskStatusFacts, "stage" | "checks" | "report">): Headline;
|
|
124
114
|
/** What the lead's claim says, in place of "waits for you" (lead-voice.ts). */
|
|
125
|
-
export declare const
|
|
126
|
-
export declare const
|
|
115
|
+
export declare const leadOnIt: (name?: string) => string;
|
|
116
|
+
export declare const leadLapsed: (name?: string) => string;
|
|
127
117
|
export declare function taskStatusOf(facts: TaskStatusFacts): TaskStatus;
|
|
128
118
|
export declare function plainReasonOf(stage: TaskStage, code: string, detail: string | null | undefined): string | null;
|
|
129
119
|
export declare function stageOfCode(code: string, options?: {
|
package/dist/task-status.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/** One answer per task, then the details (docs/design/task-status.md). Every
|
|
2
|
+
* surface — the Tasks list, the task page, the build page, Crew, the Chat
|
|
3
|
+
* result, the Telegram/Slack/Discord/Teams cards and `toolroll status` — reads
|
|
4
|
+
* its headline, sentence, tone and detail rows from `taskStatusOf`, so the
|
|
5
|
+
* words cannot drift apart again. Pure: it reads facts the existing
|
|
6
|
+
* projections already recorded and never changes a check, approval or
|
|
7
|
+
* completion.
|
|
8
|
+
*
|
|
9
|
+
* Severity: red belongs to the Failed headline alone. A detail problem that
|
|
10
|
+
* doesn't undo the outcome (a pull request that couldn't open, shortened
|
|
11
|
+
* output) is an amber note on its own row with one action; the exact
|
|
12
|
+
* technical reason stays one tap away in `why`. */
|
|
13
|
+
import { DEFAULT_LEAD_NAME } from "./lead-identity.js";
|
|
1
14
|
import { CODE_NEED, NEEDS, needSentence, waitSentence } from "./needs-you.js";
|
|
2
15
|
/** A cancelled task with a successor reads "Replaced by <id>" everywhere, never "Cancelled". */
|
|
3
16
|
export const replacedWords = (successor) => `Replaced by ${successor}`;
|
|
@@ -145,16 +158,16 @@ function detailsOf(headline, facts) {
|
|
|
145
158
|
return rows;
|
|
146
159
|
}
|
|
147
160
|
/** What the lead's claim says, in place of "waits for you" (lead-voice.ts). */
|
|
148
|
-
export const
|
|
149
|
-
export const
|
|
161
|
+
export const leadOnIt = (name = DEFAULT_LEAD_NAME) => `${name} is on it.`;
|
|
162
|
+
export const leadLapsed = (name = DEFAULT_LEAD_NAME) => `${name} hasn't acted on this for 2 hours, so it's back with you.`;
|
|
150
163
|
/** The headlines a lead's claim speaks for: the ones that would otherwise wait on a person. */
|
|
151
164
|
const LEAD_HEADLINES = new Set(["Needs you", "Failed", "Ready for review", "Stopped"]);
|
|
152
165
|
function leadSentence(headline, facts) {
|
|
153
166
|
const sha = short(facts.checks?.head ?? null);
|
|
154
167
|
// A failed check stays said: the claim changes who acts next, never the result.
|
|
155
168
|
if (headline === "Failed" && facts.checks?.status === "failed")
|
|
156
|
-
return `${facts.checks.level === "quick" ? "Quick checks" : "Checks"} failed${sha === null ? "" : ` on ${sha}`}. ${
|
|
157
|
-
return
|
|
169
|
+
return `${facts.checks.level === "quick" ? "Quick checks" : "Checks"} failed${sha === null ? "" : ` on ${sha}`}. ${leadOnIt(facts.leadName)}`;
|
|
170
|
+
return leadOnIt(facts.leadName);
|
|
158
171
|
}
|
|
159
172
|
export function taskStatusOf(facts) {
|
|
160
173
|
const read = headlineOf(facts);
|
|
@@ -165,7 +178,7 @@ export function taskStatusOf(facts) {
|
|
|
165
178
|
const need = headline === "Needs you" ? { key: facts.need ?? "other", action: NEEDS[facts.need ?? "other"].action } : null;
|
|
166
179
|
const ownWords = need !== null && (need.key === "other" || need.key === "review-result") && facts.action != null;
|
|
167
180
|
const primaryAction = need === null || ownWords ? facts.action ?? null : { label: need.action.label, href: facts.action?.href ?? null };
|
|
168
|
-
const sentence = lead === "on-it" ? leadSentence(headline, facts) : lead === "lapsed" ? `${
|
|
181
|
+
const sentence = lead === "on-it" ? leadSentence(headline, facts) : lead === "lapsed" ? `${leadLapsed(facts.leadName)} ${sentenceOf(headline, facts)}` : sentenceOf(headline, facts);
|
|
169
182
|
return { headline, tone: HEADLINE_TONE[headline], sentence, details: detailsOf(headline, facts),
|
|
170
183
|
primaryAction, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== ""), need };
|
|
171
184
|
}
|
package/dist/team-runtime.js
CHANGED
|
@@ -148,7 +148,8 @@ export function createTeamRuntime(options) {
|
|
|
148
148
|
turnId: receipt.turn, ...(turn?.state === 'answered' ? {} : { error: 'Inspect the saved response; this message was not sent again.' }) }, clock());
|
|
149
149
|
return;
|
|
150
150
|
}
|
|
151
|
-
const result = await runMateTurn({ store, who: proof.who, session, thread, ...provider, message: claim.text, requestId,
|
|
151
|
+
const result = await runMateTurn({ store, who: proof.who, session, thread, ...provider, message: claim.text, requestId, channel: "console",
|
|
152
|
+
...(() => { const name = view.leads.find(lead => lead.id === claim.leadId)?.name; return name === undefined ? {} : { leadName: name }; })(),
|
|
152
153
|
queuedMessageId: claim.messageId,
|
|
153
154
|
onAdmitted: turn => { if (!domain.current(claim) || !domain.bindTurn(claim, turn))
|
|
154
155
|
throw new Error('The queued message changed before admission.'); },
|
package/dist/teams-chat.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Microsoft Teams on the shared chat layer: activities in, Adaptive Cards
|
|
2
2
|
* out, the same durable receipts, rooms and commands as every other channel. */
|
|
3
3
|
import { chatQuestionButtons } from "./teammate-question.js";
|
|
4
|
+
import { chatAskButtons } from "./chat-ask.js";
|
|
4
5
|
import { ChatState, chatHash } from "./chat-delivery-state.js";
|
|
5
6
|
import { channelAccess, chatObject as object, planChatNotifications, planRoomMessages, processChatEvent } from "./chat-delivery.js";
|
|
6
7
|
import { roomCommand } from "./chat-rooms.js";
|
|
@@ -195,7 +196,9 @@ export async function deliverTeamsPart(options) {
|
|
|
195
196
|
const flow = content.flow ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
|
|
196
197
|
// A teammate's question (v93): its options, then "Answer in words".
|
|
197
198
|
const asked = content.question ? chatQuestionButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
|
|
198
|
-
|
|
199
|
+
// The lead's question to its owner: its options, then "Something else".
|
|
200
|
+
const owner = content.ask ? chatAskButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
|
|
201
|
+
actions = [...flow, ...asked, ...owner, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
|
|
199
202
|
}
|
|
200
203
|
const target = content.edit ?? row.message;
|
|
201
204
|
const body = actions.length || content.proposal ? teamsCard(text, actions) : { type: "message", text, textFormat: "plain" };
|
package/dist/telegram-mate.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type PhoneLink } from "./chat-channel.js";
|
|
2
2
|
export { channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps, type PhoneLink, type ResolvedMate, type ParitySupport } from "./chat-channel.js";
|
|
3
3
|
import { type DoorOptions } from "./mate-doors.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type MateAsk, type Store, type TelegramBinding } from "./store.js";
|
|
5
5
|
import type { SubscriptionMateRunner } from "./subscription-chat.js";
|
|
6
6
|
import type { TelegramTransport } from "./telegram.js";
|
|
7
7
|
/** How long one claimed turn may go without a heartbeat before another poller may take it over. */
|
|
@@ -59,6 +59,9 @@ export declare function telegramChannelProblem(store: Store, expected: {
|
|
|
59
59
|
}), readProjects: () => Promise<readonly string[]>): Promise<string | null>;
|
|
60
60
|
export declare function phoneLinkButton(origin: string | null, link: PhoneLink | null): InlineButton[] | null;
|
|
61
61
|
/** One inline button: an opaque callback token (a real in-chat act) or a url (navigation, never authority). */
|
|
62
|
+
/** The buttons under the lead's question: options two to a row, then "Something else". Each names the turn and the
|
|
63
|
+
* option's place, nothing more; a tap re-proves that the question is still open and is the tapper's own. */
|
|
64
|
+
export declare function askKeyboard(ask: MateAsk): CallbackKeyboard;
|
|
62
65
|
export type InlineButton = {
|
|
63
66
|
text: string;
|
|
64
67
|
callback_data: string;
|
package/dist/telegram-mate.js
CHANGED
|
@@ -26,6 +26,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
26
26
|
import { MATE_MESSAGE_MAX_CHARS, runMateTurn } from "./mate.js";
|
|
27
27
|
import { confirmMateProposal, dismissMateProposal } from "./mate-doors.js";
|
|
28
28
|
import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
|
|
29
|
+
import { MATE_ASK_OTHER } from "./store.js";
|
|
29
30
|
import { phoneText } from "./telegram-status.js";
|
|
30
31
|
import { TeamLeads } from "./team-leads.js";
|
|
31
32
|
import { chatResultHref } from "./chat-controls.js";
|
|
@@ -100,6 +101,17 @@ function keyboardWith(callbacks, link) {
|
|
|
100
101
|
const rows = [...(callbacks ?? []), ...(link === null ? [] : [link])];
|
|
101
102
|
return rows.length === 0 ? undefined : rows;
|
|
102
103
|
}
|
|
104
|
+
// ---- proposal cards on the wire ------------------------------------------------------
|
|
105
|
+
/** One inline button: an opaque callback token (a real in-chat act) or a url (navigation, never authority). */
|
|
106
|
+
/** The buttons under the lead's question: options two to a row, then "Something else". Each names the turn and the
|
|
107
|
+
* option's place, nothing more; a tap re-proves that the question is still open and is the tapper's own. */
|
|
108
|
+
export function askKeyboard(ask) {
|
|
109
|
+
const rows = [];
|
|
110
|
+
for (let at = 0; at < ask.options.length; at += 2)
|
|
111
|
+
rows.push(ask.options.slice(at, at + 2).map((label, offset) => ({ text: label.slice(0, 60), callback_data: `ask:${ask.turn}:${at + offset}` })));
|
|
112
|
+
rows.push([{ text: MATE_ASK_OTHER, callback_data: `ask:${ask.turn}:x` }]);
|
|
113
|
+
return rows;
|
|
114
|
+
}
|
|
103
115
|
/** Mint the card's tokens before the send, so a tap can never name a token that does not exist. */
|
|
104
116
|
export function mintCardTokens(store, binding, proposal, now, messageId, chatId = binding.chatId) {
|
|
105
117
|
const confirm = randomBytes(16).toString("hex");
|
|
@@ -579,6 +591,10 @@ async function runTelegramConversation(row, args) {
|
|
|
579
591
|
return store.transact(() => {
|
|
580
592
|
const now = clock();
|
|
581
593
|
const parts = splitParts(reply).map((text, index) => ({ kind: "reply", text, replyTo: index === 0 ? row.messageId : null }));
|
|
594
|
+
// The lead's question to its owner: one tap per option, then "Something else".
|
|
595
|
+
const ask = store.mateAsk(turn);
|
|
596
|
+
if (ask !== null)
|
|
597
|
+
parts.push({ kind: "reply", text: phoneText(ask.question, 1_000), keyboard: askKeyboard(ask) });
|
|
582
598
|
// Only a turn that answered may have its selection sent: a failed or revoked turn's rows were deleted with its drafts, and the state is read again here.
|
|
583
599
|
const selected = store.getMateTurn(turn)?.state === "answered" ? store.listMateTurnEvidence(turn) : [];
|
|
584
600
|
for (const image of selected)
|
|
@@ -694,7 +710,7 @@ async function runTelegramConversation(row, args) {
|
|
|
694
710
|
store, who, session, thread, config, key: null, message: row.text, requestId: row.request,
|
|
695
711
|
...(row.context === null ? {} : { context: row.context }),
|
|
696
712
|
...(options.subscriptionRunner === undefined ? {} : { subscriptionRunner: options.subscriptionRunner }),
|
|
697
|
-
clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents",
|
|
713
|
+
clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram",
|
|
698
714
|
});
|
|
699
715
|
}
|
|
700
716
|
finally {
|
|
@@ -9,6 +9,7 @@ import { chatResultHref, chatControlHref } from "./chat-controls.js";
|
|
|
9
9
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
10
10
|
import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
|
|
11
11
|
import { leadClaimOf } from "./lead-voice.js";
|
|
12
|
+
import { leadNameOf } from "./lead-identity.js";
|
|
12
13
|
import { chatTitle } from "./chat-voice.js";
|
|
13
14
|
import { manualReviewOnly } from "./proof.js";
|
|
14
15
|
import { failedCheckExit } from "./workspace-ui.js";
|
|
@@ -79,11 +80,11 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
|
|
|
79
80
|
const completer = assignment?.completion?.actor.replace(/^(?:operator|coordinator|lead):/, "") ?? null;
|
|
80
81
|
if (viewer !== undefined && facts.completedBy != null && (completer === viewer || facts.completedBy === viewer))
|
|
81
82
|
facts = { ...facts, completedBy: null };
|
|
82
|
-
// The person's lead took it on: "
|
|
83
|
+
// The person's lead took it on: "<name> is on it" rather than waiting for them (lead-voice.ts).
|
|
83
84
|
if (facts.lead === undefined) {
|
|
84
85
|
const lead = leadClaimOf(store, taskId, now, viewer);
|
|
85
86
|
if (lead !== null)
|
|
86
|
-
facts = { ...facts, lead: lead.state };
|
|
87
|
+
facts = { ...facts, lead: lead.state, leadName: leadNameOf(store, lead.owner) };
|
|
87
88
|
}
|
|
88
89
|
const status = taskStatusOf(facts);
|
|
89
90
|
const checkProgress = store.checkProgress(run.id);
|
package/dist/telegram.js
CHANGED
|
@@ -31,7 +31,7 @@ import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_
|
|
|
31
31
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
32
32
|
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
33
33
|
import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
|
|
34
|
-
import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText } from "./lead-voice.js";
|
|
34
|
+
import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
|
|
35
35
|
import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
|
|
36
36
|
import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
|
|
37
37
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
@@ -920,7 +920,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
920
920
|
};
|
|
921
921
|
/** The lead's words (lead-voice.ts): one message; a later say within two minutes edits it in place. */
|
|
922
922
|
const leadSayOutcome = async (fact) => {
|
|
923
|
-
const words = leadSayText(fact);
|
|
923
|
+
const words = leadSayText(store, fact);
|
|
924
924
|
const button = fact.link === null ? null : factButton(phoneOrigin, fact.link);
|
|
925
925
|
const keyboard = button === null ? [] : [button];
|
|
926
926
|
const earlier = leadSayEarlier(store, fact).map(id => store.telegramMessageOf(id, fact.destination)).find(one => one !== null) ?? null;
|
|
@@ -1064,7 +1064,7 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1064
1064
|
// A short title, never the task's id or a "— revision" suffix (chat-voice.ts).
|
|
1065
1065
|
const title = notification.taskId === null ? undefined : chatTitle(store, notification.taskId);
|
|
1066
1066
|
const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
|
|
1067
|
-
const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : notification.
|
|
1067
|
+
const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : leadSubjectOf(store, notification, binding.approver)}${body === "" ? "" : `\n\n${body}`}`, task);
|
|
1068
1068
|
// The task's title once: in front, unless the words already name it.
|
|
1069
1069
|
const parts = split(`${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
|
|
1070
1070
|
const button = notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
|
|
@@ -1970,6 +1970,46 @@ function applyCallback(context, update, effects) {
|
|
|
1970
1970
|
}
|
|
1971
1971
|
return;
|
|
1972
1972
|
}
|
|
1973
|
+
// The lead's question to its owner: an option is sent as their next message; "Something else" asks them to type it.
|
|
1974
|
+
const askTap = /^ask:([1-9][0-9]{0,14}):([0-3]|x)$/.exec(token);
|
|
1975
|
+
if (askTap !== null) {
|
|
1976
|
+
if (tapChat !== binding.chatId || context.conversation === undefined) {
|
|
1977
|
+
report.ignored++;
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
const turn = Number(askTap[1]);
|
|
1981
|
+
const found = store.getMateTurn(turn)?.approver === binding.approver ? store.mateAskState(turn, clock()) : { state: "expired" };
|
|
1982
|
+
if (found.state !== "open") {
|
|
1983
|
+
const line = found.state === "answered" ? "That question was already answered." : "That question expired. Send your answer as a message.";
|
|
1984
|
+
ack(line);
|
|
1985
|
+
editText(`${message.text ?? ""}\n\n${line}`.trim().slice(0, 4000));
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
const ask = found.ask;
|
|
1989
|
+
if (store.telegramConversationWaitingOn(binding.id, String(message.message_id))) {
|
|
1990
|
+
ack("Your answer is on its way.");
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
if (askTap[2] === "x") {
|
|
1994
|
+
ack("Type your answer as a message.");
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
const option = ask.options[Number(askTap[2])];
|
|
1998
|
+
if (option === undefined) {
|
|
1999
|
+
ack("That option is no longer there.");
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
const focused = store.chatFocus("telegram", binding.id);
|
|
2003
|
+
store.enqueueTelegramConversation({
|
|
2004
|
+
binding, updateId: update.update_id, messageId: String(message.message_id), replyTo: null,
|
|
2005
|
+
request: telegramRequestId(botId, binding.id, update.update_id), text: option,
|
|
2006
|
+
context: focused === null ? null : focusContextFor(focused), taskId: focused, sourceRun: null,
|
|
2007
|
+
}, clock());
|
|
2008
|
+
report.chatQueued = (report.chatQueued ?? 0) + 1;
|
|
2009
|
+
ack(`Sent: ${option}`.slice(0, 190));
|
|
2010
|
+
editText(`${message.text ?? ask.question}\n\nYou chose: ${option}`.slice(0, 4000));
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
1973
2013
|
const action = store.getTelegramAction(token);
|
|
1974
2014
|
if (action === null && context.conversation !== undefined && store.getTelegramProposalAction(token) !== null) {
|
|
1975
2015
|
// A proposal card's button: the shared confirm door, inside this
|
package/dist/test-temp.js
CHANGED
|
@@ -18,7 +18,7 @@ export const TEST_TEMP_PREFIXES = [
|
|
|
18
18
|
"flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
|
|
19
19
|
"proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
|
|
20
20
|
"task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
|
|
21
|
-
"needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-", "deploy-before-swap-", "one-vocabulary-", "accept-finishes-",
|
|
21
|
+
"needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-", "deploy-before-swap-", "one-vocabulary-", "accept-finishes-", "lead-promises-",
|
|
22
22
|
// Playwright's browser profiles and artifacts.
|
|
23
23
|
"playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
|
|
24
24
|
];
|
package/dist/when-html.d.ts
CHANGED
|
@@ -13,10 +13,15 @@ export declare function deadlineWords(deadline: {
|
|
|
13
13
|
/** The exact minute in `zone` ("2026-09-30 16:39", with " UTC" when it is UTC): a time's title, one hover away. */
|
|
14
14
|
export declare function fullWhen(iso: string, zone?: string): string;
|
|
15
15
|
/** Every server-rendered stamp (`whenHtml`'s `<time data-when>`) inside `root`, reworded by the one formatter in the
|
|
16
|
-
* viewer's zone: the same words on a desk and a phone, the exact minute in the title.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* viewer's zone: the same words on a desk and a phone, the exact minute in the title. An exact stamp
|
|
17
|
+
* (`exactWhenHtml`'s `data-when="exact"`, the ledger's seconds) keeps its seconds, in the viewer's zone, and its
|
|
18
|
+
* ISO title. The server's UTC words stay only without script. Times React writes and relative ages ("3 min ago")
|
|
19
|
+
* carry no `data-when` and are never touched. */
|
|
19
20
|
export declare function localizeTimes(root: ParentNode, now?: Date, zone?: string): void;
|
|
21
|
+
/** The exact second in `zone` ("2026-09-30 16:39:12", with " UTC" when it is UTC): an audit record's stamp. */
|
|
22
|
+
export declare function exactWhen(iso: string, zone?: string): string;
|
|
23
|
+
/** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
|
|
24
|
+
export declare function exactWhenHtml(iso: string): string;
|
|
20
25
|
/** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
|
|
21
26
|
export declare function whenHtml(iso: string | null, full: string, now?: Date): string;
|
|
22
27
|
/** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
|
package/dist/when-html.js
CHANGED
|
@@ -75,14 +75,21 @@ export function fullWhen(iso, zone = "UTC") {
|
|
|
75
75
|
return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")} ${p.clock}${zone === "UTC" ? " UTC" : ""}`;
|
|
76
76
|
}
|
|
77
77
|
/** Every server-rendered stamp (`whenHtml`'s `<time data-when>`) inside `root`, reworded by the one formatter in the
|
|
78
|
-
* viewer's zone: the same words on a desk and a phone, the exact minute in the title.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
* viewer's zone: the same words on a desk and a phone, the exact minute in the title. An exact stamp
|
|
79
|
+
* (`exactWhenHtml`'s `data-when="exact"`, the ledger's seconds) keeps its seconds, in the viewer's zone, and its
|
|
80
|
+
* ISO title. The server's UTC words stay only without script. Times React writes and relative ages ("3 min ago")
|
|
81
|
+
* carry no `data-when` and are never touched. */
|
|
81
82
|
export function localizeTimes(root, now = new Date(), zone = viewerZone()) {
|
|
82
83
|
root.querySelectorAll("time[data-when][datetime]").forEach(node => {
|
|
83
84
|
const iso = node.getAttribute("datetime") ?? "";
|
|
84
85
|
if (iso === "" || Number.isNaN(new Date(iso).getTime()))
|
|
85
86
|
return;
|
|
87
|
+
if (node.getAttribute("data-when") === "exact") {
|
|
88
|
+
const words = exactWhen(iso, zone);
|
|
89
|
+
if (node.textContent !== words)
|
|
90
|
+
node.textContent = words;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
86
93
|
const words = shortWhen(iso, now, zone), full = fullWhen(iso, zone);
|
|
87
94
|
if (node.title !== full)
|
|
88
95
|
node.title = full;
|
|
@@ -90,6 +97,19 @@ export function localizeTimes(root, now = new Date(), zone = viewerZone()) {
|
|
|
90
97
|
node.textContent = words;
|
|
91
98
|
});
|
|
92
99
|
}
|
|
100
|
+
/** The exact second in `zone` ("2026-09-30 16:39:12", with " UTC" when it is UTC): an audit record's stamp. */
|
|
101
|
+
export function exactWhen(iso, zone = "UTC") {
|
|
102
|
+
const at = new Date(iso);
|
|
103
|
+
if (Number.isNaN(at.getTime()))
|
|
104
|
+
return iso;
|
|
105
|
+
const seconds = String(at.getUTCSeconds()).padStart(2, "0");
|
|
106
|
+
const minute = fullWhen(iso, zone);
|
|
107
|
+
return zone === "UTC" ? minute.replace(/ UTC$/, `:${seconds} UTC`) : `${minute}:${seconds}`;
|
|
108
|
+
}
|
|
109
|
+
/** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
|
|
110
|
+
export function exactWhenHtml(iso) {
|
|
111
|
+
return `<time data-when="exact" datetime="${escape(iso)}" title="${escape(iso)}">${escape(exactWhen(iso))}</time>`;
|
|
112
|
+
}
|
|
93
113
|
/** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
|
|
94
114
|
export function whenHtml(iso, full, now = new Date()) {
|
|
95
115
|
if (iso === null || iso === "")
|
package/dist/work-index.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export type WorkIndexItem = {
|
|
|
34
34
|
digest: string;
|
|
35
35
|
} | null;
|
|
36
36
|
evidence: 'recorded';
|
|
37
|
+
/** The viewer's own lead took it on (lead-voice.ts): "<name> is on it.", by the name they gave it, read now. */
|
|
38
|
+
lead?: string;
|
|
37
39
|
};
|
|
38
40
|
export type WorkIndexOptions = {
|
|
39
41
|
view?: WorkView;
|