/** * Smart Entry — state-aware situation classifier for the /gsd front door. * * Reads project + workflow state (`.planning/STATE.md`, ROADMAP.md, git) and * classifies the user's situation into one of a small set of enumerated values, * each carrying a recommended next action and a menu of alternatives. Pure * detection → classification; no side effects, no writes. * * Surfaced as `gsd-tools smart-entry [--json]`. The markdown `/gsd` command + * workflow shells out to `smart-entry --json`, renders an AskUserQuestion menu * (with a --text numbered-list fallback for non-Claude runtimes), and dispatches * the chosen action to an existing slash command. See * docs/superpowers/specs/2026-06-27-gsd-smart-entry-design.md and * docs/adr/1787-gsd-next-smart-entry.md. * * Relationship to `/gsd:progress --next`: this classifier is a *menu* front door, * not a second router. For in-project forward motion (planning → executing → * verify-pending) the recommended action delegates to `/gsd:progress --next` * (workflows/next.md) — the single, gated advancement engine (Route 0 resume- * incomplete-phase invariant + Gates 1-3). smart-entry never re-derives forward * routing itself; a standalone advancement command that bypassed those gates is * exactly what got the old flat `/gsd-next` removed (#3054). Its distinct value is * the states `--next` cannot reach: pre-project (no-project), remediation (paused, * blocked, verify-failed), and lifecycle exits (idle-stranded, complete). * * Design note: this is the gsd-core analog of gsd-pi's `showSmartEntry` branch * tree, redesigned for gsd-core's `.planning/` phase loop. gsd-pi's * milestone/slice/task model does not exist here; the situation table is the * translation, not a port. */ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { collectSection } from './markdown-sectionizer.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import ioMod = require('./io.cjs'); const { output } = ioMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); const { planningPaths } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); const { extractFrontmatter } = frontmatter; // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-lifecycle.cjs is an export= CommonJS module import phaseLifecycle = require('./phase-lifecycle.cjs'); const { deriveProgressFromRoadmap } = phaseLifecycle; // eslint-disable-next-line @typescript-eslint/no-require-imports import stateDocument = require('./state-document.cjs'); const { stateFieldValue } = stateDocument; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseId = require('./phase-id.cjs'); const { comparePhaseNum, extractPhaseToken, matchPhaseDirs, normalizePhaseName, stripProjectCodePrefix } = phaseId; // eslint-disable-next-line @typescript-eslint/no-require-imports import stateMod = require('./state.cjs'); const { readStateHeadFreshness } = stateMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import unusableInput = require('./unusable-input.cjs'); const { warnUnusableInput, UNUSABLE_REASON } = unusableInput; // ─── Types ──────────────────────────────────────────────────────────────────── /** Situation id — the gsd-core analog of gsd-pi's phase enum. */ export type Situation = | 'no-project' | 'paused' | 'blocked' | 'verify-failed' | 'needs-first-phase' | 'planning' | 'executing' | 'verify-pending' | 'idle-stranded' | 'complete' | 'unknown'; export interface SmartEntryAction { id: string; label: string; /** Full slash command string the workflow dispatches, including flags. */ command: string; recommended: boolean; } export interface SmartEntrySignals { current_phase: number | null; total_phases: number | null; status: string; progress: number | null; has_planning: boolean; has_roadmap: boolean; git_dirty: boolean; git_unpushed: boolean; paused: boolean; blockers: string[]; has_git: boolean; /** Latest verify report failed (STATUS: blocked/failed) or status says so. */ verify_failed: boolean; /** last_activity older than IDLE_STALE_MS (computed with the clock seam). */ stale_activity: boolean; /** * Global phase counts derived from ROADMAP.md's `## Progress` table (#2427). * Preferred over the cached milestone-scoped `total_phases` (which goes stale * when phases are appended after a milestone switch). Null when ROADMAP.md is * absent or has no parseable Progress table — callers fall back to the legacy * STATE.md comparison in that case. */ roadmap_total_phases: number | null; roadmap_completed_phases: number | null; /** * Commits between STATE.md's recorded `state_head` and HEAD (#2573). Null * when unknown — no stamp, no git, or an unresolvable commit. */ state_commits_behind: number | null; /** * Tri-state freshness proxy: null = unknown, false = written at HEAD, * true = the codebase has moved since STATE.md was written. Advisory only — * classify() deliberately does NOT consume this (ADR-1787 locks the * classification/routing boundary; this is a signal, not a route). */ state_commit_stale: boolean | null; } export interface SmartEntryResult { situation: Situation; recommended: string; summary: string; signals: SmartEntrySignals; actions: SmartEntryAction[]; } // ─── Constants ──────────────────────────────────────────────────────────────── /** * Staleness threshold for idle-stranded detection. A clean tree whose last * activity is older than this (with non-complete status) is considered stranded * committed-but-unshipped work. Hardcoded for v1; configurable later. */ const IDLE_STALE_MS = 72 * 60 * 60 * 1000; // 72h /** @internal a single source of truth so the structure test can assert coverage. */ export const SITUATIONS: readonly Situation[] = Object.freeze([ 'no-project', 'paused', 'blocked', 'verify-failed', 'needs-first-phase', 'planning', 'executing', 'verify-pending', 'idle-stranded', 'complete', 'unknown', ]); // ─── Detection ───────────────────────────────────────────────────────────────── /** Read a scalar value from a nested frontmatter object (e.g. progress.total_phases). */ function fmScalarKey(obj: unknown, key: string): string | null { if (!obj || typeof obj !== 'object') return null; const v = (obj as Record)[key]; if (typeof v === 'string' && v.trim()) return v.trim(); if (typeof v === 'number' || typeof v === 'boolean') return String(v); return null; } function parseIntOrNull(s: string | null): number | null { if (s === null) return null; const cleaned = s.replace('%', '').trim(); const n = Number.parseInt(cleaned, 10); return Number.isFinite(n) ? n : null; } function phaseTokenFromDirName(name: string): string | null { const token = extractPhaseToken(name); // #2528: the shape probe runs on the PROJECT-CODE-STRIPPED token. A prefixed // directory tokenizes to `MEM-05-80-20`, which does not start with a digit, // so the unstripped probe rejected it and the entry was dropped before any // resolution ran — every phase in a project-coded plan was invisible here. // The full token is still what is returned: `comparePhaseNum` strips the // prefix itself, so the sort is unaffected, and `matchPhaseDirs` needs the // real directory name. const probe = stripProjectCodePrefix(token); return /^\d+(?:[A-Z])?(?:\.\d+)*(?:-|$)/i.test(probe) ? token : null; } /** * Parse a `last_activity` value that may be an ISO date or a free-form string * into an epoch-ms timestamp. Returns null when unparseable. * * #2570: `last_activity` routinely carries a trailing " — " — the * shape `templates/state.md` itself prescribes (`Last activity: [YYYY-MM-DD] — * [What happened]`), which gsd-core's own STATE.md mirrors into frontmatter. * `Date.parse` on the whole string returns NaN, and because `staleActivity` * treats null as "not stale" (fails open), the ONLY idle/staleness detector * never fired on any project whose last_activity retained its description. * Be liberal in what we accept (Postel): read the leading ISO date/time token * when the value carries one, so the description suffix — whatever separator * (em dash or hyphen) it uses — no longer silently blinds the detector; fall * back to a whole-string parse for any other shape a hand edit might use. */ /** Leading ISO date, with an optional time-of-day and offset. */ const ISO_LEADING_RE = /^(\d{4})-(\d{2})-(\d{2})((?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?)/; /** * A NAMED timezone designator at the start of the un-reconstructable remainder * (#2571). ISO_LEADING_RE's offset group captures only `Z` / `±HH:MM`, so a * named zone (GMT, EST, ...) is not in the leading token — it sits here. * Reconstructing the token without it would let Date.parse read the time as * LOCAL, shifting the instant by the host's offset (a wrong, host-dependent * value), so a zone-shaped remainder must fail open (ADR-227). * * The shape is a short all-caps run (2–5 letters) that stands alone — the * negative lookahead excludes the first letter of a Capitalised word like * "Milestone", and an optional trailing offset is subsumed because the leading * all-caps run already matches. Everything else — a lowercase or Capitalised * description, a separator — is describable text and is reconstructed from the * leading date. * * Consulted ONLY when the leading token captured a time-of-day (see the caller): * a zone designator qualifies a clock time, so a BARE date can carry no zone * hazard — reconstructing it is always just that date's UTC midnight, whatever * trails it. Gating on the time keeps a description that merely opens with a * tech acronym ("2026-06-08 CI green", "API refactor") on the reconstruct path * instead of failing open. The prior "any letter" guard was too liberal — it * failed open on every letter-led description and re-opened #2570. */ const ZONE_DESIGNATOR_RE = /^\s*[A-Z]{2,5}(?![A-Za-z])/; /** * True only when y/m/d name a date that actually exists on the calendar. * * `Date.parse` validates shape but not value: it rolls an out-of-range day * FORWARD rather than rejecting it (`2026-02-30` -> `2026-03-02`, * `2026-04-31` -> `2026-05-01`). Shape-only validation would therefore * propagate a different, wrong instant instead of failing safe — precisely * what ADR-227 ("validate shape AND value; on failure of either layer coerce * to the contract's safe default, never propagate") exists to prevent. A * round-trip through Date.UTC detects the rollover: any component the * constructor normalised comes back changed. */ function isRealCalendarDate(year: number, month: number, day: number): boolean { if (month < 1 || month > 12 || day < 1 || day > 31) return false; const probe = new Date(Date.UTC(year, month - 1, day)); return ( probe.getUTCFullYear() === year && probe.getUTCMonth() === month - 1 && probe.getUTCDate() === day ); } function parseActivityTimestamp(raw: string | null): number | null { if (!raw) return null; const trimmed = raw.trim(); const iso = trimmed.match(ISO_LEADING_RE); if (iso) { const [, year, month, day, time] = iso; // Reject an impossible calendar date outright rather than letting // Date.parse substitute a rolled-forward one. null = "no activity signal", // the safe default staleActivity already fails open on. if (!isRealCalendarDate(Number(year), Number(month), Number(day))) return null; // The date is real, so stay as liberal as before (Postel): a whole-string // parse still wins when the engine can make sense of the value. Reading the // token first would silently DROP a trailing zone name -- "2026-06-08 // 12:34:56 GMT" parses whole as 12:34:56Z but as local time from the token, // shifting the instant by the host's UTC offset. const whole = Date.parse(trimmed); if (!Number.isNaN(whole)) return whole; // Whole-string failed: the value carries a suffix the engine can't read as // one instant (#2570). Reconstruct from the leading token UNLESS the remainder // is a named zone the token dropped (GMT, EST, ...): reconstructing without it // reads the time as LOCAL and shifts the instant by the host's offset, so a // zone-shaped remainder fails open (ADR-227: never propagate a wrong instant; // null is the base's not-stale default). An ordinary description -- the #2570 // template's " -- description", or a hand edit's bare-space/tab/colon suffix -- // carries no zone and IS reconstructed. See ZONE_DESIGNATOR_RE for the shape; // the earlier "any letter" guard failed open on every description and re-opened // #2570 for whitespace-separated suffixes. const rest = trimmed.slice(iso[0].length); if (time && ZONE_DESIGNATOR_RE.test(rest)) return null; const ms = Date.parse(`${year}-${month}-${day}${time}`); return Number.isNaN(ms) ? null : ms; } const direct = Date.parse(trimmed); return Number.isNaN(direct) ? null : direct; } interface GitSignals { has_git: boolean; dirty: boolean; unpushed: boolean; } /** Read-only git signals. Any git error is swallowed → "no git signal". */ function readGitSignals(cwd: string): GitSignals { const run = (args: string[]): string => { try { return execFileSync('git', args, { cwd, encoding: 'utf-8', maxBuffer: 4 * 1024 * 1024, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], timeout: 10_000, }); } catch { return ''; } }; // Cheap "is this a git repo" probe that also doubles as the dirty check. const status = run(['status', '--porcelain']); if (status === '' && !run(['rev-parse', '--is-inside-work-tree']).trim()) { return { has_git: false, dirty: false, unpushed: false }; } const dirty = status.trim().length > 0; // `git log @{u}..HEAD` lists commits on this branch not on its upstream. // Non-empty ⇒ unpushed. Errors (no upstream, detached HEAD) ⇒ not unpushed. const unpushed = run(['log', '@{u}..HEAD', '--oneline']).trim().length > 0; return { has_git: true, dirty, unpushed }; } /** Leading numeric phase token from a STATE.md scalar or body `Phase:` value. */ function phaseTokenFromState(raw: string | null): string | null { if (!raw?.trim()) return null; const match = raw.trim().match(/^(\d+(?:[A-Z])?(?:\.\d+)*)/i); return match ? match[1] : null; } /** * Detect whether the current phase's verify report indicates failure. The * canonical signal is a phase summary/verify artifact whose STATUS: marker reads * blocked, failed, or fail. Scoped to STATE.md's current phase so a leftover or * newer phase tree cannot skew routing. Returns true only on a clear positive signal. */ function detectVerifyFailed(cwd: string, currentPhaseRaw: string | null): boolean { const phasesDir = path.join(planningPaths(cwd).phases); let entries: string[] = []; try { entries = fs.readdirSync(phasesDir) .map((name) => ({ name, phaseToken: phaseTokenFromDirName(name) })) .filter((entry): entry is { name: string; phaseToken: string } => entry.phaseToken !== null) .sort((a, b) => comparePhaseNum(a.phaseToken, b.phaseToken) || a.name.localeCompare(b.name)) .map((entry) => entry.name); } catch { return false; } if (entries.length === 0) return false; const phaseToken = phaseTokenFromState(currentPhaseRaw); let targetDir: string | undefined; if (phaseToken) { const normalized = normalizePhaseName(phaseToken); // #2528: the fourth directory-resolution site, and the one where a miss is // silent — a phase whose directory cannot be found reports "not failed", // which reads identically to a healthy phase. It must therefore apply the // same canonical selection as the locator and the two command scans, or a // dir like `05-80-20-cleanup` (phase 5 named "80/20 Cleanup") never // surfaces its own failed verification. `entries` is already sorted, and // `matchPhaseDirs` filters without reordering, so taking the first match // preserves the previous `.find()` selection exactly. const { matches } = matchPhaseDirs(entries, normalized); targetDir = matches[0]; if (!targetDir) return false; } else { // No current phase in state — fall back to the highest-numbered phase dir. targetDir = entries[entries.length - 1]; } const latestDir = path.join(phasesDir, targetDir); let files: string[] = []; try { files = fs.readdirSync(latestDir); } catch { return false; } const candidates = files.filter((f) => /summary|verif(?:y|ication)|uat/i.test(f)); for (const name of candidates) { let content = ''; try { content = fs.readFileSync(path.join(latestDir, name), 'utf-8'); } catch { continue; } const statusMatch = content.match(/STATUS:\s*([A-Za-z_-]+)/i); if (statusMatch && /\b(blocked|fail(ed)?)\b/i.test(statusMatch[1])) { return true; } } return false; } /** Parse `.planning/STATE.md` (frontmatter + body) into the raw signals we need. */ function readStateFile(statePath: string): { fm: Record; body: string; } | null { let content: string; try { content = fs.readFileSync(statePath, 'utf-8'); } catch { return null; } const fm = extractFrontmatter(content, statePath) as Record; const body = content.replace(/^---[\s\S]*?---\s*/, ''); return { fm, body }; } /** Collect the full signal set from disk for `cwd`. Exported for unit tests. */ export function detectSignals(cwd: string, now: () => number = Date.now): SmartEntrySignals { const paths = planningPaths(cwd); const hasPlanning = fs.existsSync(paths.planning); const hasRoadmap = fs.existsSync(paths.roadmap); const git = readGitSignals(cwd); const empty: SmartEntrySignals = { current_phase: null, total_phases: null, status: '', progress: null, has_planning: hasPlanning, has_roadmap: hasRoadmap, git_dirty: git.dirty, git_unpushed: git.unpushed, paused: false, blockers: [], has_git: git.has_git, verify_failed: false, stale_activity: false, roadmap_total_phases: null, roadmap_completed_phases: null, // No STATE.md (or unreadable) → no stamp to compare. Unknown, not fresh. state_commits_behind: null, state_commit_stale: null, }; if (!hasPlanning) return empty; const parsed = readStateFile(paths.state); if (!parsed) return empty; const { fm, body } = parsed; // STATE.md has two lineages of schemas (see state.cjs parseProsePhaseField): // - scalar frontmatter: `current_phase: 3`, `total_phases: 5` // - nested frontmatter: `progress: { total_phases: 5, percent: 40 }` // - body prose: `Phase: 3`, `**Status:** verifying`, `Total Phases: 5` // Read each field across every form, scalar-first then nested then body, so // the classifier works on real STATE.md files written by current GSD. const statusRaw = stateFieldValue(fm, body, 'status', 'Status').value; const pausedAtRaw = stateFieldValue(fm, body, 'paused_at', 'Paused At').value; const lastActivityRaw = stateFieldValue(fm, body, 'last_activity', 'Last Activity').value; // current_phase: scalar fm → nested (none) → body "Current Phase" → body "Phase". // The body `Phase:` field is the canonical location in prose-form STATE.md // (e.g. "Phase: 3" or "Phase: 3 — ui-review"); parse the leading number. // #3187 / ADR-3180 Amendment 3 ("0.x split"): this read is DELIBERATELY // unscoped — smart-entry classifies `gsd next` routing over the whole body, // whereas state.cts's copies of this same field scope it to `## Current // Position` (#1776/#2956). Those are two different questions sharing a // name; folding the scoped read in here would silently change smart-entry's // routing, an undisclosed Tier-2 change (design's Rejected #3). Both owner // calls below intentionally pass the unscoped `body`, never a Current- // Position slice. const currentPhaseRaw = stateFieldValue(fm, body, 'current_phase', 'Current Phase').value ?? stateFieldValue(fm, body, null, 'Phase').value; // total_phases & percent: nested `progress:` object takes precedence in the // nested schema; scalar fm / body fields cover the flat schema. const progressFm = typeof fm.progress === 'object' ? fm.progress : null; const totalPhasesRaw: string | null = fmScalarKey(progressFm, 'total_phases') ?? stateFieldValue(fm, body, 'total_phases', 'Total Phases').value; const progressRaw: string | null = fmScalarKey(progressFm, 'percent') ?? stateFieldValue(fm, body, 'progress', 'Progress').value; // Blockers list: `- ` items under a `## Blockers` heading. const blockers: string[] = []; const blockersSection = collectSection(body, (h) => h.level === 2 && h.text.trim().toLowerCase() === 'blockers', { levelBounded: true }); if (blockersSection) { const items = blockersSection.body.match(/^-\s+(.+)$/gm) || []; for (const item of items) blockers.push(item.replace(/^-\s+/, '').trim()); } const paused = Boolean(pausedAtRaw && pausedAtRaw.trim()); // 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 // STATUS: marker on the current phase's summary/verify artifact. const verifyFailed = /\bverify-fail(ed)?|verification-fail|uat-fail\b/i.test(statusRaw || '') || detectVerifyFailed(cwd, currentPhaseRaw); // #2427: derive global phase counts from ROADMAP.md's Progress table. These // are preferred over STATE.md's cached milestone-scoped `total_phases` (which // goes stale when phases are appended after a milestone switch) for the // completion check. Null when ROADMAP.md is absent or has no parseable // Progress table — isComplete falls back to the legacy comparison in that case. let roadmapTotalPhases: number | null = null; let roadmapCompletedPhases: number | null = null; if (hasRoadmap) { try { const roadmapContent = fs.readFileSync(paths.roadmap, 'utf8'); const derived = deriveProgressFromRoadmap(roadmapContent); roadmapTotalPhases = derived.totalPhases; roadmapCompletedPhases = derived.completedPhases; } catch { /* ROADMAP.md unreadable — leave null; isComplete falls back to legacy. */ } } // #2573: commit-age freshness proxy. Derived through state.cjs's // readStateHeadFreshness so the tri-state and the hash fence stay identical // to validate.health's W024 — one derivation, two surfaces. const stateHeadRaw = stateFieldValue(fm, body, 'state_head', 'State Head').value; const freshness = readStateHeadFreshness(cwd, stateHeadRaw); return { current_phase: parseIntOrNull(currentPhaseRaw), total_phases: parseIntOrNull(totalPhasesRaw), status: (statusRaw || '').toLowerCase(), progress: parseIntOrNull(progressRaw), has_planning: hasPlanning, has_roadmap: hasRoadmap, git_dirty: git.dirty, git_unpushed: git.unpushed, paused, blockers, has_git: git.has_git, verify_failed: verifyFailed, stale_activity: staleActivity, roadmap_total_phases: roadmapTotalPhases, roadmap_completed_phases: roadmapCompletedPhases, state_commits_behind: freshness.commits_behind, state_commit_stale: freshness.commit_stale, }; } // ─── Situation classification ───────────────────────────────────────────────── /** * True when the workflow has fully completed all phases. * * #2427: completion is grounded in ROADMAP.md's Progress table (global, * authoritative, never stale) when available, with a legacy fallback to * STATE.md's cached `total_phases` when the roadmap has no parseable Progress * table (e.g. a fresh project or a non-standard roadmap layout). The status * regex was tightened to require milestone-level completion language * (`milestone complete` / `all phases complete` / `complete(d)`) and no longer * matches per-phase messages like "Phase X shipped — PR #N" that falsely * satisfied the pre-fix alternation (`\bcomplete(d)?|done|shipped\b`). */ function isComplete(s: SmartEntrySignals): boolean { // Prefer ROADMAP-derived counts (global, authoritative) over STATE.md's // cached milestone-scoped total_phases (stale-prone). Fall back to legacy // when the roadmap has no Progress table. if (s.roadmap_total_phases !== null && s.roadmap_completed_phases !== null) { if (s.roadmap_total_phases === 0) return false; if (s.roadmap_completed_phases < s.roadmap_total_phases) return false; } else { // Legacy path: STATE.md comparison. Still subject to the two-scale bug, // but only fires when ROADMAP.md is absent or has no Progress table. if (s.total_phases === null || s.current_phase === null) return false; if (s.current_phase < s.total_phases) return false; } // Status regex: require milestone-level completion language. The pre-fix // regex matched any "shipped" / "done" substring (per-phase language). // Tightened to match: // - "milestone complete" (ADR-2207 terminal status — usually written as // " milestone complete", e.g. "v1.0 milestone complete"; the // substring match handles both forms) // - "all phases complete" (ADR-2207 intermediate terminal) // - "complete" / "completed" (legacy short form — STATE.md milestone // status is a single value, not a per-phase log) // Intentionally does NOT match "done" alone even though normalizeStateStatus // (state-document.cts) treats "done" as "completed" — in the milestone // status field, "done" is per-phase noise (e.g. "Phase X done"), not a // milestone-completion signal. Mirrors workstream-inventory-builder.cts's // terminal pattern \bmilestone\s+complete\b. return /\b(milestone\s+complete|all\s+phases\s+complete|complete(d)?)\b/i.test(s.status); } /** Idle-stranded: clean tree, committed work not shipped, optionally stale. */ function isIdleStranded(s: SmartEntrySignals): boolean { if (/\bcomplete(d)?|done|shipped|paused\b/i.test(s.status)) return false; if (s.git_dirty) return false; // Unpushed commits are the strongest signal. Otherwise require staleness. return s.git_unpushed || s.stale_activity; } /** * Classify signals into a situation. Priority order matters — the first * matching predicate wins (e.g. paused beats blocked). Exported for unit tests. * Pure function of signals (staleness + verify-failed are precomputed on signals). */ export function classify(s: SmartEntrySignals): Situation { if (!s.has_planning) return 'no-project'; if (s.paused) return 'paused'; if (s.blockers.length > 0) return 'blocked'; if (s.verify_failed) return 'verify-failed'; if (s.total_phases === null || s.total_phases <= 0 || !s.has_roadmap) return 'needs-first-phase'; if (isComplete(s)) return 'complete'; if (/\bplanning|planned\b/i.test(s.status)) return 'planning'; if (/\bexecut(e|ing)|active|in.progress|building\b/i.test(s.status)) return 'executing'; if (/\bverify|review|needs.review|pending.review\b/i.test(s.status)) return 'verify-pending'; if (isIdleStranded(s)) return 'idle-stranded'; return 'unknown'; } // ─── Action sets ────────────────────────────────────────────────────────────── const action = ( id: string, label: string, command: string, recommended = false, ): SmartEntryAction => ({ id, label, command, recommended }); function rec(id: string, label: string, command: string): SmartEntryAction { return action(id, label, command, true); } /** Per-situation ordered action list; recommended action first. */ export function actionsFor(situation: Situation, s: SmartEntrySignals): SmartEntryAction[] { const phaseN = s.current_phase ?? ''; const execLabel = phaseN === '' ? 'Continue executing' : `Continue executing phase ${phaseN}`; switch (situation) { case 'no-project': return [ rec('new-project', 'Start a new project', '/gsd:new-project'), action('map-codebase', 'Map an existing codebase', '/gsd:map-codebase'), action('quick', 'Quick task', '/gsd:quick'), action('help', 'Show help', '/gsd:help'), ]; case 'paused': return [ rec('resume-work', 'Resume work', '/gsd:resume-work'), action('progress', 'Show progress', '/gsd:progress'), action('quick', 'Quick task', '/gsd:quick'), action('help', 'Show help', '/gsd:help'), ]; case 'blocked': return [ rec('debug', 'Debug the blocker', '/gsd:debug'), action('verify-work', 'Verify current work', '/gsd:verify-work'), action('capture', 'Capture a note', '/gsd:capture'), action('progress', 'Show progress', '/gsd:progress'), ]; case 'verify-failed': return [ rec('verify-work', 'Re-verify work', '/gsd:verify-work'), action('debug', 'Debug the failure', '/gsd:debug'), action('code-review', 'Review recent work', '/gsd:code-review'), action('progress', 'Show progress', '/gsd:progress'), ]; case 'needs-first-phase': return [ rec('discuss-phase', 'Discuss the first phase', '/gsd:discuss-phase'), action('plan-phase', 'Plan phase 1', '/gsd:plan-phase'), action('quick', 'Quick task', '/gsd:quick'), action('progress', 'Show progress', '/gsd:progress'), ]; case 'planning': // Forward motion → delegate to the single gated engine (see 'executing'). return [ rec('progress-next', `Advance to the next step (plan phase ${phaseN || 1})`, '/gsd:progress --next'), action('plan-phase', `Plan phase ${phaseN || 1}`, '/gsd:plan-phase'), action('discuss-phase', 'Discuss before planning', '/gsd:discuss-phase'), action('progress', 'Show progress', '/gsd:progress'), ]; case 'executing': // In-project forward motion delegates to the single gated engine // (/gsd:progress --next → workflows/next.md). Its Route 0 resume-incomplete // -phase invariant + Gates 1-3 must not be bypassed by dispatching a raw // /gsd:execute-phase here — that divergence is why the old flat /gsd-next // was removed (#3054). Direct execute stays as an explicit secondary choice. return [ rec('progress-next', 'Advance to the next step', '/gsd:progress --next'), action('execute-phase', execLabel, '/gsd:execute-phase'), action('quick', 'Quick task', '/gsd:quick'), action('code-review', 'Review recent work', '/gsd:code-review'), ]; case 'verify-pending': // Forward motion → delegate to the single gated engine (see 'executing'). return [ rec('progress-next', 'Advance to the next step (verify)', '/gsd:progress --next'), action('verify-work', 'Verify work', '/gsd:verify-work'), action('code-review', 'Review recent work', '/gsd:code-review'), action('ship', 'Ship completed work', '/gsd:ship'), ]; case 'idle-stranded': return [ rec('ship', 'Ship committed work', '/gsd:ship'), action('complete-milestone', 'Complete the milestone', '/gsd:complete-milestone'), action('progress', 'Show progress', '/gsd:progress'), action('capture', 'Capture a note', '/gsd:capture'), ]; case 'complete': return [ rec('new-milestone', 'Start a new milestone', '/gsd:new-milestone'), action('extract-learnings', 'Extract learnings', '/gsd:extract-learnings'), action('quick', 'Quick task', '/gsd:quick'), action('progress', 'Show progress', '/gsd:progress'), ]; case 'unknown': default: return [ rec('progress', 'Show progress', '/gsd:progress'), action('progress-next', 'Advance to the next step', '/gsd:progress --next'), action('quick', 'Quick task', '/gsd:quick'), action('help', 'Show help', '/gsd:help'), ]; } } // ─── Summary line ───────────────────────────────────────────────────────────── function buildSummary(situation: Situation, s: SmartEntrySignals): string { switch (situation) { case 'no-project': return 'No project yet — start fresh or map an existing codebase'; case 'paused': return 'Work is paused — pick up where you left off'; case 'blocked': return `Blocked${s.blockers.length ? ` · ${s.blockers.length} blocker(s)` : ''} — resolve before continuing`; case 'verify-failed': return 'Recent verification failed — re-verify or debug'; case 'needs-first-phase': return 'Project initialized — plan your first phase'; case 'planning': return `Phase ${s.current_phase ?? '?'} of ${s.total_phases ?? '?'} — needs a plan`; case 'executing': return progressLine('executing', s); case 'verify-pending': return progressLine('ready to verify', s); case 'idle-stranded': return 'Committed work not shipped — time to ship'; case 'complete': return 'All phases complete — start a new milestone'; case 'unknown': default: return 'Unsure of state — showing progress'; } } function progressLine(tail: string, s: SmartEntrySignals): string { const parts: string[] = []; if (s.current_phase !== null && s.total_phases !== null) { parts.push(`Phase ${s.current_phase} of ${s.total_phases}`); } else if (s.current_phase !== null) { parts.push(`Phase ${s.current_phase}`); } if (s.progress !== null) parts.push(`${s.progress}%`); parts.push(tail); return parts.join(' · '); } // ─── Orchestration ───────────────────────────────────────────────────────────── /** * Run the full detect → classify → assemble pipeline. Pure: no stdout, no * writes. Exported for direct unit testing and reuse. The clock seam drives * staleness detection inside detectSignals. */ export function classifyProject(cwd: string, now: () => number = Date.now): SmartEntryResult { const signals = detectSignals(cwd, now); const situation = classify(signals); const actions = actionsFor(situation, signals); const recommended = actions.find((a) => a.recommended)?.id ?? 'progress'; const summary = buildSummary(situation, signals); return { situation, recommended, summary, signals, actions }; } /** * `gsd-tools smart-entry` entry point. `--json` emits machine JSON; default * emits a human-readable summary line. Never throws for a missing `.planning/` * (returns `no-project`); other internal failures surface a typed error via * `output()` so the workflow can fall back to `/gsd:progress`. */ export function runSmartEntry(cwd: string, args: string[], raw: boolean): void { const json = args.includes('--json'); const result = classifyProject(cwd); if (json) { output(result, raw, undefined); return; } // Human mode: a compact one-liner plus the recommended action. const human = `${result.summary}\nRecommended: ${result.recommended} → ${ result.actions.find((a) => a.id === result.recommended)?.command ?? '/gsd:progress' }`; output(null, true, human); }