Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ agent back at one file prevents that structurally.
4. Put tool-specific guidance (not meant for every agent) in that tool's own file,
not in `AGENTS.md`.

## Authoring a subagent

Each file in `subagents/` is a single Markdown file with YAML frontmatter,
read by both Claude Code (via `.claude/agents/`) and OpenCode (via
`.opencode/agents/`). Supported keys:

- `name` (required) — invocation name; identity comes from this, not the
filename.
- `description` (required) — used by parent agents to decide when to
delegate; start with "Use proactively when…" for auto-discovery.
- `model` (optional) — `sonnet` / `opus` / `haiku` / `inherit`.
- `tools` (optional) — Claude Code allowlist, comma-separated
(e.g. `Read, Grep, Glob, Bash`). Claude Code only.
- `permission` (optional) — OpenCode per-action map with keys
`read` / `write` / `edit` / `bash`, each `allow` / `ask` / `deny`;
`bash` may be a per-pattern map (`"rg *": allow`, `"*": deny`).
OpenCode only.
- `mode` (optional, **strongly recommended**) — OpenCode-only. One of
`primary` / `subagent` / `all`. **Defaults to `all`**, which exposes the
agent as a top-level primary OpenCode agent in addition to a delegated
one — set `mode: subagent` to keep it delegation-only.

Declare `tools` and `permission` together in the same frontmatter: each tool
reads the field it understands and ignores the other. Subagents cannot spawn
subagents; a role that needs delegation hands back to the caller (see the
hand-back convention in the role files).

## Caveats

- **Copier-managed.** This harness is generated from a Copier template
Expand Down
20 changes: 0 additions & 20 deletions .agents/commands/README.md

This file was deleted.

