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
41 changes: 41 additions & 0 deletions .changeset/a-date-is-one-day-in-every-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@openspec-ui/core": minor
"@openspec-ui/server": minor
"@openspec-ui/webui": minor
"openspec-ui-vscode": minor
---

A change's date now carries the day its own record names, alongside the
instant. A commit records its offset and `git blame`'s porcelain output
records it too; the day is read from that string before any
normalisation, so an archive committed at `02:30 +03:00` is that day
rather than the previous one in UTC. The instant is still normalised, so
ordering and lead times are unchanged. The archived date shown in the
sprint report, the timeline and the per-day chart is that day.

The audit log reaches both hosts. `getChangeTimelines` takes the
timestamps of the runs recorded against each change, and the server
route and the extension's timeline command each read the log once per
request and hand them down — so work that began with a run before anyone
ticked a task is dated from that run in a workspace, not only in a test.

A date that cannot be read is now absent and says so. One folder named
`2026-13-01-something` used to throw out of the whole multi-change
request, taking every other change's dates with it.

The one-call archive read no longer mistakes a path for a date. It told
them apart by a leading `C`, and `--name-only` prints paths relative to
the repository root, so any workspace under a directory beginning with
`C` fell back to a git call per change without saying so. It reports the
lines it could not read, and the chart's basis line says when there were
any.

The chart arithmetic moved from `webui` into core, exported through
`@openspec-ui/core/browser`, so a host showing the same figures in
another form draws them from the same functions. The lead-time buckets
are named by their boundaries — "Under a day", "1–2 days", "2–3 days",
"3–8 days", "8 days or more" — rather than by "Same day", which a
twenty-four-hour floor does not mean. The sentence explaining the
work-duration chart that is deliberately not drawn is computed from the
changes on screen; it used to state "measured over this repository, 135
of 185 changes…" in every workspace.
Binary file modified docs/images/standalone/change-charts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/standalone/harness-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 22 additions & 6 deletions openspec/changes/a-date-is-one-day-in-every-source/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,29 @@

## Decision: a day is the day where the action happened

A commit records its own offset (`%cI`); the day is taken from that
A commit records its own offset (`%aI`); the day is taken from that
string before any normalisation, so a commit made at 02:30 in Moscow is
the 27th, as the person who made it would say. A folder name is already
that day. An audit timestamp is written by this application in the
machine's local time and carries an offset too. Every source therefore
yields the same day for the same action, and the chart's per-day buckets
mean "the day someone did it" rather than "the UTC day".
the 27th, as the person who made it would say. `git blame`'s porcelain
output carries the same thing as `author-time` plus `author-tz`, and the
two are read together. A folder name is already a day. Every source
therefore yields the day its own record names, and the chart's per-day
buckets mean "the day someone did it" rather than "the UTC day".

Two corrections to what this said when it was written, found while
implementing it:

- **An audit timestamp carries no offset.** Every writer of one calls
`new Date().toISOString()`, so it is UTC with a `Z`, and the day read
from it is the UTC day. That is the day its record names, which is
the rule above; giving those timestamps an offset would be a change
to what is written, not to how it is read, and it is not made here.
- **The commit and the folder name are not always the same action.**
`openspec archive` names the folder with the local day the command
ran and the commit lands whenever it lands. Two of this repository's
archives were committed at `00:00:24 +03:00`, twenty-four seconds
after the day the folder is named for ended. The commit is still the
measurement and still wins; "every source agrees" is true of one
action read two ways, and archiving across midnight is two actions.

The full instant is kept beside the day, with its offset, for ordering
and for lead times. Only the day changes meaning; a comparison between
Expand Down
14 changes: 12 additions & 2 deletions openspec/changes/a-date-is-one-day-in-every-source/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,22 @@ local day of whoever archived, stamped as UTC midnight
archived by a commit at `02:30 +03:00` on the 27th; normalised, that is
the 26th at 23:30 UTC, so its archived date reads `2026-08-26` in the
sprint report, the timeline and the per-day chart, while its folder says
the 27th. Two of this repository's ninety-one archive commits fall in
that window; every workspace in a positive-offset timezone has the same
the 27th. Every workspace in a positive-offset timezone has the same
band each night. Before this range `archivedDate` was always the folder
prefix, so the field changed meaning for those changes without anyone
deciding it should.

Measured while implementing, correcting the count first written here:
of the 192 archived changes this repository can date from a commit,
three were committed in that band, not two — `2026-08-27-add-stale-task-
detection` at `02:30:40 +03:00`, and `2026-09-08-run-dialog-actually-
advises` and `2026-09-08-source-stays-text`, both at `00:00:24 +03:00`.
The first moves onto the day its folder names; the other two move off
it, to the 9th, because `openspec archive` named those folders before
midnight and the commit landed twenty-four seconds after. Both are the
same rule — the day the record names — and the commit is the
measurement.

