kijito-tools 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kijito-tools",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Installer for kijito-tools: copies the context-tracking, session catch-up, and self-clear scripts plus the Kijito skills into ~/.claude.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",
@@ -35,7 +35,7 @@
35
35
  "kijito"
36
36
  ],
37
37
  "scripts": {
38
- "prepublishOnly": "npm pack --dry-run",
38
+ "prepublishOnly": "bash tests/vendored_monitor_test.sh && npm pack --dry-run",
39
39
  "test:n0-subject": "node providers/codex/test/n0-subject-boundary.mjs && node --test providers/codex/test/n0-subject-boundary.test.mjs",
40
40
  "test:n0": "node --test providers/codex/test/n0-harness.test.mjs && echo N0_HARNESS_BASELINE_GREEN",
41
41
  "test:n0-cli": "node --test providers/codex/test/n0-cli-refusal.test.mjs && echo N0_CLI_REFUSAL_GREEN",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "artifacts": {
16
16
  "wakeCoreSha256": "5f7b57f71fd7ae96b303195c3671af0d410668b95f6a397c97cbec275992e412",
17
- "workflowSha256": "e5f82d7aa18d3b652466d53ef89642d4dedb3ae05b0e13cea0e3d1882cb29496",
17
+ "workflowSha256": "e411fddb3bd37d4fcf364917572f3c9d132223aa9e1dc0d522efbcae0c8fa73e",
18
18
  "planSha256": "d9d666b86ac06414f6e6a13a3e3e2115983a1dad44fcfded7eccc51b191fb451",
19
19
  "wakeHelperSha256": "838f6b4462ba037a23a14b6fdb8dc34ee8b8f4a7df258b428832bc092fac3ea4",
20
20
  "wsUdsSha256": "4da13eef89b572fb735e756cb06846a021919dec7f91c6d07989377944cc776c",
@@ -3,6 +3,44 @@
3
3
  All notable changes to kijito-inbox-monitor are documented in this file.
4
4
  The format is based on Keep a Changelog, and this project follows Semantic Versioning.
5
5
 
6
+ ## [0.5.3] - 2026-09-24
7
+
8
+ ### Changed
9
+ - **Both shipped service templates run opaque (`--no-content`).** A producer deployed from
10
+ `com.kijito.inbox-monitor.plist.template` or `kijito-inbox-monitor@.service.template` now writes
11
+ event rows that carry a message's identity, sender and timestamps but never its body. An event file
12
+ is a plain local file that other tools and agents read; message text does not belong in it by
13
+ default. To keep a bounded content preview, remove `--no-content` from your unit (or pass
14
+ `--content-chars N`). `OpaqueOutputEnforcementTest` pins both templates and the flag's behaviour.
15
+ (Previously enforced only in the copy vendored by kijito-tools; upstreamed so the two are identical.)
16
+
17
+ ## [0.5.2] - 2026-09-24
18
+
19
+ ### Added
20
+ - **`--safe-persona PERSONA`** prints the filename component the producer derives from a persona name
21
+ (casefold, any Unicode alphanumeric kept) and exits. It is the one published persona->filename rule,
22
+ so the SessionStart hook, the inbox self-test and health tools ask the producer instead of
23
+ re-implementing it (three copies had drifted).
24
+
25
+ ### Fixed
26
+ - **The persona `reserved` flag is honoured.** `/api/personas` marks the legacy row named after the
27
+ hive-wide broadcast name (`all`) as `reserved`: nobody works as it. The watcher now treats that row
28
+ exactly like a declared-`retired` one for alarm purposes: its urgent mail no longer fires the loud
29
+ "urgent-unanswered" alert (it is named once on the quiet stderr channel instead, and stays readable
30
+ and counted), and a never-read reserved inbox is classified as clearable debris. A server that does
31
+ not send the field changes nothing.
32
+
33
+ ## [0.5.1] - 2026-09-24
34
+
35
+ ### Fixed
36
+ - **An empty first window no longer swallows message id 0.** On a first launch with nothing in the inbox
37
+ the watcher baselined its cursor to `0` (`max(ids, default=0)`), and every emission test is
38
+ `id > cursor`, so the very first hive message on a new account (the server numbers it `0`) could
39
+ never be emitted. The producer only logged a quiet `dormant inbox (1 unread)` notice and the agent
40
+ never woke. An empty first window now baselines to `-1`, the value the fail-closed corrupt-state
41
+ branch already produced for an empty window; a non-empty first window still baselines to its newest
42
+ id. Two regression tests cover both cases (`EmptyFirstWindowBaselineTest`).
43
+
6
44
  ## [0.5.0] - 2026-08-15
7
45
 
8
46
  ### Fixed
@@ -66,3 +66,23 @@ Old-source transition (kijito-inbox-monitor stays the publish/pin home until fle
66
66
  P0-C7 terminal), `_shared` doorbell + authenticated consumer lease (§4.4), and F29/A29 certify+enforce
67
67
  of opaque `--no-content` output in both service templates plus the live Mac-producer gap (§4.6) are
68
68
  tracked separately and not claimed here.
69
+
70
+ ## Re-import 2026-09-24 (row M314) — the vendored copy is now verified, not asserted
71
+
72
+ By 2026-09-24 this copy had drifted both ways from upstream: upstream had fixes it lacked (M289, the
73
+ empty-first-window baseline), and it carried two changes upstream lacked (`--safe-persona`, row M290,
74
+ and the opaque-output rule in `OPAQUE-OUTPUT-ENFORCEMENT.md`). Both local changes were first sent
75
+ upstream verbatim (kijito-inbox-monitor #4 → v0.5.2, #5 → v0.5.3), so a byte-exact re-import reverts
76
+ nothing.
77
+
78
+ | role | SHA | tree |
79
+ |---|---|---|
80
+ | imported (tag `v0.5.3`) | `803c53da623fa2d9bd97f5a9e75f01a78571ea2e` | `0c8d80201933589363305f9e2753e7d717f348d8` |
81
+
82
+ From here on **never hand-edit `providers/monitor`**:
83
+ - `providers/monitor/UPSTREAM` records the upstream sha, its git tree id and the full file listing.
84
+ - `tests/vendored_monitor_test.sh` recomputes the git tree id of this directory from the files on disk
85
+ (excluding `UPSTREAM`, this file and `OPAQUE-OUTPUT-ENFORCEMENT.md`, which belong to kijito-tools) and
86
+ fails on any difference. It runs in the test workflow, in both publish workflows before the package is
87
+ built, and in npm `prepublishOnly`, so a drifted copy cannot be published.
88
+ - To move to a new upstream release: `scripts/import-monitor.sh <sha-or-tag>`.
@@ -1,5 +1,9 @@
1
1
  # Opaque output enforcement (P0-F29/A29)
2
2
 
3
+ > **Upstreamed 2026-09-24 (row M314):** both template edits and `OpaqueOutputEnforcementTest` now live in
4
+ > `KijitoAI/kijito-inbox-monitor` itself (v0.5.3), and this directory is a byte-exact copy of that release.
5
+ > The record below is kept as the history of why the rule exists.
6
+
3
7
  **Steward:** argus · **Date:** 2026-08-17 · **Row:** `P0-F29`/`A29`, anchor `monitor-foundation-v1`
4
8
  (clause: *opaque output*). This is the **certify + enforce** half of the row — the `--no-content`
5
9
  capability already exists in shipped code, so nothing is built here; it is made non-optional in the
@@ -0,0 +1,25 @@
1
+ # The upstream release providers/monitor is a byte-exact copy of (row M314).
2
+ # Written by scripts/import-monitor.sh; verified by tests/vendored_monitor_test.sh. Never hand-edit.
3
+ repo=KijitoAI/kijito-inbox-monitor
4
+ sha=803c53da623fa2d9bd97f5a9e75f01a78571ea2e
5
+ tree=0c8d80201933589363305f9e2753e7d717f348d8
6
+ tag=v0.5.3
7
+ file=100644 8d88ccb0fd09b9144505f985b4f882db278fac1f .github/workflows/publish-npm.yml
8
+ file=100644 19ce3471a2cffa689be9d261049c1e74e3e7a25e .github/workflows/publish-pypi.yml
9
+ file=100644 4534290a5dd36d18d07a782b784b6b0ad5ee38fc .gitignore
10
+ file=100644 5f0a605641efd19a9afa8d0cb0d8bed3f908e232 CHANGELOG.md
11
+ file=100644 d645695673349e3947e8e5ae42332d0ac3164cd7 LICENSE
12
+ file=100644 f6b0e5634f3fd4156c1c000efb31578bfb068cf3 NOTICE
13
+ file=100644 48038bef573e123fe4657be8a5fdb3e0293b192a README.md
14
+ file=100644 72f0c148f1e721ac1bb4a38e5cae690355774314 RELEASING.md
15
+ file=100755 fca1203267cb726736b0c8f239e2d74c40a7a895 arm-hive-monitor.sh
16
+ file=100755 bbe3f07de13dab8a8a7a4798481156ae4a46ab8f bin/cli.js
17
+ file=100644 23e088613c314bf86b384640639b545de60ea9d0 com.kijito.inbox-monitor.plist.template
18
+ file=100644 0ec3f23c29bb26d2890b9083d1014840a48cd2c3 docs/DESIGN.md
19
+ file=100644 e6d72708b425b6dfaf02e126aaac7633d3584847 kijito-inbox-monitor@.service.template
20
+ file=100644 9a1114ef2ab83cbe3b30bfa50833f3e280e2d91d kijito_inbox_monitor.py
21
+ file=100644 a20200e3aeef01e7a19ce8833f1eea743b53949c package.json
22
+ file=100644 1af00c40f8351d0a12b8563e7c7e865c6be0299d pyproject.toml
23
+ file=100755 22acef6d6875395a81c701eb535030d0a78b0df1 scripts/mutation-check.py
24
+ file=100755 acba6196213736922fc4f7135749386d85d172a6 scripts/prepublish-gate.sh
25
+ file=100644 d54d220ab8b228e557b64cc1497509c46c70a5ee test_kijito_monitor.py
@@ -33,7 +33,7 @@ try:
33
33
  except ImportError: # pragma: no cover - Windows
34
34
  fcntl = None
35
35
 
36
- __version__ = "0.5.0"
36
+ __version__ = "0.5.3"
37
37
  SOURCE = "kijito-inbox"
38
38
  # A named User-Agent is REQUIRED: api.kijito.ai is fronted by a WAF that 403s the default Python-urllib UA.
39
39
  USER_AGENT = "kijito-inbox-monitor/%s" % __version__
@@ -332,6 +332,14 @@ _PERSONA_RETIRED = {}
332
332
  # so the producer can ship before the API populates the field with zero behaviour change. A boolean.
333
333
  _PERSONA_WRITE_ONLY = {}
334
334
 
335
+ # Declared `reserved` flag per persona from /api/personas (server row M315; consumed here for row M332).
336
+ # TRUE = a legacy row named after the hive-wide BROADCAST name ('all'): it can no longer be created, but
337
+ # an account that minted one before the guard existed still holds it, and it is an ordinary inbox that
338
+ # NOBODY works as. The server calls it debris and names the remedy (retire it). For alarm purposes it is
339
+ # therefore treated exactly like a declared-`retired` row: see _is_debris. Its mail is still READ and
340
+ # still counted - only the alarms change. Same tri-state discipline: absent/non-bool => undeclared.
341
+ _PERSONA_RESERVED = {}
342
+
335
343
 
336
344
  def _row_memory_count(row):
337
345
  """Memories owned by this persona, or None if the server did not say.
@@ -387,6 +395,25 @@ def _row_write_only(row):
387
395
  return w if isinstance(w, bool) else None
388
396
 
389
397
 
398
+ def _row_reserved(row):
399
+ """The persona's declared `reserved` flag as a strict bool, or None if the server did not report it.
400
+
401
+ Same tri-state discipline as _row_retired: only a genuine bool is a declaration. A server that
402
+ predates the field reports nothing, and the producer then behaves exactly as it did before (row M332
403
+ ships ahead of, or behind, the server with zero behaviour change).
404
+ """
405
+ r = row.get("reserved")
406
+ return r if isinstance(r, bool) else None
407
+
408
+
409
+ def _is_debris(persona):
410
+ """True when the directory DECLARES that nobody works as this persona: `retired`, or `reserved`
411
+ (the legacy broadcast-name row, row M332). A positive declaration only (`is True`); an undeclared
412
+ flag is never debris. Debris is classified as clearable in the stranded tier and never fires the
413
+ urgent-unanswered alarm - there is no member there to be unresponsive."""
414
+ return _PERSONA_RETIRED.get(persona) is True or _PERSONA_RESERVED.get(persona) is True
415
+
416
+
390
417
  def fetch_personas(opener, headers):
391
418
  """Fetch the account persona directory for default/explicit all-persona mode."""
392
419
  req = urllib.request.Request(PERSONAS_URL, headers=headers, method="GET")
@@ -410,6 +437,7 @@ def fetch_personas(opener, headers):
410
437
  _PERSONA_READ_COUNTS[row["persona"]] = _row_read_count(row)
411
438
  _PERSONA_RETIRED[row["persona"]] = _row_retired(row)
412
439
  _PERSONA_WRITE_ONLY[row["persona"]] = _row_write_only(row)
440
+ _PERSONA_RESERVED[row["persona"]] = _row_reserved(row)
413
441
  if not personas:
414
442
  raise FatalConfig("/api/personas returned no personas")
415
443
  return personas
@@ -2341,7 +2369,14 @@ class WatchTarget:
2341
2369
  "reason": "state file present but unusable; re-emitting the "
2342
2370
  "visible window instead of baselining over it"})
2343
2371
  elif self.cursor is None:
2344
- self.cursor = max((m["id"] for m in items), default=0)
2372
+ # An EMPTY first window baselines BELOW id 0, not AT it. Message ids start at 0
2373
+ # on a new account, and every emission test is `id > cursor`, so a cursor of 0
2374
+ # on an empty inbox swallowed the account's very first message forever - the
2375
+ # producer only ever logged it as a quiet "dormant inbox (1 unread)". -1 is the
2376
+ # value the fail-closed branch above already produces for an empty window, so it
2377
+ # is a value every later comparison already tolerates. A NON-empty first window
2378
+ # still baselines to its newest id (never flood a new agent with history).
2379
+ self.cursor = max((m["id"] for m in items), default=-1)
2345
2380
  # ⛔ AN ABSENT STATE FILE MEANS TWO THINGS THAT DEMAND OPPOSITE BEHAVIOUR, AND
2346
2381
  # NOTHING HERE CAN TELL THEM APART. A genuine first launch must baseline - never
2347
2382
  # flood a new agent with inbox history. A LOST state file must not: everything
@@ -3043,6 +3078,12 @@ _REPORTED_URGENT_QUIET = set()
3043
3078
  # Same once-per-member, EXACT-keyed discipline as _REPORTED_STRANDED / _REPORTED_DORMANT.
3044
3079
  _REPORTED_URGENT_WO = set()
3045
3080
 
3081
+ # DEBRIS rows (declared retired, or the reserved broadcast-name row - row M332) holding urgent unread.
3082
+ # Surfaced QUIETLY (a stderr NOTICE) once per member, never the loud alarm: "nobody is answering
3083
+ # escalated mail" presumes a member who could answer, and debris by declaration has none. Its own set so
3084
+ # no tier can gag another; re-armed by intersection_update like the others.
3085
+ _REPORTED_URGENT_DEBRIS = set()
3086
+
3046
3087
 
3047
3088
  def urgent_unanswered(directory):
3048
3089
  """Directory personas holding SENDER-ESCALATED mail while showing no observed activity (§5.5).
@@ -3106,10 +3147,25 @@ def report_urgent_unanswered(directory, targets, emitter):
3106
3147
  # Partition by the DECLARED write_only fact, exactly as _partition_stranded does. `is True` is strict:
3107
3148
  # an undeclared or False flag leaves the member in the LOUD tier unchanged (graceful degradation, the
3108
3149
  # same tri-state _row_write_only guarantees).
3150
+ # DEBRIS FIRST (row M332): a declared-retired row or the reserved broadcast-name row has no member to
3151
+ # be unresponsive, so it leaves the loud tier whatever its other flags say; named quietly below.
3152
+ debris_quiet = [(p, n) for p, n in current if _is_debris(p)]
3153
+ current = [(p, n) for p, n in current if not _is_debris(p)]
3109
3154
  wo_quiet = [(p, n) for p, n in current if _PERSONA_WRITE_ONLY.get(p) is True]
3110
3155
  alerting = [(p, n) for p, n in current if _PERSONA_WRITE_ONLY.get(p) is not True]
3111
3156
  _REPORTED_URGENT_QUIET.intersection_update({p for p, _ in alerting}) # release: leaving re-arms alarm
3112
3157
  _REPORTED_URGENT_WO.intersection_update({p for p, _ in wo_quiet})
3158
+ _REPORTED_URGENT_DEBRIS.intersection_update({p for p, _ in debris_quiet})
3159
+ for persona, n in debris_quiet:
3160
+ if persona in _REPORTED_URGENT_DEBRIS:
3161
+ continue
3162
+ _REPORTED_URGENT_DEBRIS.add(persona)
3163
+ sys.stderr.write(
3164
+ "kijito-inbox-monitor: NOTICE urgent-unanswered on debris (quiet, not alarmed) - %s (%d urgent "
3165
+ "unread; declared %s, so nobody works as it - its mail is still readable; retire or clear it) "
3166
+ "(further notices for %r suppressed)\n"
3167
+ % (persona, n, "reserved (the broadcast name)" if _PERSONA_RESERVED.get(persona) is True
3168
+ else "retired", persona))
3113
3169
  fresh = [(p, n) for p, n in alerting if p not in _REPORTED_URGENT_QUIET]
3114
3170
  fresh_wo = [(p, n) for p, n in wo_quiet if p not in _REPORTED_URGENT_WO]
3115
3171
  # QUIET-BUT-NAMED tier: a stderr NOTICE is a non-waking channel (the event-stream grep filters
@@ -3242,8 +3298,8 @@ def _partition_stranded(directory, counts):
3242
3298
  if read > 0:
3243
3299
  continue # someone is consuming it - not stranded at all
3244
3300
  # read == 0: this inbox has never been consumed. Partition by the DECLARED retired flag.
3245
- if _PERSONA_RETIRED.get(p) is True:
3246
- loud.append(p) # declared clearable debris -> loud, exactly like today
3301
+ if _is_debris(p):
3302
+ loud.append(p) # declared clearable debris (retired or reserved) -> loud
3247
3303
  else:
3248
3304
  dormant.append(p) # real-but-dormant -> quiet, must NOT ride the loud alarm
3249
3305
  return loud, dormant
@@ -3272,9 +3328,11 @@ def _stranded_detail(persona, directory, counts):
3272
3328
  if twin is not None:
3273
3329
  return "%s (%s unread; case-variant of known persona %r)" % (persona, counts.get(persona), twin)
3274
3330
  in_dir = persona in set(directory)
3275
- if in_dir and _PERSONA_READ_COUNTS.get(persona) == 0 and _PERSONA_RETIRED.get(persona) is True:
3276
- return ("%s (%s unread; never consumed (read 0) and declared retired, so it is clearable debris)"
3277
- % (persona, counts.get(persona)))
3331
+ if in_dir and _PERSONA_READ_COUNTS.get(persona) == 0 and _is_debris(persona):
3332
+ return ("%s (%s unread; never consumed (read 0) and declared %s, so it is clearable debris)"
3333
+ % (persona, counts.get(persona),
3334
+ "reserved (the broadcast name, not an identity)" if _PERSONA_RESERVED.get(persona) is True
3335
+ else "retired"))
3278
3336
  if in_dir and _PERSONA_MEMORY_COUNTS.get(persona) == 0:
3279
3337
  return "%s (%s unread; registered as a recipient but owns no memories, so nobody works as it)" % (
3280
3338
  persona, counts.get(persona))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kijito-inbox-monitor",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "description": "Pointer to the Python package kijito-inbox-monitor. A zero-dependency watcher that wakes a running agent when new Kijito inbox mail arrives. Install the real tool with pipx/uv/pip.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "kijito-inbox-monitor"
10
- version = "0.5.0"
10
+ version = "0.5.3"
11
11
  description = "Zero-dependency watcher that wakes a running agent when new Kijito inbox mail arrives."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.9"
@@ -757,7 +757,10 @@ class UrgentUnansweredAlarmTest(unittest.TestCase):
757
757
  km._INBOX_FLOORS.clear()
758
758
  km._REPORTED_URGENT_QUIET.clear()
759
759
  km._REPORTED_URGENT_WO.clear()
760
+ km._REPORTED_URGENT_DEBRIS.clear()
760
761
  km._PERSONA_WRITE_ONLY.clear()
762
+ km._PERSONA_RETIRED.clear()
763
+ km._PERSONA_RESERVED.clear()
761
764
  km._OBSERVED_SINCE = "2026-07-25T07:00:00+00:00"
762
765
 
763
766
  def tearDown(self):
@@ -920,6 +923,55 @@ class UrgentUnansweredAlarmTest(unittest.TestCase):
920
923
  self.assertNotIn("jason", alerts[0]["urgent_unanswered"])
921
924
  self.assertIn("jason", err) # also on the quiet channel
922
925
 
926
+ # ── row M332: DEBRIS (the reserved broadcast-name row, or a retired row) never fires the loud alarm ──
927
+ # "Nobody is answering escalated mail" presumes a member who could answer. A reserved row (the legacy
928
+ # 'all' inbox the server marks `reserved`) or a retired one has none by declaration, so its urgent mail
929
+ # is NAMED on the quiet channel - still readable, still counted - and never wakes anyone.
930
+ def test_reserved_row_holding_urgent_mail_does_NOT_fire_the_loud_alarm(self):
931
+ km._URGENT_UNREAD.update({"all": 2})
932
+ km._PERSONA_RESERVED.update({"all": True})
933
+ self._observe([{"id": 100, "from": "river", "created": "t"}])
934
+ fresh, alerts, err = self._run_cap(directory=("argus", "all"))
935
+ self.assertEqual(fresh, [])
936
+ self.assertEqual(alerts, [])
937
+ self.assertIn("all", err) # quiet, not invisible
938
+ self.assertIn("reserved", err)
939
+ self.assertIn("still readable", err)
940
+ self.assertEqual(km._URGENT_UNREAD["all"], 2) # its mail is still counted
941
+
942
+ def test_reserved_is_treated_like_retired(self):
943
+ # The DONE-WHEN's own comparison, asserted directly: the two declarations behave the same.
944
+ for flag in (km._PERSONA_RESERVED, km._PERSONA_RETIRED):
945
+ with self.subTest(flag="reserved" if flag is km._PERSONA_RESERVED else "retired"):
946
+ self.setUp()
947
+ km._URGENT_UNREAD.update({"ghost": 1, "loom": 1})
948
+ flag.update({"ghost": True})
949
+ self._observe([{"id": 100, "from": "river", "created": "t"}])
950
+ fresh, alerts, err = self._run_cap(directory=("argus", "ghost", "loom"))
951
+ self.assertEqual(fresh, ["loom"]) # the real member still alarms
952
+ self.assertEqual(alerts[0]["urgent_unanswered"], ["loom"])
953
+ self.assertIn("ghost", err)
954
+
955
+ def test_undeclared_or_false_reserved_still_fires_loud(self):
956
+ # Only a POSITIVE declaration quiets: a flag explicitly False, or absent (an older server), leaves
957
+ # the member on the loud alarm exactly as before.
958
+ km._URGENT_UNREAD.update({"loom": 1, "quill": 3})
959
+ km._PERSONA_RESERVED.update({"loom": False}) # quill: no entry at all
960
+ self._observe([{"id": 100, "from": "river", "created": "t"}])
961
+ fresh, _alerts, _err = self._run_cap(directory=("argus", "loom", "quill"))
962
+ self.assertEqual(sorted(fresh), ["loom", "quill"])
963
+
964
+ def test_debris_quiet_notice_is_once_then_re_arms(self):
965
+ km._URGENT_UNREAD.update({"all": 1})
966
+ km._PERSONA_RESERVED.update({"all": True})
967
+ self._observe([{"id": 100, "from": "river", "created": "t"}])
968
+ self.assertIn("all", self._run_cap(directory=("argus", "all"))[2])
969
+ self.assertEqual(self._run_cap(directory=("argus", "all"))[2], "") # suppressed while it holds
970
+ km._URGENT_UNREAD.update({"all": 0})
971
+ self._run_cap(directory=("argus", "all")) # condition clears
972
+ km._URGENT_UNREAD.update({"all": 1})
973
+ self.assertIn("all", self._run_cap(directory=("argus", "all"))[2]) # recurrence announced again
974
+
923
975
  def test_undeclared_or_false_write_only_still_fires_loud(self):
924
976
  # GRACEFUL DEGRADATION + the mutation discriminator: only `is True` quiets. A flag explicitly
925
977
  # False, or absent entirely, still rides the loud alarm exactly as before - and the informational
@@ -2289,9 +2341,11 @@ class M167ReadCountPartitionTest(unittest.TestCase):
2289
2341
  self._read = dict(km._PERSONA_READ_COUNTS)
2290
2342
  self._ret = dict(km._PERSONA_RETIRED)
2291
2343
  self._wo = dict(km._PERSONA_WRITE_ONLY)
2344
+ self._rsv = dict(km._PERSONA_RESERVED)
2292
2345
  self._rs = set(km._REPORTED_STRANDED)
2293
2346
  self._rd = set(km._REPORTED_DORMANT)
2294
- for d in (km._PERSONA_MEMORY_COUNTS, km._PERSONA_READ_COUNTS, km._PERSONA_RETIRED, km._PERSONA_WRITE_ONLY):
2347
+ for d in (km._PERSONA_MEMORY_COUNTS, km._PERSONA_READ_COUNTS, km._PERSONA_RETIRED, km._PERSONA_WRITE_ONLY,
2348
+ km._PERSONA_RESERVED):
2295
2349
  d.clear()
2296
2350
  km._REPORTED_STRANDED.clear()
2297
2351
  km._REPORTED_DORMANT.clear()
@@ -2300,7 +2354,8 @@ class M167ReadCountPartitionTest(unittest.TestCase):
2300
2354
  for d, saved in ((km._PERSONA_MEMORY_COUNTS, self._mem),
2301
2355
  (km._PERSONA_READ_COUNTS, self._read),
2302
2356
  (km._PERSONA_RETIRED, self._ret),
2303
- (km._PERSONA_WRITE_ONLY, self._wo)):
2357
+ (km._PERSONA_WRITE_ONLY, self._wo),
2358
+ (km._PERSONA_RESERVED, self._rsv)):
2304
2359
  d.clear()
2305
2360
  d.update(saved)
2306
2361
  km._REPORTED_STRANDED.clear()
@@ -2415,6 +2470,27 @@ class M167ReadCountPartitionTest(unittest.TestCase):
2415
2470
  self.assertNotIn("dormant_inboxes", events[0]) # no dormant this tick
2416
2471
  self.assertIn("clearable debris", err)
2417
2472
 
2473
+ # --- row M332: the reserved broadcast-name row is classified exactly like a retired one ------------
2474
+ def test_reserved_row_read0_is_classified_like_retired_debris(self):
2475
+ km._PERSONA_MEMORY_COUNTS.update({"all": 0, "argus": 40})
2476
+ km._PERSONA_READ_COUNTS.update({"all": 0, "argus": 3})
2477
+ km._PERSONA_RESERVED.update({"all": True}) # NOT retired - reserved alone
2478
+ directory = ["all", "argus"]
2479
+ self.assertEqual(km.stranded_inboxes(directory, {"all": 1}), ["all"])
2480
+ self.assertEqual(km.dormant_inboxes(directory, {"all": 1}), [])
2481
+ fresh, events, err = self._report(directory, {"all": 1}, watchers=("argus",))
2482
+ self.assertEqual(fresh, ["all"])
2483
+ self.assertIn("reserved (the broadcast name, not an identity)", err)
2484
+ self.assertIn("clearable debris", err)
2485
+
2486
+ def test_a_reserved_False_row_is_not_debris(self):
2487
+ km._PERSONA_MEMORY_COUNTS.update({"quietone": 3, "argus": 40})
2488
+ km._PERSONA_READ_COUNTS.update({"quietone": 0, "argus": 3})
2489
+ km._PERSONA_RESERVED.update({"quietone": False})
2490
+ directory = ["quietone", "argus"]
2491
+ self.assertEqual(km.stranded_inboxes(directory, {"quietone": 1}), [])
2492
+ self.assertEqual(km.dormant_inboxes(directory, {"quietone": 1}), ["quietone"])
2493
+
2418
2494
  # --- case 2: omniview reads nothing but is NOT retired -> DORMANT/quiet, never loud -----------------
2419
2495
  def test_omniview_read0_not_retired_is_QUIET_dormant_not_loud(self):
2420
2496
  km._PERSONA_MEMORY_COUNTS.update({"omniview": 149, "argus": 40})
@@ -4435,6 +4511,40 @@ class Loom7CorruptionPinReleaseTest(unittest.TestCase):
4435
4511
  self.assertEqual(t.cursor, 200)
4436
4512
 
4437
4513
 
4514
+ class EmptyFirstWindowBaselineTest(unittest.TestCase):
4515
+ """A brand-new account's first hive message carries id 0. An EMPTY first window used to baseline the
4516
+ cursor to 0 (`max(..., default=0)`), and every emission test is `id > cursor`, so that message could
4517
+ never be emitted: the producer logged a quiet "dormant inbox (1 unread)" and the agent never woke.
4518
+ Measured 2026-09-18 on a fresh account with one persona and one launch of the producer."""
4519
+
4520
+ E2E = BoundedWindowEndToEndTest
4521
+
4522
+ def _fresh(self, em):
4523
+ t = self.E2E()._target(cursor=None, emitter=em)
4524
+ t.armed = False # a first launch: nothing persisted, not yet armed
4525
+ return t
4526
+
4527
+ def test_an_EMPTY_first_window_baselines_BELOW_id_zero_so_message_0_is_emitted(self):
4528
+ em = self.E2E.RecordingEmitter()
4529
+ t = self._fresh(em)
4530
+ self.E2E()._run(t, self.E2E()._fetch([], 0))
4531
+ self.assertEqual(t.cursor, -1, "an empty inbox has delivered nothing, so the watermark sits below 0")
4532
+ self.assertEqual(em.new_ids, [])
4533
+ self.E2E()._run(t, self.E2E()._fetch([{"id": 0}], 0))
4534
+ self.assertEqual(em.new_ids, [0], "the account's first message must wake the agent")
4535
+ self.assertEqual(t.cursor, 0)
4536
+
4537
+ def test_a_NON_EMPTY_first_window_still_baselines_to_its_newest_id(self):
4538
+ # The control: a genuine first launch onto an inbox WITH history must not flood the agent.
4539
+ em = self.E2E.RecordingEmitter()
4540
+ t = self._fresh(em)
4541
+ self.E2E()._run(t, self.E2E()._fetch([{"id": 0}, {"id": 5}], 0))
4542
+ self.assertEqual(t.cursor, 5)
4543
+ self.assertEqual(em.new_ids, [])
4544
+ self.E2E()._run(t, self.E2E()._fetch([{"id": 0}, {"id": 5}, {"id": 6}], 0))
4545
+ self.assertEqual(em.new_ids, [6])
4546
+
4547
+
4438
4548
  class Loom7StateFileHygieneTest(unittest.TestCase):
4439
4549
  """Loom re-audit 7, item 7. The lock fd was never closed - two ResourceWarnings, and a real leak."""
4440
4550
 
@@ -5579,6 +5689,139 @@ class WakeClassPhase1Test(unittest.TestCase):
5579
5689
  self.assertEqual(bool(old_lenient.search(line)), should_match)
5580
5690
  # and the field itself is the bare kind, not decorated
5581
5691
  self.assertEqual(self._emit("armed")["event"], "armed")
5692
+ class SharedPersonaFilenameRuleTest(unittest.TestCase):
5693
+ """`--safe-persona` is the ONE place the persona->filename rule is published (row M290).
5694
+
5695
+ Three programs had each re-implemented it and drifted: this producer (correct), kijito-tools'
5696
+ SessionStart hook (`sed 's/[^A-Za-z0-9._-]/_/g'` - no casefold, ASCII-only) and producer-health.sh
5697
+ (no sanitising at all). A persona whose name merely contains a CAPITAL LETTER therefore got a path
5698
+ from the hook that the producer never writes, the hook reported "your mail is not being collected",
5699
+ and a Monitor armed on that path waited forever in silence.
5700
+
5701
+ ⚠️ THE REASON IT SURVIVED REVIEW, AND WHY THESE ASSERTIONS ARE SHAPED AS THEY ARE: on macOS the
5702
+ filesystem is case-INSENSITIVE, so the hook's `[ -e ... ]` probe SUCCEEDS on the producer's
5703
+ differently-cased file and everything looks fine. The defect is only observable on Linux. So the
5704
+ tests below pin the RULE ITSELF (casefold; Unicode alnum survives) rather than "the file was
5705
+ found", because the find-the-file question answers YES on the very platform most likely to be
5706
+ running the test.
5707
+ """
5708
+
5709
+ # spaces, parentheses, slashes, unicode and case - the set row M290's DONE-WHEN names, plus the
5710
+ # ordinary name as a control (a fixture of only exotic names cannot tell "correct" from "mangles
5711
+ # everything").
5712
+ NAMES = [
5713
+ "argus", "x", "dots.and_dash-ok",
5714
+ "spaced name", "name (purpose)", "a/b", "tab\tname",
5715
+ "Loom", "UPPER", "Claude-Chat",
5716
+ "café", "Ωmega",
5717
+ ]
5718
+
5719
+ def _run(self, argv):
5720
+ out, err = io.StringIO(), io.StringIO()
5721
+ real_out, real_err = sys.stdout, sys.stderr
5722
+ sys.stdout, sys.stderr = out, err
5723
+ try:
5724
+ rc = km.main(argv)
5725
+ finally:
5726
+ sys.stdout, sys.stderr = real_out, real_err
5727
+ return rc, out.getvalue(), err.getvalue()
5728
+
5729
+ def test_cli_answer_is_the_function_answer_for_every_name(self):
5730
+ # The CLI is not a second implementation to be kept in step by hand - it must BE the function.
5731
+ for name in self.NAMES:
5732
+ with self.subTest(name=name):
5733
+ rc, out, _ = self._run(["--safe-persona", name])
5734
+ self.assertEqual(rc, 0)
5735
+ self.assertEqual(out, km._state_safe_persona(name) + "\n")
5736
+
5737
+ def test_answer_is_one_bare_line_a_shell_can_capture(self):
5738
+ # `_safe=$(kijito-inbox-monitor --safe-persona "$p")` is the calling convention, so anything
5739
+ # else on stdout silently becomes part of a filename.
5740
+ rc, out, _ = self._run(["--safe-persona", "name (purpose)"])
5741
+ self.assertEqual(rc, 0)
5742
+ self.assertEqual(out.count("\n"), 1)
5743
+ self.assertEqual(out.strip(), "name__purpose_")
5744
+
5745
+ def test_needs_no_token_no_network_no_state_file(self):
5746
+ # The hook runs at session start with none of the producer's configuration in hand. If asking
5747
+ # for the rule required a token or a state file, every caller would go back to guessing - which
5748
+ # is the defect. Proven by clearing the token env rather than by reading the code.
5749
+ saved = os.environ.pop("KIJITOMON_TOKEN", None)
5750
+ try:
5751
+ rc, out, _ = self._run(["--safe-persona", "Loom"])
5752
+ finally:
5753
+ if saved is not None:
5754
+ os.environ["KIJITOMON_TOKEN"] = saved
5755
+ self.assertEqual(rc, 0)
5756
+ self.assertEqual(out.strip(), "loom")
5757
+
5758
+ def test_empty_persona_refuses_and_prints_no_component(self):
5759
+ # A blank answer would be interpolated into a path as an empty component, producing a plausible
5760
+ # file that nothing writes. Refuse by name instead (the tool's could-not-do-it code).
5761
+ rc, out, err = self._run(["--safe-persona", ""])
5762
+ self.assertEqual(rc, 2)
5763
+ self.assertEqual(out, "")
5764
+ self.assertIn("--safe-persona", err)
5765
+
5766
+ def test_rule_casefolds_and_keeps_unicode_alphanumerics(self):
5767
+ # ⛔ THE REGRESSION GUARD, pinned as the two PROPERTIES that actually diverged rather than as a
5768
+ # list of observed strings: replace the rule with a hand-written ASCII filter (the hook's old
5769
+ # `[^A-Za-z0-9._-]`) and both of these fail - the first because it would keep the capital, the
5770
+ # second because it would blank a perfectly good Unicode letter.
5771
+ self.assertEqual(km._state_safe_persona("Loom"), "loom")
5772
+ self.assertEqual(km._state_safe_persona("Ωmega"), "ωmega")
5773
+ # and the control: it still replaces what genuinely cannot be in a filename component.
5774
+ self.assertEqual(km._state_safe_persona("a/b"), "a_b")
5775
+
5776
+ def test_the_rule_is_idempotent(self):
5777
+ # Callers chain: the hook sanitises, a script re-sanitises the result. A rule that is not
5778
+ # idempotent turns that into a third distinct filename.
5779
+ for name in self.NAMES:
5780
+ with self.subTest(name=name):
5781
+ once = km._state_safe_persona(name)
5782
+ self.assertEqual(km._state_safe_persona(once), once)
5783
+
5784
+
5785
+ class ReservedFlagParseTest(unittest.TestCase):
5786
+ """Row M332: `reserved` is read from /api/personas with the same tri-state discipline as `retired`."""
5787
+
5788
+ def test_only_a_genuine_bool_is_a_declaration(self):
5789
+ self.assertIs(km._row_reserved({"reserved": True}), True)
5790
+ self.assertIs(km._row_reserved({"reserved": False}), False)
5791
+ for junk in ({}, {"reserved": None}, {"reserved": "true"}, {"reserved": 1}):
5792
+ with self.subTest(row=junk):
5793
+ self.assertIsNone(km._row_reserved(junk))
5794
+
5795
+ def test_fetch_personas_records_it(self):
5796
+ dicts = (km._PERSONA_RESERVED, km._PERSONA_RETIRED, km._PERSONA_WRITE_ONLY,
5797
+ km._PERSONA_MEMORY_COUNTS, km._PERSONA_READ_COUNTS)
5798
+ saved = [dict(d) for d in dicts]
5799
+ try:
5800
+ body = json.dumps({"result": [{"persona": "all", "reserved": True, "retired": False},
5801
+ {"persona": "argus", "reserved": False}]}).encode()
5802
+
5803
+ class Resp:
5804
+ status = 200
5805
+ def read(self):
5806
+ return body
5807
+ def __enter__(self):
5808
+ return self
5809
+ def __exit__(self, *a):
5810
+ return False
5811
+
5812
+ class Opener:
5813
+ def open(self, req, timeout=None):
5814
+ return Resp()
5815
+
5816
+ self.assertEqual(km.fetch_personas(Opener(), {}), ["all", "argus"])
5817
+ self.assertIs(km._PERSONA_RESERVED["all"], True)
5818
+ self.assertIs(km._PERSONA_RESERVED["argus"], False)
5819
+ self.assertTrue(km._is_debris("all"))
5820
+ self.assertFalse(km._is_debris("argus"))
5821
+ finally:
5822
+ for d, keep in zip(dicts, saved):
5823
+ d.clear()
5824
+ d.update(keep)
5582
5825
 
5583
5826
 
5584
5827
  class OpaqueOutputEnforcementTest(unittest.TestCase):