iterate-plugin 3.11.0 → 3.13.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.
@@ -446,11 +446,53 @@ export const EN: Record<string, string> = {
446
446
  '防线:{a0}': 'Defense: {a0}',
447
447
  没有匹配该类型的防御事件: 'No defense events of this type',
448
448
 
449
+ // ── F11 preflight ────────────────────────────────────────────────────────
450
+ 开跑预检: 'Preflight',
451
+ 待预检: 'Not run yet',
452
+ 未就绪: 'Not ready',
453
+ '复制 iterate_preflight 预检指令(含 JSON 参数)':
454
+ 'Copy the iterate_preflight readiness instruction (JSON arguments included)',
455
+ 运行预检: 'Run preflight',
456
+ 开跑预检清单: 'Preflight checklist',
457
+ 预检结果: 'Preflight result',
458
+ '开跑前先预检:确认验证命令、Git、工作区、陈旧门禁与中断断点都已就绪,避免开跑后才发现环境问题。':
459
+ 'Run preflight first: confirm validation commands, git, the working tree, a stale gate and an interrupted checkpoint are ready, so you do not discover an environment problem mid-run.',
460
+ 复制预检指令: 'Copy preflight instruction',
461
+ 验证命令: 'Validation commands',
462
+ '已配置 {a0} 条验证命令': '{a0} validation command(s) configured',
463
+ '未配置 validation.commands(阻断项)—— 检测到 {a0} 项目,建议:{a1}':
464
+ 'validation.commands is not configured (blocking) — detected {a0} project; suggested: {a1}',
465
+ '未配置 validation.commands —— 本轮无法证明修复,属阻断项':
466
+ 'validation.commands is not configured — the run cannot prove fixes (blocking)',
467
+ 'Git 仓库': 'Git repository',
468
+ '已检测到 Git 仓库': 'Git repository detected',
469
+ '不是 Git 仓库 —— changed-only 范围与 iterate_diff 需要它':
470
+ 'Not a git repository — changed-only scope and iterate_diff need one',
471
+ 工作区: 'Working tree',
472
+ '未提交改动:未知': 'Uncommitted changes: unknown',
473
+ '{a0} 个未提交/未跟踪文件 —— 建议先 commit 或 stash':
474
+ '{a0} uncommitted/untracked file(s) — commit or stash first',
475
+ 工作区干净: 'Working tree clean',
476
+ 陈旧门禁: 'Stale gate',
477
+ '磁盘上残留 FAIL 质量门禁 —— 开新轮前先清除':
478
+ 'A stale FAIL quality gate is persisted — clear it before a fresh run',
479
+ '质量门禁:{a0}': 'Quality gate: {a0}',
480
+ 无持久化质量门禁: 'No persisted quality gate',
481
+ 中断断点: 'Interrupted checkpoint',
482
+ '存在中断断点(已恢复 {a0} 次)—— 恢复或清除后再开跑':
483
+ 'An interrupted checkpoint is waiting (resumed {a0}x) — resume or clear it first',
484
+ 无中断断点: 'No interrupted checkpoint',
485
+ 配置验证命令: 'Configure validation',
486
+ 应用建议: 'Apply suggestion',
487
+ 恢复断点: 'Resume checkpoint',
488
+ '阻断 {a0} · 警告 {a1}': 'Blocking {a0} · warnings {a1}',
489
+ '预检失败:{a0}': 'Preflight failed: {a0}',
490
+ '预检只读,不会修改配置或文件。': 'Preflight is read-only; it never modifies config or files.',
491
+
449
492
  // ── copy feedback ────────────────────────────────────────────────────────
450
493
  已复制: 'Copied',
451
494
  复制失败: 'Copy failed',
452
495
  }
453
-
454
496
  let current: Lang = 'zh'
455
497
  let storage: LangStorage | null = null
456
498
  const listeners = new Set<(lang: Lang) => void>()
