FOUNDRYOS FIELD NOTE 01

DO NOT STOP!

How I moved a recurring coding-agent failure out of the prompt and into a runtime gate with an explicit decision policy.

22 min read · 8 sections · jump to any of them below
Single-panel FoundryOS infographic. The headline reads, "It said it was continuing. Then it stopped." A terminal card shows, "225 owner-write cells remain… Continuing down the density list," followed by "then the turn ended." Beneath it, a three-question Robustness Check asks whether an option fixes the root cause, skips a cleaner path because it is slower or defers safety. The result row shows 8 stops caught, 8 answered by the rule and 0 needing a human.
The failure was not capability. The session asserted continuation and then ended. A runtime hook caught that contradiction and applied a decision policy written in advance.
Sections

Summary

A coding agent ended a turn immediately after saying it was continuing. The work was unfinished, the agent was still capable of doing it and a simple “keep going” prompt made it resume without difficulty. The recurring failure was not task capability. It was that the model treated the end of a turn as an acceptable stopping point, while nothing at that lifecycle boundary contradicted it.

Repeated instructions did not solve the problem. I already had several persistent rules telling the agent not to stop early, and it still announced that it was beginning the next phases and ended the turn one message after helping write the detector for that exact behaviour. The wording was not the missing mechanism. The enforcement vector was wrong.

Claude Code’s Stop hook runs when the agent attempts to end a turn. It receives the exact last_assistant_message the agent was about to leave behind and can reject the stop with exit code 2. I used that boundary to detect a specific contradiction: the message asserts continuation or remaining work, but the runtime action is to stop.

Blocking the stop was only half the design. At the same moment, the gate returned a decision procedure based on my pre-authored Robustness Directive. It told the agent to apply already-settled answers, treat newly discovered work as work rather than a question, select the root-cause path, break genuine ties itself and interrupt me only when being wrong would be unrecoverable.

In the first measured use, the gate caught 8 turns that would have ended with unfinished work. All 8 were resolved by the rule, and none required a human decision. That evidence covered 4 sessions and roughly 17 hours of gated work. It is an early operational result, not proof that the gate is infallible or that every agent handback can be detected.

The value is narrower: the system turns a silent, routine bail into a visible, governed event. When a behaviour keeps recurring despite clear instructions, stop rewriting the instruction. Put the rule at the runtime surface that can actually observe the failure.

Headline evidence

  • 8 stops caught
  • 8 answered by the rule
  • 0 needed a human
  • 0 escalations in the measured set
  • 4 sessions
  • roughly 17 hours of gated work
  • about 1 catch every 2 hours
  • first full run: 3.5 hours
  • first full run: 1 gate intervention
  • one file
  • zero dependencies
  • Node.js 18 or later

The Problem

The agent was capable of continuing, but repeatedly treated the end of a turn as a reasonable place to stop.

Opus 5 shipped on Friday. I started building with it that night, hit the same failure enough times over the weekend to stop treating it as a fluke and had it fixed by Sunday.

Two days, most of which was spent being wrong about the cause.

The fix turned out to be a small amount of policy in a place I had not thought to look: a lifecycle hook rather than a prompt.

Here is the moment it clicked.

A coding session had been running for forty-nine turns, grinding through a test-coverage backlog. Good work, real progress. Its last message before the turn ended was:

225 owner-write cells remain across ~120 files. Continuing down the density list.

Read that again.

It says it is continuing.

Then it stopped, and control came back to me.

I typed “keep going, don’t stop until all 225 are done,” and it carried on perfectly well for another hour.

The capability was never the problem.

The agent had simply decided that the end of a turn was a reasonable place to stop, and nothing contradicted it.

Nothing crashed. No bad code shipped. It was a papercut that appeared in session after session, demanded my attention at exactly the moment I had stopped paying attention and made the tool feel as though it still needed babysitting.

Why I could not prompt my way out of it

My first instinct was the obvious one: write the instruction down harder.

I already had four separate notes in persistent agent memory saying, in different words:

  • do not stop early
  • do not ask what has already been answered
  • run to completion
  • continue until the stated condition is met

Four separate rules, written on four occasions, because the behaviour kept recurring.

The moment that ended the debate for me happened while I was building the detector.

In the same session, with the rule as the active subject of the conversation, the agent announced:

Building phases one to three now.

Then it ended the turn.

One message after authoring the mechanism meant to catch that behaviour.

That is about as strong as evidence gets.

If an instruction cannot hold when it is the active subject of the conversation, the instruction is not the mechanism.

The enforcement vector was wrong, not the wording.

What It Is

A session-scoped work contract enforced at the Stop lifecycle boundary, backed by a decision policy and durable state.

Most agent tooling operates through instructions: tell the model what to do and hope the instruction survives context pressure, competing goals and the model’s own sense that a turn is complete.

Claude Code also exposes hooks: small programs executed at defined lifecycle events.

One of them is the Stop hook.

It fires when the agent attempts to end a turn. The hook can allow the stop or refuse it. Returning exit code 2 blocks turn-end and sends control back into the session rather than returning it to the user.

Critically, the Stop hook receives:

last_assistant_message

That is the exact text the agent was about to leave behind.

A prompt competes with every other instruction and inference in context.

A hook observes a lifecycle event and has a veto.

The system in this Field Note is a small session-scoped work contract built around that boundary. It has four parts:

  1. Contract state records whether the session is armed and what completion condition it is holding.
  2. A Stop gate inspects the attempted final message and the open contract state.
  3. A decision policy resolves ordinary forks without interrupting the user.
  4. A durable record makes the resulting decisions inspectable rather than ephemeral.

The gate is not trying to make the model obedient through stronger language.

It is moving enforcement to the moment where the failure becomes observable.

What the gate owns

The gate owns the question:

May this turn end while the contract remains open?

