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.
Files changed (83) hide show
  1. package/SPEC.md +24 -3
  2. package/dist/src/channels/contract.d.ts +15 -0
  3. package/dist/src/channels/contract.js +23 -1
  4. package/dist/src/channels/contract.js.map +1 -1
  5. package/dist/src/channels/telegram.d.ts +180 -33
  6. package/dist/src/channels/telegram.js +256 -87
  7. package/dist/src/channels/telegram.js.map +1 -1
  8. package/dist/src/cli/channel-telegram.d.ts +114 -35
  9. package/dist/src/cli/channel-telegram.js +259 -120
  10. package/dist/src/cli/channel-telegram.js.map +1 -1
  11. package/dist/src/cli/doctor.js +33 -13
  12. package/dist/src/cli/doctor.js.map +1 -1
  13. package/dist/src/cli/help.d.ts +2 -0
  14. package/dist/src/cli/help.js +39 -3
  15. package/dist/src/cli/help.js.map +1 -1
  16. package/dist/src/cli/hook-exec-code.d.ts +150 -0
  17. package/dist/src/cli/hook-exec-code.js +357 -0
  18. package/dist/src/cli/hook-exec-code.js.map +1 -0
  19. package/dist/src/cli/hook.d.ts +154 -1
  20. package/dist/src/cli/hook.js +2344 -111
  21. package/dist/src/cli/hook.js.map +1 -1
  22. package/dist/src/cli/instructions.js +9 -6
  23. package/dist/src/cli/instructions.js.map +1 -1
  24. package/dist/src/cli/main.js +9 -0
  25. package/dist/src/cli/main.js.map +1 -1
  26. package/dist/src/cli/study.d.ts +22 -0
  27. package/dist/src/cli/study.js +153 -0
  28. package/dist/src/cli/study.js.map +1 -0
  29. package/dist/src/cli/verb-registry.d.ts +14 -0
  30. package/dist/src/cli/verb-registry.js +37 -0
  31. package/dist/src/cli/verb-registry.js.map +1 -1
  32. package/dist/src/cli/wordmark.d.ts +2 -2
  33. package/dist/src/cli/wordmark.js +2 -2
  34. package/dist/src/core/command-class.d.ts +349 -1
  35. package/dist/src/core/command-class.js +2347 -87
  36. package/dist/src/core/command-class.js.map +1 -1
  37. package/dist/src/core/exec-code.d.ts +77 -0
  38. package/dist/src/core/exec-code.js +60 -0
  39. package/dist/src/core/exec-code.js.map +1 -0
  40. package/dist/src/core/gate.d.ts +38 -0
  41. package/dist/src/core/gate.js +26 -0
  42. package/dist/src/core/gate.js.map +1 -1
  43. package/dist/src/core/git-push-argv.d.ts +111 -0
  44. package/dist/src/core/git-push-argv.js +375 -0
  45. package/dist/src/core/git-push-argv.js.map +1 -0
  46. package/dist/src/core/harness-version.d.ts +71 -30
  47. package/dist/src/core/harness-version.js +101 -34
  48. package/dist/src/core/harness-version.js.map +1 -1
  49. package/dist/src/core/policy-load.d.ts +8 -0
  50. package/dist/src/core/policy-load.js.map +1 -1
  51. package/dist/src/core/sender-identity.d.ts +13 -1
  52. package/dist/src/core/sender-identity.js +15 -0
  53. package/dist/src/core/sender-identity.js.map +1 -1
  54. package/dist/src/core/study-withdraw.d.ts +137 -0
  55. package/dist/src/core/study-withdraw.js +280 -0
  56. package/dist/src/core/study-withdraw.js.map +1 -0
  57. package/dist/src/daemon/git-evidence.d.ts +2 -2
  58. package/dist/src/daemon/git-evidence.js +1 -1
  59. package/dist/src/mcp/server.js +1 -1
  60. package/dist/src/mcp/server.js.map +1 -1
  61. package/docs/README-extended.md +3 -2
  62. package/docs/cli-reference.md +83 -7
  63. package/package.json +2 -2
  64. package/schema/event.schema.json +213 -1
  65. package/schema/fixtures/event/invalid/approval-granted-study.json +23 -0
  66. package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-cli-channel.json +23 -0
  67. package/schema/fixtures/event/invalid/approval-rejected-looks-wrong-operator.json +24 -0
  68. package/schema/fixtures/event/invalid/approval-rejected-study-extra-key.json +24 -0
  69. package/schema/fixtures/event/invalid/approval-rejected-study-unknown-reason.json +23 -0
  70. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-email-operator.json +20 -0
  71. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-operator.json +19 -0
  72. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-no-ref.json +19 -0
  73. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-actor.json +20 -0
  74. package/schema/fixtures/event/invalid/approval-rejected-study-withdrawn-wrong-channel.json +20 -0
  75. package/schema/fixtures/event/invalid/approval-revoked-study.json +20 -0
  76. package/schema/fixtures/event/valid/approval-rejected-looks-wrong.json +23 -0
  77. package/schema/fixtures/event/valid/approval-rejected-study-withdrawn.json +20 -0
  78. package/schema/fixtures/policy/invalid/exec-code-custody-boolean.json +11 -0
  79. package/schema/fixtures/policy/invalid/exec-code-execute-code-value.json +11 -0
  80. package/schema/fixtures/policy/invalid/exec-code-unknown-key.json +12 -0
  81. package/schema/fixtures/policy/valid/exec-code-both.json +17 -0
  82. package/schema/fixtures/policy/valid/exec-code-custody.json +11 -0
  83. 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; calling it "the exact bytes" told
