brainiac-cli 0.18.2__tar.gz → 0.19.0__tar.gz

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 (91) hide show
  1. {brainiac_cli-0.18.2/src/brainiac_cli.egg-info → brainiac_cli-0.19.0}/PKG-INFO +1 -1
  2. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/pyproject.toml +1 -1
  3. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/AGENTS.md +5 -1
  4. brainiac_cli-0.19.0/src/brain/_assets/overlay/template/cos/drafts.md +25 -0
  5. brainiac_cli-0.19.0/src/brain/_assets/overlay/template/cos/priorities.md +42 -0
  6. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/brain-brief.sh +8 -6
  7. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_version.py +1 -1
  8. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/classification.py +25 -0
  9. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/cli.py +24 -2
  10. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/cos.py +159 -3
  11. brainiac_cli-0.19.0/src/brain/cos_chips.py +361 -0
  12. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/index.py +50 -1
  13. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0/src/brainiac_cli.egg-info}/PKG-INFO +1 -1
  14. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brainiac_cli.egg-info/SOURCES.txt +2 -0
  15. brainiac_cli-0.18.2/src/brain/_assets/overlay/template/cos/priorities.md +0 -19
  16. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/LICENSE +0 -0
  17. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/MANIFEST.in +0 -0
  18. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/README.md +0 -0
  19. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/setup.cfg +0 -0
  20. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/__init__.py +0 -0
  21. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/__main__.py +0 -0
  22. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/brand/brand-guide.md +0 -0
  23. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/keywords/glossary.md +0 -0
  24. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/people/roster.md +0 -0
  25. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/overlay/template/voice/voice-profile.md +0 -0
  26. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/routines/manifest.json +0 -0
  27. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/brain-brief-mac.plist +0 -0
  28. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/brain-synthesis-mac.plist +0 -0
  29. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/brain-synthesis.sh +0 -0
  30. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/install-brief-mac.sh +0 -0
  31. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/install-brief-windows.ps1 +0 -0
  32. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/scripts/register_tasks.py +0 -0
  33. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/company.md +0 -0
  34. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/concept.md +0 -0
  35. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/daily.md +0 -0
  36. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/decision.md +0 -0
  37. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/meeting.md +0 -0
  38. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/person.md +0 -0
  39. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/project.md +0 -0
  40. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/_assets/templates/state-moc.md +0 -0
  41. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/anchor.py +0 -0
  42. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/audit.py +0 -0
  43. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/backup.py +0 -0
  44. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/brief.py +0 -0
  45. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/capture.py +0 -0
  46. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/chunk.py +0 -0
  47. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/config.py +0 -0
  48. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/connect.py +0 -0
  49. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/context.py +0 -0
  50. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/core.py +0 -0
  51. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/doctor.py +0 -0
  52. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/egress.py +0 -0
  53. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/embed.py +0 -0
  54. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/encryption.py +0 -0
  55. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/frontmatter.py +0 -0
  56. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/golden_probe.py +0 -0
  57. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/graph.py +0 -0
  58. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/graphify.py +0 -0
  59. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/inbox.py +0 -0
  60. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/__init__.py +0 -0
  61. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/__init__.py +0 -0
  62. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/base.py +0 -0
  63. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/docx.py +0 -0
  64. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/email.py +0 -0
  65. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/html.py +0 -0
  66. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/image.py +0 -0
  67. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/pdf.py +0 -0
  68. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/pptx.py +0 -0
  69. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/tables.py +0 -0
  70. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/text.py +0 -0
  71. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/xlsx.py +0 -0
  72. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/handlers/zip.py +0 -0
  73. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/pipeline.py +0 -0
  74. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/ingest/transcript.py +0 -0
  75. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/init.py +0 -0
  76. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/maintenance.py +0 -0
  77. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/mcp_adapter.py +0 -0
  78. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/multihop.py +0 -0
  79. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/notes.py +0 -0
  80. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/overlay.py +0 -0
  81. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/projection.py +0 -0
  82. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/rerank.py +0 -0
  83. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/retro.py +0 -0
  84. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/snapshot.py +0 -0
  85. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/spine.py +0 -0
  86. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/update.py +0 -0
  87. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brain/vectors.py +0 -0
  88. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brainiac_cli.egg-info/dependency_links.txt +0 -0
  89. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brainiac_cli.egg-info/entry_points.txt +0 -0
  90. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brainiac_cli.egg-info/requires.txt +0 -0
  91. {brainiac_cli-0.18.2 → brainiac_cli-0.19.0}/src/brainiac_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brainiac-cli
3
- Version: 0.18.2
3
+ Version: 0.19.0
4
4
  Summary: Brainiac — local any-LLM second-brain core engine + brain CLI (Markdown truth, derived SQLite index).
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.9
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
  # check. The IMPORT package stays `brain` and the console script stays
9
9
  # `brain` — only the distribution/project name changes.
10
10
  name = "brainiac-cli"
11
- version = "0.18.2"
11
+ version = "0.19.0"
12
12
  description = "Brainiac — local any-LLM second-brain core engine + brain CLI (Markdown truth, derived SQLite index)."
13
13
  readme = "README.md"
14
14
  license = "Apache-2.0"
@@ -356,7 +356,11 @@ rules, in order:
356
356
  `-- N withheld …` line in text): when you see it, **re-run with
357
357
  `--max-tier Restricted`** (or `MNPI` for the most sensitive) — the
358
358
  human-gated elevation — instead of concluding the vault has nothing and
359
- web-searching to compensate.
359
+ web-searching to compensate. **On `--role vm` this elevation is NOT
360
+ self-serve:** the VM leg clamps `--max-tier` to a hard ceiling
361
+ (`$BRAIN_VM_MAX_EGRESS_TIER`, default `Internal`), so a typed higher tier is
362
+ silently capped and the elevation hint is suppressed — raising a VM's ceiling
363
+ is a host-operator action, not something the model does on its own.
360
364
 
361
365
  3. **Never leak internal topics into a web search.** A web query for a
362
366
  Confidential-or-above subject — a deal codename, a counterparty, an internal
