@miphamai/cli 0.24.16 → 0.26.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,155 @@
1
+ ---
2
+ name: triage
3
+ description: Structured task decomposition and tracking across sessions. Use for breaking complex plans into trackable tickets with dependency graphs, checking task status, or continuing work from a previous session.
4
+ version: 1.0.0
5
+ user-invocable: true
6
+ allowed-tools:
7
+ - Read
8
+ - Write
9
+ - Edit
10
+ - Bash
11
+ - Glob
12
+ - Grep
13
+ ---
14
+
15
+ # Triage — Cross-Session Task Tracking
16
+
17
+ Turn plans into trackable tickets with dependency management. Inspired by Matt Pocock's `triage` + `to-tickets` + `wayfinder` skills, consolidated into one Mipham Code skill.
18
+
19
+ ## When to Use
20
+
21
+ - Breaking a large plan into actionable tickets
22
+ - Tracking work across multiple sessions
23
+ - User asks: "what's next?", "where did I leave off?", "what's the status?"
24
+ - Complex tasks with dependencies between them
25
+
26
+ ---
27
+
28
+ ## The Ticket Format
29
+
30
+ Tickets live in `.mipham/tickets/` as individual Markdown files:
31
+
32
+ ```markdown
33
+ ---
34
+ id: T-001
35
+ title: Add user authentication
36
+ status: in-progress
37
+ priority: P0
38
+ depends_on: []
39
+ blocks: [T-003]
40
+ created: 2026-08-10
41
+ tags:
42
+ - auth
43
+ - backend
44
+ ---
45
+
46
+ ## Description
47
+
48
+ Add JWT-based authentication with refresh token rotation.
49
+
50
+ ## Acceptance Criteria
51
+
52
+ - [ ] Login endpoint returns access + refresh tokens
53
+ - [ ] Refresh endpoint rotates tokens
54
+ - [ ] Invalid tokens return 401
55
+ - [ ] Rate limiting on login attempts
56
+
57
+ ## Notes
58
+
59
+ - OAuth not in scope for T-001 (punted to T-005)
60
+ ```
61
+
62
+ ### Status Values
63
+
64
+ | Status | Meaning |
65
+ | ------------- | ------------------------------------------ |
66
+ | `backlog` | Not yet planned for any session |
67
+ | `planned` | Scoped and ready to work |
68
+ | `in-progress` | Currently being worked on |
69
+ | `review` | Implementation done, awaiting verification |
70
+ | `done` | Verified and merged |
71
+ | `blocked` | Cannot proceed due to dependency |
72
+ | `wontfix` | Decided not to do |
73
+
74
+ ---
75
+
76
+ ## The Triage Workflow
77
+
78
+ ### Phase 1: Decompose (Plan → Tickets)
79
+
80
+ Given a plan or feature request:
81
+
82
+ 1. **Identify the smallest independently-valuable units of work**
83
+ - Each ticket should deliver value on its own
84
+ - If a ticket requires 3+ files touched, it's probably too big
85
+ - If a ticket can be done in < 15 minutes, it's probably too small
86
+
87
+ 2. **Map dependencies**
88
+ - What must be done first? (hard dependency)
89
+ - What would be easier after something else? (soft dependency)
90
+ - What blocks other work? (reverse dependency)
91
+
92
+ 3. **Assign priorities**
93
+ - **P0**: Blocks other work, must do first
94
+ - **P1**: High value, should do soon
95
+ - **P2**: Nice to have, can defer
96
+ - **P3**: Optional, do if time permits
97
+
98
+ 4. **Write acceptance criteria**
99
+ - Specific, testable, unambiguous
100
+ - "Login works" is bad. "POST /auth/login with valid credentials returns 200 + JWT" is good.
101
+
102
+ ### Phase 2: Status Check
103
+
104
+ When the user asks "what's next?" or "what's the status?":
105
+
106
+ 1. Read `.mipham/tickets/` directory
107
+ 2. Report:
108
+ - Currently in-progress tickets
109
+ - Blocked tickets (and what's blocking them)
110
+ - Next unblocked P0/P1 tickets ready to work
111
+ - Recently completed tickets (for context)
112
+
113
+ ### Phase 3: Session Handoff
114
+
115
+ When starting a new session, check for continuity:
116
+
117
+ 1. Read the previous session's context from the session store
118
+ 2. Check ticket statuses — any that were `in-progress` last session?
119
+ 3. Present: "Last session you were working on T-004 (Add rate limiting). Continue from there, or start on T-007 (API docs) which is next in the P1 queue?"
120
+
121
+ ### Phase 4: Ticket Lifecycle
122
+
123
+ When working on a ticket:
124
+
125
+ - Mark it `in-progress` when you start
126
+ - Mark it `review` when implementation is done
127
+ - Mark it `done` after verification (tests pass, typecheck clean)
128
+ - If you discover new dependencies, add them to `blocks`/`depends_on`
129
+
130
+ ---
131
+
132
+ ## Dependency Graph
133
+
134
+ For tickets with complex dependencies, generate a visual summary:
135
+
136
+ ```
137
+ T-001 (Auth) ──blocks──→ T-003 (Dashboard)
138
+ │ │
139
+ └──blocks──→ T-002 (API) ─┘
140
+ │
141
+ └──soft-dep──→ T-004 (Rate Limiting)
142
+
143
+ Ready to work: T-001 (no dependencies)
144
+ Blocked: T-002 (waiting on T-001), T-003 (waiting on T-001, T-002)
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Integration With Mipham Code
150
+
151
+ - **Session Store**: Ticket status persists across sessions via `.mipham/tickets/`
152
+ - **Memory System**: Active tickets are loaded as project memory for context
153
+ - **grill-with-docs**: The output of a grill session feeds directly into ticket decomposition
154
+ - **Background Agents**: Long-running work on a ticket can be spawned as a background agent
155
+ - **Critical Thinking Layer**: When decomposing, ask "what's the smallest thing that delivers value?" — don't over-decompose
@@ -56,15 +56,20 @@ export class AgentMessageBus {
56
56
  /**
57
57
  * Get all unread messages addressed to the given agent.
58
58
  * Does NOT mark them as read — use markRead() for that.
59
+ * P2-3: Auto-prunes messages older than 1 hour before polling.
59
60
  */
60
61
  poll(agentId: string): AgentMessage[] {
62
+ // Auto-prune stale messages to prevent accumulation and stuck states
63
+ this.prune(60 * 60 * 1000) // 1 hour TTL
61
64
  return this.messages.filter((m) => m.to === agentId && !m.read)
62
65
  }
63
66
 
64
67
  /**
65
68
  * Get all messages addressed to the given agent (read + unread).
69
+ * P2-3: Auto-prunes stale messages before listing.
66
70
  */
67
71
  list(agentId: string): AgentMessage[] {
72
+ this.prune(60 * 60 * 1000) // 1 hour TTL
68
73
  return this.messages.filter((m) => m.to === agentId)
69
74
  }
70
75
 
@@ -198,6 +198,22 @@ export class SubAgent {
198
198
  const agentType = options.type || 'general'
199
199
  const agentDef = options.agentDef
200
200
 
201
+ // ── P0-3: Create isolated PermissionSystem for this sub-agent ──
202
+ // The agent definition's permissionMode is clamped against parent's org
203
+ // restrictions (maxAllowedMode, forbiddenModes). When permissionMode is
204
+ // 'inherit' or not set, the parent's mode is used but still clamped.
205
+ let subPermission = this.permission // default: use parent's
206
+ if (this.permission) {
207
+ const agentPermMode =
208
+ agentDef?.permissionMode && agentDef.permissionMode !== 'inherit'
209
+ ? agentDef.permissionMode
210
+ : 'inherit'
211
+ subPermission = this.permission.createSubAgentPermission(agentPermMode)
212
+ // P0-5: Enable sub-agent safety mode — auto mode returns 'ask' for
213
+ // Bash/Write/Edit so hooks remain the safety gate in background agents.
214
+ subPermission.setSubAgentMode(true)
215
+ }
216
+
201
217
  // Resolve execution directory: worktree isolation or process cwd
202
218
  const execCwd = options.worktreePath || process.cwd()
203
219
 
@@ -212,6 +228,25 @@ export class SubAgent {
212
228
  }
213
229
  // Otherwise, proceed with a warning — don't block agent execution
214
230
  }
231
+
232
+ // P0-4: Auto-disallow Git tool for worktree-isolated sub-agents
233
+ // to prevent destructive operations on the main checkout.
234
+ // Agent definitions can explicitly re-enable Git if needed.
235
+ if (!agentDef?.tools) {
236
+ // No explicit allowlist — add Git to disallowedTools
237
+ const existingDisallowed = agentDef?.disallowedTools
238
+ ? agentDef.disallowedTools
239
+ .split(',')
240
+ .map((s) => s.trim())
241
+ .filter(Boolean)
242
+ : []
243
+ if (!existingDisallowed.includes('Git')) {
244
+ existingDisallowed.push('Git')
245
+ }
246
+ if (agentDef) {
247
+ agentDef.disallowedTools = existingDisallowed.join(',')
248
+ }
249
+ }
215
250
  }
216
251
 
217
252
  // Resolve system prompt: agentDef > options.systemPrompt > builtin type
@@ -352,11 +387,36 @@ export class SubAgent {
352
387
  continue
353
388
  }
354
389
 
390
+ // ── P0-5: Run PreToolUse hooks (before permission check) ──
391
+ let effectiveInput = tu.input
392
+ if (this.hookEngine) {
393
+ const preResult = await this.hookEngine.executePreToolUse(
394
+ tu.name,
395
+ tu.input,
396
+ 'sub-agent',
397
+ )
398
+ if (!preResult.allowed) {
399
+ const denialMsg = preResult.reason
400
+ ? `Tool "${tu.name}" blocked by PreToolUse hook: ${preResult.reason}`
401
+ : `Tool "${tu.name}" blocked by PreToolUse hook.`
402
+ currentMessages.push({
403
+ role: 'user' as const,
404
+ content: denialMsg,
405
+ })
406
+ continue
407
+ }
408
+ // Apply modified input from hooks
409
+ if (preResult.modifiedInput) {
410
+ effectiveInput = { ...tu.input, ...preResult.modifiedInput }
411
+ }
412
+ }
413
+
355
414
  // Security: check permission before executing.
356
415
  // Sub-agents run without user interaction — tools requiring approval are rejected.
357
416
  // When permission system is absent (undefined), allow all tools (backward compat
358
417
  // for tests and headless usage). When present, always enforce approval checks.
359
- if (this.permission?.needsApproval(tool, tu.input)) {
418
+ // P0-3: Uses isolated subPermission (clamped by org restrictions) instead of parent's.
419
+ if (subPermission?.needsApproval(tool, effectiveInput)) {
360
420
  currentMessages.push({
361
421
  role: 'user' as const,
362
422
  content:
@@ -367,16 +427,40 @@ export class SubAgent {
367
427
  }
368
428
 
369
429
  try {
370
- const result = await tool.execute(tu.input, {
430
+ const result = await tool.execute(effectiveInput, {
371
431
  cwd: execCwd,
372
432
  sessionId: 'sub-agent',
373
433
  provider: '',
374
434
  model: finalModel,
375
435
  })
376
436
 
437
+ // ── P0-5: Run PostToolUse hooks ──
438
+ let displayResult = result
439
+ if (this.hookEngine) {
440
+ const postResult = await this.hookEngine.executePostToolUse(
441
+ tu.name,
442
+ effectiveInput,
443
+ result,
444
+ 'sub-agent',
445
+ )
446
+ if (postResult.updatedOutput) {
447
+ displayResult = { ...result, content: postResult.updatedOutput }
448
+ }
449
+ if (postResult.additionalContext) {
450
+ displayResult = {
451
+ ...displayResult,
452
+ content: displayResult.content
453
+ ? displayResult.content + '\n' + postResult.additionalContext
454
+ : postResult.additionalContext,
455
+ }
456
+ }
457
+ }
458
+
377
459
  currentMessages.push({
378
460
  role: 'assistant' as const,
379
- content: [{ type: 'tool_use' as const, id: tu.id, name: tu.name, input: tu.input }],
461
+ content: [
462
+ { type: 'tool_use' as const, id: tu.id, name: tu.name, input: effectiveInput },
463
+ ],
380
464
  })
381
465
  currentMessages.push({
382
466
  role: 'user' as const,
@@ -384,11 +468,22 @@ export class SubAgent {
384
468
  {
385
469
  type: 'tool_result' as const,
386
470
  tool_use_id: tu.id,
387
- content: result.success ? result.content : result.error || result.content,
471
+ content: displayResult.success
472
+ ? displayResult.content
473
+ : displayResult.error || displayResult.content,
388
474
  },
389
475
  ],
390
476
  })
391
477
  } catch (err) {
478
+ // ── P0-5: Run PostToolUseFailure hooks ──
479
+ if (this.hookEngine) {
480
+ this.hookEngine
481
+ .executePostToolUseFailure(tu.name, effectiveInput, String(err), 'sub-agent')
482
+ .catch(() => {
483
+ // Hook failures never block execution
484
+ })
485
+ }
486
+
392
487
  currentMessages.push({
393
488
  role: 'user' as const,
394
489
  content: `Tool "${tu.name}" execution error: ${String(err)}`,
@@ -8,7 +8,8 @@ export interface AgentFrontmatter {
8
8
  tools?: string // comma-separated allowlist
9
9
  disallowedTools?: string
10
10
  model?: string // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID
11
- permissionMode?: 'default' | 'acceptEdits' | 'auto' | 'bypass' | 'plan'
11
+ permissionMode?:
12
+ 'default' | 'acceptEdits' | 'auto' | 'bypass' | 'plan' | 'bypassPermissions' | 'dontAsk'
12
13
  maxTurns?: number
13
14
  skills?: string
14
15
  background?: boolean
@@ -112,6 +112,16 @@ export class QueryEngine {
112
112
  if (messages.length > 0) {
113
113
  const bus = getMessageBus()
114
114
  for (const msg of messages) {
115
+ // P2-1: Trigger Notification hook for cross-session messages
116
+ if (this.hookEngine) {
117
+ this.hookEngine
118
+ .executeNotification(
119
+ `Cross-session message from ${msg.from}: ${msg.summary}`,
120
+ this.sessionId,
121
+ )
122
+ .catch(() => {})
123
+ }
124
+
115
125
  if (policy === 'ask') {
116
126
  // Mark as awaiting approval — the model should verify with the user before acting
117
127
  bus.post(
@@ -836,17 +846,31 @@ export class QueryEngine {
836
846
  private async executeTool(name: string, params: Record<string, unknown>): Promise<ToolResult> {
837
847
  const tool = this.tools.get(name)
838
848
  if (!tool) {
839
- return { success: false, content: '', error: `Unknown tool: ${name}` }
849
+ // P2-4: Provide a more helpful error for unavailable tools
850
+ const isMcpTool = name.startsWith('mcp__')
851
+ const hint = isMcpTool
852
+ ? `\nMCP tool "${name}" is no longer available. The MCP server may have been removed or disconnected. Use tool-search to discover available tools.`
853
+ : `\nTool "${name}" is not registered. Available tools may have changed — check the tool list.`
854
+ return { success: false, content: '', error: `Unknown tool: ${name}${hint}` }
840
855
  }
841
856
 
842
857
  // Security: check permission before executing
843
858
  if (this.permission.needsApproval(tool, params)) {
859
+ // P1-4: Increment consecutive block counter; if limit exceeded,
860
+ // tell the model to move on instead of retrying.
861
+ const limitExceeded = this.permission.incrementBlockCounter()
862
+ const baseError = t('errors.tool_not_allowed', { name })
863
+ const moveOnHint = limitExceeded
864
+ ? '\n(Consecutive block limit reached. Please try a different approach or ask the user for guidance.)'
865
+ : ''
844
866
  return {
845
867
  success: false,
846
868
  content: '',
847
- error: t('errors.tool_not_allowed', { name }),
869
+ error: baseError + moveOnHint,
848
870
  }
849
871
  }
872
+ // P1-4: Tool allowed — reset block counter
873
+ this.permission.resetBlockCounter()
850
874
 
851
875
  // Run PreToolUse hooks
852
876
  let effectiveParams = params
@@ -902,7 +926,21 @@ export class QueryEngine {
902
926
 
903
927
  // Run PostToolUse hooks
904
928
  if (this.hookEngine) {
905
- await this.hookEngine.executePostToolUse(name, effectiveParams, result, this.sessionId)
929
+ const postResult = await this.hookEngine.executePostToolUse(
930
+ name,
931
+ effectiveParams,
932
+ result,
933
+ this.sessionId,
934
+ )
935
+ // P1-2: Consume hook result — apply updatedOutput and additionalContext
936
+ if (postResult.updatedOutput) {
937
+ result.content = postResult.updatedOutput
938
+ }
939
+ if (postResult.additionalContext) {
940
+ result.content = result.content
941
+ ? result.content + '\n' + postResult.additionalContext
942
+ : postResult.additionalContext
943
+ }
906
944
  }
907
945
 
908
946
  // CRSI: Track rule effectiveness after tool execution
@@ -920,6 +958,15 @@ export class QueryEngine {
920
958
 
921
959
  return result
922
960
  } catch (err) {
961
+ // P1-3: Trigger PostToolUseFailure hook on tool execution errors
962
+ if (this.hookEngine) {
963
+ this.hookEngine
964
+ .executePostToolUseFailure(name, effectiveParams, String(err), this.sessionId)
965
+ .catch(() => {
966
+ // Hook failures never block error handling
967
+ })
968
+ }
969
+
923
970
  // Sanitize error: strip stack traces and internal paths to prevent
924
971
  // information disclosure to the LLM conversation context.
925
972
  const message =
@@ -47,7 +47,7 @@ export class InstructionsLoader {
47
47
  this.tryLoad(join(home, '.mipham', 'USER.md'), 'user')
48
48
  }
49
49
 
50
- buildSystemPrompt(): string {
50
+ buildSystemPrompt(permissionMode?: string): string {
51
51
  const parts: string[] = []
52
52
 
53
53
  for (const inst of this.instructions) {
@@ -61,11 +61,56 @@ export class InstructionsLoader {
61
61
  parts.push(`<!-- ${levelLabel[inst.level] || inst.level} (${inst.path}) -->\n${inst.content}`)
62
62
  }
63
63
 
64
+ // P2-2: Inject current permission mode so the model knows its constraints
65
+ if (permissionMode) {
66
+ parts.push(this.buildPermissionContext(permissionMode))
67
+ }
68
+
64
69
  // Append skills reminder after all instructions
65
70
  if (this.skillsReminder) {
66
71
  parts.push(this.skillsReminder)
67
72
  }
68
73
 
74
+ // Inject critical thinking self-check layer (for analysis/comparison tasks)
75
+ parts.push(`## Critical Thinking Self-Check
76
+
77
+ Before delivering any analysis, comparison, evaluation, or "X vs Y"
78
+ report, run this checklist internally:
79
+
80
+ ### 1. Evidence Standard
81
+ - Every factual claim MUST cite a specific source (file path, URL, line number)
82
+ - If you cannot cite a source, label the claim as [推断] (inference) or [待验证] (unverified)
83
+ - Numbers (counts, percentages, download stats) require cross-validation from a second source
84
+
85
+ ### 2. Equivalence Verification
86
+ - When you claim "A is equivalent to B" or "X has been merged from Y",
87
+ compare their ACTUAL implementation, not just their names or descriptions
88
+ - If you haven't read both implementations, say "appears similar at the
89
+ description level; implementation equivalence not verified"
90
+
91
+ ### 3. Counter-Example Search
92
+ - For each major conclusion, find at least 1 counter-example or edge case
93
+ - If you cannot find one, state that explicitly: "No counter-example found
94
+ within the examined scope"
95
+ - When comparing two systems, ask: "What does X do that Y CANNOT do?"
96
+ (and vice versa) — don't just list overlaps
97
+
98
+ ### 4. Confidence Calibration
99
+ - Label each conclusion with confidence: [高] [中] [低]
100
+ - [高] = verified from source code or primary documentation
101
+ - [中] = inferred from description but not implementation-verified
102
+ - [低] = speculative, based on naming convention or surface similarity
103
+
104
+ ### 5. Depth Check
105
+ - If your analysis is based ONLY on file names and description fields,
106
+ you are doing surface analysis — state this limitation upfront
107
+ - To reach depth: read at least one implementation file per comparison target
108
+ - Ask: "What would a domain expert notice that I'm missing?"
109
+
110
+ These checks are not optional for analysis tasks. Apply them before
111
+ presenting conclusions, and surface any [低] confidence findings
112
+ explicitly rather than burying them.`)
113
+
69
114
  // Inject workflow auto-generation guidance
70
115
  parts.push(`## Workflow Auto-Generation
71
116
 
@@ -102,6 +147,30 @@ Script format: export const meta = { name, description, phases: [...] }
102
147
  this.skillsReminder = reminder
103
148
  }
