@junghanacs/entwurf 0.17.1 → 0.17.2

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 (37) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +9 -2
  3. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
  4. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +18 -3
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +23 -7
  7. package/package.json +1 -1
  8. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
  9. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
  10. package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
  11. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
  12. package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
  13. package/pi-extensions/lib/entwurf-v2-surface.ts +18 -3
  14. package/pi-extensions/lib/meta-session.ts +32 -7
  15. package/scripts/check-entwurf-v2-mailbox.ts +33 -0
  16. package/scripts/check-entwurf-v2-runner.ts +18 -0
  17. package/scripts/check-entwurf-v2-send.ts +13 -1
  18. package/scripts/check-entwurf-v2-surface.ts +75 -3
  19. package/scripts/check-hook-launch-topology.ts +70 -1
  20. package/scripts/check-mailbox-receipt-state.ts +6 -0
  21. package/scripts/check-meta-doctor-oracle.sh +73 -0
  22. package/scripts/check-meta-mailbox-state-write.ts +9 -2
  23. package/scripts/check-meta-manifest-schema.py +19 -1
  24. package/scripts/check-meta-session.ts +10 -2
  25. package/scripts/meta-bridge-doctor.sh +18 -3
  26. package/scripts/meta-bridge-state.py +23 -5
  27. package/scripts/meta-bridge-statusline.sh +65 -2
  28. package/scripts/raw-async-delivery/README.md +280 -9
  29. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
  30. package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
  31. package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
  32. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
  33. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
  34. package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
  35. package/scripts/smoke-meta-async-drift.sh +31 -8
  36. package/scripts/smoke-meta-install-state.sh +170 -11
  37. package/scripts/smoke-meta-keyset-guard.sh +4 -1
@@ -6,13 +6,24 @@
6
6
  #
7
7
  # WHY THIS EXISTS
8
8
  # The whole Claude async-delivery path rides on UNDOCUMENTED Claude Code behavior:
9
- # `asyncRewake` force-prepends `Stop hook feedback:` and ignores `rewakeMessage`;
10
- # the payload channel is stderr-only; `watchPaths` can arm from only 3 hook events
11
- # (SessionStart / CwdChanged / FileChanged). Half the Gotchas in
12
- # scripts/raw-async-delivery/README.md are reverse-engineered. Claude ships ~weekly.
13
- # So the path can break SILENTLY on any upgrade. This gate makes it SCREAM instead —
14
- # direct lineage of the 0.8.x fail-fast tool-surface gates ("don't let a surface
15
- # break quietly, make the gate cry out").
9
+ # `asyncRewake` names the operator row and the model prefix from `rewakeSummary` /
10
+ # `rewakeMessage` (both read from a local plugin's hooks.json, both @internal, and
11
+ # the product doorbell now DEPENDS on both); the doorbell writes its notice on
12
+ # stderr; `watchPaths` can arm from only 3 hook events (SessionStart / CwdChanged /
13
+ # FileChanged). Half the Gotchas in scripts/raw-async-delivery/README.md are
14
+ # reverse-engineered. Claude ships ~weekly. So the path can break SILENTLY on any
15
+ # upgrade. This gate makes it SCREAM instead — direct lineage of the 0.8.x fail-fast
16
+ # tool-surface gates ("don't let a surface break quietly, make the gate cry out").
17
+ #
18
+ # THIS PARAGRAPH USED TO BE WRONG, AND THAT IS THE POINT (#98, corrected
19
+ # 2026-09-03). It said `asyncRewake` "force-prepends `Stop hook feedback:` and
20
+ # ignores `rewakeMessage`", and that the payload channel was "stderr-only". Both are
21
+ # FALSE — measured on 2.1.236/2.1.258/2.1.259: `rewakeMessage` REPLACES the model
22
+ # prefix, `rewakeSummary` REPLACES the operator row, and the model body is
23
+ # `stderr || stdout`. The marker strings below stayed green the whole time, because
24
+ # a marker pins a STRING and cannot see the sentence next to it rot. Nobody tried
25
+ # the two fields for months on the strength of this header. Receipts:
26
+ # scripts/raw-async-delivery/README.md "Inherited facts corrected".
16
27
  #
