brainiac-cli 0.18.2__tar.gz → 0.19.1__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.1}/PKG-INFO +1 -1
  2. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/pyproject.toml +1 -1
  3. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/AGENTS.md +5 -1
  4. brainiac_cli-0.19.1/src/brain/_assets/overlay/template/cos/drafts.md +25 -0
  5. brainiac_cli-0.19.1/src/brain/_assets/overlay/template/cos/priorities.md +42 -0
  6. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/brain-brief.sh +8 -6
  7. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/brain-synthesis.sh +18 -2
  8. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_version.py +1 -1
  9. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/classification.py +25 -0
  10. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/cli.py +24 -2
  11. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/core.py +54 -2
  12. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/cos.py +183 -3
  13. brainiac_cli-0.19.1/src/brain/cos_chips.py +361 -0
  14. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/doctor.py +60 -0
  15. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/index.py +50 -1
  16. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/maintenance.py +70 -0
  17. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1/src/brainiac_cli.egg-info}/PKG-INFO +1 -1
  18. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brainiac_cli.egg-info/SOURCES.txt +2 -0
  19. brainiac_cli-0.18.2/src/brain/_assets/overlay/template/cos/priorities.md +0 -19
  20. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/LICENSE +0 -0
  21. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/MANIFEST.in +0 -0
  22. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/README.md +0 -0
  23. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/setup.cfg +0 -0
  24. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/__init__.py +0 -0
  25. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/__main__.py +0 -0
  26. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/overlay/template/brand/brand-guide.md +0 -0
  27. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/overlay/template/keywords/glossary.md +0 -0
  28. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/overlay/template/people/roster.md +0 -0
  29. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/overlay/template/voice/voice-profile.md +0 -0
  30. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/routines/manifest.json +0 -0
  31. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/brain-brief-mac.plist +0 -0
  32. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/brain-synthesis-mac.plist +0 -0
  33. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/install-brief-mac.sh +0 -0
  34. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/install-brief-windows.ps1 +0 -0
  35. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/scripts/register_tasks.py +0 -0
  36. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/company.md +0 -0
  37. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/concept.md +0 -0
  38. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/daily.md +0 -0
  39. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/decision.md +0 -0
  40. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/meeting.md +0 -0
  41. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/person.md +0 -0
  42. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/project.md +0 -0
  43. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/_assets/templates/state-moc.md +0 -0
  44. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/anchor.py +0 -0
  45. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/audit.py +0 -0
  46. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/backup.py +0 -0
  47. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/brief.py +0 -0
  48. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/capture.py +0 -0
  49. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/chunk.py +0 -0
  50. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/config.py +0 -0
  51. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/connect.py +0 -0
  52. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/context.py +0 -0
  53. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/egress.py +0 -0
  54. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/embed.py +0 -0
  55. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/encryption.py +0 -0
  56. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/frontmatter.py +0 -0
  57. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/golden_probe.py +0 -0
  58. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/graph.py +0 -0
  59. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/graphify.py +0 -0
  60. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/inbox.py +0 -0
  61. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/__init__.py +0 -0
  62. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/__init__.py +0 -0
  63. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/base.py +0 -0
  64. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/docx.py +0 -0
  65. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/email.py +0 -0
  66. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/html.py +0 -0
  67. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/image.py +0 -0
  68. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/pdf.py +0 -0
  69. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/pptx.py +0 -0
  70. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/tables.py +0 -0
  71. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/text.py +0 -0
  72. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/xlsx.py +0 -0
  73. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/handlers/zip.py +0 -0
  74. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/pipeline.py +0 -0
  75. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/ingest/transcript.py +0 -0
  76. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/init.py +0 -0
  77. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/mcp_adapter.py +0 -0
  78. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/multihop.py +0 -0
  79. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/notes.py +0 -0
  80. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/overlay.py +0 -0
  81. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/projection.py +0 -0
  82. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/rerank.py +0 -0
  83. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/retro.py +0 -0
  84. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/snapshot.py +0 -0
  85. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/spine.py +0 -0
  86. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/update.py +0 -0
  87. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brain/vectors.py +0 -0
  88. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brainiac_cli.egg-info/dependency_links.txt +0 -0
  89. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brainiac_cli.egg-info/entry_points.txt +0 -0
  90. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/src/brainiac_cli.egg-info/requires.txt +0 -0
  91. {brainiac_cli-0.18.2 → brainiac_cli-0.19.1}/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.1
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.1"
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 +
@@ -28,7 +28,10 @@ mkdir -p "$LOG_DIR"
28
28
  LOG="$LOG_DIR/synthesis-$(date +%F).log"