30 changes: 22 additions & 8 deletions .agents/commands/build.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
---
description: Implement the current feature one phase at a time, ticking tasks.md and running the verification gate at each phase boundary
argument-hint: <spec-dir-name> (optional; defaults to the most recent specs/* directory)
argument-hint: <spec-dir-name> (optional; defaults to the most recent development/work/* directory)
---

You are carrying out the implementation phase of a feature.

1. Identify the target spec directory.
- If `$ARGUMENTS` is provided, use `specs/$ARGUMENTS/`.
- If `$ARGUMENTS` is provided, use `development/work/$ARGUMENTS/`.
- Otherwise, use the most recently modified directory under
`specs/`.
`development/work/`.
2. Read `spec.md`, `plan.md`, and `tasks.md` in full. If `plan.md` is
missing or empty, stop and tell the user to run `/plan` first.
3. If the plan touches an unfamiliar area of the codebase, run an
Expand All @@ -25,13 +25,27 @@ You are carrying out the implementation phase of a feature.
- Work one phase at a time, writing tests first where the plan
calls for behaviour change.
- Tick `tasks.md` checkboxes in the same commit as the code change.
- Keep `report.md` current: deviations, abandoned approaches, and
`DECISION-PENDING:` escalations are recorded when they happen.
- Run `make verify` at every phase boundary.
- Stop at the end of each phase and hand off to `/verify`
(Reviewer) before starting the next.
5. When the developer reports a phase complete, **stop** and ask the
user to run `/verify` before proceeding. Do not auto-start the
next phase.
5. If the developer stops mid-phase, that is not a phase boundary. Its
reply names the stop and the servicing instruction; follow it:
- `HANDBACK(explore):` in `scratch.md` — run the `explorer`, append
its answer as a `RESULT(explore):` line (keep the `path:LINE`
citations), then re-invoke the developer. After three explore
hand-backs on the same phase, the phase is scoped too wide — stop
and put it to the user.
- `DECISION-PENDING:` in `report.md` — put the question to the user,
add the register row (`development/adr/README.md`), re-invoke the
developer with the answer.
- `HANDBACK(replan):` in `scratch.md` — hand back to `/plan`
(Architect), then re-run `/build`. After three replan hand-backs
on the same feature, the plan and reality are not converging —
stop and put the mismatch to the user instead of re-planning.
6. When the developer reports a phase complete, **stop** and ask the
user to run `/verify`. Do not auto-start the next phase.

Never silently skip a failing test, edit anything under `*/generated/`,
or run destructive Git. If the plan turns out to be wrong, hand back
to `/plan` (Architect) rather than silently re-planning.
or run destructive Git.
17 changes: 14 additions & 3 deletions .agents/commands/plan.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
---
description: Expand spec.md into a numbered, testable plan.md for the current feature
argument-hint: <spec-dir-name> (optional; defaults to the most recent specs/* directory)
argument-hint: <spec-dir-name> (optional; defaults to the most recent development/work/* directory)
---

You are expanding a feature spec into an implementation plan.

1. Identify the target spec directory.
- If `$ARGUMENTS` is provided, use `specs/$ARGUMENTS/`.
- If `$ARGUMENTS` is provided, use `development/work/$ARGUMENTS/`.
- Otherwise, use the most recently modified directory under
`specs/`.
`development/work/`.
2. Confirm `spec.md` exists and has been reviewed. If it's missing or
empty, stop and tell the user to run `/spec` first.
3. Delegate the planning to the **architect** subagent
(`.agents/subagents/architect.md`). It owns the phased-plan format,
the architecture-decisions block, the "each phase has tests"
contract, and the "stop and ask before coding" boundary.
4. If the architect hands back a **spike request** — a
`HANDBACK(spike):` line in `scratch.md` naming one question (it
cannot run code itself) — run the smallest throwaway experiment that
answers it, in a temp dir or scratch space, never left in the source
tree. Append the result to `scratch.md` on a `RESULT(spike):` line
(question → method → answer → evidence), then re-invoke the
architect; it starts with fresh context and reads `scratch.md` to
pick the answer up. Spike code is disposable; only the findings
survive, in the plan's **Spike findings** section.
After **three spike hand-backs on the same plan**, the uncertainty
is not a design experiment — stop and put the question to the user.

The architect subagent will write `plan.md` and mirror it into
`tasks.md`, then stop for user review. Once the user confirms the
Expand Down
29 changes: 23 additions & 6 deletions .agents/commands/spec.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: Create a new feature spec directory under specs/<YYYY-MM>-<slug>/
description: Create a new feature spec directory under development/work/<YYYY-MM>-<slug>/
argument-hint: <kebab-case-slug>
---

Expand All @@ -8,17 +8,34 @@ You are creating a new feature spec.
1. If `$ARGUMENTS` is empty, ask the user for a slug before creating
anything.
2. Compute today's date as `YYYY-MM`. The directory is
`specs/<YYYY-MM>-$ARGUMENTS/`.
`development/work/<YYYY-MM>-$ARGUMENTS/`.
3. Create the directory if it doesn't exist. **Do not** pre-create
`plan.md`, `tasks.md`, or `scratch.md` — each role creates the
artifacts it owns (Architect: `plan.md`/`tasks.md`; Developer:
`scratch.md`), and pre-creating them would force those roles to
`edit` files they should be able to `write` from scratch.
`plan.md`, `tasks.md`, `report.md`, or `scratch.md` — each role
creates the deliverables it owns (Architect: `plan.md`/`tasks.md`;
Developer: `report.md`), and pre-creating them would force those
roles to `edit` files they should be able to `write` from scratch.
`scratch.md` is not a deliverable but the feature's shared channel:
whoever needs it first creates it, and everyone after that appends.
4. Delegate the actual spec authoring to the **product-owner** subagent
(`.agents/subagents/product-owner.md`). It owns the spec format,
the testable-criteria rule, the non-goals requirement, and the
"stop and ask before planning" boundary.
5. If the product-owner hands back a **clarifying question** instead of
`spec.md`, put it to the user, then re-invoke the product-owner
subagent with the question, the user's answer, **and the prior Q&A**
included in the prompt — it starts each invocation with fresh
context and cannot see the exchange otherwise (the carried Q&A is
also how it knows the round count). Repeat until it produces the
spec. Cap this at **five rounds**: if the questions have not
converged by then, stop and ask the user to settle the scope
directly.

The product-owner subagent will write `spec.md` and then stop for user
review. Once the user confirms the spec, the next step is `/plan`
(Architect role). Do not start implementing yet.

If the reviewed spec's **Glossary** section pins down new domain terms,
promote them verbatim to `development/glossary.md` as part of the
review wrap-up (the product-owner subagent cannot write outside the
feature directory; the promotion rule lives in
`development/glossary.md`).
29 changes: 20 additions & 9 deletions .agents/commands/verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,29 @@ You are running the review phase for the current feature.
Delegate the work to the **reviewer** subagent
(`.agents/subagents/reviewer.md`). It will:

- Read `spec.md`, `plan.md`, `tasks.md`, and the current diff.
- Run `make verify`.
- Check spec conformance, plan conformance, and implementation
quality.
- Read `spec.md`, `plan.md` (including its **Review checklist**
section, if present — *additional* checks only; the checklist never
narrows the review or relaxes a verdict rule), `tasks.md`, `report.md`
(if the Developer has started it — expected by the final phase), and
the current diff.
- Run `make verify` itself — it never takes the Developer's
word for the gate.
- Check spec conformance, plan conformance, implementation quality,
and report honesty (undeclared deviations are defects).
- Produce a `GO` or `NEEDS-WORK` verdict with a citation-rich defect
list (`path/to/file.ext:LINE`) when work remains.
list (`path/to/file.ext:LINE`), ranked MAJOR / MINOR / INFO. Any
MAJOR means NEEDS-WORK.

Hand the reviewer's verdict back to the user verbatim. If
`NEEDS-WORK`, the next step is `/build` (Developer role) to address
the defects. If `GO`, summarise what changed since the last verify
(use `git diff --stat` and `git log -1`) and stop.
the defects. If `GO` on the feature's final phase, confirm `report.md`
is complete (what was built, deviations, negative results, follow-ups,
gate result) before the feature is declared mergeable — it freezes at
merge. Then summarise what changed since the last verify (use
`git diff --stat` and `git log -1`) and stop.

Never silently skip, disable, or `@ignore` a failing test. If a test
must be skipped, draft an ADR under `docs/adr/` and ask for
confirmation.
must be skipped, that is a `DECISION-PENDING:` escalation in the
feature's `report.md` (landing as a register row — or an ADR, if it
clears the bar in `development/adr/README.md`); ask for confirmation
before proceeding.
64 changes: 61 additions & 3 deletions .agents/hooks/block-destructive.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,72 @@
#!/usr/bin/env sh
# block-destructive.sh — canonical destructive-command matcher.
#
# Reads candidate command text on stdin; exits 2 if it matches a forbidden
# pattern, 0 otherwise. This is the single source of truth for the deny-list.
# Reads candidate command text on stdin; exits 2 (with a reason on stderr) if
# it matches a forbidden pattern, 0 otherwise. This is the single source of
# truth for the deny-list.
#
# Consumers:
# - Claude Code: the PreToolUse(Bash) hook in .claude/settings.json pipes the
# tool input here.
# - OpenCode: cannot call a script, so the deny globs in
# .opencode/opencode.jsonc restate these patterns by hand — keep in sync.
# Those globs are plain substring matches and cannot express the quoting
# and command-position rules below, so OpenCode over-blocks where this
# script does not.
#
# See .agents/README.md for the single-source-of-truth rationale.
grep -qE 'rm -rf|push --force|reset --hard|DROP TABLE' && exit 2 || exit 0
#
# DIVERGES FROM THE COPIER TEMPLATE. Upstream matches the raw command text
# with one substring `grep -qE`, which denies any command that merely *quotes*
# a pattern — enough to block a `rg` search of this file or a PR description
# explaining it. Behaviour here is pinned by tests/test_harness_config.py; on
# `copier update`, keep this version and reject the upstream hunk. Like
# upstream, the deny decision stays on POSIX `grep -qE`: `grep -o` is a
# non-POSIX extension that suppresses stdout on binary-classified input, so
# using the extracted match as the decision would fail open.

cmd=$(cat)
[ -n "$cmd" ] || exit 0

# Shell-syntax patterns are matched against text with quoted spans removed, so
# a command that merely *names* a dangerous form — `rg "rm -rf" .agents/`,
# `git log --grep="reset --hard"`, a heredoc documenting the deny-list — is not
# blocked. Only an unquoted occurrence is shell the caller is about to run.
bare=$(printf '%s' "$cmd" | sed -e "s/'[^']*'/''/g" -e 's/"[^"]*"/""/g')

deny() {
printf 'block-destructive: refusing this command — %s.\n' "$1" >&2
printf 'block-destructive: %s\n' "$cmd" >&2
printf 'block-destructive: see .agents/hooks/block-destructive.sh for the deny-list.\n' >&2
exit 2
}

# A command position: start of input, or after a separator that begins a new
# command. Keeps `rm` as an argument (`git log -- rm`) from matching.
POS='(^|[;&|(]|\$\()[[:space:]]*(sudo[[:space:]]+)?'

# Recursive *and* forced delete, in the spellings that actually occur:
# combined (-rf, -fr, -rvf), separate (-r -f, -f -r), and long-form.
match() { printf '%s' "$bare" | grep -qE "$1"; }

match "${POS}rm[[:space:]]+(-[A-Za-z]+[[:space:]]+)*-[A-Za-z]*([rR][A-Za-z]*[fF]|[fF][A-Za-z]*[rR])" ||
match "${POS}rm[[:space:]]+(-[A-Za-z]*[rR][A-Za-z]*[[:space:]]+)+(-[A-Za-z]*[fF])" ||
match "${POS}rm[[:space:]]+(-[A-Za-z]*[fF][A-Za-z]*[[:space:]]+)+(-[A-Za-z]*[rR])" ||
match "${POS}rm([[:space:]]+--(recursive|force))+[[:space:]]+--(recursive|force)" &&
deny 'a recursive forced delete'

# `[^;&|()]*` spans git's own options (`git -C /repo push …`) without running
# past the end of the command. `--force([^-]|$)` leaves --force-with-lease,
# the reviewable form, allowed.
match "${POS}git[[:space:]]+([^;&|()]*[[:space:]]+)?push[[:space:]]+[^;&|()]*--force([^-]|\$)" &&
deny 'a force push (use --force-with-lease on your own branch, deliberately)'

match "${POS}git[[:space:]]+([^;&|()]*[[:space:]]+)?reset[[:space:]]+[^;&|()]*--hard" &&
deny 'a hard reset (it discards uncommitted work irreversibly)'

# SQL, unlike the above, is normally *inside* quotes (`psql -c "DROP TABLE x"`),
# so this one is matched against the raw text.
printf '%s' "$cmd" | grep -qiE 'DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)' &&
deny 'a destructive SQL statement'

exit 0
4 changes: 2 additions & 2 deletions .agents/hooks/ensure-toolchain.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# - Any other agent / CI / devcontainer may call it too.
#
# The install command and URL come from the toolchain_install_* macros in
# _macros.jinja (the single source; docs/tool-bootstrap.md renders the same
# _macros.jinja (the single source; development/tool-bootstrap.md renders the same
# macro for its manual fallback). The installer appends $HOME/.local/bin to the
# shell profile, so the binary is on PATH for *new* shells (e.g. an agent's
# subsequent tool calls), not the current one. A caller that needs it within its
Expand All @@ -33,5 +33,5 @@ if curl -LsSf https://astral.sh/uv/install.sh | sh >&2 && [ -x "$HOME/.local/bin
fi

echo "ensure-toolchain: ERROR — could not install uv automatically (offline or restricted network?)." >&2
echo "ensure-toolchain: install it manually, then retry. See docs/tool-bootstrap.md." >&2
echo "ensure-toolchain: install it manually, then retry. See development/tool-bootstrap.md." >&2
exit 1
74 changes: 74 additions & 0 deletions .agents/hooks/hook-input.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env sh
# hook-input.sh — canonical reader for agent-hook JSON payloads.
#
# Usage: hook-input.sh <dot.path> (stdin: the hook's JSON payload)
# e.g. hook-input.sh .tool_input.command
#
# Prints the field's value on stdout, identically under either backend:
# '' for null/absent (including a path through a non-object), 'true'/'false'
# for booleans, raw text for strings, compact JSON for objects/arrays.
# (Known residual divergence: non-canonical numeric literals like 1.5e3 may
# render differently per backend; all current consumers read strings/booleans.)
#
# Exit codes: 0 read OK; 3 no working JSON parser on PATH; 4 empty or
# unparseable payload (both backends reject a payload that is not exactly
# one JSON document). Callers branch on the distinction to pick their own
# failure posture and message.
#
# Parses with jq when available, falling back to python3. Both backends are
# probed by *running* them, not `command -v` alone — a broken jq install
# (stub, wrong arch) must fall through to python3, and stock macOS ships a
# /usr/bin/python3 stub that passes `command -v` but fails until the Xcode
# Command Line Tools are installed.
#
# Consumers:
# - Claude Code: the PostToolUse, PreToolUse, and Stop hooks in
# .claude/settings.json read their payloads through this script.
#
# See .agents/README.md for the single-source-of-truth rationale.

payload=$(cat)
if [ -z "$payload" ]; then
echo 'hook-input.sh: empty hook payload on stdin.' >&2
exit 4
fi

if printf '{}' | jq . >/dev/null 2>&1; then
out=$(printf '%s' "$payload" | jq -rcn --arg p "$1" '
[inputs] as $docs
| if ($docs | length) != 1 then error("expected exactly one JSON document") else $docs[0] end
| ($p | split(".") | map(select(length > 0))) as $parts
| (try getpath($parts) catch null)
| if . == null then "" elif type == "boolean" then tostring else . end

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[code-review:xhigh] jq/python3 output differs for objects and numbers (CONFIRMED)

jq -r without -c pretty-prints objects/arrays while the python3 fallback prints compact json.dumps; JSON 1.5e3 prints '1500' (jq) vs '1500.0' (python). Violates the header's 'identically under either backend' contract. Harmless for today's string-only consumers, but any future non-string consumer behaves differently depending on whether jq is installed.

' 2>/dev/null) || {
echo 'hook-input.sh: cannot parse the hook payload as JSON.' >&2
exit 4
}
printf '%s\n' "$out"
exit 0
fi

if python3 -c '' >/dev/null 2>&1; then
printf '%s' "$payload" | python3 -c '
import json, sys
try:
v = json.load(sys.stdin)
except ValueError:
print("hook-input.sh: cannot parse the hook payload as JSON.", file=sys.stderr)
sys.exit(4)
for p in [p for p in sys.argv[1].split(".") if p]:
v = v.get(p) if isinstance(v, dict) else None
if v is None:
print("")
elif v is True or v is False:
print(str(v).lower())
elif isinstance(v, (dict, list)):
print(json.dumps(v, separators=(",", ":")))
else:
print(v)
' "$1"
exit $?
fi

echo 'hook-input.sh: no working JSON parser (jq or python3) on PATH; cannot read the hook input. Install jq (apt-get install jq / brew install jq).' >&2
exit 3
Loading
Loading