@@ -75,6 +75,9 @@ import {
75
75
  scanSessionForQualityGate,
76
76
  scanSessionForExperienceBank,
77
77
  scanSessionForDefenseEvents,
78
+ scanSessionForPreflight,
79
+ buildPreflightInstruction,
80
+ buildValidationApplyInstruction,
78
81
  filterLiveEntries,
79
82
  filterTimelineEntries,
80
83
  serializeObservatoryExport,
@@ -336,6 +339,24 @@ interface ObsQualityGate {
336
339
  dimensions?: ObsQualityGateDimension[]
337
340
  }
338
341
 
342
+ /** One readiness check from `iterate_preflight` (F11). `detail` is structured
343
+ * facts the panel localizes into a sentence. */
344
+ interface ObsPreflightCheck {
345
+ id?: string
346
+ status?: string
347
+ detail?: Record<string, unknown>
348
+ }
349
+
350
+ /** Latest `iterate_preflight` report scanned out of the session stream. */
351
+ interface ObsPreflight {
352
+ ok?: boolean
353
+ ready?: boolean
354
+ blocking?: number
355
+ warnings?: number
356
+ error?: string | null
357
+ checks?: ObsPreflightCheck[]
358
+ }
359
+
339
360
  interface ObsExperienceEntry {
340
361
  id?: string
341
362
  timestamp?: string
@@ -583,6 +604,7 @@ const ITERATE_CSS = `
583
604
  .iterate-gate[data-status="pass"] { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); }
584
605
  .iterate-gate[data-status="fail"] { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 28%, transparent); }
585
606
  .iterate-gate[data-status="pending"] { color: var(--dsw-alias-label-secondary); background: color-mix(in srgb, var(--dsw-alias-label-secondary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-label-secondary) 28%, transparent); }
607
+ .iterate-gate[data-status="warn"] { color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 28%, transparent); }
586
608
 
587
609
  /* v3.0: Experience bank hit highlight */
588
610
  .iterate-exp-hit { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); font-size: 11px; color: var(--dsw-alias-state-success-primary); }
@@ -2179,6 +2201,8 @@ const OBS_TABS = [
2179
2201
  { key: 'f8', label: { zh: '质量门禁', en: 'Quality gate' }, hotkey: '8' },
2180
2202
  { key: 'f9', label: { zh: '经验银行', en: 'Experience bank' }, hotkey: '9' },
2181
2203
  { key: 'f10', label: { zh: '防御事件', en: 'Defense events' }, hotkey: '0' },
2204
+ // v3.12: pre-run readiness checklist (validation/git/dirty-tree/stale-gate/checkpoint)
2205
+ { key: 'f11', label: { zh: '开跑预检', en: 'Preflight' }, hotkey: '' },
2182
2206
  ] as const
2183
2207
 
2184
2208
  /** Digit → tab id. `1-9` map to F1–F9, `0` to F10, `-` back to the live feed
@@ -2267,6 +2291,12 @@ function latestDefenseEvents(session: unknown): ObsDefenseResult | null {
2267
2291
  return scanSessionForDefenseEvents(session) as ObsDefenseResult | null
2268
2292
  }
2269
2293
 
2294
+ /** Find the latest preflight report inside a session snapshot. */
2295
+ function latestPreflight(session: unknown): ObsPreflight | null {
2296
+ if (!session) return null
2297
+ return scanSessionForPreflight(session) as ObsPreflight | null
2298
+ }
2299
+
2270
2300
  /** Build a human-pasteable `iterate_fix` instruction for one finding.
2271
2301
  * Gap #10: delegates to the shared builder so the payload always names the
2272
2302
  * four required `iterate_fix` params (file / content / finding / round) and
@@ -2295,6 +2325,7 @@ function ObservatoryPanel(props: SlotProps) {
2295
2325
  const qualityGate = latestQualityGate(session)
2296
2326
  const experienceBank = latestExperienceBank(session)
2297
2327
  const defenseEvents = latestDefenseEvents(session)
2328
+ const preflight = latestPreflight(session)
2298
2329
 
2299
2330
  const [open, setOpen] = React.useState(false)
2300
2331
  const [tab, setTab] = React.useState('live')
@@ -3356,6 +3387,195 @@ function ObservatoryPanel(props: SlotProps) {
3356
3387
  )
3357
3388
  }
3358
3389
 
3390
+ // ── F11: Preflight readiness ──────────────────────────────────────────────
3391
+ // v3.12: renders the latest iterate_preflight checklist scanned from the
3392
+ // session stream. Each warn/fail row offers the exact fix instruction
3393
+ // (configure validation, clear a stale gate, resume an interrupted
3394
+ // checkpoint) so the environment is repaired BEFORE a round is spent on it.
3395
+ const renderPreflight = () => {
3396
+ const preflightInstruction = buildPreflightInstruction()
3397
+ const report = preflight
3398
+ const hasReport = report !== null && Array.isArray(report.checks)
3399
+ const ready = report !== null && report.ready === true
3400
+ const blocking = Number(report && report.blocking != null ? report.blocking : 0)
3401
+ const warnings = Number(report && report.warnings != null ? report.warnings : 0)
3402
+ const statusLabel = !hasReport ? t('待预检') : ready ? t('就绪') : t('未就绪')
3403
+ const statusTone = !hasReport ? 'pending' : ready ? 'pass' : 'fail'
3404
+ const headerBar = React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
3405
+ React.createElement('b', {}, t('开跑预检')),
3406
+ React.createElement('span', { className: 'iterate-gate', 'data-status': statusTone }, statusLabel),
3407
+ React.createElement('button', {
3408
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'preflight' ? '' : undefined,
3409
+ onClick: () => copyInstruction('preflight', preflightInstruction),
3410
+ title: t('复制 iterate_preflight 预检指令(含 JSON 参数)'),
3411
+ }, copiedKey === 'preflight' ? t('已复制') : t('运行预检')),
3412
+ )
3413
+
3414
+ if (!hasReport) {
3415
+ return React.createElement('div', {},
3416
+ headerBar,
3417
+ React.createElement('div', { className: 'iterate-obs-block' },
3418
+ React.createElement('div', { className: 'iterate-obs-block-head' },
3419
+ React.createElement('span', {}, t('开跑预检清单')),
3420
+ React.createElement('span', { className: 'iterate-obs-badge' }, 'v3.12'),
3421
+ ),
3422
+ React.createElement('div', { className: 'iterate-obs-block-body' },
3423
+ React.createElement('div', { className: 'iterate-obs-msg' },
3424
+ // Gap #4: separate "pulled and empty" from "never pulled".
3425
+ diskEmptyStateText(toolCalledInSession(session, 'iterate_preflight'), t('预检结果'), getLang()),
3426
+ ),
3427
+ React.createElement('div', { className: 'iterate-obs-msg', style: { marginTop: 6 } },
3428
+ t('开跑前先预检:确认验证命令、Git、工作区、陈旧门禁与中断断点都已就绪,避免开跑后才发现环境问题。'),
3429
+ ),
3430
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
3431
+ React.createElement('button', {
3432
+ className: 'iterate-btn', 'data-copied': copiedKey === 'preflight-instr' ? '' : undefined,
3433
+ onClick: () => copyInstruction('preflight-instr', preflightInstruction),
3434
+ title: t('复制预检指令'),
3435
+ }, copiedKey === 'preflight-instr' ? t('已复制') : t('复制预检指令')),
3436
+ ),
3437
+ ),
3438
+ ),
3439
+ )
3440
+ }
3441
+
3442
+ // A tool-level failure (bad path, root refused) carries no usable checks —
3443
+ // surface the error instead of a misleading "ready, 0 blocking" summary.
3444
+ if (report && report.ok === false) {
3445
+ return React.createElement('div', {},
3446
+ headerBar,
3447
+ React.createElement('div', { className: 'iterate-obs-block' },
3448
+ React.createElement('div', { className: 'iterate-obs-block-head' },
3449
+ React.createElement('span', {}, t('开跑预检清单')),
3450
+ React.createElement('span', { className: 'iterate-obs-badge' }, 'v3.12'),
3451
+ ),
3452
+ React.createElement('div', { className: 'iterate-obs-block-body' },
3453
+ React.createElement('div', { className: 'iterate-obs-msg' },
3454
+ tf('预检失败:{a0}', { a0: String(report.error || '') }),
3455
+ ),
3456
+ ),
3457
+ ),
3458
+ )
3459
+ }
3460
+
3461
+ // Localized label + detail per check id. Detail is structured facts from
3462
+ // the tool, so the panel — not the tool — owns the wording/language.
3463
+ const CHECK_META: Record<string, { label: string; detail: (d: Record<string, unknown>) => string }> = {
3464
+ validation: {
3465
+ label: t('验证命令'),
3466
+ detail: (d) => {
3467
+ const n = Number(d.commandCount ?? 0)
3468
+ if (n > 0) return tf('已配置 {a0} 条验证命令', { a0: n })
3469
+ const suggested = Array.isArray(d.suggestedCommands)
3470
+ ? d.suggestedCommands.map((c) => String(c)).filter((c) => c.length > 0)
3471
+ : []
3472
+ if (suggested.length > 0) {
3473
+ return tf('未配置 validation.commands(阻断项)—— 检测到 {a0} 项目,建议:{a1}', {
3474
+ a0: String(d.ecosystem || ''),
3475
+ a1: suggested.join(' · '),
3476
+ })
3477
+ }
3478
+ return t('未配置 validation.commands —— 本轮无法证明修复,属阻断项')
3479
+ },
3480
+ },
3481
+ git: {
3482
+ label: t('Git 仓库'),
3483
+ detail: (d) => (d.isRepo === true ? t('已检测到 Git 仓库') : t('不是 Git 仓库 —— changed-only 范围与 iterate_diff 需要它')),
3484
+ },
3485
+ 'dirty-tree': {
3486
+ label: t('工作区'),
3487
+ detail: (d) => {
3488
+ const n = d.changedFiles
3489
+ if (n === null || n === undefined) return t('未提交改动:未知')
3490
+ const num = Number(n)
3491
+ return num > 0
3492
+ ? tf('{a0} 个未提交/未跟踪文件 —— 建议先 commit 或 stash', { a0: num })
3493
+ : t('工作区干净')
3494
+ },
3495
+ },
3496
+ 'stale-gate': {
3497
+ label: t('陈旧门禁'),
3498
+ detail: (d) => {
3499
+ const s = d.overallStatus
3500
+ if (s === 'fail') return t('磁盘上残留 FAIL 质量门禁 —— 开新轮前先清除')
3501
+ return s ? tf('质量门禁:{a0}', { a0: String(s) }) : t('无持久化质量门禁')
3502
+ },
3503
+ },
3504
+ 'interrupted-checkpoint': {
3505
+ label: t('中断断点'),
3506
+ detail: (d) => {
3507
+ if (d.interrupted === true) {
3508
+ const rc = Number(d.resumeCount ?? 0)
3509
+ return tf('存在中断断点(已恢复 {a0} 次)—— 恢复或清除后再开跑', { a0: rc })
3510
+ }
3511
+ return t('无中断断点')
3512
+ },
3513
+ },
3514
+ }
3515
+
3516
+ // Repair action per check id; null = guidance only (no tool can fix it).
3517
+ const fixFor = (id: string, detail: Record<string, unknown>): { key: string; label: string; instruction: string } | null => {
3518
+ switch (id) {
3519
+ case 'validation': {
3520
+ const suggested = Array.isArray(detail.suggestedCommands)
3521
+ ? detail.suggestedCommands.map((c) => String(c)).filter((c) => c.length > 0)
3522
+ : []
3523
+ if (suggested.length > 0) {
3524
+ return {
3525
+ key: 'preflight-fix-validation',
3526
+ label: t('应用建议'),
3527
+ instruction: buildValidationApplyInstruction(suggested),
3528
+ }
3529
+ }
3530
+ return { key: 'preflight-fix-validation', label: t('配置验证命令'), instruction: buildConfigFieldInstruction('validation.commands') }
3531
+ }
3532
+ case 'stale-gate':
3533
+ return { key: 'preflight-fix-gate', label: t('清除门禁'), instruction: buildQualityGateClearInstruction() }
3534
+ case 'interrupted-checkpoint':
3535
+ return { key: 'preflight-fix-checkpoint', label: t('恢复断点'), instruction: buildCheckpointResumeInstruction() }
3536
+ default:
3537
+ return null
3538
+ }
3539
+ }
3540
+
3541
+ const rows = (report.checks || []).map((c, i) => {
3542
+ const id = String(c.id || '')
3543
+ const status = String(c.status || 'warn')
3544
+ const meta = CHECK_META[id]
3545
+ const label = meta ? meta.label : id || '?'
3546
+ const detailText = meta ? meta.detail(c.detail || {}) : ''
3547
+ const fix = status !== 'pass' ? fixFor(id, c.detail || {}) : null
3548
+ return React.createElement('div', { key: `pf-${i}`, className: 'iterate-obs-row' },
3549
+ React.createElement('span', { className: 'iterate-gate', 'data-status': status }, status.toUpperCase()),
3550
+ React.createElement('b', {}, label),
3551
+ React.createElement('span', { className: 'iterate-obs-msg' }, detailText),
3552
+ fix
3553
+ ? React.createElement('button', {
3554
+ className: 'iterate-btn', 'data-copied': copiedKey === fix.key ? '' : undefined,
3555
+ onClick: () => copyInstruction(fix.key, fix.instruction),
3556
+ title: fix.instruction,
3557
+ }, copiedKey === fix.key ? t('已复制') : fix.label)
3558
+ : null,
3559
+ )
3560
+ })
3561
+
3562
+ return React.createElement('div', {},
3563
+ headerBar,
3564
+ React.createElement('div', { className: 'iterate-obs-block' },
3565
+ React.createElement('div', { className: 'iterate-obs-block-head' },
3566
+ React.createElement('span', {}, t('开跑预检清单')),
3567
+ React.createElement('span', { className: 'iterate-obs-head-meta' },
3568
+ tf('阻断 {a0} · 警告 {a1}', { a0: blocking, a1: warnings }),
3569
+ ),
3570
+ ),
3571
+ React.createElement('div', { className: 'iterate-obs-block-body' },
3572
+ React.createElement('div', { className: 'iterate-obs-msg' }, t('预检只读,不会修改配置或文件。')),
3573
+ ...rows,
3574
+ ),
3575
+ ),
3576
+ )
3577
+ }
3578
+
3359
3579
  // ── Live: real-time secondary-subagent activity stream (newest first) ─────
3360
3580
  const renderLive = () => {
3361
3581
  const liveEntries = manifest.live || []
@@ -3440,6 +3660,7 @@ function ObservatoryPanel(props: SlotProps) {
3440
3660
  case 'f8': return renderQualityGate()
3441
3661
  case 'f9': return renderExperienceBank()
3442
3662
  case 'f10': return renderDefenseEvents()
3663
+ case 'f11': return renderPreflight()
3443
3664
  default: return null
3444
3665
  }
3445
3666
  }
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ import { registerExperienceBankTool } from './tools/experience-bank.ts'
46
46
  import { registerQualityGateTool } from './tools/quality-gate.ts'
47
47
  import { registerDefenseEventsTool } from './tools/defense-events.ts'
48
48
  import { registerReportTool } from './tools/report.ts'
49
+ import { registerPreflightTool } from './tools/preflight.ts'
49
50
  import { registerSessionHooks } from './session-hooks.ts'
50
51
  import { registerLiveCapture } from './live.ts'
51
52
  import { installWorkingDirectoryContext } from './working-directory.ts'
@@ -71,11 +72,11 @@ export function apply(ctx: Context): void {
71
72
  registerSessionHooks(ctx)
72
73
  registerLiveCapture(ctx)
73
74
 
74
- // 1. Register the 17 tools (14 original + 3 v3.0). Each registration is
75
- // isolated: one failing tool must not take down the other 16, the gate
76
- // above, or the prompt below (a partial plugin that still gates and
77
- // teaches the workflow beats an aborted `apply`). Failures are logged,
78
- // never swallowed silently.
75
+ // 1. Register the 19 tools (14 original + 3 v3.0 + report + preflight). Each
76
+ // registration is isolated: one failing tool must not take down the other
77
+ // 18, the gate above, or the prompt below (a partial plugin that still
78
+ // gates and teaches the workflow beats an aborted `apply`). Failures are
79
+ // logged, never swallowed silently.
79
80
  const toolRegistrations: Array<[string, () => void]> = [
80
81
  ['iterate_config', () => registerConfigTool(ctx)],
81
82
  ['iterate_validate', () => registerValidateTool(ctx)],
@@ -97,6 +98,8 @@ export function apply(ctx: Context): void {
97
98
  ['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
98
99
  // v3.9: PR-ready / CI-ready report export
99
100
  ['iterate_report', () => registerReportTool(ctx)],
101
+ // v3.12: pre-run readiness check (validation/git/stale-gate/interrupted)
102
+ ['iterate_preflight', () => registerPreflightTool(ctx)],
100
103
  ]
101
104
  for (const [toolName, register] of toolRegistrations) {
102
105
  try {
@@ -2,7 +2,7 @@ import { join } from 'node:path'
2
2
  import { existsSync, readFileSync } from 'node:fs'
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
4
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
5
- import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
5
+ import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec, flattenCommands } from '../config-loader.ts'
6
6
  import {
7
7
  applyConfigUpdates,
8
8
  readRawConfig,
@@ -10,6 +10,7 @@ import {
10
10
  writeConfigFile,
11
11
  } from '../config-write.ts'
12
12
  import { withProjectLock } from '../file-lock.ts'
13
+ import { buildValidationUpdates, detectValidationCommands } from '../validation-detect.ts'
13
14
 
14
15
  /**
15
16
  * Register the `iterate_config` tool.
@@ -21,16 +22,24 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
21
22
  ctx.tools.register(
22
23
  defineTool({
23
24
  name: 'iterate_config',
24
- // `read` never writes; `write` persists a config update → only read
25
- // joins a parallel dispatch group.
26
- isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'write',
25
+ // `read` never writes; `write` persists a config update. `detect` is a
26
+ // read UNLESS it is asked to apply the detected commands.
27
+ isConcurrencySafe: (args) => {
28
+ const a = args as { operation?: unknown; apply?: unknown }
29
+ return a.operation !== 'write' && !(a.operation === 'detect' && a.apply === true)
30
+ },
27
31
  // Pending-call card: a config read vs a validated write.
28
32
  presentCall: (args) => {
29
- const a = args as { operation?: unknown; section?: unknown }
30
- const isWrite = a.operation === 'write'
33
+ const a = args as { operation?: unknown; section?: unknown; apply?: unknown }
34
+ const isWrite = a.operation === 'write' || (a.operation === 'detect' && a.apply === true)
35
+ const title = isWrite
36
+ ? 'Update iterate.config.yaml'
37
+ : a.operation === 'detect'
38
+ ? 'Detect validation commands'
39
+ : 'Read iterate.config.yaml'
31
40
  return {
32
41
  card: 'generic',
33
- title: isWrite ? 'Update iterate.config.yaml' : 'Read iterate.config.yaml',
42
+ title,
34
43
  kind: isWrite ? 'edit' : 'read',
35
44
  ...(typeof a.section === 'string' && a.section ? { rawInput: { section: a.section } } : {}),
36
45
  }
@@ -39,13 +48,17 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
39
48
  'Read or update the iterate.config.yaml configuration from the project root. ' +
40
49
  'Returns the full parsed config, a specific section, or validation errors. ' +
41
50
  'Use this to discover available dimensions, validation commands, git settings, and personalization rules, ' +
42
- 'or to write back validated changes (goal, dimensions, max_rounds, review, atomic, validation, git, etc.).',
51
+ 'or to write back validated changes (goal, dimensions, max_rounds, review, atomic, validation, git, etc.). ' +
52
+ 'operation "detect" inspects the project manifest (package.json scripts / pytest / Cargo / Go / Make) and ' +
53
+ 'proposes a ready-to-apply validation.commands set; pass apply:true to install it in one call.',
43
54
 
44
55
  parameters: {
45
56
  operation: {
46
57
  type: 'string',
47
- description: 'Default "read". "write" validates and applies a partial config update (backed up first).',
48
- enum: ['read', 'write'],
58
+ description:
59
+ 'Default "read". "write" validates and applies a partial config update (backed up first). ' +
60
+ '"detect" proposes validation commands from the project manifest.',
61
+ enum: ['read', 'write', 'detect'],
49
62
  },
50
63
  path: {
51
64
  type: 'string',
@@ -60,6 +73,16 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
60
73
  type: 'boolean',
61
74
  description: 'If true, validate the config schema and return any missing fields.',
62
75
  },
76
+ apply: {
77
+ type: 'boolean',
78
+ description:
79
+ 'For operation "detect": when true, install the detected commands under validation.commands.default ' +
80
+ '(refused if commands are already configured unless force:true).',
81
+ },
82
+ force: {
83
+ type: 'boolean',
84
+ description: 'For operation "detect" with apply:true: replace an existing default command set.',
85
+ },
63
86
  updates: {
64
87
  type: 'json',
65
88
  description:
@@ -83,6 +106,11 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
83
106
  availableSections: { type: 'array', items: { type: 'string' } },
84
107
  operation: { type: 'string' },
85
108
  ok: { type: 'boolean' },
109
+ applied: { type: 'boolean' },
110
+ ecosystem: { oneOf: [{ type: 'string' }, { type: 'null' }] },
111
+ commands: { type: 'array', items: { type: 'string' } },
112
+ sources: { type: 'json' },
113
+ updates: { type: 'json' },
86
114
  backupPath: { oneOf: [{ type: 'string' }, { type: 'null' }] },
87
115
  error: { type: 'string' },
88
116
  },
@@ -99,6 +127,81 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
99
127
  }
100
128
  const projectRoot = resolved.root
101
129
 
130
+ // ── Detect operation (validation-command auto-detection) ───────────
131
+ // Reads the project manifest and proposes a curated command set. With
132
+ // apply:true it installs the set under validation.commands.default in
133
+ // the same validated, backed-up, locked write path as operation:write.
134
+ if (args.operation === 'detect') {
135
+ const configPath = join(projectRoot, 'iterate.config.yaml')
136
+ const configExists = existsSync(configPath)
137
+ const detection = detectValidationCommands(projectRoot)
138
+ const updates = buildValidationUpdates(detection.commands)
139
+ const base = {
140
+ operation: 'detect',
141
+ found: configExists,
142
+ ecosystem: detection.ecosystem,
143
+ commands: detection.commands,
144
+ sources: detection.sources as unknown as JsonValue,
145
+ updates: updates as unknown as JsonValue,
146
+ }
147
+ if (detection.commands.length === 0) {
148
+ return {
149
+ ...base,
150
+ ok: false,
151
+ applied: false,
152
+ error:
153
+ 'no validation commands detected — the project does not advertise a test/typecheck/lint/build ' +
154
+ 'command; configure validation.commands manually',
155
+ }
156
+ }
157
+ if (args.apply !== true) {
158
+ return { ...base, ok: true, applied: false }
159
+ }
160
+ // Refuse to silently clobber a hand-tuned command set.
161
+ const current = loadEffectiveConfig(projectRoot).config
162
+ const existing = flattenCommands(current.validation?.commands)
163
+ if (existing.length > 0 && args.force !== true) {
164
+ return {
165
+ ...base,
166
+ ok: false,
167
+ applied: false,
168
+ error:
169
+ `validation.commands already has ${existing.length} command(s); ` +
170
+ 'pass force:true to replace the "default" module',
171
+ }
172
+ }
173
+ return withProjectLock(projectRoot, 'config-write', () => {
174
+ let cfgBase: Record<string, unknown>
175
+ try {
176
+ cfgBase = configExists
177
+ ? readRawConfig(configPath)
178
+ : (defaultConfig() as unknown as Record<string, unknown>)
179
+ } catch (err) {
180
+ return { ...base, ok: false, applied: false, error: `failed to read config: ${String(err)}` }
181
+ }
182
+ const merged = applyConfigUpdates(cfgBase, updates)
183
+ const schemaErrors = validateConfig(merged)
184
+ if (schemaErrors.length > 0) {
185
+ return {
186
+ ...base,
187
+ ok: false,
188
+ applied: false,
189
+ errors: schemaErrors.map((e) => `missing/required field: ${e}`),
190
+ }
191
+ }
192
+ const result = writeConfigFile(projectRoot, merged)
193
+ if (!result.ok) return { ...base, ok: false, applied: false, error: result.error }
194
+ const { config } = loadEffectiveConfig(projectRoot)
195
+ return {
196
+ ...base,
197
+ ok: true,
198
+ applied: true,
199
+ backupPath: result.backupPath ?? null,
200
+ config: config as unknown as JsonValue,
201
+ }
202
+ })
203
+ }
204
+
102
205
  // ── Write operation ────────────────────────────────────────────────
103
206
  if (args.operation === 'write') {
104
207
  const updates = args.updates as Record<string, unknown> | undefined