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
This commit is contained in:
5
.changeset/humble-dogs-gather.md
Normal file
5
.changeset/humble-dogs-gather.md
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `<planningBase>/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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
103
tests/workstream-inventory.test.cjs
Normal file
103
tests/workstream-inventory.test.cjs
Normal file
@@ -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<details><summary>✅ v1.0 MVP - SHIPPED 2026-06-01</summary>\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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user