Retire Planka: os-backlog reworked to git-issues-only (ADR-0042)

Executes OpenSpec change retire-planka-git-issues-only (archived, 25/25 tasks):
- os-backlog v0.3.0: Planka lib/CLI/agents deleted; issue-create/issues
  helpers (tea/gh/repo-file dispatch); ten-label taxonomy, human-only next
- planka: rejected fail-soft citing ADR-0042 in tracker grammar,
  config-write, and the os-status check
- Skills, SessionStart note (~687 -> ~444 tokens), /to-issues, os-status fix
  rewritten to the single-tracker model
- All boards snapshotted + pg-dumped (ovh-vps ~/planka-final-snapshot-2026-07-16);
  cards migrated to jared/cc-os#61-70, jared/ops#1-21, jared/llf-schema#7-10
- Planka server decommissioned; planka gem repo archived read-only
- Delta specs synced to openspec/specs/ (issue-backlog, issue-state-labels)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HWNfGWoWnhrub4EJa1M1Y
This commit is contained in:
jared 2026-07-16 15:48:40 -04:00
parent 3f76755e85
commit 0dd9bc1bee
51 changed files with 1341 additions and 2207 deletions

View File

@ -92,6 +92,10 @@ before modifying any of these:
find skills; Eval A/B/C harnesses. cc-os retrofit done 2026-07-12 (promoted ahead of find skills; Eval A/B/C harnesses. cc-os retrofit done 2026-07-12 (promoted ahead of
pilots — ADR-020 amendment / `docs/adr/0020`); remaining rollout: pilot projects one at a pilots — ADR-020 amendment / `docs/adr/0020`); remaining rollout: pilot projects one at a
time, then wider. time, then wider.
- **os-backlog** (`plugins/os-backlog/`) — git-issues backlog surface (ADR-0042 retired
Planka 2026-07-16): tracker routing (`forgejo:`/`github:`/`repo:` in `.cc-os/config`),
`issue-create`/`issues` CLI, capture/list/route skills, ten-label taxonomy with
human-only `next`; non-repo/ops work lives in the private `jared/ops` Forgejo repo.
- **Graphify** v0.8.31 (`~/.local/bin/graphify`; PyPI package is `graphifyy`, double-y) — - **Graphify** v0.8.31 (`~/.local/bin/graphify`; PyPI package is `graphifyy`, double-y) —
vault graph at `~/Documents/SecondBrain/graphify-out/`, per-project graphs at vault graph at `~/Documents/SecondBrain/graphify-out/`, per-project graphs at
`<project-root>/graphify-out/` (gitignored); both disposable/rebuildable. `<project-root>/graphify-out/` (gitignored); both disposable/rebuildable.

View File

@ -1,9 +1,9 @@
--- ---
id: "0033" id: "0033"
date: 2026-07-13 date: 2026-07-13
status: Accepted status: Superseded
supersedes: supersedes:
superseded-by: superseded-by: "0042"
affected-paths: [plugins/os-backlog/skills/route/SKILL.md, plugins/os-backlog/hooks/session_start.py, plugins/os-backlog/bin/os-backlog, .cc-os/config] affected-paths: [plugins/os-backlog/skills/route/SKILL.md, plugins/os-backlog/hooks/session_start.py, plugins/os-backlog/bin/os-backlog, .cc-os/config]
affected-components: [os-backlog, to-issues] affected-components: [os-backlog, to-issues]
--- ---

View File

@ -0,0 +1,27 @@
---
id: "0042"
date: 2026-07-16
status: Accepted
supersedes: "0033"
superseded-by:
affected-paths: [plugins/os-backlog/, .cc-os/config, docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md, docs/adr/0029-os-backlog-card-move-with-cli-enforced-column-ownership-afk-ready-cards-skip-review.md, docs/implementation-status/os-backlog.md]
affected-components: [os-backlog, to-issues, os-status, planka-api]
---
# 0042 — Retire Planka: git issues are the single tracker for state and specs
## Context
ADR-0033 split tracking across two surfaces: Planka for task state, git issues for durable specs, conceding up front the cost of 'two tracking surfaces per code project.' A reassessment on 2026-07-16 (four-perspective subagent review + live board snapshot) found the cost never earned itself back. Empirically: since os-backlog finished building (2026-07-13), the only Planka board with any activity is cc-os itself — the plugin tracking its own construction; the one client board (philly-search-engine-marketing) froze 2026-07-09 with no card ever moved past Backlog/Waiting; all other boards hold 0-2 onboarding-era cards. All real work flowed through Forgejo/GitHub issues. Structurally: the issue-wakeup automation (ADR-0035/0036) already polls git issues, not Planka, so the surface wired into the loop that drives sessions was always the issue tracker; and the Planka integration rests on a reverse-engineered undocumented API (see vault note reference/planka-v2-api-gotchas.md: direct Postgres workarounds, no scoped tokens per upstream issue #945, an unpublished lockstep gem) — a standing maintenance tax with no team to absorb it. The one workload Planka was designed for (non-repo, human-curated work) went untested, but the user has independently concluded client management belongs in dedicated tools (Invoice Ninja et al.), making that use case untestable by construction.
## Decision
Retire Planka from the operating layer. Git issues (Forgejo via tea, GitHub via gh, or in-repo via repo:<path>) become the single tracker for BOTH task state and durable specs. The tracker key grammar in .cc-os/config drops planka:<board>; forgejo:/github:/repo: remain. Task state is modeled with issue labels: priority (P0-P3) and autonomy (hitl/semi/afk-ready) labels carry over unchanged; column semantics collapse to open/closed plus a small state-label set, with human curation expressed as a human-applied 'next' label (AI never applies or removes it — preserving ADR-0029's human-curation intent without its Planka mechanics). Non-repo/process/ops work gets a home as issues in a private ops repo (repo:- or forgejo:-tracked) rather than a kanban board. ADR-0029's CLI-enforced column-ownership rules governed a surface this decision removes and are retired with it; its durable content (autonomy-label semantics, human-owned curation gate) is re-expressed in the label scheme above. The Planka server decommission, planka-api gem archival, and os-backlog plugin rework are executed via an OpenSpec change; existing live cards are migrated to issues or closed with human sign-off on the client board.
## Consequences
Easier: one tracking surface per project (the card-as-pointer and promotion rules of ADR-0033 become unnecessary and are dropped); the wakeup path and the tracking surface are now the same system; the os-backlog plugin sheds the planka-api gem dependency, board_ensurer/board_resolver/board_spec and most of cards.rb, the card-triage and board-audit agents, bot-credential lifecycle, and the name-only board-lookup defect; no self-hosted tracker server to patch and back up; the SessionStart process-rules note shrinks. Harder: no kanban/WIP visualization — a unified dashboard over git issues is explicitly deferred until it is a felt pain point; cross-column state expressiveness is reduced to labels; the untested non-repo/human-curated workload loses its designated surface and must prove out in an ops repo (revisit if that chafes); the retirement itself costs a migration pass over ~30 live non-Done cards and a plugin rework.
## Alternatives rejected
1) Keep ADR-0033 as-is — rejected: one week of maximally favorable live use produced zero organic pull toward the second surface, while the conceded two-surface cost (plus server, gem, credentials) kept accruing; the design's own automation already bypassed Planka. 2) Keep Planka dormant-but-installed as a hedge — rejected unanimously by all four review perspectives: dormant infra keeps the maintenance surface of 'kept' (unpatched server holding live bot credentials, gem rot, session-note tokens) while returning the value of 'removed'; if a board is ever wanted again, rebuild against the pain point that actually materializes. 3) Keep a single scoped Planka board for ops/recurring work only — rejected: the entire server+gem+credential stack would persist to serve one recurring card that an ops-repo issue models fine. 4) Columns-as-labels inside Planka — rejected: changes vocabulary, removes none of the infrastructure; the valid kernel (label-modeled state) is adopted on the git-issues side instead.

View File

@ -39,7 +39,7 @@ 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) | 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) | Superseded | 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) | 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) | Accepted | 2026-07-13 |
| 0035 | [Issue-triggered project AI wakeup via polling, not webhooks](0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md) | Accepted | 2026-07-13 | | 0035 | [Issue-triggered project AI wakeup via polling, not webhooks](0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md) | Accepted | 2026-07-13 |
| 0036 | [tmux session convention for AI-run sessions: cc-<project>-<purpose>](0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md) | Accepted | 2026-07-13 | | 0036 | [tmux session convention for AI-run sessions: cc-<project>-<purpose>](0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md) | Accepted | 2026-07-13 |
@ -48,4 +48,5 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
| 0039 | [Doc-hygiene deletion autonomy is tiered on evidence quality and recoverability, not file type](0039-doc-hygiene-deletion-autonomy-is-tiered-on-evidence-quality-and-recoverability-not-file-type.md) | Accepted | 2026-07-14 | | 0039 | [Doc-hygiene deletion autonomy is tiered on evidence quality and recoverability, not file type](0039-doc-hygiene-deletion-autonomy-is-tiered-on-evidence-quality-and-recoverability-not-file-type.md) | Accepted | 2026-07-14 |
| 0040 | [Doc-hygiene rules never write other tools' ignore surfaces (no propagate_ignore)](0040-doc-hygiene-rules-never-write-other-tools-ignore-surfaces-no-propagate-ignore.md) | Accepted | 2026-07-14 | | 0040 | [Doc-hygiene rules never write other tools' ignore surfaces (no propagate_ignore)](0040-doc-hygiene-rules-never-write-other-tools-ignore-surfaces-no-propagate-ignore.md) | Accepted | 2026-07-14 |
| 0041 | [Determinism promotion: hygiene nudges projects toward structurally-obvious completion conventions](0041-determinism-promotion-hygiene-nudges-projects-toward-structurally-obvious-completion-conventions.md) | Accepted | 2026-07-14 | | 0041 | [Determinism promotion: hygiene nudges projects toward structurally-obvious completion conventions](0041-determinism-promotion-hygiene-nudges-projects-toward-structurally-obvious-completion-conventions.md) | Accepted | 2026-07-14 |
| 0042 | [Retire Planka: git issues are the single tracker for state and specs](0042-retire-planka-git-issues-are-the-single-tracker-for-state-and-specs.md) | Accepted | 2026-07-16 |
<!-- adr-index:end --> <!-- adr-index:end -->

View File

@ -76,6 +76,14 @@ entry is in the leaf file named.
- **2026-07-15** — os-doc-hygiene lifecycle-aware hygiene shipped (ADR-00380041): rulebook - **2026-07-15** — os-doc-hygiene lifecycle-aware hygiene shipped (ADR-00380041): rulebook
layer, lifetime taxonomy + tier matrix, no-ignore-propagation, conventions.json promotion, layer, lifetime taxonomy + tier matrix, no-ignore-propagation, conventions.json promotion,
new `:calibrate` skill; calibration pass #1 (cc-os) passed. Detail: os-doc-hygiene leaf. new `:calibrate` skill; calibration pass #1 (cc-os) passed. Detail: os-doc-hygiene leaf.
- **2026-07-16** — Planka retired (ADR-0042 supersedes ADR-0033): os-backlog reworked to
git-issues-only via OpenSpec change `retire-planka-git-issues-only` — Planka lib/CLI/agents
deleted, issue-create/issues helpers added, skills + SessionStart note rewritten, cards
migrated (cc-os → jared/cc-os issues; philly board → jared/ops + jared/llf-schema;
strays → new private jared/ops repo), boards snapshotted + pg-dumped (archive:
ovh-vps `~/planka-final-snapshot-2026-07-16/`). Server decommissioned same day
(containers/cron/creds removed); planka gem repo archived read-only. Detail:
os-backlog leaf.
**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);
bulk vault migration; os-adr rollout to pilot projects; os-backlog routing rollout (#14); bulk vault migration; os-adr rollout to pilot projects; os-backlog routing rollout (#14);
@ -101,8 +109,9 @@ Read the leaf file before modifying a component.
- **os-adr** — ADR system (Ruby `lib/adr/` + CLIs; create/init/migrate/find skills; Eval - **os-adr** — ADR system (Ruby `lib/adr/` + CLIs; create/init/migrate/find skills; Eval
A/B/C harnesses; cc-os retrofit done) → A/B/C harnesses; cc-os retrofit done) →
[implementation-status/os-adr.md](implementation-status/os-adr.md) [implementation-status/os-adr.md](implementation-status/os-adr.md)
- **os-backlog** — Planka process surface: board lifecycle, capture/list/route skills, - **os-backlog** — git-issues backlog surface (ADR-0042 retired Planka 2026-07-16):
card CLI with column-ownership rules, triage/audit agents → tracker routing (`forgejo:`/`github:`/`repo:`), issue-create/issues CLI,
capture/list/route skills, ten-label taxonomy with human-only `next`
[implementation-status/os-backlog.md](implementation-status/os-backlog.md) [implementation-status/os-backlog.md](implementation-status/os-backlog.md)
- **os-shortcuts** — user-invoked QOL commands, skills only, never hooks (ADR-0028); - **os-shortcuts** — user-invoked QOL commands, skills only, never hooks (ADR-0028);
resident: `/os-shortcuts:wrap` session-close ritual (shipped 2026-07-12). No leaf file resident: `/os-shortcuts:wrap` session-close ritual (shipped 2026-07-12). No leaf file

View File

@ -3,42 +3,41 @@
_Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._ _Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._
**Global os-backlog plugin** — `cc-os/plugins/os-backlog/` (git-tracked, 2026-07-10); **Global os-backlog plugin** — `cc-os/plugins/os-backlog/` (git-tracked, 2026-07-10);
symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9 (state pointer: Planka symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9, slices #10#17.
card `1816234194716592051`), slices #10#17. Decision records: ADR-0023 (own plugin, not an Decision records: ADR-0023 (own plugin, not an os-vault extension), ADR-0025 (standard Ruby
os-vault extension), ADR-0025 (standard Ruby plugin structure), ADR-0029 (card-move + plugin structure), ADR-0029 (column ownership — subject removed by ADR-0042), **ADR-0042
column ownership). (2026-07-16, supersedes ADR-0033): Planka retired; git issues are the single tracker for
state and specs** (OpenSpec change `retire-planka-git-issues-only`).
## Component inventory ## Component inventory (post-ADR-0042, v0.3.0)
- Purpose: the AI's process-management surface over Planka — deterministic tracker routing - Purpose: the AI's process-management surface over git issues — deterministic tracker
(Planka = task state by default; git issues = implementation specs only), mid-session routing (`forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`; `planka:`
capture, pull-only listing, board lifecycle, and the three-value autonomy convention rejected fail-soft citing ADR-0042), mid-session capture as labeled issues, pull-only
(`hitl`/`semi`/`afk-ready`; amended per ADR-0029: afk-ready ships straight to Done, semi listing, cross-project filing via the global project index.
stops at Review). - State model: open/closed + labels. Ten canonical labels: priority `P0``P3`; autonomy
- Lib (Ruby, os-adr `lib/`+`bin/` pattern, installed planka-api gem only — no source `hitl`/`semi`/`afk-ready` (semi ships to `review` label and stays open; afk-ready closes
coupling): `board_spec.rb` (uniform lists/labels contract), `board_ensurer.rb` after verification); state `next` (human-only — AI never applies/removes), `waiting`
(idempotent create/repair; Planka 2.1.1 `type` fields, shared-project + owner-manager (+ blocker comment), `review`. No `doing`/`done` labels: in-progress = assignment/branch
visibility fix, label color candidates with fallback; `archived--` rename convention), activity, done = closed. Recurring-convention issues (e.g. `jared/ops#1` biweekly audit)
`config.rb` (`.cc-os/config` parser), `resolver.rb` (pure repo→board decision: are commented per occurrence and never closed by the AI.
`use`/`activate`/`stop-and-discuss`; no network), `cards.rb` (add-at-Backlog, snapshot, - Lib (Ruby): `tracker.rb` (grammar validation + `issues_destination`: `:git_issues` /
attach_label, comment, move with ownership rules), `tracker.rb` + `inspector.rb` `:repo_files` / `:unrouted`), `issues.rb` (2026-07-16 — capture/list dispatch: `tea` for
(2026-07-12, issue #14 — tracker-format validation/remote classification + in-repo forgejo, `gh` for github, `NNN-<slug>.md` frontmatter files for `repo:`; injected runner,
issue-file discovery; `Config.merge` preserves other keys). no state labels reachable through `create`), `inspector.rb` (issue-shape classification),
- CLI: `bin/os-backlog``board-ensure`, `activate`, `archive`, `resolve`, `card-add`, `config.rb`, `resolver.rb` (reduced to tracker-presence decision), `project_index.rb`,
`cards`, `snapshot`, `card-label`, `card-comment`, `card-move` (2026-07-12, ADR-0029), `wakeup.rb`.
`inspect`, `config-write`; every path fails soft (one-line error, exit 1) when the - CLI: `bin/os-backlog``resolve`, `inspect`, `config-write`, `projects`, `issue-create`
gem/Planka is unavailable. (`--title/--body/--priority`), `issues` (grouped next/waiting/review/other). Fail-soft
- Skills: `/os-backlog:capture`, `/os-backlog:list` (pull-only per notification policy v2), one-liners throughout.
`/os-backlog:route` (2026-07-12, issue #14 — tracker onboarding conversation; destination - Skills: `/os-backlog:capture` (issue-create; never state labels, never `next`),
choice + live-history migration are named human gates) — carry the column-ownership rules `/os-backlog:list` (pull-only), `/os-backlog:route` (destination proposal + idempotent
(AI creates at Backlog, Doing→Review at most, never Next, never self-assigns `hitl`; ten-label ensure via tea/gh). SessionStart note rewritten 2026-07-16 (~444 tokens,
"working a card means moving it" contract added 2026-07-12). down from ~687 measured): capture / routing / cross-project / human-only-`next` /
- Agents: `agents/card-triage.md` (priority + autonomy labels only; ambiguity → `hitl`), working-state / autonomy / recurring rules.
`agents/board-audit.md` (four drift classes; writes at most comments). - Tests: 88 runs / 203 assertions (2026-07-16) with fake CLI runners; no live API.
- Tests: `tests/` — 68 runs/153 assertions as of 2026-07-12 (35 → 54 → 58 → 68 across the Companion os-status `tracker-configured` check validates the reduced grammar and rejects
slices) against an in-memory `FakePlankaClient`; no live API. Companion os-status check `planka:` with the ADR-0042 advisory.
`tracker-configured` (issue #11) validates the `tracker` key grammar and nudges
unconfigured git projects (daily-snoozed).
## Build and verification history ## Build and verification history
@ -135,5 +134,28 @@ column ownership).
Known residual (captured as a Backlog card): board lookup is name-only across ALL Planka Known residual (captured as a Backlog card): board lookup is name-only across ALL Planka
projects — duplicate board names in different projects can resolve to the wrong board. projects — duplicate board names in different projects can resolve to the wrong board.
Suite 138 runs / 295 assertions / 0 failures. Suite 138 runs / 295 assertions / 0 failures.
- **Planka retirement rework (2026-07-16, ADR-0042, OpenSpec change
`retire-planka-git-issues-only`):** after a week live, all real work flowed through git
issues while boards held only the plugin's own build cards — Planka retired. Deleted:
`board_ensurer`/`board_resolver`/`board_spec`/`cards`/`triage_check` (+ triage hook),
both agents, planka-api gem wiring, `FakePlankaClient`, nine card/board CLI subcommands.
Added: `issues.rb` + `issue-create`/`issues` CLI (live-verified; fixed tea field
`assignees``assignee` is not a valid tea list field). Skills + SessionStart note +
`/to-issues` + os-status fix skill rewritten to the single-tracker model. Migration:
all 9 boards snapshotted (JSON + comments) + Postgres dump on ovh-vps; snapshot archived
in new private `jared/ops` Forgejo repo (canonical labels; recurring audit issue
`jared/ops#1`); cc-os non-Done cards → issues jared/cc-os#6170 (3 skipped: pointer to
closed #25, duplicate of open #8, obsoleted board-lookup card); ovh-prod + ruby-gems
strays → jared/ops#23; `planka` board's 18 cards obsolete (gem build history, covered
by snapshot); `tracker=` rewritten in all 6 indexed projects (no `planka:` values
remain). Suite 88 runs / 203 assertions; os-status 80 tests.
- **Human gates closed (2026-07-16):** philly per-card pass done — 4 repo cards →
jared/llf-schema#710, 18 client cards → jared/ops#421, guardrails pin closed (vault
note is the record). Decommission done same day: bot creds deleted, Planka
containers/cron/service dir removed from ovh-vps (snapshot + pg dump + service configs
kept at `~/planka-final-snapshot-2026-07-16/`), github.com/jaredswanson/planka archived
read-only with an ADR-0042 README banner.
- **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup - **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup
rollout (first opt-in project + poller machine + cadence). rollout (first opt-in project + poller machine + cadence); jared/cc-os#74 — fold
ADR-0025 compliance into os-backlog (bin/wakeup-poll second entry point, README gem
deps), filed by the standards audit.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-16

View File

