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
This commit is contained in:
jared 2026-07-12 15:54:08 -04:00
parent ba34e547f7
commit 696aefcf23
15 changed files with 818 additions and 16 deletions

View File

@ -167,6 +167,24 @@ comment; `snapshot --board cc-os` showed the card with the full 20-key set (19 c
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.
@ -352,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).
@ -540,16 +564,21 @@ 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`); 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

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

@ -17,6 +17,29 @@ 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 YAML contents with `key` set to `value`, preserving every
# other key already present in `contents`. 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 YAML contents
def self.merge(contents, key, value)
data = new(contents).to_h
data[key.to_s] = value
YAML.dump(data)
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

@ -27,4 +27,36 @@ class ConfigTest < Minitest::Test
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
updated = Backlog::Config.new(Backlog::Config.merge(nil, "tracker", "forgejo:jared/cc-os"))
assert_equal "forgejo:jared/cc-os", updated.tracker
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."""