iterate-plugin 3.5.2 → 3.5.4
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 +3 -3
- package/README.zh-CN.md +3 -3
- package/dist/session-hooks.js +48 -4
- package/dist/skill-prompt.js +63 -29
- package/dist/tools/checkpoint.js +39 -5
- package/dist/tools/config.js +11 -2
- package/dist/tools/context.js +5 -1
- package/dist/tools/defense-events.js +48 -9
- package/dist/tools/defense-store.js +63 -5
- package/dist/tools/experience-bank.js +14 -11
- package/dist/tools/experience-store.js +79 -14
- package/dist/tools/fix.js +30 -10
- package/dist/tools/history.js +5 -3
- package/dist/tools/prune.js +197 -29
- package/dist/tools/quality-gate.js +3 -0
- package/dist/tools/quality-store.js +8 -2
- package/dist/tools/review.js +17 -3
- package/dist/tools/transcript.js +72 -15
- package/dist/tools/triage.js +35 -2
- package/dist/transcript.js +30 -7
- package/lib/client.js +89 -21
- package/lib/parse.js +27 -2
- package/package.json +9 -7
- package/src/client/index.ts +53 -8
- package/src/session-hooks.ts +50 -4
- package/src/skill-prompt.ts +63 -29
- package/src/tools/checkpoint.ts +45 -6
- package/src/tools/config.ts +11 -2
- package/src/tools/context.ts +5 -1
- package/src/tools/defense-events.ts +50 -9
- package/src/tools/defense-store.ts +64 -8
- package/src/tools/experience-bank.ts +14 -11
- package/src/tools/experience-store.ts +88 -23
- package/src/tools/fix.ts +32 -12
- package/src/tools/history.ts +5 -3
- package/src/tools/prune.ts +204 -28
- package/src/tools/quality-gate.ts +4 -0
- package/src/tools/quality-store.ts +7 -2
- package/src/tools/review.ts +16 -3
- package/src/tools/transcript.ts +71 -14
- package/src/tools/triage.ts +36 -2
- package/src/transcript.ts +32 -7
- package/src/types.ts +2 -0
|
@@ -9,6 +9,7 @@ import * as fs from 'node:fs'
|
|
|
9
9
|
import * as path from 'node:path'
|
|
10
10
|
import { iterateDir } from '../paths.ts'
|
|
11
11
|
import { writeJsonAtomic } from '../atomic-fs.ts'
|
|
12
|
+
import { hashString } from './fix.ts'
|
|
12
13
|
import type { ExperienceBank, ExperienceEntry } from '../types.ts'
|
|
13
14
|
|
|
14
15
|
const EXPERIENCE_FILE = 'experience.json'
|
|
@@ -22,14 +23,65 @@ function emptyBank(): ExperienceBank {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** Valid severity values (kept in sync with ExperienceEntry). */
|
|
27
|
+
const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
|
|
28
|
+
|
|
29
|
+
/** String array guard for fields that must be arrays (`files`, `tags`). */
|
|
30
|
+
function stringArray(v: unknown): string[] {
|
|
31
|
+
if (!Array.isArray(v)) return []
|
|
32
|
+
return v.filter((x): x is string => typeof x === 'string')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize one persisted experience entry. A hand-edited bank entry can be
|
|
37
|
+
* missing `files`/`tags` arrays (or `hitCount`) — consumers rendering/searching
|
|
38
|
+
* entries (`render` `.join(', ')`, `searchExperienceEntries` spread) must never
|
|
39
|
+
* throw or emit NaN. Returns null for non-object entries; every required field
|
|
40
|
+
* gets a safe default.
|
|
41
|
+
*/
|
|
42
|
+
function normalizeEntry(raw: unknown, index: number): ExperienceEntry | null {
|
|
43
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
44
|
+
const e = raw as Record<string, unknown>
|
|
45
|
+
const pattern = typeof e.pattern === 'string' ? e.pattern : ''
|
|
46
|
+
const dimension = typeof e.dimension === 'string' ? e.dimension : ''
|
|
47
|
+
// Deterministic id (content + position based) so a read→write cycle never
|
|
48
|
+
// churns random ids on entries that were hand-edited without one.
|
|
49
|
+
const derivedId = `exp-${hashString(`${index}|${pattern}|${dimension}`)}`
|
|
50
|
+
const id = typeof e.id === 'string' && e.id ? e.id : derivedId
|
|
51
|
+
if (!pattern && !dimension) return null
|
|
52
|
+
const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
timestamp: typeof e.timestamp === 'string' ? e.timestamp : new Date().toISOString(),
|
|
56
|
+
dimension,
|
|
57
|
+
pattern,
|
|
58
|
+
description: typeof e.description === 'string' ? e.description : '',
|
|
59
|
+
verifiedFix: typeof e.verifiedFix === 'string' ? e.verifiedFix : '',
|
|
60
|
+
findingSummary: typeof e.findingSummary === 'string' ? e.findingSummary : '',
|
|
61
|
+
files: stringArray(e.files),
|
|
62
|
+
hitCount,
|
|
63
|
+
...(typeof e.lastHitAt === 'string' ? { lastHitAt: e.lastHitAt } : {}),
|
|
64
|
+
tags: stringArray(e.tags),
|
|
65
|
+
severity: VALID_SEVERITIES.has(String(e.severity)) ? (e.severity as ExperienceEntry['severity']) : 'low',
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
25
69
|
/** Read the experience bank from disk. Returns empty bank if not found. */
|
|
26
70
|
export function readExperienceBank(projectRoot: string): ExperienceBank {
|
|
27
71
|
const filePath = path.join(projectRoot, '.iterate', EXPERIENCE_FILE)
|
|
28
72
|
try {
|
|
29
73
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
30
|
-
const parsed = JSON.parse(content) as ExperienceBank
|
|
74
|
+
const parsed = JSON.parse(content) as Partial<ExperienceBank>
|
|
31
75
|
if (parsed && Array.isArray(parsed.entries)) {
|
|
32
|
-
|
|
76
|
+
const entries = parsed.entries
|
|
77
|
+
.map((raw, i) => normalizeEntry(raw, i))
|
|
78
|
+
.filter((e): e is ExperienceEntry => e !== null)
|
|
79
|
+
return {
|
|
80
|
+
entries,
|
|
81
|
+
lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyBank().lastUpdated,
|
|
82
|
+
totalHits:
|
|
83
|
+
typeof parsed.totalHits === 'number' && Number.isFinite(parsed.totalHits) ? parsed.totalHits : 0,
|
|
84
|
+
}
|
|
33
85
|
}
|
|
34
86
|
} catch {
|
|
35
87
|
// File not found or invalid JSON
|
|
@@ -72,6 +124,9 @@ export function searchExperienceEntries(
|
|
|
72
124
|
const lowerQuery = query.toLowerCase()
|
|
73
125
|
|
|
74
126
|
return entries.filter((entry) => {
|
|
127
|
+
if (!entry || typeof entry !== 'object') return false
|
|
128
|
+
const rawEntry = entry as unknown as Record<string, unknown>
|
|
129
|
+
const tags = stringArray(rawEntry.tags)
|
|
75
130
|
// Dimension filter
|
|
76
131
|
if (opts.dimension && entry.dimension !== opts.dimension) {
|
|
77
132
|
return false
|
|
@@ -79,22 +134,24 @@ export function searchExperienceEntries(
|
|
|
79
134
|
|
|
80
135
|
// Tags filter (AND logic)
|
|
81
136
|
if (opts.tags && opts.tags.length > 0) {
|
|
82
|
-
if (!opts.tags.every((t) =>
|
|
137
|
+
if (!opts.tags.every((t) => tags.includes(t))) {
|
|
83
138
|
return false
|
|
84
139
|
}
|
|
85
140
|
}
|
|
86
141
|
|
|
87
|
-
// Text search across multiple fields
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
142
|
+
// Text search across multiple fields. Guards against a hand-edited bank entry
|
|
143
|
+
// whose `files`/`tags` are missing or non-array (the spread below would
|
|
144
|
+
// otherwise throw a TypeError on a non-iterable).
|
|
145
|
+
if (query) {
|
|
146
|
+
const searchableText = [
|
|
147
|
+
entry.pattern,
|
|
148
|
+
entry.description,
|
|
149
|
+
entry.verifiedFix,
|
|
150
|
+
entry.findingSummary,
|
|
151
|
+
entry.dimension,
|
|
152
|
+
...stringArray(entry.files),
|
|
153
|
+
...stringArray(entry.tags),
|
|
154
|
+
].join(' ').toLowerCase()
|
|
98
155
|
|
|
99
156
|
if (!searchableText.includes(lowerQuery)) {
|
|
100
157
|
return false
|
|
@@ -114,8 +171,11 @@ export type ExperienceEntryInput = Omit<
|
|
|
114
171
|
/**
|
|
115
172
|
* Add or update an experience entry.
|
|
116
173
|
*
|
|
117
|
-
* An entry with an `id` that already exists
|
|
118
|
-
*
|
|
174
|
+
* An entry with an `id` that already exists is treated as an UPDATE + HIT:
|
|
175
|
+
* the caller-supplied fields replace the stored ones and the hitCount is
|
|
176
|
+
* incremented (lastHitAt refreshed) — this fulfills the documented
|
|
177
|
+
* "update a specific entry via add" contract. A NEW entry whose
|
|
178
|
+
* `pattern`+`dimension` pair matches an existing entry is treated as a HIT:
|
|
119
179
|
* the matching entry's hitCount is incremented (lastHitAt refreshed) so
|
|
120
180
|
* repeated encounters of the same pattern do not create duplicates. Otherwise
|
|
121
181
|
* a fresh entry is appended with hitCount 1. Never mutates the input bank.
|
|
@@ -133,11 +193,14 @@ export function upsertExperience(
|
|
|
133
193
|
: bank.entries.find((e) => e.pattern === entry.pattern && e.dimension === entry.dimension)
|
|
134
194
|
|
|
135
195
|
if (existing) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
196
|
+
// Explicit-id updates REPLACE the editable fields (fulfilling the
|
|
197
|
+
// documented "update a specific entry via add" contract); a pattern+
|
|
198
|
+
// dimension HIT only bumps the hit metadata so a repeat encounter never
|
|
199
|
+
// overwrites the curated entry.
|
|
200
|
+
const byId = typeof entry.id === 'string' && entry.id.length > 0
|
|
201
|
+
const updated: ExperienceEntry = byId
|
|
202
|
+
? { ...existing, ...entry, id: existing.id, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
|
|
203
|
+
: { ...existing, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
|
|
141
204
|
return {
|
|
142
205
|
bank: {
|
|
143
206
|
...bank,
|
|
@@ -150,14 +213,16 @@ export function upsertExperience(
|
|
|
150
213
|
}
|
|
151
214
|
}
|
|
152
215
|
|
|
153
|
-
// Add new entry
|
|
216
|
+
// Add new entry. Spread the caller input FIRST so the store-generated
|
|
217
|
+
// `id`/`timestamp`/`hitCount`/`lastHitAt` always win — a hostile or
|
|
218
|
+
// malformed input can never forge its own identity or hit count.
|
|
154
219
|
const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
155
220
|
const newEntry: ExperienceEntry = {
|
|
221
|
+
...entry,
|
|
156
222
|
id,
|
|
157
223
|
timestamp: lastUpdated,
|
|
158
224
|
hitCount: 1,
|
|
159
225
|
lastHitAt: lastUpdated,
|
|
160
|
-
...entry,
|
|
161
226
|
}
|
|
162
227
|
|
|
163
228
|
return {
|
package/src/tools/fix.ts
CHANGED
|
@@ -126,11 +126,25 @@ export function readRegistry(projectRoot: string): FixRegistry {
|
|
|
126
126
|
try {
|
|
127
127
|
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as FixRegistry
|
|
128
128
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds)) return emptyRegistry()
|
|
129
|
-
// Defensive: a hand-edited or partially-written registry may
|
|
130
|
-
// round without a `records` array
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// Defensive normalization: a hand-edited or partially-written registry may
|
|
130
|
+
// contain a round without a `records` array, or records that are missing
|
|
131
|
+
// their id / finding object — all of which would make readers
|
|
132
|
+
// (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
|
|
133
|
+
parsed.rounds = parsed.rounds
|
|
134
|
+
.filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
|
|
135
|
+
.map((r) => ({
|
|
136
|
+
...r,
|
|
137
|
+
round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
|
|
138
|
+
records: r.records.filter(
|
|
139
|
+
(rec): rec is FixRecord =>
|
|
140
|
+
!!rec &&
|
|
141
|
+
typeof rec === 'object' &&
|
|
142
|
+
typeof rec.id === 'string' &&
|
|
143
|
+
!!rec.finding &&
|
|
144
|
+
typeof rec.finding === 'object',
|
|
145
|
+
),
|
|
146
|
+
}))
|
|
147
|
+
.filter((r) => typeof r.round === 'number' && Number.isFinite(r.round))
|
|
134
148
|
return parsed
|
|
135
149
|
} catch {
|
|
136
150
|
return emptyRegistry()
|
|
@@ -621,19 +635,25 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
|
|
|
621
635
|
for (const round of registry.rounds) {
|
|
622
636
|
for (const r of round.records) {
|
|
623
637
|
if (!r.success) continue
|
|
624
|
-
const
|
|
638
|
+
const file = typeof r.finding.file === 'string' ? r.finding.file : ''
|
|
639
|
+
if (!file) continue
|
|
640
|
+
// Coerce defensively: a hand-edited registry record missing the
|
|
641
|
+
// numeric fields must not produce NaN in the accumulated summary.
|
|
642
|
+
const added = Number(r.linesAdded) || 0
|
|
643
|
+
const removed = Number(r.linesRemoved) || 0
|
|
644
|
+
const existing = files.find((f) => f.file === file)
|
|
625
645
|
if (existing) {
|
|
626
|
-
existing.linesAdded +=
|
|
627
|
-
existing.linesRemoved +=
|
|
646
|
+
existing.linesAdded += added
|
|
647
|
+
existing.linesRemoved += removed
|
|
628
648
|
// Recompute the summary from the summed counts so a multi-fix
|
|
629
649
|
// file's text does not contradict its accumulated numbers.
|
|
630
650
|
existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`
|
|
631
651
|
} else {
|
|
632
652
|
files.push({
|
|
633
|
-
file
|
|
634
|
-
diffSummary: r.diffSummary
|
|
635
|
-
linesAdded:
|
|
636
|
-
linesRemoved:
|
|
653
|
+
file,
|
|
654
|
+
diffSummary: typeof r.diffSummary === 'string' ? r.diffSummary : `+${added}/-${removed} lines`,
|
|
655
|
+
linesAdded: added,
|
|
656
|
+
linesRemoved: removed,
|
|
637
657
|
})
|
|
638
658
|
}
|
|
639
659
|
}
|
package/src/tools/history.ts
CHANGED
|
@@ -60,8 +60,10 @@ export function summarizeFixRegistry(registry: FixRegistry): {
|
|
|
60
60
|
} {
|
|
61
61
|
const rounds = (registry.rounds ?? []).map((r) => ({
|
|
62
62
|
round: r.round,
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// Coerce defensively so a hand-edited registry round missing either count
|
|
64
|
+
// can never propagate NaN into the integer output fields.
|
|
65
|
+
fixedCount: Number(r.fixedCount) || 0,
|
|
66
|
+
failedCount: Number(r.failedCount) || 0,
|
|
65
67
|
}))
|
|
66
68
|
return {
|
|
67
69
|
totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
|
|
@@ -92,7 +94,7 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
92
94
|
type: 'string',
|
|
93
95
|
description:
|
|
94
96
|
'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
|
|
95
|
-
'revert, validation, decision, report.',
|
|
97
|
+
'revert, round_failed, validation, decision, report, resume.',
|
|
96
98
|
},
|
|
97
99
|
since: {
|
|
98
100
|
type: 'string',
|
package/src/tools/prune.ts
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* - Empty fix rounds (rounds with 0 records).
|
|
14
14
|
* - Stray temp files left behind by a crashed atomic write (see
|
|
15
15
|
* `isPrunableTemp` for the recognized naming conventions).
|
|
16
|
+
* - Experience bank entries beyond `MAX_EXPERIENCE_ENTRIES` (newest kept) —
|
|
17
|
+
* the bank grows across sessions with no natural bound; a sweep caps it.
|
|
18
|
+
* - Defense events older than `retainDays` (stale iteration streams that
|
|
19
|
+
* were never cleared).
|
|
16
20
|
*
|
|
17
21
|
* Security model:
|
|
18
22
|
* - Only operates under the resolved project `.iterate/` directory.
|
|
@@ -28,14 +32,27 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
|
28
32
|
import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
|
|
29
33
|
import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
|
|
30
34
|
import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
|
|
35
|
+
import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
|
|
36
|
+
import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
|
|
31
37
|
import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from '../paths.ts'
|
|
32
|
-
import type { FixRegistry } from '../types.ts'
|
|
38
|
+
import type { DefenseEventStream, ExperienceBank, FixRegistry } from '../types.ts'
|
|
33
39
|
|
|
34
40
|
/** Default retention for decision-log entries (in days). */
|
|
35
41
|
const DEFAULT_RETAIN_DAYS = 30
|
|
36
42
|
const MIN_RETAIN_DAYS = 1
|
|
37
43
|
const MAX_RETAIN_DAYS = 365
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Upper bound on experience-bank entries retained by a prune sweep. The bank
|
|
47
|
+
* accumulates curated knowledge across sessions and each entry is small, so a
|
|
48
|
+
* generous cap drops only the oldest tail of a pathologically large bank
|
|
49
|
+
* (never recent learning).
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_EXPERIENCE_ENTRIES = 2000
|
|
52
|
+
|
|
53
|
+
/** How many compare-and-append retries bound the decision-log rewrite race. */
|
|
54
|
+
const MAX_LOG_REWRITE_RETRIES = 3
|
|
55
|
+
|
|
39
56
|
/** Clamp retainDays to a sane range. */
|
|
40
57
|
export function clampRetainDays(days: number | undefined): number {
|
|
41
58
|
if (typeof days !== 'number' || !Number.isInteger(days) || days <= 0) {
|
|
@@ -71,6 +88,62 @@ export function isPrunableTemp(name: string): boolean {
|
|
|
71
88
|
return name.startsWith('.') && name.includes('.tmp-')
|
|
72
89
|
}
|
|
73
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Bound the experience bank to its newest `cap` entries (by timestamp; ties
|
|
93
|
+
* keep their original order via stable sort). Returns the trimmed bank plus
|
|
94
|
+
* how many entries would be dropped. Never mutates the input bank.
|
|
95
|
+
*/
|
|
96
|
+
export function sweepExperienceBank(bank: ExperienceBank, cap: number): { bank: ExperienceBank; removed: number } {
|
|
97
|
+
const size = Number.isFinite(cap) && (cap as number) >= 0 ? Math.floor(cap) : MAX_EXPERIENCE_ENTRIES
|
|
98
|
+
const total = bank.entries.length
|
|
99
|
+
if (total <= size) return { bank, removed: 0 }
|
|
100
|
+
const kept = [...bank.entries]
|
|
101
|
+
.sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''))
|
|
102
|
+
.slice(0, size)
|
|
103
|
+
// Preserve the original (insertion) order of the surviving entries.
|
|
104
|
+
const keptIds = new Set(kept.map((e) => e.id))
|
|
105
|
+
const ordered = bank.entries.filter((e) => keptIds.has(e.id))
|
|
106
|
+
return {
|
|
107
|
+
bank: { ...bank, entries: ordered, lastUpdated: new Date().toISOString() },
|
|
108
|
+
removed: total - ordered.length,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Drop defense events older than `cutoff` from the stream using an ISO
|
|
114
|
+
* timestamp comparison, recomputing the counts so they can never drift from
|
|
115
|
+
* the surviving events. Never mutates the input stream.
|
|
116
|
+
*/
|
|
117
|
+
export function sweepDefenseEvents(
|
|
118
|
+
stream: DefenseEventStream,
|
|
119
|
+
cutoff: string,
|
|
120
|
+
): { stream: DefenseEventStream; removed: number } {
|
|
121
|
+
const kept = stream.events.filter((e) => e.timestamp >= cutoff)
|
|
122
|
+
if (kept.length === stream.events.length) return { stream, removed: 0 }
|
|
123
|
+
return {
|
|
124
|
+
stream: {
|
|
125
|
+
events: kept,
|
|
126
|
+
lastUpdated: new Date().toISOString(),
|
|
127
|
+
counts: computeDefenseCounts(kept),
|
|
128
|
+
},
|
|
129
|
+
removed: stream.events.length - kept.length,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Recompute per-type defense counts from a list of events. */
|
|
134
|
+
function computeDefenseCounts(events: DefenseEventStream['events']): DefenseEventStream['counts'] {
|
|
135
|
+
const counts: DefenseEventStream['counts'] = {
|
|
136
|
+
precondition_failed: 0,
|
|
137
|
+
rollback: 0,
|
|
138
|
+
invariant_violated: 0,
|
|
139
|
+
assumption_falsified: 0,
|
|
140
|
+
}
|
|
141
|
+
for (const e of events) {
|
|
142
|
+
if (e.type in counts) counts[e.type]++
|
|
143
|
+
}
|
|
144
|
+
return counts
|
|
145
|
+
}
|
|
146
|
+
|
|
74
147
|
/**
|
|
75
148
|
* Inspect the runtime state and report what would be pruned.
|
|
76
149
|
* Pure (no deletions). Returns a structured report.
|
|
@@ -86,6 +159,10 @@ export function inspectPrune(
|
|
|
86
159
|
emptyRounds: number[]
|
|
87
160
|
totalLogEntries: number
|
|
88
161
|
registryRounds: number
|
|
162
|
+
totalExperiences: number
|
|
163
|
+
experienceOversize: number
|
|
164
|
+
totalDefenseEvents: number
|
|
165
|
+
staleDefenseEvents: number
|
|
89
166
|
} {
|
|
90
167
|
const cutoff = cutoffTimestamp(retainDays)
|
|
91
168
|
|
|
@@ -106,16 +183,22 @@ export function inspectPrune(
|
|
|
106
183
|
}
|
|
107
184
|
const staleBackups: string[] = []
|
|
108
185
|
const fixDir = fixesDir(projectRoot)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
186
|
+
// existsSync does not prove readability — a permissions failure must degrade
|
|
187
|
+
// to "nothing listable" (report skipped) instead of throwing out of execute.
|
|
188
|
+
let fixEntries: string[] = []
|
|
189
|
+
try {
|
|
190
|
+
if (existsSync(fixDir)) fixEntries = readdirSync(fixDir)
|
|
191
|
+
} catch {
|
|
192
|
+
fixEntries = []
|
|
193
|
+
}
|
|
194
|
+
for (const entry of fixEntries) {
|
|
195
|
+
if (!entry.endsWith('.bak')) continue
|
|
196
|
+
// Extract the fix-id prefix (up to the first underscore after the id).
|
|
197
|
+
// e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
|
|
198
|
+
const match = entry.match(/^(fix-[a-z0-9]+)_/)
|
|
199
|
+
const id = match?.[1]
|
|
200
|
+
if (id && !activeIds.has(id)) {
|
|
201
|
+
staleBackups.push(entry)
|
|
119
202
|
}
|
|
120
203
|
}
|
|
121
204
|
|
|
@@ -126,10 +209,26 @@ export function inspectPrune(
|
|
|
126
209
|
|
|
127
210
|
// 5. Stray atomic-write temp files left behind by a crashed writer. The
|
|
128
211
|
// unique prefix makes them recognizable and safe to delete — no live writer
|
|
129
|
-
// ever reads another writer's temp file.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
212
|
+
// ever reads another writer's temp file. A permissions failure listing the
|
|
213
|
+
// dir degrades to "none found" (report-only) rather than throwing.
|
|
214
|
+
let iterateEntries: string[] = []
|
|
215
|
+
try {
|
|
216
|
+
iterateEntries = existsSync(iterateDir(projectRoot)) ? readdirSync(iterateDir(projectRoot)) : []
|
|
217
|
+
} catch {
|
|
218
|
+
iterateEntries = []
|
|
219
|
+
}
|
|
220
|
+
const staleTemps = iterateEntries.filter((f) => isPrunableTemp(f)).sort()
|
|
221
|
+
|
|
222
|
+
// 6. Experience-bank oversize (entries beyond the entry cap). The bank grows
|
|
223
|
+
// across sessions with no natural bound — a sweep reports the tail that a
|
|
224
|
+
// prune would drop (newest `MAX_EXPERIENCE_ENTRIES` preserved).
|
|
225
|
+
const bank = readExperienceBank(projectRoot)
|
|
226
|
+
const sweptBank = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES)
|
|
227
|
+
|
|
228
|
+
// 7. Defense events older than retainDays (a previous iteration's stream
|
|
229
|
+
// that was never cleared).
|
|
230
|
+
const defenseStream = readDefenseEvents(projectRoot)
|
|
231
|
+
const sweptDefense = sweepDefenseEvents(defenseStream, cutoff)
|
|
133
232
|
|
|
134
233
|
return {
|
|
135
234
|
oldLogEntries,
|
|
@@ -139,6 +238,10 @@ export function inspectPrune(
|
|
|
139
238
|
emptyRounds,
|
|
140
239
|
totalLogEntries: entries.length,
|
|
141
240
|
registryRounds: registry.rounds.length,
|
|
241
|
+
totalExperiences: bank.entries.length,
|
|
242
|
+
experienceOversize: sweptBank.removed,
|
|
243
|
+
totalDefenseEvents: defenseStream.events.length,
|
|
244
|
+
staleDefenseEvents: sweptDefense.removed,
|
|
142
245
|
}
|
|
143
246
|
}
|
|
144
247
|
|
|
@@ -146,6 +249,45 @@ export function inspectPrune(
|
|
|
146
249
|
* Actually prune the runtime artifacts (only called when dryRun=false).
|
|
147
250
|
* Returns a detailed report of what was deleted.
|
|
148
251
|
*/
|
|
252
|
+
/**
|
|
253
|
+
* Rewrite the decision log to the entries younger than `cutoff`.
|
|
254
|
+
*
|
|
255
|
+
* The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
|
|
256
|
+
* a fresh audit line between our read and our atomic rewrite, and an atomic
|
|
257
|
+
* rename would silently discard it. So instead of a single read+write we use a
|
|
258
|
+
* bounded compare-and-append loop — after each rewrite we re-read the log and
|
|
259
|
+
* retry whenever the file grew (a concurrent append slipped in). The loop
|
|
260
|
+
* terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
|
|
261
|
+
* failure here is surfaced as a structured error instead of truncating the log.
|
|
262
|
+
*
|
|
263
|
+
* @returns the number of entries removed (best-effort) or 0 on error.
|
|
264
|
+
*/
|
|
265
|
+
function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): { deleted: number; error?: string } {
|
|
266
|
+
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
|
|
267
|
+
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
268
|
+
try {
|
|
269
|
+
const entries = readDecisionEntries(projectRoot)
|
|
270
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff)
|
|
271
|
+
const deleted = entries.length - kept.length
|
|
272
|
+
if (deleted === 0) return { deleted: 0 }
|
|
273
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
274
|
+
// A concurrent appender may have landed new lines after our read. If the
|
|
275
|
+
// log grew during the write window, re-read and prune again instead of
|
|
276
|
+
// accepting a lost audit trail.
|
|
277
|
+
const after = readDecisionEntries(projectRoot)
|
|
278
|
+
if (after.length <= kept.length) return { deleted }
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Execute the prune: apply every cleanable item reported by `inspectPrune`.
|
|
288
|
+
* Every step is individually hedged so a single disk failure degrades to a
|
|
289
|
+
* reported error rather than aborting the whole cleanup.
|
|
290
|
+
*/
|
|
149
291
|
export function executePrune(
|
|
150
292
|
projectRoot: string,
|
|
151
293
|
retainDays: number,
|
|
@@ -156,6 +298,8 @@ export function executePrune(
|
|
|
156
298
|
deletedBackups: string[]
|
|
157
299
|
deletedTemps: string[]
|
|
158
300
|
trimmedEmptyRounds: number
|
|
301
|
+
deletedExperiences: number
|
|
302
|
+
deletedDefenseEvents: number
|
|
159
303
|
errors: string[]
|
|
160
304
|
} {
|
|
161
305
|
const cutoff = cutoffTimestamp(retainDays)
|
|
@@ -165,22 +309,19 @@ export function executePrune(
|
|
|
165
309
|
deletedBackups: [] as string[],
|
|
166
310
|
deletedTemps: [] as string[],
|
|
167
311
|
trimmedEmptyRounds: 0,
|
|
312
|
+
deletedExperiences: 0,
|
|
313
|
+
deletedDefenseEvents: 0,
|
|
168
314
|
errors: [] as string[],
|
|
169
315
|
}
|
|
170
316
|
|
|
171
317
|
// 1. Rewrite the decision log, keeping only recent entries. Atomic
|
|
172
|
-
// (temp + rename) so a crash mid-write can never truncate the log
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
180
|
-
}
|
|
181
|
-
} catch (err) {
|
|
182
|
-
result.errors.push(`failed to rewrite decision log: ${String(err)}`)
|
|
183
|
-
result.deletedLogEntries = 0
|
|
318
|
+
// (temp + rename) so a crash mid-write can never truncate the log; the
|
|
319
|
+
// bounded compare-and-append loop guards against a concurrent appender
|
|
320
|
+
// slipping a fresh audit line into the read-before-rewrite window.
|
|
321
|
+
{
|
|
322
|
+
const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff)
|
|
323
|
+
result.deletedLogEntries = deleted
|
|
324
|
+
if (error) result.errors.push(error)
|
|
184
325
|
}
|
|
185
326
|
|
|
186
327
|
// 2. Remove checkpoint.
|
|
@@ -233,6 +374,34 @@ export function executePrune(
|
|
|
233
374
|
}
|
|
234
375
|
}
|
|
235
376
|
|
|
377
|
+
// 6. Trim the experience bank to its entry cap (newest wins).
|
|
378
|
+
if (report.experienceOversize > 0) {
|
|
379
|
+
try {
|
|
380
|
+
const bank = readExperienceBank(projectRoot)
|
|
381
|
+
const { bank: swept, removed } = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES)
|
|
382
|
+
if (removed > 0) {
|
|
383
|
+
writeExperienceBank(projectRoot, swept)
|
|
384
|
+
result.deletedExperiences = removed
|
|
385
|
+
}
|
|
386
|
+
} catch (err) {
|
|
387
|
+
result.errors.push(`failed to trim experience bank: ${String(err)}`)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 7. Drop defense events older than retainDays (recounted on write).
|
|
392
|
+
if (report.staleDefenseEvents > 0) {
|
|
393
|
+
try {
|
|
394
|
+
const stream = readDefenseEvents(projectRoot)
|
|
395
|
+
const { stream: swept, removed } = sweepDefenseEvents(stream, cutoff)
|
|
396
|
+
if (removed > 0) {
|
|
397
|
+
writeDefenseEvents(projectRoot, swept)
|
|
398
|
+
result.deletedDefenseEvents = removed
|
|
399
|
+
}
|
|
400
|
+
} catch (err) {
|
|
401
|
+
result.errors.push(`failed to sweep defense events: ${String(err)}`)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
236
405
|
return result
|
|
237
406
|
}
|
|
238
407
|
|
|
@@ -249,7 +418,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
249
418
|
'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
250
419
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
251
420
|
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
252
|
-
'
|
|
421
|
+
'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
|
|
422
|
+
'and stale defense events. ' +
|
|
253
423
|
'Each deletion is logged to the decision log.',
|
|
254
424
|
parameters: {
|
|
255
425
|
dryRun: {
|
|
@@ -291,6 +461,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
291
461
|
` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
|
|
292
462
|
` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
|
|
293
463
|
` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
|
|
464
|
+
` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
|
|
465
|
+
` Stale defense events to remove: ${report?.staleDefenseEvents ?? 0} (of ${report?.totalDefenseEvents ?? '?'})`,
|
|
294
466
|
'',
|
|
295
467
|
'Pass dryRun:false to execute the prune.',
|
|
296
468
|
]
|
|
@@ -303,6 +475,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
303
475
|
` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
|
|
304
476
|
` Deleted ${(result?.deletedTemps as string[] | undefined)?.length ?? 0} stray temp files.`,
|
|
305
477
|
` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
|
|
478
|
+
` Trimmed ${result?.deletedExperiences ?? 0} experience entries (over the entry cap).`,
|
|
479
|
+
` Removed ${result?.deletedDefenseEvents ?? 0} stale defense events.`,
|
|
306
480
|
]
|
|
307
481
|
const errs = (result?.errors as string[] | undefined) ?? []
|
|
308
482
|
if (errs.length > 0) {
|
|
@@ -345,6 +519,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
345
519
|
deletedCheckpoint: result.deletedCheckpoint,
|
|
346
520
|
deletedBackups: result.deletedBackups.length,
|
|
347
521
|
trimmedEmptyRounds: result.trimmedEmptyRounds,
|
|
522
|
+
deletedExperiences: result.deletedExperiences,
|
|
523
|
+
deletedDefenseEvents: result.deletedDefenseEvents,
|
|
348
524
|
},
|
|
349
525
|
})
|
|
350
526
|
|
|
@@ -13,6 +13,9 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
|
13
13
|
import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from './quality-store.ts'
|
|
14
14
|
import type { QualityGateSnapshot } from '../types.ts'
|
|
15
15
|
|
|
16
|
+
/** Valid severity values (kept in sync with QualityGateSnapshot counting). */
|
|
17
|
+
const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
|
|
18
|
+
|
|
16
19
|
/** Validate a single finding object; returns true when well-formed. */
|
|
17
20
|
function isValidFinding(raw: unknown): raw is { dimension: string; severity: string; file: string; line?: number } {
|
|
18
21
|
if (!raw || typeof raw !== 'object') return false
|
|
@@ -20,6 +23,7 @@ function isValidFinding(raw: unknown): raw is { dimension: string; severity: str
|
|
|
20
23
|
return (
|
|
21
24
|
typeof f.dimension === 'string' &&
|
|
22
25
|
typeof f.severity === 'string' &&
|
|
26
|
+
VALID_SEVERITIES.has(f.severity) &&
|
|
23
27
|
typeof f.file === 'string' &&
|
|
24
28
|
(f.line === undefined || typeof f.line === 'number')
|
|
25
29
|
)
|
|
@@ -232,15 +232,20 @@ export function computeQualityGate(opts: {
|
|
|
232
232
|
? Math.round(dimensionGates.reduce((sum, d) => sum + d.score, 0) / dimensionGates.length)
|
|
233
233
|
: 0
|
|
234
234
|
|
|
235
|
-
// Determine overall status
|
|
235
|
+
// Determine overall status. An empty review (no dimensions, no findings, no
|
|
236
|
+
// validation results) is NOT a failure — there is simply nothing to gate —
|
|
237
|
+
// so it reports `pending` (not-applicable) instead of a fabricated FAIL.
|
|
236
238
|
const hasCritical = criticalCount > 0
|
|
237
239
|
const hasHighFail = dimensionGates.some((d) => d.status === 'fail')
|
|
238
240
|
const verificationFails = totalChecks > 0 && failedChecks > 0
|
|
241
|
+
const noData = dimensionGates.length === 0 && totalFindings === 0 && totalChecks === 0
|
|
239
242
|
|
|
240
243
|
let overallStatus: 'pass' | 'fail' | 'pending' = 'pass'
|
|
241
244
|
let failReason: string | undefined
|
|
242
245
|
|
|
243
|
-
if (
|
|
246
|
+
if (noData) {
|
|
247
|
+
overallStatus = 'pending'
|
|
248
|
+
} else if (hasCritical) {
|
|
244
249
|
overallStatus = 'fail'
|
|
245
250
|
failReason = `${criticalCount} critical findings present`
|
|
246
251
|
} else if (hasHighFail) {
|