Repo profile — read
.claude/repo-profile.mdfirst. This skill is repo-agnostic; arc is the reference implementation. It resolves the repo from the git remote (repo_slug); its runtime scripts are referenced as<plugin_root>/skills/issue-graph/scripts/*.ts(the profile'splugin_root— so they resolve wherever the plugin is checked out). Arc's own issue-number provenance for any lessons is not inlined here.
"Both selection and propagation are pure computation; the LLM is only responsible for doing, not for guessing who should do it."
The GitHub native graph (sub-issues + issue dependencies) is thesingle source of truth; this skill provides four deterministic scripts that turn "which issues can be done now / which parent issues should be wrapped up / which issues are unlocked by a given closure" from model guessing into graph computation calculated fresh each round. The graphis not persisted to disk—the only persistent state is GitHub itself, so there is never index drift.
Design sources, Phase 0 rehearsal evidence (149 edges, 17 rollup candidates, fencing measured), and the full concurrency design are in the arc case-law appendix (repo-profile Case Law).
Scripts (scripts/, all REST-only + manual pagination)
Why REST-only: the cloud routine's egress proxy blocks
gh's GraphQL (403 "not enabled"),--paginatealso has a proxy bug for the issues endpoint (root CLAUDE.md "Install CLI dependencies on demand" section). All scripts manually looppage=Nand behave identically locally and in the cloud routine.
graph-scan.ts — graph computation (read-only, called by sweep each round)
bun <plugin_root>/skills/issue-graph/scripts/graph-scan.ts [--window-hours 2] [--pretty]
Output JSON:
| Field | Semantics | How consumers use it |
|---|---|---|
ready |
open ∧ no open blocker;order already rotated by hostname(multi-machine staggering) | Merge into the sweep candidate set;hasHold=true handled per hold semantics |
blocked |
issues with an open blocker + blocker list | Deterministic SKIP(with reason), no longer relying on the model to guess "has its turn come or not" |
rollupCandidates |
open ∧ has children ∧ all children closed | Goes through issue-review's parent rollup (fencing mutual exclusion) |
kicks |
open parent / unlocked dependent reverse-looked-up from issues closed in the recent window | Inject directly into the candidate set without needing a human comment—this is the mechanism that fixes "a human bump is needed after a child issue completes" |
Window defaults to 2h > sweep interval 1h: it's fine if the same closure event is seen by two rounds; kick only injects candidates, and afterward everything uniformly goes through terminal-comment deduplication + lock + claim check, so duplicate injection costs nothing.
link.ts — write edges (idempotent, must be called when opening a derived issue)
bun <plugin_root>/skills/issue-graph/scripts/link.ts --parent <N> --child <M> # 父子边
bun <plugin_root>/skills/issue-graph/scripts/link.ts --issue <Y> --blocked-by <X> # 依赖边
Edge-writing discipline (the source of graph precision): every time an agent opens a spin-off / derived issue, besides the <!-- spinoff-of: #N --> marker (kept for provenance),it mustalso call link.ts
to attach a native parent-child edge; for phases with a hard ordering between them, add --blocked-by. If the edge already exists = idempotent OK; if the child already has adifferent parent = error out and stop (changing the parent is a human decision). Issues opened by hand
without edges are also fine—isolated points go through the existing label/catch-all channel; the graph only augments, never replaces.
claim.ts — claim-comment fencing (mutual exclusion for terminal actions)
bun <plugin_root>/skills/issue-graph/scripts/claim.ts --issue <N> --action rollup # exit 0=赢 / 3=输
bun <plugin_root>/skills/issue-graph/scripts/claim.ts --release <claimId> # 完成后必调
Why it exists: label addition has no CAS,agent:processing is advisory (two machines can both acquire it within seconds);
work that produces a PR has deterministic branch-collision fallback, but comment + close-type terminal actions (rollup) have
no hard fallback at all. The comment stream is GitHub's only append-only totally-ordered primitive (comment ids are monotonic),
write-then-read, earliest unexpired claim wins—two machines compute the same winner (measured: two concurrent
claimers, ids differing by only 1, still exactly one win and one loss, the loser self-deletes its claim).
Three disciplines for the caller: ① before claiming, first check whether the action has already been done (rollup marker / issue already closed);
② only act after winning, and right before acting re-read the target state one last time; ③ after completing, --release(crashes are covered by the TTL
30min expiry fallback).
backfill.ts — migration of existing markers (one-off, idempotent and rerunnable)
bun <plugin_root>/skills/issue-graph/scripts/backfill.ts # dry-run
bun <plugin_root>/skills/issue-graph/scripts/backfill.ts --execute # 真写(1 边/秒限速)
Migrate the existing spinoff-of markers into native edges. Conflicts (child already has a different parent), missing parent,
and the 100-children-per-single-parent limit are all skipped and reported, never force-written.
Concurrency design (multi-machine parallelism to prevent fights)
Deterministic selection wouldexacerbatecollisions (each machine computes the same ready set + the same ordering, cron starts in the same minute). Four countermeasures, each handling one aspect:
- Membership is deterministic, processing order is randomized—the ready set is a set, not a queue;
graph-scanthe output order is already rotated by hostname, turning lock contention from "inevitable" to "rare". - Work that produces PRs: the existing two-layer fallback is unchanged (
agent:processingadvisory lock early short-circuit + deterministic branchclaude/issue-<N>+ claim check hard dedup before opening a PR). - Terminal actions with no branch fallback(rollup's comment+close):
claim.tsfencing。 - Everything else is idempotent: writing an edge again = no-op; overlapping kick windows = duplicate injection at zero cost.
Queue / producer(Phase 4)
producer.ts — periodic graph computation + label reconciliation (runs on schedule)
bun <plugin_root>/skills/issue-graph/scripts/producer.ts [--window-hours 2] [--dry-run]
Ironclad rule of responsibility: only compute the graph + reconcile the index, do not execute any substantive work. If the producer is down, consumers degrade back to
running graph-scanon their own, with no single point of failure.
Label semantics (anti label-spam: never label every "not blocked" issue—that would make the queue view lose its signal):
| label | Meaning | Who adds | Who removes |
|---|---|---|---|
agent:ready |
Discovered by graph computation:actionable events(close-kick targets ∪ rollup candidates, excluding hold) | producer | Consumer removes after processing; the producer only cleans up invalidated ones (hold / became blocked)—a kick is a transient event, the window passing does not mean the thing is gone, and the producer does not remove it because it is "not in this round's computation" |
agent:blocked |
Has an open blocker (the deterministic SKIP person's visible view, sparse) | producer | producer (state, not event, strict full reconciliation) |
Anti-drift ironclad rule
queue/label are only index hints, never the basis for execution—a worker that receives a task must go back to GitHub and re-verify (still open, still ready, no hold, no new human input). Hold this line and the queue is a cache accelerator; lose it and you have just built another copy that will drift. After scale increases, claiming migrates to the AFS scheduler claim/lease queue (Phase 5, deferred), with labels retained as a human-readable view.
Consumers
| skill | Integration point |
|---|---|
issue-sweep |
Run before Step 1 graph-scan: kicks/rollupCandidates inject the candidate set, blocked deterministic SKIP |
issue-review |
When opening a spin-off, call link.ts(edge-writing discipline); the parent rollup's terminal action (claim.ts fencing) |
| producer routine(Phase 4) | Scheduled graph-scan + reconciliation agent:ready label, index only without doing work |