claude-dev-env 2.8.0 → 2.9.0

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.
Files changed (53) hide show
  1. package/CLAUDE.md +7 -1
  2. package/agents/clean-coder.md +9 -19
  3. package/agents/test_agent_frontmatter.py +26 -0
  4. package/docs/CODE_RULES.md +4 -2
  5. package/docs/references/CLAUDE.md +2 -2
  6. package/docs/references/advisor-tool.md +44 -6
  7. package/docs/references/team-advisor-skill.md +14 -8
  8. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  9. package/output-styles/CLAUDE.md +17 -0
  10. package/output-styles/caveman-agent.md +37 -0
  11. package/package.json +2 -1
  12. package/rules/code-standards.md +33 -7
  13. package/rules/eli11-replies.md +1 -1
  14. package/scripts/CLAUDE.md +2 -2
  15. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  16. package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +50 -0
  17. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +104 -0
  18. package/scripts/grok_patch_artifacts.py +123 -0
  19. package/scripts/grok_run_ledger.py +318 -0
  20. package/scripts/spawn_grok_batch.py +553 -9
  21. package/scripts/test_grok_patch_artifacts.py +82 -0
  22. package/scripts/test_grok_run_ledger.py +116 -0
  23. package/scripts/test_spawn_grok_batch.py +295 -0
  24. package/skills/CLAUDE.md +4 -2
  25. package/skills/_shared/CLAUDE.md +37 -4
  26. package/skills/_shared/advisor/CLAUDE.md +9 -0
  27. package/skills/_shared/advisor/advisor-protocol.md +5 -0
  28. package/skills/_shared/advisor/scripts/README.md +9 -0
  29. package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
  30. package/skills/_shared/pr-loop/CLAUDE.md +18 -1
  31. package/skills/_shared/pr-loop/audit-contract.md +5 -0
  32. package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
  33. package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
  34. package/skills/_shared/pr-loop/fix-protocol.md +5 -0
  35. package/skills/_shared/pr-loop/gh-payloads.md +5 -0
  36. package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
  37. package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
  38. package/skills/_shared/pr-loop/scripts/CLAUDE.md +8 -1
  39. package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -0
  40. package/skills/_shared/pr-loop/state-schema.md +5 -0
  41. package/skills/_shared/pr-loop/worker-spawn.md +5 -0
  42. package/skills/e-code-review/SKILL.md +6 -1
  43. package/skills/e-code-review/reference/runner-selection.md +40 -0
  44. package/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +1 -0
  45. package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -0
  46. package/skills/e-code-review/scripts/grok_code_review.py +221 -0
  47. package/skills/e-code-review/scripts/test_grok_code_review.py +212 -0
  48. package/skills/grok-spawn/SKILL.md +5 -0
  49. package/skills/orchestrator/SKILL.md +5 -0
  50. package/skills/task-build/reference/tool-routing.md +3 -0
  51. package/skills/team-advisor/SKILL.md +23 -44
  52. package/system-prompts/software-engineer.xml +6 -3
  53. package/skills/test_markdown_link_integrity.py +0 -107
