Here is the thing we want. Someone labels a ticket, and a little later there is a draft pull request for it. Nobody typed a command. Every decision on the way is written down where you can read it back, and if the machine is killed halfway, it picks the work up again and gets it right.
That loop between runs is the part nobody owns. We build agents and harnesses and then glue them together with a script someone wrote on a Friday. So this time there is no agent in it at all. No harness, no memory, no model, no tokens. The worker is gofmt. A loop that is going to earn your trust has to be boring first.
That is a factory: an event arrives, the factory works out which piece of work it belongs to, claims it, looks at the facts, decides, acts and records what it did. In this part we watch a ticket become a pull request, read its decision log, kill the factory mid-step, feed it events that lie, and ask what a different rule would have done.
The tool is ynf, an experiment built to prove the idea rather than a product. The ideas are the point: claims that expire, decisions you can replay, events treated as hints. They travel to any workflow engine you already run.
A ticket becomes a draft pull request
A lane is a rule for one kind of work: how it arrives, what runs, and what happens to the result. Ours finds any open issue labelled ynf:fmt, runs a fixed command in a container with no network, and turns the change into a pull request that may touch .go files and nothing else. If CI fails, a person is asked.
The only human act in the whole step is the label. Then the factory goes to look for work:
github.com/your-account/ltl-part3-factory#1 gofmt in_review #4 #4: CI green
ynf(gofmt): internal/format is not gofmt-cleandraft: true, branch: ynf/issue-1YNF-Item: github.com/your-account/ltl-part3-factory#1YNF-Run: 01M4DNKRGF46ZH13X89R01C37B-1What this makes possible. Work that starts from a ticket and ends at a human. The pull request is a draft, and the commit names the item, the step and the run that made it. Swap gofmt for any command that fixes something mechanically, a codemod or a licence header, and you have a lane. The worker only edits a throwaway worktree. Only the factory commits, pushes and opens the pull request.
Every decision is visible
None of that was hidden. Every item has a log, appended and never rewritten:
11:50:38Z decision ynf.ticket.matched -> ready: eligible for lane gofmt
11:50:40Z decision ynf.timer.due -> running: ready: starting run (attempt 1) [run]
11:50:41Z run 01M4DNKRGF46ZH13X89R01C37B-1 command via docker: converged, 1 changed (225ms)
11:50:42Z decision ynf.run.finished -> running: converged: proposing the change [open_pr]
11:50:46Z decision ynf.action.done -> proposed: proposed as #4
11:51:07Z decision ynf.timer.due -> in_review: #4: CI green
Every item moves through the same states, and only one move belongs to a person:
HUMAN merges
intake ──▶ ready ──▶ running ──▶ proposed ──▶ in_review ──▶ done
│ ▲ │ │ │ │
▼ ├─retry──┘ │ │ │
ignored │ ├▶ escalated │ │
│ └▶ quarantined│ │
│ │ │
└──────────────────────────┴─────────┘
ci_failed or changes_requested: back to ready with feedback
One human act in the whole machine, and it is the last one.
Read it against the loop, which is the same every time: event, correlate, claim, probe, decide, act, record, release. Each decision line is the event, the state it led to and the reason. Each action and run line is something the factory did.
What this makes possible. An audit trail you did not have to build. Why did this pull request exist? Which run made it? What did the factory believe at the time? The answers are lines in a log, not a reconstruction.
Kill it mid-step and it carries on
Now the rude test. Start a sweep on the slow lane, which is the same lane with a twenty second sleep, and send SIGKILL the moment the run starts. SIGKILL gives the process no chance to tidy up, which is the point.
The item is left running, and the dead process still holds the lease. The factory will not take a running item from whoever holds it, and retry refuses too. You do not need either. Start a fresh sweep, as a restarted machine would. The lease was set to thirty seconds. Once it runs out, the item’s timer fires:
exit 137 the sweep, killed
11:51:38Z decision ynf.timer.due -> running: ready: starting run (attempt 1) [run]
11:52:16Z decision ynf.timer.due -> running: the last attempt did not finish: starting run (attempt 2) [run]
11:52:37Z run 01M4DNPRQ53X4D2JARXYF1WY58-1 command via docker: converged, 1 changed (20.254s)
11:53:02Z decision ynf.timer.due -> in_review: #5: CI green
claims run starts SIGKILL
SWEEP A o───────o─────────────X
├─────── lease held, 30 s ────────┤
lease runs out │
SWEEP B claims it o attempt 2 ──▶ in_review
restart, not resume: attempt 2 starts from a clean copy of main
time ──────────────────────────────────────────────────────────▶
The factory never asks what the dead run was doing. It starts again.
The factory restarts, it does not resume. A half-written worktree cannot be trusted, so the second attempt begins from a clean copy of main. The lanes file also caps how many times one item is tried.
What this makes possible. A factory you can run on a machine that gets rebooted or preempted. Safety comes from three small rules: a claim that expires, a worker that only edits a disposable worktree, and every attempt starting clean. No checkpointing, no recovery code.
Events are hints, facts decide
A webhook says something may have changed. The factory does not decide from what the event says. It looks again. There is no public endpoint here, so we hand it a file, as a CI workflow would: an issues.labeled event claiming issue 3 carries ynf:fmt. It does not, yet.
Deliver it while untrue, then make it true and deliver the same file twice. Then do the same with an event claiming a pull request is merged, when it is not:
before the label no item made for issue 3
after the label first delivery does the whole step in 13s, the second finds nothing to start
claims merged, is not state=in_review reason=“#6: CI green”
after a human merges state=done reason=“#6 merged”
GITHUB WEBHOOK FACTORY (ynf) GITHUB API
│ │ │
│─"PR merged"─────────▶│ │
│ │─is it merged?────────▶│
│ │◀─not merged───────────│
│ │ still in_review │
│ │ │
│ a person merges │ │
│ │ │
│─"PR merged"─────────▶│ │
│ │─is it merged?────────▶│
│ │◀─merged───────────────│
│ │ done │
The same event twice. Only the answer from the API changes the outcome.
Every decision stores the event and the facts it was made from, and the event carried almost nothing: just the name of the GitHub event. The merge flag that mattered came from a fresh look at the pull request.
What this makes possible. You can wire the factory to every webhook you have, and an early, repeated or untrue event cannot trick it. Such an event changes when the factory looks, never what it concludes. Work also arrives by search, so a missed event costs time, not work.
What would we have done under a different rule?
Every decision is a pure function of the lane and the facts stored with it, so you can replay it. Replay makes no call to GitHub and runs nothing. As it was, item 3 comes back with 7 decisions, 0 differ.
Now change the rule. Say a person should look at every formatting change before it becomes a pull request. Copy the lanes file, change converged: open_pr to converged: escalate, and replay item 1 under it:
same ynf.ticket.matched recorded ready replayed ready
same ynf.timer.due recorded running replayed running
DIFF ynf.run.finished recorded running replayed escalated
same ynf.action.done recorded proposed replayed proposed
5 decisions, 1 differexit 32Under the strict lane the item would have been escalated to a person instead of proposed. Each decision is replayed from its own stored facts, so the later ones still agree.
What this makes possible. Policy you can test against history before it ships. A CI check on the lanes file can ask “does this change alter any recorded decision?” and block the merge until someone has read the answer. Tighten a rule, add an approval step, and see what it would have done to last month’s work.
The Debrief
TL;DR
The loop between agent runs is the part nobody owns. This part builds it with no model at all: the worker is gofmt. Someone labels a ticket, a draft pull request appears, and every decision on the way is written down.
Then come the rude tests. The factory is killed mid-step, and once its claim expires it starts the work again from a clean copy. It is fed events that lie, and decides only from what it sees when it looks again. Finally its past decisions are replayed under a stricter rule, to show what would have changed.
The ideas
- A lane is a rule for one kind of work: how it arrives, what runs and what happens to the result.
- Every decision is logged with the facts it was made from.
- Claims expire, and attempts restart clean instead of resuming.
- Webhooks are hints. The factory checks the facts again before it decides.
- Decisions can be replayed, so a policy change can be tested against history.
What to take away
- Build the loop before you put a model in it.
- Make “why did this happen?” a lookup in a log, not an investigation.
- Test a rule change against past decisions before it ships.
Tutorial
You will build the whole factory from a blank folder: a private GitHub repository holding a small Go service, two lanes, three issues, and ynf running a ticket through to a draft pull request. About ten minutes, most of it waiting for GitHub Actions. No model is involved, so it costs nothing.
1. Install the tools
brew install eyelock/tap/ynh eyelock/tap/ynm eyelock/tap/ynf
ynh --version # tested with 0.12.0
ynm --version # tested with 0.4.1
ynf --version # tested with 0.2.0
On Linux, the same versions are on each project’s GitHub releases page. You also need Docker running (the lane runs gofmt in a container, and the first run pulls the image), jq, git with an identity set, and gh signed in with the repo, workflow and delete_repo scopes (gh auth refresh -s delete_repo adds the last one, which is only used to clean up). The commands are written for bash: on macOS, run bash first. ynf finds its GitHub token by asking gh auth token, so there is nothing to export.
2. Start clean
Everything lives under one folder, and the tools get their own homes there, so nothing touches your own setup. Run this every time you start or restart the part:
export LTL="$HOME/ltl" # any folder you like; keep the path short
export YNF_HOME="$LTL/.home/ynf"
mkdir -p "$YNF_HOME"
export REPO="$(gh api user -q .login)/ltl-part3-factory"
export YNF_CONFIG="$YNF_HOME/part3/config.yaml"
# GitHub search can lag a new label by up to a minute: wait until it sees one.
wait_indexed() {
local n
for _ in $(seq 1 60); do
n=$(gh api -X GET search/issues -f q="repo:$REPO is:issue is:open label:\"$1\"" -q .total_count)
[ "$n" -ge 1 ] && return 0
sleep 3
done
echo "search never saw label $1" >&2; return 1
}
Remove anything a previous run of this part left behind:
gh repo delete "$REPO" --yes 2>/dev/null
rm -rf "$LTL/part3" "$YNF_HOME/part3"
Then fetch the target service, which is public, in the ynf repository. It is a small Go service whose internal/format package is not gofmt-clean. The CI workflow is left out on purpose; you add it in step 3.
mkdir -p "$LTL/part3" && cd "$LTL/part3"
curl -sL https://github.com/eyelock/ynf/archive/refs/tags/v0.2.0.tar.gz | tar xz
cp -R ynf-0.2.0/sandbox/seed work && rm -rf ynf-0.2.0 work/.agents work/.github
cd work && git init -q -b main && git add -A && git commit -qm "seed: the target as received"
Create the private repository, push the target, and give it the labels and three issues the lanes work from:
gh repo create "$REPO" --private --description "Loosen the Leash part 3, disposable" --source . --remote origin --push
for l in ynf:fmt ynf:slow ynf:working ynf:proposed ynf:in-review ynf:needs-human pkg:internal/format; do
gh label create "$l" -R "$REPO" --force >/dev/null
done
for t in "internal/format is not gofmt-clean" \
"internal/format is not gofmt-clean, slowly" \
"internal/format is not gofmt-clean, delivered twice"; do
gh issue create -R "$REPO" --title "$t" --label pkg:internal/format \
--body '`internal/format` is not `gofmt`-clean.'
done
You should see the repository URL and then three issue URLs ending /issues/1, /issues/2 and /issues/3. If gh repo create fails, the reset block did not run or gh is signed in to the wrong account.
Last, ynf’s configuration, with a short lease so step 5 does not make you wait:
mkdir -p "$YNF_HOME/part3"
sed "s|__REPO__|$REPO|g" > "$YNF_CONFIG" <<'EOF'
version: 1
repos: [__REPO__]
lease: { ttl: 30s, heartbeat: 10s }
poll: { ci: 15s, review: 1m }
memory: { provider: none }
EOF
cat "$YNF_CONFIG"
Coming back later. In a fresh shell, re-run the exports and the wait_indexed function above (not the reset block), then cd "$LTL/part3/work". The repository and its issues are still on GitHub. Steps 3 to 7 build on each other’s items, so come back between steps, not in the middle of one. Every command from step 3 runs from $LTL/part3/work.
3. A ticket becomes a draft pull request
A lane is a rule for one kind of work: how it arrives, what runs, and what happens to the result. Install two, and a small CI check for the pull requests they open. gofmt is the real lane; gofmt-slow is the same with a twenty second sleep, so that step 5 has time to kill things.
cd "$LTL/part3/work"
mkdir -p .agents/factory .github/workflows
cat > .github/workflows/ci.yml <<'EOF'
name: ci
on:
pull_request:
jobs:
gofmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: gofmt is clean
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
EOF
sed "s|__REPO__|$REPO|g" > .agents/factory/lanes.yaml <<'EOF'
version: 1
defaults:
executor: docker
attempts: 3
retention: 30d
lanes:
gofmt:
kind: originate
intake:
- github.search: 'repo:__REPO__ is:issue is:open label:"ynf:fmt"'
every: 5m
run:
runner: command
image: golang:1.26-alpine
command:
argv: ['gofmt', '-w', './{label.pkg}']
egress:
allow: []
when:
converged: open_pr
ci_failed: escalate
pr:
allowed_paths: ['**/*.go']
labels:
on_claim: { add: [ynf:working], remove: [ynf:fmt] }
on_propose: { add: [ynf:proposed], remove: [ynf:working] }
on_review: { add: [ynf:in-review], remove: [ynf:proposed] }
on_escalate: { add: [ynf:needs-human], remove: [ynf:working, ynf:proposed] }
gofmt-slow:
kind: originate
intake:
- github.search: 'repo:__REPO__ is:issue is:open label:"ynf:slow"'
every: 5m
run:
runner: command
image: golang:1.26-alpine
command:
argv: ['sh', '-c', 'sleep 20 && gofmt -w ./{label.pkg}']
egress:
allow: []
when:
converged: open_pr
ci_failed: escalate
pr:
allowed_paths: ['**/*.go']
labels:
on_claim: { add: [ynf:working], remove: [ynf:slow] }
on_propose: { add: [ynf:proposed], remove: [ynf:working] }
on_review: { add: [ynf:in-review], remove: [ynf:proposed] }
on_escalate: { add: [ynf:needs-human], remove: [ynf:working, ynf:proposed] }
EOF
git add -A && git commit -qm "add the gofmt lane and its CI check" && git push -q
ynf doctor
You should see a column of ok lines, among them lanes ... gofmt, gofmt-slow. (A line required checks ... main: with nothing after the colon is right: the repository requires no status checks on main.)
Now the only human act in the whole step. Label the issue, wait for GitHub’s search to see it, and let the factory look:
gh issue edit 1 -R "$REPO" --add-label ynf:fmt
wait_indexed ynf:fmt
ynf sweep --until-settled --timeout 10m --lane gofmt
A sweep is the factory going to look for work. You should see it settle, about half a minute later, with log lines and then:
ITEM LANE STATE PR REASON
github.com/<you>/ltl-part3-factory#1 gofmt in_review #4 #4: CI green
A no matching items line in the middle of the log is not an error: once the factory has claimed the issue and swapped ynf:fmt for ynf:working, the search has nothing left to find. An empty table means the search index was behind; run the sweep again. Now look at what it opened:
gh pr view 4 -R "$REPO" --json title,isDraft,headRefName -q '"\(.title)\ndraft: \(.isDraft), branch: \(.headRefName)"'
gh pr view 4 -R "$REPO" --json commits -q '.commits[0].messageBody'
ynf(gofmt): internal/format is not gofmt-clean
draft: true, branch: ynf/issue-1
Proposed by ynf for github.com/<you>/ltl-part3-factory#1, lane gofmt.
YNF-Item: github.com/<you>/ltl-part3-factory#1
YNF-Step: 01M4DNKRGF46ZH13X89R01C37B
YNF-Run: 01M4DNKRGF46ZH13X89R01C37B-1
The pull request is a draft, and the commit names the item, the step and the run that made it. Do not merge #4: all three issues format the same file, and once main is clean the next run has nothing to change.
4. You can watch every decision
Every item has a log, appended and never rewritten:
ynf items log "$REPO#1"
2026-10-08T11:50:38Z decision ynf.ticket.matched -> ready: eligible for lane gofmt
2026-10-08T11:50:39Z action label ok=true
2026-10-08T11:50:40Z decision ynf.timer.due -> running: ready: starting run (attempt 1) [run]
2026-10-08T11:50:41Z run 01M4DNKRGF46ZH13X89R01C37B-1 command via docker: converged, 1 changed (225ms)
2026-10-08T11:50:42Z decision ynf.run.finished -> running: converged: proposing the change [open_pr]
2026-10-08T11:50:45Z action open_pr ok=true
2026-10-08T11:50:46Z decision ynf.action.done -> proposed: proposed as #4
2026-10-08T11:50:48Z action label ok=true
2026-10-08T11:51:07Z decision ynf.timer.due -> in_review: #4: CI green
2026-10-08T11:51:09Z action label ok=true
Read it against the loop: the ynf.ticket.matched event, the item correlated to lane gofmt, a lease claimed, the facts probed, each decision line with its reason and, in brackets, what it asked for, each action and run line, and the log itself as the record. If the log is empty, check that $REPO is set.
5. Kill it mid-step and it carries on
Label issue 2 for the slow lane, start a sweep in the background, and kill it with SIGKILL the moment the run starts. SIGKILL gives the process no chance to tidy up, which is the point.
cd "$LTL/part3/work"
gh issue edit 2 -R "$REPO" --add-label ynf:slow
wait_indexed ynf:slow
ynf sweep --until-settled --timeout 10m --lane gofmt-slow > ../sweep1.log 2>&1 &
SWEEP=$!
until grep -q "run started" ../sweep1.log; do sleep 1; done
date -u +%T; kill -9 $SWEEP; wait $SWEEP 2>/dev/null; echo "sweep exit $?"
11:51:39
sweep exit 137
The item was left mid-step, still running, and the dead process still holds the lease:
ynf items ls
ynf items show "$REPO#2" | jq '{state, reason, lease}'
ynf items retry "$REPO#2"; echo "exit $?"
ITEM LANE STATE PR REASON
github.com/<you>/ltl-part3-factory#1 gofmt in_review #4 #4: CI green
github.com/<you>/ltl-part3-factory#2 gofmt-slow running ready: starting run (attempt 1)
{ "state": "running", "reason": "ready: starting run (attempt 1)", "lease": { "owner": "ynf@<host>/69565", "epoch": 1, ... } }
ynf: item/github.com/<you>/ltl-part3-factory/issues/2 is running; retry is only for escalated or quarantined items
exit 2
The factory will not take a running item from the process that holds it. You do not need to. Start a fresh one, as a restarted machine would:
ynf sweep --until-settled --timeout 10m --lane gofmt-slow
ynf items log "$REPO#2"
The lease runs out thirty seconds after it was taken, a few seconds later the item’s timer fires, and the work starts again:
2026-10-08T11:51:38Z decision ynf.timer.due -> running: ready: starting run (attempt 1) [run]
2026-10-08T11:52:16Z decision ynf.timer.due -> running: the last attempt did not finish: starting run (attempt 2) [run]
2026-10-08T11:52:37Z run 01M4DNPRQ53X4D2JARXYF1WY58-1 command via docker: converged, 1 changed (20.254s)
2026-10-08T11:52:38Z decision ynf.run.finished -> running: converged: proposing the change [open_pr]
...
2026-10-08T11:53:02Z decision ynf.timer.due -> in_review: #5: CI green
“The last attempt did not finish: starting run (attempt 2)” is the point: a new run id and a fresh worktree of main. The factory restarts, it does not resume. If the sweep finishes before you can kill it, you used the gofmt lane by mistake; use gofmt-slow.
6. Events are hints, facts decide
A webhook says something may have changed. The factory does not decide from what the event says; it looks again. There is no public endpoint here, so hand ynf handle a file, as a CI workflow would. This is an issues.labeled event for issue 3, delivered while it is untrue: issue 3 does not have ynf:fmt yet.
cd "$LTL/part3/work"
OWNER=${REPO%%/*}
cat > ../labeled.json <<JSON
{"action":"labeled","label":{"name":"ynf:fmt"},
"issue":{"number":3,"state":"open","labels":[{"name":"pkg:internal/format"},{"name":"ynf:fmt"}]},
"repository":{"full_name":"$REPO","name":"ltl-part3-factory","owner":{"login":"$OWNER"}},
"sender":{"login":"$OWNER"}}
JSON
ynf handle --github-event ../labeled.json --github-event-name issues --lane gofmt
ynf items ls
ITEM LANE STATE PR REASON
github.com/<you>/ltl-part3-factory#1 gofmt in_review #4 #4: CI green
github.com/<you>/ltl-part3-factory#2 gofmt-slow in_review #5 #5: CI green
The factory saw the event, found no facts to agree with it, and made no item for issue 3. Now make it true, and deliver the same file twice:
gh issue edit 3 -R "$REPO" --add-label ynf:fmt
wait_indexed ynf:fmt
ynf handle --github-event ../labeled.json --github-event-name issues --lane gofmt
ynf handle --github-event ../labeled.json --github-event-name issues --lane gofmt
The first delivery does the whole step in about a dozen seconds. The second finds nothing to start and prints one decision, which depends on whether CI has finished (it takes about twenty seconds):
time=... level=INFO msg=decided item=item/github.com/<you>/ltl-part3-factory/issues/3 event=ynf.forge.changed state=proposed reason="#6: CI pending"
state=in_review reason="#6: CI green" is equally right. There is no second run and no second pull request: gh pr list -R "$REPO" --state all shows #4, #5 and #6, one per item. The same goes for a merge. Deliver an event that claims PR 6 is merged, when it is not:
cat > ../merged.json <<JSON
{"action":"closed",
"pull_request":{"number":6,"state":"closed","merged":true,"draft":false,"head":{"ref":"ynf/issue-3"},"base":{"ref":"main"}},
"repository":{"full_name":"$REPO","name":"ltl-part3-factory","owner":{"login":"$OWNER"}},
"sender":{"login":"$OWNER"}}
JSON
ynf handle --github-event ../merged.json --github-event-name pull_request
time=... level=INFO msg=decided item=... event=ynf.forge.changed state=in_review reason="#6: CI green"
The payload said merged. The factory looked, found an open pull request, and did not move to done (if CI is still running, the state stays proposed). Now a human merges, and the same file is delivered again:
gh pr ready 6 -R "$REPO"
gh pr merge 6 -R "$REPO" --squash
ynf handle --github-event ../merged.json --github-event-name pull_request
time=... level=INFO msg=decided item=... event=ynf.forge.changed state=done reason="#6 merged"
Same file, now true, so now done. Every decision stores the event and the facts it was made from, and the event carried almost nothing:
ynf --format json items log "$REPO#3" | jq -c '.[] | select(.kind=="decision")
| {event: .body.input.event.type, carried: .body.input.event.data,
pr_merged: .body.input.facts.pr.merged, state: .body.decision.item.state}' | tail -n 3
{"event":"ynf.forge.changed","carried":{"github_event":"issues"},"pr_merged":false,"state":"proposed"}
{"event":"ynf.forge.changed","carried":{"github_event":"pull_request"},"pr_merged":false,"state":"in_review"}
{"event":"ynf.forge.changed","carried":{"github_event":"pull_request"},"pr_merged":true,"state":"done"}
7. What would we have done under a different rule?
Every decision is a pure function of the lane and the facts stored with it, so you can replay it. First as it was:
ynf replay "$REPO#3"
same ynf.ticket.matched recorded ready replayed ready eligible for lane gofmt
...
same ynf.forge.changed recorded done replayed done #6 merged
7 decisions, 0 differ
Replay makes no call to GitHub and runs nothing. Now change the rule: a person should look at every formatting change before it becomes a pull request. Copy the lanes file, change converged: open_pr to converged: escalate, and replay item 1 under it:
sed 's/converged: open_pr/converged: escalate/' .agents/factory/lanes.yaml > ../strict.yaml
ynf replay "$REPO#1" --policy ../strict.yaml; echo "exit $?"
same ynf.ticket.matched recorded ready replayed ready eligible for lane gofmt
same ynf.timer.due recorded running replayed running ready: starting run (attempt 1)
DIFF ynf.run.finished recorded running replayed escalated converged
same ynf.action.done recorded proposed replayed proposed proposed as #4
same ynf.timer.due recorded in_review replayed in_review #4: CI green
5 decisions, 1 differ
ynf: 1 decisions differ
exit 32
Under the strict lane the item would have been escalated instead of proposed. Exit 32 means a decision differs, so a CI check on the lanes file can block a policy change until someone has read the answer. Nothing changed on GitHub.
Clean up
gh repo delete "$REPO" --yes
rm -rf "$LTL/part3" "$YNF_HOME/part3"
That deletes the private repository and the local folder, and needs the delete_repo scope. Nothing in your own ~/.ynf was touched.
Next: the harness goes inside the loop, in Part 4.