@phenx-inc/ctlsurf 0.13.6 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/out/headless/index.mjs +13663 -385
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +11665 -286
  4. package/out/main/node-CUnQxFha.js +868 -0
  5. package/out/preload/index.js +17 -0
  6. package/out/renderer/assets/{cssMode-CsSLnKZ-.js → cssMode-bQYoCKra.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-C1XYRbOq.js → freemarker2-BS1ERQl_.js} +1 -1
  8. package/out/renderer/assets/{handlebars-BSdbbnMW.js → handlebars-B-ZEHRLr.js} +1 -1
  9. package/out/renderer/assets/{html-96igtyOz.js → html-lk9e1Lkl.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-BRnCcIzp.js → htmlMode-Ds1eMYgM.js} +3 -3
  11. package/out/renderer/assets/{index-Cc-jDHm3.js → index-BJNR4hRK.js} +380 -23
  12. package/out/renderer/assets/{index-BEekQe3-.css → index-BpCORklm.css} +217 -0
  13. package/out/renderer/assets/{javascript-7MdRNHk9.js → javascript-D6OeziKX.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-oGam2go_.js → jsonMode-t5SESE75.js} +3 -3
  15. package/out/renderer/assets/{liquid-DoKC7ba2.js → liquid-B5Wjh0p_.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-C2h5qGCv.js → lspLanguageFeatures-BEgiDCIW.js} +1 -1
  17. package/out/renderer/assets/{mdx-0w8n1HGo.js → mdx-ByNAXh8t.js} +1 -1
  18. package/out/renderer/assets/{python-BfY4HssB.js → python-CIFO0XQW.js} +1 -1
  19. package/out/renderer/assets/{razor-BoDk9Ula.js → razor-LXHhGcNO.js} +1 -1
  20. package/out/renderer/assets/{tsMode-CX0oVKRt.js → tsMode-WiO8CDaI.js} +1 -1
  21. package/out/renderer/assets/{typescript-BqIU7abP.js → typescript-ZBQlq256.js} +1 -1
  22. package/out/renderer/assets/{xml-BlSDnxFw.js → xml-DBaLJlQU.js} +1 -1
  23. package/out/renderer/assets/{yaml-bVMmGUtU.js → yaml-CId9skah.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +2 -1
  26. package/src/main/index.ts +36 -2
  27. package/src/main/observer/anthropic.ts +48 -0
  28. package/src/main/observer/budget.ts +79 -0
  29. package/src/main/observer/ledger.ts +61 -0
  30. package/src/main/observer/observer.ts +566 -0
  31. package/src/main/observer/spans.ts +170 -0
  32. package/src/main/observer/store.ts +200 -0
  33. package/src/main/observer/tier0.ts +234 -0
  34. package/src/main/observer/tier1.ts +306 -0
  35. package/src/main/observer/tier2.ts +125 -0
  36. package/src/main/observer/types.ts +224 -0
  37. package/src/main/orchestrator.ts +74 -0
  38. package/src/main/transcripts.ts +18 -0
  39. package/src/preload/index.ts +36 -0
  40. package/src/renderer/App.tsx +22 -0
  41. package/src/renderer/components/ObserverPanel.tsx +289 -0
  42. package/src/renderer/components/SettingsDialog.tsx +104 -0
  43. package/src/renderer/components/TerminalPanel.tsx +29 -0
  44. 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>