Type something to search...

Together, then taken apart

Part 6: one ticket through the harness, the memory and the factory to a merged pull request, then each layer removed in turn to see what the other two still do.

Warning

Viewing a draft version — this page is not published.
Together, then taken apart

Here is the thing we want. A ticket gets a label and, some minutes later, a draft pull request is waiting for you. Three separate pieces made it: one decided what to run and when, one set the rules and the definition of done, and one made sure the agent started with what the team already knows.

I have spent five parts building those pieces one at a time. This is the part where I find out whether they were really separate, or whether I had been fooling myself with tidy diagrams.

In this part all three run together on a single ticket, and then I take them apart. You watch a pinned harness turn up with a memory of its own, an agent follow a convention nobody put in its prompt, a failure leave an explanation behind, and the factory carry on first without its memory and then without its harness.

One ticket, three layers

The ticket is the store ticket from Part 1, sent to a lane that names a harness and a fallback command and no runner. The harness is pinned by tag, so moving it on is a commit like any other. Here is the whole trip:

 PERSON     FACTORY     HARNESS       AGENT      MEMORY    GITHUB/CI
             (ynf)       (ynh)                    (ynm)
    │          │           │            │           │          │
    ├─ label ──▶           │            │           │          │
    │          ├─ runs it ─▶            │           │          │
    │          │           ├── session-start hook ──▶          │
    │          │           │            ◀─ context ─┤          │
    │          │           ◀ edit,check ┤           │          │
    │          ◀ converged ┤            │           │          │
    │          ├─────── commits, pushes, opens draft PR ───────▶
    │          ◀───────────┼────── CI green ────────┼──────────┤
    ├──────────┼───────────┼── merges ──┼───────────┼──────────▶

Only the last arrow is a person, and it carries a merge.

Then what it left behind:

ynf start, then the receipts
outcome=converged exit=0 turns=2 tokens=7632ltl-part6-factory#1: proposed in lane lint (proposed as #5)

turn 1  test-store  fail

turn 2  test-store  pass

YNF-Item: ltl-part6-factory#1YNH-Session: 6b23c9364d2788dedraft, 2 files in internal/store, 3 green checksMERGED by a human
one model run: two turns, 7,632 tokens. the contract failed on turn 1 and passed on turn 2, as it did in Part 1.

The commit names the factory’s item and run, and the harness session. The run record holds the harness pin and the commit it resolved to, which matched the one my tag points at. Then the one thing no layer does: I read the diff, marked it ready and merged it. The agent also changed Remove to return its error and wrote tests nobody asked for. The sensors proved my contract holds, not that the change is wise.

What this makes possible. A label becomes a reviewed change with a receipt at every layer. Months later you can say which harness, which run and which tag produced a given line.

The harness remembers for itself

The memory that matters most to an agent is the one it can reach from inside its own session. So the harness declares it. ynm client install ynh wrote 34 lines into the harness manifest: an MCP server, a skill and three hooks. I read the diff, bumped the version to 1.1.0, tagged it and moved the lane’s pin.

Then I told the repository one thing a person would otherwise paste into every prompt, and checked where that sentence exists:

the rule that lives in one place
ynm context(semantic) In this codebase an error is returned or wrapped with %w, never discarded with _ =.

the agent’s plan, in our run: The project rule is that errors are returned or wrapped with %w.

git grep ‘%w’  no file in the repository mentions it

mentions of ynm or memory in task.md  0

the task file the factory wrote has no mention of memory. the harness handed over what the team knows.

To be plain about it: the agent has a way to write memory too, through the MCP server, and the hooks steer it to use that. In our run it wrote nothing. ynm list showed only the entry I made. Why, I do not know. Perhaps the task had nothing worth remembering, or perhaps tool approval got in the way. This part shows the read path and claims nothing more.

What this makes possible. An agent that starts every session knowing what your team has decided, without anyone editing a prompt, and without the factory having any say in what it reads. The memory is advice. The sensors still judged the work.

A failure becomes an explanation

Ticket #2 goes to a lane whose command exits 3 and says “the build step failed”. It stands in for a build that fails the same way every time. The lane retries twice, then escalates. Afterwards I asked the memory what it was told and the factory what it counted:

the same failure, told twice

escalated  outcome.error after 2 retries

memory: three records, subject sig/outcome/error, occurrence 1, 2, 3Failure sig/outcome/error in lane breaks: the run ended error, exit 3; it reported: “exit 3: the build step failed”.counters: retry/outcome.error 3, sig/outcome/error 3
no model chose that signature. the counters made the decision; the memory carries the words.

The records sit in a factory/ namespace, apart from the convention I wrote. Each says what happened, what the command itself said and what the factory did about it.

What this makes possible. In the morning you ask for everything that failed with exit 3 and get the command’s own words back, not a red badge. A person gets the explanation. The line gets the number.

The refusals, side by side

You have now seen all three work. Here is what each one declined to do, each from something already run:

four refusals, four captures

worker environment  YNM_HOME, YNM_USER, no token

commit author  ynf

ynf replay, item #2  5 decisions, 0 differ

memory mentions in task.md  0

lane asking for a merge action  value must be one of ‘open_pr’, ‘push_commit’, ‘comment’, ‘request_review’, ‘escalate’, ‘quarantine’, ‘close’

each answer is a fact about the run, not a promise in a document

