Skip to content
Merged
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
1 change: 1 addition & 0 deletions .claude/skills/research
32 changes: 32 additions & 0 deletions .opencode/command/research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
description: Research a topic from primary sources — per-claim citations, confidence, and explicit unknowns instead of guesses
---

# /research

Topic: $ARGUMENTS

**Mandate:** use all available and appropriate skills, tools, web research, and
other resources to establish what is factually true. No claim reaches the
operator unless a source was read in this session. A guess is a defect;
"unverified" is an acceptable answer.

Load the `research` skill (skill tool, id `research`) for the evidence ladder,
then:

1. Restate the question and the decision it feeds.
2. Walk the ladder — repo/code first, then live tools, then vendor docs, then
secondary sources.
3. Cross-check load-bearing claims, and date anything that drifts.
4. Answer in exactly three parts. Prose alone is a failed run:

**Answer:** one paragraph, direct.

| Claim | Source | How verified |
|---|---|---|
| … | url / `file:line` / tool call | read / reproduced / cross-checked |

**Confidence:** high · medium · low — why.
**Unverified:** what you could not confirm, or "none".

If the topic above is empty, ask what to research. Never answer from memory.
78 changes: 78 additions & 0 deletions .opencode/skills/research/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
name: research
description: Evidence-first procedure for factual questions — route through repo, live tools, and web, then report sources and confidence instead of guesses
mode: skill
triggers: research,verify claim,fact check,look up,find out,investigate,evidence,is it true
---

# research

> Load this skill when: asked to research, verify, or answer a factual question
> whose answer is not already in context — and always when a wrong answer would
> cost something.

## The rule

**No claim reaches the operator unless a source was read in this session.**

Memory is a lead, never a finding. It tells you where to look; it never tells
you what is true. "Unverified" and "I don't know" are complete answers. A
confident guess is not, because the operator cannot tell it apart from a sourced
one.

Every answer carries three parts — prose alone is an **incomplete answer**,
because the operator cannot audit it:

1. the answer, direct;
2. an evidence table, one row per load-bearing claim;
3. confidence, plus an explicit list of what is still unverified.

## Evidence ladder — cheapest authoritative rung first

| Rung | Source | Reach for |
|---|---|---|
| 1 | This repo and its code | `grep`, `read`, run the tool or test |
| 2 | Live system state | `proxmox_*`, `technitium_*`, `mikromcp` via `bin/mcp-call.py`, `gh`, `vikunja` |
| 3 | Vendor docs, API reference, upstream source | `websearch` skill → `donsetch` |
| 4 | Blog, news, forum, another model's output | corroboration only — never load-bearing |

Stop at the first rung that answers the question. Rung 1 is cheaper than rung 3
every time: look before you search.

## Procedure

1. **Restate** the question and the decision it feeds, in one line. If the
answer changes nothing, say so instead of researching it.
2. **Decompose** into the claims the answer depends on — you will source each.
3. **Walk the ladder** per claim, loading the domain skill that governs how that
source is read (`django-expert`, `proxmox-cli`, `routeros`, `gh`, …).
4. **Cross-check** load-bearing claims: two independent sources, or one primary
source plus a reproduction. Two pages quoting one press release are one
source.
5. **Date it.** Record version and date for anything that drifts — APIs, prices,
defaults, package behaviour. An undated fact is a guess with a citation.

## Output — return it literally, not as prose

**Answer:** one paragraph, direct.

| Claim | Source | How verified |
|---|---|---|
| … | url / `file:line` / tool call | read / reproduced / cross-checked |

**Confidence:** high · medium · low — why.
**Unverified:** what you could not confirm, and what would confirm it (or "none").

Name the gaps. Silence about a gap reads as certainty.

## Do NOT

- Answer from memory because the question "feels" settled.
- Let one blog post carry a factual answer.
- Repeat a failed search verbatim — reformulate twice, then report the dead end.
- Pad a thin evidence base with confident prose.

## Related