@@ -0,0 +1,25 @@
1
+ ---
2
+ overlay_type: cos
3
+ title: "Reply drafts — owner setting"
4
+ setting: drafts
5
+ ---
6
+
7
+ enabled: true
8
+
9
+ ## What this controls
10
+
11
+ Whether the nightly chief-of-staff composes reply drafts into your Drafts
12
+ folder for threads that warrant a response. It NEVER sends — sending is yours,
13
+ structurally.
14
+
15
+ - `enabled: true` (or this file absent) — compose drafts, cap 10/run.
16
+ - `enabled: false` — no drafts; the run spends that budget on battlecards.
17
+
18
+ ## Why this file exists
19
+
20
+ Without it a run has nowhere to read your answer, so a low draft-engagement
21
+ count invites it to quietly stop drafting and ask you again every night. Your
22
+ ruling lives here instead: durable, and outside the rolling skill-memory cap.
23
+
24
+ Engagement (did you send/edit a draft?) is still measured and reported. It is
25
+ DATA, not a verdict — an untouched draft may simply mean you have not looked.
@@ -0,0 +1,42 @@
1
+ ---
2
+ overlay_type: cos
3
+ title: "COS priority overrides — starter template"
4
+ updated: 2026-07-13
5
+ ---
6
+
7
+ # COS priority overrides
8
+
9
+ OPTIONAL category (CUT-01E). `brain cos-priority-map` generates the
10
+ VM-readable priority map from your `type: person` / `type: company` notes;
11
+ list ids below to override the computed placement. One list line per note:
12
+
13
+ `- <note-id>: high|normal|low|exclude`
14
+
15
+ <!-- examples (replace with your own note ids):
16
+ - contoso-acquisition-lead: high
17
+ - northwind-vendor-contact: low
18
+ - retired-supplier-x: exclude
19
+ -->
20
+
21
+ ## Priority-chip taxonomy (chief-of-staff kernel v4.6 — companion block)
22
+
23
+ The nightly run projects the act queue onto exactly three Outlook
24
+ categories (recorded verbatim — category names are IMMUTABLE once created;
25
+ a rename means delete + recreate + re-chip every tagged message):
26
+
27
+ - `P0 · Now` — red
28
+ - `P1 · Today` — orange
29
+ - `P2 · This week` — blue
30
+
31
+ `chips_confirmed` is the RUNTIME chip gate the nightly reads: chips are
32
+ withheld (the legacy flat Action mark continues) until the owner's recorded
33
+ YES to the queued name/color confirmation question, at which point the
34
+ answering session sets:
35
+
36
+ ```
37
+ chips_confirmed: false
38
+ chips_confirmed_date:
39
+ ```
40
+
41
+ (The answering session flips the value to `true` and dates it. Never
42
+ pre-fill `true` — an uncommented `true` line IS the gate opening.)
@@ -63,12 +63,14 @@ fi
63
63
 
