os-backlog: post-ship review corrections for the triage hook + project index (cards #1818330518584820864, #1818330528525321347)

Codex review + verification pass over f9a81a1/3708c00 found the triage
hook resolving boards differently from the CLI. Corrections:
- Config#planka_board: tracker=planka:<board> now counts as board
  configuration everywhere (Resolver included), not just the explicit
  board key that config-write never writes.
- Triage hook board resolution extracted to pure
  TriageCheck.board_name_for honoring the umbrella rule: the session
  cwd's .cc-os/config overrides the git root's (the hook previously
  walked up and read only the root config).
- config-write keys the global project index by the realpath of the
  directory the config lands in, not the git toplevel — umbrella
  subprojects no longer overwrite the umbrella's row.
- projects <filter> matches name, tracker, or path; hook timeout
  8s->15s; CC_OS_DEBUG=1 surfaces swallowed hook errors on stderr.
Residual (name-only board lookup across all Planka projects) captured
as Backlog card #1818385174308586768. Suite 138/295/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkeTPRWqaqJZEpf48H1aC7
This commit is contained in:
jared 2026-07-13 12:27:43 -04:00
parent 43932d40ab
commit b0a21d6211
11 changed files with 169 additions and 23 deletions

View File

@ -111,8 +111,9 @@ column ownership).
- **Cross-project filing (2026-07-13, issue #27, ADR-0034):** `Backlog::ProjectIndex` - **Cross-project filing (2026-07-13, issue #27, ADR-0034):** `Backlog::ProjectIndex`
derived global index at `~/.cc-os/projects.json` (atomic temp+rename writes, corrupt/ derived global index at `~/.cc-os/projects.json` (atomic temp+rename writes, corrupt/
missing → empty, `CC_OS_HOME` override), upserted fail-soft by config-write (canonical missing → empty, `CC_OS_HOME` override), upserted fail-soft by config-write (canonical
path via `git rev-parse --show-toplevel`), queried via new `projects [filter]` CLI path = realpath of the directory the config was written into — NOT the git toplevel, so
subcommand. Discoverer template + tea/gh/card-add mechanics in umbrella subprojects index as themselves; corrected 2026-07-13 review), queried via new
`projects [filter]` CLI subcommand (filter matches name, tracker, or path). Discoverer template + tea/gh/card-add mechanics in
`skills/route/references/cross-project-filing.md`; one CROSS-PROJECT rule line added to `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 the SessionStart note. 9 new tests; suite 112 runs / 233 assertions / 0 failures. Live
smoke: cc-os row present in the real index. smoke: cc-os row present in the real index.
@ -124,5 +125,15 @@ column ownership).
cron installed; 6 tests; suite 128 runs / 0 failures. Follow-up recorded: single 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 designated poller machine (state stays per-host); cadence/cron mechanism and a
`config-write` wakeup key deferred until a first project opts in. `config-write` wakeup key deferred until a first project opts in.
- **Review corrections (2026-07-13, cards #1818330518584820864 / #1818330528525321347):**
post-ship review (Codex + verification pass) of the triage hook and project index:
`Config#planka_board` (`tracker=planka:<board>` now counts everywhere, incl. the
Resolver, not just the explicit `board` key); triage hook board resolution moved to pure
`TriageCheck.board_name_for` honoring the umbrella rule (session cwd's `.cc-os/config`
overrides the git root's; hook previously read only the root config); index keying fixed
per above; hook timeout 8→15s; `CC_OS_DEBUG=1` surfaces swallowed hook errors on stderr.
Known residual (captured as a Backlog card): board lookup is name-only across ALL Planka
projects — duplicate board names in different projects can resolve to the wrong board.
Suite 138 runs / 295 assertions / 0 failures.
- **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup - **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup
rollout (first opt-in project + poller machine + cadence). rollout (first opt-in project + poller machine + cadence).

View File

@ -159,12 +159,13 @@ rescue StandardError
[{ "tool" => "gh", "open_count" => nil, "reason" => "could not parse gh output" }, nil] [{ "tool" => "gh", "open_count" => nil, "reason" => "could not parse gh output" }, nil]
end end
# Canonical repo path for the global project index: the git toplevel when # Canonical path for the global project index: the realpath of cwd — the
# inside a work tree, else the realpath of cwd. Never raises. # directory config-write just wrote .cc-os/config into, i.e. the project
# the row describes. Deliberately NOT the git toplevel: in an umbrella
# repo a subdirectory's config is its own project, and keying on the
# toplevel would make every subproject overwrite the umbrella's row.
# Never raises.
def canonical_repo_path def canonical_repo_path
top = `git rev-parse --show-toplevel 2>/dev/null`.strip
return top unless top.empty?
File.realpath(Dir.pwd) File.realpath(Dir.pwd)
rescue StandardError rescue StandardError
Dir.pwd Dir.pwd
@ -299,7 +300,9 @@ when "projects"
filter = rest.shift filter = rest.shift
projects = Backlog::ProjectIndex.new.all projects = Backlog::ProjectIndex.new.all
if filter if filter
projects = projects.select { |_path, row| row["name"].to_s.include?(filter) } projects = projects.select do |path, row|
[row["name"], row["tracker"], path].any? { |field| field.to_s.include?(filter) }
end
end end
puts JSON.pretty_generate({ "projects" => projects }) puts JSON.pretty_generate({ "projects" => projects })
when nil, "-h", "--help" when nil, "-h", "--help"

View File

@ -12,7 +12,7 @@
{ {
"type": "command", "type": "command",
"command": "ruby ${CLAUDE_PLUGIN_ROOT}/hooks/triage_check.rb", "command": "ruby ${CLAUDE_PLUGIN_ROOT}/hooks/triage_check.rb",
"timeout": 8 "timeout": 15
} }
] ]
} }

