iterate-plugin 2.10.0 → 2.12.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 +42 -2
- package/README.zh-CN.md +40 -2
- package/dist/approval-gate.js +92 -0
- package/dist/config-loader.js +18 -3
- package/dist/config-write.js +7 -4
- package/dist/evidence.js +67 -1
- package/dist/git-scope.js +35 -6
- package/dist/index.js +15 -5
- package/dist/live.js +155 -0
- package/dist/meta-review.js +19 -5
- package/dist/method-scope.js +5 -1
- package/dist/paths.js +4 -0
- package/dist/review-scope.js +12 -8
- package/dist/review.js +76 -24
- package/dist/session-hooks.js +89 -0
- package/dist/skill-prompt.js +101 -19
- package/dist/tools/checkpoint.js +10 -3
- package/dist/tools/context.js +16 -4
- package/dist/tools/decision-log.js +29 -9
- package/dist/tools/fix.js +120 -3
- package/dist/tools/prune.js +16 -9
- package/dist/tools/review.js +4 -1
- package/dist/tools/transcript.js +324 -0
- package/dist/tools/triage.js +9 -6
- package/dist/tools/validate.js +5 -2
- package/dist/transcript.js +421 -0
- package/lib/client.js +966 -80
- package/lib/parse.js +302 -17
- package/package.json +1 -1
- package/src/approval-gate.ts +119 -0
- package/src/client/index.ts +807 -62
- package/src/config-loader.ts +16 -2
- package/src/config-write.ts +6 -4
- package/src/evidence.ts +69 -1
- package/src/git-scope.ts +34 -6
- package/src/index.ts +17 -6
- package/src/live.ts +185 -0
- package/src/meta-review.ts +24 -10
- package/src/method-scope.ts +5 -1
- package/src/paths.ts +5 -0
- package/src/review-scope.ts +11 -7
- package/src/review.ts +82 -25
- package/src/session-hooks.ts +90 -0
- package/src/skill-prompt.ts +101 -19
- package/src/tools/checkpoint.ts +10 -3
- package/src/tools/context.ts +14 -3
- package/src/tools/decision-log.ts +27 -10
- package/src/tools/fix.ts +114 -3
- package/src/tools/prune.ts +14 -11
- package/src/tools/review.ts +5 -2
- package/src/tools/transcript.ts +334 -0
- package/src/tools/triage.ts +9 -6
- package/src/tools/validate.ts +5 -2
- package/src/transcript.ts +475 -0
- package/src/types.ts +129 -0
package/lib/parse.js
CHANGED
|
@@ -24,7 +24,10 @@ export const SEVERITY_LABEL = {
|
|
|
24
24
|
export const SEVERITY_COLOR = {
|
|
25
25
|
critical: '#ef4444',
|
|
26
26
|
high: '#f97316',
|
|
27
|
-
medium
|
|
27
|
+
// medium is used both for dots/fills and as TEXT (stat numbers, table
|
|
28
|
+
// headers); #eab308 is illegible as text on light backgrounds (~1.6:1).
|
|
29
|
+
// amber-600 (#d97706) ~3.2:1 — still short of AA; go darker for legibility.
|
|
30
|
+
medium: '#b45309',
|
|
28
31
|
low: '#6b7280',
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -250,13 +253,11 @@ export function findReportInObject(obj, seen, maxDepth = 20) {
|
|
|
250
253
|
export function scanSessionForReport(session) {
|
|
251
254
|
if (!session || typeof session !== 'object') return null
|
|
252
255
|
|
|
253
|
-
// Try direct find first
|
|
254
|
-
const direct = findReportInObject(session)
|
|
255
|
-
if (direct) return direct
|
|
256
|
-
|
|
257
|
-
// Try common session structures
|
|
258
256
|
const s = /** @type {Record<string, unknown>} */ (session)
|
|
259
257
|
|
|
258
|
+
// LATEST-first scan: walk the chronological structures in reverse before the
|
|
259
|
+
// generic deep find, so a conversation with several reviews surfaces the
|
|
260
|
+
// most recent report — the generic find would return the FIRST match.
|
|
260
261
|
// Common pattern: session.toolCalls[].result.report
|
|
261
262
|
const toolCalls = safeGet(s, 'toolCalls')
|
|
262
263
|
if (Array.isArray(toolCalls)) {
|
|
@@ -309,6 +310,282 @@ export function scanSessionForReport(session) {
|
|
|
309
310
|
return null
|
|
310
311
|
}
|
|
311
312
|
|
|
313
|
+
// ─── Runtime-observatory transcript detection ────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Check whether `obj` is a valid runtime-observatory TranscriptManifest.
|
|
317
|
+
* Discriminators vs a ReviewReport: `convergence` is a NUMBER ARRAY (the
|
|
318
|
+
* findings-per-round trend), not an object like ReviewReport.convergence, and
|
|
319
|
+
* `version` is a number. Requires `version` + `rounds` (array) + `convergence`
|
|
320
|
+
* (array) so a ReviewReport never collides with a manifest.
|
|
321
|
+
*
|
|
322
|
+
* @param {unknown} obj
|
|
323
|
+
* @returns {obj is Record<string, unknown>}
|
|
324
|
+
*/
|
|
325
|
+
export function isTranscriptManifest(obj) {
|
|
326
|
+
if (!obj || typeof obj !== 'object') return false
|
|
327
|
+
const o = /** @type {Record<string, unknown>} */ (obj)
|
|
328
|
+
return (
|
|
329
|
+
typeof safeGet(o, 'version') === 'number' &&
|
|
330
|
+
Array.isArray(safeGet(o, 'rounds')) &&
|
|
331
|
+
Array.isArray(safeGet(o, 'convergence'))
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Attach the outer `live` array (sibling of `transcript` in an
|
|
337
|
+
* iterate_transcript result: `{ operation, found, live:[...], transcript }`) to
|
|
338
|
+
* a found manifest, so the secondary-subagent activity stream rides along with
|
|
339
|
+
* the manifest. Builds a defensive shallow copy (never mutates a possibly
|
|
340
|
+
* shared/proxied manifest). Returns the manifest unchanged when the source has
|
|
341
|
+
* no `live` array or the manifest already carries one.
|
|
342
|
+
*
|
|
343
|
+
* @param {Record<string, unknown> | null} manifest
|
|
344
|
+
* @param {unknown} source
|
|
345
|
+
* @returns {Record<string, unknown> | null}
|
|
346
|
+
*/
|
|
347
|
+
function attachLive(manifest, source) {
|
|
348
|
+
if (!manifest || typeof manifest !== 'object') return manifest
|
|
349
|
+
const live = source && typeof source === 'object' ? safeGet(source, 'live') : undefined
|
|
350
|
+
if (!Array.isArray(live)) return manifest
|
|
351
|
+
const m = /** @type {Record<string, unknown>} */ (manifest)
|
|
352
|
+
if (safeGet(m, 'live') !== undefined) return manifest
|
|
353
|
+
const copy = /** @type {Record<string, unknown>} */ ({})
|
|
354
|
+
for (const k of safeKeys(m)) copy[k] = safeGet(m, k)
|
|
355
|
+
copy.live = live
|
|
356
|
+
return copy
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Pull a manifest out of a single tool-result node. Accepts either the raw
|
|
361
|
+
* manifest object, `{ operation: 'capture', transcript: manifest }`, a
|
|
362
|
+
* string-wrapped JSON payload, or a plain wrapper (e.g. `{ message: ... }`).
|
|
363
|
+
* Falls back to a shallow deep-find (findTranscriptInObject) so a nested
|
|
364
|
+
* manifest buried inside an arbitrary result still surfaces. Never throws.
|
|
365
|
+
*
|
|
366
|
+
* @param {unknown} obj
|
|
367
|
+
* @returns {Record<string, unknown> | null}
|
|
368
|
+
*/
|
|
369
|
+
function extractTranscript(obj) {
|
|
370
|
+
if (typeof obj === 'string') {
|
|
371
|
+
try {
|
|
372
|
+
const parsed = JSON.parse(obj)
|
|
373
|
+
return extractTranscript(parsed)
|
|
374
|
+
} catch {
|
|
375
|
+
return null
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (!obj || typeof obj !== 'object') return null
|
|
379
|
+
|
|
380
|
+
// Direct manifest.
|
|
381
|
+
if (isTranscriptManifest(obj)) return /** @type {Record<string, unknown>} */ (obj)
|
|
382
|
+
|
|
383
|
+
const o = /** @type {Record<string, unknown>} */ (obj)
|
|
384
|
+
|
|
385
|
+
// { operation: 'capture', transcript: manifest, live: [...] }.
|
|
386
|
+
if (safeGet(o, 'operation') === 'capture') {
|
|
387
|
+
const t = safeGet(o, 'transcript')
|
|
388
|
+
if (t && typeof t === 'object' && isTranscriptManifest(t)) {
|
|
389
|
+
return attachLive(/** @type {Record<string, unknown>} */ (t), o)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Wrapper shapes the harness may emit: { message: ... }, { result: ... },
|
|
394
|
+
// { content: [...] } (an assistant tool-call block).
|
|
395
|
+
for (const key of ['message', 'result', 'content']) {
|
|
396
|
+
const val = safeGet(o, key)
|
|
397
|
+
if (val !== undefined) {
|
|
398
|
+
if (Array.isArray(val)) {
|
|
399
|
+
for (const item of val) {
|
|
400
|
+
const found = extractTranscript(item)
|
|
401
|
+
if (found) return attachLive(found, o)
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
const found = extractTranscript(val)
|
|
405
|
+
if (found) return attachLive(found, o)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Generic deep find for resilience.
|
|
411
|
+
return attachLive(findTranscriptInObject(o), o)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Deep-scan an object tree for the first TranscriptManifest (same traversal
|
|
416
|
+
* semantics as `findReportInObject`: circular-reference + depth guards).
|
|
417
|
+
*
|
|
418
|
+
* @param {unknown} obj
|
|
419
|
+
* @param {Set<unknown>} [seen]
|
|
420
|
+
* @param {number} [maxDepth=20]
|
|
421
|
+
* @returns {Record<string, unknown> | null}
|
|
422
|
+
*/
|
|
423
|
+
export function findTranscriptInObject(obj, seen, maxDepth = 20) {
|
|
424
|
+
if (maxDepth <= 0) return null
|
|
425
|
+
if (!obj || typeof obj !== 'object') return null
|
|
426
|
+
|
|
427
|
+
const s = seen || new Set()
|
|
428
|
+
if (s.has(obj)) return null
|
|
429
|
+
s.add(obj)
|
|
430
|
+
|
|
431
|
+
if (isTranscriptManifest(obj)) return /** @type {Record<string, unknown>} */ (obj)
|
|
432
|
+
|
|
433
|
+
if (Array.isArray(obj)) {
|
|
434
|
+
for (const item of obj) {
|
|
435
|
+
const found = findTranscriptInObject(item, s, maxDepth - 1)
|
|
436
|
+
if (found) return found
|
|
437
|
+
}
|
|
438
|
+
return null
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const o = /** @type {Record<string, unknown>} */ (obj)
|
|
442
|
+
for (const key of safeKeys(o)) {
|
|
443
|
+
const val = safeGet(o, key)
|
|
444
|
+
if (val && typeof val === 'object') {
|
|
445
|
+
const found = findTranscriptInObject(val, s, maxDepth - 1)
|
|
446
|
+
if (found) return found
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return null
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Scan a session snapshot (or any object) for the latest iterate_transcript
|
|
455
|
+
* tool result that carries a runtime-observatory TranscriptManifest. Prefers
|
|
456
|
+
* the most recent one (reverse chronological). Order of preference:
|
|
457
|
+
* 1. session.toolCalls[].result/.message wrapping the manifest;
|
|
458
|
+
* 2. session.messages[].content (assistant tool-call blocks / strings).
|
|
459
|
+
* Must work from the in-memory session stream because the client cannot read
|
|
460
|
+
* `.iterate/transcript.json` off disk.
|
|
461
|
+
*
|
|
462
|
+
* @param {unknown} session
|
|
463
|
+
* @returns {Record<string, unknown> | null}
|
|
464
|
+
*/
|
|
465
|
+
export function scanSessionForTranscript(session) {
|
|
466
|
+
if (!session || typeof session !== 'object') return null
|
|
467
|
+
|
|
468
|
+
const s = /** @type {Record<string, unknown>} */ (session)
|
|
469
|
+
|
|
470
|
+
// Common pattern: session.toolCalls[].result / .message.
|
|
471
|
+
const toolCalls = safeGet(s, 'toolCalls')
|
|
472
|
+
if (Array.isArray(toolCalls)) {
|
|
473
|
+
const calls = /** @type {Array<Record<string, unknown>>} */ (toolCalls)
|
|
474
|
+
for (let i = calls.length - 1; i >= 0; i--) {
|
|
475
|
+
const call = calls[i]
|
|
476
|
+
if (!call) continue
|
|
477
|
+
const tool = String(safeGet(call, 'tool') ?? '')
|
|
478
|
+
if (tool !== 'iterate_transcript' && !tool.endsWith('iterate_transcript')) continue
|
|
479
|
+
const found = extractTranscript(safeGet(call, 'result')) ||
|
|
480
|
+
extractTranscript(safeGet(call, 'message'))
|
|
481
|
+
if (found) return found
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Common pattern: assistant message content (tool-call blocks / strings).
|
|
486
|
+
const messages = safeGet(s, 'messages')
|
|
487
|
+
if (Array.isArray(messages)) {
|
|
488
|
+
const msgs = /** @type {Array<Record<string, unknown>>} */ (messages)
|
|
489
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
490
|
+
const msg = msgs[i]
|
|
491
|
+
if (!msg) continue
|
|
492
|
+
// Prefer the explicit tool-call surface first, then generic content.
|
|
493
|
+
const calls = safeGet(msg, 'tool_calls')
|
|
494
|
+
if (Array.isArray(calls)) {
|
|
495
|
+
for (const call of calls) {
|
|
496
|
+
if (!call) continue
|
|
497
|
+
const args = safeGet(call, 'arguments')
|
|
498
|
+
if (typeof args === 'string') {
|
|
499
|
+
const found = extractTranscript(args)
|
|
500
|
+
if (found) return found
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const found = extractTranscript(safeGet(msg, 'content'))
|
|
505
|
+
if (found) return found
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return null
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Normalize a TranscriptManifest into a plain, JSON-safe object so rendering
|
|
514
|
+
* never touches a live cordis proxy (which can throw on property reads). Every
|
|
515
|
+
* optional/missing field degrades to a safe default; the input is never
|
|
516
|
+
* mutated and unknown extra fields are dropped.
|
|
517
|
+
*
|
|
518
|
+
* @param {Record<string, unknown> | null | undefined} manifest
|
|
519
|
+
* @returns {Record<string, unknown>}
|
|
520
|
+
*/
|
|
521
|
+
export function normalizeTranscript(manifest) {
|
|
522
|
+
const src = manifest && typeof manifest === 'object'
|
|
523
|
+
? /** @type {Record<string, unknown>} */ (manifest)
|
|
524
|
+
: {}
|
|
525
|
+
const asNum = (v) => (typeof v === 'number' ? v : 0)
|
|
526
|
+
const asStr = (v) => (typeof v === 'string' ? v : '')
|
|
527
|
+
const asBool = (v) => v === true
|
|
528
|
+
const asCount = (v) => (typeof v === 'number' ? v : 0)
|
|
529
|
+
const asArray = (v) => (Array.isArray(v) ? /** @type {Array<Record<string, unknown>>} */ (v) : [])
|
|
530
|
+
|
|
531
|
+
const rounds = asArray(safeGet(src, 'rounds')).map((r) => ({
|
|
532
|
+
round: asNum(safeGet(r, 'round')),
|
|
533
|
+
threads: asArray(safeGet(r, 'threads')).map((t) => ({
|
|
534
|
+
dimension: asStr(safeGet(t, 'dimension')),
|
|
535
|
+
attempt: asNum(safeGet(t, 'attempt')),
|
|
536
|
+
messages: asArray(safeGet(t, 'messages')).map((m) => (typeof m === 'string' ? m : '')),
|
|
537
|
+
readFiles: asArray(safeGet(t, 'readFiles')).map((f) => (typeof f === 'string' ? f : '')),
|
|
538
|
+
findings: asArray(safeGet(t, 'findings')).map((f) => ({ ...f })),
|
|
539
|
+
})),
|
|
540
|
+
}))
|
|
541
|
+
|
|
542
|
+
const cp = safeGet(src, 'checkpoint')
|
|
543
|
+
const checkpoint = cp && typeof cp === 'object'
|
|
544
|
+
? {
|
|
545
|
+
mode: asStr(safeGet(cp, 'mode')),
|
|
546
|
+
round: asNum(safeGet(cp, 'round')),
|
|
547
|
+
maxRounds: asNum(safeGet(cp, 'maxRounds')),
|
|
548
|
+
fixedCount: asNum(safeGet(cp, 'fixedCount')),
|
|
549
|
+
resumeCount: asNum(safeGet(cp, 'resumeCount')),
|
|
550
|
+
updatedAt: asStr(safeGet(cp, 'updatedAt')),
|
|
551
|
+
}
|
|
552
|
+
: null
|
|
553
|
+
|
|
554
|
+
const ng = safeGet(src, 'nudge')
|
|
555
|
+
const nudge = ng && typeof ng === 'object'
|
|
556
|
+
? { timestamp: asStr(safeGet(ng, 'timestamp')), text: asStr(safeGet(ng, 'text')) }
|
|
557
|
+
: null
|
|
558
|
+
|
|
559
|
+
const ap = safeGet(src, 'approval')
|
|
560
|
+
return {
|
|
561
|
+
version: asNum(safeGet(src, 'version')),
|
|
562
|
+
project: asStr(safeGet(src, 'project')),
|
|
563
|
+
updatedAt: asStr(safeGet(src, 'updatedAt')),
|
|
564
|
+
active: asBool(safeGet(src, 'active')),
|
|
565
|
+
mode: asStr(safeGet(src, 'mode')) || null,
|
|
566
|
+
goal: asStr(safeGet(src, 'goal')),
|
|
567
|
+
phases: asArray(safeGet(src, 'phases')).map((p) => (typeof p === 'string' ? p : '')),
|
|
568
|
+
round: asNum(safeGet(src, 'round')),
|
|
569
|
+
maxRounds: asNum(safeGet(src, 'maxRounds')),
|
|
570
|
+
rounds,
|
|
571
|
+
convergence: asArray(safeGet(src, 'convergence')).map((n) => asCount(n)),
|
|
572
|
+
findings: asArray(safeGet(src, 'findings')).map((f) => ({ ...f })),
|
|
573
|
+
fixes: asArray(safeGet(src, 'fixes')).map((f) => ({ ...f })),
|
|
574
|
+
live: asArray(safeGet(src, 'live')).map((e) => ({
|
|
575
|
+
ts: typeof safeGet(e, 'ts') === 'number' ? String(safeGet(e, 'ts')) : asStr(safeGet(e, 'ts')),
|
|
576
|
+
type: asStr(safeGet(e, 'type')),
|
|
577
|
+
tool: asStr(safeGet(e, 'tool')),
|
|
578
|
+
target: asStr(safeGet(e, 'target')),
|
|
579
|
+
})),
|
|
580
|
+
checkpoint,
|
|
581
|
+
timeline: asArray(safeGet(src, 'timeline')).map((t) => ({ ...t })),
|
|
582
|
+
nudge,
|
|
583
|
+
approval: ap && typeof ap === 'object'
|
|
584
|
+
? { active: asBool(safeGet(ap, 'active')), policy: asStr(safeGet(ap, 'policy')) || 'ask' }
|
|
585
|
+
: { active: false, policy: 'ask' },
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
312
589
|
// ─── Run-summary / meta-review verdict detection ─────────────────────────────
|
|
313
590
|
|
|
314
591
|
/**
|
|
@@ -382,11 +659,10 @@ export function findRunSummaryInObject(obj, seen, maxDepth = 20) {
|
|
|
382
659
|
export function scanSessionForRunSummary(session) {
|
|
383
660
|
if (!session || typeof session !== 'object') return null
|
|
384
661
|
|
|
385
|
-
const direct = findRunSummaryInObject(session)
|
|
386
|
-
if (direct) return direct
|
|
387
|
-
|
|
388
662
|
const s = /** @type {Record<string, unknown>} */ (session)
|
|
389
663
|
|
|
664
|
+
// LATEST-first: walk chronological structures in reverse before the generic
|
|
665
|
+
// deep find (which would return the FIRST match, i.e. the oldest run).
|
|
390
666
|
// Common pattern: session.toolCalls[].result contains a run summary.
|
|
391
667
|
const toolCalls = safeGet(s, 'toolCalls')
|
|
392
668
|
if (Array.isArray(toolCalls)) {
|
|
@@ -654,14 +930,23 @@ export function buildTriageState(report) {
|
|
|
654
930
|
* @returns {string}
|
|
655
931
|
*/
|
|
656
932
|
export function hashReport(report) {
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
933
|
+
const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
|
|
934
|
+
// FNV-1a over EVERY finding (file|line|dimension|summary) so two different
|
|
935
|
+
// reports never share a verdict store, while re-running the identical review
|
|
936
|
+
// restores the same verdicts.
|
|
937
|
+
let h = 0x811c9dc5
|
|
938
|
+
const mix = (s) => {
|
|
939
|
+
for (let i = 0; i < s.length; i++) {
|
|
940
|
+
h ^= s.charCodeAt(i)
|
|
941
|
+
h = Math.imul(h, 0x01000193) >>> 0
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
mix(`${String(report.mode ?? '')}|`)
|
|
945
|
+
for (const f of findings) {
|
|
946
|
+
if (!f || typeof f !== 'object') continue
|
|
947
|
+
mix(`${String(f.file ?? '')}|${typeof f.line === 'number' ? f.line : 0}|${String(f.dimension ?? '')}|${String(f.summary ?? '')}\n`)
|
|
948
|
+
}
|
|
949
|
+
return `iterate-triage-${h.toString(36)}`
|
|
665
950
|
}
|
|
666
951
|
|
|
667
952
|
// ─── Known-intentional YAML builder ──────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/approval-gate.ts — pure policy gate for destructive iterate tool calls.
|
|
3
|
+
*
|
|
4
|
+
* Feeds dsh's `tools/pre-execute` waterfall (registered in `session-hooks.ts`).
|
|
5
|
+
* The gate classifies a tool execution and returns a typed decision without
|
|
6
|
+
* any I/O, so it is fully unit-testable:
|
|
7
|
+
*
|
|
8
|
+
* - `{ kind: 'allow' }` → run the call.
|
|
9
|
+
* - `{ kind: 'ask', reason }` → prompt the human via the dsh approval service.
|
|
10
|
+
* - `{ kind: 'deny', reason }` → refuse; the caller surfaces the reason.
|
|
11
|
+
*
|
|
12
|
+
* Policy (config `observatory.approval`, default 'ask'):
|
|
13
|
+
* - `allow` → destructive iterate calls always run (debug/trusted).
|
|
14
|
+
* - `deny` → destructive iterate calls are always refused (fail-closed).
|
|
15
|
+
* - `ask` → destructive iterate calls prompt the human first.
|
|
16
|
+
*
|
|
17
|
+
* Destructive calls are exactly the ones that mutate the workspace:
|
|
18
|
+
* `iterate_fix` (writes files), `iterate_rollback` (restores from backups),
|
|
19
|
+
* and `iterate_prune` with `dryRun !== true` (deletes `.iterate/` artifacts).
|
|
20
|
+
* Read-only calls are always allowed. Non-iterate calls are untouched — the
|
|
21
|
+
* gate only ever inspects iterate tools so it cannot alter unrelated behavior.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type ApprovalDecision =
|
|
25
|
+
| { kind: 'allow' }
|
|
26
|
+
| { kind: 'ask'; reason: string }
|
|
27
|
+
| { kind: 'deny'; reason: string }
|
|
28
|
+
|
|
29
|
+
/** The subset of a tool execution the gate inspects. */
|
|
30
|
+
export interface ToolExecutionLike {
|
|
31
|
+
name: string
|
|
32
|
+
arguments?: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Destructive iterate tools subject to the gate. */
|
|
36
|
+
const DESTRUCTIVE_TOOLS = new Set(['iterate_fix', 'iterate_rollback', 'iterate_prune'])
|
|
37
|
+
|
|
38
|
+
/** Human-readable reason rendered in the approval prompt. */
|
|
39
|
+
function describe(toolName: string, arguments0?: Record<string, unknown>): string {
|
|
40
|
+
const file =
|
|
41
|
+
arguments0 && typeof arguments0.file === 'string'
|
|
42
|
+
? `\`${arguments0.file}\``
|
|
43
|
+
: 'the workspace'
|
|
44
|
+
switch (toolName) {
|
|
45
|
+
case 'iterate_fix':
|
|
46
|
+
return `Apply an atomic fix to ${file}`
|
|
47
|
+
case 'iterate_rollback': {
|
|
48
|
+
const id =
|
|
49
|
+
arguments0 && typeof arguments0.id === 'string' ? ` \`${arguments0.id}\`` : ''
|
|
50
|
+
return `Revert fix${id} (restore ${file} from backup)`
|
|
51
|
+
}
|
|
52
|
+
case 'iterate_prune':
|
|
53
|
+
return 'Delete stale `.iterate/` runtime artifacts'
|
|
54
|
+
default:
|
|
55
|
+
return `Run ${toolName}`
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether a tool execution may proceed under the given policy.
|
|
61
|
+
* Returns `allow` for read-only prune (`dryRun: true`), for `allow`-policy
|
|
62
|
+
* deployments, and for any non-iterate tool.
|
|
63
|
+
*/
|
|
64
|
+
export function decideApproval(
|
|
65
|
+
execution: ToolExecutionLike,
|
|
66
|
+
policy: 'ask' | 'deny' | 'allow',
|
|
67
|
+
): ApprovalDecision {
|
|
68
|
+
const name = typeof execution?.name === 'string' ? execution.name : ''
|
|
69
|
+
if (!name) return { kind: 'allow' }
|
|
70
|
+
if (!DESTRUCTIVE_TOOLS.has(name)) return { kind: 'allow' }
|
|
71
|
+
|
|
72
|
+
const rawArgs = execution.arguments
|
|
73
|
+
const args: Record<string, unknown> =
|
|
74
|
+
rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)
|
|
75
|
+
? (rawArgs as Record<string, unknown>)
|
|
76
|
+
: {}
|
|
77
|
+
// `iterate_prune` is read-only in its default dry-run mode — no gate needed.
|
|
78
|
+
if (name === 'iterate_prune' && args.dryRun === false) {
|
|
79
|
+
// falls through to the destructive path below
|
|
80
|
+
} else if (name === 'iterate_prune') {
|
|
81
|
+
return { kind: 'allow' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (policy === 'allow') return { kind: 'allow' }
|
|
85
|
+
const reason = describe(name, args)
|
|
86
|
+
if (policy === 'deny') return { kind: 'deny', reason }
|
|
87
|
+
return { kind: 'ask', reason }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when any destructive iterate tool is listed in a name set. */
|
|
91
|
+
export function isDestructiveIterateTool(name: unknown): boolean {
|
|
92
|
+
return typeof name === 'string' && DESTRUCTIVE_TOOLS.has(name)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Tool-facing gate result: run, or refuse (with an explicit human-approval signal). */
|
|
96
|
+
export type ToolGateResult =
|
|
97
|
+
| { ok: true }
|
|
98
|
+
| { ok: false; requiresApproval: true; reason: string }
|
|
99
|
+
| { ok: false; error: string }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Evaluate an iterate tool's own boundary gate for a destructive call.
|
|
103
|
+
* `approvedArg` is the caller-supplied `approved: true` flag (human consent
|
|
104
|
+
* already obtained). Returns a run / refuse result without any I/O.
|
|
105
|
+
*/
|
|
106
|
+
export function toolGate(
|
|
107
|
+
policy: 'ask' | 'deny' | 'allow',
|
|
108
|
+
execution: ToolExecutionLike,
|
|
109
|
+
approvedArg?: unknown,
|
|
110
|
+
): ToolGateResult {
|
|
111
|
+
const decision = decideApproval(execution, policy)
|
|
112
|
+
if (decision.kind === 'allow') return { ok: true }
|
|
113
|
+
if (decision.kind === 'deny') {
|
|
114
|
+
return { ok: false, error: `Blocked by observatory approval policy: ${decision.reason}` }
|
|
115
|
+
}
|
|
116
|
+
// ask
|
|
117
|
+
if (approvedArg === true) return { ok: true }
|
|
118
|
+
return { ok: false, requiresApproval: true, reason: decision.reason }
|
|
119
|
+
}
|