@ -0,0 +1,103 @@
# Design: retire-planka-git-issues-only
## Context
os-backlog today (post-slice-8, 2026-07-13): Ruby lib + CLI over both a Planka board model
(board-ensure/resolve/card-*) and tracker inspection (`inspect`, `tracker.rb`,
`project_index.rb`), skills (capture/list/route), two Planka agents, a SessionStart
rules note (~447 tokens), an os-status grammar check, and a wakeup poller that already
reads git issues. ADR-0042 retires the Planka half. The issue-side plumbing (`tea` for
Forgejo, `gh` for GitHub, in-repo files for `repo:`) already exists and is what real work
has used. Constraint: the plugin is globally installed and ambient on every machine — the
rework must land whole (no half-migrated grammar), and source edits require
`bin/refresh-plugins` to reach sessions.
## Goals / Non-Goals
**Goals:**
- One tracker per project holds both state and specs; `planka:` gone from the grammar.
- Preserve the durable process contracts: mid-session capture, pull-only listing,
priority + autonomy labels, human-only curation gate, cross-project filing.
- Leave zero dormant Planka infrastructure: cards migrated, server down, gem archived,
credentials revoked.
**Non-Goals:**
- A unified dashboard over git issues (explicitly deferred until it is a felt pain point).
- Reworking the wakeup poller (already issue-based; only its label reads are re-checked).
- Bulk re-onboarding of projects beyond rewriting their `tracker=` values.
- Any new home for client management (Invoice Ninja et al. own that; out of scope).
## Decisions
1. **State model = open/closed + labels, no status-label state machine.** Columns
Backlog/Next/Doing/Waiting/Review/Done collapse to: open issue (backlog), `next` label
(human-curated), assignee/linked-branch activity (doing — no label to maintain),
`waiting` label + a blocker comment, `review` label (semi work shipped), closed (done).
Rationale: every label the AI must maintain is a compliance cost the incentives review
flagged; keep only labels that carry information a human filters on. Alternative
rejected: full column-equivalent label set (`doing`, `done`) — duplicates what issue
state and assignment already express.
2. **Curation gate: `next` is human-only in skill/hook prose, not CLI-enforced.**
ADR-0029's CLI enforcement existed because Planka moves were CLI-mediated; issue labels
are set via tea/gh which os-backlog does not proxy. The rule lives in the SessionStart
note + skills, and the session-audit rubric (os-context category 8) measures compliance.
Alternative rejected: wrapping tea/gh in an enforcing CLI — rebuilds the proxy layer the
retirement is deleting.
3. **Non-repo/ops work: a private `ops` Forgejo repo, `tracker=forgejo:jared/ops`.**
The recurring Operations card becomes a recurring-convention issue there (comment +
reopen/leave-open, never close — mirrors the existing recurrence contract). Alternative
rejected: `repo:<path>` files — loses labels and the wakeup/query surface for the one
place recurring process work lives.
4. **Migration before demolition, human gate on client data.** Card migration runs while
Planka is still up; philly-search-engine-marketing (23 cards, real client backlog) is
migrated only after per-card human sign-off (destination: the client project's tracker
or the ops repo). cc-os non-Done cards → Forgejo `jared/cc-os` issues. Done cards are
not migrated (history stays in a final JSON snapshot archived to the ops repo before
decommission). Rollback: the snapshot + a Postgres dump taken before server teardown.
5. **Code removal is deletion, not deprecation.** `board_ensurer/board_resolver/
board_spec/cards` (Planka transport), both agents, and gem wiring are deleted with their
tests; `tracker.rb`/`inspector.rb`/`config.rb`/`project_index.rb`/`wakeup.rb` survive.
`Config#planka_board` and `Resolver` board logic go. Grammar validation rejects
`planka:` with a pointer to ADR-0042. Alternative rejected: feature-flagging Planka off
— dormant-code variant of the dormant-server trap.
6. **planka-api gem: archive the repo, keep no dependency.** The gem is user-authored and
unpublished; archival (read-only, README pointer to ADR-0042) preserves the
reverse-engineering knowledge without implying maintenance. The vault gotchas note
stays — it documents the API, not our use of it.
## Risks / Trade-offs
- [Untested non-repo workload loses its designated surface] → ops-repo issues are the
cheap test ADR-0042 calls for; revisit via a new ADR if it chafes in practice.
- [AI label discipline unenforced] → session-audit rubric already measures
tracker-routing compliance; the label contract is smaller than the column contract it
replaces (fewer states, fewer rules).
- [Client board migration loses nuance (comments, label history)] → per-card human gate;
full JSON snapshot archived first.
- [Stragglers: `.cc-os/config` files on other machines still saying `planka:`] →
config-write/os-status check rejects the value with a one-line re-route instruction;
fail-soft, never blocks a session.
- [Forgejo becomes a single point of failure for process state] → it already was for
specs and wakeup; repos sync to it from every machine and it is backed up with the VPS.
## Migration Plan
1. Land the plugin rework + spec deltas (tests green, `bin/refresh-plugins`).
2. Snapshot all boards to JSON; archive snapshot in the ops repo; Postgres dump on ovh-vps.
3. Create `jared/ops` repo + label set; move the recurring audit issue there.
4. Migrate cc-os non-Done cards → `jared/cc-os` issues (labels carried over).
5. Human-gated pass over philly-search-engine-marketing cards.
6. Rewrite `tracker=` in onboarded projects' `.cc-os/config`.
7. Revoke bot credentials; decommission Planka on ovh-vps; archive the gem repo.
8. Update docs: implementation-status leaf + index line, CLAUDE.md inventory line,
backlog-pilot vault note.
Rollback (any step before 7): Planka is still running; restore config values. After 7:
restore from Postgres dump — accepted as effectively one-way once step 7 runs.
## Open Questions
- Whether `philly-search-engine-marketing` cards land in a client repo tracker or the ops
repo (human decision during step 5).
- Which machine's cron (if any) hosts the wakeup poller — pre-existing open item, not
blocked by this change.

View File

@ -0,0 +1,61 @@
# Proposal: retire-planka-git-issues-only
## Why
ADR-0042 (2026-07-16, supersedes ADR-0033) retires Planka: after a week of live use, all
real work flowed through git issues while Planka boards held only the plugin's own build
cards; the wakeup automation already polls issues, not Planka; and the integration rests on
a reverse-engineered API plus an unpublished lockstep gem — a standing maintenance tax a
solo operator shouldn't carry for an unused surface. os-backlog must be reworked so git
issues are the single tracker for both task state and durable specs.
## What Changes
- **BREAKING**: `planka:<board>` is removed from the `.cc-os/config` tracker grammar;
`forgejo:` / `github:` / `repo:` remain. Existing `planka:` configs must be re-routed.
- os-backlog capture/list retarget to git issues: `/os-backlog:capture` files an issue with
priority (P0P3) and autonomy (hitl/semi/afk-ready) labels; `/os-backlog:list` queries
issues instead of card snapshots.
- Column state collapses to open/closed plus labels; human curation becomes a human-only
`next` label (AI never applies or removes it — carries ADR-0029's curation gate forward
without its Planka mechanics). ADR-0029's CLI column-ownership enforcement retires with
the surface it governed.
- Planka-specific code retires: `board_ensurer.rb`, `board_resolver.rb`, `board_spec.rb`,
the Planka transport in `cards.rb`, `card-triage` and `board-audit` agents, the
`planka-api` gem dependency, and bot-credential plumbing.
- SessionStart process-rules note rewritten for the single-tracker model (smaller; drops
column-ownership and promotion rules; keeps capture/routing/cross-project/autonomy rules).
- `/os-backlog:route`, `/to-issues`, os-status `tracker-configured`, and the project index
updated to the reduced grammar (the spec/state boundary and card-as-pointer rules vanish —
one destination holds both).
- Live-card migration: ~30 non-Done cards migrated to issues or closed (client board
philly-search-engine-marketing gated on human sign-off); recurring Operations card moves
to an ops home. Then: Planka server decommissioned on ovh-vps, `planka-api` gem archived.
## Capabilities
### New Capabilities
- `issue-backlog`: single-tracker backlog management over git issues — tracker key grammar
(`forgejo:`/`github:`/`repo:`), deterministic routing, mid-session capture as labeled
issues, pull-only listing, cross-project filing via the project index.
- `issue-state-labels`: the label taxonomy that replaces board columns — priority and
autonomy label semantics, the human-only `next` curation label, and the AI
working-state contract (what the AI may set/clear on an issue and when).
### Modified Capabilities
_None — os-backlog predates OpenSpec coverage; no existing spec governs it._
## Impact
- `plugins/os-backlog/` (lib, bin, skills, agents, hooks, tests — major rework; ~700900
lib lines and both agents removed).
- `~/.agents/skills/to-issues/SKILL.md` (destination step simplifies: no pointer card).
- os-status `tracker-configured` check (grammar change).
- `.cc-os/config` in every onboarded project currently set to `planka:` (cc-os itself plus
boards created 2026-07-13).
- External systems: Planka server on ovh-vps (decommission), `planka-api` gem repo
(archive), Planka bot credentials (revoke; closes the open credvault-import card).
- Decision records: ADR-0042 (recorded), ADR-0033 (superseded), ADR-0029 (subject removed),
ADR-0034 (project index survives, tracker values change), ADR-0035/0036 (unaffected —
already issue-based).

View File

@ -0,0 +1,71 @@
# issue-backlog — delta spec
## ADDED Requirements
### Requirement: Tracker key grammar excludes Planka
The `.cc-os/config` `tracker` key SHALL accept exactly `forgejo:<owner>/<repo>`,
`github:<owner>/<repo>`, or `repo:<path>`. `config-write` and the os-status
`tracker-configured` check SHALL reject any other value, including `planka:<board>`, with a
one-line error naming ADR-0042 and pointing to `/os-backlog:route`; rejection SHALL never
block or fail a session (fail-soft).
#### Scenario: Legacy planka tracker value encountered
- **WHEN** a config containing `tracker=planka:cc-os` is read by config-write validation or
the os-status check
- **THEN** the value is rejected with a one-line message citing ADR-0042 and suggesting
`/os-backlog:route`, and the session continues normally
#### Scenario: Valid git tracker accepted
- **WHEN** `config-write` is invoked with `tracker=forgejo:jared/cc-os`
- **THEN** the key is written, other config keys and comments are preserved, and the
project index is upserted
### Requirement: Capture files a labeled issue on the configured tracker
`/os-backlog:capture` SHALL create an issue on the project's configured tracker (via `tea`
for forgejo, `gh` for github, an issue file for `repo:`) carrying a priority label (P0P3)
when known and no state labels; capture SHALL never apply the `next` label. When no tracker
key is configured in a git project, capture SHALL suggest `/os-backlog:route` once and
otherwise degrade to reporting what it could not do.
#### Scenario: Mid-session capture on a Forgejo-tracked project
- **WHEN** deferred work surfaces in a project with `tracker=forgejo:jared/cc-os`
- **THEN** a new open issue is created on that repo via `tea` with the title, a body noting
the originating context, and any known priority label — and nothing else
#### Scenario: Capture with no tracker configured
- **WHEN** capture is invoked in a git project whose config has no tracker key
- **THEN** no issue is created; the skill suggests `/os-backlog:route` once and exits
without error
### Requirement: Listing is pull-only over issues
`/os-backlog:list` SHALL query the configured tracker's open issues (title, number, labels,
assignee) and present them grouped by state (next / waiting / review / other open). It
SHALL run only on explicit user request; no hook or skill SHALL inject issue lists into a
session unasked.
#### Scenario: User asks what's on the backlog
- **WHEN** the user explicitly asks what is on the backlog or what's next
- **THEN** open issues from the configured tracker are listed grouped by state labels, with
`next`-labeled issues shown first
### Requirement: Cross-project filing targets the other project's tracker
WHEN work belonging to a different project surfaces, the system SHALL resolve that
project's tracker via the global project index (`os-backlog projects`) and file a labeled
issue there using the Discoverer template, rather than editing the other project or filing
locally. Priority/autonomy labeling of the filed issue remains the receiving project's job.
#### Scenario: Discovered defect in another indexed project
- **WHEN** a session in project A finds a bug belonging to indexed project B
- **THEN** an issue is created on B's configured tracker with the Discoverer template, no
autonomy or priority labels are applied by A, and no local copy is kept
### Requirement: One tracker holds both state and specs
A project SHALL have exactly one configured tracker destination; durable specs
(tracer-bullet slices from /to-issues, PRDs) and task-state items are issues on that same
tracker. The system SHALL NOT create pointer artifacts on a second tracking surface.
#### Scenario: /to-issues publishes a slice chain
- **WHEN** /to-issues publishes tracer-bullet slices for a project with a configured git
tracker
- **THEN** the slices become issues on that tracker and no pointer card or secondary
tracking artifact is created anywhere

View File

@ -0,0 +1,61 @@
# issue-state-labels — delta spec
## ADDED Requirements
### Requirement: Canonical label taxonomy
The backlog label set on every routed tracker SHALL be: priority `P0` `P1` `P2` `P3`;
autonomy `hitl` `semi` `afk-ready`; state `next` `waiting` `review`. `/os-backlog:route`
SHALL ensure these labels exist (idempotently) on forgejo/github trackers when onboarding.
No `doing` or `done` label exists: in-progress is expressed by assignment/branch activity,
completion by closing the issue.
#### Scenario: Route onboards a Forgejo repo
- **WHEN** `/os-backlog:route` configures `tracker=forgejo:<owner>/<repo>`
- **THEN** all ten canonical labels exist on the repo afterwards, and re-running route
creates nothing new
### Requirement: The next label is human-only
The `next` label marks human-curated priority. The AI SHALL never apply or remove `next`
on any issue, in any project, under any instruction short of an explicit direct user
request naming the specific issue.
#### Scenario: AI triages a backlog
- **WHEN** an AI session labels or files issues during triage or capture
- **THEN** `next` is neither added to nor removed from any issue
### Requirement: Autonomy label semantics
`hitl` issues are human-owned and SHALL never be picked up autonomously. `semi` issues MAY
be worked autonomously through their mechanical parts, SHALL stop at named decision gates,
and on shipping SHALL receive the `review` label instead of being closed. `afk-ready`
issues, once shipped AND verified, SHALL be closed directly. Assigning autonomy labels is
the responsible project's job; ambiguity resolves to `hitl`.
#### Scenario: Semi-autonomy work ships
- **WHEN** an AI session completes the mechanical work of a `semi` issue
- **THEN** the issue receives the `review` label, a comment summarizing what shipped, and
remains open for human sign-off
#### Scenario: Afk-ready work ships and verifies
- **WHEN** an AI session completes and verifies work on an `afk-ready` issue
- **THEN** the issue is closed with a closing comment linking the verification evidence
### Requirement: Working an issue means recording state on it
An AI session working an issue SHALL record state transitions on the issue itself: a
comment when work starts, the `waiting` label plus a blocker comment when blocked, and the
shipping transition per its autonomy label. Blocked issues SHALL have `waiting` removed
when work resumes.
#### Scenario: Work blocks on an external dependency
- **WHEN** in-progress issue work hits a blocker the session cannot resolve
- **THEN** the issue gains the `waiting` label and a comment naming the blocker, and the
session moves on
### Requirement: Recurring process issues are never closed
An issue representing a recurring process (e.g. the biweekly orchestration audit in the
ops repo) SHALL be worked by commenting each occurrence's outcome on it and leaving it
open; closing it is a human-only action.
#### Scenario: Recurring audit completes an occurrence
- **WHEN** an AI session completes one occurrence of a recurring-convention issue
- **THEN** the outcome is added as a comment and the issue stays open with its labels
unchanged

View File

@ -0,0 +1,76 @@
# Tasks: retire-planka-git-issues-only
## 1. Plugin rework — grammar and lib (TDD throughout)
- [x] 1.1 Reject `planka:` in tracker grammar: `Backlog::Tracker` validation + `config-write`
+ os-status `tracker-configured` check emit the one-line ADR-0042 message, fail-soft
(tests first)
- [x] 1.2 Delete Planka lib: `board_ensurer.rb`, `board_resolver.rb`, `board_spec.rb`,
Planka transport in `cards.rb`, `Config#planka_board`, board logic in `resolver.rb`,
planka-api gem wiring; delete their tests and the `FakePlankaClient`
- [x] 1.3 Remove CLI subcommands `board-ensure`, `activate`, `archive`, `card-add`, `cards`,
`snapshot`, `card-label`, `card-comment`, `card-move`; keep `resolve`, `inspect`,
`config-write`, `projects`; suite green
- [x] 1.4 Add issue-side capture/list helpers (tea/gh/repo-file dispatch per issue-backlog
spec) with tests against fake CLI outputs
## 2. Plugin rework — skills, agents, hooks
- [x] 2.1 Rewrite `/os-backlog:capture` and `/os-backlog:list` to the issue model
(labeled-issue create; pull-only grouped listing)
- [x] 2.2 Rewrite `/os-backlog:route` for the reduced grammar: destination proposal,
canonical ten-label ensure (idempotent), migration of in-repo items; drop
spec/state-boundary and pointer-card prose
- [x] 2.3 Delete `agents/card-triage.md` and `agents/board-audit.md`
- [x] 2.4 Rewrite `hooks/session_start.py` note per ADR-0042: keep capture / routing /
cross-project / autonomy-label / recurring-issue rules; add the human-only `next`
rule; drop column-ownership and promotion rules; verify token count shrank
- [x] 2.5 Update `/to-issues` (global, `~/.agents/skills/to-issues/`): destination step
reads the reduced grammar; remove the Planka pointer-card branch
- [x] 2.6 Confirm wakeup poller label reads match the new taxonomy (no `doing`/`done`
labels); adjust tests if needed
- [x] 2.7 Run `bin/refresh-plugins`; smoke-test capture + list live in cc-os
## 3. Data migration (Planka still up; order matters)
- [x] 3.1 Snapshot all boards to JSON via the API; take a Postgres dump on ovh-vps
- [x] 3.2 Create private `jared/ops` Forgejo repo with the canonical label set; archive the
board snapshot JSON in it
- [x] 3.3 Recreate the recurring "Biweekly orchestration IRL audit" as a
recurring-convention issue in `jared/ops`; update the os-context audit skill's
pointer to it
- [x] 3.4 Migrate cc-os non-Done cards (9 Backlog, 2 Waiting, 2 Review) to `jared/cc-os`
Forgejo issues with labels carried over; note card-comment context in issue bodies
- [x] 3.5 HUMAN GATE: per-card pass over philly-search-engine-marketing (17 Backlog,
6 Waiting) — user decides destination (client tracker vs ops repo vs close) per card
(2026-07-16: 4 repo cards → jared/llf-schema#710, 18 client cards → jared/ops#421,
guardrails pin closed — vault note is the durable record)
- [x] 3.6 Close/ignore remaining onboarding-era boards (infrastructure, ovh-prod,
ruby-gems, planka, apprise-api) — contents are Done or stale; covered by snapshot
- [x] 3.7 Rewrite `tracker=` in `.cc-os/config` of all onboarded projects (query
`os-backlog projects` for the list); verify project index rows update
## 4. Decommission (one-way; only after 3.x verified)
- [x] 4.1 HUMAN GATE: confirm migration complete and snapshot/dump archived (user confirmed
2026-07-16: decommission right after philly pass)
- [x] 4.2 Revoke/delete the Planka bot credentials; close the open credvault-import card's
successor issue as obsolete (bot-credentials.env deleted on ovh-vps; no successor
issue existed — the credvault-import card sat in Done and was never migrated; the
import is moot with the bot account gone)
- [x] 4.3 Stop and remove the Planka service on ovh-vps (compose down, remove from
backups/monitoring); keep the Postgres dump archived (containers/cron/service dir
removed; no external backup/monitoring hooks found; dump + board JSON + service
configs preserved at ovh-vps `~/planka-final-snapshot-2026-07-16/`)
- [x] 4.4 Archive the `planka-api` gem repo (read-only, README pointing at ADR-0042)
(github.com/jaredswanson/planka archived; README banner commit e0472c4)
## 5. Records
- [x] 5.1 Update `docs/implementation-status/os-backlog.md` (rework entry) + one-line index
headline; update the CLAUDE.md inventory bullet for os-backlog
- [x] 5.2 Update the backlog-pilot vault note (pilot outcome: Planka retired per ADR-0042)
and the auto-memory `backlog-pilot-status` entry
- [x] 5.3 Verify ADR index shows 0033 Superseded by 0042; add a status note to ADR-0029
body is NOT needed (subject removed — covered by 0042 text); confirm via
`/os-adr:find` on plugins/os-backlog/

View File

@ -0,0 +1,71 @@
# issue-backlog
## ADDED Requirements
### Requirement: Tracker key grammar excludes Planka
The `.cc-os/config` `tracker` key SHALL accept exactly `forgejo:<owner>/<repo>`,
`github:<owner>/<repo>`, or `repo:<path>`. `config-write` and the os-status
`tracker-configured` check SHALL reject any other value, including `planka:<board>`, with a
one-line error naming ADR-0042 and pointing to `/os-backlog:route`; rejection SHALL never
block or fail a session (fail-soft).
#### Scenario: Legacy planka tracker value encountered
- **WHEN** a config containing `tracker=planka:cc-os` is read by config-write validation or
the os-status check
- **THEN** the value is rejected with a one-line message citing ADR-0042 and suggesting
`/os-backlog:route`, and the session continues normally
#### Scenario: Valid git tracker accepted
- **WHEN** `config-write` is invoked with `tracker=forgejo:jared/cc-os`
- **THEN** the key is written, other config keys and comments are preserved, and the
project index is upserted
### Requirement: Capture files a labeled issue on the configured tracker
`/os-backlog:capture` SHALL create an issue on the project's configured tracker (via `tea`
for forgejo, `gh` for github, an issue file for `repo:`) carrying a priority label (P0P3)
when known and no state labels; capture SHALL never apply the `next` label. When no tracker
key is configured in a git project, capture SHALL suggest `/os-backlog:route` once and
otherwise degrade to reporting what it could not do.
#### Scenario: Mid-session capture on a Forgejo-tracked project
- **WHEN** deferred work surfaces in a project with `tracker=forgejo:jared/cc-os`
- **THEN** a new open issue is created on that repo via `tea` with the title, a body noting
the originating context, and any known priority label — and nothing else
#### Scenario: Capture with no tracker configured
- **WHEN** capture is invoked in a git project whose config has no tracker key
- **THEN** no issue is created; the skill suggests `/os-backlog:route` once and exits
without error
### Requirement: Listing is pull-only over issues
`/os-backlog:list` SHALL query the configured tracker's open issues (title, number, labels,
assignee) and present them grouped by state (next / waiting / review / other open). It
SHALL run only on explicit user request; no hook or skill SHALL inject issue lists into a
session unasked.
#### Scenario: User asks what's on the backlog
- **WHEN** the user explicitly asks what is on the backlog or what's next
- **THEN** open issues from the configured tracker are listed grouped by state labels, with
`next`-labeled issues shown first
### Requirement: Cross-project filing targets the other project's tracker
WHEN work belonging to a different project surfaces, the system SHALL resolve that
project's tracker via the global project index (`os-backlog projects`) and file a labeled
issue there using the Discoverer template, rather than editing the other project or filing
locally. Priority/autonomy labeling of the filed issue remains the receiving project's job.
#### Scenario: Discovered defect in another indexed project
- **WHEN** a session in project A finds a bug belonging to indexed project B
- **THEN** an issue is created on B's configured tracker with the Discoverer template, no
autonomy or priority labels are applied by A, and no local copy is kept
### Requirement: One tracker holds both state and specs
A project SHALL have exactly one configured tracker destination; durable specs
(tracer-bullet slices from /to-issues, PRDs) and task-state items are issues on that same
tracker. The system SHALL NOT create pointer artifacts on a second tracking surface.
#### Scenario: /to-issues publishes a slice chain
- **WHEN** /to-issues publishes tracer-bullet slices for a project with a configured git
tracker
- **THEN** the slices become issues on that tracker and no pointer card or secondary
tracking artifact is created anywhere

