switchroom 0.21.15 → 0.21.16

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 (25) hide show
  1. package/bin/rules-sentinel-hook.sh +101 -0
  2. package/dist/agent-scheduler/index.js +1 -0
  3. package/dist/auth-broker/index.js +1 -0
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1924 -841
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +1 -1
  10. package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
  11. package/skills/mental-model-curator/SKILL.md +187 -56
  12. package/telegram-plugin/dist/gateway/gateway.js +5 -4
  13. package/vendor/hindsight-memory/hooks/hooks.json +10 -0
  14. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
  15. package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
  16. package/vendor/hindsight-memory/scripts/recall.py +185 -0
  17. package/vendor/hindsight-memory/scripts/retain.py +132 -49
  18. package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
  19. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
  20. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
  21. package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
  22. package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
  23. package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
  24. package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
@@ -11143,6 +11143,7 @@ var init_schema = __esm(() => {
11143
11143
  }
11144
11144
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
11145
11145
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
11146
+ rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
11146
11147
  disposition: exports_external.object({
11147
11148
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
11148
11149
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -21592,7 +21593,7 @@ function allocateAgentUid(name) {
21592
21593
  }
21593
21594
 
21594
21595
  // src/build-info.ts
21595
- var VERSION = "0.21.15";
21596
+ var VERSION = "0.21.16";
21596
21597
 
21597
21598
  // src/setup/hindsight-recall-passthrough.ts
21598
21599
  var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
@@ -4583,6 +4583,7 @@ var init_schema = __esm(() => {
4583
4583
  }
4584
4584
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
4585
4585
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
4586
+ rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
4586
4587
  disposition: exports_external.object({
4587
4588
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
4588
4589
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -4179,6 +4179,7 @@ var init_schema = __esm(() => {
4179
4179
  }
4180
4180
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
4181
4181
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
4182
+ rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
4182
4183
  disposition: exports_external.object({
4183
4184
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
4184
4185
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.21.15",
4
+ "version": "0.21.16",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -7,3 +7,5 @@ Acting in-turn and delegating are the same move: for an execution-class task the
7
7
  If the user amends in-flight delegated work mid-turn, steer the running worker now (`SendMessage` by worker name or agent id) rather than holding it for handback — and say in your reply which you did ("folded your update into the running worker" / "queued as a separate task"); never classify silently, never inferred. Unsure? Queue it and say so — queue is the default. If the worker is effectively done and the steer lands too late, say so and apply the update yourself in the parent.
8
8
 
9
9
  If no sub-agents are configured, do the work yourself.
10
+
11
+ A worker has no auto-recall of its own — it can only PULL its own bank read-only (`mcp__hindsight__recall` / `get_mental_model` / `get_knowledge_tree` / `get_knowledge_page` / `search_knowledge_pages`; M6/E-86). When you dispatch, put the orientation you already hold — the relevant roster, bank names, or prior findings — into the Task prompt as your deliberate context selection. Share orientation, not secrets: never paste a credential or another agent's private fact into a dispatch prompt.
@@ -9,11 +9,12 @@ description: >
9
9
  "review my memory bank", "what standing models should I have", "bootstrap
10
10
  mental models", "suggest mental models from my bank", "audit my mental models",
11
11
  "what recurring questions do I keep re-deriving", and typos like "propose
12
- knowlege models". ALSO owns the directive merge/retire pass — triggers like
13
- "audit my directives", "merge my directives", "retire stale directives", "my
14
- directives are overlapping", "too many active directives", "clean up my
15
- guardrails", and when switchroom doctor FLAGs a bank over the directive WARN/FAIL
16
- threshold. Do NOT use for storing a single fact/preference/decision —
12
+ knowlege models". ALSO owns the directive TRIAGE pass (Memory v2 M2) —
13
+ triggers like "audit my directives", "triage my directives", "merge my
14
+ directives", "retire stale directives", "my directives are overlapping", "too
15
+ many active directives", "clean up my guardrails", and when switchroom doctor
16
+ FLAGs a bank over the directive WARN/FAIL threshold. Do NOT use for storing a
17
+ single fact/preference/decision —
17
18
  that is retain, not a mental model. Do NOT use for identity or "who is the
18
19
  user" — that lives in profile banks, so never propose an identity model here
19
20
  (the operator's review of the card, not any code guard, is the only gate — so
@@ -22,7 +23,7 @@ description: >
22
23
  whose shape you know, PROPOSE it through the approve/deny card — never call
23
24
  create_mental_model directly (it is not pre-approved; the propose card is the
24
25
  only sanctioned write path, Fix 1.2 / #2903).
25
- allowed-tools: mcp__hindsight__list_banks mcp__hindsight__get_bank_stats mcp__hindsight__list_mental_models mcp__hindsight__get_mental_model mcp__hindsight__list_directives mcp__hindsight__reflect mcp__hindsight__recall mcp__switchroom-telegram__mental_model_propose
26
+ allowed-tools: mcp__hindsight__list_banks mcp__hindsight__get_bank_stats mcp__hindsight__list_mental_models mcp__hindsight__get_mental_model mcp__hindsight__list_directives mcp__hindsight__deactivate_directive mcp__hindsight__reactivate_directive mcp__hindsight__reflect mcp__hindsight__recall mcp__switchroom-telegram__mental_model_propose Bash(switchroom memory directive mark-rules-block *)
26
27
  ---
27
28
 
28
29
  # mental-model-curator — Propose standing knowledge models from your own bank
@@ -58,9 +59,11 @@ models. Propose the few that are earned; let the operator ratify.
58
59
  (name + source_query + one-line reason) and STOP. Do NOT call
59
60
  `mental_model_propose` in this mode. This is the safe way to run against any
60
61
  bank.
61
- - **Directive merge/retire pass:** a separate job — see "Directive merge/retire
62
- pass" below. It is always proposal-only (text out; the operator taps to enact),
63
- and runs independently of the mental-model modes above.
62
+ - **Directive triage pass:** a separate job — see "Directive triage pass (Memory
63
+ v2 M2)" below. It renders one consolidated card as text, then requires an
64
+ explicit operator go-ahead before ANY deactivation executes — never enacts on
65
+ the same turn the card is shown — and runs independently of the mental-model
66
+ modes above.
64
67
 
65
68
  ## Workflow
66
69
 
@@ -152,23 +155,87 @@ empty topic at 3am is useless and noisy. If invoked from a scheduled sweep,
152
155
  the next interactive turn instead of firing cards unattended. Treat live-turn,
153
156
  operator-present proposing as the only supported path for now.
154
157
 
155
- ## Directive merge/retire pass
156
-
157
- A second, independent job of this skill: keep the bank's **active directives**
158
- lean. Directives are hard rules applied on every `reflect` — but the bank caps
159
- active directives at **`MAX_DIRECTIVES=30`**. Past the cap, the lowest-priority
160
- directives are **truncated** from the `<active_directives>` recall block and never
161
- reach the agent (recorded as `directives_omitted` on the recall_log row — the
162
- recall hook's stderr warning is swallowed by Claude Code, so don't look there) — so an
163
- overloaded, overlapping, or stale directive set doesn't just add noise, it drops
164
- your real guardrails. Fleet doctor WARNs at >24 active and FAILs at >30
165
- (workstream C2), and its fix text points
166
- here — this pass is the durable path it names.
167
-
168
- **You PROPOSE; you never delete.** `delete_directive` is deliberately NOT
169
- pre-approved (#2911) — it falls through to a per-call operator approval card, and
170
- that human tap is the only gate that retires a standing guardrail. So this pass
171
- outputs a plan; it does not enact retirements itself.
158
+ ## Directive triage pass (Memory v2 M2)
159
+
160
+ A second, independent job of this skill: sort the bank's **active directives**
161
+ into E-45's five categories and clear the four heavy banks off the WARN line.
162
+ Directives are hard rules applied on every `reflect` — but the bank caps active
163
+ directives at **`MAX_DIRECTIVES=30`**. Past the cap, the lowest-priority
164
+ directives are **truncated** from the `<active_directives>` recall block and
165
+ never reach the agent (recorded as `directives_omitted` on the recall_log row
166
+ — the recall hook's stderr warning is swallowed by Claude Code, so don't look
167
+ there). Fleet doctor WARNs at >24 active and FAILs at >30 (`doctor-memory.ts`,
168
+ `DIRECTIVE_WARN_THRESHOLD`), and its fix text points here.
169
+
170
+ **Honest framing (do not oversell):** as of the M2 measurement pass, NOTHING is
171
+ currently being silently dropped fleet-wide — zero directives have ever been
172
+ retired, and `directives_omitted` has fired zero times. This is a **budget +
173
+ hygiene** pass, not a rescue from silent drops. Say it that way; overstating the
174
+ urgency is its own failure.
175
+
176
+ **The five categories every active directive sorts into (E-45):**
177
+
178
+ | Category | Meaning | What happens in THIS pass |
179
+ |---|---|---|
180
+ | `reflect-directive` | A genuine compliance guardrail (§4.2's designed role) | KEEP — no change |
181
+ | `rules-block` | A standing preference that belongs in the CLAUDE.md rules block (M1) | **STAGE ONLY — stays ACTIVE.** See the hard rule below. |
182
+ | `disposition` | Better expressed as `disposition_*` bank config (E-40) | Deactivate; note the config change separately for the operator — do NOT auto-apply it |
183
+ | `retire` | Superseded or mechanized — dead weight | Deactivate (record `superseded_by` when a winner exists) |
184
+ | `retain-as-memory` | A fact filed as a directive by mistake (category error) | Deactivate — verify the fact already exists as a memory in the bank BEFORE proposing this; don't assert it, check it |
185
+
186
+ **Retirement signals are deterministic and bounded (E-45).** A directive is
187
+ ONLY a retirement candidate when you can point to one of: (a) it is tagged
188
+ `superseded-by:<name>` (or you would tag it that way, naming the winner), or
189
+ (b) a mechanization reference (a named commit/PR/scaffold rule that now does
190
+ this automatically), or (c) a documented category error you can verify against
191
+ the bank's own memories/config. **Citation counts are NEVER a signal** (a rule
192
+ that works quietly gets zero citations). **Age alone is NEVER sufficient.** A
193
+ directive with none of these signals is `reflect-directive` — KEEP — by
194
+ default. Never propose retiring a directive because it "seems old" or "looks
195
+ unused."
196
+
197
+ ### HARD RULE — never deactivate a `rules-block`-category directive in this pass
198
+
199
+ If a directive's behaviour is destined for the CLAUDE.md rules block (M1), its
200
+ migration happens at **M3 flip time**, when that surface goes live for this
201
+ agent — not now. M1 shipped dark (`memory.rules_block` off on every agent
202
+ today); deactivating a `rules-block`-category directive before that flip would
203
+ leave the agent with **neither** the directive (deactivated) **nor** the rule
204
+ (block not live) — a silent guardrail gap for however long the M2→M3 interval
205
+ runs. So: **for every directive you categorise `rules-block`, call
206
+ `list_directives` to confirm it, but do NOT call `deactivate_directive` on it
207
+ in this pass, ever** — only stage its text (mention it in the card as staged,
208
+ count it, and leave it exactly as-is) for the M3 flip to pick up later
209
+ (`rule add`, once `memory.rules_block` is on for this agent).
210
+
211
+ **This is not just a prose rule you have to remember — it is now also code-
212
+ enforced, but ONLY once you complete the mandatory step below.**
213
+ `DirectiveAdmin` (`src/memory/hindsight-directive-admin.ts` —
214
+ `deactivate`/`deactivateById`/`deactivateByIdWithTag`) refuses to deactivate
215
+ ANY directive carrying the persisted `triage-category:rules-block` marker
216
+ tag, on every call path, including this skill's own `deactivate_directive`
217
+ calls. But that refusal only ever fires for a directive that actually
218
+ CARRIES the tag — and nothing stamps the tag automatically just because you,
219
+ in your own reasoning, decided a directive is `rules-block`. **You must
220
+ stamp it yourself, immediately, as part of classification — not as an
221
+ afterthought before presenting the card:**
222
+
223
+ > For every directive you classify `rules-block` in step 2 below, before you
224
+ > move on to the next directive, run:
225
+ > ```
226
+ > switchroom memory directive mark-rules-block <agent> <id>
227
+ > ```
228
+ > (`src/cli/memory-directive.ts` — this calls the exact same
229
+ > `DirectiveAdmin.markRulesBlock` write the batch triage executor uses, so
230
+ > the chokepoint above keys off it identically). Idempotent — re-running it
231
+ > on an already-marked directive is a no-op, so it is always safe to call
232
+ > even if you are not sure whether a prior pass already marked this one.
233
+
234
+ Do this for EVERY rules-block row before step 3's card renders, not just the
235
+ ones you personally plan to leave alone — the whole point is that the code
236
+ guard, not your own discipline in this pass or a future one, is what refuses
237
+ a later deactivation attempt (including a mistaken one, or one made by a
238
+ different pass that never saw this reasoning).
172
239
 
173
240
  ### When to run it
174
241
 
@@ -178,40 +245,104 @@ outputs a plan; it does not enact retirements itself.
178
245
 
179
246
  ### Workflow
180
247
 
181
- 1. **List the active set.** `mcp__hindsight__list_directives` (active only). If
182
- the count is comfortably under the WARN threshold (≤24) AND nothing reads
248
+ 1. **List every directive, active and inactive context.** `mcp__hindsight__list_directives`
249
+ (active only is fine for triage — inactive ones are already off). If the
250
+ active count is comfortably under the WARN threshold (≤24) AND nothing reads
183
251
  stale/overlapping, STOP and report "directive set is healthy (N active) —
184
- nothing to merge or retire." Don't manufacture churn.
185
- 2. **Cluster for overlap.** Group directives that encode substantially the same
186
- rule (even reworded) or that are strict specializations of a broader one. Each
187
- cluster of 2+ is a **merge candidate**: one crisp consolidated directive that
188
- subsumes the group, with the retirements it replaces named.
189
- 3. **Flag the stale.** A directive is a **retire candidate** when it's obsoleted
190
- by a newer rule, references a decision/tool/project that no longer exists, or
191
- contradicts a directive you're keeping. Contradictions are the priority — a
192
- live contradictory pair makes `reflect` nondeterministic.
193
- 4. **Present the plan as text — do NOT enact.** Output, ranked by how much it
194
- reduces the active count and risk:
195
- - **Merges:** the proposed consolidated directive text + the exact directives
196
- (by name/id) it would retire.
197
- - **Retirements:** each stale directive (name/id) + one-line reason.
198
- - The resulting active count vs `MAX_DIRECTIVES`, so the operator sees whether
199
- the plan clears the cap.
200
- Then STOP. The operator decides. Enacting a merge is a follow-up: the
201
- consolidated `create_directive` is autonomous, but **each** retirement fires
202
- its own `delete_directive` approval card — that per-card human tap is the
203
- deterministic backstop, not something this skill routes around.
252
+ nothing to triage." Don't manufacture churn.
253
+ 2. **Classify every directive — no drops.** For each one, assign exactly one of
254
+ the five categories above, with the deterministic signal that justifies it
255
+ (or "no signal — KEEP" for the default case). Do this for the FULL set —
256
+ the card is worthless if a directive silently doesn't appear on it.
257
+ **The moment you classify a directive `rules-block`, run
258
+ `switchroom memory directive mark-rules-block <agent> <id>` for it right
259
+ then** (see the HARD RULE above) — do not defer this to a later step or
260
+ batch it at the end; every rules-block row must be stamped before you move
261
+ to step 3.
262
+ 3. **Render ONE consolidated card as text.** Two sections, KEEP/staged first,
263
+ retirement candidates second — never interleaved, so a skim of "the bottom
264
+ half" cannot land on a live guardrail:
265
+ - **Keep / staged (N):** every `reflect-directive` and `rules-block` row,
266
+ each showing priority + category + signal (or "no signal — default KEEP").
267
+ - **Retirement candidates (N):** every `disposition` / `retire` /
268
+ `retain-as-memory` row, each showing priority + category + the
269
+ deterministic signal — never a bare category with no rationale.
270
+ - The resulting active count vs `MAX_DIRECTIVES` if the retirements land.
271
+ 4. **STOP. Get an explicit operator go-ahead before touching anything.** Do not
272
+ deactivate on the same turn the card is shown. The card is the operator's
273
+ one look before a batch of guardrails changes state.
274
+ 5. **On go-ahead, retire ONE AT A TIME with a human tap PER retirement — never
275
+ a single blanket go-ahead for the whole batch.** `deactivate_directive` is
276
+ pre-approved fleet-wide (`scaffold.ts`'s 2026-07-29 amendment,
277
+ `HINDSIGHT_MCP_TOOLS`) — unlike `delete_directive`, calling it does NOT
278
+ raise a Telegram permission card by itself. That is exactly why this skill
279
+ must supply the per-call gate `delete_directive`'s card would otherwise
280
+ have given: present the NEXT retirement candidate (name, priority, signal,
281
+ proposed `superseded_by`) on its own, then **STOP and end the turn** —
282
+ do not proceed to it, and do not queue the next one, until the operator's
283
+ reply for THIS ONE arrives. Only after an explicit per-row confirmation do
284
+ you call `mcp__hindsight__deactivate_directive` for that single directive.
285
+ Then present the next candidate the same way. A single "yes, go ahead" that
286
+ covers the whole retirement list is NOT sufficient — that collapses back
287
+ to the batch-level prose go-ahead this per-row loop exists to replace, and
288
+ silently trades away the human tap `delete_directive`'s approval card used
289
+ to guarantee for every one of these retirements (see PR #4760 review B2).
290
+ Pass `superseded_by` when you named a winner. After the LAST retirement in
291
+ the plan lands, `list_directives` again and report the new active count
292
+ against `MAX_DIRECTIVES`, plus which directives moved where.
293
+ 6. **Disposition rows are a note, not an auto-edit.** When you deactivate a
294
+ `disposition`-category directive, separately flag to the operator which
295
+ `disposition_*` bank config change it corresponds to — do NOT edit bank
296
+ config yourself in this pass (E-40: audit before leaning on it).
297
+
298
+ ### windows-boxes-class fixes (a drifted directive with a peer's superset text) — special-cased, rare
299
+
300
+ Do NOT attempt this as an ordinary retirement. If a directive on this bank has
301
+ drifted from a peer agent's superset/reconciled version of the "same" rule
302
+ (content differs but the two should say the same thing), that is NOT
303
+ expressible as a `content`-PATCH — the shim deliberately refuses to PATCH
304
+ directive `content` (`hindsight-directive-admin.ts` — a directive body is the
305
+ guardrail itself; there is no history table to recover a bad rewrite from).
306
+ It is also NOT safely expressible as plain `create_directive` +
307
+ `deactivate_directive` calls from this skill: once the new copy is created
308
+ carrying the SAME name, `deactivate_directive`'s name-resolution sees two
309
+ directives sharing that name and refuses as ambiguous. Use
310
+ `reconcileDirectiveSuperset` (`src/memory/directive-triage-executor.ts`) for
311
+ this instead — it resolves the old copy's id BEFORE creating the new one and
312
+ retires it by id, so the name collision never becomes an ambiguity error. This
313
+ is NOT a bare MCP tool call from this skill (no MCP tool exposes an
314
+ id-targeted deactivate) — it runs via
315
+ `switchroom memory directive reconcile <agent> <name> <content...>`
316
+ (`src/cli/memory-directive.ts`), an operator or repo-authorized CLI/script
317
+ invocation, not something this skill invokes autonomously. Flag any drift you
318
+ notice to the operator rather than improvising a fix.
204
319
 
205
320
  ### Directive-pass anti-patterns
206
321
 
207
- - ❌ Calling `delete_directive` from this skill — it proposes; the operator's tap
208
- on the per-call card is the only sanctioned retire path.
209
- - ❌ Merging directives that only look similar — a merge that drops a real
210
- distinction silently weakens a guardrail. When unsure, propose retire-nothing.
211
- - ❌ Manufacturing merges on a healthy small set just to "tidy" — run the pass
212
- when it's earned (bloat, overlap, staleness, or a doctor flag), not reflexively.
213
- - ❌ Leaving a live contradictory pair in place — surface it as the top retire
214
- candidate; it's what makes `reflect` nondeterministic.
322
+ - ❌ Deactivating a `rules-block`-category directive before M3 flips this
323
+ agent — opens a guardrail gap; see the hard rule above.
324
+ - ❌ Classifying a directive `rules-block` without immediately calling
325
+ `switchroom memory directive mark-rules-block <agent> <id>` for it — an
326
+ unmarked directive gets NO code-level protection from the
327
+ `DirectiveAdmin` chokepoint; the classification in your head is not
328
+ enough.
329
+ - ❌ Retiring on age or citation count alone — E-45 refuses both as signals.
330
+ - ❌ Calling `deactivate_directive` in parallel / without awaiting each one —
331
+ sequential only (tag-write race).
332
+ - ❌ Executing on the same turn the card is shown — the card review is the
333
+ gate; always stop for an explicit go-ahead first.
334
+ - ❌ Treating one blanket "yes, go ahead" as clearance for the whole
335
+ retirement list — `deactivate_directive` is pre-approved (no Telegram card
336
+ per call), so this skill's own per-retirement stop-and-confirm loop IS the
337
+ human tap; batching it away silently weakens the guardrail (review B2).
338
+ - ❌ Auto-editing bank config for a `disposition` row — flag it, don't apply it.
339
+ - ❌ Treating a drifted/superset-text directive as an ordinary retirement —
340
+ see the windows-boxes-class section above.
341
+ - ❌ Manufacturing retirements on a healthy small set just to "tidy" — run the
342
+ pass when it's earned (bloat, overlap, staleness, or a doctor flag), not
343
+ reflexively.
344
+ - ❌ Leaving a live contradictory pair in place — surface it as the top
345
+ retirement candidate; it's what makes `reflect` nondeterministic.
215
346
 
216
347
  ## Anti-patterns
217
348
 
@@ -21797,6 +21797,7 @@ var init_schema = __esm(() => {
21797
21797
  }
21798
21798
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry \u2014 e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced \u2014 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded \u2014 each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
21799
21799
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
21800
+ rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded \u2014 the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false \u21d2 byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded \u2014 each agent's flip is a deliberate M3 rollout step)."),
21800
21801
  disposition: exports_external.object({
21801
21802
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
21802
21803
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -105870,10 +105871,10 @@ function startOutboxSweep(deps) {
105870
105871
  }
105871
105872
 
105872
105873
  // ../src/build-info.ts
105873
- var VERSION2 = "0.21.15";
105874
- var COMMIT_SHA = "efa77e51";
105875
- var COMMIT_DATE = "2026-08-17T00:42:23Z";
105876
- var LATEST_PR = 4753;
105874
+ var VERSION2 = "0.21.16";
105875
+ var COMMIT_SHA = "d6c1267a";
105876
+ var COMMIT_DATE = "2026-08-17T09:16:43Z";
105877
+ var LATEST_PR = 4763;
105877
105878
  var COMMITS_AHEAD_OF_TAG = 0;
105878
105879
 
105879
105880
  // gateway/boot-version.ts
@@ -42,6 +42,16 @@
42
42
  "async": true
43
43
  }
44
44
  ]
45
+ },
46
+ {
47
+ "hooks": [
48
+ {
49
+ "type": "command",
50
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/prefetch.py\"",
51
+ "timeout": 20,
52
+ "async": true
53
+ }
54
+ ]
45
55
  }
46
56
  ],
47
57
  "SubagentStop": [
@@ -0,0 +1,236 @@
1
+ """M4 P0 — prefetch buffer + sentinel primitive.
2
+
3
+ The end-of-turn-N Stop hook (``prefetch.py``) writes a prefetched recall
4
+ block into a per-session buffer file, then writes a ``buffer.done`` sentinel
5
+ LAST. The start-of-turn-N+1 UserPromptSubmit hook (``recall.py``) polls for a
6
+ FRESH sentinel (bounded by ``poll_for_sentinel``'s ``cap_ms``) and, on a hit,
7
+ consumes the buffered block instead of running recall synchronously.
8
+
9
+ Read-after-write safety (the whole ballgame — carve §4 note 1):
10
+
11
+ * The payload is written via temp-file + atomic ``os.replace`` (POSIX
12
+ atomic within the same directory).
13
+ * The sentinel is written LAST, also via temp-file + ``os.fsync`` +
14
+ atomic ``os.replace``, and carries a MONOTONIC token (a `time.time_ns()`
15
+ counter, not wall-clock-comparable across machines but perfectly
16
+ ordered within one) — never content or mtime comparison, since two
17
+ writes CAN land in the same filesystem mtime tick.
18
+ * ``read_if_fresh`` treats "sentinel absent" OR "sentinel token not newer
19
+ than ``last_consumed_token``" as a miss (``None``). A torn write
20
+ (payload present, sentinel absent — e.g. the producer crashed between
21
+ the two writes) is INDISTINGUISHABLE from "no fresh buffer yet" by
22
+ construction, so it fails closed to ``None`` rather than serving a
23
+ possibly-incomplete/stale payload.
24
+
25
+ State dir: ``$HOME/.hindsight/prefetch-buffer/`` (override via
26
+ ``HINDSIGHT_PREFETCH_BUFFER_DIR`` for tests), mirroring the
27
+ ``lib/watermark.py`` convention (a distinct dir, not a shared one, so a
28
+ polling reader and a writing producer never contend for the same files as
29
+ unrelated retain-watermark state).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ import time
39
+ from typing import Optional
40
+
41
+ if sys.platform != "win32":
42
+ import fcntl
43
+ else: # pragma: no cover - switchroom agents are Linux only
44
+ fcntl = None
45
+
46
+ SCHEMA = 1
47
+
48
+ # Default poll behaviour (Hermes's 3s join, hand-rolled — P-REC/P-PRE tune
49
+ # the actual cap via config; this is the primitive's own conservative floor
50
+ # if a caller passes a cap smaller than one sleep tick).
51
+ _MIN_SLEEP_S = 0.02
52
+ _MAX_SLEEP_S = 0.1
53
+
54
+
55
+ def buffer_dir() -> str:
56
+ """Return the prefetch-buffer directory.
57
+
58
+ Override with ``HINDSIGHT_PREFETCH_BUFFER_DIR`` (tests). Default:
59
+ ``$HOME/.hindsight/prefetch-buffer/``.
60
+ """
61
+ override = os.environ.get("HINDSIGHT_PREFETCH_BUFFER_DIR")
62
+ if override:
63
+ return override
64
+ return os.path.join(os.path.expanduser("~"), ".hindsight", "prefetch-buffer")
65
+
66
+
67
+ def _ensure_dir() -> str:
68
+ d = buffer_dir()
69
+ if not os.path.isdir(d):
70
+ os.makedirs(d, mode=0o700, exist_ok=True)
71
+ else:
72
+ try:
73
+ mode = os.stat(d).st_mode & 0o777
74
+ if mode != 0o700:
75
+ os.chmod(d, 0o700)
76
+ except OSError:
77
+ pass
78
+ return d
79
+
80
+
81
+ def _safe_session(session_id: str) -> str:
82
+ """Sanitise a session id for use as a filename (no path traversal)."""
83
+ keep = [c if (c.isalnum() or c in "-_.") else "_" for c in (session_id or "unknown")]
84
+ name = "".join(keep)[:200]
85
+ return name.replace("..", "_") or "unknown"
86
+
87
+
88
+ def _buffer_path(session_id: str) -> str:
89
+ return os.path.join(buffer_dir(), f"{_safe_session(session_id)}.buffer.json")
90
+
91
+
92
+ def _sentinel_path(session_id: str) -> str:
93
+ return os.path.join(buffer_dir(), f"{_safe_session(session_id)}.buffer.done")
94
+
95
+
96
+ def write_buffer(session_id: str, context: str, telemetry: Optional[dict] = None) -> None:
97
+ """Write the prefetched recall payload for ``session_id``.
98
+
99
+ Atomic (temp file + ``os.replace`` within the same directory). Does NOT
100
+ write the sentinel — the caller MUST call ``write_sentinel`` after this,
101
+ and only once the payload write has returned, to preserve the
102
+ read-after-write ordering guarantee.
103
+ """
104
+ d = _ensure_dir()
105
+ final = _buffer_path(session_id)
106
+ tmp = final + f".tmp.{os.getpid()}"
107
+ entry = {
108
+ "schema": SCHEMA,
109
+ "session_id": session_id,
110
+ "context": context,
111
+ "telemetry": telemetry or {},
112
+ "written_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
113
+ }
114
+ with open(tmp, "w", encoding="utf-8") as f:
115
+ json.dump(entry, f, ensure_ascii=False)
116
+ f.flush()
117
+ os.fsync(f.fileno())
118
+ os.chmod(tmp, 0o600)
119
+ os.replace(tmp, final)
120
+
121
+
122
+ def write_sentinel(session_id: str) -> int:
123
+ """Write the ``buffer.done`` sentinel for ``session_id``, LAST.
124
+
125
+ Returns the monotonic token written (an ever-increasing integer derived
126
+ from ``time.time_ns()``, disambiguated against the previous token on
127
+ this session so two writes within the same nanosecond still order).
128
+ fsync'd before the atomic rename so a reader that observes the renamed
129
+ file is guaranteed to observe durable content.
130
+ """
131
+ d = _ensure_dir()
132
+ final = _sentinel_path(session_id)
133
+ token = time.time_ns()
134
+ # Guarantee strict monotonicity even under nanosecond-resolution ties or
135
+ # clock weirdness: never emit a token <= the previously written one.
136
+ prev = _read_sentinel_token(session_id)
137
+ if prev is not None and token <= prev:
138
+ token = prev + 1
139
+ tmp = final + f".tmp.{os.getpid()}"
140
+ with open(tmp, "w", encoding="utf-8") as f:
141
+ json.dump({"schema": SCHEMA, "token": token}, f)
142
+ f.flush()
143
+ os.fsync(f.fileno())
144
+ os.chmod(tmp, 0o600)
145
+ os.replace(tmp, final)
146
+ return token
147
+
148
+
149
+ def _read_sentinel_token(session_id: str) -> Optional[int]:
150
+ path = _sentinel_path(session_id)
151
+ if not os.path.isfile(path):
152
+ return None
153
+ try:
154
+ with open(path, encoding="utf-8") as f:
155
+ data = json.load(f)
156
+ token = data.get("token")
157
+ return int(token) if token is not None else None
158
+ except (OSError, json.JSONDecodeError, ValueError, TypeError):
159
+ return None
160
+
161
+
162
+ def _read_buffer_payload(session_id: str) -> Optional[dict]:
163
+ path = _buffer_path(session_id)
164
+ if not os.path.isfile(path):
165
+ return None
166
+ try:
167
+ with open(path, encoding="utf-8") as f:
168
+ return json.load(f)
169
+ except (OSError, json.JSONDecodeError):
170
+ return None
171
+
172
+
173
+ def sentinel_exists(session_id: str) -> bool:
174
+ """Return True iff a ``buffer.done`` sentinel has EVER been written for
175
+ ``session_id`` (regardless of freshness).
176
+
177
+ Cold-start guard (red-team MAJOR finding): a reader must be able to tell
178
+ "no producer has ever run for this session" from "a producer ran but
179
+ hasn't finished this turn's slice yet" WITHOUT paying the full poll cap
180
+ — the former should skip polling entirely (every session-open turn
181
+ would otherwise eat the full poll cap, the opposite of M4's latency
182
+ goal), the latter is exactly what polling is for.
183
+ """
184
+ return os.path.isfile(_sentinel_path(session_id))
185
+
186
+
187
+ def read_if_fresh(session_id: str, last_consumed_token: Optional[int]) -> tuple:
188
+ """Return ``(payload_dict | None, current_token)``.
189
+
190
+ ``payload_dict`` (when present) is ``{"context": str, "telemetry": dict}``.
191
+
192
+ Returns ``(None, token_or_last_consumed)`` when:
193
+ * no sentinel exists yet (nothing has been produced this session), or
194
+ * the sentinel's token is not strictly newer than ``last_consumed_token``
195
+ (already consumed — stale from the reader's perspective), or
196
+ * the buffer payload itself is missing/corrupt (a torn write, or a
197
+ sentinel written with no payload at all) — fail-closed, never serve
198
+ a partial/absent payload as fresh.
199
+ """
200
+ token = _read_sentinel_token(session_id)
201
+ if token is None:
202
+ return None, last_consumed_token
203
+ if last_consumed_token is not None and token <= last_consumed_token:
204
+ return None, token
205
+ payload = _read_buffer_payload(session_id)
206
+ if payload is None:
207
+ # Torn write: sentinel landed but payload didn't (or is corrupt).
208
+ # Fail-closed — never serve this as fresh.
209
+ return None, token
210
+ return {"context": payload.get("context", ""), "telemetry": payload.get("telemetry", {})}, token
211
+
212
+
213
+ def poll_for_sentinel(session_id: str, last_consumed_token: Optional[int], cap_ms: int) -> bool:
214
+ """Clock-bounded poll for a fresh sentinel. Never busy-spins.
215
+
216
+ Returns True as soon as ``read_if_fresh`` would return a payload; False
217
+ once the cumulative sleep time reaches ``cap_ms``. Short sleeps (20-100ms,
218
+ backing off) sum to at most ``cap_ms`` — never a hard-loop re-stat.
219
+ """
220
+ if cap_ms <= 0:
221
+ ctx, _ = read_if_fresh(session_id, last_consumed_token)
222
+ return ctx is not None
223
+
224
+ deadline = time.monotonic() + (cap_ms / 1000.0)
225
+ sleep_s = _MIN_SLEEP_S
226
+ while True:
227
+ ctx, _ = read_if_fresh(session_id, last_consumed_token)
228
+ if ctx is not None:
229
+ return True
230
+ remaining = deadline - time.monotonic()
231
+ if remaining <= 0:
232
+ return False
233
+ this_sleep = min(sleep_s, remaining, _MAX_SLEEP_S)
234
+ if this_sleep > 0:
235
+ time.sleep(this_sleep)
236
+ sleep_s = min(sleep_s * 1.5, _MAX_SLEEP_S)