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
package/lib/parse.js CHANGED
@@ -2,7 +2,10 @@
2
2
  * lib/parse.js — Pure logic for iterate client UI.
3
3
  *
4
4
  * Framework-agnostic, DOM-free, single-file, testable with Node.js assert.
5
- * Every function is exported for unit test coverage.
5
+ * Every scan / normalize / compute helper is exported for unit test coverage;
6
+ * only micro plumbing stays module-private (the safeGet/safeKeys proxy-safe
7
+ * readers, the guarded JSON string coercion, and the three one-line predicate
8
+ * wrappers over findFirstInObject), each exercised through its caller.
6
9
  *
7
10
  * @module iterate-ui/parse
8
11
  */
@@ -56,6 +59,27 @@ function safeKeys(o) {
56
59
  }
57
60
  }
58
61
 
62
+ /**
63
+ * Coerce a possibly STRING-encoded node (tool results and message contents
64
+ * arrive as raw JSON text) into its parsed object/array form. Only strings
65
+ * whose trimmed body starts with `{` or `[` are parsed, so plain
66
+ * conversational text is never fed to JSON.parse; unparseable text passes
67
+ * through untouched and the caller decides what a still-raw string means.
68
+ *
69
+ * @param {unknown} v
70
+ * @returns {unknown}
71
+ */
72
+ function coerceJsonNode(v) {
73
+ if (typeof v !== 'string') return v
74
+ const t = v.trim()
75
+ if (!t.startsWith('{') && !t.startsWith('[')) return v
76
+ try {
77
+ return JSON.parse(t)
78
+ } catch {
79
+ return v
80
+ }
81
+ }
82
+
59
83
  // ─── Interruption / resume + image attachment detection ──────────────────────
60
84
 
61
85
  /**
@@ -270,13 +294,17 @@ export function scanSessionForReport(session) {
270
294
  const call = calls[i]
271
295
  if (!call) continue
272
296
  if (safeGet(call, 'tool') === 'iterate_review' || String(safeGet(call, 'tool') ?? '').endsWith('iterate_review')) {
273
- const result = safeGet(call, 'result')
297
+ // Results may arrive as STRING-encoded JSON (arbitrary session text).
298
+ const result = coerceJsonNode(safeGet(call, 'result'))
274
299
  if (result && typeof result === 'object') {
275
300
  const r = /** @type {Record<string, unknown>} */ (result)
276
301
  const report = safeGet(r, 'report')
277
- if (report && typeof report === 'object') {
278
- return /** @type {Record<string, unknown>} */ (report)
279
- }
302
+ // Only a VALIDATED report wins here: a junk `result.report` must
303
+ // neither reach normalizeReport nor shadow a real report nested
304
+ // deeper in the same result (or an older, valid call).
305
+ if (isReviewReport(report)) return report
306
+ const found = findReportInObject(result)
307
+ if (found) return found
280
308
  }
281
309
  }
282
310
  }
