diff --git a/.changeset/steady-wasps-roam.md b/.changeset/steady-wasps-roam.md new file mode 100644 index 000000000..e1fdc2083 --- /dev/null +++ b/.changeset/steady-wasps-roam.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3139 +--- +**Unusable `last_activity` now emits a diagnostic** — a present-but-unparseable `last_activity` in STATE.md silently suppressed the idle-stranded recommendation. The fallback (`stale_activity: false`) stays for continuity, but a `last_activity_unparseable` warning is now emitted so the degradation is visible. (#3099) diff --git a/src/smart-entry.cts b/src/smart-entry.cts index 50e2c358e..20a81c711 100644 --- a/src/smart-entry.cts +++ b/src/smart-entry.cts @@ -51,6 +51,9 @@ const { stateExtractField } = stateDocument; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseId = require('./phase-id.cjs'); const { comparePhaseNum, extractPhaseToken, normalizePhaseName, phaseTokenMatches } = phaseId; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import unusableInput = require('./unusable-input.cjs'); +const { warnUnusableInput, UNUSABLE_REASON } = unusableInput; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -355,7 +358,16 @@ export function detectSignals(cwd: string, now: () => number = Date.now): SmartE // Stale = no recorded activity for IDLE_STALE_MS. Used only by idle-stranded. // Computed here (with the clock seam) so the pure classify() stays a function // of (signals, staleActivity) and detectSignals owns all disk reads. + // #3099 (ADR-1411 amendment): if last_activity is present but unparseable, + // emit a diagnostic so the silent fallback (stale_activity: false) is visible. + // The fallback itself stays — continuity is correct, the silence was the defect. const lastActivityMs = parseActivityTimestamp(lastActivityRaw); + if (lastActivityRaw && lastActivityMs === null) { + warnUnusableInput({ + reason: UNUSABLE_REASON.LAST_ACTIVITY_UNPARSEABLE, + source: paths.state, + }); + } const staleActivity = lastActivityMs !== null && now() - lastActivityMs > IDLE_STALE_MS; // Verify-failed may be signalled either by STATE.md status or by a failed diff --git a/src/unusable-input.cts b/src/unusable-input.cts index 96c7f5af6..83206d385 100644 --- a/src/unusable-input.cts +++ b/src/unusable-input.cts @@ -49,6 +49,14 @@ const UNUSABLE_REASON = Object.freeze({ * simply has no ROADMAP yet: absence returns the same sentinel, silently. (#1881) */ ROADMAP_UNREADABLE: 'roadmap_unreadable', + /** + * A `last_activity` value in STATE.md frontmatter is present but unparseable as a date. + * Distinct from absent: absence means the field was never set, while an unparseable value + * means it was set to something Date.parse rejects. Per ADR-1411 amendment: corrupt is not + * absent — the fallback (stale_activity: false) stays, but a diagnostic is emitted so the + * silent degradation is visible. (#3099, sixth #1879 site) + */ + LAST_ACTIVITY_UNPARSEABLE: 'last_activity_unparseable', } as const); type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON]; @@ -59,6 +67,8 @@ const REASON_PROSE: Readonly> = Object.freeze({ 'frontmatter opens with "---" but never closes; metadata was NOT applied', [UNUSABLE_REASON.ROADMAP_UNREADABLE]: 'ROADMAP.md exists but could not be read; phase and milestone lookups fell back to defaults', + [UNUSABLE_REASON.LAST_ACTIVITY_UNPARSEABLE]: + 'last_activity in STATE.md is present but unparseable as a date; stale_activity fell back to false (idle-stranded suppressed)', }); // ─── Dedup state ────────────────────────────────────────────────────────────── diff --git a/tests/smart-entry.unit.test.cjs b/tests/smart-entry.unit.test.cjs index a76c228ad..2a54e3f2c 100644 --- a/tests/smart-entry.unit.test.cjs +++ b/tests/smart-entry.unit.test.cjs @@ -549,3 +549,89 @@ describe('#2427 — roadmap-grounded completion + tightened status regex', () => `legacy fallback must still reject completion when current_phase < total_phases. Got: ${result.situation}`); }); }); + +// --------------------------------------------------------------------------- +// #3099: unusable last_activity emits a diagnostic (ADR-1411 amendment: +// corrupt is not absent — the fallback stays, the silence is the defect) +// --------------------------------------------------------------------------- + +describe('#3099: unusable last_activity emits a diagnostic', () => { + const { + _resetUnusableInputWarningsForTests, + _unusableInputEmissionCountForTests, + } = require('../gsd-core/bin/lib/unusable-input.cjs'); + + function makeStateWithActivity(activity) { + return [ + '---', + 'status: executing', + `last_activity: ${activity}`, + '---', + '', + '# Project State', + '', + 'Phase: 1', + '', + ].join('\n'); + } + + test('unusable last_activity still resolves stale_activity: false (fallback unchanged)', () => { + _resetUnusableInputWarningsForTests(); + const dir = track(makeProject({ + state: makeStateWithActivity('yesterday - did some work'), + })); + const signals = detectSignals(dir); + assert.equal(signals.stale_activity, false, + 'unusable last_activity must still resolve stale_activity: false (continuity is correct)'); + }); + + test('unusable last_activity emits LAST_ACTIVITY_UNPARSEABLE diagnostic', () => { + _resetUnusableInputWarningsForTests(); + const dir = track(makeProject({ + state: makeStateWithActivity('not-a-date-at-all'), + })); + detectSignals(dir); + assert.equal(_unusableInputEmissionCountForTests(), 1, + 'unusable last_activity must emit exactly one diagnostic'); + }); + + test('absent last_activity emits nothing (distinguishable from unusable)', () => { + _resetUnusableInputWarningsForTests(); + const dir = track(makeProject({ + state: [ + '---', + 'status: executing', + '---', + '', + '# Project State', + '', + 'Phase: 1', + '', + ].join('\n'), + })); + detectSignals(dir); + assert.equal(_unusableInputEmissionCountForTests(), 0, + 'absent last_activity must NOT emit a diagnostic (it is genuinely absent, not corrupt)'); + }); + + test('well-formed last_activity emits nothing', () => { + _resetUnusableInputWarningsForTests(); + const dir = track(makeProject({ + state: makeStateWithActivity('2026-06-13T12:00:00Z'), + })); + detectSignals(dir); + assert.equal(_unusableInputEmissionCountForTests(), 0, + 'well-formed last_activity must NOT emit a diagnostic'); + }); + + test('unusable last_activity does not re-emit on second call (dedup)', () => { + _resetUnusableInputWarningsForTests(); + const dir = track(makeProject({ + state: makeStateWithActivity('gibberish'), + })); + detectSignals(dir); + detectSignals(dir); + assert.equal(_unusableInputEmissionCountForTests(), 1, + 'dedup: second call on the same source must not re-emit'); + }); +}); diff --git a/tests/unusable-input.test.cjs b/tests/unusable-input.test.cjs index c2da7a9bf..1d9f8549b 100644 --- a/tests/unusable-input.test.cjs +++ b/tests/unusable-input.test.cjs @@ -72,7 +72,7 @@ describe('UNUSABLE_REASON', () => { // (enum + call site + this assertion) instead of a silent widening. assert.deepStrictEqual( Object.keys(UNUSABLE_REASON).sort(), - ['FRONTMATTER_UNTERMINATED', 'ROADMAP_UNREADABLE'], + ['FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE'], ); assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated'); });