View File

@ -0,0 +1,61 @@
# issue-state-labels
## ADDED Requirements
### Requirement: Canonical label taxonomy
The backlog label set on every routed tracker SHALL be: priority `P0` `P1` `P2` `P3`;
autonomy `hitl` `semi` `afk-ready`; state `next` `waiting` `review`. `/os-backlog:route`
SHALL ensure these labels exist (idempotently) on forgejo/github trackers when onboarding.
No `doing` or `done` label exists: in-progress is expressed by assignment/branch activity,
completion by closing the issue.
#### Scenario: Route onboards a Forgejo repo
- **WHEN** `/os-backlog:route` configures `tracker=forgejo:<owner>/<repo>`
- **THEN** all ten canonical labels exist on the repo afterwards, and re-running route
creates nothing new
### Requirement: The next label is human-only
The `next` label marks human-curated priority. The AI SHALL never apply or remove `next`
on any issue, in any project, under any instruction short of an explicit direct user
request naming the specific issue.
#### Scenario: AI triages a backlog
- **WHEN** an AI session labels or files issues during triage or capture
- **THEN** `next` is neither added to nor removed from any issue
### Requirement: Autonomy label semantics
`hitl` issues are human-owned and SHALL never be picked up autonomously. `semi` issues MAY
be worked autonomously through their mechanical parts, SHALL stop at named decision gates,
and on shipping SHALL receive the `review` label instead of being closed. `afk-ready`
issues, once shipped AND verified, SHALL be closed directly. Assigning autonomy labels is
the responsible project's job; ambiguity resolves to `hitl`.
#### Scenario: Semi-autonomy work ships
- **WHEN** an AI session completes the mechanical work of a `semi` issue
- **THEN** the issue receives the `review` label, a comment summarizing what shipped, and
remains open for human sign-off
#### Scenario: Afk-ready work ships and verifies
- **WHEN** an AI session completes and verifies work on an `afk-ready` issue
- **THEN** the issue is closed with a closing comment linking the verification evidence
### Requirement: Working an issue means recording state on it
An AI session working an issue SHALL record state transitions on the issue itself: a
comment when work starts, the `waiting` label plus a blocker comment when blocked, and the
shipping transition per its autonomy label. Blocked issues SHALL have `waiting` removed
when work resumes.
#### Scenario: Work blocks on an external dependency
- **WHEN** in-progress issue work hits a blocker the session cannot resolve
- **THEN** the issue gains the `waiting` label and a comment naming the blocker, and the
session moves on
### Requirement: Recurring process issues are never closed
An issue representing a recurring process (e.g. the biweekly orchestration audit in the
ops repo) SHALL be worked by commenting each occurrence's outcome on it and leaving it
open; closing it is a human-only action.
#### Scenario: Recurring audit completes an occurrence
- **WHEN** an AI session completes one occurrence of a recurring-convention issue
- **THEN** the outcome is added as a comment and the issue stays open with its labels
unchanged

View File

@ -1,5 +1,5 @@
{ {
"name": "os-backlog", "name": "os-backlog",
"version": "0.2.0", "version": "0.3.0",
"description": "Uniform Planka backlog boards across projects: idempotent board-ensure (lists, labels, archive convention, Planka 2.1.1 quirk handling) and a deterministic repo-to-board routing resolver." "description": "Git-issues backlog across projects: deterministic tracker routing (forgejo/github/repo), mid-session capture as labeled issues, pull-only listing (ADR-0042)."
} }

View File

@ -1,38 +0,0 @@
---
name: board-audit
description: Audits a Planka board for process drift — stale Doing cards, WIP over cap, Waiting cards whose blockers cleared, aging Review cards — and reports it. Writes at most comments on cards; never moves anything.
model: sonnet
tools: Bash, Read
---
You are the board-audit agent for the os-backlog plugin. Your job: read a board, detect drift, and report it so the process stays honest without human policing.
## Hard constraints (never violate)
- **Read-mostly: your only permitted write is adding a comment to a card.** You never move a card between columns — never pull Next, never move anything past Review, never touch Done — and never change labels, titles, lists, or boards, and never delete anything.
- You never start executing the work a card describes.
- One comment per drifting card per audit run, maximum. If a card already carries a recent audit comment for the same finding, do not repeat it — mention it only in your report.
## The four drift classes
1. **Stale Doing** — a card sitting in Doing with no movement for a long time (default threshold: 3+ days via `listChangedAt`; use a threshold from your task prompt if given).
2. **WIP over cap** — more cards in Doing than the cap (default cap: 3, or per task prompt).
3. **Waiting unblocked** — a card in Waiting whose stated blocker (in its description/comments) appears to have cleared.
4. **Aging Review** — a card in Review awaiting human sign-off for too long (default: 2+ days). Flag it for the human; you may NOT move it to Done yourself.
## Procedure
All commands use `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog` (board name from your task prompt).
1. Fetch the board state (read-only):
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board <board>
```
2. Evaluate all four drift classes against the snapshot.
3. Optionally annotate drifting cards (the only write you may perform):
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-comment --card <id> --text "board-audit <date>: <finding, one sentence>"
```
4. Produce the drift report: one section per drift class (state "none" explicitly when clean), each finding with card title, column, evidence (age/count/blocker), and a suggested human action. Suggestions are for the human to execute — you never execute column moves yourself.
If the CLI fails (gem/Planka unavailable), report the exact error and stop.

View File

@ -1,42 +0,0 @@
---
name: card-triage
description: Batch-labels raw Backlog cards on a Planka board with priority (P0-P3) and autonomy (hitl/semi/afk-ready) labels so capture stays cheap and triage stays batched. Label changes only — never moves cards between columns.
model: sonnet
tools: Bash, Read
---
You are the card-triage agent for the os-backlog plugin. Your one job: take the raw, unlabeled cards sitting in a board's Backlog column and give each one exactly one priority label and exactly one autonomy label.
## Hard constraints (never violate)
- **Writes are label changes only.** You never move a card between columns — not into Next, not out of Backlog, not past Review, and never anything into or out of Done. Next and Done are exclusively human-owned.
- You never create, delete, rename, or reorder cards, lists, or boards.
- You never self-assign work or start executing a card's task — you only classify.
- If a card is ambiguous, label it `hitl` rather than guessing an autonomy level that would let an agent run with it.
## Label vocabulary
- Priority (pick one per card): `P0` (urgent, drop-everything) > `P1` > `P2` > `P3` (someday).
- Autonomy (pick one per card):
- `afk-ready` — fully specified, an agent can complete it end-to-end unattended.
- `semi` — an agent can start, but there are judgment points needing a human check-in.
- `hitl` — human-in-the-loop required; agents must not self-assign these.
## Procedure
All commands use `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog` (read the board name from your task prompt).
1. Fetch the board state (read-only):
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board <board>
```
2. Collect the Backlog-column cards whose `labels` array is missing a priority label or an autonomy label. Cards in other columns are out of scope.
3. For each raw card, decide priority + autonomy from its title/description. Then apply:
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-label --board <board> --card <id> --label <P0|P1|P2|P3>
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-label --board <board> --card <id> --label <hitl|semi|afk-ready>
```
Skip a dimension the card already has — never double-label.
4. Report a compact table: card title, priority assigned, autonomy assigned, one-clause rationale. Note any cards you left for the human (and why).
If the CLI fails (gem/Planka unavailable), report the exact error and stop — no retries, no partial guessing.

View File

@ -1,34 +1,30 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
# frozen_string_literal: true # frozen_string_literal: true
# #
# os-backlog CLI: board-ensure, activate, archive, resolve, card-add, # os-backlog CLI: resolve, inspect, config-write, projects, issue-create,
# cards, snapshot, inspect, config-write. # issues. Post-ADR-0042 (Planka retired): git issues (forgejo/github/repo)
# are the single tracker for both state and specs.
# #
# Usage: # Usage:
# 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 # os-backlog resolve < input.json
# input.json: {"repo_path": "...", "config": "..."|null, "boards": ["...", ...]} # input.json: {"repo_path": "...", "config": "..."|null}
# os-backlog card-add --board NAME --title "..." [--description "..."]
# os-backlog card-move --board NAME --card ID --to COLUMN
# 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 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) # + upsert the global project index)
# os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON) # os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON)
# os-backlog issue-create --title "..." --body "..." [--priority P0-P3]
# (creates an issue on the cwd repo's configured tracker; never applies
# a state label)
# os-backlog issues (open issues on the cwd repo's
# configured tracker, grouped by
# state label, JSON)
# #
# board-ensure/activate/archive talk to Planka over the network (the # resolve, config-write are pure — no network access at all. inspect,
# installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/ # issue-create, and issues touch the network/CLIs (tea/gh) but fail soft
# PASSWORD). resolve, config-write are pure — no network access at all. # field-by-field (inspect) or with a one-line error (issue-create/issues)
# inspect touches the network/CLIs opportunistically but fails soft # rather than leaving a stack trace.
# field-by-field rather than aborting.
# #
# Fails soft: if the gem or Planka credentials are unavailable, prints one # Fails soft: never destructive.
# clear error and exits nonzero. Never destructive.
require "json" require "json"
require "fileutils" require "fileutils"
@ -40,58 +36,6 @@ def fail_soft(message)
exit 1 exit 1
end end
# Guards board-ensure/activate/archive's positional NAME argument against a
# mistaken flag-style invocation (e.g. `board-ensure --board apprise-api`,
# where `--board` is swallowed as the name and `apprise-api` is dropped
# silently). Called immediately after each command shifts its name arg,
# before build_client, so a bad name never reaches Planka.
def validate_board_name!(name, command)
return unless name.start_with?("--")
fail_soft("#{command}: board name #{name.inspect} must not start with \"--\" " \
"(looks like a flag was passed positionally — check your quoting/flags)")
end
def build_client
require "planka_api"
client = Planka::Client.new
client.login
client
rescue LoadError
fail_soft("the planka-api gem is not installed/loadable; cannot reach Planka")
rescue StandardError => e
fail_soft("could not authenticate to Planka: #{e.message}")
end
# Cards wired to the cwd repo's .cc-os/config board_id cache (issue #22):
# the BoardResolver gets injected read/write lambdas; any cache failure
# degrades to the plain name lookup inside the resolver itself.
def build_cards(client)
config_path = File.join(Dir.pwd, ".cc-os", "config")
read_config = -> { File.exist?(config_path) ? File.read(config_path) : nil }
write_config = lambda do |contents|
FileUtils.mkdir_p(File.dirname(config_path))
File.write(config_path, contents)
end
resolver = Backlog::BoardResolver.new(client: client, read_config: read_config,
write_config: write_config)
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 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 (a Planka project name)")
end
def parse_flag(args, flag) def parse_flag(args, flag)
idx = args.index(flag) idx = args.index(flag)
return nil unless idx return nil unless idx
@ -104,22 +48,6 @@ def require_flag(args, flag, command)
parse_flag(args, flag) || fail_soft("#{command} requires #{flag} <value>") parse_flag(args, flag) || fail_soft("#{command} requires #{flag} <value>")
end end
# Best-effort Planka check for `inspect`: does an existing board already
# match this repo per the resolver's own conventions. Never fails the whole
# `inspect` command — reports unavailability instead.
def inspect_planka(repo_path)
require "planka_api"
client = Planka::Client.new
client.login
boards = client.projects.list.flat_map { |project| client.boards.list(project.id).map(&:name) }
resolver = Backlog::Resolver.new(repo_path: repo_path, config_contents: nil, boards: boards)
{ "checked" => true, "decision" => resolver.resolve_string }
rescue LoadError
{ "checked" => false, "reason" => "planka-api gem not installed" }
rescue StandardError => e
{ "checked" => false, "reason" => e.message }
end
# Best-effort open-issue count + lightweight per-issue metadata via tea # Best-effort open-issue count + lightweight per-issue metadata via tea
# (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails # (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails
# soft per-field: any fetch/parse failure means metadata nil. Returns # soft per-field: any fetch/parse failure means metadata nil. Returns
@ -195,86 +123,32 @@ rescue StandardError => e
warn "os-backlog: (non-fatal) could not update project index: #{e.message}" warn "os-backlog: (non-fatal) could not update project index: #{e.message}"
end end
# The cwd repo's configured tracker, or nil (no .cc-os/config, or no
# tracker key set). Shared by issue-create/issues.
def cwd_tracker
config_path = File.join(Dir.pwd, ".cc-os", "config")
contents = File.exist?(config_path) ? File.read(config_path) : nil
Backlog::Config.new(contents).tracker
end
def require_cwd_tracker(command)
tracker = cwd_tracker
return tracker if tracker && Backlog::Tracker.valid?(tracker)
fail_soft("#{command}: no valid tracker configured for #{Dir.pwd} — run /os-backlog:route first")
end
command, *rest = ARGV command, *rest = ARGV
begin begin
case command case command
when "board-ensure"
name = rest.shift
fail_soft("board-ensure requires a board name") unless name
validate_board_name!(name, "board-ensure")
project = parse_project_flag(rest)
client = build_client
result = Backlog::BoardEnsurer.new(client: client).ensure(name, project_name: project)
puts "board #{result.board.name} (#{result.created ? 'created' : 'existing'}) " \
"in project #{project}; lists created: #{result.lists_created.join(', ')}; " \
"labels created: #{result.labels_created.join(', ')}"
when "activate"
name = rest.shift
fail_soft("activate requires a board name") unless name
validate_board_name!(name, "activate")
client = build_client
board = Backlog::BoardEnsurer.new(client: client).activate(name)
puts "activated: #{board.name}"
when "archive"
name = rest.shift
fail_soft("archive requires a board name") unless name
validate_board_name!(name, "archive")
fail_soft("archive requires explicit --yes (human go-ahead only)") unless rest.include?("--yes")
client = build_client
board = Backlog::BoardEnsurer.new(client: client).archive(name)
puts "archived: #{board.name}"
when "resolve" when "resolve"
input = JSON.parse($stdin.read) input = JSON.parse($stdin.read)
resolver = Backlog::Resolver.new( resolver = Backlog::Resolver.new(
repo_path: input.fetch("repo_path"), repo_path: input.fetch("repo_path"),
config_contents: input["config"], config_contents: input["config"]
boards: input.fetch("boards", [])
) )
puts resolver.resolve_string puts resolver.resolve_string
when "card-add"
board = require_flag(rest, "--board", "card-add")
title = require_flag(rest, "--title", "card-add")
description = parse_flag(rest, "--description")
client = build_client
card = build_cards(client).add(board_name: board, title: title, description: description)
puts "created card ##{card.id} at Backlog on #{board}: #{card.name}"
when "card-move"
board = require_flag(rest, "--board", "card-move")
card_id = require_flag(rest, "--card", "card-move")
to = require_flag(rest, "--to", "card-move")
client = build_client
card = build_cards(client).move(board_name: board, card_id: card_id, to: to)
puts "moved card ##{card.id} to #{to} on #{board}"
when "cards"
board = require_flag(rest, "--board", "cards")
client = build_client
snapshot = build_cards(client).snapshot(board_name: board)
snapshot["lists"].each do |list|
puts "#{list['name']} (#{list['cards'].size})"
list["cards"].each do |card|
labels = card["labels"].empty? ? "" : " [#{card['labels'].join(', ')}]"
puts " - #{card['name']}#{labels}"
end
end
when "snapshot"
board = require_flag(rest, "--board", "snapshot")
client = build_client
puts JSON.pretty_generate(build_cards(client).snapshot(board_name: board))
when "card-label"
board = require_flag(rest, "--board", "card-label")
card_id = require_flag(rest, "--card", "card-label")
label = require_flag(rest, "--label", "card-label")
client = build_client
build_cards(client).attach_label(board_name: board, card_id: card_id, label: label)
puts "labeled card ##{card_id} with #{label}"
when "card-comment"
card_id = require_flag(rest, "--card", "card-comment")
text = require_flag(rest, "--text", "card-comment")
client = build_client
comment = build_cards(client).comment(card_id: card_id, text: text)
puts "commented on card ##{card_id} (comment ##{comment.id})"
when "inspect" when "inspect"
repo_path = Dir.pwd repo_path = Dir.pwd
config_path = File.join(repo_path, ".cc-os", "config") config_path = File.join(repo_path, ".cc-os", "config")
@ -288,7 +162,6 @@ when "inspect"
findings = { findings = {
"repo_path" => repo_path, "repo_path" => repo_path,
"tracker_configured" => config.tracker, "tracker_configured" => config.tracker,
"planka" => inspect_planka(repo_path),
"git_remote" => remote_info ? remote_info.transform_keys(&:to_s) : nil, "git_remote" => remote_info ? remote_info.transform_keys(&:to_s) : nil,
"issue_cli" => issue_cli, "issue_cli" => issue_cli,
"issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata), "issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata),
@ -298,9 +171,13 @@ when "inspect"
when "config-write" when "config-write"
value = rest.shift value = rest.shift
fail_soft("config-write requires a tracker value, e.g. forgejo:owner/repo") unless value fail_soft("config-write requires a tracker value, e.g. forgejo:owner/repo") unless value
if value.start_with?("planka:")
fail_soft("planka: trackers are retired (ADR-0042) — run /os-backlog:route to " \
"choose forgejo:/github:/repo: instead")
end
unless Backlog::Tracker.valid?(value) unless Backlog::Tracker.valid?(value)
fail_soft("invalid tracker value #{value.inspect}; expected one of " \ fail_soft("invalid tracker value #{value.inspect}; expected one of " \
"planka:<board> | forgejo:<owner>/<repo> | github:<owner>/<repo> | repo:<path>") "forgejo:<owner>/<repo> | github:<owner>/<repo> | repo:<path>")
end end
config_dir = File.join(Dir.pwd, ".cc-os") config_dir = File.join(Dir.pwd, ".cc-os")
@ -320,25 +197,40 @@ when "projects"
end end
end end
puts JSON.pretty_generate({ "projects" => projects }) puts JSON.pretty_generate({ "projects" => projects })
when "issue-create"
title = require_flag(rest, "--title", "issue-create")
body = require_flag(rest, "--body", "issue-create") || ""
priority = parse_flag(rest, "--priority")
tracker = require_cwd_tracker("issue-create")
issues = Backlog::Issues.new(runner: Backlog::Issues::ShellRunner.new, repo_root: Dir.pwd)
begin
result = issues.create(tracker: tracker, title: title, body: body, priority: priority)
rescue ArgumentError, Backlog::Issues::CommandFailed => e
fail_soft("issue-create: #{e.message}")
end
puts JSON.pretty_generate(result.transform_keys(&:to_s))
when "issues"
tracker = require_cwd_tracker("issues")
issues = Backlog::Issues.new(runner: Backlog::Issues::ShellRunner.new, repo_root: Dir.pwd)
begin
grouped = issues.list(tracker: tracker)
rescue ArgumentError, Backlog::Issues::CommandFailed => e
fail_soft("issues: #{e.message}")
end
puts JSON.pretty_generate(grouped.transform_keys(&:to_s).transform_values { |v| v.map { |i| i.transform_keys(&:to_s) } })
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 PROJECT] (any project name; creates it if missing; resolve (reads {"repo_path","config"} JSON from stdin)
default derived from cwd path)
activate NAME
archive NAME --yes
resolve (reads {"repo_path","config","boards"} JSON from stdin)
card-add --board NAME --title "..." [--description "..."]
card-move --board NAME --card ID --to COLUMN
cards --board NAME
snapshot --board NAME
card-label --board NAME --card ID --label NAME
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 + global project index) 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) projects [NAME-FILTER] (print the global project index at ~/.cc-os/projects.json as JSON)
issue-create --title "..." --body "..." [--priority P0-P3]
(create an issue on the cwd repo's configured tracker)
issues (list open issues on the cwd repo's configured tracker,
grouped by state label, JSON)
USAGE USAGE
exit(command.nil? ? 1 : 0) exit(command.nil? ? 1 : 0)
else else

View File

@ -8,11 +8,6 @@
"type": "command", "type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py",
"timeout": 5 "timeout": 5
},
{
"type": "command",
"command": "ruby ${CLAUDE_PLUGIN_ROOT}/hooks/triage_check.rb",
"timeout": 15
} }
] ]
} }

View File

