Polling a feature done-signal in an agent loop

Gate an agent loop on one feature’s completion – not the whole project’s – using ctxgrd status --lineage and --exit-code.

Background

ctxgrd pools documents by type: all ADRs share docs/adrs/, all SPECs share docs/specs/, and so on. A repo routinely holds several in-flight features at once. Running ctxgrd status --exit-code on the whole project asks “is anything in the entire repo stuck?” – which is almost never the right question for an agent loop driving a single feature.

--lineage <ID> changes the scope. It follows the depends_on graph in reverse: every document that transitively depends on <ID>, plus <ID> itself. That set is one feature’s lineage. Combined with --exit-code, it turns “is anything in this feature stuck?” into a plain exit code – no text-scraping needed. stdout stays clean for piping; the exit code carries the decision.

Step 1 – Identify the lineage root

Pick the document at the root of your feature’s depends_on graph. For a feature driven by a PRD, that is usually the PRD:

ctxgrd status --lineage PRD-3 --exit-code

A document depended on by more than one lineage root is shared. status names the other root rather than silently folding the document into just one lineage: shared with: <ID> in text mode, a top-level "shared": ["<ID>"] array in JSON. The field is present only when the lineage actually shares a document with another root. A shared document’s other parents do not need to be done for your lineage’s exit code to be 0.

Step 2 – Understand what --exit-code checks

ctxgrd status --lineage PRD-3 --exit-code
echo "exit=$?"
CodeMeaning
0No document in scope is blocked by a non-terminal dependency – nothing is stuck.
1At least one document in scope is itself non-terminal and depends on a non-terminal document.
2Config error or dependency cycle – abort.

Read 0 carefully – it does not mean “every document reached a terminal status.” A document can sit at a non-terminal status (draft, open, in_progress) with nothing holding it back. That document is ready, not blocked, and it does not trip the exit code. --exit-code answers “is anything stuck on an unresolved dependency,” not “has the whole feature landed.” If your lineage’s last step still needs someone to flip its own status to terminal, exit 0 will not tell you that happened – check the document’s status directly in the JSON output (Step 4).

A terminal document that still depends on a non-terminal one is not “stuck” either – it is finished, and finished work does not trip the signal. The report still names the edge instead of dropping it:

settled on open work:
  ADR-001 ← ADR-002
  (enable core.dep-status to gate this)

Whether that edge is itself a defect is a separate, opt-in question: enable core.dep-status on the namespace if a finished document depending on open work should fail your lint, not your done-signal.

The report body is still printed to stdout regardless of which exit code is returned; no file is modified.

Step 3 – Wire the poll loop

The canonical shell loop for an agent driving PRD-3:

while true; do
  ctxgrd status --lineage PRD-3 --exit-code
  code=$?
  if [ "$code" -eq 0 ]; then
    echo "Nothing stuck -- stopping loop."
    break
  elif [ "$code" -eq 2 ]; then
    echo "Config or cycle error -- aborting." >&2
    exit 2
  fi
  # code is 1: something in scope is stuck on a non-terminal dependency;
  # do the next unit of work, then poll again
  sleep 30
done

Exit 0 stops the loop. Exit 1 means something is stuck; the agent does the next unit of work and polls again. Exit 2 is a hard failure the agent cannot recover from by itself.

Step 4 – Route on the JSON output for finer decisions

When the loop needs more than a binary branch – for example, to see exactly which document is the current blocker – request structured output:

ctxgrd status --lineage PRD-3 --format json

Key fields:

  • documents – every document in the lineage. Each entry carries id, namespace, status, ready (true when the document is itself non-terminal and nothing blocks it), and blocked_by (the non-terminal dependencies holding it back; empty once they land).
  • shared – present only when this lineage shares a document with another lineage root; names the other root’s id(s) (ADR-059 § LIN-005).

stdout is a clean JSON stream; pipe it directly to jq. Find whatever is currently blocking the lineage:

ctxgrd status --lineage PRD-3 --format json | jq '.documents[] | select(.blocked_by != [])'
{
  "id": "TASK-1",
  "namespace": "TASK",
  "status": "open",
  "ready": false,
  "blocked_by": [
    "SPEC-9"
  ]
}

Step 5 – Fold Definition-of-Done checkboxes into the check (optional)

core.acceptance-complete is an opt-in builtin rule that fires on any document at a terminal status that still has an unchecked - [ ] item under its acceptance heading(s). It is a lint diagnostic – ctxgrd lint reports it – and is independent of status --exit-code: a document flagged by this rule can still show up as ready or settled in status. Run both checks if unchecked boxes should also hold up your loop.

Enable it per namespace in ctxgrd.toml:

[TASK."core.acceptance-complete"]
headings = ["Acceptance", "Definition of Done"]
terminal = ["done"]

headings defaults to ["Acceptance", "Definition of Done"]. terminal defaults to the shared terminal-status set (accepted, superseded, done, fixed, wontfix, invalid, duplicate, closed, implemented, consumed, n/a); supply a narrower list to restrict the rule to specific statuses.

The rule scans only the configured heading window(s). An open checkbox under Out of scope or Open Questions does not fire – those sections are deferred work, not unmet criteria.

Example. A TASK at status: done with this body:

## Acceptance

- [x] Endpoint returns 200 for valid input
- [ ] Rate-limit header present in response   ← fires core.acceptance-complete

## Open Questions

- [ ] Should we log 429s to Datadog?           ← does not fire

The open box under Acceptance holds up the rule. After the box is checked and ctxgrd is re-run, the diagnostic clears.

Cross-references

  • SPEC-003 (docs/specs/003-status-done-gate-and-per-lineage-scope.md) – original requirements for the done-signal and lineage scope.
  • ADR-056 – --exit-code and core.acceptance-complete design.
  • ADR-059 – --lineage graph-scope design, including shared-node disclosure.
  • ADR-118 – removed the namespace stage layer; readiness and --exit-code are computed from the depends_on graph directly.
  • ADR-106 – core.dep-status, the opt-in rule for a terminal document depending on open work.