@haoyiyin/workflow 0.2.2 → 0.2.3
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/package.json +9 -8
- package/src/agents/contracts.ts +559 -0
- package/src/agents/dispatcher-enhanced.ts +350 -0
- package/src/agents/dispatcher.ts +680 -0
- package/src/agents/index.ts +48 -0
- package/src/agents/resilience.ts +255 -0
- package/src/agents/token-budget.ts +83 -0
- package/src/agents/types.ts +73 -0
- package/src/guard/main-agent.ts +245 -0
- package/src/hooks/builtin/index.ts +8 -0
- package/src/hooks/builtin/on-error.ts +23 -0
- package/src/hooks/builtin/post-execute.ts +40 -0
- package/src/hooks/builtin/post-plan.ts +23 -0
- package/src/hooks/builtin/pre-execute.ts +30 -0
- package/src/hooks/builtin/pre-plan.ts +26 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/loader.ts +98 -0
- package/src/hooks/manager.ts +99 -0
- package/src/hooks/types-enhanced.ts +38 -0
- package/src/hooks/types.ts +35 -0
- package/src/index.ts +127 -0
- package/src/persistence/index.ts +17 -0
- package/src/persistence/plan-md.ts +141 -0
- package/src/persistence/state-md.ts +167 -0
- package/src/persistence/types.ts +89 -0
- package/src/router/classifier.ts +610 -0
- package/src/router/guard.ts +483 -0
- package/src/router/index.ts +22 -0
- package/src/router/router.ts +108 -0
- package/src/router/types.ts +127 -0
- package/src/skills/agents-md/SKILL.md +45 -0
- package/src/skills/agents-md/index.ts +33 -0
- package/src/skills/execute-plan/SKILL.md +60 -0
- package/src/skills/execute-plan/index.ts +970 -0
- package/src/skills/index.ts +13 -0
- package/src/skills/quick-task/SKILL.md +54 -0
- package/src/skills/quick-task/index.ts +346 -0
- package/src/skills/registry.ts +59 -0
- package/src/skills/review-diff/SKILL.md +53 -0
- package/src/skills/review-diff/index.ts +394 -0
- package/src/skills/skill.ts +59 -0
- package/src/skills/systematic-debugging/SKILL.md +56 -0
- package/src/skills/systematic-debugging/index.ts +404 -0
- package/src/skills/tdd/SKILL.md +52 -0
- package/src/skills/tdd/index.ts +409 -0
- package/src/skills/to-plan/SKILL.md +56 -0
- package/src/skills/to-plan/index-enhanced.ts +551 -0
- package/src/skills/to-plan/index.ts +586 -0
- package/src/skills/types.ts +47 -0
- package/src/state/cleanup.ts +118 -0
- package/src/state/index.ts +8 -0
- package/src/state/manager.ts +96 -0
- package/src/state/persistence.ts +77 -0
- package/src/state/types.ts +30 -0
- package/src/state/validator.ts +78 -0
- package/src/types.ts +102 -0
- package/src/utils/compress.ts +347 -0
- package/src/utils/git.ts +82 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/logger.ts +23 -0
- package/src/utils/paths.ts +55 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main Agent Guard - enforces that the main agent NEVER does substantive work.
|
|
3
|
+
*
|
|
4
|
+
* The guard is the enforcement mechanism for the "thin dispatcher" pattern.
|
|
5
|
+
* When embargo is active, the main agent is blocked from everything except
|
|
6
|
+
* meta-operations (reading plan files, state files, and dispatching subagents).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```typescript
|
|
10
|
+
* const guard = MainAgentGuard.create({
|
|
11
|
+
* defaultModel: 'haiku',
|
|
12
|
+
* planningThreshold: 0.6,
|
|
13
|
+
* maxMainAgentTokens: 10000,
|
|
14
|
+
* autoRoute: true,
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* const check = guard.checkOperation('read-source', { path: 'src/main.ts' })
|
|
18
|
+
* if (!check.allowed) {
|
|
19
|
+
* throw new Error(check.reason)
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { RouterConfig } from './types.js'
|
|
25
|
+
import { MAIN_AGENT_PROHIBITED } from './types.js'
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Guard-specific types
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Result of a guard check. */
|
|
32
|
+
export interface GuardCheckResult {
|
|
33
|
+
/** Whether the operation is permitted */
|
|
34
|
+
allowed: boolean
|
|
35
|
+
/** Human-readable reason for the decision */
|
|
36
|
+
reason: string
|
|
37
|
+
/** The specific rule that triggered (for debugging) */
|
|
38
|
+
rule?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Context passed to guard checks for richer decision-making. */
|
|
42
|
+
export interface GuardContext {
|
|
43
|
+
/** The operation being attempted */
|
|
44
|
+
operation: string
|
|
45
|
+
/** Optional file path involved */
|
|
46
|
+
path?: string
|
|
47
|
+
/** Optional subagent ID if dispatching */
|
|
48
|
+
subagentId?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Snapshot of guard state for monitoring/debugging. */
|
|
52
|
+
export interface GuardStatus {
|
|
53
|
+
embargoActive: boolean
|
|
54
|
+
pendingSubagents: string[]
|
|
55
|
+
violations: GuardViolation[]
|
|
56
|
+
prohibitedOperations: string[]
|
|
57
|
+
allowedPaths: string[]
|
|
58
|
+
forbiddenPaths: string[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Recorded violation for audit trail. */
|
|
62
|
+
export interface GuardViolation {
|
|
63
|
+
operation: string
|
|
64
|
+
path?: string
|
|
65
|
+
timestamp: Date
|
|
66
|
+
reason: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Operation categories recognized by the guard. */
|
|
70
|
+
export type GuardOperation =
|
|
71
|
+
| 'read-source'
|
|
72
|
+
| 'search-codebase'
|
|
73
|
+
| 'write-files'
|
|
74
|
+
| 'edit-files'
|
|
75
|
+
| 'run-build'
|
|
76
|
+
| 'run-tests'
|
|
77
|
+
| 'git-commit'
|
|
78
|
+
| 'git-push'
|
|
79
|
+
| 'read-diffs'
|
|
80
|
+
| 'long-running-task'
|
|
81
|
+
| 'dispatch-subagent'
|
|
82
|
+
| 'read-meta-file'
|
|
83
|
+
| 'relay-results'
|
|
84
|
+
| 'read-plan'
|
|
85
|
+
| 'read-state'
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Helpers
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
const META_OPERATIONS: ReadonlySet<string> = new Set([
|
|
92
|
+
'dispatch-subagent',
|
|
93
|
+
'relay-results',
|
|
94
|
+
'read-meta-file',
|
|
95
|
+
'read-plan',
|
|
96
|
+
'read-state',
|
|
97
|
+
'list-plans',
|
|
98
|
+
'check-state',
|
|
99
|
+
'read-config',
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
const ALWAYS_ALLOWED_PATHS: ReadonlyArray<string> = [
|
|
103
|
+
'.pi/plans/**',
|
|
104
|
+
'.pi/yi-workflow/state/**',
|
|
105
|
+
'SKILL.md',
|
|
106
|
+
'AGENTS.md',
|
|
107
|
+
'CLAUDE.md',
|
|
108
|
+
'package.json',
|
|
109
|
+
'.claude/**',
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
const ALWAYS_FORBIDDEN_PATHS: ReadonlyArray<string> = [
|
|
113
|
+
'src/**',
|
|
114
|
+
'lib/**',
|
|
115
|
+
'app/**',
|
|
116
|
+
'dist/**',
|
|
117
|
+
'node_modules/**',
|
|
118
|
+
'**/*.test.ts',
|
|
119
|
+
'**/*.spec.ts',
|
|
120
|
+
'**/*.test.tsx',
|
|
121
|
+
'**/*.spec.tsx',
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parses a simple glob pattern into a regex for matching.
|
|
126
|
+
* Supports ** for recursive matching and * for single-segment.
|
|
127
|
+
*/
|
|
128
|
+
function globToRegex(pattern: string): RegExp {
|
|
129
|
+
const escaped = pattern
|
|
130
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
131
|
+
.replace(/\*\*/g, '___DOUBLESTAR___')
|
|
132
|
+
.replace(/\*/g, '[^/]*')
|
|
133
|
+
.replace(/___DOUBLESTAR___/g, '.*')
|
|
134
|
+
|
|
135
|
+
return new RegExp(`^${escaped}$`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Check whether a path matches any of the given glob patterns. */
|
|
139
|
+
function matchesAnyGlob(filePath: string, patterns: readonly string[]): boolean {
|
|
140
|
+
const normalized = filePath.replace(/^\.\//, '')
|
|
141
|
+
for (const pattern of patterns) {
|
|
142
|
+
const regex = globToRegex(pattern)
|
|
143
|
+
if (regex.test(normalized)) {
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// MainAgentGuard
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Enforces the "main agent as thin dispatcher" constraint.
|
|
156
|
+
*
|
|
157
|
+
* When embargoActive is true, the main agent is FORBIDDEN from doing
|
|
158
|
+
* ANY substantive work. It can only read meta-files and dispatch subagents.
|
|
159
|
+
*/
|
|
160
|
+
export class MainAgentGuard {
|
|
161
|
+
private readonly config: Readonly<RouterConfig>
|
|
162
|
+
private readonly violations: GuardViolation[]
|
|
163
|
+
private readonly pendingSubagents: Set<string>
|
|
164
|
+
private embargoActive: boolean
|
|
165
|
+
private tokensUsed: number
|
|
166
|
+
|
|
167
|
+
constructor(config: RouterConfig) {
|
|
168
|
+
this.config = Object.freeze({ ...config })
|
|
169
|
+
this.violations = []
|
|
170
|
+
this.pendingSubagents = new Set()
|
|
171
|
+
this.embargoActive = false
|
|
172
|
+
this.tokensUsed = 0
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// -----------------------------------------------------------------------
|
|
176
|
+
// Factory methods
|
|
177
|
+
// -----------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/** Create a guard with embargo active (strictest mode). */
|
|
180
|
+
static createEmbargoAll(config: RouterConfig): MainAgentGuard {
|
|
181
|
+
const guard = new MainAgentGuard(config)
|
|
182
|
+
guard.activateEmbargo()
|
|
183
|
+
return guard
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Create a guard with embargo lifted (use with extreme caution). */
|
|
187
|
+
static createDisabled(config: RouterConfig): MainAgentGuard {
|
|
188
|
+
return new MainAgentGuard(config)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Convenience: create with default config. */
|
|
192
|
+
static create(config: RouterConfig): MainAgentGuard {
|
|
193
|
+
return new MainAgentGuard(config)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// -----------------------------------------------------------------------
|
|
197
|
+
// Operation checks
|
|
198
|
+
// -----------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Check whether an operation is permitted for the main agent.
|
|
202
|
+
*
|
|
203
|
+
* @param operation - The operation being attempted
|
|
204
|
+
* @returns GuardCheckResult with allowed flag and reason
|
|
205
|
+
*/
|
|
206
|
+
checkOperation(operation: GuardOperation): GuardCheckResult {
|
|
207
|
+
// If embargo is inactive, everything is permitted
|
|
208
|
+
if (!this.embargoActive) {
|
|
209
|
+
return {
|
|
210
|
+
allowed: true,
|
|
211
|
+
reason: 'Embargo is inactive - main agent may operate',
|
|
212
|
+
rule: 'embargo-off',
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Meta-operations are always allowed (they ARE the main agent's job)
|
|
217
|
+
if (META_OPERATIONS.has(operation)) {
|
|
218
|
+
return {
|
|
219
|
+
allowed: true,
|
|
220
|
+
reason: 'Meta-operation permitted for orchestration',
|
|
221
|
+
rule: 'meta-operation',
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check against prohibited operations
|
|
226
|
+
const prohibitionMap: Record<string, boolean> = {
|
|
227
|
+
'read-source': MAIN_AGENT_PROHIBITED.readSourceFiles,
|
|
228
|
+
'search-codebase': MAIN_AGENT_PROHIBITED.searchCodebase,
|
|
229
|
+
'write-files': MAIN_AGENT_PROHIBITED.writeFiles,
|
|
230
|
+
'edit-files': MAIN_AGENT_PROHIBITED.writeFiles,
|
|
231
|
+
'run-build': MAIN_AGENT_PROHIBITED.runBuildCommands,
|
|
232
|
+
'run-tests': MAIN_AGENT_PROHIBITED.runTests,
|
|
233
|
+
'git-commit': MAIN_AGENT_PROHIBITED.writeFiles,
|
|
234
|
+
'git-push': MAIN_AGENT_PROHIBITED.writeFiles,
|
|
235
|
+
'read-diffs': MAIN_AGENT_PROHIBITED.readSourceFiles,
|
|
236
|
+
'long-running-task': MAIN_AGENT_PROHIBITED.longRunningTasks,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (prohibitionMap[operation]) {
|
|
240
|
+
return {
|
|
241
|
+
allowed: false,
|
|
242
|
+
reason: `Operation "${operation}" is prohibited for the main agent`,
|
|
243
|
+
rule: 'prohibited-operation',
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Unknown operations under embargo: deny by default
|
|
248
|
+
return {
|
|
249
|
+
allowed: false,
|
|
250
|
+
reason: `Operation "${operation}" is not recognized and embargo is active`,
|
|
251
|
+
rule: 'unknown-operation-deny',
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Check whether a file path is permitted to be read by the main agent.
|
|
257
|
+
*/
|
|
258
|
+
checkReadPath(filePath: string): GuardCheckResult {
|
|
259
|
+
if (!this.embargoActive) {
|
|
260
|
+
return { allowed: true, reason: 'Embargo is inactive', rule: 'embargo-off' }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const normalized = filePath.replace(/^\.\//, '')
|
|
264
|
+
|
|
265
|
+
// Explicitly forbidden paths take precedence
|
|
266
|
+
if (matchesAnyGlob(normalized, ALWAYS_FORBIDDEN_PATHS)) {
|
|
267
|
+
return {
|
|
268
|
+
allowed: false,
|
|
269
|
+
reason: `Path "${filePath}" is forbidden under embargo`,
|
|
270
|
+
rule: 'forbidden-path',
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Check if path is in the allowed list
|
|
275
|
+
if (matchesAnyGlob(normalized, ALWAYS_ALLOWED_PATHS)) {
|
|
276
|
+
return { allowed: true, reason: 'Path is in the allowed list', rule: 'allowed-path' }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Default: deny
|
|
280
|
+
return {
|
|
281
|
+
allowed: false,
|
|
282
|
+
reason: `Path "${filePath}" is not in the allowed list and embargo is active`,
|
|
283
|
+
rule: 'default-deny-path',
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Comprehensive check combining operation and optional file path/context.
|
|
289
|
+
*/
|
|
290
|
+
check(context: GuardContext): GuardCheckResult {
|
|
291
|
+
// Check the operation
|
|
292
|
+
const opCheck = this.checkOperation(context.operation as GuardOperation)
|
|
293
|
+
if (!opCheck.allowed) {
|
|
294
|
+
return opCheck
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// If a path is provided, check read access
|
|
298
|
+
if (context.path) {
|
|
299
|
+
const pathCheck = this.checkReadPath(context.path)
|
|
300
|
+
if (!pathCheck.allowed) {
|
|
301
|
+
return pathCheck
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Check token budget
|
|
306
|
+
if (this.tokensUsed > this.config.maxMainAgentTokens) {
|
|
307
|
+
return {
|
|
308
|
+
allowed: false,
|
|
309
|
+
reason: `Token budget exceeded: ${this.tokensUsed}/${this.config.maxMainAgentTokens}`,
|
|
310
|
+
rule: 'token-budget-exceeded',
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { allowed: true, reason: 'All checks passed' }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Assert an operation is allowed. Throws if violated.
|
|
319
|
+
*/
|
|
320
|
+
assertAllowed(context: GuardContext): void {
|
|
321
|
+
const result = this.check(context)
|
|
322
|
+
if (!result.allowed) {
|
|
323
|
+
const error = new Error(
|
|
324
|
+
`GUARD VIOLATION: ${result.reason}\n` +
|
|
325
|
+
`Operation: ${context.operation}\n` +
|
|
326
|
+
`${context.path ? `Path: ${context.path}\n` : ''}` +
|
|
327
|
+
`Rule: ${result.rule}`
|
|
328
|
+
)
|
|
329
|
+
this.violations.push({
|
|
330
|
+
operation: context.operation,
|
|
331
|
+
path: context.path,
|
|
332
|
+
timestamp: new Date(),
|
|
333
|
+
reason: result.reason,
|
|
334
|
+
})
|
|
335
|
+
throw error
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Record a violation without throwing (for audit trail).
|
|
341
|
+
*/
|
|
342
|
+
recordViolation(context: GuardContext): void {
|
|
343
|
+
const result = this.check(context)
|
|
344
|
+
this.violations.push({
|
|
345
|
+
operation: context.operation,
|
|
346
|
+
path: context.path,
|
|
347
|
+
timestamp: new Date(),
|
|
348
|
+
reason: result.reason,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// -----------------------------------------------------------------------
|
|
353
|
+
// Embargo control
|
|
354
|
+
// -----------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
/** Activate embargo mode - main agent must delegate everything. */
|
|
357
|
+
activateEmbargo(): void {
|
|
358
|
+
this.embargoActive = true
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Deactivate embargo mode - main agent regains limited capabilities. */
|
|
362
|
+
deactivateEmbargo(): void {
|
|
363
|
+
this.embargoActive = false
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Check if embargo is currently active. */
|
|
367
|
+
isEmbargoActive(): boolean {
|
|
368
|
+
return this.embargoActive
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// -----------------------------------------------------------------------
|
|
372
|
+
// Subagent tracking
|
|
373
|
+
// -----------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
/** Register a dispatched subagent. */
|
|
376
|
+
registerSubagent(id: string): void {
|
|
377
|
+
this.pendingSubagents.add(id)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Mark a subagent as completed. */
|
|
381
|
+
completeSubagent(id: string): void {
|
|
382
|
+
this.pendingSubagents.delete(id)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Check if there are any subagents still pending. */
|
|
386
|
+
hasPendingSubagents(): boolean {
|
|
387
|
+
return this.pendingSubagents.size > 0
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// -----------------------------------------------------------------------
|
|
391
|
+
// Token tracking
|
|
392
|
+
// -----------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
/** Track token usage by the main agent. */
|
|
395
|
+
trackTokens(count: number): void {
|
|
396
|
+
this.tokensUsed += count
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Get current token usage. */
|
|
400
|
+
getTokensUsed(): number {
|
|
401
|
+
return this.tokensUsed
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Percentage of token budget consumed (0-1). */
|
|
405
|
+
tokenBudgetUtilization(): number {
|
|
406
|
+
return this.tokensUsed / this.config.maxMainAgentTokens
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// -----------------------------------------------------------------------
|
|
410
|
+
// Status and reporting
|
|
411
|
+
// -----------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
/** Current guard status snapshot. */
|
|
414
|
+
getStatus(): GuardStatus {
|
|
415
|
+
return {
|
|
416
|
+
embargoActive: this.embargoActive,
|
|
417
|
+
pendingSubagents: [...this.pendingSubagents],
|
|
418
|
+
violations: [...this.violations],
|
|
419
|
+
prohibitedOperations: Object.entries(MAIN_AGENT_PROHIBITED)
|
|
420
|
+
.filter(([, prohibited]) => prohibited)
|
|
421
|
+
.map(([key]) => key),
|
|
422
|
+
allowedPaths: [...ALWAYS_ALLOWED_PATHS],
|
|
423
|
+
forbiddenPaths: [...ALWAYS_FORBIDDEN_PATHS],
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Generate the system prompt fragment that enforces guard rules.
|
|
429
|
+
* Injected into the main agent's system prompt.
|
|
430
|
+
*/
|
|
431
|
+
generateSystemPromptFragment(): string {
|
|
432
|
+
if (!this.embargoActive) {
|
|
433
|
+
return `
|
|
434
|
+
## Main Agent Guard: INACTIVE
|
|
435
|
+
|
|
436
|
+
The guard is currently disabled. You may operate normally,
|
|
437
|
+
but prefer delegation to subagents for substantive work where possible.
|
|
438
|
+
`
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const prohibited = Object.entries(MAIN_AGENT_PROHIBITED)
|
|
442
|
+
.filter(([, v]) => v)
|
|
443
|
+
.map(([k]) => `- ❌ ${k}`)
|
|
444
|
+
.join('\n')
|
|
445
|
+
|
|
446
|
+
const allowed = [...ALWAYS_ALLOWED_PATHS].map((p) => `- ${p}`).join('\n')
|
|
447
|
+
|
|
448
|
+
const pendingCount = this.pendingSubagents.size
|
|
449
|
+
const tokenPct = Math.round(this.tokenBudgetUtilization() * 100)
|
|
450
|
+
|
|
451
|
+
return `
|
|
452
|
+
## CRITICAL: Main Agent Guard — EMBARGO ACTIVE
|
|
453
|
+
|
|
454
|
+
You are a **thin dispatcher**, NOT an executor. Your only job:
|
|
455
|
+
1. Classify the user's request
|
|
456
|
+
2. Dispatch subagents to do the actual work
|
|
457
|
+
3. Relay subagent results back to the user
|
|
458
|
+
|
|
459
|
+
### Prohibited (NEVER do these):
|
|
460
|
+
${prohibited}
|
|
461
|
+
|
|
462
|
+
### Allowed to read ONLY:
|
|
463
|
+
${allowed}
|
|
464
|
+
|
|
465
|
+
### Status:
|
|
466
|
+
- Token budget: ${tokenPct}% used (${this.tokensUsed}/${this.config.maxMainAgentTokens})
|
|
467
|
+
- Pending subagents: ${pendingCount}
|
|
468
|
+
- Violations recorded: ${this.violations.length}
|
|
469
|
+
|
|
470
|
+
### Routing rules:
|
|
471
|
+
- Trivial typo/whitespace → handle directly (1-2 char fix)
|
|
472
|
+
- Small scoped change → dispatch quick-task agent
|
|
473
|
+
- New feature / complex change → dispatch to-plan → then execute-plan
|
|
474
|
+
- Bug/debugging → dispatch systematic-debugging agent
|
|
475
|
+
- Code review → dispatch review-diff agent
|
|
476
|
+
- Research/exploration → dispatch explorer agent
|
|
477
|
+
- TDD work → dispatch tdd agent
|
|
478
|
+
- Everything else → dispatch general subagent
|
|
479
|
+
|
|
480
|
+
### NEVER execute any task yourself. ALWAYS use the Agent tool.
|
|
481
|
+
`
|
|
482
|
+
}
|
|
483
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router module index
|
|
3
|
+
*
|
|
4
|
+
* The router ensures ALL user requests go through subagents.
|
|
5
|
+
* Main Agent is a "thin dispatcher" — it NEVER executes tasks itself.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
IntentClassification,
|
|
10
|
+
IntentType,
|
|
11
|
+
RouterConfig,
|
|
12
|
+
RoutingDecision,
|
|
13
|
+
SubagentContract,
|
|
14
|
+
PermissionSet,
|
|
15
|
+
RouterInput,
|
|
16
|
+
RouterOutput,
|
|
17
|
+
MAIN_AGENT_PROHIBITED,
|
|
18
|
+
ROUTE_KEYWORDS,
|
|
19
|
+
} from './types.js'
|
|
20
|
+
|
|
21
|
+
export { createRouter } from './router.js'
|
|
22
|
+
export type { Router } from './router.js'
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router - wires up classification and guard into a single dispatch pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The createRouter factory produces an object that:
|
|
5
|
+
* 1. Classifies user requests
|
|
6
|
+
* 2. Validates against main-agent guard rules
|
|
7
|
+
* 3. Produces a routing decision (what subagent to dispatch)
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { createRouter } from './router/router.js'
|
|
12
|
+
* import type { RouterConfig } from './router/types.js'
|
|
13
|
+
*
|
|
14
|
+
* const config: RouterConfig = {
|
|
15
|
+
* defaultModel: 'haiku',
|
|
16
|
+
* planningThreshold: 0.6,
|
|
17
|
+
* maxMainAgentTokens: 10000,
|
|
18
|
+
* autoRoute: true,
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* const router = createRouter(config)
|
|
22
|
+
* const output = router.route({
|
|
23
|
+
* request: "Fix the typo in src/utils.ts",
|
|
24
|
+
* planFileExists: false,
|
|
25
|
+
* isExecutingPlan: false,
|
|
26
|
+
* hasUncommittedChanges: true,
|
|
27
|
+
* mentionedFiles: ['src/utils.ts'],
|
|
28
|
+
* })
|
|
29
|
+
* // output.classification.type === 'quick-task'
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { RouterConfig, RouterInput, RouterOutput } from './types.js'
|
|
34
|
+
import { RequestClassifier } from './classifier.js'
|
|
35
|
+
import type { ClassificationContext } from './classifier.js'
|
|
36
|
+
import { MainAgentGuard } from './guard.js'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* High-level router interface.
|
|
40
|
+
*/
|
|
41
|
+
export interface Router {
|
|
42
|
+
/** Classify a request and produce a routing decision */
|
|
43
|
+
route: (input: RouterInput) => RouterOutput
|
|
44
|
+
|
|
45
|
+
/** Get the underlying classifier */
|
|
46
|
+
classifier: RequestClassifier
|
|
47
|
+
|
|
48
|
+
/** Get the underlying guard */
|
|
49
|
+
guard: MainAgentGuard
|
|
50
|
+
|
|
51
|
+
/** Generate the system prompt fragment for the main agent */
|
|
52
|
+
generateSystemPrompt: () => string
|
|
53
|
+
|
|
54
|
+
/** Check if an operation is allowed under current guard state */
|
|
55
|
+
isOperationAllowed: (operation: string) => boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a router with the given configuration.
|
|
60
|
+
*
|
|
61
|
+
* Wires together:
|
|
62
|
+
* - RequestClassifier: analyzes user input, determines intent
|
|
63
|
+
* - MainAgentGuard: enforces thin-dispatcher constraints
|
|
64
|
+
*/
|
|
65
|
+
export function createRouter(config: RouterConfig): Router {
|
|
66
|
+
const classifier = new RequestClassifier(config)
|
|
67
|
+
const guard = new MainAgentGuard(config)
|
|
68
|
+
|
|
69
|
+
function route(input: RouterInput): RouterOutput {
|
|
70
|
+
const context: ClassificationContext = {
|
|
71
|
+
planFileExists: input.planFileExists,
|
|
72
|
+
isExecutingPlan: input.isExecutingPlan,
|
|
73
|
+
hasUncommittedChanges: input.hasUncommittedChanges,
|
|
74
|
+
currentBranch: input.currentBranch,
|
|
75
|
+
mentionedFiles: input.mentionedFiles,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const result = classifier.classify(input.request, context)
|
|
79
|
+
|
|
80
|
+
// Activate embargo after routing (unless trivial edit)
|
|
81
|
+
if (result.classification.type !== 'quick-task') {
|
|
82
|
+
guard.activateEmbargo()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
classification: result.classification,
|
|
87
|
+
routing: result.routing,
|
|
88
|
+
confidence: result.confidence,
|
|
89
|
+
matchedRule: result.matchedRule,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function generateSystemPrompt(): string {
|
|
94
|
+
return guard.generateSystemPromptFragment()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isOperationAllowed(operation: string): boolean {
|
|
98
|
+
return guard.checkOperation(operation as Parameters<typeof guard.checkOperation>[0]).allowed
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
route,
|
|
103
|
+
classifier,
|
|
104
|
+
guard,
|
|
105
|
+
generateSystemPrompt,
|
|
106
|
+
isOperationAllowed,
|
|
107
|
+
}
|
|
108
|
+
}
|