17
28
  # TWO TIERS (mirrors smoke-compaction-policy: deterministic default + LIVE=1 add-on)
18
29
  # DETERMINISTIC (default; free, offline, CI/pre-commit safe):
@@ -70,7 +81,19 @@ MARKERS=(
70
81
  flushPendingAsyncRewakeHooks # engine turn-boundary flush (edge-bound delivery)
71
82
  CLAUDE_CODE_STOP_HOOK_BLOCK_CAP # native re-wake cap
72
83
  FileChanged # idle-wake event
73
- rewakeMessage # the field asyncRewake IGNORES (doorbell-only proof)
84
+ # rewakeMessage/rewakeSummary CORRECTED 2026-09-03 (#98 Phase 1). The comment here used
85
+ # to read "the field asyncRewake IGNORES (doorbell-only proof)". That was FALSE, and it is
86
+ # the reason nobody tried the field for months: measured on 2.1.236/2.1.258/2.1.259, BOTH
87
+ # are read from a local plugin's hooks.json with no first-party gate. `rewakeMessage`
88
+ # REPLACES the model-facing `Stop hook blocking error from command "…":` prefix;
89
+ # `rewakeSummary` REPLACES the operator-visible row (default `Stop hook feedback`). Only
90
+ # the stdout-JSON form of rewakeSummary is first-party gated. The marker string was right
91
+ # while the reason next to it was dead — which is why these two are pinned HERE: the
92
+ # product doorbell now DEPENDS on both (pi/meta-bridge/.../hooks/hooks.json), the fields
93
+ # are @internal, and if either disappears the surface silently reverts to
94
+ # `Stop hook feedback` + "blocking error" with no other gate to notice.
95
+ rewakeMessage # model-facing prefix replacement (product depends on it)
96
+ rewakeSummary # operator-facing row replacement (product depends on it)
74
97
  hookSpecificOutput # the watchPaths emit envelope
75
98
  CwdChanged # the 2nd of the 3 watch-arming events
76
99
  )
@@ -317,6 +317,69 @@ def structural_cells():
317
317
  assert (broken, settings) == before
318
318
 
319
319
  cell("", "retired/managed sets stay disjoint and a corrupt key ledger fails closed", structural_cells)
