@garygentry/feature-forge 0.2.4 → 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.
Files changed (112) hide show
  1. package/adapters/claude/.feature-forge-bundle.json +1 -1
  2. package/adapters/claude/references/portable-root.md +6 -3
  3. package/adapters/claude/references/shared-conventions.md +19 -6
  4. package/adapters/claude/references/stage-exit-protocol.md +144 -37
  5. package/adapters/claude/scripts/forge-root.sh +20 -1
  6. package/adapters/claude/scripts/forge-session.py +691 -1
  7. package/adapters/claude/skills/forge/SKILL.md +7 -7
  8. package/adapters/claude/skills/forge-0-epic/SKILL.md +15 -25
  9. package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +2 -2
  10. package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  11. package/adapters/claude/skills/forge-1-prd/SKILL.md +7 -8
  12. package/adapters/claude/skills/forge-2-tech/SKILL.md +7 -8
  13. package/adapters/claude/skills/forge-3-specs/SKILL.md +7 -8
  14. package/adapters/claude/skills/forge-4-backlog/SKILL.md +7 -8
  15. package/adapters/claude/skills/forge-5-loop/SKILL.md +3 -3
  16. package/adapters/claude/skills/forge-6-docs/SKILL.md +1 -1
  17. package/adapters/claude/skills/forge-bootstrap/SKILL.md +4 -4
  18. package/adapters/claude/skills/forge-fix/SKILL.md +11 -9
  19. package/adapters/claude/skills/forge-guide/SKILL.md +6 -3
  20. package/adapters/claude/skills/forge-init/SKILL.md +5 -4
  21. package/adapters/claude/skills/forge-verify/SKILL.md +1 -1
  22. package/adapters/claude/skills/forge-verify/references/verification-checklists.md +1 -1
  23. package/adapters/codex/.feature-forge-bundle.json +1 -1
  24. package/adapters/codex/references/portable-root.md +6 -3
  25. package/adapters/codex/references/shared-conventions.md +19 -6
  26. package/adapters/codex/references/stage-exit-protocol.md +144 -37
  27. package/adapters/codex/scripts/forge-root.sh +20 -1
  28. package/adapters/codex/scripts/forge-session.py +691 -1
  29. package/adapters/codex/skills/forge/SKILL.md +7 -7
  30. package/adapters/codex/skills/forge-0-epic/SKILL.md +15 -25
  31. package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +2 -2
  32. package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  33. package/adapters/codex/skills/forge-1-prd/SKILL.md +7 -8
  34. package/adapters/codex/skills/forge-2-tech/SKILL.md +7 -8
  35. package/adapters/codex/skills/forge-3-specs/SKILL.md +7 -8
  36. package/adapters/codex/skills/forge-4-backlog/SKILL.md +7 -8
  37. package/adapters/codex/skills/forge-5-loop/SKILL.md +3 -3
  38. package/adapters/codex/skills/forge-6-docs/SKILL.md +1 -1
  39. package/adapters/codex/skills/forge-bootstrap/SKILL.md +4 -4
  40. package/adapters/codex/skills/forge-fix/SKILL.md +11 -9
  41. package/adapters/codex/skills/forge-guide/SKILL.md +6 -3
  42. package/adapters/codex/skills/forge-init/SKILL.md +5 -4
  43. package/adapters/codex/skills/forge-verify/SKILL.md +1 -1
  44. package/adapters/codex/skills/forge-verify/references/verification-checklists.md +1 -1
  45. package/adapters/copilot/.feature-forge-bundle.json +1 -1
  46. package/adapters/copilot/references/portable-root.md +6 -3
  47. package/adapters/copilot/references/shared-conventions.md +19 -6
  48. package/adapters/copilot/references/stage-exit-protocol.md +144 -37
  49. package/adapters/copilot/scripts/forge-root.sh +20 -1
  50. package/adapters/copilot/scripts/forge-session.py +691 -1
  51. package/adapters/copilot/skills/forge/forge.md +7 -7
  52. package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +15 -25
  53. package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +2 -2
  54. package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  55. package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +7 -8
  56. package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +7 -8
  57. package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +7 -8
  58. package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +7 -8
  59. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +3 -3
  60. package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +1 -1
  61. package/adapters/copilot/skills/forge-bootstrap/forge-bootstrap.md +4 -4
  62. package/adapters/copilot/skills/forge-fix/forge-fix.md +11 -9
  63. package/adapters/copilot/skills/forge-guide/forge-guide.md +6 -3
  64. package/adapters/copilot/skills/forge-init/forge-init.md +5 -4
  65. package/adapters/copilot/skills/forge-verify/forge-verify.md +1 -1
  66. package/adapters/copilot/skills/forge-verify/references/verification-checklists.md +1 -1
  67. package/adapters/cursor/.feature-forge-bundle.json +1 -1
  68. package/adapters/cursor/references/portable-root.md +6 -3
  69. package/adapters/cursor/references/shared-conventions.md +19 -6
  70. package/adapters/cursor/references/stage-exit-protocol.md +144 -37
  71. package/adapters/cursor/scripts/forge-root.sh +20 -1
  72. package/adapters/cursor/scripts/forge-session.py +691 -1
  73. package/adapters/cursor/skills/forge/forge.mdc +7 -7
  74. package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +15 -25
  75. package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +2 -2
  76. package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  77. package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +7 -8
  78. package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +7 -8
  79. package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +7 -8
  80. package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +7 -8
  81. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +3 -3
  82. package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +1 -1
  83. package/adapters/cursor/skills/forge-bootstrap/forge-bootstrap.mdc +4 -4
  84. package/adapters/cursor/skills/forge-fix/forge-fix.mdc +11 -9
  85. package/adapters/cursor/skills/forge-guide/forge-guide.mdc +6 -3
  86. package/adapters/cursor/skills/forge-init/forge-init.mdc +5 -4
  87. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +1 -1
  88. package/adapters/cursor/skills/forge-verify/references/verification-checklists.md +1 -1
  89. package/adapters/gemini/.feature-forge-bundle.json +1 -1
  90. package/adapters/gemini/gemini-extension.json +1 -1
  91. package/adapters/gemini/references/portable-root.md +6 -3
  92. package/adapters/gemini/references/shared-conventions.md +19 -6
  93. package/adapters/gemini/references/stage-exit-protocol.md +144 -37
  94. package/adapters/gemini/scripts/forge-root.sh +20 -1
  95. package/adapters/gemini/scripts/forge-session.py +691 -1
  96. package/adapters/gemini/skills/forge/forge.md +7 -7
  97. package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +15 -25
  98. package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +2 -2
  99. package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  100. package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +7 -8
  101. package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +7 -8
  102. package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +7 -8
  103. package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +7 -8
  104. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +3 -3
  105. package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +1 -1
  106. package/adapters/gemini/skills/forge-bootstrap/forge-bootstrap.md +4 -4
  107. package/adapters/gemini/skills/forge-fix/forge-fix.md +11 -9
  108. package/adapters/gemini/skills/forge-guide/forge-guide.md +6 -3
  109. package/adapters/gemini/skills/forge-init/forge-init.md +5 -4
  110. package/adapters/gemini/skills/forge-verify/forge-verify.md +1 -1
  111. package/adapters/gemini/skills/forge-verify/references/verification-checklists.md +1 -1
  112. 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
- Two read-only subcommands that drive the usability features of the `/forge`
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,6 +64,7 @@ from __future__ import annotations
36
64
 
37
65
  import argparse
38
66
  import json
67
+ import subprocess
39
68
  import sys
40
69
  from datetime import datetime, timezone
41
70
  from pathlib import Path
@@ -541,6 +570,607 @@ def context_usage(
541
570
  }
542
571
 
543
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
+
544
1174
  # --------------------------------------------------------------------------- #
545
1175
  # CLI dispatch
546
1176
  # --------------------------------------------------------------------------- #
@@ -592,6 +1222,34 @@ def main() -> int:
592
1222
  p_ctx.add_argument("--threshold", type=float, default=None, help="Override warn fraction (0-1)")
593
1223
  p_ctx.add_argument("--json", action="store_true", dest="json_output")
594
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
+
595
1253
  args = parser.parse_args()
596
1254
 
597
1255
  try:
@@ -623,6 +1281,38 @@ def main() -> int:
623
1281
  _print_context(usage)
624
1282
  return 0
625
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
+
626
1316
  raise UsageError(f"unknown command: {args.cmd}")
627
1317
  except UsageError as exc:
628
1318
  print(f"Error: {exc}", file=sys.stderr)