@junghanacs/entwurf 0.12.9 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/AGENTS.md +135 -248
  2. package/BASELINE.md +46 -7
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +5 -4
  6. package/README.md +46 -18
  7. package/VERIFY.md +10 -6
  8. package/demo/demo.sh +7 -1
  9. package/docs/setup-clean-host.md +10 -4
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +15 -17
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +9 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
  28. package/mcp/entwurf-bridge/src/index.ts +190 -82
  29. package/package.json +7 -3
  30. package/pi/settings.reference.json +1 -1
  31. package/pi-extensions/acp-provider.ts +20 -10
  32. package/pi-extensions/entwurf-control.ts +69 -27
  33. package/pi-extensions/lib/acp/augment.ts +60 -2
  34. package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
  35. package/pi-extensions/lib/acp/backend.ts +5 -1
  36. package/pi-extensions/lib/acp/config.ts +19 -5
  37. package/pi-extensions/lib/acp/engraving.ts +3 -1
  38. package/pi-extensions/lib/acp/event-mapper.ts +10 -3
  39. package/pi-extensions/lib/acp/models.ts +69 -7
  40. package/pi-extensions/lib/acp/overlay.ts +234 -3
  41. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  42. package/pi-extensions/lib/entwurf-core.ts +26 -9
  43. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  44. package/pi-extensions/lib/entwurf-fact-provider.ts +15 -17
  45. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  46. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  47. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  48. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  49. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  50. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  51. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  52. package/pi-extensions/lib/entwurf-v2-production.ts +14 -11
  53. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
  54. package/pi-extensions/lib/meta-session.ts +17 -20
  55. package/run.sh +200 -62
  56. package/scripts/agy-bridge-config.py +249 -49
  57. package/scripts/agy-bridge.sh +59 -14
  58. package/scripts/check-acp-carrier-augment.ts +34 -2
  59. package/scripts/check-acp-cortex.ts +668 -0
  60. package/scripts/check-acp-provider-surface.ts +50 -6
  61. package/scripts/check-acp-sdk-surface.ts +22 -11
  62. package/scripts/check-acp-session-reuse.ts +64 -1
  63. package/scripts/check-agy-permission-matrix.py +655 -0
  64. package/scripts/check-entwurf-deliverability.ts +9 -6
  65. package/scripts/check-entwurf-fact-provider.ts +7 -7
  66. package/scripts/check-entwurf-facts.ts +12 -12
  67. package/scripts/check-entwurf-resume-args.ts +22 -60
  68. package/scripts/check-entwurf-self-address.ts +187 -4
  69. package/scripts/check-entwurf-v2-contract.ts +2 -2
  70. package/scripts/check-entwurf-v2-production.ts +5 -5
  71. package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
  72. package/scripts/check-entwurf-v2-surface.ts +327 -14
  73. package/scripts/check-gate-qualification.ts +787 -0
  74. package/scripts/check-meta-identity-consumers.ts +1 -1
  75. package/scripts/check-probe-cli-shim.ts +879 -0
  76. package/scripts/check-probe-ordering.ts +2450 -0
  77. package/scripts/check-shell-quote.ts +5 -4
  78. package/scripts/fixtures/probe-cli-shim +20 -0
  79. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  80. package/scripts/lib/mutation-qualify.ts +794 -0
  81. package/scripts/lib/probe-acp-turn.ts +207 -0
  82. package/scripts/lib/probe-cli-shim.ts +464 -0
  83. package/scripts/lib/probe-cli-target.ts +165 -0
  84. package/scripts/lib/probe-event-log.ts +383 -0
  85. package/scripts/lib/probe-verdict.ts +1213 -0
  86. package/scripts/mutants/acp-augment.json +30 -0
  87. package/scripts/mutants/acp-cortex.json +196 -0
  88. package/scripts/mutants/agy-permission.json +144 -0
  89. package/scripts/mutants/meta-identity.json +17 -0
  90. package/scripts/mutants/probe-ordering.json +1032 -0
  91. package/scripts/mutants/self-address.json +59 -0
  92. package/scripts/mutants/v2-surface.json +87 -0
  93. package/scripts/smoke-acp-cortex-live.ts +392 -0
  94. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
  95. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  96. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  97. package/scripts/smoke-agy-install-state.sh +205 -20
  98. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  99. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  100. package/scripts/check-entwurf-mailbox-guard.ts +0 -266
@@ -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+).
@@ -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).
@@ -452,32 +452,72 @@ rm -f "$LEGACY" "$DLINK" "$DSTATE"
452
452
  unset ENTWURF_DEV_BIN_DIR ENTWURF_BRIDGE_TARGET ENTWURF_AGY_STATUSLINE_TARGET
