diff --git a/docs/adr/0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md b/docs/adr/0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md new file mode 100644 index 0000000..9ee42c9 --- /dev/null +++ b/docs/adr/0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md @@ -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 + + diff --git a/docs/adr/README.md b/docs/adr/README.md index b975643..0623aee 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 127414f..c4186c9 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -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); diff --git a/docs/implementation-status/os-backlog.md b/docs/implementation-status/os-backlog.md index 42ebca1..c6f9399 100644 --- a/docs/implementation-status/os-backlog.md +++ b/docs/implementation-status/os-backlog.md @@ -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). diff --git a/plugins/os-backlog/bin/os-backlog b/plugins/os-backlog/bin/os-backlog index 8424b3f..2674d0d 100755 --- a/plugins/os-backlog/bin/os-backlog +++ b/plugins/os-backlog/bin/os-backlog @@ -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 (write tracker key to .cc-os/config) +# os-backlog config-write (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 [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 (write tracker key to .cc-os/config) + config-write (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 diff --git a/plugins/os-backlog/hooks/session_start.py b/plugins/os-backlog/hooks/session_start.py index 013ca73..1011d1f 100644 --- a/plugins/os-backlog/hooks/session_start.py +++ b/plugins/os-backlog/hooks/session_start.py @@ -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:` | `forgejo:/` | `github:/` | `repo:`) 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:` | `forgejo:/` | `github:/` | `repo:`) 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. """ diff --git a/plugins/os-backlog/hooks/triage_check.rb b/plugins/os-backlog/hooks/triage_check.rb new file mode 100644 index 0000000..4ce69a0 --- /dev/null +++ b/plugins/os-backlog/hooks/triage_check.rb @@ -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 diff --git a/plugins/os-backlog/lib/backlog.rb b/plugins/os-backlog/lib/backlog.rb index f598669..24a61fa 100644 --- a/plugins/os-backlog/lib/backlog.rb +++ b/plugins/os-backlog/lib/backlog.rb @@ -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" diff --git a/plugins/os-backlog/lib/backlog/board_ensurer.rb b/plugins/os-backlog/lib/backlog/board_ensurer.rb index 17a0243..83b8ca7 100644 --- a/plugins/os-backlog/lib/backlog/board_ensurer.rb +++ b/plugins/os-backlog/lib/backlog/board_ensurer.rb @@ -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) diff --git a/plugins/os-backlog/lib/backlog/project_index.rb b/plugins/os-backlog/lib/backlog/project_index.rb new file mode 100644 index 0000000..9956447 --- /dev/null +++ b/plugins/os-backlog/lib/backlog/project_index.rb @@ -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 diff --git a/plugins/os-backlog/lib/backlog/resolver.rb b/plugins/os-backlog/lib/backlog/resolver.rb index 97d13ed..9f5bd0a 100644 --- a/plugins/os-backlog/lib/backlog/resolver.rb +++ b/plugins/os-backlog/lib/backlog/resolver.rb @@ -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 diff --git a/plugins/os-backlog/lib/backlog/triage_check.rb b/plugins/os-backlog/lib/backlog/triage_check.rb new file mode 100644 index 0000000..eb3aef4 --- /dev/null +++ b/plugins/os-backlog/lib/backlog/triage_check.rb @@ -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] 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>] label arrays, + # one per card currently in the board's Backlog list + # @return [Array>] 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>] + # @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 diff --git a/plugins/os-backlog/skills/capture/SKILL.md b/plugins/os-backlog/skills/capture/SKILL.md index d1b2b96..965d20d 100644 --- a/plugins/os-backlog/skills/capture/SKILL.md +++ b/plugins/os-backlog/skills/capture/SKILL.md @@ -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 --project + ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog board-ensure ``` - 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 ` + 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 --title "" [--description ""] diff --git a/plugins/os-backlog/skills/route/references/cross-project-filing.md b/plugins/os-backlog/skills/route/references/cross-project-filing.md new file mode 100644 index 0000000..f7eb6f2 --- /dev/null +++ b/plugins/os-backlog/skills/route/references/cross-project-filing.md @@ -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 # 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:/` — `tea issues create --repo / --title "..." --description "..."` +- `github:/` — `gh issue create --repo / --title "..." --body "..."` +- `planka:` (planka-only targets) — `os-backlog card-add --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:** , session , . +``` + +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. diff --git a/plugins/os-backlog/tests/project_index_test.rb b/plugins/os-backlog/tests/project_index_test.rb new file mode 100644 index 0000000..62699c8 --- /dev/null +++ b/plugins/os-backlog/tests/project_index_test.rb @@ -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