**The audit-log source is unreachable.** `getChangeTimeline` accepts
`options.auditTimestamps` (`change-timeline.ts:285`); `getChangeTimelines`
has no way to pass it (`:380-405`), and it is the only production entry
Expand Down
161 changes: 138 additions & 23 deletions openspec/changes/a-date-is-one-day-in-every-source/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,74 +4,189 @@ is shown to everyone.

## 1. One day for one action

- [ ] 1.1 `packages/core/src/change-dates.ts`: a `DatedFact` carries the
- [x] 1.1 `packages/core/src/change-dates.ts`: a `DatedFact` carries the
instant with its offset as recorded, and a `day` taken from that
string before normalisation. Every reader of `slice(0, 10)` reads
`day` instead: `change-timeline.ts:362`, the sprint report, the
timeline view, the per-day chart.
- [ ] 1.2 The folder-name source stamps its day without an offset and

Corrected as written: `DatedFact.date` keeps the *normalised* instant
and `day` carries the offset's answer. The offset lives on the
evidence handed in — `getFileCreatedDate`, `getPathAddedDate`,
`readArchiveCommitDates` and `blameLineDates` all return git's own
string now — and is read by `readDatedFact` before it normalises. A
set of instants in mixed offsets sorts by its offsets under
`localeCompare`, which is how `ChangeTimelineView` orders tasks and
how the sprint report compares a range, so publishing the raw form
would have broken both.

`slice(0, 10)` had exactly one reader, `change-timeline.ts:362`
(`archivedDate`); the sprint report, the timeline view and the
per-day chart all read *that* field, and now read `dates.archived.day`
through it.
- [x] 1.2 The folder-name source stamps its day without an offset and
its `day` is the prefix.
- [ ] 1.3 A test with a commit at `02:30+03:00` asserting the day is the
- [x] 1.3 A test with a commit at `02:30+03:00` asserting the day is the
27th from the commit source and from the folder source alike.
`change-dates.test.ts` "gives an after-midnight commit and the folder
it named the same day", and end to end through a real git repository
in `change-timeline.test.ts` "gives an after-midnight archive the day
its own record names".

## 2. The audit-log source reaches the hosts

- [ ] 2.1 `getChangeTimelines` accepts audit timestamps per change; the
- [x] 2.1 `getChangeTimelines` accepts audit timestamps per change; the
server route and the extension command read the audit log once per
request and pass them.
- [ ] 2.2 A test through `getChangeTimelines` where a run precedes the

The grouping is `runTimestampsByChange` in `audit-runs.ts`, beside the
other rule about which entry belongs to which change; both hosts call
it. An archived change is looked up under the name it had when the
runs were recorded as well as under its dated one — archiving renames
the directory the log holds, so the dated name alone would have found
nothing.
- [x] 2.2 A test through `getChangeTimelines` where a run precedes the
first ticked task, asserting `firstWorked.source === "audit-log"`.
Three: `change-timeline.test.ts` "dates the work from a run when the
host passes the audit log to the batch call" and "finds an archived
change's runs under the name it had when they were recorded", plus
`server.test.ts` "dates work from the audit log it reads for the
request", which goes through the real route.

## 3. Never throws

- [ ] 3.1 One parse helper in `change-dates.ts` returns absent for a
- [x] 3.1 One parse helper in `change-dates.ts` returns absent for a
string `Date` cannot read, with source `unreadable`; every source
parses through it.
- [ ] 3.2 A test with `archive/2026-13-01-typo` and no commit: the
- [x] 3.2 A test with `archive/2026-13-01-typo` and no commit: the
timeline is returned, the archived date is absent, and its source says
why.
why. `change-timeline.test.ts` "returns every other change's dates
when one folder name is not a date", with the pure case in
`change-dates.test.ts`.

## 4. The archive read reads what git prints

- [ ] 4.1 `readArchiveCommitDates` uses a delimiter that cannot appear
- [x] 4.1 `readArchiveCommitDates` uses a delimiter that cannot appear
in a path, resolves paths against `git rev-parse --show-toplevel`, and
handles `core.quotePath` output or disables it with `-c
core.quotePath=false`.
- [ ] 4.2 It returns the count of lines it could not read beside the