453
453
 
454
454
  # ── K: permission grant — the OTHER half of a usable bridge ───────────────────
455
- # Registering the server only makes the tool REACHABLE. agy defaults every `mcp` action to Ask, so
456
- # without an allow rule every entwurf_v2 call stops for a y/n — a registered-but-ungranted bridge is
457
- # half-installed. We grant exactly ONE string in `permissions.allow`; the operator's own rules are
458
- # preserved, never managed (granting ourselves command(*) would be their trust decision, not ours).
455
+ # Registering the server only makes the tools REACHABLE. agy defaults every `mcp` action to Ask, so
456
+ # without an allow rule every call stops for a y/n — a registered-but-ungranted bridge is
457
+ # half-installed. We grant ONE NARROW STRING PER NORMAL-PATH TOOL in `permissions.allow` (every
458
+ # tool the server exposes is visible to the model; auto-granting is the smaller, deliberate set).
459
+ # The operator's own rules are preserved, never managed (granting ourselves command(*) would be their
460
+ # trust decision, not ours). The rule list comes from the config engine, never retyped here — a
461
+ # second copy is how a newly shipped tool silently stops being granted.
459
462
  SETTINGS="$HOME/.gemini/antigravity-cli/settings.json"
460
463
  SLSTATE="$XDG_DATA_HOME/entwurf/agy-statusline/install-state.json"
461
464
  STATUSLINE="$REPO_DIR/scripts/agy-statusline-bridge.sh"
