iterate-plugin 3.11.0 → 3.12.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/README.md +8 -7
- package/README.zh-CN.md +9 -8
- package/dist/index.js +8 -5
- package/dist/tools/preflight.js +239 -0
- package/lib/client.js +321 -5
- package/lib/parse.js +148 -2
- package/package.json +1 -1
- package/src/client/i18n.ts +40 -1
- package/src/client/index.ts +201 -0
- package/src/index.ts +8 -5
- package/src/tools/preflight.ts +289 -0
package/src/client/index.ts
CHANGED
|
@@ -75,6 +75,8 @@ import {
|
|
|
75
75
|
scanSessionForQualityGate,
|
|
76
76
|
scanSessionForExperienceBank,
|
|
77
77
|
scanSessionForDefenseEvents,
|
|
78
|
+
scanSessionForPreflight,
|
|
79
|
+
buildPreflightInstruction,
|
|
78
80
|
filterLiveEntries,
|
|
79
81
|
filterTimelineEntries,
|
|
80
82
|
serializeObservatoryExport,
|
|
@@ -336,6 +338,24 @@ interface ObsQualityGate {
|
|
|
336
338
|
dimensions?: ObsQualityGateDimension[]
|
|
337
339
|
}
|
|
338
340
|
|
|
341
|
+
/** One readiness check from `iterate_preflight` (F11). `detail` is structured
|
|
342
|
+
* facts the panel localizes into a sentence. */
|
|
343
|
+
interface ObsPreflightCheck {
|
|
344
|
+
id?: string
|
|
345
|
+
status?: string
|
|
346
|
+
detail?: Record<string, unknown>
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Latest `iterate_preflight` report scanned out of the session stream. */
|
|
350
|
+
interface ObsPreflight {
|
|
351
|
+
ok?: boolean
|
|
352
|
+
ready?: boolean
|
|
353
|
+
blocking?: number
|
|
354
|
+
warnings?: number
|
|
355
|
+
error?: string | null
|
|
356
|
+
checks?: ObsPreflightCheck[]
|
|
357
|
+
}
|
|
358
|
+
|
|
339
359
|
interface ObsExperienceEntry {
|
|
340
360
|
id?: string
|
|
341
361
|
timestamp?: string
|
|
@@ -583,6 +603,7 @@ const ITERATE_CSS = `
|
|
|
583
603
|
.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
604
|
.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
605
|
.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); }
|
|
606
|
+
.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
607
|
|
|
587
608
|
/* v3.0: Experience bank hit highlight */
|
|
588
609
|
.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 +2200,8 @@ const OBS_TABS = [
|
|
|
2179
2200
|
{ key: 'f8', label: { zh: '质量门禁', en: 'Quality gate' }, hotkey: '8' },
|
|
2180
2201
|
{ key: 'f9', label: { zh: '经验银行', en: 'Experience bank' }, hotkey: '9' },
|
|
2181
2202
|
{ key: 'f10', label: { zh: '防御事件', en: 'Defense events' }, hotkey: '0' },
|
|
2203
|
+
// v3.12: pre-run readiness checklist (validation/git/dirty-tree/stale-gate/checkpoint)
|
|
2204
|
+
{ key: 'f11', label: { zh: '开跑预检', en: 'Preflight' }, hotkey: '' },
|
|
2182
2205
|
] as const
|
|
2183
2206
|
|
|
2184
2207
|
/** Digit → tab id. `1-9` map to F1–F9, `0` to F10, `-` back to the live feed
|
|
@@ -2267,6 +2290,12 @@ function latestDefenseEvents(session: unknown): ObsDefenseResult | null {
|
|
|
2267
2290
|
return scanSessionForDefenseEvents(session) as ObsDefenseResult | null
|
|
2268
2291
|
}
|
|
2269
2292
|
|
|
2293
|
+
/** Find the latest preflight report inside a session snapshot. */
|
|
2294
|
+
function latestPreflight(session: unknown): ObsPreflight | null {
|
|
2295
|
+
if (!session) return null
|
|
2296
|
+
return scanSessionForPreflight(session) as ObsPreflight | null
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2270
2299
|
/** Build a human-pasteable `iterate_fix` instruction for one finding.
|
|
2271
2300
|
* Gap #10: delegates to the shared builder so the payload always names the
|
|
2272
2301
|
* four required `iterate_fix` params (file / content / finding / round) and
|
|
@@ -2295,6 +2324,7 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2295
2324
|
const qualityGate = latestQualityGate(session)
|
|
2296
2325
|
const experienceBank = latestExperienceBank(session)
|
|
2297
2326
|
const defenseEvents = latestDefenseEvents(session)
|
|
2327
|
+
const preflight = latestPreflight(session)
|
|
2298
2328
|
|
|
2299
2329
|
const [open, setOpen] = React.useState(false)
|
|
2300
2330
|
const [tab, setTab] = React.useState('live')
|
|
@@ -3356,6 +3386,176 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
3356
3386
|
)
|
|
3357
3387
|
}
|
|
3358
3388
|
|
|
3389
|
+
// ── F11: Preflight readiness ──────────────────────────────────────────────
|
|
3390
|
+
// v3.12: renders the latest iterate_preflight checklist scanned from the
|
|
3391
|
+
// session stream. Each warn/fail row offers the exact fix instruction
|
|
3392
|
+
// (configure validation, clear a stale gate, resume an interrupted
|
|
3393
|
+
// checkpoint) so the environment is repaired BEFORE a round is spent on it.
|
|
3394
|
+
const renderPreflight = () => {
|
|
3395
|
+
const preflightInstruction = buildPreflightInstruction()
|
|
3396
|
+
const report = preflight
|
|
3397
|
+
const hasReport = report !== null && Array.isArray(report.checks)
|
|
3398
|
+
const ready = report !== null && report.ready === true
|
|
3399
|
+
const blocking = Number(report && report.blocking != null ? report.blocking : 0)
|
|
3400
|
+
const warnings = Number(report && report.warnings != null ? report.warnings : 0)
|
|
3401
|
+
const statusLabel = !hasReport ? t('待预检') : ready ? t('就绪') : t('未就绪')
|
|
3402
|
+
const statusTone = !hasReport ? 'pending' : ready ? 'pass' : 'fail'
|
|
3403
|
+
const headerBar = React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
|
|
3404
|
+
React.createElement('b', {}, t('开跑预检')),
|
|
3405
|
+
React.createElement('span', { className: 'iterate-gate', 'data-status': statusTone }, statusLabel),
|
|
3406
|
+
React.createElement('button', {
|
|
3407
|
+
className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'preflight' ? '' : undefined,
|
|
3408
|
+
onClick: () => copyInstruction('preflight', preflightInstruction),
|
|
3409
|
+
title: t('复制 iterate_preflight 预检指令(含 JSON 参数)'),
|
|
3410
|
+
}, copiedKey === 'preflight' ? t('已复制') : t('运行预检')),
|
|
3411
|
+
)
|
|
3412
|
+
|
|
3413
|
+
if (!hasReport) {
|
|
3414
|
+
return React.createElement('div', {},
|
|
3415
|
+
headerBar,
|
|
3416
|
+
React.createElement('div', { className: 'iterate-obs-block' },
|
|
3417
|
+
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
3418
|
+
React.createElement('span', {}, t('开跑预检清单')),
|
|
3419
|
+
React.createElement('span', { className: 'iterate-obs-badge' }, 'v3.12'),
|
|
3420
|
+
),
|
|
3421
|
+
React.createElement('div', { className: 'iterate-obs-block-body' },
|
|
3422
|
+
React.createElement('div', { className: 'iterate-obs-msg' },
|
|
3423
|
+
// Gap #4: separate "pulled and empty" from "never pulled".
|
|
3424
|
+
diskEmptyStateText(toolCalledInSession(session, 'iterate_preflight'), t('预检结果'), getLang()),
|
|
3425
|
+
),
|
|
3426
|
+
React.createElement('div', { className: 'iterate-obs-msg', style: { marginTop: 6 } },
|
|
3427
|
+
t('开跑前先预检:确认验证命令、Git、工作区、陈旧门禁与中断断点都已就绪,避免开跑后才发现环境问题。'),
|
|
3428
|
+
),
|
|
3429
|
+
React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
|
|
3430
|
+
React.createElement('button', {
|
|
3431
|
+
className: 'iterate-btn', 'data-copied': copiedKey === 'preflight-instr' ? '' : undefined,
|
|
3432
|
+
onClick: () => copyInstruction('preflight-instr', preflightInstruction),
|
|
3433
|
+
title: t('复制预检指令'),
|
|
3434
|
+
}, copiedKey === 'preflight-instr' ? t('已复制') : t('复制预检指令')),
|
|
3435
|
+
),
|
|
3436
|
+
),
|
|
3437
|
+
),
|
|
3438
|
+
)
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
// A tool-level failure (bad path, root refused) carries no usable checks —
|
|
3442
|
+
// surface the error instead of a misleading "ready, 0 blocking" summary.
|
|
3443
|
+
if (report && report.ok === false) {
|
|
3444
|
+
return React.createElement('div', {},
|
|
3445
|
+
headerBar,
|
|
3446
|
+
React.createElement('div', { className: 'iterate-obs-block' },
|
|
3447
|
+
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
3448
|
+
React.createElement('span', {}, t('开跑预检清单')),
|
|
3449
|
+
React.createElement('span', { className: 'iterate-obs-badge' }, 'v3.12'),
|
|
3450
|
+
),
|
|
3451
|
+
React.createElement('div', { className: 'iterate-obs-block-body' },
|
|
3452
|
+
React.createElement('div', { className: 'iterate-obs-msg' },
|
|
3453
|
+
tf('预检失败:{a0}', { a0: String(report.error || '') }),
|
|
3454
|
+
),
|
|
3455
|
+
),
|
|
3456
|
+
),
|
|
3457
|
+
)
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
// Localized label + detail per check id. Detail is structured facts from
|
|
3461
|
+
// the tool, so the panel — not the tool — owns the wording/language.
|
|
3462
|
+
const CHECK_META: Record<string, { label: string; detail: (d: Record<string, unknown>) => string }> = {
|
|
3463
|
+
validation: {
|
|
3464
|
+
label: t('验证命令'),
|
|
3465
|
+
detail: (d) => {
|
|
3466
|
+
const n = Number(d.commandCount ?? 0)
|
|
3467
|
+
return n > 0
|
|
3468
|
+
? tf('已配置 {a0} 条验证命令', { a0: n })
|
|
3469
|
+
: t('未配置 validation.commands —— 本轮无法证明修复,属阻断项')
|
|
3470
|
+
},
|
|
3471
|
+
},
|
|
3472
|
+
git: {
|
|
3473
|
+
label: t('Git 仓库'),
|
|
3474
|
+
detail: (d) => (d.isRepo === true ? t('已检测到 Git 仓库') : t('不是 Git 仓库 —— changed-only 范围与 iterate_diff 需要它')),
|
|
3475
|
+
},
|
|
3476
|
+
'dirty-tree': {
|
|
3477
|
+
label: t('工作区'),
|
|
3478
|
+
detail: (d) => {
|
|
3479
|
+
const n = d.changedFiles
|
|
3480
|
+
if (n === null || n === undefined) return t('未提交改动:未知')
|
|
3481
|
+
const num = Number(n)
|
|
3482
|
+
return num > 0
|
|
3483
|
+
? tf('{a0} 个未提交/未跟踪文件 —— 建议先 commit 或 stash', { a0: num })
|
|
3484
|
+
: t('工作区干净')
|
|
3485
|
+
},
|
|
3486
|
+
},
|
|
3487
|
+
'stale-gate': {
|
|
3488
|
+
label: t('陈旧门禁'),
|
|
3489
|
+
detail: (d) => {
|
|
3490
|
+
const s = d.overallStatus
|
|
3491
|
+
if (s === 'fail') return t('磁盘上残留 FAIL 质量门禁 —— 开新轮前先清除')
|
|
3492
|
+
return s ? tf('质量门禁:{a0}', { a0: String(s) }) : t('无持久化质量门禁')
|
|
3493
|
+
},
|
|
3494
|
+
},
|
|
3495
|
+
'interrupted-checkpoint': {
|
|
3496
|
+
label: t('中断断点'),
|
|
3497
|
+
detail: (d) => {
|
|
3498
|
+
if (d.interrupted === true) {
|
|
3499
|
+
const rc = Number(d.resumeCount ?? 0)
|
|
3500
|
+
return tf('存在中断断点(已恢复 {a0} 次)—— 恢复或清除后再开跑', { a0: rc })
|
|
3501
|
+
}
|
|
3502
|
+
return t('无中断断点')
|
|
3503
|
+
},
|
|
3504
|
+
},
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
// Repair action per check id; null = guidance only (no tool can fix it).
|
|
3508
|
+
const fixFor = (id: string): { key: string; label: string; instruction: string } | null => {
|
|
3509
|
+
switch (id) {
|
|
3510
|
+
case 'validation':
|
|
3511
|
+
return { key: 'preflight-fix-validation', label: t('配置验证命令'), instruction: buildConfigFieldInstruction('validation.commands') }
|
|
3512
|
+
case 'stale-gate':
|
|
3513
|
+
return { key: 'preflight-fix-gate', label: t('清除门禁'), instruction: buildQualityGateClearInstruction() }
|
|
3514
|
+
case 'interrupted-checkpoint':
|
|
3515
|
+
return { key: 'preflight-fix-checkpoint', label: t('恢复断点'), instruction: buildCheckpointResumeInstruction() }
|
|
3516
|
+
default:
|
|
3517
|
+
return null
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
const rows = (report.checks || []).map((c, i) => {
|
|
3522
|
+
const id = String(c.id || '')
|
|
3523
|
+
const status = String(c.status || 'warn')
|
|
3524
|
+
const meta = CHECK_META[id]
|
|
3525
|
+
const label = meta ? meta.label : id || '?'
|
|
3526
|
+
const detail = meta ? meta.detail(c.detail || {}) : ''
|
|
3527
|
+
const fix = status !== 'pass' ? fixFor(id) : null
|
|
3528
|
+
return React.createElement('div', { key: `pf-${i}`, className: 'iterate-obs-row' },
|
|
3529
|
+
React.createElement('span', { className: 'iterate-gate', 'data-status': status }, status.toUpperCase()),
|
|
3530
|
+
React.createElement('b', {}, label),
|
|
3531
|
+
React.createElement('span', { className: 'iterate-obs-msg' }, detail),
|
|
3532
|
+
fix
|
|
3533
|
+
? React.createElement('button', {
|
|
3534
|
+
className: 'iterate-btn', 'data-copied': copiedKey === fix.key ? '' : undefined,
|
|
3535
|
+
onClick: () => copyInstruction(fix.key, fix.instruction),
|
|
3536
|
+
title: fix.instruction,
|
|
3537
|
+
}, copiedKey === fix.key ? t('已复制') : fix.label)
|
|
3538
|
+
: null,
|
|
3539
|
+
)
|
|
3540
|
+
})
|
|
3541
|
+
|
|
3542
|
+
return React.createElement('div', {},
|
|
3543
|
+
headerBar,
|
|
3544
|
+
React.createElement('div', { className: 'iterate-obs-block' },
|
|
3545
|
+
React.createElement('div', { className: 'iterate-obs-block-head' },
|
|
3546
|
+
React.createElement('span', {}, t('开跑预检清单')),
|
|
3547
|
+
React.createElement('span', { className: 'iterate-obs-head-meta' },
|
|
3548
|
+
tf('阻断 {a0} · 警告 {a1}', { a0: blocking, a1: warnings }),
|
|
3549
|
+
),
|
|
3550
|
+
),
|
|
3551
|
+
React.createElement('div', { className: 'iterate-obs-block-body' },
|
|
3552
|
+
React.createElement('div', { className: 'iterate-obs-msg' }, t('预检只读,不会修改配置或文件。')),
|
|
3553
|
+
...rows,
|
|
3554
|
+
),
|
|
3555
|
+
),
|
|
3556
|
+
)
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3359
3559
|
// ── Live: real-time secondary-subagent activity stream (newest first) ─────
|
|
3360
3560
|
const renderLive = () => {
|
|
3361
3561
|
const liveEntries = manifest.live || []
|
|
@@ -3440,6 +3640,7 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
3440
3640
|
case 'f8': return renderQualityGate()
|
|
3441
3641
|
case 'f9': return renderExperienceBank()
|
|
3442
3642
|
case 'f10': return renderDefenseEvents()
|
|
3643
|
+
case 'f11': return renderPreflight()
|
|
3443
3644
|
default: return null
|
|
3444
3645
|
}
|
|
3445
3646
|
}
|
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
|
|
75
|
-
// isolated: one failing tool must not take down the other
|
|
76
|
-
// above, or the prompt below (a partial plugin that still
|
|
77
|
-
// teaches the workflow beats an aborted `apply`). Failures are
|
|
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 {
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/preflight.ts — `iterate_preflight` tool.
|
|
3
|
+
*
|
|
4
|
+
* Answers one question BEFORE a run starts: "is this project ready to iterate
|
|
5
|
+
* safely, and what must be fixed first?" It reads only local state (config,
|
|
6
|
+
* git, persisted artifacts, checkpoint) and returns a structured checklist the
|
|
7
|
+
* observatory renders as a preflight panel (F11).
|
|
8
|
+
*
|
|
9
|
+
* Why it exists: the most expensive failure mode is starting a run that cannot
|
|
10
|
+
* prove anything — an empty `validation.commands` means the loop has no way to
|
|
11
|
+
* tell a fix from a regression, and a stale FAIL certificate or a leftover
|
|
12
|
+
* interrupted checkpoint makes the first round lie about its starting state.
|
|
13
|
+
* Preflight surfaces those conditions before the model spends a round
|
|
14
|
+
* discovering them mid-flight.
|
|
15
|
+
*
|
|
16
|
+
* Read-only: never mutates config, files, or artifacts.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
21
|
+
import { resolveProjectRootForExec, loadEffectiveConfig, flattenCommands } from '../config-loader.ts'
|
|
22
|
+
import { runGit } from '../git-scope.ts'
|
|
23
|
+
import { readQualityGate } from './quality-store.ts'
|
|
24
|
+
import { readCheckpoint, computeStatus } from './checkpoint.ts'
|
|
25
|
+
import { readDecisionEntries } from './decision-log.ts'
|
|
26
|
+
import { readRegistry } from './fix.ts'
|
|
27
|
+
import { resultHeadline } from './present.ts'
|
|
28
|
+
|
|
29
|
+
/** A single check's outcome. `fail` blocks a clean run; `warn` is advisory. */
|
|
30
|
+
export type PreflightStatus = 'pass' | 'warn' | 'fail'
|
|
31
|
+
|
|
32
|
+
/** One readiness check. `detail` is structured facts (no prose) so the client
|
|
33
|
+
* can localize the message and offer the right fix action. */
|
|
34
|
+
export interface PreflightCheck {
|
|
35
|
+
/** Stable id the client maps to a localized label + fix action. */
|
|
36
|
+
id: string
|
|
37
|
+
status: PreflightStatus
|
|
38
|
+
detail: Record<string, unknown>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The full preflight report. */
|
|
42
|
+
export interface PreflightReport {
|
|
43
|
+
ok: boolean
|
|
44
|
+
/** True when no check is `fail` (warnings do not block). */
|
|
45
|
+
ready: boolean
|
|
46
|
+
/** Count of `fail` checks. */
|
|
47
|
+
blocking: number
|
|
48
|
+
/** Count of `warn` checks. */
|
|
49
|
+
warnings: number
|
|
50
|
+
checks: PreflightCheck[]
|
|
51
|
+
error?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Facts gathered from disk/git before the pure decision runs. */
|
|
55
|
+
export interface PreflightInput {
|
|
56
|
+
/** Number of configured validation commands (flattened across modules). */
|
|
57
|
+
validationCommandCount: number
|
|
58
|
+
/** Whether the project root is inside a git work tree. */
|
|
59
|
+
isGitRepo: boolean
|
|
60
|
+
/** Uncommitted/untracked file count, or null when git could not answer. */
|
|
61
|
+
changedFiles: number | null
|
|
62
|
+
/** Persisted quality-gate overall status, or null when none is stored. */
|
|
63
|
+
gateOverallStatus: 'pass' | 'fail' | 'pending' | null
|
|
64
|
+
/** True when a leftover checkpoint is genuinely interrupted (nothing newer). */
|
|
65
|
+
interrupted: boolean
|
|
66
|
+
/** How many times the leftover checkpoint has already been resumed. */
|
|
67
|
+
resumeCount: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the readiness checklist from already-gathered facts (pure).
|
|
72
|
+
*
|
|
73
|
+
* The five checks and why each exists:
|
|
74
|
+
* 1. `validation` — empty command set ⇒ the loop cannot prove a
|
|
75
|
+
* fix; the product is fail-closed here, so this
|
|
76
|
+
* is a BLOCKING `fail`, not a warning.
|
|
77
|
+
* 2. `git` — changed-only scope + `iterate_diff` need a
|
|
78
|
+
* repository; full-scope review still works, so
|
|
79
|
+
* absent is a `warn`.
|
|
80
|
+
* 3. `dirty-tree` — a run over uncommitted work mixes the user's
|
|
81
|
+
* WIP with the plugin's fixes; advisory `warn`.
|
|
82
|
+
* Unknown (null) is treated as pass so a git
|
|
83
|
+
* hiccup never fabricates a warning.
|
|
84
|
+
* 4. `stale-gate` — a persisted FAIL certificate makes the first
|
|
85
|
+
* round read stale; `warn` + clear before a
|
|
86
|
+
* fresh run.
|
|
87
|
+
* 5. `interrupted-checkpoint`— a leftover checkpoint from a run that never
|
|
88
|
+
* progressed; `warn` + resume or clear.
|
|
89
|
+
*/
|
|
90
|
+
export function buildPreflightReport(input: PreflightInput): PreflightReport {
|
|
91
|
+
const checks: PreflightCheck[] = [
|
|
92
|
+
{
|
|
93
|
+
id: 'validation',
|
|
94
|
+
status: input.validationCommandCount > 0 ? 'pass' : 'fail',
|
|
95
|
+
detail: { commandCount: input.validationCommandCount },
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'git',
|
|
99
|
+
status: input.isGitRepo ? 'pass' : 'warn',
|
|
100
|
+
detail: { isRepo: input.isGitRepo },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'dirty-tree',
|
|
104
|
+
status: input.changedFiles !== null && input.changedFiles > 0 ? 'warn' : 'pass',
|
|
105
|
+
detail: { changedFiles: input.changedFiles },
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'stale-gate',
|
|
109
|
+
status: input.gateOverallStatus === 'fail' ? 'warn' : 'pass',
|
|
110
|
+
detail: { overallStatus: input.gateOverallStatus },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'interrupted-checkpoint',
|
|
114
|
+
status: input.interrupted ? 'warn' : 'pass',
|
|
115
|
+
detail: { interrupted: input.interrupted, resumeCount: input.resumeCount },
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
const blocking = checks.filter((c) => c.status === 'fail').length
|
|
120
|
+
const warnings = checks.filter((c) => c.status === 'warn').length
|
|
121
|
+
return { ok: true, ready: blocking === 0, blocking, warnings, checks }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Human-readable (English, for the model-facing render) description per check. */
|
|
125
|
+
function describeCheck(id: string, detail: Record<string, unknown>): string {
|
|
126
|
+
switch (id) {
|
|
127
|
+
case 'validation': {
|
|
128
|
+
const n = Number(detail.commandCount ?? 0)
|
|
129
|
+
return n > 0
|
|
130
|
+
? `${n} validation command(s) configured`
|
|
131
|
+
: 'no validation.commands configured — the run cannot prove fixes (blocking)'
|
|
132
|
+
}
|
|
133
|
+
case 'git':
|
|
134
|
+
return detail.isRepo === true
|
|
135
|
+
? 'git repository detected'
|
|
136
|
+
: 'not a git repository — changed-only scope and iterate_diff need one'
|
|
137
|
+
case 'dirty-tree': {
|
|
138
|
+
const n = detail.changedFiles
|
|
139
|
+
if (n === null || n === undefined) return 'uncommitted changes: unknown'
|
|
140
|
+
const num = Number(n)
|
|
141
|
+
return num > 0
|
|
142
|
+
? `${num} uncommitted/untracked file(s) — commit or stash before a run`
|
|
143
|
+
: 'working tree clean'
|
|
144
|
+
}
|
|
145
|
+
case 'stale-gate': {
|
|
146
|
+
const s = detail.overallStatus
|
|
147
|
+
if (s === 'fail') return 'a stale FAIL quality gate is persisted — clear it before a fresh run'
|
|
148
|
+
return s ? `quality gate: ${String(s)}` : 'no persisted quality gate'
|
|
149
|
+
}
|
|
150
|
+
case 'interrupted-checkpoint': {
|
|
151
|
+
if (detail.interrupted === true) {
|
|
152
|
+
const rc = Number(detail.resumeCount ?? 0)
|
|
153
|
+
return `an interrupted checkpoint is waiting (resumed ${rc}x) — resume or clear it`
|
|
154
|
+
}
|
|
155
|
+
return 'no interrupted checkpoint'
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
return ''
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Register the `iterate_preflight` tool.
|
|
164
|
+
*/
|
|
165
|
+
export function registerPreflightTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
166
|
+
ctx.tools.register(
|
|
167
|
+
defineTool({
|
|
168
|
+
name: 'iterate_preflight',
|
|
169
|
+
// Pure read of on-disk state + git → safe to join a parallel group.
|
|
170
|
+
isConcurrencySafe: () => true,
|
|
171
|
+
presentResult: (_args, result) => {
|
|
172
|
+
const title = resultHeadline(result)
|
|
173
|
+
return title ? { card: 'generic', title } : undefined
|
|
174
|
+
},
|
|
175
|
+
description:
|
|
176
|
+
'Read-only readiness check to run BEFORE starting an iterate run. Returns a checklist: whether ' +
|
|
177
|
+
'validation.commands is configured (an empty set means the loop cannot prove fixes — blocking), ' +
|
|
178
|
+
'whether the project is a git repository, how many files are uncommitted, whether a stale FAIL ' +
|
|
179
|
+
'quality gate is persisted, and whether an interrupted checkpoint is waiting. Use it before a fresh ' +
|
|
180
|
+
'run so you fix the environment first instead of discovering it mid-round.',
|
|
181
|
+
parameters: {
|
|
182
|
+
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
output: {
|
|
186
|
+
schema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
additionalProperties: false,
|
|
189
|
+
properties: {
|
|
190
|
+
ok: { type: 'boolean', required: true },
|
|
191
|
+
ready: { type: 'boolean' },
|
|
192
|
+
blocking: { type: 'integer' },
|
|
193
|
+
warnings: { type: 'integer' },
|
|
194
|
+
checks: { type: 'json', description: 'Per-check outcomes: { id, status: pass|warn|fail, detail }.' },
|
|
195
|
+
error: { type: 'string' },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
render: (_args, value) => {
|
|
199
|
+
if (!value.ok) return [{ type: 'text', text: `preflight failed: ${value.error}` }]
|
|
200
|
+
const checks = Array.isArray(value.checks) ? value.checks : []
|
|
201
|
+
const warnings = Number(value.warnings ?? 0)
|
|
202
|
+
const noun = warnings === 1 ? 'warning' : 'warnings'
|
|
203
|
+
const head = value.ready
|
|
204
|
+
? `Preflight: READY (${Number(value.blocking ?? 0)} blocking, ${warnings} ${noun})`
|
|
205
|
+
: `Preflight: NOT READY (${Number(value.blocking ?? 0)} blocking, ${warnings} ${noun})`
|
|
206
|
+
const lines = checks.map((c) => {
|
|
207
|
+
const check = c as { id?: unknown; status?: unknown; detail?: Record<string, unknown> }
|
|
208
|
+
const id = String(check.id ?? '?')
|
|
209
|
+
const status = String(check.status ?? '?').toUpperCase()
|
|
210
|
+
return ` ${status} ${id} — ${describeCheck(id, check.detail ?? {})}`
|
|
211
|
+
})
|
|
212
|
+
return [{ type: 'text', text: [head, ...lines].join('\n') }]
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
async execute(args, exec) {
|
|
217
|
+
const resolved = resolveProjectRootForExec(exec, args.path)
|
|
218
|
+
if (!resolved.ok) {
|
|
219
|
+
return { ok: false, ready: false, blocking: 0, warnings: 0, checks: [] as unknown as JsonValue, error: resolved.reason }
|
|
220
|
+
}
|
|
221
|
+
const projectRoot = resolved.root
|
|
222
|
+
|
|
223
|
+
// 1. validation.commands — the load-bearing check.
|
|
224
|
+
let validationCommandCount = 0
|
|
225
|
+
try {
|
|
226
|
+
const effective = loadEffectiveConfig(projectRoot)
|
|
227
|
+
validationCommandCount = flattenCommands(effective.config.validation?.commands).length
|
|
228
|
+
} catch {
|
|
229
|
+
validationCommandCount = 0
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 2/3. git repo + dirty tree (best-effort; any failure degrades to unknown).
|
|
233
|
+
let isGitRepo = false
|
|
234
|
+
let changedFiles: number | null = null
|
|
235
|
+
try {
|
|
236
|
+
const inside = await runGit(['rev-parse', '--is-inside-work-tree'], projectRoot, { signal: exec.signal })
|
|
237
|
+
isGitRepo = inside.ok && inside.stdout.trim() === 'true'
|
|
238
|
+
if (isGitRepo) {
|
|
239
|
+
const status = await runGit(['status', '--porcelain', '-z'], projectRoot, { signal: exec.signal })
|
|
240
|
+
if (status.ok) {
|
|
241
|
+
// `-z` NUL-terminates each entry; a rename emits two NUL-separated
|
|
242
|
+
// paths for one entry, but counting non-empty tokens is still a
|
|
243
|
+
// faithful "there is uncommitted work" signal (>=1 when dirty).
|
|
244
|
+
changedFiles = status.stdout.split('\u0000').filter((s) => s.length > 0).length
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
isGitRepo = false
|
|
249
|
+
changedFiles = null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 4. stale quality gate.
|
|
253
|
+
let gateOverallStatus: 'pass' | 'fail' | 'pending' | null = null
|
|
254
|
+
try {
|
|
255
|
+
gateOverallStatus = readQualityGate(projectRoot).overallStatus ?? null
|
|
256
|
+
} catch {
|
|
257
|
+
gateOverallStatus = null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 5. interrupted checkpoint (reuse the exact status logic so preflight
|
|
261
|
+
// and iterate_status can never disagree about "interrupted").
|
|
262
|
+
let interrupted = false
|
|
263
|
+
let resumeCount = 0
|
|
264
|
+
try {
|
|
265
|
+
const status = computeStatus({
|
|
266
|
+
checkpoint: readCheckpoint(projectRoot),
|
|
267
|
+
decisionEntries: readDecisionEntries(projectRoot),
|
|
268
|
+
fixRegistry: readRegistry(projectRoot),
|
|
269
|
+
})
|
|
270
|
+
interrupted = status.interrupted
|
|
271
|
+
resumeCount = status.resumeCount
|
|
272
|
+
} catch {
|
|
273
|
+
interrupted = false
|
|
274
|
+
resumeCount = 0
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const report = buildPreflightReport({
|
|
278
|
+
validationCommandCount,
|
|
279
|
+
isGitRepo,
|
|
280
|
+
changedFiles,
|
|
281
|
+
gateOverallStatus,
|
|
282
|
+
interrupted,
|
|
283
|
+
resumeCount,
|
|
284
|
+
})
|
|
285
|
+
return { ...report, checks: report.checks as unknown as JsonValue }
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
)
|
|
289
|
+
}
|