iterate-plugin 2.4.0 → 2.5.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/lib/client.js +245 -28
- package/lib/parse.js +331 -0
- package/package.json +1 -1
- package/src/config-write.ts +181 -0
- package/src/index.ts +8 -1
- package/src/paths.ts +38 -0
- package/src/skill-prompt.ts +117 -24
- package/src/tools/checkpoint.ts +285 -0
- package/src/tools/config.ts +64 -6
- package/src/tools/decision-log.ts +4 -4
- package/src/tools/fix.ts +565 -0
- package/src/types.ts +64 -0
package/lib/parse.js
CHANGED
|
@@ -456,4 +456,335 @@ export function collectIgnoredEntries(triageState, findings) {
|
|
|
456
456
|
})
|
|
457
457
|
}
|
|
458
458
|
return entries
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ─── Finding filtering ──────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Normalize a caller-supplied filter into a stable shape.
|
|
465
|
+
* Unknown severity values are dropped; search is lower-cased + trimmed.
|
|
466
|
+
*
|
|
467
|
+
* @param {{ severities?: string[], dimensions?: string[], search?: string } | null | undefined} filter
|
|
468
|
+
* @returns {{ severities: string[], dimensions: string[], search: string }}
|
|
469
|
+
*/
|
|
470
|
+
export function normalizeFindingFilter(filter) {
|
|
471
|
+
const f = filter && typeof filter === 'object' ? filter : {}
|
|
472
|
+
const severities = Array.isArray(f.severities)
|
|
473
|
+
? f.severities.filter((s) => SEVERITY_ORDER.includes(String(s)))
|
|
474
|
+
: []
|
|
475
|
+
const dimensions = Array.isArray(f.dimensions)
|
|
476
|
+
? f.dimensions.filter((d) => typeof d === 'string' && d.length > 0)
|
|
477
|
+
: []
|
|
478
|
+
const search = typeof f.search === 'string' ? f.search.trim().toLowerCase() : ''
|
|
479
|
+
return { severities, dimensions, search }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Whether a single finding matches a normalized filter.
|
|
484
|
+
* An empty filter matches everything.
|
|
485
|
+
*
|
|
486
|
+
* @param {Record<string, unknown>} finding
|
|
487
|
+
* @param {{ severities: string[], dimensions: string[], search: string }} filter
|
|
488
|
+
* @returns {boolean}
|
|
489
|
+
*/
|
|
490
|
+
export function findingMatches(finding, filter) {
|
|
491
|
+
const f = normalizeFindingFilter(filter)
|
|
492
|
+
const sev = String(finding.severity ?? 'low')
|
|
493
|
+
if (f.severities.length > 0 && !f.severities.includes(sev)) return false
|
|
494
|
+
const dim = String(finding.dimension ?? '')
|
|
495
|
+
if (f.dimensions.length > 0 && !f.dimensions.includes(dim)) return false
|
|
496
|
+
if (f.search) {
|
|
497
|
+
const haystack = [
|
|
498
|
+
String(finding.file ?? ''),
|
|
499
|
+
String(finding.summary ?? ''),
|
|
500
|
+
String(finding.dimension ?? ''),
|
|
501
|
+
String(finding.suggested_fix ?? ''),
|
|
502
|
+
].join(' ').toLowerCase()
|
|
503
|
+
if (haystack.indexOf(f.search) < 0) return false
|
|
504
|
+
}
|
|
505
|
+
return true
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Filter a findings array, returning only the matches.
|
|
510
|
+
*
|
|
511
|
+
* @param {Array<Record<string, unknown>>} findings
|
|
512
|
+
* @param {{ severities?: string[], dimensions?: string[], search?: string } | null | undefined} filter
|
|
513
|
+
* @returns {Array<Record<string, unknown>>}
|
|
514
|
+
*/
|
|
515
|
+
export function filterFindings(findings, filter) {
|
|
516
|
+
const f = normalizeFindingFilter(filter)
|
|
517
|
+
return (Array.isArray(findings) ? findings : []).filter((finding) => findingMatches(finding, f))
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Filter a findings array, returning the matches together with their ORIGINAL
|
|
522
|
+
* indices. Batch operations act on these indices so the triage state (keyed by
|
|
523
|
+
* original index) stays consistent even when some findings are hidden.
|
|
524
|
+
*
|
|
525
|
+
* @param {Array<Record<string, unknown>>} findings
|
|
526
|
+
* @param {{ severities?: string[], dimensions?: string[], search?: string } | null | undefined} filter
|
|
527
|
+
* @returns {{ filtered: Array<Record<string, unknown>>, indices: number[] }}
|
|
528
|
+
*/
|
|
529
|
+
export function filterFindingsWithIndices(findings, filter) {
|
|
530
|
+
const f = normalizeFindingFilter(filter)
|
|
531
|
+
const list = Array.isArray(findings) ? findings : []
|
|
532
|
+
const filtered = []
|
|
533
|
+
const indices = []
|
|
534
|
+
for (let i = 0; i < list.length; i++) {
|
|
535
|
+
if (findingMatches(list[i], f)) {
|
|
536
|
+
filtered.push(list[i])
|
|
537
|
+
indices.push(i)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return { filtered, indices }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Build the severity + dimension filter options with per-option counts, so the
|
|
545
|
+
* UI can render chips/selects and show how many findings each filters down to.
|
|
546
|
+
*
|
|
547
|
+
* @param {Array<Record<string, unknown>>} findings
|
|
548
|
+
* @returns {{ severities: Array<{ value: string, count: number }>, dimensions: Array<{ value: string, count: number }> }}
|
|
549
|
+
*/
|
|
550
|
+
export function buildFilterOptions(findings) {
|
|
551
|
+
const list = Array.isArray(findings) ? findings : []
|
|
552
|
+
const severities = SEVERITY_ORDER.map((value) => ({ value, count: 0 }))
|
|
553
|
+
/** @type {Record<string, number>} */
|
|
554
|
+
const dimCounts = {}
|
|
555
|
+
for (const f of list) {
|
|
556
|
+
const sev = String(f.severity ?? 'low')
|
|
557
|
+
const sv = severities.find((s) => s.value === sev)
|
|
558
|
+
if (sv) sv.count++
|
|
559
|
+
const dim = String(f.dimension ?? 'unknown')
|
|
560
|
+
dimCounts[dim] = (dimCounts[dim] ?? 0) + 1
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
severities: severities.map((s) => ({ ...s })),
|
|
564
|
+
dimensions: Object.keys(dimCounts).map((value) => ({ value, count: dimCounts[value] })),
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ─── Triage batch operations ────────────────────────────────────────────────
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Count how many findings carry each verdict.
|
|
572
|
+
*
|
|
573
|
+
* @param {Record<string, 'keep' | 'skip' | 'ignore'>} triageState
|
|
574
|
+
* @returns {{ keep: number, skip: number, ignore: number }}
|
|
575
|
+
*/
|
|
576
|
+
export function countVerdicts(triageState) {
|
|
577
|
+
const counts = { keep: 0, skip: 0, ignore: 0 }
|
|
578
|
+
for (const v of Object.values(triageState ?? {})) {
|
|
579
|
+
if (v === 'keep' || v === 'skip' || v === 'ignore') counts[v]++
|
|
580
|
+
}
|
|
581
|
+
return counts
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Set the verdict for a list of finding indices. Returns a NEW state
|
|
586
|
+
* (the input is never mutated).
|
|
587
|
+
*
|
|
588
|
+
* @param {Record<string, 'keep' | 'skip' | 'ignore'>} triageState
|
|
589
|
+
* @param {number[]} indices
|
|
590
|
+
* @param {'keep' | 'skip' | 'ignore'} verdict
|
|
591
|
+
* @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
|
|
592
|
+
*/
|
|
593
|
+
export function batchSetVerdict(triageState, indices, verdict) {
|
|
594
|
+
if (verdict !== 'keep' && verdict !== 'skip' && verdict !== 'ignore') return triageState
|
|
595
|
+
if (!Array.isArray(indices) || indices.length === 0) return triageState
|
|
596
|
+
const next = { ...triageState }
|
|
597
|
+
for (const idx of indices) {
|
|
598
|
+
if (typeof idx === 'number' && Number.isInteger(idx) && idx >= 0) {
|
|
599
|
+
next[String(idx)] = verdict
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return next
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Set the verdict for ALL findings (or only the given index whitelist).
|
|
607
|
+
*
|
|
608
|
+
* @param {Record<string, 'keep' | 'skip' | 'ignore'>} triageState
|
|
609
|
+
* @param {'keep' | 'skip' | 'ignore'} verdict
|
|
610
|
+
* @param {number[]} [indices]
|
|
611
|
+
* @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
|
|
612
|
+
*/
|
|
613
|
+
export function setAllVerdicts(triageState, verdict, indices) {
|
|
614
|
+
const targets = Array.isArray(indices)
|
|
615
|
+
? indices
|
|
616
|
+
: Object.keys(triageState ?? {}).map(Number)
|
|
617
|
+
return batchSetVerdict(triageState, targets, verdict)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ─── History & trend ────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Per-round finding counts (including severity breakdown), oldest first.
|
|
624
|
+
* Derived from `report.rounds`.
|
|
625
|
+
*
|
|
626
|
+
* @param {Record<string, unknown>} report
|
|
627
|
+
* @returns {Array<{ round: number, count: number, critical: number, high: number, medium: number, low: number }>}
|
|
628
|
+
*/
|
|
629
|
+
export function buildRoundHistory(report) {
|
|
630
|
+
const rounds = Array.isArray(report.rounds) ? report.rounds : []
|
|
631
|
+
return rounds.map((r) => {
|
|
632
|
+
const rr = /** @type {Record<string, unknown>} */ (r)
|
|
633
|
+
const findings = Array.isArray(rr.findings) ? rr.findings : []
|
|
634
|
+
const sev = severityStats({ findings })
|
|
635
|
+
return {
|
|
636
|
+
round: typeof rr.round === 'number' ? rr.round : 0,
|
|
637
|
+
count: findings.length,
|
|
638
|
+
critical: sev.critical,
|
|
639
|
+
high: sev.high,
|
|
640
|
+
medium: sev.medium,
|
|
641
|
+
low: sev.low,
|
|
642
|
+
}
|
|
643
|
+
})
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Findings-by-round trend points. Prefers the explicit
|
|
648
|
+
* `convergence.findingsByRound` when present, otherwise derives from rounds.
|
|
649
|
+
*
|
|
650
|
+
* @param {Record<string, unknown>} report
|
|
651
|
+
* @returns {Array<{ round: number, count: number }>}
|
|
652
|
+
*/
|
|
653
|
+
export function buildFindingTrend(report) {
|
|
654
|
+
const conv = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
|
|
655
|
+
if (Array.isArray(conv.findingsByRound)) {
|
|
656
|
+
return conv.findingsByRound.map((n, i) => ({ round: i + 1, count: typeof n === 'number' ? n : 0 }))
|
|
657
|
+
}
|
|
658
|
+
return buildRoundHistory(report).map((h) => ({ round: h.round, count: h.count }))
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Trend metrics for the dashboard chart + summary line.
|
|
663
|
+
*
|
|
664
|
+
* @param {Record<string, unknown>} report
|
|
665
|
+
* @returns {{ points: Array<{ round: number, count: number }>, total: number, firstRound: number, lastRound: number, reductionPercent: number, converged: boolean }}
|
|
666
|
+
*/
|
|
667
|
+
export function computeTrendMetrics(report) {
|
|
668
|
+
const conv = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
|
|
669
|
+
const points = buildFindingTrend(report)
|
|
670
|
+
const total = points.reduce((sum, p) => sum + p.count, 0)
|
|
671
|
+
const firstRound = points.length > 0 ? points[0].count : 0
|
|
672
|
+
const lastRound = points.length > 0 ? points[points.length - 1].count : 0
|
|
673
|
+
const reductionPercent = firstRound > 0 ? Math.round(((firstRound - lastRound) / firstRound) * 100) : 0
|
|
674
|
+
return {
|
|
675
|
+
points,
|
|
676
|
+
total,
|
|
677
|
+
firstRound,
|
|
678
|
+
lastRound,
|
|
679
|
+
reductionPercent,
|
|
680
|
+
converged: conv.converged === true,
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Peak count among trend points (for chart scaling). Never returns 0 so the
|
|
686
|
+
* chart always has a sane baseline.
|
|
687
|
+
*
|
|
688
|
+
* @param {Array<{ round: number, count: number }>} points
|
|
689
|
+
* @returns {number}
|
|
690
|
+
*/
|
|
691
|
+
export function trendMax(points) {
|
|
692
|
+
let max = 1
|
|
693
|
+
for (const p of Array.isArray(points) ? points : []) {
|
|
694
|
+
if (typeof p.count === 'number' && p.count > max) max = p.count
|
|
695
|
+
}
|
|
696
|
+
return max
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ─── Completion notification ────────────────────────────────────────────────
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* One-line completion summary for notifications ("已收敛 / 已达最大轮数").
|
|
703
|
+
*
|
|
704
|
+
* @param {Record<string, unknown>} report
|
|
705
|
+
* @returns {string}
|
|
706
|
+
*/
|
|
707
|
+
export function buildCompletionSummary(report) {
|
|
708
|
+
const conv = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
|
|
709
|
+
const rounds = getCurrentRound(report)
|
|
710
|
+
const total = getTotalRounds(report)
|
|
711
|
+
const stats = severityStats(report)
|
|
712
|
+
const converged = conv.converged === true
|
|
713
|
+
const reason = converged ? '已收敛' : `已达最大轮数 ${total}`
|
|
714
|
+
const totalFindings = stats.critical + stats.high + stats.medium + stats.low
|
|
715
|
+
return `iterate 评审完成 · ${rounds}/${total} 轮 · ${totalFindings} 项发现 · ${reason}`
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ─── Config edit guidance ───────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Editable config fields (key + label + hint), used by the settings guide.
|
|
722
|
+
* @type {Array<{ key: string, label: string, hint: string }>}
|
|
723
|
+
*/
|
|
724
|
+
export const CONFIG_EDIT_FIELDS = [
|
|
725
|
+
{ key: 'goal', label: '目标', hint: '一句话描述本次迭代目标(字符串)' },
|
|
726
|
+
{ key: 'dimensions', label: '审查维度', hint: '数组,如 ["correctness","security"]' },
|
|
727
|
+
{ key: 'max_rounds', label: '最大轮数', hint: '正整数' },
|
|
728
|
+
{ key: 'review.scope', label: '审查范围', hint: '"full" 或 "changed-only"' },
|
|
729
|
+
{ key: 'atomic.max_lines', label: '原子修复上限行数', hint: '正整数' },
|
|
730
|
+
{ key: 'git.push_per_round', label: '每轮推送', hint: 'true / false' },
|
|
731
|
+
]
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Static copy-paste config editing guide (shown in the settings page).
|
|
735
|
+
*
|
|
736
|
+
* @returns {string}
|
|
737
|
+
*/
|
|
738
|
+
export function buildConfigEditGuide() {
|
|
739
|
+
const lines = [
|
|
740
|
+
'iterate 配置编辑指引',
|
|
741
|
+
'---------------------',
|
|
742
|
+
'配置文件:项目根目录 iterate.config.yaml。',
|
|
743
|
+
'',
|
|
744
|
+
'可编辑字段:',
|
|
745
|
+
...CONFIG_EDIT_FIELDS.map((f) => `- ${f.key}(${f.label}):${f.hint}`),
|
|
746
|
+
'',
|
|
747
|
+
'让模型帮你改:',
|
|
748
|
+
'1. 调用 iterate_config({ operation: "read" }) 查看当前配置;',
|
|
749
|
+
'2. 说明想改的字段,例如「把 max_rounds 改成 5,dimensions 只保留 correctness 和 security」;',
|
|
750
|
+
'3. 模型会调用 iterate_config({ operation: "write", updates: {...} }) 写入,写入前自动备份,失败自动回滚。',
|
|
751
|
+
]
|
|
752
|
+
return lines.join('\n')
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Build a copy-paste instruction for a desired config change. The user picks
|
|
757
|
+
* the fields they want to change; the resulting text is meant to be pasted to
|
|
758
|
+
* the model to trigger an `iterate_config` write.
|
|
759
|
+
*
|
|
760
|
+
* @param {Record<string, unknown>} desiredChanges
|
|
761
|
+
* @returns {string}
|
|
762
|
+
*/
|
|
763
|
+
export function buildConfigEditInstruction(desiredChanges) {
|
|
764
|
+
const payload = JSON.stringify({ operation: 'write', updates: desiredChanges }, null, 2)
|
|
765
|
+
return `请调用 \`iterate_config\` 写入以下配置更新:\n\n\`\`\`json\n${payload}\n\`\`\``
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Keyboard shortcut → triage verdict mapping (used by the triage panel).
|
|
770
|
+
* @type {Record<string, 'keep' | 'skip' | 'ignore'>}
|
|
771
|
+
*/
|
|
772
|
+
export const VERDICT_SHORTCUTS = {
|
|
773
|
+
y: 'keep',
|
|
774
|
+
Y: 'keep',
|
|
775
|
+
n: 'skip',
|
|
776
|
+
N: 'skip',
|
|
777
|
+
a: 'ignore',
|
|
778
|
+
A: 'ignore',
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Map a keyboard event key to a triage verdict, or null when the key is not a
|
|
783
|
+
* triage shortcut.
|
|
784
|
+
*
|
|
785
|
+
* @param {string} key
|
|
786
|
+
* @returns {'keep' | 'skip' | 'ignore' | null}
|
|
787
|
+
*/
|
|
788
|
+
export function keyToVerdict(key) {
|
|
789
|
+
return VERDICT_SHORTCUTS[key] ?? null
|
|
459
790
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/config-write.ts — shared helpers for safely WRITING iterate.config.yaml.
|
|
3
|
+
*
|
|
4
|
+
* Used by the `iterate_config` write operation. Provides:
|
|
5
|
+
* - validateConfigUpdates : validate a caller-supplied partial update
|
|
6
|
+
* - applyConfigUpdates : merge a partial update into the current config
|
|
7
|
+
* - writeConfigFile : backup + write + rollback on failure
|
|
8
|
+
*
|
|
9
|
+
* The security posture mirrors the triage tool: never overwrite a malformed
|
|
10
|
+
* config, always back up before writing, roll back on failure.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import yaml from 'js-yaml'
|
|
16
|
+
|
|
17
|
+
/** Config file name (must match config-loader). */
|
|
18
|
+
export const CONFIG_FILE = 'iterate.config.yaml'
|
|
19
|
+
|
|
20
|
+
/** Backup suffix helper (filesystem-safe timestamp). */
|
|
21
|
+
export function configBackupSuffix(now = new Date()): string {
|
|
22
|
+
return now.toISOString().replace(/[:.]/g, '-')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate a partial config update.
|
|
27
|
+
* Returns an array of error strings (empty when the update is valid).
|
|
28
|
+
*/
|
|
29
|
+
export function validateConfigUpdates(updates: Record<string, unknown>): string[] {
|
|
30
|
+
const errors: string[] = []
|
|
31
|
+
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
|
32
|
+
return ['updates must be a JSON object']
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if ('goal' in updates && typeof updates.goal !== 'string') {
|
|
36
|
+
errors.push('updates.goal must be a string')
|
|
37
|
+
}
|
|
38
|
+
if ('language' in updates && updates.language !== 'zh' && updates.language !== 'en') {
|
|
39
|
+
errors.push('updates.language must be "zh" or "en"')
|
|
40
|
+
}
|
|
41
|
+
if ('dimensions' in updates) {
|
|
42
|
+
if (!Array.isArray(updates.dimensions) || updates.dimensions.some((d) => typeof d !== 'string' || d.trim().length === 0)) {
|
|
43
|
+
errors.push('updates.dimensions must be an array of non-empty strings')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if ('max_rounds' in updates) {
|
|
47
|
+
if (typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) || updates.max_rounds < 1) {
|
|
48
|
+
errors.push('updates.max_rounds must be a positive integer')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if ('review' in updates) {
|
|
52
|
+
const r = updates.review as Record<string, unknown> | undefined
|
|
53
|
+
if (!r || typeof r !== 'object') {
|
|
54
|
+
errors.push('updates.review must be an object')
|
|
55
|
+
} else if (r.scope !== undefined && r.scope !== 'full' && r.scope !== 'changed-only') {
|
|
56
|
+
errors.push('updates.review.scope must be "full" or "changed-only"')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if ('atomic' in updates) {
|
|
60
|
+
const a = updates.atomic as Record<string, unknown> | undefined
|
|
61
|
+
if (!a || typeof a !== 'object') {
|
|
62
|
+
errors.push('updates.atomic must be an object')
|
|
63
|
+
} else {
|
|
64
|
+
if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1)) {
|
|
65
|
+
errors.push('updates.atomic.max_lines must be a positive integer')
|
|
66
|
+
}
|
|
67
|
+
if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0)) {
|
|
68
|
+
errors.push('updates.atomic.max_adjacent_methods must be a non-negative number')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if ('git' in updates) {
|
|
73
|
+
const g = updates.git as Record<string, unknown> | undefined
|
|
74
|
+
if (!g || typeof g !== 'object') {
|
|
75
|
+
errors.push('updates.git must be an object')
|
|
76
|
+
} else {
|
|
77
|
+
if (g.target_branch !== undefined && typeof g.target_branch !== 'string') {
|
|
78
|
+
errors.push('updates.git.target_branch must be a string')
|
|
79
|
+
}
|
|
80
|
+
for (const boolKey of ['use_worktree', 'push_per_round', 'auto_merge'] as const) {
|
|
81
|
+
if (g[boolKey] !== undefined && typeof g[boolKey] !== 'boolean') {
|
|
82
|
+
errors.push(`updates.git.${boolKey} must be a boolean`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if ('validation' in updates) {
|
|
88
|
+
const v = updates.validation as Record<string, unknown> | undefined
|
|
89
|
+
if (!v || typeof v !== 'object') {
|
|
90
|
+
errors.push('updates.validation must be an object')
|
|
91
|
+
} else if ('commands' in v && v.commands !== undefined && typeof v.commands !== 'object') {
|
|
92
|
+
errors.push('updates.validation.commands must be an object of command arrays')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if ('personalization' in updates && (!updates.personalization || typeof updates.personalization !== 'object')) {
|
|
96
|
+
errors.push('updates.personalization must be an object')
|
|
97
|
+
}
|
|
98
|
+
if ('onboarding' in updates && (!updates.onboarding || typeof updates.onboarding !== 'object')) {
|
|
99
|
+
errors.push('updates.onboarding must be an object')
|
|
100
|
+
}
|
|
101
|
+
return errors
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Recursively merge `updates` over `base` (arrays replaced wholesale). */
|
|
105
|
+
export function applyConfigUpdates(
|
|
106
|
+
base: Record<string, unknown>,
|
|
107
|
+
updates: Record<string, unknown>,
|
|
108
|
+
): Record<string, unknown> {
|
|
109
|
+
const out: Record<string, unknown> = { ...base }
|
|
110
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
111
|
+
if (value === undefined) continue
|
|
112
|
+
const baseValue = out[key]
|
|
113
|
+
if (
|
|
114
|
+
baseValue &&
|
|
115
|
+
typeof baseValue === 'object' &&
|
|
116
|
+
!Array.isArray(baseValue) &&
|
|
117
|
+
value &&
|
|
118
|
+
typeof value === 'object' &&
|
|
119
|
+
!Array.isArray(value)
|
|
120
|
+
) {
|
|
121
|
+
out[key] = applyConfigUpdates(baseValue as Record<string, unknown>, value as Record<string, unknown>)
|
|
122
|
+
} else {
|
|
123
|
+
out[key] = value
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read the raw config object from disk (empty object when missing).
|
|
131
|
+
* Throws when the file exists but cannot be parsed as a YAML mapping
|
|
132
|
+
* (never overwrite a malformed config).
|
|
133
|
+
*/
|
|
134
|
+
export function readRawConfig(configPath: string): Record<string, unknown> {
|
|
135
|
+
if (!existsSync(configPath)) return {}
|
|
136
|
+
const content = readFileSync(configPath, 'utf-8')
|
|
137
|
+
let parsed: unknown
|
|
138
|
+
try {
|
|
139
|
+
parsed = yaml.load(content)
|
|
140
|
+
} catch {
|
|
141
|
+
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
142
|
+
}
|
|
143
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
144
|
+
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
145
|
+
}
|
|
146
|
+
return parsed as Record<string, unknown>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Write a config object to disk with backup + rollback.
|
|
151
|
+
* Returns `{ ok: true, backupPath }` or `{ ok: false, error }`.
|
|
152
|
+
*/
|
|
153
|
+
export function writeConfigFile(
|
|
154
|
+
projectRoot: string,
|
|
155
|
+
config: Record<string, unknown>,
|
|
156
|
+
): { ok: true; backupPath: string | null } | { ok: false; error: string } {
|
|
157
|
+
const configPath = join(projectRoot, CONFIG_FILE)
|
|
158
|
+
const hadFile = existsSync(configPath)
|
|
159
|
+
const backupPath = hadFile ? `${configPath}.bak-${configBackupSuffix()}` : null
|
|
160
|
+
|
|
161
|
+
if (backupPath) {
|
|
162
|
+
try {
|
|
163
|
+
copyFileSync(configPath, backupPath)
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return { ok: false, error: `failed to create backup: ${String(err)}` }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
writeFileSync(configPath, yaml.dump(config, { noRefs: true }), 'utf-8')
|
|
171
|
+
} catch (err) {
|
|
172
|
+
try {
|
|
173
|
+
if (backupPath) copyFileSync(backupPath, configPath)
|
|
174
|
+
} catch {
|
|
175
|
+
// Rollback failure is reported, never swallowed silently.
|
|
176
|
+
}
|
|
177
|
+
return { ok: false, error: `failed to write config: ${String(err)}` }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { ok: true, backupPath }
|
|
181
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -28,19 +28,26 @@ import { registerDecisionLogTool } from './tools/decision-log.ts'
|
|
|
28
28
|
import { registerContextTool } from './tools/context.ts'
|
|
29
29
|
import { registerReviewTool } from './tools/review.ts'
|
|
30
30
|
import { registerTriageTool } from './tools/triage.ts'
|
|
31
|
+
import { registerFixTool, registerDiffTool, registerRollbackTool } from './tools/fix.ts'
|
|
32
|
+
import { registerCheckpointTool, registerStatusTool } from './tools/checkpoint.ts'
|
|
31
33
|
import { ITERATE_SKILL_PROMPT } from './skill-prompt.ts'
|
|
32
34
|
|
|
33
35
|
export const name = 'iterate-plugin'
|
|
34
36
|
export const inject = ['tools', 'systemPrompt']
|
|
35
37
|
|
|
36
38
|
export function apply(ctx: Context): void {
|
|
37
|
-
// 1. Register the
|
|
39
|
+
// 1. Register the 11 tools
|
|
38
40
|
registerConfigTool(ctx)
|
|
39
41
|
registerValidateTool(ctx)
|
|
40
42
|
registerDecisionLogTool(ctx)
|
|
41
43
|
registerContextTool(ctx)
|
|
42
44
|
registerReviewTool(ctx)
|
|
43
45
|
registerTriageTool(ctx)
|
|
46
|
+
registerFixTool(ctx)
|
|
47
|
+
registerDiffTool(ctx)
|
|
48
|
+
registerRollbackTool(ctx)
|
|
49
|
+
registerCheckpointTool(ctx)
|
|
50
|
+
registerStatusTool(ctx)
|
|
44
51
|
|
|
45
52
|
// 2. Inject the iterate skill prompt as a system prompt section
|
|
46
53
|
// This teaches the model how to write iterate workflow scripts using the tools.
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared filesystem layout for the iterate plugin's runtime state.
|
|
3
|
+
*
|
|
4
|
+
* All runtime artifacts live under `<projectRoot>/.iterate/`:
|
|
5
|
+
* .iterate/decision-log.jsonl — append-only decision log
|
|
6
|
+
* .iterate/fixes/ — fix system: backups + fix registry
|
|
7
|
+
* .iterate/checkpoint.json — iteration checkpoint (resume support)
|
|
8
|
+
*
|
|
9
|
+
* Kept separate from config-loader so every tool points at the same dirs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/** Runtime state root for a project (e.g. `<projectRoot>/.iterate`). */
|
|
15
|
+
export function iterateDir(projectRoot: string): string {
|
|
16
|
+
return join(projectRoot, '.iterate')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Fix-system directory (backups + registry). */
|
|
20
|
+
export function fixesDir(projectRoot: string): string {
|
|
21
|
+
return join(iterateDir(projectRoot), 'fixes')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Fix-registry file (JSON). */
|
|
25
|
+
export function fixRegistryPath(projectRoot: string): string {
|
|
26
|
+
return join(fixesDir(projectRoot), 'registry.json')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Fix-backup file for one fix id + timestamp. */
|
|
30
|
+
export function fixBackupPath(projectRoot: string, id: string, timestamp: string): string {
|
|
31
|
+
const safe = id.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
32
|
+
return join(fixesDir(projectRoot), `${safe}_${timestamp.replace(/[:.]/g, '-')}.bak`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Iteration checkpoint file (JSON). */
|
|
36
|
+
export function checkpointPath(projectRoot: string): string {
|
|
37
|
+
return join(iterateDir(projectRoot), 'checkpoint.json')
|
|
38
|
+
}
|