The delimiter is `\x1f`, in front of every date line. Paths are
resolved against `git rev-parse --show-prefix` rather than
`--show-toplevel`: `--show-prefix` *is* the cwd's path relative to the
top level, so the prefix git puts in front of each path is read
directly instead of computed by comparing two absolute paths that
Windows can spell differently (case, short names) — a comparison that
would fail to nothing on exactly the workspaces this is fixing.
`core.quotePath=false` is passed, and a quoted path is unquoted
anyway.
- [x] 4.2 It returns the count of lines it could not read beside the
map, and `getChangeTimelines` reports that count in the timeline's
basis so a silent fallback becomes a visible one.
- [ ] 4.3 A test with the workspace under a directory named `Core`.
`ChangeTimeline.archiveDatesUnreadableLines` carries it, `ChartBasis`
picks it up, and `describeBasis` says "N archive log lines were
unreadable; those dates were read one change at a time."
- [x] 4.3 A test with the workspace under a directory named `Core`.
`change-timeline.test.ts` "reads the archive of a workspace nested
under a directory named Core" — the repository root is above it, so
git prints `Core/openspec/changes/archive/...`.

## 5. Chart arithmetic in core

- [ ] 5.1 Move `archivedPerDay`, `leadTimes`, `LEAD_BUCKETS`,
- [x] 5.1 Move `archivedPerDay`, `leadTimes`, `LEAD_BUCKETS`,
`describeBasis` and their tests from `packages/webui/src/change-charts.ts`
to a leaf module in core exported through `browser.ts`. The webui
component imports them.
- [ ] 5.2 Add `describeWorkDurationNotCharted(timelines)` in the same
- [x] 5.2 Add `describeWorkDurationNotCharted(timelines)` in the same
module: how many changes, how many with zero days from proposal to
first finished task, and the threshold. `ChangeChartsView.tsx` renders
it in place of the constant sentence.
- [ ] 5.3 Rename the lead-time buckets by their boundaries.
- [ ] 5.4 Move `readOpenTaskCount` from
- [x] 5.3 Rename the lead-time buckets by their boundaries: "Under a
day", "1–2 days", "2–3 days", "3–8 days", "8 days or more".
- [x] 5.4 Move `readOpenTaskCount` from
`run-with-harness-dispatch.ts` to core beside the task checklist.

Only the count moved, as `openTaskCount` in the new leaf module
`task-checklist-counts.ts`; the fetch around it is that host's
transport and stays there. Moving it found two more copies of the same
line in the extension's command handlers, and all three call the core
function now.

## 6. Tests that reach what they name

- [ ] 6.1 `change-timeline.test.ts`, the folder-name case: commit the
- [x] 6.1 `change-timeline.test.ts`, the folder-name case: commit the
archive on a different day from the folder's, or without a commit,
and assert `dates.archived.source === "folder-name"`.

The first of those two cannot assert `folder-name`: a commit wins over
the folder whichever day it falls on, so committing on a different day
yields `git-commit`. Both are covered by two tests instead — the
original, renamed to say it is the commit path and now committing on
the 5th while the folder says the 3rd, and a new "falls back to the
folder name when no commit moved the change", which moves the
directory and does not commit.

## 7. Verification

- [ ] 7.1 `openspec validate --strict --changes`.
- [ ] 7.2 `npm run verify` unpiped, after the last edit, with everything
- [x] 7.1 `openspec validate --strict --changes`. Run 2026-09-10: exit
0, 9 passed, 0 failed.
- [x] 7.2 `npm run verify` unpiped, after the last edit, with everything
staged. Record the run.
- [ ] 7.3 Version bump via `npx changeset` for core, webui, server and
the extension.
- [ ] 7.4 Render the charts over this repository and look at them, as
`charts-of-what-was-finished` did; confirm the two early-morning
archives moved to the day their folders say.

Run 2026-09-10: exit 0 — 48 cli, 915 core, 320 extension, 80 server,
352 webui.

And the whole browser suite, `npm run test:browser --workspace
@openspec-ui/server`, 2026-09-10: 11 passed. It rewrote two
screenshots under `docs/images/standalone/`, neither staged:
`change-charts.png`, because the lead-time bucket labels and the
sentence under the charts are what this change alters, and
`harness-settings.png`, whose only difference is the version footer —
`core 0.62.0 · server 1.17.0 · webui 1.33.0` became `0.63.0 / 1.18.0
/ 1.34.0`, which the release commit already on this branch changed
and nothing re-captured.
- [x] 7.3 Version bump via `npx changeset` for core, webui, server and
the extension. `.changeset/a-date-is-one-day-in-every-source.md`,
minor for all four.
- [x] 7.4 Render the charts over this repository and look at them, as
`charts-over-what-happened` did; confirm the early-morning archives
moved to the day their own records give.

