From 9415eb0d65894c24a8062a07c5a71f785df27998 Mon Sep 17 00:00:00 2001 From: jared Date: Mon, 13 Jul 2026 10:32:58 -0400 Subject: [PATCH] os-backlog: route inspect issue-shape classification, split-by-kind default (issue #26) inspect fetches remote-issue metadata (fail-soft) and emits issue_shape: sequential-chain|flat-adhoc|mixed|null via a pure classifier. Route step 2 rewritten as three branches with split as the default for spec-shaped issue sets (one tracker key, no migration); step 3 never-re-trigger exception; boundary rule cites ADR-0033. Suite 103/212/0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho --- docs/implementation-status.md | 3 +- docs/implementation-status/os-backlog.md | 16 ++- plugins/os-backlog/bin/os-backlog | 50 +++++++--- plugins/os-backlog/lib/backlog/inspector.rb | 56 +++++++++++ plugins/os-backlog/skills/route/SKILL.md | 20 ++-- plugins/os-backlog/tests/inspector_test.rb | 102 ++++++++++++++++++++ 6 files changed, 221 insertions(+), 26 deletions(-) diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 0b379ac..127414f 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -63,7 +63,8 @@ entry is in the leaf file named. this file distilled to index+PD (issue #25 parts 2–3, ADR-0032). os-backlog slice 7 shipped: SessionStart injection note + /to-issues tracker routing (issue #16); board-id cache via BoardResolver (issue #22); slice 8 — Operations board + tracker-routing rubric - category + ADR-0033 canonizing Planka-state/git-issues-spec (issue #17). Detail: + category + ADR-0033 canonizing Planka-state/git-issues-spec (issue #17); route inspect + issue-shape classification + split-by-kind default (issue #26). Detail: os-context / os-doc-hygiene / os-backlog leaves. **Remaining optional items:** additional project onboarding (one at a time, per ADR-0013); diff --git a/docs/implementation-status/os-backlog.md b/docs/implementation-status/os-backlog.md index 9c4ca46..42ebca1 100644 --- a/docs/implementation-status/os-backlog.md +++ b/docs/implementation-status/os-backlog.md @@ -97,6 +97,16 @@ column ownership). 0 failures). os-status `/os-status:fix` step d reworded: invoke `/os-backlog:route` directly, never pre-ask "forgejo or planka?" (boundary rule uses both — surfaced by the servers-repo onboarding, cf. issue #26). -- **Outstanding:** #14 routing-skill rollout (onboard cc-os itself, then one more project); - #26 route-inspect issue-shape classification; #27 cross-project filing index; #28 wakeup - design spike. +- **Issue-shape classification (2026-07-13, issue #26):** `inspect` now fetches lightweight + remote-issue metadata (tea `--output json` with string-index/joined-labels normalization; + gh `--json`; fail-soft → nil) and emits an always-present `issue_shape` key + (`sequential-chain` / `flat-adhoc` / `mixed` / null). Classification is a pure + `Inspector.classify_issue_shape` (label allowlist with whole-token matching; cross-refs + must hit the fetched set with dependency language within 40 chars; <3 issues or no + metadata → null). Route SKILL.md step 2 rewritten as three branches with **split as the + default** for spec-shaped issue sets (still exactly one tracker key — division of labor, + not two destinations); step 3 got a never-re-trigger exception; boundary rule now cites + ADR-0033 instead of restating it. 12 new tests incl. a CLI-contract test; suite 103 runs / + 212 assertions / 0 failures. +- **Outstanding:** #14 residual (onboard one more project — operational hitl); #27 + cross-project filing index; #28 wakeup design spike. diff --git a/plugins/os-backlog/bin/os-backlog b/plugins/os-backlog/bin/os-backlog index afc4c14..8424b3f 100755 --- a/plugins/os-backlog/bin/os-backlog +++ b/plugins/os-backlog/bin/os-backlog @@ -99,37 +99,55 @@ 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. +# Best-effort open-issue count + lightweight per-issue metadata via tea +# (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails +# soft per-field: any fetch/parse failure means metadata nil. Returns +# [issue_cli_hash, metadata_or_nil] — the metadata feeds the pure +# Backlog::Inspector.classify_issue_shape and never appears in the JSON. 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" } + return [{ "tool" => nil, "open_count" => nil, "reason" => "no forgejo/github remote detected" }, nil] end repo_slug = "#{remote_info[:owner]}/#{remote_info[:repo]}" if remote_info[:kind] == :forgejo - tea_issue_count(repo_slug) + tea_issues(repo_slug) else - gh_issue_count(repo_slug) + gh_issues(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") +def tea_issues(repo_slug) + return [{ "tool" => "tea", "open_count" => nil, "reason" => "tea CLI not found" }, nil] unless system("which tea > /dev/null 2>&1") + out = `tea issues list --repo #{repo_slug} --state open --fields index,title,labels,body --output json 2>/dev/null` + parsed = JSON.parse(out) + issues = parsed.map do |issue| + { number: issue["index"].to_i, title: issue["title"], + labels: issue["labels"].to_s.split(",").map(&:strip).reject(&:empty?), + body: issue["body"] } + end + [{ "tool" => "tea", "open_count" => issues.size }, issues] +rescue StandardError + # JSON mode unavailable/unparsable: fall back to the plain line count. 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 } + [{ "tool" => "tea", "open_count" => lines.size }, nil] 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") +def gh_issues(repo_slug) + return [{ "tool" => "gh", "open_count" => nil, "reason" => "gh CLI not found" }, nil] unless system("which gh > /dev/null 2>&1") - out = `gh issue list --repo #{repo_slug} --state open --json number 2>/dev/null` + out = `gh issue list --repo #{repo_slug} --state open --json number,title,labels,body 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" } + issues = parsed.map do |issue| + { number: issue["number"].to_i, title: issue["title"], + labels: Array(issue["labels"]).map { |label| label["name"] }, + body: issue["body"] } + end + [{ "tool" => "gh", "open_count" => issues.size }, issues] +rescue StandardError + [{ "tool" => "gh", "open_count" => nil, "reason" => "could not parse gh output" }, nil] end command, *rest = ARGV @@ -218,12 +236,14 @@ when "inspect" remote_output = `git -C #{Shellwords.escape(repo_path)} remote -v 2>/dev/null` remote_info = Backlog::Tracker.parse_remote(remote_output) + issue_cli, issue_metadata = inspect_issue_cli(remote_info) 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), + "issue_cli" => issue_cli, + "issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata), "in_repo_issue_files" => Backlog::Inspector.issue_files(repo_path) } puts JSON.pretty_generate(findings) diff --git a/plugins/os-backlog/lib/backlog/inspector.rb b/plugins/os-backlog/lib/backlog/inspector.rb index 3acc3a2..c02a092 100644 --- a/plugins/os-backlog/lib/backlog/inspector.rb +++ b/plugins/os-backlog/lib/backlog/inspector.rb @@ -8,6 +8,62 @@ module Backlog CANDIDATE_DIRS = ["docs/issues"].freeze CANDIDATE_FILES = ["ISSUES.md"].freeze + # Labels that mark an issue as part of a dependency chain. Matched as + # whole (hyphen-preserving) tokens, case-insensitively — "not-blocked" + # does NOT match "blocked". + CHAIN_LABELS = %w[blocked blocked-by depends depends-on prereq + prerequisite waiting needs].freeze + + # Dependency language that must appear near a cross-reference for it to + # count as chain evidence — a plain "fixed in #12" does not. + DEPENDENCY_LANGUAGE = /\b(blocked|depends|after|requires|prereq|follows)/i + CROSS_REF_WINDOW = 40 # chars either side of the "#N" reference + + # Pure classifier for the shape of a repo's open git issues — no shell, + # no network. Feeds the route skill's split-vs-migrate recommendation + # (ADR-0033: git issues are specs; a chain of interdependent issues is + # spec-shaped and should stay put). + # + # @param issues [Array, nil] one hash per open issue: + # {number:, title:, labels: [String], body:} + # @return [String, nil] "sequential-chain" | "flat-adhoc" | "mixed", or + # nil when there's no metadata or fewer than 3 issues (no evidence, + # which is distinct from evidence of flatness) + def self.classify_issue_shape(issues) + return nil if issues.nil? || issues.size < 3 + + numbers = issues.map { |issue| issue[:number].to_i } + with_evidence = issues.count { |issue| chain_evidence?(issue, numbers) } + return "flat-adhoc" if with_evidence.zero? + + with_evidence * 3 >= issues.size * 2 ? "sequential-chain" : "mixed" + end + + def self.chain_evidence?(issue, numbers) + chain_label?(issue[:labels]) || + chain_cross_reference?(issue[:body], own_number: issue[:number].to_i, numbers: numbers) + end + + def self.chain_label?(labels) + Array(labels).any? do |label| + tokens = label.to_s.downcase.scan(/[a-z][a-z-]*[a-z]|[a-z]/) + tokens.any? { |token| CHAIN_LABELS.include?(token) } + end + end + + def self.chain_cross_reference?(body, own_number:, numbers:) + text = body.to_s + text.enum_for(:scan, /#(\d+)/).any? do + match = Regexp.last_match + referenced = match[1].to_i + next false if referenced == own_number || !numbers.include?(referenced) + + window_start = [match.begin(0) - CROSS_REF_WINDOW, 0].max + window = text[window_start, (match.begin(0) - window_start) + match[0].length + CROSS_REF_WINDOW] + window.match?(DEPENDENCY_LANGUAGE) + end + end + # @param repo_path [String] absolute path to the repo # @return [Array] repo-relative paths of in-repo issue tracking # found (empty if none) diff --git a/plugins/os-backlog/skills/route/SKILL.md b/plugins/os-backlog/skills/route/SKILL.md index b8469c8..66075d4 100644 --- a/plugins/os-backlog/skills/route/SKILL.md +++ b/plugins/os-backlog/skills/route/SKILL.md @@ -6,11 +6,13 @@ Register a project's issue tracker: figure out what already exists, propose wher ## The boundary rule (what you're deciding between) -- **Planka = state.** Cards track *what's happening now* — backlog/next/doing/review/done. Use `planka:` when the project's work is mostly ephemeral tasks, small fixes, or process/coordination — nothing that needs a durable written spec. -- **Git issues = specs only.** Use `forgejo:/` or `github:/` when work is specified as text that needs to survive and be referenced (features, migrations, architecture-level changes) — tracer-bullet slices from `/to-issues`, PRDs from `/to-prd`. -- **`repo:`** is the escape hatch for a project that already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to keep doing so without moving to Planka or a git host's issue tracker. -- **Card-as-pointer + issue-chain-as-spec, for code efforts:** when a body of work needs both — ongoing state tracking AND a durable spec — the Planka card is a pointer (title + link) into a chain of git issues that hold the actual spec. Don't duplicate the spec text into the card description. -- **Promotion rule:** if a Planka-tracked effort accretes significant code/design decisions in card comments, that's a signal to promote it — open a git issue (or issue chain) holding the durable spec, and turn the Planka card into a pointer per the rule above. Flag this to the user when you see it happening; don't do it silently. +Planka = state, git issues = specs (ADR-0033, `docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md`) — the ADR is the canonical statement; the operative routing rules are: + +- **WHEN** the project's work is mostly ephemeral tasks, small fixes, or process/coordination (state: backlog/next/doing/review/done, nothing needing a durable written spec) → `planka:`. +- **WHEN** work is specified as text that must survive and be referenced (features, migrations, architecture-level changes, tracer-bullet slices from `/to-issues`, PRDs from `/to-prd`) → `forgejo:/` or `github:/`. +- **WHEN** a project already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to stay there → `repo:` (the escape hatch). +- **WHEN** a code effort needs both state tracking and a durable spec → card-as-pointer + issue-chain-as-spec: the Planka card is a pointer (title + link) into the git issue chain holding the spec; never duplicate spec text into the card description. +- **WHEN** a Planka-tracked effort accretes significant code/design decisions in card comments → flag promotion to the user (open a git issue chain for the spec, turn the card into a pointer); never promote silently. Valid tracker key formats (exactly one, written to `.cc-os/config`'s `tracker` key): `planka:` | `forgejo:/` | `github:/` | `repo:`. @@ -26,12 +28,16 @@ All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`. - `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). + - `issue_shape` — a classification of the open git issues' shape: `sequential-chain` (most issues carry dependency labels or blocked-by/depends-on cross-references), `flat-adhoc` (no dependency structure), `mixed` (some), or `null` (no metadata, or fewer than 3 open issues — no evidence either way). - `in_repo_issue_files` — `docs/issues/` and/or `ISSUES.md` if present. -2. **Synthesize and propose (NAMED DECISION GATE).** Given the findings, propose exactly ONE destination tracker key with a short rationale grounded in the boundary rule above — e.g. "this repo has an active Forgejo remote with 11 open issues and no Planka board; issues already carry specs, so `forgejo:jared/cc-os` fits git-issues-as-spec" or "no existing tracking found, work here is small ad hoc fixes; `planka:` fits Planka-as-state; a board will be created on first capture, not by this skill." +2. **Synthesize and propose (NAMED DECISION GATE).** Exactly ONE tracker key is ever written to `.cc-os/config` — the branches below differ in *which* key and *what happens to existing issues*, never in how many keys. Branch on the findings: + - **(a) No existing tracker found** (no open git issues, no in-repo issue files, no live board): propose one destination key with a short rationale grounded in the boundary rule — e.g. "no existing tracking found, work here is small ad hoc fixes; `planka:` fits Planka-as-state; a board will be created on first capture, not by this skill." + - **(b) Existing git issues that are spec-shaped** (`issue_shape` is `sequential-chain` or `mixed`): recommend the **SPLIT as the default**. Write `planka:` as the single declared tracker — it governs new/ephemeral capture only. The existing git issues are specs per ADR-0033: they stay exactly where they are, unmigrated, and remain the durable spec surface; skip migration entirely (step 3 does not apply to them). This is still exactly one tracker key — the split is a division of labor (state vs specs), not two destinations. + - **(c) Existing git issues that are flat-adhoc** (`issue_shape` is `flat-adhoc`): migration to Planka is on the table — propose the destination key, and if confirmed, existing open issues go through the step-3 migration gate as usual. If `issue_shape` is `null` (no metadata or too few issues to tell), treat shape as unknown: ask the user which shape fits rather than assuming. **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: +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). **Exception — never re-trigger on the split:** a `planka:` tracker alongside by-design git spec issues (branch 2b, or any project whose git issues are specs per ADR-0033) is the intended end state, not a discrepancy; do not propose migrating those issues, now or on any later re-run of this skill. 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. diff --git a/plugins/os-backlog/tests/inspector_test.rb b/plugins/os-backlog/tests/inspector_test.rb index 73d71c2..ff27e05 100644 --- a/plugins/os-backlog/tests/inspector_test.rb +++ b/plugins/os-backlog/tests/inspector_test.rb @@ -1,6 +1,7 @@ require_relative "test_helper" require "tmpdir" require "fileutils" +require "json" class InspectorTest < Minitest::Test def test_detects_docs_issues_dir @@ -23,3 +24,104 @@ class InspectorTest < Minitest::Test end end end + +class ClassifyIssueShapeTest < Minitest::Test + def issue(number, title: "issue #{number}", labels: [], body: "") + { number: number, title: title, labels: labels, body: body } + end + + def test_nil_when_metadata_unavailable + assert_nil Backlog::Inspector.classify_issue_shape(nil) + end + + def test_nil_when_fewer_than_three_issues_even_with_chain_labels + issues = [issue(1, labels: ["blocked"]), issue(2, labels: ["blocked"])] + assert_nil Backlog::Inspector.classify_issue_shape(issues) + end + + def test_flat_adhoc_when_no_issue_shows_chain_evidence + issues = [issue(1), issue(2), issue(3, body: "fixed in #1 last week")] + assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues) + end + + def test_sequential_chain_from_labels + issues = [ + issue(1, labels: ["Blocked-By"]), + issue(2, labels: ["depends-on"]), + issue(3, labels: ["enhancement"]) + ] + assert_equal "sequential-chain", Backlog::Inspector.classify_issue_shape(issues) + end + + def test_negated_label_token_does_not_count + issues = [issue(1, labels: ["not-blocked"]), issue(2), issue(3)] + assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues) + end + + def test_cross_reference_near_dependency_language_counts + issues = [ + issue(1, body: "This is blocked by #2 until the schema lands."), + issue(2, body: "Must land after #3."), + issue(3, body: "Standalone groundwork.") + ] + assert_equal "sequential-chain", Backlog::Inspector.classify_issue_shape(issues) + end + + def test_plain_cross_reference_without_dependency_language_does_not_count + issues = [ + issue(1, body: "See also #2 for background."), + issue(2, body: "Related: #3."), + issue(3) + ] + assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues) + end + + def test_reference_to_number_outside_fetched_set_does_not_count + issues = [ + issue(1, body: "blocked by #99 upstream"), + issue(2), + issue(3) + ] + assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues) + end + + def test_dependency_language_too_far_from_reference_does_not_count + padding = "x" * 60 + issues = [ + issue(1, body: "blocked #{padding} see #2"), + issue(2), + issue(3) + ] + assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues) + end + + def test_mixed_when_some_but_not_most_show_evidence + issues = [issue(1, labels: ["blocked"]), issue(2), issue(3)] + assert_equal "mixed", Backlog::Inspector.classify_issue_shape(issues) + end + + def test_self_reference_does_not_count + issues = [ + issue(1, body: "depends on #1 (this issue)"), + issue(2), + issue(3) + ] + assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues) + end +end + +class InspectCliContractTest < Minitest::Test + BIN = File.expand_path("../bin/os-backlog", __dir__) + + # `inspect` must always expose the issue_shape key, even when every + # network-ish check degrades (tmpdir: no git remote, no config). Value is + # null here because no metadata is fetchable — that's the fail-soft path. + def test_inspect_json_always_contains_issue_shape_key + Dir.mktmpdir do |dir| + out = Dir.chdir(dir) { `ruby #{BIN} inspect 2>/dev/null` } + findings = JSON.parse(out) + assert findings.key?("issue_shape"), "inspect JSON missing issue_shape key" + assert_nil findings["issue_shape"] + end + end +end