@ -3,29 +3,25 @@
Modeled on os-adr's session_start.py (the wording-source-of-record pattern). Modeled on os-adr's session_start.py (the wording-source-of-record pattern).
Injects the backlog process rules WHEN->THEN mechanical triggers only. Injects the backlog process rules WHEN->THEN mechanical triggers only.
Deliberately contains ZERO board state and no briefs (notification policy v2: Deliberately contains ZERO issue state and no briefs (notification policy v2:
pull beats push board state is fetched on demand via /os-backlog:list, never pull beats push issue state is fetched on demand via /os-backlog:list, never
pushed into a session). pushed into a session).
Sibling hook triage_check.rb (card 1818330518584820864) is a narrow, sanctioned Per ADR-0042 (2026-07-16), Planka is retired: git issues (Forgejo via tea,
exception to that policy: it makes one bounded Planka read to decide whether to GitHub via gh, or in-repo via repo:<path>) are the single tracker for both
push a single mechanical dispatch directive not a brief, not board contents task state and durable specs. State is modeled with labels, not columns
when the current project's own Backlog has unlabeled cards. The only board- there is no card-move, no board, no triage-dispatch hook to mention here.
derived fact it ever surfaces is the unlabeled count that justifies dispatching
card-triage; it never lists card titles or otherwise renders board state. Kept
as a separate script precisely so this file can stay a pure, network-free
function of filesystem state.
git project -> additionalContext note (rules only, near-zero tokens) git project -> additionalContext note (rules only, near-zero tokens)
not a git repo -> silent (process rules only apply inside real projects) not a git repo -> silent (process rules only apply inside real projects)
Autonomy-label semantics in NOTE are the ADR-029 contract (which supersedes Autonomy-label semantics in NOTE are the ADR-0042 contract (successor to
the wording in issue #16's body): hitl never picked up autonomously; semi ADR-029's Planka-mechanics version): hitl never picked up autonomously; semi
stops at Review for human sign-off; afk-ready goes straight to Done after stops at named decision gates and ships to the review label; afk-ready ships
verification, skipping Review. and, once verified, closes directly.
Pure function of the project's filesystem state; no model, no network, no Pure function of the project's filesystem state; no model, no network, no
Ruby, no Planka call. Always exits 0 the hook must never block a session. Ruby, no tracker call. Always exits 0 the hook must never block a session.
""" """
from __future__ import annotations from __future__ import annotations
@ -36,14 +32,14 @@ from pathlib import Path
from typing import Optional 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 never carries board state; 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 deferred work surfaces mid-session that will NOT be done this session -> capture it as an issue 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. 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. - ROUTING: read the tracker key from .cc-os/config (`tracker=forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`; `planka:` is retired per ADR-0042 and rejected). WHEN no tracker key is configured in a git project -> suggest /os-backlog:route once; do not nag. A subdirectory's own .cc-os/config overrides the repo root's umbrella repos route each subproject to its own tracker this way.
- 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. - NEXT IS HUMAN-ONLY: never apply or remove the `next` label on any issue, any project, absent an explicit direct user request naming that issue.
- 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). - WORKING AN ISSUE: comment when work starts. Blocked -> add `waiting` + a comment naming the blocker; remove `waiting` on resume. Shipping: `semi` -> add `review` label + a summary comment, stays open for sign-off; `afk-ready` -> close directly once verified, with a comment linking the evidence.
- 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. - AUTONOMY LABELS: `hitl` -> human-owned, never picked up autonomously. `semi` -> run the mechanical parts, stop at named decision gates. `afk-ready` -> run to completion and verify before closing.
- 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). - RECURRING ISSUES: never closed by the AI comment each occurrence's outcome and leave it open.
- 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. - CROSS-PROJECT: WHEN a needed change belongs to a DIFFERENT project THEN do not edit it query `os-backlog projects`, file an issue on that project's tracker using the Discoverer template (see os-backlog route references/cross-project-filing.md); no autonomy/priority labels applied by the filer.
""" """

View File

