From 43932d40ab2b64f9a917cad31db96b1d39751a7b Mon Sep 17 00:00:00 2001 From: jared Date: Mon, 13 Jul 2026 11:02:44 -0400 Subject: [PATCH] os-backlog: wakeup-polling + tmux-convention design spike (issue #28, ADR-0035/0036) Poll + tmux direction chosen at the human gate. Thin pilot: Backlog::Wakeup + bin/wakeup-poll (dry-run default; opt-in wakeup=true; afk-ready acts, semi triage-only, else pull-only notify). No cron installed. Suite 128/0. Follow-up: single designated poller machine; cadence deferred to first opt-in. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho --- ...ject-ai-wakeup-via-polling-not-webhooks.md | 27 ++++ ...-for-ai-run-sessions-cc-project-purpose.md | 27 ++++ docs/adr/README.md | 4 +- docs/implementation-status.md | 3 +- docs/implementation-status/os-backlog.md | 12 +- plugins/os-backlog/bin/wakeup-poll | 131 +++++++++++++++++ plugins/os-backlog/lib/backlog.rb | 1 + plugins/os-backlog/lib/backlog/wakeup.rb | 138 ++++++++++++++++++ plugins/os-backlog/tests/wakeup_test.rb | 61 ++++++++ 9 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md create mode 100644 docs/adr/0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md create mode 100755 plugins/os-backlog/bin/wakeup-poll create mode 100644 plugins/os-backlog/lib/backlog/wakeup.rb create mode 100644 plugins/os-backlog/tests/wakeup_test.rb diff --git a/docs/adr/0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md b/docs/adr/0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md new file mode 100644 index 0000000..694190f --- /dev/null +++ b/docs/adr/0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md @@ -0,0 +1,27 @@ +--- +id: "0035" +date: 2026-07-13 +status: Accepted +supersedes: +superseded-by: +affected-paths: [plugins/os-backlog/bin/wakeup-poll, plugins/os-backlog/lib/backlog/wakeup.rb] +affected-components: [os-backlog] +--- + +# 0035 — Issue-triggered project AI wakeup via polling, not webhooks + +## Context + +Issue #28 (design spike, blocked-by #27/ADR-0034): when an issue is filed with a project — often via cross-project file-don't-fix — that project's AI should be awakened to triage or act, instead of waiting for the human to open a session. The spike must pick a trigger mechanism, autonomy guardrails, credential scope, and context handoff. The human pre-chose the direction: polling trigger + tmux execution surface (ADR-0036 records the tmux convention). + +## Decision + +Trigger is a poller, not webhooks: a cron/heartbeat script (pilot: `plugins/os-backlog/bin/wakeup-poll`, one pass per invocation, no cron installed yet) scans opted-in projects for NEW open issues. (1) Scope: rows of the global project index `~/.cc-os/projects.json` (ADR-0034) whose tracker is `forgejo:` or `github:`, AND whose per-project `.cc-os/config` carries the explicit opt-in marker `wakeup=true` (the index row path locates the config; a later config-write slice may mirror the flag into the row — the config file remains the authority). Planka/repo-tracked projects are never polled. (2) New-ness: last-seen state persisted at `~/.cc-os/wakeup-state.json` (per-repo highest issue index seen); first scan of a repo baselines without waking anything. (3) Autonomy, in ADR-0029 vocabulary via issue labels: `afk-ready` -> spawn an acting session; `semi` -> spawn a triage-only session (label + comment, stop at proposing — never implement); anything else (hitl or unlabeled) -> notify-only. Notification policy v2 (pull-only) applies: the scan appends to a summary the human pulls (`~/.cc-os/wakeup-summary.md`) — no push notifications. (4) Credentials: the poller lists issues through tea's existing login only; it NEVER reads `~/.credentials` contents; spawned sessions get Planka creds the established way (source in a subshell). (5) Context handoff: the spawned session's initial prompt carries the issue number, title, URL, and the Discoverer block if present (ADR-0034), plus its autonomy tier. (6) Execution surface is a tmux session per ADR-0036. + +## Consequences + +Easier: host-agnostic (works for Forgejo and GitHub identically through tea/gh), no listener endpoint, no inbound network surface, trivially disabled (remove the cron line or the wakeup flag). Harder: latency is the poll interval, and the poller is per-machine state — two machines polling would double-wake (accepted for the single-operator pilot; state file is per-host). Non-goals, explicitly: no webhooks; no auto-merge/auto-close; no acting on hitl/unlabeled issues; wakened sessions still obey file-don't-fix (ADR-0034) — they never edit other projects; no bulk backfill of pre-existing open issues (baseline-then-watch). + +## Alternatives rejected + +1) Forgejo webhooks -> local listener — rejected for the first cut: needs an always-up endpoint reachable from the Forgejo host, doesn't cover GitHub without a second mechanism, and adds an inbound attack surface; polling via tea reuses existing credentials and works everywhere. Webhooks remain the natural upgrade if poll latency ever matters. 2) Opt-in as a field only in the global index row — rejected: the index is derived and rebuildable (ADR-0034); a rebuild would silently drop opt-ins. Authority lives in the per-project .cc-os/config (ADR-0027). 3) Push notifications per new issue (Apprise, ADR-0024) — rejected here: notification policy v2 is pull-only for ambient status; push stays reserved for the human explicitly asking. diff --git a/docs/adr/0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md b/docs/adr/0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md new file mode 100644 index 0000000..a3d28d6 --- /dev/null +++ b/docs/adr/0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md @@ -0,0 +1,27 @@ +--- +id: "0036" +date: 2026-07-13 +status: Accepted +supersedes: +superseded-by: +affected-paths: [plugins/os-backlog/bin/wakeup-poll, plugins/os-backlog/lib/backlog/wakeup.rb] +affected-components: [os-backlog] +--- + +# 0036 — tmux session convention for AI-run sessions: cc-- + +## Context + +Issue #28 chose tmux (not headless claude -p / Agent SDK) as the execution surface for issue-triggered wakeup (ADR-0035), because the human must be able to discover, inspect, and take over an AI-run session. The naming/lifecycle convention is useful beyond wakeup — any long-running or unattended AI session benefits — so it is recorded as its own ADR. No existing tmux naming habit to preserve (no tmux server was running at spike time). + +## Decision + +(1) Naming: AI-run sessions are named `cc--[-]` — `cc-` prefix marks Claude-operated sessions, `` is the index-row project name (ADR-0034), `` is short kebab-case (wakeup sessions use `issue`, e.g. `cc-llf-schema-issue12`), `-` only disambiguates collisions. Human personal sessions never take the `cc-` prefix. (2) Creation: whoever needs the session creates it — the wakeup poller (ADR-0035) for issue wakeups, a human for anything else — via `tmux new-session -d -s -c ...` so the pane starts in the project. (3) Lifecycle: a session is killed after clean completion once its closing artifact exists (for wakeups: the triage/summary comment posted on the issue); it is kept alive on error or whenever hitl input is needed, so the evidence and the takeover point are the same pane. Discovery is `tmux ls` — the `cc-` prefix makes AI sessions self-listing; an os-backlog-adjacent listing command is an explicit non-goal for now. (4) Attach/takeover: attaching pauses nothing — the pane runs an interactive Claude Code session, so a human who attaches can simply type into it; there is no separate handover protocol. + +## Consequences + +Easier: one glanceable namespace (`tmux ls | grep ^cc-`) for everything the AI is running on a machine; takeover is native tmux attach, no tooling. Harder: kill-on-clean-completion means a successful session leaves no scrollback — the issue comment is the record, so sessions must actually post it before exiting (the wakeup prompt makes this the final required step); session names carry no timestamp, so a stale kept-alive session must be triaged by attaching, not by name. + +## Alternatives rejected + +1) Headless execution (claude -p / Agent SDK) — rejected by the human for this stage: no inspection or takeover surface; may return later for pure afk-ready work once trust is established. 2) Timestamped session names (cc--20260713-...) — rejected: names become unreadable in tmux ls and collisions are rare enough for -. 3) Keep every completed session for audit — rejected: the durable record belongs on the issue/card (ADR-0033 spirit), and dead sessions accumulate into noise. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0623aee..81d87fa 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -40,5 +40,7 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`. | 0031 | [os-context prompt-composer plugin supersedes single-file os-orchestration](0031-os-context-prompt-composer-plugin-supersedes-single-file-os-orchestration.md) | Accepted | 2026-07-13 | | 0032 | [AI-maintained knowledge files are indexes with progressive disclosure](0032-ai-maintained-knowledge-files-are-indexes-with-progressive-disclosure.md) | Accepted | 2026-07-13 | | 0033 | [Tracker routing: Planka is state, git issues are specs](0033-tracker-routing-planka-is-state-git-issues-are-specs.md) | Accepted | 2026-07-13 | -| 0034 | [Cross-project filing: file-dont-fix with a derived global project index](0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md) | Proposed | 2026-07-13 | +| 0034 | [Cross-project filing: file-dont-fix with a derived global project index](0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md) | Accepted | 2026-07-13 | +| 0035 | [Issue-triggered project AI wakeup via polling, not webhooks](0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md) | Accepted | 2026-07-13 | +| 0036 | [tmux session convention for AI-run sessions: cc--](0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md) | Accepted | 2026-07-13 | diff --git a/docs/implementation-status.md b/docs/implementation-status.md index c4186c9..86312c3 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -65,7 +65,8 @@ entry is in the leaf file named. cache via BoardResolver (issue #22); slice 8 — Operations board + tracker-routing rubric category + ADR-0033 canonizing Planka-state/git-issues-spec (issue #17); route inspect issue-shape classification + split-by-kind default (issue #26); cross-project filing - convention + derived global project index (issue #27, ADR-0034). Detail: + convention + derived global project index (issue #27, ADR-0034); wakeup-polling + + tmux-convention spike with dry-run pilot (issue #28, ADR-0035/0036). Detail: os-context / os-doc-hygiene / os-backlog leaves. **Remaining optional items:** additional project onboarding (one at a time, per ADR-0013); diff --git a/docs/implementation-status/os-backlog.md b/docs/implementation-status/os-backlog.md index c6f9399..a641ed7 100644 --- a/docs/implementation-status/os-backlog.md +++ b/docs/implementation-status/os-backlog.md @@ -116,5 +116,13 @@ column ownership). `skills/route/references/cross-project-filing.md`; one CROSS-PROJECT rule line added to the SessionStart note. 9 new tests; suite 112 runs / 233 assertions / 0 failures. Live smoke: cc-os row present in the real index. -- **Outstanding:** #14 residual (onboard one more project — operational hitl); #28 wakeup - design spike (poll + tmux direction chosen 2026-07-13). +- **Wakeup design spike (2026-07-13, issue #28, ADR-0035/0036):** poll + tmux direction + chosen at the human gate. `Backlog::Wakeup` + `bin/wakeup-poll` thin pilot (dry-run + default, `--spawn` to create `cc--issue` tmux sessions and persist + `~/.cc-os/wakeup-state.json`; opt-in `wakeup=true` in `.cc-os/config`; afk-ready → act, + semi → triage-only, else notify-only into pull-only `~/.cc-os/wakeup-summary.md`). No + cron installed; 6 tests; suite 128 runs / 0 failures. Follow-up recorded: single + designated poller machine (state stays per-host); cadence/cron mechanism and a + `config-write` wakeup key deferred until a first project opts in. +- **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup + rollout (first opt-in project + poller machine + cadence). diff --git a/plugins/os-backlog/bin/wakeup-poll b/plugins/os-backlog/bin/wakeup-poll new file mode 100755 index 0000000..0b96042 --- /dev/null +++ b/plugins/os-backlog/bin/wakeup-poll @@ -0,0 +1,131 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# wakeup-poll: one pass of the issue-triggered wakeup poller (ADR-0035). +# +# Reads the global project index (~/.cc-os/projects.json, ADR-0034), and for +# each wakeup-opted-in forgejo:/github: project lists open issues (tea/gh), +# diffs against last-seen state (~/.cc-os/wakeup-state.json), and for each +# NEW issue either spawns a tmux session (ADR-0036) or — the default — +# prints the command it WOULD run. hitl/unlabeled issues are notify-only: +# appended to ~/.cc-os/wakeup-summary.md (pull-only, notification policy v2). +# +# Usage: +# wakeup-poll # dry-run: print planned actions, touch no state +# wakeup-poll --spawn # actually create tmux sessions + persist state +# +# Fails soft everywhere: missing index, unroutable projects, tea/gh errors, +# and absent tmux each degrade to a printed note, never a crash. The poller +# uses tea/gh's existing logins only and never reads ~/.credentials. +# Pilot only — install no cron around this until ADR-0035 is Accepted. + +require "json" +require "open3" +require "shellwords" +require_relative "../lib/backlog" + +spawn_mode = ARGV.include?("--spawn") + +def note(msg) = puts("wakeup-poll: #{msg}") + +def list_issues(kind, slug) + cmd = + case kind + when "forgejo" then ["tea", "issues", "list", "--repo", slug, "--state", "open", "--output", "json"] + when "github" then ["gh", "issue", "list", "--repo", slug, "--state", "open", + "--json", "number,title,labels,body,url"] + end + out, err, status = Open3.capture3(*cmd) + return nil unless status.success? + + rows = JSON.parse(out) + # Normalize gh's shape to tea's {index, title, labels}. + rows.map do |r| + { "index" => (r["index"] || r["number"]).to_s, + "title" => r["title"], + "labels" => r["labels"].is_a?(Array) ? r["labels"].map { |l| l["name"] }.join(",") : r["labels"].to_s, + "body" => r["body"].to_s, + "url" => r["url"].to_s } + end +rescue StandardError => e + warn "wakeup-poll: #{slug}: #{e.class}: #{e.message}" + nil +end + +index = Backlog::ProjectIndex.new +projects = index.all +if projects.empty? + note "project index empty or unreadable — nothing to do" + exit 0 +end + +config_reader = lambda do |path| + file = File.join(path, ".cc-os", "config") + File.exist?(file) ? File.read(file) : "" +rescue StandardError + "" +end + +eligible = Backlog::Wakeup.eligible_projects(projects, config_reader: config_reader) +skipped = projects.keys - eligible.map { |p| p[:path] } +skipped.each { |path| note "skip #{path} (#{projects[path]['tracker'] || 'no tracker'}: not a wakeup-eligible forgejo/github project, or wakeup=true not set)" } + +if eligible.empty? + note "no opted-in forgejo/github projects — nothing to do" + exit 0 +end + +state = Backlog::Wakeup::State.new +summary_path = File.join(File.dirname(Backlog::Wakeup::State.default_path), "wakeup-summary.md") + +eligible.each do |project| + issues = list_issues(project[:tracker_kind], project[:repo_slug]) + if issues.nil? + note "#{project[:repo_slug]}: issue listing failed (is #{project[:tracker_kind] == 'forgejo' ? 'tea' : 'gh'} logged in?) — skipping" + next + end + + last_seen = state.last_seen(project[:repo_slug]) + if last_seen.nil? + top = Backlog::Wakeup.highest_index(issues) + note "#{project[:repo_slug]}: first scan — baselining at ##{top}, waking nothing" + state.record(project[:repo_slug], top) if spawn_mode + next + end + + fresh = Backlog::Wakeup.new_issues(issues, last_seen) + note "#{project[:repo_slug]}: #{fresh.size} new issue(s) since ##{last_seen}" if fresh.any? + + fresh.each do |issue| + tier = Backlog::Wakeup.tier(issue["labels"]) + if tier == :notify + line = "- #{Time.now.strftime('%Y-%m-%d')} #{project[:repo_slug]}##{issue['index']} (#{tier}): #{issue['title']}" + if spawn_mode + File.open(summary_path, "a") { |f| f.puts(line) } + note "notify-only -> appended to #{summary_path}" + else + note "would append to #{summary_path}: #{line}" + end + next + end + + prompt = Backlog::Wakeup.session_prompt( + issue: issue, tier: tier, + url: issue["url"].empty? ? "#{project[:tracker_kind]}:#{project[:repo_slug]}##{issue['index']}" : issue["url"] + ) + cmd = Backlog::Wakeup.tmux_command( + project: project[:name], path: project[:path], + issue_index: issue["index"], prompt: prompt + ) + if spawn_mode + ok = system(*cmd) + note ok ? "spawned #{cmd[4]}" : "tmux spawn failed for #{cmd[4]} (is tmux installed?)" + else + note "would run: #{cmd.shelljoin}" + end + end + + state.record(project[:repo_slug], Backlog::Wakeup.highest_index(issues, last_seen)) if spawn_mode +end + +note "dry-run complete — no state written, no sessions spawned (use --spawn)" unless spawn_mode diff --git a/plugins/os-backlog/lib/backlog.rb b/plugins/os-backlog/lib/backlog.rb index 24a61fa..49743ae 100644 --- a/plugins/os-backlog/lib/backlog.rb +++ b/plugins/os-backlog/lib/backlog.rb @@ -8,3 +8,4 @@ require_relative "backlog/project_index" require_relative "backlog/resolver" require_relative "backlog/tracker" require_relative "backlog/triage_check" +require_relative "backlog/wakeup" diff --git a/plugins/os-backlog/lib/backlog/wakeup.rb b/plugins/os-backlog/lib/backlog/wakeup.rb new file mode 100644 index 0000000..ba8ba23 --- /dev/null +++ b/plugins/os-backlog/lib/backlog/wakeup.rb @@ -0,0 +1,138 @@ +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 diff --git a/plugins/os-backlog/tests/wakeup_test.rb b/plugins/os-backlog/tests/wakeup_test.rb new file mode 100644 index 0000000..c51a8e0 --- /dev/null +++ b/plugins/os-backlog/tests/wakeup_test.rb @@ -0,0 +1,61 @@ +require_relative "test_helper" +require "tmpdir" +require "json" + +class WakeupTest < Minitest::Test + W = Backlog::Wakeup + + def test_tier_mapping + assert_equal :afk_ready, W.tier("afk-ready") + assert_equal :afk_ready, W.tier("bug, afk-ready") + assert_equal :semi, W.tier("semi") + assert_equal :notify, W.tier("hitl") + assert_equal :notify, W.tier("") + assert_equal :notify, W.tier(nil) + end + + def test_eligible_projects_requires_git_tracker_and_optin + projects = { + "/p/planka" => { "name" => "planka-proj", "tracker" => "planka:board" }, + "/p/forgejo-in" => { "name" => "fin", "tracker" => "forgejo:jared/fin" }, + "/p/forgejo-out" => { "name" => "fout", "tracker" => "forgejo:jared/fout" }, + "/p/github-in" => { "name" => "gin", "tracker" => "github:jared/gin" } + } + reader = ->(path) { path.end_with?("-in") ? "tracker=x\nwakeup=true\n" : "tracker=x\n" } + rows = W.eligible_projects(projects, config_reader: reader) + assert_equal %w[/p/forgejo-in /p/github-in], rows.map { |r| r[:path] } + assert_equal "jared/fin", rows.first[:repo_slug] + assert_equal "forgejo", rows.first[:tracker_kind] + end + + def test_new_issues_baselines_on_nil_last_seen + issues = [{ "index" => "5" }, { "index" => "3" }] + assert_equal [], W.new_issues(issues, nil) + assert_equal [{ "index" => "5" }], W.new_issues(issues, 3) + assert_equal 5, W.highest_index(issues) + assert_equal 9, W.highest_index(issues, 9) + end + + def test_tmux_command_follows_adr_0036_naming + cmd = W.tmux_command(project: "llf-schema", path: "/p/llf", issue_index: "12", prompt: "hi") + assert_equal %w[tmux new-session -d -s cc-llf-schema-issue12 -c /p/llf claude hi], cmd + end + + def test_session_prompt_carries_tier_and_body + issue = { "index" => "7", "title" => "T", "body" => "Body\n**Discoverer:** x" } + semi = W.session_prompt(issue: issue, tier: :semi, url: "u") + assert_includes semi, "TRIAGE ONLY" + assert_includes semi, "**Discoverer:** x" + afk = W.session_prompt(issue: issue, tier: :afk_ready, url: "u") + assert_includes afk, "afk-ready" + end + + def test_state_reads_missing_as_empty_and_records_atomically + Dir.mktmpdir do |dir| + state = W::State.new(path: File.join(dir, "wakeup-state.json")) + assert_nil state.last_seen("jared/x") + state.record("jared/x", 7) + assert_equal 7, W::State.new(path: File.join(dir, "wakeup-state.json")).last_seen("jared/x") + end + end +end