@artale/pi-pai 4.3.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.
@@ -0,0 +1,1136 @@
1
+ /**
2
+ * π-PAI v4.2 — Personal AI Infrastructure Extension for Pi
3
+ *
4
+ * Synced with Miessler's PAI v4.0.3 algorithm:
5
+ * - Algorithm: OBSERVE → PLAN → DECIDE → EXECUTE → VERIFY (v4 loop)
6
+ * - ISC decomposition: splitting test, count gates, anti-criteria
7
+ * - 5 effort levels with ISC minimums: Standard(8)/Extended(16)/Advanced(24)/Deep(40)/Comprehensive(64)
8
+ * - Time budgets per effort level with auto-compress at 150%
9
+ * - Capability selection + invocation tracking
10
+ * - Ratings + sentiment tracking with trend analysis
11
+ * - Agent persona dispatch (architect, pentester, designer, etc.)
12
+ * - Plans directory convention (.pi/plans/)
13
+ * - Self-evolution trigger (learning pattern detection)
14
+ * - Enhanced observability: PaiSplittingTest, PaiIscGate, PaiCapability, PaiEffortCompress events
15
+ *
16
+ * Also includes:
17
+ * - Ralph Wiggum deterministic iteration engine
18
+ * - Damage control (YAML-based path/command guards)
19
+ * - Templates (trading, saas, devops, research, agent)
20
+ *
21
+ * v4.2: Full v4.0.3 sync — 7 features:
22
+ * 1. ISC splitting test (atomicity validation)
23
+ * 2. ISC count gate (effort-level minimums)
24
+ * 3. Anti-criteria (/pai isca)
25
+ * 4. Capability selection (/pai capabilities)
26
+ * 5. Capability invocation tracking (tool_call counting)
27
+ * 6. Time budgets with auto-compress warning
28
+ * 7. Enhanced observability events for dashboard
29
+ */
30
+
31
+ import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent'
32
+ import { Type } from '@sinclair/typebox'
33
+ import * as fs from 'fs'
34
+ import * as path from 'path'
35
+ import * as os from 'os'
36
+ import YAML from 'js-yaml'
37
+
38
+ // ── Observability Bridge ─────────────────────────────────────────────────────
39
+ // Writes events to ~/.claude/history/raw-outputs/ in the same format as
40
+ // Claude Code's universal_hook_logger.py — so the PAI dashboard at :5172
41
+ // shows Pi sessions alongside Claude Code sessions.
42
+
43
+ let observeSessionId = `pi-pai-${Date.now().toString(36)}`
44
+
45
+ function emitObserveEvent(hookType: string, payload: Record<string, unknown>) {
46
+ try {
47
+ const now = new Date()
48
+ const dir = path.join(os.homedir(), '.claude', 'history', 'raw-outputs', `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`)
49
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
50
+ const file = path.join(dir, `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}_all-events.jsonl`)
51
+ const entry = {
52
+ source_app: 'pi-pai',
53
+ session_id: observeSessionId,
54
+ hook_event_type: hookType,
55
+ payload: { session_id: observeSessionId, hook_event_name: hookType, ...payload },
56
+ timestamp: Date.now(),
57
+ }
58
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n')
59
+ } catch { /* non-blocking */ }
60
+ }
61
+
62
+ // ── Types ────────────────────────────────────────────────────────────────────
63
+
64
+ type AlgorithmPhase = 'OBSERVE' | 'PLAN' | 'DECIDE' | 'EXECUTE' | 'VERIFY'
65
+ type EffortLevel = 'standard' | 'extended' | 'advanced' | 'deep' | 'comprehensive'
66
+ type GoalStatus = 'active' | 'blocked' | 'completed' | 'paused'
67
+ type Sentiment = 'positive' | 'neutral' | 'negative'
68
+
69
+ // ── v4.0.3: ISC Count Minimums & Min Capabilities (Feature #2, #4) ───────────
70
+ const ISC_MINIMUMS: Record<EffortLevel, number> = {
71
+ standard: 8, extended: 16, advanced: 24, deep: 40, comprehensive: 64,
72
+ }
73
+
74
+ // ── v4.0.3: Time Budgets in minutes (Feature #6) ────────────────────────────
75
+ const TIME_BUDGETS_MIN: Record<EffortLevel, number> = {
76
+ standard: 2, extended: 8, advanced: 16, deep: 32, comprehensive: 120,
77
+ }
78
+ const TIME_COMPRESS_FACTOR = 1.5 // auto-compress warning at 150% of budget
79
+
80
+ // ── v4.0.3: ISC Splitting Test (Feature #1) ─────────────────────────────────
81
+ // Returns warnings for non-atomic ISC criteria
82
+ function splittingTest(criterion: string): string[] {
83
+ const warnings: string[] = []
84
+ // "And" / "With" test
85
+ if (/\b(and|with|including|plus)\b/i.test(criterion)) {
86
+ warnings.push(`Contains "${criterion.match(/\b(and|with|including|plus)\b/i)?.[0]}" — likely two criteria. Split them.`)
87
+ }
88
+ // Scope word test
89
+ if (/\b(all|every|complete|full|each)\b/i.test(criterion)) {
90
+ warnings.push(`Contains "${criterion.match(/\b(all|every|complete|full|each)\b/i)?.[0]}" — enumerate what this means specifically.`)
91
+ }
92
+ // Length test — atomic ISC should be 8-12 words
93
+ const words = criterion.trim().split(/\s+/).length
94
+ if (words > 15) warnings.push(`${words} words — too long for atomic ISC (target 8-12). Split or simplify.`)
95
+ if (words < 4) warnings.push(`${words} words — too vague. Be specific and testable.`)
96
+ // Domain boundary test
97
+ const domains = [/\bUI\b|display|render|visible|button|page/i, /\bAPI\b|endpoint|request|response|status/i, /\bdata|database|schema|field|column/i, /\blogic|flow|condition|branch|validate/i]
98
+ const crossedDomains = domains.filter(d => d.test(criterion))
99
+ if (crossedDomains.length > 1) warnings.push('Crosses multiple domains (UI/API/data/logic) — split per domain boundary.')
100
+ return warnings
101
+ }
102
+
103
+ interface Goal { id: string; title: string; status: GoalStatus; priority: string; isc?: string[] }
104
+ interface Challenge { id: string; title: string; severity: string; affectedGoals: string[] }
105
+ interface Learning { insight: string; confidence: number; category: string; timestamp: Date; fromRating?: number; sentiment?: Sentiment }
106
+ interface Rating { score: number; context: string; timestamp: Date; sentiment: Sentiment }
107
+
108
+ // v4.0.3: Anti-criteria (Feature #3)
109
+ interface AntiCriterion { id: string; description: string; severity: 'critical' | 'high' | 'medium' }
110
+
111
+ // v4.0.3: Capability tracking (Features #4, #5)
112
+ interface Capability { name: string; type: 'tool' | 'skill'; minRequired?: number; invocations: number }
113
+
114
+ interface InnerLoopState {
115
+ phase: AlgorithmPhase
116
+ goal: string
117
+ effort: EffortLevel
118
+ isc: string[]
119
+ iscA: AntiCriterion[] // v4.0.3: anti-criteria
120
+ capabilities: Map<string, Capability> // v4.0.3: selected capabilities
121
+ data: Record<string, string>
122
+ startTime: number
123
+ }
124
+
125
+ interface PAIState {
126
+ mission: string | null
127
+ goals: Map<string, Goal>
128
+ challenges: Map<string, Challenge>
129
+ learnings: Learning[]
130
+ ratings: Rating[]
131
+ innerLoop: InnerLoopState | null
132
+ iterationCount: number
133
+ ralphIteration: number
134
+ ralphActive: boolean
135
+ }
136
+
137
+ // ── Agent Personas (Steal #2 from Miessler's PAI) ────────────────────────────
138
+
139
+ interface AgentPersona { name: string; role: string; systemPrompt: string }
140
+
141
+ const AGENT_PERSONAS: Record<string, AgentPersona> = {
142
+ architect: {
143
+ name: 'Architect',
144
+ role: 'System design and architecture decisions',
145
+ systemPrompt: 'You are a senior system architect. Focus on: scalability, separation of concerns, API design, data flow, and failure modes. Propose 2-3 options with tradeoffs. Be opinionated — recommend the best option. Reference established patterns (hexagonal, event-driven, CQRS) when relevant.',
146
+ },
147
+ engineer: {
148
+ name: 'Engineer',
149
+ role: 'Implementation and code quality',
150
+ systemPrompt: 'You are a senior software engineer. Write production-quality code: proper error handling, types, tests, and documentation. Follow the codebase conventions. No shortcuts — if something needs a test, write the test.',
151
+ },
152
+ pentester: {
153
+ name: 'Pentester',
154
+ role: 'Security review and vulnerability assessment',
155
+ systemPrompt: 'You are a penetration tester and security researcher. Review code for: injection attacks (SQL, command, prompt), auth bypass, SSRF, path traversal, insecure deserialization, secrets in code, and dependency vulnerabilities. Report findings with severity (Critical/High/Medium/Low), exploit scenario, and remediation.',
156
+ },
157
+ designer: {
158
+ name: 'Designer',
159
+ role: 'UX/UI design and user experience',
160
+ systemPrompt: 'You are a senior product designer. Focus on: information architecture, visual hierarchy, accessibility (WCAG 2.1 AA), responsive design, and interaction patterns. Propose designs with rationale. Consider edge cases: empty states, error states, loading states, overflow.',
161
+ },
162
+ reviewer: {
163
+ name: 'Code Reviewer',
164
+ role: 'Code review and quality gates',
165
+ systemPrompt: 'You are a meticulous code reviewer. Check: correctness, edge cases, error handling, naming, complexity, test coverage, and performance. Categorize findings as P1 (must fix), P2 (should fix), P3 (nit). Be direct — if code is wrong, say so.',
166
+ },
167
+ researcher: {
168
+ name: 'Researcher',
169
+ role: 'Deep investigation and analysis',
170
+ systemPrompt: 'You are a thorough researcher. Investigate topics systematically: define the question, gather evidence, analyze findings, synthesize conclusions. Cite sources. Flag uncertainty. Separate facts from opinions.',
171
+ },
172
+ qa: {
173
+ name: 'QA Tester',
174
+ role: 'Quality assurance and test planning',
175
+ systemPrompt: 'You are a QA engineer. Think adversarially: what can go wrong? Create test plans covering: happy paths, edge cases, error paths, boundary values, concurrency, and regression. Write concrete test cases with expected results.',
176
+ },
177
+ }
178
+
179
+ // ── Damage Control Types ─────────────────────────────────────────────────────
180
+
181
+ interface DamageRule { pattern: string; reason: string; ask?: boolean }
182
+ interface DamageRules { bashToolPatterns: DamageRule[]; zeroAccessPaths: string[]; readOnlyPaths: string[]; noDeletePaths: string[] }
183
+ const EMPTY_RULES: DamageRules = { bashToolPatterns: [], zeroAccessPaths: [], readOnlyPaths: [], noDeletePaths: [] }
184
+
185
+ function loadDamageRules(cwd: string): DamageRules {
186
+ const candidates = [
187
+ path.join(cwd, '.pi', 'damage-control-rules.yaml'),
188
+ path.join(cwd, 'damage-control-rules.yaml'),
189
+ path.join(__dirname, '..', 'damage-control-rules.yaml'),
190
+ ]
191
+ for (const f of candidates) {
192
+ try {
193
+ if (!fs.existsSync(f)) continue
194
+ const raw = YAML.load(fs.readFileSync(f, 'utf8')) as Partial<DamageRules>
195
+ return { bashToolPatterns: raw.bashToolPatterns || [], zeroAccessPaths: raw.zeroAccessPaths || [], readOnlyPaths: raw.readOnlyPaths || [], noDeletePaths: raw.noDeletePaths || [] }
196
+ } catch { /* skip bad files */ }
197
+ }
198
+ return EMPTY_RULES
199
+ }
200
+
201
+ function isPathMatch(target: string, pattern: string, cwd: string): boolean {
202
+ const expanded = pattern.startsWith('~') ? path.join(os.homedir(), pattern.slice(1)) : pattern
203
+ const norm = path.normalize(expanded).replace(/\\/g, '/')
204
+ const abs = path.normalize(path.isAbsolute(target) ? target : path.resolve(cwd, target)).replace(/\\/g, '/')
205
+ if (norm.endsWith('/')) return abs.startsWith(norm) || abs.startsWith(norm.slice(0, -1))
206
+ if (norm.includes('*')) {
207
+ const re = new RegExp('^' + norm.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$')
208
+ return re.test(path.basename(abs)) || re.test(abs)
209
+ }
210
+ return path.basename(abs) === norm || abs.endsWith('/' + norm)
211
+ }
212
+
213
+ // ── Templates ────────────────────────────────────────────────────────────────
214
+
215
+ interface Template { mission: string; goals: string[]; challenges: string[] }
216
+
217
+ function loadTemplates(): Record<string, Template> {
218
+ const ext = path.join(__dirname, '..', 'templates.json')
219
+ try { if (fs.existsSync(ext)) return JSON.parse(fs.readFileSync(ext, 'utf8')) } catch { /* fall through */ }
220
+ return {
221
+ trading: { mission: 'Build a profitable algorithmic trading system', goals: ['Develop and backtest core strategy', 'Achieve >55% win rate on paper trades', 'Deploy live with risk management', 'Maintain Sharpe ratio >1.5'], challenges: ['Overfitting risk on historical data', 'Execution latency in live markets'] },
222
+ saas: { mission: 'Launch a production SaaS product', goals: ['Ship MVP with auth, billing, and core feature', 'Acquire first 10 paying users', 'Achieve <2s p95 page load', 'Set up CI/CD and monitoring'], challenges: ['Scope creep', 'Premature optimization'] },
223
+ devops: { mission: 'Build reliable infrastructure and deployment pipeline', goals: ['Automate deployments with zero downtime', 'Set up monitoring and alerting', 'Achieve 99.9% uptime SLA', 'Document runbooks for on-call'], challenges: ['Alert fatigue', 'Configuration drift'] },
224
+ research: { mission: 'Complete deep research project with actionable findings', goals: ['Define research questions and scope', 'Collect and analyze primary sources', 'Synthesize findings into report', 'Present recommendations'], challenges: ['Source reliability', 'Scope management'] },
225
+ agent: { mission: 'Build and ship a production AI agent', goals: ['Define agent capabilities and constraints', 'Implement tool use and error handling', 'Test with adversarial inputs', 'Deploy with monitoring and kill switch'], challenges: ['Prompt injection risk', 'Cost control', 'Hallucination detection'] },
226
+ }
227
+ }
228
+
229
+ // ── Sentiment Analysis (Steal #3) ────────────────────────────────────────────
230
+
231
+ function inferSentiment(score: number, context: string): Sentiment {
232
+ if (score >= 7) return 'positive'
233
+ if (score <= 3) return 'negative'
234
+ const neg = /bad|broken|wrong|fail|slow|bug|crash|awful|terrible|worse|hate|frustrat/i
235
+ const pos = /great|fast|clean|nice|perfect|love|excellent|smooth|solid/i
236
+ if (neg.test(context)) return 'negative'
237
+ if (pos.test(context)) return 'positive'
238
+ return 'neutral'
239
+ }
240
+
241
+ function ratingTrend(ratings: Rating[], window: number = 5): { trend: 'improving' | 'declining' | 'stable'; avg: number; recent: number } {
242
+ const avg = ratings.length ? ratings.reduce((s, r) => s + r.score, 0) / ratings.length : 0
243
+ const recent = ratings.slice(-window)
244
+ const recentAvg = recent.length ? recent.reduce((s, r) => s + r.score, 0) / recent.length : 0
245
+ const delta = recentAvg - avg
246
+ return { trend: delta > 0.5 ? 'improving' : delta < -0.5 ? 'declining' : 'stable', avg: +avg.toFixed(1), recent: +recentAvg.toFixed(1) }
247
+ }
248
+
249
+ // ── v4.0.3: ISC Splitting Test (Feature #1) ─────────────────────────────────
250
+ // Validates that each ISC is truly atomic — no compound criteria hiding behind
251
+ // "and", "with", scope words, or domain boundary violations.
252
+
253
+ const SPLIT_CONJUNCTIONS = /\b(and|as well as|along with|in addition to|plus|also|while|whilst)\b/i
254
+ const SPLIT_SCOPE_WORDS = /\b(all|every|each|any|both|multiple|various|several)\b/i
255
+ const SPLIT_DOMAIN_MARKERS = /\b(frontend and backend|client and server|ui and api|read and write|create and delete|input and output)\b/i
256
+
257
+ interface SplitTestResult { pass: boolean; reason?: string; suggestion?: string }
258
+
259
+ function iscSplittingTest(criterion: string): SplitTestResult {
260
+ // Check for conjunctions (compound criteria)
261
+ const conjMatch = criterion.match(SPLIT_CONJUNCTIONS)
262
+ if (conjMatch) {
263
+ const parts = criterion.split(SPLIT_CONJUNCTIONS).filter(p => p.trim() && !SPLIT_CONJUNCTIONS.test(p))
264
+ return {
265
+ pass: false,
266
+ reason: `Compound criterion — "${conjMatch[0]}" joins multiple conditions`,
267
+ suggestion: parts.length >= 2
268
+ ? `Split into:\n 1. ${parts[0].trim()}\n 2. ${parts[1].trim()}`
269
+ : `Remove "${conjMatch[0]}" and create separate ISCs for each condition`,
270
+ }
271
+ }
272
+
273
+ // Check for scope words (too broad)
274
+ const scopeMatch = criterion.match(SPLIT_SCOPE_WORDS)
275
+ if (scopeMatch) {
276
+ return {
277
+ pass: false,
278
+ reason: `Scope word "${scopeMatch[0]}" — criterion may cover multiple items`,
279
+ suggestion: `Be specific: which exact item(s)? Replace "${scopeMatch[0]}" with a concrete target`,
280
+ }
281
+ }
282
+
283
+ // Check for domain boundary violations
284
+ const domainMatch = criterion.match(SPLIT_DOMAIN_MARKERS)
285
+ if (domainMatch) {
286
+ return {
287
+ pass: false,
288
+ reason: `Cross-domain criterion — "${domainMatch[0]}" spans boundaries`,
289
+ suggestion: `Split into separate ISCs per domain`,
290
+ }
291
+ }
292
+
293
+ // Check word count (8-12 is ideal for ISC)
294
+ const words = criterion.trim().split(/\s+/).length
295
+ if (words > 20) {
296
+ return { pass: false, reason: `Too long (${words} words) — likely compound`, suggestion: 'Shorten to 8-12 words or split into multiple ISCs' }
297
+ }
298
+ if (words < 4) {
299
+ return { pass: false, reason: `Too short (${words} words) — likely not testable`, suggestion: 'Add specifics: what exactly should be verified?' }
300
+ }
301
+
302
+ return { pass: true }
303
+ }
304
+
305
+ // ── Self-Evolution Trigger (Steal #4) ────────────────────────────────────────
306
+
307
+ function detectRepeatingPatterns(learnings: Learning[]): string[] {
308
+ const counts = new Map<string, number>()
309
+ for (const l of learnings) {
310
+ // Normalize: lowercase, strip punctuation, take first 6 words
311
+ const key = l.insight.toLowerCase().replace(/[^a-z0-9 ]/g, '').split(/\s+/).slice(0, 6).join(' ')
312
+ counts.set(key, (counts.get(key) || 0) + 1)
313
+ }
314
+ return Array.from(counts.entries()).filter(([_, c]) => c >= 3).map(([k]) => k)
315
+ }
316
+
317
+ // ── Plans Directory (Steal #5) ───────────────────────────────────────────────
318
+
319
+ function ensurePlansDir(cwd: string): string {
320
+ const plansDir = path.join(cwd, '.pi', 'plans')
321
+ if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true })
322
+ return plansDir
323
+ }
324
+
325
+ function listPlans(cwd: string): string[] {
326
+ const dir = path.join(cwd, '.pi', 'plans')
327
+ if (!fs.existsSync(dir)) return []
328
+ return fs.readdirSync(dir).filter(f => f.endsWith('.md')).sort().reverse()
329
+ }
330
+
331
+ // ── Helpers ──────────────────────────────────────────────────────────────────
332
+
333
+ function persist(pi: ExtensionAPI, key: string, data: Record<string, unknown>) {
334
+ pi.appendEntry(key, { ...data, ts: new Date().toISOString() })
335
+ }
336
+
337
+ // ── Extension ────────────────────────────────────────────────────────────────
338
+
339
+ export default function (pi: ExtensionAPI) {
340
+ const state: PAIState = {
341
+ mission: null, goals: new Map(), challenges: new Map(),
342
+ learnings: [], ratings: [], innerLoop: null,
343
+ iterationCount: 0, ralphIteration: 0, ralphActive: false,
344
+ }
345
+ let rules: DamageRules = EMPTY_RULES
346
+ let widgetCtx: ExtensionContext | null = null
347
+ // v4.0.3 algorithm: OBSERVE → PLAN → DECIDE → EXECUTE → VERIFY
348
+ const PHASES: AlgorithmPhase[] = ['OBSERVE', 'PLAN', 'DECIDE', 'EXECUTE', 'VERIFY']
349
+ const EFFORTS: EffortLevel[] = ['standard', 'extended', 'advanced', 'deep', 'comprehensive']
350
+
351
+ function notify(msg: string, type: 'error' | 'warning' | 'info' = 'info') {
352
+ widgetCtx?.ui.notify(msg, type)
353
+ }
354
+
355
+ // ── Widget ─────────────────────────────────────────────────────────────
356
+
357
+ function updateWidget() {
358
+ if (!widgetCtx?.hasUI) return
359
+ widgetCtx.ui.setWidget('pai-status', (_tui: any, theme: any) => ({
360
+ render(width: number): string[] {
361
+ const lines: string[] = []
362
+ if (!state.mission) {
363
+ lines.push(theme.fg('dim', ' π-PAI v4: /pai mission <statement> to begin'))
364
+ return lines
365
+ }
366
+
367
+ const raw = state.mission ?? ''
368
+ const m = raw.length > width - 20 ? raw.slice(0, width - 23) + '...' : raw
369
+ lines.push(theme.fg('accent', ' 🎯 ') + theme.fg('success', m))
370
+
371
+ const goals = Array.from(state.goals.values())
372
+ const a = goals.filter(g => g.status === 'active').length
373
+ const b = goals.filter(g => g.status === 'blocked').length
374
+ const c = goals.filter(g => g.status === 'completed').length
375
+ const { trend, avg, recent } = ratingTrend(state.ratings)
376
+ const trendIcon = trend === 'improving' ? '📈' : trend === 'declining' ? '📉' : '➡️'
377
+
378
+ if (goals.length || state.ratings.length) {
379
+ lines.push(
380
+ theme.fg('dim', ' Goals: ') + theme.fg('success', `${a}⚡`) + ' ' +
381
+ theme.fg('warning', `${b}🚫`) + ' ' + theme.fg('muted', `${c}✓`) +
382
+ theme.fg('dim', ' │ ') + theme.fg('accent', `${state.learnings.length} learnings`) +
383
+ theme.fg('dim', ' │ ⭐') + theme.fg('accent', `${avg}`) +
384
+ theme.fg('dim', ` ${trendIcon}${recent} (${state.ratings.length})`)
385
+ )
386
+ }
387
+
388
+ if (state.innerLoop) {
389
+ const idx = PHASES.indexOf(state.innerLoop.phase)
390
+ const bar = PHASES.map((_, i) => i < idx ? theme.fg('success', '●') : i === idx ? theme.fg('accent', '◉') : theme.fg('dim', '○')).join(' ')
391
+ const elapsed = Math.round((Date.now() - state.innerLoop.startTime) / 1000)
392
+ const min = ISC_MINIMUMS[state.innerLoop.effort]
393
+ const iscProgress = `${state.innerLoop.isc.length}/${min}`
394
+ const budget = TIME_BUDGETS_MIN[state.innerLoop.effort]
395
+ const elapsedMin = Math.round(elapsed / 60)
396
+ const timeColor = elapsedMin > budget * TIME_COMPRESS_FACTOR ? 'error' : elapsedMin > budget ? 'warning' : 'dim'
397
+ lines.push(
398
+ theme.fg('dim', ' Loop: ') + bar +
399
+ theme.fg('dim', ` [${state.innerLoop.phase}] ${state.innerLoop.effort}`) +
400
+ theme.fg('dim', ' │ ISC:') + theme.fg(state.innerLoop.isc.length >= min ? 'success' : 'warning', iscProgress) +
401
+ (state.innerLoop.iscA.length ? theme.fg('dim', ' A:') + theme.fg('accent', `${state.innerLoop.iscA.length}`) : '') +
402
+ theme.fg('dim', ' │ ') + theme.fg(timeColor, `${elapsedMin}/${budget}min`)
403
+ )
404
+ }
405
+
406
+ if (state.ralphActive) lines.push(theme.fg('warning', ` 🔄 Ralph #${state.ralphIteration}`) + theme.fg('dim', ' running...'))
407
+
408
+ // Self-evolution warning
409
+ const patterns = detectRepeatingPatterns(state.learnings)
410
+ if (patterns.length) lines.push(theme.fg('warning', ` ⚠️ ${patterns.length} repeating pattern(s) — consider /pai evolve`))
411
+
412
+ return lines
413
+ },
414
+ invalidate() {},
415
+ }))
416
+ }
417
+
418
+ // ── /pai subcommand dispatch table ─────────────────────────────────────
419
+
420
+ const paiCommands: Record<string, (rest: string, ctx: ExtensionContext) => void> = {
421
+ mission(rest) {
422
+ if (!rest) { notify('Usage: /pai mission <statement>', 'error'); return }
423
+ state.mission = rest
424
+ persist(pi, 'pai-mission', { mission: rest })
425
+ emitObserveEvent('PaiMission', { mission: rest })
426
+ notify(`🎯 Mission: ${rest}`, 'info')
427
+ updateWidget()
428
+ },
429
+
430
+ goal(rest) {
431
+ if (!rest) { notify('Usage: /pai goal <title>', 'error'); return }
432
+ const id = `g${state.goals.size}`
433
+ state.goals.set(id, { id, title: rest, status: 'active', priority: 'p1', isc: [] })
434
+ persist(pi, 'pai-goal', { id, title: rest, status: 'active' })
435
+ emitObserveEvent('PaiGoal', { goal_id: id, title: rest, status: 'active' })
436
+ notify(`✅ Goal ${id}: ${rest}`, 'info')
437
+ updateWidget()
438
+ },
439
+
440
+ done(rest) {
441
+ const goal = state.goals.get(rest.trim())
442
+ if (!goal) { notify(`Goal "${rest.trim()}" not found`, 'error'); return }
443
+ goal.status = 'completed'
444
+ persist(pi, 'pai-goal-done', { goalId: rest.trim() })
445
+ notify(`🎉 Completed: ${goal.title}`, 'info')
446
+ updateWidget()
447
+ },
448
+
449
+ block(rest) {
450
+ const goal = state.goals.get(rest.trim())
451
+ if (!goal) { notify(`Goal "${rest.trim()}" not found`, 'error'); return }
452
+ goal.status = 'blocked'
453
+ persist(pi, 'pai-goal-blocked', { goalId: rest.trim() })
454
+ notify(`🚫 Blocked: ${goal.title}`, 'warning')
455
+ updateWidget()
456
+ },
457
+
458
+ challenge(rest) {
459
+ if (!rest) { notify('Usage: /pai challenge <description>', 'error'); return }
460
+ const id = `c${state.challenges.size}`
461
+ state.challenges.set(id, { id, title: rest, severity: 'medium', affectedGoals: [] })
462
+ persist(pi, 'pai-challenge', { id, title: rest })
463
+ notify(`⚠️ Challenge ${id}: ${rest}`, 'warning')
464
+ updateWidget()
465
+ },
466
+
467
+ learn(rest) {
468
+ if (!rest) { notify('Usage: /pai learn <insight>', 'error'); return }
469
+ const sentiment = inferSentiment(5, rest)
470
+ state.learnings.push({ insight: rest, confidence: 0.8, category: 'domain', timestamp: new Date(), sentiment })
471
+ persist(pi, 'pai-learning', { insight: rest, category: 'domain', sentiment })
472
+ emitObserveEvent('PaiLearning', { insight: rest, category: 'domain', sentiment })
473
+ notify(`📚 Learning: ${rest}`, 'info')
474
+
475
+ // Self-evolution trigger: check for repeating patterns
476
+ const patterns = detectRepeatingPatterns(state.learnings)
477
+ if (patterns.length) notify(`⚠️ ${patterns.length} pattern(s) repeating 3+ times — run /pai evolve to address`, 'warning')
478
+
479
+ updateWidget()
480
+ },
481
+
482
+ loop(rest) {
483
+ const goal = rest || state.mission || 'unnamed'
484
+ state.innerLoop = { phase: 'OBSERVE', goal, effort: 'standard', isc: [], iscA: [], capabilities: new Map(), data: {}, startTime: Date.now() }
485
+ emitObserveEvent('PaiAlgorithmStart', { goal, phase: 'OBSERVE', effort: 'standard' })
486
+ notify(`🔄 Algorithm started: ${goal} [OBSERVE]`, 'info')
487
+ updateWidget()
488
+ },
489
+
490
+ effort(rest) {
491
+ if (!state.innerLoop) { notify('No active loop', 'error'); return }
492
+ const level = rest.toLowerCase() as EffortLevel
493
+ if (!EFFORTS.includes(level)) { notify(`Usage: /pai effort ${EFFORTS.join('|')}`, 'error'); return }
494
+ state.innerLoop.effort = level
495
+ notify(`⚡ Effort: ${level}`, 'info')
496
+ updateWidget()
497
+ },
498
+
499
+ isc(rest) {
500
+ if (!state.innerLoop) { notify('No active loop', 'error'); return }
501
+ if (!rest) { notify('Usage: /pai isc <8-12 word testable criterion>', 'error'); return }
502
+
503
+ // v4.0.3 Feature #1: Splitting test
504
+ const test = iscSplittingTest(rest)
505
+ if (!test.pass) {
506
+ emitObserveEvent('PaiSplittingTest', { criterion: rest, pass: false, reason: test.reason })
507
+ notify(`❌ ISC failed splitting test: ${test.reason}`, 'warning')
508
+ if (test.suggestion) notify(`💡 ${test.suggestion}`, 'info')
509
+ return
510
+ }
511
+
512
+ state.innerLoop.isc.push(rest)
513
+ persist(pi, 'pai-isc', { criterion: rest, phase: state.innerLoop.phase })
514
+ emitObserveEvent('PaiSplittingTest', { criterion: rest, pass: true })
515
+ notify(`📋 ISC-${state.innerLoop.isc.length}/${ISC_MINIMUMS[state.innerLoop.effort]}: ${rest}`, 'info')
516
+ updateWidget()
517
+ },
518
+
519
+ // v4.0.3 Feature #3: Anti-criteria
520
+ isca(rest) {
521
+ if (!state.innerLoop) { notify('No active loop', 'error'); return }
522
+ if (!rest) { notify('Usage: /pai isca <what must NOT happen>', 'error'); return }
523
+ const severity = /critical|security|data.?loss|crash/i.test(rest) ? 'critical' as const : /error|fail|break/i.test(rest) ? 'high' as const : 'medium' as const
524
+ const ac: AntiCriterion = { id: `a${state.innerLoop.iscA.length}`, description: rest, severity }
525
+ state.innerLoop.iscA.push(ac)
526
+ persist(pi, 'pai-isc-anti', { anti: rest, severity })
527
+ emitObserveEvent('PaiIscGate', { type: 'anti', criterion: rest, severity })
528
+ notify(`🚫 ISC-A${state.innerLoop.iscA.length} [${severity}]: ${rest}`, 'info')
529
+ updateWidget()
530
+ },
531
+
532
+ // v4.0.3 Feature #4: Capability selection
533
+ capabilities(rest) {
534
+ if (!state.innerLoop) { notify('No active loop', 'error'); return }
535
+ if (!rest) {
536
+ // List current capabilities
537
+ if (!state.innerLoop.capabilities.size) { notify('No capabilities selected. /pai capabilities add <tool|skill> <name> [min]', 'info'); return }
538
+ const caps = Array.from(state.innerLoop.capabilities.values())
539
+ const list = caps.map(c => ` ${c.type}:${c.name} — ${c.invocations}/${c.minRequired ?? '∞'} invocations`).join('\n')
540
+ pi.sendMessage({ customType: 'pai-capabilities', content: `# Selected Capabilities\n\n${list}`, display: true, details: undefined }, { triggerTurn: false })
541
+ return
542
+ }
543
+ const parts = rest.split(/\s+/)
544
+ if (parts[0] === 'add' && parts.length >= 3) {
545
+ const type = parts[1] as 'tool' | 'skill'
546
+ const name = parts[2]
547
+ const min = parts[3] ? parseInt(parts[3], 10) : undefined
548
+ if (type !== 'tool' && type !== 'skill') { notify('Type must be "tool" or "skill"', 'error'); return }
549
+ state.innerLoop.capabilities.set(name, { name, type, minRequired: min, invocations: 0 })
550
+ emitObserveEvent('PaiCapability', { action: 'add', name, type, minRequired: min })
551
+ notify(`🔧 Capability: ${type}:${name}${min ? ` (min ${min})` : ''}`, 'info')
552
+ return
553
+ }
554
+ notify('Usage: /pai capabilities add <tool|skill> <name> [min]', 'error')
555
+ },
556
+
557
+ next(rest) {
558
+ if (!state.innerLoop) { notify('No active loop. /pai loop <goal>', 'error'); return }
559
+ if (rest) state.innerLoop.data[state.innerLoop.phase] = rest
560
+ const idx = PHASES.indexOf(state.innerLoop.phase)
561
+
562
+ // v4.0.3 Feature #2: ISC count gate before EXECUTE phase
563
+ if (state.innerLoop.phase === 'DECIDE') {
564
+ const min = ISC_MINIMUMS[state.innerLoop.effort]
565
+ const count = state.innerLoop.isc.length
566
+ if (count < min) {
567
+ emitObserveEvent('PaiIscGate', { type: 'count', count, minimum: min, effort: state.innerLoop.effort, pass: false })
568
+ notify(`❌ ISC gate: ${count}/${min} criteria (${state.innerLoop.effort} requires ${min}). Add more with /pai isc`, 'error')
569
+ return
570
+ }
571
+ emitObserveEvent('PaiIscGate', { type: 'count', count, minimum: min, effort: state.innerLoop.effort, pass: true })
572
+ }
573
+
574
+ // v4.0.3 Feature #5: Capability invocation check before VERIFY
575
+ if (state.innerLoop.phase === 'EXECUTE' && state.innerLoop.capabilities.size > 0) {
576
+ const unmet: string[] = []
577
+ const capEntries = Array.from(state.innerLoop.capabilities.entries())
578
+ for (let ci = 0; ci < capEntries.length; ci++) {
579
+ const [name, cap] = capEntries[ci]
580
+ if (cap.minRequired && cap.invocations < cap.minRequired) {
581
+ unmet.push(`${cap.type}:${name} (${cap.invocations}/${cap.minRequired})`)
582
+ }
583
+ }
584
+ if (unmet.length) {
585
+ notify(`⚠️ Unmet capabilities: ${unmet.join(', ')}`, 'warning')
586
+ }
587
+ }
588
+
589
+ if (idx < PHASES.length - 1) {
590
+ state.innerLoop.phase = PHASES[idx + 1]
591
+ emitObserveEvent('PaiPhaseTransition', { phase: state.innerLoop.phase, goal: state.innerLoop.goal, effort: state.innerLoop.effort })
592
+ notify(`→ ${state.innerLoop.phase}`, 'info')
593
+
594
+ // v4.0.3 Feature #6: Time budget check
595
+ const elapsedMin = (Date.now() - state.innerLoop.startTime) / 60000
596
+ const budget = TIME_BUDGETS_MIN[state.innerLoop.effort]
597
+ if (elapsedMin > budget * TIME_COMPRESS_FACTOR) {
598
+ emitObserveEvent('PaiEffortCompress', { elapsed: +elapsedMin.toFixed(1), budget, effort: state.innerLoop.effort })
599
+ notify(`⏰ Over time budget (${Math.round(elapsedMin)}min / ${budget}min) — compressing scope`, 'warning')
600
+ } else if (elapsedMin > budget) {
601
+ notify(`⏰ At time budget (${Math.round(elapsedMin)}min / ${budget}min)`, 'info')
602
+ }
603
+ } else {
604
+ state.iterationCount++
605
+ const elapsed = Math.round((Date.now() - state.innerLoop.startTime) / 1000)
606
+
607
+ // v4.0.3 Feature #5: Final capability report
608
+ const capReport = state.innerLoop.capabilities.size > 0
609
+ ? Array.from(state.innerLoop.capabilities.values()).map(c => `${c.type}:${c.name}=${c.invocations}`).join(', ')
610
+ : 'none'
611
+
612
+ persist(pi, 'pai-loop-complete', {
613
+ goal: state.innerLoop.goal, iteration: state.iterationCount,
614
+ effort: state.innerLoop.effort, isc: state.innerLoop.isc,
615
+ iscA: state.innerLoop.iscA.map(a => a.description),
616
+ capabilities: capReport,
617
+ data: state.innerLoop.data, elapsed,
618
+ })
619
+ emitObserveEvent('PaiLoopComplete', {
620
+ goal: state.innerLoop.goal, iteration: state.iterationCount,
621
+ effort: state.innerLoop.effort, elapsed,
622
+ isc_count: state.innerLoop.isc.length,
623
+ isc_anti_count: state.innerLoop.iscA.length,
624
+ capabilities: capReport,
625
+ })
626
+ notify(`✅ Loop #${state.iterationCount} complete (${elapsed}s) | ${state.innerLoop.isc.length} ISC, ${state.innerLoop.iscA.length} anti`, 'info')
627
+ state.innerLoop = null
628
+ }
629
+ updateWidget()
630
+ },
631
+
632
+ template(rest) {
633
+ const templates = loadTemplates()
634
+ const name = rest.trim().toLowerCase()
635
+ if (!name || !templates[name]) { notify(`Templates: ${Object.keys(templates).join(', ')}`, 'info'); return }
636
+ const t = templates[name]
637
+ state.mission = t.mission
638
+ persist(pi, 'pai-mission', { mission: t.mission, template: name })
639
+ for (const title of t.goals) {
640
+ const id = `g${state.goals.size}`
641
+ state.goals.set(id, { id, title, status: 'active', priority: 'p1', isc: [] })
642
+ persist(pi, 'pai-goal', { id, title, status: 'active' })
643
+ }
644
+ for (const title of t.challenges) {
645
+ const id = `c${state.challenges.size}`
646
+ state.challenges.set(id, { id, title, severity: 'medium', affectedGoals: [] })
647
+ persist(pi, 'pai-challenge', { id, title })
648
+ }
649
+ notify(`📋 Template "${name}": ${t.goals.length} goals, ${t.challenges.length} challenges`, 'info')
650
+ updateWidget()
651
+ },
652
+
653
+ // Steal #2: Agent Personas
654
+ agent(rest) {
655
+ const name = rest.trim().toLowerCase()
656
+ if (!name || !AGENT_PERSONAS[name]) {
657
+ const list = Object.entries(AGENT_PERSONAS).map(([k, v]) => ` ${k}: ${v.role}`).join('\n')
658
+ pi.sendMessage({ customType: 'pai-agents', content: `# Available Agent Personas\n\n${list}\n\nUsage: /pai agent <name> <task>`, display: true, details: undefined }, { triggerTurn: false })
659
+ return
660
+ }
661
+ const taskStart = rest.indexOf(' ')
662
+ const task = taskStart > 0 ? rest.slice(taskStart + 1).trim() : ''
663
+ if (!task) { notify(`Usage: /pai agent ${name} <task description>`, 'error'); return }
664
+ const persona = AGENT_PERSONAS[name]
665
+ pi.sendMessage({
666
+ customType: 'pai-agent-dispatch',
667
+ content: `# ${persona.name} Mode\n\n**System:** ${persona.systemPrompt}\n\n**Task:** ${task}`,
668
+ display: true,
669
+ details: undefined,
670
+ }, { triggerTurn: true })
671
+ notify(`🎭 ${persona.name}: ${task.slice(0, 60)}...`, 'info')
672
+ },
673
+
674
+ // Steal #5: Plans directory
675
+ plans(rest, ctx) {
676
+ const plans = listPlans(ctx.cwd)
677
+ if (!plans.length) {
678
+ notify('No plans in .pi/plans/ — use brainstorm skill to create one', 'info')
679
+ return
680
+ }
681
+ const list = plans.map(p => `- ${p}`).join('\n')
682
+ pi.sendMessage({ customType: 'pai-plans', content: `# Plans\n\n${list}\n\nPlans dir: .pi/plans/`, display: true, details: undefined }, { triggerTurn: false })
683
+ },
684
+
685
+ // Steal #4: Self-evolution
686
+ evolve() {
687
+ const patterns = detectRepeatingPatterns(state.learnings)
688
+ if (!patterns.length) { notify('No repeating patterns detected yet', 'info'); return }
689
+
690
+ const report = patterns.map((p, i) => `${i + 1}. "${p}" (3+ occurrences)`).join('\n')
691
+ const sentimentDist = { positive: 0, neutral: 0, negative: 0 }
692
+ for (const l of state.learnings) sentimentDist[l.sentiment || 'neutral']++
693
+
694
+ pi.sendMessage({
695
+ customType: 'pai-evolve',
696
+ content: `# Evolution Trigger Report\n\n## Repeating Learning Patterns\n${report}\n\n## Sentiment Distribution\n- Positive: ${sentimentDist.positive}\n- Neutral: ${sentimentDist.neutral}\n- Negative: ${sentimentDist.negative}\n\n## Recommended Actions\nThese patterns indicate recurring issues. Consider:\n1. **Run /gepa** on related skills to evolve them\n2. **Create a new skill** to address the pattern\n3. **Update AGENTS.md** with the learning\n\nUse \`pi-gepa\` extension for automated skill evolution.`,
697
+ display: true,
698
+ details: undefined,
699
+ }, { triggerTurn: false })
700
+ },
701
+
702
+ // Steal #3: Detailed trend report
703
+ trend() {
704
+ if (!state.ratings.length) { notify('No ratings yet — use /rate <1-10> after tasks', 'info'); return }
705
+
706
+ const { trend, avg, recent } = ratingTrend(state.ratings)
707
+ const sentimentDist = { positive: 0, neutral: 0, negative: 0 }
708
+ for (const r of state.ratings) sentimentDist[r.sentiment]++
709
+
710
+ const recentRatings = state.ratings.slice(-10).map(r => {
711
+ const icon = r.sentiment === 'positive' ? '😊' : r.sentiment === 'negative' ? '😞' : '😐'
712
+ return `- ⭐${r.score} ${icon} ${r.context || '(no context)'}`
713
+ }).join('\n')
714
+
715
+ pi.sendMessage({
716
+ customType: 'pai-trend',
717
+ content: `# Rating Trend\n\n**Overall:** ⭐${avg} (${state.ratings.length} ratings)\n**Recent (last 5):** ⭐${recent} ${trend === 'improving' ? '📈 Improving' : trend === 'declining' ? '📉 Declining' : '➡️ Stable'}\n\n## Sentiment\n- 😊 Positive: ${sentimentDist.positive}\n- 😐 Neutral: ${sentimentDist.neutral}\n- 😞 Negative: ${sentimentDist.negative}\n\n## Recent Ratings\n${recentRatings}`,
718
+ display: true,
719
+ details: undefined,
720
+ }, { triggerTurn: false })
721
+ },
722
+
723
+ reset() {
724
+ state.mission = null; state.goals.clear(); state.challenges.clear()
725
+ state.learnings = []; state.ratings = []; state.innerLoop = null
726
+ state.iterationCount = 0; state.ralphIteration = 0; state.ralphActive = false
727
+ persist(pi, 'pai-reset', {})
728
+ notify('🗑️ PAI state reset', 'warning')
729
+ updateWidget()
730
+ },
731
+
732
+ status(_rest, ctx) {
733
+ const goals = Array.from(state.goals.values())
734
+ const challenges = Array.from(state.challenges.values())
735
+ const { trend, avg, recent } = ratingTrend(state.ratings)
736
+ const trendIcon = trend === 'improving' ? '📈' : trend === 'declining' ? '📉' : '➡️'
737
+ const patterns = detectRepeatingPatterns(state.learnings)
738
+ const plans = listPlans(ctx.cwd)
739
+
740
+ let r = `# PAI Status (v4.2 — full v4.0.3 sync: splitting test, count gate, anti-criteria, capabilities, time budgets)\n\n`
741
+ r += `**Mission:** ${state.mission || 'Not set'}\n`
742
+ r += `**Iterations:** ${state.iterationCount} | **Rating:** ⭐${avg} ${trendIcon}${recent} (${state.ratings.length} signals)\n`
743
+ if (patterns.length) r += `**⚠️ Repeating patterns:** ${patterns.length} — run /pai evolve\n`
744
+ r += '\n'
745
+
746
+ r += `## Goals (${goals.length})\n`
747
+ for (const g of goals) {
748
+ const icon = g.status === 'completed' ? '✅' : g.status === 'blocked' ? '🚫' : '🎯'
749
+ r += `- ${icon} **${g.id}** ${g.title} (${g.status})\n`
750
+ }
751
+
752
+ r += `\n## Challenges (${challenges.length})\n`
753
+ for (const c of challenges) r += `- ⚠️ **${c.id}** ${c.title}\n`
754
+
755
+ r += `\n## Recent Learnings\n`
756
+ for (const l of state.learnings.slice(-5)) {
757
+ const sIcon = l.sentiment === 'positive' ? '😊' : l.sentiment === 'negative' ? '😞' : '📚'
758
+ r += `- ${sIcon} [${l.category}] ${l.insight}${l.fromRating ? ` (⭐${l.fromRating})` : ''}\n`
759
+ }
760
+
761
+ if (state.innerLoop) {
762
+ const min = ISC_MINIMUMS[state.innerLoop.effort]
763
+ const budget = TIME_BUDGETS_MIN[state.innerLoop.effort]
764
+ const elapsedMin = Math.round((Date.now() - state.innerLoop.startTime) / 60000)
765
+ r += `\n## Active Loop (v4.0.3 Algorithm)\n`
766
+ r += `**Phase:** ${state.innerLoop.phase} | **Effort:** ${state.innerLoop.effort} | **Goal:** ${state.innerLoop.goal}\n`
767
+ r += `**ISC:** ${state.innerLoop.isc.length}/${min} | **Anti:** ${state.innerLoop.iscA.length} | **Time:** ${elapsedMin}/${budget}min\n`
768
+ r += `**Phases:** OBSERVE → PLAN → DECIDE → EXECUTE → VERIFY\n`
769
+ for (let ii = 0; ii < state.innerLoop.isc.length; ii++) r += `- ISC-${ii + 1}: ${state.innerLoop.isc[ii]}\n`
770
+ if (state.innerLoop.iscA.length) {
771
+ r += `\n**Anti-Criteria (must NOT happen):**\n`
772
+ for (const ac of state.innerLoop.iscA) r += `- 🚫 [${ac.severity}] ${ac.description}\n`
773
+ }
774
+ if (state.innerLoop.capabilities.size) {
775
+ r += `\n**Capabilities:**\n`
776
+ const statusCaps = Array.from(state.innerLoop.capabilities.values())
777
+ for (let ci = 0; ci < statusCaps.length; ci++) {
778
+ const cap = statusCaps[ci]
779
+ r += `- 🔧 ${cap.type}:${cap.name} — ${cap.invocations}/${cap.minRequired ?? '∞'}\n`
780
+ }
781
+ }
782
+ }
783
+
784
+ r += `\n## Agent Personas\n${Object.entries(AGENT_PERSONAS).map(([k, v]) => `- **${k}**: ${v.role}`).join('\n')}\n`
785
+
786
+ if (plans.length) r += `\n## Plans (.pi/plans/)\n${plans.slice(0, 5).map(p => `- ${p}`).join('\n')}\n`
787
+
788
+ r += `\n## Damage Control\n${rules.bashToolPatterns.length} bash | ${rules.zeroAccessPaths.length} zero-access | ${rules.readOnlyPaths.length} read-only | ${rules.noDeletePaths.length} no-delete\n`
789
+
790
+ pi.sendMessage({ customType: 'pai-status', content: r, display: true, details: undefined }, { triggerTurn: false })
791
+ },
792
+ }
793
+
794
+ // ── /pai command ───────────────────────────────────────────────────────
795
+
796
+ pi.registerCommand('pai', {
797
+ description: 'PAI v4.2: /pai mission|goal|done|block|challenge|learn|loop|next|isc|isca|effort|capabilities|template|agent|plans|trend|evolve|sessions|replay|reset|status',
798
+ handler: async (args, ctx) => {
799
+ widgetCtx = ctx
800
+ ensurePlansDir(ctx.cwd)
801
+ const parts = (args || '').trim().split(/\s+/)
802
+ const sub = parts[0]?.toLowerCase()
803
+ const rest = parts.slice(1).join(' ')
804
+ const fn = paiCommands[sub]
805
+ if (fn) fn(rest, ctx)
806
+ else notify(`/pai ${Object.keys(paiCommands).join('|')}`, 'info')
807
+ },
808
+ })
809
+
810
+ // ── /rate (enhanced with sentiment) ────────────────────────────────────
811
+
812
+ pi.registerCommand('rate', {
813
+ description: 'Rate last output 1-10 with sentiment: /rate <score> [context]',
814
+ handler: async (args, ctx) => {
815
+ widgetCtx = ctx
816
+ const parts = (args || '').trim().split(/\s+/)
817
+ const score = parseInt(parts[0], 10)
818
+ const context = parts.slice(1).join(' ')
819
+
820
+ if (isNaN(score) || score < 1 || score > 10) { notify('Usage: /rate <1-10> [context]', 'error'); return }
821
+
822
+ const sentiment = inferSentiment(score, context)
823
+ state.ratings.push({ score, context, timestamp: new Date(), sentiment })
824
+ persist(pi, 'pai-rating', { score, context, sentiment })
825
+ emitObserveEvent('PaiRating', { score, context, sentiment })
826
+
827
+ if (score <= 3) {
828
+ const l: Learning = { insight: `Low rating (${score}): ${context || 'below expectations'}`, confidence: 0.9, category: 'algorithm', timestamp: new Date(), fromRating: score, sentiment: 'negative' }
829
+ state.learnings.push(l)
830
+ persist(pi, 'pai-learning', { insight: l.insight, category: 'algorithm', fromRating: score, sentiment: 'negative' })
831
+ notify(`⭐${score} 😞 — Learning captured`, 'warning')
832
+ } else {
833
+ const sIcon = sentiment === 'positive' ? '😊' : '😐'
834
+ notify(`⭐${score} ${sIcon}${score >= 8 ? ' — Excellent!' : ''}`, 'info')
835
+ }
836
+ updateWidget()
837
+ },
838
+ })
839
+
840
+ // ── /ralph ─────────────────────────────────────────────────────────────
841
+
842
+ pi.registerCommand('ralph', {
843
+ description: 'Ralph Wiggum iteration: /ralph <task> or /ralph stop',
844
+ handler: async (args, ctx) => {
845
+ widgetCtx = ctx
846
+ const task = (args || '').trim()
847
+ if (task.toLowerCase() === 'stop') {
848
+ state.ralphActive = false
849
+ notify(`🛑 Ralph stopped after ${state.ralphIteration} iterations`, 'warning')
850
+ updateWidget()
851
+ return
852
+ }
853
+ if (!task) { notify('Usage: /ralph <task> or /ralph stop', 'error'); return }
854
+ state.ralphActive = true
855
+ state.ralphIteration = 0
856
+ notify(`🔄 Ralph starting: ${task}`, 'info')
857
+ updateWidget()
858
+ pi.sendMessage({ customType: 'pai-ralph', content: `[Ralph #${++state.ralphIteration}]\n\nTask: ${task}\n\nExecute this task. Say "RALPH_DONE" when finished.`, display: true, details: undefined }, { triggerTurn: true })
859
+ },
860
+ })
861
+
862
+ pi.on('message_end', async (event) => {
863
+ if (!state.ralphActive) return
864
+ if (state.ralphIteration >= 50) { state.ralphActive = false; notify('🛑 Ralph: 50 limit', 'warning'); updateWidget(); return }
865
+ const text = typeof event === 'object' && event !== null && 'text' in event ? String((event as Record<string, unknown>).text) : ''
866
+ if (text.includes('RALPH_DONE')) { state.ralphActive = false; notify(`✅ Ralph done in ${state.ralphIteration}`, 'info'); updateWidget(); return }
867
+ pi.sendMessage({ customType: 'pai-ralph', content: `[Ralph #${++state.ralphIteration}] Continue. Say "RALPH_DONE" when finished.`, display: true, details: undefined }, { triggerTurn: true })
868
+ updateWidget()
869
+ })
870
+
871
+ // ── Tools ──────────────────────────────────────────────────────────────
872
+
873
+ pi.registerTool({
874
+ name: 'pai_status',
875
+ label: 'PAI Status',
876
+ description: 'Get PAI status: mission, goals, challenges, learnings, loop, ratings, trends, personas.',
877
+ parameters: Type.Object({}),
878
+ execute: async () => {
879
+ const { trend, avg, recent } = ratingTrend(state.ratings)
880
+ const patterns = detectRepeatingPatterns(state.learnings)
881
+ return {
882
+ details: undefined,
883
+ content: [{ type: 'text' as const, text: JSON.stringify({
884
+ version: '4.2.0',
885
+ algorithm: 'OBSERVE → PLAN → DECIDE → EXECUTE → VERIFY',
886
+ effortLevels: 'Standard(8)|Extended(16)|Advanced(24)|Deep(40)|Comprehensive(64)',
887
+ iscMethodology: 'splitting test + count gate + anti-criteria + capability tracking',
888
+ timeBudgets: TIME_BUDGETS_MIN,
889
+ mission: state.mission,
890
+ goals: Array.from(state.goals.values()),
891
+ challenges: Array.from(state.challenges.values()),
892
+ learnings: state.learnings.slice(-10).map(l => ({ insight: l.insight, category: l.category, sentiment: l.sentiment })),
893
+ innerLoop: state.innerLoop ? {
894
+ phase: state.innerLoop.phase, effort: state.innerLoop.effort, goal: state.innerLoop.goal,
895
+ isc: state.innerLoop.isc,
896
+ iscMinimum: ISC_MINIMUMS[state.innerLoop.effort],
897
+ iscAnti: state.innerLoop.iscA.map(a => ({ severity: a.severity, description: a.description })),
898
+ capabilities: Array.from(state.innerLoop.capabilities.values()).map(c => ({ name: c.name, type: c.type, invocations: c.invocations, minRequired: c.minRequired })),
899
+ elapsedMin: +((Date.now() - state.innerLoop.startTime) / 60000).toFixed(1),
900
+ timeBudgetMin: TIME_BUDGETS_MIN[state.innerLoop.effort],
901
+ } : null,
902
+ iterations: state.iterationCount,
903
+ ratings: { avg, recent, trend: trend, count: state.ratings.length },
904
+ repeatingPatterns: patterns,
905
+ agentPersonas: Object.keys(AGENT_PERSONAS),
906
+ }, null, 2) }],
907
+ }
908
+ },
909
+ })
910
+
911
+ pi.registerTool({
912
+ name: 'pai_learn',
913
+ label: 'PAI Learn',
914
+ description: 'Record a learning/insight into PAI.',
915
+ parameters: Type.Object({
916
+ insight: Type.String({ description: 'The learning or insight' }),
917
+ category: Type.Optional(Type.String({ description: 'algorithm|system|domain|process' })),
918
+ confidence: Type.Optional(Type.Number({ description: '0-1' })),
919
+ }),
920
+ execute: async (_callId, args) => {
921
+ const sentiment = inferSentiment(5, args.insight)
922
+ state.learnings.push({ insight: args.insight, confidence: args.confidence ?? 0.8, category: args.category || 'domain', timestamp: new Date(), sentiment })
923
+ persist(pi, 'pai-learning', { insight: args.insight, category: args.category || 'domain', sentiment })
924
+ updateWidget()
925
+ return { details: undefined, content: [{ type: 'text' as const, text: `Learning [${args.category || 'domain'}] ${sentiment}: ${args.insight}` }] }
926
+ },
927
+ })
928
+
929
+ pi.registerTool({
930
+ name: 'pai_rate',
931
+ label: 'PAI Rate',
932
+ description: 'Rate output quality 1-10 with sentiment tracking. Low ratings auto-capture learnings.',
933
+ parameters: Type.Object({
934
+ score: Type.Number({ description: 'Rating 1-10' }),
935
+ context: Type.Optional(Type.String({ description: 'Why' })),
936
+ }),
937
+ execute: async (_callId, args) => {
938
+ const score = Math.max(1, Math.min(10, Math.round(args.score)))
939
+ const sentiment = inferSentiment(score, args.context || '')
940
+ state.ratings.push({ score, context: args.context || '', timestamp: new Date(), sentiment })
941
+ persist(pi, 'pai-rating', { score, context: args.context, sentiment })
942
+ if (score <= 3) {
943
+ state.learnings.push({ insight: `Low rating (${score}): ${args.context || 'below expectations'}`, confidence: 0.9, category: 'algorithm', timestamp: new Date(), fromRating: score, sentiment: 'negative' })
944
+ persist(pi, 'pai-learning', { insight: `Low rating (${score})`, category: 'algorithm', fromRating: score, sentiment: 'negative' })
945
+ }
946
+ updateWidget()
947
+ const { trend, avg } = ratingTrend(state.ratings)
948
+ return { details: undefined, content: [{ type: 'text' as const, text: `Rated ⭐${score} ${sentiment} | Avg: ${avg} (${trend})${args.context ? ' — ' + args.context : ''}` }] }
949
+ },
950
+ })
951
+
952
+ // ── Damage Control ─────────────────────────────────────────────────────
953
+
954
+ pi.on('tool_call', async (event, ctx) => {
955
+ const { isToolCallEventType } = await import('@mariozechner/pi-coding-agent')
956
+
957
+ // Emit tool call to observability dashboard
958
+ const toolName = (event as any)?.name || (event as any)?.tool || 'unknown'
959
+ emitObserveEvent('PostToolUse', { tool_name: toolName, source: 'pi-pai' })
960
+
961
+ // v4.0.3 Feature #5: Track capability invocations
962
+ if (state.innerLoop?.capabilities.size) {
963
+ const cap = state.innerLoop.capabilities.get(toolName)
964
+ if (cap) {
965
+ cap.invocations++
966
+ emitObserveEvent('PaiCapability', { action: 'invoke', name: toolName, count: cap.invocations })
967
+ }
968
+ }
969
+
970
+ if (isToolCallEventType('bash', event)) {
971
+ const cmd = event.input.command || ''
972
+ for (const rule of rules.bashToolPatterns) {
973
+ try {
974
+ if (new RegExp(rule.pattern).test(cmd)) {
975
+ if (rule.ask) {
976
+ const ok = await ctx.ui.confirm('🛡️ PAI', `${rule.reason}\n\n${cmd}\n\nAllow?`, { timeout: 30000 })
977
+ if (!ok) { persist(pi, 'pai-dc', { cmd, reason: rule.reason, action: 'denied' }); ctx.abort(); return { block: true, reason: `🛑 ${rule.reason}. DO NOT retry.` } }
978
+ return { block: false }
979
+ }
980
+ persist(pi, 'pai-dc', { cmd, reason: rule.reason, action: 'blocked' })
981
+ ctx.abort()
982
+ return { block: true, reason: `🛑 ${rule.reason}. DO NOT retry.` }
983
+ }
984
+ } catch { /* bad regex, skip */ }
985
+ }
986
+ }
987
+
988
+ if (isToolCallEventType('read', event) || isToolCallEventType('write', event) || isToolCallEventType('edit', event)) {
989
+ const filePath = event.input.path || ''
990
+ const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.cwd, filePath)
991
+ for (const zap of rules.zeroAccessPaths) {
992
+ if (isPathMatch(resolved, zap, ctx.cwd)) { ctx.abort(); return { block: true, reason: `🛑 zero-access: ${zap}. DO NOT retry.` } }
993
+ }
994
+ if (isToolCallEventType('write', event) || isToolCallEventType('edit', event)) {
995
+ for (const rop of rules.readOnlyPaths) {
996
+ if (isPathMatch(resolved, rop, ctx.cwd)) { ctx.abort(); return { block: true, reason: `🛑 read-only: ${rop}. DO NOT modify.` } }
997
+ }
998
+ }
999
+ }
1000
+
1001
+ if (isToolCallEventType('bash', event)) {
1002
+ const cmd = event.input.command || ''
1003
+ if (/\b(rm|del|rmdir|Remove-Item)\b/i.test(cmd)) {
1004
+ for (const ndp of rules.noDeletePaths) {
1005
+ const clean = ndp.replace(/^~\//, '').replace(/^\*/, '')
1006
+ if (clean && cmd.includes(clean)) {
1007
+ persist(pi, 'pai-dc', { cmd, reason: `no-delete: ${ndp}`, action: 'blocked' })
1008
+ ctx.abort()
1009
+ return { block: true, reason: `🛑 no-delete: ${ndp}. DO NOT retry.` }
1010
+ }
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ return { block: false }
1016
+ })
1017
+
1018
+ // ── Session lifecycle ──────────────────────────────────────────────────
1019
+
1020
+ // ── /pai sessions — list and replay pi sessions into the dashboard ───
1021
+
1022
+ paiCommands['sessions'] = function(_rest, ctx) {
1023
+ const sessDir = path.join(os.homedir(), '.pi', 'agent', 'sessions')
1024
+ if (!fs.existsSync(sessDir)) { notify('No sessions found', 'info'); return }
1025
+
1026
+ // Find session dirs and get latest file from each
1027
+ const dirs = fs.readdirSync(sessDir).filter(d => {
1028
+ try { return fs.statSync(path.join(sessDir, d)).isDirectory() } catch { return false }
1029
+ })
1030
+
1031
+ const sessions: { dir: string; file: string; time: Date; lines: number }[] = []
1032
+ for (const dir of dirs) {
1033
+ const files = fs.readdirSync(path.join(sessDir, dir)).filter(f => f.endsWith('.jsonl')).sort().reverse()
1034
+ if (files.length) {
1035
+ const filePath = path.join(sessDir, dir, files[0])
1036
+ try {
1037
+ const stat = fs.statSync(filePath)
1038
+ const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n').length
1039
+ sessions.push({ dir, file: files[0], time: stat.mtime, lines })
1040
+ } catch { /* skip */ }
1041
+ }
1042
+ }
1043
+
1044
+ sessions.sort((a, b) => b.time.getTime() - a.time.getTime())
1045
+
1046
+ let r = `# Pi Sessions (${sessions.length})\n\n`
1047
+ r += `| # | Project | File | Events | Last Modified |\n|---|---------|------|--------|---------------|\n`
1048
+ const displaySessions = sessions.slice(0, 15)
1049
+ for (let si = 0; si < displaySessions.length; si++) {
1050
+ const s = displaySessions[si]
1051
+ const proj = s.dir.replace(/^--/, '').replace(/--$/, '').replace(/-/g, '/').slice(0, 40)
1052
+ r += `| ${si + 1} | ${proj} | ${s.file.slice(0, 30)} | ${s.lines} | ${s.time.toLocaleString()} |\n`
1053
+ }
1054
+ r += `\n**Replay to dashboard:** \`/pai replay <session-number>\`\n`
1055
+ r += `**Session dir:** ~/.pi/agent/sessions/\n`
1056
+
1057
+ pi.sendMessage({ customType: 'pai-sessions', content: r, display: true, details: undefined }, { triggerTurn: false })
1058
+ }
1059
+
1060
+ paiCommands['replay'] = function(rest) {
1061
+ const sessDir = path.join(os.homedir(), '.pi', 'agent', 'sessions')
1062
+ if (!fs.existsSync(sessDir)) { notify('No sessions found', 'error'); return }
1063
+
1064
+ // Find all sessions sorted by time
1065
+ const dirs = fs.readdirSync(sessDir).filter(d => {
1066
+ try { return fs.statSync(path.join(sessDir, d)).isDirectory() } catch { return false }
1067
+ })
1068
+ const sessions: string[] = []
1069
+ for (const dir of dirs) {
1070
+ const files = fs.readdirSync(path.join(sessDir, dir)).filter(f => f.endsWith('.jsonl')).sort().reverse()
1071
+ if (files.length) sessions.push(path.join(sessDir, dir, files[0]))
1072
+ }
1073
+ sessions.sort((a, b) => {
1074
+ try { return fs.statSync(b).mtime.getTime() - fs.statSync(a).mtime.getTime() } catch { return 0 }
1075
+ })
1076
+
1077
+ const idx = parseInt(rest.trim()) - 1
1078
+ if (isNaN(idx) || idx < 0 || idx >= sessions.length) { notify(`Usage: /pai replay <1-${sessions.length}>`, 'error'); return }
1079
+
1080
+ const sessionFile = sessions[idx]
1081
+ let replayed = 0
1082
+
1083
+ try {
1084
+ const lines = fs.readFileSync(sessionFile, 'utf8').trim().split('\n')
1085
+ for (const line of lines) {
1086
+ try {
1087
+ const ev = JSON.parse(line)
1088
+ // Map pi session events to observability format
1089
+ if (ev.type === 'session') {
1090
+ emitObserveEvent('SessionStart', { cwd: ev.cwd, source: 'pi-session-replay', session_id: ev.id })
1091
+ replayed++
1092
+ } else if (ev.type === 'message' && ev.message?.content) {
1093
+ for (const c of ev.message.content) {
1094
+ if (c.type === 'toolCall') {
1095
+ emitObserveEvent('PostToolUse', {
1096
+ tool_name: c.name,
1097
+ tool_input: c.arguments,
1098
+ source: 'pi-session-replay',
1099
+ session_id: ev.id,
1100
+ })
1101
+ replayed++
1102
+ }
1103
+ }
1104
+ // Emit token usage
1105
+ if (ev.message?.usage) {
1106
+ emitObserveEvent('PaiTokenUsage', {
1107
+ input: ev.message.usage.input,
1108
+ output: ev.message.usage.output,
1109
+ cost: ev.message.usage.cost,
1110
+ model: ev.message.model,
1111
+ source: 'pi-session-replay',
1112
+ })
1113
+ replayed++
1114
+ }
1115
+ } else if (ev.type === 'compaction') {
1116
+ emitObserveEvent('PaiCompaction', { source: 'pi-session-replay' })
1117
+ replayed++
1118
+ }
1119
+ } catch { /* skip bad lines */ }
1120
+ }
1121
+ } catch (e: any) { notify(`Failed to read session: ${e.message}`, 'error'); return }
1122
+
1123
+ notify(`📊 Replayed ${replayed} events from ${path.basename(sessionFile)} → dashboard`, 'info')
1124
+ }
1125
+
1126
+ pi.on('session_start', async (_event, ctx) => {
1127
+ widgetCtx = ctx
1128
+ rules = loadDamageRules(ctx.cwd)
1129
+ ensurePlansDir(ctx.cwd)
1130
+ observeSessionId = `pi-pai-${Date.now().toString(36)}`
1131
+ emitObserveEvent('SessionStart', { cwd: ctx.cwd, source: 'pi-pai', version: '4.1.0' })
1132
+ updateWidget()
1133
+ const n = rules.bashToolPatterns.length + rules.zeroAccessPaths.length + rules.readOnlyPaths.length + rules.noDeletePaths.length
1134
+ ctx.ui.notify(`🧠 π-PAI v4.2 (v4.0.3 full sync) | ${n ? n + ' rules' : 'no rules'} | /pai /ralph /rate`, 'info')
1135
+ })
1136
+ }