104
149
 
150
+ /**
151
+ * P2-2: Build a permission-mode context block for the system prompt.
152
+ * Tells the model its current permission level and what to expect.
153
+ */
154
+ private buildPermissionContext(mode: string): string {
155
+ const modeDescriptions: Record<string, string> = {
156
+ default:
157
+ 'You are in **default** mode. Tools marked as requiring approval will be blocked. Use Read/Grep/Glob for exploration.',
158
+ acceptEdits:
159
+ 'You are in **acceptEdits** mode. File reads and edits are allowed; Bash requires approval.',
160
+ plan: 'You are in **plan** mode. Only Read/Grep/Glob are allowed — no file modifications or command execution.',
161
+ auto: 'You are in **auto** mode. Most tools run without approval. If a tool is blocked by security policy or hooks, try a different approach instead of retrying.',
162
+ dontAsk:
163
+ 'You are in **dontAsk** mode. All tools blocked unless explicitly allowlisted. Check your allow rules before acting.',
164
+ bypassPermissions:
165
+ 'You are in **bypassPermissions** mode. All tools are allowed. Use this power responsibly.',
166
+ }
167
+
168
+ const description = modeDescriptions[mode]
169
+ if (!description) return ''
170
+
171
+ return `## Permission Context\n\n${description}\n\nWhen a tool is denied, do NOT retry with the same tool and similar parameters. Move on to a different approach or ask the user for guidance.`
172
+ }
173
+
105
174
  list(): InstructionFile[] {
106
175
  return [...this.instructions]
107
176
  }
