@garygentry/feature-forge 0.2.6 → 0.2.8
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/adapters/claude/.feature-forge-bundle.json +1 -1
- package/adapters/claude/references/portable-root.md +18 -9
- package/adapters/claude/references/shared-conventions.md +21 -4
- package/adapters/claude/references/stage-exit-protocol.md +1 -1
- package/adapters/claude/references/vendor-construct-inventory.md +1 -0
- package/adapters/claude/scripts/forge-session.py +259 -10
- package/adapters/claude/skills/forge/SKILL.md +9 -9
- package/adapters/claude/skills/forge-0-epic/SKILL.md +5 -5
- package/adapters/claude/skills/forge-0-epic/references/edit-mode.md +2 -2
- package/adapters/claude/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/claude/skills/forge-1-prd/SKILL.md +1 -1
- package/adapters/claude/skills/forge-2-tech/SKILL.md +1 -1
- package/adapters/claude/skills/forge-3-specs/SKILL.md +1 -1
- package/adapters/claude/skills/forge-4-backlog/SKILL.md +1 -1
- package/adapters/claude/skills/forge-5-loop/SKILL.md +2 -2
- package/adapters/claude/skills/forge-6-docs/SKILL.md +1 -1
- package/adapters/claude/skills/forge-bootstrap/SKILL.md +4 -4
- package/adapters/claude/skills/forge-init/SKILL.md +1 -1
- package/adapters/claude/skills/forge-verify/SKILL.md +1 -1
- package/adapters/claude/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/codex/.feature-forge-bundle.json +1 -1
- package/adapters/codex/references/portable-root.md +18 -9
- package/adapters/codex/references/shared-conventions.md +21 -4
- package/adapters/codex/references/stage-exit-protocol.md +1 -1
- package/adapters/codex/references/vendor-construct-inventory.md +1 -0
- package/adapters/codex/scripts/forge-session.py +259 -10
- package/adapters/codex/skills/forge/SKILL.md +9 -9
- package/adapters/codex/skills/forge-0-epic/SKILL.md +5 -5
- package/adapters/codex/skills/forge-0-epic/references/edit-mode.md +2 -2
- package/adapters/codex/skills/forge-0-epic/references/epic-manifest-subcommands.md +1 -1
- package/adapters/codex/skills/forge-1-prd/SKILL.md +1 -1
- package/adapters/codex/skills/forge-2-tech/SKILL.md +1 -1
- package/adapters/codex/skills/forge-3-specs/SKILL.md +1 -1
- package/adapters/codex/skills/forge-4-backlog/SKILL.md +1 -1
- package/adapters/codex/skills/forge-5-loop/SKILL.md +2 -2
- package/adapters/codex/skills/forge-6-docs/SKILL.md +1 -1
- package/adapters/codex/skills/forge-bootstrap/SKILL.md +4 -4
- package/adapters/codex/skills/forge-init/SKILL.md +1 -1
- package/adapters/codex/skills/forge-verify/SKILL.md +1 -1
- package/adapters/codex/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/copilot/.feature-forge-bundle.json +1 -1
- package/adapters/copilot/references/portable-root.md +18 -9
- package/adapters/copilot/references/shared-conventions.md +21 -4
- package/adapters/copilot/references/stage-exit-protocol.md +1 -1
- package/adapters/copilot/references/vendor-construct-inventory.md +1 -0
- package/adapters/copilot/scripts/forge-session.py +259 -10
- package/adapters/copilot/skills/forge/forge.md +9 -9
- package/adapters/copilot/skills/forge-0-epic/forge-0-epic.md +5 -5
- package/adapters/copilot/skills/forge-0-epic/references/edit-mode.md +2 -2
- 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 +1 -1
- package/adapters/copilot/skills/forge-2-tech/forge-2-tech.md +1 -1
- package/adapters/copilot/skills/forge-3-specs/forge-3-specs.md +1 -1
- package/adapters/copilot/skills/forge-4-backlog/forge-4-backlog.md +1 -1
- package/adapters/copilot/skills/forge-5-loop/forge-5-loop.md +2 -2
- package/adapters/copilot/skills/forge-6-docs/forge-6-docs.md +1 -1
- package/adapters/copilot/skills/forge-bootstrap/forge-bootstrap.md +4 -4
- package/adapters/copilot/skills/forge-init/forge-init.md +1 -1
- package/adapters/copilot/skills/forge-verify/forge-verify.md +1 -1
- package/adapters/copilot/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/cursor/.feature-forge-bundle.json +1 -1
- package/adapters/cursor/references/portable-root.md +18 -9
- package/adapters/cursor/references/shared-conventions.md +21 -4
- package/adapters/cursor/references/stage-exit-protocol.md +1 -1
- package/adapters/cursor/references/vendor-construct-inventory.md +1 -0
- package/adapters/cursor/scripts/forge-session.py +259 -10
- package/adapters/cursor/skills/forge/forge.mdc +9 -9
- package/adapters/cursor/skills/forge-0-epic/forge-0-epic.mdc +5 -5
- package/adapters/cursor/skills/forge-0-epic/references/edit-mode.md +2 -2
- 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 +1 -1
- package/adapters/cursor/skills/forge-2-tech/forge-2-tech.mdc +1 -1
- package/adapters/cursor/skills/forge-3-specs/forge-3-specs.mdc +1 -1
- package/adapters/cursor/skills/forge-4-backlog/forge-4-backlog.mdc +1 -1
- package/adapters/cursor/skills/forge-5-loop/forge-5-loop.mdc +2 -2
- package/adapters/cursor/skills/forge-6-docs/forge-6-docs.mdc +1 -1
- package/adapters/cursor/skills/forge-bootstrap/forge-bootstrap.mdc +4 -4
- package/adapters/cursor/skills/forge-init/forge-init.mdc +1 -1
- package/adapters/cursor/skills/forge-verify/forge-verify.mdc +1 -1
- package/adapters/cursor/skills/forge-verify/references/verification-checklists.md +1 -1
- package/adapters/gemini/.feature-forge-bundle.json +1 -1
- package/adapters/gemini/gemini-extension.json +1 -1
- package/adapters/gemini/references/portable-root.md +18 -9
- package/adapters/gemini/references/shared-conventions.md +21 -4
- package/adapters/gemini/references/stage-exit-protocol.md +1 -1
- package/adapters/gemini/references/vendor-construct-inventory.md +1 -0
- package/adapters/gemini/scripts/forge-session.py +259 -10
- package/adapters/gemini/skills/forge/forge.md +9 -9
- package/adapters/gemini/skills/forge-0-epic/forge-0-epic.md +5 -5
- package/adapters/gemini/skills/forge-0-epic/references/edit-mode.md +2 -2
- 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 +1 -1
- package/adapters/gemini/skills/forge-2-tech/forge-2-tech.md +1 -1
- package/adapters/gemini/skills/forge-3-specs/forge-3-specs.md +1 -1
- package/adapters/gemini/skills/forge-4-backlog/forge-4-backlog.md +1 -1
- package/adapters/gemini/skills/forge-5-loop/forge-5-loop.md +2 -2
- package/adapters/gemini/skills/forge-6-docs/forge-6-docs.md +1 -1
- package/adapters/gemini/skills/forge-bootstrap/forge-bootstrap.md +4 -4
- package/adapters/gemini/skills/forge-init/forge-init.md +1 -1
- package/adapters/gemini/skills/forge-verify/forge-verify.md +1 -1
- package/adapters/gemini/skills/forge-verify/references/verification-checklists.md +1 -1
- package/package.json +1 -1
|
@@ -59,7 +59,7 @@ Every bash invocation begins with the byte-identical portable-root prelude, then
|
|
|
59
59
|
helper. Pass `--specs-dir ./specs` (the default) so the gate allow-lists the specs directory.
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
62
|
+
R="$(bash -c 'for d in "${FEATURE_FORGE_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
63
63
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
64
64
|
python3 "$R/scripts/forge-bootstrap.py" check "<target-dir>" --json --specs-dir ./specs
|
|
65
65
|
```
|
|
@@ -136,7 +136,7 @@ when running under a Claude host (e.g. the host's question mechanism is availabl
|
|
|
136
136
|
`CLAUDE.md` only when `host == "claude"`.
|
|
137
137
|
|
|
138
138
|
```bash
|
|
139
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
139
|
+
R="$(bash -c 'for d in "${FEATURE_FORGE_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
140
140
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
141
141
|
python3 "$R/scripts/forge-bootstrap.py" scaffold "<target-dir>" --json --answers '<Answers JSON>'
|
|
142
142
|
```
|
|
@@ -144,7 +144,7 @@ python3 "$R/scripts/forge-bootstrap.py" scaffold "<target-dir>" --json --answers
|
|
|
144
144
|
### Step 5 — verify
|
|
145
145
|
|
|
146
146
|
```bash
|
|
147
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
147
|
+
R="$(bash -c 'for d in "${FEATURE_FORGE_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
148
148
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
149
149
|
python3 "$R/scripts/forge-bootstrap.py" verify "<target-dir>" --json --answers '<Answers JSON>'
|
|
150
150
|
```
|
|
@@ -170,7 +170,7 @@ and removes the sentinel before staging so it never enters history. Read
|
|
|
170
170
|
leave the sentinel in-progress (resumable) — do **not** declare success.
|
|
171
171
|
|
|
172
172
|
```bash
|
|
173
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
173
|
+
R="$(bash -c 'for d in "${FEATURE_FORGE_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
174
174
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
175
175
|
python3 "$R/scripts/forge-bootstrap.py" commit "<target-dir>" --json --answers '<Answers JSON>' [--stage-only]
|
|
176
176
|
```
|
|
@@ -10,7 +10,7 @@ alwaysApply: false
|
|
|
10
10
|
Run the initialization script to create `forge.config.json` with default settings:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
13
|
+
R="$(bash -c 'for d in "${FEATURE_FORGE_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
14
14
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
15
15
|
bash "$R/scripts/forge-init.sh"
|
|
16
16
|
```
|
|
@@ -256,7 +256,7 @@ Do NOT mark as `findings-applied` — that happens after the fix pass.
|
|
|
256
256
|
- For specs verification, also run the deterministic traceability validator to supplement agent-driven traceability checks. Include any uncovered requirements or orphaned references as findings:
|
|
257
257
|
|
|
258
258
|
```bash
|
|
259
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
259
|
+
R="$(bash -c 'for d in "${FEATURE_FORGE_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
260
260
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
261
261
|
python3 "$R/scripts/validate-traceability.py" {resolvedFeatureDir}/PRD.md {resolvedFeatureDir}/ --json
|
|
262
262
|
```
|
|
@@ -192,7 +192,7 @@ findings to E01/E02/E03/E08. Then perform the judgment checks E04–E07 by readi
|
|
|
192
192
|
manifest, EPIC.md, and completed members' specs.
|
|
193
193
|
|
|
194
194
|
```bash
|
|
195
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
195
|
+
R="$(bash -c 'for d in "${CLAUDE_PLUGIN_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
196
196
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
197
197
|
python3 "$R/scripts/epic-manifest.py" validate "{epic}" --specs-dir "{specsDir}" --json
|
|
198
198
|
```
|
|
@@ -12,7 +12,7 @@ against the fenced block here, byte-for-byte.
|
|
|
12
12
|
## Canonical bootstrap prelude
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
15
|
+
R="$(bash -c 'for d in "${CLAUDE_PLUGIN_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
16
16
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
17
17
|
```
|
|
18
18
|
|
|
@@ -24,25 +24,34 @@ makes several calls, add the prelude once and reuse `$R` for each. A fresh block
|
|
|
24
24
|
prelude (per-block re-resolution). Worked example:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
27
|
+
R="$(bash -c 'for d in "${CLAUDE_PLUGIN_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
28
28
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
29
29
|
python3 "$R/scripts/epic-manifest.py" render-status "{epic}" --specs-dir "{specsDir}" --json
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Invariants (do NOT "fix" these)
|
|
33
33
|
|
|
34
|
-
1. **
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
1. **First hint is the Claude plugin-root env var; the rest are paths.** The prelude's
|
|
35
|
+
`for d in …` list leads with the `CLAUDE_PLUGIN_ROOT` env var (in default-empty `:-` form)
|
|
36
|
+
— the exact bundle dir Claude exports in every marketplace/plugin session — followed by the
|
|
37
|
+
directory-path candidates. The hint gives exact, glob-free resolution on any current/future
|
|
38
|
+
Claude layout (no version-skew window); when unset it expands to empty and is harmlessly
|
|
39
|
+
skipped, so other hosts fall through to the path candidates unchanged. This is the **one
|
|
40
|
+
sanctioned** appearance of that variable in canonical surfaces: spec-purity rule 3 allows it
|
|
41
|
+
by stripping the byte-pinned prelude before its residual-var scan (so a stray var anywhere
|
|
42
|
+
else — including the default-empty form — still fails), and `forge-agent-adapters-build`
|
|
43
|
+
translates it to `FEATURE_FORGE_ROOT` for non-Claude bundles (which `forge-root.sh` already
|
|
44
|
+
prefers). The exact literal is shown only in the fenced prelude above and audited in
|
|
45
|
+
`vendor-construct-inventory.md`.
|
|
38
46
|
2. **First-discoverable-resolver-wins.** The `exec` inside the `$(…)` command substitution means
|
|
39
47
|
the loop stops at the first directory holding an executable `forge-root.sh` and delegates ALL
|
|
40
48
|
final root resolution to that script. The `for` list is a discovery order for `forge-root.sh`
|
|
41
49
|
itself, not a fallback chain for the plugin root. Removing the `exec` to "keep looping" is a
|
|
42
50
|
regression — once `exec`'d, the loop is replaced by the resolver process and never advances.
|
|
43
|
-
3. **Prelude candidate set is an agent-neutral bootstrap subset
|
|
44
|
-
exists only to bootstrap-discover `forge-root.sh`; the authoritative
|
|
45
|
-
`forge-root.sh` step 2.
|
|
51
|
+
3. **Prelude candidate set is an agent-neutral bootstrap subset (after the env hint).** The
|
|
52
|
+
prelude's `for d` list exists only to bootstrap-discover `forge-root.sh`; the authoritative
|
|
53
|
+
multi-root probe lives in `forge-root.sh` step 2. After the leading env-var hint (invariant 1),
|
|
54
|
+
the list enumerates install roots across agents — the Claude
|
|
46
55
|
skill/plugin dirs — including the marketplace-cache layout
|
|
47
56
|
`~/.claude/plugins/cache/<marketplace>/feature-forge/<version>/`, listed before the
|
|
48
57
|
single-star plugins glob so a versioned cache install always beats the marketplace clone —
|
|
@@ -81,7 +81,7 @@ Extract these config values (use defaults if not present):
|
|
|
81
81
|
Before any file I/O against a feature's artifacts, resolve its directory through the deterministic helper rather than hardcoding `{specsDir}/{feature}/`. This makes flat (`{specsDir}/{feature}/`) and nested (`{specsDir}/{epic}/{feature}/`) layouts both resolve from a bare feature name (REQ-DIR-03), with standalone features behaving exactly as today.
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
84
|
+
R="$(bash -c 'for d in "${CLAUDE_PLUGIN_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
85
85
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
86
86
|
resolvedFeatureDir=$(python3 "$R/scripts/epic-manifest.py" \
|
|
87
87
|
resolve "<feature>" --specs-dir "<specsDir>")
|
|
@@ -96,7 +96,7 @@ In both failure cases, do not fall back to a guessed path.
|
|
|
96
96
|
**On `not-found`, check other branches before stopping.** With `branchPerFeature`, the feature's directory (and its `.pipeline-state.json`) may exist only on its topic branch — invisible from the default branch of a fresh clone. Before concluding the pipeline does not exist, run the read-only cross-branch discovery:
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
99
|
+
R="$(bash -c 'for d in "${CLAUDE_PLUGIN_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
100
100
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
101
101
|
python3 "$R/scripts/forge-session.py" discover-feature "<feature>" --specs-dir "<specsDir>" --json
|
|
102
102
|
```
|
|
@@ -124,7 +124,7 @@ Whenever a stage creates the specs tree for the first time (the first PRD or epi
|
|
|
124
124
|
Run this after creating the feature/epic directory, before the stage's git commit:
|
|
125
125
|
|
|
126
126
|
```bash
|
|
127
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
127
|
+
R="$(bash -c 'for d in "${CLAUDE_PLUGIN_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
128
128
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
129
129
|
mkdir -p "<specsDir>"
|
|
130
130
|
[ -f "<specsDir>/AGENTS.md" ] || cp "$R/references/templates/specs-hygiene/AGENTS.md" "<specsDir>/AGENTS.md"
|
|
@@ -151,7 +151,7 @@ After resolving the feature directory, check the feature's `.pipeline-state.json
|
|
|
151
151
|
To obtain the manifest contracts and the live completion status of each dependency in one deterministic call, run `render-status` and read the per-feature `status` and the `consumes`/`exposes` arrays rather than re-deriving them:
|
|
152
152
|
|
|
153
153
|
```bash
|
|
154
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
154
|
+
R="$(bash -c 'for d in "${CLAUDE_PLUGIN_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
155
155
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
156
156
|
python3 "$R/scripts/epic-manifest.py" \
|
|
157
157
|
render-status "<epic>" --specs-dir "<specsDir>" --json
|
|
@@ -194,6 +194,23 @@ Invoke this block at the **very start** of a pipeline entry point — `forge-1-p
|
|
|
194
194
|
|
|
195
195
|
**Record the branch.** After this block resolves, write the resulting branch name to the feature's `.pipeline-state.json` top-level `branch` field (create/update it when the state file is first written for this stage). Downstream stages and `forge-5-loop` read it to detect drift back onto the default branch.
|
|
196
196
|
|
|
197
|
+
## Branch Reconciliation
|
|
198
|
+
|
|
199
|
+
The recorded `branch` is a **self-healing hint, not gospel.** A hosted environment (Claude.ai remote, cloud agents) can impose an arbitrary session branch (e.g. `claude/<slug>`) that Branch Setup silently records; the user may then move the work to the intended topic branch, leaving the recorded field stale. Every branch-aware mechanism (the `forge-5-loop` guard, `discover-feature`) keys off that field, so a stale value actively misleads — the loop would offer to switch you *back* to the imposed branch. Invoke this block from `forge-5-loop`'s pre-flight (and any stage that acts on the recorded branch) to reconcile deterministically. Skip if not a git repo or `branchPerFeature` is false.
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
R="$(bash -c 'for d in "${CLAUDE_PLUGIN_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
203
|
+
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
204
|
+
python3 "$R/scripts/forge-session.py" reconcile-branch --feature "{feature}" --specs-dir "{specsDir}" --json
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Act on the emitted `action` (source of truth is where the state actually resolves, not the recorded field):
|
|
208
|
+
- **`adopt-current`** — you are on a non-default topic branch where the state resolves, and the recorded `branch` differs (a stale/imposed value). Write `newBranch` into the state `branch` field with a **visible one-line note** ("recorded branch was `{stateBranch}`; work is on `{currentBranch}` — updating to match") — never silently, and **never push the user back** to the recorded branch (offer that only as a plain alternative).
|
|
209
|
+
- **`warn-drift`** — you are on the **default** branch and the state records a topic branch. Via `AskUserQuestion`, strongly recommend creating/switching to `{branchPrefix}{feature}` (then record it), still allowing **proceed on the default branch**. Never hard-stop.
|
|
210
|
+
- **`none`** / **`not-resolved`** — nothing to do; proceed.
|
|
211
|
+
|
|
212
|
+
If the helper is unavailable (non-Claude host without the resolver), fall back to the manual check: current branch differs from recorded → adopt the current branch unless it is the default, in which case recommend creating `{branchPrefix}{feature}`.
|
|
213
|
+
|
|
197
214
|
## Git Commit Protocol
|
|
198
215
|
|
|
199
216
|
When `gitCommitAfterStage` is true, follow this exact order to avoid state inconsistency.
|
|
@@ -57,7 +57,7 @@ placeholders the skill resolves before running the command, exactly as elsewhere
|
|
|
57
57
|
**Close this stage with the Scripted Stage Exit** (contract: `references/stage-exit-protocol.md`; do not improvise a "Next steps" list). Run:
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
|
-
R="$(bash -c 'for d in "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
60
|
+
R="$(bash -c 'for d in "${CLAUDE_PLUGIN_ROOT:-}" "$HOME"/.claude/skills/feature-forge "$HOME"/.claude/plugins/cache/*/feature-forge/* "$HOME"/.claude/plugins/*/feature-forge "$HOME"/.agents/skills/feature-forge ./.agents/skills/feature-forge; do [ -x "$d/scripts/forge-root.sh" ] && exec "$d/scripts/forge-root.sh"; done')"
|
|
61
61
|
[ -n "$R" ] || { echo "feature-forge: cannot locate plugin root" >&2; exit 1; }
|
|
62
62
|
python3 "$R/scripts/forge-session.py" stage-exit {stage-exit-args} --specs-dir "{specsDir}" --host claude
|
|
63
63
|
```
|
|
@@ -25,6 +25,7 @@ vocabulary defined in `00-core-definitions.md` §8. No free-form values are perm
|
|
|
25
25
|
| `argument-hint` (top-level frontmatter key) | 10 `skills/*/SKILL.md` (all skills **except** `forge-init`) | `relocated` | Claude-specific vendor key (constraint C-2). Moved verbatim to `metadata.argument-hint` per REQ-VND-01 (see `02-frontmatter-purity-and-inventory.md` §2). Value byte-identical; `description` untouched. |
|
|
26
26
|
| `${CLAUDE_PLUGIN_ROOT}` — canonical invocations + prose | 23 occurrences across 9 canonical surfaces: `skills/forge-0-epic/SKILL.md` (12), `skills/forge/SKILL.md` (3), `skills/forge-5-loop/SKILL.md` (1), `skills/forge-6-docs/SKILL.md` (1), `skills/forge-init/SKILL.md` (1), `skills/forge-verify/SKILL.md` (1), `skills/forge-verify/references/verification-checklists.md` (1), `references/shared-conventions.md` (2), `agents/forge-verifier.md` (1) | `routed-through-resolver` | Claude-only env var. Routed through the byte-identical bootstrap prelude + `scripts/forge-root.sh` per REQ-RES-03. Mechanics owned by `03-portable-root-resolver.md`; recorded here for audit completeness. |
|
|
27
27
|
| `${CLAUDE_PLUGIN_ROOT}` — sanctioned residual | 1 occurrence in `scripts/forge-root.sh` (env-fallback, REQ-RES-02 step 3) | `preserved-as-spec-allowed` | The single sanctioned residual: the resolver's documented Claude-compat fallback (REQ-RES-03 / REQ-RES-05). Exempt from the residual-var scan (`00-core-definitions.md` §6 `RESIDUAL_VAR_EXEMPT`). |
|
|
28
|
+
| `${CLAUDE_PLUGIN_ROOT:-}` — bootstrap-prelude first-hint (Chunk 2b) | 1 occurrence per prelude across every canonical stamp site (the byte-pinned `BOOTSTRAP_PRELUDE`) | `preserved-as-spec-allowed` | The prelude's first resolver candidate — exact, glob-free root resolution on any Claude layout; expands to empty and is skipped when unset. Rule 3 allows it by stripping the byte-pinned prelude before its scan (detection is by `${CLAUDE_PLUGIN_ROOT` prefix, so the `:-}` default form is not an escape hatch elsewhere). `forge-agent-adapters-build` translates it to `${FEATURE_FORGE_ROOT:-}` in non-Claude bundles. |
|
|
28
29
|
| `${CLAUDE_PLUGIN_ROOT}` — in `hooks/hooks.json` | 1 occurrence in `hooks/hooks.json` | `out-of-canon` | Non-canonical Claude artifact (REQ-VND-04). Not a canonical surface; exempt from the REQ-RES-03 scan. Left in place. |
|
|
29
30
|
| `hooks/hooks.json` SessionStart wiring | 1 file (`hooks/hooks.json`) — Claude `SessionStart` → `bash ${CLAUDE_PLUGIN_ROOT}/scripts/session-check.sh` | `out-of-canon` | Claude-specific plugin hook wiring (REQ-VND-04, decision D3). Preserved + documented so `forge-agent-adapters-build` treats it as a Claude artifact, not portable canon. |
|
|
30
31
|
| (contingency) any other vendor invocation directive | none found in the audit | — | REQ-VND-02 contingency did not fire (see Notes). If one is later surfaced, add a row with `removed` or `out-of-canon` per `02-frontmatter-purity-and-inventory.md` §3. |
|
|
@@ -8,7 +8,9 @@ root navigator:
|
|
|
8
8
|
python3 forge-session.py context-usage [--config FILE] [--window N] \
|
|
9
9
|
[--threshold F] [--json]
|
|
10
10
|
python3 forge-session.py doctor [--specs-dir DIR] [--config FILE] [--json]
|
|
11
|
-
python3 forge-session.py discover-feature NAME [--specs-dir DIR] [--json]
|
|
11
|
+
python3 forge-session.py discover-feature [NAME | --all] [--specs-dir DIR] [--json]
|
|
12
|
+
python3 forge-session.py reconcile-branch --feature F [--specs-dir DIR] \
|
|
13
|
+
[--config FILE] [--epic E] [--json]
|
|
12
14
|
python3 forge-session.py stage-exit --feature F --stage S [--specs-dir DIR] \
|
|
13
15
|
[--config FILE] [--epic E] [--next-feature N] [--host claude|generic] [--json]
|
|
14
16
|
|
|
@@ -135,6 +137,7 @@ class FeatureRow(TypedDict):
|
|
|
135
137
|
verifyState: str
|
|
136
138
|
autoVerify: bool
|
|
137
139
|
autoFix: bool
|
|
140
|
+
verifyGate: str
|
|
138
141
|
|
|
139
142
|
|
|
140
143
|
class UsageError(Exception):
|
|
@@ -357,6 +360,16 @@ def build_rows(specs_dir: Path, config: dict | None = None) -> list[FeatureRow]:
|
|
|
357
360
|
"verifyState": vlabel,
|
|
358
361
|
"autoVerify": effective_auto_verify,
|
|
359
362
|
"autoFix": global_auto_fix and effective_auto_verify,
|
|
363
|
+
# Single resolved verify-gate classification (5b — one exit computation,
|
|
364
|
+
# mirroring stage-exit's `verifyGate`): the navigator reads this instead of
|
|
365
|
+
# re-deriving from verifyPending + autoVerify in prose. `auto` = the §2b
|
|
366
|
+
# catch-up runs it unattended; `standard` = the §3 gate (degrades to
|
|
367
|
+
# manual-print on a non-Claude host); `none` = nothing outstanding.
|
|
368
|
+
"verifyGate": (
|
|
369
|
+
"none" if not verify_pending
|
|
370
|
+
else "auto" if effective_auto_verify
|
|
371
|
+
else "standard"
|
|
372
|
+
),
|
|
360
373
|
})
|
|
361
374
|
# Sort by updatedAt desc; rows without a parseable timestamp sort last.
|
|
362
375
|
rows.sort(
|
|
@@ -659,11 +672,18 @@ def doctor_report(specs_dir: Path, config_path: Path) -> dict:
|
|
|
659
672
|
# --show-current (not rev-parse HEAD) so an unborn branch (fresh repo,
|
|
660
673
|
# no commits yet) still reports its name instead of failing.
|
|
661
674
|
current_branch = _git_output(["branch", "--show-current"])
|
|
675
|
+
default_branch = _default_branch()
|
|
662
676
|
rows = build_rows(specs_dir, config)
|
|
663
677
|
features = []
|
|
664
678
|
for row in rows:
|
|
665
679
|
backlog = _backlog_path(config, row["name"], row["epic"], specs_dir)
|
|
666
680
|
state_branch = row["branch"]
|
|
681
|
+
mismatch = bool(state_branch and current_branch and state_branch != current_branch)
|
|
682
|
+
# Classify a mismatch: on a topic branch it is adoptable (imposed/session-branch
|
|
683
|
+
# drift, Chunk 6); on the default branch it is real drift-back, only a warning.
|
|
684
|
+
branch_reconcile = None
|
|
685
|
+
if mismatch:
|
|
686
|
+
branch_reconcile = "warn-drift" if current_branch == default_branch else "adopt-current"
|
|
667
687
|
features.append({
|
|
668
688
|
"name": row["name"],
|
|
669
689
|
"epic": row["epic"],
|
|
@@ -676,6 +696,7 @@ def doctor_report(specs_dir: Path, config_path: Path) -> dict:
|
|
|
676
696
|
if state_branch and current_branch
|
|
677
697
|
else None
|
|
678
698
|
),
|
|
699
|
+
"branchReconcile": branch_reconcile,
|
|
679
700
|
"backlogPath": str(backlog),
|
|
680
701
|
"backlogExists": backlog.is_file(),
|
|
681
702
|
})
|
|
@@ -720,7 +741,12 @@ def _print_doctor(report: dict) -> None:
|
|
|
720
741
|
label = feat["name"] + (f" [{feat['epic']}]" if feat["epic"] else "")
|
|
721
742
|
branch = feat["stateBranch"] or "?"
|
|
722
743
|
if feat["branchMatchesState"] is False:
|
|
723
|
-
|
|
744
|
+
if feat.get("branchReconcile") == "adopt-current":
|
|
745
|
+
branch += " (MISMATCH — reconcile: adopt current branch)"
|
|
746
|
+
elif feat.get("branchReconcile") == "warn-drift":
|
|
747
|
+
branch += " (MISMATCH — on default branch; create a topic branch)"
|
|
748
|
+
else:
|
|
749
|
+
branch += " (MISMATCH vs current)"
|
|
724
750
|
backlog = "exists" if feat["backlogExists"] else "MISSING"
|
|
725
751
|
print(
|
|
726
752
|
f" - {label}: stage={feat['currentStage']} "
|
|
@@ -937,6 +963,193 @@ def _print_discover(payload: dict) -> None:
|
|
|
937
963
|
print(f" switch: {cand['switchCommand']}")
|
|
938
964
|
|
|
939
965
|
|
|
966
|
+
def _all_state_paths_in_ref(ref: str, specs_rel: str) -> list[tuple[str, str]]:
|
|
967
|
+
"""Every feature-shaped ``.pipeline-state.json`` in one ref as ``(path, feature)``.
|
|
968
|
+
|
|
969
|
+
The ``--all`` counterpart to ``_state_paths_in_ref``: same flat/nested bound
|
|
970
|
+
(``{specsDir}/{name}/…`` or ``{specsDir}/{epic}/{name}/…``) but for every
|
|
971
|
+
feature, not one named one.
|
|
972
|
+
"""
|
|
973
|
+
listing = _git_output(["ls-tree", "-r", "--name-only", ref, "--", specs_rel])
|
|
974
|
+
if not listing:
|
|
975
|
+
return []
|
|
976
|
+
hits: list[tuple[str, str]] = []
|
|
977
|
+
prefix = specs_rel + "/"
|
|
978
|
+
for path in listing.splitlines():
|
|
979
|
+
if not path.startswith(prefix) or not path.endswith("/" + PIPELINE_STATE_FILENAME):
|
|
980
|
+
continue
|
|
981
|
+
segments = path[len(prefix):].split("/")
|
|
982
|
+
if len(segments) == 2: # [name, state-file] (flat)
|
|
983
|
+
hits.append((path, segments[0]))
|
|
984
|
+
elif len(segments) == 3: # [epic, name, state-file] (nested)
|
|
985
|
+
hits.append((path, segments[1]))
|
|
986
|
+
return hits
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def discover_all(specs_dir: str) -> dict:
|
|
990
|
+
"""Discover EVERY feature's pipeline state across all branches (read-only, Chunk 5c).
|
|
991
|
+
|
|
992
|
+
The empty-dashboard counterpart to ``discover-feature <name>``: enumerates every
|
|
993
|
+
feature-shaped state across local heads + remote-tracking refs and groups the
|
|
994
|
+
candidates by feature, so a fresh clone / default-branch session can see the whole
|
|
995
|
+
branch-scattered pipeline set instead of nothing. Never mutates anything.
|
|
996
|
+
"""
|
|
997
|
+
if _git_output(["rev-parse", "--git-dir"]) is None:
|
|
998
|
+
return {"gitRepo": False, "currentBranch": None, "features": []}
|
|
999
|
+
current_branch = _git_output(["branch", "--show-current"])
|
|
1000
|
+
specs_rel = _specs_rel(specs_dir)
|
|
1001
|
+
refs = [(ref, date, False) for ref, date in _list_refs("refs/heads")]
|
|
1002
|
+
refs += [(ref, date, True) for ref, date in _list_refs("refs/remotes")]
|
|
1003
|
+
|
|
1004
|
+
by_feature: dict[str, list[dict]] = {}
|
|
1005
|
+
for ref, commit_date, is_remote in refs:
|
|
1006
|
+
branch = ref.split("/", 1)[1] if is_remote else ref
|
|
1007
|
+
if is_remote and (not branch or branch == "HEAD"):
|
|
1008
|
+
continue
|
|
1009
|
+
for path, feature in _all_state_paths_in_ref(ref, specs_rel):
|
|
1010
|
+
seen = by_feature.setdefault(feature, [])
|
|
1011
|
+
if any(c["branch"] == branch for c in seen):
|
|
1012
|
+
continue # a local head already yielded this branch's state
|
|
1013
|
+
state = _read_state_at_ref(ref, path)
|
|
1014
|
+
state_branch = state.get("branch")
|
|
1015
|
+
state_branch = state_branch if isinstance(state_branch, str) else None
|
|
1016
|
+
seen.append({
|
|
1017
|
+
"branch": branch,
|
|
1018
|
+
"remoteTracking": is_remote,
|
|
1019
|
+
"path": path,
|
|
1020
|
+
"stateBranch": state_branch,
|
|
1021
|
+
"stateBranchMatches": state_branch == branch,
|
|
1022
|
+
"currentStage": state.get("currentStage"),
|
|
1023
|
+
"pipelineStatus": state.get("pipelineStatus", "active"),
|
|
1024
|
+
"commitDate": commit_date or None,
|
|
1025
|
+
"isCurrentBranch": branch == current_branch,
|
|
1026
|
+
"switchCommand": f"git switch {branch}",
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
def _rank(cand: dict) -> tuple:
|
|
1030
|
+
ts = _parse_ts(cand["commitDate"]) or datetime.min.replace(tzinfo=timezone.utc)
|
|
1031
|
+
return (not cand["stateBranchMatches"], cand["remoteTracking"], -ts.timestamp())
|
|
1032
|
+
|
|
1033
|
+
features = []
|
|
1034
|
+
for feature in sorted(by_feature):
|
|
1035
|
+
cands = sorted(by_feature[feature], key=_rank)
|
|
1036
|
+
features.append({"feature": feature, "candidates": cands})
|
|
1037
|
+
return {"gitRepo": True, "currentBranch": current_branch, "features": features}
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _print_discover_all(payload: dict) -> None:
|
|
1041
|
+
"""Human-readable ``discover-feature --all`` report."""
|
|
1042
|
+
if not payload["gitRepo"]:
|
|
1043
|
+
print("discover-feature --all: not a git repository — nothing to scan")
|
|
1044
|
+
return
|
|
1045
|
+
if not payload["features"]:
|
|
1046
|
+
print("discover-feature --all: no pipeline state found on any local or "
|
|
1047
|
+
"remote-tracking branch")
|
|
1048
|
+
return
|
|
1049
|
+
for feat in payload["features"]:
|
|
1050
|
+
print(f"{feat['feature']}:")
|
|
1051
|
+
for cand in feat["candidates"]:
|
|
1052
|
+
marks = []
|
|
1053
|
+
if cand["isCurrentBranch"]:
|
|
1054
|
+
marks.append("current branch")
|
|
1055
|
+
if cand["remoteTracking"]:
|
|
1056
|
+
marks.append("remote-tracking")
|
|
1057
|
+
if not cand["stateBranchMatches"] and cand["stateBranch"]:
|
|
1058
|
+
marks.append(f"state records branch {cand['stateBranch']}")
|
|
1059
|
+
suffix = f" ({'; '.join(marks)})" if marks else ""
|
|
1060
|
+
print(f" {cand['branch']}: stage={cand['currentStage'] or '?'} "
|
|
1061
|
+
f"status={cand['pipelineStatus']}{suffix}")
|
|
1062
|
+
if not cand["isCurrentBranch"]:
|
|
1063
|
+
print(f" switch: {cand['switchCommand']}")
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
# --------------------------------------------------------------------------- #
|
|
1067
|
+
# Branch reconciliation (Chunk 6) — imposed/session-branch drift
|
|
1068
|
+
# --------------------------------------------------------------------------- #
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _default_branch() -> str | None:
|
|
1072
|
+
"""The repo's default branch: origin/HEAD target, else `main`/`master` if present."""
|
|
1073
|
+
ref = _git_output(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"])
|
|
1074
|
+
if ref:
|
|
1075
|
+
return ref.rsplit("/", 1)[-1]
|
|
1076
|
+
for cand in ("main", "master"):
|
|
1077
|
+
if _git_output(["rev-parse", "--verify", "--quiet", f"refs/heads/{cand}"]) is not None:
|
|
1078
|
+
return cand
|
|
1079
|
+
return None
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def reconcile_branch(
|
|
1083
|
+
name: str, specs_dir: Path, config_path: Path, epic: str | None = None
|
|
1084
|
+
) -> dict:
|
|
1085
|
+
"""Decide whether a feature's recorded ``branch`` should adopt the current branch.
|
|
1086
|
+
|
|
1087
|
+
Read-only: it emits a decision; the caller performs any state write. A hosted
|
|
1088
|
+
environment (Claude.ai remote, cloud agents) imposes an arbitrary session branch
|
|
1089
|
+
that Branch Setup silently records; when the user moves to the intended branch the
|
|
1090
|
+
recorded ``branch`` goes stale and every branch-aware mechanism keys off it. This
|
|
1091
|
+
reconciler treats *where the state actually resolves* as the source of truth, with a
|
|
1092
|
+
default-branch guardrail so genuine drift-back-to-default is still surfaced, not
|
|
1093
|
+
silently adopted.
|
|
1094
|
+
"""
|
|
1095
|
+
if _git_output(["rev-parse", "--git-dir"]) is None:
|
|
1096
|
+
return {"feature": name, "gitRepo": False, "reconcile": False,
|
|
1097
|
+
"action": "none", "reason": "not a git repository"}
|
|
1098
|
+
current = _git_output(["branch", "--show-current"])
|
|
1099
|
+
default = _default_branch()
|
|
1100
|
+
config = _load_config(config_path)
|
|
1101
|
+
row = next(
|
|
1102
|
+
(r for r in build_rows(specs_dir, config)
|
|
1103
|
+
if r["name"] == name and (epic is None or r["epic"] == epic)),
|
|
1104
|
+
None,
|
|
1105
|
+
)
|
|
1106
|
+
state_path = None
|
|
1107
|
+
if row is not None:
|
|
1108
|
+
parent = specs_dir / row["epic"] / name if row["epic"] else specs_dir / name
|
|
1109
|
+
state_path = str(parent / PIPELINE_STATE_FILENAME)
|
|
1110
|
+
base = {
|
|
1111
|
+
"feature": name,
|
|
1112
|
+
"gitRepo": True,
|
|
1113
|
+
"currentBranch": current,
|
|
1114
|
+
"defaultBranch": default,
|
|
1115
|
+
"stateBranch": row["branch"] if row else None,
|
|
1116
|
+
"resolvesOnCurrentBranch": row is not None,
|
|
1117
|
+
"statePath": state_path,
|
|
1118
|
+
"newBranch": None,
|
|
1119
|
+
}
|
|
1120
|
+
if current is None:
|
|
1121
|
+
return {**base, "reconcile": False, "action": "none",
|
|
1122
|
+
"reason": "no current branch (detached HEAD or unborn branch)"}
|
|
1123
|
+
if row is None:
|
|
1124
|
+
return {**base, "reconcile": False, "action": "not-resolved",
|
|
1125
|
+
"reason": "feature state does not resolve on the current branch — "
|
|
1126
|
+
"use discover-feature to locate it"}
|
|
1127
|
+
state_branch = base["stateBranch"]
|
|
1128
|
+
if state_branch == current:
|
|
1129
|
+
return {**base, "reconcile": False, "action": "none",
|
|
1130
|
+
"reason": "recorded branch already matches the current branch"}
|
|
1131
|
+
if current == default:
|
|
1132
|
+
return {**base, "reconcile": False, "action": "warn-drift",
|
|
1133
|
+
"reason": f"on the default branch ({default}); recording it would commit "
|
|
1134
|
+
"here — create/switch to a topic branch instead of reconciling"}
|
|
1135
|
+
detail = (f"recorded branch {state_branch!r} differs from the current topic branch"
|
|
1136
|
+
if state_branch else "no branch recorded")
|
|
1137
|
+
return {**base, "reconcile": True, "action": "adopt-current", "newBranch": current,
|
|
1138
|
+
"reason": f"{detail}; the feature state resolves here, so adopt the current branch"}
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _print_reconcile(payload: dict) -> None:
|
|
1142
|
+
"""Human-readable reconcile-branch report."""
|
|
1143
|
+
if not payload["gitRepo"]:
|
|
1144
|
+
print(f"reconcile-branch {payload['feature']}: not a git repository")
|
|
1145
|
+
return
|
|
1146
|
+
print(f"reconcile-branch {payload['feature']}: {payload['action']} — {payload['reason']}")
|
|
1147
|
+
print(f" current={payload['currentBranch']} recorded={payload['stateBranch'] or '(none)'} "
|
|
1148
|
+
f"default={payload['defaultBranch']}")
|
|
1149
|
+
if payload["reconcile"]:
|
|
1150
|
+
print(f" → write state branch := {payload['newBranch']} ({payload['statePath']})")
|
|
1151
|
+
|
|
1152
|
+
|
|
940
1153
|
# --------------------------------------------------------------------------- #
|
|
941
1154
|
# Scripted Stage Exit
|
|
942
1155
|
# --------------------------------------------------------------------------- #
|
|
@@ -1045,8 +1258,8 @@ def _next_steps_block(next_command: str, host: str) -> str:
|
|
|
1045
1258
|
"I can't `/clear` for you — you have to run it yourself."
|
|
1046
1259
|
)
|
|
1047
1260
|
next_line = (
|
|
1048
|
-
|
|
1049
|
-
"`/feature-forge:forge` to let the navigator resume from disk."
|
|
1261
|
+
"2. Then start a fresh session and run the next stage below — or "
|
|
1262
|
+
"re-run `/feature-forge:forge` to let the navigator resume from disk."
|
|
1050
1263
|
)
|
|
1051
1264
|
else:
|
|
1052
1265
|
clear_line = (
|
|
@@ -1055,10 +1268,16 @@ def _next_steps_block(next_command: str, host: str) -> str:
|
|
|
1055
1268
|
"disk, so the work survives it."
|
|
1056
1269
|
)
|
|
1057
1270
|
next_line = (
|
|
1058
|
-
|
|
1059
|
-
"the forge navigator skill to resume from disk."
|
|
1271
|
+
"2. Then start a fresh session and run the next stage below — or "
|
|
1272
|
+
"re-run the forge navigator skill to resume from disk."
|
|
1060
1273
|
)
|
|
1061
|
-
|
|
1274
|
+
# The next-stage command goes in a fenced block so mobile/remote hosts get a
|
|
1275
|
+
# native copy button (inline code is not tap-to-copy). The fence sits before
|
|
1276
|
+
# the sentinel, so the sentinel remains the absolute last line.
|
|
1277
|
+
command_block = f"```\n{next_command}\n```"
|
|
1278
|
+
return "\n".join(
|
|
1279
|
+
["**Next steps**", clear_line, next_line, "", command_block, NEXT_STEPS_SENTINEL]
|
|
1280
|
+
)
|
|
1062
1281
|
|
|
1063
1282
|
|
|
1064
1283
|
def stage_exit(
|
|
@@ -1230,10 +1449,23 @@ def main() -> int:
|
|
|
1230
1449
|
p_disc = sub.add_parser(
|
|
1231
1450
|
"discover-feature", help="Find a feature's pipeline state across all branches"
|
|
1232
1451
|
)
|
|
1233
|
-
p_disc.add_argument("name",
|
|
1452
|
+
p_disc.add_argument("name", nargs="?", default=None,
|
|
1453
|
+
help="Feature name to discover (omit with --all)")
|
|
1454
|
+
p_disc.add_argument("--all", action="store_true", dest="discover_all",
|
|
1455
|
+
help="Discover every feature across all branches (empty-dashboard)")
|
|
1234
1456
|
p_disc.add_argument("--specs-dir", default="./specs", help="Specs directory")
|
|
1235
1457
|
p_disc.add_argument("--json", action="store_true", dest="json_output")
|
|
1236
1458
|
|
|
1459
|
+
p_recon = sub.add_parser(
|
|
1460
|
+
"reconcile-branch",
|
|
1461
|
+
help="Decide whether a feature's recorded branch should adopt the current branch",
|
|
1462
|
+
)
|
|
1463
|
+
p_recon.add_argument("--feature", required=True, help="Feature name")
|
|
1464
|
+
p_recon.add_argument("--specs-dir", default="./specs", help="Specs directory")
|
|
1465
|
+
p_recon.add_argument("--config", default="./forge.config.json", help="forge.config.json path")
|
|
1466
|
+
p_recon.add_argument("--epic", default=None, help="Epic name for a nested member")
|
|
1467
|
+
p_recon.add_argument("--json", action="store_true", dest="json_output")
|
|
1468
|
+
|
|
1237
1469
|
p_exit = sub.add_parser(
|
|
1238
1470
|
"stage-exit", help="Emit the Scripted Stage Exit directives + NEXT-STEPS block"
|
|
1239
1471
|
)
|
|
@@ -1290,11 +1522,28 @@ def main() -> int:
|
|
|
1290
1522
|
return 0
|
|
1291
1523
|
|
|
1292
1524
|
if args.cmd == "discover-feature":
|
|
1293
|
-
|
|
1525
|
+
if args.discover_all:
|
|
1526
|
+
payload = discover_all(args.specs_dir)
|
|
1527
|
+
printer = _print_discover_all
|
|
1528
|
+
elif args.name:
|
|
1529
|
+
payload = discover_feature(args.name, args.specs_dir)
|
|
1530
|
+
printer = _print_discover
|
|
1531
|
+
else:
|
|
1532
|
+
parser.error("discover-feature requires a NAME or --all")
|
|
1533
|
+
if args.json_output:
|
|
1534
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1535
|
+
else:
|
|
1536
|
+
printer(payload)
|
|
1537
|
+
return 0
|
|
1538
|
+
|
|
1539
|
+
if args.cmd == "reconcile-branch":
|
|
1540
|
+
payload = reconcile_branch(
|
|
1541
|
+
args.feature, Path(args.specs_dir), Path(args.config), args.epic
|
|
1542
|
+
)
|
|
1294
1543
|
if args.json_output:
|
|
1295
1544
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1296
1545
|
else:
|
|
1297
|
-
|
|
1546
|
+
_print_reconcile(payload)
|
|
1298
1547
|
return 0
|
|
1299
1548
|
|
|
1300
1549
|
if args.cmd == "stage-exit":
|