Skip to content

Commit f78f2c0

Browse files
committed
fix(pm): post-stamped's unread-knock check never narrows on a stamp that names no instant
The `--body` refresh derives "the body's last write" from the newest protocol stamp in the stored body, and the file's header promises that derivation is a LOWER bound: more comments count as newer, never fewer. It was not. The newest stamp went through `Date.parse` alone, which rolls an impossible date FORWARD and returns an ordinary number — a stored `2026-04-31T00:00Z` read back as 1 May, so a knock at `2026-04-30T12:00:00Z` fell outside the window, the check answered `none-newer`, and the refresh voided the knock. `lastWriteStamp` now judges every stamp with `stampRealInstant`, the same round trip the write side refuses a quoted stamp with — one predicate, never two. A stamp that names no instant (NaN or rolled over) is not read as the last write at all; the derivation falls back to the newest REAL stamp, which is earlier, so the window only widens, and a body whose stamps are all unreal lands on the existing no-stamp rule and counts every comment. Nothing else moves: a card with nothing newer is still written, so the acceptance control the header states ("no unread knock ⇒ the refresh is not affected") holds unchanged. The derivation always answers an object now, carrying the stamps it refused to read, so the refusal, the transcript line and the `--json` transcript can name a window that is wider than the body looks instead of claiming the body carries no stamp at all. Self-test: 186 → 204 cases; the unread-knock battery floor 23 → 41. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr
1 parent 3964efb commit f78f2c0

1 file changed

Lines changed: 138 additions & 24 deletions

File tree

‎scripts/pm/post-stamped.mjs‎

