Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
"mcp__hermit__now",
"mcp__hermit__get_loop_state",
"mcp__hermit__update_loop_state",
"mcp__hermit__run_health_checks"
"mcp__hermit__run_health_checks",
"mcp__hermit__run_self_audit"
]
}
}
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ This check is nearly free (one read-only call) and guards against a known flaky
- `run_health_checks` itself runs each configured `[[health_checks]]` command, opens a deduped `production-incident`-labeled Issue for any newly-failing check, and posts a one-time "recovered at ..." comment on the corresponding Issue for any check that has returned to passing (never auto-closing it) — no further action is needed here beyond calling the tool and updating the timestamp
- Update the stored last-health-check timestamp to now after calling
- If no `[[health_checks]]` are configured for this project, the tool is a no-op; keep calling it on cadence anyway rather than special-casing it away, so a project that later adds health checks starts getting swept without a CLAUDE.md change
9. If there are no Issues, end this pass — the recurring cron trigger starts the next pass
9. If there are no Issues, run the idle-time self-audit sweep roughly once an hour instead of ending the pass immediately, tracking a separate "last self-audit time" across passes (`self_audit_since`) the same way step 7 tracks its own requirements-sweep timestamp (store the current time before calling) — this only applies when the queue is genuinely empty; a non-empty queue always skips straight to step 10:
- Only run the self-audit when at least 3600 seconds have elapsed since the last recorded self-audit time; otherwise skip straight to ending the pass (do not audit early — the point is to fill idle passes roughly hourly, not every empty-queue pass)
- When due, call `run_self_audit` with no `findings` argument first: it returns an `instructions` field describing the lightweight review to perform (bugs, missing test coverage, security holes — see `internal/selfaudit.Instructions`, the single source of truth this prose mirrors). Follow those instructions against the current codebase.
- For each concrete problem found, call `run_self_audit` again passing `findings` (an array of `{title, body}` objects, one call covering all findings from this sweep). The tool dedupes each finding against existing open **and** closed Issues by a normalized-title match and files a new Issue (labeled `self-audit`) only for non-duplicates — this is Go-side plumbing, not something to do by hand.
- The same hard prohibition on implementing applies here: never fix a finding yourself, however small it looks — only let `run_self_audit` file the Issue and leave the fix to the normal Engineer pipeline (step 10 onward, on a later pass once the Issue exists).
- Update the stored last-self-audit timestamp to now after calling `run_self_audit` (even if the review turned up zero findings — the throttle governs how often the sweep runs, not whether it found anything)
- Once the self-audit step has run (or been skipped because it isn't due yet), end this pass — the recurring cron trigger starts the next pass
10. For each Issue (up to 4 at a time):
a. Mark as in-progress with `assign_issue` (assignee: your own username)
b. Create a worktree with `create_worktree` (base_branch: default branch)
Expand Down
12 changes: 6 additions & 6 deletions cmd/hermit/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ language = "ja"
continue
}
tools, _ := msg.Result["tools"].([]any)
if len(tools) != 21 {
t.Errorf("expected 21 tools, got %d", len(tools))
if len(tools) != 22 {
t.Errorf("expected 22 tools, got %d", len(tools))
}
return
case <-deadline:
Expand Down Expand Up @@ -207,8 +207,8 @@ language = "ja"
continue
}
tools, _ := msg.Result["tools"].([]any)
if len(tools) != 21 {
t.Errorf("expected 21 tools, got %d", len(tools))
if len(tools) != 22 {
t.Errorf("expected 22 tools, got %d", len(tools))
}
return
case <-deadline:
Expand Down Expand Up @@ -314,8 +314,8 @@ language = "ja"
continue
}
tools, _ := msg.Result["tools"].([]any)
if len(tools) != 21 {
t.Errorf("expected 21 tools, got %d", len(tools))
if len(tools) != 22 {
t.Errorf("expected 22 tools, got %d", len(tools))
}
return
case <-deadline:
Expand Down
30 changes: 29 additions & 1 deletion internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,17 @@ func (c *Client) isTrustedAuthor(association string) bool {
// are not silently dropped: each one is logged so a human operator watching
// HERMIT's logs can notice.
func (c *Client) listOpenIssuesFromRepo(owner, repo, label string) ([]Issue, error) {
return c.listIssuesFromRepoWithState(owner, repo, label, "open")
}

// listIssuesFromRepoWithState is the shared implementation behind
// listOpenIssuesFromRepo (state="open") and ListIssuesAnyState (state="all"):
// it fetches issues from a specific owner/repo pair in the given GitHub
// issue-list state, optionally filtering by label. Each returned Issue has
// its Owner and Repo fields set to the provided values.
func (c *Client) listIssuesFromRepoWithState(owner, repo, label, state string) ([]Issue, error) {
opts := &gogithub.IssueListByRepoOptions{
State: "open",
State: state,
}
if label != "" {
opts.Labels = []string{label}
Expand Down Expand Up @@ -233,6 +242,25 @@ func (c *Client) ListOpenIssues(label string) ([]Issue, error) {
return issues, nil
}

// ListIssuesAnyState returns issues in any state (open or closed) from the
// client's primary repository, optionally filtered by label. Owner/Repo
// fields are NOT set, mirroring ListOpenIssues' single-repo backward
// compatibility. This is used by callers that need to dedupe against the
// full Issue history rather than just the open queue — e.g. the self-audit
// sweep (Issue #164), which must not re-file an Issue for a finding whose
// Issue was already opened and since closed.
func (c *Client) ListIssuesAnyState(label string) ([]Issue, error) {
issues, err := c.listIssuesFromRepoWithState(c.owner, c.repo, label, "all")
if err != nil {
return nil, err
}
for i := range issues {
issues[i].Owner = ""
issues[i].Repo = ""
}
return issues, nil
}

// ListAllIssues fetches open issues from all provided repos. If repos is
// empty, it falls back to the client's primary repo (same as ListOpenIssues
// but with Owner/Repo fields populated). The label filter in each RepoConfig
Expand Down
88 changes: 85 additions & 3 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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/selfaudit"
"github.com/ytnobody/hermit/internal/state"
)

Expand Down Expand Up @@ -58,8 +59,17 @@ type githubClient interface {
// AddLabel is used by the run_health_checks tool to label newly-opened
// production-incident issues via healthcheck.NewGitHubIssueClient — see
// healthcheck.ghClient, which this interface must remain a superset of
// (Issue #190).
// (Issue #190). It is also used by run_self_audit to label newly-opened
// self-audit-finding issues via selfaudit.NewGitHubIssueClient (Issue
// #164).
AddLabel(number int, label string) error
// ListIssuesAnyState is used by the run_self_audit tool to dedupe
// findings against both open and closed issues via
// selfaudit.NewGitHubIssueClient — see selfaudit.ghClient, which this
// interface must remain a superset of (Issue #164). Unlike
// ListOpenIssues, a self-audit finding whose issue was already filed and
// since closed must not be re-filed.
ListIssuesAnyState(label string) ([]gh.Issue, error)
}

// resolveRiskConfig returns the risk.Config to apply for the given owner/repo
Expand Down Expand Up @@ -560,7 +570,7 @@ 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, requirements_sweep_since, and health_checks_since (RFC3339, omitted if never recorded) — the 'since' values the Superintendent cycle uses to decide when it last checked PR comments, checked Issue comments, ran the requirements sweep, and ran the health-check sweep. Also reports status ('running', 'paused', or 'quit' — set via `hermit pause`/`hermit resume`/`hermit quit`; defaults to 'running' when never recorded), 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 these cadence timestamps only via this tool and update_loop_state, never by hand-writing the JSON file."),
mcp.WithDescription("Returns the cadence-tracking timestamps persisted in .hermit/superintendent-state.json: pr_comments_since, issue_comments_since, requirements_sweep_since, health_checks_since, and self_audit_since (RFC3339, omitted if never recorded) — the 'since' values the Superintendent cycle uses to decide when it last checked PR comments, checked Issue comments, ran the requirements sweep, ran the health-check sweep, and ran the idle-time self-audit sweep. Also reports status ('running', 'paused', or 'quit' — set via `hermit pause`/`hermit resume`/`hermit quit`; defaults to 'running' when never recorded), 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 these 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))
Expand All @@ -574,11 +584,12 @@ func registerTools(s *server.MCPServer, client githubClient, rateLimitThreshold

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, health_checks_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.WithDescription("Updates one or more of the cadence-tracking timestamps in .hermit/superintendent-state.json: pr_comments_since, issue_comments_since, requirements_sweep_since, health_checks_since, self_audit_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")),
mcp.WithString("health_checks_since", mcp.Description("RFC3339 timestamp to record as the last health-check sweep time")),
mcp.WithString("self_audit_since", mcp.Description("RFC3339 timestamp to record as the last self-audit sweep time")),
),
func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
statePath := state.Path(rootDir)
Expand Down Expand Up @@ -614,6 +625,13 @@ func registerTools(s *server.MCPServer, client githubClient, rateLimitThreshold
}
st.HealthChecksSince = &t
}
if v := req.GetString("self_audit_since", ""); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("self_audit_since: %v", err)), nil
}
st.SelfAuditSince = &t
}
if err := state.Save(statePath, st); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
Expand Down Expand Up @@ -801,6 +819,69 @@ func registerTools(s *server.MCPServer, client githubClient, rateLimitThreshold
return mcp.NewToolResultText(string(b)), nil
},
)

