@junghanacs/entwurf 0.12.8 → 0.12.10

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 (96) hide show
  1. package/AGENTS.md +134 -248
  2. package/BASELINE.md +1 -1
  3. package/CHANGELOG.md +39 -1
  4. package/DELIVERY.md +5 -4
  5. package/README.md +148 -23
  6. package/VERIFY.md +3 -2
  7. package/demo/README.md +2 -2
  8. package/demo/demo.sh +7 -1
  9. package/docs/setup-clean-host.md +24 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +168 -81
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +5 -3
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +16 -18
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +14 -13
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +11 -3
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +500 -54
  26. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
  27. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
  28. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
  29. package/mcp/entwurf-bridge/src/index.ts +200 -94
  30. package/package.json +11 -9
  31. package/pi-extensions/entwurf-control.ts +81 -39
  32. package/pi-extensions/lib/acp/models.ts +12 -12
  33. package/pi-extensions/lib/acp/overlay.ts +5 -3
  34. package/pi-extensions/lib/entwurf-core.ts +26 -9
  35. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  36. package/pi-extensions/lib/entwurf-fact-provider.ts +24 -19
  37. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  38. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  39. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  40. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  41. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  42. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  43. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  44. package/pi-extensions/lib/entwurf-v2-production.ts +22 -13
  45. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +11 -3
  46. package/pi-extensions/lib/meta-sender-identity.ts +15 -5
  47. package/pi-extensions/lib/meta-session.ts +543 -58
  48. package/pi-extensions/meta-bridge-hook.ts +8 -2
  49. package/run.sh +78 -67
  50. package/scripts/agy-bridge-config.py +249 -49
  51. package/scripts/agy-bridge.sh +59 -14
  52. package/scripts/agy-imprint.ts +15 -1
  53. package/scripts/check-acp-carrier-augment.ts +34 -2
  54. package/scripts/check-acp-sdk-surface.ts +22 -11
  55. package/scripts/check-acp-session-reuse.ts +1 -1
  56. package/scripts/check-acp-session-store.ts +3 -3
  57. package/scripts/check-agy-permission-matrix.py +655 -0
  58. package/scripts/check-agy-sender-identity.ts +83 -0
  59. package/scripts/check-entwurf-control-rpc.ts +2 -2
  60. package/scripts/check-entwurf-deliverability.ts +9 -6
  61. package/scripts/check-entwurf-fact-provider.ts +16 -8
  62. package/scripts/check-entwurf-facts.ts +13 -13
  63. package/scripts/check-entwurf-resume-args.ts +25 -63
  64. package/scripts/check-entwurf-self-address.ts +187 -4
  65. package/scripts/check-entwurf-session-identity.ts +7 -6
  66. package/scripts/check-entwurf-v2-contract.ts +2 -2
  67. package/scripts/check-entwurf-v2-production.ts +9 -7
  68. package/scripts/check-entwurf-v2-spawn-production.ts +8 -4
  69. package/scripts/check-entwurf-v2-surface.ts +327 -14
  70. package/scripts/check-fresh-cut-gate.sh +305 -4
  71. package/scripts/check-gate-qualification.ts +785 -0
  72. package/scripts/check-meta-identity-consumers.ts +501 -1
  73. package/scripts/check-meta-listing.ts +91 -9
  74. package/scripts/check-meta-receiver-marker.ts +54 -0
  75. package/scripts/check-model-lock.ts +1 -1
  76. package/scripts/check-shell-quote.ts +2 -1
  77. package/scripts/lib/mutation-qualify.ts +794 -0
  78. package/scripts/meta-bridge-fresh-cut.ts +164 -28
  79. package/scripts/mutants/acp-augment.json +30 -0
  80. package/scripts/mutants/agy-permission.json +144 -0
  81. package/scripts/mutants/meta-identity.json +17 -0
  82. package/scripts/mutants/self-address.json +59 -0
  83. package/scripts/mutants/v2-surface.json +87 -0
  84. package/scripts/pi_settings_io.py +65 -0
  85. package/scripts/register-pi-package.py +183 -37
  86. package/scripts/register-pi-provider.py +68 -10
  87. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  88. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  89. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  90. package/scripts/smoke-agy-install-state.sh +205 -20
  91. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  92. package/scripts/smoke-meta-install-state.sh +1 -1
  93. package/scripts/smoke-pi-attach.ts +7 -2
  94. package/scripts/smoke-user-scope-citizen.sh +177 -0
  95. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  96. package/scripts/check-entwurf-mailbox-guard.ts +0 -262
