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 |
|
| 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 -->
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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>"]
|
||||||
|
|
|
||||||
|
|
@ -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