29
29
  REGISTRY="${BRAIN_WORKSPACES_JSON:-$HOME/.brainiac/workspaces.json}"
30
30
  CLAUDE_BIN="${BRAIN_CLAUDE_BIN:-$(command -v claude || echo "$HOME/.local/bin/claude")}"
31
- MAX_TURNS="${BRAIN_SYNTHESIS_MAX_TURNS:-60}"
31
+ # 120, not 60: the 2026-07-19 08:00 run died error_max_turns at 61 while the
32
+ # 22:26 rerun needed 96 turns for the same scope — 60 cannot fit a real
33
+ # synthesis pass over a week's transcript batch.
34
+ MAX_TURNS="${BRAIN_SYNTHESIS_MAX_TURNS:-120}"
32
35
 
33
36
  log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
34
37
 
@@ -95,17 +98,30 @@ PY
95
98
  # A per-run unique name (date + pid + two $RANDOM draws) avoids the
96
99
  # same-second collision the old date-second+pid name allowed (finding [9]).
97
100
  OUT_JSON="$LOG_DIR/synthesis-out-$(date +%F)-$$-${RANDOM}${RANDOM}.json"
101
+ # Allowlist covers the read-only diagnostics the watchdog prompt itself
102
+ # requires (logs, state files, index probes) — the 2026-07-19 runs burned
103
+ # 10+ turns on ls/cat/find/sqlite3/python3 denials. `Bash(python3 *)` is
104
+ # not a widening in practice: Write + `Bash(python3 tools/*)` already
105
+ # allowed arbitrary code by writing a file into tools/ first.
98
106
  ( cd "$WS" && BRAIN_VAULT="$VAULT" "$CLAUDE_BIN" -p "$PROMPT" \
99
107
  --max-turns "$MAX_TURNS" \
100
108
  --permission-mode acceptEdits \
101
109
  --output-format stream-json \
102
110
  --verbose \
103
- --allowedTools "Read,Grep,Glob,Edit,Write,Bash(brain *),Bash(python3 tools/*)" \
111
+ --allowedTools "Read,Grep,Glob,Edit,Write,Bash(brain *),Bash(python3 *),Bash(ls *),Bash(cat *),Bash(head *),Bash(tail *),Bash(find *),Bash(grep *),Bash(wc *),Bash(sed *),Bash(sort *),Bash(uniq *),Bash(file *),Bash(diff *),Bash(sqlite3 *)" \
104
112
  ) < /dev/null > "$OUT_JSON" 2>>"$LOG"
105
113
  # < /dev/null: claude -p hangs forever reading a non-TTY stdin — and inside
106
114
  # this while-read loop it would otherwise inherit (and eat) the registry pipe
107
115
  RC=$?
108
116
  log "END synthesis: vault=$VAULT rc=$RC"