64
64
  {
65
65
  echo "=== brain-daily-brief / brain-nightly $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="
66
- # cos-broker FIRST: the host broker fold claims VM proposal drops, releases due
67
- # holds, runs auto-capture, enqueues the owner batch, and renders
68
- # shared/spine-summary.md. Nothing else schedules it -- without this line the
69
- # VM proposes into a drop dir no one drains (measured 2026-07-16: spine DB empty,
70
- # candidates unclaimed ~29h) . It runs BEFORE maintain so notes it signs are
71
- # indexed + published by the same run's sync. Host-only, idempotent, and
66
+ # cos-broker first — CORRECTION (2026-07-17): `brain maintain` ALSO runs the
67
+ # fold + the ingest sweep (core.py maintain, the CUT-01E block); the original
68
+ # claim here that "nothing else schedules it" was WRONG. The 2026-07-16 field
69
+ # failure (spine DB empty, candidates unclaimed ~29h) was actually the plist
70
+ # pinning a 0.17.0 engine whose fold predates the spine stage — maintain was
71
+ # calling the fold hourly all along, at a version that couldn't render it.
72
+ # This explicit call stays as defense-in-depth: idempotent, cheap, and it
73
+ # keeps the drain floor if maintain's internal block ever regresses.
72
74
  # NON-FATAL: a broker failure must never cost the vault its capture-drain floor.
73
75
  "$BRAIN_BIN" cos-broker --json || echo "brain-brief: cos-broker fold failed (non-fatal), continuing to maintain"
74
76
  # maintain: sync --publish (drain pending captures + reconcile index +
@@ -9,4 +9,4 @@ pip-installed host, importlib.metadata stays primary; brain/__init__.py reads
9
9
  this file only as the fallback before 0.0.0+unknown.
10
10
  """
11
11
 
12
- __version__ = "0.18.2"
12
+ __version__ = "0.19.0"
@@ -44,6 +44,31 @@ if DEFAULT_MAX_TIER not in RANK:
44
44
  # Public + Internal only unless a human explicitly elevates with --max-tier.
45
45
  VM_DEFAULT_MAX_TIER = "Internal"
46
46
 
47
+ # Hard server-side ceiling for the untrusted VM leg (codex 2026-07-19). On
48
+ # role=vm, --max-tier is a caller-controlled argument the LLM can set itself;
49
+ # without a clamp the egress-starvation hint ("re-run with --max-tier
50
+ # Restricted") turns the intended HUMAN-gated elevation into an instruction the
51
+ # model follows autonomously. This is the CLI analogue of the MCP adapter's
52
+ # BRAIN_MAX_EGRESS_TIER: a VM caller can never exceed this ceiling regardless of
53
+ # what it types. The host operator raises it deliberately via the env var (the
54
+ # real human gate); an unrecognised value falls back to VM_DEFAULT_MAX_TIER.
55
+ VM_EGRESS_CEILING_ENV = "BRAIN_VM_MAX_EGRESS_TIER"
56
+
57
+
58
+ def vm_egress_ceiling() -> str:
59
+ """The tier a role=vm caller may never exceed. Operator-settable via
60
+ $BRAIN_VM_MAX_EGRESS_TIER; defaults to the conservative VM cap."""
61
+ val = os.environ.get(VM_EGRESS_CEILING_ENV, "").strip()
62
+ return val if val in RANK else VM_DEFAULT_MAX_TIER
63
+
64
+
65
+ def clamp_to(requested: str, ceiling: str) -> str:
66
+ """Return the lower (less-sensitive) of two tiers by rank — a caller can
67
+ never widen egress past the ceiling, only narrow within it."""
68
+ req = requested if requested in RANK else DEFAULT_DENY_TIER
69
+ ceil = ceiling if ceiling in RANK else VM_DEFAULT_MAX_TIER
70
+ return req if RANK[req] <= RANK[ceil] else ceil
71
+
47
72
 
48
73
  def normalize(value: object) -> str:
49
74
  """Map a raw frontmatter value to a recognised tier, default-deny on miss."""
@@ -164,6 +164,12 @@ def _emit(obj: Any, as_json: bool, human: str | None = None) -> None:
164
164
  sys.stdout.write((human if human is not None else str(obj)) + "\n")
165
165
 
166
166
 
167
+ # Set True by main() on role=vm: the untrusted leg must not be told to
168
+ # "re-run with --max-tier Restricted" — that hint is the self-elevation nudge
169
+ # codex flagged, and the VM ceiling clamp makes the instruction a no-op anyway.
170
+ _SUPPRESS_ELEVATION_HINT = False
171
+
172
+
167
173
  def _filter_dicts(items: list[dict], max_tier: str) -> tuple[list[dict], dict]:
168
174
  # THE single egress chokepoint — every content-returning subcommand routes
169
175
  # through egress.apply_gate so a new content path cannot silently bypass the
@@ -174,7 +180,8 @@ def _filter_dicts(items: list[dict], max_tier: str) -> tuple[list[dict], dict]:
174
180
  # web search — leaking internal topics outward. Say WHY it's thin and HOW to
175
181
  # elevate, in the report dict so it surfaces in BOTH --json (agent-facing)
176
182
  # and the text footer. The tier stays the human gate; this only signposts it.
177
- if report.get("withheld", 0) > 0 and max_tier != cls.TIERS[-1]:
183
+ if (report.get("withheld", 0) > 0 and max_tier != cls.TIERS[-1]
184
+ and not _SUPPRESS_ELEVATION_HINT):
178
185
  report["hint"] = (
179
186
  f"{report['withheld']} note(s) withheld above the {max_tier} cap — "
180
187
  f"re-run with --max-tier Restricted (or MNPI for the most sensitive) "
@@ -1080,6 +1087,7 @@ VM_ALLOWED = frozenset({
1080
1087
 
1081
1088
 
1082
1089
  def main(argv: list[str] | None = None) -> int:
1090
+ global _SUPPRESS_ELEVATION_HINT
1083
1091
  try:
1084
1092
  return _main(argv)
1085
1093
  except Exception as exc: # H-4: top-level guard -- never a raw traceback
@@ -1088,6 +1096,11 @@ def main(argv: list[str] | None = None) -> int:
1088
1096
  _emit({"error": type(exc).__name__, "detail": str(exc)} if as_json
1089
1097
  else f"{exc.__class__.__name__}: {exc}", as_json)
1090
1098
  return 3
1099
+ finally:
1100
+ # The VM hint-suppression flag is INVOCATION-scoped (set by _main per
1101
+ # role): reset it here so it never leaks into a later main() call or a
1102
+ # direct _filter_dicts caller in the same process (e.g. across tests).
1103
+ _SUPPRESS_ELEVATION_HINT = False
1091
1104
 
1092
1105
 
1093
1106
  def _main(argv: list[str] | None = None) -> int:
@@ -1103,6 +1116,14 @@ def _main(argv: list[str] | None = None) -> int:
1103
1116
  if getattr(args, "max_tier", "unset") is None:
1104
1117
  args.max_tier = (cls.VM_DEFAULT_MAX_TIER if role == config.ROLE_VM
1105
1118
  else cls.DEFAULT_MAX_TIER)
1119
+ # VM egress ceiling (codex 2026-07-19): on the untrusted leg, --max-tier is
1120
+ # a value the LLM can set itself, and the starvation hint nudges it to. Clamp
1121
+ # any VM-side max_tier to the operator-set ceiling so a typed --max-tier MNPI
1122
+ # cannot self-elevate past it; the hint is also suppressed for role=vm below.
1123
+ global _SUPPRESS_ELEVATION_HINT
1124
+ _SUPPRESS_ELEVATION_HINT = (role == config.ROLE_VM) # deterministic per call
1125
+ if role == config.ROLE_VM and hasattr(args, "max_tier"):
1126
+ args.max_tier = cls.clamp_to(str(args.max_tier), cls.vm_egress_ceiling())
1106
1127
  # DV-03: the VM leg fails closed on a dead embedder rather than silently
1107
1128
  # answering semantic queries with random hash vectors (no-op when a real
1108
1129
  # embedder is present or hash was chosen explicitly).
@@ -1371,7 +1392,8 @@ def _main(argv: list[str] | None = None) -> int:
1371
1392
  + srep["withheld_unlabelled_default_deny"],
1372
1393
  "max_tier": args.max_tier,
1373
1394
  }
1374
- if report["withheld"] > 0 and args.max_tier != cls.TIERS[-1]:
1395
+ if (report["withheld"] > 0 and args.max_tier != cls.TIERS[-1]
1396
+ and not _SUPPRESS_ELEVATION_HINT):
1375
1397
  report["hint"] = (
1376
1398
  f"{report['withheld']} note(s) withheld above the "
1377
1399
  f"{args.max_tier} cap — re-run with a higher --max-tier.")
@@ -963,6 +963,108 @@ def shadow_ledger_entries(vault) -> list[dict[str, Any]]:
963
963
  return list(by_key.values())
964
964
 
965
965
 
966
+ # -- behavioural grading (2026-07-17, owner decision) --------------------------
967
+ # The corrections-based calibration required ~10 mornings of the owner grading
968
+ # email by hand; across 6 rounds / 308 verdicts he filed ZERO — the ritual will
969
+ # never happen, so auto-archive was permanently gated on evidence that could
970
+ # not accrue. Behavioural grading replaces stated preference with revealed
971
+ # preference: the VM already reads the mailbox nightly, so it OBSERVES what the
972
+ # owner actually did to previously-verdicted mail (read / replied / flagged /
973
+ # archived himself / untouched) and drops raw observations; THIS module grades
974
+ # them, in one testable place. Owner ruling: also mine his own archive actions
975
+ # as pattern evidence — "albeit not exclusively".
976
+
977
+ BEHAVIOUR_OBSERVATIONS = ("owner_replied", "owner_flagged", "owner_read",
978
+ "owner_archived", "untouched")
979
+
980
+
981
+ def grade_behaviour(bucket: str, observed: str, *,
982
+ auto_archived: bool = False) -> str:
983
+ """One verdict row + one observed behaviour -> a grade.
984
+
985
+ The error that matters for auto-archive is exactly one: "would have
986
+ archived mail the owner needed". So a NOISE verdict the owner then replied
987
+ to or flagged is `contradicted` — the hard, gate-relevant failure. A noise
988
+ row he merely opened is `read_anyway`: a weak signal (people open
989
+ newsletters), reported but never gated on. Noise he left untouched or
990
+ archived himself is `consistent`. An act/read row he archived without
991
+ engaging is `overcalled` — over-caution, harmless, informational.
992
+ """
993
+ b, o = str(bucket).lower(), str(observed).lower()
994
+ # Aged-read lane (owner policy 2026-07-17): priority-list mail may be
995
+ # auto-archived when read + no-action + >7d old. Those rows are bucket
996
+ # `read`, not `noise` — so the drift contradiction must key on the ACTION
997
+ # (we auto-archived it), not the bucket: the owner replying to or flagging
998
+ # ANY row we auto-archived is the gate error, whichever lane moved it.
999
+ if auto_archived and o in ("owner_replied", "owner_flagged"):
1000
+ return "contradicted"
1001
+ if b == "noise":
1002
+ if o in ("owner_replied", "owner_flagged"):
1003
+ return "contradicted"
1004
+ if o == "owner_read":
1005
+ return "read_anyway"
1006
+ if o in ("untouched", "owner_archived"):
1007
+ return "consistent"
1008
+ elif b in ("act", "read") and o == "owner_archived":
1009
+ return "overcalled"
1010
+ return "neutral"
1011
+
1012
+
1013
+ def behaviour_entries(vault) -> list[dict[str, Any]]:
1014
+ """Raw behaviour observations from the VM drop (``behaviour-*.jsonl``),
1015
+ deduped by (round, msg_key) — last write wins, same idempotency shape as
1016
+ the shadow ledger. Rows are VM-authored and untrusted: consumed as data."""
1017
+ vdir = verdict_drop_dir(vault)
1018
+ files = sorted(vdir.glob("behaviour-*.jsonl")) if vdir.is_dir() else []
1019
+ by_key: dict[tuple[int, str], dict[str, Any]] = {}
1020
+ for f in files:
1021
+ for e in _read_jsonl(f):
1022
+ r, k = e.get("round"), e.get("msg_key")
1023
+ if isinstance(r, int) and isinstance(k, str):
1024
+ by_key[(r, k)] = e
1025
+ return list(by_key.values())
1026
+
1027
+
1028
+ def behaviour_report(vault) -> dict[str, Any]:
1029
+ """Aggregate observed-behaviour evidence: per-bucket grade counts, the
1030
+ noise-safety numbers an auto-archive re-arm decision needs, and the
1031
+ owner's own archive patterns (top senders he archives himself — evidence
1032
+ for FUTURE noise-signals, never an actuator by itself)."""
1033
+ entries = behaviour_entries(vault)
1034
+ per_bucket: dict[str, dict[str, int]] = {}
1035
+ contradicted_rows: list[dict[str, Any]] = []
1036
+ owner_archive_patterns: dict[str, int] = {}
1037
+ rounds: set[int] = set()
1038
+ for e in entries:
1039
+ b = str(e.get("bucket", "?")).lower()
1040
+ o = str(e.get("observed", "?")).lower()
1041
+ g = grade_behaviour(b, o, auto_archived=bool(e.get("auto_archived")))
1042
+ per_bucket.setdefault(b, {})[g] = per_bucket.setdefault(b, {}).get(g, 0) + 1
1043
+ rounds.add(int(e["round"]))
1044
+ if g == "contradicted":
1045
+ contradicted_rows.append(
1046
+ {k: e.get(k) for k in ("round", "msg_key", "sender", "subject",
1047
+ "observed")})
1048
+ if o == "owner_archived":
1049
+ key = str(e.get("sender") or e.get("sender_domain") or "unknown").lower()
1050
+ owner_archive_patterns[key] = owner_archive_patterns.get(key, 0) + 1
1051
+ noise = per_bucket.get("noise", {})
1052
+ noise_observed = sum(noise.values())
1053
+ contradicted = noise.get("contradicted", 0)
1054
+ return {
1055
+ "observations": len(entries),
1056
+ "rounds_observed": len(rounds),
1057
+ "per_bucket": per_bucket,
1058
+ "noise_observed": noise_observed,
1059
+ "noise_contradicted": contradicted,
1060
+ "noise_consistency": (round((noise_observed - contradicted) / noise_observed, 4)
1061
+ if noise_observed else None),
1062
+ "contradicted_rows": contradicted_rows[:20],
1063
+ "owner_archive_patterns": dict(sorted(owner_archive_patterns.items(),
1064
+ key=lambda kv: -kv[1])[:20]),
1065
+ }
1066
+
1067
+
966
1068
  def calibration_report(vault) -> dict[str, Any]:
967
1069
  """Shadow-mode trust-gate report: calibration = reduce(verdicts,
968
1070
  correction_events). A verdict is bucket-correct when no correction exists
@@ -998,6 +1100,10 @@ def calibration_report(vault) -> dict[str, Any]:
998
1100
  "overall_bucket_precision": (round(bucket_correct / total, 4)
999
1101
  if total else None),
1000
1102
  "per_bucket": buckets,
1103
+ # revealed preference alongside stated preference: the corrections
1104
+ # count above stays authoritative where it exists, but 0 corrections
1105
+ # no longer means 0 evidence.
1106
+ "behaviour": behaviour_report(vault),
1001
1107
  }
1002
1108
 
1003
1109
 
@@ -1451,7 +1557,18 @@ def _is_keeper_counterparty(vault, counterparty: str | None) -> bool:
1451
1557
  if not counterparty:
1452
1558
  return False
1453
1559
  overrides = load_priority_overrides(vault)
1454
- return overrides.get(str(counterparty).lower()) == "high"
1560
+ name = str(counterparty).lower()
1561
+ if overrides.get(name) == "high":
1562
+ return True
1563
+ # Override keys are NOTE-ID SLUGS (the only form _OVERRIDE_LINE_RE parses),
1564
+ # but a commitment's counterparty is a display name from mail — e.g. a name
1565
+ # like "Renée Dûval" could never equal "renee-duval", so keeper detection
1566
+ # silently never fired (found 2026-07-17, the day the first real roster was
1567
+ # written). Compare in slug space, accents folded.
1568
+ import unicodedata
1569
+ folded = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
1570
+ slug = re.sub(r"[^a-z0-9]+", "-", folded).strip("-")
1571
+ return overrides.get(slug) == "high"
1455
1572
 
1456
1573
 