The harness does not push: the commit is the factory’s. Memory does not decide: replay re-makes all five decisions from the log and the lanes file, and memory is not among its inputs. The factory does not guide: zero memory in the task, while the agent was reading a memory. And a lane that asks for merge is not a valid lane.

What this makes possible. You can reason about each layer on its own. The one that decides cannot be steered by the one that remembers, the one that remembers cannot push, and the one that guides cannot merge.

Taken apart

Memory first. I built a PATH with the tutorial’s ynf and ynh and no ynm, then ran ticket #3 through the same failing lane:

no ynm on the PATH

memory  off: ynm is not on PATH

escalated  outcome.error after 2 retries

memory lines in the log: 0counters: retry/outcome.error 3, sig/outcome/error 3ynf replay: 5 decisions, 0 differ
doctor says why memory is off. the line did not stop.

Same counters, same escalation as ticket #2. The factory counted six occurrences across two tickets, and ynm holds the three from the first one. Ticket #3 was never told.

Then the harness. With ynh gone, the lint lane falls back to its command. I ran the small lint ticket through it:

no ynh on the PATH
lint: originate lane, command (ynh not found) on processltl-part6-factory#4: proposed in lane lint (proposed as #6)-  if strings.Index(l, needle) != -1 {+  if strings.Contains(l, needle) {

runner command  draft, 1 file, 3 green checks

a smaller worker, and the diff shows how much smaller

golangci-lint run --fix fixed the one finding the linter can fix by itself and left the other, which needs judgement. That gap is what the harness and its agent are for. The line did not need either of them to keep moving.

┌───────────────┬───────────┬──────────────┬───────────────────────┐
│               │ decides?  │ keeps going? │ what is lost          │
├───────────────┼───────────┼──────────────┼───────────────────────┤
│ full stack    │ yes       │ yes          │ nothing               │
│ minus memory  │ yes       │ yes          │ the explanation       │
│ minus harness │ yes       │ yes, a PR    │ the agent's judgement │
│ minus factory │ a person  │ by hand      │ the loop between runs │
└───────────────┴───────────┴──────────────┴───────────────────────┘

Take away any one layer and the others keep working. Only the factory’s loss needs a person to step in.

What this makes possible. A factory that is useful on its first day with only commands in its lanes, that gets an agent later by adding a block to a lane, and that can have a memory chosen after the fact. Memory down, or harness gone, and the decisions do not change.

What is left is the shape

Take away the memory and the factory still decides. Take away the harness and it still produces a draft pull request. Take away the factory and the harness still guides, as Part 1 showed. What survives each removal is not a tool. It is five lines, and you have now watched them hold:

  1. Version the harness in git, and make its checks discoverable by a machine, not just by a reader.
  2. Keep memory append-only, outside the working tree, with a binary line between what may leave your machine and what may not.
  3. Put the loop between runs in something that can die between any two steps and come back, re-probes the facts before it decides, and never lets the model touch control flow.
  4. Give the agent a sandbox and no credentials. Let the operator push.
  5. Let a human merge.
  THE LINE                     WHAT YOU JUST WATCHED
──────────────────────────────────────────────────────────────────
  1 harness in git        ──▶  the v1.1.0 tag, with its sensors
                               listed for any tool to read
  2 append-only memory    ──▶  git notes and a personal store
                               on my machine
  3 a loop that resumes   ──▶  ynf replay: five decisions
                               re-made from a log
  4 no credentials        ──▶  no token in the worker, the
                               operator pushed
                               only in part: no container yet
  5 a human merges        ──▶  a merge typed by a person

Four of the five held in full. Line 4 held only in part, and that is the next notch.

The fourth holds only in part: the worker had no credentials and the operator pushed, but it ran on my host with a permission mode as its only wall. That is the notch still open.

The Debrief

Every removal in this part was cheap because nothing was welded to anything. The surprise was how much of that came from boring choices: a tag, a log, a namespace, a verb list that has no merge in it.

What you could build on this. None of it is built, and the pieces exist for all of it.

  • An experiment on memory itself. Run the same closed tickets in shadow mode against v1.0.0 and v1.1.0, grade both blind, and compare. The only difference is the memory, so you get a number for whether remembering helps your agent.
  • A loop that closes through people. Let ynm dream reflect on a month of factory/ failures, have a person turn the reflection into a pull request against the harness, and let the pin move by another reviewed commit.
  • Memory that travels. Promote a reviewer’s comment into the repository’s shared memory, so a convention stated once reaches every agent at the start of its next session.
  • The unattended notch. The same lanes in a container with no host to reach, and a person only at the merge. Grading and containment are the two I would want in place first.

TL;DR

This part runs all three layers on one ticket, then takes them apart. A labelled ticket becomes a reviewed, merged change with a receipt at every layer: the harness pin, the factory’s run, the commit trailers, CI’s checks and a named person on the merge.

The harness ships its own memory wiring as a tagged release, so the agent starts with what the team already knows and the factory has no say in it. A failing lane leaves explanations in memory and counts in the factory. Take memory away and the decisions do not change. Take the harness away and the lane falls back to a plain command. Four of the series’ five lines held in full. Containment is the notch still open.

The ideas

  • The harness declares its agent’s memory, as a reviewed diff and a new tag.
  • Each layer’s refusal is shown from a real run: the harness does not push, memory does not decide, the factory does not guide, and nothing merges.
  • Removing a layer is a test. If the decisions change, you had a hidden dependency.
  • What survives every removal is not a tool but a shape: five lines.

