/** * Workstream Tests — CRUD, env-var routing, collision detection */ const { describe, test, before, after, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { runMsdTools, cleanup } = require('./helpers.cjs'); const { createFixture, seedWorkstream } = require('./fixtures/index.cjs'); const { migrateToWorkstreams, getOtherActiveWorkstreams } = require('../msd-core/bin/lib/workstream.cjs'); // ─── Helper ────────────────────────────────────────────────────────────────── function createFailingTtyEnv(tmpDir) { const binDir = path.join(tmpDir, 'fake-bin'); const markerFile = path.join(tmpDir, 'tty-invoked.log'); const inheritedPath = process.env.PATH || process.env.Path || ''; fs.mkdirSync(binDir, { recursive: true }); fs.writeFileSync( path.join(binDir, 'tty'), '#!/bin/sh\nif [ -n "$MSD_TTY_MARKER" ]; then printf "tty\\n" >> "$MSD_TTY_MARKER"; fi\nexit 99\n', 'utf-8' ); fs.chmodSync(path.join(binDir, 'tty'), 0o755); fs.writeFileSync( path.join(binDir, 'tty.cmd'), '@echo off\r\nif not "%MSD_TTY_MARKER%"=="" echo tty>>"%MSD_TTY_MARKER%"\r\nexit /b 99\r\n', 'utf-8' ); return { markerFile, env: { PATH: `${binDir}${path.delimiter}${inheritedPath}`, MSD_TTY_MARKER: markerFile, }, }; } function getSessionPointerDir(tmpDir) { const planningPath = fs.realpathSync.native(path.join(tmpDir, '.planning')); const projectId = crypto .createHash('sha1') .update(planningPath) .digest('hex') .slice(0, 16); return path.join(os.tmpdir(), 'msd-workstream-sessions', projectId); } function sanitizeSessionToken(value) { const token = String(value).trim().replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, ''); return token ? token.slice(0, 160) : null; } function getSessionPointerFileName(envKey, value) { const token = sanitizeSessionToken(value); return `${envKey.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${token}`; } // ─── planningDir / planningPaths env-var awareness ────────────────────────── describe('planningDir workstream awareness via env var', () => { let tmpDir; before(() => { tmpDir = createFixture(); seedWorkstream(tmpDir, { name: 'alpha', state: '# State\n**Status:** In progress\n**Current Phase:** 1\n', roadmap: '## Roadmap v1.0: Alpha\n### Phase 1: Setup\n', active: true, }); }); after(() => cleanup(tmpDir)); test('state json returns workstream-scoped state when MSD_WORKSTREAM is set', () => { const result = runMsdTools(['state', 'json', '--raw'], tmpDir, { MSD_WORKSTREAM: 'alpha' }); assert.ok(result.success, `state json failed: ${result.error}`); const data = JSON.parse(result.output); assert.ok(data.status || data.current_phase !== undefined, 'should return state data'); }); test('state json reads from flat .planning when no workstream set', () => { // Clear active-workstream so no auto-detection try { fs.unlinkSync(path.join(tmpDir, '.planning', 'active-workstream')); } catch {} const result = runMsdTools(['state', 'json', '--raw'], tmpDir, { MSD_WORKSTREAM: '' }); // Should fail or return empty state since flat .planning/ has no STATE.md assert.ok(!result.success || result.output.includes('not found') || result.output === '{}', 'should read from flat .planning/'); // Restore fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'alpha\n'); }); test('--ws flag overrides MSD_WORKSTREAM env var', () => { // Create a second workstream const betaDir = path.join(tmpDir, '.planning', 'workstreams', 'beta'); fs.mkdirSync(path.join(betaDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(betaDir, 'STATE.md'), '# State\n**Status:** Beta active\n'); const result = runMsdTools(['state', 'json', '--raw', '--ws', 'beta'], tmpDir, { MSD_WORKSTREAM: 'alpha' }); assert.ok(result.success, `state json --ws beta failed: ${result.error}`); }); }); describe('session-scoped active workstream routing', () => { let tmpDir; before(() => { tmpDir = createFixture(); for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** ${status}\n`); } }); after(() => cleanup(tmpDir)); test('stores active workstream per session instead of mutating shared pointer', () => { const alphaSet = runMsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); const betaSet = runMsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-beta' }); assert.ok(alphaSet.success, `alpha set failed: ${alphaSet.error}`); assert.ok(betaSet.success, `beta set failed: ${betaSet.error}`); assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'active-workstream')), 'shared active-workstream file should not be used when session keys are available'); }); test('different sessions resolve different active workstreams without --ws', () => { const alpha = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); const beta = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-beta' }); assert.ok(alpha.success, `alpha get failed: ${alpha.error}`); assert.ok(beta.success, `beta get failed: ${beta.error}`); assert.strictEqual(alpha.output, 'alpha'); assert.strictEqual(beta.output, 'beta'); }); test('session-scoped pointer ignores legacy shared active-workstream file', () => { fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'beta\n'); const alpha = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); const shared = runMsdTools(['workstream', 'get', '--raw'], tmpDir); assert.ok(alpha.success, `session-scoped get failed: ${alpha.error}`); assert.ok(shared.success, `legacy get failed: ${shared.error}`); assert.strictEqual(alpha.output, 'alpha'); assert.strictEqual(shared.output, 'beta'); }); test('state commands route to the session-scoped workstream automatically', () => { const alpha = runMsdTools(['state', 'json', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); const beta = runMsdTools(['state', 'json', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-beta' }); assert.ok(alpha.success, `alpha state failed: ${alpha.error}`); assert.ok(beta.success, `beta state failed: ${beta.error}`); const alphaState = JSON.parse(alpha.output); const betaState = JSON.parse(beta.output); assert.strictEqual(alphaState.status, 'Alpha active'); assert.strictEqual(betaState.status, 'Beta active'); }); test('clearing one session does not clear another session pointer', () => { // A prior test in this shared-tmpDir block ('session-scoped pointer ignores // legacy shared active-workstream file') writes 'beta' into the shared // .planning/active-workstream marker. Under #3579 semantics, a cleared // session INHERITS that marker instead of resolving flat — so leaving the // marker in place here would make this test assert marker-inheritance // behavior it never intended to cover (that is pinned separately below). // This test's real intent is sibling-pointer isolation, not inheritance, // so remove the marker to make alpha's post-clear resolution independent // of whatever an earlier test in this block left on disk. try { fs.unlinkSync(path.join(tmpDir, '.planning', 'active-workstream')); } catch {} const clearAlpha = runMsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); const alpha = runMsdTools(['workstream', 'get'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); const beta = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-beta' }); assert.ok(clearAlpha.success, `clear alpha failed: ${clearAlpha.error}`); assert.ok(alpha.success, `alpha get after clear failed: ${alpha.error}`); assert.ok(beta.success, `beta get after alpha clear failed: ${beta.error}`); const cleared = JSON.parse(alpha.output); assert.strictEqual(cleared.active, null); assert.strictEqual(beta.output, 'beta'); }); }); describe('session resolution hardening', () => { let tmpDir; beforeEach(() => { tmpDir = createFixture(); for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** ${status}\n`); } }); afterEach(() => cleanup(tmpDir)); test('headless runs skip tty probing and use the shared active-workstream fallback', () => { const { markerFile, env } = createFailingTtyEnv(tmpDir); const set = runMsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, env); const get = runMsdTools(['workstream', 'get', '--raw'], tmpDir, env); assert.ok(set.success, `headless set failed: ${set.error}`); assert.ok(get.success, `headless get failed: ${get.error}`); assert.ok(!fs.existsSync(markerFile), 'headless fallback should not invoke the tty subprocess'); assert.strictEqual(get.output, 'alpha'); assert.strictEqual( fs.readFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'utf-8').trim(), 'alpha' ); assert.ok(!fs.existsSync(getSessionPointerDir(tmpDir)), 'headless fallback should not create session tmp pointers'); }); test('explicit runtime session ids outrank tty-derived identities', () => { const set = runMsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { MSD_SESSION_KEY: 'shared-session', TTY: '/dev/pts/42', }); const get = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'shared-session', TTY: '/dev/pts/99', }); assert.ok(set.success, `session-key set failed: ${set.error}`); assert.ok(get.success, `session-key get failed: ${get.error}`); assert.strictEqual(get.output, 'alpha'); assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'active-workstream'))); }); test('TTY environment variables provide a session-scoped pointer without spawning tty', () => { const { markerFile, env } = createFailingTtyEnv(tmpDir); const ttyEnv = { ...env, TTY: '/dev/pts/42' }; const set = runMsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, ttyEnv); const get = runMsdTools(['workstream', 'get', '--raw'], tmpDir, ttyEnv); assert.ok(set.success, `TTY set failed: ${set.error}`); assert.ok(get.success, `TTY get failed: ${get.error}`); assert.ok(!fs.existsSync(markerFile), 'TTY env should be used directly without invoking the tty subprocess'); assert.strictEqual(get.output, 'beta'); assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'active-workstream'))); }); }); // #3579: a session that has an identity (session key) but has never run // `workstream use` must inherit the repo-wide `.planning/active-workstream` // marker instead of resolving to nothing. peekActiveWorkstream has no CLI // surface (only the statusline hook calls it), so these tests require the // built module directly — same pattern as the planning-workspace.cjs require // used elsewhere in this file. describe('session inherits shared marker when pointer-less (#3579)', () => { let tmpDir; const { getActiveWorkstream, peekActiveWorkstream, } = require('../msd-core/bin/lib/active-workstream-store.cjs'); beforeEach(() => { tmpDir = createFixture(); for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** ${status}\n`); } }); afterEach(() => cleanup(tmpDir)); test('session key present, no session pointer, marker names an existing workstream -> inherits the marker', () => { fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'alpha\n'); const result = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'no-pointer-session' }); assert.ok(result.success, `get failed: ${result.error}`); assert.strictEqual(result.output, 'alpha'); }); test('peekActiveWorkstream inherits the marker and mutates nothing', (t) => { const markerPath = path.join(tmpDir, '.planning', 'active-workstream'); fs.writeFileSync(markerPath, 'alpha\n'); const sessionDir = getSessionPointerDir(tmpDir); const savedSessionKey = process.env.MSD_SESSION_KEY; process.env.MSD_SESSION_KEY = 'peek-no-pointer'; t.after(() => { if (savedSessionKey !== undefined) process.env.MSD_SESSION_KEY = savedSessionKey; else delete process.env.MSD_SESSION_KEY; }); const resolved = peekActiveWorkstream(tmpDir); assert.strictEqual(resolved, 'alpha'); assert.strictEqual(fs.readFileSync(markerPath, 'utf-8'), 'alpha\n', 'peek must not mutate the shared marker'); assert.ok(!fs.existsSync(sessionDir), 'peek must not create a session pointer file'); }); test('session pointer names beta while marker names alpha -> resolves beta, marker untouched (isolation)', () => { const markerPath = path.join(tmpDir, '.planning', 'active-workstream'); fs.writeFileSync(markerPath, 'alpha\n'); runMsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, { MSD_SESSION_KEY: 'has-own-pointer' }); const result = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'has-own-pointer' }); assert.ok(result.success, `get failed: ${result.error}`); assert.strictEqual(result.output, 'beta'); assert.strictEqual(fs.readFileSync(markerPath, 'utf-8'), 'alpha\n', 'session with its own pointer must never repoint the shared marker'); }); test('workstream set --clear with a valid repo marker present -> session returns to inheriting the marker, not flat', () => { const markerPath = path.join(tmpDir, '.planning', 'active-workstream'); fs.writeFileSync(markerPath, 'alpha\n'); runMsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, { MSD_SESSION_KEY: 'clear-then-inherit-session' }); const clear = runMsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir, { MSD_SESSION_KEY: 'clear-then-inherit-session' }); const result = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'clear-then-inherit-session' }); assert.ok(clear.success, `clear failed: ${clear.error}`); assert.ok(result.success, `get after clear failed: ${result.error}`); assert.strictEqual( result.output, 'alpha', 'clearing a session pointer must fall back to inheriting the shared marker (step 4), not resolve to null/flat', ); }); test('no session key, marker present -> resolves the marker (pre-existing path stays green)', () => { fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'alpha\n'); const result = runMsdTools(['workstream', 'get', '--raw'], tmpDir, {}); assert.ok(result.success, `get failed: ${result.error}`); assert.strictEqual(result.output, 'alpha'); }); test('no workstreams/ dir -> flat/null, unchanged', (t) => { // createFixture() only creates .planning/phases, not .planning/workstreams. const flatDir = createFixture(); t.after(() => cleanup(flatDir)); const savedSessionKey = process.env.MSD_SESSION_KEY; process.env.MSD_SESSION_KEY = 'flat-no-workstreams-dir'; t.after(() => { if (savedSessionKey !== undefined) process.env.MSD_SESSION_KEY = savedSessionKey; else delete process.env.MSD_SESSION_KEY; }); assert.strictEqual(getActiveWorkstream(flatDir), null); assert.strictEqual(peekActiveWorkstream(flatDir), null); }); test('marker names a non-existent workstream, session has no pointer -> resolves null and the marker file is NOT deleted', () => { const markerPath = path.join(tmpDir, '.planning', 'active-workstream'); fs.writeFileSync(markerPath, 'ghost-workstream\n'); const result = runMsdTools(['workstream', 'get'], tmpDir, { MSD_SESSION_KEY: 'stale-marker-session' }); assert.ok(result.success, `get failed: ${result.error}`); assert.strictEqual(JSON.parse(result.output).active, null); assert.ok(fs.existsSync(markerPath), 'a pointer-less session read must never delete the shared marker'); assert.strictEqual(fs.readFileSync(markerPath, 'utf-8'), 'ghost-workstream\n'); }); test('session pointer file exists but is whitespace-only -> treated as absent, inherits the marker', () => { const markerPath = path.join(tmpDir, '.planning', 'active-workstream'); fs.writeFileSync(markerPath, 'beta\n'); runMsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { MSD_SESSION_KEY: 'whitespace-pointer-session' }); const sessionDir = getSessionPointerDir(tmpDir); const sessionFile = getSessionPointerFileName('MSD_SESSION_KEY', 'whitespace-pointer-session'); fs.writeFileSync(path.join(sessionDir, sessionFile), ' \n'); const result = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'whitespace-pointer-session' }); assert.ok(result.success, `get failed: ${result.error}`); assert.strictEqual(result.output, 'beta'); }); test('shared marker is whitespace-only, session pointer-less -> resolves null', () => { const markerPath = path.join(tmpDir, '.planning', 'active-workstream'); fs.writeFileSync(markerPath, ' \n'); const result = runMsdTools(['workstream', 'get'], tmpDir, { MSD_SESSION_KEY: 'whitespace-marker-session' }); assert.ok(result.success, `get failed: ${result.error}`); assert.strictEqual(JSON.parse(result.output).active, null); }); test('shared marker file is empty, session pointer-less -> resolves null', () => { const markerPath = path.join(tmpDir, '.planning', 'active-workstream'); fs.writeFileSync(markerPath, ''); const result = runMsdTools(['workstream', 'get'], tmpDir, { MSD_SESSION_KEY: 'empty-marker-session' }); assert.ok(result.success, `get failed: ${result.error}`); assert.strictEqual(JSON.parse(result.output).active, null); }); test('session pointer is stale (its workstream is deleted) while marker names a different, still-valid workstream -> resolves null, never falls through to inherit the marker', () => { const markerPath = path.join(tmpDir, '.planning', 'active-workstream'); fs.writeFileSync(markerPath, 'beta\n'); runMsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { MSD_SESSION_KEY: 'stale-own-pointer-session' }); // eslint-disable-next-line local/no-raw-rmsync-in-tests -- mid-test fault injection: simulates a deleted workstream to exercise stale-pointer self-cleanup fs.rmSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true, force: true }); const result = runMsdTools(['workstream', 'get'], tmpDir, { MSD_SESSION_KEY: 'stale-own-pointer-session' }); assert.ok(result.success, `get failed: ${result.error}`); assert.strictEqual( JSON.parse(result.output).active, null, 'a stale OWNED pointer must self-heal to null — it must not fall through to the shared marker just because it failed to resolve', ); assert.strictEqual(fs.readFileSync(markerPath, 'utf-8'), 'beta\n', 'the shared marker must remain untouched'); }); // #3579 item 1: the #1912/#2028 workstream-mode fail-safe guards must // distinguish "no marker/pointer anywhere" from "a marker exists but did // not resolve" — asserted structurally (via --json-errors) per CONTRIBUTING, // not by regexing the human-readable message. test('init.progress fail-safe guard: no marker at all -> reason workstream_mode_none_active', () => { const result = runMsdTools(['--json-errors', 'init', 'progress'], tmpDir); assert.equal(result.success, false, 'must still refuse'); const payload = JSON.parse(result.error); assert.equal(payload.reason, 'workstream_mode_none_active'); }); test('init.progress fail-safe guard: marker present but names a missing workstream dir -> reason workstream_mode_marker_unresolved, distinct message', () => { const noneResult = runMsdTools(['--json-errors', 'init', 'progress'], tmpDir); const nonePayload = JSON.parse(noneResult.error); fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'ghost-workstream\n'); const unresolvedResult = runMsdTools(['--json-errors', 'init', 'progress'], tmpDir); assert.equal(unresolvedResult.success, false, 'must still refuse'); const unresolvedPayload = JSON.parse(unresolvedResult.error); assert.equal(unresolvedPayload.reason, 'workstream_mode_marker_unresolved'); assert.equal(unresolvedPayload.marker_value, 'ghost-workstream'); assert.equal(unresolvedPayload.marker_reason, 'missing_workstream_dir'); assert.notEqual( unresolvedPayload.message, nonePayload.message, 'the marker-present-but-unresolved diagnostic must differ from the no-marker-at-all diagnostic', ); }); test('init.progress fail-safe guard: marker present but names an invalid workstream name -> reason workstream_mode_marker_unresolved, invalid_name', () => { fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'bad/name\n'); const result = runMsdTools(['--json-errors', 'init', 'progress'], tmpDir); assert.equal(result.success, false, 'must still refuse'); const payload = JSON.parse(result.error); assert.equal(payload.reason, 'workstream_mode_marker_unresolved'); assert.equal(payload.marker_value, 'bad/name'); assert.equal(payload.marker_reason, 'invalid_name'); }); }); describe('pointer lifecycle hardening', () => { let tmpDir; beforeEach(() => { tmpDir = createFixture(); for (const [ws, status] of [['alpha', 'Alpha active'], ['beta', 'Beta active']]) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** ${status}\n`); } }); afterEach(() => cleanup(tmpDir)); test('clearing one session pointer leaves sibling session pointers intact', () => { const sessionDir = getSessionPointerDir(tmpDir); const alphaFile = getSessionPointerFileName('MSD_SESSION_KEY', 'session-alpha'); const betaFile = getSessionPointerFileName('MSD_SESSION_KEY', 'session-beta'); runMsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); runMsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-beta' }); const clearAlpha = runMsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); const beta = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-beta' }); assert.ok(clearAlpha.success, `clear alpha failed: ${clearAlpha.error}`); assert.ok(beta.success, `beta get failed: ${beta.error}`); assert.strictEqual(beta.output, 'beta'); assert.ok(fs.existsSync(sessionDir), 'session tmp directory should remain while a sibling pointer exists'); assert.deepStrictEqual(fs.readdirSync(sessionDir).sort(), [betaFile]); assert.ok(!fs.existsSync(path.join(sessionDir, alphaFile))); }); test('stale pointers self-clean without deleting sibling session pointers', () => { const sessionDir = getSessionPointerDir(tmpDir); const betaFile = getSessionPointerFileName('MSD_SESSION_KEY', 'session-beta'); runMsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); runMsdTools(['workstream', 'set', 'beta', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-beta' }); // eslint-disable-next-line local/no-raw-rmsync-in-tests -- mid-test fault injection: simulates a deleted workstream to exercise stale-pointer self-cleanup fs.rmSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true, force: true }); const alpha = runMsdTools(['workstream', 'get'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); const beta = runMsdTools(['workstream', 'get', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-beta' }); assert.ok(alpha.success, `stale alpha get failed: ${alpha.error}`); assert.ok(beta.success, `beta get after stale cleanup failed: ${beta.error}`); assert.strictEqual(JSON.parse(alpha.output).active, null); assert.strictEqual(beta.output, 'beta'); assert.ok(fs.existsSync(sessionDir), 'sibling pointer should keep the session tmp directory alive'); assert.deepStrictEqual(fs.readdirSync(sessionDir).sort(), [betaFile]); }); test('clearing the last session pointer removes the empty session tmp directory', () => { const sessionDir = getSessionPointerDir(tmpDir); const set = runMsdTools(['workstream', 'set', 'alpha', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); assert.ok(set.success, `set alpha failed: ${set.error}`); assert.ok(fs.existsSync(sessionDir), 'session tmp directory should exist after storing a session-scoped pointer'); const clear = runMsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir, { MSD_SESSION_KEY: 'session-alpha' }); assert.ok(clear.success, `clear alpha failed: ${clear.error}`); assert.ok(!fs.existsSync(sessionDir), 'last-pointer cleanup should remove the empty session tmp directory'); }); }); // ─── Workstream CRUD ──────────────────────────────────────────────────────── describe('workstream create', () => { let tmpDir; before(() => { tmpDir = createFixture(); fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); }); after(() => cleanup(tmpDir)); test('creates a new workstream in clean project', () => { const result = runMsdTools(['workstream', 'create', 'feature-x', '--raw'], tmpDir); assert.ok(result.success, `create failed: ${result.error}`); const data = JSON.parse(result.output); assert.strictEqual(data.created, true); assert.strictEqual(data.workstream, 'feature-x'); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-x', 'STATE.md'))); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-x', 'phases'))); }); test('sets created workstream as active', () => { const active = fs.readFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'utf-8').trim(); assert.strictEqual(active, 'feature-x'); }); test('rejects duplicate workstream', () => { const result = runMsdTools(['workstream', 'create', 'feature-x', '--raw'], tmpDir); assert.ok(result.success); // returns success with error field const data = JSON.parse(result.output); assert.strictEqual(data.created, false); assert.strictEqual(data.error, 'already_exists'); }); test('creates second workstream', () => { const result = runMsdTools(['workstream', 'create', 'feature-y', '--raw'], tmpDir); assert.ok(result.success); const data = JSON.parse(result.output); assert.strictEqual(data.created, true); assert.strictEqual(data.workstream, 'feature-y'); }); }); describe('workstream create with migration', () => { let tmpDir; before(() => { tmpDir = createFixture(); fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); // Existing flat-mode work fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '## Roadmap v1.0: Existing\n### Phase 1: A\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n**Status:** In progress\n'); }); after(() => cleanup(tmpDir)); test('migrates existing flat work to named workstream', () => { const result = runMsdTools(['workstream', 'create', 'new-feature', '--migrate-name', 'existing-work', '--raw'], tmpDir); assert.ok(result.success, `create with migration failed: ${result.error}`); const data = JSON.parse(result.output); assert.strictEqual(data.created, true); assert.ok(data.migration, 'should include migration info'); assert.strictEqual(data.migration.workstream, 'existing-work'); // Old flat files moved to workstream dir assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'existing-work', 'ROADMAP.md'))); assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'existing-work', 'STATE.md'))); // Shared files stay assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'PROJECT.md'))); }); test('normalizes --migrate-name to a valid workstream slug', () => { const isolatedDir = createFixture(); try { fs.writeFileSync(path.join(isolatedDir, '.planning', 'PROJECT.md'), '# Project\n'); fs.writeFileSync(path.join(isolatedDir, '.planning', 'ROADMAP.md'), '## Roadmap v1.0: Existing\n### Phase 1: A\n'); fs.writeFileSync(path.join(isolatedDir, '.planning', 'STATE.md'), '# State\n**Status:** In progress\n'); const result = runMsdTools( ['workstream', 'create', 'new-feature', '--migrate-name', 'Bad Name', '--raw'], isolatedDir ); assert.ok(result.success, `create with migrate-name normalization failed: ${result.error}`); const data = JSON.parse(result.output); assert.strictEqual(data.created, true); assert.strictEqual(data.migration.workstream, 'bad-name'); assert.ok(fs.existsSync(path.join(isolatedDir, '.planning', 'workstreams', 'bad-name', 'ROADMAP.md'))); assert.ok(!fs.existsSync(path.join(isolatedDir, '.planning', 'workstreams', 'Bad Name'))); } finally { cleanup(isolatedDir); } }); }); describe('migrateToWorkstreams', () => { test('rejects invalid workstream names for migration', () => { const tmpDir = createFixture(); try { assert.throws( () => migrateToWorkstreams(tmpDir, 'bad/name'), /Invalid workstream name for migration/ ); } finally { cleanup(tmpDir); } }); test('fails when already in workstream mode', () => { const tmpDir = createFixture(); try { fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'existing'), { recursive: true }); assert.throws( () => migrateToWorkstreams(tmpDir, 'new-stream'), /Already in workstream mode/ ); } finally { cleanup(tmpDir); } }); }); describe('workstream list', () => { let tmpDir; before(() => { tmpDir = createFixture(); // Create two workstreams for (const ws of ['alpha', 'beta']) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'STATE.md'), `# State\n**Status:** Working on ${ws}\n**Current Phase:** 1\n`); } fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'beta\n'); }); after(() => cleanup(tmpDir)); test('lists all workstreams with active first, then lexical name', () => { const result = runMsdTools(['workstream', 'list', '--raw'], tmpDir); assert.ok(result.success, `list failed: ${result.error}`); const data = JSON.parse(result.output); assert.strictEqual(data.mode, 'workstream'); assert.strictEqual(data.count, 2); assert.deepStrictEqual(data.workstreams.map(w => w.name), ['beta', 'alpha']); }); describe('flat mode', () => { let flatDir; beforeEach(() => { flatDir = createFixture(); }); afterEach(() => { cleanup(flatDir); }); test('reports flat mode when no workstreams exist', () => { const result = runMsdTools(['workstream', 'list', '--raw'], flatDir); assert.ok(result.success); const data = JSON.parse(result.output); assert.strictEqual(data.mode, 'flat'); }); }); }); describe('workstream status', () => { let tmpDir; before(() => { tmpDir = createFixture(); const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha'); fs.mkdirSync(path.join(wsDir, 'phases', '01-setup'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'phases', '01-setup', 'PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 1 — Setup\n'); fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '## Roadmap\n'); }); after(() => cleanup(tmpDir)); test('returns detailed status for workstream', () => { const result = runMsdTools(['workstream', 'status', 'alpha', '--raw'], tmpDir); assert.ok(result.success, `status failed: ${result.error}`); const data = JSON.parse(result.output); assert.strictEqual(data.found, true); assert.strictEqual(data.workstream, 'alpha'); assert.strictEqual(data.files.roadmap, true); assert.strictEqual(data.files.state, true); assert.strictEqual(data.phase_count, 1); }); test('returns not found for missing workstream', () => { const result = runMsdTools(['workstream', 'status', 'nonexistent', '--raw'], tmpDir); assert.ok(result.success); const data = JSON.parse(result.output); assert.strictEqual(data.found, false); }); }); describe('workstream complete', () => { let tmpDir; before(() => { tmpDir = createFixture(); const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'done-ws'); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** Complete\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'done-ws\n'); }); after(() => cleanup(tmpDir)); test('archives workstream to milestones/', () => { const result = runMsdTools(['workstream', 'complete', 'done-ws', '--raw'], tmpDir); assert.ok(result.success, `complete failed: ${result.error}`); const data = JSON.parse(result.output); assert.strictEqual(data.completed, true); assert.ok(data.archived_to.startsWith('.planning/milestones/ws-done-ws')); // Workstream dir should be gone assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'done-ws'))); }); test('clears active-workstream when completing active one', () => { assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'active-workstream'))); }); }); describe('workstream set/get', () => { let tmpDir; before(() => { tmpDir = createFixture(); for (const ws of ['ws-a', 'ws-b']) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n'); } }); after(() => cleanup(tmpDir)); test('sets active workstream', () => { const result = runMsdTools(['workstream', 'set', 'ws-a', '--raw'], tmpDir); assert.ok(result.success); assert.strictEqual(result.output, 'ws-a'); }); test('gets active workstream', () => { const result = runMsdTools(['workstream', 'get', '--raw'], tmpDir); assert.ok(result.success); assert.strictEqual(result.output, 'ws-a'); }); test('errors when set called with no name (#1527)', () => { const result = runMsdTools(['workstream', 'set', '--raw'], tmpDir); assert.ok(!result.success, 'should fail when no name provided'); assert.ok(result.error.includes('name required'), 'error should mention name required'); }); test('--clear explicitly unsets active workstream', () => { // First set one runMsdTools(['workstream', 'set', 'ws-b', '--raw'], tmpDir); // Then clear const result = runMsdTools(['workstream', 'set', '--clear', '--raw'], tmpDir); assert.ok(result.success); const data = JSON.parse(result.output); assert.strictEqual(data.active, null); assert.strictEqual(data.cleared, true); assert.strictEqual(data.previous, 'ws-b'); }); }); // ─── Collision Detection ──────────────────────────────────────────────────── describe('getOtherActiveWorkstreams', () => { let tmpDir; before(() => { tmpDir = createFixture(); // Create 3 workstreams: alpha (active), beta (active), gamma (completed) for (const ws of ['alpha', 'beta', 'gamma']) { const wsDir = path.join(tmpDir, '.planning', 'workstreams', ws); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); } fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 3\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'beta', 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 5\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'workstreams', 'gamma', 'STATE.md'), '# State\n**Status:** 1.0 milestone complete\n'); }); after(() => cleanup(tmpDir)); test('workstream list excludes completed workstreams from active count', () => { const result = runMsdTools(['workstream', 'list', '--raw'], tmpDir); assert.ok(result.success); const data = JSON.parse(result.output); assert.strictEqual(data.count, 3); // all listed const activeWs = data.workstreams.filter(w => !w.status.toLowerCase().includes('milestone complete')); assert.strictEqual(activeWs.length, 2); // alpha and beta active }); test('returns only non-complete siblings with phase progress metadata', () => { const alphaPlan = path.join(tmpDir, '.planning', 'workstreams', 'alpha', 'phases', '01-alpha', 'PLAN.md'); const betaPlan = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases', '01-beta', 'PLAN.md'); const betaSummary = path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases', '01-beta', 'SUMMARY.md'); fs.mkdirSync(path.dirname(alphaPlan), { recursive: true }); fs.mkdirSync(path.dirname(betaPlan), { recursive: true }); fs.writeFileSync(alphaPlan, '# Plan\n'); fs.writeFileSync(betaPlan, '# Plan\n'); fs.writeFileSync(betaSummary, '# Summary\n'); // Disk-strict completion (ADR-3180 §7.4, #3186): a passing // *-VERIFICATION.md is what makes beta's phase count as complete now. fs.writeFileSync( path.join(tmpDir, '.planning', 'workstreams', 'beta', 'phases', '01-beta', '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n', ); const others = getOtherActiveWorkstreams(tmpDir, 'alpha'); assert.strictEqual(others.length, 1); assert.strictEqual(others[0].name, 'beta'); assert.strictEqual(others[0].phases, '1/1'); }); }); describe('workstream progress', () => { let tmpDir; before(() => { tmpDir = createFixture(); const alphaDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha'); fs.mkdirSync(path.join(alphaDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(alphaDir, 'STATE.md'), '# State\n**Status:** In progress\n'); fs.writeFileSync(path.join(alphaDir, 'ROADMAP.md'), '## Roadmap\n'); const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'feature'); fs.mkdirSync(path.join(wsDir, 'phases', '01-init'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'phases', '01-init', 'PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(wsDir, 'phases', '01-init', 'SUMMARY.md'), '# Summary\n'); // Disk-strict completion (ADR-3180 §7.4, #3186): a passing // *-VERIFICATION.md is what makes phase 01 count as complete now. fs.writeFileSync(path.join(wsDir, 'phases', '01-init', '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n'); fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n**Current Phase:** 2\n'); fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '## Roadmap\n### Phase 1: Init\n### Phase 2: Build\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'feature\n'); }); after(() => cleanup(tmpDir)); test('returns progress summary in deterministic order', () => { const result = runMsdTools(['workstream', 'progress', '--raw'], tmpDir); assert.ok(result.success, `progress failed: ${result.error}`); const data = JSON.parse(result.output); assert.strictEqual(data.mode, 'workstream'); assert.strictEqual(data.count, 2); assert.deepStrictEqual(data.workstreams.map(w => w.name), ['feature', 'alpha']); assert.strictEqual(data.workstreams[0].name, 'feature'); assert.strictEqual(data.workstreams[0].active, true); assert.strictEqual(data.workstreams[0].progress_percent, 50); }); test('clamps progress percent when completed phase dirs exceed roadmap count', () => { const isolatedDir = createFixture(); try { const wsDir = path.join(isolatedDir, '.planning', 'workstreams', 'overflow'); for (const phase of ['01-one', '02-two']) { const phaseDir = path.join(wsDir, 'phases', phase); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(phaseDir, 'SUMMARY.md'), '# Summary\n'); // Disk-strict completion (ADR-3180 §7.4, #3186): a passing // *-VERIFICATION.md is what makes each phase count as complete now. fs.writeFileSync(path.join(phaseDir, `${phase.slice(0, 2)}-VERIFICATION.md`), '---\nstatus: passed\n---\n# Verification\n'); } fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n**Status:** In progress\n'); fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '# Roadmap\n### Phase 1: One\n'); const result = runMsdTools(['workstream', 'progress', '--raw'], isolatedDir); assert.ok(result.success, `progress failed: ${result.error}`); const data = JSON.parse(result.output); assert.strictEqual(data.workstreams[0].progress_percent, 100); } finally { cleanup(isolatedDir); } }); test('returns flat mode when no workstreams exist', () => { const emptyDir = createFixture(); try { const result = runMsdTools(['workstream', 'progress', '--raw'], emptyDir); assert.ok(result.success, `progress in flat mode failed: ${result.error}`); const data = JSON.parse(result.output); assert.strictEqual(data.mode, 'flat'); } finally { cleanup(emptyDir); } }); }); // ─── Integration: msd-tools --ws flag ──────────────────────────────────────── describe('msd-tools --ws flag integration', () => { let tmpDir; before(() => { tmpDir = createFixture(); // Create a workstream with roadmap const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'test-ws'); fs.mkdirSync(path.join(wsDir, 'phases', '01-setup'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), '## Roadmap v1.0: Test\n### Phase 1: Setup\nDo setup things.\n'); fs.writeFileSync(path.join(wsDir, 'STATE.md'), '---\nmilestone: v1.0\n---\n# State\n**Status:** In progress\n**Current Phase:** 1 — Setup\n'); fs.writeFileSync(path.join(wsDir, 'phases', '01-setup', 'PLAN.md'), '# Plan\n'); }); after(() => cleanup(tmpDir)); test('find-phase resolves to workstream-scoped phases via --ws', () => { const result = runMsdTools(['find-phase', '1', '--raw', '--ws', 'test-ws'], tmpDir); assert.ok(result.success, `find-phase failed: ${result.error}`); assert.ok(result.output.includes('workstreams/test-ws'), `path should be workstream-scoped: ${result.output}`); }); test('find-phase returns JSON with workstream path when not raw', () => { const result = runMsdTools(['find-phase', '1', '--ws', 'test-ws'], tmpDir); assert.ok(result.success, `find-phase failed: ${result.error}`); const data = JSON.parse(result.output); assert.ok(data.found, 'phase should be found'); assert.ok(data.directory.includes('workstreams/test-ws'), `path should be workstream-scoped: ${data.directory}`); }); }); // ─── Path Traversal Rejection ──────────────────────────────────────────────── describe('path traversal rejection', () => { let tmpDir; before(() => { tmpDir = createFixture(); fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), '# Project\n'); const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'legit'); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'STATE.md'), '# State\n'); }); after(() => cleanup(tmpDir)); const maliciousNames = [ '../../etc', '../foo', 'ws/../../../passwd', 'a/b', 'ws name with spaces', '..', '.', 'ws..traversal', ]; describe('--ws flag rejects traversal attempts', () => { for (const name of maliciousNames) { test(`rejects --ws=${name}`, () => { const result = runMsdTools(['workstream', 'list', '--raw', '--ws', name], tmpDir); assert.ok(!result.success, `should reject --ws=${name}`); assert.ok(result.error.includes('Invalid workstream name'), `error should mention invalid name for: ${name}`); }); } }); describe('MSD_WORKSTREAM env var rejects traversal attempts', () => { for (const name of maliciousNames) { test(`rejects MSD_WORKSTREAM=${name}`, () => { const result = runMsdTools(['workstream', 'list', '--raw'], tmpDir, { MSD_WORKSTREAM: name }); assert.ok(!result.success, `should reject MSD_WORKSTREAM=${name}`); assert.ok(result.error.includes('Invalid workstream name'), `error should mention invalid name for: ${name}`); }); } }); describe('cmdWorkstreamSet rejects traversal attempts', () => { for (const name of maliciousNames) { test(`rejects set ${name}`, () => { const result = runMsdTools(['workstream', 'set', name, '--raw'], tmpDir); // cmdWorkstreamSet validates the positional arg and returns invalid_name error assert.ok(result.success, `command should exit cleanly for: ${name}`); const data = JSON.parse(result.output); assert.strictEqual(data.error, 'invalid_name', `should return invalid_name error for: ${name}`); assert.strictEqual(data.active, null, `active should be null for: ${name}`); }); } }); describe('cmdWorkstreamStatus rejects invalid names consistently', () => { for (const name of maliciousNames) { test(`rejects status ${name}`, () => { const result = runMsdTools(['workstream', 'status', name, '--raw'], tmpDir); assert.ok(!result.success, `status should reject invalid name: ${name}`); assert.ok(result.error.includes('Invalid workstream name'), `error should mention invalid name for: ${name}`); }); } }); describe('cmdWorkstreamComplete rejects invalid names consistently', () => { for (const name of maliciousNames) { test(`rejects complete ${name}`, () => { const result = runMsdTools(['workstream', 'complete', name, '--raw'], tmpDir); assert.ok(!result.success, `complete should reject invalid name: ${name}`); assert.ok(result.error.includes('Invalid workstream name'), `error should mention invalid name for: ${name}`); }); } }); describe('getActiveWorkstream rejects poisoned active-workstream file', () => { for (const name of maliciousNames) { test(`rejects poisoned file containing ${name}`, () => { // Write malicious name directly to the active-workstream file fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), name + '\n'); const result = runMsdTools(['workstream', 'get'], tmpDir, { MSD_WORKSTREAM: '' }); assert.ok(result.success, 'get should succeed'); const data = JSON.parse(result.output); // getActiveWorkstream should return null for invalid names assert.strictEqual(data.active, null, `should return null for poisoned name: ${name}`); }); } // Cleanup: remove poisoned file test('cleanup: remove active-workstream file', () => { try { fs.unlinkSync(path.join(tmpDir, '.planning', 'active-workstream')); } catch {} }); }); describe('setActiveWorkstream rejects invalid names directly', () => { const { setActiveWorkstream } = require('../msd-core/bin/lib/planning-workspace.cjs'); for (const name of maliciousNames) { test(`throws for ${name}`, () => { assert.throws( () => setActiveWorkstream(tmpDir, name), { message: /Invalid workstream name/ }, `should throw for: ${name}` ); }); } }); }); // #2562: the inventory can refuse a shipped marker its artifacts contradict, but // a refusal that no command projects is the same silent collapse the issue is // about — the operator sees a fallback `status` and nothing saying a marker was // seen and rejected. These assert at the CLI, the surface that was missing it, // not at the builder that already had the field. describe('#2562 — a refused shipped marker reaches every workstream command', () => { let tmpDir; before(() => { tmpDir = createFixture(); const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'dirty-archive'); // A milestone that was archived (snapshot present) and then reopened: phase 1 // is complete and still on disk, phase 2 is declared with no directory. The // archive is therefore not clean AND the ratio is short. fs.mkdirSync(path.join(wsDir, 'phases', '1-foo'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'phases', '1-foo', '01-PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(wsDir, 'phases', '1-foo', '01-SUMMARY.md'), '# Summary\n'); // Disk-strict completion (ADR-3180 §7.4, #3186): a passing // *-VERIFICATION.md is what makes phase 1 count as complete now. fs.writeFileSync(path.join(wsDir, 'phases', '1-foo', '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n'); fs.writeFileSync(path.join(wsDir, 'STATE.md'), 'milestone: v2.0\nstatus: executing\n'); fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), [ '# Roadmap', '', '## Milestone v2.0 — Two', '', '## Progress', '', '| Phase | Milestone | Plans | Status | Done |', '| --- | --- | --- | --- | --- |', '| 1. Foo | v2.0 | 1/1 | Complete | - |', '| 2. Bar | v2.0 | 0/1 | Not started | - |', '', ].join('\n')); fs.mkdirSync(path.join(wsDir, 'milestones'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'milestones', 'v2.0-ROADMAP.md'), '# v2.0 archived\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'active-workstream'), 'dirty-archive\n'); }); after(() => cleanup(tmpDir)); test('workstream progress projects the refusal beside the percentage', () => { const result = runMsdTools(['workstream', 'progress', '--raw'], tmpDir); assert.ok(result.success, `progress failed: ${result.error}`); const ws = JSON.parse(result.output).workstreams.find(w => w.name === 'dirty-archive'); assert.ok(ws, 'workstream missing from progress output'); assert.strictEqual(ws.milestone_shipped_unverified, true); assert.notStrictEqual(ws.status, 'milestone complete', 'status must not contradict the percentage'); assert.strictEqual(ws.progress_percent, 50); }); test('workstream status projects the refusal', () => { const result = runMsdTools(['workstream', 'status', 'dirty-archive', '--raw'], tmpDir); assert.ok(result.success, `status failed: ${result.error}`); const data = JSON.parse(result.output); assert.strictEqual(data.found, true); assert.strictEqual(data.milestone_shipped_unverified, true); }); test('workstream list projects the refusal', () => { const result = runMsdTools(['workstream', 'list', '--raw'], tmpDir); assert.ok(result.success, `list failed: ${result.error}`); const ws = JSON.parse(result.output).workstreams.find(w => w.name === 'dirty-archive'); assert.ok(ws, 'workstream missing from list output'); assert.strictEqual(ws.milestone_shipped_unverified, true); }); test('a clean archive reports no refusal at the CLI', () => { const isolatedDir = createFixture(); try { const wsDir = path.join(isolatedDir, '.planning', 'workstreams', 'clean-archive'); fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true }); // dirs moved out by the archive fs.writeFileSync(path.join(wsDir, 'STATE.md'), 'milestone: v2.0\nstatus: executing\n'); fs.writeFileSync(path.join(wsDir, 'ROADMAP.md'), [ '# Roadmap', '', '## Milestone v2.0 — Two', '', '## Progress', '', '| Phase | Milestone | Plans | Status | Done |', '| --- | --- | --- | --- | --- |', '| 1. Foo | v2.0 | 1/1 | Complete | - |', '| 2. Bar | v2.0 | 1/1 | Complete | - |', '', ].join('\n')); fs.mkdirSync(path.join(wsDir, 'milestones'), { recursive: true }); fs.writeFileSync(path.join(wsDir, 'milestones', 'v2.0-ROADMAP.md'), '# v2.0 archived\n'); const result = runMsdTools(['workstream', 'progress', '--raw'], isolatedDir); assert.ok(result.success, `progress failed: ${result.error}`); const ws = JSON.parse(result.output).workstreams.find(w => w.name === 'clean-archive'); assert.ok(ws, 'workstream missing from progress output'); assert.strictEqual(ws.milestone_shipped_unverified, false); assert.strictEqual(ws.status, 'milestone complete'); } finally { cleanup(isolatedDir); } }); });