switchroom 0.17.6 → 0.18.3
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/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
import { describe, it, expect } from "vitest";
|
|
36
36
|
import { spinUp } from "../harness.js";
|
|
37
|
+
import type { ObservedMessage } from "../driver.js";
|
|
37
38
|
|
|
38
39
|
// The prompt pushes the model into a tool-heavy state where it has
|
|
39
40
|
// produced "an answer" internally but hasn't yet realised it must
|
|
@@ -77,12 +78,33 @@ describe("uat: silent-end recovery", () => {
|
|
|
77
78
|
// fallback wording ("still working… (no update from agent
|
|
78
79
|
// in N min)") that means the silent-end loop fired AND the
|
|
79
80
|
// model didn't recover. Acceptable outcome — the user got
|
|
80
|
-
// something — but a design-health alarm. Log it
|
|
81
|
-
|
|
81
|
+
// something — but a design-health alarm. Log it AND assert the
|
|
82
|
+
// `exhausted` latch gave fire-once (transport-side), matching the
|
|
83
|
+
// channel twin exactly — the job-spec Prove-it claim is
|
|
84
|
+
// "asserted transport-side in a DM AND a supergroup alike", so the
|
|
85
|
+
// DM twin must make the same exactly-once assertion, not just warn.
|
|
86
|
+
if (/no update from agent|didn't send a reply/i.test(reply.text)) {
|
|
82
87
|
console.warn(
|
|
83
88
|
`[silent-end-recovery] reply was the framework fallback — `
|
|
84
89
|
+ `model never replied on its own. Reply text: ${JSON.stringify(reply.text.slice(0, 200))}`,
|
|
85
90
|
);
|
|
91
|
+
|
|
92
|
+
// No SECOND fallback-shaped message should follow within a short
|
|
93
|
+
// window (the `exhausted` latch gives fire-once).
|
|
94
|
+
let secondFallback: ObservedMessage | null = null;
|
|
95
|
+
try {
|
|
96
|
+
secondFallback = await sc.expectMessage(
|
|
97
|
+
/no update from agent|didn't send a reply/i,
|
|
98
|
+
{ from: "bot", timeout: 15_000 },
|
|
99
|
+
);
|
|
100
|
+
} catch {
|
|
101
|
+
// Timeout is the expected/good outcome — no second fallback fired.
|
|
102
|
+
}
|
|
103
|
+
expect(
|
|
104
|
+
secondFallback,
|
|
105
|
+
"the dark-turn/framework fallback fired MORE than once for the same turn "
|
|
106
|
+
+ "(the `exhausted` latch should give fire-once)",
|
|
107
|
+
).toBeNull();
|
|
86
108
|
}
|
|
87
109
|
} finally {
|
|
88
110
|
await sc.tearDown();
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ownership filter for the worktree-isolated cwds the subagent-watcher should
|
|
3
|
+
* additionally watch (deterministic-turn-liveness.md Known Gap 2 + the #2893
|
|
4
|
+
* ownership-predicate review fix + the #1116 / #2893 durable-identity fix).
|
|
5
|
+
*
|
|
6
|
+
* A sub-agent dispatched into a `switchroom worktree claim` cwd runs under a
|
|
7
|
+
* different project-dir slug than the agent's own `agentCwd`, so the #1116
|
|
8
|
+
* foreign-slug filter would skip it forever unless the watcher also watches
|
|
9
|
+
* the slugs of worktrees THIS agent owns. This helper derives that set from
|
|
10
|
+
* the host-global worktree registry, filtered by the agent's own identity.
|
|
11
|
+
*
|
|
12
|
+
* Identity resolution is two-tier (durable fix for the gap where a worktree
|
|
13
|
+
* worker whose identity can't be attributed gets NO live progress feed):
|
|
14
|
+
*
|
|
15
|
+
* 1. FAST PATH — `self` (`process.env.SWITCHROOM_AGENT_NAME`). Set
|
|
16
|
+
* authoritatively by compose env (compose.ts) AND hoisted in start.sh
|
|
17
|
+
* before the gateway fork, so this is present in the overwhelming
|
|
18
|
+
* majority of runs.
|
|
19
|
+
* 2. DURABLE FALLBACK — when `self` is unset/empty, derive the identity
|
|
20
|
+
* from `agentDir` (the agent's own directory, itself derived from
|
|
21
|
+
* `TELEGRAM_STATE_DIR` = `<agentDir>/telegram`, which the gateway
|
|
22
|
+
* already requires to be present before it even starts the watcher).
|
|
23
|
+
* The basename of `agentDir` is `resolve(agents_dir, <name>)`'s leaf —
|
|
24
|
+
* i.e. this agent's OWN name. This can only ever resolve to THIS
|
|
25
|
+
* agent's identity, never another agent's, so it cannot mis-attribute:
|
|
26
|
+
* a wrong basename matches zero registry records (fail-closed), it
|
|
27
|
+
* never matches a DIFFERENT owner. Env is just the fast path; ownership
|
|
28
|
+
* resolves correctly from durable config when env is missing.
|
|
29
|
+
*
|
|
30
|
+
* Fail-CLOSED, deliberately, and never mis-attributing:
|
|
31
|
+
*
|
|
32
|
+
* - Owner match ⇒ include, realpath'd. Claude Code mints the project slug
|
|
33
|
+
* off the process's PHYSICAL cwd, so a symlinked base (macOS `/tmp` →
|
|
34
|
+
* `/private/tmp`) would otherwise derive a slug that misses the physical
|
|
35
|
+
* one; realpath best-effort, falling back to the raw path.
|
|
36
|
+
* - Ownerless registry records (`ownerAgent` undefined) are NEVER matched,
|
|
37
|
+
* even with identity set — a naive `undefined === undefined` would leak
|
|
38
|
+
* every other agent's ownerless worktree (the #1116 leak this exists to
|
|
39
|
+
* prevent).
|
|
40
|
+
* - A registry read failure ⇒ `[]` (best-effort; never disturb the base
|
|
41
|
+
* agentCwd watch).
|
|
42
|
+
* - BOTH env AND agentDir-derived identity unavailable ⇒ `[]` (same
|
|
43
|
+
* fail-closed contract as before this fix — we never guess) but escalate
|
|
44
|
+
* the log from the #2893 one-shot warn to a clear ERROR naming that
|
|
45
|
+
* identity resolution fully failed, so the lost live feed is diagnosable.
|
|
46
|
+
* Never throws, never mis-attributes.
|
|
47
|
+
*/
|
|
48
|
+
import { realpathSync } from "node:fs";
|
|
49
|
+
import { basename } from "node:path";
|
|
50
|
+
|
|
51
|
+
export interface WorktreeOwnershipRecord {
|
|
52
|
+
path: string;
|
|
53
|
+
ownerAgent?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OwnedWorktreeCwdsOptions {
|
|
57
|
+
/** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME` (fast path). */
|
|
58
|
+
self: string | undefined;
|
|
59
|
+
/** The host-global registry read (`listRecords` from src/worktree/registry). */
|
|
60
|
+
listRecords: () => WorktreeOwnershipRecord[];
|
|
61
|
+
/**
|
|
62
|
+
* Durable, non-env fallback source for identity: the agent's OWN directory
|
|
63
|
+
* (`resolveAgentDirFromEnv()` in the gateway). When `self` is unset/empty,
|
|
64
|
+
* the identity is derived as `basename(agentDir)`. Omit to disable the
|
|
65
|
+
* fallback (the pre-fix, env-only behaviour — used by the kill-switch).
|
|
66
|
+
*/
|
|
67
|
+
agentDir?: string | null;
|
|
68
|
+
/** Injectable for tests; defaults to `fs.realpathSync`. */
|
|
69
|
+
realpath?: (p: string) => string;
|
|
70
|
+
/**
|
|
71
|
+
* Injectable derivation of the agent name from `agentDir`. Defaults to
|
|
72
|
+
* `path.basename`. Returns "" when it cannot derive a usable name.
|
|
73
|
+
*/
|
|
74
|
+
deriveName?: (agentDir: string) => string;
|
|
75
|
+
/** Escalated-failure sink (both identity sources unavailable). */
|
|
76
|
+
log?: (msg: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// One-shot guard so the escalated "identity fully unresolved" ERROR is emitted
|
|
80
|
+
// ONCE per process rather than every rescan tick (the provider is re-invoked on
|
|
81
|
+
// every tick). Mirrors the #2893 one-shot-warn ethos; exported reset for tests.
|
|
82
|
+
let identityEscalated = false;
|
|
83
|
+
export function __resetIdentityEscalationForTests(): void {
|
|
84
|
+
identityEscalated = false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function defaultDeriveName(agentDir: string): string {
|
|
88
|
+
if (!agentDir || agentDir.trim().length === 0) return "";
|
|
89
|
+
const leaf = basename(agentDir).trim();
|
|
90
|
+
return leaf;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
|
|
94
|
+
// Tier 1: env fast path. Tier 2: durable agentDir-derived fallback.
|
|
95
|
+
let resolved: string = opts.self != null ? opts.self : "";
|
|
96
|
+
if (resolved === "" && opts.agentDir != null && opts.agentDir !== "") {
|
|
97
|
+
const derive = opts.deriveName ?? defaultDeriveName;
|
|
98
|
+
resolved = derive(opts.agentDir) || "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (resolved === "") {
|
|
102
|
+
// Both env and durable config unavailable. Keep the historical
|
|
103
|
+
// fail-closed contract (return [] — never guess, never mis-attribute) but
|
|
104
|
+
// ESCALATE past the #2893 one-shot warn: name that identity resolution
|
|
105
|
+
// fully failed and the live worktree-worker feed is lost for this run.
|
|
106
|
+
if (!identityEscalated) {
|
|
107
|
+
identityEscalated = true;
|
|
108
|
+
opts.log?.(
|
|
109
|
+
"ERROR: worktree identity resolution FAILED — both " +
|
|
110
|
+
"SWITCHROOM_AGENT_NAME and the agentDir-derived fallback are " +
|
|
111
|
+
"unavailable. Worktree ownership cannot be attributed; a " +
|
|
112
|
+
"worktree-isolated background sub-agent will get NO live progress " +
|
|
113
|
+
"feed this run (its registry row is still reaped by the 1h safety " +
|
|
114
|
+
"net). This is a configuration fault, not a transient error.",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const rp = opts.realpath ?? realpathSync;
|
|
121
|
+
try {
|
|
122
|
+
return opts
|
|
123
|
+
.listRecords()
|
|
124
|
+
.filter((r) => r.ownerAgent === resolved)
|
|
125
|
+
.map((r) => {
|
|
126
|
+
try {
|
|
127
|
+
return rp(r.path);
|
|
128
|
+
} catch {
|
|
129
|
+
return r.path;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Post-turn directive-capture verification hook for the Stop event.
|
|
3
|
+
|
|
4
|
+
Switchroom #2848 Stage C — deterministic correction capture (hindsight
|
|
5
|
+
synthesis-layers RFC, Phase 3 "corrections stick").
|
|
6
|
+
|
|
7
|
+
Stage B (recall.py) appends an advisory nudge to the UserPromptSubmit
|
|
8
|
+
context when the inbound looks correction-shaped, then trusts the model to
|
|
9
|
+
call ``mcp__hindsight__create_directive`` itself. That closes part of the
|
|
10
|
+
~55% miss rate Stage A measured, but capture still relies on the model
|
|
11
|
+
CHOOSING to act on the nudge — a purely advisory path. When the model
|
|
12
|
+
silently ignores the nudge, a durable correction is lost the same way it was
|
|
13
|
+
before Stage B.
|
|
14
|
+
|
|
15
|
+
This hook closes the residual gap for the HIGH-CONFIDENCE case. On Stop it:
|
|
16
|
+
|
|
17
|
+
1. Re-reads the transcript, isolates the human turn that opened this turn,
|
|
18
|
+
and tests it against a NARROW, high-precision "durable standing rule"
|
|
19
|
+
regex (a strict subset of Stage B's inclusive detector — see
|
|
20
|
+
``looks_like_durable_directive``). Bare "always"/"never"/"stop …" and
|
|
21
|
+
other one-off-prone shapes are deliberately EXCLUDED here; only explicit
|
|
22
|
+
standing-rule framings ("from now on", "as a rule", "you should always",
|
|
23
|
+
"call me …", "remember to …", "don't … again") qualify.
|
|
24
|
+
2. Scans the assistant messages of the turn for an actual
|
|
25
|
+
``create_directive`` tool call.
|
|
26
|
+
3. If the turn stated a durable rule but recorded NO directive, it BLOCKS
|
|
27
|
+
the stop ONCE (Claude Code ``{"decision":"block"}``) with a terse reason
|
|
28
|
+
telling the model to persist the rule now — or, if on reflection it was
|
|
29
|
+
genuinely a one-off, to just finish. ``stop_hook_active`` gates the
|
|
30
|
+
block to fire at most once per turn, so it can never loop and never
|
|
31
|
+
override the model's second, explicit judgment.
|
|
32
|
+
|
|
33
|
+
Why this stays invariant-clean (same reasoning as Stage B):
|
|
34
|
+
* NO model callsite here — detection is pure regex (claude-native).
|
|
35
|
+
* NO silent hook-side write — the hook never calls the Hindsight API; the
|
|
36
|
+
MODEL authors the directive verbatim and the call is visible in chat
|
|
37
|
+
(chat-legibility / no-self-escalation). The block is a re-prompt, not a
|
|
38
|
+
write.
|
|
39
|
+
* Guarded against spam — the durable regex is high-precision, the block
|
|
40
|
+
fires once, and the reason explicitly authorizes "one-off → don't create,
|
|
41
|
+
just finish". A false positive costs one bounded model continuation.
|
|
42
|
+
|
|
43
|
+
Gated by the same knob as Stage B: ``directiveCaptureNudge`` (switchroom
|
|
44
|
+
default on; operators opt out per-agent via
|
|
45
|
+
``memory.directive_capture_nudge=false`` →
|
|
46
|
+
``HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE``). Disabling the nudge disables this
|
|
47
|
+
verification too — they are one deterministic-capture feature.
|
|
48
|
+
|
|
49
|
+
Exit codes:
|
|
50
|
+
0 — always (graceful degradation; a raise here must never wedge a turn).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
59
|
+
|
|
60
|
+
from lib.config import debug_log, load_config # noqa: E402
|
|
61
|
+
from lib.directives import ( # noqa: E402
|
|
62
|
+
parse_active_directives_block,
|
|
63
|
+
rule_already_captured,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Reuse Stage B's pleasantry scrub so "as always" / "always happy to help"
|
|
67
|
+
# can't trip the high-confidence detector either. Imported lazily-safe: if
|
|
68
|
+
# recall.py fails to import for any reason we fall back to a no-op scrub so
|
|
69
|
+
# this hook still degrades gracefully rather than wedging Stop.
|
|
70
|
+
try:
|
|
71
|
+
from recall import _DIRECTIVE_NUDGE_NEGATIVE_RE as _NEGATIVE_RE
|
|
72
|
+
from recall import looks_like_standing_rule as _looks_like_standing_rule
|
|
73
|
+
except Exception: # pragma: no cover - defensive import guard
|
|
74
|
+
_NEGATIVE_RE = re.compile(r"(?!x)x") # matches nothing → scrub is a no-op
|
|
75
|
+
|
|
76
|
+
def _looks_like_standing_rule(_text): # type: ignore
|
|
77
|
+
# If recall.py can't be imported, fall back to the high-precision
|
|
78
|
+
# detector alone rather than wedging the hook. (Never expected in
|
|
79
|
+
# practice — recall.py ships in the same plugin tree.)
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# High-confidence, high-precision "durable standing rule" detector. Fires only
|
|
84
|
+
# on explicit standing-rule / preference / identity framings that read as
|
|
85
|
+
# durable on their face. Bare "always"/"never"/"stop …"/"don't …" (without a
|
|
86
|
+
# standing frame), and pure world-fact corrections ("we no longer use X" —
|
|
87
|
+
# recall/retain handle those, they aren't behavioural directives), are
|
|
88
|
+
# intentionally omitted: the blocking re-prompt is more intrusive than Stage
|
|
89
|
+
# B's advisory nudge, so its trigger set is narrower and directive-shaped only.
|
|
90
|
+
#
|
|
91
|
+
# looks_like_durable_directive() additionally AND-gates this against
|
|
92
|
+
# recall.py's inclusive looks_like_standing_rule, so the durable trigger set is
|
|
93
|
+
# a GUARANTEED SUBSET of Stage B's nudge trigger set — Stage C can never block
|
|
94
|
+
# on a turn Stage B wouldn't even have nudged on.
|
|
95
|
+
_DURABLE_DIRECTIVE_RE = re.compile(
|
|
96
|
+
r"""(?ix)
|
|
97
|
+
(?:
|
|
98
|
+
# --- explicit temporal / standing-rule framings ---
|
|
99
|
+
\b from \s+ now \s+ on \b
|
|
100
|
+
| \b going \s+ forwards? \b
|
|
101
|
+
| \b in \s+ (?: the \s+ )? future \b
|
|
102
|
+
| \b as \s+ a \s+ (?: general \s+ )?
|
|
103
|
+
(?: rule | policy | principle | convention | standard | default | habit ) \b
|
|
104
|
+
# --- directed standing behaviour ("you should always", "please never") ---
|
|
105
|
+
| \b you \s+ (?: should | must ) \s+ (?: always | never ) \b
|
|
106
|
+
| \b i \s+ want \s+ you \s+ to \s+ (?: always | never ) \b
|
|
107
|
+
| \b please \s+ (?: always | never ) \b
|
|
108
|
+
# --- durable preferences / identity ---
|
|
109
|
+
| \b i \s* ['’]? d \s+ (?: really \s+ )? prefer \b
|
|
110
|
+
| \b (?: i | we ) \s+ prefer \s+ (?: that \s+ )? you \b
|
|
111
|
+
| \b call \s+ me \b
|
|
112
|
+
# --- memory / reinforcement ---
|
|
113
|
+
| \b remember \s+ (?: to | that | always | never ) \b
|
|
114
|
+
# --- prohibitions with a durable frame ---
|
|
115
|
+
| \b (?: do \s* n['’]? t | don['’]? t | dont | do \s+ not )
|
|
116
|
+
\b [^.?!]{0,40} \b again \b
|
|
117
|
+
)
|
|
118
|
+
"""
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Terse, bounded. Fed back to the model on a single blocked Stop. It must
|
|
122
|
+
# offer an explicit escape hatch (one-off → just finish) so a false positive
|
|
123
|
+
# is cheap and never forces a spurious directive.
|
|
124
|
+
_VERIFY_BLOCK_REASON = (
|
|
125
|
+
"<directive_capture_verify>\n"
|
|
126
|
+
"The user's last message stated a DURABLE, standing rule for how you "
|
|
127
|
+
"should behave going forward (e.g. \"from now on …\", \"as a rule …\", "
|
|
128
|
+
"\"you should always …\", \"call me …\", \"remember to …\", \"don't … "
|
|
129
|
+
"again\"), but this turn is ending without recording it — so the "
|
|
130
|
+
"correction will NOT survive the next session.\n"
|
|
131
|
+
"If it is genuinely a durable rule, call "
|
|
132
|
+
"mcp__hindsight__create_directive NOW (verbatim, in the user's own "
|
|
133
|
+
"words), then briefly confirm you have saved it.\n"
|
|
134
|
+
"UNLESS an equivalent active directive already exists (see the "
|
|
135
|
+
"<active_directives> block for this turn) — in that case it is already "
|
|
136
|
+
"saved; do NOT create a duplicate, just finish.\n"
|
|
137
|
+
"If, on reflection, it was only a one-off instruction for this task, do "
|
|
138
|
+
"NOT create a directive — just finish your reply normally.\n"
|
|
139
|
+
"This verification fires once per turn.\n"
|
|
140
|
+
"</directive_capture_verify>"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Guard: skip any "user" content that is actually hook-injected context
|
|
144
|
+
# (recall's memories/nudge blocks), not a human message.
|
|
145
|
+
_INJECTED_MARKERS = (
|
|
146
|
+
"<directive_capture_check>",
|
|
147
|
+
"<directive_capture_verify>",
|
|
148
|
+
"<hindsight_memories>",
|
|
149
|
+
# recall.py injects the bank's active directives as a top-of-prompt block;
|
|
150
|
+
# it is hook-injected context, not a human turn (#2903 Fix 6.2).
|
|
151
|
+
"<active_directives>",
|
|
152
|
+
"Relevant memories from past conversations",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# SWITCHROOM DIVERGENCE (#2903 Fix 6.3): the `<channel source="...">` envelope
|
|
156
|
+
# grammar and the human-source whitelist used to be hard-coded inline here,
|
|
157
|
+
# silently coupling this vendored Python guard to the switchroom gateway's TS
|
|
158
|
+
# wire format. Extracted to lib/switchroom_envelope.py so a TS-side envelope
|
|
159
|
+
# change has ONE obvious Python counterpart to update (and its own test) rather
|
|
160
|
+
# than breaking this guard undetected. `is_synthetic_inbound` is re-exported for
|
|
161
|
+
# backward compatibility with existing tests/callers.
|
|
162
|
+
from lib.switchroom_envelope import is_synthetic_inbound # noqa: E402,F401
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def looks_like_durable_directive(text) -> bool:
|
|
166
|
+
"""High-precision test for an explicit, durable standing rule.
|
|
167
|
+
|
|
168
|
+
Narrower than recall.py's ``looks_like_standing_rule`` — see the module
|
|
169
|
+
docstring. Pleasantries are scrubbed first (shared Stage B negative
|
|
170
|
+
guard). Returns False on empty / non-string input. Pure regex; no model
|
|
171
|
+
call.
|
|
172
|
+
"""
|
|
173
|
+
if not isinstance(text, str) or not text.strip():
|
|
174
|
+
return False
|
|
175
|
+
scrubbed = _NEGATIVE_RE.sub(" ", text)
|
|
176
|
+
if not _DURABLE_DIRECTIVE_RE.search(scrubbed):
|
|
177
|
+
return False
|
|
178
|
+
# Subset gate: only act where Stage B would also have nudged.
|
|
179
|
+
return bool(_looks_like_standing_rule(text))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _message_text(content) -> str:
|
|
183
|
+
"""Extract the human-authored text from a message's content.
|
|
184
|
+
|
|
185
|
+
Handles the plain-string shape and the Claude Code list shape
|
|
186
|
+
``[{type:"text", text:...}, {type:"tool_use"/"tool_result", ...}]``.
|
|
187
|
+
Only ``text`` parts are joined — tool_result / tool_use parts are
|
|
188
|
+
ignored, so a role="user" tool-result message yields "" (correctly not a
|
|
189
|
+
human turn).
|
|
190
|
+
"""
|
|
191
|
+
if isinstance(content, str):
|
|
192
|
+
return content
|
|
193
|
+
if isinstance(content, list):
|
|
194
|
+
parts = []
|
|
195
|
+
for p in content:
|
|
196
|
+
if isinstance(p, dict) and p.get("type") == "text":
|
|
197
|
+
t = p.get("text")
|
|
198
|
+
if isinstance(t, str):
|
|
199
|
+
parts.append(t)
|
|
200
|
+
return "\n".join(parts)
|
|
201
|
+
return ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _is_injected(text: str) -> bool:
|
|
205
|
+
return any(marker in text for marker in _INJECTED_MARKERS)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def find_last_human_turn(messages: list) -> tuple:
|
|
209
|
+
"""Return ``(index, text)`` of the most recent genuine human turn.
|
|
210
|
+
|
|
211
|
+
Skips role="user" entries that are tool_result-only (no text) or
|
|
212
|
+
hook-injected context blocks. Returns ``(None, "")`` when there is no
|
|
213
|
+
human message.
|
|
214
|
+
"""
|
|
215
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
216
|
+
msg = messages[i]
|
|
217
|
+
if not isinstance(msg, dict) or msg.get("role") != "user":
|
|
218
|
+
continue
|
|
219
|
+
text = _message_text(msg.get("content"))
|
|
220
|
+
if not text.strip():
|
|
221
|
+
continue # tool_result-only user message
|
|
222
|
+
if _is_injected(text):
|
|
223
|
+
continue # recall/nudge injection, not the human
|
|
224
|
+
return i, text
|
|
225
|
+
return None, ""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _directive_call_ids(content) -> list:
|
|
229
|
+
"""Return the tool_use ids of any create_directive calls in a message's
|
|
230
|
+
content (empty list if none)."""
|
|
231
|
+
ids = []
|
|
232
|
+
if not isinstance(content, list):
|
|
233
|
+
return ids
|
|
234
|
+
for p in content:
|
|
235
|
+
if not isinstance(p, dict) or p.get("type") != "tool_use":
|
|
236
|
+
continue
|
|
237
|
+
name = p.get("name", "")
|
|
238
|
+
if isinstance(name, str) and "create_directive" in name:
|
|
239
|
+
# Track the id so we can pair it with its tool_result and reject a
|
|
240
|
+
# call whose write ERRORED. A call with no id still counts as a
|
|
241
|
+
# (best-effort) attempt — see _directive_call_present.
|
|
242
|
+
ids.append(p.get("id"))
|
|
243
|
+
return ids
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _directive_call_present(content) -> bool:
|
|
247
|
+
"""True if a message's content contains a create_directive tool_use."""
|
|
248
|
+
return len(_directive_call_ids(content)) > 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# SWITCHROOM DIVERGENCE (#2903, Fix 1.3): a create_directive tool_use whose
|
|
252
|
+
# tool_result came back with an error must NOT count as "recorded". A hindsight
|
|
253
|
+
# tools/call returns HTTP 200 + is_error:true on failure (engine down / renamed
|
|
254
|
+
# arg / isError envelope); without this the verifier would see the call, treat
|
|
255
|
+
# the correction as captured, and never fire its one bounded re-prompt — the
|
|
256
|
+
# same false-success class that made chat show "📌 remembered" for a failed
|
|
257
|
+
# write. We scan subsequent user messages for the matching tool_result id and
|
|
258
|
+
# treat is_error:true (or an error-shaped text result) as NOT-recorded.
|
|
259
|
+
def _errored_tool_use_ids(messages: list) -> set:
|
|
260
|
+
"""Collect tool_use ids whose tool_result reported an error."""
|
|
261
|
+
errored = set()
|
|
262
|
+
for msg in messages:
|
|
263
|
+
if not isinstance(msg, dict):
|
|
264
|
+
continue
|
|
265
|
+
content = msg.get("content")
|
|
266
|
+
if not isinstance(content, list):
|
|
267
|
+
continue
|
|
268
|
+
for p in content:
|
|
269
|
+
if not isinstance(p, dict) or p.get("type") != "tool_result":
|
|
270
|
+
continue
|
|
271
|
+
if p.get("is_error") is True:
|
|
272
|
+
tid = p.get("tool_use_id")
|
|
273
|
+
if tid is not None:
|
|
274
|
+
errored.add(tid)
|
|
275
|
+
return errored
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def collect_active_directive_contents(messages: list) -> list:
|
|
279
|
+
"""Gather the CONTENT strings of every active directive injected into this
|
|
280
|
+
turn's context.
|
|
281
|
+
|
|
282
|
+
recall.py injects an ``<active_directives>`` block (the bank's currently
|
|
283
|
+
active directives) into the UserPromptSubmit context; it shows up in the
|
|
284
|
+
transcript as an injected user message. We parse those back out so the
|
|
285
|
+
verifier can tell whether a restated rule is ALREADY stored — in which case
|
|
286
|
+
the model correctly declines to re-create it and we must NOT block (#2903
|
|
287
|
+
Fix 6.2). Pure string parsing; no API call.
|
|
288
|
+
"""
|
|
289
|
+
contents: list = []
|
|
290
|
+
for msg in messages:
|
|
291
|
+
if not isinstance(msg, dict):
|
|
292
|
+
continue
|
|
293
|
+
text = _message_text(msg.get("content"))
|
|
294
|
+
if "<active_directives>" in text:
|
|
295
|
+
contents.extend(parse_active_directives_block(text))
|
|
296
|
+
return contents
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def directive_recorded_after(messages: list, start_index: int) -> bool:
|
|
300
|
+
"""True if any assistant turn after ``start_index`` called create_directive
|
|
301
|
+
with a SUCCESSFUL result. A call whose tool_result errored does not count
|
|
302
|
+
(SWITCHROOM DIVERGENCE #2903, Fix 1.3) — so the verifier still re-prompts
|
|
303
|
+
once for a durable rule whose write failed."""
|
|
304
|
+
errored = _errored_tool_use_ids(messages)
|
|
305
|
+
for msg in messages[start_index + 1:]:
|
|
306
|
+
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
|
307
|
+
continue
|
|
308
|
+
ids = _directive_call_ids(msg.get("content"))
|
|
309
|
+
if not ids:
|
|
310
|
+
continue
|
|
311
|
+
# Recorded only if at least one create_directive call did NOT error.
|
|
312
|
+
# A call with a None id (older/testing shape carrying no id) has no
|
|
313
|
+
# pairable result, so treat it as a successful attempt (prior behaviour).
|
|
314
|
+
for tid in ids:
|
|
315
|
+
if tid is None or tid not in errored:
|
|
316
|
+
return True
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def read_transcript(transcript_path: str) -> list:
|
|
321
|
+
"""Read a JSONL transcript into a list of message dicts (role/content).
|
|
322
|
+
|
|
323
|
+
Mirrors retain.py.read_transcript: supports the nested Claude Code shape
|
|
324
|
+
``{type, message:{role, content}}`` and the flat testing shape
|
|
325
|
+
``{role, content}``.
|
|
326
|
+
"""
|
|
327
|
+
if not transcript_path or not os.path.isfile(transcript_path):
|
|
328
|
+
return []
|
|
329
|
+
messages = []
|
|
330
|
+
try:
|
|
331
|
+
with open(transcript_path, encoding="utf-8") as f:
|
|
332
|
+
for line in f:
|
|
333
|
+
line = line.strip()
|
|
334
|
+
if not line:
|
|
335
|
+
continue
|
|
336
|
+
try:
|
|
337
|
+
entry = json.loads(line)
|
|
338
|
+
except json.JSONDecodeError:
|
|
339
|
+
continue
|
|
340
|
+
if entry.get("type") in ("user", "assistant"):
|
|
341
|
+
msg = entry.get("message", {})
|
|
342
|
+
if isinstance(msg, dict) and msg.get("role"):
|
|
343
|
+
messages.append(msg)
|
|
344
|
+
elif "role" in entry and "content" in entry:
|
|
345
|
+
messages.append(entry)
|
|
346
|
+
except OSError:
|
|
347
|
+
pass
|
|
348
|
+
return messages
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def evaluate(hook_input: dict, config: dict) -> str | None:
|
|
352
|
+
"""Core decision. Returns a block reason string, or None to allow stop.
|
|
353
|
+
|
|
354
|
+
None → the turn is allowed to end (no-op). A non-empty string → block the
|
|
355
|
+
stop once and feed the string back to the model.
|
|
356
|
+
"""
|
|
357
|
+
# Same knob as Stage B — disabling the nudge disables this verification.
|
|
358
|
+
if not config.get("directiveCaptureNudge", True):
|
|
359
|
+
debug_log(config, "Directive-capture verify: feature disabled, allowing stop")
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
# #2873/#2903 Fix 6.2 — the BLOCK is separately gated: an operator can keep
|
|
363
|
+
# the advisory Stage B nudge while dropping the more intrusive Stop block.
|
|
364
|
+
if not config.get("directiveCaptureVerify", True):
|
|
365
|
+
debug_log(config, "Directive-capture verify: block disabled (nudge-only), allowing stop")
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
# Loop / one-off guard: if we already blocked once this turn, respect the
|
|
369
|
+
# model's second judgment and never re-block.
|
|
370
|
+
if hook_input.get("stop_hook_active"):
|
|
371
|
+
debug_log(config, "Directive-capture verify: stop_hook_active, not re-blocking")
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
messages = read_transcript(hook_input.get("transcript_path", ""))
|
|
375
|
+
if not messages:
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
idx, text = find_last_human_turn(messages)
|
|
379
|
+
if idx is None:
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
# Non-interactive turn guard: cron / synthesized-inbound turns (resume,
|
|
383
|
+
# reaction, vault-grant, subagent-handback, obligation-represent, …) are
|
|
384
|
+
# machine turns, not human corrections. Never block Stop to nag capture on
|
|
385
|
+
# them — that's spurious. (See is_synthetic_inbound.)
|
|
386
|
+
if is_synthetic_inbound(text):
|
|
387
|
+
debug_log(config, "Directive-capture verify: synthetic/cron inbound, allowing stop")
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
if not looks_like_durable_directive(text):
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
if directive_recorded_after(messages, idx):
|
|
394
|
+
debug_log(config, "Directive-capture verify: create_directive already called, allowing stop")
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
# Dedup (#2903 Fix 6.2): if the restated rule is already covered by an
|
|
398
|
+
# active directive injected into this turn's <active_directives> block, the
|
|
399
|
+
# model CORRECTLY declined to re-create a duplicate — blocking here would
|
|
400
|
+
# nag it to double-store. Allow stop.
|
|
401
|
+
existing = collect_active_directive_contents(messages)
|
|
402
|
+
if existing and rule_already_captured(text, existing):
|
|
403
|
+
debug_log(
|
|
404
|
+
config,
|
|
405
|
+
"Directive-capture verify: rule already covered by an active directive, allowing stop",
|
|
406
|
+
)
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
debug_log(
|
|
410
|
+
config,
|
|
411
|
+
"Directive-capture verify: durable rule stated, no create_directive call — blocking once",
|
|
412
|
+
)
|
|
413
|
+
return _VERIFY_BLOCK_REASON
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def main():
|
|
417
|
+
try:
|
|
418
|
+
hook_input = json.load(sys.stdin)
|
|
419
|
+
except (json.JSONDecodeError, EOFError):
|
|
420
|
+
# No input → nothing to verify. Allow stop.
|
|
421
|
+
return
|
|
422
|
+
try:
|
|
423
|
+
config = load_config()
|
|
424
|
+
except Exception:
|
|
425
|
+
return
|
|
426
|
+
try:
|
|
427
|
+
reason = evaluate(hook_input, config)
|
|
428
|
+
except Exception as e: # never wedge a turn on a verify bug
|
|
429
|
+
debug_log(config, f"Directive-capture verify error (allowing stop): {e}")
|
|
430
|
+
return
|
|
431
|
+
if reason:
|
|
432
|
+
# Claude Code Stop-hook block contract: emit decision=block + reason;
|
|
433
|
+
# the model continues the turn with `reason` as feedback.
|
|
434
|
+
print(json.dumps({"decision": "block", "reason": reason}))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
if __name__ == "__main__":
|
|
438
|
+
try:
|
|
439
|
+
main()
|
|
440
|
+
except Exception as e: # absolute backstop — Stop must never hard-fail
|
|
441
|
+
print(f"[Hindsight] Unexpected error in directive_verify: {e}", file=sys.stderr)
|
|
442
|
+
try:
|
|
443
|
+
sys.exit(2 if load_config().get("debug") else 0)
|
|
444
|
+
except Exception:
|
|
445
|
+
sys.exit(0)
|
|
Binary file
|
|
Binary file
|