What to take away

  • Ship memory wiring as part of the harness, so rolling it back is moving a pin.
  • Make removal a test for dependencies you did not know you had.
  • Ask any vendor for a receipt at every layer, and be honest about your own open notch.

Tutorial

You will build all three layers from nothing and run one ticket through them: a pinned harness that carries its own memory, a repository with a lanes file and four tickets, one agent run that ends in a pull request you merge yourself, and then the same factory with its memory and its harness taken away in turn. About fifteen minutes, mostly waiting for GitHub, and one model run of about forty-four cents at list price. Everything else uses no model.

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: ynh, ynm and ynf.

You also need Go, golangci-lint v2, jq, git with your name and email set, Claude Code signed in, and gh signed in with the repo, workflow and delete_repo scopes, because this part makes two private repositories under your account and deletes them at the end. If gh lacks a scope, gh auth refresh -s workflow -s delete_repo adds it. No Docker. The commands are for bash: on macOS, run bash first.

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 YNH_HOME="$LTL/.home/ynh" YNM_HOME="$LTL/.home/ynm-part6" YNF_HOME="$LTL/.home/ynf"
export YNM_USER=ltl
export YNF_CONFIG="$LTL/.home/ynf-part6/config.yaml"
export GOLANGCI_LINT_CACHE="$LTL/.home/golangci-lint"
mkdir -p "$YNH_HOME" "$YNM_HOME" "$YNF_HOME" "$GOLANGCI_LINT_CACHE" "$LTL/.home/ynf-part6"
unset GITHUB_TOKEN GH_TOKEN
export OWNER=$(gh api user -q .login)
export REPO="$OWNER/ltl-part6-factory" HREPO="$OWNER/ltl-part6-harness"

YNM_HOME gives this part a memory of its own. The unset line matters: ynf finds your GitHub token by asking gh auth token, and the agent runs on your machine here, so nothing it runs should inherit a token.

Now remove whatever a previous run of this part left behind, then fetch the target repository fresh. It is public, in the ynf repository:

ynh uninstall "github.com/$HREPO/leash" 2>/dev/null
gh repo delete "$REPO" --yes 2>/dev/null
gh repo delete "$HREPO" --yes 2>/dev/null
rm -rf "$LTL/part6" "$LTL/.home/ynf-part6" "$LTL/.home/ynm-part6"
mkdir -p "$LTL/.home/ynf-part6" "$YNM_HOME" "$LTL/part6" && cd "$LTL/part6"
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 seed && rm -rf ynf-0.2.0 seed/.agents
ls seed

You should see the target’s files:

cmd
go.mod
internal
README.md
scripts

Coming back later: re-run the export block above, then cd "$LTL/part6/work". The two repositories are still on GitHub; do not run the reset block again unless you want to start over.

3. Build the harness from Parts 1 and 4

This is Part 1’s finished harness with Part 4’s manifest on top (the tidy focus and the budget), in a git repository of its own and tagged v1.0.0. It carries a hidden contract the agent cannot see: a test that Save writes files private to their owner, replaces them wholly, and fails on a missing folder.

▸ Show the harness files, push and install (156 lines, open and copy the whole block)
cd "$LTL/part6"
gh repo create "$HREPO" --private --description "Loosen the Leash part 6 harness, disposable" --clone
mv ltl-part6-harness work-harness
cd work-harness
mkdir -p .agents/harness contract tools

cat > AGENTS.md <<'EOF'
# leash

Project-level instructions that apply to every session with this harness.
EOF

cat > golangci-store.yml <<'EOF'
version: "2"
run:
  relative-path-mode: wd
linters:
  default: standard
  settings:
    errcheck:
      check-blank: true
EOF

cat > contract/store_contract_test.go.txt <<'EOF'
package store_test

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/eyelock/ynf-sandbox/internal/store"
)

// Saved files hold private data: owner read and write only.
func TestSaveIsPrivate(t *testing.T) {
	p := filepath.Join(t.TempDir(), "blob")
	if err := store.Save(p, []byte("x")); err != nil {
		t.Fatal(err)
	}
	fi, err := os.Stat(p)
	if err != nil {
		t.Fatal(err)
	}
	if fi.Mode().Perm() != 0o600 {
		t.Fatalf("mode %v, want 0600", fi.Mode().Perm())
	}
}

// Saving over an existing, longer file leaves exactly the new data.
func TestSaveReplacesWholly(t *testing.T) {
	p := filepath.Join(t.TempDir(), "blob")
	if err := store.Save(p, []byte("a long first version")); err != nil {
		t.Fatal(err)
	}
	if err := store.Save(p, []byte("short")); err != nil {
		t.Fatal(err)
	}
	b, err := os.ReadFile(p)
	if err != nil || string(b) != "short" {
		t.Fatalf("got %q, %v", b, err)
	}
}

// Saving into a directory that does not exist is an error, not a silent success.
func TestSaveMissingDirFails(t *testing.T) {
	p := filepath.Join(t.TempDir(), "absent", "blob")
	if err := store.Save(p, []byte("x")); err == nil {
		t.Fatal("expected an error")
	}
}
EOF

cat > tools/test-store.sh <<'EOF'
#!/bin/sh
set -e
overlay=$(mktemp)
trap 'rm -f "$overlay"' EXIT
printf '{"Replace":{"%s/internal/store/zz_contract_test.go":"%s/contract/store_contract_test.go.txt"}}' \
  "$PWD" "$YNH_HARNESS_DIR" > "$overlay"
go test -overlay "$overlay" ./internal/store/
EOF
chmod +x tools/test-store.sh

