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
This commit is contained in:
jared 2026-07-13 09:18:45 -04:00
parent 15807ef342
commit 90db409721
4 changed files with 130 additions and 0 deletions

View File

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

View File

@ -0,0 +1,69 @@
#!/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).
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.
- 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).
- 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

@ -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,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