Skip to content

Commit 70a67f4

Browse files
authored
feat(dispatcher): support cross-repo deps in check_deps_resolved (closes #157) (#160)
## Summary - Fix `check_deps_resolved` two-bug class (#157): cross-repo `owner/repo#N` refs were collapsing to bare `N` queried in the wrong repo, and prose/blockquote `#N` mentions were greedy-extracted — both ending in the same silent-block-forever path. - Restrict extraction to list-item lines only; recognize `owner/repo#N` (cross-repo) and `#N` (same-repo) with left-boundary anchors so URL fragments and inline punctuation don't misparse. - On lookup failure (empty `$state` from `gh issue view`), fail-safe block AND emit a stderr warning naming the failed ref — without that, a typo would silently recreate the original bug class. ## Spec & docs - **Design doc**: `docs/superpowers/specs/2026-05-27-cross-repo-deps-design.md` - **New invariant**: [INV-39](https://github.com/zxkane/autonomous-dev-team/blob/feat/cross-repo-deps/docs/pipeline/invariants.md#inv-39-dependency-parsing-is-list-item-scoped-and-supports-cross-repo-refs) — list-item scope, cross-repo support, lookup-failure semantics. - **Updated invariant**: INV-11 cross-references INV-39 (`state ∉ {CLOSED, MERGED}` rule applies to both same-repo and cross-repo refs). - **Flow doc**: `docs/pipeline/dispatcher-flow.md` Step 2 description updated. - **User-facing**: `skills/create-issue/SKILL.md` and `references/issue-templates.md` document list-item scope + `owner/repo#N` syntax. ## Test plan - [x] All existing `#61`/`#73` regression cases continue to pass (no behavior change for bare-`#N`-on-list-item). - [x] Cross-repo `owner/repo#N`: CLOSED/MERGED → resolved; OPEN → blocked. - [x] Same number in two repos resolves independently (`#42` OPEN in this repo + `other/other#42` CLOSED → resolved). - [x] Mixed list items: same-repo CLOSED + cross-repo OPEN → blocked. - [x] Prose-embedded `#N` between headings → ignored (was the silent-block bug). - [x] Blockquote `> ... owner/repo#N ...` → ignored. - [x] URL fragment `https://github.com/.../issues/123` → ignored. - [x] Numbered list (`1.` / `2.`) → extracted correctly. - [x] Failed `gh issue view` (404 / unauthorized) → blocks AND emits stderr warning naming the failed ref. `bash tests/unit/test-check-deps-resolved.sh` — 20/20 PASS. `bash tests/unit/test-create-issue-dependencies-guidance.sh` — 10/10 PASS. `bash tests/unit/test-lib-dispatch.sh` — 21/21 PASS. Closes #157.
1 parent 396b92c commit 70a67f4

7 files changed

Lines changed: 507 additions & 62 deletions

File tree

docs/pipeline/dispatcher-flow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Find issues labeled `autonomous` with **no other active state label** (no `in-pr
162162

163163
For each match, in order:
164164

165-
1. **Dependency check.** Read the issue body for a `## Dependencies` section. Extract every `#N` reference. For each, call `gh issue view N --json state` and require state `CLOSED` or `MERGED` ([INV-11](invariants.md#inv-11-dependency-state-includes-merged) — PRs report `MERGED`, not `CLOSED`). If any dependency is still open, **skip silently** — no comment, no label change. The issue picks up next tick once dependencies clear.
165+
1. **Dependency check.** Read the issue body for a `## Dependencies` section. Extract refs from list-item lines only — `#N` and `owner/repo#N` are recognized; prose and blockquotes are ignored ([INV-39](invariants.md#inv-39-dependency-parsing-is-list-item-scoped-and-supports-cross-repo-refs)). For each ref, call `gh issue view N --repo <repo> --json state` and require state `CLOSED` or `MERGED` ([INV-11](invariants.md#inv-11-dependency-state-includes-merged) — PRs report `MERGED`, not `CLOSED`; the same rule applies to cross-repo refs). If any dependency is still open, **skip silently** — no comment, no label change. The issue picks up next tick once dependencies clear.
166166
2. **Add `in-progress` label.**
167167
3. **Post dispatch token** ([INV-18](invariants.md#inv-18-cold-start-grace-period-before-stale-detection)): write `<!-- dispatcher-token: <id> at <iso> mode=dev-new -->` followed by the human-readable "Dispatching autonomous development..." line. The HTML comment encodes the dispatch timestamp for Step 5's grace-period check.
168168
4. **Dispatch**: `bash $PROJECT_DIR/scripts/dispatch-local.sh dev-new <issue>`

docs/pipeline/invariants.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,14 +175,14 @@ The cutoff timestamp falls back to epoch (1970-01-01) for issues that have never
175175

176176
## INV-11: Dependency state includes `MERGED`
177177

178-
**Rule**: Step 2's dependency check MUST treat both `CLOSED` and `MERGED` as resolved states. PRs return `MERGED` when merged, NOT `CLOSED`.
178+
**Rule**: Step 2's dependency check MUST treat both `CLOSED` and `MERGED` as resolved states. PRs return `MERGED` when merged, NOT `CLOSED`. The same `state ∉ {"CLOSED", "MERGED"}` rule applies to cross-repo refs resolved under [INV-39](#inv-39-dependency-parsing-is-list-item-scoped-and-supports-cross-repo-refs).
179179

180180
**Why**: When a `## Dependencies` section references a PR that has been merged, `gh issue view N --json state` returns `state: "MERGED"`. A naive `state != "CLOSED"` check leaves the dependent issue blocked forever (#61).
181181

182182
**Producer**: dispatcher Step 2 (`lib-dispatch.sh::check_deps_resolved`).
183183
**Consumer**: itself.
184-
**Status**: **ENFORCED** in PR-4 (closes #61). The check now reads `state ∉ {"CLOSED", "MERGED"}`.
185-
**Test**: `tests/unit/test-check-deps-resolved.sh` covers single-CLOSED, single-MERGED, single-OPEN, and multi-dep mixed-state scenarios.
184+
**Status**: **ENFORCED** in PR-4 (closes #61). The check reads `state ∉ {"CLOSED", "MERGED"}` for both same-repo and cross-repo refs.
185+
**Test**: `tests/unit/test-check-deps-resolved.sh` covers single-CLOSED, single-MERGED, single-OPEN, multi-dep mixed-state scenarios, and cross-repo MERGED/CLOSED/OPEN.
186186

187187
## INV-12: Resume only against unfinished sessions
188188

@@ -1018,6 +1018,36 @@ Where `<short-token>` is one of `bot-timeout`, `ci-transport`, `no-pr-found`, `m
10181018
- [INV-31](#inv-31-operator-tunable-per-cli-flags-live-in-conf-not-in-lib-agentsh) — operator-tunable flags live in `autonomous.conf`. The new vars follow that contract.
10191019
- [INV-13](#inv-13-wall-clock-cap-on-agent-invocations) — wall-clock cap. Unaffected: each side's launcher still runs inside `_run_with_timeout` exactly as before.
10201020

1021+
## INV-39: Dependency parsing is list-item scoped and supports cross-repo refs
1022+
1023+
**Rule**: Step 2's dependency check parses ONLY list-item lines (lines that start with `-`, `*`, or `1.` after optional leading whitespace) inside the `## Dependencies` section. Prose, blockquotes (`> ...`), and headings between `## Dependencies` and the next `## ` heading are ignored — they MUST NOT cause an issue to be blocked. On each list item, two ref shapes are recognized:
1024+
1025+
- `#N` — same-repo issue/PR, resolved against `$REPO`.
1026+
- `owner/repo#N` — cross-repo issue/PR, resolved against the named repo.
1027+
1028+
Both shapes require a left boundary (start-of-line, whitespace, or `(`) so URL fragments like `https://github.com/owner/repo/issues/123` and inline punctuation don't misparse, while parenthesized refs like `(owner/repo#42)` are still recognized. The longer `owner/repo#N` shape is always matched before bare `#N` so a single ref is never double-counted.
1029+
1030+
**Why**: The pre-#157 implementation greedy-extracted every `#NNN` substring between `## Dependencies` and the next `## ` heading via `grep -oE '#[0-9]+'`, then looked each one up in `$REPO`. Two related failure modes followed:
1031+
1032+
1. Cross-repo refs (`owner/repo#NNN`) yielded a bare `NNN` which got queried against the wrong repo. If no such issue existed there, `gh issue view` errored, the surrounding `|| true` swallowed the error, the empty `$state` failed the `!= "CLOSED"` check, and the issue was silently blocked forever.
1033+
2. The same greedy extraction picked up `#NNN` tokens inside prose, blockquotes, and inline-prose `owner/repo#NNN` mentions, producing identical silent blocks.
1034+
1035+
List-item-only scope eliminates the prose false positives. Explicit `owner/repo#N` syntax makes cross-repo dependencies a first-class case instead of a silent failure.
1036+
1037+
**Producer**: dispatcher Step 2 (`lib-dispatch.sh::check_deps_resolved`).
1038+
1039+
**Consumer**: itself.
1040+
1041+
**Lookup-failure semantics**: when `gh issue view` returns a non-zero exit (404 / network error / private repo the dispatcher token can't see), the resulting empty `$state` MUST be treated as fail-safe block AND emit a stderr warning naming the failed `<repo>#N`. The original #157 bug was the silent-block half of this rule; without the warning half, a typo in `owner/repo#N` would silently recreate the same bug class.
1042+
1043+
**Status**: **ENFORCED** in #157's fix. The function uses `grep -E '^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]'` to filter the `## Dependencies` section to list items, then bash regex `(^|[[:space:]\(])([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#([0-9]+)` followed by `(^|[[:space:]\(])#([0-9]+)` for stage-2 extraction with a match-and-strip loop. Empty-state lookups emit a `[check_deps_resolved] WARNING: lookup failed for ...` line on stderr and return 1.
1044+
1045+
**Test**: `tests/unit/test-check-deps-resolved.sh` covers cross-repo CLOSED/MERGED/OPEN, same-repo + cross-repo mixed, prose-embedded refs (must not block), blockquote refs (must not block), URL-fragment refs (must not block), and lookup-failure warning (cross-repo ref to a non-existent repo blocks AND prints the warning). The mock `gh` keys state lookups on `<repo>:<num>` so the same number resolves to different states in different repos.
1046+
1047+
**Cross-references**:
1048+
- [INV-11](#inv-11-dependency-state-includes-merged)`state ∉ {CLOSED, MERGED}` rule applies to both same-repo and cross-repo refs.
1049+
- The `create-issue` skill's `## Dependencies` guidance documents the user-facing parsing rules (`skills/create-issue/SKILL.md`, `skills/create-issue/references/issue-templates.md`).
1050+
10211051
## Adding a new invariant
10221052

10231053
When fixing a pipeline bug, after locating the bug on the state machine + flow docs:
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Cross-repo dependency support in `check_deps_resolved`
2+
3+
**Issue**: #157
4+
**Author**: zxkane
5+
**Date**: 2026-05-27
6+
**Status**: approved
7+
8+
## Problem
9+
10+
`check_deps_resolved` (`skills/autonomous-dispatcher/scripts/lib-dispatch.sh:286`) parses the `## Dependencies` section of an issue body by greedy-extracting every `#NNN` substring between the `## Dependencies` heading and the next `## ` heading, then looking each one up in the **current repo** via `gh issue view N --repo "$REPO"`.
11+
12+
Two failure modes follow from this:
13+
14+
1. **Cross-repo refs are silently treated as unresolved.** `owner/repo#NNN` in the section yields a bare `NNN`, queried against `$REPO`. If no such issue exists locally, `gh issue view` returns non-zero, the surrounding `|| true` swallows the error, `$state` is empty, and `[ "$state" != "CLOSED" ]` is true → the issue is **blocked forever**, with no log line and no comment to surface the cause.
15+
2. **Prose/blockquote false positives.** The regex `grep -oE '#[0-9]+'` matches every `#NNN` substring inside the line range, including ones inside blockquotes (`> requires owner/repo#4470 ...`), inline code (`` `#42` ``), and free-form prose. These leak into the loop and trigger the same silent-block failure if the number doesn't exist in `$REPO`.
16+
17+
The function preserves [INV-11](../../pipeline/invariants.md#inv-11-dependency-state-includes-merged) (MERGED counts as resolved) and the portable extraction fix from PR-4. Both must remain green after this change.
18+
19+
## Goals
20+
21+
- Support `owner/repo#NNN` as a first-class dependency specifier.
22+
- Eliminate prose/blockquote false positives (Option B from #157).
23+
- Preserve INV-11 and #73 portability fixes.
24+
- No behavior change for issues that contain only bare `#NNN` list items.
25+
26+
## Non-goals
27+
28+
- URL-form refs (`https://github.com/owner/repo/issues/N`) — not in #157's acceptance criteria.
29+
- GitLab / Bitbucket cross-host refs.
30+
- Inline-code stripping (` `#42` ` on a list-item line is still extracted) — acceptable per #157 acceptance criteria; can be added later if needed.
31+
- Migration of existing issue bodies — old prose-style refs simply stop being parsed, which is the desired outcome.
32+
33+
## Design
34+
35+
### Parser rewrite (`check_deps_resolved`)
36+
37+
Replace the current pipeline with a two-stage parse:
38+
39+
**Stage 1 — restrict scope to list items.** Use `grep -E '^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]'` to filter the `## Dependencies` section down to lines beginning with a markdown list marker. This drops blockquotes (`> ...`), prose paragraphs, and headings.
40+
41+
**Stage 2 — extract refs with priority.** Per list-item line, scan twice:
42+
43+
1. First, match `owner/repo#NNN` — bash regex `(^|[[:space:]\(])([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#([0-9]+)`. The leading anchor (start-of-line, whitespace, or `(`) prevents matching inside larger tokens like `https://github.com/owner/repo#NNN`. Each hit fires `gh issue view NNN --repo owner/repo`. Strip the matched substring from the line before continuing.
44+
2. Then, match bare `#NNN` — bash regex `(^|[[:space:]\(])#([0-9]+)`. Each hit fires `gh issue view NNN --repo "$REPO"` (existing same-repo behavior).
45+
46+
State check is unchanged: `state ∉ {CLOSED, MERGED}` returns 1.
47+
48+
Stripping the matched substring after each hit is what makes "match owner/repo#N first, then bare #N on the residue" work correctly — `owner/repo#42` no longer survives stage-1 to be re-extracted as `#42` in stage 2.
49+
50+
### Tests (`tests/unit/test-check-deps-resolved.sh`)
51+
52+
Mock-`gh` extension: state lookups currently key on issue number. They need to key on `repo:num` so the same number in two different repos resolves independently.
53+
54+
```bash
55+
gh() {
56+
local mode="" issue_num="" repo=""
57+
while [[ $# -gt 0 ]]; do
58+
case "$1" in
59+
view) issue_num="$2"; shift 2 ;;
60+
--repo) repo="$2"; shift 2 ;;
61+
--json) [[ "$2" == body ]] && mode=body; [[ "$2" == state ]] && mode=state; shift 2 ;;
62+
*) shift ;;
63+
esac
64+
done
65+
case "$mode" in
66+
body) printf '%s' "$_MOCK_BODY" ;;
67+
state) printf '%s' "${_MOCK_STATES[${repo}:${issue_num}]:-OPEN}" ;;
68+
esac
69+
}
70+
```
71+
72+
Existing fixtures migrate to `_MOCK_STATES[zxkane/autonomous-dev-team:42]="CLOSED"` etc. — mechanical change.
73+
74+
New cases (numbered to match the acceptance criteria in #157):
75+
76+
1. **Cross-repo dep, list item, CLOSED in remote** → unblocked.
77+
2. **Cross-repo dep, list item, OPEN in remote** → blocked.
78+
3. **Same-repo `#NNN` embedded in prose between headings** → unblocked (was blocked, the regression #157 is about).
79+
4. **Blockquote `> requires owner/repo#4470 to be merged`** → unblocked.
80+
5. **Mixed list: same-repo `#42` (CLOSED) + cross-repo `owner/repo#7` (OPEN)** → blocked.
81+
6. **Inline-code on a list item: `- waiting on \`#99\` ` ** — extracted (documented limitation; not part of #157 acceptance, but worth a regression note).
82+
83+
The five existing test cases (no-section / single-CLOSED / single-MERGED / single-OPEN / multi-mixed / portable-extract `#73`) continue to pass.
84+
85+
### Pipeline doc updates
86+
87+
Per the project's "Pipeline Documentation Authority" rule, this PR also updates:
88+
89+
1. **`docs/pipeline/invariants.md`** — extend INV-11's status block to mention "extraction is restricted to list items; cross-repo `owner/repo#N` resolves against the named repo." A new invariant (INV-NN) is unnecessary; the parsing rule is a refinement of the same dependency-resolution invariant.
90+
2. **`docs/pipeline/dispatcher-flow.md:165`** — change "Extract every `#N` reference" to "Extract `#N` and `owner/repo#N` references from list items only (prose and blockquotes are ignored)."
91+
92+
## Risks
93+
94+
| Risk | Mitigation |
95+
|---|---|
96+
| Bash 3.2 (macOS default) regex semantics differ from 5.x | Patterns use only POSIX char classes, no backreferences. Verify on macOS as part of test run. |
97+
| Existing issue bodies relying on prose extraction | None known; quick `gh issue list --search 'in:body'` audit on `zxkane/autonomous-dev-team`, `zxkane/podcast-curation`, `zxkane/Panoptes`, `zxkane/VidSyllabus`, `zxkane/llm-wiki`, `zxkane/voicebiz-editorial` to confirm no live issue would change blocked-state. |
98+
| `gh issue view --repo owner/repo` for a private repo the dispatcher token can't see | Returns non-zero like the same-repo case today. The current `|| true`-based silent-block is preserved for unknown / unauthorized — this is no worse than today. Future hardening could surface a comment, but is out-of-scope. |
99+
100+
## Acceptance criteria (verbatim from #157)
101+
102+
- [x] A same-repo dep on a list item (`- #NNN`) is checked in `$REPO` as before.
103+
- [x] A cross-repo dep on a list item (`- owner/repo#NNN`) is checked in `owner/repo`.
104+
- [x] A `#NNN` reference embedded in prose, blockquotes, or inline code between `## Dependencies` and the next heading does not cause a false block.
105+
- [x] `None` (or empty section with no list items) returns 0.
106+
- [x] Unit tests cover same-repo CLOSED/OPEN, cross-repo, prose-embedded, empty.
107+
108+
(Inline code in a *list item* is the one carve-out, called out in Non-goals.)

skills/autonomous-dispatcher/scripts/lib-dispatch.sh

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -280,28 +280,69 @@ run_hygiene_pass() {
280280
# Returns 1 (blocked) on the first unresolved dependency. Returns 0 if no
281281
# dependencies are listed.
282282
#
283-
# Closes #61 (MERGED PRs report `state: "MERGED"`, not `"CLOSED"`) and
284-
# #73 (replace GNU-only `grep -oP '#\K[0-9]+'` with portable extraction).
285-
# Both fixes are in the same function and ship together.
283+
# Parsing rules (see INV-11 in docs/pipeline/invariants.md):
284+
# - Only list-item lines (`-`, `*`, or `1.` markers) inside the
285+
# `## Dependencies` section are scanned. Prose, blockquotes, and
286+
# headings are ignored — this is what stops false positives where a
287+
# `#NNN` mentioned in passing got greedy-extracted (#157).
288+
# - Two ref shapes are recognized, longest first per line:
289+
# * `owner/repo#N` → resolved against the named repo
290+
# * `#N` → resolved against $REPO (same-repo)
291+
# - Both shapes require a left boundary (start-of-line or whitespace) so
292+
# URL fragments (`https://github.com/.../issues/123`) and inline
293+
# punctuation aren't misparsed.
294+
#
295+
# Closes #61 (MERGED PRs report `state: "MERGED"`, not `"CLOSED"`),
296+
# #73 (replace GNU-only `grep -oP '#\K[0-9]+'` with portable extraction),
297+
# and #157 (cross-repo refs + list-only scope).
286298
check_deps_resolved() {
287299
local issue_num="$1"
288-
local deps state
289-
# Portable dep-number extraction: grep -oE matches `#NNN`, sed strips
290-
# the leading `#`. Equivalent to the GNU-only `grep -oP '#\K[0-9]+'`
291-
# but works on macOS / BSD grep too.
292-
deps=$(gh issue view "$issue_num" --repo "$REPO" --json body -q '.body' \
293-
| sed -n '/^## Dependencies/,/^## /p' \
294-
| grep -oE '#[0-9]+' \
295-
| sed 's/^#//' || true)
296-
297-
for dep in $deps; do
298-
state=$(gh issue view "$dep" --repo "$REPO" --json state -q '.state')
299-
# Both CLOSED (issues, closed PRs) and MERGED (merged PRs) count as
300-
# resolved. `gh issue view` on a merged PR returns state "MERGED".
301-
if [ "$state" != "CLOSED" ] && [ "$state" != "MERGED" ]; then
302-
return 1
303-
fi
304-
done
300+
local body section line state dep_repo dep_num matched
301+
body=$(gh issue view "$issue_num" --repo "$REPO" --json body -q '.body')
302+
section=$(printf '%s\n' "$body" | sed -n '/^## Dependencies/,/^## /p')
303+
304+
# Stage 1: restrict to list-item lines. `grep -E` exits non-zero when
305+
# nothing matches; the trailing `|| true` keeps the pipeline alive so
306+
# the while loop simply runs zero times and we fall through to rc=0.
307+
while IFS= read -r line; do
308+
# Stage 2a: cross-repo `owner/repo#N`. Matched longest-first so that
309+
# `owner/repo#42` doesn't survive to be re-parsed as bare `#42`. The
310+
# left boundary `(^|[[:space:]\(])` rules out URL fragments and inline
311+
# punctuation while still allowing parenthesized refs like
312+
# `- (owner/repo#42)`.
313+
while [[ "$line" =~ (^|[[:space:]\(])([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#([0-9]+) ]]; do
314+
matched="${BASH_REMATCH[0]}"
315+
dep_repo="${BASH_REMATCH[2]}"
316+
dep_num="${BASH_REMATCH[3]}"
317+
state=$(gh issue view "$dep_num" --repo "$dep_repo" --json state -q '.state' 2>/dev/null || true)
318+
# Empty state means the lookup failed (404, network error, private
319+
# repo). [INV-39]: fail-safe blocks dispatch, but the failure must
320+
# be observable — otherwise we silently recreate the #157 bug class.
321+
if [ -z "$state" ]; then
322+
echo "[check_deps_resolved] WARNING: lookup failed for ${dep_repo}#${dep_num} (issue ${issue_num}); blocking" >&2
323+
return 1
324+
fi
325+
if [ "$state" != "CLOSED" ] && [ "$state" != "MERGED" ]; then
326+
return 1
327+
fi
328+
line="${line/"$matched"/ }"
329+
done
330+
# Stage 2b: bare `#N` on the residue. Same-repo lookup against $REPO.
331+
while [[ "$line" =~ (^|[[:space:]\(])#([0-9]+) ]]; do
332+
matched="${BASH_REMATCH[0]}"
333+
dep_num="${BASH_REMATCH[2]}"
334+
state=$(gh issue view "$dep_num" --repo "$REPO" --json state -q '.state' 2>/dev/null || true)
335+
if [ -z "$state" ]; then
336+
echo "[check_deps_resolved] WARNING: lookup failed for ${REPO}#${dep_num} (issue ${issue_num}); blocking" >&2
337+
return 1
338+
fi
339+
if [ "$state" != "CLOSED" ] && [ "$state" != "MERGED" ]; then
340+
return 1
341+
fi
342+
line="${line/"$matched"/ }"
343+
done
344+
done < <(printf '%s\n' "$section" | grep -E '^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]' || true)
345+
305346
return 0
306347
}
307348

0 commit comments

Comments
 (0)