Compare commits

...

13 Commits

Author SHA1 Message Date
jared 357d13270f os-context extract: read_write_profile denominator for read-write sub-tag
Total main-loop Read/Write/Edit calls regardless of adjacency, split into
in-flagged-runs vs scattered (with line/target per scattered call), so the
rubric's missed-delegation/read-write sub-tag has a deterministic
denominator the consecutive-run scan cannot provide. Drops the stale
"sequential-dependent work is a valid reason" trailer superseded by
ADR-0043.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:19:38 -04:00
jared 930ac6e80d os-context: absolute delegation policy (ADR-0043)
10-orchestration.md rewritten from the per-task threshold heuristic to an
absolute policy: main loop as executive, enumerated E-a..E-d exemptions,
one persistent sonnet manager continued via SendMessage, opus escalation
on observed failure only. Audit rubric rewritten to match (exemption
decision procedure, exemption-chaining, delegation-overhead-mismatch,
accepted-cost severity modifiers, read-write sub-tag). WS4 eval retired
as a measurement instrument (contaminated + encodes superseded policy),
kept as harness template.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:19:32 -04:00
jared 4c71f65313 refresh-plugins: diff newest cache version dir, not first glob hit
Old version directories linger in the plugin cache after upgrades, and
verify_cache diffed an arbitrary (typically oldest) one against source,
reporting freshly refreshed plugins as STALE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XaCiJr2hkuRnJZv2eJmoWt
2026-07-17 08:32:44 -04:00
jared cc2f2914c5 Remediate cc-architect audit findings across os-* plugins (#75-#79)
- os-context: audit-sessions rubric updated to ADR-0042 git-issues-only
  boundary (#75)
- os-backlog: drop dead Planka Config#board/#configured?, retarget tests;
  record ADR-0025 exemption for bin/wakeup-poll in plugin CLAUDE.md (#76)
- os-vault: align hooks/config.py default to graphify-out; refresh stale
  eval/README.md WS2 status (#77)
- gitignore .pytest_cache/ (#78)
- cc-architect: plugin_config resolves actual marketplace from
  installed_plugins.json instead of assuming cc-os (#79)

Opus-reviewed PASS; os-backlog 85 runs / cc-architect script suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTE1yYvTEg1cJZBhKNRZvL
2026-07-16 16:36:23 -04:00
jared f2fe45bab8 openspec: to-issues renamed to to-tickets (Pocock v1.1) in issue-backlog spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgG2g9ooqbnBiZYGAonGYG
2026-07-16 16:24:36 -04:00
jared 06fff77d75 Merge branch 'worktree-os-sdlc' (os-sdlc concept map + to-tickets rename)
# Conflicts:
#	plugins/os-backlog/skills/route/SKILL.md
2026-07-16 16:24:25 -04:00
jared 23fa2fbdbd os-sdlc: /implement is a skill-as-orchestrator over the agent pipeline
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgG2g9ooqbnBiZYGAonGYG
2026-07-16 16:23:45 -04:00
jared 5c18ba696f os-sdlc: reviewer is a named agent with hook-assembled context packet
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgG2g9ooqbnBiZYGAonGYG
2026-07-16 16:18:28 -04:00
jared 4cc524b3a5 os-sdlc: observability node, ADR copy-tag-audit pattern, to-issues→to-tickets rename adopted
- reference/pipeline-observability.md: new tenth concept node (run exhaust,
  predefined lifespans via os-doc-hygiene, copy-tag-audit pattern)
- self-improvement-loops: ADR-audit mechanics via vault-copy hook + virgin-tag
- spec-and-ticket-layer: to-tickets confirmed as Pocock v1.1 rename of
  to-issues (leftover skill removed from ~/.agents/skills)
- OVERVIEW: map + index updated, grill queue recorded
- repo docs: live /to-issues references swapped to /to-tickets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgG2g9ooqbnBiZYGAonGYG
2026-07-16 16:08:23 -04:00
jared 65cc751c6f Merge branch 'worktree-os-plugin-standards-audit' (os-plugin standards audit)
Conflicts in CLAUDE.md and docs/implementation-status.md resolved keep-both:
main's Planka-retirement changes + the branch's standards-audit additions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HWNfGWoWnhrub4EJa1M1Y
2026-07-16 15:51:04 -04:00
jared 0dd9bc1bee Retire Planka: os-backlog reworked to git-issues-only (ADR-0042)
Executes OpenSpec change retire-planka-git-issues-only (archived, 25/25 tasks):
- os-backlog v0.3.0: Planka lib/CLI/agents deleted; issue-create/issues
  helpers (tea/gh/repo-file dispatch); ten-label taxonomy, human-only next
- planka: rejected fail-soft citing ADR-0042 in tracker grammar,
  config-write, and the os-status check
- Skills, SessionStart note (~687 -> ~444 tokens), /to-issues, os-status fix
  rewritten to the single-tracker model
- All boards snapshotted + pg-dumped (ovh-vps ~/planka-final-snapshot-2026-07-16);
  cards migrated to jared/cc-os#61-70, jared/ops#1-21, jared/llf-schema#7-10
- Planka server decommissioned; planka gem repo archived read-only
- Delta specs synced to openspec/specs/ (issue-backlog, issue-state-labels)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HWNfGWoWnhrub4EJa1M1Y
2026-07-16 15:48:40 -04:00
jared 79012c4fda os-sdlc: design mapped into concept-node reference docs + OVERVIEW v0.2
Decompose the 2026-07-16 design session into nine independent-context
nodes under plugins/os-sdlc/reference/, with OVERVIEW.md rewritten as
the central concept map + index. Also fixes the dangling
matt-pocock-skills-v1.1-notes.md reference (notes live in the vault as
matt-pocock-skills-v1-1-changes.md; ADR-0037 affected-paths cleaned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgG2g9ooqbnBiZYGAonGYG
2026-07-16 15:39:35 -04:00
jared a2b329dd7f Audit os-* plugins against cc-architect standards + remediate
Post-Phase-B standards audit (backlog ticket): ran cc-architect's
audit-plugin checklist against all 8 os-* plugins and remediated:

- index-style CLAUDE.md (ADR-0032) for every plugin; os-doc-hygiene's
  stale cc-plugins-era CLAUDE.md/PRD.md reconciled
- invariants.md added for os-shortcuts and os-vault
- naming convention folded into cc-architect references (canonical:
  references/conventions/cc-os-naming.md); CLAUDE.md + vault note point there
- repo-level bin/test runner for the mixed minitest/pytest suites (6 pass)
- os-vault settings.json hook wiring documented as deliberate
- ADR-0025/os-adr: exemption already recorded in the ADR; no retrofit
- deferred to retire-planka rework: os-backlog ADR-0025 deviations
  (Forgejo jared/cc-os#74), Planka refs in os-context/os-status

Detail: docs/implementation-status/standards-audit.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCs64GAhRWrnjwfGwS4biX
2026-07-16 15:34:41 -04:00
92 changed files with 2842 additions and 2597 deletions

View File

@ -22,7 +22,7 @@
{ {
"name": "os-context", "name": "os-context",
"source": "./plugins/os-context", "source": "./plugins/os-context",
"description": "Prompt-composer SessionStart plugin: concatenates prompts/session-start/*.md (currently session-orchestration rules — delegation threshold, explicit model routing on Agent spawns) into one additionalContext block, injected at session start and after compaction." "description": "Prompt-composer SessionStart plugin: concatenates prompts/session-start/*.md (session-orchestration + delegation-economics rules, kickoff conventions, standing safety rules, decision-memo format) into one additionalContext block, injected at session start and after compaction."
}, },
{ {
"name": "os-doc-hygiene", "name": "os-doc-hygiene",

1
.gitignore vendored
View File

@ -16,6 +16,7 @@ graphify-out/
# Python bytecode # Python bytecode
__pycache__/ __pycache__/
.pytest_cache/
# autoresearch skill run logs (eval iterations) # autoresearch skill run logs (eval iterations)
/autoresearch/ /autoresearch/

View File

@ -80,8 +80,9 @@ before modifying any of these:
design-template skills, SessionStart/End hooks, memsearch + vault git sync. Write-behavior design-template skills, SessionStart/End hooks, memsearch + vault git sync. Write-behavior
eval harness in `plugins/os-vault/eval/`. eval harness in `plugins/os-vault/eval/`.
- **os-context** (`plugins/os-context/`, renamed 2026-07-13 from os-orchestration) — a - **os-context** (`plugins/os-context/`, renamed 2026-07-13 from os-orchestration) — a
prompt-composer SessionStart plugin: concatenates `prompts/session-start/*.md` (currently prompt-composer SessionStart plugin: concatenates `prompts/session-start/*.md`
just `10-orchestration.md`, the session-orchestration + delegation-economics rules) into (orchestration/delegation rules, kickoff conventions, standing safety, decision-memo
format) into
one additionalContext block for all sessions; this repo carries no local override. Eval one additionalContext block for all sessions; this repo carries no local override. Eval
harness in `plugins/os-context/eval/`. harness in `plugins/os-context/eval/`.
- **os-status** (`plugins/os-status/`) — aggregated deterministic SessionStart checks - **os-status** (`plugins/os-status/`) — aggregated deterministic SessionStart checks
@ -92,6 +93,10 @@ before modifying any of these:
find skills; Eval A/B/C harnesses. cc-os retrofit done 2026-07-12 (promoted ahead of find skills; Eval A/B/C harnesses. cc-os retrofit done 2026-07-12 (promoted ahead of
pilots — ADR-020 amendment / `docs/adr/0020`); remaining rollout: pilot projects one at a pilots — ADR-020 amendment / `docs/adr/0020`); remaining rollout: pilot projects one at a
time, then wider. time, then wider.
- **os-backlog** (`plugins/os-backlog/`) — git-issues backlog surface (ADR-0042 retired
Planka 2026-07-16): tracker routing (`forgejo:`/`github:`/`repo:` in `.cc-os/config`),
`issue-create`/`issues` CLI, capture/list/route skills, ten-label taxonomy with
human-only `next`; non-repo/ops work lives in the private `jared/ops` Forgejo repo.
- **Graphify** v0.8.31 (`~/.local/bin/graphify`; PyPI package is `graphifyy`, double-y) — - **Graphify** v0.8.31 (`~/.local/bin/graphify`; PyPI package is `graphifyy`, double-y) —
vault graph at `~/Documents/SecondBrain/graphify-out/`, per-project graphs at vault graph at `~/Documents/SecondBrain/graphify-out/`, per-project graphs at
`<project-root>/graphify-out/` (gitignored); both disposable/rebuildable. `<project-root>/graphify-out/` (gitignored); both disposable/rebuildable.
@ -108,7 +113,7 @@ next signal is production IRL session audits.
## Issue tracking ## Issue tracking
Issues (created via `/to-issues`) live on self-hosted Forgejo (`jared/cc-os`), queried with Issues (created via `/to-tickets`) live on self-hosted Forgejo (`jared/cc-os`), queried with
the `tea` CLI — not GitHub/`gh`. See `docs/issue-workflow.md`. the `tea` CLI — not GitHub/`gh`. See `docs/issue-workflow.md`.
## OpenSpec workflow ## OpenSpec workflow
@ -144,7 +149,8 @@ OpenSpec artifacts comes from `docs/` and this file.
and never let the index file regrow into a changelog — the os-doc-hygiene `file_length` and never let the index file regrow into a changelog — the os-doc-hygiene `file_length`
signal (400 lines / ~4k tokens) enforces this. signal (400 lines / ~4k tokens) enforces this.
- **Plugin and skill naming:** before naming ANY new cc-os plugin, skill, or slash command, - **Plugin and skill naming:** before naming ANY new cc-os plugin, skill, or slash command,
Read `~/Documents/SecondBrain/cc-os-plugin-skill-naming-convention.md` and follow it. In Read `plugins/cc-architect/references/conventions/cc-os-naming.md` (canonical repo copy;
the SecondBrain vault note of the same name now defers to it) and follow it. In
brief: plugins are `os-[domain]`; skills are verb-first kebab-case, invoked as brief: plugins are `os-[domain]`; skills are verb-first kebab-case, invoked as
`/os-[domain]:[verb]`; no `commands/` dispatcher directories; **never set a `name:` field `/os-[domain]:[verb]`; no `commands/` dispatcher directories; **never set a `name:` field
in SKILL.md frontmatter** (it collapses the slash command to a bare unnamespaced form). in SKILL.md frontmatter** (it collapses the slash command to a bare unnamespaced form).

View File

@ -93,11 +93,11 @@ class PluginRefresher
cache_base = File.expand_path("~/.claude/plugins/cache/#{marketplace_name}/#{plugin_name}") cache_base = File.expand_path("~/.claude/plugins/cache/#{marketplace_name}/#{plugin_name}")
source_full = File.join(source_path, plugin_name) source_full = File.join(source_path, plugin_name)
# Find the version directory in cache # Old version directories linger after upgrades; diff the newest one
versions = Dir.glob(File.join(cache_base, '*')).select { |p| File.directory?(p) } versions = Dir.glob(File.join(cache_base, '*')).select { |p| File.directory?(p) }
return false if versions.empty? return false if versions.empty?
cache_path = versions.first cache_path = versions.max_by { |p| File.mtime(p) }
# Run diff check (exclude plugin manager state files) # Run diff check (exclude plugin manager state files)
output = `diff -rq "#{cache_path}" "#{source_full}" --exclude='.git' --exclude='__pycache__' --exclude='.orphaned_at' 2>&1` output = `diff -rq "#{cache_path}" "#{source_full}" --exclude='.git' --exclude='__pycache__' --exclude='.orphaned_at' 2>&1`

82
bin/test Executable file
View File

@ -0,0 +1,82 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# Repo-level test runner for the mixed minitest/pytest suites under plugins/.
#
# bin/test # run every plugin suite
# bin/test os-backlog # run one plugin's suites (repeatable)
#
# Per plugin it runs `ruby tests/all.rb` when present (minitest) and
# `pytest tests/` when any tests/*_test.py or tests/test_*.py exists.
# pytest runs once per plugin so same-named test files (hook_test.py in
# several plugins) never collide on module import.
# os-vault's tests/ is an eval harness (harness.sh), not a unit suite,
# and is intentionally not run here.
require "open3"
class PluginSuite
RUNNERS = {
minitest: ->(dir) { ["ruby", "tests/all.rb"] },
pytest: ->(dir) { ["pytest", "-q", "tests"] }
}.freeze
attr_reader :name
def initialize(dir)
@dir = dir
@name = File.basename(dir)
end
def kinds
kinds = []
kinds << :minitest if File.exist?(File.join(@dir, "tests", "all.rb"))
kinds << :pytest if pytest_files?
kinds
end
def run(kind)
command = RUNNERS.fetch(kind).call(@dir)
stdout, status = Open3.capture2e(*command, chdir: @dir)
[status.success?, stdout]
end
private
def pytest_files?
Dir.glob(File.join(@dir, "tests", "{test_*,*_test}.py")).any?
end
end
repo_root = File.expand_path("..", __dir__)
requested = ARGV
suites = Dir.glob(File.join(repo_root, "plugins", "*"))
.select { |d| File.directory?(d) }
.map { |d| PluginSuite.new(d) }
.select { |s| requested.empty? || requested.include?(s.name) }
.sort_by(&:name)
unknown = requested - suites.map(&:name)
abort "bin/test: no such plugin(s): #{unknown.join(", ")}" unless unknown.empty?
failures = []
ran = 0
suites.each do |suite|
suite.kinds.each do |kind|
ran += 1
ok, output = suite.run(kind)
puts "#{ok ? "PASS" : "FAIL"} #{suite.name} (#{kind})"
unless ok
failures << "#{suite.name} (#{kind})"
puts output.lines.map { |l| " #{l}" }.join
end
end
end
if ran.zero?
puts "bin/test: no test suites found"
elsif failures.empty?
puts "#{ran} suites passed"
else
abort "#{failures.size}/#{ran} suites failed: #{failures.join(", ")}"
end

View File

@ -1,9 +1,9 @@
--- ---
id: "0033" id: "0033"
date: 2026-07-13 date: 2026-07-13
status: Accepted status: Superseded
supersedes: supersedes:
superseded-by: superseded-by: "0042"
affected-paths: [plugins/os-backlog/skills/route/SKILL.md, plugins/os-backlog/hooks/session_start.py, plugins/os-backlog/bin/os-backlog, .cc-os/config] affected-paths: [plugins/os-backlog/skills/route/SKILL.md, plugins/os-backlog/hooks/session_start.py, plugins/os-backlog/bin/os-backlog, .cc-os/config]
affected-components: [os-backlog, to-issues] affected-components: [os-backlog, to-issues]
--- ---

View File

@ -4,7 +4,7 @@ date: 2026-07-14
status: Accepted status: Accepted
supersedes: supersedes:
superseded-by: superseded-by:
affected-paths: [plugins/os-sdlc/, docs/matt-pocock-skills-v1.1-notes.md] affected-paths: [plugins/os-sdlc/]
affected-components: [os-sdlc, os-backlog, os-adr, os-vault] affected-components: [os-sdlc, os-backlog, os-adr, os-vault]
--- ---

View File

@ -0,0 +1,27 @@
---
id: "0042"
date: 2026-07-16
status: Accepted
supersedes: "0033"
superseded-by:
affected-paths: [plugins/os-backlog/, .cc-os/config, docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md, docs/adr/0029-os-backlog-card-move-with-cli-enforced-column-ownership-afk-ready-cards-skip-review.md, docs/implementation-status/os-backlog.md]
affected-components: [os-backlog, to-issues, os-status, planka-api]
---
# 0042 — Retire Planka: git issues are the single tracker for state and specs
## Context
ADR-0033 split tracking across two surfaces: Planka for task state, git issues for durable specs, conceding up front the cost of 'two tracking surfaces per code project.' A reassessment on 2026-07-16 (four-perspective subagent review + live board snapshot) found the cost never earned itself back. Empirically: since os-backlog finished building (2026-07-13), the only Planka board with any activity is cc-os itself — the plugin tracking its own construction; the one client board (philly-search-engine-marketing) froze 2026-07-09 with no card ever moved past Backlog/Waiting; all other boards hold 0-2 onboarding-era cards. All real work flowed through Forgejo/GitHub issues. Structurally: the issue-wakeup automation (ADR-0035/0036) already polls git issues, not Planka, so the surface wired into the loop that drives sessions was always the issue tracker; and the Planka integration rests on a reverse-engineered undocumented API (see vault note reference/planka-v2-api-gotchas.md: direct Postgres workarounds, no scoped tokens per upstream issue #945, an unpublished lockstep gem) — a standing maintenance tax with no team to absorb it. The one workload Planka was designed for (non-repo, human-curated work) went untested, but the user has independently concluded client management belongs in dedicated tools (Invoice Ninja et al.), making that use case untestable by construction.
## Decision
Retire Planka from the operating layer. Git issues (Forgejo via tea, GitHub via gh, or in-repo via repo:<path>) become the single tracker for BOTH task state and durable specs. The tracker key grammar in .cc-os/config drops planka:<board>; forgejo:/github:/repo: remain. Task state is modeled with issue labels: priority (P0-P3) and autonomy (hitl/semi/afk-ready) labels carry over unchanged; column semantics collapse to open/closed plus a small state-label set, with human curation expressed as a human-applied 'next' label (AI never applies or removes it — preserving ADR-0029's human-curation intent without its Planka mechanics). Non-repo/process/ops work gets a home as issues in a private ops repo (repo:- or forgejo:-tracked) rather than a kanban board. ADR-0029's CLI-enforced column-ownership rules governed a surface this decision removes and are retired with it; its durable content (autonomy-label semantics, human-owned curation gate) is re-expressed in the label scheme above. The Planka server decommission, planka-api gem archival, and os-backlog plugin rework are executed via an OpenSpec change; existing live cards are migrated to issues or closed with human sign-off on the client board.
## Consequences
Easier: one tracking surface per project (the card-as-pointer and promotion rules of ADR-0033 become unnecessary and are dropped); the wakeup path and the tracking surface are now the same system; the os-backlog plugin sheds the planka-api gem dependency, board_ensurer/board_resolver/board_spec and most of cards.rb, the card-triage and board-audit agents, bot-credential lifecycle, and the name-only board-lookup defect; no self-hosted tracker server to patch and back up; the SessionStart process-rules note shrinks. Harder: no kanban/WIP visualization — a unified dashboard over git issues is explicitly deferred until it is a felt pain point; cross-column state expressiveness is reduced to labels; the untested non-repo/human-curated workload loses its designated surface and must prove out in an ops repo (revisit if that chafes); the retirement itself costs a migration pass over ~30 live non-Done cards and a plugin rework.
## Alternatives rejected
1) Keep ADR-0033 as-is — rejected: one week of maximally favorable live use produced zero organic pull toward the second surface, while the conceded two-surface cost (plus server, gem, credentials) kept accruing; the design's own automation already bypassed Planka. 2) Keep Planka dormant-but-installed as a hedge — rejected unanimously by all four review perspectives: dormant infra keeps the maintenance surface of 'kept' (unpatched server holding live bot credentials, gem rot, session-note tokens) while returning the value of 'removed'; if a board is ever wanted again, rebuild against the pain point that actually materializes. 3) Keep a single scoped Planka board for ops/recurring work only — rejected: the entire server+gem+credential stack would persist to serve one recurring card that an ops-repo issue models fine. 4) Columns-as-labels inside Planka — rejected: changes vocabulary, removes none of the infrastructure; the valid kernel (label-modeled state) is adopted on the git-issues side instead.

View File

@ -0,0 +1,27 @@
---
id: "0043"
date: 2026-07-17
status: Accepted
supersedes:
superseded-by:
affected-paths: [plugins/os-context/prompts/session-start/10-orchestration.md, plugins/os-context/skills/audit-sessions/references/rubric.md]
affected-components: [os-context, audit-sessions]
---
# 0043 — Absolute delegation policy: main loop as executive with enumerated exemptions
## Context
The shipped os-context orchestration rules (prompts/session-start/10-orchestration.md, wording tuned in the WS4 eval loop) gated delegation on a per-task judgment: delegate when a mechanical sequence exceeds ~3 tool calls, work directly when the chain is short, judgment-dependent, or interactive. IRL transcript review (2026-07-17) showed the failure mode of that frame: the main-loop model must predict chain length and follow-up probability at decision time, both unknowable, and the errors land systematically on the keep-it-in-session side. Each 'short chain' decision is locally defensible, is re-made fresh with no memory of accumulated cost, and one chain reliably leads to another — sessions blow past 100k tokens of carried tool results on a fable/opus-tier main loop. Judgment-shaped rules are also expensive to audit: /os-context:audit-sessions had to judge judgment calls rather than count violations.
## Decision
Delegation is the default, not a threshold call. The main loop acts as the session's executive: it interprets the user, plans, delegates, verifies, and reports — it never does grunt work, regardless of how short the coming chain looks. Direct work is limited to an enumerated, mechanical exemption list (user-named file reads; diff/verification review before sign-off; single state-inspection commands; acts only the main loop can perform). Grunt work routes to ONE persistent sonnet manager agent spawned at the first delegable work and continued via SendMessage, not spawn-per-errand; fresh spawns only for genuinely parallel independent work. Tier routing: haiku for fully specified mechanical batches; sonnet as default manager tier; opus entered on observed failure (sonnet wrong or stuck twice on the same problem) rather than forecast, plus judgment-dense end-to-end work. Verification stays executive: the main loop reads the diff, not the report of the diff.
## Consequences
Easier: main-loop context stays small, extending session lifetime before compaction/clear; the audit rubric becomes largely countable (non-exempt main-loop tool calls are violations) instead of interpretive; the economics stop depending on per-episode forecasts. Harder: trivial errands pay delegation overhead (spawn prompt, round-trip latency, report) that direct work would not; interactive tight-loop troubleshooting pays a wall-clock tax per round-trip since the old interactive carve-out was deliberately dropped as judgment-shaped; a main loop that never touches the repo can drift toward vague direction, mitigated by the exemption allowing grounding reads of user-named artifacts and by diff review. The WS4 eval sets validated the old wording and are contaminated, so behavioral validation of the new wording comes only from future IRL session audits.
## Alternatives rejected
1) Keep the per-task threshold heuristic (status quo): rejected — requires predicting chain length, which is unknowable at decision time and biased toward direct work; empirically produced 100k+-token main-loop sessions. 2) Cumulative session-budget trigger (delegate once main-loop tool-result tokens pass a budget): rejected as primary rule — better than per-task forecasting but still lets early episodes accrete and still needs a judged threshold; absorbed instead as the rationale (every byte re-billed per turn) rather than the mechanism. 3) Four-tier standing hierarchy with forecast routing (fable→opus→sonnet→haiku, choosing tier by predicted difficulty): rejected — 'does this need opus?' is the same unfalsifiable forecast the policy bans, and deep chains lose intent fidelity; replaced by failure-triggered escalation, which is observable. 4) Evaluative exemptions ('unless it's quick'): rejected — any judgment-shaped exemption reintroduces the broken prediction; exemptions must be enumerable.

View File

@ -39,7 +39,7 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
| 0030 | [Read-only work uses Claude Code plan mode; no /readonly shortcut command](0030-read-only-work-uses-claude-code-plan-mode-no-readonly-shortcut-command.md) | Accepted | 2026-07-13 | | 0030 | [Read-only work uses Claude Code plan mode; no /readonly shortcut command](0030-read-only-work-uses-claude-code-plan-mode-no-readonly-shortcut-command.md) | Accepted | 2026-07-13 |
| 0031 | [os-context prompt-composer plugin supersedes single-file os-orchestration](0031-os-context-prompt-composer-plugin-supersedes-single-file-os-orchestration.md) | Accepted | 2026-07-13 | | 0031 | [os-context prompt-composer plugin supersedes single-file os-orchestration](0031-os-context-prompt-composer-plugin-supersedes-single-file-os-orchestration.md) | Accepted | 2026-07-13 |
| 0032 | [AI-maintained knowledge files are indexes with progressive disclosure](0032-ai-maintained-knowledge-files-are-indexes-with-progressive-disclosure.md) | Accepted | 2026-07-13 | | 0032 | [AI-maintained knowledge files are indexes with progressive disclosure](0032-ai-maintained-knowledge-files-are-indexes-with-progressive-disclosure.md) | Accepted | 2026-07-13 |
| 0033 | [Tracker routing: Planka is state, git issues are specs](0033-tracker-routing-planka-is-state-git-issues-are-specs.md) | Accepted | 2026-07-13 | | 0033 | [Tracker routing: Planka is state, git issues are specs](0033-tracker-routing-planka-is-state-git-issues-are-specs.md) | Superseded | 2026-07-13 |
| 0034 | [Cross-project filing: file-dont-fix with a derived global project index](0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md) | Accepted | 2026-07-13 | | 0034 | [Cross-project filing: file-dont-fix with a derived global project index](0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md) | Accepted | 2026-07-13 |
| 0035 | [Issue-triggered project AI wakeup via polling, not webhooks](0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md) | Accepted | 2026-07-13 | | 0035 | [Issue-triggered project AI wakeup via polling, not webhooks](0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md) | Accepted | 2026-07-13 |
| 0036 | [tmux session convention for AI-run sessions: cc-<project>-<purpose>](0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md) | Accepted | 2026-07-13 | | 0036 | [tmux session convention for AI-run sessions: cc-<project>-<purpose>](0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md) | Accepted | 2026-07-13 |
@ -48,4 +48,6 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
| 0039 | [Doc-hygiene deletion autonomy is tiered on evidence quality and recoverability, not file type](0039-doc-hygiene-deletion-autonomy-is-tiered-on-evidence-quality-and-recoverability-not-file-type.md) | Accepted | 2026-07-14 | | 0039 | [Doc-hygiene deletion autonomy is tiered on evidence quality and recoverability, not file type](0039-doc-hygiene-deletion-autonomy-is-tiered-on-evidence-quality-and-recoverability-not-file-type.md) | Accepted | 2026-07-14 |
| 0040 | [Doc-hygiene rules never write other tools' ignore surfaces (no propagate_ignore)](0040-doc-hygiene-rules-never-write-other-tools-ignore-surfaces-no-propagate-ignore.md) | Accepted | 2026-07-14 | | 0040 | [Doc-hygiene rules never write other tools' ignore surfaces (no propagate_ignore)](0040-doc-hygiene-rules-never-write-other-tools-ignore-surfaces-no-propagate-ignore.md) | Accepted | 2026-07-14 |
| 0041 | [Determinism promotion: hygiene nudges projects toward structurally-obvious completion conventions](0041-determinism-promotion-hygiene-nudges-projects-toward-structurally-obvious-completion-conventions.md) | Accepted | 2026-07-14 | | 0041 | [Determinism promotion: hygiene nudges projects toward structurally-obvious completion conventions](0041-determinism-promotion-hygiene-nudges-projects-toward-structurally-obvious-completion-conventions.md) | Accepted | 2026-07-14 |
| 0042 | [Retire Planka: git issues are the single tracker for state and specs](0042-retire-planka-git-issues-are-the-single-tracker-for-state-and-specs.md) | Accepted | 2026-07-16 |
| 0043 | [Absolute delegation policy: main loop as executive with enumerated exemptions](0043-absolute-delegation-policy-main-loop-as-executive-with-enumerated-exemptions.md) | Accepted | 2026-07-17 |
<!-- adr-index:end --> <!-- adr-index:end -->

View File

@ -1,6 +1,6 @@
# cc-os implementation status — index # cc-os implementation status — index
_Last updated: 2026-07-13. This file is an **index with progressive disclosure** (see the _Last updated: 2026-07-17. This file is an **index with progressive disclosure** (see the
index+PD convention ADR): headline timeline entries and per-component one-liners live here; index+PD convention ADR): headline timeline entries and per-component one-liners live here;
full per-component detail lives in leaf files under `docs/implementation-status/`, read on full per-component detail lives in leaf files under `docs/implementation-status/`, read on
demand. When a build step completes, add a **one-line** timeline entry here and put the demand. When a build step completes, add a **one-line** timeline entry here and put the
@ -61,7 +61,7 @@ entry is in the leaf file named.
(ADR-0031) + three new prompt files; `/readonly` decided as plan-mode convention, no (ADR-0031) + three new prompt files; `/readonly` decided as plan-mode convention, no
skill (ADR-0030); os-doc-hygiene `file_length` scanner signal shipped (issue #25 part 1); skill (ADR-0030); os-doc-hygiene `file_length` scanner signal shipped (issue #25 part 1);
this file distilled to index+PD (issue #25 parts 23, ADR-0032). os-backlog slice 7 this file distilled to index+PD (issue #25 parts 23, ADR-0032). os-backlog slice 7
shipped: SessionStart injection note + /to-issues tracker routing (issue #16); board-id shipped: SessionStart injection note + /to-tickets tracker routing (issue #16); board-id
cache via BoardResolver (issue #22); slice 8 — Operations board + tracker-routing rubric cache via BoardResolver (issue #22); slice 8 — Operations board + tracker-routing rubric
category + ADR-0033 canonizing Planka-state/git-issues-spec (issue #17); route inspect category + ADR-0033 canonizing Planka-state/git-issues-spec (issue #17); route inspect
issue-shape classification + split-by-kind default (issue #26); cross-project filing issue-shape classification + split-by-kind default (issue #26); cross-project filing
@ -76,6 +76,23 @@ entry is in the leaf file named.
- **2026-07-15** — os-doc-hygiene lifecycle-aware hygiene shipped (ADR-00380041): rulebook - **2026-07-15** — os-doc-hygiene lifecycle-aware hygiene shipped (ADR-00380041): rulebook
layer, lifetime taxonomy + tier matrix, no-ignore-propagation, conventions.json promotion, layer, lifetime taxonomy + tier matrix, no-ignore-propagation, conventions.json promotion,
new `:calibrate` skill; calibration pass #1 (cc-os) passed. Detail: os-doc-hygiene leaf. new `:calibrate` skill; calibration pass #1 (cc-os) passed. Detail: os-doc-hygiene leaf.
- **2026-07-16** — Planka retired (ADR-0042 supersedes ADR-0033): os-backlog reworked to
git-issues-only via OpenSpec change `retire-planka-git-issues-only` — Planka lib/CLI/agents
deleted, issue-create/issues helpers added, skills + SessionStart note rewritten, cards
migrated (cc-os → jared/cc-os issues; philly board → jared/ops + jared/llf-schema;
strays → new private jared/ops repo), boards snapshotted + pg-dumped (archive:
ovh-vps `~/planka-final-snapshot-2026-07-16/`). Server decommissioned same day
(containers/cron/creds removed); planka gem repo archived read-only. Detail:
os-backlog leaf.
- **2026-07-16** — os-* standards audit vs cc-architect (post Phase B): every plugin got an
index-style CLAUDE.md, os-doc-hygiene's cc-plugins-era docs reconciled, invariants.md
added (os-shortcuts, os-vault), naming convention folded into cc-architect references,
repo-level `bin/test` runner added. Detail:
[implementation-status/standards-audit.md](implementation-status/standards-audit.md).
- **2026-07-17** — Absolute delegation policy shipped (ADR-0043): `10-orchestration.md`
rewritten (executive frame, enumerated exemptions, persistent sonnet manager,
failure-triggered opus escalation); audit rubric + `extract` driver updated to match;
WS4 eval retired as instrument, kept as template. Detail: os-context leaf.
**Remaining optional items:** additional project onboarding (one at a time, per ADR-0013); **Remaining optional items:** additional project onboarding (one at a time, per ADR-0013);
bulk vault migration; os-adr rollout to pilot projects; os-backlog routing rollout (#14); bulk vault migration; os-adr rollout to pilot projects; os-backlog routing rollout (#14);
@ -101,8 +118,9 @@ Read the leaf file before modifying a component.
- **os-adr** — ADR system (Ruby `lib/adr/` + CLIs; create/init/migrate/find skills; Eval - **os-adr** — ADR system (Ruby `lib/adr/` + CLIs; create/init/migrate/find skills; Eval
A/B/C harnesses; cc-os retrofit done) → A/B/C harnesses; cc-os retrofit done) →
[implementation-status/os-adr.md](implementation-status/os-adr.md) [implementation-status/os-adr.md](implementation-status/os-adr.md)
- **os-backlog** — Planka process surface: board lifecycle, capture/list/route skills, - **os-backlog** — git-issues backlog surface (ADR-0042 retired Planka 2026-07-16):
card CLI with column-ownership rules, triage/audit agents → tracker routing (`forgejo:`/`github:`/`repo:`), issue-create/issues CLI,
capture/list/route skills, ten-label taxonomy with human-only `next`
[implementation-status/os-backlog.md](implementation-status/os-backlog.md) [implementation-status/os-backlog.md](implementation-status/os-backlog.md)
- **os-shortcuts** — user-invoked QOL commands, skills only, never hooks (ADR-0028); - **os-shortcuts** — user-invoked QOL commands, skills only, never hooks (ADR-0028);
resident: `/os-shortcuts:wrap` session-close ritual (shipped 2026-07-12). No leaf file resident: `/os-shortcuts:wrap` session-close ritual (shipped 2026-07-12). No leaf file

View File

@ -3,42 +3,41 @@
_Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._ _Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._
**Global os-backlog plugin** — `cc-os/plugins/os-backlog/` (git-tracked, 2026-07-10); **Global os-backlog plugin** — `cc-os/plugins/os-backlog/` (git-tracked, 2026-07-10);
symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9 (state pointer: Planka symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9, slices #10#17.
card `1816234194716592051`), slices #10#17. Decision records: ADR-0023 (own plugin, not an Decision records: ADR-0023 (own plugin, not an os-vault extension), ADR-0025 (standard Ruby
os-vault extension), ADR-0025 (standard Ruby plugin structure), ADR-0029 (card-move + plugin structure), ADR-0029 (column ownership — subject removed by ADR-0042), **ADR-0042
column ownership). (2026-07-16, supersedes ADR-0033): Planka retired; git issues are the single tracker for
state and specs** (OpenSpec change `retire-planka-git-issues-only`).
## Component inventory ## Component inventory (post-ADR-0042, v0.3.0)
- Purpose: the AI's process-management surface over Planka — deterministic tracker routing - Purpose: the AI's process-management surface over git issues — deterministic tracker
(Planka = task state by default; git issues = implementation specs only), mid-session routing (`forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`; `planka:`
capture, pull-only listing, board lifecycle, and the three-value autonomy convention rejected fail-soft citing ADR-0042), mid-session capture as labeled issues, pull-only
(`hitl`/`semi`/`afk-ready`; amended per ADR-0029: afk-ready ships straight to Done, semi listing, cross-project filing via the global project index.
stops at Review). - State model: open/closed + labels. Ten canonical labels: priority `P0``P3`; autonomy
- Lib (Ruby, os-adr `lib/`+`bin/` pattern, installed planka-api gem only — no source `hitl`/`semi`/`afk-ready` (semi ships to `review` label and stays open; afk-ready closes
coupling): `board_spec.rb` (uniform lists/labels contract), `board_ensurer.rb` after verification); state `next` (human-only — AI never applies/removes), `waiting`
(idempotent create/repair; Planka 2.1.1 `type` fields, shared-project + owner-manager (+ blocker comment), `review`. No `doing`/`done` labels: in-progress = assignment/branch
visibility fix, label color candidates with fallback; `archived--` rename convention), activity, done = closed. Recurring-convention issues (e.g. `jared/ops#1` biweekly audit)
`config.rb` (`.cc-os/config` parser), `resolver.rb` (pure repo→board decision: are commented per occurrence and never closed by the AI.
`use`/`activate`/`stop-and-discuss`; no network), `cards.rb` (add-at-Backlog, snapshot, - Lib (Ruby): `tracker.rb` (grammar validation + `issues_destination`: `:git_issues` /
attach_label, comment, move with ownership rules), `tracker.rb` + `inspector.rb` `:repo_files` / `:unrouted`), `issues.rb` (2026-07-16 — capture/list dispatch: `tea` for
(2026-07-12, issue #14 — tracker-format validation/remote classification + in-repo forgejo, `gh` for github, `NNN-<slug>.md` frontmatter files for `repo:`; injected runner,
issue-file discovery; `Config.merge` preserves other keys). no state labels reachable through `create`), `inspector.rb` (issue-shape classification),
- CLI: `bin/os-backlog``board-ensure`, `activate`, `archive`, `resolve`, `card-add`, `config.rb`, `resolver.rb` (reduced to tracker-presence decision), `project_index.rb`,
`cards`, `snapshot`, `card-label`, `card-comment`, `card-move` (2026-07-12, ADR-0029), `wakeup.rb`.
`inspect`, `config-write`; every path fails soft (one-line error, exit 1) when the - CLI: `bin/os-backlog``resolve`, `inspect`, `config-write`, `projects`, `issue-create`
gem/Planka is unavailable. (`--title/--body/--priority`), `issues` (grouped next/waiting/review/other). Fail-soft
- Skills: `/os-backlog:capture`, `/os-backlog:list` (pull-only per notification policy v2), one-liners throughout.
`/os-backlog:route` (2026-07-12, issue #14 — tracker onboarding conversation; destination - Skills: `/os-backlog:capture` (issue-create; never state labels, never `next`),
choice + live-history migration are named human gates) — carry the column-ownership rules `/os-backlog:list` (pull-only), `/os-backlog:route` (destination proposal + idempotent
(AI creates at Backlog, Doing→Review at most, never Next, never self-assigns `hitl`; ten-label ensure via tea/gh). SessionStart note rewritten 2026-07-16 (~444 tokens,
"working a card means moving it" contract added 2026-07-12). down from ~687 measured): capture / routing / cross-project / human-only-`next` /
- Agents: `agents/card-triage.md` (priority + autonomy labels only; ambiguity → `hitl`), working-state / autonomy / recurring rules.
`agents/board-audit.md` (four drift classes; writes at most comments). - Tests: 88 runs / 203 assertions (2026-07-16) with fake CLI runners; no live API.
- Tests: `tests/` — 68 runs/153 assertions as of 2026-07-12 (35 → 54 → 58 → 68 across the Companion os-status `tracker-configured` check validates the reduced grammar and rejects
slices) against an in-memory `FakePlankaClient`; no live API. Companion os-status check `planka:` with the ADR-0042 advisory.
`tracker-configured` (issue #11) validates the `tracker` key grammar and nudges
unconfigured git projects (daily-snoozed).
## Build and verification history ## Build and verification history
@ -73,8 +72,8 @@ column ownership).
(`hooks/session_start.py` + `hooks/hooks.json`, os-adr-style plugin-local hook): WHEN→THEN (`hooks/session_start.py` + `hooks/hooks.json`, os-adr-style plugin-local hook): WHEN→THEN
rules for capture / routing / promotion / column ownership / autonomy labels, ADR-0029 rules for capture / routing / promotion / column ownership / autonomy labels, ADR-0029
semantics (the issue body's "afk-ready → Review" was wrong — caught in Codex review), semantics (the issue body's "afk-ready → Review" was wrong — caught in Codex review),
rules only, zero board state, ~447 tokens. `/to-issues` (global skill, rules only, zero board state, ~447 tokens. `to-issues` (now `to-tickets`; global skill,
`~/.agents/skills/to-issues/`) gained a destination-tracker step reading the then at `~/.agents/skills/to-issues/`) gained a destination-tracker step reading the
`.cc-os/config` tracker key; the `planka:`/unset default publishes slices as git issues `.cc-os/config` tracker key; the `planka:`/unset default publishes slices as git issues
on the repo remote + one Planka pointer card (spec/state boundary preserved — human gate on the repo remote + one Planka pointer card (spec/state boundary preserved — human gate
decided 2026-07-13). `Backlog::Tracker.issues_destination` + 6 routing tests. decided 2026-07-13). `Backlog::Tracker.issues_destination` + 6 routing tests.
@ -89,7 +88,7 @@ column ownership).
IRL audit (recurring)" (card `1818310715438531668`, P2 + semi, first progress comment IRL audit (recurring)" (card `1818310715438531668`, P2 + semi, first progress comment
`1818310889929966683`; recurrence contract: comment + move back to Backlog, never Done). `1818310889929966683`; recurrence contract: comment + move back to Backlog, never Done).
Tracker-routing convention canonized as **ADR-0033** (Planka = state / git issues = specs, Tracker-routing convention canonized as **ADR-0033** (Planka = state / git issues = specs,
tracker key grammar, pointer + promotion rules; cites route/to-issues/session-start tracker key grammar, pointer + promotion rules; cites route/to-tickets/session-start
rather than restating). os-context audit rubric gained category 8 `tracker-routing` rather than restating). os-context audit rubric gained category 8 `tracker-routing`
(capture/route/promote compliance only). Bug found live and fixed: label color fallback (capture/route/promote compliance only). Bug found live and fixed: label color fallback
only rescued `Planka::ValidationError`, but Planka rejects unknown colors as HTTP 400 only rescued `Planka::ValidationError`, but Planka rejects unknown colors as HTTP 400
@ -135,5 +134,28 @@ column ownership).
Known residual (captured as a Backlog card): board lookup is name-only across ALL Planka Known residual (captured as a Backlog card): board lookup is name-only across ALL Planka
projects — duplicate board names in different projects can resolve to the wrong board. projects — duplicate board names in different projects can resolve to the wrong board.
Suite 138 runs / 295 assertions / 0 failures. Suite 138 runs / 295 assertions / 0 failures.
- **Planka retirement rework (2026-07-16, ADR-0042, OpenSpec change
`retire-planka-git-issues-only`):** after a week live, all real work flowed through git
issues while boards held only the plugin's own build cards — Planka retired. Deleted:
`board_ensurer`/`board_resolver`/`board_spec`/`cards`/`triage_check` (+ triage hook),
both agents, planka-api gem wiring, `FakePlankaClient`, nine card/board CLI subcommands.
Added: `issues.rb` + `issue-create`/`issues` CLI (live-verified; fixed tea field
`assignees``assignee` is not a valid tea list field). Skills + SessionStart note +
`/to-issues` + os-status fix skill rewritten to the single-tracker model. Migration:
all 9 boards snapshotted (JSON + comments) + Postgres dump on ovh-vps; snapshot archived
in new private `jared/ops` Forgejo repo (canonical labels; recurring audit issue
`jared/ops#1`); cc-os non-Done cards → issues jared/cc-os#6170 (3 skipped: pointer to
closed #25, duplicate of open #8, obsoleted board-lookup card); ovh-prod + ruby-gems
strays → jared/ops#23; `planka` board's 18 cards obsolete (gem build history, covered
by snapshot); `tracker=` rewritten in all 6 indexed projects (no `planka:` values
remain). Suite 88 runs / 203 assertions; os-status 80 tests.
- **Human gates closed (2026-07-16):** philly per-card pass done — 4 repo cards →
jared/llf-schema#710, 18 client cards → jared/ops#421, guardrails pin closed (vault
note is the record). Decommission done same day: bot creds deleted, Planka
containers/cron/service dir removed from ovh-vps (snapshot + pg dump + service configs
kept at `~/planka-final-snapshot-2026-07-16/`), github.com/jaredswanson/planka archived
read-only with an ADR-0042 README banner.
- **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup - **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup
rollout (first opt-in project + poller machine + cadence). rollout (first opt-in project + poller machine + cadence); jared/cc-os#74 — fold
ADR-0025 compliance into os-backlog (bin/wakeup-poll second entry point, README gem
deps), filed by the standards audit.

View File

@ -108,3 +108,19 @@ the rename refer to the plugin by its former name.
decision, never a continuation of the main loop's review thread (issue #30). Applied decision, never a continuation of the main loop's review thread (issue #30). Applied
directly (design pre-approved, no wording-loop re-run); `bin/refresh-plugins` run after directly (design pre-approved, no wording-loop re-run); `bin/refresh-plugins` run after
the edit. the edit.
- **Absolute delegation policy (2026-07-17, ADR-0043):** IRL transcript review showed the
per-task delegation threshold fails by induction — chain length is unknowable at decision
time, each "short chain" call is locally defensible, and sessions accrete 100k+ tokens of
main-loop tool results. `10-orchestration.md` rewritten (design pre-approved in session):
executive frame, enumerated E-a..E-d exemptions (no evaluative carve-outs), one persistent
sonnet manager continued via SendMessage, opus escalation on observed failure only.
Audit rubric rewritten to match, shaped by a sonnet perspectives fan-out (measurement +
devils-advocate): exemption decision procedure with recorded steps per finding, new
`exemption-chaining` category, `over-delegation` narrowed to `delegation-overhead-mismatch`
(not deleted), old defenses demoted to LOW/`accepted-cost` severity modifiers,
`missed-delegation/read-write` sub-tag. `audit/bin/extract` gains `read_write_profile`
(total main-loop Read/Write/Edit incl. non-consecutive — largest prior session: 67 total,
only 14 visible to the run detector) and drops its stale pro-direct-work trailer. Eval
E1E3/E5 retired as a measurement instrument (README banner): sets contaminated AND
scenarios encode the superseded policy; kept as harness template. Validation of the new
wording comes only from future IRL audits.

View File

@ -0,0 +1,47 @@
# os-* standards audit vs cc-architect — 2026-07-16
One-time audit executed after cc-architect's Phase B migration into cc-os (commit
3f76755), per the backlog ticket "Audit os-* plugins against cc-architect standards
(post standards-centralization)". Ran cc-architect's `workflows/audit-plugin.md`
Phase 12 checklist against all eight os-* plugins, then remediated.
## Results
All eight plugins pass manifest validation (name/version/description, kebab-case,
semver), have no `name:` fields in SKILL.md frontmatter, and have no `commands/`
dispatcher directories.
Remediation shipped:
- **Per-plugin CLAUDE.md indexes** (ADR-0032 index+PD style) created for os-adr,
os-backlog (minimal — Planka-retirement rework in flight), os-context, os-sdlc,
os-shortcuts, os-status, os-vault; os-doc-hygiene's stale pre-build cc-plugins-era
CLAUDE.md rewritten.
- **os-doc-hygiene cc-plugins references reconciled**: PRD.md target path and
marketplace naming corrected; broken `../.claude/references/` path and retired
`/install doc-hygiene@cc-plugins` instructions removed.
- **invariants.md** added for os-shortcuts (4 invariants) and os-vault (8); already
existed for os-adr, os-doc-hygiene, os-status. os-backlog deferred (rework in
flight); os-sdlc/os-context not warranted yet.
- **ADR-0025 / os-adr**: no action — the ADR itself records the per-verb-`bin/`
exemption ("grandfathered... until os-adr is next touched for other reasons").
- **Naming convention** folded into the repo:
`plugins/cc-architect/references/conventions/cc-os-naming.md` is now canonical;
the SecondBrain vault note and root CLAUDE.md point to it.
- **Repo-level test runner**: `bin/test` runs every plugin's minitest
(`tests/all.rb`) and pytest suite, one pytest invocation per plugin (avoids
same-named `hook_test.py` module collisions). os-vault's `tests/` is an eval
harness, deliberately excluded.
- **os-vault hook wiring documented**: hooks are intentionally wired via user-level
`~/.claude/settings.json` pointing at repo source scripts, not a plugin
`hooks/hooks.json`; recorded in os-vault/CLAUDE.md so the absent hooks.json stops
reading as a defect.
## Deferred (filed, not fixed)
- os-backlog ADR-0025 deviations (second entry point `bin/wakeup-poll`; no README
naming gem deps) → Forgejo issue jared/cc-os#74, to be folded into the
retire-planka-git-issues-only rework.
- os-context `skills/audit-sessions/SKILL.md` stale Planka references and os-status
`planka:` tracker-scheme handling — both already being fixed by the in-flight
ADR-0042 rework on main; not touched here to avoid conflicts.

View File

@ -1,8 +1,10 @@
# Issue workflow (Forgejo + tea) # Issue workflow (Forgejo + tea)
_Last updated: 2026-06-30._ _Last updated: 2026-07-16._
How issues created by the `/to-issues` skill get tracked and implemented in this repo. Note (2026-07-16): the `/to-issues` skill was renamed to `/to-tickets` in Matt Pocock's v1.1 skill set.
How issues created by the `/to-tickets` skill get tracked and implemented in this repo.
## Tracker ## Tracker
@ -33,7 +35,7 @@ tea issues <N> --repo jared/cc-os # view one issue's body
## Implement ## Implement
There is **no auto-trigger and no `/grab` command**. Issues from `/to-issues` are written as independently-grabbable tracer-bullet slices. To implement: There is **no auto-trigger and no `/grab` command**. Issues from `/to-tickets` are written as independently-grabbable tracer-bullet slices. To implement:
1. Pick the next **unblocked** issue (check the "Blocked by" line in the body — slices often form a linear chain). 1. Pick the next **unblocked** issue (check the "Blocked by" line in the body — slices often form a linear chain).
2. Hand it to a coding session: _"implement issue #N from forgejo jared/cc-os"_. 2. Hand it to a coding session: _"implement issue #N from forgejo jared/cc-os"_.
@ -48,4 +50,4 @@ tea issues close <N> --repo jared/cc-os
## Caveat ## Caveat
The Matt-Pocock `to-issues` / `triage` skills default to **GitHub** and were never wired to this Forgejo/`tea` backend for cc-os (no `docs/agents/` config, no `.scratch/`). Re-running them assumes GitHub. Running `/setup-matt-pocock-skills` and describing the `tea`/Forgejo workflow would wire them to the right tracker. The Matt-Pocock `to-tickets` (formerly `to-issues`) / `triage` skills default to **GitHub** and were never wired to this Forgejo/`tea` backend for cc-os (no `docs/agents/` config, no `.scratch/`). Re-running them assumes GitHub. Running `/setup-matt-pocock-skills` and describing the `tea`/Forgejo workflow would wire them to the right tracker.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-16

View File

@ -0,0 +1,103 @@
# Design: retire-planka-git-issues-only
## Context
os-backlog today (post-slice-8, 2026-07-13): Ruby lib + CLI over both a Planka board model
(board-ensure/resolve/card-*) and tracker inspection (`inspect`, `tracker.rb`,
`project_index.rb`), skills (capture/list/route), two Planka agents, a SessionStart
rules note (~447 tokens), an os-status grammar check, and a wakeup poller that already
reads git issues. ADR-0042 retires the Planka half. The issue-side plumbing (`tea` for
Forgejo, `gh` for GitHub, in-repo files for `repo:`) already exists and is what real work
has used. Constraint: the plugin is globally installed and ambient on every machine — the
rework must land whole (no half-migrated grammar), and source edits require
`bin/refresh-plugins` to reach sessions.
## Goals / Non-Goals
**Goals:**
- One tracker per project holds both state and specs; `planka:` gone from the grammar.
- Preserve the durable process contracts: mid-session capture, pull-only listing,
priority + autonomy labels, human-only curation gate, cross-project filing.
- Leave zero dormant Planka infrastructure: cards migrated, server down, gem archived,
credentials revoked.
**Non-Goals:**
- A unified dashboard over git issues (explicitly deferred until it is a felt pain point).
- Reworking the wakeup poller (already issue-based; only its label reads are re-checked).
- Bulk re-onboarding of projects beyond rewriting their `tracker=` values.
- Any new home for client management (Invoice Ninja et al. own that; out of scope).
## Decisions
1. **State model = open/closed + labels, no status-label state machine.** Columns
Backlog/Next/Doing/Waiting/Review/Done collapse to: open issue (backlog), `next` label
(human-curated), assignee/linked-branch activity (doing — no label to maintain),
`waiting` label + a blocker comment, `review` label (semi work shipped), closed (done).
Rationale: every label the AI must maintain is a compliance cost the incentives review
flagged; keep only labels that carry information a human filters on. Alternative
rejected: full column-equivalent label set (`doing`, `done`) — duplicates what issue
state and assignment already express.
2. **Curation gate: `next` is human-only in skill/hook prose, not CLI-enforced.**
ADR-0029's CLI enforcement existed because Planka moves were CLI-mediated; issue labels
are set via tea/gh which os-backlog does not proxy. The rule lives in the SessionStart
note + skills, and the session-audit rubric (os-context category 8) measures compliance.
Alternative rejected: wrapping tea/gh in an enforcing CLI — rebuilds the proxy layer the
retirement is deleting.
3. **Non-repo/ops work: a private `ops` Forgejo repo, `tracker=forgejo:jared/ops`.**
The recurring Operations card becomes a recurring-convention issue there (comment +
reopen/leave-open, never close — mirrors the existing recurrence contract). Alternative
rejected: `repo:<path>` files — loses labels and the wakeup/query surface for the one
place recurring process work lives.
4. **Migration before demolition, human gate on client data.** Card migration runs while
Planka is still up; philly-search-engine-marketing (23 cards, real client backlog) is
migrated only after per-card human sign-off (destination: the client project's tracker
or the ops repo). cc-os non-Done cards → Forgejo `jared/cc-os` issues. Done cards are
not migrated (history stays in a final JSON snapshot archived to the ops repo before
decommission). Rollback: the snapshot + a Postgres dump taken before server teardown.
5. **Code removal is deletion, not deprecation.** `board_ensurer/board_resolver/
board_spec/cards` (Planka transport), both agents, and gem wiring are deleted with their
tests; `tracker.rb`/`inspector.rb`/`config.rb`/`project_index.rb`/`wakeup.rb` survive.
`Config#planka_board` and `Resolver` board logic go. Grammar validation rejects
`planka:` with a pointer to ADR-0042. Alternative rejected: feature-flagging Planka off
— dormant-code variant of the dormant-server trap.
6. **planka-api gem: archive the repo, keep no dependency.** The gem is user-authored and
unpublished; archival (read-only, README pointer to ADR-0042) preserves the
reverse-engineering knowledge without implying maintenance. The vault gotchas note
stays — it documents the API, not our use of it.
## Risks / Trade-offs
- [Untested non-repo workload loses its designated surface] → ops-repo issues are the
cheap test ADR-0042 calls for; revisit via a new ADR if it chafes in practice.
- [AI label discipline unenforced] → session-audit rubric already measures
tracker-routing compliance; the label contract is smaller than the column contract it
replaces (fewer states, fewer rules).
- [Client board migration loses nuance (comments, label history)] → per-card human gate;
full JSON snapshot archived first.
- [Stragglers: `.cc-os/config` files on other machines still saying `planka:`] →
config-write/os-status check rejects the value with a one-line re-route instruction;
fail-soft, never blocks a session.
- [Forgejo becomes a single point of failure for process state] → it already was for
specs and wakeup; repos sync to it from every machine and it is backed up with the VPS.
## Migration Plan
1. Land the plugin rework + spec deltas (tests green, `bin/refresh-plugins`).
2. Snapshot all boards to JSON; archive snapshot in the ops repo; Postgres dump on ovh-vps.
3. Create `jared/ops` repo + label set; move the recurring audit issue there.
4. Migrate cc-os non-Done cards → `jared/cc-os` issues (labels carried over).
5. Human-gated pass over philly-search-engine-marketing cards.
6. Rewrite `tracker=` in onboarded projects' `.cc-os/config`.
7. Revoke bot credentials; decommission Planka on ovh-vps; archive the gem repo.
8. Update docs: implementation-status leaf + index line, CLAUDE.md inventory line,
backlog-pilot vault note.
Rollback (any step before 7): Planka is still running; restore config values. After 7:
restore from Postgres dump — accepted as effectively one-way once step 7 runs.
## Open Questions
- Whether `philly-search-engine-marketing` cards land in a client repo tracker or the ops
repo (human decision during step 5).
- Which machine's cron (if any) hosts the wakeup poller — pre-existing open item, not
blocked by this change.

View File

@ -0,0 +1,61 @@
# Proposal: retire-planka-git-issues-only
## Why
ADR-0042 (2026-07-16, supersedes ADR-0033) retires Planka: after a week of live use, all
real work flowed through git issues while Planka boards held only the plugin's own build
cards; the wakeup automation already polls issues, not Planka; and the integration rests on
a reverse-engineered API plus an unpublished lockstep gem — a standing maintenance tax a
solo operator shouldn't carry for an unused surface. os-backlog must be reworked so git
issues are the single tracker for both task state and durable specs.
## What Changes
- **BREAKING**: `planka:<board>` is removed from the `.cc-os/config` tracker grammar;
`forgejo:` / `github:` / `repo:` remain. Existing `planka:` configs must be re-routed.
- os-backlog capture/list retarget to git issues: `/os-backlog:capture` files an issue with
priority (P0P3) and autonomy (hitl/semi/afk-ready) labels; `/os-backlog:list` queries
issues instead of card snapshots.
- Column state collapses to open/closed plus labels; human curation becomes a human-only
`next` label (AI never applies or removes it — carries ADR-0029's curation gate forward
without its Planka mechanics). ADR-0029's CLI column-ownership enforcement retires with
the surface it governed.
- Planka-specific code retires: `board_ensurer.rb`, `board_resolver.rb`, `board_spec.rb`,
the Planka transport in `cards.rb`, `card-triage` and `board-audit` agents, the
`planka-api` gem dependency, and bot-credential plumbing.
- SessionStart process-rules note rewritten for the single-tracker model (smaller; drops
column-ownership and promotion rules; keeps capture/routing/cross-project/autonomy rules).
- `/os-backlog:route`, `/to-issues`, os-status `tracker-configured`, and the project index
updated to the reduced grammar (the spec/state boundary and card-as-pointer rules vanish —
one destination holds both).
- Live-card migration: ~30 non-Done cards migrated to issues or closed (client board
philly-search-engine-marketing gated on human sign-off); recurring Operations card moves
to an ops home. Then: Planka server decommissioned on ovh-vps, `planka-api` gem archived.
## Capabilities
### New Capabilities
- `issue-backlog`: single-tracker backlog management over git issues — tracker key grammar
(`forgejo:`/`github:`/`repo:`), deterministic routing, mid-session capture as labeled
issues, pull-only listing, cross-project filing via the project index.
- `issue-state-labels`: the label taxonomy that replaces board columns — priority and
autonomy label semantics, the human-only `next` curation label, and the AI
working-state contract (what the AI may set/clear on an issue and when).
### Modified Capabilities
_None — os-backlog predates OpenSpec coverage; no existing spec governs it._
## Impact
- `plugins/os-backlog/` (lib, bin, skills, agents, hooks, tests — major rework; ~700900
lib lines and both agents removed).
- `~/.agents/skills/to-issues/SKILL.md` (destination step simplifies: no pointer card).
- os-status `tracker-configured` check (grammar change).
- `.cc-os/config` in every onboarded project currently set to `planka:` (cc-os itself plus
boards created 2026-07-13).
- External systems: Planka server on ovh-vps (decommission), `planka-api` gem repo
(archive), Planka bot credentials (revoke; closes the open credvault-import card).
- Decision records: ADR-0042 (recorded), ADR-0033 (superseded), ADR-0029 (subject removed),
ADR-0034 (project index survives, tracker values change), ADR-0035/0036 (unaffected —
already issue-based).

View File

@ -0,0 +1,71 @@
# issue-backlog — delta spec
## ADDED Requirements
### Requirement: Tracker key grammar excludes Planka
The `.cc-os/config` `tracker` key SHALL accept exactly `forgejo:<owner>/<repo>`,
`github:<owner>/<repo>`, or `repo:<path>`. `config-write` and the os-status
`tracker-configured` check SHALL reject any other value, including `planka:<board>`, with a
one-line error naming ADR-0042 and pointing to `/os-backlog:route`; rejection SHALL never
block or fail a session (fail-soft).
#### Scenario: Legacy planka tracker value encountered
- **WHEN** a config containing `tracker=planka:cc-os` is read by config-write validation or
the os-status check
- **THEN** the value is rejected with a one-line message citing ADR-0042 and suggesting
`/os-backlog:route`, and the session continues normally
#### Scenario: Valid git tracker accepted
- **WHEN** `config-write` is invoked with `tracker=forgejo:jared/cc-os`
- **THEN** the key is written, other config keys and comments are preserved, and the
project index is upserted
### Requirement: Capture files a labeled issue on the configured tracker
`/os-backlog:capture` SHALL create an issue on the project's configured tracker (via `tea`
for forgejo, `gh` for github, an issue file for `repo:`) carrying a priority label (P0P3)
when known and no state labels; capture SHALL never apply the `next` label. When no tracker
key is configured in a git project, capture SHALL suggest `/os-backlog:route` once and
otherwise degrade to reporting what it could not do.
#### Scenario: Mid-session capture on a Forgejo-tracked project
- **WHEN** deferred work surfaces in a project with `tracker=forgejo:jared/cc-os`
- **THEN** a new open issue is created on that repo via `tea` with the title, a body noting
the originating context, and any known priority label — and nothing else
#### Scenario: Capture with no tracker configured
- **WHEN** capture is invoked in a git project whose config has no tracker key
- **THEN** no issue is created; the skill suggests `/os-backlog:route` once and exits
without error
### Requirement: Listing is pull-only over issues
`/os-backlog:list` SHALL query the configured tracker's open issues (title, number, labels,
assignee) and present them grouped by state (next / waiting / review / other open). It
SHALL run only on explicit user request; no hook or skill SHALL inject issue lists into a
session unasked.
#### Scenario: User asks what's on the backlog
- **WHEN** the user explicitly asks what is on the backlog or what's next
- **THEN** open issues from the configured tracker are listed grouped by state labels, with
`next`-labeled issues shown first
### Requirement: Cross-project filing targets the other project's tracker
WHEN work belonging to a different project surfaces, the system SHALL resolve that
project's tracker via the global project index (`os-backlog projects`) and file a labeled
issue there using the Discoverer template, rather than editing the other project or filing
locally. Priority/autonomy labeling of the filed issue remains the receiving project's job.
#### Scenario: Discovered defect in another indexed project
- **WHEN** a session in project A finds a bug belonging to indexed project B
- **THEN** an issue is created on B's configured tracker with the Discoverer template, no
autonomy or priority labels are applied by A, and no local copy is kept
### Requirement: One tracker holds both state and specs
A project SHALL have exactly one configured tracker destination; durable specs
(tracer-bullet slices from /to-issues, PRDs) and task-state items are issues on that same
tracker. The system SHALL NOT create pointer artifacts on a second tracking surface.
#### Scenario: /to-issues publishes a slice chain
- **WHEN** /to-issues publishes tracer-bullet slices for a project with a configured git
tracker
- **THEN** the slices become issues on that tracker and no pointer card or secondary
tracking artifact is created anywhere

View File

@ -0,0 +1,61 @@
# issue-state-labels — delta spec
## ADDED Requirements
### Requirement: Canonical label taxonomy
The backlog label set on every routed tracker SHALL be: priority `P0` `P1` `P2` `P3`;
autonomy `hitl` `semi` `afk-ready`; state `next` `waiting` `review`. `/os-backlog:route`
SHALL ensure these labels exist (idempotently) on forgejo/github trackers when onboarding.
No `doing` or `done` label exists: in-progress is expressed by assignment/branch activity,
completion by closing the issue.
#### Scenario: Route onboards a Forgejo repo
- **WHEN** `/os-backlog:route` configures `tracker=forgejo:<owner>/<repo>`
- **THEN** all ten canonical labels exist on the repo afterwards, and re-running route
creates nothing new
### Requirement: The next label is human-only
The `next` label marks human-curated priority. The AI SHALL never apply or remove `next`
on any issue, in any project, under any instruction short of an explicit direct user
request naming the specific issue.
#### Scenario: AI triages a backlog
- **WHEN** an AI session labels or files issues during triage or capture
- **THEN** `next` is neither added to nor removed from any issue
### Requirement: Autonomy label semantics
`hitl` issues are human-owned and SHALL never be picked up autonomously. `semi` issues MAY
be worked autonomously through their mechanical parts, SHALL stop at named decision gates,
and on shipping SHALL receive the `review` label instead of being closed. `afk-ready`
issues, once shipped AND verified, SHALL be closed directly. Assigning autonomy labels is
the responsible project's job; ambiguity resolves to `hitl`.
#### Scenario: Semi-autonomy work ships
- **WHEN** an AI session completes the mechanical work of a `semi` issue
- **THEN** the issue receives the `review` label, a comment summarizing what shipped, and
remains open for human sign-off
#### Scenario: Afk-ready work ships and verifies
- **WHEN** an AI session completes and verifies work on an `afk-ready` issue
- **THEN** the issue is closed with a closing comment linking the verification evidence
### Requirement: Working an issue means recording state on it
An AI session working an issue SHALL record state transitions on the issue itself: a
comment when work starts, the `waiting` label plus a blocker comment when blocked, and the
shipping transition per its autonomy label. Blocked issues SHALL have `waiting` removed
when work resumes.
#### Scenario: Work blocks on an external dependency
- **WHEN** in-progress issue work hits a blocker the session cannot resolve
- **THEN** the issue gains the `waiting` label and a comment naming the blocker, and the
session moves on
### Requirement: Recurring process issues are never closed
An issue representing a recurring process (e.g. the biweekly orchestration audit in the
ops repo) SHALL be worked by commenting each occurrence's outcome on it and leaving it
open; closing it is a human-only action.
#### Scenario: Recurring audit completes an occurrence
- **WHEN** an AI session completes one occurrence of a recurring-convention issue
- **THEN** the outcome is added as a comment and the issue stays open with its labels
unchanged

View File

@ -0,0 +1,76 @@
# Tasks: retire-planka-git-issues-only
## 1. Plugin rework — grammar and lib (TDD throughout)
- [x] 1.1 Reject `planka:` in tracker grammar: `Backlog::Tracker` validation + `config-write`
+ os-status `tracker-configured` check emit the one-line ADR-0042 message, fail-soft
(tests first)
- [x] 1.2 Delete Planka lib: `board_ensurer.rb`, `board_resolver.rb`, `board_spec.rb`,
Planka transport in `cards.rb`, `Config#planka_board`, board logic in `resolver.rb`,
planka-api gem wiring; delete their tests and the `FakePlankaClient`
- [x] 1.3 Remove CLI subcommands `board-ensure`, `activate`, `archive`, `card-add`, `cards`,
`snapshot`, `card-label`, `card-comment`, `card-move`; keep `resolve`, `inspect`,
`config-write`, `projects`; suite green
- [x] 1.4 Add issue-side capture/list helpers (tea/gh/repo-file dispatch per issue-backlog
spec) with tests against fake CLI outputs
## 2. Plugin rework — skills, agents, hooks
- [x] 2.1 Rewrite `/os-backlog:capture` and `/os-backlog:list` to the issue model
(labeled-issue create; pull-only grouped listing)
- [x] 2.2 Rewrite `/os-backlog:route` for the reduced grammar: destination proposal,
canonical ten-label ensure (idempotent), migration of in-repo items; drop
spec/state-boundary and pointer-card prose
- [x] 2.3 Delete `agents/card-triage.md` and `agents/board-audit.md`
- [x] 2.4 Rewrite `hooks/session_start.py` note per ADR-0042: keep capture / routing /
cross-project / autonomy-label / recurring-issue rules; add the human-only `next`
rule; drop column-ownership and promotion rules; verify token count shrank
- [x] 2.5 Update `/to-issues` (global, `~/.agents/skills/to-issues/`): destination step
reads the reduced grammar; remove the Planka pointer-card branch
- [x] 2.6 Confirm wakeup poller label reads match the new taxonomy (no `doing`/`done`
labels); adjust tests if needed
- [x] 2.7 Run `bin/refresh-plugins`; smoke-test capture + list live in cc-os
## 3. Data migration (Planka still up; order matters)
- [x] 3.1 Snapshot all boards to JSON via the API; take a Postgres dump on ovh-vps
- [x] 3.2 Create private `jared/ops` Forgejo repo with the canonical label set; archive the
board snapshot JSON in it
- [x] 3.3 Recreate the recurring "Biweekly orchestration IRL audit" as a
recurring-convention issue in `jared/ops`; update the os-context audit skill's
pointer to it
- [x] 3.4 Migrate cc-os non-Done cards (9 Backlog, 2 Waiting, 2 Review) to `jared/cc-os`
Forgejo issues with labels carried over; note card-comment context in issue bodies
- [x] 3.5 HUMAN GATE: per-card pass over philly-search-engine-marketing (17 Backlog,
6 Waiting) — user decides destination (client tracker vs ops repo vs close) per card
(2026-07-16: 4 repo cards → jared/llf-schema#710, 18 client cards → jared/ops#421,
guardrails pin closed — vault note is the durable record)
- [x] 3.6 Close/ignore remaining onboarding-era boards (infrastructure, ovh-prod,
ruby-gems, planka, apprise-api) — contents are Done or stale; covered by snapshot
- [x] 3.7 Rewrite `tracker=` in `.cc-os/config` of all onboarded projects (query
`os-backlog projects` for the list); verify project index rows update
## 4. Decommission (one-way; only after 3.x verified)
- [x] 4.1 HUMAN GATE: confirm migration complete and snapshot/dump archived (user confirmed
2026-07-16: decommission right after philly pass)
- [x] 4.2 Revoke/delete the Planka bot credentials; close the open credvault-import card's
successor issue as obsolete (bot-credentials.env deleted on ovh-vps; no successor
issue existed — the credvault-import card sat in Done and was never migrated; the
import is moot with the bot account gone)
- [x] 4.3 Stop and remove the Planka service on ovh-vps (compose down, remove from
backups/monitoring); keep the Postgres dump archived (containers/cron/service dir
removed; no external backup/monitoring hooks found; dump + board JSON + service
configs preserved at ovh-vps `~/planka-final-snapshot-2026-07-16/`)
- [x] 4.4 Archive the `planka-api` gem repo (read-only, README pointing at ADR-0042)
(github.com/jaredswanson/planka archived; README banner commit e0472c4)
## 5. Records
- [x] 5.1 Update `docs/implementation-status/os-backlog.md` (rework entry) + one-line index
headline; update the CLAUDE.md inventory bullet for os-backlog
- [x] 5.2 Update the backlog-pilot vault note (pilot outcome: Planka retired per ADR-0042)
and the auto-memory `backlog-pilot-status` entry
- [x] 5.3 Verify ADR index shows 0033 Superseded by 0042; add a status note to ADR-0029
body is NOT needed (subject removed — covered by 0042 text); confirm via
`/os-adr:find` on plugins/os-backlog/

View File

@ -0,0 +1,71 @@
# issue-backlog
## ADDED Requirements
### Requirement: Tracker key grammar excludes Planka
The `.cc-os/config` `tracker` key SHALL accept exactly `forgejo:<owner>/<repo>`,
`github:<owner>/<repo>`, or `repo:<path>`. `config-write` and the os-status
`tracker-configured` check SHALL reject any other value, including `planka:<board>`, with a
one-line error naming ADR-0042 and pointing to `/os-backlog:route`; rejection SHALL never
block or fail a session (fail-soft).
#### Scenario: Legacy planka tracker value encountered
- **WHEN** a config containing `tracker=planka:cc-os` is read by config-write validation or
the os-status check
- **THEN** the value is rejected with a one-line message citing ADR-0042 and suggesting
`/os-backlog:route`, and the session continues normally
#### Scenario: Valid git tracker accepted
- **WHEN** `config-write` is invoked with `tracker=forgejo:jared/cc-os`
- **THEN** the key is written, other config keys and comments are preserved, and the
project index is upserted
### Requirement: Capture files a labeled issue on the configured tracker
`/os-backlog:capture` SHALL create an issue on the project's configured tracker (via `tea`
for forgejo, `gh` for github, an issue file for `repo:`) carrying a priority label (P0P3)
when known and no state labels; capture SHALL never apply the `next` label. When no tracker
key is configured in a git project, capture SHALL suggest `/os-backlog:route` once and
otherwise degrade to reporting what it could not do.
#### Scenario: Mid-session capture on a Forgejo-tracked project
- **WHEN** deferred work surfaces in a project with `tracker=forgejo:jared/cc-os`
- **THEN** a new open issue is created on that repo via `tea` with the title, a body noting
the originating context, and any known priority label — and nothing else
#### Scenario: Capture with no tracker configured
- **WHEN** capture is invoked in a git project whose config has no tracker key
- **THEN** no issue is created; the skill suggests `/os-backlog:route` once and exits
without error
### Requirement: Listing is pull-only over issues
`/os-backlog:list` SHALL query the configured tracker's open issues (title, number, labels,
assignee) and present them grouped by state (next / waiting / review / other open). It
SHALL run only on explicit user request; no hook or skill SHALL inject issue lists into a
session unasked.
#### Scenario: User asks what's on the backlog
- **WHEN** the user explicitly asks what is on the backlog or what's next
- **THEN** open issues from the configured tracker are listed grouped by state labels, with
`next`-labeled issues shown first
### Requirement: Cross-project filing targets the other project's tracker
WHEN work belonging to a different project surfaces, the system SHALL resolve that
project's tracker via the global project index (`os-backlog projects`) and file a labeled
issue there using the Discoverer template, rather than editing the other project or filing
locally. Priority/autonomy labeling of the filed issue remains the receiving project's job.
#### Scenario: Discovered defect in another indexed project
- **WHEN** a session in project A finds a bug belonging to indexed project B
- **THEN** an issue is created on B's configured tracker with the Discoverer template, no
autonomy or priority labels are applied by A, and no local copy is kept
### Requirement: One tracker holds both state and specs
A project SHALL have exactly one configured tracker destination; durable specs
(tracer-bullet slices from /to-tickets, PRDs) and task-state items are issues on that same
tracker. The system SHALL NOT create pointer artifacts on a second tracking surface.
#### Scenario: /to-tickets publishes a slice chain
- **WHEN** /to-tickets publishes tracer-bullet slices for a project with a configured git
tracker
- **THEN** the slices become issues on that tracker and no pointer card or secondary
tracking artifact is created anywhere

View File

@ -0,0 +1,61 @@
# issue-state-labels
## ADDED Requirements
### Requirement: Canonical label taxonomy
The backlog label set on every routed tracker SHALL be: priority `P0` `P1` `P2` `P3`;
autonomy `hitl` `semi` `afk-ready`; state `next` `waiting` `review`. `/os-backlog:route`
SHALL ensure these labels exist (idempotently) on forgejo/github trackers when onboarding.
No `doing` or `done` label exists: in-progress is expressed by assignment/branch activity,
completion by closing the issue.
#### Scenario: Route onboards a Forgejo repo
- **WHEN** `/os-backlog:route` configures `tracker=forgejo:<owner>/<repo>`
- **THEN** all ten canonical labels exist on the repo afterwards, and re-running route
creates nothing new
### Requirement: The next label is human-only
The `next` label marks human-curated priority. The AI SHALL never apply or remove `next`
on any issue, in any project, under any instruction short of an explicit direct user
request naming the specific issue.
#### Scenario: AI triages a backlog
- **WHEN** an AI session labels or files issues during triage or capture
- **THEN** `next` is neither added to nor removed from any issue
### Requirement: Autonomy label semantics
`hitl` issues are human-owned and SHALL never be picked up autonomously. `semi` issues MAY
be worked autonomously through their mechanical parts, SHALL stop at named decision gates,
and on shipping SHALL receive the `review` label instead of being closed. `afk-ready`
issues, once shipped AND verified, SHALL be closed directly. Assigning autonomy labels is
the responsible project's job; ambiguity resolves to `hitl`.
#### Scenario: Semi-autonomy work ships
- **WHEN** an AI session completes the mechanical work of a `semi` issue
- **THEN** the issue receives the `review` label, a comment summarizing what shipped, and
remains open for human sign-off
#### Scenario: Afk-ready work ships and verifies
- **WHEN** an AI session completes and verifies work on an `afk-ready` issue
- **THEN** the issue is closed with a closing comment linking the verification evidence
### Requirement: Working an issue means recording state on it
An AI session working an issue SHALL record state transitions on the issue itself: a
comment when work starts, the `waiting` label plus a blocker comment when blocked, and the
shipping transition per its autonomy label. Blocked issues SHALL have `waiting` removed
when work resumes.
#### Scenario: Work blocks on an external dependency
- **WHEN** in-progress issue work hits a blocker the session cannot resolve
- **THEN** the issue gains the `waiting` label and a comment naming the blocker, and the
session moves on
### Requirement: Recurring process issues are never closed
An issue representing a recurring process (e.g. the biweekly orchestration audit in the
ops repo) SHALL be worked by commenting each occurrence's outcome on it and leaving it
open; closing it is a human-only action.
#### Scenario: Recurring audit completes an occurrence
- **WHEN** an AI session completes one occurrence of a recurring-convention issue
- **THEN** the outcome is added as a comment and the issue stays open with its labels
unchanged

View File

@ -0,0 +1,46 @@
# cc-os Plugin and Skill Naming Convention
Canonical repo copy of the convention established 2026-07-02 (previously only in the
SecondBrain vault note `cc-os-plugin-skill-naming-convention.md`, which now points here).
Read this before naming ANY new cc-os plugin, skill, or slash command.
## Core principles
1. **Plugins use an `os-` prefix**: `os-[domain]` (e.g. `os-vault`, `os-backlog`). The
prefix ties the plugin to cc-os and distinguishes it from unrelated tools
(`memsearch` is NOT cc-os).
2. **Skills are verb-first**: the plugin namespace answers "what is this about?", the
skill name answers "what action?" — `query`, `write`, `reorganize`, producing
invocations like `/os-vault:query`.
3. **Multi-word skills are kebab-case, verb + noun**: `design-template`,
`onboard-project`. Kebab-case in plugin `name` fields is a Claude Code requirement;
cc-os extends it to skill names by convention.
4. **No `commands/` dispatcher directories** — skills are the only invocation surface.
## Registration mechanics
**Never set a `name:` field in SKILL.md frontmatter.** The directory name is the skill
name; with `name:` absent, Claude Code registers the namespaced `/plugin:skill` form.
An explicit `name: find` collapses the command to bare unnamespaced `/find`
(discovered 2026-07-04 in os-adr and os-doc-hygiene; fixed by deleting the lines).
After any SKILL.md edit, run `bin/refresh-plugins` — installs copy files into
`~/.claude/plugins/cache/`, so source edits don't reach sessions until refreshed.
## Anti-patterns
- Plugin name without the `os-` prefix (`vault` instead of `os-vault`).
- Noun-stacked skill names (`vault-query` instead of `query`).
- Generic verbs duplicating the namespace context (`/memory-find` instead of
`/os-vault:find` — the namespace provides the context).
- CamelCase or snake_case in plugin or skill names.
## Open questions
Agent-name and hook-script-name governance are deferred until their use patterns
stabilize; hook scripts currently use snake_case (Python convention) and are not
externally visible.
## Cross-references
- ADR-0025 (Ruby structure standard — the structural layer below this naming layer)
- `references/layout/plugin-structure.md`, `references/layout/skill-structure.md`

View File

@ -20,7 +20,23 @@ module PluginConfig
private private
def plugin_key def plugin_key
"#{plugin}@#{marketplace}" "#{plugin}@#{resolved_marketplace}"
end
# The marketplace id this plugin is actually installed under, per
# installed_plugins.json, falling back to the --marketplace param when
# no installed entry exists yet (e.g. before first install). Needed
# because cc-os's os-* plugins install under a `local-plugins`
# marketplace (symlink into ~/.claude/plugins) rather than whatever
# marketplace id the caller assumes (e.g. the repo's own `cc-os`),
# which otherwise produces false "not installed" results.
def resolved_marketplace
data = read_json(installed_plugins_path)
if data && data['plugins']
match = data['plugins'].keys.find { |k| k.start_with?("#{plugin}@") }
return match.split('@', 2).last if match
end
marketplace
end end
def read_json(path) def read_json(path)

43
plugins/os-adr/CLAUDE.md Normal file
View File

@ -0,0 +1,43 @@
# os-adr
ADR (architecture decision record) system for cc-os and pilot projects: numbered
`docs/adr/` files + generated index, migration of legacy decision logs, deterministic
find/create/init workflows. Full design/build history: `docs/implementation-status/os-adr.md`.
## Component map
- `skills/` — four verbs, each `/os-adr:<verb>`: `create`, `find`, `init`, `migrate`.
- `lib/adr.rb` + `lib/adr/{detector,finder,index,migration_report,migrator,record,
repository,template}.rb` — single `Adr` namespace, `require_relative` loading,
stdlib-only (no external gems).
- `bin/`**per-verb scripts** (`adr-new`, `adr-find`, `adr-init`, `adr-migrate`,
`adr-detect`), invoked as `ruby ${CLAUDE_PLUGIN_ROOT}/bin/adr-<verb>` from skills.
This is **not** the single-dispatcher pattern ADR-0025 requires of newer plugins —
os-adr is explicitly grandfathered (see below).
- `templates/adr.md` — the ADR template migration and creation write against.
- `hooks/hooks.json` — SessionStart entry is intentionally empty; the check itself
moved to os-status's `adr-system-present` (`hooks/session_start.py` here is kept
only as the wording source of record).
- `tests/` — minitest, `ruby tests/all.rb` (glob-runs `*_test.rb`) + `test_helper.rb`
tmpdir fixtures; `hook_test.py` covers the hooks-parity case.
- `invariants.md` — reversion-protection contract; read before touching detection,
migration, indexing, or find-filtering logic.
- `eval/`, `eval-b/`, `eval-c/` — Eval A/B/C harnesses (prompted grid, held-out
unprompted-behavior, ambiguity-ladder). See `docs/implementation-status/os-adr.md`
for what each measures; eval discipline (reserves never read informally) per root
`CLAUDE.md`.
## ADR-0025 exemption
The per-verb `bin/` layout (rather than a single `bin/os-adr` dispatcher) is
**grandfathered by
[ADR-0025](../../docs/adr/0025-standard-ruby-structure-for-cc-os-plugins-lib-single-dispatcher-bin-fail-soft-installed-gem-only-dependencies.md)**:
the exemption is recorded there explicitly ("not worth a retrofit until os-adr is
next touched for other reasons"), not an oversight. Don't "fix" the dispatcher shape
as a drive-by — fold it into whatever unrelated work next touches os-adr's `bin/`.
## Pointers
- Decisions: `docs/adr/0025-...md` (Ruby structure), `docs/adr/0020` and its
amendment (cc-os retrofit).
- Status/build history: `docs/implementation-status/os-adr.md`.

View File

@ -1,5 +1,5 @@
{ {
"name": "os-backlog", "name": "os-backlog",
"version": "0.2.0", "version": "0.3.0",
"description": "Uniform Planka backlog boards across projects: idempotent board-ensure (lists, labels, archive convention, Planka 2.1.1 quirk handling) and a deterministic repo-to-board routing resolver." "description": "Git-issues backlog across projects: deterministic tracker routing (forgejo/github/repo), mid-session capture as labeled issues, pull-only listing (ADR-0042)."
} }

View File

@ -0,0 +1,31 @@
# os-backlog
Git-issues backlog surface: per-repo tracker routing (`forgejo:`/`github:`/`repo:` in
`.cc-os/config`), issue create/list/route skills, ten-label taxonomy.
Entry points: `bin/os-backlog` (dispatcher CLI), skills `capture`/`list`/`route`,
`hooks/` (SessionStart wiring).
Full detail: `docs/implementation-status/os-backlog.md`. No `invariants.md` yet —
see NOTE below.
NOTE (2026-07-16): a major rework retiring Planka in favor of git-issues-only
(ADR-0042, OpenSpec change retire-planka-git-issues-only) is in flight on main;
this index intentionally avoids enumerating internals that rework replaces.
## ADR-0025 exemption
`bin/wakeup-poll` is a second `bin/` entry point alongside the `bin/os-backlog`
dispatcher — a deviation from [ADR-0025](../../docs/adr/0025-standard-ruby-structure-for-cc-os-plugins-lib-single-dispatcher-bin-fail-soft-installed-gem-only-dependencies.md)'s
single-dispatcher rule. This is **grandfathered**, following the same
exemption mechanism os-adr recorded for its own ADR-0025 deviation (see
`plugins/os-adr/CLAUDE.md`): the split is backed by
[ADR-0034](../../docs/adr/0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md)
(global project index),
[ADR-0035](../../docs/adr/0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md)
(issue-triggered wakeup via polling), and
[ADR-0036](../../docs/adr/0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md)
(tmux session convention) — "Pilot only" per the script's header comment,
not an oversight. Don't
fold `wakeup-poll` into the dispatcher as a drive-by; revisit if/when those
ADRs graduate past pilot.

View File

@ -1,38 +0,0 @@
---
name: board-audit
description: Audits a Planka board for process drift — stale Doing cards, WIP over cap, Waiting cards whose blockers cleared, aging Review cards — and reports it. Writes at most comments on cards; never moves anything.
model: sonnet
tools: Bash, Read
---
You are the board-audit agent for the os-backlog plugin. Your job: read a board, detect drift, and report it so the process stays honest without human policing.
## Hard constraints (never violate)
- **Read-mostly: your only permitted write is adding a comment to a card.** You never move a card between columns — never pull Next, never move anything past Review, never touch Done — and never change labels, titles, lists, or boards, and never delete anything.
- You never start executing the work a card describes.
- One comment per drifting card per audit run, maximum. If a card already carries a recent audit comment for the same finding, do not repeat it — mention it only in your report.
## The four drift classes
1. **Stale Doing** — a card sitting in Doing with no movement for a long time (default threshold: 3+ days via `listChangedAt`; use a threshold from your task prompt if given).
2. **WIP over cap** — more cards in Doing than the cap (default cap: 3, or per task prompt).
3. **Waiting unblocked** — a card in Waiting whose stated blocker (in its description/comments) appears to have cleared.
4. **Aging Review** — a card in Review awaiting human sign-off for too long (default: 2+ days). Flag it for the human; you may NOT move it to Done yourself.
## Procedure
All commands use `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog` (board name from your task prompt).
1. Fetch the board state (read-only):
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board <board>
```
2. Evaluate all four drift classes against the snapshot.
3. Optionally annotate drifting cards (the only write you may perform):
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-comment --card <id> --text "board-audit <date>: <finding, one sentence>"
```
4. Produce the drift report: one section per drift class (state "none" explicitly when clean), each finding with card title, column, evidence (age/count/blocker), and a suggested human action. Suggestions are for the human to execute — you never execute column moves yourself.
If the CLI fails (gem/Planka unavailable), report the exact error and stop.

View File

@ -1,42 +0,0 @@
---
name: card-triage
description: Batch-labels raw Backlog cards on a Planka board with priority (P0-P3) and autonomy (hitl/semi/afk-ready) labels so capture stays cheap and triage stays batched. Label changes only — never moves cards between columns.
model: sonnet
tools: Bash, Read
---
You are the card-triage agent for the os-backlog plugin. Your one job: take the raw, unlabeled cards sitting in a board's Backlog column and give each one exactly one priority label and exactly one autonomy label.
## Hard constraints (never violate)
- **Writes are label changes only.** You never move a card between columns — not into Next, not out of Backlog, not past Review, and never anything into or out of Done. Next and Done are exclusively human-owned.
- You never create, delete, rename, or reorder cards, lists, or boards.
- You never self-assign work or start executing a card's task — you only classify.
- If a card is ambiguous, label it `hitl` rather than guessing an autonomy level that would let an agent run with it.
## Label vocabulary
- Priority (pick one per card): `P0` (urgent, drop-everything) > `P1` > `P2` > `P3` (someday).
- Autonomy (pick one per card):
- `afk-ready` — fully specified, an agent can complete it end-to-end unattended.
- `semi` — an agent can start, but there are judgment points needing a human check-in.
- `hitl` — human-in-the-loop required; agents must not self-assign these.
## Procedure
All commands use `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog` (read the board name from your task prompt).
1. Fetch the board state (read-only):
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board <board>
```
2. Collect the Backlog-column cards whose `labels` array is missing a priority label or an autonomy label. Cards in other columns are out of scope.
3. For each raw card, decide priority + autonomy from its title/description. Then apply:
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-label --board <board> --card <id> --label <P0|P1|P2|P3>
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-label --board <board> --card <id> --label <hitl|semi|afk-ready>
```
Skip a dimension the card already has — never double-label.
4. Report a compact table: card title, priority assigned, autonomy assigned, one-clause rationale. Note any cards you left for the human (and why).
If the CLI fails (gem/Planka unavailable), report the exact error and stop — no retries, no partial guessing.

View File

@ -1,34 +1,30 @@
#!/usr/bin/env ruby #!/usr/bin/env ruby
# frozen_string_literal: true # frozen_string_literal: true
# #
# os-backlog CLI: board-ensure, activate, archive, resolve, card-add, # os-backlog CLI: resolve, inspect, config-write, projects, issue-create,
# cards, snapshot, inspect, config-write. # issues. Post-ADR-0042 (Planka retired): git issues (forgejo/github/repo)
# are the single tracker for both state and specs.
# #
# Usage: # Usage:
# os-backlog board-ensure NAME [--project PROJECT] (any Planka project name;
# defaults to Backlog::Resolver.project_for(Dir.pwd) — Dev/Clients/Servers
# by path, else "Dev"; creates the project if it doesn't exist yet)
# os-backlog activate NAME
# os-backlog archive NAME --yes
# os-backlog resolve < input.json # os-backlog resolve < input.json
# input.json: {"repo_path": "...", "config": "..."|null, "boards": ["...", ...]} # input.json: {"repo_path": "...", "config": "..."|null}
# os-backlog card-add --board NAME --title "..." [--description "..."]
# os-backlog card-move --board NAME --card ID --to COLUMN
# 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 inspect (detect existing tracking for cwd repo, JSON)
# os-backlog config-write <tracker-value> (write tracker key to .cc-os/config # os-backlog config-write <tracker-value> (write tracker key to .cc-os/config
# + upsert the global project index) # + upsert the global project index)
# os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON) # os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON)
# os-backlog issue-create --title "..." --body "..." [--priority P0-P3]
# (creates an issue on the cwd repo's configured tracker; never applies
# a state label)
# os-backlog issues (open issues on the cwd repo's
# configured tracker, grouped by
# state label, JSON)
# #
# board-ensure/activate/archive talk to Planka over the network (the # resolve, config-write are pure — no network access at all. inspect,
# installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/ # issue-create, and issues touch the network/CLIs (tea/gh) but fail soft
# PASSWORD). resolve, config-write are pure — no network access at all. # field-by-field (inspect) or with a one-line error (issue-create/issues)
# inspect touches the network/CLIs opportunistically but fails soft # rather than leaving a stack trace.
# field-by-field rather than aborting.
# #
# Fails soft: if the gem or Planka credentials are unavailable, prints one # Fails soft: never destructive.
# clear error and exits nonzero. Never destructive.
require "json" require "json"
require "fileutils" require "fileutils"
@ -40,58 +36,6 @@ def fail_soft(message)
exit 1 exit 1
end end
# Guards board-ensure/activate/archive's positional NAME argument against a
# mistaken flag-style invocation (e.g. `board-ensure --board apprise-api`,
# where `--board` is swallowed as the name and `apprise-api` is dropped
# silently). Called immediately after each command shifts its name arg,
# before build_client, so a bad name never reaches Planka.
def validate_board_name!(name, command)
return unless name.start_with?("--")
fail_soft("#{command}: board name #{name.inspect} must not start with \"--\" " \
"(looks like a flag was passed positionally — check your quoting/flags)")
end
def build_client
require "planka_api"
client = Planka::Client.new
client.login
client
rescue LoadError
fail_soft("the planka-api gem is not installed/loadable; cannot reach Planka")
rescue StandardError => e
fail_soft("could not authenticate to Planka: #{e.message}")
end
# Cards wired to the cwd repo's .cc-os/config board_id cache (issue #22):
# the BoardResolver gets injected read/write lambdas; any cache failure
# degrades to the plain name lookup inside the resolver itself.
def build_cards(client)
config_path = File.join(Dir.pwd, ".cc-os", "config")
read_config = -> { File.exist?(config_path) ? File.read(config_path) : nil }
write_config = lambda do |contents|
FileUtils.mkdir_p(File.dirname(config_path))
File.write(config_path, contents)
end
resolver = Backlog::BoardResolver.new(client: client, read_config: read_config,
write_config: write_config)
Backlog::Cards.new(client: client, board_resolver: resolver)
end
# --project accepts any Planka project name (Dev/Clients/Servers, or a
# fresh one board-ensure will create). When --project is omitted, the
# default is derived from the repo's path via
# Backlog::Resolver.project_for(Dir.pwd), falling back to "Dev" for paths
# outside every known root.
def parse_project_flag(args)
idx = args.index("--project")
return Backlog::Resolver.project_for(Dir.pwd) || "Dev" unless idx
args.delete_at(idx)
project = args.delete_at(idx)
project || fail_soft("--project requires a value (a Planka project name)")
end
def parse_flag(args, flag) def parse_flag(args, flag)
idx = args.index(flag) idx = args.index(flag)
return nil unless idx return nil unless idx
@ -104,22 +48,6 @@ 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 + lightweight per-issue metadata via tea # Best-effort open-issue count + lightweight per-issue metadata via tea
# (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails # (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails
# soft per-field: any fetch/parse failure means metadata nil. Returns # soft per-field: any fetch/parse failure means metadata nil. Returns
@ -195,86 +123,32 @@ rescue StandardError => e
warn "os-backlog: (non-fatal) could not update project index: #{e.message}" warn "os-backlog: (non-fatal) could not update project index: #{e.message}"
end end
# The cwd repo's configured tracker, or nil (no .cc-os/config, or no
# tracker key set). Shared by issue-create/issues.
def cwd_tracker
config_path = File.join(Dir.pwd, ".cc-os", "config")
contents = File.exist?(config_path) ? File.read(config_path) : nil
Backlog::Config.new(contents).tracker
end
def require_cwd_tracker(command)
tracker = cwd_tracker
return tracker if tracker && Backlog::Tracker.valid?(tracker)
fail_soft("#{command}: no valid tracker configured for #{Dir.pwd} — run /os-backlog:route first")
end
command, *rest = ARGV command, *rest = ARGV
begin begin
case command case command
when "board-ensure"
name = rest.shift
fail_soft("board-ensure requires a board name") unless name
validate_board_name!(name, "board-ensure")
project = parse_project_flag(rest)
client = build_client
result = Backlog::BoardEnsurer.new(client: client).ensure(name, project_name: project)
puts "board #{result.board.name} (#{result.created ? 'created' : 'existing'}) " \
"in project #{project}; lists created: #{result.lists_created.join(', ')}; " \
"labels created: #{result.labels_created.join(', ')}"
when "activate"
name = rest.shift
fail_soft("activate requires a board name") unless name
validate_board_name!(name, "activate")
client = build_client
board = Backlog::BoardEnsurer.new(client: client).activate(name)
puts "activated: #{board.name}"
when "archive"
name = rest.shift
fail_soft("archive requires a board name") unless name
validate_board_name!(name, "archive")
fail_soft("archive requires explicit --yes (human go-ahead only)") unless rest.include?("--yes")
client = build_client
board = Backlog::BoardEnsurer.new(client: client).archive(name)
puts "archived: #{board.name}"
when "resolve" when "resolve"
input = JSON.parse($stdin.read) input = JSON.parse($stdin.read)
resolver = Backlog::Resolver.new( resolver = Backlog::Resolver.new(
repo_path: input.fetch("repo_path"), repo_path: input.fetch("repo_path"),
config_contents: input["config"], config_contents: input["config"]
boards: input.fetch("boards", [])
) )
puts resolver.resolve_string puts resolver.resolve_string
when "card-add"
board = require_flag(rest, "--board", "card-add")
title = require_flag(rest, "--title", "card-add")
description = parse_flag(rest, "--description")
client = build_client
card = build_cards(client).add(board_name: board, title: title, description: description)
puts "created card ##{card.id} at Backlog on #{board}: #{card.name}"
when "card-move"
board = require_flag(rest, "--board", "card-move")
card_id = require_flag(rest, "--card", "card-move")
to = require_flag(rest, "--to", "card-move")
client = build_client
card = build_cards(client).move(board_name: board, card_id: card_id, to: to)
puts "moved card ##{card.id} to #{to} on #{board}"
when "cards"
board = require_flag(rest, "--board", "cards")
client = build_client
snapshot = build_cards(client).snapshot(board_name: board)
snapshot["lists"].each do |list|
puts "#{list['name']} (#{list['cards'].size})"
list["cards"].each do |card|
labels = card["labels"].empty? ? "" : " [#{card['labels'].join(', ')}]"
puts " - #{card['name']}#{labels}"
end
end
when "snapshot"
board = require_flag(rest, "--board", "snapshot")
client = build_client
puts JSON.pretty_generate(build_cards(client).snapshot(board_name: board))
when "card-label"
board = require_flag(rest, "--board", "card-label")
card_id = require_flag(rest, "--card", "card-label")
label = require_flag(rest, "--label", "card-label")
client = build_client
build_cards(client).attach_label(board_name: board, card_id: card_id, label: label)
puts "labeled card ##{card_id} with #{label}"
when "card-comment"
card_id = require_flag(rest, "--card", "card-comment")
text = require_flag(rest, "--text", "card-comment")
client = build_client
comment = build_cards(client).comment(card_id: card_id, text: text)
puts "commented on card ##{card_id} (comment ##{comment.id})"
when "inspect" when "inspect"
repo_path = Dir.pwd repo_path = Dir.pwd
config_path = File.join(repo_path, ".cc-os", "config") config_path = File.join(repo_path, ".cc-os", "config")
@ -288,7 +162,6 @@ when "inspect"
findings = { findings = {
"repo_path" => repo_path, "repo_path" => repo_path,
"tracker_configured" => config.tracker, "tracker_configured" => config.tracker,
"planka" => inspect_planka(repo_path),
"git_remote" => remote_info ? remote_info.transform_keys(&:to_s) : nil, "git_remote" => remote_info ? remote_info.transform_keys(&:to_s) : nil,
"issue_cli" => issue_cli, "issue_cli" => issue_cli,
"issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata), "issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata),
@ -298,9 +171,13 @@ when "inspect"
when "config-write" when "config-write"
value = rest.shift value = rest.shift
fail_soft("config-write requires a tracker value, e.g. forgejo:owner/repo") unless value fail_soft("config-write requires a tracker value, e.g. forgejo:owner/repo") unless value
if value.start_with?("planka:")
fail_soft("planka: trackers are retired (ADR-0042) — run /os-backlog:route to " \
"choose forgejo:/github:/repo: instead")
end
unless Backlog::Tracker.valid?(value) unless Backlog::Tracker.valid?(value)
fail_soft("invalid tracker value #{value.inspect}; expected one of " \ fail_soft("invalid tracker value #{value.inspect}; expected one of " \
"planka:<board> | forgejo:<owner>/<repo> | github:<owner>/<repo> | repo:<path>") "forgejo:<owner>/<repo> | github:<owner>/<repo> | repo:<path>")
end end
config_dir = File.join(Dir.pwd, ".cc-os") config_dir = File.join(Dir.pwd, ".cc-os")
@ -320,25 +197,40 @@ when "projects"
end end
end end
puts JSON.pretty_generate({ "projects" => projects }) puts JSON.pretty_generate({ "projects" => projects })
when "issue-create"
title = require_flag(rest, "--title", "issue-create")
body = require_flag(rest, "--body", "issue-create") || ""
priority = parse_flag(rest, "--priority")
tracker = require_cwd_tracker("issue-create")
issues = Backlog::Issues.new(runner: Backlog::Issues::ShellRunner.new, repo_root: Dir.pwd)
begin
result = issues.create(tracker: tracker, title: title, body: body, priority: priority)
rescue ArgumentError, Backlog::Issues::CommandFailed => e
fail_soft("issue-create: #{e.message}")
end
puts JSON.pretty_generate(result.transform_keys(&:to_s))
when "issues"
tracker = require_cwd_tracker("issues")
issues = Backlog::Issues.new(runner: Backlog::Issues::ShellRunner.new, repo_root: Dir.pwd)
begin
grouped = issues.list(tracker: tracker)
rescue ArgumentError, Backlog::Issues::CommandFailed => e
fail_soft("issues: #{e.message}")
end
puts JSON.pretty_generate(grouped.transform_keys(&:to_s).transform_values { |v| v.map { |i| i.transform_keys(&:to_s) } })
when nil, "-h", "--help" when nil, "-h", "--help"
puts <<~USAGE puts <<~USAGE
usage: os-backlog <command> [options] usage: os-backlog <command> [options]
commands: commands:
board-ensure NAME [--project PROJECT] (any project name; creates it if missing; resolve (reads {"repo_path","config"} JSON from stdin)
default derived from cwd path)
activate NAME
archive NAME --yes
resolve (reads {"repo_path","config","boards"} JSON from stdin)
card-add --board NAME --title "..." [--description "..."]
card-move --board NAME --card ID --to COLUMN
cards --board NAME
snapshot --board NAME
card-label --board NAME --card ID --label NAME
card-comment --card ID --text "..."
inspect (detect existing tracking for cwd repo, JSON) inspect (detect existing tracking for cwd repo, JSON)
config-write <tracker-value> (write tracker key to .cc-os/config + global project index) config-write <tracker-value> (write tracker key to .cc-os/config + global project index)
projects [NAME-FILTER] (print the global project index at ~/.cc-os/projects.json as JSON) projects [NAME-FILTER] (print the global project index at ~/.cc-os/projects.json as JSON)
issue-create --title "..." --body "..." [--priority P0-P3]
(create an issue on the cwd repo's configured tracker)
issues (list open issues on the cwd repo's configured tracker,
grouped by state label, JSON)
USAGE USAGE
exit(command.nil? ? 1 : 0) exit(command.nil? ? 1 : 0)
else else

View File

@ -8,11 +8,6 @@
"type": "command", "type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py",
"timeout": 5 "timeout": 5
},
{
"type": "command",
"command": "ruby ${CLAUDE_PLUGIN_ROOT}/hooks/triage_check.rb",
"timeout": 15
} }
] ]
} }