cat > .agents/harness/plugin.json <<'EOF'
{
  "$schema": "https://eyelock.github.io/ynh/schema/plugin.schema.json",
  "name": "leash",
  "version": "1.0.0",
  "default_vendor": "claude",
  "agent": {
    "max_turns": 12,
    "max_tokens": 600000,
    "max_wall": "10m"
  },
  "focuses": {
    "tidy": {
      "prompt": "Fix the golangci-lint findings in the package the task names, and change nothing outside that package. Stop when the change is ready: do not commit, push or open pull requests, because whatever ran you does that."
    }
  },
  "sensors": {
    "gofmt": {
      "category": "maintainability",
      "tolerance": "blocking",
      "version_command": "go version",
      "source": {
        "command": "test -z \"$(gofmt -l .)\" || { gofmt -l . ; exit 1; }"
      },
      "output": {
        "format": "text"
      }
    },
    "lint": {
      "category": "maintainability",
      "tolerance": "blocking",
      "version_command": "golangci-lint version",
      "source": {
        "command": "GOLANGCI_LINT_CACHE=\"$PWD/.cache/golangci-lint\" golangci-lint run ./..."
      },
      "output": {
        "format": "text",
        "match": "^[^ ]+\\.go:[0-9]+:[0-9]+:"
      }
    },
    "lint-store": {
      "category": "maintainability",
      "tolerance": "blocking",
      "version_command": "golangci-lint version",
      "source": {
        "command": "GOLANGCI_LINT_CACHE=\"$PWD/.cache/golangci-lint\" golangci-lint run -c \"$YNH_HARNESS_DIR/golangci-store.yml\" ./internal/store/..."
      },
      "output": {
        "format": "text",
        "match": "^[^ ]+\\.go:[0-9]+:[0-9]+:"
      }
    },
    "test-store": {
      "category": "behaviour",
      "tolerance": "blocking",
      "version_command": "go version",
      "source": {
        "command": "\"$YNH_HARNESS_DIR/tools/test-store.sh\""
      },
      "output": {
        "format": "text",
        "match": "^(--- FAIL|[^ ]+\\.go:[0-9]+)"
      }
    }
  }
}
EOF

git add -A && git commit -qm "leash harness v1.0.0"
git push -q -u origin HEAD && git tag v1.0.0 && git push -q origin v1.0.0
ynh install "github.com/$HREPO" --ref v1.0.0

You should see the new repository’s URL and ynh installing the harness at the tag:

https://github.com/<you>/ltl-part6-harness
Installed harness "leash"
  Location: .../.home/ynh/harnesses/github.com--<you>--ltl-part6-harness--leash
  Launcher: .../.home/ynh/bin/leash
  Vendor:   claude

If gh repo create fails, check gh auth status for the repo scope.

4. Build the factory

The factory is a second repository: the target, its own CI, a lanes file and four tickets. The lane that matters names a harness and a fallback command, and no runner. With ynh on the machine ynf runs the harness; without it, ynf runs the command. Step 11 uses that.

▸ Show the factory repository and lanes (78 lines, open and copy the whole block)
cd "$LTL/part6"
gh repo create "$REPO" --private --description "Loosen the Leash part 6 target, disposable" --clone
mv ltl-part6-factory work
cp -R seed/. work/
cd work
sed -i.bak "s/@eyelock/@$OWNER/" .github/CODEOWNERS && rm .github/CODEOWNERS.bak
gofmt -w internal/format/format.go
mkdir -p .agents/factory
printf ".ltl/\n" >> .git/info/exclude

cat > .agents/factory/lanes.yaml <<'EOF'
# yaml-language-server: $schema=https://eyelock.github.io/ynf/schema/lanes.schema.json
#
# Part 6 of Loosen the Leash: all three layers together, then taken apart.
# Runs are on the host (executor: process), started by a person with `ynf --interactive start`.
version: 1

defaults:
  executor: process
  attempts: 2
  retention: 30d

lanes:
  # The working lane. It names no runner. With ynh on the host it runs the pinned harness; without
  # ynh it falls back to the command, which fixes what the linter can fix by itself.
  lint:
    kind: originate
    intake:
      - github.search: 'repo:__REPO__ is:issue is:open label:"ynf:lint"'
        every: 5m
    run:
      ynh:
        harness: __HARNESS__
        focus: tidy
        budgets: { max_turns: 6 }
        sensor_scope:
          lint: 'GOLANGCI_LINT_CACHE="$PWD/.cache/golangci-lint" golangci-lint run ./{label.pkg}/...'
      command:
        argv: ['sh', '-c', 'golangci-lint run --fix ./{label.pkg}/... || true']
    when:
      converged: open_pr
      ci_failed: escalate
      outcome.budget: escalate
      outcome.stuck: escalate
      outcome.tamper: escalate
    pr:
      allowed_paths: ['internal/**']
      protected_paths: ['.golangci*.yml']
    labels:
      on_claim:    { add: [ynf:working], remove: [ynf:lint] }
      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] }

  # No model. A command that always exits 3, standing in for a build step that fails. ynf reads
  # that as outcome "error" and counts it under sig/outcome/error. Retry twice, then escalate.
  breaks:
    kind: originate
    intake:
      - github.search: 'repo:__REPO__ is:issue is:open label:"ynf:breaks"'
        every: 5m
    run:
      runner: command
      command:
        argv: ['sh', '-c', 'echo "the build step failed" >&2; exit 3']
    when:
      converged: open_pr
      outcome.error: { retry: 2, then: escalate }
    pr:
      allowed_paths: ['**/*.go']
    labels:
      on_claim:    { add: [ynf:working], remove: [ynf:breaks] }
      on_escalate: { add: [ynf:needs-human], remove: [ynf:working] }