s.AddTool(
mcp.NewTool("run_self_audit",
mcp.WithDescription("Runs the idle-time self-audit sweep (Issue #164): a lightweight code-review pass looking for bugs, missing test coverage, and security holes, used to keep HERMIT finding work when the Issue queue is empty. This tool is two-phase and stateless between calls — it does not run any analysis itself:\n\n1. Call with no 'findings' argument. The response's 'instructions' field describes the review to perform (see selfaudit.Instructions) — read it and actually perform that review against the current codebase.\n2. For each concrete finding, call this tool again passing 'findings': an array of {\"title\", \"body\"} objects, one per finding. Each finding is deduped against existing open AND closed GitHub Issues by a normalized-title match (so a finding whose Issue was already filed and since closed is not re-filed) and, if not a duplicate, filed as a new Issue labeled 'self-audit'. Returns a summary of how many issues were opened vs. skipped as duplicates.\n\nThe caller (Superintendent) must never fix a finding itself — only file the Issue and leave implementation to the normal Engineer pipeline (same 'coordinator, not implementer' rule as every other step). The Superintendent loop should call this roughly hourly when the Issue queue is empty (tracking its own 'last self-audit' timestamp via self_audit_since, the same way it tracks requirements_sweep_since), not on every cycle. The on-demand caller (e.g. a human in a chat session) may call it any time without waiting for that cadence — both paths share the exact same dedupe/filing logic (internal/selfaudit.File)."),
mcp.WithArray("findings",
mcp.Description("Findings to file as (deduped) GitHub Issues, one call after the review described in 'instructions' has actually been performed. Omit entirely (or pass an empty array) to just receive the instructions without filing anything."),
mcp.Items(map[string]any{
"type": "object",
"properties": map[string]any{
"title": map[string]any{
"type": "string",
"description": "Short human-readable summary of the finding, e.g. \"nil pointer dereference in foo.Bar when cfg is empty\". Do not include a [self-audit] prefix — it is added automatically.",
},
"body": map[string]any{
"type": "string",
"description": "Full finding detail: what/where the problem is, why it matters, and file references. Becomes the Issue body verbatim.",
},
},
"required": []string{"title", "body"},
}),
),
),
func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
rawFindings, hasFindings := req.GetArguments()["findings"]
findingsList, _ := rawFindings.([]any)
if !hasFindings || len(findingsList) == 0 {
b, _ := json.Marshal(map[string]any{
"instructions": selfaudit.Instructions,
"note": "No findings provided — no Issues were filed. Perform the review described in 'instructions', then call run_self_audit again with a non-empty 'findings' array for each concrete problem found.",
})
return mcp.NewToolResultText(string(b)), nil
}

findings := make([]selfaudit.Finding, 0, len(findingsList))
for i, raw := range findingsList {
obj, ok := raw.(map[string]any)
if !ok {
return mcp.NewToolResultError(fmt.Sprintf("findings[%d]: expected an object with title/body", i)), nil
}
title, _ := obj["title"].(string)
body, _ := obj["body"].(string)
if title == "" {
return mcp.NewToolResultError(fmt.Sprintf("findings[%d]: title is required", i)), nil
}
findings = append(findings, selfaudit.Finding{Title: title, Body: body})
}

summary, err := selfaudit.File(findings, selfaudit.NewGitHubIssueClient(client))
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
b, err := json.Marshal(map[string]any{
"findings_received": summary.FindingsReceived,
"issues_opened": summary.IssuesOpened,
"duplicates_skipped": summary.Duplicates,
"results": summary.Results,
})
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(string(b)), nil
},
)
}

// loopStateResponse converts a state.LoopState into the JSON-friendly shape
Expand All @@ -825,6 +906,7 @@ func loopStateResponse(st state.LoopState) map[string]any {
setIfNotNil("issue_comments_since", st.IssueCommentsSince)
setIfNotNil("requirements_sweep_since", st.RequirementsSweepSince)
setIfNotNil("health_checks_since", st.HealthChecksSince)
setIfNotNil("self_audit_since", st.SelfAuditSince)
setIfNotNil("last_success_tick", st.LastSuccessTick)
return resp
}
Loading
Loading