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
|
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.
|
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
|
**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.
|
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.
|
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` +
|
`.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`).
|
`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`.
|
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,
|
Smoke-tested 2026-07-06 (cache==source, real headless session fired the hook,
|
||||||
env-override canary warns).
|
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`,
|
- CLI: `bin/os-backlog` — `board-ensure`, `activate`, `archive`, `resolve`, `card-add`,
|
||||||
`cards`, `snapshot`, `card-label`, `card-comment`; every path fails soft (one-line error,
|
`cards`, `snapshot`, `card-label`, `card-comment`; every path fails soft (one-line error,
|
||||||
exit 1) when the gem/Planka is unavailable.
|
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
|
creates at Backlog, Doing→Review at most, never Next or Review→Done, never self-assigns
|
||||||
`hitl`); list is pull-only per notification policy v2.
|
`hitl`); list is pull-only per notification policy v2.
|
||||||
- Agents: `agents/card-triage.md` (priority + autonomy labels only; ambiguity → `hitl`),
|
- Agents: `agents/card-triage.md` (priority + autonomy labels only; ambiguity → `hitl`),
|
||||||
`agents/board-audit.md` (four drift classes; writes at most comments).
|
`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`
|
API. Companion os-status check `tracker-configured` (issue #11) validates the `tracker`
|
||||||
key grammar and nudges unconfigured git projects (daily-snoozed).
|
key grammar and nudges unconfigured git projects (daily-snoozed).
|
||||||
- Outstanding: Semi slices #14/#16/#17 (routing/migration skill, injection note +
|
- Outstanding: Semi slices #16/#17 (injection note + /to-issues routing, ops board + rubric
|
||||||
/to-issues routing, ops board + rubric category + routing ADR). Live verification
|
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;
|
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
|
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
|
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
|
# frozen_string_literal: true
|
||||||
#
|
#
|
||||||
# os-backlog CLI: board-ensure, activate, archive, resolve, card-add,
|
# os-backlog CLI: board-ensure, activate, archive, resolve, card-add,
|
||||||
# cards, snapshot.
|
# cards, snapshot, inspect, config-write.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# os-backlog board-ensure NAME --project Dev|Clients
|
# os-backlog board-ensure NAME --project Dev|Clients
|
||||||
|
|
@ -13,15 +13,21 @@
|
||||||
# os-backlog card-add --board NAME --title "..." [--description "..."]
|
# os-backlog card-add --board NAME --title "..." [--description "..."]
|
||||||
# os-backlog cards --board NAME (human-readable per-list card listing)
|
# 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 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
|
# board-ensure/activate/archive talk to Planka over the network (the
|
||||||
# installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/
|
# 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
|
# Fails soft: if the gem or Planka credentials are unavailable, prints one
|
||||||
# clear error and exits nonzero. Never destructive.
|
# clear error and exits nonzero. Never destructive.
|
||||||
|
|
||||||
require "json"
|
require "json"
|
||||||
|
require "fileutils"
|
||||||
|
require "shellwords"
|
||||||
require_relative "../lib/backlog"
|
require_relative "../lib/backlog"
|
||||||
|
|
||||||
def fail_soft(message)
|
def fail_soft(message)
|
||||||
|
|
@ -61,6 +67,55 @@ def require_flag(args, flag, command)
|
||||||
parse_flag(args, flag) || fail_soft("#{command} requires #{flag} <value>")
|
parse_flag(args, flag) || fail_soft("#{command} requires #{flag} <value>")
|
||||||
end
|
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
|
command, *rest = ARGV
|
||||||
|
|
||||||
begin
|
begin
|
||||||
|
|
@ -131,6 +186,39 @@ when "card-comment"
|
||||||
client = build_client
|
client = build_client
|
||||||
comment = Backlog::Cards.new(client: client).comment(card_id: card_id, text: text)
|
comment = Backlog::Cards.new(client: client).comment(card_id: card_id, text: text)
|
||||||
puts "commented on card ##{card_id} (comment ##{comment.id})"
|
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"
|
when nil, "-h", "--help"
|
||||||
puts <<~USAGE
|
puts <<~USAGE
|
||||||
usage: os-backlog <command> [options]
|
usage: os-backlog <command> [options]
|
||||||
|
|
@ -145,6 +233,8 @@ when nil, "-h", "--help"
|
||||||
snapshot --board NAME
|
snapshot --board NAME
|
||||||
card-label --board NAME --card ID --label NAME
|
card-label --board NAME --card ID --label NAME
|
||||||
card-comment --card ID --text "..."
|
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
|
USAGE
|
||||||
exit(command.nil? ? 1 : 0)
|
exit(command.nil? ? 1 : 0)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@ require_relative "backlog/board_spec"
|
||||||
require_relative "backlog/board_ensurer"
|
require_relative "backlog/board_ensurer"
|
||||||
require_relative "backlog/cards"
|
require_relative "backlog/cards"
|
||||||
require_relative "backlog/config"
|
require_relative "backlog/config"
|
||||||
|
require_relative "backlog/inspector"
|
||||||
require_relative "backlog/resolver"
|
require_relative "backlog/resolver"
|
||||||
|
require_relative "backlog/tracker"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,29 @@ module Backlog
|
||||||
@data["board"]
|
@data["board"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [String, nil] the tracker key (e.g. "forgejo:owner/repo"), if configured
|
||||||
|
def tracker
|
||||||
|
@data["tracker"]
|
||||||
|
end
|
||||||
|
|
||||||
def configured? = !board.nil?
|
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
|
||||||
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")
|
config = Backlog::Config.new("other: value\n")
|
||||||
refute config.configured?
|
refute config.configured?
|
||||||
end
|
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
|
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 = (
|
TRACKER_ABSENT_NOTE = (
|
||||||
"No 'tracker' key set in .cc-os/config — this project's issue tracker is"
|
"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> |"
|
" record it (planka:<board> | forgejo:<owner>/<repo> |"
|
||||||
" github:<owner>/<repo> | repo:<path>)."
|
" 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 = {
|
_TRACKER_PATTERNS = {
|
||||||
"planka": re.compile(r"^[^/\s]+$"),
|
"planka": re.compile(r"^[^/\s]+$"),
|
||||||
"forgejo": re.compile(r"^[^/\s]+/[^/\s]+$"),
|
"forgejo": re.compile(r"^[^/\s]+/[^/\s]+$"),
|
||||||
|
|
@ -234,12 +269,108 @@ class Check:
|
||||||
name: str
|
name: str
|
||||||
fn: Callable
|
fn: Callable
|
||||||
project_scoped: bool
|
project_scoped: bool
|
||||||
|
remediation: str = ""
|
||||||
|
|
||||||
|
|
||||||
REGISTRY = [
|
REGISTRY = [
|
||||||
Check("subagent-model-env-override", subagent_model_env_override, project_scoped=False),
|
Check(
|
||||||
Check("adr-system-present", adr_system_present, project_scoped=True),
|
"subagent-model-env-override",
|
||||||
Check("vault-hub-note-present", vault_hub_note_present, project_scoped=True),
|
subagent_model_env_override,
|
||||||
Check("orchestration-audit-due", orchestration_audit_due, project_scoped=False),
|
project_scoped=False,
|
||||||
Check("tracker-configured", tracker_configured, project_scoped=True),
|
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
|
from state import StateDir, find_project_root, read_config # noqa: E402
|
||||||
|
|
||||||
CHECK_BUDGET_SECONDS = 2
|
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:
|
def run_isolated(check, ctx) -> CheckResult:
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,32 @@ def read_config(root: Path) -> dict:
|
||||||
return config
|
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:
|
class StateDir:
|
||||||
"""The gitignored per-project .cc-os/ state directory."""
|
"""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
|
import session_start # noqa: E402
|
||||||
from checks import ( # noqa: E402
|
from checks import ( # noqa: E402
|
||||||
ABSENT_NOTE,
|
ABSENT_NOTE,
|
||||||
|
CURRENT_CONFIG_VERSION,
|
||||||
ENV_VAR,
|
ENV_VAR,
|
||||||
PRESENT_NOTE,
|
PRESENT_NOTE,
|
||||||
TRACKER_ABSENT_NOTE,
|
TRACKER_ABSENT_NOTE,
|
||||||
|
|
@ -27,11 +28,14 @@ from checks import ( # noqa: E402
|
||||||
CheckResult,
|
CheckResult,
|
||||||
Ctx,
|
Ctx,
|
||||||
adr_system_present,
|
adr_system_present,
|
||||||
|
config_version_current,
|
||||||
|
project_graph_present,
|
||||||
|
run_all_json,
|
||||||
subagent_model_env_override,
|
subagent_model_env_override,
|
||||||
tracker_configured,
|
tracker_configured,
|
||||||
vault_hub_note_present,
|
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)
|
TODAY = date(2026, 7, 6)
|
||||||
YESTERDAY = date(2026, 7, 5)
|
YESTERDAY = date(2026, 7, 5)
|
||||||
|
|
@ -238,7 +242,7 @@ class TrackerCheckTest(unittest.TestCase):
|
||||||
root = git_project(tmp)
|
root = git_project(tmp)
|
||||||
result = tracker_configured(make_ctx(project_root=root))
|
result = tracker_configured(make_ctx(project_root=root))
|
||||||
self.assertEqual(CheckResult("warn", TRACKER_ABSENT_NOTE), result)
|
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):
|
def test_planka_value_is_ok(self):
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
|
|
@ -291,6 +295,130 @@ class TrackerCheckTest(unittest.TestCase):
|
||||||
self.assertTrue(entry.project_scoped)
|
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):
|
class RunnerTest(unittest.TestCase):
|
||||||
"""run() contract: routing, aggregation, snooze/suppress, isolation."""
|
"""run() contract: routing, aggregation, snooze/suppress, isolation."""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue