From 28d239006421cb54cc5c5638fd5906a99734a98d Mon Sep 17 00:00:00 2001 From: Martin <60476606+mtmtian@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:59:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(history):=20ISO=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E4=B9=9F=E5=85=88=E6=A0=A1=E9=AA=8C=E6=97=A5=E5=8E=86=E6=97=A5?= =?UTF-8?q?=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date.parse 会把超出当月的日数顺延到下个月,例如 --until 2026-02-30T23:00:00Z 被当成 3 月 2 日,时间窗被悄悄放宽。 现在纯日期和 ISO 时间都先校验写出的年月日,不存在的日期报 "is not a calendar date";小时、时区等其余部分仍交给 Date.parse。 Co-Authored-By: Claude Opus 5.5 --- bin/transcripts.mjs | 21 +++++++++++---------- scripts/transcripts.test.mjs | 2 ++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/bin/transcripts.mjs b/bin/transcripts.mjs index f1a0aa3..d43cd86 100644 --- a/bin/transcripts.mjs +++ b/bin/transcripts.mjs @@ -181,18 +181,19 @@ function listTranscripts({ host, cwds, since, until }) { } // A bare date is a local calendar day: --since starts at its midnight and --until includes the whole day. +// Date.parse rolls a date-time's day past the month's end into the next month, so both forms check the date first. function parseTime(value, flag, { endOfDay }) { - const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); - if (day) { - const [year, month, date] = day.slice(1).map(Number); - const start = new Date(year, month - 1, date); - if (start.getFullYear() !== year || start.getMonth() !== month - 1 || start.getDate() !== date) { - throw new Error(`${flag} ${value} is not a calendar date`); - } - return endOfDay ? new Date(year, month - 1, date + 1).getTime() - 1 : start.getTime(); + const malformed = `${flag} must be YYYY-MM-DD or an ISO 8601 date-time`; + const day = /^(\d{4})-(\d{2})-(\d{2})(T.*)?$/.exec(value); + if (!day) throw new Error(malformed); + const [year, month, date] = day.slice(1, 4).map(Number); + const start = new Date(year, month - 1, date); + if (start.getFullYear() !== year || start.getMonth() !== month - 1 || start.getDate() !== date) { + throw new Error(`${flag} ${value} is not a calendar date`); } - const time = /^\d{4}-\d{2}-\d{2}T/.test(value) ? Date.parse(value) : Number.NaN; - if (Number.isNaN(time)) throw new Error(`${flag} must be YYYY-MM-DD or an ISO 8601 date-time`); + if (day[4] === undefined) return endOfDay ? new Date(year, month - 1, date + 1).getTime() - 1 : start.getTime(); + const time = Date.parse(value); + if (Number.isNaN(time)) throw new Error(malformed); return time; } diff --git a/scripts/transcripts.test.mjs b/scripts/transcripts.test.mjs index d2b7bc2..2647b36 100644 --- a/scripts/transcripts.test.mjs +++ b/scripts/transcripts.test.mjs @@ -177,6 +177,8 @@ test("Given invalid arguments, When the CLI runs, Then it exits 1 with the reaso [["--host", "cursor"], /host must be one of claude or codex/], [["--host", "claude", "--since", "last week"], /--since must be YYYY-MM-DD or an ISO 8601 date-time/], [["--host", "claude", "--until", "2026-02-30"], /--until 2026-02-30 is not a calendar date/], + [["--host", "claude", "--until", "2026-02-30T23:00:00Z"], /--until 2026-02-30T23:00:00Z is not a calendar date/], + [["--host", "claude", "--since", "2026-04-31T00:00"], /--since 2026-04-31T00:00 is not a calendar date/], [["--host", "claude", "--since", "2026-10-10", "--until", "2026-10-01"], /since must not be later than until/], [["--host", "claude", "--host", "codex"], /may only be provided once/], [["--host", "codex", "--cwd"], /--cwd requires a value/], From f50147c9194d74428130f23b0708c6a472b0f013 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mtmtian@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:59:02 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore(release):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8D=87=E5=88=B0=200.15.25-shared.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发布 PR #15 新增的 bin/transcripts.mjs 与本次日期校验修复,两个 manifest 按 docs/guide/01-setup.md 的要求一起升级。 Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b53b413..bffe736 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pstack", - "version": "0.15.25-shared.5", + "version": "0.15.25-shared.6", "description": "Shared pstack engineering workflows for Claude Code and Codex, with one configuration and host-specific runtime adapters.", "author": { "name": "Local adaptation; upstream Lauren Tan" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 46ae614..990901f 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pstack-codex", - "version": "0.15.25-shared.5", + "version": "0.15.25-shared.6", "description": "Shared pstack engineering workflows for Codex and Claude Code, with one configuration and native host adapters.", "author": { "name": "Lauren Tan; local Codex adaptation" From b67d358f1b18b27272522b3d2817cf08ab8609d1 Mon Sep 17 00:00:00 2001 From: Martin <60476606+mtmtian@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:07:58 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(history):=20=E6=97=A5=E5=8E=86=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=97=B6=E5=8C=BA=E4=B8=8E=20Date=20=E6=9E=84=E9=80=A0?= =?UTF-8?q?=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交用本地 new Date(y, m, d) 校验日期,ISO 时间因此被两类情况误拒: 0000–0099 年会被构造函数映射到 1900 年代,本地日历跳过的日子(如 Pacific/Apia 的 2011-12-30)对 UTC 时间也判无效。改为按 Date.parse 所用 的前推格里历做纯算术校验;纯日期的本地零点改用 setFullYear 构造,0050-01-01 这类纯日期也不再被误拒。四个时区下 24 组常规输入与 main 结果一致。 Co-Authored-By: Claude Opus 5.5 --- bin/transcripts.mjs | 20 +++++++++++++++----- scripts/transcripts.test.mjs | 11 +++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/bin/transcripts.mjs b/bin/transcripts.mjs index d43cd86..4133c4b 100644 --- a/bin/transcripts.mjs +++ b/bin/transcripts.mjs @@ -180,6 +180,19 @@ function listTranscripts({ host, cwds, since, until }) { }; } +// The proleptic Gregorian calendar Date.parse uses, independent of the local time zone. +function isCalendarDate(year, month, date) { + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + return month >= 1 && month <= 12 && date >= 1 && date <= [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]; +} + +// setFullYear, unlike the Date constructor, does not map years 0 to 99 onto 1900 to 1999. +function localMidnight(year, monthIndex, date) { + const day = new Date(0); + day.setFullYear(year, monthIndex, date); + return day.setHours(0, 0, 0, 0); +} + // A bare date is a local calendar day: --since starts at its midnight and --until includes the whole day. // Date.parse rolls a date-time's day past the month's end into the next month, so both forms check the date first. function parseTime(value, flag, { endOfDay }) { @@ -187,11 +200,8 @@ function parseTime(value, flag, { endOfDay }) { const day = /^(\d{4})-(\d{2})-(\d{2})(T.*)?$/.exec(value); if (!day) throw new Error(malformed); const [year, month, date] = day.slice(1, 4).map(Number); - const start = new Date(year, month - 1, date); - if (start.getFullYear() !== year || start.getMonth() !== month - 1 || start.getDate() !== date) { - throw new Error(`${flag} ${value} is not a calendar date`); - } - if (day[4] === undefined) return endOfDay ? new Date(year, month - 1, date + 1).getTime() - 1 : start.getTime(); + if (!isCalendarDate(year, month, date)) throw new Error(`${flag} ${value} is not a calendar date`); + if (day[4] === undefined) return endOfDay ? localMidnight(year, month - 1, date + 1) - 1 : localMidnight(year, month - 1, date); const time = Date.parse(value); if (Number.isNaN(time)) throw new Error(malformed); return time; diff --git a/scripts/transcripts.test.mjs b/scripts/transcripts.test.mjs index 2647b36..cd3196e 100644 --- a/scripts/transcripts.test.mjs +++ b/scripts/transcripts.test.mjs @@ -170,6 +170,17 @@ test("Given relocated host homes and several checkouts, When listed, Then CLAUDE assert.deepEqual(paths(list(home, ["--host", "codex", "--cwd", "/work/repo"], env)), [rollout]); }); +test("Given bounds in years 0 to 99 or on a day the local calendar skipped, When the CLI parses them, Then it keeps the dates as written", (t) => { + const home = tempHome(t); + const ancient = list(home, ["--host", "claude", "--since", "0000-01-01T00:00:00Z", "--until", "0000-01-02T00:00:00Z"], { TZ: "UTC" }); + assert.equal(ancient.since, "0000-01-01T00:00:00.000Z"); + const bare = list(home, ["--host", "claude", "--since", "0050-01-01", "--until", "0050-01-01"], { TZ: "UTC" }); + assert.deepEqual([bare.since, bare.until], ["0050-01-01T00:00:00.000Z", "0050-01-01T23:59:59.999Z"]); + // Samoa skipped 2011-12-30 locally, but that UTC date still exists. + const skipped = list(home, ["--host", "claude", "--since", "2011-12-30T12:00:00Z", "--until", "2011-12-31T00:00:00Z"], { TZ: "Pacific/Apia" }); + assert.equal(skipped.since, "2011-12-30T12:00:00.000Z"); +}); + test("Given invalid arguments, When the CLI runs, Then it exits 1 with the reason", (t) => { const home = tempHome(t); const cases = [