iterate-plugin 3.5.7 → 3.6.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 (75) hide show
  1. package/README.md +64 -20
  2. package/README.zh-CN.md +53 -17
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +509 -34
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +165 -0
  9. package/dist/index.js +54 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +52 -13
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +55 -13
  17. package/dist/skill-prompt.js +396 -66
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +111 -27
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +150 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/lib/client.js +945 -181
  38. package/lib/parse.js +760 -57
  39. package/package.json +14 -12
  40. package/src/approval-gate.ts +160 -23
  41. package/src/atomic-fs.ts +152 -17
  42. package/src/client/index.ts +600 -129
  43. package/src/config-loader.ts +542 -35
  44. package/src/config-write.ts +108 -26
  45. package/src/evidence.ts +194 -92
  46. package/src/file-lock.ts +184 -0
  47. package/src/index.ts +53 -29
  48. package/src/jobs.ts +63 -12
  49. package/src/live.ts +48 -11
  50. package/src/meta-review.ts +151 -34
  51. package/src/method-scope.ts +31 -8
  52. package/src/review-scope.ts +33 -10
  53. package/src/review.ts +96 -17
  54. package/src/session-hooks.ts +54 -12
  55. package/src/skill-prompt.ts +396 -66
  56. package/src/tools/checkpoint.ts +81 -19
  57. package/src/tools/config.ts +44 -2
  58. package/src/tools/context.ts +25 -5
  59. package/src/tools/decision-log.ts +115 -22
  60. package/src/tools/defense-events.ts +87 -31
  61. package/src/tools/defense-store.ts +30 -3
  62. package/src/tools/experience-bank.ts +112 -44
  63. package/src/tools/experience-store.ts +54 -5
  64. package/src/tools/fix.ts +258 -20
  65. package/src/tools/history.ts +74 -15
  66. package/src/tools/present.ts +89 -0
  67. package/src/tools/prune.ts +154 -51
  68. package/src/tools/quality-gate.ts +57 -2
  69. package/src/tools/quality-store.ts +18 -1
  70. package/src/tools/review.ts +155 -6
  71. package/src/tools/transcript.ts +178 -36
  72. package/src/tools/triage.ts +70 -13
  73. package/src/tools/validate.ts +138 -32
  74. package/src/transcript.ts +279 -4
  75. package/src/types.ts +29 -1
@@ -82,6 +82,28 @@ import {
82
82
  SEVERITY_LABEL,
83
83
  SEVERITY_COLOR,
84
84
  stoppedReasonLabel,
85
+ // ── gap fixes ──
86
+ START_INSTRUCTIONS, // #1: registered instruction set (test-locked)
87
+ dashboardRunState, // #5: running vs onboarding empty state
88
+ buildRoundComparison, // #13: cross-round comparison table
89
+ buildDiskSnapshotInstruction, // #4: pull `.iterate/` artifacts into the stream
90
+ diskEmptyStateText, // #4: "not pulled" vs "pulled and empty"
91
+ toolCalledInSession, // #4: which read-side tools ran this session
92
+ buildFixInstruction, // #10: single-finding fix payload w/ content + round
93
+ buildAssignFixesInstruction, // #10: "one call per finding" batch wording
94
+ buildArchitecturalFixInstruction, // #10: force:true still needs 4 required fields
95
+ buildCheckpointResumeInstruction, // #10: resume takes {operation} only
96
+ buildCheckpointClearInstruction, // #10: clear takes {operation} only
97
+ buildRollbackInstruction, // #10: shared rollback payload
98
+ buildQualityGateQueryInstruction, // #10: JSON-carrying query instruction
99
+ buildQualityGateClearInstruction, // #10: JSON-carrying clear instruction
100
+ buildTriageReadbackInstruction, // #7: close the write-back loop
101
+ buildExperienceListInstruction, // #3/#10: JSON list payload for F9
102
+ buildDefenseEventsListInstruction, // #3/#10: JSON list payload for F10
103
+ buildConfigFieldInstruction, // #8: field → iterate_config write instruction
104
+ configFieldByKey, // #8: field lookup for the settings picker
105
+ CONFIG_EDIT_FIELDS, // #8: field list incl. validation.commands
106
+ DISK_SNAPSHOT_SOURCES, // #4/#3: read-side tools listed by the pull instruction
85
107
  } from '../../lib/parse.js'
86
108
 
87
109
  // ─── Module contract ─────────────────────────────────────────────────────────
@@ -247,6 +269,17 @@ interface ObsManifest {
247
269
  approval?: ObsApproval
248
270
  /** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
249
271
  stoppedReason?: string | null
272
+ /** Gap #6: per-round validation rows persisted by `iterate_transcript capture`. */
273
+ validations?: ObsValidation[]
274
+ }
275
+
276
+ /** One `iterate_validate` outcome for a round (gap #6 contract). */
277
+ interface ObsValidation {
278
+ round?: number
279
+ command?: string
280
+ exitCode?: number | null
281
+ allowed?: boolean
282
+ rejectReason?: string
250
283
  }
251
284
 
252
285
  // ─── Quality command center data types (v3.1+; mirrors src/types.ts) ────────
@@ -426,6 +459,7 @@ const ITERATE_CSS = `
426
459
  }
427
460
  .iterate-btn[data-primary] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
428
461
  .iterate-btn[data-copied] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); }
462
+ .iterate-btn[data-failed], .iterate-cmd[data-failed] { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); }
429
463
  .iterate-payload { width: 100%; margin-top: 8px; padding: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 11px; white-space: pre-wrap; }
430
464
 
431
465
  .iterate-stats { margin: 10px 0; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); padding: 12px 14px; }
@@ -610,6 +644,16 @@ const ITERATE_CSS = `
610
644
  .iterate-obs-block-head[data-click] { cursor: pointer; }
611
645
  .iterate-obs-block-body { padding: 8px 10px; }
612
646
  .iterate-obs-chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 6px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
647
+ /* Gap #6: validation / gate status colors (pass=green, fail=red). */
648
+ .iterate-obs-chip[data-status="pass"], .iterate-obs-badge[data-status="pass"] { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); }
649
+ .iterate-obs-chip[data-status="fail"], .iterate-obs-badge[data-status="fail"] { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 28%, transparent); }
650
+ .iterate-obs-row[data-status="fail"] { color: var(--dsw-alias-state-error-primary); }
651
+ .iterate-obs-row[data-highlight] { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 14%, transparent); border-radius: 6px; padding-left: 6px; padding-right: 6px; border-bottom-color: transparent; font-weight: 600; }
652
+ /* Gap #13: cross-round comparison table. */
653
+ .iterate-obs-table { width: 100%; border-collapse: collapse; font-size: 11px; }
654
+ .iterate-obs-table th, .iterate-obs-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--dsw-alias-border-l1); white-space: nowrap; }
655
+ .iterate-obs-table th { color: var(--dsw-alias-label-secondary); font-weight: 600; }
656
+ .iterate-obs-table td { color: var(--dsw-alias-label-primary); }
613
657
  .iterate-obs-msg { padding: 2px 0; color: var(--dsw-alias-label-secondary); line-height: 1.5; font-size: 11px; word-break: break-word; }
614
658
  .iterate-obs-file { font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 10.5px; color: var(--dsw-alias-label-secondary); word-break: break-all; }
615
659
  .iterate-obs-bar { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); flex-wrap: wrap; }
@@ -873,15 +917,37 @@ function TrendChart({ points }: { points: Array<{ round: number; count: number }
873
917
  }
874
918
 
875
919
  /** Start-iteration launcher shown on the empty dashboard: copies a runnable
876
- * command (the browser cannot invoke harness tools directly, so every action
877
- * follows the panel's copy-to-command pattern). */
920
+ * instruction (the browser cannot invoke harness tools directly, so every
921
+ * action follows the panel's copy-to-command pattern).
922
+ *
923
+ * Gap #1: the plugin registers NO dsh command (`src/index.ts` only registers
924
+ * 17 tools + a system prompt), so `/iterate` never existed — the copied text
925
+ * is now a natural-language instruction from `START_INSTRUCTIONS`, which the
926
+ * model resolves through the `workflow` tool exactly as the skill prompt
927
+ * teaches. Label/tooltip say "指令", never "命令". */
878
928
  function StartIterationButton() {
879
929
  const [copied, setCopied] = React.useState<string | null>(null)
930
+ const [failed, setFailed] = React.useState<string | null>(null)
931
+ const timers = React.useRef<number[]>([])
932
+ React.useEffect(
933
+ () => () => {
934
+ for (const t of timers.current) clearTimeout(t)
935
+ timers.current = []
936
+ },
937
+ [],
938
+ )
880
939
  const copy = (key: string, text: string) => {
881
940
  copyText(text).then((ok) => {
882
941
  if (ok) {
942
+ setFailed(null)
883
943
  setCopied(key)
884
- setTimeout(() => setCopied((cur) => (cur === key ? null : cur)), 1600)
944
+ const t = window.setTimeout(() => setCopied((cur) => (cur === key ? null : cur)), 1600)
945
+ timers.current.push(t)
946
+ } else {
947
+ // Copy failure must be visible, not silent — otherwise the button
948
+ // looks like a no-op and the user never learns the instruction text.
949
+ setCopied(null)
950
+ setFailed(key)
885
951
  }
886
952
  })
887
953
  }
@@ -891,14 +957,15 @@ function StartIterationButton() {
891
957
  className: 'iterate-cmd',
892
958
  'data-primary': primary ? '' : undefined,
893
959
  'data-copied': copied === key ? '' : undefined,
960
+ 'data-failed': failed === key ? '' : undefined,
894
961
  onClick: () => copy(key, command),
895
962
  title,
896
- }, copied === key ? '已复制' : label)
963
+ }, copied === key ? '已复制' : failed === key ? '复制失败' : label)
897
964
  return React.createElement(
898
965
  'span',
899
966
  { className: 'iterate-dashboard-launch' },
900
- btn('start-full', '完整迭代', '/iterate', true, '复制启动命令:完整「审查 → 修复 → 验证」闭环'),
901
- btn('start-review', '仅评审', '/iterate review-only', false, '复制启动命令:只审查不修改(dry-run)'),
967
+ btn('start-full', '完整迭代', START_INSTRUCTIONS.full, true, '复制启动指令:完整「审查 → 修复 → 验证 → 复盘」闭环(粘贴给模型即可)'),
968
+ btn('start-review', '仅评审', START_INSTRUCTIONS.reviewOnly, false, '复制启动指令:只审查不修改(dry-run)(粘贴给模型即可)'),
902
969
  )