View File

@ -3,29 +3,25 @@
Modeled on os-adr's session_start.py (the wording-source-of-record pattern). Modeled on os-adr's session_start.py (the wording-source-of-record pattern).
Injects the backlog process rules WHEN->THEN mechanical triggers only. Injects the backlog process rules WHEN->THEN mechanical triggers only.
Deliberately contains ZERO board state and no briefs (notification policy v2: Deliberately contains ZERO issue state and no briefs (notification policy v2:
pull beats push board state is fetched on demand via /os-backlog:list, never pull beats push issue state is fetched on demand via /os-backlog:list, never
pushed into a session). pushed into a session).
Sibling hook triage_check.rb (card 1818330518584820864) is a narrow, sanctioned Per ADR-0042 (2026-07-16), Planka is retired: git issues (Forgejo via tea,
exception to that policy: it makes one bounded Planka read to decide whether to GitHub via gh, or in-repo via repo:<path>) are the single tracker for both
push a single mechanical dispatch directive not a brief, not board contents task state and durable specs. State is modeled with labels, not columns
when the current project's own Backlog has unlabeled cards. The only board- there is no card-move, no board, no triage-dispatch hook to mention here.
derived fact it ever surfaces is the unlabeled count that justifies dispatching
card-triage; it never lists card titles or otherwise renders board state. Kept
as a separate script precisely so this file can stay a pure, network-free
function of filesystem state.
git project -> additionalContext note (rules only, near-zero tokens) git project -> additionalContext note (rules only, near-zero tokens)
not a git repo -> silent (process rules only apply inside real projects) not a git repo -> silent (process rules only apply inside real projects)
Autonomy-label semantics in NOTE are the ADR-029 contract (which supersedes Autonomy-label semantics in NOTE are the ADR-0042 contract (successor to
the wording in issue #16's body): hitl never picked up autonomously; semi ADR-029's Planka-mechanics version): hitl never picked up autonomously; semi
stops at Review for human sign-off; afk-ready goes straight to Done after stops at named decision gates and ships to the review label; afk-ready ships
verification, skipping Review. and, once verified, closes directly.
Pure function of the project's filesystem state; no model, no network, no Pure function of the project's filesystem state; no model, no network, no
Ruby, no Planka call. Always exits 0 the hook must never block a session. Ruby, no tracker call. Always exits 0 the hook must never block a session.
""" """
from __future__ import annotations from __future__ import annotations
@ -36,14 +32,14 @@ from pathlib import Path
from typing import Optional from typing import Optional
NOTE = """\ NOTE = """\
[os-backlog] Backlog process rules (rules only this note never carries board state; board state is pull-only via /os-backlog:list when the user asks). [os-backlog] Backlog process rules (rules only never carries board state; state is pull-only via /os-backlog:list when the user asks).
- CAPTURE: WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will NOT be done this session -> capture it as a Backlog card via /os-backlog:capture. Never a TODO comment in code, never only a mention in chat. - CAPTURE: WHEN deferred work surfaces mid-session that will NOT be done this session -> capture it as an issue via /os-backlog:capture. Never a TODO comment in code, never only a mention in chat.
- ROUTING: WHEN process/backlog/spec-tracking work surfaces -> read the tracker key from .cc-os/config (`tracker=planka:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`) and send work where it says. WHEN this is a git project and no tracker key is configured -> suggest /os-backlog:route once; do not nag. A subdirectory's own .cc-os/config overrides the repo root's for any session started there umbrella repos route each subproject to its own board/tracker this way; do not assume one repo means one board. - ROUTING: read the tracker key from .cc-os/config (`tracker=forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`; `planka:` is retired per ADR-0042 and rejected). WHEN no tracker key is configured in a git project -> suggest /os-backlog:route once; do not nag. A subdirectory's own .cc-os/config overrides the repo root's umbrella repos route each subproject to its own tracker this way.
- PROMOTION: WHEN a Planka-tracked effort accretes significant code or design decisions in card comments -> flag promotion to the user (never silently): carve out a repo, move the durable spec into git issues via /to-issues, and keep the Planka card as a pointer (title + link). Never duplicate spec text into a card description. - NEXT IS HUMAN-ONLY: never apply or remove the `next` label on any issue, any project, absent an explicit direct user request naming that issue.
- COLUMN OWNERSHIP (CLI-enforced per ADR-029; a violating card-move fails with the rule named): create cards at Backlog only. Never move a card into or out of Next Next is human-curated in both directions. Never move a card labeled hitl; never move a card out of Done. Working a card MEANS moving it via card-move: Doing when work starts, Waiting + a blocker comment when blocked, and on shipping: Review (semi) or Done (afk-ready, only after verification). - WORKING AN ISSUE: comment when work starts. Blocked -> add `waiting` + a comment naming the blocker; remove `waiting` on resume. Shipping: `semi` -> add `review` label + a summary comment, stays open for sign-off; `afk-ready` -> close directly once verified, with a comment linking the evidence.
- LABELING/TRIAGE: priority (P0-P3) and autonomy (hitl/semi/afk-ready) labeling is the RESPONSIBLE project's job (it knows its own priorities) — a parent umbrella may label its own and its child boards, but never label cards on a board that belongs to a different project you're just filing onto. Triage is initiated by this project's own SessionStart check (a separate hook), not by capture — if it reports unlabeled Backlog cards, dispatch the card-triage agent (haiku, background) to clear them. - AUTONOMY LABELS: `hitl` -> human-owned, never picked up autonomously. `semi` -> run the mechanical parts, stop at named decision gates. `afk-ready` -> run to completion and verify before closing.
- CROSS-PROJECT: WHEN a needed change belongs to a DIFFERENT project THEN do not edit it query `os-backlog projects`, file an issue/card with that project's tracker using the Discoverer template (see os-backlog route references/cross-project-filing.md). - RECURRING ISSUES: never closed by the AI comment each occurrence's outcome and leave it open.
- AUTONOMY LABELS (ADR-029): hitl -> human-owned; never picked up autonomously. semi -> run the mechanical parts, stop at named decision gates; shipped work goes to Review for human sign-off. afk-ready -> shipped + verified goes straight to Done, skipping Review. - CROSS-PROJECT: WHEN a needed change belongs to a DIFFERENT project THEN do not edit it query `os-backlog projects`, file an issue on that project's tracker using the Discoverer template (see os-backlog route references/cross-project-filing.md); no autonomy/priority labels applied by the filer.
""" """

View File

@ -1,94 +0,0 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# triage_check.rb — SessionStart hook (card 1818330518584820864): checks
# whether the CURRENT project's own resolved board has unlabeled Backlog
# cards and, if so, emits additionalContext instructing dispatch of the
# card-triage agent (haiku, background).
#
# Design intent: labeling is the RESPONSIBLE project's job, not capture's
# and not whichever project happened to file a card cross-project. This
# hook only ever looks at the board the CURRENT session's project resolves
# to (the same board /os-backlog:capture would file onto from here) — a
# session run from the owning project, or a parent umbrella, naturally
# only ever triggers triage for a board it owns; it never inspects or
# labels a board belonging to a different project.
#
# This is deliberately separate from session_start.py (which stays a pure,
# network-free function of filesystem state per its own docstring): this
# script does one bounded Planka read to decide whether to fire, then
# hands off the actual labeling to the card-triage agent — it never
# labels anything itself.
#
# Fails soft everywhere: no git project, no board name derivable, no
# network/credentials, any Planka error, or a board that doesn't exist yet
# -> silent, exit 0. Never blocks a session.
require "json"
require_relative "../lib/backlog"
def find_project_root(cwd)
dir = File.expand_path(cwd)
loop do
return dir if Dir.exist?(File.join(dir, ".git"))
parent = File.dirname(dir)
return nil if parent == dir
dir = parent
end
end
# Same config semantics as the CLI: the session cwd's own .cc-os/config
# wins (umbrella subdir configs override the repo root's for sessions
# started there), then the root config, then the path heuristic — and
# `tracker=planka:<board>` counts, not just the explicit `board` key.
# The decision itself is pure (TriageCheck.board_name_for); this only
# reads the two candidate config files.
def resolve_board_name(root)
read = lambda do |dir|
path = File.join(dir, ".cc-os", "config")
File.exist?(path) ? File.read(path) : nil
end
cwd = File.expand_path(Dir.pwd)
Backlog::TriageCheck.board_name_for(cwd: cwd, root: root,
cwd_config: read.call(cwd),
root_config: read.call(root))
end
def build_client
require "planka_api"
client = Planka::Client.new
client.login
client
end
def main
root = find_project_root(Dir.pwd)
return unless root
board_name = resolve_board_name(root)
return unless board_name
client = build_client
snapshot = Backlog::Cards.new(client: client).snapshot(board_name: board_name)
backlog_list = snapshot["lists"].find { |l| l["name"] == "Backlog" }
return unless backlog_list
labels = backlog_list["cards"].map { |c| c["labels"] }
note = Backlog::TriageCheck.note_for(board_name, labels)
return unless note
puts JSON.generate(
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: note
}
)
end
begin
main
rescue Exception => e # rubocop:disable Lint/RescueException -- a hook must never block a session
warn "os-backlog triage_check: #{e.class}: #{e.message}" if ENV["CC_OS_DEBUG"]
end

View File

@ -1,11 +1,7 @@
require_relative "backlog/board_spec"
require_relative "backlog/board_ensurer"
require_relative "backlog/board_resolver"
require_relative "backlog/cards"
require_relative "backlog/config" require_relative "backlog/config"
require_relative "backlog/inspector" require_relative "backlog/inspector"
require_relative "backlog/issues"
require_relative "backlog/project_index" require_relative "backlog/project_index"
require_relative "backlog/resolver" require_relative "backlog/resolver"
require_relative "backlog/tracker" require_relative "backlog/tracker"
require_relative "backlog/triage_check"
require_relative "backlog/wakeup" require_relative "backlog/wakeup"

View File

@ -1,175 +0,0 @@
require_relative "board_spec"
module Backlog
# Idempotently creates/repairs a uniform Planka board: the project it
# lives under, its lists, and its label set — plus the Planka 2.1.1
# quirks the planka-api gem does not handle for us:
#
# 1. undocumented `type` fields on create (project: "shared" so a
# manager can be added later; list: "active"; the gem already
# requires callers to pass these — it does not default them)
# 2. bot-created projects are invisible to the human: a `"private"`
# project auto-assigns its creator as sole ownerProjectManagerId and
# rejects additional managers (422). We create projects as
# `type: "shared"` instead, then explicitly add the human as a
# project manager and null out ownerProjectManagerId.
# 3. label colors must come from Planka's (undocumented) color-name
# whitelist — we try an ordered candidate list per label, same
# pattern the planka-api gem uses for its own priority labels.
#
# This class never archives a board — archiving only happens on explicit
# human request (see BoardSpec / the resolver). It does provide the raw
# rename primitives (+activate+ / +archive+) that a caller may invoke.
#
# project_name accepts ANY Planka project name, not just "Dev"/"Clients"
# — #ensure creates the project on demand (find_or_create_project) if it
# doesn't exist yet. There is deliberately no move-a-board-between-
# projects command: Planka's API doesn't expose one cleanly and the need
# is rare enough that it isn't worth building — do it by hand in the
# Planka UI (drag the board card into the target project) instead.
class BoardEnsurer
Result = Struct.new(:board, :created, :project, :lists_created, :labels_created, keyword_init: true)
# Raised instead of silently creating a bot-only-visible Planka project.
class MissingHumanUserIdError < StandardError; end
# @param client [Planka::Client] an already-authenticated client
# @param human_user_id [String, Integer, nil] Planka user id of the
# human who must be able to see bot-created projects. Falls back to
# +ENV["PLANKA_HUMAN_USER_ID"]+. Only needed when a project doesn't
# already exist.
def initialize(client:, human_user_id: nil)
@client = client
@human_user_id = human_user_id || ENV["PLANKA_HUMAN_USER_ID"]
end
# @param name [String] board name (never an archived---prefixed name;
# this method only ever targets the active board)
# @param project_name [String] any Planka project name — created on demand
# if it doesn't already exist
# @return [Result]
def ensure(name, project_name:)
raise ArgumentError, "board name must not use the archived-- prefix" if BoardSpec.archived?(name)
project = find_or_create_project(project_name)
board, created = find_or_create_board(project, name)
lists_created = ensure_lists(board)
labels_created = ensure_labels(board)
Result.new(board: board, created: created, project: project,
lists_created: lists_created, labels_created: labels_created)
end
# Rename an archived board back to its active name. Raises if no
# archived board by that name exists.
#
# @param name [String] the active (non-prefixed) name to activate
# @return [Types::Board] the updated board
def activate(name)
board = find_board_by_name(BoardSpec.archived_name(name))
raise "no archived board named #{BoardSpec.archived_name(name).inspect} found" unless board
@client.boards.update(board.id, name: name)
end
# Rename an active board to its archived form. Only ever called on
# explicit human request — never invoked by +ensure+.
#
# @param name [String] the active (non-prefixed) name to archive
# @return [Types::Board] the updated board
def archive(name)
board = find_board_by_name(name)
raise "no active board named #{name.inspect} found" unless board
@client.boards.update(board.id, name: BoardSpec.archived_name(name))
end
private
def find_or_create_project(project_name)
existing = @client.projects.list.find { |p| p.name == project_name }
return existing if existing
project = @client.projects.create(name: project_name, type: "shared")
fix_visibility(project)
project
end
# Quirk 2: null the bot's sole ownerProjectManagerId and add the human
# as a project manager, so the project shows up for them. Only reached
# right after find_or_create_project creates a brand-new project — an
# already-existing project never calls this and so never requires
# human_user_id.
def fix_visibility(project)
unless @human_user_id
# The Planka API has no atomic create-with-manager call, so the
# project already exists (bot-only-visible) by the time we can
# detect the missing id — we deliberately don't try to roll it
# back/delete it here; raise loudly instead and let a human fix
# visibility (env var + rerun, or the Planka UI) after the fact.
raise MissingHumanUserIdError,
"cannot make new Planka project #{project.name.inspect} visible to a human: " \
"no human_user_id given and PLANKA_HUMAN_USER_ID is unset — the project was " \
"created bot-only; set the env var and fix visibility manually or via the " \
"Planka UI"
end
@client.projects.add_manager(project.id, @human_user_id)
@client.projects.update(project.id, ownerProjectManagerId: nil)
end
def find_or_create_board(project, name)
board = find_board_by_name(name, project: project)
return [board, false] if board
created = @client.boards.create(project.id, name: name, position: 65_536)
[created, true]
end
def find_board_by_name(name, project: nil)
projects = project ? [project] : @client.projects.list
projects.each do |p|
board = @client.boards.list(p.id).find { |b| b.name == name }
return board if board
end
nil
end
def ensure_lists(board)
existing = @client.lists.list(board.id).filter_map(&:name)
created = []
BoardSpec::LISTS.each_with_index do |list_name, index|
next if existing.include?(list_name)
@client.lists.create(board.id, name: list_name, type: "active", position: (index + 1) * 65_536)
created << list_name
end
created
end
def ensure_labels(board)
existing = @client.labels.list(board.id).filter_map(&:name)
created = []
BoardSpec::LABEL_NAMES.each do |label_name|
next if existing.include?(label_name)
create_label_with_fallback_color(board, label_name)
created << label_name
end
created
end
def create_label_with_fallback_color(board, label_name)
candidates = BoardSpec::LABEL_COLOR_CANDIDATES.fetch(label_name)
last_error = nil
candidates.each do |color|
return @client.labels.create(board.id, name: label_name, color: color, position: 65_536)
rescue Planka::ValidationError, Planka::BadRequestError => e
last_error = e
next
end
raise last_error || RuntimeError, "could not create label #{label_name.inspect}: no candidate color accepted"
end
end
end

View File

@ -1,86 +0,0 @@
require_relative "config"
module Backlog
# Resolves a board name to its Planka board record, caching the resolved
# id as `board_id=<id>` in the repo's `.cc-os/config` (issue #22).
#
# The tracker's board *name* stays the source of truth; the id is only a
# cache. The cached id is honored only when the config's tracker is
# exactly `planka:<board_name>`, and a cached `boards.get(id)` hit counts
# only if the returned board's name still matches — a 404 or a rename
# falls back to the projects+boards name lookup and rewrites the cache.
# Any cache failure (unreadable config, unwritable config, stale id)
# degrades to the name lookup; it never fails the command.
#
# Filesystem access is injected: `read_config` returns the raw
# `.cc-os/config` contents (or nil), `write_config` persists updated
# contents. Cache writes go through Config.set, the comment-preserving
# writer — never Config.merge.
class BoardResolver
# @param client [Planka::Client] an already-authenticated client
# @param read_config [#call] -> String, nil — raw .cc-os/config contents
# @param write_config [#call] (String) -> void — persist updated contents
def initialize(client:, read_config:, write_config:)
@client = client
@read_config = read_config
@write_config = write_config
end
# A resolver with no config access: always name-lookup, never caches.
def self.null(client:)
new(client: client, read_config: -> {}, write_config: ->(_) {})
end
# @param name [String] the board name (source of truth)
# @return [Planka::Types::Board]
# @raise [RuntimeError] if no board with that name exists
def board_for(name)
cached = cached_board(name)
return cached if cached
board = lookup_by_name!(name)
cache_id(name, board.id)
board
end
private
def cached_board(name)
config = safe_config
return nil unless config && config.tracker == "planka:#{name}"
id = config.board_id
return nil unless id
board = @client.boards.get(id).board
board && board.name == name ? board : nil
rescue StandardError
nil
end
def lookup_by_name!(name)
@client.projects.list.each do |project|
board = @client.boards.list(project.id).find { |b| b.name == name }
return board if board
end
raise "no board named #{name.inspect} found (run board-ensure first)"
end
def cache_id(name, id)
contents = @read_config.call
config = Config.new(contents)
return unless config.tracker == "planka:#{name}"
return if config.board_id == id.to_s
@write_config.call(Config.set(contents, "board_id", id))
rescue StandardError
nil # cache write failure never fails the command
end
def safe_config
Config.new(@read_config.call)
rescue StandardError
nil
end
end
end

View File

@ -1,34 +0,0 @@
module Backlog
# The uniform board contract every os-backlog board must have: list order
# and the enforced label set. Values only — no client/network code.
module BoardSpec
# Lists in required left-to-right order.
LISTS = %w[Backlog Next Doing Waiting Review Done].freeze
# Label name => ordered candidate colors, tried in turn until the server
# accepts one (Planka's palette-name whitelist is not documented, so we
# can't assert it in advance — cf. planka-api gem's Priority::COLOR_CANDIDATES
# pattern). The first candidate in each list is the verified-good default.
LABEL_COLOR_CANDIDATES = {
"P0" => %w[berry-red red-burgundy salsa-red],
"P1" => %w[pumpkin-orange orange-peel sunset-orange],
"P2" => %w[egg-yellow sunny-grass bright-moss],
"P3" => %w[steel-grey morning-sky light-cocoa],
"hitl" => %w[tank-green bright-moss lagoon-blue],
"semi" => %w[lagoon-blue summer-sky navy-blue],
"afk-ready" => %w[antique-blue bright-moss slate-blue]
}.freeze
LABEL_NAMES = LABEL_COLOR_CANDIDATES.keys.freeze
ARCHIVE_PREFIX = "archived--"
class << self
def archived_name(name) = "#{ARCHIVE_PREFIX}#{name}"
def archived?(name) = name.to_s.start_with?(ARCHIVE_PREFIX)
def active_name(name) = archived?(name) ? name.sub(ARCHIVE_PREFIX, "") : name
end
end
end

View File

@ -1,190 +0,0 @@
require_relative "board_spec"
require_relative "board_resolver"
module Backlog
# Card-level operations over an authenticated Planka client: create a
# card at Backlog (the only column the AI ever creates into), build a
# read-only board snapshot (lists with their cards, labels, card-label
# assignments) for the list skill and the triage/audit agents, and move
# cards between columns under the deterministic policy guardrails
# enforced by #move (Next is human-curated, hitl cards are human-owned,
# Done is a one-way autonomous door gated on the afk-ready label).
#
# Never deletes anything.
class Cards
# @param client [Planka::Client] an already-authenticated client
# @param board_resolver [BoardResolver, nil] resolves board names to
# board records (with the .cc-os/config board_id cache — issue #22);
# defaults to a cache-less name-only resolver. Cards never touches
# the filesystem itself — all config I/O lives in the resolver.
def initialize(client:, board_resolver: nil)
@client = client
@board_resolver = board_resolver || BoardResolver.null(client: client)
end
# Create a card at the board's Backlog list. Raises if the board or
# its Backlog list doesn't exist (callers run board-ensure first).
#
# @return [Planka::Types::Card] the created card
def add(board_name:, title:, description: nil)
board = find_board!(board_name)
backlog = find_list!(board, "Backlog")
attrs = { name: title, type: "project", position: next_position(board, backlog) }
attrs[:description] = description if description
@client.cards.create(backlog.id, attrs)
end
# Read-only snapshot of a whole board: every list in BoardSpec order
# (plus any extras), each with its cards and their label names.
#
# @return [Hash] { "board" => name, "labels" => [names],
# "lists" => [{ "name", "cards" => [{"id","name","labels",...card fields}] }] }
def snapshot(board_name:)
board = find_board!(board_name)
detail = @client.boards.get(board.id)
labels_by_id = detail.labels.to_h { |l| [l.id, l.name] }
card_labels = detail.card_labels.group_by(&:card_id)
cards_by_list = detail.cards.group_by(&:list_id)
lists = detail.lists
.select(&:name) # skip built-in archive/trash lists (name: nil)
.sort_by { |l| l.position || Float::INFINITY }
.map do |list|
cards = (cards_by_list[list.id] || []).map do |card|
label_names = (card_labels[card.id] || []).filter_map { |cl| labels_by_id[cl.label_id] }
card_hash(card, label_names)
end
{ "name" => list.name, "cards" => cards }
end
{ "board" => board.name, "labels" => labels_by_id.values, "lists" => lists }
end
# Attach a board label (by name) to a card. The only card mutation the
# triage agent is allowed — label changes, never column moves.
#
# @return [Planka::Types::CardLabel] the card-label join record
def attach_label(board_name:, card_id:, label:)
board = find_board!(board_name)
record = @client.labels.list(board.id).find { |l| l.name == label }
raise "board #{board_name.inspect} has no label #{label.inspect} (run board-ensure)" unless record
@client.labels.attach_to_card(card_id, record.id)
end
# Add a comment to a card. The only card mutation the audit agent is
# allowed.
#
# @return [Planka::Types::Comment] the created comment
def comment(card_id:, text:)
@client.comments.create(card_id, text: text)
end
# Move a card to another list on the same board, enforcing the
# deterministic column-ownership policy (this is code, not skill
# prose, precisely so it can't be talked around):
#
# 1. Any move into or out of Next is refused — Next is human-curated.
# 2. Any move of a card labeled hitl is refused — hitl cards are
# human-owned.
# 3. Moving a card out of Done is refused.
# 4. Moving a card to Done is allowed only if it carries the
# afk-ready label (afk-ready work that's shipped+verified goes
# straight to Done; semi work stops at Review for human sign-off).
# 5. Everything else among Backlog/Doing/Waiting/Review is allowed.
#
# Lands the card at the end of the destination list (max existing
# position + 65536, or 65536 if the list is empty).
#
# @param board_name [String]
# @param card_id [String, Integer]
# @param to [String] destination list name (one of BoardSpec::LISTS)
# @return [Planka::Types::Card] the moved card
def move(board_name:, card_id:, to:)
unless BoardSpec::LISTS.include?(to)
raise "unknown list #{to.inspect} (expected one of #{BoardSpec::LISTS.join(', ')})"
end
board = find_board!(board_name)
detail = @client.boards.get(board.id)
card = detail.cards.find { |c| c.id == card_id }
raise "no card ##{card_id} found on board #{board_name.inspect}" unless card
from_name = detail.lists.find { |l| l.id == card.list_id }&.name
to_list = detail.lists.find { |l| l.name == to }
raise "board #{board_name.inspect} has no #{to.inspect} list (run board-ensure)" unless to_list
enforce_move_policy!(from_name: from_name, to_name: to, card: card, detail: detail)
position = next_position(board, to_list)
@client.cards.move(card_id, list_id: to_list.id, position: position)
end
private
def enforce_move_policy!(from_name:, to_name:, card:, detail:)
raise "Next is human-curated" if from_name == "Next" || to_name == "Next"
label_names = detail.card_labels
.select { |cl| cl.card_id == card.id }
.filter_map { |cl| detail.labels.find { |l| l.id == cl.label_id }&.name }
raise "hitl cards are human-owned" if label_names.include?("hitl")
raise "cannot move a card out of Done" if from_name == "Done" && to_name != "Done"
return unless to_name == "Done" && !label_names.include?("afk-ready")
raise "move to Done requires the afk-ready label"
end
# Build the plain-hash card representation the CLI/JSON output emits,
# keeping the output shape byte-compatible with the 0.1.x snapshot
# (string keys, "labels" merged in) even though Types::Card is an
# immutable Data object we can no longer +merge+. Emits every
# Types::Card field — dropping fields here silently breaks consumers
# like board-audit, which reads "listChangedAt".
def card_hash(card, label_names)
{
"id" => card.id,
"createdAt" => card.created_at,
"updatedAt" => card.updated_at,
"type" => card.type,
"position" => card.position,
"name" => card.name,
"description" => card.description,
"dueDate" => card.due_date,
"isDueCompleted" => card.is_due_completed,
"stopwatch" => card.stopwatch,
"commentsTotal" => card.comments_total,
"isClosed" => card.is_closed,
"listChangedAt" => card.list_changed_at,
"boardId" => card.board_id,
"listId" => card.list_id,
"creatorUserId" => card.creator_user_id,
"prevListId" => card.prev_list_id,
"coverAttachmentId" => card.cover_attachment_id,
"isSubscribed" => card.is_subscribed,
"labels" => label_names
}
end
def find_board!(name)
@board_resolver.board_for(name)
end
def find_list!(board, list_name)
list = @client.lists.list(board.id).find { |l| l.name == list_name }
raise "board #{board.name.inspect} has no #{list_name.inspect} list (run board-ensure)" unless list
list
end
def next_position(board, list)
detail = @client.boards.get(board.id)
positions = detail.cards
.select { |c| c.list_id == list.id }
.filter_map(&:position)
(positions.max || 0) + 65_536
end
end
end

View File

@ -25,35 +25,18 @@ module Backlog
end end
end end
# @return [String, nil] the explicit board name override, if configured
def board
@data["board"]
end
# @return [String, nil] the tracker key (e.g. "forgejo:owner/repo"), if configured # @return [String, nil] the tracker key (e.g. "forgejo:owner/repo"), if configured
def tracker def tracker
@data["tracker"] @data["tracker"]
end end
# @return [String, nil] the effective Planka board name: the explicit # @return [String, nil] the cached Planka board id, if present (legacy;
# `board` key when present, else the board named by a # Planka is retired per ADR-0042 — kept only so old .cc-os/config
# `tracker=planka:<board>` key (the form config-write actually # files with this key still parse without error).
# writes). Non-planka trackers contribute nothing.
def planka_board
return board if board
return nil unless tracker&.start_with?("planka:")
tracker.delete_prefix("planka:")
end
# @return [String, nil] the cached Planka board id, if present. Only a
# cache — the tracker's board *name* stays the source of truth
# (issue #22); BoardResolver decides whether to honor it.
def board_id def board_id
@data["board_id"] @data["board_id"]
end end
def configured? = !board.nil?
def tracker_configured? = !tracker.nil? def tracker_configured? = !tracker.nil?
# @return [Hash] a copy of the parsed config data # @return [Hash] a copy of the parsed config data

View File

@ -0,0 +1,219 @@
require "json"
require "time"
require "fileutils"
module Backlog
# Issue-side capture/list helpers (ADR-0042: one tracker holds both state
# and specs, git issues are the single tracker). Dispatches on the
# tracker's kind: `tea` for forgejo:, `gh` for github:, in-repo markdown
# files for repo:<path>.
#
# The git-host CLIs are shelled out to via an injected #capture(*cmd)
# runner — [stdout, success?], mirroring the tail of Open3.capture3 — so
# tests stub the runner object directly instead of hacking PATH
# (ShellRunner below is the only piece that actually shells out). The
# repo: file mode does real filesystem IO directly (same style as
# Inspector), exercised in tests via tmpdir.
class Issues
# Priority labels capture may attach. Deliberately excludes every state
# label (`next`/`waiting`/`review`) — there is no parameter through
# which a state label can reach #create, so it can never apply `next`
# (issue-state-labels spec: next is human-only).
PRIORITY_LABELS = %w[P0 P1 P2 P3].freeze
STATE_LABELS = %w[next waiting review].freeze
CommandFailed = Class.new(StandardError)
# Default runner: actually shells out via Open3. The only non-pure
# piece of this file; production CLI wiring uses it, tests never do.
class ShellRunner
def capture(*cmd)
require "open3"
out, _err, status = Open3.capture3(*cmd)
[out, status.success?]
end
end
# @param runner [#capture] responds to #capture(*cmd) -> [stdout, success?]
# @param repo_root [String] base directory a repo:<path> tracker's path is relative to
# @param clock [#call] returns the current Time; injected for repo: issue dating
def initialize(runner:, repo_root: Dir.pwd, clock: -> { Time.now })
@runner = runner
@repo_root = repo_root
@clock = clock
end
# Create an issue with a title, body, and optional priority label
# (P0-P3). Never applies a state label.
#
# @return [Hash] {number:, url:} for forgejo:/github:, {number:, path:} for repo:
def create(tracker:, title:, body:, priority: nil)
validate_priority!(priority)
case Tracker.kind(tracker)
when :forgejo then create_tea(slug(tracker), title: title, body: body, priority: priority)
when :github then create_gh(slug(tracker), title: title, body: body, priority: priority)
when :repo then create_file(rel_path(tracker), title: title, body: body, priority: priority)
else raise ArgumentError, "cannot create an issue on unroutable tracker #{tracker.inspect}"
end
end
# Open issues, grouped by state label.
#
# @return [Hash{Symbol=>Array<Hash>}] :next / :waiting / :review / :other
# => arrays of {number:, title:, labels:, assignee:}; :next first is
# the caller's job to render, not this method's (it returns a plain
# Hash, insertion-ordered next/waiting/review/other).
def list(tracker:)
issues =
case Tracker.kind(tracker)
when :forgejo then list_tea(slug(tracker))
when :github then list_gh(slug(tracker))
when :repo then list_files(rel_path(tracker))
else raise ArgumentError, "cannot list issues on unroutable tracker #{tracker.inspect}"
end
group_by_state(issues)
end
private
def validate_priority!(priority)
return if priority.nil? || PRIORITY_LABELS.include?(priority)
raise ArgumentError, "invalid priority #{priority.inspect}; expected one of #{PRIORITY_LABELS.join(', ')}"
end
def slug(tracker) = tracker.split(":", 2).last
def rel_path(tracker) = tracker.split(":", 2).last
def group_by_state(issues)
buckets = { next: [], waiting: [], review: [], other: [] }
issues.each do |issue|
labels = Array(issue[:labels])
key = STATE_LABELS.find { |label| labels.include?(label) }&.to_sym || :other
buckets[key] << issue
end
buckets
end
# --- forgejo (tea) ---------------------------------------------------
def create_tea(repo_slug, title:, body:, priority:)
cmd = ["tea", "issue", "create", "--repo", repo_slug, "--title", title, "--description", body]
cmd += ["--labels", priority] if priority
out, ok = @runner.capture(*cmd)
raise CommandFailed, "tea issue create failed: #{out}" unless ok
{ number: extract_number(out), url: extract_url(out) }
end
def list_tea(repo_slug)
cmd = ["tea", "issues", "list", "--repo", repo_slug, "--state", "open",
"--fields", "index,title,labels,assignees", "--output", "json"]
out, ok = @runner.capture(*cmd)
raise CommandFailed, "tea issues list failed: #{out}" unless ok
JSON.parse(out).map do |issue|
assignee = issue["assignees"].to_s.split(",").map(&:strip).reject(&:empty?).first
{ number: issue["index"].to_i, title: issue["title"],
labels: issue["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
assignee: assignee }
end
end
# --- github (gh) ------------------------------------------------------
def create_gh(repo_slug, title:, body:, priority:)
cmd = ["gh", "issue", "create", "--repo", repo_slug, "--title", title, "--body", body]
cmd += ["--label", priority] if priority
out, ok = @runner.capture(*cmd)
raise CommandFailed, "gh issue create failed: #{out}" unless ok
{ number: extract_number(out), url: extract_url(out) }
end
def list_gh(repo_slug)
cmd = ["gh", "issue", "list", "--repo", repo_slug, "--state", "open",
"--json", "number,title,labels,assignees"]
out, ok = @runner.capture(*cmd)
raise CommandFailed, "gh issue list failed: #{out}" unless ok
JSON.parse(out).map do |issue|
{ number: issue["number"].to_i, title: issue["title"],
labels: Array(issue["labels"]).map { |label| label["name"] },
assignee: Array(issue["assignees"]).map { |a| a["login"] }.first }
end
end
def extract_number(text)
match = text[/#(\d+)/, 1] || text[%r{/issues/(\d+)}, 1]
match&.to_i
end
def extract_url(text)
text.to_s[%r{https?://\S+}] || text.to_s.strip
end
# --- repo: (in-repo markdown issue files) ------------------------------
#
# `<path>/NNN-<slug>.md`, front matter + body. No existing in-repo
# convention for individual issue files predates this (Inspector only
# detects the presence of a docs/issues dir or ISSUES.md, not a file
# shape) — this is a new, deliberately simple convention.
def create_file(rel_dir, title:, body:, priority:)
dir = File.join(@repo_root, rel_dir)
FileUtils.mkdir_p(dir)
number = next_file_number(dir)
path = File.join(dir, format("%03d-%s.md", number, slugify(title)))
File.write(path, file_contents(number: number, title: title, body: body, priority: priority))
{ number: number, path: path }
end
def slugify(title)
title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
end
def file_contents(number:, title:, body:, priority:)
front = {
"number" => number.to_s,
"title" => title.to_s,
"labels" => [priority].compact.join(","),
"state" => "open",
"created" => @clock.call.strftime("%Y-%m-%d")
}
lines = ["---"] + front.map { |k, v| "#{k}: #{v}" } + ["---", ""]
"#{lines.join("\n")}\n#{body}\n"
end
def next_file_number(dir)
existing = Dir.glob(File.join(dir, "[0-9][0-9][0-9]-*.md"))
existing.map { |f| File.basename(f)[/\A(\d+)-/, 1].to_i }.max.to_i + 1
end
def list_files(rel_dir)
dir = File.join(@repo_root, rel_dir)
return [] unless Dir.exist?(dir)
Dir.glob(File.join(dir, "*.md")).sort.filter_map { |path| parse_issue_file(path) }
end
def parse_issue_file(path)
contents = File.read(path)
return nil unless contents.start_with?("---\n")
_, front_matter, = contents.split("---\n", 3)
data = {}
front_matter.to_s.each_line do |line|
key, value = line.split(":", 2)
next unless key && value
data[key.strip] = value.strip
end
return nil unless data["state"].to_s.empty? || data["state"] == "open"
{ number: data["number"].to_i, title: data["title"],
labels: data["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
assignee: data["assignee"] }
end
end
end

View File

@ -1,91 +1,44 @@
require_relative "board_spec"
require_relative "config" require_relative "config"
module Backlog module Backlog
# Pure repo -> board routing decision. No filesystem, no network: every # Pure decision over a project's configured tracker. No filesystem, no
# input (repo path, config contents, board inventory) is passed in. # network: every input (repo path, config contents) is passed in.
# #
# Decision, exactly one of: # Post-ADR-0042 (Planka retired), there is no board inventory to resolve
# "use <board>" - an active board to work against # against — a project either has a `tracker` key in `.cc-os/config` or it
# "activate <archived board>" - an archived board matches; rename it back # doesn't. Decision, exactly one of:
# "stop-and-discuss" - no confident match; ask the human # "use <tracker>" - a tracker key is configured; use it
# "stop-and-discuss" - no tracker configured; ask the human
# (see /os-backlog:route)
class Resolver class Resolver
Decision = Struct.new(:action, :board) do Decision = Struct.new(:action, :tracker) do
def to_s def to_s
action == :use || action == :activate ? "#{action} #{board}" : "stop-and-discuss" action == :use ? "use #{tracker}" : "stop-and-discuss"
end end
end end
DEV_ROOT = File.expand_path("~/dev") # @param repo_path [String] absolute path to the repo (kept for callers
CLIENTS_ROOT = File.expand_path("~/clients") # that pass it through; not otherwise used since board heuristics
SERVERS_ROOT = File.expand_path("~/servers") # were retired with Planka)
# Path-based Planka project heuristic, shared by the resolver's own
# ambiguity gate and the `board-ensure` CLI's --project default: "Dev"
# under ~/dev/*, "Clients" under ~/clients/*, "Servers" under
# ~/servers/* (the servers umbrella repo — ADR: infrastructure is a
# distinct domain from Dev), nil (ambiguous) otherwise. Board-ensure
# always accepts an arbitrary --project name too — this heuristic only
# supplies the default when none is given.
#
# @param repo_path [String]
# @return [String, nil]
def self.project_for(repo_path)
path = File.expand_path(repo_path)
return "Dev" if under_root?(path, DEV_ROOT)
return "Clients" if under_root?(path, CLIENTS_ROOT)
return "Servers" if under_root?(path, SERVERS_ROOT)
nil
end
def self.under_root?(path, root)
path == root || path.start_with?("#{root}/")
end
private_class_method :under_root?
# @param repo_path [String] absolute path to the repo
# @param config_contents [String, nil] raw contents of .cc-os/config, or nil/absent # @param config_contents [String, nil] raw contents of .cc-os/config, or nil/absent
# @param boards [Array<String>] board names known to exist on the Planka instance def initialize(repo_path:, config_contents:, **)
# (both active and archived--prefixed names)
def initialize(repo_path:, config_contents:, boards:)
@repo_path = File.expand_path(repo_path) @repo_path = File.expand_path(repo_path)
@config = Config.new(config_contents) @config = Config.new(config_contents)
@boards = boards
end end
# @return [Decision] # @return [Decision]
def resolve def resolve
configured_board = @config.planka_board return use(@config.tracker) if @config.tracker_configured?
return decision_for_name(configured_board) || stop_and_discuss if configured_board
derived_decision || stop_and_discuss stop_and_discuss
end end
# @return [String] one of "use <board>" / "activate <board>" / "stop-and-discuss" # @return [String] "use <tracker>" or "stop-and-discuss"
def resolve_string = resolve.to_s def resolve_string = resolve.to_s
private private
def derived_decision def use(tracker) = Decision.new(:use, tracker)
return nil if project.nil?
decision_for_name(board_name)
end
def decision_for_name(name)
return use(name) if @boards.include?(name)
return activate(name) if @boards.include?(BoardSpec.archived_name(name))
nil
end
def stop_and_discuss = Decision.new(:stop_and_discuss, nil) def stop_and_discuss = Decision.new(:stop_and_discuss, nil)
def use(name) = Decision.new(:use, name)
def activate(name) = Decision.new(:activate, BoardSpec.archived_name(name))
def board_name = File.basename(@repo_path)
def project = self.class.project_for(@repo_path)
end end
end end

View File

@ -5,19 +5,21 @@ module Backlog
class Tracker class Tracker
DEFAULT_FORGEJO_HOST = "forgejo.swansoncloud.com" DEFAULT_FORGEJO_HOST = "forgejo.swansoncloud.com"
# ADR-0042 retired Planka: the tracker grammar is exactly these three
# forms. `planka:` is deliberately absent — it is rejected, not merely
# unmatched (see config-write and os-status's tracker-configured check).
FORMATS = { FORMATS = {
planka: %r{\Aplanka:[\w.-]+\z},
forgejo: %r{\Aforgejo:[\w.-]+/[\w.-]+\z}, forgejo: %r{\Aforgejo:[\w.-]+/[\w.-]+\z},
github: %r{\Agithub:[\w.-]+/[\w.-]+\z}, github: %r{\Agithub:[\w.-]+/[\w.-]+\z},
repo: /\Arepo:.+\z/ repo: /\Arepo:.+\z/
}.freeze }.freeze
# @return [Boolean] true if value matches one of the four valid formats # @return [Boolean] true if value matches one of the three valid formats
def self.valid?(value) def self.valid?(value)
!kind(value).nil? !kind(value).nil?
end end
# @return [Symbol, nil] :planka / :forgejo / :github / :repo, or nil if invalid # @return [Symbol, nil] :forgejo / :github / :repo, or nil if invalid
def self.kind(value) def self.kind(value)
return nil unless value.is_a?(String) return nil unless value.is_a?(String)
@ -26,18 +28,17 @@ module Backlog
end end
# Where /to-issues output (spec slices) should be published, given the # Where /to-issues output (spec slices) should be published, given the
# repo's tracker key. The ADR'd boundary: git issues = specs, Planka = # repo's tracker key. One tracker holds both state and specs (ADR-0042):
# state. So forgejo:/github: keys route spec slices to that git issue # forgejo:/github: keys route spec slices to that git issue tracker;
# tracker. `planka:` / `repo:` / nil deliberately map to # repo:<path> routes to the in-repo issue-file convention; nil or any
# :planka_default — what that MEANS for spec-shaped output is a named # invalid key (including retired `planka:` values) is :unrouted.
# decision gate at wording review (issue #16), not settled here.
# #
# @return [Symbol] :git_issues | :planka_default | :unrouted (invalid key) # @return [Symbol] :git_issues | :repo_files | :unrouted
def self.issues_destination(value) def self.issues_destination(value)
case kind(value) case kind(value)
when :forgejo, :github then :git_issues when :forgejo, :github then :git_issues
when :planka, :repo then :planka_default when :repo then :repo_files
else value.nil? ? :planka_default : :unrouted else :unrouted
end end
end end

View File

@ -1,69 +0,0 @@
module Backlog
# Pure decision for the triage-trigger SessionStart hook (card
# 1818330518584820864): given the label arrays already sitting on a
# board's Backlog-column cards, does the hook need to instruct dispatch
# of the card-triage agent? Network/board-fetch stays in the hook
# script (hooks/triage_check.rb) — this class only classifies +
# composes the note text, so the decision is unit-testable without
# Planka.
#
# "Unlabeled" mirrors card-triage's own definition (agents/card-triage.md):
# a card is raw if it is missing a priority label (P0-P3) OR an
# autonomy label (hitl/semi/afk-ready) — not just totally bare.
class TriageCheck
PRIORITY_LABELS = %w[P0 P1 P2 P3].freeze
AUTONOMY_LABELS = %w[hitl semi afk-ready].freeze
# Which board should this session's triage check inspect? Mirrors the
# CLI's config semantics (the session cwd's own .cc-os/config wins —
# umbrella subdir configs override the repo root's), falling back to
# the root config, then to the path heuristic. Honors both the
# explicit `board` key and `tracker=planka:<board>` (via
# Config#planka_board). Pure: the hook script reads the files and
# passes contents (or nil) in.
#
# @param cwd [String] the session's working directory
# @param root [String] the enclosing git toplevel (== cwd outside umbrellas)
# @param cwd_config [String, nil] contents of <cwd>/.cc-os/config
# @param root_config [String, nil] contents of <root>/.cc-os/config
# @return [String, nil] board name, or nil (don't guess) when nothing
# is configured and the path heuristic doesn't recognize the path
def self.board_name_for(cwd:, root:, cwd_config:, root_config:)
config_home = cwd_config ? cwd : root
config = Config.new(cwd_config || root_config)
return config.planka_board if config.planka_board
return nil unless Resolver.project_for(config_home)
File.basename(config_home)
end
# @param labels [Array<String>] the label names on one card
# @return [Boolean]
def self.needs_triage?(labels)
(labels & PRIORITY_LABELS).empty? || (labels & AUTONOMY_LABELS).empty?
end
# @param backlog_cards_labels [Array<Array<String>>] label arrays,
# one per card currently in the board's Backlog list
# @return [Array<Array<String>>] the subset needing triage
def self.unlabeled(backlog_cards_labels)
backlog_cards_labels.select { |labels| needs_triage?(labels) }
end
# @param board_name [String]
# @param backlog_cards_labels [Array<Array<String>>]
# @return [String, nil] the SessionStart additionalContext note, or
# nil when there is nothing to triage
def self.note_for(board_name, backlog_cards_labels)
count = unlabeled(backlog_cards_labels).size
return nil if count.zero?
card_word = count == 1 ? "card" : "cards"
"[os-backlog] #{count} unlabeled Backlog #{card_word} on board #{board_name} -> " \
"dispatch the card-triage agent now (model: haiku, run in background) to label " \
"them. Labeling is this project's job for its own board (a parent umbrella may " \
"also label its child boards) — never label cards on a board that belongs to a " \
"different project."
end
end
end

View File

@ -1,59 +1,43 @@
--- ---
description: Capture work discovered mid-session as a Backlog card on the current repo's Planka board — resolves the board deterministically from the repo path, creates a missing board transparently via board-ensure, and always lands the card at Backlog. Use unprompted WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will not be done in this session. Invoked by `/os-backlog:capture`. description: Capture work discovered mid-session as an issue on the current repo's configured tracker — creates it with a priority label when known and no state labels. Use unprompted WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will not be done in this session. Invoked by `/os-backlog:capture`.
--- ---
Capture a piece of work as a card on the right Planka board, cheaply, without interrupting the current task. Capture a piece of work as an issue on the current repo's configured tracker, cheaply, without interrupting the current task.
## Column-ownership rules (non-negotiable) ## Rules (non-negotiable)
- **The AI creates cards at Backlog only.** Never create a card in any other list. - **Capture never applies state labels, and never applies `next`.** `next` is human-curated
- **Working a card means moving it**, via `card-move` (see below): Doing when work starts, Waiting plus a blocker comment when blocked, Review when a semi card ships, Done when an afk-ready card ships and is verified. (ADR-0042) — the AI never adds or removes it, under any instruction short of an explicit
- **Next stays human-curated in both directions** — never move a card into or out of Next. direct user request naming the specific issue.
- **Never move a card labeled `hitl`** — hitl cards are human-owned, never self-assigned or moved by the AI. - Apply a priority label (`P0``P3`) only when it's actually known from context; otherwise
- These rules are enforced deterministically by the CLI (`card-move`), not just documented here — a violating move fails with a one-line reason naming the rule. leave priority unset — don't guess one to fill the field.
- Priority/autonomy labeling is not capture's job — leave new cards unlabeled; the card-triage agent batches that later. Triage is initiated by the SessionStart hook of the board's OWNING project (the project the card was filed onto, or a parent umbrella labeling its own/child boards) — never by whichever project happened to file the card. A cross-project filer (see CROSS-PROJECT below) must never label the destination board's cards itself. - Never a TODO comment in code, never only a mention in chat — always a real issue.
## Procedure ## Procedure
All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`. All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
1. **Resolve the board** for the current repo. Gather the inputs and run the pure resolver (no network): 1. Create the issue on the cwd repo's configured tracker:
```bash ```bash
# config: contents of .cc-os/config if present, else null ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog issue-create --title "<short imperative title>" \
# boards: names from Planka — every project's boards, including archived--* ones --body "<context: what surfaced, why it's deferred, repo/file paths>" \
echo '{"repo_path": "<repo root>", "config": <config-or-null>, "boards": [...]}' \ [--priority P0|P1|P2|P3]
| ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog resolve
``` ```
To build the `boards` inventory, list boards via `snapshot`/the Planka client; if Planka is unreachable, stop here and report the error — do not guess. This creates the issue via `tea` (forgejo), `gh` (github), or a `NNN-<slug>.md`
2. **Act on the decision:** frontmatter file (`repo:`) — whichever the project's tracker key names.
- `use <board>` → proceed to step 3. `issue-create` is structurally incapable of applying state labels; there is no flag for
- `activate <archived board>` → run `os-backlog activate <name>` (activation is automatic — an archived board matching this repo is simply renamed back), then proceed. one, by design.
- `stop-and-discuss` → do NOT create anything. Tell the user the repo doesn't map to a board and ask where the card should go. 2. Confirm to the user in one line: tracker, title, priority if set. Then return to the
3. **Ensure the board exists** (idempotent — safe to run every time): interrupted task.
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog board-ensure <board>
```
Omit `--project` and it derives the right Planka project from the repo path: `Dev` under
`~/dev/`, `Clients` under `~/clients/`, `Servers` under `~/servers/`. Pass `--project <name>`
to target (or create) any other project explicitly — board-ensure creates the project too if
it doesn't exist yet. There's no command to move an existing board between projects; that's a
manual step in the Planka UI. Creating a NEW Planka project requires `PLANKA_HUMAN_USER_ID`
(or an explicit human user id) — board-ensure fails loudly rather than silently creating a
bot-only project.
4. **Create the card at Backlog:**
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-add --board <board> --title "<short imperative title>" [--description "<context: repo, file paths, why>"]
```
5. Confirm to the user in one line: board, title. Then return to the interrupted task.
## Moving a card ## No tracker configured
When work on a card starts, blocks, or ships, move it with: If `issue-create` fails because the project has no `tracker` key in `.cc-os/config`,
```bash suggest `/os-backlog:route` once — do not nag or retry — and otherwise degrade gracefully:
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-move --board <board> --card <ID> --to Backlog|Doing|Waiting|Review|Done tell the user what could not be captured and let them decide where it should go for now.
```
The CLI refuses (one-line reason, exit 1) any move into/out of Next, any move of a `hitl` card, any move out of Done, or a move to Done without the `afk-ready` label.
## Failure behavior ## Failure behavior
Fail soft. If the planka-api gem or the Planka instance is unavailable, the CLI prints one clear error and exits nonzero — report that error to the user verbatim and offer to note the task elsewhere. Never retry destructively, never leave partial state unmentioned. Fail soft. If the CLI or the underlying tracker (`tea`/`gh`) is unavailable, it prints one
clear error and exits nonzero — report that error to the user verbatim and offer to note the
task elsewhere. Never retry destructively, never leave partial state unmentioned.

View File

@ -1,34 +1,44 @@
--- ---
description: Show the Planka backlog cards relevant to the current repo, on demand. Pull-only — ONLY use when the user explicitly asks what's on the board / backlog / what's next; never inject board state into a session unasked. Invoked by `/os-backlog:list`. description: Show the open issues relevant to the current repo's configured tracker, on demand. Pull-only — ONLY use when the user explicitly asks what's on the backlog / what's next; never inject issue state into a session unasked. Invoked by `/os-backlog:list`.
--- ---
Show the current repo's board state when — and only when — the user asks for it. Show the current repo's tracker state when — and only when — the user asks for it.
## Pull-only rule (notification policy v2) ## Pull-only rule (notification policy v2)
This skill is strictly pull-based. **Never volunteer board state**: not at session start, not after a capture, not "while you're here". The only trigger is an explicit user request ("what's on the backlog?", "/os-backlog:list", "show the board"). This skill is strictly pull-based. **Never volunteer issue state**: not at session start,
not after a capture, not "while you're here". The only trigger is an explicit user request
## Column-ownership rules (also apply here) ("what's on the backlog?", "/os-backlog:list", "what's next?").
Listing is read-only — this skill never moves cards itself. While discussing the listed cards:
- Working a card means moving it (via `card-move`, see `/os-backlog:capture`): Doing when started, Waiting plus a blocker comment when blocked, Review when a semi card ships, Done when an afk-ready card ships and is verified.
- Next stays human-curated in both directions — never move a card into or out of Next.
- If the user asks you to start work from the list, cards labeled `hitl` are off-limits — never moved by the AI, flag them back to the human instead.
- These rules are enforced deterministically by the CLI (`card-move`), not just documented here.
## Procedure ## Procedure
1. Resolve the board for the current repo the same way capture does (`resolve` — see `/os-backlog:capture`). On `stop-and-discuss`, ask the user which board they mean instead of guessing. 1. List the open issues on the cwd repo's configured tracker:
2. Show the cards:
```bash ```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog cards --board <board> ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog issues
``` ```
For a structured view (filtering, counting), use JSON instead: This returns open issues (number, title, labels, assignee) grouped by state: `next` /
```bash `waiting` / `review` / other open.
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board <board> 2. Relay the listing compactly, `next`-labeled issues first, then `waiting`, then `review`,
``` then the rest. If the user asked a narrower question ("what's in review?"), answer only
3. Relay the listing compactly, in board-column order (Backlog, Next, Doing, Waiting, Review, Done). If the user asked a narrower question ("what's in review?"), answer only that. that.
## While discussing listed issues
Listing is read-only — this skill never changes issue state itself. If the user asks you to
start work from the list:
- `hitl`-labeled issues are off-limits — never picked up by the AI; flag them back to the
human instead.
- Comment on the issue when work starts; if it blocks, add the `waiting` label plus a
comment naming the blocker (see `/os-backlog:capture`'s home hook note for the full
working-state contract).
- Never touch the `next` label — human-curated in both directions, always.
## No tracker configured
If `issues` fails because the project has no `tracker` key in `.cc-os/config`, suggest
`/os-backlog:route` once and stop — there's nothing to list.
## Failure behavior ## Failure behavior
Fail soft: if the planka-api gem or Planka is unreachable, the CLI prints one clear error and exits nonzero — relay it and stop. Never fabricate board state. Fail soft: if the CLI or the underlying tracker (`tea`/`gh`) is unreachable, it prints one
clear error and exits nonzero — relay it and stop. Never fabricate issue state.

View File

@ -1,20 +1,19 @@
--- ---
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`. description: Onboard a project's issue tracker — inspect what tracking already exists, propose a destination (forgejo/github/repo), write it to .cc-os/config, and ensure the ten canonical labels exist. 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 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. Register a project's issue tracker: figure out what already exists, propose the single
destination where its state and specs both live, and — with the human's confirmation —
write it and ensure its labels exist.
## The boundary rule (what you're deciding between) ## One tracker, both jobs
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: Per ADR-0042 there is exactly one tracker per project. It holds both task state (labels)
and durable specs (issue bodies, `/to-tickets` slices, PRDs) — there is no second surface.
- **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:<board>`. Valid tracker key formats (exactly one, written to `.cc-os/config`'s `tracker` key):
- **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:<owner>/<repo>` or `github:<owner>/<repo>`. `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`. `planka:<board>` is
- **WHEN** a project already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to stay there → `repo:<path>` (the escape hatch). rejected — `config-write` and the os-status `tracker-configured` check both reject it with
- **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. a one-line message citing ADR-0042, fail-soft.
- **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:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`.
## Procedure ## Procedure
@ -24,38 +23,77 @@ All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
```bash ```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog inspect ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog inspect
``` ```
This is read-only and safe to run without asking. It reports, as JSON: Read-only, safe without asking. It reports `tracker_configured` (existing key, if any —
- `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). if set, confirm re-routing with the user before continuing; re-routing is itself a
- `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). decision gate), `git_remote` (parsed `git remote -v`, classified `forgejo`/`github`/
- `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). `unknown`, plus open-issue count via `tea`/`gh` if available — 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). and `in_repo_issue_files` (`docs/issues/` and/or `ISSUES.md` if present).
- `in_repo_issue_files``docs/issues/` and/or `ISSUES.md` if present.
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: 2. **Propose a destination (NAMED DECISION GATE).** Pick one:
- **(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:<repo-name>` fits Planka-as-state; a board will be created on first capture, not by this skill." - Git remote is `forgejo` → propose `forgejo:<owner>/<repo>`.
- **(b) Existing git issues that are spec-shaped** (`issue_shape` is `sequential-chain` or `mixed`): recommend the **SPLIT as the default**. Write `planka:<board>` 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. - Git remote is `github` → propose `github:<owner>/<repo>`.
- **(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. - No git-host remote, but `in_repo_issue_files` exist → propose `repo:<path>` naming
**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. that location.
- Neither → ask the user which of the three they want; do not guess.
**Stop here and wait for the user to confirm or override before writing or migrating
anything.**
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: 3. **Migrate existing items if the tracker type is changing (SECOND NAMED DECISION GATE).**
- 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. Only applies when open items currently live somewhere other than the confirmed
- 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. destination (e.g. `ISSUES.md` entries moving to Forgejo, or switching from GitHub to
- **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. Forgejo). Before touching any live project history:
- Tell the user exactly what will move (list the items) and get explicit go-ahead —
separate from the destination confirmation in step 2.
- Once confirmed, migrate mechanically using existing machinery: `tea issues create` /
`gh issue create` for items moving onto a git-host tracker; append to the target file
for items moving into `repo:<path>`. Do not invent bespoke migration scripts.
- Back-link both ways: the old item gets a pointer to its new home, the new item's body
links back to the source. Close/archive the old item once the back-link is in place.
4. **Write the tracker key (autonomous, mechanical, once destination is confirmed).** 4. **Write the tracker key (autonomous, mechanical, once destination is confirmed).**
```bash ```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog config-write <tracker-value> ${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`. This validates the format, rejects anything malformed (including `planka:`, citing
ADR-0042), writes `.cc-os/config`, and upserts the global project index
(`~/.cc-os/projects.json`) so cross-project filing can find this project later.
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. 5. **Ensure the ten canonical labels exist (forgejo/github only, idempotent).** Skip for
`repo:` trackers — they carry no label mechanism. List what's already there, then create
only what's missing:
- Forgejo:
```bash
tea labels list --repo <owner>/<repo> -o json
tea labels create --repo <owner>/<repo> --name <label> --color <hex>
```
- GitHub:
```bash
gh label list --repo <owner>/<repo> --json name
gh label create <label> --repo <owner>/<repo> --color <hex>
```
The ten labels: priority `P0` `P1` `P2` `P3`; autonomy `hitl` `semi` `afk-ready`; state
`next` `waiting` `review`. Pick any consistent color scheme (e.g. red-to-green for
priority) — the color itself isn't contractual, only that all ten exist. Re-running
`/os-backlog:route` on an already-labeled repo creates nothing new.
6. **Close out.** Tell the user the tracker is set, which labels were created (if any), and
that the os-status tracker warning goes silent starting next session.
## Decision gates (non-negotiable) ## Decision gates (non-negotiable)
- **Destination gate (step 2):** never call `config-write` or move anything before the human has confirmed which tracker to use. - **Destination gate (step 2):** never call `config-write` before the human has confirmed
- **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. which tracker to use.
- 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. - **Migration gate (step 3):** even after the destination is confirmed, migrating existing
open items needs its own explicit go-ahead — a destination choice for new work is not
consent to move old work.
- Inspection (step 1), `config-write` (step 4), and label creation (step 5) are mechanical
and run without a gate — they don't change or move anything you haven't already been told
to.
## Failure behavior ## 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. Fail soft. `inspect`'s individual checks (`tea`/`gh`) degrade gracefully and are reported as
unavailable rather than aborting the whole command — relay those as informational unless
the missing signal is the only way to make the destination decision, in which case say
what's missing and ask the human directly. `config-write` fails loudly on an invalid
tracker value — relay the CLI's error verbatim.

View File

@ -21,13 +21,13 @@ project has no row, it hasn't been routed yet — tell the user rather than gues
- `forgejo:<owner>/<repo>``tea issues create --repo <owner>/<repo> --title "..." --description "..."` - `forgejo:<owner>/<repo>``tea issues create --repo <owner>/<repo> --title "..." --description "..."`
- `github:<owner>/<repo>``gh issue create --repo <owner>/<repo> --title "..." --body "..."` - `github:<owner>/<repo>``gh issue create --repo <owner>/<repo> --title "..." --body "..."`
- `planka:<board>` (planka-only targets) — `os-backlog card-add --board <board> --title "..." [--description "..."]` - `repo:<path>` — append an `NNN-<slug>.md` frontmatter file to the target's `<path>`.
Routing inside the target project still follows ITS boundary rule (ADR-0033: Planka is No autonomy or priority label is applied by the filer — that's the receiving project's job,
state, git issues are specs) — a durable spec goes to the git-issue tracker even if the per its own triage. The one tracker holds both state and specs for the target project too
project also has a board. (ADR-0042) — there is no second surface to route within.
## Discoverer block (append verbatim to the issue/card description) ## Discoverer block (append verbatim to the issue description/body)
``` ```
-------- --------

View File

@ -1,169 +0,0 @@
require_relative "test_helper"
class BoardEnsurerTest < Minitest::Test
include BacklogTestHelpers
def test_creates_missing_project_board_lists_and_labels
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
result = ensurer.ensure("llf-schema", project_name: "Dev")
assert result.created
assert_equal "llf-schema", result.board.name
assert_equal Backlog::BoardSpec::LISTS, result.lists_created
assert_equal Backlog::BoardSpec::LABEL_NAMES, result.labels_created
project = client.projects_store.first
assert_equal "Dev", project.name
end
def test_rerun_is_a_noop
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
result = ensurer.ensure("llf-schema", project_name: "Dev")
refute result.created
assert_empty result.lists_created
assert_empty result.labels_created
assert_equal 1, client.boards_store.size
assert_equal Backlog::BoardSpec::LISTS.size, client.lists_store.size
assert_equal Backlog::BoardSpec::LABEL_NAMES.size, client.labels_store.size
end
def test_reuses_existing_project
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("board-a", project_name: "Dev")
ensurer.ensure("board-b", project_name: "Dev")
assert_equal 1, client.projects_store.size
assert_equal 2, client.boards_store.size
end
def test_applies_owner_manager_visibility_fix_on_new_project
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
project = client.projects_store.first
assert_includes client.managers_for(project.id), "human-1"
assert_nil project.owner_project_manager_id
end
def test_semi_label_is_part_of_enforced_set
assert_includes Backlog::BoardSpec::LABEL_NAMES, "semi"
assert_includes Backlog::BoardSpec::LABEL_NAMES, "hitl"
assert_includes Backlog::BoardSpec::LABEL_NAMES, "afk-ready"
end
def test_label_color_falls_back_to_next_candidate_on_rejection
first_choice = Backlog::BoardSpec::LABEL_COLOR_CANDIDATES.fetch("P0").first
client = FakePlankaClient.new(reject_colors: [first_choice])
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
p0 = client.labels_store.find { |l| l.name == "P0" }
refute_equal first_choice, p0.color
end
def test_label_color_falls_back_when_server_rejects_with_400
first_choice = Backlog::BoardSpec::LABEL_COLOR_CANDIDATES.fetch("P3").first
client = FakePlankaClient.new(reject_colors_with_400: [first_choice])
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
p3 = client.labels_store.find { |l| l.name == "P3" }
refute_equal first_choice, p3.color
end
def test_activate_renames_archived_board_back
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
board = client.boards_store.first
client.boards.update(board.id, name: "archived--llf-schema")
result = ensurer.activate("llf-schema")
assert_equal "llf-schema", result.name
end
def test_archive_renames_active_board
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
result = ensurer.archive("llf-schema")
assert_equal "archived--llf-schema", result.name
end
def test_ensure_never_touches_archived_boards
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
ensurer.ensure("llf-schema", project_name: "Dev")
ensurer.archive("llf-schema")
result = ensurer.ensure("llf-schema", project_name: "Dev")
assert result.created
assert_equal 2, client.boards_store.size
archived = client.boards_store.find { |b| b.name == "archived--llf-schema" }
refute_nil archived
end
def test_ensure_creates_an_arbitrary_project_name_on_demand
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
result = ensurer.ensure("ovh-prod", project_name: "Servers")
assert result.created
project = client.projects_store.first
assert_equal "Servers", project.name
assert_equal "Servers", result.project.name
end
def test_raises_missing_human_user_id_error_when_creating_new_project_without_one
original = ENV.delete("PLANKA_HUMAN_USER_ID")
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client)
error = assert_raises(Backlog::BoardEnsurer::MissingHumanUserIdError) do
ensurer.ensure("llf-schema", project_name: "Dev")
end
assert_includes error.message, "PLANKA_HUMAN_USER_ID"
ensure
ENV["PLANKA_HUMAN_USER_ID"] = original
end
def test_ensure_against_existing_project_does_not_raise_without_human_user_id
original = ENV.delete("PLANKA_HUMAN_USER_ID")
client = FakePlankaClient.new
seeding_ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
seeding_ensurer.ensure("board-a", project_name: "Dev")
ensurer = Backlog::BoardEnsurer.new(client: client)
result = ensurer.ensure("board-b", project_name: "Dev")
refute result.project.nil?
ensure
ENV["PLANKA_HUMAN_USER_ID"] = original
end
def test_ensure_rejects_archived_prefixed_name
client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
assert_raises(ArgumentError) { ensurer.ensure("archived--x", project_name: "Dev") }
end
end

View File

@ -1,50 +0,0 @@
require_relative "test_helper"
# Card #1818501985909868001: a mistaken flag-style invocation like
# `os-backlog board-ensure --board apprise-api` must fail loudly with a
# usage error instead of silently creating a Planka board literally named
# "--board". These are CLI-contract tests (shell out to bin/os-backlog)
# because the bug is about what the positional NAME arg parsing accepts
# BEFORE any Planka client/network call is made — no PLANKA_* env vars are
# set, so if validation didn't happen first, these would instead fail on
# network auth with a different message.
class BoardNameValidationCliTest < Minitest::Test
BIN = File.expand_path("../bin/os-backlog", __dir__)
# Strip any live Planka credentials from the child's env: these tests
# assert that flag-like names are rejected BEFORE build_client ever runs,
# so no test here may be allowed to reach the network even accidentally.
def run_cli(*args)
env = { "PLANKA_BASE_URL" => "", "PLANKA_USERNAME" => "", "PLANKA_PASSWORD" => "" }
out = IO.popen(env, [RbConfig.ruby, BIN, *args], err: [:child, :out], &:read)
[out, $?.exitstatus]
end
def test_board_ensure_rejects_flag_like_name
out, status = run_cli("board-ensure", "--board", "apprise-api")
refute_equal 0, status
assert_match(/board name.*must not start with/i, out)
end
def test_activate_rejects_flag_like_name
out, status = run_cli("activate", "--board")
refute_equal 0, status
assert_match(/board name.*must not start with/i, out)
end
def test_archive_rejects_flag_like_name
out, status = run_cli("archive", "--board", "--yes")
refute_equal 0, status
assert_match(/board name.*must not start with/i, out)
end
def test_board_ensure_accepts_normal_name_and_fails_later_on_network
out, status = run_cli("board-ensure", "apprise-api")
refute_equal 0, status
refute_match(/board name.*must not start with/i, out)
end
end

View File

@ -1,135 +0,0 @@
require_relative "test_helper"
class BoardResolverTest < Minitest::Test
include BacklogTestHelpers
def setup
@client = FakePlankaClient.new
ensurer = Backlog::BoardEnsurer.new(client: @client, human_user_id: "human-1")
@board = ensurer.ensure("llf-schema", project_name: "Dev").board
@writes = []
end
def resolver_with(config)
@config = config
Backlog::BoardResolver.new(
client: @client,
read_config: -> { @config },
write_config: ->(contents) { @writes << contents }
)
end
def config_with_cache(id = @board.id)
"# repo config\ntracker=planka:llf-schema\nboard_id=#{id}\nversion=3\n"
end
def test_cache_hit_uses_boards_get_and_skips_name_listing
@client.projects.define_singleton_method(:list) { raise "projects.list must not be called on a cache hit" }
resolver = resolver_with(config_with_cache)
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_empty @writes
end
def test_cache_miss_resolves_by_name_and_writes_board_id_preserving_comments
resolver = resolver_with("# repo config\ntracker=planka:llf-schema\nversion=3\n")
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_equal 1, @writes.size
assert_equal "# repo config\ntracker=planka:llf-schema\nversion=3\nboard_id=#{@board.id}\n",
@writes.last
end
def test_stale_id_404_falls_back_to_name_lookup_and_rewrites_cache
resolver = resolver_with(config_with_cache("no-such-id"))
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_includes @writes.last, "board_id=#{@board.id}\n"
assert_includes @writes.last, "# repo config"
end
def test_renamed_board_name_mismatch_falls_back_and_rewrites_cache
stale = @board
@client.boards.update(stale.id, name: "something-else")
current = @client.boards.create(stale.project_id, name: "llf-schema", position: 2)
resolver = resolver_with(config_with_cache(stale.id))
board = resolver.board_for("llf-schema")
assert_equal current.id, board.id
assert_includes @writes.last, "board_id=#{current.id}\n"
end
def test_non_planka_tracker_ignores_board_id_and_never_writes
resolver = resolver_with("tracker=forgejo:jared/cc-os\nboard_id=bogus-id\n")
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_empty @writes
end
def test_board_id_for_different_planka_board_name_is_ignored
resolver = resolver_with("tracker=planka:other-board\nboard_id=bogus-id\n")
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_empty @writes # tracker doesn't match this board name; no cache write either
end
def test_unreadable_config_degrades_to_name_lookup
resolver = Backlog::BoardResolver.new(
client: @client,
read_config: -> { raise Errno::EACCES, "config" },
write_config: ->(contents) { @writes << contents }
)
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
assert_empty @writes
end
def test_unwritable_config_never_fails_the_command
resolver = Backlog::BoardResolver.new(
client: @client,
read_config: -> { "tracker=planka:llf-schema\n" },
write_config: ->(_) { raise Errno::EACCES, "config" }
)
board = resolver.board_for("llf-schema")
assert_equal @board.id, board.id
end
def test_matching_cached_id_is_not_rewritten
resolver = resolver_with(config_with_cache)
resolver.board_for("llf-schema")
assert_empty @writes
end
def test_unknown_board_still_raises
resolver = resolver_with(nil)
error = assert_raises(RuntimeError) { resolver.board_for("nope") }
assert_match(/no board named "nope"/, error.message)
end
def test_cards_add_through_resolver_writes_cache_once
resolver = resolver_with("tracker=planka:llf-schema\n")
cards = Backlog::Cards.new(client: @client, board_resolver: resolver)
card = cards.add(board_name: "llf-schema", title: "Cache me")
assert_equal "Cache me", card.name
assert_equal 1, @writes.size
assert_includes @writes.last, "board_id=#{@board.id}\n"
end
end

View File

@ -1,223 +0,0 @@
require_relative "test_helper"
class CardsTest < Minitest::Test
include BacklogTestHelpers
def setup
@client = FakePlankaClient.new
@ensurer = Backlog::BoardEnsurer.new(client: @client, human_user_id: "human-1")
@ensurer.ensure("llf-schema", project_name: "Dev")
@cards = Backlog::Cards.new(client: @client)
end
def test_add_creates_card_at_backlog_list
card = @cards.add(board_name: "llf-schema", title: "Fix the flaky import")
backlog = @client.lists_store.find { |l| l.name == "Backlog" }
assert_equal backlog.id, card.list_id
assert_equal "Fix the flaky import", card.name
assert_equal "project", card.type
end
def test_add_positions_new_card_after_existing_backlog_cards
first = @cards.add(board_name: "llf-schema", title: "one")
second = @cards.add(board_name: "llf-schema", title: "two")
assert_operator second.position, :>, first.position
end
def test_add_with_description
card = @cards.add(board_name: "llf-schema", title: "t", description: "details here")
assert_equal "details here", card.description
end
def test_add_raises_for_missing_board
error = assert_raises(RuntimeError) { @cards.add(board_name: "nope", title: "t") }
assert_match(/no board named/, error.message)
end
def test_snapshot_shape_and_list_order
@cards.add(board_name: "llf-schema", title: "captured task")
snapshot = @cards.snapshot(board_name: "llf-schema")
assert_equal "llf-schema", snapshot["board"]
assert_equal Backlog::BoardSpec::LISTS, snapshot["lists"].map { |l| l["name"] }
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
assert_equal ["captured task"], backlog["cards"].map { |c| c["name"] }
assert_equal Backlog::BoardSpec::LABEL_NAMES.sort, snapshot["labels"].sort
end
def test_snapshot_card_emits_complete_key_set
@cards.add(board_name: "llf-schema", title: "captured task")
snapshot = @cards.snapshot(board_name: "llf-schema")
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
card = backlog["cards"].first
expected_keys = %w[
id createdAt updatedAt type position name description dueDate
isDueCompleted stopwatch commentsTotal isClosed listChangedAt
boardId listId creatorUserId prevListId coverAttachmentId
isSubscribed labels
]
assert_equal expected_keys.sort, card.keys.sort
assert card.key?("listChangedAt"), "snapshot card must carry listChangedAt for board-audit"
end
def test_snapshot_includes_card_label_names
card = @cards.add(board_name: "llf-schema", title: "labeled task")
p1 = @client.labels_store.find { |l| l.name == "P1" }
@client.attach_label(card.id, p1.id)
snapshot = @cards.snapshot(board_name: "llf-schema")
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
assert_equal ["P1"], backlog["cards"].first["labels"]
end
def test_snapshot_raises_for_missing_board
assert_raises(RuntimeError) { @cards.snapshot(board_name: "nope") }
end
def test_attach_label_by_name
card = @cards.add(board_name: "llf-schema", title: "raw card")
@cards.attach_label(board_name: "llf-schema", card_id: card.id, label: "afk-ready")
snapshot = @cards.snapshot(board_name: "llf-schema")
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
assert_equal ["afk-ready"], backlog["cards"].first["labels"]
end
def test_attach_label_raises_for_unknown_label
card = @cards.add(board_name: "llf-schema", title: "raw card")
error = assert_raises(RuntimeError) do
@cards.attach_label(board_name: "llf-schema", card_id: card.id, label: "nonexistent")
end
assert_match(/no label/, error.message)
end
def test_comment_adds_text_to_card
card = @cards.add(board_name: "llf-schema", title: "stale card")
comment = @cards.comment(card_id: card.id, text: "audit: in Doing 9 days")
assert_equal card.id, comment.card_id
assert_equal "audit: in Doing 9 days", comment.text
assert_equal 1, @client.comments.store.size
end
# -- move -----------------------------------------------------------
def list_named(name)
@client.lists_store.find { |l| l.name == name }
end
def move_card_to(list_name, card = nil)
card ||= @cards.add(board_name: "llf-schema", title: "moving card")
@client.cards.move(card.id, list_id: list_named(list_name).id, position: 65_536) unless list_name == "Backlog"
card
end
def label!(card, name)
label = @client.labels_store.find { |l| l.name == name }
@client.attach_label(card.id, label.id)
end
def test_move_backlog_to_doing
card = @cards.add(board_name: "llf-schema", title: "task")
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
assert_equal list_named("Doing").id, moved.list_id
end
def test_move_doing_to_review
card = move_card_to("Doing")
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Review")
assert_equal list_named("Review").id, moved.list_id
end
def test_move_afk_ready_doing_to_done
card = move_card_to("Doing")
label!(card, "afk-ready")
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
assert_equal list_named("Done").id, moved.list_id
end
def test_move_positions_card_at_end_of_destination_list
first = move_card_to("Doing")
second = @cards.add(board_name: "llf-schema", title: "second")
@cards.move(board_name: "llf-schema", card_id: first.id, to: "Waiting")
moved_second = @cards.move(board_name: "llf-schema", card_id: second.id, to: "Waiting")
waiting_first = @client.cards.all_for_board(list_named("Waiting").board_id)
.find { |c| c.id == first.id }
assert_operator moved_second.position, :>, waiting_first.position
end
def test_move_into_next_refused
card = @cards.add(board_name: "llf-schema", title: "task")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Next")
end
assert_match(/Next is human-curated/, error.message)
end
def test_move_out_of_next_refused
card = move_card_to("Next")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
end
assert_match(/Next is human-curated/, error.message)
end
def test_move_hitl_card_refused
card = @cards.add(board_name: "llf-schema", title: "task")
label!(card, "hitl")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
end
assert_match(/hitl cards are human-owned/, error.message)
end
def test_move_to_done_without_afk_ready_refused
card = move_card_to("Doing")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
end
assert_match(/afk-ready/, error.message)
end
def test_move_out_of_done_refused
card = move_card_to("Doing")
label!(card, "afk-ready")
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
end
assert_match(/cannot move a card out of Done/, error.message)
end
def test_move_unknown_column_refused
card = @cards.add(board_name: "llf-schema", title: "task")
error = assert_raises(RuntimeError) do
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Someplace")
end
assert_match(/unknown list/, error.message)
end
end

View File

@ -1,37 +1,21 @@
require_relative "test_helper" require_relative "test_helper"
class ConfigTest < Minitest::Test class ConfigTest < Minitest::Test
def test_nil_contents_is_unconfigured def test_nil_contents_has_no_tracker
config = Backlog::Config.new(nil) config = Backlog::Config.new(nil)
refute config.configured? refute config.tracker_configured?
assert_nil config.board assert_nil config.tracker
end end
def test_blank_contents_is_unconfigured def test_reads_legacy_yaml_style_key
config = Backlog::Config.new(" \n") config = Backlog::Config.new("---\ntracker: forgejo:jared/cc-os\n")
refute config.configured? assert config.tracker_configured?
end assert_equal "forgejo:jared/cc-os", config.tracker
def test_reads_board_key
config = Backlog::Config.new("board=llf-schema\n")
assert config.configured?
assert_equal "llf-schema", config.board
end
def test_reads_legacy_yaml_style_board_key
config = Backlog::Config.new("---\nboard: llf-schema\n")
assert config.configured?
assert_equal "llf-schema", config.board
end end
def test_lines_without_separator_are_ignored def test_lines_without_separator_are_ignored
config = Backlog::Config.new("# comment\njunk line\n") config = Backlog::Config.new("# comment\njunk line\n")
refute config.configured? assert_empty config.to_h
end
def test_contents_without_board_key_is_unconfigured
config = Backlog::Config.new("other=value\n")
refute config.configured?
end end
def test_reads_tracker_key def test_reads_tracker_key
@ -41,7 +25,7 @@ class ConfigTest < Minitest::Test
end end
def test_tracker_unconfigured_when_absent def test_tracker_unconfigured_when_absent
config = Backlog::Config.new("board=llf-schema\n") config = Backlog::Config.new("other=value\n")
refute config.tracker_configured? refute config.tracker_configured?
end end
@ -52,11 +36,11 @@ class ConfigTest < Minitest::Test
def test_merge_preserves_other_keys def test_merge_preserves_other_keys
existing = "board=llf-schema\nother=kept\n" existing = "board=llf-schema\nother=kept\n"
updated_contents = Backlog::Config.merge(existing, "tracker", "planka:llf-schema") updated_contents = Backlog::Config.merge(existing, "tracker", "forgejo:jared/cc-os")
updated = Backlog::Config.new(updated_contents) updated = Backlog::Config.new(updated_contents)
assert_equal "llf-schema", updated.board assert_equal "llf-schema", updated.to_h["board"]
assert_equal "planka:llf-schema", updated.tracker assert_equal "forgejo:jared/cc-os", updated.tracker
assert_equal "kept", updated.to_h["other"] assert_equal "kept", updated.to_h["other"]
end end
@ -82,7 +66,7 @@ class ConfigTest < Minitest::Test
def test_merge_normalizes_existing_spaced_lines_on_rewrite def test_merge_normalizes_existing_spaced_lines_on_rewrite
existing = "version = 1\nhub = my-hub\n" existing = "version = 1\nhub = my-hub\n"
updated_contents = Backlog::Config.merge(existing, "tracker", "planka:board") updated_contents = Backlog::Config.merge(existing, "tracker", "forgejo:jared/demo")
updated_contents.each_line do |line| updated_contents.each_line do |line|
next if line.strip.empty? next if line.strip.empty?
@ -91,26 +75,26 @@ class ConfigTest < Minitest::Test
updated = Backlog::Config.new(updated_contents) updated = Backlog::Config.new(updated_contents)
assert_equal "1", updated.to_h["version"] assert_equal "1", updated.to_h["version"]
assert_equal "my-hub", updated.to_h["hub"] assert_equal "my-hub", updated.to_h["hub"]
assert_equal "planka:board", updated.tracker assert_equal "forgejo:jared/demo", updated.tracker
end end
def test_board_id_reader def test_board_id_reader
config = Backlog::Config.new("tracker=planka:board\nboard_id=42\n") config = Backlog::Config.new("tracker=forgejo:jared/demo\nboard_id=42\n")
assert_equal "42", config.board_id assert_equal "42", config.board_id
assert_nil Backlog::Config.new(nil).board_id assert_nil Backlog::Config.new(nil).board_id
end end
def test_set_preserves_comments_and_blank_lines def test_set_preserves_comments_and_blank_lines
existing = "# managed by cc-os\ntracker=planka:board\n\n# stamped by os-status\nversion=3\n" existing = "# managed by cc-os\ntracker=forgejo:jared/demo\n\n# stamped by os-status\nversion=3\n"
updated = Backlog::Config.set(existing, "board_id", "42") updated = Backlog::Config.set(existing, "board_id", "42")
assert_equal "# managed by cc-os\ntracker=planka:board\n\n# stamped by os-status\nversion=3\nboard_id=42\n", assert_equal "# managed by cc-os\ntracker=forgejo:jared/demo\n\n# stamped by os-status\nversion=3\nboard_id=42\n",
updated updated
end end
def test_set_replaces_existing_key_in_place def test_set_replaces_existing_key_in_place
existing = "# top comment\nboard_id=old\ntracker=planka:board\n" existing = "# top comment\nboard_id=old\ntracker=forgejo:jared/demo\n"
updated = Backlog::Config.set(existing, "board_id", "new") updated = Backlog::Config.set(existing, "board_id", "new")
assert_equal "# top comment\nboard_id=new\ntracker=planka:board\n", updated assert_equal "# top comment\nboard_id=new\ntracker=forgejo:jared/demo\n", updated
end end
def test_set_normalizes_spaced_key_value_lines_only def test_set_normalizes_spaced_key_value_lines_only
@ -122,19 +106,4 @@ class ConfigTest < Minitest::Test
def test_set_on_nil_contents def test_set_on_nil_contents
assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42") assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42")
end end
def test_planka_board_prefers_explicit_board_key
config = Backlog::Config.new("board=explicit\ntracker=planka:from-tracker\n")
assert_equal "explicit", config.planka_board
end
def test_planka_board_derives_from_planka_tracker
config = Backlog::Config.new("tracker=planka:my-board\n")
assert_equal "my-board", config.planka_board
end
def test_planka_board_nil_for_non_planka_tracker
assert_nil Backlog::Config.new("tracker=forgejo:me/repo\n").planka_board
assert_nil Backlog::Config.new(nil).planka_board
end
end end

View File

@ -1,5 +1,6 @@
# /to-issues routing wiring (issue #16): spec-slice output honors the # /to-issues routing wiring (issue #16): spec-slice output honors the
# tracker key per the ADR'd boundary (git issues = specs, Planka = state). # tracker key. Post-ADR-0042, one tracker holds both state and specs — no
# second "planka default" destination exists anymore.
require_relative "test_helper" require_relative "test_helper"
class IssuesRoutingTest < Minitest::Test class IssuesRoutingTest < Minitest::Test
@ -11,16 +12,16 @@ class IssuesRoutingTest < Minitest::Test
assert_equal :git_issues, Backlog::Tracker.issues_destination("github:jared/some-repo") assert_equal :git_issues, Backlog::Tracker.issues_destination("github:jared/some-repo")
end end
def test_planka_tracker_is_the_gated_default def test_repo_tracker_routes_spec_slices_to_repo_files
assert_equal :planka_default, Backlog::Tracker.issues_destination("planka:cc-os") assert_equal :repo_files, Backlog::Tracker.issues_destination("repo:docs/issues")
end end
def test_repo_tracker_is_the_gated_default def test_absent_tracker_is_unrouted
assert_equal :planka_default, Backlog::Tracker.issues_destination("repo:docs/issues") assert_equal :unrouted, Backlog::Tracker.issues_destination(nil)
end end
def test_absent_tracker_is_the_gated_default def test_retired_planka_tracker_is_unrouted
assert_equal :planka_default, Backlog::Tracker.issues_destination(nil) assert_equal :unrouted, Backlog::Tracker.issues_destination("planka:cc-os")
end end
def test_invalid_tracker_is_unrouted def test_invalid_tracker_is_unrouted

View File

@ -0,0 +1,181 @@
require_relative "test_helper"
require "tmpdir"
class FakeIssuesRunner
attr_reader :calls
def initialize(responses)
@responses = responses.dup
@calls = []
end
def capture(*cmd)
@calls << cmd
@responses.shift || ["", true]
end
end
class IssuesCreateTest < Minitest::Test
def test_forgejo_create_shells_out_to_tea_with_priority_label
runner = FakeIssuesRunner.new([["Created issue #7: https://forgejo.example/jared/cc-os/issues/7\n", true]])
issues = Backlog::Issues.new(runner: runner)
result = issues.create(tracker: "forgejo:jared/cc-os", title: "Fix the thing", body: "context here", priority: "P1")
assert_equal 7, result[:number]
assert_equal "https://forgejo.example/jared/cc-os/issues/7", result[:url]
cmd = runner.calls.first
assert_equal "tea", cmd[0]
assert_includes cmd, "--repo"
assert_includes cmd, "jared/cc-os"
assert_includes cmd, "--title"
assert_includes cmd, "Fix the thing"
assert_includes cmd, "--labels"
assert_includes cmd, "P1"
end
def test_forgejo_create_omits_labels_flag_without_priority
runner = FakeIssuesRunner.new([["Created issue #1: https://x/issues/1\n", true]])
Backlog::Issues.new(runner: runner).create(tracker: "forgejo:jared/cc-os", title: "T", body: "B")
refute_includes runner.calls.first, "--labels"
end
def test_github_create_shells_out_to_gh
runner = FakeIssuesRunner.new([["https://github.com/acme/widget/issues/42\n", true]])
issues = Backlog::Issues.new(runner: runner)
result = issues.create(tracker: "github:acme/widget", title: "Fix", body: "ctx", priority: "P0")
assert_equal 42, result[:number]
assert_equal "https://github.com/acme/widget/issues/42", result[:url]
cmd = runner.calls.first
assert_equal "gh", cmd[0]
assert_includes cmd, "--label"
assert_includes cmd, "P0"
end
def test_create_raises_command_failed_on_nonzero_exit
runner = FakeIssuesRunner.new([["auth error", false]])
issues = Backlog::Issues.new(runner: runner)
assert_raises(Backlog::Issues::CommandFailed) do
issues.create(tracker: "forgejo:jared/cc-os", title: "T", body: "B")
end
end
def test_create_rejects_invalid_priority
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]))
assert_raises(ArgumentError) do
issues.create(tracker: "forgejo:jared/cc-os", title: "T", body: "B", priority: "P9")
end
end
def test_create_can_never_apply_the_next_label
# There is no `labels:`/state-label parameter on #create at all — the
# only label surface is `priority:`, restricted to P0-P3. This test
# documents that constraint as executable spec.
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]))
assert_raises(ArgumentError) do
issues.create(tracker: "forgejo:jared/cc-os", title: "T", body: "B", priority: "next")
end
end
def test_repo_create_writes_a_frontmattered_markdown_file
Dir.mktmpdir do |dir|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir,
clock: -> { Time.new(2026, 7, 16) })
result = issues.create(tracker: "repo:docs/issues", title: "Fix the Thing!", body: "details here",
priority: "P2")
assert_equal 1, result[:number]
assert_equal File.join(dir, "docs/issues/001-fix-the-thing.md"), result[:path]
contents = File.read(result[:path])
assert_includes contents, "number: 1"
assert_includes contents, "title: Fix the Thing!"
assert_includes contents, "labels: P2"
assert_includes contents, "state: open"
assert_includes contents, "created: 2026-07-16"
assert_includes contents, "details here"
end
end
def test_repo_create_numbers_sequentially
Dir.mktmpdir do |dir|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir)
issues.create(tracker: "repo:docs/issues", title: "First", body: "b")
second = issues.create(tracker: "repo:docs/issues", title: "Second", body: "b")
assert_equal 2, second[:number]
end
end
end
class IssuesListTest < Minitest::Test
def test_forgejo_list_groups_by_state_label
tea_json = JSON.generate([
{ "index" => 1, "title" => "Next thing", "labels" => "P1,next", "assignees" => "jared" },
{ "index" => 2, "title" => "Blocked thing", "labels" => "waiting", "assignees" => "" },
{ "index" => 3, "title" => "Shipped semi", "labels" => "review", "assignees" => "" },
{ "index" => 4, "title" => "Ad hoc", "labels" => "P3", "assignees" => "" }
])
runner = FakeIssuesRunner.new([[tea_json, true]])
grouped = Backlog::Issues.new(runner: runner).list(tracker: "forgejo:jared/cc-os")
assert_equal [1], grouped[:next].map { |i| i[:number] }
assert_equal [2], grouped[:waiting].map { |i| i[:number] }
assert_equal [3], grouped[:review].map { |i| i[:number] }
assert_equal [4], grouped[:other].map { |i| i[:number] }
assert_equal "jared", grouped[:next].first[:assignee]
cmd = runner.calls.first
assert_equal "tea", cmd[0]
end
def test_github_list_groups_by_state_label
gh_json = JSON.generate([
{ "number" => 5, "title" => "Ship it", "labels" => [{ "name" => "review" }],
"assignees" => [{ "login" => "jared" }] }
])
runner = FakeIssuesRunner.new([[gh_json, true]])
grouped = Backlog::Issues.new(runner: runner).list(tracker: "github:acme/widget")
assert_equal [5], grouped[:review].map { |i| i[:number] }
assert_equal "jared", grouped[:review].first[:assignee]
assert_equal "gh", runner.calls.first[0]
end
def test_list_raises_command_failed_on_nonzero_exit
runner = FakeIssuesRunner.new([["boom", false]])
assert_raises(Backlog::Issues::CommandFailed) do
Backlog::Issues.new(runner: runner).list(tracker: "forgejo:jared/cc-os")
end
end
def test_repo_list_reads_open_issue_files_and_groups_them
Dir.mktmpdir do |dir|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir)
issues.create(tracker: "repo:docs/issues", title: "Waiting one", body: "b", priority: "P1")
waiting_path = Dir.glob(File.join(dir, "docs/issues/*.md")).first
File.write(waiting_path, File.read(waiting_path).sub("labels: P1", "labels: P1,waiting"))
grouped = issues.list(tracker: "repo:docs/issues")
assert_equal ["Waiting one"], grouped[:waiting].map { |i| i[:title] }
end
end
def test_repo_list_is_empty_when_directory_absent
Dir.mktmpdir do |dir|
grouped = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir)
.list(tracker: "repo:docs/issues")
assert_equal [], grouped[:other]
end
end
def test_unroutable_tracker_raises
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]))
assert_raises(ArgumentError) { issues.list(tracker: "planka:board") }
assert_raises(ArgumentError) { issues.create(tracker: nil, title: "T", body: "B") }
end
end

