approval-md 0.4.3 → 0.4.6

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 (52) hide show
  1. package/SPEC.md +13 -2
  2. package/dist/src/channels/telegram.d.ts +105 -31
  3. package/dist/src/channels/telegram.js +157 -81
  4. package/dist/src/channels/telegram.js.map +1 -1
  5. package/dist/src/cli/channel-telegram.d.ts +100 -35
  6. package/dist/src/cli/channel-telegram.js +216 -119
  7. package/dist/src/cli/channel-telegram.js.map +1 -1
  8. package/dist/src/cli/doctor.js +33 -13
  9. package/dist/src/cli/doctor.js.map +1 -1
  10. package/dist/src/cli/help.js +3 -3
  11. package/dist/src/cli/hook-exec-code.d.ts +150 -0
  12. package/dist/src/cli/hook-exec-code.js +357 -0
  13. package/dist/src/cli/hook-exec-code.js.map +1 -0
  14. package/dist/src/cli/hook.d.ts +125 -1
  15. package/dist/src/cli/hook.js +2177 -107
  16. package/dist/src/cli/hook.js.map +1 -1
  17. package/dist/src/cli/wordmark.d.ts +2 -2
  18. package/dist/src/cli/wordmark.js +2 -2
  19. package/dist/src/core/command-class.d.ts +368 -1
  20. package/dist/src/core/command-class.js +2830 -93
  21. package/dist/src/core/command-class.js.map +1 -1
  22. package/dist/src/core/date-argv.d.ts +83 -0
  23. package/dist/src/core/date-argv.js +212 -0
  24. package/dist/src/core/date-argv.js.map +1 -0
  25. package/dist/src/core/exec-code.d.ts +77 -0
  26. package/dist/src/core/exec-code.js +60 -0
  27. package/dist/src/core/exec-code.js.map +1 -0
  28. package/dist/src/core/git-push-argv.d.ts +111 -0
  29. package/dist/src/core/git-push-argv.js +375 -0
  30. package/dist/src/core/git-push-argv.js.map +1 -0
  31. package/dist/src/core/harness-version.d.ts +71 -30
  32. package/dist/src/core/harness-version.js +101 -34
  33. package/dist/src/core/harness-version.js.map +1 -1
  34. package/dist/src/core/policy-load.d.ts +8 -0
  35. package/dist/src/core/policy-load.js.map +1 -1
  36. package/dist/src/core/read-scope.js +1 -0
  37. package/dist/src/core/read-scope.js.map +1 -1
  38. package/dist/src/core/sysinfo-argv.d.ts +48 -0
  39. package/dist/src/core/sysinfo-argv.js +340 -0
  40. package/dist/src/core/sysinfo-argv.js.map +1 -0
  41. package/dist/src/daemon/git-evidence.d.ts +2 -2
  42. package/dist/src/daemon/git-evidence.js +1 -1
  43. package/docs/README-extended.md +3 -2
  44. package/docs/cli-reference.md +20 -7
  45. package/package.json +2 -2
  46. package/schema/event.schema.json +1 -1
  47. package/schema/fixtures/policy/invalid/exec-code-custody-boolean.json +11 -0
  48. package/schema/fixtures/policy/invalid/exec-code-execute-code-value.json +11 -0
  49. package/schema/fixtures/policy/invalid/exec-code-unknown-key.json +12 -0
  50. package/schema/fixtures/policy/valid/exec-code-both.json +17 -0
  51. package/schema/fixtures/policy/valid/exec-code-custody.json +11 -0
  52. package/schema/policy.schema.json +23 -0
@@ -115,7 +115,7 @@ import { assembleBatch } from "../channels/batch.js";
115
115
  import { recordChannelDecision, recordSurfaceRefusal, refusedDecisionLine, } from "../channels/contract.js";
116
116
  import { ageText, attestedPromptOf, buildPendingQueue, } from "../channels/tagging.js";
117
117
  import { checkpointOfferFor, checkpointPromptLines, checkpointSignedLines, signCheckpointOffer, } from "./checkpoint-tap.js";
