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:
parent
43932d40ab
commit
b0a21d6211
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue