Skip to content

Commit 850430e

Browse files
Yash-Chindamclaude
andcommitted
fix(github): restore core parity for override persistence and the plan.md gate
Core resolves through check-prerequisites --require-tasks --include-tasks, which persists a SPECIFY_FEATURE_DIRECTORY override into feature.json and requires plan.md before tasks.md. The vendored resolvers did neither, so a later run reverted to the previously stored feature and the command ran on features where core stops. - Persist the override in all three twins, mirroring core's helpers (relative value, write only on change) - Require plan.md, with core's error wording; spec.md stays optional, as core passes no --require-spec here - Parity tests per runtime: the two-run override sequence, plan.md rejection, and spec.md remaining optional - README: correct the resolution and parity claims Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assisted-by: Claude Code (model: Claude Opus 5, autonomous)
1 parent 2afe3c9 commit 850430e

5 files changed

Lines changed: 209 additions & 16 deletions

File tree

‎extensions/github/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ The extension ships its own feature-resolution script in all three supported run
6262
| PowerShell | `scripts/powershell/resolve-tasks.ps1` |
6363
| Python | `scripts/python/resolve_tasks.py` |
6464

65-
Once installed they live under `.specify/extensions/github/scripts/`. The script is a trimmed twin of core's `check-prerequisites`: it resolves the project root and active feature directory, requires `tasks.md`, and reports the design docs alongside it. It performs none of core's `plan.md`/`spec.md` gating, and it never writes `.specify/feature.json`.
65+
Once installed they live under `.specify/extensions/github/scripts/`. The script is a trimmed twin of core's `check-prerequisites`: it resolves the project root and active feature directory, requires `plan.md` and `tasks.md`, and reports the design docs alongside them. It matches the invocation the core command makes (`--require-tasks --include-tasks`), so `spec.md` stays optional, and a `SPECIFY_FEATURE_DIRECTORY` override is persisted to `.specify/feature.json` the same way core persists it — a later run without the variable resolves to the same feature.
6666

6767
## Migrating from the core `taskstoissues` command
6868

@@ -82,4 +82,4 @@ specify extension add github
8282
| ------------------------- | --------------------------------- |
8383
| `/speckit.taskstoissues` | `/speckit.github.taskstoissues` |
8484

