iterate-plugin 3.5.7 → 3.7.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.
Files changed (79) hide show
  1. package/README.md +97 -22
  2. package/README.zh-CN.md +79 -19
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +516 -35
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +193 -0
  9. package/dist/index.js +61 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +54 -14
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +61 -14
  17. package/dist/skill-prompt.js +396 -66
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +106 -28
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +150 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/dist/working-directory.js +114 -0
  38. package/lib/client.js +1912 -512
  39. package/lib/parse.js +1001 -103
  40. package/package.json +16 -9
  41. package/src/approval-gate.ts +160 -23
  42. package/src/atomic-fs.ts +152 -17
  43. package/src/client/i18n.ts +517 -0
  44. package/src/client/index.ts +1031 -369
  45. package/src/client/session-cache.ts +54 -0
  46. package/src/config-loader.ts +549 -36
  47. package/src/config-write.ts +108 -26
  48. package/src/evidence.ts +194 -92
  49. package/src/file-lock.ts +211 -0
  50. package/src/index.ts +60 -29
  51. package/src/jobs.ts +63 -12
  52. package/src/live.ts +50 -12
  53. package/src/meta-review.ts +151 -34
  54. package/src/method-scope.ts +31 -8
  55. package/src/review-scope.ts +33 -10
  56. package/src/review.ts +96 -17
  57. package/src/session-hooks.ts +60 -13
  58. package/src/skill-prompt.ts +396 -66
  59. package/src/tools/checkpoint.ts +81 -19
  60. package/src/tools/config.ts +44 -2
  61. package/src/tools/context.ts +25 -5
  62. package/src/tools/decision-log.ts +114 -23
  63. package/src/tools/defense-events.ts +87 -31
  64. package/src/tools/defense-store.ts +30 -3
  65. package/src/tools/experience-bank.ts +112 -44
  66. package/src/tools/experience-store.ts +54 -5
  67. package/src/tools/fix.ts +258 -20
  68. package/src/tools/history.ts +74 -15
  69. package/src/tools/present.ts +89 -0
  70. package/src/tools/prune.ts +154 -51
  71. package/src/tools/quality-gate.ts +57 -2
  72. package/src/tools/quality-store.ts +18 -1
  73. package/src/tools/review.ts +155 -6
  74. package/src/tools/transcript.ts +178 -36
  75. package/src/tools/triage.ts +70 -13
  76. package/src/tools/validate.ts +138 -32
  77. package/src/transcript.ts +279 -4
  78. package/src/types.ts +29 -1
  79. package/src/working-directory.ts +126 -0
package/src/transcript.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * - F5 — checkpoint summary (resume).
12
12
  * - F6 — nudge channel (steer the next round).
13
13
  * - F7 — append-only decision timeline.
14
+ * - validations — per-round validation outcomes (command / exitCode /
15
+ * allowed) for the run console; see {@link TranscriptValidation}.
14
16
  *
15
17
  * It performs NO I/O and NEVER touches the filesystem — persistence lives in
16
18
  * the `iterate_transcript` tool. Inputs are defensively normalized so a
@@ -29,6 +31,8 @@ import type {
29
31
  TranscriptManifest,
30
32
  TranscriptNudge,
31
33
  TranscriptRound,
34
+ TranscriptThread,
35
+ TranscriptValidation,
32
36
  } from './types.ts'
33
37
 
34
38
  /** Manifest schema version (bump on incompatible shape change). */
@@ -47,16 +51,26 @@ const MAX_FINDINGS_PER_THREAD = 100
47
51
  const MAX_FINDINGS_TOTAL = 2000
48
52
 
49
53
  /** Max timeline entries kept (newest wins). */
50
- const MAX_TIMELINE = 500
54
+ export const MAX_TIMELINE = 500
51
55
 
52
56
  /** Max applied-fix records kept (newest wins; bounded so a long run cannot
53
57
  * grow the manifest payload without limit). */
54
58
  const MAX_FIXES = 200
55
59
 
