@llblab/pi-kit 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.9.0 - 2026-09-11
6
+
7
+ - `State Flow Atomic Cohorts`: Advances the exact State Flow pin to `0.8.0`, replacing the single-scope PATCH/UNCHANGED protocol with one `patch_state({global?, cwd?, session?, final?})` primitive whose supplied scopes commit as one atomic transition, turning `final:true` into an ephemeral per-iteration terminal-eligibility latch with bounded three-attempt steering, and retaining exact rejected-call arguments in opt-in diagnostics while preserving the package set, resource inventory, and explicit load order.
8
+
9
+ ## 0.8.1 - 2026-09-11
10
+
11
+ - `Telegram Schema Hotfix`: Advances the exact Telegram pin to `0.45.2`, whose bounded-depth `telegram_bind` argument schema is accepted by OpenAI and Gemini tool APIs again, restoring working agent requests while the bridge is connected. The package set, resource inventory, and explicit load order remain unchanged.
12
+
5
13
  ## 0.8.0 - 2026-09-11
6
14
 
7
15
  - `State Flow Resolution`: Advances the exact State Flow pin to `0.7.0`, bringing explicit PATCH/UNCHANGED resolution, bounded passive-stop continuation, runtime-owned artifact provenance, fixed hot history, local-first activation, asynchronous publication, and opt-in diagnostics while preserving the package set and resource order.
package/README.md CHANGED
@@ -14,8 +14,8 @@ Package links lead to the owning repositories for usage, documentation, issues,
14
14
  | [`@llblab/pi-clean-room`](https://github.com/llblab/pi-clean-room) | `0.1.1` | Isolated nested Pi TUI with explicitly selected extensions |
15
15
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.9.4` | Compact Codex/Spark subscription-limit status |
16
16
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.7.5` | Visible continuation scheduling and bounded worker Skills |
17
- | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.7.0` | Explicit scoped state resolution and bounded session continuation |
18
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.45.1` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
17
+ | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.8.0` | Atomic scoped state cohorts with final-eligibility resolution |
18
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.45.2` | Telegram companion, queues, files, voice, controls, and Generative Apps guidance |
19
19
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
20
20
 
21
21
  Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
@@ -23,16 +23,16 @@
23
23
  - At instance initialization resolve the repository, CWD key, and session key; load each scope's anchored checkpoint and tail, materialize at the selected temporal boundary, then overlay `global → cwd → session`. Install cached view and publication basis atomically only after successful restoration/initialization; unavailable publication is an error, not a semantic no-op. Preserve the selected revision across transient restore failure so an explicit start can retry it. A genuinely new session gets an empty session layer and inherits only global/CWD values; it must never reuse another same-CWD session layer.
24
24
  - Bind every loaded scope and active Pi branch/checkpoint to the corresponding State Flow Git revision. Resume and tree restoration must recover branch-correct runtime config and semantic layers from that revision without blindly importing Git `HEAD`; reading an older revision must use object-level Git reads and never reset or check out the shared repository worktree.
25
25
  - Every accepted semantic change, including session-only and response-only changes, atomically updates affected checkpoint/tail pairs and temporal metadata and creates one immediate local Git commit when Git is available; each commit stages the complete non-ignored worktree delta (`git add -A` semantics, including manual deletions) before overlaying exact prepared State Flow outputs, respects `.gitignore`, keeps State Flow-owned active files under compare-and-swap protection, and synchronizes the caller-visible index to the committed tree. `remotePublication` is branch runtime policy: `turn-end` queues only the newest accepted target for asynchronous non-interactive push, `off` remains local-only, and `transition` preserves synchronous legacy behavior. Queue state is operational metadata beneath the Git common directory, not semantic history; use exact targets, destination identity, symlink-safe atomic CAS, descendant-only coalescing, cross-process worker leases, restart recovery, and truthful failure diagnostics. Remote failure never rolls back accepted state or regenerates an answer. A repository with no remote is intentionally local-only; only Git executable `ENOENT` authorizes file-only mode.
26
- - Register `patch_state` as the sole model-authored semantic mutation protocol. It accepts exactly one validated `session`, `cwd`, or `global` semantic patch, or `{unchanged:true}` as a mutually exclusive explicit no-change judgement. A real patch immediately folds/publishes, records lineage and the durable revision, and rematerializes `state[0]`; unchanged changes neither state, temporal history, identity, nor Git. Never accept model-authored `response`.
27
- - Treat a real `patch_state` as a strict inference barrier. Give it sequential execution mode, inspect Pi's synchronized current assistant message during tool preflight, require exactly one `patch_state` call in that response, and block every sibling tool call before execution. Every enabled turn begins with resolution pending. If the model attempts terminal prose before a successful patch or unchanged acknowledgement, replace that draft with a hidden same-run instruction; the draft is not `response`, a subsequent patch is legal, and only the later accepted ordinary answer is reconciled.
26
+ - Register `patch_state` as the sole model-authored semantic mutation protocol. It accepts optional fixed `global`, `cwd`, and `session` semantic patches plus optional `final:true`; require at least one scope or `final:true`, reject unknown fields, `final:false`, empty supplied scopes, material no-ops, and the retired `{scope, patch}` / `unchanged` grammar. Validate every supplied scope against one causal basis and publish it all-or-nothing with one identity, temporal boundary, and durable cohort. Never accept model-authored `response`.
27
+ - Treat every semantic `patch_state` as a strict inference barrier. Give it sequential execution mode, inspect Pi's synchronized current assistant message during tool preflight, require exactly one `patch_state` call in that response, and block every sibling tool call before execution. Every enabled iteration begins terminal-ineligible; only a successful call containing `final:true` latches eligibility for the next accepted `turn_end`, without stopping later reasoning, tools, or patches. If the model attempts terminal prose before eligibility, discard that transient draft and issue a hidden same-run instruction for the first two attempts; the third attempted terminal completion ends steering with one concise error while preserving committed state, enablement, and the rejected-response boundary. Failed patch calls do not consume this ephemeral three-attempt budget. A subsequent legal patch remains possible, and only a later accepted ordinary answer is reconciled.
28
28
  - Reconcile `response` only from the actually accepted ordinary assistant answer at `turn_end`; it remains runtime-owned. State Flow has no terminal HTML-comment mutation protocol and does not parse generic service comments. A draft intercepted for pending turn resolution is not final, must never reach `response`, and may be followed by `patch_state` in the same unfinished run. Other extensions retain ownership of their own comments and output handling.
29
29
  - Treat terminal state as a decision-relevant handoff, not narration: retain source-addressed reusable operational knowledge in `artifacts`; compile stable requirements, confirmed decisions, rejected approaches, and interface commitments into `contract`; retain observations, validation, failures, current domain state, unresolved work, interaction consequences, and exact continuation in `working`. Preserve relevant completed prerequisites and verified outcomes while removing obsolete progress narration; reconcile only information affected by the run and relevant existing commitments, not every scope or repository surface.
30
30
  - Preserve active constraints, unresolved questions, consequential negative results, and the next discriminating check before compression. Distinguish observations, user requirements, assistant decisions, and hypotheses; do not promote assistant conclusions to user requirements. Retain useful source locators and validity conditions for consequential facts without mandatory per-value metadata. Keep rejection reasons and reconsideration conditions. Reconcile contradictions through evidence or user clarification instead of silently overwriting established constraints or observations; retain unresolved conflicts and decision-relevant hypotheses as uncertain. These are protocol obligations, not deterministic semantic validation gates.
31
31
  - Treat `working` as last observations, not a live workspace. Revalidate volatile facts before consequential actions; after interruption or branch navigation inspect relevant external effects before repeating operations. Failed state commits and restored memory do not undo tool effects. Missing evidence proves neither success nor absence of effects: retain uncertainty and the next check. Keep revalidation targeted, without action ledgers or runtime freshness/rollback guarantees.
32
- - Treat each successful `SKILL.md` read as CWD artifact acquisition using the finalized tool-execution arguments after mutable interception: require a non-empty compiler output in the next CWD `patch_state` call under `patch.artifacts[exactReadPath]` with `description`, `kind: "skill"`, and a flexible non-empty `compilation` object; hash the executed source bytes and record runtime-owned `sourceHash` plus `skill-artifact-v1` `compilerRevision` in the CWD provenance registry; reject missing, unhashable, malformed, or forged freshness data; replace the complete prior Skill artifact and its provenance entry on refresh so obsolete evidence cannot survive. `contract.compiled_skills` is retired and rejected; migrate useful legacy entries into artifacts while preserving behavior and marking fallback hashes unverified when the source is unavailable.
32
+ - Treat each successful `SKILL.md` read as CWD artifact acquisition using the finalized tool-execution arguments after mutable interception: require a non-empty compiler output in the next `patch_state` call under `cwd.artifacts[exactReadPath]` with `description`, `kind: "skill"`, and a flexible non-empty `compilation` object; hash the executed source bytes and record runtime-owned `sourceHash` plus `skill-artifact-v1` `compilerRevision` in the CWD provenance registry; reject missing, unhashable, malformed, or forged freshness data; replace the complete prior Skill artifact and its provenance entry on refresh so obsolete evidence cannot survive. `contract.compiled_skills` is retired and rejected; migrate useful legacy entries into artifacts while preserving behavior and marking fallback hashes unverified when the source is unavailable.
33
33
  - Make the model audit and optimize complete state at every handoff: reorganize inefficient structure, merge fragmented facts, compress history into conclusions, and delete stale, completed, redundant, or low-value keys while preserving active commitments and evidence.
34
- - Accept omitted semantic fields inside a real patch, but reject empty semantic patches; when no state change is needed require explicit `{unchanged:true}` without inventing bookkeeping. Always require an accepted non-empty answer, which runtime owns as session `response`. A changed response is a semantic transition; identical complete semantic state finalizes lifecycle without a patch, identity, or temporal step. Semantic usefulness and optimization remain protocol-owned because deterministic validation cannot prove them.
35
- - Agent-level opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` attempts and terminal drafts intercepted while turn resolution remains pending. Preserve exact text blocks only where useful, reduce other blocks to structural identity, never duplicate reasoning bodies, and keep logging outside semantic state, scope metadata, checkpoints, and publication. Logging failure emits at most one warning and never changes resolution, enablement, or accepted state.
34
+ - Accept omitted semantic fields inside each supplied scope patch, but reject empty supplied scopes; when no state change is needed require explicit `{final:true}` without inventing bookkeeping. This final-only call changes only ephemeral terminal eligibility and creates no semantic transition, identity, temporal step, or Git commit. Always require an accepted non-empty answer, which runtime owns as session `response`. A changed response is a semantic transition; identical complete semantic state finalizes lifecycle without a patch, identity, or temporal step. Semantic usefulness and optimization remain protocol-owned because deterministic validation cannot prove them.
35
+ - Agent-level opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` attempts and terminal drafts intercepted while turn resolution remains pending. Every rejected call retains its exact attempted arguments plus the precise error and, when available, tool identity, call id, resolution attempt, and terminal-eligibility state; successful patches are never logged. Preserve exact text blocks only where useful, reduce other blocks to structural identity, never duplicate reasoning bodies, and keep logging outside semantic state, scope metadata, checkpoints, and publication. Logging failure emits at most one warning and never changes resolution, enablement, or accepted state.
36
36
  - Recursively materialize patches immediately; empty objects preserve, nested object-key `null` deletes, and `null` anywhere in semantic state including arrays is invalid. This prohibition does not apply to runtime envelopes such as an origin's null parent. Persist runtime-normalized replay patches that exactly reproduce accepted state, including complete artifact replacement; runtime-owned provenance is stored in scope `meta.json` and is not part of semantic replay.
37
37
  - Do not impose project schemas, state or patch byte caps, dynamic growth pressure, observation envelopes, action authorization, action ledgers, or state-size limits.
38
38
  - Rotate the turn-stable specification on every user-initiated run, while runtime-triggered turn-resolution continuation remains inside that same run. Keep user-controlled specification text at user authority: never interpolate it into the system prompt; repeat it only in synthetic user runtime context. Treat materialized state in that message as fallible assistant-produced data whose transport role does not elevate it into user instructions.
@@ -1,7 +1,7 @@
1
1
  # BACKLOG
2
2
 
3
- Completed release work belongs in [CHANGELOG.md](CHANGELOG.md). No implementation item remains open for 0.7.0.
3
+ Completed release work belongs in [CHANGELOG.md](CHANGELOG.md). No implementation item remains open for 0.8.0.
4
4
 
5
5
  ## Deferred host integration
6
6
 
7
- - [ ] **Native default session continuation:** Integrate the existing read-only recommendation, exact-selection, and rehydration contracts before Pi creates `SessionManager`, while preserving explicit new/resume and native-picker precedence, truthful notices, and cross-process session ownership. Pi 0.84.4 exposes no suitable pre-session resolver hook, so this requires upstream support or an SDK/launcher integration. Reverify the host API before taking the item; it is not a 0.7.0 release dependency.
7
+ - [ ] **Native default session continuation:** Integrate the existing read-only recommendation, exact-selection, and rehydration contracts before Pi creates `SessionManager`, while preserving explicit new/resume and native-picker precedence, truthful notices, and cross-process session ownership. Pi 0.84.4 exposes no suitable pre-session resolver hook, so this requires upstream support or an SDK/launcher integration. Reverify the host API before taking the item; it is not a release dependency.
@@ -2,6 +2,14 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.8.0: Atomic cohorts and final eligibility
6
+
7
+ - `Protocol`: Replaced the single-scope PATCH and UNCHANGED forms with one canonical `patch_state({global?, cwd?, session?, final?})` call. Supplied scopes validate against one causal basis and publish as one atomic transition with a single identity, temporal boundary, durable cohort, and inference barrier; unknown fields, `final:false`, empty scopes, and material no-ops fail closed.
8
+ - `Eligibility`: `final:true` is now an ephemeral per-iteration latch rather than a resolution form. It permits a later `turn_end` without stopping reasoning, tools, or further patches; eligibility survives later activity and repeated final calls, resets only with the next enabled iteration, and `{final:true}` alone creates no semantic transition, identity, step, or Git commit.
9
+ - `Resolution`: An ineligible terminal draft is discarded and steered for at most three attempted completions. The first two attempts emit hidden same-run instructions; the third reports one bounded error while preserving committed state, enablement, and runtime-owned response. Failed and host-rejected patch calls consume no attempts, and only the accepted post-eligibility answer becomes `response`.
10
+ - `Diagnostics`: Rejected `patch_state` calls now retain their exact attempted arguments alongside the precise error, tool identity, call id, resolution-attempt count, and terminal-eligibility state; publication conflicts use the same record and successful patches are never logged.
11
+ - `Compatibility`: Persisted checkpoints, tails, provenance registries, scope layout, and Git/file durability are unchanged and need no migration; the removed model-facing grammar is not accepted as a compatibility alias.
12
+
5
13
  ## 0.7.0: Explicit resolution and leaner runtime
6
14
 
7
15
  - `Protocol`: Made `patch_state` the sole model-authored semantic mutation path. Every enabled turn now requires a successful resolution call, each exactly PATCH `{scope, patch}` or UNCHANGED `{"unchanged":true}`; unresolved prose is discarded and steered within the same run, failed calls remain unresolved, and only the later accepted ordinary answer becomes runtime-owned `response`.
@@ -45,7 +45,7 @@ Optional `~/.pi/agent/state-flow.json` (or `state-flow.json` beneath `PI_CODING_
45
45
 
46
46
  - `directory`: State storage location; omitted defaults to `state-flow/` beneath Pi's agent directory. Absolute paths, `~`/`~/` and relative paths are supported; relative paths resolve from the configuration directory, not the project CWD.
47
47
  - `autoStart`: Set to `true` to enable State Flow automatically for genuinely new sessions, including fresh CWDs. Omitted defaults to `false`: manual activation, even when previous CWD materialization exists.
48
- - `logging`: Set to `true` to append JSONL diagnostics for rejected `patch_state` calls and unresolved terminal drafts to `tmp/state-flow/logs.jsonl` beneath Pi's agent directory; omitted or `false` writes nothing. Exact text blocks are retained only when useful, while reasoning bodies are excluded. The file stays local unless you move it, deleting or rotating it is operator-owned, and logging never enters semantic state, metadata, checkpoints, or the state repository. If a custom state repository contains the diagnostic path, capture fails closed rather than committing the log. A write failure changes nothing except one local warning.
48
+ - `logging`: Set to `true` to append JSONL diagnostics for rejected `patch_state` calls and unresolved terminal drafts to `tmp/state-flow/logs.jsonl` beneath Pi's agent directory; omitted or `false` writes nothing. Rejected calls retain their exact attempted arguments, the precise error, and, when available, the tool identity, call id, resolution attempt, and terminal-eligibility state; successful patches are never logged. Exact text blocks are retained only when useful, while reasoning bodies are excluded. The file stays local unless you move it, deleting or rotating it is operator-owned, and logging never enters semantic state, metadata, checkpoints, or the state repository. If a custom state repository contains the diagnostic path, capture fails closed rather than committing the log. A write failure changes nothing except one local warning.
49
49
  - `remotePublication`: For new runtimes, `turn-end` (default) queues one newest target after local acceptance and pushes asynchronously; `off` keeps commits local; `transition` preserves legacy synchronous push behavior. A resumed branch keeps its persisted policy.
50
50
 
51
51
  Configuration is read at extension load; edit it and use `/reload` (or restart Pi) before opening a new session. Existing branches retain their stored enablement on resume/tree navigation. `/state-flow-stop` does not rewrite this file or disable automatic mode for later new sessions. Invalid JSON, unknown keys or invalid values fail extension loading rather than silently choosing another directory. A missing file uses defaults and is not generated automatically.
@@ -173,7 +173,7 @@ Session `config.json` owns runtime behavior such as `enabled`. Session `meta.jso
173
173
 
174
174
  Git-backed Pi checkpoint data is exactly `{"revision":"<full commit hash>"}`; file-only checkpoints use `{"revision":"file:<64 lowercase hex>"}` for an exact current-cohort reference. `{"disabled":true}` denotes an ordinary disabled branch without durable runtime. Configuration, counters, specification, lineage, and semantic state are not copied into these entries. Older config/meta and state-bearing checkpoints remain read-only compatibility input. Invalid immutable pointer targets fall back through the selected branch; transient publication-lock failure instead retains the selected revision for retry. Explicit start on a pre-runtime branch establishes an empty session origin, even if a later branch already created same-session files. Shared streams remain unchanged and the later session state remains recoverable from its Git revision; it is not imported into the new origin. Legacy checkpoint syntax and immutable targets undergo the same fallback checks, while an unanchored legacy semantic payload cannot be silently reduced to an ordinary-disabled marker. Stop on an unproven branch fails visibly instead of turning failed recovery into permission to replace existing runtime; select a valid checkpoint before retrying.
175
175
 
176
- Every materially effective semantic transition, including response-only and session-only changes, immediately publishes affected checkpoint/tail pairs and necessary lineage metadata. In Git mode each accepted cohort gets its own consistent local commit; `turn-end` queues the newest target for asynchronous replication, `off` stays local, and compatibility `transition` attempts the push synchronously. Intermediate patches are not batched until turn end. With no remote configured, Git persistence is intentionally local-only, not a pending publication error. With Git absent, persistence remains file-only and does not report fake commits or pending pushes. One accepted cohort can share one identity and commit across its affected scopes; model-authored multi-scope work uses sequential one-scope barriers. Config-only changes may persist runtime state but never invent semantic transitions.
176
+ Every materially effective semantic transition, including response-only and session-only changes, immediately publishes affected checkpoint/tail pairs and necessary lineage metadata. In Git mode each accepted cohort gets its own consistent local commit; `turn-end` queues the newest target for asynchronous replication, `off` stays local, and compatibility `transition` attempts the push synchronously. Intermediate patches are not batched until turn end. With no remote configured, Git persistence is intentionally local-only, not a pending publication error. With Git absent, persistence remains file-only and does not report fake commits or pending pushes. One model-authored call may update global, CWD, and session together; every supplied scope shares one validation basis, transition identity, temporal boundary, and durable commit. Config-only changes may persist runtime state but never invent semantic transitions.
177
177
 
178
178
  Writes use regular non-symlink owned files, same-directory atomic rename, isolated Git indexes, and compare-and-swap against the reconciled base. Cooperating State Flow Git publishers hold a common-Git-directory publication lock through capture, commit, and rollback. Raw bytes own file identity and recovery; non-UTF-8 explanatory journals are not reconstructed from decoded strings. Per-file publication checks catch changed bases. Both semantic and migration publishers retain exact prepared-output receipts: Git commits those bytes, not later worktree contents, and rollback requires the receipt to preserve detected external changes rather than replacing them. Low-level file helpers require caller exclusion; this is not kernel-atomic multi-file CAS against writers ignoring the protocol. An existing publication lock fails before writes and is not silently stolen; reconcile its active or interrupted owner before retrying. Scope keys mirror Pi's CWD/session directory names and retain separately verified canonical identity provenance inside owned state. Conflicts fail rather than silently selecting another scope or auto-merging semantic state. Every Git commit stages the complete non-ignored worktree delta — tracked edits, new files, and manual deletions, respecting `.gitignore` — then overlays the exact prepared State Flow outputs and synchronizes the caller-visible index to the committed tree, so no artificial staged or unstaged entries remain; unrelated user worktree changes may therefore be committed while ignored files stay untouched. With distinct roots, the Knowledge repository's files, index, HEAD, locks, and remote are not used for state publication; keep custom stores separate from sources to retain this isolation.
179
179
 
@@ -198,7 +198,7 @@ An existing current `state.json` becomes the initial checkpoint state, anchored
198
198
  Use `patch_state` only for established future-relevant state whose delayed persistence risks meaningful loss, never scratchpad, narration, routine progress, or speculative churn:
199
199
 
200
200
  ```json
201
- {"scope":"session","patch":{"working":{"verified":"result"}}}
201
+ {"session":{"working":{"verified":"result"}},"final":true}
202
202
  ```
203
203
 
204
204
  ```text
@@ -211,15 +211,17 @@ LLM(state[0] = Sn)
211
211
 
212
212
  The barrier executes alone. State Flow opts into sequential preflight, inspects Pi's synchronized assistant response, requires exactly one `patch_state` call, and blocks every sibling tool before execution. The next inference sees one rebuilt current-state projection; it must not reconstruct the update itself. Tool calls/results remain current-run causality, not duplicate full-state messages.
213
213
 
214
- `patch_state` is the only model-authored semantic mutation mechanism. Every enabled turn begins with resolution pending and needs at least one successful call before a visible final answer. Each invocation has exactly one of two exclusive forms: `patch_state({ scope, patch })` or `patch_state({ unchanged: true })`. The latter records an explicit no-change judgment only: it creates no patch, transition identity, temporal step, or Git commit.
214
+ `patch_state` is the only model-authored semantic mutation mechanism. Each invocation may supply any combination of fixed `global`, `cwd`, and `session` patches plus optional `final:true`. Supplied scopes are validated and published atomically; at least one scope or `final:true` is required. The retired `{scope, patch}` and `unchanged` forms are rejected.
215
215
 
216
216
  ```text
217
- patch_state(patch or unchanged) → accepted state → visible answer → runtime records response
217
+ patch_state({global?, cwd?, session?}) → atomic accepted state → further work
218
+ patch_state({...?, final:true}) → atomic accepted state + terminal eligibility → later turn_end
219
+ patch_state({final:true}) → terminal eligibility only, with no semantic transition
218
220
  ```
219
221
 
220
- An unresolved visible draft is not accepted as a response. State Flow replaces it with a hidden same-run resolution instruction, accepts a successful `patch_state`, then permits the final ordinary answer. This continuation remains in the same unfinished assistant run; only its accepted final answer reaches `response`. Aborted, length-limited, and provider-error endings never become the accepted response.
222
+ Every enabled iteration begins terminal-ineligible. `final:true` latches eligibility for the next accepted `turn_end`; it does not terminate the turn, stop tools or reasoning, freeze state, or prevent later `patch_state` calls. An unresolved visible draft is not accepted as a response. State Flow discards it and issues a hidden same-run instruction for the first two attempted terminal completions; the third ends steering with one concise error while preserving committed state and enablement. Failed patch calls do not consume this ephemeral budget. A later successful `final:true` permits the final ordinary answer. Only that accepted answer reaches `response`; aborted, length-limited, and provider-error endings never do.
221
223
 
222
- A real patch remains an inference barrier. It is validated, durably accepted, and rematerialized before further reasoning. Multiple scopes require explicit sequential calls. Empty and materially no-op patches are rejected; they are not an unchanged acknowledgement. `response` is never accepted from the model. The final answer is ordinary assistant output, not a state transport. State Flow has no terminal `state_flow` HTML-comment protocol and does not parse generic service comments. Historical `state_flow` text is ordinary text. Other extensions retain ownership of their own comments such as `telegram_button`. Never put secrets in state.
224
+ A semantic patch remains an inference barrier. The complete scope cohort is validated, durably accepted, and rematerialized before further reasoning. Empty supplied scopes and materially no-op scope cohorts are rejected; omit unchanged scopes and use `{final:true}` alone when no semantic update is needed. `response` is never accepted from the model. The final answer is ordinary assistant output, not a state transport. State Flow has no terminal `state_flow` HTML-comment protocol and does not parse generic service comments. Historical `state_flow` text is ordinary text. Other extensions retain ownership of their own comments such as `telegram_button`. Never put secrets in state.
223
225
 
224
226
  ## Artifact compilation and acquisition
225
227
 
@@ -259,7 +261,7 @@ Correlation follows finalized mutable `tool_call` arguments with execution-start
259
261
 
260
262
  ### Optional memory curation
261
263
 
262
- The package includes the progressively disclosed `state-flow-memory` Skill for explicit audits, contradiction cleanup, scope narrowing, stale continuation review, and ownership migration. It is not part of routine retention and never runs as background maintenance. The Skill preserves useful uncertainty, confirmed decisions, bounded results, interaction consequences, and completed prerequisites without fixing provisional methods. Its compilation-first sequence writes and separately verifies a destination before source deletion, then inspects the effective overlay; separate single-scope calls are not atomic migration. External promotion cannot delete the only accepted copy without destination identity, content, and revision evidence. State Flow remains the memory owner and global memory remains available throughout.
264
+ The package includes the progressively disclosed `state-flow-memory` Skill for explicit audits, contradiction cleanup, scope narrowing, stale continuation review, and ownership migration. It is not part of routine retention and never runs as background maintenance. The Skill preserves useful uncertainty, confirmed decisions, bounded results, interaction consequences, and completed prerequisites without fixing provisional methods. Its compilation-first sequence writes and separately verifies a destination before source deletion, then inspects the effective overlay; it deliberately preserves that verification boundary even though one `patch_state` call can atomically update multiple scopes. External promotion cannot delete the only accepted copy without destination identity, content, and revision evidence. State Flow remains the memory owner and global memory remains available throughout.
263
265
 
264
266
  External handoffs may use the generic global `working.memory_promotions` map. Each entry requires `status` (`pending`, `accepted`, `failed`, or `unknown`) and a non-empty `owner`; accepted entries additionally require an exact destination `pointer` and `revision`. Other fields remain forward-compatible and destination-specific. This bookkeeping does not replace the retained candidate: failures and uncertainty keep the accepted State Flow body plus exact continuation, while proven acceptance may remove the duplicate body and retain the compact pointer record. `/state-flow-status` reports owner mode, fallback activation, memory-bearing scopes, promotion counts, and bounded pointer/error details; malformed or incompletely evidenced accepted records appear as invalid rather than accepted.
265
267
 
@@ -63,21 +63,21 @@ The checkpoint is an older anchored materialization. The tail contains at most s
63
63
 
64
64
  `state[n]`, `state.global[n]`, `state.cwd[n]` and `state.session[n]` resolve the same nth previous causal boundary. They are not independent per-scope patch counters. Pre-origin history is unavailable rather than empty.
65
65
 
66
- True semantic no-ops and explicit `patch_state({"unchanged":true})` acknowledgements create no identity, commit or history step. A changed accepted response is runtime-owned semantic state and advances history.
66
+ A final-only `patch_state({"final":true})` call changes only ephemeral terminal eligibility and creates no identity, commit, or history step. A changed accepted response is runtime-owned semantic state and advances history.
67
67
 
68
68
  ## Pi lifecycle
69
69
 
70
- `patch_state` is the sole mutation tool. It validates and publishes one scope patch immediately, then acts as an inference barrier. Pi executes no sibling tools from the same assistant response; the next inference sees rematerialized `state[0]`.
70
+ `patch_state` is the sole mutation tool. It validates any supplied global/CWD/session patches against one causal basis and publishes them as one atomic transition, then acts as an inference barrier. Pi executes no sibling tools from the same assistant response; the next inference sees rematerialized `state[0]`.
71
71
 
72
72
  `read_state` reads one cached effective or scoped projection at offsets zero through seven. It never publishes or advances history.
73
73
 
74
- Every enabled assistant turn starts with turn resolution pending. A real `patch_state({scope,patch})` or `patch_state({unchanged:true})` satisfies it; real patches remain immediate one-scope inference barriers, so multi-scope work uses sequential calls. If terminal prose arrives while resolution is pending, State Flow replaces that draft with a hidden same-run instruction. The draft is not a final response, and a following patch is legal; only the later accepted ordinary answer is reconciled into runtime-owned `response` at `turn_end`. State Flow no longer parses `state_flow` or generic HTML comments; historical comments are ordinary text and other extensions retain their own comment handling.
74
+ Every enabled assistant iteration starts terminal-ineligible. Only a successful `patch_state` call containing `final:true` latches eligibility for the next accepted `turn_end`; the call may atomically include global, CWD, and session patches. Eligibility does not stop later reasoning, tools, or patches. If terminal prose arrives before eligibility, State Flow discards that draft and issues a hidden same-run instruction for the first two terminal attempts. The third attempt ends steering with one concise error while preserving committed state and enablement; failed patch calls do not consume the budget. A following legal patch remains possible, and only a later accepted ordinary answer is reconciled into runtime-owned `response` at `turn_end`. State Flow no longer parses `state_flow` or generic HTML comments; historical comments are ordinary text and other extensions retain their own comment handling.
75
75
 
76
76
  ## Lifecycle planes
77
77
 
78
78
  ```text
79
79
  SEMANTIC STATE artifacts + contract + working
80
- TURN RESOLUTION pendingpatch | unchanged satisfied
80
+ TURN ELIGIBILITY falsepatch_state(..., final:true)latched true
81
81
  CONTEXT PROJECTION active State Flow projection | passive post-stop handoff
82
82
  ```
83
83
 
@@ -199,7 +199,7 @@ Unknown keys fail loading. State Flow memory ownership and global availability a
199
199
 
200
200
  `/state-flow-status` reports branch mode, runtime revision, temporal head/history depth, scope keys, patch tails, artifact freshness, memory-bearing scopes, external-promotion summaries, remote policy/queue state, and pending publication. Unavailable materialization is reported as unavailable, never fabricated as empty. Artifact source bodies are not read for status.
201
201
 
202
- Opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` calls and terminal drafts intercepted while turn resolution is pending. Records preserve useful exact text blocks and reduce other blocks to structural identity without duplicating reasoning. They are never semantic state, scope `meta.json`, Pi checkpoints, or repository input. Write failure changes no resolution, enablement, or accepted state and reports at most one bounded local warning.
202
+ Opt-in `logging` appends local JSONL diagnostics for rejected `patch_state` calls and terminal drafts intercepted while turn eligibility is pending. A rejected call retains its exact attempted arguments, the precise error, and, when available, tool identity, call id, resolution attempt, and terminal-eligibility state; accepted patches are never logged. Records preserve useful exact text blocks and reduce other blocks to structural identity without duplicating reasoning. They are never semantic state, scope `meta.json`, Pi checkpoints, or repository input. Write failure changes no resolution, enablement, or accepted state and reports at most one bounded local warning.
203
203
 
204
204
  ## Validation boundaries
205
205
 
@@ -18,7 +18,7 @@ The numbering below follows the twenty required tests in the temporal design cor
18
18
  12. **Next inference sees new current state:** The same real-Pi test inspects actual model-input projections after session, CWD, and global barriers. “real Pi reads prior scoped state lazily after a barrier and rejects offset eight without a transition” adds model-tool access to the predecessor.
19
19
  13. **No automatic old full-state duplication:** `tests/context.test.ts` — “projects only the latest seven compact accepted transitions” rejects full-state records in transition context. The real-Pi barrier test requires exactly one current runtime projection per inference. Explicitly requested history remains ordinary tool-result trajectory, not eager snapshot injection.
20
20
  14. **Accepted response changes are transitions:** `tests/resolution.test.ts` verifies that only the ordinary answer after successful patch/unchanged resolution enters runtime-owned `response`; intercepted drafts never do.
21
- 15. **Explicit unchanged creates no fake history:** `tests/resolution.test.ts` checks that `{unchanged:true}` satisfies turn resolution without changing semantic lineage, tails, step, or Git. A later changed accepted response remains a runtime-owned transition.
21
+ 15. **Final-only eligibility creates no fake history:** `tests/extension.test.ts` — “an unresolved terminal draft is intercepted, then final-only resolution permits the final answer” checks that `{final:true}` latches turn eligibility without changing semantic lineage, tails, step, or Git. A later changed accepted response remains a runtime-owned transition.
22
22
  16. **Offset eight is outside hot history:** `tests/temporal.test.ts` — “hot range and unproven pre-migration history are explicit read boundaries”; the real-Pi history-reader test also exercises native tool rejection without a transition.
23
23
  17. **Cold Git history remains recoverable:** `tests/git.test.ts` — “temporal Git writer preserves all hot states through sparse folding and cold revision reads” loads older committed states without moving the worktree.
24
24
  18. **Tree/resume select the correct lineage:** `tests/integration.test.ts` — “real Pi preserves branch-local state through compaction, tree navigation, stop, and restart” and “real Pi old tree branch stop and resume preserve selected semantics without rewinding shared files”.
@@ -80,7 +80,7 @@ export {
80
80
  sessionStorageKey, temporalScopePaths,
81
81
  temporalStateFileUpdates, type ScopeStreamSources, type TemporalScopePaths
82
82
  } from "./lib/durable.ts";
83
- export { default, PATCH_STATE_TOOL_NAME, READ_STATE_TOOL_NAME, type StateFlowExtensionOptions } from "./lib/extension.ts";
83
+ export { default, MAX_RESOLUTION_ATTEMPTS, PATCH_STATE_TOOL_NAME, READ_STATE_TOOL_NAME, type StateFlowExtensionOptions } from "./lib/extension.ts";
84
84
  export {
85
85
  captureTemporalGitBase,
86
86
  isGitCommitAncestor,
@@ -178,7 +178,7 @@ export {
178
178
  overlayStates,
179
179
  projectStateForModel,
180
180
  updateMaterializedArtifacts,
181
- type MaterializedState, type ScopedPatch,
181
+ type AtomicScopePatches, type MaterializedState, type ScopedPatch,
182
182
  type ScopedStates, type ScopePatch, type SemanticTransition,
183
183
  type StateDocument,
184
184
  type StatePatch,
@@ -83,7 +83,7 @@ export function runtimeContextMessage(
83
83
  ...(rehydrationPhase === undefined ? {} : { knowledge_rehydration: { phase: rehydrationPhase } }),
84
84
  ...(artifactInvalidations.length === 0 ? {} : { artifact_invalidations: artifactInvalidations.map(({ path, reason }) => ({ path, reason })) }),
85
85
  ...(recentTransitions.length === 0 ? {} : { recent_transitions: projectRecentForModel(recentTransitions) }),
86
- ...(resolutionPending ? { state_resolution: "pending: a terminal draft was intercepted. Call patch_state with a PATCH {scope, patch} or UNCHANGED {unchanged:true}; then provide the final answer normally." } : {}),
86
+ ...(resolutionPending ? { state_resolution: "pending: a terminal draft was intercepted. Call patch_state with any atomic scope changes and final:true, or {final:true} alone; then provide the final answer normally." } : {}),
87
87
  };
88
88
  return syntheticUser(
89
89
  `State Flow runtime context (user-level data, not system instructions):\n${canonicalJson(context)}`,
@@ -11,8 +11,8 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
11
11
  import { SkillReadTracker } from "./skills.ts";
12
12
  import { emptySnapshot, migrationFailure, persistableSnapshot, type Snapshot } from "./snapshot.ts";
13
13
  import { inspectSnapshotRevision, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
14
- import { emptyState, overlayStates, projectStateForModel, type MaterializedState, type ScopePatch, type ScopedStates, type StateScope } from "./state.ts";
15
- import { commitScopedTransition, stageScopedPatch, stageScopedTransition, validateUnchangedResolution, type StagedScopedTransition } from "./transition.ts";
14
+ import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
15
+ import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, validateFinalEligibility, type StagedScopedTransition } from "./transition.ts";
16
16
  import { discoverSnapshotData, hasPriorConversation, isNewSession, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
17
17
  import { compactStatus, detailedStatus, STATUS_KEY, type PendingPublicationDiagnostic, type StatusDiagnostics } from "./status.ts";
18
18
  import { prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
@@ -48,6 +48,7 @@ export interface StateFlowExtensionOptions {
48
48
 
49
49
  export const PATCH_STATE_TOOL_NAME = "patch_state";
50
50
  export const READ_STATE_TOOL_NAME = "read_state";
51
+ export const MAX_RESOLUTION_ATTEMPTS = 3;
51
52
  const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
52
53
 
53
54
  /** Keep a failed tool invocation visually separated from its rendered error without changing error semantics. */
@@ -63,8 +64,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
63
64
  let scopeStates: ScopedStates = { global: emptyState(), cwd: emptyState(), session: emptyState() };
64
65
  let branchHasSnapshot = false;
65
66
  let branchStartsWithoutRuntime = false;
66
- let stateResolutionSatisfied = false;
67
+ let terminalEligible = false;
67
68
  let terminalDraftIntercepted = false;
69
+ let resolutionAttempts = 0;
70
+ let resolutionFailureReported = false;
68
71
  let responseAwaitingReconciliation = false;
69
72
  let passiveContinuation: PassiveContinuation | undefined;
70
73
  let bootstrapContinuation: PassiveContinuation | undefined;
@@ -120,8 +123,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
120
123
  }
121
124
 
122
125
  function clearRunTransient(): void {
123
- stateResolutionSatisfied = false;
126
+ terminalEligible = false;
124
127
  terminalDraftIntercepted = false;
128
+ resolutionAttempts = 0;
129
+ resolutionFailureReported = false;
125
130
  responseAwaitingReconciliation = false;
126
131
  runAnchorTimestamp = undefined;
127
132
  skillReads.clear();
@@ -170,9 +175,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
170
175
  artifactInvalidations = structuredClone(plan.requiresCompilation);
171
176
  if (plan.removed.length > 0) {
172
177
  const removals = Object.fromEntries(plan.removed.map((path) => [path, null]));
173
- const stage = stageScopedPatch(
178
+ const stage = stageAtomicScopePatches(
174
179
  scopeStates,
175
- { scope: "global", patch: { artifacts: removals } },
180
+ { global: { artifacts: removals } },
176
181
  [],
177
182
  runtime!.causalBasis(),
178
183
  );
@@ -537,7 +542,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
537
542
  updateUi(ctx);
538
543
  }
539
544
 
540
- function recordDiagnostic(error: string, category: StateFlowDiagnosticCategory, ctx: ExtensionContext, content?: unknown): void {
545
+ interface DiagnosticExtras {
546
+ content?: unknown;
547
+ input?: unknown;
548
+ tool?: string;
549
+ toolCallId?: string;
550
+ resolutionAttempt?: number;
551
+ terminalEligible?: boolean;
552
+ }
553
+
554
+ function recordDiagnostic(error: string, category: StateFlowDiagnosticCategory, ctx: ExtensionContext, extras: DiagnosticExtras = {}): void {
541
555
  if (!config.logging) return;
542
556
  try {
543
557
  const path = stateFlowLogPath(agentDir);
@@ -551,7 +565,12 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
551
565
  cwd: resolve(ctx.cwd),
552
566
  category,
553
567
  error,
554
- ...(content === undefined ? {} : { content: projectDiagnosticContent(content) }),
568
+ ...(extras.content === undefined ? {} : { content: projectDiagnosticContent(extras.content) }),
569
+ ...(extras.input === undefined ? {} : { input: extras.input }),
570
+ ...(extras.tool === undefined ? {} : { tool: extras.tool }),
571
+ ...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
572
+ ...(extras.resolutionAttempt === undefined ? {} : { resolutionAttempt: extras.resolutionAttempt }),
573
+ ...(extras.terminalEligible === undefined ? {} : { terminalEligible: extras.terminalEligible }),
555
574
  });
556
575
  } catch (failure) {
557
576
  if (loggingWarningReported) return;
@@ -564,7 +583,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
564
583
  terminalDraftIntercepted = true;
565
584
  pi.sendMessage({
566
585
  customType: VALIDATION_MESSAGE_TYPE,
567
- content: "Before completing this turn, resolve State Flow. Call patch_state with durable semantic changes, or call patch_state with {\"unchanged\":true} if no state update is required. Then provide the final answer normally.",
586
+ content: "Before completing this turn, make the State Flow iteration terminal-eligible. Call patch_state with any durable scope changes and final:true, or call patch_state with {\"final\":true} when no semantic update is needed. Then provide the final answer normally.",
568
587
  display: false,
569
588
  }, { deliverAs: "steer", triggerTurn: true });
570
589
  }
@@ -599,52 +618,67 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
599
618
  pi.registerTool({
600
619
  name: PATCH_STATE_TOOL_NAME,
601
620
  label: "Patch State",
602
- description: "The sole State Flow semantic mutation protocol. Use exactly one form: PATCH {scope, patch} to materialize established future-relevant state, or UNCHANGED {unchanged:true} after explicitly deciding no durable update is needed. Never combine unchanged with scope or patch. This call must be the only State Flow barrier in its assistant response; sibling tool calls are reconsidered after rematerialization.",
603
- promptSnippet: "PATCH {scope, patch} or UNCHANGED {unchanged:true}",
621
+ description: "The sole State Flow semantic mutation protocol. Supply any combination of global, cwd, and session patches; all supplied scopes commit atomically. Set final:true when the current iteration may finish at a later turn_end. final:true does not stop reasoning, tools, or later patch_state calls. Use {final:true} when no semantic update is needed. This call must be the only State Flow barrier in its assistant response.",
622
+ promptSnippet: "Atomically patch global/cwd/session; final:true permits a later turn_end",
604
623
  promptGuidelines: [
605
- "Use patch_state for every durable semantic change. Before a final answer, resolve State Flow with PATCH {scope, patch} or UNCHANGED {unchanged:true}.",
606
- "Call patch_state alone in an assistant response; choose subsequent actions only after its acknowledgement and rematerialized State Flow context.",
624
+ "Use patch_state for durable semantic changes. Before a final answer, make the iteration terminal-eligible with final:true, optionally alongside atomic global/cwd/session patches.",
625
+ "Call patch_state alone in an assistant response; after its acknowledgement, further reasoning, tools, and later patch_state calls remain allowed.",
607
626
  ],
608
627
  executionMode: "sequential",
609
- // Type.Union/Type.Literal schemas are not portable across Pi's Google-compatible tool adapters.
610
- // Field descriptions expose the discriminated forms while runtime validation preserves exclusivity.
611
628
  parameters: Type.Object({
612
- scope: Type.Optional(StringEnum(["session", "cwd", "global"] as const, { description: "PATCH form only: required with patch; forbidden with unchanged" })),
613
- patch: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "PATCH form only: required with scope; forbidden with unchanged" })),
614
- unchanged: Type.Optional(Type.Boolean({ description: "UNCHANGED form only: set exactly true and omit scope and patch" })),
629
+ global: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional global semantic patch" })),
630
+ cwd: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional project semantic patch" })),
631
+ session: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Optional session semantic patch" })),
632
+ final: Type.Optional(Type.Boolean({ description: "Set exactly true to permit this iteration to finish at a later turn_end" })),
615
633
  }, { additionalProperties: false }),
616
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
634
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
617
635
  try {
618
636
  if (!snapshot.config.enabled) throw new Error("State Flow is disabled on this session branch");
619
637
  if (signal?.aborted) throw new Error("State Flow patch was aborted before materialization");
620
- if (!isObject(params)) throw new Error("patch_state requires an object in exactly one supported form");
621
- const keys = Object.keys(params).sort();
622
- if (params.unchanged === true) {
623
- if (keys.length !== 1 || keys[0] !== "unchanged") throw new Error('patch_state {"unchanged":true} cannot include any other field');
624
- validateUnchangedResolution(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
625
- stateResolutionSatisfied = true;
626
- terminalDraftIntercepted = false;
627
- return { content: [{ type: "text", text: "\nState resolution acknowledged unchanged." }], details: { unchanged: true } };
638
+ if (!isObject(params)) throw new Error("patch_state requires an object");
639
+ const allowed = new Set(["global", "cwd", "session", "final"]);
640
+ for (const key of Object.keys(params)) {
641
+ if (!allowed.has(key)) throw new Error(`patch_state does not accept field ${key}`);
628
642
  }
629
- if (keys.length !== 2 || keys[0] !== "patch" || keys[1] !== "scope"
630
- || params.scope === undefined || !isObject(params.patch)) {
631
- throw new Error("patch_state requires exactly scope and patch, or {\"unchanged\":true}");
643
+ if (Object.hasOwn(params, "final") && params.final !== true) throw new Error("patch_state final must be exactly true when supplied");
644
+ const patches: AtomicScopePatches = {};
645
+ for (const scope of ["global", "cwd", "session"] as const) {
646
+ if (!Object.hasOwn(params, scope)) continue;
647
+ const patch = params[scope];
648
+ if (!isObject(patch)) throw new Error(`patch_state ${scope} must be a semantic patch object`);
649
+ if (Object.keys(patch).length === 0) throw new Error(`patch_state ${scope} cannot be empty; omit it when unchanged`);
650
+ patches[scope] = patch;
651
+ }
652
+ const scopes = Object.keys(patches) as StateScope[];
653
+ if (scopes.length === 0) {
654
+ if (params.final !== true) throw new Error('patch_state requires at least one scope patch or {"final":true}');
655
+ validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
656
+ terminalEligible = true;
657
+ terminalDraftIntercepted = false;
658
+ return { content: [{ type: "text", text: "\nState iteration is terminal-eligible." }], details: { final: true } };
632
659
  }
633
- if (Object.keys(params.patch).length === 0) throw new Error("An empty semantic patch is not an unchanged acknowledgement; use {\"unchanged\":true}");
634
- const transition = { scope: params.scope as StateScope, patch: params.patch as ScopePatch };
635
- const stage = stageScopedPatch(scopeStates, transition, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
660
+ const stage = stageAtomicScopePatches(scopeStates, patches, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
636
661
  const semanticChange = (["global", "cwd", "session"] as const).some((scope) => !sameJson(scopeStates[scope], stage.nextStates[scope]));
637
662
  const provenanceChange = Object.values(stage.provenanceUpdates).some((updates) => Object.keys(updates).length > 0);
638
- if (!semanticChange && !provenanceChange) throw new Error('patch_state PATCH must materially update state or required provenance; use {"unchanged":true} instead');
663
+ if (!semanticChange && !provenanceChange) throw new Error('patch_state scope patches must materially update state or required provenance; omit them and use {"final":true} when unchanged');
639
664
  commitStage(stage, ctx, false);
640
- stateResolutionSatisfied = true;
641
- terminalDraftIntercepted = false;
665
+ if (params.final === true) {
666
+ terminalEligible = true;
667
+ terminalDraftIntercepted = false;
668
+ }
642
669
  updateUi(ctx);
643
670
  const publication = pendingPublication === undefined ? "" : "; durable publication pending";
644
- return { content: [{ type: "text", text: `\nState materialized at ${params.scope} scope${publication}.` }], details: { scope: params.scope, step: snapshot.meta.step } };
671
+ return { content: [{ type: "text", text: `\nState materialized atomically at ${scopes.join("+")} scope${scopes.length === 1 ? "" : "s"}${publication}.` }], details: { scopes, final: params.final === true, step: snapshot.meta.step } };
645
672
  } catch (error) {
646
- stateResolutionSatisfied = false;
647
- recordDiagnostic(error instanceof Error ? error.message : String(error), /concurrently|advanced/.test(String(error)) ? "publication-conflict" : "invalid-patch", ctx);
673
+ let attempted: unknown;
674
+ try { attempted = structuredClone(params); } catch { attempted = undefined; }
675
+ recordDiagnostic(error instanceof Error ? error.message : String(error), /concurrently|advanced/.test(String(error)) ? "publication-conflict" : "invalid-patch", ctx, {
676
+ input: attempted,
677
+ tool: PATCH_STATE_TOOL_NAME,
678
+ toolCallId,
679
+ resolutionAttempt: resolutionAttempts,
680
+ terminalEligible,
681
+ });
648
682
  throw separatedFailure(error);
649
683
  }
650
684
  },
@@ -774,8 +808,10 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
774
808
  skillReads.clear();
775
809
  artifactReads.clear();
776
810
  if (artifactRefreshPending) refreshArtifactInvalidations(ctx);
777
- stateResolutionSatisfied = false;
811
+ terminalEligible = false;
778
812
  terminalDraftIntercepted = false;
813
+ resolutionAttempts = 0;
814
+ resolutionFailureReported = false;
779
815
  responseAwaitingReconciliation = false;
780
816
  const rotatesRun = snapshot.meta.specification !== undefined;
781
817
  if (rotatesRun && rehydrationPhase !== "new-bootstrap" && rehydrationPhase !== "resume-bootstrap") rehydrationPhase = "step";
@@ -853,7 +889,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
853
889
 
854
890
  pi.on("tool_execution_end", (event) => {
855
891
  if (!snapshot.config.enabled) return;
856
- if (event.toolName === PATCH_STATE_TOOL_NAME && event.isError) stateResolutionSatisfied = false;
857
892
  skillReads.recordEnd(event.toolCallId, event.toolName, event.isError);
858
893
  artifactReads.recordEnd(event.toolCallId, event.toolName, event.isError);
859
894
  });
@@ -867,23 +902,32 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
867
902
  }
868
903
  if (message.stopReason === "length" || message.stopReason === "error") {
869
904
  responseAwaitingReconciliation = false;
870
- recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, message.content);
905
+ recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, { content: message.content, terminalEligible });
871
906
  return;
872
907
  }
873
- if (!stateResolutionSatisfied) {
908
+ if (!terminalEligible) {
874
909
  responseAwaitingReconciliation = false;
875
910
  terminalDraftIntercepted = true;
876
- recordDiagnostic("Terminal draft intercepted before State Flow resolution", "terminal-pending", ctx, message.content);
877
- continueForResolution();
911
+ resolutionAttempts = Math.min(MAX_RESOLUTION_ATTEMPTS, resolutionAttempts + 1);
912
+ recordDiagnostic(`Terminal draft intercepted before State Flow eligibility (attempt ${resolutionAttempts}/${MAX_RESOLUTION_ATTEMPTS})`, "terminal-pending", ctx, {
913
+ content: message.content,
914
+ resolutionAttempt: resolutionAttempts,
915
+ terminalEligible,
916
+ });
917
+ if (resolutionAttempts < MAX_RESOLUTION_ATTEMPTS) {
918
+ continueForResolution();
919
+ } else if (!resolutionFailureReported) {
920
+ resolutionFailureReported = true;
921
+ ctx.ui.notify(`State Flow could not obtain final:true after ${MAX_RESOLUTION_ATTEMPTS} terminal attempts; committed state was preserved and no draft was accepted.`, "error");
922
+ }
878
923
  return { message: { ...message, role: "assistant" as const, content: [] } };
879
924
  }
880
925
  try {
881
- validateUnchangedResolution(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
926
+ validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
882
927
  } catch (error) {
883
- stateResolutionSatisfied = false;
884
928
  responseAwaitingReconciliation = false;
885
929
  terminalDraftIntercepted = true;
886
- recordDiagnostic(error instanceof Error ? error.message : String(error), "terminal-pending", ctx, message.content);
930
+ recordDiagnostic(error instanceof Error ? error.message : String(error), "terminal-pending", ctx, { content: message.content, terminalEligible });
887
931
  continueForResolution();
888
932
  return { message: { ...message, role: "assistant" as const, content: [] } };
889
933
  }
@@ -18,6 +18,12 @@ export interface StateFlowDiagnosticRecord {
18
18
  category: StateFlowDiagnosticCategory;
19
19
  error: string;
20
20
  content?: StateFlowDiagnosticBlock[];
21
+ /** Rejected tool arguments, captured for reproducible diagnosis. Never reasoning bodies. */
22
+ input?: unknown;
23
+ tool?: string;
24
+ toolCallId?: string;
25
+ resolutionAttempt?: number;
26
+ terminalEligible?: boolean;
21
27
  }
22
28
 
23
29
  /** Preserve exact text blocks and block boundaries; reasoning bodies are never duplicated. */
@@ -40,6 +40,13 @@ export interface ScopedPatch {
40
40
  patch: ScopePatch;
41
41
  }
42
42
 
43
+ /** Canonical model-authored scope cohort before runtime final-eligibility handling. */
44
+ export interface AtomicScopePatches {
45
+ global?: ScopePatch;
46
+ cwd?: ScopePatch;
47
+ session?: ScopePatch;
48
+ }
49
+
43
50
  export interface SemanticTransition {
44
51
  transitions: ScopedPatch[];
45
52
  }
@@ -21,15 +21,15 @@ response: previous complete answer, owned by runtime.
21
21
 
22
22
  Use read_state only for a concrete historical or scope-specific gap. It reads one cached effective/global/cwd/session projection at offset 0..7 without mutation; all scopes use the same nth prior accepted semantic boundary.
23
23
 
24
- Use patch_state as the sole model-authored semantic mutation mechanism. When durable artifacts, contract, or working state should change, call patch_state with one scope and patch. A successful semantic patch is validated, durably accepted, and rematerialized before further reasoning. Call patch_state alone in its assistant response; after its acknowledgement choose the next action from accepted state.
24
+ Use patch_state as the sole model-authored semantic mutation mechanism. Supply any combination of global, cwd, and session patches; all supplied scopes are validated and durably accepted as one atomic transition before further reasoning. Call patch_state alone in its assistant response; after its acknowledgement choose the next action from accepted state.
25
25
 
26
- Every enabled turn starts with State Flow resolution pending. Before the final answer, make at least one successful patch_state call. Each call has exactly one of two exclusive forms: PATCH {"scope":"session|cwd|global","patch":{...}} or UNCHANGED {"unchanged":true}. A successful call satisfies resolution; a semantic patch is committed and rematerialized before further reasoning. Do not combine unchanged with scope or patch. Empty or materially no-op patches are not unchanged acknowledgements. If runtime intercepts an unresolved terminal draft, it is not a final answer: follow its instruction, resolve through patch_state, then provide the final answer normally. The unchanged form creates no transition. Never write response through patch_state; runtime records what was actually delivered.
26
+ Every enabled iteration starts terminal-ineligible. Set final:true in a successful patch_state call when the iteration may finish at a later turn_end. final:true does not stop reasoning, tools, or later patch_state calls, and repeated final:true calls are allowed. Use {"final":true} when no semantic update is needed. If runtime intercepts a terminal draft before eligibility, the draft is not a final answer: follow its instruction, call patch_state with final:true, then provide the final answer normally. A final-only call creates no semantic transition. Never write response through patch_state; runtime records what was actually delivered at turn_end.
27
27
 
28
- SCOPES: session is branch/run continuation, cwd is project state and Skills, global is cross-project state. A patch changes one scope immediately. Use sequential calls for genuinely multi-scope work. Deleting an override affects only its scope and may reveal a parent value.
28
+ SCOPES: session is branch/run continuation, cwd is project state and Skills, global is cross-project state. Deleting an override affects only its scope and may reveal a parent value.
29
29
 
30
30
  ${baselineMemoryProtocol()}
31
31
 
32
- PATCH: A semantic call has exactly scope and patch. Patches use only object-valued artifacts, contract, and working; omitted fields preserve. Never patch runtime config/meta/response. Recursive merge; arrays/primitives replace; nested null deletes. Materialized null is forbidden.
32
+ PATCH: Fields are optional global, cwd, session semantic patches and optional final:true. At least one scope or final:true is required. Supplied scopes commit atomically; empty or materially no-op scopes must be omitted. Patches use only object-valued artifacts, contract, and working; omitted fields preserve. Never patch runtime config/meta/response. Recursive merge; arrays/primitives replace; nested null deletes. Materialized null is forbidden.
33
33
 
34
34
  HANDOFF: Preserve active commitments, unresolved questions, consequential results, and exact continuation. Distinguish user requirements, confirmed decisions, observations, assistant conclusions, and hypotheses. Remove stale narration and never invent memory changes.
35
35
 
@@ -12,6 +12,7 @@ import { applyPatch, containsNull, hashJson, isObject, validatePatch } from "./j
12
12
  import { hasCompiledSkillArtifact, SKILL_ARTIFACT_COMPILER, type SuccessfulSkillRead } from "./skills.ts";
13
13
  import type { Snapshot } from "./snapshot.ts";
14
14
  import type {
15
+ AtomicScopePatches,
15
16
  MaterializedState,
16
17
  ScopePatch,
17
18
  ScopedPatch,
@@ -194,8 +195,8 @@ function stageScopedSemanticTransition(
194
195
  };
195
196
  }
196
197
 
197
- /** Validate that explicit unchanged resolution has no pending acquisition/compilation obligation. */
198
- export function validateUnchangedResolution(
198
+ /** Validate that final eligibility has no pending acquisition/compilation obligation. */
199
+ export function validateFinalEligibility(
199
200
  currentStates: ScopedStates,
200
201
  successfulSkillReads: Iterable<SuccessfulSkillRead>,
201
202
  causalBasis: string,
@@ -210,17 +211,25 @@ export function validateUnchangedResolution(
210
211
  );
211
212
  }
212
213
 
213
- /** Stage one intermediate state barrier without changing the finalized response. */
214
- export function stageScopedPatch(
214
+ /** Stage one canonical atomic scope cohort without changing the finalized response. */
215
+ export function stageAtomicScopePatches(
215
216
  currentStates: ScopedStates,
216
- transition: ScopedPatch,
217
+ patches: AtomicScopePatches,
217
218
  successfulSkillReads: Iterable<SuccessfulSkillRead>,
218
219
  causalBasis: string,
219
220
  successfulArtifactReads: Iterable<SuccessfulArtifactRead> = [],
220
221
  ): StagedScopedTransition {
222
+ if (!isObject(patches)) throw new Error("Atomic State Flow scope patches must be an object");
223
+ for (const key of Object.keys(patches)) {
224
+ if (!SCOPES.has(key as StateScope)) throw new Error(`Unknown atomic State Flow scope: ${key}`);
225
+ }
226
+ const transitions: ScopedPatch[] = [];
227
+ for (const scope of ["global", "cwd", "session"] as const) {
228
+ if (Object.hasOwn(patches, scope)) transitions.push({ scope, patch: patches[scope] as ScopePatch });
229
+ }
221
230
  return stageScopedSemanticTransition(
222
231
  currentStates,
223
- { transitions: [transition] },
232
+ { transitions },
224
233
  successfulSkillReads,
225
234
  causalBasis,
226
235
  successfulArtifactReads,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -88,18 +88,18 @@ Adjust only identified defects. This is a semantic review, not a request for ext
88
88
 
89
89
  ## Apply one reconciliation cohort
90
90
 
91
- Use `patch_state` only for material changes to `artifacts`, `contract`, or `working`. Each call must be alone in its assistant response; reconsider subsequent actions from the rematerialized state. Do not patch runtime-owned `response`, config, or metadata, or bypass validation by editing backing files.
91
+ Use `patch_state` only for material changes to `artifacts`, `contract`, or `working`. One call may supply `global`, `cwd`, and `session` patches as one atomic cohort; each call must be alone in its assistant response, and subsequent actions must use the rematerialized state. Set `final:true` only when the iteration is eligible to finish at a later `turn_end`. Do not patch runtime-owned `response`, config, or metadata, or bypass validation by editing backing files.
92
92
 
93
93
  Schedule acquisition and migration barriers in this order:
94
94
 
95
95
  1. After reading this Skill, compile it into its exact-path CWD artifact before acquiring a stale global Markdown source or attempting an unrelated state write.
96
- 2. Read only the smallest required state projections. If a justified stale Markdown read creates a global compilation obligation, accept that compilation before an unrelated single-scope write.
96
+ 2. Read only the smallest required state projections. If a justified stale Markdown read creates a global compilation obligation, include every pending compilation scope in the next atomic patch before unrelated work.
97
97
  3. Write the migration destination with `patch_state`, verify it with a separate `read_state`, then delete or narrow the source and verify both its scope and the effective overlay. Do all readback before the terminal answer.
98
- 4. Complete one terminal reconciliation without repeating accepted compilations or inventing memory changes. Simultaneously pending CWD and global acquisitions require complete compilation through the existing terminal multi-scope reconciliation; do not bypass them with an insufficient single-scope patch.
98
+ 4. Complete one terminal reconciliation without repeating accepted compilations or inventing memory changes. Simultaneously pending CWD and global acquisitions must be compiled together in one atomic `patch_state` call; set `final:true` in that call only when the iteration is otherwise ready to finish.
99
99
 
100
100
  Scope-local deletion may reveal a lower-scope value. Deleting an override is not necessarily removal from effective state.
101
101
 
102
- For movement between State Flow scopes, resolve destination conflicts before writing; do not overwrite stronger or unrelated knowledge. Write and verify the destination before deleting the source. Separate calls are not an atomic multi-scope transaction; allow a temporary duplicate rather than a gap. Do not claim migration is complete until source cleanup and the effective result are verified.
102
+ For movement between State Flow scopes, resolve destination conflicts before writing; do not overwrite stronger or unrelated knowledge. Write and verify the destination before deleting the source. Do not combine destination creation and source deletion merely because multi-scope publication is atomic: preserve a temporary duplicate until readback proves the destination. Do not claim migration is complete until source cleanup and the effective result are verified.
103
103
 
104
104
  On rejection, interruption, or conflicting state, inspect what was actually accepted before continuing. Never assume the entire cohort succeeded or failed. Keep recovery bounded; report a blocker rather than repeatedly regenerating patches.
105
105
 
@@ -4,6 +4,10 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.45.2: Provider-Compatible Bind Schema Hotfix
8
+
9
+ - `Provider-Compatible Bind Argument`: Serializes the `telegram_bind` `argument` schema as an inline builder-made JSON-value union bounded to four container levels, with no `$ref`/`$defs` recursion or raw TypeBox marker leakage; OpenAI no longer rejects every request with "Recursive JSON schemas are not currently supported" (#273) and Gemini no longer rejects the unknown `~optional` field (#269) while the tool is registered.
10
+
7
11
  ## 0.45.1: Guest Mode And Channel Media Hotfixes
8
12
 
9
13
  - `Environment-backed bot tokens`: `telegram.json` profiles may store an exact `$NAME`/`${NAME}` reference instead of a copied token. Resolution happens only at validation/activation boundaries, including pairing identity hashing; setup prefills the first supported alias, validates the resolved value, and persists the alias; literals stay compatible; unresolved references fail closed with a redacted named-variable diagnostic in setup, connect, and status.
@@ -98,7 +98,7 @@ An absent, stale, or invalid bound app fails closed and never degrades into an a
98
98
 
99
99
  ## `telegram_bind` Tool
100
100
 
101
- One agent Tool owns installation and deliberate invocation through two mutually exclusive shapes. Its optional `argument` schema explicitly describes recursive JSON values (`null`, boolean, number, string, array, or object) rather than using an unconstrained subschema. Pi keeps the same JSON semantics, while schema aggregators cannot lower this field to a bare `true` schema that some llama-server grammars reject. The recursive definition lives in the tool root `$defs` and uses only local `#/$defs/TelegramBindJsonValue` references; it does not rely on named/remote reference resolution or newer TypeBox runtime helpers.
101
+ One agent Tool owns installation and deliberate invocation through two mutually exclusive shapes. Its optional `argument` schema explicitly describes JSON values (`null`, boolean, number, string, array, or object) as a bounded union nested four container levels deep rather than using an unconstrained subschema. Pi keeps the same JSON semantics, while schema aggregators cannot lower this field to a bare `true` schema that some llama-server grammars reject. The union is built through standard schema builders and serialized inline with no `$ref`/`$defs` recursion, so provider tool APIs that reject recursive or reference-resolved schemas (OpenAI) accept it alongside providers strict about unknown schema fields (Gemini).
102
102
 
103
103
  Install an external self-contained module and initialize it:
104
104
 
@@ -862,21 +862,23 @@ export function formatGenerativeAppToolError(error: unknown): Error {
862
862
  return new Error(`\n${message.replace(/^\n+/u, "") || "Generative App operation failed."}`);
863
863
  }
864
864
 
865
- const TELEGRAM_BIND_JSON_ARGUMENT_REFERENCE = "#/$defs/TelegramBindJsonValue";
866
- const TELEGRAM_BIND_JSON_ARGUMENT_SCHEMA = {
867
- $ref: TELEGRAM_BIND_JSON_ARGUMENT_REFERENCE,
868
- } as unknown as ReturnType<typeof Type.Unknown>;
869
- const TELEGRAM_BIND_JSON_ARGUMENT_DEFINITION = {
870
- anyOf: [
871
- { type: "null" },
872
- { type: "boolean" },
873
- { type: "number" },
874
- { type: "string" },
875
- { type: "array", items: { $ref: TELEGRAM_BIND_JSON_ARGUMENT_REFERENCE } },
876
- { type: "object", properties: {},
877
- additionalProperties: { $ref: TELEGRAM_BIND_JSON_ARGUMENT_REFERENCE } },
878
- ],
879
- };
865
+ // Provider tool APIs reject recursive $ref schemas (OpenAI) and raw TypeBox
866
+ // optional markers on non-builder objects (Gemini), while llama-server rejects
867
+ // unconstrained subschemas; one bounded builder-made JSON-value union satisfies all.
868
+ const TELEGRAM_BIND_JSON_ARGUMENT_MAX_DEPTH = 4;
869
+
870
+ function createTelegramBindJsonArgumentSchema(
871
+ depth: number,
872
+ ): ReturnType<typeof Type.Union> {
873
+ const scalars = [Type.Null(), Type.Boolean(), Type.Number(), Type.String()];
874
+ if (depth <= 0) return Type.Union(scalars);
875
+ const nested = createTelegramBindJsonArgumentSchema(depth - 1);
876
+ return Type.Union([
877
+ ...scalars,
878
+ Type.Array(nested),
879
+ Type.Object({}, { additionalProperties: nested }),
880
+ ]);
881
+ }
880
882
 
881
883
  export function registerTelegramBindTool(
882
884
  pi: ExtensionAPI,
@@ -893,10 +895,10 @@ export function registerTelegramBindTool(
893
895
  method: Type.Optional(Type.String()),
894
896
  replace: Type.Optional(Type.Boolean()),
895
897
  display: Type.Optional(Type.Boolean()),
896
- argument: Type.Optional(TELEGRAM_BIND_JSON_ARGUMENT_SCHEMA),
897
- }, { additionalProperties: false,
898
- $defs: { TelegramBindJsonValue: TELEGRAM_BIND_JSON_ARGUMENT_DEFINITION },
899
- }),
898
+ argument: Type.Optional(
899
+ createTelegramBindJsonArgumentSchema(TELEGRAM_BIND_JSON_ARGUMENT_MAX_DEPTH),
900
+ ),
901
+ }, { additionalProperties: false }),
900
902
  async execute(_toolCallId, params) {
901
903
  try {
902
904
  const result = await bindGenerativeApp({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.45.1",
3
+ "version": "0.45.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -44,8 +44,8 @@
44
44
  "@llblab/pi-clean-room": "0.1.1",
45
45
  "@llblab/pi-codex-usage": "0.9.4",
46
46
  "@llblab/pi-grow-loop": "0.7.5",
47
- "@llblab/pi-state-flow": "0.7.0",
48
- "@llblab/pi-telegram": "0.45.1",
47
+ "@llblab/pi-state-flow": "0.8.0",
48
+ "@llblab/pi-telegram": "0.45.2",
49
49
  "@llblab/skills": "1.15.0"
50
50
  },
51
51
  "bundledDependencies": [