@miphamai/cli 0.81.8 → 0.82.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.
Files changed (54) hide show
  1. package/bin/mipham.ts +35 -1
  2. package/package.json +1 -1
  3. package/src/agent/message-bus.ts +10 -3
  4. package/src/agent/sub-agent.ts +60 -12
  5. package/src/agent/types.ts +14 -1
  6. package/src/config/credential-crypto.ts +28 -5
  7. package/src/config/defaults.ts +18 -10
  8. package/src/config/keys-manager.ts +7 -1
  9. package/src/config/loader.ts +202 -63
  10. package/src/core/credential-masker/output-scrub.ts +16 -2
  11. package/src/core/crsi-modify.ts +34 -1
  12. package/src/core/crsi-producer.ts +77 -7
  13. package/src/core/crsi-sandbox.ts +65 -0
  14. package/src/core/engine.ts +7 -2
  15. package/src/core/eval-harness.ts +77 -4
  16. package/src/core/hooks-executor.ts +30 -2
  17. package/src/core/hooks.ts +51 -4
  18. package/src/core/improvement-track.ts +41 -0
  19. package/src/core/paths.ts +44 -1
  20. package/src/core/permission-config.ts +146 -14
  21. package/src/core/permission-rules.ts +17 -2
  22. package/src/core/permission.ts +81 -13
  23. package/src/core/rules-loader.ts +35 -5
  24. package/src/core/session-log.ts +5 -1
  25. package/src/core/workspace-trust.ts +42 -4
  26. package/src/daemon/auth.ts +15 -14
  27. package/src/daemon/engine-capabilities.ts +12 -2
  28. package/src/daemon/remote-engine.ts +9 -4
  29. package/src/daemon/server.ts +29 -1
  30. package/src/i18n-core/locales/en-US.json +12 -8
  31. package/src/i18n-core/locales/zh-CN.json +12 -8
  32. package/src/index.tsx +44 -17
  33. package/src/mcp/client.ts +24 -0
  34. package/src/mcp/http-transport.ts +35 -3
  35. package/src/plugin/plugin-manager.ts +13 -2
  36. package/src/providers/anthropic.ts +48 -11
  37. package/src/security/gate.ts +18 -0
  38. package/src/security/path.ts +19 -1
  39. package/src/shared/arg-validation.ts +37 -2
  40. package/src/shared/package-info.ts +1 -1
  41. package/src/shared/sanitize.ts +27 -2
  42. package/src/shared/types.ts +8 -0
  43. package/src/shared/update.ts +22 -5
  44. package/src/tools/agent/agent.ts +3 -0
  45. package/src/tools/exec/bash.ts +106 -6
  46. package/src/tools/exec/enter-worktree.ts +9 -3
  47. package/src/tools/exec/exit-worktree.ts +6 -3
  48. package/src/tools/exec/git.ts +76 -1
  49. package/src/tools/file/glob.ts +19 -3
  50. package/src/tools/file/grep.ts +33 -3
  51. package/src/tools/index.ts +12 -4
  52. package/src/ui/app.tsx +47 -11
  53. package/src/ui/commands.ts +205 -30
  54. package/src/workflow/primitives/agent.ts +4 -0
