switchroom 0.19.1 → 0.19.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/dist/agent-scheduler/index.js +31 -1
- package/dist/auth-broker/index.js +565 -48
- package/dist/cli/autoaccept-poll.js +31 -1
- package/dist/cli/drive-write-pretool.mjs +32 -2
- package/dist/cli/ms-365-write-pretool.mjs +32 -2
- package/dist/cli/switchroom.js +1148 -274
- package/dist/host-control/main.js +3 -3
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +1 -0
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/skills/switchroom-cli/SKILL.md +25 -0
- package/telegram-plugin/auth-snapshot-format.ts +143 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1427 -689
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/external-spend.ts +135 -0
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +141 -158
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +309 -1
- package/telegram-plugin/gateway/narrative-lane.ts +23 -9
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/status-pin-store.ts +64 -4
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/gateway/usage-mask.ts +29 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
- package/telegram-plugin/quota-bar-format.ts +78 -12
- package/telegram-plugin/quota-check.ts +17 -2
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/external-spend.test.ts +168 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
- package/telegram-plugin/tests/quota-check.test.ts +57 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
- package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
- package/telegram-plugin/tests/usage-mask.test.ts +35 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adversarial-review F1 + F2 — the /usage card must NEVER stamp "Live" when
|
|
3
|
+
* no account row carries usable live data.
|
|
4
|
+
*
|
|
5
|
+
* Root cause (F1): the gateway `/usage` handler decided the freshness footer
|
|
6
|
+
* from `probeResp.results.length > 0`. But opProbeQuota
|
|
7
|
+
* (src/auth/broker/server.ts) returns `{result:{ok:false}, served:"live"}`
|
|
8
|
+
* for a failed live probe against an EMPTY cache — a non-empty results array
|
|
9
|
+
* where every row is `ok:false`. `zipProbeResults` only marks
|
|
10
|
+
* `served==="cache"` rows stale, so those failed rows carry no cache stamp
|
|
11
|
+
* either. Result: on a fresh restart (empty cache) + a transient probe
|
|
12
|
+
* failure, every row rendered "⚠️ no data — probe failed" UNDER a false
|
|
13
|
+
* "Live · refreshed 0s ago" stamp.
|
|
14
|
+
*
|
|
15
|
+
* F2 (test gap): the footer decision had zero coverage. This drives the
|
|
16
|
+
* extracted decision (`deriveUsageFooterFreshness`) composed with the real
|
|
17
|
+
* card renderer (`renderUsageCard`) with an all-`ok:false`, no-cache broker
|
|
18
|
+
* stub and asserts the rendered footer is NOT "Live" — i.e. it would fail
|
|
19
|
+
* against the pre-fix `.length > 0` gate.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest'
|
|
23
|
+
import {
|
|
24
|
+
deriveUsageFooterFreshness,
|
|
25
|
+
type AccountSnapshot,
|
|
26
|
+
type ProbeQuotaResultRow,
|
|
27
|
+
} from '../auth-snapshot-format.js'
|
|
28
|
+
import { renderUsageCard } from '../quota-bar-format.js'
|
|
29
|
+
|
|
30
|
+
const NOW = new Date('2026-07-19T12:00:00Z')
|
|
31
|
+
|
|
32
|
+
// Two accounts, no usable quota (the probe failed, nothing cached) — mirrors
|
|
33
|
+
// what the gateway hands renderUsageCard when the broker cache is empty and
|
|
34
|
+
// the live probe threw for every account.
|
|
35
|
+
const NO_DATA_SNAPSHOTS: AccountSnapshot[] = [
|
|
36
|
+
{ label: 'alice@example.com', isActive: true, quota: null, quotaError: 'probe failed' },
|
|
37
|
+
{ label: 'bob@example.com', isActive: false, quota: null, quotaError: 'probe failed' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
// A broker probe-quota response where every row failed and nothing was served
|
|
41
|
+
// from cache — the exact shape opProbeQuota returns for a failed live probe
|
|
42
|
+
// against an empty cache.
|
|
43
|
+
const ALL_FAILED_NO_CACHE: ProbeQuotaResultRow[] = [
|
|
44
|
+
{ label: 'alice@example.com', result: { ok: false, reason: 'network error' }, served: 'live' },
|
|
45
|
+
{ label: 'bob@example.com', result: { ok: false, reason: 'network error' }, served: 'live' },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const EXHAUSTED = new Map<string, boolean>()
|
|
49
|
+
|
|
50
|
+
describe('deriveUsageFooterFreshness (F1)', () => {
|
|
51
|
+
it('flags probeFailed when every row is ok:false and there is no cache', () => {
|
|
52
|
+
const opts = deriveUsageFooterFreshness(ALL_FAILED_NO_CACHE, undefined, NOW.getTime())
|
|
53
|
+
// Would have been { liveProbedAtMs } under the old `.length > 0` gate.
|
|
54
|
+
expect(opts).toEqual({ probeFailed: true })
|
|
55
|
+
expect(opts.liveProbedAtMs).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('stamps liveProbedAtMs when at least one row carries usable data', () => {
|
|
59
|
+
const rows: ProbeQuotaResultRow[] = [
|
|
60
|
+
ALL_FAILED_NO_CACHE[0],
|
|
61
|
+
{
|
|
62
|
+
label: 'bob@example.com',
|
|
63
|
+
result: {
|
|
64
|
+
ok: true,
|
|
65
|
+
data: {
|
|
66
|
+
fiveHourUtilizationPct: 12,
|
|
67
|
+
sevenDayUtilizationPct: 34,
|
|
68
|
+
fiveHourResetAt: null,
|
|
69
|
+
sevenDayResetAt: null,
|
|
70
|
+
representativeClaim: null,
|
|
71
|
+
overageStatus: null,
|
|
72
|
+
overageDisabledReason: null,
|
|
73
|
+
fiveHourUtilPresent: true,
|
|
74
|
+
sevenDayUtilPresent: true,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
served: 'live',
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
const opts = deriveUsageFooterFreshness(rows, undefined, NOW.getTime())
|
|
81
|
+
expect(opts).toEqual({ liveProbedAtMs: NOW.getTime() })
|
|
82
|
+
expect(opts.probeFailed).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('gives cache-served data precedence over both live and failed', () => {
|
|
86
|
+
const capturedAt = NOW.getTime() - 60_000
|
|
87
|
+
expect(deriveUsageFooterFreshness(ALL_FAILED_NO_CACHE, capturedAt, NOW.getTime())).toEqual({
|
|
88
|
+
staleCachedAtMs: capturedAt,
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('flags probeFailed for a totally empty results array', () => {
|
|
93
|
+
expect(deriveUsageFooterFreshness([], undefined, NOW.getTime())).toEqual({ probeFailed: true })
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('/usage card footer end-to-end (F2)', () => {
|
|
98
|
+
it('renders "probe failed", NOT "Live", when no row carries live data', () => {
|
|
99
|
+
const freshness = deriveUsageFooterFreshness(ALL_FAILED_NO_CACHE, undefined, NOW.getTime())
|
|
100
|
+
const card = renderUsageCard(NO_DATA_SNAPSHOTS, EXHAUSTED, { now: NOW, ...freshness })
|
|
101
|
+
expect(card).toContain('probe failed — no live data')
|
|
102
|
+
// The honesty invariant: no "Live" stamp when every row shows no data.
|
|
103
|
+
expect(card).not.toContain('Live')
|
|
104
|
+
// And the rows themselves are the no-data warning, not a fake 0% bar.
|
|
105
|
+
expect(card).toContain('no data — probe failed')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('renders a "Live" footer when a row carries usable data', () => {
|
|
109
|
+
const liveSnapshots: AccountSnapshot[] = [
|
|
110
|
+
{
|
|
111
|
+
label: 'bob@example.com',
|
|
112
|
+
isActive: true,
|
|
113
|
+
quota: {
|
|
114
|
+
fiveHourUtilizationPct: 12,
|
|
115
|
+
sevenDayUtilizationPct: 34,
|
|
116
|
+
fiveHourResetAt: null,
|
|
117
|
+
sevenDayResetAt: null,
|
|
118
|
+
representativeClaim: null,
|
|
119
|
+
overageStatus: null,
|
|
120
|
+
overageDisabledReason: null,
|
|
121
|
+
fiveHourUtilPresent: true,
|
|
122
|
+
sevenDayUtilPresent: true,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
]
|
|
126
|
+
const freshness = deriveUsageFooterFreshness(
|
|
127
|
+
[
|
|
128
|
+
{
|
|
129
|
+
label: 'bob@example.com',
|
|
130
|
+
result: { ok: true, data: liveSnapshots[0].quota! },
|
|
131
|
+
served: 'live',
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
undefined,
|
|
135
|
+
NOW.getTime(),
|
|
136
|
+
)
|
|
137
|
+
const card = renderUsageCard(liveSnapshots, EXHAUSTED, { now: NOW, ...freshness })
|
|
138
|
+
expect(card).toContain('Live · refreshed')
|
|
139
|
+
expect(card).not.toContain('probe failed')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adversarial-review F4 — `/usage` account-label masking policy.
|
|
3
|
+
*
|
|
4
|
+
* A group with an empty `allowFrom` authorizes every member, so the quota
|
|
5
|
+
* card would leak per-account email labels to a broadly shared group unless
|
|
6
|
+
* masked. `shouldMaskUsageLabels` (gateway/usage-mask.ts) is the pure
|
|
7
|
+
* decision the gateway feeds into the existing demo-mask machinery.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import { shouldMaskUsageLabels } from '../gateway/usage-mask.js'
|
|
12
|
+
|
|
13
|
+
describe('shouldMaskUsageLabels (F4)', () => {
|
|
14
|
+
it('never masks in a private (operator DM) chat', () => {
|
|
15
|
+
expect(shouldMaskUsageLabels('private', undefined)).toBe(false)
|
|
16
|
+
expect(shouldMaskUsageLabels('private', [])).toBe(false)
|
|
17
|
+
expect(shouldMaskUsageLabels('private', ['123'])).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('masks in a group with an EMPTY allowFrom (open membership)', () => {
|
|
21
|
+
expect(shouldMaskUsageLabels('group', [])).toBe(true)
|
|
22
|
+
expect(shouldMaskUsageLabels('group', undefined)).toBe(true)
|
|
23
|
+
expect(shouldMaskUsageLabels('supergroup', [])).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('does NOT mask in a group with a curated non-empty allowFrom', () => {
|
|
27
|
+
expect(shouldMaskUsageLabels('group', ['123'])).toBe(false)
|
|
28
|
+
expect(shouldMaskUsageLabels('supergroup', ['123', '456'])).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('masks defensively for any other chat type reaching a quota render', () => {
|
|
32
|
+
expect(shouldMaskUsageLabels('channel', undefined)).toBe(true)
|
|
33
|
+
expect(shouldMaskUsageLabels(undefined, undefined)).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -72,6 +72,33 @@ describe('resolveWorkerFeedDispatch (#2002 regression pin)', () => {
|
|
|
72
72
|
expect(resolveWorkerFeedDispatch(null, 'sub-agent').feedModel).toBeNull()
|
|
73
73
|
expect(resolveWorkerFeedDispatch(makeSub({ model: null }), 'sub-agent').feedModel).toBeNull()
|
|
74
74
|
})
|
|
75
|
+
|
|
76
|
+
// F3 (progress-card fork model) — outcome coverage. The dispatch-time seed is
|
|
77
|
+
// suppressed for forks at the hook (subagent-tracker-pretool.mjs), so a fork
|
|
78
|
+
// row carries model=null and the card omits the model until the watcher writes
|
|
79
|
+
// the transcript model. resolveWorkerFeedDispatch simply surfaces the row's
|
|
80
|
+
// CURRENT model, so these assert the feed-level outcomes on top of that.
|
|
81
|
+
it('(b) a fork row carries no dispatch-time model → feedModel null (card omits it until transcript)', () => {
|
|
82
|
+
// What the hook now writes for a fork dispatch: agent_type 'fork', model null
|
|
83
|
+
// (the ignored override is not persisted).
|
|
84
|
+
const forkRow = makeSub({ background: true, agent_type: 'fork', model: null })
|
|
85
|
+
expect(resolveWorkerFeedDispatch(forkRow, 'sub-agent').feedModel).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('(a) once the watcher overwrites the row model from the transcript, feedModel reflects the transcript model', () => {
|
|
89
|
+
// Transcript wins: the watcher (recordSubagentModel) overwrites the row's
|
|
90
|
+
// model column in place, so the resolved feedModel is the transcript value —
|
|
91
|
+
// even for a fork that started with model=null.
|
|
92
|
+
const afterTranscript = makeSub({ background: true, agent_type: 'fork', model: 'claude-opus-4-8' })
|
|
93
|
+
expect(resolveWorkerFeedDispatch(afterTranscript, 'sub-agent').feedModel).toBe('claude-opus-4-8')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('(c) a mid-run model switch is reflected: feedModel tracks the row’s latest recorded model', () => {
|
|
97
|
+
// The watcher writes model-on-change; the row holds the LATEST model, so a
|
|
98
|
+
// later substantive tick resolves the switched-to model.
|
|
99
|
+
const switchedTo = makeSub({ background: true, model: 'sr-glm-5' })
|
|
100
|
+
expect(resolveWorkerFeedDispatch(switchedTo, 'sub-agent').feedModel).toBe('sr-glm-5')
|
|
101
|
+
})
|
|
75
102
|
})
|
|
76
103
|
|
|
77
104
|
describe('gateway onFinish — fix #1: resultText-driven handback fallback (model)', () => {
|
|
@@ -5,7 +5,12 @@ import {
|
|
|
5
5
|
type BotApiForWorkerFeed,
|
|
6
6
|
} from '../worker-activity-feed.js'
|
|
7
7
|
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
8
|
-
import type
|
|
8
|
+
import { decidePinAction, type PinState, type DesiredPin } from '../status-pin.js'
|
|
9
|
+
import {
|
|
10
|
+
reconcileAndPersistStatusPin,
|
|
11
|
+
loadStatusPins,
|
|
12
|
+
type StatusPinStoreFsSeam,
|
|
13
|
+
} from '../gateway/status-pin-store.js'
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
16
|
* Outcome tests for the invisible-worker-cards fix (2026-07-15).
|
|
@@ -119,6 +124,131 @@ async function flush(): Promise<void> {
|
|
|
119
124
|
for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r))
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
/** In-memory fs seam with atomic rename, mirroring status-pin-store.test.ts. */
|
|
128
|
+
function memFs(): { fs: StatusPinStoreFsSeam; files: Map<string, string> } {
|
|
129
|
+
const files = new Map<string, string>()
|
|
130
|
+
const fs: StatusPinStoreFsSeam = {
|
|
131
|
+
readFileSync: (p) => {
|
|
132
|
+
if (!files.has(p)) throw new Error(`ENOENT ${p}`)
|
|
133
|
+
return files.get(p)!
|
|
134
|
+
},
|
|
135
|
+
writeFileSync: (p, d) => {
|
|
136
|
+
files.set(p, d)
|
|
137
|
+
},
|
|
138
|
+
renameSync: (a, b) => {
|
|
139
|
+
if (!files.has(a)) throw new Error(`ENOENT ${a}`)
|
|
140
|
+
files.set(b, files.get(a)!)
|
|
141
|
+
files.delete(a)
|
|
142
|
+
},
|
|
143
|
+
existsSync: (p) => files.has(p),
|
|
144
|
+
}
|
|
145
|
+
return { fs, files }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A pin harness that routes the feed's `reconcilePin` hook through the REAL
|
|
150
|
+
* persistence path (`reconcileAndPersistStatusPin`) against a memFs-backed
|
|
151
|
+
* status-pins.json — exactly as the gateway wires it: read prev claim → decide
|
|
152
|
+
* → map to a persist op → reconcileAndPersistStatusPin(applyPin=reconcilePin).
|
|
153
|
+
* This lets a test INSPECT THE FILE across steady-state edits (F1).
|
|
154
|
+
*/
|
|
155
|
+
function makePersistingPinHarness(path: string) {
|
|
156
|
+
const { fs } = memFs()
|
|
157
|
+
const claims = new Map<string, PinState>()
|
|
158
|
+
const pinCalls: number[] = []
|
|
159
|
+
const unpinCalls: number[] = []
|
|
160
|
+
const api: PinBotApi = {
|
|
161
|
+
pinChatMessage: async (_c, id) => {
|
|
162
|
+
pinCalls.push(id)
|
|
163
|
+
},
|
|
164
|
+
unpinChatMessage: async (_c, id) => {
|
|
165
|
+
unpinCalls.push(id)
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
async function reconcile(key: string, chatId: string, desired: DesiredPin): Promise<void> {
|
|
169
|
+
const prev = claims.get(key) ?? null
|
|
170
|
+
const action = decidePinAction(prev, desired)
|
|
171
|
+
const op = action.kind === 'pin'
|
|
172
|
+
? ({ kind: 'pin', messageId: action.messageId } as const)
|
|
173
|
+
: ({ kind: 'clear' } as const)
|
|
174
|
+
const next = await reconcileAndPersistStatusPin({
|
|
175
|
+
path,
|
|
176
|
+
fs,
|
|
177
|
+
pinKey: key,
|
|
178
|
+
chatId,
|
|
179
|
+
op,
|
|
180
|
+
applyPin: () => reconcilePin({ api, chatId, prevState: prev, desired }),
|
|
181
|
+
log: () => {},
|
|
182
|
+
})
|
|
183
|
+
if (next == null) claims.delete(key)
|
|
184
|
+
else claims.set(key, next)
|
|
185
|
+
}
|
|
186
|
+
const reconcilePinFn = (args: {
|
|
187
|
+
feedKey: string
|
|
188
|
+
chatId: string
|
|
189
|
+
threadId?: number
|
|
190
|
+
messageId: number | null
|
|
191
|
+
}): void => {
|
|
192
|
+
const key = `wk:group:${args.feedKey}`
|
|
193
|
+
if (args.messageId != null) void reconcile(key, args.chatId, { pinned: true, messageId: args.messageId })
|
|
194
|
+
else void reconcile(key, args.chatId, { pinned: false })
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
reconcilePinFn,
|
|
198
|
+
fs,
|
|
199
|
+
pinCalls,
|
|
200
|
+
unpinCalls,
|
|
201
|
+
rows: () => loadStatusPins(path, fs),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
describe('worker-feed pin persistence — durable status-pins.json survives steady-state edits (F1)', () => {
|
|
206
|
+
const PATH = '/state/agent/telegram/status-pins.json'
|
|
207
|
+
|
|
208
|
+
it('preserves the wk:group row across many steady-state edits (noop-clear must NOT delete it)', async () => {
|
|
209
|
+
const bot = makeFakeBot()
|
|
210
|
+
const pin = makePersistingPinHarness(PATH)
|
|
211
|
+
let clock = 0
|
|
212
|
+
const feed = createWorkerActivityFeed({
|
|
213
|
+
bot,
|
|
214
|
+
now: () => clock,
|
|
215
|
+
firstPaintMinMs: 0,
|
|
216
|
+
minEditIntervalMs: 0,
|
|
217
|
+
reconcilePin: pin.reconcilePinFn,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// First paint → message posted, pinned once, durable row written.
|
|
221
|
+
clock = 1000
|
|
222
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
|
|
223
|
+
await flush()
|
|
224
|
+
const msgId = bot.sent[0].messageId
|
|
225
|
+
expect(pin.pinCalls).toEqual([msgId])
|
|
226
|
+
expect(pin.rows()).toHaveLength(1)
|
|
227
|
+
const pinKey = pin.rows()[0].pinKey
|
|
228
|
+
expect(pinKey).toMatch(/^wk:group:/)
|
|
229
|
+
expect(pin.rows()).toEqual([
|
|
230
|
+
{ pinKey, chatId: 'chat', messageId: msgId },
|
|
231
|
+
])
|
|
232
|
+
|
|
233
|
+
// Many steady-state edits — the feed calls syncPin on EVERY edit, each a
|
|
234
|
+
// re-pin of the SAME id → noop → a `clear` op with a LIVE claim. Pre-fix the
|
|
235
|
+
// clear branch deleted the durable row here; the fix preserves it.
|
|
236
|
+
for (let i = 2; i <= 8; i++) {
|
|
237
|
+
clock = i * 1000
|
|
238
|
+
await feed.update('w1', 'chat', view({ elapsedMs: i * 1000, toolCount: i }))
|
|
239
|
+
await flush()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The durable row is STILL on disk (inspect the file), and no extra pin/
|
|
243
|
+
// unpin API call was issued across all those steady-state edits.
|
|
244
|
+
expect(pin.rows()).toEqual([
|
|
245
|
+
{ pinKey, chatId: 'chat', messageId: msgId },
|
|
246
|
+
])
|
|
247
|
+
expect(pin.pinCalls).toEqual([msgId]) // exactly one pin, ever
|
|
248
|
+
expect(pin.unpinCalls).toEqual([]) // never unpinned
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
122
252
|
describe('worker-feed pin persistence — steady-state re-pin (invisible-worker-cards)', () => {
|
|
123
253
|
it('re-pins on a steady-state edit when the claim was lost, and does NOT re-pin when already correct (no storm)', async () => {
|
|
124
254
|
const bot = makeFakeBot()
|
|
@@ -4,6 +4,108 @@
|
|
|
4
4
|
|
|
5
5
|
### Changed (switchroom divergence)
|
|
6
6
|
|
|
7
|
+
- **`recallContextTurns` default `1` → `2`** (switchroom hindsight-leverage
|
|
8
|
+
PR2, workstream A2). A bare follow-up user message ("and the port?", "what
|
|
9
|
+
about staging?") now embeds together with its antecedent human turn in the
|
|
10
|
+
recall query, instead of recalling on the pronoun alone. Depends on PR1's
|
|
11
|
+
(#3435) `<channel>` envelope strip so the composed 2-turn query stays
|
|
12
|
+
envelope-free (both the trailing latest segment and the `Prior context:`
|
|
13
|
+
lines). The composition is bounded by `recallMaxQueryChars` (800) —
|
|
14
|
+
`truncate_recall_query` preserves the latest turn and drops oldest context
|
|
15
|
+
first, so a large antecedent can never blow the recall query budget.
|
|
16
|
+
- **New `recallTranscriptTailBytes` (default `262144`)** — latency bound for
|
|
17
|
+
multi-turn recall. With `recallContextTurns > 1` now the default, every
|
|
18
|
+
recall reads the transcript to slice prior turns; `read_transcript_messages`
|
|
19
|
+
now byte-tail-bounds that read (seek to `EOF - tail_bytes`, discard the
|
|
20
|
+
partial first line, parse only complete trailing lines) so the added
|
|
21
|
+
per-recall read stays O(1) regardless of session `.jsonl` size. `0` reads the
|
|
22
|
+
whole file (rollback lever). Env: `HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES`.
|
|
23
|
+
|
|
24
|
+
### Added (switchroom divergence)
|
|
25
|
+
|
|
26
|
+
- **retain.py + recall.py: lesson/anti-pattern tagging → recall demotion**
|
|
27
|
+
(switchroom hindsight-leverage PR9, workstream E2, #398). Closes the corpus-
|
|
28
|
+
hygiene half of #398: a retained transcript that captures a self-recognised
|
|
29
|
+
lesson ("lesson learned", "note to self:") or a failure mode ("anti-pattern:",
|
|
30
|
+
"what not to do") is now deterministically tagged (`lesson` / `anti-pattern`)
|
|
31
|
+
at retain time by `retain.detect_lesson_tags` — a case-insensitive substring
|
|
32
|
+
match against the configurable `lessonTagMarkers` map (NOT model-dependent),
|
|
33
|
+
wired into `build_retain_payload` so it applies to both Stop-hook and sidechain
|
|
34
|
+
retains without clobbering configured `retainTags`. Recall then DEMOTES those
|
|
35
|
+
tags via the PR5 score-penalty weight map: `recall._effective_tag_weights`
|
|
36
|
+
merges built-in `lessonDemotionWeights` (`{lesson: 0.85, anti-pattern: 0.5}`)
|
|
37
|
+
UNDER `recallTagWeights`, so a failure-mode-adjacent transcript ranks below a
|
|
38
|
+
clean equal-score session memory yet is NEVER hard-dropped (re-rank, not the
|
|
39
|
+
demote-tag DROP filter) and still surfaces when it is the only relevant hit.
|
|
40
|
+
Precedence: an explicit `recallTagWeights` entry wins over the built-in for the
|
|
41
|
+
same tag; the PR5 `sidechain: 0.8` seed composes cleanly. Toggles:
|
|
42
|
+
`HINDSIGHT_LESSON_TAGGING=false` (retain side), `HINDSIGHT_LESSON_DEMOTION=false`
|
|
43
|
+
(recall side) as rollback levers; `HINDSIGHT_LESSON_TAG_MARKERS` /
|
|
44
|
+
`HINDSIGHT_LESSON_DEMOTION_WEIGHTS` (JSON) for overrides. NON-GOAL
|
|
45
|
+
(epic-recorded): the historical corpus is NOT re-tagged — this fires on NEW
|
|
46
|
+
retains only. Acceptance: `scripts/tests/test_lesson_tagging.py`.
|
|
47
|
+
|
|
48
|
+
- **recall.py: parallel multi-bank recall under one shared deadline**
|
|
49
|
+
(switchroom hindsight-leverage PR3, workstream A3 stage 2). The directives
|
|
50
|
+
fetch and every bank recall (own + additional/profile/shared/sender banks)
|
|
51
|
+
now run CONCURRENTLY in daemon threads via `lib/parallel_recall.py`
|
|
52
|
+
(`run_parallel`), bounded by ONE shared deadline
|
|
53
|
+
(`recallParallelDeadlineSeconds`, default 10 = the 12s UserPromptSubmit hook
|
|
54
|
+
ceiling minus 2s headroom). Serially the round-trips SUM, so a heavy
|
|
55
|
+
multi-bank agent could breach the ceiling and drop recall entirely; parallel
|
|
56
|
+
makes the critical path the SLOWEST slot. A slot still running at the deadline
|
|
57
|
+
is abandoned (daemon thread, reaped on process exit) and marked `timed_out` —
|
|
58
|
+
a straggler bank can never hold the hook open past its ceiling; `recall.py`'s
|
|
59
|
+
`__main__` additionally `os._exit(0)`s (after a stdout flush) as a
|
|
60
|
+
belt-and-suspenders. The directives slot is dedicated and composes with the A4
|
|
61
|
+
directives cache (a cache HIT returns near-instantly with no HTTP). Env-gated
|
|
62
|
+
rollback: `HINDSIGHT_RECALL_PARALLEL=false` restores the pre-A3 serial path.
|
|
63
|
+
The `deadline_hit` telemetry field (shipped interim in PR1) is FINALIZED:
|
|
64
|
+
True when any bank raised a hard per-request timeout OR any bank/directives
|
|
65
|
+
slot was abandoned at the shared deadline; serial mode reduces to the pre-A3
|
|
66
|
+
per-bank-only form so both modes' `recall_log.jsonl` rows stay comparable in
|
|
67
|
+
the breach baseline. New log fields: `recall_mode`, `deadline_budget_ms`
|
|
68
|
+
(the CONFIGURED budget), `deadline_effective_ms` (the smaller wait the slots
|
|
69
|
+
actually got after pre-fan-out spend; null in serial mode / on cache hits),
|
|
70
|
+
`directives_timed_out`. Acceptance: `scripts/tests/test_recall_parallel_deadline.py`
|
|
71
|
+
(stub-timing tests proving a 3s bank cannot breach a 0.6s deadline).
|
|
72
|
+
|
|
73
|
+
- **SubagentStop sidechain retain** (switchroom hindsight-leverage PR5). New
|
|
74
|
+
`scripts/subagent_retain.py`, registered on the `SubagentStop` event in
|
|
75
|
+
`hooks/hooks.json` (async, 15s). Delegated (Task-tool / sub-agent) work was
|
|
76
|
+
the biggest systematic memory hole — the main-session Stop retain only reads
|
|
77
|
+
the parent `transcript_path`, so a worker's process facts reached memory only
|
|
78
|
+
as its terse final report. This hook retains a bounded window (last 40 human
|
|
79
|
+
turns) of the *sidechain* transcript, tagged `sidechain` +
|
|
80
|
+
`parent_session:<id>`, with a deterministic content-derived `document_id` in a
|
|
81
|
+
distinct namespace (`{session}-sub-{agent}-r{start}-{end}`) so re-fires upsert.
|
|
82
|
+
Failures enqueue to the same `pending-retains` durability queue the Stop retain
|
|
83
|
+
uses. A **volume gate** (< 6 human turns OR < 2,000 chars of non-tool-result
|
|
84
|
+
text) skips trivial forks (every Task fires SubagentStop, including
|
|
85
|
+
10-second ones). Empirically probed on Claude Code 2.1.215: the hook input
|
|
86
|
+
carries a first-class `agent_transcript_path` pointing at
|
|
87
|
+
`<project>/<session>/subagents/agent-<agent_id>.jsonl` (used as the primary
|
|
88
|
+
path), with a directory-scan of the newest `isSidechain:true` jsonl as the
|
|
89
|
+
fallback for CLIs that omit the field. `reconcile_tail.py` and any transcript
|
|
90
|
+
sweeper now **skip** sidechain transcripts (shared
|
|
91
|
+
`content.transcript_first_line_is_sidechain` predicate) so the boot reconciler
|
|
92
|
+
cannot re-retain a sub-agent fork as a pseudo-session — untagged, at full
|
|
93
|
+
recall weight, bypassing the volume gate — which its recursive `**/*.jsonl`
|
|
94
|
+
glob would otherwise do one restart after any worker.
|
|
95
|
+
|
|
96
|
+
- **recall.py: `recallTagWeights` per-tag score penalty** (switchroom
|
|
97
|
+
hindsight-leverage PR5). A `{tag: multiplier}` config map (default `{}`,
|
|
98
|
+
env `HINDSIGHT_RECALL_TAG_WEIGHTS`) applied to each result's `scores.final`
|
|
99
|
+
immediately before the relevance sort. Unlike the demote-tag DROP filter
|
|
100
|
+
(`_is_demoted_memory`), which removes a tagged memory from recall entirely,
|
|
101
|
+
this DEMOTES (down-ranks) a memory while keeping it recallable when it is the
|
|
102
|
+
only relevant hit — the "reduced weight" the drop filter cannot express.
|
|
103
|
+
Switchroom's scaffold seeds `{"sidechain": 0.8}` so delegated-worker
|
|
104
|
+
process-memories rank just below first-party session memory. **Candidate to
|
|
105
|
+
upstream** — a general recall-shaping primitive, not switchroom-specific.
|
|
106
|
+
|
|
107
|
+
### Changed (switchroom divergence)
|
|
108
|
+
|
|
7
109
|
- **retain.py: decouple chunked window-slicing from the `retainEveryNTurns > 1`
|
|
8
110
|
throttle** (switchroom Phase 6b). Previously the chunked sliding-window only
|
|
9
111
|
applied when `retainEveryNTurns > 1`; with `retainEveryNTurns=1` (switchroom
|
|
@@ -183,8 +183,9 @@ Auto-recall runs on every user prompt. It queries Hindsight for relevant memorie
|
|
|
183
183
|
| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Controls how hard Hindsight searches for memories. `"low"` = fast, fewer strategies; `"mid"` = balanced; `"high"` = thorough, slower. Affects latency directly. |
|
|
184
184
|
| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Maximum number of tokens in the recalled memory block. Lower values reduce context usage but may truncate relevant memories. |
|
|
185
185
|
| `recallTypes` | — | `["world", "experience"]` | Which memory types to retrieve. `"world"` = general facts; `"experience"` = personal experiences; `"observation"` = raw observations. |
|
|
186
|
-
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `
|
|
186
|
+
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `2` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; `2` (default) also embeds the antecedent human turn so a bare follow-up ("and the port?") recalls with context instead of on the pronoun alone. Higher values give more context but may dilute the query. The composed query is always bounded by `recallMaxQueryChars` (latest turn preserved, oldest context dropped first). |
|
|
187
187
|
| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. |
|
|
188
|
+
| `recallTranscriptTailBytes` | `HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES` | `262144` | Latency bound for multi-turn recall (`recallContextTurns > 1`): read only the last N bytes of the session transcript when slicing prior turns, so the per-recall read stays cheap on long sessions. `0` reads the whole file. |
|
|
188
189
|
| `recallRoles` | — | `["user", "assistant"]` | Which message roles to include when building the recall query from prior turns. |
|
|
189
190
|
| `recallTags` | `HINDSIGHT_RECALL_TAGS` | `[]` | Optional tags to pass to the recall API, such as `["memory_type:rule"]`. The env var accepts JSON or a comma-separated list. |
|
|
190
191
|
| `recallTagsMatch` | `HINDSIGHT_RECALL_TAGS_MATCH` | `"any"` | Tag matching mode used with `recallTags` or `recallTagGroups`: `"any"`, `"all"`, `"any_strict"`, or `"all_strict"`. |
|
|
@@ -43,6 +43,18 @@
|
|
|
43
43
|
]
|
|
44
44
|
}
|
|
45
45
|
],
|
|
46
|
+
"SubagentStop": [
|
|
47
|
+
{
|
|
48
|
+
"hooks": [
|
|
49
|
+
{
|
|
50
|
+
"type": "command",
|
|
51
|
+
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent_retain.py\"",
|
|
52
|
+
"timeout": 15,
|
|
53
|
+
"async": true
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
],
|
|
46
58
|
"SessionEnd": [
|
|
47
59
|
{
|
|
48
60
|
"hooks": [
|
|
@@ -59,6 +59,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
59
59
|
|
|
60
60
|
from lib.config import debug_log, load_config # noqa: E402
|
|
61
61
|
from lib.directives import ( # noqa: E402
|
|
62
|
+
DIRECTIVES_CACHE_TTL_SECONDS,
|
|
63
|
+
invalidate_directives_cache,
|
|
62
64
|
parse_active_directives_block,
|
|
63
65
|
rule_already_captured,
|
|
64
66
|
)
|
|
@@ -317,6 +319,70 @@ def directive_recorded_after(messages: list, start_index: int) -> bool:
|
|
|
317
319
|
return False
|
|
318
320
|
|
|
319
321
|
|
|
322
|
+
# --- Directives-cache invalidation (switchroom hindsight-leverage A4) ---------
|
|
323
|
+
#
|
|
324
|
+
# recall.py caches the bank's active-directives list with a short TTL to skip an
|
|
325
|
+
# HTTP round-trip on the critical path. That cache would otherwise stay stale
|
|
326
|
+
# until the TTL elapsed — so this Stop hook, which already re-reads the turn's
|
|
327
|
+
# transcript, deletes the cache whenever the just-ended turn performed a
|
|
328
|
+
# directive WRITE (create/update/delete). The next recall then re-fetches, so an
|
|
329
|
+
# in-session directive change is visible in the very next turn's
|
|
330
|
+
# <active_directives> block (cross-process writes still rely on TTL alone).
|
|
331
|
+
|
|
332
|
+
# Matches the hindsight directive-write MCP tools by their bare or namespaced
|
|
333
|
+
# name, e.g. "create_directive", "mcp__hindsight__update_directive",
|
|
334
|
+
# "delete_directive". Read-only "list_directives" is deliberately excluded.
|
|
335
|
+
_DIRECTIVE_WRITE_TOOL_RE = re.compile(r"(?:create|update|delete)_directive")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _is_directive_write_tool(name) -> bool:
|
|
339
|
+
return isinstance(name, str) and bool(_DIRECTIVE_WRITE_TOOL_RE.search(name))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def turn_contains_directive_write(messages: list, start_index: int) -> bool:
|
|
343
|
+
"""True if any assistant turn after ``start_index`` issued a directive-write
|
|
344
|
+
tool_use (create/update/delete). ``start_index = -1`` scans all messages
|
|
345
|
+
(used when there is no human turn, e.g. a synthetic/cron inbound that still
|
|
346
|
+
wrote a directive). Pure inspection; no API call."""
|
|
347
|
+
for msg in messages[start_index + 1:]:
|
|
348
|
+
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
|
349
|
+
continue
|
|
350
|
+
content = msg.get("content")
|
|
351
|
+
if not isinstance(content, list):
|
|
352
|
+
continue
|
|
353
|
+
for p in content:
|
|
354
|
+
if (
|
|
355
|
+
isinstance(p, dict)
|
|
356
|
+
and p.get("type") == "tool_use"
|
|
357
|
+
and _is_directive_write_tool(p.get("name", ""))
|
|
358
|
+
):
|
|
359
|
+
return True
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def invalidate_cache_on_directive_write(messages: list, config: dict) -> None:
|
|
364
|
+
"""Delete the directives cache if this turn wrote a directive.
|
|
365
|
+
|
|
366
|
+
Takes the ALREADY-READ transcript ``messages`` (main() reads the transcript
|
|
367
|
+
exactly once and shares it with the capture verifier — a Stop hook must not
|
|
368
|
+
parse a multi-MB session twice). Only the tail after the last human turn is
|
|
369
|
+
scanned, since only writes issued THIS turn matter. Runs independently of
|
|
370
|
+
the capture-verify decision (and of the directiveCaptureNudge knob) — the
|
|
371
|
+
cache is a separate feature. Best-effort; never raises, so a bug here can
|
|
372
|
+
never wedge Stop.
|
|
373
|
+
"""
|
|
374
|
+
try:
|
|
375
|
+
if not messages:
|
|
376
|
+
return
|
|
377
|
+
idx, _text = find_last_human_turn(messages)
|
|
378
|
+
start = idx if idx is not None else -1
|
|
379
|
+
if turn_contains_directive_write(messages, start):
|
|
380
|
+
invalidate_directives_cache()
|
|
381
|
+
debug_log(config, "Directives cache invalidated — turn wrote a directive")
|
|
382
|
+
except Exception as e: # pragma: no cover - defensive; Stop must not wedge
|
|
383
|
+
debug_log(config, f"Directives cache invalidation skipped (error): {e}")
|
|
384
|
+
|
|
385
|
+
|
|
320
386
|
def read_transcript(transcript_path: str) -> list:
|
|
321
387
|
"""Read a JSONL transcript into a list of message dicts (role/content).
|
|
322
388
|
|
|
@@ -348,11 +414,15 @@ def read_transcript(transcript_path: str) -> list:
|
|
|
348
414
|
return messages
|
|
349
415
|
|
|
350
416
|
|
|
351
|
-
def evaluate(hook_input: dict, config: dict) -> str | None:
|
|
417
|
+
def evaluate(hook_input: dict, config: dict, messages: list | None = None) -> str | None:
|
|
352
418
|
"""Core decision. Returns a block reason string, or None to allow stop.
|
|
353
419
|
|
|
354
420
|
None → the turn is allowed to end (no-op). A non-empty string → block the
|
|
355
421
|
stop once and feed the string back to the model.
|
|
422
|
+
|
|
423
|
+
``messages`` is the pre-read transcript when the caller already parsed it
|
|
424
|
+
(main() reads once and shares it); when None, the transcript is read here so
|
|
425
|
+
direct callers/tests keep the old single-arg contract.
|
|
356
426
|
"""
|
|
357
427
|
# Same knob as Stage B — disabling the nudge disables this verification.
|
|
358
428
|
if not config.get("directiveCaptureNudge", True):
|
|
@@ -371,7 +441,8 @@ def evaluate(hook_input: dict, config: dict) -> str | None:
|
|
|
371
441
|
debug_log(config, "Directive-capture verify: stop_hook_active, not re-blocking")
|
|
372
442
|
return None
|
|
373
443
|
|
|
374
|
-
messages
|
|
444
|
+
if messages is None:
|
|
445
|
+
messages = read_transcript(hook_input.get("transcript_path", ""))
|
|
375
446
|
if not messages:
|
|
376
447
|
return None
|
|
377
448
|
|
|
@@ -423,8 +494,34 @@ def main():
|
|
|
423
494
|
config = load_config()
|
|
424
495
|
except Exception:
|
|
425
496
|
return
|
|
497
|
+
|
|
498
|
+
# Read the transcript AT MOST ONCE and share it with both consumers below
|
|
499
|
+
# (a Stop hook must not parse a multi-MB session twice, nor at all when
|
|
500
|
+
# nothing here needs it). Two features want the transcript:
|
|
501
|
+
# * A4 directives-cache invalidation — only when the cache is enabled
|
|
502
|
+
# (TTL > 0); with the cache off there is nothing to invalidate.
|
|
503
|
+
# * capture-verify (evaluate) — only when the nudge+verify knobs are on
|
|
504
|
+
# and this is not an already-blocked re-fire.
|
|
505
|
+
ttl = config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS)
|
|
506
|
+
cache_on = isinstance(ttl, (int, float)) and ttl > 0
|
|
507
|
+
verify_maybe = (
|
|
508
|
+
config.get("directiveCaptureNudge", True)
|
|
509
|
+
and config.get("directiveCaptureVerify", True)
|
|
510
|
+
and not hook_input.get("stop_hook_active")
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
messages: list = []
|
|
514
|
+
if cache_on or verify_maybe:
|
|
515
|
+
messages = read_transcript(hook_input.get("transcript_path", ""))
|
|
516
|
+
|
|
517
|
+
# A4: invalidate the directives cache when this turn wrote a directive, so
|
|
518
|
+
# the change is visible on the very next recall. Independent of the
|
|
519
|
+
# capture-verify decision below and self-guarded — runs on every Stop.
|
|
520
|
+
if cache_on:
|
|
521
|
+
invalidate_cache_on_directive_write(messages, config)
|
|
522
|
+
|
|
426
523
|
try:
|
|
427
|
-
reason = evaluate(hook_input, config)
|
|
524
|
+
reason = evaluate(hook_input, config, messages=messages)
|
|
428
525
|
except Exception as e: # never wedge a turn on a verify bug
|
|
429
526
|
debug_log(config, f"Directive-capture verify error (allowing stop): {e}")
|
|
430
527
|
return
|