View File

@ -92,18 +92,18 @@ class ProjectsCliContractTest < Minitest::Test
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` } out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
assert_equal({ "projects" => {} }, JSON.parse(out)) assert_equal({ "projects" => {} }, JSON.parse(out))
Dir.chdir(repo) { `#{env} ruby #{BIN} config-write planka:demo 2>/dev/null` } Dir.chdir(repo) { `#{env} ruby #{BIN} config-write forgejo:jared/demo 2>/dev/null` }
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` } out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
projects = JSON.parse(out)["projects"] projects = JSON.parse(out)["projects"]
assert_equal 1, projects.size assert_equal 1, projects.size
row = projects.values.first row = projects.values.first
assert_equal "planka:demo", row["tracker"] assert_equal "forgejo:jared/demo", row["tracker"]
assert_equal File.basename(File.realpath(repo)), row["name"] assert_equal File.basename(File.realpath(repo)), row["name"]
filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` } filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` }
assert_equal({}, JSON.parse(filtered)["projects"]) assert_equal({}, JSON.parse(filtered)["projects"])
by_tracker = Dir.chdir(repo) { `#{env} ruby #{BIN} projects planka:demo 2>/dev/null` } by_tracker = Dir.chdir(repo) { `#{env} ruby #{BIN} projects forgejo:jared/demo 2>/dev/null` }
assert_equal 1, JSON.parse(by_tracker)["projects"].size assert_equal 1, JSON.parse(by_tracker)["projects"].size
end end
end end
@ -121,7 +121,7 @@ class ProjectsCliContractTest < Minitest::Test
FileUtils.mkdir_p(subdir) FileUtils.mkdir_p(subdir)
env = "CC_OS_HOME=#{home} HOME=#{home}" env = "CC_OS_HOME=#{home} HOME=#{home}"
Dir.chdir(subdir) { `#{env} ruby #{BIN} config-write planka:child 2>/dev/null` } Dir.chdir(subdir) { `#{env} ruby #{BIN} config-write forgejo:jared/child 2>/dev/null` }
projects = Dir.chdir(subdir) { JSON.parse(`#{env} ruby #{BIN} projects 2>/dev/null`)["projects"] } projects = Dir.chdir(subdir) { JSON.parse(`#{env} ruby #{BIN} projects 2>/dev/null`)["projects"] }
assert_equal [File.realpath(subdir)], projects.keys assert_equal [File.realpath(subdir)], projects.keys