@@ -0,0 +1,65 @@
1
+ """pi_settings_io — the shared serializer for the pi settings.json files this repo WRITES.
2
+
3
+ Both Python writers touch the SAME file (`<repo>/.pi/settings.json` at project scope,
4
+ `~/.pi/agent/settings.json` at user scope):
5
+
6
+ - register-pi-package.py → packages[]
7
+ - register-pi-provider.py → entwurfProvider.mcpServers.entwurf-bridge
8
+
9
+ A settings file can be tracked and owned by a formatter (this repo's own is: tab
10
+ indented, biome-governed, pinned byte-for-byte by check-install-surface S7b–S7d), so a
11
+ writer that re-serializes it in its own house style turns `install` into a source edit.
12
+ #53 B closed that for the packages writer and left the provider writer open — the same
13
+ file, the same defect, one function call away. The rules therefore live HERE rather than
14
+ in either script, because a rule that has to be remembered at each call site is exactly
15
+ what shipped the bug twice:
16
+
17
+ 1. NO WRITE WHEN NOTHING CHANGES. The strongest idempotence is an untouched file —
18
+ same bytes, same mtime. Compare the mutated document to the one that was loaded and
19
+ skip the write when they are equal; only the caller knows what "changed" means, so
20
+ each one asks `unchanged` and decides.
21
+ 2. A GENUINE REWRITE KEEPS THE FILE'S OWN INDENT UNIT. Narrower than it sounds: this
22
+ preserves the indent UNIT, not a formatter's line-collapsing decisions, so a rewrite
23
+ is NOT guaranteed to come back formatter-clean. Byte identity comes from rule 1.
24
+
25
+ Ships inside `scripts/` (package.json `files` carries the directory whole, with
26
+ `__pycache__`/`*.pyc` excluded), and both importers add their own directory to sys.path
27
+ so the import holds however they are invoked.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+
35
+ # The first indented line's leading whitespace is the file's unit. `\S` so a blank or
36
+ # whitespace-only line never answers for the document.
37
+ _INDENT = re.compile(r"\n([ \t]+)\S")
38
+
39
+
40
+ def detect_indent(text: str | None) -> str | int:
41
+ """The file's own indent unit — a tab, or N spaces. Defaults to 2 for a new/one-line
42
+ file, which is what both writers produced before they knew to ask."""
43
+ if not text:
44
+ return 2
45
+ m = _INDENT.search(text)
46
+ if not m:
47
+ return 2
48
+ unit = m.group(1)
49
+ return "\t" if unit[0] == "\t" else len(unit)
50
+
51
+
52
+ def dumps(data: dict, indent: str | int) -> str:
53
+ """Serialize with the given unit and exactly one trailing newline."""
54
+ return json.dumps(data, indent=indent) + "\n"
55
+
56
+
57
+ def unchanged(before: dict, after: dict) -> bool:
58
+ """Would writing `after` change what the file MEANS?
59
+
60
+ Value equality, not byte equality, and deliberately so: dict `==` ignores key ORDER,
61
+ so a document whose keys merely moved is reported unchanged and the operator's own
62
+ ordering survives. The caller must pass a `before` taken BEFORE any mutation — both
63
+ writers mutate in place, so they parse the raw text twice rather than aliasing it.
64
+ """
65
+ return before == after
@@ -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):
@@ -3,7 +3,7 @@
3
3
  // LIVE=1 ./run.sh smoke-acp-raw-turn-live
4
4
  //
5
5
  // What this proves (and ONLY this): the pinned Claude ACP adapter
6
- // (@agentclientprotocol/claude-agent-acp@0.61.0) spawns, speaks the ACP wire
6
+ // (@agentclientprotocol/claude-agent-acp@0.62.0) spawns, speaks the ACP wire
7
7
  // protocol over stdio NDJSON, and returns one real model turn. It is the
8
8
  // bytes-flow proof that the S2a dep surface is not just installable but
9
9
  // actually drivable — before any provider/overlay/streamSimple code (S2b+).
@@ -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";
@@ -32,26 +32,41 @@
32
32
  // The isolated world also keeps the operator's live store and mailbox clean: a
33
33
  // smoke that delivers into the real garden would page a human.
34
34
  //
35
- // KNOWN OPEN DEFECT this gate catches — bundled-MCP readiness (2026-07-24). Two
36
- // independent failures were first read as the model declining an explicit
37
- // instruction, and the transcripts said otherwise: in both, the entwurf tool was
38
- // ABSENT from the session's schema. The two models then behaved differently, which
39
- // is why the shared cause was easy to miss in this gate's failure (a standalone
40
- // run) the model called anyway and the runtime answered `No such tool available:
41
- // mcp__entwurf-bridge__entwurf_v2`; in the sibling smoke-acp-bundled-mcp-live's
42
- // failure (a separate run, half an hour later, inside a release-gate aggregate) the
43
- // model read its schema first, reported that only Read/Bash/Edit/Write/Skill were
44
- // exposed, and refused to invent a result for a tool it did not have. Two
45
- // independent runs, different behaviour, one defect: the server was not there to be
46
- // called. The window is
47
- // structural: claude-agent-acp 0.61.0's createSession awaits only
35
+ // KNOWN OPEN DEFECT this gate catches — bundled-MCP readiness (2026-07-24).
36
+ // SSOT for the ledger and the claim tiers: ROADMAP 「번들 MCP readiness race」 +
37
+ // docs/acp-backend-rail.md §11. Keep this comment at the strength those carry.
38
+ //
39
+ // OBSERVED (established): in three runs the entwurf tool was ABSENT from the
40
+ // session's schema the same SYMPTOM each time, reached by two different model
41
+ // behaviours, which is why one shared symptom was easy to miss. In this gate's
42
+ // failure (a standalone run) the model called anyway and the runtime answered
43
+ // `No such tool available: mcp__entwurf-bridge__entwurf_v2`; in the sibling
44
+ // smoke-acp-bundled-mcp-live's failure (a separate run, half an hour later,
45
+ // inside a release-gate aggregate) the model read its schema first, reported that
46
+ // only Read/Bash/Edit/Write/Skill were exposed, and refused to invent a result for
47
+ // a tool it did not have.
48
+ //
49
+ // ESTABLISHED CONTRACT GAP (not the same as a cause): there is no client-side
50
+ // readiness fence on this path. claude-agent-acp's createSession awaits only
48
51
  // `initializationResult()`, and this backend prompts right after
49
52
  // (acp/backend.ts:718-790) — nothing waits for the configured MCP servers to reach
50
- // `connected`, though claude-agent-sdk 0.3.217 exposes exactly that via
51
- // `mcpServerStatus()`. Both observed hits came under heavy concurrent load, which
52
- // is correlation, not established cause. So this gate stays MUST: its failures are
53
- // OURS. Until the readiness wait exists, a FAIL here is a real release blocker and
54
- // must not be re-read as model flakiness.
53
+ // `connected`, though claude-agent-sdk exposes exactly that via
54
+ // `mcpServerStatus()`.
55
+ //
56
+ // OPEN (do NOT write as settled): whether that missing fence is what produced the
57
+ // three observations. No controlled repro exists yet, and the load association is
58
+ // correlation only. Do not restate this as "one defect" or "the cause".
59
+ //
60
+ // Bump note: measured at claude-agent-acp 0.61.0 / claude-agent-sdk 0.3.217;
61
+ // re-checked at the 2026-07-27 bump to 0.62.0 / 0.3.219 — the adapter's `dist/` is
62
+ // byte-identical across 0.61.0→0.62.0 and adds no readiness fence. That is an
63
+ // unchanged ABSENCE of any explicit wait, not an unchanged window: the transitive
64
+ // SDK moved and MCP startup lives inside it, so effective timing may differ.
65
+ //
66
+ // So this gate stays MUST: it TELLS the model which tool to call, so a failure
67
+ // here is OURS, not model preference. While the symptom and the contract gap
68
+ // remain unresolved, a FAIL here is a real release blocker and must not be
69
+ // re-read as model flakiness.
55
70
  //
56
71
  // LIVE-only — kept OUT of `pnpm check`; honest skip when LIVE!=1 (skip = CI safety,
57
72
  // NOT an acceptance PASS). Model override: ENTWURF_ACP_PROVIDER_MODEL (default sonnet).