diff --git a/docs/implementation-status/os-backlog.md b/docs/implementation-status/os-backlog.md index a641ed7..2cbdf14 100644 --- a/docs/implementation-status/os-backlog.md +++ b/docs/implementation-status/os-backlog.md @@ -111,8 +111,9 @@ column ownership). - **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/ 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 - subcommand. Discoverer template + tea/gh/card-add mechanics in + path = realpath of the directory the config was written into — NOT the git toplevel, so + 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 the SessionStart note. 9 new tests; suite 112 runs / 233 assertions / 0 failures. Live 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 designated poller machine (state stays per-host); cadence/cron mechanism and a `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:` 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 rollout (first opt-in project + poller machine + cadence). diff --git a/plugins/os-backlog/bin/os-backlog b/plugins/os-backlog/bin/os-backlog index 2674d0d..cc975d2 100755 --- a/plugins/os-backlog/bin/os-backlog +++ b/plugins/os-backlog/bin/os-backlog @@ -159,12 +159,13 @@ rescue StandardError [{ "tool" => "gh", "open_count" => nil, "reason" => "could not parse gh output" }, nil] end -# Canonical repo path for the global project index: the git toplevel when -# inside a work tree, else the realpath of cwd. Never raises. +# Canonical path for the global project index: the realpath of cwd — the +# 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 - top = `git rev-parse --show-toplevel 2>/dev/null`.strip - return top unless top.empty? - File.realpath(Dir.pwd) rescue StandardError Dir.pwd @@ -299,7 +300,9 @@ when "projects" filter = rest.shift projects = Backlog::ProjectIndex.new.all 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 puts JSON.pretty_generate({ "projects" => projects }) when nil, "-h", "--help" diff --git a/plugins/os-backlog/hooks/hooks.json b/plugins/os-backlog/hooks/hooks.json index c92ebf1..91b32ed 100644 --- a/plugins/os-backlog/hooks/hooks.json +++ b/plugins/os-backlog/hooks/hooks.json @@ -12,7 +12,7 @@ { "type": "command", "command": "ruby ${CLAUDE_PLUGIN_ROOT}/hooks/triage_check.rb", - "timeout": 8 + "timeout": 15 } ] } diff --git a/plugins/os-backlog/hooks/triage_check.rb b/plugins/os-backlog/hooks/triage_check.rb index 4ce69a0..3089470 100644 --- a/plugins/os-backlog/hooks/triage_check.rb +++ b/plugins/os-backlog/hooks/triage_check.rb @@ -39,18 +39,21 @@ def find_project_root(cwd) end end -# Same board-name convention capture/route already use: an explicit -# .cc-os/config `board` key, else — only inside a path the project -# heuristic actually recognizes — the repo directory's own name. Outside -# any known root with no explicit config, we don't guess (mirrors the -# resolver's stop-and-discuss ambiguity gate). +# Same config semantics as the CLI: the session cwd's own .cc-os/config +# wins (umbrella subdir configs override the repo root's for sessions +# started there), then the root config, then the path heuristic — and +# `tracker=planka:` counts, not just the explicit `board` key. +# The decision itself is pure (TriageCheck.board_name_for); this only +# reads the two candidate config files. def resolve_board_name(root) - config_path = File.join(root, ".cc-os", "config") - config = Backlog::Config.new(File.exist?(config_path) ? File.read(config_path) : nil) - return config.board if config.board - return nil unless Backlog::Resolver.project_for(root) - - File.basename(root) + read = lambda do |dir| + path = File.join(dir, ".cc-os", "config") + File.exist?(path) ? File.read(path) : nil + end + cwd = File.expand_path(Dir.pwd) + Backlog::TriageCheck.board_name_for(cwd: cwd, root: root, + cwd_config: read.call(cwd), + root_config: read.call(root)) end def build_client @@ -86,6 +89,6 @@ end begin main -rescue Exception # rubocop:disable Lint/RescueException -- a hook must never block a session - nil +rescue Exception => e # rubocop:disable Lint/RescueException -- a hook must never block a session + warn "os-backlog triage_check: #{e.class}: #{e.message}" if ENV["CC_OS_DEBUG"] end diff --git a/plugins/os-backlog/lib/backlog/config.rb b/plugins/os-backlog/lib/backlog/config.rb index 0bb46e4..50f4cb3 100644 --- a/plugins/os-backlog/lib/backlog/config.rb +++ b/plugins/os-backlog/lib/backlog/config.rb @@ -35,6 +35,17 @@ module Backlog @data["tracker"] end + # @return [String, nil] the effective Planka board name: the explicit + # `board` key when present, else the board named by a + # `tracker=planka:` 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 # cache — the tracker's board *name* stays the source of truth # (issue #22); BoardResolver decides whether to honor it. diff --git a/plugins/os-backlog/lib/backlog/resolver.rb b/plugins/os-backlog/lib/backlog/resolver.rb index 9f5bd0a..63deb5d 100644 --- a/plugins/os-backlog/lib/backlog/resolver.rb +++ b/plugins/os-backlog/lib/backlog/resolver.rb @@ -56,7 +56,8 @@ module Backlog # @return [Decision] 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 end diff --git a/plugins/os-backlog/lib/backlog/triage_check.rb b/plugins/os-backlog/lib/backlog/triage_check.rb index eb3aef4..131f0a2 100644 --- a/plugins/os-backlog/lib/backlog/triage_check.rb +++ b/plugins/os-backlog/lib/backlog/triage_check.rb @@ -14,6 +14,29 @@ module Backlog PRIORITY_LABELS = %w[P0 P1 P2 P3].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:` (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 /.cc-os/config + # @param root_config [String, nil] contents of /.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] the label names on one card # @return [Boolean] def self.needs_triage?(labels) diff --git a/plugins/os-backlog/tests/config_test.rb b/plugins/os-backlog/tests/config_test.rb index 2b84056..3d1b6c6 100644 --- a/plugins/os-backlog/tests/config_test.rb +++ b/plugins/os-backlog/tests/config_test.rb @@ -122,4 +122,19 @@ class ConfigTest < Minitest::Test def test_set_on_nil_contents assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42") 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 diff --git a/plugins/os-backlog/tests/project_index_test.rb b/plugins/os-backlog/tests/project_index_test.rb index 62699c8..4aab83c 100644 --- a/plugins/os-backlog/tests/project_index_test.rb +++ b/plugins/os-backlog/tests/project_index_test.rb @@ -102,6 +102,30 @@ class ProjectsCliContractTest < Minitest::Test filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` } 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 diff --git a/plugins/os-backlog/tests/resolver_test.rb b/plugins/os-backlog/tests/resolver_test.rb index b26ff59..a635da2 100644 --- a/plugins/os-backlog/tests/resolver_test.rb +++ b/plugins/os-backlog/tests/resolver_test.rb @@ -101,6 +101,16 @@ class ResolverTest < Minitest::Test assert_equal "use proxmox-ubuntu", resolver.resolve_string 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 assert_equal "Dev", Backlog::Resolver.project_for("/home/jared/dev/some-repo") assert_equal "Clients", Backlog::Resolver.project_for("/home/jared/clients/acme-co") diff --git a/plugins/os-backlog/tests/triage_check_test.rb b/plugins/os-backlog/tests/triage_check_test.rb index 583909d..10c6edd 100644 --- a/plugins/os-backlog/tests/triage_check_test.rb +++ b/plugins/os-backlog/tests/triage_check_test.rb @@ -38,4 +38,49 @@ class TriageCheckTest < Minitest::Test assert_includes note, "1 unlabeled Backlog card on board cc-os" refute_includes note, "1 unlabeled Backlog cards" 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