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" diff --git a/bin/transcripts.mjs b/bin/transcripts.mjs index f1a0aa3..4133c4b 100644 --- a/bin/transcripts.mjs +++ b/bin/transcripts.mjs @@ -180,19 +180,30 @@ 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 }) { - 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 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`); + 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); + 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 d2b7bc2..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 = [ @@ -177,6 +188,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/],