@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,170 @@
1
+ import type { LedgerEvent, Span, SpanKind, WorkRef } from './types'
2
+ import { extractTicketRefs } from './tier0'
3
+
4
+ /**
5
+ * Span Tracker — deterministic time attribution (spec §5).
6
+ *
7
+ * A span is a contiguous interval attributed to a work item. The activity
8
+ * sub-ledger (agent_active / human_active / idle) is derived purely from
9
+ * pty timing — no model needed:
10
+ * - agent_active: the AgentStateMonitor reports the agent busy
11
+ * - human_active: a human keystroke landed within the last HUMAN_WINDOW_MS
12
+ * - idle: everything else
13
+ *
14
+ * Deterministic segmentation first: a new external ticket id in a user
15
+ * message closes the current span and opens a new one (boundary_source
16
+ * `tier0`). Tier 1 can also emit a boundary from its span hypothesis
17
+ * (`tier1`), costing ~20 output tokens inside a call already happening.
18
+ */
19
+
20
+ const HUMAN_WINDOW_MS = 15_000
21
+
22
+ const BUG_HINT = /\b(bug|fix|broken|crash|error|regression|doesn'?t work|not working)\b/i
23
+ const CHORE_HINT = /\b(bump|upgrade|rename|cleanup|clean up|chore|reformat|typo)\b/i
24
+
25
+ export type SpanSink = (span: Span) => void
26
+
27
+ export class SpanTracker {
28
+ private readonly session: string
29
+ private readonly sink: SpanSink
30
+ private readonly now: () => number
31
+
32
+ private spanSeq = 0
33
+ private start = 0
34
+ private workRef: WorkRef = { node: null, external: [] }
35
+ private kind: SpanKind = 'unticketed'
36
+ private firstEventId: string | null = null
37
+ private lastEventId: string | null = null
38
+
39
+ private agentBusy = false
40
+ private lastHumanAt = 0
41
+ private lastAccountAt = 0
42
+ private activityMs = { agent: 0, human: 0, idle: 0 }
43
+ private started = false
44
+
45
+ constructor(session: string, sink: SpanSink, now: () => number = Date.now) {
46
+ this.session = session
47
+ this.sink = sink
48
+ this.now = now
49
+ }
50
+
51
+ /** Begin attribution at session start. */
52
+ begin(): void {
53
+ if (this.started) return
54
+ this.started = true
55
+ const t = this.now()
56
+ this.start = t
57
+ this.lastAccountAt = t
58
+ }
59
+
60
+ /** A human keystroke reached the pty. */
61
+ noteHumanInput(): void {
62
+ if (!this.started) return
63
+ this.account()
64
+ this.lastHumanAt = this.now()
65
+ }
66
+
67
+ /** Agent activity state changed (from AgentStateMonitor). */
68
+ noteAgentState(state: string): void {
69
+ if (!this.started) return
70
+ this.account()
71
+ this.agentBusy = state === 'busy'
72
+ }
73
+
74
+ /**
75
+ * A ledger event arrived. Detects deterministic boundaries: a user message
76
+ * referencing a different external ticket id switches the work item.
77
+ */
78
+ noteEvent(event: LedgerEvent): void {
79
+ if (!this.started) return
80
+ this.account()
81
+ if (!this.firstEventId) this.firstEventId = event.id
82
+ this.lastEventId = event.id
83
+
84
+ if (event.type !== 'user_message' || !event.text) return
85
+ const refs = extractTicketRefs(event.text)
86
+ if (refs.length === 0) {
87
+ if (this.workRef.external.length === 0) this.kind = this.inferKind(event.text, null)
88
+ return
89
+ }
90
+ const known = this.workRef.external
91
+ if (known.length === 0) {
92
+ // Attribution was unknown until now: the first ticket mention names
93
+ // the current span rather than splitting off a leading stub.
94
+ this.workRef = { node: null, external: refs }
95
+ this.kind = this.inferKind(event.text, refs[0])
96
+ } else if (!refs.some((r) => known.includes(r))) {
97
+ this.closeSpan('tier0')
98
+ this.workRef = { node: null, external: refs }
99
+ this.kind = this.inferKind(event.text, refs[0])
100
+ this.firstEventId = event.id
101
+ this.lastEventId = event.id
102
+ }
103
+ }
104
+
105
+ /** Tier-1 detected a context switch the structural signals missed. */
106
+ applyBoundary(newWorkRef: string | undefined, kind: SpanKind | undefined): void {
107
+ if (!this.started) return
108
+ this.account()
109
+ this.closeSpan('tier1')
110
+ this.workRef = newWorkRef
111
+ ? { node: null, external: extractTicketRefs(newWorkRef).length ? extractTicketRefs(newWorkRef) : [newWorkRef] }
112
+ : { node: null, external: [] }
113
+ this.kind = kind || (this.workRef.external.length ? 'feature' : 'unticketed')
114
+ }
115
+
116
+ /** Session over: close and emit the final span. */
117
+ end(): void {
118
+ if (!this.started) return
119
+ this.account()
120
+ this.closeSpan('tier0')
121
+ this.started = false
122
+ }
123
+
124
+ /** Current hypothesis (fed to Tier 1 as rolling state). */
125
+ currentWorkRef(): string {
126
+ return this.workRef.external.join(',') || '(unticketed)'
127
+ }
128
+
129
+ private inferKind(text: string, ref: string | null): SpanKind {
130
+ if (BUG_HINT.test(text)) return 'bug'
131
+ if (CHORE_HINT.test(text)) return 'chore'
132
+ return ref ? 'feature' : 'unticketed'
133
+ }
134
+
135
+ private account(): void {
136
+ const t = this.now()
137
+ const delta = t - this.lastAccountAt
138
+ if (delta <= 0) return
139
+ if (this.agentBusy) this.activityMs.agent += delta
140
+ else if (t - this.lastHumanAt < HUMAN_WINDOW_MS) this.activityMs.human += delta
141
+ else this.activityMs.idle += delta
142
+ this.lastAccountAt = t
143
+ }
144
+
145
+ private closeSpan(source: Span['boundary_source']): void {
146
+ const t = this.now()
147
+ if (t - this.start < 1_000) return // ignore zero-length churn
148
+ const span: Span = {
149
+ type: 'observer.span',
150
+ id: `spn_${this.session.slice(4, 10)}_${++this.spanSeq}`,
151
+ session: this.session,
152
+ work_ref: this.workRef,
153
+ kind: this.kind,
154
+ start: new Date(this.start).toISOString(),
155
+ end: new Date(t).toISOString(),
156
+ activity: {
157
+ agent_active_s: Math.round(this.activityMs.agent / 1000),
158
+ human_active_s: Math.round(this.activityMs.human / 1000),
159
+ idle_s: Math.round(this.activityMs.idle / 1000),
160
+ },
161
+ boundary_source: source,
162
+ receipts: [this.firstEventId, this.lastEventId].filter((r): r is string => !!r),
163
+ }
164
+ this.sink(span)
165
+ this.start = t
166
+ this.activityMs = { agent: 0, human: 0, idle: 0 }
167
+ this.firstEventId = null
168
+ this.lastEventId = null
169
+ }
170
+ }
@@ -0,0 +1,200 @@
1
+ import type { CtlsurfApi } from '../ctlsurfApi'
2
+ import type { Finding } from './types'
3
+ import { log as baseLog } from '../logger'
4
+
5
+ const FINDINGS_TITLE = 'Observer Findings'
6
+ const FINDINGS_KEY = 'observer_findings'
7
+ const LOG_TITLE = 'Observer'
8
+ const LOG_KEY = 'observer_log'
9
+ const AGENT_DATASTORE_PAGE_TITLE = 'Agent Datastore'
10
+
11
+ /**
12
+ * Observer → ctlsurf delivery (spec §7.1).
13
+ *
14
+ * Findings land in an "Observer Findings" datastore and session digests in
15
+ * an "Observer" log block, both on the project's Agent Datastore page —
16
+ * the same resolution pattern as TicketStore/TimeTracker (folder by path →
17
+ * Agent Datastore page → find/create block by system_key). Everything is
18
+ * best-effort: delivery failures are logged, never thrown.
19
+ */
20
+
21
+ const SEVERITY_OPTIONS = [
22
+ { value: 'info', color: 'gray' },
23
+ { value: 'warn', color: 'yellow' },
24
+ { value: 'high', color: 'orange' },
25
+ { value: 'critical', color: 'red' },
26
+ ]
27
+ const CATEGORY_OPTIONS = [
28
+ { value: 'assumption', color: 'blue' },
29
+ { value: 'license', color: 'purple' },
30
+ { value: 'security', color: 'red' },
31
+ { value: 'destructive', color: 'red' },
32
+ { value: 'scope_drift', color: 'yellow' },
33
+ { value: 'unverified_claim', color: 'orange' },
34
+ { value: 'test_integrity', color: 'red' },
35
+ { value: 'contradiction', color: 'red' },
36
+ { value: 'bad_practice', color: 'yellow' },
37
+ { value: 'dependency', color: 'blue' },
38
+ ]
39
+ const STATUS_OPTIONS = [
40
+ { value: 'open', color: 'blue' },
41
+ { value: 'ack', color: 'yellow' },
42
+ { value: 'dismissed', color: 'gray' },
43
+ { value: 'resolved', color: 'green' },
44
+ { value: 'escalated', color: 'red' },
45
+ ]
46
+
47
+ interface ColumnSpec {
48
+ name: string
49
+ type: string
50
+ options?: Array<{ value: string; color?: string }>
51
+ }
52
+
53
+ const COLUMNS: ColumnSpec[] = [
54
+ { name: 'Time', type: 'date' },
55
+ { name: 'Title', type: 'text' },
56
+ { name: 'Category', type: 'select', options: CATEGORY_OPTIONS },
57
+ { name: 'Severity', type: 'select', options: SEVERITY_OPTIONS },
58
+ { name: 'Confidence', type: 'number' },
59
+ { name: 'Detail', type: 'text' },
60
+ { name: 'Receipts', type: 'text' },
61
+ { name: 'Session', type: 'text' },
62
+ { name: 'Tier', type: 'number' },
63
+ { name: 'Status', type: 'select', options: STATUS_OPTIONS },
64
+ ]
65
+
66
+ function log(...args: unknown[]): void {
67
+ baseLog('[observer-store]', ...args)
68
+ }
69
+
70
+ function findPageByTitle(pages: any[], title: string): any | null {
71
+ for (const p of pages) {
72
+ if (p?.title === title) return p
73
+ if (p?.children?.length) {
74
+ const c = findPageByTitle(p.children, title)
75
+ if (c) return c
76
+ }
77
+ }
78
+ return null
79
+ }
80
+
81
+ export class ObserverStore {
82
+ private api: CtlsurfApi
83
+ private findingsCache = new Map<string, string>()
84
+ private logCache = new Map<string, string>()
85
+
86
+ constructor(api: CtlsurfApi) {
87
+ this.api = api
88
+ }
89
+
90
+ async addFindings(cwd: string, findings: Finding[]): Promise<void> {
91
+ if (findings.length === 0 || !this.api.getApiKey()) return
92
+ try {
93
+ const blockId = await this.ensureBlock(cwd, this.findingsCache, {
94
+ type: 'datastore',
95
+ title: FINDINGS_TITLE,
96
+ systemKey: FINDINGS_KEY,
97
+ columns: COLUMNS,
98
+ })
99
+ if (!blockId) return
100
+ for (const f of findings) {
101
+ await this.api.addRow(blockId, {
102
+ Time: f.ts,
103
+ Title: f.title,
104
+ Category: f.category,
105
+ Severity: f.severity,
106
+ Confidence: Math.round(f.confidence * 100) / 100,
107
+ Detail: f.detail,
108
+ Receipts: f.receipts.join(', '),
109
+ Session: f.session,
110
+ Tier: f.tier,
111
+ Status: f.status,
112
+ })
113
+ }
114
+ log(`Delivered ${findings.length} finding(s) for ${cwd}`)
115
+ } catch (err: any) {
116
+ log(`addFindings failed for ${cwd}: ${err?.message || err}`)
117
+ }
118
+ }
119
+
120
+ async appendDigest(
121
+ cwd: string,
122
+ digest: string,
123
+ data: Record<string, unknown>,
124
+ ): Promise<void> {
125
+ if (!this.api.getApiKey()) return
126
+ try {
127
+ const blockId = await this.ensureBlock(cwd, this.logCache, {
128
+ type: 'log',
129
+ title: LOG_TITLE,
130
+ systemKey: LOG_KEY,
131
+ })
132
+ if (!blockId) return
133
+ await this.api.appendLog(blockId, 'session_digest', digest, data)
134
+ log(`Delivered session digest for ${cwd}`)
135
+ } catch (err: any) {
136
+ log(`appendDigest failed for ${cwd}: ${err?.message || err}`)
137
+ }
138
+ }
139
+
140
+ /** Folder by path → Agent Datastore page → find/create block by system_key. */
141
+ private async ensureBlock(
142
+ cwd: string,
143
+ cache: Map<string, string>,
144
+ spec: { type: string; title: string; systemKey: string; columns?: ColumnSpec[] },
145
+ ): Promise<string | null> {
146
+ const cached = cache.get(cwd)
147
+ if (cached) return cached
148
+
149
+ let folder: any = null
150
+ try {
151
+ folder = await this.api.findFolderByPath(cwd)
152
+ } catch {
153
+ return null
154
+ }
155
+ if (!folder?.id) return null
156
+
157
+ const folderDetail = await this.api.getFolder(folder.id)
158
+ const agentPage = findPageByTitle(folderDetail?.pages || [], AGENT_DATASTORE_PAGE_TITLE)
159
+ if (!agentPage?.id) return null
160
+
161
+ const summaries = (await this.api.getPageBlockSummaries(agentPage.id)) || []
162
+ for (const s of summaries.filter((b: any) => b?.type === spec.type && b?.id)) {
163
+ try {
164
+ const block = await this.api.getBlock(s.id)
165
+ const props = (block?.props || {}) as Record<string, unknown>
166
+ if (props.system_key === spec.systemKey || s.title === spec.title) {
167
+ if (props.system_key !== spec.systemKey) {
168
+ await this.api.updateBlock(s.id, { props: { ...props, system_key: spec.systemKey } }).catch(() => { /* ignore */ })
169
+ }
170
+ cache.set(cwd, s.id)
171
+ return s.id
172
+ }
173
+ } catch { /* try the next candidate */ }
174
+ }
175
+
176
+ const props: Record<string, unknown> = { system_key: spec.systemKey }
177
+ if (spec.columns) {
178
+ props.columns = spec.columns.map((c, i) => ({
179
+ id: `col_${i}`,
180
+ name: c.name,
181
+ type: c.type,
182
+ ...(c.options ? { options: c.options } : {}),
183
+ }))
184
+ } else {
185
+ props.entries = []
186
+ props.max_entries = 500
187
+ }
188
+ const created = await this.api.createBlock(agentPage.id, {
189
+ type: spec.type,
190
+ title: spec.title,
191
+ props,
192
+ })
193
+ if (created?.id) {
194
+ log(`Created "${spec.title}" block on Agent Datastore page for ${cwd}`)
195
+ cache.set(cwd, created.id)
196
+ return created.id
197
+ }
198
+ return null
199
+ }
200
+ }
@@ -0,0 +1,234 @@
1
+ import path from 'path'
2
+ import type { LedgerEvent, Tier0Tag } from './types'
3
+
4
+ /**
5
+ * Tier 0 — static taggers + truncation policy (spec §3.1, Phase 1 cut).
6
+ *
7
+ * Zero model cost. Pure pattern matches that annotate ledger events for
8
+ * downstream tiers, plus the truncation policy applied to what Tier 1 reads
9
+ * (the full output stays in the ledger).
10
+ *
11
+ * No gates yet: destructive patterns are tagged `would_gate:*` (log-only
12
+ * "would have gated") and surface in the digest, never block.
13
+ */
14
+
15
+ // ─── Truncation policy ───────────────────────────
16
+
17
+ const TRUNCATE_THRESHOLD = 2_000
18
+ const HEAD_CHARS = 900
19
+ const TAIL_CHARS = 400
20
+ const MAX_ERROR_LINES = 5
21
+
22
+ /** Head + tail + structural summary for oversized tool output. */
23
+ export function truncateForTier1(content: string): string {
24
+ if (content.length <= TRUNCATE_THRESHOLD) return content
25
+ const lines = content.split('\n')
26
+ const errorLines = lines
27
+ .filter((l) => /\b(error|failed|failure|exception|traceback|fatal)\b/i.test(l))
28
+ .slice(0, MAX_ERROR_LINES)
29
+ const summaryParts = [`[truncated: ${content.length} chars, ${lines.length} lines]`]
30
+ if (errorLines.length > 0) {
31
+ summaryParts.push(`[error lines: ${errorLines.map((l) => l.trim().slice(0, 160)).join(' | ')}]`)
32
+ }
33
+ return `${content.slice(0, HEAD_CHARS)}\n…\n${content.slice(-TAIL_CHARS)}\n${summaryParts.join('\n')}`
34
+ }
35
+
36
+ // ─── Pattern tables ──────────────────────────────
37
+
38
+ /** Assumption phrases in agent text — the residue of unstated decisions. */
39
+ const ASSUMPTION_PATTERNS =
40
+ /\bI(?:'|’)?ll assume\b|\bI(?:'|’)?m assuming\b|\bassuming (?:that|the|you|it)\b|\bfor simplicity\b|\bin production you(?:'|’)?d\b|\bshould work\b|\bfor now\b|\bas a placeholder\b|\bhardcod(?:e|ed|ing) for now\b/i
41
+
42
+ /** Completion claims the ledger can ground-truth against. */
43
+ const TEST_CLAIM_PATTERNS =
44
+ /\btests? (?:are |all |now )?(?:pass|passing|green)\b|\ball (?:tests? )?green\b|\btest suite passes\b/i
45
+ const DEPLOY_CLAIM_PATTERNS = /\b(?:deployed|published|released) (?:to|the|successfully)\b|\bsuccessfully (?:deployed|published)\b/i
46
+
47
+ /** Dependency manifests whose diffs deserve license/pinning review. */
48
+ const MANIFEST_FILES =
49
+ /\b(?:package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.toml|Cargo\.lock|requirements(?:-[\w]+)?\.txt|pyproject\.toml|poetry\.lock|Pipfile(?:\.lock)?|go\.mod|go\.sum|Gemfile(?:\.lock)?|composer\.json|composer\.lock)\b/
50
+
51
+ /** Dependency-adding commands. */
52
+ const DEP_ADD_COMMANDS =
53
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:install|add|i)\s+(?!-D\s*$)\S|\bpip3?\s+install\s+\S|\bcargo\s+add\s+\S|\bgo\s+get\s+\S|\bgem\s+install\s+\S|\bcomposer\s+require\s+\S/
54
+
55
+ /** Test-runner invocations (paired with exit results for ground truth). */
56
+ const TEST_COMMANDS =
57
+ /(?:^|[;&|]\s*)(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|\bpytest\b|\bjest\b|\bvitest\b|\bgo\s+test\b|\bcargo\s+(?:test|nextest)\b|\brspec\b|\bphpunit\b|\bmvn\s+test\b|\bgradle(?:w)?\s+test\b/
58
+
59
+ /** Secret-shaped strings: known prefixes only (near-zero false positives). */
60
+ const SECRET_PATTERNS: Array<{ re: RegExp; label: string }> = [
61
+ { re: /\bAKIA[0-9A-Z]{16}\b/, label: 'AWS access key id' },
62
+ { re: /\bghp_[A-Za-z0-9]{36,}\b/, label: 'GitHub personal access token' },
63
+ { re: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/, label: 'GitHub fine-grained PAT' },
64
+ { re: /\bsk-ant-[A-Za-z0-9_-]{24,}\b/, label: 'Anthropic API key' },
65
+ { re: /\bsk-[A-Za-z0-9]{40,}\b/, label: 'API secret key' },
66
+ { re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, label: 'Slack token' },
67
+ { re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/, label: 'private key material' },
68
+ ]
69
+
70
+ /** External ticket ids (Jira/Linear style) — feeds span attribution. */
71
+ const TICKET_REF = /\b[A-Z][A-Z0-9]{1,9}-\d{1,6}\b/g
72
+ /** Common uppercase tokens that match the Jira shape but aren't tickets. */
73
+ const TICKET_REF_STOPLIST = /^(?:UTF|ISO|SHA|MD|RFC|CVE|GPT|HTTP|TLS|X|V)\d*-/
74
+
75
+ // ─── Would-gate rules (log-only in Phase 1) ──────
76
+
77
+ interface GateRule {
78
+ rule: string
79
+ severity: Tier0Tag['severity']
80
+ test: (command: string, cwd: string) => boolean
81
+ }
82
+
83
+ const GATE_RULES: GateRule[] = [
84
+ {
85
+ rule: 'would_gate:rm_rf_outside_workspace',
86
+ severity: 'high',
87
+ test: (cmd, cwd) => {
88
+ const m = cmd.match(/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+(.+)/)
89
+ if (!m) return false
90
+ const targets = m[2].split(/\s+/).filter((t) => t && !t.startsWith('-'))
91
+ return targets.some((t) => {
92
+ if (t === '/' || t === '/*' || t === '~' || t === '~/') return true
93
+ if (!path.isAbsolute(t)) return false
94
+ return !t.startsWith(cwd)
95
+ })
96
+ },
97
+ },
98
+ {
99
+ rule: 'would_gate:force_push',
100
+ severity: 'high',
101
+ test: (cmd) => /\bgit\s+push\b/.test(cmd) && /\s(?:--force(?!-with-lease)|-f)\b/.test(cmd),
102
+ },
103
+ {
104
+ rule: 'would_gate:chmod_777',
105
+ severity: 'warn',
106
+ test: (cmd) => /\bchmod\s+(-[a-zA-Z]+\s+)*0?777\b/.test(cmd),
107
+ },
108
+ {
109
+ rule: 'would_gate:curl_pipe_sh',
110
+ severity: 'high',
111
+ test: (cmd) => /\b(?:curl|wget)\b[^|;&]*\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b/.test(cmd),
112
+ },
113
+ {
114
+ rule: 'would_gate:drop_table',
115
+ severity: 'high',
116
+ test: (cmd) => /\bDROP\s+(?:TABLE|DATABASE|SCHEMA)\b/i.test(cmd),
117
+ },
118
+ {
119
+ rule: 'would_gate:env_file_write',
120
+ severity: 'warn',
121
+ test: (cmd) => /(?:>>?\s*|tee\s+(?:-a\s+)?)\S*\.env(?:\.\w+)?\b/.test(cmd),
122
+ },
123
+ {
124
+ rule: 'would_gate:tls_verify_off',
125
+ severity: 'warn',
126
+ test: (cmd) =>
127
+ /\bcurl\b[^|;&]*\s(?:-k|--insecure)\b/.test(cmd) ||
128
+ /NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0/.test(cmd) ||
129
+ /\bgit\s+config\b.*http\.sslVerify\s+false/i.test(cmd),
130
+ },
131
+ ]
132
+
133
+ // ─── Tagger ──────────────────────────────────────
134
+
135
+ /** Extract external ticket references from text (deduped, stoplisted). */
136
+ export function extractTicketRefs(text: string): string[] {
137
+ const refs = new Set<string>()
138
+ for (const m of text.match(TICKET_REF) || []) {
139
+ if (!TICKET_REF_STOPLIST.test(m)) refs.add(m)
140
+ }
141
+ return [...refs]
142
+ }
143
+
144
+ /** Annotate one normalized event with Tier-0 tags. */
145
+ export function tagEvent(event: LedgerEvent, cwd: string): Tier0Tag[] {
146
+ const tags: Tier0Tag[] = []
147
+ const text = event.text || ''
148
+ const input = event.input || ''
149
+
150
+ if (event.type === 'agent_message') {
151
+ const assumption = text.match(ASSUMPTION_PATTERNS)
152
+ if (assumption) tags.push({ rule: 'assumption_language', severity: 'info', note: assumption[0] })
153
+ const testClaim = text.match(TEST_CLAIM_PATTERNS)
154
+ if (testClaim) tags.push({ rule: 'claim_tests_pass', severity: 'info', note: testClaim[0] })
155
+ const deployClaim = text.match(DEPLOY_CLAIM_PATTERNS)
156
+ if (deployClaim) tags.push({ rule: 'claim_deployed', severity: 'info', note: deployClaim[0] })
157
+ }
158
+
159
+ if (event.type === 'user_message' || event.type === 'agent_message') {
160
+ for (const ref of extractTicketRefs(text)) {
161
+ tags.push({ rule: 'ticket_ref', severity: 'info', note: ref })
162
+ }
163
+ }
164
+
165
+ if (event.type === 'tool_use') {
166
+ if (MANIFEST_FILES.test(input)) {
167
+ tags.push({ rule: 'manifest_change', severity: 'warn', note: input.match(MANIFEST_FILES)?.[0] })
168
+ }
169
+ if (DEP_ADD_COMMANDS.test(input)) {
170
+ tags.push({ rule: 'dependency_add', severity: 'warn', note: input.slice(0, 160) })
171
+ }
172
+ if (TEST_COMMANDS.test(input)) {
173
+ tags.push({ rule: 'test_command', severity: 'info', note: input.slice(0, 160) })
174
+ }
175
+ for (const gate of GATE_RULES) {
176
+ if (gate.test(input, cwd)) {
177
+ tags.push({ rule: gate.rule, severity: gate.severity, note: input.slice(0, 160) })
178
+ }
179
+ }
180
+ }
181
+
182
+ const secretHaystack = `${text}\n${input}`
183
+ for (const { re, label } of SECRET_PATTERNS) {
184
+ if (re.test(secretHaystack)) {
185
+ tags.push({ rule: 'secret_material', severity: 'high', note: label })
186
+ break
187
+ }
188
+ }
189
+
190
+ return tags
191
+ }
192
+
193
+ // ─── Ground truth: claim vs ledger (spec §4) ─────
194
+
195
+ export interface ClaimCheckResult {
196
+ verified: boolean
197
+ detail: string
198
+ receipts: string[]
199
+ }
200
+
201
+ /**
202
+ * "Tests pass" is decidable from the ledger, not vibes: was a test command
203
+ * actually run since the last user message, and did its result succeed?
204
+ * Tuned aggressive — nearly false-positive-free by construction.
205
+ */
206
+ export function checkTestClaim(recent: LedgerEvent[], claimEventId: string): ClaimCheckResult {
207
+ let lastUserIdx = -1
208
+ for (let i = recent.length - 1; i >= 0; i--) {
209
+ if (recent[i].type === 'user_message') { lastUserIdx = i; break }
210
+ }
211
+ const windowEvents = recent.slice(lastUserIdx + 1)
212
+
213
+ const testRuns = windowEvents.filter(
214
+ (e) => e.type === 'tool_use' && (e.tags || []).some((t) => t.rule === 'test_command'),
215
+ )
216
+ if (testRuns.length === 0) {
217
+ return {
218
+ verified: false,
219
+ detail: `Agent claimed tests pass at ${claimEventId}; no test command in the ledger since the last human message.`,
220
+ receipts: [claimEventId],
221
+ }
222
+ }
223
+
224
+ const lastRun = testRuns[testRuns.length - 1]
225
+ const result = windowEvents.find((e) => e.type === 'tool_result' && e.toolUseId && e.toolUseId === lastRun.toolUseId)
226
+ if (result?.isError) {
227
+ return {
228
+ verified: false,
229
+ detail: `Agent claimed tests pass at ${claimEventId}; most recent test run ${lastRun.id} failed (${result.id}).`,
230
+ receipts: [claimEventId, lastRun.id, result.id],
231
+ }
232
+ }
233
+ return { verified: true, detail: '', receipts: [claimEventId, lastRun.id] }
234
+ }