diff --git a/openspec/changes/archive/2026-08-27-add-stale-task-detection/design.md b/openspec/changes/archive/2026-08-27-add-stale-task-detection/design.md new file mode 100644 index 0000000..d4470ce --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-stale-task-detection/design.md @@ -0,0 +1,73 @@ +## Context + +The user asked for stale-pending-task detection with a configurable +threshold, explicitly leaving the default value to this agent's +judgment. `2026-08-26-add-change-timeline-data-layer` already computes +a `finalLineNumber -> ISO date` blame map per `tasks.md`; the only gap +is that `getChangeTimeline` discards it for still-pending tasks. + +## Goals / Non-Goals + +**Goals:** +- Reuse the existing blame computation entirely — no new git calls. +- A configurable threshold, not a hardcoded one, in both delivery + targets. +- `isTaskStale`/`findStaleTasks` are pure and host-neutral, so both + webui and the extension use the identical logic rather than each + reimplementing "how old is too old." + +**Non-Goals:** +- Not adding staleness to the multi-change comparison view — it only + plots `createdDate`/done-task `date`/`archivedDate` today, never + pending tasks at all, so there is no existing point to flag. Adding + pending-task points there is a bigger, separate UI change. +- Not a proactive notification/popup for stale tasks. Consistent with + this project's established local-first, no-noise stance (see the + archive-time Changesets reminder's own design.md for the same + reasoning) — the signal is visible in the timeline view when the user + looks, not pushed at them. +- Not adding a tree-view badge in the Changes/Archive views — that + would need a background scan (recomputing blame) independent of the + timeline view's already-paid-for fetch, a real performance question + deferred rather than answered here. + +## Decisions + +### 14-day default threshold + +No existing convention in this codebase to anchor to, so: two weeks is +long enough that normal day-to-day pending work (a task genuinely +being actively worked on, just not finished yet) is not flagged, and +short enough that a truly forgotten task is still caught while the +context to act on it is still fresh. Configurable in both hosts, so +this default is not load-bearing. + +### `lastTouchedDate` is a new field, not a repurposed `date` + +`ChangeTimelineTask.date` was deliberately restricted to checked tasks +only in the prior change, specifically because a blame date for an +unchecked line reads as a misleading "completion" date. Reusing it here +for staleness would undo that fix. A second field keeps both meanings +intact: `date` = "completed on," `lastTouchedDate` = "line last edited +on," used for different purposes. + +### `change-timeline-client.ts` now imports real types instead of a hand-duplicated copy + +Adding `lastTouchedDate` to core's `ChangeTimelineTask` required also +adding it to webui's hand-duplicated copy in `change-timeline-client.ts` +— a maintenance burden that already caused exactly this kind of drift +once. Since `browser.ts` already exists as the browser-safe export +surface, and adding type-only exports there costs nothing at runtime, +this change re-points `change-timeline-client.ts` at the real +`@openspec-ui/core/browser` types instead, closing the drift risk for +good rather than patching this one instance of it. + +## Verification note + +Following the lesson from `2026-08-26-fix-timeline-webview-csp-inline-script` +(a prior smoke test that loaded the bundle in an unrestricted page +missed a real CSP bug), this change's manual verification constructed +the HTML exactly as `TimelineWebviewPanel.getHtml()` does — same CSP, +same nonce'd inline-script shape, both `__OPENSPEC_UI_TIMELINE__` and +`__OPENSPEC_UI_STALE_THRESHOLD_DAYS__` assignments — before trusting +that the real webview would render stale/fresh tasks correctly. diff --git a/openspec/changes/archive/2026-08-27-add-stale-task-detection/proposal.md b/openspec/changes/archive/2026-08-27-add-stale-task-detection/proposal.md new file mode 100644 index 0000000..103713a --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-stale-task-detection/proposal.md @@ -0,0 +1,65 @@ +## Why + +Following up on the 2026-08-26 product-direction discussion: a pending +task that has sat untouched for a long time in an active change is a +useful signal that it may have been forgotten. The data needed for this +already exists — `blameLineDates` (from +`2026-08-26-add-change-timeline-data-layer`) already computes a +per-line last-touched date via git blame; it just wasn't exposed for +still-pending tasks (`ChangeTimelineTask.date` is deliberately `null` +for those, since a "last touched" date would misleadingly read as a +completion date — see that change's design.md). + +## What Changes + +- Add `ChangeTimelineTask.lastTouchedDate`: always populated from blame + (done or not), distinct from `date` (completion-only). No new git + calls — reuses the blame map `getChangeTimeline` already computes. +- Add `packages/core/src/stale-tasks.ts`: `isTaskStale`/`findStaleTasks`, + pure date-math functions (no git/fs access) — a pending task is stale + when `lastTouchedDate` is older than a threshold (default 14 days, + `DEFAULT_STALE_TASK_THRESHOLD_DAYS`). Exported from both the Node-only + barrel and the browser-safe barrel, since it has no Node dependency + and both delivery targets need it. +- `packages/webui/src/change-timeline-client.ts` now re-exports the + `ChangeTimeline*` interfaces from `@openspec-ui/core/browser` instead + of hand-duplicating them — the hand-duplicated copy had already + drifted out of sync once (missing this exact new field) before this + module started importing the real ones. +- `ChangeTimelineView.tsx` gains a `staleThresholdDays` prop (default + 14) and flags stale pending tasks distinctly (a warning marker/color, + not just plain "pending"). +- Standalone app: a "Stale after (days)" number input next to the + single-change Timeline picker. +- VS Code extension: a new setting, + `openspec-ui.staleTaskThresholdDays` (default 14), read when + `openspec-ui.showChangeTimeline` fetches the timeline and passed to + the webview alongside the `ChangeTimeline` payload (its own global, + `window.__OPENSPEC_UI_STALE_THRESHOLD_DAYS__`, embedded the same + nonce'd way as the timeline data itself — see + `2026-08-26-fix-timeline-webview-csp-inline-script`). +- Not changed: the multi-change comparison view — it does not currently + plot pending tasks at all, so staleness has nothing to attach to + there yet. Out of scope for this change. + +## Capabilities + +### Modified Capabilities + +- `execution-core`: adds a Requirement for stale-pending-task detection. +- `vscode-extension`: the Change Timeline webview now surfaces stale + tasks, configurable via a new setting. +- `standalone-app`: the Timeline tab gains a configurable staleness + threshold. + +## Impact + +- `packages/core/src/change-timeline.ts`, `browser.ts`, `index.ts` +- `packages/core/src/stale-tasks.ts` (new) +- `packages/webui/src/change-timeline-client.ts` +- `packages/webui/src/components/ChangeTimelineView.tsx` +- `packages/webui/src/standalone-entry.tsx`, `timeline-entry.tsx`, + `shell-ui.ts` +- `packages/extension/src/webview/timeline-panel.ts` +- `packages/extension/src/commands.ts`, `package.json` +- `.changeset/*.md` (new changeset file) diff --git a/openspec/changes/archive/2026-08-27-add-stale-task-detection/specs/execution-core/spec.md b/openspec/changes/archive/2026-08-27-add-stale-task-detection/specs/execution-core/spec.md new file mode 100644 index 0000000..9a655d0 --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-stale-task-detection/specs/execution-core/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Stale-pending-task detection + +The system SHALL determine, for a still-pending task, whether it has +sat untouched (per git blame on `tasks.md`) longer than a configurable +threshold, defaulting to 14 days. A task with an undeterminable +last-touched date SHALL never be flagged, and a completed task SHALL +never be flagged regardless of age. + +#### Scenario: A pending task untouched past the threshold + +- **WHEN** a still-pending task's last-touched date is older than the + configured threshold +- **THEN** the system reports it as stale + +#### Scenario: A pending task touched recently + +- **WHEN** a still-pending task's last-touched date is within the + configured threshold +- **THEN** the system does not report it as stale + +#### Scenario: A completed task, regardless of age + +- **WHEN** a task is checked off, however old its last-touched date +- **THEN** the system never reports it as stale + +#### Scenario: An undeterminable last-touched date + +- **WHEN** a pending task's last-touched date cannot be determined + (e.g. blame unavailable) +- **THEN** the system does not report it as stale diff --git a/openspec/changes/archive/2026-08-27-add-stale-task-detection/specs/standalone-app/spec.md b/openspec/changes/archive/2026-08-27-add-stale-task-detection/specs/standalone-app/spec.md new file mode 100644 index 0000000..1bcc8fc --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-stale-task-detection/specs/standalone-app/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: The Timeline tab's staleness threshold is user-configurable + +The system SHALL let the user set the stale-pending-task threshold (in +days) in the standalone Timeline tab, defaulting to 14 days, and apply +it when rendering a change's timeline. + +#### Scenario: User changes the threshold + +- **WHEN** the user sets a different stale-after value and loads (or + reloads) a change's timeline +- **THEN** pending tasks are flagged stale according to the new value diff --git a/openspec/changes/archive/2026-08-27-add-stale-task-detection/specs/vscode-extension/spec.md b/openspec/changes/archive/2026-08-27-add-stale-task-detection/specs/vscode-extension/spec.md new file mode 100644 index 0000000..b1f5315 --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-stale-task-detection/specs/vscode-extension/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: The Change Timeline webview flags stale pending tasks + +The system SHALL flag, in the Change Timeline webview, any pending task +that stale-task detection identifies as stale, using a threshold +configurable via the `openspec-ui.staleTaskThresholdDays` setting +(default 14). + +#### Scenario: A change has a stale pending task + +- **WHEN** the user opens the Change Timeline for a change containing a + pending task untouched past the configured threshold +- **THEN** that task is visually distinguished from a fresh pending + task in the webview + +#### Scenario: User changes the threshold setting + +- **WHEN** the user sets `openspec-ui.staleTaskThresholdDays` to a + different value and reopens the Change Timeline +- **THEN** the new threshold is used to determine staleness diff --git a/openspec/changes/archive/2026-08-27-add-stale-task-detection/tasks.md b/openspec/changes/archive/2026-08-27-add-stale-task-detection/tasks.md new file mode 100644 index 0000000..d334e80 --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-stale-task-detection/tasks.md @@ -0,0 +1,59 @@ +## 1. Core: data layer and staleness logic + +- [x] 1.1 Add `ChangeTimelineTask.lastTouchedDate` to + `change-timeline.ts`, always populated from the existing blame map + (done or not) — distinct from `date`, which stays completion-only. +- [x] 1.2 Update `change-timeline.test.ts` for the new field. +- [x] 1.3 Add `packages/core/src/stale-tasks.ts` + (`DEFAULT_STALE_TASK_THRESHOLD_DAYS`, `isTaskStale`, `findStaleTasks`) + and `stale-tasks.test.ts`. Pure date math, no git/fs. +- [x] 1.4 Export from `index.ts`; export the pure logic plus the + `ChangeTimeline*` types (type-only) from `browser.ts`. + +## 2. Webui: shared component and standalone UI + +- [x] 2.1 Re-point `change-timeline-client.ts` at + `@openspec-ui/core/browser`'s real types instead of a hand-duplicated + copy (which had already drifted once). +- [x] 2.2 Add `staleThresholdDays`/`now` props to `ChangeTimelineView`; + flag stale pending tasks distinctly (marker + message), matching CSS + in `shell-ui.ts`. +- [x] 2.3 Add tests for stale/fresh rendering with a deterministic + `now`. +- [x] 2.4 Add a "Stale after (days)" number input to the standalone + Timeline tab's single-change mode, defaulting to + `DEFAULT_STALE_TASK_THRESHOLD_DAYS`. + +## 3. Extension: setting and webview wiring + +- [x] 3.1 Add `openspec-ui.staleTaskThresholdDays` to + `contributes.configuration` in `package.json` (default 14). +- [x] 3.2 `openspec-ui.showChangeTimeline` reads the setting and passes + it to `TimelineWebviewPanel.show(...)`. +- [x] 3.3 `TimelineWebviewPanel` embeds it as its own global + (`window.__OPENSPEC_UI_STALE_THRESHOLD_DAYS__`), same nonce'd + mechanism as the timeline data; `timeline-entry.tsx` reads it and + passes it to `ChangeTimelineView`. +- [x] 3.4 Update `timeline-panel.test.ts` and `commands.test.ts` for + the new parameter/setting (added a dedicated test asserting the + setting is actually read, not just defaulted). + +## 4. Verification + +- [x] 4.1 `npm run typecheck` and `npm run lint` (including + `lint:english`) pass workspace-wide. +- [x] 4.2 `npm run test` passes workspace-wide, including all updated/ + new test files. +- [x] 4.3 Rebuild the VSIX (`npm run package --workspace + openspec-ui-vscode`) and confirm it packages without error. +- [x] 4.4 Manual verification against the *real* CSP shape (not a bare + unrestricted page — see design.md's Verification note): constructed + HTML matching `TimelineWebviewPanel.getHtml()` exactly, with a + synthetic genuinely-stale task and a fresh pending task; confirmed + zero console errors and correct stale/fresh visual distinction via a + real Chromium screenshot. +- [x] 4.5 Propose a changeset (`npx changeset`) for `@openspec-ui/core`, + `@openspec-ui/webui`, and `openspec-ui-vscode` (all minor: new + capability, no breaking change) instead of hand-editing `version`/ + `CHANGELOG.md`; apply it via `npx changeset version`. +- [x] 4.6 Run `openspec change validate --strict add-stale-task-detection`. diff --git a/openspec/specs/execution-core/spec.md b/openspec/specs/execution-core/spec.md index 6313c9c..95b663a 100644 --- a/openspec/specs/execution-core/spec.md +++ b/openspec/specs/execution-core/spec.md @@ -140,3 +140,34 @@ than causing the read to fail. the change's data (proposal/design/tasks/spec content, other determinable dates) is still returned +### Requirement: Stale-pending-task detection + +The system SHALL determine, for a still-pending task, whether it has +sat untouched (per git blame on `tasks.md`) longer than a configurable +threshold, defaulting to 14 days. A task with an undeterminable +last-touched date SHALL never be flagged, and a completed task SHALL +never be flagged regardless of age. + +#### Scenario: A pending task untouched past the threshold + +- **WHEN** a still-pending task's last-touched date is older than the + configured threshold +- **THEN** the system reports it as stale + +#### Scenario: A pending task touched recently + +- **WHEN** a still-pending task's last-touched date is within the + configured threshold +- **THEN** the system does not report it as stale + +#### Scenario: A completed task, regardless of age + +- **WHEN** a task is checked off, however old its last-touched date +- **THEN** the system never reports it as stale + +#### Scenario: An undeterminable last-touched date + +- **WHEN** a pending task's last-touched date cannot be determined + (e.g. blame unavailable) +- **THEN** the system does not report it as stale + diff --git a/openspec/specs/standalone-app/spec.md b/openspec/specs/standalone-app/spec.md index d0c43c6..cc6ed1f 100644 --- a/openspec/specs/standalone-app/spec.md +++ b/openspec/specs/standalone-app/spec.md @@ -243,3 +243,15 @@ log-scaled time axis. - **THEN** the system reports what is missing rather than loading an empty or partial comparison +### Requirement: The Timeline tab's staleness threshold is user-configurable + +The system SHALL let the user set the stale-pending-task threshold (in +days) in the standalone Timeline tab, defaulting to 14 days, and apply +it when rendering a change's timeline. + +#### Scenario: User changes the threshold + +- **WHEN** the user sets a different stale-after value and loads (or + reloads) a change's timeline +- **THEN** pending tasks are flagged stale according to the new value + diff --git a/openspec/specs/vscode-extension/spec.md b/openspec/specs/vscode-extension/spec.md index 59afaa4..453e913 100644 --- a/openspec/specs/vscode-extension/spec.md +++ b/openspec/specs/vscode-extension/spec.md @@ -369,3 +369,23 @@ detail rather than compressing it further. - **THEN** the extension shows an error message and does not open a webview +### Requirement: The Change Timeline webview flags stale pending tasks + +The system SHALL flag, in the Change Timeline webview, any pending task +that stale-task detection identifies as stale, using a threshold +configurable via the `openspec-ui.staleTaskThresholdDays` setting +(default 14). + +#### Scenario: A change has a stale pending task + +- **WHEN** the user opens the Change Timeline for a change containing a + pending task untouched past the configured threshold +- **THEN** that task is visually distinguished from a fresh pending + task in the webview + +#### Scenario: User changes the threshold setting + +- **WHEN** the user sets `openspec-ui.staleTaskThresholdDays` to a + different value and reopens the Change Timeline +- **THEN** the new threshold is used to determine staleness + diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 7178870..56e84ea 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,15 @@ # @openspec-ui/core +## 0.28.0 + +### Minor Changes + +- Add stale-pending-task detection: a pending task untouched (per git + blame) longer than a configurable threshold (default 14 days) is now + flagged in the Change Timeline view. Configurable via a number input in + the standalone Timeline tab and the new `openspec-ui.staleTaskThresholdDays` + VS Code setting. + ## 0.27.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 50b47da..5c23769 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/core", "private": true, - "version": "0.27.0", + "version": "0.28.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts index 2d23852..36ab2fc 100644 --- a/packages/core/src/browser.ts +++ b/packages/core/src/browser.ts @@ -18,3 +18,8 @@ export type { TemplateManifest, TemplateVariable, } from "./template-catalog.js"; +export type { ChangeTimeline, ChangeTimelineSpec, ChangeTimelineTask } from "./change-timeline.js"; +// Pure date math, no git/fs access — safe for the browser bundle (see the +// file header comment for why this differs from change-timeline.js's +// runtime exports, which stay Node-only). +export * from "./stale-tasks.js"; diff --git a/packages/core/src/change-timeline.test.ts b/packages/core/src/change-timeline.test.ts index 2970fc2..b713f09 100644 --- a/packages/core/src/change-timeline.test.ts +++ b/packages/core/src/change-timeline.test.ts @@ -135,8 +135,20 @@ describe("getChangeTimeline", () => { expect(timeline.proposal).toContain("Because."); expect(timeline.design).toContain("Some context."); expect(timeline.tasks).toEqual([ - { lineNumber: 0, text: "first", done: true, date: "2026-01-02T00:00:00.000Z" }, - { lineNumber: 1, text: "second", done: false, date: null }, + { + lineNumber: 0, + text: "first", + done: true, + date: "2026-01-02T00:00:00.000Z", + lastTouchedDate: "2026-01-02T00:00:00.000Z", + }, + { + lineNumber: 1, + text: "second", + done: false, + date: null, + lastTouchedDate: "2026-01-01T00:00:00.000Z", + }, ]); }); @@ -164,7 +176,13 @@ describe("getChangeTimeline", () => { expect(timeline.archived).toBe(true); expect(timeline.archivedDate).toBe("2026-01-03"); expect(timeline.tasks).toEqual([ - { lineNumber: 0, text: "only", done: true, date: "2026-01-02T00:00:00.000Z" }, + { + lineNumber: 0, + text: "only", + done: true, + date: "2026-01-02T00:00:00.000Z", + lastTouchedDate: "2026-01-02T00:00:00.000Z", + }, ]); }); diff --git a/packages/core/src/change-timeline.ts b/packages/core/src/change-timeline.ts index 2f19b8b..8a798e9 100644 --- a/packages/core/src/change-timeline.ts +++ b/packages/core/src/change-timeline.ts @@ -12,8 +12,14 @@ import { discoverOpenSpecWorkspace } from "./workbench.js"; export interface ChangeTimelineTask extends TaskChecklistItem { /** ISO 8601 date the task was last checked/unchecked, per git blame on * its tasks.md line — `null` when undeterminable (never checked, or - * blame data unavailable). */ + * blame data unavailable). Only ever set for a checked (`done`) task — + * see the comment in `getChangeTimeline` for why. */ date: string | null; + /** ISO 8601 date this task's line was last touched, per git blame — + * set regardless of `done`, unlike `date`. Used to flag a still-pending + * task as stale (see `stale-tasks.ts`), not to imply completion. `null` + * when undeterminable. */ + lastTouchedDate: string | null; } export interface ChangeTimelineSpec { @@ -160,6 +166,7 @@ export async function getChangeTimeline( const tasks: ChangeTimelineTask[] = taskItems.map((task) => ({ ...task, date: task.done ? blameDates?.get(task.lineNumber) ?? null : null, + lastTouchedDate: blameDates?.get(task.lineNumber) ?? null, })); return { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 85fbbe7..a71754e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,6 +22,7 @@ export * from "./workbench-recovery.js"; export * from "./agent-detection.js"; export * from "./changeset-reminder.js"; export * from "./change-timeline.js"; +export * from "./stale-tasks.js"; export { ClaudeCliAdapter } from "./agents/claude.js"; export { CopilotCliAdapter } from "./agents/copilot.js"; diff --git a/packages/core/src/stale-tasks.test.ts b/packages/core/src/stale-tasks.test.ts new file mode 100644 index 0000000..b482aec --- /dev/null +++ b/packages/core/src/stale-tasks.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_STALE_TASK_THRESHOLD_DAYS, findStaleTasks, isTaskStale } from "./stale-tasks.js"; +import type { ChangeTimeline, ChangeTimelineTask } from "./change-timeline.js"; + +const now = new Date("2026-02-01T00:00:00.000Z"); + +function task(overrides: Partial): ChangeTimelineTask { + return { lineNumber: 0, text: "a task", done: false, date: null, lastTouchedDate: null, ...overrides }; +} + +describe("isTaskStale", () => { + it("flags a pending task last touched well past the threshold", () => { + const t = task({ lastTouchedDate: "2026-01-01T00:00:00.000Z" }); // 31 days before `now` + expect(isTaskStale(t, 14, now)).toBe(true); + }); + + it("does not flag a pending task touched recently", () => { + const t = task({ lastTouchedDate: "2026-01-30T00:00:00.000Z" }); // 2 days before `now` + expect(isTaskStale(t, 14, now)).toBe(false); + }); + + it("flags exactly at the threshold boundary", () => { + const t = task({ lastTouchedDate: "2026-01-18T00:00:00.000Z" }); // exactly 14 days before `now` + expect(isTaskStale(t, 14, now)).toBe(true); + }); + + it("never flags a done task, regardless of lastTouchedDate", () => { + const t = task({ done: true, lastTouchedDate: "2020-01-01T00:00:00.000Z" }); + expect(isTaskStale(t, 14, now)).toBe(false); + }); + + it("never flags a task with an undeterminable lastTouchedDate", () => { + const t = task({ lastTouchedDate: null }); + expect(isTaskStale(t, 14, now)).toBe(false); + }); + + it("uses the default 14-day threshold when none is given", () => { + expect(DEFAULT_STALE_TASK_THRESHOLD_DAYS).toBe(14); + const justOver = task({ lastTouchedDate: "2026-01-17T00:00:00.000Z" }); // 15 days before `now` + expect(isTaskStale(justOver, undefined, now)).toBe(true); + }); +}); + +describe("findStaleTasks", () => { + it("returns only stale tasks, in their original order", () => { + const timeline: ChangeTimeline = { + changeName: "my-change", + archived: false, + createdDate: null, + archivedDate: null, + proposal: "", + design: "", + specs: [], + tasks: [ + task({ lineNumber: 0, text: "stale", lastTouchedDate: "2026-01-01T00:00:00.000Z" }), + task({ lineNumber: 1, text: "fresh", lastTouchedDate: "2026-01-30T00:00:00.000Z" }), + task({ lineNumber: 2, text: "done", done: true, lastTouchedDate: "2026-01-01T00:00:00.000Z" }), + task({ lineNumber: 3, text: "also stale", lastTouchedDate: "2025-12-01T00:00:00.000Z" }), + ], + }; + + const stale = findStaleTasks(timeline, 14, now); + + expect(stale.map((t) => t.text)).toEqual(["stale", "also stale"]); + }); +}); diff --git a/packages/core/src/stale-tasks.ts b/packages/core/src/stale-tasks.ts new file mode 100644 index 0000000..26fcb54 --- /dev/null +++ b/packages/core/src/stale-tasks.ts @@ -0,0 +1,37 @@ +// Stale-pending-task detection over an already-fetched `ChangeTimeline` +// (see openspec/changes/add-stale-task-detection/design.md). Pure date +// math only — no git/fs access here, so this is safe to export from the +// browser-safe barrel too and reuse identically in both delivery +// targets, per host-neutral shared-logic convention (ADR-0001). + +import type { ChangeTimeline, ChangeTimelineTask } from "./change-timeline.js"; + +/** A pending task sitting untouched for two weeks is a reasonable + * default signal that it may have been forgotten — long enough not to + * flag normal day-to-day pending work, short enough to still be + * actionable when raised. */ +export const DEFAULT_STALE_TASK_THRESHOLD_DAYS = 14; + +/** A task is stale when it is still pending and its line has not been + * touched (per git blame) within `thresholdDays`. A task whose + * `lastTouchedDate` is undeterminable is never flagged — staleness is a + * best-effort nudge, not something to guess at. */ +export function isTaskStale( + task: ChangeTimelineTask, + thresholdDays: number = DEFAULT_STALE_TASK_THRESHOLD_DAYS, + now: Date = new Date(), +): boolean { + if (task.done || !task.lastTouchedDate) return false; + const ageMs = now.getTime() - new Date(task.lastTouchedDate).getTime(); + return ageMs >= thresholdDays * 24 * 60 * 60 * 1000; +} + +/** Returns every stale task in `timeline`, in their original `tasks.md` + * order. */ +export function findStaleTasks( + timeline: ChangeTimeline, + thresholdDays: number = DEFAULT_STALE_TASK_THRESHOLD_DAYS, + now: Date = new Date(), +): ChangeTimelineTask[] { + return timeline.tasks.filter((task) => isTaskStale(task, thresholdDays, now)); +} diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 66e0693..6d5d397 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.25.0 + +### Minor Changes + +- Add stale-pending-task detection: a pending task untouched (per git + blame) longer than a configurable threshold (default 14 days) is now + flagged in the Change Timeline view. Configurable via a number input in + the standalone Timeline tab and the new `openspec-ui.staleTaskThresholdDays` + VS Code setting. + +### Patch Changes + +- Updated dependencies + - @openspec-ui/core@0.28.0 + - @openspec-ui/webui@1.14.0 + ## 0.24.1 ### Patch Changes diff --git a/packages/extension/package.json b/packages/extension/package.json index a42ba20..acd41f3 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -4,7 +4,7 @@ "displayName": "OpenSpec Workbench", "description": "A dashboard + VS Code extension for OpenSpec, with Claude, Copilot, Codex, and Gemini agents built in.", "publisher": "openspec-ui", - "version": "0.24.1", + "version": "0.25.0", "icon": "media/icon.png", "license": "MIT", "repository": { @@ -341,6 +341,12 @@ "type": "number", "default": 0, "description": "Days to keep process/checkpoint history before it's pruned on the next window reload. 0 or a negative number keeps everything forever (the default, unchanged from prior versions). WARNING: once a process is pruned, Rollback (both single-process and whole-Change) is permanently unavailable for it — pruning is not itself reversible." + }, + "openspec-ui.staleTaskThresholdDays": { + "type": "number", + "default": 14, + "minimum": 1, + "description": "Days a pending task can sit untouched (per git blame on tasks.md) before the Change Timeline view flags it as stale." } } }, diff --git a/packages/extension/src/commands.test.ts b/packages/extension/src/commands.test.ts index 61cb0ce..876789f 100644 --- a/packages/extension/src/commands.test.ts +++ b/packages/extension/src/commands.test.ts @@ -31,6 +31,7 @@ class UnknownProjectTemplateError extends Error {} class TaskListChangedError extends Error {} const TASK_CHECKBOX_LINE_RE = /^[ \t]*-\s\[([ xX])\]\s*(.*)$/; vi.mock("@openspec-ui/core", () => ({ + DEFAULT_STALE_TASK_THRESHOLD_DAYS: 14, archiveChange: (...args: unknown[]) => archiveChangeMock(...args), checkChangesetReminder: (...args: unknown[]) => checkChangesetReminderMock(...args), createChange: (...args: unknown[]) => createChangeMock(...args), @@ -187,7 +188,24 @@ describe("registerCommands", () => { }); expect(getChangeTimelineMock).toHaveBeenCalledWith("/workspace/repo", "my-change", false); - expect(timelinePanelShowMock).toHaveBeenCalledWith("my-change", timeline); + expect(timelinePanelShowMock).toHaveBeenCalledWith("my-change", timeline, 14); + }); + + it("reads the stale-task threshold from the openspec-ui.staleTaskThresholdDays setting", async () => { + const timeline = { changeName: "my-change", archived: false, tasks: [] }; + getChangeTimelineMock.mockResolvedValue(timeline); + const configuredGet = vi.fn((_key: string, _defaultValue?: unknown) => 30); + vscodeMock.workspace.getConfiguration.mockReturnValue({ get: configuredGet }); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.showChangeTimeline")?.({ + changeName: "my-change", + archived: false, + }); + + expect(configuredGet).toHaveBeenCalledWith("staleTaskThresholdDays", 14); + expect(timelinePanelShowMock).toHaveBeenCalledWith("my-change", timeline, 30); }); it("fetches an archived change's timeline with archived: true", async () => { diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index 5f41d41..c0768e4 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -5,6 +5,7 @@ import path from "node:path"; import * as vscode from "vscode"; import { + DEFAULT_STALE_TASK_THRESHOLD_DAYS, TASK_CHECKBOX_LINE_RE, TaskListChangedError, TemplateAlreadyExistsError, @@ -463,7 +464,10 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command if (!workspaceRoot || !item) return; try { const timeline = await getChangeTimeline(workspaceRoot, item.changeName, item.archived); - timelinePanel.show(item.changeName, timeline); + const staleThresholdDays = vscode.workspace + .getConfiguration("openspec-ui") + .get("staleTaskThresholdDays", DEFAULT_STALE_TASK_THRESHOLD_DAYS); + timelinePanel.show(item.changeName, timeline, staleThresholdDays); } catch (error) { await showCommandError("show change timeline", error); } diff --git a/packages/extension/src/webview/timeline-panel.test.ts b/packages/extension/src/webview/timeline-panel.test.ts index 3218d8f..418fcb2 100644 --- a/packages/extension/src/webview/timeline-panel.test.ts +++ b/packages/extension/src/webview/timeline-panel.test.ts @@ -33,7 +33,7 @@ describe("TimelineWebviewPanel", () => { const panel = createPanelFixture(); const timelinePanel = createTimelinePanel(); - timelinePanel.show("my-change", { changeName: "my-change" } as never); + timelinePanel.show("my-change", { changeName: "my-change" } as never, 14); // The inline data-injection script must carry a nonce that also // appears in the CSP's script-src — without it, a real VS Code @@ -48,6 +48,7 @@ describe("TimelineWebviewPanel", () => { expect(panel.webview.html).toContain(`script-src vscode-webview: 'nonce-${nonce}'`); expect(panel.webview.html).not.toContain("script-src vscode-webview: 'unsafe-inline'"); expect(panel.webview.html).toContain('window.__OPENSPEC_UI_TIMELINE__ = {"changeName":"my-change"}'); + expect(panel.webview.html).toContain("window.__OPENSPEC_UI_STALE_THRESHOLD_DAYS__ = 14;"); }); it("embeds the multi-change payload behind a CSP nonce", () => { @@ -66,9 +67,9 @@ describe("TimelineWebviewPanel", () => { const panel = createPanelFixture(); const timelinePanel = createTimelinePanel(); - timelinePanel.show("first", { changeName: "first" } as never); + timelinePanel.show("first", { changeName: "first" } as never, 14); const firstHtml = panel.webview.html; - timelinePanel.show("second", { changeName: "second" } as never); + timelinePanel.show("second", { changeName: "second" } as never, 14); const secondHtml = panel.webview.html; expect(extractInlineScriptNonce(firstHtml)).not.toBe(extractInlineScriptNonce(secondHtml)); @@ -78,7 +79,7 @@ describe("TimelineWebviewPanel", () => { const panel = createPanelFixture(); const timelinePanel = createTimelinePanel(); - timelinePanel.show("my-change", { changeName: "my-change", proposal: "" } as never); + timelinePanel.show("my-change", { changeName: "my-change", proposal: "" } as never, 14); // Escaping every literal `<` to `\u003c` is sufficient on its own — // the HTML tokenizer's script-end-tag detection requires a real `<` diff --git a/packages/extension/src/webview/timeline-panel.ts b/packages/extension/src/webview/timeline-panel.ts index b3bb9ef..42da483 100644 --- a/packages/extension/src/webview/timeline-panel.ts +++ b/packages/extension/src/webview/timeline-panel.ts @@ -19,10 +19,17 @@ export class TimelineWebviewPanel { /** Opens a new webview tab for `timeline` — deliberately not a * singleton (unlike `AiPanel`): opening timelines for different - * changes yields separate tabs a user can compare side by side. */ - show(changeName: string, timeline: ChangeTimeline): void { + * changes yields separate tabs a user can compare side by side. + * `staleThresholdDays` (see openspec/changes/ + * add-stale-task-detection/design.md) is embedded alongside the + * timeline itself, under its own global — `ChangeTimelineView` reads + * it as a prop, not baked into the `ChangeTimeline` data shape. */ + show(changeName: string, timeline: ChangeTimeline, staleThresholdDays: number): void { const panel = this.createPanel(`OpenSpec UI: ${changeName} timeline`); - panel.webview.html = this.getHtml(panel.webview, "__OPENSPEC_UI_TIMELINE__", timeline); + panel.webview.html = this.getHtml(panel.webview, { + __OPENSPEC_UI_TIMELINE__: timeline, + __OPENSPEC_UI_STALE_THRESHOLD_DAYS__: staleThresholdDays, + }); } /** Same not-a-singleton shape as `show()`, for the multi-change @@ -30,7 +37,7 @@ export class TimelineWebviewPanel { * add-multi-change-timeline-view/design.md). */ showMulti(payload: MultiChangeTimelinePayload): void { const panel = this.createPanel("OpenSpec UI: change comparison"); - panel.webview.html = this.getHtml(panel.webview, "__OPENSPEC_UI_MULTI_TIMELINE__", payload); + panel.webview.html = this.getHtml(panel.webview, { __OPENSPEC_UI_MULTI_TIMELINE__: payload }); } private createPanel(title: string): vscode.WebviewPanel { @@ -45,7 +52,7 @@ export class TimelineWebviewPanel { ); } - private getHtml(webview: vscode.Webview, globalName: string, payload: unknown): string { + private getHtml(webview: vscode.Webview, globals: Record): string { const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.deps.extensionUri, "dist", "timeline.js")); // A nonce, not a blanket 'unsafe-inline', authorizes only this one // inline script — CSP's `script-src` otherwise blocks inline @@ -57,7 +64,9 @@ export class TimelineWebviewPanel { const csp = `default-src 'none'; script-src ${webview.cspSource} 'nonce-${nonce}'; style-src ${webview.cspSource} 'unsafe-inline';`; // `<` -> `<` prevents an embedded `` sequence (e.g. inside // markdown content) from closing the script tag early. - const payloadJson = JSON.stringify(payload).replaceAll("<", "\\u003c"); + const assignments = Object.entries(globals) + .map(([name, value]) => `window.${name} = ${JSON.stringify(value).replaceAll("<", "\\u003c")};`) + .join("\n"); return ` @@ -67,7 +76,7 @@ export class TimelineWebviewPanel {
- + `; diff --git a/packages/server/src/server.test.ts b/packages/server/src/server.test.ts index e5bcf8e..d93349f 100644 --- a/packages/server/src/server.test.ts +++ b/packages/server/src/server.test.ts @@ -459,7 +459,7 @@ describe("server — REST /api/status", () => { createdDate: string | null; archivedDate: string | null; proposal: string; - tasks: Array<{ text: string; done: boolean; date: string | null }>; + tasks: Array<{ text: string; done: boolean; date: string | null; lastTouchedDate: string | null }>; }; expect(response.status).toBe(200); @@ -469,8 +469,8 @@ describe("server — REST /api/status", () => { expect(body.createdDate).toBeNull(); expect(body.proposal).toContain("Because."); expect(body.tasks).toEqual([ - { lineNumber: 0, text: "done", done: true, date: null }, - { lineNumber: 1, text: "todo", done: false, date: null }, + { lineNumber: 0, text: "done", done: true, date: null, lastTouchedDate: null }, + { lineNumber: 1, text: "todo", done: false, date: null, lastTouchedDate: null }, ]); }); diff --git a/packages/webui/CHANGELOG.md b/packages/webui/CHANGELOG.md index 58f25c0..d5e4474 100644 --- a/packages/webui/CHANGELOG.md +++ b/packages/webui/CHANGELOG.md @@ -1,5 +1,20 @@ # @openspec-ui/webui +## 1.14.0 + +### Minor Changes + +- Add stale-pending-task detection: a pending task untouched (per git + blame) longer than a configurable threshold (default 14 days) is now + flagged in the Change Timeline view. Configurable via a number input in + the standalone Timeline tab and the new `openspec-ui.staleTaskThresholdDays` + VS Code setting. + +### Patch Changes + +- Updated dependencies + - @openspec-ui/core@0.28.0 + ## 1.13.0 ### Minor Changes diff --git a/packages/webui/package.json b/packages/webui/package.json index 30ce14d..c7b2d0f 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/webui", "private": true, - "version": "1.13.0", + "version": "1.14.0", "type": "module", "main": "src/index.ts", "dependencies": { diff --git a/packages/webui/src/change-timeline-client.test.ts b/packages/webui/src/change-timeline-client.test.ts index 4c11725..c97f45d 100644 --- a/packages/webui/src/change-timeline-client.test.ts +++ b/packages/webui/src/change-timeline-client.test.ts @@ -9,7 +9,15 @@ const timeline: ChangeTimeline = { proposal: "## Why\n", design: "## Context\n", specs: [], - tasks: [{ lineNumber: 0, text: "done", done: true, date: "2026-01-02T00:00:00.000Z" }], + tasks: [ + { + lineNumber: 0, + text: "done", + done: true, + date: "2026-01-02T00:00:00.000Z", + lastTouchedDate: "2026-01-02T00:00:00.000Z", + }, + ], }; describe("loadChangeTimeline", () => { diff --git a/packages/webui/src/change-timeline-client.ts b/packages/webui/src/change-timeline-client.ts index e01d388..72cbb9f 100644 --- a/packages/webui/src/change-timeline-client.ts +++ b/packages/webui/src/change-timeline-client.ts @@ -1,25 +1,10 @@ -export interface ChangeTimelineTask { - lineNumber: number; - text: string; - done: boolean; - date: string | null; -} - -export interface ChangeTimelineSpec { - specId: string; - content: string; -} - -export interface ChangeTimeline { - changeName: string; - archived: boolean; - createdDate: string | null; - archivedDate: string | null; - proposal: string; - design: string; - specs: ChangeTimelineSpec[]; - tasks: ChangeTimelineTask[]; -} +// Re-exported from core's browser-safe barrel (not hand-duplicated) — +// see openspec/changes/add-stale-task-detection/design.md for why: a +// hand-duplicated copy of these interfaces already drifted out of sync +// once (missing `lastTouchedDate`) before this module started importing +// them for real. +export type { ChangeTimeline, ChangeTimelineSpec, ChangeTimelineTask } from "@openspec-ui/core/browser"; +import type { ChangeTimeline } from "@openspec-ui/core/browser"; export interface ChangeTimelineEntry { changeName: string; diff --git a/packages/webui/src/components/ChangeTimelineView.test.tsx b/packages/webui/src/components/ChangeTimelineView.test.tsx index fc24b7d..cd08a3f 100644 --- a/packages/webui/src/components/ChangeTimelineView.test.tsx +++ b/packages/webui/src/components/ChangeTimelineView.test.tsx @@ -12,9 +12,27 @@ const timeline: ChangeTimeline = { design: "## Context\n\nSome context.\n", specs: [{ specId: "execution-core", content: "## ADDED Requirements\n" }], tasks: [ - { lineNumber: 0, text: "second task, checked later", done: true, date: "2026-01-03T00:00:00.000Z" }, - { lineNumber: 1, text: "first task, checked earlier", done: true, date: "2026-01-02T00:00:00.000Z" }, - { lineNumber: 2, text: "still pending", done: false, date: null }, + { + lineNumber: 0, + text: "second task, checked later", + done: true, + date: "2026-01-03T00:00:00.000Z", + lastTouchedDate: "2026-01-03T00:00:00.000Z", + }, + { + lineNumber: 1, + text: "first task, checked earlier", + done: true, + date: "2026-01-02T00:00:00.000Z", + lastTouchedDate: "2026-01-02T00:00:00.000Z", + }, + { + lineNumber: 2, + text: "still pending", + done: false, + date: null, + lastTouchedDate: "2026-01-10T00:00:00.000Z", + }, ], }; @@ -57,4 +75,31 @@ describe("ChangeTimelineView", () => { render(); expect(screen.getByText("No tasks found.")).toBeInTheDocument(); }); + + it("flags a pending task as stale once it has sat untouched past the threshold", () => { + render( + , + ); + const pendingTask = screen.getByTestId("timeline-task-2"); + expect(pendingTask.className).toContain("openspec-timeline-task-stale"); + expect(pendingTask).toHaveTextContent("stale"); + }); + + it("does not flag a pending task touched recently", () => { + render( + , + ); + const pendingTask = screen.getByTestId("timeline-task-2"); + expect(pendingTask.className).not.toContain("openspec-timeline-task-stale"); + expect(pendingTask).toHaveTextContent("pending"); + expect(pendingTask).not.toHaveTextContent("stale"); + }); }); diff --git a/packages/webui/src/components/ChangeTimelineView.tsx b/packages/webui/src/components/ChangeTimelineView.tsx index b3b612b..4110a7e 100644 --- a/packages/webui/src/components/ChangeTimelineView.tsx +++ b/packages/webui/src/components/ChangeTimelineView.tsx @@ -4,11 +4,18 @@ // See openspec/changes/add-change-timeline-view/design.md. import { useState } from "react"; +import { DEFAULT_STALE_TASK_THRESHOLD_DAYS, isTaskStale } from "@openspec-ui/core/browser"; import { renderMarkdown } from "../markdown.js"; import type { ChangeTimeline, ChangeTimelineTask } from "../change-timeline-client.js"; export interface ChangeTimelineViewProps { timeline: ChangeTimeline; + /** Days a pending task can sit untouched before it's flagged stale. + * Defaults to `DEFAULT_STALE_TASK_THRESHOLD_DAYS` (14). */ + staleThresholdDays?: number; + /** Injectable for deterministic tests — defaults to the real current + * time. */ + now?: Date; } function formatDate(date: string | null): string { @@ -28,11 +35,23 @@ function sortedTasks(tasks: ChangeTimelineTask[]): ChangeTimelineTask[] { }); } -function TaskRow({ task }: { task: ChangeTimelineTask }) { +function TaskRow({ + task, + staleThresholdDays, + now, +}: { + task: ChangeTimelineTask; + staleThresholdDays: number; + now: Date; +}) { const [expanded, setExpanded] = useState(false); + const stale = isTaskStale(task, staleThresholdDays, now); return ( -
  • +
  • + {timelineMessage ?

    {timelineMessage}

    : null} - {timeline ? : null} + {timeline ? : null} ) : ( diff --git a/packages/webui/src/timeline-entry.tsx b/packages/webui/src/timeline-entry.tsx index f724278..6288f6d 100644 --- a/packages/webui/src/timeline-entry.tsx +++ b/packages/webui/src/timeline-entry.tsx @@ -23,21 +23,24 @@ declare global { interface Window { __OPENSPEC_UI_TIMELINE__?: ChangeTimeline; __OPENSPEC_UI_MULTI_TIMELINE__?: MultiChangeTimelinePayload; + __OPENSPEC_UI_STALE_THRESHOLD_DAYS__?: number; } } function TimelineApp({ timeline, multi, + staleThresholdDays, }: { timeline: ChangeTimeline | undefined; multi: MultiChangeTimelinePayload | undefined; + staleThresholdDays: number | undefined; }) { return (
    {timeline ? ( - + ) : multi ? ( ) : ( @@ -52,5 +55,9 @@ if (!container) { throw new Error("timeline-entry: #root element not found"); } createRoot(container).render( - , + , );