Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-15
52 changes: 52 additions & 0 deletions openspec/changes/fix-cli-local-date-semantics/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## Context

The CLI currently creates two user-visible date-only values by truncating `Date#toISOString()`: archive directory prefixes and the `created` field in newly scaffolded `.openspec.yaml` files. ISO serialization is UTC, so either value can disagree with the calendar date in the effective local time zone of the Node.js process running the CLI.

The repository supports Node.js 20.19+ on Windows, macOS, and Linux. The selected contract is the calendar date in the executing Node.js process's effective local time zone, rather than a project-wide or UTC time zone. "Effective local time zone" means the time zone used by Node.js local `Date` accessors, normally derived from the host environment and any runtime-supported process time-zone configuration.

## Goals / Non-Goals

**Goals:**

- Produce date-only archive prefixes and new-change metadata from the executing CLI process's effective local calendar date.
- Keep the date representation stable as zero-padded `YYYY-MM-DD` on every supported platform.
- Cover a UTC/local-calendar boundary with deterministic tests.

**Non-Goals:**

- Rename or migrate existing archive directories or existing change metadata.
- Add a project time-zone setting, CLI flag, or user-selectable time zone.
- Change full UTC timestamps used for logs, JSON timestamps, feedback metadata, or backup identifiers.
- Alter agent-generated date prefixes in OPSX archive workflows, which do not derive their dates through `Date#toISOString()`.

## Decisions

### Use a shared local calendar-date formatter

Introduce one small shared formatter for date-only values. It will derive year, month, and day with local `Date` accessors and zero-pad the numeric parts into `YYYY-MM-DD`. It will accept a `Date` value (defaulting to the current time) so callers share the same behavior and tests can provide a fixed instant.

Both archive naming and change creation will call this formatter. This prevents the two date-only concepts from diverging again while keeping the existing archive and metadata APIs unchanged.

`toISOString().split('T')[0]` is not suitable because it deliberately selects the UTC calendar date. Locale-formatted strings are also unsuitable as a storage and path contract because their separators and ordering are locale-dependent.

### Bind the rule to the executing CLI process's effective local time zone

The formatter will use the local time zone effective for the Node.js process. This matches the user-visible meaning of "today" for an interactive CLI session and gives scripts deterministic behavior when the process time zone is configured. Processes in different time zones may produce different dates for the same instant near a boundary; that is intentional under the selected contract.

### Test the boundary through the process time zone

Tests will temporarily set the Node process time zone to `Asia/Shanghai` and use a fixed instant such as `2026-07-14T16:30:00.000Z`. At that instant the local date is `2026-07-15` while the UTC date is `2026-07-14`, so the test fails if UTC truncation returns. The test setup will restore time and environment state after each case.

## Risks / Trade-offs

- [Different processes can choose different dates at the same instant] → This is the explicit effective-local-time-zone contract and is covered by the affected behavior.
- [Date formatting is accidentally made locale-sensitive] → Use numeric local `Date` parts rather than locale display formatting.
- [Existing historical names retain UTC-derived dates] → Apply the new rule prospectively and leave existing directories and metadata untouched.

## Migration Plan

No data migration is required. New archives and newly created changes use the local-date rule after release; existing archives and metadata remain valid as-is.

## Open Questions

None.
27 changes: 27 additions & 0 deletions openspec/changes/fix-cli-local-date-semantics/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Why

Two CLI code paths currently derive date-only values by truncating a UTC ISO timestamp: archive directory prefixes and the `created` field in newly scaffolded change metadata. Near a local midnight boundary, these values can resolve to the previous or next calendar date instead of the date in the CLI process's effective local time zone.

## What Changes