1457
1574
  def _spine_ingest_commitment(vault, meta: dict[str, Any], *, source_ref: str,
@@ -1527,6 +1644,13 @@ DEFAULT_INGEST_SWEEP_MAX_BYTES = 200 * 1024 * 1024
1527
1644
  INGEST_SWEEP_DOWNLOADS_ENV = "BRAIN_COS_DOWNLOADS_DIR"
1528
1645
  INGEST_SWEEP_SKEW_SECONDS = 300 # manifest ts vs file mtime clock skew
1529
1646
  INGEST_SWEEP_SIZE_TOLERANCE = 0.10 # when the manifest carries a size
1647
+ # Host-observed freshness floor (codex 2026-07-19): a candidate whose mtime is
1648
+ # older than this relative to the sweep's OWN clock is a pre-existing host file,
1649
+ # not something the VM just downloaded — the un-forgeable provenance anchor. The
1650
+ # maintain umbrella fires hourly; a 6h window tolerates a few missed runs / a
1651
+ # sleeping Mac while still excluding files that have sat in Downloads for days.
1652
+ INGEST_SWEEP_RECENCY_ENV = "BRAIN_COS_SWEEP_RECENCY_SECONDS"
1653
+ DEFAULT_INGEST_SWEEP_RECENCY_SECONDS = 6 * 3600
1530
1654
 
1531
1655
 
1532
1656
  def ingest_manifest_dir(vault=None) -> Path:
@@ -1550,6 +1674,14 @@ def _sweep_max_bytes() -> int:
1550
1674
  return DEFAULT_INGEST_SWEEP_MAX_BYTES
1551
1675
 
1552
1676
 
1677
+ def _sweep_recency_seconds() -> int:
1678
+ try:
1679
+ return int(os.environ.get(INGEST_SWEEP_RECENCY_ENV,
1680
+ DEFAULT_INGEST_SWEEP_RECENCY_SECONDS))
1681
+ except ValueError:
1682
+ return DEFAULT_INGEST_SWEEP_RECENCY_SECONDS
1683
+
1684
+
1553
1685
  def _unique_dest(inbox: Path, filename: str) -> Path:
1554
1686
  dest = inbox / filename
1555
1687
  if not dest.exists():
@@ -1572,8 +1704,12 @@ def ingest_sweep(vault, *, downloads_dir: Path | str | None = None,
1572
1704
  - filenames are basename-only — any path separator / ``..`` is refused;
1573
1705
  - a symlinked candidate (or manifest file) is refused, never followed;
1574
1706
  - files above the size cap (default 200 MB) are refused;
1575
- - a match requires exact filename, size within tolerance when the
1576
- manifest carries one, and mtime newer than the manifest ts minus skew;
1707
+ - a candidate must be a FRESH download — mtime within the recency window of
1708
+ the sweep's own clock — so a VM manifest cannot claim a pre-existing host
1709
+ Downloads file it did not just download (codex 2026-07-19, VM/host
1710
+ boundary): the mtime is the one provenance signal the VM cannot forge;
1711
+ - a match requires exact filename, size within tolerance when the manifest
1712
+ carries one, and mtime newer than the manifest ts minus skew;
1577
1713
  - claims are append-only, one per manifest line — a re-run never
1578
1714
  double-moves (idempotent);
1579
1715
  - NOTHING the manifest does not name is ever touched, moved, or deleted.
@@ -1653,6 +1789,26 @@ def ingest_sweep(vault, *, downloads_dir: Path | str | None = None,
1653
1789
  report["refused"].append({"filename": fname,
1654
1790
  "reason": "over size cap"})
1655
1791
  continue
1792
+ # SECURITY (codex 2026-07-19, VM/host boundary): the manifest is
1793
+ # VM-writable, so its own fields cannot prove provenance — the VM
1794
+ # can name any basename and BACKDATE `ts` to defeat the staleness
1795
+ # check on an old host file. The one thing a VM-side attacker CANNOT
1796
+ # forge is a host file's mtime, so the sweep's OWN clock is the
1797
+ # anchor: only a file whose mtime is recent relative to `now` — i.e.
1798
+ # one genuinely downloaded within this maintenance window — is
1799
+ # eligible. A guessed sensitive file that has been sitting in
1800
+ # ~/Downloads (tax.pdf, statement.pdf …) is refused regardless of
1801
+ # what the manifest claims. Widen the window via env for slow hosts.
1802
+ age = now.timestamp() - cand.stat().st_mtime
1803
+ if age > _sweep_recency_seconds():
1804
+ report["unmatched"].append(fname)
1805
+ report.setdefault("unmatched_reasons", []).append({
1806
+ "filename": fname,
1807
+ "reason": f"not a fresh download: host mtime is {age / 3600.0:.1f}h "
1808
+ f"old (recency window {_sweep_recency_seconds() // 3600}h) "
1809
+ f"— a pre-existing host file the VM manifest cannot claim",
1810
+ })
1811
+ continue
1656
1812
  want = entry.get("approx_size_bytes")
1657
1813
  if isinstance(want, int) and want > 0:
1658
1814
  if abs(size - want) > max(want * INGEST_SWEEP_SIZE_TOLERANCE, 4096):
@@ -0,0 +1,361 @@
1
+ """Priority-chip projection (chief-of-staff kernel v4.7) — tested reference
2
+ implementation for the executable halves of the P-taxonomy contract.
3
+
4
+ The nightly chief-of-staff run is executed by a model reading SKILL.md; the
5
+ behaviours here are the parts that must never drift on a prompt rewrite, so
6
+ they live as engine code with contract tests (``tests/test_cos_chips.py``,
7
+ fake mailbox adapter + fault injection):
8
+
9
+ - ``assign_chip`` — the authoritative P0/P1/P2 assignment rules
10
+ - ``desired_categories``— category-set PRESERVATION diff (never a bare-set write)
11
+ - ``verify_write`` — whole-set server-state verification
12
+ - ``apply_chip_to_conversation`` — message-level apply + journal, per-row verified
13
+ - ``recover_from_journal`` — journal recovery after a partial/failed pass
14
+ - ``lease_state`` — the cos-ops/_mutation_lease.json semantics
15
+ - ``desired_chip_and_trigger`` — v4.7 LIF-01/02 nightly reconciliation (auto-clear +
16
+ re-level), full-evidence desired-state diff, never rule-ordering
17
+ - ``apply_relevel_to_conversation`` — v4.7 add-before-remove re-level apply
18
+ - ``dedupe_automated_p2`` — v4.7 recurring-automated-sender P2 collapse (s02 finding)
19
+
20
+ Chips are a PROJECTION of the act queue — never a verdict. Classification
21
+ (Phase 1.5 buckets/tiers) is untouched by this module.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from datetime import datetime, timezone
26
+ from typing import Any, Iterable, Protocol
27
+
28
+ CHIP_P0 = "P0 · Now" # red
29
+ CHIP_P1 = "P1 · Today" # orange
30
+ CHIP_P2 = "P2 · This week" # blue
31
+ CHIPS = (CHIP_P0, CHIP_P1, CHIP_P2)
32
+ LEGACY_ACTION = "Action"
33
+ #: every category this projection owns and may add/remove; anything else on a
34
+ #: message belongs to the owner and must survive every write untouched.
35
+ MANAGED_CATEGORIES = frozenset(CHIPS) | {LEGACY_ACTION}
36
+
37
+ CHIP_COLORS = {CHIP_P0: "red", CHIP_P1: "orange", CHIP_P2: "blue"}
38
+
39
+
40
+ # --------------------------------------------------------------------------
41
+ # Assignment (PRJ-01 hardened rules — roster tier alone never makes P0)
42
+ # --------------------------------------------------------------------------
43
+
44
+ def assign_chip(
45
+ *,
46
+ bucket: str,
47
+ roster_high: bool = False,
48
+ direct_ask: bool = False,
49
+ deadline_hours: float | None = None,
50
+ blocking_others: bool = False,
51
+ ) -> str | None:
52
+ """Return the ONE chip for a conversation, or ``None`` for non-``act``.
53
+
54
+ P0 · Now = (roster-high AND (direct ask OR any stated deadline))
55
+ OR hard deadline < 48h OR blocking others.
56
+ P1 · Today = a direct ask on the owner.
57
+ P2 · This week = every other act row.
58
+ Roster tier alone NEVER makes P0.
59
+ """
60
+ if bucket != "act":
61
+ return None
62
+ has_deadline = deadline_hours is not None
63
+ if (
64
+ (roster_high and (direct_ask or has_deadline))
65
+ or (has_deadline and deadline_hours < 48)
66
+ or blocking_others
67
+ ):
68
+ return CHIP_P0
69
+ if direct_ask:
70
+ return CHIP_P1
71
+ return CHIP_P2
72
+
73
+
74
+ # --------------------------------------------------------------------------
75
+ # Desired-state category diff (codex-r2 i — preservation, never a bare set)
76
+ # --------------------------------------------------------------------------
77
+
78
+ def desired_categories(existing: Iterable[str], chip: str | None) -> list[str]:
79
+ """``existing − MANAGED + {chip}`` with the owner's categories preserved
80
+ in their original order; ``chip=None`` strips every managed category."""
81
+ if chip is not None and chip not in CHIPS:
82
+ raise ValueError(f"not a managed P-chip: {chip!r}")
83
+ kept = [c for c in existing if c not in MANAGED_CATEGORIES]
84
+ return kept + ([chip] if chip else [])
85
+
86
+
87
+ def verify_write(
88
+ pre_categories: Iterable[str], post_categories: Iterable[str], chip: str | None
89
+ ) -> bool:
90
+ """A chip write verifies ONLY when the entire post-write set is right:
91
+ the chip present (or absent, on removal) AND the non-managed subset
92
+ unchanged. Order of the owner's categories is not significant."""
93
+ pre_kept = sorted(c for c in pre_categories if c not in MANAGED_CATEGORIES)
94
+ post = list(post_categories)
95
+ post_kept = sorted(c for c in post if c not in MANAGED_CATEGORIES)
96
+ if pre_kept != post_kept:
97
+ return False
98
+ managed_present = [c for c in post if c in MANAGED_CATEGORIES]
99
+ return managed_present == ([chip] if chip else [])
100
+
101
+
102
+ # --------------------------------------------------------------------------
103
+ # Message-level apply under the conversation abstraction + journal recovery
104
+ # --------------------------------------------------------------------------
105
+
106
+ class MailboxAdapter(Protocol):
107
+ """The minimal mutation surface (fake in tests, Chrome/REST in life)."""
108
+
109
+ def inbox_message_ids(self, conversation_id: str) -> list[str]: ...
110
+ def get_categories(self, message_id: str) -> list[str]: ...
111
+ def set_categories(self, message_id: str, categories: list[str]) -> None: ...
112
+
113
+
114
+ def apply_chip_to_conversation(
115
+ mailbox: MailboxAdapter, conversation_id: str, chip: str | None
116
+ ) -> list[dict[str, Any]]:
117
+ """Apply (or, with ``chip=None``, remove) the conversation's chip on EVERY
118
+ message currently in Inbox. Per message: compute the preserved desired
119
+ set, write it, RE-READ SERVER STATE, and journal the row with its
120
+ verification result. A failure on one message never aborts the rest.
121
+
122
+ Journal rows: ``{conversation_id, message_id, chip, verification}`` with
123
+ verification ∈ ``response-confirmed`` (server re-read verified) ·
124
+ ``verified-failed`` (write landed wrong / silently dropped) ·
125
+ ``error:<msg>`` (the mutation call raised).
126
+ """
127
+ journal: list[dict[str, Any]] = []
128
+ for mid in mailbox.inbox_message_ids(conversation_id):
129
+ row = {"conversation_id": conversation_id, "message_id": mid, "chip": chip}
130
+ try:
131
+ pre = list(mailbox.get_categories(mid))
132
+ mailbox.set_categories(mid, desired_categories(pre, chip))
133
+ post = list(mailbox.get_categories(mid)) # server re-read, never trust the write
134
+ row["verification"] = (
135
+ "response-confirmed" if verify_write(pre, post, chip) else "verified-failed"
136
+ )
137
+ except Exception as exc: # fault-injection boundary: journal, continue
138
+ row["verification"] = f"error:{exc}"
139
+ journal.append(row)
140
+ return journal
141
+
142
+
143
+ def rows_needing_recovery(journal: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
144
+ """Journal rows whose write is NOT server-confirmed — the re-apply set."""
145
+ return [r for r in journal if r.get("verification") != "response-confirmed"]
146
+
147
+
148
+ def recover_from_journal(
149
+ mailbox: MailboxAdapter, journal: Iterable[dict[str, Any]]
150
+ ) -> list[dict[str, Any]]:
151
+ """Re-drive every unconfirmed journal row, idempotently: a row whose
152
+ server state already matches the desired set verifies as
153
+ ``already-applied`` without a write; otherwise re-apply + re-verify.
154
+ Returns the recovery journal (same row shape)."""
155
+ out: list[dict[str, Any]] = []
156
+ for r in rows_needing_recovery(journal):
157
+ mid, chip = r["message_id"], r["chip"]
158
+ row = {**r}
159
+ try:
160
+ current = list(mailbox.get_categories(mid))
161
+ if verify_write(current, current, chip):
162
+ row["verification"] = "already-applied"
163
+ else:
164
+ mailbox.set_categories(mid, desired_categories(current, chip))
165
+ post = list(mailbox.get_categories(mid))
166
+ row["verification"] = (
167
+ "response-confirmed" if verify_write(current, post, chip)
168
+ else "verified-failed"
169
+ )
170
+ except Exception as exc:
171
+ row["verification"] = f"error:{exc}"
172
+ out.append(row)
173
+ return out
174
+
175
+
176
+ # --------------------------------------------------------------------------
177
+ # Lifecycle v4.7 (LIF-01/02/03) — desired-state reconciliation, never
178
+ # rule-ordering: every conversation's desired chip is recomputed from the
179
+ # FULL current evidence each night, so a reply followed by NEW actionable
180
+ # inbound still chips (a stale rule-order clear-on-reply would silently drop
181
+ # it under the "no chip = don't look" runbook rule — codex-r2 (i)).
182
+ # --------------------------------------------------------------------------
183
+
184
+ #: the ONLY trigger that clears a chip outright — every other signal below
185
+ #: (thread-closed / meeting-passed / handled-by-others) may only de-escalate.
186
+ CLEAR_TRIGGER = "owner_reply_is_latest_no_open_items"
187
+ DEESCALATE_TRIGGERS = frozenset({"thread_closed", "meeting_passed", "handled_by_others"})
188
+ _DEESCALATE_STEP = {CHIP_P0: CHIP_P1, CHIP_P1: CHIP_P2, CHIP_P2: CHIP_P2}
189
+
190
+
191
+ def desired_chip_and_trigger(
192
+ *,
193
+ bucket: str,
194
+ roster_high: bool = False,
195
+ direct_ask: bool = False,
196
+ deadline_hours: float | None = None,
197
+ blocking_others: bool = False,
198
+ owner_reply_is_latest: bool = False,
199
+ has_unsent_draft: bool = False,
200
+ has_flag: bool = False,
201
+ has_open_commitment: bool = False,
202
+ has_pending_deadline: bool = False,
203
+ thread_closed: bool = False,
204
+ meeting_passed: bool = False,
205
+ handled_by_others: bool = False,
206
+ ) -> tuple[str | None, str]:
207
+ """Nightly desired-state reconciliation for ONE conversation, over the
208
+ FULL current evidence (LIF-01/02, hardened (i)/(ii)).
209
+
210
+ Clear (chip=None) fires ONLY on the closed enum: owner's reply is the
211
+ LATEST message (no later inbound), AND no unsent draft, no flag, no open
212
+ spine commitment, no pending deadline — every clear ledgers this trigger
213
+ verbatim. 'thread_closed' / 'meeting_passed' / 'handled_by_others' NEVER
214
+ clear alone; at most they de-escalate one level (P0->P1->P2, floor P2).
215
+ """
216
+ if (
217
+ owner_reply_is_latest
218
+ and not has_unsent_draft
219
+ and not has_flag
220
+ and not has_open_commitment
221
+ and not has_pending_deadline
222
+ ):
223
+ return None, CLEAR_TRIGGER
224
+
225
+ chip = assign_chip(
226
+ bucket=bucket, roster_high=roster_high, direct_ask=direct_ask,
227
+ deadline_hours=deadline_hours, blocking_others=blocking_others,
228
+ )
229
+ if chip is None:
230
+ return None, "not-act"
231
+
232
+ if handled_by_others or thread_closed or meeting_passed:
233
+ trigger = ("handled_by_others" if handled_by_others
234
+ else "thread_closed" if thread_closed else "meeting_passed")
235
+ return _DEESCALATE_STEP[chip], trigger
236
+
237
+ return chip, "assignment"
238
+
239
+
240
+ def reconcile_action(existing_chip: str | None, desired: str | None) -> str:
241
+ """Diff existing vs. desired -> the ledger action bucket."""
242
+ if existing_chip == desired:
243
+ return "none"
244
+ if existing_chip is None:
245
+ return "added"
246
+ if desired is None:
247
+ return "cleared"
248
+ return "re-leveled"
249
+
250
+
251
+ def ledger_entry(
252
+ conversation_id: str, *, existing_chip: str | None, desired: str | None, trigger: str,
253
+ ) -> dict[str, Any]:
254
+ """One CHIP LEDGER row (LIF-03) — added/re-leveled/cleared, with reason."""
255
+ return {
256
+ "conversation_id": conversation_id,
257
+ "action": reconcile_action(existing_chip, desired),
258
+ "from": existing_chip,
259
+ "to": desired,
260
+ "trigger": trigger,
261
+ }
262
+
263
+
264
+ def apply_relevel_to_conversation(
265
+ mailbox: MailboxAdapter, conversation_id: str, old_chip: str, new_chip: str,
266
+ ) -> list[dict[str, Any]]:
267
+ """Re-level a conversation ADD-BEFORE-REMOVE (hardened (b)): every
268
+ message first gets BOTH chips written (transient two-chip state is
269
+ acceptable), then the old chip is stripped in a second pass. A zero-chip
270
+ gap never occurs even under a mid-pass failure — the next nightly
271
+ reconciliation heals any partial state, since desired-state diff is
272
+ idempotent (add step re-detects "already has new_chip", remove step
273
+ re-detects "already single-chip")."""
274
+ journal: list[dict[str, Any]] = []
275
+ mids = mailbox.inbox_message_ids(conversation_id)
276
+ for mid in mids:
277
+ row = {"conversation_id": conversation_id, "message_id": mid,
278
+ "chip": new_chip, "step": "add-new"}
279
+ try:
280
+ pre = list(mailbox.get_categories(mid))
281
+ kept = [c for c in pre if c not in MANAGED_CATEGORIES]
282
+ both = kept + [c for c in (old_chip, new_chip) if c not in kept]
283
+ mailbox.set_categories(mid, both)
284
+ post = list(mailbox.get_categories(mid))
285
+ row["verification"] = (
286
+ "response-confirmed" if new_chip in post and old_chip in post
287
+ else "verified-failed"
288
+ )
289
+ except Exception as exc:
290
+ row["verification"] = f"error:{exc}"
291
+ journal.append(row)
292
+ for mid in mids:
293
+ row = {"conversation_id": conversation_id, "message_id": mid,
294
+ "chip": new_chip, "step": "remove-old"}
295
+ try:
296
+ pre = list(mailbox.get_categories(mid))
297
+ mailbox.set_categories(mid, desired_categories(pre, new_chip))
298
+ post = list(mailbox.get_categories(mid))
299
+ row["verification"] = (
300
+ "response-confirmed" if verify_write(pre, post, new_chip) else "verified-failed"
301
+ )
302
+ except Exception as exc:
303
+ row["verification"] = f"error:{exc}"
304
+ journal.append(row)
305
+ return journal
306
+
307
+
308
+ # --------------------------------------------------------------------------
309
+ # Automated-sender P2 dedupe (s02 finding, folded into v4.7 assignment)
310
+ # --------------------------------------------------------------------------
311
+
312
+ def dedupe_automated_p2(rows: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
313
+ """Collapse recurring-automated-sender act rows to ONE P2 chip per
314
+ sender per cycle. ``rows``: dicts carrying ``chip`` and ``automated_sender``
315
+ (the overlay-driven flag naming a known recurring bulk sender, e.g. an
316
+ automated notification system) and ``sender``. Only P2 rows from a
317
+ flagged automated sender are collapsed — P0/P1 rows (a direct ask or
318
+ deadline from that sender) are never suppressed."""
319
+ seen: set[str] = set()
320
+ out: list[dict[str, Any]] = []
321
+ for r in rows:
322
+ if r.get("chip") == CHIP_P2 and r.get("automated_sender"):
323
+ key = r["sender"]
324
+ if key in seen:
325
+ continue
326
+ seen.add(key)
327
+ out.append(r)
328
+ return out
329
+
330
+
331
+ # --------------------------------------------------------------------------
332
+ # Mutation lease (codex-r3 — one mutator at a time)
333
+ # --------------------------------------------------------------------------
334
+
335
+ def lease_state(
336
+ lease: dict[str, Any] | None, *, now: datetime, run_id: str
337
+ ) -> tuple[str, str | None]:
338
+ """Judge ``cos-ops/_mutation_lease.json`` content for a run.
339
+
340
+ Returns ``(state, holder)`` — state ∈:
341
+ ``clear`` no lease / our own lease ⇒ mutations allowed;
342
+ ``held`` unexpired foreign lease (or malformed — unreadable intent
343
+ fails closed) ⇒ ZERO mailbox mutations this pass;
344
+ ``expired`` stale foreign lease ⇒ ignored but REPORTED in the banner.
345
+ """
346
+ if lease is None:
347
+ return "clear", None
348
+ holder = lease.get("owner") or lease.get("run_id") or "unknown"
349
+ try:
350
+ if not isinstance(lease, dict) or not lease.get("run_id"):
351
+ return "held", str(holder)
352
+ if lease["run_id"] == run_id:
353
+ return "clear", str(holder)
354
+ exp = datetime.fromisoformat(str(lease["ttl_expires"]))
355
+ if exp.tzinfo is None:
356
+ exp = exp.replace(tzinfo=timezone.utc)
357
+ except Exception:
358
+ return "held", str(holder) # malformed ⇒ fail closed, reported
359
+ if exp <= now:
360
+ return "expired", str(holder)
361
+ return "held", str(holder)
@@ -457,7 +457,56 @@ class BrainIndex:
457
457
 
458
458
  # -- build (full) -----------------------------------------------------
459
459
  def rebuild(self, vault: Path) -> dict[str, Any]:
460
- """Drop and rebuild the entire index from vault/. Always safe.
460
+ """Rebuild the entire index from vault/ — crash-safe.
461
+
462
+ Builds into a TEMP db file and atomically swaps it into place on
463
+ success (same posture as ``snapshot.publish_snapshot``), so a killed
464
+ rebuild leaves the prior live index untouched. Before this fix,
465
+ ``_create_schema`` dropped the live tables FIRST and inserts landed at
466
+ the end — a kill mid-rebuild (a full re-embed of a large vault can run
467
+ an hour+) left the live index wiped (field report 2026-07-16: a killed
468
+ rebuild on a 2,312-note vault left 0 notes / 0 chunks).
469
+
470
+ In-memory DBs (``:memory:``, test-only) build in place — there is
471
+ nothing to swap."""
472
+ if self.db_path == Path(":memory:"):
473
+ return self._rebuild_impl(vault)
474
+
475
+ tmp_path = self.db_path.with_name(self.db_path.name + f".rebuild.tmp.{os.getpid()}")
476
+ for p in (tmp_path, Path(str(tmp_path) + "-wal"), Path(str(tmp_path) + "-shm")):
477
+ p.unlink(missing_ok=True)
478
+
479
+ orig_db_path = self.db_path
480
+ self.close()
481
+ self.db_path = tmp_path
482
+ try:
483
+ result = self._rebuild_impl(vault)
484
+ # Checkpoint the temp DB's WAL into its main file so the file we
485
+ # swap in is fully self-contained (mirrors snapshot.py's
486
+ # wal_checkpoint(TRUNCATE) before publish).
487
+ self.conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
488
+ self.close()
489
+ os.replace(tmp_path, orig_db_path)
490
+ except BaseException:
491
+ self.close()
492
+ self.db_path = orig_db_path
493
+ for p in (tmp_path, Path(str(tmp_path) + "-wal"), Path(str(tmp_path) + "-shm")):
494
+ p.unlink(missing_ok=True)
495
+ raise
496
+ finally:
497
+ for p in (tmp_path, Path(str(tmp_path) + "-wal"), Path(str(tmp_path) + "-shm")):
498
+ p.unlink(missing_ok=True)
499
+ # The just-replaced live DB has no matching sidecars of its own yet
500
+ # (the temp ones were checkpointed away above) — drop any stale
501
+ # leftovers from the PRIOR live DB so the next open starts clean.
502
+ for suffix in ("-wal", "-shm"):
503
+ Path(str(orig_db_path) + suffix).unlink(missing_ok=True)
504
+ self.db_path = orig_db_path
505
+ result["db"] = str(self.db_path)
506
+ return result
507
+
508
+ def _rebuild_impl(self, vault: Path) -> dict[str, Any]:
509
+ """Drop and rebuild the tables at ``self.db_path`` in place.
461
510
 
462
511
  S11 indexing speed fix: chunking / embedding / writing are now THREE
463
512
  separate passes. Previously ``_insert_note`` was called once per note
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: brainiac-cli
3
- Version: 0.18.2
3
+ Version: 0.19.0
4
4
  Summary: Brainiac — local any-LLM second-brain core engine + brain CLI (Markdown truth, derived SQLite index).
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.9
@@ -18,6 +18,7 @@ src/brain/connect.py
18
18
  src/brain/context.py
19
19
  src/brain/core.py
20
20
  src/brain/cos.py
21
+ src/brain/cos_chips.py
21
22
  src/brain/doctor.py
22
23
  src/brain/egress.py
23
24
  src/brain/embed.py
@@ -43,6 +44,7 @@ src/brain/update.py
43
44
  src/brain/vectors.py
44
45
  src/brain/_assets/AGENTS.md
45
46
  src/brain/_assets/overlay/template/brand/brand-guide.md
47
+ src/brain/_assets/overlay/template/cos/drafts.md
46
48
  src/brain/_assets/overlay/template/cos/priorities.md
47
49
  src/brain/_assets/overlay/template/keywords/glossary.md
48
50
  src/brain/_assets/overlay/template/people/roster.md
@@ -1,19 +0,0 @@
1
- ---
2
- overlay_type: cos
3
- title: "COS priority overrides — starter template"
4
- updated: 2026-07-13
5
- ---
6
-
7
- # COS priority overrides
8
-
9
- OPTIONAL category (CUT-01E). `brain cos-priority-map` generates the
10
- VM-readable priority map from your `type: person` / `type: company` notes;
11
- list ids below to override the computed placement. One list line per note:
12
-
13
- `- <note-id>: high|normal|low|exclude`
14
-
15
- <!-- examples (replace with your own note ids):
16
- - contoso-acquisition-lead: high
17
- - northwind-vendor-contact: low
18
- - retired-supplier-x: exclude
19
- -->
File without changes
File without changes
File without changes
File without changes