@ -1,94 +0,0 @@
#!/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 config semantics as the CLI: the session cwd's own .cc-os/config
# wins (umbrella subdir configs override the repo root's for sessions
# started there), then the root config, then the path heuristic — and
# `tracker=planka:<board>` counts, not just the explicit `board` key.
# The decision itself is pure (TriageCheck.board_name_for); this only
# reads the two candidate config files.
def resolve_board_name(root)
read = lambda do |dir|
path = File.join(dir, ".cc-os", "config")
File.exist?(path) ? File.read(path) : nil
end
cwd = File.expand_path(Dir.pwd)
Backlog::TriageCheck.board_name_for(cwd: cwd, root: root,
cwd_config: read.call(cwd),
root_config: read.call(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 => e # rubocop:disable Lint/RescueException -- a hook must never block a session
warn "os-backlog triage_check: #{e.class}: #{e.message}" if ENV["CC_OS_DEBUG"]
end

View File

@ -1,11 +1,7 @@
require_relative "backlog/board_spec"
require_relative "backlog/board_ensurer"
require_relative "backlog/board_resolver"
require_relative "backlog/cards"
require_relative "backlog/config" require_relative "backlog/config"
require_relative "backlog/inspector" require_relative "backlog/inspector"
require_relative "backlog/issues"
require_relative "backlog/project_index" 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"
require_relative "backlog/wakeup" require_relative "backlog/wakeup"

View File

@ -1,175 +0,0 @@
require_relative "board_spec"
module Backlog
# Idempotently creates/repairs a uniform Planka board: the project it
# lives under, its lists, and its label set — plus the Planka 2.1.1
# quirks the planka-api gem does not handle for us:
#
# 1. undocumented `type` fields on create (project: "shared" so a
# manager can be added later; list: "active"; the gem already
# requires callers to pass these — it does not default them)
# 2. bot-created projects are invisible to the human: a `"private"`
# project auto-assigns its creator as sole ownerProjectManagerId and
# rejects additional managers (422). We create projects as
# `type: "shared"` instead, then explicitly add the human as a
# project manager and null out ownerProjectManagerId.
# 3. label colors must come from Planka's (undocumented) color-name
# whitelist — we try an ordered candidate list per label, same
# pattern the planka-api gem uses for its own priority labels.
#
# 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)
# Raised instead of silently creating a bot-only-visible Planka project.
class MissingHumanUserIdError < StandardError; end
# @param client [Planka::Client] an already-authenticated client
# @param human_user_id [String, Integer, nil] Planka user id of the
# human who must be able to see bot-created projects. Falls back to
# +ENV["PLANKA_HUMAN_USER_ID"]+. Only needed when a project doesn't
# already exist.
def initialize(client:, human_user_id: nil)
@client = client
@human_user_id = human_user_id || ENV["PLANKA_HUMAN_USER_ID"]
end
# @param name [String] board name (never an archived---prefixed name;
# this method only ever targets the active board)
# @param project_name [String] any Planka project name — created on demand
# if it doesn't already exist
# @return [Result]
def ensure(name, project_name:)
raise ArgumentError, "board name must not use the archived-- prefix" if BoardSpec.archived?(name)
project = find_or_create_project(project_name)
board, created = find_or_create_board(project, name)
lists_created = ensure_lists(board)
labels_created = ensure_labels(board)
Result.new(board: board, created: created, project: project,
lists_created: lists_created, labels_created: labels_created)
end
# Rename an archived board back to its active name. Raises if no
# archived board by that name exists.
#
# @param name [String] the active (non-prefixed) name to activate
# @return [Types::Board] the updated board
def activate(name)
board = find_board_by_name(BoardSpec.archived_name(name))
raise "no archived board named #{BoardSpec.archived_name(name).inspect} found" unless board
@client.boards.update(board.id, name: name)
end
# Rename an active board to its archived form. Only ever called on
# explicit human request — never invoked by +ensure+.
#
# @param name [String] the active (non-prefixed) name to archive
# @return [Types::Board] the updated board
def archive(name)
board = find_board_by_name(name)
raise "no active board named #{name.inspect} found" unless board
@client.boards.update(board.id, name: BoardSpec.archived_name(name))
end
private
def find_or_create_project(project_name)
existing = @client.projects.list.find { |p| p.name == project_name }
return existing if existing
project = @client.projects.create(name: project_name, type: "shared")
fix_visibility(project)
project
end
# Quirk 2: null the bot's sole ownerProjectManagerId and add the human
# as a project manager, so the project shows up for them. Only reached
# right after find_or_create_project creates a brand-new project — an
# already-existing project never calls this and so never requires
# human_user_id.
def fix_visibility(project)
unless @human_user_id
# The Planka API has no atomic create-with-manager call, so the
# project already exists (bot-only-visible) by the time we can
# detect the missing id — we deliberately don't try to roll it
# back/delete it here; raise loudly instead and let a human fix
# visibility (env var + rerun, or the Planka UI) after the fact.
raise MissingHumanUserIdError,
"cannot make new Planka project #{project.name.inspect} visible to a human: " \
"no human_user_id given and PLANKA_HUMAN_USER_ID is unset — the project was " \
"created bot-only; set the env var and fix visibility manually or via the " \
"Planka UI"
end
@client.projects.add_manager(project.id, @human_user_id)
@client.projects.update(project.id, ownerProjectManagerId: nil)
end
def find_or_create_board(project, name)
board = find_board_by_name(name, project: project)
return [board, false] if board
created = @client.boards.create(project.id, name: name, position: 65_536)
[created, true]
end
def find_board_by_name(name, project: nil)
projects = project ? [project] : @client.projects.list
projects.each do |p|
board = @client.boards.list(p.id).find { |b| b.name == name }
return board if board
end
nil
end
def ensure_lists(board)
existing = @client.lists.list(board.id).filter_map(&:name)
created = []
BoardSpec::LISTS.each_with_index do |list_name, index|
next if existing.include?(list_name)
@client.lists.create(board.id, name: list_name, type: "active", position: (index + 1) * 65_536)
created << list_name
end
created
end
def ensure_labels(board)
existing = @client.labels.list(board.id).filter_map(&:name)
created = []
BoardSpec::LABEL_NAMES.each do |label_name|
next if existing.include?(label_name)
create_label_with_fallback_color(board, label_name)
created << label_name
end
created
end
def create_label_with_fallback_color(board, label_name)
candidates = BoardSpec::LABEL_COLOR_CANDIDATES.fetch(label_name)
last_error = nil
candidates.each do |color|
return @client.labels.create(board.id, name: label_name, color: color, position: 65_536)
rescue Planka::ValidationError, Planka::BadRequestError => e
last_error = e
next
end
raise last_error || RuntimeError, "could not create label #{label_name.inspect}: no candidate color accepted"
end
end
end

View File

@ -1,86 +0,0 @@
require_relative "config"
module Backlog
# Resolves a board name to its Planka board record, caching the resolved
# id as `board_id=<id>` in the repo's `.cc-os/config` (issue #22).
#
# The tracker's board *name* stays the source of truth; the id is only a
# cache. The cached id is honored only when the config's tracker is
# exactly `planka:<board_name>`, and a cached `boards.get(id)` hit counts
# only if the returned board's name still matches — a 404 or a rename
# falls back to the projects+boards name lookup and rewrites the cache.
# Any cache failure (unreadable config, unwritable config, stale id)
# degrades to the name lookup; it never fails the command.
#
# Filesystem access is injected: `read_config` returns the raw
# `.cc-os/config` contents (or nil), `write_config` persists updated
# contents. Cache writes go through Config.set, the comment-preserving
# writer — never Config.merge.
class BoardResolver
# @param client [Planka::Client] an already-authenticated client
# @param read_config [#call] -> String, nil — raw .cc-os/config contents
# @param write_config [#call] (String) -> void — persist updated contents
def initialize(client:, read_config:, write_config:)
@client = client
@read_config = read_config
@write_config = write_config
end
# A resolver with no config access: always name-lookup, never caches.
def self.null(client:)
new(client: client, read_config: -> {}, write_config: ->(_) {})
end
# @param name [String] the board name (source of truth)
# @return [Planka::Types::Board]
# @raise [RuntimeError] if no board with that name exists
def board_for(name)
cached = cached_board(name)
return cached if cached
board = lookup_by_name!(name)
cache_id(name, board.id)
board
end
private
def cached_board(name)
config = safe_config
return nil unless config && config.tracker == "planka:#{name}"
id = config.board_id
return nil unless id
board = @client.boards.get(id).board
board && board.name == name ? board : nil
rescue StandardError
nil
end
def lookup_by_name!(name)
@client.projects.list.each do |project|
board = @client.boards.list(project.id).find { |b| b.name == name }
return board if board
end
raise "no board named #{name.inspect} found (run board-ensure first)"
end
def cache_id(name, id)
contents = @read_config.call
config = Config.new(contents)
return unless config.tracker == "planka:#{name}"
return if config.board_id == id.to_s
@write_config.call(Config.set(contents, "board_id", id))
rescue StandardError
nil # cache write failure never fails the command
end
def safe_config
Config.new(@read_config.call)
rescue StandardError
nil
end
end
end

View File

@ -1,34 +0,0 @@
module Backlog
# The uniform board contract every os-backlog board must have: list order
# and the enforced label set. Values only — no client/network code.
module BoardSpec
# Lists in required left-to-right order.
LISTS = %w[Backlog Next Doing Waiting Review Done].freeze
# Label name => ordered candidate colors, tried in turn until the server
# accepts one (Planka's palette-name whitelist is not documented, so we
# can't assert it in advance — cf. planka-api gem's Priority::COLOR_CANDIDATES
# pattern). The first candidate in each list is the verified-good default.
LABEL_COLOR_CANDIDATES = {
"P0" => %w[berry-red red-burgundy salsa-red],
"P1" => %w[pumpkin-orange orange-peel sunset-orange],
"P2" => %w[egg-yellow sunny-grass bright-moss],
"P3" => %w[steel-grey morning-sky light-cocoa],
"hitl" => %w[tank-green bright-moss lagoon-blue],
"semi" => %w[lagoon-blue summer-sky navy-blue],
"afk-ready" => %w[antique-blue bright-moss slate-blue]
}.freeze
LABEL_NAMES = LABEL_COLOR_CANDIDATES.keys.freeze
ARCHIVE_PREFIX = "archived--"
class << self
def archived_name(name) = "#{ARCHIVE_PREFIX}#{name}"
def archived?(name) = name.to_s.start_with?(ARCHIVE_PREFIX)
def active_name(name) = archived?(name) ? name.sub(ARCHIVE_PREFIX, "") : name
end
end
end

View File

@ -1,190 +0,0 @@
require_relative "board_spec"
require_relative "board_resolver"
module Backlog
# Card-level operations over an authenticated Planka client: create a
# card at Backlog (the only column the AI ever creates into), build a
# read-only board snapshot (lists with their cards, labels, card-label
# assignments) for the list skill and the triage/audit agents, and move
# cards between columns under the deterministic policy guardrails
# enforced by #move (Next is human-curated, hitl cards are human-owned,
# Done is a one-way autonomous door gated on the afk-ready label).
#
# Never deletes anything.
class Cards
# @param client [Planka::Client] an already-authenticated client
# @param board_resolver [BoardResolver, nil] resolves board names to
# board records (with the .cc-os/config board_id cache — issue #22);
# defaults to a cache-less name-only resolver. Cards never touches
# the filesystem itself — all config I/O lives in the resolver.
def initialize(client:, board_resolver: nil)
@client = client
@board_resolver = board_resolver || BoardResolver.null(client: client)
end
# Create a card at the board's Backlog list. Raises if the board or
# its Backlog list doesn't exist (callers run board-ensure first).
#
# @return [Planka::Types::Card] the created card
def add(board_name:, title:, description: nil)
board = find_board!(board_name)
backlog = find_list!(board, "Backlog")
attrs = { name: title, type: "project", position: next_position(board, backlog) }
attrs[:description] = description if description
@client.cards.create(backlog.id, attrs)
end
# Read-only snapshot of a whole board: every list in BoardSpec order
# (plus any extras), each with its cards and their label names.
#
# @return [Hash] { "board" => name, "labels" => [names],
# "lists" => [{ "name", "cards" => [{"id","name","labels",...card fields}] }] }
def snapshot(board_name:)
board = find_board!(board_name)
detail = @client.boards.get(board.id)
labels_by_id = detail.labels.to_h { |l| [l.id, l.name] }
card_labels = detail.card_labels.group_by(&:card_id)
cards_by_list = detail.cards.group_by(&:list_id)
lists = detail.lists
.select(&:name) # skip built-in archive/trash lists (name: nil)
.sort_by { |l| l.position || Float::INFINITY }
.map do |list|
cards = (cards_by_list[list.id] || []).map do |card|
label_names = (card_labels[card.id] || []).filter_map { |cl| labels_by_id[cl.label_id] }
card_hash(card, label_names)
end
{ "name" => list.name, "cards" => cards }
end
{ "board" => board.name, "labels" => labels_by_id.values, "lists" => lists }
end
# Attach a board label (by name) to a card. The only card mutation the
# triage agent is allowed — label changes, never column moves.
#
# @return [Planka::Types::CardLabel] the card-label join record
def attach_label(board_name:, card_id:, label:)
board = find_board!(board_name)
record = @client.labels.list(board.id).find { |l| l.name == label }
raise "board #{board_name.inspect} has no label #{label.inspect} (run board-ensure)" unless record
@client.labels.attach_to_card(card_id, record.id)
end
# Add a comment to a card. The only card mutation the audit agent is
# allowed.
#
# @return [Planka::Types::Comment] the created comment
def comment(card_id:, text:)
@client.comments.create(card_id, text: text)
end
# Move a card to another list on the same board, enforcing the
# deterministic column-ownership policy (this is code, not skill
# prose, precisely so it can't be talked around):
#
# 1. Any move into or out of Next is refused — Next is human-curated.
# 2. Any move of a card labeled hitl is refused — hitl cards are
# human-owned.
# 3. Moving a card out of Done is refused.
# 4. Moving a card to Done is allowed only if it carries the
# afk-ready label (afk-ready work that's shipped+verified goes
# straight to Done; semi work stops at Review for human sign-off).
# 5. Everything else among Backlog/Doing/Waiting/Review is allowed.
#
# Lands the card at the end of the destination list (max existing
# position + 65536, or 65536 if the list is empty).
#
# @param board_name [String]
# @param card_id [String, Integer]
# @param to [String] destination list name (one of BoardSpec::LISTS)
# @return [Planka::Types::Card] the moved card
def move(board_name:, card_id:, to:)
unless BoardSpec::LISTS.include?(to)
raise "unknown list #{to.inspect} (expected one of #{BoardSpec::LISTS.join(', ')})"
end
board = find_board!(board_name)
detail = @client.boards.get(board.id)
card = detail.cards.find { |c| c.id == card_id }
raise "no card ##{card_id} found on board #{board_name.inspect}" unless card
from_name = detail.lists.find { |l| l.id == card.list_id }&.name
to_list = detail.lists.find { |l| l.name == to }
raise "board #{board_name.inspect} has no #{to.inspect} list (run board-ensure)" unless to_list
enforce_move_policy!(from_name: from_name, to_name: to, card: card, detail: detail)
position = next_position(board, to_list)
@client.cards.move(card_id, list_id: to_list.id, position: position)
end
private
def enforce_move_policy!(from_name:, to_name:, card:, detail:)
raise "Next is human-curated" if from_name == "Next" || to_name == "Next"
label_names = detail.card_labels
.select { |cl| cl.card_id == card.id }
.filter_map { |cl| detail.labels.find { |l| l.id == cl.label_id }&.name }
raise "hitl cards are human-owned" if label_names.include?("hitl")
raise "cannot move a card out of Done" if from_name == "Done" && to_name != "Done"
return unless to_name == "Done" && !label_names.include?("afk-ready")
raise "move to Done requires the afk-ready label"
end
# Build the plain-hash card representation the CLI/JSON output emits,
# keeping the output shape byte-compatible with the 0.1.x snapshot
# (string keys, "labels" merged in) even though Types::Card is an
# immutable Data object we can no longer +merge+. Emits every
# Types::Card field — dropping fields here silently breaks consumers
# like board-audit, which reads "listChangedAt".
def card_hash(card, label_names)
{
"id" => card.id,
"createdAt" => card.created_at,
"updatedAt" => card.updated_at,
"type" => card.type,
"position" => card.position,
"name" => card.name,
"description" => card.description,
"dueDate" => card.due_date,
"isDueCompleted" => card.is_due_completed,
"stopwatch" => card.stopwatch,
"commentsTotal" => card.comments_total,
"isClosed" => card.is_closed,
"listChangedAt" => card.list_changed_at,
"boardId" => card.board_id,
"listId" => card.list_id,
"creatorUserId" => card.creator_user_id,
"prevListId" => card.prev_list_id,
"coverAttachmentId" => card.cover_attachment_id,
"isSubscribed" => card.is_subscribed,
"labels" => label_names
}
end
def find_board!(name)
@board_resolver.board_for(name)
end
def find_list!(board, list_name)
list = @client.lists.list(board.id).find { |l| l.name == list_name }
raise "board #{board.name.inspect} has no #{list_name.inspect} list (run board-ensure)" unless list
list
end
def next_position(board, list)
detail = @client.boards.get(board.id)
positions = detail.cards
.select { |c| c.list_id == list.id }
.filter_map(&:position)
(positions.max || 0) + 65_536
end
end
end

View File

@ -35,20 +35,9 @@ module Backlog
@data["tracker"] @data["tracker"]
end end
# @return [String, nil] the effective Planka board name: the explicit # @return [String, nil] the cached Planka board id, if present (legacy;
# `board` key when present, else the board named by a # Planka is retired per ADR-0042 — kept only so old .cc-os/config
# `tracker=planka:<board>` key (the form config-write actually # files with this key still parse without error).
# writes). Non-planka trackers contribute nothing.
def planka_board
return board if board
return nil unless tracker&.start_with?("planka:")
tracker.delete_prefix("planka:")
end
# @return [String, nil] the cached Planka board id, if present. Only a
# cache — the tracker's board *name* stays the source of truth
# (issue #22); BoardResolver decides whether to honor it.
def board_id def board_id
@data["board_id"] @data["board_id"]
end end

View File

@ -0,0 +1,219 @@
require "json"
require "time"
require "fileutils"
module Backlog
# Issue-side capture/list helpers (ADR-0042: one tracker holds both state
# and specs, git issues are the single tracker). Dispatches on the
# tracker's kind: `tea` for forgejo:, `gh` for github:, in-repo markdown
# files for repo:<path>.
#
# The git-host CLIs are shelled out to via an injected #capture(*cmd)
# runner — [stdout, success?], mirroring the tail of Open3.capture3 — so
# tests stub the runner object directly instead of hacking PATH
# (ShellRunner below is the only piece that actually shells out). The
# repo: file mode does real filesystem IO directly (same style as
# Inspector), exercised in tests via tmpdir.
class Issues
# Priority labels capture may attach. Deliberately excludes every state
# label (`next`/`waiting`/`review`) — there is no parameter through
# which a state label can reach #create, so it can never apply `next`
# (issue-state-labels spec: next is human-only).
PRIORITY_LABELS = %w[P0 P1 P2 P3].freeze
STATE_LABELS = %w[next waiting review].freeze
CommandFailed = Class.new(StandardError)
# Default runner: actually shells out via Open3. The only non-pure
# piece of this file; production CLI wiring uses it, tests never do.
class ShellRunner
def capture(*cmd)
require "open3"
out, _err, status = Open3.capture3(*cmd)
[out, status.success?]
end
end
# @param runner [#capture] responds to #capture(*cmd) -> [stdout, success?]
# @param repo_root [String] base directory a repo:<path> tracker's path is relative to
# @param clock [#call] returns the current Time; injected for repo: issue dating
def initialize(runner:, repo_root: Dir.pwd, clock: -> { Time.now })
@runner = runner
@repo_root = repo_root
@clock = clock
end
# Create an issue with a title, body, and optional priority label
# (P0-P3). Never applies a state label.
#
# @return [Hash] {number:, url:} for forgejo:/github:, {number:, path:} for repo:
def create(tracker:, title:, body:, priority: nil)
validate_priority!(priority)
case Tracker.kind(tracker)
when :forgejo then create_tea(slug(tracker), title: title, body: body, priority: priority)
when :github then create_gh(slug(tracker), title: title, body: body, priority: priority)
when :repo then create_file(rel_path(tracker), title: title, body: body, priority: priority)
else raise ArgumentError, "cannot create an issue on unroutable tracker #{tracker.inspect}"
end
end
# Open issues, grouped by state label.
#
# @return [Hash{Symbol=>Array<Hash>}] :next / :waiting / :review / :other
# => arrays of {number:, title:, labels:, assignee:}; :next first is
# the caller's job to render, not this method's (it returns a plain
# Hash, insertion-ordered next/waiting/review/other).
def list(tracker:)
issues =
case Tracker.kind(tracker)
when :forgejo then list_tea(slug(tracker))
when :github then list_gh(slug(tracker))
when :repo then list_files(rel_path(tracker))
else raise ArgumentError, "cannot list issues on unroutable tracker #{tracker.inspect}"
end
group_by_state(issues)
end
private
def validate_priority!(priority)
return if priority.nil? || PRIORITY_LABELS.include?(priority)
raise ArgumentError, "invalid priority #{priority.inspect}; expected one of #{PRIORITY_LABELS.join(', ')}"
end
def slug(tracker) = tracker.split(":", 2).last
def rel_path(tracker) = tracker.split(":", 2).last
def group_by_state(issues)
buckets = { next: [], waiting: [], review: [], other: [] }
issues.each do |issue|
labels = Array(issue[:labels])
key = STATE_LABELS.find { |label| labels.include?(label) }&.to_sym || :other
buckets[key] << issue
end
buckets
end
# --- forgejo (tea) ---------------------------------------------------
def create_tea(repo_slug, title:, body:, priority:)
cmd = ["tea", "issue", "create", "--repo", repo_slug, "--title", title, "--description", body]
cmd += ["--labels", priority] if priority
out, ok = @runner.capture(*cmd)
raise CommandFailed, "tea issue create failed: #{out}" unless ok
{ number: extract_number(out), url: extract_url(out) }
end
def list_tea(repo_slug)
cmd = ["tea", "issues", "list", "--repo", repo_slug, "--state", "open",
"--fields", "index,title,labels,assignees", "--output", "json"]
out, ok = @runner.capture(*cmd)
raise CommandFailed, "tea issues list failed: #{out}" unless ok
JSON.parse(out).map do |issue|
assignee = issue["assignees"].to_s.split(",").map(&:strip).reject(&:empty?).first
{ number: issue["index"].to_i, title: issue["title"],
labels: issue["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
assignee: assignee }
end
end
# --- github (gh) ------------------------------------------------------
def create_gh(repo_slug, title:, body:, priority:)
cmd = ["gh", "issue", "create", "--repo", repo_slug, "--title", title, "--body", body]
cmd += ["--label", priority] if priority
out, ok = @runner.capture(*cmd)
raise CommandFailed, "gh issue create failed: #{out}" unless ok
{ number: extract_number(out), url: extract_url(out) }
end
def list_gh(repo_slug)
cmd = ["gh", "issue", "list", "--repo", repo_slug, "--state", "open",
"--json", "number,title,labels,assignees"]
out, ok = @runner.capture(*cmd)
raise CommandFailed, "gh issue list failed: #{out}" unless ok
JSON.parse(out).map do |issue|
{ number: issue["number"].to_i, title: issue["title"],
labels: Array(issue["labels"]).map { |label| label["name"] },
assignee: Array(issue["assignees"]).map { |a| a["login"] }.first }
end
end
def extract_number(text)
match = text[/#(\d+)/, 1] || text[%r{/issues/(\d+)}, 1]
match&.to_i
end
def extract_url(text)
text.to_s[%r{https?://\S+}] || text.to_s.strip
end
# --- repo: (in-repo markdown issue files) ------------------------------
#
# `<path>/NNN-<slug>.md`, front matter + body. No existing in-repo
# convention for individual issue files predates this (Inspector only
# detects the presence of a docs/issues dir or ISSUES.md, not a file
# shape) — this is a new, deliberately simple convention.
def create_file(rel_dir, title:, body:, priority:)
dir = File.join(@repo_root, rel_dir)
FileUtils.mkdir_p(dir)
number = next_file_number(dir)
path = File.join(dir, format("%03d-%s.md", number, slugify(title)))
File.write(path, file_contents(number: number, title: title, body: body, priority: priority))
{ number: number, path: path }
end
def slugify(title)
title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
end
def file_contents(number:, title:, body:, priority:)
front = {
"number" => number.to_s,
"title" => title.to_s,
"labels" => [priority].compact.join(","),
"state" => "open",
"created" => @clock.call.strftime("%Y-%m-%d")
}
lines = ["---"] + front.map { |k, v| "#{k}: #{v}" } + ["---", ""]
"#{lines.join("\n")}\n#{body}\n"
end
def next_file_number(dir)
existing = Dir.glob(File.join(dir, "[0-9][0-9][0-9]-*.md"))
existing.map { |f| File.basename(f)[/\A(\d+)-/, 1].to_i }.max.to_i + 1
end
def list_files(rel_dir)
dir = File.join(@repo_root, rel_dir)
return [] unless Dir.exist?(dir)
Dir.glob(File.join(dir, "*.md")).sort.filter_map { |path| parse_issue_file(path) }
end
def parse_issue_file(path)
contents = File.read(path)
return nil unless contents.start_with?("---\n")
_, front_matter, = contents.split("---\n", 3)
data = {}
front_matter.to_s.each_line do |line|
key, value = line.split(":", 2)
next unless key && value
data[key.strip] = value.strip
end
return nil unless data["state"].to_s.empty? || data["state"] == "open"
{ number: data["number"].to_i, title: data["title"],
labels: data["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
assignee: data["assignee"] }
end
end
end

View File

@ -1,91 +1,44 @@
require_relative "board_spec"
require_relative "config" require_relative "config"
module Backlog module Backlog
# Pure repo -> board routing decision. No filesystem, no network: every # Pure decision over a project's configured tracker. No filesystem, no
# input (repo path, config contents, board inventory) is passed in. # network: every input (repo path, config contents) is passed in.
# #
# Decision, exactly one of: # Post-ADR-0042 (Planka retired), there is no board inventory to resolve
# "use <board>" - an active board to work against # against — a project either has a `tracker` key in `.cc-os/config` or it
# "activate <archived board>" - an archived board matches; rename it back # doesn't. Decision, exactly one of:
# "stop-and-discuss" - no confident match; ask the human # "use <tracker>" - a tracker key is configured; use it
# "stop-and-discuss" - no tracker configured; ask the human
# (see /os-backlog:route)
class Resolver class Resolver
Decision = Struct.new(:action, :board) do Decision = Struct.new(:action, :tracker) do
def to_s def to_s
action == :use || action == :activate ? "#{action} #{board}" : "stop-and-discuss" action == :use ? "use #{tracker}" : "stop-and-discuss"
end end
end end
DEV_ROOT = File.expand_path("~/dev") # @param repo_path [String] absolute path to the repo (kept for callers
CLIENTS_ROOT = File.expand_path("~/clients") # that pass it through; not otherwise used since board heuristics
SERVERS_ROOT = File.expand_path("~/servers") # were retired with Planka)
# 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 # @param config_contents [String, nil] raw contents of .cc-os/config, or nil/absent
# @param boards [Array<String>] board names known to exist on the Planka instance def initialize(repo_path:, config_contents:, **)
# (both active and archived--prefixed names)
def initialize(repo_path:, config_contents:, boards:)
@repo_path = File.expand_path(repo_path) @repo_path = File.expand_path(repo_path)
@config = Config.new(config_contents) @config = Config.new(config_contents)
@boards = boards
end end
# @return [Decision] # @return [Decision]
def resolve def resolve
configured_board = @config.planka_board return use(@config.tracker) if @config.tracker_configured?
return decision_for_name(configured_board) || stop_and_discuss if configured_board
derived_decision || stop_and_discuss stop_and_discuss
end end
# @return [String] one of "use <board>" / "activate <board>" / "stop-and-discuss" # @return [String] "use <tracker>" or "stop-and-discuss"
def resolve_string = resolve.to_s def resolve_string = resolve.to_s
private private
def derived_decision def use(tracker) = Decision.new(:use, tracker)
return nil if project.nil?
decision_for_name(board_name)
end
def decision_for_name(name)
return use(name) if @boards.include?(name)
return activate(name) if @boards.include?(BoardSpec.archived_name(name))
nil
end
def stop_and_discuss = Decision.new(:stop_and_discuss, nil) def stop_and_discuss = Decision.new(:stop_and_discuss, nil)
def use(name) = Decision.new(:use, name)
def activate(name) = Decision.new(:activate, BoardSpec.archived_name(name))
def board_name = File.basename(@repo_path)
def project = self.class.project_for(@repo_path)
end end
end end

View File

@ -5,19 +5,21 @@ module Backlog
class Tracker class Tracker
DEFAULT_FORGEJO_HOST = "forgejo.swansoncloud.com" DEFAULT_FORGEJO_HOST = "forgejo.swansoncloud.com"
# ADR-0042 retired Planka: the tracker grammar is exactly these three
# forms. `planka:` is deliberately absent — it is rejected, not merely
# unmatched (see config-write and os-status's tracker-configured check).
FORMATS = { FORMATS = {
planka: %r{\Aplanka:[\w.-]+\z},
forgejo: %r{\Aforgejo:[\w.-]+/[\w.-]+\z}, forgejo: %r{\Aforgejo:[\w.-]+/[\w.-]+\z},
github: %r{\Agithub:[\w.-]+/[\w.-]+\z}, github: %r{\Agithub:[\w.-]+/[\w.-]+\z},
repo: /\Arepo:.+\z/ repo: /\Arepo:.+\z/
}.freeze }.freeze
# @return [Boolean] true if value matches one of the four valid formats # @return [Boolean] true if value matches one of the three valid formats
def self.valid?(value) def self.valid?(value)
!kind(value).nil? !kind(value).nil?
end end
# @return [Symbol, nil] :planka / :forgejo / :github / :repo, or nil if invalid # @return [Symbol, nil] :forgejo / :github / :repo, or nil if invalid
def self.kind(value) def self.kind(value)
return nil unless value.is_a?(String) return nil unless value.is_a?(String)
@ -26,18 +28,17 @@ module Backlog
end end
# Where /to-issues output (spec slices) should be published, given the # Where /to-issues output (spec slices) should be published, given the
# repo's tracker key. The ADR'd boundary: git issues = specs, Planka = # repo's tracker key. One tracker holds both state and specs (ADR-0042):
# state. So forgejo:/github: keys route spec slices to that git issue # forgejo:/github: keys route spec slices to that git issue tracker;
# tracker. `planka:` / `repo:` / nil deliberately map to # repo:<path> routes to the in-repo issue-file convention; nil or any
# :planka_default — what that MEANS for spec-shaped output is a named # invalid key (including retired `planka:` values) is :unrouted.
# decision gate at wording review (issue #16), not settled here.
# #
# @return [Symbol] :git_issues | :planka_default | :unrouted (invalid key) # @return [Symbol] :git_issues | :repo_files | :unrouted
def self.issues_destination(value) def self.issues_destination(value)
case kind(value) case kind(value)
when :forgejo, :github then :git_issues when :forgejo, :github then :git_issues
when :planka, :repo then :planka_default when :repo then :repo_files
else value.nil? ? :planka_default : :unrouted else :unrouted
end end
end end

View File

@ -1,69 +0,0 @@
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
# Which board should this session's triage check inspect? Mirrors the
# CLI's config semantics (the session cwd's own .cc-os/config wins —
# umbrella subdir configs override the repo root's), falling back to
# the root config, then to the path heuristic. Honors both the
# explicit `board` key and `tracker=planka:<board>` (via
# Config#planka_board). Pure: the hook script reads the files and
# passes contents (or nil) in.
#
# @param cwd [String] the session's working directory
# @param root [String] the enclosing git toplevel (== cwd outside umbrellas)
# @param cwd_config [String, nil] contents of <cwd>/.cc-os/config
# @param root_config [String, nil] contents of <root>/.cc-os/config
# @return [String, nil] board name, or nil (don't guess) when nothing
# is configured and the path heuristic doesn't recognize the path
def self.board_name_for(cwd:, root:, cwd_config:, root_config:)
config_home = cwd_config ? cwd : root
config = Config.new(cwd_config || root_config)
return config.planka_board if config.planka_board
return nil unless Resolver.project_for(config_home)
File.basename(config_home)
end
# @param labels [Array<String>] the label names on one card
# @return [Boolean]
def self.needs_triage?(labels)
(labels & PRIORITY_LABELS).empty? || (labels & AUTONOMY_LABELS).empty?
end
# @param backlog_cards_labels [Array<Array<String>>] label arrays,
# one per card currently in the board's Backlog list
# @return [Array<Array<String>>] the subset needing triage
def self.unlabeled(backlog_cards_labels)
backlog_cards_labels.select { |labels| needs_triage?(labels) }
end
# @param board_name [String]
# @param backlog_cards_labels [Array<Array<String>>]
# @return [String, nil] the SessionStart additionalContext note, or
# nil when there is nothing to triage
def self.note_for(board_name, backlog_cards_labels)
count = unlabeled(backlog_cards_labels).size
return nil if count.zero?
card_word = count == 1 ? "card" : "cards"
"[os-backlog] #{count} unlabeled Backlog #{card_word} on board #{board_name} -> " \
"dispatch the card-triage agent now (model: haiku, run in background) to label " \
"them. Labeling is this project's job for its own board (a parent umbrella may " \
"also label its child boards) — never label cards on a board that belongs to a " \
"different project."
end
end
end

View File

@ -1,59 +1,43 @@
--- ---
description: Capture work discovered mid-session as a Backlog card on the current repo's Planka board — resolves the board deterministically from the repo path, creates a missing board transparently via board-ensure, and always lands the card at Backlog. Use unprompted WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will not be done in this session. Invoked by `/os-backlog:capture`. description: Capture work discovered mid-session as an issue on the current repo's configured tracker — creates it with a priority label when known and no state labels. Use unprompted WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will not be done in this session. Invoked by `/os-backlog:capture`.
--- ---
Capture a piece of work as a card on the right Planka board, cheaply, without interrupting the current task. Capture a piece of work as an issue on the current repo's configured tracker, cheaply, without interrupting the current task.
## Column-ownership rules (non-negotiable) ## Rules (non-negotiable)
- **The AI creates cards at Backlog only.** Never create a card in any other list. - **Capture never applies state labels, and never applies `next`.** `next` is human-curated
- **Working a card means moving it**, via `card-move` (see below): Doing when work starts, Waiting plus a blocker comment when blocked, Review when a semi card ships, Done when an afk-ready card ships and is verified. (ADR-0042) — the AI never adds or removes it, under any instruction short of an explicit
- **Next stays human-curated in both directions** — never move a card into or out of Next. direct user request naming the specific issue.
- **Never move a card labeled `hitl`** — hitl cards are human-owned, never self-assigned or moved by the AI. - Apply a priority label (`P0``P3`) only when it's actually known from context; otherwise
- 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. leave priority unset — don't guess one to fill the field.
- 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. - Never a TODO comment in code, never only a mention in chat — always a real issue.
## Procedure ## Procedure
All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`. All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
1. **Resolve the board** for the current repo. Gather the inputs and run the pure resolver (no network): 1. Create the issue on the cwd repo's configured tracker:
```bash ```bash
# config: contents of .cc-os/config if present, else null ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog issue-create --title "<short imperative title>" \
# boards: names from Planka — every project's boards, including archived--* ones --body "<context: what surfaced, why it's deferred, repo/file paths>" \
echo '{"repo_path": "<repo root>", "config": <config-or-null>, "boards": [...]}' \ [--priority P0|P1|P2|P3]
| ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog resolve
``` ```
To build the `boards` inventory, list boards via `snapshot`/the Planka client; if Planka is unreachable, stop here and report the error — do not guess. This creates the issue via `tea` (forgejo), `gh` (github), or a `NNN-<slug>.md`
2. **Act on the decision:** frontmatter file (`repo:`) — whichever the project's tracker key names.
- `use <board>` → proceed to step 3. `issue-create` is structurally incapable of applying state labels; there is no flag for
- `activate <archived board>` → run `os-backlog activate <name>` (activation is automatic — an archived board matching this repo is simply renamed back), then proceed. one, by design.
- `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. 2. Confirm to the user in one line: tracker, title, priority if set. Then return to the
3. **Ensure the board exists** (idempotent — safe to run every time): interrupted task.
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog board-ensure <board>
```
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. Creating a NEW Planka project requires `PLANKA_HUMAN_USER_ID`
(or an explicit human user id) — board-ensure fails loudly rather than silently creating a
bot-only project.
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>"]
```
5. Confirm to the user in one line: board, title. Then return to the interrupted task.
## Moving a card ## No tracker configured
When work on a card starts, blocks, or ships, move it with: If `issue-create` fails because the project has no `tracker` key in `.cc-os/config`,
```bash suggest `/os-backlog:route` once — do not nag or retry — and otherwise degrade gracefully:
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-move --board <board> --card <ID> --to Backlog|Doing|Waiting|Review|Done tell the user what could not be captured and let them decide where it should go for now.
```
The CLI refuses (one-line reason, exit 1) any move into/out of Next, any move of a `hitl` card, any move out of Done, or a move to Done without the `afk-ready` label.
## Failure behavior ## Failure behavior
Fail soft. If the planka-api gem or the Planka instance is unavailable, the CLI prints one clear error and exits nonzero — report that error to the user verbatim and offer to note the task elsewhere. Never retry destructively, never leave partial state unmentioned. Fail soft. If the CLI or the underlying tracker (`tea`/`gh`) is unavailable, it prints one
clear error and exits nonzero — report that error to the user verbatim and offer to note the
task elsewhere. Never retry destructively, never leave partial state unmentioned.

View File

@ -1,34 +1,44 @@
--- ---
description: Show the Planka backlog cards relevant to the current repo, on demand. Pull-only — ONLY use when the user explicitly asks what's on the board / backlog / what's next; never inject board state into a session unasked. Invoked by `/os-backlog:list`. description: Show the open issues relevant to the current repo's configured tracker, on demand. Pull-only — ONLY use when the user explicitly asks what's on the backlog / what's next; never inject issue state into a session unasked. Invoked by `/os-backlog:list`.
--- ---
Show the current repo's board state when — and only when — the user asks for it. Show the current repo's tracker state when — and only when — the user asks for it.
## Pull-only rule (notification policy v2) ## Pull-only rule (notification policy v2)
This skill is strictly pull-based. **Never volunteer board state**: not at session start, not after a capture, not "while you're here". The only trigger is an explicit user request ("what's on the backlog?", "/os-backlog:list", "show the board"). This skill is strictly pull-based. **Never volunteer issue state**: not at session start,
not after a capture, not "while you're here". The only trigger is an explicit user request
## Column-ownership rules (also apply here) ("what's on the backlog?", "/os-backlog:list", "what's next?").
Listing is read-only — this skill never moves cards itself. While discussing the listed cards:
- Working a card means moving it (via `card-move`, see `/os-backlog:capture`): Doing when started, Waiting plus a blocker comment when blocked, Review when a semi card ships, Done when an afk-ready card ships and is verified.
- Next stays human-curated in both directions — never move a card into or out of Next.
- If the user asks you to start work from the list, cards labeled `hitl` are off-limits — never moved by the AI, flag them back to the human instead.
- These rules are enforced deterministically by the CLI (`card-move`), not just documented here.
## Procedure ## Procedure
1. Resolve the board for the current repo the same way capture does (`resolve` — see `/os-backlog:capture`). On `stop-and-discuss`, ask the user which board they mean instead of guessing. 1. List the open issues on the cwd repo's configured tracker:
2. Show the cards:
```bash ```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog cards --board <board> ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog issues
``` ```
For a structured view (filtering, counting), use JSON instead: This returns open issues (number, title, labels, assignee) grouped by state: `next` /
```bash `waiting` / `review` / other open.
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board <board> 2. Relay the listing compactly, `next`-labeled issues first, then `waiting`, then `review`,
``` then the rest. If the user asked a narrower question ("what's in review?"), answer only
3. Relay the listing compactly, in board-column order (Backlog, Next, Doing, Waiting, Review, Done). If the user asked a narrower question ("what's in review?"), answer only that. that.
## While discussing listed issues
Listing is read-only — this skill never changes issue state itself. If the user asks you to
start work from the list:
- `hitl`-labeled issues are off-limits — never picked up by the AI; flag them back to the
human instead.
- Comment on the issue when work starts; if it blocks, add the `waiting` label plus a
comment naming the blocker (see `/os-backlog:capture`'s home hook note for the full
working-state contract).
- Never touch the `next` label — human-curated in both directions, always.
## No tracker configured
If `issues` fails because the project has no `tracker` key in `.cc-os/config`, suggest
`/os-backlog:route` once and stop — there's nothing to list.
## Failure behavior ## Failure behavior
Fail soft: if the planka-api gem or Planka is unreachable, the CLI prints one clear error and exits nonzero — relay it and stop. Never fabricate board state. Fail soft: if the CLI or the underlying tracker (`tea`/`gh`) is unreachable, it prints one
clear error and exits nonzero — relay it and stop. Never fabricate issue state.

View File

@ -1,20 +1,19 @@
--- ---
description: Onboard a project's issue tracker — inspect what tracking already exists, propose a destination per the Planka-state/git-issues-spec boundary rule, migrate existing open items with back-links, and write the tracker key to .cc-os/config. Use unprompted WHEN a project has no tracker key configured and process/backlog work surfaces, or when the user explicitly asks to set up or change how a repo's issues/backlog are tracked. Invoked by `/os-backlog:route`. description: Onboard a project's issue tracker — inspect what tracking already exists, propose a destination (forgejo/github/repo), write it to .cc-os/config, and ensure the ten canonical labels exist. Use unprompted WHEN a project has no tracker key configured and process/backlog work surfaces, or when the user explicitly asks to set up or change how a repo's issues are tracked. Invoked by `/os-backlog:route`.
--- ---
Register a project's issue tracker: figure out what already exists, propose where issue/backlog tracking should live, and — with the human's confirmation at each decision gate — migrate and record it. Register a project's issue tracker: figure out what already exists, propose the single
destination where its state and specs both live, and — with the human's confirmation —
write it and ensure its labels exist.
## The boundary rule (what you're deciding between) ## One tracker, both jobs
Planka = state, git issues = specs (ADR-0033, `docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md`) — the ADR is the canonical statement; the operative routing rules are: Per ADR-0042 there is exactly one tracker per project. It holds both task state (labels)
and durable specs (issue bodies, `/to-issues` slices, PRDs) — there is no second surface.
- **WHEN** the project's work is mostly ephemeral tasks, small fixes, or process/coordination (state: backlog/next/doing/review/done, nothing needing a durable written spec) → `planka:<board>`. Valid tracker key formats (exactly one, written to `.cc-os/config`'s `tracker` key):
- **WHEN** work is specified as text that must survive and be referenced (features, migrations, architecture-level changes, tracer-bullet slices from `/to-issues`, PRDs from `/to-prd`) → `forgejo:<owner>/<repo>` or `github:<owner>/<repo>`. `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`. `planka:<board>` is
- **WHEN** a project already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to stay there → `repo:<path>` (the escape hatch). rejected — `config-write` and the os-status `tracker-configured` check both reject it with
- **WHEN** a code effort needs both state tracking and a durable spec → card-as-pointer + issue-chain-as-spec: the Planka card is a pointer (title + link) into the git issue chain holding the spec; never duplicate spec text into the card description. a one-line message citing ADR-0042, fail-soft.
- **WHEN** a Planka-tracked effort accretes significant code/design decisions in card comments → flag promotion to the user (open a git issue chain for the spec, turn the card into a pointer); never promote silently.
Valid tracker key formats (exactly one, written to `.cc-os/config`'s `tracker` key): `planka:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`.
## Procedure ## Procedure
@ -24,38 +23,77 @@ All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
```bash ```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog inspect ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog inspect
``` ```
This is read-only and safe to run without asking. It reports, as JSON: Read-only, safe without asking. It reports `tracker_configured` (existing key, if any —
- `tracker_configured` — an existing `tracker` key in `.cc-os/config`, if any. If this is already set, tell the user the project is already routed to it and confirm they want to re-route before continuing (re-routing is itself a decision gate — treat it as step 2). if set, confirm re-routing with the user before continuing; re-routing is itself a
- `planka` — whether a Planka board already resolves for this repo (via the same resolver `/os-backlog:capture` uses), or why that check couldn't run (gem/credentials unavailable — fail-soft, not blocking). decision gate), `git_remote` (parsed `git remote -v`, classified `forgejo`/`github`/
- `git_remote` — the parsed `git remote -v`, classified as `forgejo` (the user's self-hosted instance) / `github` / `unknown`, plus the open-issue count via `tea` or `gh` if that CLI is available (`null` with a `reason` if not — fail-soft, not blocking). `unknown`, plus open-issue count via `tea`/`gh` if available — fail-soft, not blocking),
- `issue_shape` — a classification of the open git issues' shape: `sequential-chain` (most issues carry dependency labels or blocked-by/depends-on cross-references), `flat-adhoc` (no dependency structure), `mixed` (some), or `null` (no metadata, or fewer than 3 open issues — no evidence either way). and `in_repo_issue_files` (`docs/issues/` and/or `ISSUES.md` if present).
- `in_repo_issue_files``docs/issues/` and/or `ISSUES.md` if present.
2. **Synthesize and propose (NAMED DECISION GATE).** Exactly ONE tracker key is ever written to `.cc-os/config` — the branches below differ in *which* key and *what happens to existing issues*, never in how many keys. Branch on the findings: 2. **Propose a destination (NAMED DECISION GATE).** Pick one:
- **(a) No existing tracker found** (no open git issues, no in-repo issue files, no live board): propose one destination key with a short rationale grounded in the boundary rule — e.g. "no existing tracking found, work here is small ad hoc fixes; `planka:<repo-name>` fits Planka-as-state; a board will be created on first capture, not by this skill." - Git remote is `forgejo` → propose `forgejo:<owner>/<repo>`.
- **(b) Existing git issues that are spec-shaped** (`issue_shape` is `sequential-chain` or `mixed`): recommend the **SPLIT as the default**. Write `planka:<board>` as the single declared tracker — it governs new/ephemeral capture only. The existing git issues are specs per ADR-0033: they stay exactly where they are, unmigrated, and remain the durable spec surface; skip migration entirely (step 3 does not apply to them). This is still exactly one tracker key — the split is a division of labor (state vs specs), not two destinations. - Git remote is `github` → propose `github:<owner>/<repo>`.
- **(c) Existing git issues that are flat-adhoc** (`issue_shape` is `flat-adhoc`): migration to Planka is on the table — propose the destination key, and if confirmed, existing open issues go through the step-3 migration gate as usual. If `issue_shape` is `null` (no metadata or too few issues to tell), treat shape as unknown: ask the user which shape fits rather than assuming. - No git-host remote, but `in_repo_issue_files` exist → propose `repo:<path>` naming
**Stop here and wait for the user to confirm or override the destination before writing anything or moving anything.** If findings are ambiguous (e.g. both a live Planka board AND an active issue tracker with open items, and it's unclear which is authoritative), say so plainly and ask rather than guessing. that location.
- Neither → ask the user which of the three they want; do not guess.
**Stop here and wait for the user to confirm or override before writing or migrating
anything.**
3. **If migration is needed (SECOND NAMED DECISION GATE).** Only applies when the confirmed destination differs from where open items currently live (e.g. moving from ad hoc `ISSUES.md` entries to Planka, or from an unrouted Planka board to git issues). **Exception — never re-trigger on the split:** a `planka:` tracker alongside by-design git spec issues (branch 2b, or any project whose git issues are specs per ADR-0033) is the intended end state, not a discrepancy; do not propose migrating those issues, now or on any later re-run of this skill. Before touching any live project history: 3. **Migrate existing items if the tracker type is changing (SECOND NAMED DECISION GATE).**
- Tell the user exactly what will move (list the items) and ask for explicit go-ahead. This gate is separate from the destination gate in step 2 — confirming the destination is not confirming the migration. Only applies when open items currently live somewhere other than the confirmed
- Once confirmed, migrate mechanically using **existing machinery, not new code paths**: `card-add`/`cards` (via `/os-backlog:capture`'s CLI calls) for items moving into Planka; `tea`/`gh` issue-create commands for items moving into git issues. Do not invent bespoke migration scripts. destination (e.g. `ISSUES.md` entries moving to Forgejo, or switching from GitHub to
- **Back-link both ways so nothing is double-tracked**: the old item (closed Planka card comment, closed in-repo issue entry, or a note in the git-host issue if migrating away from it) gets a pointer to its new home; the new item (card description or issue body) links back to the source. Close/archive the old item once the back-link is in place — don't leave both open. Forgejo). Before touching any live project history:
- Tell the user exactly what will move (list the items) and get explicit go-ahead —
separate from the destination confirmation in step 2.
- Once confirmed, migrate mechanically using existing machinery: `tea issues create` /
`gh issue create` for items moving onto a git-host tracker; append to the target file
for items moving into `repo:<path>`. Do not invent bespoke migration scripts.
- Back-link both ways: the old item gets a pointer to its new home, the new item's body
links back to the source. Close/archive the old item once the back-link is in place.
4. **Write the tracker key (autonomous, mechanical, once destination is confirmed).** 4. **Write the tracker key (autonomous, mechanical, once destination is confirmed).**
```bash ```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog config-write <tracker-value> ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog config-write <tracker-value>
``` ```
The CLI validates the format and rejects anything malformed — if it fails, relay the error verbatim and re-ask rather than hand-editing `.cc-os/config`. This validates the format, rejects anything malformed (including `planka:`, citing
ADR-0042), writes `.cc-os/config`, and upserts the global project index
(`~/.cc-os/projects.json`) so cross-project filing can find this project later.
5. **Close out.** Tell the user the tracker is set and that the os-status tracker warning (ADR-022/ADR-026) goes silent starting next session. 5. **Ensure the ten canonical labels exist (forgejo/github only, idempotent).** Skip for
`repo:` trackers — they carry no label mechanism. List what's already there, then create
only what's missing:
- Forgejo:
```bash
tea labels list --repo <owner>/<repo> -o json
tea labels create --repo <owner>/<repo> --name <label> --color <hex>
```
- GitHub:
```bash
gh label list --repo <owner>/<repo> --json name
gh label create <label> --repo <owner>/<repo> --color <hex>
```
The ten labels: priority `P0` `P1` `P2` `P3`; autonomy `hitl` `semi` `afk-ready`; state
`next` `waiting` `review`. Pick any consistent color scheme (e.g. red-to-green for
priority) — the color itself isn't contractual, only that all ten exist. Re-running
`/os-backlog:route` on an already-labeled repo creates nothing new.
6. **Close out.** Tell the user the tracker is set, which labels were created (if any), and
that the os-status tracker warning goes silent starting next session.
## Decision gates (non-negotiable) ## Decision gates (non-negotiable)
- **Destination gate (step 2):** never call `config-write` or move anything before the human has confirmed which tracker to use. - **Destination gate (step 2):** never call `config-write` before the human has confirmed
- **Migration gate (step 3):** even after the destination is confirmed, migrating *existing open items* (live project history) needs its own explicit go-ahead — a destination choice for new work is not consent to move old work. which tracker to use.
- Inspection (step 1) and the final `config-write` (step 4) are mechanical and run without a gate — they don't change or move anything you haven't already been told to. - **Migration gate (step 3):** even after the destination is confirmed, migrating existing
open items needs its own explicit go-ahead — a destination choice for new work is not
consent to move old work.
- Inspection (step 1), `config-write` (step 4), and label creation (step 5) are mechanical
and run without a gate — they don't change or move anything you haven't already been told
to.
## Failure behavior ## Failure behavior
Fail soft, per the plugin convention. `inspect`'s individual checks (Planka, tea/gh) degrade gracefully and are reported as unavailable rather than aborting the whole command — relay those as informational, not blocking, unless the missing signal is the only way to make the destination decision (in which case say what's missing and ask the human directly). `config-write` fails loudly on an invalid tracker value — relay the CLI's error verbatim. Fail soft. `inspect`'s individual checks (`tea`/`gh`) degrade gracefully and are reported as
unavailable rather than aborting the whole command — relay those as informational unless
the missing signal is the only way to make the destination decision, in which case say
what's missing and ask the human directly. `config-write` fails loudly on an invalid
tracker value — relay the CLI's error verbatim.

View File

@ -21,13 +21,13 @@ project has no row, it hasn't been routed yet — tell the user rather than gues
- `forgejo:<owner>/<repo>``tea issues create --repo <owner>/<repo> --title "..." --description "..."` - `forgejo:<owner>/<repo>``tea issues create --repo <owner>/<repo> --title "..." --description "..."`
- `github:<owner>/<repo>``gh issue create --repo <owner>/<repo> --title "..." --body "..."` - `github:<owner>/<repo>``gh issue create --repo <owner>/<repo> --title "..." --body "..."`
- `planka:<board>` (planka-only targets) — `os-backlog card-add --board <board> --title "..." [--description "..."]` - `repo:<path>` — append an `NNN-<slug>.md` frontmatter file to the target's `<path>`.
Routing inside the target project still follows ITS boundary rule (ADR-0033: Planka is No autonomy or priority label is applied by the filer — that's the receiving project's job,
state, git issues are specs) — a durable spec goes to the git-issue tracker even if the per its own triage. The one tracker holds both state and specs for the target project too
project also has a board. (ADR-0042) — there is no second surface to route within.
## Discoverer block (append verbatim to the issue/card description) ## Discoverer block (append verbatim to the issue description/body)
``` ```
-------- --------

View File

@ -1,169 +0,0 @@
require_relative "test_helper"
class BoardEnsurerTest < Minitest::Test
include BacklogTestHelpers
def test_creates_missing_project_board_lists_and_labels
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
result = ensurer.ensure("llf-schema", project_name: "Dev")
assert result.created
assert_equal "llf-schema", result.board.name
assert_equal Backlog::BoardSpec::LISTS, result.lists_created
assert_equal Backlog::BoardSpec::LABEL_NAMES, result.labels_created
project = client.projects_store.first
assert_equal "Dev", project.name
end
def test_rerun_is_a_noop
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
result = ensurer.ensure("llf-schema", project_name: "Dev")
refute result.created
assert_empty result.lists_created
assert_empty result.labels_created
assert_equal 1, client.boards_store.size
assert_equal Backlog::BoardSpec::LISTS.size, client.lists_store.size
assert_equal Backlog::BoardSpec::LABEL_NAMES.size, client.labels_store.size
end
def test_reuses_existing_project
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("board-a", project_name: "Dev")
ensurer.ensure("board-b", project_name: "Dev")
assert_equal 1, client.projects_store.size
assert_equal 2, client.boards_store.size
end
def test_applies_owner_manager_visibility_fix_on_new_project
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
project = client.projects_store.first
assert_includes client.managers_for(project.id), "human-1"
assert_nil project.owner_project_manager_id
end
def test_semi_label_is_part_of_enforced_set
assert_includes Backlog::BoardSpec::LABEL_NAMES, "semi"
assert_includes Backlog::BoardSpec::LABEL_NAMES, "hitl"
assert_includes Backlog::BoardSpec::LABEL_NAMES, "afk-ready"
end
def test_label_color_falls_back_to_next_candidate_on_rejection
first_choice = Backlog::BoardSpec::LABEL_COLOR_CANDIDATES.fetch("P0").first
client = FakePlankaClient.new(reject_colors: [first_choice])
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
p0 = client.labels_store.find { |l| l.name == "P0" }
refute_equal first_choice, p0.color
end
def test_label_color_falls_back_when_server_rejects_with_400
first_choice = Backlog::BoardSpec::LABEL_COLOR_CANDIDATES.fetch("P3").first
client = FakePlankaClient.new(reject_colors_with_400: [first_choice])
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
p3 = client.labels_store.find { |l| l.name == "P3" }
refute_equal first_choice, p3.color
end
def test_activate_renames_archived_board_back
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
board = client.boards_store.first
client.boards.update(board.id, name: "archived--llf-schema")
result = ensurer.activate("llf-schema")
assert_equal "llf-schema", result.name
end
def test_archive_renames_active_board
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
result = ensurer.archive("llf-schema")
assert_equal "archived--llf-schema", result.name
end
def test_ensure_never_touches_archived_boards
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
ensurer.archive("llf-schema")
result = ensurer.ensure("llf-schema", project_name: "Dev")
assert result.created
assert_equal 2, client.boards_store.size
archived = client.boards_store.find { |b| b.name == "archived--llf-schema" }
refute_nil archived
end
def test_ensure_creates_an_arbitrary_project_name_on_demand
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
result = ensurer.ensure("ovh-prod", project_name: "Servers")
assert result.created
project = client.projects_store.first
assert_equal "Servers", project.name
assert_equal "Servers", result.project.name
end
def test_raises_missing_human_user_id_error_when_creating_new_project_without_one
original = ENV.delete("PLANKA_HUMAN_USER_ID")
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client)
error = assert_raises(Backlog::BoardEnsurer::MissingHumanUserIdError) do
ensurer.ensure("llf-schema", project_name: "Dev")
end
assert_includes error.message, "PLANKA_HUMAN_USER_ID"
ensure
ENV["PLANKA_HUMAN_USER_ID"] = original
end
def test_ensure_against_existing_project_does_not_raise_without_human_user_id
original = ENV.delete("PLANKA_HUMAN_USER_ID")
client = FakePlankaClient.new
seeding_ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
seeding_ensurer.ensure("board-a", project_name: "Dev")
ensurer = Backlog::BoardEnsurer.new(client: client)
result = ensurer.ensure("board-b", project_name: "Dev")
refute result.project.nil?
ensure
ENV["PLANKA_HUMAN_USER_ID"] = original
end
def test_ensure_rejects_archived_prefixed_name
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
assert_raises(ArgumentError) { ensurer.ensure("archived--x", project_name: "Dev") }
end
end

View File

@ -1,50 +0,0 @@
require_relative "test_helper"
# Card #1818501985909868001: a mistaken flag-style invocation like
# `os-backlog board-ensure --board apprise-api` must fail loudly with a
# usage error instead of silently creating a Planka board literally named
# "--board". These are CLI-contract tests (shell out to bin/os-backlog)
# because the bug is about what the positional NAME arg parsing accepts
# BEFORE any Planka client/network call is made — no PLANKA_* env vars are
# set, so if validation didn't happen first, these would instead fail on
# network auth with a different message.
class BoardNameValidationCliTest < Minitest::Test
BIN = File.expand_path("../bin/os-backlog", __dir__)
# Strip any live Planka credentials from the child's env: these tests
# assert that flag-like names are rejected BEFORE build_client ever runs,
# so no test here may be allowed to reach the network even accidentally.
def run_cli(*args)
env = { "PLANKA_BASE_URL" => "", "PLANKA_USERNAME" => "", "PLANKA_PASSWORD" => "" }
out = IO.popen(env, [RbConfig.ruby, BIN, *args], err: [:child, :out], &:read)
[out, $?.exitstatus]
end
def test_board_ensure_rejects_flag_like_name
out, status = run_cli("board-ensure", "--board", "apprise-api")
refute_equal 0, status
assert_match(/board name.*must not start with/i, out)
end
def test_activate_rejects_flag_like_name
out, status = run_cli("activate", "--board")
refute_equal 0, status
assert_match(/board name.*must not start with/i, out)
end
def test_archive_rejects_flag_like_name
out, status = run_cli("archive", "--board", "--yes")
refute_equal 0, status
assert_match(/board name.*must not start with/i, out)
end
def test_board_ensure_accepts_normal_name_and_fails_later_on_network
out, status = run_cli("board-ensure", "apprise-api")
refute_equal 0, status
refute_match(/board name.*must not start with/i, out)
end
end

View File

@ -1,135 +0,0 @@
require_relative "test_helper"
class BoardResolverTest < Minitest::Test
include BacklogTestHelpers
def setup
@client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: @client, human_user_id: "human-1")
@board = ensurer.ensure("llf-schema", project_name: "Dev").board
@writes = []
end
def resolver_with(config)
@config = config
Backlog::BoardResolver.new(
client: @client,
read_config: -> { @config },
write_config: ->(contents) { @writes << contents }
)
end
def config_with_cache(id = @board.id)
"# repo config\ntracker=planka:llf-schema\nboard_id=#{id}\nversion=3\n"
end
def test_cache_hit_uses_boards_get_and_skips_name_listing
@client.projects.define_singleton_method(:list) { raise "projects.list must not be called on a cache hit" }
resolver = resolver_with(config_with_cache)
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_empty @writes
end
def test_cache_miss_resolves_by_name_and_writes_board_id_preserving_comments
resolver = resolver_with("# repo config\ntracker=planka:llf-schema\nversion=3\n")
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_equal 1, @writes.size
assert_equal "# repo config\ntracker=planka:llf-schema\nversion=3\nboard_id=#{@board.id}\n",
@writes.last
end
def test_stale_id_404_falls_back_to_name_lookup_and_rewrites_cache
resolver = resolver_with(config_with_cache("no-such-id"))
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_includes @writes.last, "board_id=#{@board.id}\n"
assert_includes @writes.last, "# repo config"
end
def test_renamed_board_name_mismatch_falls_back_and_rewrites_cache
stale = @board
@client.boards.update(stale.id, name: "something-else")
current = @client.boards.create(stale.project_id, name: "llf-schema", position: 2)
resolver = resolver_with(config_with_cache(stale.id))
board = resolver.board_for("llf-schema")
assert_equal current.id, board.id
assert_includes @writes.last, "board_id=#{current.id}\n"
end
def test_non_planka_tracker_ignores_board_id_and_never_writes
resolver = resolver_with("tracker=forgejo:jared/cc-os\nboard_id=bogus-id\n")
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_empty @writes
end
def test_board_id_for_different_planka_board_name_is_ignored
resolver = resolver_with("tracker=planka:other-board\nboard_id=bogus-id\n")
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_empty @writes # tracker doesn't match this board name; no cache write either
end
def test_unreadable_config_degrades_to_name_lookup
resolver = Backlog::BoardResolver.new(
client: @client,
read_config: -> { raise Errno::EACCES, "config" },
write_config: ->(contents) { @writes << contents }
)
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_empty @writes
end
def test_unwritable_config_never_fails_the_command
resolver = Backlog::BoardResolver.new(
client: @client,
read_config: -> { "tracker=planka:llf-schema\n" },
write_config: ->(_) { raise Errno::EACCES, "config" }
)
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
end
def test_matching_cached_id_is_not_rewritten
resolver = resolver_with(config_with_cache)
resolver.board_for("llf-schema")
assert_empty @writes
end
def test_unknown_board_still_raises
resolver = resolver_with(nil)
error = assert_raises(RuntimeError) { resolver.board_for("nope") }
assert_match(/no board named "nope"/, error.message)
end
def test_cards_add_through_resolver_writes_cache_once
resolver = resolver_with("tracker=planka:llf-schema\n")
cards = Backlog::Cards.new(client: @client, board_resolver: resolver)
card = cards.add(board_name: "llf-schema", title: "Cache me")
assert_equal "Cache me", card.name
assert_equal 1, @writes.size
assert_includes @writes.last, "board_id=#{@board.id}\n"
end
end

View File

@ -1,223 +0,0 @@
require_relative "test_helper"
class CardsTest < Minitest::Test
include BacklogTestHelpers
def setup
@client = FakePlankaClient.new
@ensurer = Backlog::BoardEnsurer.new(client: @client, human_user_id: "human-1")
@ensurer.ensure("llf-schema", project_name: "Dev")
@cards = Backlog::Cards.new(client: @client)
end
def test_add_creates_card_at_backlog_list
card = @cards.add(board_name: "llf-schema", title: "Fix the flaky import")
backlog = @client.lists_store.find { |l| l.name == "Backlog" }
assert_equal backlog.id, card.list_id
assert_equal "Fix the flaky import", card.name
assert_equal "project", card.type
end
def test_add_positions_new_card_after_existing_backlog_cards
first = @cards.add(board_name: "llf-schema", title: "one")
second = @cards.add(board_name: "llf-schema", title: "two")
assert_operator second.position, :>, first.position
end
def test_add_with_description
card = @cards.add(board_name: "llf-schema", title: "t", description: "details here")
assert_equal "details here", card.description
end
def test_add_raises_for_missing_board
error = assert_raises(RuntimeError) { @cards.add(board_name: "nope", title: "t") }
assert_match(/no board named/, error.message)
end
def test_snapshot_shape_and_list_order
@cards.add(board_name: "llf-schema", title: "captured task")
snapshot = @cards.snapshot(board_name: "llf-schema")
assert_equal "llf-schema", snapshot["board"]
assert_equal Backlog::BoardSpec::LISTS, snapshot["lists"].map { |l| l["name"] }
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
assert_equal ["captured task"], backlog["cards"].map { |c| c["name"] }
assert_equal Backlog::BoardSpec::LABEL_NAMES.sort, snapshot["labels"].sort
end
def test_snapshot_card_emits_complete_key_set
@cards.add(board_name: "llf-schema", title: "captured task")
snapshot = @cards.snapshot(board_name: "llf-schema")
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
card = backlog["cards"].first
expected_keys = %w[
id createdAt updatedAt type position name description dueDate
isDueCompleted stopwatch commentsTotal isClosed listChangedAt
boardId listId creatorUserId prevListId coverAttachmentId
isSubscribed labels
]
assert_equal expected_keys.sort, card.keys.sort
assert card.key?("listChangedAt"), "snapshot card must carry listChangedAt for board-audit"
end
def test_snapshot_includes_card_label_names
card = @cards.add(board_name: "llf-schema", title: "labeled task")
p1 = @client.labels_store.find { |l| l.name == "P1" }
@client.attach_label(card.id, p1.id)
snapshot = @cards.snapshot(board_name: "llf-schema")
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
assert_equal ["P1"], backlog["cards"].first["labels"]
end
def test_snapshot_raises_for_missing_board
assert_raises(RuntimeError) { @cards.snapshot(board_name: "nope") }
end
def test_attach_label_by_name
card = @cards.add(board_name: "llf-schema", title: "raw card")
@cards.attach_label(board_name: "llf-schema", card_id: card.id, label: "afk-ready")
snapshot = @cards.snapshot(board_name: "llf-schema")
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
assert_equal ["afk-ready"], backlog["cards"].first["labels"]
end
def test_attach_label_raises_for_unknown_label
card = @cards.add(board_name: "llf-schema", title: "raw card")
error = assert_raises(RuntimeError) do
@cards.attach_label(board_name: "llf-schema", card_id: card.id, label: "nonexistent")
end
assert_match(/no label/, error.message)
end
def test_comment_adds_text_to_card
card = @cards.add(board_name: "llf-schema", title: "stale card")
comment = @cards.comment(card_id: card.id, text: "audit: in Doing 9 days")
assert_equal card.id, comment.card_id
assert_equal "audit: in Doing 9 days", comment.text
assert_equal 1, @client.comments.store.size
end
# -- move -----------------------------------------------------------
def list_named(name)
@client.lists_store.find { |l| l.name == name }
end
def move_card_to(list_name, card = nil)
card ||= @cards.add(board_name: "llf-schema", title: "moving card")
@client.cards.move(card.id, list_id: list_named(list_name).id, position: 65_536) unless list_name == "Backlog"
card
end
def label!(card, name)
label = @client.labels_store.find { |l| l.name == name }
@client.attach_label(card.id, label.id)
end
def test_move_backlog_to_doing
card = @cards.add(board_name: "llf-schema", title: "task")
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
assert_equal list_named("Doing").id, moved.list_id
end
def test_move_doing_to_review
card = move_card_to("Doing")
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Review")
assert_equal list_named("Review").id, moved.list_id
end
def test_move_afk_ready_doing_to_done
card = move_card_to("Doing")
label!(card, "afk-ready")
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
assert_equal list_named("Done").id, moved.list_id
end
def test_move_positions_card_at_end_of_destination_list
first = move_card_to("Doing")
second = @cards.add(board_name: "llf-schema", title: "second")
@cards.move(board_name: "llf-schema", card_id: first.id, to: "Waiting")
moved_second = @cards.move(board_name: "llf-schema", card_id: second.id, to: "Waiting")
waiting_first = @client.cards.all_for_board(list_named("Waiting").board_id)
.find { |c| c.id == first.id }
assert_operator moved_second.position, :>, waiting_first.position
end
def test_move_into_next_refused
card = @cards.add(board_name: "llf-schema", title: "task")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Next")
end
assert_match(/Next is human-curated/, error.message)
end
def test_move_out_of_next_refused
card = move_card_to("Next")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
end
assert_match(/Next is human-curated/, error.message)
end
def test_move_hitl_card_refused
card = @cards.add(board_name: "llf-schema", title: "task")
label!(card, "hitl")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
end
assert_match(/hitl cards are human-owned/, error.message)
end
def test_move_to_done_without_afk_ready_refused
card = move_card_to("Doing")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
end
assert_match(/afk-ready/, error.message)
end
def test_move_out_of_done_refused
card = move_card_to("Doing")
label!(card, "afk-ready")
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
end
assert_match(/cannot move a card out of Done/, error.message)
end
def test_move_unknown_column_refused
card = @cards.add(board_name: "llf-schema", title: "task")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Someplace")
end
assert_match(/unknown list/, error.message)
end
end

View File

@ -122,19 +122,4 @@ class ConfigTest < Minitest::Test
def test_set_on_nil_contents def test_set_on_nil_contents
assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42") assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42")
end end
def test_planka_board_prefers_explicit_board_key
config = Backlog::Config.new("board=explicit\ntracker=planka:from-tracker\n")
assert_equal "explicit", config.planka_board
end
def test_planka_board_derives_from_planka_tracker
config = Backlog::Config.new("tracker=planka:my-board\n")
assert_equal "my-board", config.planka_board
end
def test_planka_board_nil_for_non_planka_tracker
assert_nil Backlog::Config.new("tracker=forgejo:me/repo\n").planka_board
assert_nil Backlog::Config.new(nil).planka_board
end
end end

View File

@ -1,5 +1,6 @@
# /to-issues routing wiring (issue #16): spec-slice output honors the # /to-issues routing wiring (issue #16): spec-slice output honors the
# tracker key per the ADR'd boundary (git issues = specs, Planka = state). # tracker key. Post-ADR-0042, one tracker holds both state and specs — no
# second "planka default" destination exists anymore.
require_relative "test_helper" require_relative "test_helper"
class IssuesRoutingTest < Minitest::Test class IssuesRoutingTest < Minitest::Test
@ -11,16 +12,16 @@ class IssuesRoutingTest < Minitest::Test
assert_equal :git_issues, Backlog::Tracker.issues_destination("github:jared/some-repo") assert_equal :git_issues, Backlog::Tracker.issues_destination("github:jared/some-repo")
end end
def test_planka_tracker_is_the_gated_default def test_repo_tracker_routes_spec_slices_to_repo_files
assert_equal :planka_default, Backlog::Tracker.issues_destination("planka:cc-os") assert_equal :repo_files, Backlog::Tracker.issues_destination("repo:docs/issues")
end end
def test_repo_tracker_is_the_gated_default def test_absent_tracker_is_unrouted
assert_equal :planka_default, Backlog::Tracker.issues_destination("repo:docs/issues") assert_equal :unrouted, Backlog::Tracker.issues_destination(nil)
end end
def test_absent_tracker_is_the_gated_default def test_retired_planka_tracker_is_unrouted
assert_equal :planka_default, Backlog::Tracker.issues_destination(nil) assert_equal :unrouted, Backlog::Tracker.issues_destination("planka:cc-os")
end end
def test_invalid_tracker_is_unrouted def test_invalid_tracker_is_unrouted

View File

@ -0,0 +1,181 @@
require_relative "test_helper"
require "tmpdir"
class FakeIssuesRunner
attr_reader :calls
def initialize(responses)
@responses = responses.dup
@calls = []
end
def capture(*cmd)
@calls << cmd
@responses.shift || ["", true]
end
end
class IssuesCreateTest < Minitest::Test
def test_forgejo_create_shells_out_to_tea_with_priority_label
runner = FakeIssuesRunner.new([["Created issue #7: https://forgejo.example/jared/cc-os/issues/7\n", true]])
issues = Backlog::Issues.new(runner: runner)
result = issues.create(tracker: "forgejo:jared/cc-os", title: "Fix the thing", body: "context here", priority: "P1")
assert_equal 7, result[:number]
assert_equal "https://forgejo.example/jared/cc-os/issues/7", result[:url]
cmd = runner.calls.first
assert_equal "tea", cmd[0]
assert_includes cmd, "--repo"
assert_includes cmd, "jared/cc-os"
assert_includes cmd, "--title"
assert_includes cmd, "Fix the thing"
assert_includes cmd, "--labels"
assert_includes cmd, "P1"
end
def test_forgejo_create_omits_labels_flag_without_priority
runner = FakeIssuesRunner.new([["Created issue #1: https://x/issues/1\n", true]])
Backlog::Issues.new(runner: runner).create(tracker: "forgejo:jared/cc-os", title: "T", body: "B")
refute_includes runner.calls.first, "--labels"
end
def test_github_create_shells_out_to_gh
runner = FakeIssuesRunner.new([["https://github.com/acme/widget/issues/42\n", true]])
issues = Backlog::Issues.new(runner: runner)
result = issues.create(tracker: "github:acme/widget", title: "Fix", body: "ctx", priority: "P0")
assert_equal 42, result[:number]
assert_equal "https://github.com/acme/widget/issues/42", result[:url]
cmd = runner.calls.first
assert_equal "gh", cmd[0]
assert_includes cmd, "--label"
assert_includes cmd, "P0"
end
def test_create_raises_command_failed_on_nonzero_exit
runner = FakeIssuesRunner.new([["auth error", false]])
issues = Backlog::Issues.new(runner: runner)
assert_raises(Backlog::Issues::CommandFailed) do
issues.create(tracker: "forgejo:jared/cc-os", title: "T", body: "B")
end
end
def test_create_rejects_invalid_priority
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]))
assert_raises(ArgumentError) do
issues.create(tracker: "forgejo:jared/cc-os", title: "T", body: "B", priority: "P9")
end
end
def test_create_can_never_apply_the_next_label
# There is no `labels:`/state-label parameter on #create at all — the
# only label surface is `priority:`, restricted to P0-P3. This test
# documents that constraint as executable spec.
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]))
assert_raises(ArgumentError) do
issues.create(tracker: "forgejo:jared/cc-os", title: "T", body: "B", priority: "next")
end
end
def test_repo_create_writes_a_frontmattered_markdown_file
Dir.mktmpdir do |dir|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir,
clock: -> { Time.new(2026, 7, 16) })
result = issues.create(tracker: "repo:docs/issues", title: "Fix the Thing!", body: "details here",
priority: "P2")
assert_equal 1, result[:number]
assert_equal File.join(dir, "docs/issues/001-fix-the-thing.md"), result[:path]
contents = File.read(result[:path])
assert_includes contents, "number: 1"
assert_includes contents, "title: Fix the Thing!"
assert_includes contents, "labels: P2"
assert_includes contents, "state: open"
assert_includes contents, "created: 2026-07-16"
assert_includes contents, "details here"
end
end
def test_repo_create_numbers_sequentially
Dir.mktmpdir do |dir|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir)
issues.create(tracker: "repo:docs/issues", title: "First", body: "b")
second = issues.create(tracker: "repo:docs/issues", title: "Second", body: "b")
assert_equal 2, second[:number]
end
end
end
class IssuesListTest < Minitest::Test
def test_forgejo_list_groups_by_state_label
tea_json = JSON.generate([
{ "index" => 1, "title" => "Next thing", "labels" => "P1,next", "assignees" => "jared" },
{ "index" => 2, "title" => "Blocked thing", "labels" => "waiting", "assignees" => "" },
{ "index" => 3, "title" => "Shipped semi", "labels" => "review", "assignees" => "" },
{ "index" => 4, "title" => "Ad hoc", "labels" => "P3", "assignees" => "" }
])
runner = FakeIssuesRunner.new([[tea_json, true]])
grouped = Backlog::Issues.new(runner: runner).list(tracker: "forgejo:jared/cc-os")
assert_equal [1], grouped[:next].map { |i| i[:number] }
assert_equal [2], grouped[:waiting].map { |i| i[:number] }
assert_equal [3], grouped[:review].map { |i| i[:number] }
assert_equal [4], grouped[:other].map { |i| i[:number] }
assert_equal "jared", grouped[:next].first[:assignee]
cmd = runner.calls.first
assert_equal "tea", cmd[0]
end
def test_github_list_groups_by_state_label
gh_json = JSON.generate([
{ "number" => 5, "title" => "Ship it", "labels" => [{ "name" => "review" }],
"assignees" => [{ "login" => "jared" }] }
])
runner = FakeIssuesRunner.new([[gh_json, true]])
grouped = Backlog::Issues.new(runner: runner).list(tracker: "github:acme/widget")
assert_equal [5], grouped[:review].map { |i| i[:number] }
assert_equal "jared", grouped[:review].first[:assignee]
assert_equal "gh", runner.calls.first[0]
end
def test_list_raises_command_failed_on_nonzero_exit
runner = FakeIssuesRunner.new([["boom", false]])
assert_raises(Backlog::Issues::CommandFailed) do
Backlog::Issues.new(runner: runner).list(tracker: "forgejo:jared/cc-os")
end
end
def test_repo_list_reads_open_issue_files_and_groups_them
Dir.mktmpdir do |dir|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir)
issues.create(tracker: "repo:docs/issues", title: "Waiting one", body: "b", priority: "P1")
waiting_path = Dir.glob(File.join(dir, "docs/issues/*.md")).first
File.write(waiting_path, File.read(waiting_path).sub("labels: P1", "labels: P1,waiting"))
grouped = issues.list(tracker: "repo:docs/issues")
assert_equal ["Waiting one"], grouped[:waiting].map { |i| i[:title] }
end
end
def test_repo_list_is_empty_when_directory_absent
Dir.mktmpdir do |dir|
grouped = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir)
.list(tracker: "repo:docs/issues")
assert_equal [], grouped[:other]
end
end
def test_unroutable_tracker_raises
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]))
assert_raises(ArgumentError) { issues.list(tracker: "planka:board") }
assert_raises(ArgumentError) { issues.create(tracker: nil, title: "T", body: "B") }
end
end

