diff --git a/docs/implementation-status.md b/docs/implementation-status.md index d76721d..63adca2 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -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/` 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 diff --git a/plugins/os-backlog/bin/os-backlog b/plugins/os-backlog/bin/os-backlog index 30fd268..67a6795 100755 --- a/plugins/os-backlog/bin/os-backlog +++ b/plugins/os-backlog/bin/os-backlog @@ -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 (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} ") 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: | forgejo:/ | github:/ | repo:") + 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 [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 (write tracker key to .cc-os/config) USAGE exit(command.nil? ? 1 : 0) else diff --git a/plugins/os-backlog/lib/backlog.rb b/plugins/os-backlog/lib/backlog.rb index ce6038b..fa3bc22 100644 --- a/plugins/os-backlog/lib/backlog.rb +++ b/plugins/os-backlog/lib/backlog.rb @@ -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" diff --git a/plugins/os-backlog/lib/backlog/config.rb b/plugins/os-backlog/lib/backlog/config.rb index 244e52d..0bdddb1 100644 --- a/plugins/os-backlog/lib/backlog/config.rb +++ b/plugins/os-backlog/lib/backlog/config.rb @@ -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 diff --git a/plugins/os-backlog/lib/backlog/inspector.rb b/plugins/os-backlog/lib/backlog/inspector.rb new file mode 100644 index 0000000..3acc3a2 --- /dev/null +++ b/plugins/os-backlog/lib/backlog/inspector.rb @@ -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] 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 diff --git a/plugins/os-backlog/lib/backlog/tracker.rb b/plugins/os-backlog/lib/backlog/tracker.rb new file mode 100644 index 0000000..fee4c7d --- /dev/null +++ b/plugins/os-backlog/lib/backlog/tracker.rb @@ -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 diff --git a/plugins/os-backlog/skills/route/SKILL.md b/plugins/os-backlog/skills/route/SKILL.md new file mode 100644 index 0000000..b8469c8 --- /dev/null +++ b/plugins/os-backlog/skills/route/SKILL.md @@ -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:` 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:/` or `github:/` 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:`** 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:` | `forgejo:/` | `github:/` | `repo:`. + +## 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:` 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 + ``` + 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. diff --git a/plugins/os-backlog/tests/config_test.rb b/plugins/os-backlog/tests/config_test.rb index 15532a5..bd0bf69 100644 --- a/plugins/os-backlog/tests/config_test.rb +++ b/plugins/os-backlog/tests/config_test.rb @@ -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 diff --git a/plugins/os-backlog/tests/inspector_test.rb b/plugins/os-backlog/tests/inspector_test.rb new file mode 100644 index 0000000..73d71c2 --- /dev/null +++ b/plugins/os-backlog/tests/inspector_test.rb @@ -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 diff --git a/plugins/os-backlog/tests/tracker_test.rb b/plugins/os-backlog/tests/tracker_test.rb new file mode 100644 index 0000000..0173665 --- /dev/null +++ b/plugins/os-backlog/tests/tracker_test.rb @@ -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 diff --git a/plugins/os-status/hooks/checks.py b/plugins/os-status/hooks/checks.py index e86a60c..8254e90 100644 --- a/plugins/os-status/hooks/checks.py +++ b/plugins/os-status/hooks/checks.py @@ -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: | forgejo:/ |" " github:/ | repo:)." ) +# --- 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()) diff --git a/plugins/os-status/hooks/session_start.py b/plugins/os-status/hooks/session_start.py index 5b25673..f7b8c3e 100644 --- a/plugins/os-status/hooks/session_start.py +++ b/plugins/os-status/hooks/session_start.py @@ -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: diff --git a/plugins/os-status/hooks/state.py b/plugins/os-status/hooks/state.py index 1be1b93..ced1cee 100644 --- a/plugins/os-status/hooks/state.py +++ b/plugins/os-status/hooks/state.py @@ -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.""" diff --git a/plugins/os-status/skills/fix/SKILL.md b/plugins/os-status/skills/fix/SKILL.md new file mode 100644 index 0000000..150bce2 --- /dev/null +++ b/plugins/os-status/skills/fix/SKILL.md @@ -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/`), or if the user says a hub note + already exists under a different name, set `hub = ` 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. diff --git a/plugins/os-status/tests/hook_test.py b/plugins/os-status/tests/hook_test.py index 9bbb733..f9137ec 100644 --- a/plugins/os-status/tests/hook_test.py +++ b/plugins/os-status/tests/hook_test.py @@ -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."""