@garygentry/feature-forge 0.2.3 → 0.2.5
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.
- package/README.md +6 -1
- package/adapters/GENERATION-REPORT.md +5 -1
- package/adapters/claude/.feature-forge-bundle.json +1 -1
- package/adapters/claude/references/forge-config-schema.json +25 -3
- package/adapters/claude/references/pipeline-state-schema.json +3 -2
- package/adapters/claude/references/portable-root.md +6 -3
- package/adapters/claude/references/process-overview.md +10 -0
- package/adapters/claude/references/shared-conventions.md +27 -9
- package/adapters/claude/references/stage-exit-protocol.md +206 -0
- package/adapters/claude/scripts/epic-manifest.py +10 -0
- package/adapters/claude/scripts/forge-bootstrap.py +94 -16
- package/adapters/claude/scripts/forge-init.sh +7 -1
- package/adapters/claude/scripts/forge-root.sh +20 -1
- package/adapters/claude/scripts/forge-session.py +866 -31
- package/adapters/claude/skills/forge/SKILL.md +29 -15
- package/adapters/claude/skills/forge-0-epic/SKILL.md +19 -24
- package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +6 -4
- package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/claude/skills/forge-1-prd/SKILL.md +13 -4
- package/adapters/claude/skills/forge-2-tech/SKILL.md +13 -3
- package/adapters/claude/skills/forge-3-specs/SKILL.md +13 -3
- package/adapters/claude/skills/forge-4-backlog/SKILL.md +15 -5
- package/adapters/claude/skills/forge-5-loop/SKILL.md +19 -21
- package/adapters/claude/skills/forge-5-loop/references/result-reporting.md +10 -5
- package/adapters/claude/skills/forge-6-docs/SKILL.md +6 -6
- package/adapters/claude/skills/forge-bootstrap/SKILL.md +4 -4
- package/adapters/claude/skills/forge-fix/SKILL.md +29 -6
- package/adapters/claude/skills/forge-guide/SKILL.md +182 -0
- package/adapters/claude/skills/forge-init/SKILL.md +29 -1
- package/adapters/claude/skills/forge-verify/SKILL.md +46 -15
- package/adapters/claude/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/codex/.feature-forge-bundle.json +1 -1
- package/adapters/codex/references/forge-config-schema.json +25 -3
- package/adapters/codex/references/pipeline-state-schema.json +3 -2
- package/adapters/codex/references/portable-root.md +6 -3
- package/adapters/codex/references/process-overview.md +10 -0
- package/adapters/codex/references/shared-conventions.md +27 -9
- package/adapters/codex/references/stage-exit-protocol.md +206 -0
- package/adapters/codex/scripts/epic-manifest.py +10 -0
- package/adapters/codex/scripts/forge-bootstrap.py +94 -16
- package/adapters/codex/scripts/forge-init.sh +7 -1
- package/adapters/codex/scripts/forge-root.sh +20 -1
- package/adapters/codex/scripts/forge-session.py +866 -31
- package/adapters/codex/skills/forge/SKILL.md +34 -20
- package/adapters/codex/skills/forge-0-epic/SKILL.md +20 -25
- package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +6 -4
- package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/codex/skills/forge-1-prd/SKILL.md +13 -4
- package/adapters/codex/skills/forge-2-tech/SKILL.md +14 -4
- package/adapters/codex/skills/forge-3-specs/SKILL.md +13 -3
- package/adapters/codex/skills/forge-4-backlog/SKILL.md +15 -5
- package/adapters/codex/skills/forge-5-loop/SKILL.md +21 -23
- package/adapters/codex/skills/forge-5-loop/references/result-reporting.md +10 -5
- package/adapters/codex/skills/forge-6-docs/SKILL.md +6 -6
- package/adapters/codex/skills/forge-bootstrap/SKILL.md +4 -4
- package/adapters/codex/skills/forge-fix/SKILL.md +29 -6
- package/adapters/codex/skills/forge-guide/SKILL.md +191 -0
- package/adapters/codex/skills/forge-init/SKILL.md +29 -1
- package/adapters/codex/skills/forge-verify/SKILL.md +45 -14
- package/adapters/codex/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/copilot/.feature-forge-bundle.json +1 -1
- package/adapters/copilot/references/forge-config-schema.json +25 -3
- package/adapters/copilot/references/pipeline-state-schema.json +3 -2
- package/adapters/copilot/references/portable-root.md +6 -3
- package/adapters/copilot/references/process-overview.md +10 -0
- package/adapters/copilot/references/shared-conventions.md +27 -9
- package/adapters/copilot/references/stage-exit-protocol.md +206 -0
- package/adapters/copilot/scripts/epic-manifest.py +10 -0
- package/adapters/copilot/scripts/forge-bootstrap.py +94 -16
- package/adapters/copilot/scripts/forge-init.sh +7 -1
- package/adapters/copilot/scripts/forge-root.sh +20 -1
- package/adapters/copilot/scripts/forge-session.py +866 -31
- package/adapters/copilot/skills/forge/forge.md +34 -20
- package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +20 -25
- package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +6 -4
- package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +13 -4
- package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +14 -4
- package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +13 -3
- package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +15 -5
- package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +21 -23
- package/adapters/copilot/skills/forge-5-loop/references/result-reporting.md +10 -5
- package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +6 -6
- package/adapters/copilot/skills/forge-bootstrap/forge-bootstrap.md +4 -4
- package/adapters/copilot/skills/forge-fix/forge-fix.md +29 -6
- package/adapters/copilot/skills/forge-guide/forge-guide.md +191 -0
- package/adapters/copilot/skills/forge-init/forge-init.md +29 -1
- package/adapters/copilot/skills/forge-verify/forge-verify.md +45 -14
- package/adapters/copilot/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/cursor/.feature-forge-bundle.json +1 -1
- package/adapters/cursor/references/forge-config-schema.json +25 -3
- package/adapters/cursor/references/pipeline-state-schema.json +3 -2
- package/adapters/cursor/references/portable-root.md +6 -3
- package/adapters/cursor/references/process-overview.md +10 -0
- package/adapters/cursor/references/shared-conventions.md +27 -9
- package/adapters/cursor/references/stage-exit-protocol.md +206 -0
- package/adapters/cursor/scripts/epic-manifest.py +10 -0
- package/adapters/cursor/scripts/forge-bootstrap.py +94 -16
- package/adapters/cursor/scripts/forge-init.sh +7 -1
- package/adapters/cursor/scripts/forge-root.sh +20 -1
- package/adapters/cursor/scripts/forge-session.py +866 -31
- package/adapters/cursor/skills/forge/forge.mdc +34 -20
- package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +20 -25
- package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +6 -4
- package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +13 -4
- package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +14 -4
- package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +13 -3
- package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +15 -5
- package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +21 -23
- package/adapters/cursor/skills/forge-5-loop/references/result-reporting.md +10 -5
- package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +6 -6
- package/adapters/cursor/skills/forge-bootstrap/forge-bootstrap.mdc +4 -4
- package/adapters/cursor/skills/forge-fix/forge-fix.mdc +29 -6
- package/adapters/cursor/skills/forge-guide/forge-guide.mdc +192 -0
- package/adapters/cursor/skills/forge-init/forge-init.mdc +29 -1
- package/adapters/cursor/skills/forge-verify/forge-verify.mdc +45 -14
- package/adapters/cursor/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/gemini/.feature-forge-bundle.json +1 -1
- package/adapters/gemini/gemini-extension.json +5 -1
- package/adapters/gemini/references/forge-config-schema.json +25 -3
- package/adapters/gemini/references/pipeline-state-schema.json +3 -2
- package/adapters/gemini/references/portable-root.md +6 -3
- package/adapters/gemini/references/process-overview.md +10 -0
- package/adapters/gemini/references/shared-conventions.md +27 -9
- package/adapters/gemini/references/stage-exit-protocol.md +206 -0
- package/adapters/gemini/scripts/epic-manifest.py +10 -0
- package/adapters/gemini/scripts/forge-bootstrap.py +94 -16
- package/adapters/gemini/scripts/forge-init.sh +7 -1
- package/adapters/gemini/scripts/forge-root.sh +20 -1
- package/adapters/gemini/scripts/forge-session.py +866 -31
- package/adapters/gemini/skills/forge/forge.md +34 -20
- package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +20 -25
- package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +6 -4
- package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +13 -4
- package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +14 -4
- package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +13 -3
- package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +15 -5
- package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +21 -23
- package/adapters/gemini/skills/forge-5-loop/references/result-reporting.md +10 -5
- package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +6 -6
- package/adapters/gemini/skills/forge-bootstrap/forge-bootstrap.md +4 -4
- package/adapters/gemini/skills/forge-fix/forge-fix.md +29 -6
- package/adapters/gemini/skills/forge-guide/forge-guide.md +191 -0
- package/adapters/gemini/skills/forge-init/forge-init.md +29 -1
- package/adapters/gemini/skills/forge-verify/forge-verify.md +45 -14
- package/adapters/gemini/skills/forge-verify/references/verification-checklists.md +1 -1
- package/dist/apply.js +34 -8
- package/dist/cli.js +40 -4
- package/dist/fsutil.d.ts +0 -12
- package/dist/fsutil.js +10 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/plan.js +22 -2
- package/dist/rauf.d.ts +4 -4
- package/dist/rauf.js +3 -3
- package/dist/report.js +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Session-aware navigation helpers for the feature-forge pipeline navigator.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Read-only subcommands that drive the usability features of the `/forge`
|
|
5
5
|
root navigator:
|
|
6
6
|
|
|
7
7
|
python3 forge-session.py rank-features [--specs-dir DIR] [--json]
|
|
8
8
|
python3 forge-session.py context-usage [--config FILE] [--window N] \
|
|
9
9
|
[--threshold F] [--json]
|
|
10
|
+
python3 forge-session.py doctor [--specs-dir DIR] [--config FILE] [--json]
|
|
11
|
+
python3 forge-session.py discover-feature NAME [--specs-dir DIR] [--json]
|
|
12
|
+
python3 forge-session.py stage-exit --feature F --stage S [--specs-dir DIR] \
|
|
13
|
+
[--config FILE] [--epic E] [--next-feature N] [--host claude|generic] [--json]
|
|
10
14
|
|
|
11
15
|
`rank-features` scans the specs tree for feature-shaped directories (those that
|
|
12
16
|
directly contain a `.pipeline-state.json`, in both the flat
|
|
@@ -24,6 +28,30 @@ and degrades gracefully: when no transcript or usage is found (a non-Claude host
|
|
|
24
28
|
or a fresh session) it reports `{"available": false}` and still exits 0, so the
|
|
25
29
|
caller simply omits the context advice.
|
|
26
30
|
|
|
31
|
+
`doctor` captures pipeline ground truth in one shot for debugging a confused
|
|
32
|
+
session or a broken install: the plugin root the sibling `forge-root.sh`
|
|
33
|
+
actually resolves (plus its version and commit), the current git branch vs.
|
|
34
|
+
each feature's recorded state branch, the recency-ranked feature summary, and
|
|
35
|
+
whether each feature's composed backlog path exists on disk. Every probe is
|
|
36
|
+
best-effort — a failure is reported as data, never as a crash — and the
|
|
37
|
+
command always exits 0 so it can run in any half-broken environment.
|
|
38
|
+
|
|
39
|
+
`discover-feature` looks for a feature's `.pipeline-state.json` across ALL
|
|
40
|
+
git branches (local heads and remote-tracking refs), so a session on the
|
|
41
|
+
default branch can learn that a pipeline exists on a topic branch instead of
|
|
42
|
+
concluding it was never started. When nothing is found locally it also asks
|
|
43
|
+
`git ls-remote --heads origin` about branches a single-branch clone never
|
|
44
|
+
fetched, and emits the exact `git fetch`/`git switch` commands a caller could
|
|
45
|
+
run. It is strictly read-only — it never checks anything out itself — and
|
|
46
|
+
like `doctor` it always exits 0 and degrades to data.
|
|
47
|
+
|
|
48
|
+
`stage-exit` computes everything an authoring stage's closing used to derive
|
|
49
|
+
in prose (the Scripted Stage Exit, `references/stage-exit-protocol.md`):
|
|
50
|
+
the DIRECTIVES (whether the in-stage auto-verify runs, which verify gate to
|
|
51
|
+
present, autoFix eligibility, the verify and next-stage commands) plus the
|
|
52
|
+
exact sentinel-terminated NEXT-STEPS block the skill must print verbatim as
|
|
53
|
+
its absolute last output. Deterministic and read-only; always exits 0.
|
|
54
|
+
|
|
27
55
|
3.10 baseline, Google-style docstrings, full type annotations, stdlib only —
|
|
28
56
|
matching the conventions of `scripts/epic-manifest.py`.
|
|
29
57
|
|
|
@@ -36,8 +64,9 @@ from __future__ import annotations
|
|
|
36
64
|
|
|
37
65
|
import argparse
|
|
38
66
|
import json
|
|
67
|
+
import subprocess
|
|
39
68
|
import sys
|
|
40
|
-
from datetime import datetime
|
|
69
|
+
from datetime import datetime, timezone
|
|
41
70
|
from pathlib import Path
|
|
42
71
|
from typing import Final, TypedDict
|
|
43
72
|
|
|
@@ -102,6 +131,10 @@ class FeatureRow(TypedDict):
|
|
|
102
131
|
nextCommand: str | None
|
|
103
132
|
verifyPending: bool
|
|
104
133
|
verifyCommand: str | None
|
|
134
|
+
verifyStage: str | None
|
|
135
|
+
verifyState: str
|
|
136
|
+
autoVerify: bool
|
|
137
|
+
autoFix: bool
|
|
105
138
|
|
|
106
139
|
|
|
107
140
|
class UsageError(Exception):
|
|
@@ -181,13 +214,52 @@ def next_stage(state: dict) -> str | None:
|
|
|
181
214
|
return None
|
|
182
215
|
|
|
183
216
|
|
|
184
|
-
def
|
|
185
|
-
"""Return the
|
|
217
|
+
def _stage_version(state: dict, stage: str) -> int | None:
|
|
218
|
+
"""Return the recorded ``version`` of a stage entry, or None if absent."""
|
|
219
|
+
stages = state.get("stages")
|
|
220
|
+
if not isinstance(stages, dict):
|
|
221
|
+
return None
|
|
222
|
+
entry = stages.get(stage)
|
|
223
|
+
if not isinstance(entry, dict):
|
|
224
|
+
return None
|
|
225
|
+
version = entry.get("version")
|
|
226
|
+
return version if isinstance(version, int) else None
|
|
186
227
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
228
|
+
|
|
229
|
+
def _verify_entry(state: dict, verify_key: str) -> dict:
|
|
230
|
+
"""Return the ``forge-verify-*`` entry dict, or ``{}`` if absent."""
|
|
231
|
+
stages = state.get("stages")
|
|
232
|
+
if not isinstance(stages, dict):
|
|
233
|
+
return {}
|
|
234
|
+
entry = stages.get(verify_key)
|
|
235
|
+
return entry if isinstance(entry, dict) else {}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def verify_state(state: dict) -> tuple[str | None, str]:
|
|
239
|
+
"""Classify verify freshness for the most-recently-completed stage.
|
|
240
|
+
|
|
241
|
+
Returns ``(stage, state_label)`` where ``state_label`` is one of:
|
|
242
|
+
|
|
243
|
+
- ``fresh`` — verify is resolved AND its ``verifiedStageVersion`` matches the
|
|
244
|
+
stage's current ``version`` (so no re-verify is needed).
|
|
245
|
+
- ``stale`` — verify was resolved once, but the stage version has since moved
|
|
246
|
+
(artifact revised) OR the entry predates the freshness ledger (no
|
|
247
|
+
``verifiedStageVersion``). A revised artifact must be re-verified.
|
|
248
|
+
- ``failing`` — verify ran and reported findings that are not yet applied
|
|
249
|
+
(``findings-reported``).
|
|
250
|
+
- ``never`` — the stage completed but verify has not run at all.
|
|
251
|
+
- ``skipped`` — the user explicitly chose to proceed without verifying. A
|
|
252
|
+
resolved, non-pending state: it is deliberately NOT re-offered or
|
|
253
|
+
auto-verified, and (unlike a genuine verification result) it does not go
|
|
254
|
+
stale on an artifact revision — skip writers record no version to compare
|
|
255
|
+
against, and re-surfacing would override an explicit human decision.
|
|
256
|
+
- ``none`` — no completed verify-capable stage (nothing to verify), stage
|
|
257
|
+
is ``None``.
|
|
258
|
+
|
|
259
|
+
Only the most-recent completed production stage is considered, matching the
|
|
260
|
+
navigator's "verify before continuing" gate. Absent ``verifiedStageVersion``
|
|
261
|
+
on a ``passed``/``findings-applied`` entry (legacy state) is deliberately
|
|
262
|
+
treated as ``stale`` — verify rather than skip.
|
|
191
263
|
"""
|
|
192
264
|
for stage in reversed(PRODUCTION_STAGES):
|
|
193
265
|
if _stage_status(state, stage) != _DONE_STATUS:
|
|
@@ -195,11 +267,41 @@ def pending_verify(state: dict) -> str | None:
|
|
|
195
267
|
token = VERIFY_TOKEN_BY_STAGE.get(stage)
|
|
196
268
|
if token is None:
|
|
197
269
|
continue # forge-6-docs has no verify step
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
270
|
+
entry = _verify_entry(state, f"forge-verify-{token}")
|
|
271
|
+
status = entry.get("status")
|
|
272
|
+
if status == "skipped":
|
|
273
|
+
# An explicit skip is resolved and non-pending — preserve the user's
|
|
274
|
+
# decision. It never goes stale (no recorded version to compare), so
|
|
275
|
+
# the freshness check below deliberately does not apply.
|
|
276
|
+
return stage, "skipped"
|
|
277
|
+
if status not in _VERIFY_RESOLVED:
|
|
278
|
+
if status == "findings-reported":
|
|
279
|
+
return stage, "failing"
|
|
280
|
+
return stage, "never"
|
|
281
|
+
verified_version = entry.get("verifiedStageVersion")
|
|
282
|
+
stage_version = _stage_version(state, stage)
|
|
283
|
+
if (
|
|
284
|
+
isinstance(verified_version, int)
|
|
285
|
+
and stage_version is not None
|
|
286
|
+
and verified_version == stage_version
|
|
287
|
+
):
|
|
288
|
+
return stage, "fresh"
|
|
289
|
+
return stage, "stale"
|
|
290
|
+
return None, "none"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def pending_verify(state: dict) -> str | None:
|
|
294
|
+
"""Return the production stage whose verify is outstanding, if any.
|
|
295
|
+
|
|
296
|
+
Outstanding means the most-recently-completed production stage's verify is not
|
|
297
|
+
``fresh`` (never run, reported findings, or gone stale after an artifact
|
|
298
|
+
revision). An explicit ``skipped`` is treated as resolved (never outstanding).
|
|
299
|
+
Surfaced so the navigator can offer "verify before continuing" as an
|
|
300
|
+
alternative to advancing. Returns ``None`` when the latest stage is fresh,
|
|
301
|
+
skipped, or there is nothing to verify.
|
|
302
|
+
"""
|
|
303
|
+
stage, label = verify_state(state)
|
|
304
|
+
return stage if label not in ("fresh", "none", "skipped") else None
|
|
203
305
|
|
|
204
306
|
|
|
205
307
|
def _parse_ts(value: str | None) -> datetime | None:
|
|
@@ -207,25 +309,37 @@ def _parse_ts(value: str | None) -> datetime | None:
|
|
|
207
309
|
if not isinstance(value, str):
|
|
208
310
|
return None
|
|
209
311
|
try:
|
|
210
|
-
|
|
312
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
211
313
|
except ValueError:
|
|
212
314
|
return None
|
|
315
|
+
if dt.tzinfo is None:
|
|
316
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
317
|
+
return dt
|
|
213
318
|
|
|
214
319
|
|
|
215
|
-
def build_rows(specs_dir: Path) -> list[FeatureRow]:
|
|
320
|
+
def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
|
|
216
321
|
"""Build the recency-ranked active-feature rows (the rank-features payload).
|
|
217
322
|
|
|
218
323
|
Active features (``pipelineStatus == "active"``, the default when absent) are
|
|
219
324
|
sorted by ``updatedAt`` descending — most recently touched first — so the
|
|
220
325
|
navigator's recency default is row 0.
|
|
326
|
+
|
|
327
|
+
``config`` is the loaded forge.config.json (or ``{}``); it drives the effective
|
|
328
|
+
``autoVerify``/``autoFix`` per stage so the navigator can branch without
|
|
329
|
+
re-reading config.
|
|
221
330
|
"""
|
|
331
|
+
config = config or {}
|
|
332
|
+
# Fail closed: only a literal JSON ``true`` enables artifact-mutating autoFix.
|
|
333
|
+
global_auto_fix = config.get("autoFix") is True
|
|
222
334
|
rows: list[FeatureRow] = []
|
|
223
335
|
for name, epic, state in _scan_features(specs_dir):
|
|
224
336
|
status = state.get("pipelineStatus", "active")
|
|
225
337
|
if status != "active":
|
|
226
338
|
continue
|
|
227
339
|
nxt = next_stage(state)
|
|
228
|
-
|
|
340
|
+
vstage, vlabel = verify_state(state)
|
|
341
|
+
verify_pending = vstage is not None and vlabel not in ("fresh", "none", "skipped")
|
|
342
|
+
effective_auto_verify = auto_verify_for(config, vstage) if vstage else False
|
|
229
343
|
branch = state.get("branch")
|
|
230
344
|
updated = state.get("updatedAt")
|
|
231
345
|
rows.append({
|
|
@@ -237,12 +351,16 @@ def build_rows(specs_dir: Path) -> list[FeatureRow]:
|
|
|
237
351
|
"complete": nxt is None,
|
|
238
352
|
"nextStage": nxt,
|
|
239
353
|
"nextCommand": f"/feature-forge:{nxt} {name}" if nxt else None,
|
|
240
|
-
"verifyPending":
|
|
241
|
-
"verifyCommand": f"/feature-forge:forge-verify {name}" if
|
|
354
|
+
"verifyPending": verify_pending,
|
|
355
|
+
"verifyCommand": f"/feature-forge:forge-verify {name}" if verify_pending else None,
|
|
356
|
+
"verifyStage": vstage,
|
|
357
|
+
"verifyState": vlabel,
|
|
358
|
+
"autoVerify": effective_auto_verify,
|
|
359
|
+
"autoFix": global_auto_fix and effective_auto_verify,
|
|
242
360
|
})
|
|
243
361
|
# Sort by updatedAt desc; rows without a parseable timestamp sort last.
|
|
244
362
|
rows.sort(
|
|
245
|
-
key=lambda r: (_parse_ts(r["updatedAt"]) or datetime.min.replace(tzinfo=
|
|
363
|
+
key=lambda r: (_parse_ts(r["updatedAt"]) or datetime.min.replace(tzinfo=timezone.utc)),
|
|
246
364
|
reverse=True,
|
|
247
365
|
)
|
|
248
366
|
return rows
|
|
@@ -308,12 +426,17 @@ def _last_usage(transcript: Path) -> tuple[int, str | None] | None:
|
|
|
308
426
|
usage = message.get("usage") if isinstance(message, dict) else record.get("usage")
|
|
309
427
|
if not isinstance(usage, dict):
|
|
310
428
|
continue
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
429
|
+
# A malformed transcript may carry a non-numeric usage field; skip that
|
|
430
|
+
# record rather than crash the whole context-usage read (ValueError/TypeError).
|
|
431
|
+
try:
|
|
432
|
+
total = (
|
|
433
|
+
int(usage.get("input_tokens", 0) or 0)
|
|
434
|
+
+ int(usage.get("cache_creation_input_tokens", 0) or 0)
|
|
435
|
+
+ int(usage.get("cache_read_input_tokens", 0) or 0)
|
|
436
|
+
+ int(usage.get("output_tokens", 0) or 0)
|
|
437
|
+
)
|
|
438
|
+
except (TypeError, ValueError):
|
|
439
|
+
continue
|
|
317
440
|
if total <= 0:
|
|
318
441
|
continue
|
|
319
442
|
model = message.get("model") if isinstance(message, dict) else record.get("model")
|
|
@@ -328,13 +451,53 @@ def _infer_window(model: str | None) -> int:
|
|
|
328
451
|
return _DEFAULT_WINDOW
|
|
329
452
|
|
|
330
453
|
|
|
331
|
-
def
|
|
332
|
-
"""Read
|
|
454
|
+
def _load_config(config_path: Path) -> dict:
|
|
455
|
+
"""Read forge.config.json into a dict, tolerating missing/corrupt files.
|
|
456
|
+
|
|
457
|
+
A missing, unreadable, or non-object config downgrades to ``{}`` so callers
|
|
458
|
+
read every key through absent-safe ``.get`` defaults.
|
|
459
|
+
"""
|
|
333
460
|
try:
|
|
334
461
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
335
462
|
except (OSError, json.JSONDecodeError):
|
|
336
|
-
return
|
|
337
|
-
return config
|
|
463
|
+
return {}
|
|
464
|
+
return config if isinstance(config, dict) else {}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _config_value(config_path: Path, key: str):
|
|
468
|
+
"""Read a single key from forge.config.json, or None if absent/unreadable."""
|
|
469
|
+
return _load_config(config_path).get(key)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def auto_verify_for(config: dict, stage: str) -> bool:
|
|
473
|
+
"""Return the effective auto-verify setting for ``stage``.
|
|
474
|
+
|
|
475
|
+
Per-stage override in ``autoVerifyStages`` wins over the global ``autoVerify``;
|
|
476
|
+
both default to off, so a config with neither key means "no auto-verify".
|
|
477
|
+
|
|
478
|
+
Parsing is strict and **fails closed**: only a literal JSON ``true`` enables
|
|
479
|
+
auto-verify. A non-boolean value (e.g. the string ``"false"``, which is truthy
|
|
480
|
+
in Python) is treated as off, not on. The schema already rejects non-booleans
|
|
481
|
+
at author time; this guards a hand-edited config from silently enabling
|
|
482
|
+
automation.
|
|
483
|
+
"""
|
|
484
|
+
stages = config.get("autoVerifyStages")
|
|
485
|
+
if isinstance(stages, dict) and stage in stages:
|
|
486
|
+
return stages[stage] is True
|
|
487
|
+
return config.get("autoVerify") is True
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def invalid_auto_verify_keys(config: dict) -> list[str]:
|
|
491
|
+
"""Return ``autoVerifyStages`` keys outside the verify-capable stage ids.
|
|
492
|
+
|
|
493
|
+
An unknown/typo key (e.g. ``forge-1-prod``) would silently never take effect,
|
|
494
|
+
turning an intended off-switch into a no-op. Surfacing it lets the navigator
|
|
495
|
+
warn instead of failing quietly. Mirrors the schema's ``propertyNames.enum``.
|
|
496
|
+
"""
|
|
497
|
+
stages = config.get("autoVerifyStages")
|
|
498
|
+
if not isinstance(stages, dict):
|
|
499
|
+
return []
|
|
500
|
+
return [key for key in stages if key not in VERIFY_TOKEN_BY_STAGE]
|
|
338
501
|
|
|
339
502
|
|
|
340
503
|
def context_usage(
|
|
@@ -407,6 +570,607 @@ def context_usage(
|
|
|
407
570
|
}
|
|
408
571
|
|
|
409
572
|
|
|
573
|
+
# --------------------------------------------------------------------------- #
|
|
574
|
+
# Doctor
|
|
575
|
+
# --------------------------------------------------------------------------- #
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _git_output(args: list[str]) -> str | None:
|
|
579
|
+
"""Run a read-only git command and return stripped stdout, or None.
|
|
580
|
+
|
|
581
|
+
Any failure (git missing, not a repo, nonzero exit, timeout) degrades to
|
|
582
|
+
``None`` — doctor reports absence rather than crashing.
|
|
583
|
+
"""
|
|
584
|
+
try:
|
|
585
|
+
proc = subprocess.run(
|
|
586
|
+
["git", *args], capture_output=True, text=True, timeout=10,
|
|
587
|
+
)
|
|
588
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
589
|
+
return None
|
|
590
|
+
if proc.returncode != 0:
|
|
591
|
+
return None
|
|
592
|
+
out = proc.stdout.strip()
|
|
593
|
+
return out or None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _resolve_plugin_root() -> dict:
|
|
597
|
+
"""Resolve the plugin root by running the sibling ``forge-root.sh``.
|
|
598
|
+
|
|
599
|
+
Uses the resolver that ships next to this script, so the answer reflects
|
|
600
|
+
the install this helper actually belongs to — exactly what a skill's
|
|
601
|
+
bootstrap prelude would find (or fail to find). On success the dict also
|
|
602
|
+
carries the root's ``version`` (from ``.claude-plugin/plugin.json`` or the
|
|
603
|
+
neutral ``.feature-forge-bundle.json``) and, when the root is a git
|
|
604
|
+
checkout, its short ``commit`` — enough to spot version skew between the
|
|
605
|
+
resolved root and the skills a session loaded.
|
|
606
|
+
"""
|
|
607
|
+
resolver = Path(__file__).resolve().parent / "forge-root.sh"
|
|
608
|
+
if not resolver.is_file():
|
|
609
|
+
return {"resolved": False, "error": f"resolver not found: {resolver}"}
|
|
610
|
+
try:
|
|
611
|
+
proc = subprocess.run(
|
|
612
|
+
["bash", str(resolver)], capture_output=True, text=True, timeout=10,
|
|
613
|
+
)
|
|
614
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
615
|
+
return {"resolved": False, "error": str(exc)}
|
|
616
|
+
if proc.returncode != 0:
|
|
617
|
+
return {
|
|
618
|
+
"resolved": False,
|
|
619
|
+
"error": proc.stderr.strip() or f"resolver exited {proc.returncode}",
|
|
620
|
+
}
|
|
621
|
+
root = proc.stdout.strip()
|
|
622
|
+
info: dict = {"resolved": True, "root": root}
|
|
623
|
+
for rel in (".claude-plugin/plugin.json", ".feature-forge-bundle.json"):
|
|
624
|
+
manifest = Path(root) / rel
|
|
625
|
+
if manifest.is_file():
|
|
626
|
+
version = _load_config(manifest).get("version")
|
|
627
|
+
if isinstance(version, str):
|
|
628
|
+
info["version"] = version
|
|
629
|
+
info["manifest"] = rel
|
|
630
|
+
break
|
|
631
|
+
commit = _git_output(["-C", root, "rev-parse", "--short", "HEAD"])
|
|
632
|
+
if commit:
|
|
633
|
+
info["commit"] = commit
|
|
634
|
+
return info
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _backlog_path(config: dict, name: str, epic: str | None, specs_dir: Path) -> Path:
|
|
638
|
+
"""Compose a feature's backlog.json path per the forge-4-backlog rule.
|
|
639
|
+
|
|
640
|
+
``{backlogDir}/{feature}/backlog.json`` when ``backlogDir`` is configured,
|
|
641
|
+
else ``{resolvedFeatureDir}/backlog.json`` (flat or nested under the epic).
|
|
642
|
+
"""
|
|
643
|
+
backlog_dir = config.get("backlogDir")
|
|
644
|
+
if isinstance(backlog_dir, str) and backlog_dir:
|
|
645
|
+
return Path(backlog_dir) / name / "backlog.json"
|
|
646
|
+
feature_dir = specs_dir / epic / name if epic else specs_dir / name
|
|
647
|
+
return feature_dir / "backlog.json"
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def doctor_report(specs_dir: Path, config_path: Path) -> dict:
|
|
651
|
+
"""Assemble the ground-truth diagnostic payload (always succeeds).
|
|
652
|
+
|
|
653
|
+
One snapshot of everything a confused session needs checked: resolved
|
|
654
|
+
plugin root + version/commit, current git branch vs. each feature's
|
|
655
|
+
recorded state branch, the recency-ranked feature summary, and whether
|
|
656
|
+
each feature's composed backlog path exists on disk.
|
|
657
|
+
"""
|
|
658
|
+
config = _load_config(config_path)
|
|
659
|
+
# --show-current (not rev-parse HEAD) so an unborn branch (fresh repo,
|
|
660
|
+
# no commits yet) still reports its name instead of failing.
|
|
661
|
+
current_branch = _git_output(["branch", "--show-current"])
|
|
662
|
+
rows = build_rows(specs_dir, config)
|
|
663
|
+
features = []
|
|
664
|
+
for row in rows:
|
|
665
|
+
backlog = _backlog_path(config, row["name"], row["epic"], specs_dir)
|
|
666
|
+
state_branch = row["branch"]
|
|
667
|
+
features.append({
|
|
668
|
+
"name": row["name"],
|
|
669
|
+
"epic": row["epic"],
|
|
670
|
+
"currentStage": row["currentStage"],
|
|
671
|
+
"nextStage": row["nextStage"],
|
|
672
|
+
"verifyState": row["verifyState"],
|
|
673
|
+
"stateBranch": state_branch,
|
|
674
|
+
"branchMatchesState": (
|
|
675
|
+
state_branch == current_branch
|
|
676
|
+
if state_branch and current_branch
|
|
677
|
+
else None
|
|
678
|
+
),
|
|
679
|
+
"backlogPath": str(backlog),
|
|
680
|
+
"backlogExists": backlog.is_file(),
|
|
681
|
+
})
|
|
682
|
+
return {
|
|
683
|
+
"pluginRoot": _resolve_plugin_root(),
|
|
684
|
+
"currentBranch": current_branch,
|
|
685
|
+
"specsDir": str(specs_dir),
|
|
686
|
+
"specsDirExists": specs_dir.is_dir(),
|
|
687
|
+
"configPath": str(config_path),
|
|
688
|
+
"configExists": config_path.is_file(),
|
|
689
|
+
"counts": _counts(specs_dir),
|
|
690
|
+
"features": features,
|
|
691
|
+
"invalidAutoVerifyKeys": invalid_auto_verify_keys(config),
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _print_doctor(report: dict) -> None:
|
|
696
|
+
"""Print the human-readable doctor report."""
|
|
697
|
+
root = report["pluginRoot"]
|
|
698
|
+
if root.get("resolved"):
|
|
699
|
+
detail = " ".join(
|
|
700
|
+
f"{key}={root[key]}" for key in ("version", "commit") if key in root
|
|
701
|
+
)
|
|
702
|
+
print(f"plugin root: {root['root']}" + (f" ({detail})" if detail else ""))
|
|
703
|
+
else:
|
|
704
|
+
print(f"plugin root: UNRESOLVED — {root.get('error', 'unknown')}")
|
|
705
|
+
print(f"current branch: {report['currentBranch'] or '(not a git repo)'}")
|
|
706
|
+
print(
|
|
707
|
+
f"specs dir: {report['specsDir']}"
|
|
708
|
+
+ ("" if report["specsDirExists"] else " (MISSING)")
|
|
709
|
+
)
|
|
710
|
+
print(
|
|
711
|
+
f"config: {report['configPath']}"
|
|
712
|
+
+ ("" if report["configExists"] else " (MISSING)")
|
|
713
|
+
)
|
|
714
|
+
counts = report["counts"]
|
|
715
|
+
print(
|
|
716
|
+
f"features: {counts['active']} active "
|
|
717
|
+
f"(paused: {counts['paused']}, abandoned: {counts['abandoned']})"
|
|
718
|
+
)
|
|
719
|
+
for feat in report["features"]:
|
|
720
|
+
label = feat["name"] + (f" [{feat['epic']}]" if feat["epic"] else "")
|
|
721
|
+
branch = feat["stateBranch"] or "?"
|
|
722
|
+
if feat["branchMatchesState"] is False:
|
|
723
|
+
branch += " (MISMATCH vs current)"
|
|
724
|
+
backlog = "exists" if feat["backlogExists"] else "MISSING"
|
|
725
|
+
print(
|
|
726
|
+
f" - {label}: stage={feat['currentStage']} "
|
|
727
|
+
f"verify={feat['verifyState']} branch={branch} "
|
|
728
|
+
f"backlog={backlog} ({feat['backlogPath']})"
|
|
729
|
+
)
|
|
730
|
+
invalid = report.get("invalidAutoVerifyKeys") or []
|
|
731
|
+
if invalid:
|
|
732
|
+
print(" ! invalid autoVerifyStages keys (ignored): " + ", ".join(invalid))
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# --------------------------------------------------------------------------- #
|
|
736
|
+
# Cross-branch feature discovery
|
|
737
|
+
# --------------------------------------------------------------------------- #
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _specs_rel(specs_dir: str) -> str:
|
|
741
|
+
"""Normalize a specs dir to the repo-relative POSIX form git ls-tree uses."""
|
|
742
|
+
rel = specs_dir.replace("\\", "/")
|
|
743
|
+
while rel.startswith("./"):
|
|
744
|
+
rel = rel[2:]
|
|
745
|
+
return rel.rstrip("/")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _state_paths_in_ref(ref: str, specs_rel: str, name: str) -> list[str]:
|
|
749
|
+
"""Feature-shaped ``.pipeline-state.json`` paths for ``name`` in one ref.
|
|
750
|
+
|
|
751
|
+
Mirrors the ``_scan_features`` flat/nested bound: exactly
|
|
752
|
+
``{specsDir}/{name}/.pipeline-state.json`` or
|
|
753
|
+
``{specsDir}/{epic}/{name}/.pipeline-state.json`` — never deeper.
|
|
754
|
+
"""
|
|
755
|
+
listing = _git_output(["ls-tree", "-r", "--name-only", ref, "--", specs_rel])
|
|
756
|
+
if not listing:
|
|
757
|
+
return []
|
|
758
|
+
hits: list[str] = []
|
|
759
|
+
prefix = specs_rel + "/"
|
|
760
|
+
for path in listing.splitlines():
|
|
761
|
+
if not path.startswith(prefix) or not path.endswith("/" + PIPELINE_STATE_FILENAME):
|
|
762
|
+
continue
|
|
763
|
+
segments = path[len(prefix):].split("/")
|
|
764
|
+
# [name, state-file] (flat) or [epic, name, state-file] (nested).
|
|
765
|
+
if len(segments) == 2 and segments[0] == name:
|
|
766
|
+
hits.append(path)
|
|
767
|
+
elif len(segments) == 3 and segments[1] == name:
|
|
768
|
+
hits.append(path)
|
|
769
|
+
return hits
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _read_state_at_ref(ref: str, path: str) -> dict:
|
|
773
|
+
"""Parse ``git show ref:path`` as pipeline state, downgrading failures to {}."""
|
|
774
|
+
raw = _git_output(["show", f"{ref}:{path}"])
|
|
775
|
+
if raw is None:
|
|
776
|
+
return {}
|
|
777
|
+
try:
|
|
778
|
+
parsed = json.loads(raw)
|
|
779
|
+
except json.JSONDecodeError:
|
|
780
|
+
return {}
|
|
781
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _list_refs(pattern: str) -> list[tuple[str, str]]:
|
|
785
|
+
"""Return ``(short_ref, committer_date)`` pairs under a ref namespace."""
|
|
786
|
+
raw = _git_output([
|
|
787
|
+
"for-each-ref",
|
|
788
|
+
"--format=%(refname:short)\t%(committerdate:iso-strict)",
|
|
789
|
+
pattern,
|
|
790
|
+
])
|
|
791
|
+
if not raw:
|
|
792
|
+
return []
|
|
793
|
+
out: list[tuple[str, str]] = []
|
|
794
|
+
for line in raw.splitlines():
|
|
795
|
+
ref, _, date = line.partition("\t")
|
|
796
|
+
if ref:
|
|
797
|
+
out.append((ref, date))
|
|
798
|
+
return out
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def discover_feature(name: str, specs_dir: str) -> dict:
|
|
802
|
+
"""Find a feature's pipeline state across all branches (strictly read-only).
|
|
803
|
+
|
|
804
|
+
Scans every local head and remote-tracking ref for a feature-shaped
|
|
805
|
+
``.pipeline-state.json``, parses each hit via ``git show``, and ranks
|
|
806
|
+
candidates by (state's own ``branch`` field matches the ref) first, then
|
|
807
|
+
local-before-remote-tracking, then newest commit. When no candidate exists
|
|
808
|
+
locally, ``git ls-remote --heads origin`` surfaces plausibly-named
|
|
809
|
+
branches a single-branch clone never fetched, as ``needsFetch`` entries
|
|
810
|
+
with the exact fetch/switch commands.
|
|
811
|
+
|
|
812
|
+
Never mutates anything: checkout is the caller's decision (and requires
|
|
813
|
+
the user's explicit accept plus a clean tree — see shared-conventions).
|
|
814
|
+
"""
|
|
815
|
+
if _git_output(["rev-parse", "--git-dir"]) is None:
|
|
816
|
+
return {
|
|
817
|
+
"feature": name,
|
|
818
|
+
"gitRepo": False,
|
|
819
|
+
"currentBranch": None,
|
|
820
|
+
"candidates": [],
|
|
821
|
+
"remoteCandidates": [],
|
|
822
|
+
}
|
|
823
|
+
current_branch = _git_output(["branch", "--show-current"])
|
|
824
|
+
specs_rel = _specs_rel(specs_dir)
|
|
825
|
+
|
|
826
|
+
refs = [(ref, date, False) for ref, date in _list_refs("refs/heads")]
|
|
827
|
+
refs += [(ref, date, True) for ref, date in _list_refs("refs/remotes")]
|
|
828
|
+
|
|
829
|
+
candidates: list[dict] = []
|
|
830
|
+
matched_branches: set[str] = set()
|
|
831
|
+
known_branches: set[str] = set()
|
|
832
|
+
for ref, commit_date, is_remote in refs:
|
|
833
|
+
branch = ref.split("/", 1)[1] if is_remote else ref
|
|
834
|
+
if is_remote and (not branch or branch == "HEAD"):
|
|
835
|
+
continue
|
|
836
|
+
known_branches.add(branch)
|
|
837
|
+
if branch in matched_branches:
|
|
838
|
+
continue # the local head already yielded this branch's state
|
|
839
|
+
for path in _state_paths_in_ref(ref, specs_rel, name):
|
|
840
|
+
state = _read_state_at_ref(ref, path)
|
|
841
|
+
state_branch = state.get("branch")
|
|
842
|
+
state_branch = state_branch if isinstance(state_branch, str) else None
|
|
843
|
+
updated = state.get("updatedAt")
|
|
844
|
+
matched_branches.add(branch)
|
|
845
|
+
candidates.append({
|
|
846
|
+
"branch": branch,
|
|
847
|
+
"ref": ref,
|
|
848
|
+
"remoteTracking": is_remote,
|
|
849
|
+
"path": path,
|
|
850
|
+
"stateBranch": state_branch,
|
|
851
|
+
"stateBranchMatches": state_branch == branch,
|
|
852
|
+
"currentStage": state.get("currentStage"),
|
|
853
|
+
"pipelineStatus": state.get("pipelineStatus", "active"),
|
|
854
|
+
"updatedAt": updated if isinstance(updated, str) else None,
|
|
855
|
+
"commitDate": commit_date or None,
|
|
856
|
+
"isCurrentBranch": branch == current_branch,
|
|
857
|
+
"switchCommand": f"git switch {branch}",
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
def _rank(cand: dict) -> tuple:
|
|
861
|
+
ts = _parse_ts(cand["commitDate"]) or datetime.min.replace(tzinfo=timezone.utc)
|
|
862
|
+
return (
|
|
863
|
+
not cand["stateBranchMatches"],
|
|
864
|
+
cand["remoteTracking"],
|
|
865
|
+
-ts.timestamp(),
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
candidates.sort(key=_rank)
|
|
869
|
+
|
|
870
|
+
# Single-branch clones: the branch holding the state may never have been
|
|
871
|
+
# fetched. Only when nothing was found locally, ask the remote for heads we
|
|
872
|
+
# do not know and surface the plausibly-named ones (the feature name appears
|
|
873
|
+
# in the branch name — e.g. forge/<feature>). These are name-based hints
|
|
874
|
+
# only; their contents were NOT inspected.
|
|
875
|
+
remote_candidates: list[dict] = []
|
|
876
|
+
if not candidates:
|
|
877
|
+
ls_remote = _git_output(["ls-remote", "--heads", "origin"])
|
|
878
|
+
for line in (ls_remote or "").splitlines():
|
|
879
|
+
_, _, refname = line.partition("\t")
|
|
880
|
+
if not refname.startswith("refs/heads/"):
|
|
881
|
+
continue
|
|
882
|
+
branch = refname[len("refs/heads/"):]
|
|
883
|
+
if branch in known_branches or name not in branch:
|
|
884
|
+
continue
|
|
885
|
+
remote_candidates.append({
|
|
886
|
+
"branch": branch,
|
|
887
|
+
"needsFetch": True,
|
|
888
|
+
"fetchCommand": f"git fetch origin {branch}:refs/remotes/origin/{branch}",
|
|
889
|
+
"switchCommand": f"git switch {branch}",
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
"feature": name,
|
|
894
|
+
"gitRepo": True,
|
|
895
|
+
"currentBranch": current_branch,
|
|
896
|
+
"specsDir": specs_rel,
|
|
897
|
+
"candidates": candidates,
|
|
898
|
+
"remoteCandidates": remote_candidates,
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _print_discover(payload: dict) -> None:
|
|
903
|
+
"""Print the human-readable discovery report."""
|
|
904
|
+
name = payload["feature"]
|
|
905
|
+
if not payload["gitRepo"]:
|
|
906
|
+
print(f"discover-feature {name}: not a git repository — nothing to scan")
|
|
907
|
+
return
|
|
908
|
+
candidates = payload["candidates"]
|
|
909
|
+
remote = payload["remoteCandidates"]
|
|
910
|
+
if not candidates and not remote:
|
|
911
|
+
print(
|
|
912
|
+
f"discover-feature {name}: no pipeline state found on any local or "
|
|
913
|
+
"remote-tracking branch"
|
|
914
|
+
)
|
|
915
|
+
return
|
|
916
|
+
for cand in candidates:
|
|
917
|
+
marks = []
|
|
918
|
+
if cand["isCurrentBranch"]:
|
|
919
|
+
marks.append("current branch")
|
|
920
|
+
if cand["remoteTracking"]:
|
|
921
|
+
marks.append("remote-tracking")
|
|
922
|
+
if not cand["stateBranchMatches"] and cand["stateBranch"]:
|
|
923
|
+
marks.append(f"state records branch {cand['stateBranch']}")
|
|
924
|
+
suffix = f" ({'; '.join(marks)})" if marks else ""
|
|
925
|
+
print(
|
|
926
|
+
f" {cand['branch']}: stage={cand['currentStage'] or '?'} "
|
|
927
|
+
f"status={cand['pipelineStatus']} path={cand['path']}{suffix}"
|
|
928
|
+
)
|
|
929
|
+
if not cand["isCurrentBranch"]:
|
|
930
|
+
print(f" switch: {cand['switchCommand']}")
|
|
931
|
+
for cand in remote:
|
|
932
|
+
print(
|
|
933
|
+
f" {cand['branch']}: on origin only (never fetched; contents not "
|
|
934
|
+
"inspected — name matches)"
|
|
935
|
+
)
|
|
936
|
+
print(f" fetch: {cand['fetchCommand']}")
|
|
937
|
+
print(f" switch: {cand['switchCommand']}")
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
# --------------------------------------------------------------------------- #
|
|
941
|
+
# Scripted Stage Exit
|
|
942
|
+
# --------------------------------------------------------------------------- #
|
|
943
|
+
|
|
944
|
+
#: Authoring stages whose closing runs stage-exit (the loop keeps bespoke exits).
|
|
945
|
+
EXIT_STAGES: Final[tuple[str, ...]] = (
|
|
946
|
+
"forge-0-epic",
|
|
947
|
+
"forge-1-prd",
|
|
948
|
+
"forge-2-tech",
|
|
949
|
+
"forge-3-specs",
|
|
950
|
+
"forge-4-backlog",
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
#: Stage id -> the noun phrase gate wording uses (the old {stage} stamp slot).
|
|
954
|
+
STAGE_NOUN: Final[dict[str, str]] = {
|
|
955
|
+
"forge-0-epic": "the epic decomposition",
|
|
956
|
+
"forge-1-prd": "the PRD",
|
|
957
|
+
"forge-2-tech": "the tech spec",
|
|
958
|
+
"forge-3-specs": "the implementation specs",
|
|
959
|
+
"forge-4-backlog": "the backlog",
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
#: Verify token per exit stage. Extends the production map with the epic stage,
|
|
963
|
+
#: whose verify entry is recorded under ``forge-verify-epic``.
|
|
964
|
+
_EXIT_VERIFY_TOKEN: Final[dict[str, str]] = {
|
|
965
|
+
**VERIFY_TOKEN_BY_STAGE,
|
|
966
|
+
"forge-0-epic": "epic",
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
#: The stage each exit hands off to when pipeline state cannot say better.
|
|
970
|
+
_EXIT_NEXT_STAGE: Final[dict[str, str]] = {
|
|
971
|
+
"forge-0-epic": "forge-1-prd",
|
|
972
|
+
"forge-1-prd": "forge-2-tech",
|
|
973
|
+
"forge-2-tech": "forge-3-specs",
|
|
974
|
+
"forge-3-specs": "forge-4-backlog",
|
|
975
|
+
"forge-4-backlog": "forge-5-loop",
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
#: The fixed final line of the NEXT-STEPS block. The stamp instructs the skill
|
|
979
|
+
#: to print the block verbatim as its absolute last output — nothing after this.
|
|
980
|
+
NEXT_STEPS_SENTINEL: Final = "─ forge: end of stage ─"
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _verify_state_for(state: dict, stage: str) -> str:
|
|
984
|
+
"""Classify THIS stage's verify freshness (stage-scoped ``verify_state``).
|
|
985
|
+
|
|
986
|
+
Same labels as ``verify_state`` — fresh / stale / failing / never /
|
|
987
|
+
skipped / none — but for the given stage rather than the most-recently
|
|
988
|
+
completed one, because stage-exit runs inside the stage that just closed.
|
|
989
|
+
"""
|
|
990
|
+
token = _EXIT_VERIFY_TOKEN.get(stage)
|
|
991
|
+
if token is None:
|
|
992
|
+
return "none"
|
|
993
|
+
entry = _verify_entry(state, f"forge-verify-{token}")
|
|
994
|
+
status = entry.get("status")
|
|
995
|
+
if status == "skipped":
|
|
996
|
+
return "skipped"
|
|
997
|
+
if status == "findings-reported":
|
|
998
|
+
return "failing"
|
|
999
|
+
if status not in _VERIFY_RESOLVED:
|
|
1000
|
+
return "never"
|
|
1001
|
+
verified_version = entry.get("verifiedStageVersion")
|
|
1002
|
+
stage_version = _stage_version(state, stage)
|
|
1003
|
+
if (
|
|
1004
|
+
isinstance(verified_version, int)
|
|
1005
|
+
and stage_version is not None
|
|
1006
|
+
and verified_version == stage_version
|
|
1007
|
+
):
|
|
1008
|
+
return "fresh"
|
|
1009
|
+
return "stale"
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _resolve_feature_dir(specs_dir: Path, feature: str, epic: str | None) -> Path:
|
|
1013
|
+
"""Best-effort feature dir (flat, else unique nested, else flat literal).
|
|
1014
|
+
|
|
1015
|
+
stage-exit tolerates an unresolvable dir — the state read downgrades to
|
|
1016
|
+
``{}`` and every directive still computes from defaults.
|
|
1017
|
+
"""
|
|
1018
|
+
if epic:
|
|
1019
|
+
return specs_dir / epic / feature
|
|
1020
|
+
flat = specs_dir / feature
|
|
1021
|
+
if (flat / PIPELINE_STATE_FILENAME).is_file():
|
|
1022
|
+
return flat
|
|
1023
|
+
if specs_dir.is_dir():
|
|
1024
|
+
nested = [
|
|
1025
|
+
p for p in specs_dir.glob(f"*/{feature}")
|
|
1026
|
+
if (p / PIPELINE_STATE_FILENAME).is_file()
|
|
1027
|
+
]
|
|
1028
|
+
if len(nested) == 1:
|
|
1029
|
+
return nested[0]
|
|
1030
|
+
return flat
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _next_steps_block(next_command: str, host: str) -> str:
|
|
1034
|
+
"""Render the sentinel-terminated NEXT-STEPS block for the given host.
|
|
1035
|
+
|
|
1036
|
+
The Claude wording uses the literal ``/clear`` slash-command; the generic
|
|
1037
|
+
wording is host-neutral (matching the adapter build's host-term table, so
|
|
1038
|
+
a non-Claude bundle invoking ``--host generic`` never instructs a fake
|
|
1039
|
+
slash-command).
|
|
1040
|
+
"""
|
|
1041
|
+
if host == "claude":
|
|
1042
|
+
clear_line = (
|
|
1043
|
+
"1. `/clear` — recommended unconditionally at this stage boundary; "
|
|
1044
|
+
"every artifact is on disk, so the work survives the clear. "
|
|
1045
|
+
"I can't `/clear` for you — you have to run it yourself."
|
|
1046
|
+
)
|
|
1047
|
+
next_line = (
|
|
1048
|
+
f"2. Then run `{next_command}` in the fresh session — or re-run "
|
|
1049
|
+
"`/feature-forge:forge` to let the navigator resume from disk."
|
|
1050
|
+
)
|
|
1051
|
+
else:
|
|
1052
|
+
clear_line = (
|
|
1053
|
+
"1. Clear your session / start a fresh session — recommended "
|
|
1054
|
+
"unconditionally at this stage boundary; every artifact is on "
|
|
1055
|
+
"disk, so the work survives it."
|
|
1056
|
+
)
|
|
1057
|
+
next_line = (
|
|
1058
|
+
f"2. Then run `{next_command}` in the fresh session — or re-run "
|
|
1059
|
+
"the forge navigator skill to resume from disk."
|
|
1060
|
+
)
|
|
1061
|
+
return "\n".join(["**Next steps**", clear_line, next_line, NEXT_STEPS_SENTINEL])
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def stage_exit(
|
|
1065
|
+
feature: str,
|
|
1066
|
+
stage: str,
|
|
1067
|
+
specs_dir: Path,
|
|
1068
|
+
config_path: Path,
|
|
1069
|
+
epic: str | None,
|
|
1070
|
+
host: str,
|
|
1071
|
+
next_feature: str | None,
|
|
1072
|
+
) -> dict:
|
|
1073
|
+
"""Compute the Scripted Stage Exit payload: DIRECTIVES + NEXT-STEPS block.
|
|
1074
|
+
|
|
1075
|
+
Directive semantics (the contract in ``references/stage-exit-protocol.md``):
|
|
1076
|
+
|
|
1077
|
+
- ``runInStageVerify`` — the effective auto-verify (per-stage override,
|
|
1078
|
+
else global; strict-true) is on AND this stage's verify is not already
|
|
1079
|
+
resolved (fresh/skipped). The skill then dispatches the clean-room
|
|
1080
|
+
verify in-session (principle #2: verify before the clear).
|
|
1081
|
+
- ``autoFixEligible`` — ``autoFix`` is strict-true AND the in-stage verify
|
|
1082
|
+
runs AND the working tree is clean. Findings-level preconditions (zero
|
|
1083
|
+
unresolved decisions) remain the skill's runtime check.
|
|
1084
|
+
- ``verifyGate`` — ``none`` when verify is resolved or the in-stage run
|
|
1085
|
+
covers it; ``standard`` when auto-verify is off and verification is
|
|
1086
|
+
outstanding on a host with a question mechanism + clean-room path
|
|
1087
|
+
(``--host claude``); ``manual-print`` for the same state on a generic
|
|
1088
|
+
host (print ``verifyCommand`` instead of presenting the gate).
|
|
1089
|
+
- ``nextStage``/``nextCommand`` — from pipeline state when it already
|
|
1090
|
+
records this stage complete (first non-complete production stage), else
|
|
1091
|
+
the fixed successor. ``--next-feature`` names the first actionable
|
|
1092
|
+
feature for the epic handoff; without it the runtime placeholder
|
|
1093
|
+
``{first-actionable-feature}`` passes through for the skill to resolve.
|
|
1094
|
+
|
|
1095
|
+
Read-only, deterministic, exit 0 — errors degrade to defaults, never
|
|
1096
|
+
crash a stage closing.
|
|
1097
|
+
"""
|
|
1098
|
+
config = _load_config(config_path)
|
|
1099
|
+
feature_dir = _resolve_feature_dir(specs_dir, feature, epic)
|
|
1100
|
+
state = _read_state(feature_dir / PIPELINE_STATE_FILENAME)
|
|
1101
|
+
|
|
1102
|
+
git_repo = _git_output(["rev-parse", "--git-dir"]) is not None
|
|
1103
|
+
clean_tree: bool | None = None
|
|
1104
|
+
if git_repo:
|
|
1105
|
+
porcelain = _git_output(["status", "--porcelain"])
|
|
1106
|
+
clean_tree = porcelain is None or porcelain == ""
|
|
1107
|
+
|
|
1108
|
+
verify_label = _verify_state_for(state, stage)
|
|
1109
|
+
resolved = verify_label in ("fresh", "skipped")
|
|
1110
|
+
effective_auto_verify = auto_verify_for(config, stage)
|
|
1111
|
+
run_in_stage = effective_auto_verify and not resolved
|
|
1112
|
+
auto_fix_eligible = (
|
|
1113
|
+
config.get("autoFix") is True and run_in_stage and clean_tree is True
|
|
1114
|
+
)
|
|
1115
|
+
if resolved or effective_auto_verify:
|
|
1116
|
+
verify_gate = "none"
|
|
1117
|
+
elif host == "claude":
|
|
1118
|
+
verify_gate = "standard"
|
|
1119
|
+
else:
|
|
1120
|
+
verify_gate = "manual-print"
|
|
1121
|
+
|
|
1122
|
+
next_stage_id = _EXIT_NEXT_STAGE.get(stage)
|
|
1123
|
+
state_next = next_stage(state)
|
|
1124
|
+
if (
|
|
1125
|
+
stage in PRODUCTION_STAGES
|
|
1126
|
+
and state_next is not None
|
|
1127
|
+
and PRODUCTION_STAGES.index(state_next) > PRODUCTION_STAGES.index(stage)
|
|
1128
|
+
):
|
|
1129
|
+
# State records this stage complete AND its walk lands beyond it —
|
|
1130
|
+
# trust it (it skips stages already completed out of order). A missing
|
|
1131
|
+
# or behind-the-stage walk (state not yet flushed, corrupt file) falls
|
|
1132
|
+
# back to the fixed successor, never to an earlier stage.
|
|
1133
|
+
next_stage_id = state_next
|
|
1134
|
+
next_arg = next_feature or (
|
|
1135
|
+
"{first-actionable-feature}" if stage == "forge-0-epic" else feature
|
|
1136
|
+
)
|
|
1137
|
+
next_command = f"/feature-forge:{next_stage_id} {next_arg}" if next_stage_id else None
|
|
1138
|
+
|
|
1139
|
+
directives = {
|
|
1140
|
+
"stage": stage,
|
|
1141
|
+
"stageNoun": STAGE_NOUN.get(stage, stage),
|
|
1142
|
+
"feature": feature,
|
|
1143
|
+
"runInStageVerify": run_in_stage,
|
|
1144
|
+
"verifyGate": verify_gate,
|
|
1145
|
+
"autoFixEligible": auto_fix_eligible,
|
|
1146
|
+
"verifyState": verify_label,
|
|
1147
|
+
"verifyCommand": f"/feature-forge:forge-verify {feature}",
|
|
1148
|
+
"autoVerifyEffective": effective_auto_verify,
|
|
1149
|
+
"nextStage": next_stage_id,
|
|
1150
|
+
"nextCommand": next_command,
|
|
1151
|
+
"invalidAutoVerifyKeys": invalid_auto_verify_keys(config),
|
|
1152
|
+
"gitRepo": git_repo,
|
|
1153
|
+
"cleanTree": clean_tree,
|
|
1154
|
+
"host": host,
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
"directives": directives,
|
|
1158
|
+
"nextSteps": _next_steps_block(next_command or "/feature-forge:forge", host),
|
|
1159
|
+
"sentinel": NEXT_STEPS_SENTINEL,
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _print_stage_exit(payload: dict) -> None:
|
|
1164
|
+
"""Print DIRECTIVES then the NEXT-STEPS block (the skill-facing form)."""
|
|
1165
|
+
print("DIRECTIVES:")
|
|
1166
|
+
print(json.dumps(payload["directives"], indent=2, ensure_ascii=False))
|
|
1167
|
+
print(
|
|
1168
|
+
"NEXT-STEPS (print this block verbatim as your absolute last output — "
|
|
1169
|
+
"nothing after the sentinel):"
|
|
1170
|
+
)
|
|
1171
|
+
print(payload["nextSteps"])
|
|
1172
|
+
|
|
1173
|
+
|
|
410
1174
|
# --------------------------------------------------------------------------- #
|
|
411
1175
|
# CLI dispatch
|
|
412
1176
|
# --------------------------------------------------------------------------- #
|
|
@@ -449,6 +1213,7 @@ def main() -> int:
|
|
|
449
1213
|
|
|
450
1214
|
p_rank = sub.add_parser("rank-features", help="Rank active features by recency")
|
|
451
1215
|
p_rank.add_argument("--specs-dir", default="./specs", help="Specs directory")
|
|
1216
|
+
p_rank.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
|
|
452
1217
|
p_rank.add_argument("--json", action="store_true", dest="json_output")
|
|
453
1218
|
|
|
454
1219
|
p_ctx = sub.add_parser("context-usage", help="Report live context-window usage")
|
|
@@ -457,17 +1222,55 @@ def main() -> int:
|
|
|
457
1222
|
p_ctx.add_argument("--threshold", type=float, default=None, help="Override warn fraction (0-1)")
|
|
458
1223
|
p_ctx.add_argument("--json", action="store_true", dest="json_output")
|
|
459
1224
|
|
|
1225
|
+
p_doc = sub.add_parser("doctor", help="Capture pipeline ground truth for debugging")
|
|
1226
|
+
p_doc.add_argument("--specs-dir", default="./specs", help="Specs directory")
|
|
1227
|
+
p_doc.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
|
|
1228
|
+
p_doc.add_argument("--json", action="store_true", dest="json_output")
|
|
1229
|
+
|
|
1230
|
+
p_disc = sub.add_parser(
|
|
1231
|
+
"discover-feature", help="Find a feature's pipeline state across all branches"
|
|
1232
|
+
)
|
|
1233
|
+
p_disc.add_argument("name", help="Feature name to discover")
|
|
1234
|
+
p_disc.add_argument("--specs-dir", default="./specs", help="Specs directory")
|
|
1235
|
+
p_disc.add_argument("--json", action="store_true", dest="json_output")
|
|
1236
|
+
|
|
1237
|
+
p_exit = sub.add_parser(
|
|
1238
|
+
"stage-exit", help="Emit the Scripted Stage Exit directives + NEXT-STEPS block"
|
|
1239
|
+
)
|
|
1240
|
+
p_exit.add_argument("--feature", required=True,
|
|
1241
|
+
help="Feature name (the epic name for forge-0-epic)")
|
|
1242
|
+
p_exit.add_argument("--stage", required=True, choices=EXIT_STAGES,
|
|
1243
|
+
help="The just-completed authoring stage")
|
|
1244
|
+
p_exit.add_argument("--specs-dir", default="./specs", help="Specs directory")
|
|
1245
|
+
p_exit.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
|
|
1246
|
+
p_exit.add_argument("--epic", default=None, help="Epic name for a nested member")
|
|
1247
|
+
p_exit.add_argument("--next-feature", default=None, dest="next_feature",
|
|
1248
|
+
help="First actionable feature (epic handoff next-command arg)")
|
|
1249
|
+
p_exit.add_argument("--host", default="claude", choices=("claude", "generic"),
|
|
1250
|
+
help="Host wording for the NEXT-STEPS block")
|
|
1251
|
+
p_exit.add_argument("--json", action="store_true", dest="json_output")
|
|
1252
|
+
|
|
460
1253
|
args = parser.parse_args()
|
|
461
1254
|
|
|
462
1255
|
try:
|
|
463
1256
|
if args.cmd == "rank-features":
|
|
464
1257
|
specs_dir = Path(args.specs_dir)
|
|
465
|
-
|
|
1258
|
+
config = _load_config(Path(args.config))
|
|
1259
|
+
rows = build_rows(specs_dir, config)
|
|
466
1260
|
counts = _counts(specs_dir)
|
|
1261
|
+
invalid_keys = invalid_auto_verify_keys(config)
|
|
467
1262
|
if args.json_output:
|
|
468
|
-
|
|
1263
|
+
payload = {"active": rows, "counts": counts}
|
|
1264
|
+
if invalid_keys:
|
|
1265
|
+
payload["invalidAutoVerifyKeys"] = invalid_keys
|
|
1266
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
469
1267
|
else:
|
|
470
1268
|
_print_rank_table(rows, counts)
|
|
1269
|
+
if invalid_keys:
|
|
1270
|
+
print(
|
|
1271
|
+
" ! invalid autoVerifyStages keys (ignored): "
|
|
1272
|
+
+ ", ".join(invalid_keys)
|
|
1273
|
+
)
|
|
471
1274
|
return 0
|
|
472
1275
|
|
|
473
1276
|
if args.cmd == "context-usage":
|
|
@@ -478,6 +1281,38 @@ def main() -> int:
|
|
|
478
1281
|
_print_context(usage)
|
|
479
1282
|
return 0
|
|
480
1283
|
|
|
1284
|
+
if args.cmd == "doctor":
|
|
1285
|
+
report = doctor_report(Path(args.specs_dir), Path(args.config))
|
|
1286
|
+
if args.json_output:
|
|
1287
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
1288
|
+
else:
|
|
1289
|
+
_print_doctor(report)
|
|
1290
|
+
return 0
|
|
1291
|
+
|
|
1292
|
+
if args.cmd == "discover-feature":
|
|
1293
|
+
payload = discover_feature(args.name, args.specs_dir)
|
|
1294
|
+
if args.json_output:
|
|
1295
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1296
|
+
else:
|
|
1297
|
+
_print_discover(payload)
|
|
1298
|
+
return 0
|
|
1299
|
+
|
|
1300
|
+
if args.cmd == "stage-exit":
|
|
1301
|
+
payload = stage_exit(
|
|
1302
|
+
args.feature,
|
|
1303
|
+
args.stage,
|
|
1304
|
+
Path(args.specs_dir),
|
|
1305
|
+
Path(args.config),
|
|
1306
|
+
args.epic,
|
|
1307
|
+
args.host,
|
|
1308
|
+
args.next_feature,
|
|
1309
|
+
)
|
|
1310
|
+
if args.json_output:
|
|
1311
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1312
|
+
else:
|
|
1313
|
+
_print_stage_exit(payload)
|
|
1314
|
+
return 0
|
|
1315
|
+
|
|
481
1316
|
raise UsageError(f"unknown command: {args.cmd}")
|
|
482
1317
|
except UsageError as exc:
|
|
483
1318
|
print(f"Error: {exc}", file=sys.stderr)
|