require "json" require "fileutils" module Backlog # Pure logic for the issue-triggered wakeup poller (ADR-0035): which # projects are polled, which issues count as new, what autonomy tier a # label set maps to, and the tmux command a wakeup would run (ADR-0036). # # All methods are pure over injected data — the bin/wakeup-poll glue does # the shelling out (tea/gh, tmux) and file IO. Read paths never raise. module Wakeup module_function # Autonomy tiers, from ADR-0029 label vocabulary (ADR-0035 mapping): # afk-ready -> acting session; semi -> triage-only session; # anything else (hitl / unlabeled) -> notify-only. def tier(labels) names = Array(labels).flat_map { |l| l.to_s.split(",") }.map(&:strip) return :afk_ready if names.include?("afk-ready") return :semi if names.include?("semi") :notify end # Filter global-index rows (ADR-0034 shape: path -> row) down to # wakeup-eligible projects: tracker forgejo:/github: AND the per-project # .cc-os/config carries wakeup=true (config is the authority; the # config_reader is injected so this stays pure). # # @return [Array] rows as {path:, name:, tracker_kind:, repo_slug:} def eligible_projects(projects, config_reader:) projects.filter_map do |path, row| tracker = row["tracker"].to_s kind, slug = tracker.split(":", 2) next unless %w[forgejo github].include?(kind) && !slug.to_s.empty? next unless opted_in?(config_reader.call(path)) { path: path, name: row["name"] || File.basename(path), tracker_kind: kind, repo_slug: slug } end end def opted_in?(config_text) config_text.to_s.each_line.any? { |l| l.strip == "wakeup=true" } end # Issues newer than the last-seen index for this repo. A nil last_seen # means the repo has never been scanned: baseline without waking # anything (ADR-0035 — no bulk backfill of pre-existing issues). def new_issues(issues, last_seen) return [] if last_seen.nil? issues.select { |i| i["index"].to_i > last_seen.to_i } end def highest_index(issues, floor = 0) ([floor.to_i] + issues.map { |i| i["index"].to_i }).max end # The tmux command a wakeup runs (ADR-0036 naming: cc--issue). def tmux_command(project:, path:, issue_index:, prompt:) [ "tmux", "new-session", "-d", "-s", "cc-#{project}-issue#{issue_index}", "-c", path, "claude", prompt ] end # Initial prompt for the wakened session: issue coordinates + autonomy # tier + the standing guardrails (ADR-0035 context handoff). The issue # body (with any Discoverer block) is passed through verbatim. def session_prompt(issue:, tier:, url:) action = tier == :afk_ready ? AFK_READY_ACTION : SEMI_ACTION <<~PROMPT Wakeup: new issue ##{issue['index']} — #{issue['title']} #{url} #{action} File-don't-fix still applies (ADR-0034): never edit other projects. Before exiting, post your triage/summary as a comment on the issue — that comment is the durable record (ADR-0036 lifecycle). Issue body: #{issue['body']} PROMPT end AFK_READY_ACTION = "This issue is labeled afk-ready: triage it and, if it is actionable " \ "within this repo, do the work." SEMI_ACTION = "This issue is labeled semi: TRIAGE ONLY — label, comment, and " \ "propose an approach, but stop at proposing. Do not implement." # Last-seen state (~/.cc-os/wakeup-state.json): repo_slug -> highest # issue index seen. Missing/corrupt reads as empty; writes are atomic. class State def self.default_path(env: ENV) base = env["CC_OS_HOME"] || File.join(env.fetch("HOME", Dir.home), ".cc-os") File.join(base, "wakeup-state.json") end def initialize(path: self.class.default_path) @path = path end def last_seen(repo_slug) read[repo_slug] end def record(repo_slug, index) data = read data[repo_slug] = index write(data) end private def read return {} unless File.exist?(@path) parsed = JSON.parse(File.read(@path)) parsed.is_a?(Hash) ? parsed : {} rescue StandardError {} end def write(data) dir = File.dirname(@path) FileUtils.mkdir_p(dir) temp = File.join(dir, ".wakeup-state.json.tmp.#{Process.pid}") File.write(temp, JSON.pretty_generate(data) + "\n") File.rename(temp, @path) end end end end