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
|
@@ -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_STUDY_WITHDRAWN_HEADLINE, 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";
|
|
@@ -664,6 +664,11 @@ export function prepareListen(request) {
|
|
|
664
664
|
// off. The policy loaded above is deliberately not consulted, so nothing
|
|
665
665
|
// an agent can propose into a policy file can turn it back on.
|
|
666
666
|
staleCopy: staleCopyFor(request.apiBase, request.noStaleCopy === true),
|
|
667
|
+
// Amended SPEC.md §8 (the study record). Read from this process's launch
|
|
668
|
+
// environment ONCE, here, and never from the policy: nothing an agent can
|
|
669
|
+
// propose into a policy file may switch it. Off leaves the key out, so the
|
|
670
|
+
// channel is configured exactly as it was before study mode existed.
|
|
671
|
+
...(studyModeFor(process.env) ? { study: true } : {}),
|
|
667
672
|
};
|
|
668
673
|
return {
|
|
669
674
|
ok: true,
|
|
@@ -853,7 +858,8 @@ export const DISPATCH_LOUD_ATTEMPTS = 3;
|
|
|
853
858
|
*/
|
|
854
859
|
export const DISPATCH_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
855
860
|
/**
|
|
856
|
-
* The
|
|
861
|
+
* The one message that introduces the first batch a listener process sends
|
|
862
|
+
* (APRV-196). Two lines, sent together by `announce`, the first in bold.
|
|
857
863
|
*
|
|
858
864
|
* **Why a banner and not an edit of the earlier copies.** The incident was a
|
|
859
865
|
* restart re-sending five pending requests with no warning, on top of five
|
|
@@ -869,27 +875,59 @@ export const DISPATCH_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
|
869
875
|
* (`actionRefOf` in `channels/telegram.ts`), so a human who taps the copy they
|
|
870
876
|
* can see decides the request they meant.
|
|
871
877
|
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
878
|
+
* **The words are a resident's, not the system's.** The reader is whoever
|
|
879
|
+
* holds the phone, so the banner is one short message in plain words: how many
|
|
880
|
+
* requests are still waiting, and whether the buttons on the older messages
|
|
881
|
+
* above still work. It names no process, no log and no cycle, and it says
|
|
882
|
+
* neither "started" nor "restarted": a listener cannot tell the two apart,
|
|
883
|
+
* having deliberately kept nothing that would let it, so the text claims only
|
|
884
|
+
* what is true either way (these requests are still waiting). The old third
|
|
885
|
+
* line, about where the pending set is read from, is gone; it described the
|
|
886
|
+
* runtime to someone who only needs to know which button to press.
|
|
876
887
|
*
|
|
877
888
|
* **With the stale-copy fallback off (APRV-456)** the second line says the
|
|
878
|
-
* opposite, because the earlier copies' buttons no longer
|
|
879
|
-
*
|
|
880
|
-
*
|
|
889
|
+
* opposite, because the earlier copies' buttons no longer work: a relayed
|
|
890
|
+
* listener refuses a nonce it is not holding (`nonce-not-issued`), so the
|
|
891
|
+
* banner tells the approver to use the buttons below it. A banner that kept
|
|
881
892
|
* promising working older copies would be this channel's own text sending the
|
|
882
893
|
* approver to a button that refuses them.
|
|
883
894
|
*/
|
|
884
895
|
export function bannerLines(pending, staleCopy = true) {
|
|
885
|
-
const
|
|
886
|
-
const live = `The ${pending === 1 ? "message" : "messages"} below ${pending === 1 ? "is" : "are"} the live ${pending === 1 ? "copy" : "copies"}.`;
|
|
896
|
+
const one = pending === 1;
|
|
887
897
|
return [
|
|
888
|
-
`
|
|
898
|
+
`Reminder: ${pending} ${one ? "request is" : "requests are"} still waiting for you.`,
|
|
889
899
|
staleCopy
|
|
890
|
-
? `${
|
|
891
|
-
:
|
|
892
|
-
|
|
900
|
+
? `${one ? "It is" : "They are"} repeated below; buttons on older messages above still work, and nothing is answered twice.`
|
|
901
|
+
: "Use the buttons below; buttons on older messages above no longer work.",
|
|
902
|
+
];
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* The reminder a collapsed re-delivery opens with, in place of the banner
|
|
906
|
+
* (P-9).
|
|
907
|
+
*
|
|
908
|
+
* Before this, a restart with two stale requests and one live one sent the
|
|
909
|
+
* stale summary first and the {@link bannerLines} banner second, and the banner
|
|
910
|
+
* counted only the cards after it: "1 request is still waiting" with three
|
|
911
|
+
* pending and the summary of the other two directly above. Two reminders, the
|
|
912
|
+
* more verbose one first, and a count that read as wrong. Now the summary IS the
|
|
913
|
+
* reminder: its first line counts everything this cycle re-sends (`pending`,
|
|
914
|
+
* collapsed and carded together), and the banner is skipped.
|
|
915
|
+
*
|
|
916
|
+
* `following` is how many requests get their own card after the summary. When
|
|
917
|
+
* there are any, the second line says so and says whether the buttons on older
|
|
918
|
+
* messages still work, which is what the banner it replaces said (APRV-456).
|
|
919
|
+
* When there are none, the summary is the whole restart and nothing follows.
|
|
920
|
+
*/
|
|
921
|
+
export function staleReminderLines(pending, following, staleCopy = true) {
|
|
922
|
+
const head = `Reminder: ${String(pending)} ${pending === 1 ? "request is" : "requests are"} still waiting for you.`;
|
|
923
|
+
if (following <= 0)
|
|
924
|
+
return [head];
|
|
925
|
+
const below = following === 1 ? "The other one is repeated below" : `The other ${String(following)} are repeated below`;
|
|
926
|
+
return [
|
|
927
|
+
head,
|
|
928
|
+
staleCopy
|
|
929
|
+
? `${below}; buttons on older messages above still work.`
|
|
930
|
+
: `${below}; use ${following === 1 ? "its buttons" : "their buttons"}, as buttons on older messages above no longer work.`,
|
|
893
931
|
];
|
|
894
932
|
}
|
|
895
933
|
/**
|
|
@@ -914,79 +952,145 @@ export function staleCopyFor(apiBase, noStaleCopy) {
|
|
|
914
952
|
return true;
|
|
915
953
|
return apiBase.replace(/\/+$/u, "") === TELEGRAM_DEFAULT_API_BASE;
|
|
916
954
|
}
|
|
955
|
+
/**
|
|
956
|
+
* The launch variable that turns study mode on (amended SPEC.md §8, the study
|
|
957
|
+
* record). Generic on purpose: the core is a public spec and names no study.
|
|
958
|
+
* A hosted deployment writes it into the listener's environment for the
|
|
959
|
+
* tenants a study covers; nothing else sets it.
|
|
960
|
+
*/
|
|
961
|
+
export const STUDY_MODE_ENV = "APPROVAL_STUDY_MODE";
|
|
962
|
+
/**
|
|
963
|
+
* Whether `env` turns study mode on: the value is exactly `1`, and anything
|
|
964
|
+
* else (unset, blank, `true`, ` 1`, `0`) is off. Called once, by
|
|
965
|
+
* {@link prepareListen}; the channel keeps the answer for its lifetime, so a
|
|
966
|
+
* change takes effect only when the listener restarts.
|
|
967
|
+
*/
|
|
968
|
+
export function studyModeFor(env) {
|
|
969
|
+
return env[STUDY_MODE_ENV] === "1";
|
|
970
|
+
}
|
|
917
971
|
// ---------------------------------------------------------------------------
|
|
918
972
|
// Paced delivery (APRV-216) — one question at a time
|
|
919
973
|
// ---------------------------------------------------------------------------
|
|
920
974
|
/**
|
|
921
|
-
* The
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
975
|
+
* The count line a paced cycle sends when the pending set has grown, and the
|
|
976
|
+
* header of `/queue` (APRV-216), in a resident's words (P-9).
|
|
977
|
+
*
|
|
978
|
+
* One line, and everything in it is arithmetic on the verified log at the
|
|
979
|
+
* instant it is written: how many requests are waiting and how long the oldest
|
|
980
|
+
* has waited. Nothing is remembered between calls, so two summaries a minute
|
|
981
|
+
* apart can disagree only because the log moved.
|
|
982
|
+
*
|
|
983
|
+
* Before P-9 it also carried a class tally ("communicate.email.external ×3").
|
|
984
|
+
* The tally told an operator what kind of work was queued, and told a resident
|
|
985
|
+
* nothing they could read; the kind of each request is on its own card, and
|
|
986
|
+
* `/queue` now lists each by the agent's own summary ({@link queueLines}).
|
|
987
|
+
*
|
|
988
|
+
* The oldest is the one asked longest ago, whatever order `requests` is in:
|
|
989
|
+
* `/queue` passes the set in presentation order ({@link orderPending}, live
|
|
990
|
+
* newest first), where the first entry is the NEWEST, and a header that read
|
|
991
|
+
* the first entry's age called the newest one the oldest.
|
|
932
992
|
*/
|
|
933
993
|
export function summaryLines(requests, now) {
|
|
934
994
|
if (requests.length === 0)
|
|
935
|
-
return ["Nothing
|
|
936
|
-
const
|
|
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(", ");
|
|
995
|
+
return ["Nothing is waiting for you right now."];
|
|
996
|
+
const one = requests.length === 1;
|
|
948
997
|
return [
|
|
949
|
-
`${String(requests.length)}
|
|
998
|
+
`${String(requests.length)} ${one ? "request is" : "requests are"} waiting for you (${one ? "asked" : "oldest"} ${oldestAge(requests, now)}).`,
|
|
950
999
|
];
|
|
951
1000
|
}
|
|
1001
|
+
/** How long ago the longest-waiting request was asked, in words. */
|
|
1002
|
+
function oldestAge(requests, now) {
|
|
1003
|
+
const nowMs = Date.parse(now);
|
|
1004
|
+
const instants = requests
|
|
1005
|
+
.map((request) => Date.parse(request.requested_ts.value))
|
|
1006
|
+
.filter((at) => !Number.isNaN(at));
|
|
1007
|
+
if (Number.isNaN(nowMs) || instants.length === 0)
|
|
1008
|
+
return "at an unknown time";
|
|
1009
|
+
return ageText(nowMs - Math.min(...instants));
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* The reminder a paced listener's first cycle opens with (P-9).
|
|
1013
|
+
*
|
|
1014
|
+
* Paced is the product default (APRV-216), and a restart there used to open
|
|
1015
|
+
* with the bare count line and then one card. Nothing in it said this was a
|
|
1016
|
+
* reminder, or whether the buttons on older messages above still worked, which
|
|
1017
|
+
* matters most behind a relay, where they do not (APRV-456). So the first
|
|
1018
|
+
* cycle's line is a reminder in the burst banner's words ({@link bannerLines}):
|
|
1019
|
+
* how many are waiting, how many are below, and what the older buttons do.
|
|
1020
|
+
*
|
|
1021
|
+
* `shown` is how many requests this cycle puts below it: one, or a digest
|
|
1022
|
+
* group. With the stale-copy fallback on, older buttons decide only while their
|
|
1023
|
+
* request is the one being shown, so when fewer are shown than are pending the
|
|
1024
|
+
* line says only the ones below work right now (fold round 1, S1). Later cycles that find the set has grown send {@link summaryLines}
|
|
1025
|
+
* instead, which is a count and not a reminder.
|
|
1026
|
+
*/
|
|
1027
|
+
export function pacedReminderLines(pending, shown, staleCopy = true) {
|
|
1028
|
+
const head = `Reminder: ${String(pending)} ${pending === 1 ? "request is" : "requests are"} waiting for you.`;
|
|
1029
|
+
const below = shown >= pending
|
|
1030
|
+
? pending === 1
|
|
1031
|
+
? "It is below."
|
|
1032
|
+
: "They are below."
|
|
1033
|
+
: shown === 1
|
|
1034
|
+
? "One is below; the next comes after you answer it."
|
|
1035
|
+
: `${String(shown)} are below; the next come after you answer them.`;
|
|
1036
|
+
if (staleCopy && shown < pending) {
|
|
1037
|
+
// Fold round 1, S1: under paced only the unit below has a live delivery.
|
|
1038
|
+
// An older copy of any OTHER pending request is answered "Still pending —
|
|
1039
|
+
// this copy's buttons are not live here", so "older buttons still work"
|
|
1040
|
+
// would be this channel's own text sending the approver to a refusal.
|
|
1041
|
+
return [
|
|
1042
|
+
head,
|
|
1043
|
+
shown === 1
|
|
1044
|
+
? "One is below. Only its buttons work right now; the others come next, with new buttons, after you answer it."
|
|
1045
|
+
: `${String(shown)} are below. Only their buttons work right now; the others come next, with new buttons, after you answer them.`,
|
|
1046
|
+
];
|
|
1047
|
+
}
|
|
1048
|
+
const older = staleCopy
|
|
1049
|
+
? "Buttons on older messages above still work."
|
|
1050
|
+
: `Buttons on older messages above no longer work; use ${shown === 1 ? "the one" : "the ones"} below.`;
|
|
1051
|
+
return [head, `${below} ${older}`];
|
|
1052
|
+
}
|
|
952
1053
|
/**
|
|
953
|
-
* The marker `/queue` puts on the request this listener has selected (APRV-256)
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
* messages, or lost with the chat
|
|
961
|
-
*
|
|
962
|
-
*
|
|
1054
|
+
* The marker `/queue` puts on the request this listener has selected (APRV-256),
|
|
1055
|
+
* in a resident's words (P-9).
|
|
1056
|
+
*
|
|
1057
|
+
* It says one thing and claims no second: a message for this request was sent
|
|
1058
|
+
* earlier, which is the delivery bookkeeping recording that a send once
|
|
1059
|
+
* succeeded. That is not evidence the message is still in the chat: Telegram
|
|
1060
|
+
* reports a successful send, never a message's continued existence, and a card
|
|
1061
|
+
* can be deleted, buried under a thousand later messages, or lost with the chat
|
|
1062
|
+
* history on a reinstall. The marker before APRV-256, "shown now", asserted
|
|
1063
|
+
* present visibility from a past delivery, which is the bug this constant
|
|
1064
|
+
* exists to keep fixed, and why it does not say "on screen" either.
|
|
963
1065
|
*/
|
|
964
|
-
const SELECTED_MARKER = "
|
|
1066
|
+
const SELECTED_MARKER = " · sent to you earlier";
|
|
965
1067
|
/**
|
|
966
1068
|
* What `/queue` says about itself, immediately under the summary (APRV-256).
|
|
967
1069
|
*
|
|
968
1070
|
* `/queue` is a summary reply and carries no buttons, so an approver reading it
|
|
969
1071
|
* 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
|
|
1072
|
+
* on it. Where the controls DO live is stated without a direction: a message is
|
|
971
1073
|
* somewhere in the chat's history, and "above" was only ever true for the
|
|
972
1074
|
* approver who asked while looking straight at it.
|
|
973
1075
|
*/
|
|
974
|
-
const QUEUE_IS_A_LIST = "This
|
|
1076
|
+
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
1077
|
/**
|
|
976
1078
|
* `/queue`'s reply: the summary, then one numbered line per pending request.
|
|
977
1079
|
*
|
|
978
1080
|
* Derived, like the summary, from the verified log at reply time and not from
|
|
979
1081
|
* anything this process is holding: the numbering is positional and names no
|
|
980
1082
|
* button, so a stale copy of this list cannot be used to decide anything. The
|
|
981
|
-
* marker says which one
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
1083
|
+
* marker says which one was sent earlier, because the question `/queue` is
|
|
1084
|
+
* usually asked to answer is "what else is there besides the one I am looking
|
|
1085
|
+
* at" and, since APRV-256, its unhappy twin, "where is the one I am supposed to
|
|
1086
|
+
* be looking at".
|
|
1087
|
+
*
|
|
1088
|
+
* P-9: each line is the agent's own summary and its age, under the label that
|
|
1089
|
+
* says the summary is the agent's and unchecked. Before P-9 each line was the
|
|
1090
|
+
* action key, the task id, the class and the age, and in real use the first
|
|
1091
|
+
* two are `hook:<session uuid>:<tool_use_id>…`, two long raw ids per line. The
|
|
1092
|
+
* three recovery paragraphs are two short lines: what /skip and /next do, and
|
|
1093
|
+
* that neither answers anything.
|
|
990
1094
|
*/
|
|
991
1095
|
export function queueLines(requests, now, shown) {
|
|
992
1096
|
const lines = summaryLines(requests, now);
|
|
@@ -995,36 +1099,35 @@ export function queueLines(requests, now, shown) {
|
|
|
995
1099
|
const nowMs = Date.parse(now);
|
|
996
1100
|
const current = new Set(shown);
|
|
997
1101
|
let selected = 0;
|
|
998
|
-
lines.push(QUEUE_IS_A_LIST);
|
|
1102
|
+
lines.push(QUEUE_IS_A_LIST, `${TELEGRAM_CLAIMED_LABEL}:`);
|
|
999
1103
|
requests.forEach((request, index) => {
|
|
1000
|
-
const key = request.action_key.value;
|
|
1001
1104
|
const requestedMs = Date.parse(request.requested_ts.value);
|
|
1002
1105
|
const age = Number.isNaN(nowMs) || Number.isNaN(requestedMs)
|
|
1003
1106
|
? "unknown age"
|
|
1004
1107
|
: ageText(nowMs - requestedMs);
|
|
1005
|
-
const
|
|
1006
|
-
const isSelected = current.has(key);
|
|
1108
|
+
const isSelected = current.has(request.action_key.value);
|
|
1007
1109
|
if (isSelected)
|
|
1008
1110
|
selected += 1;
|
|
1009
|
-
lines.push(`${String(index + 1)}. ${
|
|
1111
|
+
lines.push(`${String(index + 1)}. ${request.summary.value ?? "(none given)"} · ${age}${isSelected ? SELECTED_MARKER : ""}`);
|
|
1010
1112
|
});
|
|
1011
1113
|
if (selected === 0) {
|
|
1012
1114
|
// Nothing selected is an ordinary state, not a fault: a decided or passed
|
|
1013
1115
|
// over request leaves the listener holding nothing until the next cycle
|
|
1014
1116
|
// picks the next one up. Saying so is what stops the reader searching the
|
|
1015
|
-
// chat for a
|
|
1016
|
-
lines.push("
|
|
1117
|
+
// chat for a message this process never claimed to have sent.
|
|
1118
|
+
lines.push("None of these has been sent to you yet. The next one arrives with its buttons shortly.");
|
|
1017
1119
|
return lines;
|
|
1018
1120
|
}
|
|
1019
1121
|
// More than one key is marked when the selection is a digest group, which
|
|
1020
|
-
// Telegram receives as ONE
|
|
1021
|
-
//
|
|
1122
|
+
// Telegram receives as ONE summary message covering the set, so the plural
|
|
1123
|
+
// branch speaks of one message. No positional word in either: the reply may
|
|
1022
1124
|
// be chunked across several messages, so "above" is not this function's to
|
|
1023
1125
|
// promise even about its own lines.
|
|
1024
|
-
|
|
1025
|
-
? "
|
|
1026
|
-
:
|
|
1027
|
-
|
|
1126
|
+
lines.push(selected === 1
|
|
1127
|
+
? "Can't find its message? /skip sends the next one instead; this one comes back later."
|
|
1128
|
+
: "Can't find their message? /skip sends the next ones instead; these come back later.", selected === 1
|
|
1129
|
+
? "/next stops showing you this one and does not send it again. Neither command answers anything."
|
|
1130
|
+
: "/next stops showing you these and does not send them again. Neither command answers anything.");
|
|
1028
1131
|
return lines;
|
|
1029
1132
|
}
|
|
1030
1133
|
/**
|
|
@@ -1104,6 +1207,13 @@ export function newDispatchState() {
|
|
|
1104
1207
|
},
|
|
1105
1208
|
};
|
|
1106
1209
|
}
|
|
1210
|
+
/** Whether a settling record is the study team's withdrawal (amended SPEC.md §8). */
|
|
1211
|
+
function isStudyWithdrawal(payload) {
|
|
1212
|
+
const study = payload["study"];
|
|
1213
|
+
return (typeof study === "object" &&
|
|
1214
|
+
study !== null &&
|
|
1215
|
+
study["reason"] === "study_withdrawn");
|
|
1216
|
+
}
|
|
1107
1217
|
/** The event that records each terminal state, for finding the settling record. */
|
|
1108
1218
|
const TERMINAL_EVENT = {
|
|
1109
1219
|
granted: "approval.granted",
|
|
@@ -1220,6 +1330,20 @@ function terminalDeliveries(setup, state, now) {
|
|
|
1220
1330
|
});
|
|
1221
1331
|
continue;
|
|
1222
1332
|
}
|
|
1333
|
+
// Amended SPEC.md §8, the study record: the study team withdrew a pending
|
|
1334
|
+
// request. Its own headline and line, so the resident never reads it as a
|
|
1335
|
+
// person's REJECTED and does read that nothing ran. The operator id the
|
|
1336
|
+
// record carries is not shown.
|
|
1337
|
+
if (outcome === "rejected" && record !== undefined && isStudyWithdrawal(payload)) {
|
|
1338
|
+
settled.push({
|
|
1339
|
+
actionKey,
|
|
1340
|
+
deliveryId,
|
|
1341
|
+
outcome,
|
|
1342
|
+
headline: TELEGRAM_STUDY_WITHDRAWN_HEADLINE,
|
|
1343
|
+
detail: [`withdrawn by the study team at ${utcClock(record.ts)} · nothing ran`],
|
|
1344
|
+
});
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1223
1347
|
// granted / rejected / revoked: a human answered, somewhere. The actor is
|
|
1224
1348
|
// the log's, never this listener's configured identity — the answer may
|
|
1225
1349
|
// have come from another surface entirely.
|
|
@@ -1278,7 +1402,7 @@ export async function dispatchPending(setup, streams, state, now) {
|
|
|
1278
1402
|
// The action key is what makes this per member on a digest (APRV-115):
|
|
1279
1403
|
// one delivery id can carry several requests, and settling one of them
|
|
1280
1404
|
// must leave the others armed.
|
|
1281
|
-
await setup.channel.annotate(settled.deliveryId, TELEGRAM_TERMINAL_HEADLINES[settled.outcome], settled.detail, settled.actionKey);
|
|
1405
|
+
await setup.channel.annotate(settled.deliveryId, settled.headline ?? TELEGRAM_TERMINAL_HEADLINES[settled.outcome], settled.detail, settled.actionKey);
|
|
1282
1406
|
result.annotated.push({
|
|
1283
1407
|
action_key: settled.actionKey,
|
|
1284
1408
|
delivery_id: settled.deliveryId,
|
|
@@ -1443,10 +1567,16 @@ export async function dispatchPending(setup, streams, state, now) {
|
|
|
1443
1567
|
if (setup.delivery === "paced" && undelivered.length > 0) {
|
|
1444
1568
|
const pending = queue.requests.length;
|
|
1445
1569
|
if (!state.paced.summarySent || pending > state.paced.announced) {
|
|
1570
|
+
const opening = !state.paced.summarySent;
|
|
1446
1571
|
state.paced.summarySent = true;
|
|
1447
1572
|
state.paced.announced = pending;
|
|
1573
|
+
// P-9: the first one is a reminder that says what the older buttons do;
|
|
1574
|
+
// a later one, for a set that grew, is the count line.
|
|
1575
|
+
const lines = opening
|
|
1576
|
+
? pacedReminderLines(pending, undelivered.length, setup.channel.staleCopyEnabled())
|
|
1577
|
+
: summaryLines(queue.requests, now);
|
|
1448
1578
|
try {
|
|
1449
|
-
const deliveryId = await setup.channel.announce(
|
|
1579
|
+
const deliveryId = await setup.channel.announce(lines);
|
|
1450
1580
|
result.summary = { delivery_id: deliveryId, pending };
|
|
1451
1581
|
}
|
|
1452
1582
|
catch (cause) {
|
|
@@ -1465,7 +1595,15 @@ export async function dispatchPending(setup, streams, state, now) {
|
|
|
1465
1595
|
// started against an empty queue has no re-delivery to announce, ever. See
|
|
1466
1596
|
// {@link bannerLines} for why this is a banner rather than an edit of the
|
|
1467
1597
|
// copies that came before.
|
|
1468
|
-
|
|
1598
|
+
//
|
|
1599
|
+
// P-9: a cycle whose stale summary went out has had its reminder already. The
|
|
1600
|
+
// summary opens with the count of everything this cycle re-sends and says
|
|
1601
|
+
// whether older buttons still work ({@link staleReminderLines}), so a banner
|
|
1602
|
+
// here would be the second reminder of one restart.
|
|
1603
|
+
const announcing = setup.delivery === "burst" &&
|
|
1604
|
+
!state.banner.sent &&
|
|
1605
|
+
undelivered.length > 0 &&
|
|
1606
|
+
result.collapsed === undefined;
|
|
1469
1607
|
state.banner.sent = true;
|
|
1470
1608
|
if (announcing) {
|
|
1471
1609
|
try {
|
|
@@ -1860,55 +1998,51 @@ export function orderPending(requests, now) {
|
|
|
1860
1998
|
return [...live.map((entry) => entry.request), ...stale.map((entry) => entry.request), ...proposals];
|
|
1861
1999
|
}
|
|
1862
2000
|
/**
|
|
1863
|
-
*
|
|
2001
|
+
* The computed lines a collapsed re-delivery leads with (APRV-287), in a
|
|
2002
|
+
* resident's words (P-9).
|
|
1864
2003
|
*
|
|
1865
|
-
* `
|
|
1866
|
-
*
|
|
1867
|
-
*
|
|
1868
|
-
*
|
|
2004
|
+
* `superseded` is the subset a newer pending request replaced (APRV-425), and
|
|
2005
|
+
* the line states the split rather than claiming every member is old. Before
|
|
2006
|
+
* APRV-425 it said every member was past the hook's wait, which stopped being
|
|
2007
|
+
* true the moment the collapse widened: a message that overstates what it
|
|
2008
|
+
* collapsed is the stale-documentation failure in message form, and this one is
|
|
2009
|
+
* read by the person deciding.
|
|
2010
|
+
*
|
|
2011
|
+
* Before P-9 these lines also spelled out the hook's wait and retry grace in
|
|
2012
|
+
* seconds and minutes, a tally of class names, and a sentence about log events
|
|
2013
|
+
* and identical retries. The reader is whoever holds the phone, so what is left
|
|
2014
|
+
* is the one thing they need: how many are listed, and why nobody needs an
|
|
2015
|
+
* answer to them now. The age of each one moves onto its own line
|
|
2016
|
+
* ({@link staleAges}).
|
|
1869
2017
|
*/
|
|
1870
|
-
function
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
const
|
|
1874
|
-
|
|
2018
|
+
export function staleLines(requests, _now, superseded = new Set()) {
|
|
2019
|
+
const count = requests.length;
|
|
2020
|
+
const listed = `${String(count)} older ${count === 1 ? "one is" : "ones are"} listed here`;
|
|
2021
|
+
const replaced = requests.filter((request) => superseded.has(request.action_key.value)).length;
|
|
2022
|
+
const abandoned = count - replaced;
|
|
2023
|
+
if (replaced === 0) {
|
|
2024
|
+
return [`${listed}; nobody is waiting on ${count === 1 ? "it" : "them"} any more.`];
|
|
2025
|
+
}
|
|
2026
|
+
if (abandoned === 0) {
|
|
2027
|
+
return [`${listed}; your agent has asked the same thing again since, so answer the newer one instead.`];
|
|
2028
|
+
}
|
|
2029
|
+
return [
|
|
2030
|
+
`${listed}. Nobody is waiting on ${String(abandoned)} of them, and your agent has asked the same thing again since for the other ${String(replaced)}.`,
|
|
2031
|
+
];
|
|
1875
2032
|
}
|
|
1876
2033
|
/**
|
|
1877
|
-
*
|
|
2034
|
+
* How long ago each collapsed request was asked, in the order given (P-9).
|
|
1878
2035
|
*
|
|
1879
|
-
*
|
|
1880
|
-
*
|
|
1881
|
-
*
|
|
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.
|
|
2036
|
+
* Computed from the verified log's own `requested_ts`, like the rest of the
|
|
2037
|
+
* summary, and rendered beside each member's claimed summary line so the
|
|
2038
|
+
* resident can tell the requests apart without an action key.
|
|
1885
2039
|
*/
|
|
1886
|
-
export function
|
|
2040
|
+
export function staleAges(requests, now) {
|
|
1887
2041
|
const nowMs = Date.parse(now);
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
.
|
|
1891
|
-
|
|
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
|
-
];
|
|
2042
|
+
return requests.map((request) => {
|
|
2043
|
+
const at = Date.parse(request.requested_ts.value);
|
|
2044
|
+
return Number.isNaN(nowMs) || Number.isNaN(at) ? "unknown age" : ageText(nowMs - at);
|
|
2045
|
+
});
|
|
1912
2046
|
}
|
|
1913
2047
|
/**
|
|
1914
2048
|
* Put the requests nobody is waiting on into ONE message, and hand back the
|
|
@@ -1980,8 +2114,13 @@ async function collapseStale(setup, streams, state, result, undecided, now) {
|
|
|
1980
2114
|
return undecided;
|
|
1981
2115
|
let delivered = null;
|
|
1982
2116
|
try {
|
|
2117
|
+
// P-9: the summary carries this cycle's reminder, counting everything it
|
|
2118
|
+
// re-sends (the collapsed members plus the cards that follow), and the
|
|
2119
|
+
// banner is then skipped. `undecided` is exactly that set on a first cycle.
|
|
1983
2120
|
delivered = await setup.channel.notifyStale(stale, {
|
|
2121
|
+
reminder: staleReminderLines(undecided.length, undecided.length - stale.length, setup.channel.staleCopyEnabled()),
|
|
1984
2122
|
lines: staleLines(stale, now, superseded),
|
|
2123
|
+
ages: staleAges(stale, now),
|
|
1985
2124
|
});
|
|
1986
2125
|
}
|
|
1987
2126
|
catch (cause) {
|