From 05cd55d43bffade3d22683c43b70d956cbc6098b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 2 Jul 2026 10:24:41 -0400 Subject: [PATCH] fix(#1913): derive workstream progress status from shipped signals (#1916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workstream progress trusted the mutable STATE.md `Status` field, so a shipped/archived milestone whose field was left at `executing` was reported as executing — a stale hand-maintained field became the source of truth instead of the authoritative archive/tag/ROADMAP signals. Derive status in the inventory builder from a milestoneShipped signal (archived milestone snapshot under milestones/, or a SHIPPED marker in the workstream ROADMAP), collected by inspectWorkstream. The inventory now reports `status_source` (field|derived) and `status_conflict` (true when the derived value disagrees with the stale field), and a shipped workstream is never reported executing. - src/workstream-inventory-builder.cts: milestoneShipped input + status_source/status_conflict outputs + derivation - src/workstream-inventory.cts: workstreamMilestoneShipped() signal detector wired into inspectWorkstream - tests/workstream-inventory.test.cjs: regression (builder unit + inspectWorkstream integration + negative) Closes #1913 --- .changeset/humble-dogs-gather.md | 5 ++ src/workstream-inventory-builder.cts | 26 ++++++- src/workstream-inventory.cts | 25 +++++++ tests/workstream-inventory.test.cjs | 103 +++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 .changeset/humble-dogs-gather.md create mode 100644 tests/workstream-inventory.test.cjs diff --git a/.changeset/humble-dogs-gather.md b/.changeset/humble-dogs-gather.md new file mode 100644 index 000000000..6a0cc0e60 --- /dev/null +++ b/.changeset/humble-dogs-gather.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1916 +--- +**workstream progress no longer reports shipped milestones as `executing`** — `gsd-tools workstream progress` now derives each workstream's status from authoritative shipped signals (an archived milestone snapshot under milestones/, or a SHIPPED marker in the workstream ROADMAP) instead of trusting the mutable STATE.md `Status` field, so a stale field can never hide a shipped/archived milestone. The output adds `status_source` (`field` | `derived`) and `status_conflict` (true when the derived value disagrees with the stale field). (#1913) diff --git a/src/workstream-inventory-builder.cts b/src/workstream-inventory-builder.cts index da75bcbad..c89386df8 100644 --- a/src/workstream-inventory-builder.cts +++ b/src/workstream-inventory-builder.cts @@ -60,6 +60,14 @@ export interface BuildWorkstreamInventoryInputs { roadmapPhaseCount: number; stateProjection: StateProjection; filesExist: WorkstreamFilesExist; + /** + * True when an authoritative shipped signal is present for this workstream + * (an archived milestone snapshot under milestones/, or a SHIPPED marker in + * the workstream ROADMAP). When true, the inventory status is DERIVED as + * "milestone complete" regardless of the mutable STATE.md `Status` field, + * so a stale field can never report a shipped workstream as executing (#1913). + */ + milestoneShipped: boolean; } export interface WorkstreamInventory { @@ -68,6 +76,10 @@ export interface WorkstreamInventory { active: boolean; files: WorkstreamFilesExist; status: string; + /** Whether `status` was derived from an authoritative signal ("derived") or taken verbatim from the STATE.md field ("field"). */ + status_source: 'field' | 'derived'; + /** True when the derived status disagrees with the STATE.md `Status` field (the field is stale). */ + status_conflict: boolean; current_phase: string | null | undefined; last_activity: string | null | undefined; phases: PhaseStatus[]; @@ -90,6 +102,7 @@ export function buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs) roadmapPhaseCount, stateProjection, filesExist, + milestoneShipped, } = inputs; // Index counts by directory for O(1) lookup during sort/iteration @@ -122,6 +135,15 @@ export function buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs) }); } + // #1913: derive status from authoritative shipped signals rather than trusting + // the mutable STATE.md `Status` field. When a shipped signal is present, the + // workstream is "milestone complete" regardless of a stale field value. + const fieldStatus = stateProjection.status; + const useDerived = milestoneShipped; + const status = useDerived ? 'milestone complete' : fieldStatus; + const status_source: 'field' | 'derived' = useDerived ? 'derived' : 'field'; + const status_conflict = useDerived && !isCompletedInventory(fieldStatus); + return { name, path: toPosixPath(path.relative(projectDir, workstreamDir)), @@ -131,7 +153,9 @@ export function buildWorkstreamInventory(inputs: BuildWorkstreamInventoryInputs) state: filesExist.state, requirements: filesExist.requirements, }, - status: stateProjection.status, + status, + status_source, + status_conflict, current_phase: stateProjection.current_phase, last_activity: stateProjection.last_activity, phases, diff --git a/src/workstream-inventory.cts b/src/workstream-inventory.cts index 67a2f0edd..97b0d8e7e 100644 --- a/src/workstream-inventory.cts +++ b/src/workstream-inventory.cts @@ -84,6 +84,30 @@ function readStateProjection(statePath: string): StateProjection { } } +/** + * #1913: detect an authoritative shipped signal for a workstream so the + * inventory status is never trusted from the mutable STATE.md `Status` field + * alone. Returns true when EITHER an archived milestone snapshot is present + * under `/milestones/` OR the workstream ROADMAP carries a + * SHIPPED marker — both are hard to desync, unlike the hand-maintained field. + */ +function workstreamMilestoneShipped(roadmapPath: string, planningBase: string): boolean { + try { + const milestonesDir = path.join(planningBase, 'milestones'); + for (const entry of fs.readdirSync(milestonesDir, { withFileTypes: true })) { + if (entry.isFile() && /-ROADMAP\.md$/i.test(entry.name)) return true; + } + } catch { + /* no milestones archive dir */ + } + try { + if (/SHIPPED/i.test(fs.readFileSync(roadmapPath, 'utf-8'))) return true; + } catch { + /* no roadmap */ + } + return false; +} + function sortWorkstreamInventories(inventories: WorkstreamInventory[], activeWorkstreamName: string | null): WorkstreamInventory[] { return [...inventories].sort((a, b) => { const aActive = a.name === activeWorkstreamName ? 1 : 0; @@ -123,6 +147,7 @@ function inspectWorkstream(cwd: string, name: string, options: InspectWorkstream state: fs.existsSync(p.state), requirements: fs.existsSync(p.requirements), }, + milestoneShipped: workstreamMilestoneShipped(p.roadmap, p.planning), }); } diff --git a/tests/workstream-inventory.test.cjs b/tests/workstream-inventory.test.cjs new file mode 100644 index 000000000..20d6e9c6c --- /dev/null +++ b/tests/workstream-inventory.test.cjs @@ -0,0 +1,103 @@ +'use strict'; + +// Regression + projection coverage for the workstream-inventory module. +// #1913: status must be derived from authoritative shipped signals (milestone +// archive snapshot / ROADMAP SHIPPED marker), not trusted from the mutable +// STATE.md `Status` field. + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { cleanup } = require('./helpers.cjs'); +const { createFixture, seedWorkstream } = require('./fixtures/index.cjs'); +const { buildWorkstreamInventory } = require('../gsd-core/bin/lib/workstream-inventory-builder.cjs'); +const { inspectWorkstream } = require('../gsd-core/bin/lib/workstream-inventory.cjs'); + +const STALE_STATE = 'status: executing\n'; +const IN_PROGRESS_ROADMAP = + '# Roadmap\n## Milestones\n- v2.0 Test — IN PROGRESS\n## Phases\n### Phase 1: Foo\n**Goal:** foo\n'; + +describe('#1913 — workstream status derived from authoritative shipped signals', () => { + let tmpDir; + before(() => { tmpDir = createFixture(); }); + after(() => cleanup(tmpDir)); + + test('builder: milestoneShipped overrides a stale executing field (derived + conflict)', () => { + const inv = buildWorkstreamInventory({ + name: 'ws-a', + projectDir: tmpDir, + workstreamDir: path.join(tmpDir, '.planning', 'workstreams', 'ws-a'), + phaseDirNames: [], + activeWorkstreamName: '', + phaseFilesCounts: [], + roadmapPhaseCount: 0, + stateProjection: { status: 'executing', current_phase: null, last_activity: null }, + filesExist: { roadmap: true, state: true, requirements: true }, + milestoneShipped: true, + }); + assert.equal(inv.status, 'milestone complete'); + assert.equal(inv.status_source, 'derived'); + assert.equal(inv.status_conflict, true); + }); + + test('builder: no shipped signal → field status, no conflict', () => { + const inv = buildWorkstreamInventory({ + name: 'ws-b', + projectDir: tmpDir, + workstreamDir: path.join(tmpDir, '.planning', 'workstreams', 'ws-b'), + phaseDirNames: [], + activeWorkstreamName: '', + phaseFilesCounts: [], + roadmapPhaseCount: 0, + stateProjection: { status: 'executing', current_phase: null, last_activity: null }, + filesExist: { roadmap: true, state: true, requirements: true }, + milestoneShipped: false, + }); + assert.equal(inv.status, 'executing'); + assert.equal(inv.status_source, 'field'); + assert.equal(inv.status_conflict, false); + }); + + test('inspectWorkstream: shipped archive snapshot + stale executing STATE → derived complete', () => { + const wsDir = seedWorkstream(tmpDir, { name: 'ws-archived' }); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), STALE_STATE); + fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), IN_PROGRESS_ROADMAP); + // Authoritative shipped signal: an archived milestone snapshot. + fs.mkdirSync(path.join(wsDir, 'milestones'), { recursive: true }); + fs.writeFileSync(path.join(wsDir, 'milestones', 'v1.0-ROADMAP.md'), '# v1.0 archived\n'); + + const inv = inspectWorkstream(tmpDir, 'ws-archived', { active: null }); + assert.ok(inv, 'inventory should be produced'); + assert.equal(inv.status, 'milestone complete'); + assert.equal(inv.status_source, 'derived'); + assert.equal(inv.status_conflict, true); + }); + + test('inspectWorkstream: ROADMAP SHIPPED marker + stale executing STATE → derived complete', () => { + const wsDir = seedWorkstream(tmpDir, { name: 'ws-shipped' }); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), STALE_STATE); + fs.writeFileSync( + path.join(wsDir, 'ROADMAP.md'), + '# Roadmap\n## Milestones\n
✅ v1.0 MVP - SHIPPED 2026-06-01\n## Phases\n### Phase 1: Foo\n**Goal:** foo\n' + ); + + const inv = inspectWorkstream(tmpDir, 'ws-shipped', { active: null }); + assert.ok(inv, 'inventory should be produced'); + assert.equal(inv.status, 'milestone complete'); + assert.equal(inv.status_source, 'derived'); + assert.equal(inv.status_conflict, true); + }); + + test('inspectWorkstream: no shipped signals + executing STATE → field status, no conflict', () => { + const wsDir = seedWorkstream(tmpDir, { name: 'ws-active' }); + fs.writeFileSync(path.join(wsDir, 'STATE.md'), STALE_STATE); + fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), IN_PROGRESS_ROADMAP); + + const inv = inspectWorkstream(tmpDir, 'ws-active', { active: null }); + assert.ok(inv, 'inventory should be produced'); + assert.equal(inv.status, 'executing'); + assert.equal(inv.status_source, 'field'); + assert.equal(inv.status_conflict, false); + }); +});