Skip to content

Never read the source

An agent says it researched something thoroughly. There is no way to tell whether it read the sources or skimmed one, whether it covered the terms that mattered or a handful of the many. The claim of rigor reads the same whether the work was deep or shallow, because nothing about the run is checked against what actually happened. “I looked into it” is unfalsifiable, and unfalsifiable is exactly what a shortcut hides behind.

Watch it happen — then get refused

A research run is declared, and the declaration is what puts it in scope. From there the run has to meet a real depth, checked against the trajectory rather than the agent’s summary of it.

  1. A context wakes when the turn declares research, or dispatches a sub-agent to do it, so depth-checking only applies to a branch that was actually declared as research.

  2. A gate at Stop runs only while that context is active and refuses the stop unless the run cloned a repository itself and read at least two of that clone’s source files — not its README or docs. It counts what any dispatched sub-agent did as well, credits a clone only when git’s own record of it says it was made this session (not output the agent controls, so a checkout already on disk does not count even when the clone that ran into it hid its failure), and says exactly what is missing and what to do.

  3. A second gate holds the conclusion until the reading is done: while a research run is open, a write of the research notes (Markdown in the project, in any letter case) is refused before it lands until the run has depth — by the same rule, with the same remedy. So is a write that adds the proposal itself (a “Proposed approach” section) when no research was declared at all: the project’s convention is research before proposing, declared or not. A Stop gate can refuse a turn, but only a gate on the write can keep the proposal from being written first.

A context can only track; the gate is what refuses the shallow run.

This is the real example shipped at examples/research-rigor/. Two natures, each a directory under .sloprail/ — a *.yaml declaration plus the scripts it names:

  • Directory.sloprail/
    • Directorycontext/
      • Directoryresearch-run/
        • context.yaml
        • enter.sh
        • exit.sh
    • Directorygate/
      • Directorydepth-check/
        • gate.yaml
        • verify-depth.sh
        • research-facts.jq
      • Directoryfindings-need-depth/
        • gate.yaml
        • findings-after-depth.sh

Wakes when the turn declares research, and stays active for that branch. It only tracks — it never refuses.