60
+ /** Max validation rows kept (newest wins; bounds a very long run's payload). */
61
+ const MAX_VALIDATIONS = 500
62
+
56
63
  /** Max round number accepted by the builder. A model-authored/manifest-backed
57
64
  * round value is attacker-influenced JSON: `roundStart(1e9)` / `snapshotConvergence`
58
- * would otherwise preallocate arrays of that size and OOM the host. */
59
- const MAX_ROUNDS = 1000
65
+ * would otherwise preallocate arrays of that size and OOM the host.
66
+ * Exported so the capture/rehydrate FEED side can truncate before feeding
67
+ * (a value above the cap would otherwise be folded into the last slot). */
68
+ export const MAX_ROUNDS = 1000
69
+
70
+ /** Max threads restored per round from a persisted manifest (the live cap plus
71
+ * headroom for the single overflow thread). Shared by {@link ReviewTranscriptBuilder.restoreThread}
72
+ * and the read-side normalizer so both bound hostile manifests identically. */
73
+ export const MAX_THREADS_RESTORED = MAX_THREADS_PER_ROUND * 2 + 1
60
74
 
61
75
  /** Thresholds applied when reducing a string list under a cap. */
62
76
  function clampStringList(source: string[], cap: number): string[] {
@@ -115,6 +129,40 @@ function normalizeFinding(input: unknown): TranscriptFinding | null {
115
129
  }
116
130
  }
117
131
 
132
+ /**
133
+ * Normalize one validation row (`capture` `validations` contract), dropping
134
+ * rows that cannot identify WHAT was run and clamping the rest — the same
135
+ * defensive style as the round/fix normalizers, because these values arrive
136
+ * from a `type:'json'` argument a model authored.
137
+ *
138
+ * - a non-object row, a missing/non-finite round (≤ 0 after flooring), or
139
+ * a missing/blank command → dropped (there is nothing to attribute);
140
+ * - `round` above the builder cap → clamped (never dropped: the row is real);
141
+ * - `exitCode` → integer, or `null` when absent/unknown/not a number;
142
+ * - `allowed` → strict boolean; an ABSENT flag degrades to `false` (fail
143
+ * closed: a row that cannot prove it was allow-listed reads as rejected);
144
+ * - `rejectReason` → trimmed string, omitted when blank.
145
+ */
146
+ function normalizeValidation(input: unknown): TranscriptValidation | null {
147
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null
148
+ const v = input as Record<string, unknown>
149
+ const rawRound = typeof v.round === 'number' && Number.isFinite(v.round) ? Math.floor(v.round) : 0
150
+ if (rawRound <= 0) return null
151
+ const command = typeof v.command === 'string' ? v.command.trim() : ''
152
+ if (!command) return null
153
+ const exitCode =
154
+ typeof v.exitCode === 'number' && Number.isFinite(v.exitCode) ? Math.floor(v.exitCode) : null
155
+ const rejectReason =
156
+ typeof v.rejectReason === 'string' && v.rejectReason.trim() ? v.rejectReason.trim() : undefined
157
+ return {
158
+ round: Math.min(rawRound, MAX_ROUNDS),
159
+ command,
160
+ exitCode,
161
+ allowed: v.allowed === true,
162
+ ...(rejectReason ? { rejectReason } : {}),
163
+ }
164
+ }
165
+
118
166
  /** Stage an in-progress thread so the builder can append messages/reads/findings. */
