@opengsd/gsd-core 1.7.0-rc.1 → 1.7.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/GEMINI.md +25 -23
  5. package/README.md +3 -3
  6. package/agents/gsd-advisor-researcher.md +3 -1
  7. package/agents/gsd-ai-researcher.md +2 -2
  8. package/agents/gsd-assumptions-analyzer.md +2 -0
  9. package/agents/gsd-code-fixer.md +3 -1
  10. package/agents/gsd-code-reviewer.md +2 -0
  11. package/agents/gsd-codebase-mapper.md +2 -0
  12. package/agents/gsd-debug-session-manager.md +1 -1
  13. package/agents/gsd-debugger.md +3 -1
  14. package/agents/gsd-doc-writer.md +2 -0
  15. package/agents/gsd-domain-researcher.md +1 -1
  16. package/agents/gsd-eval-auditor.md +3 -1
  17. package/agents/gsd-executor.md +12 -9
  18. package/agents/gsd-integration-checker.md +2 -0
  19. package/agents/gsd-intel-updater.md +1 -1
  20. package/agents/gsd-mempalace-curator.md +1 -1
  21. package/agents/gsd-nyquist-auditor.md +2 -0
  22. package/agents/gsd-phase-researcher.md +4 -2
  23. package/agents/gsd-plan-checker.md +3 -1
  24. package/agents/gsd-planner.md +4 -2
  25. package/agents/gsd-project-researcher.md +4 -2
  26. package/agents/gsd-research-synthesizer.md +3 -1
  27. package/agents/gsd-roadmapper.md +2 -0
  28. package/agents/gsd-security-auditor.md +2 -0
  29. package/agents/gsd-ui-auditor.md +2 -0
  30. package/agents/gsd-ui-checker.md +2 -0
  31. package/agents/gsd-ui-researcher.md +4 -2
  32. package/agents/gsd-verifier.md +6 -4
  33. package/bin/install.js +226 -459
  34. package/commands/gsd/mempalace-capture.md +1 -1
  35. package/commands/gsd/mempalace-recall.md +9 -5
  36. package/commands/gsd/next.md +29 -0
  37. package/commands/gsd/ns-workflow.md +5 -2
  38. package/gsd-core/bin/ensure-runtime-build.cjs +246 -0
  39. package/gsd-core/bin/gsd-tools.cjs +43 -8
  40. package/gsd-core/bin/lib/capability-loader.cjs +27 -9
  41. package/gsd-core/bin/lib/capability-registry.cjs +209 -205
  42. package/gsd-core/bin/lib/capability-source.cjs +22 -7
  43. package/gsd-core/bin/lib/capability-validator.cjs +24 -2
  44. package/gsd-core/bin/lib/check-command-router.cjs +111 -1
  45. package/gsd-core/bin/lib/clusters.cjs +1 -0
  46. package/gsd-core/bin/lib/commands.cjs +2 -1
  47. package/gsd-core/bin/lib/config.cjs +19 -1
  48. package/gsd-core/bin/lib/core-utils.cjs +41 -0
  49. package/gsd-core/bin/lib/external-job.cjs +287 -0
  50. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  51. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +151 -0
  52. package/gsd-core/bin/lib/graphify.cjs +41 -9
  53. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  54. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  55. package/gsd-core/bin/lib/host-integration.cjs +61 -0
  56. package/gsd-core/bin/lib/init.cjs +34 -6
  57. package/gsd-core/bin/lib/milestone.cjs +56 -11
  58. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  59. package/gsd-core/bin/lib/phase.cjs +51 -29
  60. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  61. package/gsd-core/bin/lib/plan-scan.cjs +9 -1
  62. package/gsd-core/bin/lib/probe-core.cjs +44 -4
  63. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  64. package/gsd-core/bin/lib/prohibition-enforcement.cjs +21 -19
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  67. package/gsd-core/bin/lib/roadmap.cjs +45 -22
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +18 -13
  69. package/gsd-core/bin/lib/runtime-name-policy.cjs +8 -10
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +12 -8
  71. package/gsd-core/bin/lib/smart-entry.cjs +521 -0
  72. package/gsd-core/bin/lib/state-transition.cjs +15 -0
  73. package/gsd-core/bin/lib/state.cjs +27 -8
  74. package/gsd-core/bin/lib/surface.cjs +18 -9
  75. package/gsd-core/bin/lib/update-context.cjs +0 -5
  76. package/gsd-core/bin/lib/validate.cjs +2 -1
  77. package/gsd-core/bin/lib/verify.cjs +6 -4
  78. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  79. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  80. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  81. package/gsd-core/bin/shared/model-catalog.json +8 -13
  82. package/gsd-core/bin/shared/runtime-aliases.manifest.json +0 -5
  83. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  84. package/gsd-core/references/gates.md +1 -1
  85. package/gsd-core/references/model-profiles.md +27 -0
  86. package/gsd-core/references/planning-config.md +1 -1
  87. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  88. package/gsd-core/workflows/add-backlog.md +1 -1
  89. package/gsd-core/workflows/add-phase.md +1 -1
  90. package/gsd-core/workflows/add-tests.md +1 -1
  91. package/gsd-core/workflows/add-todo.md +1 -1
  92. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  93. package/gsd-core/workflows/audit-fix.md +1 -1
  94. package/gsd-core/workflows/audit-milestone.md +1 -1
  95. package/gsd-core/workflows/audit-uat.md +1 -1
  96. package/gsd-core/workflows/autonomous.md +23 -25
  97. package/gsd-core/workflows/check-todos.md +1 -1
  98. package/gsd-core/workflows/cleanup.md +1 -1
  99. package/gsd-core/workflows/code-review-fix.md +1 -1
  100. package/gsd-core/workflows/code-review.md +1 -1
  101. package/gsd-core/workflows/complete-milestone.md +7 -11
  102. package/gsd-core/workflows/debug.md +1 -1
  103. package/gsd-core/workflows/diagnose-issues.md +1 -1
  104. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  105. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  106. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  107. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  108. package/gsd-core/workflows/discuss-phase.md +2 -2
  109. package/gsd-core/workflows/do.md +1 -1
  110. package/gsd-core/workflows/docs-update.md +1 -1
  111. package/gsd-core/workflows/edit-phase.md +1 -1
  112. package/gsd-core/workflows/eval-review.md +1 -1
  113. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  114. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  115. package/gsd-core/workflows/execute-phase.md +5 -6
  116. package/gsd-core/workflows/execute-plan.md +1 -1
  117. package/gsd-core/workflows/explore.md +1 -1
  118. package/gsd-core/workflows/extract-learnings.md +1 -1
  119. package/gsd-core/workflows/forensics.md +4 -4
  120. package/gsd-core/workflows/graduation.md +1 -1
  121. package/gsd-core/workflows/health.md +1 -1
  122. package/gsd-core/workflows/help/modes/full.md +15 -1
  123. package/gsd-core/workflows/help/modes/topic.md +1 -0
  124. package/gsd-core/workflows/import.md +1 -1
  125. package/gsd-core/workflows/ingest-docs.md +1 -1
  126. package/gsd-core/workflows/insert-phase.md +1 -1
  127. package/gsd-core/workflows/list-seeds.md +1 -1
  128. package/gsd-core/workflows/list-workspaces.md +1 -1
  129. package/gsd-core/workflows/manager.md +1 -1
  130. package/gsd-core/workflows/map-codebase.md +1 -1
  131. package/gsd-core/workflows/milestone-summary.md +4 -4
  132. package/gsd-core/workflows/mvp-phase.md +1 -1
  133. package/gsd-core/workflows/new-milestone.md +7 -1
  134. package/gsd-core/workflows/new-project.md +1 -1
  135. package/gsd-core/workflows/new-workspace.md +1 -1
  136. package/gsd-core/workflows/next.md +1 -1
  137. package/gsd-core/workflows/pause-work.md +1 -1
  138. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  139. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  141. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  142. package/gsd-core/workflows/plan-phase.md +12 -160
  143. package/gsd-core/workflows/plan-review-convergence.md +1 -1
  144. package/gsd-core/workflows/plant-seed.md +1 -1
  145. package/gsd-core/workflows/pr-branch.md +1 -1
  146. package/gsd-core/workflows/profile-user.md +1 -1
  147. package/gsd-core/workflows/progress.md +1 -1
  148. package/gsd-core/workflows/quick.md +1 -1
  149. package/gsd-core/workflows/reapply-patches.md +1 -1
  150. package/gsd-core/workflows/remove-phase.md +1 -1
  151. package/gsd-core/workflows/remove-workspace.md +1 -1
  152. package/gsd-core/workflows/resume-project.md +1 -1
  153. package/gsd-core/workflows/review.md +50 -9
  154. package/gsd-core/workflows/scan.md +1 -1
  155. package/gsd-core/workflows/secure-phase.md +1 -1
  156. package/gsd-core/workflows/settings-advanced.md +13 -11
  157. package/gsd-core/workflows/settings-integrations.md +1 -1
  158. package/gsd-core/workflows/settings.md +1 -1
  159. package/gsd-core/workflows/ship.md +1 -1
  160. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  161. package/gsd-core/workflows/sketch.md +1 -1
  162. package/gsd-core/workflows/smart-entry.md +120 -0
  163. package/gsd-core/workflows/spec-phase.md +8 -6
  164. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  165. package/gsd-core/workflows/spike.md +1 -1
  166. package/gsd-core/workflows/stats.md +1 -1
  167. package/gsd-core/workflows/thread.md +1 -1
  168. package/gsd-core/workflows/transition.md +1 -1
  169. package/gsd-core/workflows/ui-phase.md +1 -1
  170. package/gsd-core/workflows/ui-review.md +1 -1
  171. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  172. package/gsd-core/workflows/update.md +3 -6
  173. package/gsd-core/workflows/validate-phase.md +1 -1
  174. package/gsd-core/workflows/verify-phase.md +1 -1
  175. package/gsd-core/workflows/verify-work.md +63 -6
  176. package/package.json +5 -5
  177. package/scripts/ci-test-scope.cjs +19 -1
  178. package/scripts/generate-package-identity.cjs +27 -2
  179. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -148
  180. package/scripts/lint-regression-test-names.allowlist.json +1 -273
  181. package/scripts/lint-test-file-count.allowlist.json +7 -91
  182. package/scripts/research-profiles.cjs +6 -6
  183. package/scripts/run-tests.cjs +67 -4
  184. package/scripts/slurm-adapter.cjs +269 -0
  185. package/scripts/sync-manifest-versions.cjs +67 -14
  186. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  187. package/skills/gsd-mempalace-recall/SKILL.md +9 -5
  188. package/skills/gsd-next/SKILL.md +30 -0
  189. package/skills/gsd-ns-workflow/SKILL.md +4 -1
  190. package/skills/gsd-review/SKILL.md +6 -0
  191. package/gemini-extension.json +0 -6