View File

@ -92,18 +92,18 @@ class ProjectsCliContractTest < Minitest::Test
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` } out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
assert_equal({ "projects" => {} }, JSON.parse(out)) assert_equal({ "projects" => {} }, JSON.parse(out))
Dir.chdir(repo) { `#{env} ruby #{BIN} config-write planka:demo 2>/dev/null` } Dir.chdir(repo) { `#{env} ruby #{BIN} config-write forgejo:jared/demo 2>/dev/null` }
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` } out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
projects = JSON.parse(out)["projects"] projects = JSON.parse(out)["projects"]
assert_equal 1, projects.size assert_equal 1, projects.size
row = projects.values.first row = projects.values.first
assert_equal "planka:demo", row["tracker"] assert_equal "forgejo:jared/demo", row["tracker"]
assert_equal File.basename(File.realpath(repo)), row["name"] assert_equal File.basename(File.realpath(repo)), row["name"]
filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` } filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` }
assert_equal({}, JSON.parse(filtered)["projects"]) assert_equal({}, JSON.parse(filtered)["projects"])
by_tracker = Dir.chdir(repo) { `#{env} ruby #{BIN} projects planka:demo 2>/dev/null` } by_tracker = Dir.chdir(repo) { `#{env} ruby #{BIN} projects forgejo:jared/demo 2>/dev/null` }
assert_equal 1, JSON.parse(by_tracker)["projects"].size assert_equal 1, JSON.parse(by_tracker)["projects"].size
end end
end end
@ -121,7 +121,7 @@ class ProjectsCliContractTest < Minitest::Test
FileUtils.mkdir_p(subdir) FileUtils.mkdir_p(subdir)
env = "CC_OS_HOME=#{home} HOME=#{home}" env = "CC_OS_HOME=#{home} HOME=#{home}"
Dir.chdir(subdir) { `#{env} ruby #{BIN} config-write planka:child 2>/dev/null` } Dir.chdir(subdir) { `#{env} ruby #{BIN} config-write forgejo:jared/child 2>/dev/null` }
projects = Dir.chdir(subdir) { JSON.parse(`#{env} ruby #{BIN} projects 2>/dev/null`)["projects"] } projects = Dir.chdir(subdir) { JSON.parse(`#{env} ruby #{BIN} projects 2>/dev/null`)["projects"] }
assert_equal [File.realpath(subdir)], projects.keys assert_equal [File.realpath(subdir)], projects.keys

