From 8e7012fbb1bdc0f0fe75161b0f3443209f9e1f0b Mon Sep 17 00:00:00 2001 From: Cristian Uibar Date: Fri, 2 Oct 2026 17:54:49 +0300 Subject: [PATCH] Fix package-level dispatch resource defaults --- scripts/dispatch_driver.py | 6 +- .../gsd-path-build/references/build-native.md | 8 ++ .../gsd-path-build/scripts/dispatch_driver.py | 6 +- skills/gsd-path/references/build-native.md | 8 ++ skills/gsd-path/scripts/dispatch_driver.py | 6 +- skills/path/references/build-native.md | 8 ++ skills/path/scripts/dispatch_driver.py | 6 +- tests/test_dispatch_driver.py | 92 +++++++++++++++---- 8 files changed, 116 insertions(+), 24 deletions(-) diff --git a/scripts/dispatch_driver.py b/scripts/dispatch_driver.py index 010358d9..991001c1 100644 --- a/scripts/dispatch_driver.py +++ b/scripts/dispatch_driver.py @@ -2286,7 +2286,11 @@ def positive_int(value: str) -> int: def default_resource(name: str) -> Optional[Path]: - candidate = Path(__file__).resolve().parent.parent / name + script_root = Path(__file__).resolve().parent.parent + candidate = script_root / name + if candidate.is_file(): + return candidate + candidate = script_root / "skills" / "gsd-path" / name return candidate if candidate.is_file() else None diff --git a/skills/gsd-path-build/references/build-native.md b/skills/gsd-path-build/references/build-native.md index 9a59a556..47df4ace 100644 --- a/skills/gsd-path-build/references/build-native.md +++ b/skills/gsd-path-build/references/build-native.md @@ -24,6 +24,14 @@ python3 round --repo --wave [--child-timeout ] ``` +Use the absolute `dispatch_driver.py` from the active installed +`gsd-path-build` skill when orchestrating a consuming project's build; that +copy resolves role briefs and templates from its own skill bundle. The +repository or npm package also supports the top-level `scripts/dispatch_driver.py`: +when a default is not beside that script, it resolves from the shipped +`skills/gsd-path` bundle. Explicit `--role-brief`, `--task-template`, and +`--template` paths override those defaults and must name existing files. + `round` owns the entry transition described in Preconditions and branch binding, checkpointed as `build: start milestone`, and sets `build/blocked` on a dependency deadlock or a blocked diff --git a/skills/gsd-path-build/scripts/dispatch_driver.py b/skills/gsd-path-build/scripts/dispatch_driver.py index 010358d9..991001c1 100644 --- a/skills/gsd-path-build/scripts/dispatch_driver.py +++ b/skills/gsd-path-build/scripts/dispatch_driver.py @@ -2286,7 +2286,11 @@ def positive_int(value: str) -> int: def default_resource(name: str) -> Optional[Path]: - candidate = Path(__file__).resolve().parent.parent / name + script_root = Path(__file__).resolve().parent.parent + candidate = script_root / name + if candidate.is_file(): + return candidate + candidate = script_root / "skills" / "gsd-path" / name return candidate if candidate.is_file() else None diff --git a/skills/gsd-path/references/build-native.md b/skills/gsd-path/references/build-native.md index 9a59a556..47df4ace 100644 --- a/skills/gsd-path/references/build-native.md +++ b/skills/gsd-path/references/build-native.md @@ -24,6 +24,14 @@ python3 round --repo --wave [--child-timeout ] ``` +Use the absolute `dispatch_driver.py` from the active installed +`gsd-path-build` skill when orchestrating a consuming project's build; that +copy resolves role briefs and templates from its own skill bundle. The +repository or npm package also supports the top-level `scripts/dispatch_driver.py`: +when a default is not beside that script, it resolves from the shipped +`skills/gsd-path` bundle. Explicit `--role-brief`, `--task-template`, and +`--template` paths override those defaults and must name existing files. + `round` owns the entry transition described in Preconditions and branch binding, checkpointed as `build: start milestone`, and sets `build/blocked` on a dependency deadlock or a blocked diff --git a/skills/gsd-path/scripts/dispatch_driver.py b/skills/gsd-path/scripts/dispatch_driver.py index 010358d9..991001c1 100644 --- a/skills/gsd-path/scripts/dispatch_driver.py +++ b/skills/gsd-path/scripts/dispatch_driver.py @@ -2286,7 +2286,11 @@ def positive_int(value: str) -> int: def default_resource(name: str) -> Optional[Path]: - candidate = Path(__file__).resolve().parent.parent / name + script_root = Path(__file__).resolve().parent.parent + candidate = script_root / name + if candidate.is_file(): + return candidate + candidate = script_root / "skills" / "gsd-path" / name return candidate if candidate.is_file() else None diff --git a/skills/path/references/build-native.md b/skills/path/references/build-native.md index 9a59a556..47df4ace 100644 --- a/skills/path/references/build-native.md +++ b/skills/path/references/build-native.md @@ -24,6 +24,14 @@ python3 round --repo --wave [--child-timeout ] ``` +Use the absolute `dispatch_driver.py` from the active installed +`gsd-path-build` skill when orchestrating a consuming project's build; that +copy resolves role briefs and templates from its own skill bundle. The +repository or npm package also supports the top-level `scripts/dispatch_driver.py`: +when a default is not beside that script, it resolves from the shipped +`skills/gsd-path` bundle. Explicit `--role-brief`, `--task-template`, and +`--template` paths override those defaults and must name existing files. + `round` owns the entry transition described in Preconditions and branch binding, checkpointed as `build: start milestone`, and sets `build/blocked` on a dependency deadlock or a blocked diff --git a/skills/path/scripts/dispatch_driver.py b/skills/path/scripts/dispatch_driver.py index 010358d9..991001c1 100644 --- a/skills/path/scripts/dispatch_driver.py +++ b/skills/path/scripts/dispatch_driver.py @@ -2286,7 +2286,11 @@ def positive_int(value: str) -> int: def default_resource(name: str) -> Optional[Path]: - candidate = Path(__file__).resolve().parent.parent / name + script_root = Path(__file__).resolve().parent.parent + candidate = script_root / name + if candidate.is_file(): + return candidate + candidate = script_root / "skills" / "gsd-path" / name return candidate if candidate.is_file() else None diff --git a/tests/test_dispatch_driver.py b/tests/test_dispatch_driver.py index f69cafb1..f87b5342 100644 --- a/tests/test_dispatch_driver.py +++ b/tests/test_dispatch_driver.py @@ -12,7 +12,7 @@ import textwrap import time import unittest -from contextlib import ExitStack, contextmanager, redirect_stdout +from contextlib import ExitStack, contextmanager, redirect_stderr, redirect_stdout from pathlib import Path from typing import Iterator from unittest import mock @@ -163,6 +163,50 @@ def setUp(self) -> None: self.root = Path(temporary.name) / "repo" self.root.mkdir() + def test_default_resource_uses_packaged_bundle_and_installed_skill_resources(self) -> None: + names = ( + "references/coder.md", + "references/reviewer.md", + "templates/task.md", + "templates/wave-review.md", + "templates/wave-panel.md", + "templates/skeptic.md", + ) + for name in names: + self.assertEqual(dispatch_driver.default_resource(name), + (PROJECT_ROOT / "skills/gsd-path" / name).resolve()) + + installed_driver = PROJECT_ROOT / "skills/gsd-path-build/scripts/dispatch_driver.py" + with mock.patch.object(dispatch_driver, "__file__", str(installed_driver)): + for name in names: + self.assertEqual(dispatch_driver.default_resource(name), + (installed_driver.parent.parent / name).resolve()) + + package = self.root / "package" + package_driver = package / "scripts/dispatch_driver.py" + package_driver.parent.mkdir(parents=True) + package_driver.touch() + canonical = package / "skills/gsd-path" + coder = canonical / "references/coder.md" + coder.parent.mkdir(parents=True) + coder.touch() + with mock.patch.object(dispatch_driver, "__file__", str(package_driver)): + self.assertEqual(dispatch_driver.default_resource("references/coder.md"), coder.resolve()) + self.assertIsNone(dispatch_driver.default_resource("references/reviewer.md")) + + def test_require_files_preserves_explicit_override_and_rejects_missing_path(self) -> None: + custom = self.root / "custom.md" + custom.write_text("custom resource\n", encoding="utf-8") + parser = argparse.ArgumentParser() + arguments = argparse.Namespace(role_brief=custom) + dispatch_driver.require_files(parser, arguments, "role_brief") + self.assertEqual(arguments.role_brief, custom.resolve()) + + missing = self.root / "missing.md" + arguments = argparse.Namespace(role_brief=missing) + with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + dispatch_driver.require_files(parser, arguments, "role_brief") + def head(self, root: Path) -> str: return run_git(root, "rev-parse", "HEAD").stdout.strip() @@ -229,42 +273,50 @@ def driver(self, root: Path, *args: str, mode: str = "ready", **fake: str) -> di return json.loads(completed.stdout) def round(self, root: Path, *extra: str, mode: str = "ready", wave: int = 1, - **fake: str) -> dict: + default_resources: bool = False, **fake: str) -> dict: + resources = ([] if default_resources else + ["--role-brief", str(ROLE_BRIEF), "--task-template", str(TASK_TEMPLATE)]) return self.driver( root, "round", "--wave", str(wave), "--child-command", f"{sys.executable} {root / 'fake_coder.py'}", - "--role-brief", str(ROLE_BRIEF), "--task-template", str(TASK_TEMPLATE), *extra, + *resources, *extra, mode=mode, **fake, ) - def review(self, root: Path, *extra: str, verdict: str = "pass", wave: int = 1, cycle: int = 1) -> dict: + def review(self, root: Path, *extra: str, verdict: str = "pass", wave: int = 1, cycle: int = 1, + default_resources: bool = False) -> dict: env = dict(os.environ, FAKE_REVIEW=verdict) + resources = ([] if default_resources else + ["--role-brief", str(PROJECT_ROOT / "skills/gsd-path-build/references/reviewer.md"), + "--template", str(PROJECT_ROOT / "skills/gsd-path-build/templates/wave-review.md")]) completed = subprocess.run( [sys.executable, "-B", str(SCRIPT), "review", "--wave", str(wave), "--cycle", str(cycle), "--child-command", f"{sys.executable} {root / 'fake_reviewer.py'}", - "--role-brief", str(PROJECT_ROOT / "skills/gsd-path-build/references/reviewer.md"), - "--template", str(PROJECT_ROOT / "skills/gsd-path-build/templates/wave-review.md"), - *extra, "--repo", str(root)], capture_output=True, encoding="utf-8", errors="replace", env=env) + *resources, *extra, "--repo", str(root)], capture_output=True, encoding="utf-8", errors="replace", env=env) self.assertTrue(completed.stdout.strip(), completed.stderr) return json.loads(completed.stdout) - def panel(self, root: Path, *extra: str, advertised: str = "gpt-6-astra,claude-opus") -> dict: + def panel(self, root: Path, *extra: str, advertised: str = "gpt-6-astra,claude-opus", + default_resources: bool = False) -> dict: + resources = ([] if default_resources else + ["--role-brief", str(PROJECT_ROOT / "skills/gsd-path-build/references/reviewer.md"), + "--template", str(PROJECT_ROOT / "skills/gsd-path-build/templates/wave-panel.md")]) completed = subprocess.run( [sys.executable, "-B", str(SCRIPT), "panel", "--wave", "1", "--cycle", "1", "--child-command", f"{sys.executable} {root / 'fake_panelist.py'} {{model}}", "--advertised", advertised, "--parent-slug", "claude-opus", - "--role-brief", str(PROJECT_ROOT / "skills/gsd-path-build/references/reviewer.md"), - "--template", str(PROJECT_ROOT / "skills/gsd-path-build/templates/wave-panel.md"), - *extra, "--repo", str(root)], capture_output=True, encoding="utf-8", errors="replace") + *resources, *extra, "--repo", str(root)], capture_output=True, encoding="utf-8", errors="replace") self.assertTrue(completed.stdout.strip(), completed.stderr) return json.loads(completed.stdout) - def skeptics(self, root: Path, *extra: str, verdict: str = "stands") -> dict: + def skeptics(self, root: Path, *extra: str, verdict: str = "stands", + default_resources: bool = False) -> dict: + resources = ([] if default_resources else + ["--role-brief", str(PROJECT_ROOT / "skills/gsd-path-build/references/reviewer.md"), + "--template", str(PROJECT_ROOT / "skills/gsd-path-build/templates/skeptic.md")]) return self.driver( root, "skeptics", "--wave", "1", "--cycle", "1", "--child-command", f"{sys.executable} {root / 'fake_skeptic.py'}", - "--role-brief", str(PROJECT_ROOT / "skills/gsd-path-build/references/reviewer.md"), - "--template", str(PROJECT_ROOT / "skills/gsd-path-build/templates/skeptic.md"), - *extra, FAKE_SKEPTIC=verdict) + *resources, *extra, FAKE_SKEPTIC=verdict) def set_deep_review_with_skeptics(self, root: Path) -> None: plan = root / ".project/plan/PLAN.md" @@ -1142,7 +1194,7 @@ def test_review_full_collects_validates_and_checkpoints_a_pass(self) -> None: self.fixture(root) self.assertEqual(self.round(root, "--wait", "60")["status"], "done") head = self.head(root) - receipt = self.review(root, "--wait", "60") + receipt = self.review(root, "--wait", "60", default_resources=True) self.assertEqual(receipt["status"], "pass", receipt) self.assertEqual(receipt["depth"], "full") self.assertEqual(receipt["base"], head) @@ -1153,7 +1205,7 @@ def test_review_full_collects_validates_and_checkpoints_a_pass(self) -> None: self.assertEqual(self.subjects(root)[0], "build: record wave 1 cycle 1 review") self.assertEqual(run_git(root, "status", "--porcelain").stdout, "") self.assertEqual(self.branches(root), ["gsd-path/M001"]) - again = self.review(root, "--wait", "60") + again = self.review(root, "--wait", "60", default_resources=True) self.assertEqual(again["status"], "pass", again) self.assertIsNone(again["checkpoint"]) @@ -1602,7 +1654,7 @@ def test_skeptics_run_once_per_locator_and_fix_tasks_batches_what_stands(self) - self.assertTrue(locators, review["findings"]) self.assertEqual(self.driver(root, "fix-tasks", "--wave", "1", "--cycle", "1")["escalation"], ["skeptic_groups"]) - receipt = self.skeptics(root, "--wait", "60") + receipt = self.skeptics(root, "--wait", "60", default_resources=True) self.assertEqual(receipt["status"], "done", receipt) self.assertEqual({key: item["verdict"] for key, item in receipt["skeptics"].items()}, {locator: "stands" for locator in locators}) @@ -1649,7 +1701,7 @@ def state(self, root: Path) -> str: def test_round_enters_build_from_plan_done_and_checkpoints_the_transition(self) -> None: root = self.root self.fixture(root, state=("plan", "done")) - receipt = self.round(root, "--wait", "60") + receipt = self.round(root, "--wait", "60", default_resources=True) self.assertEqual(receipt["status"], "done", receipt) self.assertEqual(self.state(root), "build/active") self.assertIn("build: start milestone", self.subjects(root)) @@ -1821,7 +1873,7 @@ def test_panel_named_family_runs_merges_and_checkpoints_with_the_review(self) -> self.assertEqual(review["status"], "pass", review) self.assertTrue(review["panel_required"]) self.assertIsNone(review.get("checkpoint")) - receipt = self.panel(root, "--wait", "60") + receipt = self.panel(root, "--wait", "60", default_resources=True) self.assertEqual(receipt["status"], "pass", receipt) self.assertEqual(receipt["families"]["gpt"]["slug"], "gpt-6-astra") self.assertTrue((root / ".project/review/wave-1.cycle1.panel.gpt.md").is_file())