diff --git a/plugins/os-backlog/hooks/hooks.json b/plugins/os-backlog/hooks/hooks.json new file mode 100644 index 0000000..cdb73ee --- /dev/null +++ b/plugins/os-backlog/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|compact", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/plugins/os-backlog/hooks/session_start.py b/plugins/os-backlog/hooks/session_start.py new file mode 100644 index 0000000..013ca73 --- /dev/null +++ b/plugins/os-backlog/hooks/session_start.py @@ -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:` | `forgejo:/` | `github:/` | `repo:`) 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()) diff --git a/plugins/os-backlog/lib/backlog/tracker.rb b/plugins/os-backlog/lib/backlog/tracker.rb index fee4c7d..8480022 100644 --- a/plugins/os-backlog/lib/backlog/tracker.rb +++ b/plugins/os-backlog/lib/backlog/tracker.rb @@ -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). # diff --git a/plugins/os-backlog/tests/issues_routing_test.rb b/plugins/os-backlog/tests/issues_routing_test.rb new file mode 100644 index 0000000..251474f --- /dev/null +++ b/plugins/os-backlog/tests/issues_routing_test.rb @@ -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