119
167
  interface LiveThread {
120
168
  dimension: string
@@ -177,6 +225,7 @@ export class ReviewTranscriptBuilder {
177
225
  private readonly globalFindings: TranscriptFinding[] = []
178
226
  private readonly globalSeenKeys = new Set<string>()
179
227
  private readonly fixes: TranscriptFix[] = []
228
+ private readonly validations: TranscriptValidation[] = []
180
229
  private checkpoint: TranscriptCheckpoint | null = null
181
230
  private readonly timeline: TranscriptEntry[] = []
182
231
  private nudge: TranscriptNudge | null = null
@@ -262,6 +311,30 @@ export class ReviewTranscriptBuilder {
262
311
  this.touch()
263
312
  }
264
313
 
314
+ /**
315
+ * Advance the CURRENT-round marker to `round` WITHOUT creating round rows.
316
+ *
317
+ * `roundStart` pre-allocates empty rows up to `round` (legitimate while a
318
+ * round is opening — reviewers write into those rows). At capture time,
319
+ * though, `roundsExecuted` may legitimately exceed the rounds we actually
320
+ * captured data for: calling `roundStart(roundsExecuted)` there fabricated
321
+ * phantom EMPTY round rows the reviewers never produced. This setter only
322
+ * moves the marker — and only FORWARD (never rewinds past a row capture
323
+ * already recorded) — so `manifest.round` still reports how far the run got
324
+ * while `manifest.rounds` keeps only rounds with captured content.
325
+ */
326
+ advanceRound(round: number, maxRounds?: number): void {
327
+ const r =
328
+ typeof round === 'number' && Number.isFinite(round) && round > 0
329
+ ? Math.min(Math.floor(round), MAX_ROUNDS)
330
+ : 0
331
+ if (r > this.round) this.round = r
332
+ if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
333
+ this.maxRounds = Math.min(Math.floor(maxRounds), MAX_ROUNDS)
334
+ }
335
+ this.touch()
336
+ }
337
+
265
338
  // ─── Reviewer threads (F1) ──────────────────────────────────────────────
266
339
 
267
340
  /** Start a reviewer sub-agent's thread for the current round. */
@@ -330,7 +403,7 @@ export class ReviewTranscriptBuilder {
330
403
  if (!thread || typeof thread !== 'object') return
331
404
  // Hostile-manifest guard: keep the live hard bound (with headroom for the
332
405
  // single overflow thread) and newest-first truncate beyond it.
333
- if (live.threads.length >= MAX_THREADS_PER_ROUND * 2 + 1) return
406
+ if (live.threads.length >= MAX_THREADS_RESTORED) return
334
407
  const dim =
335
408
  typeof thread.dimension === 'string' && thread.dimension.trim()
336
409
  ? thread.dimension.trim()
@@ -464,6 +537,24 @@ export class ReviewTranscriptBuilder {
464
537
  this.touch()
465
538
  }
466
539
 
540
+ // ─── Validations (F6/F8 console) ────────────────────────────────────────
541
+
542
+ /**
543
+ * Record one validation command outcome. Normalizes defensively (bad rows
544
+ * are dropped, out-of-cap rounds clamped) and keeps the NEWEST rows under
545
+ * MAX_VALIDATIONS, mirroring {@link fix}. A malformed row can never crash
546
+ * the capture nor leak non-JSON state into the manifest.
547
+ */
548
+ validation(input: unknown): void {
549
+ const record = normalizeValidation(input)
550
+ if (!record) return
551
+ this.validations.push(record)
552
+ if (this.validations.length > MAX_VALIDATIONS) {
553
+ this.validations.splice(0, this.validations.length - MAX_VALIDATIONS)
554
+ }
555
+ this.touch()
556
+ }
557
+
467
558
  // ─── Checkpoint (F5) ────────────────────────────────────────────────────
468
559
 
469
560
  /** Record the current checkpoint summary (null clears it). */
@@ -557,6 +648,7 @@ export class ReviewTranscriptBuilder {
557
648
  // already ≤ MAX_FINDINGS_TOTAL — no post-hoc slice needed.
558
649
  findings: this.globalFindings,
559
650
  fixes: this.fixes,
651
+ validations: this.validations,
560
652
  checkpoint: this.checkpoint,
561
653
  timeline: this.timeline,
562
654
  nudge: this.nudge,
@@ -628,4 +720,187 @@ function dedupePaths(paths: string[]): string[] {
628
720
  /** ISO timestamp helper (kept injectable in tests via the builder's now). */
629
721
  function isoNow(): string {
630
722
  return new Date().toISOString()
723
+ }
724
+
725
+ // ─── Read-side normalization ────────────────────────────────────────────────
726
+
727
+ /** Clamp an unknown round marker into [0, MAX_ROUNDS] (0 = never recorded). */
728
+ function clampRoundValue(v: unknown): number {
729
+ return typeof v === 'number' && Number.isFinite(v)
730
+ ? Math.min(Math.max(Math.floor(v), 0), MAX_ROUNDS)
731
+ : 0
732
+ }
733
+
734
+ /** Clamp an unknown non-negative counter (0 when absent/garbage). */
735
+ function clampCountValue(v: unknown): number {
736
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0
737
+ }
738
+
739
+ /** Re-run one persisted thread row through the builder's per-row bounds. */
740
+ function normalizeThreadRow(input: unknown): TranscriptThread {
741
+ const t = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
742
+ const dim = typeof t.dimension === 'string' && t.dimension.trim() ? t.dimension.trim() : 'review'
743
+ const att = typeof t.attempt === 'number' && Number.isFinite(t.attempt) ? Math.floor(t.attempt) : 1
744
+ const messages = Array.isArray(t.messages) ? (t.messages as unknown[]) : []
745
+ const readFiles = Array.isArray(t.readFiles) ? (t.readFiles as unknown[]) : []
746
+ const rawFindings = Array.isArray(t.findings) ? (t.findings as unknown[]) : []
747
+ const findings = rawFindings
748
+ .map((f) => normalizeFinding(f))
749
+ .filter((f): f is TranscriptFinding => f !== null)
750
+ return {
751
+ dimension: dim,
752
+ attempt: att > 0 ? att : 1,
753
+ // Same reductions serialize() applies: messages newest-capped + trimmed,
754
+ // readFiles deduped, per-thread findings newest-capped.
755
+ messages: clampStringList(messages as string[], MAX_MESSAGES_PER_THREAD),
756
+ readFiles: dedupePaths(readFiles as string[]),
757
+ findings:
758
+ findings.length > MAX_FINDINGS_PER_THREAD
759
+ ? findings.slice(-MAX_FINDINGS_PER_THREAD)
760
+ : findings,
761
+ }
762
+ }
763
+
764
+ /** Re-run one persisted fix row through the builder's fix() requirements. */
765
+ function normalizeFixRow(input: unknown): TranscriptFix | null {
766
+ if (!input || typeof input !== 'object') return null
767
+ const f = input as Record<string, unknown>
768
+ const id = typeof f.id === 'string' ? f.id : ''
769
+ const file = typeof f.file === 'string' ? f.file : ''
770
+ if (!id || !file) return null
771
+ return {
772
+ id,
773
+ file,
774
+ // No fabricated "now" on read: a corrupt/absent timestamp stays blank
775
+ // instead of masquerading as a fix that just happened.
776
+ timestamp: typeof f.timestamp === 'string' ? f.timestamp : '',
777
+ round: clampRoundValue(f.round),
778
+ summary: typeof f.summary === 'string' ? f.summary : '',
779
+ linesAdded: clampCountValue(f.linesAdded),
780
+ linesRemoved: clampCountValue(f.linesRemoved),
781
+ success: f.success !== false,
782
+ }
783
+ }
784
+
785
+ /** Re-run one persisted timeline row through the builder's decision() shape. */
786
+ function normalizeTimelineRow(input: unknown): TranscriptEntry | null {
787
+ if (!input || typeof input !== 'object') return null
788
+ const e = input as Record<string, unknown>
789
+ return {
790
+ timestamp: typeof e.timestamp === 'string' ? e.timestamp : '',
791
+ round: clampRoundValue(e.round),
792
+ type: typeof e.type === 'string' && e.type ? e.type : 'decision',
793
+ data:
794
+ e.data && typeof e.data === 'object' && !Array.isArray(e.data)
795
+ ? (e.data as Record<string, unknown>)
796
+ : {},
797
+ }
798
+ }
799
+
800
+ /** Re-run a persisted checkpoint through the builder's recordCheckpoint rules. */
801
+ function normalizeCheckpointRow(input: unknown): TranscriptCheckpoint | null {
802
+ if (!input || typeof input !== 'object') return null
803
+ const c = input as Record<string, unknown>
804
+ const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
805
+ if (round <= 0) return null
806
+ return {
807
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
808
+ round: clampRoundValue(c.round),
809
+ maxRounds: clampCountValue(c.maxRounds),
810
+ fixedCount: clampCountValue(c.fixedCount),
811
+ resumeCount: clampCountValue(c.resumeCount),
812
+ updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : '',
813
+ }
814
+ }
815
+
816
+ /** Re-run a persisted nudge through the builder's setNudge rules. */
817
+ function normalizeNudgeRow(input: unknown): TranscriptNudge | null {
818
+ if (!input || typeof input !== 'object') return null
819
+ const n = input as Record<string, unknown>
820
+ const text = typeof n.text === 'string' ? n.text.trim() : ''
821
+ if (!text) return null
822
+ return { timestamp: typeof n.timestamp === 'string' ? n.timestamp : '', text }
823
+ }
824
+
825
+ /**
826
+ * Defensive read-side normalization of a persisted manifest.
827
+ *
828
+ * `.iterate/transcript.json` is hand-editable JSON handed straight to the
829
+ * client/model by `iterate_transcript.read`: the tool gates the ROOT shape,
830
+ * but row-level bounds (threads, messages, findings, timeline, fixes …) used
831
+ * to pass through untouched, so a hostile or corrupted file could leak an
832
+ * unbounded payload or junk field values (fractional lines, non-string
833
+ * phases, negative counters) that a capture could never have written.
834
+ *
835
+ * Every field is re-run through the SAME normalizers and caps the builder
836
+ * uses at capture time. Deliberately NOT implemented as
837
+ * `rehydrateBuilder(...).serialize()`: that drops fields the builder cannot
838
+ * rebuild (phases, the run identity, the round marker), so a plain read would
839
+ * silently rewrite history. Unknown inputs degrade instead of throwing — this
840
+ * runs after the tool's structural gate but must stay safe on its own.
841
+ */
842
+ export function normalizeManifestBounds(value: TranscriptManifest): TranscriptManifest {
843
+ const m = (value && typeof value === 'object' ? value : {}) as Partial<TranscriptManifest>
844
+
845
+ const rounds: TranscriptRound[] = (Array.isArray(m.rounds) ? m.rounds : [])
846
+ .slice(0, MAX_ROUNDS)
847
+ .map((r) => {
848
+ const row = (r && typeof r === 'object' ? r : {}) as Partial<TranscriptRound>
849
+ const threads = (Array.isArray(row.threads) ? row.threads : [])
850
+ .slice(0, MAX_THREADS_RESTORED)
851
+ .map((t) => normalizeThreadRow(t))
852
+ return { round: Math.max(clampRoundValue(row.round), 1), threads }
853
+ })
854
+
855
+ const fixes = (Array.isArray(m.fixes) ? m.fixes : [])
856
+ .map((f) => normalizeFixRow(f))
857
+ .filter((f): f is TranscriptFix => f !== null)
858
+ const timeline = (Array.isArray(m.timeline) ? m.timeline : [])
859
+ .map((e) => normalizeTimelineRow(e))
860
+ .filter((e): e is TranscriptEntry => e !== null)
861
+ const globalFindings = (Array.isArray(m.findings) ? m.findings : [])
862
+ .map((f) => normalizeFinding(f))
863
+ .filter((f): f is TranscriptFinding => f !== null)
864
+ const convergence = (Array.isArray(m.convergence) ? m.convergence : [])
865
+ .slice(0, MAX_ROUNDS)
866
+ .map((n) => (typeof n === 'number' && Number.isFinite(n) ? n : -1))
867
+ const validations = Array.isArray(m.validations)
868
+ ? m.validations
869
+ .map((v) => normalizeValidation(v))
870
+ .filter((v): v is TranscriptValidation => v !== null)
871
+ .slice(-MAX_VALIDATIONS)
872
+ : undefined
873
+
874
+ const policy =
875
+ m.approval?.policy === 'deny' || m.approval?.policy === 'allow' ? m.approval.policy : 'ask'
876
+ const out: TranscriptManifest = {
877
+ version: typeof m.version === 'number' && Number.isFinite(m.version) ? m.version : TRANSCRIPT_VERSION,
878
+ project: typeof m.project === 'string' ? m.project : '',
879
+ updatedAt: typeof m.updatedAt === 'string' ? m.updatedAt : '',
880
+ active: m.active === true,
881
+ mode: m.mode === 'dry-run' || m.mode === 'normal' ? m.mode : null,
882
+ goal: typeof m.goal === 'string' ? m.goal : '',
883
+ // A phase list is free-form narration names — strings only, bounded.
884
+ phases: clampStringList(Array.isArray(m.phases) ? (m.phases as string[]) : [], 100),
885
+ round: clampRoundValue(m.round),
886
+ maxRounds: clampCountValue(m.maxRounds),
887
+ rounds,
888
+ convergence,
889
+ findings:
890
+ globalFindings.length > MAX_FINDINGS_TOTAL
891
+ ? globalFindings.slice(-MAX_FINDINGS_TOTAL)
892
+ : globalFindings,
893
+ fixes: fixes.slice(-MAX_FIXES),
894
+ checkpoint: normalizeCheckpointRow(m.checkpoint),
895
+ timeline: timeline.slice(-MAX_TIMELINE),
896
+ nudge: normalizeNudgeRow(m.nudge),
897
+ approval: {
898
+ active: typeof m.approval?.active === 'boolean' ? m.approval.active : policy !== 'allow',
899
+ policy,
900
+ },
901
+ }
902
+ if (validations !== undefined) out.validations = validations
903
+ if (m.taskMode === 'code' || m.taskMode === 'iterate' || m.taskMode === null) out.taskMode = m.taskMode
904
+ if (typeof m.stoppedReason === 'string' || m.stoppedReason === null) out.stoppedReason = m.stoppedReason
905
+ return out
631
906
  }
package/src/types.ts CHANGED
@@ -41,7 +41,8 @@ export interface IterateConfig {
41
41
  capture?: boolean
42
42
  /**
43
43
  * Approval policy for destructive iterate tools (iterate_fix /
44
- * iterate_rollback / iterate_prune with dryRun:false).
44
+ * iterate_rollback / iterate_prune with dryRun:false /
45
+ * iterate_config with operation:"write").
45
46
  * - 'ask': prompt the human through the dsh approval service before running.
46
47
  * - 'deny': refuse the call outright (fail-closed).
47
48
  * - 'allow': always run (debug/trusted). Default 'ask'.
@@ -327,6 +328,28 @@ export interface TranscriptCheckpoint {
327
328
  updatedAt: string
328
329
  }
329
330
 
331
+ /**
332
+ * One validation command outcome captured for the observatory.
333
+ *
334
+ * Client/server contract (agreed with the client implementation — do not
335
+ * deviate): every row is `{round, command, exitCode, allowed}` with an
336
+ * optional `rejectReason`. `exitCode` is `null` when the run never produced
337
+ * an exit code; `allowed:false` marks a command that is NOT in
338
+ * `validation.commands` (a config gap, not a code failure).
339
+ */
340
+ export interface TranscriptValidation {
341
+ /** 1-based round that ran the command. */
342
+ round: number
343
+ /** The exact command string passed to `iterate_validate`. */
344
+ command: string
345
+ /** Process exit code, or null when unknown/never started. */
346
+ exitCode: number | null
347
+ /** False when the command was rejected by the allow-list (`rejectReason`). */
348
+ allowed: boolean
349
+ /** Why the command was rejected (present only when `allowed` is false). */
350
+ rejectReason?: string
351
+ }
352
+
330
353
  /**
331
354
  * Serializable runtime-observatory manifest the client renders. Every field is
332
355
  * `JsonValue`-safe; the builder caps growth so a long run cannot blow up memory.
@@ -348,6 +371,11 @@ export interface TranscriptManifest {
348
371
  checkpoint: TranscriptCheckpoint | null
349
372
  timeline: TranscriptEntry[]
350
373
  nudge: TranscriptNudge | null
374
+ /**
375
+ * Per-round validation outcomes (F6/F8 console). Absent on manifests
376
+ * captured before the field existed; always an array on new captures.
377
+ */
378
+ validations?: TranscriptValidation[]
351
379
  approval: {
352
380
  active: boolean
353
381
  policy: 'ask' | 'deny' | 'allow'
@@ -0,0 +1,126 @@
1
+ /**
2
+ * src/working-directory.ts — follow dsh's mutable session working directory.
3
+ *
4
+ * Upstream history:
5
+ * - through dsh 0.2.1-alpha.1 the session's working directory WAS the
6
+ * immutable `Session.header.cwd`, so reading `exec.agent.session.header.cwd`
7
+ * was authoritative;
8
+ * - dsh 0.2.1-alpha.2 added the `working_directory` tool and the
9
+ * `WorkingDirectoryService` (`@deepseek-ai/dsh-working-directory`), whose
10
+ * contract explicitly states "the immutable Session header retains the
11
+ * ORIGINAL project" while `working-directory/change` events record the
12
+ * current one. After a model/user switches directories mid-session,
13
+ * `header.cwd` is therefore stale BY DESIGN.
14
+ *
15
+ * The iterate plugin resolves the project root (where `.iterate/` lives, where
16
+ * fixes are written, and — critically — which workspace the F8 approval policy
17
+ * is read from) from the session cwd. Sticking to `header.cwd` on a host that
18
+ * supports directory switches would silently operate on the wrong project:
19
+ * reviews would anchor to the original checkout while the model edits files
20
+ * elsewhere, and a `deny` policy in the active workspace would be read from
21
+ * the original one.
22
+ *
23
+ * So the effective directory is resolved through the host's service when it
24
+ * exists, and degrades to `header.cwd` otherwise:
25
+ * - feature-detected (`ctx.get('workingDirectory')`) — hosts older than
26
+ * 0.2.1-alpha.2 simply have no such service and keep the old behavior;
27
+ * - `service.get(session)` is the service's synchronous, I/O-free read of
28
+ * the committed directory, so this stays safe on the approval path;
29
+ * - every failure mode (no service, service throws, hostile getters, a
30
+ * non-string result) falls back to `header.cwd`, never to `undefined`
31
+ * where a header cwd exists.
32
+ *
33
+ * The service object is fetched lazily on every call, not once at plugin
34
+ * apply: dsh may register the service after this plugin loads, and a disposed
35
+ * context must degrade rather than cache a dead handle.
36
+ */
37
+
38
+ /** Minimal structural view of dsh's `WorkingDirectoryService` (0.2.1-alpha.2+). */
39
+ interface WorkingDirectoryServiceLike {
40
+ get(session: unknown): string
41
+ }
42
+
43
+ /** Minimal structural view of the cordis context surface this module uses. */
44
+ interface ContextLike {
45
+ get?(name: string, strict?: boolean): unknown
46
+ }
47
+
48
+ /** The plugin context, installed once by `apply()`; null on hosts without it. */
49
+ let installedContext: ContextLike | null = null
50
+
51
+ /** True when `value` looks like a cordis context carrying `ctx.get`. */
52
+ function isContextLike(value: unknown): value is ContextLike {
53
+ return (
54
+ typeof value === 'object' &&
55
+ value !== null &&
56
+ typeof (value as ContextLike).get === 'function'
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Install the plugin context used to look up the `workingDirectory` service.
62
+ * Passing anything without a callable `get` clears the installation (tests and
63
+ * hosts that load the plugin into a degraded context).
64
+ */
65
+ export function installWorkingDirectoryContext(ctx: unknown): void {
66
+ installedContext = isContextLike(ctx) ? ctx : null
67
+ }
68
+
69
+ /** Forget the installed context (test isolation / unload). */
70
+ export function resetWorkingDirectoryContext(): void {
71
+ installedContext = null
72
+ }
73
+
74
+ /**
75
+ * Read the host's committed working directory for `session`, if the host
76
+ * provides the service and it answers with a usable absolute path.
77
+ *
78
+ * Returns `undefined` for every degradation path so callers keep their
79
+ * previous `header.cwd` behavior.
80
+ */
81
+ function serviceWorkingDirectory(session: unknown): string | undefined {
82
+ if (session === undefined || session === null) return undefined
83
+ const ctx = installedContext
84
+ if (!ctx) return undefined
85
+ try {
86
+ const service = ctx.get?.('workingDirectory')
87
+ if (!service || typeof (service as WorkingDirectoryServiceLike).get !== 'function') return undefined
88
+ const dir = (service as WorkingDirectoryServiceLike).get(session)
89
+ // A relative or empty directory would move the project root somewhere
90
+ // unspecified — refuse it and keep the header value instead.
91
+ if (typeof dir !== 'string' || dir.length === 0) return undefined
92
+ return dir
93
+ } catch {
94
+ // Missing/disposed service, unpublished session, or a throwing getter —
95
+ // degrade to the historical source rather than failing the tool call.
96
+ return undefined
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The effective working directory of a session: the host's committed
102
+ * directory when available, else the immutable session header cwd.
103
+ *
104
+ * Returns `undefined` only when neither source yields a usable path.
105
+ */
106
+ export function effectiveSessionCwd(session: unknown, headerCwd?: string): string | undefined {
107
+ const fromService = serviceWorkingDirectory(session)
108
+ if (fromService !== undefined) return fromService
109
+ return typeof headerCwd === 'string' && headerCwd.length > 0 ? headerCwd : undefined
110
+ }
111
+
112
+ /**
113
+ * Extract the effective cwd from a tool execution (`exec.agent.session`),
114
+ * reading every property defensively: a hostile/proxied exec must degrade to
115
+ * the fallback path, never throw out of an approval or capture hook.
116
+ */
117
+ export function sessionCwdOf(exec: unknown): string | undefined {
118
+ try {
119
+ const agent = (exec as { agent?: { session?: { header?: { cwd?: unknown } } } } | null | undefined)?.agent
120
+ const session = agent?.session
121
+ const headerCwd = typeof agent?.session?.header?.cwd === 'string' ? agent.session.header.cwd : undefined
122
+ return effectiveSessionCwd(session, headerCwd)
123
+ } catch {
124
+ return undefined
125
+ }
126
+ }