85-
Issue creation is unchanged: the same remote validation, the same deduplication across open and closed issues, the same issue titles, and the same hook contract. Feature resolution differs in one way: this extension does not require `plan.md` (see [Scripts](#scripts)), so it can run on a feature where the core command would stop. This extension does **not** register `speckit.taskstoissues` as an alias, so the two commands coexist without shadowing each other while the core command still exists.
85+
Behavior is unchanged: the same feature resolution, the same `plan.md` and `tasks.md` prerequisites, the same remote validation, the same deduplication across open and closed issues, the same issue titles, and the same hook contract. This extension does **not** register `speckit.taskstoissues` as an alias, so the two commands coexist without shadowing each other while the core command still exists.

‎extensions/github/scripts/bash/resolve-tasks.sh‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
# `speckit.github.taskstoissues` keeps working when the core `taskstoissues`
77
# command (and its `check-prerequisites` helper invocation) is deprecated and
88
# removed. It is a trimmed twin of core `check-prerequisites.sh` — it resolves
9-
# the project root and the active feature directory, requires tasks.md, and
10-
# reports the optional design docs that sit next to it. It performs none of
11-
# core's plan.md/spec.md gating and never writes .specify/feature.json.
9+
# the project root and the active feature directory, requires plan.md and
10+
# tasks.md exactly as core's `--require-tasks --include-tasks` invocation does,
11+
# and reports the optional design docs that sit next to them. Core does not
12+
# require spec.md for this command, so neither does this script.
1213
#
1314
# Usage: ./resolve-tasks.sh [--json]
1415
#
@@ -167,12 +168,43 @@ read_feature_json_feature_directory() {
167168

168169
REPO_ROOT=$(get_repo_root) || exit 1
169170

171+
# Persist a feature_directory value to .specify/feature.json.
172+
# Writes only when the file is missing or the stored value differs.
173+
# Mirrors core's _persist_feature_json (scripts/bash/common.sh).
174+
persist_feature_json() {
175+
local repo_root="$1"
176+
local feature_dir_value="$2"
177+
local fj="$repo_root/.specify/feature.json"
178+
179+
# Strip repo_root prefix if the value is absolute and under repo_root.
180+
if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
181+
feature_dir_value="${feature_dir_value#"$repo_root/"}"
182+
fi
183+
184+
local current_val
185+
current_val=$(read_feature_json_feature_directory "$repo_root")
186+
if [[ "$current_val" == "$feature_dir_value" ]]; then
187+
return 0
188+
fi
189+
190+
mkdir -p "$repo_root/.specify"
191+
192+
if command -v jq >/dev/null 2>&1; then
193+
jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
194+
else
195+
printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
196+
fi
197+
}
198+
170199
# Resolve the feature directory. Priority:
171200
# 1. SPECIFY_FEATURE_DIRECTORY (explicit override)
172201
# 2. .specify/feature.json "feature_directory"
173-
# Read-only by design: unlike core, this never persists feature.json (#3025).
202+
# An override is persisted, exactly as core does, so a later run without the
203+
# variable resolves to the same feature rather than reverting to the previous
204+
# one and creating issues from the wrong task list.
174205
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
175206
FEATURE_DIR="$SPECIFY_FEATURE_DIRECTORY"
207+
persist_feature_json "$REPO_ROOT" "$SPECIFY_FEATURE_DIRECTORY"
176208
else
177209
FEATURE_DIR=$(read_feature_json_feature_directory "$REPO_ROOT")
178210
if [[ -z "$FEATURE_DIR" ]]; then
@@ -188,6 +220,13 @@ if [[ ! -d "$FEATURE_DIR" ]]; then
188220
exit 1
189221
fi
190222

223+
IMPL_PLAN="$FEATURE_DIR/plan.md"
224+
if [[ ! -f "$IMPL_PLAN" ]]; then
225+
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
226+
echo "Run the Spec Kit plan command (e.g. /speckit.plan) first to create the implementation plan." >&2
227+
exit 1
228+
fi
229+
191230
TASKS="$FEATURE_DIR/tasks.md"
192231
if [[ ! -f "$TASKS" ]]; then
193232
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2

‎extensions/github/scripts/powershell/resolve-tasks.ps1‎

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
# speckit.github.taskstoissues keeps working when the core taskstoissues
77
# command (and its check-prerequisites helper invocation) is deprecated and
88
# removed. It is a trimmed twin of core check-prerequisites.ps1 -- it resolves
9-
# the project root and the active feature directory, requires tasks.md, and
10-
# reports the optional design docs that sit next to it. It performs none of
11-
# core's plan.md/spec.md gating and never writes .specify/feature.json.
9+
# the project root and the active feature directory, requires plan.md and
10+
# tasks.md exactly as core's -RequireTasks -IncludeTasks invocation does, and
11+
# reports the optional design docs that sit next to them. Core does not require
12+
# spec.md for this command, so neither does this script.
1213
#
1314
# Usage: ./resolve-tasks.ps1 [-Json]
1415
#
@@ -102,15 +103,64 @@ function Get-ProjectRoot {
102103
exit 1
103104
}
104105

106+
# Persist a feature_directory value to .specify/feature.json.
107+
# Writes only when the file is missing or the stored value differs.
108+
# Mirrors core's Save-FeatureJson (scripts/powershell/common.ps1).
109+
function Save-FeatureJson {
110+
param(
111+
[Parameter(Mandatory = $true)][string]$RepoRoot,
112+
[Parameter(Mandatory = $true)][string]$FeatureDirectory
113+
)
114+
115+
# Strip the repo root prefix when the value is absolute and under it.
116+
# Case-insensitive on Windows only; elsewhere the filesystem is case-sensitive.
117+
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
118+
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
119+
if ($onWin) {
120+
$cmp = [System.StringComparison]::OrdinalIgnoreCase
121+
} else {
122+
$cmp = [System.StringComparison]::Ordinal
123+
}
124+
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
125+
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
126+
}
127+
128+
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
129+
130+
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
131+
try {
132+
$raw = [System.IO.File]::ReadAllText($fjPath, [System.Text.Encoding]::UTF8)
133+
$cfg = $raw | ConvertFrom-Json
134+
if ($cfg.feature_directory -eq $FeatureDirectory) {
135+
return
136+
}
137+
} catch {
138+
# File is corrupt or unreadable - overwrite it.
139+
}
140+
}
141+
142+
$specifyDir = Join-Path $RepoRoot '.specify'
143+
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
144+
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
145+
}
146+
147+
$json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
148+
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
149+
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
150+
}
151+
105152
$repoRoot = Get-ProjectRoot
106153

