iterate-plugin 3.5.1 → 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/context.js +39 -5
- package/dist/tools/decision-log.js +8 -1
- 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 +52 -19
- 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 +7 -6
- package/src/client/index.ts +30 -5
- package/src/tools/checkpoint.ts +45 -6
- package/src/tools/config.ts +11 -2
- package/src/tools/context.ts +36 -4
- package/src/tools/decision-log.ts +10 -1
- 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 +53 -21
- 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
package/src/tools/checkpoint.ts
CHANGED
|
@@ -24,16 +24,41 @@ import type { DefenseEventType, IterationCheckpoint, IterationStatus, QualityGat
|
|
|
24
24
|
|
|
25
25
|
// ─── Pure helpers (exported for unit tests) ─────────────────────────────────
|
|
26
26
|
|
|
27
|
+
/** Max findings persisted in a checkpoint (a model-authored findings payload is
|
|
28
|
+
* bounded, mirroring MAX_FIX_CONTENT_CHARS for fix content). */
|
|
29
|
+
export const MAX_CHECKPOINT_FINDINGS = 1000
|
|
30
|
+
|
|
27
31
|
/** Read the current checkpoint from disk (missing/corrupt → null). */
|
|
28
32
|
export function readCheckpoint(projectRoot: string): IterationCheckpoint | null {
|
|
29
33
|
const file = checkpointPath(projectRoot)
|
|
30
34
|
if (!existsSync(file)) return null
|
|
31
35
|
try {
|
|
32
|
-
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as IterationCheckpoint
|
|
36
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial<IterationCheckpoint>
|
|
33
37
|
if (!parsed || typeof parsed !== 'object') return null
|
|
34
38
|
if (parsed.mode !== 'dry-run' && parsed.mode !== 'normal') return null
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
// Strict round gate: non-numeric (incl. NaN — `NaN <= 0` is false and
|
|
40
|
+
// `NaN < 0` also false) round values must not survive into the status.
|
|
41
|
+
const round =
|
|
42
|
+
typeof parsed.round === 'number' && Number.isFinite(parsed.round)
|
|
43
|
+
? Math.floor(parsed.round)
|
|
44
|
+
: null
|
|
45
|
+
if (round === null || round < 0) return null
|
|
46
|
+
const num = (v: unknown, fallback: number): number =>
|
|
47
|
+
typeof v === 'number' && Number.isFinite(v) ? v : fallback
|
|
48
|
+
// Normalize every field so a hand-edited/corrupt checkpoint (string
|
|
49
|
+
// maxRounds, missing fixedCount, …) can never leak a non-numeric value
|
|
50
|
+
// into the status output or crash a consumer.
|
|
51
|
+
return {
|
|
52
|
+
mode: parsed.mode,
|
|
53
|
+
round,
|
|
54
|
+
maxRounds: num(parsed.maxRounds, round),
|
|
55
|
+
fixedCount: num(parsed.fixedCount, 0),
|
|
56
|
+
architecturalCount: num(parsed.architecturalCount, 0),
|
|
57
|
+
resumeCount: num(parsed.resumeCount, 0),
|
|
58
|
+
findings: Array.isArray(parsed.findings) ? (parsed.findings as IterationCheckpoint['findings']) : [],
|
|
59
|
+
startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : new Date().toISOString(),
|
|
60
|
+
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
|
|
61
|
+
}
|
|
37
62
|
} catch {
|
|
38
63
|
return null
|
|
39
64
|
}
|
|
@@ -82,6 +107,17 @@ export function validateCheckpoint(input: {
|
|
|
82
107
|
) {
|
|
83
108
|
return 'resumeCount must be a non-negative integer'
|
|
84
109
|
}
|
|
110
|
+
// A round that claims to be past the configured cap is an inconsistent save —
|
|
111
|
+
// the status would render `Round X / Y` with X > Y, which is never legitimate
|
|
112
|
+
// for a resumed run (resume continues AT the checkpoint, it does not exceed
|
|
113
|
+
// the cap).
|
|
114
|
+
if (
|
|
115
|
+
typeof input.round === 'number' &&
|
|
116
|
+
typeof input.maxRounds === 'number' &&
|
|
117
|
+
input.round > input.maxRounds
|
|
118
|
+
) {
|
|
119
|
+
return 'round must not exceed maxRounds'
|
|
120
|
+
}
|
|
85
121
|
return null
|
|
86
122
|
}
|
|
87
123
|
|
|
@@ -118,8 +154,11 @@ export function computeStatus(input: {
|
|
|
118
154
|
}
|
|
119
155
|
|
|
120
156
|
const totalRounds = checkpoint?.maxRounds ?? currentRound
|
|
121
|
-
|
|
122
|
-
|
|
157
|
+
// Coerce the registry's per-round counts defensively: a hand-edited registry
|
|
158
|
+
// round missing fixedCount/failedCount would otherwise feed NaN into the
|
|
159
|
+
// status integer fields.
|
|
160
|
+
const registryFixed = registry.rounds.reduce((sum, r) => sum + (Number(r.fixedCount) || 0), 0)
|
|
161
|
+
const failedCount = registry.rounds.reduce((sum, r) => sum + (Number(r.failedCount) || 0), 0)
|
|
123
162
|
// When a checkpoint exists, its snapshot fields are authoritative for resume
|
|
124
163
|
// (fixedCount / architecturalCount / findings); otherwise derive from the
|
|
125
164
|
// live fix registry and decision log.
|
|
@@ -258,7 +297,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
258
297
|
fixedCount: args.fixedCount as number,
|
|
259
298
|
architecturalCount: args.architecturalCount as number,
|
|
260
299
|
resumeCount: (typeof args.resumeCount === 'number' ? args.resumeCount : 0),
|
|
261
|
-
findings: (Array.isArray(args.findings) ? args.findings : []) as unknown as IterationCheckpoint['findings'],
|
|
300
|
+
findings: (Array.isArray(args.findings) ? args.findings.slice(0, MAX_CHECKPOINT_FINDINGS) : []) as unknown as IterationCheckpoint['findings'],
|
|
262
301
|
startedAt: readCheckpoint(projectRoot)?.startedAt ?? new Date().toISOString(),
|
|
263
302
|
updatedAt: new Date().toISOString(),
|
|
264
303
|
}
|
package/src/tools/config.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
2
3
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
|
-
import { loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
5
|
+
import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
5
6
|
import {
|
|
6
7
|
applyConfigUpdates,
|
|
7
8
|
readRawConfig,
|
|
@@ -106,7 +107,15 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
106
107
|
}
|
|
107
108
|
let base: Record<string, unknown>
|
|
108
109
|
try {
|
|
109
|
-
|
|
110
|
+
// Partial updates must be mergeable on a FRESH project (no config
|
|
111
|
+
// file yet): otherwise `{max_rounds: 5}` alone fails schema
|
|
112
|
+
// validation with a misleading "missing goal". Merge against the
|
|
113
|
+
// built-in defaults when there is no file to read, so the written
|
|
114
|
+
// config captures the effective config (defaults + update) and the
|
|
115
|
+
// documented "write a partial update" contract works out of the box.
|
|
116
|
+
base = existsSync(join(projectRoot, 'iterate.config.yaml'))
|
|
117
|
+
? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
|
|
118
|
+
: (defaultConfig() as unknown as Record<string, unknown>)
|
|
110
119
|
} catch (err) {
|
|
111
120
|
return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` }
|
|
112
121
|
}
|
package/src/tools/context.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
|
-
import { join, dirname, resolve } from 'node:path'
|
|
1
|
+
import { readFileSync, existsSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, dirname, resolve, sep } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
5
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
@@ -7,6 +7,33 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
|
7
7
|
/** How many ancestor directories we walk up looking for a SKILL.md. */
|
|
8
8
|
const MAX_SKILL_DIR_LOOKUP_DEPTH = 12
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Whether a model-supplied `skillDir` may be used as a SKILL.md search root.
|
|
12
|
+
*
|
|
13
|
+
* `skillDir` is model-controlled, so it is only honored when it is an existing
|
|
14
|
+
* directory whose REAL path (symlinks resolved) sits inside one of the allowed
|
|
15
|
+
* roots (the project root or the plugin directory). Anything else — a path
|
|
16
|
+
* outside the allowed roots, a nonexistent dir, or a symlink pointing outside
|
|
17
|
+
* (e.g. at `/etc`) — is rejected so arbitrary file contents can never be
|
|
18
|
+
* injected wholesale into the review context.
|
|
19
|
+
*/
|
|
20
|
+
export function isAllowedSkillDir(skillDir: string, allowedRoots: readonly string[]): boolean {
|
|
21
|
+
if (typeof skillDir !== 'string' || skillDir.trim().length === 0) return false
|
|
22
|
+
try {
|
|
23
|
+
const dir = resolve(skillDir)
|
|
24
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return false
|
|
25
|
+
return allowedRoots.some((root) => {
|
|
26
|
+
if (typeof root !== 'string' || root.length === 0) return false
|
|
27
|
+
const rootReal = realpathSync(root)
|
|
28
|
+
const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep
|
|
29
|
+
const real = realpathSync(dir)
|
|
30
|
+
return real === rootReal || real.startsWith(rootPrefix)
|
|
31
|
+
})
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
10
37
|
/** Maximum number of image attachments relayed into the context in one call. */
|
|
11
38
|
const MAX_ATTACHMENTS = 8
|
|
12
39
|
|
|
@@ -304,8 +331,13 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
304
331
|
// the auto-detected root / project root.
|
|
305
332
|
if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
|
|
306
333
|
try {
|
|
307
|
-
|
|
308
|
-
|
|
334
|
+
// Model-controlled path: only honor it when its REAL path stays
|
|
335
|
+
// inside the project root or the plugin directory — never let a
|
|
336
|
+
// skillDir point at arbitrary filesystem locations (e.g. /etc)
|
|
337
|
+
// whose contents would be injected wholesale into the context.
|
|
338
|
+
if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
|
|
339
|
+
candidates.push(resolve(args.skillDir))
|
|
340
|
+
}
|
|
309
341
|
} catch {
|
|
310
342
|
// unreadable/invalid skillDir — skip it
|
|
311
343
|
}
|
|
@@ -106,7 +106,16 @@ export function readDecisionLogDetailed(projectRoot: string): DecisionLogRead {
|
|
|
106
106
|
const trimmed = line.trim()
|
|
107
107
|
if (trimmed.length === 0) continue
|
|
108
108
|
try {
|
|
109
|
-
|
|
109
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
110
|
+
if (
|
|
111
|
+
parsed === null || typeof parsed !== 'object' ||
|
|
112
|
+
typeof (parsed as { timestamp?: unknown }).timestamp !== 'string' ||
|
|
113
|
+
typeof (parsed as { type?: unknown }).type !== 'string'
|
|
114
|
+
) {
|
|
115
|
+
invalidLines += 1
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
entries.push(parsed as DecisionLogEntry)
|
|
110
119
|
} catch {
|
|
111
120
|
invalidLines += 1
|
|
112
121
|
}
|
|
@@ -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
|