Two corrections to this item as written. The change it names is
`charts-over-what-happened`, not `charts-of-what-was-finished`. And
"the day their folders say" is right for one of them and wrong for the
other two: three commits fall in the band, not two, and two of them
move *away* from the folder's day, because `openspec archive` named
those folders before midnight and the commit landed twenty-four
seconds after it. The commit is the measurement in both directions.

Run 2026-09-10, the real standalone app over this repository, all 201
changes selected, 2026-08-01 to 2026-09-11, screenshot captured by
Playwright and looked at:

- **Archived per day**: 34 columns from 2026-08-08 to 2026-09-10, 24
of them carrying something; busiest 2026-09-02 with 24. Basis: "192
changes · 192 dated from a commit." Nothing excluded, no folder-name
date, and no unread archive-log lines — the one-call read handled
all 192 on a repository whose root is `C:\Prog\OpenSpec-UI`, which
the old `startsWith("C")` test would have failed had the workspace
been the nested case.
- **Days that moved**, against the same log read the old way: 08-26
15 -> 14 and 08-27 2 -> 3 (`2026-08-27-add-stale-task-detection`,
committed `02:30:40 +03:00`, onto the day its folder names); 09-08
20 -> 18 and 09-09 17 -> 19 (`2026-09-08-run-dialog-actually-
advises` and `2026-09-08-source-stays-text`, both committed
`00:00:24 +03:00`, off the day their folders name). The rendered
table shows exactly 14 / 3 / 18 / 19.
- **How long a change took**: Under a day 138, 1–2 days 26, 2–3 days
9, 3–8 days 14, 8 days or more 5. Nothing excluded. The row labels
sit clear of the bars at every length, which is what looking at the
picture is for.
- **The sentence under the charts**: "How long the work itself took is
not charted: 190 of 199 changes have exactly zero days between being
proposed and their first finished task. Past 50% the chart would be
a flat line presented as a finding." Computed, and neither "135 of
185" nor "this repository" appears anywhere on the page.
- [ ] 7.5 **Delegated to copilot-cli**: drive the standalone shell over
this repository with Playwright, open the timeline, and assert the
sentence under the charts carries this repository's own change count
Expand Down
48 changes: 47 additions & 1 deletion packages/core/src/audit-runs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isRunEntry, VERIFY_CHECKS_AGENT_NAME } from "./audit-runs.js";
import { changeNameOf, isRunEntry, runTimestampsByChange, VERIFY_CHECKS_AGENT_NAME } from "./audit-runs.js";
import type { AuditEntry } from "./security.js";

// quality-is-charged-to-the-agent-whose-work-was-checked:
Expand Down Expand Up @@ -34,3 +34,49 @@ describe("isRunEntry", () => {
expect(isRunEntry(entry("git-stage"))).toBe(true);
});
});

describe("runTimestampsByChange", () => {
// a-date-is-one-day-in-every-source: the map the hosts hand to
// `getChangeTimelines` so a run recorded before the first tick is
// evidence of when work started.

function ran(changeDir: string | undefined, timestamp: string): AuditEntry {
return {
runId: "r1",
agent: "claude-cli",
outcome: "completed",
cwd: "/workspace",
timestamp,
...(changeDir ? { changeDir } : {}),
};
}

it("groups timestamps by the change directory's name", () => {
const byChange = runTimestampsByChange([
ran("/workspace/openspec/changes/one", "2026-09-01T09:00:00.000Z"),
ran("/workspace/openspec/changes/one", "2026-09-02T09:00:00.000Z"),
ran("/workspace/openspec/changes/two", "2026-09-03T09:00:00.000Z"),
]);

expect(byChange.get("one")).toEqual(["2026-09-01T09:00:00.000Z", "2026-09-02T09:00:00.000Z"]);
expect(byChange.get("two")).toEqual(["2026-09-03T09:00:00.000Z"]);
});

it("leaves out an entry with nothing to attribute it to", () => {
expect(runTimestampsByChange([ran(undefined, "2026-09-01T09:00:00.000Z")]).size).toBe(0);
});

it("reads a Windows path the same as a POSIX one", () => {
const byChange = runTimestampsByChange([
ran("C:\\workspace\\openspec\\changes\\one", "2026-09-01T09:00:00.000Z"),
]);

expect(byChange.get("one")).toEqual(["2026-09-01T09:00:00.000Z"]);
});
});

describe("changeNameOf", () => {
it("ignores a trailing separator", () => {
expect(changeNameOf("/workspace/openspec/changes/one/")).toBe("one");
});
});
Loading
Loading