@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.
- package/README.md +6 -1
- package/adapters/GENERATION-REPORT.md +5 -1
- package/adapters/claude/references/forge-config-schema.json +43 -4
- package/adapters/claude/references/pipeline-state-schema.json +3 -2
- package/adapters/claude/references/portable-root.md +2 -2
- package/adapters/claude/references/process-overview.md +10 -0
- package/adapters/claude/references/shared-conventions.md +17 -9
- package/adapters/claude/references/stage-exit-protocol.md +99 -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 +13 -1
- package/adapters/claude/scripts/forge-session.py +636 -0
- package/adapters/claude/skills/forge/SKILL.md +60 -4
- package/adapters/claude/skills/forge-0-epic/SKILL.md +20 -15
- 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 +14 -4
- package/adapters/claude/skills/forge-2-tech/SKILL.md +14 -3
- package/adapters/claude/skills/forge-3-specs/SKILL.md +14 -3
- package/adapters/claude/skills/forge-4-backlog/SKILL.md +16 -5
- package/adapters/claude/skills/forge-5-loop/SKILL.md +20 -21
- package/adapters/claude/skills/forge-5-loop/references/result-reporting.md +10 -5
- package/adapters/claude/skills/forge-5-loop/references/runner-contract.md +41 -15
- 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 +27 -6
- package/adapters/claude/skills/forge-guide/SKILL.md +179 -0
- package/adapters/claude/skills/forge-init/SKILL.md +31 -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/references/forge-config-schema.json +43 -4
- package/adapters/codex/references/pipeline-state-schema.json +3 -2
- package/adapters/codex/references/portable-root.md +2 -2
- package/adapters/codex/references/process-overview.md +10 -0
- package/adapters/codex/references/shared-conventions.md +17 -9
- package/adapters/codex/references/stage-exit-protocol.md +99 -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 +13 -1
- package/adapters/codex/scripts/forge-session.py +636 -0
- package/adapters/codex/skills/forge/SKILL.md +60 -4
- package/adapters/codex/skills/forge-0-epic/SKILL.md +21 -16
- 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 +14 -4
- package/adapters/codex/skills/forge-2-tech/SKILL.md +15 -4
- package/adapters/codex/skills/forge-3-specs/SKILL.md +14 -3
- package/adapters/codex/skills/forge-4-backlog/SKILL.md +16 -5
- package/adapters/codex/skills/forge-5-loop/SKILL.md +22 -23
- package/adapters/codex/skills/forge-5-loop/references/result-reporting.md +10 -5
- package/adapters/codex/skills/forge-5-loop/references/runner-contract.md +41 -15
- 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 +27 -6
- package/adapters/codex/skills/forge-guide/SKILL.md +188 -0
- package/adapters/codex/skills/forge-init/SKILL.md +31 -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/references/forge-config-schema.json +43 -4
- package/adapters/copilot/references/pipeline-state-schema.json +3 -2
- package/adapters/copilot/references/portable-root.md +2 -2
- package/adapters/copilot/references/process-overview.md +10 -0
- package/adapters/copilot/references/shared-conventions.md +17 -9
- package/adapters/copilot/references/stage-exit-protocol.md +99 -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 +13 -1
- package/adapters/copilot/scripts/forge-session.py +636 -0
- package/adapters/copilot/skills/forge/forge.md +60 -4
- package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +21 -16
- 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 +14 -4
- package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +15 -4
- package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +14 -3
- package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +16 -5
- package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +22 -23
- package/adapters/copilot/skills/forge-5-loop/references/result-reporting.md +10 -5
- package/adapters/copilot/skills/forge-5-loop/references/runner-contract.md +41 -15
- 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 +27 -6
- package/adapters/copilot/skills/forge-guide/forge-guide.md +188 -0
- package/adapters/copilot/skills/forge-init/forge-init.md +31 -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/references/forge-config-schema.json +43 -4
- package/adapters/cursor/references/pipeline-state-schema.json +3 -2
- package/adapters/cursor/references/portable-root.md +2 -2
- package/adapters/cursor/references/process-overview.md +10 -0
- package/adapters/cursor/references/shared-conventions.md +17 -9
- package/adapters/cursor/references/stage-exit-protocol.md +99 -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 +13 -1
- package/adapters/cursor/scripts/forge-session.py +636 -0
- package/adapters/cursor/skills/forge/forge.mdc +60 -4
- package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +21 -16
- 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 +14 -4
- package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +15 -4
- package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +14 -3
- package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +16 -5
- package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +22 -23
- package/adapters/cursor/skills/forge-5-loop/references/result-reporting.md +10 -5
- package/adapters/cursor/skills/forge-5-loop/references/runner-contract.md +41 -15
- 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 +27 -6
- package/adapters/cursor/skills/forge-guide/forge-guide.mdc +189 -0
- package/adapters/cursor/skills/forge-init/forge-init.mdc +31 -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/gemini-extension.json +4 -0
- package/adapters/gemini/references/forge-config-schema.json +43 -4
- package/adapters/gemini/references/pipeline-state-schema.json +3 -2
- package/adapters/gemini/references/portable-root.md +2 -2
- package/adapters/gemini/references/process-overview.md +10 -0
- package/adapters/gemini/references/shared-conventions.md +17 -9
- package/adapters/gemini/references/stage-exit-protocol.md +99 -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 +13 -1
- package/adapters/gemini/scripts/forge-session.py +636 -0
- package/adapters/gemini/skills/forge/forge.md +60 -4
- package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +21 -16
- 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 +14 -4
- package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +15 -4
- package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +14 -3
- package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +16 -5
- package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +22 -23
- package/adapters/gemini/skills/forge-5-loop/references/result-reporting.md +10 -5
- package/adapters/gemini/skills/forge-5-loop/references/runner-contract.md +41 -15
- 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 +27 -6
- package/adapters/gemini/skills/forge-guide/forge-guide.md +188 -0
- package/adapters/gemini/skills/forge-init/forge-init.md +31 -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
|
@@ -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())
|