117
+ # Immediate failure ping (owner ask 2026-07-19): WATCHDOG-01 only trips at
118
+ # >8 days stale, which let two consecutive Sunday failures hide for two
119
+ # weeks. Best-effort banner, never fails the run; the durable channels are
120
+ # the log line above + synthesis-state.json (read by the session-start
121
+ # alerts hook).
122
+ if [ "$RC" -ne 0 ] && command -v osascript >/dev/null 2>&1; then
123
+ osascript -e "display notification \"weekly synthesis FAILED (rc=$RC) for $VAULT — see $LOG\" with title \"Brainiac health\"" >/dev/null 2>&1 || true
124
+ fi
109
125
  # Render a human-readable transcript from the structured NDJSON into $LOG
110
126
  # (finding [5]): the prior `tee`'d raw NDJSON REPLACED the readable session
111
127
  # transcript the weekly-watchdog prompt tells an operator to open. Structured
@@ -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.1"
@@ -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.")
@@ -2125,19 +2125,59 @@ class BrainCore:
2125
2125
  """Append ``entry_md`` to ``hot.md`` guarded by an idempotency-key
2126
2126
  HTML comment; a no-op (returns ``False``) if the key is already
2127
2127
  present — the per-branch/per-run-date idempotency guard for every
2128
- scheduled fold that queues a hot-queue entry (HARDENED:codex)."""
2128
+ scheduled fold that queues a hot-queue entry (HARDENED:codex).
2129
+
2130
+ Two shared gates live HERE so every fold renderer inherits them:
2131
+ (1) vault-absolute paths are rewritten vault-relative (retro
2132
+ signature ``absolute-paths`` — an absolute path in fold output goes
2133
+ stale on a vault move); (2) the idempotency check also scans rotated
2134
+ ``archive/hot-*.md`` segments, so an entry rotated out by
2135
+ ``_rotate_hot_md`` is never re-appended under the same key."""
2136
+ import os as _os
2129
2137
  path = self._hot_md_path()
2130
2138
  path.parent.mkdir(parents=True, exist_ok=True)
2139
+ for root in {str(self.vault), str(Path(self.vault).resolve())}:
2140
+ entry_md = entry_md.replace(root.rstrip(_os.sep) + _os.sep, "")
2131
2141
  marker = f"<!-- idempotency-key: {key} -->"
2132
2142
  existing = path.read_text(encoding="utf-8") if path.exists() else ""
2133
2143
  if marker in existing:
2134
2144
  return False
2145
+ # ponytail: linear scan of rotated segments; index them if they ever
2146
+ # number in the hundreds.
2147
+ for seg in sorted((path.parent / "archive").glob("hot-*.md")):
2148
+ try:
2149
+ if marker in seg.read_text(encoding="utf-8"):
2150
+ return False
2151
+ except OSError:
2152
+ continue
2135
2153
  with path.open("a", encoding="utf-8") as fh:
2136
2154
  if existing and not existing.endswith("\n"):
2137
2155
  fh.write("\n")
2138
2156
  fh.write(marker + "\n" + entry_md.rstrip("\n") + "\n\n")
2139
2157
  return True
2140
2158
 
2159
+ def _rotate_hot_md(self, today: Any) -> bool:
2160
+ """Rotate aged, resolved hot.md entries into ``archive/hot-<date>.md``
2161
+ once the live file exceeds the soft cap (retro signature
2162
+ ``hot-md-bloat``) — same auto-rotate posture handoff.md already has.
2163
+ Pure judgment lives in ``maintenance.rotate_hot_md``; this method is
2164
+ the file I/O. Returns True when a rotation happened."""
2165
+ from . import maintenance as maint
2166
+ path = self._hot_md_path()
2167
+ if not path.exists():
2168
+ return False
2169
+ kept, rotated = maint.rotate_hot_md(
2170
+ path.read_text(encoding="utf-8"), today)
2171
+ if not rotated:
2172
+ return False
2173
+ archive_dir = path.parent / "archive"
2174
+ archive_dir.mkdir(parents=True, exist_ok=True)
2175
+ seg = archive_dir / f"hot-{today.isoformat()}.md"
2176
+ with seg.open("a", encoding="utf-8") as fh:
2177
+ fh.write(rotated)
2178
+ path.write_text(kept, encoding="utf-8")
2179
+ return True
2180
+
2141
2181
  # -- Tier-2 owner question queue (PUSH redesign, 2026-07-13) --------------
2142
2182
  def _inbox_path(self) -> Path:
2143
2183
  return config.memory_dir(self.vault) / "inbox.jsonl"
@@ -2970,8 +3010,13 @@ class BrainCore:
2970
3010
  curate_res["stale_links"], curate_res["revisit_sample"], d),
2971
3011
  )
2972
3012
  if promote_res.get("candidates"):
3013
+ # Idempotency key = hash of the DISTINCT candidate-id
3014
+ # set, NOT the run date — an unchanged candidate set
3015
+ # isn't re-reported every run under a fresh key
3016
+ # (retro signature: duplicate-findings).
2973
3017
  self._append_hot_once(
2974
- f"maintain:promote-scan:{d.isoformat()}",
3018
+ "maintain:promote-scan:"
3019
+ + maint.promote_scan_finding_key(promote_res["candidates"]),
2975
3020
  maint.render_promote_scan_hot_entry(promote_res["candidates"], d),
2976
3021
  )
2977
3022
  results["digest_html"] = self.digest_html(days=7, today=d)
@@ -3371,6 +3416,13 @@ class BrainCore:
3371
3416
  results["notifications"] = notifications
3372
3417
 
3373
3418
  if not dry_run:
3419
+ # hot-md-bloat: rotate aged/resolved entries to archive/ once
3420
+ # the live file exceeds the soft cap. Best-effort hygiene —
3421
+ # never allowed to fail the maintain run.
3422
+ try:
3423
+ self._rotate_hot_md(d)
3424
+ except Exception: # noqa: BLE001
3425
+ pass
3374
3426
  # In-process graphify (owner decision 2026-07-11): the
3375
3427
  # `_graphify_drift` marker is written by `_run_bounded_graphify`
3376
3428
  # within THIS process, into THIS `state` dict, so `state` already
@@ -963,6 +963,132 @@ 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
+ # Legacy VM-drop key scheme (pre-canonical-msg_key): `content-rejoin:<sender>|
981
+ # <subject>` rows join 0/10 to any verdict row's msg_key (field evidence,
982
+ # behaviour-r12.jsonl 2026-07-18) — grading them double-counts mail that is
983
+ # already graded under its canonical key, so they are excluded from BOTH the
984
+ # numerator and denominator of the consistency/contradiction rates.
985
+ LEGACY_REJOIN_PREFIX = "content-rejoin:"
986
+
987
+
988
+ def grade_behaviour(bucket: str, observed: str, *,
989
+ auto_archived: bool = False) -> str:
990
+ """One verdict row + one observed behaviour -> a grade.
991
+
992
+ The error that matters for auto-archive is exactly one: "would have
993
+ archived mail the owner needed". So a NOISE verdict the owner then replied
994
+ to or flagged is `contradicted` — the hard, gate-relevant failure. A noise
995
+ row he merely opened is `read_anyway`: a weak signal (people open
996
+ newsletters), reported but never gated on. Noise he left untouched or
997
+ archived himself is `consistent`. An act/read row he archived without
998
+ engaging is `overcalled` — over-caution, harmless, informational.
999
+ """
1000
+ b, o = str(bucket).lower(), str(observed).lower()
1001
+ # Aged-read lane (owner policy 2026-07-17): priority-list mail may be
1002
+ # auto-archived when read + no-action + >7d old. Those rows are bucket
1003
+ # `read`, not `noise` — so the drift contradiction must key on the ACTION
1004
+ # (we auto-archived it), not the bucket: the owner replying to or flagging
1005
+ # ANY row we auto-archived is the gate error, whichever lane moved it.
1006
+ if auto_archived and o in ("owner_replied", "owner_flagged"):
1007
+ return "contradicted"
1008
+ if b == "noise":
1009
+ if o in ("owner_replied", "owner_flagged"):
1010
+ return "contradicted"
1011
+ if o == "owner_read":
1012
+ return "read_anyway"
1013
+ if o in ("untouched", "owner_archived"):
1014
+ return "consistent"
1015
+ elif b in ("act", "read") and o == "owner_archived":
1016
+ return "overcalled"
1017
+ return "neutral"
1018
+
1019
+
1020
+ def behaviour_entries(vault) -> list[dict[str, Any]]:
1021
+ """Raw behaviour observations from the VM drop (``behaviour-*.jsonl``),
1022
+ deduped by (round, msg_key) — last write wins, same idempotency shape as
1023
+ the shadow ledger. Rows are VM-authored and untrusted: consumed as data."""
1024
+ vdir = verdict_drop_dir(vault)
1025
+ files = sorted(vdir.glob("behaviour-*.jsonl")) if vdir.is_dir() else []
1026
+ by_key: dict[tuple[int, str], dict[str, Any]] = {}
1027
+ for f in files:
1028
+ for e in _read_jsonl(f):
1029
+ r, k = e.get("round"), e.get("msg_key")
1030
+ if isinstance(r, int) and isinstance(k, str):
1031
+ by_key[(r, k)] = e
1032
+ return list(by_key.values())
1033
+
1034
+
1035
+ def behaviour_report(vault) -> dict[str, Any]:
1036
+ """Aggregate observed-behaviour evidence: per-bucket grade counts, the
1037
+ noise-safety numbers an auto-archive re-arm decision needs, and the
1038
+ owner's own archive patterns (top senders he archives himself — evidence
1039
+ for FUTURE noise-signals, never an actuator by itself)."""
1040
+ entries = behaviour_entries(vault)
1041
+ # Exclusion (2026-07-18 field report): legacy `content-rejoin:` keys — and,
1042
+ # when a shadow ledger exists, any row whose msg_key joins no verdict —
1043
+ # never enter the rates. No ledger at all ⇒ only the legacy scheme is
1044
+ # excludable (can't prove a join miss against nothing).
1045
+ ledger = known_ledger_keys(vault)
1046
+ verdict_keys = {k for _, k in ledger} if ledger else None
1047
+ excluded = 0
1048
+ joined: list[dict[str, Any]] = []
1049
+ for e in entries:
1050
+ k = str(e.get("msg_key", ""))
1051
+ if k.startswith(LEGACY_REJOIN_PREFIX) or (
1052
+ verdict_keys is not None and k not in verdict_keys):
1053
+ excluded += 1
1054
+ continue
1055
+ joined.append(e)
1056
+ entries = joined
1057
+ per_bucket: dict[str, dict[str, int]] = {}
1058
+ contradicted_rows: list[dict[str, Any]] = []
1059
+ owner_archive_patterns: dict[str, int] = {}
1060
+ rounds: set[int] = set()
1061
+ for e in entries:
1062
+ b = str(e.get("bucket", "?")).lower()
1063
+ o = str(e.get("observed", "?")).lower()
1064
+ g = grade_behaviour(b, o, auto_archived=bool(e.get("auto_archived")))
1065
+ per_bucket.setdefault(b, {})[g] = per_bucket.setdefault(b, {}).get(g, 0) + 1
1066
+ rounds.add(int(e["round"]))
1067
+ if g == "contradicted":
1068
+ contradicted_rows.append(
1069
+ {k: e.get(k) for k in ("round", "msg_key", "sender", "subject",
1070
+ "observed")})
1071
+ if o == "owner_archived":
1072
+ key = str(e.get("sender") or e.get("sender_domain") or "unknown").lower()
1073
+ owner_archive_patterns[key] = owner_archive_patterns.get(key, 0) + 1
1074
+ noise = per_bucket.get("noise", {})
1075
+ noise_observed = sum(noise.values())
1076
+ contradicted = noise.get("contradicted", 0)
1077
+ return {
1078
+ "observations": len(entries),
1079
+ "excluded_unjoined": excluded,
1080
+ "rounds_observed": len(rounds),
1081
+ "per_bucket": per_bucket,
1082
+ "noise_observed": noise_observed,
1083
+ "noise_contradicted": contradicted,
1084
+ "noise_consistency": (round((noise_observed - contradicted) / noise_observed, 4)
1085
+ if noise_observed else None),
1086
+ "contradicted_rows": contradicted_rows[:20],
1087
+ "owner_archive_patterns": dict(sorted(owner_archive_patterns.items(),
1088
+ key=lambda kv: -kv[1])[:20]),
1089
+ }
1090
+
1091
+
966
1092
  def calibration_report(vault) -> dict[str, Any]:
967
1093
  """Shadow-mode trust-gate report: calibration = reduce(verdicts,
968
1094
  correction_events). A verdict is bucket-correct when no correction exists
