Compare commits

...

5 Commits

Author SHA1 Message Date
jared 3b9c4fbd88 os-backlog: align .cc-os/config with os-status key=value contract (refs #14)
Config parsed/wrote YAML while os-status owns the file as key=value
lines, so config-write output was invisible to the tracker-configured
check. Now reads key=value (legacy 'key: value' lines still accepted)
and merge emits key=value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUyiRB4vHaRkhYKUdoMW2P
2026-07-12 16:04:12 -04:00
jared 696aefcf23 os-status + os-backlog: /os-status:fix unified setup/update + /os-backlog:route tracker onboarding (closes #21, refs #14, ADR-026)
os-status (#21): fix skill orchestrates remediation of failing checks via a
new hooks/checks.py --json runner; remediation pointers on every Check; new
project-graph-present and config-version-current checks; version= stamping
in .cc-os/config; banner names /os-status:fix as the single entry point.
Suite 64 tests (was 36).

os-backlog (#14): route skill (inspect + config-write subcommands,
lib/backlog/tracker.rb + inspector.rb); destination choice and live-history
migration are named human gates; Config.merge preserves other keys. Suite
54 runs / 110 assertions (was 35). Rollout (onboard cc-os, then one more
project) still pending, so #14 stays open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUyiRB4vHaRkhYKUdoMW2P
2026-07-12 15:54:08 -04:00
jared ba34e547f7 docs: ADR-026 — unified /os-status:fix setup/update command (refs #21, #14)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUyiRB4vHaRkhYKUdoMW2P
2026-07-12 15:45:14 -04:00
jared 4b4e08c89b docs: ADR-025 — standard Ruby structure for cc-os plugins
lib/ + single-dispatcher bin/, fail-soft errors, installed-gem-only
dependencies, minitest with in-memory fakes; os-backlog is the
template, os-adr retrofit opportunistic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS59Nkb7fvJMFB9dZiwZ7H
2026-07-10 17:13:57 -04:00
jared bfb747bd7e os-backlog: restore full snapshot card key set + record/verify 0.2.0 migration (refs #18, #19, #20)
Cards#snapshot again emits all 19 Types::Card fields (camelCase) plus
labels — 8df0cb7 had narrowed it to 8 keys, dropping listChangedAt and
silently breaking board-audit's stale-Doing check. A regression test
now pins the complete key set (35 runs green). implementation-status
records the 0.2.0 typed-returns migration and the live end-to-end
verification against the real Planka board (run on ovh-vps 2026-07-10).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BS59Nkb7fvJMFB9dZiwZ7H
2026-07-10 17:13:57 -04:00
18 changed files with 944 additions and 33 deletions

View File

@ -137,6 +137,54 @@ behind gem issue 13) and live verification completed against production Planka
Backlog card: get planka_bot into credvault (credvault has no import path — rotate vs.
add import capability is a human decision).
**os-backlog migrated to planka-api 0.2.0 typed returns (2026-07-10):** commit `8df0cb7`
moved the gem from 0.1.0 hash-return to 0.2.0, which returns `Planka::Types` value objects
instead of parsed-JSON hashes. `board_ensurer.rb` and `cards.rb` were rewritten on typed
accessors (`card.id`, `card.list_id`, …); `Cards#snapshot` now builds explicit string-keyed
hashes from `BoardDetail#lists`/`#cards`/`#labels`/`#card_labels` (`BoardDetail` replaces the
old `included`-hash shape). Test fakes in `tests/test_helper.rb` were rebuilt to return real
`Planka::Types` records instead of hashes. JSON output stays byte-compatible, so skill/agent
contracts are unchanged. A follow-up fix (issue #18, same day) found the migration had
silently dropped one field from `Cards#snapshot`'s per-card hash; restored the full 19-key
camelCase card key set (`id`, `createdAt`, `updatedAt`, `type`, `position`, `name`,
`description`, `dueDate`, `isDueCompleted`, `stopwatch`, `commentsTotal`, `isClosed`,
`listChangedAt`, `boardId`, `listId`, `creatorUserId`, `prevListId`, `coverAttachmentId`,
`isSubscribed`, plus `labels`) with a regression test pinning the key set including
`listChangedAt`. Suite green at 35 runs (was 34 pre-#18).
**os-backlog live verification (0.2.0) (2026-07-10):** end-to-end re-verification of
`bin/os-backlog` against the real Planka instance with the installed 0.2.0 gem (issue #20).
Local machine had no `PLANKA_*` env/credvault entry, so the plugin tree was rsynced to
`ovh-vps:/tmp/os-backlog-verify` (where `planka-api` 0.2.0 is also installed) and exercised
there with bot credentials sourced from `~/services/planka/bot-credentials.env` in a
subshell, per the established pattern — credential contents were never read or printed.
Results: `tests/all.rb` 35 runs/0 failures on ovh-vps; `board-ensure cc-os --project Dev` run
twice both reported `(existing)` with no lists/labels created (idempotent); `card-add`
created a card on Backlog; `card-label` attached `afk-ready`; `card-comment` posted a
comment; `snapshot --board cc-os` showed the card with the full 20-key set (19 card fields +
`labels`) including `"listChangedAt": "2026-07-10T21:04:37.013Z"` and `"labels":
["afk-ready"]`. The test card was deleted via `Planka::Client#cards.delete` (the CLI has no
delete subcommand) and a follow-up snapshot confirmed it was gone; the rsynced verification
tree on ovh-vps was removed afterward. No regressions vs. the pre-0.2.0 behavior.
**Unified setup/update command + tracker routing skill shipped (2026-07-12):** ADR-026
decided the unified per-project setup/update command lives in os-status as `/os-status:fix`
(rejected: new `os-project` plugin, literal `/cc-os:init`). Built in parallel (issues #21,
#14, both sonnet-delegated): **os-status** gained the `fix` skill (runs the check registry
via a new `python3 hooks/checks.py --json` runner, drives each failing check's owning-plugin
remediation, idempotent — re-run is the update path), a `remediation` pointer on every
`Check`, two new checks (`project-graph-present` → `/os-vault:onboard-project`;
`config-version-current` vs `CURRENT_CONFIG_VERSION`, stamped as `version=` in
`.cc-os/config` by fix via `state.write_config_value`), and a banner header naming
`/os-status:fix` as the single entry point; suite 64 tests (was 36). **os-backlog** gained
`/os-backlog:route` (issue #14): `inspect` + `config-write` dispatcher subcommands
(`lib/backlog/tracker.rb` validates the four tracker formats + classifies git remotes,
`lib/backlog/inspector.rb` finds in-repo issue files; `Config.merge` preserves other keys),
with destination choice and live-history migration as named human gates in the skill; suite
54 runs/110 assertions (was 35). Human gates preserved throughout: fix never fabricates a
tracker value and never edits global settings.json. Rollout per #14 acceptance (onboard
cc-os itself, then one more project) still pending.
**Remaining optional items:** additional project onboarding (one at a time, per ADR-013); bulk
vault migration. All required build steps complete as of 2026-06-15.
@ -322,7 +370,13 @@ symlinked into `~/.claude/plugins/os-status`
at session start), `adr-system-present` (verbatim port of os-adr's hook behavior incl.
`.os-adr/suppress`), `vault-hub-note-present` (config slug first, else `type/hub` +
`project/<name>` facet-tag scan of the vault; missing → warn naming `/os-vault:write`).
- Tests: `python3 tests/hook_test.py` (36, model-free) incl. byte-identity of
- Added 2026-07-12 (ADR-026, issue #21): `/os-status:fix` skill — the unified project
setup/update command; runs the registry via `python3 hooks/checks.py --json` (each result
now carries a `remediation` pointer), drives the owning plugin's remediation per failing
check, stamps `version=` into `.cc-os/config` (`CURRENT_CONFIG_VERSION`); idempotent, so
re-running is the update path. New checks `project-graph-present` and
`config-version-current`; warn banner names `/os-status:fix` as the single entry point.
- Tests: `python3 tests/hook_test.py` (64, model-free; 36 pre-#21) incl. byte-identity of
PRESENT_NOTE/ABSENT_NOTE against os-adr's source. Invariants in `invariants.md`.
Smoke-tested 2026-07-06 (cache==source, real headless session fired the hook,
env-override canary warns).
@ -499,7 +553,8 @@ symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9, slices #10
(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`).
- Lib (Ruby, os-adr `lib/`+`bin/` pattern, installed planka-api gem only — no source
- Lib (Ruby, os-adr `lib/`+`bin/` pattern, installed planka-api gem only (0.2.0, typed
`Planka::Types` returns as of 2026-07-10) — 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),
@ -509,18 +564,25 @@ symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9, slices #10
- CLI: `bin/os-backlog``board-ensure`, `activate`, `archive`, `resolve`, `card-add`,
`cards`, `snapshot`, `card-label`, `card-comment`; every path fails soft (one-line error,
exit 1) when the gem/Planka is unavailable.
- Skills: `/os-backlog:capture`, `/os-backlog:list` — carry the column-ownership rules (AI
- Skills: `/os-backlog:capture`, `/os-backlog:list`, `/os-backlog:route` (2026-07-12, issue
#14 — tracker onboarding conversation over new `inspect`/`config-write` subcommands and
`lib/backlog/tracker.rb`/`inspector.rb`; destination choice + live-history migration are
named human gates) — carry the column-ownership rules (AI
creates at Backlog, Doing→Review at most, never Next or Review→Done, never self-assigns
`hitl`); list is pull-only per notification policy v2.
- Agents: `agents/card-triage.md` (priority + autonomy labels only; ambiguity → `hitl`),
`agents/board-audit.md` (four drift classes; writes at most comments).
- Tests: `tests/` — 34 runs/70 assertions against an in-memory `FakePlankaClient`; no live
- Tests: `tests/` — 54 runs/110 assertions (35 pre-#14) 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).
- Outstanding: Semi slices #14/#16/#17 (routing/migration skill, injection note +
/to-issues routing, ops board + rubric category + routing ADR). Live verification
- Outstanding: Semi slices #16/#17 (injection note + /to-issues routing, ops board + rubric
category + routing ADR); #14 (routing skill) shipped 2026-07-12 — rollout (onboard cc-os,
then one more project) pending. Live verification
completed 2026-07-10 (snapshot / board-ensure / card-add / card-label against production;
gem 0.1.0 locally installed via `rake install`).
gem 0.1.0 locally installed via `rake install`); re-verified 2026-07-10 (later) against the
installed 0.2.0 gem post-migration — see "os-backlog migrated to planka-api 0.2.0" and
"os-backlog live verification (0.2.0)" timeline entries above.
## Operational procedures

View File

@ -653,6 +653,33 @@ _Date: 2026-07-10_
- **Cross-references**: ADR-023 (os-backlog / ecosystem role); vault note `vault-backlog-pilot-plan` (notification policy v2); gem repo `~/dev/ruby-gems/planka` (tick delivery); Apprise docs https://appriseit.com/.
- **Status**: Accepted 2026-07-10. Server deploy + P0 swap queued on the boards; expansion cards seeded at P2/P3.
## ADR-025 — Standard Ruby structure for cc-os plugins (lib/ + single-dispatcher bin/, fail-soft, installed-gem-only dependencies)
_Date: 2026-07-10_
- **Context**: Two plugins ship Ruby (os-adr, os-backlog) and more will follow (os-notify is queued). implementation-status.md informally calls os-backlog's layout "the os-adr `lib/`+`bin/` pattern", but no ADR pins it, and the two plugins already diverge: os-adr uses one `bin/` script per verb (`adr-new`, `adr-find`, …) invoked as `ruby ${CLAUDE_PLUGIN_ROOT}/bin/adr-new`, inline `abort` error handling, stdlib only; os-backlog uses a single dispatcher `bin/os-backlog <subcommand>` invoked without a `ruby` prefix, a centralized `fail_soft` helper, and a lazily `require`d installed gem (`planka-api`) guarded by `rescue LoadError`. Without a recorded standard, each new plugin re-decides and the surfaces drift.
- **Decision**: New cc-os plugin Ruby follows this standard (deviate only with a recorded reason):
1. **Library**: `lib/<domain>.rb` + `lib/<domain>/*.rb`, one `module <Domain>` namespace, loaded via `require_relative`, stdlib-first. No gemspec/Gemfile inside a plugin — plugins are source trees, not gems.
2. **CLI**: a single dispatcher `bin/os-<domain>` with subcommands (os-backlog style), executable with a `#!/usr/bin/env ruby` shebang; skills invoke `${CLAUDE_PLUGIN_ROOT}/bin/os-<domain> <subcommand>` with no `ruby` prefix. One entry point per plugin keeps skill docs uniform and namespaces the verbs.
3. **Errors**: every user-reachable failure goes through one fail-soft helper printing exactly one line, `os-<domain>: <message>`, exit 1. Never a raw stack trace from a skill-invoked path.
4. **External gems**: consumed as *installed* gems, `require`d lazily inside the method that needs them with `rescue LoadError` → fail-soft. Never vendored, never source-coupled to a sibling repo, never shelled out to another gem's binary when the client classes suffice. Each required gem is named in the plugin README and implementation-status.md (installation is an environment prerequisite, so it must be discoverable).
5. **Tests**: minitest, `tests/all.rb` + `tests/test_helper.rb`, with in-memory fakes for any network service so the suite runs on machines without the gem or credentials (os-backlog's `FakePlankaClient` is the template).
- **Rationale**: os-backlog is the newer, deliberate iteration of the pattern and its choices earned their place: the dispatcher scales without `bin/` sprawl, fail-soft is mandatory for network-touching plugins (ADR-023's hook-hygiene argument), and lazy installed-gem requires keep pure subcommands (e.g. `resolve`) working on machines where the gem is absent — verified in practice 2026-07-10.
- **Alternatives rejected**: **Per-verb bin scripts (os-adr style)** — fine at 4 verbs, sprawl at 9; grandfathered in os-adr, not worth a retrofit until os-adr is next touched for other reasons. **Packaging plugins as real gems** — adds release/version ceremony for code that ships by symlink + cache refresh (ADR-018). **Vendoring external gems** — duplicates code and hides the dependency instead of documenting it.
- **Cross-references**: ADR-023 (plugin boundaries, hook hygiene); implementation-status.md os-adr/os-backlog component sections; `cc-os-plugin-skill-naming-convention.md` (naming layer above this structural layer).
- **Status**: Accepted 2026-07-10. Applies to all new plugin Ruby; os-adr retrofit is opportunistic, not scheduled.
## ADR-026 — Unified project setup/update command: `/os-status:fix` remediates what the os-status checks flag
_Date: 2026-07-12_
- **Context**: Bringing a project up to cc-os standards currently requires knowing and running each plugin's own onboarding surface separately (`/os-adr:init`, `/os-vault:onboard-project`, a manual hub-note write, the os-backlog routing skill — which is itself still unbuilt, issue #14, so the os-status tracker warning points at a skill that does not exist). The SessionStart warnings (ADR-022) name a different remediation per line, and only os-adr's names a real command. The user wants one command that brings any project up to date with the current cc-os approach, without per-plugin ceremony. A literal `/cc-os:init` is impossible under the naming convention (`os-[domain]` plugins only).
- **Decision**: (1) New skill **`/os-status:fix`** in the os-status plugin: it runs the same check registry the SessionStart hook runs, then for each non-`ok` result drives the owning plugin's remediation — invoking that plugin's existing skill (`/os-adr:init`, `/os-vault:onboard-project`, the routing skill once built) or performing the small direct action (hub-note creation via `/os-vault:write` conventions). Decision-bearing steps (tracker destination, ignore-list confirmation) keep their human gates; mechanical steps run autonomously. (2) **Idempotent by construction**: re-running `fix` on a configured project is the update path — no separate update command. (3) Each `Check` in `hooks/checks.py` gains a **remediation pointer** so check and fix stay co-located in one registry; SessionStart warn output suggests `/os-status:fix` as the single entry point (per-check detail remains for context). (4) `.cc-os/config` gains a **`version` key** stamped by `fix`; a future check can warn when a project was configured under an older approach.
- **Rationale**: os-status already owns the per-project precondition registry (ADR-022); the fix skill is the write-side of the same seam, so checks and remediations cannot drift apart. Idempotent fix collapses setup/update into one verb. Per-plugin init skills remain the implementation (composition per ADR-023's cooperation goal) — `fix` orchestrates, it does not reimplement.
- **Alternatives rejected**: **New `os-project` plugin with `/os-project:init`** — reads slightly more naturally but splits remediations from the check registry they mirror and adds a seventh plugin for glue. **`/os-status:configure`** — same home, weaker verb; `fix` reads as "fix what the status check flagged", which is exactly the contract. **Literal `/cc-os:init`** — violates the `os-[domain]` naming convention.
- **Cross-references**: ADR-022 (check registry), ADR-023 (cross-plugin cooperation), `cc-os-plugin-skill-naming-convention.md`; Forgejo issue #14 (routing skill — the tracker remediation `fix` will delegate to).
- **Status**: Accepted 2026-07-12. Implementation queued as a Forgejo issue; tracker remediation lands with or after issue #14.
## Rejected tools (summary)
| Tool | Why rejected for our use |

View File

@ -2,7 +2,7 @@
# frozen_string_literal: true
#
# os-backlog CLI: board-ensure, activate, archive, resolve, card-add,
# cards, snapshot.
# cards, snapshot, inspect, config-write.
#
# Usage:
# os-backlog board-ensure NAME --project Dev|Clients
@ -13,15 +13,21 @@
# os-backlog card-add --board NAME --title "..." [--description "..."]
# os-backlog cards --board NAME (human-readable per-list card listing)
# os-backlog snapshot --board NAME (full board state as JSON; read-only)
# os-backlog inspect (detect existing tracking for cwd repo, JSON)
# os-backlog config-write <tracker-value> (write tracker key to .cc-os/config)
#
# board-ensure/activate/archive talk to Planka over the network (the
# installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/
# PASSWORD). resolve is pure — no network access at all.
# 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.
#
# Fails soft: if the gem or Planka credentials are unavailable, prints one
# clear error and exits nonzero. Never destructive.
require "json"
require "fileutils"
require "shellwords"
require_relative "../lib/backlog"
def fail_soft(message)
@ -61,6 +67,55 @@ def require_flag(args, flag, command)
parse_flag(args, flag) || fail_soft("#{command} requires #{flag} <value>")
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 via tea (Forgejo) or gh (GitHub), whichever
# the detected remote implies. Fails soft per-field.
def inspect_issue_cli(remote_info)
unless remote_info && %i[forgejo github].include?(remote_info[:kind])
return { "tool" => nil, "open_count" => nil, "reason" => "no forgejo/github remote detected" }
end
repo_slug = "#{remote_info[:owner]}/#{remote_info[:repo]}"
if remote_info[:kind] == :forgejo
tea_issue_count(repo_slug)
else
gh_issue_count(repo_slug)
end
end
def tea_issue_count(repo_slug)
return { "tool" => "tea", "open_count" => nil, "reason" => "tea CLI not found" } unless system("which tea > /dev/null 2>&1")
out = `tea issues list --repo #{repo_slug} --state open 2>/dev/null`
lines = out.to_s.lines.map(&:strip).reject(&:empty?)
{ "tool" => "tea", "open_count" => lines.size }
end
def gh_issue_count(repo_slug)
return { "tool" => "gh", "open_count" => nil, "reason" => "gh CLI not found" } unless system("which gh > /dev/null 2>&1")
out = `gh issue list --repo #{repo_slug} --state open --json number 2>/dev/null`
parsed = JSON.parse(out)
{ "tool" => "gh", "open_count" => parsed.size }
rescue JSON::ParserError
{ "tool" => "gh", "open_count" => nil, "reason" => "could not parse gh output" }
end
command, *rest = ARGV
begin
@ -131,6 +186,39 @@ when "card-comment"
client = build_client
comment = Backlog::Cards.new(client: 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")
config_contents = File.exist?(config_path) ? File.read(config_path) : nil
config = Backlog::Config.new(config_contents)
remote_output = `git -C #{Shellwords.escape(repo_path)} remote -v 2>/dev/null`
remote_info = Backlog::Tracker.parse_remote(remote_output)
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" => inspect_issue_cli(remote_info),
"in_repo_issue_files" => Backlog::Inspector.issue_files(repo_path)
}
puts JSON.pretty_generate(findings)
when "config-write"
value = rest.shift
fail_soft("config-write requires a tracker value, e.g. forgejo:owner/repo") unless value
unless Backlog::Tracker.valid?(value)
fail_soft("invalid tracker value #{value.inspect}; expected one of " \
"planka:<board> | forgejo:<owner>/<repo> | github:<owner>/<repo> | repo:<path>")
end
config_dir = File.join(Dir.pwd, ".cc-os")
config_path = File.join(config_dir, "config")
existing = File.exist?(config_path) ? File.read(config_path) : nil
updated = Backlog::Config.merge(existing, "tracker", value)
FileUtils.mkdir_p(config_dir)
File.write(config_path, updated)
puts "tracker set to #{value} in #{config_path}"
when nil, "-h", "--help"
puts <<~USAGE
usage: os-backlog <command> [options]
@ -145,6 +233,8 @@ when nil, "-h", "--help"
snapshot --board NAME
card-label --board NAME --card ID --label NAME
card-comment --card ID --text "..."
inspect (detect existing tracking for cwd repo, JSON)
config-write <tracker-value> (write tracker key to .cc-os/config)
USAGE
exit(command.nil? ? 1 : 0)
else

View File

@ -2,4 +2,6 @@ require_relative "backlog/board_spec"
require_relative "backlog/board_ensurer"
require_relative "backlog/cards"
require_relative "backlog/config"
require_relative "backlog/inspector"
require_relative "backlog/resolver"
require_relative "backlog/tracker"

View File

@ -76,16 +76,30 @@ module Backlog
# 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+.
# 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,
"position" => card.position,
"listId" => card.list_id,
"boardId" => card.board_id,
"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

View File

@ -1,15 +1,26 @@
require "yaml"
module Backlog
# The parsed contents of a repo's `.cc-os/config` (YAML). Only the
# `board` key matters to the resolver: an explicit board-name override.
# The parsed contents of a repo's `.cc-os/config`. The file format is
# '#'-commented key=value lines — the contract is owned by os-status
# (plugins/os-status/hooks/state.py); legacy `key: value` YAML lines
# from earlier os-backlog versions are still read for compatibility.
# Never touches the filesystem itself — callers read the file and pass
# its contents (or nil, if absent) in.
class Config
def initialize(contents)
@data = contents.to_s.strip.empty? ? {} : (YAML.safe_load(contents) || {})
rescue Psych::SyntaxError
@data = {}
contents.to_s.each_line do |line|
line = line.strip
next if line.empty? || line.start_with?("#") || line == "---"
if line.include?("=")
key, value = line.split("=", 2)
elsif line.include?(": ")
key, value = line.split(": ", 2)
else
next
end
@data[key.strip] = value.strip
end
end
# @return [String, nil] the explicit board name override, if configured
@ -17,6 +28,30 @@ module Backlog
@data["board"]
end
# @return [String, nil] the tracker key (e.g. "forgejo:owner/repo"), if configured
def tracker
@data["tracker"]
end
def configured? = !board.nil?
def tracker_configured? = !tracker.nil?
# @return [Hash] a copy of the parsed config data
def to_h = @data.dup
# Return new key=value contents with `key` set to `value`, preserving
# every other key already present in `contents` (legacy YAML-style
# lines are rewritten to key=value). Pure — does not touch the
# filesystem; callers read/write the file themselves.
#
# @param contents [String, nil] existing raw `.cc-os/config` contents
# @param key [String, Symbol] key to set
# @param value [Object] value to set it to
# @return [String] the updated key=value contents
def self.merge(contents, key, value)
data = new(contents).to_h
data[key.to_s] = value.to_s
data.map { |k, v| "#{k}=#{v}\n" }.join
end
end
end

View File

@ -0,0 +1,25 @@
module Backlog
# Filesystem-only detection helpers for the `inspect` subcommand: does the
# repo already have in-repo issue files. Deliberately narrow — network
# checks (Planka, tea, gh) stay in bin/os-backlog where the rest of the
# CLI's IO lives; this class exists so that piece is unit-testable without
# touching the network or shelling out.
class Inspector
CANDIDATE_DIRS = ["docs/issues"].freeze
CANDIDATE_FILES = ["ISSUES.md"].freeze
# @param repo_path [String] absolute path to the repo
# @return [Array<String>] repo-relative paths of in-repo issue tracking
# found (empty if none)
def self.issue_files(repo_path)
found = []
CANDIDATE_DIRS.each do |rel|
found << rel if Dir.exist?(File.join(repo_path, rel))
end
CANDIDATE_FILES.each do |rel|
found << rel if File.exist?(File.join(repo_path, rel))
end
found
end
end
end

View File

@ -0,0 +1,69 @@
module Backlog
# Validates and classifies tracker keys (the value written to
# `.cc-os/config`'s `tracker` field) and parses `git remote -v` output to
# detect a Forgejo/GitHub remote. Pure — no filesystem, no network.
class Tracker
DEFAULT_FORGEJO_HOST = "forgejo.swansoncloud.com"
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
def self.valid?(value)
!kind(value).nil?
end
# @return [Symbol, nil] :planka / :forgejo / :github / :repo, or nil if invalid
def self.kind(value)
return nil unless value.is_a?(String)
FORMATS.each { |name, pattern| return name if value.match?(pattern) }
nil
end
# Parse the first remote line of `git remote -v` output and classify it
# as a Forgejo or GitHub remote (or :unknown for anything else).
#
# @param output [String] raw `git remote -v` output
# @param forgejo_host [String] hostname that identifies the user's
# self-hosted Forgejo instance
# @return [Hash, nil] {kind:, host:, owner:, repo:}, or nil if no remote
# URL could be parsed
def self.parse_remote(output, forgejo_host: DEFAULT_FORGEJO_HOST)
line = output.to_s.lines.first
return nil unless line
url = line.split(/\s+/)[1]
return nil unless url
host, path = split_host_path(url)
return nil unless host && path
owner, repo = path.sub(/\.git\z/, "").split("/").last(2)
return nil unless owner && repo
kind = if host == forgejo_host
:forgejo
elsif host == "github.com"
:github
else
:unknown
end
{ kind: kind, host: host, owner: owner, repo: repo }
end
def self.split_host_path(url)
if url =~ %r{\A[\w+.-]+://(?:[^/@]+@)?([^:/]+)(?::\d+)?/(.+)\z}
[Regexp.last_match(1), Regexp.last_match(2)]
elsif url =~ /\A[^@\s]+@([^:]+):(.+)\z/
[Regexp.last_match(1), Regexp.last_match(2)]
end
end
private_class_method :split_host_path
end
end

View File

@ -0,0 +1,55 @@
---
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`.
---
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.
## The boundary rule (what you're deciding between)
- **Planka = state.** Cards track *what's happening now* — backlog/next/doing/review/done. Use `planka:<board>` when the project's work is mostly ephemeral tasks, small fixes, or process/coordination — nothing that needs a durable written spec.
- **Git issues = specs only.** Use `forgejo:<owner>/<repo>` or `github:<owner>/<repo>` when work is specified as text that needs to survive and be referenced (features, migrations, architecture-level changes) — tracer-bullet slices from `/to-issues`, PRDs from `/to-prd`.
- **`repo:<path>`** is the escape hatch for a project that already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to keep doing so without moving to Planka or a git host's issue tracker.
- **Card-as-pointer + issue-chain-as-spec, for code efforts:** when a body of work needs both — ongoing state tracking AND a durable spec — the Planka card is a pointer (title + link) into a chain of git issues that hold the actual spec. Don't duplicate the spec text into the card description.
- **Promotion rule:** if a Planka-tracked effort accretes significant code/design decisions in card comments, that's a signal to promote it — open a git issue (or issue chain) holding the durable spec, and turn the Planka card into a pointer per the rule above. Flag this to the user when you see it happening; don't do it silently.
Valid tracker key formats (exactly one, written to `.cc-os/config`'s `tracker` key): `planka:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`.
## Procedure
All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
1. **Inspect (autonomous, mechanical).** Run:
```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).
- `in_repo_issue_files``docs/issues/` and/or `ISSUES.md` if present.
2. **Synthesize and propose (NAMED DECISION GATE).** Given the findings, propose exactly ONE destination tracker key with a short rationale grounded in the boundary rule above — e.g. "this repo has an active Forgejo remote with 11 open issues and no Planka board; issues already carry specs, so `forgejo:jared/cc-os` fits git-issues-as-spec" or "no existing tracking found, work here is small ad hoc fixes; `planka:<repo-name>` fits Planka-as-state; a board will be created on first capture, not by this skill."
**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.
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). 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.
4. **Write the tracker key (autonomous, mechanical, once destination is confirmed).**
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog config-write <tracker-value>
```
The CLI validates the format and rejects anything malformed — if it fails, relay the error verbatim and re-ask rather than hand-editing `.cc-os/config`.
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.
## Decision gates (non-negotiable)
- **Destination gate (step 2):** never call `config-write` or move anything before the human has confirmed which tracker to use.
- **Migration gate (step 3):** even after the destination is confirmed, migrating *existing open items* (live project history) needs its own explicit go-ahead — a destination choice for new work is not consent to move old work.
- Inspection (step 1) and the final `config-write` (step 4) are mechanical and run without a gate — they don't change or move anything you haven't already been told to.
## Failure behavior
Fail soft, per the plugin convention. `inspect`'s individual checks (Planka, tea/gh) degrade gracefully and are reported as unavailable rather than aborting the whole command — relay those as informational, not blocking, unless the missing signal is the only way to make the destination decision (in which case say what's missing and ask the human directly). `config-write` fails loudly on an invalid tracker value — relay the CLI's error verbatim.

View File

@ -49,6 +49,23 @@ class CardsTest < Minitest::Test
assert_equal Backlog::BoardSpec::LABEL_NAMES.sort, snapshot["labels"].sort
end
def test_snapshot_card_emits_complete_key_set
@cards.add(board_name: "llf-schema", title: "captured task")
snapshot = @cards.snapshot(board_name: "llf-schema")
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
card = backlog["cards"].first
expected_keys = %w[
id createdAt updatedAt type position name description dueDate
isDueCompleted stopwatch commentsTotal isClosed listChangedAt
boardId listId creatorUserId prevListId coverAttachmentId
isSubscribed labels
]
assert_equal expected_keys.sort, card.keys.sort
assert card.key?("listChangedAt"), "snapshot card must carry listChangedAt for board-audit"
end
def test_snapshot_includes_card_label_names
card = @cards.add(board_name: "llf-schema", title: "labeled task")
p1 = @client.labels_store.find { |l| l.name == "P1" }

View File

@ -13,18 +13,56 @@ class ConfigTest < Minitest::Test
end
def test_reads_board_key
config = Backlog::Config.new("board: llf-schema\n")
config = Backlog::Config.new("board=llf-schema\n")
assert config.configured?
assert_equal "llf-schema", config.board
end
def test_malformed_yaml_is_treated_as_unconfigured
config = Backlog::Config.new("board: [unterminated\n")
def test_reads_legacy_yaml_style_board_key
config = Backlog::Config.new("---\nboard: llf-schema\n")
assert config.configured?
assert_equal "llf-schema", config.board
end
def test_lines_without_separator_are_ignored
config = Backlog::Config.new("# comment\njunk line\n")
refute config.configured?
end
def test_yaml_without_board_key_is_unconfigured
config = Backlog::Config.new("other: value\n")
def test_contents_without_board_key_is_unconfigured
config = Backlog::Config.new("other=value\n")
refute config.configured?
end
def test_reads_tracker_key
config = Backlog::Config.new("tracker=forgejo:jared/cc-os\n")
assert config.tracker_configured?
assert_equal "forgejo:jared/cc-os", config.tracker
end
def test_tracker_unconfigured_when_absent
config = Backlog::Config.new("board=llf-schema\n")
refute config.tracker_configured?
end
def test_merge_sets_new_key_on_nil_contents
contents = Backlog::Config.merge(nil, "tracker", "forgejo:jared/cc-os")
assert_equal "tracker=forgejo:jared/cc-os\n", contents
end
def test_merge_preserves_other_keys
existing = "board=llf-schema\nother=kept\n"
updated_contents = Backlog::Config.merge(existing, "tracker", "planka:llf-schema")
updated = Backlog::Config.new(updated_contents)
assert_equal "llf-schema", updated.board
assert_equal "planka:llf-schema", updated.tracker
assert_equal "kept", updated.to_h["other"]
end
def test_merge_overwrites_existing_value_for_key
existing = "tracker=repo:old\n"
updated = Backlog::Config.new(Backlog::Config.merge(existing, "tracker", "repo:new"))
assert_equal "repo:new", updated.tracker
end
end

View File

@ -0,0 +1,25 @@
require_relative "test_helper"
require "tmpdir"
require "fileutils"
class InspectorTest < Minitest::Test
def test_detects_docs_issues_dir
Dir.mktmpdir do |dir|
FileUtils.mkdir_p(File.join(dir, "docs/issues"))
assert_includes Backlog::Inspector.issue_files(dir), "docs/issues"
end
end
def test_detects_issues_md_file
Dir.mktmpdir do |dir|
File.write(File.join(dir, "ISSUES.md"), "# issues\n")
assert_includes Backlog::Inspector.issue_files(dir), "ISSUES.md"
end
end
def test_returns_empty_when_nothing_present
Dir.mktmpdir do |dir|
assert_empty Backlog::Inspector.issue_files(dir)
end
end
end

View File

@ -0,0 +1,70 @@
require_relative "test_helper"
class TrackerTest < Minitest::Test
def test_accepts_planka_format
assert Backlog::Tracker.valid?("planka:llf-schema")
assert_equal :planka, Backlog::Tracker.kind("planka:llf-schema")
end
def test_accepts_forgejo_format
assert Backlog::Tracker.valid?("forgejo:jared/cc-os")
assert_equal :forgejo, Backlog::Tracker.kind("forgejo:jared/cc-os")
end
def test_accepts_github_format
assert Backlog::Tracker.valid?("github:acme/widget")
assert_equal :github, Backlog::Tracker.kind("github:acme/widget")
end
def test_accepts_repo_format
assert Backlog::Tracker.valid?("repo:docs/issues")
assert_equal :repo, Backlog::Tracker.kind("repo:docs/issues")
end
def test_rejects_garbage
refute Backlog::Tracker.valid?("just some text")
refute Backlog::Tracker.valid?("forgejo:missing-slash")
refute Backlog::Tracker.valid?("")
refute Backlog::Tracker.valid?(nil)
end
def test_rejects_unknown_prefix
refute Backlog::Tracker.valid?("jira:PROJ-123")
end
def test_parse_remote_detects_forgejo_ssh_url
output = "origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (fetch)\n" \
"origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (push)\n"
info = Backlog::Tracker.parse_remote(output)
assert_equal :forgejo, info[:kind]
assert_equal "jared", info[:owner]
assert_equal "cc-os", info[:repo]
end
def test_parse_remote_detects_github_https_url
output = "origin\thttps://github.com/acme/widget.git (fetch)\n"
info = Backlog::Tracker.parse_remote(output)
assert_equal :github, info[:kind]
assert_equal "acme", info[:owner]
assert_equal "widget", info[:repo]
end
def test_parse_remote_detects_github_scp_url
output = "origin\tgit@github.com:acme/widget.git (fetch)\n"
info = Backlog::Tracker.parse_remote(output)
assert_equal :github, info[:kind]
assert_equal "acme", info[:owner]
assert_equal "widget", info[:repo]
end
def test_parse_remote_classifies_other_hosts_as_unknown
output = "origin\thttps://gitlab.com/acme/widget.git (fetch)\n"
info = Backlog::Tracker.parse_remote(output)
assert_equal :unknown, info[:kind]
end
def test_parse_remote_returns_nil_for_blank_output
assert_nil Backlog::Tracker.parse_remote("")
assert_nil Backlog::Tracker.parse_remote(nil)
end
end

View File

@ -197,11 +197,46 @@ def orchestration_audit_due(ctx: Ctx) -> CheckResult:
TRACKER_ABSENT_NOTE = (
"No 'tracker' key set in .cc-os/config — this project's issue tracker is"
" unregistered. Run the os-backlog routing skill to pick a tracker and"
" unregistered. Run /os-backlog:route to pick a tracker and"
" record it (planka:<board> | forgejo:<owner>/<repo> |"
" github:<owner>/<repo> | repo:<path>)."
)
# --- config version (ADR-026) ---------------------------------------------
CURRENT_CONFIG_VERSION = 1
def config_version_current(ctx: Ctx) -> CheckResult:
"""Warn when .cc-os/config has no 'version' key or an older one than
CURRENT_CONFIG_VERSION the project was set up under an older cc-os
approach. /os-status:fix stamps the current version."""
raw = str(ctx.config.get("version", "")).strip()
try:
version = int(raw)
except ValueError:
version = None
if version is not None and version >= CURRENT_CONFIG_VERSION:
return OK
return warn(
f".cc-os/config has no current 'version' key (found: {raw or 'unset'},"
f" current: {CURRENT_CONFIG_VERSION}) — run /os-status:fix to bring"
" this project up to date."
)
# --- project graph (ADR-026) -----------------------------------------------
def project_graph_present(ctx: Ctx) -> CheckResult:
"""Warn when the project has no Graphify codebase graph at its root."""
if (ctx.project_root / "graphify-out" / "graph.json").is_file():
return OK
return warn(
"No project Graphify graph found (graphify-out/graph.json) — run"
" /os-vault:onboard-project to build one."
)
_TRACKER_PATTERNS = {
"planka": re.compile(r"^[^/\s]+$"),
"forgejo": re.compile(r"^[^/\s]+/[^/\s]+$"),
@ -234,12 +269,108 @@ class Check:
name: str
fn: Callable
project_scoped: bool
remediation: str = ""
REGISTRY = [
Check("subagent-model-env-override", subagent_model_env_override, project_scoped=False),
Check("adr-system-present", adr_system_present, project_scoped=True),
Check("vault-hub-note-present", vault_hub_note_present, project_scoped=True),
Check("orchestration-audit-due", orchestration_audit_due, project_scoped=False),
Check("tracker-configured", tracker_configured, project_scoped=True),
Check(
"subagent-model-env-override",
subagent_model_env_override,
project_scoped=False,
remediation="remove CLAUDE_CODE_SUBAGENT_MODEL from the environment / settings.json",
),
Check(
"adr-system-present",
adr_system_present,
project_scoped=True,
remediation="/os-adr:init",
),
Check(
"vault-hub-note-present",
vault_hub_note_present,
project_scoped=True,
remediation="/os-vault:write (create a hub note) or set hub= in .cc-os/config",
),
Check(
"orchestration-audit-due",
orchestration_audit_due,
project_scoped=False,
remediation="/os-orchestration:audit-sessions",
),
Check(
"tracker-configured",
tracker_configured,
project_scoped=True,
remediation="/os-backlog:route",
),
Check(
"config-version-current",
config_version_current,
project_scoped=True,
remediation="/os-status:fix",
),
Check(
"project-graph-present",
project_graph_present,
project_scoped=True,
remediation="/os-vault:onboard-project",
),
]
# --- JSON runner (ADR-026) --------------------------------------------------
#
# `python3 hooks/checks.py --json` runs REGISTRY against the cwd project and
# prints a machine-readable JSON array for the /os-status:fix skill. This is
# additive: it does not change how session_start.py imports from this module.
def build_ctx() -> "Ctx":
from state import find_project_root, read_config # local import, no cycle
root = find_project_root(Path.cwd())
config = read_config(root) if root else {}
return Ctx(
project_root=root,
settings_path=Path.home() / ".claude" / "settings.json",
vault_path=Path(os.path.expanduser(config.get("vault_path", DEFAULT_VAULT_PATH))),
config=config,
environ=dict(os.environ),
)
def run_all_json(ctx: "Ctx") -> list:
"""Run every applicable check and return a list of plain dicts:
{name, status, message, remediation}. project_scoped checks are skipped
when ctx.project_root is None, same rule as the SessionStart runner."""
results = []
for check in REGISTRY:
if check.project_scoped and ctx.project_root is None:
continue
try:
result = check.fn(ctx)
if not isinstance(result, CheckResult):
raise TypeError("check returned a non-CheckResult")
except Exception:
result = CheckResult("warn", f"status check '{check.name}' failed to run")
results.append(
{
"name": check.name,
"status": result.status,
"message": result.message,
"remediation": check.remediation,
}
)
return results
def _main_json() -> int:
print(json.dumps(run_all_json(build_ctx())))
return 0
if __name__ == "__main__":
import sys
if "--json" in sys.argv[1:]:
raise SystemExit(_main_json())

View File

@ -26,7 +26,10 @@ from checks import DEFAULT_VAULT_PATH, REGISTRY, CheckResult, Ctx # noqa: E402
from state import StateDir, find_project_root, read_config # noqa: E402
CHECK_BUDGET_SECONDS = 2
BANNER_HEADER = "[os-status] Session preconditions need attention:"
BANNER_HEADER = (
"[os-status] Session preconditions need attention (run /os-status:fix to"
" address all of these):"
)
def run_isolated(check, ctx) -> CheckResult:

View File

@ -36,6 +36,32 @@ def read_config(root: Path) -> dict:
return config
def write_config_value(root: Path, key: str, value: str) -> None:
"""Set key=value in .cc-os/config, preserving every other line
(comments included) and appending the key if absent. Used by
/os-status:fix to stamp the config 'version' key."""
config_path = root / ".cc-os" / "config"
try:
lines = config_path.read_text().splitlines()
except Exception:
lines = []
found = False
out = []
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
existing_key = stripped.split("=", 1)[0].strip()
if existing_key == key:
out.append(f"{key} = {value}")
found = True
continue
out.append(line)
if not found:
out.append(f"{key} = {value}")
config_path.parent.mkdir(exist_ok=True)
config_path.write_text("\n".join(out) + "\n")
class StateDir:
"""The gitignored per-project .cc-os/ state directory."""

View File

@ -0,0 +1,94 @@
---
description: Bring the current project up to the current cc-os approach by remediating whatever /os-status checks flag — idempotent, doubles as the update path. Invoked by /os-status:fix.
---
# fix
Unified project setup/update. Runs the same check registry the SessionStart hook
runs, then drives each failing check's remediation. Per ADR-026: `fix` orchestrates
existing per-plugin skills — it never reimplements them. Idempotent by construction:
re-running `fix` on an already-configured project is the update path, not a separate
command.
## Flow
1. **Get machine-readable results.** From the project root, run:
```
python3 ${CLAUDE_PLUGIN_ROOT}/hooks/checks.py --json
```
This prints a JSON array of `{name, status, message, remediation}` for every
check applicable to the current project (project-scoped checks are skipped
outside a git project, same rule the SessionStart hook uses).
2. **All `ok` (and only `note`/`ok`)?** Report "this project is up to date" and go
straight to step 4 (version stamp) — nothing else to do.
3. **Otherwise, walk the non-`ok` entries in this order** (mechanical → decision-
bearing, so autonomous fixes land before anything needing a human gate):
a. **`adr-system-present`** → invoke `/os-adr:init` (or `/os-adr:migrate` if the
project already has decision-log-like content the message/context suggests —
use judgment, this is mechanical either way).
b. **`vault-hub-note-present`** → either invoke `/os-vault:write` to create a hub
note (tags `type/hub` + `project/<name>`), or if the user says a hub note
already exists under a different name, set `hub = <slug>` in `.cc-os/config`
via the config-write helper (see step 4).
c. **`project-graph-present`** → invoke `/os-vault:onboard-project`.
d. **`tracker-configured`** → **human gate.** Do not guess a tracker. Ask the user
which tracker this project uses, then invoke `/os-backlog:route` (the
os-backlog routing skill) with that answer. If `/os-backlog:route` does not
exist yet in this installation (issue #14 not yet landed), tell the user and
skip — do not fabricate a `.cc-os/config` tracker value yourself.
e. **`subagent-model-env-override`** → **human gate, and typically out of scope
for a project-level fix.** This is an environment/settings.json condition, not
a per-project one. Report it and ask the user to remove the env var
themselves; do not edit `~/.claude/settings.json` from this skill.
f. **`config-version-current`** → resolved automatically by step 4 below; no
separate action.
Re-run the JSON check after each remediation that plausibly changed state, so
later steps see fresh results (e.g. don't act on a stale `vault-hub-note-present`
warning after already creating the note).
4. **Stamp the config version.** Once the mechanical/human-gated fixes above are
done (or were already `ok`), write the current version into `.cc-os/config`,
preserving every other key. Use the helper:
```python
import sys
sys.path.insert(0, "${CLAUDE_PLUGIN_ROOT}/hooks")
from state import write_config_value, find_project_root
from checks import CURRENT_CONFIG_VERSION
from pathlib import Path
root = find_project_root(Path.cwd())
write_config_value(root, "version", str(CURRENT_CONFIG_VERSION))
```
(Equivalently, run it as a one-off `python3 -c "..."` from the project root.)
This is what makes `config-version-current` pass on the next run and what makes
re-running `fix` on a fully-configured project a fast, silent no-op.
5. **Report a short summary**: which checks were already `ok`, which were fixed and
how, which were skipped pending a human decision, and confirm the config version
was stamped.
## Notes
- Never edit `.cc-os/config` by hand-writing the whole file — always go through
`write_config_value` (or the equivalent read-modify-write) so unrelated keys
(`hub`, `tracker`, `vault_path`, ...) are preserved.
- Decision-bearing steps (tracker destination, anything destructive) keep their
human gate even when this skill is otherwise running autonomously. Mechanical
steps (running `/os-adr:init`, `/os-vault:onboard-project`, stamping the version)
proceed without asking.
- This skill does not touch `subagent-model-env-override` state — that's a machine
environment condition, not a per-project one, and editing global settings.json is
out of scope.

View File

@ -20,6 +20,7 @@ import checks # noqa: E402
import session_start # noqa: E402
from checks import ( # noqa: E402
ABSENT_NOTE,
CURRENT_CONFIG_VERSION,
ENV_VAR,
PRESENT_NOTE,
TRACKER_ABSENT_NOTE,
@ -27,11 +28,14 @@ from checks import ( # noqa: E402
CheckResult,
Ctx,
adr_system_present,
config_version_current,
project_graph_present,
run_all_json,
subagent_model_env_override,
tracker_configured,
vault_hub_note_present,
)
from state import StateDir, find_project_root, read_config # noqa: E402
from state import StateDir, find_project_root, read_config, write_config_value # noqa: E402
TODAY = date(2026, 7, 6)
YESTERDAY = date(2026, 7, 5)
@ -238,7 +242,7 @@ class TrackerCheckTest(unittest.TestCase):
root = git_project(tmp)
result = tracker_configured(make_ctx(project_root=root))
self.assertEqual(CheckResult("warn", TRACKER_ABSENT_NOTE), result)
self.assertIn("os-backlog routing skill", result.message)
self.assertIn("/os-backlog:route", result.message)
def test_planka_value_is_ok(self):
with TemporaryDirectory() as tmp:
@ -291,6 +295,130 @@ class TrackerCheckTest(unittest.TestCase):
self.assertTrue(entry.project_scoped)
class ConfigVersionCheckTest(unittest.TestCase):
def test_missing_version_warns(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
result = config_version_current(make_ctx(project_root=root))
self.assertEqual("warn", result.status)
self.assertIn("/os-status:fix", result.message)
def test_current_version_is_ok(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
ctx = make_ctx(project_root=root, config={"version": str(CURRENT_CONFIG_VERSION)})
self.assertEqual("ok", config_version_current(ctx).status)
def test_old_version_warns(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
ctx = make_ctx(project_root=root, config={"version": "0"})
self.assertEqual("warn", config_version_current(ctx).status)
def test_non_numeric_version_warns_without_crashing(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
ctx = make_ctx(project_root=root, config={"version": "banana"})
result = config_version_current(ctx)
self.assertEqual("warn", result.status)
class ProjectGraphCheckTest(unittest.TestCase):
def test_missing_graph_warns_naming_onboard(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
result = project_graph_present(make_ctx(project_root=root))
self.assertEqual("warn", result.status)
self.assertIn("/os-vault:onboard-project", result.message)
def test_present_graph_is_ok(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
(root / "graphify-out").mkdir()
(root / "graphify-out" / "graph.json").write_text("{}")
result = project_graph_present(make_ctx(project_root=root))
self.assertEqual("ok", result.status)
class RemediationTest(unittest.TestCase):
def test_every_registry_entry_has_a_remediation(self):
for entry in checks.REGISTRY:
self.assertTrue(entry.remediation, f"{entry.name} has no remediation")
def test_known_remediations(self):
by_name = {c.name: c.remediation for c in checks.REGISTRY}
self.assertEqual("/os-adr:init", by_name["adr-system-present"])
self.assertEqual("/os-backlog:route", by_name["tracker-configured"])
self.assertEqual("/os-vault:onboard-project", by_name["project-graph-present"])
self.assertEqual("/os-status:fix", by_name["config-version-current"])
class JsonRunnerTest(unittest.TestCase):
def test_shape_has_name_status_message_remediation(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
ctx = make_ctx(project_root=root)
results = run_all_json(ctx)
self.assertTrue(results)
for entry in results:
self.assertEqual({"name", "status", "message", "remediation"}, set(entry.keys()))
def test_project_scoped_checks_skipped_when_no_project_root(self):
results = run_all_json(make_ctx(project_root=None))
names = {r["name"] for r in results}
self.assertNotIn("adr-system-present", names)
self.assertNotIn("tracker-configured", names)
def test_subprocess_json_flag_prints_valid_json_array(self):
with TemporaryDirectory() as tmp:
root = git_project(tmp)
proc = subprocess.run(
[sys.executable, str(PLUGIN_ROOT / "hooks" / "checks.py"), "--json"],
cwd=str(root),
capture_output=True,
text=True,
)
self.assertEqual(0, proc.returncode)
payload = json.loads(proc.stdout)
self.assertIsInstance(payload, list)
names = {e["name"] for e in payload}
self.assertIn("subagent-model-env-override", names)
class WriteConfigValueTest(unittest.TestCase):
def test_stamps_new_key(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
write_config_value(root, "version", "1")
self.assertEqual({"version": "1"}, read_config(root))
def test_preserves_existing_keys(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "config").write_text("hub = my-hub\ntracker = planka:board\n")
write_config_value(root, "version", "1")
self.assertEqual(
{"hub": "my-hub", "tracker": "planka:board", "version": "1"},
read_config(root),
)
def test_updates_existing_key_in_place(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
(root / ".cc-os").mkdir()
(root / ".cc-os" / "config").write_text("version = 0\nhub = my-hub\n")
write_config_value(root, "version", "1")
config = read_config(root)
self.assertEqual("1", config["version"])
self.assertEqual("my-hub", config["hub"])
class BannerWordingTest(unittest.TestCase):
def test_banner_header_names_fix_skill(self):
self.assertIn("/os-status:fix", session_start.BANNER_HEADER)
class RunnerTest(unittest.TestCase):
"""run() contract: routing, aggregation, snooze/suppress, isolation."""