139 lines
4.6 KiB
Ruby
139 lines
4.6 KiB
Ruby
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
|