View File

@ -1,121 +1,40 @@
require_relative "test_helper" require_relative "test_helper"
class ResolverTest < Minitest::Test class ResolverTest < Minitest::Test
def test_configured_project_uses_configured_board def test_configured_tracker_is_used
resolver = Backlog::Resolver.new( resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo", repo_path: "/home/jared/dev/some-repo",
config_contents: "board: llf-schema\n", config_contents: "tracker=forgejo:jared/some-repo\n"
boards: %w[llf-schema other-board]
) )
assert_equal "use llf-schema", resolver.resolve_string assert_equal "use forgejo:jared/some-repo", resolver.resolve_string
end end
def test_unconfigured_dev_repo_uses_board_named_after_repo def test_unconfigured_project_stops_and_discusses
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/llf-schema",
config_contents: nil,
boards: %w[llf-schema]
)
assert_equal "use llf-schema", resolver.resolve_string
end
def test_unconfigured_client_repo_uses_board_named_after_repo
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/clients/acme-co",
config_contents: nil,
boards: %w[acme-co]
)
assert_equal "use acme-co", resolver.resolve_string
end
def test_matching_archived_board_activates
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/old-project",
config_contents: nil,
boards: %w[archived--old-project]
)
assert_equal "activate archived--old-project", resolver.resolve_string
end
def test_configured_archived_board_activates
resolver = Backlog::Resolver.new( resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo", repo_path: "/home/jared/dev/some-repo",
config_contents: "board: old-project\n", config_contents: nil
boards: %w[archived--old-project]
) )
assert_equal "activate archived--old-project", resolver.resolve_string assert_equal "stop-and-discuss", resolver.resolve_string
end end
def test_no_match_stops_and_discusses def test_blank_config_contents_stops_and_discusses
resolver = Backlog::Resolver.new( resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/brand-new-repo", repo_path: "/home/jared/dev/some-repo",
config_contents: nil, config_contents: ""
)
assert_equal "stop-and-discuss", resolver.resolve_string
end
def test_tolerates_legacy_boards_keyword_for_backward_compatibility
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo",
config_contents: "tracker=github:jared/some-repo\n",
boards: %w[unrelated-board] boards: %w[unrelated-board]
) )
assert_equal "stop-and-discuss", resolver.resolve_string assert_equal "use github:jared/some-repo", resolver.resolve_string
end
def test_path_outside_dev_and_clients_is_ambiguous
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/scratch/some-repo",
config_contents: nil,
boards: %w[some-repo]
)
assert_equal "stop-and-discuss", resolver.resolve_string
end
def test_configured_board_with_no_match_stops_and_discusses
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo",
config_contents: "board: nonexistent\n",
boards: %w[llf-schema]
)
assert_equal "stop-and-discuss", resolver.resolve_string
end
def test_empty_config_contents_is_treated_as_unconfigured
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/llf-schema",
config_contents: "",
boards: %w[llf-schema]
)
assert_equal "use llf-schema", resolver.resolve_string
end
def test_unconfigured_servers_repo_uses_board_named_after_repo
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/servers/proxmox-ubuntu",
config_contents: nil,
boards: %w[proxmox-ubuntu]
)
assert_equal "use proxmox-ubuntu", resolver.resolve_string
end
def test_planka_tracker_key_resolves_to_its_board
resolver = Backlog::Resolver.new(
repo_path: "/somewhere/unrecognized/repo",
config_contents: "tracker=planka:my-board\n",
boards: %w[my-board]
)
assert_equal "use my-board", resolver.resolve_string
end
def test_project_for_returns_dev_clients_servers_or_nil
assert_equal "Dev", Backlog::Resolver.project_for("/home/jared/dev/some-repo")
assert_equal "Clients", Backlog::Resolver.project_for("/home/jared/clients/acme-co")
assert_equal "Servers", Backlog::Resolver.project_for("/home/jared/servers/ovh-prod")
assert_equal "Servers", Backlog::Resolver.project_for("/home/jared/servers")
assert_nil Backlog::Resolver.project_for("/home/jared/scratch/some-repo")
end end
end end

