| name | research-op |
| description | Use when the user invokes /research-op or needs guarded changes to an existing research package or Scope SSOT: row/card/section edits, inventory field updates, checks/lints, artifact event fanout, registry additions, scope transitions, or natural-language status fixes. |
| allowed-tools | Bash(python3 *), Read, Edit, Write, Grep, Glob |
| context | fork |
| disable-model-invocation | false |
research-op
Purpose
The single mutation surface for existing research packages. Replaces direct Edit/Write on package files. Enforces the (category, status, op, target) legality matrix and per-target invariants before any byte hits disk. Appends one audit line per op to a local jsonl log.
Invocation
Two shapes — structured (autonomous) and natural-language (user).
python skills/research-op/scripts/research_op.py --pkg <id> --op insert --target methodsTried --payload '{...}'
python skills/research-op/scripts/research_op.py --pkg <id> --event CHAIN_DONE --payload '{"artifact": "..."}'
python skills/research-op/scripts/research_op.py --pkg <id> --op check --scope all
python skills/research-op/scripts/research_op.py --pkg <id> --op check --scope scope-alignment
python skills/research-op/scripts/research_op.py --pkg <id> --op scan-events
python skills/research-op/scripts/research_op.py --pkg _scope --op scope-transition --from-triage <triage-id>
python skills/research-op/scripts/research_op.py --pkg _scope --op scope-transition \
--payload '{"id":"dir/<id>","level":"direction","parents":["project/main"],"version":1,"status":"ACTIVE","spec":{...},"source":"txn-0","op":"create","gate":"USER_CROSS_MODEL_AUDIT"}'
python skills/research-op/scripts/research_op.py --pkg <id> --op registry-add --target paper \
--payload '{"id":"dpr2020","title":"Dense Passage Retrieval","url":"https://arxiv.org/abs/2004.04906"}'
python skills/research-op/scripts/research_op.py --pkg <id> --op registry-add --target edge \
--payload '{"from":"paper:dpr2020","to":"paper:ours","type":"extends","evidence":"we adapt its dual-encoder"}'
python skills/research-op/scripts/research_op.py --pkg <id> --op registry-add --target gap \
--payload '{"id":"G1","summary":"no zero-shot benchmark for this domain"}'
python skills/research-op/scripts/research_op.py --pkg <id> --op insert --target rule \
--payload '{"level":"package","kind":"binding","slug":"one-notebook","title":"One notebook per figure","text":"Every figure gets its own notebook.","rationale":"reproducibility","addedAt":"2026-06-11"}'
python skills/research-op/scripts/research_op.py --pkg _project --op insert --target rule \
--payload '{"level":"project","kind":"constraint","slug":"no-eval-leak","title":"No eval leakage","text":"Never train on the eval split.","rationale":"validity","addedAt":"2026-06-11","ack":"<verbatim human approval>"}'
python skills/research-op/scripts/research_op.py --pkg _project --op check --target rule
python skills/research-op/scripts/research_op.py --nl 'update: set status of 2026-05-15-panda-baselines to BLOCKED, reason: GPU contention'
Natural-language handling
--nl is an escape hatch: the CLI does not parse prose — it returns exit 4 and asks for the structured form. Parsing is the agent's job, done here in the body, so the agent stays the one translator (no brittle regex in the script). To handle an NL fix:
- Pick the op from the leading verb:
set/update → update, add/insert → insert, remove/delete → delete, check/lint → check.
- Read the package id (the
YYYY-MM-DD-slug token in the prose).
- Read the target and the new value(s): e.g. "status of X to BLOCKED" → target
status, payload {"to":"BLOCKED"}; a trailing reason: … clause becomes an extra payload field.
- Echo one preview line back to the user before running:
→ --pkg <id> --op <op> --target <target> --payload <json>.
- Run that structured command. On reject, follow On reject.
Preconditions
| Precondition | Check |
|---|
| Package exists | test -f research_html/packages/<id>/index.html |
| Inventory entry exists | grep -q "id: '<id>'" research_html/data/research-packages.js |
| Runtime root resolved | RESEARCH_RUNTIME_ROOT env or default outputs/<id>/ exists |
These package preconditions do not apply to --pkg _scope --op scope-transition; Scope transitions
are project-level and can create the first Project node before any package exists.
Op surface
Primitives: insert · update · delete · check. Composite events: CHAIN_DONE · CHECKPOINT_SAVED · SENTINEL_WRITE · PHASE_MARKER · CANDIDATE_SUBMITTED. Full legality matrix in references/matrix.md. Per-event surface map in references/composite-events.md.
Three ops live outside the (category, status) matrix (they are project-level, not package surfaces):
scan-events — read-only artifact scan (no state-gate, no validation) that lists newly-locked facts for the per-turn propagation cycle.
scope-transition — the one gated writer for the Scope SSOT, used by research-scope after human ratification. Invoke it with --pkg _scope, preferably --from-triage <triage-id> so the committed payload is the accepted proposal snapshot. It is gated by the node level (project / direction / task), not the package state machine, and appends one transition to outputs/_scope/transitions.jsonl. When research_html/data/ exists, it also refreshes and checks research_html/data/scope-projection.json/js. The explicit payload form carries the node fields (id, level, parents, version, status, spec, source) plus the transition meta (op, gate, trigger, cause, invalidates, reopens, dial_revert).
registry-add — the gated writer for the project-level knowledge registries (--target paper | edge | gap), the durable cross-package stores the Context Pack reads. Gated by per-target reject-before-write validators (registry.py), not the package state machine; dedups and appends one line to research_html/data/{papers,edges,gaps}.jsonl. Payloads: paper = {id|arxiv|source_id (≥1 required), title (required), url, pkg}; edge = {from (required), to (required), type ∈ extends|contradicts|addresses_gap|invalidates (required), evidence}; gap = {id (required), summary (required), status}. A duplicate is a silent idempotent skip (still audited). --pkg is the adding context (must exist) and is recorded on the audit line.
--target rule with --pkg _project — the project-level half of the unified rules registry (data/rules.js). Package-level rule rows flow the normal state-gated path (--pkg <pkg-id>, matrix rows I12/U14/D9); project-level rows use the synthetic _project context and require a non-empty payload.ack (the distinct human action supplied by the governed caller). level=universal is write-locked everywhere (the R/T mirror); origin ∈ {mirror, selfevolve} rows are export-owned. --op check --target rule wraps learnings_lint.py lint-rules. Retired targets package-invariant / analysis-rule reject with a pointer to this target.
Validate-before-write contract
Every Insert / Update / Delete passes Phase 1 (state gate) and Phase 2 (invariant check) before bytes hit disk. Phase 1 looks up (category, status, op, target) in transitions.py; Phase 2 runs per-target rules from references/validate-rules.md. Reject envelope: {rejected: true, phase, rule, file, anchor, field, expected, actual, suggested_fix, op, target}. On reject, the agent retries with the rule visible.
For Scope-materialized packages, run --op check --scope scope-alignment before status transitions,
terminal routing, package result closeout, or any rewrite that depends on sourceDirection /
sourceTasks. This check wraps learnings_lint.py lint-status --pkg <id> and verifies package
provenance against the current Scope SSOT projection.
On reject
Read the structured envelope. The suggested_fix field tells you how to adjust the payload. Re-invoke with the corrected payload. Do not bypass — every rejection traces to a real spec invariant.
Audit log
Path: outputs/<pkg>/_actions.jsonl. One JSONL line per op invocation (success or reject). Verbatim payload included. Never tracked in git. tail -f is the live-observability surface; grep '"validation": "OP_REJECTED"' is the agent-stuck debug surface.
Single-home invariants this skill protects
- Terminal freeze (delete-only): a
methodsTried row cannot be deleted once status is in success/* or fail/* (enforced by rule_methodstried_terminal_frozen); appending rows and updating terminationMessage stay legal there. verdict and evidencePath are not independent --targets — they are fields inside a methodsTried / result-gate row, mutated through those targets, never directly.
- Single-home rule: each Insert has exactly one target file; downstream surfaces re-paint.
- Per-event atomicity: composite events either succeed for every owning surface or fail entirely; no half-written fan-out.
Pointers
Bundled resources
scripts/research_op.py — CLI entry.
scripts/rules_store.py — data/rules.js load/save + row validation (the registry writer the rule ops use).
scripts/transitions.py — the legality matrix as Python dicts (per-op target maps).
scripts/events.py — 5 composite events.
scripts/validate.py — Phase 2 rules.
scripts/router.py — dispatcher.
scripts/audit.py — jsonl writer.
scripts/scan_events.py — artifact mtime scanner.
scripts/ops/{insert,update,delete,check}.py — per-op handlers.
scripts/ops/_pkg_block.py — HTML package-block parser shared by the insert/update/delete handlers.