@@ -29,6 +29,13 @@ export class PermissionSystem {
29
29
  // ── Org-level restrictions (P0 security) ──
30
30
  private restrictions: PermissionRestrictions | undefined = undefined
31
31
 
32
+ // ── P0-5: Sub-agent mode (auto mode hardening for background tasks) ──
33
+ private isSubAgent = false
34
+
35
+ // ── P1-4: Consecutive block counter (prevents infinite retry loops) ──
36
+ private consecutiveBlockCount = 0
37
+ private static readonly MAX_CONSECUTIVE_BLOCKS = 3
38
+
32
39
  /** Invalidate the permission cache (called on any rule/mode change). */
33
40
  private invalidateCache(): void {
34
41
  this.checkCache.clear()
@@ -77,6 +84,85 @@ export class PermissionSystem {
77
84
  return this.restrictions
78
85
  }
79
86
 
87
+ /**
88
+ * P0-5 (v2.1.222 alignment): Enable sub-agent safety mode.
89
+ * When true, auto mode returns 'ask' for Bash/Write/Edit tools instead of
90
+ * 'bypass', ensuring PreToolUse hooks remain the safety gate in background agents.
91
+ */
92
+ setSubAgentMode(enabled: boolean): void {
93
+ this.isSubAgent = enabled
94
+ this.invalidateCache()
95
+ }
96
+
97
+ /**
98
+ * P1-4 (v2.1.225 alignment): Increment the consecutive block counter
99
+ * when a tool is denied. Safety-filter refusals should NOT count.
100
+ * Returns true if the consecutive block limit has been exceeded.
101
+ */
102
+ incrementBlockCounter(): boolean {
103
+ this.consecutiveBlockCount++
104
+ return this.consecutiveBlockCount >= PermissionSystem.MAX_CONSECUTIVE_BLOCKS
105
+ }
106
+
107
+ /** Reset the block counter when a tool is successfully executed. */
108
+ resetBlockCounter(): void {
109
+ this.consecutiveBlockCount = 0
110
+ }
111
+
112
+ /** Get the current consecutive block count. */
113
+ getBlockCount(): number {
114
+ return this.consecutiveBlockCount
115
+ }
116
+
117
+ /**
118
+ * P0-3 (v2.1.223 alignment): Create a permission context for a sub-agent.
119
+ *
120
+ * The sub-agent's requested permissionMode is clamped against the parent's
121
+ * org restrictions (maxAllowedMode, forbiddenModes). Deny rules from the
122
+ * parent are propagated so org safety policies always apply.
123
+ *
124
+ * Returns a new PermissionSystem instance — NOT shared with the parent.
125
+ */
126
+ createSubAgentPermission(agentPermissionMode: string): PermissionSystem {
127
+ const resolvedMode = this.resolveAgentMode(agentPermissionMode)
128
+ const subPerm = new PermissionSystem(resolvedMode)
129
+
130
+ // Propagate org restrictions to sub-agent
131
+ if (this.restrictions) {
132
+ subPerm.setRestrictions(this.restrictions)
133
+ }
134
+
135
+ // Propagate deny rules (org safety policies must always apply)
136
+ for (const denyEntry of this.denyRules) {
137
+ subPerm.deny(denyEntry.pattern)
138
+ }
139
+
140
+ return subPerm
141
+ }
142
+
143
+ /**
144
+ * Resolve an agent's permissionMode string to a clamped PermissionMode.
145
+ * 'inherit' means "use the parent's current mode".
146
+ * 'bypass' is treated as an alias for 'bypassPermissions'.
147
+ */
148
+ private resolveAgentMode(agentMode: string): PermissionMode {
149
+ // Normalize aliases
150
+ const normalized = agentMode === 'bypass' ? 'bypassPermissions' : agentMode
151
+
152
+ const modeMap: Record<string, PermissionMode> = {
153
+ bypassPermissions: 'bypassPermissions',
154
+ dontAsk: 'dontAsk',
155
+ auto: 'auto',
156
+ plan: 'plan',
157
+ acceptEdits: 'acceptEdits',
158
+ default: 'default',
159
+ inherit: this.mode,
160
+ }
161
+
162
+ const desired: PermissionMode = modeMap[normalized] || 'default'
163
+ return clampMode(desired, this.restrictions)
164
+ }
165
+
80
166
  // ── Rule management ──
81
167
 
82
168
  allow(rule: string): void {
@@ -267,6 +353,12 @@ export class PermissionSystem {
267
353
  if (tool.name === 'SendMessage') {
268
354
  return 'mode-baseline'
269
355
  }
356
+ // P0-5: In sub-agent context, enforce 'ask' for destructive tools
357
+ // so hooks remain the sole safety gate. Without hooks, these tools
358
+ // would otherwise run completely un-gated in auto mode.
359
+ if (this.isSubAgent && ['Bash', 'Write', 'Edit'].includes(tool.name)) {
360
+ return 'ask'
361
+ }
270
362
  return 'bypass'
271
363
 
272
364
  case 'dontAsk':
package/src/index.tsx CHANGED
@@ -238,12 +238,12 @@ export async function runApp(options: RunOptions): Promise<void> {
238
238
  for (const msg of saved.messages) {
239
239
  context.addMessage(msg)
240
240
  }
241
- context.setSystemPrompt(instructions.buildSystemPrompt())
241
+ context.setSystemPrompt(instructions.buildSystemPrompt(config.permission as string))
242
242
  }
243
243
  }
244
244
 
245
245
  if (context.getMessageCount() === 0) {
246
- const basePrompt = instructions.buildSystemPrompt()
246
+ const basePrompt = instructions.buildSystemPrompt(config.permission as string)
247
247
  const memoryReminder = loadSessionMemories(basePrompt)
248
248
 
249
249
  // Inject previous session summary for AI continuity
@@ -430,12 +430,19 @@ export async function runApp(options: RunOptions): Promise<void> {
430
430
  const crossSessionConfig = loadCrossSessionConfig(process.cwd())
431
431
  engine.setCrossSessionConfig(crossSessionConfig)
432
432
 
433
+ // P2-1: Trigger SessionStart hooks after full initialization
434
+ hookEngine.executeSessionStart(sessionName).catch(() => {
435
+ // Hook failures never block session startup
436
+ })
437
+
433
438
  // Auto-save session on exit
434
439
  let saved = false
435
440
  const saveAndExit = () => {
436
441
  saved = true
437
442
  clearInterval(heartbeatInterval)
438
443
  unregisterSession(sessionName)
444
+ // P2-1: Trigger SessionEnd hooks before cleanup (best-effort)
445
+ hookEngine.executeSessionEnd(sessionName).catch(() => {})
439
446
  artifactServer.stop()
440
447
  if (context.getMessageCount() > 0) {
441
448
  SessionStore.save(sessionName, context.getMessages(), {