@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.
- 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-bQYoCKra.js} +3 -3
- package/out/renderer/assets/{freemarker2-C1XYRbOq.js → freemarker2-BS1ERQl_.js} +1 -1
- package/out/renderer/assets/{handlebars-BSdbbnMW.js → handlebars-B-ZEHRLr.js} +1 -1
- package/out/renderer/assets/{html-96igtyOz.js → html-lk9e1Lkl.js} +1 -1
- package/out/renderer/assets/{htmlMode-BRnCcIzp.js → htmlMode-Ds1eMYgM.js} +3 -3
- package/out/renderer/assets/{index-Cc-jDHm3.js → index-BJNR4hRK.js} +380 -23
- package/out/renderer/assets/{index-BEekQe3-.css → index-BpCORklm.css} +217 -0
- package/out/renderer/assets/{javascript-7MdRNHk9.js → javascript-D6OeziKX.js} +2 -2
- package/out/renderer/assets/{jsonMode-oGam2go_.js → jsonMode-t5SESE75.js} +3 -3
- package/out/renderer/assets/{liquid-DoKC7ba2.js → liquid-B5Wjh0p_.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-C2h5qGCv.js → lspLanguageFeatures-BEgiDCIW.js} +1 -1
- package/out/renderer/assets/{mdx-0w8n1HGo.js → mdx-ByNAXh8t.js} +1 -1
- package/out/renderer/assets/{python-BfY4HssB.js → python-CIFO0XQW.js} +1 -1
- package/out/renderer/assets/{razor-BoDk9Ula.js → razor-LXHhGcNO.js} +1 -1
- package/out/renderer/assets/{tsMode-CX0oVKRt.js → tsMode-WiO8CDaI.js} +1 -1
- package/out/renderer/assets/{typescript-BqIU7abP.js → typescript-ZBQlq256.js} +1 -1
- package/out/renderer/assets/{xml-BlSDnxFw.js → xml-DBaLJlQU.js} +1 -1
- package/out/renderer/assets/{yaml-bVMmGUtU.js → yaml-CId9skah.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/components/TerminalPanel.tsx +29 -0
- 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
|
+
}
|