@@ -998,6 +1124,10 @@ def calibration_report(vault) -> dict[str, Any]:
998
1124
  "overall_bucket_precision": (round(bucket_correct / total, 4)
999
1125
  if total else None),
1000
1126
  "per_bucket": buckets,
1127
+ # revealed preference alongside stated preference: the corrections
1128
+ # count above stays authoritative where it exists, but 0 corrections
1129
+ # no longer means 0 evidence.
1130
+ "behaviour": behaviour_report(vault),
1001
1131
  }
1002
1132
 
1003
1133
 
@@ -1451,7 +1581,18 @@ def _is_keeper_counterparty(vault, counterparty: str | None) -> bool:
1451
1581
  if not counterparty:
1452
1582
  return False
1453
1583
  overrides = load_priority_overrides(vault)
1454
- return overrides.get(str(counterparty).lower()) == "high"
1584
+ name = str(counterparty).lower()
1585
+ if overrides.get(name) == "high":
1586
+ return True
1587
+ # Override keys are NOTE-ID SLUGS (the only form _OVERRIDE_LINE_RE parses),
1588
+ # but a commitment's counterparty is a display name from mail — e.g. a name
1589
+ # like "Renée Dûval" could never equal "renee-duval", so keeper detection
1590
+ # silently never fired (found 2026-07-17, the day the first real roster was
1591
+ # written). Compare in slug space, accents folded.
1592
+ import unicodedata
1593
+ folded = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
1594
+ slug = re.sub(r"[^a-z0-9]+", "-", folded).strip("-")
1595
+ return overrides.get(slug) == "high"
1455
1596
 
