@miphamai/cli 0.7.10 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.7.10",
3
+ "version": "0.9.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,107 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ const AUTOLOOP_DIR = join(homedir(), '.mipham', 'autoloop')
6
+
7
+ function ensureDir(): void {
8
+ if (!existsSync(AUTOLOOP_DIR)) mkdirSync(AUTOLOOP_DIR, { recursive: true })
9
+ }
10
+
11
+ interface AutoloopJournal {
12
+ sessionId: string
13
+ prompt: string
14
+ status: 'active' | 'completed' | 'stopped'
15
+ iterations: number
16
+ startedAt: string
17
+ lastIteration?: string
18
+ logs: string[]
19
+ }
20
+
21
+ function journalPath(sessionId: string): string {
22
+ return join(AUTOLOOP_DIR, `${sessionId}.json`)
23
+ }
24
+
25
+ /** Create or reset an autonomous loop journal. */
26
+ export function createAutoloopJournal(sessionId: string, prompt: string): AutoloopJournal {
27
+ ensureDir()
28
+ const journal: AutoloopJournal = {
29
+ sessionId,
30
+ prompt,
31
+ status: 'active',
32
+ iterations: 0,
33
+ startedAt: new Date().toISOString(),
34
+ logs: [],
35
+ }
36
+ writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
37
+ return journal
38
+ }
39
+
40
+ /** Read the journal for an autonomous loop. */
41
+ export function readAutoloopJournal(sessionId: string): AutoloopJournal | null {
42
+ const path = journalPath(sessionId)
43
+ if (!existsSync(path)) return null
44
+ try {
45
+ return JSON.parse(readFileSync(path, 'utf-8'))
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ /** Log an iteration in the autonomous loop journal. */
52
+ export function logAutoloopIteration(sessionId: string, summary: string): void {
53
+ const journal = readAutoloopJournal(sessionId)
54
+ if (!journal) return
55
+ journal.iterations++
56
+ journal.lastIteration = new Date().toISOString()
57
+ journal.logs.push(`[${journal.lastIteration}] #${journal.iterations}: ${summary.slice(0, 200)}`)
58
+ // Keep last 50 log entries
59
+ if (journal.logs.length > 50) journal.logs = journal.logs.slice(-50)
60
+ writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
61
+ }
62
+
63
+ /** Mark an autonomous loop as completed or stopped. */
64
+ export function completeAutoloopJournal(sessionId: string, status: 'completed' | 'stopped'): void {
65
+ const journal = readAutoloopJournal(sessionId)
66
+ if (!journal) return
67
+ journal.status = status
68
+ journal.lastIteration = new Date().toISOString()
69
+ writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
70
+ }
71
+
72
+ /** List all active autonomous loops. */
73
+ export function listActiveAutoloops(): AutoloopJournal[] {
74
+ ensureDir()
75
+ const journals: AutoloopJournal[] = []
76
+ for (const file of readdirSync(AUTOLOOP_DIR)) {
77
+ if (!file.endsWith('.json')) continue
78
+ try {
79
+ const j = JSON.parse(readFileSync(join(AUTOLOOP_DIR, file), 'utf-8'))
80
+ if (j.status === 'active') journals.push(j)
81
+ } catch {
82
+ /* skip */
83
+ }
84
+ }
85
+ return journals
86
+ }
87
+
88
+ /** Get the autonomous loop status message. */
89
+ export function getAutoloopStatus(sessionId: string): string {
90
+ const journal = readAutoloopJournal(sessionId)
91
+ if (!journal) return 'No active autonomous loop.'
92
+ const elapsed = Date.now() - new Date(journal.startedAt).getTime()
93
+ const mins = Math.floor(elapsed / 60000)
94
+ const lines = [
95
+ `── Autonomous Loop: ${journal.sessionId} ──`,
96
+ `Status: ${journal.status === 'active' ? '🔄 Running' : journal.status === 'completed' ? '✅ Completed' : '⏹ Stopped'}`,
97
+ `Prompt: "${journal.prompt.slice(0, 100)}${journal.prompt.length > 100 ? '...' : ''}"`,
98
+ `Iterations: ${journal.iterations}`,
99
+ `Started: ${journal.startedAt} (${mins}m ago)`,
100
+ `Last: ${journal.lastIteration || 'N/A'}`,
101
+ ]
102
+ if (journal.logs.length > 0) {
103
+ lines.push('', 'Recent activity:')
104
+ for (const log of journal.logs.slice(-5)) lines.push(` ${log}`)
105
+ }
106
+ return lines.join('\n')
107
+ }
@@ -19,6 +19,14 @@ export class QueryEngine {
19
19
  private goal?: string
20
20
  private maxGoalLoops = 20
21
21
  private lastAssistantContent?: string
22
+ /** Custom verification shell script path for goal checking. */
23
+ private goalVerifyScript?: string
24
+ /** Custom verification skill name for goal checking. */
25
+ private goalVerifySkill?: string
26
+ /** Whether to auto-decompose goal into subtasks. */
27
+ private goalDecompose = false
28
+ /** Subtask IDs created via decomposition. */
29
+ private goalSubtasks: string[] = []
22
30
 
23
31
  constructor(
24
32
  private registry: ProviderRegistry,
@@ -63,8 +71,37 @@ export class QueryEngine {
63
71
  }
64
72
 
65
73
  /** Set the session goal for goal-driven execution. */
66
- setGoal(goal: string): void {
74
+ setGoal(
75
+ goal: string,
76
+ opts?: { verifyScript?: string; verifySkill?: string; decompose?: boolean },
77
+ ): void {
67
78
  this.goal = goal
79
+ this.goalVerifyScript = opts?.verifyScript
80
+ this.goalVerifySkill = opts?.verifySkill
81
+ this.goalDecompose = opts?.decompose ?? false
82
+ this.goalSubtasks = []
83
+ }
84
+
85
+ /** Get current goal state for status display. */
86
+ getGoalState(): {
87
+ goal?: string
88
+ verifyScript?: string
89
+ verifySkill?: string
90
+ decompose: boolean
91
+ subtasks: string[]
92
+ } {
93
+ return {
94
+ goal: this.goal,
95
+ verifyScript: this.goalVerifyScript,
96
+ verifySkill: this.goalVerifySkill,
97
+ decompose: this.goalDecompose,
98
+ subtasks: this.goalSubtasks,
99
+ }
100
+ }
101
+
102
+ /** Register subtasks created via decomposition. */
103
+ addGoalSubtask(taskId: string): void {
104
+ this.goalSubtasks.push(taskId)
68
105
  }
69
106
 
70
107
  /** Get the last assistant text content. */
@@ -291,12 +328,63 @@ export class QueryEngine {
291
328
 
292
329
  if (!this.goal) break
293
330
 
294
- // Ask AI to check the goal condition
295
- const checkMsg = `Has this goal been achieved? "${this.goal}" Answer YES or NO with reason.`
331
+ // Build verification prompt based on mode
332
+ const checkMsg = this.buildGoalCheckMessage()
333
+
334
+ // If using script verification, run the script instead of asking AI
335
+ if (this.goalVerifyScript) {
336
+ const passed = await this.runScriptVerification()
337
+ if (passed) {
338
+ yield { type: 'text', content: `✅ Goal verification passed: ${this.goalVerifyScript}` }
339
+ break
340
+ }
341
+ // Script failed — continue looping
342
+ yield { type: 'text', content: `🔄 Verification script failed — continuing (loop ${loop})` }
343
+ continue
344
+ }
345
+
346
+ // If using skill verification, delegate to the skill
347
+ if (this.goalVerifySkill) {
348
+ yield { type: 'text', content: `🔍 Running verification skill: ${this.goalVerifySkill}` }
349
+ yield* this.process(checkMsg, signal)
350
+ if (this.lastAssistantContent?.includes('VERIFIED')) break
351
+ continue
352
+ }
353
+
354
+ // Default: ask AI YES/NO
296
355
  yield* this.process(checkMsg, signal)
297
- // If AI responds "YES", break
298
356
  if (this.lastAssistantContent?.includes('YES')) break
299
357
  }
358
+
359
+ if (loop >= this.maxGoalLoops) {
360
+ yield {
361
+ type: 'text',
362
+ content: `⚠ Max goal loops (${this.maxGoalLoops}) reached — goal may not be achieved.`,
363
+ }
364
+ }
365
+ }
366
+
367
+ /** Build the goal verification prompt. */
368
+ private buildGoalCheckMessage(): string {
369
+ if (this.goalVerifyScript) {
370
+ return `Run the verification script "${this.goalVerifyScript}" to check: ${this.goal}`
371
+ }
372
+ if (this.goalVerifySkill) {
373
+ return `Use the skill "${this.goalVerifySkill}" to verify: ${this.goal}. If the goal is achieved, respond with VERIFIED. Otherwise explain what's missing.`
374
+ }
375
+ return `Has this goal been achieved? "${this.goal}" Answer YES or NO with reason.`
376
+ }
377
+
378
+ /** Run a shell script for goal verification. */
379
+ private async runScriptVerification(): Promise<boolean> {
380
+ if (!this.goalVerifyScript) return false
381
+ try {
382
+ const { execSync } = await import('node:child_process')
383
+ execSync(this.goalVerifyScript, { timeout: 30000, stdio: 'pipe' })
384
+ return true // exit code 0 = success
385
+ } catch {
386
+ return false // non-zero exit = not yet achieved
387
+ }
300
388
  }
301
389
 
302
390
  private async *continueWithTools(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
@@ -61,7 +61,14 @@ export interface Message {
61
61
 
62
62
  // ── Tool Types ──
63
63
  export type ToolPermission = 'auto' | 'ask' | 'bypass'
64
- export type ToolCategory = 'file' | 'exec' | 'agent' | 'network' | 'system' | 'artifact'
64
+ export type ToolCategory =
65
+ | 'file'
66
+ | 'exec'
67
+ | 'agent'
68
+ | 'network'
69
+ | 'system'
70
+ | 'artifact'
71
+ | 'scheduling'
65
72
 
66
73
  export interface ToolContext {
67
74
  cwd: string
@@ -4,15 +4,31 @@ interface Task {
4
4
  id: string
5
5
  subject: string
6
6
  description: string
7
- status: 'pending' | 'in_progress' | 'completed'
7
+ status: 'pending' | 'in_progress' | 'completed' | 'deleted'
8
+ activeForm?: string
9
+ metadata?: Record<string, unknown>
10
+ blocks: string[]
11
+ blockedBy: string[]
12
+ owner?: string
13
+ createdAt: string
8
14
  }
9
15
 
10
16
  const tasks = new Map<string, Task>()
11
17
  let taskCounter = 0
12
18
 
19
+ function formatTask(t: Task): string {
20
+ const meta = t.metadata ? ` ${JSON.stringify(t.metadata)}` : ''
21
+ const blocks = t.blocks.length ? ` blocks:[${t.blocks.join(',')}]` : ''
22
+ const blocked = t.blockedBy.length ? ` waits:[${t.blockedBy.join(',')}]` : ''
23
+ return `[${t.status}] #${t.id}: ${t.subject}${blocks}${blocked}${meta}`
24
+ }
25
+
13
26
  export const taskTool: ToolDefinition = {
14
27
  name: 'Task',
15
- description: 'Create and manage structured task lists for tracking progress.',
28
+ description:
29
+ 'Create and manage structured task lists for tracking progress. ' +
30
+ 'Supports CRUD, dependencies (blocks/blockedBy), metadata, owner assignment, and status workflow: pending → in_progress → completed. ' +
31
+ 'Use for complex multi-step tasks, session tracking, and organizing work.',
16
32
  category: 'exec',
17
33
  permission: 'auto',
18
34
  parameters: {
@@ -20,48 +36,147 @@ export const taskTool: ToolDefinition = {
20
36
  properties: {
21
37
  action: {
22
38
  type: 'string',
23
- enum: ['create', 'list', 'update'],
24
- description: 'Action to perform',
39
+ enum: ['create', 'list', 'update', 'get', 'delete'],
40
+ description:
41
+ 'Action: create a task, list all, update status/fields, get one by ID, or delete.',
42
+ },
43
+ subject: { type: 'string', description: 'A brief, actionable title (for create/update).' },
44
+ description: { type: 'string', description: 'What needs to be done (for create/update).' },
45
+ activeForm: {
46
+ type: 'string',
47
+ description: 'Present continuous form shown during work (for create/update).',
25
48
  },
26
- subject: { type: 'string', description: 'Task subject (for create)' },
27
- description: { type: 'string', description: 'Task description (for create)' },
28
- taskId: { type: 'string', description: 'Task ID (for update)' },
49
+ taskId: { type: 'string', description: 'Task ID (for update/get/delete).' },
29
50
  status: {
30
51
  type: 'string',
31
- enum: ['pending', 'in_progress', 'completed'],
32
- description: 'New status (for update)',
52
+ enum: ['pending', 'in_progress', 'completed', 'deleted'],
53
+ description: 'New status. "deleted" permanently removes the task.',
54
+ },
55
+ addBlocks: {
56
+ type: 'array',
57
+ items: { type: 'string' },
58
+ description: 'Task IDs that this task blocks (they depend on this one).',
59
+ },
60
+ addBlockedBy: {
61
+ type: 'array',
62
+ items: { type: 'string' },
63
+ description: 'Task IDs that block this task (this one depends on them).',
64
+ },
65
+ owner: { type: 'string', description: 'Agent or user name assigned to this task.' },
66
+ metadata: {
67
+ type: 'object',
68
+ description: 'Arbitrary key-value metadata to attach to the task.',
33
69
  },
34
70
  },
35
71
  required: ['action'],
36
72
  },
73
+
37
74
  async execute(params, _ctx) {
38
75
  const action = params.action as string
39
76
 
77
+ // ── CREATE ──
40
78
  if (action === 'create') {
41
79
  const id = String(++taskCounter)
42
80
  const subject = (params.subject as string) || 'Untitled'
43
- tasks.set(id, {
81
+ const task: Task = {
44
82
  id,
45
83
  subject,
46
84
  description: (params.description as string) || '',
47
85
  status: 'pending',
48
- })
86
+ activeForm: (params.activeForm as string) || undefined,
87
+ metadata: (params.metadata as Record<string, unknown>) || undefined,
88
+ blocks: [],
89
+ blockedBy: [],
90
+ owner: (params.owner as string) || undefined,
91
+ createdAt: new Date().toISOString(),
92
+ }
93
+ tasks.set(id, task)
49
94
  return { success: true, content: `Task #${id} created: ${subject}` }
50
95
  }
51
96
 
97
+ // ── LIST ──
52
98
  if (action === 'list') {
53
- const list = Array.from(tasks.values())
54
- .map((t) => `[${t.status}] #${t.id}: ${t.subject}`)
55
- .join('\n')
56
- return { success: true, content: list || '(no tasks)' }
99
+ const all = Array.from(tasks.values()).filter((t) => t.status !== 'deleted')
100
+ if (all.length === 0) return { success: true, content: '(no tasks)' }
101
+
102
+ // Group by status
103
+ const groups: Record<string, Task[]> = { pending: [], in_progress: [], completed: [] }
104
+ for (const t of all) groups[t.status]?.push(t)
105
+
106
+ const lines: string[] = []
107
+ for (const status of ['in_progress', 'pending', 'completed'] as const) {
108
+ const group = groups[status]!
109
+ if (group.length === 0) continue
110
+ const emoji = status === 'in_progress' ? '🔄' : status === 'pending' ? '📋' : '✅'
111
+ lines.push(`${emoji} ${status.replace('_', ' ')} (${group.length}):`)
112
+ for (const t of group) lines.push(` ${formatTask(t)}`)
113
+ }
114
+ return { success: true, content: lines.join('\n') }
115
+ }
116
+
117
+ // ── GET ──
118
+ if (action === 'get') {
119
+ const taskId = params.taskId as string
120
+ const task = tasks.get(taskId)
121
+ if (!task) return { success: false, content: '', error: `Task #${taskId} not found` }
122
+
123
+ const lines = [
124
+ `── Task #${task.id} ──`,
125
+ `Subject: ${task.subject}`,
126
+ `Status: ${task.status}`,
127
+ `Description: ${task.description || '(none)'}`,
128
+ ]
129
+ if (task.activeForm) lines.push(`Active form: ${task.activeForm}`)
130
+ if (task.owner) lines.push(`Owner: ${task.owner}`)
131
+ if (task.blocks.length) lines.push(`Blocks: ${task.blocks.join(', ')}`)
132
+ if (task.blockedBy.length) lines.push(`Blocked by: ${task.blockedBy.join(', ')}`)
133
+ if (task.metadata) lines.push(`Metadata: ${JSON.stringify(task.metadata)}`)
134
+
135
+ return { success: true, content: lines.join('\n') }
57
136
  }
58
137
 
138
+ // ── UPDATE ──
59
139
  if (action === 'update') {
60
140
  const taskId = params.taskId as string
61
141
  const task = tasks.get(taskId)
62
142
  if (!task) return { success: false, content: '', error: `Task #${taskId} not found` }
63
- if (params.status) task.status = params.status as Task['status']
64
- return { success: true, content: `Task #${taskId} updated: ${task.status}` }
143
+
144
+ if (params.subject !== undefined) task.subject = params.subject as string
145
+ if (params.description !== undefined) task.description = params.description as string
146
+ if (params.activeForm !== undefined) task.activeForm = params.activeForm as string
147
+ if (params.status !== undefined) task.status = params.status as Task['status']
148
+ if (params.owner !== undefined) task.owner = params.owner as string
149
+
150
+ // Merge dependency arrays
151
+ if (Array.isArray(params.addBlocks)) {
152
+ for (const bid of params.addBlocks as string[]) {
153
+ if (!task.blocks.includes(bid)) task.blocks.push(bid)
154
+ }
155
+ }
156
+ if (Array.isArray(params.addBlockedBy)) {
157
+ for (const bid of params.addBlockedBy as string[]) {
158
+ if (!task.blockedBy.includes(bid)) task.blockedBy.push(bid)
159
+ }
160
+ }
161
+
162
+ // Merge metadata
163
+ if (params.metadata && typeof params.metadata === 'object') {
164
+ task.metadata = {
165
+ ...(task.metadata || {}),
166
+ ...(params.metadata as Record<string, unknown>),
167
+ }
168
+ }
169
+
170
+ return { success: true, content: `Task #${taskId} updated.\n${formatTask(task)}` }
171
+ }
172
+
173
+ // ── DELETE ──
174
+ if (action === 'delete') {
175
+ const taskId = params.taskId as string
176
+ const task = tasks.get(taskId)
177
+ if (!task) return { success: false, content: '', error: `Task #${taskId} not found` }
178
+ task.status = 'deleted'
179
+ return { success: true, content: `Task #${taskId} deleted.` }
65
180
  }
66
181
 
67
182
  return { success: false, content: '', error: `Unknown action: ${action}` }
@@ -18,6 +18,8 @@ import { configTool } from './system/config'
18
18
  import { mcpTool } from './system/mcp'
19
19
  import { artifactTool } from './artifact/artifact'
20
20
  import { computerUseTool } from './computer/computer-use'
21
+ import { scheduleWakeupTool } from './scheduling/schedule-wakeup.js'
22
+ import { cronCreateTool, cronDeleteTool, cronListTool } from './scheduling/cron.js'
21
23
 
22
24
  /**
23
25
  * Validate tool parameters against the tool's JSON Schema definition.
@@ -124,6 +126,11 @@ export function createToolRegistry(): Map<string, ToolDefinition> {
124
126
  withValidation(artifactTool),
125
127
  // Computer Use tools
126
128
  withValidation(computerUseTool),
129
+ // Scheduling tools
130
+ withValidation(scheduleWakeupTool),
131
+ withValidation(cronCreateTool),
132
+ withValidation(cronDeleteTool),
133
+ withValidation(cronListTool),
127
134
  ]
128
135
 
129
136
  const map = new Map<string, ToolDefinition>()
@@ -0,0 +1,168 @@
1
+ import {
2
+ mkdirSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ unlinkSync,
6
+ readdirSync,
7
+ existsSync,
8
+ } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { createHash } from 'node:crypto'
11
+ import { homedir } from 'node:os'
12
+ import type { ToolDefinition } from '../../shared/index.ts'
13
+
14
+ const CRON_DIR = join(homedir(), '.mipham', 'cron')
15
+
16
+ function ensureCronDir(): void {
17
+ if (!existsSync(CRON_DIR)) mkdirSync(CRON_DIR, { recursive: true })
18
+ }
19
+
20
+ interface CronJob {
21
+ id: string
22
+ cron: string
23
+ prompt: string
24
+ recurring: boolean
25
+ createdAt: string
26
+ }
27
+
28
+ function jobPath(id: string): string {
29
+ return join(CRON_DIR, `${id}.json`)
30
+ }
31
+
32
+ function generateId(cron: string, prompt: string): string {
33
+ return createHash('sha256').update(`${cron}:${prompt}`).digest('hex').slice(0, 12)
34
+ }
35
+
36
+ function readAllJobs(): CronJob[] {
37
+ ensureCronDir()
38
+ const jobs: CronJob[] = []
39
+ for (const file of readdirSync(CRON_DIR)) {
40
+ if (!file.endsWith('.json')) continue
41
+ try {
42
+ jobs.push(JSON.parse(readFileSync(join(CRON_DIR, file), 'utf-8')))
43
+ } catch {
44
+ /* skip corrupt files */
45
+ }
46
+ }
47
+ return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
48
+ }
49
+
50
+ export const cronCreateTool: ToolDefinition = {
51
+ name: 'CronCreate',
52
+ description:
53
+ 'Schedule a prompt to be enqueued at a future time. Uses standard 5-field cron in local timezone. ' +
54
+ 'For recurring schedules (default): "0 9 * * *" = 9am daily. ' +
55
+ 'For one-shot: set recurring:false with pinned minute/hour/day-of-month/month. ' +
56
+ 'Durable — survives restarts, written to ~/.mipham/cron/.',
57
+ category: 'scheduling',
58
+ permission: 'auto',
59
+ parameters: {
60
+ type: 'object',
61
+ properties: {
62
+ cron: {
63
+ type: 'string',
64
+ description:
65
+ 'Standard 5-field cron expression: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 min).',
66
+ },
67
+ prompt: {
68
+ type: 'string',
69
+ description: 'The prompt to enqueue at each fire time.',
70
+ },
71
+ recurring: {
72
+ type: 'boolean',
73
+ description:
74
+ 'true (default) = fire on every cron match. false = fire once then auto-delete.',
75
+ },
76
+ },
77
+ required: ['cron', 'prompt'],
78
+ },
79
+ async execute(params, _ctx) {
80
+ const cron = params.cron as string
81
+ const prompt = params.prompt as string
82
+ const recurring = params.recurring !== false
83
+ const id = generateId(cron, prompt)
84
+
85
+ ensureCronDir()
86
+
87
+ const job: CronJob = {
88
+ id,
89
+ cron,
90
+ prompt: prompt.slice(0, 1000),
91
+ recurring,
92
+ createdAt: new Date().toISOString(),
93
+ }
94
+
95
+ writeFileSync(jobPath(id), JSON.stringify(job, null, 2), 'utf-8')
96
+
97
+ const type = recurring ? 'recurring' : 'one-shot'
98
+ return {
99
+ success: true,
100
+ content:
101
+ `Created ${type} cron job.\n` +
102
+ `ID: ${id}\n` +
103
+ `Schedule: ${cron}\n` +
104
+ `Prompt: "${prompt.slice(0, 80)}${prompt.length > 80 ? '...' : ''}"`,
105
+ }
106
+ },
107
+ }
108
+
109
+ export const cronDeleteTool: ToolDefinition = {
110
+ name: 'CronDelete',
111
+ description:
112
+ 'Cancel a cron job previously scheduled with CronCreate. Removes from ~/.mipham/cron/.',
113
+ category: 'scheduling',
114
+ permission: 'auto',
115
+ parameters: {
116
+ type: 'object',
117
+ properties: {
118
+ id: {
119
+ type: 'string',
120
+ description: 'Job ID returned by CronCreate.',
121
+ },
122
+ },
123
+ required: ['id'],
124
+ },
125
+ async execute(params, _ctx) {
126
+ const id = params.id as string
127
+ const path = jobPath(id)
128
+ if (!existsSync(path)) {
129
+ return { success: false, content: '', error: `Cron job "${id}" not found.` }
130
+ }
131
+ unlinkSync(path)
132
+ return { success: true, content: `Cron job "${id}" deleted.` }
133
+ },
134
+ }
135
+
136
+ export const cronListTool: ToolDefinition = {
137
+ name: 'CronList',
138
+ description: 'List all cron jobs scheduled via CronCreate, both durable and session-only.',
139
+ category: 'scheduling',
140
+ permission: 'auto',
141
+ parameters: {
142
+ type: 'object',
143
+ properties: {},
144
+ required: [],
145
+ },
146
+ async execute(_params, _ctx) {
147
+ const jobs = readAllJobs()
148
+ if (jobs.length === 0) {
149
+ return {
150
+ success: true,
151
+ content:
152
+ 'No scheduled cron jobs.\n\n' +
153
+ 'Use CronCreate to schedule a recurring or one-shot job.\n' +
154
+ 'Use /loop <interval> <prompt> to start a recurring task.',
155
+ }
156
+ }
157
+
158
+ const lines = [`── Scheduled Cron Jobs (${jobs.length}) ──`, '']
159
+ for (const j of jobs) {
160
+ const type = j.recurring ? 'recurring' : 'one-shot'
161
+ lines.push(`${j.id} ${j.cron} ${type}`)
162
+ lines.push(` ${j.prompt.slice(0, 100)}`)
163
+ lines.push('')
164
+ }
165
+
166
+ return { success: true, content: lines.join('\n') }
167
+ },
168
+ }
@@ -0,0 +1,117 @@
1
+ import type { ToolDefinition } from '../../shared/index.ts'
2
+
3
+ /**
4
+ * Active wakeup timers — in-memory, per-session.
5
+ * Key: "sessionId:wakeup"
6
+ */
7
+ const activeTimers = new Map<string, ReturnType<typeof setTimeout>>()
8
+
9
+ export const scheduleWakeupTool: ToolDefinition = {
10
+ name: 'ScheduleWakeup',
11
+ description:
12
+ 'Schedule when to resume work in /loop dynamic mode — the user invoked /loop without an interval, asking you to self-pace iterations of a specific task. Do NOT schedule a short-interval wakeup to poll for background work you started — when harness-tracked work finishes, you are re-invoked automatically. The runtime clamps to [60, 3600].',
13
+ category: 'scheduling',
14
+ permission: 'auto',
15
+ parameters: {
16
+ type: 'object',
17
+ properties: {
18
+ delaySeconds: {
19
+ type: 'number',
20
+ description: 'Seconds from now to wake up. Clamped to [60, 3600] by the runtime.',
21
+ },
22
+ reason: {
23
+ type: 'string',
24
+ description: 'One short sentence explaining the chosen delay.',
25
+ },
26
+ prompt: {
27
+ type: 'string',
28
+ description: 'The /loop input to fire on wake-up.',
29
+ },
30
+ stop: {
31
+ type: 'boolean',
32
+ description: 'Set to true to end the dynamic loop immediately.',
33
+ },
34
+ },
35
+ required: [],
36
+ },
37
+
38
+ async execute(params, ctx) {
39
+ const sessionId = ctx.sessionId
40
+
41
+ // ── Stop — cancel all timers for this session ──
42
+ if (params.stop === true) {
43
+ let cancelled = 0
44
+ for (const [key, timer] of activeTimers) {
45
+ if (key.startsWith(sessionId + ':')) {
46
+ clearTimeout(timer)
47
+ activeTimers.delete(key)
48
+ cancelled++
49
+ }
50
+ }
51
+ return {
52
+ success: true,
53
+ content: `Loop ended. ${cancelled} pending wakeup(s) cancelled.`,
54
+ }
55
+ }
56
+
57
+ // ── Schedule — validate and register timer ──
58
+ const delaySeconds = params.delaySeconds as number
59
+ const prompt = (params.prompt as string) || ''
60
+ const reason = (params.reason as string) || 'scheduled wakeup'
61
+
62
+ if (!delaySeconds || typeof delaySeconds !== 'number') {
63
+ return {
64
+ success: false,
65
+ content: '',
66
+ error: 'delaySeconds is required and must be a number in [60, 3600].',
67
+ }
68
+ }
69
+
70
+ if (delaySeconds < 60 || delaySeconds > 3600) {
71
+ return {
72
+ success: false,
73
+ content: '',
74
+ error: `delaySeconds must be in [60, 3600], got ${delaySeconds}.`,
75
+ }
76
+ }
77
+
78
+ // Cancel any previous timer for this session (one active wakeup per session)
79
+ for (const [key, timer] of activeTimers) {
80
+ if (key.startsWith(sessionId + ':')) {
81
+ clearTimeout(timer)
82
+ activeTimers.delete(key)
83
+ }
84
+ }
85
+
86
+ const timerKey = `${sessionId}:wakeup`
87
+ const timeoutId = setTimeout(() => {
88
+ activeTimers.delete(timerKey)
89
+ }, delaySeconds * 1000)
90
+
91
+ activeTimers.set(timerKey, timeoutId)
92
+
93
+ const mins = Math.floor(delaySeconds / 60)
94
+ const secs = delaySeconds % 60
95
+ const humanDelay = mins > 0 ? `${mins}m${secs > 0 ? `${secs}s` : ''}` : `${secs}s`
96
+
97
+ return {
98
+ success: true,
99
+ content:
100
+ `⏰ Wakeup scheduled in ${humanDelay} (${reason})\n` +
101
+ `Prompt: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
102
+ }
103
+ },
104
+ }
105
+
106
+ /** Cancel all timers for a session. Called on session cleanup. */
107
+ export function cancelAllSessionTimers(sessionId: string): number {
108
+ let count = 0
109
+ for (const [key, timer] of activeTimers) {
110
+ if (key.startsWith(sessionId + ':')) {
111
+ clearTimeout(timer)
112
+ activeTimers.delete(key)
113
+ count++
114
+ }
115
+ }
116
+ return count
117
+ }
@@ -74,7 +74,7 @@ const helpCmd: CommandHandler = (ctx) => {
74
74
  /cost Token usage estimate
75
75
  /usage Detailed usage dashboard
76
76
  /rename <name> Rename current session
77
- /goal <text> Set session goal
77
+ /goal <text> [--decompose] [--verify-script <path>] [--verify-skill <name>] Set session goal with verification
78
78
  /recap Summarize session so far
79
79
  /summary Generate session summary
80
80
  /stats Session usage statistics
@@ -129,8 +129,11 @@ const helpCmd: CommandHandler = (ctx) => {
129
129
  /pr-comments PR review summary
130
130
  /diff Show git diff
131
131
  /workflows List workflow scripts
132
- /loop <int> <p> Run prompt on interval
133
- /loop init Scaffold .mipham/ vault structure
132
+ /loop <int> <p> Run prompt on interval
133
+ /loop auto <p> Autonomous self-paced loop
134
+ /loop stop Stop active autonomous loop
135
+ /loop init Scaffold .mipham/ vault structure
136
+ /loop status Show autonomous loop progress
134
137
  /batch Apply changes across files
135
138
  /hooks Manage lifecycle hook scripts
136
139
  /schedule View scheduled tasks
@@ -570,17 +573,84 @@ const renameCmd: CommandHandler = (ctx, args) => {
570
573
  }
571
574
 
572
575
  const goalCmd: CommandHandler = (ctx, args) => {
573
- const goal = args.join(' ')
574
- if (!goal.trim()) {
575
- return {
576
- content: `Usage: /goal <statement>\n\nSet a session-level completion condition. Mipham Code will track progress toward this goal.\n\nExample: /goal Fix all TypeScript errors and make tests pass`,
576
+ const input = args.join(' ')
577
+
578
+ // ── Show goal status ──
579
+ if (!input.trim()) {
580
+ const state = ctx.engine.getGoalState?.() || { goal: undefined, decompose: false, subtasks: [] }
581
+ if (!state.goal) {
582
+ return {
583
+ content:
584
+ `Usage: /goal <statement> [options]\n\n` +
585
+ `Set a session-level completion condition with optional verification.\n\n` +
586
+ `Options:\n` +
587
+ ` --decompose Auto-decompose goal into subtasks\n` +
588
+ ` --verify-script <path> Run a shell script to check completion\n` +
589
+ ` --verify-skill <name> Use a skill for verification\n\n` +
590
+ `Examples:\n` +
591
+ ` /goal Fix all TS errors and make tests pass --decompose\n` +
592
+ ` /goal Complete the build --verify-script ./check-build.sh\n` +
593
+ ` /goal Security audit passes --verify-skill security-review`,
594
+ }
577
595
  }
596
+ const lines = [`── Goal Status ──`, '', `🎯 Goal: ${state.goal}`]
597
+ if (state.verifyScript) lines.push(`📜 Verification: ${state.verifyScript}`)
598
+ if (state.verifySkill) lines.push(`🛠 Verification skill: ${state.verifySkill}`)
599
+ if (state.decompose) lines.push(`📋 Decompose: enabled (${state.subtasks.length} subtasks)`)
600
+ lines.push('', 'Type /goal without arguments to clear.')
601
+ return { content: lines.join('\n') }
578
602
  }
579
- ctx.setGoal(goal.trim())
580
- ctx.engine.setGoal(goal.trim())
581
- return {
582
- content: `✓ Goal set: "${goal.trim()}"\n\nUse /status to view progress. Type /goal without arguments to clear.`,
603
+
604
+ // ── Parse options ──
605
+ let goal = input
606
+ let decompose = false
607
+ let verifyScript: string | undefined
608
+ let verifySkill: string | undefined
609
+
610
+ // Extract --decompose
611
+ if (goal.includes(' --decompose')) {
612
+ decompose = true
613
+ goal = goal.replace(' --decompose', '')
614
+ }
615
+
616
+ // Extract --verify-script <path>
617
+ const scriptMatch = goal.match(/ --verify-script\s+(\S+)/)
618
+ if (scriptMatch) {
619
+ verifyScript = scriptMatch[1]!
620
+ goal = goal.replace(` --verify-script ${verifyScript}`, '')
621
+ }
622
+
623
+ // Extract --verify-skill <name>
624
+ const skillMatch = goal.match(/ --verify-skill\s+(\S+)/)
625
+ if (skillMatch) {
626
+ verifySkill = skillMatch[1]!
627
+ goal = goal.replace(` --verify-skill ${verifySkill}`, '')
628
+ }
629
+
630
+ goal = goal.trim()
631
+ if (!goal) {
632
+ return { content: 'Goal text is required. Use /goal without arguments to see usage.' }
583
633
  }
634
+
635
+ ctx.setGoal(goal)
636
+ ctx.engine.setGoal(goal, { verifyScript, verifySkill, decompose })
637
+
638
+ const lines = [`✓ Goal set: "${goal}"`]
639
+ if (decompose) {
640
+ lines.push('📋 Decomposition: enabled — AI will break goal into subtasks')
641
+ // Decompose by creating initial subtasks
642
+ const decomposeMsg = `Break down this goal into 3-5 subtasks: "${goal}". For each subtask, use TaskCreate with the subject and description. Mark each as blocked by the previous one to create a dependency chain.`
643
+ return {
644
+ content: lines.join('\n'),
645
+ forwardToAI: decomposeMsg,
646
+ }
647
+ }
648
+ if (verifyScript) lines.push(`📜 Verification: ${verifyScript}`)
649
+ if (verifySkill) lines.push(`🛠 Verification skill: ${verifySkill}`)
650
+ lines.push('')
651
+ lines.push('Use /status to view progress. Type /goal without arguments to clear.')
652
+
653
+ return { content: lines.join('\n') }
584
654
  }
585
655
 
586
656
  const recapCmd: CommandHandler = (ctx) => {
@@ -1208,25 +1278,94 @@ const loopCmd: CommandHandler = async (_ctx, args) => {
1208
1278
  return { content: lines.join('\n') }
1209
1279
  }
1210
1280
 
1211
- // ── /loop <interval> <prompt> — recurring prompt ──
1281
+ // ── /loop stop — stop autonomous loop ──
1282
+ if (sub === 'stop') {
1283
+ const { completeAutoloopJournal, listActiveAutoloops } =
1284
+ await import('../commands/autoloop-journal.js')
1285
+ const active = listActiveAutoloops()
1286
+ if (active.length === 0) {
1287
+ return { content: 'No active autonomous loops to stop.' }
1288
+ }
1289
+ for (const j of active) completeAutoloopJournal(j.sessionId, 'stopped')
1290
+ return { content: `Stopped ${active.length} autonomous loop(s).` }
1291
+ }
1292
+
1293
+ // ── /loop status — show autonomous loop progress ──
1294
+ if (sub === 'status') {
1295
+ const { listActiveAutoloops, getAutoloopStatus } =
1296
+ await import('../commands/autoloop-journal.js')
1297
+ const active = listActiveAutoloops()
1298
+ if (active.length === 0) {
1299
+ return { content: 'No active autonomous loops.\n\nUse /loop auto <prompt> to start one.' }
1300
+ }
1301
+ const lines = active.map((j) => getAutoloopStatus(j.sessionId))
1302
+ return { content: lines.join('\n\n') }
1303
+ }
1304
+
1305
+ // ── /loop auto <prompt> — autonomous self-paced loop ──
1306
+ if (sub === 'auto') {
1307
+ const prompt = args.slice(1).join(' ')
1308
+ if (!prompt.trim()) {
1309
+ return {
1310
+ content:
1311
+ 'Usage: /loop auto <prompt>\n\n' +
1312
+ 'Start an autonomous self-paced loop. The AI will:\n' +
1313
+ '1. Work on the task\n' +
1314
+ '2. Decide how long to wait before the next iteration\n' +
1315
+ '3. Use ScheduleWakeup to self-schedule\n' +
1316
+ '4. Use ScheduleWakeup(stop:true) when the goal is reached\n\n' +
1317
+ 'Use /loop status to view progress.\n' +
1318
+ 'Use /loop stop to stop the loop.',
1319
+ }
1320
+ }
1321
+
1322
+ const sessionId = `autoloop-${Date.now()}`
1323
+ const { createAutoloopJournal } = await import('../commands/autoloop-journal.js')
1324
+ createAutoloopJournal(sessionId, prompt)
1325
+
1326
+ const autoPrompt =
1327
+ `## Autonomous Loop — ${sessionId}\n\n` +
1328
+ `Task: ${prompt}\n\n` +
1329
+ `You are in an autonomous loop. Each iteration:\n` +
1330
+ `1. Make progress on the task above\n` +
1331
+ `2. When you need to wait (e.g., for a deploy, CI, external event), call ScheduleWakeup with:\n` +
1332
+ ` - delaySeconds: your best estimate of how long to wait (60-3600)\n` +
1333
+ ` - reason: one sentence explaining why\n` +
1334
+ ` - prompt: include this full autonomous loop ID: ${sessionId}\n` +
1335
+ `3. When the task is COMPLETE, call ScheduleWakeup with stop:true\n\n` +
1336
+ `After each iteration, log progress by reading/writing the journal at ~/.mipham/autoloop/${sessionId}.json.\n` +
1337
+ `Use the autoloop-journal module: logAutoloopIteration("${sessionId}", "<summary>").`
1338
+
1339
+ return {
1340
+ content:
1341
+ `── Autonomous Loop Started ──\n\n` +
1342
+ `ID: ${sessionId}\n` +
1343
+ `Prompt: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"\n\n` +
1344
+ `The AI will self-pace. Use /loop status to view progress.\n` +
1345
+ `Use /loop stop to stop.`,
1346
+ forwardToAI: autoPrompt,
1347
+ }
1348
+ }
1349
+
1350
+ // ── /loop <interval> <prompt> — recurring prompt (or auto-detect autonomous) ──
1212
1351
  if (args.length < 2) {
1213
1352
  return {
1214
1353
  content: stripIndent`
1215
- Usage: /loop <interval> <prompt>
1216
- or: /loop init [path] Scaffold project vault structure
1354
+ Usage: /loop <interval> <prompt> Fixed-interval loop
1355
+ or: /loop auto <prompt> Autonomous self-paced loop
1356
+ or: /loop stop Stop active autonomous loop
1357
+ or: /loop status Show autonomous loop progress
1358
+ or: /loop init [path] Scaffold project vault structure
1217
1359
 
1218
- Run a prompt repeatedly on a set interval, or initialize
1219
- a LoopKit Vault project structure with Mipham Code config.
1220
-
1221
- Interval formats:
1360
+ Fixed interval formats:
1222
1361
  10s — 10 seconds
1223
1362
  5m — 5 minutes
1224
1363
  1h — 1 hour
1225
1364
 
1226
- Example:
1365
+ Examples:
1227
1366
  /loop 5m check the deploy status
1228
- /loop init Create vault in current directory
1229
- /loop init ~/my-project Create vault in specific directory
1367
+ /loop auto monitor CI and fix failing tests
1368
+ /loop init Create vault in current directory
1230
1369
  `,
1231
1370
  }
1232
1371
  }
@@ -1235,34 +1374,66 @@ const loopCmd: CommandHandler = async (_ctx, args) => {
1235
1374
  const prompt = args.slice(1).join(' ')
1236
1375
  const seconds = parseInterval(interval)
1237
1376
 
1377
+ // Auto-detect: if first arg is not a valid interval, assume autonomous mode
1238
1378
  if (seconds === null) {
1379
+ // Could be: /loop <full prompt without interval>
1380
+ const fullPrompt = args.join(' ')
1381
+ if (fullPrompt.length > 0) {
1382
+ const sessionId = `autoloop-${Date.now()}`
1383
+ const { createAutoloopJournal } = await import('../commands/autoloop-journal.js')
1384
+ createAutoloopJournal(sessionId, fullPrompt)
1385
+
1386
+ const autoPrompt =
1387
+ `## Autonomous Loop — ${sessionId}\n\n` +
1388
+ `Task: ${fullPrompt}\n\n` +
1389
+ `You are in an autonomous loop (auto-detected — no interval specified). Each iteration:\n` +
1390
+ `1. Make progress on the task above\n` +
1391
+ `2. When you need to wait, call ScheduleWakeup with your chosen delaySeconds and this loop ID: ${sessionId}\n` +
1392
+ `3. When COMPLETE, call ScheduleWakeup with stop:true\n\n` +
1393
+ `Log progress using logAutoloopIteration("${sessionId}", "<summary>").`
1394
+
1395
+ return {
1396
+ content:
1397
+ `── Autonomous Loop Started (auto-detected) ──\n\n` +
1398
+ `ID: ${sessionId}\n` +
1399
+ `Prompt: "${fullPrompt.slice(0, 100)}${fullPrompt.length > 100 ? '...' : ''}"\n\n` +
1400
+ `No interval specified — using autonomous self-paced mode.\n` +
1401
+ `Use /loop stop to stop.`,
1402
+ forwardToAI: autoPrompt,
1403
+ }
1404
+ }
1239
1405
  return {
1240
- content: `── Invalid Interval ──\n\n"${interval}" is not a recognised interval.\n\nUse formats like: 10s, 5m, 1h, 30min, 2hr`,
1406
+ content: `── Invalid Interval ──\n\n"${interval}" is not a recognised interval.\n\nUse formats like: 10s, 5m, 1h, 30min, 2hr\nOr use /loop auto <prompt> for autonomous mode.`,
1241
1407
  }
1242
1408
  }
1243
1409
 
1244
- return {
1245
- content: `── Loop Started ──\n\nInterval: ${interval} (${seconds}s)\nPrompt: "${prompt}"\n\nScheduling via ScheduleWakeup — the prompt will re-schedule itself each cycle.\nUse /schedule to view active loops.`,
1246
- forwardToAI: `use ScheduleWakeup to set up a recurring loop. delaySeconds=${seconds}, prompt: "${prompt}". Include at the end of the prompt: "Then call ScheduleWakeup again with delaySeconds=${seconds} and the same prompt to continue the loop."`,
1410
+ // Fixed-interval loop — directly invoke ScheduleWakeup
1411
+ try {
1412
+ const { scheduleWakeupTool } = await import('../tools/scheduling/schedule-wakeup.js')
1413
+ const result = await scheduleWakeupTool.execute(
1414
+ { delaySeconds: seconds, reason: `Loop: ${prompt.slice(0, 40)}`, prompt },
1415
+ { cwd: process.cwd(), sessionId: 'loop-session', provider: '', model: '' },
1416
+ )
1417
+ return result.success
1418
+ ? { content: result.content }
1419
+ : { content: `Loop failed: ${result.error || 'unknown error'}` }
1420
+ } catch (e) {
1421
+ return { content: `Loop failed: ${e instanceof Error ? e.message : String(e)}` }
1247
1422
  }
1248
1423
  }
1249
1424
 
1250
- const scheduleCmd: CommandHandler = (_ctx) => ({
1251
- content: stripIndent`
1252
- ── Scheduled Tasks ──
1253
-
1254
- Fetching active scheduled tasks and loops...
1255
-
1256
- Active loops are managed via ScheduleWakeup (in-session) and
1257
- CronCreate (durable, survives restarts).
1258
-
1259
- Use /loop <interval> <prompt> to start a recurring task.
1260
- Use CronList to view all scheduled jobs.
1261
- Use CronDelete <id> to cancel a durable job.
1262
- `,
1263
- forwardToAI:
1264
- 'Use CronList to show all currently scheduled cron jobs (both durable and session-only). Present them in a clear table with: name/ID, schedule (cron expression), type (recurring vs one-shot), and status. Also note any active ScheduleWakeup loops if visible in the current session. If no scheduled tasks exist, suggest: "No scheduled tasks. Use /loop <interval> <prompt> to create a recurring task."',
1265
- })
1425
+ const scheduleCmd: CommandHandler = async (_ctx) => {
1426
+ try {
1427
+ const { cronListTool } = await import('../tools/scheduling/cron.js')
1428
+ const result = await cronListTool.execute(
1429
+ {},
1430
+ { cwd: process.cwd(), sessionId: 'schedule-view', provider: '', model: '' },
1431
+ )
1432
+ return { content: result.content }
1433
+ } catch (e) {
1434
+ return { content: `Schedule check failed: ${e instanceof Error ? e.message : String(e)}` }
1435
+ }
1436
+ }
1266
1437
 
1267
1438
  // ═══════════════════════════════════════════════════════════════
1268
1439
  // Diagnostic