iterate-plugin 3.5.7 → 3.7.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 (79) hide show
  1. package/README.md +97 -22
  2. package/README.zh-CN.md +79 -19
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +516 -35
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +193 -0
  9. package/dist/index.js +61 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +54 -14
  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 +61 -14
  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 +106 -28
  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/dist/working-directory.js +114 -0
  38. package/lib/client.js +1912 -512
  39. package/lib/parse.js +1001 -103
  40. package/package.json +16 -9
  41. package/src/approval-gate.ts +160 -23
  42. package/src/atomic-fs.ts +152 -17
  43. package/src/client/i18n.ts +517 -0
  44. package/src/client/index.ts +1031 -369
  45. package/src/client/session-cache.ts +54 -0
  46. package/src/config-loader.ts +549 -36
  47. package/src/config-write.ts +108 -26
  48. package/src/evidence.ts +194 -92
  49. package/src/file-lock.ts +211 -0
  50. package/src/index.ts +60 -29
  51. package/src/jobs.ts +63 -12
  52. package/src/live.ts +50 -12
  53. package/src/meta-review.ts +151 -34
  54. package/src/method-scope.ts +31 -8
  55. package/src/review-scope.ts +33 -10
  56. package/src/review.ts +96 -17
  57. package/src/session-hooks.ts +60 -13
  58. package/src/skill-prompt.ts +396 -66
  59. package/src/tools/checkpoint.ts +81 -19
  60. package/src/tools/config.ts +44 -2
  61. package/src/tools/context.ts +25 -5
  62. package/src/tools/decision-log.ts +114 -23
  63. package/src/tools/defense-events.ts +87 -31
  64. package/src/tools/defense-store.ts +30 -3
  65. package/src/tools/experience-bank.ts +112 -44
  66. package/src/tools/experience-store.ts +54 -5
  67. package/src/tools/fix.ts +258 -20
  68. package/src/tools/history.ts +74 -15
  69. package/src/tools/present.ts +89 -0
  70. package/src/tools/prune.ts +154 -51
  71. package/src/tools/quality-gate.ts +57 -2
  72. package/src/tools/quality-store.ts +18 -1
  73. package/src/tools/review.ts +155 -6
  74. package/src/tools/transcript.ts +178 -36
  75. package/src/tools/triage.ts +70 -13
  76. package/src/tools/validate.ts +138 -32
  77. package/src/transcript.ts +279 -4
  78. package/src/types.ts +29 -1
  79. package/src/working-directory.ts +126 -0
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
  /**
@@ -183,9 +207,15 @@ export function countSessionImages(session) {
183
207
 
184
208
  /**
185
209
  * Check whether `obj` is a valid ReviewReport-like object.
186
- * The minimum requirement: an object with `convergence` (object),
210
+ * The minimum requirement: an object with `convergence` (a RECORD),
187
211
  * `findings` (array), and `rounds` (array).
188
212
  *
213
+ * The convergence guard is also the TranscriptManifest discriminator: a
214
+ * manifest carries `convergence` / `rounds` / `findings` too, but its
215
+ * convergence is the per-round count SERIES (an array). Since
216
+ * `typeof [] === 'object'` in JS, only an explicit array rejection keeps the
217
+ * two shapes apart — see `isTranscriptManifest`.
218
+ *
189
219
  * @param {unknown} obj
190
220
  * @returns {obj is Record<string, unknown>}
191
221
  */
@@ -193,12 +223,8 @@ export function isReviewReport(obj) {
193
223
  if (!obj || typeof obj !== 'object') return false
194
224
  const o = /** @type {Record<string, unknown>} */ (obj)
195
225
  const convergence = safeGet(o, 'convergence')
196
- return (
197
- typeof convergence === 'object' &&
198
- convergence !== null &&
199
- Array.isArray(safeGet(o, 'findings')) &&
200
- Array.isArray(safeGet(o, 'rounds'))
201
- )
226
+ if (!convergence || typeof convergence !== 'object' || Array.isArray(convergence)) return false
227
+ return Array.isArray(safeGet(o, 'findings')) && Array.isArray(safeGet(o, 'rounds'))
202
228
  }
203
229
 
204
230
  /**
@@ -270,13 +296,17 @@ export function scanSessionForReport(session) {
270
296
  const call = calls[i]
271
297
  if (!call) continue
272
298
  if (safeGet(call, 'tool') === 'iterate_review' || String(safeGet(call, 'tool') ?? '').endsWith('iterate_review')) {
273
- const result = safeGet(call, 'result')
299
+ // Results may arrive as STRING-encoded JSON (arbitrary session text).
300
+ const result = coerceJsonNode(safeGet(call, 'result'))
274
301
  if (result && typeof result === 'object') {
275
302
  const r = /** @type {Record<string, unknown>} */ (result)
276
303
  const report = safeGet(r, 'report')
277
- if (report && typeof report === 'object') {
278
- return /** @type {Record<string, unknown>} */ (report)
279
- }
304
+ // Only a VALIDATED report wins here: a junk `result.report` must
305
+ // neither reach normalizeReport nor shadow a real report nested
306
+ // deeper in the same result (or an older, valid call).
307
+ if (isReviewReport(report)) return report
308
+ const found = findReportInObject(result)
309
+ if (found) return found
280
310
  }
281
311
  }
282
312
  }
