approval-md 0.4.5 → 0.4.7
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/SPEC.md +24 -3
- package/dist/src/channels/contract.d.ts +15 -0
- package/dist/src/channels/contract.js +23 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/telegram.d.ts +180 -33
- package/dist/src/channels/telegram.js +256 -87
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +114 -35
- package/dist/src/cli/channel-telegram.js +259 -120
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/doctor.js +33 -13
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/help.d.ts +2 -0
- package/dist/src/cli/help.js +39 -3
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-exec-code.d.ts +150 -0
- package/dist/src/cli/hook-exec-code.js +357 -0
- package/dist/src/cli/hook-exec-code.js.map +1 -0
- package/dist/src/cli/hook.d.ts +154 -1
- package/dist/src/cli/hook.js +2344 -111
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/instructions.js +9 -6
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/main.js +9 -0
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/study.d.ts +22 -0
- package/dist/src/cli/study.js +153 -0
- package/dist/src/cli/study.js.map +1 -0
- package/dist/src/cli/verb-registry.d.ts +14 -0
- package/dist/src/cli/verb-registry.js +37 -0
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/command-class.d.ts +349 -1
- package/dist/src/core/command-class.js +2347 -87
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/exec-code.d.ts +77 -0
- package/dist/src/core/exec-code.js +60 -0
- package/dist/src/core/exec-code.js.map +1 -0
- package/dist/src/core/gate.d.ts +38 -0
- package/dist/src/core/gate.js +26 -0
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/git-push-argv.d.ts +111 -0
- package/dist/src/core/git-push-argv.js +375 -0
- package/dist/src/core/git-push-argv.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +71 -30
- package/dist/src/core/harness-version.js +101 -34
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +8 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +13 -1
- package/dist/src/core/sender-identity.js +15 -0
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/study-withdraw.d.ts +137 -0
- package/dist/src/core/study-withdraw.js +280 -0
- package/dist/src/core/study-withdraw.js.map +1 -0
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/mcp/server.js +1 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/README-extended.md +3 -2
- package/docs/cli-reference.md +83 -7
- package/package.json +2 -2
- package/schema/event.schema.json +213 -1
- package/schema/fixtures/event/invalid/approval-granted-study.json +23 -0
- package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-cli-channel.json +23 -0
- package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-operator.json +24 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-extra-key.json +24 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-unknown-reason.json +23 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-email-operator.json +20 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-operator.json +19 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-ref.json +19 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-actor.json +20 -0
- package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-channel.json +20 -0
- package/schema/fixtures/event/invalid/approval-revoked-study.json +20 -0
- package/schema/fixtures/event/valid/approval-rejected-looks-wrong.json +23 -0
- package/schema/fixtures/event/valid/approval-rejected-study-withdrawn.json +20 -0
- package/schema/fixtures/policy/invalid/exec-code-custody-boolean.json +11 -0
- package/schema/fixtures/policy/invalid/exec-code-execute-code-value.json +11 -0
- package/schema/fixtures/policy/invalid/exec-code-unknown-key.json +12 -0
- package/schema/fixtures/policy/valid/exec-code-both.json +17 -0
- package/schema/fixtures/policy/valid/exec-code-custody.json +11 -0
- package/schema/policy.schema.json +23 -0
|
@@ -227,6 +227,7 @@ import { GLOSS_UNVERIFIED_SUFFIX, refusedDecisionLine } from "./contract.js";
|
|
|
227
227
|
// §11.1 invariant 10's guard scans the modules that decide, which this is not.
|
|
228
228
|
import { REACTIONS } from "../core/audit.js";
|
|
229
229
|
import { DEFAULT_PROMPT_STYLE, TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
|
|
230
|
+
import { isAttestationActionKey } from "../core/policy-proposal.js";
|
|
230
231
|
import { commandPayloadView, payloadRegionText } from "./payload-view.js";
|
|
231
232
|
import { MINIMAL_DENY_LABEL, renderTelegramMinimal } from "./telegram-minimal.js";
|
|
232
233
|
// ---------------------------------------------------------------------------
|
|
@@ -310,27 +311,84 @@ export const TELEGRAM_STALE_UNKNOWN = "This request is not open here — it was
|
|
|
310
311
|
*/
|
|
311
312
|
export const TELEGRAM_PROMPT_HEADING = "APPROVAL REQUIRED";
|
|
312
313
|
/**
|
|
313
|
-
* What the label over the payload chunks names (APRV-162)
|
|
314
|
+
* What the label over the payload chunks names (APRV-162), in a resident's
|
|
315
|
+
* words (P-9).
|
|
314
316
|
*
|
|
315
317
|
* The chunks carry the canonical rendering, which is a deterministic function
|
|
316
|
-
* of the bytes and not the bytes themselves
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
318
|
+
* of the bytes and not the bytes themselves, and the rendering names its own
|
|
319
|
+
* `display_hash` and store path inside the block (SPEC.md §10.3, bound
|
|
320
|
+
* verbatim by `display_hash`). This label sits OUTSIDE that block, so it is
|
|
321
|
+
* free to say what the block is for the person reading it: what they are
|
|
322
|
+
* approving. Before P-9 it said "the canonical rendering this approval's
|
|
323
|
+
* display_hash names; raw bytes at the store path inside", which is the
|
|
324
|
+
* block's own first lines restated in the system's vocabulary.
|
|
320
325
|
*/
|
|
321
|
-
export const PAYLOAD_CHUNK_LABEL_TAIL = "
|
|
322
|
-
export const PAYLOAD_CHUNK_LABEL = `
|
|
326
|
+
export const PAYLOAD_CHUNK_LABEL_TAIL = "what you are approving";
|
|
327
|
+
export const PAYLOAD_CHUNK_LABEL = `Exactly ${PAYLOAD_CHUNK_LABEL_TAIL}`;
|
|
328
|
+
/**
|
|
329
|
+
* The plain-words label over lines the requesting agent wrote (P-9).
|
|
330
|
+
*
|
|
331
|
+
* The same words the minimal card uses for the agent's own summary: the reader
|
|
332
|
+
* learns that the agent said it and that nobody checked it, which is what the
|
|
333
|
+
* old "CLAIMED ... NOT verified by the runtime" said in the system's
|
|
334
|
+
* vocabulary. The author id stays after it, because which agent said a thing
|
|
335
|
+
* is part of what an approver weighs (APRV-144).
|
|
336
|
+
*/
|
|
337
|
+
export const TELEGRAM_CLAIMED_LABEL = "What your agent says (not checked)";
|
|
323
338
|
/**
|
|
324
339
|
* What the claimed block is headed, and what a second claimed message is headed
|
|
325
340
|
* when a rationale overflows one (APRV-165).
|
|
326
341
|
*
|
|
327
|
-
* Both say
|
|
328
|
-
* message a reader may see first, and a claimed line that arrives under
|
|
329
|
-
* heading at all reads as the runtime's own.
|
|
342
|
+
* Both say the lines are the agent's and not checked, because a continuation
|
|
343
|
+
* is a message a reader may see first, and a claimed line that arrives under
|
|
344
|
+
* no heading at all reads as the runtime's own.
|
|
345
|
+
*/
|
|
346
|
+
export const TELEGRAM_CLAIMED_HEADING_PREFIX = `${TELEGRAM_CLAIMED_LABEL} —`;
|
|
347
|
+
export const TELEGRAM_CLAIMED_CONTINUED_HEADING = "What your agent says (continued, not checked)";
|
|
348
|
+
/**
|
|
349
|
+
* The heading over the rows the runtime derived itself (P-9): the class, the
|
|
350
|
+
* wait, the budget. Before P-9 it read "COMPUTED — derived by the runtime from
|
|
351
|
+
* the log, the policy and the payload bytes". The rows keep their origin tags,
|
|
352
|
+
* which the village relay reads (its quiet hold matches the `ttl` row up to
|
|
353
|
+
* its `<i>(` tag).
|
|
354
|
+
*/
|
|
355
|
+
export const TELEGRAM_COMPUTED_HEADING = "Checked by approval.md";
|
|
356
|
+
/** The most characters of an author id the claimed label shows (fold round 1, S3). */
|
|
357
|
+
export const TELEGRAM_CLAIMED_AUTHOR_MAX = 80;
|
|
358
|
+
/**
|
|
359
|
+
* {@link TELEGRAM_CLAIMED_LABEL} naming who wrote the lines under it.
|
|
360
|
+
*
|
|
361
|
+
* The author is the requester's actor id, which the actor rule
|
|
362
|
+
* (`^(human|agent):.+`) checks only at its start: a line break later in it
|
|
363
|
+
* passes. Escaping stops markup, but not a second line inside the bold heading
|
|
364
|
+
* that reads like the runtime's own "Checked by approval.md" (fold round 1,
|
|
365
|
+
* S3). So every run of whitespace, line breaks included, becomes one space,
|
|
366
|
+
* control and format characters are dropped, and the id is cut at
|
|
367
|
+
* {@link TELEGRAM_CLAIMED_AUTHOR_MAX} characters with an ellipsis. The label is
|
|
368
|
+
* always one line.
|
|
369
|
+
*/
|
|
370
|
+
export function claimedLabelFor(author) {
|
|
371
|
+
return `${TELEGRAM_CLAIMED_LABEL} — ${oneLineAuthor(author)}`;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* An author id as one short line (fold round 1, S3): whitespace runs, line
|
|
375
|
+
* breaks included, become one space; control and format characters go; the id
|
|
376
|
+
* is cut at {@link TELEGRAM_CLAIMED_AUTHOR_MAX} characters with an ellipsis.
|
|
377
|
+
* Used by {@link claimedLabelFor} and by the `(author)` tag on each claimed
|
|
378
|
+
* row, which carries the same id. A computed row's origin is a fixed word, so
|
|
379
|
+
* this changes nothing there.
|
|
330
380
|
*/
|
|
331
|
-
export
|
|
332
|
-
|
|
333
|
-
|
|
381
|
+
export function oneLineAuthor(author) {
|
|
382
|
+
const flat = author
|
|
383
|
+
.replace(/\s+/gu, " ")
|
|
384
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, "")
|
|
385
|
+
.trim();
|
|
386
|
+
const chars = Array.from(flat);
|
|
387
|
+
const shown = chars.length > TELEGRAM_CLAIMED_AUTHOR_MAX
|
|
388
|
+
? `${chars.slice(0, TELEGRAM_CLAIMED_AUTHOR_MAX - 1).join("")}…`
|
|
389
|
+
: flat;
|
|
390
|
+
return shown.length === 0 ? "unknown" : shown;
|
|
391
|
+
}
|
|
334
392
|
/**
|
|
335
393
|
* The most members one digest may carry (APRV-115).
|
|
336
394
|
*
|
|
@@ -361,6 +419,29 @@ export const TELEGRAM_TERMINAL_HEADLINES = {
|
|
|
361
419
|
expired: "✗ EXPIRED — the approval window closed",
|
|
362
420
|
withdrawn: "WITHDRAWN — no decision is needed",
|
|
363
421
|
};
|
|
422
|
+
/**
|
|
423
|
+
* The headline a "Looks wrong" tap settles its card on (amended SPEC.md §8,
|
|
424
|
+
* the study record). The record underneath is an ordinary `approval.rejected`;
|
|
425
|
+
* this says, in the resident's words, that the card was skipped because they
|
|
426
|
+
* flagged it. Emoji-free like every other headline here.
|
|
427
|
+
*/
|
|
428
|
+
export const TELEGRAM_LOOKS_WRONG_HEADLINE = "SKIPPED — you flagged this as looks wrong";
|
|
429
|
+
/**
|
|
430
|
+
* The label of the study-mode button (amended SPEC.md §8, the study record).
|
|
431
|
+
* Drawn only while the listener was launched with `APPROVAL_STUDY_MODE=1`, on a
|
|
432
|
+
* row of its own under Approve and Reject. Its verb is `w`, a reject with the
|
|
433
|
+
* `looks_wrong` flag.
|
|
434
|
+
*/
|
|
435
|
+
export const TELEGRAM_LOOKS_WRONG_LABEL = "🚩 Looks wrong";
|
|
436
|
+
/**
|
|
437
|
+
* The headline a card settles on when the study team withdrew its request
|
|
438
|
+
* (amended SPEC.md §8, the study record: an `approval.rejected` carrying
|
|
439
|
+
* `payload.study.reason` `study_withdrawn`). It replaces "✗ REJECTED" for that
|
|
440
|
+
* record only, so the resident reads that nobody they know said no and that
|
|
441
|
+
* nothing ran. The operator id in the record is never shown. Emoji-free like
|
|
442
|
+
* every other headline here.
|
|
443
|
+
*/
|
|
444
|
+
export const TELEGRAM_STUDY_WITHDRAWN_HEADLINE = "WITHDRAWN BY THE STUDY TEAM";
|
|
364
445
|
/** Whether a derived request state is one an annotation can settle a message on. */
|
|
365
446
|
export function isTelegramTerminalState(state) {
|
|
366
447
|
return Object.prototype.hasOwnProperty.call(TELEGRAM_TERMINAL_HEADLINES, state);
|
|
@@ -736,16 +817,16 @@ export function renderTelegram(request, heading = TELEGRAM_PROMPT_HEADING, layou
|
|
|
736
817
|
claimedLines.push(entry);
|
|
737
818
|
}
|
|
738
819
|
const author = request.summary.kind === "claimed" ? request.summary.author : "the requesting party";
|
|
739
|
-
const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(entry.text)} <i>(${escapeHtml(entry.origin)})</i>`;
|
|
820
|
+
const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(entry.text)} <i>(${escapeHtml(oneLineAuthor(entry.origin))})</i>`;
|
|
740
821
|
const header = [
|
|
741
822
|
`<b>${escapeHtml(heading)}</b>`,
|
|
742
823
|
`<code>${escapeHtml(request.action_key.value)}</code>`,
|
|
743
824
|
"",
|
|
744
|
-
|
|
825
|
+
`<b>${TELEGRAM_COMPUTED_HEADING}</b>`,
|
|
745
826
|
...computedLines.map(render),
|
|
746
827
|
].join("\n");
|
|
747
828
|
const claimedText = [
|
|
748
|
-
`<b>${
|
|
829
|
+
`<b>${escapeHtml(claimedLabelFor(author))}</b>`,
|
|
749
830
|
...claimedLines.map(render),
|
|
750
831
|
].join("\n");
|
|
751
832
|
return {
|
|
@@ -1048,44 +1129,33 @@ export function groupForDigest(requests, max = TELEGRAM_DIGEST_MAX_MEMBERS) {
|
|
|
1048
1129
|
return groups;
|
|
1049
1130
|
}
|
|
1050
1131
|
/**
|
|
1051
|
-
* The computed
|
|
1132
|
+
* The one computed line a digest states about its members (P-9).
|
|
1133
|
+
*
|
|
1134
|
+
* Read off the members themselves, which is sound precisely because the
|
|
1135
|
+
* grouping key made them alike: a digest whose members disagreed about their
|
|
1136
|
+
* class or their task is a digest the listener would not have built. What the
|
|
1137
|
+
* line says is the thing an approver needs before the buttons: why these are
|
|
1138
|
+
* one message, and that every request it decides is shown in full above it
|
|
1139
|
+
* (SPEC.md §10.3).
|
|
1052
1140
|
*
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
*
|
|
1141
|
+
* Before P-9 this was five facts with origin tags: the class name, the
|
|
1142
|
+
* autonomy, the task id, the grouping key (`keys:body,from,subject,to`) and the
|
|
1143
|
+
* payload binding. Each is still on every member's own card above, where an
|
|
1144
|
+
* operator who wants it finds it; the digest is where a resident answers, and
|
|
1145
|
+
* there it was ids and vocabulary between them and the buttons.
|
|
1058
1146
|
*/
|
|
1059
|
-
export function
|
|
1147
|
+
export function digestLine(members) {
|
|
1060
1148
|
const first = members[0];
|
|
1061
1149
|
if (first === undefined)
|
|
1062
|
-
return
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
const
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
{ label: "class", text: classes.join(", "), origin: originOf(first.class) },
|
|
1072
|
-
{ label: "autonomy", text: autonomies.join(", "), origin: originOf(first.autonomy) },
|
|
1073
|
-
{ label: "task", text: first.task.value ?? "(none)", origin: originOf(first.task) },
|
|
1074
|
-
{
|
|
1075
|
-
label: "grouped by",
|
|
1076
|
-
text: shared === null
|
|
1077
|
-
? `one class, one task, one payload shape (${payloadShapeKey(first.fullPayload.value?.value)})`
|
|
1078
|
-
: `one tool call: one task, one payload, ${String(classes.length)} class(es) of the same command`,
|
|
1079
|
-
origin: "grouping",
|
|
1080
|
-
},
|
|
1081
|
-
{
|
|
1082
|
-
label: "payloads",
|
|
1083
|
-
text: shared === null
|
|
1084
|
-
? `${members.length} full payloads, one per request, in the ${members.length} prompts above this message`
|
|
1085
|
-
: `one payload, shared by all ${members.length} requests, in the prompt above this message`,
|
|
1086
|
-
origin: originOf(first.fullPayload),
|
|
1087
|
-
},
|
|
1088
|
-
];
|
|
1150
|
+
return "";
|
|
1151
|
+
const count = String(members.length);
|
|
1152
|
+
if (sharedPayload(members) !== null) {
|
|
1153
|
+
return `All ${count} are about one action, shown once in full above.`;
|
|
1154
|
+
}
|
|
1155
|
+
const task = first.task.value;
|
|
1156
|
+
return task === null || task.length === 0
|
|
1157
|
+
? `All ${count} are the same kind of request. Each one is shown in full above.`
|
|
1158
|
+
: `All ${count} come from the same task. Each one is shown in full above.`;
|
|
1089
1159
|
}
|
|
1090
1160
|
/**
|
|
1091
1161
|
* The one payload every member is about, or `null` when they differ
|
|
@@ -1113,36 +1183,56 @@ function sharedPayload(members) {
|
|
|
1113
1183
|
}
|
|
1114
1184
|
/**
|
|
1115
1185
|
* The collapsed re-delivery's own message: what is waiting, and one way to
|
|
1116
|
-
* clear it (APRV-287).
|
|
1186
|
+
* clear it (APRV-287), in a resident's words (P-9).
|
|
1187
|
+
*
|
|
1188
|
+
* Everything above the claimed label is computed by the runtime from the
|
|
1189
|
+
* verified log (the reminder's count, why nobody is waiting, each member's
|
|
1190
|
+
* age), and the per-member lines carry the agent's own summaries under a label
|
|
1191
|
+
* that says they are the agent's and were not checked, exactly as an ordinary
|
|
1192
|
+
* digest does (SPEC.md §9: computed and claimed stay visibly separate). What it
|
|
1193
|
+
* does NOT carry is any payload, and the footer says so in the same breath as
|
|
1194
|
+
* it explains why the only button rejects: a decision is bound to the bytes the
|
|
1195
|
+
* approver was shown, and nothing here shows them.
|
|
1117
1196
|
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
* bound to the bytes the approver was shown, and nothing here shows them.
|
|
1197
|
+
* Before P-9 each member line led with its action key, and the text explained
|
|
1198
|
+
* the hook's wait, log events and `approval grant <action key>`. None of that
|
|
1199
|
+
* helps the person holding the phone, so a member is now its summary and its
|
|
1200
|
+
* age, and the footer says what they can do: use the earlier message if its
|
|
1201
|
+
* buttons still work (`staleCopy`), or let the agent ask again.
|
|
1124
1202
|
*/
|
|
1125
1203
|
function renderStaleSummary(digest, stale, open, total) {
|
|
1204
|
+
// P-9: the restart's one reminder, while every member is still open. Its
|
|
1205
|
+
// first line is the headline; any further line (what follows this message)
|
|
1206
|
+
// goes after the computed lines, which say what this message lists.
|
|
1207
|
+
const reminder = open === total ? (stale.reminder ?? []) : [];
|
|
1208
|
+
const headline = reminder[0] ??
|
|
1209
|
+
(open === 0
|
|
1210
|
+
? `All ${String(total)} older requests decided`
|
|
1211
|
+
: open === total
|
|
1212
|
+
? `${String(open)} older request${open === 1 ? " is" : "s are"} still open`
|
|
1213
|
+
: `${String(open)} of ${String(total)} older requests still open`);
|
|
1126
1214
|
const lines = [
|
|
1127
|
-
`<b>${escapeHtml(
|
|
1128
|
-
|
|
1129
|
-
|
|
1215
|
+
`<b>${escapeHtml(headline)}</b>`,
|
|
1216
|
+
...stale.lines.map((line) => escapeHtml(line)),
|
|
1217
|
+
...reminder.slice(1).map((line) => escapeHtml(line)),
|
|
1130
1218
|
"",
|
|
1131
|
-
|
|
1132
|
-
...stale.lines.map((line) => `• ${escapeHtml(line)}`),
|
|
1133
|
-
...digest.facts.map((fact) => `• <b>${escapeHtml(fact.label)}:</b> ${escapeHtml(fact.text)} <i>(${escapeHtml(fact.origin)})</i>`),
|
|
1134
|
-
"",
|
|
1135
|
-
`<b>CLAIMED — authored by ${escapeHtml(digest.author)}, NOT verified by the runtime</b>`,
|
|
1219
|
+
`<b>${escapeHtml(claimedLabelFor(digest.author))}</b>`,
|
|
1136
1220
|
];
|
|
1137
1221
|
for (const [index, member] of digest.members.entries()) {
|
|
1138
|
-
|
|
1222
|
+
const age = stale.ages?.[index];
|
|
1223
|
+
lines.push(`${index + 1}. ${escapeHtml(member.summary)}${age === undefined ? "" : ` · ${escapeHtml(age)}`}`);
|
|
1139
1224
|
if (member.settled !== null) {
|
|
1140
1225
|
lines.push(` <b>${escapeHtml(member.settled.headline)}</b>`);
|
|
1141
1226
|
for (const detail of member.settled.detail)
|
|
1142
1227
|
lines.push(` ${escapeHtml(detail)}`);
|
|
1143
1228
|
}
|
|
1144
1229
|
}
|
|
1145
|
-
|
|
1230
|
+
if (open > 0) {
|
|
1231
|
+
const approve = stale.staleCopy === false
|
|
1232
|
+
? "To approve one, ask your agent to try again."
|
|
1233
|
+
: "To approve one, use its earlier message if you still have it, or ask your agent to try again.";
|
|
1234
|
+
lines.push("", escapeHtml(`This message does not show what they would send, so it has no approve button. ${approve} Reject all turns down only ${open === 1 ? "this one" : `these ${String(open)}`}.`));
|
|
1235
|
+
}
|
|
1146
1236
|
const rows = open === 0
|
|
1147
1237
|
? []
|
|
1148
1238
|
: [
|
|
@@ -1161,10 +1251,10 @@ function renderStaleSummary(digest, stale, open, total) {
|
|
|
1161
1251
|
/** The digest's headline, given how much of it is still open. */
|
|
1162
1252
|
function digestHeadline(open, total) {
|
|
1163
1253
|
if (open === 0)
|
|
1164
|
-
return `
|
|
1254
|
+
return `All ${total} requests decided`;
|
|
1165
1255
|
if (open === total)
|
|
1166
|
-
return `${total}
|
|
1167
|
-
return `${open}
|
|
1256
|
+
return `${total} requests are waiting for you`;
|
|
1257
|
+
return `${open} of ${total} requests still waiting for you`;
|
|
1168
1258
|
}
|
|
1169
1259
|
/**
|
|
1170
1260
|
* The digest message: text plus the keyboard for whatever is still open.
|
|
@@ -1186,23 +1276,28 @@ export function renderDigest(digest) {
|
|
|
1186
1276
|
const stale = digest.stale ?? null;
|
|
1187
1277
|
if (stale !== null)
|
|
1188
1278
|
return renderStaleSummary(digest, stale, open.length, total);
|
|
1279
|
+
// P-9: a headline, one computed line, then the agent's own words under a
|
|
1280
|
+
// label that says they are the agent's and unchecked. No action key: the
|
|
1281
|
+
// members are told apart by number, and the numbers are on the buttons.
|
|
1189
1282
|
const lines = [
|
|
1190
1283
|
`<b>${escapeHtml(digestHeadline(open.length, total))}</b>`,
|
|
1284
|
+
escapeHtml(digest.line),
|
|
1191
1285
|
"",
|
|
1192
|
-
|
|
1193
|
-
...digest.facts.map((fact) => `• <b>${escapeHtml(fact.label)}:</b> ${escapeHtml(fact.text)} <i>(${escapeHtml(fact.origin)})</i>`),
|
|
1194
|
-
"",
|
|
1195
|
-
`<b>CLAIMED — authored by ${escapeHtml(digest.author)}, NOT verified by the runtime</b>`,
|
|
1286
|
+
`<b>${escapeHtml(claimedLabelFor(digest.author))}</b>`,
|
|
1196
1287
|
];
|
|
1197
1288
|
for (const [index, member] of digest.members.entries()) {
|
|
1198
|
-
lines.push(`${index + 1}.
|
|
1289
|
+
lines.push(`${index + 1}. ${member.kind === undefined ? "" : `${escapeHtml(member.kind)} — `}${escapeHtml(member.summary)} · agent's estimate ${escapeHtml(member.cost)}`);
|
|
1199
1290
|
if (member.settled !== null) {
|
|
1200
1291
|
lines.push(` <b>${escapeHtml(member.settled.headline)}</b>`);
|
|
1201
1292
|
for (const detail of member.settled.detail)
|
|
1202
1293
|
lines.push(` ${escapeHtml(detail)}`);
|
|
1203
1294
|
}
|
|
1204
1295
|
}
|
|
1205
|
-
|
|
1296
|
+
if (open.length > 0) {
|
|
1297
|
+
lines.push("", escapeHtml(open.length > 1
|
|
1298
|
+
? `Each button answers one request, by number. "All" answers every request still open.`
|
|
1299
|
+
: "Each button answers one request, by number."));
|
|
1300
|
+
}
|
|
1206
1301
|
const rows = [];
|
|
1207
1302
|
for (const [index, member] of digest.members.entries()) {
|
|
1208
1303
|
if (member.settled !== null)
|
|
@@ -1262,6 +1357,17 @@ export const ACTION_REF_HEX = 16;
|
|
|
1262
1357
|
export function actionRefOf(actionKey) {
|
|
1263
1358
|
return createHash("sha256").update(actionKey, "utf8").digest("hex").slice(0, ACTION_REF_HEX);
|
|
1264
1359
|
}
|
|
1360
|
+
/** How many hex characters of the key's sha256 a study log line carries. */
|
|
1361
|
+
export const STUDY_LOG_REF_HEX = 12;
|
|
1362
|
+
/**
|
|
1363
|
+
* The short reference a "Looks wrong" detection line names (amended SPEC.md
|
|
1364
|
+
* §8): the first {@link STUDY_LOG_REF_HEX} hex of the key's sha256, a prefix of
|
|
1365
|
+
* {@link actionRefOf}, so an operator holding the key can match the line and a
|
|
1366
|
+
* reader holding the line cannot recover the key.
|
|
1367
|
+
*/
|
|
1368
|
+
export function studyLogRefOf(actionKey) {
|
|
1369
|
+
return actionRefOf(actionKey).slice(0, STUDY_LOG_REF_HEX);
|
|
1370
|
+
}
|
|
1265
1371
|
/**
|
|
1266
1372
|
* `callback_data` for one button: `<g|r>:<nonce>:<action ref>`.
|
|
1267
1373
|
*
|
|
@@ -1368,13 +1474,23 @@ const CALLBACK_VERBS = {
|
|
|
1368
1474
|
G: { decision: "grant", scope: "all" },
|
|
1369
1475
|
R: { decision: "reject", scope: "all" },
|
|
1370
1476
|
};
|
|
1371
|
-
|
|
1477
|
+
/**
|
|
1478
|
+
* The study-mode verb (amended SPEC.md §8), in a table of its own so that the
|
|
1479
|
+
* base vocabulary above is untouched. Consulted only when the caller passes
|
|
1480
|
+
* `study`, which a listener does only when it was launched with
|
|
1481
|
+
* `APPROVAL_STUDY_MODE=1`; otherwise `w:` is no verb at all and the callback is
|
|
1482
|
+
* malformed, exactly as before the verb existed.
|
|
1483
|
+
*/
|
|
1484
|
+
const STUDY_CALLBACK_VERB = { decision: "reject", scope: "one", flag: "looks_wrong" };
|
|
1485
|
+
export function parseCallbackData(data, study = false) {
|
|
1372
1486
|
if (typeof data !== "string")
|
|
1373
1487
|
return null;
|
|
1374
1488
|
const first = data.indexOf(":");
|
|
1375
1489
|
if (first === -1)
|
|
1376
1490
|
return null;
|
|
1377
|
-
const
|
|
1491
|
+
const head = data.slice(0, first);
|
|
1492
|
+
const studyVerb = study && head === "w";
|
|
1493
|
+
const verb = studyVerb ? STUDY_CALLBACK_VERB : CALLBACK_VERBS[head];
|
|
1378
1494
|
if (verb === undefined)
|
|
1379
1495
|
return null;
|
|
1380
1496
|
const rest = data.slice(first + 1);
|
|
@@ -1387,6 +1503,7 @@ export function parseCallbackData(data) {
|
|
|
1387
1503
|
scope: verb.scope,
|
|
1388
1504
|
nonce,
|
|
1389
1505
|
actionRef: second === -1 ? null : rest.slice(second + 1),
|
|
1506
|
+
...(studyVerb ? { flag: STUDY_CALLBACK_VERB.flag } : {}),
|
|
1390
1507
|
};
|
|
1391
1508
|
}
|
|
1392
1509
|
// ---------------------------------------------------------------------------
|
|
@@ -2082,6 +2199,11 @@ export class TelegramChannel {
|
|
|
2082
2199
|
* carries can change it.
|
|
2083
2200
|
*/
|
|
2084
2201
|
staleCopy;
|
|
2202
|
+
/**
|
|
2203
|
+
* Study mode (amended SPEC.md §8). Fixed at construction from
|
|
2204
|
+
* {@link TelegramConfig.study}; nothing an update carries can change it.
|
|
2205
|
+
*/
|
|
2206
|
+
study;
|
|
2085
2207
|
/**
|
|
2086
2208
|
* Where a `nonce-not-issued` refusal is recorded (APRV-456). The runtime's,
|
|
2087
2209
|
* like every other handler here; absent, the refusal still happens and only
|
|
@@ -2240,6 +2362,8 @@ export class TelegramChannel {
|
|
|
2240
2362
|
// APRV-456. Only an explicit `true` or an absent key leaves the fallback on;
|
|
2241
2363
|
// anything else a caller passes turns it off, the stricter direction.
|
|
2242
2364
|
this.staleCopy = config.staleCopy === undefined || config.staleCopy === true;
|
|
2365
|
+
// Only an explicit `true` turns study mode on; anything else is off.
|
|
2366
|
+
this.study = config.study === true;
|
|
2243
2367
|
this.makeNonce =
|
|
2244
2368
|
config.nonce ??
|
|
2245
2369
|
(() => {
|
|
@@ -2277,6 +2401,10 @@ export class TelegramChannel {
|
|
|
2277
2401
|
staleCopyEnabled() {
|
|
2278
2402
|
return this.staleCopy;
|
|
2279
2403
|
}
|
|
2404
|
+
/** Whether study mode is on (amended SPEC.md §8): fixed at construction. */
|
|
2405
|
+
studyModeEnabled() {
|
|
2406
|
+
return this.study;
|
|
2407
|
+
}
|
|
2280
2408
|
/**
|
|
2281
2409
|
* Register what to do with a checkpoint tap (APRV-257).
|
|
2282
2410
|
*
|
|
@@ -2498,7 +2626,10 @@ export class TelegramChannel {
|
|
|
2498
2626
|
async notifyStale(members, stale) {
|
|
2499
2627
|
if (members.length === 0)
|
|
2500
2628
|
return null;
|
|
2501
|
-
return this.deliverDigest(members, `tg-batch-${this.makeNonce()}`,
|
|
2629
|
+
return this.deliverDigest(members, `tg-batch-${this.makeNonce()}`, {
|
|
2630
|
+
...stale,
|
|
2631
|
+
staleCopy: stale.staleCopy ?? this.staleCopy,
|
|
2632
|
+
});
|
|
2502
2633
|
}
|
|
2503
2634
|
/**
|
|
2504
2635
|
* The digest itself: every member's prompt and payload, then the one message
|
|
@@ -2520,6 +2651,7 @@ export class TelegramChannel {
|
|
|
2520
2651
|
const windowMs = windows.includes(null)
|
|
2521
2652
|
? null
|
|
2522
2653
|
: Math.max(...windows.filter((window) => window !== null));
|
|
2654
|
+
const mixedClasses = new Set(members.map((member) => member.class.value)).size > 1;
|
|
2523
2655
|
const state = {
|
|
2524
2656
|
deliveredAtMs,
|
|
2525
2657
|
windowMs,
|
|
@@ -2528,13 +2660,14 @@ export class TelegramChannel {
|
|
|
2528
2660
|
batchDeliveryId,
|
|
2529
2661
|
allNonce,
|
|
2530
2662
|
stale,
|
|
2531
|
-
|
|
2663
|
+
line: stale === null ? digestLine(members) : "",
|
|
2532
2664
|
author: originOf(members[0].summary),
|
|
2533
2665
|
members: members.map((member) => ({
|
|
2534
2666
|
actionKey: member.action_key.value,
|
|
2535
2667
|
nonce: this.makeNonce(),
|
|
2536
2668
|
summary: member.summary.value ?? "(none given)",
|
|
2537
2669
|
cost: `$${member.est_cost_usd.value.toFixed(2)}`,
|
|
2670
|
+
...(mixedClasses ? { kind: member.class.value } : {}),
|
|
2538
2671
|
settled: null,
|
|
2539
2672
|
})),
|
|
2540
2673
|
};
|
|
@@ -2557,12 +2690,12 @@ export class TelegramChannel {
|
|
|
2557
2690
|
}
|
|
2558
2691
|
else if (shared === null) {
|
|
2559
2692
|
for (const [index, member] of members.entries()) {
|
|
2560
|
-
const one = await this.sendPrompt(member, `
|
|
2693
|
+
const one = await this.sendPrompt(member, `Request ${index + 1} of ${members.length} — answer it on the summary below`, null);
|
|
2561
2694
|
rendered.push({ ...one.rendered, batchDeliveryId });
|
|
2562
2695
|
}
|
|
2563
2696
|
}
|
|
2564
2697
|
else {
|
|
2565
|
-
const one = await this.sendPrompt(shared, `
|
|
2698
|
+
const one = await this.sendPrompt(shared, `What all ${members.length} requests are about — answer them on the summary below`, null);
|
|
2566
2699
|
for (const member of members) {
|
|
2567
2700
|
rendered.push({
|
|
2568
2701
|
...one.rendered,
|
|
@@ -2631,7 +2764,7 @@ export class TelegramChannel {
|
|
|
2631
2764
|
for (const [index, chunk] of chunks.entries()) {
|
|
2632
2765
|
const label = chunks.length === 1
|
|
2633
2766
|
? `<b>${PAYLOAD_CHUNK_LABEL}</b>`
|
|
2634
|
-
: `<b
|
|
2767
|
+
: `<b>${PAYLOAD_CHUNK_LABEL} (part ${index + 1} of ${chunks.length})</b>`;
|
|
2635
2768
|
segments.push(`${label}\n<pre>${escapeHtml(chunk)}</pre>`);
|
|
2636
2769
|
}
|
|
2637
2770
|
}
|
|
@@ -2665,6 +2798,27 @@ export class TelegramChannel {
|
|
|
2665
2798
|
},
|
|
2666
2799
|
};
|
|
2667
2800
|
}
|
|
2801
|
+
/**
|
|
2802
|
+
* The study-mode row under a per-request card (amended SPEC.md §8): one
|
|
2803
|
+
* "Looks wrong" button on a row of its own, so the Approve/Reject row keeps
|
|
2804
|
+
* its bytes and layout. Empty when study mode is off, which leaves the
|
|
2805
|
+
* keyboard exactly as it was before study mode existed.
|
|
2806
|
+
*
|
|
2807
|
+
* Empty on a policy attestation prompt too (APRV-528). Such a prompt is
|
|
2808
|
+
* answered by attesting or declining, and `channels/contract.ts` routes its
|
|
2809
|
+
* gesture to the attestation path, which records no study reason: a flag
|
|
2810
|
+
* drawn there would be counted by the detection line yet held by no log
|
|
2811
|
+
* record. The prompt is known by its `policy_diff` and, as the contract
|
|
2812
|
+
* routes it, by its `policy.attest:` key.
|
|
2813
|
+
*/
|
|
2814
|
+
studyRows(nonce, request) {
|
|
2815
|
+
if (!this.study)
|
|
2816
|
+
return [];
|
|
2817
|
+
const actionKey = request.action_key.value;
|
|
2818
|
+
if (request.policy_diff !== undefined || isAttestationActionKey(actionKey))
|
|
2819
|
+
return [];
|
|
2820
|
+
return [[{ text: TELEGRAM_LOOKS_WRONG_LABEL, callback_data: callbackData("w", nonce, actionKey) }]];
|
|
2821
|
+
}
|
|
2668
2822
|
async deliverOne(request, batchDeliveryId) {
|
|
2669
2823
|
const actionKey = request.action_key.value;
|
|
2670
2824
|
const nonce = this.makeNonce();
|
|
@@ -2674,6 +2828,7 @@ export class TelegramChannel {
|
|
|
2674
2828
|
{ text: "✅ Approve", callback_data: callbackData("g", nonce, actionKey) },
|
|
2675
2829
|
{ text: "🛑 Reject", callback_data: callbackData("r", nonce, actionKey) },
|
|
2676
2830
|
],
|
|
2831
|
+
...this.studyRows(nonce, request),
|
|
2677
2832
|
],
|
|
2678
2833
|
};
|
|
2679
2834
|
// APRV-489. The minimal card is tried only when the policy asked for it,
|
|
@@ -2762,6 +2917,7 @@ export class TelegramChannel {
|
|
|
2762
2917
|
{ text: "✅ Approve", callback_data: callbackData("g", nonce, actionKey) },
|
|
2763
2918
|
{ text: MINIMAL_DENY_LABEL, callback_data: callbackData("r", nonce, actionKey) },
|
|
2764
2919
|
],
|
|
2920
|
+
...this.studyRows(nonce, request),
|
|
2765
2921
|
],
|
|
2766
2922
|
},
|
|
2767
2923
|
});
|
|
@@ -3605,7 +3761,9 @@ export class TelegramChannel {
|
|
|
3605
3761
|
await this.handleReviewTap(review, callbackId, result, sender);
|
|
3606
3762
|
return;
|
|
3607
3763
|
}
|
|
3608
|
-
|
|
3764
|
+
// The study verb `w` parses only when this listener was launched in study
|
|
3765
|
+
// mode; otherwise it is malformed below, exactly as before it existed.
|
|
3766
|
+
const parsed = parseCallbackData(query["data"], this.study);
|
|
3609
3767
|
if (parsed === null) {
|
|
3610
3768
|
await this.ignore(result, callbackId, "malformed-callback", `callback_data ${JSON.stringify(query["data"])} is not a decision this channel issued`, "Unrecognized button.");
|
|
3611
3769
|
return;
|
|
@@ -3683,6 +3841,9 @@ export class TelegramChannel {
|
|
|
3683
3841
|
...(parsed.decision === "reject"
|
|
3684
3842
|
? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId})` }
|
|
3685
3843
|
: {}),
|
|
3844
|
+
// Amended SPEC.md §8. A "Looks wrong" tap is this same reject with the
|
|
3845
|
+
// flag recorded; every check above and below is a Deny's.
|
|
3846
|
+
...(parsed.flag === "looks_wrong" ? { study: { reason: "looks_wrong" } } : {}),
|
|
3686
3847
|
};
|
|
3687
3848
|
if (this.handler === null) {
|
|
3688
3849
|
await this.ignore(result, callbackId, "unknown-callback", "a callback arrived before the runtime registered a decision handler", "The runtime is not ready to record decisions.");
|
|
@@ -3750,9 +3911,17 @@ export class TelegramChannel {
|
|
|
3750
3911
|
return;
|
|
3751
3912
|
}
|
|
3752
3913
|
const record = outcome.record;
|
|
3753
|
-
const
|
|
3754
|
-
|
|
3755
|
-
:
|
|
3914
|
+
const lookedWrong = parsed.flag === "looks_wrong" && outcome.decision === "reject";
|
|
3915
|
+
if (lookedWrong) {
|
|
3916
|
+
// The detection line: the 12-hex reference and nothing else. Never the
|
|
3917
|
+
// key text, a chat id, a sender id or any card text.
|
|
3918
|
+
this.complain(`approval: telegram looks_wrong ref=${studyLogRefOf(delivery.actionKey)}`);
|
|
3919
|
+
}
|
|
3920
|
+
const headline = lookedWrong
|
|
3921
|
+
? TELEGRAM_LOOKS_WRONG_HEADLINE
|
|
3922
|
+
: outcome.decision === "grant"
|
|
3923
|
+
? TELEGRAM_TERMINAL_HEADLINES.granted
|
|
3924
|
+
: TELEGRAM_TERMINAL_HEADLINES.rejected;
|
|
3756
3925
|
try {
|
|
3757
3926
|
await this.annotate(delivery.deliveryId, headline, [decidedLine(record.actor, record.ts, record.seq)], delivery.actionKey);
|
|
3758
3927
|
}
|