320
+
321
+ def compaction_cells():
322
+ # #94 — the two compaction keys join the retired set. They exercise two axes
323
+ # the skipDangerous cells above never touch: a NESTED path and a STRING
324
+ # scalar. Both matter, because relinquish compares `type(value) is
325
+ # type(last_managed_value)` and walks the path through get_nested.
326
+ for name, path, last in [
327
+ ("autoCompactEnabled", ["autoCompactEnabled"], False),
328
+ ("env.DISABLE_AUTOCOMPACT", ["env", "DISABLE_AUTOCOMPACT"], "1"),
329
+ ]:
330
+ def build(current=MISSING, old=MISSING):
331
+ settings = {}
332
+ if current is not MISSING:
333
+ node = settings
334
+ for key in path[:-1]:
335
+ node = node.setdefault(key, {})
336
+ node[path[-1]] = current
337
+ keys = {} if old is MISSING else {name: copy.deepcopy(old)}
338
+ return {"files": {"settings": {"keys": keys}}}, settings
339
+
340
+ def led(existed, value):
341
+ return {"kind": "scalar", "path": path, "original": {"existed": existed, "value": value}}
342
+
343
+ def call(current=MISSING, old=MISSING):
344
+ state, settings = build(current, old)
345
+ m.relinquish_retired_scalar(state, settings, name, path, last)
346
+ return state, settings
347
+
348
+ # (a) still carrying what entwurf last managed, with provenance -> the
349
+ # pre-entwurf snapshot is restored, and the entry is consumed.
350
+ state, settings = call(last, led(True, last))
351
+ assert m.get_nested(settings, path) == (True, last), (name, settings)
352
+ assert name not in state["files"]["settings"]["keys"], name
353
+ # (a') the same, where the operator had NOTHING before entwurf: restoring
354
+ # the snapshot must DELETE the key rather than leave our value.
355
+ state, settings = call(last, led(False, None))
356
+ assert m.get_nested(settings, path)[0] is False, (name, settings)
357
+ # (b) the operator has since changed it -> already theirs, untouched.
358
+ other = True if last is False else "0"
359
+ state, settings = call(other, led(True, last))
360
+ assert m.get_nested(settings, path) == (True, other), (name, settings)
361
+ assert name not in state["files"]["settings"]["keys"], name
362
+ # (c) no ledger entry at all (fresh install) -> untouched, and a fresh
363
+ # install never creates one because the key is not managed any more.
364
+ for current in [last, other, MISSING]:
365
+ state, settings = call(current)
366
+ expected = (False, None) if current is MISSING else (True, current)
367
+ assert m.get_nested(settings, path) == expected, (name, current, settings)
368
+ # Type strictness, where a lookalike EXISTS. For the bool axis that is `0`
369
+ # (`0 == False` is True while `type(0) is not bool`) — NOT `1`, which is
370
+ # simply unequal to False and would pass the cell without the type guard
371
+ # ever being consulted. The string axis has no lookalike at all: no
372
+ # non-str value equals `"1"`, so equality alone already refuses and there
373
+ # is nothing for a type check to add. Asserted only where it can fail.
374
+ if last is False:
375
+ state, settings = call(0, led(True, last))
376
+ assert m.get_nested(settings, path) == (True, 0), (name, settings)
377
+
378
+ cell(
379
+ "",
380
+ "#94 compaction keys relinquish across nested/string axes: restore, delete, operator-changed, fresh, type-strict",
381
+ compaction_cells,
382
+ )
320
383
  sys.exit(failed)
321
384
  PY
322
385
  then ok "#71 retirement leaf: provenance, bool strictness, malformed refusal, one-shot convergence"
@@ -339,10 +402,12 @@ assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
339
402
  s=json.load(open(path))
340
403
  assert s['files']['settings']['keys']['enabledPlugins.entwurf-meta-receive@meta-bridge-local']['original']['value'] is False
341
404
  assert s['files']['settings']['keys']['cleanupPeriodDays']['original']['value'] == 30
342
- assert s['files']['settings']['keys']['env.DISABLE_AUTOCOMPACT']['original']['value'] == '0'
343
405
  assert s['files']['settings']['keys']['statusLine']['original']['value']['command'] == '/old/user/statusline.sh'
344
406
  assert s['files']['settings']['keys']['promptSuggestionEnabled']['original']['value'] is True
345
- assert s['files']['settings']['keys']['autoCompactEnabled']['original']['existed'] is False
407
+ # #94 case (c) — a FRESH install never captures a retired key, so entwurf holds no
408
+ # ownership evidence for compaction and uninstall can never restore a suppression.
409
+ assert 'autoCompactEnabled' not in s['files']['settings']['keys']
410
+ assert 'env.DISABLE_AUTOCOMPACT' not in s['files']['settings']['keys']
346
411
  assert 'skipDangerousModePermissionPrompt' not in s['files']['settings']['keys']
347
412
  assert s['files']['claudeRoot']['keys']['mcpServers.entwurf-bridge']['original']['value']['command'] == 'old'
348
413
  PY
@@ -360,6 +425,21 @@ state['files']['settings']['keys']['skipDangerousModePermissionPrompt']={
360
425
  'kind':'scalar', 'path':['skipDangerousModePermissionPrompt'],
361
426
  'original':{'existed':True, 'value':False}
362
427
  }