EOF
sed -i.bak -e "s|__REPO__|$REPO|g" -e "s|__HARNESS__|github.com/$HREPO@v1.0.0|g" .agents/factory/lanes.yaml && rm .agents/factory/lanes.yaml.bak

git add -A && git commit -qm "seed: Loosen the Leash target, plus the part 6 lanes" && git push -q -u origin HEAD

Now the labels, the four tickets and ynf’s configuration. Ticket 1 is the store ticket from Part 1, tickets 2 and 3 are stand-ins for a failing build, and ticket 4 is a small lint ticket in internal/report:

▸ Show the labels, tickets and config (50 lines, open and copy the whole block)
cd "$LTL/part6/work"
for l in ynf:lint ynf:breaks ynf:working ynf:proposed ynf:in-review ynf:needs-human pkg:internal/store pkg:internal/report; do
  gh label create "$l" -R "$REPO" --force >/dev/null
done

cat > .ltl-store.md <<'EOF'
`golangci-lint` reports unchecked errors in `internal/store`:

```
internal/store/file.go:12:15: Error return value of `f.Close` is not checked (errcheck)
internal/store/file.go:13:9: Error return value of `f.Write` is not checked (errcheck)
internal/store/file.go:19:11: Error return value of `os.Remove` is not checked (errcheck)
```

`Save` can report success after a failed write, which is the one that matters.
EOF

cat > .ltl-breaks.md <<'EOF'
Runs the `breaks` lane: a command, not an agent, that always exits 3 with "the build step failed".
It stands in for a real build that fails the same way every time, so the failure is the same
every run.
EOF

cat > .ltl-report.md <<'EOF'
Two findings in `internal/report`:

```
internal/report/report.go:19:2: ineffectual assignment to title (ineffassign)
internal/report/report.go:10:6: S1003: should use strings.Contains(l, needle) instead (staticcheck)
```
EOF

gh issue create -R "$REPO" --title "Unchecked errors in internal/store" --label pkg:internal/store --body-file .ltl-store.md
gh issue create -R "$REPO" --title "Build step fails, memory on (no model)" --body-file .ltl-breaks.md
gh issue create -R "$REPO" --title "Build step fails, memory off (no model)" --body-file .ltl-breaks.md
gh issue create -R "$REPO" --title "ineffassign and staticcheck findings in internal/report" --label pkg:internal/report --body-file .ltl-report.md
rm .ltl-store.md .ltl-breaks.md .ltl-report.md

cat > "$YNF_CONFIG" <<'EOF'
# yaml-language-server: $schema=https://eyelock.github.io/ynf/schema/config.schema.json
#
# Part 6. No `memory:` block on purpose: ynf detects ynm on PATH, as in Part 5.
version: 1
repos: [__REPO__]
lease: { ttl: 5m, heartbeat: 60s }
poll: { ci: 20s, review: 20s }
EOF
sed -i.bak "s|__REPO__|$REPO|g" "$YNF_CONFIG" && rm "$YNF_CONFIG.bak"
ynf lanes validate 2>&1 | tail -3

You should see four issue URLs ending in /issues/1 to /issues/4, then ynf accepting the lanes file. If the issue numbers do not start at 1, the repository was not new: go back to the reset block.

Every command from here runs from $LTL/part6/work unless it says otherwise.

5. Give the harness a memory

The memory that matters most to an agent is the one it can reach from inside its own session, so the harness declares it and it is versioned with everything else the harness is. ynm writes the wiring; you read it before you commit it:

cd "$LTL/part6/work-harness"
ynm client install ynh
git diff --stat
git diff | grep -E '^\+.*("ynm"|"git"|"command"|"on_)'

You should see one file and the lines that matter:

 .agents/harness/plugin.json | 34 ++++++++++++++++++++++++++++++++++
 1 file changed, 34 insertions(+)