View File

@ -1,234 +1,2 @@
require "minitest/autorun" require "minitest/autorun"
require "planka_api"
require_relative "../lib/backlog" require_relative "../lib/backlog"
module BacklogTestHelpers
# A hand-rolled fake of the subset of Planka::Client's resource surface
# BoardEnsurer/Cards touches: projects, boards, lists, labels, cards,
# comments. In-memory only, no network. Returns the real planka-api 0.2.0
# typed records (Planka::Types::*) so these tests exercise the same
# accessor shapes production code does.
class FakePlankaClient
attr_reader :projects, :boards, :lists, :labels, :cards, :comments
def initialize(reject_colors: [], reject_colors_with_400: [])
@next_id = 0
@projects_store = []
@boards_store = []
@lists_store = []
@labels_store = []
@cards_store = []
@card_labels_store = []
@comments_store = []
@projects = ProjectsResource.new(self)
@boards = BoardsResource.new(self)
@lists = ListsResource.new(self)
@labels = LabelsResource.new(self, reject_colors: reject_colors,
reject_colors_with_400: reject_colors_with_400)
@cards = CardsResource.new(self)
@comments = CommentsResource.new(self)
end
def next_id = (@next_id += 1).to_s
attr_reader :projects_store, :boards_store, :lists_store, :labels_store,
:cards_store, :card_labels_store, :comments_store
# Project managers aren't part of Types::Project (they're their own
# join record, Types::ProjectManager) — tracked here so tests can
# assert on the visibility-fix quirk (BoardEnsurer#fix_visibility).
def managers_for(project_id)
@managers_store ||= {}
@managers_store[project_id] ||= []
end
def attach_label(card_id, label_id)
record = Planka::Types::CardLabel.new(
id: next_id, created_at: nil, updated_at: nil, card_id: card_id, label_id: label_id
)
@card_labels_store << record
record
end
class ProjectsResource
def initialize(client) = @client = client
def list = @client.projects_store.dup
def create(attrs)
project = Planka::Types::Project.new(
id: @client.next_id, created_at: nil, updated_at: nil, name: attrs[:name],
description: nil, background_type: nil, background_gradient: nil, is_hidden: nil,
owner_project_manager_id: @client.next_id, background_image_id: nil, is_favorite: nil
)
@client.projects_store << project
project
end
def update(id, attrs)
project = @client.projects_store.find { |p| p.id == id }
idx = @client.projects_store.index(project)
updated = project
updated = updated.with(owner_project_manager_id: attrs[:ownerProjectManagerId]) if attrs.key?(:ownerProjectManagerId)
@client.projects_store[idx] = updated
updated
end
def add_manager(project_id, user_id)
@client.managers_for(project_id) << user_id
Planka::Types::ProjectManager.new(id: @client.next_id, created_at: nil, updated_at: nil,
project_id: project_id, user_id: user_id)
end
end
class BoardsResource
def initialize(client) = @client = client
def list(project_id)
@client.boards_store.select { |b| b.project_id == project_id }
end
def create(project_id, attrs)
board = Planka::Types::Board.new(
id: @client.next_id, created_at: nil, updated_at: nil, position: attrs[:position],
name: attrs[:name], default_view: nil, default_card_type: nil,
limit_card_types_to_default_one: nil, always_display_card_creator: nil,
display_card_ages: nil, expand_task_lists_by_default: nil,
project_id: project_id, is_subscribed: nil
)
@client.boards_store << board
board
end
def update(id, attrs)
board = @client.boards_store.find { |b| b.id == id }
idx = @client.boards_store.index(board)
updated = board
updated = updated.with(name: attrs[:name]) if attrs.key?(:name)
@client.boards_store[idx] = updated
updated
end
def get(id)
board = @client.boards_store.find { |b| b.id == id }
unless board
raise Planka::NotFoundError.new(status: 404,
body: { "message" => "board #{id} not found" })
end
Planka::Types::BoardDetail.new(
board: board,
lists: @client.lists.list(id),
labels: @client.labels.list(id),
cards: @client.cards.all_for_board(id),
card_labels: @client.card_labels_store.dup,
board_memberships: []
)
end
end
class ListsResource
def initialize(client) = @client = client
def list(board_id)
@client.lists_store.select { |l| l.board_id == board_id }
end
def create(board_id, attrs)
raise ArgumentError, "list create requires type" unless attrs[:type]
list = Planka::Types::List.new(
id: @client.next_id, created_at: nil, updated_at: nil, type: attrs[:type],
position: attrs[:position], name: attrs[:name], color: nil, board_id: board_id
)
@client.lists_store << list
list
end
end
class CardsResource
def initialize(client) = @client = client
def create(list_id, attrs)
raise ArgumentError, "card create requires type" unless attrs[:type]
list = @client.lists_store.find { |l| l.id == list_id }
card = Planka::Types::Card.new(
id: @client.next_id, created_at: nil, updated_at: nil, type: attrs[:type],
position: attrs[:position], name: attrs[:name], description: attrs[:description],
due_date: nil, is_due_completed: nil, stopwatch: nil, comments_total: nil,
is_closed: nil, list_changed_at: nil, board_id: list.board_id, list_id: list_id,
creator_user_id: nil, prev_list_id: nil, cover_attachment_id: nil, is_subscribed: nil
)
@client.cards_store << card
card
end
def all_for_board(board_id)
@client.cards_store.select { |c| c.board_id == board_id }
end
def move(id, list_id:, position:, board_id: nil)
card = @client.cards_store.find { |c| c.id == id }
idx = @client.cards_store.index(card)
updated = card.with(list_id: list_id, position: position)
updated = updated.with(board_id: board_id) if board_id
@client.cards_store[idx] = updated
updated
end
end
class LabelsResource
def initialize(client, reject_colors: [], reject_colors_with_400: [])
@client = client
@reject_colors = reject_colors
@reject_colors_with_400 = reject_colors_with_400
end
def list(board_id)
@client.labels_store.select { |l| l.board_id == board_id }
end
def create(board_id, attrs)
if @reject_colors_with_400.include?(attrs[:color])
# Planka 2.x rejects unknown color names as E_MISSING_OR_INVALID_PARAMS
raise Planka::BadRequestError.new(status: 400,
body: { "message" => "color #{attrs[:color]} rejected" })
end
if @reject_colors.include?(attrs[:color])
raise Planka::ValidationError.new(status: 422,
body: { "message" => "color #{attrs[:color]} rejected" })
end
label = Planka::Types::Label.new(
id: @client.next_id, created_at: nil, updated_at: nil, position: attrs[:position],
name: attrs[:name], color: attrs[:color], board_id: board_id
)
@client.labels_store << label
label
end
def attach_to_card(card_id, label_id)
@client.attach_label(card_id, label_id)
end
end
class CommentsResource
def initialize(client)
@client = client
end
def store = @client.comments_store
def create(card_id, attrs)
comment = Planka::Types::Comment.new(
id: @client.next_id, created_at: nil, updated_at: nil, text: attrs[:text],
card_id: card_id, user_id: nil
)
@client.comments_store << comment
comment
end
end
end
end

View File

@ -1,9 +1,9 @@
require_relative "test_helper" require_relative "test_helper"
class TrackerTest < Minitest::Test class TrackerTest < Minitest::Test
def test_accepts_planka_format def test_rejects_planka_format
assert Backlog::Tracker.valid?("planka:llf-schema") refute Backlog::Tracker.valid?("planka:llf-schema")
assert_equal :planka, Backlog::Tracker.kind("planka:llf-schema") assert_nil Backlog::Tracker.kind("planka:llf-schema")
end end
def test_accepts_forgejo_format def test_accepts_forgejo_format
@ -32,6 +32,21 @@ class TrackerTest < Minitest::Test
refute Backlog::Tracker.valid?("jira:PROJ-123") refute Backlog::Tracker.valid?("jira:PROJ-123")
end end
def test_issues_destination_routes_git_hosts_to_git_issues
assert_equal :git_issues, Backlog::Tracker.issues_destination("forgejo:jared/cc-os")
assert_equal :git_issues, Backlog::Tracker.issues_destination("github:jared/cc-os")
end
def test_issues_destination_routes_repo_to_repo_files
assert_equal :repo_files, Backlog::Tracker.issues_destination("repo:docs/issues")
end
def test_issues_destination_is_unrouted_for_planka_nil_or_invalid
assert_equal :unrouted, Backlog::Tracker.issues_destination("planka:cc-os")
assert_equal :unrouted, Backlog::Tracker.issues_destination(nil)
assert_equal :unrouted, Backlog::Tracker.issues_destination("jira:whatever")
end
def test_parse_remote_detects_forgejo_ssh_url def test_parse_remote_detects_forgejo_ssh_url
output = "origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (fetch)\n" \ output = "origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (fetch)\n" \
"origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (push)\n" "origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (push)\n"

View File

@ -1,86 +0,0 @@
require_relative "test_helper"
class TriageCheckTest < Minitest::Test
def test_card_missing_both_labels_needs_triage
assert Backlog::TriageCheck.needs_triage?([])
end
def test_card_missing_priority_label_needs_triage
assert Backlog::TriageCheck.needs_triage?(["semi"])
end
def test_card_missing_autonomy_label_needs_triage
assert Backlog::TriageCheck.needs_triage?(["P1"])
end
def test_fully_labeled_card_does_not_need_triage
refute Backlog::TriageCheck.needs_triage?(%w[P1 semi])
end
def test_note_for_no_unlabeled_cards_is_nil
assert_nil Backlog::TriageCheck.note_for("cc-os", [%w[P1 semi], %w[P0 hitl]])
end
def test_note_for_counts_only_unlabeled_cards
labels = [%w[P1 semi], [], ["P2"]]
note = Backlog::TriageCheck.note_for("cc-os", labels)
refute_nil note
assert_includes note, "2 unlabeled Backlog cards on board cc-os"
assert_includes note, "card-triage"
assert_includes note, "haiku"
assert_includes note, "background"
end
def test_note_for_singular_card_uses_singular_wording
note = Backlog::TriageCheck.note_for("cc-os", [[]])
assert_includes note, "1 unlabeled Backlog card on board cc-os"
refute_includes note, "1 unlabeled Backlog cards"
end
def test_board_name_for_subdir_config_overrides_root_config
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/servers/proxmox", root: "/home/jared/servers",
cwd_config: "tracker=planka:proxmox-board\n", root_config: "board=servers\n"
)
assert_equal "proxmox-board", name
end
def test_board_name_for_falls_back_to_root_config
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/dev/cc-os/plugins", root: "/home/jared/dev/cc-os",
cwd_config: nil, root_config: "tracker=planka:cc-os\n"
)
assert_equal "cc-os", name
end
def test_board_name_for_subdir_config_without_board_names_the_subdir
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/servers/proxmox", root: "/home/jared/servers",
cwd_config: "version=3\n", root_config: nil
)
assert_equal "proxmox", name
end
def test_board_name_for_no_config_uses_heuristic_repo_basename
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/dev/cc-os", root: "/home/jared/dev/cc-os",
cwd_config: nil, root_config: nil
)
assert_equal "cc-os", name
end
def test_board_name_for_unrecognized_path_without_config_is_nil
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/scratch/x", root: "/home/jared/scratch/x",
cwd_config: nil, root_config: nil
)
assert_nil name
end
end

View File

@ -1,5 +1,5 @@
--- ---
description: Run the biweekly orchestration IRL audit - deterministic transcript stats precompute, then auditor fan-out judging flagged regions against the shipped `prompts/session-start/10-orchestration.md` rules, then a tiered tune-up report. Works from any directory. Trigger via the os-status due nudge or the Planka recurrence card. description: Run the biweekly orchestration IRL audit - deterministic transcript stats precompute, then auditor fan-out judging flagged regions against the shipped `prompts/session-start/10-orchestration.md` rules, then a tiered tune-up report. Works from any directory. Trigger via the os-status due nudge or the recurring issue jared/ops#1 on Forgejo.
--- ---
# Audit production sessions against the shipped orchestration rules # Audit production sessions against the shipped orchestration rules
@ -65,5 +65,6 @@ later: edit
`~/dev/cc-os/plugins/os-context/prompts/session-start/10-orchestration.md`, run `~/dev/cc-os/bin/refresh-plugins`, `~/dev/cc-os/plugins/os-context/prompts/session-start/10-orchestration.md`, run `~/dev/cc-os/bin/refresh-plugins`,
and record the change in `~/dev/cc-os/docs/implementation-status.md`. and record the change in `~/dev/cc-os/docs/implementation-status.md`.
Finally, if a Planka card for this audit exists on the backlog board, move it to Finally, comment the audit date + report location on the recurring issue
Review (never to Done). `jared/ops#1` (Forgejo, via `tea`) and leave it open — recurring-convention
issues are never closed by the AI (ADR-0042).

View File

@ -198,10 +198,16 @@ def orchestration_audit_due(ctx: Ctx) -> CheckResult:
TRACKER_ABSENT_NOTE = ( TRACKER_ABSENT_NOTE = (
"No 'tracker' key set in .cc-os/config — this project's issue tracker is" "No 'tracker' key set in .cc-os/config — this project's issue tracker is"
" unregistered. Run /os-backlog:route to pick a tracker and" " unregistered. Run /os-backlog:route to pick a tracker and"
" record it (planka:<board> | forgejo:<owner>/<repo> |" " record it (forgejo:<owner>/<repo> |"
" github:<owner>/<repo> | repo:<path>)." " github:<owner>/<repo> | repo:<path>)."
) )
TRACKER_PLANKA_RETIRED_NOTE = (
"planka: trackers are retired (ADR-0042 — git issues are the single"
" tracker for state and specs). Run /os-backlog:route to re-route to"
" forgejo:/github:/repo:."
)
# --- config version (ADR-026) --------------------------------------------- # --- config version (ADR-026) ---------------------------------------------
CURRENT_CONFIG_VERSION = 1 CURRENT_CONFIG_VERSION = 1
@ -238,7 +244,6 @@ def project_graph_present(ctx: Ctx) -> CheckResult:
) )
_TRACKER_PATTERNS = { _TRACKER_PATTERNS = {
"planka": re.compile(r"^[^/\s]+$"),
"forgejo": re.compile(r"^[^/\s]+/[^/\s]+$"), "forgejo": re.compile(r"^[^/\s]+/[^/\s]+$"),
"github": re.compile(r"^[^/\s]+/[^/\s]+$"), "github": re.compile(r"^[^/\s]+/[^/\s]+$"),
"repo": re.compile(r"^\S+$"), "repo": re.compile(r"^\S+$"),
@ -248,17 +253,20 @@ _TRACKER_PATTERNS = {
def tracker_configured(ctx: Ctx) -> CheckResult: def tracker_configured(ctx: Ctx) -> CheckResult:
"""Read the 'tracker' key from .cc-os/config. Present + valid -> silent """Read the 'tracker' key from .cc-os/config. Present + valid -> silent
(configured projects cost nothing at session start); present + malformed (configured projects cost nothing at session start); present + malformed
-> one-line warn, never a crash; absent -> daily-snoozed nudge toward the -> one-line warn, never a crash; a retired `planka:` value -> one-line
os-backlog routing skill.""" warn naming ADR-0042; absent -> daily-snoozed nudge toward the
os-backlog routing skill. Always fail-soft: never blocks the session."""
raw = str(ctx.config.get("tracker", "")).strip() raw = str(ctx.config.get("tracker", "")).strip()
if not raw: if not raw:
return warn(TRACKER_ABSENT_NOTE) return warn(TRACKER_ABSENT_NOTE)
scheme, sep, rest = raw.partition(":") scheme, sep, rest = raw.partition(":")
if scheme == "planka":
return warn(TRACKER_PLANKA_RETIRED_NOTE)
pattern = _TRACKER_PATTERNS.get(scheme) pattern = _TRACKER_PATTERNS.get(scheme)
if not sep or not pattern or not pattern.match(rest): if not sep or not pattern or not pattern.match(rest):
return warn( return warn(
f".cc-os/config sets tracker = '{raw}', which does not match the" f".cc-os/config sets tracker = '{raw}', which does not match the"
" expected grammar (planka:<board> | forgejo:<owner>/<repo> |" " expected grammar (forgejo:<owner>/<repo> |"
" github:<owner>/<repo> | repo:<path>) — fix the value." " github:<owner>/<repo> | repo:<path>) — fix the value."
) )
return OK return OK

View File

@ -50,13 +50,14 @@ like a `.gitignore` addition or a config stamp.
c. **`project-graph-present`** → invoke `/os-vault:onboard-project`. c. **`project-graph-present`** → invoke `/os-vault:onboard-project`.
d. **`tracker-configured`** → invoke `/os-backlog:route` directly and let it run d. **`tracker-configured`** → invoke `/os-backlog:route` directly and let it run
its own inspect → propose → confirm flow. Do NOT pre-ask the user "forgejo or its own inspect → propose → confirm flow. Per ADR-0042, one tracker holds both
planka?" — that framing is wrong: the boundary rule uses BOTH (Planka = state, task state and durable specs — there is no second surface to weigh, so don't
git issues = specs; a project routinely keeps its git-issue spec chains AND a pre-ask the user "forgejo or github or repo?" either; the route skill owns that
`planka:` tracker key for new ad hoc state). The route skill owns that decision conversation and its gates. A `planka:` value found in an existing
decision conversation and its gates. If `/os-backlog:route` is not installed, `.cc-os/config` is rejected fail-soft (one line citing ADR-0042) by both
tell the user and skip — do not fabricate a `.cc-os/config` tracker value `config-write` and this check — the fix is the same `/os-backlog:route` call.
yourself. If `/os-backlog:route` is not installed, tell the user and skip — do not
fabricate a `.cc-os/config` tracker value yourself.
e. **`subagent-model-env-override`** → **human gate, and typically out of scope e. **`subagent-model-env-override`** → **human gate, and typically out of scope
for a project-level fix.** This is an environment/settings.json condition, not for a project-level fix.** This is an environment/settings.json condition, not

View File

@ -24,6 +24,7 @@ from checks import ( # noqa: E402
ENV_VAR, ENV_VAR,
PRESENT_NOTE, PRESENT_NOTE,
TRACKER_ABSENT_NOTE, TRACKER_ABSENT_NOTE,
TRACKER_PLANKA_RETIRED_NOTE,
Check, Check,
CheckResult, CheckResult,
Ctx, Ctx,
@ -250,11 +251,14 @@ class TrackerCheckTest(unittest.TestCase):
self.assertEqual(CheckResult("warn", TRACKER_ABSENT_NOTE), result) self.assertEqual(CheckResult("warn", TRACKER_ABSENT_NOTE), result)
self.assertIn("/os-backlog:route", result.message) self.assertIn("/os-backlog:route", result.message)
def test_planka_value_is_ok(self): def test_planka_value_is_rejected_citing_adr_0042(self):
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
root = git_project(tmp) root = git_project(tmp)
ctx = make_ctx(project_root=root, config={"tracker": "planka:my-board"}) ctx = make_ctx(project_root=root, config={"tracker": "planka:my-board"})
self.assertEqual("ok", tracker_configured(ctx).status) result = tracker_configured(ctx)
self.assertEqual(CheckResult("warn", TRACKER_PLANKA_RETIRED_NOTE), result)
self.assertIn("ADR-0042", result.message)
self.assertIn("/os-backlog:route", result.message)
def test_forgejo_value_is_ok(self): def test_forgejo_value_is_ok(self):
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
@ -285,7 +289,7 @@ class TrackerCheckTest(unittest.TestCase):
def test_missing_separator_warns_without_crashing(self): def test_missing_separator_warns_without_crashing(self):
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
root = git_project(tmp) root = git_project(tmp)
ctx = make_ctx(project_root=root, config={"tracker": "planka-my-board"}) ctx = make_ctx(project_root=root, config={"tracker": "bogus-my-board"})
result = tracker_configured(ctx) result = tracker_configured(ctx)
self.assertEqual("warn", result.status) self.assertEqual("warn", result.status)
@ -417,10 +421,10 @@ class WriteConfigValueTest(unittest.TestCase):
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
(root / ".cc-os").mkdir() (root / ".cc-os").mkdir()
(root / ".cc-os" / "config").write_text("hub = my-hub\ntracker = planka:board\n") (root / ".cc-os" / "config").write_text("hub = my-hub\ntracker = forgejo:jared/x\n")
write_config_value(root, "version", "1") write_config_value(root, "version", "1")
self.assertEqual( self.assertEqual(
{"hub": "my-hub", "tracker": "planka:board", "version": "1"}, {"hub": "my-hub", "tracker": "forgejo:jared/x", "version": "1"},
read_config(root), read_config(root),
) )