@@ -0,0 +1,521 @@
1
+ "use strict";
2
+ /**
3
+ * Smart Entry — state-aware situation classifier for the /gsd front door.
4
+ *
5
+ * Reads project + workflow state (`.planning/STATE.md`, ROADMAP.md, git) and
6
+ * classifies the user's situation into one of a small set of enumerated values,
7
+ * each carrying a recommended next action and a menu of alternatives. Pure
8
+ * detection → classification; no side effects, no writes.
9
+ *
10
+ * Surfaced as `gsd-tools smart-entry [--json]`. The markdown `/gsd` command +
11
+ * workflow shells out to `smart-entry --json`, renders an AskUserQuestion menu
12
+ * (with a --text numbered-list fallback for non-Claude runtimes), and dispatches
13
+ * the chosen action to an existing slash command. See
14
+ * docs/superpowers/specs/2026-06-27-gsd-smart-entry-design.md and
15
+ * docs/adr/1787-gsd-next-smart-entry.md.
16
+ *
17
+ * Relationship to `/gsd:progress --next`: this classifier is a *menu* front door,
18
+ * not a second router. For in-project forward motion (planning → executing →
19
+ * verify-pending) the recommended action delegates to `/gsd:progress --next`
20
+ * (workflows/next.md) — the single, gated advancement engine (Route 0 resume-
21
+ * incomplete-phase invariant + Gates 1-3). smart-entry never re-derives forward
22
+ * routing itself; a standalone advancement command that bypassed those gates is
23
+ * exactly what got the old flat `/gsd-next` removed (#3054). Its distinct value is
24
+ * the states `--next` cannot reach: pre-project (no-project), remediation (paused,
25
+ * blocked, verify-failed), and lifecycle exits (idle-stranded, complete).
26
+ *
27
+ * Design note: this is the gsd-core analog of gsd-pi's `showSmartEntry` branch
28
+ * tree, redesigned for gsd-core's `.planning/` phase loop. gsd-pi's
29
+ * milestone/slice/task model does not exist here; the situation table is the
30
+ * translation, not a port.
31
+ */
32
+ var __importDefault = (this && this.__importDefault) || function (mod) {
33
+ return (mod && mod.__esModule) ? mod : { "default": mod };
34
+ };
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SITUATIONS = void 0;
37
+ exports.detectSignals = detectSignals;
38
+ exports.classify = classify;
39
+ exports.actionsFor = actionsFor;
40
+ exports.classifyProject = classifyProject;
41
+ exports.runSmartEntry = runSmartEntry;
42
+ const node_fs_1 = __importDefault(require("node:fs"));
43
+ const node_path_1 = __importDefault(require("node:path"));
44
+ const node_child_process_1 = require("node:child_process");
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
46
+ const ioMod = require("./io.cjs");
47
+ const { output } = ioMod;
48
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
49
+ const planningWorkspace = require("./planning-workspace.cjs");
50
+ const { planningPaths } = planningWorkspace;
51
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
52
+ const frontmatter = require("./frontmatter.cjs");
53
+ const { extractFrontmatter } = frontmatter;
54
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
55
+ const stateDocument = require("./state-document.cjs");
56
+ const { stateExtractField } = stateDocument;
57
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
58
+ const phaseId = require("./phase-id.cjs");
59
+ const { comparePhaseNum, extractPhaseToken, normalizePhaseName, phaseTokenMatches } = phaseId;
60
+ // ─── Constants ────────────────────────────────────────────────────────────────
61
+ /**
62
+ * Staleness threshold for idle-stranded detection. A clean tree whose last
63
+ * activity is older than this (with non-complete status) is considered stranded
64
+ * committed-but-unshipped work. Hardcoded for v1; configurable later.
65
+ */
66
+ const IDLE_STALE_MS = 72 * 60 * 60 * 1000; // 72h
67
+ /** @internal a single source of truth so the structure test can assert coverage. */
68
+ exports.SITUATIONS = Object.freeze([
69
+ 'no-project',
70
+ 'paused',
71
+ 'blocked',
72
+ 'verify-failed',
73
+ 'needs-first-phase',
74
+ 'planning',
75
+ 'executing',
76
+ 'verify-pending',
77
+ 'idle-stranded',
78
+ 'complete',
79
+ 'unknown',
80
+ ]);
81
+ // ─── Detection ─────────────────────────────────────────────────────────────────
82
+ /** Frontmatter scalar helper: prefer YAML frontmatter, fall back to body field. */
83
+ function fmScalar(fm, body, key, bodyField) {
84
+ const v = fm[key];
85
+ if (typeof v === 'string' && v.trim())
86
+ return v.trim();
87
+ if (typeof v === 'number' || typeof v === 'boolean')
88
+ return String(v);
89
+ return stateExtractField(body, bodyField);
90
+ }
91
+ /** Read a scalar value from a nested frontmatter object (e.g. progress.total_phases). */
92
+ function fmScalarKey(obj, key) {
93
+ if (!obj || typeof obj !== 'object')
94
+ return null;
95
+ const v = obj[key];
96
+ if (typeof v === 'string' && v.trim())
97
+ return v.trim();
98
+ if (typeof v === 'number' || typeof v === 'boolean')
99
+ return String(v);
100
+ return null;
101
+ }
102
+ function parseIntOrNull(s) {
103
+ if (s === null)
104
+ return null;
105
+ const cleaned = s.replace('%', '').trim();
106
+ const n = Number.parseInt(cleaned, 10);
107
+ return Number.isFinite(n) ? n : null;
108
+ }
109
+ function phaseTokenFromDirName(name) {
110
+ const token = extractPhaseToken(name);
111
+ return /^\d+(?:[A-Z])?(?:\.\d+)*(?:-|$)/i.test(token) ? token : null;
112
+ }
113
+ /**
114
+ * Parse a `last_activity` value that may be an ISO date or a free-form string
115
+ * into an epoch-ms timestamp. Returns null when unparseable.
116
+ */
117
+ function parseActivityTimestamp(raw) {
118
+ if (!raw)
119
+ return null;
120
+ const ms = Date.parse(raw);
121
+ return Number.isNaN(ms) ? null : ms;
122
+ }
123
+ /** Read-only git signals. Any git error is swallowed → "no git signal". */
124
+ function readGitSignals(cwd) {
125
+ const run = (args) => {
126
+ try {
127
+ return (0, node_child_process_1.execFileSync)('git', args, {
128
+ cwd,
129
+ encoding: 'utf-8',
130
+ maxBuffer: 4 * 1024 * 1024,
131
+ windowsHide: true,
132
+ stdio: ['pipe', 'pipe', 'pipe'],
133
+ });
134
+ }
135
+ catch {
136
+ return '';
137
+ }
138
+ };
139
+ // Cheap "is this a git repo" probe that also doubles as the dirty check.
140
+ const status = run(['status', '--porcelain']);
141
+ if (status === '' && !run(['rev-parse', '--is-inside-work-tree']).trim()) {
142
+ return { has_git: false, dirty: false, unpushed: false };
143
+ }
144
+ const dirty = status.trim().length > 0;
145
+ // `git log @{u}..HEAD` lists commits on this branch not on its upstream.
146
+ // Non-empty ⇒ unpushed. Errors (no upstream, detached HEAD) ⇒ not unpushed.
147
+ const unpushed = run(['log', '@{u}..HEAD', '--oneline']).trim().length > 0;
148
+ return { has_git: true, dirty, unpushed };
149
+ }
150
+ /** Leading numeric phase token from a STATE.md scalar or body `Phase:` value. */
151
+ function phaseTokenFromState(raw) {
152
+ if (!raw?.trim())
153
+ return null;
154
+ const match = raw.trim().match(/^(\d+(?:[A-Z])?(?:\.\d+)*)/i);
155
+ return match ? match[1] : null;
156
+ }
157
+ /**
158
+ * Detect whether the current phase's verify report indicates failure. The
159
+ * canonical signal is a phase summary/verify artifact whose STATUS: marker reads
160
+ * blocked, failed, or fail. Scoped to STATE.md's current phase so a leftover or
161
+ * newer phase tree cannot skew routing. Returns true only on a clear positive signal.
162
+ */
163
+ function detectVerifyFailed(cwd, currentPhaseRaw) {
164
+ const phasesDir = node_path_1.default.join(planningPaths(cwd).phases);
165
+ let entries = [];
166
+ try {
167
+ entries = node_fs_1.default.readdirSync(phasesDir)
168
+ .map((name) => ({ name, phaseToken: phaseTokenFromDirName(name) }))
169
+ .filter((entry) => entry.phaseToken !== null)
170
+ .sort((a, b) => comparePhaseNum(a.phaseToken, b.phaseToken) || a.name.localeCompare(b.name))
171
+ .map((entry) => entry.name);
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ if (entries.length === 0)
177
+ return false;
178
+ const phaseToken = phaseTokenFromState(currentPhaseRaw);
179
+ let targetDir;
180
+ if (phaseToken) {
181
+ const normalized = normalizePhaseName(phaseToken);
182
+ targetDir = entries.find((name) => phaseTokenMatches(name, normalized));
183
+ if (!targetDir)
184
+ return false;
185
+ }
186
+ else {
187
+ // No current phase in state — fall back to the highest-numbered phase dir.
188
+ targetDir = entries[entries.length - 1];
189
+ }
190
+ const latestDir = node_path_1.default.join(phasesDir, targetDir);
191
+ let files = [];
192
+ try {
193
+ files = node_fs_1.default.readdirSync(latestDir);
194
+ }
195
+ catch {
196
+ return false;
197
+ }
198
+ const candidates = files.filter((f) => /summary|verif(?:y|ication)|uat/i.test(f));
199
+ for (const name of candidates) {
200
+ let content = '';
201
+ try {
202
+ content = node_fs_1.default.readFileSync(node_path_1.default.join(latestDir, name), 'utf-8');
203
+ }
204
+ catch {
205
+ continue;
206
+ }
207
+ const statusMatch = content.match(/STATUS:\s*([A-Za-z_-]+)/i);
208
+ if (statusMatch && /\b(blocked|fail(ed)?)\b/i.test(statusMatch[1])) {
209
+ return true;
210
+ }
211
+ }
212
+ return false;
213
+ }
214
+ /** Parse `.planning/STATE.md` (frontmatter + body) into the raw signals we need. */
215
+ function readStateFile(statePath) {
216
+ let content;
217
+ try {
218
+ content = node_fs_1.default.readFileSync(statePath, 'utf-8');
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ const fm = extractFrontmatter(content);
224
+ const body = content.replace(/^---[\s\S]*?---\s*/, '');
225
+ return { fm, body };
226
+ }
227
+ /** Collect the full signal set from disk for `cwd`. Exported for unit tests. */
228
+ function detectSignals(cwd, now = Date.now) {
229
+ const paths = planningPaths(cwd);
230
+ const hasPlanning = node_fs_1.default.existsSync(paths.planning);
231
+ const hasRoadmap = node_fs_1.default.existsSync(paths.roadmap);
232
+ const git = readGitSignals(cwd);
233
+ const empty = {
234
+ current_phase: null,
235
+ total_phases: null,
236
+ status: '',
237
+ progress: null,
238
+ has_planning: hasPlanning,
239
+ has_roadmap: hasRoadmap,
240
+ git_dirty: git.dirty,
241
+ git_unpushed: git.unpushed,
242
+ paused: false,
243
+ blockers: [],
244
+ has_git: git.has_git,
245
+ verify_failed: false,
246
+ stale_activity: false,
247
+ };
248
+ if (!hasPlanning)
249
+ return empty;
250
+ const parsed = readStateFile(paths.state);
251
+ if (!parsed)
252
+ return empty;
253
+ const { fm, body } = parsed;
254
+ // STATE.md has two lineages of schemas (see state.cjs parseProsePhaseField):
255
+ // - scalar frontmatter: `current_phase: 3`, `total_phases: 5`
256
+ // - nested frontmatter: `progress: { total_phases: 5, percent: 40 }`
257
+ // - body prose: `Phase: 3`, `**Status:** verifying`, `Total Phases: 5`
258
+ // Read each field across every form, scalar-first then nested then body, so
259
+ // the classifier works on real STATE.md files written by current GSD.
260
+ const statusRaw = fmScalar(fm, body, 'status', 'Status');
261
+ const pausedAtRaw = fmScalar(fm, body, 'paused_at', 'Paused At');
262
+ const lastActivityRaw = fmScalar(fm, body, 'last_activity', 'Last Activity');
263
+ // current_phase: scalar fm → nested (none) → body "Current Phase" → body "Phase".
264
+ // The body `Phase:` field is the canonical location in prose-form STATE.md
265
+ // (e.g. "Phase: 3" or "Phase: 3 — ui-review"); parse the leading number.
266
+ const currentPhaseRaw = fmScalar(fm, body, 'current_phase', 'Current Phase') ??
267
+ stateExtractField(body, 'Phase');
268
+ // total_phases & percent: nested `progress:` object takes precedence in the
269
+ // nested schema; scalar fm / body fields cover the flat schema.
270
+ const progressFm = typeof fm.progress === 'object' ? fm.progress : null;
271
+ const totalPhasesRaw = fmScalarKey(progressFm, 'total_phases') ??
272
+ fmScalar(fm, body, 'total_phases', 'Total Phases');
273
+ const progressRaw = fmScalarKey(progressFm, 'percent') ??
274
+ fmScalar(fm, body, 'progress', 'Progress');
275
+ // Blockers list: `- <text>` items under a `## Blockers` heading.
276
+ const blockers = [];
277
+ const blockersMatch = body.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); // allow-adhoc-markdown: read-only blockers section-collect in smart-entry.cts; mirrors state.cts (#1372), pending collectSection migration
278
+ if (blockersMatch) {
279
+ const items = blockersMatch[1].match(/^-\s+(.+)$/gm) || [];
280
+ for (const item of items)
281
+ blockers.push(item.replace(/^-\s+/, '').trim());
282
+ }
283
+ const paused = Boolean(pausedAtRaw && pausedAtRaw.trim());
284
+ // Stale = no recorded activity for IDLE_STALE_MS. Used only by idle-stranded.
285
+ // Computed here (with the clock seam) so the pure classify() stays a function
286
+ // of (signals, staleActivity) and detectSignals owns all disk reads.
287
+ const lastActivityMs = parseActivityTimestamp(lastActivityRaw);
288
+ const staleActivity = lastActivityMs !== null && now() - lastActivityMs > IDLE_STALE_MS;
289
+ // Verify-failed may be signalled either by STATE.md status or by a failed
290
+ // STATUS: marker on the current phase's summary/verify artifact.
291
+ const verifyFailed = /\bverify-fail(ed)?|verification-fail|uat-fail\b/i.test(statusRaw || '') ||
292
+ detectVerifyFailed(cwd, currentPhaseRaw);
293
+ return {
294
+ current_phase: parseIntOrNull(currentPhaseRaw),
295
+ total_phases: parseIntOrNull(totalPhasesRaw),
296
+ status: (statusRaw || '').toLowerCase(),
297
+ progress: parseIntOrNull(progressRaw),
298
+ has_planning: hasPlanning,
299
+ has_roadmap: hasRoadmap,
300
+ git_dirty: git.dirty,
301
+ git_unpushed: git.unpushed,
302
+ paused,
303
+ blockers,
304
+ has_git: git.has_git,
305
+ verify_failed: verifyFailed,
306
+ stale_activity: staleActivity,
307
+ };
308
+ }
309
+ // ─── Situation classification ─────────────────────────────────────────────────
310
+ /** True when the workflow has fully completed all phases. */
311
+ function isComplete(s) {
312
+ if (s.total_phases === null || s.current_phase === null)
313
+ return false;
314
+ return s.current_phase >= s.total_phases && /\bcomplete(d)?|done|shipped\b/i.test(s.status);
315
+ }
316
+ /** Idle-stranded: clean tree, committed work not shipped, optionally stale. */
317
+ function isIdleStranded(s) {
318
+ if (/\bcomplete(d)?|done|shipped|paused\b/i.test(s.status))
319
+ return false;
320
+ if (s.git_dirty)
321
+ return false;
322
+ // Unpushed commits are the strongest signal. Otherwise require staleness.
323
+ return s.git_unpushed || s.stale_activity;
324
+ }
325
+ /**
326
+ * Classify signals into a situation. Priority order matters — the first
327
+ * matching predicate wins (e.g. paused beats blocked). Exported for unit tests.
328
+ * Pure function of signals (staleness + verify-failed are precomputed on signals).
329
+ */
330
+ function classify(s) {
331
+ if (!s.has_planning)
332
+ return 'no-project';
333
+ if (s.paused)
334
+ return 'paused';
335
+ if (s.blockers.length > 0)
336
+ return 'blocked';
337
+ if (s.verify_failed)
338
+ return 'verify-failed';
339
+ if (s.total_phases === null || s.total_phases <= 0 || !s.has_roadmap)
340
+ return 'needs-first-phase';
341
+ if (isComplete(s))
342
+ return 'complete';
343
+ if (/\bplanning|planned\b/i.test(s.status))
344
+ return 'planning';
345
+ if (/\bexecut(e|ing)|active|in.progress|building\b/i.test(s.status))
346
+ return 'executing';
347
+ if (/\bverify|review|needs.review|pending.review\b/i.test(s.status))
348
+ return 'verify-pending';
349
+ if (isIdleStranded(s))
350
+ return 'idle-stranded';
351
+ return 'unknown';
352
+ }
353
+ // ─── Action sets ──────────────────────────────────────────────────────────────
354
+ const action = (id, label, command, recommended = false) => ({ id, label, command, recommended });
355
+ function rec(id, label, command) {
356
+ return action(id, label, command, true);
357
+ }
358
+ /** Per-situation ordered action list; recommended action first. */
359
+ function actionsFor(situation, s) {
360
+ const phaseN = s.current_phase ?? '';
361
+ const execLabel = phaseN === '' ? 'Continue executing' : `Continue executing phase ${phaseN}`;
362
+ switch (situation) {
363
+ case 'no-project':
364
+ return [
365
+ rec('new-project', 'Start a new project', '/gsd:new-project'),
366
+ action('map-codebase', 'Map an existing codebase', '/gsd:map-codebase'),
367
+ action('quick', 'Quick task', '/gsd:quick'),
368
+ action('help', 'Show help', '/gsd:help'),
369
+ ];
370
+ case 'paused':
371
+ return [
372
+ rec('resume-work', 'Resume work', '/gsd:resume-work'),
373
+ action('progress', 'Show progress', '/gsd:progress'),
374
+ action('quick', 'Quick task', '/gsd:quick'),
375
+ action('help', 'Show help', '/gsd:help'),
376
+ ];
377
+ case 'blocked':
378
+ return [
379
+ rec('debug', 'Debug the blocker', '/gsd:debug'),
380
+ action('verify-work', 'Verify current work', '/gsd:verify-work'),
381
+ action('capture', 'Capture a note', '/gsd:capture'),
382
+ action('progress', 'Show progress', '/gsd:progress'),
383
+ ];
384
+ case 'verify-failed':
385
+ return [
386
+ rec('verify-work', 'Re-verify work', '/gsd:verify-work'),
387
+ action('debug', 'Debug the failure', '/gsd:debug'),
388
+ action('code-review', 'Review recent work', '/gsd:code-review'),
389
+ action('progress', 'Show progress', '/gsd:progress'),
390
+ ];
391
+ case 'needs-first-phase':
392
+ return [
393
+ rec('discuss-phase', 'Discuss the first phase', '/gsd:discuss-phase'),
394
+ action('plan-phase', 'Plan phase 1', '/gsd:plan-phase'),
395
+ action('quick', 'Quick task', '/gsd:quick'),
396
+ action('progress', 'Show progress', '/gsd:progress'),
397
+ ];
398
+ case 'planning':
399
+ // Forward motion → delegate to the single gated engine (see 'executing').
400
+ return [
401
+ rec('progress-next', `Advance to the next step (plan phase ${phaseN || 1})`, '/gsd:progress --next'),
402
+ action('plan-phase', `Plan phase ${phaseN || 1}`, '/gsd:plan-phase'),
403
+ action('discuss-phase', 'Discuss before planning', '/gsd:discuss-phase'),
404
+ action('progress', 'Show progress', '/gsd:progress'),
405
+ ];
406
+ case 'executing':
407
+ // In-project forward motion delegates to the single gated engine
408
+ // (/gsd:progress --next → workflows/next.md). Its Route 0 resume-incomplete
409
+ // -phase invariant + Gates 1-3 must not be bypassed by dispatching a raw
410
+ // /gsd:execute-phase here — that divergence is why the old flat /gsd-next
411
+ // was removed (#3054). Direct execute stays as an explicit secondary choice.
412
+ return [
413
+ rec('progress-next', 'Advance to the next step', '/gsd:progress --next'),
414
+ action('execute-phase', execLabel, '/gsd:execute-phase'),
415
+ action('quick', 'Quick task', '/gsd:quick'),
416
+ action('code-review', 'Review recent work', '/gsd:code-review'),
417
+ ];
418
+ case 'verify-pending':
419
+ // Forward motion → delegate to the single gated engine (see 'executing').
420
+ return [
421
+ rec('progress-next', 'Advance to the next step (verify)', '/gsd:progress --next'),
422
+ action('verify-work', 'Verify work', '/gsd:verify-work'),
423
+ action('code-review', 'Review recent work', '/gsd:code-review'),
424
+ action('ship', 'Ship completed work', '/gsd:ship'),
425
+ ];
426
+ case 'idle-stranded':
427
+ return [
428
+ rec('ship', 'Ship committed work', '/gsd:ship'),
429
+ action('complete-milestone', 'Complete the milestone', '/gsd:complete-milestone'),
430
+ action('progress', 'Show progress', '/gsd:progress'),
431
+ action('capture', 'Capture a note', '/gsd:capture'),
432
+ ];
433
+ case 'complete':
434
+ return [
435
+ rec('new-milestone', 'Start a new milestone', '/gsd:new-milestone'),
436
+ action('extract-learnings', 'Extract learnings', '/gsd:extract-learnings'),
437
+ action('quick', 'Quick task', '/gsd:quick'),
438
+ action('progress', 'Show progress', '/gsd:progress'),
439
+ ];
440
+ case 'unknown':
441
+ default:
442
+ return [
443
+ rec('progress', 'Show progress', '/gsd:progress'),
444
+ action('progress-next', 'Advance to the next step', '/gsd:progress --next'),
445
+ action('quick', 'Quick task', '/gsd:quick'),
446
+ action('help', 'Show help', '/gsd:help'),
447
+ ];
448
+ }
449
+ }
450
+ // ─── Summary line ─────────────────────────────────────────────────────────────
451
+ function buildSummary(situation, s) {
452
+ switch (situation) {
453
+ case 'no-project':
454
+ return 'No project yet — start fresh or map an existing codebase';
455
+ case 'paused':
456
+ return 'Work is paused — pick up where you left off';
457
+ case 'blocked':
458
+ return `Blocked${s.blockers.length ? ` · ${s.blockers.length} blocker(s)` : ''} — resolve before continuing`;
459
+ case 'verify-failed':
460
+ return 'Recent verification failed — re-verify or debug';
461
+ case 'needs-first-phase':
462
+ return 'Project initialized — plan your first phase';
463
+ case 'planning':
464
+ return `Phase ${s.current_phase ?? '?'} of ${s.total_phases ?? '?'} — needs a plan`;
465
+ case 'executing':
466
+ return progressLine('executing', s);
467
+ case 'verify-pending':
468
+ return progressLine('ready to verify', s);
469
+ case 'idle-stranded':
470
+ return 'Committed work not shipped — time to ship';
471
+ case 'complete':
472
+ return 'All phases complete — start a new milestone';
473
+ case 'unknown':
474
+ default:
475
+ return 'Unsure of state — showing progress';
476
+ }
477
+ }
478
+ function progressLine(tail, s) {
479
+ const parts = [];
480
+ if (s.current_phase !== null && s.total_phases !== null) {
481
+ parts.push(`Phase ${s.current_phase} of ${s.total_phases}`);
482
+ }
483
+ else if (s.current_phase !== null) {
484
+ parts.push(`Phase ${s.current_phase}`);
485
+ }
486
+ if (s.progress !== null)
487
+ parts.push(`${s.progress}%`);
488
+ parts.push(tail);
489
+ return parts.join(' · ');
490
+ }
491
+ // ─── Orchestration ─────────────────────────────────────────────────────────────
492
+ /**
493
+ * Run the full detect → classify → assemble pipeline. Pure: no stdout, no
494
+ * writes. Exported for direct unit testing and reuse. The clock seam drives
495
+ * staleness detection inside detectSignals.
496
+ */
497
+ function classifyProject(cwd, now = Date.now) {
498
+ const signals = detectSignals(cwd, now);
499
+ const situation = classify(signals);
500
+ const actions = actionsFor(situation, signals);
501
+ const recommended = actions.find((a) => a.recommended)?.id ?? 'progress';
502
+ const summary = buildSummary(situation, signals);
503
+ return { situation, recommended, summary, signals, actions };
504
+ }
505
+ /**
506
+ * `gsd-tools smart-entry` entry point. `--json` emits machine JSON; default
507
+ * emits a human-readable summary line. Never throws for a missing `.planning/`
508
+ * (returns `no-project`); other internal failures surface a typed error via
509
+ * `output()` so the workflow can fall back to `/gsd:progress`.
510
+ */
511
+ function runSmartEntry(cwd, args, raw) {
512
+ const json = args.includes('--json');
513
+ const result = classifyProject(cwd);
514
+ if (json) {
515
+ output(result, raw, undefined);
516
+ return;
517
+ }
518
+ // Human mode: a compact one-liner plus the recommended action.
519
+ const human = `${result.summary}\nRecommended: ${result.recommended} → ${result.actions.find((a) => a.id === result.recommended)?.command ?? '/gsd:progress'}`;
520
+ output(null, true, human);
521
+ }
@@ -19,6 +19,7 @@ exports.STATE_MD_SECTIONS = exports.FIELD_CLASSIFICATION = void 0;
19
19
  exports.getFieldClassification = getFieldClassification;
20
20
  exports.applyStatePreservation = applyStatePreservation;
21
21
  exports.transitionCore = transitionCore;
22
+ exports.sliceCurrentPositionSection = sliceCurrentPositionSection;
22
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
23
24
  const frontmatter = require("./frontmatter.cjs");
24
25
  const state_document_cjs_1 = require("./state-document.cjs");
@@ -319,6 +320,20 @@ function locateCurrentPosition(body) {
319
320
  }
320
321
  return { start, end };
321
322
  }