View File

@ -39,18 +39,21 @@ def find_project_root(cwd)
end end
end end
# Same board-name convention capture/route already use: an explicit # Same config semantics as the CLI: the session cwd's own .cc-os/config
# .cc-os/config `board` key, else — only inside a path the project # wins (umbrella subdir configs override the repo root's for sessions
# heuristic actually recognizes — the repo directory's own name. Outside # started there), then the root config, then the path heuristic — and
# any known root with no explicit config, we don't guess (mirrors the # `tracker=planka:<board>` counts, not just the explicit `board` key.
# resolver's stop-and-discuss ambiguity gate). # The decision itself is pure (TriageCheck.board_name_for); this only
# reads the two candidate config files.
def resolve_board_name(root) def resolve_board_name(root)
config_path = File.join(root, ".cc-os", "config") read = lambda do |dir|
config = Backlog::Config.new(File.exist?(config_path) ? File.read(config_path) : nil) path = File.join(dir, ".cc-os", "config")
return config.board if config.board File.exist?(path) ? File.read(path) : nil
return nil unless Backlog::Resolver.project_for(root) end
cwd = File.expand_path(Dir.pwd)
File.basename(root) Backlog::TriageCheck.board_name_for(cwd: cwd, root: root,
cwd_config: read.call(cwd),
root_config: read.call(root))
end end
def build_client def build_client
@ -86,6 +89,6 @@ end
begin begin
main main
rescue Exception # rubocop:disable Lint/RescueException -- a hook must never block a session rescue Exception => e # rubocop:disable Lint/RescueException -- a hook must never block a session
nil warn "os-backlog triage_check: #{e.class}: #{e.message}" if ENV["CC_OS_DEBUG"]
end end

View File

@ -35,6 +35,17 @@ module Backlog
@data["tracker"] @data["tracker"]
end end
# @return [String, nil] the effective Planka board name: the explicit
# `board` key when present, else the board named by a
# `tracker=planka:<board>` key (the form config-write actually
# writes). Non-planka trackers contribute nothing.
def planka_board
return board if board
return nil unless tracker&.start_with?("planka:")
tracker.delete_prefix("planka:")
end
# @return [String, nil] the cached Planka board id, if present. Only a # @return [String, nil] the cached Planka board id, if present. Only a
# cache — the tracker's board *name* stays the source of truth # cache — the tracker's board *name* stays the source of truth
# (issue #22); BoardResolver decides whether to honor it. # (issue #22); BoardResolver decides whether to honor it.

View File

@ -56,7 +56,8 @@ module Backlog
# @return [Decision] # @return [Decision]
def resolve def resolve
return decision_for_name(@config.board) || stop_and_discuss if @config.configured? configured_board = @config.planka_board
return decision_for_name(configured_board) || stop_and_discuss if configured_board
derived_decision || stop_and_discuss derived_decision || stop_and_discuss
end end

View File

@ -14,6 +14,29 @@ module Backlog
PRIORITY_LABELS = %w[P0 P1 P2 P3].freeze PRIORITY_LABELS = %w[P0 P1 P2 P3].freeze
AUTONOMY_LABELS = %w[hitl semi afk-ready].freeze AUTONOMY_LABELS = %w[hitl semi afk-ready].freeze
# Which board should this session's triage check inspect? Mirrors the
# CLI's config semantics (the session cwd's own .cc-os/config wins —
# umbrella subdir configs override the repo root's), falling back to
# the root config, then to the path heuristic. Honors both the
# explicit `board` key and `tracker=planka:<board>` (via
# Config#planka_board). Pure: the hook script reads the files and
# passes contents (or nil) in.
#
# @param cwd [String] the session's working directory
# @param root [String] the enclosing git toplevel (== cwd outside umbrellas)
# @param cwd_config [String, nil] contents of <cwd>/.cc-os/config
# @param root_config [String, nil] contents of <root>/.cc-os/config
# @return [String, nil] board name, or nil (don't guess) when nothing
# is configured and the path heuristic doesn't recognize the path
def self.board_name_for(cwd:, root:, cwd_config:, root_config:)
config_home = cwd_config ? cwd : root
config = Config.new(cwd_config || root_config)
return config.planka_board if config.planka_board
return nil unless Resolver.project_for(config_home)
File.basename(config_home)
end
# @param labels [Array<String>] the label names on one card # @param labels [Array<String>] the label names on one card
# @return [Boolean] # @return [Boolean]
def self.needs_triage?(labels) def self.needs_triage?(labels)

