Compare commits

...

16 Commits

Author SHA1 Message Date
jared 58f330fdf3 os-adr: ADR-0024 addendum — apprise-api gem + CLI done, apprise-client retired
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuTaxjCa543Sf77Ti7zYkh
2026-07-13 16:13:43 -04:00
jared bd7615d962 os-context: audit-driven delegation wording — TDD-loophole + review-convergence gate (issues #29, #30)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SjyyMTpscUFMwXfF2BTaSC
2026-07-13 13:28:07 -04:00
jared 0879ef10ae os-context: orchestration prompt — TDD loops and confirmed-defect lists are delegable units
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUGCrg5osQMLUaSfkBdVZE
2026-07-13 13:26:28 -04:00
jared d303b957f5 os-backlog: board-ensure fails loudly when PLANKA_HUMAN_USER_ID is unset (card #1818408943538406701)
fix_visibility now raises MissingHumanUserIdError instead of silently
creating a bot-only Planka project when no human user id is available.
Existing projects still ensure without the env var. Supersedes card
#1818396164853073182.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUGCrg5osQMLUaSfkBdVZE
2026-07-13 13:26:27 -04:00
jared b0a21d6211 os-backlog: post-ship review corrections for the triage hook + project index (cards #1818330518584820864, #1818330528525321347)
Codex review + verification pass over f9a81a1/3708c00 found the triage
hook resolving boards differently from the CLI. Corrections:
- Config#planka_board: tracker=planka:<board> now counts as board
  configuration everywhere (Resolver included), not just the explicit
  board key that config-write never writes.
- Triage hook board resolution extracted to pure
  TriageCheck.board_name_for honoring the umbrella rule: the session
  cwd's .cc-os/config overrides the git root's (the hook previously
  walked up and read only the root config).
- config-write keys the global project index by the realpath of the
  directory the config lands in, not the git toplevel — umbrella
  subprojects no longer overwrite the umbrella's row.
- projects <filter> matches name, tracker, or path; hook timeout
  8s->15s; CC_OS_DEBUG=1 surfaces swallowed hook errors on stderr.
Residual (name-only board lookup across all Planka projects) captured
as Backlog card #1818385174308586768. Suite 138/295/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkeTPRWqaqJZEpf48H1aC7
2026-07-13 12:27:43 -04:00
jared 43932d40ab os-backlog: wakeup-polling + tmux-convention design spike (issue #28, ADR-0035/0036)
Poll + tmux direction chosen at the human gate. Thin pilot: Backlog::Wakeup
+ bin/wakeup-poll (dry-run default; opt-in wakeup=true; afk-ready acts,
semi triage-only, else pull-only notify). No cron installed. Suite 128/0.
Follow-up: single designated poller machine; cadence deferred to first opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
2026-07-13 11:02:44 -04:00
jared 3708c0099d os-backlog: wire the triage-trigger hook + arbitrary-project test coverage (cards #1818330518584820864, #1818330528525321347)
hooks/triage_check.rb and the Servers project heuristic/board-ensure
generalization already landed in f9a81a1 but the triage hook was never
registered in hooks.json, so it could never fire. This wires it in
(second SessionStart command, 8s timeout) and adds the missing test
coverage: TriageCheck's needs-triage/note-composition logic, an
arbitrary-project (Servers) BoardEnsurer case, and the ~/servers
resolver-ambiguity fix. Also reconciles session_start.py's pull-beats-push
docstring with the new hook's sanctioned exception, and fixes a stray
comment-indent bug in board_ensurer.rb. Suite 128/282/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLudjwx2GWw8CFdYaBzVzK
2026-07-13 11:00:26 -04:00
jared f9a81a17c5 os-backlog: cross-project filing convention + derived global project index (issue #27, ADR-0034)
Backlog::ProjectIndex at ~/.cc-os/projects.json, upserted fail-soft by
config-write, queried via new 'projects' subcommand. Discoverer template +
filing mechanics reference doc; CROSS-PROJECT rule line in the SessionStart
note. ADR-0034 Accepted. Suite 112/233/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
2026-07-13 10:48:19 -04:00
jared 9415eb0d65 os-backlog: route inspect issue-shape classification, split-by-kind default (issue #26)
inspect fetches remote-issue metadata (fail-soft) and emits issue_shape:
sequential-chain|flat-adhoc|mixed|null via a pure classifier. Route step 2
rewritten as three branches with split as the default for spec-shaped issue
sets (one tracker key, no migration); step 3 never-re-trigger exception;
boundary rule cites ADR-0033. Suite 103/212/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
2026-07-13 10:32:58 -04:00
jared 49c8f5eace os-backlog slice 8: Operations board + tracker-routing rubric + ADR-0033 (issue #17)
- ADR-0033 canonizes Planka=state / git-issues=specs, tracker key grammar,
  card-as-pointer and promotion rules (cites route/to-issues/session-start).
- os-context audit rubric: new category 8 tracker-routing (compliance only).
- BoardEnsurer label-color fallback also rescues Planka::BadRequestError
  (Planka rejects unknown colors as HTTP 400, found live) + regression test.
- os-status /os-status:fix step d: invoke /os-backlog:route directly instead
  of pre-asking forgejo-vs-planka (boundary rule uses both; cf. issue #26).
- Records updated per index+leaf convention.

Closes #17.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
2026-07-13 10:00:36 -04:00
jared 46b0584643 os-backlog: cache resolved Planka board id in .cc-os/config (issue #22)
- Backlog::BoardResolver with injected config read/write; Cards does no filesystem I/O
- board_id honored only for planka: trackers; id hit validated against returned board
  name; 404/mismatch/cache failure degrades to name lookup and rewrites the cache
- comment-preserving Config.set mirrors os-status's writer (Config.merge not used)
- FakePlankaClient boards.get 404 support; 14 new tests; suite 90 runs / 0 failures

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
2026-07-13 09:18:45 -04:00
jared 90db409721 os-backlog slice 7: SessionStart injection note + /to-issues tracker routing (issue #16)
- hooks/session_start.py + hooks.json: WHEN->THEN rules for capture, routing, promotion,
  column ownership, autonomy labels — ADR-0029 semantics (issue body's contract was wrong),
  rules only, zero board state, ~447 tokens injected
- Backlog::Tracker.issues_destination routes /to-issues output by the tracker key
- /to-issues skill itself (global, ~/.agents/skills/to-issues/) updated out-of-repo:
  planka:/unset default = git issues on the repo remote + one Planka pointer card
  (spec/state boundary preserved; human gate decided 2026-07-13)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
2026-07-13 09:18:45 -04:00
jared 15807ef342 docs: distill implementation-status.md to index+PD; ADR-0032 records the convention (issue #25 parts 2-3)
- implementation-status.md: 729 lines/~9k tokens -> index (~110 lines/~1.8k tokens);
  per-component detail in docs/implementation-status/ leaf files; operational runbooks
  preserved in operational-procedures.md; ADR-restated detail removed, not moved
- CLAUDE.md record-keeping rule and pointers updated (convention migration, not just reorg)
- ADR-0032 (Accepted): AI-maintained knowledge files are indexes with progressive
  disclosure; the os-doc-hygiene file_length signal enforces it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
2026-07-13 09:18:23 -04:00
jared ef8b7e4a52 os-doc-hygiene: deterministic file_length scanner signal (issue #25 part 1)
Objective signal (line count + estimated tokens) fires when either
threshold is exceeded; defaults 400 lines / 4000 tokens, configurable via
constructor and --max-lines/--max-tokens. Injectable token estimator for
tests. Suite: 286 passed. Parts 2-3 (implementation-status distill +
index+PD convention ADR) remain human-gated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
2026-07-13 08:06:48 -04:00
jared 46ebfb1b2f ADR-030: read-only work uses plan mode; no /readonly shortcut
Closes the /readonly backlog card as convention-only: plan mode enforces
read-only at the permission layer; a skill cannot activate plan mode and
would imply enforcement it doesn't have.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
2026-07-13 08:06:48 -04:00
jared 1ab1bf5f8c os-context: prompt-composer plugin supersedes os-orchestration; prompt files authored (ADR-031)
Rename plugins/os-orchestration -> plugins/os-context. SessionStart hook is
now a composer: globs prompts/session-start/*.md in filename order, emits one
additionalContext block, byte-identical for the existing orchestration rules
(tested), line budgets enforced (warn 120 / refuse 240). ORCHESTRATION.md ->
prompts/session-start/10-orchestration.md. New prompt files from the
2026-07-09 audit: 20-kickoff-conventions, 30-standing-safety,
40-decision-memos (total injection 102/120 lines). os-status audit-skill and
state-path references updated (old ~/.local/state path symlinked;
OS_ORCH_AUDIT_LEDGER name kept for compatibility).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
2026-07-13 08:06:48 -04:00
118 changed files with 3214 additions and 846 deletions

View File

@ -2,8 +2,9 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this
repository. It is orientation only — implementation history, component details, and
operational procedures live in [docs/implementation-status.md](docs/implementation-status.md)
(read it on demand, e.g. before touching a plugin or claiming something isn't built yet).
operational procedures live in the [docs/implementation-status.md](docs/implementation-status.md)
index and its `docs/implementation-status/` leaf files (read on demand, e.g. before touching
a plugin or claiming something isn't built yet).
## What this repository is
@ -32,9 +33,10 @@ topic you need.
is the architecture, `03-architecture-decisions.md` is the historical ADR log (see
`docs/adr/` above), `04-build-plan.md` the build outline with step status,
`06-graphify-evaluation.md` justifies the Graphify pivot.
- **`docs/implementation-status.md`** — the status timeline, full per-component detail for
every shipped plugin (os-vault, os-orchestration, os-status, os-doc-hygiene, os-adr),
eval-harness records, and plugin rename/cache-refresh procedures.
- **`docs/implementation-status.md`** — the status index: headline timeline + per-component
one-liners. Full detail (per-plugin build history, eval-harness records) lives in leaf
files under `docs/implementation-status/`, read on demand; plugin rename/cache-refresh
runbooks are in `docs/implementation-status/operational-procedures.md`.
- **`docs/graphify/`** — verified handbook for the Graphify knowledge-graph tool. Skim
`00-README.md` for the model; keep `09-best-practices-checklist.md` open when running it.
Claims are provenance-tagged (`[github]` trustworthy; `[interview]` / `[unverified claim]`
@ -69,16 +71,19 @@ is deferred to last (ADR-013).
## Implemented components (inventory)
All build-plan steps required for the memory system are complete (2026-06-15). Full detail
for each item is in [docs/implementation-status.md](docs/implementation-status.md) — read it
All build-plan steps required for the memory system are complete (2026-06-15).
[docs/implementation-status.md](docs/implementation-status.md) is the index; full detail per
item is in its leaf files under `docs/implementation-status/` — read the relevant leaf
before modifying any of these:
- **os-vault** (`plugins/os-vault/`) — vault write/query/reorganize/onboard-project/
design-template skills, SessionStart/End hooks, memsearch + vault git sync. Write-behavior
eval harness in `plugins/os-vault/eval/`.
- **os-orchestration** (`plugins/os-orchestration/`) — injects `ORCHESTRATION.md`
(session-orchestration + delegation-economics rules) into all sessions; this repo carries
no local override. Eval harness in `plugins/os-orchestration/eval/`.
- **os-context** (`plugins/os-context/`, renamed 2026-07-13 from os-orchestration) — a
prompt-composer SessionStart plugin: concatenates `prompts/session-start/*.md` (currently
just `10-orchestration.md`, the session-orchestration + delegation-economics rules) into
one additionalContext block for all sessions; this repo carries no local override. Eval
harness in `plugins/os-context/eval/`.
- **os-status** (`plugins/os-status/`) — aggregated deterministic SessionStart checks
(subagent-model env override, ADR system present, vault hub note present). ADR-022.
- **os-doc-hygiene** (`plugins/os-doc-hygiene/`) — stale/bloated-doc monitoring;
@ -131,10 +136,13 @@ OpenSpec artifacts comes from `docs/` and this file.
you make such a choice → record it with `/os-adr:create`. A task that reverses an Accepted
ADR is not complete until the superseding ADR exists.
- **Keep records current:** when a build step completes, (a) mark it done in
`docs/memory-system/04-build-plan.md`, (b) record it in
`docs/implementation-status.md` (timeline entry + component pointer), and (c) update the
design paragraph in THIS file only if the design itself changed. Never append status
history to this file — it goes in implementation-status.md.
`docs/memory-system/04-build-plan.md`, (b) record it per the index+progressive-disclosure
convention: a **one-line** headline entry in `docs/implementation-status.md` plus the
detail in the relevant `docs/implementation-status/<component>.md` leaf file (create one
if needed; don't restate what an ADR already records), and (c) update the design paragraph
in THIS file only if the design itself changed. Never append status history to this file,
and never let the index file regrow into a changelog — the os-doc-hygiene `file_length`
signal (400 lines / ~4k tokens) enforces this.
- **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
brief: plugins are `os-[domain]`; skills are verb-first kebab-case, invoked as
@ -142,4 +150,4 @@ OpenSpec artifacts comes from `docs/` and this file.
in SKILL.md frontmatter** (it collapses the slash command to a bare unnamespaced form).
- **After editing any plugin source** (SKILL.md, hooks, CLIs), run `bin/refresh-plugins`
source edits don't reach sessions until the cache is refreshed. Rename/move procedure and
cache details: docs/implementation-status.md → "Operational procedures".
cache details: `docs/implementation-status/operational-procedures.md`.

View File

@ -1,9 +1,9 @@
---
id: "0019"
date: 2026-07-03
status: Accepted
status: Superseded
supersedes:
superseded-by:
superseded-by: "0031"
affected-paths: []
affected-components: []
migration_confidence: medium

View File

@ -25,6 +25,8 @@ The `planka-api` gem's tick/digest delivery was specified as Pushover-direct (`P
Apprise replaces the originally planned Pushover-direct integration as the single notification transport, deployed as an Apprise API server so any service (Discord, Telegram, Bark, Home Assistant, etc.) can be targeted via configuration rather than new code, with Planka's native Apprise support converging on the same server. This iteration's scope stays minimal (server + a minimal Apprise client swapped in for the planned Pushover gem code), with a fuller client gem, CLI wrapper, and an os-notify plugin captured as later backlog work; the underlying notification-blindness policy is unchanged.
_Addendum 2026-07-13:_ the fuller client gem and CLI wrapper backlog items are done — `~/dev/ruby-gems/apprise-api` (gem `apprise-api`, module `Apprise`, v0.2.0) is the sole Apprise client, now including the `apprise-notify` CLI executable. The interim minimal client (`~/dev/ruby-gems/apprise`, gem `apprise-client`) was retired and deleted after live verification and a no-dependents check. Remaining backlog: the `os-notify` plugin and multi-service routing/Planka-native wiring.
## Alternatives rejected
**Pushover gem as planned** — single-service dead end; every future service would be a new integration. **Deferring Apprise entirely** — the server + minimal client is comparable effort to the Pushover path, so there is no cheaper interim.

View File

@ -0,0 +1,27 @@
---
id: "0030"
date: 2026-07-13
status: Accepted
supersedes:
superseded-by:
affected-paths: [plugins/os-shortcuts/]
affected-components: [os-shortcuts, plan-mode]
---
# 0030 — Read-only work uses Claude Code plan mode; no /readonly shortcut command
## Context
The 2026-07-09 AI-workflow audit (finding #2) showed the user typed 'read-only, do NOT change anything' near-verbatim 15+ times. A backlog card proposed either documenting a convention or building a /readonly skill in os-shortcuts. Claude Code plan mode already enforces read-only at the permission layer, while a prompt or skill saying 'don't change anything' is only advisory; os-shortcuts has no mechanism to switch the client into plan mode or enforce filesystem permissions, so a /readonly slash command would imply stronger enforcement than it actually has.
## Decision
The convention for read-only tasks is to start them in Claude Code plan mode (shift+tab / --permission-mode plan). No /readonly skill is built in os-shortcuts. Per-task read-only expectations are expressed by entering plan mode, not by injected or skill-carried phrasing.
## Consequences
Easier: read-only is enforced at the permission layer rather than trusted to prompt compliance; no new skill to maintain or cache-refresh; the os-shortcuts surface stays small. Harder: the user must remember the plan-mode keybinding instead of a memorable slash command; any standing read-only phrasing beyond 'no changes' (reporting expectations, output format) still has to be typed per task or captured elsewhere.
## Alternatives rejected
1) Build /os-shortcuts:readonly as a thin skill that instructs the model to behave read-only and states the user's expectations - rejected: skills cannot activate plan mode, so the command would be advisory-only while its name implies enforcement; a false sense of safety is worse than the keybinding. 2) Inject standing read-only rules via os-context session-start prompts - rejected: read-only is a per-task property, not an always-true rule; injecting it globally would contradict sessions that are supposed to write.

View File

@ -0,0 +1,27 @@
---
id: "0031"
date: 2026-07-13
status: Accepted
supersedes: "0019"
superseded-by:
affected-paths: [plugins/os-context/, plugins/os-status/hooks/checks.py]
affected-components: [os-context, os-orchestration, os-status]
---
# 0031 — os-context prompt-composer plugin supersedes single-file os-orchestration
## Context
ADR-0019 shipped os-orchestration, a global plugin whose SessionStart hook injected one hardcoded file (ORCHESTRATION.md). The 2026-07-09 AI-workflow audit surfaced further always-true context worth injecting (kickoff conventions, standing safety rules, decision-memo format), and a one-file plugin cannot host multiple independently maintained rule sets or enforce an injection budget across them.
## Decision
Rename/generalize os-orchestration into os-context (plugins/os-context/): the SessionStart hook is a composer that globs prompts/session-start/*.md, concatenates in filename order (numeric prefixes), and emits one additionalContext block. ORCHESTRATION.md became prompts/session-start/10-orchestration.md with byte-identical injected output. Deterministic line budgets are enforced in the composer (warn at 120 injected lines, refuse at 240). Future event directories (e.g. prompts/user-prompt-submit/) are anticipated by directory naming but only session-start is implemented; no prompts/session-end/ directory will exist because SessionEnd hooks cannot inject model-visible context (ADR-0028 context). The audit-sessions skill moved to /os-context:audit-sessions and its state dir to ~/.local/state/os-context (old path symlinked); os-status references updated in the same change.
## Consequences
Easier: new standing rules ship as small numbered prompt files instead of edits to one monolith; injection cost is bounded by an enforced budget; the plugin name matches its actual domain (session context, not just orchestration). Harder: one-time identity cutover across marketplace/settings/installed-plugin surfaces; historical docs and frozen eval results still say os-orchestration and must be read with the rename note in mind; the OS_ORCH_AUDIT_LEDGER env var name is retained for compatibility despite the rename.
## Alternatives rejected
1) Keep os-orchestration and append new rules to ORCHESTRATION.md - rejected: one monolithic file with no per-ruleset ownership or ordering, and no budget enforcement. 2) Separate plugin per rule set - rejected: each global plugin adds cache-refresh and sync overhead; these rule sets share one event and one budget. 3) Put the new rules in ~/.claude/CLAUDE.md - rejected: CLAUDE.md is not compaction-safe the way the SessionStart matcher (startup|resume|clear|compact) is, and the single-source rule keeps injected rules in exactly one place.

View File

@ -0,0 +1,27 @@
---
id: "0032"
date: 2026-07-13
status: Accepted
supersedes:
superseded-by:
affected-paths: [docs/implementation-status.md, docs/implementation-status/, plugins/os-doc-hygiene/scripts/scanner.py, CLAUDE.md]
affected-components: [docs, os-doc-hygiene]
---
# 0032 — AI-maintained knowledge files are indexes with progressive disclosure
## Context
AI-read markdown files grow unbounded when maintained append-only: docs/implementation-status.md reached 729 lines (~9k estimated tokens) and was read routinely at main-loop rates, paying the full context cost on every read even when only one component's status was needed. The os-doc-hygiene scanner gained a deterministic `file_length` signal (2026-07-13, issue #25 part 1; defaults 400 lines / 4000 estimated tokens) that now flags every oversized doc, but a signal without a target structure only reports the problem. The same growth pattern threatens CLAUDE.md and any other file the AI is instructed to keep current.
## Decision
AI-maintained knowledge files are **indexes with progressive disclosure**: the main file holds only headline entries and one-liners with pointers; full detail lives in leaf files (e.g. `docs/implementation-status/<component>.md`) read on demand. Detail that merely restates a decision already recorded in `docs/adr/` is removed, not moved — the ADR is the durable record. Operational runbooks referenced from CLAUDE.md land in a stable leaf file (`docs/implementation-status/operational-procedures.md`), never silently dropped. The os-doc-hygiene `file_length` signal (400 lines / ~4k tokens) is the enforcement mechanism: a flagged AI-maintained file is distilled back to index form rather than left to grow. Applied first to docs/implementation-status.md (2026-07-13); applies later to CLAUDE.md cleanup (see the os-context prompt-files card).
## Consequences
Easier: routine reads cost one small index instead of the whole history; per-component detail is fetched only when working on that component; the length signal makes drift objective and cheap to catch; ADRs stop being duplicated in prose. Harder: recording a completed step now touches two files (one-line index entry + leaf detail); cross-file links must be kept unbroken (a link-integrity pass belongs to any restructure); readers must follow a pointer for depth instead of scrolling one file.
## Alternatives rejected
1) Keep append-only files and rely on the classifier's bloat/distill/split taxonomy downstream — rejected: oversized files were only caught if some other signal fired, and by then the context cost had already been paid for weeks. 2) Periodic manual pruning of the monolith with no structural convention — rejected: pruning decisions are re-litigated every time and history gets lost inconsistently; an index+leaf structure makes the "where does this go" decision mechanical. 3) Move stale sections into leaf files too (archive everything) — rejected: detail already recorded in an ADR is duplication; the ADR system exists to be the durable decision record.

View File

@ -0,0 +1,27 @@
---
id: "0033"
date: 2026-07-13
status: Accepted
supersedes:
superseded-by:
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]
---
# 0033 — Tracker routing: Planka is state, git issues are specs
## Context
The backlog pilot plan (vault note `vault-backlog-pilot-plan.md`) originally routed everything to Forgejo issues, later amended informally to "Forgejo = code work in a repo; Planka = everything else." The os-backlog slices then shipped a sharper boundary in practice — encoded in `plugins/os-backlog/skills/route/SKILL.md`, the `/to-issues` destination-tracker step (global skill `~/.agents/skills/to-issues/SKILL.md`, step 5), and the `plugins/os-backlog/hooks/session_start.py` injection note — but no ADR canonizes it, so the rule lives only in skill prose and could drift or be silently edited away. This ADR records already-implemented practice; it does not introduce new behavior.
## Decision
Canonize the tracker-routing boundary, amending the pilot plan's "Forgejo for everything" rule. (1) Boundary rule: Planka = task STATE (backlog/next/doing/waiting/review/done — what's happening now); git issues = durable SPECS (text that must survive and be referenced: features, migrations, architecture-level work, tracer-bullet slices from /to-issues, PRDs); `repo:<path>` is the escape hatch for projects that already track issues in-repo and want to stay there. (2) Each project declares exactly one destination via the `tracker` key in `.cc-os/config`, grammar: `planka:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>` (validated by `os-backlog config-write`). (3) Card-as-pointer + issue-chain-as-spec: when a code effort needs both state tracking and a durable spec, the Planka card is a pointer (title + link) into the git issue chain holding the spec — never duplicate spec text into a card description. (4) Promotion rule: when a Planka-tracked effort accretes significant code/design decisions in card comments, that is the signal to promote — open a git issue (or chain) for the durable spec and turn the card into a pointer; the AI flags promotion to the user, never performs it silently. Operational detail lives in the cited sources (route SKILL.md, to-issues step 5, session_start.py note) and is not restated here; column-ownership rules are ADR-0029's.
## Consequences
Easier: one recorded boundary the route skill, /to-issues, and the SessionStart note can all be audited against; changing the boundary now requires a superseding ADR rather than a quiet skill edit; the pilot plan's informal amendment has a durable home. Harder: two tracking surfaces per code project (board + issue tracker) instead of one, mitigated by the pointer pattern and back-links; promotion is a judgment call the AI can only flag, so under-flagging is a live risk (measured via the session-audit rubric).
## Alternatives rejected
1) Keep "Forgejo for everything" (original pilot plan) — rejected: process/coordination/household-style tasks have no repo home and card state (columns, WIP) is exactly what issue trackers do badly. 2) Planka for everything, specs in card descriptions — rejected: card descriptions are not durable, linkable, or commit-referenceable; specs need git-issue permanence. 3) Leave the rule in skill prose only, no ADR — rejected: prose rules drifting out of sync with enforcement is precisely what ADR-0029 fixed for column moves; the routing boundary deserves the same durability.

View File

@ -0,0 +1,27 @@
---
id: "0034"
date: 2026-07-13
status: Accepted
supersedes:
superseded-by:
affected-paths: [plugins/os-backlog/lib/backlog/project_index.rb, plugins/os-backlog/bin/os-backlog, plugins/os-backlog/hooks/session_start.py, plugins/os-backlog/skills/route/references/cross-project-filing.md]
affected-components: [os-backlog]
---
# 0034 — Cross-project filing: file-dont-fix with a derived global project index
## Context
Work discovered mid-session often belongs to a different project than the one being worked on (issue #27). Editing the other project from the wrong session loses context and bypasses its tracker; but filing requires knowing which tracker that project uses, which until now lived only in its per-project .cc-os/config (ADR-0027).
## Decision
File-don't-fix: a session never edits another project — it files an issue/card on that project's own tracker. To make the target tracker discoverable, os-backlog maintains a derived global project index at ~/.cc-os/projects.json, upserted as a side effect of every successful config-write and queried via `os-backlog projects`. This is deliberately GLOBAL state, distinct from the per-project .cc-os/ state of ADR-0027; it is derived and rebuildable (re-running route/config-write regenerates rows). Filed items carry a Discoverer metadata block (filing repo/path, Claude Code session id, date, one-line motivation); the session id comes from the SessionStart hook stdin JSON or the transcript filename and is NEVER fabricated — write "session id unavailable" if unknown. Routing inside the target project still follows its own boundary rule (ADR-0033). Filing mechanics live in plugins/os-backlog/skills/route/references/cross-project-filing.md, not restated here.
## Consequences
One compact CROSS-PROJECT rule line added to the os-backlog SessionStart note. Index rows only exist for projects that have run config-write; unrouted projects are reported, not guessed. Related: issue #27, ADR-0027, ADR-0029, ADR-0033.
## Alternatives rejected

View File

@ -0,0 +1,27 @@
---
id: "0035"
date: 2026-07-13
status: Accepted
supersedes:
superseded-by:
affected-paths: [plugins/os-backlog/bin/wakeup-poll, plugins/os-backlog/lib/backlog/wakeup.rb]
affected-components: [os-backlog]
---
# 0035 — Issue-triggered project AI wakeup via polling, not webhooks
## Context
Issue #28 (design spike, blocked-by #27/ADR-0034): when an issue is filed with a project — often via cross-project file-don't-fix — that project's AI should be awakened to triage or act, instead of waiting for the human to open a session. The spike must pick a trigger mechanism, autonomy guardrails, credential scope, and context handoff. The human pre-chose the direction: polling trigger + tmux execution surface (ADR-0036 records the tmux convention).
## Decision
Trigger is a poller, not webhooks: a cron/heartbeat script (pilot: `plugins/os-backlog/bin/wakeup-poll`, one pass per invocation, no cron installed yet) scans opted-in projects for NEW open issues. (1) Scope: rows of the global project index `~/.cc-os/projects.json` (ADR-0034) whose tracker is `forgejo:` or `github:`, AND whose per-project `.cc-os/config` carries the explicit opt-in marker `wakeup=true` (the index row path locates the config; a later config-write slice may mirror the flag into the row — the config file remains the authority). Planka/repo-tracked projects are never polled. (2) New-ness: last-seen state persisted at `~/.cc-os/wakeup-state.json` (per-repo highest issue index seen); first scan of a repo baselines without waking anything. (3) Autonomy, in ADR-0029 vocabulary via issue labels: `afk-ready` -> spawn an acting session; `semi` -> spawn a triage-only session (label + comment, stop at proposing — never implement); anything else (hitl or unlabeled) -> notify-only. Notification policy v2 (pull-only) applies: the scan appends to a summary the human pulls (`~/.cc-os/wakeup-summary.md`) — no push notifications. (4) Credentials: the poller lists issues through tea's existing login only; it NEVER reads `~/.credentials` contents; spawned sessions get Planka creds the established way (source in a subshell). (5) Context handoff: the spawned session's initial prompt carries the issue number, title, URL, and the Discoverer block if present (ADR-0034), plus its autonomy tier. (6) Execution surface is a tmux session per ADR-0036.
## Consequences
Easier: host-agnostic (works for Forgejo and GitHub identically through tea/gh), no listener endpoint, no inbound network surface, trivially disabled (remove the cron line or the wakeup flag). Harder: latency is the poll interval, and the poller is per-machine state — two machines polling would double-wake (accepted for the single-operator pilot; state file is per-host). Non-goals, explicitly: no webhooks; no auto-merge/auto-close; no acting on hitl/unlabeled issues; wakened sessions still obey file-don't-fix (ADR-0034) — they never edit other projects; no bulk backfill of pre-existing open issues (baseline-then-watch).
## Alternatives rejected
1) Forgejo webhooks -> local listener — rejected for the first cut: needs an always-up endpoint reachable from the Forgejo host, doesn't cover GitHub without a second mechanism, and adds an inbound attack surface; polling via tea reuses existing credentials and works everywhere. Webhooks remain the natural upgrade if poll latency ever matters. 2) Opt-in as a field only in the global index row — rejected: the index is derived and rebuildable (ADR-0034); a rebuild would silently drop opt-ins. Authority lives in the per-project .cc-os/config (ADR-0027). 3) Push notifications per new issue (Apprise, ADR-0024) — rejected here: notification policy v2 is pull-only for ambient status; push stays reserved for the human explicitly asking.

View File

@ -0,0 +1,27 @@
---
id: "0036"
date: 2026-07-13
status: Accepted
supersedes:
superseded-by:
affected-paths: [plugins/os-backlog/bin/wakeup-poll, plugins/os-backlog/lib/backlog/wakeup.rb]
affected-components: [os-backlog]
---
# 0036 — tmux session convention for AI-run sessions: cc-<project>-<purpose>
## Context
Issue #28 chose tmux (not headless claude -p / Agent SDK) as the execution surface for issue-triggered wakeup (ADR-0035), because the human must be able to discover, inspect, and take over an AI-run session. The naming/lifecycle convention is useful beyond wakeup — any long-running or unattended AI session benefits — so it is recorded as its own ADR. No existing tmux naming habit to preserve (no tmux server was running at spike time).
## Decision
(1) Naming: AI-run sessions are named `cc-<project>-<purpose>[-<n>]``cc-` prefix marks Claude-operated sessions, `<project>` is the index-row project name (ADR-0034), `<purpose>` is short kebab-case (wakeup sessions use `issue<N>`, e.g. `cc-llf-schema-issue12`), `-<n>` only disambiguates collisions. Human personal sessions never take the `cc-` prefix. (2) Creation: whoever needs the session creates it — the wakeup poller (ADR-0035) for issue wakeups, a human for anything else — via `tmux new-session -d -s <name> -c <project-root> ...` so the pane starts in the project. (3) Lifecycle: a session is killed after clean completion once its closing artifact exists (for wakeups: the triage/summary comment posted on the issue); it is kept alive on error or whenever hitl input is needed, so the evidence and the takeover point are the same pane. Discovery is `tmux ls` — the `cc-` prefix makes AI sessions self-listing; an os-backlog-adjacent listing command is an explicit non-goal for now. (4) Attach/takeover: attaching pauses nothing — the pane runs an interactive Claude Code session, so a human who attaches can simply type into it; there is no separate handover protocol.
## Consequences
Easier: one glanceable namespace (`tmux ls | grep ^cc-`) for everything the AI is running on a machine; takeover is native tmux attach, no tooling. Harder: kill-on-clean-completion means a successful session leaves no scrollback — the issue comment is the record, so sessions must actually post it before exiting (the wakeup prompt makes this the final required step); session names carry no timestamp, so a stale kept-alive session must be triaged by attaching, not by name.
## Alternatives rejected
1) Headless execution (claude -p / Agent SDK) — rejected by the human for this stage: no inspection or takeover surface; may return later for pure afk-ready work once trust is established. 2) Timestamped session names (cc-<project>-20260713-...) — rejected: names become unreadable in tmux ls and collisions are rare enough for -<n>. 3) Keep every completed session for audit — rejected: the durable record belongs on the issue/card (ADR-0033 spirit), and dead sessions accumulate into noise.

View File

@ -25,7 +25,7 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
| 0016 | [Memory plugin sourced from cc-os git repo; bash→Python deep-module port; memsearch-sync split; symlink cutover](0016-memory-plugin-sourced-from-cc-os-git-repo-bash-python-deep-module-port-memsearch-sync-split-symlink-cutover.md) | Accepted | 2026-06-12 |
| 0017 | [Project graph onboarding assesses the repo and writes `.graphifyignore` before extracting](0017-project-graph-onboarding-assesses-the-repo-and-writes-graphifyignore-before-extracting.md) | Accepted | 2026-06-17 |
| 0018 | [Plugin renames must update the marketplace manifest, not just the directory and settings.json](0018-plugin-renames-must-update-the-marketplace-manifest-not-just-the-directory-and-settings-json.md) | Accepted | 2026-07-03 |
| 0019 | [Global os-orchestration plugin supersedes per-project orchestration text](0019-global-os-orchestration-plugin-supersedes-per-project-orchestration-text.md) | Accepted | 2026-07-03 |
| 0019 | [Global os-orchestration plugin supersedes per-project orchestration text](0019-global-os-orchestration-plugin-supersedes-per-project-orchestration-text.md) | Superseded | 2026-07-03 |
| 0020 | [os-adr plugin built; implementation-time resolutions of the design's open questions](0020-os-adr-plugin-built-implementation-time-resolutions-of-the-design-s-open-questions.md) | Accepted | 2026-07-03 |
| 0021 | [Model-tier skill-execution eval for os-adr (Eval A), run in-session via subagents](0021-model-tier-skill-execution-eval-for-os-adr-eval-a-run-in-session-via-subagents.md) | Accepted | 2026-07-03 |
| 0022 | [os-status plugin: aggregated in-process SessionStart status checks](0022-os-status-plugin-aggregated-in-process-sessionstart-status-checks.md) | Accepted | 2026-07-06 |
@ -36,4 +36,11 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
| 0027 | [All cc-os per-project state lives under `.cc-os/<plugin>/`](0027-all-cc-os-per-project-state-lives-under-cc-os-plugin.md) | Accepted | 2026-07-12 |
| 0028 | [os-shortcuts plugin for user-invoked QOL commands, bounded against hook-injection plugins](0028-os-shortcuts-plugin-for-user-invoked-qol-commands-bounded-against-hook-injection-plugins.md) | Accepted | 2026-07-12 |
| 0029 | [os-backlog card-move with CLI-enforced column ownership; afk-ready cards skip Review](0029-os-backlog-card-move-with-cli-enforced-column-ownership-afk-ready-cards-skip-review.md) | Accepted | 2026-07-12 |
| 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 |
| 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 |
| 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 |
| 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 |
<!-- adr-index:end -->

View File

@ -1,702 +1,110 @@
# cc-os implementation status & changelog
# cc-os implementation status — index
_Last updated: 2026-07-12. This is the append-only implementation-status record moved out of
`CLAUDE.md` (2026-07-08, task B1 of
[system-prompt-profiles-tasks.md](plans/system-prompt-profiles-tasks.md)). CLAUDE.md keeps
orientation only; when a build step completes, record it HERE (plus
`docs/memory-system/04-build-plan.md`), and touch CLAUDE.md only if the current design or
conventions changed._
_Last updated: 2026-07-13. This file is an **index with progressive disclosure** (see the
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
demand. When a build step completes, add a **one-line** timeline entry here and put the
detail in (or create) the relevant leaf file; decisions go to `docs/adr/` — an ADR is the
durable record, so don't duplicate its content here. Touch `CLAUDE.md` only if the current
design or conventions changed. (History: moved out of CLAUDE.md 2026-07-08; distilled from
the previous 729-line append-only changelog 2026-07-13, issue #25.)_
## Status timeline
## Status timeline (headlines)
**Recent pivot (2026-06-03):** Graphify **replaces** the originally-planned Ruby/SQLite
tag-index CLI and also covers the deferred QMD semantic layer. `04-build-plan.md` and
`06-graphify-evaluation.md` reflect this; if an older doc still describes the Ruby CLI, defer
to those two and fix the stale doc.
Decisions are recorded in `docs/adr/` (see `docs/adr/README.md`); entries below that only
restated an ADR were removed in the 2026-07-13 distill. Component-level detail for each
entry is in the leaf file named.
**Decisions locked (2026-06-04):** Six-facet tag taxonomy + `scope/` (ADR-011); reuse
`~/Documents/SecondBrain` vault rather than creating a new one (ADR-012); build-first /
migrate-incrementally — build full system against a fixture set first, defer bulk vault
migration to last, onboard projects one at a time (ADR-013).
- **2026-06-03** — Graphify pivot: replaces the planned Ruby/SQLite tag-index CLI and the
deferred QMD layer (ADR-0010; rationale: `docs/memory-system/06-graphify-evaluation.md`).
- **2026-06-04** — Core design decisions locked: facet taxonomy, vault reuse, build-first
order (ADR-0011/0012/0013).
- **2026-06-05** — Empirical: Graphify is a structure extractor, not a topic clusterer;
hubs/wikilinks must be authored (ADR-0014; findings:
`docs/memory-system/07-graph-connectivity-findings.md`).
- **2026-06-09** — os-vault plugin live; build-plan Steps 1/2a/2b/3/4/5a/6 complete; single
global memsearch store decided (ADR-0015). Detail:
[implementation-status/os-vault.md](implementation-status/os-vault.md).
- **2026-06-12** — Plugin source moved into cc-os git; bash→Python deep-module port; symlink
cutover (ADR-0016).
- **2026-06-15** — Step 5b vault git sync automated; llf-schema pilot onboarded; **all
required build steps complete**. Detail: os-vault leaf.
- **2026-06-17** — Assessment-first project onboarding + `.graphifyignore` (ADR-0017).
- **2026-06-30** — SB Content Plan Phase 1 complete (schema reconciliation, templates,
design-template skill). Detail: os-vault leaf; Phase 2 (issue #7) open as steady-state
epic.
- **2026-07-03** — Plugin renamed `memory``os-vault`; marketplace-manifest failure mode
found (ADR-0018, runbook in
[implementation-status/operational-procedures.md](implementation-status/operational-procedures.md)).
os-doc-hygiene and os-orchestration migrated in from cc-plugins (ADR-0019); os-adr plugin
built + migration pilot passed + Eval A/B baselines (ADR-0020/0021). Detail: os-adr /
os-doc-hygiene / os-context leaves.
- **2026-07-04** — Stale-plugin-cache incident resolved; `bin/refresh-plugins` created
(runbook: operational-procedures leaf). os-adr Eval B wording loop closed the gap (sonnet
8/8, haiku 7/8). Detail: os-adr leaf.
- **2026-07-06** — os-status plugin built (ADR-0022); os-vault WS2 + os-orchestration
WS1/E1E3 eval harnesses + baseline grids; os-adr Eval C harness + saturated frozen grid.
Detail: os-status / os-vault / os-context / os-adr leaves.
- **2026-07-07/08** — os-vault WS2 wording loop and os-orchestration WS4 economics loop
complete and shipped; run-sets AND reserves now contaminated for future wording tuning —
next signal is production IRL audits. Detail: os-vault / os-context leaves.
- **2026-07-10** — Multi-machine freshness closed (SessionStart ff-only pull); os-backlog
AFK slices shipped + live-verified, planka-api 0.2.0 migration (ADR-0023/0025); first
orchestration IRL re-audit done + biweekly audit semi-automated. Detail: os-vault /
os-backlog / os-context leaves.
- **2026-07-12**`/os-status:fix` unified setup/update command (ADR-0026) +
`/os-backlog:route`; fix tune-up + `.cc-os/` state consolidation (ADR-0027); cc-os ADR
retrofit — 27-ADR monolith migrated to `docs/adr/` (ADR-0020 amendment); os-shortcuts
plugin + `/wrap` (ADR-0028); os-backlog card-move + afk-ready-skips-Review (ADR-0029).
Detail: os-status / os-backlog / os-adr leaves.
- **2026-07-13** — os-orchestration renamed/generalized to os-context prompt composer
(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);
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
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
issue-shape classification + split-by-kind default (issue #26); cross-project filing
convention + derived global project index (issue #27, ADR-0034); wakeup-polling +
tmux-convention spike with dry-run pilot (issue #28, ADR-0035/0036). Detail:
os-context / os-doc-hygiene / os-backlog leaves.
**Empirical finding locked (2026-06-05):** Graphify is a structure extractor, not a topic
clusterer — no emergent hub nodes appear even at `--mode deep`; hub notes + wikilinks must be
author-provided during migration (not deferred). Migration scaffolding is now a first-class
deliverable. Open question: do facet tags create graph edges? (ADR-014; findings:
`docs/memory-system/07-graph-connectivity-findings.md`).
**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);
cross-project filing convention (#27) and wakeup design spike (#28).
**Implementation status (2026-06-09):** The global Claude Code plugin is live
(`~/.claude/plugins/os-vault/`). Steps 1, 2a, 2b, 3, and 6 of the build plan are complete
(including the `onboard-project` skill, previously TODO under Step 6/Part D). Step 1
completed 2026-06-09: vault-conventions.md exists, all 6 fixture notes seeded, and 14
Graphify handbook + memory-system design notes migrated to the vault as migration
scaffolding. Step 4 (memsearch) completed 2026-06-09: plugin installed via marketplace,
MEMSEARCH_DIR set global (~/.memsearch), Stop hook verified producing daily memory files,
search confirmed working. Step 5a (memsearch episodic git sync) completed 2026-06-09:
dedicated private Forgejo repo (`forgejo.swansoncloud.com/jared/memsearch`), whitelist
`.gitignore` (memory/*.md only), auto-commit+push via cc-os `session-end.sh` hook (ADR-015;
relocated to `memsearch_sync.py` by ADR-016, 2026-06-12). Step 5b (Obsidian vault → VPS
sync) remained at that date.
## Implemented components (one-liners)
**Decision (2026-06-09):** Single global memsearch store for ALL clients — cross-client
commingling is the accepted, intended design (ADR-015 resolved). One private Forgejo repo
(`forgejo.swansoncloud.com/jared/memsearch`), not per-client repos. Forward direction:
minimize/eliminate dedicated per-client working directories; work projects locally or use a
single general `clients/` dir for non-local client work — memory is captured globally
regardless of cwd. No `clients/` directory structure designed yet; open item.
Read the leaf file before modifying a component.
**Implementation status (2026-06-12):** os-vault plugin source moved into git at
`cc-os/plugins/os-vault/` and bash hooks ported to Python (deep-module architecture: shared
`config.py`, `hook_io.py`, `session_state.py`; thin entry-point scripts). Cutover via symlink
`~/.claude/plugins/os-vault → cc-os/plugins/os-vault/` and settings.json hook rewrite.
memsearch sync split into dedicated `memsearch_sync.py` SessionEnd hook (relocation of
ADR-015 behavior, not reversal). Fresh-session test passed 2026-06-12. See ADR-016.
**Implementation status (2026-06-15):** Step 5b done and automated — vault
(`~/Documents/SecondBrain`) initialized as git repo, pushed to private Forgejo
(`ssh://git@forgejo.swansoncloud.com:2222/jared/SecondBrain.git`); 52 files, git chosen over
Syncthing. Auto-commit+push wired via `vault_sync.py` SessionEnd hook (third SessionEnd
hook, runs after `session_end.py` so the daily journal note is included; push-only —
SessionStart pull is an optional future item for multi-machine). Step 2e pilot done —
llf-schema (`/home/jared/dev/llf-schema`, PHP 8.2 WordPress plugin) onboarded via
`/os-vault:onboard-project` skill: 605 nodes / 930 edges / 52 communities; Ollama doc pass
lossy on WordPress docs but AST pass solid. Step 2d closed — live vault `graphify-out/` is
the baseline; fixture-only build superseded.
**Implementation status (2026-06-17):** `/os-vault:onboard-project` skill now uses
assessment-first onboarding: surveys the repo structure, classifies exclude candidates by
TYPE (11 categories: fetched deps, build output, caches, VCS internals, editor/AI-tooling
dirs, lockfiles, coverage/logs, bulk data, binaries, secrets, graphify-out/ — illustrative
names, not fixed templates), generates a per-project `.graphifyignore`, confirms with the
user, then extracts the graph using `qwen25-coder-7b-16k` (config.yaml `ollama_model`;
16k-context build of qwen2.5-coder:7b — larger context window cuts chunk count per doc, the
main speed lever). Fixes issue where repos with large dependency trees routed non-code files
through the Ollama doc pass (see ADR-017).
**Implementation status (2026-06-30):** Phase 1 of the SecondBrain Content Plan complete
(`docs/memory-system/09-sb-content-plan.md`, issues #1#6).
`~/Documents/SecondBrain/vault-conventions.md` reconciled to a single authoritative typed
frontmatter schema (removed the contradictory block; canonical filenames are slug-only,
`source:` is a frontmatter field not a tag, `scope` is a field not a tag). New
`/os-vault:design-template` skill added
(`cc-os/plugins/os-vault/skills/design-template/SKILL.md`) — routes between template-design
(4-step process + injection-economics filter) and new-type creation (9-step lifecycle).
Three vault note templates created in `~/Documents/SecondBrain/_templates/` (`howto.md`,
`convention.md`, `reference.md`; `reference` carries a four-subtype variant selector —
pattern/framework, api-integration, role-definitions, design-rules), each dogfooded against
a real note. Four proof-of-concept vault notes patched (cookbook subtype →
pattern/framework, glossary de-duped to a wikilink, design-mode scope → project, tags +
`last_reviewed` added). Phase 2 (issue #7) remains open as the steady-state
migration/onboarding epic (no code). See `docs/memory-system/09-sb-content-plan.md`.
**Plugin renamed `memory``os-vault` (2026-07-03):** directory renamed in git,
`~/.claude/plugins/os-vault` symlink and `settings.json`'s `enabledPlugins` updated — but
the `local-plugins` marketplace manifest
(`~/.claude/plugins/.claude-plugin/marketplace.json`) still declared the old
`memory`/`./memory` entry, and `installed_plugins.json` still had a cached
`memory@local-plugins` install record. Skills (and therefore slash commands) never
registered under the new name as a result; hooks kept working because they're wired by
absolute path in `settings.json`, independent of plugin resolution. Fixed by editing the
marketplace manifest to `"os-vault"`/`"./os-vault"`, then
`claude plugin marketplace update local-plugins`,
`claude plugin install os-vault@local-plugins`,
`claude plugin uninstall memory@local-plugins`. See ADR-018 and the "Renaming or moving a
local plugin" procedure below.
**Resolved (2026-07-04):** os-adr's skills failed to register on first install due to stale
plugin caches — the cache that was installed on 2026-07-03 17:21:48 was missing
`hooks/hooks.json`, `bin/adr-detect`, `bin/adr-find`, `bin/adr-migrate`. Root cause:
unknown, but the caches were restored via plugin uninstall/reinstall. A parallel issue
affected os-doc-hygiene (cache retained deleted `commands/` directory). Fixed by manually
refreshing both caches, then created `bin/refresh-plugins` automation to prevent future
drift — see "Editing a local plugin (cache refresh)" below. Investigation result: stale
caches (not manifest-naming issues) were the cause; slash command registration works
correctly once caches are fresh.
**Multi-machine freshness closed (2026-07-10):** `session_start.py` gained a fast-forward-only
`git pull` (`timeout 10 git pull --ff-only --quiet`) for both `cfg.vault_path` and
`cfg.memsearch_dir`, run before the staleness check — silent on success, quiet one-line stderr
note on failure (offline machine), never blocking session start. Closes the push-only gap noted
below; mirrors the push style of `vault_sync.py`/`memsearch_sync.py` in the opposite direction.
**os-backlog AFK slices shipped (2026-07-10):** PRD published as Forgejo issue #9 (state
pointer: Planka card `1816234194716592051`); sliced into issues #10#17 via `/to-issues`.
The five AFK slices are implemented and committed: #10 plugin scaffold + `board-ensure`
(uniform lists/labels incl. new `semi` autonomy label, Planka 2.1.1 quirks, `archived--`
rename convention), #11 `tracker` key in `.cc-os/config` + os-status `tracker-configured`
check, #12 pure routing resolver CLI, #13 `/os-backlog:capture` + `/os-backlog:list`
skills + card CLI, #15 `card-triage` + `board-audit` named agents. All deterministic parts
covered by stubbed-client tests (34 runs os-backlog, 49 os-status). Semi slices #14
(routing/migration skill), #16 (injection note + /to-issues routing), #17 (ops board +
rubric category + routing ADR) await human decision gates. 2026-07-10 (later): planka-api
gem 0.1.0 installed locally (`rake install`, rbenv 4.0.2; publish/versioning still parked
behind gem issue 13) and live verification completed against production Planka —
`snapshot`, `board-ensure` (created the missing `semi` label on the cc-os board), and
`card-add` + `card-label` all succeeded with bot credentials sourced from ovh-vps
(`~/services/planka/bot-credentials.env`) in a subshell. Follow-up captured as a `hitl`
Backlog card: get planka_bot into credvault (credvault has no import path — rotate vs.
add import capability is a human decision).
**os-backlog migrated to planka-api 0.2.0 typed returns (2026-07-10):** commit `8df0cb7`
moved the gem from 0.1.0 hash-return to 0.2.0, which returns `Planka::Types` value objects
instead of parsed-JSON hashes. `board_ensurer.rb` and `cards.rb` were rewritten on typed
accessors (`card.id`, `card.list_id`, …); `Cards#snapshot` now builds explicit string-keyed
hashes from `BoardDetail#lists`/`#cards`/`#labels`/`#card_labels` (`BoardDetail` replaces the
old `included`-hash shape). Test fakes in `tests/test_helper.rb` were rebuilt to return real
`Planka::Types` records instead of hashes. JSON output stays byte-compatible, so skill/agent
contracts are unchanged. A follow-up fix (issue #18, same day) found the migration had
silently dropped one field from `Cards#snapshot`'s per-card hash; restored the full 19-key
camelCase card key set (`id`, `createdAt`, `updatedAt`, `type`, `position`, `name`,
`description`, `dueDate`, `isDueCompleted`, `stopwatch`, `commentsTotal`, `isClosed`,
`listChangedAt`, `boardId`, `listId`, `creatorUserId`, `prevListId`, `coverAttachmentId`,
`isSubscribed`, plus `labels`) with a regression test pinning the key set including
`listChangedAt`. Suite green at 35 runs (was 34 pre-#18).
**os-backlog live verification (0.2.0) (2026-07-10):** end-to-end re-verification of
`bin/os-backlog` against the real Planka instance with the installed 0.2.0 gem (issue #20).
Local machine had no `PLANKA_*` env/credvault entry, so the plugin tree was rsynced to
`ovh-vps:/tmp/os-backlog-verify` (where `planka-api` 0.2.0 is also installed) and exercised
there with bot credentials sourced from `~/services/planka/bot-credentials.env` in a
subshell, per the established pattern — credential contents were never read or printed.
Results: `tests/all.rb` 35 runs/0 failures on ovh-vps; `board-ensure cc-os --project Dev` run
twice both reported `(existing)` with no lists/labels created (idempotent); `card-add`
created a card on Backlog; `card-label` attached `afk-ready`; `card-comment` posted a
comment; `snapshot --board cc-os` showed the card with the full 20-key set (19 card fields +
`labels`) including `"listChangedAt": "2026-07-10T21:04:37.013Z"` and `"labels":
["afk-ready"]`. The test card was deleted via `Planka::Client#cards.delete` (the CLI has no
delete subcommand) and a follow-up snapshot confirmed it was gone; the rsynced verification
tree on ovh-vps was removed afterward. No regressions vs. the pre-0.2.0 behavior.
**Unified setup/update command + tracker routing skill shipped (2026-07-12):** ADR-026
decided the unified per-project setup/update command lives in os-status as `/os-status:fix`
(rejected: new `os-project` plugin, literal `/cc-os:init`). Built in parallel (issues #21,
#14, both sonnet-delegated): **os-status** gained the `fix` skill (runs the check registry
via a new `python3 hooks/checks.py --json` runner, drives each failing check's owning-plugin
remediation, idempotent — re-run is the update path), a `remediation` pointer on every
`Check`, two new checks (`project-graph-present` → `/os-vault:onboard-project`;
`config-version-current` vs `CURRENT_CONFIG_VERSION`, stamped as `version=` in
`.cc-os/config` by fix via `state.write_config_value`), and a banner header naming
`/os-status:fix` as the single entry point; suite 64 tests (was 36). **os-backlog** gained
`/os-backlog:route` (issue #14): `inspect` + `config-write` dispatcher subcommands
(`lib/backlog/tracker.rb` validates the four tracker formats + classifies git remotes,
`lib/backlog/inspector.rb` finds in-repo issue files; `Config.merge` preserves other keys),
with destination choice and live-history migration as named human gates in the skill; suite
54 runs/110 assertions (was 35). Human gates preserved throughout: fix never fabricates a
tracker value and never edits global settings.json. Rollout per #14 acceptance (onboard
cc-os itself, then one more project) still pending.
**Fix tune-up + per-project state consolidation (2026-07-12, issues #23/#24, ADR-027):** the
credvault smoke-test of `/os-status:fix` (session cd1ae1cd) was audited against the SKILL.md
contract; the run was on-contract but surfaced four gaps (stale snoozes never cleared,
`.cc-os/config` format split between the Ruby and Python writers, ad-hoc gitignore hygiene,
an unprompted commit) plus root-dotdir sprawl. Fixes (both sonnet-delegated, in parallel):
**os-status** — `clear_snooze` helper + self-clearing snoozes when a check evaluates ok/note
(both SessionStart and `--json` runners); its own state (`snooze-*`, `suppress-*`,
`state.json`) relocated to `.cc-os/status/` with legacy fallback read + migrate-on-write
(shared `config` stays at `.cc-os/config`); `write_config_value` normalizes the whole file to
`key=value` (no spaces); fix SKILL.md now scopes out committing, adds an explicit
"`.cc-os/` in .gitignore" step, and clarifies when re-runs are needed; suite 80 tests (was
64). **os-backlog**`Config` already emitted the normalized format; locked in by new tests
(58 runs, was 54). **os-doc-hygiene** — state moved `.dochygiene/``.cc-os/dochygiene/`
(ADR-027) with canonical-then-legacy fallback reads and auto-migration (+ legacy dir removal)
on first write; scanner self-excludes `.cc-os/`; all four SKILL.md files updated; suite 279
passed. `graphify-out/` explicitly exempt from consolidation (ADR-027). Re-run of
`/os-status:fix` in credvault is the pending IRL verification.
**cc-os ADR retrofit (2026-07-12, via `/os-status:fix`):** the 27-ADR monolithic
`docs/memory-system/03-architecture-decisions.md` was migrated to `docs/adr/` with
`/os-adr:migrate`, promoted ahead of the pilot projects (ADR-020 amended in place: migrate
pilot had already passed its gate, migrate is non-destructive, and the monolith's context
cost was real and growing). Mechanics: the detector only scans `docs/{adr,decisions,decision}/`
+ `DECISIONS.md`, so the source was shimmed in via a format-normalized copy under
`docs/decisions/` (bullet-label `- **Context**: …` → line-start `**Context:** …` so the
bold-label parser could extract sections; Rationale/Cross-references left as bullets so they
ride inside Decision/Alternatives content). Result: 27/27 converted, flag rate 3.7% (one low:
0016), consequences filled per file from source material (sonnet-delegated), 0004/0005/0006
marked Superseded by 0010 with frontmatter links, `migration_source` repointed at the real
monolith path, shim removed. The monolith is retained untouched as the historical source; new
decisions go through `/os-adr:create`. CLAUDE.md updated (docs/adr/ layout entry,
trigger-phrased decisions convention, os-adr rollout line). Remaining os-adr rollout: pilot
projects one at a time, then wider.
**os-shortcuts plugin + `/wrap` shipped (2026-07-12, ADR-028, Planka card):** new plugin
`plugins/os-shortcuts/` for user-invoked QOL commands — the "keybindings" of cc-os; domain
boundary vs automatic hook injection (os-orchestration / future os-context) recorded in
ADR-028; skills only, no hooks. First resident `/os-shortcuts:wrap` encodes the session-close
ritual from the 2026-07-09 workflow audit (typed 20+ times): git review → commits per repo
conventions → doc/todo sweep deferring to project CLAUDE.md → Done/In-flight/Next handoff.
Registered in the local-plugins marketplace, enabled in settings.json, installed via
`claude plugin install os-shortcuts@local-plugins`. Sonnet-delegated build. `/readonly`
remains a separate open card (decide vs plan mode first).
**os-backlog card-move + afk-ready-skips-Review (2026-07-12, ADR-029):** the process policy
always intended the AI to move worked cards, but the CLI had no move command, so worked/blocked
cards accumulated in place with comments. Added `card-move --board --card --to` over
planka-api `Cards#move`, with column-ownership rules enforced in `lib/backlog/cards.rb`
(never into/out of Next; hitl cards never moved; never out of Done; →Done only for
afk-ready cards). Autonomy semantics amended per user decision: afk-ready ships straight to
Done (no Review sift); semi still stops at Review. capture/list SKILL.md updated to the
"working a card means moving it" contract. Suite 68 runs/153 assertions (was 58/130).
Sonnet-delegated build.
**Remaining optional items:** additional project onboarding (one at a time, per ADR-013); bulk
vault migration. All required build steps complete as of 2026-06-15.
## Implemented components
**Global os-vault plugin** — `cc-os/plugins/os-vault/` (git-tracked, 2026-06-12); symlinked
into `~/.claude/plugins/os-vault`
- Hooks: `hooks/``session_start.py` (SessionStart; staleness check + detached graph
rebuild, and, as of 2026-07-10, a fast-forward-only `git pull` for `cfg.vault_path` and
`cfg.memsearch_dir` before the staleness check — silent on success, quiet one-line stderr
note on failure, never blocking), `session_context.py` (project graph path only),
`post_tool_use_write.py`, `session_end.py` (vault journal), `memsearch_sync.py` (second
SessionEnd hook; memsearch auto-commit+push, 30s timeout), `vault_sync.py` (third
SessionEnd hook; vault auto-commit+push to forgejo.swansoncloud.com/jared/SecondBrain, 30s
timeout; mirrors memsearch_sync.py)
- Shared modules: `config.py` (load_config → frozen Config dataclass), `hook_io.py`
(read_input → HookInput dataclass), `session_state.py` (record_touch/read_touches;
encapsulates `/tmp/claude-vault-touched-$SESSION_ID` contract)
- Skills: `skills/` — query, write, reorganize, onboard-project (assessment-first
onboarding: surveys repo, classifies excludes by type (11 categories), writes per-project
`.graphifyignore`, confirms with user, then extracts using `qwen25-coder-7b-16k` — per
ADR-017), design-template (routes template-design ↔ new-type-creation: 4-step
template-design process + injection-economics filter, 9-step new-type lifecycle; backs
Phase 1 of the SB Content Plan — see `docs/memory-system/09-sb-content-plan.md`)
- Config: `config.yaml` — vault path, Ollama model (qwen25-coder-7b-16k), env vars
- Hook wiring: `~/.claude/settings.json` (hook entries invoke `/usr/bin/python3` with
absolute paths into cc-os)
- Isolation seam (2026-07-06, WS2): `OS_VAULT_PATH` env var overrides `vault_path` for the
write skill and every hook via `config.load_config()`; `OS_VAULT_SKIP_REBUILD` suppresses
the SessionStart graphify rebuild. Added for the eval harness; zero production impact when
unset.
- Write-behavior eval (2026-07-06, WS2): `plugins/os-vault/eval/` — held-out unprompted
vault-write discrimination eval (Eval B/C playbook: ambiguity ladder L1 explicit → L3
conceptual, paired positives/negatives, 6 run-set scenarios on a new `reportgen` Ruby
fixture + 6 frozen reserve twins in a different knowledge domain, isolated sandbox vault,
headless-only runner, deterministic-first Ruby checker with narrow frozen offer-detection
judge fallback stubbable via `OS_VAULT_EVAL_JUDGE_CMD`, model-free `bin/self-test` 21/21).
**Scenario Task blocks are held-out — never run informally; the reserve set is never even
read informally.** Procedure: `plugins/os-vault/eval/README.md`.
- Untuned baseline grid (2026-07-06, run-set × sonnet/haiku × 3 reps + 1 counted canary, 37
reps, zero harness errors): **positives 1/19, negatives 18/18**. Headline: at L1 (explicit
cue) both tiers persist the knowledge every rep but route it to Claude Code's built-in
auto-memory (`~/.claude/projects/<slug>/memory/`) instead of the vault; L2/L3 mostly don't
persist at all; zero over-triggering anywhere. TSV:
`eval/results/2026-07-06-baseline-grid.tsv`; full analysis: vault note
[[os-vault-write-eval-baseline-grid-results]].
- WS2 wording loop COMPLETE (2026-07-07, 4 iterations + confirm + held-out reserve; run-set
AND reserve now contaminated): shipped destination-ladder write SKILL.md + SessionStart
USAGE_NOTE (mechanical triggers + before-final-reply checkpoint), mechanical vault-root
resolution (`${OS_VAULT_PATH:-$HOME/Documents/SecondBrain}` — fixed real-vault breaches
found on iteration 1), fixture CLAUDE.md "Cross-project memory" section (**the
real-project adoption template**, required to reach haiku at L2/L3; includes ADR-vs-vault
disambiguation), candidates 24 unscored, post-reserve YAML-colon quoting rule. Confirm
grid sonnet 18/18 / haiku 16/18; reserve (held-out) positives 8/18 strict / trigger-axis
13/18, negatives 17/18 (vs baseline 1/19, 18/18). Residual gaps: YAML-hostile titles,
haiku L2 no-trigger, generalization-laundering over-trigger. One checker false-positive
fixed+rescored mid-loop (breach regex vs `2>/dev/null`); negative-criterion redesign
pre-registered. TSVs: `eval/results/2026-07-07-wording-*.tsv`; loop log:
`autoresearch/classic-260707-1253/loop-log.md`; analysis: vault note
[[os-vault-write-eval-wording-loop-results]]. Next signal is production IRL audits.
**Global os-orchestration plugin** — `cc-os/plugins/os-orchestration/` (git-tracked,
2026-07-03); symlinked into `~/.claude/plugins/os-orchestration`
- Hooks: `hooks/``inject.py` (injects `ORCHESTRATION.md` as additionalContext to all
sessions)
- Behavior: SessionStart hook injects an `ORCHESTRATION.md` markdown doc (hardcoded; lives
in plugin source) as additionalContext, carrying the canonical global
session-orchestration rule set for Claude Code across all projects.
- Migration: migrated from a standalone repo (`~/dev/cc-plugins/orchestration/`, 2026-07-03)
and integrated into cc-os. Supersedes the per-project copy-pasted orchestration text
blocks that previously existed in individual project CLAUDE.md files (including a stricter
local override that cc-os had carried — now removed; see ADR-019).
- Session audit (WS1, 2026-07-06): 10 stratified real sessions audited via
`audit/bin/extract` (Ruby fact-sheet extractor, dual-use as future eval checker) +
parallel sonnet auditors + verified synthesis. Findings + E1E4 eval-scenario backlog:
`docs/orchestration-audit/2026-07-06-findings.md`. Headlines: model param was silently
overridden to haiku on all 23 verified spawns — root cause found post-audit:
`CLAUDE_CODE_SUBAGENT_MODEL=haiku` in `~/.claude/settings.json` env block (set by an
earlier session as a cost measure; removed 2026-07-06; the Fable-5 correlation was
coincidental timing). Omitted model param inherits the main-loop model (opus/sonnet) —
misses cluster in pre-rollout and ops sessions; no over-sharing/full-dump failures
anywhere.
- Wording fixes shipped (2026-07-06, post-audit): ORCHESTRATION.md rewritten with
trigger-conditioned when→then phrasing for the three verified clusters — explicit
`model:` before every Agent call (cluster 2), subagent model self-report on sonnet/opus
spawns (cluster 1 — the E1 canary proved `resolvedModel` is NOT model-visible in the
launch result; self-report is the only observable channel), don't re-cover your own ground
before delegating (cluster 3). Delegation thresholds unchanged. Cache refreshed via
`bin/refresh-plugins`. Deterministic env-override detection (warn on
`CLAUDE_CODE_SUBAGENT_MODEL`) is delegated to WS3's status-check design.
- Eval harness E1E3 (2026-07-06): `plugins/os-orchestration/eval/` — scripted headless
orchestration-behavior eval scored by the audit extractor (extended with per-segment read
targets). 6 run-set scenarios (E1P/N downgrade detection with
`CLAUDE_CODE_SUBAGENT_MODEL=haiku` runner stub; E2P/N explicit-model fan-out vs
no-delegation-on-trivial; E3P/N delegate-investigation vs orienting-reads-correct) + 6
frozen reserve twins; new Node.js "relaystation" fixture with deterministic sandbox-time
log generation; deterministic-first checker (`eval/bin/check`, narrow frozen haiku-judge
fallback for E1 language axes only, stubbable via `ORCH_EVAL_JUDGE_CMD`); model-free
`bin/self-test` 21/21 green incl. shipped-instruction-compliant conformance transcripts.
FAIL axes map only to verified audit misses; tier choice informational. **Scenario Task
blocks are held-out — never run informally.** Procedure:
`plugins/os-orchestration/eval/README.md`.
- Baseline grid run (2026-07-06, run-set × sonnet/haiku × 3 reps + 2 counted canaries, all
counted, zero harness errors): negatives 18/18 PASS both tiers (zero over-delegation, zero
false downgrade claims); positives 1/18 on the delegate-at-all axis — but transcript
verification shows most positive FAILs are *superior direct strategies* (E2P sonnet: one
scripted bulk edit; E3P sonnet: surgical greps over 400KB logs), which the WS1 audit
itself scored as justified non-delegation, vs genuine miss shapes (E3P haiku: whole 112KB
logs into own context; E2P haiku: 20-call per-file grind). Conditional rules validated
where engaged: 14/14 spawns explicit-model; the one delegating E1P rep flagged the stubbed
downgrade via self-report. Criterion redesign for E2P/E3P A-axes pre-registered (anchor
thresholds independently, NOT to this grid) before any rescore/tuning; reserve stays
frozen. ORCHESTRATION.md gained a scripted-bulk-edit carve-out. TSV:
`eval/results/2026-07-06-baseline-grid.tsv`; full analysis: vault note
[[os-orchestration-eval-baseline-grid-results]].
- WS4 economics loop COMPLETE + SHIPPED (2026-07-08; run-set AND reserve now contaminated
for future wording tuning): ORCHESTRATION.md v3 shipped — cost-asymmetry +
tier-conditional threshold opener, symmetric delegate-when/work-directly-when trigger
lists (replaces "delegate only when"), batching rule (~58 items per grouped spawn,
SendMessage reuse), async rule, Workflow effort dial; explicit-model/self-report/
don't-re-cover-ground rules unchanged. Harness extended first (commit b226601): real token
econ axes — subagent transcripts persist at
`<projects>/<flat-cwd>/<session-id>/subagents/agent-*.jsonl` with per-message usage blocks
(solves the mini-audit's instrumentation gap; vault note
[[claude-code-subagent-transcripts-and-token-accounting]]) — E5 batching pair + reserve
twins (authored by subagent, held-out content never entered the tuning session), fable
column, pre-registered E2P/E3P criterion redesign landed (scripted-direct PASS branch;
74KB ingestion anchor + id-keyed root-cause concepts). Results: E5P 0/6 → majority-PASS
both tiers incl. reserve twin 5/5 (fable median mltok 43% of baseline, H3 met); negatives
clean everywhere (36/36 across confirm+reserve). Residual gaps: E1P sonnet delegate-at-all
(fixture too small — needs bigger fixture before meaningful), fable downgrade-flag ~1/4
judge flicker. 15 baseline reps were session-limit-truncated and mechanically
excluded+replaced (limit-banner sweep now a standard step, promoted to the autoresearch
howto). TSVs: `eval/results/2026-07-08-*.tsv`; loop log:
`autoresearch/classic-260708-1039/`; vault note
[[os-orchestration-ws4-econ-wording-results]]. IRL re-audit of ~5 real Fable sessions
scheduled ~2026-07-22.
- **First IRL re-audit DONE (2026-07-10, ahead of schedule):** 19 production transcripts
post-2026-07-08 audited by 3 parallel agents against the shipped rules (run:
`autoresearch/improve-260710-1057/`, plan in `improvement-plan.md`). Solved surfaces:
model-param discipline (~48/49 spawns pinned), over-delegation guardrail. Tier 1 findings
→ ORCHESTRATION.md wording patch shipped same day (grid validation impossible —
run-set/reserve contaminated — so this is a one-shot production patch, not a wording
loop): (1) settled-design mid-session fan-out trigger, (2) never-sleep-poll rule, (3) own
eval/benchmark loops named as mechanical sequences, (4) unfamiliar-tree ~5-call Explore
trigger. SendMessage worked example deliberately skipped (no violations found;
injected-token budget). Acceptance check = next IRL audit (~23 weeks; baseline:
settled-design drift 8/19 sessions). Side finding needing separate repro: harness ignored
explicit `model: "sonnet"` pins on general-purpose agents (env override unset — distinct
from WS1 cause); the self-report line caught it.
- **IRL audit semi-automated (2026-07-10):** recurring biweekly audit formalized, working
from any cwd. Deterministic layer: `audit/bin/audit-stats` (Ruby, composes the existing
`audit/bin/extract` fact-sheets) enumerates production transcripts since the last run
(excludes `-tmp-orch-*`/`-tmp-claude-*` sandboxes, `*-eval` dirs, active/trivial
sessions), writes `~/.local/state/os-orchestration/audit/run-<date>/`
(stats.tsv/flags.md/sheets) and appends the trend ledger `metrics.tsv` (seeded with the
2026-07-10 run: 23 sessions, 52 spawns, 1 missing model). Judgment layer: new skill
`/os-orchestration:audit-sessions` (rubric in `references/`) — sonnet auditor fan-out
over flagged regions only, tiered synthesis, run-over-run trend verdicts, stops before
any wording edit. Nudge: os-status check `orchestration-audit-due` warns when the
ledger's last row is ≥14 days old (missing ledger = machine not opted in, silent; env
override `OS_ORCH_AUDIT_LEDGER` for tests). Planka recurrence-manifest entry skipped:
the gem's offsets are deliberately `+1y/+6m/+3m/+1m` only (issue 11) — a `+2w` offset is
a gem change blocked on the parked publish/versioning decisions; the os-status nudge is
the trigger for now.
**Global os-status plugin** — `cc-os/plugins/os-status/` (git-tracked, 2026-07-06);
symlinked into `~/.claude/plugins/os-status`
- Purpose: aggregated deterministic SessionStart status checks for the cc-os plugin family —
per-project artifacts (ADR system, vault hub note) and environment hazards (subagent model
env override). Built via OpenSpec change `add-os-status-plugin` (supersedes the WS3 plan
doc); decision record ADR-022.
- Architecture: checks are **in-process** plain Python functions in `hooks/checks.py` with
the uniform signature `check(ctx) -> CheckResult(status, message)` plus one registry entry
(no per-plugin subprocess protocol, no per-project code copies — both rejected in ADR-022
after perspective review). Three states: `ok` (silent), `note` (near-zero-token
additionalContext line, never snoozed/suppressed), `warn` (aggregated into at most one
banner; once-per-day snooze + permanent `suppress-<check>` markers in gitignored
per-project `.cc-os/` — state only, never code; `.cc-os/config` holds optional
`hub`/`vault_path` key=value overrides).
- Initial checks: `subagent-model-env-override` (env + `~/.claude/settings.json` `env`
block; runs outside git projects too — catches the WS1 Cluster 1 incident deterministically
at session start), `adr-system-present` (verbatim port of os-adr's hook behavior incl.
`.os-adr/suppress`), `vault-hub-note-present` (config slug first, else `type/hub` +
`project/<name>` facet-tag scan of the vault; missing → warn naming `/os-vault:write`).
- Added 2026-07-12 (ADR-026, issue #21): `/os-status:fix` skill — the unified project
setup/update command; runs the registry via `python3 hooks/checks.py --json` (each result
now carries a `remediation` pointer), drives the owning plugin's remediation per failing
check, stamps `version=` into `.cc-os/config` (`CURRENT_CONFIG_VERSION`); idempotent, so
re-running is the update path. New checks `project-graph-present` and
`config-version-current`; warn banner names `/os-status:fix` as the single entry point.
- Tests: `python3 tests/hook_test.py` (64, model-free; 36 pre-#21) incl. byte-identity of
PRESENT_NOTE/ABSENT_NOTE against os-adr's source. Invariants in `invariants.md`.
Smoke-tested 2026-07-06 (cache==source, real headless session fired the hook,
env-override canary warns).
**Global os-doc-hygiene plugin** — `cc-os/plugins/os-doc-hygiene/` (git-tracked,
2026-07-03); symlinked into `~/.claude/plugins/os-doc-hygiene`
- Hooks: `hooks/hooks.json` → SessionStart hook (matcher: startup|resume) runs
`scripts/reminder.py` via `${CLAUDE_PLUGIN_ROOT}` (5s timeout), emitting a deterministic
zero-token reminder banner
- Behavior: Monitors stale and bloated project documentation per-project under
`.dochygiene/` state dir (gitignored). SessionStart reminder is deterministic (no AI
tokens, once/day snooze). Skills (verb-first, no `commands/` dispatcher — invoked directly
as `/os-doc-hygiene:<skill>`, per [[cc-os-plugin-skill-naming-convention]]): `check`
(AI-assisted classification of staleness signals, emits machine+human reports), `clean`
(AI-assisted or deterministic patch application with git-safe scoped cleanup), `status`
(read-only lifecycle-timestamp read), `sweep` (check then clean in sequence).
Reversion-protected via invariants.md + golden-example test fixtures.
- Migration: migrated from standalone repo (`~/dev/cc-plugins/doc-hygiene/`, 2026-07-03) and
integrated into cc-os. No content conflicts detected. Renamed from `doc-hygiene` to
`os-doc-hygiene` per cc-os plugin naming convention. Skills renamed
`hygiene-check`/`hygiene-clean` → `check`/`clean`, and the `commands/hygiene.md`
dispatcher removed in favor of two new skills (`status`, `sweep`), aligning with the
`os-vault`/`os-orchestration` pattern of no `commands/` directory (2026-07-03).
**Global os-adr plugin** — `cc-os/plugins/os-adr/` (git-tracked, 2026-07-03); symlinked into
`~/.claude/plugins/os-adr`
- Purpose: Architecture Decision Records, consistently in every project — one
customized-Nygard template (frontmatter
`id`/`date`/`status`/`supersedes`/`superseded-by`/`affected-paths`/`affected-components`;
Context/Decision/Consequences/Alternatives-rejected sections),
`docs/adr/NNNN-kebab-title.md` one-file-per-decision + fully-regenerated
`docs/adr/README.md` index. Built from `docs/adr-system/` (requirements `04`, PRD `05`,
eval sketches `06`) via OpenSpec change `add-os-adr-plugin`.
- Core: Ruby OO library `lib/adr/` (`Record`, `Repository`, `Index`, `Template`, `Detector`,
`Migrator`, `MigrationReport`, `Finder`; Sandi Metz style, injected paths) behind thin
CLIs in `bin/` (`adr-new`, `adr-init`, `adr-detect`, `adr-migrate`, `adr-find`). Tests:
`ruby tests/all.rb` (47) + `python3 tests/hook_test.py` (15), all model-free. Behavioral
invariants in `invariants.md`.
- Hook: **moved to os-status (2026-07-06)** — the SessionStart existence check now runs as
the `adr-system-present` check in the os-status plugin (`hooks.json` here is empty;
`hooks/session_start.py` stays as the wording source of record, byte-copied into
os-status). Behavior unchanged: present → near-zero-token usage note naming
`/os-adr:create` + `/os-adr:find`; absent → once-per-day init/migrate suggestion, silenced
permanently by `.os-adr/suppress` (still honored); silent outside git projects.
- Skills (verb-first, no `commands/`): `create` (one-invocation templated+numbered+indexed
ADR with mechanical supersession), `init`, `migrate` (non-destructive: detect surveyed
shapes → mechanical heuristic fill → LLM fills only manifest-listed interpretive fields
via `--apply-fills``migration_confidence` frontmatter + `docs/adr/migration-report.md`;
old-system deletion only as an explicit separate user-approved step), `find`
(deterministic-first retrieval: affected-paths/components match → Accepted-only filter →
one-hop Graphify query-path expansion with graceful degradation → AI judgment over the
candidate set only).
- Migration pilot passed 2026-07-03 (sandboxed project copies; gate threshold 25%
low-confidence flags): viking-warrior-training-log 0%, delta-refinery 0%, llf-schema 8.3%.
Retrieval verified end-to-end against the real llf-schema project graph. Resolved: ADRs
need no Graphify tag/edge convention — retrieval layer 3 expands the *query* paths via
graph-node `source_file`s.
- Eval A harness (2026-07-03, ADR-021): `plugins/os-adr/eval/` — prompted skill-execution
eval across model tiers (haiku/sonnet). Two fixtures (generated with the plugin's own
CLIs), six scenarios (S1 create, S2 create+supersede, S3 find/conflict, S4
find/distractor, S5 init, S6 migrate+fills), deterministic Ruby checker (`eval/bin/check`,
structural invariants, TSV mode for autoresearch), sandbox + headless-runner scripts,
runner-prompt template. Primary run mode: in-session Agent-tool subagents with pinned
`model:`; optimized via the `/autoresearch` Classic loop over SKILL.md *wording only*
(checker/fixtures/scenarios frozen during a loop). Procedure:
`plugins/os-adr/eval/README.md`. Self-tested both directions; grid run 2026-07-03, all
scenarios × both tiers passing. Distinct from the held-out Eval B (unprompted behavior) —
do not conflate.
- Eval B harness (2026-07-03, OpenSpec change `add-os-adr-eval-b-harness`):
`plugins/os-adr/eval-b/` — held-out unprompted-behavior eval (requirements 45). 7
scenarios (W1W3 write-trigger, R1R4 retrieval) authored from the frozen shapes in
`docs/adr-system/06-eval-scenarios.md`; dedicated Ruby webhook-relay fixture with a 6-ADR
history (Superseded pair + near-miss distractors, generated via the plugin's own CLIs);
R4's one-hop graph reach uses a real `graphify update` AST build (model-free, rebuilt via
`eval-b/bin/build-fixture-graph`, never committed). **Headless-only runner**
(`eval-b/bin/run` — fresh `claude -p` per rep, cwd = sandbox, so the real SessionStart
hook fires; in-session subagents are invalid here, unlike Eval A) and a two-axis
deterministic-first checker (`eval-b/bin/check`): axis (a) unprompted consultation,
mechanical from transcript tool_use blocks; axis (b) correct-ADR citation (R1R4) or
new-ADR-file with a narrow frozen-rubric haiku judge fallback (W1W3, `judge-rubric.md`,
stubbable via `ADR_EVAL_B_JUDGE_CMD`). `R4-nograph` is the graph-degradation variant
(expected FAIL). Self-tested both directions model-free via `eval-b/bin/self-test`.
**Scenario Task blocks are held-out — never run them informally.** Procedure:
`plugins/os-adr/eval-b/README.md`. Grid run 2026-07-03 (1 rep/cell): haiku 0/8 PASS;
sonnet 5/8 PASS (fails W3, R1). `R4-nograph` FAILed on both tiers as expected. Full
results + observations: vault note [[os-adr-eval-b-grid-results-and-observations]].
- **Initial iteration complete (2026-07-03):** plugin build, migration pilot, Eval A grid
(clean pass), and Eval B grid (baseline) all done — closes the first pass on os-adr.
- **Wording experiment complete (2026-07-04):** the follow-up `/autoresearch` loop over
Eval B wording ran (5 iterations, checker/fixtures/scenarios/rubric frozen; fixture
CLAUDE.md declared a wording surface upfront) and closed the gap — final full grid
**sonnet 8/8, haiku 7/8** (from 5/8 / 0/8 baseline; haiku's one miss is a W3 axis-b
judge-boundary flicker). Winning wording shipped in `hooks/session_start.py`
(PRESENT_NOTE), find/create SKILL.mds, and `eval-b/fixture/project/CLAUDE.md` (new — the
trigger-phrased "Architecture decisions" section, the template for real-project adoption).
Confirmed mechanisms: trigger-conditioned when→then phrasing beats inventory phrasing on
both tiers; each rule must live where its precondition is visible (the reversal→supersede
rule in the find skill's act-on-findings step fixed W3); lower tiers need *mechanical*
triggers — semantic triggers only reach sonnet. Open: channel ablation never run;
R4-nograph now passes both tiers, so the graph-degradation check no longer differentiates.
Full hypothesis→result mapping: vault note [[os-adr-eval-b-wording-experiment-hypotheses]].
**Before designing or running any autoresearch eval, Read
`~/Documents/SecondBrain/howto/running-autoresearch-skill-evals.md`.**
- **Eval B W3 stability check (2026-07-06):** 7 headless reps of haiku × W3 (all reps
counted): axis (a) consultation 7/7 PASS; axis (b) recording-offer ~5/7 (~7585%) — one
FAIL plausibly infra, one a genuine behavioral miss. Verdict: intermittent flicker, not a
hard gap — grid claim stays **haiku 7/8**, W3 characterized as an ~1-in-4/5 axis-b miss.
Haiku cleared for daily use with that caveat. Recorded in the vault good-enough gate:
[[os-adr-eval-b-wording-experiment-hypotheses]].
- **Eval C harness built, NOT yet run as designed (2026-07-06):** `plugins/os-adr/eval-c/`
ambiguity-ladder DISCRIMINATION eval (held-out; Eval B is contaminated by the wording
loop). 3 levels (explicit → moderate → conceptual framing) × paired positive/negative
scenarios; run-set (6, job-execution domain) + frozen reserve-set (6, notifications
domain). New Python fixture (`taskq` async job queue, 6-ADR history generated via the
plugin's own CLIs, trigger-phrased CLAUDE.md section copied from eval-b). Scoring:
positives = both axes; negatives FAIL **only on unneeded ADR creation**. Model-free
`bin/self-test` green, including a truthful-citation-must-PASS guard. **Scenario Task
blocks are held-out — never run informally.** Methodology: vault notes
[[eval-methodology-ladder]] and [[eval-methodology-irl-feedback-loop]]; vault also gained
an `eval-results` note type + `_templates/eval-results.md` (2026-07-06).
- **Eval C frozen grid run (2026-07-06):** run-set × {sonnet, haiku} × 3 reps/cell (36
headless reps, all counted) — **12/12 cells PASS at ≥2/3 majority; haiku 18/18 reps,
sonnet 17/18**. Zero over-trigger ADR creation on any negative at any level; every
positive pass was via governing-ADR citation (16) or judge-recognized proposal (1), never
duplicate creation. Sonnet's one FAIL rep (P1-L1) is a final-message-citation boundary
artifact, not a behavioral miss. Two harness defects fixed on the first live rep, BEFORE
the grid, with Task blocks/fixtures/rubric untouched: (1) `bin/run` pipefail bug; (2)
positive axis-b contradiction fixed via a mechanical `cited-governing` PASS branch + two
new self-test guards (`B-via:` now recorded in TSV). Verdict: the Eval B wording
**generalizes** — grid saturated, ceiling not found; next signal is production (rollout +
IRL session audits), not another lab rung. Run-set is now the baseline; wording tuning
against it moves measurement to the reserve-set. Full results: vault note
[[os-adr-eval-c-frozen-grid-results]].
- **Remaining (rollout order, amended 2026-07-12):** the cc-os retrofit is done (promoted
ahead of pilots — see the 2026-07-12 timeline entry and the ADR-020 amendment); remaining:
real-project migration/adoption one at a time via `/os-adr:migrate` — pilot projects, then
wider. When onboarding real projects,
add the trigger-phrased "Architecture decisions" CLAUDE.md section (copy from
`eval-b/fixture/project/CLAUDE.md`; candidate: emit it from `/os-adr:init`/`migrate`).
**Graphify** — v0.8.31 at `/home/jared/.local/bin/graphify`
- Vault graph: `~/Documents/SecondBrain/graphify-out/` — disposable, structural index
rebuilt by SessionStart hook; handles relational/graph queries. Distinct from
vault-index.json (planned, Plan B Phase 1): a flat `{tag: [{path, title, summary}]}`
lookup rebuilt at SessionEnd, queried by the `/memory-find` skill for fast tag-based SB
note discovery.
- Project graph: `<project-root>/graphify-out/` — same pattern; gitignore it in each project
repo
- Vault conventions: `~/Documents/SecondBrain/vault-conventions.md` — frontmatter contract +
tag taxonomy (canonical name decided 2026-06-09; formerly referred to as CONVENTIONS.md)
**memsearch** — v0.4.6 via Claude Code plugin marketplace (`memsearch@memsearch-plugins`)
- Hooks: Stop, SessionStart, UserPromptSubmit, SessionEnd (ship with plugin)
- Memory store: `~/.memsearch/memory/YYYY-MM-DD.md` daily files (global, cross-project, all
clients — one store by design)
- Index: `~/.memsearch/milvus.db` (Milvus Lite, local); embeddings via ONNX bge-m3
- Config: `MEMSEARCH_DIR=~/.memsearch` in `~/.zshrc` for global scope
- Skills: `/memory-recall`, `/memory-config` (ship with plugin)
- Git sync: `~/.memsearch` is a dedicated private Forgejo repo
(`forgejo.swansoncloud.com/jared/memsearch`); whitelist `.gitignore` commits only
`memory/*.md`; auto-commit+push wired into `memsearch_sync.py` SessionEnd hook (relocated
from `session-end.sh` by ADR-016, 2026-06-12; behavior preserved — see ADR-015)
**Obsidian vault git sync** — `~/Documents/SecondBrain` (2026-06-15)
- Remote: `ssh://git@forgejo.swansoncloud.com:2222/jared/SecondBrain.git` (private Forgejo;
web: `https://forgejo.swansoncloud.com/jared/SecondBrain`)
- 52 files tracked (notes, journal, templates, vault-conventions.md, CLAUDE.md, .obsidian
config); `graphify-out/` excluded via `.gitignore`
- Auto-commit+push via `vault_sync.py` SessionEnd hook (mirrors `memsearch_sync.py`;
push-only — SessionStart pull is optional future item for multi-machine)
**Global os-backlog plugin** — `cc-os/plugins/os-backlog/` (git-tracked, 2026-07-10);
symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9, slices #10#17
- Purpose: the AI's process-management surface over Planka — deterministic tracker routing
(Planka = task state by default; git issues = implementation specs only), mid-session
capture, pull-only listing, board lifecycle, and the three-value autonomy convention
(`hitl`/`semi`/`afk-ready`).
- Lib (Ruby, os-adr `lib/`+`bin/` pattern, installed planka-api gem only (0.2.0, typed
`Planka::Types` returns as of 2026-07-10) — no source
coupling): `board_spec.rb` (uniform lists/labels contract), `board_ensurer.rb`
(idempotent create/repair; Planka 2.1.1 `type` fields, shared-project + owner-manager
visibility fix, label color candidates with fallback; `archived--` rename convention),
`config.rb` (`.cc-os/config` parser), `resolver.rb` (pure repo→board decision:
`use`/`activate`/`stop-and-discuss`; no network), `cards.rb` (add-at-Backlog, snapshot,
attach_label, comment).
- CLI: `bin/os-backlog``board-ensure`, `activate`, `archive`, `resolve`, `card-add`,
`cards`, `snapshot`, `card-label`, `card-comment`, `card-move` (2026-07-12, ADR-029 —
ownership rules enforced in lib); every path fails soft (one-line error,
exit 1) when the gem/Planka is unavailable.
- Skills: `/os-backlog:capture`, `/os-backlog:list`, `/os-backlog:route` (2026-07-12, issue
#14 — tracker onboarding conversation over new `inspect`/`config-write` subcommands and
`lib/backlog/tracker.rb`/`inspector.rb`; destination choice + live-history migration are
named human gates) — carry the column-ownership rules (AI
creates at Backlog, Doing→Review at most, never Next or Review→Done, never self-assigns
`hitl`); list is pull-only per notification policy v2.
- Agents: `agents/card-triage.md` (priority + autonomy labels only; ambiguity → `hitl`),
`agents/board-audit.md` (four drift classes; writes at most comments).
- Tests: `tests/` — 54 runs/110 assertions (35 pre-#14) against an in-memory
`FakePlankaClient`; no live
API. Companion os-status check `tracker-configured` (issue #11) validates the `tracker`
key grammar and nudges unconfigured git projects (daily-snoozed).
- Outstanding: Semi slices #16/#17 (injection note + /to-issues routing, ops board + rubric
category + routing ADR); #14 (routing skill) shipped 2026-07-12 — rollout (onboard cc-os,
then one more project) pending. Live verification
completed 2026-07-10 (snapshot / board-ensure / card-add / card-label against production;
gem 0.1.0 locally installed via `rake install`); re-verified 2026-07-10 (later) against the
installed 0.2.0 gem post-migration — see "os-backlog migrated to planka-api 0.2.0" and
"os-backlog live verification (0.2.0)" timeline entries above.
### os-shortcuts (plugin)
Global plugin: user-invoked QOL shortcut commands (ADR-028). Skills only — never hooks;
automatic injection belongs to os-orchestration / the planned os-context.
- Skills: `/os-shortcuts:wrap` — session-close ritual (git review, commits per repo
conventions, doc/todo sweep deferring to project CLAUDE.md, handoff summary). Shipped
2026-07-12.
- Expected future residents: additional shortcuts as audit findings warrant (`/readonly`
card is open — decide vs plan mode before implementing).
- **os-vault** — vault write/query/reorganize/onboard-project/design-template skills,
SessionStart/End hooks, vault + memsearch git sync, WS2 write-behavior eval →
[implementation-status/os-vault.md](implementation-status/os-vault.md)
- **os-context** (ex os-orchestration) — SessionStart prompt composer
(`prompts/session-start/*.md`), orchestration rules, audit-sessions skill, E1E5 eval
harness + IRL audit machinery →
[implementation-status/os-context.md](implementation-status/os-context.md)
- **os-status** — aggregated in-process SessionStart checks + `/os-status:fix` unified
setup/update command →
[implementation-status/os-status.md](implementation-status/os-status.md)
- **os-doc-hygiene** — stale/bloated-doc monitoring; check/clean/status/sweep skills;
deterministic scanner incl. `file_length` signal →
[implementation-status/os-doc-hygiene.md](implementation-status/os-doc-hygiene.md)
- **os-adr** — ADR system (Ruby `lib/adr/` + CLIs; create/init/migrate/find skills; Eval
A/B/C harnesses; cc-os retrofit done) →
[implementation-status/os-adr.md](implementation-status/os-adr.md)
- **os-backlog** — Planka process surface: board lifecycle, capture/list/route skills,
card CLI with column-ownership rules, triage/audit agents →
[implementation-status/os-backlog.md](implementation-status/os-backlog.md)
- **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
yet — this entry is the full detail.
- **Graphify / memsearch / vault git sync** — external memory infrastructure (versions,
paths, sync topology) →
[implementation-status/memory-infrastructure.md](implementation-status/memory-infrastructure.md)
## Operational procedures
### Renaming or moving a local plugin
Renaming a plugin directory under a directory-source marketplace (like `local-plugins`, whose
source is `~/.claude/plugins` itself) requires updating **three** places, not just the
directory and `settings.json`:
1. **Plugin directory / symlink** — the actual files (or symlink target, for plugins that
live in a git repo like `cc-os/plugins/os-vault/`).
2. **Marketplace manifest**`~/.claude/plugins/.claude-plugin/marketplace.json` (for
`local-plugins`) lists each plugin's `name` and `source` path explicitly; this does
**not** auto-discover from the directory listing. Update the entry to match the new
name/path.
3. **`settings.json` `enabledPlugins`** — the `<name>@<marketplace>` key must match the
manifest entry from step 2.
Then refresh the plugin manager's state so it re-resolves the marketplace and drops the
stale install record:
```bash
claude plugin marketplace update <marketplace-name> # re-validates the manifest
claude plugin install <new-name>@<marketplace-name> # creates the new install record
claude plugin uninstall <old-name>@<marketplace-name> # drops the stale one
claude plugin list # verify: new name enabled, old name gone
claude plugin details <new-name>@<marketplace-name> # verify skills/agents/hooks resolved
```
Skipping step 2 is the failure mode to watch for: hooks (wired by absolute path in
`settings.json`) keep working, masking the fact that skills/slash-commands silently stopped
registering under the plugin's new name.
### Editing a local plugin (cache refresh)
Plugin source edits (including SKILL.md wording, hook scripts, or CLI code) do not reach
running sessions until the cache is refreshed. Claude Code caches plugins when installed,
copying them to `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`. To refresh a
stale cache after source edits:
```bash
bin/refresh-plugins [marketplace-names...] # refresh all or specific local marketplaces
```
This uninstalls and reinstalls each plugin, forcing the cache to repopulate. See
`bin/refresh-plugins --help` for usage. After refreshing,
`claude plugin details <name>@<marketplace>` should show updated skill descriptions and hook
counts. Directory-source marketplaces (e.g., `local-plugins` sourced from
`~/.claude/plugins`, `cc-plugins` sourced from `~/dev/cc-plugins`) are the only ones that
need manual refresh — public marketplaces auto-update.
Runbooks (plugin rename/move, plugin cache refresh via `bin/refresh-plugins`) live in
[implementation-status/operational-procedures.md](implementation-status/operational-procedures.md)
— stable leaf, referenced from CLAUDE.md.

View File

@ -0,0 +1,38 @@
# Memory infrastructure (Graphify, memsearch, vault git sync) — detail
_Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._
## Graphify — v0.8.31 at `/home/jared/.local/bin/graphify`
- Vault graph: `~/Documents/SecondBrain/graphify-out/` — disposable, structural index
rebuilt by SessionStart hook; handles relational/graph queries. Distinct from
vault-index.json (planned, Plan B Phase 1): a flat `{tag: [{path, title, summary}]}`
lookup rebuilt at SessionEnd, queried by the `/memory-find` skill for fast tag-based SB
note discovery.
- Project graph: `<project-root>/graphify-out/` — same pattern; gitignore it in each project
repo. Exempt from the `.cc-os/` state consolidation (ADR-0027).
- Vault conventions: `~/Documents/SecondBrain/vault-conventions.md` — frontmatter contract +
tag taxonomy (canonical name decided 2026-06-09; formerly referred to as CONVENTIONS.md).
## memsearch — v0.4.6 via Claude Code plugin marketplace (`memsearch@memsearch-plugins`)
- Hooks: Stop, SessionStart, UserPromptSubmit, SessionEnd (ship with plugin)
- Memory store: `~/.memsearch/memory/YYYY-MM-DD.md` daily files (global, cross-project, all
clients — one store by design; ADR-0015)
- Index: `~/.memsearch/milvus.db` (Milvus Lite, local); embeddings via ONNX bge-m3
- Config: `MEMSEARCH_DIR=~/.memsearch` in `~/.zshrc` for global scope
- Skills: `/memory-recall`, `/memory-config` (ship with plugin)
- Git sync: `~/.memsearch` is a dedicated private Forgejo repo
(`forgejo.swansoncloud.com/jared/memsearch`); whitelist `.gitignore` commits only
`memory/*.md`; auto-commit+push wired into os-vault's `memsearch_sync.py` SessionEnd hook
(relocated from `session-end.sh` by ADR-0016; behavior per ADR-0015)
## Obsidian vault git sync — `~/Documents/SecondBrain` (2026-06-15)
- Remote: `ssh://git@forgejo.swansoncloud.com:2222/jared/SecondBrain.git` (private Forgejo;
web: `https://forgejo.swansoncloud.com/jared/SecondBrain`)
- 52 files tracked at initialization (notes, journal, templates, vault-conventions.md,
CLAUDE.md, .obsidian config); `graphify-out/` excluded via `.gitignore`
- Auto-commit+push via os-vault's `vault_sync.py` SessionEnd hook (mirrors
`memsearch_sync.py`); SessionStart fast-forward pull added 2026-07-10 for multi-machine
freshness (see [os-vault.md](os-vault.md))

View File

@ -0,0 +1,52 @@
# Operational procedures
_Stable runbooks referenced from CLAUDE.md. Keep this file's procedures current — they are
the canonical copies._
## Renaming or moving a local plugin
Renaming a plugin directory under a directory-source marketplace (like `local-plugins`, whose
source is `~/.claude/plugins` itself) requires updating **three** places, not just the
directory and `settings.json`:
1. **Plugin directory / symlink** — the actual files (or symlink target, for plugins that
live in a git repo like `cc-os/plugins/os-vault/`).
2. **Marketplace manifest**`~/.claude/plugins/.claude-plugin/marketplace.json` (for
`local-plugins`) lists each plugin's `name` and `source` path explicitly; this does
**not** auto-discover from the directory listing. Update the entry to match the new
name/path.
3. **`settings.json` `enabledPlugins`** — the `<name>@<marketplace>` key must match the
manifest entry from step 2.
Then refresh the plugin manager's state so it re-resolves the marketplace and drops the
stale install record:
```bash
claude plugin marketplace update <marketplace-name> # re-validates the manifest
claude plugin install <new-name>@<marketplace-name> # creates the new install record
claude plugin uninstall <old-name>@<marketplace-name> # drops the stale one
claude plugin list # verify: new name enabled, old name gone
claude plugin details <new-name>@<marketplace-name> # verify skills/agents/hooks resolved
```
Skipping step 2 is the failure mode to watch for: hooks (wired by absolute path in
`settings.json`) keep working, masking the fact that skills/slash-commands silently stopped
registering under the plugin's new name. See ADR-0018.
## Editing a local plugin (cache refresh)
Plugin source edits (including SKILL.md wording, hook scripts, or CLI code) do not reach
running sessions until the cache is refreshed. Claude Code caches plugins when installed,
copying them to `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`. To refresh a
stale cache after source edits:
```bash
bin/refresh-plugins [marketplace-names...] # refresh all or specific local marketplaces
```
This uninstalls and reinstalls each plugin, forcing the cache to repopulate. See
`bin/refresh-plugins --help` for usage. After refreshing,
`claude plugin details <name>@<marketplace>` should show updated skill descriptions and hook
counts. Directory-source marketplaces (e.g., `local-plugins` sourced from
`~/.claude/plugins`, `cc-plugins` sourced from `~/dev/cc-plugins`) are the only ones that
need manual refresh — public marketplaces auto-update.

View File

@ -0,0 +1,121 @@
# os-adr — component detail
_Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._
**Global os-adr plugin** — `cc-os/plugins/os-adr/` (git-tracked, 2026-07-03); symlinked into
`~/.claude/plugins/os-adr`. Decision records: ADR-0020 (build + open-question resolutions,
amended 2026-07-12 for the cc-os retrofit promotion), ADR-0021 (Eval A design).
## Component inventory
- Purpose: Architecture Decision Records, consistently in every project — one
customized-Nygard template (frontmatter
`id`/`date`/`status`/`supersedes`/`superseded-by`/`affected-paths`/`affected-components`;
Context/Decision/Consequences/Alternatives-rejected sections),
`docs/adr/NNNN-kebab-title.md` one-file-per-decision + fully-regenerated
`docs/adr/README.md` index. Built from `docs/adr-system/` (requirements `04`, PRD `05`,
eval sketches `06`) via OpenSpec change `add-os-adr-plugin`.
- Core: Ruby OO library `lib/adr/` (`Record`, `Repository`, `Index`, `Template`, `Detector`,
`Migrator`, `MigrationReport`, `Finder`; Sandi Metz style, injected paths) behind thin
CLIs in `bin/` (`adr-new`, `adr-init`, `adr-detect`, `adr-migrate`, `adr-find`). Tests:
`ruby tests/all.rb` (47) + `python3 tests/hook_test.py` (15), all model-free. Behavioral
invariants in `invariants.md`.
- Hook: **moved to os-status (2026-07-06)** — the SessionStart existence check now runs as
the `adr-system-present` check in the os-status plugin (`hooks.json` here is empty;
`hooks/session_start.py` stays as the wording source of record, byte-copied into
os-status). Behavior unchanged: present → near-zero-token usage note naming
`/os-adr:create` + `/os-adr:find`; absent → once-per-day init/migrate suggestion, silenced
permanently by `.os-adr/suppress` (still honored); silent outside git projects.
- Skills (verb-first, no `commands/`): `create` (one-invocation templated+numbered+indexed
ADR with mechanical supersession), `init`, `migrate` (non-destructive: detect surveyed
shapes → mechanical heuristic fill → LLM fills only manifest-listed interpretive fields
via `--apply-fills``migration_confidence` frontmatter + `docs/adr/migration-report.md`;
old-system deletion only as an explicit separate user-approved step), `find`
(deterministic-first retrieval: affected-paths/components match → Accepted-only filter →
one-hop Graphify query-path expansion with graceful degradation → AI judgment over the
candidate set only).
- Migration pilot passed 2026-07-03 (sandboxed project copies; gate threshold 25%
low-confidence flags): viking-warrior-training-log 0%, delta-refinery 0%, llf-schema 8.3%.
Retrieval verified end-to-end against the real llf-schema project graph. Resolved: ADRs
need no Graphify tag/edge convention — retrieval layer 3 expands the *query* paths via
graph-node `source_file`s.
## Evals
- **Eval A harness (2026-07-03, ADR-0021):** `plugins/os-adr/eval/` — prompted
skill-execution eval across model tiers (haiku/sonnet). Two fixtures (generated with the
plugin's own CLIs), six scenarios (S1 create, S2 create+supersede, S3 find/conflict, S4
find/distractor, S5 init, S6 migrate+fills), deterministic Ruby checker (`eval/bin/check`,
structural invariants, TSV mode for autoresearch), sandbox + headless-runner scripts,
runner-prompt template. Primary run mode: in-session Agent-tool subagents with pinned
`model:`; optimized via the `/autoresearch` Classic loop over SKILL.md *wording only*.
Procedure: `plugins/os-adr/eval/README.md`. Grid run 2026-07-03, all scenarios × both
tiers passing. Distinct from the held-out Eval B — do not conflate.
- **Eval B harness (2026-07-03, OpenSpec change `add-os-adr-eval-b-harness`):**
`plugins/os-adr/eval-b/` — held-out unprompted-behavior eval (requirements 45). 7
scenarios (W1W3 write-trigger, R1R4 retrieval) from the frozen shapes in
`docs/adr-system/06-eval-scenarios.md`; Ruby webhook-relay fixture with a 6-ADR history
(Superseded pair + near-miss distractors); R4's one-hop graph reach uses a real
`graphify update` AST build (rebuilt via `eval-b/bin/build-fixture-graph`, never
committed). **Headless-only runner** (`eval-b/bin/run` — fresh `claude -p` per rep, cwd =
sandbox, so the real SessionStart hook fires) and a two-axis deterministic-first checker
(`eval-b/bin/check`): (a) unprompted consultation, mechanical; (b) correct-ADR citation
(R1R4) or new-ADR-file with a narrow frozen-rubric haiku judge fallback (W1W3,
stubbable via `ADR_EVAL_B_JUDGE_CMD`). **Scenario Task blocks are held-out — never run
informally.** Procedure: `plugins/os-adr/eval-b/README.md`. Baseline grid 2026-07-03:
haiku 0/8, sonnet 5/8 (fails W3, R1); `R4-nograph` degradation variant FAILed both tiers
as expected. Results: vault note [[os-adr-eval-b-grid-results-and-observations]].
- **Wording experiment complete (2026-07-04):** `/autoresearch` loop over Eval B wording (5
iterations; checker/fixtures/scenarios/rubric frozen) closed the gap — final full grid
**sonnet 8/8, haiku 7/8** (haiku's one miss is a W3 axis-b judge-boundary flicker).
Winning wording shipped in `hooks/session_start.py` (PRESENT_NOTE), find/create SKILL.mds,
and `eval-b/fixture/project/CLAUDE.md` (the trigger-phrased "Architecture decisions"
section — the template for real-project adoption). Confirmed mechanisms:
trigger-conditioned when→then phrasing beats inventory phrasing on both tiers; each rule
must live where its precondition is visible (the reversal→supersede rule in the find
skill's act-on-findings step fixed W3); lower tiers need *mechanical* triggers — semantic
triggers only reach sonnet. Open: channel ablation never run; R4-nograph now passes both
tiers so no longer differentiates. Full mapping: vault note
[[os-adr-eval-b-wording-experiment-hypotheses]]. **Before designing or running any
autoresearch eval, Read
`~/Documents/SecondBrain/howto/running-autoresearch-skill-evals.md`.**
- **Eval B W3 stability check (2026-07-06):** 7 headless reps of haiku × W3: axis (a) 7/7
PASS; axis (b) ~5/7 — intermittent flicker, not a hard gap; grid claim stays **haiku
7/8**; haiku cleared for daily use with that caveat. Recorded in
[[os-adr-eval-b-wording-experiment-hypotheses]].
- **Eval C harness (2026-07-06):** `plugins/os-adr/eval-c/` — ambiguity-ladder
DISCRIMINATION eval (held-out; Eval B is contaminated by the wording loop). 3 levels
(explicit → moderate → conceptual) × paired positive/negative scenarios; run-set (6,
job-execution domain) + frozen reserve-set (6, notifications domain). Python `taskq`
fixture with 6-ADR history and the eval-b trigger-phrased CLAUDE.md section. Scoring:
positives = both axes; negatives FAIL **only on unneeded ADR creation**. Model-free
`bin/self-test` green. **Scenario Task blocks are held-out — never run informally.**
Methodology: vault notes [[eval-methodology-ladder]] and
[[eval-methodology-irl-feedback-loop]]; vault gained an `eval-results` note type +
template (2026-07-06).
- **Eval C frozen grid run (2026-07-06):** run-set × {sonnet, haiku} × 3 reps/cell (36
headless reps, all counted) — **12/12 cells PASS at ≥2/3 majority; haiku 18/18 reps,
sonnet 17/18**. Zero over-trigger ADR creation on any negative; every positive pass via
governing-ADR citation (16) or judge-recognized proposal (1), never duplicate creation.
Sonnet's one FAIL rep is a final-message-citation boundary artifact. Two harness defects
fixed on the first live rep, BEFORE the grid (run pipefail bug; mechanical
`cited-governing` PASS branch + two self-test guards). Verdict: the Eval B wording
**generalizes** — grid saturated; next signal is production (rollout + IRL session
audits). Full results: vault note [[os-adr-eval-c-frozen-grid-results]].
## cc-os retrofit and rollout
- **cc-os ADR retrofit (2026-07-12, via `/os-status:fix`):** the 27-ADR monolithic
`docs/memory-system/03-architecture-decisions.md` was migrated to `docs/adr/` with
`/os-adr:migrate`, promoted ahead of the pilot projects (ADR-0020 amended in place).
Mechanics: the detector only scans `docs/{adr,decisions,decision}/` + `DECISIONS.md`, so
the source was shimmed in via a format-normalized copy under `docs/decisions/`
(bullet-label → line-start bold-label so the parser could extract sections). Result: 27/27
converted, flag rate 3.7% (one low: 0016), consequences filled per file from source
material (sonnet-delegated), 0004/0005/0006 marked Superseded by 0010 with frontmatter
links, `migration_source` repointed at the real monolith path, shim removed. The monolith
is retained untouched as the historical source; new decisions go through `/os-adr:create`.
- **Remaining (rollout order, amended 2026-07-12):** real-project migration/adoption one at
a time via `/os-adr:migrate` — pilot projects, then wider. When onboarding real projects,
add the trigger-phrased "Architecture decisions" CLAUDE.md section (copy from
`eval-b/fixture/project/CLAUDE.md`; candidate: emit it from `/os-adr:init`/`migrate`).

View File

@ -0,0 +1,139 @@
# os-backlog — component detail
_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);
symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9 (state pointer: Planka
card `1816234194716592051`), slices #10#17. Decision records: ADR-0023 (own plugin, not an
os-vault extension), ADR-0025 (standard Ruby plugin structure), ADR-0029 (card-move +
column ownership).
## Component inventory
- Purpose: the AI's process-management surface over Planka — deterministic tracker routing
(Planka = task state by default; git issues = implementation specs only), mid-session
capture, pull-only listing, board lifecycle, and the three-value autonomy convention
(`hitl`/`semi`/`afk-ready`; amended per ADR-0029: afk-ready ships straight to Done, semi
stops at Review).
- Lib (Ruby, os-adr `lib/`+`bin/` pattern, installed planka-api gem only — no source
coupling): `board_spec.rb` (uniform lists/labels contract), `board_ensurer.rb`
(idempotent create/repair; Planka 2.1.1 `type` fields, shared-project + owner-manager
visibility fix, label color candidates with fallback; `archived--` rename convention),
`config.rb` (`.cc-os/config` parser), `resolver.rb` (pure repo→board decision:
`use`/`activate`/`stop-and-discuss`; no network), `cards.rb` (add-at-Backlog, snapshot,
attach_label, comment, move with ownership rules), `tracker.rb` + `inspector.rb`
(2026-07-12, issue #14 — tracker-format validation/remote classification + in-repo
issue-file discovery; `Config.merge` preserves other keys).
- CLI: `bin/os-backlog``board-ensure`, `activate`, `archive`, `resolve`, `card-add`,
`cards`, `snapshot`, `card-label`, `card-comment`, `card-move` (2026-07-12, ADR-0029),
`inspect`, `config-write`; every path fails soft (one-line error, exit 1) when the
gem/Planka is unavailable.
- Skills: `/os-backlog:capture`, `/os-backlog:list` (pull-only per notification policy v2),
`/os-backlog:route` (2026-07-12, issue #14 — tracker onboarding conversation; destination
choice + live-history migration are named human gates) — carry the column-ownership rules
(AI creates at Backlog, Doing→Review at most, never Next, never self-assigns `hitl`;
"working a card means moving it" contract added 2026-07-12).
- Agents: `agents/card-triage.md` (priority + autonomy labels only; ambiguity → `hitl`),
`agents/board-audit.md` (four drift classes; writes at most comments).
- Tests: `tests/` — 68 runs/153 assertions as of 2026-07-12 (35 → 54 → 58 → 68 across the
slices) against an in-memory `FakePlankaClient`; no live API. Companion os-status check
`tracker-configured` (issue #11) validates the `tracker` key grammar and nudges
unconfigured git projects (daily-snoozed).
## Build and verification history
- **AFK slices shipped (2026-07-10):** #10 plugin scaffold + `board-ensure` (uniform
lists/labels incl. new `semi` autonomy label), #11 `tracker` key in `.cc-os/config` +
os-status check, #12 pure routing resolver CLI, #13 capture/list skills + card CLI, #15
`card-triage` + `board-audit` named agents. Live verification same day: planka-api gem
0.1.0 installed locally (`rake install`, rbenv 4.0.2; publish/versioning parked behind gem
issue 13); `snapshot`, `board-ensure` (created the missing `semi` label on the cc-os
board), `card-add` + `card-label` all succeeded against production Planka with bot
credentials sourced from ovh-vps (`~/services/planka/bot-credentials.env`) in a subshell.
Follow-up captured as a `hitl` Backlog card: get planka_bot into credvault (no import
path — rotate vs. add import capability is a human decision).
- **planka-api 0.2.0 typed-returns migration (2026-07-10, commit `8df0cb7`):** 0.2.0 returns
`Planka::Types` value objects instead of parsed-JSON hashes. `board_ensurer.rb` and
`cards.rb` rewritten on typed accessors; `Cards#snapshot` builds explicit string-keyed
hashes from `BoardDetail#lists`/`#cards`/`#labels`/`#card_labels`. Test fakes rebuilt to
return real `Planka::Types` records. JSON output stays byte-compatible, so skill/agent
contracts are unchanged. Follow-up fix (issue #18, same day): the migration had silently
dropped one field from the per-card snapshot hash; restored the full 19-key camelCase card
key set plus `labels`, with a regression test pinning the key set including
`listChangedAt`.
- **Live verification 0.2.0 (2026-07-10, issue #20):** local machine had no `PLANKA_*`
env/credvault entry, so the plugin tree was rsynced to `ovh-vps:/tmp/os-backlog-verify`
and exercised there with bot credentials sourced in a subshell (contents never read or
printed). Results: tests 35 runs/0 failures on ovh-vps; `board-ensure cc-os` idempotent
(twice `(existing)`, nothing created); card-add/card-label/card-comment/snapshot all
correct with the full 20-key set; test card deleted via `Planka::Client#cards.delete`
(the CLI has no delete subcommand) and confirmed gone; verification tree removed. No
regressions vs. pre-0.2.0.
- **Slice 7 (2026-07-13, issue #16):** SessionStart injection note shipped
(`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
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,
`~/.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
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.
- **Board-id cache (2026-07-13, issue #22):** `Backlog::BoardResolver` with injected config
read/write lambdas; `board_id=` cached in `.cc-os/config` beside `tracker=` (name stays
source of truth; id honored only for `planka:` trackers and validated against the
returned board name; 404/mismatch/any cache failure degrades to name lookup and rewrites
the cache; write failures never fail the command). Comment-preserving `Config.set`
mirrors os-status's writer. 14 new tests; suite 90 runs / 198 assertions / 0 failures.
- **Slice 8 (2026-07-13, issue #17):** Operations board created in the Dev project (board
`1818308120145822783`) with uniform lists/labels; recurring card "Biweekly orchestration
IRL audit (recurring)" (card `1818310715438531668`, P2 + semi, first progress comment
`1818310889929966683`; recurrence contract: comment + move back to Backlog, never Done).
Tracker-routing convention canonized as **ADR-0033** (Planka = state / git issues = specs,
tracker key grammar, pointer + promotion rules; cites route/to-issues/session-start
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
only rescued `Planka::ValidationError`, but Planka rejects unknown colors as HTTP 400
`Planka::BadRequestError` — fallback now rescues both (regression test; suite 91 runs /
0 failures). os-status `/os-status:fix` step d reworded: invoke `/os-backlog:route`
directly, never pre-ask "forgejo or planka?" (boundary rule uses both — surfaced by the
servers-repo onboarding, cf. issue #26).
- **Issue-shape classification (2026-07-13, issue #26):** `inspect` now fetches lightweight
remote-issue metadata (tea `--output json` with string-index/joined-labels normalization;
gh `--json`; fail-soft → nil) and emits an always-present `issue_shape` key
(`sequential-chain` / `flat-adhoc` / `mixed` / null). Classification is a pure
`Inspector.classify_issue_shape` (label allowlist with whole-token matching; cross-refs
must hit the fetched set with dependency language within 40 chars; <3 issues or no
metadata → null). Route SKILL.md step 2 rewritten as three branches with **split as the
default** for spec-shaped issue sets (still exactly one tracker key — division of labor,
not two destinations); step 3 got a never-re-trigger exception; boundary rule now cites
ADR-0033 instead of restating it. 12 new tests incl. a CLI-contract test; suite 103 runs /
212 assertions / 0 failures.
- **Cross-project filing (2026-07-13, issue #27, ADR-0034):** `Backlog::ProjectIndex`
derived global index at `~/.cc-os/projects.json` (atomic temp+rename writes, corrupt/
missing → empty, `CC_OS_HOME` override), upserted fail-soft by config-write (canonical
path = realpath of the directory the config was written into — NOT the git toplevel, so
umbrella subprojects index as themselves; corrected 2026-07-13 review), queried via new
`projects [filter]` CLI subcommand (filter matches name, tracker, or path). Discoverer template + tea/gh/card-add mechanics in
`skills/route/references/cross-project-filing.md`; one CROSS-PROJECT rule line added to
the SessionStart note. 9 new tests; suite 112 runs / 233 assertions / 0 failures. Live
smoke: cc-os row present in the real index.
- **Wakeup design spike (2026-07-13, issue #28, ADR-0035/0036):** poll + tmux direction
chosen at the human gate. `Backlog::Wakeup` + `bin/wakeup-poll` thin pilot (dry-run
default, `--spawn` to create `cc-<project>-issue<N>` tmux sessions and persist
`~/.cc-os/wakeup-state.json`; opt-in `wakeup=true` in `.cc-os/config`; afk-ready → act,
semi → triage-only, else notify-only into pull-only `~/.cc-os/wakeup-summary.md`). No
cron installed; 6 tests; suite 128 runs / 0 failures. Follow-up recorded: single
designated poller machine (state stays per-host); cadence/cron mechanism and a
`config-write` wakeup key deferred until a first project opts in.
- **Review corrections (2026-07-13, cards #1818330518584820864 / #1818330528525321347):**
post-ship review (Codex + verification pass) of the triage hook and project index:
`Config#planka_board` (`tracker=planka:<board>` now counts everywhere, incl. the
Resolver, not just the explicit `board` key); triage hook board resolution moved to pure
`TriageCheck.board_name_for` honoring the umbrella rule (session cwd's `.cc-os/config`
overrides the git root's; hook previously read only the root config); index keying fixed
per above; hook timeout 8→15s; `CC_OS_DEBUG=1` surfaces swallowed hook errors on stderr.
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.
Suite 138 runs / 295 assertions / 0 failures.
- **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup
rollout (first opt-in project + poller machine + cadence).

View File

@ -0,0 +1,110 @@
# os-context (formerly os-orchestration) — component detail
_Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._
**Global os-context plugin** — `cc-os/plugins/os-context/` (git-tracked); symlinked into
`~/.claude/plugins/os-context`. Migrated 2026-07-03 from a standalone repo
(`~/dev/cc-plugins/orchestration/`), superseding per-project orchestration text (ADR-0019);
renamed and generalized from os-orchestration 2026-07-13 (ADR-0031). Entries below predating
the rename refer to the plugin by its former name.
## Component inventory
- Hooks: `hooks/inject.py` — SessionStart prompt composer: globs
`prompts/session-start/*.md`, sorts by filename, concatenates into one additionalContext
block for all sessions, with a deterministic line-count budget (warn >120 lines, refuse
>240 lines). Byte-identical output to the old single-file `ORCHESTRATION.md` behavior when
only `10-orchestration.md` is present (tested).
- Prompt files (2026-07-13): `10-orchestration.md` (the canonical session-orchestration +
delegation-economics rules; formerly `ORCHESTRATION.md`), `20-kickoff-conventions.md`,
`30-standing-safety.md`, `40-decision-memos.md` (43 lines; total injection 102/120).
Decision-memo auto-memory file deleted per single-source rule.
- Skill: `/os-context:audit-sessions` (formerly `/os-orchestration:audit-sessions`).
- State: `~/.local/state/os-context/` (old `os-orchestration` path symlinked;
`OS_ORCH_AUDIT_LEDGER` env var name kept). os-status's `orchestration-audit-due` check
updated to match.
## Session audits and wording iterations
- **WS1 session audit (2026-07-06):** 10 stratified real sessions audited via
`audit/bin/extract` (Ruby fact-sheet extractor, dual-use as future eval checker) +
parallel sonnet auditors + verified synthesis. Findings + E1E4 eval-scenario backlog:
`docs/orchestration-audit/2026-07-06-findings.md`. Headlines: model param silently
overridden to haiku on all 23 verified spawns — root cause
`CLAUDE_CODE_SUBAGENT_MODEL=haiku` in `~/.claude/settings.json` env block (removed
2026-07-06); omitted model param inherits the main-loop model; no over-sharing/full-dump
failures anywhere.
- **Wording fixes shipped (2026-07-06, post-audit):** ORCHESTRATION.md rewritten with
trigger-conditioned when→then phrasing for the three verified clusters — explicit
`model:` before every Agent call, subagent model self-report on sonnet/opus spawns (the E1
canary proved `resolvedModel` is NOT model-visible in the launch result; self-report is
the only observable channel), don't re-cover your own ground before delegating.
Delegation thresholds unchanged. Deterministic env-override detection delegated to
os-status (WS3).
- **Eval harness E1E3 (2026-07-06):** `plugins/os-context/eval/` — scripted headless
orchestration-behavior eval scored by the audit extractor (extended with per-segment read
targets). 6 run-set scenarios (E1P/N downgrade detection with runner stub; E2P/N
explicit-model fan-out vs no-delegation-on-trivial; E3P/N delegate-investigation vs
orienting-reads-correct) + 6 frozen reserve twins; Node.js "relaystation" fixture with
deterministic sandbox-time log generation; deterministic-first checker (`eval/bin/check`,
narrow frozen haiku-judge fallback for E1 language axes only, stubbable via
`ORCH_EVAL_JUDGE_CMD`); model-free `bin/self-test` 21/21. **Scenario Task blocks are
held-out — never run informally.** Procedure: `plugins/os-context/eval/README.md`.
- **Baseline grid (2026-07-06,** run-set × sonnet/haiku × 3 reps + 2 counted canaries):
negatives 18/18 PASS both tiers (zero over-delegation, zero false downgrade claims);
positives 1/18 on delegate-at-all — but transcript verification shows most positive FAILs
are *superior direct strategies* (scripted bulk edit; surgical greps over 400KB logs),
which the WS1 audit itself scored as justified non-delegation, vs genuine miss shapes
(haiku: whole 112KB logs into own context; 20-call per-file grind). 14/14 spawns
explicit-model; the one delegating E1P rep flagged the stubbed downgrade via self-report.
E2P/E3P A-axis criterion redesign pre-registered; reserve stayed frozen. ORCHESTRATION.md
gained a scripted-bulk-edit carve-out. TSV: `eval/results/2026-07-06-baseline-grid.tsv`;
analysis: vault note [[os-orchestration-eval-baseline-grid-results]].
- **WS4 economics loop COMPLETE + SHIPPED (2026-07-08;** run-set AND reserve now
contaminated for future wording tuning): ORCHESTRATION.md v3 shipped — cost-asymmetry +
tier-conditional threshold opener, symmetric delegate-when/work-directly-when trigger
lists, batching rule (~58 items per grouped spawn, SendMessage reuse), async rule,
Workflow effort dial; explicit-model/self-report/don't-re-cover-ground rules unchanged.
Harness extended first (commit b226601): real token-econ axes — subagent transcripts
persist at `<projects>/<flat-cwd>/<session-id>/subagents/agent-*.jsonl` with per-message
usage blocks (vault note [[claude-code-subagent-transcripts-and-token-accounting]]) — E5
batching pair + reserve twins (subagent-authored, held out), fable column, pre-registered
E2P/E3P criterion redesign landed. Results: E5P 0/6 → majority-PASS both tiers incl.
reserve twin 5/5 (fable median mltok 43% of baseline); negatives clean everywhere
(36/36). Residual gaps: E1P sonnet delegate-at-all (fixture too small), fable
downgrade-flag ~1/4 judge flicker. 15 truncated baseline reps mechanically
excluded+replaced (limit-banner sweep promoted to the autoresearch howto). TSVs:
`eval/results/2026-07-08-*.tsv`; loop log: `autoresearch/classic-260708-1039/`; vault
note [[os-orchestration-ws4-econ-wording-results]].
- **First IRL re-audit DONE (2026-07-10, ahead of schedule):** 19 production transcripts
post-2026-07-08 audited by 3 parallel agents (run: `autoresearch/improve-260710-1057/`).
Solved surfaces: model-param discipline (~48/49 spawns pinned), over-delegation guardrail.
Tier 1 findings → same-day one-shot ORCHESTRATION.md production patch (grid validation
impossible — sets contaminated): settled-design mid-session fan-out trigger,
never-sleep-poll rule, own eval/benchmark loops named as mechanical sequences,
unfamiliar-tree ~5-call Explore trigger. Acceptance check = next IRL audit (~23 weeks;
baseline: settled-design drift 8/19 sessions). Side finding needing separate repro:
harness ignored explicit `model: "sonnet"` pins on general-purpose agents (env override
unset — distinct from WS1 cause); the self-report line caught it.
- **IRL audit semi-automated (2026-07-10):** recurring biweekly audit formalized, working
from any cwd. Deterministic layer: `audit/bin/audit-stats` (Ruby, composes
`audit/bin/extract` fact-sheets) enumerates production transcripts since the last run
(excludes sandboxes, `*-eval` dirs, active/trivial sessions), writes
`~/.local/state/os-context/audit/run-<date>/` (stats.tsv/flags.md/sheets) and appends the
trend ledger `metrics.tsv` (seeded 2026-07-10: 23 sessions, 52 spawns, 1 missing model).
Judgment layer: `/os-context:audit-sessions` (rubric in `references/`) — sonnet auditor
fan-out over flagged regions only, tiered synthesis, run-over-run trend verdicts, stops
before any wording edit. Nudge: os-status check `orchestration-audit-due` warns when the
ledger's last row is ≥14 days old (missing ledger = machine not opted in, silent; env
override `OS_ORCH_AUDIT_LEDGER` for tests). Planka recurrence-manifest entry skipped
(gem offsets are `+1y/+6m/+3m/+1m` only; a `+2w` offset is blocked on parked gem
publish/versioning decisions).
- **Audit-driven wording patch (2026-07-13):** IRL session audit (session fdee577b) found
the delegation-economics rules failed to fire when a main-loop fable model implemented a
fully-specified fix list itself via TDD (~110k tokens). Two approved additions to
`prompts/session-start/10-orchestration.md`: (1) names a red-green TDD loop over an
already-specified fix list as a single delegable unit (issue #29); (2) names
review/diagnosis-agent convergence on a confirmed defect list as a fresh delegation
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
the edit.

View File

@ -0,0 +1,31 @@
# os-doc-hygiene — component detail
_Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._
**Global os-doc-hygiene plugin** — `cc-os/plugins/os-doc-hygiene/` (git-tracked,
2026-07-03); symlinked into `~/.claude/plugins/os-doc-hygiene`.
- Hooks: `hooks/hooks.json` → SessionStart hook (matcher: startup|resume) runs
`scripts/reminder.py` via `${CLAUDE_PLUGIN_ROOT}` (5s timeout), emitting a deterministic
zero-token reminder banner (once/day snooze).
- Behavior: monitors stale and bloated project documentation; per-project state under
`.cc-os/dochygiene/` (gitignored; moved from `.dochygiene/` 2026-07-12 per ADR-0027, with
canonical-then-legacy fallback reads and auto-migration + legacy dir removal on first
write; scanner self-excludes `.cc-os/`).
- Skills (verb-first, no `commands/` dispatcher — invoked directly as
`/os-doc-hygiene:<skill>`, per [[cc-os-plugin-skill-naming-convention]]): `check`
(AI-assisted classification of staleness signals, emits machine+human reports), `clean`
(AI-assisted or deterministic patch application with git-safe scoped cleanup), `status`
(read-only lifecycle-timestamp read), `sweep` (check then clean in sequence).
Reversion-protected via invariants.md + golden-example test fixtures.
- Scanner signals: broken refs, version skew, churn, archive ratio, frontmatter markers,
and — added 2026-07-13 (issue #25 part 1) — a deterministic `file_length` signal
(defaults 400 lines / 4000 estimated tokens, `--max-lines` / `--max-tokens`; token
estimate via `scripts/token_estimator.py`). Objective facts only; classification lives
downstream. Suite 286 passed as of 2026-07-13 (279 at the ADR-0027 move).
- Migration: migrated from standalone repo (`~/dev/cc-plugins/doc-hygiene/`, 2026-07-03) and
integrated into cc-os; no content conflicts. Renamed `doc-hygiene``os-doc-hygiene` per
the cc-os naming convention; skills renamed `hygiene-check`/`hygiene-clean` →
`check`/`clean`; the `commands/hygiene.md` dispatcher was removed in favor of two new
skills (`status`, `sweep`), aligning with the no-`commands/`-directory pattern
(2026-07-03).

View File

@ -0,0 +1,47 @@
# os-status — component detail
_Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._
**Global os-status plugin** — `cc-os/plugins/os-status/` (git-tracked, 2026-07-06);
symlinked into `~/.claude/plugins/os-status`. Decision record: ADR-0022 (architecture),
ADR-0026 (`/os-status:fix`), ADR-0027 (state location). Built via OpenSpec change
`add-os-status-plugin` (supersedes the WS3 plan doc).
- Purpose: aggregated deterministic SessionStart status checks for the cc-os plugin family —
per-project artifacts (ADR system, vault hub note) and environment hazards (subagent model
env override).
- Architecture: checks are **in-process** plain Python functions in `hooks/checks.py` with
the uniform signature `check(ctx) -> CheckResult(status, message)` plus one registry entry
(no per-plugin subprocess protocol, no per-project code copies — both rejected in
ADR-0022). Three states: `ok` (silent), `note` (near-zero-token additionalContext line,
never snoozed/suppressed), `warn` (aggregated into at most one banner; once-per-day snooze
+ permanent `suppress-<check>` markers in gitignored per-project `.cc-os/status/` — state
only, never code; shared `.cc-os/config` holds optional `hub`/`vault_path` key=value
overrides).
- Initial checks: `subagent-model-env-override` (env + `~/.claude/settings.json` `env`
block; runs outside git projects too — catches the WS1 Cluster 1 incident deterministically
at session start), `adr-system-present` (verbatim port of os-adr's hook behavior incl.
`.os-adr/suppress`), `vault-hub-note-present` (config slug first, else `type/hub` +
`project/<name>` facet-tag scan of the vault; missing → warn naming `/os-vault:write`).
Later additions: `tracker-configured` (2026-07-10, os-backlog issue #11),
`orchestration-audit-due` (2026-07-10), `project-graph-present` and
`config-version-current` (2026-07-12).
- Added 2026-07-12 (ADR-0026, issue #21): `/os-status:fix` skill — the unified project
setup/update command; runs the registry via `python3 hooks/checks.py --json` (each result
carries a `remediation` pointer), drives the owning plugin's remediation per failing
check, stamps `version=` into `.cc-os/config` (`CURRENT_CONFIG_VERSION`); idempotent, so
re-running is the update path. Warn banner names `/os-status:fix` as the single entry
point. Human gates preserved: fix never fabricates a tracker value and never edits global
settings.json.
- Fix tune-up (2026-07-12, issues #23/#24): the credvault smoke-test of `/os-status:fix`
(session cd1ae1cd) was on-contract but surfaced four gaps. Fixes: `clear_snooze` helper +
self-clearing snoozes when a check evaluates ok/note (both runners); own state relocated
to `.cc-os/status/` with legacy fallback read + migrate-on-write (ADR-0027);
`write_config_value` normalizes the whole file to `key=value` (no spaces); fix SKILL.md
scopes out committing, adds an explicit "`.cc-os/` in .gitignore" step, clarifies when
re-runs are needed. Re-run of `/os-status:fix` in credvault is the pending IRL
verification.
- Tests: `python3 tests/hook_test.py` (80 as of 2026-07-12; 64 post-#21; 36 initial) incl.
byte-identity of PRESENT_NOTE/ABSENT_NOTE against os-adr's source. Invariants in
`invariants.md`. Smoke-tested 2026-07-06 (cache==source, real headless session fired the
hook, env-override canary warns).

View File

@ -0,0 +1,93 @@
# os-vault — component detail
_Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._
**Global os-vault plugin** — `cc-os/plugins/os-vault/` (git-tracked, 2026-06-12); symlinked
into `~/.claude/plugins/os-vault`. Renamed from `memory` 2026-07-03 (ADR-0018 records the
marketplace-manifest lesson). Plugin sourcing/port architecture: ADR-0016.
## Component inventory
- Hooks: `hooks/``session_start.py` (SessionStart; staleness check + detached graph
rebuild, and, as of 2026-07-10, a fast-forward-only `git pull` (`timeout 10 git pull
--ff-only --quiet`) for `cfg.vault_path` and `cfg.memsearch_dir` before the staleness
check — silent on success, quiet one-line stderr note on failure (offline machine), never
blocking; closes the multi-machine push-only gap, mirroring the push style of the sync
hooks in the opposite direction), `session_context.py` (project graph path only),
`post_tool_use_write.py`, `session_end.py` (vault journal), `memsearch_sync.py` (second
SessionEnd hook; memsearch auto-commit+push, 30s timeout), `vault_sync.py` (third
SessionEnd hook; vault auto-commit+push to forgejo.swansoncloud.com/jared/SecondBrain, 30s
timeout; mirrors memsearch_sync.py)
- Shared modules: `config.py` (load_config → frozen Config dataclass), `hook_io.py`
(read_input → HookInput dataclass), `session_state.py` (record_touch/read_touches;
encapsulates `/tmp/claude-vault-touched-$SESSION_ID` contract)
- Skills: `skills/` — query, write, reorganize, onboard-project (assessment-first
onboarding: surveys repo, classifies excludes by type (11 categories), writes per-project
`.graphifyignore`, confirms with user, then extracts using `qwen25-coder-7b-16k` — per
ADR-0017), design-template (routes template-design ↔ new-type-creation: 4-step
template-design process + injection-economics filter, 9-step new-type lifecycle; backs
Phase 1 of the SB Content Plan — see `docs/memory-system/09-sb-content-plan.md`)
- Config: `config.yaml` — vault path, Ollama model (qwen25-coder-7b-16k), env vars
- Hook wiring: `~/.claude/settings.json` (hook entries invoke `/usr/bin/python3` with
absolute paths into cc-os)
- Isolation seam (2026-07-06, WS2): `OS_VAULT_PATH` env var overrides `vault_path` for the
write skill and every hook via `config.load_config()`; `OS_VAULT_SKIP_REBUILD` suppresses
the SessionStart graphify rebuild. Added for the eval harness; zero production impact when
unset.
## Build-out history (2026-06-09 → 2026-06-30)
- **2026-06-09:** plugin live at `~/.claude/plugins/os-vault/`; build-plan Steps 1, 2a, 2b,
3, 6 complete (incl. `onboard-project`). Step 1: vault-conventions.md, 6 fixture notes, 14
Graphify handbook + design notes migrated as scaffolding. Step 4 (memsearch): installed
via marketplace, `MEMSEARCH_DIR` global, Stop hook verified, search working. Step 5a
(memsearch git sync): private Forgejo repo, whitelist `.gitignore` (memory/*.md only),
auto-commit+push SessionEnd hook (ADR-0015; relocated to `memsearch_sync.py` by ADR-0016).
- **2026-06-15:** Step 5b done — vault initialized as git repo, pushed to private Forgejo
(52 files; git chosen over Syncthing); auto-commit+push via `vault_sync.py` (runs after
`session_end.py` so the daily journal note is included). Step 2e pilot done — llf-schema
(PHP 8.2 WordPress plugin) onboarded via `/os-vault:onboard-project`: 605 nodes / 930
edges / 52 communities; Ollama doc pass lossy on WordPress docs but AST pass solid. Step
2d closed — live vault `graphify-out/` is the baseline; fixture-only build superseded.
All required build steps complete as of this date.
- **2026-06-17:** onboard-project moved to assessment-first onboarding with per-project
`.graphifyignore` (ADR-0017); extraction model `qwen25-coder-7b-16k` (16k-context build of
qwen2.5-coder:7b — larger context window cuts chunk count per doc, the main speed lever).
- **2026-06-30:** SB Content Plan Phase 1 complete (`docs/memory-system/09-sb-content-plan.md`,
issues #1#6): vault-conventions.md reconciled to one authoritative typed frontmatter
schema (slug-only filenames; `source:` and `scope` are frontmatter fields, not tags); new
`/os-vault:design-template` skill; three templates in `~/Documents/SecondBrain/_templates/`
(`howto.md`, `convention.md`, `reference.md` with four-subtype variant selector), each
dogfooded; four proof-of-concept notes patched. Phase 2 (issue #7) remains open as the
steady-state migration/onboarding epic (no code).
## Write-behavior eval (WS2)
- Harness (2026-07-06): `plugins/os-vault/eval/` — held-out unprompted vault-write
discrimination eval (Eval B/C playbook: ambiguity ladder L1 explicit → L3 conceptual,
paired positives/negatives, 6 run-set scenarios on a new `reportgen` Ruby fixture + 6
frozen reserve twins in a different knowledge domain, isolated sandbox vault,
headless-only runner, deterministic-first Ruby checker with narrow frozen offer-detection
judge fallback stubbable via `OS_VAULT_EVAL_JUDGE_CMD`, model-free `bin/self-test` 21/21).
**Scenario Task blocks are held-out — never run informally; the reserve set is never even
read informally.** Procedure: `plugins/os-vault/eval/README.md`.
- Untuned baseline grid (2026-07-06, run-set × sonnet/haiku × 3 reps + 1 counted canary, 37
reps, zero harness errors): **positives 1/19, negatives 18/18**. Headline: at L1 (explicit
cue) both tiers persist the knowledge every rep but route it to Claude Code's built-in
auto-memory instead of the vault; L2/L3 mostly don't persist at all; zero over-triggering
anywhere. TSV: `eval/results/2026-07-06-baseline-grid.tsv`; full analysis: vault note
[[os-vault-write-eval-baseline-grid-results]].
- WS2 wording loop COMPLETE (2026-07-07, 4 iterations + confirm + held-out reserve; run-set
AND reserve now contaminated): shipped destination-ladder write SKILL.md + SessionStart
USAGE_NOTE (mechanical triggers + before-final-reply checkpoint), mechanical vault-root
resolution (`${OS_VAULT_PATH:-$HOME/Documents/SecondBrain}` — fixed real-vault breaches
found on iteration 1), fixture CLAUDE.md "Cross-project memory" section (**the
real-project adoption template**, required to reach haiku at L2/L3; includes ADR-vs-vault
disambiguation), candidates 24 unscored, post-reserve YAML-colon quoting rule. Confirm
grid sonnet 18/18 / haiku 16/18; reserve (held-out) positives 8/18 strict / trigger-axis
13/18, negatives 17/18 (vs baseline 1/19, 18/18). Residual gaps: YAML-hostile titles,
haiku L2 no-trigger, generalization-laundering over-trigger. One checker false-positive
fixed+rescored mid-loop (breach regex vs `2>/dev/null`); negative-criterion redesign
pre-registered. TSVs: `eval/results/2026-07-07-wording-*.tsv`; loop log:
`autoresearch/classic-260707-1253/loop-log.md`; analysis: vault note
[[os-vault-write-eval-wording-loop-results]]. Next signal is production IRL audits.

View File

@ -5,7 +5,9 @@
# cards, snapshot, inspect, config-write.
#
# Usage:
# os-backlog board-ensure NAME --project Dev|Clients
# 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
@ -15,7 +17,9 @@
# os-backlog cards --board NAME (human-readable per-list card listing)
# os-backlog snapshot --board NAME (full board state as JSON; read-only)
# os-backlog inspect (detect existing tracking for cwd repo, JSON)
# os-backlog config-write <tracker-value> (write tracker key to .cc-os/config)
# os-backlog config-write <tracker-value> (write tracker key to .cc-os/config
# + upsert the global project index)
# os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON)
#
# board-ensure/activate/archive talk to Planka over the network (the
# installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/
@ -47,13 +51,33 @@ 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 "Dev" unless idx
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 (Dev or Clients)")
project || fail_soft("--project requires a value (a Planka project name)")
end
def parse_flag(args, flag)
@ -84,37 +108,79 @@ rescue StandardError => e
{ "checked" => false, "reason" => e.message }
end
# Best-effort open-issue count via tea (Forgejo) or gh (GitHub), whichever
# the detected remote implies. Fails soft per-field.
# Best-effort open-issue count + lightweight per-issue metadata via tea
# (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails
# soft per-field: any fetch/parse failure means metadata nil. Returns
# [issue_cli_hash, metadata_or_nil] — the metadata feeds the pure
# Backlog::Inspector.classify_issue_shape and never appears in the JSON.
def inspect_issue_cli(remote_info)
unless remote_info && %i[forgejo github].include?(remote_info[:kind])
return { "tool" => nil, "open_count" => nil, "reason" => "no forgejo/github remote detected" }
return [{ "tool" => nil, "open_count" => nil, "reason" => "no forgejo/github remote detected" }, nil]
end
repo_slug = "#{remote_info[:owner]}/#{remote_info[:repo]}"
if remote_info[:kind] == :forgejo
tea_issue_count(repo_slug)
tea_issues(repo_slug)
else
gh_issue_count(repo_slug)
gh_issues(repo_slug)
end
end
def tea_issue_count(repo_slug)
return { "tool" => "tea", "open_count" => nil, "reason" => "tea CLI not found" } unless system("which tea > /dev/null 2>&1")
def tea_issues(repo_slug)
return [{ "tool" => "tea", "open_count" => nil, "reason" => "tea CLI not found" }, nil] unless system("which tea > /dev/null 2>&1")
out = `tea issues list --repo #{repo_slug} --state open --fields index,title,labels,body --output json 2>/dev/null`
parsed = JSON.parse(out)
issues = parsed.map do |issue|
{ number: issue["index"].to_i, title: issue["title"],
labels: issue["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
body: issue["body"] }
end
[{ "tool" => "tea", "open_count" => issues.size }, issues]
rescue StandardError
# JSON mode unavailable/unparsable: fall back to the plain line count.
out = `tea issues list --repo #{repo_slug} --state open 2>/dev/null`
lines = out.to_s.lines.map(&:strip).reject(&:empty?)
{ "tool" => "tea", "open_count" => lines.size }
[{ "tool" => "tea", "open_count" => lines.size }, nil]
end
def gh_issue_count(repo_slug)
return { "tool" => "gh", "open_count" => nil, "reason" => "gh CLI not found" } unless system("which gh > /dev/null 2>&1")
def gh_issues(repo_slug)
return [{ "tool" => "gh", "open_count" => nil, "reason" => "gh CLI not found" }, nil] unless system("which gh > /dev/null 2>&1")
out = `gh issue list --repo #{repo_slug} --state open --json number 2>/dev/null`
out = `gh issue list --repo #{repo_slug} --state open --json number,title,labels,body 2>/dev/null`
parsed = JSON.parse(out)
{ "tool" => "gh", "open_count" => parsed.size }
rescue JSON::ParserError
{ "tool" => "gh", "open_count" => nil, "reason" => "could not parse gh output" }
issues = parsed.map do |issue|
{ number: issue["number"].to_i, title: issue["title"],
labels: Array(issue["labels"]).map { |label| label["name"] },
body: issue["body"] }
end
[{ "tool" => "gh", "open_count" => issues.size }, issues]
rescue StandardError
[{ "tool" => "gh", "open_count" => nil, "reason" => "could not parse gh output" }, nil]
end
# Canonical path for the global project index: the realpath of cwd — the
# directory config-write just wrote .cc-os/config into, i.e. the project
# the row describes. Deliberately NOT the git toplevel: in an umbrella
# repo a subdirectory's config is its own project, and keying on the
# toplevel would make every subproject overwrite the umbrella's row.
# Never raises.
def canonical_repo_path
File.realpath(Dir.pwd)
rescue StandardError
Dir.pwd
end
# Best-effort upsert of the global project index row after a successful
# config-write. Index failures must never fail config-write — warn at most.
def update_project_index(tracker)
repo_path = canonical_repo_path
remote = `git remote get-url origin 2>/dev/null`.strip
remote = nil if remote.empty?
Backlog::ProjectIndex.new.upsert(path: repo_path, name: File.basename(repo_path),
tracker: tracker, remote: remote)
rescue StandardError => e
warn "os-backlog: (non-fatal) could not update project index: #{e.message}"
end
command, *rest = ARGV
@ -157,19 +223,19 @@ when "card-add"
title = require_flag(rest, "--title", "card-add")
description = parse_flag(rest, "--description")
client = build_client
card = Backlog::Cards.new(client: client).add(board_name: board, title: title, description: description)
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 = Backlog::Cards.new(client: client).move(board_name: board, card_id: card_id, to: to)
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 = Backlog::Cards.new(client: client).snapshot(board_name: board)
snapshot = build_cards(client).snapshot(board_name: board)
snapshot["lists"].each do |list|
puts "#{list['name']} (#{list['cards'].size})"
list["cards"].each do |card|
@ -180,19 +246,19 @@ when "cards"
when "snapshot"
board = require_flag(rest, "--board", "snapshot")
client = build_client
puts JSON.pretty_generate(Backlog::Cards.new(client: client).snapshot(board_name: board))
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
Backlog::Cards.new(client: client).attach_label(board_name: board, card_id: card_id, label: label)
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 = Backlog::Cards.new(client: client).comment(card_id: card_id, text: text)
comment = build_cards(client).comment(card_id: card_id, text: text)
puts "commented on card ##{card_id} (comment ##{comment.id})"
when "inspect"
repo_path = Dir.pwd
@ -203,12 +269,14 @@ when "inspect"
remote_output = `git -C #{Shellwords.escape(repo_path)} remote -v 2>/dev/null`
remote_info = Backlog::Tracker.parse_remote(remote_output)
issue_cli, issue_metadata = inspect_issue_cli(remote_info)
findings = {
"repo_path" => repo_path,
"tracker_configured" => config.tracker,
"planka" => inspect_planka(repo_path),
"git_remote" => remote_info ? remote_info.transform_keys(&:to_s) : nil,
"issue_cli" => inspect_issue_cli(remote_info),
"issue_cli" => issue_cli,
"issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata),
"in_repo_issue_files" => Backlog::Inspector.issue_files(repo_path)
}
puts JSON.pretty_generate(findings)
@ -226,13 +294,24 @@ when "config-write"
updated = Backlog::Config.merge(existing, "tracker", value)
FileUtils.mkdir_p(config_dir)
File.write(config_path, updated)
update_project_index(value)
puts "tracker set to #{value} in #{config_path}"
when "projects"
filter = rest.shift
projects = Backlog::ProjectIndex.new.all
if filter
projects = projects.select do |path, row|
[row["name"], row["tracker"], path].any? { |field| field.to_s.include?(filter) }
end
end
puts JSON.pretty_generate({ "projects" => projects })
when nil, "-h", "--help"
puts <<~USAGE
usage: os-backlog <command> [options]
commands:
board-ensure NAME --project Dev|Clients
board-ensure NAME [--project PROJECT] (any project name; creates it if missing;
default derived from cwd path)
activate NAME
archive NAME --yes
resolve (reads {"repo_path","config","boards"} JSON from stdin)
@ -243,7 +322,8 @@ when nil, "-h", "--help"
card-label --board NAME --card ID --label NAME
card-comment --card ID --text "..."
inspect (detect existing tracking for cwd repo, JSON)
config-write <tracker-value> (write tracker key to .cc-os/config)
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)
USAGE
exit(command.nil? ? 1 : 0)
else

View File

@ -0,0 +1,131 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# wakeup-poll: one pass of the issue-triggered wakeup poller (ADR-0035).
#
# Reads the global project index (~/.cc-os/projects.json, ADR-0034), and for
# each wakeup-opted-in forgejo:/github: project lists open issues (tea/gh),
# diffs against last-seen state (~/.cc-os/wakeup-state.json), and for each
# NEW issue either spawns a tmux session (ADR-0036) or — the default —
# prints the command it WOULD run. hitl/unlabeled issues are notify-only:
# appended to ~/.cc-os/wakeup-summary.md (pull-only, notification policy v2).
#
# Usage:
# wakeup-poll # dry-run: print planned actions, touch no state
# wakeup-poll --spawn # actually create tmux sessions + persist state
#
# Fails soft everywhere: missing index, unroutable projects, tea/gh errors,
# and absent tmux each degrade to a printed note, never a crash. The poller
# uses tea/gh's existing logins only and never reads ~/.credentials.
# Pilot only — install no cron around this until ADR-0035 is Accepted.
require "json"
require "open3"
require "shellwords"
require_relative "../lib/backlog"
spawn_mode = ARGV.include?("--spawn")
def note(msg) = puts("wakeup-poll: #{msg}")
def list_issues(kind, slug)
cmd =
case kind
when "forgejo" then ["tea", "issues", "list", "--repo", slug, "--state", "open", "--output", "json"]
when "github" then ["gh", "issue", "list", "--repo", slug, "--state", "open",
"--json", "number,title,labels,body,url"]
end
out, err, status = Open3.capture3(*cmd)
return nil unless status.success?
rows = JSON.parse(out)
# Normalize gh's shape to tea's {index, title, labels}.
rows.map do |r|
{ "index" => (r["index"] || r["number"]).to_s,
"title" => r["title"],
"labels" => r["labels"].is_a?(Array) ? r["labels"].map { |l| l["name"] }.join(",") : r["labels"].to_s,
"body" => r["body"].to_s,
"url" => r["url"].to_s }
end
rescue StandardError => e
warn "wakeup-poll: #{slug}: #{e.class}: #{e.message}"
nil
end
index = Backlog::ProjectIndex.new
projects = index.all
if projects.empty?
note "project index empty or unreadable — nothing to do"
exit 0
end
config_reader = lambda do |path|
file = File.join(path, ".cc-os", "config")
File.exist?(file) ? File.read(file) : ""
rescue StandardError
""
end
eligible = Backlog::Wakeup.eligible_projects(projects, config_reader: config_reader)
skipped = projects.keys - eligible.map { |p| p[:path] }
skipped.each { |path| note "skip #{path} (#{projects[path]['tracker'] || 'no tracker'}: not a wakeup-eligible forgejo/github project, or wakeup=true not set)" }
if eligible.empty?
note "no opted-in forgejo/github projects — nothing to do"
exit 0
end
state = Backlog::Wakeup::State.new
summary_path = File.join(File.dirname(Backlog::Wakeup::State.default_path), "wakeup-summary.md")
eligible.each do |project|
issues = list_issues(project[:tracker_kind], project[:repo_slug])
if issues.nil?
note "#{project[:repo_slug]}: issue listing failed (is #{project[:tracker_kind] == 'forgejo' ? 'tea' : 'gh'} logged in?) — skipping"
next
end
last_seen = state.last_seen(project[:repo_slug])
if last_seen.nil?
top = Backlog::Wakeup.highest_index(issues)
note "#{project[:repo_slug]}: first scan — baselining at ##{top}, waking nothing"
state.record(project[:repo_slug], top) if spawn_mode
next
end
fresh = Backlog::Wakeup.new_issues(issues, last_seen)
note "#{project[:repo_slug]}: #{fresh.size} new issue(s) since ##{last_seen}" if fresh.any?
fresh.each do |issue|
tier = Backlog::Wakeup.tier(issue["labels"])
if tier == :notify
line = "- #{Time.now.strftime('%Y-%m-%d')} #{project[:repo_slug]}##{issue['index']} (#{tier}): #{issue['title']}"
if spawn_mode
File.open(summary_path, "a") { |f| f.puts(line) }
note "notify-only -> appended to #{summary_path}"
else
note "would append to #{summary_path}: #{line}"
end
next
end
prompt = Backlog::Wakeup.session_prompt(
issue: issue, tier: tier,
url: issue["url"].empty? ? "#{project[:tracker_kind]}:#{project[:repo_slug]}##{issue['index']}" : issue["url"]
)
cmd = Backlog::Wakeup.tmux_command(
project: project[:name], path: project[:path],
issue_index: issue["index"], prompt: prompt
)
if spawn_mode
ok = system(*cmd)
note ok ? "spawned #{cmd[4]}" : "tmux spawn failed for #{cmd[4]} (is tmux installed?)"
else
note "would run: #{cmd.shelljoin}"
end
end
state.record(project[:repo_slug], Backlog::Wakeup.highest_index(issues, last_seen)) if spawn_mode
end
note "dry-run complete — no state written, no sessions spawned (use --spawn)" unless spawn_mode

View File

@ -0,0 +1,21 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|compact",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py",
"timeout": 5
},
{
"type": "command",
"command": "ruby ${CLAUDE_PLUGIN_ROOT}/hooks/triage_check.rb",
"timeout": 15
}
]
}
]
}
}

View File

@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""session_start.py — deterministic SessionStart injection note for os-backlog.
Modeled on os-adr's session_start.py (the wording-source-of-record pattern).
Injects the backlog process rules WHEN->THEN mechanical triggers only.
Deliberately contains ZERO board state and no briefs (notification policy v2:
pull beats push board state is fetched on demand via /os-backlog:list, never
pushed into a session).
Sibling hook triage_check.rb (card 1818330518584820864) is a narrow, sanctioned
exception to that policy: it makes one bounded Planka read to decide whether to
push a single mechanical dispatch directive not a brief, not board contents
when the current project's own Backlog has unlabeled cards. The only board-
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)
not a git repo -> silent (process rules only apply inside real projects)
Autonomy-label semantics in NOTE are the ADR-029 contract (which supersedes
the wording in issue #16's body): hitl never picked up autonomously; semi
stops at Review for human sign-off; afk-ready goes straight to Done after
verification, skipping Review.
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.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Optional
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).
- 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.
- 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.
- 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.
- 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).
- 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.
- 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).
- 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.
"""
def find_project_root(cwd: Path) -> Optional[Path]:
"""Nearest ancestor (including cwd) containing .git, else None."""
for candidate in [cwd, *cwd.parents]:
if (candidate / ".git").exists():
return candidate
return None
def run(root: Optional[Path], out) -> str:
"""Pure decision: ('context'|'silent'). Emits hook JSON when in a project."""
if root is None:
return "silent"
out.write(json.dumps({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": NOTE,
}
}))
return "context"
def main() -> int:
try:
run(find_project_root(Path.cwd()), sys.stdout)
except Exception:
pass # never block a session
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,94 @@
#!/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,7 +1,11 @@
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/inspector"
require_relative "backlog/project_index"
require_relative "backlog/resolver"
require_relative "backlog/tracker"
require_relative "backlog/triage_check"
require_relative "backlog/wakeup"

View File

@ -20,9 +20,19 @@ module Backlog
# 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
@ -35,7 +45,8 @@ module Backlog
# @param name [String] board name (never an archived---prefixed name;
# this method only ever targets the active board)
# @param project_name [String] "Dev" or "Clients" (or any project name)
# @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)
@ -86,9 +97,23 @@ module Backlog
end
# Quirk 2: null the bot's sole ownerProjectManagerId and add the human
# as a project manager, so the project shows up for them.
# 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)
return unless @human_user_id
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)
@ -140,7 +165,7 @@ module Backlog
last_error = nil
candidates.each do |color|
return @client.labels.create(board.id, name: label_name, color: color, position: 65_536)
rescue Planka::ValidationError => e
rescue Planka::ValidationError, Planka::BadRequestError => e
last_error = e
next
end

View File

@ -0,0 +1,86 @@
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,4 +1,5 @@
require_relative "board_spec"
require_relative "board_resolver"
module Backlog
# Card-level operations over an authenticated Planka client: create a
@ -12,8 +13,13 @@ module Backlog
# Never deletes anything.
class Cards
# @param client [Planka::Client] an already-authenticated client
def initialize(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
@ -163,11 +169,7 @@ module Backlog
end
def find_board!(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)"
@board_resolver.board_for(name)
end
def find_list!(board, list_name)

View File

@ -35,6 +35,24 @@ module Backlog
@data["tracker"]
end
# @return [String, nil] the effective Planka board name: the explicit
# `board` key when present, else the board named by a
# `tracker=planka:<board>` key (the form config-write actually
# 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
@data["board_id"]
end
def configured? = !board.nil?
def tracker_configured? = !tracker.nil?
@ -55,5 +73,35 @@ module Backlog
data[key.to_s] = value.to_s
data.map { |k, v| "#{k}=#{v}\n" }.join
end
# Like .merge but comment/format-preserving: every non-key line
# (comments, blanks) is left byte-for-byte untouched; existing
# key=value lines are re-emitted in the normalized no-spaces form; the
# key is replaced in place or appended if absent. Mirrors os-status's
# write_config_value (plugins/os-status/hooks/state.py) — the shared
# .cc-os/config contract owner. Pure — no filesystem.
#
# @param contents [String, nil] existing raw `.cc-os/config` contents
# @param key [String, Symbol] key to set
# @param value [Object] value to set it to
# @return [String] the updated contents
def self.set(contents, key, value)
key = key.to_s
found = false
out = contents.to_s.split("\n").map do |line|
stripped = line.strip
next line if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=")
k, v = stripped.split("=", 2)
if k.strip == key
found = true
"#{key}=#{value}"
else
"#{k.strip}=#{v.strip}"
end
end
out << "#{key}=#{value}" unless found
"#{out.join("\n")}\n"
end
end
end

View File

@ -8,6 +8,62 @@ module Backlog
CANDIDATE_DIRS = ["docs/issues"].freeze
CANDIDATE_FILES = ["ISSUES.md"].freeze
# Labels that mark an issue as part of a dependency chain. Matched as
# whole (hyphen-preserving) tokens, case-insensitively — "not-blocked"
# does NOT match "blocked".
CHAIN_LABELS = %w[blocked blocked-by depends depends-on prereq
prerequisite waiting needs].freeze
# Dependency language that must appear near a cross-reference for it to
# count as chain evidence — a plain "fixed in #12" does not.
DEPENDENCY_LANGUAGE = /\b(blocked|depends|after|requires|prereq|follows)/i
CROSS_REF_WINDOW = 40 # chars either side of the "#N" reference
# Pure classifier for the shape of a repo's open git issues — no shell,
# no network. Feeds the route skill's split-vs-migrate recommendation
# (ADR-0033: git issues are specs; a chain of interdependent issues is
# spec-shaped and should stay put).
#
# @param issues [Array<Hash>, nil] one hash per open issue:
# {number:, title:, labels: [String], body:}
# @return [String, nil] "sequential-chain" | "flat-adhoc" | "mixed", or
# nil when there's no metadata or fewer than 3 issues (no evidence,
# which is distinct from evidence of flatness)
def self.classify_issue_shape(issues)
return nil if issues.nil? || issues.size < 3
numbers = issues.map { |issue| issue[:number].to_i }
with_evidence = issues.count { |issue| chain_evidence?(issue, numbers) }
return "flat-adhoc" if with_evidence.zero?
with_evidence * 3 >= issues.size * 2 ? "sequential-chain" : "mixed"
end
def self.chain_evidence?(issue, numbers)
chain_label?(issue[:labels]) ||
chain_cross_reference?(issue[:body], own_number: issue[:number].to_i, numbers: numbers)
end
def self.chain_label?(labels)
Array(labels).any? do |label|
tokens = label.to_s.downcase.scan(/[a-z][a-z-]*[a-z]|[a-z]/)
tokens.any? { |token| CHAIN_LABELS.include?(token) }
end
end
def self.chain_cross_reference?(body, own_number:, numbers:)
text = body.to_s
text.enum_for(:scan, /#(\d+)/).any? do
match = Regexp.last_match
referenced = match[1].to_i
next false if referenced == own_number || !numbers.include?(referenced)
window_start = [match.begin(0) - CROSS_REF_WINDOW, 0].max
window = text[window_start, (match.begin(0) - window_start) + match[0].length + CROSS_REF_WINDOW]
window.match?(DEPENDENCY_LANGUAGE)
end
end
# @param repo_path [String] absolute path to the repo
# @return [Array<String>] repo-relative paths of in-repo issue tracking
# found (empty if none)

View File

@ -0,0 +1,71 @@
require "json"
require "fileutils"
module Backlog
# The global project index at ~/.cc-os/projects.json: a derived map of
# canonical repo path -> {name, tracker, remote, updated_at}, maintained as
# a side effect of config-write (issue #27). Deliberately GLOBAL state
# (distinct from per-project .cc-os/ under ADR-0027) so any session can ask
# "which tracker does project X use" without visiting its repo.
#
# Pure over an injected path: callers hand in the already-canonical repo
# path (the CLI resolves it via `git rev-parse --show-toplevel`). Read
# paths never raise — a missing or corrupt file reads as an empty index; a
# corrupt file is only overwritten when a new upsert happens. Writes are
# atomic (temp file + rename in the same directory).
class ProjectIndex
VERSION = 1
# Default index path, honoring a CC_OS_HOME override (used by tests to
# keep the real index untouched) and falling back to $HOME/.cc-os.
def self.default_path(env: ENV)
base = env["CC_OS_HOME"] || File.join(env.fetch("HOME", Dir.home), ".cc-os")
File.join(base, "projects.json")
end
def initialize(path: self.class.default_path, today: -> { Time.now.strftime("%Y-%m-%d") })
@path = path
@today = today
end
# @return [Hash] the projects map (canonical path -> row); {} when the
# file is missing or unreadable/corrupt. Never raises.
def all
data = read
data["projects"].is_a?(Hash) ? data["projects"] : {}
end
# Insert or update the row for a canonical repo path. Atomic write;
# creates the parent directory if missing.
def upsert(path:, name:, tracker:, remote:)
projects = all
projects[path] = {
"name" => name,
"tracker" => tracker,
"remote" => remote,
"updated_at" => @today.call
}
write({ "version" => VERSION, "projects" => projects })
projects[path]
end
private
def read
return {} unless File.exist?(@path)
parsed = JSON.parse(File.read(@path))
parsed.is_a?(Hash) ? parsed : {}
rescue StandardError
{}
end
def write(data)
dir = File.dirname(@path)
FileUtils.mkdir_p(dir)
temp = File.join(dir, ".projects.json.tmp.#{Process.pid}")
File.write(temp, JSON.pretty_generate(data) + "\n")
File.rename(temp, @path)
end
end
end

View File

@ -18,6 +18,31 @@ module Backlog
DEV_ROOT = File.expand_path("~/dev")
CLIENTS_ROOT = File.expand_path("~/clients")
SERVERS_ROOT = File.expand_path("~/servers")
# 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
@ -31,7 +56,8 @@ module Backlog
# @return [Decision]
def resolve
return decision_for_name(@config.board) || stop_and_discuss if @config.configured?
configured_board = @config.planka_board
return decision_for_name(configured_board) || stop_and_discuss if configured_board
derived_decision || stop_and_discuss
end
@ -60,17 +86,6 @@ module Backlog
def board_name = File.basename(@repo_path)
# "Dev" for paths under ~/dev/*, "Clients" for paths under ~/clients/*,
# nil (ambiguous) otherwise.
def project
return "Dev" if under?(DEV_ROOT)
return "Clients" if under?(CLIENTS_ROOT)
nil
end
def under?(root)
@repo_path == root || @repo_path.start_with?("#{root}/")
end
def project = self.class.project_for(@repo_path)
end
end

View File

@ -25,6 +25,22 @@ module Backlog
nil
end
# Where /to-issues output (spec slices) should be published, given the
# repo's tracker key. The ADR'd boundary: git issues = specs, Planka =
# state. So forgejo:/github: keys route spec slices to that git issue
# tracker. `planka:` / `repo:` / nil deliberately map to
# :planka_default — what that MEANS for spec-shaped output is a named
# decision gate at wording review (issue #16), not settled here.
#
# @return [Symbol] :git_issues | :planka_default | :unrouted (invalid key)
def self.issues_destination(value)
case kind(value)
when :forgejo, :github then :git_issues
when :planka, :repo then :planka_default
else value.nil? ? :planka_default : :unrouted
end
end
# Parse the first remote line of `git remote -v` output and classify it
# as a Forgejo or GitHub remote (or :unknown for anything else).
#

View File

@ -0,0 +1,69 @@
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

@ -0,0 +1,138 @@
require "json"
require "fileutils"
module Backlog
# Pure logic for the issue-triggered wakeup poller (ADR-0035): which
# projects are polled, which issues count as new, what autonomy tier a
# label set maps to, and the tmux command a wakeup would run (ADR-0036).
#
# All methods are pure over injected data — the bin/wakeup-poll glue does
# the shelling out (tea/gh, tmux) and file IO. Read paths never raise.
module Wakeup
module_function
# Autonomy tiers, from ADR-0029 label vocabulary (ADR-0035 mapping):
# afk-ready -> acting session; semi -> triage-only session;
# anything else (hitl / unlabeled) -> notify-only.
def tier(labels)
names = Array(labels).flat_map { |l| l.to_s.split(",") }.map(&:strip)
return :afk_ready if names.include?("afk-ready")
return :semi if names.include?("semi")
:notify
end
# Filter global-index rows (ADR-0034 shape: path -> row) down to
# wakeup-eligible projects: tracker forgejo:/github: AND the per-project
# .cc-os/config carries wakeup=true (config is the authority; the
# config_reader is injected so this stays pure).
#
# @return [Array<Hash>] rows as {path:, name:, tracker_kind:, repo_slug:}
def eligible_projects(projects, config_reader:)
projects.filter_map do |path, row|
tracker = row["tracker"].to_s
kind, slug = tracker.split(":", 2)
next unless %w[forgejo github].include?(kind) && !slug.to_s.empty?
next unless opted_in?(config_reader.call(path))
{ path: path, name: row["name"] || File.basename(path),
tracker_kind: kind, repo_slug: slug }
end
end
def opted_in?(config_text)
config_text.to_s.each_line.any? { |l| l.strip == "wakeup=true" }
end
# Issues newer than the last-seen index for this repo. A nil last_seen
# means the repo has never been scanned: baseline without waking
# anything (ADR-0035 — no bulk backfill of pre-existing issues).
def new_issues(issues, last_seen)
return [] if last_seen.nil?
issues.select { |i| i["index"].to_i > last_seen.to_i }
end
def highest_index(issues, floor = 0)
([floor.to_i] + issues.map { |i| i["index"].to_i }).max
end
# The tmux command a wakeup runs (ADR-0036 naming: cc-<project>-issue<N>).
def tmux_command(project:, path:, issue_index:, prompt:)
[
"tmux", "new-session", "-d",
"-s", "cc-#{project}-issue#{issue_index}",
"-c", path,
"claude", prompt
]
end
# Initial prompt for the wakened session: issue coordinates + autonomy
# tier + the standing guardrails (ADR-0035 context handoff). The issue
# body (with any Discoverer block) is passed through verbatim.
def session_prompt(issue:, tier:, url:)
action = tier == :afk_ready ? AFK_READY_ACTION : SEMI_ACTION
<<~PROMPT
Wakeup: new issue ##{issue['index']} — #{issue['title']}
#{url}
#{action}
File-don't-fix still applies (ADR-0034): never edit other projects.
Before exiting, post your triage/summary as a comment on the issue
that comment is the durable record (ADR-0036 lifecycle).
Issue body:
#{issue['body']}
PROMPT
end
AFK_READY_ACTION =
"This issue is labeled afk-ready: triage it and, if it is actionable " \
"within this repo, do the work."
SEMI_ACTION =
"This issue is labeled semi: TRIAGE ONLY — label, comment, and " \
"propose an approach, but stop at proposing. Do not implement."
# Last-seen state (~/.cc-os/wakeup-state.json): repo_slug -> highest
# issue index seen. Missing/corrupt reads as empty; writes are atomic.
class State
def self.default_path(env: ENV)
base = env["CC_OS_HOME"] || File.join(env.fetch("HOME", Dir.home), ".cc-os")
File.join(base, "wakeup-state.json")
end
def initialize(path: self.class.default_path)
@path = path
end
def last_seen(repo_slug)
read[repo_slug]
end
def record(repo_slug, index)
data = read
data[repo_slug] = index
write(data)
end
private
def read
return {} unless File.exist?(@path)
parsed = JSON.parse(File.read(@path))
parsed.is_a?(Hash) ? parsed : {}
rescue StandardError
{}
end
def write(data)
dir = File.dirname(@path)
FileUtils.mkdir_p(dir)
temp = File.join(dir, ".wakeup-state.json.tmp.#{Process.pid}")
File.write(temp, JSON.pretty_generate(data) + "\n")
File.rename(temp, @path)
end
end
end
end

View File

@ -11,7 +11,7 @@ Capture a piece of work as a card on the right Planka board, cheaply, without in
- **Next stays human-curated in both directions** — never move a card into or out of Next.
- **Never move a card labeled `hitl`** — hitl cards are human-owned, never self-assigned or moved by the AI.
- 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.
- Priority/autonomy labeling is not capture's job — leave new cards unlabeled; the card-triage agent batches that later.
- 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.
## Procedure
@ -31,9 +31,15 @@ All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
- `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.
3. **Ensure the board exists** (idempotent — safe to run every time):
```bash
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog board-ensure <board> --project <Dev|Clients>
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog board-ensure <board>
```
Project is `Dev` for repos under `~/dev/`, `Clients` for repos under `~/clients/`.
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>"]

View File

@ -6,11 +6,13 @@ Register a project's issue tracker: figure out what already exists, propose wher
## The boundary rule (what you're deciding between)
- **Planka = state.** Cards track *what's happening now* — backlog/next/doing/review/done. Use `planka:<board>` when the project's work is mostly ephemeral tasks, small fixes, or process/coordination — nothing that needs a durable written spec.
- **Git issues = specs only.** Use `forgejo:<owner>/<repo>` or `github:<owner>/<repo>` when work is specified as text that needs to survive and be referenced (features, migrations, architecture-level changes) — tracer-bullet slices from `/to-issues`, PRDs from `/to-prd`.
- **`repo:<path>`** is the escape hatch for a project that already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to keep doing so without moving to Planka or a git host's issue tracker.
- **Card-as-pointer + issue-chain-as-spec, for code efforts:** when a body of work needs both — ongoing state tracking AND a durable spec — the Planka card is a pointer (title + link) into a chain of git issues that hold the actual spec. Don't duplicate the spec text into the card description.
- **Promotion rule:** if a Planka-tracked effort accretes significant code/design decisions in card comments, that's a signal to promote it — open a git issue (or issue chain) holding the durable spec, and turn the Planka card into a pointer per the rule above. Flag this to the user when you see it happening; don't do it silently.
Planka = state, git issues = specs (ADR-0033, `docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md`) — the ADR is the canonical statement; the operative routing rules are:
- **WHEN** the project's work is mostly ephemeral tasks, small fixes, or process/coordination (state: backlog/next/doing/review/done, nothing needing a durable written spec) → `planka:<board>`.
- **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>`.
- **WHEN** a project already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to stay there → `repo:<path>` (the escape hatch).
- **WHEN** a code effort needs both state tracking and a durable spec → card-as-pointer + issue-chain-as-spec: the Planka card is a pointer (title + link) into the git issue chain holding the spec; never duplicate spec text into the card description.
- **WHEN** a Planka-tracked effort accretes significant code/design decisions in card comments → flag promotion to the user (open a git issue chain for the spec, turn the card into a pointer); never promote silently.
Valid tracker key formats (exactly one, written to `.cc-os/config`'s `tracker` key): `planka:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`.
@ -26,12 +28,16 @@ All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
- `tracker_configured` — an existing `tracker` key in `.cc-os/config`, if any. If this is already set, tell the user the project is already routed to it and confirm they want to re-route before continuing (re-routing is itself a decision gate — treat it as step 2).
- `planka` — whether a Planka board already resolves for this repo (via the same resolver `/os-backlog:capture` uses), or why that check couldn't run (gem/credentials unavailable — fail-soft, not blocking).
- `git_remote` — the parsed `git remote -v`, classified as `forgejo` (the user's self-hosted instance) / `github` / `unknown`, plus the open-issue count via `tea` or `gh` if that CLI is available (`null` with a `reason` if not — fail-soft, not blocking).
- `issue_shape` — a classification of the open git issues' shape: `sequential-chain` (most issues carry dependency labels or blocked-by/depends-on cross-references), `flat-adhoc` (no dependency structure), `mixed` (some), or `null` (no metadata, or fewer than 3 open issues — no evidence either way).
- `in_repo_issue_files``docs/issues/` and/or `ISSUES.md` if present.
2. **Synthesize and propose (NAMED DECISION GATE).** Given the findings, propose exactly ONE destination tracker key with a short rationale grounded in the boundary rule above — e.g. "this repo has an active Forgejo remote with 11 open issues and no Planka board; issues already carry specs, so `forgejo:jared/cc-os` fits git-issues-as-spec" or "no existing tracking found, work here is small ad hoc fixes; `planka:<repo-name>` fits Planka-as-state; a board will be created on first capture, not by this skill."
2. **Synthesize and propose (NAMED DECISION GATE).** Exactly ONE tracker key is ever written to `.cc-os/config` — the branches below differ in *which* key and *what happens to existing issues*, never in how many keys. Branch on the findings:
- **(a) No existing tracker found** (no open git issues, no in-repo issue files, no live board): propose one destination key with a short rationale grounded in the boundary rule — e.g. "no existing tracking found, work here is small ad hoc fixes; `planka:<repo-name>` fits Planka-as-state; a board will be created on first capture, not by this skill."
- **(b) Existing git issues that are spec-shaped** (`issue_shape` is `sequential-chain` or `mixed`): recommend the **SPLIT as the default**. Write `planka:<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.
- **(c) Existing git issues that are flat-adhoc** (`issue_shape` is `flat-adhoc`): migration to Planka is on the table — propose the destination key, and if confirmed, existing open issues go through the step-3 migration gate as usual. If `issue_shape` is `null` (no metadata or too few issues to tell), treat shape as unknown: ask the user which shape fits rather than assuming.
**Stop here and wait for the user to confirm or override the destination before writing anything or moving anything.** If findings are ambiguous (e.g. both a live Planka board AND an active issue tracker with open items, and it's unclear which is authoritative), say so plainly and ask rather than guessing.
3. **If migration is needed (SECOND NAMED DECISION GATE).** Only applies when the confirmed destination differs from where open items currently live (e.g. moving from ad hoc `ISSUES.md` entries to Planka, or from an unrouted Planka board to git issues). Before touching any live project history:
3. **If migration is needed (SECOND NAMED DECISION GATE).** Only applies when the confirmed destination differs from where open items currently live (e.g. moving from ad hoc `ISSUES.md` entries to Planka, or from an unrouted Planka board to git issues). **Exception — never re-trigger on the split:** a `planka:` tracker alongside by-design git spec issues (branch 2b, or any project whose git issues are specs per ADR-0033) is the intended end state, not a discrepancy; do not propose migrating those issues, now or on any later re-run of this skill. Before touching any live project history:
- Tell the user exactly what will move (list the items) and ask for explicit go-ahead. This gate is separate from the destination gate in step 2 — confirming the destination is not confirming the migration.
- Once confirmed, migrate mechanically using **existing machinery, not new code paths**: `card-add`/`cards` (via `/os-backlog:capture`'s CLI calls) for items moving into Planka; `tea`/`gh` issue-create commands for items moving into git issues. Do not invent bespoke migration scripts.
- **Back-link both ways so nothing is double-tracked**: the old item (closed Planka card comment, closed in-repo issue entry, or a note in the git-host issue if migrating away from it) gets a pointer to its new home; the new item (card description or issue body) links back to the source. Close/archive the old item once the back-link is in place — don't leave both open.

View File

@ -0,0 +1,39 @@
# Cross-project filing (file-don't-fix)
When work surfaces that belongs to a DIFFERENT project than the one this session is
working in, do not edit that project — file the work on ITS tracker and continue. The
owning project's own session picks it up with full context.
## Finding the target's tracker
Query the global project index (derived from every `config-write`, at
`~/.cc-os/projects.json`):
```
os-backlog projects # full index as JSON
os-backlog projects <substring> # filter by project name
```
Each row: canonical repo path -> `{name, tracker, remote, updated_at}`. If the target
project has no row, it hasn't been routed yet — tell the user rather than guessing.
## Filing mechanics by tracker kind
- `forgejo:<owner>/<repo>``tea issues create --repo <owner>/<repo> --title "..." --description "..."`
- `github:<owner>/<repo>``gh issue create --repo <owner>/<repo> --title "..." --body "..."`
- `planka:<board>` (planka-only targets) — `os-backlog card-add --board <board> --title "..." [--description "..."]`
Routing inside the target project still follows ITS boundary rule (ADR-0033: Planka is
state, git issues are specs) — a durable spec goes to the git-issue tracker even if the
project also has a board.
## Discoverer block (append verbatim to the issue/card description)
```
--------
**Discoverer:** <filing repo or path>, session <claude-code-session-id>, <YYYY-MM-DD>. <one-line motivation>
```
Session id source: the SessionStart hook receives it as the `session_id` field of its
stdin JSON; a live session can also find it in its transcript filename. If it is
genuinely unavailable, write `session id unavailable` — never fabricate one.

View File

@ -72,6 +72,17 @@ class BoardEnsurerTest < Minitest::Test
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
@ -109,6 +120,46 @@ class BoardEnsurerTest < Minitest::Test
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")

View File

@ -0,0 +1,135 @@
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

@ -93,4 +93,48 @@ class ConfigTest < Minitest::Test
assert_equal "my-hub", updated.to_h["hub"]
assert_equal "planka:board", updated.tracker
end
def test_board_id_reader
config = Backlog::Config.new("tracker=planka:board\nboard_id=42\n")
assert_equal "42", config.board_id
assert_nil Backlog::Config.new(nil).board_id
end
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"
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",
updated
end
def test_set_replaces_existing_key_in_place
existing = "# top comment\nboard_id=old\ntracker=planka:board\n"
updated = Backlog::Config.set(existing, "board_id", "new")
assert_equal "# top comment\nboard_id=new\ntracker=planka:board\n", updated
end
def test_set_normalizes_spaced_key_value_lines_only
existing = "# note\nversion = 1\n"
updated = Backlog::Config.set(existing, "board_id", "42")
assert_equal "# note\nversion=1\nboard_id=42\n", updated
end
def test_set_on_nil_contents
assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42")
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

View File

@ -1,6 +1,7 @@
require_relative "test_helper"
require "tmpdir"
require "fileutils"
require "json"
class InspectorTest < Minitest::Test
def test_detects_docs_issues_dir
@ -23,3 +24,104 @@ class InspectorTest < Minitest::Test
end
end
end
class ClassifyIssueShapeTest < Minitest::Test
def issue(number, title: "issue #{number}", labels: [], body: "")
{ number: number, title: title, labels: labels, body: body }
end
def test_nil_when_metadata_unavailable
assert_nil Backlog::Inspector.classify_issue_shape(nil)
end
def test_nil_when_fewer_than_three_issues_even_with_chain_labels
issues = [issue(1, labels: ["blocked"]), issue(2, labels: ["blocked"])]
assert_nil Backlog::Inspector.classify_issue_shape(issues)
end
def test_flat_adhoc_when_no_issue_shows_chain_evidence
issues = [issue(1), issue(2), issue(3, body: "fixed in #1 last week")]
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
end
def test_sequential_chain_from_labels
issues = [
issue(1, labels: ["Blocked-By"]),
issue(2, labels: ["depends-on"]),
issue(3, labels: ["enhancement"])
]
assert_equal "sequential-chain", Backlog::Inspector.classify_issue_shape(issues)
end
def test_negated_label_token_does_not_count
issues = [issue(1, labels: ["not-blocked"]), issue(2), issue(3)]
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
end
def test_cross_reference_near_dependency_language_counts
issues = [
issue(1, body: "This is blocked by #2 until the schema lands."),
issue(2, body: "Must land after #3."),
issue(3, body: "Standalone groundwork.")
]
assert_equal "sequential-chain", Backlog::Inspector.classify_issue_shape(issues)
end
def test_plain_cross_reference_without_dependency_language_does_not_count
issues = [
issue(1, body: "See also #2 for background."),
issue(2, body: "Related: #3."),
issue(3)
]
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
end
def test_reference_to_number_outside_fetched_set_does_not_count
issues = [
issue(1, body: "blocked by #99 upstream"),
issue(2),
issue(3)
]
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
end
def test_dependency_language_too_far_from_reference_does_not_count
padding = "x" * 60
issues = [
issue(1, body: "blocked #{padding} see #2"),
issue(2),
issue(3)
]
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
end
def test_mixed_when_some_but_not_most_show_evidence
issues = [issue(1, labels: ["blocked"]), issue(2), issue(3)]
assert_equal "mixed", Backlog::Inspector.classify_issue_shape(issues)
end
def test_self_reference_does_not_count
issues = [
issue(1, body: "depends on #1 (this issue)"),
issue(2),
issue(3)
]
assert_equal "flat-adhoc", Backlog::Inspector.classify_issue_shape(issues)
end
end
class InspectCliContractTest < Minitest::Test
BIN = File.expand_path("../bin/os-backlog", __dir__)
# `inspect` must always expose the issue_shape key, even when every
# network-ish check degrades (tmpdir: no git remote, no config). Value is
# null here because no metadata is fetchable — that's the fail-soft path.
def test_inspect_json_always_contains_issue_shape_key
Dir.mktmpdir do |dir|
out = Dir.chdir(dir) { `ruby #{BIN} inspect 2>/dev/null` }
findings = JSON.parse(out)
assert findings.key?("issue_shape"), "inspect JSON missing issue_shape key"
assert_nil findings["issue_shape"]
end
end
end

View File

@ -0,0 +1,29 @@
# /to-issues routing wiring (issue #16): spec-slice output honors the
# tracker key per the ADR'd boundary (git issues = specs, Planka = state).
require_relative "test_helper"
class IssuesRoutingTest < Minitest::Test
def test_forgejo_tracker_routes_spec_slices_to_git_issues
assert_equal :git_issues, Backlog::Tracker.issues_destination("forgejo:jared/cc-os")
end
def test_github_tracker_routes_spec_slices_to_git_issues
assert_equal :git_issues, Backlog::Tracker.issues_destination("github:jared/some-repo")
end
def test_planka_tracker_is_the_gated_default
assert_equal :planka_default, Backlog::Tracker.issues_destination("planka:cc-os")
end
def test_repo_tracker_is_the_gated_default
assert_equal :planka_default, Backlog::Tracker.issues_destination("repo:docs/issues")
end
def test_absent_tracker_is_the_gated_default
assert_equal :planka_default, Backlog::Tracker.issues_destination(nil)
end
def test_invalid_tracker_is_unrouted
assert_equal :unrouted, Backlog::Tracker.issues_destination("jira:whatever")
end
end

View File

@ -0,0 +1,132 @@
require_relative "test_helper"
require "tmpdir"
require "fileutils"
require "json"
class ProjectIndexTest < Minitest::Test
def with_index
Dir.mktmpdir do |dir|
path = File.join(dir, ".cc-os", "projects.json")
yield Backlog::ProjectIndex.new(path: path, today: -> { "2026-07-13" }), path
end
end
def test_missing_file_reads_as_empty
with_index do |index, _path|
assert_equal({}, index.all)
end
end
def test_corrupt_file_reads_as_empty_and_is_not_clobbered_by_reads
with_index do |index, path|
FileUtils.mkdir_p(File.dirname(path))
File.write(path, "{not json")
assert_equal({}, index.all)
assert_equal "{not json", File.read(path) # read paths never rewrite
end
end
def test_upsert_creates_row_with_updated_at
with_index do |index, path|
index.upsert(path: "/repos/foo", name: "foo", tracker: "planka:foo", remote: nil)
data = JSON.parse(File.read(path))
assert_equal 1, data["version"]
row = data["projects"]["/repos/foo"]
assert_equal "foo", row["name"]
assert_equal "planka:foo", row["tracker"]
assert_nil row["remote"]
assert_equal "2026-07-13", row["updated_at"]
end
end
def test_upsert_updates_existing_row_keyed_by_path
with_index do |index, _path|
index.upsert(path: "/repos/foo", name: "foo", tracker: "planka:foo", remote: nil)
index.upsert(path: "/repos/foo", name: "foo", tracker: "github:me/foo", remote: "r")
assert_equal 1, index.all.size
assert_equal "github:me/foo", index.all["/repos/foo"]["tracker"]
end
end
def test_name_collision_two_paths_same_basename_keeps_both
with_index do |index, _path|
index.upsert(path: "/a/foo", name: "foo", tracker: "planka:foo", remote: nil)
index.upsert(path: "/b/foo", name: "foo", tracker: "github:me/foo", remote: nil)
assert_equal %w[/a/foo /b/foo], index.all.keys.sort
end
end
def test_upsert_over_corrupt_file_leaves_valid_json_and_no_temp_files
with_index do |index, path|
FileUtils.mkdir_p(File.dirname(path))
File.write(path, "{not json")
index.upsert(path: "/repos/foo", name: "foo", tracker: "repo:.", remote: nil)
data = JSON.parse(File.read(path))
assert_equal ["/repos/foo"], data["projects"].keys
leftovers = Dir.children(File.dirname(path)).reject { |f| f == "projects.json" }
assert_empty leftovers, "atomic write left temp files: #{leftovers}"
end
end
def test_default_path_honors_cc_os_home_override
path = Backlog::ProjectIndex.default_path(env: { "CC_OS_HOME" => "/tmp/x", "HOME" => "/home/y" })
assert_equal "/tmp/x/projects.json", path
end
def test_default_path_falls_back_to_home
path = Backlog::ProjectIndex.default_path(env: { "HOME" => "/home/y" })
assert_equal "/home/y/.cc-os/projects.json", path
end
end
class ProjectsCliContractTest < Minitest::Test
BIN = File.expand_path("../bin/os-backlog", __dir__)
# config-write must upsert the index (redirected via CC_OS_HOME so the
# real ~/.cc-os is never touched), and `projects` must always emit a
# top-level "projects" key — even when the index is empty.
def test_config_write_upserts_index_and_projects_lists_it
Dir.mktmpdir do |home|
Dir.mktmpdir do |repo|
env = "CC_OS_HOME=#{home} HOME=#{home}"
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
assert_equal({ "projects" => {} }, JSON.parse(out))
Dir.chdir(repo) { `#{env} ruby #{BIN} config-write planka:demo 2>/dev/null` }
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
projects = JSON.parse(out)["projects"]
assert_equal 1, projects.size
row = projects.values.first
assert_equal "planka:demo", row["tracker"]
assert_equal File.basename(File.realpath(repo)), row["name"]
filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` }
assert_equal({}, JSON.parse(filtered)["projects"])
by_tracker = Dir.chdir(repo) { `#{env} ruby #{BIN} projects planka:demo 2>/dev/null` }
assert_equal 1, JSON.parse(by_tracker)["projects"].size
end
end
end
# Umbrella repos: config-write from a subdirectory writes the config to
# that subdirectory, so the index row must be keyed by the SUBDIRECTORY
# (the project the config describes), never the enclosing git toplevel —
# otherwise every subproject masquerades as the umbrella repo.
def test_config_write_in_umbrella_subdir_indexes_the_subdir
Dir.mktmpdir do |home|
Dir.mktmpdir do |repo|
`git -C #{repo} init -q 2>/dev/null`
subdir = File.join(repo, "child-project")
FileUtils.mkdir_p(subdir)
env = "CC_OS_HOME=#{home} HOME=#{home}"
Dir.chdir(subdir) { `#{env} ruby #{BIN} config-write planka:child 2>/dev/null` }
projects = Dir.chdir(subdir) { JSON.parse(`#{env} ruby #{BIN} projects 2>/dev/null`)["projects"] }
assert_equal [File.realpath(subdir)], projects.keys
assert_equal "child-project", projects.values.first["name"]
end
end
end
end

View File

@ -90,4 +90,32 @@ class ResolverTest < Minitest::Test
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

View File

@ -11,7 +11,7 @@ module BacklogTestHelpers
class FakePlankaClient
attr_reader :projects, :boards, :lists, :labels, :cards, :comments
def initialize(reject_colors: [])
def initialize(reject_colors: [], reject_colors_with_400: [])
@next_id = 0
@projects_store = []
@boards_store = []
@ -24,7 +24,8 @@ module BacklogTestHelpers
@projects = ProjectsResource.new(self)
@boards = BoardsResource.new(self)
@lists = ListsResource.new(self)
@labels = LabelsResource.new(self, reject_colors: reject_colors)
@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
@ -111,6 +112,11 @@ module BacklogTestHelpers
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),
@ -174,9 +180,10 @@ module BacklogTestHelpers
end
class LabelsResource
def initialize(client, reject_colors: [])
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)
@ -184,6 +191,11 @@ module BacklogTestHelpers
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" })

View File

@ -0,0 +1,86 @@
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

@ -0,0 +1,61 @@
require_relative "test_helper"
require "tmpdir"
require "json"
class WakeupTest < Minitest::Test
W = Backlog::Wakeup
def test_tier_mapping
assert_equal :afk_ready, W.tier("afk-ready")
assert_equal :afk_ready, W.tier("bug, afk-ready")
assert_equal :semi, W.tier("semi")
assert_equal :notify, W.tier("hitl")
assert_equal :notify, W.tier("")
assert_equal :notify, W.tier(nil)
end
def test_eligible_projects_requires_git_tracker_and_optin
projects = {
"/p/planka" => { "name" => "planka-proj", "tracker" => "planka:board" },
"/p/forgejo-in" => { "name" => "fin", "tracker" => "forgejo:jared/fin" },
"/p/forgejo-out" => { "name" => "fout", "tracker" => "forgejo:jared/fout" },
"/p/github-in" => { "name" => "gin", "tracker" => "github:jared/gin" }
}
reader = ->(path) { path.end_with?("-in") ? "tracker=x\nwakeup=true\n" : "tracker=x\n" }
rows = W.eligible_projects(projects, config_reader: reader)
assert_equal %w[/p/forgejo-in /p/github-in], rows.map { |r| r[:path] }
assert_equal "jared/fin", rows.first[:repo_slug]
assert_equal "forgejo", rows.first[:tracker_kind]
end
def test_new_issues_baselines_on_nil_last_seen
issues = [{ "index" => "5" }, { "index" => "3" }]
assert_equal [], W.new_issues(issues, nil)
assert_equal [{ "index" => "5" }], W.new_issues(issues, 3)
assert_equal 5, W.highest_index(issues)
assert_equal 9, W.highest_index(issues, 9)
end
def test_tmux_command_follows_adr_0036_naming
cmd = W.tmux_command(project: "llf-schema", path: "/p/llf", issue_index: "12", prompt: "hi")
assert_equal %w[tmux new-session -d -s cc-llf-schema-issue12 -c /p/llf claude hi], cmd
end
def test_session_prompt_carries_tier_and_body
issue = { "index" => "7", "title" => "T", "body" => "Body\n**Discoverer:** x" }
semi = W.session_prompt(issue: issue, tier: :semi, url: "u")
assert_includes semi, "TRIAGE ONLY"
assert_includes semi, "**Discoverer:** x"
afk = W.session_prompt(issue: issue, tier: :afk_ready, url: "u")
assert_includes afk, "afk-ready"
end
def test_state_reads_missing_as_empty_and_records_atomically
Dir.mktmpdir do |dir|
state = W::State.new(path: File.join(dir, "wakeup-state.json"))
assert_nil state.last_seen("jared/x")
state.record("jared/x", 7)
assert_equal 7, W::State.new(path: File.join(dir, "wakeup-state.json")).last_seen("jared/x")
end
end
end

View File

@ -0,0 +1,5 @@
{
"name": "os-context",
"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."
}

View File

@ -5,7 +5,7 @@
#
# Enumerates production session transcripts modified since a given date,
# reduces each to a delegation fact-sheet (audit/bin/extract), and writes a
# run directory under the audit state dir (~/.local/state/os-orchestration/audit):
# run directory under the audit state dir (~/.local/state/os-context/audit):
#
# run-<date>/stats.tsv one row per audited session (deterministic columns)
# run-<date>/flags.md heuristic missed-delegation candidates, per session,
@ -31,7 +31,7 @@ require "optparse"
load File.expand_path("extract", __dir__)
module OrchAudit
DEFAULT_STATE_DIR = File.expand_path("~/.local/state/os-orchestration/audit")
DEFAULT_STATE_DIR = File.expand_path("~/.local/state/os-context/audit")
DEFAULT_PROJECTS_DIR = File.expand_path("~/.claude/projects")
class TranscriptSet

View File

@ -1,10 +1,10 @@
# os-orchestration eval — E1E3, E5 (session-orchestration behavior)
# os-context eval — E1E3, E5 (session-orchestration behavior)
_Created: 2026-07-06. Scenarios sourced from the verified misses in
`docs/orchestration-audit/2026-07-06-findings.md` (clusters 13), per the
IRL-feedback-loop methodology. E4 (batch planning) deliberately deferred._
Measures whether an orchestrating session follows the shipped `ORCHESTRATION.md`
Measures whether an orchestrating session follows the shipped `prompts/session-start/10-orchestration.md`
(the 2026-07-06 trigger-conditioned wording) mid-task: explicit `model:` per spawn,
resolved-model verification, delegation thresholds, and not re-covering its own
ground. This validates the wording fixes the way Eval B/C validated os-adr's —
@ -18,7 +18,7 @@ was wrong).**
## Run mode (the only valid one)
Headless: fresh `claude -p` per rep, cwd = a sandbox copy of `fixture/project/`, so
the global os-orchestration SessionStart hook injects ORCHESTRATION.md for real.
the global os-context SessionStart hook injects the orchestration prompt for real.
In-session subagents are invalid here (they inherit the parent session). The behavior
under test is mid-session, so unlike Eval B there is no SessionStart trigger to
protect — but the injection channel is the SessionStart hook, so the constraint is
@ -104,7 +104,7 @@ name field); metadata gains `total_spawns` and `max_rounds`.
## Conformance dry-run (done at design time, 2026-07-06)
Per the ladder anti-pattern list, each cell was checked against "would a model
perfectly following the shipped ORCHESTRATION.md pass?" — yes on all six (E1P:
perfectly following the shipped prompts/session-start/10-orchestration.md pass?" — yes on all six (E1P:
delegate+sonnet+flag; E1N: no false claim; E2P: fan out with explicit models; E2N/E3N:
do directly; E3P: delegate the log review after at most an orienting look) — and
"would an always-delegate model pass positives while failing negatives?" — it fails
@ -128,7 +128,7 @@ done; wait
- Sandboxes must live under a dot-free path (/tmp/orch-eval): the checker flattens
the sandbox realpath to find the project transcript dir.
- Run `cc-os/bin/refresh-plugins` after ANY ORCHESTRATION.md edit, before any grid.
- Run `cc-os/bin/refresh-plugins` after ANY prompts/session-start/10-orchestration.md edit, before any grid.
- TSV columns: scenario, model, rep, verdict, axes, info. The TSV is the truth —
verify from it, not from prose reports.
- Judge (E1 language axes only): frozen rubric in `judge-rubric.md`; stub with
@ -141,7 +141,7 @@ The first live E1P rep (sonnet, counted: FAIL, `B:fail-mechanical`) falsified th
premise that the orchestrator can see `resolvedModel` in the launch stub — the model-visible
launch result carries no model info, so the first shipped wording ("compare the resolved model
reported in the launch result") was unactionable and E1 was unwinnable by a compliant model.
Fixed in ORCHESTRATION.md wording ONLY (subagent model self-report mechanism — subagents know
Fixed in prompts/session-start/10-orchestration.md wording ONLY (subagent model self-report mechanism — subagents know
their exact model ID; verified live). Scenarios, checker, and rubric were NOT touched.
## Fixture

View File

@ -1,7 +1,7 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# Deterministic-first checker for the os-orchestration eval (E1-E3, E5).
# Deterministic-first checker for the os-context eval (E1-E3, E5).
#
# Usage: check <scenario> <sandbox> [--tsv <model> [<rep>]]
#

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Headless runner for the os-orchestration eval — the ONLY valid execution mode
# Headless runner for the os-context eval — the ONLY valid execution mode
# (mid-session orchestration behavior under the real global SessionStart-injected
# ORCHESTRATION.md; fresh `claude -p` per rep with cwd = sandbox).
# prompts/session-start/10-orchestration.md; fresh `claude -p` per rep with cwd = sandbox).
#
# Usage: run <scenario> <model> <workdir> [--reps N] [--results FILE]
#

View File

@ -1,7 +1,7 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# Model-free self-test for the os-orchestration eval checker. Fabricates
# Model-free self-test for the os-context eval checker. Fabricates
# session-format transcripts and asserts verdicts in BOTH directions per
# scenario, including at least one SHIPPED-INSTRUCTION-COMPLIANT transcript
# per positive scenario (the Eval C conformance lesson: self-tests that only

View File

@ -1,4 +1,4 @@
# Frozen judge rubrics — os-orchestration eval (E1 language axes only)
# Frozen judge rubrics — os-context eval (E1 language axes only)
Used only when the mechanical scan is inconclusive. FROZEN during any wording loop.
The judge receives one rubric section plus the transcript text, and must answer with

View File

Can't render this file because it has a wrong number of fields in line 2.

Some files were not shown because too many files have changed in this diff Show More