- Define CLI-generated date-only values as the calendar date in the effective local time zone of the Node.js process executing the CLI, formatted as `YYYY-MM-DD`.
- Generate CLI archive directory names from that local date.
- Record the same local date in the `created` field of newly created change metadata.
- Add regression coverage for a non-UTC local-date boundary.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli-archive`: archive target names use the CLI process's effective local calendar date.
- `change-creation`: newly created change metadata records the CLI process's effective local calendar date.

## Impact

- Affected code: archive naming, change-creation metadata, and a shared date-only formatter.
- Affected tests: archive and change-creation coverage.
- Existing archive directories remain unchanged; the rule applies to newly generated names and metadata only.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## ADDED Requirements

### Requirement: Local Creation Date Metadata

The system SHALL record the `created` value in metadata for a newly created change as the `YYYY-MM-DD` calendar date in the effective local time zone of the Node.js process executing the CLI.

#### Scenario: Create change across a UTC date boundary

- **GIVEN** the CLI process's effective local time zone is `Asia/Shanghai`
- **AND** the current instant is `2026-07-14T16:30:00.000Z`
- **WHEN** the user creates a change
- **THEN** the new change's `.openspec.yaml` contains `created: 2026-07-15`
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## ADDED Requirements

### Requirement: Local Archive Date

The archive command SHALL derive the `YYYY-MM-DD` prefix of a new archive target from the calendar date in the effective local time zone of the Node.js process executing the CLI.

#### Scenario: Archive crosses a UTC date boundary

- **GIVEN** the CLI process's effective local time zone is `Asia/Shanghai`
- **AND** the current instant is `2026-07-14T16:30:00.000Z`
- **WHEN** the user archives a change named `add-auth`
- **THEN** the target archive name begins with `2026-07-15-add-auth`

#### Scenario: Non-interactive archive uses the local date

- **WHEN** an automation invokes `openspec archive <change-name> --yes`
- **THEN** the target archive name uses the CLI process's effective local calendar date
13 changes: 13 additions & 0 deletions openspec/changes/fix-cli-local-date-semantics/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## 1. Local date behavior

- [x] 1.1 Add a shared formatter that returns the calendar date in the executing Node.js process's effective local time zone as `YYYY-MM-DD`.
- [x] 1.2 Use the shared formatter for native archive target names.
- [x] 1.3 Use the shared formatter when writing `created` metadata for a new change.

## 2. Regression coverage and validation

- [x] 2.1 Add archive and change-creation tests for a fixed `Asia/Shanghai` UTC-boundary instant, restoring clock and environment state afterward.
- [x] 2.2 Update affected archive test expectations to use the effective-local-date contract.
- [x] 2.3 Add archive and change-creation tests for a non-boundary instant where UTC and local calendar dates match.
- [x] 2.4 Run focused archive and change-creation tests on the supported cross-platform test suite.
- [x] 2.5 Run the full build and OpenSpec validation for the change.
8 changes: 2 additions & 6 deletions src/core/archive.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { promises as fs } from 'fs';
import path from 'path';
import { formatLocalDate } from '../utils/date.js';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { Validator } from './validation/validator.js';
import chalk from 'chalk';
Expand Down Expand Up @@ -482,7 +483,7 @@ export class ArchiveCommand {
}

// Create archive directory with date prefix
const archiveName = `${this.getArchiveDate()}-${changeName}`;
const archiveName = `${formatLocalDate()}-${changeName}`;
const archivePath = path.join(archiveDir, archiveName);

// Check if archive already exists
Expand Down Expand Up @@ -557,9 +558,4 @@ export class ArchiveCommand {
return null;
}
}

private getArchiveDate(): string {
// Returns date in YYYY-MM-DD format
return new Date().toISOString().split('T')[0];
}
}
4 changes: 2 additions & 2 deletions src/utils/change-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from 'path';
import { FileSystemUtils } from './file-system.js';
import { writeChangeMetadata, validateSchemaName } from './change-metadata.js';
import { formatLocalDate } from './date.js';
import { readProjectConfig } from '../core/project-config.js';
import type { ChangeMetadata } from '../core/change-metadata/index.js';

Expand Down Expand Up @@ -179,10 +180,9 @@ export async function createChange(
}

// Write metadata file with schema and creation date
const today = new Date().toISOString().split('T')[0];
writeChangeMetadata(changeDir, {
schema: schemaName,
created: today,
created: formatLocalDate(),
...options.metadata,
}, projectRoot);

Expand Down
13 changes: 13 additions & 0 deletions src/utils/date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Formats a date using the effective local time zone of the Node.js process.
*
* The result is locale-independent and suitable for date-only metadata and
* path prefixes.
*/
export function formatLocalDate(date: Date = new Date()): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');

return `${year}-${month}-${day}`;
}
44 changes: 43 additions & 1 deletion test/core/archive.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ArchiveCommand } from '../../src/core/archive.js';
import { Validator } from '../../src/core/validation/validator.js';
import { formatLocalDate } from '../../src/utils/date.js';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
Expand All @@ -17,6 +18,7 @@ describe('ArchiveCommand', () => {
const originalConsoleLog = console.log;
const originalExitCode = process.exitCode;
const originalXdgDataHome = process.env.XDG_DATA_HOME;
const originalTimeZone = process.env.TZ;

beforeEach(async () => {
// Create temp directory
Expand Down Expand Up @@ -47,6 +49,8 @@ describe('ArchiveCommand', () => {
});

afterEach(async () => {
vi.useRealTimers();

// Restore console.log
console.log = originalConsoleLog;

Expand All @@ -59,6 +63,12 @@ describe('ArchiveCommand', () => {
process.env.XDG_DATA_HOME = originalXdgDataHome;
}

if (originalTimeZone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTimeZone;
}

// Clear mocks
vi.clearAllMocks();

Expand Down Expand Up @@ -95,6 +105,38 @@ describe('ArchiveCommand', () => {
await expect(fs.access(changeDir)).rejects.toThrow();
});

it('should use the process local date across a UTC date boundary', async () => {
process.env.TZ = 'Asia/Shanghai';
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-14T16:30:00.000Z'));

const changeName = 'local-date-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n');

await archiveCommand.execute(changeName, { yes: true, noValidate: true, skipSpecs: true });

const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
await expect(fs.readdir(archiveDir)).resolves.toEqual([`2026-07-15-${changeName}`]);
});

it('should preserve the date when UTC and local calendar dates match', async () => {
process.env.TZ = 'Asia/Shanghai';
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-05T04:30:00.000Z'));

const changeName = 'same-date-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n');

await archiveCommand.execute(changeName, { yes: true, noValidate: true, skipSpecs: true });

const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
await expect(fs.readdir(archiveDir)).resolves.toEqual([`2026-01-05-${changeName}`]);
});

it('should warn about incomplete tasks', async () => {
const changeName = 'incomplete-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
Expand Down Expand Up @@ -441,7 +483,7 @@ New feature description.
await fs.mkdir(changeDir, { recursive: true });

// Create existing archive with same date
const date = new Date().toISOString().split('T')[0];
const date = formatLocalDate();
const archivePath = path.join(tempDir, 'openspec', 'changes', 'archive', `${date}-${changeName}`);
await fs.mkdir(archivePath, { recursive: true });

Expand Down
33 changes: 32 additions & 1 deletion test/utils/change-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
Expand Down Expand Up @@ -110,13 +110,20 @@ describe('validateChangeName', () => {

describe('createChange', () => {
let testDir: string;
const originalTimeZone = process.env.TZ;

beforeEach(async () => {
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
await fs.mkdir(testDir, { recursive: true });
});

afterEach(async () => {
vi.useRealTimers();
if (originalTimeZone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTimeZone;
}
await fs.rm(testDir, { recursive: true, force: true });
});

Expand All @@ -138,6 +145,30 @@ describe('createChange', () => {
expect(content).toMatch(/created: \d{4}-\d{2}-\d{2}/);
});

it('should use the process local date across a UTC date boundary', async () => {
process.env.TZ = 'Asia/Shanghai';
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-14T16:30:00.000Z'));

await createChange(testDir, 'local-date-change');

const metaPath = path.join(testDir, 'openspec', 'changes', 'local-date-change', '.openspec.yaml');
const content = await fs.readFile(metaPath, 'utf-8');
expect(content).toContain('created: 2026-07-15');
});

it('should preserve the date when UTC and local calendar dates match', async () => {
process.env.TZ = 'Asia/Shanghai';
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-05T04:30:00.000Z'));

await createChange(testDir, 'same-date-change');

const metaPath = path.join(testDir, 'openspec', 'changes', 'same-date-change', '.openspec.yaml');
const content = await fs.readFile(metaPath, 'utf-8');
expect(content).toContain('created: 2026-01-05');
});

it('should create .openspec.yaml with custom schema', async () => {
await createChange(testDir, 'add-auth', { schema: 'spec-driven' });

Expand Down
Loading