@miphamai/cli 0.81.6 → 0.81.7

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.
Files changed (51) hide show
  1. package/README.md +9 -9
  2. package/bin/daemon.ts +7 -32
  3. package/bin/mipham.ts +43 -29
  4. package/package.json +5 -2
  5. package/skills/standard/mipham-code-setup.SKILL.md +3 -3
  6. package/src/agent/sub-agent.ts +12 -1
  7. package/src/commands/project.ts +92 -12
  8. package/src/config/keys-manager.ts +3 -3
  9. package/src/config/loader.ts +82 -1
  10. package/src/core/context.ts +10 -2
  11. package/src/core/engine.ts +19 -4
  12. package/src/core/metrics.ts +8 -0
  13. package/src/core/paths.ts +79 -0
  14. package/src/core/permission-rules.ts +121 -13
  15. package/src/core/permission.ts +3 -0
  16. package/src/core/session-log.ts +11 -2
  17. package/src/daemon/engine-capabilities.ts +131 -0
  18. package/src/daemon/index.ts +4 -1
  19. package/src/daemon/launch.ts +287 -0
  20. package/src/daemon/remote-engine.ts +2 -0
  21. package/src/daemon/server.ts +9 -0
  22. package/src/daemon/session-worker.ts +7 -4
  23. package/src/i18n-core/locales/en-US.json +6 -7
  24. package/src/i18n-core/locales/zh-CN.json +6 -7
  25. package/src/index.tsx +79 -0
  26. package/src/mcp/client.ts +4 -2
  27. package/src/providers/anthropic.ts +2 -0
  28. package/src/shared/package-info.ts +1 -1
  29. package/src/shared/types.ts +15 -0
  30. package/src/skills/bundled-skills.ts +1 -1
  31. package/src/telemetry/consent.ts +209 -0
  32. package/src/telemetry/crash.ts +197 -0
  33. package/src/telemetry/endpoint.ts +82 -0
  34. package/src/telemetry/index.ts +153 -0
  35. package/src/telemetry/payload.ts +141 -0
  36. package/src/telemetry/queue.ts +95 -0
  37. package/src/telemetry/redact.ts +127 -0
  38. package/src/telemetry/transport.ts +81 -0
  39. package/src/tools/agent/workflow.ts +11 -4
  40. package/src/tools/exec/bash.ts +6 -4
  41. package/src/tools/exec/enter-worktree.ts +6 -5
  42. package/src/tools/exec/exit-worktree.ts +10 -5
  43. package/src/tools/exec/git.ts +18 -8
  44. package/src/tools/system/config.ts +3 -3
  45. package/src/ui/app.tsx +40 -11
  46. package/src/ui/commands.ts +159 -34
  47. package/src/workflow/primitives/agent.ts +4 -2
  48. package/src/core/task-runner-tasks.json +0 -14
  49. package/src/core/task-runner.ts +0 -163
  50. package/src/skills/mipham/runtime.ts +0 -66
  51. package/src/skills/standard/runtime.ts +0 -62
@@ -1,10 +1,11 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
+ import { worktreeRoot } from '../../core/paths.ts'
2
3
 
3
4
  export const enterWorktreeTool: ToolDefinition = {
4
5
  name: 'EnterWorktree',
5
6
  description:
6
7
  'Create an isolated git worktree for parallel development. ' +
7
- 'Creates a new worktree at .claude/worktrees/<name> on its own branch. ' +
8
+ 'Creates a new worktree at .mipham/worktrees/<name> on its own branch. ' +
8
9
  'Use this when you need to work on a separate task without affecting the main workspace. ' +
9
10
  'Pair with ExitWorktree to clean up when done.',
10
11
  category: 'exec',
@@ -56,10 +57,10 @@ export const enterWorktreeTool: ToolDefinition = {
56
57
 
57
58
  const cwd = ctx.cwd
58
59
  const { resolve } = await import('node:path')
59
- const worktreePath = resolve(`${cwd}/.claude/worktrees/${name}`)
60
- const allowedPrefix = resolve(`${cwd}/.claude/worktrees/`)
60
+ const worktreePath = resolve(worktreeRoot(cwd), name)
61
+ const allowedPrefix = resolve(worktreeRoot(cwd))
61
62
 
62
- // Defense-in-depth: verify resolved path is within .claude/worktrees/
63
+ // Defense-in-depth: verify resolved path stays within the worktree root
63
64
  if (
64
65
  !worktreePath.startsWith(allowedPrefix + '/') &&
65
66
  worktreePath !== allowedPrefix.slice(0, -1)
@@ -67,7 +68,7 @@ export const enterWorktreeTool: ToolDefinition = {
67
68
  return {
68
69
  success: false,
69
70
  content: '',
70
- error: 'Worktree path must be within .claude/worktrees/.',
71
+ error: 'Worktree path must be within .mipham/worktrees/.',
71
72
  }
72
73
  }
73
74
 
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
+ import { worktreeRoots } from '../../core/paths.ts'
2
3
 
3
4
  export const exitWorktreeTool: ToolDefinition = {
4
5
  name: 'ExitWorktree',
@@ -13,7 +14,8 @@ export const exitWorktreeTool: ToolDefinition = {
13
14
  properties: {
14
15
  path: {
15
16
  type: 'string',
16
- description: 'Absolute path to the worktree to exit. Must be under .claude/worktrees/.',
17
+ description:
18
+ 'Absolute path to the worktree to exit. Must be under .mipham/worktrees/ (or the legacy .claude/worktrees/).',
17
19
  },
18
20
  action: {
19
21
  type: 'string',
@@ -34,18 +36,21 @@ export const exitWorktreeTool: ToolDefinition = {
34
36
  const action = params.action as string
35
37
  const discardChanges = params.discard_changes === true
36
38
 
37
- // Validate the path is under .claude/worktrees
39
+ // Validate the path is under a worktree root(新目录优先,兼容历史 .claude/)
38
40
  const cwd = ctx.cwd
39
41
  const { resolve } = await import('node:path')
40
42
  const resolvedPath = resolve(worktreePath)
41
- const allowedPrefix = resolve(`${cwd}/.claude/worktrees/`)
43
+ const roots = worktreeRoots(cwd).map((root) => resolve(root))
44
+ const inWorktree = roots.some(
45
+ (root) => resolvedPath === root || resolvedPath.startsWith(root + '/'),
46
+ )
42
47
 
43
- if (!resolvedPath.startsWith(allowedPrefix)) {
48
+ if (!inWorktree) {
44
49
  return {
45
50
  success: false,
46
51
  content: '',
47
52
  error:
48
- `Path "${worktreePath}" is not under .claude/worktrees/. ` +
53
+ `Path "${worktreePath}" is not under .mipham/worktrees/ or .claude/worktrees/. ` +
49
54
  `Only worktrees created by EnterWorktree can be managed here.`,
50
55
  }
51
56
  }
@@ -1,4 +1,5 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
+ import { findWorktreeMarker } from '../../core/paths.ts'
2
3
 
3
4
  // P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
4
5
  // fragile substring matching. Each pattern describes what it blocks.
@@ -52,14 +53,23 @@ export const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: strin
52
53
  * when operating in a worktree context.
53
54
  */
54
55
  function isOutsideWorktree(command: string, cwd: string): string | null {
55
- const WORKTREE_MARKER = '.claude/worktrees/'
56
- if (!cwd.includes(WORKTREE_MARKER)) return null
57
-
58
- // Extract the project root (everything before .claude/worktrees/)
59
- const worktreeRoot = cwd.substring(0, cwd.indexOf(WORKTREE_MARKER))
60
-
61
- // Detect git commands that reference the main checkout path
62
- const mainCheckoutPaths = [/\b--work-tree=([^\s]+)/g, /\b--git-dir=([^\s]+)/g, /\b-C\s+([^\s]+)/g]
56
+ // 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,隔离度只增不减。
57
+ const marker = findWorktreeMarker(cwd)
58
+ if (!marker) return null
59
+
60
+ // Extract the project root (everything before the worktree marker)
61
+ const worktreeRoot = marker.root
62
+
63
+ // Detect git commands that reference the main checkout path.
64
+ // NOTE: no `\b` before the leading `-` — `\b` needs a word/non-word
65
+ // transition and `-` is itself a non-word char, so `\b--work-tree=` never
66
+ // matched anything and this whole check was silently dead. `-C` needs an
67
+ // explicit boundary because without one it would match inside a path.
68
+ const mainCheckoutPaths = [
69
+ /--work-tree=([^\s]+)/g,
70
+ /--git-dir=([^\s]+)/g,
71
+ /(?:^|\s)-C\s+([^\s]+)/g,
72
+ ]
63
73
 
64
74
  for (const pathPattern of mainCheckoutPaths) {
65
75
  let match: RegExpExecArray | null
@@ -4,8 +4,8 @@ import { homedir } from 'node:os'
4
4
  import { parse as parseYaml, stringify } from 'yaml'
5
5
  import type { ToolDefinition } from '../../shared/index.ts'
6
6
 
7
- const MIPHAM_DIR = join(homedir(), '.mipham')
8
- const USER_CONFIG = join(MIPHAM_DIR, 'config.yml')
7
+ const MIPHAM_HOME = join(homedir(), '.mipham')
8
+ const USER_CONFIG = join(MIPHAM_HOME, 'config.yml')
9
9
 
10
10
  export const configTool: ToolDefinition = {
11
11
  name: 'Config',
@@ -26,7 +26,7 @@ export const configTool: ToolDefinition = {
26
26
  required: ['action'],
27
27
  },
28
28
  async execute(params, _ctx) {
29
- mkdirSync(MIPHAM_DIR, { recursive: true })
29
+ mkdirSync(MIPHAM_HOME, { recursive: true })
30
30
  const action = params.action as string
31
31
 
32
32
  let config: Record<string, unknown> = {}
package/src/ui/app.tsx CHANGED
@@ -14,6 +14,8 @@ import type { SkillsLoader } from '../skills/loader'
14
14
  import type { PluginManager } from '../plugin/plugin-manager'
15
15
  import { setPreference } from '../config/preferences'
16
16
  import { saveProviderApiKey } from '../config/loader'
17
+ import { recordCommand } from '../telemetry/index'
18
+ import { recordCrash } from '../telemetry/crash'
17
19
  import { AgentRegistry } from '../agent/agent-registry'
18
20
  import { getBackgroundAgentRegistry } from '../agent/background-registry'
19
21
  import { getMessageRouter, parseMention, resolveRecipientSession } from '../agent/message-router'
@@ -46,6 +48,7 @@ import type { AgentViewManager } from '../agent-view/agent-view-manager'
46
48
  import { WorkflowProgress } from './workflow-progress.js'
47
49
  import { GoalProgress } from './goal-progress.js'
48
50
  import {
51
+ commandLabelFor,
49
52
  getCommand,
50
53
  looksLikeSlashCommand,
51
54
  parseSlashCommand,
@@ -206,9 +209,13 @@ export function App({
206
209
  useEffect(() => {
207
210
  if (!gitBranch) return
208
211
  let cancelled = false
209
- resolveGitPr(gitBranch).then((pr) => {
210
- if (!cancelled) setGitPr(pr)
211
- })
212
+ resolveGitPr(gitBranch)
213
+ .then((pr) => {
214
+ if (!cancelled) setGitPr(pr)
215
+ })
216
+ .catch(() => {
217
+ /* best effort — the PR badge is decoration, never worth surfacing */
218
+ })
212
219
  return () => {
213
220
  cancelled = true
214
221
  }
@@ -217,11 +224,15 @@ export function App({
217
224
  // 启动后台查新版(非阻塞;离线静默失败)
218
225
  useEffect(() => {
219
226
  let cancelled = false
220
- checkForUpdatesAsync().then((update) => {
221
- if (!cancelled && update.available) {
222
- setUpdateStatus({ state: 'available', latest: update.latest })
223
- }
224
- })
227
+ checkForUpdatesAsync()
228
+ .then((update) => {
229
+ if (!cancelled && update.available) {
230
+ setUpdateStatus({ state: 'available', latest: update.latest })
231
+ }
232
+ })
233
+ .catch(() => {
234
+ /* offline — silent, per the comment above */
235
+ })
225
236
  return () => {
226
237
  cancelled = true
227
238
  }
@@ -750,7 +761,7 @@ export function App({
750
761
  }
751
762
 
752
763
  // Turn finished — drain any /loop wakeup queued while we were running.
753
- drainLoopQueueRef.current?.(turnId)
764
+ void drainLoopQueueRef.current?.(turnId)
754
765
  },
755
766
  [engine, syncBgAgents, config, t],
756
767
  )
@@ -794,7 +805,7 @@ export function App({
794
805
  useEffect(() => {
795
806
  if (wakeupTick === 0) return
796
807
  if (isLoading) return
797
- drainLoopQueueRef.current?.(turnIdRef.current)
808
+ void drainLoopQueueRef.current?.(turnIdRef.current)
798
809
  }, [wakeupTick, isLoading])
799
810
 
800
811
  // ── cron poller ──
@@ -850,6 +861,15 @@ export function App({
850
861
  if (looksLikeSlashCommand(input)) {
851
862
  const { command, args } = parseSlashCommand(input)
852
863
 
864
+ // Counted here rather than at the registry lookup below: /switch, /pick,
865
+ // /model-picker, /exit, /quit and /focus are special-cased and return
866
+ // before ever reaching it, so counting there would silently under-report
867
+ // six of the most-used commands.
868
+ //
869
+ // `commandLabelFor` collapses unrecognised names into `/unknown` — see it
870
+ // for why an unbounded `command_name` is not a cosmetic problem.
871
+ recordCommand(commandLabelFor(command))
872
+
853
873
  // /switch takes args, handled separately
854
874
  if (command === '/switch') {
855
875
  const result = await handleSwitch(mkCtx(), args)
@@ -1099,7 +1119,16 @@ export function App({
1099
1119
  }
1100
1120
 
1101
1121
  return (
1102
- <ErrorBoundary>
1122
+ <ErrorBoundary
1123
+ onError={(error) => {
1124
+ // The boundary's own job is *surviving* a render error — it renders a
1125
+ // fallback and the session continues. But this is the exact failure the
1126
+ // boundary was written for (a frozen layout with a live process, i.e. a
1127
+ // silent hang), so record it as a crash signal rather than letting it
1128
+ // vanish once the fallback paints over the evidence.
1129
+ recordCrash(error, 'render')
1130
+ }}
1131
+ >
1103
1132
  <Box flexDirection="column" padding={1} height="100%">
1104
1133
  {/* Workflow progress — auto-detects active workflows, renders nothing when idle */}
1105
1134
  <WorkflowProgress />
@@ -16,6 +16,7 @@ import { McpClient } from '../mcp/client'
16
16
  import { buildCapabilityReport } from '../core/capability-inventory'
17
17
  import { InstructionsLoader } from '../core/instructions'
18
18
  import { findDerivableSections, DERIVABLE_HINTS } from '../core/claude-md-audit'
19
+ import { worktreeRoot, workflowScriptDir, workflowScriptDirs } from '../core/paths.ts'
19
20
  import { fixDoctor, fixConfig, fixCache, selectRepoClaudeFiles } from '../core/fix'
20
21
  import { fixCodeTarget } from '../core/fix-code'
21
22
  import { homedir } from 'node:os'
@@ -56,6 +57,15 @@ import { NPM_UPDATE_COMMAND, PACKAGE_VERSION, COAUTHOR_TRAILER } from '../shared
56
57
  import { getPreference } from '../config/preferences'
57
58
  import { loadCrossSessionConfig, tryRestoreFromBackup } from '../config/loader'
58
59
  import { getMemoryManager } from '../core/memory/memory-loader'
60
+ import {
61
+ resolveTelemetry,
62
+ readTelemetrySettings,
63
+ setTelemetryEnabled,
64
+ setTelemetryEndpoint,
65
+ resetInstallId,
66
+ } from '../telemetry/consent'
67
+ import { getTelemetryConsent, enableTelemetryNow } from '../telemetry/index'
68
+ import { NO_ENDPOINT } from '../telemetry/endpoint'
59
69
  import { stripIndent } from './strip-indent.js'
60
70
  import { createT } from '../i18n-core/t'
61
71
  import type { TranslationMap } from '../i18n-core/types'
@@ -251,7 +261,7 @@ const helpCmd: CommandHandler = (ctx) => {
251
261
  /init Initialize .mipham config
252
262
  /setup Guided project setup wizard
253
263
  /recommend Analyze project + recommend setup
254
- /permissions Show permission settings
264
+ /permissions Show or persist permission rules
255
265
  /add-dir <dir> Add workspace directory
256
266
  /security Security review checklist
257
267
  /audit Same as /security
@@ -2076,8 +2086,8 @@ const todosCmd: CommandHandler = (_ctx, args) => {
2076
2086
  return { content: t('commands.todos.usage_create') }
2077
2087
  }
2078
2088
  return {
2079
- content: `${t('commands.todos.create_title')}\n\nCreating task: "${title.trim()}"\n\nPassing to AI for structured task creation with TaskCreate...`,
2080
- forwardToAI: `Create a new task using TaskCreate with subject "${title.trim()}". Set a clear description and activeForm.`,
2089
+ content: `${t('commands.todos.create_title')}\n\nCreating task: "${title.trim()}"\n\nPassing to AI for structured task creation with the Task tool...`,
2090
+ forwardToAI: `Create a new task using the Task tool with action "create" and subject "${title.trim()}". Set a clear description and activeForm.`,
2081
2091
  }
2082
2092
  }
2083
2093
 
@@ -2092,7 +2102,7 @@ const todosCmd: CommandHandler = (_ctx, args) => {
2092
2102
  ${t('commands.todos.item_create')}
2093
2103
  `,
2094
2104
  forwardToAI:
2095
- 'Use TaskList to show all current tasks. Present them in a clear summary grouped by status (pending/in_progress/completed). If there are no tasks, suggest creating one.',
2105
+ 'Use the Task tool with action "list" to show all current tasks. Present them in a clear summary grouped by status (pending/in_progress/completed). If there are no tasks, suggest creating one.',
2096
2106
  }
2097
2107
  }
2098
2108
 
@@ -2104,7 +2114,8 @@ const todosCmd: CommandHandler = (_ctx, args) => {
2104
2114
 
2105
2115
  ${t('commands.todos.default_body')}
2106
2116
  `,
2107
- forwardToAI: 'Use TaskList to show all current tasks, then present them clearly.',
2117
+ forwardToAI:
2118
+ 'Use the Task tool with action "list" to show all current tasks, then present them clearly.',
2108
2119
  }
2109
2120
  }
2110
2121
 
@@ -2192,7 +2203,7 @@ const goalCmd: CommandHandler = (ctx, args) => {
2192
2203
  if (decompose) {
2193
2204
  lines.push(t('commands.goal.decompose_enabled'))
2194
2205
  // Decompose by creating initial subtasks
2195
- 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.`
2206
+ const decomposeMsg = `Break down this goal into 3-5 subtasks: "${goal}". For each subtask, use the Task tool with action "create", giving the subject and description. Mark each as blocked by the previous one to create a dependency chain.`
2196
2207
  return {
2197
2208
  content: lines.join('\n'),
2198
2209
  forwardToAI: decomposeMsg,
@@ -2813,12 +2824,12 @@ const tasksCmd: CommandHandler = (ctx) => {
2813
2824
  const c = ctx.engine.getContext()
2814
2825
  const msgs = c.getMessages()
2815
2826
 
2816
- // Scan for task-related tool uses in message history
2827
+ // Scan for task-related tool uses in message history.
2828
+ // 任务工具只有一个 `Task`,动作走 action 参数 —— 过滤条件必须按真实工具名匹配,
2829
+ // 否则计数恒为 0,「已检测到 N 次任务操作」这条分支永远不可达。
2817
2830
  const toolUses = msgs.flatMap((m) => {
2818
2831
  if (Array.isArray(m.content)) {
2819
- return m.content.filter(
2820
- (b) => b.type === 'tool_use' && ['TaskCreate', 'TaskUpdate', 'TaskList'].includes(b.name),
2821
- )
2832
+ return m.content.filter((b) => b.type === 'tool_use' && b.name === 'Task')
2822
2833
  }
2823
2834
  return []
2824
2835
  })
@@ -2830,12 +2841,13 @@ const tasksCmd: CommandHandler = (ctx) => {
2830
2841
  ${toolUses.length > 0 ? t('commands.task_list.detected', { count: String(toolUses.length) }) : t('commands.task_list.no_tasks')}
2831
2842
 
2832
2843
  ${t('commands.task_list.reference')}
2833
- TaskCreate — create a new task
2834
- TaskList — list all tasks
2835
- TaskUpdate — update task status
2836
- TaskGet — get task details
2837
- TaskOutput — get background task output
2838
- TaskStop — stop a running task
2844
+ Task(action: "create") — create a new task
2845
+ Task(action: "list") — list all tasks
2846
+ Task(action: "update") — update task status
2847
+ Task(action: "get") — get task details
2848
+ Task(action: "delete") — delete a task
2849
+ Task(action: "output") — get background task output
2850
+ Task(action: "stop") — stop a running task
2839
2851
 
2840
2852
  ${t('commands.task_list.legacy_hint')}
2841
2853
  `,
@@ -3807,6 +3819,72 @@ const hooksCmd: CommandHandler = async (ctx) => {
3807
3819
  return { content: lines.join('\n') }
3808
3820
  }
3809
3821
 
3822
+ const telemetryCmd: CommandHandler = async (ctx, args) => {
3823
+ const sub = (args[0] ?? 'status').toLowerCase()
3824
+
3825
+ if (sub === 'on' || sub === 'off') {
3826
+ const enable = sub === 'on'
3827
+ setTelemetryEnabled(enable)
3828
+ if (enable) enableTelemetryNow()
3829
+ return {
3830
+ content: enable
3831
+ ? '✓ Telemetry enabled. Anonymous usage counts will be sent to the configured endpoint.'
3832
+ : '✓ Telemetry disabled. Nothing is collected and no queue file is written.',
3833
+ }
3834
+ }
3835
+
3836
+ if (sub === 'reset-id') {
3837
+ const id = resetInstallId()
3838
+ return { content: `✓ New anonymous install id: ${id}` }
3839
+ }
3840
+
3841
+ if (sub === 'endpoint') {
3842
+ const url = args[1]
3843
+ if (!url) return { content: 'Usage: /telemetry endpoint <url|none>' }
3844
+ setTelemetryEndpoint(url)
3845
+ return {
3846
+ content:
3847
+ url === NO_ENDPOINT
3848
+ ? '✓ Destination cleared. Telemetry stays on, but nothing is ever sent.'
3849
+ : `✓ Endpoint set to ${url}`,
3850
+ }
3851
+ }
3852
+
3853
+ if (sub !== 'status') {
3854
+ return {
3855
+ content: 'Usage: /telemetry [status|on|off|reset-id|endpoint <url|none>]',
3856
+ }
3857
+ }
3858
+
3859
+ // ── status ──
3860
+ const consent = getTelemetryConsent() ?? resolveTelemetry()
3861
+ const settings = readTelemetrySettings('user')
3862
+ // Two ways for `endpoint` to be empty, and they mean different things: never
3863
+ // resolved (the kill switch fired first) or deliberately cleared (the `none`
3864
+ // sentinel). A third — nothing configured — is unreachable now that the
3865
+ // default is a real URL, which is why this used to be a one-line fallback.
3866
+ const destination = consent.endpoint
3867
+ ? consent.endpoint
3868
+ : consent.endpointSource === 'off'
3869
+ ? '_(not resolved — telemetry is off)_'
3870
+ : '_(nothing — the `none` sentinel cleared it)_'
3871
+ const lines = [
3872
+ '## 📡 Telemetry',
3873
+ '',
3874
+ `| Field | Value |`,
3875
+ `| --- | --- |`,
3876
+ `| State | ${consent.enabled ? '🟢 enabled' : '⚪ disabled'} |`,
3877
+ `| Decided by | \`${consent.source}\` |`,
3878
+ `| Endpoint | ${destination} |`,
3879
+ `| Source | \`${consent.endpointSource}\` |`,
3880
+ `| Install id | ${settings.installId ?? '_(not yet generated)_'} |`,
3881
+ `| Prompted | ${settings.promptedAt ?? '_(never)_'} |`,
3882
+ '',
3883
+ 'Full data dictionary: `docs/telemetry.md`',
3884
+ ]
3885
+ return { content: lines.join('\n') }
3886
+ }
3887
+
3810
3888
  const hooksHealthCmd: CommandHandler = (ctx) => {
3811
3889
  const hookEngine = ctx.engine.getHookEngine?.()
3812
3890
  if (!hookEngine) {
@@ -4319,10 +4397,7 @@ const workflowsCmd: CommandHandler = async () => {
4319
4397
  const { existsSync, readdirSync, readFileSync } = await import('node:fs')
4320
4398
  const { join } = await import('node:path')
4321
4399
 
4322
- const locations = [
4323
- join(process.cwd(), '.claude', 'workflows'),
4324
- join(homedir(), '.claude', 'workflows'),
4325
- ]
4400
+ const locations = workflowScriptDirs(process.cwd())
4326
4401
 
4327
4402
  const lines: string[] = ['─ Workflows ─', '']
4328
4403
  let found = 0
@@ -4360,10 +4435,11 @@ const workflowsCmd: CommandHandler = async () => {
4360
4435
  lines.push('No workflow scripts found.')
4361
4436
  lines.push('')
4362
4437
  lines.push('Workflows are multi-agent orchestration scripts stored in:')
4363
- lines.push(' .claude/workflows/ (project-level)')
4364
- lines.push(' ~/.claude/workflows/ (user-level)')
4438
+ lines.push(' .mipham/workflows/ (project-level — new scripts go here)')
4439
+ lines.push(' .claude/workflows/ (project-level, legacy — still read)')
4440
+ lines.push(' ~/.claude/workflows/ (user-level, legacy — still read)')
4365
4441
  lines.push('')
4366
- lines.push('Create a .js file in either location to add a workflow.')
4442
+ lines.push('Create a .js file in either project location to add a workflow.')
4367
4443
  } else {
4368
4444
  lines.push('')
4369
4445
  lines.push(`${found} workflow(s) found.`)
@@ -4381,14 +4457,20 @@ const workflowSaveCmd = async (name: string): Promise<CommandResult> => {
4381
4457
  const { existsSync, mkdirSync, writeFileSync, readFileSync } = await import('node:fs')
4382
4458
  const { join } = await import('node:path')
4383
4459
 
4384
- const targetDir = join(process.cwd(), '.claude', 'workflows')
4460
+ const targetDir = workflowScriptDir(process.cwd())
4385
4461
  if (!existsSync(targetDir)) {
4386
4462
  mkdirSync(targetDir, { recursive: true })
4387
4463
  }
4388
4464
 
4389
- // Read the last-run state persisted by the Workflow tool
4390
- const stateFile = join(targetDir, '.last-run.json')
4391
- if (!existsSync(stateFile)) {
4465
+ // Read the last-run state persisted by the Workflow tool. Every readable
4466
+ // location is checked, not just the writable one: a run that predates the
4467
+ // move to `.mipham/` left its state in `.claude/workflows/`, so looking in
4468
+ // the new directory alone would report "no recent run" on the first save
4469
+ // after upgrading.
4470
+ const stateFile = workflowScriptDirs(process.cwd())
4471
+ .map((dir) => join(dir, '.last-run.json'))
4472
+ .find((file) => existsSync(file))
4473
+ if (!stateFile) {
4392
4474
  return { content: 'No recent workflow run found. Run a workflow first with /workflow <task>.' }
4393
4475
  }
4394
4476
 
@@ -4417,10 +4499,7 @@ const workflowRunCmd = async (name: string): Promise<CommandResult> => {
4417
4499
 
4418
4500
  const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '-')
4419
4501
 
4420
- const locations = [
4421
- join(process.cwd(), '.claude', 'workflows'),
4422
- join(homedir(), '.claude', 'workflows'),
4423
- ]
4502
+ const locations = workflowScriptDirs(process.cwd())
4424
4503
 
4425
4504
  for (const loc of locations) {
4426
4505
  const scriptPath = join(loc, `${safeName}.js`)
@@ -4435,7 +4514,9 @@ const workflowRunCmd = async (name: string): Promise<CommandResult> => {
4435
4514
  }
4436
4515
 
4437
4516
  return {
4438
- content: `Workflow "${safeName}" not found in .claude/workflows/ or ~/.claude/workflows/`,
4517
+ content:
4518
+ `Workflow "${safeName}" not found in .mipham/workflows/, ` +
4519
+ `.claude/workflows/ or ~/.claude/workflows/`,
4439
4520
  }
4440
4521
  }
4441
4522
 
@@ -5003,7 +5084,7 @@ const forkCmd: CommandHandler = async (ctx, args) => {
5003
5084
  .slice(0, 40)
5004
5085
  const name = `${slug}-${Date.now().toString(36)}`
5005
5086
  const branch = `worktree/${name}`
5006
- const wtPath = join(process.cwd(), '.claude', 'worktrees', name)
5087
+ const wtPath = join(worktreeRoot(process.cwd()), name)
5007
5088
 
5008
5089
  try {
5009
5090
  execSync(`git worktree add -b ${branch} ${wtPath} HEAD`, { stdio: 'ignore', timeout: 30_000 })
@@ -5304,6 +5385,7 @@ const commandsListCmd: CommandHandler = () => {
5304
5385
  '/keys audit': 'Account',
5305
5386
  '/keys view': 'Account',
5306
5387
  '/feedback': 'Account',
5388
+ '/telemetry': 'Account',
5307
5389
  '/agents': 'Agents',
5308
5390
  '/bg': 'Agents',
5309
5391
  '/fork': 'Agents',
@@ -5525,6 +5607,7 @@ registry.set('/cd', cdCmd)
5525
5607
  registry.set('/hooks', hooksCmd)
5526
5608
  registry.set('/hooks health', hooksHealthCmd)
5527
5609
  registry.set('/hooks enable', hooksEnableCmd)
5610
+ registry.set('/telemetry', telemetryCmd)
5528
5611
  registry.set('/batch', batchCmd)
5529
5612
 
5530
5613
  // ═══════════════════════════════════════════════════════════════
@@ -5539,6 +5622,47 @@ export function getCommandNames(): string[] {
5539
5622
  return Array.from(registry.keys()).sort()
5540
5623
  }
5541
5624
 
5625
+ /**
5626
+ * The commands `app.tsx` intercepts **before** the registry lookup.
5627
+ *
5628
+ * They return early and never reach `getCommand`, so the registry is not
5629
+ * authoritative for them. `/model-picker` is the one that is not a registry key
5630
+ * at all — the other five are, so listing them here is belt-and-braces rather
5631
+ * than a claim that they are missing.
5632
+ */
5633
+ const PRE_REGISTRY_COMMANDS = ['/switch', '/pick', '/model-picker', '/exit', '/quit', '/focus']
5634
+
5635
+ /**
5636
+ * The bucket an unrecognised command name is recorded under.
5637
+ *
5638
+ * **Why this exists.** `parseSlashCommand` returns `parts[0]` — whatever the user
5639
+ * typed. Without a convergence point, `/foobar` mints a `command_calls./foobar`
5640
+ * series on the spot, and nothing bounds the number of series:
5641
+ * `MAX_LABEL_LENGTH` in `payload.ts` truncates the **value**, not the key count.
5642
+ * `tool_name` really is closed by construction (the registry declares the tool
5643
+ * set); `command_name` is not.
5644
+ */
5645
+ export const UNKNOWN_COMMAND = '/unknown'
5646
+
5647
+ /** The label a command name is recorded under: itself if we ship it, else the bucket. */
5648
+ export function commandLabelFor(command: string): string {
5649
+ if (registry.has(command) || PRE_REGISTRY_COMMANDS.includes(command)) return command
5650
+ return UNKNOWN_COMMAND
5651
+ }
5652
+
5653
+ /**
5654
+ * Every label `command_calls` can carry — the source the collector's allowlist is
5655
+ * generated from.
5656
+ *
5657
+ * Two more than `getCommandNames()`: `/model-picker` (user-typable, not a registry
5658
+ * key) and `UNKNOWN_COMMAND`. Miss either and the allowlist does not contain it,
5659
+ * so the collector folds those events into `__other__` — and `__other__` is
5660
+ * exactly what T4 must not be reading when it votes.
5661
+ */
5662
+ export function getCommandLabelNames(): string[] {
5663
+ return Array.from(new Set([...registry.keys(), ...PRE_REGISTRY_COMMANDS, UNKNOWN_COMMAND])).sort()
5664
+ }
5665
+
5542
5666
  export interface CommandEntry {
5543
5667
  name: string
5544
5668
  description: string
@@ -5640,7 +5764,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
5640
5764
  '/loop': 'Run prompt on interval',
5641
5765
  '/init': 'Initialize .mipham config',
5642
5766
  '/setup': 'Guided project setup wizard',
5643
- '/permissions': 'Show permission settings',
5767
+ '/permissions': 'Show or persist permission rules',
5644
5768
  '/add-dir': 'Add workspace directory',
5645
5769
  '/recommend': 'Analyze project + recommend skills & setup',
5646
5770
  '/security': 'Security review checklist',
@@ -5681,6 +5805,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
5681
5805
  '/hooks health': 'Check hook health — see failures, disabled hooks, recovery status',
5682
5806
  '/hooks enable': 'Manually re-enable a hook that was auto-disabled after repeated failures',
5683
5807
  '/batch': 'Apply changes across multiple files',
5808
+ '/telemetry': 'Show or change anonymous usage-reporting settings',
5684
5809
  }
5685
5810
 
5686
5811
  export function getCommandList(): CommandEntry[] {
@@ -1,8 +1,10 @@
1
+ import { join } from 'node:path'
1
2
  import { SubAgent } from '../../agent/sub-agent'
2
3
  import type { ProviderRegistry } from '../../providers/registry'
3
4
  import type { Llm } from '../../providers/llm'
4
5
  import type { ToolDefinition } from '../../shared/index.ts'
5
6
  import type { PermissionSystem } from '../../core/permission'
7
+ import { worktreeRoot } from '../../core/paths.ts'
6
8
  import { validateJSONSchema, formatValidationErrors } from '../schema-validator'
7
9
 
8
10
  export interface WorkflowAgentOpts {
@@ -32,7 +34,7 @@ export interface WorkflowAgentOpts {
32
34
  * 4. Returns the validated object, or { raw, validationErrors } on final failure.
33
35
  *
34
36
  * When `isolation: 'worktree'` is set:
35
- * 1. A git worktree is created at .claude/worktrees/wf-<slug>
37
+ * 1. A git worktree is created at .mipham/worktrees/wf-<slug>
36
38
  * 2. The sub-agent runs with its cwd set to the worktree path
37
39
  * 3. Changes are auto-committed (best-effort)
38
40
  * 4. The worktree is cleaned up after execution
@@ -60,7 +62,7 @@ export async function workflowAgent(
60
62
  if (opts.isolation === 'worktree') {
61
63
  const slug = `wf-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
62
64
  worktreeBranch = `worktree/${slug}`
63
- worktreePath = `.claude/worktrees/${slug}`
65
+ worktreePath = join(worktreeRoot(process.cwd()), slug)
64
66
 
65
67
  const proc = Bun.spawn(['git', 'worktree', 'add', '-b', worktreeBranch, worktreePath, 'HEAD'], {
66
68
  stdout: 'pipe',
@@ -1,14 +0,0 @@
1
- {
2
- "version": 1,
3
- "tasks": [
4
- {
5
- "id": "task-answer-fn",
6
- "instruction": "用 Write 工具在 <taskDir>/solution.ts 写入一个 TypeScript 文件,导出 `export function answer(): number { return 42 }`。",
7
- "groundTruth": {
8
- "kind": "file-contains",
9
- "file": "solution.ts",
10
- "contains": ["export function answer", "42"]
11
- }
12
- }
13
- ]
14
- }