diff --git a/CLAUDE.md b/CLAUDE.md index d44fe99..f48f712 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 pilots — ADR-020 amendment / `docs/adr/0020`); remaining rollout: pilot projects one at a 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) — vault graph at `~/Documents/SecondBrain/graphify-out/`, per-project graphs at `/graphify-out/` (gitignored); both disposable/rebuildable. diff --git a/docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md b/docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md index f013851..acf5d82 100644 --- a/docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md +++ b/docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md @@ -1,9 +1,9 @@ --- id: "0033" date: 2026-07-13 -status: Accepted +status: Superseded 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-components: [os-backlog, to-issues] --- diff --git a/docs/adr/0042-retire-planka-git-issues-are-the-single-tracker-for-state-and-specs.md b/docs/adr/0042-retire-planka-git-issues-are-the-single-tracker-for-state-and-specs.md new file mode 100644 index 0000000..ce096a4 --- /dev/null +++ b/docs/adr/0042-retire-planka-git-issues-are-the-single-tracker-for-state-and-specs.md @@ -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:) become the single tracker for BOTH task state and durable specs. The tracker key grammar in .cc-os/config drops planka:; 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3056fef..2e9df28 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | | 0031 | [os-context prompt-composer plugin supersedes single-file os-orchestration](0031-os-context-prompt-composer-plugin-supersedes-single-file-os-orchestration.md) | Accepted | 2026-07-13 | | 0032 | [AI-maintained knowledge files are indexes with progressive disclosure](0032-ai-maintained-knowledge-files-are-indexes-with-progressive-disclosure.md) | Accepted | 2026-07-13 | -| 0033 | [Tracker routing: Planka is state, git issues are specs](0033-tracker-routing-planka-is-state-git-issues-are-specs.md) | 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 | | 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--](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 | | 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 | +| 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 | diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 405b967..f5516ef 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -76,6 +76,14 @@ entry is in the leaf file named. - **2026-07-15** — os-doc-hygiene lifecycle-aware hygiene shipped (ADR-0038–0041): rulebook 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. +- **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); 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 A/B/C harnesses; cc-os retrofit done) → [implementation-status/os-adr.md](implementation-status/os-adr.md) -- **os-backlog** — Planka process surface: board lifecycle, capture/list/route skills, - card CLI with column-ownership rules, triage/audit agents → +- **os-backlog** — git-issues backlog surface (ADR-0042 retired Planka 2026-07-16): + 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) - **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 diff --git a/docs/implementation-status/os-backlog.md b/docs/implementation-status/os-backlog.md index 2cbdf14..c332c8c 100644 --- a/docs/implementation-status/os-backlog.md +++ b/docs/implementation-status/os-backlog.md @@ -3,42 +3,41 @@ _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); -symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9 (state pointer: Planka -card `1816234194716592051`), slices #10–#17. Decision records: ADR-0023 (own plugin, not an -os-vault extension), ADR-0025 (standard Ruby plugin structure), ADR-0029 (card-move + -column ownership). +symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9, slices #10–#17. +Decision records: ADR-0023 (own plugin, not an os-vault extension), ADR-0025 (standard Ruby +plugin structure), ADR-0029 (column ownership — subject removed by ADR-0042), **ADR-0042 +(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 - (Planka = task state by default; git issues = implementation specs only), mid-session - capture, pull-only listing, board lifecycle, and the three-value autonomy convention - (`hitl`/`semi`/`afk-ready`; amended per ADR-0029: afk-ready ships straight to Done, semi - stops at Review). -- Lib (Ruby, os-adr `lib/`+`bin/` pattern, installed planka-api gem only — no source - coupling): `board_spec.rb` (uniform lists/labels contract), `board_ensurer.rb` - (idempotent create/repair; Planka 2.1.1 `type` fields, shared-project + owner-manager - visibility fix, label color candidates with fallback; `archived--` rename convention), - `config.rb` (`.cc-os/config` parser), `resolver.rb` (pure repo→board decision: - `use`/`activate`/`stop-and-discuss`; no network), `cards.rb` (add-at-Backlog, snapshot, - attach_label, comment, move with ownership rules), `tracker.rb` + `inspector.rb` - (2026-07-12, issue #14 — tracker-format validation/remote classification + in-repo - issue-file discovery; `Config.merge` preserves other keys). -- CLI: `bin/os-backlog` — `board-ensure`, `activate`, `archive`, `resolve`, `card-add`, - `cards`, `snapshot`, `card-label`, `card-comment`, `card-move` (2026-07-12, ADR-0029), - `inspect`, `config-write`; every path fails soft (one-line error, exit 1) when the - gem/Planka is unavailable. -- Skills: `/os-backlog:capture`, `/os-backlog:list` (pull-only per notification policy v2), - `/os-backlog:route` (2026-07-12, issue #14 — tracker onboarding conversation; destination - choice + live-history migration are named human gates) — carry the column-ownership rules - (AI creates at Backlog, Doing→Review at most, never Next, never self-assigns `hitl`; - "working a card means moving it" contract added 2026-07-12). -- Agents: `agents/card-triage.md` (priority + autonomy labels only; ambiguity → `hitl`), - `agents/board-audit.md` (four drift classes; writes at most comments). -- Tests: `tests/` — 68 runs/153 assertions as of 2026-07-12 (35 → 54 → 58 → 68 across the - slices) against an in-memory `FakePlankaClient`; no live API. Companion os-status check - `tracker-configured` (issue #11) validates the `tracker` key grammar and nudges - unconfigured git projects (daily-snoozed). +- Purpose: the AI's process-management surface over git issues — deterministic tracker + routing (`forgejo:/` | `github:/` | `repo:`; `planka:` + rejected fail-soft citing ADR-0042), mid-session capture as labeled issues, pull-only + listing, cross-project filing via the global project index. +- State model: open/closed + labels. Ten canonical labels: priority `P0`–`P3`; autonomy + `hitl`/`semi`/`afk-ready` (semi ships to `review` label and stays open; afk-ready closes + after verification); state `next` (human-only — AI never applies/removes), `waiting` + (+ blocker comment), `review`. No `doing`/`done` labels: in-progress = assignment/branch + activity, done = closed. Recurring-convention issues (e.g. `jared/ops#1` biweekly audit) + are commented per occurrence and never closed by the AI. +- Lib (Ruby): `tracker.rb` (grammar validation + `issues_destination`: `:git_issues` / + `:repo_files` / `:unrouted`), `issues.rb` (2026-07-16 — capture/list dispatch: `tea` for + forgejo, `gh` for github, `NNN-.md` frontmatter files for `repo:`; injected runner, + no state labels reachable through `create`), `inspector.rb` (issue-shape classification), + `config.rb`, `resolver.rb` (reduced to tracker-presence decision), `project_index.rb`, + `wakeup.rb`. +- CLI: `bin/os-backlog` — `resolve`, `inspect`, `config-write`, `projects`, `issue-create` + (`--title/--body/--priority`), `issues` (grouped next/waiting/review/other). Fail-soft + one-liners throughout. +- Skills: `/os-backlog:capture` (issue-create; never state labels, never `next`), + `/os-backlog:list` (pull-only), `/os-backlog:route` (destination proposal + idempotent + ten-label ensure via tea/gh). SessionStart note rewritten 2026-07-16 (~444 tokens, + down from ~687 measured): capture / routing / cross-project / human-only-`next` / + working-state / autonomy / recurring rules. +- Tests: 88 runs / 203 assertions (2026-07-16) with fake CLI runners; no live API. + Companion os-status `tracker-configured` check validates the reduced grammar and rejects + `planka:` with the ADR-0042 advisory. ## 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 projects — duplicate board names in different projects can resolve to the wrong board. 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#61–70 (3 skipped: pointer to + closed #25, duplicate of open #8, obsoleted board-lookup card); ovh-prod + ruby-gems + strays → jared/ops#2–3; `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#7–10, 18 client cards → jared/ops#4–21, 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 - 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. diff --git a/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/.openspec.yaml b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/.openspec.yaml new file mode 100644 index 0000000..cd2ce7e --- /dev/null +++ b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-16 diff --git a/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/design.md b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/design.md new file mode 100644 index 0000000..fa9d07f --- /dev/null +++ b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/design.md @@ -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:` 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. diff --git a/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/proposal.md b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/proposal.md new file mode 100644 index 0000000..9a3ea01 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/proposal.md @@ -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:` 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 (P0–P3) 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; ~700–900 + 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). diff --git a/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/specs/issue-backlog/spec.md b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/specs/issue-backlog/spec.md new file mode 100644 index 0000000..50ecf08 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/specs/issue-backlog/spec.md @@ -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:/`, +`github:/`, or `repo:`. `config-write` and the os-status +`tracker-configured` check SHALL reject any other value, including `planka:`, 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 (P0–P3) +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 diff --git a/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/specs/issue-state-labels/spec.md b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/specs/issue-state-labels/spec.md new file mode 100644 index 0000000..50c1e48 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/specs/issue-state-labels/spec.md @@ -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:/` +- **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 diff --git a/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/tasks.md b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/tasks.md new file mode 100644 index 0000000..68a9cc6 --- /dev/null +++ b/openspec/changes/archive/2026-07-16-retire-planka-git-issues-only/tasks.md @@ -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#7–10, 18 client cards → jared/ops#4–21, + 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/ diff --git a/openspec/specs/issue-backlog/spec.md b/openspec/specs/issue-backlog/spec.md new file mode 100644 index 0000000..4bc70d2 --- /dev/null +++ b/openspec/specs/issue-backlog/spec.md @@ -0,0 +1,71 @@ +# issue-backlog + +## ADDED Requirements + +### Requirement: Tracker key grammar excludes Planka +The `.cc-os/config` `tracker` key SHALL accept exactly `forgejo:/`, +`github:/`, or `repo:`. `config-write` and the os-status +`tracker-configured` check SHALL reject any other value, including `planka:`, 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 (P0–P3) +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 diff --git a/openspec/specs/issue-state-labels/spec.md b/openspec/specs/issue-state-labels/spec.md new file mode 100644 index 0000000..08caa79 --- /dev/null +++ b/openspec/specs/issue-state-labels/spec.md @@ -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:/` +- **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 diff --git a/plugins/os-backlog/.claude-plugin/plugin.json b/plugins/os-backlog/.claude-plugin/plugin.json index 0a0f26b..27335f0 100644 --- a/plugins/os-backlog/.claude-plugin/plugin.json +++ b/plugins/os-backlog/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "os-backlog", - "version": "0.2.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." + "version": "0.3.0", + "description": "Git-issues backlog across projects: deterministic tracker routing (forgejo/github/repo), mid-session capture as labeled issues, pull-only listing (ADR-0042)." } diff --git a/plugins/os-backlog/agents/board-audit.md b/plugins/os-backlog/agents/board-audit.md deleted file mode 100644 index a5d0e31..0000000 --- a/plugins/os-backlog/agents/board-audit.md +++ /dev/null @@ -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 - ``` -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 --text "board-audit : " - ``` -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. diff --git a/plugins/os-backlog/agents/card-triage.md b/plugins/os-backlog/agents/card-triage.md deleted file mode 100644 index 3c56d6a..0000000 --- a/plugins/os-backlog/agents/card-triage.md +++ /dev/null @@ -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 - ``` -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 --card --label - ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-label --board --card --label - ``` - 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. diff --git a/plugins/os-backlog/bin/os-backlog b/plugins/os-backlog/bin/os-backlog index 6b5f8e3..ef9d4da 100755 --- a/plugins/os-backlog/bin/os-backlog +++ b/plugins/os-backlog/bin/os-backlog @@ -1,34 +1,30 @@ #!/usr/bin/env ruby # frozen_string_literal: true # -# os-backlog CLI: board-ensure, activate, archive, resolve, card-add, -# cards, snapshot, inspect, config-write. +# os-backlog CLI: resolve, inspect, config-write, projects, issue-create, +# issues. Post-ADR-0042 (Planka retired): git issues (forgejo/github/repo) +# are the single tracker for both state and specs. # # 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 -# input.json: {"repo_path": "...", "config": "..."|null, "boards": ["...", ...]} -# 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) +# input.json: {"repo_path": "...", "config": "..."|null} # os-backlog inspect (detect existing tracking for cwd repo, JSON) # os-backlog config-write (write tracker key to .cc-os/config # + upsert the global project index) # os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON) +# 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 -# installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/ -# PASSWORD). resolve, config-write are pure — no network access at all. -# inspect touches the network/CLIs opportunistically but fails soft -# field-by-field rather than aborting. +# resolve, config-write are pure — no network access at all. inspect, +# issue-create, and issues touch the network/CLIs (tea/gh) but fail soft +# field-by-field (inspect) or with a one-line error (issue-create/issues) +# rather than leaving a stack trace. # -# Fails soft: if the gem or Planka credentials are unavailable, prints one -# clear error and exits nonzero. Never destructive. +# Fails soft: never destructive. require "json" require "fileutils" @@ -40,58 +36,6 @@ def fail_soft(message) exit 1 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) idx = args.index(flag) return nil unless idx @@ -104,22 +48,6 @@ def require_flag(args, flag, command) parse_flag(args, flag) || fail_soft("#{command} requires #{flag} ") 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 # (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails # 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}" 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 begin 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" input = JSON.parse($stdin.read) resolver = Backlog::Resolver.new( repo_path: input.fetch("repo_path"), - config_contents: input["config"], - boards: input.fetch("boards", []) + config_contents: input["config"] ) 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" repo_path = Dir.pwd config_path = File.join(repo_path, ".cc-os", "config") @@ -288,7 +162,6 @@ when "inspect" findings = { "repo_path" => repo_path, "tracker_configured" => config.tracker, - "planka" => inspect_planka(repo_path), "git_remote" => remote_info ? remote_info.transform_keys(&:to_s) : nil, "issue_cli" => issue_cli, "issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata), @@ -298,9 +171,13 @@ when "inspect" when "config-write" value = rest.shift 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) fail_soft("invalid tracker value #{value.inspect}; expected one of " \ - "planka: | forgejo:/ | github:/ | repo:") + "forgejo:/ | github:/ | repo:") end config_dir = File.join(Dir.pwd, ".cc-os") @@ -320,25 +197,40 @@ when "projects" end end 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" puts <<~USAGE usage: os-backlog [options] commands: - board-ensure NAME [--project PROJECT] (any project name; creates it if missing; - default derived from cwd path) - activate NAME - archive NAME --yes - resolve (reads {"repo_path","config","boards"} JSON from stdin) - 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 "..." + resolve (reads {"repo_path","config"} JSON from stdin) inspect (detect existing tracking for cwd repo, JSON) config-write (write tracker key to .cc-os/config + global project index) projects [NAME-FILTER] (print the global project index at ~/.cc-os/projects.json as JSON) + 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 exit(command.nil? ? 1 : 0) else diff --git a/plugins/os-backlog/hooks/hooks.json b/plugins/os-backlog/hooks/hooks.json index 91b32ed..cdb73ee 100644 --- a/plugins/os-backlog/hooks/hooks.json +++ b/plugins/os-backlog/hooks/hooks.json @@ -8,11 +8,6 @@ "type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py", "timeout": 5 - }, - { - "type": "command", - "command": "ruby ${CLAUDE_PLUGIN_ROOT}/hooks/triage_check.rb", - "timeout": 15 } ] } diff --git a/plugins/os-backlog/hooks/session_start.py b/plugins/os-backlog/hooks/session_start.py index 6eca611..31aed54 100644 --- a/plugins/os-backlog/hooks/session_start.py +++ b/plugins/os-backlog/hooks/session_start.py @@ -3,29 +3,25 @@ Modeled on os-adr's session_start.py (the wording-source-of-record pattern). Injects the backlog process rules — WHEN->THEN mechanical triggers only. -Deliberately contains ZERO board state and no briefs (notification policy v2: -pull beats push — board state is fetched on demand via /os-backlog:list, never +Deliberately contains ZERO issue state and no briefs (notification policy v2: +pull beats push — issue state is fetched on demand via /os-backlog:list, never pushed into a session). -Sibling hook triage_check.rb (card 1818330518584820864) is a narrow, sanctioned -exception to that policy: it makes one bounded Planka read to decide whether to -push a single mechanical dispatch directive — not a brief, not board contents — -when the current project's own Backlog has unlabeled cards. The only board- -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. +Per ADR-0042 (2026-07-16), Planka is retired: git issues (Forgejo via tea, +GitHub via gh, or in-repo via repo:) are the single tracker for both +task state and durable specs. State is modeled with labels, not columns — +there is no card-move, no board, no triage-dispatch hook to mention here. git project -> additionalContext note (rules only, near-zero tokens) not a git repo -> silent (process rules only apply inside real projects) -Autonomy-label semantics in NOTE are the ADR-029 contract (which supersedes -the wording in issue #16's body): hitl never picked up autonomously; semi -stops at Review for human sign-off; afk-ready goes straight to Done after -verification, skipping Review. +Autonomy-label semantics in NOTE are the ADR-0042 contract (successor to +ADR-029's Planka-mechanics version): hitl never picked up autonomously; semi +stops at named decision gates and ships to the review label; afk-ready ships +and, once verified, closes directly. 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 @@ -36,14 +32,14 @@ from pathlib import Path from typing import Optional NOTE = """\ -[os-backlog] Backlog process rules (rules only — this note never carries board state; board state is pull-only via /os-backlog:list when the user asks). -- CAPTURE: WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will NOT be done this session -> capture it as a Backlog card via /os-backlog:capture. Never a TODO comment in code, never only a mention in chat. -- ROUTING: WHEN process/backlog/spec-tracking work surfaces -> read the tracker key from .cc-os/config (`tracker=planka:` | `forgejo:/` | `github:/` | `repo:`) and send work where it says. WHEN this is a git project and no tracker key is configured -> suggest /os-backlog:route once; do not nag. A subdirectory's own .cc-os/config overrides the repo root's for any session started there — umbrella repos route each subproject to its own board/tracker this way; do not assume one repo means one board. -- PROMOTION: WHEN a Planka-tracked effort accretes significant code or design decisions in card comments -> flag promotion to the user (never silently): carve out a repo, move the durable spec into git issues via /to-issues, and keep the Planka card as a pointer (title + link). Never duplicate spec text into a card description. -- COLUMN OWNERSHIP (CLI-enforced per ADR-029; a violating card-move fails with the rule named): create cards at Backlog only. Never move a card into or out of Next — Next is human-curated in both directions. Never move a card labeled hitl; never move a card out of Done. Working a card MEANS moving it via card-move: Doing when work starts, Waiting + a blocker comment when blocked, and on shipping: Review (semi) or Done (afk-ready, only after verification). -- LABELING/TRIAGE: priority (P0-P3) and autonomy (hitl/semi/afk-ready) labeling is the RESPONSIBLE project's job (it knows its own priorities) — a parent umbrella may label its own and its child boards, but never label cards on a board that belongs to a different project you're just filing onto. Triage is initiated by this project's own SessionStart check (a separate hook), not by capture — if it reports unlabeled Backlog cards, dispatch the card-triage agent (haiku, background) to clear them. -- CROSS-PROJECT: WHEN a needed change belongs to a DIFFERENT project THEN do not edit it — query `os-backlog projects`, file an issue/card with that project's tracker using the Discoverer template (see os-backlog route references/cross-project-filing.md). -- AUTONOMY LABELS (ADR-029): hitl -> human-owned; never picked up autonomously. semi -> run the mechanical parts, stop at named decision gates; shipped work goes to Review for human sign-off. afk-ready -> shipped + verified goes straight to Done, skipping Review. +[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 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: read the tracker key from .cc-os/config (`tracker=forgejo:/` | `github:/` | `repo:`; `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. +- 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. +- 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. +- 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. +- RECURRING ISSUES: never closed by the AI — comment each occurrence's outcome and leave it open. +- 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. """ diff --git a/plugins/os-backlog/hooks/triage_check.rb b/plugins/os-backlog/hooks/triage_check.rb deleted file mode 100644 index 3089470..0000000 --- a/plugins/os-backlog/hooks/triage_check.rb +++ /dev/null @@ -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:` 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 diff --git a/plugins/os-backlog/lib/backlog.rb b/plugins/os-backlog/lib/backlog.rb index 49743ae..ada8166 100644 --- a/plugins/os-backlog/lib/backlog.rb +++ b/plugins/os-backlog/lib/backlog.rb @@ -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/inspector" +require_relative "backlog/issues" require_relative "backlog/project_index" require_relative "backlog/resolver" require_relative "backlog/tracker" -require_relative "backlog/triage_check" require_relative "backlog/wakeup" diff --git a/plugins/os-backlog/lib/backlog/board_ensurer.rb b/plugins/os-backlog/lib/backlog/board_ensurer.rb deleted file mode 100644 index 4243f49..0000000 --- a/plugins/os-backlog/lib/backlog/board_ensurer.rb +++ /dev/null @@ -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 diff --git a/plugins/os-backlog/lib/backlog/board_resolver.rb b/plugins/os-backlog/lib/backlog/board_resolver.rb deleted file mode 100644 index f7d510e..0000000 --- a/plugins/os-backlog/lib/backlog/board_resolver.rb +++ /dev/null @@ -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=` 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:`, 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 diff --git a/plugins/os-backlog/lib/backlog/board_spec.rb b/plugins/os-backlog/lib/backlog/board_spec.rb deleted file mode 100644 index c1f5b98..0000000 --- a/plugins/os-backlog/lib/backlog/board_spec.rb +++ /dev/null @@ -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 diff --git a/plugins/os-backlog/lib/backlog/cards.rb b/plugins/os-backlog/lib/backlog/cards.rb deleted file mode 100644 index 0a0cb01..0000000 --- a/plugins/os-backlog/lib/backlog/cards.rb +++ /dev/null @@ -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 diff --git a/plugins/os-backlog/lib/backlog/config.rb b/plugins/os-backlog/lib/backlog/config.rb index 50f4cb3..a2fc4a9 100644 --- a/plugins/os-backlog/lib/backlog/config.rb +++ b/plugins/os-backlog/lib/backlog/config.rb @@ -35,20 +35,9 @@ module Backlog @data["tracker"] end - # @return [String, nil] the effective Planka board name: the explicit - # `board` key when present, else the board named by a - # `tracker=planka:` key (the form config-write actually - # 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. + # @return [String, nil] the cached Planka board id, if present (legacy; + # Planka is retired per ADR-0042 — kept only so old .cc-os/config + # files with this key still parse without error). def board_id @data["board_id"] end diff --git a/plugins/os-backlog/lib/backlog/issues.rb b/plugins/os-backlog/lib/backlog/issues.rb new file mode 100644 index 0000000..2e7a6ee --- /dev/null +++ b/plugins/os-backlog/lib/backlog/issues.rb @@ -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:. + # + # 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: 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}] :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) ------------------------------ + # + # `/NNN-.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 diff --git a/plugins/os-backlog/lib/backlog/resolver.rb b/plugins/os-backlog/lib/backlog/resolver.rb index 63deb5d..b6ca90e 100644 --- a/plugins/os-backlog/lib/backlog/resolver.rb +++ b/plugins/os-backlog/lib/backlog/resolver.rb @@ -1,91 +1,44 @@ -require_relative "board_spec" require_relative "config" module Backlog - # Pure repo -> board routing decision. No filesystem, no network: every - # input (repo path, config contents, board inventory) is passed in. + # Pure decision over a project's configured tracker. No filesystem, no + # network: every input (repo path, config contents) is passed in. # - # Decision, exactly one of: - # "use " - an active board to work against - # "activate " - an archived board matches; rename it back - # "stop-and-discuss" - no confident match; ask the human + # Post-ADR-0042 (Planka retired), there is no board inventory to resolve + # against — a project either has a `tracker` key in `.cc-os/config` or it + # doesn't. Decision, exactly one of: + # "use " - a tracker key is configured; use it + # "stop-and-discuss" - no tracker configured; ask the human + # (see /os-backlog:route) class Resolver - Decision = Struct.new(:action, :board) do + Decision = Struct.new(:action, :tracker) do def to_s - action == :use || action == :activate ? "#{action} #{board}" : "stop-and-discuss" + action == :use ? "use #{tracker}" : "stop-and-discuss" end end - DEV_ROOT = File.expand_path("~/dev") - CLIENTS_ROOT = File.expand_path("~/clients") - SERVERS_ROOT = File.expand_path("~/servers") - - # Path-based Planka project heuristic, shared by the resolver's own - # ambiguity gate and the `board-ensure` CLI's --project default: "Dev" - # under ~/dev/*, "Clients" under ~/clients/*, "Servers" under - # ~/servers/* (the servers umbrella repo — ADR: infrastructure is a - # distinct domain from Dev), nil (ambiguous) otherwise. Board-ensure - # always accepts an arbitrary --project name too — this heuristic only - # supplies the default when none is given. - # - # @param repo_path [String] - # @return [String, nil] - def self.project_for(repo_path) - path = File.expand_path(repo_path) - return "Dev" if under_root?(path, DEV_ROOT) - return "Clients" if under_root?(path, CLIENTS_ROOT) - return "Servers" if under_root?(path, SERVERS_ROOT) - - nil - end - - def self.under_root?(path, root) - path == root || path.start_with?("#{root}/") - end - private_class_method :under_root? - - # @param repo_path [String] absolute path to the repo + # @param repo_path [String] absolute path to the repo (kept for callers + # that pass it through; not otherwise used since board heuristics + # were retired with Planka) # @param config_contents [String, nil] raw contents of .cc-os/config, or nil/absent - # @param boards [Array] board names known to exist on the Planka instance - # (both active and archived--prefixed names) - def initialize(repo_path:, config_contents:, boards:) + def initialize(repo_path:, config_contents:, **) @repo_path = File.expand_path(repo_path) @config = Config.new(config_contents) - @boards = boards end # @return [Decision] def resolve - configured_board = @config.planka_board - return decision_for_name(configured_board) || stop_and_discuss if configured_board + return use(@config.tracker) if @config.tracker_configured? - derived_decision || stop_and_discuss + stop_and_discuss end - # @return [String] one of "use " / "activate " / "stop-and-discuss" + # @return [String] "use " or "stop-and-discuss" def resolve_string = resolve.to_s private - def derived_decision - 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 use(tracker) = Decision.new(:use, tracker) 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 diff --git a/plugins/os-backlog/lib/backlog/tracker.rb b/plugins/os-backlog/lib/backlog/tracker.rb index 8480022..734c9e3 100644 --- a/plugins/os-backlog/lib/backlog/tracker.rb +++ b/plugins/os-backlog/lib/backlog/tracker.rb @@ -5,19 +5,21 @@ module Backlog class Tracker 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 = { - planka: %r{\Aplanka:[\w.-]+\z}, forgejo: %r{\Aforgejo:[\w.-]+/[\w.-]+\z}, github: %r{\Agithub:[\w.-]+/[\w.-]+\z}, repo: /\Arepo:.+\z/ }.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) !kind(value).nil? 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) return nil unless value.is_a?(String) @@ -26,18 +28,17 @@ module Backlog end # Where /to-issues output (spec slices) should be published, given the - # repo's tracker key. The ADR'd boundary: git issues = specs, Planka = - # state. So forgejo:/github: keys route spec slices to that git issue - # tracker. `planka:` / `repo:` / nil deliberately map to - # :planka_default — what that MEANS for spec-shaped output is a named - # decision gate at wording review (issue #16), not settled here. + # repo's tracker key. One tracker holds both state and specs (ADR-0042): + # forgejo:/github: keys route spec slices to that git issue tracker; + # repo: routes to the in-repo issue-file convention; nil or any + # invalid key (including retired `planka:` values) is :unrouted. # - # @return [Symbol] :git_issues | :planka_default | :unrouted (invalid key) + # @return [Symbol] :git_issues | :repo_files | :unrouted def self.issues_destination(value) case kind(value) when :forgejo, :github then :git_issues - when :planka, :repo then :planka_default - else value.nil? ? :planka_default : :unrouted + when :repo then :repo_files + else :unrouted end end diff --git a/plugins/os-backlog/lib/backlog/triage_check.rb b/plugins/os-backlog/lib/backlog/triage_check.rb deleted file mode 100644 index 131f0a2..0000000 --- a/plugins/os-backlog/lib/backlog/triage_check.rb +++ /dev/null @@ -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:` (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 /.cc-os/config - # @param root_config [String, nil] contents of /.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] the label names on one card - # @return [Boolean] - def self.needs_triage?(labels) - (labels & PRIORITY_LABELS).empty? || (labels & AUTONOMY_LABELS).empty? - end - - # @param backlog_cards_labels [Array>] label arrays, - # one per card currently in the board's Backlog list - # @return [Array>] the subset needing triage - def self.unlabeled(backlog_cards_labels) - backlog_cards_labels.select { |labels| needs_triage?(labels) } - end - - # @param board_name [String] - # @param backlog_cards_labels [Array>] - # @return [String, nil] the SessionStart additionalContext note, or - # nil when there is nothing to triage - def self.note_for(board_name, backlog_cards_labels) - count = unlabeled(backlog_cards_labels).size - return nil if count.zero? - - card_word = count == 1 ? "card" : "cards" - "[os-backlog] #{count} unlabeled Backlog #{card_word} on board #{board_name} -> " \ - "dispatch the card-triage agent now (model: haiku, run in background) to label " \ - "them. Labeling is this project's job for its own board (a parent umbrella may " \ - "also label its child boards) — never label cards on a board that belongs to a " \ - "different project." - end - end -end diff --git a/plugins/os-backlog/skills/capture/SKILL.md b/plugins/os-backlog/skills/capture/SKILL.md index e4800fc..1c18154 100644 --- a/plugins/os-backlog/skills/capture/SKILL.md +++ b/plugins/os-backlog/skills/capture/SKILL.md @@ -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. -- **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. -- **Next stays human-curated in both directions** — never move a card into or out of Next. -- **Never move a card labeled `hitl`** — hitl cards are human-owned, never self-assigned or moved by the AI. -- These rules are enforced deterministically by the CLI (`card-move`), not just documented here — a violating move fails with a one-line reason naming the rule. -- Priority/autonomy labeling is not capture's job — leave new cards unlabeled; the card-triage agent batches that later. 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. +- **Capture never applies state labels, and never applies `next`.** `next` is human-curated + (ADR-0042) — the AI never adds or removes it, under any instruction short of an explicit + direct user request naming the specific issue. +- Apply a priority label (`P0`–`P3`) only when it's actually known from context; otherwise + leave priority unset — don't guess one to fill the field. +- Never a TODO comment in code, never only a mention in chat — always a real issue. ## Procedure 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 - # config: contents of .cc-os/config if present, else null - # boards: names from Planka — every project's boards, including archived--* ones - echo '{"repo_path": "", "config": , "boards": [...]}' \ - | ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog resolve + ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog issue-create --title "" \ + --body "" \ + [--priority P0|P1|P2|P3] ``` - 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. -2. **Act on the decision:** - - `use ` → proceed to step 3. - - `activate ` → run `os-backlog activate ` (activation is automatic — an archived board matching this repo is simply renamed back), then proceed. - - `stop-and-discuss` → do NOT create anything. Tell the user the repo doesn't map to a board and ask where the card should go. -3. **Ensure the board exists** (idempotent — safe to run every time): - ```bash - ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog board-ensure - ``` - Omit `--project` and it derives the right Planka project from the repo path: `Dev` under - `~/dev/`, `Clients` under `~/clients/`, `Servers` under `~/servers/`. Pass `--project ` - to target (or create) any other project explicitly — board-ensure creates the project too if - it doesn't exist yet. There's no command to move an existing board between projects; that's a - manual step in the Planka UI. 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 --title "" [--description ""] - ``` -5. Confirm to the user in one line: board, title. Then return to the interrupted task. + This creates the issue via `tea` (forgejo), `gh` (github), or a `NNN-.md` + frontmatter file (`repo:`) — whichever the project's tracker key names. + `issue-create` is structurally incapable of applying state labels; there is no flag for + one, by design. +2. Confirm to the user in one line: tracker, title, priority if set. Then return to the + interrupted task. -## Moving a card +## No tracker configured -When work on a card starts, blocks, or ships, move it with: -```bash -${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-move --board --card --to Backlog|Doing|Waiting|Review|Done -``` -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. +If `issue-create` fails because the project has no `tracker` key in `.cc-os/config`, +suggest `/os-backlog:route` once — do not nag or retry — and otherwise degrade gracefully: +tell the user what could not be captured and let them decide where it should go for now. ## 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. diff --git a/plugins/os-backlog/skills/list/SKILL.md b/plugins/os-backlog/skills/list/SKILL.md index fdd9408..5fd3639 100644 --- a/plugins/os-backlog/skills/list/SKILL.md +++ b/plugins/os-backlog/skills/list/SKILL.md @@ -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) -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"). - -## Column-ownership rules (also apply here) - -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. +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 +("what's on the backlog?", "/os-backlog:list", "what's next?"). ## 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. -2. Show the cards: +1. List the open issues on the cwd repo's configured tracker: ```bash - ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog cards --board + ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog issues ``` - For a structured view (filtering, counting), use JSON instead: - ```bash - ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board - ``` -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. + This returns open issues (number, title, labels, assignee) grouped by state: `next` / + `waiting` / `review` / other open. +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 + 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 -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. diff --git a/plugins/os-backlog/skills/route/SKILL.md b/plugins/os-backlog/skills/route/SKILL.md index 66075d4..84ff3fb 100644 --- a/plugins/os-backlog/skills/route/SKILL.md +++ b/plugins/os-backlog/skills/route/SKILL.md @@ -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: - -- **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:`. -- **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:/` or `github:/`. -- **WHEN** a project already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to stay there → `repo:` (the escape hatch). -- **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. -- **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:` | `forgejo:/` | `github:/` | `repo:`. +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. +Valid tracker key formats (exactly one, written to `.cc-os/config`'s `tracker` key): +`forgejo:/` | `github:/` | `repo:`. `planka:` is +rejected — `config-write` and the os-status `tracker-configured` check both reject it with +a one-line message citing ADR-0042, fail-soft. ## Procedure @@ -24,38 +23,77 @@ All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`. ```bash ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog inspect ``` - This is read-only and safe to run without asking. It reports, as JSON: - - `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). - - `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). - - `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). - - `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). - - `in_repo_issue_files` — `docs/issues/` and/or `ISSUES.md` if present. + Read-only, safe without asking. It reports `tracker_configured` (existing key, if any — + if set, confirm re-routing with the user before continuing; re-routing is itself a + decision gate), `git_remote` (parsed `git remote -v`, classified `forgejo`/`github`/ + `unknown`, plus open-issue count via `tea`/`gh` if available — fail-soft, not blocking), + and `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: - - **(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:` fits Planka-as-state; a board will be created on first capture, not by this skill." - - **(b) Existing git issues that are spec-shaped** (`issue_shape` is `sequential-chain` or `mixed`): recommend the **SPLIT as the default**. Write `planka:` 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. - - **(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. - **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. +2. **Propose a destination (NAMED DECISION GATE).** Pick one: + - Git remote is `forgejo` → propose `forgejo:/`. + - Git remote is `github` → propose `github:/`. + - No git-host remote, but `in_repo_issue_files` exist → propose `repo:` naming + 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: - - 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. - - 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. - - **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. +3. **Migrate existing items if the tracker type is changing (SECOND NAMED DECISION GATE).** + Only applies when open items currently live somewhere other than the confirmed + destination (e.g. `ISSUES.md` entries moving to Forgejo, or switching from GitHub to + 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:`. 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).** ```bash ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog config-write ``` - 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 / -o json + tea labels create --repo / --name