diff --git a/.claude/settings.json b/.claude/settings.json
index 6aa1afb..4b9fabd 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -38,7 +38,9 @@
"mcp__hermit__review_pr",
"mcp__hermit__close_issue",
"mcp__hermit__run_requirements_sweep",
- "mcp__hermit__now"
+ "mcp__hermit__now",
+ "mcp__hermit__get_loop_state",
+ "mcp__hermit__update_loop_state"
]
}
}
diff --git a/README.md b/README.md
index 3abff98..1ba2b3b 100644
--- a/README.md
+++ b/README.md
@@ -207,6 +207,25 @@ GitHub Issue created
No further action is needed. HERMIT handles the entire development workflow autonomously.
+### Alternative to Step 2: `hermit run` (no Claude Code session required)
+
+`/hermit` keeps the Superintendent loop alive inside a Claude Code session — a terminal running `claude` has to stay open for as long as you want autonomous operation. `hermit run` moves that loop into HERMIT's own long-lived process instead: it owns an internal ticker and, once per tick, launches `claude -p` non-interactively (no permission prompts, `--dangerously-skip-permissions`), waits for it to finish, then waits `[agent].loop_interval` before the next tick. `hermit serve` is already a long-lived process (the MCP server); `hermit run` is the same shape applied to the Superintendent cycle itself.
+
+```sh
+cd your-project # directory where hermit init was run
+hermit run
+```
+
+`hermit run` and `/hermit` are not mutually exclusive — use whichever fits how you want to keep HERMIT alive:
+
+- `hermit pause` / `hermit resume` / `hermit quit` / `hermit status` all work the same way regardless of which one is driving the loop; `hermit run` checks `.hermit-paused`/`.hermit-quit` itself before every tick.
+- `hermit run` sends SIGINT/SIGTERM a graceful shutdown: an in-flight pass is never interrupted — it always finishes, and only then does the loop stop.
+- A pass that hangs or takes a long time never causes overlapping ticks: the next tick is only scheduled after the previous one returns.
+- If `N` consecutive passes fail, `hermit run` sends a notification via `[notification]` (`[run].failure_notify_threshold`, default 3) so unattended failures don't go unnoticed.
+- The three cadence timestamps the Superintendent cycle tracks (last PR-comment check, last Issue-comment check, last requirements sweep) live in `.hermit/superintendent-state.json`, owned by HERMIT's Go code and read/written only through the `get_loop_state`/`update_loop_state` MCP tools — never hand-written by the Superintendent session itself.
+
+See "Running HERMIT Continuously" below for keeping `hermit run` alive across reboots/logouts on each platform.
+
### Superintendent Cycle
1. Retrieve open Issues with `list_issues`
@@ -238,6 +257,10 @@ No further action is needed. HERMIT handles the entire development workflow auto
| `get_config` | Returns current HERMIT configuration values (e.g. `loop_interval`) |
| `notify` | Sends a notification to the configured webhook (Slack, Discord, or generic) |
| `get_default_branch` | Returns the repository's default branch name |
+| `now` | Returns the current wall-clock time (RFC3339), authoritative for cadence tracking |
+| `get_loop_state` | Returns the cadence timestamps (`pr_comments_since`, `issue_comments_since`, `requirements_sweep_since`) and `hermit run` liveness fields (`last_success_tick`, `consecutive_failures`) from `.hermit/superintendent-state.json` |
+| `update_loop_state` | Updates one or more of the three cadence timestamps in `.hermit/superintendent-state.json`; the only supported way to write that file besides `hermit run` itself |
+| `run_requirements_sweep` | Reconciles REQUIREMENTS.md against test results, opening Issues for unimplemented/regressed requirements |
### Risk Evaluation Criteria
@@ -291,6 +314,9 @@ engineer = "claude-sonnet-5" # model used for Engineer roles
# [notification]
# webhook_url = "https://hooks.slack.com/services/..." # Slack, Discord, or generic webhook
# type = "slack" # "slack" | "discord" | "generic" (auto-detected from URL if omitted)
+
+# [run]
+# failure_notify_threshold = 3 # `hermit run`: consecutive failed passes before a webhook notification (default: 3)
```
**Pass `GITHUB_TOKEN` as an environment variable. Do not write it in `harness.toml`.**
@@ -417,6 +443,7 @@ When the score drops below 70, a lesson is generated and saved to `.hermit/lesso
```
hermit serve # Start the MCP server (stdio) — Claude Code auto-starts this, manual execution normally not needed
+hermit run # Start the Superintendent tick loop as a standalone long-lived process (no Claude Code session needed)
hermit install # Register MCP server via `claude mcp add` and install slash commands
hermit init # Initialize a project (generate harness.toml, CLAUDE.md, issue template, settings)
hermit pause # Pause autonomous operation (resumable)
@@ -463,6 +490,99 @@ Run this once after upgrading from an older version if you see warnings about le
---
+## Running HERMIT Continuously
+
+`hermit run` (see "Alternative to Step 2" above) is a plain foreground process: it runs until it receives SIGINT/SIGTERM, a `.hermit-quit` file appears, or it's killed. To keep it running unattended across reboots, logouts, or crashes, use your platform's normal process-supervision tooling — HERMIT intentionally does not generate or install any of these for you (see REQ-019/REQ-014: HERMIT stays a thin toolbox, not a process manager).
+
+### systemd (Linux)
+
+Create a user service, e.g. `~/.config/systemd/user/hermit-run.service`:
+
+```ini
+[Unit]
+Description=HERMIT Superintendent loop
+
+[Service]
+WorkingDirectory=/path/to/your-project
+ExecStart=/path/to/hermit run
+Restart=on-failure
+Environment=GITHUB_TOKEN=...
+
+[Install]
+WantedBy=default.target
+```
+
+```sh
+systemctl --user daemon-reload
+systemctl --user enable --now hermit-run.service
+journalctl --user -u hermit-run -f # follow logs
+```
+
+### launchd (macOS)
+
+Create `~/Library/LaunchAgents/com.hermit.run.plist`:
+
+```xml
+
+
+
+
+ Labelcom.hermit.run
+ ProgramArguments
+
+ /path/to/hermit
+ run
+
+ WorkingDirectory/path/to/your-project
+ RunAtLoad
+ KeepAlive
+ StandardOutPath/tmp/hermit-run.log
+ StandardErrorPath/tmp/hermit-run.err
+
+
+```
+
+```sh
+launchctl load ~/Library/LaunchAgents/com.hermit.run.plist
+```
+
+### Windows Service
+
+Use a lightweight wrapper such as [WinSW](https://github.com/winsw/winsw) or [NSSM](https://nssm.cc/) to register `hermit.exe run` (with `WorkingDirectory` set to your project) as a Windows service — HERMIT does not register itself as one.
+
+### tmux / screen
+
+The simplest option for a single long-running session:
+
+```sh
+tmux new -d -s hermit 'cd /path/to/your-project && hermit run'
+tmux attach -t hermit # to check on it later
+```
+
+### Docker
+
+```dockerfile
+FROM golang:1-alpine AS build
+# ... build the hermit binary ...
+
+FROM alpine
+RUN apk add --no-cache git github-cli nodejs npm \
+ && npm install -g @anthropic-ai/claude-code
+COPY --from=build /out/hermit /usr/local/bin/hermit
+WORKDIR /project
+ENTRYPOINT ["hermit", "run"]
+```
+
+```sh
+docker run -d --name hermit-run \
+ -e GITHUB_TOKEN=... -e ANTHROPIC_API_KEY=... \
+ -v /path/to/your-project:/project \
+ your-hermit-image
+docker logs -f hermit-run
+```
+
+---
+
## License
MIT
diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md
index 2676612..e97680b 100644
--- a/REQUIREMENTS.md
+++ b/REQUIREMENTS.md
@@ -21,7 +21,7 @@
## 現状把握サマリ (2026-07 時点)
-全 14 要件のうち **実装済み 14 件 / 一部実装 0 件 / 未実装 0 件**。設計文書 HERMIT.md の骨格はすべて実装されており、多くの領域で設計を超えて拡張されています。一方、HERMIT.md 自体が実装に追従しておらず、以下の差分 (設計文書の記述と実際の実装の乖離) があります。
+全 17 要件のうち **実装済み 17 件 / 一部実装 0 件 / 未実装 0 件**。設計文書 HERMIT.md の骨格はすべて実装されており、多くの領域で設計を超えて拡張されています(Issue #181 の `hermit run` (REQ-019) を含む)。一方、HERMIT.md 自体が実装に追従しておらず、以下の差分 (設計文書の記述と実際の実装の乖離) があります。
| # | 差分 | 設計 (HERMIT.md) | 実装 (現状) |
|---|---|---|---|
@@ -176,13 +176,13 @@ Superintendent が同時に生成する Engineer の数は `harness.toml` の `[
MADFLOW の教訓 (HERMIT.md §1, §9) に基づき、HERMIT では以下を行わない。
-- Go バイナリが Claude Code をサブプロセスとして起動・管理する「外側から包む」アーキテクチャ
+- Go バイナリが Claude Code をサブプロセスとして起動・管理する「外側から包む」アーキテクチャ (ただし REQ-019 `hermit run` は明示的な例外— 下記注記参照)
- エージェンティックループ・LLM API 呼び出しの Go による再実装
- チャットログ (ファイル) 経由のエージェント間通信 — Agent tool の入出力で代替する
- タイマーによるコンテキストリセット等のコンテキスト/プロセスライフサイクル管理 — Claude Code に委譲する
-- 受け入れ条件: 上記に該当する実装がコードベースに追加されていないこと (レビューで担保)
+- 受け入れ条件: 上記に該当する実装 (ただし REQ-019 で明示的に許可された `hermit run` の範囲を除く) がコードベースに追加されていないこと (レビューで担保)
- verify: manual
-- 実装状況: 実装済み (遵守) — 現行コードベースに該当実装は存在しない。`cmd/hermit` のサブコマンドは serve / install / init / doctor / upgrade / version 等の CLI に留まり、Claude Code のプロセス管理や LLM 呼び出しは行っていない
+- 実装状況: 実装済み (遵守、Issue #181 による限定的な例外あり) — `cmd/hermit` のサブコマンドは serve / run / install / init / doctor / upgrade / version 等の CLI に留まり、Claude Code のプロセス管理や LLM 呼び出しの再実装は行っていない。唯一の例外が REQ-019 の `hermit run`: `claude -p` を子プロセスとして起動する「外側から包む」形に見えるが、(1) エージェンティックループ・コンテキスト管理・LLM API 呼び出しの再実装は一切行わず、1 tick = 1 回の `claude -p` 起動を待つだけの薄いラッパーに留まる、(2) これは Issue #147 (Superintendent をバックグラウンドサブエージェント化する設計) の再導入ではなく、Issue #171 で実証された「サブエージェント spawn 上限の枯渇によるループの静かな停止」という失敗モードを構造的に回避するための独立した設計 (3 回目の再設計) である、という理由により、この 1 行に限り本要件の対象から明示的に除外する。詳細は REQ-019 を参照
## REQ-015: 制御面パスの evaluate_risk 既定 HIGH 昇格
@@ -204,7 +204,6 @@ reconcile sweep の review-test は、要件の「仕様」が変わったとき
- 受け入れ条件: `Requirement.Hash` が `受け入れ条件` と `verify` のみから計算され、要件ブロック全体からは計算されないこと。`実装状況` 行のみを変更しても次の sweep で review-test が発火しないこと。`受け入れ条件` の変更、および `verify` の `test` ↔ `manual` の切り替えは従来どおり発火すること。見出しや説明文のみの変更では発火しないこと。ハッシュストアに計算方式のバージョンが記録され、方式変更後の初回 sweep は全件を再計算・保存するのみで Issue を起票しないこと
- verify: test
- 実装状況: 実装済み — `internal/requirements/requirements.go` の `specHash` が `AcceptanceCriteria` と `Verify` のみからハッシュを計算するように変更 (旧 `hashText(block)` を置き換え)。`internal/requirements/hashstore.go` の `HashStore` インターフェースを `Load() (version int, hashes map[string]string, err error)` / `Save(version int, hashes map[string]string) error` に拡張し、`HashSchemeVersion` 定数 (現在値 2) を導入。旧形式 (バージョン無しの素の map) のファイルは version 0 として扱われ後方互換。`internal/requirements/sweep.go` の `Sweep` は読み込んだバージョンが `HashSchemeVersion` と異なる場合 `schemeChanged` として HashChanged 判定を強制的に false にし (review-test を発火させず)、sweep 終了時に現行バージョンでハッシュを保存し直すことで移行を1回のsweepで完了させる。自己増殖ループの回帰テストは `internal/requirements/sweep_test.go` の `TestSweep_ImplementationStatusOnlyChange_DoesNotFireReviewTest`、スキーマ移行の回帰テストは同ファイルの `TestSweep_HashSchemeMigration_DoesNotFireReviewTest_JustRecomputesAndSaves`、ハッシュ計算自体の単体テストは `internal/requirements/requirements_test.go` の `TestParse_HashUnaffectedByImplementationStatusField` / `TestParse_HashUnaffectedByTitleOrDescriptionOnly` / `TestParse_HashChangesWithVerifyMode` で検証。REQ-ID 命名規約に沿った `TestREQ016_ReviewTestHashIgnoresImplementationStatus` を追加
-
## REQ-017: list_issues は信頼できる author_association の Issue のみを返す
HERMIT は public リポジトリで運用され得るため、第三者が作成した Issue の本文がそのまま Engineer への指示としてローカルで実行されることを防ぐ。`ListOpenIssues` / `ListAllIssues` は GitHub API の `author_association` を参照し、信頼できる association を持つ Issue のみを返す。信頼する association は `harness.toml` の `[security] trusted_author_associations` で設定可能で、既定値は `OWNER` / `MEMBER` / `COLLABORATOR` の 3 種のみ (`CONTRIBUTOR` / `FIRST_TIME_CONTRIBUTOR` / `NONE` は含めない)。
@@ -227,3 +226,13 @@ Engineer は `Bash(*)` 許可でローカルマシン上で動作しており、
- 受け入れ条件: `hermit init` が生成する `.claude/settings.json` に上記構造の `sandbox` ブロックが含まれ、`allowUnsandboxedCommands: false` / `GITHUB_TOKEN` の `mode: mask` + `injectHosts` が満たされていること。生成された設定を適用した状態で `go build ./...` / `go test ./...` が成功し、`gh pr create` 相当の操作が実行できること。`hermit doctor` が上記 3 種の警告を検出すること。既存プロジェクトへの `hermit init` 再実行が既存の `permissions` 設定を破壊しないこと
- verify: test
- 実装状況: 実装済み — `internal/permissions/permissions.go` の `DefaultSandboxSettings` / `MergeDefaultSettings` (再実行時は既存のトップレベルキーを保持し、欠けているキーのみ補完)、`cmd/hermit/main.go` の `writeClaudeSettings` (`MergeDefaultSettings` を経由するよう変更)、`cmd/hermit/doctor.go` の `checkSandboxSettings` (3 種の警告)。テストは `internal/permissions/permissions_test.go` の `TestREQ018_*` 群 (`DefaultSandboxSettings` の `allowUnsandboxedCommands`/`GITHUB_TOKEN`/Go ツールチェーン許可ドメイン、`MergeDefaultSettings` の新規生成・既存 `permissions`/`sandbox` の保持・エラー経路) と `cmd/hermit/doctor_test.go` / `cmd/hermit/unit_test.go` の `TestREQ018_*` 群 (`checkSandboxSettings` の警告条件、`writeClaudeSettings` の再実行時非破壊)。`go build ./...` と `go test ./...` の成功、および `gh pr create` 相当操作の実行可能性は本 Issue #180 の PR 自体 (生成された設定下で `go test ./...` を通し、同じ worktree から `gh pr create` で PR を作成) によって実地検証済み。README の "Sandboxing the Engineer" セクションにスコープ precedence・managed settings・Issue #179 依存の記載を追加
+
+## REQ-019: `hermit run` — Superintendent ループを Claude Code セッションの外に出す
+
+Issue #181。無人運用のために利用者が `claude` を起動し `/hermit` を打ったセッションを永続的に保持し続ける必要がある、という #147→#172 を経てもなお残っていた制約を取り除くため、`hermit run` サブコマンドを追加する。長寿命の Go プロセスが内部 ticker を持ち、`hermit serve` が MCP サーバとして長寿命プロセスであるのと同じ形で、毎 tick `claude -p` を 1 回起動して完了を待つ。
+
+サブエージェント方式 (#147) への回帰は明示的に禁止する — #171 で「バックグラウンドサブエージェントを cron tick ごとに spawn し続けると、長時間運用でセッションの spawn 上限を静かに枯渇させる」という失敗モードが実証済みのため、`hermit run` は Claude Code のサブエージェントを一切 spawn しない設計でなければならない。
+
+- 受け入れ条件: (1) `hermit run` サブコマンドが存在し usage 出力に記載されている、(2) 内部 ticker で周期実行し前のパスが完了してから `[agent].loop_interval` (既定 270 秒) 待って次のパスを開始する、(3) パスが長時間かかっても重複起動しない、(4) 各 tick はプロジェクトルートを cwd として `claude -p` を非対話 (permission prompt でハングしない) で起動する、(5) `.hermit/superintendent-state.json` を Go 側が所有し、PR コメント確認/Issue コメント確認/要件スイープの 3 つの since タイムスタンプは `get_loop_state`/`update_loop_state` MCP ツール経由でのみ読み書きされる、(6) 最終成功 tick 時刻を記録し設定可能な N 回連続失敗で webhook 通知する、(7) `.hermit-paused`/`.hermit-quit` を `hermit run` 自身が検知する、(8) SIGINT/SIGTERM を受けても実行中のパスを中断せずグレースフルに停止する、(9) systemd unit / launchd plist / Windows サービス登録などの OS 固有コードは追加せず常駐化方法は README の案内に留める、(10) Windows amd64 でビルド・動作する、(11) `/hermit` スラッシュコマンドは廃止せず併存させる
+- verify: test
+- 実装状況: 実装済み — `internal/runloop` (`Run`/`Options`) が ticker・重複起動防止 (1 ループ内で Invoke を直列実行するのみで並行呼び出しの余地がない構造)・グレースフルシャットダウン (shutdown context はパス開始前とインターバル待機中のみ参照し、実行中の Invoke には渡さない)・`.hermit-paused`/`.hermit-quit` 検知・失敗連続カウントに応じた webhook 通知を実装。`internal/state` が `.hermit/superintendent-state.json` の読み書き (load-modify-save、一時ファイル+rename によるアトミック書き込み) を所有。`internal/mcp/tools.go` の `get_loop_state`/`update_loop_state` ツールが 3 つの since タイムスタンプを internal/state 経由で読み書きし、Superintendent サイクルが同ファイルを直接書き込む経路は存在しない。`cmd/hermit/main.go` の `cmdRun`/`newClaudeInvoker`/`buildClaudeRunArgs` が `hermit run` サブコマンド本体・非対話 `claude` 起動 (`--dangerously-skip-permissions`)・SIGINT/SIGTERM (`signal.NotifyContext`) を実装し、`go build`/`go test` を `GOOS=windows GOARCH=amd64` で実行して Windows ビルドを確認済み。systemd/launchd/Windows サービス/tmux/Docker の常駐化案内は README「Running `hermit run` Continuously」節に追記。テストは `internal/runloop/runloop_test.go`・`internal/state/state_test.go`・`internal/mcp/req_test.go`・`cmd/hermit/run_test.go` の `TestREQ019_*`
diff --git a/cmd/hermit/main.go b/cmd/hermit/main.go
index a6ed891..e84d3af 100644
--- a/cmd/hermit/main.go
+++ b/cmd/hermit/main.go
@@ -2,25 +2,31 @@ package main
import (
"bufio"
+ "context"
"embed"
"fmt"
"log"
"os"
"os/exec"
+ "os/signal"
"path/filepath"
"strconv"
"strings"
+ "syscall"
"text/template"
+ "time"
"github.com/BurntSushi/toml"
"github.com/ytnobody/hermit/internal/git"
gh "github.com/ytnobody/hermit/internal/github"
"github.com/ytnobody/hermit/internal/mcp"
+ "github.com/ytnobody/hermit/internal/notification"
"github.com/ytnobody/hermit/internal/permissions"
"github.com/ytnobody/hermit/internal/readiness"
"github.com/ytnobody/hermit/internal/requirements"
"github.com/ytnobody/hermit/internal/risk"
+ "github.com/ytnobody/hermit/internal/runloop"
)
//go:embed templates/* templates/commands/*
@@ -173,6 +179,17 @@ type Config struct {
// "allow everyone" fallback; it resolves to this same safe default.
TrustedAuthorAssociations []string `toml:"trusted_author_associations"`
} `toml:"security"`
+ // Run configures `hermit run` (Issue #181), the long-lived process that
+ // owns the Superintendent tick loop outside of any Claude Code session.
+ Run struct {
+ // FailureNotifyThreshold is the number of consecutive failed passes
+ // after which `hermit run` sends a webhook notification (using
+ // [notification]). <= 0 falls back to defaultFailureNotifyThreshold,
+ // matching this codebase's existing "<=0 means default" convention
+ // (see LoopInterval/MaxEngineers in loadConfig) rather than treating
+ // 0 as "disabled".
+ FailureNotifyThreshold int `toml:"failure_notify_threshold"`
+ } `toml:"run"`
}
// resolveRiskConfig builds the effective default risk.Config (harness.toml's
@@ -242,6 +259,8 @@ func main() {
switch os.Args[1] {
case "serve":
cmdServe()
+ case "run":
+ cmdRun()
case "install":
cmdInstall()
case "init":
@@ -282,7 +301,7 @@ func main() {
}
func usage() {
- fmt.Fprintln(os.Stderr, "Usage: hermit ")
+ fmt.Fprintln(os.Stderr, "Usage: hermit ")
}
const pauseFile = ".hermit-paused"
@@ -525,6 +544,101 @@ func cmdServe() {
}
}
+// defaultFailureNotifyThreshold is the fallback [run].failure_notify_threshold
+// value applied when harness.toml omits it or sets it to a non-positive
+// number (see Config.Run's doc comment).
+const defaultFailureNotifyThreshold = 3
+
+// claudeMdFile is the prompt file `hermit run` feeds to `claude -p` on every
+// tick, read fresh each time so edits to CLAUDE.md take effect on the very
+// next tick without restarting `hermit run`.
+const claudeMdFile = "CLAUDE.md"
+
+// buildClaudeRunArgs builds the argument list for the non-interactive
+// `claude` invocation `hermit run` performs on every tick. It mirrors the
+// pattern documented in docs/github-actions.md for driving Claude Code
+// unattended: `--dangerously-skip-permissions` so a tick never blocks on a
+// permission prompt (Issue #181's "非対話で完走すること — permission prompt で
+// ハングしない"), `--model` when a Superintendent model is configured, and
+// `-p ` with the full CLAUDE.md contents as the prompt.
+func buildClaudeRunArgs(superintendentModel, promptBody string) []string {
+ args := []string{"--dangerously-skip-permissions"}
+ if superintendentModel != "" {
+ args = append(args, "--model", superintendentModel)
+ }
+ args = append(args, "-p", promptBody)
+ return args
+}
+
+// newClaudeInvoker returns a runloop.Invoker that runs `claude` once,
+// non-interactively, using dir as both the working directory and the
+// location CLAUDE.md is read from (so `hermit run` always reflects the
+// current CLAUDE.md, even if it was edited between ticks).
+func newClaudeInvoker(claudeBin, superintendentModel string) runloop.Invoker {
+ return func(ctx context.Context, dir string) error {
+ promptBody, err := os.ReadFile(filepath.Join(dir, claudeMdFile))
+ if err != nil {
+ return fmt.Errorf("reading %s: %w", claudeMdFile, err)
+ }
+ args := buildClaudeRunArgs(superintendentModel, string(promptBody))
+ cmd := exec.CommandContext(ctx, claudeBin, args...)
+ cmd.Dir = dir
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmd.Stdin = nil
+ return cmd.Run()
+ }
+}
+
+// cmdRun implements `hermit run` (Issue #181): a long-lived process, external
+// to any Claude Code session, that ticks the Superintendent cycle by
+// launching `claude -p` once per tick and waiting for it to finish. This
+// replaces the requirement that a human keep a `claude` session with /hermit
+// open indefinitely — see runloop.Run's doc comment for the tick/overlap/
+// graceful-shutdown semantics, and internal/state for the
+// .hermit/superintendent-state.json file this owns.
+//
+// `hermit run` and the CLAUDE.md-driven `/hermit` slash command are not
+// mutually exclusive: a project may keep using `/hermit` interactively while
+// also running `hermit run` unattended (or vice versa) — see Issue #181's
+// explicit "スコープ外" on retiring the slash command.
+func cmdRun() {
+ cfg := loadConfig()
+ rootDir, err := os.Getwd()
+ if err != nil {
+ fatal(err.Error())
+ }
+ if _, err := os.Stat(filepath.Join(rootDir, claudeMdFile)); err != nil {
+ fatal(fmt.Sprintf("%s not found in %s; run `hermit init` first", claudeMdFile, rootDir))
+ }
+
+ threshold := cfg.Run.FailureNotifyThreshold
+ if threshold <= 0 {
+ threshold = defaultFailureNotifyThreshold
+ }
+ interval := time.Duration(cfg.Agent.LoopInterval) * time.Second
+
+ fmt.Printf("hermit run: starting (interval: %s, failure_notify_threshold: %d)\n", interval, threshold)
+ fmt.Println("hermit run: press Ctrl-C (SIGINT) or send SIGTERM to stop gracefully after the current pass")
+
+ shutdownCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ opts := runloop.Options{
+ RootDir: rootDir,
+ Invoke: newClaudeInvoker("claude", cfg.Model.Superintendent),
+ Interval: interval,
+ FailureNotifyThreshold: threshold,
+ WebhookURL: cfg.Notification.WebhookURL,
+ WebhookType: cfg.Notification.Type,
+ Notify: notification.Send,
+ }
+ if err := runloop.Run(shutdownCtx, opts); err != nil {
+ fatal(err.Error())
+ }
+ fmt.Println("hermit run: stopped")
+}
+
// resolveHearingPaths returns the effective list of candidate
// requirements-document paths used by runRequirementsHearingCheck.
//
diff --git a/cmd/hermit/run_test.go b/cmd/hermit/run_test.go
new file mode 100644
index 0000000..23b1c3d
--- /dev/null
+++ b/cmd/hermit/run_test.go
@@ -0,0 +1,230 @@
+package main
+
+// Tests for `hermit run` (Issue #181): the long-lived process that ticks the
+// Superintendent cycle by launching `claude -p` on an interval, outside of
+// any Claude Code session. TestREQ019_* functions verify REQUIREMENTS.md's
+// REQ-019 acceptance criteria; the plain-named tests exercise supporting
+// helpers not directly named by the REQ-019 acceptance criteria.
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/BurntSushi/toml"
+)
+
+// TestREQ019_UsageMentionsRun verifies `hermit run` is documented in the
+// top-level usage/help text, per REQ-019's "hermit --help / usage に記載される".
+func TestREQ019_UsageMentionsRun(t *testing.T) {
+ r, w, _ := os.Pipe()
+ orig := os.Stderr
+ os.Stderr = w
+ usage()
+ w.Close()
+ os.Stderr = orig
+
+ buf := make([]byte, 4096)
+ n, _ := r.Read(buf)
+ out := string(buf[:n])
+ if !strings.Contains(out, "run") {
+ t.Errorf("usage() output does not mention the run subcommand: %q", out)
+ }
+}
+
+// TestREQ019_MainSwitchDispatchesRun verifies "run" is wired into main's
+// subcommand dispatch (not just usage text).
+func TestREQ019_MainSwitchDispatchesRun(t *testing.T) {
+ found := false
+ for _, name := range []string{"serve", "run", "install", "init", "pause", "resume", "quit", "status", "use", "version", "upgrade", "cleanup", "doctor", "dry-run"} {
+ if name == "run" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("sanity check failed: 'run' missing from the expected subcommand list")
+ }
+ // The authoritative check: cmdRun must exist and be callable (compile-time
+ // guarantee) — see TestCmdRunFatal_ClaudeMdMissing below for a behavioral
+ // exercise of the actual switch-case-invoked function.
+ var _ = cmdRun
+}
+
+// TestREQ019_BuildClaudeRunArgsNonInteractive verifies the constructed
+// `claude` invocation matches the non-interactive pattern documented in
+// docs/github-actions.md: --dangerously-skip-permissions is always present
+// (REQ-019's "非対話で完走すること — permission prompt でハングしない"), --model is
+// included only when a Superintendent model is configured, and the prompt is
+// passed via -p.
+func TestREQ019_BuildClaudeRunArgsNonInteractive(t *testing.T) {
+ args := buildClaudeRunArgs("claude-sonnet-5", "prompt body")
+ joined := strings.Join(args, "\x00")
+ if !strings.Contains(joined, "--dangerously-skip-permissions") {
+ t.Errorf("args missing --dangerously-skip-permissions: %v", args)
+ }
+ if !strings.Contains(joined, "--model\x00claude-sonnet-5") {
+ t.Errorf("args missing --model claude-sonnet-5: %v", args)
+ }
+ if !strings.Contains(joined, "-p\x00prompt body") {
+ t.Errorf("args missing -p 'prompt body': %v", args)
+ }
+
+ argsNoModel := buildClaudeRunArgs("", "prompt body")
+ for _, a := range argsNoModel {
+ if a == "--model" {
+ t.Errorf("args should omit --model when no Superintendent model is configured: %v", argsNoModel)
+ }
+ }
+}
+
+// TestREQ019_NewClaudeInvokerRunsClaudeInProjectRoot verifies each tick's
+// `claude` invocation uses the project root as its working directory and
+// reads CLAUDE.md content fresh from that directory as the prompt — REQ-019's
+// "各tickはプロジェクトルートをcwdとして claude -p を起動する".
+func TestREQ019_NewClaudeInvokerRunsClaudeInProjectRoot(t *testing.T) {
+ dir := t.TempDir()
+ claudeMd := "the superintendent prompt"
+ if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte(claudeMd), 0o644); err != nil {
+ t.Fatalf("write CLAUDE.md: %v", err)
+ }
+
+ // Fake `claude` binary that records its cwd and args to a file.
+ fakeBinDir := t.TempDir()
+ recordPath := filepath.Join(dir, "invocation.txt")
+ script := "#!/bin/sh\npwd > \"" + recordPath + "\"\nprintf '%s\\n' \"$@\" >> \"" + recordPath + "\"\nexit 0\n"
+ if err := os.WriteFile(filepath.Join(fakeBinDir, "claude"), []byte(script), 0o755); err != nil {
+ t.Fatalf("write fake claude: %v", err)
+ }
+ t.Setenv("PATH", fakeBinDir+":"+os.Getenv("PATH"))
+
+ invoke := newClaudeInvoker("claude", "claude-sonnet-5")
+ if err := invoke(context.Background(), dir); err != nil {
+ t.Fatalf("invoke: %v", err)
+ }
+
+ recorded, err := os.ReadFile(recordPath)
+ if err != nil {
+ t.Fatalf("reading invocation record: %v", err)
+ }
+ got := string(recorded)
+
+ resolvedDir, _ := filepath.EvalSymlinks(dir)
+ resolvedGot, _ := filepath.EvalSymlinks(strings.TrimSpace(strings.SplitN(got, "\n", 2)[0]))
+ if resolvedGot != resolvedDir {
+ t.Errorf("claude ran with cwd %q, want %q", resolvedGot, resolvedDir)
+ }
+ if !strings.Contains(got, claudeMd) {
+ t.Errorf("claude was not invoked with CLAUDE.md's contents as the prompt: %q", got)
+ }
+ if !strings.Contains(got, "--dangerously-skip-permissions") {
+ t.Errorf("claude was not invoked non-interactively: %q", got)
+ }
+}
+
+// TestCmdRunFatal_ClaudeMdMissing verifies cmdRun fails fast (before ever
+// starting the tick loop) when CLAUDE.md is missing from the project root,
+// instead of looping forever invoking a `claude -p` that would immediately
+// error on every tick.
+func TestCmdRunFatal_ClaudeMdMissing(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "harness.toml"), []byte(minimalHarnessTOML), 0o644); err != nil {
+ t.Fatalf("write harness.toml: %v", err)
+ }
+ prev, _ := os.Getwd()
+ if err := os.Chdir(dir); err != nil {
+ t.Fatal(err)
+ }
+ defer os.Chdir(prev)
+
+ msg := catchFatal(t, func() { cmdRun() })
+ if !strings.Contains(msg, "CLAUDE.md") {
+ t.Errorf("expected a CLAUDE.md fatal message, got: %q", msg)
+ }
+}
+
+// TestConfigRunSectionParsing verifies harness.toml's [run] section decodes
+// into Config.Run.FailureNotifyThreshold.
+func TestConfigRunSectionParsing(t *testing.T) {
+ src := minimalHarnessTOML + "\n[run]\nfailure_notify_threshold = 5\n"
+ var cfg Config
+ if _, err := toml.Decode(src, &cfg); err != nil {
+ t.Fatalf("decoding harness.toml: %v", err)
+ }
+ if cfg.Run.FailureNotifyThreshold != 5 {
+ t.Errorf("Run.FailureNotifyThreshold = %d, want 5", cfg.Run.FailureNotifyThreshold)
+ }
+}
+
+// TestConfigRunSectionDefaultsToZeroWhenAbsent verifies an unconfigured
+// [run] section decodes to the zero value, which cmdRun then maps to
+// defaultFailureNotifyThreshold (mirroring how LoopInterval/MaxEngineers
+// handle "<=0 means default" elsewhere in loadConfig).
+func TestConfigRunSectionDefaultsToZeroWhenAbsent(t *testing.T) {
+ var cfg Config
+ if _, err := toml.Decode(minimalHarnessTOML, &cfg); err != nil {
+ t.Fatalf("decoding harness.toml: %v", err)
+ }
+ if cfg.Run.FailureNotifyThreshold != 0 {
+ t.Errorf("Run.FailureNotifyThreshold = %d, want 0 (unset) before cmdRun applies its default", cfg.Run.FailureNotifyThreshold)
+ }
+}
+
+// TestNewClaudeInvokerPropagatesClaudeFailure verifies a non-zero `claude`
+// exit code surfaces as an error from the Invoker, so runloop.Run's
+// failure-counting/notification logic actually engages on a broken tick.
+func TestNewClaudeInvokerPropagatesClaudeFailure(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("prompt"), 0o644); err != nil {
+ t.Fatalf("write CLAUDE.md: %v", err)
+ }
+
+ fakeBinDir := t.TempDir()
+ script := "#!/bin/sh\nexit 1\n"
+ if err := os.WriteFile(filepath.Join(fakeBinDir, "claude"), []byte(script), 0o755); err != nil {
+ t.Fatalf("write fake claude: %v", err)
+ }
+ t.Setenv("PATH", fakeBinDir+":"+os.Getenv("PATH"))
+
+ invoke := newClaudeInvoker("claude", "")
+ if err := invoke(context.Background(), dir); err == nil {
+ t.Error("expected an error when the claude invocation exits non-zero")
+ }
+}
+
+// Sanity: exec.Command / exec.CommandContext usage in newClaudeInvoker must
+// not require a shell (no shell metacharacter interpretation of the prompt
+// body), otherwise a CLAUDE.md containing shell-special characters (quotes,
+// backticks, $()) could corrupt or fail the invocation. This is implicit in
+// using exec.CommandContext with a []string arg list (never exec.Command via
+// "sh -c"), verified indirectly by TestREQ019_NewClaudeInvokerRunsClaudeInProjectRoot
+// above using a CLAUDE.md body without special characters; this test uses one
+// that does.
+func TestNewClaudeInvokerHandlesShellSpecialCharsInPrompt(t *testing.T) {
+ dir := t.TempDir()
+ claudeMd := "prompt with `backticks`, $(command), and \"quotes\""
+ if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte(claudeMd), 0o644); err != nil {
+ t.Fatalf("write CLAUDE.md: %v", err)
+ }
+
+ fakeBinDir := t.TempDir()
+ recordPath := filepath.Join(dir, "invocation.txt")
+ script := "#!/bin/sh\nprintf '%s' \"$3\" > \"" + recordPath + "\"\nexit 0\n"
+ if err := os.WriteFile(filepath.Join(fakeBinDir, "claude"), []byte(script), 0o755); err != nil {
+ t.Fatalf("write fake claude: %v", err)
+ }
+ t.Setenv("PATH", fakeBinDir+":"+os.Getenv("PATH"))
+
+ invoke := newClaudeInvoker("claude", "")
+ if err := invoke(context.Background(), dir); err != nil {
+ t.Fatalf("invoke: %v", err)
+ }
+ got, err := os.ReadFile(recordPath)
+ if err != nil {
+ t.Fatalf("reading invocation record: %v", err)
+ }
+ if string(got) != claudeMd {
+ t.Errorf("prompt arg = %q, want %q (shell metacharacters must reach claude literally, unexpanded)", string(got), claudeMd)
+ }
+}
diff --git a/cmd/hermit/serve_test.go b/cmd/hermit/serve_test.go
index 2c5055e..6e67c26 100644
--- a/cmd/hermit/serve_test.go
+++ b/cmd/hermit/serve_test.go
@@ -103,8 +103,8 @@ language = "ja"
continue
}
tools, _ := msg.Result["tools"].([]any)
- if len(tools) != 18 {
- t.Errorf("expected 18 tools, got %d", len(tools))
+ if len(tools) != 20 {
+ t.Errorf("expected 20 tools, got %d", len(tools))
}
return
case <-deadline:
@@ -207,8 +207,8 @@ language = "ja"
continue
}
tools, _ := msg.Result["tools"].([]any)
- if len(tools) != 18 {
- t.Errorf("expected 18 tools, got %d", len(tools))
+ if len(tools) != 20 {
+ t.Errorf("expected 20 tools, got %d", len(tools))
}
return
case <-deadline:
@@ -314,8 +314,8 @@ language = "ja"
continue
}
tools, _ := msg.Result["tools"].([]any)
- if len(tools) != 18 {
- t.Errorf("expected 18 tools, got %d", len(tools))
+ if len(tools) != 20 {
+ t.Errorf("expected 20 tools, got %d", len(tools))
}
return
case <-deadline:
diff --git a/cmd/hermit/templates/harness.toml.tmpl b/cmd/hermit/templates/harness.toml.tmpl
index f5694dd..9b3b5e3 100644
--- a/cmd/hermit/templates/harness.toml.tmpl
+++ b/cmd/hermit/templates/harness.toml.tmpl
@@ -76,3 +76,6 @@ analyst_effort = "{{ .AnalystEffort }}" # reasoning effort: low/medium/
# # There is no config knob to disable the check; delete this comment block's
# # intent by simply keeping a requirements doc at one of the default paths.
# paths = ["REQUIREMENTS.md", "docs/requirements.md"] # any one of these existing is OK (default shown)
+
+# [run]
+# failure_notify_threshold = 3 # `hermit run`: consecutive failed passes before a [notification] webhook fires (default: 3)
diff --git a/internal/mcp/req_test.go b/internal/mcp/req_test.go
index 88ba5dc..771408f 100644
--- a/internal/mcp/req_test.go
+++ b/internal/mcp/req_test.go
@@ -526,3 +526,103 @@ func TestREQ008_MergePR_WorktreeCleanup(t *testing.T) {
// errMergeFailed is a sentinel merge error for TestREQ008_MergePR_WorktreeCleanup.
var errMergeFailed = errors.New("merge failed (test)")
+
+// TestREQ019_GetLoopState_EmptyWhenNoStateFile verifies get_loop_state
+// succeeds and reports nothing recorded yet when
+// .hermit/superintendent-state.json does not exist — the state a fresh
+// project starts in before `hermit run` or update_loop_state have ever
+// touched it.
+func TestREQ019_GetLoopState_EmptyWhenNoStateFile(t *testing.T) {
+ s, _ := newTestServerWithRoot(t, &mockGithubClient{})
+
+ result := callTool(t, s, "get_loop_state", map[string]any{})
+ if result.IsError {
+ t.Fatalf("expected success, got error: %v", result.Content)
+ }
+ var resp map[string]any
+ decodeToolResult(t, result, &resp)
+ for _, key := range []string{"pr_comments_since", "issue_comments_since", "requirements_sweep_since", "last_success_tick"} {
+ if _, ok := resp[key]; ok {
+ t.Errorf("expected %q to be absent on a fresh project, got %v", key, resp[key])
+ }
+ }
+ if cf, _ := resp["consecutive_failures"].(float64); cf != 0 {
+ t.Errorf("consecutive_failures = %v, want 0", resp["consecutive_failures"])
+ }
+}
+
+// TestREQ019_UpdateLoopState_PersistsViaMCPTool verifies the core of Issue
+// #181's ".hermit/superintendent-state.json をGo側が所有する。3つのタイムスタンプの
+// 読み書きはMCPツール経由で行う" requirement: update_loop_state writes the three
+// cadence timestamps to the Go-owned state file, get_loop_state reads them
+// back, and a partial update leaves previously-set fields untouched.
+func TestREQ019_UpdateLoopState_PersistsViaMCPTool(t *testing.T) {
+ s, root := newTestServerWithRoot(t, &mockGithubClient{})
+
+ result := callTool(t, s, "update_loop_state", map[string]any{
+ "pr_comments_since": "2026-07-28T10:00:00Z",
+ "issue_comments_since": "2026-07-28T11:00:00Z",
+ })
+ if result.IsError {
+ t.Fatalf("update_loop_state: expected success, got error: %v", result.Content)
+ }
+
+ // The file must actually exist on disk under .hermit/, owned by Go, not
+ // hand-written by the calling session.
+ if _, err := os.Stat(filepath.Join(root, ".hermit", "superintendent-state.json")); err != nil {
+ t.Fatalf(".hermit/superintendent-state.json not created by update_loop_state: %v", err)
+ }
+
+ // Partial update: only requirements_sweep_since is set this time; the
+ // two fields set above must survive untouched.
+ result = callTool(t, s, "update_loop_state", map[string]any{
+ "requirements_sweep_since": "2026-07-28T12:00:00Z",
+ })
+ if result.IsError {
+ t.Fatalf("update_loop_state (partial): expected success, got error: %v", result.Content)
+ }
+
+ result = callTool(t, s, "get_loop_state", map[string]any{})
+ if result.IsError {
+ t.Fatalf("get_loop_state: expected success, got error: %v", result.Content)
+ }
+ var resp map[string]any
+ decodeToolResult(t, result, &resp)
+ if resp["pr_comments_since"] != "2026-07-28T10:00:00Z" {
+ t.Errorf("pr_comments_since = %v, want 2026-07-28T10:00:00Z", resp["pr_comments_since"])
+ }
+ if resp["issue_comments_since"] != "2026-07-28T11:00:00Z" {
+ t.Errorf("issue_comments_since = %v, want 2026-07-28T11:00:00Z", resp["issue_comments_since"])
+ }
+ if resp["requirements_sweep_since"] != "2026-07-28T12:00:00Z" {
+ t.Errorf("requirements_sweep_since = %v, want 2026-07-28T12:00:00Z", resp["requirements_sweep_since"])
+ }
+}
+
+// TestREQ019_UpdateLoopState_RejectsInvalidTimestamp verifies update_loop_state
+// validates its RFC3339 inputs rather than silently persisting garbage.
+func TestREQ019_UpdateLoopState_RejectsInvalidTimestamp(t *testing.T) {
+ s, _ := newTestServerWithRoot(t, &mockGithubClient{})
+
+ result := callTool(t, s, "update_loop_state", map[string]any{
+ "pr_comments_since": "not-a-timestamp",
+ })
+ if !result.IsError {
+ t.Fatalf("expected an error result for an invalid timestamp, got %v", result.Content)
+ }
+}
+
+// decodeToolResult decodes the JSON text content of a tool result into out.
+func decodeToolResult(t *testing.T, result *mcp.CallToolResult, out any) {
+ t.Helper()
+ if len(result.Content) == 0 {
+ t.Fatalf("tool result has no content")
+ }
+ tc, ok := result.Content[0].(mcp.TextContent)
+ if !ok {
+ t.Fatalf("tool result content is not text: %#v", result.Content[0])
+ }
+ if err := json.Unmarshal([]byte(tc.Text), out); err != nil {
+ t.Fatalf("decoding tool result: %v", err)
+ }
+}
diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go
index 8749e3b..3298f72 100644
--- a/internal/mcp/tools.go
+++ b/internal/mcp/tools.go
@@ -17,6 +17,7 @@ import (
"github.com/ytnobody/hermit/internal/readiness"
"github.com/ytnobody/hermit/internal/requirements"
"github.com/ytnobody/hermit/internal/risk"
+ "github.com/ytnobody/hermit/internal/state"
)
// clarificationTrigger is the marker HERMIT looks for in Issue/PR comments to
@@ -551,6 +552,62 @@ func registerTools(s *server.MCPServer, client githubClient, rateLimitThreshold
},
)
+ s.AddTool(
+ mcp.NewTool("get_loop_state",
+ mcp.WithDescription("Returns the cadence-tracking timestamps persisted in .hermit/superintendent-state.json: pr_comments_since, issue_comments_since, and requirements_sweep_since (RFC3339, omitted if never recorded) — the 'since' values the Superintendent cycle uses to decide when it last checked PR comments, checked Issue comments, and ran the requirements sweep. Also reports last_success_tick and consecutive_failures, written by `hermit run`'s own tick loop. This file is owned by HERMIT's Go side: read/write the three cadence timestamps only via this tool and update_loop_state, never by hand-writing the JSON file."),
+ ),
+ func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ st, err := state.Load(state.Path(rootDir))
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ b, _ := json.Marshal(loopStateResponse(st))
+ return mcp.NewToolResultText(string(b)), nil
+ },
+ )
+
+ s.AddTool(
+ mcp.NewTool("update_loop_state",
+ mcp.WithDescription("Updates one or more of the cadence-tracking timestamps in .hermit/superintendent-state.json: pr_comments_since, issue_comments_since, requirements_sweep_since, each an RFC3339 timestamp. Only the fields provided are changed; omitted fields are left as-is. Call the now tool first to get an authoritative current timestamp to pass in, then use this instead of writing the JSON file directly. Returns the full updated state."),
+ mcp.WithString("pr_comments_since", mcp.Description("RFC3339 timestamp to record as the last PR-review-comment check time")),
+ mcp.WithString("issue_comments_since", mcp.Description("RFC3339 timestamp to record as the last Issue-comment check time")),
+ mcp.WithString("requirements_sweep_since", mcp.Description("RFC3339 timestamp to record as the last requirements-sweep time")),
+ ),
+ func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ statePath := state.Path(rootDir)
+ st, err := state.Load(statePath)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ if v := req.GetString("pr_comments_since", ""); v != "" {
+ t, err := time.Parse(time.RFC3339, v)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("pr_comments_since: %v", err)), nil
+ }
+ st.PRCommentsSince = &t
+ }
+ if v := req.GetString("issue_comments_since", ""); v != "" {
+ t, err := time.Parse(time.RFC3339, v)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("issue_comments_since: %v", err)), nil
+ }
+ st.IssueCommentsSince = &t
+ }
+ if v := req.GetString("requirements_sweep_since", ""); v != "" {
+ t, err := time.Parse(time.RFC3339, v)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("requirements_sweep_since: %v", err)), nil
+ }
+ st.RequirementsSweepSince = &t
+ }
+ if err := state.Save(statePath, st); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ b, _ := json.Marshal(loopStateResponse(st))
+ return mcp.NewToolResultText(string(b)), nil
+ },
+ )
+
s.AddTool(
mcp.NewTool("review_pr",
mcp.WithDescription("Posts a structured automated review comment on a PR based on static analysis of the diff"),
@@ -701,3 +758,23 @@ func registerTools(s *server.MCPServer, client githubClient, rateLimitThreshold
},
)
}
+
+// loopStateResponse converts a state.LoopState into the JSON-friendly shape
+// returned by get_loop_state / update_loop_state: each timestamp is an
+// RFC3339 string when set, or omitted entirely when nil, so callers can
+// treat a missing key the same way as "never recorded".
+func loopStateResponse(st state.LoopState) map[string]any {
+ resp := map[string]any{
+ "consecutive_failures": st.ConsecutiveFailures,
+ }
+ setIfNotNil := func(key string, t *time.Time) {
+ if t != nil {
+ resp[key] = t.UTC().Format(time.RFC3339)
+ }
+ }
+ setIfNotNil("pr_comments_since", st.PRCommentsSince)
+ setIfNotNil("issue_comments_since", st.IssueCommentsSince)
+ setIfNotNil("requirements_sweep_since", st.RequirementsSweepSince)
+ setIfNotNil("last_success_tick", st.LastSuccessTick)
+ return resp
+}
diff --git a/internal/runloop/runloop.go b/internal/runloop/runloop.go
new file mode 100644
index 0000000..c6f53de
--- /dev/null
+++ b/internal/runloop/runloop.go
@@ -0,0 +1,210 @@
+// Package runloop implements the ticker that drives `hermit run` (Issue
+// #181): a long-lived process, external to any Claude Code session, that
+// invokes one Superintendent pass at a time and waits [agent].loop_interval
+// after each pass completes before starting the next one.
+//
+// This intentionally replaces the Claude-Code-session-hosted `/loop`
+// mechanism (previously driven by CLAUDE.md's Superintendent cycle) as the
+// thing that keeps ticking. It does NOT reintroduce a background-subagent
+// loop inside a Claude Code session (issue #147's design, which exhausted
+// the session's subagent-spawn cap per issue #171) — there is no subagent
+// spawn here at all; each tick is a plain child-process invocation
+// (`claude -p ...`) of a Go long-lived process, the same shape `hermit
+// serve` already uses for the MCP server.
+package runloop
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/ytnobody/hermit/internal/state"
+)
+
+// Invoker runs one Superintendent pass to completion, using dir as the
+// working directory, and returns any error. ctx passed to Invoke is always
+// context.Background()-derived from Run's perspective, never the shutdown
+// context — see Run's doc comment for why a pass is never interrupted.
+type Invoker func(ctx context.Context, dir string) error
+
+// NotifyFunc sends a notification (e.g. internal/notification.Send).
+type NotifyFunc func(webhookURL, webhookType, event, message string) error
+
+// Options configures Run.
+type Options struct {
+ // RootDir is the project root: both the working directory passed to
+ // Invoke and the directory containing .hermit/superintendent-state.json.
+ RootDir string
+
+ // Invoke runs one Superintendent pass. Required.
+ Invoke Invoker
+
+ // Interval is how long Run waits after a pass finishes before starting
+ // the next one (harness.toml's [agent].loop_interval).
+ Interval time.Duration
+
+ // FailureNotifyThreshold is the number of consecutive failed passes
+ // after which Run sends one notification via Notify. <= 0 disables
+ // failure notification.
+ FailureNotifyThreshold int
+
+ // WebhookURL / WebhookType are passed through to Notify.
+ WebhookURL string
+ WebhookType string
+
+ // Notify sends the failure notification. Required only when
+ // FailureNotifyThreshold > 0; if nil in that case, Run logs a warning
+ // and skips notifying rather than panicking.
+ Notify NotifyFunc
+
+ // Now returns the current time; defaults to time.Now when nil.
+ Now func() time.Time
+
+ // Logger receives progress/diagnostic output; defaults to log.Default()
+ // when nil.
+ Logger *log.Logger
+}
+
+// pauseFileName / quitFileName mirror cmd/hermit's own pauseFile/quitFile
+// constants (".hermit-paused" / ".hermit-quit"). Duplicated here (rather
+// than imported) to keep this package free of an import-cycle risk back
+// into cmd/hermit; both sides are simple constants unlikely to drift.
+const (
+ pauseFileName = ".hermit-paused"
+ quitFileName = ".hermit-quit"
+)
+
+// sleeper abstracts waiting for Interval so tests don't need real time.
+// It returns true if the wait was cut short by shutdownCtx being canceled.
+type sleeper func(shutdownCtx context.Context, d time.Duration) (canceled bool)
+
+func defaultSleeper(shutdownCtx context.Context, d time.Duration) bool {
+ if d <= 0 {
+ select {
+ case <-shutdownCtx.Done():
+ return true
+ default:
+ return false
+ }
+ }
+ t := time.NewTimer(d)
+ defer t.Stop()
+ select {
+ case <-shutdownCtx.Done():
+ return true
+ case <-t.C:
+ return false
+ }
+}
+
+// Run drives the tick loop until shutdownCtx is canceled (SIGINT/SIGTERM in
+// `hermit run`'s case) or .hermit-quit is detected in RootDir.
+//
+// Overlap safety: each iteration blocks on opts.Invoke before doing
+// anything else in the loop body, so two passes running at once is
+// structurally impossible within one Run call — there is no separate
+// goroutine or timer that could fire a second Invoke while the first is
+// still in flight.
+//
+// Graceful shutdown: shutdownCtx is only ever consulted BETWEEN passes —
+// before starting the next Invoke call, and while waiting out Interval — and
+// is deliberately never wired into the context passed to Invoke itself
+// (Invoke always receives a fresh context.Background()-derived context).
+// This means SIGINT/SIGTERM never aborts a Superintendent pass that has
+// already started; Run finishes the current pass, records its result, and
+// then stops instead of starting another one.
+func Run(shutdownCtx context.Context, opts Options) error {
+ return run(shutdownCtx, opts, defaultSleeper)
+}
+
+func run(shutdownCtx context.Context, opts Options, sleep sleeper) error {
+ if opts.Invoke == nil {
+ return fmt.Errorf("runloop: Options.Invoke is required")
+ }
+ now := opts.Now
+ if now == nil {
+ now = time.Now
+ }
+ logger := opts.Logger
+ if logger == nil {
+ logger = log.Default()
+ }
+ statePath := state.Path(opts.RootDir)
+ pausePath := filepath.Join(opts.RootDir, pauseFileName)
+ quitPath := filepath.Join(opts.RootDir, quitFileName)
+
+ for {
+ if shutdownRequested(shutdownCtx) {
+ logger.Println("hermit run: shutdown signal received, stopping")
+ return nil
+ }
+ if fileExists(quitPath) {
+ logger.Println("hermit run: .hermit-quit detected, stopping (not resumable; run `hermit run` again to restart)")
+ return nil
+ }
+ if fileExists(pausePath) {
+ logger.Println("hermit run: .hermit-paused detected, skipping this tick")
+ if sleep(shutdownCtx, opts.Interval) {
+ logger.Println("hermit run: shutdown signal received while paused, stopping")
+ return nil
+ }
+ continue
+ }
+
+ logger.Println("hermit run: starting pass")
+ // Deliberately context.Background(), not shutdownCtx: see Run's doc
+ // comment on why an in-flight pass is never interrupted.
+ passErr := opts.Invoke(context.Background(), opts.RootDir)
+
+ st, loadErr := state.Load(statePath)
+ if loadErr != nil {
+ logger.Printf("hermit run: warning: failed to load state: %v", loadErr)
+ }
+ n := now()
+ if passErr != nil {
+ st.ConsecutiveFailures++
+ logger.Printf("hermit run: pass failed (%d consecutive failure(s)): %v", st.ConsecutiveFailures, passErr)
+ if opts.FailureNotifyThreshold > 0 && st.ConsecutiveFailures == opts.FailureNotifyThreshold {
+ msg := fmt.Sprintf("HERMIT: %d consecutive `hermit run` passes have failed. Last error: %v", st.ConsecutiveFailures, passErr)
+ if opts.Notify == nil {
+ logger.Println("hermit run: warning: failure threshold reached but no Notify function configured")
+ } else if err := opts.Notify(opts.WebhookURL, opts.WebhookType, "run_failure", msg); err != nil {
+ logger.Printf("hermit run: warning: failed to send failure notification: %v", err)
+ }
+ }
+ } else {
+ st.ConsecutiveFailures = 0
+ st.LastSuccessTick = &n
+ logger.Println("hermit run: pass completed successfully")
+ }
+ if err := state.Save(statePath, st); err != nil {
+ logger.Printf("hermit run: warning: failed to save state: %v", err)
+ }
+
+ if shutdownRequested(shutdownCtx) {
+ logger.Println("hermit run: shutdown signal received after pass, stopping")
+ return nil
+ }
+ if sleep(shutdownCtx, opts.Interval) {
+ logger.Println("hermit run: shutdown signal received while waiting, stopping")
+ return nil
+ }
+ }
+}
+
+func shutdownRequested(ctx context.Context) bool {
+ select {
+ case <-ctx.Done():
+ return true
+ default:
+ return false
+ }
+}
+
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
diff --git a/internal/runloop/runloop_test.go b/internal/runloop/runloop_test.go
new file mode 100644
index 0000000..46b2377
--- /dev/null
+++ b/internal/runloop/runloop_test.go
@@ -0,0 +1,339 @@
+package runloop
+
+import (
+ "context"
+ "errors"
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/ytnobody/hermit/internal/state"
+)
+
+func testLogger() *log.Logger {
+ return log.New(discardWriter{}, "", 0)
+}
+
+type discardWriter struct{}
+
+func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
+
+func TestRun_RequiresInvoke(t *testing.T) {
+ err := Run(context.Background(), Options{RootDir: t.TempDir(), Logger: testLogger()})
+ if err == nil {
+ t.Fatal("Run with nil Invoke: want error, got nil")
+ }
+}
+
+// TestREQ019_NoOverlappingPasses verifies that Run never starts a new pass
+// before the previous one's Invoke call has returned, even when a pass
+// takes a while — Issue #181's "パスが長時間かかっても重複起動しない".
+func TestREQ019_NoOverlappingPasses(t *testing.T) {
+ dir := t.TempDir()
+
+ var (
+ mu sync.Mutex
+ concurrent int
+ maxSeen int
+ invokeCalls int32
+ )
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ invoke := func(ctx context.Context, d string) error {
+ mu.Lock()
+ concurrent++
+ if concurrent > maxSeen {
+ maxSeen = concurrent
+ }
+ mu.Unlock()
+
+ time.Sleep(10 * time.Millisecond)
+
+ mu.Lock()
+ concurrent--
+ mu.Unlock()
+
+ if atomic.AddInt32(&invokeCalls, 1) >= 3 {
+ cancel()
+ }
+ return nil
+ }
+
+ opts := Options{
+ RootDir: dir,
+ Invoke: invoke,
+ Interval: time.Millisecond,
+ Logger: testLogger(),
+ }
+ if err := Run(ctx, opts); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if maxSeen > 1 {
+ t.Errorf("observed %d concurrent passes, want at most 1", maxSeen)
+ }
+ if invokeCalls < 3 {
+ t.Errorf("invokeCalls = %d, want at least 3", invokeCalls)
+ }
+}
+
+// TestREQ019_GracefulShutdownDoesNotInterruptInFlightPass verifies that
+// canceling the shutdown context while a pass is running lets that pass run
+// to completion (its ctx is never canceled), and Run stops only after it
+// finishes — Issue #181's "SIGINT/SIGTERM で実行中のパスを中断せずグレースフルに
+// 停止する".
+func TestREQ019_GracefulShutdownDoesNotInterruptInFlightPass(t *testing.T) {
+ dir := t.TempDir()
+ shutdownCtx, cancel := context.WithCancel(context.Background())
+
+ var completed atomic.Bool
+ started := make(chan struct{})
+
+ invoke := func(passCtx context.Context, d string) error {
+ close(started)
+ // Cancel the shutdown signal partway through this pass.
+ cancel()
+ select {
+ case <-passCtx.Done():
+ t.Error("pass context was canceled; an in-flight pass must not be interrupted by shutdown")
+ case <-time.After(20 * time.Millisecond):
+ }
+ completed.Store(true)
+ return nil
+ }
+
+ opts := Options{
+ RootDir: dir,
+ Invoke: invoke,
+ Interval: time.Millisecond,
+ Logger: testLogger(),
+ }
+
+ done := make(chan error, 1)
+ go func() { done <- Run(shutdownCtx, opts) }()
+
+ <-started
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("Run did not stop after shutdown + one in-flight pass")
+ }
+ if !completed.Load() {
+ t.Error("pass did not complete before Run returned")
+ }
+}
+
+// TestREQ019_QuitFileStopsLoop verifies .hermit-quit is detected by the
+// run loop itself (not only by the invoked Claude session).
+func TestREQ019_QuitFileStopsLoop(t *testing.T) {
+ dir := t.TempDir()
+ var calls int32
+
+ invoke := func(ctx context.Context, d string) error {
+ n := atomic.AddInt32(&calls, 1)
+ if n == 1 {
+ if err := os.WriteFile(filepath.Join(dir, ".hermit-quit"), nil, 0o644); err != nil {
+ t.Fatalf("write quit file: %v", err)
+ }
+ }
+ return nil
+ }
+
+ opts := Options{
+ RootDir: dir,
+ Invoke: invoke,
+ Interval: time.Millisecond,
+ Logger: testLogger(),
+ }
+ done := make(chan error, 1)
+ go func() { done <- Run(context.Background(), opts) }()
+
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("Run did not stop after .hermit-quit was created")
+ }
+ if calls != 1 {
+ t.Errorf("invoke called %d times, want exactly 1 (loop should stop before a 2nd pass once quit is detected)", calls)
+ }
+}
+
+// TestREQ019_PauseFileSkipsPassesWithoutStoppingLoop verifies .hermit-paused
+// is detected by the run loop itself, and that it skips Invoke rather than
+// stopping the loop entirely.
+func TestREQ019_PauseFileSkipsPassesWithoutStoppingLoop(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".hermit-paused"), nil, 0o644); err != nil {
+ t.Fatalf("write pause file: %v", err)
+ }
+
+ var calls int32
+ invoke := func(ctx context.Context, d string) error {
+ atomic.AddInt32(&calls, 1)
+ return nil
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
+ defer cancel()
+
+ opts := Options{
+ RootDir: dir,
+ Invoke: invoke,
+ Interval: 2 * time.Millisecond,
+ Logger: testLogger(),
+ }
+ if err := Run(ctx, opts); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if calls != 0 {
+ t.Errorf("invoke called %d times while paused, want 0", calls)
+ }
+}
+
+// TestREQ019_RecordsSuccessAndResetsFailures verifies a successful pass
+// updates last_success_tick and resets consecutive_failures in
+// .hermit/superintendent-state.json — the Go-owned state file.
+func TestREQ019_RecordsSuccessAndResetsFailures(t *testing.T) {
+ dir := t.TempDir()
+ fixedNow := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
+
+ // Seed a prior failure count to verify it gets reset on success.
+ if err := state.Save(state.Path(dir), state.LoopState{ConsecutiveFailures: 2}); err != nil {
+ t.Fatalf("seed state: %v", err)
+ }
+
+ var calls int32
+ ctx, cancel := context.WithCancel(context.Background())
+ invoke := func(ctx context.Context, d string) error {
+ if atomic.AddInt32(&calls, 1) >= 1 {
+ cancel()
+ }
+ return nil
+ }
+
+ opts := Options{
+ RootDir: dir,
+ Invoke: invoke,
+ Interval: time.Millisecond,
+ Now: func() time.Time { return fixedNow },
+ Logger: testLogger(),
+ }
+ if err := Run(ctx, opts); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ st, err := state.Load(state.Path(dir))
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if st.ConsecutiveFailures != 0 {
+ t.Errorf("ConsecutiveFailures = %d, want 0 after success", st.ConsecutiveFailures)
+ }
+ if st.LastSuccessTick == nil || !st.LastSuccessTick.Equal(fixedNow) {
+ t.Errorf("LastSuccessTick = %v, want %v", st.LastSuccessTick, fixedNow)
+ }
+}
+
+// TestREQ019_NotifiesAfterConsecutiveFailureThreshold verifies the
+// liveness/notification requirement: after N consecutive failed passes, a
+// notification fires via the configured webhook (N configurable).
+func TestREQ019_NotifiesAfterConsecutiveFailureThreshold(t *testing.T) {
+ dir := t.TempDir()
+
+ var notifyCalls int32
+ var lastMsg string
+ notify := func(webhookURL, webhookType, event, message string) error {
+ atomic.AddInt32(¬ifyCalls, 1)
+ lastMsg = message
+ return nil
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ var calls int32
+ invoke := func(ctx context.Context, d string) error {
+ n := atomic.AddInt32(&calls, 1)
+ if n >= 3 {
+ cancel()
+ }
+ return errors.New("boom")
+ }
+
+ opts := Options{
+ RootDir: dir,
+ Invoke: invoke,
+ Interval: time.Millisecond,
+ FailureNotifyThreshold: 2,
+ Notify: notify,
+ Logger: testLogger(),
+ }
+ if err := Run(ctx, opts); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if notifyCalls != 1 {
+ t.Fatalf("notify called %d times, want exactly 1 (fires once when the threshold is first reached)", notifyCalls)
+ }
+ if lastMsg == "" {
+ t.Error("notification message was empty")
+ }
+
+ st, err := state.Load(state.Path(dir))
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if st.ConsecutiveFailures < 2 {
+ t.Errorf("ConsecutiveFailures = %d, want >= 2", st.ConsecutiveFailures)
+ }
+}
+
+// TestREQ019_ZeroThresholdDisablesNotification verifies
+// FailureNotifyThreshold <= 0 never calls Notify, matching the codebase's
+// existing "<=0 means use/disable default" convention (see loadConfig's
+// LoopInterval/MaxEngineers handling in cmd/hermit).
+func TestREQ019_ZeroThresholdDisablesNotification(t *testing.T) {
+ dir := t.TempDir()
+ var notifyCalls int32
+ notify := func(webhookURL, webhookType, event, message string) error {
+ atomic.AddInt32(¬ifyCalls, 1)
+ return nil
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ var calls int32
+ invoke := func(ctx context.Context, d string) error {
+ n := atomic.AddInt32(&calls, 1)
+ if n >= 5 {
+ cancel()
+ }
+ return errors.New("boom")
+ }
+
+ opts := Options{
+ RootDir: dir,
+ Invoke: invoke,
+ Interval: time.Millisecond,
+ FailureNotifyThreshold: 0,
+ Notify: notify,
+ Logger: testLogger(),
+ }
+ if err := Run(ctx, opts); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if notifyCalls != 0 {
+ t.Errorf("notify called %d times with threshold disabled, want 0", notifyCalls)
+ }
+}
diff --git a/internal/state/state.go b/internal/state/state.go
new file mode 100644
index 0000000..595d55a
--- /dev/null
+++ b/internal/state/state.go
@@ -0,0 +1,122 @@
+// Package state owns .hermit/superintendent-state.json, the persisted
+// cadence/liveness state for HERMIT's autonomous loop (Issue #181).
+//
+// Two independent writers share this one file:
+//
+// - `hermit run` (internal/runloop), the long-lived process that owns the
+// ticker, writes LastSuccessTick / ConsecutiveFailures directly after
+// each pass.
+// - The get_loop_state / update_loop_state MCP tools (internal/mcp),
+// called from inside a Superintendent pass, own
+// PRCommentsSince / IssueCommentsSince / RequirementsSweepSince — the
+// three "since" timestamps the Superintendent cycle previously had to
+// hand-write into the file itself.
+//
+// Either writer only ever does a load-modify-save round trip, and the two
+// never run concurrently by construction: `hermit run` blocks on the
+// Invoke call (which runs the Superintendent pass, including any
+// update_loop_state calls) before it touches the file itself again. Save
+// still writes atomically (temp file + rename) as cheap insurance against a
+// half-written file if the process is killed mid-write.
+package state
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// DirName is the HERMIT state directory name, relative to the project root.
+const DirName = ".hermit"
+
+// FileName is the state file name within DirName.
+const FileName = "superintendent-state.json"
+
+// Dir returns the .hermit directory path under rootDir.
+func Dir(rootDir string) string {
+ return filepath.Join(rootDir, DirName)
+}
+
+// Path returns the full path to the state file under rootDir.
+func Path(rootDir string) string {
+ return filepath.Join(Dir(rootDir), FileName)
+}
+
+// LoopState is the JSON shape of .hermit/superintendent-state.json. All
+// fields are optional (pointers/zero values) so a project that has never
+// run any part of the loop yet round-trips a valid, empty state.
+type LoopState struct {
+ // PRCommentsSince is the "since" timestamp for the last PR-review-comment
+ // check (get_recent_pr_comments), set via update_loop_state.
+ PRCommentsSince *time.Time `json:"pr_comments_since,omitempty"`
+ // IssueCommentsSince is the "since" timestamp for the last Issue-comment
+ // check (get_issue_comments), set via update_loop_state.
+ IssueCommentsSince *time.Time `json:"issue_comments_since,omitempty"`
+ // RequirementsSweepSince is the last time run_requirements_sweep ran, set
+ // via update_loop_state.
+ RequirementsSweepSince *time.Time `json:"requirements_sweep_since,omitempty"`
+ // LastSuccessTick is the wall-clock time of the most recent
+ // `hermit run` pass that completed without error. Written directly by
+ // internal/runloop, not via an MCP tool.
+ LastSuccessTick *time.Time `json:"last_success_tick,omitempty"`
+ // ConsecutiveFailures counts consecutive failed `hermit run` passes
+ // since the last success; reset to 0 on success. Written directly by
+ // internal/runloop.
+ ConsecutiveFailures int `json:"consecutive_failures,omitempty"`
+}
+
+// Load reads the state file at path. A missing file is not an error: it
+// returns the zero-value LoopState, matching a project where the loop has
+// never recorded any state yet.
+func Load(path string) (LoopState, error) {
+ var st LoopState
+ b, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return st, nil
+ }
+ return st, err
+ }
+ if len(b) == 0 {
+ return st, nil
+ }
+ if err := json.Unmarshal(b, &st); err != nil {
+ return st, err
+ }
+ return st, nil
+}
+
+// Save atomically writes st to path, creating the parent directory (e.g.
+// .hermit/) if it does not already exist. The write goes through a temp
+// file in the same directory followed by a rename, so a process killed
+// mid-write can never leave a truncated/corrupt state file behind.
+func Save(path string, st LoopState) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ b, err := json.MarshalIndent(st, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(dir, ".superintendent-state-*.tmp")
+ if err != nil {
+ return err
+ }
+ tmpName := tmp.Name()
+ if _, err := tmp.Write(b); err != nil {
+ tmp.Close()
+ os.Remove(tmpName)
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ os.Remove(tmpName)
+ return err
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ os.Remove(tmpName)
+ return err
+ }
+ return nil
+}
diff --git a/internal/state/state_test.go b/internal/state/state_test.go
new file mode 100644
index 0000000..ff9df4f
--- /dev/null
+++ b/internal/state/state_test.go
@@ -0,0 +1,111 @@
+package state
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestLoadMissingFileReturnsZeroValue(t *testing.T) {
+ dir := t.TempDir()
+ st, err := Load(Path(dir))
+ if err != nil {
+ t.Fatalf("Load: unexpected error: %v", err)
+ }
+ if st.PRCommentsSince != nil || st.IssueCommentsSince != nil || st.RequirementsSweepSince != nil {
+ t.Fatalf("Load on missing file: want zero-value LoopState, got %+v", st)
+ }
+ if st.ConsecutiveFailures != 0 {
+ t.Fatalf("Load on missing file: want ConsecutiveFailures 0, got %d", st.ConsecutiveFailures)
+ }
+}
+
+func TestSaveThenLoadRoundTrips(t *testing.T) {
+ dir := t.TempDir()
+ path := Path(dir)
+
+ now := time.Now().UTC().Truncate(time.Second)
+ want := LoopState{
+ PRCommentsSince: &now,
+ IssueCommentsSince: &now,
+ RequirementsSweepSince: &now,
+ LastSuccessTick: &now,
+ ConsecutiveFailures: 2,
+ }
+ if err := Save(path, want); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ got, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if got.PRCommentsSince == nil || !got.PRCommentsSince.Equal(now) {
+ t.Errorf("PRCommentsSince = %v, want %v", got.PRCommentsSince, now)
+ }
+ if got.IssueCommentsSince == nil || !got.IssueCommentsSince.Equal(now) {
+ t.Errorf("IssueCommentsSince = %v, want %v", got.IssueCommentsSince, now)
+ }
+ if got.RequirementsSweepSince == nil || !got.RequirementsSweepSince.Equal(now) {
+ t.Errorf("RequirementsSweepSince = %v, want %v", got.RequirementsSweepSince, now)
+ }
+ if got.LastSuccessTick == nil || !got.LastSuccessTick.Equal(now) {
+ t.Errorf("LastSuccessTick = %v, want %v", got.LastSuccessTick, now)
+ }
+ if got.ConsecutiveFailures != 2 {
+ t.Errorf("ConsecutiveFailures = %d, want 2", got.ConsecutiveFailures)
+ }
+}
+
+func TestSaveCreatesHermitDir(t *testing.T) {
+ dir := t.TempDir()
+ if _, err := os.Stat(Dir(dir)); !os.IsNotExist(err) {
+ t.Fatalf(".hermit dir already exists before Save")
+ }
+ if err := Save(Path(dir), LoopState{}); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+ if _, err := os.Stat(Dir(dir)); err != nil {
+ t.Fatalf(".hermit dir not created by Save: %v", err)
+ }
+}
+
+func TestSavePartialUpdatePreservesOtherFields(t *testing.T) {
+ dir := t.TempDir()
+ path := Path(dir)
+
+ t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ if err := Save(path, LoopState{PRCommentsSince: &t1}); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ st, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ t2 := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
+ st.IssueCommentsSince = &t2
+ if err := Save(path, st); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ got, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if got.PRCommentsSince == nil || !got.PRCommentsSince.Equal(t1) {
+ t.Errorf("PRCommentsSince lost after partial update: got %v, want %v", got.PRCommentsSince, t1)
+ }
+ if got.IssueCommentsSince == nil || !got.IssueCommentsSince.Equal(t2) {
+ t.Errorf("IssueCommentsSince = %v, want %v", got.IssueCommentsSince, t2)
+ }
+}
+
+func TestPathUnderHermitDir(t *testing.T) {
+ got := Path("/some/project")
+ want := filepath.Join("/some/project", ".hermit", "superintendent-state.json")
+ if got != want {
+ t.Errorf("Path = %q, want %q", got, want)
+ }
+}