428
+ # #94 — the same upgrade shape for the two compaction keys, planted at DRIVE level
429
+ # so apply() itself is the subject, not the leaf. Two branches that the unit cells
430
+ # cannot reach through the real ledger: a NESTED path whose original must come
431
+ # back, and a key whose original never existed and must therefore be DELETED
432
+ # rather than left carrying entwurf's value.
433
+ settings['autoCompactEnabled']=False
434
+ state['files']['settings']['keys']['autoCompactEnabled']={
435
+ 'kind':'scalar', 'path':['autoCompactEnabled'],
436
+ 'original':{'existed':False, 'value':None}
437
+ }
438
+ settings.setdefault('env',{})['DISABLE_AUTOCOMPACT']='1'
439
+ state['files']['settings']['keys']['env.DISABLE_AUTOCOMPACT']={
440
+ 'kind':'scalar', 'path':['env','DISABLE_AUTOCOMPACT'],
441
+ 'original':{'existed':True, 'value':'0'}
442
+ }
363
443
  json.dump(settings, open(sp,'w'), indent=2); open(sp,'a').write('\n')
364
444
  json.dump(state, open(stp,'w'), indent=2); open(stp,'a').write('\n')
365
445
  PY
@@ -373,14 +453,26 @@ assert settings['enabledPlugins']['entwurf-meta-receive@meta-bridge-local'] is T
373
453
  assert settings['enabledPlugins']['keep@user'] is True
374
454
  assert settings['extraKnownMarketplaces']['meta-bridge-local']['source']['path'] == os.environ['ASM']
375
455
  assert settings['cleanupPeriodDays'] == 365
376
- assert settings['env']['DISABLE_AUTOCOMPACT'] == '1'
456
+ # #94 - retired: apply neither writes nor rewrites it. The fixture's operator
457
+ # value ('0') survives untouched, and the scalar is never introduced at all.
458
+ assert settings['env']['DISABLE_AUTOCOMPACT'] == '0'
459
+ assert 'autoCompactEnabled' not in settings
377
460
  assert settings['env']['KEEP_ME'] == 'yes'
378
461
  assert settings['statusLine']['command'] == os.environ['REPO'] + '/scripts/meta-bridge-statusline.sh'
379
- for key in ['promptSuggestionEnabled','awaySummaryEnabled','autoMemoryEnabled','verbose','autoCompactEnabled','showTurnDuration','terminalProgressBarEnabled','useAutoModeDuringPlan','enableWorkflows','workflowKeywordTriggerEnabled']:
462
+ for key in ['promptSuggestionEnabled','awaySummaryEnabled','autoMemoryEnabled','verbose','showTurnDuration','terminalProgressBarEnabled','useAutoModeDuringPlan','enableWorkflows','workflowKeywordTriggerEnabled']:
380
463
  assert settings[key] is False, key
381
464
  assert settings['skipDangerousModePermissionPrompt'] is False
465
+ # #94 drive-level relinquishment, both branches:
466
+ # nested path -> the operator's pre-entwurf '0' is back, and the env map that
467
+ # carries it survives with the neighbour key untouched.
468
+ # absent original -> the key is REMOVED, not left holding entwurf's False.
469
+ assert settings['env']['DISABLE_AUTOCOMPACT'] == '0'
470
+ assert settings['env']['KEEP_ME'] == 'yes'
471
+ assert 'autoCompactEnabled' not in settings
382
472
  state=json.load(open(cfg + '/entwurf.install-state.json'))
383
473
  assert 'skipDangerousModePermissionPrompt' not in state['files']['settings']['keys']
474
+ assert 'autoCompactEnabled' not in state['files']['settings']['keys']
475
+ assert 'env.DISABLE_AUTOCOMPACT' not in state['files']['settings']['keys']
384
476
  for item in ['Bash','Read','Write','Edit','Grep','Glob','WebFetch','WebSearch','Skill','mcp__entwurf-bridge__*']:
385
477
  assert item in settings['permissions']['allow'], item
386
478
  assert settings['permissions']['allow'].count('Read') == 1
