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/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.4.0",
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 6 core tools
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
+ }