@@ -65,10 +65,21 @@ function mergeProviders(
65
65
  const merged = [...baseProviders]
66
66
 
67
67
  for (const op of overrideProviders) {
68
+ // This array comes straight out of hand-written YAML: an entry can be
69
+ // `null`, or a bare scalar (iterating `providers: nope` yields its
70
+ // characters). Neither is a provider config, and neither should reach the
71
+ // field reads below.
72
+ if (!op || typeof op !== 'object') continue
73
+
74
+ // A non-string `apiKey` (a number, a list, a nested map) is not a secret.
75
+ // It reads as "no key" — the same invariant getProviderApiKey enforces —
76
+ // rather than reaching a `.startsWith` and taking the whole CLI down.
77
+ const apiKey = typeof op.apiKey === 'string' ? op.apiKey : undefined
78
+
68
79
  const idx = merged.findIndex((bp) => bp.id === op.id)
69
80
  if (idx === -1) {
70
81
  // Provider not in defaults — add it wholesale (custom provider)
71
- merged.push(op)
82
+ merged.push(apiKey === undefined ? op : { ...op, apiKey })
72
83
  continue
73
84
  }
74
85
 
@@ -82,7 +93,7 @@ function mergeProviders(
82
93
  // sent, so only trusted (user-level) config may override it. Untrusted
83
94
  // (project-level) config cannot redirect a built-in provider's traffic.
84
95
  baseUrl: allowBaseUrlOverride ? (op.baseUrl ?? base.baseUrl) : base.baseUrl,
85
- apiKey: op.apiKey ?? base.apiKey,
96
+ apiKey: apiKey ?? base.apiKey,
86
97
  models: op.models?.length ? op.models : base.models,
87
98
  status: op.status ?? base.status,
88
99
  }
@@ -91,16 +102,44 @@ function mergeProviders(
91
102
  return merged
92
103
  }
93
104
 
105
+ /**
106
+ * Merge `override` into `base`, recursing into plain objects so that a source
107
+ * setting one branch of an object does not drop the other source's siblings
108
+ * (`features.mcp` must not wipe `features.context`).
109
+ *
110
+ * Arrays and scalars replace: a project's `skills.paths` must not append to the
111
+ * user's list. Objects are rebuilt with spread rather than assignment — a parsed
112
+ * `__proto__` key is then copied as an ordinary own property instead of
113
+ * mutating the result's prototype.
114
+ */
115
+ function mergeObjects<T extends Record<string, unknown>>(base: T, override: T): T {
116
+ let merged: Record<string, unknown> = { ...base }
117
+ for (const [key, value] of Object.entries(override)) {
118
+ const existing = (base as Record<string, unknown>)[key]
119
+ merged = {
120
+ ...merged,
121
+ [key]:
122
+ isPlainObject(value) && isPlainObject(existing) ? mergeObjects(existing, value) : value,
123
+ }
124
+ }
125
+ return merged as T
126
+ }
127
+
128
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
129
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
130
+ }
131
+
94
132
  function mergeConfig(
95
133
  base: MiphamConfig,
96
134
  override: Partial<MiphamConfig>,
97
135
  allowBaseUrlOverride: boolean,
98
136
  ): MiphamConfig {
99
- const merged: MiphamConfig = { ...base, ...override }
137
+ const merged = mergeObjects(
138
+ base as unknown as Record<string, unknown>,
139
+ override as Record<string, unknown>,
140
+ ) as unknown as MiphamConfig
100
141
  if (override.providers) {
101
142
  merged.providers = mergeProviders(base.providers, override.providers, allowBaseUrlOverride)
102
- } else {
103
- merged.providers = base.providers
104
143
  }
105
144
  return merged
106
145
  }
@@ -182,31 +221,19 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
182
221
  try {
183
222
  if (!existsSync(path)) continue
184
223
  const raw = readFileSync(path, 'utf-8')
224
+ // Every McpServerConfig field is optional here (the name comes from the
225
+ // key), so a field added to the type is accepted without touching this.
185
226
  const parsed = JSON.parse(raw) as {
186
- mcpServers?: Record<
187
- string,
188
- {
189
- command?: string
190
- args?: string[]
191
- url?: string
192
- headers?: Record<string, string>
193
- env?: Record<string, string>
194
- }
195
- >
227
+ mcpServers?: Record<string, Partial<McpServerConfig>>
196
228
  }
197
229
 
198
230
  if (parsed.mcpServers) {
199
231
  for (const [name, cfg] of Object.entries(parsed.mcpServers)) {
200
232
  // Avoid duplicates by name
201
233
  if (servers.some((s) => s.name === name)) continue
202
- servers.push({
203
- name,
204
- command: cfg.command,
205
- args: cfg.args || [],
206
- url: cfg.url,
207
- headers: cfg.headers,
208
- env: cfg.env,
209
- })
234
+ // Spread the whole entry rather than re-listing fields: hand-rebuilding
235
+ // the object silently dropped request_timeout_ms and auth.
236
+ servers.push({ ...cfg, name, args: cfg.args || [] })
210
237
  }
211
238
  }
212
239
  } catch {
@@ -224,20 +251,57 @@ function loadMcpJson(cwd: string): McpServerConfig[] {
224
251
  export interface SettingsJson {
225
252
  hooks: SettingsHooks
226
253
  permissions: { allow: string[]; deny: string[] }
254
+ /**
255
+ * Present (and `true`) only when the project-level file really did declare
256
+ * hooks and they were withheld because the caller did not vouch for the
257
+ * workspace. Absent in every other case — including "the file has no hooks",
258
+ * so a caller announcing the skip cannot announce one that never happened.
259
+ */
260
+ projectHooksSkipped?: true
261
+ /**
262
+ * The subset of `hooks` that came from the project-level file — the entries
263
+ * the workspace-trust gate governs. Absent unless the caller vouched for the
264
+ * workspace *and* that file really declared hooks.
265
+ *
266
+ * Needed because `hooks` is provenance-free once merged: project and user
267
+ * entries sit in one bucket, yet only one of the two is gated. A caller that
268
+ * lists the merged form therefore cannot say which entries will run.
269
+ */
270
+ projectHooks?: SettingsHooks
227
271
  }
228
272
 
229
273
  /**
230
274
  * Load `settings.json` — project-level `.mipham/settings.json` then user-level
231
275
  * `~/.mipham/settings.json`. Mirrors the Claude Code convention (hooks additive,
232
276
  * permissions merged), so users can migrate their Claude settings unchanged.
277
+ *
278
+ * `includeProjectHooks` defaults to **false**, because the project-level file is
279
+ * repository-controlled and its `hooks` are shell commands this process will
280
+ * spawn — reading them is an act of trust, not a default. Callers that have
281
+ * established trust (or that only *display* the configured list) opt in
282
+ * explicitly. The flag gates hooks only: `permissions` still merge from both
283
+ * levels, since that question is answered by the mode ceiling, not by trust.
233
284
  */
234
- export function loadSettingsJson(cwd: string = process.cwd()): SettingsJson {
285
+ export function loadSettingsJson(
286
+ cwd: string = process.cwd(),
287
+ options: { includeProjectHooks?: boolean } = {},
288
+ ): SettingsJson {
235
289
  const hooks: SettingsHooks = {}
236
290
  const permissions = { allow: [] as string[], deny: [] as string[] }
291
+ let projectHooksSkipped = false
292
+ // The project file's entries, kept out of the merge so provenance survives it.
293
+ const projectHooks: SettingsHooks = {}
294
+
295
+ const searchPaths: Array<{ path: string; readHooks: boolean; isProject?: boolean }> = [
296
+ {
297
+ path: join(cwd, '.mipham', 'settings.json'),
298
+ readHooks: options.includeProjectHooks ?? false,
299
+ isProject: true,
300
+ },
301
+ { path: join(MIPHAM_HOME, 'settings.json'), readHooks: true },
302
+ ]
237
303
 
238
- const searchPaths = [join(cwd, '.mipham', 'settings.json'), join(MIPHAM_HOME, 'settings.json')]
239
-
240
- for (const path of searchPaths) {
304
+ for (const { path, readHooks, isProject } of searchPaths) {
241
305
  try {
242
306
  if (!existsSync(path)) continue
243
307
  const raw = readFileSync(path, 'utf-8')
@@ -246,11 +310,27 @@ export function loadSettingsJson(cwd: string = process.cwd()): SettingsJson {
246
310
  permissions?: { allow?: unknown; deny?: unknown }
247
311
  }
248
312
 
249
- if (parsed.hooks && typeof parsed.hooks === 'object') {
313
+ if (!readHooks) {
314
+ // "Was anything actually withheld?" is answered from the same parse that
315
+ // would have read it, so the skip notice cannot outrun the fact.
316
+ const declared = Object.values(parsed.hooks ?? {}).some(
317
+ (entries) => Array.isArray(entries) && entries.length > 0,
318
+ )
319
+ if (declared) projectHooksSkipped = true
320
+ }
321
+
322
+ if (readHooks && parsed.hooks && typeof parsed.hooks === 'object') {
250
323
  for (const [eventName, entries] of Object.entries(parsed.hooks)) {
251
324
  if (!Array.isArray(entries)) continue
252
325
  const bucket = (hooks as Record<string, unknown[]>)[eventName]
253
326
  ;(hooks as Record<string, unknown[]>)[eventName] = [...(bucket ?? []), ...entries]
327
+ if (isProject) {
328
+ const pBucket = (projectHooks as Record<string, unknown[]>)[eventName]
329
+ ;(projectHooks as Record<string, unknown[]>)[eventName] = [
330
+ ...(pBucket ?? []),
331
+ ...entries,
332
+ ]
333
+ }
254
334
  }
255
335
  }
256
336
 
@@ -268,7 +348,16 @@ export function loadSettingsJson(cwd: string = process.cwd()): SettingsJson {
268
348
  }
269
349
  }
270
350
 
271
- return { hooks, permissions }
351
+ // Keys added only when they have something to say: `toEqual` distinguishes
352
+ // `false` from absent, and "no project hooks" must stay indistinguishable from
353
+ // "nothing to report" — an empty marker would let a caller tag user hooks as
354
+ // gated on the strength of a file with nothing in it.
355
+ const result: SettingsJson = { hooks, permissions }
356
+ if (projectHooksSkipped) result.projectHooksSkipped = true
357
+ if (Object.values(projectHooks).some((entries) => Array.isArray(entries) && entries.length > 0)) {
358
+ result.projectHooks = projectHooks
359
+ }
360
+ return result
272
361
  }
273
362
 
274
363
  /** Which settings.json a permission rule is persisted to. */
@@ -478,44 +567,90 @@ export function loadInferenceHookConfig(): InferenceHookConfig {
478
567
  }
479
568
 
480
569
  /**
481
- * Load credential masking configuration from the same config sources.
482
- * Merges project-level over user-level. Returns defaults if no section present.
570
+ * Overlay one config file's `credential_masking` section onto `merged`.
571
+ *
572
+ * A malformed file leaves `merged` untouched — so a config that cannot be read
573
+ * costs you the *overrides*, never the masking itself (the defaults are on).
574
+ *
575
+ * `allowLoosening` is the same split as `baseUrl` in `mergeProviders`: the
576
+ * user's own `~/.mipham/config.yml` is trusted and may set anything, while a
577
+ * **project** file is whatever the repo you cloned shipped. Project level may
578
+ * only tighten — switch masking on and add rules, never switch it off or drop a
579
+ * rule. Otherwise one line in a cloned repo silently takes away a control the
580
+ * user already had (`enabled: false`), or `files: [...]` replaces the defaults
581
+ * wholesale and the SSH private-key rule stops being masked.
483
582
  */
484
- export function loadCredentialMaskingConfig(cwd: string = process.cwd()): CredentialMaskingConfig {
485
- const configPath = join(cwd, '.mipham', 'config.yml')
486
- const userConfigPath = join(MIPHAM_HOME, 'config.yml')
487
-
488
- let merged = { ...DEFAULT_CREDENTIAL_MASKING_CONFIG }
489
-
490
- const paths = [userConfigPath, configPath] // project wins (loaded last)
491
- for (const path of paths) {
492
- try {
493
- if (!existsSync(path)) continue
494
- const raw = readFileSync(path, 'utf-8')
495
- const parsed = parseYaml(raw) as Record<string, unknown>
496
- const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
497
- if (section) {
498
- merged = {
499
- enabled: section.enabled ?? merged.enabled,
500
- files: section.files ?? merged.files,
501
- output_scrubbing: {
502
- enabled: section.output_scrubbing?.enabled ?? merged.output_scrubbing.enabled,
503
- patterns: section.output_scrubbing?.patterns ?? merged.output_scrubbing.patterns,
504
- },
505
- env_filter: {
506
- enabled: section.env_filter?.enabled ?? merged.env_filter.enabled,
507
- patterns: section.env_filter?.patterns ?? merged.env_filter.patterns,
508
- },
509
- }
510
- }
511
- } catch {
512
- // Silently skip malformed configs
583
+ function mergeCredentialMaskingFile(
584
+ merged: CredentialMaskingConfig,
585
+ path: string,
586
+ allowLoosening: boolean,
587
+ ): CredentialMaskingConfig {
588
+ try {
589
+ if (!existsSync(path)) return merged
590
+ const raw = readFileSync(path, 'utf-8')
591
+ const parsed = parseYaml(raw) as Record<string, unknown>
592
+ const section = parsed.credential_masking as Partial<CredentialMaskingConfig> | undefined
593
+ if (!section) return merged
594
+
595
+ // Tightening can only ever turn a switch on, never off.
596
+ const flag = (next: boolean | undefined, cur: boolean): boolean =>
597
+ allowLoosening ? (next ?? cur) : cur || (next ?? false)
598
+
599
+ // Tightening adds rules instead of replacing them. Earlier rules stay in
600
+ // front because `matchCredentialFile` returns the **first** match — so a
601
+ // project cannot weaken a path's mode by adding a second rule for it.
602
+ const rules = <T>(next: T[] | undefined, cur: T[]): T[] =>
603
+ allowLoosening ? (next ?? cur) : [...cur, ...(next ?? [])]
604
+
605
+ return {
606
+ enabled: flag(section.enabled, merged.enabled),
607
+ files: rules(section.files, merged.files),
608
+ output_scrubbing: {
609
+ enabled: flag(section.output_scrubbing?.enabled, merged.output_scrubbing.enabled),
610
+ patterns: rules(section.output_scrubbing?.patterns, merged.output_scrubbing.patterns),
611
+ },
612
+ env_filter: {
613
+ enabled: flag(section.env_filter?.enabled, merged.env_filter.enabled),
614
+ patterns: rules(section.env_filter?.patterns, merged.env_filter.patterns),
615
+ },
513
616
  }
617
+ } catch {
618
+ // Silently skip malformed configs
619
+ return merged
514
620
  }
621
+ }
515
622
 
623
+ /**
624
+ * Load credential masking configuration from the same config sources.
625
+ * Returns defaults if no section present.
626
+ *
627
+ * User level first (trusted, may loosen), then project level (tighten only).
628
+ */
629
+ export function loadCredentialMaskingConfig(cwd: string = process.cwd()): CredentialMaskingConfig {
630
+ let merged = { ...DEFAULT_CREDENTIAL_MASKING_CONFIG }
631
+ merged = mergeCredentialMaskingFile(merged, join(MIPHAM_HOME, 'config.yml'), true)
632
+ merged = mergeCredentialMaskingFile(merged, join(cwd, '.mipham', 'config.yml'), false)
516
633
  return merged
517
634
  }
518
635
 
636
+ /**
637
+ * User-level masking policy only (`~/.mipham/config.yml`), without any project
638
+ * section.
639
+ *
640
+ * The tool registry is one instance per daemon process, while sessions carry
641
+ * different cwds (`createToolRegistry()` in `daemon/server.ts` is memoized) —
642
+ * so a project's section cannot be applied to it without leaking one project's
643
+ * policy into another session. User level is the only scope that is true for
644
+ * every session the registry serves.
645
+ */
646
+ export function loadUserCredentialMaskingConfig(): CredentialMaskingConfig {
647
+ return mergeCredentialMaskingFile(
648
+ { ...DEFAULT_CREDENTIAL_MASKING_CONFIG },
649
+ join(MIPHAM_HOME, 'config.yml'),
650
+ true,
651
+ )
652
+ }
653
+
519
654
  /**
520
655
  * Load background agent configuration from config sources.
521
656
  */
@@ -588,11 +723,15 @@ export function loadCrossSessionConfig(cwd: string = process.cwd()): CrossSessio
588
723
  */
589
724
  function decryptProviderApiKeys(providers: ProviderConfig[] | undefined): void {
590
725
  if (!providers) return
591
- const needsKey = providers.some((p) => p.apiKey.startsWith(ENC_PREFIX))
592
- if (!needsKey) return
726
+ // Same invariant as getProviderApiKey: only a string can carry the `enc:v1:`
727
+ // prefix, so a malformed entry counts as "no key" instead of throwing on
728
+ // `.startsWith` and taking the whole CLI down at startup.
729
+ const isEncrypted = (p: ProviderConfig | null): boolean =>
730
+ !!p && typeof p.apiKey === 'string' && p.apiKey.startsWith(ENC_PREFIX)
731
+ if (!providers.some(isEncrypted)) return
593
732
  const key = getCredentialKey(MIPHAM_HOME)
594
733
  for (const p of providers) {
595
- if (!p.apiKey.startsWith(ENC_PREFIX)) continue
734
+ if (!isEncrypted(p)) continue
596
735
  try {
597
736
  p.apiKey = decryptApiKey(p.apiKey, key)
598
737
  } catch (err: unknown) {
@@ -11,6 +11,16 @@ import { SecurityGate } from '../../security/gate'
11
11
  const TOKEN_REDACTION_PATTERN =
12
12
  /\b(?:ghp_|gho_|ghs_|ghu_|ghr_|github_pat_|glpat-|gldt-|glrt-|gloas-)[A-Za-z0-9_-]{8,}/g
13
13
 
14
+ /**
15
+ * Credentials embedded in a URL's userinfo — `scheme://user:password@host`.
16
+ *
17
+ * Nothing in the *name* gives this away: `DATABASE_URL` holds no secret word,
18
+ * so a name-based pattern cannot see it (and masking every `*_URL` would take
19
+ * out every non-secret endpoint too). The shape is the only reliable tell.
20
+ * Redacts the password and keeps user + host, or the line stops being readable.
21
+ */
22
+ const URL_USERINFO_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^/\s@]+)@/gi
23
+
14
24
  /**
15
25
  * Scrub credential patterns from stdout/stderr output.
16
26
  * Uses the configured output_scrubbing patterns to detect and replace
@@ -24,6 +34,9 @@ export function maskOutput(output: string, config: CredentialMaskingConfig): str
24
34
  // Redact bare secret tokens by prefix (always on, independent of config patterns).
25
35
  masked = masked.replace(TOKEN_REDACTION_PATTERN, CREDENTIAL_SENTINEL)
26
36
 
37
+ // Redact URL-embedded passwords — also shape-based, also always on.
38
+ masked = masked.replace(URL_USERINFO_PATTERN, `$1$2:${CREDENTIAL_SENTINEL}@`)
39
+
27
40
  // Redact bare sk-/sk-ant-/JWT tokens — wire the SecurityGate credential-leak
28
41
  // detection into the output path (defense-in-depth beyond the config patterns).
29
42
  masked = SecurityGate.redactCredentialLeak(masked)
@@ -34,8 +47,9 @@ export function maskOutput(output: string, config: CredentialMaskingConfig): str
34
47
  const clean = pattern.replace(/^\(\?i\)/, '')
35
48
  const regex = new RegExp(clean, 'gim')
36
49
  masked = masked.replace(regex, (match) => {
37
- // Replace everything after the separator (= or :)
38
- return match.replace(/\s*[:=]\s*\S+/, `=${CREDENTIAL_SENTINEL}`)
50
+ // Replace everything after the separator (= or :), swallowing the quote
51
+ // a JSON-shaped hit puts in front of it (`"apiKey": "…"`).
52
+ return match.replace(/\s*["']?\s*[:=]\s*["']?\S+/, `=${CREDENTIAL_SENTINEL}`)
39
53
  })
40
54
  } catch {
41
55
  // Invalid regex — skip
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { randomUUID } from 'node:crypto'
16
- import { CrsiSandbox, validateBlastRadius } from './crsi-sandbox'
16
+ import { CrsiSandbox, validateBlastRadius, validateMergeConvergence } from './crsi-sandbox'
17
17
  import type { CrsiModificationResult } from './crsi-sandbox'
18
18
  import { appendEvalScore, getLastEvalScore, regressedAnchors } from './eval-harness'
19
19
  import { mechanismSentinel, type RewardFn } from './reward-fn'
@@ -36,6 +36,19 @@ export interface CrsiProposal {
36
36
  * 局部正确、全局遗漏。自修改前必须摸清并声明全部受影响路径,否则 fail-closed 拒绝。
37
37
  */
38
38
  blastRadius?: string[]
39
+ /**
40
+ * ε:提交者**事前**写下的预期效果(任务表现提升点数)。
41
+ * 缺席 = 不预测。判定见 improvement-track 的 predictionHit。
42
+ */
43
+ expectedEffect?: number
44
+ /** R:风险声明(这次改动可能在哪方面变差)。缺席 = 未声明。 */
45
+ risk?: string
46
+ /**
47
+ * 声明这是一次**合并型**提案(整合已有内容,而非新增)。
48
+ * 只有它为 true 时 `validateMergeConvergence` 才开火 —— 学习本身就是增长,
49
+ * 对新增型设非增长约束等于永久禁掉 `/crsi propose`。
50
+ */
51
+ merge?: boolean
39
52
  }
40
53
 
41
54
  // ── Pending proposal registry (两阶段闸门) ──
@@ -70,6 +83,26 @@ export async function runCrsiModification(
70
83
  }
71
84
  }
72
85
 
86
+ // B_H 收敛闸:合并型提案不得抬高脚手架成本。
87
+ // 位置与 blast radius 闸同序 —— 都在 worktree 之前,纯字符串比较、零磁盘 I/O、零副作用。
88
+ const convergenceError = validateMergeConvergence(proposal)
89
+ if (convergenceError) {
90
+ return {
91
+ modification: {
92
+ id: 'crsi-mod-rejected-merge-convergence',
93
+ description: proposal.description,
94
+ filePath: proposal.filePath,
95
+ newContent: proposal.newContent,
96
+ originalContent: proposal.originalContent ?? '',
97
+ crsiInsightId: proposal.crsiInsightId,
98
+ timestamp: new Date().toISOString(),
99
+ },
100
+ applied: false,
101
+ phase: 'failed',
102
+ error: convergenceError,
103
+ }
104
+ }
105
+
73
106
  sandbox.createWorktree()
74
107
 
75
108
  const applied = sandbox.applyModification({
@@ -336,7 +336,7 @@ export async function selectTargetSkill(
336
336
  return extractFilePath(response, skillFiles)
337
337
  }
338
338
 
339
- const PROSE_GENERATE_PROMPT_VERSION = '1.0.0'
339
+ const PROSE_GENERATE_PROMPT_VERSION = '1.1.0'
340
340
 
341
341
  function buildGenerateProsePrompt(
342
342
  signal: CrsiSignal,
@@ -357,7 +357,12 @@ function buildGenerateProsePrompt(
357
357
  '当前内容:',
358
358
  originalContent,
359
359
  '',
360
- '请返回改进后的完整 markdown(保持 YAML frontmatter 的 name/description 字段,正文针对失败信号做针对性改进)。只返回 markdown,不要额外说明。',
360
+ '返回格式(严格遵守,两段):',
361
+ '第 1 行:一行 JSON,写下你对这次改动的**预期效果**与**风险**:',
362
+ '{"expectedDelta": <number 或 null>, "risk": "<字符串>"}',
363
+ '- expectedDelta 是预期该 skill 的任务表现提升**点数**(可正可负;无法预测写 null)。',
364
+ '- risk 是这次改动可能在哪方面变差(一句话)。',
365
+ '第 2 行起:改进后的完整 markdown(保持 YAML frontmatter 的 name/description 字段,正文针对失败信号做针对性改进)。不要用代码围栏包住。',
361
366
  ].join('\n')
362
367
  }
363
368
 
@@ -366,16 +371,68 @@ function stripMarkdownFence(text: string): string {
366
371
  return match ? match[1]! : text
367
372
  }
368
373
 
374
+ /** prose 提议的解析产物:正文 + 可选的事前预登记(ε 与风险声明)。 */
375
+ export interface ProsePrediction {
376
+ body: string
377
+ /** ε:事前写下的预期提升点数。缺席 = 模型没预测(含显式写 null)。 */
378
+ expectedEffect?: number
379
+ /** R:风险声明。缺席 = 未声明。 */
380
+ risk?: string
381
+ }
382
+
383
+ /**
384
+ * 解析 prose 响应:可选的一行 JSON 前缀(ε)+ 正文。
385
+ *
386
+ * 顺序是**先归一化、后嗅探**(不可颠倒):stripMarkdownFence 的正则锚在串首
387
+ * (/^```(?:markdown|md)?\s*\n…\n```\s*$/)。若先剥「首行围栏」再嗅探,正文尾部的
388
+ * 那个 ``` 就再没有东西去剥它 ⇒ 孤立的尾部围栏会进入写盘路径。
389
+ *
390
+ * 认领标记是**含 `expectedDelta` 键**(盖住 number 与显式 null 两种写法);
391
+ * 其余任何情况都走兜底 —— 正文 = 归一化后的原文,一字不改。
392
+ */
393
+ export function parseProsePrediction(raw: string): ProsePrediction {
394
+ const stripped = stripMarkdownFence(raw)
395
+ const lines = stripped.split('\n')
396
+ const firstIdx = lines.findIndex((l) => l.trim() !== '')
397
+ if (firstIdx === -1) return { body: stripped }
398
+
399
+ let parsed: unknown
400
+ try {
401
+ parsed = JSON.parse(lines[firstIdx]!.trim())
402
+ } catch {
403
+ return { body: stripped } // 首行不是 JSON → 兜底
404
+ }
405
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
406
+ return { body: stripped }
407
+ }
408
+ const rec = parsed as { expectedDelta?: unknown; risk?: unknown }
409
+ if (!('expectedDelta' in rec)) return { body: stripped } // 不带 ε 的 JSON 不吃
410
+
411
+ const expectedEffect = typeof rec.expectedDelta === 'number' ? rec.expectedDelta : undefined
412
+ const risk = typeof rec.risk === 'string' ? rec.risk : undefined
413
+ // 剥掉 JSON 行本身 + 紧随其后的空行
414
+ const body = lines
415
+ .slice(firstIdx + 1)
416
+ .join('\n')
417
+ .replace(/^[ \t]*\n/, '')
418
+
419
+ return {
420
+ body,
421
+ ...(expectedEffect !== undefined ? { expectedEffect } : {}),
422
+ ...(risk !== undefined ? { risk } : {}),
423
+ }
424
+ }
425
+
369
426
  export async function generateProseContent(
370
427
  signal: CrsiSignal,
371
428
  llm: Llm,
372
429
  filePath: string,
373
430
  originalContent: string,
374
- ): Promise<string | null> {
431
+ ): Promise<ProsePrediction | null> {
375
432
  const prompt = buildGenerateProsePrompt(signal, filePath, originalContent)
376
433
  const response = await collectLlmText(llm, prompt)
377
434
  if (!response) return null
378
- return stripMarkdownFence(response)
435
+ return parseProsePrediction(response)
379
436
  }
380
437
 
381
438
  export interface ProseProposalResult {
@@ -383,6 +440,10 @@ export interface ProseProposalResult {
383
440
  newContent: string
384
441
  originalContent: string
385
442
  description: string
443
+ /** ε:由模型在正文之前写下(见 parseProsePrediction)。 */
444
+ expectedEffect?: number
445
+ /** R:风险声明。 */
446
+ risk?: string
386
447
  }
387
448
 
388
449
  export async function produceProseProposal(
@@ -401,10 +462,17 @@ export async function produceProseProposal(
401
462
  return null
402
463
  }
403
464
 
404
- const newContent = await generateProseContent(signal, llm, filePath, originalContent)
405
- if (!newContent) return null
465
+ const generated = await generateProseContent(signal, llm, filePath, originalContent)
466
+ if (!generated || !generated.body) return null
406
467
 
407
- return { filePath, newContent, originalContent, description: signal.title }
468
+ return {
469
+ filePath,
470
+ newContent: generated.body,
471
+ originalContent,
472
+ description: signal.title,
473
+ ...(generated.expectedEffect !== undefined ? { expectedEffect: generated.expectedEffect } : {}),
474
+ ...(generated.risk !== undefined ? { risk: generated.risk } : {}),
475
+ }
408
476
  }
409
477
 
410
478
  const SKILL_DIRS: Array<[string, string]> = [
@@ -582,6 +650,7 @@ export async function produceCrossoverProposal(
582
650
  newContent: string
583
651
  originalContent: string
584
652
  blastRadius: string[]
653
+ merge: boolean
585
654
  } | null> {
586
655
  const response = await collectLlmText(llm, buildCrossoverPrompt(currentLessons))
587
656
  if (!response) return null
@@ -607,5 +676,6 @@ export async function produceCrossoverProposal(
607
676
  newContent,
608
677
  originalContent: currentLessons,
609
678
  blastRadius: [LESSONS_FILE],
679
+ merge: true,
610
680
  }
611
681
  }