@@ -0,0 +1,212 @@
1
+ """Behavioral tests for Grok medium-review discovery and verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _SCRIPTS = Path(__file__).resolve().parent
11
+ if str(_SCRIPTS) not in sys.path:
12
+ sys.path.insert(0, str(_SCRIPTS))
13
+
14
+ from e_code_review_scripts_constants.grok_code_review_constants import ( # noqa: E402
15
+ ALL_MEDIUM_FINDER_ANGLES,
16
+ MEDIUM_REVIEW_FINDER_COUNT,
17
+ VERDICT_CONFIRMED,
18
+ VERDICT_PLAUSIBLE,
19
+ VERDICT_REFUTED,
20
+ )
21
+ from grok_code_review import ( # noqa: E402
22
+ FinderCandidate,
23
+ deduplicate_candidates,
24
+ require_exact_finder_set,
25
+ retain_verified_findings,
26
+ run_medium_review,
27
+ )
28
+
29
+
30
+ def _candidate(
31
+ angle: str,
32
+ *,
33
+ file_path: str = "a.py",
34
+ line_number: int = 1,
35
+ mechanism: str = "m",
36
+ scenario: str = "short",
37
+ suffix: str = "",
38
+ head: str = "head1",
39
+ ) -> FinderCandidate:
40
+ token = suffix or angle
41
+ return FinderCandidate(
42
+ angle=angle,
43
+ file_path=file_path,
44
+ line_number=line_number,
45
+ mechanism=mechanism,
46
+ scenario=scenario,
47
+ worktree_path=f"/wt/{token}",
48
+ leader_socket=f"sock-{token}",
49
+ advisor_session_id=f"adv-{token}",
50
+ reviewed_head=head,
51
+ )
52
+
53
+
54
+ def test_require_exact_finder_set_accepts_all_angles() -> None:
55
+ require_exact_finder_set(ALL_MEDIUM_FINDER_ANGLES)
56
+ with pytest.raises(ValueError):
57
+ require_exact_finder_set(ALL_MEDIUM_FINDER_ANGLES[:-1])
58
+
59
+
60
+ def test_deduplicate_keeps_most_concrete_scenario() -> None:
61
+ short = _candidate("correctness", scenario="short")
62
+ long = _candidate("security", scenario="much more concrete failure path")
63
+ # same key different angles - force same file/line/mech
64
+ short = FinderCandidate(
65
+ angle="correctness",
66
+ file_path="a.py",
67
+ line_number=3,
68
+ mechanism="null",
69
+ scenario="x",
70
+ worktree_path="/wt/a",
71
+ leader_socket="s1",
72
+ advisor_session_id="a1",
73
+ reviewed_head="h",
74
+ )
75
+ long = FinderCandidate(
76
+ angle="security",
77
+ file_path="a.py",
78
+ line_number=3,
79
+ mechanism="null",
80
+ scenario="concrete long scenario",
81
+ worktree_path="/wt/b",
82
+ leader_socket="s2",
83
+ advisor_session_id="a2",
84
+ reviewed_head="h",
85
+ )
86
+ kept = deduplicate_candidates([short, long])
87
+ assert len(kept) == 1
88
+ assert kept[0].scenario == "concrete long scenario"
89
+
90
+
91
+ def test_run_medium_review_happy_path() -> None:
92
+ head = "abc123"
93
+ all_finders = [
94
+ _candidate(each_angle, head=head, suffix=each_angle)
95
+ for each_angle in ALL_MEDIUM_FINDER_ANGLES
96
+ ]
97
+ # make unique keys per angle
98
+ all_finders = [
99
+ FinderCandidate(
100
+ angle=each_angle,
101
+ file_path=f"{each_angle}.py",
102
+ line_number=1,
103
+ mechanism="m",
104
+ scenario=f"scenario {each_angle}",
105
+ worktree_path=f"/wt/{each_angle}",
106
+ leader_socket=f"sock-{each_angle}",
107
+ advisor_session_id=f"adv-{each_angle}",
108
+ reviewed_head=head,
109
+ )
110
+ for each_angle in ALL_MEDIUM_FINDER_ANGLES
111
+ ]
112
+ verdicts = {
113
+ (f"{each}.py", 1, "m"): VERDICT_CONFIRMED
114
+ for each in ALL_MEDIUM_FINDER_ANGLES
115
+ }
116
+ severities = {
117
+ (f"{each}.py", 1, "m"): "medium" for each in ALL_MEDIUM_FINDER_ANGLES
118
+ }
119
+ # refute one
120
+ first = ALL_MEDIUM_FINDER_ANGLES[0]
121
+ verdicts[(f"{first}.py", 1, "m")] = VERDICT_REFUTED
122
+ batch = run_medium_review(
123
+ target_head=head,
124
+ diff_base="base",
125
+ all_finder_candidates=all_finders,
126
+ verdict_by_key=verdicts,
127
+ severity_by_key=severities,
128
+ live_head=head,
129
+ )
130
+ assert batch.is_rejected is False
131
+ assert len(batch.all_finder_angles) == MEDIUM_REVIEW_FINDER_COUNT
132
+ assert all(each.reviewed_head == head for each in batch.all_retained_findings)
133
+ assert all(each.verdict in {VERDICT_CONFIRMED, VERDICT_PLAUSIBLE} for each in batch.all_retained_findings)
134
+ assert len(batch.all_retained_findings) == MEDIUM_REVIEW_FINDER_COUNT - 1
135
+
136
+
137
+ def test_head_drift_rejects_batch() -> None:
138
+ head = "h1"
139
+ all_finders = [
140
+ FinderCandidate(
141
+ angle=each_angle,
142
+ file_path=f"{each_angle}.py",
143
+ line_number=1,
144
+ mechanism="m",
145
+ scenario="s",
146
+ worktree_path=f"/wt/{each_angle}",
147
+ leader_socket=f"sock-{each_angle}",
148
+ advisor_session_id=f"adv-{each_angle}",
149
+ reviewed_head=head,
150
+ )
151
+ for each_angle in ALL_MEDIUM_FINDER_ANGLES
152
+ ]
153
+ batch = run_medium_review(
154
+ target_head=head,
155
+ diff_base="b",
156
+ all_finder_candidates=all_finders,
157
+ verdict_by_key={},
158
+ severity_by_key={},
159
+ live_head="other",
160
+ )
161
+ assert batch.is_rejected is True
162
+ assert batch.rejection_reason == "head_drift"
163
+
164
+
165
+ def test_advisor_blocked_rejects_batch() -> None:
166
+ head = "h1"
167
+ all_finders = [
168
+ FinderCandidate(
169
+ angle=each_angle,
170
+ file_path=f"{each_angle}.py",
171
+ line_number=1,
172
+ mechanism="m",
173
+ scenario="s",
174
+ worktree_path=f"/wt/{each_angle}",
175
+ leader_socket=f"sock-{each_angle}",
176
+ advisor_session_id=f"adv-{each_angle}",
177
+ reviewed_head=head,
178
+ )
179
+ for each_angle in ALL_MEDIUM_FINDER_ANGLES
180
+ ]
181
+ batch = run_medium_review(
182
+ target_head=head,
183
+ diff_base="b",
184
+ all_finder_candidates=all_finders,
185
+ verdict_by_key={},
186
+ severity_by_key={},
187
+ live_head=head,
188
+ is_any_advisor_blocked=True,
189
+ )
190
+ assert batch.is_rejected is True
191
+ assert batch.rejection_reason == "advisor_blocked"
192
+
193
+
194
+ def test_retain_verified_findings_keeps_confirmed() -> None:
195
+ candidate = FinderCandidate(
196
+ angle="correctness",
197
+ file_path="a.py",
198
+ line_number=2,
199
+ mechanism="null",
200
+ scenario="boom",
201
+ worktree_path="/wt/x",
202
+ leader_socket="s",
203
+ advisor_session_id="a",
204
+ reviewed_head="h",
205
+ )
206
+ kept = retain_verified_findings(
207
+ all_candidates=[candidate],
208
+ verdict_by_key={("a.py", 2, "null"): VERDICT_CONFIRMED},
209
+ severity_by_key={("a.py", 2, "null"): "high"},
210
+ )
211
+ assert len(kept) == 1
212
+ assert kept[0].verdict == VERDICT_CONFIRMED
@@ -231,3 +231,8 @@ Sibling skill: `/grokify` for a single paste-ready interactive Grok Build handof
231
231
 
232
232
  - `SKILL.md` — hub and process.
233
233
  - `reference/` — brief templates and flag profiles.
234
+
235
+ ## Worker advisors
236
+
237
+ Each grok worker binds a unique Opus-high dvisor_session_id through the lead-supplied dvisor.launcher in the batch spec (committed default is a placeholder). Reports require the same handle\'s ENDORSE (or bounded CORRECTION/PLAN then ENDORSE); STOP or malformed signals end as advisor_blocked.
238
+
@@ -358,3 +358,8 @@ no open work remains, run `set --status done` before any re-arm attempt.
358
358
  - `scripts/` — deterministic status_gate.
359
359
  - Advisor policy:
360
360
  [`_shared/advisor/advisor-protocol.md`](../../_shared/advisor/advisor-protocol.md).
361
+
362
+ ## File-backed run ledger
363
+
364
+ When host task tools are absent, reconcile delegated work through `scripts/grok_run_ledger.py` under the run-state directory (stable task ids, one live owner, unique advisor sessions, dependency blocking, snapshot-drift reopening).
365
+
@@ -17,3 +17,6 @@ Do not invent a tool name. Tool availability means the callable tool is exposed
17
17
  ## Reporting
18
18
 
19
19
  State the selected tool, the number of new items, the number already tracked, and the new task subjects. If the fallback path was used, name the unavailable preferred tools briefly.
20
+
21
+ 4. **File ledger fallback** — when no host task/plan tool is exposed, register work in the durable `grok_run_ledger` under the run-state directory so orchestration stays reconcilable across hosts.
22
+
@@ -2,63 +2,42 @@
2
2
  name: team-advisor
3
3
  description: >-
4
4
  Spawn one warm session-advisor at the strongest reachable tier and consult
5
- it before big decisions, completion, commits, or when stuck. Triggers:
5
+ it before substantive work, completion, commits, or when stuck. Triggers:
6
6
  'team-advisor', 'team advisor', 'second opinion', 'advisor', 'consult',
7
7
  'verify', 'validate', 'commit', 'push'.
8
8
  ---
9
9
 
10
10
  # Team Advisor
11
11
 
12
- ## Principle
12
+ One warm advisor at the strongest tier this session can reach. This session is the sole consumer.
13
13
 
14
- One warm, addressable advisor available at the strongest model tier the session can reach. The session sends concise briefs when a decision benefits from a second opinion: before acting on a plan, at completion, before commits, when stuck, when reconsidering the approach, or when an agent deems it necessary and beneficial to the user's goals.
14
+ ## Refs
15
15
 
16
- ## Follow the shared protocol
17
-
18
- **Detect the host profile first** (Host profiles in
19
- [`_shared/advisor/advisor-protocol.md`](../../_shared/advisor/advisor-protocol.md)
20
- e.g. %USERPROFILE%\.claude\_shared\advisor\advisor-protocol.md). Do not start a model-floor
21
- walk until the host is known.
22
-
23
- This session is the shared advisor's sole consumer, so its model floor is
24
- simply this session's own tier — no routing table to take a max against.
16
+ | Doc | Holds |
17
+ |---|---|
18
+ | [`docs/references/advisor-tool.md`](../../docs/references/advisor-tool.md) | **Consult cadence and weight** — when to call, hard rule before first write, how to treat advice. Read this for every consult. |
19
+ | [`~/.claude/_shared/advisor/advisor-protocol.md`](../../_shared/advisor/advisor-protocol.md) | **Bind and lifecycle** — host detect, model floor, warm-up or CLI bind, charter, drift re-bind, CLI fallback, executor paste blocks. |
20
+ | [`agents/session-advisor.md`](../../agents/session-advisor.md) | **Reply contract** ENDORSE / CORRECTION / PLAN / STOP; SendMessage only. |
25
21
 
26
- **Claude host:** follow the shared protocol for the model-floor walk, the
27
- warm-up spawn and charter, the consult format and cadence, drift-respawn, and
28
- the CLI fallback — using `team-advisor-agent` as the name and this session as
29
- the only consumer (skip the "who you are and your assignment" opener in each
30
- consult; a single-consumer session doesn't need it). A **Fable**-tier attempt
31
- carries the exact token `FABLE-SPAWN-AUTHORIZED` in its spawn prompt, as the
32
- protocol's warm-up rule states; `hooks/blocking/fable_spawn_gate.py` denies a
33
- fable spawn whose prompt lacks it.
22
+ ## Bind
34
23
 
35
- **Third-party host:** bind a max-tier Claude advisor through the shared CLI Claude-chain
36
- in the protocol (Fable max, then Opus max; `claude_chain_runner.py` ranks
37
- `~/.claude/claude-chain.json` accounts by weekly remaining via `claude_chain_usage`
38
- and fails over on usage limits). Consult via
39
- `--resume <session_id>` on that bind. This session is the sole consumer of that
40
- CLI advisor; skip the multi-consumer opener. When the chain cannot bind or
41
- reply, fail closed and report to the user — do **not** answer ENDORSE /
42
- CORRECTION / PLAN / STOP as this third-party session.
24
+ 1. Detect the host profile first (protocol **Host profiles**), then walk the model floor.
25
+ 2. Floor: this session's own tier on Claude; Opus floor with Fable first on a third-party host.
26
+ 3. Name: `team-advisor-agent` on Claude (Agent spawn of `session-advisor`); one CLI `session_id` on a third-party host via the protocol Claude-chain.
27
+ 4. A Fable-tier spawn or re-spawn carries the exact token `FABLE-SPAWN-AUTHORIZED` in its prompt (protocol warm-up; `fable_spawn_gate` requires it).
28
+ 5. Skip the multi-consumer "who you are" opener sole consumer.
29
+ 6. When the bind or reply path fails, fail closed and report to the user. On a third-party host, only the bound Claude advisor issues ENDORSE / CORRECTION / PLAN / STOP.
43
30
 
44
- ## Constraints
31
+ Full walk, charter, consult message shape, and drift re-bind live in the protocol.
45
32
 
46
- - One advisor bind per session (`team-advisor-agent` on Claude; one CLI
47
- `session_id` on a third-party host), owned by this session for its whole lifecycle
48
- (spawn or CLI bind, drift re-bind, shutdown) — see
49
- [`_shared/advisor/advisor-protocol.md`](../../_shared/advisor/advisor-protocol.md).
50
- - Never bind the advisor, or its CLI path, at a tier below the protocol floor
51
- for this host (Claude: this session's own tier; a third-party host: Opus floor
52
- with Fable first).
53
- - The advisor only answers. It never edits a file, never runs a build or
54
- test, and never posts anything on the session's behalf.
33
+ ## Consult
55
34
 
56
- ## File Index
35
+ Follow **When to call**, **Hard rule**, and **How to treat advice** in `advisor-tool.md`.
57
36
 
58
- | File | Purpose |
59
- |---|---|
60
- | `SKILL.md` | Pointer to the shared advisor protocol; this session's consumer-specific wiring and constraints. |
37
+ Each brief: delta since last consult, live decision or blocker, paths or excerpts needed. Protocol owns the full consult format.
61
38
 
62
- ## Folder Map
39
+ ## Constraints
63
40
 
64
- - `SKILL.md` complete team-advisor workflow instructions.
41
+ - One bind per session; this session owns spawn or CLI bind, drift re-bind, and shutdown.
42
+ - Bind at or above the protocol floor for this host.
43
+ - The advisor only answers (messaging); the session runs tools and posts.
@@ -90,8 +90,10 @@
90
90
  </problem_solving>
91
91
 
92
92
  <code_quality>
93
- Apply the system-prompt code-quality checklist as the canonical naming and style source for
94
- all code you write or modify. ~/.claude/docs/CODE_RULES.md is a pointer to this source.
93
+ Canonical code-quality policy is repository-root AGENTS.md (installed review contract).
94
+ ~/.claude/docs/CODE_RULES.md is the compact projection of AGENTS.md for generation load —
95
+ when the two disagree, AGENTS.md wins. The checklist below is a short reminder, not a
96
+ second canonical source.
95
97
 
96
98
  Naming — full words only:
97
99
  - Use "context" over "ctx", "configuration" over "cfg", "message" over "msg",
@@ -109,7 +111,8 @@
109
111
  Types — complete coverage required:
110
112
  - Every parameter and return value carries an explicit type hint
111
113
  - Replace Any with the most specific type available
112
- - Replace every # type: ignore annotation with correct typing
114
+ - Prefer a real type over `# type: ignore`; when an ignore is required, add a second
115
+ trailing `#` justification of at least five characters (AGENTS Types)
113
116
 
114
117
  Configuration — one source of truth:
115
118
  - Every constant lives in config/ only
@@ -1,107 +0,0 @@
1
- """Link-integrity check for the skills tree and shared protocol docs.
2
-
3
- Walks every markdown file under ``skills/``, ``_shared/pr-loop/``, and
4
- ``_shared/advisor/`` and resolves each relative markdown link target against
5
- the linking file's directory. A link whose target file or directory does not
6
- exist on disk is reported with its source file and line number, and the test
7
- fails.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import re
13
- from pathlib import Path
14
-
15
-
16
- SKILLS_ROOT = Path(__file__).parent
17
- SHARED_PR_LOOP_ROOT = SKILLS_ROOT.parent / "_shared" / "pr-loop"
18
- SHARED_ADVISOR_ROOT = SKILLS_ROOT.parent / "_shared" / "advisor"
19
-
20
- MARKDOWN_LINK_PATTERN = re.compile(r"\]\(([^)\s]+)\)")
21
- FENCE_MARKER = "```"
22
-
23
- SKIPPED_TARGET_PREFIXES = (
24
- "http://",
25
- "https://",
26
- "mailto:",
27
- "#",
28
- "/",
29
- "$",
30
- "~",
31
- "<",
32
- )
33
-
34
-
35
- def _iter_markdown_files() -> list[Path]:
36
- """List every markdown file the integrity check covers.
37
-
38
- Returns:
39
- All ``.md`` files under the skills tree, the shared pr-loop tree, and
40
- the shared advisor tree.
41
- """
42
- all_markdown_files = sorted(SKILLS_ROOT.rglob("*.md"))
43
- all_markdown_files.extend(sorted(SHARED_PR_LOOP_ROOT.rglob("*.md")))
44
- all_markdown_files.extend(sorted(SHARED_ADVISOR_ROOT.rglob("*.md")))
45
- return all_markdown_files
46
-
47
-
48
- def _is_checkable_target(link_target: str) -> bool:
49
- """Decide whether a markdown link target names a local relative path.
50
-
51
- Args:
52
- link_target: The raw text between ``](`` and ``)`` in a markdown link.
53
-
54
- Returns:
55
- True when the target is a relative filesystem path this check can
56
- resolve; False for URLs, anchors, absolute paths, environment-variable
57
- paths, home-relative paths, and angle-bracket placeholders.
58
- """
59
- if link_target.startswith(SKIPPED_TARGET_PREFIXES):
60
- return False
61
- if "\\" in link_target and link_target.count(":") > 0:
62
- return False
63
- return True
64
-
65
-
66
- def _collect_broken_links(markdown_path: Path) -> list[str]:
67
- """Resolve every relative link in one markdown file.
68
-
69
- Fenced code blocks are skipped, and a ``#fragment`` suffix is stripped
70
- before resolution.
71
-
72
- Args:
73
- markdown_path: The markdown file whose links are resolved.
74
-
75
- Returns:
76
- One ``file:line -> target`` description per unresolvable link.
77
- """
78
- all_broken_links: list[str] = []
79
- is_inside_fence = False
80
- for line_number, each_line in enumerate(
81
- markdown_path.read_text(encoding="utf-8").splitlines(), start=1
82
- ):
83
- if each_line.lstrip().startswith(FENCE_MARKER):
84
- is_inside_fence = not is_inside_fence
85
- continue
86
- if is_inside_fence:
87
- continue
88
- for each_match in MARKDOWN_LINK_PATTERN.finditer(each_line):
89
- link_target = each_match.group(1).split("#", 1)[0]
90
- if not link_target or not _is_checkable_target(link_target):
91
- continue
92
- resolved_target = (markdown_path.parent / link_target).resolve()
93
- if not resolved_target.exists():
94
- all_broken_links.append(
95
- f"{markdown_path}:{line_number} -> {each_match.group(1)}"
96
- )
97
- return all_broken_links
98
-
99
-
100
- def test_every_relative_markdown_link_resolves() -> None:
101
- all_broken_links: list[str] = []
102
- for each_markdown_path in _iter_markdown_files():
103
- all_broken_links.extend(_collect_broken_links(each_markdown_path))
104
- broken_link_report = "\n".join(all_broken_links)
105
- assert not all_broken_links, (
106
- f"Unresolvable relative markdown links:\n{broken_link_report}"
107
- )