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.
- package/bin/rules-sentinel-hook.sh +101 -0
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1924 -841
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
- package/skills/mental-model-curator/SKILL.md +187 -56
- package/telegram-plugin/dist/gateway/gateway.js +5 -4
- package/vendor/hindsight-memory/hooks/hooks.json +10 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
- package/vendor/hindsight-memory/scripts/recall.py +185 -0
- package/vendor/hindsight-memory/scripts/retain.py +132 -49
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
- 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.
|
|
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.
|
|
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
|
|
13
|
-
"audit my directives", "
|
|
14
|
-
directives
|
|
15
|
-
guardrails", and when switchroom doctor
|
|
16
|
-
threshold. Do NOT use for storing a
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
A second, independent job of this skill:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
directives
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
182
|
-
|
|
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
|
|
185
|
-
2. **
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
- **
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
- ❌
|
|
208
|
-
|
|
209
|
-
- ❌
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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.
|
|
105874
|
-
var COMMIT_SHA = "
|
|
105875
|
-
var COMMIT_DATE = "2026-08-
|
|
105876
|
-
var LATEST_PR =
|
|
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
|
|
@@ -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)
|