+    "ynm": {
+      "command": "ynm",
+      "git": "https://github.com/eyelock/ynm",
+    "on_session_start": [
+        "command": "ynm hook session-start"
+        "command": "ynm hook prompt"
+    "on_stop": [
+        "command": "ynm hook stop"

That is an MCP server the agent can call, a skill, and three hooks. Now the part ynm cannot do for you. A worker sees only the environment variables its harness declares, and ynm cannot know that this tutorial keeps its memory in a home of its own. So the harness must declare env_passthrough for YNM_HOME and YNM_USER. Without it the agent does not see them, falls back to the default, and reads and writes your real ~/.ynm instead of this tutorial’s memory. Add it, bump the version and tag:

cd "$LTL/part6/work-harness"
jq '.version = "1.1.0" | .env_passthrough = ["YNM_HOME", "YNM_USER"]' .agents/harness/plugin.json > p.json && mv p.json .agents/harness/plugin.json
ynm validate
git commit -qam "leash 1.1.0: the harness remembers through ynm" && git tag v1.1.0 && git push -q origin HEAD v1.1.0

ynm validate prints ok for the manifest, the server, the guidance, the hooks and ynm on PATH. Now move the lane to the new tag. The pin lives in the factory’s own repository, so moving it is a commit like any other:

cd "$LTL/part6/work"
sed -i.bak "s/ltl-part6-harness@v1.0.0/ltl-part6-harness@v1.1.0/" .agents/factory/lanes.yaml && rm .agents/factory/lanes.yaml.bak
git commit -qam "pin harness v1.1.0" && git push -q
ynf harness 2>&1 | grep -A3 'ok    lint:' | grep -E 'harness github|resolves'

You should see the pin and the commit it resolved to:

        harness github.com/<you>/ltl-part6-harness@v1.1.0, pinned from https://github.com/<you>/ltl-part6-harness at v1.1.0, ...
        resolves to github.com/<you>/ltl-part6-harness/leash@1.1.0 at a250e55c55611fd384e25e898c5aaa2fa42ccdd7

6. Run one ticket and merge it

Give the repository a memory, and tell it one thing a person would otherwise have to put in every prompt. ynm init keeps memory in git notes and commits a small config.json so every clone gets the same setup. Its search index is excluded only in the clone where init ran, and ynf works in another clone, so the .gitignore for .ynm/index and .ynm/wiki is committed too. Without those two lines the factory finds a stray file in a protected folder and refuses the change.

cd "$LTL/part6/work"
ynm init --personal && ynm init
printf '.ynm/index/\n.ynm/wiki/\n' >> .gitignore
git add -A && git commit -qm "ynm in this repository" && git push -q
ynm remember --type semantic --subject errors --content "In this codebase an error is returned or wrapped with %w, never discarded with _ =. A write that fails must reach the caller."
ynf doctor | grep -E '^(ok|--|FAIL) +(ynh|ynm|memory)' | cut -c1-110

You should see three ok lines: ynh, ynm, and memory on: ynm on PATH, the project store at .... ynf found both and was told about neither. Start the ticket. This is the one model run:

cd "$LTL/part6/work"
ynf start "$REPO#1" --lane lint --auto-approve edits 2>&1 | grep -E 'run finished|proposed in lane' | cut -c1-260

--auto-approve edits is you, at the terminal, letting the worker edit files for this one run. After a minute or two you should see, trimmed:

time=... level=INFO msg="run finished" ... outcome=converged exit=0 duration=1m15.842s changed=2 ... model=claude/claude-opus-5-5 turns=2 tokens=7632
github.com/<you>/ltl-part6-factory#1: proposed in lane lint (proposed as #5)

Now read the receipts:

cd "$LTL/part6/work"
RUN=$(ls -d "$LTL"/.home/ynf-part6/work/steps/*_issues_1/*/run | tail -1)
jq -r 'select(.type=="sensor_result" and .data.name=="test-store") | "turn \(.turn)  \(.data.name)  \(.data.status)"' "$RUN/trajectory.jsonl"
ynf --format json items log "$REPO#1" | jq '.[] | select(.kind=="run") | .body | {harness_pin, harness_sha}'
git -C ../work-harness rev-parse HEAD

You should see the hidden contract fail once and then pass, and the pin resolving to the commit your tag points at:

turn 1  test-store  fail
turn 2  test-store  pass
{
  "harness_pin": "github.com/<you>/ltl-part6-harness@v1.1.0",
  "harness_sha": "a250e55c55611fd384e25e898c5aaa2fa42ccdd7"
}
a250e55c55611fd384e25e898c5aaa2fa42ccdd7

Now let CI report and look at what was proposed:

cd "$LTL/part6/work"
ynf --interactive sweep --until-settled --timeout 10m --lane lint 2>&1 | tail -2
PR=$(gh pr list -R "$REPO" --state all --head ynf/issue-1 --json number -q '.[0].number')
gh pr view "$PR" -R "$REPO" --json commits -q '.commits[0].messageBody' | grep -E '^(Co-Authored|YNF-|YNH-)'
gh pr view "$PR" -R "$REPO" --json isDraft,files,statusCheckRollup -q '{isDraft, files: [.files[].path], checks: [.statusCheckRollup[].conclusion]}'

You should see a draft, two files in internal/store, three green checks, and a commit that names the factory’s item and run and the harness session:

ITEM                                    LANE  STATE      PR  REASON
github.com/<you>/ltl-part6-factory#1  lint  in_review  #5  #5: CI green
Co-Authored-By: claude/claude-opus-5-5 <noreply@anthropic.com>
YNF-Item: github.com/<you>/ltl-part6-factory#1
YNF-Step: 01M4DRCBSA58PDFDHT50YJT78Y
YNF-Run: 01M4DRCBSA58PDFDHT50YJT78Y-1
YNH-Session: 6b23c9364d2788de
{"checks":["SUCCESS","SUCCESS","SUCCESS"],"files":["internal/store/file.go","internal/store/file_test.go"],"isDraft":true}

Read the diff, then do the one thing no layer does:

cd "$LTL/part6/work"
PR=$(gh pr list -R "$REPO" --state all --head ynf/issue-1 --json number -q '.[0].number')
gh pr diff "$PR" -R "$REPO"
gh pr ready "$PR" -R "$REPO"
gh pr merge "$PR" -R "$REPO" --squash
gh pr view "$PR" -R "$REPO" --json state,mergedBy -q '"\(.state) by \(.mergedBy.login)"'
sleep 30
ynf --interactive sweep --until-settled --timeout 2m --lane lint | tail -1

You should see your own login on the merge, and the factory closing the item:

MERGED by <you>
github.com/<you>/ltl-part6-factory#1  lint  done   #5  #5 merged

The agent also changed Remove to return its error and wrote tests nobody asked for. The sensors proved your contract holds, not that the change is wise; a person decides that.

7. Read what the harness remembered

The agent was never told the convention: not in the ticket, not in the task, not in the repository. Here is what its harness handed it when the session started:

cd "$LTL/part6/work"
RUN=$(ls -d "$LTL"/.home/ynf-part6/work/steps/*_issues_1/*/run | tail -1)
ynm context
jq -r 'select(.type=="assistant_message") | .data' "$RUN/trajectory.jsonl" | grep -m1 -o "[^.]*wrapped with .%w.[^.]*\."
git grep -c '%w' || echo "no file in the repository mentions it"
grep -c 'ynm\|memory' "$RUN/task.md"
ynm list --json | jq -c '.[] | {namespace, type, subject}'

You should see the memory block, the agent quoting the rule in its plan, nothing in the repository or the task file, and one memory in the store, with ynm context printing it under a Memory heading (the wording of the agent’s line varies from run to run):

- (semantic) In this codebase an error is returned or wrapped with %w, never discarded with _ =. A write that fails must reach the caller.

The project rule says the same thing: errors are returned or wrapped with `%w`, never discarded with `_ =`.
no file in the repository mentions it
0
{"namespace":"user/ltl","type":"semantic","subject":"errors"}

ynm context is the block the session-start hook gives the agent. The only place that rule exists is the memory.

8. Fail on purpose and ask for the explanation

Ticket 2 goes to the breaks lane, a command that exits 3 and says “the build step failed”. It uses no model. The lane retries twice and then escalates:

cd "$LTL/part6/work"
ynf start "$REPO#2" --lane breaks 2>&1 | tail -1
ynf items log "$REPO#2" | grep -E 'Z  run |-> escalated' | cut -c1-150

You should see three runs and the escalation:

github.com/<you>/ltl-part6-factory#2: escalated in lane breaks (outcome.error after 2 retries)
2026-10-08T12:44:44Z  run      01M4DRPQ0119PY055GQ0MD0SP6-1 command via process: error exit 3: the build step failed, 0 changed (17ms)
2026-10-08T12:44:48Z  run      01M4DRPQ0119PY055GQ0MD0SP6-2 command via process: error exit 3: the build step failed, 0 changed (18ms)
2026-10-08T12:44:49Z  run      01M4DRPQ0119PY055GQ0MD0SP6-3 command via process: error exit 3: the build step failed, 0 changed (18ms)
2026-10-08T12:44:50Z  decision ynf.run.finished -> escalated: outcome.error after 2 retries [escalate]

Now ask the memory what it was told, and the factory what it counted:

cd "$LTL/part6/work"
ynm list --namespace factory/ --json | jq -c 'sort_by(.createdAt)[] | {subject, occurrence: .current.data.count, tags}'
ynm list --namespace factory/ --json | jq -r 'sort_by(.createdAt)[0] | .current.content'
ynf items show "$REPO#2" | jq -c .counters
ynf stats --lane breaks | sed -n 4,5p

You should see three records under one signature, the first one’s text, and the same three occurrences in the counters:

{"subject":"sig/outcome/error","occurrence":1,"tags":["ynf","ynf.failure.v1","lane:breaks","failure","occurrence"]}
{"subject":"sig/outcome/error","occurrence":2,"tags":["ynf","ynf.failure.v1","lane:breaks","failure","occurrence"]}
{"subject":"sig/outcome/error","occurrence":3,"tags":["ynf","ynf.failure.v1","lane:breaks","failure","occurrence"]}
Failure `sig/outcome/error` on github.com/<you>/ltl-part6-factory#2 in lane `breaks`: the run ended error, exit 3; it reported: "exit 3: the build step failed". Occurrence 1, ... ynf then decided: outcome.error: retry 1 of 2: ...
{"retry/outcome.error":3,"sig/outcome/error":3}
<you>/ltl-part6-factory breaks, top failure signatures:
     3  sig/outcome/error

The records sit in a factory/ namespace, apart from your user/ convention.

9. Collect the refusals

Each of these comes from something already run:

cd "$LTL/part6/work"
RUN=$(ls -d "$LTL"/.home/ynf-part6/work/steps/*_issues_1/*/run | tail -1)
PR=$(gh pr list -R "$REPO" --state all --head ynf/issue-1 --json number -q '.[0].number')
jq -c 'select(.type=="worker_env") | .data.passed | map(select(test("YNM|TOKEN|KEY")))' "$RUN/trajectory.jsonl"
gh pr view "$PR" -R "$REPO" --json commits -q '.commits[0].authors[0] | "commit by \(.name)"'
ynf replay "$REPO#2" | tail -1
grep -ci 'memory\|%w' "$RUN/task.md"
gh pr view "$PR" -R "$REPO" --json state,mergedBy -q '"\(.state) by \(.mergedBy.login)"'
mkdir -p .ltl
sed 's/converged: open_pr/converged: merge/' .agents/factory/lanes.yaml > .ltl/lanes-merge.yaml
ynf lanes validate --file .ltl/lanes-merge.yaml 2>&1 | grep -m1 "value must be one of" | sed 's/.*value must be/value must be/'

You should see five lines of refusal, in this order:

["YNM_HOME","YNM_USER"]
commit by ynf
5 decisions, 0 differ
0
MERGED by <you>
value must be one of 'open_pr', 'push_commit', 'comment', 'request_review', 'escalate', 'quarantine', 'close'

In order: the worker saw only the two declared names and no token; the commit is ynf’s; replay re-decides all five decisions with memory not among its inputs; the task file never mentions memory; a human merged; and a lane asking for merge is invalid.

10. Take away the memory

Build a PATH that has ynf, ynh and the other tools they need, and no ynm:

cd "$LTL/part6/work"
mkdir -p .ltl/noynm
for t in gh jq go gofmt golangci-lint; do ln -sf "$(command -v $t)" .ltl/noynm/$t; done
ln -sf "$(command -v ynf)" .ltl/noynm/ynf
ln -sf "$(command -v ynh)" .ltl/noynm/ynh
NOYNM="$PWD/.ltl/noynm:/usr/bin:/bin"
PATH="$NOYNM" command -v ynm; echo "ynm found: $?"
PATH="$NOYNM" ynf doctor | grep -E 'ynm|memory' | cut -c1-110
PATH="$NOYNM" ynf start "$REPO#3" --lane breaks 2>&1 | tail -1
echo "memory lines in the log: $(ynf items log "$REPO#3" | grep -ci memory)"
ynf items show "$REPO#3" | jq -c .counters
PATH="$NOYNM" ynf replay "$REPO#3" | tail -1
ynm list --namespace factory/ --json | jq -r '[.[]|select(.type=="episodic")] | group_by(.subject)[] | "\(length)  \(.[0].subject)"'
ynf stats --lane breaks | sed -n 4,5p

You should see doctor say why memory is off, ticket 3 escalate exactly as ticket 2 did, and ynm holding three records while ynf has counted six:

ynm found: 1
--    ynm                      not found or not working (exec: "ynm": executable file not found in $PATH)
--    memory                   off: ynm is not on PATH
github.com/<you>/ltl-part6-factory#3: escalated in lane breaks (outcome.error after 2 retries)
memory lines in the log: 0
{"retry/outcome.error":3,"sig/outcome/error":3}
5 decisions, 0 differ
3  sig/outcome/error
<you>/ltl-part6-factory breaks, top failure signatures:
     6  sig/outcome/error

11. Take away the harness

The lint lane names no runner, so with ynh gone ynf should fall back to the lane’s command. Make a PATH with ynm and no ynh, and ask ynf what it will do before it does it:

cd "$LTL/part6/work"
mkdir -p .ltl/noynh
for t in gh jq go gofmt golangci-lint; do ln -sf "$(command -v $t)" .ltl/noynh/$t; done
ln -sf "$(command -v ynf)" .ltl/noynh/ynf
ln -sf "$(command -v ynm)" .ltl/noynh/ynm
NOYNH="$PWD/.ltl/noynh:/usr/bin:/bin"
PATH="$NOYNH" command -v ynh; echo "ynh found: $?"
PATH="$NOYNH" ynf harness 2>&1 | grep -A1 'ok    lint:'

You should see:

ynh found: 1
  ok    lint: originate lane, command (ynh not found) on process
        a command, not a harness

Now run ticket 4, the small lint ticket, through the same lane:

cd "$LTL/part6/work"
NOYNH="$PWD/.ltl/noynh:/usr/bin:/bin"
PATH="$NOYNH" ynf start "$REPO#4" --lane lint 2>&1 | tail -2
PATH="$NOYNH" ynf --interactive sweep --until-settled --timeout 10m --lane lint 2>&1 | tail -3
PR=$(gh pr list -R "$REPO" --state all --head ynf/issue-4 --json number -q '.[0].number')
gh pr diff "$PR" -R "$REPO" | grep -E '^[-+][^-+]'
gh pr view "$PR" -R "$REPO" --json isDraft,files,statusCheckRollup -q '{isDraft, files: [.files[].path], checks: [.statusCheckRollup[].conclusion]}'
gh pr view "$PR" -R "$REPO" --json body -q .body | grep -o 'runner `[a-z]*`'

You should see a draft pull request with green CI and runner command in its description, with no harness and no model:

github.com/<you>/ltl-part6-factory#4: proposed in lane lint (proposed as #6)
pull request: https://github.com/<you>/ltl-part6-factory/pull/6
ITEM                                    LANE  STATE      PR  REASON
github.com/<you>/ltl-part6-factory#1  lint  done       #5  #5 merged
github.com/<you>/ltl-part6-factory#4  lint  in_review  #6  #6: CI green
-		if strings.Index(l, needle) != -1 {
+		if strings.Contains(l, needle) {
{"checks":["SUCCESS","SUCCESS","SUCCESS"],"files":["internal/report/report.go"],"isDraft":true}
runner `command`

The linter fixed the one finding it can fix alone and left the other, which needs judgement. That gap is what the harness is for.

Clean up

This deletes both GitHub repositories, uninstalls the harness from the tutorial’s own ynh home, and removes the folder and this part’s ynf and ynm homes. It needs the delete_repo scope. Nothing in your own ~/.ynh, ~/.ynm or ~/.ynf was touched. In a new shell, re-run the export block from step 2 first.

cd "$LTL"
gh repo delete "$REPO" --yes
gh repo delete "$HREPO" --yes
ynh uninstall "github.com/$HREPO/leash"
rm -rf "$LTL/part6" "$LTL/.home/ynf-part6" "$LTL/.home/ynm-part6"
gh repo list "$OWNER" --limit 200 | grep ltl-part6 || echo "no ltl-part6 repositories left"

The harness guides. The memory remembers. The factory decides. A human merges.