@phenx-inc/ctlsurf 0.13.6 → 0.14.0
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/out/headless/index.mjs +13663 -385
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +11665 -286
- package/out/main/node-CUnQxFha.js +868 -0
- package/out/preload/index.js +17 -0
- package/out/renderer/assets/{cssMode-CsSLnKZ-.js → cssMode-7adWSORr.js} +3 -3
- package/out/renderer/assets/{freemarker2-C1XYRbOq.js → freemarker2-BkLLWnzk.js} +1 -1
- package/out/renderer/assets/{handlebars-BSdbbnMW.js → handlebars-Cinfm9fZ.js} +1 -1
- package/out/renderer/assets/{html-96igtyOz.js → html-vl9fkHKL.js} +1 -1
- package/out/renderer/assets/{htmlMode-BRnCcIzp.js → htmlMode-BtGqkTNI.js} +3 -3
- package/out/renderer/assets/{index-Cc-jDHm3.js → index-BhrV6ld-.js} +359 -23
- package/out/renderer/assets/{index-BEekQe3-.css → index-BpCORklm.css} +217 -0
- package/out/renderer/assets/{javascript-7MdRNHk9.js → javascript-B7CCdfuW.js} +2 -2
- package/out/renderer/assets/{jsonMode-oGam2go_.js → jsonMode-ut9LWBMJ.js} +3 -3
- package/out/renderer/assets/{liquid-DoKC7ba2.js → liquid-DVDJ0lKt.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-C2h5qGCv.js → lspLanguageFeatures-BbMj8Wsc.js} +1 -1
- package/out/renderer/assets/{mdx-0w8n1HGo.js → mdx-CcQzYB0J.js} +1 -1
- package/out/renderer/assets/{python-BfY4HssB.js → python-Byy7wKMv.js} +1 -1
- package/out/renderer/assets/{razor-BoDk9Ula.js → razor-D1xi_wMM.js} +1 -1
- package/out/renderer/assets/{tsMode-CX0oVKRt.js → tsMode-DJPzbn5i.js} +1 -1
- package/out/renderer/assets/{typescript-BqIU7abP.js → typescript-BuVaU7i3.js} +1 -1
- package/out/renderer/assets/{xml-BlSDnxFw.js → xml-rzc91O3d.js} +1 -1
- package/out/renderer/assets/{yaml-bVMmGUtU.js → yaml-fAOzQMge.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +2 -1
- package/src/main/index.ts +36 -2
- package/src/main/observer/anthropic.ts +48 -0
- package/src/main/observer/budget.ts +79 -0
- package/src/main/observer/ledger.ts +61 -0
- package/src/main/observer/observer.ts +566 -0
- package/src/main/observer/spans.ts +170 -0
- package/src/main/observer/store.ts +200 -0
- package/src/main/observer/tier0.ts +234 -0
- package/src/main/observer/tier1.ts +306 -0
- package/src/main/observer/tier2.ts +125 -0
- package/src/main/observer/types.ts +224 -0
- package/src/main/orchestrator.ts +74 -0
- package/src/main/transcripts.ts +18 -0
- package/src/preload/index.ts +36 -0
- package/src/renderer/App.tsx +22 -0
- package/src/renderer/components/ObserverPanel.tsx +289 -0
- package/src/renderer/components/SettingsDialog.tsx +104 -0
- package/src/renderer/styles.css +217 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Finding,
|
|
3
|
+
LedgerEvent,
|
|
4
|
+
ModelCaller,
|
|
5
|
+
Sensitivity,
|
|
6
|
+
SpanKind,
|
|
7
|
+
Tier0Tag,
|
|
8
|
+
Tier1Output,
|
|
9
|
+
} from './types'
|
|
10
|
+
import { SEVERITY_ORDER } from './types'
|
|
11
|
+
import { truncateForTier1 } from './tier0'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Tier 1 — Haiku windowed reviewer (spec §3.2).
|
|
15
|
+
*
|
|
16
|
+
* Triggers: every N ledger events (default 20) OR T seconds of debounce
|
|
17
|
+
* (default 120s) OR immediately on any Tier-0 tag with severity ≥ warn.
|
|
18
|
+
*
|
|
19
|
+
* Input: cached system prompt (taxonomy + output contract + license policy)
|
|
20
|
+
* + the truncated event window + a compact rolling state object. Output:
|
|
21
|
+
* strict JSON via structured outputs. Malformed output → one retry with the
|
|
22
|
+
* error appended; second failure → health event (spec §6.3).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const MAX_OUTPUT_TOKENS = 1_500
|
|
26
|
+
const RETRY_LIMIT = 1
|
|
27
|
+
|
|
28
|
+
export interface Tier1Options {
|
|
29
|
+
session: string
|
|
30
|
+
model: string
|
|
31
|
+
windowEvents: number
|
|
32
|
+
debounceMs: number
|
|
33
|
+
callModel: ModelCaller
|
|
34
|
+
/** Degrade to Tier-0-only when true (budget breach). */
|
|
35
|
+
isOverBudget: () => boolean
|
|
36
|
+
onUsage: (model: string, usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }) => void
|
|
37
|
+
onFindings: (findings: Array<Omit<Finding, 'id' | 'session'>>) => void
|
|
38
|
+
onSpanBoundary: (newWorkRef: string | undefined, kind: SpanKind | undefined) => void
|
|
39
|
+
onEscalate: (reason: string, windowIds: string[]) => void
|
|
40
|
+
onHealth: (message: string) => void
|
|
41
|
+
/** Called after each completed review with what it looked at/found —
|
|
42
|
+
* feeds the panel's "it ran" heartbeat. */
|
|
43
|
+
onReviewed?: (eventCount: number, findingCount: number) => void
|
|
44
|
+
/** Review in-flight signal (true at start, false when settled) —
|
|
45
|
+
* drives the panel's processing animation. Always called in pairs. */
|
|
46
|
+
onReviewState?: (running: boolean) => void
|
|
47
|
+
/** Detection threshold lever; read per call so the panel lever applies
|
|
48
|
+
* to reviews already scheduled. */
|
|
49
|
+
getSensitivity?: () => Sensitivity
|
|
50
|
+
/** Rolling state seed. */
|
|
51
|
+
getSpanHypothesis: () => string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TIER1_SYSTEM = `You are Observer, a supervision layer reviewing a coding agent's session ledger. You are NOT the coding agent. You review process, not product.
|
|
55
|
+
|
|
56
|
+
You receive a window of ledger events (JSON, one per line) plus a rolling state object. Each event has an id (evt_N) — these are the ONLY valid receipts.
|
|
57
|
+
|
|
58
|
+
Report findings in these categories only:
|
|
59
|
+
- assumption: the agent made an unstated or weakly-stated assumption that could bite later (timezone, idempotency, "for simplicity" scope cuts, hardcoded placeholder left in).
|
|
60
|
+
- unverified_claim: the agent claimed something the window does not support (says tests pass but no passing test run visible; says deployed but no deploy command).
|
|
61
|
+
- license: a dependency change introduces license risk. Policy: AGPL, SSPL, and unknown/unlicensed dependencies are flagged (high). LGPL is flagged (warn). MIT/Apache-2.0/BSD/ISC are fine.
|
|
62
|
+
- dependency: new dependency for trivial functionality, unpinned/wildcard versions in manifests, or a dep bump with suspicious scope.
|
|
63
|
+
|
|
64
|
+
Precision over recall: only report findings a busy human supervisor would want to see. Do not report style nits, normal tool usage, or things the human explicitly asked for. Confidence is your honest probability the finding is real and worth attention. Severity: info (worth a digest line), warn (should be reviewed), high (needs attention soon), critical (drop everything — almost never from this tier).
|
|
65
|
+
|
|
66
|
+
Every finding MUST cite receipts: event ids from THIS window that support it.
|
|
67
|
+
|
|
68
|
+
Span tracking: the state object carries the current span hypothesis (which work item the session time is attributed to). If the window shows the human switching to a different work item (new ticket id, clearly different task), set span_action.boundary=true with new_work_ref and kind (feature|bug|chore|tangent|unticketed). Otherwise boundary=false.
|
|
69
|
+
|
|
70
|
+
Escalate (escalate=true, with reason) only when judgment exceeds your pay grade: an apparent contradiction with earlier human instructions, ambiguous scope drift, or suspected test-integrity issues.
|
|
71
|
+
|
|
72
|
+
Update state_updates.span_hypothesis (one line: what work item is active) and state_updates.human_instructions_summary (2-3 sentences: what the human has asked for so far, cumulative).
|
|
73
|
+
|
|
74
|
+
Output strict JSON matching the provided schema. No prose.`
|
|
75
|
+
|
|
76
|
+
/** Sensitivity addendum appended to the system block. Stable per level, so
|
|
77
|
+
* the prompt cache only re-primes when the user moves the lever. */
|
|
78
|
+
const SENSITIVITY_ADDENDUM: Record<Sensitivity, string> = {
|
|
79
|
+
severe:
|
|
80
|
+
'\n\nSENSITIVITY: SEVERE. Report only findings a supervisor must act on: severity high or critical with confidence >= 0.75. When in doubt, stay silent.',
|
|
81
|
+
balanced:
|
|
82
|
+
'\n\nSENSITIVITY: BALANCED. Report clearly-supported findings of severity warn and above; skip marginal info-level observations.',
|
|
83
|
+
all:
|
|
84
|
+
'\n\nSENSITIVITY: ALL SIGNALS. Report every plausible signal, including low-confidence and info-level observations — recall over precision at this level.',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const TIER1_SCHEMA: Record<string, unknown> = {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
findings: {
|
|
91
|
+
type: 'array',
|
|
92
|
+
items: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
category: { type: 'string', enum: ['assumption', 'unverified_claim', 'license', 'dependency'] },
|
|
96
|
+
severity: { type: 'string', enum: ['info', 'warn', 'high', 'critical'] },
|
|
97
|
+
confidence: { type: 'number' },
|
|
98
|
+
title: { type: 'string' },
|
|
99
|
+
detail: { type: 'string' },
|
|
100
|
+
receipts: { type: 'array', items: { type: 'string' } },
|
|
101
|
+
},
|
|
102
|
+
required: ['category', 'severity', 'confidence', 'title', 'detail', 'receipts'],
|
|
103
|
+
additionalProperties: false,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
span_action: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
boundary: { type: 'boolean' },
|
|
110
|
+
new_work_ref: { type: 'string' },
|
|
111
|
+
kind: { type: 'string', enum: ['feature', 'bug', 'chore', 'tangent', 'unticketed', 'idle'] },
|
|
112
|
+
},
|
|
113
|
+
required: ['boundary'],
|
|
114
|
+
additionalProperties: false,
|
|
115
|
+
},
|
|
116
|
+
escalate: { type: 'boolean' },
|
|
117
|
+
escalate_reason: { type: 'string' },
|
|
118
|
+
state_updates: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
span_hypothesis: { type: 'string' },
|
|
122
|
+
human_instructions_summary: { type: 'string' },
|
|
123
|
+
},
|
|
124
|
+
required: ['span_hypothesis', 'human_instructions_summary'],
|
|
125
|
+
additionalProperties: false,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
required: ['findings', 'span_action', 'escalate', 'state_updates'],
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class Tier1Reviewer {
|
|
133
|
+
private readonly opts: Tier1Options
|
|
134
|
+
private buffer: LedgerEvent[] = []
|
|
135
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
136
|
+
private running: Promise<void> = Promise.resolve()
|
|
137
|
+
private degraded = false
|
|
138
|
+
|
|
139
|
+
/** Rolling state (spec §3.2): kept compact, fed back each call. */
|
|
140
|
+
private humanInstructionsSummary = ''
|
|
141
|
+
private openFindingTitles: string[] = []
|
|
142
|
+
|
|
143
|
+
constructor(opts: Tier1Options) {
|
|
144
|
+
this.opts = opts
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Feed a tagged ledger event into the window. */
|
|
148
|
+
push(event: LedgerEvent): void {
|
|
149
|
+
this.buffer.push(event)
|
|
150
|
+
const urgent = (event.tags || []).some((t) => SEVERITY_ORDER[t.severity] >= SEVERITY_ORDER.warn)
|
|
151
|
+
if (urgent || this.buffer.length >= this.opts.windowEvents) {
|
|
152
|
+
this.schedule()
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if (!this.debounceTimer) {
|
|
156
|
+
this.debounceTimer = setTimeout(() => {
|
|
157
|
+
this.debounceTimer = null
|
|
158
|
+
this.schedule()
|
|
159
|
+
}, this.opts.debounceMs)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Review whatever is buffered right now, without waiting for the window
|
|
164
|
+
* to fill or the debounce to expire. Used at turn boundaries — the agent
|
|
165
|
+
* just handed control back to the human. */
|
|
166
|
+
reviewNow(): void {
|
|
167
|
+
this.schedule()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Flush any pending window and wait for in-flight calls (session end). */
|
|
171
|
+
async flush(): Promise<void> {
|
|
172
|
+
this.schedule()
|
|
173
|
+
await this.running
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
dispose(): void {
|
|
177
|
+
if (this.debounceTimer) {
|
|
178
|
+
clearTimeout(this.debounceTimer)
|
|
179
|
+
this.debounceTimer = null
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Serialize reviews: one in flight at a time, windows drain in order.
|
|
184
|
+
private schedule(): void {
|
|
185
|
+
if (this.debounceTimer) {
|
|
186
|
+
clearTimeout(this.debounceTimer)
|
|
187
|
+
this.debounceTimer = null
|
|
188
|
+
}
|
|
189
|
+
if (this.buffer.length === 0) return
|
|
190
|
+
const window = this.buffer
|
|
191
|
+
this.buffer = []
|
|
192
|
+
this.running = this.running.then(() => this.review(window)).catch(() => { /* handled inside */ })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async review(window: LedgerEvent[]): Promise<void> {
|
|
196
|
+
if (this.opts.isOverBudget()) {
|
|
197
|
+
if (!this.degraded) {
|
|
198
|
+
this.degraded = true
|
|
199
|
+
this.opts.onHealth('Daily budget cap reached; Tier 1 degraded to Tier-0-only for the rest of the day.')
|
|
200
|
+
}
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
this.degraded = false
|
|
204
|
+
|
|
205
|
+
const windowIds = window.map((e) => e.id)
|
|
206
|
+
const user = this.buildUser(window)
|
|
207
|
+
const system = TIER1_SYSTEM + SENSITIVITY_ADDENDUM[this.opts.getSensitivity?.() || 'all']
|
|
208
|
+
|
|
209
|
+
this.opts.onReviewState?.(true)
|
|
210
|
+
try {
|
|
211
|
+
let lastError = ''
|
|
212
|
+
for (let attempt = 0; attempt <= RETRY_LIMIT; attempt++) {
|
|
213
|
+
try {
|
|
214
|
+
const result = await this.opts.callModel({
|
|
215
|
+
model: this.opts.model,
|
|
216
|
+
maxTokens: MAX_OUTPUT_TOKENS,
|
|
217
|
+
system,
|
|
218
|
+
user: lastError
|
|
219
|
+
? `${user}\n\nYour previous output was invalid (${lastError}). Return valid JSON matching the schema.`
|
|
220
|
+
: user,
|
|
221
|
+
schema: TIER1_SCHEMA,
|
|
222
|
+
})
|
|
223
|
+
this.opts.onUsage(this.opts.model, result.usage)
|
|
224
|
+
const parsed = this.parse(result.text, windowIds)
|
|
225
|
+
this.apply(parsed)
|
|
226
|
+
this.opts.onReviewed?.(window.length, parsed.findings.length)
|
|
227
|
+
return
|
|
228
|
+
} catch (err: any) {
|
|
229
|
+
lastError = String(err?.message || err).slice(0, 300)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
this.opts.onHealth(`Tier 1 review failed after retry: ${lastError}. Window ${windowIds[0]}..${windowIds[windowIds.length - 1]} unreviewed.`)
|
|
233
|
+
} finally {
|
|
234
|
+
this.opts.onReviewState?.(false)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private buildUser(window: LedgerEvent[]): string {
|
|
239
|
+
const compact = window.map((e) => {
|
|
240
|
+
const out: Record<string, unknown> = { id: e.id, ts: e.ts, type: e.type }
|
|
241
|
+
if (e.text) out.text = truncateForTier1(e.text)
|
|
242
|
+
if (e.tool) out.tool = e.tool
|
|
243
|
+
if (e.input) out.input = truncateForTier1(e.input)
|
|
244
|
+
if (e.toolUseId) out.tool_use_id = e.toolUseId
|
|
245
|
+
if (e.isError !== undefined) out.is_error = e.isError
|
|
246
|
+
if (e.state) out.state = e.state
|
|
247
|
+
if (e.tags?.length) out.tags = e.tags
|
|
248
|
+
return JSON.stringify(out)
|
|
249
|
+
})
|
|
250
|
+
const state = {
|
|
251
|
+
span_hypothesis: this.opts.getSpanHypothesis(),
|
|
252
|
+
open_findings: this.openFindingTitles.slice(-10),
|
|
253
|
+
human_instructions_summary: this.humanInstructionsSummary,
|
|
254
|
+
}
|
|
255
|
+
return `STATE:\n${JSON.stringify(state)}\n\nWINDOW (${window.length} events):\n${compact.join('\n')}`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private parse(text: string, windowIds: string[]): Tier1Output {
|
|
259
|
+
const parsed = JSON.parse(text) as Tier1Output
|
|
260
|
+
if (!Array.isArray(parsed.findings) || !parsed.span_action || !parsed.state_updates) {
|
|
261
|
+
throw new Error('missing required fields')
|
|
262
|
+
}
|
|
263
|
+
// Receipts must resolve to events in this window; drop hallucinated ids.
|
|
264
|
+
const valid = new Set(windowIds)
|
|
265
|
+
for (const f of parsed.findings) {
|
|
266
|
+
f.receipts = (f.receipts || []).filter((r) => valid.has(r))
|
|
267
|
+
}
|
|
268
|
+
return parsed
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private apply(out: Tier1Output): void {
|
|
272
|
+
if (out.findings.length > 0) {
|
|
273
|
+
const ts = new Date().toISOString()
|
|
274
|
+
this.opts.onFindings(
|
|
275
|
+
out.findings.map((f) => ({
|
|
276
|
+
type: 'observer.finding' as const,
|
|
277
|
+
ts,
|
|
278
|
+
category: f.category,
|
|
279
|
+
severity: f.severity,
|
|
280
|
+
confidence: Math.max(0, Math.min(1, f.confidence)),
|
|
281
|
+
title: f.title,
|
|
282
|
+
detail: f.detail,
|
|
283
|
+
receipts: f.receipts,
|
|
284
|
+
tier: 1 as const,
|
|
285
|
+
status: 'open' as const,
|
|
286
|
+
delivery: ['digest' as const],
|
|
287
|
+
resolution: null,
|
|
288
|
+
})),
|
|
289
|
+
)
|
|
290
|
+
this.openFindingTitles.push(...out.findings.map((f) => f.title))
|
|
291
|
+
}
|
|
292
|
+
if (out.span_action.boundary) {
|
|
293
|
+
this.opts.onSpanBoundary(out.span_action.new_work_ref, out.span_action.kind)
|
|
294
|
+
}
|
|
295
|
+
if (out.escalate) {
|
|
296
|
+
this.opts.onEscalate(out.escalate_reason || 'unspecified', [])
|
|
297
|
+
}
|
|
298
|
+
if (out.state_updates?.human_instructions_summary) {
|
|
299
|
+
this.humanInstructionsSummary = out.state_updates.human_instructions_summary.slice(0, 1_000)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
get instructionsSummary(): string {
|
|
304
|
+
return this.humanInstructionsSummary
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Finding, ModelCaller, Span } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tier 2 — session-end digest (spec §3.3, §7.1; Phase 1 cut item 4).
|
|
5
|
+
*
|
|
6
|
+
* The 30-second supervisor summary: what happened, what deserves attention,
|
|
7
|
+
* where the time went — every item backed by receipts already recorded in
|
|
8
|
+
* the ledger. The model call adjudicates escalations and writes the prose;
|
|
9
|
+
* if it fails, a deterministic fallback digest is produced from counts so a
|
|
10
|
+
* session never ends silently.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MAX_OUTPUT_TOKENS = 1_000
|
|
14
|
+
|
|
15
|
+
export interface DigestInput {
|
|
16
|
+
session: string
|
|
17
|
+
agentName: string
|
|
18
|
+
cwd: string
|
|
19
|
+
startedAt: string
|
|
20
|
+
endedAt: string
|
|
21
|
+
findings: Finding[]
|
|
22
|
+
spans: Span[]
|
|
23
|
+
humanInstructionsSummary: string
|
|
24
|
+
escalations: string[]
|
|
25
|
+
/** Recent user messages, for the contradiction sweep. */
|
|
26
|
+
userMessages: string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DigestResult {
|
|
30
|
+
digest: string
|
|
31
|
+
/** True when the model produced it; false for the deterministic fallback. */
|
|
32
|
+
fromModel: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DIGEST_SCHEMA: Record<string, unknown> = {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
digest: { type: 'string' },
|
|
39
|
+
},
|
|
40
|
+
required: ['digest'],
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DIGEST_SYSTEM = `You are Observer, writing the end-of-session digest for a human supervising a coding agent. Target: a 30-second read.
|
|
45
|
+
|
|
46
|
+
Write 3-8 plain sentences (no headers, no markdown tables):
|
|
47
|
+
1. What the session worked on and roughly how the time split (agent-active vs human vs idle), from the spans.
|
|
48
|
+
2. The findings worth the supervisor's attention, most severe first, each with its receipt ids in parentheses. Merge duplicates. Skip pure noise.
|
|
49
|
+
3. If any escalation or user message suggests the agent contradicted an earlier human instruction, say so explicitly — this is the highest-value check.
|
|
50
|
+
4. If there is nothing concerning, say so in one sentence — do not invent concerns.
|
|
51
|
+
|
|
52
|
+
Be concrete and specific. Never soften a real problem; never inflate a non-problem.`
|
|
53
|
+
|
|
54
|
+
export async function generateDigest(
|
|
55
|
+
input: DigestInput,
|
|
56
|
+
callModel: ModelCaller | null,
|
|
57
|
+
model: string,
|
|
58
|
+
onUsage: (model: string, usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }) => void,
|
|
59
|
+
): Promise<DigestResult> {
|
|
60
|
+
if (callModel) {
|
|
61
|
+
try {
|
|
62
|
+
const result = await callModel({
|
|
63
|
+
model,
|
|
64
|
+
maxTokens: MAX_OUTPUT_TOKENS,
|
|
65
|
+
system: DIGEST_SYSTEM,
|
|
66
|
+
user: JSON.stringify({
|
|
67
|
+
session: input.session,
|
|
68
|
+
agent: input.agentName,
|
|
69
|
+
project: input.cwd,
|
|
70
|
+
window: { start: input.startedAt, end: input.endedAt },
|
|
71
|
+
human_instructions_summary: input.humanInstructionsSummary,
|
|
72
|
+
recent_user_messages: input.userMessages.slice(-8).map((m) => m.slice(0, 500)),
|
|
73
|
+
escalations: input.escalations,
|
|
74
|
+
findings: input.findings.map((f) => ({
|
|
75
|
+
id: f.id, category: f.category, severity: f.severity,
|
|
76
|
+
confidence: f.confidence, title: f.title, detail: f.detail, receipts: f.receipts,
|
|
77
|
+
})),
|
|
78
|
+
spans: input.spans.map((s) => ({
|
|
79
|
+
id: s.id, work_ref: s.work_ref, kind: s.kind,
|
|
80
|
+
start: s.start, end: s.end, activity: s.activity,
|
|
81
|
+
})),
|
|
82
|
+
}),
|
|
83
|
+
schema: DIGEST_SCHEMA,
|
|
84
|
+
})
|
|
85
|
+
onUsage(model, result.usage)
|
|
86
|
+
const parsed = JSON.parse(result.text)
|
|
87
|
+
if (typeof parsed?.digest === 'string' && parsed.digest.trim()) {
|
|
88
|
+
return { digest: parsed.digest.trim(), fromModel: true }
|
|
89
|
+
}
|
|
90
|
+
} catch { /* fall through to deterministic digest */ }
|
|
91
|
+
}
|
|
92
|
+
return { digest: fallbackDigest(input), fromModel: false }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Counts-only digest so delivery never depends on a model call succeeding. */
|
|
96
|
+
export function fallbackDigest(input: DigestInput): string {
|
|
97
|
+
const bySeverity = new Map<string, number>()
|
|
98
|
+
for (const f of input.findings) bySeverity.set(f.severity, (bySeverity.get(f.severity) || 0) + 1)
|
|
99
|
+
const sevSummary = [...bySeverity.entries()].map(([s, n]) => `${n} ${s}`).join(', ') || 'none'
|
|
100
|
+
|
|
101
|
+
const activity = input.spans.reduce(
|
|
102
|
+
(acc, s) => ({
|
|
103
|
+
agent: acc.agent + s.activity.agent_active_s,
|
|
104
|
+
human: acc.human + s.activity.human_active_s,
|
|
105
|
+
idle: acc.idle + s.activity.idle_s,
|
|
106
|
+
}),
|
|
107
|
+
{ agent: 0, human: 0, idle: 0 },
|
|
108
|
+
)
|
|
109
|
+
const min = (s: number) => Math.round(s / 60)
|
|
110
|
+
|
|
111
|
+
const workItems = [...new Set(input.spans.map((s) => s.work_ref.external.join(',') || '(unticketed)'))]
|
|
112
|
+
const top = input.findings
|
|
113
|
+
.filter((f) => f.severity === 'high' || f.severity === 'critical')
|
|
114
|
+
.slice(0, 3)
|
|
115
|
+
.map((f) => `${f.title} (${f.receipts.join(', ')})`)
|
|
116
|
+
|
|
117
|
+
const parts = [
|
|
118
|
+
`Session ${input.session} (${input.agentName}): ${input.spans.length} span(s) across ${workItems.join('; ')}.`,
|
|
119
|
+
`Activity: ~${min(activity.agent)}min agent-active, ~${min(activity.human)}min human, ~${min(activity.idle)}min idle.`,
|
|
120
|
+
`Findings: ${sevSummary}.`,
|
|
121
|
+
]
|
|
122
|
+
if (top.length > 0) parts.push(`Needs attention: ${top.join(' | ')}.`)
|
|
123
|
+
if (input.escalations.length > 0) parts.push(`Escalations (unadjudicated): ${input.escalations.join(' | ')}.`)
|
|
124
|
+
return parts.join(' ')
|
|
125
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observer — shared types (spec §4–§6).
|
|
3
|
+
*
|
|
4
|
+
* All Observer outputs are ledger events themselves: append-only JSONL,
|
|
5
|
+
* namespaced (`observer.*`) so consumers can filter. Findings and spans
|
|
6
|
+
* always carry receipts — ledger event ids linking to the exact moment.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Severity = 'info' | 'warn' | 'high' | 'critical'
|
|
10
|
+
|
|
11
|
+
export const SEVERITY_ORDER: Record<Severity, number> = {
|
|
12
|
+
info: 0,
|
|
13
|
+
warn: 1,
|
|
14
|
+
high: 2,
|
|
15
|
+
critical: 3,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Full taxonomy (spec §4). Phase 1 Tier-1 emits only the subset in
|
|
19
|
+
* TIER1_CATEGORIES; the rest are reserved for Tier 0 ground-truth checks
|
|
20
|
+
* (`unverified_claim`, `destructive`) and later phases. */
|
|
21
|
+
export type FindingCategory =
|
|
22
|
+
| 'assumption'
|
|
23
|
+
| 'license'
|
|
24
|
+
| 'security'
|
|
25
|
+
| 'destructive'
|
|
26
|
+
| 'scope_drift'
|
|
27
|
+
| 'unverified_claim'
|
|
28
|
+
| 'test_integrity'
|
|
29
|
+
| 'contradiction'
|
|
30
|
+
| 'bad_practice'
|
|
31
|
+
| 'dependency'
|
|
32
|
+
|
|
33
|
+
export const TIER1_CATEGORIES: FindingCategory[] = [
|
|
34
|
+
'assumption',
|
|
35
|
+
'unverified_claim',
|
|
36
|
+
'license',
|
|
37
|
+
'dependency',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** Tier-0 tag attached to a ledger event for downstream tiers. */
|
|
41
|
+
export interface Tier0Tag {
|
|
42
|
+
rule: string
|
|
43
|
+
severity: Severity
|
|
44
|
+
/** Short human-readable annotation (matched text, path, ticket id…). */
|
|
45
|
+
note?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Normalized ledger event. The `observer.*` types are Observer outputs
|
|
49
|
+
* appended to the same substrate. */
|
|
50
|
+
export type LedgerEventType =
|
|
51
|
+
| 'user_message'
|
|
52
|
+
| 'agent_message'
|
|
53
|
+
| 'tool_use'
|
|
54
|
+
| 'tool_result'
|
|
55
|
+
| 'agent_state'
|
|
56
|
+
| 'observer.finding'
|
|
57
|
+
| 'observer.span'
|
|
58
|
+
| 'observer.digest'
|
|
59
|
+
| 'observer.health'
|
|
60
|
+
|
|
61
|
+
export interface LedgerEvent {
|
|
62
|
+
id: string
|
|
63
|
+
ts: string
|
|
64
|
+
session: string
|
|
65
|
+
type: LedgerEventType
|
|
66
|
+
/** user_message / agent_message text, or truncated tool_result output. */
|
|
67
|
+
text?: string
|
|
68
|
+
/** tool_use: tool name (Bash, Edit, …). */
|
|
69
|
+
tool?: string
|
|
70
|
+
/** tool_use: compact input summary (command, file_path, …). */
|
|
71
|
+
input?: string
|
|
72
|
+
/** tool_result: id linking back to the tool_use. */
|
|
73
|
+
toolUseId?: string
|
|
74
|
+
/** tool_result: the agent's runtime reported failure. */
|
|
75
|
+
isError?: boolean
|
|
76
|
+
/** agent_state: busy | awaiting_input | awaiting_decision | idle. */
|
|
77
|
+
state?: string
|
|
78
|
+
tags?: Tier0Tag[]
|
|
79
|
+
/** observer.* events carry their payload here. */
|
|
80
|
+
payload?: unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Finding (spec §6.1). */
|
|
84
|
+
export interface Finding {
|
|
85
|
+
type: 'observer.finding'
|
|
86
|
+
id: string
|
|
87
|
+
ts: string
|
|
88
|
+
session: string
|
|
89
|
+
category: FindingCategory
|
|
90
|
+
severity: Severity
|
|
91
|
+
confidence: number
|
|
92
|
+
title: string
|
|
93
|
+
detail: string
|
|
94
|
+
receipts: string[]
|
|
95
|
+
tier: 0 | 1 | 2
|
|
96
|
+
status: 'open' | 'ack' | 'dismissed' | 'resolved' | 'escalated'
|
|
97
|
+
delivery: Array<'interrupt' | 'digest' | 'agent_channel'>
|
|
98
|
+
resolution: string | null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type SpanKind = 'feature' | 'bug' | 'chore' | 'tangent' | 'unticketed' | 'idle'
|
|
102
|
+
|
|
103
|
+
export interface WorkRef {
|
|
104
|
+
/** ctlsurf task-tree node id (primary key when known). */
|
|
105
|
+
node: string | null
|
|
106
|
+
/** External ticket ids (JIRA:AUTH-231, …) — attributes, not the key. */
|
|
107
|
+
external: string[]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Span (spec §6.2). */
|
|
111
|
+
export interface Span {
|
|
112
|
+
type: 'observer.span'
|
|
113
|
+
id: string
|
|
114
|
+
session: string
|
|
115
|
+
work_ref: WorkRef
|
|
116
|
+
kind: SpanKind
|
|
117
|
+
start: string
|
|
118
|
+
end: string
|
|
119
|
+
activity: { agent_active_s: number; human_active_s: number; idle_s: number }
|
|
120
|
+
boundary_source: 'tier0' | 'tier1' | 'tier2_retro' | 'human_edit'
|
|
121
|
+
receipts: string[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Tier-1 call output contract (spec §6.3), enforced via structured outputs. */
|
|
125
|
+
export interface Tier1Output {
|
|
126
|
+
findings: Array<{
|
|
127
|
+
category: FindingCategory
|
|
128
|
+
severity: Severity
|
|
129
|
+
confidence: number
|
|
130
|
+
title: string
|
|
131
|
+
detail: string
|
|
132
|
+
receipts: string[]
|
|
133
|
+
}>
|
|
134
|
+
span_action: {
|
|
135
|
+
boundary: boolean
|
|
136
|
+
new_work_ref?: string
|
|
137
|
+
kind?: SpanKind
|
|
138
|
+
}
|
|
139
|
+
escalate: boolean
|
|
140
|
+
escalate_reason?: string
|
|
141
|
+
state_updates: {
|
|
142
|
+
span_hypothesis: string
|
|
143
|
+
human_instructions_summary: string
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Observer block in settings.json (all optional; observer is off unless
|
|
148
|
+
* `enabled` is true and an API key is resolvable). */
|
|
149
|
+
export interface ObserverSettings {
|
|
150
|
+
enabled?: boolean
|
|
151
|
+
/** Falls back to ANTHROPIC_API_KEY in the environment. */
|
|
152
|
+
anthropicApiKey?: string
|
|
153
|
+
tier1Model?: string
|
|
154
|
+
tier2Model?: string
|
|
155
|
+
/** Tier-1 window size in events (default 20). */
|
|
156
|
+
windowEvents?: number
|
|
157
|
+
/** Tier-1 debounce in seconds (default 120). */
|
|
158
|
+
debounceSec?: number
|
|
159
|
+
/** Daily spend cap in USD (default 1). On breach Tier 1 degrades to
|
|
160
|
+
* Tier-0-only and an observer.health event is emitted. */
|
|
161
|
+
dailyCapUsd?: number
|
|
162
|
+
/** Detection threshold (default 'all' — every signal surfaces). */
|
|
163
|
+
sensitivity?: Sensitivity
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Detection threshold lever: `severe` surfaces only what a supervisor must
|
|
167
|
+
* act on; `all` relaxes for maximum signals. Applies to what is surfaced
|
|
168
|
+
* (panel + ctlsurf delivery) and steers the Tier-1 prompt; the local ledger
|
|
169
|
+
* keeps every finding regardless. */
|
|
170
|
+
export type Sensitivity = 'severe' | 'balanced' | 'all'
|
|
171
|
+
|
|
172
|
+
/** Minimum severity surfaced per sensitivity level. */
|
|
173
|
+
export function severityFloor(sensitivity: Sensitivity): Severity {
|
|
174
|
+
if (sensitivity === 'severe') return 'high'
|
|
175
|
+
if (sensitivity === 'balanced') return 'warn'
|
|
176
|
+
return 'info'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const OBSERVER_DEFAULTS = {
|
|
180
|
+
tier1Model: 'claude-haiku-4-5',
|
|
181
|
+
tier2Model: 'claude-sonnet-4-6',
|
|
182
|
+
windowEvents: 20,
|
|
183
|
+
debounceSec: 120,
|
|
184
|
+
dailyCapUsd: 1,
|
|
185
|
+
sensitivity: 'all' as Sensitivity,
|
|
186
|
+
} as const
|
|
187
|
+
|
|
188
|
+
/** One entry in the live feed shown by the in-app Observer panel. Findings,
|
|
189
|
+
* digests, and health events map 1:1 to their ledger records; `status` items
|
|
190
|
+
* are UI-only heartbeats (attached, review ran, …) and are not persisted. */
|
|
191
|
+
export interface ObserverFeedItem {
|
|
192
|
+
id: string
|
|
193
|
+
ts: string
|
|
194
|
+
kind: 'finding' | 'digest' | 'health' | 'status'
|
|
195
|
+
tabId?: string
|
|
196
|
+
session?: string
|
|
197
|
+
title: string
|
|
198
|
+
detail?: string
|
|
199
|
+
category?: FindingCategory
|
|
200
|
+
severity?: Severity
|
|
201
|
+
confidence?: number
|
|
202
|
+
receipts?: string[]
|
|
203
|
+
tier?: 0 | 1 | 2
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Model call abstraction so tiers are testable without the network.
|
|
207
|
+
* The production implementation (anthropic.ts) wraps @anthropic-ai/sdk. */
|
|
208
|
+
export interface ModelCallRequest {
|
|
209
|
+
model: string
|
|
210
|
+
maxTokens: number
|
|
211
|
+
/** Stable system text; the caller marks it for prompt caching. */
|
|
212
|
+
system: string
|
|
213
|
+
/** Single user turn content. */
|
|
214
|
+
user: string
|
|
215
|
+
/** JSON schema for structured output (output_config.format). */
|
|
216
|
+
schema?: Record<string, unknown>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface ModelCallResult {
|
|
220
|
+
text: string
|
|
221
|
+
usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export type ModelCaller = (req: ModelCallRequest) => Promise<ModelCallResult>
|