View File

@ -1,121 +1,40 @@
require_relative "test_helper" require_relative "test_helper"
class ResolverTest < Minitest::Test class ResolverTest < Minitest::Test
def test_configured_project_uses_configured_board def test_configured_tracker_is_used
resolver = Backlog::Resolver.new( resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo", repo_path: "/home/jared/dev/some-repo",
config_contents: "board: llf-schema\n", config_contents: "tracker=forgejo:jared/some-repo\n"
boards: %w[llf-schema other-board]
) )
assert_equal "use llf-schema", resolver.resolve_string assert_equal "use forgejo:jared/some-repo", resolver.resolve_string
end end
def test_unconfigured_dev_repo_uses_board_named_after_repo def test_unconfigured_project_stops_and_discusses
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/llf-schema",
config_contents: nil,
boards: %w[llf-schema]
)
assert_equal "use llf-schema", resolver.resolve_string
end
def test_unconfigured_client_repo_uses_board_named_after_repo
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/clients/acme-co",
config_contents: nil,
boards: %w[acme-co]
)
assert_equal "use acme-co", resolver.resolve_string
end
def test_matching_archived_board_activates
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/old-project",
config_contents: nil,
boards: %w[archived--old-project]
)
assert_equal "activate archived--old-project", resolver.resolve_string
end
def test_configured_archived_board_activates
resolver = Backlog::Resolver.new( resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo", repo_path: "/home/jared/dev/some-repo",
config_contents: "board: old-project\n", config_contents: nil
boards: %w[archived--old-project]
) )
assert_equal "activate archived--old-project", resolver.resolve_string assert_equal "stop-and-discuss", resolver.resolve_string
end end
def test_no_match_stops_and_discusses def test_blank_config_contents_stops_and_discusses
resolver = Backlog::Resolver.new( resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/brand-new-repo", repo_path: "/home/jared/dev/some-repo",
config_contents: nil, config_contents: ""
)
assert_equal "stop-and-discuss", resolver.resolve_string
end
def test_tolerates_legacy_boards_keyword_for_backward_compatibility
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo",
config_contents: "tracker=github:jared/some-repo\n",
boards: %w[unrelated-board] boards: %w[unrelated-board]
) )
assert_equal "stop-and-discuss", resolver.resolve_string assert_equal "use github:jared/some-repo", resolver.resolve_string
end
def test_path_outside_dev_and_clients_is_ambiguous
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/scratch/some-repo",
config_contents: nil,
boards: %w[some-repo]
)
assert_equal "stop-and-discuss", resolver.resolve_string
end
def test_configured_board_with_no_match_stops_and_discusses
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/some-repo",
config_contents: "board: nonexistent\n",
boards: %w[llf-schema]
)
assert_equal "stop-and-discuss", resolver.resolve_string
end
def test_empty_config_contents_is_treated_as_unconfigured
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/dev/llf-schema",
config_contents: "",
boards: %w[llf-schema]
)
assert_equal "use llf-schema", resolver.resolve_string
end
def test_unconfigured_servers_repo_uses_board_named_after_repo
resolver = Backlog::Resolver.new(
repo_path: "/home/jared/servers/proxmox-ubuntu",
config_contents: nil,
boards: %w[proxmox-ubuntu]
)
assert_equal "use proxmox-ubuntu", resolver.resolve_string
end
def test_planka_tracker_key_resolves_to_its_board
resolver = Backlog::Resolver.new(
repo_path: "/somewhere/unrecognized/repo",
config_contents: "tracker=planka:my-board\n",
boards: %w[my-board]
)
assert_equal "use my-board", resolver.resolve_string
end
def test_project_for_returns_dev_clients_servers_or_nil
assert_equal "Dev", Backlog::Resolver.project_for("/home/jared/dev/some-repo")
assert_equal "Clients", Backlog::Resolver.project_for("/home/jared/clients/acme-co")
assert_equal "Servers", Backlog::Resolver.project_for("/home/jared/servers/ovh-prod")
assert_equal "Servers", Backlog::Resolver.project_for("/home/jared/servers")
assert_nil Backlog::Resolver.project_for("/home/jared/scratch/some-repo")
end end
end end

