---
name: sindri-loop
description: The sindri orchestrator. Runs one experiment per invocation. Reads state, checks the HALT sentinel and termination predicates, picks next candidate, dispatches an experiment subagent via the Task tool, acts on the result (commit or reset), records to state, and schedules the next wakeup — unless terminated. Invoked automatically via ScheduleWakeup during an active run. A single manual tick is available via /sindri:loop (advanced/debug); otherwise do not invoke directly.
tools: Bash, Read, Edit, Task
---

# sindri-loop — per-wakeup orchestrator

You run **once per wakeup**. One invocation = one experiment end-to-end. You are deliberately thin: every deterministic step goes through the backend wrapper `"$FORGE" <cmd>` (resolve `FORGE` to this plugin's `scripts/forge.sh` — the skill lives at `<plugin-root>/skills/sindri-loop/SKILL.md`, so the wrapper is `<plugin-root>/scripts/forge.sh`), every reasoning step goes to a fresh subagent via `Task`. Your own context stays tiny so autocompaction is a non-issue.

**Invariant: every wakeup is identical in structure.** No state persists in your head — it all lives on disk.

## Steps (strict order, no skipping)

### 1. Read state

```bash
"$FORGE" read-state
```

Parse the JSON. It includes: `pool`, `baseline`, `current_best`, `branch`, `mode`, `guardrails`, `started_at`.

### 2. Termination check

Before anything else, check for the user-halt sentinel:

```bash
if [ -f .sindri/current/HALT ]; then
  # User invoked /sindri:stop. Terminate with reason halted_by_user.
  # auto_finalize is true iff at least one kept commit exists.
fi
```

If the sentinel exists:

1. Count kept commits on the branch (`git log --oneline --grep "^kept:" | wc -l`).
2. Append a terminated record to `sindri.jsonl`: `{"type":"terminated","reason":"halted_by_user","auto_finalize": <kept > 0>, ...}`.
3. If `kept > 0`, invoke `sindri-finalize`; otherwise announce to chat and stop.
4. `rm .sindri/current/HALT` (clean up after consuming).
5. **Do NOT call `ScheduleWakeup`.** Return.

Otherwise, compute counters from state + jsonl:

- `experiments_run`: count of `type == "experiment"` records in `sindri.jsonl`.
- `consecutive_reverts`: starting from the tail of `sindri.jsonl`, count experiments with `status in {"regressed", "inconclusive", "check_failed", "errored", "timeout"}` until you hit a `status == "improved"` or run out. (`check_failed` is a non-improvement like the rest — a pool that systematically fails its checks must trip `max_reverts_in_a_row` rather than burning the whole pool.)

Pipe these to `check-termination` on stdin:

```bash
echo '{"experiments_run": <N>, "consecutive_reverts": <K>}' | "$FORGE" check-termination
```

Returns JSON: `{"terminated": bool, "reason": str | null, "auto_finalize": bool}`.

If `terminated`:

1. Append a terminated record to `sindri.jsonl` (via direct shell append, since Plan 1's record-result requires a candidate_id).
2. If `auto_finalize` is `true` AND `kept >= 1`, invoke skill `sindri-finalize`.
3. Otherwise print a human-readable summary:
   - `reason == "pool_empty"` + kept == 0 → *"sindri: terminated (pool exhausted). 0 kept experiments out of <N> tried. No PR created. See `.sindri/current/`."*
   - Structural halts (`max_reverts_in_a_row`, `max_experiments`) → surface the halt reason and tell the user: *"State preserved in `.sindri/current/`. Fix the underlying issue and re-run `/sindri:forge`, or `/sindri:clear` to abandon."*
4. **Do NOT call ScheduleWakeup.** The loop ends here.
5. Return.

### 3. Pre-flight git check

```bash
git status --porcelain
```

If output is non-empty (dirty tree from a previous interrupted experiment):

```bash
git reset --hard HEAD
```

This is a belt-and-suspenders recovery — kept commits survive (they're in HEAD); only unstaged/uncommitted changes are discarded.

### 4. Pick next candidate

```bash
"$FORGE" pick-next
```

Returns JSON describing the next candidate: `{"id": int, "name": str, "expected_impact_pct": float, "status": "pending", "files": [...], "notes": null}`. If `null`, re-run step 2 — state should already have told you the pool is empty, so this path is defensive.

### 5. Dispatch the experiment subagent

Load the template at `prompts/experiment-subagent.md` (relative to the plugin install path). Fill in the placeholders from the candidate + state:

- `$CANDIDATE` — the candidate JSON from step 4.
- `$BENCHMARK_CMD` — `.claude/scripts/sindri/benchmark.py` (or whatever the benchmark path is in state).
- `$CHECKS_CMD` — `.claude/scripts/sindri/checks.py` or empty if missing.
- `$METRIC` — `{"name": ..., "direction": ...}` from state.
- `$CURRENT_BEST` — state.current_best if non-null, else state.baseline.value.
- `$NOISE_FLOOR` — from state.baseline.
- `$MODE` — `"local"` or `"remote"`.
- `$REPS_POLICY` — from state.guardrails.reps_policy.
- `$TIMEOUT_SECONDS` — from state.guardrails.timeout_per_experiment_seconds.

Invoke via `Task` tool:

```
Task(
  subagent_type="general-purpose",
  description="sindri experiment: <candidate name>",
  prompt=<filled template>
)
```

### 6. Parse the subagent result

The subagent returns JSON on stdout matching `src/sindri/core/validators.py::SubagentResult`:

```json
{
  "metric_value": 719218,
  "reps_used": 3,
  "confidence_ratio": 4.2,
  "status": "improved",
  "files_modified": ["package.json", "..."],
  "notes": "..."
}
```

If the subagent's output doesn't parse as JSON or fails schema validation, treat it as `status: errored` (synthesize a minimal SubagentResult with `status: "errored"`, `metric_value: 0`, etc.) and continue with the revert path.

### 7. Act on the result

`metric_before` is `state.current_best` if set, else `state.baseline.value`.

| `status` | Git action | Pool status (handled by record-result) |
|---|---|---|
| `improved` | `git add -A && git commit -m "kept: <candidate name> (Δ<delta>)"` — capture the new SHA | `kept` |
| `regressed` | `git reset --hard HEAD` | `reverted` |
| `inconclusive` | `git reset --hard HEAD` | `reverted` (low-confidence = no keep) |
| `check_failed` | `git reset --hard HEAD` | `check_failed` (no retry) |
| `errored` | `git reset --hard HEAD` | `errored` |
| `timeout` | `git reset --hard HEAD` | `errored` (tracked separately for consecutive-timeout halt) |

Commit message format: `kept: <candidate name> (Δ<signed delta>)`, e.g., `kept: Replace moment with date-fns (Δ-82000)`.

### 8. Record the result

Compose the RecordPayload and pipe to `record-result`:

```json
{
  "candidate_id": <int, from step 4>,
  "metric_before": <float, from state.current_best or state.baseline.value>,
  "subagent_result": <subagent JSON verbatim from step 6>,
  "commit_sha": "<new SHA if status == improved, else null>"
}
```

```bash
echo "$PAYLOAD" | "$FORGE" record-result
```

`record-result` atomically:

- Updates the candidate's pool status in `sindri.md`.
- Sets `current_best` to the new metric value on `improved`.
- Appends a `type: "experiment"` record to `sindri.jsonl` with full `metric_before`, `metric_after`, `delta`, `confidence_ratio`, `status`, `commit_sha`, `files_modified`.
- Returns `{"ok": true, "new_status": "...", "current_best": ...}` on stdout.

If record-result exits non-zero, surface the stderr and halt — writing state is the one operation that must never silently fail.

### 9. Structural halt check

Re-compute `experiments_run` and `consecutive_reverts` (now including this experiment) and re-pipe them into `check-termination`:

```bash
echo '{"experiments_run": <N+1>, "consecutive_reverts": <updated K>}' | "$FORGE" check-termination
```

Structural halts may now fire:

- `max_reverts_in_a_row` hit → halt.
- `max_experiments` exceeded → halt with reason `max_experiments`.
- `target_hit` reached (current_best beats target) → halt + `auto_finalize: true`.

If `terminated`: jump back to step 2's handling (surface → no wakeup).

### 10. Schedule the next wakeup

```
ScheduleWakeup(
  delay=60,
  prompt="Resume sindri-loop orchestrator. Read state from .sindri/current/ and run one wakeup cycle."
)
```

`ScheduleWakeup` runs only on this autonomous path. A manual `/sindri:loop` tick runs steps 1–9 and **stops before this step**, so it never creates a wakeup competing with the already-pending auto-loop.

Return. You are done. The next wakeup starts fresh at step 1.

## Anti-patterns

- **Never modify files in the repo directly** outside of git commit/reset. The subagent applies the candidate; you only keep or discard.
- **Never maintain state in your own prompt context.** Every wakeup starts by reading disk.
- **Never skip the pre-flight git check** — a dirty tree from an interrupted run poisons the next experiment.
- **Never finalize unless `auto_finalize: true`.** Some terminations (e.g., `halted_by_user` with 0 kept) should not produce a PR.
- **Never call `ScheduleWakeup` on terminated runs.** That's a bug; the loop must end.
- **Never retry `check_failed`.** Tests are deterministic; retrying is theater.
