ph-stabilize 0.1.0__tar.gz

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.
@@ -0,0 +1,28 @@
1
+ # Build and environment
2
+ .venv/
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ __pycache__/
7
+ *.py[cod]
8
+ jjt/
9
+ w2/
10
+
11
+ # Tooling caches
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ htmlcov/
17
+ # Dropped at the repo root by pytest-textual-snapshot when a snapshot test
18
+ # fails. The reference snapshots under `__snapshots__/` are the committed
19
+ # expectation; this is the diff viewer for a run that did not match one.
20
+ snapshot_report.html
21
+
22
+ # Reference checkouts of the upstream projects this port reads from. Vendored
23
+ # locally so the plans' citations are verifiable; never part of this repo.
24
+ sources/
25
+
26
+ # Local scratch
27
+ .ph/
28
+ *.local.yaml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Charles Tabor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,248 @@
1
+ Metadata-Version: 2.5
2
+ Name: ph-stabilize
3
+ Version: 0.1.0
4
+ Summary: pH stabilization bundle: todo, offload, compaction, limits, HITL and permissions.
5
+ Project-URL: Homepage, https://github.com/chastabor/pH
6
+ Project-URL: Repository, https://github.com/chastabor/pH
7
+ Project-URL: Documentation, https://github.com/chastabor/pH/blob/main/docs/README.md
8
+ Project-URL: Issues, https://github.com/chastabor/pH/issues
9
+ Author: Charles Tabor
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent,compaction,hitl,llm,permissions,todo
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Software Development
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: ph-core==0.1.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # ph-stabilize
28
+
29
+ *The things that keep a long session from falling over: a plan, offloading in
30
+ both directions, compaction, limits, a human in the loop, and path rules — each
31
+ a row on a seam that already exists.*
32
+
33
+ Deep Agents' stabilization features are *algorithms plus prompts, not runtime*,
34
+ so none of them lands as a parameter on the agent loop. Todo planning is a tool
35
+ plus a prompt section. Offloading is a `tools/post-execute` listener.
36
+ Summarization is `agent/pre-step`. The driver does not know any of them is
37
+ there — which is the whole integration thesis (D12), and the reason a deployment
38
+ that wants the plain harness gets the plain harness.
39
+
40
+ ```bash
41
+ phern --profile rlm-stable --provider llama --model <model> --mode tui
42
+ ```
43
+
44
+ The package registers the `stabilize` **bundle** (`src/ph_stabilize/bundle.yaml`),
45
+ which `ph-app` composes through the `ph.bundles` entry-point group without
46
+ depending on this distribution.
47
+
48
+ **Every profile `ph-app` offers layers it** — `base` included — as an *optional*
49
+ bundle: an install without this distribution composes the same profiles and
50
+ simply never compacts. Compaction is the row that earns that, because a session
51
+ that grows until the provider refuses it is a defect in any posture; the rest
52
+ come along because a bundle is the unit a profile can name, and they are inert
53
+ until configured. `phern doctor` reports which of them actually activated.
54
+
55
+ ## The rows
56
+
57
+ | row | what it does | shipped |
58
+ |---|---|---|
59
+ | `tool-todo` | `write_todos`, the `todo/write` event, the prompt section, and the one-call-per-turn rule | **off** |
60
+ | `skill-steps` | a `SKILL.md` that declares `steps:` becomes work the turn must finish | **off** |
61
+ | `tool-result-offload` | a result over the threshold goes to the spill store; the model gets a head-and-tail preview and the path | on |
62
+ | `input-offload` | the other direction: a pasted build log or dumped table | on |
63
+ | `compaction-summarize` | the conversation replaced by a summary when it stops fitting | on |
64
+ | `command-compact` | `/compact`, the human verb for the same thing | on |
65
+ | `limits` | model-call, tool-call and child ceilings, plus a consecutive-failure breaker | on, every ceiling unset |
66
+ | `hitl` | a person between the model and what it cannot take back | on, asking about nothing |
67
+ | `permissions-fs` | path rules over `ctx.fs` | on, with one default write rule |
68
+
69
+ **"On" mostly means "inert."** Layering this bundle must not, by itself, change
70
+ what a deployment does: `limits` mounts with every ceiling unset, `hitl` with an
71
+ empty `interruptOn`, `permissions-fs` with rules that allow everything inside
72
+ the workspace. A harness that starts asking on first run teaches its user to
73
+ approve without reading, and a limit nobody chose is a limit that fires on
74
+ somebody's longest legitimate turn.
75
+
76
+ The two that ship **off** are off for a different reason: they hand the model a
77
+ tool or keep a turn going, and that is a posture a profile chooses rather than
78
+ inherits. `rlm-stable` is the profile that chooses it — and the reason the
79
+ bundle reaching every profile does not thereby give every deployment a todo
80
+ tool.
81
+
82
+ The one row that is **not** inert on arrival is `permissions-fs`: it ships a
83
+ rule sending writes outside the agent's workspace to `interrupt`. That is E6's
84
+ intended default rather than an oversight, but it is a behaviour change a
85
+ deployment should know it inherited — `phern config --row permissions-fs` prints
86
+ what is in force.
87
+
88
+ ## The tool, and the command
89
+
90
+ - **`write_todos`** (row `tool-todo`) — the whole list, replaced. The list lives
91
+ in the log and nowhere else, so the TUI sidebar and the model's own view are
92
+ one projection rather than two that can disagree, and it survives a resume and
93
+ a fork for free. Two deliberate forks from upstream: `requires` declares what
94
+ a step waits on (upstream works around the gap in *prose*, which is a
95
+ dependency graph nothing can read or check), and a completed entry carries
96
+ `worked` — the number of tools the harness saw run while it was being
97
+ finished — which is the one field the model does not write, because a receipt
98
+ the claimant issues is not a receipt.
99
+ - **`/compact`** (row `command-compact`) — replace older history with a summary,
100
+ optionally told what you are about to work on. A command, not a turn: it
101
+ dispatches directly, records `command/run`/`command/done`, and insists the
102
+ agent is idle first, because a compaction landing mid-turn would move the
103
+ surface underneath a request the loop had already derived.
104
+
105
+ Every failure is a sentence rather than a traceback — `CompactionError.code` is
106
+ a closed set precisely so a front end can phrase each one.
107
+
108
+ ## Including it, and adjusting it
109
+
110
+ `rlm-stable` is `rlm` plus this bundle plus the rows it arms. Without a profile
111
+ that names the bundle, insert the rows you want by name — they are ordinary
112
+ plugin entry points, so this works against any profile:
113
+
114
+ ```bash
115
+ phern --patch '{insert: [{id: tool-result-offload, name: tool-result-offload}]}' \
116
+ --profile llama -p "…"
117
+ ```
118
+
119
+ ```yaml
120
+ # $PH_HOME/profiles/rlm-stable.yaml — ceilings, and a narrower file rule
121
+ - id: limits
122
+ config:
123
+ modelCalls: {turnLimit: 40, sessionLimit: 400, exit: end}
124
+ toolCalls: {turnLimit: 100, perTool: {bash: {turnLimit: 20}}, exit: continue}
125
+ breaker: {consecutiveFailures: 5}
126
+
127
+ - id: permissions-fs
128
+ config:
129
+ rules:
130
+ - operations: [read]
131
+ paths: ["**/.env", "**/*.pem"]
132
+ mode: deny
133
+ - operations: [write]
134
+ paths: ["**"]
135
+ scope: outside-workspace
136
+ mode: interrupt
137
+ ```
138
+
139
+ | row | config | default |
140
+ |---|---|---|
141
+ | `tool-result-offload` | `tokenLimit`, `maxInlineBytes`, `excludedTools` | `20000`; `None` disables offloading |
142
+ | `input-offload` | `tokenLimit` | `50000` |
143
+ | `compaction-summarize` | `auto`, `triggerFraction`, `keepFraction`, `triggerTokens`, `keepMessages`, `maxTokens`, `summaryInputTokens`, `truncateArgs`, `overflowClipTokens` | `0.85` / `0.10` of a known window; `170000` tokens / `6` messages when it is not — upstream's numbers |
144
+ | `limits` | `modelCalls` (`exit: end \| error`), `toolCalls` (plus `perTool`, `exit: continue \| end \| error`), `children`, `breaker.consecutiveFailures` — each budget takes `turnLimit` and `sessionLimit` | all ceilings unset; breaker `5` |
145
+ | `hitl` | `mode` (`manual` \| `auto` \| `yolo`), `declared`, `interruptOn` | `auto`, nothing configured |
146
+ | `permissions-fs` | `rules` — each `operations`, `paths`, `scope` (`anywhere` \| `outside-workspace`), `mode` (default `deny`), `description` | one rule: `write` anywhere `outside-workspace` → `interrupt` |
147
+ | `tool-todo`, `skill-steps`, `command-compact` | — | no configuration |
148
+
149
+ ### Writing a `hitl` rule
150
+
151
+ Keys are tool names, and each takes a `preset`, a `when:` list of regexes, a
152
+ `description` and an allowed decision set. `rlm-stable`'s own rules are the
153
+ worked example:
154
+
155
+ ```yaml
156
+ - id: hitl
157
+ config:
158
+ interruptOn:
159
+ bash:
160
+ preset: destructive
161
+ when: ["\\b(npm|pnpm|yarn|uv|pip)\\s+publish\\b", "\\bsudo\\b"]
162
+ description: >-
163
+ This command changes something a worktree cannot undo.
164
+ run_code:
165
+ preset: destructive
166
+ when: ["\\bsubprocess\\b"]
167
+ allowedDecisions: [approve, reject]
168
+ ```
169
+
170
+ Three things that rule says, spelled out:
171
+
172
+ - **Name the preset rather than retyping it.** `destructive` is the set this
173
+ package ships and tests. The first draft of `rlm-stable.yaml` retyped a subset
174
+ and had already widened `git push` from force-only to *every* push, against a
175
+ shipped test pinning an ordinary push as ordinary — one security judgement
176
+ beats two that disagree.
177
+ - **Key Code Mode on the *reserved* name.** The registry renames the transport
178
+ to whatever the presentation calls it (`ipython`, in the `rlm` bundle) and
179
+ `hitl` resolves that. Keying on the presented name would not turn the gate off
180
+ — the tool declares `is_irreversible`, so `declared` still catches it — but
181
+ the `when:` additions are keyed by name and would silently stop applying.
182
+ - **Four decisions, not two.** `approve` and `reject` were always reachable;
183
+ this row adds `edit` (run it with these arguments instead) and `respond` (do
184
+ not run it — tell the model this), because stopping a turn to say "wrong path"
185
+ or "you don't need that, the answer is X" costs a round trip that answering in
186
+ place does not. `allowedDecisions` narrows the set — a program is not
187
+ something to hand-patch in a modal, so `run_code` above drops `edit`.
188
+
189
+ **The classifier parses; it does not pattern-match.** `preset: destructive` runs
190
+ `ph_stabilize.destructive`, which reads each string argument in its own dialect
191
+ — shell through `shlex`, SQL as statements, Python through `ast` — and judges
192
+ the *structure*. The twelve regexes it replaced were run over the call's
193
+ arguments rendered as JSON, so a real newline became the two characters `\` and
194
+ `n` and every `\b`-anchored pattern stopped matching on a second line: `rm -rf`,
195
+ `DROP TABLE`, `shutil.rmtree` and `curl | sh` were all ungated inside a
196
+ multi-line cell, which is *every* `run_code` cell. Nothing failed; the gate
197
+ simply did not fire. A `when:` list is still regex, as the deployment's own
198
+ escape hatch, but it is matched against the decoded strings.
199
+
200
+ ## Limitations, and things that are deliberate
201
+
202
+ - **`permissions-fs` bounds seam-mediated access only** (E9, N1). The rules
203
+ attach to `ctx.fs`'s intent waterfalls, so any tool going through the seam is
204
+ covered without this module knowing a tool name — and a model-authored
205
+ `open(path, "w")` inside a code cell, or a `subprocess` that shells out, never
206
+ fires an intent and is not touched by *these rules*. What bounds those is the
207
+ rung below: `code-runtime-python` confines the kernel itself. The row says so
208
+ at mount and carries the sentence on `ctx.fs_permissions.reach`, so the
209
+ statement toggles with the sandbox rather than being a paragraph in a README
210
+ that is wrong half the time.
211
+ - **Offloading never deletes.** Both directions are a surface `replace`: the log
212
+ keeps every event, `derive_messages()` yields the preview, and `transcript()`
213
+ still shows the person what they sent. Rewriting a message *before* logging it
214
+ was the alternative and is refused — the log would then attribute
215
+ harness-authored text to the human.
216
+ - **Offloading fails open.** If the spill write fails, the original result is
217
+ kept: an offload that cannot store the content must not be the reason the
218
+ model loses it.
219
+ - **Self-limiting tools are left alone, and they say so themselves.** A tool
220
+ that bounds its own output and offers a way to page (`read` with an offset,
221
+ `grep` with a match cap) sets `ToolDefinition.self_limits`. Upstream matches a
222
+ hardcoded list of *its* tool names; pH asks the tool, because a deployment
223
+ renames them and an MCP server adds its own. `excludedTools` is the escape
224
+ hatch for a third-party tool whose author has not declared.
225
+ - **Compaction can decline.** The cut is moved back to the nearest balanced
226
+ boundary so a call is never split from its result; if the only balanced cut is
227
+ the start of the conversation, nothing is compacted and the attempt **says
228
+ so**. Shipping an orphaned `tool-result` to a provider that rejects it is not
229
+ a repair. Two cheaper remedies run first — eliding over-long call arguments in
230
+ retained history, then spilling the trailing tool-result batch.
231
+ - **Limits are a fold over the log, not a counter in memory.** A limit that
232
+ lives in a field is a limit a resume forgets. Upstream's *thread* and *run*
233
+ map to pH's **session** and **turn**, renamed once here so a diff against
234
+ `model_call_limit.py` stays readable.
235
+ - **`skill-steps` holds the model to its own accepted plan, not to reality.**
236
+ Marking a step done is still the model's word; it asserts only that work
237
+ remains which *can* begin, and never claims to know which step is running.
238
+ Gates that check the world are `ctx.goals` and `ctx.approval`. It is also
239
+ useless without `tool-todo` — the list it seeds is that row's — so a profile
240
+ enabling one enables both.
241
+
242
+ ## Tests
243
+
244
+ `tests/` — nine modules, one per feature plus the classifier. The destructive-command
245
+ tables are a starting point and are meant to grow: neither list claims to be
246
+ complete, and the honest posture is that the gate reports what it found, in the
247
+ parser's own words, so a person is told what the harness saw and an auditor can
248
+ tune it.
@@ -0,0 +1,222 @@
1
+ # ph-stabilize
2
+
3
+ *The things that keep a long session from falling over: a plan, offloading in
4
+ both directions, compaction, limits, a human in the loop, and path rules — each
5
+ a row on a seam that already exists.*
6
+
7
+ Deep Agents' stabilization features are *algorithms plus prompts, not runtime*,
8
+ so none of them lands as a parameter on the agent loop. Todo planning is a tool
9
+ plus a prompt section. Offloading is a `tools/post-execute` listener.
10
+ Summarization is `agent/pre-step`. The driver does not know any of them is
11
+ there — which is the whole integration thesis (D12), and the reason a deployment
12
+ that wants the plain harness gets the plain harness.
13
+
14
+ ```bash
15
+ phern --profile rlm-stable --provider llama --model <model> --mode tui
16
+ ```
17
+
18
+ The package registers the `stabilize` **bundle** (`src/ph_stabilize/bundle.yaml`),
19
+ which `ph-app` composes through the `ph.bundles` entry-point group without
20
+ depending on this distribution.
21
+
22
+ **Every profile `ph-app` offers layers it** — `base` included — as an *optional*
23
+ bundle: an install without this distribution composes the same profiles and
24
+ simply never compacts. Compaction is the row that earns that, because a session
25
+ that grows until the provider refuses it is a defect in any posture; the rest
26
+ come along because a bundle is the unit a profile can name, and they are inert
27
+ until configured. `phern doctor` reports which of them actually activated.
28
+
29
+ ## The rows
30
+
31
+ | row | what it does | shipped |
32
+ |---|---|---|
33
+ | `tool-todo` | `write_todos`, the `todo/write` event, the prompt section, and the one-call-per-turn rule | **off** |
34
+ | `skill-steps` | a `SKILL.md` that declares `steps:` becomes work the turn must finish | **off** |
35
+ | `tool-result-offload` | a result over the threshold goes to the spill store; the model gets a head-and-tail preview and the path | on |
36
+ | `input-offload` | the other direction: a pasted build log or dumped table | on |
37
+ | `compaction-summarize` | the conversation replaced by a summary when it stops fitting | on |
38
+ | `command-compact` | `/compact`, the human verb for the same thing | on |
39
+ | `limits` | model-call, tool-call and child ceilings, plus a consecutive-failure breaker | on, every ceiling unset |
40
+ | `hitl` | a person between the model and what it cannot take back | on, asking about nothing |
41
+ | `permissions-fs` | path rules over `ctx.fs` | on, with one default write rule |
42
+
43
+ **"On" mostly means "inert."** Layering this bundle must not, by itself, change
44
+ what a deployment does: `limits` mounts with every ceiling unset, `hitl` with an
45
+ empty `interruptOn`, `permissions-fs` with rules that allow everything inside
46
+ the workspace. A harness that starts asking on first run teaches its user to
47
+ approve without reading, and a limit nobody chose is a limit that fires on
48
+ somebody's longest legitimate turn.
49
+
50
+ The two that ship **off** are off for a different reason: they hand the model a
51
+ tool or keep a turn going, and that is a posture a profile chooses rather than
52
+ inherits. `rlm-stable` is the profile that chooses it — and the reason the
53
+ bundle reaching every profile does not thereby give every deployment a todo
54
+ tool.
55
+
56
+ The one row that is **not** inert on arrival is `permissions-fs`: it ships a
57
+ rule sending writes outside the agent's workspace to `interrupt`. That is E6's
58
+ intended default rather than an oversight, but it is a behaviour change a
59
+ deployment should know it inherited — `phern config --row permissions-fs` prints
60
+ what is in force.
61
+
62
+ ## The tool, and the command
63
+
64
+ - **`write_todos`** (row `tool-todo`) — the whole list, replaced. The list lives
65
+ in the log and nowhere else, so the TUI sidebar and the model's own view are
66
+ one projection rather than two that can disagree, and it survives a resume and
67
+ a fork for free. Two deliberate forks from upstream: `requires` declares what
68
+ a step waits on (upstream works around the gap in *prose*, which is a
69
+ dependency graph nothing can read or check), and a completed entry carries
70
+ `worked` — the number of tools the harness saw run while it was being
71
+ finished — which is the one field the model does not write, because a receipt
72
+ the claimant issues is not a receipt.
73
+ - **`/compact`** (row `command-compact`) — replace older history with a summary,
74
+ optionally told what you are about to work on. A command, not a turn: it
75
+ dispatches directly, records `command/run`/`command/done`, and insists the
76
+ agent is idle first, because a compaction landing mid-turn would move the
77
+ surface underneath a request the loop had already derived.
78
+
79
+ Every failure is a sentence rather than a traceback — `CompactionError.code` is
80
+ a closed set precisely so a front end can phrase each one.
81
+
82
+ ## Including it, and adjusting it
83
+
84
+ `rlm-stable` is `rlm` plus this bundle plus the rows it arms. Without a profile
85
+ that names the bundle, insert the rows you want by name — they are ordinary
86
+ plugin entry points, so this works against any profile:
87
+
88
+ ```bash
89
+ phern --patch '{insert: [{id: tool-result-offload, name: tool-result-offload}]}' \
90
+ --profile llama -p "…"
91
+ ```
92
+
93
+ ```yaml
94
+ # $PH_HOME/profiles/rlm-stable.yaml — ceilings, and a narrower file rule
95
+ - id: limits
96
+ config:
97
+ modelCalls: {turnLimit: 40, sessionLimit: 400, exit: end}
98
+ toolCalls: {turnLimit: 100, perTool: {bash: {turnLimit: 20}}, exit: continue}
99
+ breaker: {consecutiveFailures: 5}
100
+
101
+ - id: permissions-fs
102
+ config:
103
+ rules:
104
+ - operations: [read]
105
+ paths: ["**/.env", "**/*.pem"]
106
+ mode: deny
107
+ - operations: [write]
108
+ paths: ["**"]
109
+ scope: outside-workspace
110
+ mode: interrupt
111
+ ```
112
+
113
+ | row | config | default |
114
+ |---|---|---|
115
+ | `tool-result-offload` | `tokenLimit`, `maxInlineBytes`, `excludedTools` | `20000`; `None` disables offloading |
116
+ | `input-offload` | `tokenLimit` | `50000` |
117
+ | `compaction-summarize` | `auto`, `triggerFraction`, `keepFraction`, `triggerTokens`, `keepMessages`, `maxTokens`, `summaryInputTokens`, `truncateArgs`, `overflowClipTokens` | `0.85` / `0.10` of a known window; `170000` tokens / `6` messages when it is not — upstream's numbers |
118
+ | `limits` | `modelCalls` (`exit: end \| error`), `toolCalls` (plus `perTool`, `exit: continue \| end \| error`), `children`, `breaker.consecutiveFailures` — each budget takes `turnLimit` and `sessionLimit` | all ceilings unset; breaker `5` |
119
+ | `hitl` | `mode` (`manual` \| `auto` \| `yolo`), `declared`, `interruptOn` | `auto`, nothing configured |
120
+ | `permissions-fs` | `rules` — each `operations`, `paths`, `scope` (`anywhere` \| `outside-workspace`), `mode` (default `deny`), `description` | one rule: `write` anywhere `outside-workspace` → `interrupt` |
121
+ | `tool-todo`, `skill-steps`, `command-compact` | — | no configuration |
122
+
123
+ ### Writing a `hitl` rule
124
+
125
+ Keys are tool names, and each takes a `preset`, a `when:` list of regexes, a
126
+ `description` and an allowed decision set. `rlm-stable`'s own rules are the
127
+ worked example:
128
+
129
+ ```yaml
130
+ - id: hitl
131
+ config:
132
+ interruptOn:
133
+ bash:
134
+ preset: destructive
135
+ when: ["\\b(npm|pnpm|yarn|uv|pip)\\s+publish\\b", "\\bsudo\\b"]
136
+ description: >-
137
+ This command changes something a worktree cannot undo.
138
+ run_code:
139
+ preset: destructive
140
+ when: ["\\bsubprocess\\b"]
141
+ allowedDecisions: [approve, reject]
142
+ ```
143
+
144
+ Three things that rule says, spelled out:
145
+
146
+ - **Name the preset rather than retyping it.** `destructive` is the set this
147
+ package ships and tests. The first draft of `rlm-stable.yaml` retyped a subset
148
+ and had already widened `git push` from force-only to *every* push, against a
149
+ shipped test pinning an ordinary push as ordinary — one security judgement
150
+ beats two that disagree.
151
+ - **Key Code Mode on the *reserved* name.** The registry renames the transport
152
+ to whatever the presentation calls it (`ipython`, in the `rlm` bundle) and
153
+ `hitl` resolves that. Keying on the presented name would not turn the gate off
154
+ — the tool declares `is_irreversible`, so `declared` still catches it — but
155
+ the `when:` additions are keyed by name and would silently stop applying.
156
+ - **Four decisions, not two.** `approve` and `reject` were always reachable;
157
+ this row adds `edit` (run it with these arguments instead) and `respond` (do
158
+ not run it — tell the model this), because stopping a turn to say "wrong path"
159
+ or "you don't need that, the answer is X" costs a round trip that answering in
160
+ place does not. `allowedDecisions` narrows the set — a program is not
161
+ something to hand-patch in a modal, so `run_code` above drops `edit`.
162
+
163
+ **The classifier parses; it does not pattern-match.** `preset: destructive` runs
164
+ `ph_stabilize.destructive`, which reads each string argument in its own dialect
165
+ — shell through `shlex`, SQL as statements, Python through `ast` — and judges
166
+ the *structure*. The twelve regexes it replaced were run over the call's
167
+ arguments rendered as JSON, so a real newline became the two characters `\` and
168
+ `n` and every `\b`-anchored pattern stopped matching on a second line: `rm -rf`,
169
+ `DROP TABLE`, `shutil.rmtree` and `curl | sh` were all ungated inside a
170
+ multi-line cell, which is *every* `run_code` cell. Nothing failed; the gate
171
+ simply did not fire. A `when:` list is still regex, as the deployment's own
172
+ escape hatch, but it is matched against the decoded strings.
173
+
174
+ ## Limitations, and things that are deliberate
175
+
176
+ - **`permissions-fs` bounds seam-mediated access only** (E9, N1). The rules
177
+ attach to `ctx.fs`'s intent waterfalls, so any tool going through the seam is
178
+ covered without this module knowing a tool name — and a model-authored
179
+ `open(path, "w")` inside a code cell, or a `subprocess` that shells out, never
180
+ fires an intent and is not touched by *these rules*. What bounds those is the
181
+ rung below: `code-runtime-python` confines the kernel itself. The row says so
182
+ at mount and carries the sentence on `ctx.fs_permissions.reach`, so the
183
+ statement toggles with the sandbox rather than being a paragraph in a README
184
+ that is wrong half the time.
185
+ - **Offloading never deletes.** Both directions are a surface `replace`: the log
186
+ keeps every event, `derive_messages()` yields the preview, and `transcript()`
187
+ still shows the person what they sent. Rewriting a message *before* logging it
188
+ was the alternative and is refused — the log would then attribute
189
+ harness-authored text to the human.
190
+ - **Offloading fails open.** If the spill write fails, the original result is
191
+ kept: an offload that cannot store the content must not be the reason the
192
+ model loses it.
193
+ - **Self-limiting tools are left alone, and they say so themselves.** A tool
194
+ that bounds its own output and offers a way to page (`read` with an offset,
195
+ `grep` with a match cap) sets `ToolDefinition.self_limits`. Upstream matches a
196
+ hardcoded list of *its* tool names; pH asks the tool, because a deployment
197
+ renames them and an MCP server adds its own. `excludedTools` is the escape
198
+ hatch for a third-party tool whose author has not declared.
199
+ - **Compaction can decline.** The cut is moved back to the nearest balanced
200
+ boundary so a call is never split from its result; if the only balanced cut is
201
+ the start of the conversation, nothing is compacted and the attempt **says
202
+ so**. Shipping an orphaned `tool-result` to a provider that rejects it is not
203
+ a repair. Two cheaper remedies run first — eliding over-long call arguments in
204
+ retained history, then spilling the trailing tool-result batch.
205
+ - **Limits are a fold over the log, not a counter in memory.** A limit that
206
+ lives in a field is a limit a resume forgets. Upstream's *thread* and *run*
207
+ map to pH's **session** and **turn**, renamed once here so a diff against
208
+ `model_call_limit.py` stays readable.
209
+ - **`skill-steps` holds the model to its own accepted plan, not to reality.**
210
+ Marking a step done is still the model's word; it asserts only that work
211
+ remains which *can* begin, and never claims to know which step is running.
212
+ Gates that check the world are `ctx.goals` and `ctx.approval`. It is also
213
+ useless without `tool-todo` — the list it seeds is that row's — so a profile
214
+ enabling one enables both.
215
+
216
+ ## Tests
217
+
218
+ `tests/` — nine modules, one per feature plus the classifier. The destructive-command
219
+ tables are a starting point and are meant to grow: neither list claims to be
220
+ complete, and the honest posture is that the gate reports what it found, in the
221
+ parser's own words, so a person is told what the harness saw and an auditor can
222
+ tune it.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ph-stabilize"
7
+ version = "0.1.0"
8
+ description = "pH stabilization bundle: todo, offload, compaction, limits, HITL and permissions."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Charles Tabor" }]
14
+ keywords = ["agent", "llm", "compaction", "todo", "hitl", "permissions"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Operating System :: MacOS",
23
+ "Topic :: Software Development",
24
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = ["ph-core==0.1.0"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/chastabor/pH"
31
+ Repository = "https://github.com/chastabor/pH"
32
+ Documentation = "https://github.com/chastabor/pH/blob/main/docs/README.md"
33
+ Issues = "https://github.com/chastabor/pH/issues"
34
+
35
+ [project.entry-points."ph.bundles"]
36
+ stabilize = "ph_stabilize:BUNDLE"
37
+
38
+ [project.entry-points."ph.plugins"]
39
+ command-compact = "ph_stabilize.compact_command:apply"
40
+ compaction-summarize = "ph_stabilize.compaction:apply"
41
+ hitl = "ph_stabilize.hitl:apply"
42
+ input-offload = "ph_stabilize.input_offload:apply"
43
+ limits = "ph_stabilize.limits:apply"
44
+ permissions-fs = "ph_stabilize.permissions_fs:apply"
45
+ tool-result-offload = "ph_stabilize.offload:apply"
46
+ tool-todo = "ph_stabilize.todo:apply"
47
+ skill-steps = "ph_stabilize.skill_steps:apply"
48
+
49
+ [tool.uv.sources]
50
+ ph-core = { workspace = true }
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/ph_stabilize"]
@@ -0,0 +1,17 @@
1
+ """pH stabilization bundle: todo, offload, compaction, limits, HITL and permissions.
2
+
3
+ Deep Agents' features are *algorithms plus prompts, not runtime* (§1.3 of the
4
+ port plan), so each one lands here as a row on a seam `ph-core` already
5
+ publishes. Reserved in Phase 0 (P0-01); built from Phase 4.
6
+
7
+ @module ph_stabilize
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ BUNDLE = Path(__file__).parent / "bundle.yaml"
15
+ """The rows the `stabilize` layer adds over `ph-base`."""
16
+
17
+ __all__ = ["BUNDLE"]