@junghanacs/entwurf 0.12.8 → 0.12.9
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/AGENTS.md +4 -4
- package/CHANGELOG.md +20 -1
- package/DELIVERY.md +1 -1
- package/README.md +122 -10
- package/demo/README.md +2 -2
- package/docs/setup-clean-host.md +14 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +9 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +8 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +483 -34
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
- package/mcp/entwurf-bridge/src/index.ts +10 -12
- package/package.json +7 -7
- package/pi-extensions/entwurf-control.ts +12 -12
- package/pi-extensions/lib/acp/models.ts +12 -12
- package/pi-extensions/lib/entwurf-fact-provider.ts +9 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +8 -2
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +8 -2
- package/pi-extensions/lib/meta-sender-identity.ts +15 -5
- package/pi-extensions/lib/meta-session.ts +526 -38
- package/pi-extensions/meta-bridge-hook.ts +8 -2
- package/run.sh +30 -26
- package/scripts/agy-imprint.ts +15 -1
- package/scripts/check-acp-session-reuse.ts +1 -1
- package/scripts/check-acp-session-store.ts +3 -3
- package/scripts/check-agy-sender-identity.ts +83 -0
- package/scripts/check-entwurf-control-rpc.ts +2 -2
- package/scripts/check-entwurf-fact-provider.ts +9 -1
- package/scripts/check-entwurf-facts.ts +1 -1
- package/scripts/check-entwurf-mailbox-guard.ts +6 -2
- package/scripts/check-entwurf-resume-args.ts +6 -6
- package/scripts/check-entwurf-session-identity.ts +7 -6
- package/scripts/check-entwurf-v2-production.ts +4 -2
- package/scripts/check-entwurf-v2-spawn-production.ts +2 -2
- package/scripts/check-fresh-cut-gate.sh +305 -4
- package/scripts/check-meta-identity-consumers.ts +501 -1
- package/scripts/check-meta-listing.ts +91 -9
- package/scripts/check-meta-receiver-marker.ts +54 -0
- package/scripts/check-model-lock.ts +1 -1
- package/scripts/meta-bridge-fresh-cut.ts +164 -28
- package/scripts/pi_settings_io.py +65 -0
- package/scripts/register-pi-package.py +183 -37
- package/scripts/register-pi-provider.py +68 -10
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-meta-install-state.sh +1 -1
- package/scripts/smoke-pi-attach.ts +7 -2
- package/scripts/smoke-user-scope-citizen.sh +177 -0
|
@@ -1,18 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Register (or --remove) entwurf in a pi settings.json packages[].
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
remove, so
|
|
4
|
+
One MATCHING predicate (is_entwurf_source) + one idempotency / fail-loud SSOT,
|
|
5
|
+
shared by BOTH scopes and by remove, so "which entries are ours" cannot drift
|
|
6
|
+
between install and uninstall:
|
|
6
7
|
- project <repo>/.pi/settings.json (run.sh install_local_package / remove_local_package)
|
|
7
8
|
- user ~/.pi/agent/settings.json (run.sh register_user_scope_citizen)
|
|
8
9
|
|
|
9
10
|
Register is idempotent: absent → append REPO_DIR; already the sole canonical
|
|
10
11
|
entry → no-op (file not rewritten, mtime stable); any other entwurf entry (object
|
|
11
|
-
form, stale path, duplicate) collapses into one canonical string form.
|
|
12
|
-
|
|
12
|
+
form, stale path, duplicate) collapses into one canonical string form.
|
|
13
|
+
|
|
14
|
+
Matching is shared; the ACTION on a match is not, and the asymmetry is deliberate.
|
|
15
|
+
Remove drops every MANAGED shape the matcher recognizes — the resolved repo dir, an
|
|
16
|
+
`…/node_modules/@junghanacs/entwurf` install path, an `npm:@junghanacs/entwurf[@ver]`
|
|
17
|
+
spec, a stale local path whose last segment is `entwurf` — including shapes register
|
|
18
|
+
itself never literally wrote, because those are how an operator's earlier install or
|
|
19
|
+
a moved clone left this package registered. It preserves exactly ONE class: a
|
|
20
|
+
settings-RELATIVE entry that resolves to repo_dir (see below). A look-alike repo
|
|
13
21
|
(entwurf-notes, openclaw-entwurf) is neither wrongly registered-over nor wrongly
|
|
14
22
|
removed. Every non-entwurf package and every other settings key is preserved.
|
|
15
23
|
|
|
24
|
+
CANONICAL IS NOT ONLY THE ABSOLUTE PATH. A packages[] entry is resolved by pi
|
|
25
|
+
against the SETTINGS FILE'S OWN DIRECTORY, so this repo's committed
|
|
26
|
+
`.pi/settings.json` names itself portably as `".."` — the exact form
|
|
27
|
+
check-install-surface S7c pins. Comparing entries against the resolved absolute
|
|
28
|
+
path ALONE did not recognize that as entwurf, so `setup` appended the absolute
|
|
29
|
+
path beside it and rewrote the tracked, biome-governed file in a foreign style:
|
|
30
|
+
a dev clone went RED at `pnpm check` step 1, diagnosed as a "formatting" error
|
|
31
|
+
(#53 B). Two rules follow, and they are the same rule read forwards and backwards:
|
|
32
|
+
- register: an entry that RESOLVES to repo_dir is already canonical → no-op, and
|
|
33
|
+
when a rewrite is genuinely needed a settings-relative self-reference is kept
|
|
34
|
+
as the survivor, so the portable form is never silently absolutized;
|
|
35
|
+
- remove: register only ever WRITES the absolute form, so a settings-relative
|
|
36
|
+
self-reference cannot be install's own output — it was authored by the repo
|
|
37
|
+
(this one commits `".."`) or by the operator. Uninstall is install's inverse,
|
|
38
|
+
not a settings editor: it leaves that ONE class in place and SAYS SO on stdout,
|
|
39
|
+
rather than deleting source bytes install never wrote. The cost is stated
|
|
40
|
+
rather than hidden: on a settings file whose only entwurf entry is relative,
|
|
41
|
+
`--remove` is a no-op and the package stays registered until a human edits it.
|
|
42
|
+
would_remove() asks the same split, so `--dry-run` can never disagree with what
|
|
43
|
+
`--remove` does.
|
|
44
|
+
A rewrite also preserves the file's existing indentation instead of forcing 2
|
|
45
|
+
spaces. That is narrower than it sounds — it keeps the indent UNIT, not a
|
|
46
|
+
formatter's line-collapsing decisions — so the byte-identity guarantee this repo's
|
|
47
|
+
own settings depend on comes from the no-op path, never from the writer's style.
|
|
48
|
+
|
|
16
49
|
This wiring (user scope) dropped when `pi install` was removed from setup
|
|
17
50
|
(2026-07-03: `--entwurf-control` unknown in a foreign cwd). Extracting it here
|
|
18
51
|
lets run.sh (both scopes + remove) and smoke-user-scope-citizen share ONE
|
|
@@ -24,16 +57,53 @@ Usage: register-pi-package.py <settings.json> <repo_dir> [--remove]
|
|
|
24
57
|
from __future__ import annotations
|
|
25
58
|
|
|
26
59
|
import json
|
|
60
|
+
import os
|
|
61
|
+
import re
|
|
27
62
|
import sys
|
|
28
63
|
from pathlib import Path
|
|
29
64
|
|
|
65
|
+
# The serializer rules are SHARED with register-pi-provider.py — both write the same
|
|
66
|
+
# settings file, and a copied indent-detector is how the second writer stayed open after
|
|
67
|
+
# the first one was closed (#53 B). sys.path[0] already holds this directory when the
|
|
68
|
+
# script is run by path, which is how run.sh and every gate invoke it; the explicit
|
|
69
|
+
# insert keeps the import true under any other invocation form.
|
|
70
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
71
|
+
from pi_settings_io import detect_indent, dumps # noqa: E402
|
|
72
|
+
|
|
73
|
+
# A leading `<scheme>:` means the string is a package SPEC (npm:, git:, https:),
|
|
74
|
+
# never a filesystem path — so it is never resolved against the settings dir.
|
|
75
|
+
_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
76
|
+
|
|
30
77
|
|
|
31
78
|
def source_of(item: object) -> object:
|
|
32
79
|
"""The package spec of a packages[] entry — string form or {"source": …}."""
|
|
33
80
|
return item.get("source") if isinstance(item, dict) else item
|
|
34
81
|
|
|
35
82
|
|
|
36
|
-
def
|
|
83
|
+
def is_settings_relative_self(source: str, settings_dir: str, repo_dir: str) -> bool:
|
|
84
|
+
"""True iff this entry is a SETTINGS-RELATIVE path naming repo_dir itself.
|
|
85
|
+
|
|
86
|
+
This is the repo's own committed portable form (`".."` in
|
|
87
|
+
<repo>/.pi/settings.json) — functionally identical to the absolute entry,
|
|
88
|
+
because pi resolves a relative package source against the settings file's own
|
|
89
|
+
directory. It is also the one shape register never writes, which is what lets
|
|
90
|
+
remove treat it as source rather than as install state.
|
|
91
|
+
|
|
92
|
+
Anything carrying a scheme (npm:/git:/https:) is a spec, not a path; anything
|
|
93
|
+
absolute (`/`, `~`) is not settings-relative. Everything else is resolved and
|
|
94
|
+
compared — a relative entry only matches when it genuinely names this repo, so
|
|
95
|
+
`../../repos/gh/andenken` stays untouched.
|
|
96
|
+
"""
|
|
97
|
+
p = source.rstrip("/")
|
|
98
|
+
if not p or _SCHEME.match(p) or p.startswith(("/", "~")):
|
|
99
|
+
return False
|
|
100
|
+
try:
|
|
101
|
+
return str((Path(settings_dir) / p).resolve()) == repo_dir
|
|
102
|
+
except OSError:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_entwurf_source(source: str, repo_dir: str, settings_dir: str | None = None) -> bool:
|
|
37
107
|
"""True iff this package entry points at THIS entwurf — the only entries
|
|
38
108
|
register/remove may touch. Strict on purpose: user-scope settings are GLOBAL,
|
|
39
109
|
so a substring "entwurf" match would wrongly eat unrelated repos like
|
|
@@ -43,6 +113,8 @@ def is_entwurf_source(source: str, repo_dir: str) -> bool:
|
|
|
43
113
|
- the exact resolved repo dir;
|
|
44
114
|
- an npm install path ending in node_modules/@junghanacs/entwurf;
|
|
45
115
|
- an explicit npm package source for @junghanacs/entwurf;
|
|
116
|
+
- a settings-relative path that RESOLVES to the repo dir (the committed
|
|
117
|
+
portable `".."`), when the caller supplies settings_dir;
|
|
46
118
|
- a local filesystem path whose final directory is literally "entwurf"
|
|
47
119
|
(dev clone / stale move). Remote URL/git-like strings are NOT treated as
|
|
48
120
|
local paths merely because their last segment is "entwurf".
|
|
@@ -52,10 +124,14 @@ def is_entwurf_source(source: str, repo_dir: str) -> bool:
|
|
|
52
124
|
return True
|
|
53
125
|
if p == "npm:@junghanacs/entwurf" or p.startswith("npm:@junghanacs/entwurf@"):
|
|
54
126
|
return True
|
|
127
|
+
if settings_dir is not None and is_settings_relative_self(p, settings_dir, repo_dir):
|
|
128
|
+
return True
|
|
55
129
|
local_like = p.startswith(("/", "./", "../", "~"))
|
|
56
130
|
return local_like and Path(p).name == "entwurf"
|
|
57
131
|
|
|
58
132
|
|
|
133
|
+
|
|
134
|
+
|
|
59
135
|
def _load(settings_path: Path) -> dict:
|
|
60
136
|
if settings_path.exists():
|
|
61
137
|
data = json.loads(settings_path.read_text())
|
|
@@ -76,62 +152,130 @@ def _packages(settings_path: Path, data: dict) -> list:
|
|
|
76
152
|
return packages
|
|
77
153
|
|
|
78
154
|
|
|
79
|
-
def
|
|
155
|
+
def _settings_dir(settings_path: Path) -> str:
|
|
156
|
+
"""The directory a relative packages[] entry is resolved against — the settings
|
|
157
|
+
file's own parent, the way pi reads it. Never the process cwd: run.sh and the
|
|
158
|
+
gates invoke this from anywhere."""
|
|
159
|
+
return str(settings_path.parent.resolve()) if settings_path.parent.exists() else str(settings_path.parent)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_relative_self_item(item: object, settings_dir: str, repo_dir: str) -> bool:
|
|
163
|
+
"""is_settings_relative_self, asked of a packages[] ENTRY of either shape."""
|
|
164
|
+
src = source_of(item)
|
|
165
|
+
return isinstance(src, str) and is_settings_relative_self(src, settings_dir, repo_dir)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _entwurf_matches(packages: list, repo_dir: str, settings_dir: str) -> list:
|
|
80
169
|
return [
|
|
81
170
|
item for item in packages
|
|
82
|
-
if isinstance(source_of(item), str) and is_entwurf_source(source_of(item), repo_dir) # type: ignore[arg-type]
|
|
171
|
+
if isinstance(source_of(item), str) and is_entwurf_source(source_of(item), repo_dir, settings_dir) # type: ignore[arg-type]
|
|
83
172
|
]
|
|
84
173
|
|
|
85
174
|
|
|
175
|
+
def _write(settings_path: Path, data: dict, original_text: str | None) -> None:
|
|
176
|
+
"""Serialize with the file's own indentation (pi_settings_io.detect_indent)."""
|
|
177
|
+
settings_path.write_text(dumps(data, detect_indent(original_text)))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _read_text(settings_path: Path) -> str | None:
|
|
181
|
+
return settings_path.read_text() if settings_path.exists() else None
|
|
182
|
+
|
|
183
|
+
|
|
86
184
|
def register(settings_path: Path, repo_dir_arg: str) -> str:
|
|
87
185
|
""""noop" if entwurf is already the sole canonical entry (file untouched),
|
|
88
186
|
else "registered" (rewritten with a single canonical entry)."""
|
|
89
187
|
repo_dir = str(Path(repo_dir_arg).resolve())
|
|
90
188
|
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
original_text = _read_text(settings_path)
|
|
91
190
|
data = _load(settings_path)
|
|
92
191
|
packages = _packages(settings_path, data)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
#
|
|
97
|
-
|
|
192
|
+
settings_dir = _settings_dir(settings_path)
|
|
193
|
+
|
|
194
|
+
entwurf_entries = _entwurf_matches(packages, repo_dir, settings_dir)
|
|
195
|
+
# Already correct iff exactly ONE entwurf entry and it NAMES repo_dir — either as
|
|
196
|
+
# the canonical absolute string or as a settings-relative path resolving there.
|
|
197
|
+
# Both are the same registration to pi, so both are a no-op: order-insensitive, no
|
|
198
|
+
# rewrite, mtime stable. Recognizing only the absolute form is what made `setup`
|
|
199
|
+
# duplicate this repo's own committed `".."` and restyle the tracked file (#53 B).
|
|
200
|
+
# Both arms require the STRING form, so an object-form entry still collapses to a
|
|
201
|
+
# canonical string exactly as before.
|
|
202
|
+
if len(entwurf_entries) == 1 and isinstance(entwurf_entries[0], str) and (
|
|
203
|
+
entwurf_entries[0] == repo_dir
|
|
204
|
+
or is_settings_relative_self(entwurf_entries[0], settings_dir, repo_dir)
|
|
205
|
+
):
|
|
98
206
|
return "noop"
|
|
99
207
|
|
|
208
|
+
# A rewrite keeps the PORTABLE form when the file already had one: absolutizing a
|
|
209
|
+
# committed `".."` would repair the duplicate and dirty the tracked bytes in the
|
|
210
|
+
# same breath. Otherwise the canonical absolute path is what install writes.
|
|
211
|
+
survivor = next(
|
|
212
|
+
(
|
|
213
|
+
source_of(e) for e in entwurf_entries
|
|
214
|
+
if _is_relative_self_item(e, settings_dir, repo_dir)
|
|
215
|
+
),
|
|
216
|
+
repo_dir,
|
|
217
|
+
)
|
|
100
218
|
filtered = [item for item in packages if item not in entwurf_entries]
|
|
101
|
-
data["packages"] = filtered + [
|
|
102
|
-
settings_path
|
|
219
|
+
data["packages"] = filtered + [survivor]
|
|
220
|
+
_write(settings_path, data, original_text)
|
|
103
221
|
return "registered"
|
|
104
222
|
|
|
105
223
|
|
|
106
|
-
def
|
|
107
|
-
"""
|
|
224
|
+
def _removable(packages: list, repo_dir: str, settings_dir: str) -> tuple[list, list]:
|
|
225
|
+
"""Split entwurf entries into (removable, preserved).
|
|
226
|
+
|
|
227
|
+
REMOVABLE = every managed shape the matcher recognizes, including ones register
|
|
228
|
+
never literally wrote (an npm spec, a node_modules path, a stale clone path) —
|
|
229
|
+
those are how a previous install or a moved checkout left this package
|
|
230
|
+
registered, and the inverse has to reach them.
|
|
231
|
+
|
|
232
|
+
PRESERVED = settings-relative self-references, the one shape register CANNOT
|
|
233
|
+
have produced (it always writes the resolved absolute path). Such an entry is
|
|
234
|
+
the repo's committed portable registration or the operator's own hand edit.
|
|
235
|
+
Deleting it would make uninstall a source editor, which is the defect #53 B is
|
|
236
|
+
about, pointed the other way. main() prints what was kept so the incompleteness
|
|
237
|
+
is loud rather than silent.
|
|
238
|
+
"""
|
|
239
|
+
matches = _entwurf_matches(packages, repo_dir, settings_dir)
|
|
240
|
+
preserved = [item for item in matches if _is_relative_self_item(item, settings_dir, repo_dir)]
|
|
241
|
+
removable = [item for item in matches if item not in preserved]
|
|
242
|
+
return removable, preserved
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def remove(settings_path: Path, repo_dir_arg: str) -> tuple[int, int]:
|
|
246
|
+
"""Drop every MANAGED entwurf entry (any shape/path), preserving settings-relative
|
|
247
|
+
self-references. Returns (removed, preserved)."""
|
|
108
248
|
repo_dir = str(Path(repo_dir_arg).resolve())
|
|
109
249
|
if not settings_path.exists():
|
|
110
|
-
return 0
|
|
250
|
+
return 0, 0
|
|
251
|
+
original_text = _read_text(settings_path)
|
|
111
252
|
data = _load(settings_path)
|
|
112
253
|
packages = _packages(settings_path, data)
|
|
254
|
+
settings_dir = _settings_dir(settings_path)
|
|
113
255
|
|
|
114
|
-
|
|
115
|
-
if not
|
|
116
|
-
return 0
|
|
117
|
-
data["packages"] = [item for item in packages if item not in
|
|
118
|
-
settings_path
|
|
119
|
-
return len(
|
|
256
|
+
removable, preserved = _removable(packages, repo_dir, settings_dir)
|
|
257
|
+
if not removable:
|
|
258
|
+
return 0, len(preserved)
|
|
259
|
+
data["packages"] = [item for item in packages if item not in removable]
|
|
260
|
+
_write(settings_path, data, original_text)
|
|
261
|
+
return len(removable), len(preserved)
|
|
120
262
|
|
|
121
263
|
|
|
122
|
-
def would_remove(settings_path: Path, repo_dir_arg: str) -> int:
|
|
123
|
-
"""Count
|
|
264
|
+
def would_remove(settings_path: Path, repo_dir_arg: str) -> tuple[int, int]:
|
|
265
|
+
"""Count what a --remove WOULD drop and what it would preserve, writing NOTHING.
|
|
124
266
|
|
|
125
267
|
Read-only companion to remove() for --dry-run — lets a caller (e.g. run.sh's
|
|
126
268
|
project `remove` pointer note) decide whether the global user-scope inverse is
|
|
127
|
-
worth suggesting without mutating the operator's settings.
|
|
269
|
+
worth suggesting without mutating the operator's settings. It asks the SAME
|
|
270
|
+
predicate remove asks, so a dry-run can never over- or under-report it.
|
|
128
271
|
"""
|
|
129
272
|
repo_dir = str(Path(repo_dir_arg).resolve())
|
|
130
273
|
if not settings_path.exists():
|
|
131
|
-
return 0
|
|
274
|
+
return 0, 0
|
|
132
275
|
data = _load(settings_path)
|
|
133
276
|
packages = _packages(settings_path, data)
|
|
134
|
-
|
|
277
|
+
removable, preserved = _removable(packages, repo_dir, _settings_dir(settings_path))
|
|
278
|
+
return len(removable), len(preserved)
|
|
135
279
|
|
|
136
280
|
|
|
137
281
|
def main(argv: list[str]) -> int:
|
|
@@ -156,18 +300,20 @@ def main(argv: list[str]) -> int:
|
|
|
156
300
|
resolved = str(Path(repo_dir_arg).resolve())
|
|
157
301
|
|
|
158
302
|
if do_remove:
|
|
159
|
-
if dry_run
|
|
160
|
-
|
|
161
|
-
if n:
|
|
162
|
-
print(f"remove: would remove {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
|
|
163
|
-
else:
|
|
164
|
-
print(f"remove: no entwurf packages[] entry to remove ({settings_path})")
|
|
165
|
-
return 0
|
|
166
|
-
n = remove(settings_path, repo_dir_arg)
|
|
303
|
+
n, kept = (would_remove if dry_run else remove)(settings_path, repo_dir_arg)
|
|
304
|
+
verb = "would remove" if dry_run else "removed"
|
|
167
305
|
if n:
|
|
168
|
-
print(f"remove:
|
|
306
|
+
print(f"remove: {verb} {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
|
|
169
307
|
else:
|
|
170
308
|
print(f"remove: no entwurf packages[] entry to remove ({settings_path})")
|
|
309
|
+
# Never silent: an inverse that deliberately leaves something behind has to
|
|
310
|
+
# say what and why, or the operator reads "removed" as "fully unregistered".
|
|
311
|
+
if kept:
|
|
312
|
+
print(
|
|
313
|
+
f"remove: kept {kept} settings-relative entwurf entr{'y' if kept == 1 else 'ies'} "
|
|
314
|
+
f"({settings_path}) — install never writes that form, so it is committed/operator "
|
|
315
|
+
"source, not install state; edit the file by hand to drop it"
|
|
316
|
+
)
|
|
171
317
|
return 0
|
|
172
318
|
|
|
173
319
|
result = register(settings_path, repo_dir_arg)
|
|
@@ -39,6 +39,16 @@ import json
|
|
|
39
39
|
import os
|
|
40
40
|
import sys
|
|
41
41
|
|
|
42
|
+
# Shared with register-pi-package.py: both writers touch the SAME settings file, so the
|
|
43
|
+
# "no write when nothing changes / keep the file's indent unit" rules live in one module
|
|
44
|
+
# instead of being remembered at each call site. Closing only the packages writer for
|
|
45
|
+
# #53 B left THIS one restyling the repo's own tracked, biome-governed settings on every
|
|
46
|
+
# `install` — semantically a no-op, byte-wise a RED `pnpm check`. sys.path[0] already
|
|
47
|
+
# holds this directory when the script is run by path (how run.sh and the gates invoke
|
|
48
|
+
# it); the explicit insert keeps the import true under any other invocation form.
|
|
49
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
50
|
+
from pi_settings_io import detect_indent, dumps, unchanged # noqa: E402
|
|
51
|
+
|
|
42
52
|
SERVER_KEY = "entwurf-bridge"
|
|
43
53
|
BARE_COMMAND = "entwurf-bridge"
|
|
44
54
|
STATE_SCHEMA_VERSION = 1
|
|
@@ -78,9 +88,14 @@ def _atomic_write(path: str, text: str) -> None:
|
|
|
78
88
|
os.replace(tmp, path)
|
|
79
89
|
|
|
80
90
|
|
|
81
|
-
def
|
|
91
|
+
def _read(path: str) -> str:
|
|
92
|
+
if not os.path.exists(path):
|
|
93
|
+
return ""
|
|
82
94
|
with open(path, "r", encoding="utf-8") as fh:
|
|
83
|
-
|
|
95
|
+
return fh.read()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_settings(path: str, raw: str) -> dict:
|
|
84
99
|
if raw.strip() == "":
|
|
85
100
|
return {}
|
|
86
101
|
try:
|
|
@@ -92,10 +107,33 @@ def _load(path: str) -> dict:
|
|
|
92
107
|
return data
|
|
93
108
|
|
|
94
109
|
|
|
110
|
+
def _load(path: str) -> dict:
|
|
111
|
+
return _parse_settings(path, _read(path))
|
|
112
|
+
|
|
113
|
+
|
|
95
114
|
def _dump(data: dict) -> str:
|
|
115
|
+
"""For files THIS script owns end to end (the install-state record), where no
|
|
116
|
+
formatter has a claim. A settings file goes through _persist instead."""
|
|
96
117
|
return json.dumps(data, indent=2) + "\n"
|
|
97
118
|
|
|
98
119
|
|
|
120
|
+
def _persist(path: str, before: dict, after: dict, original_text: str) -> bool:
|
|
121
|
+
"""Write a SETTINGS file — but only if `after` actually differs from what was
|
|
122
|
+
loaded, and then in the file's own indent unit. Returns True when it wrote.
|
|
123
|
+
|
|
124
|
+
This is the whole of #53 B on this writer. `install` is documented as idempotent
|
|
125
|
+
and a managed-current classification changes nothing, yet the old unconditional
|
|
126
|
+
`_atomic_write` re-serialized the document every time: this repo's committed
|
|
127
|
+
`.pi/settings.json` is tab-indented with compact arrays, came back at indent=2, and
|
|
128
|
+
took `pnpm check` RED at its first step — reported as a formatting error, which is
|
|
129
|
+
what sent the diagnosis away from "install wrote this" (#53 B, both rounds).
|
|
130
|
+
"""
|
|
131
|
+
if unchanged(before, after):
|
|
132
|
+
return False
|
|
133
|
+
_atomic_write(path, dumps(after, detect_indent(original_text)))
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
|
|
99
137
|
def _now() -> str:
|
|
100
138
|
import datetime
|
|
101
139
|
|
|
@@ -140,7 +178,11 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str)
|
|
|
140
178
|
_die(3, f"register-pi-provider: refusing to adopt {settings_path} — it is a symlink to {target} "
|
|
141
179
|
f"(someone else's SSOT). Manage it there, or replace it with a regular file, then retry.")
|
|
142
180
|
|
|
143
|
-
|
|
181
|
+
raw = _read(settings_path)
|
|
182
|
+
# Parsed TWICE on purpose: everything below mutates in place, so `before` has to be
|
|
183
|
+
# an independent document or the comparison would be against itself.
|
|
184
|
+
before = _parse_settings(settings_path, raw)
|
|
185
|
+
data = _parse_settings(settings_path, raw)
|
|
144
186
|
provider, servers = _provider_servers(data, create=True)
|
|
145
187
|
_prune_legacy(servers, repo_dir) # independent of entwurf-bridge ownership
|
|
146
188
|
existing = servers.get(SERVER_KEY)
|
|
@@ -149,11 +191,12 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str)
|
|
|
149
191
|
|
|
150
192
|
if ownership == "user-override":
|
|
151
193
|
# DO NOT overwrite our key, DO NOT own it (no state). doctor reports it as unowned. Still
|
|
152
|
-
# persist
|
|
194
|
+
# persist IF the legacy prune above (or a materialized parent) actually changed something.
|
|
153
195
|
sys.stdout.write(
|
|
154
196
|
f"install: preserved entwurfProvider.mcpServers.{SERVER_KEY} (user override, NOT owned: {existing_cmd!r})\n"
|
|
155
197
|
)
|
|
156
|
-
|
|
198
|
+
if not _persist(settings_path, before, data, raw):
|
|
199
|
+
sys.stdout.write(f"install: no change — {settings_path} left untouched (bytes and mtime stable)\n")
|
|
157
200
|
return
|
|
158
201
|
|
|
159
202
|
# absent / managed-current / managed-legacy → normalize to the bare stable bin.
|
|
@@ -164,10 +207,15 @@ def cmd_install(settings_path: str, repo_dir: str, scope: str, state_path: str)
|
|
|
164
207
|
else:
|
|
165
208
|
newval["args"] = []
|
|
166
209
|
servers[SERVER_KEY] = newval
|
|
167
|
-
|
|
210
|
+
wrote = _persist(settings_path, before, data, raw)
|
|
168
211
|
sys.stdout.write(
|
|
169
212
|
f"install: {ownership} → entwurfProvider.mcpServers.{SERVER_KEY} = {BARE_COMMAND} (bare stable bin)\n"
|
|
170
213
|
)
|
|
214
|
+
# The desired value AND the legacy prune both already held: nothing to say to the
|
|
215
|
+
# file. Reported so an operator (and the gate) can tell "already correct" from
|
|
216
|
+
# "rewritten to the same thing" — only the first leaves a tracked file alone.
|
|
217
|
+
if not wrote:
|
|
218
|
+
sys.stdout.write(f"install: no change — {settings_path} left untouched (bytes and mtime stable)\n")
|
|
171
219
|
|
|
172
220
|
if scope == "user":
|
|
173
221
|
if state_path:
|
|
@@ -201,7 +249,9 @@ def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str) -
|
|
|
201
249
|
if os.path.islink(managed):
|
|
202
250
|
_die(3, f"register-pi-provider: refusing to uninstall — {managed} became a symlink since install.")
|
|
203
251
|
if os.path.exists(managed):
|
|
204
|
-
|
|
252
|
+
raw = _read(managed)
|
|
253
|
+
before = _parse_settings(managed, raw)
|
|
254
|
+
data = _parse_settings(managed, raw)
|
|
205
255
|
provider, servers = _provider_servers(data, create=False)
|
|
206
256
|
# honest inverse: absent/managed-* → remove OUR key (a legacy repo path is NOT
|
|
207
257
|
# restored — it was our old managed value, not a user value).
|
|
@@ -211,7 +261,9 @@ def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str) -
|
|
|
211
261
|
provider.pop("mcpServers", None)
|
|
212
262
|
if isinstance(provider, dict) and not provider:
|
|
213
263
|
data.pop("entwurfProvider", None)
|
|
214
|
-
|
|
264
|
+
# Same rule as install: an inverse that has nothing left to undo must not
|
|
265
|
+
# restyle the file on its way out.
|
|
266
|
+
_persist(managed, before, data, raw)
|
|
215
267
|
os.remove(state_path)
|
|
216
268
|
sys.stdout.write(f"remove: removed our {SERVER_KEY} key (ownership={state.get('ownership')}) from {managed}\n")
|
|
217
269
|
return
|
|
@@ -222,7 +274,9 @@ def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str) -
|
|
|
222
274
|
return
|
|
223
275
|
if os.path.islink(settings_path):
|
|
224
276
|
_die(3, f"register-pi-provider: refusing to touch {settings_path} — it is a symlink.")
|
|
225
|
-
|
|
277
|
+
raw = _read(settings_path)
|
|
278
|
+
before = _parse_settings(settings_path, raw)
|
|
279
|
+
data = _parse_settings(settings_path, raw)
|
|
226
280
|
provider, servers = _provider_servers(data, create=False)
|
|
227
281
|
if not isinstance(servers, dict):
|
|
228
282
|
sys.stdout.write("remove: no entwurfProvider.mcpServers — nothing to do.\n")
|
|
@@ -242,7 +296,11 @@ def cmd_remove(settings_path: str, repo_dir: str, scope: str, state_path: str) -
|
|
|
242
296
|
sys.stdout.write(f"remove: preserved entwurfProvider.mcpServers.{SERVER_KEY} (user override: {existing_cmd!r})\n")
|
|
243
297
|
else: # absent
|
|
244
298
|
sys.stdout.write(f"remove: entwurfProvider.mcpServers.{SERVER_KEY} already absent.\n")
|
|
245
|
-
|
|
299
|
+
# `absent` + nothing pruned is the common case on a clean checkout, and it used to
|
|
300
|
+
# rewrite the file anyway — a project `remove` restyled the tracked settings exactly
|
|
301
|
+
# as install did.
|
|
302
|
+
if not _persist(settings_path, before, data, raw):
|
|
303
|
+
sys.stdout.write(f"remove: no change — {settings_path} left untouched (bytes and mtime stable)\n")
|
|
246
304
|
|
|
247
305
|
|
|
248
306
|
def _parse(argv: list):
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// LIVE-only (spawns a real pi + opens a real socket) — kept OUT of `pnpm check`;
|
|
21
21
|
// honest skip when LIVE!=1 (skip = CI safety, NOT an acceptance PASS).
|
|
22
22
|
// LIVE=1 ./run.sh smoke-acp-socket-citizen-live
|
|
23
|
-
// override model via ENTWURF_S1_MODEL (default claude-opus-
|
|
23
|
+
// override model via ENTWURF_S1_MODEL (default claude-opus-5).
|
|
24
24
|
|
|
25
25
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
26
26
|
import { existsSync } from "node:fs";
|
|
@@ -34,7 +34,7 @@ import { terminateChild } from "./lib/acp-child-cleanup.ts";
|
|
|
34
34
|
import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
35
35
|
|
|
36
36
|
const ACP_PROVIDER = "entwurf";
|
|
37
|
-
const ACP_MODEL = process.env.ENTWURF_S1_MODEL?.trim() || "claude-opus-
|
|
37
|
+
const ACP_MODEL = process.env.ENTWURF_S1_MODEL?.trim() || "claude-opus-5";
|
|
38
38
|
|
|
39
39
|
const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
|
|
40
40
|
const SOCKET_SUFFIX = ".sock";
|
|
@@ -534,7 +534,7 @@ valid_record "20260606T000000-aaaaaa" "native-a" > "$STORE/20260606T000000-aaaaa
|
|
|
534
534
|
valid_record "20260606T000001-bbbbbb" "native-b" > "$STORE/20260606T000001-bbbbbb.meta.json"
|
|
535
535
|
|
|
536
536
|
STATUS_INPUT_MATCH='{"session_id":"native-a","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-sonnet-5"},"context_window":{"context_window_size":200000,"used_percentage":2,"current_usage":{"input_tokens":10}}}'
|
|
537
|
-
STATUS_INPUT_MISS='{"session_id":"native-missing","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-opus-
|
|
537
|
+
STATUS_INPUT_MISS='{"session_id":"native-missing","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-opus-5"}}'
|
|
538
538
|
STATUS_INPUT_READY='{"workspace":{"current_dir":"/tmp"},"model":{"id":"claude-haiku-4-5"}}'
|
|
539
539
|
STATUS_OUT_MATCH="$(printf '%s' "$STATUS_INPUT_MATCH" | ENTWURF_META_SESSIONS_DIR="$STORE" "$REPO/scripts/meta-bridge-statusline.sh")"
|
|
540
540
|
if [ "$(printf '%s\n' "$STATUS_OUT_MATCH" | wc -l | tr -d ' ')" = "2" ]; then ok "statusline renders exactly two rows"; else bad "statusline should render two rows: $STATUS_OUT_MATCH"; fi
|
|
@@ -52,12 +52,17 @@ import net from "node:net";
|
|
|
52
52
|
import os from "node:os";
|
|
53
53
|
import path from "node:path";
|
|
54
54
|
import { type AcpKeyValue, type AcpMcpServer, enrichMcpServersWithEnvelope } from "../pi-extensions/lib/acp/config.ts";
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
listAllMetaIdentities,
|
|
57
|
+
makeStoreRecordReader,
|
|
58
|
+
parseMetaRecordV3,
|
|
59
|
+
readActiveStoreEntries,
|
|
60
|
+
} from "../pi-extensions/lib/meta-session.ts";
|
|
56
61
|
import { birthPiCitizen } from "../pi-extensions/lib/pi-citizen-birth.ts";
|
|
57
62
|
|
|
58
63
|
/** Record count in the isolated store, read the same way production reads it. */
|
|
59
64
|
function citizenCount(dir: string): number {
|
|
60
|
-
return listAllMetaIdentities(
|
|
65
|
+
return listAllMetaIdentities(readActiveStoreEntries(dir), makeStoreRecordReader(dir), {
|
|
61
66
|
mode: "strict",
|
|
62
67
|
}).identities.length;
|
|
63
68
|
}
|