From 68a30533369d77829c6d29bc66aa229ff3e62236 Mon Sep 17 00:00:00 2001 From: Gandy2025 Date: Mon, 10 Aug 2026 16:12:53 +0800 Subject: [PATCH] fix: harden work graph input validation --- CHANGELOG.md | 7 + README.md | 2 +- README.zh-CN.md | 4 +- .../references/work-graph-contract.md | 19 +- .../scripts/build_local_work_graph.py | 4 + .../scripts/validate_work_graph.py | 14 +- tests/test_work_graph_scripts.py | 190 ++++++++++++------ 7 files changed, 162 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ebb289..490b1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ patch version. - Pinned `skills@1.5.22` distribution discovery plus a separate `skills@latest` forward-compatibility smoke. +### Fixed + +- Reject non-integer manifest schema versions and blank item titles or source + references. +- Fail closed when a local ticket omits its `Blocked by` declaration; tickets + without blockers must state `None` explicitly. + ### Boundaries - The public release, anonymous-install verification, clean-home Codex and diff --git a/README.md b/README.md index 898eaf5..c3cbea6 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ collected only on a frozen release candidate. | Source | V0 support | | --- | --- | -| `to-tickets` local one-ticket-per-file Markdown | Supported; attested to upstream `v1.2.2` | +| `to-tickets` local one-ticket-per-file Markdown | Supported; attested to upstream `v1.2.2`; every ticket must explicitly declare `Blocked by`, using `None` when empty | | GitHub native sub-issues and dependencies | Supported | | GitHub task-list children and `## Blocked by` / `Blocked by: #N` fallback | Supported | | Normalized work-graph JSON | Supported | diff --git a/README.zh-CN.md b/README.zh-CN.md index ca42cb4..b3a14fd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # First Tree Skills -> 英文 [README.md](README.md) 是规范来源。本译文同步于 2026-08-06;若内容不一致,以英文版为准。 +> 英文 [README.md](README.md) 是规范来源。本译文同步于 2026-08-10;若内容不一致,以英文版为准。 用于把结构化 agent 工作安全移入 [First Tree](https://first-tree.ai/?utm_source=first-tree-skills&utm_medium=github&utm_campaign=readme) 的 companion skills。 @@ -56,7 +56,7 @@ Use the run-with-first-tree skill to inspect and run the tickets under ## V0 输入与边界 -支持:`to-tickets` v1.2.2 的一票一文件 Markdown、GitHub 原生子 issue 与依赖、正文中的 `Blocked by` fallback,以及 normalized work-graph JSON。 +支持:`to-tickets` v1.2.2 的一票一文件 Markdown(每张票必须显式声明 `Blocked by`,无依赖时写 `None`)、GitHub 原生子 issue 与依赖、正文中的 `Blocked by` fallback,以及 normalized work-graph JSON。 不支持:直接消费 Wayfinder map、GitLab/custom tracker 原生 API、后台监控、自动改写源 tracker,以及把派发权限扩展到用户未授权的代码、生产或外部系统。 diff --git a/skills/run-with-first-tree/references/work-graph-contract.md b/skills/run-with-first-tree/references/work-graph-contract.md index e0e5396..f59722e 100644 --- a/skills/run-with-first-tree/references/work-graph-contract.md +++ b/skills/run-with-first-tree/references/work-graph-contract.md @@ -35,10 +35,12 @@ Use UTF-8 JSON: } ``` -The validator requires `schema_version`, `source`, and `items`. Every item -requires every field shown above. Its `graph_digest` identifies the stable -source, item identities, dispatchability, and blocking topology; mutable status, -claim, and acceptance fields do not change the digest. +The validator requires `schema_version`, `source`, and `items`. +`schema_version` must be the JSON integer `1`; booleans and numeric strings are +invalid. Every item requires every field shown above, and `title` and `source` +must be non-empty strings. Its `graph_digest` identifies the stable source, item +identities, dispatchability, and blocking topology; mutable status, claim, and +acceptance fields do not change the digest. ## Item semantics @@ -49,8 +51,9 @@ claim, and acceptance fields do not change the digest. or `-` so it can be reused safely in dispatch metadata. `AMBIGUOUS:` and `UNRESOLVED:` are reserved legacy dependency-marker prefixes and cannot be used as item ids. -- `title` — human-readable ticket name. -- `source` — absolute or project-relative path, or canonical issue URL. +- `title` — non-empty human-readable ticket name. +- `source` — non-empty absolute or project-relative path, or canonical issue + URL. - `status` — `open`, `completed`, or `cancelled`. A GitHub issue closed as `not_planned`, `duplicate`, or another non-completion reason is cancelled and does not release dependents. @@ -115,6 +118,10 @@ The local builder recognizes the one-ticket-per-file shape used by - `**Status:** ready-for-agent` as the readiness signal; and - Markdown task-list items as acceptance criteria. +Every local ticket must contain exactly one `Blocked by` declaration. Tickets +without blockers must state `None` explicitly. A missing or repeated declaration +is unresolved and validation fails closed. + An atom that is exactly `#` (optionally followed by one terminal `.` or `!`) resolves an exact-case item id before case-folded ids or aliases. Formatting or list-marker prefixes do not participate in that identity rule. diff --git a/skills/run-with-first-tree/scripts/build_local_work_graph.py b/skills/run-with-first-tree/scripts/build_local_work_graph.py index e3d64a8..d835513 100755 --- a/skills/run-with-first-tree/scripts/build_local_work_graph.py +++ b/skills/run-with-first-tree/scripts/build_local_work_graph.py @@ -372,6 +372,10 @@ def identity_syntax_is_valid(original: str, reference_id: str) -> bool: f"{UNRESOLVED_DEPENDENCY_PREFIX}oversized-blocker-value:{size}" for size in oversized_blocker_sizes ] + if blocker_declaration_count == 0: + resolved.append( + f"{UNRESOLVED_DEPENDENCY_PREFIX}missing-blocked-by-declaration" + ) for original_blocker, blocker, syntax_valid in raw_blockers: identity_candidates = [blocker] if blocker.endswith((".", "!")): diff --git a/skills/run-with-first-tree/scripts/validate_work_graph.py b/skills/run-with-first-tree/scripts/validate_work_graph.py index ef011f2..daa3264 100755 --- a/skills/run-with-first-tree/scripts/validate_work_graph.py +++ b/skills/run-with-first-tree/scripts/validate_work_graph.py @@ -43,8 +43,11 @@ def _load_manifest(path: Path) -> tuple[dict[str, Any] | None, list[str]]: def _validate_shape(manifest: dict[str, Any]) -> list[str]: errors: list[str] = [] - if manifest.get("schema_version") != 1: - errors.append("schema_version must be 1") + if ( + type(manifest.get("schema_version")) is not int + or manifest.get("schema_version") != 1 + ): + errors.append("schema_version must be the integer 1") source = manifest.get("source") if not isinstance(source, dict): @@ -77,6 +80,13 @@ def _validate_shape(manifest: dict[str, Any]) -> list[str]: elif not isinstance(item[field], expected_type): errors.append(f"{prefix}.{field} must be {expected_type.__name__}") + title = item.get("title") + if isinstance(title, str) and not title.strip(): + errors.append(f"{prefix}.title must be a non-empty string") + source_reference = item.get("source") + if isinstance(source_reference, str) and not source_reference.strip(): + errors.append(f"{prefix}.source must be a non-empty string") + item_id = item.get("id") if isinstance(item_id, str): if not item_id.strip(): diff --git a/tests/test_work_graph_scripts.py b/tests/test_work_graph_scripts.py index 2b1829f..b1c3f6d 100644 --- a/tests/test_work_graph_scripts.py +++ b/tests/test_work_graph_scripts.py @@ -241,6 +241,33 @@ def test_cycle_is_invalid(self) -> None: self.assertFalse(result["valid"]) self.assertTrue(any("Dependency cycle" in error for error in result["errors"])) + def test_schema_version_requires_the_json_integer_one(self) -> None: + payload = manifest([]) + payload["schema_version"] = True + + result = self.validator.analyze_manifest(payload) + + self.assertFalse(result["valid"]) + self.assertIn("schema_version must be the integer 1", result["errors"]) + + def test_item_title_must_not_be_blank(self) -> None: + blank_title = item("01") + blank_title["title"] = " " + + result = self.validator.analyze_manifest(manifest([blank_title])) + + self.assertFalse(result["valid"]) + self.assertIn("items[0].title must be a non-empty string", result["errors"]) + + def test_item_source_must_not_be_blank(self) -> None: + blank_source = item("01") + blank_source["source"] = "" + + result = self.validator.analyze_manifest(manifest([blank_source])) + + self.assertFalse(result["valid"]) + self.assertIn("items[0].source must be a non-empty string", result["errors"]) + def test_unsafe_item_identity_is_invalid(self) -> None: result = self.validator.analyze_manifest(manifest([item("$(unsafe)")])) @@ -310,6 +337,35 @@ def test_unresolved_local_dependency_fails_closed(self) -> None: "Missing dependency: 01 -> !UNRESOLVED:missing-ticket", result["errors"] ) + def test_local_builder_requires_an_explicit_blocker_declaration(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + ticket = Path(temp_dir) / "01-task.md" + ticket.write_text( + "# Task\n\n**Status:** ready-for-agent\n\n- [ ] Task works\n", + encoding="utf-8", + ) + output = Path(temp_dir) / "graph.json" + subprocess.run( + [ + "python3", + str(SCRIPTS / "build_local_work_graph.py"), + temp_dir, + "--output", + str(output), + ], + check=True, + ) + result = self.validator.analyze_manifest( + json.loads(output.read_text(encoding="utf-8")) + ) + + self.assertFalse(result["valid"]) + self.assertIn( + "items[0].blocked_by contains an invalid dependency identifier: " + "!UNRESOLVED:missing-blocked-by-declaration", + result["errors"], + ) + def test_local_builder_accepts_none_blocker_section(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: ticket = Path(temp_dir) / "01-task.md" @@ -341,7 +397,7 @@ def test_local_builder_treats_only_whole_atom_sentinels_as_empty(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "01-foundation.md").write_text( - "# Foundation\n\n**Status:** completed\n", + "# Foundation\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-valid.md").write_text( @@ -355,7 +411,7 @@ def test_local_builder_treats_only_whole_atom_sentinels_as_empty(self) -> None: encoding="utf-8", ) (source / "NO-123.md").write_text( - "# Shell-safe identifier\n\n**Status:** completed\n", + "# Shell-safe identifier\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) @@ -372,11 +428,11 @@ def test_local_builder_preserves_identity_significant_underscores(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) tickets = { - "N_o_n_e.md": "# Underscore sentinel lookalike\n\n**Status:** ready-for-agent\n", - "foo_bar.md": "# Underscore id\n\n**Status:** completed\n", - "foobar.md": "# Plain id\n\n**Status:** completed\n", - "10-underscore-alias.md": "# review_gate\n\n**Status:** completed\n", - "11-plain-alias.md": "# reviewgate\n\n**Status:** completed\n", + "N_o_n_e.md": "# Underscore sentinel lookalike\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", + "foo_bar.md": "# Underscore id\n\n**Status:** completed\n**Blocked by:** None\n", + "foobar.md": "# Plain id\n\n**Status:** completed\n**Blocked by:** None\n", + "10-underscore-alias.md": "# review_gate\n\n**Status:** completed\n**Blocked by:** None\n", + "11-plain-alias.md": "# reviewgate\n\n**Status:** completed\n**Blocked by:** None\n", "20-dependent.md": ( "# Sentinel lookalike dependent\n\n**Status:** ready-for-agent\n" "**Blocked by:** N_o_n_e\n" @@ -422,7 +478,7 @@ def test_local_builder_resolves_real_tickets_before_empty_sentinels(self) -> Non exact_id_source = root / "exact-id" exact_id_source.mkdir() (exact_id_source / "None.md").write_text( - "# Exact id\n\n**Status:** ready-for-agent\n", + "# Exact id\n\n**Status:** ready-for-agent\n**Blocked by:** No\n", encoding="utf-8", ) (exact_id_source / "01-dependent.md").write_text( @@ -434,7 +490,7 @@ def test_local_builder_resolves_real_tickets_before_empty_sentinels(self) -> Non unique_alias_source = root / "unique-alias" unique_alias_source.mkdir() (unique_alias_source / "01-none.md").write_text( - "# None\n\n**Status:** ready-for-agent\n", + "# None\n\n**Status:** ready-for-agent\n**Blocked by:** No\n", encoding="utf-8", ) (unique_alias_source / "02-dependent.md").write_text( @@ -446,11 +502,11 @@ def test_local_builder_resolves_real_tickets_before_empty_sentinels(self) -> Non ambiguous_source = root / "ambiguous-alias" ambiguous_source.mkdir() (ambiguous_source / "01-none.md").write_text( - "# None\n\n**Status:** ready-for-agent\n", + "# None\n\n**Status:** ready-for-agent\n**Blocked by:** No\n", encoding="utf-8", ) (ambiguous_source / "02-none.md").write_text( - "# None\n\n**Status:** completed\n", + "# None\n\n**Status:** completed\n**Blocked by:** No\n", encoding="utf-8", ) (ambiguous_source / "03-dependent.md").write_text( @@ -509,7 +565,7 @@ def test_local_builder_resolves_punctuated_aliases_before_sentinels(self) -> Non real_none_source = root / "real-none" real_none_source.mkdir() (real_none_source / "None.md").write_text( - "# None\n\n**Status:** ready-for-agent\n", + "# None\n\n**Status:** ready-for-agent\n**Blocked by:** No\n", encoding="utf-8", ) (real_none_source / "01-dependent.md").write_text( @@ -534,11 +590,11 @@ def test_local_builder_resolves_punctuated_aliases_before_sentinels(self) -> Non exact_dot_source = root / "exact-dot" exact_dot_source.mkdir() (exact_dot_source / "foo..md").write_text( - "# Dotted identity\n\n**Status:** ready-for-agent\n", + "# Dotted identity\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", encoding="utf-8", ) (exact_dot_source / "foo.md").write_text( - "# Plain identity\n\n**Status:** ready-for-agent\n", + "# Plain identity\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", encoding="utf-8", ) (exact_dot_source / "01-dependent.md").write_text( @@ -605,11 +661,11 @@ def test_local_builder_fails_closed_on_punctuated_sentinel_alias_conflicts( with self.subTest(name=name), tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / blocker_file).write_text( - f"# {blocker_title}\n\n**Status:** ready-for-agent\n", + f"# {blocker_title}\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", encoding="utf-8", ) (source / "98-shadow.md").write_text( - f"# {shadow}\n\n**Status:** completed\n", + f"# {shadow}\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -645,11 +701,11 @@ def test_local_builder_prefers_punctuated_item_ids_over_aliases(self) -> None: ): source = Path(temp_dir) (source / "FOO.md").write_text( - "# Open blocker\n\n**Status:** ready-for-agent\n", + "# Open blocker\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", encoding="utf-8", ) (source / "shadow.md").write_text( - f"# {shadow_alias}\n\n**Status:** completed\n", + f"# {shadow_alias}\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "dependent.md").write_text( @@ -674,11 +730,11 @@ def test_local_builder_fails_closed_for_ambiguous_aliases(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "01-open.md").write_text( - "# Shared foundation\n\n**Status:** ready-for-agent\n", + "# Shared foundation\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-completed.md").write_text( - "# Shared foundation\n\n**Status:** completed\n", + "# Shared foundation\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "03-dependent.md").write_text( @@ -687,7 +743,7 @@ def test_local_builder_fails_closed_for_ambiguous_aliases(self) -> None: encoding="utf-8", ) (source / "04-unique.md").write_text( - "# Unique foundation\n\n**Status:** completed\n", + "# Unique foundation\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "05-unique-dependent.md").write_text( @@ -701,7 +757,7 @@ def test_local_builder_fails_closed_for_ambiguous_aliases(self) -> None: encoding="utf-8", ) (source / "07-id-lookalike.md").write_text( - "# 01\n\n**Status:** completed\n", + "# 01\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) graph = builder.build_manifest(source) @@ -726,11 +782,11 @@ def test_local_builder_prefers_exact_case_item_id(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "FOO.md").write_text( - "# Uppercase blocker\n\n**Status:** completed\n", + "# Uppercase blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "foo.md").write_text( - "# Lowercase blocker\n\n**Status:** completed\n", + "# Lowercase blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) filenames = {path.name for path in source.iterdir()} @@ -757,7 +813,7 @@ def test_local_builder_preserves_exact_adjacent_hash_id_precedence(self) -> None with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "01#02.md").write_text( - "# Exact compound id\n\n**Status:** completed\n", + "# Exact compound id\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "dependent.md").write_text( @@ -789,7 +845,7 @@ def test_local_builder_preserves_trailing_underscore_id_precedence(self) -> None ): source = Path(temp_dir) (source / f"{item_id}.md").write_text( - "# Exact underscore id\n\n**Status:** completed\n", + "# Exact underscore id\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "dependent.md").write_text( @@ -828,7 +884,7 @@ def test_local_builder_preserves_decorated_underscore_id_precedence(self) -> Non ): source = Path(temp_dir) (source / f"{item_id}.md").write_text( - "# Exact underscore id\n\n**Status:** completed\n", + "# Exact underscore id\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "dependent.md").write_text( @@ -858,7 +914,7 @@ def test_local_builder_retains_edges_for_repeated_terminal_punctuation( ): source = Path(temp_dir) (source / "01.md").write_text( - "# Blocker\n\n**Status:** completed\n", + "# Blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "dependent.md").write_text( @@ -886,15 +942,15 @@ def test_synthetic_dependency_markers_cannot_collide_with_item_ids(self) -> None ambiguous_source = Path(temp_dir) / "ambiguous" ambiguous_source.mkdir() (ambiguous_source / "01-open.md").write_text( - "# shared\n\n**Status:** ready-for-agent\n", + "# shared\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", encoding="utf-8", ) (ambiguous_source / "02-completed.md").write_text( - "# shared\n\n**Status:** completed\n", + "# shared\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (ambiguous_source / "AMBIGUOUS:shared.md").write_text( - "# Collision\n\n**Status:** completed\n", + "# Collision\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (ambiguous_source / "03-dependent.md").write_text( @@ -906,7 +962,7 @@ def test_synthetic_dependency_markers_cannot_collide_with_item_ids(self) -> None unresolved_source = Path(temp_dir) / "unresolved" unresolved_source.mkdir() (unresolved_source / "UNRESOLVED:missing.md").write_text( - "# Collision\n\n**Status:** completed\n", + "# Collision\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (unresolved_source / "01-dependent.md").write_text( @@ -943,7 +999,7 @@ def test_local_builder_preserves_references_inside_blocker_prose(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "01-foundation.md").write_text( - "# Foundation\n\n**Status:** completed\n", + "# Foundation\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-dependent.md").write_text( @@ -979,11 +1035,11 @@ def test_local_builder_fails_closed_when_whole_alias_shadows_embedded_id( ): source = Path(temp_dir) (source / "01-open.md").write_text( - "# Open blocker\n\n**Status:** ready-for-agent\n", + "# Open blocker\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", encoding="utf-8", ) (source / "98-shadow.md").write_text( - f"# {blocker_value}\n\n**Status:** completed\n", + f"# {blocker_value}\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1010,7 +1066,7 @@ def test_local_builder_preserves_prose_after_leading_numeric_reference( with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "01-foundation.md").write_text( - "# 01 — Foundation\n\n**Status:** completed\n", + "# 01 — Foundation\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-id-dependent.md").write_text( @@ -1065,12 +1121,12 @@ def test_local_builder_preserves_unconsumed_connector_prose(self) -> None: ): source = Path(temp_dir) (source / "01-completed.md").write_text( - "# Completed blocker\n\n**Status:** completed\n", + "# Completed blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) if alias is not None: (source / f"{alias}.md").write_text( - f"# {alias}\n\n**Status:** ready-for-agent\n", + f"# {alias}\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1100,11 +1156,11 @@ def test_local_builder_accepts_connectors_between_explicit_references(self) -> N ): source = Path(temp_dir) (source / "01-first.md").write_text( - "# First blocker\n\n**Status:** completed\n", + "# First blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-second.md").write_text( - "# Second blocker\n\n**Status:** completed\n", + "# Second blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1131,7 +1187,7 @@ def test_local_builder_accepts_declared_list_and_wrapper_syntax(self) -> None: ): source = Path(temp_dir) (source / "01-blocker.md").write_text( - "# Blocker\n\n**Status:** completed\n", + "# Blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1154,7 +1210,7 @@ def test_local_builder_accepts_wrapper_around_connected_references(self) -> None source = Path(temp_dir) for ticket_id in ("01", "02"): (source / f"{ticket_id}-blocker.md").write_text( - f"# Blocker {ticket_id}\n\n**Status:** completed\n", + f"# Blocker {ticket_id}\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1181,7 +1237,7 @@ def test_local_builder_accepts_separately_wrapped_atoms(self) -> None: source = Path(temp_dir) for ticket_id in ("01", "02"): (source / f"{ticket_id}-blocker.md").write_text( - f"# Blocker {ticket_id}\n\n**Status:** completed\n", + f"# Blocker {ticket_id}\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1207,7 +1263,7 @@ def test_local_builder_fails_closed_for_duplicate_blocker_fields(self) -> None: ): source = Path(temp_dir) (source / "01-blocker.md").write_text( - "# Blocker\n\n**Status:** completed\n", + "# Blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1241,7 +1297,7 @@ def test_local_builder_fails_closed_for_duplicate_blocker_sections(self) -> None ): source = Path(temp_dir) (source / "01-blocker.md").write_text( - "# Blocker\n\n**Status:** completed\n", + "# Blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1266,7 +1322,7 @@ def test_local_builder_parses_each_duplicate_before_size_validation(self) -> Non source = Path(temp_dir) for ticket_id in ("01", "02"): (source / f"{ticket_id}-blocker.md").write_text( - f"# Blocker {ticket_id}\n\n**Status:** completed\n", + f"# Blocker {ticket_id}\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1292,7 +1348,7 @@ def test_local_builder_preserves_oversized_and_lost_content_evidence(self) -> No with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "01-blocker.md").write_text( - "# Blocker\n\n**Status:** completed\n", + "# Blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1327,7 +1383,7 @@ def test_local_builder_rejects_unbalanced_alias_wrappers(self) -> None: ): source = Path(temp_dir) (source / "01-blocker.md").write_text( - f"# {alias}\n\n**Status:** completed\n", + f"# {alias}\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1360,7 +1416,7 @@ def test_local_builder_revalidates_syntax_exposed_by_wrappers(self) -> None: ): source = Path(temp_dir) (source / "01-blocker.md").write_text( - f"# {alias}\n\n**Status:** completed\n", + f"# {alias}\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1385,11 +1441,11 @@ def test_local_builder_preserves_invalid_connector_atom_syntax(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "01-blocker.md").write_text( - "# First\n\n**Status:** completed\n", + "# First\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-alias.md").write_text( - "# Foo\n\n**Status:** completed\n", + "# Foo\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1414,11 +1470,11 @@ def test_local_builder_does_not_split_and_inside_ids_or_aliases(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "foo-and-bar.md").write_text( - "# Explicit id blocker\n\n**Status:** completed\n", + "# Explicit id blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-alias.md").write_text( - "# review-and-ship\n\n**Status:** completed\n", + "# review-and-ship\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "03-dependent.md").write_text( @@ -1446,15 +1502,15 @@ def test_local_builder_fails_closed_on_whole_alias_split_conflicts(self) -> None ): source = Path(temp_dir) (source / "01-whole.md").write_text( - f"# {blocker_value}\n\n**Status:** ready-for-agent\n", + f"# {blocker_value}\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-first.md").write_text( - "# Foo\n\n**Status:** completed\n", + "# Foo\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "03-second.md").write_text( - "# Bar\n\n**Status:** completed\n", + "# Bar\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1537,11 +1593,11 @@ def test_local_builder_rejects_empty_delimiter_fragments(self) -> None: ): source = Path(temp_dir) (source / "01-completed.md").write_text( - "# Completed blocker\n\n**Status:** completed\n", + "# Completed blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-completed.md").write_text( - "# Second blocker\n\n**Status:** completed\n", + "# Second blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1587,19 +1643,19 @@ def test_local_builder_rejects_unsupported_reference_gaps(self) -> None: ): source = Path(temp_dir) (source / "01-first.md").write_text( - "# First blocker\n\n**Status:** completed\n", + "# First blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-second.md").write_text( - "# Second blocker\n\n**Status:** completed\n", + "# Second blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "FOO.md").write_text( - "# Upper blocker\n\n**Status:** completed\n", + "# Upper blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "BAR.md").write_text( - "# Lower blocker\n\n**Status:** completed\n", + "# Lower blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1632,7 +1688,7 @@ def test_local_builder_enforces_outer_wrapper_depth(self) -> None: ): source = Path(temp_dir) (source / "01-blocker.md").write_text( - "# Blocker\n\n**Status:** completed\n", + "# Blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1661,7 +1717,7 @@ def test_local_blocker_gap_validation_is_bounded_for_nul_prose(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "01-blocker.md").write_text( - "# Blocker\n\n**Status:** completed\n", + "# Blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1711,7 +1767,7 @@ def test_local_builder_counts_whitespace_before_size_normalization(self) -> None with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) (source / "01-blocker.md").write_text( - "# Blocker\n\n**Status:** completed\n", + "# Blocker\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text( @@ -1744,11 +1800,11 @@ def test_local_builder_compares_whole_alias_when_delimiters_leave_one_atom( ): source = Path(temp_dir) (source / "01-whole.md").write_text( - f"# {blocker_value}\n\n**Status:** ready-for-agent\n", + f"# {blocker_value}\n\n**Status:** ready-for-agent\n**Blocked by:** None\n", encoding="utf-8", ) (source / "02-split.md").write_text( - "# Foo\n\n**Status:** completed\n", + "# Foo\n\n**Status:** completed\n**Blocked by:** None\n", encoding="utf-8", ) (source / "99-dependent.md").write_text(