Lines changed: 138 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,30 @@
223223
* "The body's last write" has no platform field: the REST issue object carries
224224
* `updated_at`, which moves on comments and labels too, and the body's edit
225225
* history is GraphQL-only, which agent containers cannot reach. So the instant
226-
* is read from the body ITSELF — the newest protocol stamp in the stored body,
227-
* which is this tool's own `{{NOW}}` whenever the last refresh came through it
228-
* (the protocol says every seat-post stamp does). ⚠️ That derivation is a LOWER
229-
* bound: a refresh that carried no `{{NOW}}`, or one made by hand, leaves an
230-
* older stamp behind, so MORE comments count as newer, never fewer — the check
231-
* may ask for an acknowledgement it did not strictly need, and cannot skip one
232-
* it did. A body with no stamp at all counts every comment, and says so.
226+
* is read from the body ITSELF — the newest protocol stamp in the stored body
227+
* that names an instant the calendar HAS, which is this tool's own `{{NOW}}`
228+
* whenever the last refresh came through it (the protocol says every seat-post
229+
* stamp does). ⚠️ That derivation is a LOWER bound: a refresh that carried no
230+
* `{{NOW}}`, or one made by hand, leaves an older stamp behind, so MORE
231+
* comments count as newer, never fewer — the check may ask for an
232+
* acknowledgement it did not strictly need, and cannot skip one it did. A body
233+
* with no stamp at all counts every comment, and says so.
234+
*
235+
* "Names an instant the calendar has" is the load-bearing half of that promise,
236+
* not a politeness. `Date.parse` rolls an impossible date FORWARD and hands back
237+
* an ordinary number, so a stored `2026-04-31T00:00Z` reads as 1 May — LATER
238+
* than its own digits — and a knock at 30 April falls outside a window measured
239+
* from it: the derivation would have SHRUNK, which is the one direction it may
240+
* never move. So a stamp `stampRealInstant` judges unreal (NaN or rolled over —
241+
* the same round trip the write side refuses a quoted stamp with, one predicate
242+
* and never two) is not read as the last write at all. The derivation falls
243+
* back to the newest REAL stamp, which is earlier, so the window only widens;
244+
* a body whose stamps are ALL unreal lands on the no-stamp rule above and
245+
* counts every comment. Nothing else changes — a card with nothing newer is
246+
* still written, because refusing a refresh nobody knocked on would break the
247+
* same acceptance the next paragraph states. Refusal and pass both NAME the
248+
* stamps that were not read: a window wider than the body looks is a thing the
249+
* transcript has to be able to say.
233250
*
234251
* ⛔ It cannot tell a knock from any other comment: under one shared identity
235252
* the author field names no seat, and content is not classified. So the control
@@ -702,18 +719,38 @@ export function readBackVerdict({ stamp, writtenAt, sent, stored, substituted =
702719

703720
/**
704721
* The instant the stored body was last written, as far as the body itself can
705-
* say: its NEWEST protocol stamp, judged as an instant (a seconds-grained stamp
706-
* and a minute-grained one compare by `stampSpan`, never as strings). `null`
707-
* when the body carries no stamp. A lower bound on the true last write — see
708-
* the header — so a caller using it as "since" over-includes, never under.
722+
* say: its NEWEST protocol stamp that names a real instant, judged as an
723+
* instant (a seconds-grained stamp and a minute-grained one compare by
724+
* `stampSpan`, never as strings).
725+
*
726+
* Always an object. `stamp`/`from` are null when the body carries no stamp this
727+
* may read — the same population as "no stamp at all", on purpose — and
728+
* `unreal` carries the stamps it refused, each with the date it rolls over to
729+
* (`null` for the half that does not parse at all, so a caller cannot report a
730+
* rollover that did not happen).
731+
*
732+
* ONE predicate decides what may be read: `stampRealInstant`, the round trip
733+
* the write side refuses a quoted stamp with. Reading a stamp through
734+
* `Date.parse` alone lets an impossible date roll FORWARD into a LATER instant
735+
* and NARROW the unread window, which is the one direction the header forbids —
736+
* so NaN and rollover are one class here, not two accidents of the parser.
737+
*
738+
* A lower bound on the true last write — see the header — so a caller using it
739+
* as "since" over-includes, never under.
709740
*/
710741
export function lastWriteStamp(storedBody) {
711742
let best = null;
743+
const unreal = [];
712744
for (const stamp of protocolStamps(storedBody)) {
745+
const calendar = stampRealInstant(stamp);
746+
if (!calendar.real) {
747+
unreal.push({ stamp, rolledTo: calendar.rolledTo });
748+
continue;
749+
}
713750
const span = stampSpan(stamp);
714-
if (span && (!best || span.from > best.from)) best = { stamp, from: span.from };
751+
if (!best || span.from > best.from) best = { stamp, from: span.from };
715752
}
716-
return best;
753+
return { stamp: best?.stamp ?? null, from: best?.from ?? null, unreal };
717754
}
718755

719756
const commentCreatedMs = (c) => {
@@ -724,8 +761,10 @@ const commentCreatedMs = (c) => {
724761
/**
725762
* Whether this refresh may write over the card's comment tail. Pure: the
726763
* caller hands in the stored body and the comments it fetched; the population
727-
* judged is every comment CREATED at or after the minute of the body's newest
728-
* stamp (an edit to an older comment is not a knock the read window knows).
764+
* judged is every comment CREATED at or after the minute of the newest stamp in
765+
* the body that names a real instant (an edit to an older comment is not a
766+
* knock the read window knows) — and EVERY comment when the body carries no
767+
* such stamp, whether it carries none at all or only impossible ones.
729768
*
730769
* ok, kind 'none-newer' nothing newer than the last write — the control
731770
* ok, kind 'acknowledged' `ackThrough` names the newest of the newer ones
@@ -737,7 +776,7 @@ export function unreadComments({ storedBody, comments, ackThrough = null }) {
737776
const since = lastWriteStamp(storedBody);
738777
const all = Array.isArray(comments) ? comments : [];
739778
const newer = all
740-
.filter((c) => since === null || commentCreatedMs(c) >= since.from)
779+
.filter((c) => since.from === null || commentCreatedMs(c) >= since.from)
741780
.sort((a, b) => commentCreatedMs(a) - commentCreatedMs(b) || Number(a?.id) - Number(b?.id));
742781
const newest = newer.length > 0 ? newer[newer.length - 1] : null;
743782
const base = { since, newer, newest, ackThrough };
@@ -756,9 +795,23 @@ const commentRow = (c, i) => {
756795
return ` ${i + 1}. ${c?.id ?? '?'} · ${c?.created_at ?? '(no created_at)'} · ${c?.user?.login ?? '?'} · ${shown}`;
757796
};
758797

798+
/**
799+
* The stamps `lastWriteStamp` refused to read, as the rows a refusal carries.
800+
* Rendered in ONE place so the refusal and the transcript cannot come to
801+
* describe the same skipped stamp two ways.
802+
*/
803+
const unrealStampRows = (unreal) =>
804+
unreal.map(
805+
(u) =>
806+
` · \`${u.stamp}\` — ` +
807+
(u.rolledTo === null
808+
? 'a field is outside its own range, so it does not parse at all'
809+
: `it rolls over to \`${u.rolledTo}\`, a LATER instant than the date its own digits spell`),
810+
);
811+
759812
/** The refusal a caller reads when `unreadComments` says no. */
760813
export function unreadRefusalText(check, number) {
761-
const sinceText = check.since ? `the body's last write stamp (\`${check.since.stamp}\`)` : 'the body\'s last write';
814+
const sinceText = check.since.stamp ? `the body's last write stamp (\`${check.since.stamp}\`)` : 'the body\'s last write';
762815
const newestId = check.newest?.id ?? '?';
763816
const head =
764817
check.kind === 'ack-unknown'
@@ -775,27 +828,50 @@ export function unreadRefusalText(check, number) {
775828
' silence. Read the tail to its end, receipt every request it carries (a reply comment, or a',
776829
` carry-over into the body), then re-run with --ack-through=${newestId} — the newest comment.`,
777830
);
778-
if (check.since === null) {
831+
if (check.since.stamp === null && check.since.unreal.length === 0) {
779832
lines.push(
780833
' ⚠️ The stored body carries NO protocol stamp, so EVERY comment on the card counts as newer than',
781834
` its last write. Put \`${STAMP_TOKEN}\` in the body so the next refresh measures from this write.`,
782835
);
836+
} else if (check.since.stamp === null) {
837+
lines.push(
838+
' ⚠️ No stamp in the stored body names an instant the calendar HAS, so none of them is read as the',
839+
' last write and EVERY comment on the card counts as newer — a date nothing can have been written',
840+
` on may not narrow this window. Put \`${STAMP_TOKEN}\` in the body so the next refresh measures from`,
841+
' this write.',
842+
...unrealStampRows(check.since.unreal),
843+
);
783844
} else {
784845
lines.push(
785846
' (The stamp is a lower bound on the last write — a refresh that carried no token leaves an older',
786847
' stamp behind — so this list can be longer than the true unread set, never shorter.)',
787848
);
849+
if (check.since.unreal.length > 0) {
850+
lines.push(
851+
` ⚠️ ${check.since.unreal.length} stamp(s) in the body name no instant the calendar has and were NOT read as the`,
852+
' last write; the window measures from the newest REAL stamp instead, so it is WIDER here, never',
853+
' narrower.',
854+
...unrealStampRows(check.since.unreal),
855+
);
856+
}
788857
}
789858
lines.push(...check.after.map(commentRow));
790859
return lines.join('\n');
791860
}
792861

793862
/** The one line a transcript carries when the check passed. */
794863
export function unreadPassText(check) {
795-
const sinceText = check.since ? `the body's last write stamp \`${check.since.stamp}\`` : 'the body (which carries no stamp)';
864+
const sinceText = check.since.stamp
865+
? `the body's last write stamp \`${check.since.stamp}\``
866+
: 'the body (which carries no stamp that names an instant)';
867+
const skipped =
868+
check.since.unreal.length === 0
869+
? ''
870+
: ` (${check.since.unreal.length} stamp(s) naming no instant the calendar has were NOT read as the last write: ` +
871+
`${check.since.unreal.map((u) => `\`${u.stamp}\``).join(', ')} — the window is wider, never narrower)`;
796872
return check.kind === 'none-newer'
797-
? ` unread check: no comment newer than ${sinceText} — nothing to acknowledge`
798-
: ` unread check: ${check.newer.length} comment(s) newer than ${sinceText}, acknowledged through ${check.newest?.id} (the newest)`;
873+
? ` unread check: no comment newer than ${sinceText}${skipped} — nothing to acknowledge`
874+
: ` unread check: ${check.newer.length} comment(s) newer than ${sinceText}${skipped}, acknowledged through ${check.newest?.id} (the newest)`;
799875
}
800876

801877
/** The flags this tool takes. An argument outside this set is a typo, and a typo is refused. */
@@ -1029,7 +1105,7 @@ async function main(argv) {
10291105
let tail;
10301106
try {
10311107
const probe = await rest(`/repos/${repoRes.repo}/issues/${options.number}`);
1032-
tail = await readCardTail(repoRes.repo, options.number, lastWriteStamp(probe?.body)?.from);
1108+
tail = await readCardTail(repoRes.repo, options.number, lastWriteStamp(probe?.body).from);
10331109
} catch (err) {
10341110
return reportPrerequisiteNotMet(err);
10351111
}
@@ -1071,7 +1147,14 @@ async function main(argv) {
10711147
drift_minutes: verdict.drift,
10721148
body_mutated: verdict.mutated,
10731149
...(unread
1074-
? { unread_check: { since: unread.since?.stamp ?? null, newer: unread.newer.length, ack_through: unread.ackThrough } }
1150+
? {
1151+
unread_check: {
1152+
since: unread.since.stamp,
1153+
unreal_stamps: unread.since.unreal.map((u) => u.stamp),
1154+
newer: unread.newer.length,
1155+
ack_through: unread.ackThrough,
1156+
},
1157+
}
10751158
: {}),
10761159
},
10771160
null,
@@ -1112,7 +1195,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
11121195
'the substitution: one clock, read once, written everywhere': 9,
11131196
'the read-back: what the transcript can actually prove': 11,
11141197
'the CLI: the one decision a typo must never make': 16,
1115-
'the unread-knock check: a refresh cannot void what nobody read': 23,
1198+
'the unread-knock check: a refresh cannot void what nobody read': 41,
11161199
'the shared rule: this tool and H56 cannot come to disagree': 6,
11171200
});
11181201
const SELF_TEST_BATTERY_FLOOR = 10;
@@ -1386,6 +1469,37 @@ export function selfTest() {
13861469
t('…and one at the last second of the minute before does not', unreadComments({ storedBody: SEAT_BODY, comments: [{ id: 1, created_at: '2026-09-12T09:59:59Z' }] }).ok === true);
13871470
t('the newest is by created_at, not by input order', unreadComments({ storedBody: SEAT_BODY, comments: [LATER, KNOCK] }).newest.id === 5648793698);
13881471
t('a comment with an unreadable created_at is newer, never silently old', unreadComments({ storedBody: SEAT_BODY, comments: [{ id: 7, created_at: 'n/a' }] }).ok === false);
1472+
// The post-stamped reading: a STORED body whose newest stamp names no instant
1473+
// the calendar has. `Date.parse` rolls 31 April FORWARD to 1 May, so reading
1474+
// it as the last write moved the window LATER and a knock inside the gap read
1475+
// as "none-newer" — the derivation shrank, which the header forbids. The
1476+
// write side refuses such a stamp; a stored body can still carry one by hand,
1477+
// through the platform's own editor, or from before this tool existed.
1478+
const ROLLED_BODY = '**Seat post.**\n\n🟢 seat · 自 2026-04-31T00:00Z 就座。\n';
1479+
const NO_PARSE_BODY = '**Seat post.**\n\n🟢 seat · 自 2026-13-45T99:99Z 就座。\n';
1480+
const ROLLED_OVER_REAL_BODY = '**Seat post.**\n\n🟢 seat · 自 2026-04-31T00:00Z 就座;前任 2026-04-29T09:00Z 离任。\n';
1481+
const GAP_KNOCK = { id: 5678039238, created_at: '2026-04-30T12:00:00Z', user: { login: 'engine-seat' }, body: '敲门:a knock inside the rollover gap.' };
1482+
const rolled = unreadComments({ storedBody: ROLLED_BODY, comments: [GAP_KNOCK] });
1483+
t('⭐ THE FILED READING: a knock inside a rolled-over stamp\'s gap is NOT "none-newer"', rolled.kind !== 'none-newer', `kind=${rolled.kind}`);
1484+
t('…it is refused as unacknowledged — the window may only widen', rolled.ok === false && rolled.kind === 'unacknowledged');
1485+
t('…because the rolled-over stamp is not read as the last write at all', rolled.since.stamp === null && rolled.since.from === null);
1486+
t('…and the stamp it would not read is named, with the date it actually rolls to', rolled.since.unreal.length === 1 && rolled.since.unreal[0].stamp === '2026-04-31T00:00Z' && rolled.since.unreal[0].rolledTo === '2026-05-01T00:00Z');
1487+
t('…the refusal says THAT, rather than claiming the body carries no stamp', unreadRefusalText(rolled, 18293).includes('2026-04-31T00:00Z') && unreadRefusalText(rolled, 18293).includes('names an instant the calendar HAS'));
1488+
t('…and still points at the newest comment as the flag to pass', unreadRefusalText(rolled, 18293).includes('--ack-through=5678039238'));
1489+
const noParse = unreadComments({ storedBody: NO_PARSE_BODY, comments: [GAP_KNOCK] });
1490+
t('⭐ a stamp that does not parse AT ALL reaches the same outcome — one rule, not two accidents', noParse.kind === 'unacknowledged' && noParse.since.stamp === null);
1491+
t('…and reports no rollover it did not have', noParse.since.unreal.length === 1 && noParse.since.unreal[0].rolledTo === null);
1492+
const fellBack = unreadComments({ storedBody: ROLLED_OVER_REAL_BODY, comments: [GAP_KNOCK] });
1493+
t('⭐ with an older REAL stamp beside it the window measures from THAT one — earlier, so wider', fellBack.since.stamp === '2026-04-29T09:00Z');
1494+
t('…so the same knock still counts as newer', fellBack.ok === false && fellBack.after.length === 1 && fellBack.after[0].id === 5678039238);
1495+
t('…and the refusal says the window is WIDER here, never narrower', unreadRefusalText(fellBack, 18293).includes('WIDER here, never') && unreadRefusalText(fellBack, 18293).includes('2026-04-31T00:00Z'));
1496+
t('⭐ --ack-through naming the newest comment still clears a body with no readable stamp', unreadComments({ storedBody: ROLLED_BODY, comments: [GAP_KNOCK], ackThrough: 5678039238 }).kind === 'acknowledged');
1497+
t('⭐ THE ACCEPTANCE CONTROL: nothing newer ⇒ the refresh is still written, unreal stamp or not', unreadComments({ storedBody: ROLLED_BODY, comments: [] }).ok === true);
1498+
t('…and the pass line names the stamp the derivation did not read', unreadPassText(unreadComments({ storedBody: ROLLED_BODY, comments: [] })).includes('2026-04-31T00:00Z'));
1499+
t('⭐ THE CONTROL, UNCHANGED: a body whose stamps are all real is judged exactly as before', control.ok === true && control.kind === 'none-newer' && control.since.stamp === '2026-09-12T10:00Z' && control.since.unreal.length === 0);
1500+
t('…and its pass line carries no skipped-stamp clause at all', unreadPassText(control).includes('NOT read as the last write') === false);
1501+
t('a body with NO stamp stays distinguishable from one with only unreal stamps', lastWriteStamp('nothing stamped').unreal.length === 0 && lastWriteStamp(ROLLED_BODY).unreal.length === 1);
1502+
t('lastWriteStamp always answers an object, so no caller can read a null as "no stamp problem"', lastWriteStamp('nothing stamped').stamp === null && Array.isArray(lastWriteStamp('nothing stamped').unreal));
13891503
t('the pass line names what was measured against', unreadPassText(control).includes('2026-09-12T10:00Z'));
13901504

13911505
battery('the shared rule: this tool and H56 cannot come to disagree');

0 commit comments

Comments
 (0)