From 71edb27fe15d53dbe53cff1acb94510abdc50837 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 12 Jul 2026 12:10:27 -0400 Subject: [PATCH] fix(#2136): route operator-facing date fields through local clock day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clock.today() derived the calendar day by slicing a UTC ISO instant (nowIso().split('T')[0]), so in any negative-UTC-offset zone during UTC's early hours last_activity named a day the operator had not reached yet — ahead of last_updated's local date, written by the same call. - Add Clock.localToday(): host-local YYYY-MM-DD via getMonth/getDate/ getFullYear, honoring the same GSD_NOW_MS pin as today()/nowIso(). - Route operator-facing date-only fields through localToday(): last_activity (state-transition ×7, state.cts), roadmap 'completed ' (phase.cts, roadmap.cts), milestone completion date (milestone.cts), todo/scaffold completion (commands.cts — now threaded through the realClock seam). - Leave today() (UTC) as the source for internal/cosmetic stamps. Closes #2136 --- src/clock.cts | 22 ++++++++++++++++++++++ src/commands.cts | 5 +++-- src/milestone.cts | 2 +- src/phase.cts | 2 +- src/roadmap.cts | 2 +- src/state-transition.cts | 16 ++++++++-------- src/state.cts | 2 +- 7 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/clock.cts b/src/clock.cts index 60ef55d71..6127b7b22 100644 --- a/src/clock.cts +++ b/src/clock.cts @@ -20,6 +20,8 @@ export interface Clock { now(): number; nowIso(): string; today(): string; + /** Host-local calendar day as YYYY-MM-DD — for operator-facing date-only fields. */ + localToday(): string; sleep(ms: number): void; } @@ -87,6 +89,26 @@ export const realClock: Clock = { return this.nowIso().split('T')[0]; }, + /** + * Return today's date as a YYYY-MM-DD string in the HOST-LOCAL calendar day. + * Uses this.now() so the subprocess time-pin adapter (GSD_NOW_MS) is honoured + * exactly as today()/nowIso() are — deterministic when GSD_NOW_MS and TZ are + * both pinned (#2136). + * + * Operator-facing date-only fields (last_activity, "completed ", etc.) + * must use the local calendar day: an operator reads them as "the day I did + * this", and they must never name a day ahead of `last_updated`'s local date. + * `today()` (UTC) stays the source for internal/cosmetic stamps. + * + * @returns e.g. "2020-06-14" (local), which may differ from today() near UTC midnight + */ + localToday(): string { + const d = new Date(this.now()); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return d.getFullYear() + '-' + mm + '-' + dd; + }, + /** * Synchronous sleep via Atomics.wait. * This is the identical primitive acquireStateLock and withPlanningLock used diff --git a/src/commands.cts b/src/commands.cts index 9ffd6522c..c1d38cdf8 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -42,6 +42,7 @@ const { extractFrontmatter } = frontmatter; import modelProfiles = require('./model-profiles.cjs'); const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles; import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; +import { realClock } from './clock.cjs'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -1418,7 +1419,7 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, raw: boolean // Read, add completion timestamp, move let content = fs.readFileSync(sourcePath, 'utf-8'); - const today = new Date().toISOString().split('T')[0]; + const today = realClock.localToday(); content = `completed: ${today}\n` + content; platformWriteSync(path.join(completedDir, filename as string), content); @@ -1430,7 +1431,7 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, raw: boolean function cmdScaffold(cwd: string, type: string, options: ScaffoldOptions, raw: boolean): void { const { phase, name } = options; const padded = phase ? normalizePhaseName(phase) : '00'; - const today = new Date().toISOString().split('T')[0]; + const today = realClock.localToday(); // Find phase directory const phaseInfo = phase ? findPhaseInternal(cwd, phase) as Record | null : null; diff --git a/src/milestone.cts b/src/milestone.cts index f6f3386e6..f1f53d331 100644 --- a/src/milestone.cts +++ b/src/milestone.cts @@ -138,7 +138,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo const milestonesPath = path.join(planningBase, 'MILESTONES.md'); const archiveDir = path.join(planningBase, 'milestones'); const phasesDir = planningPaths(cwd).phases; - const today = new Date().toISOString().split('T')[0]; + const today = realClock.localToday(); const milestoneName = options.name || version; // Ensure archive directory exists diff --git a/src/phase.cts b/src/phase.cts index 78de4bfae..8bdc40cae 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -1401,7 +1401,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void { const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); const statePath = path.join(planningDir(cwd), 'STATE.md'); const phasesDir = path.join(planningDir(cwd), 'phases'); - const today = realClock.today(); + const today = realClock.localToday(); const phaseInfoRaw = findPhaseInternal(cwd, phaseNum); if (!phaseInfoRaw) { diff --git a/src/roadmap.cts b/src/roadmap.cts index 427c44365..e8ef6c967 100644 --- a/src/roadmap.cts +++ b/src/roadmap.cts @@ -497,7 +497,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und const verificationPassed = readVerificationStatus(phaseDir).status === 'passed'; const isComplete = summaryCount >= planCount && verificationPassed; const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned'; - const today = realClock.today(); + const today = realClock.localToday(); if (!fs.existsSync(roadmapPath)) { output({ updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap'); diff --git a/src/state-transition.cts b/src/state-transition.cts index ca93e82aa..7b0226904 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -255,7 +255,7 @@ export type ProgressRecord = Record; export type StateTransitionDeps = { progressProvider: () => ProgressRecord | null; - clock: { today: () => string; nowIso: () => string }; + clock: { today: () => string; localToday: () => string; nowIso: () => string }; /** * Roadmap content provider for transitions that re-derive milestone-wide * progress from ROADMAP.md (completePhase). Optional: transitions that don't @@ -418,7 +418,7 @@ function beginPhaseCore( ? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}` : b; - const today = deps.clock.today(); + const today = deps.clock.localToday(); // Consult the field-classification table for the frontmatter keys this // transition touches (codex Phase 1 review: "table not consulted by @@ -721,7 +721,7 @@ function mutateCurrentPositionForAdvance( * adapter to construct CLI output. */ function advancePlanCore(content: string, deps: StateTransitionDeps): StateTransitionResult { - const today = deps.clock.today(); + const today = deps.clock.localToday(); // #1255: body-field replacements operate on body only (frontmatter stripped), // not on the full content. The YAML `status:` key matches `^Status:\s*` @@ -849,7 +849,7 @@ function completePhaseCore( deps: StateTransitionDeps, ): StateTransitionResult { const updated: string[] = []; - const today = deps.clock.today(); + const today = deps.clock.localToday(); // Consult the field-classification table for the frontmatter keys this // transition touches (same guard beginPhaseCore applies). A missing row is a @@ -1020,7 +1020,7 @@ function plannedPhaseCore( deps: StateTransitionDeps, ): StateTransitionResult { const updated: string[] = []; - const today = deps.clock.today(); + const today = deps.clock.localToday(); for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) { const cls = getFieldClassification(fmKey); @@ -1124,7 +1124,7 @@ function milestoneSwitchCore( intent: { kind: 'milestoneSwitch'; version: string; name: string }, deps: StateTransitionDeps, ): StateTransitionResult { - const today = deps.clock.today(); + const today = deps.clock.localToday(); const updated: string[] = [ 'milestone', 'milestone_name', @@ -1222,7 +1222,7 @@ function milestoneCompleteCore( deps: StateTransitionDeps, ): StateTransitionResult { const updated: string[] = []; - const today = deps.clock.today(); + const today = deps.clock.localToday(); const version = intent.version; for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) { @@ -1520,7 +1520,7 @@ function syncCore( intent: { kind: 'sync'; totalPlansInPhase: number | null; percent: number | null }, deps: StateTransitionDeps, ): StateTransitionResult { - const today = deps.clock.today(); + const today = deps.clock.localToday(); const changes: string[] = []; let modified = content; const updated: string[] = []; diff --git a/src/state.cts b/src/state.cts index b28faeb40..cdd018080 100644 --- a/src/state.cts +++ b/src/state.cts @@ -2769,7 +2769,7 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string return; } - const today = realClock.today(); + const today = realClock.localToday(); const updated: string[] = []; readModifyWriteStateMd(statePath, (content) => {