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.
- package/README.md +97 -22
- package/README.zh-CN.md +79 -19
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +516 -35
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +193 -0
- package/dist/index.js +61 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +54 -14
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +61 -14
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +106 -28
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/dist/working-directory.js +114 -0
- package/lib/client.js +1912 -512
- package/lib/parse.js +1001 -103
- package/package.json +16 -9
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/i18n.ts +517 -0
- package/src/client/index.ts +1031 -369
- package/src/client/session-cache.ts +54 -0
- package/src/config-loader.ts +549 -36
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +211 -0
- package/src/index.ts +60 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +50 -12
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +60 -13
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +114 -23
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
- 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
|
|
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` (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
|
734
|
-
* matching the harness's
|
|
735
|
-
* `session.messages[].
|
|
736
|
-
*
|
|
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
|
-
//
|
|
769
|
-
//
|
|
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
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
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
|
|
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
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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:
|
|
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
|
|
1366
|
+
for (const f of list) {
|
|
1194
1367
|
if (!f || typeof f !== 'object') continue
|
|
1195
1368
|
const sev = String(f.severity ?? 'low')
|
|
1196
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1420
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1695
|
-
// default instead of a bare "已结束". Kept in parse.js so both the
|
|
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
|
-
*
|
|
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
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
|
1740
|
-
*
|
|
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
|
-
{
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
{
|
|
1749
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
1944
|
-
* string the client can copy/export. Always includes an
|
|
1945
|
-
* guards against non-serializable / oversized payloads
|
|
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
|