@garygentry/feature-forge 0.2.2 → 0.2.4

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 (154) hide show
  1. package/README.md +6 -1
  2. package/adapters/GENERATION-REPORT.md +5 -1
  3. package/adapters/claude/references/forge-config-schema.json +43 -4
  4. package/adapters/claude/references/pipeline-state-schema.json +3 -2
  5. package/adapters/claude/references/portable-root.md +2 -2
  6. package/adapters/claude/references/process-overview.md +10 -0
  7. package/adapters/claude/references/shared-conventions.md +17 -9
  8. package/adapters/claude/references/stage-exit-protocol.md +99 -0
  9. package/adapters/claude/scripts/epic-manifest.py +10 -0
  10. package/adapters/claude/scripts/forge-bootstrap.py +94 -16
  11. package/adapters/claude/scripts/forge-init.sh +13 -1
  12. package/adapters/claude/scripts/forge-session.py +636 -0
  13. package/adapters/claude/skills/forge/SKILL.md +60 -4
  14. package/adapters/claude/skills/forge-0-epic/SKILL.md +20 -15
  15. package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +6 -4
  16. package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  17. package/adapters/claude/skills/forge-1-prd/SKILL.md +14 -4
  18. package/adapters/claude/skills/forge-2-tech/SKILL.md +14 -3
  19. package/adapters/claude/skills/forge-3-specs/SKILL.md +14 -3
  20. package/adapters/claude/skills/forge-4-backlog/SKILL.md +16 -5
  21. package/adapters/claude/skills/forge-5-loop/SKILL.md +20 -21
  22. package/adapters/claude/skills/forge-5-loop/references/result-reporting.md +10 -5
  23. package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +41 -15
  24. package/adapters/claude/skills/forge-6-docs/SKILL.md +6 -6
  25. package/adapters/claude/skills/forge-bootstrap/SKILL.md +4 -4
  26. package/adapters/claude/skills/forge-fix/SKILL.md +27 -6
  27. package/adapters/claude/skills/forge-guide/SKILL.md +179 -0
  28. package/adapters/claude/skills/forge-init/SKILL.md +31 -1
  29. package/adapters/claude/skills/forge-verify/SKILL.md +46 -15
  30. package/adapters/claude/skills/forge-verify/references/verification-checklists.md +1 -1
  31. package/adapters/codex/references/forge-config-schema.json +43 -4
  32. package/adapters/codex/references/pipeline-state-schema.json +3 -2
  33. package/adapters/codex/references/portable-root.md +2 -2
  34. package/adapters/codex/references/process-overview.md +10 -0
  35. package/adapters/codex/references/shared-conventions.md +17 -9
  36. package/adapters/codex/references/stage-exit-protocol.md +99 -0
  37. package/adapters/codex/scripts/epic-manifest.py +10 -0
  38. package/adapters/codex/scripts/forge-bootstrap.py +94 -16
  39. package/adapters/codex/scripts/forge-init.sh +13 -1
  40. package/adapters/codex/scripts/forge-session.py +636 -0
  41. package/adapters/codex/skills/forge/SKILL.md +60 -4
  42. package/adapters/codex/skills/forge-0-epic/SKILL.md +21 -16
  43. package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +6 -4
  44. package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  45. package/adapters/codex/skills/forge-1-prd/SKILL.md +14 -4
  46. package/adapters/codex/skills/forge-2-tech/SKILL.md +15 -4
  47. package/adapters/codex/skills/forge-3-specs/SKILL.md +14 -3
  48. package/adapters/codex/skills/forge-4-backlog/SKILL.md +16 -5
  49. package/adapters/codex/skills/forge-5-loop/SKILL.md +22 -23
  50. package/adapters/codex/skills/forge-5-loop/references/result-reporting.md +10 -5
  51. package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +41 -15
  52. package/adapters/codex/skills/forge-6-docs/SKILL.md +6 -6
  53. package/adapters/codex/skills/forge-bootstrap/SKILL.md +4 -4
  54. package/adapters/codex/skills/forge-fix/SKILL.md +27 -6
  55. package/adapters/codex/skills/forge-guide/SKILL.md +188 -0
  56. package/adapters/codex/skills/forge-init/SKILL.md +31 -1
  57. package/adapters/codex/skills/forge-verify/SKILL.md +45 -14
  58. package/adapters/codex/skills/forge-verify/references/verification-checklists.md +1 -1
  59. package/adapters/copilot/references/forge-config-schema.json +43 -4
  60. package/adapters/copilot/references/pipeline-state-schema.json +3 -2
  61. package/adapters/copilot/references/portable-root.md +2 -2
  62. package/adapters/copilot/references/process-overview.md +10 -0
  63. package/adapters/copilot/references/shared-conventions.md +17 -9
  64. package/adapters/copilot/references/stage-exit-protocol.md +99 -0
  65. package/adapters/copilot/scripts/epic-manifest.py +10 -0
  66. package/adapters/copilot/scripts/forge-bootstrap.py +94 -16
  67. package/adapters/copilot/scripts/forge-init.sh +13 -1
  68. package/adapters/copilot/scripts/forge-session.py +636 -0
  69. package/adapters/copilot/skills/forge/forge.md +60 -4
  70. package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +21 -16
  71. package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +6 -4
  72. package/adapters/copilot/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  73. package/adapters/copilot/skills/forge-1-prd/forge-1-prd.md +14 -4
  74. package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +15 -4
  75. package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +14 -3
  76. package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +16 -5
  77. package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +22 -23
  78. package/adapters/copilot/skills/forge-5-loop/references/result-reporting.md +10 -5
  79. package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +41 -15
  80. package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +6 -6
  81. package/adapters/copilot/skills/forge-bootstrap/forge-bootstrap.md +4 -4
  82. package/adapters/copilot/skills/forge-fix/forge-fix.md +27 -6
  83. package/adapters/copilot/skills/forge-guide/forge-guide.md +188 -0
  84. package/adapters/copilot/skills/forge-init/forge-init.md +31 -1
  85. package/adapters/copilot/skills/forge-verify/forge-verify.md +45 -14
  86. package/adapters/copilot/skills/forge-verify/references/verification-checklists.md +1 -1
  87. package/adapters/cursor/references/forge-config-schema.json +43 -4
  88. package/adapters/cursor/references/pipeline-state-schema.json +3 -2
  89. package/adapters/cursor/references/portable-root.md +2 -2
  90. package/adapters/cursor/references/process-overview.md +10 -0
  91. package/adapters/cursor/references/shared-conventions.md +17 -9
  92. package/adapters/cursor/references/stage-exit-protocol.md +99 -0
  93. package/adapters/cursor/scripts/epic-manifest.py +10 -0
  94. package/adapters/cursor/scripts/forge-bootstrap.py +94 -16
  95. package/adapters/cursor/scripts/forge-init.sh +13 -1
  96. package/adapters/cursor/scripts/forge-session.py +636 -0
  97. package/adapters/cursor/skills/forge/forge.mdc +60 -4
  98. package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +21 -16
  99. package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +6 -4
  100. package/adapters/cursor/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  101. package/adapters/cursor/skills/forge-1-prd/forge-1-prd.mdc +14 -4
  102. package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +15 -4
  103. package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +14 -3
  104. package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +16 -5
  105. package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +22 -23
  106. package/adapters/cursor/skills/forge-5-loop/references/result-reporting.md +10 -5
  107. package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +41 -15
  108. package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +6 -6
  109. package/adapters/cursor/skills/forge-bootstrap/forge-bootstrap.mdc +4 -4
  110. package/adapters/cursor/skills/forge-fix/forge-fix.mdc +27 -6
  111. package/adapters/cursor/skills/forge-guide/forge-guide.mdc +189 -0
  112. package/adapters/cursor/skills/forge-init/forge-init.mdc +31 -1
  113. package/adapters/cursor/skills/forge-verify/forge-verify.mdc +45 -14
  114. package/adapters/cursor/skills/forge-verify/references/verification-checklists.md +1 -1
  115. package/adapters/gemini/gemini-extension.json +4 -0
  116. package/adapters/gemini/references/forge-config-schema.json +43 -4
  117. package/adapters/gemini/references/pipeline-state-schema.json +3 -2
  118. package/adapters/gemini/references/portable-root.md +2 -2
  119. package/adapters/gemini/references/process-overview.md +10 -0
  120. package/adapters/gemini/references/shared-conventions.md +17 -9
  121. package/adapters/gemini/references/stage-exit-protocol.md +99 -0
  122. package/adapters/gemini/scripts/epic-manifest.py +10 -0
  123. package/adapters/gemini/scripts/forge-bootstrap.py +94 -16
  124. package/adapters/gemini/scripts/forge-init.sh +13 -1
  125. package/adapters/gemini/scripts/forge-session.py +636 -0
  126. package/adapters/gemini/skills/forge/forge.md +60 -4
  127. package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +21 -16
  128. package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +6 -4
  129. package/adapters/gemini/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
  130. package/adapters/gemini/skills/forge-1-prd/forge-1-prd.md +14 -4
  131. package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +15 -4
  132. package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +14 -3
  133. package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +16 -5
  134. package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +22 -23
  135. package/adapters/gemini/skills/forge-5-loop/references/result-reporting.md +10 -5
  136. package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +41 -15
  137. package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +6 -6
  138. package/adapters/gemini/skills/forge-bootstrap/forge-bootstrap.md +4 -4
  139. package/adapters/gemini/skills/forge-fix/forge-fix.md +27 -6
  140. package/adapters/gemini/skills/forge-guide/forge-guide.md +188 -0
  141. package/adapters/gemini/skills/forge-init/forge-init.md +31 -1
  142. package/adapters/gemini/skills/forge-verify/forge-verify.md +45 -14
  143. package/adapters/gemini/skills/forge-verify/references/verification-checklists.md +1 -1
  144. package/dist/apply.js +34 -8
  145. package/dist/cli.js +40 -4
  146. package/dist/fsutil.d.ts +0 -12
  147. package/dist/fsutil.js +10 -1
  148. package/dist/manifest.d.ts +1 -1
  149. package/dist/plan.js +22 -2
  150. package/dist/rauf.d.ts +4 -4
  151. package/dist/rauf.js +3 -3
  152. package/dist/report.js +1 -1
  153. package/dist/types.d.ts +1 -1
  154. package/package.json +1 -1
