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:
parent
ba34e547f7
commit
696aefcf23
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue