iterate-plugin 2.11.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/lib/parse.js CHANGED
@@ -310,6 +310,282 @@ export function scanSessionForReport(session) {
310
310
  return null
311
311
  }
312
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
+
313
589
  // ─── Run-summary / meta-review verdict detection ─────────────────────────────
314
590
 
315
591
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "2.11.0",
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
+ }