903
970
  }
904
971
 
@@ -926,13 +993,45 @@ function ConvergenceDashboard(props: SlotProps) {
926
993
  }, [report && hashReport(report) + ':' + getCurrentRound(report)])
927
994
 
928
995
  if (!report) {
996
+ // Gap #5: split the old single empty state in two. A run that is already
997
+ // in flight has no aggregated report yet (round 1 is still in
998
+ // review → fix → validate), but showing "运行一次评审后…" + a start button
999
+ // mid-run tells the user nothing is happening — and invites a second
1000
+ // concurrent start. The transcript alone carries round + phase.
1001
+ const runState = dashboardRunState(latestTranscript(session))
1002
+ if (runState.state === 'running') {
1003
+ return React.createElement(
1004
+ 'div',
1005
+ { 'data-iterate-root': '', 'data-iterate': 'dashboard', 'data-run-state': 'running', className: 'iterate-dashboard iterate-dashboard-empty' },
1006
+ React.createElement('span', { className: 'iterate-round-badge', 'data-live': '' }, '运行中'),
1007
+ React.createElement(
1008
+ 'span',
1009
+ { className: 'iterate-empty-hint' },
1010
+ `第 ${String(runState.round)} 轮进行中${runState.phase ? ` · 阶段「${runState.phase}」` : ''}——首轮汇总报告落地后,这里会显示收敛进度与发现统计。`,
1011
+ ),
1012
+ // No start button while a run is active.
1013
+ )
1014
+ }
1015
+ if (runState.state === 'done') {
1016
+ return React.createElement(
1017
+ 'div',
1018
+ { 'data-iterate-root': '', 'data-iterate': 'dashboard', 'data-run-state': 'done', className: 'iterate-dashboard iterate-dashboard-empty' },
1019
+ React.createElement('span', { className: 'iterate-round-badge' }, '已结束'),
1020
+ React.createElement(
1021
+ 'span',
1022
+ { className: 'iterate-empty-hint' },
1023
+ `上次迭代在第 ${String(runState.round)} 轮后停止${runState.stoppedReason ? `(${stoppedReasonLabel(runState.stoppedReason)})` : ''},但没有可展示的汇总报告。`,
1024
+ ),
1025
+ React.createElement(StartIterationButton, null),
1026
+ )
1027
+ }
929
1028
  // Empty/onboarding state: first-time users otherwise see nothing and have
930
- // no idea the plugin exists or how to start. Provide a one-click command
931
- // that launches the loop (copied for paste — the browser cannot call
932
- // harness tools directly).
1029
+ // no idea the plugin exists or how to start. Provide a one-click copy of
1030
+ // the startup instruction (the browser cannot call harness tools
1031
+ // directly, and dsh exposes no command API to plugins).
933
1032
  return React.createElement(
934
1033
  'div',
935
- { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard iterate-dashboard-empty' },
1034
+ { 'data-iterate-root': '', 'data-iterate': 'dashboard', 'data-run-state': 'empty', className: 'iterate-dashboard iterate-dashboard-empty' },
936
1035
  React.createElement('span', { className: 'iterate-round-badge' }, 'iterate'),
937
1036
  React.createElement('span', { className: 'iterate-empty-hint' }, '运行一次评审后,这里会显示收敛进度与发现统计。'),
938
1037
  React.createElement(StartIterationButton, null),
@@ -1176,6 +1275,31 @@ function TriagePanel(props: SlotProps) {
1176
1275
  const [selectAll, setSelectAll] = React.useState(false)
1177
1276
  // v3.0: Command mode for native action buttons
1178
1277
  const [cmdCopied, setCmdCopied] = React.useState<string | null>(null)
1278
+ const [cmdFailed, setCmdFailed] = React.useState<string | null>(null)
1279
+ // Audit: pending copy-reset timers must not outlive the panel (a unmounted
1280
+ // setState is a React warning / leaked timer).
1281
+ const cmdTimers = React.useRef<number[]>([])
1282
+ React.useEffect(() => () => {
1283
+ for (const t of cmdTimers.current) clearTimeout(t)
1284
+ cmdTimers.current = []
1285
+ }, [])
1286
+
1287
+ /** Copy + flip `cmdCopied` for 1.6s, or surface `cmdFailed` on clipboard
1288
+ * denial so the button never looks like a dead control. */
1289
+ const copyWithFeedback = (text: string, key: string) => {
1290
+ copyText(text).then((ok) => {
1291
+ if (ok) {
1292
+ setCmdFailed(null)
1293
+ setCmdCopied(key)
1294
+ cmdTimers.current.push(window.setTimeout(() => setCmdCopied((cur) => (cur === key ? null : cur)), 1600))
1295
+ } else {
1296
+ setCmdCopied(null)
1297
+ setCmdFailed(key)
1298
+ // Fall back to the inline payload box so the text stays copyable by hand.
1299
+ setPayload(text)
1300
+ }
1301
+ })
1302
+ }
1179
1303
 
1180
1304
  /** Persist the verdicts to localStorage whenever they change. Kept OUT of
1181
1305
  * the state updater (updaters must stay pure — React may double-invoke them
@@ -1263,26 +1387,18 @@ function TriagePanel(props: SlotProps) {
1263
1387
 
1264
1388
  // v3.0: Command handlers for native action buttons
1265
1389
  const doApproveArchitecturalFix = () => {
1266
- // Architectural findings exceed the atomic threshold, so "approving" one
1267
- // means telling the model to apply it as a deliberate forced change via
1268
- // iterate_fix with force:true (is_architectural is not a real parameter).
1269
- const cmd = '请调用 `iterate_fix`,对本项目剩余架构型 finding 生成修复内容并设置 force: true 应用(架构修复允许超过 atomic 阈值)'
1270
- copyText(cmd).then((ok) => {
1271
- if (ok) {
1272
- setCmdCopied('approve-arch')
1273
- setTimeout(() => setCmdCopied(null), 1600)
1274
- }
1275
- })
1390
+ // Gap #10: `force: true` exists on `iterate_fix`, but the tool still
1391
+ // requires file / content / finding / round — the old copy text implied a
1392
+ // lone force flag was enough. The builder names all four and marks the two
1393
+ // the model must fill in.
1394
+ const cmd = buildArchitecturalFixInstruction(findings as IterateFinding[])
1395
+ if (!cmd) return
1396
+ copyWithFeedback(cmd, 'approve-arch')
1276
1397
  }
1277
1398
 
1278
1399
  const doTriggerNewRound = () => {
1279
1400
  const cmd = '请继续执行下一轮迭代审查'
1280
- copyText(cmd).then((ok) => {
1281
- if (ok) {
1282
- setCmdCopied('new-round')
1283
- setTimeout(() => setCmdCopied(null), 1600)
1284
- }
1285
- })
1401
+ copyWithFeedback(cmd, 'new-round')
1286
1402
  }
1287
1403
 
1288
1404
  const doRollbackToCheckpoint = () => {
@@ -1290,39 +1406,22 @@ function TriagePanel(props: SlotProps) {
1290
1406
  // only); reverting applied fixes is done via iterate_rollback by fix id,
1291
1407
  // so the paste-able instruction must point there instead.
1292
1408
  const cmd = '请回滚上一轮迭代的修复:调用 `iterate_history` 查看本轮 fix id,再用 `iterate_rollback` 逐个撤销这些修复'
1293
- copyText(cmd).then((ok) => {
1294
- if (ok) {
1295
- setCmdCopied('rollback')
1296
- setTimeout(() => setCmdCopied(null), 1600)
1297
- }
1298
- })
1409
+ copyWithFeedback(cmd, 'rollback')
1299
1410
  }
1300
1411
 
1301
1412
  // v3.1+ §8: assign the currently scoped findings to the model for fixing.
1302
- // Scoping mirrors the batch buttons (visible when selectAll is off, all
1303
- // otherwise); produces one paste-able iterate_fix instruction carrying each
1304
- // finding's file/line/dimension/severity/summary.
1413
+ // Gap #10: `iterate_fix` takes ONE finding + required `content` (full new
1414
+ // file) + required `round` — never an array — so the payload now says so
1415
+ // explicitly instead of handing the model a raw array to translate.
1305
1416
  const doAssignFindings = () => {
1306
1417
  const targetAll = selectAll
1307
1418
  const scopeIndices = targetAll ? allIndices : indices
1308
1419
  const scopeFindings = (findings as IterateFinding[])
1309
1420
  .filter((f, i) => scopeIndices.includes(i))
1310
- .map((f) => ({
1311
- file: String(f.file || ''),
1312
- ...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
1313
- dimension: String(f.dimension || ''),
1314
- severity: String(f.severity || ''),
1315
- summary: String(f.summary || ''),
1316
- ...(f.suggested_fix ? { suggested_fix: String(f.suggested_fix) } : {}),
1317
- }))
1318
1421
  if (scopeFindings.length === 0) return
1319
- const cmd = `请调用 \`iterate_fix\` 指派并修复以下 findings:\n\n\`\`\`json\n${JSON.stringify(scopeFindings, null, 2)}\n\`\`\``
1320
- copyText(cmd).then((ok) => {
1321
- if (ok) {
1322
- setCmdCopied('assign')
1323
- setTimeout(() => setCmdCopied(null), 1600)
1324
- }
1325
- })
1422
+ const cmd = buildAssignFixesInstruction(scopeFindings)
1423
+ if (!cmd) return
1424
+ copyWithFeedback(cmd, 'assign')
1326
1425
  }
1327
1426
 
1328
1427
  const doCopyYaml = () => {
@@ -1331,23 +1430,45 @@ function TriagePanel(props: SlotProps) {
1331
1430
  copyText(yaml).then((ok) => {
1332
1431
  if (ok) {
1333
1432
  setCopied(true)
1334
- setTimeout(() => setCopied(false), 1600)
1433
+ cmdTimers.current.push(window.setTimeout(() => setCopied(false), 1600))
1335
1434
  } else {
1336
1435
  // Copy failed (permissions/unsupported) — reveal the payload as a
1337
1436
  // manual-copy fallback instead of claiming success.
1338
1437
  setPayload(yaml)
1438
+ setCmdFailed('yaml')
1339
1439
  }
1340
1440
  })
1341
1441
  }
1342
1442
 
1443
+ // Gap #7: the SAFE path is `iterate_triage apply` (validation, dedupe,
1444
+ // backup, automatic rollback) — so it is now the primary action, and it
1445
+ // tells the user what happens after they paste.
1343
1446
  const doBuildInstruction = () => {
1344
1447
  const text = buildApplyInstruction(ignored)
1448
+ if (!text) return
1449
+ setPayload(text)
1450
+ copyWithFeedback(text, 'apply')
1451
+ }
1452
+
1453
+ // Gap #7: closing the write-back loop — copying is not writing, so offer a
1454
+ // readback instruction (`iterate_triage list`) to verify what landed.
1455
+ const doReadback = () => {
1456
+ const text = buildTriageReadbackInstruction()
1345
1457
  setPayload(text)
1346
- if (text) copyText(text)
1458
+ copyWithFeedback(text, 'readback')
1347
1459
  }
1348
1460
 
1349
- const rows = (filtered as IterateFinding[]).map((finding, i) => {
1350
- const index = indices[i] as number
1461
+ // Audit: `filtered` and `indices` are produced together, but an
1462
+ // out-of-range slot would otherwise render a row keyed `undefined` whose
1463
+ // verdict writes to `verdicts["undefined"]`. Pair them up-front so the
1464
+ // mapping index can never drift from the original findings index.
1465
+ const rowPairs: Array<{ index: number; finding: IterateFinding }> = []
1466
+ for (let i = 0; i < filtered.length; i++) {
1467
+ const idx = indices[i]
1468
+ const f = filtered[i]
1469
+ if (typeof idx === 'number' && f) rowPairs.push({ index: idx, finding: f as IterateFinding })
1470
+ }
1471
+ const rows = rowPairs.map(({ index, finding }) => {
1351
1472
  const severity = finding.severity || 'low'
1352
1473
  const verdict = verdicts[String(index)] || 'keep'
1353
1474
  const isSelected = selected === index
@@ -1445,43 +1566,74 @@ function TriagePanel(props: SlotProps) {
1445
1566
  ...rows,
1446
1567
  React.createElement('div', { className: 'iterate-triage-foot' },
1447
1568
  React.createElement('span', {}, `y ${counts.keep} · n ${counts.skip} · a ${counts.ignore} · 待写回 known_intentional:${ignoredCount} 条`),
1448
- React.createElement('span', { style: { display: 'flex', gap: 6 } },
1569
+ React.createElement('span', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
1570
+ // Gap #7: the apply instruction (safe path) is PRIMARY; the raw YAML
1571
+ // is demoted to an explicit "just the snippet" escape hatch.
1572
+ React.createElement('button', {
1573
+ className: 'iterate-btn', 'data-primary': '',
1574
+ 'data-copied': cmdCopied === 'apply' ? '' : undefined,
1575
+ 'data-failed': cmdFailed === 'apply' ? '' : undefined,
1576
+ onClick: doBuildInstruction, disabled: ignoredCount === 0,
1577
+ title: ignoredCount === 0
1578
+ ? '当前没有标记为「已知有意」的 finding'
1579
+ : '复制应用指令:粘贴后模型会调用 iterate_triage apply(校验、去重、写入前备份、失败自动回滚)',
1580
+ }, cmdCopied === 'apply' ? '已复制' : cmdFailed === 'apply' ? '复制失败' : `生成应用指令${ignoredCount > 0 ? `(${ignoredCount})` : ''}`),
1449
1581
  React.createElement('button', {
1450
- className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined,
1582
+ className: 'iterate-btn',
1583
+ 'data-copied': copied ? '' : undefined,
1584
+ 'data-failed': cmdFailed === 'yaml' ? '' : undefined,
1451
1585
  onClick: doCopyYaml, disabled: ignoredCount === 0,
1452
- title: ignoredCount === 0 ? '当前没有标记为「已知有意」的 finding' : '复制 known_intentional YAML',
1453
- }, copied ? '已复制' : `复制 known_intentional${ignoredCount > 0 ? `(${ignoredCount})` : ''}`),
1586
+ title: ignoredCount === 0
1587
+ ? '当前没有标记为「已知有意」的 finding'
1588
+ : '只复制 known_intentional YAML 片段(需自行粘到 iterate.config.yaml 的 personalization.known_intentional 下)',
1589
+ }, copied ? '已复制' : cmdFailed === 'yaml' ? '复制失败' : '仅复制 YAML 片段'),
1454
1590
  React.createElement('button', {
1455
- className: 'iterate-btn', onClick: doBuildInstruction, disabled: ignoredCount === 0,
1456
- title: ignoredCount === 0 ? '当前没有标记为「已知有意」的 finding' : '生成 iterate_triage 应用指令',
1457
- }, '生成应用指令'),
1591
+ className: 'iterate-btn',
1592
+ 'data-copied': cmdCopied === 'readback' ? '' : undefined,
1593
+ 'data-failed': cmdFailed === 'readback' ? '' : undefined,
1594
+ onClick: doReadback,
1595
+ title: '复制 iterate_triage list 指令,核对已经写入的 known_intentional 条目(写回闭环确认)',
1596
+ }, cmdCopied === 'readback' ? '已复制' : cmdFailed === 'readback' ? '复制失败' : '回读已写入条目'),
1458
1597
  ),
1598
+ cmdCopied === 'apply'
1599
+ ? React.createElement('span', { className: 'iterate-triage-hint', role: 'status' }, '已复制应用指令——粘贴回会话,模型将调用 iterate_triage apply 并回报写入条数。')
1600
+ : cmdCopied === 'readback'
1601
+ ? React.createElement('span', { className: 'iterate-triage-hint', role: 'status' }, '已复制回读指令——粘贴回会话可列出当前已写入的 known_intentional。')
1602
+ : null,
1459
1603
  ),
1460
1604
  // v3.0: Command buttons for native actions (§8)
1461
1605
  React.createElement('div', { className: 'iterate-triage-foot', style: { borderTop: '1px solid var(--dsw-alias-border-l1)' } },
1462
1606
  React.createElement('span', { style: { fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, '指挥操作'),
1463
- React.createElement('span', { style: { display: 'flex', gap: 6 } },
1607
+ React.createElement('span', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
1464
1608
  React.createElement('button', {
1465
- className: 'iterate-cmd', 'data-primary': '', 'data-copied': cmdCopied === 'approve-arch' ? '' : undefined,
1609
+ className: 'iterate-cmd', 'data-primary': '',
1610
+ 'data-copied': cmdCopied === 'approve-arch' ? '' : undefined,
1611
+ 'data-failed': cmdFailed === 'approve-arch' ? '' : undefined,
1466
1612
  onClick: doApproveArchitecturalFix,
1467
- title: '批准架构修复(复制指令文本)',
1468
- }, cmdCopied === 'approve-arch' ? '已复制' : '批准架构修复'),
1613
+ title: '批准架构修复(复制指令文本:iterate_fix force:true,含全部必填参数)',
1614
+ }, cmdCopied === 'approve-arch' ? '已复制' : cmdFailed === 'approve-arch' ? '复制失败' : '批准架构修复'),
1469
1615
  React.createElement('button', {
1470
- className: 'iterate-cmd', 'data-copied': cmdCopied === 'new-round' ? '' : undefined,
1616
+ className: 'iterate-cmd',
1617
+ 'data-copied': cmdCopied === 'new-round' ? '' : undefined,
1618
+ 'data-failed': cmdFailed === 'new-round' ? '' : undefined,
1471
1619
  onClick: doTriggerNewRound,
1472
1620
  title: '触发新一轮迭代审查',
1473
- }, cmdCopied === 'new-round' ? '已复制' : '触发新一轮'),
1621
+ }, cmdCopied === 'new-round' ? '已复制' : cmdFailed === 'new-round' ? '复制失败' : '触发新一轮'),
1474
1622
  React.createElement('button', {
1475
- className: 'iterate-cmd', 'data-primary': '', 'data-copied': cmdCopied === 'assign' ? '' : undefined,
1623
+ className: 'iterate-cmd', 'data-primary': '',
1624
+ 'data-copied': cmdCopied === 'assign' ? '' : undefined,
1625
+ 'data-failed': cmdFailed === 'assign' ? '' : undefined,
1476
1626
  onClick: doAssignFindings,
1477
1627
  disabled: (selectAll ? allIndices : indices).length === 0,
1478
- title: selectAll ? `指派全部 ${allIndices.length} 个 findings 修复` : `指派当前可见 ${indices.length} 个 findings 修复`,
1479
- }, cmdCopied === 'assign' ? '已复制' : '指派修复'),
1628
+ title: selectAll ? `指派全部 ${allIndices.length} 个 findings 修复(逐个 iterate_fix)` : `指派当前可见 ${indices.length} 个 findings 修复(逐个 iterate_fix)`,
1629
+ }, cmdCopied === 'assign' ? '已复制' : cmdFailed === 'assign' ? '复制失败' : '指派修复'),
1480
1630
  React.createElement('button', {
1481
- className: 'iterate-cmd', 'data-danger': '', 'data-copied': cmdCopied === 'rollback' ? '' : undefined,
1631
+ className: 'iterate-cmd', 'data-danger': '',
1632
+ 'data-copied': cmdCopied === 'rollback' ? '' : undefined,
1633
+ 'data-failed': cmdFailed === 'rollback' ? '' : undefined,
1482
1634
  onClick: doRollbackToCheckpoint,
1483
1635
  title: '回滚到上一个检查点',
1484
- }, cmdCopied === 'rollback' ? '已复制' : '回滚检查点'),
1636
+ }, cmdCopied === 'rollback' ? '已复制' : cmdFailed === 'rollback' ? '复制失败' : '回滚检查点'),
1485
1637
  ),
1486
1638
  ),
1487
1639
  payload
@@ -1592,9 +1744,27 @@ function SettingsPanel(_props: SlotProps) {
1592
1744
  const [showStatus, setShowStatus] = React.useState(false)
1593
1745
  const [confirming, setConfirming] = React.useState(false)
1594
1746
  const [clearedInfo, setClearedInfo] = React.useState<number | null>(null)
1747
+ // Gap #8: "pick a field → generate an iterate_config write instruction".
1748
+ // Defaults to validation.commands, the field the old guide omitted entirely
1749
+ // (and the root cause of the zero-verification P0).
1750
+ const [cfgField, setCfgField] = React.useState<string>(
1751
+ () => (CONFIG_EDIT_FIELDS.some((f) => f.key === 'validation.commands') ? 'validation.commands' : (CONFIG_EDIT_FIELDS[0]?.key ?? 'goal')),
1752
+ )
1753
+ const [cfgCopied, setCfgCopied] = React.useState(false)
1754
+ const [cfgFailed, setCfgFailed] = React.useState(false)
1755
+ const [cfgText, setCfgText] = React.useState<string | null>(null)
1756
+ const [pullCopied, setPullCopied] = React.useState(false)
1757
+ const [copyError, setCopyError] = React.useState<'guide' | 'status' | 'pull' | null>(null)
1595
1758
  const guide = buildConfigEditGuide()
1596
1759
  const statusGuide = buildRuntimeStatusGuide()
1597
1760
 
1761
+ // Audit: don't leak flash timers across settings unmounts.
1762
+ const cfgTimers = React.useRef<number[]>([])
1763
+ React.useEffect(() => () => {
1764
+ for (const t of cfgTimers.current) clearTimeout(t)
1765
+ cfgTimers.current = []
1766
+ }, [])
1767
+
1598
1768
  const toggleTheme = () => {
1599
1769
  const next = !enabled
1600
1770
  setEnabled(next)
@@ -1602,30 +1772,46 @@ function SettingsPanel(_props: SlotProps) {
1602
1772
  }
1603
1773
 
1604
1774
  /** Flash a per-slot "已复制" state for the given duration. */
1605
- const flashCopied = (slot: 'guide' | 'status') => {
1606
- const setter = slot === 'guide' ? setGuideCopied : setStatusCopied
1775
+ const flashCopied = (slot: 'guide' | 'status' | 'pull') => {
1776
+ const setter = slot === 'guide' ? setGuideCopied : slot === 'status' ? setStatusCopied : setPullCopied
1607
1777
  setter(true)
1608
- setTimeout(() => setter(false), 1600)
1778
+ cfgTimers.current.push(window.setTimeout(() => setter(false), 1600))
1609
1779
  }
1610
1780
 
1611
- /** Copy a guide/status block; flash success only when it actually copied. */
1612
- const doCopy = (text: string, slot: 'guide' | 'status') => {
1781
+ /** Copy a guide/status block; flash success only when it actually copied,
1782
+ * and surface a visible error when the clipboard write is refused (the old
1783
+ * behaviour was a silent no-op, which reads as a broken button). */
1784
+ const doCopy = (text: string, slot: 'guide' | 'status' | 'pull') => {
1613
1785
  copyText(text).then((ok) => {
1786
+ setCopyError(ok ? null : slot)
1614
1787
  if (ok) flashCopied(slot)
1615
1788
  })
1616
1789
  }
1617
1790
 
1791
+ /** Gap #8: build + copy the write instruction for the selected field. */
1792
+ const doBuildConfigInstruction = () => {
1793
+ const text = buildConfigFieldInstruction(cfgField)
1794
+ setCfgText(text)
1795
+ copyText(text).then((ok) => {
1796
+ setCfgFailed(!ok)
1797
+ setCfgCopied(ok)
1798
+ cfgTimers.current.push(window.setTimeout(() => setCfgCopied(false), 1600))
1799
+ // On failure the raw text stays on screen for a manual copy.
1800
+ if (!ok) return
1801
+ })
1802
+ }
1803
+
1618
1804
  /** Two-step destroy guard: first click arms, second click clears. */
1619
1805
  const requestClear = () => {
1620
1806
  if (confirming) {
1621
1807
  const count = removeStorageByPrefix(TRIAGE_STORAGE_PREFIX)
1622
1808
  setClearedInfo(count)
1623
1809
  setConfirming(false)
1624
- setTimeout(() => setClearedInfo(null), 3000)
1810
+ cfgTimers.current.push(window.setTimeout(() => setClearedInfo(null), 3000))
1625
1811
  return
1626
1812
  }
1627
1813
  setConfirming(true)
1628
- setTimeout(() => setConfirming(false), 3000)
1814
+ cfgTimers.current.push(window.setTimeout(() => setConfirming(false), 3000))
1629
1815
  }
1630
1816
 
1631
1817
  const clearButton = clearedInfo !== null
@@ -1675,13 +1861,50 @@ function SettingsPanel(_props: SlotProps) {
1675
1861
  React.createElement('div', { className: 'iterate-scard-head' },
1676
1862
  React.createElement('div', {},
1677
1863
  React.createElement('div', { className: 'iterate-settings-title' }, '配置管理'),
1678
- React.createElement('div', { className: 'iterate-settings-desc' }, '目标 / 维度 / 最大轮数写在项目的 iterate.config.yaml,复制指引可让模型按需调整。'),
1864
+ React.createElement('div', { className: 'iterate-settings-desc' }, '目标 / 维度 / 最大轮数 / validation.commands 写在项目的 iterate.config.yaml,复制指引可让模型按需调整。'),
1679
1865
  ),
1680
1866
  React.createElement('div', { className: 'iterate-scard-actions' },
1681
1867
  React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': guideCopied ? '' : undefined, onClick: () => doCopy(guide, 'guide') }, guideCopied ? '已复制' : '复制指引'),
1682
1868
  React.createElement('button', { className: 'iterate-btn', 'data-ghost': '', onClick: () => setShowGuide((v) => !v) }, showGuide ? '收起' : '展开'),
1683
1869
  ),
1684
1870
  ),
1871
+ // Gap #8: wire up the previously-dead `buildConfigFieldInstruction` — pick
1872
+ // a field, generate the `iterate_config` write payload for it, paste it to
1873
+ // the model. `validation.commands` is the default selection.
1874
+ React.createElement('div', { className: 'iterate-guide-bar', style: { marginTop: 8, flexWrap: 'wrap' } },
1875
+ React.createElement('select', {
1876
+ className: 'iterate-filter-select',
1877
+ value: cfgField,
1878
+ 'aria-label': '选择要修改的配置字段',
1879
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setCfgField(e.target.value),
1880
+ },
1881
+ ...CONFIG_EDIT_FIELDS.map((f) =>
1882
+ React.createElement('option', { key: f.key, value: f.key }, `${f.label}(${f.key})`),
1883
+ ),
1884
+ ),
1885
+ React.createElement('span', { className: 'iterate-settings-desc' },
1886
+ configFieldByKey(cfgField)?.hint ?? '',
1887
+ ),
1888
+ React.createElement('button', {
1889
+ className: 'iterate-btn', 'data-primary': '',
1890
+ 'data-copied': cfgCopied ? '' : undefined,
1891
+ 'data-failed': cfgFailed ? '' : undefined,
1892
+ onClick: doBuildConfigInstruction,
1893
+ title: '生成并复制该字段的 iterate_config 写入指令(写入前自动备份、失败自动回滚)',
1894
+ }, cfgCopied ? '已复制' : cfgFailed ? '复制失败' : '生成写入指令'),
1895
+ cfgFailed
1896
+ ? React.createElement('span', { className: 'iterate-settings-desc' }, '剪贴板被拒绝——指令已展示在下方,请手动复制。')
1897
+ : null,
1898
+ ),
1899
+ cfgText
1900
+ ? React.createElement('div', { className: 'iterate-guide' },
1901
+ React.createElement('div', { className: 'iterate-guide-bar' },
1902
+ React.createElement('span', {}, '写入指令(粘贴给模型)'),
1903
+ React.createElement('button', { className: 'iterate-btn', 'data-ghost': '', onClick: () => doCopy(cfgText, 'guide') }, '复制'),
1904
+ ),
1905
+ React.createElement('div', { className: 'iterate-guide-body' }, cfgText),
1906
+ )
1907
+ : null,
1685
1908
  showGuide
1686
1909
  ? React.createElement('div', { className: 'iterate-guide' },
1687
1910
  React.createElement('div', { className: 'iterate-guide-bar' },
@@ -1701,6 +1924,13 @@ function SettingsPanel(_props: SlotProps) {
1701
1924
  ),
1702
1925
  React.createElement('div', { className: 'iterate-scard-actions' },
1703
1926
  React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': statusCopied ? '' : undefined, onClick: () => doCopy(statusGuide, 'status') }, statusCopied ? '已复制' : '复制指引'),
1927
+ // Gap #4: RUNTIME_ARTIFACTS stops being documentation-only — this
1928
+ // copies the instruction that makes the model echo those artifacts
1929
+ // into the session so the observatory panels can read them.
1930
+ React.createElement('button', {
1931
+ className: 'iterate-btn', 'data-copied': pullCopied ? '' : undefined,
1932
+ onClick: () => doCopy(buildDiskSnapshotInstruction(), 'pull'),
1933
+ }, pullCopied ? '已复制' : '拉取磁盘快照'),
1704
1934
  React.createElement('button', { className: 'iterate-btn', 'data-ghost': '', onClick: () => setShowStatus((v) => !v) }, showStatus ? '收起' : '展开'),
1705
1935
  ),
1706
1936
  ),
@@ -1731,6 +1961,13 @@ function SettingsPanel(_props: SlotProps) {
1731
1961
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'settings', className: 'iterate-settings' },
1732
1962
  React.createElement('div', { className: 'iterate-settings-title', style: { fontSize: 15, fontWeight: 700 } }, 'iterate 设置'),
1733
1963
  banner,
1964
+ copyError
1965
+ ? React.createElement('div', { key: 'copy-err', className: 'iterate-scard', role: 'alert' },
1966
+ React.createElement('div', { className: 'iterate-settings-desc' },
1967
+ '剪贴板写入被拒绝,复制未生效——请展开对应区块手动选中文本复制。',
1968
+ ),
1969
+ )
1970
+ : null,
1734
1971
  themeCard,
1735
1972
  dataCard,
1736
1973
  guideCard,
@@ -1740,23 +1977,43 @@ function SettingsPanel(_props: SlotProps) {
1740
1977
 
1741
1978
  // ─── Runtime observatory panel (F1–F7) ──────────────────────────────────────
1742
1979
 
1743
- /** Tab id → label for the observatory's eight panels. Live (real-time activity
1744
- * stream) is first so it is the default view; F-panels follow. */
1980
+ /** Tab id → label for the observatory's eleven panels. Live (real-time
1981
+ * activity stream) is first so it is the default view; F-panels follow.
1982
+ * Gap #14: labels carry the "Fn" number used everywhere else (README,
1983
+ * code comments, the copy instructions), so the docs and the UI line up. */
1745
1984
  const OBS_TABS = [
1746
- { key: 'live', label: '实时活动流' },
1747
- { key: 'f1', label: '审查线程' },
1748
- { key: 'f2', label: '收敛趋势' },
1749
- { key: 'f3', label: '发现定位' },
1750
- { key: 'f4', label: '修复与回滚' },
1751
- { key: 'f5', label: '断点恢复' },
1752
- { key: 'f6', label: '运行控制台' },
1753
- { key: 'f7', label: '决策时间线' },
1985
+ { key: 'live', label: '实时活动流', hotkey: '' },
1986
+ { key: 'f1', label: '审查线程', hotkey: '1' },
1987
+ { key: 'f2', label: '收敛趋势', hotkey: '2' },
1988
+ { key: 'f3', label: '发现定位', hotkey: '3' },
1989
+ { key: 'f4', label: '修复与回滚', hotkey: '4' },
1990
+ { key: 'f5', label: '断点恢复', hotkey: '5' },
1991
+ { key: 'f6', label: '运行控制台', hotkey: '6' },
1992
+ { key: 'f7', label: '决策时间线', hotkey: '7' },
1754
1993
  // v3.0: Quality Command Center tabs
1755
- { key: 'f8', label: '质量门禁' },
1756
- { key: 'f9', label: '经验银行' },
1757
- { key: 'f10', label: '防御事件' },
1994
+ { key: 'f8', label: '质量门禁', hotkey: '8' },
1995
+ { key: 'f9', label: '经验银行', hotkey: '9' },
1996
+ { key: 'f10', label: '防御事件', hotkey: '0' },
1758
1997
  ] as const
1759
1998
 
1999
+ /** Digit → tab id. `1-9` map to F1–F9, `0` to F10, `-` back to the live feed
2000
+ * (the default view). Digits are used instead of F1–F10 to avoid clashing
2001
+ * with browser/system F-key bindings. */
2002
+ const OBS_HOTKEY_TABS: Record<string, (typeof OBS_TABS)[number]['key']> = {
2003
+ 1: 'f1', 2: 'f2', 3: 'f3', 4: 'f4', 5: 'f5',
2004
+ 6: 'f6', 7: 'f7', 8: 'f8', 9: 'f9', 0: 'f10',
2005
+ '-': 'live',
2006
+ }
2007
+
2008
+ /** Visible label for a tab (keeps the number prefix in sync with `OBS_TABS`). */
2009
+ function obsTabLabel(t: (typeof OBS_TABS)[number]): string {
2010
+ if (t.key === 'live') return t.label
2011
+ return `${t.key.toUpperCase()} ${t.label}`
2012
+ }
2013
+
2014
+ /** Hotkey hint shown in the collapsed header, e.g. "11 页 · 1-9/0/- 切换". */
2015
+ const OBS_HOTKEY_HINT = `${String(OBS_TABS.length)} 页 · 按 1-9/0 切 Fn、- 回实时流`
2016
+
1760
2017
  /** Live subagent activity badges: type → Chinese label + dsw token color accent. */
1761
2018
  const OBS_LIVE_META: Record<string, { label: string; color: string }> = {
1762
2019
  read: { label: '阅读', color: 'var(--dsw-alias-brand-primary)' },
@@ -1820,16 +2077,13 @@ function latestDefenseEvents(session: unknown): ObsDefenseResult | null {
1820
2077
  return scanSessionForDefenseEvents(session) as ObsDefenseResult | null
1821
2078
  }
1822
2079
 
1823
- /** Build a human-pasteable `iterate_fix` instruction for one finding. */
2080
+ /** Build a human-pasteable `iterate_fix` instruction for one finding.
2081
+ * Gap #10: delegates to the shared builder so the payload always names the
2082
+ * four required `iterate_fix` params (file / content / finding / round) and
2083
+ * marks `content` + `round` as model-supplied instead of shipping a partial
2084
+ * call the model has to guess at. */
1824
2085
  function buildObsFixInstruction(f: ObsFinding): string {
1825
- const payload = JSON.stringify({
1826
- file: String(f.file || ''),
1827
- ...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
1828
- dimension: String(f.dimension || ''),
1829
- summary: String(f.summary || ''),
1830
- ...(f.suggested_fix ? { suggested_fix: String(f.suggested_fix) } : {}),
1831
- }, null, 2)
1832
- return `请调用 \`iterate_fix\` 修复以下 finding:\n\n\`\`\`json\n${payload}\n\`\`\``
2086
+ return buildFixInstruction(f as unknown as Record<string, unknown>)
1833
2087
  }
1834
2088
 
1835
2089
  /**
@@ -1880,6 +2134,40 @@ function ObservatoryPanel(props: SlotProps) {
1880
2134
  const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
1881
2135
  React.useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current) }, [])
1882
2136
 
2137
+ // Gap #14: digit shortcuts for tab switching. Digit keys (1-9 / 0 / -) are
2138
+ // used instead of F1–F10 so we never fight the browser's own F-key handling.
2139
+ // Guards mirror the triage panel: no modifier keys, no editable focus, and
2140
+ // only while the observatory is expanded AND focus is inside it — so the
2141
+ // keys never steal input from the composer or another panel.
2142
+ React.useEffect(() => {
2143
+ const doc = typeof document !== 'undefined' ? document : null
2144
+ if (!doc || !open) return
2145
+ const onKeyDown = (ev: KeyboardEvent) => {
2146
+ if (ev.metaKey || ev.ctrlKey || ev.altKey || ev.shiftKey) return
2147
+ const t = ev.target as HTMLElement | null
2148
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
2149
+ if (t && typeof t.isContentEditable === 'boolean' && t.isContentEditable) return
2150
+ const rootEl = doc.querySelector('[data-iterate="obs"]')
2151
+ if (!rootEl) return
2152
+ const activeEl = doc.activeElement as HTMLElement | null
2153
+ // Act when focus is INSIDE the observatory, or nowhere in particular
2154
+ // (body/null — some browsers never focus a <button> on click). Focus
2155
+ // parked on a non-editable element outside the panel keeps standard
2156
+ // behavior; focus in the composer was already excluded above.
2157
+ const inPanel = activeEl === rootEl ||
2158
+ (activeEl !== null && typeof rootEl.contains === 'function' && rootEl.contains(activeEl))
2159
+ const idle = activeEl === null || activeEl === doc.body
2160
+ if (!inPanel && !idle) return
2161
+ const next = OBS_HOTKEY_TABS[ev.key]
2162
+ if (!next) return
2163
+ ev.preventDefault()
2164
+ setTab(next)
2165
+ }
2166
+ doc.addEventListener('keydown', onKeyDown)
2167
+ return () => doc.removeEventListener('keydown', onKeyDown)
2168
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2169
+ }, [open])
2170
+
1883
2171
  /** Copy an instruction, flashing "已复制" on the originating button only when the copy actually succeeded. */
1884
2172
  const copyInstruction = (key: string, text: string) => {
1885
2173
  if (!text) return
@@ -1891,6 +2179,12 @@ function ObservatoryPanel(props: SlotProps) {
1891
2179
  })
1892
2180
  }
1893
2181
 
2182
+ // Gap #4: `.iterate/` artifacts are unreachable from the browser — this is
2183
+ // the one instruction that makes the model echo them into the session
2184
+ // stream, which is what every panel scans.
2185
+ const pullInstruction = buildDiskSnapshotInstruction()
2186
+ const pullTitle = `复制拉取磁盘快照指令:让模型逐个调用 ${DISK_SNAPSHOT_SOURCES.map((s) => s.tool).join(' / ')} 并回显完整结果`
2187
+
1894
2188
  /** Toggle one thread; a manual toggle exits any forced all/none mode. */
1895
2189
  const toggleThread = (key: string) => {
1896
2190
  setThreadMode('auto')
@@ -1901,10 +2195,19 @@ function ObservatoryPanel(props: SlotProps) {
1901
2195
  })
1902
2196
  }
1903
2197
 
1904
- /** Export the full observatory snapshot as JSON (download, then copy fallback). */
2198
+ /** Export the full observatory snapshot as JSON (download, then copy fallback).
2199
+ * Gap #11: the payload also carries the four on-disk artifacts the panels
2200
+ * render (quality gate, experience bank, defense events, final report) —
2201
+ * previously only `manifest` + `live` were exported, so a review could not
2202
+ * be reconstructed from the file. */
1905
2203
  const exportObservatory = () => {
1906
2204
  if (!manifest) return
1907
- const json = serializeObservatoryExport(manifest, manifest.live || [])
2205
+ const json = serializeObservatoryExport(manifest, manifest.live || [], {
2206
+ qualityGate,
2207
+ experienceBank,
2208
+ defenseEvents,
2209
+ report: latestReport(session),
2210
+ })
1908
2211
  const filename = `iterate-observatory-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
1909
2212
  const flash = () => {
1910
2213
  setCopiedKey('export')
@@ -1938,7 +2241,15 @@ function ObservatoryPanel(props: SlotProps) {
1938
2241
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
1939
2242
  React.createElement('div', { className: 'iterate-obs-head', 'data-closed': '' },
1940
2243
  React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
1941
- React.createElement('span', { className: 'iterate-obs-head-meta' }, '暂无运行时观测数据'),
2244
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, `暂无运行时观测数据 · ${OBS_HOTKEY_HINT}`),
2245
+ React.createElement('button', {
2246
+ className: 'iterate-btn', 'data-copied': copiedKey === 'pull-snapshot' ? '' : undefined,
2247
+ onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
2248
+ e.stopPropagation()
2249
+ copyInstruction('pull-snapshot', pullInstruction)
2250
+ },
2251
+ title: '复制拉取磁盘快照指令:磁盘上可能已有历史产物,回显进会话后观测台即可填充',
2252
+ }, copiedKey === 'pull-snapshot' ? '已复制' : '拉取磁盘快照'),
1942
2253
  ),
1943
2254
  )
1944
2255
  }
@@ -2041,11 +2352,66 @@ function ObservatoryPanel(props: SlotProps) {
2041
2352
  return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无收敛数据')
2042
2353
  }
2043
2354
  const points = conv.map((n, i) => ({ round: i + 1, count: n }))
2355
+ // Gap #13 (step ①): single-session cross-round comparison. One row per
2356
+ // round — findings raised / fixed / severity mix / validation outcome.
2357
+ // Cross-SESSION comparison rides on the #4 pull instruction instead (the
2358
+ // client has no filesystem access, so `iterate_history` aggregates have to
2359
+ // be echoed back into the session first).
2360
+ const report = latestReport(session)
2361
+ const comparison = buildRoundComparison(report, manifest.validations || [])
2362
+ const comparisonTable = comparison.length === 0
2363
+ ? null
2364
+ : React.createElement('table', { className: 'iterate-obs-table' },
2365
+ React.createElement('thead', {},
2366
+ React.createElement('tr', {},
2367
+ React.createElement('th', {}, '轮次'),
2368
+ React.createElement('th', {}, '发现'),
2369
+ React.createElement('th', {}, '已修复'),
2370
+ React.createElement('th', {}, '严重度分布'),
2371
+ React.createElement('th', {}, '验证'),
2372
+ ),
2373
+ ),
2374
+ React.createElement('tbody', {},
2375
+ ...comparison.map((row) => {
2376
+ const sevText = Object.entries(row.severities)
2377
+ .map(([k, v]) => `${k}:${v}`)
2378
+ .join(' · ')
2379
+ const anyFailed = row.validations.some((v) => v.allowed !== true)
2380
+ const valText = row.validations.length === 0
2381
+ ? '—'
2382
+ : `${row.validations.filter((v) => v.allowed === true).length}/${row.validations.length} 通过${anyFailed ? '(含失败)' : ''}`
2383
+ return React.createElement('tr', { key: `cmp-${row.round}` },
2384
+ React.createElement('td', {}, `Round ${row.round}`),
2385
+ React.createElement('td', {}, String(row.findings)),
2386
+ React.createElement('td', {}, String(row.fixed)),
2387
+ React.createElement('td', {}, sevText || '—'),
2388
+ React.createElement('td', {
2389
+ style: anyFailed
2390
+ ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 600 }
2391
+ : row.validations.length > 0
2392
+ ? { color: 'var(--dsw-alias-state-success-primary)' }
2393
+ : undefined,
2394
+ }, valText),
2395
+ )
2396
+ }),
2397
+ ),
2398
+ )
2044
2399
  return React.createElement('div', {},
2045
2400
  React.createElement(TrendChart, { points }),
2046
2401
  React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
2047
2402
  `各轮发现数量:${conv.join(' → ')}${typeof manifest.round === 'number' ? ` · 当前 Round ${manifest.round}` : ''}`,
2048
2403
  ),
2404
+ comparisonTable
2405
+ ? React.createElement('div', { className: 'iterate-obs-block', style: { marginTop: 8 } },
2406
+ React.createElement('div', { className: 'iterate-obs-block-head' },
2407
+ React.createElement('span', {}, '跨轮对比(本会话)'),
2408
+ React.createElement('span', { className: 'iterate-obs-head-meta' },
2409
+ '跨会话对比:本表只覆盖当前会话;点「拉取磁盘快照」让模型回显 iterate_history,再用「导出 JSON」并排比较两次运行',
2410
+ ),
2411
+ ),
2412
+ React.createElement('div', { className: 'iterate-obs-block-body' }, comparisonTable),
2413
+ )
2414
+ : null,
2049
2415
  )
2050
2416
  }
2051
2417
 
@@ -2171,7 +2537,7 @@ function ObservatoryPanel(props: SlotProps) {
2171
2537
  return React.createElement('div', {}, ...fixes.map((f, i) => {
2172
2538
  const k = `f4-${i}`
2173
2539
  const id = String(f.id || `fix#${i + 1}`)
2174
- const rollbackText = `请调用 \`iterate_rollback\` 回滚以下修复:\n\n\`\`\`json\n${JSON.stringify({ id }, null, 2)}\n\`\`\``
2540
+ const rollbackText = buildRollbackInstruction(id)
2175
2541
  const added = typeof f.linesAdded === 'number' ? f.linesAdded : 0
2176
2542
  const removed = typeof f.linesRemoved === 'number' ? f.linesRemoved : 0
2177
2543
  return React.createElement('div', { key: k, className: 'iterate-obs-block' },
@@ -2203,18 +2569,19 @@ function ObservatoryPanel(props: SlotProps) {
2203
2569
  const renderCheckpoint = () => {
2204
2570
  const cp = manifest.checkpoint
2205
2571
  if (!cp) {
2206
- return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无断点')
2572
+ return React.createElement('div', { className: 'iterate-obs-empty' },
2573
+ diskEmptyStateText(toolCalledInSession(session, 'iterate_transcript'), '断点'),
2574
+ )
2207
2575
  }
2208
- const resumeText = `请调用 \`iterate_checkpoint\` 从断点恢复迭代:\n\n\`\`\`json\n${JSON.stringify({
2209
- operation: 'resume',
2210
- mode: String(cp.mode || 'normal'),
2211
- maxRounds: typeof cp.maxRounds === 'number' ? cp.maxRounds : null,
2212
- }, null, 2)}\n\`\`\``
2576
+ // Gap #10: `resume` accepts ONLY `operation` — `mode` / `round` /
2577
+ // `maxRounds` are save-side inputs and `maxRounds: null` was an
2578
+ // invalid-integer field on a positive-integer schema.
2579
+ const resumeText = buildCheckpointResumeInstruction()
2213
2580
  // Clearing a stale checkpoint after an interrupted run — iterate_checkpoint
2214
2581
  // exposes `clear`, but before this button it was only reachable by typing
2215
2582
  // the command manually (an interrupted run otherwise shows a permanent
2216
2583
  // "可恢复" chip with no companion reset action).
2217
- const clearText = `请调用 \`iterate_checkpoint\` 清除当前断点:\n\n\`\`\`json\n${JSON.stringify({ operation: 'clear' }, null, 2)}\n\`\`\``
2584
+ const clearText = buildCheckpointClearInstruction()
2218
2585
  const item = (label: string, value: unknown) =>
2219
2586
  React.createElement('span', { className: 'iterate-obs-chip' }, `${label} ${String(value ?? '?')}`)
2220
2587
  return React.createElement('div', { className: 'iterate-obs-block' },
@@ -2259,6 +2626,50 @@ function ObservatoryPanel(props: SlotProps) {
2259
2626
  // text:null. Copied as a paste-able instruction, mirroring the panel's
2260
2627
  // copy-to-command pattern — it cannot be cleared from the client directly.
2261
2628
  const activeClearInstruction = `请调用 \`iterate_transcript\` 清除当前 nudge:\n\n\`\`\`json\n${JSON.stringify({ operation: 'nudge', text: null }, null, 2)}\n\`\`\``
2629
+ // Gap #6: per-round validation rows (command / exitCode / allowed) live on
2630
+ // the manifest; when the run stopped on a validation failure the failing
2631
+ // command is called out explicitly instead of being buried in a list.
2632
+ const validations = Array.isArray(manifest.validations) ? manifest.validations : []
2633
+ const abortedByValidation = String(manifest.stoppedReason || '') === 'aborted_by_validation'
2634
+ const validationRows = validations.map((v, i) => {
2635
+ const allowed = v.allowed === true
2636
+ const failed = !allowed
2637
+ const highlight = failed && abortedByValidation
2638
+ const exit = typeof v.exitCode === 'number' ? `exit ${v.exitCode}` : 'exit —'
2639
+ return React.createElement('div', {
2640
+ key: `val-${i}`,
2641
+ className: 'iterate-obs-row',
2642
+ 'data-status': allowed ? 'pass' : 'fail',
2643
+ 'data-highlight': highlight ? '' : undefined,
2644
+ title: v.rejectReason ? String(v.rejectReason) : undefined,
2645
+ },
2646
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, `Round ${typeof v.round === 'number' ? v.round : '?'}`),
2647
+ React.createElement('code', { className: 'iterate-obs-mono' }, String(v.command || '')),
2648
+ React.createElement('span', { className: 'iterate-obs-chip', 'data-status': allowed ? 'pass' : 'fail' },
2649
+ allowed ? '通过' : '失败',
2650
+ ),
2651
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, exit),
2652
+ highlight ? React.createElement('span', { className: 'iterate-obs-chip', 'data-status': 'fail' }, '导致中止') : null,
2653
+ )
2654
+ })
2655
+ const validationsBlock = React.createElement('div', { className: 'iterate-obs-block', style: { marginTop: 8 } },
2656
+ React.createElement('div', { className: 'iterate-obs-block-head' },
2657
+ React.createElement('span', {}, '验证结果'),
2658
+ validations.length === 0
2659
+ ? React.createElement('span', { className: 'iterate-obs-head-meta' }, '本轮尚无验证记录')
2660
+ : React.createElement('span', { className: 'iterate-obs-head-meta' }, `${validations.length} 条命令`),
2661
+ abortedByValidation
2662
+ ? React.createElement('span', { className: 'iterate-obs-badge', 'data-status': 'fail' }, '因验证失败中止')
2663
+ : null,
2664
+ ),
2665
+ React.createElement('div', { className: 'iterate-obs-block-body' },
2666
+ validations.length === 0
2667
+ ? React.createElement('div', { className: 'iterate-obs-empty' },
2668
+ '尚未拉取验证结果:数据在磁盘 transcript 里,点「拉取磁盘快照」复制指令发回会话即可回填。',
2669
+ )
2670
+ : React.createElement(React.Fragment, null, ...validationRows),
2671
+ ),
2672
+ )
2262
2673
  return React.createElement('div', { className: 'iterate-obs-block' },
2263
2674
  React.createElement('div', { className: 'iterate-obs-block-head' },
2264
2675
  React.createElement('span', {}, '运行控制台'),
@@ -2293,6 +2704,7 @@ function ObservatoryPanel(props: SlotProps) {
2293
2704
  title: '复制 iterate_transcript nudge 指令文本',
2294
2705
  }, copiedKey === 'nudge' ? '已复制' : '复制 nudge 指令'),
2295
2706
  ),
2707
+ validationsBlock,
2296
2708
  ),
2297
2709
  )
2298
2710
  }
@@ -2387,29 +2799,40 @@ function ObservatoryPanel(props: SlotProps) {
2387
2799
  // session (dimension convergence, verification pass rate, overall PASS/FAIL);
2388
2800
  // falls back to copy-able query instructions when no snapshot exists yet.
2389
2801
  const renderQualityGate = () => {
2390
- const gateInstruction = '请调用 `iterate_quality_gate` 查询当前质量门禁状态'
2802
+ // Gap #10: the query instruction now carries explicit JSON args, matching
2803
+ // the rest of the panel's copy-to-command payloads (the model no longer
2804
+ // has to infer `operation`).
2805
+ const gateInstruction = buildQualityGateQueryInstruction()
2391
2806
  // Reset a stale FAIL certificate before a fresh iteration — the tool's
2392
2807
  // `clear` operation existed since v3.5.1 but had no client entry point.
2393
- const gateClearInstruction = `请调用 \`iterate_quality_gate\` 清除当前质量门禁证书:\n\n\`\`\`json\n${JSON.stringify({ operation: 'clear' }, null, 2)}\n\`\`\``
2808
+ const gateClearInstruction = buildQualityGateClearInstruction()
2394
2809
  const gate = qualityGate
2395
2810
  const dims = gate && gate.dimensions ? gate.dimensions : []
2396
2811
  const hasGate = gate !== null && Boolean(gate.overallStatus || gate.overallScore != null || dims.length > 0)
2397
2812
  const status = gate && gate.overallStatus ? String(gate.overallStatus) : 'pending'
2398
2813
  const statusLabel = status === 'pass' ? 'PASS' : status === 'fail' ? 'FAIL' : 'PENDING'
2399
2814
  const num = (v: number | undefined) => Number(v != null ? v : 0)
2815
+ const pulled = toolCalledInSession(session, 'iterate_quality_gate')
2400
2816
 
2401
2817
  const headerBar = React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
2402
2818
  React.createElement('b', {}, '质量门禁'),
2403
2819
  React.createElement('button', {
2404
2820
  className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'qgate' ? '' : undefined,
2405
2821
  onClick: () => copyInstruction('qgate', gateInstruction),
2406
- title: '复制 iterate_quality_gate 查询指令',
2822
+ title: '复制 iterate_quality_gate 查询指令(含 JSON 参数)',
2407
2823
  }, copiedKey === 'qgate' ? '已复制' : '查询门禁'),
2408
2824
  React.createElement('button', {
2409
2825
  className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === 'qgate-clear' ? '' : undefined,
2410
2826
  onClick: () => copyInstruction('qgate-clear', gateClearInstruction),
2411
2827
  title: '复制 iterate_quality_gate clear 指令文本(重置陈旧的 FAIL 证书)',
2412
2828
  }, copiedKey === 'qgate-clear' ? '已复制' : '清除门禁'),
2829
+ // Gap #4: the browser cannot read `.iterate/` directly — one click
2830
+ // copies the pull instruction that makes the model echo it into the stream.
2831
+ React.createElement('button', {
2832
+ className: 'iterate-btn', 'data-copied': copiedKey === 'disk-snapshot' ? '' : undefined,
2833
+ onClick: () => copyInstruction('disk-snapshot', pullInstruction),
2834
+ title: '复制拉取磁盘快照指令:让模型回显 .iterate/ 下的门禁、经验、防御事件与 transcript',
2835
+ }, copiedKey === 'disk-snapshot' ? '已复制' : '拉取磁盘快照'),
2413
2836
  )
2414
2837
 
2415
2838
  if (!hasGate) {
@@ -2421,13 +2844,25 @@ function ObservatoryPanel(props: SlotProps) {
2421
2844
  React.createElement('span', { className: 'iterate-obs-badge' }, 'v3.0'),
2422
2845
  ),
2423
2846
  React.createElement('div', { className: 'iterate-obs-block-body' },
2424
- React.createElement('div', { className: 'iterate-obs-msg' }, '质量门禁显示各维度收敛度、验证通过率和整体 PASS/FAIL 状态。'),
2847
+ React.createElement('div', { className: 'iterate-obs-msg' },
2848
+ // Gap #4: distinguish "we pulled it and it is empty" from "we
2849
+ // never asked the model to read the disk".
2850
+ diskEmptyStateText(pulled, '质量门禁证书'),
2851
+ ),
2852
+ React.createElement('div', { className: 'iterate-obs-msg', style: { marginTop: 6 } },
2853
+ '质量门禁显示各维度收敛度、验证通过率和整体 PASS/FAIL 状态。',
2854
+ ),
2425
2855
  React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
2426
2856
  React.createElement('button', {
2427
2857
  className: 'iterate-btn', 'data-copied': copiedKey === 'qgate-instr' ? '' : undefined,
2428
2858
  onClick: () => copyInstruction('qgate-instr', gateInstruction),
2429
2859
  title: '复制查询指令',
2430
2860
  }, copiedKey === 'qgate-instr' ? '已复制' : '复制查询指令'),
2861
+ React.createElement('button', {
2862
+ className: 'iterate-btn', 'data-copied': copiedKey === 'disk-snapshot-2' ? '' : undefined,
2863
+ onClick: () => copyInstruction('disk-snapshot-2', pullInstruction),
2864
+ title: '复制拉取磁盘快照指令',
2865
+ }, copiedKey === 'disk-snapshot-2' ? '已复制' : '拉取磁盘快照'),
2431
2866
  ),
2432
2867
  ),
2433
2868
  ),
@@ -2484,7 +2919,7 @@ function ObservatoryPanel(props: SlotProps) {
2484
2919
  const renderExperienceBank = () => {
2485
2920
  const expInstruction = expSearch
2486
2921
  ? `请调用 \`iterate_experience\` 搜索经验:\n\n\`\`\`json\n${JSON.stringify({ operation: 'search', query: expSearch }, null, 2)}\n\`\`\``
2487
- : '请调用 `iterate_experience` 列出所有经验'
2922
+ : buildExperienceListInstruction()
2488
2923
 
2489
2924
  const expEntries = experienceBank && experienceBank.entries ? experienceBank.entries : []
2490
2925
  const query = expSearch.trim().toLowerCase()
@@ -2495,6 +2930,7 @@ function ObservatoryPanel(props: SlotProps) {
2495
2930
  .some((t) => String(t).toLowerCase().includes(query)),
2496
2931
  )
2497
2932
  : expEntries
2933
+ const pulled = toolCalledInSession(session, 'iterate_experience')
2498
2934
 
2499
2935
  const resultRows = visible.map((e, i) => {
2500
2936
  const adoptInstruction = `请调用 \`iterate_experience\` 采纳经验并应用已验证修法:\n\n\`\`\`json\n${JSON.stringify({ operation: 'get', id: e.id }, null, 2)}\n\`\`\``
@@ -2525,9 +2961,14 @@ function ObservatoryPanel(props: SlotProps) {
2525
2961
  React.createElement('span', { className: 'iterate-obs-head-meta' }, `会话内 ${expEntries.length} 条`),
2526
2962
  React.createElement('button', {
2527
2963
  className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'exp-list' ? '' : undefined,
2528
- onClick: () => copyInstruction('exp-list', '请调用 `iterate_experience` 列出所有经验'),
2529
- title: '复制列出经验指令',
2964
+ onClick: () => copyInstruction('exp-list', buildExperienceListInstruction()),
2965
+ title: '复制列出经验指令(含 JSON 参数)',
2530
2966
  }, copiedKey === 'exp-list' ? '已复制' : '列出经验'),
2967
+ React.createElement('button', {
2968
+ className: 'iterate-btn', 'data-copied': copiedKey === 'exp-snapshot' ? '' : undefined,
2969
+ onClick: () => copyInstruction('exp-snapshot', pullInstruction),
2970
+ title: '复制拉取磁盘快照指令:把 .iterate/ 下的经验银行等产物回显进会话',
2971
+ }, copiedKey === 'exp-snapshot' ? '已复制' : '拉取磁盘快照'),
2531
2972
  ),
2532
2973
  React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
2533
2974
  React.createElement('input', {
@@ -2549,7 +2990,11 @@ function ObservatoryPanel(props: SlotProps) {
2549
2990
  ? React.createElement('div', {}, ...resultRows)
2550
2991
  : expEntries.length > 0
2551
2992
  ? React.createElement('div', { className: 'iterate-obs-empty' }, '没有匹配「' + expSearch + '」的经验')
2552
- : React.createElement('div', { className: 'iterate-obs-empty' }, '本次会话暂无经验记录,可点击「列出经验」查询历史经验'),
2993
+ : React.createElement('div', {},
2994
+ // Gap #4: separate "pulled and empty" from "never pulled".
2995
+ React.createElement('div', { className: 'iterate-obs-empty' }, diskEmptyStateText(pulled, '经验银行')),
2996
+ React.createElement('div', { className: 'iterate-obs-empty' }, '也可点「列出经验」查询磁盘上的全部历史经验。'),
2997
+ ),
2553
2998
  )
2554
2999
  }
2555
3000
 
@@ -2560,7 +3005,7 @@ function ObservatoryPanel(props: SlotProps) {
2560
3005
  const renderDefenseEvents = () => {
2561
3006
  const defenseInstruction = defenseFilter
2562
3007
  ? `请调用 \`iterate_defense_events\` 查询防御事件:\n\n\`\`\`json\n${JSON.stringify({ operation: 'list', type: defenseFilter || undefined }, null, 2)}\n\`\`\``
2563
- : '请调用 `iterate_defense_events` 列出所有防御事件'
3008
+ : buildDefenseEventsListInstruction()
2564
3009
 
2565
3010
  const typeOptions = [
2566
3011
  { value: '', label: '全部类型' },
@@ -2588,13 +3033,18 @@ function ObservatoryPanel(props: SlotProps) {
2588
3033
  React.createElement('span', { className: 'iterate-obs-head-meta' }, `会话内 ${allEvents.length} 条 / 累计 ${totalCount}`),
2589
3034
  React.createElement('button', {
2590
3035
  className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'defense-list' ? '' : undefined,
2591
- onClick: () => copyInstruction('defense-list', '请调用 `iterate_defense_events` 列出所有防御事件'),
2592
- title: '复制列出防御事件指令',
3036
+ onClick: () => copyInstruction('defense-list', buildDefenseEventsListInstruction()),
3037
+ title: '复制列出防御事件指令(含 JSON 参数)',
2593
3038
  }, copiedKey === 'defense-list' ? '已复制' : '列出事件'),
3039
+ React.createElement('button', {
3040
+ className: 'iterate-btn', 'data-copied': copiedKey === 'defense-snapshot' ? '' : undefined,
3041
+ onClick: () => copyInstruction('defense-snapshot', pullInstruction),
3042
+ title: '复制拉取磁盘快照指令:把 .iterate/ 下的防御事件流回显进会话',
3043
+ }, copiedKey === 'defense-snapshot' ? '已复制' : '拉取磁盘快照'),
2594
3044
  React.createElement('button', {
2595
3045
  className: 'iterate-btn', 'data-copied': copiedKey === 'defense-counts' ? '' : undefined,
2596
- onClick: () => copyInstruction('defense-counts', '请调用 `iterate_defense_events` 查询事件统计'),
2597
- title: '复制统计指令',
3046
+ onClick: () => copyInstruction('defense-counts', `请调用 \`iterate_defense_events\` 查询事件统计:\n\n\`\`\`json\n${JSON.stringify({ operation: 'counts' }, null, 2)}\n\`\`\``),
3047
+ title: '复制统计指令(含 JSON 参数)',
2598
3048
  }, copiedKey === 'defense-counts' ? '已复制' : '统计'),
2599
3049
  React.createElement('button', {
2600
3050
  className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === 'defense-clear' ? '' : undefined,
@@ -2612,7 +3062,12 @@ function ObservatoryPanel(props: SlotProps) {
2612
3062
  React.createElement('span', { className: 'iterate-obs-badge' }, 'v3.0'),
2613
3063
  ),
2614
3064
  React.createElement('div', { className: 'iterate-obs-block-body' },
2615
- React.createElement('div', { className: 'iterate-obs-msg' }, '防御事件包括:前置校验失败、回滚、不变量违反、假设被证伪。'),
3065
+ React.createElement('div', { className: 'iterate-obs-msg' },
3066
+ diskEmptyStateText(toolCalledInSession(session, 'iterate_defense_events'), '防御事件流'),
3067
+ ),
3068
+ React.createElement('div', { className: 'iterate-obs-msg', style: { marginTop: 6 } },
3069
+ '防御事件包括:前置校验失败、回滚、不变量违反、假设被证伪。',
3070
+ ),
2616
3071
  React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
2617
3072
  React.createElement('button', {
2618
3073
  className: 'iterate-btn', 'data-copied': copiedKey === 'defense-instr' ? '' : undefined,
@@ -2771,6 +3226,11 @@ function ObservatoryPanel(props: SlotProps) {
2771
3226
  React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
2772
3227
  React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, runStatusText(live, manifest)),
2773
3228
  React.createElement('span', { className: 'iterate-obs-head-meta' }, headMeta || 'runtime'),
3229
+ // Gap #14: make the tab system discoverable without expanding it.
3230
+ React.createElement('span', {
3231
+ className: 'iterate-obs-head-meta',
3232
+ title: OBS_HOTKEY_HINT,
3233
+ }, OBS_HOTKEY_HINT),
2774
3234
  React.createElement('button', {
2775
3235
  className: 'iterate-btn', 'data-ghost': '',
2776
3236
  onClick: (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); setOpen((v) => !v) },
@@ -2800,15 +3260,26 @@ function ObservatoryPanel(props: SlotProps) {
2800
3260
  onClick: exportObservatory,
2801
3261
  title: '将观测台全部数据导出为 JSON(优先下载,失败则复制)',
2802
3262
  }, copiedKey === 'export' ? '已导出' : '导出 JSON'),
3263
+ // Gap #4: `.iterate/` artifacts are unreachable from the browser;
3264
+ // this copies the one instruction that makes the model echo them
3265
+ // into the session stream (which is what every panel scans).
3266
+ React.createElement('button', {
3267
+ className: 'iterate-btn', 'data-copied': copiedKey === 'pull-snapshot' ? '' : undefined,
3268
+ onClick: () => copyInstruction('pull-snapshot', pullInstruction),
3269
+ title: pullTitle,
3270
+ }, copiedKey === 'pull-snapshot' ? '已复制' : '拉取磁盘快照'),
2803
3271
  ),
2804
- React.createElement('div', { className: 'iterate-obs-tabs' },
3272
+ React.createElement('div', { className: 'iterate-obs-tabs', role: 'tablist', 'aria-label': '观测台页签(快捷键 1-9 / 0 / -)' },
2805
3273
  ...OBS_TABS.map((t) =>
2806
3274
  React.createElement('button', {
2807
3275
  key: t.key,
2808
3276
  className: 'iterate-obs-tab',
3277
+ role: 'tab',
3278
+ 'aria-selected': tab === t.key,
2809
3279
  'data-active': tab === t.key ? '' : undefined,
2810
3280
  onClick: () => setTab(t.key),
2811
- }, t.label),
3281
+ title: t.hotkey ? `${obsTabLabel(t)}(按 ${t.hotkey} 切换)` : `${obsTabLabel(t)}(按 - 切换)`,
3282
+ }, obsTabLabel(t)),
2812
3283
  ),
2813
3284
  ),
2814
3285
  React.createElement('div', { className: 'iterate-obs-body' }, renderBody()),