107154
# Resolve the feature directory. Priority:
108155
# 1. SPECIFY_FEATURE_DIRECTORY (explicit override)
109156
# 2. .specify/feature.json "feature_directory"
110-
# Read-only by design: unlike core, this never persists feature.json (#3025).
157+
# An override is persisted, exactly as core does, so a later run without the
158+
# variable resolves to the same feature rather than reverting to the previous
159+
# one and creating issues from the wrong task list.
111160
$featureJson = Join-Path $repoRoot '.specify/feature.json'
112161
if ($env:SPECIFY_FEATURE_DIRECTORY) {
113162
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
163+
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
114164
} elseif (Test-Path -LiteralPath $featureJson -PathType Leaf) {
115165
# Read as UTF-8 explicitly: Windows PowerShell 5.1 otherwise decodes with
116166
# the legacy ANSI code page and mangles non-ASCII feature paths (#4359).
@@ -142,6 +192,13 @@ if (-not (Test-Path -LiteralPath $featureDir -PathType Container)) {
142192
exit 1
143193
}
144194

195+
$implPlan = Join-Path $featureDir 'plan.md'
196+
if (-not (Test-Path -LiteralPath $implPlan -PathType Leaf)) {
197+
[Console]::Error.WriteLine("ERROR: plan.md not found in $featureDir")
198+
[Console]::Error.WriteLine("Run the Spec Kit plan command (e.g. /speckit.plan) first to create the implementation plan.")
199+
exit 1
200+
}
201+
145202
$tasks = Join-Path $featureDir 'tasks.md'
146203
if (-not (Test-Path -LiteralPath $tasks -PathType Leaf)) {
147204
[Console]::Error.WriteLine("ERROR: tasks.md not found in $featureDir")

‎extensions/github/scripts/python/resolve_tasks.py‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
``speckit.github.taskstoissues`` keeps working when the core ``taskstoissues``
66
command (and its ``check_prerequisites`` helper invocation) is deprecated and
77
removed. It is a trimmed twin of core ``check_prerequisites.py`` -- it resolves
8-
the project root and the active feature directory, requires ``tasks.md``, and
9-
reports the optional design docs that sit next to it. It performs none of
10-
core's ``plan.md``/``spec.md`` gating and never writes ``.specify/feature.json``.
8+
the project root and the active feature directory, requires ``plan.md`` and
9+
``tasks.md`` exactly as core's ``--require-tasks --include-tasks`` invocation
10+
does, and reports the optional design docs that sit next to them. Core does not
11+
require ``spec.md`` for this command, so neither does this script.
1112
"""
1213

1314
from __future__ import annotations
@@ -120,6 +121,34 @@ def read_feature_json_feature_directory(repo_root: Path) -> str:
120121
return value if isinstance(value, str) else ""
121122

122123

124+
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
125+
"""Write feature_directory to .specify/feature.json when it changed.
126+
127+
Mirrors core's persist_feature_json, including the lexical prefix strip:
128+
with a symlinked <repo>/specs, resolve() would escape the repo and persist
129+
a machine-specific absolute path rather than the relative "specs/NNN-name"
130+
the bash and PowerShell twins store.
131+
"""
132+
value = feature_dir_value
133+
relative = Path(value)
134+
if relative.is_absolute():
135+
try:
136+
value = relative.relative_to(repo_root).as_posix()
137+
except ValueError:
138+
value = str(relative)
139+
140+
if read_feature_json_feature_directory(repo_root) == value:
141+
return
142+
143+
specify_dir = repo_root / ".specify"
144+
specify_dir.mkdir(parents=True, exist_ok=True)
145+
(specify_dir / "feature.json").write_bytes(
146+
(json.dumps({"feature_directory": value}, separators=(",", ":")) + "\n").encode(
147+
"utf-8"
148+
)
149+
)
150+
151+
123152
def main(argv: list[str]) -> int:
124153
json_mode = False
125154
for arg in argv:
@@ -140,8 +169,12 @@ def main(argv: list[str]) -> int:
140169
# Resolve the feature directory. Priority:
141170
# 1. SPECIFY_FEATURE_DIRECTORY (explicit override)
142171
# 2. .specify/feature.json "feature_directory"
143-
# Read-only by design: unlike core, this never persists feature.json (#3025).
172+
# An override is persisted, exactly as core does, so a later run without
173+
# the variable resolves to the same feature rather than reverting to the
174+
# previous one and creating issues from the wrong task list.
144175
raw_feature_dir = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
176+
if raw_feature_dir:
177+
persist_feature_json(repo_root, raw_feature_dir)
145178
if not raw_feature_dir:
146179
raw_feature_dir = read_feature_json_feature_directory(repo_root)
147180
if not raw_feature_dir:
@@ -161,6 +194,14 @@ def main(argv: list[str]) -> int:
161194
"create the feature structure.",
162195
)
163196

197+
impl_plan = feature_dir / "plan.md"
198+
if not impl_plan.is_file():
199+
_die(
200+
f"ERROR: plan.md not found in {feature_dir}",
201+
"Run the Spec Kit plan command (e.g. /speckit.plan) first to "
202+
"create the implementation plan.",
203+
)
204+
164205
tasks = feature_dir / "tasks.md"
165206
if not tasks.is_file():
166207
_die(

‎tests/extensions/github/test_github_extension.py‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,7 @@ def _make_feature_project(tmp_path: Path) -> Path:
538538
(project / ".specify" / "feature.json").write_text(
539539
json.dumps({"feature_directory": "specs/001-demo"}), encoding="utf-8"
540540
)
541+
(feature / "plan.md").write_text("# Plan\n", encoding="utf-8")
541542
(feature / "tasks.md").write_text(
542543
"- [ ] T001 Create project structure\n", encoding="utf-8"
543544
)
@@ -611,6 +612,7 @@ def test_json_mode_survives_a_cp1252_stdout_with_a_non_ascii_path(
611612
(project / ".specify" / "feature.json").write_text(
612613
json.dumps({"feature_directory": "specs/001-功能"}), encoding="utf-8"
613614
)
615+
(feature / "plan.md").write_text("# Plan\n", encoding="utf-8")
614616
(feature / "tasks.md").write_text("- [ ] T001 Task\n", encoding="utf-8")
615617

616618
env = {**os.environ, "PYTHONIOENCODING": "cp1252"}
@@ -648,8 +650,8 @@ def test_text_mode_uses_the_glyph_when_stdout_can_encode_it(
648650
assert result.returncode == 0, result.stderr
649651
assert "✓ tasks.md" in result.stdout
650652

651-
def test_does_not_write_feature_json(self, tmp_path: Path):
652-
"""Resolution is read-only; it must not dirty the working tree."""
653+
def test_does_not_write_feature_json_without_an_override(self, tmp_path: Path):
654+
"""Plain resolution stays read-only; only an override persists."""
653655
project = _make_feature_project(tmp_path)
654656
feature_json = project / ".specify" / "feature.json"
655657
before = feature_json.read_bytes()
@@ -896,6 +898,7 @@ def _run_twin(
896898
def _add_feature(project: Path, name: str) -> Path:
897899
feature = project / "specs" / name
898900
feature.mkdir(parents=True)
901+
(feature / "plan.md").write_text("# Plan\n", encoding="utf-8")
899902
(feature / "tasks.md").write_text("- [ ] T001 Task\n", encoding="utf-8")
900903
return feature
901904

@@ -1004,3 +1007,56 @@ def test_init_dir_without_specify_fails(self, tmp_path: Path, twin: str):
10041007
assert result.returncode == 1
10051008
assert "SPECIFY_INIT_DIR is not a Spec Kit project" in result.stderr
10061009
assert result.stdout.strip() == ""
1010+
1011+
1012+
@pytest.mark.parametrize("twin", _TWINS)
1013+
class TestCoreResolutionParity:
1014+
"""Behaviours core's ``check-prerequisites`` has that this twin must keep.
1015+
1016+
Core resolves via ``check-prerequisites --require-tasks --include-tasks``,
1017+
which persists an override into ``feature.json`` and gates on ``plan.md``
1018+
before ``tasks.md``. Divergence here is not cosmetic: a lost override makes
1019+
the next run read a different feature's task list, and a missing plan gate
1020+
lets the command run where core would stop.
1021+
"""
1022+
1023+
def test_override_persists_for_the_next_run(self, tmp_path: Path, twin: str):
1024+
"""feature.json points at A, an override selects B, then B sticks."""
1025+
project = _make_feature_project(tmp_path)
1026+
_add_feature(project, "002-override")
1027+
feature_json = project / ".specify" / "feature.json"
1028+
1029+
first = _run_twin(
1030+
twin, project, SPECIFY_FEATURE_DIRECTORY="specs/002-override"
1031+
)
1032+
assert first.returncode == 0, first.stderr
1033+
assert Path(json.loads(first.stdout)["FEATURE_DIR"]).name == "002-override"
1034+
1035+
# Persisted as the relative path the other twins store, not absolute.
1036+
stored = json.loads(feature_json.read_text(encoding="utf-8"))
1037+
assert stored["feature_directory"] == "specs/002-override"
1038+
1039+
# Second run, no override: core selects B again, so this must too.
1040+
second = _run_twin(twin, project)
1041+
assert second.returncode == 0, second.stderr
1042+
assert Path(json.loads(second.stdout)["FEATURE_DIR"]).name == "002-override"
1043+
1044+
def test_missing_plan_md_is_rejected(self, tmp_path: Path, twin: str):
1045+
"""Core stops without plan.md even when tasks.md is present."""
1046+
project = _make_feature_project(tmp_path)
1047+
(project / "specs" / "001-demo" / "plan.md").unlink()
1048+
1049+
result = _run_twin(twin, project)
1050+
1051+
assert result.returncode == 1
1052+
assert "plan.md not found" in result.stderr
1053+
assert result.stdout.strip() == ""
1054+
1055+
def test_spec_md_is_not_required(self, tmp_path: Path, twin: str):
1056+
"""Core passes no --require-spec here, so spec.md stays optional."""
1057+
project = _make_feature_project(tmp_path)
1058+
assert not (project / "specs" / "001-demo" / "spec.md").exists()
1059+
1060+
result = _run_twin(twin, project)
1061+
1062+
assert result.returncode == 0, result.stderr

0 commit comments

Comments
 (0)