iterate-plugin 3.5.7 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +55 -13
- package/dist/skill-prompt.js +396 -66
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +111 -27
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +150 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +14 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +54 -12
- package/src/skill-prompt.ts +396 -66
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +115 -22
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +154 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
package/src/tools/prune.ts
CHANGED
|
@@ -21,16 +21,18 @@
|
|
|
21
21
|
* Security model:
|
|
22
22
|
* - Only operates under the resolved project `.iterate/` directory.
|
|
23
23
|
* - dryRun=true by default — the caller must explicitly opt into deletion.
|
|
24
|
-
* - Each
|
|
24
|
+
* - Each non-dry-run appends ONE aggregate summary entry to the decision
|
|
25
|
+
* log (counts per artifact class) — not one log line per deleted file.
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
|
-
import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs'
|
|
28
|
+
import { existsSync, readdirSync, readFileSync, rmSync, unlinkSync, statSync } from 'node:fs'
|
|
28
29
|
import { join } from 'node:path'
|
|
29
30
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
30
31
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
31
32
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
32
33
|
import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
|
|
33
|
-
import {
|
|
34
|
+
import { withProjectLock } from '../file-lock.ts'
|
|
35
|
+
import { readDecisionEntries, appendDecisionEntry, acquireLogLockChecked, invalidateLogCountCache } from './decision-log.ts'
|
|
34
36
|
import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
|
|
35
37
|
import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
|
|
36
38
|
import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
|
|
@@ -97,12 +99,20 @@ export function sweepExperienceBank(bank: ExperienceBank, cap: number): { bank:
|
|
|
97
99
|
const size = Number.isFinite(cap) && (cap as number) >= 0 ? Math.floor(cap) : MAX_EXPERIENCE_ENTRIES
|
|
98
100
|
const total = bank.entries.length
|
|
99
101
|
if (total <= size) return { bank, removed: 0 }
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
// Keep by POSITION, not by id: a Set of kept ids lets duplicate ids (a
|
|
103
|
+
// hand-edited or double-written bank) defeat the cap — every original entry
|
|
104
|
+
// sharing a kept id would survive, so `removed` stayed 0 and the cap never
|
|
105
|
+
// applied. Rank positions newest-first (ties fall back to the original index
|
|
106
|
+
// so equal timestamps keep insertion order, as before), keep the first `size`
|
|
107
|
+
// positions, then re-emit the survivors in insertion order.
|
|
108
|
+
const ranked = bank.entries.map((_, i) => i)
|
|
109
|
+
ranked.sort((a, b) => {
|
|
110
|
+
const ta = bank.entries[a]?.timestamp ?? ''
|
|
111
|
+
const tb = bank.entries[b]?.timestamp ?? ''
|
|
112
|
+
return tb.localeCompare(ta) || a - b
|
|
113
|
+
})
|
|
114
|
+
const keep = new Set(ranked.slice(0, size))
|
|
115
|
+
const ordered = bank.entries.filter((_, i) => keep.has(i))
|
|
106
116
|
return {
|
|
107
117
|
bank: { ...bank, entries: ordered, lastUpdated: new Date().toISOString() },
|
|
108
118
|
removed: total - ordered.length,
|
|
@@ -160,6 +170,9 @@ export function inspectPrune(
|
|
|
160
170
|
emptyRounds: number[]
|
|
161
171
|
totalLogEntries: number
|
|
162
172
|
registryRounds: number
|
|
173
|
+
/** Non-null when the fix registry file is PRESENT but unreadable/corrupt —
|
|
174
|
+
* stale-backup deletion must then be refused (see `executePrune`). */
|
|
175
|
+
registryError: string | null
|
|
163
176
|
totalExperiences: number
|
|
164
177
|
experienceOversize: number
|
|
165
178
|
totalDefenseEvents: number
|
|
@@ -187,6 +200,26 @@ export function inspectPrune(
|
|
|
187
200
|
}
|
|
188
201
|
|
|
189
202
|
// 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
|
|
203
|
+
//
|
|
204
|
+
// `readRegistry` returns an EMPTY registry for BOTH "file absent" and "file
|
|
205
|
+
// present but corrupt" — and for the corrupt case an empty active-id set
|
|
206
|
+
// would classify EVERY backup stale, so a single dryRun:false prune would
|
|
207
|
+
// delete the entire rollback safety net. Read the registry file directly to
|
|
208
|
+
// distinguish the two: absent ⇒ backups are orphaned (stale, delete as
|
|
209
|
+
// before); present-but-unreadable ⇒ set `registryError`, classify nothing,
|
|
210
|
+
// and let `executePrune`/the render surface the refusal.
|
|
211
|
+
const registryFile = fixRegistryPath(projectRoot)
|
|
212
|
+
let registryError: string | null = null
|
|
213
|
+
if (existsSync(registryFile)) {
|
|
214
|
+
try {
|
|
215
|
+
const parsed: unknown = JSON.parse(readFileSync(registryFile, 'utf-8'))
|
|
216
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as { rounds?: unknown }).rounds)) {
|
|
217
|
+
registryError = `refusing to delete fix backups: fix registry is present but unreadable (invalid shape): ${registryFile}`
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
registryError = `refusing to delete fix backups: fix registry is present but unreadable: ${String(err)} (${registryFile})`
|
|
221
|
+
}
|
|
222
|
+
}
|
|
190
223
|
const registry = readRegistry(projectRoot)
|
|
191
224
|
const activeIds = new Set<string>()
|
|
192
225
|
for (const r of registry.rounds) {
|
|
@@ -204,14 +237,16 @@ export function inspectPrune(
|
|
|
204
237
|
} catch {
|
|
205
238
|
fixEntries = []
|
|
206
239
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
240
|
+
if (registryError === null) {
|
|
241
|
+
for (const entry of fixEntries) {
|
|
242
|
+
if (!entry.endsWith('.bak')) continue
|
|
243
|
+
// Extract the fix-id prefix (up to the first underscore after the id).
|
|
244
|
+
// e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
|
|
245
|
+
const match = entry.match(/^(fix-[a-z0-9]+)_/)
|
|
246
|
+
const id = match?.[1]
|
|
247
|
+
if (id && !activeIds.has(id)) {
|
|
248
|
+
staleBackups.push(entry)
|
|
249
|
+
}
|
|
215
250
|
}
|
|
216
251
|
}
|
|
217
252
|
|
|
@@ -252,6 +287,7 @@ export function inspectPrune(
|
|
|
252
287
|
emptyRounds,
|
|
253
288
|
totalLogEntries: entries.length,
|
|
254
289
|
registryRounds: registry.rounds.length,
|
|
290
|
+
registryError,
|
|
255
291
|
totalExperiences: bank.entries.length,
|
|
256
292
|
experienceOversize: sweptBank.removed,
|
|
257
293
|
totalDefenseEvents: defenseStream.events.length,
|
|
@@ -268,28 +304,60 @@ export function inspectPrune(
|
|
|
268
304
|
*
|
|
269
305
|
* The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
|
|
270
306
|
* a fresh audit line between our read and our atomic rewrite, and an atomic
|
|
271
|
-
* rename would silently discard it. So instead of a single read+write we
|
|
272
|
-
*
|
|
307
|
+
* rename would silently discard it. So instead of a single read+write we take
|
|
308
|
+
* the cross-process log mutex for the whole loop AND keep a bounded
|
|
309
|
+
* compare-and-append loop behind it — after each rewrite we re-read the log and
|
|
273
310
|
* retry whenever the file grew (a concurrent append slipped in). The loop
|
|
274
311
|
* terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
|
|
275
312
|
* failure here is surfaced as a structured error instead of truncating the log.
|
|
276
313
|
*
|
|
277
|
-
*
|
|
314
|
+
* Lock required: when the mutex cannot be taken (another process is appending
|
|
315
|
+
* and does not release within the wait budget) the rewrite is REFUSED with a
|
|
316
|
+
* structured error rather than proceeding unlocked — an unlocked rename can
|
|
317
|
+
* drop a concurrent appender's audit line (its fd points at the pre-rename
|
|
318
|
+
* inode) with no way to detect the loss afterwards.
|
|
319
|
+
*
|
|
320
|
+
* `deleted` is the TOTAL across every attempt: attempt 1 may already have
|
|
321
|
+
* removed entries before a retry is needed, and reporting only the final
|
|
322
|
+
* attempt's slice would under-count the audit entry (the pre-fix code returned
|
|
323
|
+
* `deleted: 0` on the exhaustion path even after real deletions).
|
|
324
|
+
*
|
|
325
|
+
* @param options.afterRewrite test hook run after each successful rewrite —
|
|
326
|
+
* simulates a concurrent appender landing lines mid-loop (production passes
|
|
327
|
+
* no hook).
|
|
328
|
+
* @param options.lock lock wait/stale budgets (tests pass short waits so the
|
|
329
|
+
* lock-unavailable refusal is exercised in milliseconds, not 5s).
|
|
330
|
+
* @returns the number of entries removed across all attempts, or 0 with an
|
|
331
|
+
* `error` when nothing needed removing or the rewrite was refused/failed.
|
|
278
332
|
*/
|
|
279
|
-
function rewriteDecisionLogKeepingRecent(
|
|
333
|
+
export function rewriteDecisionLogKeepingRecent(
|
|
334
|
+
projectRoot: string,
|
|
335
|
+
cutoff: string,
|
|
336
|
+
options: { afterRewrite?: () => void; lock?: { waitMs?: number; staleMs?: number } } = {},
|
|
337
|
+
): { deleted: number; error?: string } {
|
|
280
338
|
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
|
|
281
339
|
// Take the cross-process log lock for the ENTIRE loop: with the mutex held,
|
|
282
340
|
// no concurrent appender can slip a fresh line into the read-before-rewrite
|
|
283
341
|
// window, so the first attempt is nearly always the only one. (The retry
|
|
284
|
-
// loop stays as defense-in-depth for the
|
|
285
|
-
|
|
342
|
+
// loop stays as defense-in-depth for a line landing via the afterRewrite
|
|
343
|
+
// hook or an already-running appender's buffered write.)
|
|
344
|
+
const { acquired, release } = acquireLogLockChecked(projectRoot, options.lock)
|
|
345
|
+
if (!acquired) {
|
|
346
|
+
return {
|
|
347
|
+
deleted: 0,
|
|
348
|
+
error:
|
|
349
|
+
'failed to acquire the decision-log lock — refusing to rewrite the log unlocked ' +
|
|
350
|
+
'(a concurrent appender could lose an audit line). Retry after the other operation finishes.',
|
|
351
|
+
}
|
|
352
|
+
}
|
|
286
353
|
try {
|
|
354
|
+
let deletedTotal = 0
|
|
287
355
|
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
288
356
|
try {
|
|
289
357
|
const entries = readDecisionEntries(projectRoot)
|
|
290
358
|
const kept = entries.filter((e) => e.timestamp >= cutoff)
|
|
291
359
|
const deleted = entries.length - kept.length
|
|
292
|
-
if (deleted === 0) return { deleted:
|
|
360
|
+
if (deleted === 0) return { deleted: deletedTotal }
|
|
293
361
|
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
294
362
|
// The append-side entry-count cache is keyed by path and assumes "same
|
|
295
363
|
// byte size ⇒ same count"; the rename above replaced the file with a
|
|
@@ -297,16 +365,21 @@ function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): {
|
|
|
297
365
|
// append can report a wrong entryCount when the sizes coincidentally
|
|
298
366
|
// match.
|
|
299
367
|
invalidateLogCountCache(logPath)
|
|
368
|
+
// Accumulate THIS attempt's deletions before the re-read: whatever the
|
|
369
|
+
// later attempts (or the exhaustion path) report, these entries are
|
|
370
|
+
// already gone from disk and must count toward the audit total.
|
|
371
|
+
deletedTotal += deleted
|
|
372
|
+
options.afterRewrite?.()
|
|
300
373
|
// A concurrent appender (cross-process, lock-unavailable path) may have
|
|
301
374
|
// landed new lines after our read. If the log grew during the write
|
|
302
375
|
// window, re-read and prune again instead of accepting a lost audit trail.
|
|
303
376
|
const after = readDecisionEntries(projectRoot)
|
|
304
|
-
if (after.length <= kept.length) return { deleted }
|
|
377
|
+
if (after.length <= kept.length) return { deleted: deletedTotal }
|
|
305
378
|
} catch (err) {
|
|
306
|
-
return { deleted:
|
|
379
|
+
return { deleted: deletedTotal, error: `failed to rewrite decision log: ${String(err)}` }
|
|
307
380
|
}
|
|
308
381
|
}
|
|
309
|
-
return { deleted:
|
|
382
|
+
return { deleted: deletedTotal, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
|
|
310
383
|
} finally {
|
|
311
384
|
release()
|
|
312
385
|
}
|
|
@@ -344,9 +417,10 @@ export function executePrune(
|
|
|
344
417
|
}
|
|
345
418
|
|
|
346
419
|
// 1. Rewrite the decision log, keeping only recent entries. Atomic
|
|
347
|
-
// (temp + rename)
|
|
348
|
-
//
|
|
349
|
-
//
|
|
420
|
+
// (temp + rename) under the cross-process log mutex — REFUSED with a
|
|
421
|
+
// structured error when the lock cannot be taken (an unlocked rename could
|
|
422
|
+
// drop a concurrent appender's audit line); the bounded compare-and-append
|
|
423
|
+
// loop behind it guards the remaining read-before-rewrite window.
|
|
350
424
|
{
|
|
351
425
|
const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff)
|
|
352
426
|
result.deletedLogEntries = deleted
|
|
@@ -365,30 +439,41 @@ export function executePrune(
|
|
|
365
439
|
}
|
|
366
440
|
}
|
|
367
441
|
|
|
368
|
-
// 3. Delete stale backups
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
442
|
+
// 3. Delete stale backups — REFUSED outright when the registry is present
|
|
443
|
+
// but unreadable: without a readable active-id set every backup would have
|
|
444
|
+
// classified stale, and one dryRun:false prune would wipe the whole rollback
|
|
445
|
+
// safety net. Surface the refusal as a structured error instead.
|
|
446
|
+
if (report.registryError) {
|
|
447
|
+
result.errors.push(report.registryError)
|
|
448
|
+
} else {
|
|
449
|
+
for (const bak of report.staleBackups) {
|
|
450
|
+
try {
|
|
451
|
+
unlinkSync(join(fixesDir(projectRoot), bak))
|
|
452
|
+
result.deletedBackups.push(bak)
|
|
453
|
+
} catch (err) {
|
|
454
|
+
result.errors.push(`failed to delete backup ${bak}: ${String(err)}`)
|
|
455
|
+
}
|
|
375
456
|
}
|
|
376
457
|
}
|
|
377
458
|
|
|
378
|
-
// 4. Trim empty rounds from the registry
|
|
459
|
+
// 4. Trim empty rounds from the registry (locked read-modify-write: the
|
|
460
|
+
// registry read and rewrite must not interleave with another process's
|
|
461
|
+
// fix/rollback or the update is lost — see src/file-lock.ts).
|
|
379
462
|
if (report.emptyRounds.length > 0) {
|
|
380
463
|
try {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
464
|
+
withProjectLock(projectRoot, 'fix-registry', () => {
|
|
465
|
+
let registry = readRegistry(projectRoot)
|
|
466
|
+
const emptyRoundNos = new Set(report.emptyRounds)
|
|
467
|
+
// Drop whole empty rounds (records.length === 0) instead of only
|
|
468
|
+
// removing their records — an empty round has no records to remove, so
|
|
469
|
+
// the old loop was a no-op that still reported trimmedEmptyRounds.
|
|
470
|
+
registry = {
|
|
471
|
+
...registry,
|
|
472
|
+
rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
|
|
473
|
+
}
|
|
474
|
+
registry = recomputeRoundCounts(registry)
|
|
475
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), registry)
|
|
476
|
+
})
|
|
392
477
|
result.trimmedEmptyRounds = report.emptyRounds.length
|
|
393
478
|
} catch (err) {
|
|
394
479
|
result.errors.push(`failed to trim empty rounds: ${String(err)}`)
|
|
@@ -445,13 +530,28 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
445
530
|
ctx.tools.register(
|
|
446
531
|
defineTool({
|
|
447
532
|
name: 'iterate_prune',
|
|
533
|
+
// Pending-call card (#12): say up front whether this sweep is the default
|
|
534
|
+
// report-only preview or a REAL deletion — that distinction is prune's
|
|
535
|
+
// entire safety model, so it must be visible before the call runs. Pure:
|
|
536
|
+
// derived from args only.
|
|
537
|
+
presentCall: (args) => {
|
|
538
|
+
const a = args as { dryRun?: unknown }
|
|
539
|
+
const deleting = a.dryRun === false
|
|
540
|
+
return {
|
|
541
|
+
card: 'generic',
|
|
542
|
+
title: deleting
|
|
543
|
+
? 'Prune stale .iterate/ artifacts (delete)'
|
|
544
|
+
: 'Prune preview (dry run — nothing deleted)',
|
|
545
|
+
kind: deleting ? 'delete' : 'read',
|
|
546
|
+
}
|
|
547
|
+
},
|
|
448
548
|
description:
|
|
449
549
|
'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
450
550
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
451
551
|
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
452
552
|
'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
|
|
453
553
|
'and stale defense events. ' +
|
|
454
|
-
'
|
|
554
|
+
'A non-dry-run appends ONE aggregate summary entry (counts per artifact class) to the decision log.',
|
|
455
555
|
parameters: {
|
|
456
556
|
dryRun: {
|
|
457
557
|
type: 'boolean',
|
|
@@ -485,11 +585,14 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
485
585
|
const report = value.report as Record<string, unknown> | undefined
|
|
486
586
|
const result = value.result as Record<string, unknown> | undefined
|
|
487
587
|
if (value.dryRun) {
|
|
588
|
+
const registryError = report?.registryError as string | null | undefined
|
|
488
589
|
const lines = [
|
|
489
590
|
`[dry-run] prune report (retainDays=${value.retainDays}):`,
|
|
490
591
|
` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
|
|
491
592
|
` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
|
|
492
|
-
|
|
593
|
+
registryError
|
|
594
|
+
? ` Stale backups to delete: none — ${registryError}`
|
|
595
|
+
: ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
|
|
493
596
|
` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
|
|
494
597
|
` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
|
|
495
598
|
` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
|
|
@@ -11,6 +11,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
11
11
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
12
12
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
13
13
|
import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from './quality-store.ts'
|
|
14
|
+
import { resultHeadline } from './present.ts'
|
|
14
15
|
import type { QualityGateSnapshot } from '../types.ts'
|
|
15
16
|
|
|
16
17
|
/** Valid severity values (kept in sync with QualityGateSnapshot counting). */
|
|
@@ -68,6 +69,14 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
68
69
|
ctx.tools.register(
|
|
69
70
|
defineTool({
|
|
70
71
|
name: 'iterate_quality_gate',
|
|
72
|
+
// Result card (#12): the certificate render already opens with its
|
|
73
|
+
// headline ("✓ Quality Gate: PASS (score: 92)") — surface that as the
|
|
74
|
+
// card title so a completed gate query isn't a bare blob. Pure: reads
|
|
75
|
+
// only the rendered result (replay-safe); failures decline the card.
|
|
76
|
+
presentResult: (_args, result) => {
|
|
77
|
+
const title = resultHeadline(result)
|
|
78
|
+
return title ? { card: 'generic', title } : undefined
|
|
79
|
+
},
|
|
71
80
|
// `read` never writes; `compute` persists the snapshot → only read
|
|
72
81
|
// joins a parallel dispatch group.
|
|
73
82
|
isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'read',
|
|
@@ -88,12 +97,14 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
88
97
|
dimensions: {
|
|
89
98
|
type: 'array',
|
|
90
99
|
items: { type: 'string' },
|
|
91
|
-
description: 'Dimensions to gate (required for compute).',
|
|
100
|
+
description: 'Dimensions to gate (required and must be non-empty for compute).',
|
|
92
101
|
},
|
|
93
102
|
findings: {
|
|
94
103
|
type: 'json',
|
|
95
104
|
description:
|
|
96
|
-
'Findings array
|
|
105
|
+
'Findings array for compute (optional — defaults to []). Each item: ' +
|
|
106
|
+
'{ dimension, severity (critical|high|medium|low), file, line? }. Findings whose dimension ' +
|
|
107
|
+
'is not listed in `dimensions` are excluded from scoring and reported as warnings.',
|
|
97
108
|
},
|
|
98
109
|
validationResults: {
|
|
99
110
|
type: 'json',
|
|
@@ -124,6 +135,11 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
124
135
|
kind: { type: 'string' },
|
|
125
136
|
operation: { type: 'string' },
|
|
126
137
|
snapshot: { type: 'json' },
|
|
138
|
+
warnings: {
|
|
139
|
+
type: 'array',
|
|
140
|
+
items: { type: 'string' },
|
|
141
|
+
description: 'compute only: findings excluded from scoring because their dimension is not gated.',
|
|
142
|
+
},
|
|
127
143
|
error: { type: 'string' },
|
|
128
144
|
},
|
|
129
145
|
},
|
|
@@ -148,6 +164,9 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
148
164
|
if (snapshot.failReason) {
|
|
149
165
|
lines.push('', `Fail Reason: ${snapshot.failReason}`)
|
|
150
166
|
}
|
|
167
|
+
if (Array.isArray(value.warnings) && value.warnings.length > 0) {
|
|
168
|
+
lines.push('', 'Warnings:', ...value.warnings.map((w) => ` ! ${String(w)}`))
|
|
169
|
+
}
|
|
151
170
|
if (operation === 'compute') {
|
|
152
171
|
lines.push('', 'Quality gate snapshot computed and persisted.')
|
|
153
172
|
}
|
|
@@ -182,6 +201,21 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
182
201
|
const dimensions = Array.isArray(args.dimensions)
|
|
183
202
|
? args.dimensions.filter((d): d is string => typeof d === 'string' && d.length > 0)
|
|
184
203
|
: []
|
|
204
|
+
// A compute without any gateable dimension would score an empty
|
|
205
|
+
// dimension list at overallScore 0 and PERSIST a fabricated FAIL
|
|
206
|
+
// certificate for a review that was simply never scoped. Refuse
|
|
207
|
+
// instead: no snapshot is written, and the caller gets a structured
|
|
208
|
+
// error naming the missing argument.
|
|
209
|
+
if (dimensions.length === 0) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
kind: 'quality_gate',
|
|
213
|
+
operation: 'compute',
|
|
214
|
+
error:
|
|
215
|
+
'dimensions is required (and must be a non-empty array of dimension names) for operation "compute" — ' +
|
|
216
|
+
'without dimensions there is nothing to gate and no certificate can be computed.',
|
|
217
|
+
}
|
|
218
|
+
}
|
|
185
219
|
const findings = Array.isArray(args.findings) ? args.findings.filter(isValidFinding) : []
|
|
186
220
|
const validationResults = Array.isArray(args.validationResults)
|
|
187
221
|
? args.validationResults.filter(isValidValidationResult)
|
|
@@ -189,6 +223,24 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
189
223
|
const findingsByRound = sanitizeRoundSeries(args.findingsByRound)
|
|
190
224
|
const fixedByDimension = sanitizeNumberMap(args.fixedByDimension)
|
|
191
225
|
|
|
226
|
+
// Findings in dimensions outside the gated list are silently skipped
|
|
227
|
+
// by computeQualityGate (they never touch any dimension score) — a
|
|
228
|
+
// critical one still fails the gate globally, everything else just
|
|
229
|
+
// vanishes from the numbers. Surface those dimensions as warnings so
|
|
230
|
+
// the caller knows the score does NOT cover them; scoring is
|
|
231
|
+
// deliberately left unchanged.
|
|
232
|
+
const gated = new Set(dimensions)
|
|
233
|
+
const ungatedCounts = new Map<string, number>()
|
|
234
|
+
for (const finding of findings) {
|
|
235
|
+
if (!gated.has(finding.dimension)) {
|
|
236
|
+
ungatedCounts.set(finding.dimension, (ungatedCounts.get(finding.dimension) ?? 0) + 1)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const warnings = [...ungatedCounts].map(
|
|
240
|
+
([dimension, count]) =>
|
|
241
|
+
`finding dimension "${dimension}" is not in the gated dimensions and was excluded from scoring (${count} finding${count === 1 ? '' : 's'})`,
|
|
242
|
+
)
|
|
243
|
+
|
|
192
244
|
const snapshot = computeQualityGate({
|
|
193
245
|
dimensions,
|
|
194
246
|
findings,
|
|
@@ -205,6 +257,9 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
205
257
|
kind: 'quality_gate',
|
|
206
258
|
operation: 'compute',
|
|
207
259
|
snapshot: snapshot as unknown as JsonValue,
|
|
260
|
+
// Typed by the output schema as string[] — do not widen this to
|
|
261
|
+
// JsonValue or the execute return type stops matching the schema.
|
|
262
|
+
warnings,
|
|
208
263
|
}
|
|
209
264
|
}
|
|
210
265
|
|
|
@@ -67,6 +67,11 @@ export function readQualityGate(projectRoot: string): QualityGateSnapshot {
|
|
|
67
67
|
})
|
|
68
68
|
: []
|
|
69
69
|
return {
|
|
70
|
+
// Snapshot-level fallback timestamp: display-only metadata — the gate
|
|
71
|
+
// is a SINGLE snapshot file (overwritten wholesale), and nothing sorts
|
|
72
|
+
// or evicts by `timestamp`, so a missing/corrupt value degrading to
|
|
73
|
+
// "now" cannot affect retention or ordering. (Reaffirmed from the
|
|
74
|
+
// earlier review: comment only, behavior unchanged.)
|
|
70
75
|
timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : emptySnapshot().timestamp,
|
|
71
76
|
overallStatus: status,
|
|
72
77
|
overallScore: num(parsed.overallScore),
|
|
@@ -191,6 +196,12 @@ export function computeQualityGate(opts: {
|
|
|
191
196
|
}
|
|
192
197
|
|
|
193
198
|
for (const finding of findings) {
|
|
199
|
+
// Findings in dimensions outside the gated list land here with no matching
|
|
200
|
+
// `dimensionStats` entry and are skipped — they never touch any dimension
|
|
201
|
+
// score (only a global critical still fails the gate). Scoring stays
|
|
202
|
+
// unchanged by contract; the tool layer surfaces the excluded dimensions
|
|
203
|
+
// as `warnings` so the numbers are never silently incomplete
|
|
204
|
+
// (see src/tools/quality-gate.ts).
|
|
194
205
|
const stats = dimensionStats[finding.dimension]
|
|
195
206
|
if (stats) {
|
|
196
207
|
stats.count++
|
|
@@ -207,7 +218,13 @@ export function computeQualityGate(opts: {
|
|
|
207
218
|
// Score: 100 - (critical*30 + high*15 + medium*5 + low*1), capped at 0
|
|
208
219
|
const penalty = stats.critical * 30 + stats.high * 15 + stats.medium * 5 + stats.low * 1
|
|
209
220
|
const score = Math.max(0, 100 - penalty)
|
|
210
|
-
|
|
221
|
+
// Arithmetic alone would grade a single critical as 100-30=70 → 'warn',
|
|
222
|
+
// while the snapshot's overall status is 'fail' (hasCritical below) — a
|
|
223
|
+
// certificate that contradicts itself ("WARN" on the dimension that made
|
|
224
|
+
// the gate fail). Any critical finding is a hard FAIL for that dimension,
|
|
225
|
+
// regardless of its score; non-critical dimensions keep the score bands.
|
|
226
|
+
const status: 'pass' | 'warn' | 'fail' =
|
|
227
|
+
stats.critical > 0 ? 'fail' : score >= 80 ? 'pass' : score >= 50 ? 'warn' : 'fail'
|
|
211
228
|
const series = findingsByRound?.[dim]
|
|
212
229
|
const convergenceRate = convergenceRateFor(Array.isArray(series) ? series : undefined, stats.count)
|
|
213
230
|
|