fix(#2136): route operator-facing date fields through local clock day
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 <date>' (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
This commit is contained in:
@@ -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 <date>", 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
|
||||
|
||||
@@ -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<string, unknown> | null : null;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -255,7 +255,7 @@ export type ProgressRecord = Record<string, unknown>;
|
||||
|
||||
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[] = [];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user