@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.
Files changed (52) hide show
  1. package/AGENTS.md +4 -4
  2. package/CHANGELOG.md +20 -1
  3. package/DELIVERY.md +1 -1
  4. package/README.md +122 -10
  5. package/demo/README.md +2 -2
  6. package/docs/setup-clean-host.md +14 -3
  7. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +9 -15
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +1 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +8 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +483 -34
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
  15. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
  16. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
  17. package/mcp/entwurf-bridge/src/index.ts +10 -12
  18. package/package.json +7 -7
  19. package/pi-extensions/entwurf-control.ts +12 -12
  20. package/pi-extensions/lib/acp/models.ts +12 -12
  21. package/pi-extensions/lib/entwurf-fact-provider.ts +9 -2
  22. package/pi-extensions/lib/entwurf-v2-production.ts +8 -2
  23. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +8 -2
  24. package/pi-extensions/lib/meta-sender-identity.ts +15 -5
  25. package/pi-extensions/lib/meta-session.ts +526 -38
  26. package/pi-extensions/meta-bridge-hook.ts +8 -2
  27. package/run.sh +30 -26
  28. package/scripts/agy-imprint.ts +15 -1
  29. package/scripts/check-acp-session-reuse.ts +1 -1
  30. package/scripts/check-acp-session-store.ts +3 -3
  31. package/scripts/check-agy-sender-identity.ts +83 -0
  32. package/scripts/check-entwurf-control-rpc.ts +2 -2
  33. package/scripts/check-entwurf-fact-provider.ts +9 -1
  34. package/scripts/check-entwurf-facts.ts +1 -1
  35. package/scripts/check-entwurf-mailbox-guard.ts +6 -2
  36. package/scripts/check-entwurf-resume-args.ts +6 -6
  37. package/scripts/check-entwurf-session-identity.ts +7 -6
  38. package/scripts/check-entwurf-v2-production.ts +4 -2
  39. package/scripts/check-entwurf-v2-spawn-production.ts +2 -2
  40. package/scripts/check-fresh-cut-gate.sh +305 -4
  41. package/scripts/check-meta-identity-consumers.ts +501 -1
  42. package/scripts/check-meta-listing.ts +91 -9
  43. package/scripts/check-meta-receiver-marker.ts +54 -0
  44. package/scripts/check-model-lock.ts +1 -1
  45. package/scripts/meta-bridge-fresh-cut.ts +164 -28
  46. package/scripts/pi_settings_io.py +65 -0
  47. package/scripts/register-pi-package.py +183 -37
  48. package/scripts/register-pi-provider.py +68 -10
  49. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  50. package/scripts/smoke-meta-install-state.sh +1 -1
  51. package/scripts/smoke-pi-attach.ts +7 -2
  52. 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
- The SINGLE predicate / idempotency / fail-loud SSOT shared by BOTH scopes and by
5
- remove, so install and uninstall can never drift to different meanings:
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. Remove
12
- drops every entwurf entry. Both use is_entwurf_source(), so a look-alike repo
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 is_entwurf_source(source: str, repo_dir: str) -> bool:
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 _entwurf_matches(packages: list, repo_dir: str) -> list:
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
- entwurf_entries = _entwurf_matches(packages, repo_dir)
95
- # Already correct iff exactly ONE entwurf entry and it is the canonical string
96
- # form at repo_dir. Order-insensitive; no rewrite mtime stable.
97
- if len(entwurf_entries) == 1 and entwurf_entries[0] == repo_dir:
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 + [repo_dir]
102
- settings_path.write_text(json.dumps(data, indent=2) + "\n")
219
+ data["packages"] = filtered + [survivor]
220
+ _write(settings_path, data, original_text)
103
221
  return "registered"
104
222
 
105
223
 
106
- def remove(settings_path: Path, repo_dir_arg: str) -> int:
107
- """Drop every entwurf entry (any shape/path). Returns the count removed."""
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
- entwurf_entries = _entwurf_matches(packages, repo_dir)
115
- if not entwurf_entries:
116
- return 0
117
- data["packages"] = [item for item in packages if item not in entwurf_entries]
118
- settings_path.write_text(json.dumps(data, indent=2) + "\n")
119
- return len(entwurf_entries)
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 the entwurf entries a --remove WOULD drop, writing NOTHING.
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
- return len(_entwurf_matches(packages, repo_dir))
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
- n = would_remove(settings_path, repo_dir_arg)
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: removed {n} entwurf packages[] entr{'y' if n == 1 else 'ies'} from {settings_path}")
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 _load(path: str) -> dict:
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
- raw = fh.read()
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
- data = _load(settings_path) if os.path.exists(settings_path) else {}
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 so any legacy prune above (and a materialized parent) is written.
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
- _atomic_write(settings_path, _dump(data))
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
- _atomic_write(settings_path, _dump(data))
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
- data = _load(managed)
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
- _atomic_write(managed, _dump(data))
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
- data = _load(settings_path)
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
- _atomic_write(settings_path, _dump(data))
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-4-8).
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-4-8";
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-4-8"}}'
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 { listAllMetaIdentities, parseMetaRecordV3 } from "../pi-extensions/lib/meta-session.ts";
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(fs.readdirSync(dir), (f) => fs.readFileSync(path.join(dir, f), "utf8"), {
65
+ return listAllMetaIdentities(readActiveStoreEntries(dir), makeStoreRecordReader(dir), {
61
66
  mode: "strict",
62
67
  }).identities.length;
63
68
  }