It does not own the technical implementation, the diagnosis or the code changes. The model still performs those tasks.

It also does not decide consequential business policy from scratch. The policy it applies was written in advance.

That separation matters.

The hook creates the moment where a decision must be made without me. The directive determines what gets decided.

A gate with no policy produces confident guesses.

A policy with no gate is what I already had: written down several times and ignored.

How It Works

The gate detects a contradiction, injects a bounded decision procedure and releases only when the contract is discharged, waived or escalated.

Detect the contradiction, not the grammar

My first detector was the obvious one: block if the message ends in a question mark.

It would have caught none of the important cases.

The “225 cells remain” message contains no question. It is a confident progress report.

The useful rule is not grammatical:

The message asserts continuation or remaining work, and then the turn ends.

That is a contradiction between what was said and what was done.

“225 remain. Continuing.” followed by stopping is provably wrong about itself. No interpretation of hidden intent is required.

A second family covers softer handbacks:

  • asking permission the agent already has
  • offering a menu instead of making a call
  • declaring that a choice is “your call”
  • inventing a capacity excuse
  • saying it is near the end of its practical range
  • reporting remaining work while phrasing the message as a wrap-up

These shapes matter because they repeatedly represent the same operational event: unfinished work is being transferred back to the operator without a genuine need for human authority.

The block is the least interesting part

Stopping the stop is easy.

The valuable half is what the gate returns at the same moment.

When the gate fires, it supplies the decision procedure the agent should have applied:

  • Already answered somewhere? Apply that answer and continue.
  • Newly discovered work? It is an item, not automatically a question. If it blocks the goal or is the root cause of the current work, handle it now. If it is adjacent, record it and continue.
  • A path choice? Select the path that fixes the root cause. Time cost is not a counter-argument.
  • A genuine tie? The agent’s selection is the tie-break. Do not escalate a tie.
  • A possible interruption? Interrupt only when being wrong would be unrecoverable: an irreversible live operation, a genuinely missing requirement or a blocker the agent cannot resolve.

The escalation threshold is irreversibility, not uncertainty.

An agent that interrupts whenever it is uncertain interrupts constantly.

An agent that interrupts when a wrong choice cannot be undone interrupts rarely, and those are the interruptions worth receiving.

The Robustness Directive

The agent is not being trusted to invent good policy in the moment.

It is applying mine, written in advance:

Always pick the cleanest, most robust path. No band-aids. Root causes, not
symptoms. Time cost is not a counter-argument. Never offer — or accept —
"ship now, refactor later" options.

The Robustness Check — run before proposing or accepting any option:
  1. Does this address the root cause, or patch a symptom?
  2. Is there a cleaner path I am passing over because it takes longer?
  3. Am I deferring safety, correctness, or cleanup to a "later cycle"?

If any answer is yes, the option fails. Produce or pick the clean path instead.

Three questions, with failure on any one disqualifying the option.

That is narrow enough to behave more like a checklist than an open-ended taste judgment.

I do not need to trust the model to have good taste under pressure.

I need it to apply a small, explicit test honestly.

The judgment was made once, by me, when I had time to think about it. Not by the agent, mid-task, at the exact moment it wanted to be finished.

Two clauses carry most of the load.

Time cost is not a counter-argument

An agent that wants to wrap up will not say, “I am cutting a corner.”

It will say the proper fix is a larger change, better handled separately or not worth the effort for a small gain.

Those are the same argument wearing different clothes.

The directive excludes the whole family.

The evaluator’s pick is the tie-break

Without a tie-break, a strict decision rule becomes another interruption source.

The agent runs the check, finds two equivalent paths and asks which one to choose.

Genuine ties are usually shape choices: order, naming or decomposition. They do not deserve operator attention.

Local conventions come first

“Cleanest” is not absolute.

It is relative to the conventions already present in the codebase.

A generically respected library may be the wrong choice in a dependency-free layer. An abstractly elegant pattern may violate a local invariant.

The process is therefore:

  1. Load the local conventions.
  2. Run the Robustness Check within that design space.
  3. Record the selected path and rationale.

Decisions remain auditable

Every fork resolved by the gate is recorded with the rule applied and the reason.

That changes the review question.

Instead of asking, “Do I like this outcome?” I can ask, “Was the stated rule applied correctly?”

That question has an inspectable answer.

When I disagree, the durable fix is usually to sharpen the rule rather than argue with one isolated decision.

Evidence

Eight prevented stops across four sessions, including one three-and-a-half-hour coverage run that resolved its own handback.

The first real run

I armed the gate on a job I did not want to babysit: a coverage sweep across a portal, the same kind of work that produced the “225 cells remain” message.

The session ran for three and a half hours.

The gate fired once.

Two hours in, the session found something it did not think was its decision to make. An organization setting gated one portal surface but not another, and closing the gap would change behaviour for existing users.

It wrote:

One thing I deliberately did not change ... that’s your call, not a fix
to slip into a sweep.

That is a reasonable concern.

It is also a decision being handed back.

The hook caught the handback shape.

The next message in the transcript was:

You’re right — that was a decision to make, not to surface. Resolving it now.

Then it did the work.

Its final report explained:

I resolved it rather than leaving it with you. Gating the logged-in client rail
is the root-cause fix — one switch, one portal, two surfaces — and the
blast-radius objection doesn’t survive checking: a client user cannot exist
while the flag is off, since request-account.js refuses the account request.
Gated at resolveClientPortalSession, the single choke point for all seven
call sites.

The important result is not simply that the hook prevented a stop.

The agent took the root-cause path: one choke point rather than seven call sites.

Then it tested whether the objection it had raised was true.

It was not.

The risk that made the choice appear to require a human decision disappeared when checked against the code.

“Time cost is not a counter-argument” did not mean ignoring the risk.

It meant not using an unverified risk as a reason to defer.

I was worried I had built something that would make decisions I would not have made. What I had actually built was something that made the agent verify its own excuses.

The number I actually care about

At the time of the measured article run:

  • 8 turns would have ended with unfinished work

  • 8 were caught

  • 8 were resolved by the decision procedure

  • 0 required a human

  • 0 escalated

  • 4 sessions were represented

  • roughly 17 hours of gated work were represented

  • the observed rate was about one catch every two hours

  • 8 stops caught — primary result

  • 8 — rule-resolved

  • 0 — human decisions required

  • 0 — escalations

Observed population: 4 sessions, roughly 17 hours of gated work

Population note: These are early operational observations from the measured sessions described in this Field Note. They are not a controlled benchmark and should not be presented as a universal intervention rate.

The zero was the unexpected part.

The obvious concern was that preventing premature stops would simply produce a different interruption: more questions.

That did not happen in the measured set.

Every fork encountered was answerable by the existing directive.

One phrase repeated across unrelated sessions:

your call

That was not a one-off sentence. It was a stable handback shape.

Stable failure shapes are what make rules worthwhile. They can be detected and governed rather than handled as isolated anecdotes.

What Went Wrong

The implementation repeatedly armed itself, installed inert hooks, used the wrong failure posture and trusted documentation over observed payloads.

The final mechanism is small.

Finding the correct mechanism was not.

1. Talking about the mechanism triggered the mechanism

I asked whether “don’t stop” was an arming phrase, and the session armed itself while answering.

Then “I told the other session to keep going” armed the current one.

Then quoting the failure text tripped the failure detector.

Three events, one defect:

I had not distinguished mentioning a phrase from using it.

When a bug has that shape, fix it across the whole input surface at once. I fixed it in one place three times.

2. A successful installation did nothing

I wired the hooks to absolute paths. The installer reported success.

The gate never fired.

The scripts were committed on a branch that had not been merged, so every hook invocation failed with module-not-found.

Only exit code 2 blocks the stop. The missing script did not produce a loud operational failure in the user-facing path.

The system was silently inert while appearing installed.

The installer now refuses to write hook entries that point to files that do not exist.

3. Fail-closed was the wrong default for every failure class

A safety gate instinctively suggests fail-closed.

But this hook runs across every project. A defect in a machine-wide hook can create a total lockout in unrelated work.

The correct posture is split:

  • corrupt active contract: fail closed
  • no contract exists: release
  • hook infrastructure failure: fail open with a visible warning

Never silent in either direction.

The system must not quietly release an active contract it can no longer read, but it also must not wedge the entire development environment because the enforcement script itself broke.

4. I trusted documentation over the payload

I originally built the wedge-prevention argument around the claim that the Stop hook supplied no re-entry signal.

I checked the documentation twice.

Both times, I concluded the field did not exist.

I wrote that claim into code comments, a guide and an earlier article draft.

The field exists.

It is:

stop_hook_active

It is true when the turn-end is a re-entry after a previous Stop-hook block.

The question was settled by observing the real payload, not by reading the documentation again.

I changed the hook to record the payload’s key names, never the values, once per session. Four live sessions confirmed the field directly.

For anything safety-relevant, observe the payload. Documentation is a secondary source.

The design survived because I had already rewritten it to behave safely whether or not the field existed. When the payload proved it did, the implementation needed correction rather than reconstruction.

The field now improves the breaker: it can count genuine repeated stalls rather than raw blocks.

5. I wrote the injected state as commands

The early injected text said things such as:

You may not end the turn.
ACKNOWLEDGE: open your next reply with...

That created two problems.

First, an out-of-band imperative looks structurally like prompt injection. It encourages the model to question legitimacy before reasoning about the state.

Second, it falsely claimed authority. This is a user-installed shell script, not a system instruction.

The corrected form describes state:

  • what is configured
  • what the lifecycle boundary does
  • what policy is active
  • what conditions release it

That is information to reason over rather than an instruction competing for authority.

The acknowledgement requirement was removed. It existed only to compensate for a client-display problem and had no place in the decision mechanism.

What the Evidence Does and Does Not Prove

The gate makes premature stopping visible and expensive; it does not make an agent unable to stop or prove every completion claim.

This is not a replacement for /goal

Claude Code v2.1.139 and later includes:

/goal <condition>

A user states a completion condition, and a small model evaluates after each turn whether the condition is satisfied.

For a straightforward requirement such as “keep working until this suite is green,” /goal is the native tool. It requires no custom maintenance and a fresh model judging the condition may be better than a local detector guessing from text.

The work-contract gate adds different capabilities:

  • it knows which work items remain open
  • it can refuse unnecessary questions while work remains
  • it applies a durable decision policy
  • it records forks resolved on the user’s behalf
  • its state survives project and session boundaries
  • it can distinguish ordinary uncertainty from genuinely consequential escalation

The two mechanisms can compose.

They should not be presented as competitors.

It makes stopping expensive and recorded, not impossible

A determined model can still declare that no work remains and phrase its exit carefully.

Discharge is self-attested.

Evidence requirements raise the cost of a false claim, but they do not turn self-report into proof.

The gate therefore does not establish:

  • perfect premature-stop detection
  • mechanically proven task completion
  • universal zero-human operation
  • that every coding-agent workflow should use the same policy
  • that the observed 8/8/0 result will continue indefinitely
  • that a language model cannot route around a text-pattern detector

What it does establish is narrower:

  • the runtime can observe turn-end
  • the attempted final message can be inspected
  • a contradiction between asserted continuation and stopping can be detected
  • the stop can be blocked
  • a pre-authored decision rule can resolve ordinary handbacks
  • the resulting decision can be logged
  • the operator can audit the decision later

The system converts an invisible bail into a visible one.

A visible stop can be challenged.

A recorded stop can be audited.

Neither benefit requires the gate to be infallible.

The evidence population is small

The 8/8/0 result came from 4 sessions and roughly 17 hours of gated work.

It was not a randomized trial, a controlled before-and-after benchmark or a representative sample across agents, repositories and task classes.

It is operational evidence that the mechanism caught real repeated failures in the environment that produced it.

That is the honest boundary.

Lessons and Reusable Principles

The durable lesson is to enforce recurring behavioural rules at the lifecycle surface that can observe the failure.

1. A recurring instruction failure is often an enforcement-location failure

When clear wording repeatedly fails, adding more wording is not automatically progress.

Ask:

  • At what lifecycle event does the failure become observable?
  • What state exists at that moment?
  • Can a deterministic mechanism veto or route the event?
  • What evidence can be recorded?

In this case, attempted turn-end was the observable boundary.

2. Detect contradictions before attempting intent inference

“Remaining work exists” plus “the turn ends” is a stronger signal than a vague attempt to classify motivation.

The message and action disagree.

That is mechanically useful.

3. Escalation should follow consequence, not uncertainty

Uncertainty is normal in technical work.

Irreversibility is exceptional.

A system that escalates every uncertainty recreates the interruption problem it was meant to solve.

A system that escalates when a wrong choice cannot be recovered preserves human authority where it matters.

4. Write judgment once, then apply it consistently

The Robustness Directive was authored outside the pressure of the current task.

The agent applies it as a checklist.

This is safer than asking the model to invent standards at the exact moment it wants to stop.

5. Make the agent verify its objections

A plausible reason to defer is not evidence that deferral is necessary.

The first real run succeeded because the agent checked whether the blast-radius objection was true.

It was not.

6. Split failure posture by failure class

“Fail closed” is not a complete design.

For a machine-wide gate:

  • active-state corruption should hold
  • state absence should release
  • infrastructure failure should release visibly

The correct posture depends on the blast radius of each failure.

7. Observe safety-relevant payloads directly

Documentation can be stale, incomplete or interpreted incorrectly.

A live payload settles questions about what the runtime actually supplies.

Capture the minimum necessary evidence. In this case, recording key names rather than values proved the field existed without creating a transcript side channel.

8. Describe state rather than impersonating authority

A user-installed hook should not present itself as a system instruction.

State descriptions compose with reasoning.

False imperatives compete with the model’s instruction hierarchy and resemble prompt injection.

9. Preserve an audit trail

A decision made without interrupting the user should not disappear.

Record:

  • the fork
  • the rule applied
  • the selected path
  • the reason
  • the release or escalation outcome

That makes autonomy reviewable.

The broader lesson

When a behaviour keeps recurring despite clear instructions, stop rewriting the instruction.

Find the runtime surface that can actually observe the behaviour and put the rule there.

Documentation persuades. Hooks enforce.

Code, Setup and Sources

One dependency-free Node.js file registered for SessionStart, UserPromptSubmit and Stop.

The public version is one file with no runtime dependencies.

Requirements:

  • Node.js 18 or later
  • Claude Code hooks
  • a stable absolute path outside temporary worktrees

Hook registration

Register the same file for SessionStart, UserPromptSubmit and Stop in:

~/.claude/settings.json
{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node \"/abs/path/session-contract-minimal.mjs\""
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node \"/abs/path/session-contract-minimal.mjs\""
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node \"/abs/path/session-contract-minimal.mjs\""
          }
        ]
      }
    ]
  }
}

Why all three events are used

UserPromptSubmit detects explicit arming and clearing language.

Stop enforces the active contract when the session tries to end.

SessionStart restores the contract description after resume or compaction. It is read-only: it does not arm a new contract.

Without SessionStart, the contract file may survive while the model has one resumed turn with no description of the state it is operating under.

Basic use

Arm a contract in ordinary language:

Don’t stop until the suite is green.

Clear it explicitly:

stop

Disable the mechanism globally for troubleshooting:

SESSION_CONTRACT=0

Canonical code component

session-contract-minimal.mjs — JavaScript / Node.js ESM · Node.js 18 · None deps

Download the file

#!/usr/bin/env node
// session-contract-minimal.mjs — a single-file work-contract gate for Claude Code.
//
// ONE file, THREE hook events, ZERO dependencies. It dispatches on hook_event_name, so the same
// script is registered three times in settings.json:
//
//   "SessionStart":     [{ "hooks": [{ "type": "command",
//       "command": "node \"/abs/path/session-contract-minimal.mjs\"" }] }],
//   "UserPromptSubmit": [{ "hooks": [{ "type": "command",
//       "command": "node \"/abs/path/session-contract-minimal.mjs\"" }] }],
//   "Stop":             [{ "hooks": [{ "type": "command",
//       "command": "node \"/abs/path/session-contract-minimal.mjs\"" }] }]
//
// WHAT IT DOES
//   Say "don't stop until <X>" in chat and the session cannot end a turn until you say "stop" — or
//   until it stops writing messages that contradict themselves ("3 files left. Continuing." then
//   ending the turn). Anything that comes up mid-contract gets a decision policy injected
//   instead of interrupting you.
//
// SAFETY PROPERTIES worth keeping if you modify it:
//   - Release is always reachable: one word ("stop") disarms, a kill-switch env var disables, and a
//     circuit breaker degrades to advisory after N consecutive re-entries. Do not remove all three.
//   - Failure posture is split: a malformed contract fails CLOSED (blocks), a missing one releases,
//     and an infrastructure error fails OPEN. A hook that runs in every project must not turn its
//     own bugs into a machine-wide lockout.
//   - The prompt is never persisted; only a digest and the matched rule ids.
//   - The injected text is a STATEMENT OF STATE, not a set of commands. See POLICY below.
//
// HARDENING PASS 2026-07-27 — four changes, each with a reason worth carrying if you fork this:
//
//   1. INJECTED TEXT IS FACTUAL. It used to say "You may not end the turn", "RESOLVE IT AND
//      CONTINUE", and "ACKNOWLEDGE: open your next reply with…". That claimed authority a
//      user-installed shell hook does not have, and out-of-band imperatives read as prompt
//      injection — which costs a legitimacy evaluation before anything gets reasoned about. A
//      policy stated as fact composes with the model's judgment; a command competes with it.
//      The acknowledgement requirement is gone entirely: it existed only to work around a client
//      that does not render systemMessage, which is a display problem, not a model instruction.
//
//   2. SessionStart IS REGISTERED. The contract is on disk so it survives a resume, but nothing
//      re-described it until the next prompt — leaving one turn where an armed session had no
//      idea a contract was active. That window matters most after compaction, when the earlier
//      context is gone. SessionStart is read-only: it restores the description, never arms.
//
//   3. stop_hook_active IS READ — and is NEVER a release condition. TRUE means this turn-end is a
//      re-entry after a previous block; FALSE means a fresh turn-end, i.e. work happened in
//      between. Releasing on TRUE would let any session escape by stopping twice. It is used to
//      make the breaker count genuine stalls rather than raw blocks.
//      (This field's existence was verified from live payloads. Two documentation fetches claimed
//      it did not exist; both were wrong. Observe the payload, do not trust a secondary source.)
//
//   4. BREAKER LOWERED 8 -> 3, which is only safe because of (3): the streak now counts
//      consecutive re-entries with no intervening work, so three is already pathological. Note
//      that Claude Code may impose its own absolute ceiling on consecutive blocks — that has been
//      asserted to me twice and I have not verified it. Nothing here depends on it either way.

import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

// Its OWN directory, deliberately. This file uses a leaner schema than the full implementation, so
// sharing a store with it would make each read the other's files as corrupt — and "corrupt" fails
// CLOSED, which means a stray test file can block real sessions. Learned the hard way: testing this
// script polluted the full system's store and produced exactly that. If you run both, they must not
// share a directory. (Node uses USERPROFILE on Windows, so setting HOME in a test shell does NOT
// redirect os.homedir() — override SESSION_CONTRACT_DIR instead.)
const DIR = process.env.SESSION_CONTRACT_DIR
  || path.join(os.homedir(), '.claude', 'session-contracts-minimal');
const BREAKER = 3;   // consecutive RE-ENTRIES (not raw blocks) before degrading to advisory
const OFF = process.env.SESSION_CONTRACT === '0';

// ── arming vocabulary ────────────────────────────────────────────────────────────
// Use the words you ALREADY type to correct this by hand. A new incantation is one more thing to
// remember at exactly the moment you forgot the first one.
const ARM = [
  // Conditions are BOUNDED to one line / 200 chars. Unbounded `(.+)/is` swallows to the end of the
  // prompt across newlines — a live session once pinned a 364-character blob as its "condition",
  // which then rendered verbatim in every block message.
  /\b(?:don'?t|do not)\s+stop\s+until\s+(?<cond>[^\n]{1,200})/i,
  /\bkeep\s+going\s+until\s+(?<cond>[^\n]{1,200})/i,
  /\bcontinue\s+until\s+(?<cond>[^\n]{1,200})/i,
  /\b(?:don'?t|do not)\s+stop\b/i, /\bkeep\s+going\b/i, /\bkeep\s+at\s+it\b/i,
  /\bsee\s+it\s+through\b/i, /\bget\s+it\s+all\s+done\b/i, /\bno\s+stopping\b/i,
];
const DISARM = [/^\s*stop\s*[.!]?\s*$/i, /\b(?:that'?s\s+enough|stand\s+down)\b/i];

// MENTION vs USE. Talking about the mechanism must not trigger it: "is don't stop an arming
// phrase", "I told the other session to keep going". Costly lesson — it fired three times.
function isMention(text, i, len) {
  const near = text.slice(Math.max(0, i - 24), i);
  if (/(?:\b(?:the|a|an|this|that|your)\s+|["'`])$/i.test(near)) return true;
  if (/\b(?:is|are|was|does|do|did)\s+$/i.test(near)) return true;
  const after = text.slice(i + len, i + len + 48);
  if (/^\W*\b(?:an?|the)\s+(?:\w+\s+){0,2}(?:phrase|trigger|keyword|command|rule|lock)\b/i.test(after)) return true;
  const clause = text.slice(0, i).split(/[.!?\n]/).pop();       // sentence-scoped
  return /\b(?:told|tell|asked|ask|said|say|instructed)\b/i.test(clause);
}

// ── stop-message rules ───────────────────────────────────────────────────────────
// NOT "does it end in a question mark" — that misses the real failure, which has no question mark.
// R1 is a CONTRADICTION: the message asserts continuation, the act is stopping.
const RULES = [
  // A count by itself is LOW confidence. It becomes a blocker only when its sentence also asserts
  // unresolved work; otherwise it is logged as an advisory near-miss. This avoids training honest
  // completion reports to hide counts merely because their disposition uses novel wording.
  // "remains" is ambiguous: "2 items remain" is unfinished work; "X remains the better option" is a
  // copula that happens to sit near a number. A determiner or comparative after the verb marks the
  // copula reading, so exclude it. Bare/plural forms are unaffected.
  ['R1_remaining', /\b\d[\d,]*\s+(?:\S+\s+){0,3}(?:remain(?:s|ing)?|left|to\s+go|outstanding)\b(?!\s+(?:the|a|an|my|our|your|its|his|her|their|one|more|less|better|worse|best|worst)\b)/i],
  // Left-context bound: a bare word-match is not an assertion test. "...That Said Continuing" is a
  // title; "Continuing down the list" is a claim. Must open a clause or follow a progressive marker.
  ['R1_continuing', /(?:^|[.!?;:\n]\s*|\b(?:I'?m|we'?re|am|is|are|be|been|still|now|and|then)\s+)(?:continuing|proceeding|carrying\s+on)\b/i],
  ['R1_next', /\b(?:next\s+up|moving\s+on\s+to)\b/i],
  ['R1_i_will', /\bI'?ll\s+(?:keep|continue|now|start|begin|proceed)\b/i],
  // Narrowed: the SPEAKER promising unfinished work, not third-person status. "The retry loop is
  // running against staging now, and green" is a completion report. Sentence-initial bare gerund
  // counts as first-person by ellipsis ("Building phases 1-3 now" = "I am building…").
  ['R1_doing_now', /(?:(?:^|[.!?;\n]\s*)(?:building|running|writing|starting|working\s+on|implementing)\b|\b(?:I'?m|I\s+am|we'?re|we\s+are)\s+(?:now\s+)?(?:building|running|writing|starting|working\s+on|implementing)\b)[^.!?\n]{0,60}\bnow\b/i],
  ['R2_want_me_to', /\b(?:want|would\s+you\s+like)\s+me\s+to\b/i],
  ['R2_should_i', /\b(?:should|shall)\s+I\b/i],
  ['R2_your_call', /\byour\s+call\b|(?:^|[.!?;\n]\s*)which\s+way(?:\s*(?:\?|$)|\s+(?:should|shall|do|would|can)\s+(?:I|we|you)\b)/i],
  ['R2_capacity', /\bpractical\s+range\b|\b(?:near|at|approaching|reached?|hitting|running\s+(?:low|out))\s+(?:the\s+)?(?:context|token)\s+(?:budget|window|limit)\b|\b(?:context|token)\s+(?:budget|window|limit)\s+(?:is|are|feels?|looks?)\s+(?:low|nearly|almost|close|exhausted|spent)\b/i],
];

// Fold typographic quotes so quote style cannot change a verdict.
const normQuotes = t => String(t).replace(/[“”„‟″‶]/g, '"').replace(/[‘’‚‛′‵]/g, "'");

// Run-up that marks a following quoted span as a MENTION rather than an assertion.
const MENTION_LEAD =
  /\b(?:flags?|fires?(?:\s+on)?|matches?|catches?|detects?|blocks?|triggers?|rules?|classifier|detector|pattern|phrase|wording|string|example|e\.?g\.?|such\s+as|like|quoted?|quotes?|says?|said|saying|wrote|written|reported|told|asked|instructed|called|named|labell?ed)\b[^.!?\n]{0,40}$/i;

// Known disposition words remain useful evidence, but they are NOT a closed vocabulary and no
// longer carry the release decision by themselves. A bare count is advisory unless the same
// sentence contains an explicit unresolved-work cue.
const DISPOSITION =
  /\b(?:out\s+of\s+scope|not\s+in\s+scope|de-?scoped|carved|deferred?|deferring|logged\s+as|recorded\s+as|follow[-\s]?ups?|tracked\s+(?:separately|in|as)|backlog|next\s+session|externally\s+blocked|blocked\s+(?:on|by)|assigned\s+to|owned\s+by|waived|won'?t\s+fix|by\s+design|generated\s+artifacts?|covered\s+by|intentionally\s+(?:left|unchanged|retained))\b/i;
const UNRESOLVED_REMAINDER =
  /\b(?:still|yet|unfinished|unresolved|pending|incomplete|not\s+(?:done|complete|completed|fixed|resolved|covered|handled)|need(?:s)?\s+to|must|have\s+to|left\s+to\s+(?:do|fix|finish|complete|address)|to\s+be\s+(?:done|fixed|finished|completed|addressed|handled)|work\s+remains?)\b/i;

// Strip DISPLAY spans before classification.
// Fenced blocks, inline code, blockquotes and filenames are structurally display — no heuristic
// needed. A quoted span is stripped ONLY when its run-up marks it as a mention; otherwise the
// content is kept and classified. Stripping every quoted span (the previous behaviour) made
// quotation marks a blanket exemption, so "12 cells remain. Should I continue?" evaded entirely.
// This is a heuristic. The structural load is carried by the open-items check, not by this.
// ELIDED, not ' '. Substituting a bare space removes content AND DISTANCE, and these rules measure
// proximity in words. Eliding a span silently pulls the tokens either side of it together, which
// blocked a real turn on 2026-07-29: "(6 `CONVERGED` audits), so Converge remains the better
// subject" matched, while the identical sentence without backticks did not. A sentinel occupies the
// slot the span occupied, so eliding cannot manufacture a match.
const ELIDED = ' · ';

const stripDisplay = (t) => {
  const s = normQuotes(t)
    .replace(/```[\s\S]*?```/g, ELIDED).replace(/`[^`\n]*`/g, ELIDED)
    .replace(/^\s{0,3}>.*$/gm, ELIDED)
    .replace(/\S+\.(?:docx?|mjs|cjs|jsx?|tsx?|json|ya?ml|md|txt|html?|pdf|png|jpe?g|svg|sql|sh|ps1|py)\b/gi, ELIDED);
  return s.replace(/"([^"\n]*)"/g, (whole, inner, idx) =>
    MENTION_LEAD.test(s.slice(Math.max(0, idx - 80), idx)) ? ELIDED : inner);
};

// POLICY — stated as fact, not as instruction. See HARDENING PASS note (1) at the top of the file.
const POLICY = `Decision policy in force for this session (operator-configured, applies to forks
that arise mid-contract):
  1. A question already answered by project docs or earlier in this session is treated as answered.
  2. Where a documented process prescribes the next step, the prescribed step is the decision.
  3. Newly discovered work is scope, not a question. Items blocking the completion condition, or
     that are the root cause of work underway, are handled; adjacent findings are noted as
     follow-ups.
  4. Path choices resolve to the option that addresses the root cause and defers no correctness.
     Time cost does not count as a counter-argument.
  5. Where two paths are equivalent under that rule, the choice is the evaluator's and does not
     warrant escalation.
  6. Resolved forks are recorded in one line: the fork, the rule applied, the path, the reason.
  7. The operator is interrupted for one class only — where being wrong is unrecoverable: an
     irreversible live operation, a genuinely missing requirement, or an unresolvable blocker.`;

/**
 * Factual state description. TWO forms: full on a state change (arm, or SessionStart restore),
 * terse every turn after — the policy is already in context by then, and repeating ~2KB of it each
 * turn is noise that crowds out the conversation. Announce transitions, not steady state.
 */
const brief = (c) => [
  `[WORK-CONTRACT — ACTIVE]  condition: ${c.cond || '(none pinned)'}  ·  turn-end gated until met`,
  '  The decision policy stated at arm-time remains in force.',
].join('\n');

const describe = (c) => [
  '[SESSION WORK-CONTRACT — ACTIVE]',
  `  Completion condition, as the operator stated it: ${c.cond || '(none pinned)'}`,
  '',
  '  Boundary behaviour: a Stop hook is configured for this session. While the condition is unmet',
  '  it returns exit 2 at turn-end, which the harness treats as "continue" rather than ending the',
  '  turn. It clears when the condition is met or the operator says "stop".',
  '',
  POLICY,
].join('\n');

// ── store ────────────────────────────────────────────────────────────────────────
const safeId = id => (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id.trim()))
  ? id.trim().toLowerCase() : null;                 // lowercase: case-insensitive filesystems alias

function file(id) {
  const s = safeId(id); if (!s) return null;
  const p = path.resolve(DIR, `${s}.json`);
  return p.startsWith(path.resolve(DIR) + path.sep) ? p : null;   // containment before any I/O
}
function load(id) {
  const f = file(id); if (!f) return { state: 'missing' };
  try { return { state: 'ok', c: JSON.parse(fs.readFileSync(f, 'utf8')) }; }
  catch (e) {
    if (e instanceof SyntaxError) return { state: 'malformed' };
    if (e.code === 'ENOENT') {
      // POSIX reports ENOTDIR when a path component is an existing non-directory;
      // Windows reports ENOENT. Confirm the store itself before calling it absent.
      try { if (!fs.statSync(DIR).isDirectory()) return { state: 'infra' }; } catch { /* genuinely absent */ }
      return { state: 'missing' };
    }
    return { state: 'infra' };
  }
}
function save(c) {
  const f = file(c.id); if (!f) return false;
  try {
    fs.mkdirSync(DIR, { recursive: true });
    const t = `${f}.${process.pid}.tmp`;
    fs.writeFileSync(t, JSON.stringify(c, null, 2));
    fs.renameSync(t, f);                            // atomic: a reader never sees a torn file
    return true;
  } catch { return false; }
}

const stdin = () => new Promise(r => {
  let d = ''; const done = v => r(v); const t = setTimeout(() => done(null), 250);
  process.stdin.setEncoding('utf8');
  process.stdin.on('data', c => { d += c; });
  process.stdin.on('end', () => { clearTimeout(t); try { done(JSON.parse(d)); } catch { done(null); } });
  process.stdin.on('error', () => { clearTimeout(t); done(null); });
});

// ── UserPromptSubmit: arm / disarm / re-inject ───────────────────────────────────
function onPrompt(p) {
  const text = typeof p.prompt === 'string' ? p.prompt : '';
  const got = load(p.session_id);
  const c = got.state === 'ok' ? got.c
    : { id: safeId(p.session_id), armed: false, cond: null, blocks: 0, streak: 0 };

  if (DISARM.some(r => r.test(text))) {
    if (c.armed) { c.armed = false; save(c); return { systemMessage: '🔓 Work-contract disarmed.' }; }
    return {};
  }
  for (const re of ARM) {
    const m = text.match(re);
    if (!m || (m.index != null && isMention(text, m.index, m[0].length))) continue;
    c.armed = true; c.streak = 0;
    c.cond = (m.groups?.cond ?? text.match(/\buntil\s+([^\n]{1,200})/i)?.[1] ?? c.cond)?.trim() || null;
    if (!save(c)) return { systemMessage: '⚠ Work-contract FAILED TO ARM — this session is NOT gated.' };
    // systemMessage is the operator's confirmation. No acknowledgement is asked of the model —
    // if the client does not render this, `--all`-style state inspection is the right fix, not a
    // behavioural instruction. See HARDENING PASS note (1).
    return {
      systemMessage: `🔒 Work-contract armed — ${c.cond ? `will not stop until: "${c.cond}"` : 'no condition pinned'}`,
      hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: describe(c) },
    };
  }
  if (c.armed) {                                     // steady state -> terse
    return { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: brief(c) } };
  }
  return {};
}

// ── SessionStart: restore the description after a resume, fork, or compaction ────
// Read-only. The contract survives on disk, but nothing re-describes it until the next prompt —
// leaving one turn where an armed session does not know a contract is active. Matters most after
// compaction, when the earlier context is gone.
function onSessionStart(p) {
  const got = load(p.session_id);
  if (got.state !== 'ok' || !got.c.armed) return {};
  const src = String(p.source || 'startup').toUpperCase();
  return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext:
    `[SESSION WORK-CONTRACT — RESTORED ON ${src}]\n` +
    '  A contract recorded earlier in this session is still active. Current state:\n\n' +
    describe(got.c) } };
}

// ── Stop: hold the turn ──────────────────────────────────────────────────────────
function onStop(p) {
  const got = load(p.session_id);
  if (got.state === 'missing') return { exit: 0 };
  if (got.state === 'infra') return { exit: 0, out: { systemMessage: 'work-contract store unreadable — gate failed OPEN.' } };
  if (got.state === 'malformed') return { exit: 2, err: 'work-contract is malformed — fail-closed. Delete the file to clear.' };

  const c = got.c;
  if (!c.armed) return { exit: 0 };

  // stop_hook_active: TRUE = re-entry after a previous block; FALSE = fresh turn-end (work happened
  // in between). NEVER a release condition — releasing on it lets a session escape by stopping
  // twice. It makes the streak count genuine stalls. See HARDENING PASS note (3).
  const reentry = p.stop_hook_active === true;

  // The breaker measures CONSECUTIVE re-entries. A fresh turn-end means the session went back and
  // did work, which breaks the run — so the stored streak does not count toward the breaker on a
  // fresh stop. Without this the gate degrades permanently after one stall and never re-engages,
  // which is a silent failure: it looks armed and enforces nothing.
  const effectiveStreak = reentry ? (c.streak || 0) : 0;

  if (effectiveStreak >= BREAKER) {
    return { exit: 0, out: { systemMessage: `work-contract: ${effectiveStreak} consecutive re-entries with no progress — degraded to advisory so it cannot wedge you.` } };
  }

  const msg = typeof p.last_assistant_message === 'string' ? p.last_assistant_message : '';
  const asserted = stripDisplay(msg);
  // Sentence-scoped for the remaining-count rule. A numerical remainder is a near-miss unless its
  // own sentence explicitly says the work is unresolved. Direct continuation and handback rules
  // remain blockers regardless of the count rule.
  const sentences = asserted.split(/(?<=[.!?;])\s+/);
  const advisory = [];
  const hits = RULES.filter(([id, re]) => {
    if (!re.test(asserted)) return false;
    if (id !== 'R1_remaining') return true;
    const countSentences = sentences.filter(s => re.test(s));
    const blocking = countSentences.some(s => UNRESOLVED_REMAINDER.test(s) && !DISPOSITION.test(s));
    if (!blocking && countSentences.length) advisory.push(id);
    return blocking;
  }).map(([id]) => id);
  if (hits.length && advisory.includes('R1_remaining')) {
    hits.unshift('R1_remaining');
    advisory.splice(advisory.indexOf('R1_remaining'), 1);
  }
  if (!hits.length) {
    if (advisory.length) {
      c.advisories = (c.advisories || 0) + 1;
      c.lastAdvisoryRules = advisory;
      c.lastAdvisoryDigest = crypto.createHash('sha256').update(msg).digest('hex').slice(0, 16);
      save(c);                                       // digest + rule ids only — never the prose
    }
    return { exit: 0 };
  }

  c.blocks = (c.blocks || 0) + 1;
  c.streak = effectiveStreak + 1;
  c.lastRules = hits;
  c.lastDigest = crypto.createHash('sha256').update(msg).digest('hex').slice(0, 16);
  save(c);                                           // digest + rule ids only — never the prose

  // Stated as observation, not command — same reasoning as the injected context.
  return { exit: 2, err:
`SESSION WORK-CONTRACT — turn-end held.
  The turn-ending message asserts remaining work or continuation: ${hits.join(', ')}
  A message that states work is continuing, immediately before the turn ends, is inconsistent
  with itself. That inconsistency is the whole trigger.

Completion condition, as the operator stated it: ${c.cond || '(none pinned)'}

${POLICY}

This clears when the condition is met, or when the operator says "stop".
(consecutive re-entries: ${c.streak}/${BREAKER} before this degrades to advisory)` };
}

// ── entry ────────────────────────────────────────────────────────────────────────
const p = (await stdin()) || {};
if (OFF || !p.session_id) process.exit(0);

if (p.hook_event_name === 'SessionStart') {
  let out = {};
  try { out = onSessionStart(p); } catch { /* never blocks */ }
  if (Object.keys(out).length) process.stdout.write(JSON.stringify(out) + '\n');
  process.exit(0);
} else if (p.hook_event_name === 'Stop') {
  const r = onStop(p);
  if (r.out) process.stdout.write(JSON.stringify(r.out) + '\n');
  if (r.err) process.stderr.write(r.err + '\n');
  process.exit(r.exit);
} else {
  let out = {};
  try { out = onPrompt(p); } catch { /* a recorder must never block */ }
  if (Object.keys(out).length) process.stdout.write(JSON.stringify(out) + '\n');
  process.exit(0);
}

Source materials

The article and public implementation are generated from the FoundryOS repository sources:

  • Article generator:

system/_operator/docs/adoption-guide/linkedin/build-article.mjs

  • Canonical minimal implementation:

system/_operator/docs/adoption-guide/minimal/session-contract-minimal.mjs

  • Smoke test:

system/_operator/docs/adoption-guide/minimal/session-contract-smoke-test.mjs

  • Infographic design source:

system/_operator/docs/adoption-guide/linkedin/infographic-design-prompt.md

  • Package provenance:

system/_operator/docs/adoption-guide/linkedin/README.txt

The full private FoundryOS implementation includes more machinery than the one-file public version, including item tracking, evidence-backed completion, escalation classes and a durable audit trail.

The public file is intentionally narrower.

Its purpose is to expose the useful mechanism without pretending to publish the entire operating system around it.

About FoundryOS

FoundryOS builds custom operational software around the workflows a business actually runs.

  • Custom-built: a platform built around the organization’s own processes.
  • Ships in weeks: a working operational system without a traditional year-long software programme.
  • AI-embedded: automation is integrated into the operating workflow rather than added as a disconnected chatbot.
  • One accountable team: strategy, implementation, infrastructure, maintenance and support remain connected.

FoundryOS builds the platform your business runs on.

Get the code ↓