.sloprail/context/research-run/context.yaml
# Only tracks turns declared #research. `match` filters declaratively, so
# enter never runs on a turn that tagged something else.
#
# Research handed to a sub-agent is research too: a dispatch whose prompt
# declares #research activates the context in the dispatching session, whose
# Stop the depth gate then judges — counting what the sub-agent did as well.
on:
- event: PostTagWrite
match: any(event.tags, .label == "research")
- event: PreToolUse
match: event.tool in ["Agent", "Task"] and event.input.prompt contains "#research"
# A proposal written without a declaration is research that must have been
# done too: NOTES.md's convention is research BEFORE proposing. A write that
# added a "Proposed approach" section (gate/findings-need-depth/proposal.jq)
# opens the run, so depth-check judges it at Stop — the backstop for a
# proposal the write-time gate could not see coming (a shell write whose
# result was not known ahead). enter decides; the match only narrows.
- event: PostFileWrite
match: >-
event.path matches "(?i)[.](md|markdown|mdx|txt|text|rst|adoc|asciidoc|org)$"
and not (event.path startsWith "/")
and not (event.path startsWith ".")
enter: ./enter.sh
exit: ./exit.sh
.sloprail/context/research-run/enter.sh
#!/usr/bin/env bash
# enter: a #research tag or dispatch (the triggers' match already confirmed
# it) opens a declared run. A file write opens one only if it added a
# "Proposed approach" section — the findings — was not already seen at an
# earlier Stop, and was written by this trajectory or one it dispatched; an
# active run stays as it is. See context.md — a clean exit ACTIVATES, so every
# "no" here exits non-zero.
set -uo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
input="$(cat)"
kind="$(printf '%s' "$input" | jq -r '.event.kind // empty')"
case "$kind" in
PostFileCreate | PostFileUpdate) ;;
*)
jq -n '{declared: true}'
exit 0
;;
esac
# Declining is a NON-ZERO exit: it leaves the context exactly as it was.
# (A clean exit with no output would activate it, keeping the old payload.)
# Already open: keep what opened it — a declared run stays declared.
[ "$(printf '%s' "$input" | jq -r '.currentContext.active // false')" != "true" ] || exit 1
adds="$(printf '%s' "$input" | jq -r -L "$here/../../gate/findings-need-depth" 'include "proposal";
if .event.seen == true then false else (.event | adds_proposal) end' 2>/dev/null)"
[ "$adds" = "true" ] || exit 1
# One spelling per file (/var vs /private/var), for comparing trajectories.
rp() { realpath -q -- "$1" 2>/dev/null || printf '%s' "$1"; }
open() { printf '%s' "$input" | jq -c '{declared: false, proposal: .event.path}'; exit 0; }
# Who owes the research for this proposal? A Stop sees every file that
# changed in the session, so a sub-agent whose dispatcher wrote NOTES.md must
# not be asked for it — but the dispatcher of a sub-agent that wrote it must:
# the sub-agent's own refusals end when its Stop cap does, and the research was
# the dispatcher's to see done. So the proposal belongs to the trajectory that
# wrote it AND every trajectory above it (its parentPath chain), never to a
# sibling or one below. A write no call names is owed by the calls of this
# cycle that could have made it unseen (an interpreter, a script, eval of a
# variable); with none of those either, it is not the agent's (a user's edit,
# git bringing in committed content). Anything that cannot be read opens the
# run (fail closed).
me="$(printf '%s' "$input" | jq -r '.transcriptPath // empty')"
path="$(printf '%s' "$input" | jq -r '.event.path // empty' | tr '[:upper:]' '[:lower:]')"
[ -n "$me" ] || open
me="$(rp "$me")"
parent_of() { sr-session trajectory describe --path "$1" 2>/dev/null | jq -r '.parentPath // empty' 2>/dev/null; }
# The session's root, and every record of the session.
root="$me"
for _ in 1 2 3 4 5 6 7 8; do
up="$(parent_of "$root")"
[ -n "$up" ] || break
root="$up"
done
if ! desc="$(sr-session trajectory describe --path "$root" 2>/dev/null)"; then open; fi
records="$(printf '%s\n' "$root"; printf '%s' "$desc" | jq -r '.subagentPaths[]?' 2>/dev/null)"
# This cycle: what ran since the root's last prompt (a human message, or the
# feedback of a refused Stop). A write made in an earlier cycle was judged at
# that cycle's Stop; a change arriving now that no call of this cycle made —
# a user's own edit between turns — is not the agent's.
# Run from the workspace: the engine resolves a recorded command's relative
# paths against the record's own cwd, and this is the defence should it not
# (this script's own folder holds no NOTES.md).
entries_of() { (cd "${SR_WORKSPACE:-.}" && sr-session trajectory normalize --path "$1" --events PreCommandInvoke,PreFileCreate,PreFileUpdate 2>/dev/null); }
root_entries="$(entries_of "$root")" || open
# The cycle's start time is the prompt's own timestamp, or — a record written
# without one — that of the first stamped record after it: either way no
# earlier than anything this cycle ran.
since="$(printf '%s' "$root_entries" | jq -r '
. as $all
| [ .[] | select(.type == "user" and (.isSidechain | not))
| select(.message.content | if type == "string" then true else (map(.type) | index("tool_result") | not) end)
] | last
| if . == null then "0\t"
else .line as $l
| (.timestamp // ([ $all[] | select(.line > $l) | .timestamp // empty ] | first) // "") as $ts
| "\($l)\t\($ts)" end' 2>/dev/null)" || open
since_line="$(printf '%s' "$since" | cut -f1)"
since_ts="$(printf '%s' "$since" | cut -f2)"
# Writers this cycle: a call the engine derived a write of the path from
# (named), else a call that could write without naming it (unnamed:
# writers.jq). Git bringing in committed content is neither.
# An executable git hook makes every git command a possible writer.
githooks=false
ws="${SR_WORKSPACE:-.}"
if [ -n "$(git -C "$ws" config --get core.hooksPath 2>/dev/null)" ]; then
githooks=true
else
hooks_dir="$(git -C "$ws" rev-parse --git-path hooks 2>/dev/null)"
case "$hooks_dir" in /*) ;; ?*) hooks_dir="$ws/$hooks_dir" ;; esac
if [ -n "$hooks_dir" ] && [ -d "$hooks_dir" ]; then
for h in "$hooks_dir"/*; do
case "$h" in *.sample) continue ;; esac
[ -f "$h" ] && [ -x "$h" ] && { githooks=true; break; }
done
fi
fi
named=""
unnamed=""
background=""
while IFS= read -r rec; do
[ -n "$rec" ] || continue
[ -r "$rec" ] || open
if [ "$rec" = "$root" ]; then ents="$root_entries"; else ents="$(entries_of "$rec")" || open; fi
kinds="$(printf '%s' "$ents" | jq -r -L "$here/../../gate/findings-need-depth" \
--arg p "$path" --argjson root "$([ "$rec" = "$root" ] && echo true || echo false)" \
--argjson sl "${since_line:-0}" --arg st "$since_ts" --argjson gh "$githooks" 'include "writers";
[ .[] | (if $root then .line > $sl
elif $st != "" and (.timestamp // "") != "" then .timestamp >= $st
else true end) as $now
| if $now then
(if names_write($p) then "named" elif runs_unnamed_writer($gh) then "unnamed" else empty end)
elif starts_background_writer($p; $gh) then "background@\(.timestamp // "")"
else empty end ]
| unique | join(" ")' 2>/dev/null)" || open
case " $kinds " in *" named "*) named="$named$rec
" ;; esac
case " $kinds " in *" unnamed "*) unnamed="$unnamed$rec
" ;; esac
# The EARLIEST background writer's start in this record, for the time test.
bg_ts="$(printf '%s' "$kinds" | tr ' ' '\n' | sed -n 's/^background@//p' | sort | head -n 1)"
case " $kinds " in *" background@"*) background="$background$rec $bg_ts
" ;; esac
done <<EOF
$records
EOF
# A named writer is the writer. With none, the calls that could have written
# it unseen are. With neither, something the agent started in an EARLIER cycle
# and left running (`… &`, nohup, setsid, run_in_background) could have — its
# trajectory owes it. With none of those either, nothing the agent ran wrote
# it (a user's edit, git bringing in committed content with no hook).
# A call that could write UNSEEN is charged only if the file changed at or
# after the time that call could have run: its status-change time (ctime),
# which no one can set back (`touch -d` and os.utime move mtime, and change
# ctime to now). A file that last changed before this cycle began — a user's
# edit between turns — was not written by this cycle's calls. A background
# job is measured from its own start in the earlier cycle, so a user edit made
# while such a job still runs IS charged: the two cannot be told apart.
# An unknown time on either side fails closed (charged).
# A file's ctime in epoch seconds: the GNU/BusyBox form first, then BSD's
# (macOS rejects -c). Not chosen by `stat --version` — BusyBox rejects it,
# and there BSD's -f means filesystem status (%c = total inodes). Anything but
# a plausible epoch is no answer.
ctime_of() {
v="$(stat -c %Z -- "$1" 2>/dev/null)"
case "$v" in "" | *[!0-9]*) v="$(stat -f %c -- "$1" 2>/dev/null)" ;; esac
case "$v" in "" | *[!0-9]*) return 1 ;; esac
[ "$v" -gt 1000000000 ] || return 1
printf '%s' "$v"
}
# How far the filesystem's clock lags this host's, in seconds — or "unknown":
# the cycle's start is the harness's clock, a file's ctime the filesystem's,
# and a bind mount or network share can lag, which would make a write in this
# cycle look older than it. Measured once, on a scratch file created where it
# is invisible to the user's tree: the repository's own git directory (in a
# linked worktree .git is a FILE, and the real one is .git/worktrees/<name>)
# when it is on the same filesystem as the notes, else beside the notes. When
# no scratch file can be made the lag is unknown, and a change is charged.
dev_of() {
v="$(stat -c %d -- "$1" 2>/dev/null)"
case "$v" in "" | *[!0-9]*) v="$(stat -f %d -- "$1" 2>/dev/null)" ;; esac
case "$v" in "" | *[!0-9]*) return 1 ;; esac
printf '%s' "$v"
}
fs_lag() {
notes_dir="$(dirname "$ws/$path_as_given")"
probe_dir="$notes_dir"
gitdir="$(git -C "$ws" rev-parse --absolute-git-dir 2>/dev/null)"
if [ -n "$gitdir" ] && [ -d "$gitdir" ] && [ "$(dev_of "$gitdir")" = "$(dev_of "$notes_dir")" ]; then
probe_dir="$gitdir"
fi
probe="$(mktemp "$probe_dir/.sr-clock.XXXXXX" 2>/dev/null)" || { echo unknown; return; }
trap 'rm -f "$probe"' EXIT
now="$(date +%s)"; pc="$(ctime_of "$probe")"; rm -f "$probe"
[ -n "$pc" ] || { echo unknown; return; }
echo $(( now - pc ))
}
changed_since() { # <iso time>
[ -n "$1" ] || return 0
start="$(printf '%s' "$1" | jq -Rr 'sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601' 2>/dev/null)" || return 0
[ -n "$start" ] || return 0
ct="$(ctime_of "$ws/$path_as_given")" || return 0
[ -n "$lag" ] || lag="$(fs_lag)"
# An unmeasurable lag could hide an in-cycle write: charged (fail closed).
[ "$lag" != "unknown" ] || return 0
# A filesystem clock more than a second behind moves the start back by as
# much; one ahead only makes more changes look recent (charged — fails
# closed).
[ "$lag" -gt 1 ] 2>/dev/null && start=$(( start - lag ))
[ "$ct" -ge "$start" ]
}
lag=""
path_as_given="$(printf '%s' "$input" | jq -r '.event.path // empty')"
writers="$named"
if [ -z "$writers" ] && [ -n "$unnamed" ] && changed_since "$since_ts"; then writers="$unnamed"; fi
if [ -z "$writers" ] && [ -n "$background" ]; then
while IFS="$(printf '\t')" read -r rec ts; do
[ -n "$rec" ] || continue
changed_since "$ts" && writers="$writers$rec
"
done <<EOF
$background
EOF
fi
[ -n "$writers" ] || exit 1
# Open when this trajectory is a writer or above one.
while IFS= read -r w; do
[ -n "$w" ] || continue
cur="$w"
for _ in 1 2 3 4 5 6 7 8 9; do
[ "$(rp "$cur")" = "$me" ] && open
cur="$(parent_of "$cur")"
[ -n "$cur" ] || break
done
done <<EOF
$writers
EOF
exit 1
.sloprail/context/research-run/exit.sh
#!/usr/bin/env bash
# exit: reads the paired gate's verdict from `gates`; does not check anything itself.
set -uo pipefail
input="$(cat)"
status="$(printf '%s' "$input" | jq -r '.gates["depth-check"].status // "fail"' 2>/dev/null)"
if [ "$status" = "pass" ]; then
exit 0
fi
exit 1

The piece that blocks. It runs at Stop only while a research run is active, and refuses when the declared research doesn’t meet the required depth. When no research was declared, it skips.

.sloprail/gate/depth-check/gate.yaml
# On Stop, only when research-run is active (match skips otherwise). `require`
# names the context so its enter runs first this cycle, making the active read
# meaningful.
on:
- event: Stop
match: context["research-run"].active
require:
- context: research-run
checks:
- script: ./verify-depth.sh
.sloprail/gate/depth-check/verify-depth.sh
#!/usr/bin/env bash
# Depth check: this research run cloned a real repository AND read its source —
# at least MIN_SOURCE_FILES distinct files (or searches of a source
# subdirectory that printed something) inside a directory THIS run cloned —
# confirmed by git's own output or on disk — beyond its README and docs. See
# the example's README.md for why depth means reading what was cloned, not
# counting searches.
#
# Shared: the depth-check Stop gate runs it as its check, and the
# findings-need-depth gate runs it before a research-notes write with
# DEPTH_FOR_WRITE=<path>, so both judge depth by the same rule and word the
# remedy the same way.
set -uo pipefail
# Two distinct source reads. One file can be an entry point that only
# re-exports; a second means the reading followed the implementation past it.
# A floor that separates "opened the repo" from "read how it works", not a
# measure of quality — and meeting it costs the agent real reading either way.
MIN_SOURCE_FILES=2
here="$(cd "$(dirname "$0")" && pwd)"
input="$(cat)"
transcript_path="$(printf '%s' "$input" | jq -r '.transcriptPath // empty')"
block() {
echo "$1" >&2
exit 1
}
[ -n "$transcript_path" ] || block "The depth check got no transcript path, so what this research run did cannot be read."
# A trajectory that cannot be read is not evidence of anything the agent did or
# did not do: the refusal says it could not be read, rather than "has not
# cloned", which would send the agent to clone again for a failure not its own.
unreadable() {
block "The depth check could not read this #research run's trajectory $1, so whether the research has depth is unknown: $2"
}
# A tool's stderr is kept apart from the JSON on its stdout — a warning on
# stderr must not corrupt what is parsed — and shown only when the tool fails.
errf="$(mktemp)" || block "The depth check could not create a temporary file, so this research run was not checked."
trap 'rm -f "$errf"' EXIT
errtext() { tr '\n' ' ' < "$errf" | cut -c1-400; }
# Every trajectory of this research run: this one and each sub-agent it
# dispatched (`describe` lists them) — research handed to a sub-agent is still
# this run's research, and a clone in one and reads in another are one run.
if ! described_run="$(sr-session trajectory describe --path "$transcript_path" 2>"$errf")"; then
unreadable "$transcript_path" "$(errtext)"
fi
if ! subagents="$(printf '%s' "$described_run" | jq -r '.subagentPaths[]?' 2>"$errf")"; then
unreadable "$transcript_path" "trajectory describe returned no readable subagentPaths: $(errtext)"
fi
facts="[]"
while IFS= read -r traj; do
[ -n "$traj" ] || continue
[ -f "$traj" ] || unreadable "$traj" "the file does not exist"
if ! entries="$(sr-session trajectory normalize --path "$traj" --events PreCommandInvoke 2>"$errf")"; then
unreadable "$traj" "$(errtext)"
fi
if ! one="$(printf '%s' "$entries" | jq -c -L "$here" --arg ws "${SR_WORKSPACE:-}" --arg home "${HOME:-}" -f "$here/research-facts.jq" 2>"$errf")" \
|| [ -z "$one" ]; then
unreadable "$traj" "its research facts could not be computed: $(errtext)"
fi
facts="$(printf '%s' "$facts" | jq -c --argjson f "$one" '. + [$f]')"
done <<EOF
$transcript_path
$subagents
EOF
# Whether a clone happened is read from git's own record of it, not from the
# command's output (which the agent controls: `2>/dev/null`, `|| echo`, `echo
# "Cloning into …"`): the first line of <dest>/.git/logs/HEAD, which git
# writes as `<old> <new> <who> <epoch> <tz>\tclone: from <url>`. The clone
# counts when that names the repository the invocation cloned and is no older
# than this session's first record — a checkout from an earlier session, or a
# .git copied or moved from one, carries its original line; a hand-made .git
# carries none. Nothing here reads file times, so it holds on any filesystem.
since="$(jq -rn 'first(inputs | .timestamp? | strings) | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601' < "$transcript_path" 2>/dev/null)"
reflogs="[]"
while IFS= read -r dest; do
[ -n "$dest" ] || continue
gitdir="$dest/.git"
if [ -f "$gitdir" ]; then
# --separate-git-dir: .git is a file naming the real one.
gd="$(sed -n 's/^gitdir: //p' "$gitdir" | head -n 1)"
case "$gd" in /*) gitdir="$gd" ;; ?*) gitdir="$dest/$gd" ;; esac
fi
first="$(head -n 1 "$gitdir/logs/HEAD" 2>/dev/null)"
# Beyond the line itself: the commit it says the clone checked out exists in
# the repository, and one of its remotes is what it was cloned from — a
# hand-written reflog over hand-made files has neither.
newsha="$(printf '%s' "$first" | cut -f1 | awk '{print $2}')"
hascommit=false
[ -n "$newsha" ] && git --git-dir="$gitdir" cat-file -e "$newsha^{commit}" 2>/dev/null && hascommit=true
# Every remote's URL: `git clone -o upstream` names its remote otherwise.
remotes="$(git --git-dir="$gitdir" config --get-regexp '^remote\..*\.url$' 2>/dev/null | cut -d' ' -f2- | jq -R -s -c 'split("\n") | map(select(. != ""))')"
reflogs="$(printf '%s' "$reflogs" | jq -c --arg d "$dest" --arg l "$first" --argjson c "$hascommit" --argjson r "${remotes:-[]}" \
'. + [{dest: $d, line: $l, commit: $c, remotes: $r}]')"
done <<EOF
$(printf '%s' "$facts" | jq -r '[ .[].clones[].dest ] | unique[]')
EOF
# What each read path really is: its target once symlinks are resolved, and
# its file identity (device:inode). `lib2 -> lib` is not a second directory,
# a hard link is not a second file, and a symlink out of a clone is not a read
# inside it.
# GNU/BusyBox form first, then BSD's (macOS rejects -c). Not chosen by
# `stat --version`: BusyBox rejects it, and there BSD's -f means "filesystem
# status" — every file would get the same filesystem's numbers and every read
# collapse into one. An answer that is not device:inode is no answer.
ident() {
v="$(stat -L -c %d:%i -- "$1" 2>/dev/null)"
case "$v" in *[!0-9:]* | "" | :* | *:) v="$(stat -L -f %d:%i -- "$1" 2>/dev/null)" ;; esac
case "$v" in *[!0-9:]* | "" | :* | *:) return 0 ;; esac
printf '%s' "$v"
}
real() { realpath -q -- "$1" 2>/dev/null || readlink -f -- "$1" 2>/dev/null || printf '%s' "$1"; }
paths="$(printf '%s' "$facts" | jq -r '[ .[].reads[], .[].clones[].dest ] | unique[]' | while IFS= read -r p; do
[ -n "$p" ] || continue
printf '%s\t%s\t%s\n' "$p" "$(real "$p")" "$(ident "$p")"
done | jq -R -s -c 'split("\n") | map(select(. != "") | split("\t") | {key: .[0], value: {real: .[1], id: .[2]}}) | from_entries')"
[ -n "$paths" ] || paths="{}"
# A Read with a small limit whose shown lines ran to the file's end showed the
# whole file (a short file read in one go): it counts as a full read.
endreads="$(printf '%s' "$facts" | jq -r '.[].partialEnds[]? | "\(.last)\t\(.path)"' | while IFS="$(printf '\t')" read -r last p; do
[ -f "$p" ] || continue
n="$(wc -l < "$p" 2>/dev/null | tr -d ' ')"
[ -n "$n" ] && [ "$n" -le "$last" ] 2>/dev/null && printf '%s\n' "$p"
done | jq -R -s -c 'split("\n") | map(select(. != ""))')"
[ -n "$endreads" ] || endreads="[]"
# The verdict over the whole run: which clone directories count, which reads
# landed inside them, and what the agent read elsewhere (for the refusal).
verdict="$(printf '%s' "$facts" | jq -c -L "$here" --argjson min "$MIN_SOURCE_FILES" \
--argjson reflogs "$reflogs" --argjson paths "$paths" --arg since "${since:-}" --argjson endreads "$endreads" \
--arg ws "${SR_WORKSPACE:-}" --arg home "${HOME:-}" '
include "paths";
def under($d): . == $d or startswith($d + "/");
# A README, changelog, licence, anything under a docs directory, and the
# project metadata around the code (manifests, lockfiles, dotfiles, CI and
# build config) say what a library claims or how it is built, not how it
# works.
def is_doc:
(split("/") | map(ascii_downcase)) as $parts
| ($parts | last) as $base
| ($parts | any(IN("docs", "doc", "documentation", ".git", ".github", ".circleci", ".gitlab", ".vscode", ".idea")))
or ($base | test("^(readme|changelog|changes|history|license|licence|copying|notice|contributing|code_of_conduct|security|authors|maintainers|codeowners)([.-].*)?$"))
or ($base | test("\\.(md|markdown|mdx|rst|txt|adoc|asciidoc|org|lock)$"))
or ($base | startswith("."))
or ($base | test("^(package(-lock)?\\.json|npm-shrinkwrap\\.json|yarn\\.lock|pnpm-lock\\.yaml|bun\\.lockb|go\\.(mod|sum|work)|cargo\\.(toml|lock)|pyproject\\.toml|setup\\.cfg|pipfile(\\.lock)?|poetry\\.lock|requirements[^/]*\\.(txt|in)|gemfile(\\.lock)?|[^/]*\\.gemspec|composer\\.(json|lock)|pom\\.xml|(build|settings)\\.gradle(\\.kts)?|gradle\\.properties|tsconfig[^/]*\\.json|jsconfig\\.json|tox\\.ini|renovate\\.json|dependabot\\.ya?ml|codecov\\.ya?ml|appveyor\\.ya?ml|azure-pipelines\\.ya?ml|mkdocs\\.ya?ml)$"));
# Where a path really is (symlinks resolved) and which file it is.
def realof: . as $p | ($paths[$p].real // "" | if . == "" then $p else . end | canon);
def idof: . as $p | ($paths[$p].id // "" | if . == "" then ($p | realof) else . end);
($since | if . == "" then null else tonumber end) as $since
| ($ws | canon) as $ws
# A clone counts when the record git wrote says it was cloned from that
# repository during this session.
| ([ $reflogs[]
| .dest as $d
| (.line | split("\t")) as $parts
| select(($parts | length) >= 2)
| ($parts[0] | split(" ") | .[-2] | tonumber? // null) as $ts
| ($parts[1:] | join("\t") | capture("^clone: from (?<url>.*)$")? | .url | repokey(null)) as $url
| select($ts != null and $since != null and $ts >= ($since | floor))
| select(.commit == true and any(.remotes[]; repokey(null) == $url))
| {key: $d, value: $url} ] | from_entries) as $cloned
# A clone of this project itself is not prior art: it reads what the agent
# is meant to be researching FOR.
| ([ .[].clones[] | select(.repo != null and $ws != "" and (.repo == $ws or (.repo | startswith($ws + "/")))) | .dest ] | unique) as $self
| ([ .[].clones[] | select(.repo != null and $cloned[.dest] == .repo) | .dest ] | unique - $self) as $dirs
| ([ .[].clones[].dest ] | unique - $dirs - $self) as $unconfirmed
| ([ .[].unresolvedClones ] | add // 0) as $unresolved
| ([ .[].failedClones[]? ] | unique - $dirs) as $failed
| ([ .[].reads[] ] | unique) as $reads
| ([ .[].fullReads[]?, $endreads[] ] | unique) as $fullreads
| [ $dirs[] | realof ] as $realdirs
# Source: really under a confirmed clone, below its root (a search of the
# root takes in the README and docs too), not documentation or metadata —
# one per file, however many spellings reached it.
| ([ $reads[] | . as $p | ($p | realof) as $rp
| first($realdirs[] | select(. as $d | $rp | under($d))) as $d
| ($rp | ltrimstr($d) | ltrimstr("/")) as $rel
| select($rel != "" and ($rel | is_doc | not))
| {p: $p, id: ($p | idof)} ]
| unique_by(.id) | map(.p)) as $source
# Credited source files every read of which showed only part (head -c 1,
# sed -n 1p, a Read with limit): credited, and reported for the eval.
| [ $source[] | select(. as $p | $fullreads | index($p) | not) ] as $glimpsed
| [ $reads[] | . as $p
| select(any($realdirs[]; . as $d | $p | realof | under($d)) | not)
| select(($ws == "" or ($p | under($ws) | not)) and ($home == "" or ($p | under($home + "/.claude") | not)))
| select(is_doc | not) ] as $elsewhere
| {pass: (($dirs | length) > 0 and ($source | length) >= $min),
dirs: $dirs, unresolved: $unresolved, source: $source, elsewhere: $elsewhere,
failed: [ $failed[] | select(. as $f | $elsewhere | any(. == $f or startswith($f + "/"))) ],
unconfirmed: $unconfirmed, self: $self, glimpsed: $glimpsed}
' 2>"$errf")" || block "The depth check could not evaluate this research run's trajectory ($transcript_path): $(errtext)"
# DEPTH_REPORT=<file>: the verdict itself, for a caller that needs more than
# pass/fail (the eval's scorer asks which credited reads were glimpses).
[ -z "${DEPTH_REPORT:-}" ] || printf '%s' "$verdict" > "$DEPTH_REPORT"
if [ "$(printf '%s' "$verdict" | jq -r '.pass')" != "true" ]; then
# An undeclared run is held for its proposal alone: "#research run" would
# name a declaration it never made.
label="This #research run"
if [ -n "${DEPTH_PROPOSAL:-}" ] || [ "$(printf '%s' "$input" | jq -r '.context["research-run"].payload.declared == false')" = "true" ]; then
label="This run"
fi
reason="$(printf '%s' "$verdict" | jq -r --argjson min "$MIN_SOURCE_FILES" --arg label "$label" '
def list($xs): ($xs[:3] | join(", ")) + (if ($xs | length) > 3 then ", …" else "" end);
def more($n): if $n == 1 then "1 more distinct source file" else "\($n) more distinct source files" end;
(if (.dirs | length) == 0 then
$label + " has not cloned a repository: no git clone in it (or in a sub-agent it dispatched) succeeded"
+ (if .unresolved > 0 then " into a directory that can be located — clone into a literal path, not one built from a variable or reached through an unresolvable cd" else "" end)
+ ". To finish the research: git clone a real repository that implements what you are researching, then read at least \($min) of its source files (not only the README or docs) with Read, Grep, cat, sed, grep or rg."
else
(if (.dirs | length) == 1 then "its" else "their" end) as $its
| $label + " cloned " + list(.dirs) + " but read "
+ (if (.source | length) == 0 then "none of " + $its + " source files"
else "only one source file " + (if (.dirs | length) == 1 then "in it" else "across them" end)
+ " (" + list(.source) + "), and \($min) are needed" end)
+ " — a README or docs file does not count. To finish the research: read "
+ more($min - (.source | length)) + " inside " + list(.dirs)
+ " with Read, Grep, cat, sed, grep or rg; reading the same file again does not add one."
end)
+ (if (.failed | length) > 0 then
" Your git clone into " + list(.failed) + " failed because the directory was already there, so its contents are not this run'"'"'s clone — clone into a new directory to use that repository."
else "" end)
+ (if (.unconfirmed | length) > 0 then
" Your git clone into " + list(.unconfirmed) + " could not be confirmed: git'"'"'s own record of the clone (.git/logs/HEAD) is missing, older than this session, or names a different repository, so it may be a checkout that was already on disk — clone into a new directory."
else "" end)
+ (if (.self | length) > 0 then
" Your clone into " + list(.self) + " is of this project itself, which is not prior art — clone a real repository that implements what you are researching."
else "" end)
+ (if (.elsewhere | length) > 0 then
" Reads of directories this run did not clone do not count (e.g. " + list(.elsewhere) + ") — a checkout already on disk is not research this run did."
else " Reads of directories this run did not clone do not count." end)
')"
# Invoked by findings-need-depth before a research-notes write: say why the
# write is held, so the agent reads first and writes after.
if [ -n "${DEPTH_FOR_WRITE:-}" ] && [ -n "${DEPTH_PROPOSAL:-}" ]; then
reason="Writing $DEPTH_FOR_WRITE now would add a Proposed approach before any research — this project's NOTES.md requires researching real prior art before proposing, whether or not #research was declared. Do the reading first, then write it. $reason"
elif [ -n "${DEPTH_FOR_WRITE:-}" ]; then
reason="Writing $DEPTH_FOR_WRITE now would record this #research run's findings before the research has depth — do the reading first, then write it. $reason"
elif [ "$(printf '%s' "$input" | jq -r '.context["research-run"].payload.proposal // empty')" != "" ]; then
reason="$(printf '%s' "$input" | jq -r '.context["research-run"].payload.proposal') now holds a Proposed approach, and this project's NOTES.md requires researching real prior art before proposing — whether or not #research was declared. $reason"
fi
block "$reason"
fi
# A write is judged on depth alone; the trajectory-shape check below is about
# how the research ran, which the Stop gate answers.
[ -z "${DEPTH_FOR_WRITE:-}" ] || exit 0
# Each research trajectory must be its own agent. Only meaningful inside a
# subagent run: refuse when this ran as a subagent (.isSubagent) that carries
# sibling trajectory paths (.subagentPaths) alongside it.
described="$described_run"
# Checked by VALUE, not jq's exit status: some jq builds exit 0 on unparseable
# or empty input, which would read as "not a subagent" and permit.
is_subagent="$(printf '%s' "$described" | jq -r '.isSubagent' 2>/dev/null)"
case "$is_subagent" in
true | false) ;;
*) block "trajectory describe did not report isSubagent for $transcript_path, so whether this research ran as its own agent is unknown." ;;
esac
if [ "$is_subagent" = "true" ]; then
sibling_count="$(printf '%s' "$described" | jq '(.subagentPaths // []) | length' 2>/dev/null)"
case "$sibling_count" in
'' | *[!0-9]*) block "trajectory describe returned unreadable subagentPaths for $transcript_path, so sibling trajectories could not be counted." ;;
esac
if [ "$sibling_count" -gt 0 ]; then
block "This research ran in a subagent trajectory alongside ${sibling_count} sibling trajectories — each research trajectory must run as its own separate agent, not share one with others."
fi
fi
exit 0
.sloprail/gate/depth-check/research-facts.jq
# research-facts.jq — what ONE trajectory's research did, read off
# `sr-session trajectory normalize` output (an array of normalized entries).
#
# Emits {clones, failedClones, unresolvedClones, reads}:
# clones [{dest, repo}] — `git clone`s that did not visibly fail
# and whose destination directory is known (absolute,
# canonical); repo is the repository keyed as paths.jq's
# repokey spells it. Whether the clone really happened is
# NOT decided from its output here (the agent controls
# that — `echo "Cloning into …"`): verify-depth.sh reads
# git's own record of it, <dest>/.git/logs/HEAD.
# failedClones [dest] — `git clone`s that failed (an error result, or a
# `fatal:` naming them): a directory already there is not
# this run's clone
# unresolvedClones how many `git clone`s ran whose destination could not be
# placed (a directory named through a variable, or after a
# `cd` the engine could not resolve)
# reads [path] — files or directories whose CONTENT a successful
# tool call read: the Read tool, the Grep tool, and shell
# readers (cat, head, tail, sed, awk, grep, rg, …). A
# search counts only if it printed matching lines — not a
# count, a file list or nothing — and was not restricted
# to documentation files.
#
# Nothing here decides depth; verify-depth.sh does, over every trajectory of
# the research run at once (a sub-agent's clone and the root's reads are one
# run's research).
#
# Inputs: --arg ws the workspace root, the directory a record with no `cwd`
# of its own started in (the mock harness writes cwd only on
# a transcript's first record; Claude Code writes it on all)
# --arg home $HOME, for a leading `~`
# ---- paths ------------------------------------------------------------------
# canon, repokey — shared with the verdict (run with -L this directory).
include "paths";
# Resolve a path as written against the directory it was written in. null when
# it cannot be placed — no base to put a relative path on.
def resolve($base):
if . == null or . == "" then null
elif startswith("/") then canon
elif . == "~" then ($home | canon)
elif startswith("~/") then ($home + .[1:] | canon)
elif $base == null then null
else ($base + "/" + . | canon)
end;
# The directory one invocation runs in: the engine's `.cwd` (see events.md) is
# "." for where the line started, relative to that, absolute, or "" when a `cd`
# could not be resolved — which stays unknown rather than being guessed.
def invdir($base):
(.cwd // ".") as $c
| if $c == "" then null
elif $c == "." then $base
else ($c | resolve($base))
end;
# ---- git clone --------------------------------------------------------------
# git's own options that take the NEXT word as their value, before the
# subcommand. `-C <dir>` also moves where the clone lands.
def git_global_valued: ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"];
# `git clone` options that take the NEXT word as their value — so that value is
# not read as the repository or the destination.
def clone_valued: ["-o", "--origin", "-b", "--branch", "-u", "--upload-pack",
"--reference", "--reference-if-able", "--separate-git-dir", "--depth",
"--shallow-since", "--shallow-exclude", "-c", "--config", "--server-option",
"-j", "--jobs", "--template", "--filter", "--bundle-uri", "--revision",
"--ref-format"];
# The directory git derives from a repository when no destination is given:
# `https://github.com/a/node-retry.git` → node-retry, `git@h:a/b.git` → b.
def humanish:
sub("/+$"; "") | sub("/\\.git$"; "") | sub("^.*[/:]"; "") | sub("\\.git$"; "");
# argv of one `git` invocation → {cdirs, repo, dest} when it is a clone, else
# empty. dest is as written (null → git's humanish name of the repo).
def clone_of:
. as $a
| {i: 1, cdirs: []}
| until(.i >= ($a | length) or (($a[.i] | startswith("-")) | not);
if $a[.i] == "-C" then .cdirs += [$a[.i + 1]] | .i += 2
elif ($a[.i] as $t | git_global_valued | index($t)) then .i += 2
else .i += 1 end)
| select($a[.i] == "clone")
| .cdirs as $cdirs
| reduce $a[(.i + 1):][] as $t ({pos: [], skip: false, dd: false};
if .skip then .skip = false
elif .dd then .pos += [$t]
elif $t == "--" then .dd = true
elif ($t | startswith("-")) and $t != "-" then
(if ($t as $x | clone_valued | index($x)) then .skip = true else . end)
else .pos += [$t] end)
| select(.pos | length > 0)
| {cdirs: $cdirs, repo: .pos[0], explicit: (.pos | length > 1),
dest: (.pos[1] // (.pos[0] | humanish))};
# Whether a command line expands a variable or a substitution. The engine
# expands every word against an EMPTY environment (it never guesses at one), so
# `git clone url "$TMPDIR/x"` reaches argv as `/x` — a directory the clone did
# not land in. A destination written on such a line is not trusted.
def dynamic: test("\\$[{(A-Za-z_]|`");
# ---- reading ----------------------------------------------------------------
# Shell programs whose operands are files they read, and the options of each
# that take the next word as a value (so the value is not read as a file).
def readers: {
cat: [], less: [], more: [], nl: [], view: [],
bat: ["-l", "--language", "-r", "--line-range", "-H", "--highlight-line", "--theme", "--style", "-m", "--map-syntax"],
head: ["-n", "-c", "--lines", "--bytes"],
tail: ["-n", "-c", "--lines", "--bytes"],
sed: ["-e", "-f", "-l", "--expression", "--file", "--line-length"],
awk: ["-f", "-v", "-F", "--file", "--assign", "--field-separator"],
grep: ["-e", "-f", "-m", "-A", "-B", "-C", "-d", "-D", "--regexp", "--file", "--max-count",
"--after-context", "--before-context", "--context", "--devices", "--directories", "--label",
"--include", "--exclude", "--exclude-dir", "--exclude-from"],
rg: ["-e", "-f", "-g", "-t", "-T", "-m", "-A", "-B", "-C", "-M", "-j", "-E", "-d", "-r",
"--regexp", "--file", "--glob", "--iglob", "--type", "--type-not", "--max-count",
"--after-context", "--before-context", "--context", "--max-columns", "--threads",
"--encoding", "--max-depth", "--max-filesize", "--sort", "--sortr", "--type-add",
"--colors", "--pre", "--pre-glob", "--replace", "--context-separator"],
ag: ["-A", "-B", "-C", "-G", "-m", "--file-search-regex", "--max-count", "--ignore", "--depth"]
} | .egrep = .grep | .fgrep = .grep | .gawk = .awk;
# Options whose value restricts a search to the files it names: grep's
# --include, rg's -g/--glob/--iglob and -t/--type, ag's -G.
def include_opts: ["--include", "-g", "--glob", "--iglob", "-t", "--type", "-G", "--file-search-regex"];
# Whether a search's file filter (a glob, an rg type name, an ag regex) names
# only documentation — `*.md`, `*.{md,rst}`, `md`, `README*`. A negated rg glob
# (`!*.md`) excludes rather than selects, and is never documentation-only.
def doc_filter:
"(md|markdown|mdx|rst|txt|adoc|asciidoc|org)" as $ext
| ascii_downcase | gsub("[\\\\$]"; "")
| (startswith("!") | not)
and (test("\\." + $ext + "$") or test("\\.\\{(" + $ext + ",?)+\\}$") or test("^" + $ext + "$")
or test("(^|/)\\*?(readme|changelog|license|licence)"));
# The options that make a search print no matching lines — a count, a list of
# file names, or nothing (just an exit status) — per program. A search run that
# way read no content, whatever it matched. Short letters, then long names.
def nocontent: {
grep: [["c", "l", "L", "q"], ["--count", "--files-with-matches", "--files-without-match", "--quiet", "--silent"]],
rg: [["c", "l", "q"], ["--count", "--count-matches", "--files-with-matches", "--files-without-match", "--quiet", "--files"]],
ag: [["c", "l", "L"], ["--count", "--files-with-matches", "--files-without-matches"]]
} | .egrep = .grep | .fgrep = .grep;
# One option's value, folded into the operand scan: -e/-f give the pattern (or
# program), so the first operand is a file; an include filter is recorded.
def optval($o; $v):
(if ($o | IN("-e", "-f", "--regexp", "--file", "--expression")) then .given = true else . end)
| (if ($o as $x | include_opts | index($x)) then .incl += [$v] else . end);
# A cluster of short options — `-rn`, `-A3`, `-rnefoo`, `-tmd`. Each letter is
# its own option until one that takes a value, which takes the rest of the word
# (or, when nothing is left, the next word).
def short_cluster($t; $valued; $quiet):
($t[1:] | explode | map([.] | implode)) as $cs
| reduce range(0; $cs | length) as $i (. + {stop: false};
if .stop then .
else ("-" + $cs[$i]) as $o
| if ($valued | index($o)) != null then
($cs[$i + 1:] | join("")) as $rest
| (if $rest == "" then .skip = $o else optval($o; $rest) end)
| .stop = true
else (if ($cs[$i] | IN("r", "R")) then .recursive = true else . end)
| (if ($quiet[0] | index($cs[$i])) != null then .nocontent = true else . end)
end
end)
| del(.stop);
# argv → the operands left once options (and their values) are set aside;
# whether a pattern/program was given by an option (-e/-f), in which case the
# first operand is a file rather than the pattern; and any include filters.
def operands($valued; $quiet):
reduce .[1:][] as $t ({ops: [], skip: null, dd: false, given: false, recursive: false, incl: [], nocontent: false};
if .skip != null then optval(.skip; $t) | .skip = null
elif .dd then .ops += [$t]
elif $t == "--" then .dd = true
elif ($t | startswith("--")) then
($t | sub("=.*$"; "")) as $name
| if ($t | contains("=")) then optval($name; $t | sub("^[^=]*="; ""))
elif ($valued | index($name)) != null then .skip = $name
elif ($name | IN("--recursive", "--dereference-recursive")) then .recursive = true
elif ($quiet[1] | index($name)) != null then .nocontent = true
else . end
elif ($t | startswith("-")) and ($t | length) > 1 then short_cluster($t; $valued; $quiet)
else .ops += [$t] end);
# One invocation of a reader → {paths, search, doconly, nocontent}: the paths
# (as written) whose content it reads, whether it is a search (which reads only
# what it prints), whether its include filters name documentation alone, and
# whether it was told to print no matching lines (-c, -l, -q, …). No paths
# for a program that is not a reader. A search with no path searches where it
# runs, returned as ".".
def read_of:
.bin as $bin
| (readers[$bin]) as $valued
| if $valued == null then {paths: []}
else (.argv | operands($valued; nocontent[$bin] // [[], []])) as $o
| ($bin | IN("grep", "egrep", "fgrep", "rg", "ag")) as $search
| {search: $search, nocontent: $o.nocontent,
doconly: (($o.incl | length) > 0 and all($o.incl[]; doc_filter)),
paths: (
if ($bin | IN("sed", "awk", "gawk")) then
(if $o.given then $o.ops else $o.ops[1:] end)
| map(select(test("^[A-Za-z_][A-Za-z0-9_]*=") | not))
elif $search then
(if $o.given then $o.ops else $o.ops[1:] end) as $files
| if ($files | length) > 0 then $files
elif ($bin | IN("rg", "ag")) or ($bin != "rg" and $o.recursive) then ["."]
else [] end
elif ($bin | IN("less", "more", "view")) then
# `less +G f`: a +command is not a file.
$o.ops | map(select(startswith("+") | not))
else $o.ops
end
| map(select(. != "-")))}
end;
# Whether one reader invocation shows only a GLIMPSE of what it reads — less
# than about 50 lines: `head -n 3`, `head -c 1`, `head -5`, `tail -n 10`, a
# sed printing a short range or one line (`sed -n 1p`, `sed -n '1,20p'`), a
# search capped at a few matches (`grep -m1`). `head -n 80`, `tail -n +5`,
# `sed -n '1,200p'`, `grep -m 100` show enough to count as reads. Such a
# glimpse is still credited by the depth gate — how much a read showed cannot
# be told apart per file (see the README) — but it is reported, so the eval
# does not take it for research.
def glimpse_lines: 50;
def glimpse_bytes: 2000;
# The value of a counting option in argv: `-n 3`, `-n3`, `--lines=3`, `-3`.
def count_of($short; $long):
. as $a
| [ range(0; $a | length) as $i
| $a[$i] as $t
| if $t == $short then ($a[$i + 1] // "")
elif ($t | startswith($short)) and ($t | length) > ($short | length) then $t[($short | length):]
elif ($t | startswith($long + "=")) then $t[($long | length) + 1:]
else empty end ] | last;
def small($n; $limit): ($n // "") | test("^[0-9]+$") and tonumber < $limit;
def partial_read:
.bin as $b | (.argv[1:] // []) as $a
| if ($b | IN("head", "tail")) then
small($a | count_of("-n"; "--lines"); glimpse_lines)
or small($a | count_of("-c"; "--bytes"); glimpse_bytes)
or ($a | any(test("^-[0-9]+$") and (.[1:] | tonumber) < glimpse_lines))
elif $b == "sed" then
if ($a | any(test("^-[a-zA-Z]*n[a-zA-Z]*$|^--(quiet|silent)$")) | not) then false
else ($a | map(select(startswith("-") | not)) | .[0] // "") as $script
| ([$script | capture("^(?<from>[0-9]+)(,(?<to>[0-9]+))?p$")] | first) as $r
# A numeric range is measured; any other script that prints only
# what it is told to (`/re/p`) is taken as a glimpse.
| if $r == null then true
else ((($r.to // $r.from) | tonumber) - ($r.from | tonumber) + 1) < glimpse_lines end
end
elif ($b | IN("grep", "egrep", "fgrep", "rg", "ag")) then
small(($a | count_of("-m"; "--max-count")); glimpse_lines)
else false end;
# Whether a tool result shows nothing: a search that printed nothing read
# nothing. Claude Code says so in words — "(Bash completed with no output)",
# the Grep tool's "No files found" — and appends a note when a `cd` in the
# command was undone ("Shell cwd was reset to …"), which is not output either.
def blank:
split("\n") | map(select(test("^Shell cwd was reset to ") | not)) | join("\n")
| (test("\\S") | not) or test("^\\s*\\((Bash|Read) completed with no output\\)\\s*$");
def grep_tool_empty: blank or test("^\\s*No (files|matches) found");
# ---- tool results -----------------------------------------------------------
# tool_use id → {err, text} of its result. The LAST result for an id wins.
def results:
[ .[] | select(.type == "user") | (.message.content? // empty) | arrays | .[]
| select(.type == "tool_result")
| {key: .tool_use_id,
value: {err: (.is_error == true),
text: (if (.content | type) == "string" then .content
elif (.content | type) == "array" then ([.content[] | .text? // empty] | join("\n"))
else "" end)}} ]
| from_entries;
# ---- the trajectory ---------------------------------------------------------
. as $entries
| ($entries | results) as $res
# Every tool call, with the directory its record ran in. A record without its
# own cwd inherits the last one seen (the harness's shell cwd persists).
| (reduce $entries[] as $e ({base: (if $ws == "" then null else ($ws | canon) end), calls: []};
.base = (if ($e.cwd // "") != "" then ($e.cwd | canon) else .base end)
| . as $st
| if $e.type == "assistant" then
.calls += [ ($e.message.content? // []) | arrays | .[] | select(.type == "tool_use")
| {id, name, input, base: $st.base, events: ($e.events // [])} ]
else . end)
| .calls) as $calls
| [ $calls[]
| . as $c
| ($res[$c.id] // {err: false, text: ""}) as $r
| if $c.name == "Bash" then
[ first($c.events[] | select(.kind == "PreCommandInvoke" and .raw == $c.input.command)) | .invocations[] ]
| map(
invdir($c.base) as $dir
| if .bin == "git" then
(.argv | clone_of) as $cl
| if $cl == null then empty
else
(reduce $cl.cdirs[] as $d ($dir; . as $acc | $d | resolve($acc))) as $gdir
| ($cl.dest | resolve($gdir)) as $dest
| ($r.text | split("\n") | map(split("\r")[])) as $lines
# git reports a clone it refused (destination exists, repo
# not found) as `fatal:` quoting the destination as written
# or naming the repository. A pipeline (`git clone … |
# head`) exits 0 anyway, so the output is read too.
| ($lines | map(select(startswith("fatal:")))) as $fatal
# git drops a destination's trailing slashes when it quotes
# it (`dest/` → 'dest').
| if $r.err or ($fatal | any(. as $l | ($cl.repo != null and ($l | contains($cl.repo)))
or ($l | contains("'" + ($cl.dest | sub("(?<k>.)/+$"; "\(.k)")) + "'"))
or ($dest != null and ($l | contains("'" + $dest + "'")))))
then (if $dest == null then empty else {failed: $dest} end)
elif $dest == null or (($cl.explicit or ($cl.cdirs | length) > 0) and ($c.input.command | dynamic)) then {unresolved: 1}
else {clone: {dest: $dest, repo: ($cl.repo | repokey($gdir))}} end
end
elif $r.err then empty
else
read_of as $rd
| if ($rd.paths | length) == 0 then empty
elif $rd.nocontent then empty
elif $rd.search and ($rd.doconly or ($r.text | blank)) then empty
else (partial_read) as $part
| $rd.paths[] | resolve($dir) | select(. != null) | {read: ., partial: $part} end
end)
| .[]
elif $r.err then empty
elif $c.name == "Read" then
($c.input.file_path | resolve($c.base)) | select(. != null)
# A Read with a small limit is a glimpse — unless what it showed ran to
# the file's end (its last numbered line), which verify-depth checks
# against the file; with no limit, or a limit of 50 or more, it counts.
| {read: .,
partial: (($c.input.limit // null) as $l | $l != null and small($l | tostring; glimpse_lines)),
last: ($r.text | [splits("\n") | capture("^\\s*(?<n>[0-9]+)\\t")? | .n | tonumber] | last)}
elif $c.name == "Grep" then
# The Grep tool's default output_mode is files_with_matches: file
# names, no content. Only "content" shows what a file says.
if ($c.input.output_mode // "files_with_matches") != "content"
or ($r.text | grep_tool_empty)
or ([ $c.input.glob, $c.input.type ] | map(select(. != null and . != "")) as $f
| ($f | length) > 0 and all($f[]; doc_filter))
then empty
else (($c.input.path // ".") | resolve($c.base)) | select(. != null) | {read: .} end
else empty end ]
| {clones: [ .[] | .clone // empty ],
failedClones: [ .[] | .failed // empty ],
unresolvedClones: ([ .[] | .unresolved // empty ] | add // 0),
reads: [ .[] | .read // empty ],
fullReads: [ .[] | select(.read != null and (.partial | not)) | .read ],
partialEnds: [ .[] | select(.read != null and .partial and .last != null) | {path: .read, last} ]}

The write-time half: the same depth rule, checked before a research-notes write lands.

.sloprail/gate/findings-need-depth/gate.yaml
# Findings are written after the reading, not before. While a #research run is
# open, a write of the project's research notes — this example's convention:
# any Markdown file (.md, .markdown, .mdx, in any letter case) in the project,
# outside a top-level dot-directory, NOTES.md being where this project keeps
# them — is refused BEFORE it lands until the run has depth (the same rule
# depth-check applies at Stop, shared via its verify-depth.sh). Case-insensitive
# because on macOS's default filesystem NOTES.MD IS NOTES.md.
#
# PreFileWrite covers the Write and Edit tools and a shell command the engine
# sees writing the file (`cat > NOTES.md`, `echo >> NOTES.md`, `sed -i`).
# No `require: context`: `require` would refuse every Markdown write while no
# research is open. Whether research is open is the check's to decide — the
# context's flag, or a #research declaration already on the record (a tag
# written earlier in this very turn has not reached the context yet).
#
# Every project write is matched, not only *.md paths: a write through a second
# name for the notes — a symbolic or hard link, `ln -s NOTES.md n.txt && echo
# >> n.txt` — has a path that is not Markdown. The check resolves the name and
# lets anything that is not the notes through at once. `ln` of a Markdown file
# is held itself, so a link made and written on one line never gets a name.
on:
- event: PreFileWrite
match: >-
not (event.path startsWith "/")
and not (event.path startsWith ".")
- event: PreCommandInvoke
match: any(event.invocations, .bin == "ln")
checks:
- script: ./findings-after-depth.sh
.sloprail/gate/findings-need-depth/findings-after-depth.sh
#!/usr/bin/env bash
# Before a research-notes write: if a #research run is open, the write waits for
# depth. Depth itself is judged by depth-check's verify-depth.sh — one rule,
# shared, not a copy that could drift.
set -uo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
input="$(cat)"
kind="$(printf '%s' "$input" | jq -r '.event.kind // empty')"
path="$(printf '%s' "$input" | jq -r '.event.path // empty')"
transcript_path="$(printf '%s' "$input" | jq -r '.transcriptPath // empty')"
ws="${SR_WORKSPACE:-.}"
block() {
echo "$1" >&2
exit 1
}
# Research notes are Markdown in the project, outside a top-level
# dot-directory (the gate's match already keeps absolute and dot paths out).
is_notes() { printf '%s' "$1" | grep -Eiq '[.](md|markdown|mdx)$' && case "$1" in .*) false ;; esac; }
# Whether the write is to NOTES.md itself (or, below, a link to it): there any
# proposal title counts; elsewhere only "Proposed approach" (proposal.jq).
notes_scope=false
printf '%s' "$path" | grep -Eiq '(^|/)notes[.]md$' && notes_scope=true
# Which notes this action would write, if any — the name the refusal uses.
if [ "$kind" = "PreCommandInvoke" ]; then
# `ln [-s] NOTES.md n.txt` makes a second name for the notes, and a write
# through that name is not a write of a *.md path: the link itself is held
# while research is open, on the same line or before the write.
target="$(printf '%s' "$input" | jq -r '
[ .event.invocations[]? | select(.bin == "ln")
| [ .argv[1:][] | select(startswith("-") | not) ] as $ops
| ($ops | if length >= 2 then .[:-1] else . end)[] as $src
| {src: $src, link: (if ($ops | length) >= 2 then $ops[-1] else ($src | split("/") | last) end)}
| select($src | test("(?i)[.](md|markdown|mdx)$"))
| select(($src | startswith(".") | not) or ($src | startswith("./")))
] | first | if . == null then empty else "\(.link) (a link to \(.src))" end' 2>/dev/null)"
[ -n "$target" ] || exit 0
path="$target"
elif printf '%s' "$path" | grep -Eiq '[.](txt|text|rst|adoc|asciidoc|org)$'; then
# Plain-text notes (PROPOSAL.txt, NOTES.rst) are not this project's research
# notes, but a proposal is a proposal wherever it is written: such a write is
# held when it adds a "Proposed approach" section, declared research or not.
adds="$(printf '%s' "$input" | jq -r -L "$here" 'include "proposal";
if (.event.kind | IN("PreFileCreate", "PreFileUpdate")) and .event.resultKnown == true
then (.event | adds_proposal(false)) else false end' 2>/dev/null)"
[ "$adds" = "true" ] || exit 0
printf '%s' "$input" | DEPTH_FOR_WRITE="$path" DEPTH_PROPOSAL=1 bash "$here/../depth-check/verify-depth.sh"
exit $?
elif ! is_notes "$path"; then
# Not a Markdown path — but it may be a second name for one made earlier:
# a symbolic link resolving to the notes, or a hard link sharing their inode.
abs="$ws/$path"
[ -e "$abs" ] || exit 0
wsreal="$(realpath -q -- "$ws" 2>/dev/null || printf '%s' "$ws")"
real="$(realpath -q -- "$abs" 2>/dev/null || printf '%s' "$abs")"
rel="${real#"$wsreal"/}"
if [ "$rel" != "$real" ] && is_notes "$rel"; then
printf '%s' "$rel" | grep -Eiq '(^|/)notes[.]md$' && notes_scope=true
path="$path (a link to $rel)"
else
# GNU/BusyBox form first, then BSD's (see verify-depth.sh's ident: BusyBox
# rejects --version, and its -f is filesystem status).
li="$(stat -c %h:%i -- "$abs" 2>/dev/null)"
case "$li" in *[!0-9:]* | "" | :* | *:) li="$(stat -f %l:%i -- "$abs" 2>/dev/null)" ;; esac
case "$li" in *[!0-9:]* | "" | :* | *:) block "Whether $path is a second name for the research notes could not be read (stat), so writing it is held." ;; esac
links="${li%%:*}"; ino="${li#*:}"
[ "$links" -gt 1 ] 2>/dev/null || exit 0
twin="$(find "$ws" -path "$ws/.*" -prune -o -inum "$ino" -type f -print 2>/dev/null \
| while IFS= read -r f; do r="${f#"$ws"/}"; is_notes "$r" && { printf '%s' "$r"; break; }; done)"
[ -n "$twin" ] || exit 0
printf '%s' "$twin" | grep -Eiq '(^|/)notes[.]md$' && notes_scope=true
path="$path (a hard link to $twin)"
fi
fi
[ -n "$transcript_path" ] || block "Whether a #research run is open could not be read: the check got no transcript path, so writing $path is held."
# Is a #research run open?
# - the research-run context is active (declared in an earlier turn, or by a
# #research dispatch), or
# - the record already declares #research: a tag in the agent's own text, a
# sub-agent dispatch whose prompt carries it, or — in a sub-agent — the
# prompt it was dispatched with. A tag written earlier in THIS turn is on
# the record before this write's tool call, but the context only hears of
# it at Stop, which is too late for a write that must not land first.
open="$(printf '%s' "$input" | jq -r '.context["research-run"].active // false')"
if [ "$open" != "true" ]; then
if ! entries="$(sr-session trajectory normalize --path "$transcript_path" --events PostTagWrite 2>&1)"; then
block "Whether a #research run is open could not be read from $transcript_path, so writing $path is held: $entries"
fi
if ! declared="$(printf '%s' "$entries" | jq -r '
[ .[]
| ( (.events[]? | select(.kind == "PostTagWrite") | .tags[]? | select(.label == "research"))
, (select(.type == "assistant") | .message.content[]? | select(.type == "tool_use")
| select(.name == "Agent" or .name == "Task")
| select((.input.prompt // "") | contains("#research")))
, (select(.type == "user" and .isSidechain == true)
| .message.content | strings | select(contains("#research"))) )
] | length > 0' 2>&1)"; then
block "Whether a #research run is open could not be decided from $transcript_path, so writing $path is held: $declared"
fi
case "$declared" in
false)
# No research declared. The write is still held if it IS the proposal —
# it adds a proposal section (proposal.jq: any proposal title in NOTES.md
# or a link to it, only "Proposed approach" elsewhere): the convention is
# research before proposing, declared or not. A write whose result the
# engine cannot know ahead (resultKnown false) is let through here and
# judged at Stop by depth-check, which the proposal activates (see the
# research-run context) — holding every such write would hold unrelated
# Markdown edits too.
adds="$(printf '%s' "$input" | jq -r -L "$here" --argjson notes "$notes_scope" 'include "proposal";
if (.event.kind | IN("PreFileCreate", "PreFileUpdate")) and .event.resultKnown == true
then (.event | adds_proposal($notes)) else false end' 2>/dev/null)"
[ "$adds" = "true" ] || exit 0 # an ordinary write
export DEPTH_PROPOSAL=1
;;
true) ;;
*) block "Whether a #research run is open could not be decided from $transcript_path (got '$declared'), so writing $path is held." ;;
esac
fi
# Research is open: the write lands only once the run has depth.
printf '%s' "$input" | DEPTH_FOR_WRITE="$path" bash "$here/../depth-check/verify-depth.sh"