1456
1597
 
1457
1598
  def _spine_ingest_commitment(vault, meta: dict[str, Any], *, source_ref: str,
@@ -1527,6 +1668,13 @@ DEFAULT_INGEST_SWEEP_MAX_BYTES = 200 * 1024 * 1024
1527
1668
  INGEST_SWEEP_DOWNLOADS_ENV = "BRAIN_COS_DOWNLOADS_DIR"
1528
1669
  INGEST_SWEEP_SKEW_SECONDS = 300 # manifest ts vs file mtime clock skew
1529
1670
  INGEST_SWEEP_SIZE_TOLERANCE = 0.10 # when the manifest carries a size
1671
+ # Host-observed freshness floor (codex 2026-07-19): a candidate whose mtime is
1672
+ # older than this relative to the sweep's OWN clock is a pre-existing host file,
1673
+ # not something the VM just downloaded — the un-forgeable provenance anchor. The
1674
+ # maintain umbrella fires hourly; a 6h window tolerates a few missed runs / a
1675
+ # sleeping Mac while still excluding files that have sat in Downloads for days.
1676
+ INGEST_SWEEP_RECENCY_ENV = "BRAIN_COS_SWEEP_RECENCY_SECONDS"
1677
+ DEFAULT_INGEST_SWEEP_RECENCY_SECONDS = 6 * 3600
1530
1678
 
1531
1679
 
1532
1680
  def ingest_manifest_dir(vault=None) -> Path:
@@ -1550,6 +1698,14 @@ def _sweep_max_bytes() -> int:
1550
1698
  return DEFAULT_INGEST_SWEEP_MAX_BYTES
1551
1699
 
1552
1700
 
1701
+ def _sweep_recency_seconds() -> int:
1702
+ try:
1703
+ return int(os.environ.get(INGEST_SWEEP_RECENCY_ENV,
1704
+ DEFAULT_INGEST_SWEEP_RECENCY_SECONDS))
1705
+ except ValueError:
1706
+ return DEFAULT_INGEST_SWEEP_RECENCY_SECONDS
1707
+
1708
+
1553
1709
  def _unique_dest(inbox: Path, filename: str) -> Path:
1554
1710
  dest = inbox / filename
1555
1711
  if not dest.exists():
@@ -1572,8 +1728,12 @@ def ingest_sweep(vault, *, downloads_dir: Path | str | None = None,
1572
1728
  - filenames are basename-only — any path separator / ``..`` is refused;
1573
1729
  - a symlinked candidate (or manifest file) is refused, never followed;
1574
1730
  - 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;
1731
+ - a candidate must be a FRESH download — mtime within the recency window of
1732
+ the sweep's own clock — so a VM manifest cannot claim a pre-existing host
1733
+ Downloads file it did not just download (codex 2026-07-19, VM/host
1734
+ boundary): the mtime is the one provenance signal the VM cannot forge;
1735
+ - a match requires exact filename, size within tolerance when the manifest
1736
+ carries one, and mtime newer than the manifest ts minus skew;
1577
1737
  - claims are append-only, one per manifest line — a re-run never
1578
1738
  double-moves (idempotent);
1579
1739
  - NOTHING the manifest does not name is ever touched, moved, or deleted.
@@ -1653,6 +1813,26 @@ def ingest_sweep(vault, *, downloads_dir: Path | str | None = None,
1653
1813
  report["refused"].append({"filename": fname,
1654
1814
  "reason": "over size cap"})
1655
1815
  continue
1816
+ # SECURITY (codex 2026-07-19, VM/host boundary): the manifest is
1817
+ # VM-writable, so its own fields cannot prove provenance — the VM
1818
+ # can name any basename and BACKDATE `ts` to defeat the staleness
1819
+ # check on an old host file. The one thing a VM-side attacker CANNOT
1820
+ # forge is a host file's mtime, so the sweep's OWN clock is the
1821
+ # anchor: only a file whose mtime is recent relative to `now` — i.e.
1822
+ # one genuinely downloaded within this maintenance window — is
1823
+ # eligible. A guessed sensitive file that has been sitting in
1824
+ # ~/Downloads (tax.pdf, statement.pdf …) is refused regardless of
1825
+ # what the manifest claims. Widen the window via env for slow hosts.
1826
+ age = now.timestamp() - cand.stat().st_mtime
1827
+ if age > _sweep_recency_seconds():
1828
+ report["unmatched"].append(fname)
1829
+ report.setdefault("unmatched_reasons", []).append({
1830
+ "filename": fname,
1831
+ "reason": f"not a fresh download: host mtime is {age / 3600.0:.1f}h "
1832
+ f"old (recency window {_sweep_recency_seconds() // 3600}h) "
1833
+ f"— a pre-existing host file the VM manifest cannot claim",
1834
+ })
1835
+ continue
1656
1836
  want = entry.get("approx_size_bytes")
1657
1837
  if isinstance(want, int) and want > 0:
1658
1838
  if abs(size - want) > max(want * INGEST_SWEEP_SIZE_TOLERANCE, 4096):