View File

@ -122,4 +122,19 @@ class ConfigTest < Minitest::Test
def test_set_on_nil_contents def test_set_on_nil_contents
assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42") assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42")
end end
def test_planka_board_prefers_explicit_board_key
config = Backlog::Config.new("board=explicit\ntracker=planka:from-tracker\n")
assert_equal "explicit", config.planka_board
end
def test_planka_board_derives_from_planka_tracker
config = Backlog::Config.new("tracker=planka:my-board\n")
assert_equal "my-board", config.planka_board
end
def test_planka_board_nil_for_non_planka_tracker
assert_nil Backlog::Config.new("tracker=forgejo:me/repo\n").planka_board
assert_nil Backlog::Config.new(nil).planka_board
end
end end

View File

@ -102,6 +102,30 @@ class ProjectsCliContractTest < Minitest::Test
filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` } filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` }
assert_equal({}, JSON.parse(filtered)["projects"]) assert_equal({}, JSON.parse(filtered)["projects"])
by_tracker = Dir.chdir(repo) { `#{env} ruby #{BIN} projects planka:demo 2>/dev/null` }
assert_equal 1, JSON.parse(by_tracker)["projects"].size
end
end
end
# Umbrella repos: config-write from a subdirectory writes the config to
# that subdirectory, so the index row must be keyed by the SUBDIRECTORY
# (the project the config describes), never the enclosing git toplevel —
# otherwise every subproject masquerades as the umbrella repo.
def test_config_write_in_umbrella_subdir_indexes_the_subdir
Dir.mktmpdir do |home|
Dir.mktmpdir do |repo|
`git -C #{repo} init -q 2>/dev/null`
subdir = File.join(repo, "child-project")
FileUtils.mkdir_p(subdir)
env = "CC_OS_HOME=#{home} HOME=#{home}"
Dir.chdir(subdir) { `#{env} ruby #{BIN} config-write planka:child 2>/dev/null` }
projects = Dir.chdir(subdir) { JSON.parse(`#{env} ruby #{BIN} projects 2>/dev/null`)["projects"] }
assert_equal [File.realpath(subdir)], projects.keys
assert_equal "child-project", projects.values.first["name"]
end end
end end
end end

View File

@ -101,6 +101,16 @@ class ResolverTest < Minitest::Test
assert_equal "use proxmox-ubuntu", resolver.resolve_string assert_equal "use proxmox-ubuntu", resolver.resolve_string
end end
def test_planka_tracker_key_resolves_to_its_board
resolver = Backlog::Resolver.new(
repo_path: "/somewhere/unrecognized/repo",
config_contents: "tracker=planka:my-board\n",
boards: %w[my-board]
)
assert_equal "use my-board", resolver.resolve_string
end
def test_project_for_returns_dev_clients_servers_or_nil def test_project_for_returns_dev_clients_servers_or_nil
assert_equal "Dev", Backlog::Resolver.project_for("/home/jared/dev/some-repo") assert_equal "Dev", Backlog::Resolver.project_for("/home/jared/dev/some-repo")
assert_equal "Clients", Backlog::Resolver.project_for("/home/jared/clients/acme-co") assert_equal "Clients", Backlog::Resolver.project_for("/home/jared/clients/acme-co")

View File

@ -38,4 +38,49 @@ class TriageCheckTest < Minitest::Test
assert_includes note, "1 unlabeled Backlog card on board cc-os" assert_includes note, "1 unlabeled Backlog card on board cc-os"
refute_includes note, "1 unlabeled Backlog cards" refute_includes note, "1 unlabeled Backlog cards"
end end
def test_board_name_for_subdir_config_overrides_root_config
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/servers/proxmox", root: "/home/jared/servers",
cwd_config: "tracker=planka:proxmox-board\n", root_config: "board=servers\n"
)
assert_equal "proxmox-board", name
end
def test_board_name_for_falls_back_to_root_config
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/dev/cc-os/plugins", root: "/home/jared/dev/cc-os",
cwd_config: nil, root_config: "tracker=planka:cc-os\n"
)
assert_equal "cc-os", name
end
def test_board_name_for_subdir_config_without_board_names_the_subdir
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/servers/proxmox", root: "/home/jared/servers",
cwd_config: "version=3\n", root_config: nil
)
assert_equal "proxmox", name
end
def test_board_name_for_no_config_uses_heuristic_repo_basename
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/dev/cc-os", root: "/home/jared/dev/cc-os",
cwd_config: nil, root_config: nil
)
assert_equal "cc-os", name
end
def test_board_name_for_unrecognized_path_without_config_is_nil
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/scratch/x", root: "/home/jared/scratch/x",
cwd_config: nil, root_config: nil
)
assert_nil name
end
end end