323
+ /**
324
+ * Return the body text of the `## Current Position` section, or `null` when it
325
+ * is absent. Reuses the fence-aware `locateCurrentPosition` locator (ADR-1372).
326
+ *
327
+ * Exposed so callers that must read a position field (e.g. `cmdStatePrune`,
328
+ * #1776) can scope extraction to the canonical section instead of the whole
329
+ * document — where `stateExtractField`'s pipe-table fallback could otherwise
330
+ * latch onto an unrelated `| Phase | N |` row elsewhere in STATE.md. This
331
+ * scopes the *caller*; the shared extractor is left broad for every other use.
332
+ */
333
+ function sliceCurrentPositionSection(body) {
334
+ const span = locateCurrentPosition(body);
335
+ return span === null ? null : body.slice(span.start, span.end);
336
+ }
322
337
  /**
323
338
  * First-time ## Current Position mutation: update Phase / Plan / Status /
324
339
  * Last activity lines. Mirrors state.cts:2261-2324 byte-for-behaviour
@@ -35,7 +35,7 @@ const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
35
35
  const scanPhasePlans = require("./plan-scan.cjs");
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
37
  const stateTransitionMod = require("./state-transition.cjs");
38
- const { transitionCore, applyStatePreservation } = stateTransitionMod;
38
+ const { transitionCore, applyStatePreservation, sliceCurrentPositionSection } = stateTransitionMod;
39
39
  const state_document_cjs_1 = require("./state-document.cjs");
40
40
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
41
41
  const STATE_PROGRESS_RESYNC_FIELDS = new Set([
@@ -1293,7 +1293,8 @@ function buildStateFrontmatter(bodyContent, cwd) {
1293
1293
  // truth for total_phases (#549).
1294
1294
  let roadmapPhaseCount = 0;
1295
1295
  if (roadmapScope !== null) {
1296
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1296
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1297
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
1297
1298
  let m;
1298
1299
  while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
1299
1300
  // Only count tokens that contain at least one digit — excludes
@@ -2245,7 +2246,8 @@ function cmdStateSync(cwd, options, raw) {
2245
2246
  try {
2246
2247
  let roadmapPhaseCount = 0;
2247
2248
  if (syncRoadmapScope !== null) {
2248
- const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
2249
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
2250
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
2249
2251
  let m;
2250
2252
  while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
2251
2253
  // Only count tokens that contain at least one digit — excludes
@@ -2321,12 +2323,29 @@ function cmdStatePrune(cwd, options, raw) {
2321
2323
  }
2322
2324
  const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
2323
2325
  const dryRun = !!options.dryRun;
2324
- // #1760: the canonical STATE.md template emits `Phase: [X] of [Y]`, not
2325
- // `Current Phase:`. Read both (mirroring buildStateFrontmatter /
2326
- // resolvePhaseIdForCompletePhase) so prune engages on template-conformant
2327
- // STATE.md instead of bailing with "Only 0 phases — nothing to prune".
2326
+ // Resolve the current phase via the same canonical chain buildStateFrontmatter
2327
+ // uses (frontmatter `current_phase` → `Current Phase` field → prose `Phase: X
2328
+ // of Y`), so prune engages on template-conformant STATE.md instead of bailing
2329
+ // "Only 0 phases" (#1760).
2330
+ // #1776: scope ONLY the prose `Phase:` term to the canonical `## Current
2331
+ // Position` section. Over the whole body, `stateExtractField`'s pipe-table
2332
+ // fallback matches any `| Phase | N |` row (e.g. a historical verification
2333
+ // table), resolving a stale phase and computing a wrong cutoff. Frontmatter and
2334
+ // the explicit `Current Phase` field are unambiguous, so they stay document-wide;
2335
+ // the shared extractor is not narrowed for any other caller.
2328
2336
  const rawState = node_fs_1.default.readFileSync(statePath, 'utf-8');
2329
- const currentPhaseRaw = (0, state_document_cjs_1.stateExtractField)(rawState, 'Current Phase') || (0, state_document_cjs_1.stateExtractField)(rawState, 'Phase');
2337
+ const fm = extractFrontmatter(rawState);
2338
+ const body = stripFrontmatter(rawState);
2339
+ // Mirror buildStateFrontmatter's fmScalar: only string/number/boolean
2340
+ // frontmatter scalars are usable (an object/array `current_phase` is ignored,
2341
+ // which also avoids a base-to-string on a non-primitive).
2342
+ const fmRawPhase = fm.current_phase;
2343
+ const fmCurrentPhase = typeof fmRawPhase === 'string' ? (fmRawPhase.trim() || null)
2344
+ : typeof fmRawPhase === 'number' || typeof fmRawPhase === 'boolean' ? String(fmRawPhase)
2345
+ : null;
2346
+ const positionSection = sliceCurrentPositionSection(body);
2347
+ const prosePhase = positionSection !== null ? parseProsePhaseField((0, state_document_cjs_1.stateExtractField)(positionSection, 'Phase')).phase : null;
2348
+ const currentPhaseRaw = fmCurrentPhase ?? (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') ?? prosePhase;
2330
2349
  const currentPhase = parseInt(String(currentPhaseRaw), 10) || 0;
2331
2350
  const cutoff = currentPhase - keepRecent;
2332
2351
  if (cutoff <= 0) {
@@ -466,16 +466,25 @@ function _syncGsdDir(stagedDir, destDir, kind, manifest) {
466
466
  // - agents: only gsd-* are GSD-owned
467
467
  // - flat command dirs: only `${kindPrefix}`-prefixed are GSD-owned
468
468
  // - namespaced command dirs: the whole dir is GSD-owned
469
- for (const file of node_fs_1.default.readdirSync(destDir).filter(f => f.endsWith('.md'))) {
470
- if (kindName === 'agents' && !file.startsWith('gsd-'))
471
- continue;
472
- if (kindName === 'commands' && !namespacedByDir && kindPrefix && !file.startsWith(kindPrefix))
473
- continue;
474
- if (!stagedDestNames.has(file)) {
475
- try {
476
- node_fs_1.default.unlinkSync(node_path_1.default.join(destDir, file));
469
+ //
470
+ // Manifest gate (#2018): when the manifest is empty/absent (e.g. an unresolvable
471
+ // install source root yields an empty staged dir), the staged set is untrustworthy.
472
+ // Skills are guarded by pruneSkillDirs' manifest-membership check; agents must be
473
+ // guarded here — skip the prune loop entirely so an empty manifest never deletes
474
+ // every gsd-* agent. Copying (above) still runs so genuinely new agents are added.
475
+ const shouldPruneAgents = !(kindName === 'agents' && (!manifest || manifest.size === 0));
476
+ if (shouldPruneAgents) {
477
+ for (const file of node_fs_1.default.readdirSync(destDir).filter(f => f.endsWith('.md'))) {
478
+ if (kindName === 'agents' && !file.startsWith('gsd-'))
479
+ continue;
480
+ if (kindName === 'commands' && !namespacedByDir && kindPrefix && !file.startsWith(kindPrefix))
481
+ continue;
482
+ if (!stagedDestNames.has(file)) {
483
+ try {
484
+ node_fs_1.default.unlinkSync(node_path_1.default.join(destDir, file));
485
+ }
486
+ catch { /* ignore */ }
477
487
  }
478
- catch { /* ignore */ }
479
488
  }
480
489
  }
481
490
  }