118
- import { actionRefOf, decidedLine, groupForDigest, isDeterministicSendRefusal, isMessageNotModified, isTelegramTerminalState, retryAfterMsOf, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
118
+ import { actionRefOf, decidedLine, groupForDigest, isDeterministicSendRefusal, isMessageNotModified, isTelegramTerminalState, retryAfterMsOf, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_CLAIMED_LABEL, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
119
119
  import { openReviewCards } from "./audit-card.js";
120
120
  import { reviewSample } from "../core/audit.js";
121
121
  import { abandonedAfterMs, HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
@@ -853,7 +853,8 @@ export const DISPATCH_LOUD_ATTEMPTS = 3;
853
853
  */
854
854
  export const DISPATCH_RETENTION_MS = 24 * 60 * 60 * 1000;
855
855
  /**
856
- * The line that introduces the first batch a listener process sends (APRV-196).
856
+ * The one message that introduces the first batch a listener process sends
857
+ * (APRV-196). Two lines, sent together by `announce`, the first in bold.
857
858
  *
858
859
  * **Why a banner and not an edit of the earlier copies.** The incident was a
859
860
  * restart re-sending five pending requests with no warning, on top of five
@@ -869,27 +870,59 @@ export const DISPATCH_RETENTION_MS = 24 * 60 * 60 * 1000;
869
870
  * (`actionRefOf` in `channels/telegram.ts`), so a human who taps the copy they
870
871
  * can see decides the request they meant.
871
872
  *
872
- * It says "started" rather than "restarted" because a listener cannot tell the
873
- * two apart, having deliberately kept nothing that would let it, and a first
874
- * start that claimed to be a restart would be this channel's own text lying
875
- * about the system's history.
873
+ * **The words are a resident's, not the system's.** The reader is whoever
874
+ * holds the phone, so the banner is one short message in plain words: how many
875
+ * requests are still waiting, and whether the buttons on the older messages
876
+ * above still work. It names no process, no log and no cycle, and it says
877
+ * neither "started" nor "restarted": a listener cannot tell the two apart,
878
+ * having deliberately kept nothing that would let it, so the text claims only
879
+ * what is true either way (these requests are still waiting). The old third
880
+ * line, about where the pending set is read from, is gone; it described the
881
+ * runtime to someone who only needs to know which button to press.
876
882
  *
877
883
  * **With the stale-copy fallback off (APRV-456)** the second line says the
878
- * opposite, because the earlier copies' buttons no longer decide anything: a
879
- * relayed listener refuses a nonce it is not holding (`nonce-not-issued`), so
880
- * the banner tells the approver to use the copies below it. A banner that kept
884
+ * opposite, because the earlier copies' buttons no longer work: a relayed
885
+ * listener refuses a nonce it is not holding (`nonce-not-issued`), so the
886
+ * banner tells the approver to use the buttons below it. A banner that kept
881
887
  * promising working older copies would be this channel's own text sending the
882
888
  * approver to a button that refuses them.
883
889
  */
884
890
  export function bannerLines(pending, staleCopy = true) {
885
- const plural = pending === 1 ? "request" : "requests";
886
- const live = `The ${pending === 1 ? "message" : "messages"} below ${pending === 1 ? "is" : "are"} the live ${pending === 1 ? "copy" : "copies"}.`;
891
+ const one = pending === 1;
887
892
  return [
888
- `LISTENER STARTED — re-sending ${pending} pending ${plural}.`,
893
+ `Reminder: ${pending} ${one ? "request is" : "requests are"} still waiting for you.`,
889
894
  staleCopy
890
- ? `${live} If an earlier copy of the same request is further up this chat, its buttons still decide the same request; nothing is decided twice.`
891
- : `${live} Buttons on earlier copies further up this chat no longer decide anything on this listener; tap the ${pending === 1 ? "copy" : "copies"} below.`,
892
- "Which requests are pending is read from the log on every cycle, never from this chat.",
895
+ ? `${one ? "It is" : "They are"} repeated below; buttons on older messages above still work, and nothing is answered twice.`
896
+ : "Use the buttons below; buttons on older messages above no longer work.",
897
+ ];
898
+ }
899
+ /**
900
+ * The reminder a collapsed re-delivery opens with, in place of the banner
901
+ * (P-9).
902
+ *
903
+ * Before this, a restart with two stale requests and one live one sent the
904
+ * stale summary first and the {@link bannerLines} banner second, and the banner
905
+ * counted only the cards after it: "1 request is still waiting" with three
906
+ * pending and the summary of the other two directly above. Two reminders, the
907
+ * more verbose one first, and a count that read as wrong. Now the summary IS the
908
+ * reminder: its first line counts everything this cycle re-sends (`pending`,
909
+ * collapsed and carded together), and the banner is skipped.
910
+ *
911
+ * `following` is how many requests get their own card after the summary. When
912
+ * there are any, the second line says so and says whether the buttons on older
913
+ * messages still work, which is what the banner it replaces said (APRV-456).
914
+ * When there are none, the summary is the whole restart and nothing follows.
915
+ */
916
+ export function staleReminderLines(pending, following, staleCopy = true) {
917
+ const head = `Reminder: ${String(pending)} ${pending === 1 ? "request is" : "requests are"} still waiting for you.`;
918
+ if (following <= 0)
919
+ return [head];
920
+ const below = following === 1 ? "The other one is repeated below" : `The other ${String(following)} are repeated below`;
921
+ return [
922
+ head,
923
+ staleCopy
924
+ ? `${below}; buttons on older messages above still work.`
925
+ : `${below}; use ${following === 1 ? "its buttons" : "their buttons"}, as buttons on older messages above no longer work.`,
893
926
  ];
894
927
  }
895
928
  /**
@@ -918,75 +951,125 @@ export function staleCopyFor(apiBase, noStaleCopy) {
918
951
  // Paced delivery (APRV-216) — one question at a time
919
952
  // ---------------------------------------------------------------------------
920
953
  /**
921
- * The summary line that precedes a paced send, and the body of `/queue`.
922
- *
923
- * One message, and everything in it is arithmetic on the verified log at the
924
- * instant it is written: how many requests are pending, how long the oldest has
925
- * waited, and which classes they are. Nothing is remembered between calls, so
926
- * two summaries a minute apart can disagree only because the log moved.
927
- *
928
- * The class tally is the part worth the space. The count alone says how much
929
- * work is waiting; the classes say what KIND of work, which is what tells an
930
- * approver whether the queue is six identical `network.call`s they can walk
931
- * through or one `policy.edit` they should read carefully.
954
+ * The count line a paced cycle sends when the pending set has grown, and the
955
+ * header of `/queue` (APRV-216), in a resident's words (P-9).
956
+ *
957
+ * One line, and everything in it is arithmetic on the verified log at the
958
+ * instant it is written: how many requests are waiting and how long the oldest
959
+ * has waited. Nothing is remembered between calls, so two summaries a minute
960
+ * apart can disagree only because the log moved.
961
+ *
962
+ * Before P-9 it also carried a class tally ("communicate.email.external ×3").
963
+ * The tally told an operator what kind of work was queued, and told a resident
964
+ * nothing they could read; the kind of each request is on its own card, and
965
+ * `/queue` now lists each by the agent's own summary ({@link queueLines}).
966
+ *
967
+ * The oldest is the one asked longest ago, whatever order `requests` is in:
968
+ * `/queue` passes the set in presentation order ({@link orderPending}, live
969
+ * newest first), where the first entry is the NEWEST, and a header that read
970
+ * the first entry's age called the newest one the oldest.
932
971
  */
933
972
  export function summaryLines(requests, now) {
934
973
  if (requests.length === 0)
935
- return ["Nothing pending — the queue is empty."];
936
- const nowMs = Date.parse(now);
937
- const oldest = requests[0];
938
- const oldestMs = Date.parse(oldest.requested_ts.value);
939
- const age = Number.isNaN(nowMs) || Number.isNaN(oldestMs) ? "unknown age" : ageText(nowMs - oldestMs);
940
- const tally = new Map();
941
- for (const request of requests) {
942
- const cls = request.class.value;
943
- tally.set(cls, (tally.get(cls) ?? 0) + 1);
944
- }
945
- const classes = [...tally.entries()]
946
- .map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
947
- .join(", ");
974
+ return ["Nothing is waiting for you right now."];
975
+ const one = requests.length === 1;
948
976
  return [
949
- `${String(requests.length)} pending — oldest ${age} — ${classes}`,
977
+ `${String(requests.length)} ${one ? "request is" : "requests are"} waiting for you (${one ? "asked" : "oldest"} ${oldestAge(requests, now)}).`,
950
978
  ];
951
979
  }
980
+ /** How long ago the longest-waiting request was asked, in words. */
981
+ function oldestAge(requests, now) {
982
+ const nowMs = Date.parse(now);
983
+ const instants = requests
984
+ .map((request) => Date.parse(request.requested_ts.value))
985
+ .filter((at) => !Number.isNaN(at));
986
+ if (Number.isNaN(nowMs) || instants.length === 0)
987
+ return "at an unknown time";
988
+ return ageText(nowMs - Math.min(...instants));
989
+ }
990
+ /**
991
+ * The reminder a paced listener's first cycle opens with (P-9).
992
+ *
993
+ * Paced is the product default (APRV-216), and a restart there used to open
994
+ * with the bare count line and then one card. Nothing in it said this was a
995
+ * reminder, or whether the buttons on older messages above still worked, which
996
+ * matters most behind a relay, where they do not (APRV-456). So the first
997
+ * cycle's line is a reminder in the burst banner's words ({@link bannerLines}):
998
+ * how many are waiting, how many are below, and what the older buttons do.
999
+ *
1000
+ * `shown` is how many requests this cycle puts below it: one, or a digest
1001
+ * group. With the stale-copy fallback on, older buttons decide only while their
1002
+ * request is the one being shown, so when fewer are shown than are pending the
1003
+ * line says only the ones below work right now (fold round 1, S1). Later cycles that find the set has grown send {@link summaryLines}
1004
+ * instead, which is a count and not a reminder.
1005
+ */
1006
+ export function pacedReminderLines(pending, shown, staleCopy = true) {
1007
+ const head = `Reminder: ${String(pending)} ${pending === 1 ? "request is" : "requests are"} waiting for you.`;
1008
+ const below = shown >= pending
1009
+ ? pending === 1
1010
+ ? "It is below."
1011
+ : "They are below."
1012
+ : shown === 1
1013
+ ? "One is below; the next comes after you answer it."
1014
+ : `${String(shown)} are below; the next come after you answer them.`;
1015
+ if (staleCopy && shown < pending) {
1016
+ // Fold round 1, S1: under paced only the unit below has a live delivery.
1017
+ // An older copy of any OTHER pending request is answered "Still pending —
1018
+ // this copy's buttons are not live here", so "older buttons still work"
1019
+ // would be this channel's own text sending the approver to a refusal.
1020
+ return [
1021
+ head,
1022
+ shown === 1
1023
+ ? "One is below. Only its buttons work right now; the others come next, with new buttons, after you answer it."
1024
+ : `${String(shown)} are below. Only their buttons work right now; the others come next, with new buttons, after you answer them.`,
1025
+ ];
1026
+ }
1027
+ const older = staleCopy
1028
+ ? "Buttons on older messages above still work."
1029
+ : `Buttons on older messages above no longer work; use ${shown === 1 ? "the one" : "the ones"} below.`;
1030
+ return [head, `${below} ${older}`];
1031
+ }
952
1032
  /**
953
- * The marker `/queue` puts on the request this listener has selected (APRV-256).
954
- *
955
- * It says two things and claims no third. "Selected" is this process's memory of
956
- * which request it is holding; "card sent earlier" is the delivery bookkeeping
957
- * recording that a send once succeeded. Neither is evidence that the card is
958
- * still in the chat: Telegram reports a successful send, never a message's
959
- * continued existence, and a card can be deleted, buried under a thousand later
960
- * messages, or lost with the chat history on a reinstall. The old marker,
961
- * "shown now", asserted present visibility from a past delivery, which is the
962
- * bug this constant exists to keep fixed.
1033
+ * The marker `/queue` puts on the request this listener has selected (APRV-256),
1034
+ * in a resident's words (P-9).
1035
+ *
1036
+ * It says one thing and claims no second: a message for this request was sent
1037
+ * earlier, which is the delivery bookkeeping recording that a send once
1038
+ * succeeded. That is not evidence the message is still in the chat: Telegram
1039
+ * reports a successful send, never a message's continued existence, and a card
1040
+ * can be deleted, buried under a thousand later messages, or lost with the chat
1041
+ * history on a reinstall. The marker before APRV-256, "shown now", asserted
1042
+ * present visibility from a past delivery, which is the bug this constant
1043
+ * exists to keep fixed, and why it does not say "on screen" either.
963
1044
  */
964
- const SELECTED_MARKER = " — selected — card sent earlier";
1045
+ const SELECTED_MARKER = " · sent to you earlier";
965
1046
  /**
966
1047
  * What `/queue` says about itself, immediately under the summary (APRV-256).
967
1048
  *
968
1049
  * `/queue` is a summary reply and carries no buttons, so an approver reading it
969
1050
  * on a phone must not be left hunting this message for controls that were never
970
- * on it. Where the controls DO live is stated without a direction: a card is
1051
+ * on it. Where the controls DO live is stated without a direction: a message is
971
1052
  * somewhere in the chat's history, and "above" was only ever true for the
972
1053
  * approver who asked while looking straight at it.
973
1054
  */
974
- const QUEUE_IS_A_LIST = "This is a list of what the log is holding. It has no decision buttons: a request is decided on its own approval card, wherever that card sits in this chat.";
1055
+ const QUEUE_IS_A_LIST = "This list has no buttons. Each request is answered on its own message, wherever it is in this chat.";
975
1056
  /**
976
1057
  * `/queue`'s reply: the summary, then one numbered line per pending request.
977
1058
  *
978
1059
  * Derived, like the summary, from the verified log at reply time and not from
979
1060
  * anything this process is holding: the numbering is positional and names no
980
1061
  * button, so a stale copy of this list cannot be used to decide anything. The
981
- * marker says which one this listener has selected and once delivered, because
982
- * the question `/queue` is usually asked to answer is "what else is there
983
- * besides the one I am looking at" — and, since APRV-256, its unhappy twin,
984
- * "where is the one I am supposed to be looking at".
985
- *
986
- * The footer answers that second question the only honest way available to a
987
- * process whose knowledge of the chat ends at "a send returned success": it
988
- * says what was sent, says it cannot tell whether the card survived, and then
989
- * spends its remaining words on recovery rather than reassurance.
1062
+ * marker says which one was sent earlier, because the question `/queue` is
1063
+ * usually asked to answer is "what else is there besides the one I am looking
1064
+ * at" and, since APRV-256, its unhappy twin, "where is the one I am supposed to
1065
+ * be looking at".
1066
+ *
1067
+ * P-9: each line is the agent's own summary and its age, under the label that
1068
+ * says the summary is the agent's and unchecked. Before P-9 each line was the
1069
+ * action key, the task id, the class and the age, and in real use the first
1070
+ * two are `hook:<session uuid>:<tool_use_id>…`, two long raw ids per line. The
1071
+ * three recovery paragraphs are two short lines: what /skip and /next do, and
1072
+ * that neither answers anything.
990
1073
  */
991
1074
  export function queueLines(requests, now, shown) {
992
1075
  const lines = summaryLines(requests, now);
@@ -995,36 +1078,35 @@ export function queueLines(requests, now, shown) {
995
1078
  const nowMs = Date.parse(now);
996
1079
  const current = new Set(shown);
997
1080
  let selected = 0;
998
- lines.push(QUEUE_IS_A_LIST);
1081
+ lines.push(QUEUE_IS_A_LIST, `${TELEGRAM_CLAIMED_LABEL}:`);
999
1082
  requests.forEach((request, index) => {
1000
- const key = request.action_key.value;
1001
1083
  const requestedMs = Date.parse(request.requested_ts.value);
1002
1084
  const age = Number.isNaN(nowMs) || Number.isNaN(requestedMs)
1003
1085
  ? "unknown age"
1004
1086
  : ageText(nowMs - requestedMs);
1005
- const task = request.task.value ?? "no task";
1006
- const isSelected = current.has(key);
1087
+ const isSelected = current.has(request.action_key.value);
1007
1088
  if (isSelected)
1008
1089
  selected += 1;
1009
- lines.push(`${String(index + 1)}. ${key} — ${task} — ${request.class.value} — ${age}${isSelected ? SELECTED_MARKER : ""}`);
1090
+ lines.push(`${String(index + 1)}. ${request.summary.value ?? "(none given)"} · ${age}${isSelected ? SELECTED_MARKER : ""}`);
1010
1091
  });
1011
1092
  if (selected === 0) {
1012
1093
  // Nothing selected is an ordinary state, not a fault: a decided or passed
1013
1094
  // over request leaves the listener holding nothing until the next cycle
1014
1095
  // picks the next one up. Saying so is what stops the reader searching the
1015
- // chat for a card this process never claimed to have sent.
1016
- lines.push("Nothing is selected right now, so no approval card has been sent for any of these. The next one goes out with its buttons on an upcoming listener cycle.");
1096
+ // chat for a message this process never claimed to have sent.
1097
+ lines.push("None of these has been sent to you yet. The next one arrives with its buttons shortly.");
1017
1098
  return lines;
1018
1099
  }
1019
1100
  // More than one key is marked when the selection is a digest group, which
1020
- // Telegram receives as ONE card covering the set. Hence "a single approval
1021
- // card" in the plural branch and no positional word in either: the reply may
1101
+ // Telegram receives as ONE summary message covering the set, so the plural
1102
+ // branch speaks of one message. No positional word in either: the reply may
1022
1103
  // be chunked across several messages, so "above" is not this function's to
1023
1104
  // promise even about its own lines.
1024
- const holding = selected === 1
1025
- ? "The request marked selected is the one this listener is holding, and an approval card for it was sent to this chat earlier. The buttons on that card decide it."
1026
- : `The ${String(selected)} requests marked selected are what this listener is holding as one digest, and a single approval card for them was sent to this chat earlier. The buttons on that card decide them.`;
1027
- lines.push(`${holding} This listener cannot tell whether that card is still here.`, "If you cannot find the card, /skip is the recovery: it puts the request at the back of the order and lets the next one through. Nothing is decided by typing it, the request stays pending in the log, and a fresh card is sent on a later listener cycle once the requests ahead of it have had their turn (a cycle can run a little long while a gloss is being written).", "/next gives up your place instead: this listener moves past the request and stops offering it, and no new card is sent for it. It is not a way to ask for the card again.");
1105
+ lines.push(selected === 1
1106
+ ? "Can't find its message? /skip sends the next one instead; this one comes back later."
1107
+ : "Can't find their message? /skip sends the next ones instead; these come back later.", selected === 1
1108
+ ? "/next stops showing you this one and does not send it again. Neither command answers anything."
1109
+ : "/next stops showing you these and does not send them again. Neither command answers anything.");
1028
1110
  return lines;
1029
1111
  }
1030
1112
  /**
@@ -1443,10 +1525,16 @@ export async function dispatchPending(setup, streams, state, now) {
1443
1525
  if (setup.delivery === "paced" && undelivered.length > 0) {
1444
1526
  const pending = queue.requests.length;
1445
1527
  if (!state.paced.summarySent || pending > state.paced.announced) {
1528
+ const opening = !state.paced.summarySent;
1446
1529
  state.paced.summarySent = true;
1447
1530
  state.paced.announced = pending;
1531
+ // P-9: the first one is a reminder that says what the older buttons do;
1532
+ // a later one, for a set that grew, is the count line.
1533
+ const lines = opening
1534
+ ? pacedReminderLines(pending, undelivered.length, setup.channel.staleCopyEnabled())
1535
+ : summaryLines(queue.requests, now);
1448
1536
  try {
1449
- const deliveryId = await setup.channel.announce(summaryLines(queue.requests, now));
1537
+ const deliveryId = await setup.channel.announce(lines);
1450
1538
  result.summary = { delivery_id: deliveryId, pending };
1451
1539
  }
1452
1540
  catch (cause) {
@@ -1465,7 +1553,15 @@ export async function dispatchPending(setup, streams, state, now) {
1465
1553
  // started against an empty queue has no re-delivery to announce, ever. See
1466
1554
  // {@link bannerLines} for why this is a banner rather than an edit of the
1467
1555
  // copies that came before.
1468
- const announcing = setup.delivery === "burst" && !state.banner.sent && undelivered.length > 0;
1556
+ //
1557
+ // P-9: a cycle whose stale summary went out has had its reminder already. The
1558
+ // summary opens with the count of everything this cycle re-sends and says
1559
+ // whether older buttons still work ({@link staleReminderLines}), so a banner
1560
+ // here would be the second reminder of one restart.
1561
+ const announcing = setup.delivery === "burst" &&
1562
+ !state.banner.sent &&
1563
+ undelivered.length > 0 &&
1564
+ result.collapsed === undefined;
1469
1565
  state.banner.sent = true;
1470
1566
  if (announcing) {
1471
1567
  try {
@@ -1860,55 +1956,51 @@ export function orderPending(requests, now) {
1860
1956
  return [...live.map((entry) => entry.request), ...stale.map((entry) => entry.request), ...proposals];
1861
1957
  }
1862
1958
  /**
1863
- * A DURATION in words, which is not what {@link ageText} renders.
1959
+ * The computed lines a collapsed re-delivery leads with (APRV-287), in a
1960
+ * resident's words (P-9).
1864
1961
  *
1865
- * `ageText` says how long ago something happened ("5 min ago"), and these two
1866
- * numbers are lengths of time rather than instants: writing "older than the
1867
- * hook's 5 min ago retry grace" would be a sentence about the wrong kind of
1868
- * thing.
1962
+ * `superseded` is the subset a newer pending request replaced (APRV-425), and
1963
+ * the line states the split rather than claiming every member is old. Before
1964
+ * APRV-425 it said every member was past the hook's wait, which stopped being
1965
+ * true the moment the collapse widened: a message that overstates what it
1966
+ * collapsed is the stale-documentation failure in message form, and this one is
1967
+ * read by the person deciding.
1968
+ *
1969
+ * Before P-9 these lines also spelled out the hook's wait and retry grace in
1970
+ * seconds and minutes, a tally of class names, and a sentence about log events
1971
+ * and identical retries. The reader is whoever holds the phone, so what is left
1972
+ * is the one thing they need: how many are listed, and why nobody needs an
1973
+ * answer to them now. The age of each one moves onto its own line
1974
+ * ({@link staleAges}).
1869
1975
  */
1870
- function durationText(ms) {
1871
- if (ms < 60_000)
1872
- return `${String(Math.round(ms / 1000))}s`;
1873
- const minutes = Math.round(ms / 60_000);
1874
- return `${String(minutes)}m`;
1976
+ export function staleLines(requests, _now, superseded = new Set()) {
1977
+ const count = requests.length;
1978
+ const listed = `${String(count)} older ${count === 1 ? "one is" : "ones are"} listed here`;
1979
+ const replaced = requests.filter((request) => superseded.has(request.action_key.value)).length;
1980
+ const abandoned = count - replaced;
1981
+ if (replaced === 0) {
1982
+ return [`${listed}; nobody is waiting on ${count === 1 ? "it" : "them"} any more.`];
1983
+ }
1984
+ if (abandoned === 0) {
1985
+ return [`${listed}; your agent has asked the same thing again since, so answer the newer one instead.`];
1986
+ }
1987
+ return [
1988
+ `${listed}. Nobody is waiting on ${String(abandoned)} of them, and your agent has asked the same thing again since for the other ${String(replaced)}.`,
1989
+ ];
1875
1990
  }
1876
1991
  /**
1877
- * The computed lines a collapsed re-delivery leads with (APRV-287).
1992
+ * How long ago each collapsed request was asked, in the order given (P-9).
1878
1993
  *
1879
- * `superseded` is the subset a newer pending request replaced (APRV-425), and
1880
- * the first line states the split rather than claiming every member is old.
1881
- * Before this the line said "all older than the hook's wait plus its retry
1882
- * grace", which stopped being true the moment the collapse widened: a message
1883
- * that overstates what it collapsed is the stale-documentation failure in
1884
- * message form, and this one is read by the person deciding.
1994
+ * Computed from the verified log's own `requested_ts`, like the rest of the
1995
+ * summary, and rendered beside each member's claimed summary line so the
1996
+ * resident can tell the requests apart without an action key.
1885
1997
  */
1886
- export function staleLines(requests, now, superseded = new Set()) {
1998
+ export function staleAges(requests, now) {
1887
1999
  const nowMs = Date.parse(now);
1888
- const ages = requests
1889
- .map((request) => nowMs - Date.parse(request.requested_ts.value))
1890
- .filter((age) => !Number.isNaN(age));
1891
- const oldest = ages.length === 0 ? null : Math.max(...ages);
1892
- const tally = new Map();
1893
- for (const request of requests) {
1894
- const cls = request.class.value;
1895
- tally.set(cls, (tally.get(cls) ?? 0) + 1);
1896
- }
1897
- const replaced = requests.filter((request) => superseded.has(request.action_key.value)).length;
1898
- const abandoned = requests.length - replaced;
1899
- const why = replaced === 0
1900
- ? `all older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace`
1901
- : abandoned === 0
1902
- ? "all superseded by a newer pending request for the same bytes and class"
1903
- : `${String(abandoned)} older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace, ${String(replaced)} superseded by a newer pending request for the same bytes and class`;
1904
- return [
1905
- `${String(requests.length)} pending requests, ${why}`,
1906
- `oldest: ${oldest === null ? "unknown age" : ageText(oldest)}`,
1907
- `classes: ${[...tally.entries()]
1908
- .map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
1909
- .join(", ")}`,
1910
- "collapsed into this one message because nothing is waiting on them; each one stays pending in the log, and a decision on any of them can still authorize an identical retry",
1911
- ];
2000
+ return requests.map((request) => {
2001
+ const at = Date.parse(request.requested_ts.value);
2002
+ return Number.isNaN(nowMs) || Number.isNaN(at) ? "unknown age" : ageText(nowMs - at);
2003
+ });
1912
2004
  }
1913
2005
  /**
1914
2006
  * Put the requests nobody is waiting on into ONE message, and hand back the
@@ -1980,8 +2072,13 @@ async function collapseStale(setup, streams, state, result, undecided, now) {
1980
2072
  return undecided;
1981
2073
  let delivered = null;
1982
2074
  try {
2075
+ // P-9: the summary carries this cycle's reminder, counting everything it
2076
+ // re-sends (the collapsed members plus the cards that follow), and the
2077
+ // banner is then skipped. `undecided` is exactly that set on a first cycle.
1983
2078
  delivered = await setup.channel.notifyStale(stale, {
2079
+ reminder: staleReminderLines(undecided.length, undecided.length - stale.length, setup.channel.staleCopyEnabled()),
1984
2080
  lines: staleLines(stale, now, superseded),
2081
+ ages: staleAges(stale, now),
1985
2082
  });
1986
2083
  }
1987
2084
  catch (cause) {