iterate-plugin 3.5.2 → 3.5.3
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/tools/checkpoint.js +39 -5
- package/dist/tools/config.js +11 -2
- package/dist/tools/defense-events.js +41 -6
- package/dist/tools/defense-store.js +53 -2
- package/dist/tools/experience-bank.js +8 -8
- package/dist/tools/experience-store.js +58 -5
- package/dist/tools/fix.js +30 -10
- package/dist/tools/history.js +5 -3
- package/dist/tools/prune.js +29 -15
- package/dist/tools/quality-gate.js +3 -0
- package/dist/tools/quality-store.js +8 -2
- package/dist/tools/transcript.js +46 -11
- package/dist/transcript.js +20 -5
- package/lib/client.js +52 -10
- package/lib/parse.js +3 -0
- package/package.json +1 -1
- package/src/client/index.ts +30 -5
- package/src/tools/checkpoint.ts +45 -6
- package/src/tools/config.ts +11 -2
- package/src/tools/defense-events.ts +43 -6
- package/src/tools/defense-store.ts +54 -5
- package/src/tools/experience-bank.ts +8 -8
- package/src/tools/experience-store.ts +67 -14
- package/src/tools/fix.ts +32 -12
- package/src/tools/history.ts +5 -3
- package/src/tools/prune.ts +25 -14
- package/src/tools/quality-gate.ts +4 -0
- package/src/tools/quality-store.ts +7 -2
- package/src/tools/transcript.ts +47 -11
- package/src/transcript.ts +23 -5
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
13
13
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
14
14
|
import { resolveProjectRootForExec, loadEffectiveConfig } from '../config-loader.ts'
|
|
15
|
-
import { readDefenseEvents, writeDefenseEvents, addDefenseEvent } from './defense-store.ts'
|
|
15
|
+
import { readDefenseEvents, writeDefenseEvents, addDefenseEvent, clearDefenseEvents } from './defense-store.ts'
|
|
16
16
|
import type { DefenseEvent, DefenseEventType } from '../types.ts'
|
|
17
17
|
|
|
18
18
|
const DEFAULT_LIMIT = 50
|
|
@@ -95,16 +95,18 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
|
|
|
95
95
|
// join a parallel dispatch group.
|
|
96
96
|
isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'record',
|
|
97
97
|
description:
|
|
98
|
-
'Query or
|
|
98
|
+
'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
|
|
99
99
|
'and assumption falsifications. ' +
|
|
100
100
|
'List/counts return events with descriptions, outcomes, and summary counts; ' +
|
|
101
|
-
'"record" persists a new event to .iterate/defense-events.json
|
|
101
|
+
'"record" persists a new event to .iterate/defense-events.json; ' +
|
|
102
|
+
'"clear" resets the persisted event stream (use it to start a fresh iteration without stale ' +
|
|
103
|
+
'defense data). ' +
|
|
102
104
|
'Use it to review defensive actions taken, or to log one when a defense fires.',
|
|
103
105
|
parameters: {
|
|
104
106
|
operation: {
|
|
105
107
|
type: 'string',
|
|
106
|
-
description: 'Operation: list (browse all), counts (summary by type), record (log a new event). Default: list.',
|
|
107
|
-
enum: ['list', 'counts', 'record'],
|
|
108
|
+
description: 'Operation: list (browse all), counts (summary by type), record (log a new event), clear (reset the stream). Default: list.',
|
|
109
|
+
enum: ['list', 'counts', 'record', 'clear'],
|
|
108
110
|
},
|
|
109
111
|
type: {
|
|
110
112
|
type: 'string',
|
|
@@ -162,6 +164,7 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
|
|
|
162
164
|
kind: { type: 'string' },
|
|
163
165
|
operation: { type: 'string' },
|
|
164
166
|
count: { type: 'integer' },
|
|
167
|
+
counted: { type: 'boolean', description: 'clear only: true when a persisted stream was removed.' },
|
|
165
168
|
events: { type: 'json' },
|
|
166
169
|
counts: { type: 'json' },
|
|
167
170
|
event: { type: 'json' },
|
|
@@ -174,6 +177,17 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
|
|
|
174
177
|
if (!value.ok) return [{ type: 'text', text: `defense events query failed: ${value.error}` }]
|
|
175
178
|
const language: 'zh' | 'en' = value.language === 'zh' ? 'zh' : 'en'
|
|
176
179
|
|
|
180
|
+
if (value.operation === 'clear') {
|
|
181
|
+
// Fresh stream: counts are all zero and the event list is empty.
|
|
182
|
+
return [{ type: 'text', text: [
|
|
183
|
+
value.counted === true
|
|
184
|
+
? 'Defense event stream cleared — a fresh iteration starts with an empty stream.'
|
|
185
|
+
: 'No persisted defense event stream to clear.',
|
|
186
|
+
'Defense Event Summary:',
|
|
187
|
+
...EVENT_TYPES.map((type) => ` ${labelFor(type, language)}: 0`),
|
|
188
|
+
].join('\n') }]
|
|
189
|
+
}
|
|
190
|
+
|
|
177
191
|
if (value.operation === 'counts' && value.counts) {
|
|
178
192
|
const counts = value.counts as Record<DefenseEventType, number>
|
|
179
193
|
const lines = [
|
|
@@ -220,12 +234,35 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
|
|
|
220
234
|
if (!resolved.ok) return { ok: false, kind: 'defense_events', error: resolved.reason }
|
|
221
235
|
const projectRoot = resolved.root
|
|
222
236
|
|
|
223
|
-
|
|
237
|
+
// A hand-edited `language` value outside zh/en must not leak out of the
|
|
238
|
+
// declared 'zh' | 'en' enum (labelFor would return undefined for it) —
|
|
239
|
+
// normalize any non-'zh' config value to 'en'.
|
|
240
|
+
const rawConfigLang = loadEffectiveConfig(projectRoot).config.language
|
|
241
|
+
const configLang: 'zh' | 'en' = rawConfigLang === 'zh' ? 'zh' : 'en'
|
|
224
242
|
const language: 'zh' | 'en' = args.language === 'zh' || args.language === 'en' ? args.language : configLang
|
|
225
243
|
|
|
226
244
|
const operation = typeof args.operation === 'string' ? args.operation : 'list'
|
|
227
245
|
const limit = clampLimit(args.limit as number | undefined)
|
|
228
246
|
|
|
247
|
+
if (operation === 'clear') {
|
|
248
|
+
// Reset the persisted event stream so a fresh iteration does not carry
|
|
249
|
+
// stale defensive data (mirrors iterate_quality_gate clear / clearQualityGate).
|
|
250
|
+
const result = clearDefenseEvents(projectRoot)
|
|
251
|
+
if (!result.ok) {
|
|
252
|
+
return { ok: false, kind: 'defense_events', operation: 'clear', error: result.error }
|
|
253
|
+
}
|
|
254
|
+
const empty = readDefenseEvents(projectRoot)
|
|
255
|
+
return {
|
|
256
|
+
ok: true,
|
|
257
|
+
kind: 'defense_events',
|
|
258
|
+
operation: 'clear',
|
|
259
|
+
counted: result.existed,
|
|
260
|
+
language,
|
|
261
|
+
counts: empty.counts as unknown as JsonValue,
|
|
262
|
+
events: empty.events as unknown as JsonValue,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
229
266
|
if (operation === 'record') {
|
|
230
267
|
const errors = validateRecordInput(args)
|
|
231
268
|
if (errors.length > 0) {
|
|
@@ -46,11 +46,41 @@ function emptyStream(): DefenseEventStream {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** Valid severity values (kept in sync with DefenseEvent). */
|
|
50
|
+
const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalize one persisted event. Hand-edited files can carry events missing
|
|
54
|
+
* `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
|
|
55
|
+
* (list sort by timestamp, render label selection) must never crash or emit
|
|
56
|
+
* NaN for those. Returns null when the entry is not an object or has no usable
|
|
57
|
+
* `type`; otherwise fills every required field with a safe default.
|
|
58
|
+
*/
|
|
59
|
+
function normalizeEvent(raw: unknown): DefenseEvent | null {
|
|
60
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
61
|
+
const e = raw as Record<string, unknown>
|
|
62
|
+
if (typeof e.type !== 'string' || !VALID_EVENT_TYPES.has(e.type as DefenseEventType)) return null
|
|
63
|
+
return {
|
|
64
|
+
id: typeof e.id === 'string' && e.id ? e.id : `def-${Math.random().toString(36).slice(2, 8)}`,
|
|
65
|
+
timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
|
|
66
|
+
round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
|
|
67
|
+
type: e.type as DefenseEventType,
|
|
68
|
+
description: typeof e.description === 'string' ? e.description : '',
|
|
69
|
+
defense: typeof e.defense === 'string' ? e.defense : '',
|
|
70
|
+
outcome: typeof e.outcome === 'string' ? e.outcome : '',
|
|
71
|
+
...(typeof e.file === 'string' && e.file.length > 0 ? { file: e.file } : {}),
|
|
72
|
+
...(typeof e.line === 'number' && Number.isFinite(e.line) && e.line >= 0 ? { line: e.line } : {}),
|
|
73
|
+
severity: VALID_SEVERITIES.has(String(e.severity)) ? (e.severity as DefenseEvent['severity']) : 'low',
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
49
77
|
/**
|
|
50
78
|
* Read the defense events stream from disk.
|
|
51
79
|
* Normalizes the persisted stream so a hand-edited / partial file can never
|
|
52
80
|
* produce NaN counts: `counts` is recomputed from the events when missing or
|
|
53
|
-
* malformed,
|
|
81
|
+
* malformed, every type key is guaranteed present, and every surviving event is
|
|
82
|
+
* shape-normalized so consumers (timestamp sort, render label selection) cannot
|
|
83
|
+
* throw on missing fields.
|
|
54
84
|
*/
|
|
55
85
|
export function readDefenseEvents(projectRoot: string): DefenseEventStream {
|
|
56
86
|
const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE)
|
|
@@ -58,10 +88,9 @@ export function readDefenseEvents(projectRoot: string): DefenseEventStream {
|
|
|
58
88
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
59
89
|
const parsed = JSON.parse(content) as Partial<DefenseEventStream>
|
|
60
90
|
if (parsed && Array.isArray(parsed.events)) {
|
|
61
|
-
const events = parsed.events
|
|
62
|
-
(
|
|
63
|
-
|
|
64
|
-
)
|
|
91
|
+
const events = parsed.events
|
|
92
|
+
.map(normalizeEvent)
|
|
93
|
+
.filter((e): e is DefenseEvent => e !== null)
|
|
65
94
|
const counts = computeCounts(events)
|
|
66
95
|
return {
|
|
67
96
|
events,
|
|
@@ -101,6 +130,26 @@ export function writeDefenseEvents(
|
|
|
101
130
|
return { ok: true }
|
|
102
131
|
}
|
|
103
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Clear the persisted defense-event stream (`.iterate/defense-events.json`).
|
|
135
|
+
* Mirrors `clearQualityGate` in quality-store.ts: a stale event stream from a
|
|
136
|
+
* previous iteration must be resettable before a fresh run. Returns whether a
|
|
137
|
+
* file existed and was removed, or a structured error when removal fails.
|
|
138
|
+
*/
|
|
139
|
+
export function clearDefenseEvents(
|
|
140
|
+
projectRoot: string,
|
|
141
|
+
): { ok: true; existed: boolean } | { ok: false; error: string } {
|
|
142
|
+
const filePath = path.join(iterateDir(projectRoot), DEFENSE_EVENTS_FILE)
|
|
143
|
+
const existed = fs.existsSync(filePath)
|
|
144
|
+
if (!existed) return { ok: true, existed: false }
|
|
145
|
+
try {
|
|
146
|
+
fs.rmSync(filePath, { force: true })
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` }
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, existed: true }
|
|
151
|
+
}
|
|
152
|
+
|
|
104
153
|
/** Add a defense event to the stream. */
|
|
105
154
|
export function addDefenseEvent(
|
|
106
155
|
stream: DefenseEventStream,
|
|
@@ -151,8 +151,8 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
151
151
|
`Dimension: ${entry.dimension}`,
|
|
152
152
|
`Description: ${entry.description}`,
|
|
153
153
|
`Fix: ${entry.verifiedFix}`,
|
|
154
|
-
`Files: ${entry.files.join(', ')}`,
|
|
155
|
-
`Tags: ${entry.tags.join(', ')}`,
|
|
154
|
+
`Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
|
|
155
|
+
`Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
|
|
156
156
|
].join('\n') }]
|
|
157
157
|
}
|
|
158
158
|
if (value.operation === 'remove') {
|
|
@@ -165,9 +165,9 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
165
165
|
`Pattern: ${entry.pattern}`,
|
|
166
166
|
`Description: ${entry.description}`,
|
|
167
167
|
`Fix: ${entry.verifiedFix}`,
|
|
168
|
-
`Files: ${entry.files.join(', ')}`,
|
|
168
|
+
`Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
|
|
169
169
|
`Hits: ${entry.hitCount}`,
|
|
170
|
-
`Tags: ${entry.tags.join(', ')}`,
|
|
170
|
+
`Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
|
|
171
171
|
].join('\n') }]
|
|
172
172
|
}
|
|
173
173
|
const entries = (value.entries as unknown as ExperienceEntry[] | undefined) ?? []
|
|
@@ -264,7 +264,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
264
264
|
operation: 'get',
|
|
265
265
|
count: 1,
|
|
266
266
|
entry: entry as unknown as JsonValue,
|
|
267
|
-
totalHits: bank.totalHits,
|
|
267
|
+
totalHits: bank.totalHits ?? 0,
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
|
@@ -280,7 +280,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
280
280
|
operation: 'search',
|
|
281
281
|
count: entries.length,
|
|
282
282
|
entries: entries as unknown as JsonValue,
|
|
283
|
-
totalHits: bank.totalHits,
|
|
283
|
+
totalHits: bank.totalHits ?? 0,
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
|
|
@@ -290,7 +290,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
290
290
|
entries = entries.filter((e) => e.dimension === args.dimension)
|
|
291
291
|
}
|
|
292
292
|
if (Array.isArray(args.tags) && args.tags.length > 0) {
|
|
293
|
-
entries = entries.filter((e) => args.tags!.every((t: string) => e.tags.includes(t)))
|
|
293
|
+
entries = entries.filter((e) => args.tags!.every((t: string) => Array.isArray(e.tags) && e.tags.includes(t)))
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
return {
|
|
@@ -299,7 +299,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
299
299
|
operation: 'list',
|
|
300
300
|
count: Math.min(entries.length, limit),
|
|
301
301
|
entries: entries.slice(0, limit) as unknown as JsonValue,
|
|
302
|
-
totalHits: bank.totalHits,
|
|
302
|
+
totalHits: bank.totalHits ?? 0,
|
|
303
303
|
}
|
|
304
304
|
},
|
|
305
305
|
}),
|
|
@@ -22,14 +22,62 @@ function emptyBank(): ExperienceBank {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Valid severity values (kept in sync with ExperienceEntry). */
|
|
26
|
+
const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
|
|
27
|
+
|
|
28
|
+
/** String array guard for fields that must be arrays (`files`, `tags`). */
|
|
29
|
+
function stringArray(v: unknown): string[] {
|
|
30
|
+
if (!Array.isArray(v)) return []
|
|
31
|
+
return v.filter((x): x is string => typeof x === 'string')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize one persisted experience entry. A hand-edited bank entry can be
|
|
36
|
+
* missing `files`/`tags` arrays (or `hitCount`) — consumers rendering/searching
|
|
37
|
+
* entries (`render` `.join(', ')`, `searchExperienceEntries` spread) must never
|
|
38
|
+
* throw or emit NaN. Returns null for non-object entries; every required field
|
|
39
|
+
* gets a safe default.
|
|
40
|
+
*/
|
|
41
|
+
function normalizeEntry(raw: unknown): ExperienceEntry | null {
|
|
42
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
43
|
+
const e = raw as Record<string, unknown>
|
|
44
|
+
const pattern = typeof e.pattern === 'string' ? e.pattern : ''
|
|
45
|
+
const dimension = typeof e.dimension === 'string' ? e.dimension : ''
|
|
46
|
+
const id = typeof e.id === 'string' && e.id ? e.id : `exp-${Math.random().toString(36).slice(2, 8)}`
|
|
47
|
+
if (!pattern && !dimension) return null
|
|
48
|
+
const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
timestamp: typeof e.timestamp === 'string' ? e.timestamp : new Date().toISOString(),
|
|
52
|
+
dimension,
|
|
53
|
+
pattern,
|
|
54
|
+
description: typeof e.description === 'string' ? e.description : '',
|
|
55
|
+
verifiedFix: typeof e.verifiedFix === 'string' ? e.verifiedFix : '',
|
|
56
|
+
findingSummary: typeof e.findingSummary === 'string' ? e.findingSummary : '',
|
|
57
|
+
files: stringArray(e.files),
|
|
58
|
+
hitCount,
|
|
59
|
+
...(typeof e.lastHitAt === 'string' ? { lastHitAt: e.lastHitAt } : {}),
|
|
60
|
+
tags: stringArray(e.tags),
|
|
61
|
+
severity: VALID_SEVERITIES.has(String(e.severity)) ? (e.severity as ExperienceEntry['severity']) : 'low',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
25
65
|
/** Read the experience bank from disk. Returns empty bank if not found. */
|
|
26
66
|
export function readExperienceBank(projectRoot: string): ExperienceBank {
|
|
27
67
|
const filePath = path.join(projectRoot, '.iterate', EXPERIENCE_FILE)
|
|
28
68
|
try {
|
|
29
69
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
30
|
-
const parsed = JSON.parse(content) as ExperienceBank
|
|
70
|
+
const parsed = JSON.parse(content) as Partial<ExperienceBank>
|
|
31
71
|
if (parsed && Array.isArray(parsed.entries)) {
|
|
32
|
-
|
|
72
|
+
const entries = parsed.entries
|
|
73
|
+
.map(normalizeEntry)
|
|
74
|
+
.filter((e): e is ExperienceEntry => e !== null)
|
|
75
|
+
return {
|
|
76
|
+
entries,
|
|
77
|
+
lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyBank().lastUpdated,
|
|
78
|
+
totalHits:
|
|
79
|
+
typeof parsed.totalHits === 'number' && Number.isFinite(parsed.totalHits) ? parsed.totalHits : 0,
|
|
80
|
+
}
|
|
33
81
|
}
|
|
34
82
|
} catch {
|
|
35
83
|
// File not found or invalid JSON
|
|
@@ -72,6 +120,9 @@ export function searchExperienceEntries(
|
|
|
72
120
|
const lowerQuery = query.toLowerCase()
|
|
73
121
|
|
|
74
122
|
return entries.filter((entry) => {
|
|
123
|
+
if (!entry || typeof entry !== 'object') return false
|
|
124
|
+
const rawEntry = entry as unknown as Record<string, unknown>
|
|
125
|
+
const tags = stringArray(rawEntry.tags)
|
|
75
126
|
// Dimension filter
|
|
76
127
|
if (opts.dimension && entry.dimension !== opts.dimension) {
|
|
77
128
|
return false
|
|
@@ -79,22 +130,24 @@ export function searchExperienceEntries(
|
|
|
79
130
|
|
|
80
131
|
// Tags filter (AND logic)
|
|
81
132
|
if (opts.tags && opts.tags.length > 0) {
|
|
82
|
-
if (!opts.tags.every((t) =>
|
|
133
|
+
if (!opts.tags.every((t) => tags.includes(t))) {
|
|
83
134
|
return false
|
|
84
135
|
}
|
|
85
136
|
}
|
|
86
137
|
|
|
87
|
-
// Text search across multiple fields
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
138
|
+
// Text search across multiple fields. Guards against a hand-edited bank entry
|
|
139
|
+
// whose `files`/`tags` are missing or non-array (the spread below would
|
|
140
|
+
// otherwise throw a TypeError on a non-iterable).
|
|
141
|
+
if (query) {
|
|
142
|
+
const searchableText = [
|
|
143
|
+
entry.pattern,
|
|
144
|
+
entry.description,
|
|
145
|
+
entry.verifiedFix,
|
|
146
|
+
entry.findingSummary,
|
|
147
|
+
entry.dimension,
|
|
148
|
+
...stringArray(entry.files),
|
|
149
|
+
...stringArray(entry.tags),
|
|
150
|
+
].join(' ').toLowerCase()
|
|
98
151
|
|
|
99
152
|
if (!searchableText.includes(lowerQuery)) {
|
|
100
153
|
return false
|
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
|
@@ -106,16 +106,22 @@ export function inspectPrune(
|
|
|
106
106
|
}
|
|
107
107
|
const staleBackups: string[] = []
|
|
108
108
|
const fixDir = fixesDir(projectRoot)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
109
|
+
// existsSync does not prove readability — a permissions failure must degrade
|
|
110
|
+
// to "nothing listable" (report skipped) instead of throwing out of execute.
|
|
111
|
+
let fixEntries: string[] = []
|
|
112
|
+
try {
|
|
113
|
+
if (existsSync(fixDir)) fixEntries = readdirSync(fixDir)
|
|
114
|
+
} catch {
|
|
115
|
+
fixEntries = []
|
|
116
|
+
}
|
|
117
|
+
for (const entry of fixEntries) {
|
|
118
|
+
if (!entry.endsWith('.bak')) continue
|
|
119
|
+
// Extract the fix-id prefix (up to the first underscore after the id).
|
|
120
|
+
// e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
|
|
121
|
+
const match = entry.match(/^(fix-[a-z0-9]+)_/)
|
|
122
|
+
const id = match?.[1]
|
|
123
|
+
if (id && !activeIds.has(id)) {
|
|
124
|
+
staleBackups.push(entry)
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
|
|
@@ -126,10 +132,15 @@ export function inspectPrune(
|
|
|
126
132
|
|
|
127
133
|
// 5. Stray atomic-write temp files left behind by a crashed writer. The
|
|
128
134
|
// unique prefix makes them recognizable and safe to delete — no live writer
|
|
129
|
-
// ever reads another writer's temp file.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
// ever reads another writer's temp file. A permissions failure listing the
|
|
136
|
+
// dir degrades to "none found" (report-only) rather than throwing.
|
|
137
|
+
let iterateEntries: string[] = []
|
|
138
|
+
try {
|
|
139
|
+
iterateEntries = existsSync(iterateDir(projectRoot)) ? readdirSync(iterateDir(projectRoot)) : []
|
|
140
|
+
} catch {
|
|
141
|
+
iterateEntries = []
|
|
142
|
+
}
|
|
143
|
+
const staleTemps = iterateEntries.filter((f) => isPrunableTemp(f)).sort()
|
|
133
144
|
|
|
134
145
|
return {
|
|
135
146
|
oldLogEntries,
|
|
@@ -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) {
|
package/src/tools/transcript.ts
CHANGED
|
@@ -44,7 +44,10 @@ function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
|
|
|
44
44
|
findings?: unknown
|
|
45
45
|
readFiles?: unknown
|
|
46
46
|
}
|
|
47
|
-
const roundNo = typeof r.round === 'number' ? Math.floor(r.round) : 0
|
|
47
|
+
const roundNo = typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0
|
|
48
|
+
// NaN round values (`Math.floor(NaN) === NaN`, and `NaN <= 0` is false) would
|
|
49
|
+
// fall through the guard below; a finite check rejects them outright so a
|
|
50
|
+
// malformed round can never collapse into round 1 via the builder's coercion.
|
|
48
51
|
if (roundNo <= 0) return
|
|
49
52
|
builder.roundStart(roundNo)
|
|
50
53
|
const findings = Array.isArray(r.findings) ? r.findings : []
|
|
@@ -70,14 +73,16 @@ function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
|
|
|
70
73
|
function normalizeCheckpoint(input: unknown): TranscriptManifest['checkpoint'] {
|
|
71
74
|
if (!input || typeof input !== 'object') return null
|
|
72
75
|
const c = input as Record<string, unknown>
|
|
73
|
-
|
|
76
|
+
// Number.isFinite: a NaN round must not pass `round <= 0` (which is false for
|
|
77
|
+
// NaN) and later coerce into a phantom round 1 row.
|
|
78
|
+
const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
|
|
74
79
|
if (round <= 0) return null
|
|
75
80
|
return {
|
|
76
81
|
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
|
|
77
82
|
round,
|
|
78
|
-
maxRounds: typeof c.maxRounds === 'number' ? c.maxRounds : 0,
|
|
79
|
-
fixedCount: typeof c.fixedCount === 'number' ? c.fixedCount : 0,
|
|
80
|
-
resumeCount: typeof c.resumeCount === 'number' ? c.resumeCount : 0,
|
|
83
|
+
maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
|
|
84
|
+
fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
|
|
85
|
+
resumeCount: typeof c.resumeCount === 'number' && Number.isFinite(c.resumeCount) ? c.resumeCount : 0,
|
|
81
86
|
updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : new Date().toISOString(),
|
|
82
87
|
}
|
|
83
88
|
}
|
|
@@ -92,7 +97,7 @@ function normalizeFix(input: unknown): TranscriptFix | null {
|
|
|
92
97
|
return {
|
|
93
98
|
id,
|
|
94
99
|
timestamp: typeof f.timestamp === 'string' ? f.timestamp : new Date().toISOString(),
|
|
95
|
-
round: typeof f.round === 'number' ? f.round : 0,
|
|
100
|
+
round: typeof f.round === 'number' && Number.isFinite(f.round) ? f.round : 0,
|
|
96
101
|
file,
|
|
97
102
|
summary: typeof f.summary === 'string' ? f.summary : '',
|
|
98
103
|
linesAdded: typeof f.linesAdded === 'number' ? f.linesAdded : 0,
|
|
@@ -224,11 +229,24 @@ export function registerTranscriptTool(ctx: {
|
|
|
224
229
|
manifest = null
|
|
225
230
|
}
|
|
226
231
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
232
|
+
// Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
|
|
233
|
+
// or a non-object) must never crash the nudge — degrade to a fresh
|
|
234
|
+
// builder so the steering text still lands.
|
|
235
|
+
let builder: ReviewTranscriptBuilder
|
|
236
|
+
if (manifest) {
|
|
237
|
+
try {
|
|
238
|
+
builder = rehydrateBuilder(manifest, approval)
|
|
239
|
+
} catch {
|
|
240
|
+
builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
|
|
244
|
+
}
|
|
230
245
|
builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
|
|
231
|
-
await
|
|
246
|
+
const persisted = await persistChecked(file, builder.serialize())
|
|
247
|
+
if (!persisted.ok) {
|
|
248
|
+
return { operation: 'nudge', updated: false, error: persisted.error }
|
|
249
|
+
}
|
|
232
250
|
return {
|
|
233
251
|
operation: 'nudge',
|
|
234
252
|
updated: true,
|
|
@@ -291,7 +309,10 @@ export function registerTranscriptTool(ctx: {
|
|
|
291
309
|
const last = convergence[convergence.length - 1]
|
|
292
310
|
if (convergence.length > 0 && last === 0) builder.finish()
|
|
293
311
|
|
|
294
|
-
await
|
|
312
|
+
const persisted = await persistChecked(file, builder.serialize())
|
|
313
|
+
if (!persisted.ok) {
|
|
314
|
+
return { operation: 'capture', found: true, updated: false, error: persisted.error }
|
|
315
|
+
}
|
|
295
316
|
const live = await readLive(projectRoot)
|
|
296
317
|
return {
|
|
297
318
|
operation: 'capture',
|
|
@@ -342,6 +363,21 @@ async function persist(file: string, manifest: TranscriptManifest): Promise<void
|
|
|
342
363
|
await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2))
|
|
343
364
|
}
|
|
344
365
|
|
|
366
|
+
/** persist wrapped with a structured error channel so disk failures surface
|
|
367
|
+
* as a structured tool result instead of rejecting the whole execute (matches
|
|
368
|
+
* every other write path in this codebase). */
|
|
369
|
+
async function persistChecked(
|
|
370
|
+
file: string,
|
|
371
|
+
manifest: TranscriptManifest,
|
|
372
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
373
|
+
try {
|
|
374
|
+
await persist(file, manifest)
|
|
375
|
+
return { ok: true }
|
|
376
|
+
} catch (err) {
|
|
377
|
+
return { ok: false, error: `failed to persist transcript: ${err instanceof Error ? err.message : String(err)}` }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
345
381
|
/**
|
|
346
382
|
* Mark one applied fix as rolled back in the PERSISTED transcript, so the
|
|
347
383
|
* client observatory (F4 fix/rollback tab) reflects the reversal instead of
|