cc-os/plugins/os-backlog/lib/backlog/wakeup.rb

139 lines
4.6 KiB
Ruby
Raw Normal View History

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<Hash>] 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-<project>-issue<N>).
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