@@ -291,8 +319,29 @@ export function scanSessionForReport(session) {
291
319
  const msgCalls = msg && Array.isArray(safeGet(msg, 'tool_calls')) ? safeGet(msg, 'tool_calls') : null
292
320
  if (!msg || !msgCalls) continue
293
321
  const calls = /** @type {Array<Record<string, unknown>>} */ (msgCalls)
294
- for (const call of calls) {
322
+ // Reverse: within a single assistant message the LAST tool call is the
323
+ // most recent one — two parallel iterate_review results must resolve to
324
+ // the newer report, not the older ("Prefers the most recent one").
325
+ for (let j = calls.length - 1; j >= 0; j--) {
326
+ const call = calls[j]
295
327
  if (!call) continue
328
+ // Result surface (same shape the quality-gate / defense scanners read):
329
+ // `{ name|tool, result }` message tool calls. Without this branch a
330
+ // report carried on `tool_calls[].result` was only reachable through
331
+ // the generic content fallback, so the dashboard could stay empty
332
+ // while F8–F10 filled from the very same message.
333
+ const callName = String(safeGet(call, 'name') ?? safeGet(call, 'tool') ?? '')
334
+ if (callName === 'iterate_review' || callName.endsWith('iterate_review')) {
335
+ for (const key of ['result', 'response', 'message']) {
336
+ const node = coerceJsonNode(safeGet(call, key))
337
+ if (!node || typeof node !== 'object') continue
338
+ const r = /** @type {Record<string, unknown>} */ (node)
339
+ const report = safeGet(r, 'report')
340
+ if (isReviewReport(report)) return report
341
+ const found = findReportInObject(node)
342
+ if (found) return found
343
+ }
344
+ }
296
345
  const fn = safeGet(call, 'function')
297
346
  if (fn && typeof fn === 'object') {
298
347
  const f = /** @type {Record<string, unknown>} */ (fn)
@@ -348,7 +397,7 @@ export function isTranscriptManifest(obj) {
348
397
  * @param {unknown} source
349
398
  * @returns {Record<string, unknown> | null}
350
399
  */
351
- function attachLive(manifest, source) {
400
+ export function attachLive(manifest, source) {
352
401
  if (!manifest || typeof manifest !== 'object') return manifest
353
402
  const live = source && typeof source === 'object' ? safeGet(source, 'live') : undefined
354
403
  if (!Array.isArray(live)) return manifest
@@ -372,7 +421,7 @@ function attachLive(manifest, source) {
372
421
  * @param {number} [depth]
373
422
  * @returns {Record<string, unknown> | null}
374
423
  */
375
- function extractTranscript(obj, seen, depth) {
424
+ export function extractTranscript(obj, seen, depth) {
376
425
  if (depth === undefined) depth = 0
377
426
  if (depth > 20) return null
378
427
  if (typeof obj === 'string') {
@@ -503,10 +552,29 @@ export function scanSessionForTranscript(session) {
503
552
  // Prefer the explicit tool-call surface first, then generic content.
504
553
  const calls = safeGet(msg, 'tool_calls')
505
554
  if (Array.isArray(calls)) {
506
- for (const call of calls) {
555
+ // Reverse: the last tool call in one message is the most recent —
556
+ // two parallel iterate_transcript results must resolve to the newer
557
+ // manifest ("Prefers the most recent one").
558
+ const callList = /** @type {Array<Record<string, unknown>>} */ (calls)
559
+ for (let j = callList.length - 1; j >= 0; j--) {
560
+ const call = callList[j]
507
561
  if (!call) continue
508
- const args = safeGet(call, 'arguments')
509
- if (typeof args === 'string') {
562
+ // OpenAI-style calls nest the payload under `function.arguments`
563
+ // (a JSON string) — the shape scanSessionForReport already reads.
564
+ // Missing it here meant a manifest echoed only in that surface was
565
+ // invisible to the observatory (F1–F7 stayed empty).
566
+ const fn = safeGet(call, 'function')
567
+ const candidates = [
568
+ // Result surface first: `{ name|tool, result }` message tool calls
569
+ // (the same shape scanSessionForQualityGate already accepts).
570
+ safeGet(call, 'result'),
571
+ safeGet(call, 'response'),
572
+ safeGet(call, 'message'),
573
+ safeGet(call, 'arguments'),
574
+ fn && typeof fn === 'object' ? safeGet(/** @type {Record<string, unknown>} */ (fn), 'arguments') : undefined,
575
+ ]
576
+ for (const args of candidates) {
577
+ if (args === undefined || args === null) continue
510
578
  const found = extractTranscript(args)
511
579
  if (found) return found
512
580
  }
@@ -520,6 +588,38 @@ export function scanSessionForTranscript(session) {
520
588
  return null
521
589
  }
522
590
 
591
+ /**
592
+ * Normalize the `validations` array the transcript capture carries (contract:
593
+ * `{round, command, exitCode, allowed, rejectReason?}` — one row per
594
+ * validation command run in a round). Defensive like every other normalizer:
595
+ * junk rows are dropped, missing fields degrade to safe defaults, and the
596
+ * input is never mutated.
597
+ *
598
+ * @param {unknown} raw
599
+ * @returns {Array<{ round: number, command: string, exitCode: number | null, allowed: boolean, rejectReason?: string }>}
600
+ */
601
+ export function normalizeValidations(raw) {
602
+ if (!Array.isArray(raw)) return []
603
+ const out = []
604
+ for (const v of /** @type {unknown[]} */ (raw)) {
605
+ if (!v || typeof v !== 'object') continue
606
+ const rec = /** @type {Record<string, unknown>} */ (v)
607
+ const roundRaw = Number(rec.round)
608
+ const exitRaw = rec.exitCode
609
+ const command = typeof rec.command === 'string' ? rec.command : String(rec.command ?? '')
610
+ if (!command) continue
611
+ const rejectReason = typeof rec.rejectReason === 'string' ? rec.rejectReason : ''
612
+ out.push({
613
+ round: Number.isFinite(roundRaw) && roundRaw > 0 ? Math.floor(roundRaw) : 0,
614
+ command,
615
+ exitCode: typeof exitRaw === 'number' && Number.isFinite(exitRaw) ? exitRaw : null,
616
+ allowed: rec.allowed === true,
617
+ ...(rejectReason ? { rejectReason } : {}),
618
+ })
619
+ }
620
+ return out
621
+ }
622
+
523
623
  /**
524
624
  * Normalize a TranscriptManifest into a plain, JSON-safe object so rendering
525
625
  * never touches a live cordis proxy (which can throw on property reads). Every
@@ -599,6 +699,9 @@ export function normalizeTranscript(manifest) {
599
699
  })),
600
700
  checkpoint,
601
701
  timeline: asArray(safeGet(src, 'timeline')).map((t) => ({ ...t })),
702
+ // #6: per-round validation rows (command / exitCode / allowed), captured
703
+ // by the workflow and persisted through `iterate_transcript capture`.
704
+ validations: normalizeValidations(safeGet(src, 'validations')),
602
705
  nudge,
603
706
  approval: ap && typeof ap === 'object'
604
707
  ? { active: asBool(safeGet(ap, 'active')), policy: asStr(safeGet(ap, 'policy')) || 'ask' }
@@ -675,7 +778,10 @@ export function isDefenseEventsResult(obj) {
675
778
 
676
779
  /**
677
780
  * Shared deep-find: first node in `obj` (circular + depth guarded) satisfying a
678
- * predicate, with the same traversal semantics as findReportInObject.
781
+ * predicate, with the same traversal semantics as findReportInObject. String
782
+ * nodes are parsed when they carry a JSON payload (`{...}` / `[...]`), so
783
+ * STRING-encoded tool results / message contents are as visible to the
784
+ * scanSessionFor* scanners as object nodes.
679
785
  *
680
786
  * @param {unknown} obj
681
787
  * @param {(o: Record<string, unknown>) => boolean} predicate
@@ -683,8 +789,15 @@ export function isDefenseEventsResult(obj) {
683
789
  * @param {number} [maxDepth=20]
684
790
  * @returns {Record<string, unknown> | null}
685
791
  */
686
- function findFirstInObject(obj, predicate, seen, maxDepth = 20) {
792
+ export function findFirstInObject(obj, predicate, seen, maxDepth = 20) {
687
793
  if (maxDepth <= 0) return null
794
+ // Guarded JSON.parse for string nodes; a plain / unparseable string has
795
+ // nothing to scan. Keeps the same depth (one hop per parse) and seen guards.
796
+ if (typeof obj === 'string') {
797
+ const parsed = coerceJsonNode(obj)
798
+ if (parsed === obj) return null // not (parseable) JSON text
799
+ return findFirstInObject(parsed, predicate, seen, maxDepth - 1)
800
+ }
688
801
  if (!obj || typeof obj !== 'object') return null
689
802
 
690
803
  const s = seen || new Set()
@@ -730,16 +843,23 @@ function findDefenseResultInObject(obj) {
730
843
 
731
844
  /**
732
845
  * Return the raw result/message node of the most recent execution of `toolName`
733
- * in a session snapshot. Scans `session.toolCalls` (reverse chronological,
734
- * matching the harness's in-memory stream shape) and falls back to
735
- * `session.messages[].content` (assistant tool-call blocks). Returns the raw
736
- * node (object or string) or null.
846
+ * in a session snapshot whose subtree actually contains a match for `find`.
847
+ * Scans `session.toolCalls` (reverse chronological, matching the harness's
848
+ * in-memory stream shape), then `session.messages[].tool_calls` (assistant
849
+ * tool-call blocks), then falls back to `session.messages[].content` — the
850
+ * content fallback loops newest→oldest and only accepts a message whose
851
+ * content embeds a match, so a conversational closing message can never
852
+ * shadow an earlier message's embedded result. A junk LATEST result is
853
+ * skipped (an older valid one still surfaces) because `find` gates every
854
+ * candidate. Returns the raw node (object or string) or null.
737
855
  *
738
856
  * @param {unknown} session
739
857
  * @param {string} toolName
858
+ * @param {(node: unknown) => Record<string, unknown> | null} find deep-find
859
+ * predicate identifying a usable match inside a candidate node
740
860
  * @returns {unknown}
741
861
  */
742
- function latestToolResultNode(session, toolName) {
862
+ export function latestToolResultNode(session, toolName, find) {
743
863
  if (!session || typeof session !== 'object') return null
744
864
 
745
865
  const s = /** @type {Record<string, unknown>} */ (session)
@@ -753,9 +873,9 @@ function latestToolResultNode(session, toolName) {
753
873
  const tool = String(safeGet(call, 'tool') ?? '')
754
874
  if (tool !== toolName && !tool.endsWith(toolName)) continue
755
875
  const result = safeGet(call, 'result')
756
- if (result !== undefined && result !== null) return result
876
+ if (result !== undefined && result !== null && find(result)) return result
757
877
  const message = safeGet(call, 'message')
758
- if (message !== undefined && message !== null) return message
878
+ if (message !== undefined && message !== null && find(message)) return message
759
879
  }
760
880
  }
761
881
 
@@ -765,8 +885,8 @@ function latestToolResultNode(session, toolName) {
765
885
  // Assistant tool-call variant: the tool result may live on the call object
766
886
  // inside message.tool_calls rather than on session.toolCalls. Scan every
767
887
  // message for a matching call (newest-first) before falling back to the
768
- // last message's raw content — a conversational closing message must not
769
- // shadow an earlier message's real tool result.
888
+ // raw contents — a conversational closing message must not shadow an
889
+ // earlier message's real tool result.
770
890
  for (let i = msgs.length - 1; i >= 0; i--) {
771
891
  const msg = msgs[i]
772
892
  if (!msg) continue
@@ -779,17 +899,23 @@ function latestToolResultNode(session, toolName) {
779
899
  const name = String(safeGet(call, 'name') ?? safeGet(call, 'tool') ?? '')
780
900
  if (name !== toolName && !name.endsWith(toolName)) continue
781
901
  const result = safeGet(call, 'result') ?? safeGet(call, 'response') ?? safeGet(call, 'message')
782
- if (result !== undefined && result !== null) return result
902
+ if (result !== undefined && result !== null && find(result)) return result
903
+ // `function.arguments` is deliberately NOT consulted here: it holds the
904
+ // CALL INPUT, and rendering an input as a stored result would show an
905
+ // `iterate_experience add` payload as if it were bank content. Inputs
906
+ // are not results — only result/response/message surfaces count.
783
907
  }
784
908
  }
785
- // Fallback: the last message's raw content (pre-existing behavior — a
786
- // plain conversational string yields null upstream, but an embedded JSON
787
- // body is still worth surfacing for the deep-scan finders).
909
+ // Content fallback: loop messages newest→oldest and return the first
910
+ // content whose subtree actually embeds a match (the deep-scan finders
911
+ // see inside STRING-encoded JSON payloads too). An unmatched newest
912
+ // message must not shadow an older message's embedded result.
788
913
  for (let i = msgs.length - 1; i >= 0; i--) {
789
914
  const msg = msgs[i]
790
915
  if (!msg) continue
791
916
  const content = safeGet(msg, 'content')
792
- if (content !== undefined && content !== null) return content
917
+ if (content === undefined || content === null) continue
918
+ if (find(content)) return content
793
919
  }
794
920
  }
795
921
 
@@ -848,7 +974,7 @@ export function normalizeQualityGateSnapshot(raw) {
848
974
  * @returns {Record<string, unknown> | null}
849
975
  */
850
976
  export function scanSessionForQualityGate(session) {
851
- const node = latestToolResultNode(session, 'iterate_quality_gate')
977
+ const node = latestToolResultNode(session, 'iterate_quality_gate', findQualityGateInObject)
852
978
  if (node === null) return null
853
979
  const found = findQualityGateInObject(node)
854
980
  if (!found) return null
@@ -907,7 +1033,7 @@ export function normalizeExperienceBankResult(raw) {
907
1033
  * @returns {Record<string, unknown> | null}
908
1034
  */
909
1035
  export function scanSessionForExperienceBank(session) {
910
- const node = latestToolResultNode(session, 'iterate_experience')
1036
+ const node = latestToolResultNode(session, 'iterate_experience', findExperienceResultInObject)
911
1037
  if (node === null) return null
912
1038
  const found = findExperienceResultInObject(node)
913
1039
  if (!found) return null
@@ -971,7 +1097,7 @@ export function normalizeDefenseEventsResult(raw) {
971
1097
  * @returns {Record<string, unknown> | null}
972
1098
  */
973
1099
  export function scanSessionForDefenseEvents(session) {
974
- const node = latestToolResultNode(session, 'iterate_defense_events')
1100
+ const node = latestToolResultNode(session, 'iterate_defense_events', findDefenseResultInObject)
975
1101
  if (node === null) return null
976
1102
  const found = findDefenseResultInObject(node)
977
1103
  if (!found) return null
@@ -1122,9 +1248,12 @@ export function extractVerdict(runSummary) {
1122
1248
  * @returns {Record<string, unknown>}
1123
1249
  */
1124
1250
  export function normalizeReport(report) {
1251
+ // Session/model text is arbitrary: `findings` / `rounds` may arrive as a
1252
+ // NON-ARRAY junk value ({} / string / number) — coerce at every ingestion
1253
+ // point so downstream map/iteration never throws `... is not iterable`.
1125
1254
  const convergence = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
1126
- const rounds = /** @type {Array<unknown>} */ (report.rounds ?? [])
1127
- const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
1255
+ const rounds = /** @type {Array<unknown>} */ (Array.isArray(report.rounds) ? report.rounds : [])
1256
+ const findings = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(report.findings) ? report.findings : [])
1128
1257
 
1129
1258
  // Normalize convergence
1130
1259
  const totalRounds =
@@ -1132,6 +1261,16 @@ export function normalizeReport(report) {
1132
1261
  ? convergence.totalRounds
1133
1262
  : rounds.length
1134
1263
 
1264
+ // Why the run stopped: an explicit harness-provided reason always wins;
1265
+ // the derived fallback may only be stamped when the run is actually
1266
+ // FINISHED (flagged converged, or every budgeted round executed). An
1267
+ // in-progress run (rounds < totalRounds, not converged) must stay null —
1268
+ // stamping 'converged' mid-run would render a false finished badge.
1269
+ const explicitReason =
1270
+ typeof convergence.stoppedReason === 'string' && convergence.stoppedReason.trim()
1271
+ ? convergence.stoppedReason
1272
+ : null
1273
+
1135
1274
  const normalizedConvergence = {
1136
1275
  totalRounds,
1137
1276
  findingsByRound: Array.isArray(convergence.findingsByRound)
@@ -1141,7 +1280,12 @@ export function normalizeReport(report) {
1141
1280
  return Array.isArray(rr?.findings) ? rr.findings.length : 0
1142
1281
  }),
1143
1282
  converged: convergence.converged === true,
1144
- stoppedReason: convergence.stoppedReason ?? (rounds.length < totalRounds ? 'converged' : 'max_rounds_reached'),
1283
+ stoppedReason: explicitReason ??
1284
+ (convergence.converged === true
1285
+ ? 'converged'
1286
+ : rounds.length >= totalRounds
1287
+ ? 'max_rounds_reached'
1288
+ : null),
1145
1289
  }
1146
1290
 
1147
1291
  // Compute summary if missing. Always build a NEW object so the input's
@@ -1186,20 +1330,31 @@ export function normalizeReport(report) {
1186
1330
  * @returns {{ totalFindings: number, critical: number, high: number, medium: number, low: number, byDimension: Record<string, number> }}
1187
1331
  */
1188
1332
  function computeSummaryFromFindings(findings) {
1333
+ // Coerce: a junk (non-array) findings value must degrade to "no findings",
1334
+ // not throw `... is not iterable` during React render.
1335
+ const list = Array.isArray(findings) ? findings : []
1189
1336
  const counts = { critical: 0, high: 0, medium: 0, low: 0 }
1337
+ // NULL-PROTOTYPE map: `dimension` comes from arbitrary review/model text, so
1338
+ // a finding tagged `__proto__` / `constructor` / `toString` must count as an
1339
+ // ordinary bucket instead of reading (or assigning through) the prototype
1340
+ // chain. `{} ` would make `byDimension['__proto__'] ?? 0` return
1341
+ // Object.prototype and produce garbage counts — mirrors review.ts, which
1342
+ // already creates its summary map with Object.create(null).
1190
1343
  /** @type {Record<string, number>} */
1191
- const byDimension = {}
1344
+ const byDimension = Object.create(null)
1192
1345
 
1193
- for (const f of findings) {
1346
+ for (const f of list) {
1194
1347
  if (!f || typeof f !== 'object') continue
1195
1348
  const sev = String(f.severity ?? 'low')
1196
- if (sev in counts) counts[sev]++
1349
+ // OWN-property check: `'__proto__' in counts` is true via the prototype
1350
+ // chain, which would corrupt the tally with a non-number.
1351
+ if (Object.prototype.hasOwnProperty.call(counts, sev)) counts[sev]++
1197
1352
  const dim = String(f.dimension ?? 'unknown')
1198
1353
  byDimension[dim] = (byDimension[dim] ?? 0) + 1
1199
1354
  }
1200
1355
 
1201
1356
  return {
1202
- totalFindings: findings.length,
1357
+ totalFindings: list.length,
1203
1358
  critical: counts.critical,
1204
1359
  high: counts.high,
1205
1360
  medium: counts.medium,
@@ -1239,8 +1394,10 @@ export function computeConvergenceProgress(report) {
1239
1394
  * @param {Record<string, unknown>} report
1240
1395
  * @returns {number}
1241
1396
  */
1242
- function currentRoundNumber(report) {
1243
- const rounds = /** @type {Array<Record<string, unknown>>} */ (report.rounds ?? [])
1397
+ export function currentRoundNumber(report) {
1398
+ // Coerce: `rounds` may be a non-array junk value in arbitrary session text —
1399
+ // `for...of {}` would throw `... is not iterable`.
1400
+ const rounds = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(report.rounds) ? report.rounds : [])
1244
1401
  let max = 0
1245
1402
  for (const r of rounds) {
1246
1403
  if (!r || typeof r !== 'object') continue
@@ -1284,12 +1441,16 @@ export function getTotalRounds(report) {
1284
1441
  * @returns {{ critical: number, high: number, medium: number, low: number }}
1285
1442
  */
1286
1443
  export function severityStats(report) {
1287
- const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
1444
+ // Coerce at the ingestion point: non-array `findings` degrades to [].
1445
+ const findings = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(report.findings) ? report.findings : [])
1288
1446
  const counts = { critical: 0, high: 0, medium: 0, low: 0 }
1289
1447
  for (const f of findings) {
1290
1448
  if (!f || typeof f !== 'object') continue
1291
1449
  const sev = String(f.severity ?? 'low')
1292
- if (sev in counts) counts[sev]++
1450
+ // OWN-property check: a junk severity of `__proto__`/`constructor` is not
1451
+ // a bucket and must not read through the prototype chain (`counts[sev]++`
1452
+ // would turn into NaN and try to reassign the prototype).
1453
+ if (Object.prototype.hasOwnProperty.call(counts, sev)) counts[sev]++
1293
1454
  }
1294
1455
  return counts
1295
1456
  }
@@ -1303,13 +1464,17 @@ export function severityStats(report) {
1303
1464
  * @returns {Record<string, Array<Record<string, unknown>>>}
1304
1465
  */
1305
1466
  export function groupByDimension(report) {
1306
- const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
1467
+ // Coerce at the ingestion point: non-array `findings` degrades to [].
1468
+ const findings = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(report.findings) ? report.findings : [])
1469
+ // NULL-PROTOTYPE buckets: `dimension` is arbitrary model text, so
1470
+ // `__proto__` would otherwise resolve to Object.prototype (truthy → skip the
1471
+ // bucket assignment) and crash on `.push` — or worse, mutate the prototype.
1307
1472
  /** @type {Record<string, Array<Record<string, unknown>>>} */
1308
- const groups = {}
1473
+ const groups = Object.create(null)
1309
1474
  for (const f of findings) {
1310
1475
  if (!f || typeof f !== 'object') continue
1311
1476
  const dim = String(f.dimension ?? 'unknown')
1312
- if (!groups[dim]) groups[dim] = []
1477
+ if (!Object.prototype.hasOwnProperty.call(groups, dim)) groups[dim] = []
1313
1478
  groups[dim].push(f)
1314
1479
  }
1315
1480
  return groups
@@ -1328,7 +1493,8 @@ export const TRIAGE_VERDICTS = /** @type {const} */ (['keep', 'skip', 'ignore'])
1328
1493
  * @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
1329
1494
  */
1330
1495
  export function buildTriageState(report) {
1331
- const findings = /** @type {Array<unknown>} */ (report.findings ?? [])
1496
+ // Coerce: non-array `findings` ({} / string) must not fabricate keys.
1497
+ const findings = /** @type {Array<unknown>} */ (Array.isArray(report.findings) ? report.findings : [])
1332
1498
  /** @type {Record<string, 'keep' | 'skip' | 'ignore'>} */
1333
1499
  const state = {}
1334
1500
  for (let i = 0; i < findings.length; i++) {
@@ -1347,7 +1513,9 @@ export function buildTriageState(report) {
1347
1513
  * @returns {string}
1348
1514
  */
1349
1515
  export function hashReport(report) {
1350
- const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
1516
+ // Coerce: non-array `findings` degrades to [] instead of throwing on the
1517
+ // `for...of` below.
1518
+ const findings = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(report.findings) ? report.findings : [])
1351
1519
  // FNV-1a over EVERY finding (file|line|dimension|summary) so two different
1352
1520
  // reports never share a verdict store, while re-running the identical review
1353
1521
  // restores the same verdicts.
@@ -1416,8 +1584,27 @@ export function buildApplyInstruction(entries) {
1416
1584
  )
1417
1585
 
1418
1586
  return (
1419
- `Please call \`iterate_triage\` with the following payload to apply the triage verdicts:\n\n` +
1420
- `\`\`\`json\n${payload}\n\`\`\``
1587
+ `请调用 \`iterate_triage\` 应用下面的 known_intentional 条目(粘贴后由模型调用 ` +
1588
+ `\`iterate_triage\` 的 \`operation: "apply"\` 写入配置:自动去重、写入前备份、失败自动回滚):\n\n` +
1589
+ `\`\`\`json\n${payload}\n\`\`\`\n\n` +
1590
+ `写入完成后请复制面板的「回读已写入条目」指令(\`iterate_triage\` \`operation: "list"\`)核对结果。`
1591
+ )
1592
+ }
1593
+
1594
+ /**
1595
+ * Copy-back instruction for the triage write-back loop: after an `apply`, the
1596
+ * user needs the stored entries echoed back so the panel's "待写回" count has a
1597
+ * confirmation step (copy → apply → read back).
1598
+ *
1599
+ * @returns {string}
1600
+ */
1601
+ export function buildTriageReadbackInstruction() {
1602
+ return (
1603
+ '请调用 `iterate_triage` 回读当前已写入配置的 known_intentional 条目,' +
1604
+ '并把每条的 file / line / dimension / reason 原样列出来供我核对:\n\n' +
1605
+ '```json\n' +
1606
+ `${JSON.stringify({ operation: 'list' }, null, 2)}\n` +
1607
+ '```'
1421
1608
  )
1422
1609
  }
1423
1610
 
@@ -1430,10 +1617,12 @@ export function buildApplyInstruction(entries) {
1430
1617
  * @returns {Array<{ file: string, line?: number, dimension: string, reason: string }>}
1431
1618
  */
1432
1619
  export function collectIgnoredEntries(triageState, findings) {
1620
+ // Coerce: a non-array findings value must never index string characters.
1621
+ const list = Array.isArray(findings) ? findings : []
1433
1622
  const entries = []
1434
1623
  for (const [idx, verdict] of Object.entries(triageState)) {
1435
1624
  if (verdict !== 'ignore') continue
1436
- const finding = findings[Number(idx)]
1625
+ const finding = list[Number(idx)]
1437
1626
  if (!finding) continue
1438
1627
  entries.push({
1439
1628
  file: String(finding.file ?? ''),
@@ -1447,6 +1636,342 @@ export function collectIgnoredEntries(triageState, findings) {
1447
1636
  return entries
1448
1637
  }
1449
1638
 
1639
+ // ─── Paste-able instruction payloads (copy-to-command contract) ─────────────
1640
+ //
1641
+ // The browser client cannot call harness tools, so every action button copies
1642
+ // a text the user pastes back into the session. These builders are the SINGLE
1643
+ // source of truth for those payloads (src/client/index.ts imports them), and
1644
+ // every payload is written against the real parameter schema declared in
1645
+ // src/tools/*.ts so test/parse.test.ts can assert field alignment.
1646
+
1647
+ /** Placeholder the model must replace with the NEW full file content. */
1648
+ const CONTENT_PLACEHOLDER = '<由模型填写:修复后的完整文件内容>'
1649
+ /** Placeholder the model must replace with the current iteration round. */
1650
+ const ROUND_PLACEHOLDER = '<由模型填写:当前迭代轮次(≥1 的整数)>'
1651
+
1652
+ /**
1653
+ * Startup instructions copied by the empty-dashboard CTA.
1654
+ *
1655
+ * dsh exposes NO command / slash-command registration surface to plugins: the
1656
+ * cordis Context services are `tools`, `systemPrompt`, `sessions`, `jobs`,
1657
+ * `agents`, `llm`, `sandbox`, `sandboxPolicy`, `approval`, `sessionProjections`
1658
+ * and `ptcRuntime` — there is no `ctx.command` / prompt-command registry, and
1659
+ * `cordis.patch.yml` only inserts the bundle. So `/iterate` never existed as a
1660
+ * command; the CTA instead copies natural-language instructions that the model
1661
+ * resolves through the `workflow` tool exactly as the skill prompt teaches
1662
+ * ("When the user asks to review or iterate on the project … run an iterate
1663
+ * workflow by calling the `workflow` tool", mode normal / dry-run).
1664
+ *
1665
+ * @type {string}
1666
+ */
1667
+ export const START_INSTRUCTION_FULL =
1668
+ '开始一次完整迭代(审查 → 修复 → 验证 → 复盘):请调用 `workflow` 工具运行 iterate 工作流,' +
1669
+ 'mode: "normal",按系统提示的 Iterate Workflow 执行 plan → 多线程并行评审 → 分诊 → 原子修复 → 每轮验证 → 循环直至收敛或达到 max_rounds → 终报并 `iterate_transcript` capture。' +
1670
+ '开跑前先 `iterate_config({operation:"read"})` 检查 validation.commands:若为空,先停下来提示我用 `iterate_config` 写入验证命令,否则本轮迭代不受任何测试保护。'
1671
+
1672
+ /**
1673
+ * Dry-run (review-only) startup instruction — mirrors the skill prompt's
1674
+ * "review only / dry run / 不要改文件 → mode: dry-run" branch.
1675
+ *
1676
+ * @type {string}
1677
+ */
1678
+ export const START_INSTRUCTION_REVIEW_ONLY =
1679
+ '开始一次仅评审(dry-run,不修改任何文件):请调用 `workflow` 工具运行 iterate 工作流,' +
1680
+ 'mode: "dry-run",按系统提示的 Iterate Workflow 执行 plan → 多线程并行评审 → 分诊 → `iterate_review aggregate` / `meta-review` 终报并 `iterate_transcript` capture;' +
1681
+ '全程不要调用 `iterate_fix` / `iterate_rollback`。'
1682
+
1683
+ /** The registered startup instruction set the client CTA copies from. */
1684
+ export const START_INSTRUCTIONS = {
1685
+ full: START_INSTRUCTION_FULL,
1686
+ reviewOnly: START_INSTRUCTION_REVIEW_ONLY,
1687
+ }
1688
+
1689
+ /** Normalize an arbitrary finding-ish node into the payload field set. */
1690
+ function fixFindingPayload(finding) {
1691
+ const f = finding && typeof finding === 'object' ? finding : {}
1692
+ const file = String(f.file ?? '')
1693
+ return {
1694
+ file,
1695
+ ...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
1696
+ dimension: String(f.dimension ?? ''),
1697
+ severity: String(f.severity ?? ''),
1698
+ summary: String(f.summary ?? ''),
1699
+ ...(f.failure_scenario ? { failure_scenario: String(f.failure_scenario) } : {}),
1700
+ ...(f.suggested_fix ? { suggested_fix: String(f.suggested_fix) } : {}),
1701
+ }
1702
+ }
1703
+
1704
+ /**
1705
+ * Staged `iterate_fix` instruction for ONE finding.
1706
+ *
1707
+ * The tool schema (`src/tools/fix.ts`) requires `file` / `content` / `finding`
1708
+ * / `round`; a raw JSON blob without `content` + `round` made the model guess
1709
+ * the two hardest values, so the payload now names every required field and
1710
+ * marks the two the model must fill in (`content` = the full NEW file, `round`
1711
+ * = the current round) instead of pretending to ship a complete call.
1712
+ *
1713
+ * @param {Record<string, unknown>} finding
1714
+ * @param {{ round?: number } | null} [opts] current round, when known by the UI
1715
+ * @returns {string}
1716
+ */
1717
+ export function buildFixInstruction(finding, opts) {
1718
+ const payload = fixFindingPayload(finding)
1719
+ if (!payload.file) return ''
1720
+ const round = opts && Number.isInteger(opts.round) ? opts.round : null
1721
+ const call = {
1722
+ file: payload.file,
1723
+ content: CONTENT_PLACEHOLDER,
1724
+ finding: payload,
1725
+ round: round !== null ? round : ROUND_PLACEHOLDER,
1726
+ }
1727
+ return [
1728
+ '请修复下面这一个 finding。`iterate_fix` 一次只接受一个 finding,且 file / content / finding / round 四个参数全部必填:',
1729
+ `步骤 1:读取 \`${payload.file}\`,按 finding 生成修复后的【完整文件内容】(content 占位必须换成真实文件内容)。`,
1730
+ `步骤 2:调用 \`iterate_fix\`,把 content 与 round 填成真实值${round !== null ? `(本轮 round = ${round})` : ''}:`,
1731
+ '```json',
1732
+ JSON.stringify(call, null, 2),
1733
+ '```',
1734
+ '需要超过 atomic 行数上限时,另加 "force": true。',
1735
+ ].join('\n')
1736
+ }
1737
+
1738
+ /**
1739
+ * Batch assign instruction: N findings, but `iterate_fix` has NO array
1740
+ * parameter — the payload says so explicitly ("一次一个").
1741
+ *
1742
+ * @param {Array<Record<string, unknown>>} findings
1743
+ * @returns {string}
1744
+ */
1745
+ export function buildAssignFixesInstruction(findings) {
1746
+ const list = (Array.isArray(findings) ? findings : [])
1747
+ .filter((f) => f && typeof f === 'object')
1748
+ if (list.length === 0) return ''
1749
+ const payload = list.map((f) => fixFindingPayload(f)).filter((p) => p.file)
1750
+ if (payload.length === 0) return ''
1751
+ return [
1752
+ `请修复下面这 ${payload.length} 个 findings。注意:\`iterate_fix\` 每次只处理【一个】finding(没有数组入参),请逐个调用 iterate_fix——一次一个 finding,处理完一个再发起下一个,绝不要把整批数组当作参数传入。`,
1753
+ '每个 finding 都走同样的两步:① 读取文件生成修复后的完整 content;② 调用 iterate_fix(file / content / finding / round 必填,content 与 round 由你填写)。',
1754
+ '待修复清单:',
1755
+ '```json',
1756
+ JSON.stringify(payload, null, 2),
1757
+ '```',
1758
+ ].join('\n')
1759
+ }
1760
+
1761
+ /**
1762
+ * Architectural-fix approval instruction: `force: true` exists on the tool,
1763
+ * but the four required fields still apply — the old text implied a lone
1764
+ * `force` flag was enough.
1765
+ *
1766
+ * @param {Array<Record<string, unknown>>} [findings] optional scoped findings
1767
+ * @returns {string}
1768
+ */
1769
+ export function buildArchitecturalFixInstruction(findings) {
1770
+ const list = (Array.isArray(findings) ? findings : []).filter((f) => f && typeof f === 'object')
1771
+ const lines = [
1772
+ '请批准并执行架构型修复(允许超过 atomic 行数上限):对每个架构型 finding 逐个调用 `iterate_fix`,一次一个 finding,并显式设置 `"force": true`;',
1773
+ 'file / content / finding / round 四个必填参数一个都不能少(content = 修复后的完整文件内容,round = 当前迭代轮次,两者由你填写)。',
1774
+ ]
1775
+ if (list.length > 0) {
1776
+ lines.push('本项目剩余架构型 finding:', '```json', JSON.stringify(list.map((f) => fixFindingPayload(f)), null, 2), '```')
1777
+ }
1778
+ return lines.join('\n')
1779
+ }
1780
+
1781
+ /**
1782
+ * `iterate_checkpoint` resume payload. The tool's `mode` / `round` /
1783
+ * `maxRounds` / `fixedCount` are SAVE-only inputs; `resume` takes nothing but
1784
+ * `operation`, and `maxRounds: null` was an invalid-integer field.
1785
+ *
1786
+ * @returns {string}
1787
+ */
1788
+ export function buildCheckpointResumeInstruction() {
1789
+ return (
1790
+ '请调用 `iterate_checkpoint` 从断点恢复迭代(加载断点并把 resumeCount +1):\n\n' +
1791
+ '```json\n' +
1792
+ `${JSON.stringify({ operation: 'resume' }, null, 2)}\n` +
1793
+ '```'
1794
+ )
1795
+ }
1796
+
1797
+ /**
1798
+ * `iterate_checkpoint` clear payload (stale checkpoint reset).
1799
+ *
1800
+ * @returns {string}
1801
+ */
1802
+ export function buildCheckpointClearInstruction() {
1803
+ return (
1804
+ '请调用 `iterate_checkpoint` 清除当前断点:\n\n' +
1805
+ '```json\n' +
1806
+ `${JSON.stringify({ operation: 'clear' }, null, 2)}\n` +
1807
+ '```'
1808
+ )
1809
+ }
1810
+
1811
+ /**
1812
+ * `iterate_quality_gate` query instruction with explicit JSON args — the old
1813
+ * plain-prose version had no `operation` and occasionally lost the enum.
1814
+ *
1815
+ * @returns {string}
1816
+ */
1817
+ export function buildQualityGateQueryInstruction() {
1818
+ return (
1819
+ '请调用 `iterate_quality_gate` 查询当前质量门禁状态(读取磁盘上持久化的证书):\n\n' +
1820
+ '```json\n' +
1821
+ `${JSON.stringify({ operation: 'read' }, null, 2)}\n` +
1822
+ '```'
1823
+ )
1824
+ }
1825
+
1826
+ /**
1827
+ * `iterate_quality_gate` clear payload (reset a stale FAIL certificate).
1828
+ *
1829
+ * @returns {string}
1830
+ */
1831
+ export function buildQualityGateClearInstruction() {
1832
+ return (
1833
+ '请调用 `iterate_quality_gate` 清除当前质量门禁证书:\n\n' +
1834
+ '```json\n' +
1835
+ `${JSON.stringify({ operation: 'clear' }, null, 2)}\n` +
1836
+ '```'
1837
+ )
1838
+ }
1839
+
1840
+ /**
1841
+ * `iterate_rollback` payload for one applied fix id.
1842
+ *
1843
+ * @param {string} id
1844
+ * @returns {string}
1845
+ */
1846
+ export function buildRollbackInstruction(id) {
1847
+ return (
1848
+ '请调用 `iterate_rollback` 回滚以下修复:\n\n' +
1849
+ '```json\n' +
1850
+ `${JSON.stringify({ id: String(id || '') }, null, 2)}\n` +
1851
+ '```'
1852
+ )
1853
+ }
1854
+
1855
+ /**
1856
+ * `iterate_experience` list instruction (F9 empty state + header button).
1857
+ *
1858
+ * @returns {string}
1859
+ */
1860
+ export function buildExperienceListInstruction() {
1861
+ return (
1862
+ '请调用 `iterate_experience` 列出经验银行中的所有经验,并把完整返回结果回显到会话里:\n\n' +
1863
+ '```json\n' +
1864
+ `${JSON.stringify({ operation: 'list' }, null, 2)}\n` +
1865
+ '```'
1866
+ )
1867
+ }
1868
+
1869
+ /**
1870
+ * `iterate_defense_events` list instruction (F10 empty state + header button).
1871
+ *
1872
+ * @returns {string}
1873
+ */
1874
+ export function buildDefenseEventsListInstruction() {
1875
+ return (
1876
+ '请调用 `iterate_defense_events` 列出全部防御事件,并把完整返回结果回显到会话里:\n\n' +
1877
+ '```json\n' +
1878
+ `${JSON.stringify({ operation: 'list' }, null, 2)}\n` +
1879
+ '```'
1880
+ )
1881
+ }
1882
+
1883
+ /**
1884
+ * Disk snapshot pull instruction (#4): the browser client has no filesystem
1885
+ * access, so the only way to fill the panels from `.iterate/` is to ask the
1886
+ * model to call the read-side tools and echo their FULL results back into the
1887
+ * session stream (which is exactly what the session scanners consume).
1888
+ *
1889
+ * @returns {string}
1890
+ */
1891
+ export function buildDiskSnapshotInstruction() {
1892
+ const lines = [
1893
+ '请从磁盘拉取 iterate 快照:逐个调用下面的只读工具,并把每个工具的【完整返回结果】原样回显到会话里(不要只给摘要——插件面板靠这些回显填充):',
1894
+ ]
1895
+ DISK_SNAPSHOT_SOURCES.forEach((s, i) => {
1896
+ lines.push(`${i + 1}. \`${s.tool}\`(${s.label}):${JSON.stringify(s.args)}`)
1897
+ })
1898
+ lines.push('回显后,观测台实时流 / F2 跨轮对比 / F5 断点 / F8 质量门禁 / F9 经验银行 / F10 防御事件会自动读取这些结果。')
1899
+ return lines.join('\n')
1900
+ }
1901
+
1902
+ /**
1903
+ * Read-side tools whose results fill the client panels, with the exact
1904
+ * argument objects their schemas accept (all operations are optional-input).
1905
+ * @type {Array<{ tool: string, args: Record<string, unknown>, label: string }>}
1906
+ */
1907
+ export const DISK_SNAPSHOT_SOURCES = [
1908
+ { tool: 'iterate_status', args: {}, label: '运行状态汇总' },
1909
+ { tool: 'iterate_transcript', args: { operation: 'read' }, label: 'transcript 观测清单' },
1910
+ { tool: 'iterate_quality_gate', args: { operation: 'read' }, label: '质量门禁证书' },
1911
+ { tool: 'iterate_experience', args: { operation: 'list' }, label: '经验银行' },
1912
+ { tool: 'iterate_defense_events', args: { operation: 'list' }, label: '防御事件流' },
1913
+ { tool: 'iterate_history', args: {}, label: '决策日志与修复注册表(跨轮/跨会话对比)' },
1914
+ ]
1915
+
1916
+ /**
1917
+ * Whether `toolName` has actually been called in this session snapshot — the
1918
+ * difference between "磁盘无数据" (we pulled it and it is empty) and "尚未拉取"
1919
+ * (we never asked the model to read it).
1920
+ *
1921
+ * @param {unknown} session
1922
+ * @param {string} toolName
1923
+ * @returns {boolean}
1924
+ */
1925
+ export function toolCalledInSession(session, toolName) {
1926
+ if (!session || typeof session !== 'object' || typeof toolName !== 'string' || !toolName) return false
1927
+ const s = /** @type {Record<string, unknown>} */ (session)
1928
+
1929
+ const toolCalls = safeGet(s, 'toolCalls')
1930
+ if (Array.isArray(toolCalls)) {
1931
+ for (const call of /** @type {unknown[]} */ (toolCalls)) {
1932
+ if (!call || typeof call !== 'object') continue
1933
+ const t = String(safeGet(/** @type {Record<string, unknown>} */ (call), 'tool') ?? '')
1934
+ if (t === toolName || t.endsWith(toolName)) return true
1935
+ }
1936
+ }
1937
+
1938
+ const messages = safeGet(s, 'messages')
1939
+ if (Array.isArray(messages)) {
1940
+ for (const msg of /** @type {unknown[]} */ (messages)) {
1941
+ if (!msg || typeof msg !== 'object') continue
1942
+ const calls = safeGet(/** @type {Record<string, unknown>} */ (msg), 'tool_calls')
1943
+ if (!Array.isArray(calls)) continue
1944
+ for (const call of /** @type {unknown[]} */ (calls)) {
1945
+ if (!call || typeof call !== 'object') continue
1946
+ const c = /** @type {Record<string, unknown>} */ (call)
1947
+ const name = String(safeGet(c, 'name') ?? safeGet(c, 'tool') ?? '')
1948
+ if (name === toolName || name.endsWith(toolName)) return true
1949
+ const fn = safeGet(c, 'function')
1950
+ if (fn && typeof fn === 'object' && String(safeGet(/** @type {Record<string, unknown>} */ (fn), 'name') ?? '') === toolName) {
1951
+ return true
1952
+ }
1953
+ }
1954
+ }
1955
+ }
1956
+ return false
1957
+ }
1958
+
1959
+ /**
1960
+ * Empty-state copy that separates the two very different "nothing here"
1961
+ * situations: the tool was never pulled from disk (call to action) vs. it was
1962
+ * pulled and the disk really has no data.
1963
+ *
1964
+ * @param {boolean} pulled whether the backing tool was called this session
1965
+ * @param {string} topic what is missing (e.g. "质量门禁证书")
1966
+ * @returns {string}
1967
+ */
1968
+ export function diskEmptyStateText(pulled, topic) {
1969
+ const subject = topic || '数据'
1970
+ return pulled
1971
+ ? `已从磁盘拉取:${subject}暂无记录(磁盘无数据)。`
1972
+ : `尚未从磁盘拉取${subject}:数据存放在项目 .iterate/ 下,点「拉取磁盘快照」复制指令发回会话即可回填。`
1973
+ }
1974
+
1450
1975
  // ─── Finding filtering ──────────────────────────────────────────────────────
1451
1976
 
1452
1977
  /**
@@ -1543,6 +2068,9 @@ export function buildFilterOptions(findings) {
1543
2068
  /** @type {Record<string, number>} */
1544
2069
  const dimCounts = {}
1545
2070
  for (const f of list) {
2071
+ // TriagePanel feeds report findings in unconditionally — ONE null / junk
2072
+ // element must not kill the whole filter bar with a TypeError.
2073
+ if (!f || typeof f !== 'object') continue
1546
2074
  const sev = String(f.severity ?? 'low')
1547
2075
  const sv = severities.find((s) => s.value === sev)
1548
2076
  if (sv) sv.count++
@@ -1581,7 +2109,9 @@ export function countVerdicts(triageState) {
1581
2109
  * @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
1582
2110
  */
1583
2111
  export function batchSetVerdict(triageState, indices, verdict) {
1584
- if (verdict !== 'keep' && verdict !== 'skip' && verdict !== 'ignore') return triageState
2112
+ // TRIAGE_VERDICTS is the source of truth for valid verdict keys — anything
2113
+ // outside it (unknown key, junk type) is rejected unchanged.
2114
+ if (!TRIAGE_VERDICTS.includes(verdict)) return triageState
1585
2115
  if (!Array.isArray(indices) || indices.length === 0) return triageState
1586
2116
  const next = { ...triageState }
1587
2117
  for (const idx of indices) {
@@ -1601,6 +2131,8 @@ export function batchSetVerdict(triageState, indices, verdict) {
1601
2131
  * @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
1602
2132
  */
1603
2133
  export function setAllVerdicts(triageState, verdict, indices) {
2134
+ // Delegates to batchSetVerdict, whose TRIAGE_VERDICTS validation rejects an
2135
+ // unknown verdict key here as well (unchanged state is returned).
1604
2136
  const targets = Array.isArray(indices)
1605
2137
  ? indices
1606
2138
  : Object.keys(triageState ?? {}).map(Number)
@@ -1745,6 +2277,21 @@ export const CONFIG_EDIT_FIELDS = [
1745
2277
  { key: 'max_rounds', label: '最大轮数', hint: '正整数' },
1746
2278
  { key: 'review.scope', label: '审查范围', hint: '"full" 或 "changed-only"' },
1747
2279
  { key: 'reasoning_effort', label: '审查推理强度', hint: '"low" / "medium" / "high",缺省跟随模型默认' },
2280
+ {
2281
+ key: 'validation.commands',
2282
+ label: '验证命令',
2283
+ hint: '数组,每项是一条白名单命令字符串,如 ["npm test","npm run lint"];每轮验证逐条执行。留空数组 = 不验证(本轮迭代不受任何测试保护)',
2284
+ },
2285
+ {
2286
+ key: 'language',
2287
+ label: '界面语言',
2288
+ hint: '"zh" 或 "en"(默认 "en"):控制审批理由、防御事件标签等面向人文案的语言',
2289
+ },
2290
+ {
2291
+ key: 'personalization.known_intentional',
2292
+ label: '已知有意问题',
2293
+ hint: '数组,每项 {file, line?, dimension, reason};命中条目在评审中被跳过。可用分诊面板的「回读已写入条目」核对',
2294
+ },
1748
2295
  { key: 'atomic.max_lines', label: '原子修复上限行数', hint: '正整数' },
1749
2296
  { key: 'git.push_per_round', label: '每轮推送', hint: 'true / false' },
1750
2297
  ]
@@ -1760,13 +2307,19 @@ export function buildConfigEditGuide() {
1760
2307
  '---------------------',
1761
2308
  '配置文件:项目根目录 iterate.config.yaml。',
1762
2309
  '',
2310
+ '⚠ 未配置 validation.commands 时迭代不被保护:每轮 validate 拿到空结果会被当作',
2311
+ ' “验证通过”,等于在没有任何测试保护的情况下继续收敛。第一次启动迭代前,',
2312
+ ' 请先给 validation.commands 配上至少一条真实命令(如 npm test)。',
2313
+ '',
1763
2314
  '可编辑字段:',
1764
2315
  ...CONFIG_EDIT_FIELDS.map((f) => `- ${f.key}(${f.label}):${f.hint}`),
1765
2316
  '',
1766
2317
  '让模型帮你改:',
1767
2318
  '1. 调用 iterate_config({ operation: "read" }) 查看当前配置;',
1768
- '2. 说明想改的字段,例如「把 max_rounds 改成 5,dimensions 只保留 correctness 和 security」;',
2319
+ '2. 说明想改的字段,例如「把 max_rounds 改成 5,validation.commands 加上 npm test」;',
1769
2320
  '3. 模型会调用 iterate_config({ operation: "write", updates: {...} }) 写入,写入前自动备份,失败自动回滚。',
2321
+ '',
2322
+ '也可以用设置页的“选字段 → 生成写入指令”直接复制第 3 步的指令粘贴给模型。',
1770
2323
  ]
1771
2324
  return lines.join('\n')
1772
2325
  }
@@ -1784,6 +2337,48 @@ export function buildConfigEditInstruction(desiredChanges) {
1784
2337
  return `请调用 \`iterate_config\` 写入以下配置更新:\n\n\`\`\`json\n${payload}\n\`\`\``
1785
2338
  }
1786
2339
 
2340
+ /** Placeholder for a config value the user still has to supply. */
2341
+ export const CONFIG_VALUE_PLACEHOLDER = '<由模型填写:该字段的新值>'
2342
+
2343
+ /**
2344
+ * Look up an editable config field by its dotted key (gap #8).
2345
+ *
2346
+ * @param {string} key
2347
+ * @returns {{ key: string, label: string, hint: string } | null}
2348
+ */
2349
+ export function configFieldByKey(key) {
2350
+ if (typeof key !== 'string' || !key) return null
2351
+ return CONFIG_EDIT_FIELDS.find((f) => f.key === key) ?? null
2352
+ }
2353
+
2354
+ /**
2355
+ * Gap #8: turn a single selected config field into a paste-able write
2356
+ * instruction. `value` may be omitted, in which case an explicit
2357
+ * "filled in by the model" placeholder is emitted so the user knows what to
2358
+ * complete (same convention as the other instruction builders).
2359
+ *
2360
+ * @param {string} key
2361
+ * @param {unknown} [value]
2362
+ * @returns {string}
2363
+ */
2364
+ export function buildConfigFieldInstruction(key, value) {
2365
+ const field = configFieldByKey(key)
2366
+ const label = field ? field.label : key
2367
+ const hint = field ? field.hint : '见 iterate.config.yaml'
2368
+ const updates = { [key]: value === undefined ? CONFIG_VALUE_PLACEHOLDER : value }
2369
+ // Delegate the payload to `buildConfigEditInstruction` so the multi-field
2370
+ // builder stays a live code path (it used to be test-only dead code).
2371
+ const payload = buildConfigEditInstruction(updates)
2372
+ return [
2373
+ `配置字段 · ${label}(${key})`,
2374
+ `字段说明:${hint}`,
2375
+ '',
2376
+ payload,
2377
+ '',
2378
+ '写入前请先 \`operation: "read"\` 展示当前值;写入由工具自动备份,失败自动回滚。',
2379
+ ].join('\n')
2380
+ }
2381
+
1787
2382
  /**
1788
2383
  * Keyboard shortcut → triage verdict mapping (used by the triage panel).
1789
2384
  * @type {Record<string, 'keep' | 'skip' | 'ignore'>}
@@ -1805,7 +2400,10 @@ export const VERDICT_SHORTCUTS = {
1805
2400
  * @returns {'keep' | 'skip' | 'ignore' | null}
1806
2401
  */
1807
2402
  export function keyToVerdict(key) {
1808
- return VERDICT_SHORTCUTS[key] ?? null
2403
+ const verdict = VERDICT_SHORTCUTS[key]
2404
+ // TRIAGE_VERDICTS is the verdict source of truth: a shortcut entry whose
2405
+ // value falls outside the verdict set is not a triage shortcut.
2406
+ return verdict !== undefined && TRIAGE_VERDICTS.includes(verdict) ? verdict : null
1809
2407
  }
1810
2408
 
1811
2409
  // ─── Select-all keys ────────────────────────────────────────────────────────
@@ -1940,20 +2538,26 @@ export function filterTimelineEntries(entries, opts) {
1940
2538
  }
1941
2539
 
1942
2540
  /**
1943
- * Serialize the full observatory state (manifest + live feed) into a JSON
1944
- * string the client can copy/export. Always includes an `exportedAt` stamp and
1945
- * guards against non-serializable / oversized payloads by falling back to the
1946
- * manifest only.
2541
+ * Serialize the full observatory state (manifest + live feed + pulled disk
2542
+ * snapshots) into a JSON string the client can copy/export. Always includes an
2543
+ * `exportedAt` stamp and guards against non-serializable / oversized payloads
2544
+ * by falling back to the manifest only.
2545
+ *
2546
+ * Gap #11: `extra` lets the caller attach the disk-side artifacts
2547
+ * (qualityGate / experienceBank / defenseEvents / report) that the export
2548
+ * previously dropped, so the copy actually captures the on-disk state.
1947
2549
  *
1948
2550
  * @param {unknown} manifest
1949
2551
  * @param {unknown} live
2552
+ * @param {Record<string, unknown>} [extra]
1950
2553
  * @returns {string}
1951
2554
  */
1952
- export function serializeObservatoryExport(manifest, live) {
2555
+ export function serializeObservatoryExport(manifest, live, extra) {
1953
2556
  const payload = {
1954
2557
  exportedAt: new Date().toISOString(),
1955
2558
  manifest: manifest && typeof manifest === 'object' ? manifest : null,
1956
2559
  live: Array.isArray(live) ? live : [],
2560
+ ...pickExportExtras(extra),
1957
2561
  }
1958
2562
  try {
1959
2563
  return JSON.stringify(payload, null, 2)
@@ -1963,6 +2567,105 @@ export function serializeObservatoryExport(manifest, live) {
1963
2567
  }
1964
2568
  }
1965
2569
 
2570
+ /** Disk-side keys the export recognizes (gap #11). */
2571
+ export const EXPORT_EXTRA_KEYS = ['qualityGate', 'experienceBank', 'defenseEvents', 'report']
2572
+
2573
+ /**
2574
+ * Copy only the known extra keys, dropping `undefined` so the export shape
2575
+ * stays stable when a snapshot has not been pulled yet.
2576
+ *
2577
+ * @param {unknown} extra
2578
+ * @returns {Record<string, unknown>}
2579
+ */
2580
+ function pickExportExtras(extra) {
2581
+ const out = {}
2582
+ if (!extra || typeof extra !== 'object') return out
2583
+ for (const key of EXPORT_EXTRA_KEYS) {
2584
+ if (!Object.prototype.hasOwnProperty.call(extra, key)) continue
2585
+ const value = extra[key]
2586
+ if (value === undefined) continue
2587
+ out[key] = value
2588
+ }
2589
+ return out
2590
+ }
2591
+
2592
+ /**
2593
+ * Gap #13: build the cross-round comparison table for a single session.
2594
+ * One row per round: findings raised, findings fixed, severity mix and the
2595
+ * validation command outcomes recorded on the manifest.
2596
+ *
2597
+ * Cross-*session* comparison is intentionally out of scope — that is what the
2598
+ * #4 pull instruction is for (see `buildDiskSnapshotInstruction`).
2599
+ *
2600
+ * @param {unknown} report
2601
+ * @param {unknown} validations
2602
+ * @returns {Array<{round: number, findings: number, fixed: number, severities: Record<string, number>, validations: Array<{command: string, exitCode: number|null, allowed: boolean}>}>}
2603
+ */
2604
+ export function buildRoundComparison(report, validations) {
2605
+ const rounds = new Map()
2606
+ const rowsFor = (round) => {
2607
+ const n = Number(round)
2608
+ const key = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
2609
+ if (!rounds.has(key)) {
2610
+ rounds.set(key, { round: key, findings: 0, fixed: 0, severities: Object.create(null), validations: [] })
2611
+ }
2612
+ return rounds.get(key)
2613
+ }
2614
+
2615
+ const list = report && Array.isArray(report.findings) ? report.findings : []
2616
+ for (const f of list) {
2617
+ if (!f || typeof f !== 'object') continue
2618
+ const row = rowsFor(f.round)
2619
+ row.findings += 1
2620
+ const sev = typeof f.severity === 'string' && f.severity ? f.severity : 'unknown'
2621
+ row.severities[sev] = (row.severities[sev] || 0) + 1
2622
+ if (f.status === 'fixed') row.fixed += 1
2623
+ }
2624
+
2625
+ const vals = Array.isArray(validations) ? validations : []
2626
+ for (const v of vals) {
2627
+ if (!v || typeof v !== 'object') continue
2628
+ const row = rowsFor(v.round)
2629
+ row.validations.push({
2630
+ command: typeof v.command === 'string' ? v.command : '',
2631
+ exitCode: typeof v.exitCode === 'number' ? v.exitCode : null,
2632
+ allowed: v.allowed === true,
2633
+ })
2634
+ }
2635
+
2636
+ return [...rounds.values()]
2637
+ .filter((r) => r.round > 0)
2638
+ .sort((a, b) => a.round - b.round)
2639
+ .map((r) => ({ ...r, severities: { ...r.severities } }))
2640
+ }
2641
+
2642
+ /**
2643
+ * Gap #5: decide what the convergence dashboard should say.
2644
+ *
2645
+ * - `running` — a manifest exists and the run has not stopped: show the live
2646
+ * round/phase instead of the onboarding copy (gap #5's actual bug: the old
2647
+ * check was `!transcript`, so a run in progress still rendered "how to start").
2648
+ * - `done` — a manifest exists but the run is no longer active: show the
2649
+ * outcome (with `stoppedReason` when the workflow recorded one).
2650
+ * - `empty` — nothing has run yet: show the start-instruction prompt.
2651
+ *
2652
+ * @param {unknown} transcript normalized transcript (or a raw manifest)
2653
+ * @returns {{state: 'running'|'done'|'empty', round: number, phase: string, stoppedReason: string}}
2654
+ */
2655
+ export function dashboardRunState(transcript) {
2656
+ const t = transcript && typeof transcript === 'object' ? transcript : null
2657
+ if (!t) return { state: 'empty', round: 0, phase: '', stoppedReason: '' }
2658
+ const rawRound = Number(t.round)
2659
+ const round = Number.isFinite(rawRound) && rawRound > 0 ? Math.floor(rawRound) : 1
2660
+ const stoppedReason = typeof t.stoppedReason === 'string' ? t.stoppedReason.trim() : ''
2661
+ return {
2662
+ state: t.active === true ? 'running' : 'done',
2663
+ round,
2664
+ phase: latestPhase(t.phases),
2665
+ stoppedReason,
2666
+ }
2667
+ }
2668
+
1966
2669
  /**
1967
2670
  * Latest non-empty workflow phase name (plan / review / fix / validate /
1968
2671
  * report …) from the transcript manifest's phase list. The last recorded