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