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:
parent
9415eb0d65
commit
f9a81a17c5
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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 |
|
||||
| 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) | 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 -->
|
||||
|
|
|
|||
|
|
@ -64,7 +64,8 @@ entry is in the leaf file named.
|
|||
shipped: SessionStart injection note + /to-issues tracker routing (issue #16); board-id
|
||||
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). 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.
|
||||
|
||||
**Remaining optional items:** additional project onboarding (one at a time, per ADR-0013);
|
||||
|
|
|
|||
|
|
@ -108,5 +108,13 @@ column ownership).
|
|||
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 /
|
||||
212 assertions / 0 failures.
|
||||
- **Outstanding:** #14 residual (onboard one more project — operational hitl); #27
|
||||
cross-project filing index; #28 wakeup design spike.
|
||||
- **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
|
||||
`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).
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
# cards, snapshot, inspect, config-write.
|
||||
#
|
||||
# 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 archive NAME --yes
|
||||
# os-backlog resolve < input.json
|
||||
|
|
@ -15,7 +17,9 @@
|
|||
# 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 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
|
||||
# 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)
|
||||
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)
|
||||
idx = args.index("--project")
|
||||
return "Dev" unless idx
|
||||
return Backlog::Resolver.project_for(Dir.pwd) || "Dev" unless idx
|
||||
|
||||
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
|
||||
|
||||
def parse_flag(args, flag)
|
||||
|
|
@ -150,6 +159,29 @@ 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.
|
||||
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
|
||||
|
||||
begin
|
||||
|
|
@ -261,13 +293,22 @@ when "config-write"
|
|||
updated = Backlog::Config.merge(existing, "tracker", value)
|
||||
FileUtils.mkdir_p(config_dir)
|
||||
File.write(config_path, updated)
|
||||
update_project_index(value)
|
||||
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"
|
||||
puts <<~USAGE
|
||||
usage: os-backlog <command> [options]
|
||||
|
||||
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
|
||||
archive NAME --yes
|
||||
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-comment --card ID --text "..."
|
||||
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
|
||||
exit(command.nil? ? 1 : 0)
|
||||
else
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ from typing import Optional
|
|||
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).
|
||||
- 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.
|
||||
- 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.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -4,5 +4,7 @@ require_relative "backlog/board_resolver"
|
|||
require_relative "backlog/cards"
|
||||
require_relative "backlog/config"
|
||||
require_relative "backlog/inspector"
|
||||
require_relative "backlog/project_index"
|
||||
require_relative "backlog/resolver"
|
||||
require_relative "backlog/tracker"
|
||||
require_relative "backlog/triage_check"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ module Backlog
|
|||
# This class never archives a board — archiving only happens on explicit
|
||||
# human request (see BoardSpec / the resolver). It does provide the raw
|
||||
# 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
|
||||
Result = Struct.new(:board, :created, :project, :lists_created, :labels_created, keyword_init: true)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -18,6 +18,31 @@ module Backlog
|
|||
|
||||
DEV_ROOT = File.expand_path("~/dev")
|
||||
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 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)
|
||||
|
||||
# "Dev" for paths under ~/dev/*, "Clients" for paths under ~/clients/*,
|
||||
# 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
|
||||
def project = self.class.project_for(@repo_path)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
- **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.
|
||||
- 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
|
||||
|
||||
|
|
@ -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.
|
||||
3. **Ensure the board exists** (idempotent — safe to run every time):
|
||||
```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:**
|
||||
```bash
|
||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-add --board <board> --title "<short imperative title>" [--description "<context: repo, file paths, why>"]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue