ponder

Ponder — Specification v1

Ponder is a convergent deliberation loop for coding agents. An agent runs repeated passes over a subject. Each pass revises the previous pass’s output — adding what was missed, editing what was vague, re-grading what was mis-scored, deleting what was noise — until further passes stop changing the answer. The agent decides how many passes to run; the engine measures whether stopping is honest and refuses to let a pass be silently skipped.

This document is the contract. bin/ponder, oca/bridge.js, the lens packs, the skill, and the conformance suite are all written against it. Where an implementation and this document disagree, this document wins.


1. Vocabulary

Term Meaning
session One pondering of one subject through one lens. Lives in .ponder/<slug>/.
subject What is being pondered. Free text: "the UI", "our error handling".
lens The rubric pack that defines how to enumerate and grade. ui, architecture, docs, tests, product, copy, custom.
unit One gradable thing inside the subject. For ui, one screen/route/view.
finding One graded judgment about one unit. The atom that passes revise.
pass One full cycle: predict → survey → appraise → deliberate → revise → metacognize → judge → decide.
delta One revision operation applied to the finding set (add, edit, change, delete) or to the inventory (unit-add, unit-edit, unit-delete).
verdict The engine’s convergence advice for the pass: continue, stop, escalate, blocked.
decision The agent’s committed choice, which may override the advice, plus a written reason.

2. The pass shape (mirrors OCA layers)

Ponder’s loop is the Oneiro Cognitive Architecture’s loop, applied to a subject instead of to a machine’s own existence. Each phase names the OCA layer it borrows from.

Phase OCA layer What happens
predict L5 Hypothesis Before working, the agent states what this pass will find: expected churn (0–1) and a one-line claim. Recorded, then scored against reality. Falling surprise means the loop now models itself.
survey L1 Sensory Enumerate the units. Pass 1 builds the inventory; later passes may amend it (an amendment is itself a delta).
appraise L5 Hypothesis Grade each unit against the lens rubric. Pass 1 creates findings; later passes revise them.
deliberate L8 Adversarial Deliberation Four perspectives argue over the current finding set.
revise L10 Executive Control Apply deltas. Every op appends to the ledger, forever.
metacognize L7 Metacognition Measure this pass against history: novelty, churn, thrash, surprise, stall, coverage. Detect stuck and perseveration.
judge L7 + L10 Engine computes advice. Agent commits a decision with a reason.

2.1 The four perspectives are the four delta operations

This is the load-bearing idea. OCA’s deliberation engine runs Skeptic, Builder, Dreamer and Empath over every decision. Mapped onto a finding set, each perspective naturally produces exactly one kind of revision:

Perspective OCA loss function Asks Emits
Skeptic minimise false positives “What here is unsupported, duplicated, or just wrong?” delete
Builder maximise shipped output “Is this actionable? What’s the concrete next step?” edit
Dreamer maximise novelty “What did everyone miss? What if the frame is wrong?” add
Empath maximise social accuracy “Does this serve the person using it? Is it ranked right?” change

A pass that produces only add is a pass that never argued with itself. A healthy pass shows all four voices.

3. State layout

Sessions live under <project>/.ponder/<slug>/, or ~/.ponder/<slug>/ when the project path is not a directory the agent can write to.

.ponder/<slug>/
  session.json          identity, config, status, pass history
  units.json            the inventory
  findings.json         the live finding set (current truth)
  ledger.jsonl          append-only; every delta ever applied
  passes/NN/
    predict.json        the pass's own prediction, written before work
    deliberation.json   the four perspectives' arguments
    deltas.jsonl        the deltas this pass applied
    metrics.json        computed convergence signals
    verdict.json        engine advice + agent decision + reason
  report.md             generated by `ponder report`

3.1 session.json

{
  "version": 1,
  "slug": "ui-2026-08-24",
  "subject": "the UI",
  "lens": "ui",
  "project": "/abs/path",
  "projectFrom": { "how": "marker", "marker": ".git", "cwd": "/abs/path/src/routes" },
  "maxPasses": 6,
  "minPasses": 2,
  "createdAt": "2026-08-24T15:00:00.000Z",
  "updatedAt": "2026-08-24T15:42:00.000Z",
  "status": "open",
  "currentPass": 3,
  "phase": "deliberate",
  "oca": { "enabled": true, "reachable": true, "sessionRef": 12345 },
  "history": [
    { "pass": 1, "verdict": "continue", "decision": "continue", "reason": "coverage 0.6" }
  ]
}

status: open | converged | stopped | aborted | applied. phase: the next phase the agent owes. Advanced by the command that satisfies it.

The project root, and how it was decided

project is the directory every relative path in the session resolves against — every unit path, every finding evidence anchor, and every phantom claim checked against one (3.5). A wrong root does not raise an error: it makes every path check quietly return “not found”, which is the wrong answer in the direction that lets a session stop early.

ponder start --project <path> sets it outright. Otherwise the engine resolves it from the working directory in this order, and the order is the contract:

  1. Up the tree for a project marker. .git, .hg, .svn, package.json, go.mod, Cargo.toml, pyproject.toml, deno.json, deno.jsonc, Gemfile, pom.xml, build.gradle, build.gradle.kts, composer.json, mix.exs, or a directory named *.xcodeproj / *.xcworkspace. The nearest directory holding one is the project root, full stop.
  2. Only if no marker exists anywhere up the tree, up the tree again for an existing .ponder. With no project to anchor to, adopting a session store that is already there is the most useful thing available — but it is a guess.
  3. Otherwise the working directory.

.ponder is deliberately not in step 1. .git defines what the project is; .ponder is only where its sessions are kept, and it must never outrank the thing it belongs to. A single walk over both kinds of marker lets an unrelated ancestor .ponder capture any directory with no markers of its own — which is exactly where scratch work happens.

projectFrom records the answer to “how do we know?”, so a session can be inherited without re-deriving it:

how Meaning marker
explicit --project was given null
marker a project marker decided it the marker’s name, e.g. .git
ponder no marker anywhere; an existing .ponder was adopted .ponder
cwd nothing was found; the working directory is the root null

cwd also carries the directory the resolving command was run in.

A root is established when how is explicit or marker, and guessed when it is ponder or cwd. A guessed root makes every path miss ambiguous, which is why 3.5 refuses a phantom claim under one. ponder start, ponder status and ponder doctor all print the root and what decided it; start and status warn when it was guessed, and doctor reports an adopted ancestor .ponder as a problem.

A session written before this field existed does not carry it, and is never refused or warned about for that. Its origin is re-derived from the recorded root: a root that holds a project marker was a sound root however it was found, and one that does not was a guess. The field is engine bookkeeping — the ledger (3.6) knows nothing about it, so its absence, presence or value cannot affect reconciliation.

There is exactly one mode. Ponder deliberates and produces a plan; ponder apply is the separate explicit step that turns the plan into work. A session written by an older build may still carry a mode field — it is ignored on load and never written back.

3.2 units.json

{
  "version": 1,
  "lens": "ui",
  "units": [
    { "id": "screen.settings.billing", "label": "Settings → Billing",
      "path": "src/routes/settings/billing.tsx", "kind": "screen",
      "meta": { "route": "/settings/billing" }, "addedInPass": 1 }
  ]
}

id is stable across passes and is the join key for findings. Required, unique, lowercase dotted slug. id is the one field nothing can change — a unit-edit that tries is rejected, exit 1, because changing it would orphan every finding on that unit (3.4). path optional but strongly preferred — it is the evidence anchor, and it is what lets the engine check a phantom claim (3.5).

label, path, kind and meta are all correctable by a later pass with a unit-edit delta. A unit whose caption went stale is fixed in place, on the record, rather than by a unit-delete plus a unit-add — that pair would drop coverage below 1.0 and block the stop, which turns a wrong caption into a reopened session.

A unit that has left the inventory keeps its entry and gains:

Field Meaning
removedInPass the pass whose unit-delete removed it
removalKind appraised | removed-ungraded | phantom — see 3.5
phantomVerification on a phantom only: verified-missing | unverified
phantomPath on a verified-missing phantom: the absolute path the engine checked

A session written before removal kinds existed carries only removedInPass. Its kind is inferred on read the same way the engine would decide it today — appraised if the unit carries any finding, removed-ungraded otherwise — so an old session cannot inherit an escape the current rules close.

3.3 findings.json

{
  "version": 1,
  "findings": [
    {
      "id": "f-0007",
      "unit": "screen.settings.billing",
      "title": "Empty state is a bare 'No invoices' string",
      "detail": "No illustration, no explanation of when invoices appear, no primary action.",
      "dimension": "empty-states",
      "score": 2,
      "severity": "must",
      "confidence": 0.8,
      "effort": "s",
      "evidence": ["src/routes/settings/billing.tsx:88-94"],
      "recommendation": "Add an empty state with cause, expectation and a primary action.",
      "status": "open",
      "createdInPass": 1,
      "lastTouchedPass": 3,
      "history": [{ "pass": 3, "op": "change", "field": "severity", "from": "should", "to": "must" }]
    }
  ]
}

3.4 deltas.jsonl / ledger.jsonl

One JSON object per line.

{"pass":3,"op":"delete","target":"f-0012","perspective":"skeptic","why":"Duplicate of f-0004.","at":"2026-08-24T15:40:00.000Z"}
{"pass":3,"op":"add","perspective":"dreamer","why":"No pass had looked at loading states as a set.","finding":{ "...": "a full finding object without id" },"at":"..."}
{"pass":3,"op":"edit","target":"f-0007","perspective":"builder","why":"Recommendation was not actionable.","set":{"recommendation":"..."},"at":"..."}
{"pass":3,"op":"change","target":"f-0009","perspective":"empath","why":"Blocks first-run, outranks polish.","set":{"severity":"must","score":2},"at":"..."}
{"pass":3,"op":"unit-add","perspective":"dreamer","why":"Route was missed in pass 1.","unit":{ "...": "a full unit object" },"at":"..."}
{"pass":3,"op":"unit-edit","target":"screen.marketing.footer","perspective":"builder","why":"Pass 1's label said both links 404; the finding under it now says the opposite, and says it correctly.","set":{"label":"Marketing footer — /privacy-policy resolves","meta":{"route":"/"}},"at":"..."}
{"pass":3,"op":"unit-delete","target":"screen.settings.profile","perspective":"skeptic","why":"Owned by another team; out of this subject.","removal":"appraised","at":"..."}
{"pass":3,"op":"unit-delete","target":"screen.auth.sso","phantom":true,"perspective":"skeptic","why":"Pass 1 read this route off a stale sitemap; the app has no SSO screen.","removal":"phantom","verification":"verified-missing","verifiedPath":"/abs/path/src/routes/auth/sso.tsx","at":"..."}
{"pass":1,"op":"unit-seed","source":"units","perspective":null,"why":"Pass 1 enumerated 14 screens as the coverage denominator.","ids":["screen.settings.billing","..."],"units":[{ "...": "each full unit object, as stored" }],"at":"..."}

Nothing enters a session’s state without a ledger line. That is what makes 3.6 possible, and it is why unit-seed exists: pass-1 findings have always arrived as add records tagged source: "grade", and every later inventory amendment is a unit-add, unit-edit or unit-delete delta, but pass 1’s inventory used to be written straight into units.json and nowhere else. An unledgered inventory is a coverage denominator taken on trust.

unit-seed is written by ponder units, once, on pass 1. It carries ids — the enumerated unit ids, in order — and units, their full bodies exactly as units.json stores them. It is a seed, not a delta: it is not one of the seven delta ops, no delta payload may contain it, and it is excluded from every convergence ratio and from the delta counts in metrics.json and report.md, the same way source: "grade" seeding is.

Rules:

3.5 unit-delete and the phantom claim

unit-delete is the one operation that can shrink the coverage denominator, which makes it the closest thing in the system to a coverage override. It is governed accordingly.

Every unit-delete needs a non-empty why, like every other delta. What it costs beyond that depends on whether the unit was ever appraised. Appraised means the unit carries at least one finding — a retracted one counts, because withdrawing a judgment does not un-see the thing it judged.

The unit The delta carries What happens Coverage denominator
has ≥ 1 finding no phantom removalKind: "appraised" shrinks — an honest scope change
has ≥ 1 finding "phantom": true rejected, exit 1 — it was appraised, so it existed unchanged
has no findings no phantom removalKind: "removed-ungraded" unchanged — the unit still counts
has no findings "phantom": true the engine verifies the claim (below) shrinks once verified

"phantom": true means: this unit does not exist in the software; pass 1 enumerated it wrongly. It is a claim about the world, so the engine checks it rather than trusting it — and it checks it against every path the unit has ever carried, not just the one units.json holds now.

That distinction is load-bearing. unit-edit can move a unit’s path (3.4), so a claim checked only against the current value would be a two-delta route through the coverage gate: point a real unit at a filename that does not exist, then claim the unit never existed and let the engine certify the absence. So the candidate set is the original path from the unit’s unit-seed or unit-add ledger record, plus every value any unit-edit has set since, plus whatever units.json holds now — read off the ledger, resolved against session.project, deduplicated. A path set earlier in the same delta batch counts too.

The unit’s paths Result
any of them exists the whole delta batch is rejected, exit 1. The message names the path that was found and the pass that set it. Something you can open is not something pass 1 imagined.
exactly one, never edited, and it does not exist accepted · phantomVerification: "verified-missing", phantomPath = the path checked
the path was ever edited, and no version of it exists accepted · phantomVerification: "unverified" — never verified-missing. Every version was checked and every one was missing, but the engine will not certify an absence about a target that moved: “missing” and “never existed” are only the same claim when the target stayed still. The ledger record carries pathsChecked so the report can say exactly what was looked for.
the unit never had a path accepted · phantomVerification: "unverified" — the claim could not be checked at all

Editing a path is therefore never a way to strengthen a phantom claim: at best it leaves the claim where it would have been with no path at all, and at worst it is refused outright.

Those rows assume the paths were resolved against a project root that was established — --project, or a project marker (3.1). Under a guessed root, a unit with at least one path and no path that resolves is a different situation entirely: nothing was really checked. The claim is refused, exit 1, and the refusal names the resolved root, says it was guessed rather than established, and names --project as the fix. This is not a judgment about the agent’s honesty — it is that a file missing from the wrong directory is not evidence about a screen. A unit that never had a path is unaffected: there was nothing for a root to resolve, so it is still accepted as unverified.

That closes the gap this rule exists to close. path verification is what makes a phantom claim a check rather than an assertion, so a resolution that silently misses every path turns verification into a rubber stamp — coverage 1.0 and a permitted stop, bought by a root the user never chose and was never shown.

Because “appraised” is defined as carrying at least one finding, the engine re-derives it rather than believing it. removalKind is read back off units.json, and both of the kinds that shrink the denominator are read from there, so a stored appraised on a unit with no findings contradicts its own definition. When the field and the definition disagree the definition wins and the unit stays in the denominator — whether the disagreement arrived by a hand-edit or by a bug. That re-derivation is the inner defence and it is deliberately kept: reconciliation (3.6) catches a forged removalKind outright and refuses the command, but if it ever failed to, the denominator would still not shrink.

phantom is legal on unit-delete only, and only on a unit with no findings: a unit that was appraised cannot also be one that never existed. A removed-ungraded unit may be deleted a second time with "phantom": true — that is the upgrade path from “I dropped it” to “I checked, and it is not there.” No other repeat delete is legal.

Every accepted phantom is listed permanently in report.md under Phantom units with its why, its pass and its verification result. An unverified phantom is marked distinctly there, because it is the one claim nobody checked.

The point: dropping thirty screens you never looked at costs thirty explicit, individually justified, permanently reported phantom claims that the engine checks wherever it can. It is not free and it is not silent.

3.6 Reconciliation — the ledger is the authority

Everything in 3.5 governs what the engine will do. None of it governs a text editor. units.json and findings.json are ordinary files, and an agent that wants past the coverage gate can write to them directly: forge removalKind: "phantom" onto a unit nobody graded, delete a unit’s entry outright, invent a finding, flip a status from open to resolved. Each of those is a lie the engine would previously have believed, because it read the gate’s inputs out of the very files the lie was written into.

ledger.jsonl is append-only and records every operation the session has ever applied (3.4). So the honest state is not something to be trusted — it is something to be recomputed.

Reconciliation replays ledger.jsonl from line 1, derives the state those records must have produced, and compares it against what units.json and findings.json actually contain. Any disagreement is a divergence, reported with the id, the field, what the ledger says and what the file says.

It then checks the ledger against itself. ledger.jsonl is the ordered union of the per-pass deltas.jsonl files (3.4) — ponder delta, ponder grade and ponder units write both copies in one call — so the per-pass files are an independent second copy of every record. Reconciliation compares them record for record, in order, grouped by pass. That is what catches the forgeries a replay alone cannot see, because they add no contradiction to the ledger:

What the ledger pins, and is therefore reconciled:

Document Reconciled
findings.json which ids exist; and per finding unit, title, detail, dimension, score, severity, confidence, effort, evidence, recommendation, status, createdInPass
units.json which ids exist; and per unit label, path, kind, meta, addedInPass, removedInPass, removalKind, phantomVerification, phantomPath

Every one of those is determined: an add record carries the whole finding body after normalisation, every edit / change carries the exact set that was applied, delete means status: "retracted", unit-seed and unit-add carry unit bodies as stored, unit-edit carries the exact set it applied — replayed with the same key-by-key meta merge the engine used, so a corrected route derives identically — and unit-delete carries the removal kind and the phantom verification.

Because label, path, kind and meta are all replayed from unit-edit records, a label changed by hand in units.json with no matching ledger record is a divergence and is refused exactly like a forged removalKind. Correcting a caption is cheap; correcting one off the record is not possible. path is in the list because a phantom claim is checked against it — a unit that quietly loses its path is a unit whose phantom claim nobody can check.

Deliberately not reconciled: lastTouchedPass and a finding’s history. The ledger implies both, but they are derived bookkeeping — a disagreement there cannot buy anyone coverage, a finding, or a line in the apply plan, and pinning derived presentation is how a reconciler starts producing false alarms. Timestamps are not reconciled either. Neither is session.json: the ledger records operations on units and findings, and it does not record the phase machine.

Enforcement. judge, decide, report and apply reconcile before they act, and a divergence is exit 2 naming what diverged. Those four are the commands that turn state into a claim — a verdict, a decision, a document, a work plan. The rest of the loop does not pay the cost: predict, units, grade, delta, status and list run without it.

ponder verify [--repair] [--session <slug>]

verify runs the reconciliation on demand and reports: exit 0 and a summary when the session reconciles, exit 2 and the full diagnosis when it does not. It is the command to run first on a session you inherited. ponder doctor reconciles every session it can see and lists any that fail.

Write ordering, and the crash window it leaves. Every command that changes state appends to ledger.jsonl first and writes units.json, findings.json and passes/NN/deltas.jsonl after. The order is not arbitrary and it is not symmetric, because the ledger is the authority:

So the ordering makes the only reachable crash state the recoverable one, and the engine is never in a position where a torn write costs a pass’s work.

ponder verify --repair repairs a ledger-ahead session. It replays ledger.jsonl, rewrites units.json and findings.json to the state those records must have produced, and appends to any passes/NN/deltas.jsonl that is a prefix of what the ledger holds for that pass. It then re-reconciles and reports, line by line, exactly what it rebuilt. A repaired session is byte-identical to the one the interrupted command would have written.

--repair refuses a state-ahead session, and that stays a hard error, exit 2:

What --repair found What it does
a unit or finding the ledger created that the state file lacks, or a field whose stored value is behind the ledger’s rebuilds it
a passes/NN/deltas.jsonl holding a strict prefix of the ledger’s records for that pass appends the missing tail
a unit or finding in a state file that no ledger record ever created refuses — the data exists in no other copy, so nothing can rebuild it. Forgery or unrecoverable loss; both are a hard error
a duplicated or unidentified entry in a state file refuses — same reason
a passes/NN/deltas.jsonl holding a record the ledger does not, or a different record at the same position refuses — that is two copies of an append-only log disagreeing, not one lagging
a problem inside ledger.jsonl itself, or a line of it that will not parse refuses — repair rebuilds from the ledger, so a broken ledger is nothing to rebuild from

Without --repair, verify never writes anything.

What this guarantees. The ledger is the authority; the state files are a view of it. A forged state file is detected rather than believed, and every command that would draw a conclusion from it refuses.

What it does not guarantee. It is not a signature and not a hash chain. Someone who edits every copy consistently — the state file, ledger.jsonl, and the matching passes/NN/deltas.jsonl, at the right position — defeats it, and nothing in ponder stops them. That is outside the threat model, and it is stated here rather than implied away. What reconciliation does is raise the cost of a dishonest stop: from one edit to a state file, to a consistent forgery across three files, one of which is an append-only log that report.md quotes and the OCA may already have read.

Two boundaries are worth naming explicitly. session.json is not reconciled — the ledger records operations on units and findings, not the phase machine — so a hand-edited status or history is not detected by this mechanism, and --repair does not rebuild it either. And a session whose inventory was seeded before unit-seed existed has no ledger record for its units and will not reconcile; re-seed it rather than working around it.

4. Convergence signals

Computed by ponder judge for pass N and written to passes/NN/metrics.json.

Signal Formula Reads as
coverage graded units ÷ counted units Did we actually look at everything?
novelty revising add deltas ÷ live findings Still finding new ground?
churn revising deltas ÷ live findings How much moved this pass?
thrash revising deltas that reverse an earlier delta on the same target ÷ revising deltas Oscillating instead of converging?
surprise |predicted churn − actual churn| How well the loop models itself.
stall consecutive passes with churn < 0.02 Perseveration guard.
confidenceFloor fraction of open + must findings with confidence < 0.5 Are the important calls still guesses?
voices count of distinct perspectives in this pass’s deliberation.json Did the pass argue with itself?

Counted units. The coverage denominator is every unit still in scope: every live unit, plus every unit removed with removalKind: "removed-ungraded" (3.5). A unit leaves the denominator only by being appraised and then dropped, or by being an accepted phantom. Deleting what you never looked at does not improve coverage, and correcting a unit with a unit-edit does not move coverage at all — it is neither an addition nor a removal, so both sides of the ratio are exactly what they were.

Revising deltas. Pass 1’s ponder grade seeding is written to the ledger as add deltas tagged source: "grade" so that every finding has a creation record — the thrash detector reads creation values as the first setting of a field. Those deltas are the baseline, not a revision of one, so they are excluded from the numerator of every ratio above, in every pass. They stay on the ledger for provenance and are reported separately as deltas.seeded.

Pass 1 has no novelty, churn, thrash or surprise. All four measure change relative to a previous pass, and pass 1 has none. They are emitted as null in metrics.json and rendered as — in human output — never as 0.00, which would read as a healthy quiet pass, and never as a 1.00 computed against the pass’s own baseline. surprise follows churn: pass 1’s prediction is recorded and shown, and scored from pass 2 onward. No verdict rule may read a null signal; each threshold below is guarded on the signal actually being a number.

voices counts who spoke, not who emitted a delta. A perspective that argues “nothing to change here” has spoken and counts. Deltas keep their perspective attribution — that is what the ledger and the thrash detector read, and it is reported alongside as deltaVoices — but counting deltas made every genuine convergence look silent: rule 6 caps churn below 0.10, which on a small finding set permits at most one delta, which forces voices == 1.

A delta reverses an earlier delta when, on the same target, an earlier pass set a field to value X, a later pass set it to something else, and this pass sets it back to X — on a unit’s label, path, kind or meta via unit-edit exactly as on a finding’s fields, with the unit’s seeded or added values counting as the first setting, and meta compared on its merged value rather than on the partial object that was submitted; or when this pass adds a finding materially matching one an earlier pass deleted (same unit + same dimension), or deletes one an earlier pass added within the previous two passes.

4.1 Verdict rules, in order

Evaluate top to bottom; first match wins.

  1. coverage < 1.0 → blocked. Stopping is not permitted. The verdict names every ungraded unit, and names apart the ones that were deleted without ever being graded, saying why they still count (3.5). This is the anti-half-delivery gate and it has no override.
  2. pass < minPasses → continue. A single pass is an opinion, not a pondering.
  3. thrash > 0.25 → escalate. The loop is oscillating. Another identical pass will not help; rotate perspectives, widen evidence, or split the subject. Skipped when thrash is null. 3a. Evidence/review guard: any open must below 0.5 confidence or fewer than three perspectives → continue; if stalled, escalate; if the budget is exhausted, stop with reason budget. No quiet pass bypasses this guard.
  4. stall >= 2 and coverage == 1.0 → stop, reason perseveration. Nothing is moving. Further passes are theatre. stall only ever counts passes whose churn was a number, so a pass with no churn cannot contribute to it.
  5. confidenceFloor > 0 → continue. Too many important findings are still guesses.
  6. novelty < 0.05 and churn < 0.10, sustained for 2 consecutive passes → stop, reason converged. Both passes must have at least three voices and no low-confidence must.
  7. pass >= maxPasses → stop, reason budget. Reported as a budget stop, never as convergence.
  8. otherwise → continue.

4.2 The agent decides

ponder judge advises. ponder decide <continue|stop|escalate> --why "..." commits.

This is the whole point: the harness cannot silently quit, and the agent is never railroaded into a loop it can justify leaving.

5. CLI contract

All commands take --session <slug> (defaults to the most recently touched open session in the current project) and --json (machine-readable output on stdout). All commands exit 0 on success, 1 on user error, 2 on state-machine violation.

Every command prints a NEXT: line naming the phase the agent now owes. The engine drives the agent. That line is the mechanism that survives context compaction.

ponder start "<subject>" --lens <lens> [--rubric <file>] [--project <path>]
                         [--slug <slug>] [--max-passes N] [--min-passes N]
ponder units    --file <units.json>            # or --stdin. Pass 1 only; later passes use unit-add / unit-edit / unit-delete deltas.
ponder predict  --churn <0-1> --claim "<one line>"
ponder grade    --file <findings.json>         # pass 1 seeding of the finding set
ponder deliberate --file <deliberation.json>   # or --stdin, or --skip --why "<reason>"
ponder delta    --file <deltas.jsonl>          # or --stdin
ponder judge                                    # compute metrics, print advice
ponder decide   <continue|stop|escalate> --why "<reason>"
ponder status                                   # where am I, what do I owe
ponder verify   [--repair]                      # replay the ledger, check units.json and findings.json against it; --repair rebuilds a ledger-ahead session (3.6)
ponder report   [--out <path>]
ponder apply    [--dry-run] [--severity must,should]
ponder list
ponder abort    --why "<reason>"
ponder doctor                                   # environment + OCA reachability
ponder version                                  # print the engine version

ponder help lists every one of these, version included. A command that runs but is documented nowhere is a command nobody can find.

5.1 Phase enforcement

session.phase names what the agent owes next. Commands that arrive out of order exit 2 with a message naming the expected phase. The legal cycle per pass:

predict → survey → appraise → deliberate → revise → judge → decide → (next pass) predict

5.2 ponder apply

Separate and explicit. Never runs as part of a pass. Reads findings.json, selects status: open findings at or above --severity (default must), and emits an ordered work plan grouped by unit, with evidence paths and recommendations. In --dry-run it prints the plan. Without it, it prints the plan and writes apply-plan.md, marks the session applied, and records an OCA episodic entry. Ponder itself never edits the subject’s source — it hands the agent an ordered plan and the agent does the work.

apply refuses an aborted session, exit 2, with or without --dry-run. An abandoned pondering must not be able to quietly become an applied one. An open session is refused for a committed apply too (finish it, or preview with --dry-run).

6. OCA bridge

Optional, degrades silently, zero npm dependencies — it shells out to psql.

6.1 Writes

When Table Row
start episodic_memory event_type='ponder_start', content=subject + lens, importance_score=0.7
each pass, at judge episodic_memory event_type='ponder_pass', prediction=predicted churn + claim, actual_outcome=actual churn + delta counts, surprise_magnitude=surprise
each pass from 2 on, at judge hypotheses domain='ponder:<slug>', claim=the pass’s convergence claim, prediction, confidence, status, and on the following pass actual_outcome + surprise_magnitude + model_update
deliberate deliberations the four arguments and confidences, resolution=the pass’s delta summary, resolution_method='ponder-pass'
metacognize, when triggered metacognitive_observations target_layer='ponder:<slug>', observation_type ∈ stuck_state, perseveration, thrash, low_confidence, deliberation_skipped, plus severity and recommended_intervention
deliberate, judge cognitive_events event_type ∈ deliberation_result, hypothesis_formed, hypothesis_tested, metacognition_alert, reasoning_trace_created; source_layer='ponder'
decide stop episodic_memory ponder_converged only for real convergence; otherwise ponder_stopped, with final counts

cognitive_events.event_type is a Postgres enum. The live enum carries 33 labels; ponder deliberately uses only this sanctioned subset of seven, because these are the ones whose meaning matches what a pondering pass actually does: deliberation_request, deliberation_result, hypothesis_formed, hypothesis_tested, metacognition_alert, reasoning_trace_created, workspace_broadcast. Never invent a label — a value outside the enum fails the insert, and a value inside it but outside this subset misfiles the row.

6.2 Reads

On start, the bridge queries prior ponder episodes for a similar subject:

SELECT content, actual_outcome, timestamp FROM episodic_memory
WHERE event_type IN ('ponder_converged','ponder_stopped','ponder_start')
  AND content ILIKE '%' || $subject || '%'
ORDER BY timestamp DESC LIMIT 5;

and prints them as Prior pondering so the agent starts where it last left off. This is the cross-session self-reflection: the loop remembers having thought about this before.

On judge, it reads open metacognitive_observations for this session’s target layer and folds their severity into the advice as a tie-breaker toward escalate.

6.3 Safety

7. Lens packs

lenses/<name>.md. Front matter plus prose the agent reads at survey and appraise.

---
name: ui
unit: screen
enumerate: |
  How to find every unit. Concrete commands and heuristics.
dimensions: [information-hierarchy, empty-states, loading-states, error-states,
             motion, affordance-clarity, density, responsive-behaviour,
             accessibility, copy-tone, consistency, delight]
scale:
  1: broken
  2: bad
  3: acceptable
  4: good
  5: exemplary
---

The body covers: what a unit is for this lens, how to enumerate exhaustively, what each dimension means with a concrete 1 / 3 / 5 example, what evidence looks like, and what the four perspectives should each be hunting for.

--lens custom --rubric <file> loads any file with the same front-matter shape.

What the engine guarantees about enumerate. The block is the one part of a lens pack that an agent is expected to run, so two properties are guaranteed and both are tested:

  1. A block scalar is literal. Inside enumerate: | (or any other | block), # is a character, not a comment. rg -F '#ff0044' src/, git log --format='%h # %s', npm run build # rebuilds the token file and a Markdown heading like ## Sweep 2 all reach the agent whole. Comment stripping applies only to lines being read as YAML structure — a key/value line, a flow sequence, a block-sequence item — which is why name: ui # the default lens still yields the name ui.
  2. A command-shaped line reaches the terminal byte for byte. ponder start wraps the block to terminal width, but a line that looks like a command — quotes, a trailing \, $VAR or $(…), a pipe, &&, a redirect, a -/-- flag — is printed verbatim, with the caller’s indent prepended and the line’s own leading whitespace kept. That second half is load-bearing: a \ continuation is only visibly a continuation while it stays indented under the command it continues, and the shipped packs and the conformance suite both depend on it. Runs of internal whitespace are preserved too, so --format='%h %s' and awk '{ print $1 " " $3 }' are still the program the author wrote. Prose in the same block does wrap, and a wrapped list item hangs under its text rather than resetting to the left margin.

The width everything is laid out at. COLUMNS wins — it is the shell’s own answer and the only lever a caller that is not a terminal has. Then the stream’s own width when stdout is a real TTY. Off a terminal, a fixed 88 columns: output captured into a file still has to be readable, and 0 or Infinity columns is not a width. The result is clamped to 40–120, so a maximised window gets a measure rather than a 300-column line. Nothing wraps under --json, whose output must stay byte-stable, or under PONDER_NO_WRAP=1. A long unbroken token — a path, a DSN — overflows rather than being cut in half.

So an enumerate block may hold commands of any length, and a custom-lens author does not have to hand-wrap them or work around the renderer.

8. Non-negotiables

  1. coverage < 1.0 blocks stop. No override. This is the reason ponder exists. Deleting a unit you never graded does not raise coverage (3.5).
  2. Nothing is ever deleted from findings.json or ledger.jsonl.
  3. Every delta carries a why.
  4. The OCA bridge can never break the engine.
  5. Zero npm dependencies. Node’s standard library and psql only.
  6. Every command ends with a NEXT: line.
  7. ponder apply never edits source. It produces a plan, and never out of an aborted session.
  8. There is one mode. Ponder deliberates and plans; apply is the separate explicit step.
  9. The ledger is written first. Every state change appends to ledger.jsonl before it touches units.json, findings.json or a pass’s deltas.jsonl, so the only crash state reachable is a ledger that is ahead of its own view — which ponder verify --repair rebuilds deterministically (3.6). State ahead of the ledger is unrecoverable and stays a hard error.
  10. The ledger is the authority. Nothing enters a session’s state without a ledger line, and judge, decide, report and apply refuse — exit 2 — to draw a conclusion from units.json or findings.json when they disagree with what replaying ledger.jsonl says they must contain (3.6). The ledger is checked against the per-pass deltas.jsonl files too, which are the same records written a second time, so appending one plausible line to ledger.jsonl does not make a forged state file reconcile. Forging a state file is detected, not believed. A forgery consistent across all three copies is outside the model.
  11. A unit id never changes. unit-edit corrects a unit’s label, path, kind and meta; an attempt to set id is rejected, exit 1. The id is the join key between units and findings, and moving it would orphan every finding hanging off that unit (3.4).
  12. A phantom claim is checked against every path the unit has ever carried, not just the current one, so editing a path is never a route past the coverage gate. A unit whose path was ever edited can never be certified verified-missing (3.5).
  13. A project is defined by project markers, never by where sessions are kept. The root resolves to the nearest ancestor holding .git, package.json or another project marker; an existing .ponder is adopted only when no marker exists anywhere up the tree (3.1). Every command that resolves a root prints it and says what decided it — silence about a root is how every path in a session becomes a guess nobody was told about.
  14. A guessed root cannot certify an absence. When no marker or --project established the root, a phantom claim whose paths all fail to resolve is refused, exit 1, naming the root and --project (3.5). “The file is not there” is a claim about a directory; it is only a claim about the software once you know you are in the right directory.

Evidence-loop amendment — 2026-09-12

After coverage, minimum passes, thrash and the evidence/review guard, check two-pass convergence before the fallback perseveration stop. Two fully reviewed, confident zero-delta passes earn convergence; stillness with unresolved uncertainty does not.