@@ -0,0 +1,636 @@
1
+ #!/usr/bin/env python3
2
+ """Session-aware navigation helpers for the feature-forge pipeline navigator.
3
+
4
+ Two read-only subcommands that drive the usability features of the `/forge`
5
+ root navigator:
6
+
7
+ python3 forge-session.py rank-features [--specs-dir DIR] [--json]
8
+ python3 forge-session.py context-usage [--config FILE] [--window N] \
9
+ [--threshold F] [--json]
10
+
11
+ `rank-features` scans the specs tree for feature-shaped directories (those that
12
+ directly contain a `.pipeline-state.json`, in both the flat
13
+ `{specsDir}/{feature}/` and nested `{specsDir}/{epic}/{feature}/` layouts) and
14
+ reports the **active** ones ordered by `updatedAt` descending, so the navigator
15
+ can offer the most-recently-touched feature as the recency default. Each row
16
+ carries the next actionable stage + its slash command, derived from the single
17
+ ordered stage map below.
18
+
19
+ `context-usage` reads the live Claude Code session transcript (the most-recently
20
+ modified `*.jsonl` under `~/.claude/projects/<cwd-slug>/`), sums the last
21
+ assistant message's token usage, and compares it to the context window so the
22
+ navigator can recommend a clean session before the next stage. It is best-effort
23
+ and degrades gracefully: when no transcript or usage is found (a non-Claude host,
24
+ or a fresh session) it reports `{"available": false}` and still exits 0, so the
25
+ caller simply omits the context advice.
26
+
27
+ 3.10 baseline, Google-style docstrings, full type annotations, stdlib only —
28
+ matching the conventions of `scripts/epic-manifest.py`.
29
+
30
+ Exit codes:
31
+ 0 = ok (including an empty feature list or unavailable context usage)
32
+ 2 = usage error or unreadable I/O
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import sys
40
+ from datetime import datetime, timezone
41
+ from pathlib import Path
42
+ from typing import Final, TypedDict
43
+
44
+
45
+ # --------------------------------------------------------------------------- #
46
+ # Constants
47
+ # --------------------------------------------------------------------------- #
48
+
49
+ #: A directory is "feature-shaped" iff it directly contains this file.
50
+ PIPELINE_STATE_FILENAME: Final = ".pipeline-state.json"
51
+ #: Epic roots hold this (and no .pipeline-state.json) — never a feature.
52
+ MANIFEST_FILENAME: Final = "epic-manifest.json"
53
+
54
+ #: The ordered production stages. This is the ONE place stage order lives.
55
+ PRODUCTION_STAGES: Final[tuple[str, ...]] = (
56
+ "forge-1-prd",
57
+ "forge-2-tech",
58
+ "forge-3-specs",
59
+ "forge-4-backlog",
60
+ "forge-5-loop",
61
+ "forge-6-docs",
62
+ )
63
+
64
+ #: Production stage -> the verify token its findings file uses, and the
65
+ #: `forge-verify-<token>` key its state lives under. forge-6-docs has no verify.
66
+ VERIFY_TOKEN_BY_STAGE: Final[dict[str, str]] = {
67
+ "forge-1-prd": "prd",
68
+ "forge-2-tech": "tech",
69
+ "forge-3-specs": "specs",
70
+ "forge-4-backlog": "backlog",
71
+ "forge-5-loop": "impl",
72
+ }
73
+
74
+ #: A production stage status that counts as "done" for next-stage selection.
75
+ _DONE_STATUS: Final = "complete"
76
+ #: Verify statuses that count as "resolved" (no outstanding verify needed).
77
+ _VERIFY_RESOLVED: Final = frozenset({"passed", "findings-applied", "skipped"})
78
+
79
+ #: Default context window when the model can't be inferred and config is silent.
80
+ _DEFAULT_WINDOW: Final = 200_000
81
+ #: Window for 1M-context models (model id carries a `[1m]` / `-1m` marker).
82
+ _WIDE_WINDOW: Final = 1_000_000
83
+ #: Default fraction of the window past which a clean session is recommended.
84
+ _DEFAULT_THRESHOLD: Final = 0.7
85
+
86
+
87
+ # --------------------------------------------------------------------------- #
88
+ # Types
89
+ # --------------------------------------------------------------------------- #
90
+
91
+
92
+ class FeatureRow(TypedDict):
93
+ """One active feature, ranked by recency, with its next actionable step."""
94
+
95
+ name: str
96
+ epic: str | None
97
+ currentStage: str
98
+ branch: str | None
99
+ updatedAt: str | None
100
+ complete: bool
101
+ nextStage: str | None
102
+ nextCommand: str | None
103
+ verifyPending: bool
104
+ verifyCommand: str | None
105
+ verifyStage: str | None
106
+ verifyState: str
107
+ autoVerify: bool
108
+ autoFix: bool
109
+
110
+
111
+ class UsageError(Exception):
112
+ """A usage or I/O failure that must exit 2."""
113
+
114
+
115
+ # --------------------------------------------------------------------------- #
116
+ # Feature scanning & ranking
117
+ # --------------------------------------------------------------------------- #
118
+
119
+
120
+ def _read_state(state_path: Path) -> dict:
121
+ """Read a `.pipeline-state.json`, tolerating missing/corrupt files.
122
+
123
+ A missing, unreadable, or unparseable state downgrades to ``{}`` rather than
124
+ crashing the scan — the navigator simply treats that feature as not-started.
125
+ """
126
+ try:
127
+ parsed = json.loads(state_path.read_text(encoding="utf-8"))
128
+ except (OSError, json.JSONDecodeError):
129
+ return {}
130
+ return parsed if isinstance(parsed, dict) else {}
131
+
132
+
133
+ def _scan_features(specs_dir: Path) -> list[tuple[str, str | None, dict]]:
134
+ """Find every feature-shaped dir under the specs tree (flat + nested).
135
+
136
+ Descends exactly one level below each top-level dir (never deeper), matching
137
+ ``epic-manifest.py``'s feature-shaped-dir bound.
138
+
139
+ Args:
140
+ specs_dir: The configured specs directory.
141
+
142
+ Returns:
143
+ A list of ``(feature_name, epic_name_or_None, state_dict)`` tuples. The
144
+ epic name is the parent dir name for a nested member, ``None`` for a flat
145
+ feature.
146
+ """
147
+ if not specs_dir.is_dir():
148
+ return []
149
+ out: list[tuple[str, str | None, dict]] = []
150
+ for top in sorted(p for p in specs_dir.iterdir() if p.is_dir()):
151
+ flat_state = top / PIPELINE_STATE_FILENAME
152
+ if flat_state.is_file():
153
+ out.append((top.name, None, _read_state(flat_state)))
154
+ # Descend one level for nested epic members (skip the epic root itself).
155
+ for child in sorted(p for p in top.iterdir() if p.is_dir()):
156
+ nested_state = child / PIPELINE_STATE_FILENAME
157
+ if nested_state.is_file():
158
+ out.append((child.name, top.name, _read_state(nested_state)))
159
+ return out
160
+
161
+
162
+ def _stage_status(state: dict, stage: str) -> str | None:
163
+ """Return the recorded status of a stage, or None if absent."""
164
+ stages = state.get("stages")
165
+ if not isinstance(stages, dict):
166
+ return None
167
+ entry = stages.get(stage)
168
+ if not isinstance(entry, dict):
169
+ return None
170
+ status = entry.get("status")
171
+ return status if isinstance(status, str) else None
172
+
173
+
174
+ def next_stage(state: dict) -> str | None:
175
+ """Return the first production stage that is not yet complete (the next step).
176
+
177
+ Walks ``PRODUCTION_STAGES`` in order and returns the first whose recorded
178
+ status is not ``complete`` (a missing/pending/in-progress/stale stage all
179
+ count as "not done"). Returns ``None`` when every production stage is
180
+ complete (nothing left to run).
181
+ """
182
+ for stage in PRODUCTION_STAGES:
183
+ if _stage_status(state, stage) != _DONE_STATUS:
184
+ return stage
185
+ return None
186
+
187
+
188
+ def _stage_version(state: dict, stage: str) -> int | None:
189
+ """Return the recorded ``version`` of a stage entry, or None if absent."""
190
+ stages = state.get("stages")
191
+ if not isinstance(stages, dict):
192
+ return None
193
+ entry = stages.get(stage)
194
+ if not isinstance(entry, dict):
195
+ return None
196
+ version = entry.get("version")
197
+ return version if isinstance(version, int) else None
198
+
199
+
200
+ def _verify_entry(state: dict, verify_key: str) -> dict:
201
+ """Return the ``forge-verify-*`` entry dict, or ``{}`` if absent."""
202
+ stages = state.get("stages")
203
+ if not isinstance(stages, dict):
204
+ return {}
205
+ entry = stages.get(verify_key)
206
+ return entry if isinstance(entry, dict) else {}
207
+
208
+
209
+ def verify_state(state: dict) -> tuple[str | None, str]:
210
+ """Classify verify freshness for the most-recently-completed stage.
211
+
212
+ Returns ``(stage, state_label)`` where ``state_label`` is one of:
213
+
214
+ - ``fresh`` — verify is resolved AND its ``verifiedStageVersion`` matches the
215
+ stage's current ``version`` (so no re-verify is needed).
216
+ - ``stale`` — verify was resolved once, but the stage version has since moved
217
+ (artifact revised) OR the entry predates the freshness ledger (no
218
+ ``verifiedStageVersion``). A revised artifact must be re-verified.
219
+ - ``failing`` — verify ran and reported findings that are not yet applied
220
+ (``findings-reported``).
221
+ - ``never`` — the stage completed but verify has not run at all.
222
+ - ``skipped`` — the user explicitly chose to proceed without verifying. A
223
+ resolved, non-pending state: it is deliberately NOT re-offered or
224
+ auto-verified, and (unlike a genuine verification result) it does not go
225
+ stale on an artifact revision — skip writers record no version to compare
226
+ against, and re-surfacing would override an explicit human decision.
227
+ - ``none`` — no completed verify-capable stage (nothing to verify), stage
228
+ is ``None``.
229
+
230
+ Only the most-recent completed production stage is considered, matching the
231
+ navigator's "verify before continuing" gate. Absent ``verifiedStageVersion``
232
+ on a ``passed``/``findings-applied`` entry (legacy state) is deliberately
233
+ treated as ``stale`` — verify rather than skip.
234
+ """
235
+ for stage in reversed(PRODUCTION_STAGES):
236
+ if _stage_status(state, stage) != _DONE_STATUS:
237
+ continue
238
+ token = VERIFY_TOKEN_BY_STAGE.get(stage)
239
+ if token is None:
240
+ continue # forge-6-docs has no verify step
241
+ entry = _verify_entry(state, f"forge-verify-{token}")
242
+ status = entry.get("status")
243
+ if status == "skipped":
244
+ # An explicit skip is resolved and non-pending — preserve the user's
245
+ # decision. It never goes stale (no recorded version to compare), so
246
+ # the freshness check below deliberately does not apply.
247
+ return stage, "skipped"
248
+ if status not in _VERIFY_RESOLVED:
249
+ if status == "findings-reported":
250
+ return stage, "failing"
251
+ return stage, "never"
252
+ verified_version = entry.get("verifiedStageVersion")
253
+ stage_version = _stage_version(state, stage)
254
+ if (
255
+ isinstance(verified_version, int)
256
+ and stage_version is not None
257
+ and verified_version == stage_version
258
+ ):
259
+ return stage, "fresh"
260
+ return stage, "stale"
261
+ return None, "none"
262
+
263
+
264
+ def pending_verify(state: dict) -> str | None:
265
+ """Return the production stage whose verify is outstanding, if any.
266
+
267
+ Outstanding means the most-recently-completed production stage's verify is not
268
+ ``fresh`` (never run, reported findings, or gone stale after an artifact
269
+ revision). An explicit ``skipped`` is treated as resolved (never outstanding).
270
+ Surfaced so the navigator can offer "verify before continuing" as an
271
+ alternative to advancing. Returns ``None`` when the latest stage is fresh,
272
+ skipped, or there is nothing to verify.
273
+ """
274
+ stage, label = verify_state(state)
275
+ return stage if label not in ("fresh", "none", "skipped") else None
276
+
277
+
278
+ def _parse_ts(value: str | None) -> datetime | None:
279
+ """Parse an ISO-8601 timestamp (tolerating a trailing 'Z'), else None."""
280
+ if not isinstance(value, str):
281
+ return None
282
+ try:
283
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
284
+ except ValueError:
285
+ return None
286
+ if dt.tzinfo is None:
287
+ dt = dt.replace(tzinfo=timezone.utc)
288
+ return dt
289
+
290
+
291
+ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
292
+ """Build the recency-ranked active-feature rows (the rank-features payload).
293
+
294
+ Active features (``pipelineStatus == "active"``, the default when absent) are
295
+ sorted by ``updatedAt`` descending — most recently touched first — so the
296
+ navigator's recency default is row 0.
297
+
298
+ ``config`` is the loaded forge.config.json (or ``{}``); it drives the effective
299
+ ``autoVerify``/``autoFix`` per stage so the navigator can branch without
300
+ re-reading config.
301
+ """
302
+ config = config or {}
303
+ # Fail closed: only a literal JSON ``true`` enables artifact-mutating autoFix.
304
+ global_auto_fix = config.get("autoFix") is True
305
+ rows: list[FeatureRow] = []
306
+ for name, epic, state in _scan_features(specs_dir):
307
+ status = state.get("pipelineStatus", "active")
308
+ if status != "active":
309
+ continue
310
+ nxt = next_stage(state)
311
+ vstage, vlabel = verify_state(state)
312
+ verify_pending = vstage is not None and vlabel not in ("fresh", "none", "skipped")
313
+ effective_auto_verify = auto_verify_for(config, vstage) if vstage else False
314
+ branch = state.get("branch")
315
+ updated = state.get("updatedAt")
316
+ rows.append({
317
+ "name": name,
318
+ "epic": epic,
319
+ "currentStage": state.get("currentStage") or (nxt or "complete"),
320
+ "branch": branch if isinstance(branch, str) else None,
321
+ "updatedAt": updated if isinstance(updated, str) else None,
322
+ "complete": nxt is None,
323
+ "nextStage": nxt,
324
+ "nextCommand": f"/feature-forge:{nxt} {name}" if nxt else None,
325
+ "verifyPending": verify_pending,
326
+ "verifyCommand": f"/feature-forge:forge-verify {name}" if verify_pending else None,
327
+ "verifyStage": vstage,
328
+ "verifyState": vlabel,
329
+ "autoVerify": effective_auto_verify,
330
+ "autoFix": global_auto_fix and effective_auto_verify,
331
+ })
332
+ # Sort by updatedAt desc; rows without a parseable timestamp sort last.
333
+ rows.sort(
334
+ key=lambda r: (_parse_ts(r["updatedAt"]) or datetime.min.replace(tzinfo=timezone.utc)),
335
+ reverse=True,
336
+ )
337
+ return rows
338
+
339
+
340
+ def _counts(specs_dir: Path) -> dict[str, int]:
341
+ """Tally active/paused/abandoned pipelines across the specs tree."""
342
+ tally = {"active": 0, "paused": 0, "abandoned": 0}
343
+ for _name, _epic, state in _scan_features(specs_dir):
344
+ status = state.get("pipelineStatus", "active")
345
+ if status in tally:
346
+ tally[status] += 1
347
+ return tally
348
+
349
+
350
+ # --------------------------------------------------------------------------- #
351
+ # Context-window usage
352
+ # --------------------------------------------------------------------------- #
353
+
354
+
355
+ def _cwd_slug(cwd: Path) -> str:
356
+ """Map a working directory to its Claude Code project-dir slug.
357
+
358
+ Claude Code names the per-project transcript dir by replacing path
359
+ separators (and dots) in the absolute cwd with hyphens, e.g.
360
+ ``/home/u/proj`` -> ``-home-u-proj``.
361
+ """
362
+ return str(cwd.resolve()).replace("/", "-").replace(".", "-")
363
+
364
+
365
+ def _latest_transcript(cwd: Path) -> Path | None:
366
+ """Return the most-recently-modified transcript JSONL for this cwd, if any."""
367
+ project_dir = Path.home() / ".claude" / "projects" / _cwd_slug(cwd)
368
+ if not project_dir.is_dir():
369
+ return None
370
+ transcripts = [p for p in project_dir.glob("*.jsonl") if p.is_file()]
371
+ if not transcripts:
372
+ return None
373
+ return max(transcripts, key=lambda p: p.stat().st_mtime)
374
+
375
+
376
+ def _last_usage(transcript: Path) -> tuple[int, str | None] | None:
377
+ """Scan a transcript from the end for the last `usage` record.
378
+
379
+ Returns ``(token_total, model_id)`` where the total sums
380
+ ``input_tokens + cache_creation_input_tokens + cache_read_input_tokens +
381
+ output_tokens`` of the most recent message carrying a usage object — i.e. the
382
+ current context occupancy. Returns ``None`` if no usable record is found.
383
+ """
384
+ try:
385
+ lines = transcript.read_text(encoding="utf-8").splitlines()
386
+ except OSError:
387
+ return None
388
+ for line in reversed(lines):
389
+ line = line.strip()
390
+ if not line or '"usage"' not in line:
391
+ continue
392
+ try:
393
+ record = json.loads(line)
394
+ except json.JSONDecodeError:
395
+ continue
396
+ message = record.get("message")
397
+ usage = message.get("usage") if isinstance(message, dict) else record.get("usage")
398
+ if not isinstance(usage, dict):
399
+ continue
400
+ # A malformed transcript may carry a non-numeric usage field; skip that
401
+ # record rather than crash the whole context-usage read (ValueError/TypeError).
402
+ try:
403
+ total = (
404
+ int(usage.get("input_tokens", 0) or 0)
405
+ + int(usage.get("cache_creation_input_tokens", 0) or 0)
406
+ + int(usage.get("cache_read_input_tokens", 0) or 0)
407
+ + int(usage.get("output_tokens", 0) or 0)
408
+ )
409
+ except (TypeError, ValueError):
410
+ continue
411
+ if total <= 0:
412
+ continue
413
+ model = message.get("model") if isinstance(message, dict) else record.get("model")
414
+ return total, (model if isinstance(model, str) else None)
415
+ return None
416
+
417
+
418
+ def _infer_window(model: str | None) -> int:
419
+ """Infer the context window from a model id (1M-context markers -> wide)."""
420
+ if model and ("[1m]" in model.lower() or "-1m" in model.lower()):
421
+ return _WIDE_WINDOW
422
+ return _DEFAULT_WINDOW
423
+
424
+
425
+ def _load_config(config_path: Path) -> dict:
426
+ """Read forge.config.json into a dict, tolerating missing/corrupt files.
427
+
428
+ A missing, unreadable, or non-object config downgrades to ``{}`` so callers
429
+ read every key through absent-safe ``.get`` defaults.
430
+ """
431
+ try:
432
+ config = json.loads(config_path.read_text(encoding="utf-8"))
433
+ except (OSError, json.JSONDecodeError):
434
+ return {}
435
+ return config if isinstance(config, dict) else {}
436
+
437
+
438
+ def _config_value(config_path: Path, key: str):
439
+ """Read a single key from forge.config.json, or None if absent/unreadable."""
440
+ return _load_config(config_path).get(key)
441
+
442
+
443
+ def auto_verify_for(config: dict, stage: str) -> bool:
444
+ """Return the effective auto-verify setting for ``stage``.
445
+
446
+ Per-stage override in ``autoVerifyStages`` wins over the global ``autoVerify``;
447
+ both default to off, so a config with neither key means "no auto-verify".
448
+
449
+ Parsing is strict and **fails closed**: only a literal JSON ``true`` enables
450
+ auto-verify. A non-boolean value (e.g. the string ``"false"``, which is truthy
451
+ in Python) is treated as off, not on. The schema already rejects non-booleans
452
+ at author time; this guards a hand-edited config from silently enabling
453
+ automation.
454
+ """
455
+ stages = config.get("autoVerifyStages")
456
+ if isinstance(stages, dict) and stage in stages:
457
+ return stages[stage] is True
458
+ return config.get("autoVerify") is True
459
+
460
+
461
+ def invalid_auto_verify_keys(config: dict) -> list[str]:
462
+ """Return ``autoVerifyStages`` keys outside the verify-capable stage ids.
463
+
464
+ An unknown/typo key (e.g. ``forge-1-prod``) would silently never take effect,
465
+ turning an intended off-switch into a no-op. Surfacing it lets the navigator
466
+ warn instead of failing quietly. Mirrors the schema's ``propertyNames.enum``.
467
+ """
468
+ stages = config.get("autoVerifyStages")
469
+ if not isinstance(stages, dict):
470
+ return []
471
+ return [key for key in stages if key not in VERIFY_TOKEN_BY_STAGE]
472
+
473
+
474
+ def context_usage(
475
+ config_path: Path,
476
+ window_override: int | None,
477
+ threshold_override: float | None,
478
+ ) -> dict:
479
+ """Compute live context-window occupancy for the current session.
480
+
481
+ Window precedence: ``--window`` > config ``contextWindowTokens`` > inferred
482
+ from the transcript's model id > ``_DEFAULT_WINDOW``. When inferring (no
483
+ override, no config) and the observed token total already exceeds the default
484
+ window, the window is auto-bumped to ``_WIDE_WINDOW`` — observed tokens above
485
+ 200k prove a wider (1M-beta) window is active, so this corrects the reading
486
+ without ever under-reporting a genuine 200k session. Threshold precedence:
487
+ ``--threshold`` > config ``contextWarnThreshold`` > ``_DEFAULT_THRESHOLD``.
488
+
489
+ Returns a dict with ``available: True`` and ``{tokens, windowTokens, pct,
490
+ overThreshold, recommendation, model}`` when usage is found, or
491
+ ``{available: False, reason}`` otherwise. Never raises for a missing
492
+ transcript — that is the expected non-Claude / fresh-session path.
493
+ """
494
+ threshold = threshold_override
495
+ if threshold is None:
496
+ cfg_threshold = _config_value(config_path, "contextWarnThreshold")
497
+ threshold = (
498
+ float(cfg_threshold)
499
+ if isinstance(cfg_threshold, (int, float))
500
+ else _DEFAULT_THRESHOLD
501
+ )
502
+
503
+ transcript = _latest_transcript(Path.cwd())
504
+ if transcript is None:
505
+ return {"available": False, "reason": "no session transcript found"}
506
+ found = _last_usage(transcript)
507
+ if found is None:
508
+ return {"available": False, "reason": "no usage record in transcript"}
509
+ tokens, model = found
510
+
511
+ window = window_override
512
+ if window is None or window <= 0:
513
+ cfg_window = _config_value(config_path, "contextWindowTokens")
514
+ if isinstance(cfg_window, int) and cfg_window > 0:
515
+ window = cfg_window
516
+ else:
517
+ # Inferring (no override, no config). Start from the model marker /
518
+ # conservative default, then auto-bump: observed tokens above the
519
+ # default window PROVE a wider window is active (a 200k session can
520
+ # never exceed 200k), so widen to 1M rather than report a nonsensical
521
+ # >100%. Never under-reports a real 200k session, which can't trip it.
522
+ window = _infer_window(model)
523
+ if tokens > window:
524
+ window = _WIDE_WINDOW
525
+
526
+ pct = round(tokens / window, 4)
527
+ over = pct >= threshold
528
+ if over:
529
+ recommendation = "clean-session"
530
+ else:
531
+ recommendation = "continue"
532
+ return {
533
+ "available": True,
534
+ "tokens": tokens,
535
+ "windowTokens": window,
536
+ "pct": pct,
537
+ "threshold": threshold,
538
+ "overThreshold": over,
539
+ "recommendation": recommendation,
540
+ "model": model,
541
+ }
542
+
543
+
544
+ # --------------------------------------------------------------------------- #
545
+ # CLI dispatch
546
+ # --------------------------------------------------------------------------- #
547
+
548
+
549
+ def _print_rank_table(rows: list[FeatureRow], counts: dict[str, int]) -> None:
550
+ """Print a human-readable recency-ranked feature list."""
551
+ print(
552
+ f"Active: {counts['active']} "
553
+ f"(paused: {counts['paused']}, abandoned: {counts['abandoned']})"
554
+ )
555
+ if not rows:
556
+ print(" (no active feature pipelines)")
557
+ return
558
+ for idx, row in enumerate(rows):
559
+ marker = "→" if idx == 0 else " "
560
+ label = row["name"] + (f" [{row['epic']}]" if row["epic"] else "")
561
+ nxt = row["nextCommand"] or "complete"
562
+ print(f" {marker} {label}: {row['currentStage']} — next: {nxt}")
563
+ if row["verifyPending"]:
564
+ print(f" (verify available: {row['verifyCommand']})")
565
+
566
+
567
+ def _print_context(usage: dict) -> None:
568
+ """Print a one-line human-readable context-usage summary."""
569
+ if not usage.get("available"):
570
+ print(f"context usage: unavailable ({usage.get('reason', 'unknown')})")
571
+ return
572
+ pct = round(usage["pct"] * 100, 1)
573
+ flag = " — over threshold, clean session recommended" if usage["overThreshold"] else ""
574
+ print(
575
+ f"context: {usage['tokens']:,} / {usage['windowTokens']:,} tokens "
576
+ f"(~{pct}%){flag}"
577
+ )
578
+
579
+
580
+ def main() -> int:
581
+ parser = argparse.ArgumentParser(prog="forge-session.py", description=__doc__)
582
+ sub = parser.add_subparsers(dest="cmd", required=True)
583
+
584
+ p_rank = sub.add_parser("rank-features", help="Rank active features by recency")
585
+ p_rank.add_argument("--specs-dir", default="./specs", help="Specs directory")
586
+ p_rank.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
587
+ p_rank.add_argument("--json", action="store_true", dest="json_output")
588
+
589
+ p_ctx = sub.add_parser("context-usage", help="Report live context-window usage")
590
+ p_ctx.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
591
+ p_ctx.add_argument("--window", type=int, default=None, help="Override context window size")
592
+ p_ctx.add_argument("--threshold", type=float, default=None, help="Override warn fraction (0-1)")
593
+ p_ctx.add_argument("--json", action="store_true", dest="json_output")
594
+
595
+ args = parser.parse_args()
596
+
597
+ try:
598
+ if args.cmd == "rank-features":
599
+ specs_dir = Path(args.specs_dir)
600
+ config = _load_config(Path(args.config))
601
+ rows = build_rows(specs_dir, config)
602
+ counts = _counts(specs_dir)
603
+ invalid_keys = invalid_auto_verify_keys(config)
604
+ if args.json_output:
605
+ payload = {"active": rows, "counts": counts}
606
+ if invalid_keys:
607
+ payload["invalidAutoVerifyKeys"] = invalid_keys
608
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
609
+ else:
610
+ _print_rank_table(rows, counts)
611
+ if invalid_keys:
612
+ print(
613
+ " ! invalid autoVerifyStages keys (ignored): "
614
+ + ", ".join(invalid_keys)
615
+ )
616
+ return 0
617
+
618
+ if args.cmd == "context-usage":
619
+ usage = context_usage(Path(args.config), args.window, args.threshold)
620
+ if args.json_output:
621
+ print(json.dumps(usage, indent=2, ensure_ascii=False))
622
+ else:
623
+ _print_context(usage)
624
+ return 0
625
+
626
+ raise UsageError(f"unknown command: {args.cmd}")
627
+ except UsageError as exc:
628
+ print(f"Error: {exc}", file=sys.stderr)
629
+ return 2
630
+ except OSError as exc:
631
+ print(f"Error: {exc}", file=sys.stderr)
632
+ return 2
633
+
634
+
635
+ if __name__ == "__main__":
636
+ sys.exit(main())