os-backlog: cross-project filing convention + derived global project index (issue #27, ADR-0034)

Backlog::ProjectIndex at ~/.cc-os/projects.json, upserted fail-soft by
config-write, queried via new 'projects' subcommand. Discoverer template +
filing mechanics reference doc; CROSS-PROJECT rule line in the SessionStart
note. ADR-0034 Accepted. Suite 112/233/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
This commit is contained in:
jared 2026-07-13 10:48:19 -04:00
parent 9415eb0d65
commit f9a81a17c5
15 changed files with 489 additions and 26 deletions

View File

@ -0,0 +1,27 @@
---
id: "0034"
date: 2026-07-13
status: Accepted
supersedes:
superseded-by:
affected-paths: [plugins/os-backlog/lib/backlog/project_index.rb, plugins/os-backlog/bin/os-backlog, plugins/os-backlog/hooks/session_start.py, plugins/os-backlog/skills/route/references/cross-project-filing.md]
affected-components: [os-backlog]
---
# 0034 — Cross-project filing: file-dont-fix with a derived global project index
## Context
Work discovered mid-session often belongs to a different project than the one being worked on (issue #27). Editing the other project from the wrong session loses context and bypasses its tracker; but filing requires knowing which tracker that project uses, which until now lived only in its per-project .cc-os/config (ADR-0027).
## Decision
File-don't-fix: a session never edits another project — it files an issue/card on that project's own tracker. To make the target tracker discoverable, os-backlog maintains a derived global project index at ~/.cc-os/projects.json, upserted as a side effect of every successful config-write and queried via `os-backlog projects`. This is deliberately GLOBAL state, distinct from the per-project .cc-os/ state of ADR-0027; it is derived and rebuildable (re-running route/config-write regenerates rows). Filed items carry a Discoverer metadata block (filing repo/path, Claude Code session id, date, one-line motivation); the session id comes from the SessionStart hook stdin JSON or the transcript filename and is NEVER fabricated — write "session id unavailable" if unknown. Routing inside the target project still follows its own boundary rule (ADR-0033). Filing mechanics live in plugins/os-backlog/skills/route/references/cross-project-filing.md, not restated here.
## Consequences
One compact CROSS-PROJECT rule line added to the os-backlog SessionStart note. Index rows only exist for projects that have run config-write; unrouted projects are reported, not guessed. Related: issue #27, ADR-0027, ADR-0029, ADR-0033.
## Alternatives rejected

View File

@ -39,5 +39,6 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
| 0030 | [Read-only work uses Claude Code plan mode; no /readonly shortcut command](0030-read-only-work-uses-claude-code-plan-mode-no-readonly-shortcut-command.md) | Accepted | 2026-07-13 | | 0030 | [Read-only work uses Claude Code plan mode; no /readonly shortcut command](0030-read-only-work-uses-claude-code-plan-mode-no-readonly-shortcut-command.md) | Accepted | 2026-07-13 |
| 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 | | 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 | | 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) | Proposed | 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 |
<!-- adr-index:end --> <!-- adr-index:end -->

View File

@ -64,7 +64,8 @@ entry is in the leaf file named.
shipped: SessionStart injection note + /to-issues tracker routing (issue #16); board-id shipped: SessionStart injection note + /to-issues tracker routing (issue #16); board-id
cache via BoardResolver (issue #22); slice 8 — Operations board + tracker-routing rubric 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 category + ADR-0033 canonizing Planka-state/git-issues-spec (issue #17); route inspect
issue-shape classification + split-by-kind default (issue #26). Detail: issue-shape classification + split-by-kind default (issue #26); cross-project filing
convention + derived global project index (issue #27, ADR-0034). Detail:
os-context / os-doc-hygiene / os-backlog leaves. os-context / os-doc-hygiene / os-backlog leaves.
**Remaining optional items:** additional project onboarding (one at a time, per ADR-0013); **Remaining optional items:** additional project onboarding (one at a time, per ADR-0013);

View File

@ -108,5 +108,13 @@ column ownership).
not two destinations); step 3 got a never-re-trigger exception; boundary rule now cites not two destinations); step 3 got a never-re-trigger exception; boundary rule now cites
ADR-0033 instead of restating it. 12 new tests incl. a CLI-contract test; suite 103 runs / ADR-0033 instead of restating it. 12 new tests incl. a CLI-contract test; suite 103 runs /
212 assertions / 0 failures. 212 assertions / 0 failures.
- **Outstanding:** #14 residual (onboard one more project — operational hitl); #27 - **Cross-project filing (2026-07-13, issue #27, ADR-0034):** `Backlog::ProjectIndex`
cross-project filing index; #28 wakeup design spike. 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
`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).

View File

@ -5,7 +5,9 @@
# cards, snapshot, inspect, config-write. # cards, snapshot, inspect, config-write.
# #
# Usage: # Usage:
# os-backlog board-ensure NAME --project Dev|Clients # os-backlog board-ensure NAME [--project PROJECT] (any Planka project name;
# defaults to Backlog::Resolver.project_for(Dir.pwd) — Dev/Clients/Servers
# by path, else "Dev"; creates the project if it doesn't exist yet)
# os-backlog activate NAME # os-backlog activate NAME
# os-backlog archive NAME --yes # os-backlog archive NAME --yes
# os-backlog resolve < input.json # os-backlog resolve < input.json
@ -15,7 +17,9 @@
# os-backlog cards --board NAME (human-readable per-list card listing) # os-backlog cards --board NAME (human-readable per-list card listing)
# os-backlog snapshot --board NAME (full board state as JSON; read-only) # os-backlog snapshot --board NAME (full board state as JSON; read-only)
# os-backlog inspect (detect existing tracking for cwd repo, JSON) # os-backlog inspect (detect existing tracking for cwd repo, JSON)
# os-backlog config-write <tracker-value> (write tracker key to .cc-os/config) # os-backlog config-write <tracker-value> (write tracker key to .cc-os/config
# + upsert the global project index)
# os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON)
# #
# board-ensure/activate/archive talk to Planka over the network (the # board-ensure/activate/archive talk to Planka over the network (the
# installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/ # installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/
@ -62,13 +66,18 @@ def build_cards(client)
Backlog::Cards.new(client: client, board_resolver: resolver) Backlog::Cards.new(client: client, board_resolver: resolver)
end end
# --project accepts any Planka project name (Dev/Clients/Servers, or a
# fresh one board-ensure will create). When --project is omitted, the
# default is derived from the repo's path via
# Backlog::Resolver.project_for(Dir.pwd), falling back to "Dev" for paths
# outside every known root.
def parse_project_flag(args) def parse_project_flag(args)
idx = args.index("--project") idx = args.index("--project")
return "Dev" unless idx return Backlog::Resolver.project_for(Dir.pwd) || "Dev" unless idx
args.delete_at(idx) args.delete_at(idx)
project = args.delete_at(idx) project = args.delete_at(idx)
project || fail_soft("--project requires a value (Dev or Clients)") project || fail_soft("--project requires a value (a Planka project name)")
end end
def parse_flag(args, flag) def parse_flag(args, flag)
@ -150,6 +159,29 @@ 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
# inside a work tree, else the realpath of cwd. 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
end
# Best-effort upsert of the global project index row after a successful
# config-write. Index failures must never fail config-write — warn at most.
def update_project_index(tracker)
repo_path = canonical_repo_path
remote = `git remote get-url origin 2>/dev/null`.strip
remote = nil if remote.empty?
Backlog::ProjectIndex.new.upsert(path: repo_path, name: File.basename(repo_path),
tracker: tracker, remote: remote)
rescue StandardError => e
warn "os-backlog: (non-fatal) could not update project index: #{e.message}"
end
command, *rest = ARGV command, *rest = ARGV
begin begin
@ -261,13 +293,22 @@ when "config-write"
updated = Backlog::Config.merge(existing, "tracker", value) updated = Backlog::Config.merge(existing, "tracker", value)
FileUtils.mkdir_p(config_dir) FileUtils.mkdir_p(config_dir)
File.write(config_path, updated) File.write(config_path, updated)
update_project_index(value)
puts "tracker set to #{value} in #{config_path}" puts "tracker set to #{value} in #{config_path}"
when "projects"
filter = rest.shift
projects = Backlog::ProjectIndex.new.all
if filter
projects = projects.select { |_path, row| row["name"].to_s.include?(filter) }
end
puts JSON.pretty_generate({ "projects" => projects })
when nil, "-h", "--help" when nil, "-h", "--help"
puts <<~USAGE puts <<~USAGE
usage: os-backlog <command> [options] usage: os-backlog <command> [options]
commands: commands:
board-ensure NAME --project Dev|Clients board-ensure NAME [--project PROJECT] (any project name; creates it if missing;
default derived from cwd path)
activate NAME activate NAME
archive NAME --yes archive NAME --yes
resolve (reads {"repo_path","config","boards"} JSON from stdin) resolve (reads {"repo_path","config","boards"} JSON from stdin)
@ -278,7 +319,8 @@ when nil, "-h", "--help"
card-label --board NAME --card ID --label NAME card-label --board NAME --card ID --label NAME
card-comment --card ID --text "..." card-comment --card ID --text "..."
inspect (detect existing tracking for cwd repo, JSON) inspect (detect existing tracking for cwd repo, JSON)
config-write <tracker-value> (write tracker key to .cc-os/config) config-write <tracker-value> (write tracker key to .cc-os/config + global project index)
projects [NAME-FILTER] (print the global project index at ~/.cc-os/projects.json as JSON)
USAGE USAGE
exit(command.nil? ? 1 : 0) exit(command.nil? ? 1 : 0)
else else

View File

@ -29,9 +29,11 @@ from typing import Optional
NOTE = """\ NOTE = """\
[os-backlog] Backlog process rules (rules only this note never carries board state; board state is pull-only via /os-backlog:list when the user asks). [os-backlog] Backlog process rules (rules only this note never carries board state; board state is pull-only via /os-backlog:list when the user asks).
- CAPTURE: WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will NOT be done this session -> capture it as a Backlog card via /os-backlog:capture. Never a TODO comment in code, never only a mention in chat. - CAPTURE: WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will NOT be done this session -> capture it as a Backlog card via /os-backlog:capture. Never a TODO comment in code, never only a mention in chat.
- ROUTING: WHEN process/backlog/spec-tracking work surfaces -> read the tracker key from .cc-os/config (`tracker=planka:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`) and send work where it says. WHEN this is a git project and no tracker key is configured -> suggest /os-backlog:route once; do not nag. - ROUTING: WHEN process/backlog/spec-tracking work surfaces -> read the tracker key from .cc-os/config (`tracker=planka:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`) and send work where it says. WHEN this is a git project and no tracker key is configured -> suggest /os-backlog:route once; do not nag. A subdirectory's own .cc-os/config overrides the repo root's for any session started there umbrella repos route each subproject to its own board/tracker this way; do not assume one repo means one board.
- PROMOTION: WHEN a Planka-tracked effort accretes significant code or design decisions in card comments -> flag promotion to the user (never silently): carve out a repo, move the durable spec into git issues via /to-issues, and keep the Planka card as a pointer (title + link). Never duplicate spec text into a card description. - PROMOTION: WHEN a Planka-tracked effort accretes significant code or design decisions in card comments -> flag promotion to the user (never silently): carve out a repo, move the durable spec into git issues via /to-issues, and keep the Planka card as a pointer (title + link). Never duplicate spec text into a card description.
- COLUMN OWNERSHIP (CLI-enforced per ADR-029; a violating card-move fails with the rule named): create cards at Backlog only. Never move a card into or out of Next Next is human-curated in both directions. Never move a card labeled hitl; never move a card out of Done. Working a card MEANS moving it via card-move: Doing when work starts, Waiting + a blocker comment when blocked, and on shipping: Review (semi) or Done (afk-ready, only after verification). - COLUMN OWNERSHIP (CLI-enforced per ADR-029; a violating card-move fails with the rule named): create cards at Backlog only. Never move a card into or out of Next Next is human-curated in both directions. Never move a card labeled hitl; never move a card out of Done. Working a card MEANS moving it via card-move: Doing when work starts, Waiting + a blocker comment when blocked, and on shipping: Review (semi) or Done (afk-ready, only after verification).
- LABELING/TRIAGE: priority (P0-P3) and autonomy (hitl/semi/afk-ready) labeling is the RESPONSIBLE project's job (it knows its own priorities) — a parent umbrella may label its own and its child boards, but never label cards on a board that belongs to a different project you're just filing onto. Triage is initiated by this project's own SessionStart check (a separate hook), not by capture — if it reports unlabeled Backlog cards, dispatch the card-triage agent (haiku, background) to clear them.
- CROSS-PROJECT: WHEN a needed change belongs to a DIFFERENT project THEN do not edit it query `os-backlog projects`, file an issue/card with that project's tracker using the Discoverer template (see os-backlog route references/cross-project-filing.md).
- AUTONOMY LABELS (ADR-029): hitl -> human-owned; never picked up autonomously. semi -> run the mechanical parts, stop at named decision gates; shipped work goes to Review for human sign-off. afk-ready -> shipped + verified goes straight to Done, skipping Review. - AUTONOMY LABELS (ADR-029): hitl -> human-owned; never picked up autonomously. semi -> run the mechanical parts, stop at named decision gates; shipped work goes to Review for human sign-off. afk-ready -> shipped + verified goes straight to Done, skipping Review.
""" """

View File

@ -0,0 +1,91 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# triage_check.rb — SessionStart hook (card 1818330518584820864): checks
# whether the CURRENT project's own resolved board has unlabeled Backlog
# cards and, if so, emits additionalContext instructing dispatch of the
# card-triage agent (haiku, background).
#
# Design intent: labeling is the RESPONSIBLE project's job, not capture's
# and not whichever project happened to file a card cross-project. This
# hook only ever looks at the board the CURRENT session's project resolves
# to (the same board /os-backlog:capture would file onto from here) — a
# session run from the owning project, or a parent umbrella, naturally
# only ever triggers triage for a board it owns; it never inspects or
# labels a board belonging to a different project.
#
# This is deliberately separate from session_start.py (which stays a pure,
# network-free function of filesystem state per its own docstring): this
# script does one bounded Planka read to decide whether to fire, then
# hands off the actual labeling to the card-triage agent — it never
# labels anything itself.
#
# Fails soft everywhere: no git project, no board name derivable, no
# network/credentials, any Planka error, or a board that doesn't exist yet
# -> silent, exit 0. Never blocks a session.
require "json"
require_relative "../lib/backlog"
def find_project_root(cwd)
dir = File.expand_path(cwd)
loop do
return dir if Dir.exist?(File.join(dir, ".git"))
parent = File.dirname(dir)
return nil if parent == dir
dir = parent
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).
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)
end
def build_client
require "planka_api"
client = Planka::Client.new
client.login
client
end
def main
root = find_project_root(Dir.pwd)
return unless root
board_name = resolve_board_name(root)
return unless board_name
client = build_client
snapshot = Backlog::Cards.new(client: client).snapshot(board_name: board_name)
backlog_list = snapshot["lists"].find { |l| l["name"] == "Backlog" }
return unless backlog_list
labels = backlog_list["cards"].map { |c| c["labels"] }
note = Backlog::TriageCheck.note_for(board_name, labels)
return unless note
puts JSON.generate(
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: note
}
)
end
begin
main
rescue Exception # rubocop:disable Lint/RescueException -- a hook must never block a session
nil
end

View File

@ -4,5 +4,7 @@ require_relative "backlog/board_resolver"
require_relative "backlog/cards" require_relative "backlog/cards"
require_relative "backlog/config" require_relative "backlog/config"
require_relative "backlog/inspector" require_relative "backlog/inspector"
require_relative "backlog/project_index"
require_relative "backlog/resolver" require_relative "backlog/resolver"
require_relative "backlog/tracker" require_relative "backlog/tracker"
require_relative "backlog/triage_check"

View File

@ -20,6 +20,13 @@ module Backlog
# This class never archives a board — archiving only happens on explicit # This class never archives a board — archiving only happens on explicit
# human request (see BoardSpec / the resolver). It does provide the raw # human request (see BoardSpec / the resolver). It does provide the raw
# rename primitives (+activate+ / +archive+) that a caller may invoke. # rename primitives (+activate+ / +archive+) that a caller may invoke.
#
# project_name accepts ANY Planka project name, not just "Dev"/"Clients"
# — #ensure creates the project on demand (find_or_create_project) if it
# doesn't exist yet. There is deliberately no move-a-board-between-
# projects command: Planka's API doesn't expose one cleanly and the need
# is rare enough that it isn't worth building — do it by hand in the
# Planka UI (drag the board card into the target project) instead.
class BoardEnsurer class BoardEnsurer
Result = Struct.new(:board, :created, :project, :lists_created, :labels_created, keyword_init: true) Result = Struct.new(:board, :created, :project, :lists_created, :labels_created, keyword_init: true)

View File

@ -0,0 +1,71 @@
require "json"
require "fileutils"
module Backlog
# The global project index at ~/.cc-os/projects.json: a derived map of
# canonical repo path -> {name, tracker, remote, updated_at}, maintained as
# a side effect of config-write (issue #27). Deliberately GLOBAL state
# (distinct from per-project .cc-os/ under ADR-0027) so any session can ask
# "which tracker does project X use" without visiting its repo.
#
# Pure over an injected path: callers hand in the already-canonical repo
# path (the CLI resolves it via `git rev-parse --show-toplevel`). Read
# paths never raise — a missing or corrupt file reads as an empty index; a
# corrupt file is only overwritten when a new upsert happens. Writes are
# atomic (temp file + rename in the same directory).
class ProjectIndex
VERSION = 1
# Default index path, honoring a CC_OS_HOME override (used by tests to
# keep the real index untouched) and falling back to $HOME/.cc-os.
def self.default_path(env: ENV)
base = env["CC_OS_HOME"] || File.join(env.fetch("HOME", Dir.home), ".cc-os")
File.join(base, "projects.json")
end
def initialize(path: self.class.default_path, today: -> { Time.now.strftime("%Y-%m-%d") })
@path = path
@today = today
end
# @return [Hash] the projects map (canonical path -> row); {} when the
# file is missing or unreadable/corrupt. Never raises.
def all
data = read
data["projects"].is_a?(Hash) ? data["projects"] : {}
end
# Insert or update the row for a canonical repo path. Atomic write;
# creates the parent directory if missing.
def upsert(path:, name:, tracker:, remote:)
projects = all
projects[path] = {
"name" => name,
"tracker" => tracker,
"remote" => remote,
"updated_at" => @today.call
}
write({ "version" => VERSION, "projects" => projects })
projects[path]
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, ".projects.json.tmp.#{Process.pid}")
File.write(temp, JSON.pretty_generate(data) + "\n")
File.rename(temp, @path)
end
end
end

View File

@ -18,6 +18,31 @@ module Backlog
DEV_ROOT = File.expand_path("~/dev") DEV_ROOT = File.expand_path("~/dev")
CLIENTS_ROOT = File.expand_path("~/clients") CLIENTS_ROOT = File.expand_path("~/clients")
SERVERS_ROOT = File.expand_path("~/servers")
# Path-based Planka project heuristic, shared by the resolver's own
# ambiguity gate and the `board-ensure` CLI's --project default: "Dev"
# under ~/dev/*, "Clients" under ~/clients/*, "Servers" under
# ~/servers/* (the servers umbrella repo — ADR: infrastructure is a
# distinct domain from Dev), nil (ambiguous) otherwise. Board-ensure
# always accepts an arbitrary --project name too — this heuristic only
# supplies the default when none is given.
#
# @param repo_path [String]
# @return [String, nil]
def self.project_for(repo_path)
path = File.expand_path(repo_path)
return "Dev" if under_root?(path, DEV_ROOT)
return "Clients" if under_root?(path, CLIENTS_ROOT)
return "Servers" if under_root?(path, SERVERS_ROOT)
nil
end
def self.under_root?(path, root)
path == root || path.start_with?("#{root}/")
end
private_class_method :under_root?
# @param repo_path [String] absolute path to the repo # @param repo_path [String] absolute path to the repo
# @param config_contents [String, nil] raw contents of .cc-os/config, or nil/absent # @param config_contents [String, nil] raw contents of .cc-os/config, or nil/absent
@ -60,17 +85,6 @@ module Backlog
def board_name = File.basename(@repo_path) def board_name = File.basename(@repo_path)
# "Dev" for paths under ~/dev/*, "Clients" for paths under ~/clients/*, def project = self.class.project_for(@repo_path)
# nil (ambiguous) otherwise.
def project
return "Dev" if under?(DEV_ROOT)
return "Clients" if under?(CLIENTS_ROOT)
nil
end
def under?(root)
@repo_path == root || @repo_path.start_with?("#{root}/")
end
end end
end end

View File

@ -0,0 +1,46 @@
module Backlog
# Pure decision for the triage-trigger SessionStart hook (card
# 1818330518584820864): given the label arrays already sitting on a
# board's Backlog-column cards, does the hook need to instruct dispatch
# of the card-triage agent? Network/board-fetch stays in the hook
# script (hooks/triage_check.rb) — this class only classifies +
# composes the note text, so the decision is unit-testable without
# Planka.
#
# "Unlabeled" mirrors card-triage's own definition (agents/card-triage.md):
# a card is raw if it is missing a priority label (P0-P3) OR an
# autonomy label (hitl/semi/afk-ready) — not just totally bare.
class TriageCheck
PRIORITY_LABELS = %w[P0 P1 P2 P3].freeze
AUTONOMY_LABELS = %w[hitl semi afk-ready].freeze
# @param labels [Array<String>] the label names on one card
# @return [Boolean]
def self.needs_triage?(labels)
(labels & PRIORITY_LABELS).empty? || (labels & AUTONOMY_LABELS).empty?
end
# @param backlog_cards_labels [Array<Array<String>>] label arrays,
# one per card currently in the board's Backlog list
# @return [Array<Array<String>>] the subset needing triage
def self.unlabeled(backlog_cards_labels)
backlog_cards_labels.select { |labels| needs_triage?(labels) }
end
# @param board_name [String]
# @param backlog_cards_labels [Array<Array<String>>]
# @return [String, nil] the SessionStart additionalContext note, or
# nil when there is nothing to triage
def self.note_for(board_name, backlog_cards_labels)
count = unlabeled(backlog_cards_labels).size
return nil if count.zero?
card_word = count == 1 ? "card" : "cards"
"[os-backlog] #{count} unlabeled Backlog #{card_word} on board #{board_name} -> " \
"dispatch the card-triage agent now (model: haiku, run in background) to label " \
"them. Labeling is this project's job for its own board (a parent umbrella may " \
"also label its child boards) — never label cards on a board that belongs to a " \
"different project."
end
end
end

View File

@ -11,7 +11,7 @@ Capture a piece of work as a card on the right Planka board, cheaply, without in
- **Next stays human-curated in both directions** — never move a card into or out of Next. - **Next stays human-curated in both directions** — never move a card into or out of Next.
- **Never move a card labeled `hitl`** — hitl cards are human-owned, never self-assigned or moved by the AI. - **Never move a card labeled `hitl`** — hitl cards are human-owned, never self-assigned or moved by the AI.
- These rules are enforced deterministically by the CLI (`card-move`), not just documented here — a violating move fails with a one-line reason naming the rule. - These rules are enforced deterministically by the CLI (`card-move`), not just documented here — a violating move fails with a one-line reason naming the rule.
- Priority/autonomy labeling is not capture's job — leave new cards unlabeled; the card-triage agent batches that later. - Priority/autonomy labeling is not capture's job — leave new cards unlabeled; the card-triage agent batches that later. Triage is initiated by the SessionStart hook of the board's OWNING project (the project the card was filed onto, or a parent umbrella labeling its own/child boards) — never by whichever project happened to file the card. A cross-project filer (see CROSS-PROJECT below) must never label the destination board's cards itself.
## Procedure ## Procedure
@ -31,9 +31,13 @@ All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
- `stop-and-discuss` → do NOT create anything. Tell the user the repo doesn't map to a board and ask where the card should go. - `stop-and-discuss` → do NOT create anything. Tell the user the repo doesn't map to a board and ask where the card should go.
3. **Ensure the board exists** (idempotent — safe to run every time): 3. **Ensure the board exists** (idempotent — safe to run every time):
```bash ```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog board-ensure <board> --project <Dev|Clients> ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog board-ensure <board>
``` ```
Project is `Dev` for repos under `~/dev/`, `Clients` for repos under `~/clients/`. Omit `--project` and it derives the right Planka project from the repo path: `Dev` under
`~/dev/`, `Clients` under `~/clients/`, `Servers` under `~/servers/`. Pass `--project <name>`
to target (or create) any other project explicitly — board-ensure creates the project too if
it doesn't exist yet. There's no command to move an existing board between projects; that's a
manual step in the Planka UI.
4. **Create the card at Backlog:** 4. **Create the card at Backlog:**
```bash ```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-add --board <board> --title "<short imperative title>" [--description "<context: repo, file paths, why>"] ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-add --board <board> --title "<short imperative title>" [--description "<context: repo, file paths, why>"]

View File

@ -0,0 +1,39 @@
# Cross-project filing (file-don't-fix)
When work surfaces that belongs to a DIFFERENT project than the one this session is
working in, do not edit that project — file the work on ITS tracker and continue. The
owning project's own session picks it up with full context.
## Finding the target's tracker
Query the global project index (derived from every `config-write`, at
`~/.cc-os/projects.json`):
```
os-backlog projects # full index as JSON
os-backlog projects <substring> # filter by project name
```
Each row: canonical repo path -> `{name, tracker, remote, updated_at}`. If the target
project has no row, it hasn't been routed yet — tell the user rather than guessing.
## Filing mechanics by tracker kind
- `forgejo:<owner>/<repo>``tea issues create --repo <owner>/<repo> --title "..." --description "..."`
- `github:<owner>/<repo>``gh issue create --repo <owner>/<repo> --title "..." --body "..."`
- `planka:<board>` (planka-only targets) — `os-backlog card-add --board <board> --title "..." [--description "..."]`
Routing inside the target project still follows ITS boundary rule (ADR-0033: Planka is
state, git issues are specs) — a durable spec goes to the git-issue tracker even if the
project also has a board.
## Discoverer block (append verbatim to the issue/card description)
```
--------
**Discoverer:** <filing repo or path>, session <claude-code-session-id>, <YYYY-MM-DD>. <one-line motivation>
```
Session id source: the SessionStart hook receives it as the `session_id` field of its
stdin JSON; a live session can also find it in its transcript filename. If it is
genuinely unavailable, write `session id unavailable` — never fabricate one.

View File

@ -0,0 +1,108 @@
require_relative "test_helper"
require "tmpdir"
require "fileutils"
require "json"
class ProjectIndexTest < Minitest::Test
def with_index
Dir.mktmpdir do |dir|
path = File.join(dir, ".cc-os", "projects.json")
yield Backlog::ProjectIndex.new(path: path, today: -> { "2026-07-13" }), path
end
end
def test_missing_file_reads_as_empty
with_index do |index, _path|
assert_equal({}, index.all)
end
end
def test_corrupt_file_reads_as_empty_and_is_not_clobbered_by_reads
with_index do |index, path|
FileUtils.mkdir_p(File.dirname(path))
File.write(path, "{not json")
assert_equal({}, index.all)
assert_equal "{not json", File.read(path) # read paths never rewrite
end
end
def test_upsert_creates_row_with_updated_at
with_index do |index, path|
index.upsert(path: "/repos/foo", name: "foo", tracker: "planka:foo", remote: nil)
data = JSON.parse(File.read(path))
assert_equal 1, data["version"]
row = data["projects"]["/repos/foo"]
assert_equal "foo", row["name"]
assert_equal "planka:foo", row["tracker"]
assert_nil row["remote"]
assert_equal "2026-07-13", row["updated_at"]
end
end
def test_upsert_updates_existing_row_keyed_by_path
with_index do |index, _path|
index.upsert(path: "/repos/foo", name: "foo", tracker: "planka:foo", remote: nil)
index.upsert(path: "/repos/foo", name: "foo", tracker: "github:me/foo", remote: "r")
assert_equal 1, index.all.size
assert_equal "github:me/foo", index.all["/repos/foo"]["tracker"]
end
end
def test_name_collision_two_paths_same_basename_keeps_both
with_index do |index, _path|
index.upsert(path: "/a/foo", name: "foo", tracker: "planka:foo", remote: nil)
index.upsert(path: "/b/foo", name: "foo", tracker: "github:me/foo", remote: nil)
assert_equal %w[/a/foo /b/foo], index.all.keys.sort
end
end
def test_upsert_over_corrupt_file_leaves_valid_json_and_no_temp_files
with_index do |index, path|
FileUtils.mkdir_p(File.dirname(path))
File.write(path, "{not json")
index.upsert(path: "/repos/foo", name: "foo", tracker: "repo:.", remote: nil)
data = JSON.parse(File.read(path))
assert_equal ["/repos/foo"], data["projects"].keys
leftovers = Dir.children(File.dirname(path)).reject { |f| f == "projects.json" }
assert_empty leftovers, "atomic write left temp files: #{leftovers}"
end
end
def test_default_path_honors_cc_os_home_override
path = Backlog::ProjectIndex.default_path(env: { "CC_OS_HOME" => "/tmp/x", "HOME" => "/home/y" })
assert_equal "/tmp/x/projects.json", path
end
def test_default_path_falls_back_to_home
path = Backlog::ProjectIndex.default_path(env: { "HOME" => "/home/y" })
assert_equal "/home/y/.cc-os/projects.json", path
end
end
class ProjectsCliContractTest < Minitest::Test
BIN = File.expand_path("../bin/os-backlog", __dir__)
# config-write must upsert the index (redirected via CC_OS_HOME so the
# real ~/.cc-os is never touched), and `projects` must always emit a
# top-level "projects" key — even when the index is empty.
def test_config_write_upserts_index_and_projects_lists_it
Dir.mktmpdir do |home|
Dir.mktmpdir do |repo|
env = "CC_OS_HOME=#{home} HOME=#{home}"
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
assert_equal({ "projects" => {} }, JSON.parse(out))
Dir.chdir(repo) { `#{env} ruby #{BIN} config-write planka:demo 2>/dev/null` }
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
projects = JSON.parse(out)["projects"]
assert_equal 1, projects.size
row = projects.values.first
assert_equal "planka:demo", row["tracker"]
assert_equal File.basename(File.realpath(repo)), row["name"]
filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` }
assert_equal({}, JSON.parse(filtered)["projects"])
end
end
end
end