@@ -455,7 +547,6 @@ import json, os
455
547
  s=json.load(open(os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'))
456
548
  assert s['files']['settings']['keys']['enabledPlugins.entwurf-meta-receive@meta-bridge-local']['original']['value'] is False
457
549
  assert s['files']['settings']['keys']['cleanupPeriodDays']['original']['value'] == 30
458
- assert s['files']['settings']['keys']['env.DISABLE_AUTOCOMPACT']['original']['value'] == '0'
459
550
  assert s['files']['settings']['keys']['statusLine']['original']['value']['command'] == '/old/user/statusline.sh'
460
551
  assert s['files']['settings']['keys']['promptSuggestionEnabled']['original']['value'] is True
461
552
  PY
@@ -479,13 +570,18 @@ root=json.load(open(os.environ['HOME'] + '/.claude.json'))
479
570
  assert settings['cleanupPeriodDays'] == 30
480
571
  assert settings['enabledPlugins']['entwurf-meta-receive@meta-bridge-local'] is False
481
572
  assert 'meta-bridge-local' not in settings.get('extraKnownMarketplaces', {})
573
+ # #94 - still the operator's '0'. NOT restored by uninstall (we never owned it):
574
+ # never written in the first place.
482
575
  assert settings['env']['DISABLE_AUTOCOMPACT'] == '0'
483
576
  assert settings['env']['KEEP_ME'] == 'yes'
484
577
  assert settings['statusLine']['command'] == '/old/user/statusline.sh'
485
578
  assert settings['promptSuggestionEnabled'] is True
486
579
  assert settings['skipDangerousModePermissionPrompt'] is False
487
580
  assert settings['showTurnDuration'] is True
488
- for key in ['awaySummaryEnabled','autoMemoryEnabled','verbose','autoCompactEnabled','terminalProgressBarEnabled','useAutoModeDuringPlan','enableWorkflows','workflowKeywordTriggerEnabled']:
581
+ # One list, one meaning: these are the keys UNINSTALL removed. `autoCompactEnabled`
582
+ # is not among them post-#94 — that entwurf never introduced it is a different
583
+ # claim, pinned on the post-install assertion above.
584
+ for key in ['awaySummaryEnabled','autoMemoryEnabled','verbose','terminalProgressBarEnabled','useAutoModeDuringPlan','enableWorkflows','workflowKeywordTriggerEnabled']:
489
585
  assert key not in settings, key
490
586
  allow=settings['permissions']['allow']
491
587
  deny=settings['permissions']['deny']
@@ -667,7 +763,8 @@ s=json.load(open(os.environ['CLAUDE_CONFIG_DIR'] + '/entwurf.install-state.json'
667
763
  assert s['files']['settings']['keys']['enabledPlugins.entwurf-meta-receive@meta-bridge-local']['original']['existed'] is False
668
764
  assert s['files']['settings']['keys']['extraKnownMarketplaces.meta-bridge-local']['original']['existed'] is False
669
765
  assert s['files']['claudeRoot']['keys']['mcpServers.entwurf-bridge']['original']['existed'] is False
670
- assert s['files']['settings']['keys']['env.DISABLE_AUTOCOMPACT']['original']['existed'] is True
766
+ # #94 - retired: prepare captures no ownership evidence for it any more.
767
+ assert 'env.DISABLE_AUTOCOMPACT' not in s['files']['settings']['keys']
671
768
  PY
672
769
  then ok "legacy exact plugin/marketplace/MCP migrate as pi-owned absent, policy keys remain user-owned"; else bad "legacy migration state check failed"; fi
673
770
  py uninstall >/dev/null
@@ -706,18 +803,80 @@ valid_record "20260606T000001-bbbbbb" "native-b" > "$STORE/20260606T000001-bbbbb
706
803
  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}}}'
707
804
  STATUS_INPUT_MISS='{"session_id":"native-missing","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-opus-5"}}'
708
805
  STATUS_INPUT_READY='{"workspace":{"current_dir":"/tmp"},"model":{"id":"claude-haiku-4-5"}}'
709
- STATUS_OUT_MATCH="$(printf '%s' "$STATUS_INPUT_MATCH" | ENTWURF_META_SESSIONS_DIR="$STORE" "$REPO/scripts/meta-bridge-statusline.sh")"
806
+ # Pin the mailbox root on EVERY statusline render below, not just the badge cells: the
807
+ # #98 B badge reads it, so an unpinned call would let the operator's real ~/.pi mailbox
808
+ # decide whether these rows carry an envelope.
809
+ MBX="$TMP/mbx"; mkdir -p "$MBX/20260606T000000-aaaaaa"
810
+ STATUS_OUT_MATCH="$(printf '%s' "$STATUS_INPUT_MATCH" | ENTWURF_META_SESSIONS_DIR="$STORE" ENTWURF_META_MAILBOX_DIR="$MBX" "$REPO/scripts/meta-bridge-statusline.sh")"
710
811
  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
711
812
  if printf '%s\n' "$STATUS_OUT_MATCH" | sed -n '1p' | grep -q 'tmp' && printf '%s\n' "$STATUS_OUT_MATCH" | sed -n '2p' | grep -q '🪛 20260606T000000-aaaaaa cc | s'; then ok "statusline keeps row-1 work context and maps native session_id to row-2 garden-id"; else bad "statusline did not show expected two-row content for native-a: $STATUS_OUT_MATCH"; fi
712
- STATUS_OUT_MISS="$(printf '%s' "$STATUS_INPUT_MISS" | ENTWURF_META_SESSIONS_DIR="$STORE" "$REPO/scripts/meta-bridge-statusline.sh")"
813
+ STATUS_OUT_MISS="$(printf '%s' "$STATUS_INPUT_MISS" | ENTWURF_META_SESSIONS_DIR="$STORE" ENTWURF_META_MAILBOX_DIR="$MBX" "$REPO/scripts/meta-bridge-statusline.sh")"
713
814
  if printf '%s' "$STATUS_OUT_MISS" | grep -q '🪛 ? cc'; then ok "statusline no-record fallback is ?"; else bad "statusline no-record fallback wrong: $STATUS_OUT_MISS"; fi
714
- STATUS_OUT_READY="$(printf '%s' "$STATUS_INPUT_READY" | ENTWURF_META_SESSIONS_DIR="$STORE" "$REPO/scripts/meta-bridge-statusline.sh")"
815
+ STATUS_OUT_READY="$(printf '%s' "$STATUS_INPUT_READY" | ENTWURF_META_SESSIONS_DIR="$STORE" ENTWURF_META_MAILBOX_DIR="$MBX" "$REPO/scripts/meta-bridge-statusline.sh")"
715
816
  if printf '%s' "$STATUS_OUT_READY" | grep -q '🪛 ready cc'; then ok "statusline no-session_id fallback is ready"; else bad "statusline ready fallback wrong: $STATUS_OUT_READY"; fi
716
817
  cp "$STORE/20260606T000000-aaaaaa.meta.json" "$STORE/20260606T000003-dddddd.meta.json"
717
- STATUS_OUT_DUP="$(printf '%s' "$STATUS_INPUT_MATCH" | ENTWURF_META_SESSIONS_DIR="$STORE" "$REPO/scripts/meta-bridge-statusline.sh")"
818
+ STATUS_OUT_DUP="$(printf '%s' "$STATUS_INPUT_MATCH" | ENTWURF_META_SESSIONS_DIR="$STORE" ENTWURF_META_MAILBOX_DIR="$MBX" "$REPO/scripts/meta-bridge-statusline.sh")"
718
819
  if printf '%s' "$STATUS_OUT_DUP" | grep -q '🪛 ! cc'; then ok "statusline duplicate nativeSessionId fallback is !"; else bad "statusline duplicate fallback wrong: $STATUS_OUT_DUP"; fi
719
820
  rm "$STORE/20260606T000003-dddddd.meta.json"
720
821
 
822
+ # --- #98 B: the unread badge -------------------------------------------------
823
+ # Three states that must LOOK different: N letters, none, and "could not count".
824
+ # The last one is the whole reason the badge exists — an unread letter that the
825
+ # operator never saw is exactly the failure #98 opened on, and a statusline that
826
+ # renders a confident 0 when its read failed reproduces it one layer down.
827
+ badge_line() { printf '%s' "$1" | ENTWURF_META_SESSIONS_DIR="$STORE" ENTWURF_META_MAILBOX_DIR="$MBX" "$REPO/scripts/meta-bridge-statusline.sh" | sed -n '2p'; }
828
+
829
+ # empty mailbox -> NO badge. Silence is the quiet common case; the badge is for mail.
830
+ B_EMPTY="$(badge_line "$STATUS_INPUT_MATCH")"
831
+ if ! printf '%s' "$B_EMPTY" | grep -q '✉'; then ok "badge: empty mailbox draws no envelope"; else bad "badge: empty mailbox drew one: $B_EMPTY"; fi
832
+
833
+ # The counted set is EXACTLY readMetaInbox's unread union: *.msg + *.msg.delivered.
834
+ # The three decoys below must all be excluded: a .read (already drained — a badge that
835
+ # counted it would never clear), the signal file, and state.json.
836
+ : > "$MBX/20260606T000000-aaaaaa/one.msg"
837
+ : > "$MBX/20260606T000000-aaaaaa/two.msg.delivered"
838
+ : > "$MBX/20260606T000000-aaaaaa/three.msg.delivered.read"
839
+ : > "$MBX/20260606T000000-aaaaaa/inbox.signal"
840
+ : > "$MBX/20260606T000000-aaaaaa/state.json"
841
+ B_TWO="$(badge_line "$STATUS_INPUT_MATCH")"
842
+ if printf '%s' "$B_TWO" | grep -q '✉2'; then ok "badge: .msg + .msg.delivered counted, .read/signal/state excluded (✉2)"; else bad "badge: expected ✉2, got: $B_TWO"; fi
843
+
844
+ # The drain clears it on the NEXT render — the property acceptance criterion 4 names.
845
+ mv "$MBX/20260606T000000-aaaaaa/one.msg" "$MBX/20260606T000000-aaaaaa/one.msg.read"
846
+ mv "$MBX/20260606T000000-aaaaaa/two.msg.delivered" "$MBX/20260606T000000-aaaaaa/two.msg.delivered.read"
847
+ B_DRAINED="$(badge_line "$STATUS_INPUT_MATCH")"
848
+ if ! printf '%s' "$B_DRAINED" | grep -q '✉'; then ok "badge: clears after the read archives both bodies to .read"; else bad "badge: stayed after drain: $B_DRAINED"; fi
849
+
850
+ # Could-not-count #1: no garden id resolved, so there is no mailbox to look in. The
851
+ # statusline must NOT quietly claim zero for a session it could not place.
852
+ B_NOGARDEN="$(badge_line "$STATUS_INPUT_MISS")"
853
+ if printf '%s' "$B_NOGARDEN" | grep -q '✉?'; then ok "badge: unresolved garden id renders ✉? (never a false zero)"; else bad "badge: unresolved garden id should be ✉?, got: $B_NOGARDEN"; fi
854
+
855
+ # Could-not-count #2: the mailbox dir exists but cannot be read. Same rule.
856
+ chmod 000 "$MBX/20260606T000000-aaaaaa"
857
+ B_UNREADABLE="$(badge_line "$STATUS_INPUT_MATCH")"
858
+ chmod 755 "$MBX/20260606T000000-aaaaaa"
859
+ if printf '%s' "$B_UNREADABLE" | grep -q '✉?'; then ok "badge: unreadable mailbox renders ✉? (measurement failure, not zero)"; else bad "badge: unreadable mailbox should be ✉?, got: $B_UNREADABLE"; fi
860
+
861
+ # Could-not-count #3: no python3 at all. That degrade path predates the badge and used
862
+ # to print a bare `🪛 ? cc`; without the ✉? it would read as "counted, none".
863
+ # The stand-in PATH carries `cat` and nothing else: the script reads its stdin with `cat`
864
+ # BEFORE it tests for python3, so a truly empty PATH would kill it under `set -e` and the
865
+ # cell would pass for the wrong reason.
866
+ mkdir -p "$TMP/emptybin"
867
+ ln -sf "$(command -v cat)" "$TMP/emptybin/cat"
868
+ if [ -n "$(command -v python3 2>/dev/null)" ] && PATH="$TMP/emptybin" command -v python3 >/dev/null 2>&1; then bad "no-python3 fixture is not actually python3-less"; else ok "no-python3 fixture really hides python3"; fi
869
+ # `$BASH` (an absolute path) rather than the name `bash`, so the interpreter is reachable
870
+ # without putting a second binary on the stand-in PATH the fixture is trying to empty.
871
+ B_NOPY="$(printf '%s' "$STATUS_INPUT_MATCH" | PATH="$TMP/emptybin" ENTWURF_META_SESSIONS_DIR="$STORE" ENTWURF_META_MAILBOX_DIR="$MBX" "$BASH" "$REPO/scripts/meta-bridge-statusline.sh" | sed -n '2p')"
872
+ if printf '%s' "$B_NOPY" | grep -q '✉?'; then ok "badge: python3-less degrade line still says ✉? (not a silent zero)"; else bad "badge: python3-less degrade should carry ✉?, got: $B_NOPY"; fi
873
+
874
+ # A garden id that never renders a real mailbox: the pre-record "ready" state. Nothing
875
+ # has been addressed to this session yet, so an empty badge is TRUE, not a guess.
876
+ B_READY="$(badge_line "$STATUS_INPUT_READY")"
877
+ if ! printf '%s' "$B_READY" | grep -q '✉'; then ok "badge: pre-record 'ready' draws no envelope (nothing addressed yet)"; else bad "badge: ready state drew an envelope: $B_READY"; fi
878
+ rm -rf "$MBX"
879
+
721
880
  if node --experimental-strip-types "$STORE_DOCTOR" "$STORE" >/dev/null; then ok "store doctor accepts valid records"; else bad "store doctor rejected valid records"; fi
722
881
  cp "$STORE/20260606T000000-aaaaaa.meta.json" "$STORE/20260606T000002-cccccc.meta.json"
723
882
  if node --experimental-strip-types "$STORE_DOCTOR" "$STORE" >/dev/null 2>&1; then bad "store doctor missed body/filename drift + duplicate"; else ok "store doctor fails on body/filename drift and duplicate nativeSessionId"; fi
@@ -31,7 +31,10 @@ d=json.load(sys.stdin)
31
31
  s=d['settings']
32
32
  assert 'statusLine' in s['map-entry'], 'statusLine missing from SSOT map-entry'
33
33
  assert 'permissions.allow' in s['array-items'] and 'permissions.deny' in s['array-items'], 'permissions missing'
34
- assert 'verbose' in s['scalar'] and 'autoCompactEnabled' in s['scalar'], 'scalar policy missing'
34
+ assert 'verbose' in s['scalar'] and 'awaySummaryEnabled' in s['scalar'], 'scalar policy missing'
35
+ # #94: the compaction keys are RETIRED, so managed-keys must NOT claim them.
36
+ assert 'autoCompactEnabled' not in s['scalar'], 'retired compaction scalar still claimed as managed'
37
+ assert 'env.DISABLE_AUTOCOMPACT' not in s['scalar'], 'retired compaction env scalar still claimed as managed'
35
38
  assert any(k.startswith('enabledPlugins.') for k in s['map-entry']), 'plugin ref missing'
36
39
  assert d['claudeRoot']['map-entry'] == ['mcpServers.entwurf-bridge'], 'MCP key missing'
37
40
  " && ok "managed-keys SSOT is valid JSON with the expected owned keys" \