`websearch` (the web rung's tool) · `knowledge-base` (write up what you learned) ·
`gh`, `github-cli` (issue and PR evidence)
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ python3 bin/automation-ladder.py sync-agents # then restart the agent session
hook is the tighter follow-up.

## Skills (load with: opencode tool skill use <name>)
`startsession` | `lifecycle` | `git` | `security` | `backup` | `infra` | `check-connectivity` | `templates` | `tools` | `endsession` | `idea-triage` | `idea-cmd` | `baseline` | `gh` | `helpdesk-ticket` | `frappe-access` | `dogfood-cycle` | `release` | `zabbix` | `cleanup` | `handoff` | `ticket-triage` | `gitlab-pages-dns` | `grind` | `knowledge-base` | `routeros` | `github-cli` | `triage` | `bwunlock` | `websearch` | `technitium-dns` | `proxmox-cli` | `vikunja-ticket` | `vikunja-user` | `mail-auth-check` | `shared-browser`
`startsession` | `lifecycle` | `git` | `security` | `backup` | `infra` | `check-connectivity` | `templates` | `tools` | `endsession` | `idea-triage` | `idea-cmd` | `baseline` | `gh` | `helpdesk-ticket` | `frappe-access` | `dogfood-cycle` | `release` | `zabbix` | `cleanup` | `handoff` | `ticket-triage` | `gitlab-pages-dns` | `grind` | `knowledge-base` | `routeros` | `github-cli` | `triage` | `bwunlock` | `research` | `websearch` | `technitium-dns` | `proxmox-cli` | `vikunja-ticket` | `vikunja-user` | `mail-auth-check` | `shared-browser`

Load the relevant skill before working in its domain.

Expand Down
117 changes: 117 additions & 0 deletions tests/test_research_skill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""The research skill and its /research command must stay real and discoverable.

Two ways this pair degrades silently, and both are worse than having no skill:

- the command survives while the skill it names is renamed or deleted, so the
agent is told to load a procedure that does not exist and falls back to
answering from memory — the exact defect the skill exists to prevent;
- the skill's frontmatter drifts off the canonical four fields, so discovery
stops advertising it and nobody notices, because a missing skill looks
identical to a skill that was never needed.

The definition-of-done guard already covers AGENTS.md registration for new
files; it only fires on the commit that adds them, so registration is re-checked
here too.
"""

import re
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SKILL = ROOT / ".opencode" / "skills" / "research" / "SKILL.md"
COMMAND = ROOT / ".opencode" / "command" / "research.md"
AGENTS = ROOT / "AGENTS.md"

CANONICAL_FIELDS = ["name", "description", "mode", "triggers"]
MAX_CONTENT_LINES = 60


def _split_frontmatter(path):
"""(field names, frontmatter text, body) for a Markdown file."""
text = path.read_text()
lines = text.splitlines()
assert lines and lines[0].strip() == "---", f"{path}: no frontmatter"
try:
end = lines.index("---", 1)
except ValueError: # pragma: no cover - malformed file
raise AssertionError(f"{path}: frontmatter is never closed")
fields = [
line.split(":", 1)[0].strip()
for line in lines[1:end]
if line and not line.startswith((" ", "\t")) and ":" in line
]
return fields, "\n".join(lines[1:end]), "\n".join(lines[end + 1:])


def test_skill_exists_with_the_canonical_four_fields():
assert SKILL.is_file(), "the research skill is missing"
fields, frontmatter, _ = _split_frontmatter(SKILL)
assert fields == CANONICAL_FIELDS, (
"skills carry exactly name/description/mode/triggers — a fifth field or "
f"a reordering breaks discovery; found {fields}"
)
assert re.search(r"^name:\s*research\s*$", frontmatter, re.M)
assert re.search(r"^mode:\s*skill\s*$", frontmatter, re.M)


def test_skill_is_short_enough_to_load_every_time():
"""A skill costs tokens on every matching message; a long one is skipped."""
_, _, body = _split_frontmatter(SKILL)
content_lines = [line for line in body.splitlines() if line.strip()]
assert len(content_lines) <= MAX_CONTENT_LINES, (
f"{len(content_lines)} content lines exceeds the {MAX_CONTENT_LINES} "
"ceiling — move procedure detail to docs/ and reference it"
)


def test_command_loads_the_skill_and_takes_arguments():
assert COMMAND.is_file(), "the /research command is missing"
_, frontmatter, body = _split_frontmatter(COMMAND)
assert re.search(r"^description:\s*\S", frontmatter, re.M), (
"the command needs a description or it reads as an unlabelled prompt"
)
assert "`research`" in body, (
"the command must name the `research` skill it loads, so renaming the "
"skill fails here instead of silently degrading to an unsourced answer"
)
assert "$ARGUMENTS" in body, "the command must pass the operator's topic through"


def test_command_carries_the_output_contract():
"""The command body is the only text guaranteed to reach the model.

Verified 2026-10-25 by capturing the provider request: `opencode run` does
not expand slash commands, so on that path the literal text is the whole
prompt; on the command path the body is the whole prompt. Either way, if the
body loses the format contract, nothing else supplies it — the skill is only
read when the model chooses to load it.
"""
_, _, body = _split_frontmatter(COMMAND)
for required in (
"| Claim | Source | How verified |",
"**Confidence:**",
"**Unverified:**",
):
assert required in body, (
f"the command no longer states {required!r}; the three-part answer "
"is the deliverable, not a suggestion"
)


def test_skill_is_registered_in_agents_md():
assert "`research`" in AGENTS.read_text(), (
"AGENTS.md is the discovery surface agents read first; an unregistered "
"skill is one nobody loads — bin/definition-of-done-guard.py rejects "
"this on the commit that adds it"
)


def test_skill_carries_the_no_guess_rule():
"""The one sentence that makes the pair worth having."""
_, _, body = _split_frontmatter(SKILL)
lowered = body.lower()
assert "guess" in lowered, "the no-guess rule is the point of the skill"
assert "unverified" in lowered, (
"the skill must make 'unverified' an acceptable answer, or the agent "
"has no honest output for a question it cannot source"
)
Loading