View File

@ -1,234 +1,2 @@
require "minitest/autorun" require "minitest/autorun"
require "planka_api"
require_relative "../lib/backlog" require_relative "../lib/backlog"
module BacklogTestHelpers
# A hand-rolled fake of the subset of Planka::Client's resource surface
# BoardEnsurer/Cards touches: projects, boards, lists, labels, cards,
# comments. In-memory only, no network. Returns the real planka-api 0.2.0
# typed records (Planka::Types::*) so these tests exercise the same
# accessor shapes production code does.
class FakePlankaClient
attr_reader :projects, :boards, :lists, :labels, :cards, :comments
def initialize(reject_colors: [], reject_colors_with_400: [])
@next_id = 0
@projects_store = []
@boards_store = []
@lists_store = []
@labels_store = []
@cards_store = []
@card_labels_store = []
@comments_store = []
@projects = ProjectsResource.new(self)
@boards = BoardsResource.new(self)
@lists = ListsResource.new(self)
@labels = LabelsResource.new(self, reject_colors: reject_colors,
reject_colors_with_400: reject_colors_with_400)
@cards = CardsResource.new(self)
@comments = CommentsResource.new(self)
end
def next_id = (@next_id += 1).to_s
attr_reader :projects_store, :boards_store, :lists_store, :labels_store,
:cards_store, :card_labels_store, :comments_store
# Project managers aren't part of Types::Project (they're their own
# join record, Types::ProjectManager) — tracked here so tests can
# assert on the visibility-fix quirk (BoardEnsurer#fix_visibility).
def managers_for(project_id)
@managers_store ||= {}
@managers_store[project_id] ||= []
end
def attach_label(card_id, label_id)
record = Planka::Types::CardLabel.new(
id: next_id, created_at: nil, updated_at: nil, card_id: card_id, label_id: label_id
)
@card_labels_store << record
record
end
class ProjectsResource
def initialize(client) = @client = client
def list = @client.projects_store.dup
def create(attrs)
project = Planka::Types::Project.new(
id: @client.next_id, created_at: nil, updated_at: nil, name: attrs[:name],
description: nil, background_type: nil, background_gradient: nil, is_hidden: nil,
owner_project_manager_id: @client.next_id, background_image_id: nil, is_favorite: nil
)
@client.projects_store << project
project
end
def update(id, attrs)
project = @client.projects_store.find { |p| p.id == id }
idx = @client.projects_store.index(project)
updated = project
updated = updated.with(owner_project_manager_id: attrs[:ownerProjectManagerId]) if attrs.key?(:ownerProjectManagerId)
@client.projects_store[idx] = updated
updated
end
def add_manager(project_id, user_id)
@client.managers_for(project_id) << user_id
Planka::Types::ProjectManager.new(id: @client.next_id, created_at: nil, updated_at: nil,
project_id: project_id, user_id: user_id)
end
end
class BoardsResource
def initialize(client) = @client = client
def list(project_id)
@client.boards_store.select { |b| b.project_id == project_id }
end
def create(project_id, attrs)
board = Planka::Types::Board.new(
id: @client.next_id, created_at: nil, updated_at: nil, position: attrs[:position],
name: attrs[:name], default_view: nil, default_card_type: nil,
limit_card_types_to_default_one: nil, always_display_card_creator: nil,
display_card_ages: nil, expand_task_lists_by_default: nil,
project_id: project_id, is_subscribed: nil
)
@client.boards_store << board
board
end
def update(id, attrs)
board = @client.boards_store.find { |b| b.id == id }
idx = @client.boards_store.index(board)
updated = board
updated = updated.with(name: attrs[:name]) if attrs.key?(:name)
@client.boards_store[idx] = updated
updated
end
def get(id)
board = @client.boards_store.find { |b| b.id == id }
unless board
raise Planka::NotFoundError.new(status: 404,
body: { "message" => "board #{id} not found" })
end
Planka::Types::BoardDetail.new(
board: board,
lists: @client.lists.list(id),
labels: @client.labels.list(id),
cards: @client.cards.all_for_board(id),
card_labels: @client.card_labels_store.dup,
board_memberships: []
)
end
end
class ListsResource
def initialize(client) = @client = client
def list(board_id)
@client.lists_store.select { |l| l.board_id == board_id }
end
def create(board_id, attrs)
raise ArgumentError, "list create requires type" unless attrs[:type]
list = Planka::Types::List.new(
id: @client.next_id, created_at: nil, updated_at: nil, type: attrs[:type],
position: attrs[:position], name: attrs[:name], color: nil, board_id: board_id
)
@client.lists_store << list
list
end
end
class CardsResource
def initialize(client) = @client = client
def create(list_id, attrs)
raise ArgumentError, "card create requires type" unless attrs[:type]
list = @client.lists_store.find { |l| l.id == list_id }
card = Planka::Types::Card.new(
id: @client.next_id, created_at: nil, updated_at: nil, type: attrs[:type],
position: attrs[:position], name: attrs[:name], description: attrs[:description],
due_date: nil, is_due_completed: nil, stopwatch: nil, comments_total: nil,
is_closed: nil, list_changed_at: nil, board_id: list.board_id, list_id: list_id,
creator_user_id: nil, prev_list_id: nil, cover_attachment_id: nil, is_subscribed: nil
)
@client.cards_store << card
card
end
def all_for_board(board_id)
@client.cards_store.select { |c| c.board_id == board_id }
end
def move(id, list_id:, position:, board_id: nil)
card = @client.cards_store.find { |c| c.id == id }
idx = @client.cards_store.index(card)
updated = card.with(list_id: list_id, position: position)
updated = updated.with(board_id: board_id) if board_id
@client.cards_store[idx] = updated
updated
end
end
class LabelsResource
def initialize(client, reject_colors: [], reject_colors_with_400: [])
@client = client
@reject_colors = reject_colors
@reject_colors_with_400 = reject_colors_with_400
end
def list(board_id)
@client.labels_store.select { |l| l.board_id == board_id }
end
def create(board_id, attrs)
if @reject_colors_with_400.include?(attrs[:color])
# Planka 2.x rejects unknown color names as E_MISSING_OR_INVALID_PARAMS
raise Planka::BadRequestError.new(status: 400,
body: { "message" => "color #{attrs[:color]} rejected" })
end
if @reject_colors.include?(attrs[:color])
raise Planka::ValidationError.new(status: 422,
body: { "message" => "color #{attrs[:color]} rejected" })
end
label = Planka::Types::Label.new(
id: @client.next_id, created_at: nil, updated_at: nil, position: attrs[:position],
name: attrs[:name], color: attrs[:color], board_id: board_id
)
@client.labels_store << label
label
end
def attach_to_card(card_id, label_id)
@client.attach_label(card_id, label_id)
end
end
class CommentsResource
def initialize(client)
@client = client
end
def store = @client.comments_store
def create(card_id, attrs)
comment = Planka::Types::Comment.new(
id: @client.next_id, created_at: nil, updated_at: nil, text: attrs[:text],
card_id: card_id, user_id: nil
)
@client.comments_store << comment
comment
end
end
end
end

