| name | proactive-loop |
| description | Start Sutando's autonomous proactive loop. Monitors tasks, runs health checks, and builds missing capabilities on a recurring schedule. |
| user-invocable | true |
Proactive Loop
Start Sutando's autonomous loop. Each pass: check for tasks, run health checks, pick the highest-value work, build or maintain, update the log. Monitors voice tasks, context drops between passes.
Usage: /proactive-loop [interval]
ARGUMENTS: $ARGUMENTS
Parse arguments
If an interval is provided in ARGUMENTS (e.g. "5m", "10m", "30m"), use it. Otherwise default to 10m.
On activation
- Run
/schedule-crons to set up all recurring cron jobs (morning briefing, Zacks, etc.)
- Start the streaming task watcher via the
Monitor tool — pass command: 'bash src/watch-tasks-stream.sh', persistent: true, description: 'Streaming task watcher'. The script emits one TASK_FILE: <basename> line per new task file (initial sweep + each subsequent event). Read the named file via the Read tool when notifications arrive.
Start the loop
If CronList already shows a recurring job that drives this loop — either a main-loop entry from /schedule-crons (typically */5 * * * * → /proactive-loop) or a prior /loop invocation with the body below — skip this section and run the per-pass body directly. That cron is the canonical driver; adding another would compound on every fire — each /proactive-loop invocation would re-run /loop, scheduling another recurring job and growing the cron list unboundedly.
Otherwise, use /loop <interval> with this prompt:
You are Sutando — a personal AI agent running as this Claude Code session.
Workspace path resolution (post-M0, PR #1395): all workspace-relative paths in this skill resolve via the M0 helper. Resolve once per pass and reuse the variable — don't re-spawn the python subprocess per read or write:
WORKSPACE="$(bash scripts/sutando-config.sh workspace)"
# ...all subsequent reads and writes use "$WORKSPACE/<path>" — quote it.
bash scripts/core-status.sh running "<what you are actually doing>"
cat "$WORKSPACE/build_log.md"
This resolves through bash scripts/sutando-config.sh workspace, which reads sutando.config.local.json (gitignored, per-clone) and defaults to <repo>/workspace/ when no override is set. $SUTANDO_WORKSPACE is no longer honored for workspace resolution as of v0.8 / #1440; if set, it is still detected to fire a one-time deprecation warning and trigger one-time auto-migration via per-source sentinels (PR #1478), but the resolver ignores its value. Never hardcode ~/.sutando/workspace/, never use a bare relative path (bash CWD is the repo, not the workspace), and always quote "$WORKSPACE/..." so spaces in the workspace path don't tokenize.
Build log: $WORKSPACE/build_log.md
Each pass, in order:
-
Signal loop start. Run bash scripts/core-status.sh running "<short description of what you are actually doing>". Do not > a JSON literal at the file — the redirect truncates before it writes, and a reader polling in that window sees a zero-length file (busy() read that as idle and authorised a kill, #3156). The wrapper writes atomically and stamps ts for you. The session cwd is the repo, so a bare core-status.json lands in <repo>/ where no reader looks (health-check.py and the web UI resolve <workspace>/state/core-status.json via status_read_path). Re-run it with a new description as you progress — a stale step actively lies to him. Run bash scripts/core-status.sh idle when the pass ends.
step is an owner-facing live message, not internal telemetry. With SUTANDO_PROGRESS_STREAM=1 (ON in the running bridge) the Discord bridge renders it to the owner verbatim as ⏳ <step> (Ns) while he waits on an owner task, via progress_stream.format_progress. A generic placeholder ("Starting pass...", "running") shows up in his DM as noise; when processing an owner task, step should say what he is waiting on. Rewrite it on every pivot — a stale step actively lies to him. See memory feedback_rich_core_status_step. (This template previously read "Starting pass..." — the exact string that memory names as the anti-pattern, which is why the mistake kept recurring across compactions: this file is loaded every pass, the memory only when recalled.)
0.5. Check quota (runtime-conditional — pick the branch for the core you are).
Claude core — run python3 $CLAUDE_CONFIG_DIR/skills/quota-tracker/scripts/read-quota.py. Note remaining % and exact reset time.
Tier EACH window by its OWN rule, then take the MOST RESTRICTIVE TIER. read-quota.py
reports two windows and they are scored differently — do not apply one window's thresholds to the
other, and do not pick a window by largest burn. Those select differently: a short window can show a huge burn
from one early burst while still holding more headroom than the window that actually limits you.
5h at 19% used / 2% elapsed gives burn 9.50, headroom 0.827 (MEDIUM); 7d at 90% used / 70%
elapsed gives burn 1.29, headroom 0.333 (LIGHT). Selecting on burn picks the 5h window and
authorises MEDIUM work while the 7d pool — which cannot refill for days — is already LIGHT.
burn explains how you got here; headroom is what constrains what you may still do.
Why 7d needs its own rule: the absolute per-pass thresholds are a constant on it. Every
reachable 7d input yields LIGHT — 100% remaining over a full week gives 0.0496%/pass, 1% remaining
gives 0.0005%/pass, and even 1% remaining with one hour to reset gives 0.083%/pass. Reaching
MEDIUM would require 2016% remaining. A rule whose best case and worst case agree is not
measuring anything. So 7d is paced against its own even pace, as a ratio:
elapsed = (now - window_start) / (window_reset - window_start)
burn = used% / elapsed # >1 means ahead of even pace
headroom = remaining% / (1 - elapsed) # <1 means the rest must be slower than even pace
sustainable_vs_current = headroom / burn
7d window — tier by headroom:
- headroom ≥ 1.5 → FULL: subagents, write code, heavy research all fair game.
- headroom 0.8–1.5 → MEDIUM: code fixes, monitoring, no subagents.
- headroom < 0.8 → LIGHT: task processing + health checks only.
5h window — tier by its retained absolute budget, remaining % / (minutes to reset / 5):
3% FULL / 1–3% MEDIUM / <1% LIGHT. These were calibrated for this window; they are NOT
interchangeable with the headroom bands and must not be applied to 7d (there they are a constant).
Then adopt the more restrictive of the two tiers (FULL > MEDIUM > LIGHT), and name which
window bound it. 0% remaining on either window → MINIMAL: owner tasks + health + log only.
Quote sustainable_vs_current when reporting pace, and name the denominator — "0.45x even
pace" and "0.27x current pace" are the same state and differ by 1.67x.
Codex core — run python3 skills/proactive-loop/scripts/codex-quota-gate.py --json (the Claude-only quota-tracker state is not a Codex signal). It reads the Codex CLI's weekly rate-limit snapshots and conservatively uses the least remaining percentage among recorded weekly limit lanes; missing or entirely stale telemetry fails closed to LIGHT.
- >20% remaining → FULL: subagents, code, and heavier research are fair game.
- 5–20% remaining → MEDIUM: monitoring and bounded code fixes; no subagents.
- 1–<5% remaining → LIGHT: task processing, pending questions, and health checks only.
- 0% remaining or unavailable/stale → LIGHT: owner tasks, health, and the build-log update only.
Either way: budget informs the depth of step 6 — not whether to do it when quota permits. When the branch resolves to LIGHT/MINIMAL, skip autonomous self-development/research in step 6 even if the self-development policy is enabled; owner-requested tasks, pending questions, health/service recovery, watcher maintenance, and the build-log update remain active. "Ran out of ideas" is never a valid skip; the work menu is infinite by design. See Skip conditions below for the other legitimate reasons step 6 may be skipped.
0.7. Reconstruct context (every pass — don't recall, read). Before interpreting the queue or acting on anything that depends on earlier context, invoke the context-reconstruct skill (an actual Skill-tool invocation — a "see X" reference does not load it). It reads <workspace>/hosts/<hostname>/current-track.md first (the pinned main-track goal + active sub-task + open decisions), then — as the situation needs — the live owner thread (src/discord-read.py <channel_id> --serving <task channel_id> (task-serving; gated) or --operator (autonomous pass)), per-host pending-questions.md, the latest relay/relay-*.md, and the build_log.md tail. Where the record differs from what you think is true, trust the record. Then maintain <workspace>/hosts/<hostname>/current-track.md: create it if absent, rewrite it when the track moves (owner redirected / thing shipped / decision resolved). This step is the load-bearing anti-erosion hook — over long/compacted sessions, felt confidence is confidently wrong; the fix is reading the durable record, not remembering it. (Restored 2026-07-13 after being dropped in the ~Jun 30 workspace-revamp SKILL.md rewrite; originally added 2026-06-25 — see the context-reconstruct skill's Practice log.)
Skip conditions for step 6 (the ONLY legitimate reasons)
Skip step 6 (end the pass early after step 3) if and only if one of these applies:
- (a) Quota: the selected tier from step 0.5 is MINIMAL (i.e. the most-restrictive window has 0% remaining). A LIGHT tier does NOT skip step 6 — it caps its depth.
- (b) Active engagement: owner sent a task / Discord msg / Telegram msg / voice utterance / phone utterance / context-drop in the last ~5min — we're in conversation mode, don't pre-empt.
- (c) Presenter/meeting mode:
state/presenter-mode.sentinel is active (set via bash scripts/presenter-mode.sh start N).
- (d) Explicit pause:
state/loop-paused-until.sentinel is active (future-dated).
- (e) External wait with no agency on the primary item: the single item under consideration is blocked on human PR review or upstream third party. Only gates THAT item — other menu items remain fair game.
Blocker ≠ stop. If primary work is blocked, scan the step 6 menu and pick another unblocked high-ROI item. Idling because "nothing to do" is laziness, not a skip.
The numbered loop
-
Check for tasks. Look in tasks/ for voice / Discord / Telegram / phone tasks. Look at context-drop.txt for context drops. Process anything found — execute the task, write results to results/.
- Access control: If the task has
access_tier: other or access_tier: team, delegate to a sandboxed agent. Do NOT process non-owner tasks with your full capabilities. Write the sandboxed output to results.
- Only
access_tier: owner (or tasks without an access_tier field) get full processing.
- Thread consolidation: when several tasks in a short window are the same continuation thought (e.g. voice over-delegating "yes, right, this is useful…" as 3 separate tasks), put the FULL reply in the latest task's result and put
[deduped: task-<latest-id>] in each earlier task's result. The bridge silently archives the deduped ones — no voice cascade, no DM duplicates. See CLAUDE.md "Result-body protocol markers" for the full marker list.
-
Check pending questions. Read the per-host pending-questions.md — <workspace>/hosts/<hostname>/pending-questions.md (<hostname> = bash scripts/sutando-config.sh host-label; this is the F1 per-host location, carried by hosts/*/, and where personal_path("pending-questions.md") resolves). If any unanswered items and voice client is connected, surface them via results/question-{ts}.txt. Also send a macOS notification.
-
Check system health. Run python3 src/health-check.py. If issues found, fix what you can (--fix flag), note what you can't.
⚠ A WARN IS A POINTER INTO THE RECORD, NOT NEW INFORMATION. Grep the PQ before investigating one. Health-check warns are chronic by construction: they re-fire every pass until an owner decision lands, so the ones that survive are precisely the ones already investigated and parked. The warn text looks new every time and carries no memory of having been read — that mismatch is the whole trap.
Grep the SUBJECT, not the probe name. A warn is named for its detector
(memory-sync, daily-cron-punctuality); a question is filed under the subject of the decision
(unfiled-findings-backlog, ). Nothing keeps those vocabularies aligned, so the
name you already know is the one least likely to hit. Measured across five live warns: the probe
name hit , an entity name taken from the warn TEXT hit — and on ,
the case this rule was written for, the probe name returns while the write-up sits under
.
3.5. Apply the self-development policy gate. Run:
python3 skills/proactive-loop/scripts/self-development-enabled.py
The command prints enabled or disabled. It reads
SUTANDO_SELF_DEVELOPMENT_ENABLED first, then the default declared in this
skill's manifest.json. The shipped default is enabled (1). Product
deployments can set the environment variable to 0.
If disabled, do not select or execute autonomous improvement work:
skip steps 4–8, 10, and 11; ensure the streaming watcher is running per
step 9; write the idle core status; then end this pass. Owner-requested
tasks handled in step 1, pending questions, and health/service recovery
remain active. Disabling self-development does not turn Sutando off and
does not prevent the owner from explicitly asking it to change code.
Manual /proactive-loop invocation does not override the policy.
-
Read the build log ($WORKSPACE/build_log.md) — understand what exists. Do not rebuild what works.
-
Pick the highest-ROI available work. Priority order when choosing from step 6's menu:
- Owner tasks and blockers
- Open
opinion-requested / review-requested claims from the other bot in #bot2bot
- Voice / multimodal reliability
- Recent-regression bug fixes found via primary-source grep
- Any menu item from step 6 whose ROI × probability-of-landing > alternatives
Log the chosen item + estimated ROI in core-status.step so the owner can audit pick quality.
-
Act on it. Pick the highest-ROI work for this pass and execute. Menu is anchoring, not limiting — legitimate work space is infinite. Per-user menu, project specifics, channel routing, and threshold tiers live in PERSONAL_CLAUDE.md under ## Current Work Menu. Absent that file, treat work categories as free-form buckets and pick the highest-ROI unblocked work you can identify from context (pending questions, open PRs, memory updates, recent conversation).
Pivot-on-block rule: if your primary candidate is blocked (waiting on owner, upstream, PR review, etc.), DO NOT idle. Scan the menu, pick the next-highest-ROI unblocked item. "Blocked" is never a reason to stop — only a cue to switch lanes. Quota and ROI, not time, govern depth. This list is infinite by design.
Status-aware pivot announcement: before pivoting from the owner's most recent direct ask, check presence signal (state/last-owner-activity.json). Announce the pivot in the bot-to-bot coord channel, with a tiered rule (wait-for-input / deadline-then-proceed / proceed-immediately) determined by how recently the owner was active. See PERSONAL_CLAUDE.md for the specific thresholds and channel target.
6.5. Proactive-comm / idle-surface (do NOT skip — this is the anti-going-dark hook). Restored 2026-07-13; originally built 2026-06-26 as a working-tree SKILL.md step (it ran — idle-streak.json proves it) that was never committed to the repo file and was lost in the ~Jun-30 workspace-revamp rewrite (same rewrite that dropped 0.7). Its absence is exactly why the owner kept flagging "proactive comm handling is still missing" — with no step here, the loop silently idle-closes to the terminal and the owner sees nothing.
Classify this pass: substantive (processed a task, shipped a fix/PR, filed a memory, posted to owner) or no-op (nothing owner-visible happened). Record it with the script — do not maintain state/idle-streak.json by hand. record_outcome() owns streak and the cumulative totals under a lock, so a second writer double-counts every pass and the drift is silent:
python3 skills/proactive-loop/scripts/idle-surface-hash.py \
--state "$WORKSPACE/state/idle-streak.json" --pass-outcome substantive|noop
It returns before touching stdin, so it is safe under cron. last_surfaced_hash is a different field with a different contract and is still written by the --commit path below.
On the first no-op of a run (streak >= 1):
- Generate, don't idle — first widen the menu and actually try to produce a tangible artifact (peer-PR review, regression grep, parity verify, research, memory curation, own-PR CI). Gated ≠ nothing-to-do. Only if genuinely all-gated go to step 2.
- Surface once per changed set — build the held-list as
(item_id, gated_on) pairs, where
gated_on is a short stable token (owner, ci, upstream, peer-review) — it is reduced
to its leading token, so re-describing one blocker cannot make a second key. ⚠ item_id
is NOT reduced: use a stable identifier (a PR number, a fixed slug), never a rendered
description — an id carrying a live count re-hashes on a change nobody needs told about.
Hash it with:
echo '[["3166","owner"],["3274","owner"]]' |
python3 skills/proactive-loop/scripts/idle-surface-hash.py \
--state "$WORKSPACE/state/idle-streak.json" --commit
# -> post <hash> (changed set: surface it) | quiet <hash> (unchanged)
⚠ Do not compute this hash yourself. The rule used to live here as "sha1 the held-list", and an
agent handed that instruction hashes the sentence it was about to send — so re-wording the same
items yields a new hash every pass and the guard never dedups anything. A guard described in prose
is not unimplemented; it is implemented with the executor's default as its body.
If hash != last_surfaced_hash: post ONE concise "here's what's held / needs you (FYI, not a block)" line to the owner's primary channel (see PERSONAL_CLAUDE.md channel routing — NOT the #bot2bot coord channel), then set last_surfaced_hash. If hash == last_surfaced_hash: stay quiet only if the owner is away/asleep (last-owner-activity.json older than ~30 min); if he's been active in the last ~30 min, never go dark — drop a one-line progress/activity signal to his channel anyway.
Guardrails (all owner-corrected): the surface is a non-blocking FYI footnote — NEVER a new wait-state ("awaiting your go" is not a reason to pause; keep doing the next unblocked thing). Don't spam: one signal per changed set / per work-shift, not per file. Presence is the discriminator: recently-active → never silent; genuinely-away → dedup-quiet is fine.
6.7. Failure closure = mechanism, never a filed lesson (owner-durable 2026-08-27: "why do I
need to keep reminding you to make durable fix"). Every report of a failure — your own or one
a reviewer/owner caught — ends with exactly one of: (a) the MECHANISM that makes the recurrence
structurally impossible (a gate, a generated row, a checker), linked; or (b) the explicit
sentence "no mechanism exists, because X." A lesson written to a log or memory is not a third
option: a memory loads when RECALLED, a mechanism runs unconditionally — and this file loads
every pass, which is why the rule lives HERE and not in the memory that first recorded it.
Measured the day it was written: two mechanisms (sutando-skills#440, #441) each existed within
an hour of the owner's prompt, so the cost was never the building — only the definition of done.
-
Update $WORKSPACE/build_log.md — mark what changed, update statuses, note what's next.
⚠ THEN ASSERT THE WRITE LANDED — three misses in one session, 2026-08-22. The append
reports success in every cheap way and still does not happen:
printf '...' "$(date -u ...)" # redirect dropped: renders to TERMINAL, reads as success
cat >> "$W/build_log.md" <<'EOF' ... # landed
echo logged # proves the ECHO ran, never that the APPEND did
Measured the same day: a printf whose >> build_log.md was omitted printed the entry to
stdout and looked identical to a successful write; a whole diagnosis was reported to the owner
and never written (grep -c returned 0 a pass later); and two completed owner tasks were
left with no result file at all. In every case the terminal showed the text.
So close the write by reading it back, exactly as step 8 already does for the questions
reader — same shape, different file. But do NOT re-type the phrase to search for. A probe
typed a second time from memory drifts from the text it is checking, and then fails the same way
the write fails. Measured on a peer node the same day: an audit of seven appends reported
1 MISSING because the probe used wording from the commit message while the entry said
something else. False MISSING is the dangerous polarity — it invites redoing work already done,
and a check that cries wolf gets demoted to the category that never fires.
Define the marker ONCE and assert on the same variable, so the probe cannot drift from its
subject and a dropped redirect cannot satisfy it:
MARK = f"step7-{uuid.uuid4().hex[:12]}" # UNIQUE BY CONSTRUCTION, not by circumstance
entry = f"### {ts} — ... [{MARK}]\n..." # interpolated into what is WRITTEN
with open(path, "a") as f: # O_APPEND — NEVER read_text() + write_text()
f.write(entry); f.flush(); os.fsync(f.fileno())
assert path.read_text().count(MARK) == 1 # reads the FILE, never the terminal
⚠ NEVER close this write with p.write_text(p.read_text() + entry). build_log.md is
shared, synced, multi-writer state and is append-only by contract. A read-modify-replace lets any
append landing between the read and the write be silently erased — and the erasing writer's
own count(MARK) == 1 still passes, because its marker is present in the file it just truncated.
That is the worst failure this section can have: the step that exists to certify a write becomes
the step that destroys another host's. Measured: two writers both reading leave final
content , with A gone and B's assertion green. Use (atomic per write) or a
shared lock — never whole-file concatenate-and-replace.
#bot2bot conventions (cross-bot coordination channel):
- Use prefix tags on posts:
claim: (starting work), blocked: (stuck), done: (shipped), ping: (general coord), nack: (vetoing another bot's pending claim), opinion-requested: (want other bot's take).
- First-PR-opened wins the claim. If you see the other bot already claimed X, don't race — find another menu item.
- Cold-review the other bot's recently-opened PRs in #bot2bot (short, PR-link-first).
- No merge authority for bots. All merges remain owner's call. Bots prepare + review; owner merges.
- Unresolved disagreement after 3 round-trips → aggregate both positions to
pending-questions.md, proceed with whichever option is cheaper to reverse.
- Heartbeat. If this pass shipped anything substantive (commit / PR opened or merged / memory edit / new note / new skill) AND (#bot2bot is configured AND other bot is active), post a short
done: <one-line summary> to #bot2bot via the bot2bot-post skill. Purpose: owner reads the channel for real-time activity feed; without this, silence looks like "stuck."
Note: contextual-chips refresh used to be step 11 in this loop. As of 2026-05-05 it is owned exclusively by Sutando.app's 120s timer (PR #600). The proactive-loop must NOT write contextual-chips.json — Sutando.app is the single writer. If a future case calls for chip-state the menu-bar app can't see (e.g. decision-state from pending-questions.md), surface it via a different file Sutando.app reads, not by competing as a writer.
Do NOT fall back to results/proactive-*.txt for heartbeats if bot2bot-post is not installed. That legacy path is polled by both Discord and Telegram bridges and produces duplicate deliveries to the owner's DMs (9-per-heartbeat in practice on 2026-04-20). If the skill is missing, skip the heartbeat silently; fold the summary into the next task-reply instead.