sonichi / proactive-loop
Start Sutando's autonomous proactive loop. Monitors tasks, runs health checks, and builds missing capabilities on a recurring schedule.
Proactive Loop
One pass = the numbered steps below, in order. Each step is a command, its exit codes, and what to
do per code. The reasoning, measurements and incidents behind every step live in
[`docs/proactive-loop-rationale.md`](../../docs/proactive-loop-rationale.md) under the same step
number; read it when a step surprises you, never per pass. A rule belongs here only as a command a
mechanism enforces; prose lessons go to the rationale doc (tests/proactive-loop-skill-budget.test.py
caps this file and refuses date stamps in it).
Usage: /proactive-loop [interval] (default 10m).
On activation
/schedule-crons— registers the session crons and stamps them.Task watcher via the
Monitortool:command: 'bash src/watch-tasks-stream.sh --role session --inbox "$(bash scripts/sutando-config.sh workspace)/tasks"'
(substitute$SUTANDO_TASKS_DIRfor the inbox when it is set — the same tag step 9's re-arm uses),timeout_ms: 1800000,description: 'Streaming task watcher'. EachTASK_FILE: <name>line is one task to Read and process.
Re-arm it on the expiry notice and on any notice that it exited early (on macOS Claude Code reports a
SIGTERM death asexit code 144); the inbox is unwatched until the standby's grace elapses.Monitorhas nopersistentoption: it kills the command attimeout_ms(30 min is the maximum) and
sends one expiry notice. On that notice, re-arm the watcher the same way (a start on a watched inbox
exits 0); until you do, the external standby announces tasks through the pane after a 45 s grace.
Windows has noMonitortool:src/startup.ps1ownssrc/task-dispatcher.ps1; do not start another watcher.If
CronListalready shows amain-loop//proactive-loopjob, run the per-pass body directly —
never add a second loop driver.
Per pass
WORKSPACE="$(bash scripts/sutando-config.sh workspace)" once; quote every "$WORKSPACE/..." path.
Status.
bash scripts/core-status.sh running "<what the owner is waiting on>"; rewrite on every
pivot;bash scripts/core-status.sh idleat the end. Never>the JSON yourself.
0.5. Quota tier (Claude core):python3 $CLAUDE_CONFIG_DIR/skills/quota-tracker/scripts/read-quota.py | python3 skills/proactive-loop/scripts/quota-tier.py
→ TIER <FULL|MEDIUM|LIGHT|MINIMAL> (bound by …). Thenpython3 skills/proactive-loop/scripts/claude-quota-cadence.py --json → if effective_cron differs
from the /proactive-loop job in CronList: capture the old job id, CronCreate the new job withprompt: "/proactive-loop" and cron: <effective_cron>, confirm it in CronList. Then CronDelete
the old id and confirm exactly one /proactive-loop job remains. If create or confirmation fails,
keep the old job and stop loudly. Codex core: python3 skills/proactive-loop/scripts/codex-quota-gate.py --json. Tier caps
step 6's depth (LIGHT/MINIMAL: no self-development); it never skips owner tasks, health or the log.
0.7. Reconstruct. Invoke the context-reconstruct skill (a Skill-tool call, not a mention). It
reads <workspace>/hosts/<host>/current-track.md first, then the live thread
(python3 src/discord-read.py <channel> --serving <channel> when serving a task, --operator otherwise),
pending questions, relay, build log. Trust the record over recall; maintain current-track.md.
Tasks. Process every file in
$WORKSPACE/tasks/;access_tier: team|other→ the sandboxed path.
Group a thread with[deduped: task-<latest>], staged under its FINAL name and gated into
place —results/is claimed by a poller in under a second, and the checker reads the source
id from the BASENAME, so a generic temp name makes it pass everything:S="$WORKSPACE/state/dedup-staging/<file>"thenpython3 skills/proactive-loop/scripts/check-dedup-targets.py "$S" && mv -f "$S" "$WORKSPACE/results/<file>"
(0 clean · 1 the dedup delivers nothing · 2 cannot answer). All-notice groups use[no-send]on each.
Marker semantics belong tosrc/result_markers.py; never re-implement them.
When this core consumes a task itself, move$WORKSPACE/tasks/<id>.txtto$WORKSPACE/tasks/archive/<id>.txtafter writing its result; bridges and the Windows dispatcher
archive their own claims.
Before idle:python3 scripts/unanswered-tasks.py --workspace "$WORKSPACE" && bash scripts/core-status.sh idle
(1 = a task got no result, so idle does not run).
1.5. Connect waits. python3 skills/connect-apps/scripts/connectors.py rearm restarts the waiter of
any pending connector wait that lost it; idempotent, and a failure never blocks the pass.
Questions. Nothing surfaces on a pass: a question reaches the owner once, when asked
(scripts/ask-owner.py);src/check-pending-questions.py --notifyreminds only when he asks,
flagless it files what the outbox holds, sends nothing.src/pending_questions_reader.py list
only when he asks or you are blocked on an answer.Health.
python3 src/health-check.py; fix with--fixwhat it can. A warn is a pointer into the
record: before investigating,grep -in "<entity from the warn TEXT>" "$H/current-track.md"andpending_questions_reader.py list --json | grep -i "<entity>"
withH="$WORKSPACE/hosts/$(bash scripts/sutando-config.sh host-label)"; a zero means try another
token, thengrep -n '^## ' "$H"/*.mdbefore concluding absence. Extend a hit; never re-file it.
3.45. Duplicate issue gate, chained so a refusal cannot be skipped:python3 skills/proactive-loop/scripts/gh-duplicate-check.py --repo <owner/name> --title "<title>" && gh issue create --repo <owner/name> --title "..." --body-file <f>
(0 no candidate · 1 do not file, candidates named · 2 cannot answer).
3.5. Policy. python3 skills/proactive-loop/scripts/self-development-enabled.py → disabled skips
4–8, 10, 11. Owner-requested tasks, pending questions, health/service recovery and the watcher remain
active. Manual /proactive-loop invocation does not override the policy.
3.6. Tool suites. python3 skills/proactive-loop/scripts/tool-suites-check.py --workspace "$WORKSPACE" --repo "$PWD"
(0 pass · 1 a suite failed · 2 cannot answer). Extra suites are declared in$WORKSPACE/hosts/<host>/tool-suites-extra.json. It passes no argv to a suite.
- Build log. Read
$WORKSPACE/build_log.md; do not rebuild what works. Pick the highest-ROI unblocked item: owner tasks and blockers, then peer
opinion-requested/review-requestedclaims in #bot2bot, then voice reliability, then regressions, then the menu.
Write the pick into the statusstep.Act. A blocked primary is a cue to switch lanes, never to idle. Before pivoting from the owner's
latest ask, readstate/last-owner-activity.jsonand announce the pivot in the bot-to-bot channel
perPERSONAL_CLAUDE.md. Skip this step only for: MINIMAL tier; owner active in the last ~5 min;state/presenter-mode.sentinel(bash scripts/presenter-mode.sh);state/loop-paused-until.sentinel;python3 src/shutdown.py checkexiting 0 (finish in hand, write idle, do not relaunch).
6.5. Idle surface. Record the pass:python3 skills/proactive-loop/scripts/idle-surface-hash.py --state "$WORKSPACE/state/idle-streak.json" --pass-outcome substantive|noop.
The held set is edited only throughpython3 skills/proactive-loop/scripts/idle-held.py --state "$WORKSPACE/state/idle-streak.json" --remove <id> --reason "<why>" --add <id>:<gate> --note <owner/repo#n>
(no whole-list interface; a removal needs a reason); audit notes with --audit-notes "$PWD" and
retire merged items. Compute: idle-held.py … | idle-surface-hash.py --state … → post <hash> orquiet <hash>. On post: send ONE FYI line to the owner's primary channel, THEN re-run the same
pipe with --write on idle-held.py AND --commit on idle-surface-hash.py — each flag belongs
to its own side, and --commit alone leaves added holds, removal reasons and notes unpersisted, so
the next pass re-posts the stale hold. Never commit before the send; never build the list from
recall.quiet + owner active in the last ~30 min → still drop a one-line activity signal.
6.7. Failure closure. Every reported failure ends with the mechanism that prevents its recurrence,
linked, or the sentence "no mechanism exists, because X". A filed lesson is not a third option.
Build log write. Append with
O_APPENDand a random marker; assertcount(MARK) == 1by reading
the file back. Never read-modify-replace. Then decide whether arelay/relay-<ts>.mdnote is owed
(a PR event, a resolved question, a lifted or new blocker, a judgment) — most passes owe none.
7.5. Memory index, chained so a refusal cannot be skipped:python3 skills/proactive-loop/scripts/memory-index-budget.py --adding "<row>" && <append the row>
(0 safe · 1 refuse, casualty named · 2 cannot answer). On refusal free room FIRST and check the row is still reachable
from its hub before removing it; which rows go is the owner's call.
Ask.
python3 scripts/ask-owner.py "<question>" [--context "<why / options>"] [--task-file <workspace>/tasks/<task>.txt]
— never hand-edit. It records the row in the owner's Pending questions database (the outbox holds it
while the room is unreachable), queues the question to the owner (an owner-DM task's conversation,
else his DM) and fires the macOS notification. Read its output: aFAILEDline is not an ask;
confirm the id withpending_questions_reader.py list --json. Then pivot; never block.Watcher. Ask for this inbox, never host-wide (on a pool host a worker's watcher satisfies any
"is a watcher running" probe):python3 src/watcher_identity.py role-present session --inbox "$WORKSPACE/tasks" --ready "$WORKSPACE/state"
(substitute$SUTANDO_TASKS_DIRfor the inbox on an instance whose tasks dir isn't<workspace>/tasks/).no→ run the launcher:Monitorbash src/watch-tasks-stream.sh --role session --inbox "$WORKSPACE/tasks"
(same substitution),description: 'Streaming task watcher'. The watcher checks its own inbox at startup:
if a session watcher already covers it, the new one exits 0 naming the holder, so a start is never a
duplicate; over a standby it proceeds and the supervisor stands the standby down.yesorunknown→
change nothing and say so. A re-arm that printsWATCHER_HELD:on stdout did not start: it names
the holder (pid, role, whether its output is read) and the--force-restartcommand; report that
line and do not re-arm again.--force-restartreplaces a holder; use it only on the owner's word.
Stop pids only when thetask-watcherprobe from step 3 presents owned and ownerless as two separately
labelled groups; one undifferentiated list means change nothing. Never start the watcher untagged: an
untagged watcher is invisible to the verdict above and to the supervisor.
9.5. PR thread gate, chained so a refusal cannot be skipped:python3 skills/proactive-loop/scripts/pr-monologue-check.py <PR url|number --repo owner/name> --me <your-login> && gh pr comment <number> --repo <owner/name> --body-file <f>
(0 safe · 1 refuse, run and span named · 2 cannot answer). On refuse, re-solicit through a stand.
Discord. Check the channels in
reference_discord_channels.md; forward actionable public items to
the dev channel. #bot2bot tags:claim:blocked:done:ping:nack:opinion-requested:.
First PR opened wins a claim. Bots never merge. Three unresolved round-trips → both positions in oneask-owner.pyask, proceed with the cheaper-to-reverse option.Heartbeat. Substantive pass + #bot2bot configured + other bot active →
done: <one line>via thebot2bot-postskill. Never fall back toresults/proactive-*.txt. Never writecontextual-chips.json.