View File

@ -1,9 +1,9 @@
require_relative "test_helper" require_relative "test_helper"
class TrackerTest < Minitest::Test class TrackerTest < Minitest::Test
def test_accepts_planka_format def test_rejects_planka_format
assert Backlog::Tracker.valid?("planka:llf-schema") refute Backlog::Tracker.valid?("planka:llf-schema")
assert_equal :planka, Backlog::Tracker.kind("planka:llf-schema") assert_nil Backlog::Tracker.kind("planka:llf-schema")
end end
def test_accepts_forgejo_format def test_accepts_forgejo_format
@ -32,6 +32,21 @@ class TrackerTest < Minitest::Test
refute Backlog::Tracker.valid?("jira:PROJ-123") refute Backlog::Tracker.valid?("jira:PROJ-123")
end end
def test_issues_destination_routes_git_hosts_to_git_issues
assert_equal :git_issues, Backlog::Tracker.issues_destination("forgejo:jared/cc-os")
assert_equal :git_issues, Backlog::Tracker.issues_destination("github:jared/cc-os")
end
def test_issues_destination_routes_repo_to_repo_files
assert_equal :repo_files, Backlog::Tracker.issues_destination("repo:docs/issues")
end
def test_issues_destination_is_unrouted_for_planka_nil_or_invalid
assert_equal :unrouted, Backlog::Tracker.issues_destination("planka:cc-os")
assert_equal :unrouted, Backlog::Tracker.issues_destination(nil)
assert_equal :unrouted, Backlog::Tracker.issues_destination("jira:whatever")
end
def test_parse_remote_detects_forgejo_ssh_url def test_parse_remote_detects_forgejo_ssh_url
output = "origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (fetch)\n" \ 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" "origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (push)\n"

View File

@ -1,86 +0,0 @@
require_relative "test_helper"
class TriageCheckTest < Minitest::Test
def test_card_missing_both_labels_needs_triage
assert Backlog::TriageCheck.needs_triage?([])
end
def test_card_missing_priority_label_needs_triage
assert Backlog::TriageCheck.needs_triage?(["semi"])
end
def test_card_missing_autonomy_label_needs_triage
assert Backlog::TriageCheck.needs_triage?(["P1"])
end
def test_fully_labeled_card_does_not_need_triage
refute Backlog::TriageCheck.needs_triage?(%w[P1 semi])
end
def test_note_for_no_unlabeled_cards_is_nil
assert_nil Backlog::TriageCheck.note_for("cc-os", [%w[P1 semi], %w[P0 hitl]])
end
def test_note_for_counts_only_unlabeled_cards
labels = [%w[P1 semi], [], ["P2"]]
note = Backlog::TriageCheck.note_for("cc-os", labels)
refute_nil note
assert_includes note, "2 unlabeled Backlog cards on board cc-os"
assert_includes note, "card-triage"
assert_includes note, "haiku"
assert_includes note, "background"
end
def test_note_for_singular_card_uses_singular_wording
note = Backlog::TriageCheck.note_for("cc-os", [[]])
assert_includes note, "1 unlabeled Backlog card on board cc-os"
refute_includes note, "1 unlabeled Backlog cards"
end
def test_board_name_for_subdir_config_overrides_root_config
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/servers/proxmox", root: "/home/jared/servers",
cwd_config: "tracker=planka:proxmox-board\n", root_config: "board=servers\n"
)
assert_equal "proxmox-board", name
end
def test_board_name_for_falls_back_to_root_config
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/dev/cc-os/plugins", root: "/home/jared/dev/cc-os",
cwd_config: nil, root_config: "tracker=planka:cc-os\n"
)
assert_equal "cc-os", name
end
def test_board_name_for_subdir_config_without_board_names_the_subdir
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/servers/proxmox", root: "/home/jared/servers",
cwd_config: "version=3\n", root_config: nil
)
assert_equal "proxmox", name
end
def test_board_name_for_no_config_uses_heuristic_repo_basename
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/dev/cc-os", root: "/home/jared/dev/cc-os",
cwd_config: nil, root_config: nil
)
assert_equal "cc-os", name
end
def test_board_name_for_unrecognized_path_without_config_is_nil
name = Backlog::TriageCheck.board_name_for(
cwd: "/home/jared/scratch/x", root: "/home/jared/scratch/x",
cwd_config: nil, root_config: nil
)
assert_nil name
end
end

View File

@ -1,5 +1,5 @@
{ {
"name": "os-context", "name": "os-context",
"version": "0.1.0", "version": "0.1.0",
"description": "Prompt-composer SessionStart plugin: concatenates prompts/session-start/*.md (currently session-orchestration rules — delegation threshold, explicit model routing on Agent spawns) into one additionalContext block, injected at session start and after compaction." "description": "Prompt-composer SessionStart plugin: concatenates prompts/session-start/*.md (session-orchestration + delegation-economics rules, kickoff conventions, standing safety rules, decision-memo format) into one additionalContext block, injected at session start and after compaction."
} }

View File

@ -0,0 +1,28 @@
# os-context
Prompt-composer `SessionStart` plugin (renamed 2026-07-13 from os-orchestration):
concatenates `prompts/session-start/*.md` into one `additionalContext` block,
injected at session start and after compaction/resume.
## Component map
- `prompts/session-start/` — 4 files, concatenated in filename order:
`10-orchestration.md` (session-orchestration + delegation-economics rules — do
not edit without explicit approval, per eval-wording sensitivity),
`20-kickoff-conventions.md`, `30-standing-safety.md`, `40-decision-memos.md`.
- `hooks/inject.py` — the composer; enforces the line-budget guard (120/240).
- `hooks/hooks.json` — SessionStart wiring, matcher `startup|resume|clear|compact`.
- `skills/audit-sessions/SKILL.md``/os-context:audit-sessions`, the biweekly
orchestration IRL audit against `10-orchestration.md`'s shipped rules; see
`skills/audit-sessions/references/rubric.md`.
- `audit/bin/{audit-stats,extract}` — deterministic driver scripts the audit
skill calls; skill-local, not a plugin-root `bin/`.
- `tests/hook_test.py` — unittest, covers compose order + budget logic.
- `eval/` — Eval harness (prompted grid, scenarios + `scenarios-reserve/`); eval
discipline (reserves never read informally) per root `CLAUDE.md`.
## Pointers
- Decision: [docs/adr/0031](../../docs/adr/0031-os-context-prompt-composer-plugin-supersedes-single-file-os-orchestration.md)
(prompt-composer plugin, supersedes the single-file os-orchestration approach).
- Status/build history: `docs/implementation-status/os-context.md`.

View File

@ -14,6 +14,7 @@ require "optparse"
module OrchAudit module OrchAudit
READ_TOOLS = %w[Read Grep Glob Bash].freeze READ_TOOLS = %w[Read Grep Glob Bash].freeze
RUN_TOOLS = %w[Read Grep Glob Edit Write].freeze RUN_TOOLS = %w[Read Grep Glob Edit Write].freeze
RW_TOOLS = %w[Read Write Edit].freeze
class Line class Line
attr_reader :number, :raw attr_reader :number, :raw
@ -139,19 +140,23 @@ module OrchAudit
@threshold = threshold @threshold = threshold
end end
def candidates def candidates = runs.map { |r| describe(r) }
runs = []
current = [] def runs
@calls.each do |call| @runs ||= begin
if extend_run?(current, call) found = []
current << call current = []
else @calls.each do |call|
runs << current if qualifying?(current) if extend_run?(current, call)
current = RUN_TOOLS.include?(call.name) ? [call] : [] current << call
else
found << current if qualifying?(current)
current = RUN_TOOLS.include?(call.name) ? [call] : []
end
end end
found << current if qualifying?(current)
found
end end
runs << current if qualifying?(current)
runs.map { |r| describe(r) }
end end
private private
@ -220,7 +225,16 @@ module OrchAudit
m[:targets_sample].each { |t| md << " - `#{t}`\n" } m[:targets_sample].each { |t| md << " - `#{t}`\n" }
end end
end end
md << "\nHeuristic candidates require auditor judgment; sequential-dependent work is a valid reason not to delegate.\n" rw = d[:read_write_profile]
md << "\n## Main-loop Read/Write/Edit profile (missed-delegation/read-write denominator)\n\n"
counts = rw[:tool_counts].map { |k, v| "#{k}:#{v}" }.join(" ")
md << "- Total: #{rw[:total]} (#{counts}); in flagged runs: #{rw[:in_flagged_runs]}; scattered: #{rw[:scattered].size}\n"
rw[:scattered].each do |c|
md << " - #{c[:tool]} line #{c[:line]} `#{c[:target]}`\n"
end
md << "\nHeuristic candidates and scattered calls require auditor judgment via the rubric's " \
"exemption decision procedure (E-a..E-d); under ADR-0043 there are no validity defenses " \
"for direct grunt work, only severity modifiers.\n"
md md
end end
@ -235,12 +249,31 @@ module OrchAudit
attach_rounds(main, agent_spawns) attach_rounds(main, agent_spawns)
orchestrator_calls = calls.values.reject { |c| c.name == "Agent" } orchestrator_calls = calls.values.reject { |c| c.name == "Agent" }
scan = MissedDelegationScan.new(orchestrator_calls, @run_threshold)
{ {
metadata: metadata(main, agent_spawns), metadata: metadata(main, agent_spawns),
agent_spawns: agent_spawns.map(&:to_h), agent_spawns: agent_spawns.map(&:to_h),
segments: segments(main).map(&:to_h), segments: segments(main).map(&:to_h),
missed_delegation_candidates: missed_delegation_candidates: scan.candidates,
MissedDelegationScan.new(orchestrator_calls, @run_threshold).candidates read_write_profile: read_write_profile(orchestrator_calls, scan)
}
end
# Denominator for the rubric's missed-delegation/read-write sub-tag: total
# main-loop Read/Write/Edit calls regardless of adjacency. The run scan only
# sees consecutive same-tool runs; "scattered" lists the calls it can't
# flag, for per-call exemption judgment by the auditor.
def read_write_profile(orchestrator_calls, scan)
rw = orchestrator_calls.select { |c| RW_TOOLS.include?(c.name) }
in_run = scan.runs.flatten.select { |c| RW_TOOLS.include?(c.name) }
scattered = rw - in_run
{
total: rw.size,
tool_counts: rw.map(&:name).tally,
in_flagged_runs: in_run.size,
scattered: scattered.map do |c|
{ tool: c.name, line: c.line.number, target: c.target.to_s[0, 120] }
end
} }
end end

View File

@ -1,5 +1,15 @@
# os-context eval — E1E3, E5 (session-orchestration behavior) # os-context eval — E1E3, E5 (session-orchestration behavior)
> **STATUS 2026-07-17 — RETIRED AS A MEASUREMENT INSTRUMENT; KEPT AS TEMPLATE.**
> Two independent reasons, either sufficient: (1) the WS4 wording loop consumed the
> run-set AND the reserves — both are contaminated for any future wording tuning;
> (2) ADR-0043 replaced the per-task delegation threshold these scenarios encode
> with an absolute delegation policy, so the scenario expectations now judge
> against a superseded rule set. Do not run or rebuild expectations in place; a
> future eval of the ADR-0043 wording needs fresh scenarios sourced from new IRL
> audit findings, using this harness (bin/, fixture/, run mode below) as the
> structural template.
_Created: 2026-07-06. Scenarios sourced from the verified misses in _Created: 2026-07-06. Scenarios sourced from the verified misses in
`docs/orchestration-audit/2026-07-06-findings.md` (clusters 13), per the `docs/orchestration-audit/2026-07-06-findings.md` (clusters 13), per the
IRL-feedback-loop methodology. E4 (batch planning) deliberately deferred._ IRL-feedback-loop methodology. E4 (batch planning) deliberately deferred._

View File

@ -1,64 +1,60 @@
## Session orchestration ## Session orchestration
- Main-loop tokens are the most expensive tokens in the session: every direct tool - The role split is absolute, not situational: the main loop is the session's
call and every byte read into this context bills at the main-loop model's rate, executive. It interprets the user, plans, delegates, verifies, and reports —
while a haiku/sonnet subagent does the same mechanical work for a fraction of it. it does not do grunt work. Main-loop tokens are the most expensive in the
The more capable the main-loop model (opus- or fable-tier), the lower the session, and every byte read here is re-billed on every later turn: budget
delegation threshold. The question is not "is this task big enough to delegate?" spent on grunt work is stolen from the session's total capacity for
but "does this step need main-loop judgment?" — when a sequence beyond ~3 tool high-level turns. Do NOT decide per task whether the coming chain is "short
calls needs no main-loop judgment between steps, delegate it down-tier, even if enough" to do directly — chain length and follow-up probability are
the steps are strictly sequential. unknowable at decision time, and one short chain reliably leads to another.
- Delegate when: work is parallelizable across independent files/subtasks; an Delegation is the default for everything outside the exemption list below.
implementation task produces N independent files from an already-settled design, - Exempt from delegation (enumerated and mechanical — never "quick" or
spec, or plan (write-N-files fan-out is delegated work — the design decisions are "simple"): (a) reading a file or path the user explicitly named this turn;
already made); an investigation spans many files or needs a large/isolated (b) reviewing diffs, test output, and verification results before sign-off —
context (long log review, wide grep-and-synthesize); or a mechanical multi-call verification IS executive work, and a confident report reads the same whether
sequence (edits, lookups, conversions) needs no judgment between steps — this the work is right or subtly wrong: read the diff, not the report of the
includes your own eval/benchmark/extraction work: repeated near-identical diff; (c) a single state-inspection command needed to answer the user
parsers, headless grid runs, and CLI syntax hunts are mechanical sequences too; accurately (a status, a version, a path check); (d) acts only the main loop
script once or delegate. A red-green TDD loop over an already-specified fix can perform — user interaction, spawning/steering agents, final commits it
list is a single delegable unit — the internal test-driven sequencing is the must stand behind. Everything else — searching, investigating, editing,
implementer's judgment, not the orchestrator's; put the failing-test list and testing, running evals — is delegated work, even when strictly sequential.
fix directions into the spawn prompt. This holds for the whole session: after - Persistent manager, not spawn-per-errand: at the first delegable work of the
one round of spawns completes, the next eligible chunk of work is delegated session, spawn ONE sonnet manager agent; route subsequent grunt episodes to
too — don't drift back into long direct runs mid-session. WHEN a that same agent via SendMessage — it already holds the files, layout, and
design/decision settles mid-session, stop before the first direct Write/Edit diagnosis, while a fresh spawn re-pays the per-agent system-prompt tax and
that implements it — the resulting fan-out is a delegation event, even if the re-covers ground. Spawn additional agents only for genuinely parallel,
session started with disciplined spawns. WHEN one or more review/diagnosis independent work (then batch: group ~58 similar items into one prompt with
agents return a confirmed defect list → implementing that list is a NEW an explicit return format, planned before the first spawn).
delegation decision, never a continuation of the main loop's review work. - Tier routing — set `model:` explicitly on every `Agent` call (an omitted
- Work directly when: the op is single-file or ≤2 tool calls; steps are genuinely `model` silently bills at the main-loop rate):
judgment-dependent (each result changes what you do next); the user is in the - `haiku`: near-mechanical, fully specified steps — apply a listed edit set,
loop every few turns (interactive troubleshooting — delegation overhead exceeds run-and-report a command matrix, format/parser conversions, ticket and
savings); a uniform multi-file change is covered by one scripted command (a bookkeeping API sequences, bulk moves from an explicit map. The sonnet
loop/script in a few Bash calls is direct work, cheaper than a per-file grind or manager may hand these down itself.
a spawn); or you are driving/polling a script's own output. - `sonnet` (default manager tier): investigation and diagnosis, implementing
- Batch before spawning: plan the full fan-out before the first spawn, then group from a settled design/spec/defect list, red-green TDD over a specified fix
related subtasks (~58 similar items) into one agent prompt with an explicit list, wide grep-and-synthesize, log review, eval extraction runs.
return format, so each agent completes in one round. A follow-up on an agent's - `opus`: entered on observed failure, not forecast — when the sonnet
result goes to that same live agent via SendMessage, not a fresh spawn — every manager is wrong or stuck twice on the same problem, re-delegate that
new spawn re-pays the per-agent system-prompt tax. problem to opus (or pull it up to the main loop). Also first choice for
- Delegate async and keep working: launch independent subagents in the background work that is judgment-dense end-to-end: architectural review, subtle
and continue your own thread while they run; intervene only when a result shows concurrency/correctness diagnosis, adversarial test design.
an agent off track. Never sleep-poll a background job from the main loop — - When a spawn requests `sonnet` or `opus` → append to its prompt: "State the
background it and rely on completion notification (or Monitor); a `sleep` loop is exact model ID you are running as in the first line of your report." (The
main-loop idle time billed at the top rate. launch result does not show the resolved model.) A report showing a lower
- Before every `Agent` call → set `model:` explicitly in that call. An omitted tier than requested → say so and adapt (re-spawn or flag the downgrade);
`model` silently bills the subagent at the main-loop model. Mechanical never treat downgraded output as judgment-tier work silently.
file-edit/shell work → `haiku`; anything requiring judgment → `sonnet`; genuinely - Delegate async and keep working: launch independent agents in the background
hard reasoning → `opus`. and continue the executive thread; intervene only when a result shows an
- When a spawn requests `sonnet` or `opus` → append to its prompt: "State the exact agent off track. Never sleep-poll a background job from the main loop —
model ID you are running as in the first line of your report." (The launch result rely on completion notification (or Monitor); a `sleep` loop is main-loop
does not show the resolved model; the subagent's self-report is the only visible idle time billed at the top rate.
signal.) When a report comes back showing a lower tier than you requested → say so - Don't re-cover your own ground: a file already read under an exemption goes
and adapt (re-spawn or flag the downgrade) — never treat downgraded output as into the agent prompt as a stated fact or summary, not an instruction to
judgment-tier work silently. re-read it. If the target of an investigation is uncertain, that uncertainty
- Before delegating investigation → don't re-cover your own ground: a file you is itself the delegable task — send orientation to an Explore agent rather
already read goes into the subagent prompt as a stated fact or summary, not as an than orienting by hand.
instruction to read it again. If an investigation will span many files, delegate - Where a call exposes an effort dial (Workflow `agent()` opts), set it per
it before reading them yourself — a short orienting Read is fine only when the stage: mechanical stages `effort: low`; hard verify/judge stages
target file/path is uncertain. In an unfamiliar codebase, more than ~5 orienting `high`/`xhigh`.
grep/read calls means the orientation itself is the delegable task — send it to
an Explore agent.
- Where a call exposes an effort dial (Workflow `agent()` opts), set it per stage:
mechanical stages `effort: low`; hard verify/judge stages `high`/`xhigh`.

View File

@ -1,5 +1,5 @@
--- ---
description: Run the biweekly orchestration IRL audit - deterministic transcript stats precompute, then auditor fan-out judging flagged regions against the shipped `prompts/session-start/10-orchestration.md` rules, then a tiered tune-up report. Works from any directory. Trigger via the os-status due nudge or the Planka recurrence card. description: Run the biweekly orchestration IRL audit - deterministic transcript stats precompute, then auditor fan-out judging flagged regions against the shipped `prompts/session-start/10-orchestration.md` rules, then a tiered tune-up report. Works from any directory. Trigger via the os-status due nudge or the recurring issue jared/ops#1 on Forgejo.
--- ---
# Audit production sessions against the shipped orchestration rules # Audit production sessions against the shipped orchestration rules
@ -65,5 +65,6 @@ later: edit
`~/dev/cc-os/plugins/os-context/prompts/session-start/10-orchestration.md`, run `~/dev/cc-os/bin/refresh-plugins`, `~/dev/cc-os/plugins/os-context/prompts/session-start/10-orchestration.md`, run `~/dev/cc-os/bin/refresh-plugins`,
and record the change in `~/dev/cc-os/docs/implementation-status.md`. and record the change in `~/dev/cc-os/docs/implementation-status.md`.
Finally, if a Planka card for this audit exists on the backlog board, move it to Finally, comment the audit date + report location on the recurring issue
Review (never to Done). `jared/ops#1` (Forgejo, via `tea`) and leave it open — recurring-convention
issues are never closed by the AI (ADR-0042).

View File

@ -1,42 +1,87 @@
# IRL audit rubric — shipped prompts/session-start/10-orchestration.md delegation-economics rules # IRL audit rubric — shipped prompts/session-start/10-orchestration.md delegation rules (ADR-0043 absolute policy)
Audit each assigned session against these shipped rules. A "finding" is a concrete Audit each assigned session against the shipped ABSOLUTE delegation policy: the main
violation, near-miss, or friction point, with evidence (jsonl line number / short quote). loop is the executive (interpret, plan, delegate, verify, report); every main-loop tool
The deterministic fact-sheet (stats + flagged regions) is precomputed — do NOT recount call outside the enumerated exemptions is a violation. A "finding" is a concrete
tool calls or spawns; your job is the judgment layer: mechanical vs judgment-dependent, violation, near-miss, or friction point, with evidence (jsonl line number / short
severity, and category. quote). The deterministic fact-sheet (stats + flagged regions) is precomputed — do NOT
recount tool calls, spawns, or SendMessage rounds; your job is the judgment layer the
sheet cannot do: exemption classification, rationale reading, severity.
## Exemption decision procedure (apply BEFORE counting any missed-delegation finding)
For every non-exempt-looking main-loop tool call, walk these steps in order and record
in the evidence line which step exempted it or that all failed:
- **E-a** — nearest preceding user turn (or same turn) explicitly names the file/path
touched → exempt. Evidence: quote the naming text.
- **E-b** — the call reads a diff, test output, or verification result and is followed
within ~3 tool calls by sign-off action or interpretation to the user (commit,
"verified", result explanation) → exempt. Evidence: quote the sign-off.
- **E-c** — a single state-inspection command (status/version/path check), not part of
a run, whose result is relayed to the user in the same or next turn → exempt.
- **E-d** — an act only the main loop can perform (user interaction, Agent/SendMessage
calls, final commits it stands behind) → exempt.
- **None apply** → count it under `missed-delegation`.
A finding whose evidence does not state the failed exemption steps is not falsifiable
by a second reader — do not report one without them.
## Rule surfaces (categories) ## Rule surfaces (categories)
1. **missed-delegation** — main loop ran a mechanical sequence beyond ~3 tool calls with no 1. **missed-delegation** — any non-exempt main-loop tool call (per the procedure
judgment between steps (multi-file edits, lookups, conversions, long log review, wide above). The old defenses are GONE as defenses: "the chain was short," "steps were
grep-and-synthesize) instead of delegating down-tier. Includes: write-N-files fan-out judgment-dependent," and "sequential-dependent work" no longer justify direct work.
from a settled design done directly; the operator's own eval/benchmark/extraction loops. Sub-tag `missed-delegation/read-write` for main-loop Read/Write/Edit calls
2. **model-param** — an Agent spawn with no explicit `model:` param, or mechanical work scattered non-consecutively across the session (the pattern the fact-sheet's
sent to sonnet/opus when haiku would do, or a sonnet/opus spawn whose prompt lacks the run-detector cannot flag) — use the sheet's per-segment tool profiles as the
self-report line, or a downgraded/mismatched resolved model treated silently as denominator and judge each call through the procedure.
judgment-tier. Severity modifier (not a defense): a violation inside a tight interactive loop
3. **spawn-batching** — many small spawns where ~58 similar items should have been grouped (user replying every few turns) or a ≤2-call op is still counted but reported LOW
into one agent prompt; or a follow-up on an agent's result sent as a fresh spawn instead and tagged `accepted-cost` (ADR-0043 accepts this overhead knowingly); a long
of SendMessage to the live agent. direct grunt run is HIGH. Never mix the two at one severity.
4. **async-usage** — main loop idle-waiting on a synchronous agent when background + 2. **exemption-chaining** — grunt work laundered through the exemption list: 3+
continue was possible; sleep-polling a background job; or conversely exemption-justified reads/commands (esp. E-b/E-c) touching the same file or
babysitting/polling that the rules say to do directly. subsystem in a short window, which taken together constitute the delegable
5. **redundant-context** — delegated investigation re-covering ground the main loop already investigation the policy says to delegate. Flag on the aggregate pattern; do not
read, or the main loop reading many files itself before delegating the investigation adjudicate each call individually.
anyway. 3. **delegation-overhead-mismatch** (narrowed over-delegation) — delegation executed
6. **over-delegation** — delegation where direct work was correct: ≤2 tool-call ops, so badly it bought nothing: a fresh spawn for a trivial exempt-adjacent errand that
judgment-dependent steps, interactive troubleshooting, or a uniform change coverable by belonged in the persistent manager's next SendMessage; or delegation theater —
one scripted Bash loop. main-loop re-verification so thorough (re-reading full files, re-running the work)
7. **drift** — session starts disciplined then drifts into long direct runs mid-session, that it exceeds diff/output review and re-imports the delegated context.
especially after a design/decision settles. 4. **model-param** — an Agent spawn with no explicit `model:`; mechanical fully
8. **tracker-routing** — mid-session follow-up work (a concrete task, bug, or deferred specified work sent to sonnet/opus when haiku would do; a sonnet/opus spawn whose
item not done this session) left as a TODO comment or chat mention instead of captured prompt lacks the self-report line; a downgraded resolved model treated silently as
to the tracked board; durable spec text routed to a Planka card description (or task judgment-tier; or **forecast-escalation**: opus chosen with no cited prior sonnet
state routed to git issues) against the Planka-state/git-issues-spec boundary failure on the same problem AND no judgment-dense-end-to-end rationale (arch
(ADR-0033, `.cc-os/config` tracker key); or a Planka card visibly accreting spec-like review, subtle concurrency/correctness, adversarial test design). Evidence must
decisions in comments with no promotion flag raised to the user — silent promotion pair the fact-sheet spawn row (model, round) with a quote of the delegation
counts too. Convention compliance only, not whether the routing choice was wise. rationale — the sheet alone cannot distinguish legitimate first-choice opus.
5. **manager-continuation** — no persistent manager: grunt episodes after the first
delegable work sent as fresh spawns instead of SendMessage to the live manager
(evidence: the fresh spawn line + the prior live agent from the sheet's spawns[]
with matching task domain); or many small spawns where ~58 similar items should
have been one batched prompt with an explicit return format. Fresh spawns for
genuinely parallel independent work are correct.
6. **async-usage** — main loop idle-waiting on a synchronous agent when background +
continue was possible; sleep-polling a background job.
7. **redundant-context** — delegated investigation re-covering ground the main loop
already read under an exemption (the prompt should have carried it as stated
fact/summary), or main-loop orienting reads that should have gone to Explore
(target uncertain ⇒ the orientation itself was the delegable task).
8. **drift** — session applies the policy early then decays into direct grunt runs
mid-session, especially after a design/decision settles. Report as a pattern
finding over the constituent missed-delegation findings, not a duplicate count.
9. **tracker-routing** — mid-session follow-up work (a concrete task, bug, or deferred
item not done this session) left as a TODO comment or chat mention instead of
captured as an issue on the repo's configured tracker (`.cc-os/config` tracker key,
`os-backlog:capture`); an AI-authored issue carrying state labels or the
human-curated `next` label it shouldn't (ADR-0042 — git issues are the single
tracker, `next` is human-only, the AI never adds or removes it short of an explicit
direct request); or an issue visibly accreting scope/decisions in comments with no
promotion flag raised to the user — silent promotion counts too. Convention
compliance only, not whether the routing choice was wise.
## Per-session report format (return exactly this) ## Per-session report format (return exactly this)
@ -44,19 +89,22 @@ severity, and category.
SESSION: <session id> SESSION: <session id>
PROJECT/TOPIC: <one line what the session was doing> PROJECT/TOPIC: <one line what the session was doing>
FINDINGS: FINDINGS:
- [<category>] <severity HIGH/MED/LOW><one-sentence finding> | evidence: <jsonl line or short quote> - [<category>] <severity HIGH/MED/LOW><one-sentence finding> | evidence: <jsonl line or short quote> | exemption-steps: <e.g. "E-a no (no path named), E-b no (no sign-off follows), E-c no (part of run)" required for missed-delegation; "n/a" otherwise>
...or "none" ...or "none"
FLAGGED-REGION VERDICTS: <for each precomputed flagged region: mechanical | judgment-dependent | mixed, one line each> FLAGGED-REGION VERDICTS: <for each precomputed flagged region: violation | exempt (state step) | mixed, one line each>
POSITIVE: <patterns worth keeping, if notable> POSITIVE: <patterns worth keeping, if notable>
``` ```
## Method notes ## Method notes
- The fact-sheet JSON gives spawns (with model params and resolved models), per-segment - The fact-sheet JSON gives spawns (with model params, resolved models, SendMessage
tool profiles, and flagged same-tool runs with jsonl line ranges. Read the transcript round counts), per-segment tool profiles, and flagged same-tool runs with jsonl line
ONLY selectively around those lines, e.g. ranges. Read the transcript ONLY selectively around those lines, e.g.
`sed -n '120,180p' file.jsonl | jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text'` `sed -n '120,180p' file.jsonl | jq -r 'select(.type=="assistant") | .message.content[]? | select(.type=="text") | .text'`
- Judge "mechanical vs judgment" from tool names + inputs + the surrounding - Exemption facts live in the surrounding prose, not the tool call: E-a in the
user/assistant text: did each result change what happened next? preceding user turn, E-b/E-c in what happens next with the result. Always read both
- Sequential-dependent work is a valid reason not to delegate; interactive sides of a flagged call.
troubleshooting (user replying every few turns) is a valid reason too. - There are no validity defenses for direct grunt work under ADR-0043 — only the
severity modifiers in category 1. If a finding feels "technically a violation but
obviously fine," that is what LOW/`accepted-cost` is for; report it there rather
than dropping it, so the accepted-cost volume stays visible in the trend ledger.

