@miphamai/cli 0.13.0 → 0.15.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 +1 -1
- package/skills/workflows/audit.js +51 -0
- package/skills/workflows/hunt.js +95 -0
- package/skills/workflows/judge.js +55 -0
- package/skills/workflows/migrate.js +92 -0
- package/skills/workflows/research.js +87 -0
- package/skills/workflows/review.js +92 -0
- package/src/agent/sub-agent.ts +18 -1
- package/src/agent/types.ts +2 -0
- package/src/core/engine.ts +40 -0
- package/src/core/instructions.ts +28 -0
- package/src/core/permission.ts +5 -0
- package/src/core/usage-tracker.ts +103 -0
- package/src/providers/anthropic.ts +9 -1
- package/src/providers/openai-compat.ts +10 -0
- package/src/shared/types.ts +14 -1
- package/src/skills/fork-executor.ts +3 -1
- package/src/tools/agent/agent.ts +1 -1
- package/src/tools/agent/skill.ts +1 -0
- package/src/tools/agent/workflow.ts +67 -3
- package/src/ui/commands.ts +143 -6
- package/src/workflow/primitives/agent.ts +132 -46
- package/src/workflow/primitives/loop.ts +103 -0
- package/src/workflow/primitives/verify.ts +275 -0
- package/src/workflow/runtime.ts +22 -7
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UsageTracker — per-session token usage tracking with per-tool attribution.
|
|
3
|
+
*
|
|
4
|
+
* Tracks actual API-reported token counts (when available) and falls back
|
|
5
|
+
* to character-estimated counts. Token costs are attributed to the tool
|
|
6
|
+
* invoked in each turn — MCP tools (prefixed `mcp__`) get correct
|
|
7
|
+
* per-invocation attribution, not inflated cumulative costs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ToolUsage {
|
|
11
|
+
/** Total input tokens attributed to this tool. */
|
|
12
|
+
inputTokens: number
|
|
13
|
+
/** Total output tokens attributed to this tool. */
|
|
14
|
+
outputTokens: number
|
|
15
|
+
/** Number of times this tool was invoked. */
|
|
16
|
+
calls: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UsageSummary {
|
|
20
|
+
/** Total input tokens from API (0 if API data unavailable). */
|
|
21
|
+
apiInputTokens: number
|
|
22
|
+
/** Total output tokens from API (0 if API data unavailable). */
|
|
23
|
+
apiOutputTokens: number
|
|
24
|
+
/** Estimated total tokens (chars/4 heuristic, always available). */
|
|
25
|
+
estimatedTokens: number
|
|
26
|
+
/** Per-tool breakdown, keyed by tool name. "chat" = text-only turns. */
|
|
27
|
+
tools: Record<string, ToolUsage>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class UsageTracker {
|
|
31
|
+
private _apiInputTokens = 0
|
|
32
|
+
private _apiOutputTokens = 0
|
|
33
|
+
private _estimatedTokens = 0
|
|
34
|
+
private _toolUsage = new Map<string, ToolUsage>()
|
|
35
|
+
|
|
36
|
+
/** Record API-reported token usage for a turn, attributed to the given tool. */
|
|
37
|
+
recordApiUsage(inputTokens: number, outputTokens: number, toolName?: string): void {
|
|
38
|
+
this._apiInputTokens += inputTokens
|
|
39
|
+
this._apiOutputTokens += outputTokens
|
|
40
|
+
this._recordTool(toolName || 'chat', inputTokens, outputTokens)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Record character-estimated token usage for a turn. */
|
|
44
|
+
recordEstimatedUsage(tokenCount: number, toolName?: string): void {
|
|
45
|
+
this._estimatedTokens += tokenCount
|
|
46
|
+
this._recordToolEstimated(toolName || 'chat', tokenCount)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Get a summary of all usage for the current session. */
|
|
50
|
+
getSummary(): UsageSummary {
|
|
51
|
+
const tools: Record<string, ToolUsage> = {}
|
|
52
|
+
for (const [name, usage] of this._toolUsage) {
|
|
53
|
+
tools[name] = { ...usage }
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
apiInputTokens: this._apiInputTokens,
|
|
57
|
+
apiOutputTokens: this._apiOutputTokens,
|
|
58
|
+
estimatedTokens: this._estimatedTokens,
|
|
59
|
+
tools,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Reset all counters for a new session. */
|
|
64
|
+
reset(): void {
|
|
65
|
+
this._apiInputTokens = 0
|
|
66
|
+
this._apiOutputTokens = 0
|
|
67
|
+
this._estimatedTokens = 0
|
|
68
|
+
this._toolUsage.clear()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Total API tokens (input + output). */
|
|
72
|
+
get totalApiTokens(): number {
|
|
73
|
+
return this._apiInputTokens + this._apiOutputTokens
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether any real API usage data has been recorded. */
|
|
77
|
+
get hasApiData(): boolean {
|
|
78
|
+
return this._apiInputTokens > 0 || this._apiOutputTokens > 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Private helpers ──
|
|
82
|
+
|
|
83
|
+
private _recordTool(name: string, inputTokens: number, outputTokens: number): void {
|
|
84
|
+
const existing = this._toolUsage.get(name)
|
|
85
|
+
if (existing) {
|
|
86
|
+
existing.inputTokens += inputTokens
|
|
87
|
+
existing.outputTokens += outputTokens
|
|
88
|
+
existing.calls++
|
|
89
|
+
} else {
|
|
90
|
+
this._toolUsage.set(name, { inputTokens, outputTokens, calls: 1 })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private _recordToolEstimated(name: string, tokenCount: number): void {
|
|
95
|
+
const existing = this._toolUsage.get(name)
|
|
96
|
+
if (existing) {
|
|
97
|
+
existing.inputTokens += tokenCount
|
|
98
|
+
existing.calls++
|
|
99
|
+
} else {
|
|
100
|
+
this._toolUsage.set(name, { inputTokens: tokenCount, outputTokens: 0, calls: 1 })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -194,7 +194,15 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
case 'message_delta': {
|
|
197
|
-
//
|
|
197
|
+
// Capture token usage for accurate cost tracking
|
|
198
|
+
if (event.usage) {
|
|
199
|
+
yield {
|
|
200
|
+
type: 'usage',
|
|
201
|
+
inputTokens: event.usage.input_tokens,
|
|
202
|
+
outputTokens: event.usage.output_tokens,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Contains stop_reason; also handles late input_json_delta
|
|
198
206
|
if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
|
|
199
207
|
accumulatedToolInput += event.delta.partial_json
|
|
200
208
|
}
|
|
@@ -99,6 +99,16 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
99
99
|
try {
|
|
100
100
|
const parsed = JSON.parse(data)
|
|
101
101
|
const choice = parsed.choices?.[0]
|
|
102
|
+
|
|
103
|
+
// Capture token usage when available (final chunk with stream_options.include_usage)
|
|
104
|
+
if (parsed.usage) {
|
|
105
|
+
yield {
|
|
106
|
+
type: 'usage',
|
|
107
|
+
inputTokens: parsed.usage.prompt_tokens,
|
|
108
|
+
outputTokens: parsed.usage.completion_tokens,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
102
112
|
if (!choice) continue
|
|
103
113
|
|
|
104
114
|
const delta = choice.delta
|
package/src/shared/types.ts
CHANGED
|
@@ -81,6 +81,7 @@ export interface ToolContext {
|
|
|
81
81
|
artifactServer?: import('../artifacts/server').ArtifactServer
|
|
82
82
|
agentRegistry?: import('../agent/agent-registry').AgentRegistry
|
|
83
83
|
backgroundAgentRegistry?: import('../agent/background-registry').BackgroundAgentRegistry
|
|
84
|
+
permissionSystem?: import('../core/permission').PermissionSystem
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
// ── Artifact Types ──
|
|
@@ -128,7 +129,15 @@ export interface TaskNotification {
|
|
|
128
129
|
|
|
129
130
|
// ── Stream Types ──
|
|
130
131
|
export interface StreamChunk {
|
|
131
|
-
type:
|
|
132
|
+
type:
|
|
133
|
+
| 'text'
|
|
134
|
+
| 'tool_use'
|
|
135
|
+
| 'tool_result'
|
|
136
|
+
| 'thinking'
|
|
137
|
+
| 'stop'
|
|
138
|
+
| 'error'
|
|
139
|
+
| 'task_notification'
|
|
140
|
+
| 'usage'
|
|
132
141
|
content?: string
|
|
133
142
|
toolUse?: ToolUseContent
|
|
134
143
|
tool_use_id?: string
|
|
@@ -139,6 +148,10 @@ export interface StreamChunk {
|
|
|
139
148
|
thinking?: string
|
|
140
149
|
/** Background task notification payload (type: 'task_notification'). */
|
|
141
150
|
taskNotification?: TaskNotification
|
|
151
|
+
/** API-reported input token count (type: 'usage'). */
|
|
152
|
+
inputTokens?: number
|
|
153
|
+
/** API-reported output token count (type: 'usage'). */
|
|
154
|
+
outputTokens?: number
|
|
142
155
|
}
|
|
143
156
|
|
|
144
157
|
// ── Config Types ──
|
|
@@ -2,6 +2,7 @@ import { SubAgent } from '../agent/sub-agent'
|
|
|
2
2
|
import type { ProviderRegistry } from '../providers/registry'
|
|
3
3
|
import type { ToolDefinition, SkillDefinition } from '../shared/index.ts'
|
|
4
4
|
import type { AgentDefinition } from '../agent/types'
|
|
5
|
+
import type { PermissionSystem } from '../core/permission'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Execute a skill in an isolated subagent context (context: fork).
|
|
@@ -15,6 +16,7 @@ export async function executeForkedSkill(
|
|
|
15
16
|
args: string,
|
|
16
17
|
registry: ProviderRegistry,
|
|
17
18
|
toolRegistry: Map<string, ToolDefinition>,
|
|
19
|
+
permissionSystem?: PermissionSystem,
|
|
18
20
|
): Promise<string> {
|
|
19
21
|
const agentDef: AgentDefinition = {
|
|
20
22
|
name: `skill:${skill.name}`,
|
|
@@ -27,7 +29,7 @@ export async function executeForkedSkill(
|
|
|
27
29
|
source: 'builtin',
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
const sub = new SubAgent(registry, toolRegistry)
|
|
32
|
+
const sub = new SubAgent(registry, toolRegistry, permissionSystem)
|
|
31
33
|
const prompt = args
|
|
32
34
|
? `Execute the "${skill.name}" skill with arguments: ${args}`
|
|
33
35
|
: `Execute the "${skill.name}" skill.`
|
package/src/tools/agent/agent.ts
CHANGED
|
@@ -60,7 +60,7 @@ export const agentTool: ToolDefinition = {
|
|
|
60
60
|
const agentDef = ctx.agentRegistry?.resolve(agentType)
|
|
61
61
|
|
|
62
62
|
try {
|
|
63
|
-
const sub = new SubAgent(registry, toolRegistry)
|
|
63
|
+
const sub = new SubAgent(registry, toolRegistry, ctx.permissionSystem)
|
|
64
64
|
const result = await sub.execute(prompt, description, {
|
|
65
65
|
type: agentType,
|
|
66
66
|
agentDef,
|
package/src/tools/agent/skill.ts
CHANGED
|
@@ -5,9 +5,56 @@ import type { QueryEngine } from '../../core/engine'
|
|
|
5
5
|
export const workflowTool: ToolDefinition = {
|
|
6
6
|
name: 'Workflow',
|
|
7
7
|
description:
|
|
8
|
-
'Execute a
|
|
9
|
-
'
|
|
10
|
-
'and
|
|
8
|
+
'Execute a workflow script that orchestrates multiple subagents deterministically. ' +
|
|
9
|
+
'Workflows run in the background — this tool returns immediately with a task ID, ' +
|
|
10
|
+
'and a <task-notification> arrives when the workflow completes. Use /workflows to watch live progress.\n\n' +
|
|
11
|
+
'A workflow structures work across many agents — to be comprehensive (decompose and cover in parallel), ' +
|
|
12
|
+
'to be confident (independent perspectives and adversarial checks before committing), ' +
|
|
13
|
+
'or to take on scale one context cannot hold (migrations, audits, broad sweeps). ' +
|
|
14
|
+
'The script is where you encode that structure: what fans out, what verifies, what synthesizes.\n\n' +
|
|
15
|
+
'ONLY call this tool when the task benefits from multi-agent orchestration. ' +
|
|
16
|
+
'For a simple single-agent lookup or edit, use the Agent tool or direct tools instead.\n\n' +
|
|
17
|
+
'## Primitives\n\n' +
|
|
18
|
+
'- agent(prompt: string, opts?: {label?, phase?, schema?, model?, effort?, isolation?}): Promise<any> — spawn a subagent. ' +
|
|
19
|
+
'Without schema, returns final text as string. With schema (JSON Schema), returns validated object — retries on mismatch.\n' +
|
|
20
|
+
'- parallel(thunks: Array<() => Promise<any>>): Promise<any[]> — BARRIER: runs all thunks concurrently, waits for all. ' +
|
|
21
|
+
'Failed thunks resolve to null. Use filter(Boolean) before consuming results.\n' +
|
|
22
|
+
'- pipeline(items: T[], ...stages): Promise<any[]> — NO barrier: each item flows through all stages independently. ' +
|
|
23
|
+
'Item A can be in stage 3 while item B is still in stage 1. DEFAULT choice for multi-stage work.\n' +
|
|
24
|
+
'- verify(finding, opts): Promise<VerifyResult> — adversarial/perspective/consensus quality gate. ' +
|
|
25
|
+
'Spawns skeptics or lens-based judges, applies threshold, returns {survives, votes, score}.\n' +
|
|
26
|
+
'- judge(attempts, opts): Promise<JudgeResult> — judge panel: N attempts scored by M judges across K criteria. ' +
|
|
27
|
+
'Returns winner with optional synthesis grafting runner-up ideas.\n' +
|
|
28
|
+
'- loopUntilConvergence(opts): Promise<LoopUntilConvergenceResult> — convergent discovery loop. ' +
|
|
29
|
+
'Fans out finders repeatedly, deduplicates against seen-set (NOT confirmed-set), ' +
|
|
30
|
+
'stops after N consecutive dry rounds or maxRounds. Optionally verifies each finding.\n' +
|
|
31
|
+
'- phase(title: string): void — start a new progress group\n' +
|
|
32
|
+
'- log(message: string): void — emit progress message\n' +
|
|
33
|
+
'- args: any — verbatim args passed to Workflow tool\n' +
|
|
34
|
+
'- budget: {total, spent(), remaining()} — token budget tracking\n\n' +
|
|
35
|
+
'## Topology Selection Guide\n\n' +
|
|
36
|
+
'DEFAULT TO pipeline(). Only reach for a barrier (parallel between stages) when you genuinely ' +
|
|
37
|
+
'need ALL prior-stage results together.\n\n' +
|
|
38
|
+
'A barrier is correct ONLY when stage N needs cross-item context from all of stage N-1: ' +
|
|
39
|
+
'dedup/merge across the full result set, early-exit if total count is zero, cross-finding comparison.\n\n' +
|
|
40
|
+
'A barrier is NOT justified by: flatten/map/filter (do it inside a pipeline stage), ' +
|
|
41
|
+
'conceptually separate stages, cleaner code — barrier latency is real and measurable.\n\n' +
|
|
42
|
+
'- Diamond (fan-out → reduce → synthesize): market scans, audits, research\n' +
|
|
43
|
+
'- Pipeline (no barrier): each item flows independently — DEFAULT\n' +
|
|
44
|
+
'- Loop-until-convergence: unknown-size discovery (bugs, vulnerabilities, edge cases)\n' +
|
|
45
|
+
'- Judge panel: multiple competing approaches, pick best + graft runner-ups\n' +
|
|
46
|
+
'- Verifier-on-edge: quality gates before results reach downstream\n\n' +
|
|
47
|
+
'## Critical Rules\n\n' +
|
|
48
|
+
'- EDGE LOGIC IS FREE: flatten, dedupe, filter in plain JavaScript — NOT agent calls. ' +
|
|
49
|
+
'results.flatMap(...) and a Set are deterministic, instant, zero tokens.\n' +
|
|
50
|
+
'- seen-set dedup for loops, NOT confirmed-set — rejected findings would otherwise revive every round.\n' +
|
|
51
|
+
'- Each node should have bounded input, validated output (schema), and one clear purpose.\n' +
|
|
52
|
+
'- Model tiering: use cheaper models for repetitive extraction/classification nodes, ' +
|
|
53
|
+
'expensive models for synthesis/judgment nodes.\n\n' +
|
|
54
|
+
'## Script Format\n\n' +
|
|
55
|
+
'Every script MUST begin with: export const meta = { name, description, phases: [{title, detail}] }\n' +
|
|
56
|
+
'The meta object must be a PURE LITERAL — no variables, function calls, or template interpolation.\n' +
|
|
57
|
+
'Use the SAME phase titles in meta.phases as in phase() calls.',
|
|
11
58
|
category: 'agent',
|
|
12
59
|
permission: 'ask',
|
|
13
60
|
parameters: {
|
|
@@ -60,6 +107,23 @@ export const workflowTool: ToolDefinition = {
|
|
|
60
107
|
resumeFromRunId,
|
|
61
108
|
)
|
|
62
109
|
|
|
110
|
+
// Persist last-run state for /workflow save
|
|
111
|
+
try {
|
|
112
|
+
const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')
|
|
113
|
+
const { join } = await import('node:path')
|
|
114
|
+
const workflowsDir = join(process.cwd(), '.claude', 'workflows')
|
|
115
|
+
if (!existsSync(workflowsDir)) {
|
|
116
|
+
mkdirSync(workflowsDir, { recursive: true })
|
|
117
|
+
}
|
|
118
|
+
writeFileSync(
|
|
119
|
+
join(workflowsDir, '.last-run.json'),
|
|
120
|
+
JSON.stringify({ runId, script, timestamp: new Date().toISOString() }),
|
|
121
|
+
'utf-8',
|
|
122
|
+
)
|
|
123
|
+
} catch {
|
|
124
|
+
// best-effort — don't fail the workflow if state persistence fails
|
|
125
|
+
}
|
|
126
|
+
|
|
63
127
|
let content = `Workflow ${runId} completed.\n\n`
|
|
64
128
|
if (resumeFromRunId) {
|
|
65
129
|
content += `Cache: ${cacheHits} hits · ${cacheMisses} live\n\n`
|
package/src/ui/commands.ts
CHANGED
|
@@ -689,22 +689,47 @@ const recapCmd: CommandHandler = (ctx) => {
|
|
|
689
689
|
|
|
690
690
|
const usageCmd: CommandHandler = (ctx) => {
|
|
691
691
|
const c = ctx.engine.getContext()
|
|
692
|
-
const
|
|
692
|
+
const estTokens = c.getEstimatedTokens()
|
|
693
693
|
const msgs = c.getMessages()
|
|
694
694
|
const maxTokens = 200_000
|
|
695
|
-
const pct = ((
|
|
695
|
+
const pct = ((estTokens / maxTokens) * 100).toFixed(1)
|
|
696
|
+
|
|
697
|
+
const tracker = ctx.engine.getUsageTracker()
|
|
698
|
+
const summary = tracker.getSummary()
|
|
699
|
+
|
|
700
|
+
// Build per-tool breakdown
|
|
701
|
+
const toolLines: string[] = []
|
|
702
|
+
const sortedTools = Object.entries(summary.tools).sort(
|
|
703
|
+
(a, b) => b[1].inputTokens + b[1].outputTokens - (a[1].inputTokens + a[1].outputTokens),
|
|
704
|
+
)
|
|
705
|
+
for (const [name, usage] of sortedTools) {
|
|
706
|
+
const total = usage.inputTokens + usage.outputTokens
|
|
707
|
+
const prefix = name.startsWith('mcp__') ? '🔌 ' : ' '
|
|
708
|
+
toolLines.push(
|
|
709
|
+
`${prefix}${name.padEnd(18)} ${total.toLocaleString().padStart(8)} tokens (${usage.calls} call${usage.calls !== 1 ? 's' : ''})`,
|
|
710
|
+
)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const apiTotal = summary.apiInputTokens + summary.apiOutputTokens
|
|
714
|
+
const apiLine =
|
|
715
|
+
summary.apiInputTokens > 0 || summary.apiOutputTokens > 0
|
|
716
|
+
? `API tokens: ${summary.apiInputTokens.toLocaleString().padStart(8)} in / ${summary.apiOutputTokens.toLocaleString().padStart(6)} out (${apiTotal.toLocaleString()} total)`
|
|
717
|
+
: 'API tokens: (no API usage data yet)'
|
|
718
|
+
|
|
719
|
+
const toolSection = toolLines.length > 0 ? `\n── Per-Tool ──\n${toolLines.join('\n')}` : ''
|
|
696
720
|
|
|
697
721
|
return {
|
|
698
722
|
content: stripIndent`
|
|
699
723
|
── Usage Dashboard ──
|
|
700
|
-
|
|
724
|
+
${apiLine}
|
|
725
|
+
Context tokens: ~${estTokens.toLocaleString()} / ${maxTokens.toLocaleString()} (${pct}%)
|
|
701
726
|
Messages: ${msgs.length}
|
|
702
727
|
Provider: ${ctx.providerId}
|
|
703
728
|
Model: ${ctx.modelId}
|
|
704
729
|
|
|
705
730
|
${'█'.repeat(Math.ceil(Number(pct) / 5))}${'░'.repeat(20 - Math.ceil(Number(pct) / 5))} ${pct}%
|
|
731
|
+
${toolSection}
|
|
706
732
|
|
|
707
|
-
Note: Token counting is approximate (chars/4).
|
|
708
733
|
Use /context for detailed stats, /compact to free space.
|
|
709
734
|
`,
|
|
710
735
|
}
|
|
@@ -2388,6 +2413,111 @@ const workflowsCmd: CommandHandler = async () => {
|
|
|
2388
2413
|
return { content: lines.join('\n') }
|
|
2389
2414
|
}
|
|
2390
2415
|
|
|
2416
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2417
|
+
// /workflow <task> — auto-generate + execute
|
|
2418
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2419
|
+
|
|
2420
|
+
const workflowSaveCmd = async (name: string): Promise<CommandResult> => {
|
|
2421
|
+
const { existsSync, mkdirSync, writeFileSync, readFileSync } = await import('node:fs')
|
|
2422
|
+
const { join } = await import('node:path')
|
|
2423
|
+
|
|
2424
|
+
const targetDir = join(process.cwd(), '.claude', 'workflows')
|
|
2425
|
+
if (!existsSync(targetDir)) {
|
|
2426
|
+
mkdirSync(targetDir, { recursive: true })
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// Read the last-run state persisted by the Workflow tool
|
|
2430
|
+
const stateFile = join(targetDir, '.last-run.json')
|
|
2431
|
+
if (!existsSync(stateFile)) {
|
|
2432
|
+
return { content: 'No recent workflow run found. Run a workflow first with /workflow <task>.' }
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
try {
|
|
2436
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
|
2437
|
+
const script = state.script as string
|
|
2438
|
+
if (!script) {
|
|
2439
|
+
return { content: 'No script found in last run state.' }
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '-')
|
|
2443
|
+
const scriptPath = join(targetDir, `${safeName}.js`)
|
|
2444
|
+
writeFileSync(scriptPath, script, 'utf-8')
|
|
2445
|
+
|
|
2446
|
+
return {
|
|
2447
|
+
content: `Workflow saved to ${scriptPath}\nUse /workflow run ${safeName} to run it again.`,
|
|
2448
|
+
}
|
|
2449
|
+
} catch (err) {
|
|
2450
|
+
return { content: `Failed to save workflow: ${String(err)}` }
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
const workflowRunCmd = async (name: string): Promise<CommandResult> => {
|
|
2455
|
+
const { existsSync } = await import('node:fs')
|
|
2456
|
+
const { join } = await import('node:path')
|
|
2457
|
+
|
|
2458
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '-')
|
|
2459
|
+
|
|
2460
|
+
const locations = [
|
|
2461
|
+
join(process.cwd(), '.claude', 'workflows'),
|
|
2462
|
+
join(process.env.HOME || '~', '.claude', 'workflows'),
|
|
2463
|
+
]
|
|
2464
|
+
|
|
2465
|
+
for (const loc of locations) {
|
|
2466
|
+
const scriptPath = join(loc, `${safeName}.js`)
|
|
2467
|
+
if (existsSync(scriptPath)) {
|
|
2468
|
+
return {
|
|
2469
|
+
content: '',
|
|
2470
|
+
forwardToAI:
|
|
2471
|
+
`Read the workflow script at ${scriptPath}, then call the Workflow tool with ` +
|
|
2472
|
+
`the file contents as the "script" parameter to execute it. Report the results.`,
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
return {
|
|
2478
|
+
content: `Workflow "${safeName}" not found in .claude/workflows/ or ~/.claude/workflows/`,
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
const workflowAutoCmd: CommandHandler = async (_ctx, args) => {
|
|
2483
|
+
const task = args.join(' ').trim()
|
|
2484
|
+
|
|
2485
|
+
if (!task) {
|
|
2486
|
+
return {
|
|
2487
|
+
content:
|
|
2488
|
+
'Usage: /workflow <task description>\n\n' +
|
|
2489
|
+
'Describes the task, and the AI will generate a workflow script to execute it.\n' +
|
|
2490
|
+
'Examples:\n' +
|
|
2491
|
+
' /workflow audit all routes for missing auth\n' +
|
|
2492
|
+
' /workflow research the impact of React 19 on our codebase\n' +
|
|
2493
|
+
' /workflow find all hardcoded credentials in the codebase\n\n' +
|
|
2494
|
+
'Sub-commands:\n' +
|
|
2495
|
+
' /workflow save <name> — save last successful workflow script\n' +
|
|
2496
|
+
' /workflow run <name> — run a saved workflow by name\n' +
|
|
2497
|
+
' /workflows — list all saved workflow scripts',
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// Sub-commands
|
|
2502
|
+
const firstArg = args[0] || ''
|
|
2503
|
+
if (firstArg === 'save' && args[1]) {
|
|
2504
|
+
return workflowSaveCmd(args.slice(1).join(' '))
|
|
2505
|
+
}
|
|
2506
|
+
if (firstArg === 'run' && args[1]) {
|
|
2507
|
+
return workflowRunCmd(args.slice(1).join(' '))
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// Default: forward to AI to generate + execute workflow
|
|
2511
|
+
return {
|
|
2512
|
+
content: '',
|
|
2513
|
+
forwardToAI:
|
|
2514
|
+
`Write and execute a workflow script for this task: ${task}\n\n` +
|
|
2515
|
+
`Use the Workflow tool to execute the generated script. ` +
|
|
2516
|
+
`After the workflow completes, summarize the results and offer to save the script ` +
|
|
2517
|
+
`with /workflow save <name> if it is reusable.`,
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2391
2521
|
// ═══════════════════════════════════════════════════════════════
|
|
2392
2522
|
// Permissions
|
|
2393
2523
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -3988,8 +4118,14 @@ const forkCmd: CommandHandler = async (ctx, args) => {
|
|
|
3988
4118
|
'general',
|
|
3989
4119
|
async (_signal) => {
|
|
3990
4120
|
const { SubAgent } = await import('../agent/sub-agent')
|
|
3991
|
-
const sa = new SubAgent(
|
|
3992
|
-
|
|
4121
|
+
const sa = new SubAgent(
|
|
4122
|
+
ctx.engine.getRegistry(),
|
|
4123
|
+
ctx.engine.getTools(),
|
|
4124
|
+
ctx.engine.getPermission(),
|
|
4125
|
+
)
|
|
4126
|
+
const result = await sa.execute(prompt, 'fork: ' + prompt.slice(0, 60), {
|
|
4127
|
+
worktreePath: wtPath,
|
|
4128
|
+
})
|
|
3993
4129
|
try {
|
|
3994
4130
|
const { execSync: ex } = await import('node:child_process')
|
|
3995
4131
|
ex(`git -C ${wtPath} add -A`, { stdio: 'ignore', timeout: 10_000 })
|
|
@@ -4318,6 +4454,7 @@ registry.set('/tasks', tasksCmd)
|
|
|
4318
4454
|
registry.set('/diff', diffCmd)
|
|
4319
4455
|
registry.set('/loop', loopCmd)
|
|
4320
4456
|
registry.set('/no-plan', noPlanCmd)
|
|
4457
|
+
registry.set('/workflow', workflowAutoCmd)
|
|
4321
4458
|
registry.set('/workflows', workflowsCmd)
|
|
4322
4459
|
registry.set('/review', reviewCmd)
|
|
4323
4460
|
registry.set('/pr-comments', prCommentsCmd)
|