462
- RULE='mcp(entwurf-bridge/entwurf_v2)'
465
+ RULES="$(python3 "$REPO_DIR/scripts/agy-bridge-config.py" permission-rules)"
466
+ RULE="${RULES%% *}" # first rule, for the single-string cases below
467
+
468
+ # ── INDEPENDENT CONTRACT ─────────────────────────────────────────────────────
469
+ # Everything below drives the SUT with $RULES, which the SUT itself printed. That is the right way
470
+ # to keep operator messages and assertions from drifting — but as an ORACLE it is circular: adding
471
+ # entwurf_inbox_read to ALLOW_RULES, or swapping entwurf_peers for something else, would sail
472
+ # through every rule-set assertion because the expectation moved with the code. So the SET ITSELF is
473
+ # pinned here, literally, once. Over-granting is the failure this catches: a permission we take and
474
+ # do not need is not a smaller bug than one we forget.
475
+ EXPECT_RULES='mcp(entwurf-bridge/entwurf_v2) mcp(entwurf-bridge/entwurf_peers) mcp(entwurf-bridge/entwurf_self)'
476
+ want "contract: the auto-granted rule set is EXACTLY the three normal-path tools, in order [QK:AGY-EXACT-RULE-SET]" \
477
+ "[ \"$RULES\" = \"$EXPECT_RULES\" ]"
478
+ # Named negatives, not just an equality: these two are the tools a future edit is most likely to add
479
+ # by reflex ("grant everything the server exposes"), and each would be a grant we never justified —
480
+ # inbox_read names a rail native-push does not have, register_native is a manual fallback the normal
481
+ # birth path never calls.
482
+ for forbidden in entwurf_inbox_read entwurf_register_native; do
483
+ want "contract: '$forbidden' is NOT auto-granted (we do not take permissions the normal path never uses)" \
484
+ "! printf '%s' \"$RULES\" | grep -qF 'mcp(entwurf-bridge/$forbidden)'"
485
+ done
463
486
  has_rule() { python3 -c "
464
487
  import json,sys
465
488
  d=json.load(open('$1'))
466
- sys.exit(0 if '$RULE' in (d.get('permissions') or {}).get('allow', []) else 1)"; }
489
+ allow=(d.get('permissions') or {}).get('allow', [])
490
+ sys.exit(0 if all(r in allow for r in '''$RULES'''.split()) else 1)"; }
467
491
 
468
492
  rm -f "$SETTINGS" "$PSTATE" "$STATE" "$GLOBAL"
469
493
  bash "$BRIDGE" install >/dev/null 2>&1
470
- want "permission: install grants the rule into permissions.allow" "has_rule '$SETTINGS'"
494
+ want "permission: install grants EVERY normal-path rule into permissions.allow" "has_rule '$SETTINGS'"
495
+
496
+ # TWO state files, TWO schemas, versioned APART. The permission state moved to 2 when its shape
497
+ # changed to a rule set; the MCP install-state's layout never moved and must stay 1. Sharing one
498
+ # constant makes the version a lie in whichever file did not change — a later reader branching on it
499
+ # would be branching on noise, and "the schema bumped" would stop meaning "the shape changed".
500
+ want "state schemas are independent: MCP install-state stays at its own version (1) [QK:AGY-SCHEMA-VERSIONS-APART]" \
501
+ "[ \"\$(python3 -c \"import json;print(json.load(open('$STATE'))['schemaVersion'])\")\" = 1 ]"
502
+ want "state schemas are independent: permission-state carries the rule-set version (2)" \
503
+ "[ \"\$(python3 -c \"import json;print(json.load(open('$PSTATE'))['schemaVersion'])\")\" = 2 ]"
504
+ # The defect this pins (measured 2026-07-27 on a live agy citizen): install granted entwurf_v2 only,
505
+ # so entwurf_peers and entwurf_self prompted for a y/n on every call while the doctor reported green.
506
+ # A grant for the tool we happen to check first must never stand in for the whole surface.
507
+ want "permission: a PARTIAL grant is not reported as configured (agy still prompts on the rest)" \
508
+ "printf '{\"permissions\":{\"allow\":[\"%s\"]}}\n' '$RULE' > '$SETTINGS'; \
509
+ [ \"\$(python3 '$REPO_DIR/scripts/agy-bridge-config.py' permission-doctor '$SETTINGS' | cut -d' ' -f1)\" = partially-configured ]"
510
+ bash "$BRIDGE" install >/dev/null 2>&1
471
511
 
472
512
  # IDEMPOTENCY, and not only of the file: a re-install must not rewrite PROVENANCE either. Re-reading
473
513
  # the rule we ourselves wrote as "the operator already had it" would strand it forever (the inverse
474
514
  # would decline to remove it). Installers are re-run on every upgrade, so this path is the norm.
475
515
  bash "$BRIDGE" install >/dev/null 2>&1
476
516
  bash "$BRIDGE" install >/dev/null 2>&1
477
- want "permission: re-install is idempotent (rule appears exactly once)" \
478
- "[ \"\$(python3 -c \"import json;print(json.load(open('$SETTINGS'))['permissions']['allow'].count('$RULE'))\")\" = 1 ]"
479
- want "permission: re-install does NOT re-attribute OUR rule to the operator (provenance is sticky)" \
480
- "[ \"\$(python3 -c \"import json;print(json.load(open('$PSTATE'))['ruleExistedBefore'])\")\" = False ]"
517
+ want "permission: re-install is idempotent (every rule appears exactly once)" \
518
+ "[ \"\$(python3 -c \"import json;a=json.load(open('$SETTINGS'))['permissions']['allow'];print(max(a.count(r) for r in '''$RULES'''.split()))\")\" = 1 ]"
519
+ want "permission: re-install does NOT re-attribute OUR rules to the operator (provenance is sticky)" \
520
+ "[ \"\$(python3 -c \"import json;e=json.load(open('$PSTATE'))['rulesExistedBefore'];print(any(e.values()))\")\" = False ]"
481
521
 
482
522
  # Honest inverse after those re-installs: WE created the file and both containers, so nothing of
483
523
  # ours may survive. (Before the provenance fix, install×2 → uninstall left the rule behind.)
@@ -496,16 +536,145 @@ bash "$BRIDGE" uninstall >/dev/null 2>&1
496
536
  want "permission: uninstall takes back ONLY our rule, leaving the operator's structure intact" \
497
537
  "python3 -c \"import json,sys;p=json.load(open('$SETTINGS'))['permissions'];sys.exit(0 if p['allow']==['command(*)'] and p['deny']==['read_file(/etc)'] else 1)\""
498
538
 
499
- # The rule was ALREADY the operator's before we ever installed → it was never ours to take away.
539
+ # The rules were ALREADY the operator's before we ever installed → never ours to take away. Written
540
+ # as the FULL set so that nothing here is ours: provenance is per rule, so a host where the operator
541
+ # granted some and we added the rest is the MIXED case, pinned separately below.
542
+ python3 -c "import json;json.dump({'permissions':{'allow':'''$RULES'''.split()}},open('$SETTINGS','w'))"
543
+ bash "$BRIDGE" install >/dev/null 2>&1
544
+ want "permission: an operator's pre-existing rules are recorded as theirs (rulesExistedBefore)" \
545
+ "[ \"\$(python3 -c \"import json;e=json.load(open('$PSTATE'))['rulesExistedBefore'];print(all(e.values()))\")\" = True ]"
546
+ bash "$BRIDGE" uninstall >/dev/null 2>&1
547
+ want "permission: uninstall does NOT revoke rules the operator already had" "has_rule '$SETTINGS'"
548
+
549
+ # ── v1 → v2 permission-state migration, from a REAL v1 fixture ───────────────
550
+ # A shipped host is mid-migration right now: its permission-state was written by an entwurf that
551
+ # granted one rule and recorded `rule`/`ruleExistedBefore`. The release contract is that upgrading
552
+ # carries that answer instead of re-capturing it — a re-capture would read the rule WE wrote as the
553
+ # operator's and strand it, or read THEIRS as ours and delete it. Prose claimed this; now the gate
554
+ # owns it, including a direct uninstall from an unmigrated v1 state.
555
+ V1_RULE="$RULE" # what a v1 install granted
556
+ V1_OTHER="${RULES#* }"; V1_OTHER="${V1_OTHER%% *}" # second rule: operator-owned in this fixture
557
+ V1_NEW="${RULES##* }" # third rule: nobody has it yet → ours on migration
558
+ rm -f "$STATE" "$PSTATE"
559
+ python3 -c "import json;json.dump({'permissions':{'allow':['$V1_RULE','$V1_OTHER']}},open('$SETTINGS','w'))"
560
+ python3 -c "
561
+ import json,os
562
+ json.dump({'schemaVersion':1,'managedSettingsPath':os.path.abspath('$SETTINGS'),
563
+ 'rule':'$V1_RULE','detectMode':'adopt-regular-file','settingsExistedBefore':True,
564
+ 'permissionsExistedBefore':True,'allowExistedBefore':True,'ruleExistedBefore':False,
565
+ 'installedAt':'2026-07-26T00:00:18Z'}, open('$PSTATE','w'))"
566
+ want "v1→v2: a v1 permission-state uninstalls on its own shape (removes ours, keeps theirs)" \
567
+ "bash '$BRIDGE' uninstall >/dev/null 2>&1 && python3 -c \"import json,sys;a=json.load(open('$SETTINGS'))['permissions']['allow'];sys.exit(0 if a==['$V1_OTHER'] else 1)\""
568
+
569
+ # Now the migration proper: same v1 fixture, but install (upgrade) runs first.
570
+ rm -f "$STATE" "$PSTATE"
571
+ python3 -c "import json;json.dump({'permissions':{'allow':['$V1_RULE','$V1_OTHER']}},open('$SETTINGS','w'))"
572
+ python3 -c "
573
+ import json,os
574
+ json.dump({'schemaVersion':1,'managedSettingsPath':os.path.abspath('$SETTINGS'),
575
+ 'rule':'$V1_RULE','detectMode':'adopt-regular-file','settingsExistedBefore':True,
576
+ 'permissionsExistedBefore':True,'allowExistedBefore':True,'ruleExistedBefore':False,
577
+ 'installedAt':'2026-07-26T00:00:18Z'}, open('$PSTATE','w'))"
578
+ bash "$BRIDGE" install >/dev/null 2>&1
579
+ want "v1→v2: install migrates the state in place (schemaVersion 2, per-rule provenance)" \
580
+ "[ \"\$(python3 -c \"import json;s=json.load(open('$PSTATE'));print(s['schemaVersion']==2 and isinstance(s.get('rulesExistedBefore'),dict))\")\" = True ]"
581
+ # The three-way answer, which is the whole point of per-rule provenance:
582
+ # the v1 rule stays OURS (carried, not re-read) · the operator's rule stays THEIRS
583
+ # the newly granted tool becomes OURS
584
+ want "v1→v2: provenance is carried per rule (v1 rule ours, operator's rule theirs, new rule ours)" \
585
+ "[ \"\$(python3 -c \"import json;e=json.load(open('$PSTATE'))['rulesExistedBefore'];print(e['$V1_RULE'] is False and e['$V1_OTHER'] is True and e['$V1_NEW'] is False)\")\" = True ]"
586
+ bash "$BRIDGE" uninstall >/dev/null 2>&1
587
+ want "v1→v2: the migrated inverse takes back only ours and leaves the operator's rule" \
588
+ "python3 -c \"import json,sys;a=json.load(open('$SETTINGS'))['permissions']['allow'];sys.exit(0 if a==['$V1_OTHER'] else 1)\""
589
+
590
+ # A state shape we cannot read must REFUSE, never fall through to "nothing was theirs" and revoke.
591
+ rm -f "$STATE" "$PSTATE"
592
+ python3 -c "import json;json.dump({'permissions':{'allow':'''$RULES'''.split()}},open('$SETTINGS','w'))"
593
+ python3 -c "
594
+ import json,os
595
+ json.dump({'schemaVersion':99,'managedSettingsPath':os.path.abspath('$SETTINGS')}, open('$PSTATE','w'))"
596
+ want "malformed state: an unknown schemaVersion FAILS the uninstall instead of guessing" \
597
+ "! bash '$BRIDGE' uninstall >/dev/null 2>&1"
598
+ want "malformed state: and the operator's rules are still there afterwards (no blind revoke)" \
599
+ "has_rule '$SETTINGS'"
600
+ # The specific deletion hazard: a v2 state whose provenance map is EMPTY. Read permissively, every
601
+ # rule reads as "not theirs" and the inverse revokes the operator's whole set.
602
+ python3 -c "
603
+ import json,os
604
+ json.dump({'schemaVersion':2,'managedSettingsPath':os.path.abspath('$SETTINGS'),
605
+ 'rules':'''$RULES'''.split(),'rulesExistedBefore':{}}, open('$PSTATE','w'))"
606
+ want "malformed state: an INCOMPLETE provenance map fails loud (it must not promote their rules to ours)" \
607
+ "! bash '$BRIDGE' uninstall >/dev/null 2>&1"
608
+ want "malformed state: the operator's rules survive the incomplete-provenance refusal" \
609
+ "has_rule '$SETTINGS'"
610
+
611
+ # INSTALL must refuse the same unreadable prior, not silently re-capture over it. A shallow shape
612
+ # check (dict? values bool?) passes an EMPTY map — all() of nothing is true — and the reinstall then
613
+ # rewrote provenance from disk, which is exactly the re-capture this state file exists to prevent:
614
+ # every rule would be recorded as the operator's and the inverse would decline to remove any of it.
615
+ python3 -c "
616
+ import json,os
617
+ json.dump({'schemaVersion':2,'managedSettingsPath':os.path.abspath('$SETTINGS'),
618
+ 'rules':'''$RULES'''.split(),'rulesExistedBefore':{}}, open('$PSTATE','w'))"
619
+ PRE_S="$(python3 -c "import hashlib;print(hashlib.sha256(open('$SETTINGS','rb').read()).hexdigest())")"
620
+ PRE_P="$(python3 -c "import hashlib;print(hashlib.sha256(open('$PSTATE','rb').read()).hexdigest())")"
621
+ want "malformed state: INSTALL refuses an unreadable prior instead of re-capturing provenance [QK:AGY-INSTALL-MALFORMED-PRIOR]" \
622
+ "! bash '$BRIDGE' install >/dev/null 2>&1"
623
+ want "malformed state: the refused install left settings AND state byte-identical" \
624
+ "[ \"\$(python3 -c \"import hashlib;print(hashlib.sha256(open('$SETTINGS','rb').read()).hexdigest())\")\" = '$PRE_S' ] && \
625
+ [ \"\$(python3 -c \"import hashlib;print(hashlib.sha256(open('$PSTATE','rb').read()).hexdigest())\")\" = '$PRE_P' ]"
626
+
627
+ # A refusal must leave the world untouched even when the settings file is GONE. The validation used
628
+ # to run inside the settings-exists branch, so on this host it was first reached in the closing
629
+ # message — AFTER os.remove(state_path). The safety check destroyed the only record of what we owed.
630
+ rm -f "$SETTINGS"
631
+ want "malformed state: uninstall refuses when the settings file is absent (nothing to guess from) [QK:AGY-UNINSTALL-VALIDATE-FIRST]" \
632
+ "! bash '$BRIDGE' uninstall >/dev/null 2>&1"
633
+ want "malformed state: the refused uninstall did NOT delete the permission-state" \
634
+ "[ -f '$PSTATE' ]"
635
+
636
+ # DOCTOR must not round a corrupt ownership record up to green. Runtime here is perfect — all three
637
+ # rules present, agy prompts on nothing — while the record that tells our grants from theirs is
638
+ # unreadable. Hard rule 13: runtime truth and ownership truth are separate axes.
639
+ python3 -c "import json;json.dump({'permissions':{'allow':'''$RULES'''.split()}},open('$SETTINGS','w'))"
640
+ if bash "$BRIDGE" doctor >/dev/null 2>&1; then
641
+ die "corrupt-ownership: doctor must FAIL on an unreadable permission-state even when runtime is fine [QK:AGY-DOCTOR-OWNERSHIP-AXIS]"
642
+ fi
643
+ ok "permission: doctor FAILS on a path-correct but UNREADABLE permission-state (ownership axis)"
644
+ DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
645
+ want "permission: the corrupt-ownership report names the axis and does not blame runtime" \
646
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'CORRUPT (permission)'"
647
+ rm -f "$STATE" "$PSTATE"
648
+
649
+ # CROSS-LIST scope: broad-most-covering is a statement about the whole file, not about one list.
650
+ # A deny of ONE exact tool next to an ask of the server-wide rule is a host where EVERYTHING is
651
+ # shadowed; naming the exact hit because `deny` is scanned first would tell the operator their other
652
+ # grants still work while agy prompts on all of them.
653
+ python3 -c "
654
+ import json
655
+ json.dump({'permissions':{'allow':'''$RULES'''.split(),
656
+ 'deny':['${RULES##* }'],
657
+ 'ask':['mcp(entwurf-bridge)']}}, open('$SETTINGS','w'))"
658
+ want "cross-list shadow: a broad rule in ANY list outranks an exact hit in a higher-precedence list" \
659
+ "[ \"\$(python3 '$REPO_DIR/scripts/agy-bridge-config.py' permission-doctor '$SETTINGS' | cut -d' ' -f2)\" = broad ]"
660
+ DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
661
+ want "cross-list shadow: the report says EVERY call is blocked, not 'other grants still work'" \
662
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'EVERY entwurf tool call'"
663
+ rm -f "$STATE" "$PSTATE"
664
+
665
+ # MIXED provenance — the shape agy's own "always allow" prompt creates, one rule at a time. The
666
+ # operator owns one; we add the rest; the inverse must split them exactly, taking back only ours.
667
+ rm -f "$STATE" "$PSTATE"
500
668
  printf '{"permissions":{"allow":["%s"]}}\n' "$RULE" > "$SETTINGS"
501
669
  bash "$BRIDGE" install >/dev/null 2>&1
502
- want "permission: an operator's pre-existing rule is recorded as theirs (ruleExistedBefore)" \
503
- "[ \"\$(python3 -c \"import json;print(json.load(open('$PSTATE'))['ruleExistedBefore'])\")\" = True ]"
670
+ want "permission: mixed provenance is recorded per rule (theirs stays theirs, ours stays ours)" \
671
+ "[ \"\$(python3 -c \"import json;e=json.load(open('$PSTATE'))['rulesExistedBefore'];print(e['$RULE'] is True and sum(1 for v in e.values() if v is False)==len(e)-1)\")\" = True ]"
504
672
  bash "$BRIDGE" uninstall >/dev/null 2>&1
505
- want "permission: uninstall does NOT revoke a rule the operator already had" "has_rule '$SETTINGS'"
673
+ want "permission: uninstall keeps THEIR rule and removes only the ones WE added" \
674
+ "python3 -c \"import json,sys;a=json.load(open('$SETTINGS'))['permissions']['allow'];sys.exit(0 if a==['$RULE'] else 1)\""
506
675
 
507
676
  # ── K2: TWO adapters, ONE file — element ownership is what keeps them apart ────
508
- # The statusline adapter owns the `statusLine` subtree of this same settings.json; we own one string
677
+ # The statusline adapter owns the `statusLine` subtree of this same settings.json; we own our rules
509
678
  # in `permissions.allow`. Neither may restore a whole-file preimage, or uninstalling one would
510
679
  # silently revert the other. Both orders, both inverses.
511
680
  rm -f "$SETTINGS" "$PSTATE" "$SLSTATE"
@@ -550,6 +719,22 @@ ok "permission: doctor FAILS when a higher-precedence ask/deny rule shadows our
550
719
  DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
551
720
  want "permission: the shadow report names the offending list and rule" \
552
721
  "printf '%s' \"\$DOC_OUT\" | grep -q 'SHADOWED'"
722
+ want "permission: a BROAD shadow is described as covering EVERY entwurf tool call" \
723
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'EVERY entwurf tool call'"
724
+
725
+ # SCOPE HONESTY: an EXACT shadow takes one tool, not the surface. Saying "every entwurf tool call is
726
+ # blocked" when only entwurf_self is sends the operator hunting a wildcard that is not there. The
727
+ # verdict stays red — a granted tool that agy still stops on is not green — but the diagnosis must
728
+ # match what agy will actually do.
729
+ EXACT_SHADOW="${RULES##* }" # last rule in the set, shadowed alone
730
+ python3 -c "
731
+ import json
732
+ json.dump({'permissions':{'allow':'''$RULES'''.split(),'ask':['$EXACT_SHADOW']}}, open('$SETTINGS','w'))"
733
+ if bash "$BRIDGE" doctor >/dev/null 2>&1; then die "exact-shadow: doctor should FAIL when one of our tools is shadowed"; fi
734
+ ok "permission: doctor FAILS when an EXACT rule shadows one granted tool (still not green)"
735
+ DOC_OUT="$(bash "$BRIDGE" doctor 2>&1 || true)"
736
+ want "permission: an EXACT shadow names that ONE tool and does NOT claim every call is blocked" \
737
+ "printf '%s' \"\$DOC_OUT\" | grep -qF '$EXACT_SHADOW' && printf '%s' \"\$DOC_OUT\" | grep -q 'that ONE tool' && ! printf '%s' \"\$DOC_OUT\" | grep -q 'EVERY entwurf tool call'"
553
738
 
554
739
  # THE MIRROR OF IT: the same rules that shadow our allow from ask/deny COVER it from allow. An
555
740
  # operator who granted a broad mcp(*) has already made entwurf_v2 callable — reporting that host as
@@ -588,11 +773,11 @@ want "permission: an operator wildcard does NOT mask drift of the rule WE instal
588
773
  want "permission: the owned-drift report names both axes (our grant gone, their rule covering)" \
589
774
  "printf '%s' \"\$DOC_OUT\" | grep -q 'DRIFT' && printf '%s' \"\$DOC_OUT\" | grep -qF 'mcp(*)'"
590
775
 
591
- # …but an operator's OWN pre-existing rule vanishing is not our drift: ruleExistedBefore=true means
592
- # the rule was never ours to lose. Their file, their edit; the wildcard covering it stays a NOTE.
776
+ # …but an operator's OWN pre-existing rules vanishing is not our drift: rulesExistedBefore all true
777
+ # means nothing here was ours to lose. Their file, their edit; the wildcard covering it stays a NOTE.
593
778
  rm -f "$STATE" "$PSTATE"
594
- printf '{"permissions":{"allow":["%s"]}}\n' "$RULE" > "$SETTINGS"
595
- bash "$BRIDGE" install >/dev/null 2>&1 # rule pre-existed → recorded as theirs
779
+ python3 -c "import json;json.dump({'permissions':{'allow':'''$RULES'''.split()}},open('$SETTINGS','w'))"
780
+ bash "$BRIDGE" install >/dev/null 2>&1 # rules pre-existed → recorded as theirs
596
781
  python3 -c "import json;d=json.load(open('$SETTINGS'));d['permissions']['allow']=['mcp(*)'];json.dump(d,open('$SETTINGS','w'))"
597
782
  if ! bash "$BRIDGE" doctor >/dev/null 2>&1; then
598
783
  die "covered: losing the OPERATOR's own pre-existing rule must not be reported as OUR drift"
@@ -66,8 +66,9 @@ import {
66
66
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
67
67
  import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
68
68
 
69
- // pi's control socket lives at the canonical dir keyed by session id pi owns this path, so
70
- // C1 must point the decider's controlSocketDir at the REAL dir (a fresh gid avoids collision).
69
+ // pi's control socket lives at the canonical dir keyed by the RECORD's garden id (#50 C4: the
70
+ // record is the sole address authority never a transcript/session id; :196 below proves it),
71
+ // so C1 must point the decider's controlSocketDir at the REAL dir (a fresh gid avoids collision).
71
72
  const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
72
73
  const SOCKET_SUFFIX = ".sock";
73
74
  // Release-gate topology: repo-under-test, not deployment smoke. Load only this
@@ -1,100 +0,0 @@
1
- /**
2
- * entwurf-mailbox-guard — the IO orchestration that gates a conversational mailbox
3
- * enqueue on the (pure) deliverability predicate (SE-1/SE-2 slice 2d). Kept SEPARATE
4
- * from entwurf-deliverability.ts so the predicate stays pure (gate-pinnable) and only
5
- * this thin wrapper touches the record store / capability registry / presence marker.
6
- *
7
- * The contract every conversational-reply enqueue site (MCP entwurf_v2 fallback,
8
- * pi-native entwurf_v2 fallback) must use INSTEAD of calling enqueueMetaMessage
9
- * directly:
10
- * 1. gather facts for the target garden id (record? wakeMode? receiver active?),
11
- * 2. ask mailboxConversationalDeliverable,
12
- * 3. enqueue ONLY when deliverable; otherwise return a typed non-delivery with a
13
- * reason — no .msg written, no doorbell poked.
14
- *
15
- * This is the SE-2 "no garbage in a dead/absent receiver's mailbox" guard and the
16
- * SE-1 "never enqueue for a backend with no mailbox drain (pi)" guard, in one place.
17
- * The low-level enqueueMetaMessage stays a raw primitive — it has no in-band reject;
18
- * the refusal happens HERE, before transport, not inside it.
19
- */
20
-
21
- import {
22
- type MailboxDeliverabilityFacts,
23
- mailboxConversationalDeliverable,
24
- receiverMarkerMatchesIdentity,
25
- } from "./entwurf-deliverability.ts";
26
- import {
27
- type MetaCapability,
28
- type MetaCitizenBackend,
29
- type MetaIdentity,
30
- type MetaReceiverMarker,
31
- metaCapabilityFor,
32
- readMetaIdentityByGardenId,
33
- readMetaReceiverMarker,
34
- } from "./meta-session.ts";
35
-
36
- export interface MailboxGuardDeps {
37
- /** Read the target's identity (throws when there is no backing record). */
38
- readIdentity?: (gardenId: string) => MetaIdentity;
39
- /** Read the target's receiver presence marker (null = no live, armed receiver). */
40
- readReceiverMarker?: (gardenId: string) => MetaReceiverMarker | null;
41
- /** Resolve a backend's capability (wake mode). */
42
- capabilityFor?: (backend: MetaCitizenBackend) => MetaCapability;
43
- }
44
-
45
- /**
46
- * Gather the deliverability facts for a target garden id, all via injected (or
47
- * production-default) readers. recordBacked/wakeMode come from the record + capability
48
- * registry; ownerAlive and watchArmed both derive from the receiver presence marker —
49
- * at runtime they move together (a verified marker means a live owner that reached the
50
- * watch-arm path; its absence/dead-owner means neither), but the pure predicate keeps
51
- * the axes separate so each cause stays nameable. A present marker only counts when it
52
- * actually belongs to THIS identity (garden/backend/native id match via the shared
53
- * receiverMarkerMatchesIdentity helper) — a drifted/foreign marker is fail-closed to
54
- * inactive, the same SSOT the v2 production mailboxDeliverabilityFor seam uses.
55
- */
56
- export function gatherMailboxDeliverabilityFacts(
57
- gardenId: string,
58
- deps: MailboxGuardDeps = {},
59
- ): MailboxDeliverabilityFacts {
60
- const readIdentity = deps.readIdentity ?? ((g: string) => readMetaIdentityByGardenId(g));
61
- const readReceiverMarker = deps.readReceiverMarker ?? ((g: string) => readMetaReceiverMarker({ gardenId: g }));
62
- const capabilityFor = deps.capabilityFor ?? metaCapabilityFor;
63
-
64
- let recordBacked = false;
65
- let wakeMode: string | undefined;
66
- let identity: MetaIdentity | null = null;
67
- try {
68
- identity = readIdentity(gardenId);
69
- recordBacked = true;
70
- wakeMode = capabilityFor(identity.backend).wakeMode;
71
- } catch {
72
- recordBacked = false;
73
- identity = null;
74
- }
75
-
76
- // No record → no identity → never matched (a marker without a record cannot be
77
- // verified against one). With a record, the marker must agree on garden/backend/
78
- // native id; otherwise it is a stale or foreign marker and the receiver is inactive.
79
- const marker = readReceiverMarker(gardenId);
80
- const matched = identity !== null && receiverMarkerMatchesIdentity(marker, identity);
81
- return { wakeMode, recordBacked, ownerAlive: matched, watchArmed: matched };
82
- }
83
-
84
- export type GuardedMailboxOutcome<T> = { delivered: true; result: T } | { delivered: false; reason: string };
85
-
86
- /**
87
- * Enqueue a conversational reply to the target's mailbox ONLY when it is deliverable.
88
- * When not, returns `{ delivered: false, reason }` and the injected `enqueue` is never
89
- * called — the SE-2 guarantee that a refused send mutates nothing.
90
- */
91
- export function guardedMailboxEnqueue<T>(
92
- gardenId: string,
93
- deps: MailboxGuardDeps,
94
- enqueue: () => T,
95
- ): GuardedMailboxOutcome<T> {
96
- const facts = gatherMailboxDeliverabilityFacts(gardenId, deps);
97
- const verdict = mailboxConversationalDeliverable(facts);
98
- if (!verdict.deliverable) return { delivered: false, reason: verdict.reason };
99
- return { delivered: true, result: enqueue() };
100
- }