@@ -291,8 +321,29 @@ export function scanSessionForReport(session) {
291
321
  const msgCalls = msg && Array.isArray(safeGet(msg, 'tool_calls')) ? safeGet(msg, 'tool_calls') : null
292
322
  if (!msg || !msgCalls) continue
293
323
  const calls = /** @type {Array<Record<string, unknown>>} */ (msgCalls)
294
- for (const call of calls) {
324
+ // Reverse: within a single assistant message the LAST tool call is the
325
+ // most recent one — two parallel iterate_review results must resolve to
326
+ // the newer report, not the older ("Prefers the most recent one").
327
+ for (let j = calls.length - 1; j >= 0; j--) {
328
+ const call = calls[j]
295
329
  if (!call) continue
330
+ // Result surface (same shape the quality-gate / defense scanners read):
331
+ // `{ name|tool, result }` message tool calls. Without this branch a
332
+ // report carried on `tool_calls[].result` was only reachable through
333
+ // the generic content fallback, so the dashboard could stay empty
334
+ // while F8–F10 filled from the very same message.
335
+ const callName = String(safeGet(call, 'name') ?? safeGet(call, 'tool') ?? '')
336
+ if (callName === 'iterate_review' || callName.endsWith('iterate_review')) {
337
+ for (const key of ['result', 'response', 'message']) {
338
+ const node = coerceJsonNode(safeGet(call, key))
339
+ if (!node || typeof node !== 'object') continue
340
+ const r = /** @type {Record<string, unknown>} */ (node)
341
+ const report = safeGet(r, 'report')
342
+ if (isReviewReport(report)) return report
343
+ const found = findReportInObject(node)
344
+ if (found) return found
345
+ }
346
+ }
296
347
  const fn = safeGet(call, 'function')
297
348
  if (fn && typeof fn === 'object') {
298
349
  const f = /** @type {Record<string, unknown>} */ (fn)
@@ -348,7 +399,7 @@ export function isTranscriptManifest(obj) {
348
399
  * @param {unknown} source
349
400
  * @returns {Record<string, unknown> | null}
350
401
  */
351
- function attachLive(manifest, source) {
402
+ export function attachLive(manifest, source) {
352
403
  if (!manifest || typeof manifest !== 'object') return manifest
353
404
  const live = source && typeof source === 'object' ? safeGet(source, 'live') : undefined
354
405
  if (!Array.isArray(live)) return manifest
@@ -372,7 +423,7 @@ function attachLive(manifest, source) {
372
423
  * @param {number} [depth]
373
424
  * @returns {Record<string, unknown> | null}
374
425
  */
375
- function extractTranscript(obj, seen, depth) {
426
+ export function extractTranscript(obj, seen, depth) {
376
427
  if (depth === undefined) depth = 0
377
428
  if (depth > 20) return null
378
429
  if (typeof obj === 'string') {
@@ -503,10 +554,29 @@ export function scanSessionForTranscript(session) {
503
554
  // Prefer the explicit tool-call surface first, then generic content.
504
555
  const calls = safeGet(msg, 'tool_calls')
505
556
  if (Array.isArray(calls)) {
506
- for (const call of calls) {
557
+ // Reverse: the last tool call in one message is the most recent —
558
+ // two parallel iterate_transcript results must resolve to the newer
559
+ // manifest ("Prefers the most recent one").
560
+ const callList = /** @type {Array<Record<string, unknown>>} */ (calls)
561
+ for (let j = callList.length - 1; j >= 0; j--) {
562
+ const call = callList[j]
507
563
  if (!call) continue
508
- const args = safeGet(call, 'arguments')
509
- if (typeof args === 'string') {
564
+ // OpenAI-style calls nest the payload under `function.arguments`
565
+ // (a JSON string) — the shape scanSessionForReport already reads.
566
+ // Missing it here meant a manifest echoed only in that surface was
567
+ // invisible to the observatory (F1–F7 stayed empty).
568
+ const fn = safeGet(call, 'function')
569
+ const candidates = [
570
+ // Result surface first: `{ name|tool, result }` message tool calls
571
+ // (the same shape scanSessionForQualityGate already accepts).
572
+ safeGet(call, 'result'),
573
+ safeGet(call, 'response'),
574
+ safeGet(call, 'message'),
575
+ safeGet(call, 'arguments'),
576
+ fn && typeof fn === 'object' ? safeGet(/** @type {Record<string, unknown>} */ (fn), 'arguments') : undefined,
577
+ ]
578
+ for (const args of candidates) {
579
+ if (args === undefined || args === null) continue
510
580
  const found = extractTranscript(args)
511
581
  if (found) return found
512
582
  }
@@ -517,7 +587,45 @@ export function scanSessionForTranscript(session) {
517
587
  }
518
588
  }
519
589
 
520
- return null
590
+ // Fallback: a host snapshot without the tool-call envelope (a restored
591
+ // transcript, a rehydrated turn, a shell that surfaces artifacts directly)
592
+ // may still carry a manifest somewhere in the tree. Deep-scan for it so the
593
+ // dashboard and the observatory keep filling instead of degrading to the
594
+ // first-run empty state. The manifest is returned RAW — callers normalize.
595
+ const loose = findFirstInObject(session, isTranscriptManifest)
596
+ return loose ? /** @type {Record<string, unknown>} */ (loose) : null
597
+ }
598
+
599
+ /**
600
+ * Normalize the `validations` array the transcript capture carries (contract:
601
+ * `{round, command, exitCode, allowed, rejectReason?}` — one row per
602
+ * validation command run in a round). Defensive like every other normalizer:
603
+ * junk rows are dropped, missing fields degrade to safe defaults, and the
604
+ * input is never mutated.
605
+ *
606
+ * @param {unknown} raw
607
+ * @returns {Array<{ round: number, command: string, exitCode: number | null, allowed: boolean, rejectReason?: string }>}
608
+ */
609
+ export function normalizeValidations(raw) {
610
+ if (!Array.isArray(raw)) return []
611
+ const out = []
612
+ for (const v of /** @type {unknown[]} */ (raw)) {
613
+ if (!v || typeof v !== 'object') continue
614
+ const rec = /** @type {Record<string, unknown>} */ (v)
615
+ const roundRaw = Number(rec.round)
616
+ const exitRaw = rec.exitCode
617
+ const command = typeof rec.command === 'string' ? rec.command : String(rec.command ?? '')
618
+ if (!command) continue
619
+ const rejectReason = typeof rec.rejectReason === 'string' ? rec.rejectReason : ''
620
+ out.push({
621
+ round: Number.isFinite(roundRaw) && roundRaw > 0 ? Math.floor(roundRaw) : 0,
622
+ command,
623
+ exitCode: typeof exitRaw === 'number' && Number.isFinite(exitRaw) ? exitRaw : null,
624
+ allowed: rec.allowed === true,
625
+ ...(rejectReason ? { rejectReason } : {}),
626
+ })
627
+ }
628
+ return out
521
629
  }
522
630
 
523
631
  /**
@@ -599,6 +707,9 @@ export function normalizeTranscript(manifest) {
599
707
  })),
600
708
  checkpoint,
601
709
  timeline: asArray(safeGet(src, 'timeline')).map((t) => ({ ...t })),
710
+ // #6: per-round validation rows (command / exitCode / allowed), captured
711
+ // by the workflow and persisted through `iterate_transcript capture`.
712
+ validations: normalizeValidations(safeGet(src, 'validations')),
602
713
  nudge,
603
714
  approval: ap && typeof ap === 'object'
604
715
  ? { active: asBool(safeGet(ap, 'active')), policy: asStr(safeGet(ap, 'policy')) || 'ask' }
@@ -675,7 +786,10 @@ export function isDefenseEventsResult(obj) {
675
786
 
676
787
  /**
677
788
  * Shared deep-find: first node in `obj` (circular + depth guarded) satisfying a
678
- * predicate, with the same traversal semantics as findReportInObject.
789
+ * predicate, with the same traversal semantics as findReportInObject. String
790
+ * nodes are parsed when they carry a JSON payload (`{...}` / `[...]`), so
791
+ * STRING-encoded tool results / message contents are as visible to the
792
+ * scanSessionFor* scanners as object nodes.
679
793
  *
680
794
  * @param {unknown} obj
681
795
  * @param {(o: Record<string, unknown>) => boolean} predicate
@@ -683,8 +797,15 @@ export function isDefenseEventsResult(obj) {
683
797
  * @param {number} [maxDepth=20]
684
798
  * @returns {Record<string, unknown> | null}
685
799
  */
686
- function findFirstInObject(obj, predicate, seen, maxDepth = 20) {
800
+ export function findFirstInObject(obj, predicate, seen, maxDepth = 20) {
687
801
  if (maxDepth <= 0) return null
802
+ // Guarded JSON.parse for string nodes; a plain / unparseable string has
803
+ // nothing to scan. Keeps the same depth (one hop per parse) and seen guards.
804
+ if (typeof obj === 'string') {
805
+ const parsed = coerceJsonNode(obj)
806
+ if (parsed === obj) return null // not (parseable) JSON text
807
+ return findFirstInObject(parsed, predicate, seen, maxDepth - 1)
808
+ }
688
809
  if (!obj || typeof obj !== 'object') return null
689
810
 
690
811
  const s = seen || new Set()
@@ -730,16 +851,23 @@ function findDefenseResultInObject(obj) {
730
851
 
731
852
  /**
732
853
  * 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.
854
+ * in a session snapshot whose subtree actually contains a match for `find`.
855
+ * Scans `session.toolCalls` (reverse chronological, matching the harness's
856
+ * in-memory stream shape), then `session.messages[].tool_calls` (assistant
857
+ * tool-call blocks), then falls back to `session.messages[].content` — the
858
+ * content fallback loops newest→oldest and only accepts a message whose
859
+ * content embeds a match, so a conversational closing message can never
860
+ * shadow an earlier message's embedded result. A junk LATEST result is
861
+ * skipped (an older valid one still surfaces) because `find` gates every
862
+ * candidate. Returns the raw node (object or string) or null.
737
863
  *
738
864
  * @param {unknown} session
739
865
  * @param {string} toolName
866
+ * @param {(node: unknown) => Record<string, unknown> | null} find deep-find
867
+ * predicate identifying a usable match inside a candidate node
740
868
  * @returns {unknown}
741
869
  */
742
- function latestToolResultNode(session, toolName) {
870
+ export function latestToolResultNode(session, toolName, find) {
743
871
  if (!session || typeof session !== 'object') return null
744
872
 
745
873
  const s = /** @type {Record<string, unknown>} */ (session)
@@ -753,9 +881,9 @@ function latestToolResultNode(session, toolName) {
753
881
  const tool = String(safeGet(call, 'tool') ?? '')
754
882
  if (tool !== toolName && !tool.endsWith(toolName)) continue
755
883
  const result = safeGet(call, 'result')
756
- if (result !== undefined && result !== null) return result
884
+ if (result !== undefined && result !== null && find(result)) return result
757
885
  const message = safeGet(call, 'message')
758
- if (message !== undefined && message !== null) return message
886
+ if (message !== undefined && message !== null && find(message)) return message
759
887
  }
760
888
  }
761
889
 
@@ -765,8 +893,8 @@ function latestToolResultNode(session, toolName) {
765
893
  // Assistant tool-call variant: the tool result may live on the call object
766
894
  // inside message.tool_calls rather than on session.toolCalls. Scan every
767
895
  // 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.
896
+ // raw contents — a conversational closing message must not shadow an
897
+ // earlier message's real tool result.
770
898
  for (let i = msgs.length - 1; i >= 0; i--) {
771
899
  const msg = msgs[i]
772
900
  if (!msg) continue
@@ -779,17 +907,23 @@ function latestToolResultNode(session, toolName) {
779
907
  const name = String(safeGet(call, 'name') ?? safeGet(call, 'tool') ?? '')
780
908
  if (name !== toolName && !name.endsWith(toolName)) continue
781
909
  const result = safeGet(call, 'result') ?? safeGet(call, 'response') ?? safeGet(call, 'message')
782
- if (result !== undefined && result !== null) return result
910
+ if (result !== undefined && result !== null && find(result)) return result
911
+ // `function.arguments` is deliberately NOT consulted here: it holds the
912
+ // CALL INPUT, and rendering an input as a stored result would show an
913
+ // `iterate_experience add` payload as if it were bank content. Inputs
914
+ // are not results — only result/response/message surfaces count.
783
915
  }
784
916
  }
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).
917
+ // Content fallback: loop messages newest→oldest and return the first
918
+ // content whose subtree actually embeds a match (the deep-scan finders
919
+ // see inside STRING-encoded JSON payloads too). An unmatched newest
920
+ // message must not shadow an older message's embedded result.
788
921
  for (let i = msgs.length - 1; i >= 0; i--) {
789
922
  const msg = msgs[i]
790
923
  if (!msg) continue
791
924
  const content = safeGet(msg, 'content')
792
- if (content !== undefined && content !== null) return content
925
+ if (content === undefined || content === null) continue
926
+ if (find(content)) return content
793
927
  }
794
928
  }
795
929
 
@@ -848,11 +982,17 @@ export function normalizeQualityGateSnapshot(raw) {
848
982
  * @returns {Record<string, unknown> | null}
849
983
  */
850
984
  export function scanSessionForQualityGate(session) {
851
- const node = latestToolResultNode(session, 'iterate_quality_gate')
852
- if (node === null) return null
853
- const found = findQualityGateInObject(node)
854
- if (!found) return null
855
- return normalizeQualityGateSnapshot(found)
985
+ const node = latestToolResultNode(session, 'iterate_quality_gate', findQualityGateInObject)
986
+ if (node !== null) {
987
+ const found = findQualityGateInObject(node)
988
+ if (found) return normalizeQualityGateSnapshot(found)
989
+ }
990
+ // Fallback: a host snapshot without the tool-call envelope (a restored
991
+ // transcript, a rehydrated turn, a shell that surfaces artifacts directly)
992
+ // still carries the snapshot somewhere in the tree — deep-scan for it so
993
+ // the panel fills instead of claiming "not pulled yet".
994
+ const loose = findFirstInObject(session, isQualityGateSnapshot)
995
+ return loose ? normalizeQualityGateSnapshot(loose) : null
856
996
  }
857
997
 
858
998
  /**
@@ -907,11 +1047,14 @@ export function normalizeExperienceBankResult(raw) {
907
1047
  * @returns {Record<string, unknown> | null}
908
1048
  */
909
1049
  export function scanSessionForExperienceBank(session) {
910
- const node = latestToolResultNode(session, 'iterate_experience')
911
- if (node === null) return null
912
- const found = findExperienceResultInObject(node)
913
- if (!found) return null
914
- return normalizeExperienceBankResult(found)
1050
+ const node = latestToolResultNode(session, 'iterate_experience', findExperienceResultInObject)
1051
+ if (node !== null) {
1052
+ const found = findExperienceResultInObject(node)
1053
+ if (found) return normalizeExperienceBankResult(found)
1054
+ }
1055
+ // Same deep-scan fallback as the quality-gate scanner (see above).
1056
+ const loose = findFirstInObject(session, isExperienceBankResult)
1057
+ return loose ? normalizeExperienceBankResult(loose) : null
915
1058
  }
916
1059
 
917
1060
  /**
@@ -971,11 +1114,14 @@ export function normalizeDefenseEventsResult(raw) {
971
1114
  * @returns {Record<string, unknown> | null}
972
1115
  */
973
1116
  export function scanSessionForDefenseEvents(session) {
974
- const node = latestToolResultNode(session, 'iterate_defense_events')
975
- if (node === null) return null
976
- const found = findDefenseResultInObject(node)
977
- if (!found) return null
978
- return normalizeDefenseEventsResult(found)
1117
+ const node = latestToolResultNode(session, 'iterate_defense_events', findDefenseResultInObject)
1118
+ if (node !== null) {
1119
+ const found = findDefenseResultInObject(node)
1120
+ if (found) return normalizeDefenseEventsResult(found)
1121
+ }
1122
+ // Same deep-scan fallback as the quality-gate scanner (see above).
1123
+ const loose = findFirstInObject(session, isDefenseEventsResult)
1124
+ return loose ? normalizeDefenseEventsResult(loose) : null
979
1125
  }
980
1126
 
981
1127
  // ─── Run-summary / meta-review verdict detection ─────────────────────────────
@@ -1122,9 +1268,12 @@ export function extractVerdict(runSummary) {
1122
1268
  * @returns {Record<string, unknown>}
1123
1269
  */
1124
1270
  export function normalizeReport(report) {
1271
+ // Session/model text is arbitrary: `findings` / `rounds` may arrive as a
1272
+ // NON-ARRAY junk value ({} / string / number) — coerce at every ingestion
1273
+ // point so downstream map/iteration never throws `... is not iterable`.
1125
1274
  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 ?? [])
1275
+ const rounds = /** @type {Array<unknown>} */ (Array.isArray(report.rounds) ? report.rounds : [])
1276
+ const findings = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(report.findings) ? report.findings : [])
1128
1277
 
1129
1278
  // Normalize convergence
1130
1279
  const totalRounds =
@@ -1132,6 +1281,16 @@ export function normalizeReport(report) {
1132
1281
  ? convergence.totalRounds
1133
1282
  : rounds.length
1134
1283
 
1284
+ // Why the run stopped: an explicit harness-provided reason always wins;
1285
+ // the derived fallback may only be stamped when the run is actually
1286
+ // FINISHED (flagged converged, or every budgeted round executed). An
1287
+ // in-progress run (rounds < totalRounds, not converged) must stay null —
1288
+ // stamping 'converged' mid-run would render a false finished badge.
1289
+ const explicitReason =
1290
+ typeof convergence.stoppedReason === 'string' && convergence.stoppedReason.trim()
1291
+ ? convergence.stoppedReason
1292
+ : null
1293
+
1135
1294
  const normalizedConvergence = {
1136
1295
  totalRounds,
1137
1296
  findingsByRound: Array.isArray(convergence.findingsByRound)
@@ -1141,7 +1300,12 @@ export function normalizeReport(report) {
1141
1300
  return Array.isArray(rr?.findings) ? rr.findings.length : 0
1142
1301
  }),
1143
1302
  converged: convergence.converged === true,
1144
- stoppedReason: convergence.stoppedReason ?? (rounds.length < totalRounds ? 'converged' : 'max_rounds_reached'),
1303
+ stoppedReason: explicitReason ??
1304
+ (convergence.converged === true
1305
+ ? 'converged'
1306
+ : rounds.length >= totalRounds
1307
+ ? 'max_rounds_reached'
1308
+ : null),
1145
1309
  }
1146
1310
 
1147
1311
  // Compute summary if missing. Always build a NEW object so the input's
@@ -1186,20 +1350,31 @@ export function normalizeReport(report) {
1186
1350
  * @returns {{ totalFindings: number, critical: number, high: number, medium: number, low: number, byDimension: Record<string, number> }}
1187
1351
  */
1188
1352
  function computeSummaryFromFindings(findings) {
1353
+ // Coerce: a junk (non-array) findings value must degrade to "no findings",
1354
+ // not throw `... is not iterable` during React render.
1355
+ const list = Array.isArray(findings) ? findings : []
1189
1356
  const counts = { critical: 0, high: 0, medium: 0, low: 0 }
1357
+ // NULL-PROTOTYPE map: `dimension` comes from arbitrary review/model text, so
1358
+ // a finding tagged `__proto__` / `constructor` / `toString` must count as an
1359
+ // ordinary bucket instead of reading (or assigning through) the prototype
1360
+ // chain. `{} ` would make `byDimension['__proto__'] ?? 0` return
1361
+ // Object.prototype and produce garbage counts — mirrors review.ts, which
1362
+ // already creates its summary map with Object.create(null).
1190
1363
  /** @type {Record<string, number>} */
1191
- const byDimension = {}
1364
+ const byDimension = Object.create(null)
1192
1365
 
1193
- for (const f of findings) {
1366
+ for (const f of list) {
1194
1367
  if (!f || typeof f !== 'object') continue
1195
1368
  const sev = String(f.severity ?? 'low')
1196
- if (sev in counts) counts[sev]++
1369
+ // OWN-property check: `'__proto__' in counts` is true via the prototype
1370
+ // chain, which would corrupt the tally with a non-number.
1371
+ if (Object.prototype.hasOwnProperty.call(counts, sev)) counts[sev]++
1197
1372
  const dim = String(f.dimension ?? 'unknown')
1198
1373
  byDimension[dim] = (byDimension[dim] ?? 0) + 1
1199
1374
  }
1200
1375
 
1201
1376
  return {
1202
- totalFindings: findings.length,
1377
+ totalFindings: list.length,
1203
1378
  critical: counts.critical,
1204
1379
  high: counts.high,
1205
1380
  medium: counts.medium,
@@ -1239,8 +1414,10 @@ export function computeConvergenceProgress(report) {
1239
1414
  * @param {Record<string, unknown>} report
1240
1415
  * @returns {number}
1241
1416
  */
1242
- function currentRoundNumber(report) {
1243
- const rounds = /** @type {Array<Record<string, unknown>>} */ (report.rounds ?? [])
1417
+ export function currentRoundNumber(report) {
1418
+ // Coerce: `rounds` may be a non-array junk value in arbitrary session text —
1419
+ // `for...of {}` would throw `... is not iterable`.
1420
+ const rounds = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(report.rounds) ? report.rounds : [])
1244
1421
  let max = 0
1245
1422
  for (const r of rounds) {
1246
1423
  if (!r || typeof r !== 'object') continue
@@ -1284,12 +1461,16 @@ export function getTotalRounds(report) {
1284
1461
  * @returns {{ critical: number, high: number, medium: number, low: number }}
1285
1462
  */
1286
1463
  export function severityStats(report) {
1287
- const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
1464
+ // Coerce at the ingestion point: non-array `findings` degrades to [].
1465
+ const findings = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(report.findings) ? report.findings : [])
1288
1466
  const counts = { critical: 0, high: 0, medium: 0, low: 0 }
1289
1467
  for (const f of findings) {
1290
1468
  if (!f || typeof f !== 'object') continue
1291
1469
  const sev = String(f.severity ?? 'low')
1292
- if (sev in counts) counts[sev]++
1470
+ // OWN-property check: a junk severity of `__proto__`/`constructor` is not
1471
+ // a bucket and must not read through the prototype chain (`counts[sev]++`
1472
+ // would turn into NaN and try to reassign the prototype).
1473
+ if (Object.prototype.hasOwnProperty.call(counts, sev)) counts[sev]++
1293
1474
  }
1294
1475
  return counts
1295
1476
  }
@@ -1303,13 +1484,17 @@ export function severityStats(report) {
1303
1484
  * @returns {Record<string, Array<Record<string, unknown>>>}
1304
1485
  */
1305
1486
  export function groupByDimension(report) {
1306
- const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
1487
+ // Coerce at the ingestion point: non-array `findings` degrades to [].
1488
+ const findings = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(report.findings) ? report.findings : [])
1489
+ // NULL-PROTOTYPE buckets: `dimension` is arbitrary model text, so
1490
+ // `__proto__` would otherwise resolve to Object.prototype (truthy → skip the
1491
+ // bucket assignment) and crash on `.push` — or worse, mutate the prototype.
1307
1492
  /** @type {Record<string, Array<Record<string, unknown>>>} */
1308
- const groups = {}
1493
+ const groups = Object.create(null)
1309
1494
  for (const f of findings) {
1310
1495
  if (!f || typeof f !== 'object') continue
1311
1496
  const dim = String(f.dimension ?? 'unknown')
1312
- if (!groups[dim]) groups[dim] = []
1497
+ if (!Object.prototype.hasOwnProperty.call(groups, dim)) groups[dim] = []
1313
1498
  groups[dim].push(f)
1314
1499
  }
1315
1500
  return groups
@@ -1328,7 +1513,8 @@ export const TRIAGE_VERDICTS = /** @type {const} */ (['keep', 'skip', 'ignore'])
1328
1513
  * @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
1329
1514
  */
1330
1515
  export function buildTriageState(report) {
1331
- const findings = /** @type {Array<unknown>} */ (report.findings ?? [])
1516
+ // Coerce: non-array `findings` ({} / string) must not fabricate keys.
1517
+ const findings = /** @type {Array<unknown>} */ (Array.isArray(report.findings) ? report.findings : [])
1332
1518
  /** @type {Record<string, 'keep' | 'skip' | 'ignore'>} */
1333
1519
  const state = {}
1334
1520
  for (let i = 0; i < findings.length; i++) {
@@ -1347,7 +1533,9 @@ export function buildTriageState(report) {
1347
1533
  * @returns {string}
1348
1534
  */
1349
1535
  export function hashReport(report) {
1350
- const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
1536
+ // Coerce: non-array `findings` degrades to [] instead of throwing on the
1537
+ // `for...of` below.
1538
+ const findings = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(report.findings) ? report.findings : [])
1351
1539
  // FNV-1a over EVERY finding (file|line|dimension|summary) so two different
1352
1540
  // reports never share a verdict store, while re-running the identical review
1353
1541
  // restores the same verdicts.
@@ -1416,8 +1604,27 @@ export function buildApplyInstruction(entries) {
1416
1604
  )
1417
1605
 
1418
1606
  return (
1419
- `Please call \`iterate_triage\` with the following payload to apply the triage verdicts:\n\n` +
1420
- `\`\`\`json\n${payload}\n\`\`\``
1607
+ `请调用 \`iterate_triage\` 应用下面的 known_intentional 条目(粘贴后由模型调用 ` +
1608
+ `\`iterate_triage\` 的 \`operation: "apply"\` 写入配置:自动去重、写入前备份、失败自动回滚):\n\n` +
1609
+ `\`\`\`json\n${payload}\n\`\`\`\n\n` +
1610
+ `写入完成后请复制面板的「回读已写入条目」指令(\`iterate_triage\` \`operation: "list"\`)核对结果。`
1611
+ )
1612
+ }
1613
+
1614
+ /**
1615
+ * Copy-back instruction for the triage write-back loop: after an `apply`, the
1616
+ * user needs the stored entries echoed back so the panel's "待写回" count has a
1617
+ * confirmation step (copy → apply → read back).
1618
+ *
1619
+ * @returns {string}
1620
+ */
1621
+ export function buildTriageReadbackInstruction() {
1622
+ return (
1623
+ '请调用 `iterate_triage` 回读当前已写入配置的 known_intentional 条目,' +
1624
+ '并把每条的 file / line / dimension / reason 原样列出来供我核对:\n\n' +
1625
+ '```json\n' +
1626
+ `${JSON.stringify({ operation: 'list' }, null, 2)}\n` +
1627
+ '```'
1421
1628
  )
1422
1629
  }
1423
1630
 
@@ -1430,10 +1637,12 @@ export function buildApplyInstruction(entries) {
1430
1637
  * @returns {Array<{ file: string, line?: number, dimension: string, reason: string }>}
1431
1638
  */
1432
1639
  export function collectIgnoredEntries(triageState, findings) {
1640
+ // Coerce: a non-array findings value must never index string characters.
1641
+ const list = Array.isArray(findings) ? findings : []
1433
1642
  const entries = []
1434
1643
  for (const [idx, verdict] of Object.entries(triageState)) {
1435
1644
  if (verdict !== 'ignore') continue
1436
- const finding = findings[Number(idx)]
1645
+ const finding = list[Number(idx)]
1437
1646
  if (!finding) continue
1438
1647
  entries.push({
1439
1648
  file: String(finding.file ?? ''),
@@ -1447,6 +1656,348 @@ export function collectIgnoredEntries(triageState, findings) {
1447
1656
  return entries
1448
1657
  }
1449
1658
 
1659
+ // ─── Paste-able instruction payloads (copy-to-command contract) ─────────────
1660
+ //
1661
+ // The browser client cannot call harness tools, so every action button copies
1662
+ // a text the user pastes back into the session. These builders are the SINGLE
1663
+ // source of truth for those payloads (src/client/index.ts imports them), and
1664
+ // every payload is written against the real parameter schema declared in
1665
+ // src/tools/*.ts so test/parse.test.ts can assert field alignment.
1666
+
1667
+ /** Placeholder the model must replace with the NEW full file content. */
1668
+ const CONTENT_PLACEHOLDER = '<由模型填写:修复后的完整文件内容>'
1669
+ /** Placeholder the model must replace with the current iteration round. */
1670
+ const ROUND_PLACEHOLDER = '<由模型填写:当前迭代轮次(≥1 的整数)>'
1671
+
1672
+ /**
1673
+ * Startup instructions copied by the empty-dashboard CTA.
1674
+ *
1675
+ * dsh exposes NO command / slash-command registration surface to plugins: the
1676
+ * cordis Context services are `tools`, `systemPrompt`, `sessions`, `jobs`,
1677
+ * `agents`, `llm`, `sandbox`, `sandboxPolicy`, `approval`, `sessionProjections`
1678
+ * and `ptcRuntime` — there is no `ctx.command` / prompt-command registry, and
1679
+ * `cordis.patch.yml` only inserts the bundle. So `/iterate` never existed as a
1680
+ * command; the CTA instead copies natural-language instructions that the model
1681
+ * resolves through the `workflow` tool exactly as the skill prompt teaches
1682
+ * ("When the user asks to review or iterate on the project … run an iterate
1683
+ * workflow by calling the `workflow` tool", mode normal / dry-run).
1684
+ *
1685
+ * @type {string}
1686
+ */
1687
+ export const START_INSTRUCTION_FULL =
1688
+ '开始一次完整迭代(审查 → 修复 → 验证 → 复盘):请调用 `workflow` 工具运行 iterate 工作流,' +
1689
+ 'mode: "normal",按系统提示的 Iterate Workflow 执行 plan → 多线程并行评审 → 分诊 → 原子修复 → 每轮验证 → 循环直至收敛或达到 max_rounds → 终报并 `iterate_transcript` capture。' +
1690
+ '开跑前先 `iterate_config({operation:"read"})` 检查 validation.commands:若为空,先停下来提示我用 `iterate_config` 写入验证命令,否则本轮迭代不受任何测试保护。'
1691
+
1692
+ /**
1693
+ * Dry-run (review-only) startup instruction — mirrors the skill prompt's
1694
+ * "review only / dry run / 不要改文件 → mode: dry-run" branch.
1695
+ *
1696
+ * @type {string}
1697
+ */
1698
+ export const START_INSTRUCTION_REVIEW_ONLY =
1699
+ '开始一次仅评审(dry-run,不修改任何文件):请调用 `workflow` 工具运行 iterate 工作流,' +
1700
+ 'mode: "dry-run",按系统提示的 Iterate Workflow 执行 plan → 多线程并行评审 → 分诊 → `iterate_review aggregate` / `meta-review` 终报并 `iterate_transcript` capture;' +
1701
+ '全程不要调用 `iterate_fix` / `iterate_rollback`。'
1702
+
1703
+ /** The registered startup instruction set the client CTA copies from. */
1704
+ export const START_INSTRUCTIONS = {
1705
+ full: START_INSTRUCTION_FULL,
1706
+ reviewOnly: START_INSTRUCTION_REVIEW_ONLY,
1707
+ }
1708
+
1709
+ /** Normalize an arbitrary finding-ish node into the payload field set. */
1710
+ function fixFindingPayload(finding) {
1711
+ const f = finding && typeof finding === 'object' ? finding : {}
1712
+ const file = String(f.file ?? '')
1713
+ return {
1714
+ file,
1715
+ ...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
1716
+ dimension: String(f.dimension ?? ''),
1717
+ severity: String(f.severity ?? ''),
1718
+ summary: String(f.summary ?? ''),
1719
+ ...(f.failure_scenario ? { failure_scenario: String(f.failure_scenario) } : {}),
1720
+ ...(f.suggested_fix ? { suggested_fix: String(f.suggested_fix) } : {}),
1721
+ }
1722
+ }
1723
+
1724
+ /**
1725
+ * Staged `iterate_fix` instruction for ONE finding.
1726
+ *
1727
+ * The tool schema (`src/tools/fix.ts`) requires `file` / `content` / `finding`
1728
+ * / `round`; a raw JSON blob without `content` + `round` made the model guess
1729
+ * the two hardest values, so the payload now names every required field and
1730
+ * marks the two the model must fill in (`content` = the full NEW file, `round`
1731
+ * = the current round) instead of pretending to ship a complete call.
1732
+ *
1733
+ * @param {Record<string, unknown>} finding
1734
+ * @param {{ round?: number } | null} [opts] current round, when known by the UI
1735
+ * @returns {string}
1736
+ */
1737
+ export function buildFixInstruction(finding, opts) {
1738
+ const payload = fixFindingPayload(finding)
1739
+ if (!payload.file) return ''
1740
+ const round = opts && Number.isInteger(opts.round) ? opts.round : null
1741
+ const call = {
1742
+ file: payload.file,
1743
+ content: CONTENT_PLACEHOLDER,
1744
+ finding: payload,
1745
+ round: round !== null ? round : ROUND_PLACEHOLDER,
1746
+ }
1747
+ return [
1748
+ '请修复下面这一个 finding。`iterate_fix` 一次只接受一个 finding,且 file / content / finding / round 四个参数全部必填:',
1749
+ `步骤 1:读取 \`${payload.file}\`,按 finding 生成修复后的【完整文件内容】(content 占位必须换成真实文件内容)。`,
1750
+ `步骤 2:调用 \`iterate_fix\`,把 content 与 round 填成真实值${round !== null ? `(本轮 round = ${round})` : ''}:`,
1751
+ '```json',
1752
+ JSON.stringify(call, null, 2),
1753
+ '```',
1754
+ '需要超过 atomic 行数上限时,另加 "force": true。',
1755
+ ].join('\n')
1756
+ }
1757
+
1758
+ /**
1759
+ * Batch assign instruction: N findings, but `iterate_fix` has NO array
1760
+ * parameter — the payload says so explicitly ("一次一个").
1761
+ *
1762
+ * @param {Array<Record<string, unknown>>} findings
1763
+ * @returns {string}
1764
+ */
1765
+ export function buildAssignFixesInstruction(findings) {
1766
+ const list = (Array.isArray(findings) ? findings : [])
1767
+ .filter((f) => f && typeof f === 'object')
1768
+ if (list.length === 0) return ''
1769
+ const payload = list.map((f) => fixFindingPayload(f)).filter((p) => p.file)
1770
+ if (payload.length === 0) return ''
1771
+ return [
1772
+ `请修复下面这 ${payload.length} 个 findings。注意:\`iterate_fix\` 每次只处理【一个】finding(没有数组入参),请逐个调用 iterate_fix——一次一个 finding,处理完一个再发起下一个,绝不要把整批数组当作参数传入。`,
1773
+ '每个 finding 都走同样的两步:① 读取文件生成修复后的完整 content;② 调用 iterate_fix(file / content / finding / round 必填,content 与 round 由你填写)。',
1774
+ '待修复清单:',
1775
+ '```json',
1776
+ JSON.stringify(payload, null, 2),
1777
+ '```',
1778
+ ].join('\n')
1779
+ }
1780
+
1781
+ /**
1782
+ * Architectural-fix approval instruction: `force: true` exists on the tool,
1783
+ * but the four required fields still apply — the old text implied a lone
1784
+ * `force` flag was enough.
1785
+ *
1786
+ * @param {Array<Record<string, unknown>>} [findings] optional scoped findings
1787
+ * @returns {string}
1788
+ */
1789
+ export function buildArchitecturalFixInstruction(findings) {
1790
+ const list = (Array.isArray(findings) ? findings : []).filter((f) => f && typeof f === 'object')
1791
+ const lines = [
1792
+ '请批准并执行架构型修复(允许超过 atomic 行数上限):对每个架构型 finding 逐个调用 `iterate_fix`,一次一个 finding,并显式设置 `"force": true`;',
1793
+ 'file / content / finding / round 四个必填参数一个都不能少(content = 修复后的完整文件内容,round = 当前迭代轮次,两者由你填写)。',
1794
+ ]
1795
+ if (list.length > 0) {
1796
+ lines.push('本项目剩余架构型 finding:', '```json', JSON.stringify(list.map((f) => fixFindingPayload(f)), null, 2), '```')
1797
+ }
1798
+ return lines.join('\n')
1799
+ }
1800
+
1801
+ /**
1802
+ * `iterate_checkpoint` resume payload. The tool's `mode` / `round` /
1803
+ * `maxRounds` / `fixedCount` are SAVE-only inputs; `resume` takes nothing but
1804
+ * `operation`, and `maxRounds: null` was an invalid-integer field.
1805
+ *
1806
+ * @returns {string}
1807
+ */
1808
+ export function buildCheckpointResumeInstruction() {
1809
+ return (
1810
+ '请调用 `iterate_checkpoint` 从断点恢复迭代(加载断点并把 resumeCount +1):\n\n' +
1811
+ '```json\n' +
1812
+ `${JSON.stringify({ operation: 'resume' }, null, 2)}\n` +
1813
+ '```'
1814
+ )
1815
+ }
1816
+
1817
+ /**
1818
+ * `iterate_checkpoint` clear payload (stale checkpoint reset).
1819
+ *
1820
+ * @returns {string}
1821
+ */
1822
+ export function buildCheckpointClearInstruction() {
1823
+ return (
1824
+ '请调用 `iterate_checkpoint` 清除当前断点:\n\n' +
1825
+ '```json\n' +
1826
+ `${JSON.stringify({ operation: 'clear' }, null, 2)}\n` +
1827
+ '```'
1828
+ )
1829
+ }
1830
+
1831
+ /**
1832
+ * `iterate_quality_gate` query instruction with explicit JSON args — the old
1833
+ * plain-prose version had no `operation` and occasionally lost the enum.
1834
+ *
1835
+ * @returns {string}
1836
+ */
1837
+ export function buildQualityGateQueryInstruction() {
1838
+ return (
1839
+ '请调用 `iterate_quality_gate` 查询当前质量门禁状态(读取磁盘上持久化的证书):\n\n' +
1840
+ '```json\n' +
1841
+ `${JSON.stringify({ operation: 'read' }, null, 2)}\n` +
1842
+ '```'
1843
+ )
1844
+ }
1845
+
1846
+ /**
1847
+ * `iterate_quality_gate` clear payload (reset a stale FAIL certificate).
1848
+ *
1849
+ * @returns {string}
1850
+ */
1851
+ export function buildQualityGateClearInstruction() {
1852
+ return (
1853
+ '请调用 `iterate_quality_gate` 清除当前质量门禁证书:\n\n' +
1854
+ '```json\n' +
1855
+ `${JSON.stringify({ operation: 'clear' }, null, 2)}\n` +
1856
+ '```'
1857
+ )
1858
+ }
1859
+
1860
+ /**
1861
+ * `iterate_rollback` payload for one applied fix id.
1862
+ *
1863
+ * @param {string} id
1864
+ * @returns {string}
1865
+ */
1866
+ export function buildRollbackInstruction(id) {
1867
+ return (
1868
+ '请调用 `iterate_rollback` 回滚以下修复:\n\n' +
1869
+ '```json\n' +
1870
+ `${JSON.stringify({ id: String(id || '') }, null, 2)}\n` +
1871
+ '```'
1872
+ )
1873
+ }
1874
+
1875
+ /**
1876
+ * `iterate_experience` list instruction (F9 empty state + header button).
1877
+ *
1878
+ * @returns {string}
1879
+ */
1880
+ export function buildExperienceListInstruction() {
1881
+ return (
1882
+ '请调用 `iterate_experience` 列出经验银行中的所有经验,并把完整返回结果回显到会话里:\n\n' +
1883
+ '```json\n' +
1884
+ `${JSON.stringify({ operation: 'list' }, null, 2)}\n` +
1885
+ '```'
1886
+ )
1887
+ }
1888
+
1889
+ /**
1890
+ * `iterate_defense_events` list instruction (F10 empty state + header button).
1891
+ *
1892
+ * @returns {string}
1893
+ */
1894
+ export function buildDefenseEventsListInstruction() {
1895
+ return (
1896
+ '请调用 `iterate_defense_events` 列出全部防御事件,并把完整返回结果回显到会话里:\n\n' +
1897
+ '```json\n' +
1898
+ `${JSON.stringify({ operation: 'list' }, null, 2)}\n` +
1899
+ '```'
1900
+ )
1901
+ }
1902
+
1903
+ /**
1904
+ * Disk snapshot pull instruction (#4): the browser client has no filesystem
1905
+ * access, so the only way to fill the panels from `.iterate/` is to ask the
1906
+ * model to call the read-side tools and echo their FULL results back into the
1907
+ * session stream (which is exactly what the session scanners consume).
1908
+ *
1909
+ * @returns {string}
1910
+ */
1911
+ export function buildDiskSnapshotInstruction() {
1912
+ const lines = [
1913
+ '请从磁盘拉取 iterate 快照:逐个调用下面的只读工具,并把每个工具的【完整返回结果】原样回显到会话里(不要只给摘要——插件面板靠这些回显填充):',
1914
+ ]
1915
+ DISK_SNAPSHOT_SOURCES.forEach((s, i) => {
1916
+ lines.push(`${i + 1}. \`${s.tool}\`(${s.label}):${JSON.stringify(s.args)}`)
1917
+ })
1918
+ lines.push('回显后,观测台实时流 / F2 跨轮对比 / F5 断点 / F8 质量门禁 / F9 经验银行 / F10 防御事件会自动读取这些结果。')
1919
+ return lines.join('\n')
1920
+ }
1921
+
1922
+ /**
1923
+ * Read-side tools whose results fill the client panels, with the exact
1924
+ * argument objects their schemas accept (all operations are optional-input).
1925
+ * @type {Array<{ tool: string, args: Record<string, unknown>, label: string }>}
1926
+ */
1927
+ export const DISK_SNAPSHOT_SOURCES = [
1928
+ { tool: 'iterate_status', args: {}, label: '运行状态汇总' },
1929
+ { tool: 'iterate_transcript', args: { operation: 'read' }, label: 'transcript 观测清单' },
1930
+ { tool: 'iterate_quality_gate', args: { operation: 'read' }, label: '质量门禁证书' },
1931
+ { tool: 'iterate_experience', args: { operation: 'list' }, label: '经验银行' },
1932
+ { tool: 'iterate_defense_events', args: { operation: 'list' }, label: '防御事件流' },
1933
+ { tool: 'iterate_history', args: {}, label: '决策日志与修复注册表(跨轮/跨会话对比)' },
1934
+ ]
1935
+
1936
+ /**
1937
+ * Whether `toolName` has actually been called in this session snapshot — the
1938
+ * difference between "磁盘无数据" (we pulled it and it is empty) and "尚未拉取"
1939
+ * (we never asked the model to read it).
1940
+ *
1941
+ * @param {unknown} session
1942
+ * @param {string} toolName
1943
+ * @returns {boolean}
1944
+ */
1945
+ export function toolCalledInSession(session, toolName) {
1946
+ if (!session || typeof session !== 'object' || typeof toolName !== 'string' || !toolName) return false
1947
+ const s = /** @type {Record<string, unknown>} */ (session)
1948
+
1949
+ const toolCalls = safeGet(s, 'toolCalls')
1950
+ if (Array.isArray(toolCalls)) {
1951
+ for (const call of /** @type {unknown[]} */ (toolCalls)) {
1952
+ if (!call || typeof call !== 'object') continue
1953
+ const t = String(safeGet(/** @type {Record<string, unknown>} */ (call), 'tool') ?? '')
1954
+ if (t === toolName || t.endsWith(toolName)) return true
1955
+ }
1956
+ }
1957
+
1958
+ const messages = safeGet(s, 'messages')
1959
+ if (Array.isArray(messages)) {
1960
+ for (const msg of /** @type {unknown[]} */ (messages)) {
1961
+ if (!msg || typeof msg !== 'object') continue
1962
+ const calls = safeGet(/** @type {Record<string, unknown>} */ (msg), 'tool_calls')
1963
+ if (!Array.isArray(calls)) continue
1964
+ for (const call of /** @type {unknown[]} */ (calls)) {
1965
+ if (!call || typeof call !== 'object') continue
1966
+ const c = /** @type {Record<string, unknown>} */ (call)
1967
+ const name = String(safeGet(c, 'name') ?? safeGet(c, 'tool') ?? '')
1968
+ if (name === toolName || name.endsWith(toolName)) return true
1969
+ const fn = safeGet(c, 'function')
1970
+ if (fn && typeof fn === 'object' && String(safeGet(/** @type {Record<string, unknown>} */ (fn), 'name') ?? '') === toolName) {
1971
+ return true
1972
+ }
1973
+ }
1974
+ }
1975
+ }
1976
+ return false
1977
+ }
1978
+
1979
+ /**
1980
+ * Empty-state copy that separates the two very different "nothing here"
1981
+ * situations: the tool was never pulled from disk (call to action) vs. it was
1982
+ * pulled and the disk really has no data.
1983
+ *
1984
+ * @param {boolean} pulled whether the backing tool was called this session
1985
+ * @param {string} topic what is missing (e.g. "质量门禁证书")
1986
+ * @param {'zh'|'en'} [lang] UI language (the caller localizes `topic` itself)
1987
+ * @returns {string}
1988
+ */
1989
+ export function diskEmptyStateText(pulled, topic, lang = 'zh') {
1990
+ const subject = topic || (lang === 'en' ? 'data' : '数据')
1991
+ if (lang === 'en') {
1992
+ return pulled
1993
+ ? `Pulled from disk: ${subject} has no records yet (the disk is empty).`
1994
+ : `Not pulled from disk yet: ${subject} lives under the project's .iterate/ — copy the "Pull disk snapshot" instruction back into the session and it will fill in.`
1995
+ }
1996
+ return pulled
1997
+ ? `已从磁盘拉取:${subject}暂无记录(磁盘无数据)。`
1998
+ : `尚未从磁盘拉取${subject}:数据存放在项目 .iterate/ 下,点「拉取磁盘快照」复制指令发回会话即可回填。`
1999
+ }
2000
+
1450
2001
  // ─── Finding filtering ──────────────────────────────────────────────────────
1451
2002
 
1452
2003
  /**
@@ -1543,6 +2094,9 @@ export function buildFilterOptions(findings) {
1543
2094
  /** @type {Record<string, number>} */
1544
2095
  const dimCounts = {}
1545
2096
  for (const f of list) {
2097
+ // TriagePanel feeds report findings in unconditionally — ONE null / junk
2098
+ // element must not kill the whole filter bar with a TypeError.
2099
+ if (!f || typeof f !== 'object') continue
1546
2100
  const sev = String(f.severity ?? 'low')
1547
2101
  const sv = severities.find((s) => s.value === sev)
1548
2102
  if (sv) sv.count++
@@ -1581,7 +2135,9 @@ export function countVerdicts(triageState) {
1581
2135
  * @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
1582
2136
  */
1583
2137
  export function batchSetVerdict(triageState, indices, verdict) {
1584
- if (verdict !== 'keep' && verdict !== 'skip' && verdict !== 'ignore') return triageState
2138
+ // TRIAGE_VERDICTS is the source of truth for valid verdict keys — anything
2139
+ // outside it (unknown key, junk type) is rejected unchanged.
2140
+ if (!TRIAGE_VERDICTS.includes(verdict)) return triageState
1585
2141
  if (!Array.isArray(indices) || indices.length === 0) return triageState
1586
2142
  const next = { ...triageState }
1587
2143
  for (const idx of indices) {
@@ -1601,6 +2157,8 @@ export function batchSetVerdict(triageState, indices, verdict) {
1601
2157
  * @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
1602
2158
  */
1603
2159
  export function setAllVerdicts(triageState, verdict, indices) {
2160
+ // Delegates to batchSetVerdict, whose TRIAGE_VERDICTS validation rejects an
2161
+ // unknown verdict key here as well (unchanged state is returned).
1604
2162
  const targets = Array.isArray(indices)
1605
2163
  ? indices
1606
2164
  : Object.keys(triageState ?? {}).map(Number)
@@ -1691,27 +2249,40 @@ export function trendMax(points) {
1691
2249
  //
1692
2250
  // Every value the iterate loop can report as `stoppedReason` — including the
1693
2251
  // schema-retry batch (schema_invalid / no_usable_reviewer_output / inconclusive)
1694
- // — maps to a concrete Chinese label. Unknown values still get a readable
1695
- // default instead of a bare "已结束". Kept in parse.js so both the client
1696
- // observatory badge and Node unit tests share one source of truth.
2252
+ // — maps to a concrete label in zh and en. Unknown values still get a readable
2253
+ // default instead of a bare "已结束" / "Done". Kept in parse.js so both the
2254
+ // client observatory badge and Node unit tests share one source of truth.
1697
2255
 
1698
2256
  /**
1699
- * Chinese badge label for a run's `stoppedReason`, or null/undefined-safe.
2257
+ * Badge label for a run's `stoppedReason`, or null/undefined-safe.
2258
+ *
2259
+ * @param {unknown} reason
2260
+ * @param {'zh'|'en'} [lang] UI language (default zh, the model-facing default)
1700
2261
  *
1701
2262
  * @param {string | null | undefined} reason
1702
2263
  * @returns {string}
1703
2264
  */
1704
- export function stoppedReasonLabel(reason) {
1705
- switch (reason) {
1706
- case 'converged': return '已结束 · 已收敛(无新发现)'
1707
- case 'max_rounds_reached': return '已结束 · 达到轮数上限'
1708
- case 'aborted_by_validation': return '已结束 · 验证失败后回滚停止'
1709
- case 'aborted_by_config': return '已结束 · 验证命令不在白名单(配置需修复)'
1710
- case 'inconclusive': return '已结束 · 轮次结论不明(审查输出无效)'
1711
- case 'schema_invalid': return '已结束 · 审查输出连续 schema 校验失败'
1712
- case 'no_usable_reviewer_output': return '已结束 · 无可用审查输出'
1713
- default: return reason ? `已结束 · ${reason}` : '已结束'
2265
+ export function stoppedReasonLabel(reason, lang = 'zh') {
2266
+ const zh = {
2267
+ converged: '已结束 · 已收敛(无新发现)',
2268
+ max_rounds_reached: '已结束 · 达到轮数上限',
2269
+ aborted_by_validation: '已结束 · 验证失败后回滚停止',
2270
+ aborted_by_config: '已结束 · 验证命令不在白名单(配置需修复)',
2271
+ inconclusive: '已结束 · 轮次结论不明(审查输出无效)',
2272
+ schema_invalid: '已结束 · 审查输出连续 schema 校验失败',
2273
+ no_usable_reviewer_output: '已结束 · 无可用审查输出',
2274
+ }
2275
+ const en = {
2276
+ converged: 'Done \u00b7 converged (no new findings)',
2277
+ max_rounds_reached: 'Done \u00b7 reached the round limit',
2278
+ aborted_by_validation: 'Done \u00b7 rolled back after a validation failure',
2279
+ aborted_by_config: 'Done \u00b7 validation command not allow-listed (config needs fixing)',
2280
+ inconclusive: 'Done \u00b7 inconclusive rounds (invalid review output)',
2281
+ schema_invalid: 'Done \u00b7 repeated schema validation failures in review output',
2282
+ no_usable_reviewer_output: 'Done \u00b7 no usable review output',
1714
2283
  }
2284
+ if (lang === 'en') return en[reason] ?? (reason ? `Done \u00b7 ${reason}` : 'Done')
2285
+ return zh[reason] ?? (reason ? `已结束 · ${reason}` : '已结束')
1715
2286
  }
1716
2287
 
1717
2288
  // ─── Completion notification ────────────────────────────────────────────────
@@ -1720,33 +2291,93 @@ export function stoppedReasonLabel(reason) {
1720
2291
  * One-line completion summary for notifications ("已收敛 / 已达最大轮数").
1721
2292
  *
1722
2293
  * @param {Record<string, unknown>} report
2294
+ * @param {'zh'|'en'} [lang] UI language
1723
2295
  * @returns {string}
1724
2296
  */
1725
- export function buildCompletionSummary(report) {
2297
+ export function buildCompletionSummary(report, lang = 'zh') {
1726
2298
  const conv = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
1727
2299
  const rounds = getCurrentRound(report)
1728
2300
  const total = getTotalRounds(report)
1729
2301
  const stats = severityStats(report)
1730
2302
  const converged = conv.converged === true
1731
- const reason = converged ? '已收敛' : `已达最大轮数 ${total}`
1732
2303
  const totalFindings = stats.critical + stats.high + stats.medium + stats.low
2304
+ if (lang === 'en') {
2305
+ const reason = converged ? 'converged' : `reached the round limit ${total}`
2306
+ return `iterate review complete \u00b7 ${rounds}/${total} rounds \u00b7 ${totalFindings} findings \u00b7 ${reason}`
2307
+ }
2308
+ const reason = converged ? '已收敛' : `已达最大轮数 ${total}`
1733
2309
  return `iterate 评审完成 · ${rounds}/${total} 轮 · ${totalFindings} 项发现 · ${reason}`
1734
2310
  }
1735
2311
 
1736
2312
  // ─── Config edit guidance ───────────────────────────────────────────────────
1737
2313
 
1738
2314
  /**
1739
- * Editable config fields (key + label + hint), used by the settings guide.
1740
- * @type {Array<{ key: string, label: string, hint: string }>}
2315
+ * Editable config fields, used by the settings picker (rendered in the UI) and
2316
+ * by the copy-paste guide / instruction builders (model-facing, always zh).
2317
+ * `label`/`hint` are `{ zh, en }` pairs — the UI localizes them with
2318
+ * `field.label[getLang()]`, while the guide builders below read `.zh`.
2319
+ * @type {Array<{ key: string, label: { zh: string, en: string }, hint: { zh: string, en: string } }>}
1741
2320
  */
1742
2321
  export const CONFIG_EDIT_FIELDS = [
1743
- { key: 'goal', label: '目标', hint: '一句话描述本次迭代目标(字符串)' },
1744
- { key: 'dimensions', label: '审查维度', hint: '数组,如 ["correctness","security"]' },
1745
- { key: 'max_rounds', label: '最大轮数', hint: '正整数' },
1746
- { key: 'review.scope', label: '审查范围', hint: '"full" 或 "changed-only"' },
1747
- { key: 'reasoning_effort', label: '审查推理强度', hint: '"low" / "medium" / "high",缺省跟随模型默认' },
1748
- { key: 'atomic.max_lines', label: '原子修复上限行数', hint: '正整数' },
1749
- { key: 'git.push_per_round', label: '每轮推送', hint: 'true / false' },
2322
+ {
2323
+ key: 'goal',
2324
+ label: { zh: '目标', en: 'Goal' },
2325
+ hint: { zh: '一句话描述本次迭代目标(字符串)', en: "One-sentence description of this iteration's goal (a string)" },
2326
+ },
2327
+ {
2328
+ key: 'dimensions',
2329
+ label: { zh: '审查维度', en: 'Review dimensions' },
2330
+ hint: { zh: '数组,如 ["correctness","security"]', en: 'Array, e.g. ["correctness","security"]' },
2331
+ },
2332
+ {
2333
+ key: 'max_rounds',
2334
+ label: { zh: '最大轮数', en: 'Max rounds' },
2335
+ hint: { zh: '正整数', en: 'Positive integer' },
2336
+ },
2337
+ {
2338
+ key: 'review.scope',
2339
+ label: { zh: '审查范围', en: 'Review scope' },
2340
+ hint: { zh: '"full" 或 "changed-only"', en: '"full" or "changed-only"' },
2341
+ },
2342
+ {
2343
+ key: 'reasoning_effort',
2344
+ label: { zh: '审查推理强度', en: 'Review reasoning effort' },
2345
+ hint: { zh: '"low" / "medium" / "high",缺省跟随模型默认', en: '"low" / "medium" / "high"; omitted = follow the model default' },
2346
+ },
2347
+ {
2348
+ key: 'validation.commands',
2349
+ label: { zh: '验证命令', en: 'Validation commands' },
2350
+ hint: {
2351
+ zh: '数组,每项是一条白名单命令字符串,如 ["npm test","npm run lint"];每轮验证逐条执行。留空数组 = 不验证(本轮迭代不受任何测试保护)',
2352
+ en: 'Array of allow-listed command strings, e.g. ["npm test","npm run lint"]; run one by one every round. An empty array = no verification (this iteration runs with no test protection)',
2353
+ },
2354
+ },
2355
+ {
2356
+ key: 'language',
2357
+ label: { zh: '界面语言', en: 'Interface language' },
2358
+ hint: {
2359
+ zh: '"zh" 或 "en"(默认 "en"):控制审批理由、防御事件标签等面向人文案的语言',
2360
+ en: '"zh" or "en" (default "en"): language of human-facing copy such as approval rationales and defense-event labels',
2361
+ },
2362
+ },
2363
+ {
2364
+ key: 'personalization.known_intentional',
2365
+ label: { zh: '已知有意问题', en: 'Known intentional issues' },
2366
+ hint: {
2367
+ zh: '数组,每项 {file, line?, dimension, reason};命中条目在评审中被跳过。可用分诊面板的「回读已写入条目」核对',
2368
+ en: 'Array of {file, line?, dimension, reason}; findings hitting an entry are skipped in review. Verify via the triage panel\'s "read back written entries"',
2369
+ },
2370
+ },
2371
+ {
2372
+ key: 'atomic.max_lines',
2373
+ label: { zh: '原子修复上限行数', en: 'Atomic fix line cap' },
2374
+ hint: { zh: '正整数', en: 'Positive integer' },
2375
+ },
2376
+ {
2377
+ key: 'git.push_per_round',
2378
+ label: { zh: '每轮推送', en: 'Push per round' },
2379
+ hint: { zh: 'true / false', en: 'true / false' },
2380
+ },
1750
2381
  ]
1751
2382
 
1752
2383
  /**
@@ -1760,13 +2391,19 @@ export function buildConfigEditGuide() {
1760
2391
  '---------------------',
1761
2392
  '配置文件:项目根目录 iterate.config.yaml。',
1762
2393
  '',
2394
+ '⚠ 未配置 validation.commands 时迭代不被保护:每轮 validate 拿到空结果会被当作',
2395
+ ' “验证通过”,等于在没有任何测试保护的情况下继续收敛。第一次启动迭代前,',
2396
+ ' 请先给 validation.commands 配上至少一条真实命令(如 npm test)。',
2397
+ '',
1763
2398
  '可编辑字段:',
1764
- ...CONFIG_EDIT_FIELDS.map((f) => `- ${f.key}(${f.label}):${f.hint}`),
2399
+ ...CONFIG_EDIT_FIELDS.map((f) => `- ${f.key}(${f.label.zh}):${f.hint.zh}`),
1765
2400
  '',
1766
2401
  '让模型帮你改:',
1767
2402
  '1. 调用 iterate_config({ operation: "read" }) 查看当前配置;',
1768
- '2. 说明想改的字段,例如「把 max_rounds 改成 5,dimensions 只保留 correctness 和 security」;',
2403
+ '2. 说明想改的字段,例如「把 max_rounds 改成 5,validation.commands 加上 npm test」;',
1769
2404
  '3. 模型会调用 iterate_config({ operation: "write", updates: {...} }) 写入,写入前自动备份,失败自动回滚。',
2405
+ '',
2406
+ '也可以用设置页的“选字段 → 生成写入指令”直接复制第 3 步的指令粘贴给模型。',
1770
2407
  ]
1771
2408
  return lines.join('\n')
1772
2409
  }
@@ -1784,6 +2421,159 @@ export function buildConfigEditInstruction(desiredChanges) {
1784
2421
  return `请调用 \`iterate_config\` 写入以下配置更新:\n\n\`\`\`json\n${payload}\n\`\`\``
1785
2422
  }
1786
2423
 
2424
+ /** Placeholder for a config value the user still has to supply. */
2425
+ export const CONFIG_VALUE_PLACEHOLDER = '<由模型填写:该字段的新值>'
2426
+
2427
+ /**
2428
+ * Look up an editable config field by its dotted key (gap #8).
2429
+ *
2430
+ * @param {string} key
2431
+ * @returns {{ key: string, label: { zh: string, en: string }, hint: { zh: string, en: string } } | null}
2432
+ */
2433
+ export function configFieldByKey(key) {
2434
+ if (typeof key !== 'string' || !key) return null
2435
+ return CONFIG_EDIT_FIELDS.find((f) => f.key === key) ?? null
2436
+ }
2437
+
2438
+ /**
2439
+ * Gap #8: turn a single selected config field into a paste-able write
2440
+ * instruction. `value` may be omitted, in which case an explicit
2441
+ * "filled in by the model" placeholder is emitted so the user knows what to
2442
+ * complete (same convention as the other instruction builders).
2443
+ *
2444
+ * @param {string} key
2445
+ * @param {unknown} [value]
2446
+ * @returns {string}
2447
+ */
2448
+ export function buildConfigFieldInstruction(key, value) {
2449
+ const field = configFieldByKey(key)
2450
+ // Model-facing instruction text: always zh, like every other builder here.
2451
+ const label = field ? field.label.zh : key
2452
+ const hint = field ? field.hint.zh : '见 iterate.config.yaml'
2453
+ const updates = { [key]: value === undefined ? CONFIG_VALUE_PLACEHOLDER : value }
2454
+ // Delegate the payload to `buildConfigEditInstruction` so the multi-field
2455
+ // builder stays a live code path (it used to be test-only dead code).
2456
+ const payload = buildConfigEditInstruction(updates)
2457
+ return [
2458
+ `配置字段 · ${label}(${key})`,
2459
+ `字段说明:${hint}`,
2460
+ '',
2461
+ payload,
2462
+ '',
2463
+ '写入前请先 \`operation: "read"\` 展示当前值;写入由工具自动备份,失败自动回滚。',
2464
+ ].join('\n')
2465
+ }
2466
+
2467
+ // ─── Config read-back (write → read-back loop) ──────────────────────────────
2468
+ //
2469
+ // The triage panel got a read-back instruction (#7); the config editor only
2470
+ // generated writes, so there was no way to SEE what the file actually says
2471
+ // after pasting an instruction. These three pieces close that loop: an
2472
+ // instruction the settings card copies, a session scanner that picks the
2473
+ // echoed result back up, and a dotted-path resolver that surfaces the value
2474
+ // of the field the user currently has selected.
2475
+
2476
+ /**
2477
+ * Copy-paste instruction that makes the model call `iterate_config` read and
2478
+ * echo the full result (including `config`) into the session, where the
2479
+ * settings card can display it.
2480
+ *
2481
+ * @returns {string}
2482
+ */
2483
+ export function buildConfigReadInstruction() {
2484
+ return (
2485
+ '请调用 `iterate_config` 回读当前生效配置,并把完整结果(含 config 字段)原样回显,' +
2486
+ '供我核对界面上展示的当前值:\n\n' +
2487
+ '```json\n' +
2488
+ `${JSON.stringify({ operation: 'read' }, null, 2)}\n` +
2489
+ '```'
2490
+ )
2491
+ }
2492
+
2493
+ /**
2494
+ * Top-level keys of an iterate config, derived from the editable field list
2495
+ * so a newly editable section is covered by the predicate automatically.
2496
+ * @type {string[]}
2497
+ */
2498
+ const CONFIG_TOP_LEVEL_KEYS = [...new Set(CONFIG_EDIT_FIELDS.map((f) => f.key.split('.')[0]))]
2499
+
2500
+ /**
2501
+ * Is `v` an effective iterate config? Requires several known top-level keys:
2502
+ * `loadEffectiveConfig` always merges the built-in defaults, so a real config
2503
+ * is complete — while a stray `{ goal: ... }` record elsewhere in the
2504
+ * transcript cannot impersonate one.
2505
+ *
2506
+ * @param {unknown} v
2507
+ * @returns {boolean}
2508
+ */
2509
+ export function isEffectiveConfig(v) {
2510
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return false
2511
+ const rec = /** @type {Record<string, unknown>} */ (v)
2512
+ let hits = 0
2513
+ for (const key of CONFIG_TOP_LEVEL_KEYS) {
2514
+ if (Object.hasOwn(rec, key)) hits += 1
2515
+ if (hits >= 3) return true
2516
+ }
2517
+ return false
2518
+ }
2519
+
2520
+ /**
2521
+ * Locate the config inside one `iterate_config` tool result. Reads and writes
2522
+ * both report it under `config`; a section read (`data`) is deliberately not
2523
+ * accepted because it is only a slice.
2524
+ *
2525
+ * @param {unknown} v
2526
+ * @returns {Record<string, unknown> | null}
2527
+ */
2528
+ export function findConfigInResult(v) {
2529
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null
2530
+ const rec = /** @type {Record<string, unknown>} */ (v)
2531
+ const cfg = safeGet(rec, 'config')
2532
+ if (isEffectiveConfig(cfg)) return /** @type {Record<string, unknown>} */ (cfg)
2533
+ return isEffectiveConfig(v) ? rec : null
2534
+ }
2535
+
2536
+ /**
2537
+ * Latest effective config echoed into the session: the newest full
2538
+ * `iterate_config` result (read or post-write), falling back to a deep scan
2539
+ * for a config that reached the stream without the tool envelope (restored
2540
+ * transcript, host that surfaces artifacts directly).
2541
+ *
2542
+ * @param {unknown} session
2543
+ * @returns {Record<string, unknown> | null}
2544
+ */
2545
+ export function scanSessionForConfigRead(session) {
2546
+ const node = latestToolResultNode(session, 'iterate_config', findConfigInResult)
2547
+ if (node !== null) {
2548
+ const cfg = findConfigInResult(node)
2549
+ if (cfg) return cfg
2550
+ }
2551
+ const loose = findFirstInObject(session, isEffectiveConfig)
2552
+ return loose ? /** @type {Record<string, unknown>} */ (loose) : null
2553
+ }
2554
+
2555
+ /**
2556
+ * Resolve a dotted editable-field key (`validation.commands`, `review.scope`)
2557
+ * against a read-back config.
2558
+ *
2559
+ * @param {Record<string, unknown> | null | undefined} config
2560
+ * @param {string} key dotted field key from CONFIG_EDIT_FIELDS
2561
+ * @returns {{ resolved: boolean, value: unknown }} `value` is undefined when unresolved
2562
+ */
2563
+ export function configValueByPath(config, key) {
2564
+ if (!config || typeof config !== 'object' || typeof key !== 'string' || !key) {
2565
+ return { resolved: false, value: undefined }
2566
+ }
2567
+ let node = /** @type {unknown} */ (config)
2568
+ for (const part of key.split('.')) {
2569
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return { resolved: false, value: undefined }
2570
+ const rec = /** @type {Record<string, unknown>} */ (node)
2571
+ if (!Object.hasOwn(rec, part)) return { resolved: false, value: undefined }
2572
+ node = rec[part]
2573
+ }
2574
+ return { resolved: true, value: node }
2575
+ }
2576
+
1787
2577
  /**
1788
2578
  * Keyboard shortcut → triage verdict mapping (used by the triage panel).
1789
2579
  * @type {Record<string, 'keep' | 'skip' | 'ignore'>}
@@ -1805,7 +2595,10 @@ export const VERDICT_SHORTCUTS = {
1805
2595
  * @returns {'keep' | 'skip' | 'ignore' | null}
1806
2596
  */
1807
2597
  export function keyToVerdict(key) {
1808
- return VERDICT_SHORTCUTS[key] ?? null
2598
+ const verdict = VERDICT_SHORTCUTS[key]
2599
+ // TRIAGE_VERDICTS is the verdict source of truth: a shortcut entry whose
2600
+ // value falls outside the verdict set is not a triage shortcut.
2601
+ return verdict !== undefined && TRIAGE_VERDICTS.includes(verdict) ? verdict : null
1809
2602
  }
1810
2603
 
1811
2604
  // ─── Select-all keys ────────────────────────────────────────────────────────
@@ -1940,20 +2733,26 @@ export function filterTimelineEntries(entries, opts) {
1940
2733
  }
1941
2734
 
1942
2735
  /**
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.
2736
+ * Serialize the full observatory state (manifest + live feed + pulled disk
2737
+ * snapshots) into a JSON string the client can copy/export. Always includes an
2738
+ * `exportedAt` stamp and guards against non-serializable / oversized payloads
2739
+ * by falling back to the manifest only.
2740
+ *
2741
+ * Gap #11: `extra` lets the caller attach the disk-side artifacts
2742
+ * (qualityGate / experienceBank / defenseEvents / report) that the export
2743
+ * previously dropped, so the copy actually captures the on-disk state.
1947
2744
  *
1948
2745
  * @param {unknown} manifest
1949
2746
  * @param {unknown} live
2747
+ * @param {Record<string, unknown>} [extra]
1950
2748
  * @returns {string}
1951
2749
  */
1952
- export function serializeObservatoryExport(manifest, live) {
2750
+ export function serializeObservatoryExport(manifest, live, extra) {
1953
2751
  const payload = {
1954
2752
  exportedAt: new Date().toISOString(),
1955
2753
  manifest: manifest && typeof manifest === 'object' ? manifest : null,
1956
2754
  live: Array.isArray(live) ? live : [],
2755
+ ...pickExportExtras(extra),
1957
2756
  }
1958
2757
  try {
1959
2758
  return JSON.stringify(payload, null, 2)
@@ -1963,6 +2762,105 @@ export function serializeObservatoryExport(manifest, live) {
1963
2762
  }
1964
2763
  }
1965
2764
 
2765
+ /** Disk-side keys the export recognizes (gap #11). */
2766
+ export const EXPORT_EXTRA_KEYS = ['qualityGate', 'experienceBank', 'defenseEvents', 'report']
2767
+
2768
+ /**
2769
+ * Copy only the known extra keys, dropping `undefined` so the export shape
2770
+ * stays stable when a snapshot has not been pulled yet.
2771
+ *
2772
+ * @param {unknown} extra
2773
+ * @returns {Record<string, unknown>}
2774
+ */
2775
+ function pickExportExtras(extra) {
2776
+ const out = {}
2777
+ if (!extra || typeof extra !== 'object') return out
2778
+ for (const key of EXPORT_EXTRA_KEYS) {
2779
+ if (!Object.prototype.hasOwnProperty.call(extra, key)) continue
2780
+ const value = extra[key]
2781
+ if (value === undefined) continue
2782
+ out[key] = value
2783
+ }
2784
+ return out
2785
+ }
2786
+
2787
+ /**
2788
+ * Gap #13: build the cross-round comparison table for a single session.
2789
+ * One row per round: findings raised, findings fixed, severity mix and the
2790
+ * validation command outcomes recorded on the manifest.
2791
+ *
2792
+ * Cross-*session* comparison is intentionally out of scope — that is what the
2793
+ * #4 pull instruction is for (see `buildDiskSnapshotInstruction`).
2794
+ *
2795
+ * @param {unknown} report
2796
+ * @param {unknown} validations
2797
+ * @returns {Array<{round: number, findings: number, fixed: number, severities: Record<string, number>, validations: Array<{command: string, exitCode: number|null, allowed: boolean}>}>}
2798
+ */
2799
+ export function buildRoundComparison(report, validations) {
2800
+ const rounds = new Map()
2801
+ const rowsFor = (round) => {
2802
+ const n = Number(round)
2803
+ const key = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
2804
+ if (!rounds.has(key)) {
2805
+ rounds.set(key, { round: key, findings: 0, fixed: 0, severities: Object.create(null), validations: [] })
2806
+ }
2807
+ return rounds.get(key)
2808
+ }
2809
+
2810
+ const list = report && Array.isArray(report.findings) ? report.findings : []
2811
+ for (const f of list) {
2812
+ if (!f || typeof f !== 'object') continue
2813
+ const row = rowsFor(f.round)
2814
+ row.findings += 1
2815
+ const sev = typeof f.severity === 'string' && f.severity ? f.severity : 'unknown'
2816
+ row.severities[sev] = (row.severities[sev] || 0) + 1
2817
+ if (f.status === 'fixed') row.fixed += 1
2818
+ }
2819
+
2820
+ const vals = Array.isArray(validations) ? validations : []
2821
+ for (const v of vals) {
2822
+ if (!v || typeof v !== 'object') continue
2823
+ const row = rowsFor(v.round)
2824
+ row.validations.push({
2825
+ command: typeof v.command === 'string' ? v.command : '',
2826
+ exitCode: typeof v.exitCode === 'number' ? v.exitCode : null,
2827
+ allowed: v.allowed === true,
2828
+ })
2829
+ }
2830
+
2831
+ return [...rounds.values()]
2832
+ .filter((r) => r.round > 0)
2833
+ .sort((a, b) => a.round - b.round)
2834
+ .map((r) => ({ ...r, severities: { ...r.severities } }))
2835
+ }
2836
+
2837
+ /**
2838
+ * Gap #5: decide what the convergence dashboard should say.
2839
+ *
2840
+ * - `running` — a manifest exists and the run has not stopped: show the live
2841
+ * round/phase instead of the onboarding copy (gap #5's actual bug: the old
2842
+ * check was `!transcript`, so a run in progress still rendered "how to start").
2843
+ * - `done` — a manifest exists but the run is no longer active: show the
2844
+ * outcome (with `stoppedReason` when the workflow recorded one).
2845
+ * - `empty` — nothing has run yet: show the start-instruction prompt.
2846
+ *
2847
+ * @param {unknown} transcript normalized transcript (or a raw manifest)
2848
+ * @returns {{state: 'running'|'done'|'empty', round: number, phase: string, stoppedReason: string}}
2849
+ */
2850
+ export function dashboardRunState(transcript) {
2851
+ const t = transcript && typeof transcript === 'object' ? transcript : null
2852
+ if (!t) return { state: 'empty', round: 0, phase: '', stoppedReason: '' }
2853
+ const rawRound = Number(t.round)
2854
+ const round = Number.isFinite(rawRound) && rawRound > 0 ? Math.floor(rawRound) : 1
2855
+ const stoppedReason = typeof t.stoppedReason === 'string' ? t.stoppedReason.trim() : ''
2856
+ return {
2857
+ state: t.active === true ? 'running' : 'done',
2858
+ round,
2859
+ phase: latestPhase(t.phases),
2860
+ stoppedReason,
2861
+ }
2862
+ }
2863
+
1966
2864
  /**
1967
2865
  * Latest non-empty workflow phase name (plan / review / fix / validate /
1968
2866
  * report …) from the transcript manifest's phase list. The last recorded