317
- * the reader that a diff view and a JSON file were the same object. The
318
- * rendering names its own `display_hash`, and the store path inside it is the
319
- * route back to the bytes.
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 = "the canonical rendering this approval's display_hash names; raw bytes at the store path inside";
322
- export const PAYLOAD_CHUNK_LABEL = `PAYLOAD — ${PAYLOAD_CHUNK_LABEL_TAIL}`;
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 CLAIMED and both say NOT verified, because a continuation is a
328
- * message a reader may see first, and a claimed line that arrives under no
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 const TELEGRAM_CLAIMED_HEADING_PREFIX = "WHAT THIS DOES — CLAIMED by";
332
- export const TELEGRAM_CLAIMED_HEADING_SUFFIX = "NOT verified by the runtime";
333
- export const TELEGRAM_CLAIMED_CONTINUED_HEADING = `WHAT THIS DOES (continued) — CLAIMED, ${TELEGRAM_CLAIMED_HEADING_SUFFIX}`;
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
- "<b>COMPUTED — derived by the runtime from the log, the policy and the payload bytes</b>",
825
+ `<b>${TELEGRAM_COMPUTED_HEADING}</b>`,
745
826
  ...computedLines.map(render),
746
827
  ].join("\n");
747
828
  const claimedText = [
748
- `<b>${TELEGRAM_CLAIMED_HEADING_PREFIX} ${escapeHtml(author)}, ${TELEGRAM_CLAIMED_HEADING_SUFFIX}</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 facts a digest's members share, as the digest states them.
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
- * Every one is read off the first member, which is sound precisely because the
1054
- * grouping key made them equal across the set: a digest whose members disagreed
1055
- * about their class or their task is a digest the listener would not have
1056
- * built. The last line is the one an approver needs most — it says how many
1057
- * payloads are above and that each request has its own.
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 digestFacts(members) {
1147
+ export function digestLine(members) {
1060
1148
  const first = members[0];
1061
1149
  if (first === undefined)
1062
- return [];
1063
- // APRV-287. A digest may now carry the several classes of ONE tool call, so
1064
- // the class line states the set rather than the first member's, and the
1065
- // grouping line says which of the two groupings put this set together. A
1066
- // digest whose members share one class reads exactly as it did.
1067
- const classes = [...new Set(members.map((member) => member.class.value))];
1068
- const shared = sharedPayload(members);
1069
- const autonomies = [...new Set(members.map((member) => member.autonomy.value))];
1070
- return [
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
- * Everything above the buttons is computed by the runtime from the verified log
1119
- * — the count, the ages, the classes — and the per-member lines carry the
1120
- * agent's own summaries under a heading that says so, exactly as an ordinary
1121
- * digest does. What it does NOT carry is any payload, and the message says so
1122
- * in the same breath as it explains why the only button rejects: a decision is
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(open === 0
1128
- ? `ALL ${total} STALE REQUESTS DECIDED`
1129
- : `${open} STALE REQUEST${open === 1 ? "" : "S"} — NOBODY IS WAITING ON ${open === 1 ? "IT" : "THEM"}`)}</b>`,
1215
+ `<b>${escapeHtml(headline)}</b>`,
1216
+ ...stale.lines.map((line) => escapeHtml(line)),
1217
+ ...reminder.slice(1).map((line) => escapeHtml(line)),
1130
1218
  "",
1131
- "<b>COMPUTED — derived by the runtime from the log and the policy</b>",
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
- lines.push(`${index + 1}. <code>${escapeHtml(member.actionKey)}</code> — ${escapeHtml(member.summary)}`);
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
- lines.push("", escapeHtml("These asked while a hook waited, and the wait is long over: no tool call is holding the answer. This message carries NO payload, so it offers no approve button — a decision is bound to the bytes you were shown, and nothing here shows them. Rejecting is one log event per request and authorizes nothing. To approve one instead, decide it on its own card or run `approval grant <action key>`; the requests stay listed by /queue either way."));
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 `ALL ${total} REQUESTS DECIDED`;
1254
+ return `All ${total} requests decided`;
1165
1255
  if (open === total)
1166
- return `${total} REQUESTS AWAITING APPROVAL`;
1167
- return `${open} OF ${total} REQUESTS STILL AWAITING APPROVAL`;
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
- "<b>COMPUTED — derived by the runtime from the log and the policy</b>",
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}. <code>${escapeHtml(member.actionKey)}</code> — ${escapeHtml(member.summary)} · ${escapeHtml(member.cost)}`);
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
- lines.push("", escapeHtml(`Each button decides ONE request, numbered as above. "all" is ${open.length} separate decisions, one log event each; the full payload of every request is in the messages above this one.`));
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
- export function parseCallbackData(data) {
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 verb = CALLBACK_VERBS[data.slice(0, first)];
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()}`, stale);
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
- facts: stale === null ? digestFacts(members) : [],
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, `REQUEST ${index + 1} OF ${members.length} — decide it on the digest below`, null);
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, `THE COMMAND ALL ${members.length} REQUESTS ARE ABOUT — decide it on the digest below`, null);
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>PAYLOAD ${index + 1}/${chunks.length} — ${PAYLOAD_CHUNK_LABEL_TAIL}</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
- const parsed = parseCallbackData(query["data"]);
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 headline = outcome.decision === "grant"
3754
- ? TELEGRAM_TERMINAL_HEADLINES.granted
3755
- : TELEGRAM_TERMINAL_HEADLINES.rejected;
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
  }