View File

@ -4,21 +4,13 @@ Claude Code plugin that monitors and manages **stale** and **bloated** project
documentation. Installed globally, operates per-project. Reminds (deterministic, documentation. Installed globally, operates per-project. Reminds (deterministic,
zero-token) on `SessionStart`; checks and cleans on demand via skills. zero-token) on `SessionStart`; checks and cleans on demand via skills.
> Read `PRD.md` first — it is the source of truth for scope, decisions, and > `PRD.md` is the historical source-of-truth design doc (see its top-of-file
> rationale. This file is the build map. > migration note for current location). This file is a current-state index,
> not a build map — the plugin is fully built (5 skills, tests passing).
> Vault user guide (nuance/mental-model, not a how-to): > Vault user guide (nuance/mental-model, not a how-to):
> `~/Documents/SecondBrain/user-guide/os-doc-hygiene-user-guide.md`. > `~/Documents/SecondBrain/user-guide/os-doc-hygiene-user-guide.md`.
## Lifecycle layer (2026-07-15)
A rulebook layer sits on top of the scan/classify/clean core: global
`rulebook.json` + optional per-project `.dochygiene-rules.json` declare
lifetime rules for known-temporary artifacts, feeding tier derivation and a
`/os-doc-hygiene:calibrate` skill for tuning rules against a real project.
Read `lifecycle-spec.md` before touching `rulebook.py`, the scanner's
lifecycle signals, or the calibrate skill; decisions are ADR-00380041.
## Core distinction (do not conflate) ## Core distinction (do not conflate)
- **Stale** = the doc is *wrong* (contradicted, orphaned, superseded, - **Stale** = the doc is *wrong* (contradicted, orphaned, superseded,
@ -29,99 +21,39 @@ lifecycle signals, or the calibrate skill; decisions are ADR-00380041.
Severity scales with **injection frequency** — a stale line in `CLAUDE.md` (auto Severity scales with **injection frequency** — a stale line in `CLAUDE.md` (auto
-injected every session) is worse than the same line in a doc nobody opens. -injected every session) is worse than the same line in a doc nobody opens.
## Design invariants (see PRD + reversion-protection pattern) ## Component map
1. The `SessionStart` hook **only reminds** — it never runs analysis or mutates - `skills/` — five skills: `check`, `clean`, `status`, `sweep`, `calibrate`
anything, and spends no AI tokens. All mutation is user-invoked. (each `SKILL.md` + `workflows/`); see `skills/CONTEXT.md` for the index.
2. Reminder **snoozes** (≤ once/day while stale) via `last_reminded`. - `scripts/` — deterministic Python core (scanner, state store, reminder,
3. State lives **in-project** under gitignored `.cc-os/dochygiene/` (ADR-0027; report builder, validator, patch applier, token estimator, rulebook,
legacy `.dochygiene/` is fallback-read only). No global index. calibrate helpers); see `scripts/CONTEXT.md`.
4. **Report rollover**: keep only the latest report. The tool must not become - `hooks/hooks.json``SessionStart``scripts/reminder.py` (banner only,
the bloat it polices. no analysis, no mutation beyond `last_reminded`).
5. Cleanup is **git-safe**: clean/committed tree required (or auto WIP - `tests/` — pytest suite covering every deterministic seam; see
checkpoint), each run is one reviewable commit. `tests/CONTEXT.md`.
6. **Deterministic-first**: scan/state/patch-apply/token-estimate are scripts - `invariants.md` — the reversion-protection contract (numbered behavioral
(no model). AI does only classification and prose distillation. invariants); read before changing scanner, state, or safety-tier logic.
7. **Safety tiers**: `auto` (deterministic+reversible+objective) runs without - `examples/golden/` — golden classifier fixtures (Layer 2 of reversion
prompt; `confirm` (destructive/subjective/generative) escalates for approval. protection, paired with `invariants.md`).
8. **mtime guard**: never apply a cached edit to a file changed since the check. - `lifecycle-spec.md` — the rulebook layer (global `rulebook.json` +
9. Frozen/ignored files (`hygiene: frozen` frontmatter, `.dochygiene-ignore`, per-project `.dochygiene-rules.json`) feeding tier derivation and the
append-only logs) are never flagged. `calibrate` skill; decisions are ADR-00380041.
- `openspec/` — this plugin's own OpenSpec changes/specs tree (archived
changes correspond to shipped features).
Changing any invariant requires updating `invariants.md` and the golden ## Conventions
examples, with explicit human approval.
## Build order (report schema gates everything)
1. **Machine report schema** — the contract every component consumes. Freeze
first. (per-file: category, signals, op, op-type, safety tier, optional
exact-edit, token estimate.)
2. **Deterministic core***build-spike #1 first*: a trivial `hooks/hooks.json`
emitting a `systemMessage` banner on `SessionStart`, confirmed to render
visibly in a real session (no plugin in this collection wires a CC hook yet).
Then: scanner (signals + shortlist), state store (timestamps, rollover,
snooze, atomic writes, project-root resolution), reminder hook
(`SessionStart`, `matcher: startup|resume`, injected-clock testable).
3. **`check` skill** — scanner shortlist → AI classify (Sonnet, judgment only) →
**`report_builder.py`** deterministic finalize pass → human + machine reports →
update `last_check`. The finalize pass sits **between** model classification and
the report write (invariants #6, #10): it computes `expected_sha256`, looks up
`is_destructive`/`is_reversible` from `exact_edit.kind`, derives `safety_tier`
via `validate_report.py`'s single-source `derive_safety_tier(...)`, sources
`raw_tokens` from the token estimator, and assembles the schema-valid machine
report + human-report skeleton. The model authors none of those four fields.
4. **`clean` skill + patch-applier** — consume report, mtime-guard, apply
`deterministic` ops mechanically, delegate `generative` to Sonnet, gate
`confirm`, git checkpoint + single commit, scopable by category/file →
update `last_clean`. Then **`sweep`** = check-then-clean.
- **RESOLVED (Phase 4):** `insert-frontmatter` ops have `has_anchor=False` and
carry **no** `expected_sha256`, so invariant #8's content hash cannot protect
them. The applier re-derives frontmatter freshness at apply time: re-read the
file, parse frontmatter; key present with target value → idempotent no-op; key
present with different value → skip (`frontmatter-key-conflict`); key absent →
insert. No cached hash is trusted. This resolves the deferred hole noted in
`add-check`.
5. **Bonus (v2)** — deterministic patch-apply hardening + token estimator
(local tokenizer, injection-frequency weighting, bottom-up rollup).
## Intended layout (per cc-plugins conventions)
```
doc-hygiene/
├── .claude-plugin/plugin.json # done
├── PRD.md # done — source of truth
├── CLAUDE.md # this file
├── invariants.md # TODO — declare behavioral invariants
├── skills/ # slash-command entry points: /os-doc-hygiene:<skill>
│ ├── check/SKILL.md
│ ├── clean/SKILL.md
│ ├── status/SKILL.md
│ └── sweep/SKILL.md
├── scripts/ # Python OOP — scanner, state, reminder, estimator, report_builder, applier
│ └── ...
├── hooks/hooks.json # SessionStart wiring → reminder script (systemMessage banner)
├── examples/golden/ # classifier golden examples (input tree → expected report)
└── tests/ # unit tests per deterministic seam + fixture doc trees
```
## Conventions (inherited from cc-plugins)
- **Language**: Python, OOP — small single-responsibility classes, dependency - **Language**: Python, OOP — small single-responsibility classes, dependency
injection, immutable transforms where possible. See injection, immutable transforms where possible. See
`../cc-architect/references/tool-patterns/deterministic-scripting.md`. `../cc-architect/references/tool-patterns/deterministic-scripting.md`.
- **Scripts**: structured JSON output, correct exit codes, idempotent, testable
in isolation with injected clock/filesystem.
- **Model routing**: scripts = no model; classification = Sonnet (hard cases → - **Model routing**: scripts = no model; classification = Sonnet (hard cases →
Opus); generative distillation = Sonnet (NOT Haiku); orchestration = Opus. Opus); generative distillation = Sonnet (NOT Haiku).
- **Reversion protection**: `invariants.md` + `examples/golden/` + change-impact - **Reversion protection**: `invariants.md` + `examples/golden/` + human gate
analysis + human gate for golden-example changes. See for changes to either. See
`../cc-architect/references/tool-patterns/reversion-protection.md`. `../cc-architect/references/tool-patterns/reversion-protection.md`.
- Read a directory's `CONTEXT.md` before its source files (progressive - Read a directory's `CONTEXT.md` before its source files (progressive
disclosure); generate one per directory as it's built. disclosure).
## Completion steps (do NOT do until functional) Changing any invariant requires updating `invariants.md` and the golden
examples, with explicit human approval — do not restate the invariants here.
1. Add `invariants.md` and 35 golden examples.
2. Register in `../.claude-plugin/marketplace.json` (name + source + description,
matching `plugin.json`). See `../.claude/references/plugin-registration.md`.
3. Verify installable via `/install doc-hygiene@cc-plugins`.

View File

@ -1,6 +1,8 @@
# PRD: `doc-hygiene` plugin # PRD: `doc-hygiene` plugin
> Status: `ready-for-agent` · Working name: `doc-hygiene` · Target: `~/dev/cc-plugins/doc-hygiene/` > Status: shipped · Built at `plugins/os-doc-hygiene/` in cc-os, not the
> `cc-plugins` target/marketplace named below. Historical design doc — kept
> as-is except this note and the location/marketplace corrections below.
## Problem Statement ## Problem Statement
@ -144,7 +146,8 @@ prose distillation). This keeps it fast, cheap, and trustworthy.
## Implementation Decisions ## Implementation Decisions
**Distribution & activation** **Distribution & activation**
- New plugin `doc-hygiene` in the `cc-plugins` marketplace. Installed globally, - New plugin `os-doc-hygiene` in the cc-os marketplace (built at
`plugins/os-doc-hygiene/`; see migration note above). Installed globally,
operates per-project. No global state index — the question of "how to key operates per-project. No global state index — the question of "how to key
projects globally" is dissolved by per-project storage. projects globally" is dissolved by per-project storage.
- Marketplace registration deferred until the plugin is functional (documented - Marketplace registration deferred until the plugin is functional (documented

18
plugins/os-sdlc/CLAUDE.md Normal file
View File

@ -0,0 +1,18 @@
# os-sdlc
Status: scaffold only — no skills/agents/hooks/scripts exist yet. Only
`.claude-plugin/plugin.json` and `OVERVIEW.md` are present.
This will be cc-os's lifecycle/SDLC plugin: a lightweight
grill → to-spec → to-tickets → implement → review → ship pipeline, adapted
from Matt Pocock's skill set and Delta Refinery's multi-level handoff
discipline, wired to `os-backlog` (tickets) and `os-adr` (decisions) rather
than reinventing either.
Read `OVERVIEW.md` for the full design brief (why this plugin exists, scope,
working philosophy, first-iteration build plan, open questions) and
[docs/adr/0037](../../docs/adr/0037-os-sdlc-lives-inside-cc-os-as-a-new-plugin-not-a-separate-cc-sdlc-marketplace.md)
for why it lives inside cc-os as a plugin rather than a separate marketplace.
Expand this file into a real component-map index once skills/agents/hooks
first land, per this repo's plugin conventions.

View File

@ -1,127 +1,99 @@
# os-sdlc — overview (launching point, v0.1) # os-sdlc — overview & concept map (v0.2)
Status: scaffold only. No skills/agents/hooks/scripts are implemented yet — this _Last updated: 2026-07-16. Status: design mapped, no implementation yet._
document exists to anchor a follow-up brainstorming session, not to lock a design. _This is the single entry point: an AI pointed here loads only the reference/
nodes its task needs, following the map below._
## Why this plugin exists ## Why this plugin exists
Matt Pocock (mattpocock/skills) shipped a v1.1 lifecycle update (2026-07-14) that Blend two proven inputs into one harness-driven SDLC "factory":
turned his skill set from a planning tool into a full grill → to-spec → to-tickets →
implement → code-review → commit pipeline. Separately, `~/dev/delta-refinery` runs a
heavier, DB-backed, multi-level pipeline (System Design → Behavioral Design →
Architecture → Slicer → Requirements) with named agent roles per level and a
Pre/Post/Handoff pattern for resumable multi-agent work.
`os-sdlc` is where cc-os adapts the good parts of both — Matt's lightweight linear - **Matt Pocock's v1.1 skill lifecycle** (wayfinder/grill → to-spec → to-tickets →
lifecycle and Delta Refinery's structured multi-level handoff discipline — into the implement → code-review → commit) — lightweight, linear, skill-shaped. Notes:
existing os-* family, wired to `os-backlog` (tickets/tracker) and `os-adr` (decision SecondBrain vault note `matt-pocock-skills-v1-1-changes.md`.
gate), rather than reinventing either. See - **DeltaRefinery** (`~/dev/delta-refinery`) — the user's prior multi-level pipeline.
[docs/adr/0037](../../docs/adr/0037-os-sdlc-lives-inside-cc-os-as-a-new-plugin-not-a-separate-cc-sdlc-marketplace.md) Retrospective (2026-07-16): keep its virtues — everything deterministic is
for why this is a plugin inside cc-os rather than a separate `cc-sdlc` marketplace. mechanical; single-job agents with minimal read/communicate/tool surfaces;
hook-run tests (the programmer never runs its own tests). Drop its failure
modes — headless-run allotment cost, and per-example TDD granularity (batch to
one-ticket grain instead).
## Scope: not just skills Placement and scope are decided in
[docs/adr/0037](../../docs/adr/0037-os-sdlc-lives-inside-cc-os-as-a-new-plugin-not-a-separate-cc-sdlc-marketplace.md):
a plugin inside cc-os carrying skills + agents + hooks + reference + scripts,
composing with `os-backlog` (git issues — Planka retired by ADR-0042), `os-adr`,
`os-vault`, and `os-context` rather than reinventing any of them.
Unlike most current cc-os plugins, `os-sdlc` is expected to carry all of: ## Concept map
- **skills/** — the lifecycle verbs (`review` is the first: cherry-picks Matt's
standards-conformance + spec-fidelity + Fowler refactor-smell axes into a new
`/os-sdlc:review`, coexisting with the existing generic `/code-review`).
- **agents/** — named roles for pipeline stages, in the spirit of Delta Refinery's
per-level agent rosters, sized down to what a lightweight harness actually needs.
- **hooks/** — session/state wiring where a deterministic check beats a skill.
- **reference/** — general best-practice material by language/framework/pattern that
pipeline stages can pull from (not client- or project-specific — that stays in the
vault).
- **scripts/** — mechanical CLI tooling supporting the above (candidate for the
ADR-0025 lib/+bin/ Ruby structure once real logic exists).
## Working philosophy this plugin should encode Each node is an independent context in `reference/`. Edges are "design here
constrains/feeds design there." Decomposing a node into tickets = grill its
"Open questions" section, then `to-spec``to-tickets` as usual.
Carried over verbatim from the brainstorming that led here, because it's the design ```mermaid
target, not just a preference: graph TD
ST[spec-and-ticket-layer] --> PS[pipeline-stages]
PS --> DG[deterministic-gates]
PS --> AD[agent-design-principles]
PS --> WP[worktree-parallelism]
DG --> PF[plugin-factory]
PS --> PO[pipeline-observability]
PS -.blocked stage.-> NAT[never-ask-twice]
NAT <--> SIL[self-improvement-loops]
PO --> SIL
SIL --> PF
PS -.deferred.-> HZ[horizon]
WP -.deferred.-> HZ
NAT -.mobbin.-> HZ
```
- **Draft-then-refine over get-it-perfect-up-front.** Autoresearch-style loop: draft ## Index
an idea, implement a first pass, audit process and outcome, hypothesize an
improvement, iterate. Applies to both the artifacts os-sdlc produces (specs,
tickets, code) and to os-sdlc's own design.
- **Goal is throughput at trust, not just automation.** The target is being able to
automate large chunks of the dev process for big upcoming projects — "move at the
speed of thought" — which requires the pipeline to be trustworthy enough at each
stage that skipping human review of that stage is safe, not just fast.
- **Composability with the rest of cc-os is a hard constraint**, not a nice-to-have:
os-sdlc must interoperate with `os-backlog` (tickets), `os-adr` (decisions),
`os-vault` (cross-project knowledge) — ADR-023's "os-* plugins cooperate" bet
applies here directly.
## Known inputs for the follow-up brainstorming session | Node | Status | One-liner |
|---|---|---|
| [spec-and-ticket-layer](reference/spec-and-ticket-layer.md) | settled direction | Git issues (Forgejo/GitHub per tracker key) are the durable spec layer; Pocock verbs map onto it |
| [pipeline-stages](reference/pipeline-stages.md) | direction | The v1 tracer bullet: `/implement` → test-writer → red-assert → programmer → green-assert → reviewer → human merge |
| [deterministic-gates](reference/deterministic-gates.md) | settled | Hooks, not agents, run tests/lint; red/green asserted mechanically; green command pluggable per project |
| [agent-design-principles](reference/agent-design-principles.md) | settled | Single job, minimal tool allowlist (programmer: no Bash), settled facts in via prompt, minimum out via return format |
| [worktree-parallelism](reference/worktree-parallelism.md) | direction | Worktree-per-spec, sequential tickets within a spec, parallel across specs, human merge gate |
| [pipeline-observability](reference/pipeline-observability.md) | direction | What a run writes down (exhaust), where it lives, predefined lifespans (os-doc-hygiene); copy-tag-audit pattern feeds the loops |
| [never-ask-twice](reference/never-ask-twice.md) | direction | The agency mechanism: two-tier lookup → mint-time scope classification → audit promotion; decision-category autonomy labels |
| [self-improvement-loops](reference/self-improvement-loops.md) | direction | Session audits, ADR audits, eval harnesses, skill-lint — each catches a different drift |
| [plugin-factory](reference/plugin-factory.md) | direction | Same factory builds plugins from a PRD; the eval harness is the green command |
| [horizon](reference/horizon.md) | horizon | Wake-on-trigger, Herdr controller, router agent, Mobbin — preserved intent, explicitly out of v1 |
- Matt Pocock v1.1 lifecycle notes: `docs/matt-pocock-skills-v1.1-notes.md`. ## Working philosophy (design target, not preference)
- Delta Refinery structure (facts gathered this session, not yet written up as a
standalone doc): 5-level `PipelineRunner`/`PipelineOrchestrator`, per-level agent
rosters under `.claude/agents/{prd,functional_spec,technical_spec,requirements}/`,
intra-level in-memory `Handoff` + inter-level DB-persisted `Artifact#structured_content`
for resumability, two composition roots (`HeadwatersComposition` full pipeline vs.
`ProductionComposition` requirement-level-only).
- Existing cc-os pieces this must integrate with: `os-backlog` (capture/list/route),
`os-adr` (find/create/init/migrate), `os-vault` (query/write/onboard-project).
- Open question carried into the brainstorm: how much of Delta Refinery's
resumable-handoff machinery is worth adopting now vs. deferred until a concrete
multi-session pipeline actually needs it (avoid building it ahead of a real need).
## ADW taxonomy input (2026-07-14 session) - **Draft-then-refine over get-it-perfect-up-front** — applies to the factory's
artifacts and to the factory itself.
- **Throughput at trust, not just automation** — a stage is done when skipping
human review of it is *safe*; trust is earned in units and made durable
(see never-ask-twice).
- **Three actors** (vault note `agentic-sdlc-ai-developer-workflow-taxonomy.md`):
every stage first asks whether it needs **code** (deterministic, free),
**engineer** (fixed start/end gates), or **agent** (judgment) — never default
to agent.
- **Composability with os-* siblings is a hard constraint** (ADR-023).
Cross-project methodology reference, not repo-specific: see the vault note ## Build sequencing
`agentic-sdlc-ai-developer-workflow-taxonomy.md` (SecondBrain) — a taxonomy of AI Developer
Workflow (ADW) structures from IndyDevDan's "Forget Loop Engineering" video (mermaid diagrams
included, with `[dan]`/`[jrs]` provenance tags separating his claims from cc-os-specific
extrapolation). Read it before designing os-sdlc's pipeline shape; the plan below is the
repo-specific slice of that broader taxonomy.
Three actors of value creation apply directly to os-sdlc's component design: **code** v1 = the tracer bullet in [pipeline-stages](reference/pipeline-stages.md),
(deterministic, free, most reliable — lint/format/test/CI/ticket-state transitions), triggered manually, walked by hand once before wiring into skills/hooks/agents.
**engineer** (the two fixed constraints: prompting/planning at the start, reviewing at the Everything in [horizon](reference/horizon.md) stays unbuilt until the interactive
end), **agent** (judgment work: planning, building, scouting). Every os-sdlc pipeline stage factory is trusted. Open items live per-node in each doc's "Open questions" —
should be built by first asking which of the three actors it actually needs — don't default this file carries none.
to "agent" for something code or a human gate should own.
## First-iteration build plan: a single tracer-bullet ADW **Grill queue** (decided 2026-07-16): the critical path is
[spec-and-ticket-layer](reference/spec-and-ticket-layer.md) →
[pipeline-stages](reference/pipeline-stages.md) — grill in that order first.
Then [never-ask-twice](reference/never-ask-twice.md) +
[self-improvement-loops](reference/self-improvement-loops.md) (can run in
parallel with pipeline build; NAT's formalization ADR is independent), then
[horizon](reference/horizon.md). The remaining nodes' open questions are
implementation-time calls, not grill material.
Per the vault note's escalation ladder, and per standing tracer-bullet convention, the first ## Sources
build target is the smallest complete loop, not the software factory. Scope:
**One worktree, one pipeline**: `ticket intake → build agent → lint/format/test (hooks) → - Vault: `matt-pocock-skills-v1-1-changes.md`, `agentic-sdlc-ai-developer-workflow-taxonomy.md`
engineer review → ship`. Sandboxes, N-way worktree fan-out, the hotfix ADW, and the software - Repo: ADR-0037 (placement), ADR-0042 (git-issues-only backlog), `~/dev/delta-refinery`
factory router are explicitly deferred — documented in the vault note, not built here. - Tracked follow-ups: cc-os Forgejo issues #71 (audit criteria), #72 (skill-lint), #73 (worktree tracker resolution)
Step by step:
1. **Ticket intake** (code, not agent). Reuse `os-backlog` as the trigger: a card moving to
`Doing` (or a linked Forgejo issue via `/to-tickets`) is the pipeline's entry point. No new
ticketing system — os-sdlc consumes os-backlog's state, per ADR-0037's composability
constraint.
2. **Build agent** (agent, minimal tools). New `os-sdlc` agent definition: system prompt scoped
to "write/modify code to satisfy the spec," tool grants limited to `Read`/`Write`(/`Edit`) —
no `Bash`. It cannot run tests or linting itself; it only ever sees pass/fail feedback handed
back to it.
3. **Lint/format/test gate** (code, via hooks — delta-refinery-style Pre/Post/Handoff, not a
skill-embedded step). A `PostToolUse`-style hook (or a small script the pipeline invokes
between agent turns) runs the project's lint/format/test commands after each build-agent
turn. On failure, the failing output is fed back into the *same* build-agent session as the
next turn's context (per Dan's separation-of-concerns principle). On pass, advance.
4. **Engineer review** (human gate). Standard PR/diff review — no change from how review works
today; the pipeline's job is to get a clean, tested diff in front of the engineer, not to
replace this gate.
5. **Ship** (code). Merge + whatever this repo's existing deploy path is — os-sdlc does not
own deploy; it hands off a mergeable, reviewed change.
Each step above should be walked by hand first (per Dan's second tip) before being wired into
skills/hooks/agents — run the lint/test loop manually against a real small change, confirm the
feedback-loop shape works, *then* automate it.
## Not decided yet (do not assume in implementation)
- Which of Matt's skills get adopted as-is vs. adapted vs. skipped, beyond `review`.
- Whether `implement` is adopted as a thin router as-is, or redesigned as a
Delta-Refinery-style level with named sub-roles.
- Whether `wayfinder` (multi-issue planning for big-plan decomposition) subsumes or
sits alongside a Delta-Refinery-style level structure.
- Any hook or agent definitions — none exist yet.

View File

@ -0,0 +1,52 @@
# agent design principles
_Status: settled — as of 2026-07-16_
_Connects to: [pipeline-stages](pipeline-stages.md), [deterministic-gates](deterministic-gates.md), [overview](../OVERVIEW.md)_
## Purpose
Fixes the shape every os-sdlc agent definition must follow: one job, minimal reads, minimal
tools, minimal returns. Load this before writing or reviewing any `agents/*.md` definition in
this plugin.
## Design
- **One clearly-defined job per agent.** It reads only what it needs for that job; it returns
the minimum under an explicit return format, so the next stage's context stays clean rather
than accumulating everything every prior stage touched.
- **Minimal tool allowlists via `tools:` frontmatter** in `agents/*.md`. The
programmer/build agent gets `Read`/`Write`/`Edit` only — no `Bash`. This is a deliberate
`[jrs]` decision in the vault taxonomy note, stronger than IndyDevDan's "separate
invocation" stance: rather than merely running build and test in separate agent
invocations, the build agent is architecturally incapable of running tests or shells at
all. No `WebFetch`/MCP/other-plugin tools unless the specific job needs them. Effect
observed in DeltaRefinery: agents scoped this tightly were more focused, cheaper per
invocation, and faster — the constraint is a feature, not friction.
- **Settled facts travel in via the spawn prompt** — an agent never "re-reads what the
orchestrator already read." The orchestrating stage resolves context once and hands the
agent exactly what it needs in the prompt itself.
- **Inter-stage state travels via small on-disk handoff artifacts** — a file-based analog of
DeltaRefinery's in-memory `Handoff` (intra-level) and DB-persisted `Artifact` (inter-level).
os-sdlc keeps this lightweight (files, not a database) because v1 is a single-session
tracer-bullet pipeline, not DeltaRefinery's multi-session resumable system.
- **Model tiering:** mechanical work → haiku; judgment work → sonnet; hard reasoning → opus.
Applied per-agent-definition, not per-pipeline — a test-writer doing mechanical
transcription of ticket acceptance criteria into test stubs is a different tier than a
reviewer judging spec alignment.
## Open questions
- Concrete handoff artifact file format (plain markdown, JSON, frontmatter'd markdown) —
not yet chosen.
- Whether DeltaRefinery-style persisted resumability is needed before multi-session
pipelines actually exist. Per the standing "don't build ahead of need" convention (see
[pipeline-stages](pipeline-stages.md)'s tracer-bullet framing), this stays undecided until a
real multi-session use case shows up.
## Sources
- SecondBrain vault: `agentic-sdlc-ai-developer-workflow-taxonomy.md` (`[jrs]` tool-allowlist
stance vs. `[dan]` separate-invocation stance)
- `~/dev/delta-refinery` (Handoff/Artifact pattern this generalizes from)
- `plugins/os-sdlc/OVERVIEW.md`
- 2026-07-16 design session (this doc's origin)

View File

@ -0,0 +1,53 @@
# deterministic gates
_Status: settled — as of 2026-07-16_
_Connects to: [pipeline-stages](pipeline-stages.md), [agent-design-principles](agent-design-principles.md), [plugin-factory](plugin-factory.md), [overview](../OVERVIEW.md)_
## Purpose
States the core rule that keeps os-sdlc's trust model cheap: agents never self-certify their
own work, hooks do. Load this when designing any pipeline stage that checks correctness, or
when wiring hook events for os-sdlc.
## Design
- **Core rule: agents never run tests/lint/format.** Hooks do — either Stop/SubagentStop
hooks, or a between-turn pipeline script. This is the mechanism, not just a preference: it
is what makes "red→green" something the pipeline *proves* rather than something an agent
*claims*, at zero additional LLM cost.
- On failure, the failing output is injected into the same agent's next turn as context — not
routed to a different agent, so the agent that wrote the code gets the direct feedback loop.
A max-iteration counter escalates to the human after N failed attempts, rather than looping
indefinitely.
- **Red-assert and green-assert gates** (defined in
[pipeline-stages](pipeline-stages.md)) are the two concrete applications of this rule in the
v1 pipeline: red-assert proves the tests actually exercise unbuilt behavior, green-assert
proves the implementation actually satisfies them.
- **Pluggable green command.** A per-project config value — proposed home: next to the
tracker key in `.cc-os/config` — names the command that proves work green: `rake test` +
rubocop for a Rails app, the eval harness for a cc-os plugin itself. This one indirection is
what lets the same factory drive arbitrarily different project types without os-sdlc knowing
anything about their toolchains.
- **Three-actors framing** (from the SecondBrain vault note
`agentic-sdlc-ai-developer-workflow-taxonomy.md`, IndyDevDan): **code** (deterministic,
free, most reliable), **engineer** (fixed start/end points — prompting and reviewing),
**agent** (judgment, most expensive and most variable). Every stage in os-sdlc is designed
by first asking which actor it actually needs — the default is never "agent."
Test-execution and lint/format are unambiguously **code**-actor work; routing them through
an agent would be strictly worse on cost, reliability, and speed.
## Open questions
- Exact hook events to use (Stop/SubagentStop) vs. a pipeline-invoked script called between
agent turns — a runtime/implementation choice, not yet made.
- Where the max-iteration count lives (hook config, `.cc-os/config`, or hardcoded per stage)
and what the escalation-to-human path looks like concretely.
## Sources
- SecondBrain vault: `agentic-sdlc-ai-developer-workflow-taxonomy.md` (three-actors framing,
IndyDevDan)
- `~/dev/delta-refinery` (Pre/Post/Handoff pattern this generalizes from)
- `plugins/os-sdlc/OVERVIEW.md`
- ADR-0037, ADR-0042
- 2026-07-16 design session (this doc's origin)

View File

@ -0,0 +1,65 @@
# horizon
_Status: horizon — explicitly not being built yet; this doc exists to keep these OUT of v1 scope while preserving the design intent — as of 2026-07-16_
_Connects to: [pipeline-stages](pipeline-stages.md), [worktree-parallelism](worktree-parallelism.md), [never-ask-twice](never-ask-twice.md), [overview](../OVERVIEW.md)_
## Purpose
Parks the ideas that shaped the 2026-07-16 design session but are deliberately excluded
from v1, so they don't get silently reintroduced as scope creep and don't get lost either.
Load this when scoping any future os-sdlc milestone, or when a v1 stage's design seems to be
straining toward one of these — that strain is expected; the boundary is intentional.
## Design
- **Wake-on-trigger.** An issue state change (e.g. label added, ticket moved to "ready")
wakes the pipeline instead of a human running `/implement` manually. v1 is deliberately
manual — the factory is developed and iterated within interactive sessions until it's
trusted to run autonomously. Autonomy is earned the same way [never-ask-twice](never-ask-twice.md)
describes for individual decisions, applied here to the pipeline's own trigger.
- **Herdr integration** (https://herdr.dev/docs/, socket API at
https://herdr.dev/docs/socket-api/). A small deterministic controller — no LLM — opens
panes, starts interactive Claude Code sessions, sends stage prompts (send_keys-style),
detects completion, reads the result, and triggers the next stage. Rationale: Anthropic
now charges extra time allotment for headless runs, so driving automated coding through
headless mode would eat into the allotment the Max plan's coding was bought for; Herdr-driven
*interactive* sessions bill as normal usage instead, preserving that allotment for other
purposes (e.g. a future Hermes integration). **Load-bearing unknown to spike before leaning
on this at all: does the Herdr socket API give reliable "agent finished" detection, or only
heuristic pane-scraping?** If it's only heuristic scraping, completion-detection false
positives/negatives could silently corrupt pipeline stage transitions — this needs to be
proven, not assumed, before Herdr becomes load-bearing infrastructure.
- **Router agent** dispatching tickets to specialized pipelines (chore/bug/feature/hotfix,
each with a different stage shape). Deferred until v1's single pipeline shape is proven
insufficient — matches the ladder described in the SecondBrain vault taxonomy note; adding
a router before it's needed would be exactly the premature-granularity mistake
[pipeline-stages](pipeline-stages.md) already rejected once (the seam-creator/implementor
split).
- **Mobbin UX decision-tree seeding.** See [never-ask-twice](never-ask-twice.md) — a
pre-seeded convention library (mobbin.com) for the UI-flow decision category, letting
front-end decisions start at `semi` autonomy instead of `hitl`. Horizon because it depends
on the category-autonomy mechanism existing and being trusted first.
- **End-state vision** (verbatim intent from the user, 2026-07-16): plan alongside the AI in
one window; when ready, work is delegated, automatically prioritized, decomposed, and
worked until complete or blocked; blockages are resolved immediately AND minted into
ADRs/best practices/conventions so that next time the AI confidently picks the obvious path
or presents a narrowed choice; the factory self-improves over time and agents take agency
as confidence in their decisions is earned.
## Open questions
- Whether wake-on-trigger and Herdr integration are actually separable milestones (wake-on-trigger
could plausibly ship without Herdr, using headless mode at the accepted allotment cost) or
whether they're coupled in practice.
- What "trusted to run autonomously" concretely means as an exit criterion for v1 — likely an
ask-rate threshold fed by [never-ask-twice](never-ask-twice.md)'s audits, but not yet defined.
- Whether the router-agent ladder rungs (chore/bug/feature/hotfix) match the vault taxonomy
note's categories exactly or need adjustment once real ticket volume exists.
## Sources
- Herdr docs: https://herdr.dev/docs/, https://herdr.dev/docs/socket-api/
- SecondBrain vault: taxonomy/ladder note (chore/bug/feature/hotfix router categories)
- mobbin.com (design-pattern library)
- ADR-0037
- 2026-07-16 design session (this doc's origin, including verbatim end-state vision)

View File

@ -0,0 +1,76 @@
# never ask twice
_Status: direction — as of 2026-07-16_
_Connects to: [self-improvement-loops](self-improvement-loops.md), [spec-and-ticket-layer](spec-and-ticket-layer.md), [horizon](horizon.md), [overview](../OVERVIEW.md)_
## Purpose
The agency mechanism, and the most important node in this reference set. States the
operating principle that lets the factory earn autonomy over time instead of asking the
human the same question on every run. Load this before designing any blocked-stage
behavior, any human-input point in the pipeline, or ADR-0037's follow-on formalization ADR.
## Design
- **Operating principle: the factory never asks the human the same question twice.** Trust
is earned in units, and every unit is made durable and reusable. Goal state: the human is
only presented options when the factory genuinely needs direction — not because a decision
that was already made once was forgotten.
- No new storage system and no literal decision-tree engine. The "tree" is the existing ADR
corpus plus vault conventions; os-sdlc adds only wiring on top of os-adr and os-vault.
Three thin pieces:
1. **Two-tier lookup at block time.** Before any question reaches the human, the blocked
stage runs a mandatory, mechanical chain: `/os-adr:find` over the repo's ADRs first,
then `/os-vault:query` over `domain/`/`tool/`/`convention/` facets. Only a genuine miss
on both tiers produces a human-facing question.
2. **Scope classification at mint time.** When the human unblocks a stage by answering,
the factory asks one follow-up: "does this generalize beyond this repo?" No → record a
repo-local ADR only. Yes → a vault convention note becomes the source of truth, and the
repo ADR records the local adoption with a link back to it. The ask is not complete
until the record exists — an answered-but-unrecorded question is the failure mode this
whole mechanism exists to prevent.
3. **Promotion by audit, not memory.** A decision minted local that later blocks a second
project is a recurrence — visible across blocked-stage logs, not tracked live. A
periodic audit (see [self-improvement-loops](self-improvement-loops.md)) sees the
recurrence and promotes the answer to a vault convention.
- **Decision-category autonomy labels**, generalized from individual tickets to decision
*categories* (e.g. dependency choice, schema-migration approach, UI flow pattern):
- `afk-ready` + a covering record → proceed and cite the record.
- `semi` → proceed, but flag the decision in the stage report for human review.
- `hitl`, or no coverage at all → stop and ask.
Promotion and demotion of categories happens only in periodic audits, never mid-run.
- **Cross-project answer, worked example:** a decision minted while building rails app A
should be available when building rails app B. Repo ADRs are deliberately project-local
(they record local adoption, not universal truth); the vault is the cross-project layer.
os-sdlc doesn't change this split — it adds the lookup chain, the mint ritual, and the
audit-driven promotion path on top of it.
- **Honest limit:** this mechanism guarantees the *plumbing* — a decision recorded once is
findable everywhere it applies — not the *judgment* that the agent applies the right
convention in an ambiguous case. Judgment quality is proven only by accumulated audits.
Expect the ask-rate to fall as categories graduate from `hitl`/`semi` to `afk-ready`; a
category that graduates too early gets demoted, with the ADR trail showing exactly which
decision went wrong and why.
- **Mobbin** (mobbin.com, a design-pattern reference library) as a future pre-seeded
convention library for the UI-flow decision category — would let front-end decisions start
at `semi` instead of `hitl` from day one. This is horizon-scoped (see
[horizon](horizon.md)) but validates that the category-autonomy frame generalizes to
externally-sourced conventions, not just self-mined ones.
- This mechanism spans os-sdlc, os-adr, and os-vault jointly and gets its own ADR when
formalized — it is not owned by any single plugin.
## Open questions
- Block-report format: what a blocked stage writes when it stops to ask (must carry enough
context for the two-tier lookup to be re-run later without re-asking).
- Where decision-category labels live (ADR frontmatter? a small config file? vault note
frontmatter?) and who assigns the initial label for a brand-new category.
- Audit cadence for promotion/demotion (tied to [self-improvement-loops](self-improvement-loops.md)'s
ADR-audit loop, but not yet fixed).
## Sources
- ADR-0037 (`docs/adr/0037-os-sdlc-lives-inside-cc-os-as-a-new-plugin-not-a-separate-cc-sdlc-marketplace.md`)
- os-adr `find`/`create` skills; os-vault `query`/`write` skills
- SecondBrain vault: taxonomy/ladder note (referenced by [horizon](horizon.md))
- mobbin.com (design-pattern library, horizon reference)
- 2026-07-16 design session (this doc's origin)

View File

@ -0,0 +1,46 @@
# pipeline observability
_Status: direction — as of 2026-07-16_
_Connects to: [pipeline-stages](pipeline-stages.md), [self-improvement-loops](self-improvement-loops.md), [never-ask-twice](never-ask-twice.md), [overview](../OVERVIEW.md)_
## Purpose
Defines what a pipeline run writes down — its exhaust — where it goes, and how long it
lives. The self-improvement loops and the never-ask-twice promotion mechanism are only as
good as this exhaust; it was implicit in the original design and is made explicit here.
Load this before designing any run report, audit input, or blocked-stage log.
## Design
- **Every run emits exhaust**: stages executed, actor per stage, gate outcomes (red/green,
iterations consumed), blocked-stage reports (the questions the pipeline could not
auto-resolve — [never-ask-twice](never-ask-twice.md)'s raw input), and a human-facing
summary. This is the feed the loops in
[self-improvement-loops](self-improvement-loops.md) consume.
- **Every exhaust artifact has a predefined lifespan** (composes with os-doc-hygiene):
run-scoped scratch dies with the worktree; run reports persist on the ticket; anything
durable gets *promoted* to its proper home (ADR, vault note, issue comment) rather than
accumulating as clutter. No unbounded log directories.
- **Copy → tag → audit pattern** (deterministic where possible, per the three-actor test):
the write side is a hook (code actor), the review side is the periodic audit (agent
actor). Reference case — ADRs: the ADR-writing skill becomes a named agent whose hook
copies every new ADR into the vault at write time; the ADR audit then reads only *virgin*
(untagged) vault copies, keeps the durable ones, revises for clarity where needed, and
tags them audited — the next round checks only new virgin ADRs. The same pattern
generalizes to other streams (blocked-stage reports, run reports): mechanical capture at
write time, audit consumes the untagged backlog, tagging marks progress.
## Open questions
- Run-report format and home: an issue comment on the ticket (durable, in-tracker, visible
at merge review) vs. a file artifact — leaning issue comment.
- Lifespan policy per artifact class (scratch / run report / blocked-stage report) and how
os-doc-hygiene enforces it.
- Which exhaust stream gets the copy-tag-audit hook first (ADRs are the obvious pilot).
## Sources
- 2026-07-16 design session — user-identified gap ("I assumed this mechanism was baked into
the concept") + the ADR copy-tag-audit proposal
- os-doc-hygiene plugin (lifespan enforcement)
- ADR-0037

View File

@ -0,0 +1,81 @@
# pipeline stages
_Status: direction — as of 2026-07-16_
_Connects to: [deterministic-gates](deterministic-gates.md), [agent-design-principles](agent-design-principles.md), [spec-and-ticket-layer](spec-and-ticket-layer.md), [worktree-parallelism](worktree-parallelism.md), [pipeline-observability](pipeline-observability.md), [overview](../OVERVIEW.md)_
## Purpose
Defines the v1 tracer-bullet pipeline shape for `/implement <ticket>`: the ordered stages,
which actor (code/engineer/agent) owns each, and the two decisions that keep the pipeline at
one-agent-one-job grain. Load this before designing any pipeline stage, skill, or agent
definition for os-sdlc.
## Design
Manually triggered by `/implement <ticket>`. Stages, in order:
1. **Ticket intake** (code) — reads the issue via the `os-backlog` tracker key
(`.cc-os/config`); no new ticketing system, per ADR-0037's composability constraint.
2. **Test-writer agent** — writes failing tests from the ticket, nothing else.
3. **Red-assert gate** (deterministic hook) — runs the suite; it MUST fail. A pass here means
vacuous tests or the feature already exists — either way the pipeline halts rather than
proceeding on a false signal.
4. **Programmer agent** — makes the tests pass. One agent, not a seam-creator/implementor
split.
5. **Green-assert gate** (deterministic hook) — suite + lint must pass. Failures are
auto-fed back into the same programmer's next turn; a max-iterations cap escalates to the
human rather than looping forever.
6. **Reviewer agent** — a *named agent* (not a skill), so its tool surface is minimal and
its context never leaks into the orchestrator's (see
[agent-design-principles](agent-design-principles.md)). It reads only a hook-assembled
context packet — test status from the green-assert gate (passing, unless max-iterations
escalated), a mechanically generated diff, and the spec/ticket — gathered into one
document by a code-actor hook before the agent is invoked; the reviewer runs nothing
itself. Two ordered checks: first tests align with the spec/ticket, then code aligns
with both tests and spec. Order matters — misaligned tests invalidate a "code passes
tests" result. ADR-0037's `/os-sdlc:review` (standards-conformance + spec-fidelity +
Fowler refactor smells) is satisfied by this same agent; if standalone review of an
arbitrary diff is wanted outside a pipeline run, a thin skill wrapper dispatches the
agent with a hand-assembled packet — one reviewer, two entry points.
7. **Cleanup & report** (agent/code mix) — trims scratch artifacts, produces a summary for the
human. What gets written, where, and for how long is defined in
[pipeline-observability](pipeline-observability.md).
8. **Human merge gate** — v1 has no automated merge; see
[worktree-parallelism](worktree-parallelism.md).
Two decisions fix the grain of this pipeline:
- **One programmer agent, not seam-creator/implementor.** The seam is not a judgment
boundary — splitting it adds a handoff and context loss for no trust gain. This is exactly
the over-granularity DeltaRefinery suffered from. Revisit only if audits show programmers
entangling design decisions to cheat tests (i.e., gaming the green-assert gate instead of
solving the ticket).
- **Refactor is not a standing stage.** The reviewer's critique triggers a correction loop —
re-invoking the programmer with findings — using the same feedback mechanism as a failing
gate, rather than a dedicated pipeline stage.
Grain: one ticket, one vertical slice. TDD is internal to the stage (test-writer then
programmer within one ticket's run), never orchestrated per-example from outside — that
per-example granularity was a DeltaRefinery downside being deliberately dropped.
## Open questions
- Skill/agent naming for each stage (test-writer, programmer, reviewer) — must follow the
cc-os naming convention before anything ships.
- Whether a cheap tests-vs-spec review should run *before* the programmer stage in later
versions, to catch misaligned tests before paying for implementation.
- Shape settled 2026-07-16: `/implement` (or our custom successor) is a *skill acting as
orchestrator* — slash commands are the user-invocable layer, agents aren't. The skill
triggers the agent pipeline, watches over it, adjusts to surprises, and keeps the user
in the loop on progress/results. Remaining question: how much of Pocock's `implement`
wording survives vs. a from-scratch orchestrator prompt.
## Sources
- ADR-0037 (`docs/adr/0037-os-sdlc-lives-inside-cc-os-as-a-new-plugin-not-a-separate-cc-sdlc-marketplace.md`)
- ADR-0042 (2026-07-16, Planka retirement / git-issues-only os-backlog)
- `plugins/os-sdlc/OVERVIEW.md`
- SecondBrain vault: `agentic-sdlc-ai-developer-workflow-taxonomy.md`,
`matt-pocock-skills-v1-1-changes.md`
- `~/dev/delta-refinery` (prior art, principles kept/dropped as noted above)
- 2026-07-16 design session (this doc's origin)

View File

@ -0,0 +1,53 @@
# plugin factory
_Status: direction — as of 2026-07-16_
_Connects to: [deterministic-gates](deterministic-gates.md), [self-improvement-loops](self-improvement-loops.md), [pipeline-stages](pipeline-stages.md), [overview](../OVERVIEW.md)_
## Purpose
States that os-sdlc is the same factory cc-os uses to build its own `os-*` plugins, not a
separate meta-tool — and how that reflexive use case is possible without a second pipeline.
Load this before scaffolding a new `os-*` plugin, or before designing anything that looks
like a "plugin builder" separate from the ticket pipeline in
[pipeline-stages](pipeline-stages.md).
## Design
- **The same factory builds cc-os plugins from a PRD.** This works because the green command
is pluggable per project (see [deterministic-gates](deterministic-gates.md)): for a normal
application repo it's `rake test` + lint; for an AiDD-type project (a cc-os plugin) **the
eval harness IS the test suite**. TDD becomes eval-driven development — write the eval,
watch it fail for the right reason, build the skill until it passes — with no separate
pipeline, no special-cased plugin-building mode. The pipeline stages in
[pipeline-stages](pipeline-stages.md) run unchanged; only the green-command config value
differs.
- **Plugins self-evolve after they ship.** Once a plugin exists, its own operation becomes
input back into the same factory: audits of sessions it participated in (loop a),
ADR-corpus audits touching its decisions (loop b), its own eval-harness regressions (loop
c), and direct user feedback all generate improvement tickets. Those tickets re-enter
factory intake exactly like any other ticket — a plugin is never "done," it's a standing
client of its own factory. See [self-improvement-loops](self-improvement-loops.md) for the
four loops that produce this feedback.
- This closes the loop cc-os has been running informally (build a plugin, notice drift in
use, fix it in a follow-up session) into something the factory itself can drive
end-to-end: PRD → spec → tickets → implement → ship → audit → new tickets.
## Open questions
- PRD template for plugins specifically — likely narrower than a general product PRD (needs
to name: the eval harness location, the green command, which existing `os-*` plugins it
composes with per ADR-0037's composability constraint).
- Eval-first scaffolding: the red-assert analog for eval-driven development — does
`to-tickets`/`/implement` need a variant stage that writes the eval scenario before the
skill exists, mirroring the test-writer stage in [pipeline-stages](pipeline-stages.md)?
- How improvement tickets are auto-generated from audit findings — format, who/what creates
the issue (a code-actor script parsing an audit report, or an agent stage), and how
duplicate/overlapping findings across the four loops get deduplicated before hitting the
tracker.
## Sources
- ADR-0037 (`docs/adr/0037-os-sdlc-lives-inside-cc-os-as-a-new-plugin-not-a-separate-cc-sdlc-marketplace.md`)
- `plugins/os-sdlc/OVERVIEW.md`
- `plugins/os-vault/eval/`, `plugins/os-adr/eval/`, `plugins/os-context/eval/`
- 2026-07-16 design session (this doc's origin)

View File

@ -0,0 +1,71 @@
# self improvement loops
_Status: direction — as of 2026-07-16_
_Connects to: [never-ask-twice](never-ask-twice.md), [plugin-factory](plugin-factory.md), [pipeline-observability](pipeline-observability.md), [overview](../OVERVIEW.md)_
## Purpose
Names the four periodic audit loops that keep the factory (and the plugins it builds)
honest over time, and which of them already exist vs. are new. Load this before designing
any audit skill, before scoping ADR-audit work, or before wiring an improvement-ticket
generator into the factory.
## Design
Four loops, each catching a different kind of drift. None of them fire mid-run — all are
periodic, out-of-band audits, deliberately kept separate from the pipeline itself so an
audit's own cost/latency never blocks a ticket.
- **a. Session audits** (exists) — os-context's biweekly IRL audit. Catches behavior drift
in how sessions actually run. Needs rework per cc-os Forgejo issue #71: current criteria
don't catch expensive-model overuse where delegation or determinism would cut cost with no
quality drop — a gap directly relevant to os-sdlc's actor-choice discipline (see
[deterministic-gates](deterministic-gates.md)'s code/engineer/agent framing).
- **b. ADR audits** (new, proposed 2026-07-16) — catches corpus drift, the counterpart to
session audits but aimed at the decision record itself rather than session transcripts.
Periodically audits the ADR corpus for: stale decisions, missed promotions to the vault
(a local ADR that should have graduated per
[never-ask-twice](never-ask-twice.md)'s promotion-by-audit step but didn't), and
scope-classification judgment calls that slipped through at mint time. A wrong mint-time
call is treated as a delay, not a loss — anything missed gets caught in the next ADR
audit, so the loop is self-correcting rather than requiring the mint-time call to be
perfect. Proposed mechanics (2026-07-16, user): the copy-tag-audit pattern from
[pipeline-observability](pipeline-observability.md) — a hook copies every new ADR into
the vault at write time; the audit reads only *virgin* (untagged) copies, keeps the
durable ones, revises for clarity, tags them audited; the next round checks only new
virgin ADRs. This makes the capture side deterministic and the audit's workload bounded.
- **c. Eval harnesses** (exists — autoresearch pattern, already running for os-vault,
os-adr, os-context) — catches capability regression. Functions as "the AI version of TDD"
for plugin-type projects: a plugin's eval suite is the thing that must stay green across
changes, the same role a test suite plays for application code (see
[plugin-factory](plugin-factory.md) for the eval-as-green-command connection).
- **d. Skill-lint** (new, cc-os Forgejo issue #72) — catches skill-authoring drift. os-context
lints `SKILL.md` files for progressive disclosure on complex tasks and reasonable file
sizes, keeping agent context windows lean as the skill corpus grows.
Together, these four loops are:
- the **promotion engine** for [never-ask-twice](never-ask-twice.md) — (b) is the mechanism
that actually performs the audit-driven promotion that mechanism depends on;
- the **self-evolution channel** for [plugin-factory](plugin-factory.md) — (a), (c), and (d)
are exactly the inputs that turn into improvement tickets fed back into factory intake.
## Open questions
- ADR-audit criteria: what specifically counts as "stale" and "durable," and cadence.
(Mechanical detection is now sketched via the copy-tag-audit pattern above — remaining
question is the judgment rubric the audit applies to each virgin ADR.)
- Whether ADR audits live in os-context (alongside the existing session-audit skill) or in
os-adr (alongside `find`/`create`/`migrate`) — an ownership question, not yet settled.
- Whether (a)-(d) should share a common audit-report format so
[plugin-factory](plugin-factory.md)'s improvement-ticket generator can consume all four
uniformly, or whether each stays bespoke.
## Sources
- os-context `audit-sessions` skill (existing biweekly IRL audit)
- cc-os Forgejo issue #71 (session-audit rework, expensive-model overuse)
- cc-os Forgejo issue #72 (skill-lint)
- `plugins/os-vault/eval/`, `plugins/os-adr/eval/`, `plugins/os-context/eval/` (autoresearch
eval harnesses)
- ADR-0037
- 2026-07-16 design session (this doc's origin)

View File

@ -0,0 +1,58 @@
# spec and ticket layer
_Status: settled direction — as of 2026-07-16_
_Connects to: [pipeline-stages](pipeline-stages.md), [worktree-parallelism](worktree-parallelism.md), [never-ask-twice](never-ask-twice.md), [overview](../OVERVIEW.md)_
## Purpose
States where specs and tickets live and how a plan becomes an increment `/implement` can
consume. Load this before designing `to-spec`, `to-tickets`, or any skill that reads/writes
issue-tracker state for os-sdlc.
## Design
- **Git issues are the durable spec layer.** Forgejo (via `tea`) or GitHub (via `gh`),
resolved per-project by the os-backlog tracker key in `.cc-os/config`
(`forgejo:owner/repo`, `github:owner/repo`, or `repo:path` — ADR-0042). Planka is retired;
there is no separate os-sdlc storage for specs or tickets.
- **Composability constraint (ADR-0037):** os-sdlc consumes `os-backlog` rather than
reinventing ticketing. Ticket intake, creation, and listing go through os-backlog's
`issue-create`/`issues` CLI wrappers, not a parallel os-sdlc data model.
- **Pocock lifecycle mapping**, adapted to the git-issues substrate:
- **wayfinder** — big plans, new ideas. Free-form exploration, not yet tracker-shaped.
- **grill-with-docs** — sharpens or decomposes a plan further where user direction is
genuinely needed (see [never-ask-twice](never-ask-twice.md) for when a question should
instead be answered by lookup rather than posed here).
- **to-spec** — publishes the sharpened plan to the issue tracker as a spec-bearing issue
(or epic-equivalent) — the durable artifact other stages read from.
- **to-tickets** — decomposes the spec into feature-specific, independently-implementable
increments, each its own issue, tracer-bullet style. Confirmed 2026-07-16: this is
Pocock's v1.1 *rename* of `to-issues` ("a spec defines the destination; tickets are the
journey"), not a sibling — the leftover `to-issues` skill was removed the same day.
- **`/implement`** — consumes exactly one ticket into the [pipeline-stages](pipeline-stages.md)
pipeline. One ticket in, one worktree/branch out (see
[worktree-parallelism](worktree-parallelism.md)).
- The tracker is the single source of truth for spec *and* ticket state — no shadow status
file. Stage reports (pipeline-stages) update the issue via comments/labels rather than a
separate os-sdlc-owned record.
- Spec and ticket content structure is intentionally not fixed yet (see Open questions) —
the mapping above fixes *where* things live and *what stage produces them*, not their
internal template.
## Open questions
- Which Pocock skills (wayfinder, grill-with-docs, to-spec, to-tickets) are adopted
near-verbatim vs. adapted for the git-issues substrate vs. skipped entirely.
- Whether wayfinder subsumes or sits alongside a DeltaRefinery-style level structure
(DeltaRefinery decomposed work into explicit levels; Pocock's wayfinder is looser).
- Concrete spec issue template (sections, required fields) and ticket issue template
(acceptance criteria shape, tracer-bullet slice size).
## Sources
- ADR-0037 (`docs/adr/0037-os-sdlc-lives-inside-cc-os-as-a-new-plugin-not-a-separate-cc-sdlc-marketplace.md`)
- ADR-0042 (2026-07-16, Planka retirement / git-issues-only os-backlog)
- `plugins/os-sdlc/OVERVIEW.md`
- SecondBrain vault: `matt-pocock-skills-v1-1-changes.md`
- `plugins/os-backlog/` (tracker key, `issue-create`/`issues` CLI)
- 2026-07-16 design session (this doc's origin)

View File

@ -0,0 +1,49 @@
# worktree parallelism
_Status: direction — as of 2026-07-16_
_Connects to: [pipeline-stages](pipeline-stages.md), [spec-and-ticket-layer](spec-and-ticket-layer.md), [horizon](horizon.md), [overview](../OVERVIEW.md)_
## Purpose
Defines how os-sdlc scopes concurrency: worktree-per-spec, sequential tickets within a spec,
parallelism across specs. Load this when designing worktree lifecycle automation or any
multi-ticket/multi-spec orchestration for os-sdlc.
## Design
- **Worktree-per-spec.** Tickets belonging to one spec run sequentially inside a single
worktree — this avoids intra-spec merge conflicts entirely, since only one pipeline run is
ever touching that worktree's files at a time.
- **Parallelism happens across specs**, not within one. Multiple specs, each with its own
worktree and its own sequential ticket queue, can run concurrently to keep overall progress
rolling without any single spec's pipeline blocking another's.
- **Merge is a human gate in v1** — consistent with [pipeline-stages](pipeline-stages.md)'s
final stage. No automated merge choreography yet.
- **Tiered isolation ladder** (from the vault taxonomy note): no isolation → single worktree →
N parallel worktrees → full sandbox. os-sdlc v1 sits at "single worktree" per spec; a router
agent that picks the isolation tier per task is explicitly deferred until a concrete case
proves it's needed — not built ahead of that need.
- **Known bug — cc-os Forgejo issue #73:** `.cc-os/config` is untracked, so worktrees don't
inherit the tracker key, and `os-backlog` issue-create fails inside them. This blocks
ticket-intake (stage 1 of [pipeline-stages](pipeline-stages.md)) from working inside a
worktree today. The factory needs a native fix — either walking up to the owning repo root
to find the config, or provisioning `.cc-os/config` at worktree-creation time.
## Open questions
- Whether Pocock's `wayfinder:map` tags should drive merge choreography — check the vault
note `matt-pocock-skills-v1-1-changes.md` when designing this; not yet evaluated against
os-sdlc's spec/ticket model.
- Worktree lifecycle automation: who creates a worktree for a new spec, who cleans it up after
merge, and what triggers each.
- Branch naming convention for spec-level and (if they ever become worth it) per-ticket
branches under a spec branch.
## Sources
- SecondBrain vault: `agentic-sdlc-ai-developer-workflow-taxonomy.md` (tiered isolation
ladder), `matt-pocock-skills-v1-1-changes.md` (wayfinder:map)
- cc-os Forgejo issue #73 (`.cc-os/config` untracked, worktree tracker-key inheritance bug)
- ADR-0042 (2026-07-16, Planka retirement / git-issues-only os-backlog)
- `plugins/os-sdlc/OVERVIEW.md`
- 2026-07-16 design session (this doc's origin)

View File

@ -0,0 +1,20 @@
# os-shortcuts
The "keybindings" of cc-os: user-invoked QOL shortcut commands — ritual
multi-step sequences run on demand, never via hook injection. See
[docs/adr/0028](../../docs/adr/0028-os-shortcuts-plugin-for-user-invoked-qol-commands-bounded-against-hook-injection-plugins.md)
for the boundary this plugin is scoped against: it exists specifically to
hold user-invoked commands so hook-injection plugins (os-context, os-status,
etc.) don't accumulate them.
## Component map
- `skills/wrap/SKILL.md` — the only skill so far: the session-close ritual
(review changes → commit → sweep affected docs/todos → handoff summary).
Invoked by `/os-shortcuts:wrap`. No hooks — runs only when explicitly
invoked.
- `invariants.md` — reversion-protection contract for `wrap`'s guarantees.
More shortcuts are expected to accumulate here (per `plugin.json`'s own
description) — add a line above per skill as they land, and a matching
section in `invariants.md` once its behavior is worth protecting.

View File

@ -0,0 +1,21 @@
# os-shortcuts behavioral invariants
Reversion protection for the `wrap` skill. Any change that breaks one of these
is a regression, not a refactor. No automated test suite exists yet (the
skill is prompt-only, no scripts) — enforced by review of `skills/wrap/SKILL.md`
against this list.
1. **Never auto-invoked.** `wrap` has no hook wiring and runs only when the
user explicitly triggers it ("wrap up", "wrap", end-of-session). It must
never fire from a `SessionStart`/`SessionEnd`/other hook.
2. **Never force-pushes or skips hooks.** Commits made during `wrap` follow
this repo's standing git-safety rules: never `--no-verify`, never force-push,
new commits rather than amends.
3. **Session-close ritual runs in order: review → commit → sweep → handoff.**
The handoff summary (step 4) is always the final message, and always
covers Done / In flight / Next steps — it must not be skipped even when
steps 23 found nothing to do.
4. **No fabricated doc-update conventions.** The docs/todo sweep (step 3)
only touches files a project's own `CLAUDE.md` (or discernible existing
convention) identifies as a source of truth; if none exists, `wrap` skips
the step and says so rather than inventing structure.

View File

@ -0,0 +1,40 @@
# os-status
Aggregated, in-process `SessionStart` status checks for the cc-os plugin
family: per-project artifact checks (ADR system, vault hub note, project
graph, tracker config, config version) and environment hazard checks
(subagent model env override). See
[docs/adr/0022](../../docs/adr/0022-os-status-plugin-aggregated-in-process-sessionstart-status-checks.md)
for why checks are aggregated into one plugin instead of scattered per-plugin
hooks.
At most one `systemMessage` banner per session (one line per surviving warn);
`note`-level results bypass snooze/suppress and go out as `additionalContext`.
Snooze/suppress state lives under `.cc-os/status/` (ADR-027, with legacy
top-level fallback read).
## Component map
- `hooks/checks.py` — the check registry (`REGISTRY` at the bottom of the
file) plus each check function and the `--json` CLI entry point the `fix`
skill drives. Current checks: `subagent-model-env-override`,
`adr-system-present`, `vault-hub-note-present`, `orchestration-audit-due`,
`tracker-configured`, `config-version-current`, `project-graph-present`.
Read `checks.py` directly for exact check logic — this list is a pointer,
not a restatement.
- `hooks/session_start.py` — the `SessionStart` hook entry point; runs the
registry, applies snooze/suppress, emits the single banner.
- `hooks/state.py` — project-root resolution, `.cc-os/config` read, snooze/
suppress state read/write.
- `hooks/hooks.json``SessionStart` wiring.
- `skills/fix/SKILL.md``/os-status:fix`: remediates whatever the registry
flags, idempotent, doubles as the update-to-current-approach path
(ADR-026).
- `invariants.md` — the reversion-protection contract (8 numbered
invariants, including byte-identical wording parity with os-adr's
session_start.py); enforced by `tests/hook_test.py`.
- `tests/hook_test.py` — 80 tests, model-free (`python3 tests/hook_test.py`).
Adding a check means adding a function + a `Check(...)` entry to `REGISTRY` in
`checks.py`, following the uniform `check(ctx) -> CheckResult` signature
(invariant #8) — do not special-case a new check's wiring elsewhere.

View File

@ -198,10 +198,16 @@ 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 /os-backlog:route to pick a tracker and" " unregistered. Run /os-backlog:route to pick a tracker and"
" record it (planka:<board> | forgejo:<owner>/<repo> |" " record it (forgejo:<owner>/<repo> |"
" github:<owner>/<repo> | repo:<path>)." " github:<owner>/<repo> | repo:<path>)."
) )
TRACKER_PLANKA_RETIRED_NOTE = (
"planka: trackers are retired (ADR-0042 — git issues are the single"
" tracker for state and specs). Run /os-backlog:route to re-route to"
" forgejo:/github:/repo:."
)
# --- config version (ADR-026) --------------------------------------------- # --- config version (ADR-026) ---------------------------------------------
CURRENT_CONFIG_VERSION = 1 CURRENT_CONFIG_VERSION = 1
@ -238,7 +244,6 @@ def project_graph_present(ctx: Ctx) -> CheckResult:
) )
_TRACKER_PATTERNS = { _TRACKER_PATTERNS = {
"planka": re.compile(r"^[^/\s]+$"),
"forgejo": re.compile(r"^[^/\s]+/[^/\s]+$"), "forgejo": re.compile(r"^[^/\s]+/[^/\s]+$"),
"github": re.compile(r"^[^/\s]+/[^/\s]+$"), "github": re.compile(r"^[^/\s]+/[^/\s]+$"),
"repo": re.compile(r"^\S+$"), "repo": re.compile(r"^\S+$"),
@ -248,17 +253,20 @@ _TRACKER_PATTERNS = {
def tracker_configured(ctx: Ctx) -> CheckResult: def tracker_configured(ctx: Ctx) -> CheckResult:
"""Read the 'tracker' key from .cc-os/config. Present + valid -> silent """Read the 'tracker' key from .cc-os/config. Present + valid -> silent
(configured projects cost nothing at session start); present + malformed (configured projects cost nothing at session start); present + malformed
-> one-line warn, never a crash; absent -> daily-snoozed nudge toward the -> one-line warn, never a crash; a retired `planka:` value -> one-line
os-backlog routing skill.""" warn naming ADR-0042; absent -> daily-snoozed nudge toward the
os-backlog routing skill. Always fail-soft: never blocks the session."""
raw = str(ctx.config.get("tracker", "")).strip() raw = str(ctx.config.get("tracker", "")).strip()
if not raw: if not raw:
return warn(TRACKER_ABSENT_NOTE) return warn(TRACKER_ABSENT_NOTE)
scheme, sep, rest = raw.partition(":") scheme, sep, rest = raw.partition(":")
if scheme == "planka":
return warn(TRACKER_PLANKA_RETIRED_NOTE)
pattern = _TRACKER_PATTERNS.get(scheme) pattern = _TRACKER_PATTERNS.get(scheme)
if not sep or not pattern or not pattern.match(rest): if not sep or not pattern or not pattern.match(rest):
return warn( return warn(
f".cc-os/config sets tracker = '{raw}', which does not match the" f".cc-os/config sets tracker = '{raw}', which does not match the"
" expected grammar (planka:<board> | forgejo:<owner>/<repo> |" " expected grammar (forgejo:<owner>/<repo> |"
" github:<owner>/<repo> | repo:<path>) — fix the value." " github:<owner>/<repo> | repo:<path>) — fix the value."
) )
return OK return OK

View File

@ -50,13 +50,14 @@ like a `.gitignore` addition or a config stamp.
c. **`project-graph-present`** → invoke `/os-vault:onboard-project`. c. **`project-graph-present`** → invoke `/os-vault:onboard-project`.
d. **`tracker-configured`** → invoke `/os-backlog:route` directly and let it run d. **`tracker-configured`** → invoke `/os-backlog:route` directly and let it run
its own inspect → propose → confirm flow. Do NOT pre-ask the user "forgejo or its own inspect → propose → confirm flow. Per ADR-0042, one tracker holds both
planka?" — that framing is wrong: the boundary rule uses BOTH (Planka = state, task state and durable specs — there is no second surface to weigh, so don't
git issues = specs; a project routinely keeps its git-issue spec chains AND a pre-ask the user "forgejo or github or repo?" either; the route skill owns that
`planka:` tracker key for new ad hoc state). The route skill owns that decision conversation and its gates. A `planka:` value found in an existing
decision conversation and its gates. If `/os-backlog:route` is not installed, `.cc-os/config` is rejected fail-soft (one line citing ADR-0042) by both
tell the user and skip — do not fabricate a `.cc-os/config` tracker value `config-write` and this check — the fix is the same `/os-backlog:route` call.
yourself. If `/os-backlog:route` is not installed, 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 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 for a project-level fix.** This is an environment/settings.json condition, not

View File

@ -24,6 +24,7 @@ from checks import ( # noqa: E402
ENV_VAR, ENV_VAR,
PRESENT_NOTE, PRESENT_NOTE,
TRACKER_ABSENT_NOTE, TRACKER_ABSENT_NOTE,
TRACKER_PLANKA_RETIRED_NOTE,
Check, Check,
CheckResult, CheckResult,
Ctx, Ctx,
@ -250,11 +251,14 @@ class TrackerCheckTest(unittest.TestCase):
self.assertEqual(CheckResult("warn", TRACKER_ABSENT_NOTE), result) self.assertEqual(CheckResult("warn", TRACKER_ABSENT_NOTE), result)
self.assertIn("/os-backlog:route", result.message) self.assertIn("/os-backlog:route", result.message)
def test_planka_value_is_ok(self): def test_planka_value_is_rejected_citing_adr_0042(self):
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
root = git_project(tmp) root = git_project(tmp)
ctx = make_ctx(project_root=root, config={"tracker": "planka:my-board"}) ctx = make_ctx(project_root=root, config={"tracker": "planka:my-board"})
self.assertEqual("ok", tracker_configured(ctx).status) result = tracker_configured(ctx)
self.assertEqual(CheckResult("warn", TRACKER_PLANKA_RETIRED_NOTE), result)
self.assertIn("ADR-0042", result.message)
self.assertIn("/os-backlog:route", result.message)
def test_forgejo_value_is_ok(self): def test_forgejo_value_is_ok(self):
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
@ -285,7 +289,7 @@ class TrackerCheckTest(unittest.TestCase):
def test_missing_separator_warns_without_crashing(self): def test_missing_separator_warns_without_crashing(self):
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
root = git_project(tmp) root = git_project(tmp)
ctx = make_ctx(project_root=root, config={"tracker": "planka-my-board"}) ctx = make_ctx(project_root=root, config={"tracker": "bogus-my-board"})
result = tracker_configured(ctx) result = tracker_configured(ctx)
self.assertEqual("warn", result.status) self.assertEqual("warn", result.status)
@ -417,10 +421,10 @@ class WriteConfigValueTest(unittest.TestCase):
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
(root / ".cc-os").mkdir() (root / ".cc-os").mkdir()
(root / ".cc-os" / "config").write_text("hub = my-hub\ntracker = planka:board\n") (root / ".cc-os" / "config").write_text("hub = my-hub\ntracker = forgejo:jared/x\n")
write_config_value(root, "version", "1") write_config_value(root, "version", "1")
self.assertEqual( self.assertEqual(
{"hub": "my-hub", "tracker": "planka:board", "version": "1"}, {"hub": "my-hub", "tracker": "forgejo:jared/x", "version": "1"},
read_config(root), read_config(root),
) )

View File

@ -0,0 +1,39 @@
# os-vault
Integrates the SecondBrain Obsidian vault (semantic/knowledge memory, ADR-012) and
per-project Graphify knowledge graphs into Claude sessions. Episodic memory is a
separate system (memsearch, ADR-015) — see `README.md` for the requirements/version
pins and `config.yaml` for vault path, model, and threshold knobs.
## Component map
- `skills/` — five skills: `query`, `write`, `reorganize`, `onboard-project`,
`design-template`.
- `hooks/``session_start.py` (SessionStart), `session_context.py`
(UserPromptSubmit), `post_tool_use_write.py` (PostToolUse), `session_end.py` +
`memsearch_sync.py` + `vault_sync.py` (SessionEnd); shared `config.py`,
`hook_io.py`, `session_state.py`. See "Hook wiring" below.
- `tests/` — golden-file harness (`harness.sh`, `golden/`, `inputs/`,
`python-wrappers/`); own `tests/README.md`.
- `eval/` — write-behavior eval harness (scenarios + `scenarios-reserve/`,
judge-rubric, results); eval discipline (reserves never read informally) per
root `CLAUDE.md`.
- `invariants.md` — reversion-protection contract; read before changing frontmatter
facets, hook fail-soft behavior, or sync timing.
- `config.yaml` — vault_path, graphify model, thresholds, env overrides.
## Hook wiring
os-vault's hooks are **not** wired via a plugin `hooks/hooks.json` — there isn't
one, and this is deliberate, not a defect. They're registered at user level in
`~/.claude/settings.json`, pointing at the repo source scripts directly:
`SessionStart``hooks/session_start.py`, `UserPromptSubmit`
`hooks/session_context.py`, `PostToolUse``hooks/post_tool_use_write.py`,
`SessionEnd``hooks/session_end.py` + `hooks/memsearch_sync.py` +
`hooks/vault_sync.py`.
## Pointers
- Design: `docs/memory-system/02-system-design.md`. Decisions: ADR-011 (facets),
ADR-012 (vault as semantic source of truth), ADR-015 (memsearch split).
- Status/build history: `docs/implementation-status/os-vault.md`.

View File

@ -40,6 +40,10 @@ Shared modules: `hooks/config.py`, `hooks/hook_io.py`, `hooks/session_state.py`.
- `skills/query/SKILL.md` — When and how to query the vault graph - `skills/query/SKILL.md` — When and how to query the vault graph
- `skills/write/SKILL.md` — When and how to write to the vault - `skills/write/SKILL.md` — When and how to write to the vault
- `skills/reorganize/SKILL.md` — Plan-mode vault consolidation - `skills/reorganize/SKILL.md` — Plan-mode vault consolidation
- `skills/onboard-project/SKILL.md` — Onboard, update, remove, or query the
per-project Graphify knowledge graph (codebase structure, this repo only)
- `skills/design-template/SKILL.md` — Design or revise SecondBrain note-type
templates, including new-type creation
## Configuration ## Configuration

View File

@ -1,6 +1,8 @@
# os-vault write-behavior eval — unprompted vault-write discrimination (held-out) # os-vault write-behavior eval — unprompted vault-write discrimination (held-out)
_Last updated: 2026-07-06 — harness built (WS2); baseline grid NOT yet run._ _Last updated: 2026-07-07 — harness built (WS2), baseline grid run, and the
wording loop complete and shipped; run-set and reserve are now contaminated for
future wording tuning (see `eval/results/`)._
Measures whether the model **unprompted** (a) writes evergreen cross-project knowledge to Measures whether the model **unprompted** (a) writes evergreen cross-project knowledge to
the SecondBrain vault when it surfaces during normal work, and (b) writes it with the SecondBrain vault when it surfaces during normal work, and (b) writes it with

View File

@ -21,7 +21,7 @@ except Exception: # pragma: no cover - yaml is provided via graphify
CONFIG_PATH = os.path.expanduser("~/.claude/plugins/os-vault/config.yaml") CONFIG_PATH = os.path.expanduser("~/.claude/plugins/os-vault/config.yaml")
DEFAULT_VAULT_PATH = "~/Documents/SecondBrain" DEFAULT_VAULT_PATH = "~/Documents/SecondBrain"
DEFAULT_GRAPHIFY_OUTPUT_DIR = "~/Documents/SecondBrain/.graphify" DEFAULT_GRAPHIFY_OUTPUT_DIR = "~/Documents/SecondBrain/graphify-out"
DEFAULT_MODEL = "qwen25-coder-7b-16k" DEFAULT_MODEL = "qwen25-coder-7b-16k"
DEFAULT_STALE_DAYS = 7 DEFAULT_STALE_DAYS = 7
DEFAULT_MEMSEARCH_DIR = "~/.memsearch" DEFAULT_MEMSEARCH_DIR = "~/.memsearch"

View File

@ -0,0 +1,40 @@
# os-vault invariants
Behavioral invariants of the plugin. Changing any of these requires explicit human approval.
Enforced by `tests/harness.sh` (golden fixtures) unless noted otherwise.
1. **Episodic and semantic memory stay separate systems.** Vault notes hold evergreen,
cross-project knowledge (ADR-012); session/event history goes to memsearch (ADR-015).
`/os-vault:query` never answers "what happened when" questions — those route to memsearch.
2. **Six flat namespaced facets, plus `scope`.** Note frontmatter tags are
`type/`/`client/`/`project/`/`domain/`/`tool/`/`convention/` (ADR-011); `scope`
(`global`/`project`/`client`) is a frontmatter field, not a tag. `type/` is required on
every note. Source of truth for the schema is `vault-conventions.md` at the vault root —
never duplicated or paraphrased into plugin code or skills.
3. **Hooks are fail-open, never block a session.** Every hook
(`session_start.py`, `session_context.py`, `post_tool_use_write.py`, `session_end.py`,
`memsearch_sync.py`, `vault_sync.py`) degrades silently on error; none may disrupt session
startup or shutdown.
4. **SessionStart returns sub-second.** Heavy work (graph rebuild) is detached to the
background; `session_start.py` only does the staleness check and kicks off the detached
rebuild. Context injection is a separate hook (`session_context.py`, UserPromptSubmit).
5. **Sync happens only at SessionEnd, in a fixed order.** `vault_sync.py` must run after
`session_end.py` so the daily journal note it writes is included in the commit;
`memsearch_sync.py` is a separate, independent SessionEnd entry (split by ADR-016) for the
memsearch repo. Neither auto-commit runs mid-session.
6. **Vault-not-repo rule.** `/os-vault:write` writes only to the resolved vault root
(`${OS_VAULT_PATH:-$HOME/Documents/SecondBrain}`), never to a project repository. The vault
path is resolved mechanically at the start of every write — never hardcoded.
7. **Write is query-first.** Before creating a new note, check for an existing note on the
same subject; a contradicting/extending fact updates the existing note (bumping
`last_updated`) rather than creating a duplicate.
8. **No silent new note types.** A fact that fits no existing type is filed under the closest
existing type with a visible `## Type-fit note` misfit marker, or escalated to
`/os-vault:design-template` — never given an invented type silently.