iterate-plugin 3.5.6 → 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.
Files changed (75) hide show
  1. package/README.md +64 -20
  2. package/README.zh-CN.md +53 -17
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +509 -34
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +165 -0
  9. package/dist/index.js +54 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +52 -13
  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 +55 -13
  17. package/dist/skill-prompt.js +417 -67
  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 +122 -27
  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 +156 -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/lib/client.js +945 -181
  38. package/lib/parse.js +760 -57
  39. package/package.json +15 -12
  40. package/src/approval-gate.ts +160 -23
  41. package/src/atomic-fs.ts +152 -17
  42. package/src/client/index.ts +600 -129
  43. package/src/config-loader.ts +542 -35
  44. package/src/config-write.ts +108 -26
  45. package/src/evidence.ts +194 -92
  46. package/src/file-lock.ts +184 -0
  47. package/src/index.ts +53 -29
  48. package/src/jobs.ts +63 -12
  49. package/src/live.ts +48 -11
  50. package/src/meta-review.ts +151 -34
  51. package/src/method-scope.ts +31 -8
  52. package/src/review-scope.ts +33 -10
  53. package/src/review.ts +96 -17
  54. package/src/session-hooks.ts +54 -12
  55. package/src/skill-prompt.ts +417 -67
  56. package/src/tools/checkpoint.ts +81 -19
  57. package/src/tools/config.ts +44 -2
  58. package/src/tools/context.ts +25 -5
  59. package/src/tools/decision-log.ts +127 -22
  60. package/src/tools/defense-events.ts +87 -31
  61. package/src/tools/defense-store.ts +30 -3
  62. package/src/tools/experience-bank.ts +112 -44
  63. package/src/tools/experience-store.ts +54 -5
  64. package/src/tools/fix.ts +258 -20
  65. package/src/tools/history.ts +74 -15
  66. package/src/tools/present.ts +89 -0
  67. package/src/tools/prune.ts +160 -51
  68. package/src/tools/quality-gate.ts +57 -2
  69. package/src/tools/quality-store.ts +18 -1
  70. package/src/tools/review.ts +155 -6
  71. package/src/tools/transcript.ts +178 -36
  72. package/src/tools/triage.ts +70 -13
  73. package/src/tools/validate.ts +138 -32
  74. package/src/transcript.ts +279 -4
  75. package/src/types.ts +29 -1
@@ -0,0 +1,184 @@
1
+ /**
2
+ * src/file-lock.ts — generic cross-process advisory lock for `.iterate/` state.
3
+ *
4
+ * Several plugin writers mutate small JSON/JSONL state files with a
5
+ * read-modify-write cycle (fix registry, decision log, defense events,
6
+ * experience bank). `writeJsonAtomic` only makes a SINGLE write atomic — two
7
+ * plugin processes racing on the same project can still lose an update between
8
+ * their read and their write. The decision log already guards itself with a
9
+ * purpose-built mutex (see `tools/decision-log.ts`); this module generalizes
10
+ * that exact pattern so every other writer can serialize the same way.
11
+ *
12
+ * Semantics (identical to the decision-log lock, deliberately):
13
+ * - exclusive create (`openSync(..., 'wx')`) with the holder pid stamped in;
14
+ * - a lock whose owner process is gone, or whose mtime is ancient, is
15
+ * stolen (unlink + retry);
16
+ * - bounded wait — on timeout the lock degrades to "proceed unlocked"
17
+ * (returns a callable no-op release) instead of wedging the tool call;
18
+ * - the returned release is always callable, idempotent, and never throws.
19
+ *
20
+ * Best-effort by design: an advisory lock that cannot be taken must never turn
21
+ * a working tool into a failed one; the worst case is the pre-existing
22
+ * (unlocked) behavior.
23
+ *
24
+ * In-process callers get synchronous mutual exclusion through the same API —
25
+ * tools run on one thread, so acquiring twice in one process would self-deadlock;
26
+ * `withProjectLock` is therefore NOT reentrant and must not be nested for the
27
+ * same (projectRoot, name).
28
+ */
29
+
30
+ import {
31
+ closeSync,
32
+ existsSync,
33
+ mkdirSync,
34
+ openSync,
35
+ readFileSync,
36
+ statSync,
37
+ unlinkSync,
38
+ writeSync,
39
+ } from 'node:fs'
40
+ import { join } from 'node:path'
41
+
42
+ const LOCK_DIR = '.iterate'
43
+ const DEFAULT_WAIT_MS = 5000
44
+ const DEFAULT_STALE_MS = 10000
45
+ const POLL_MS = 25
46
+
47
+ /** Tunables for tests (short waits) and callers wanting a different budget. */
48
+ export interface LockOptions {
49
+ /** Max time to wait for a live holder before proceeding unlocked. */
50
+ waitMs?: number
51
+ /** Age after which an existing lock file is considered abandoned. */
52
+ staleMs?: number
53
+ }
54
+
55
+ /** True when a pid refers to a live process (or we lack permission to tell). */
56
+ function processAlive(pid: number): boolean {
57
+ if (!Number.isInteger(pid) || pid <= 0) return false
58
+ try {
59
+ process.kill(pid, 0)
60
+ return true
61
+ } catch (err) {
62
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
63
+ }
64
+ }
65
+
66
+ /** Synchronous sleep (Atomics.wait is a reliable sleep in Node). */
67
+ function sleepSync(ms: number): void {
68
+ const sab = new Int32Array(new SharedArrayBuffer(4))
69
+ Atomics.wait(sab, 0, 0, ms)
70
+ }
71
+
72
+ /**
73
+ * Validate a lock name into a safe file-name fragment.
74
+ * Throws on anything that could escape `.iterate/` (path separators, dots).
75
+ */
76
+ function lockFileName(name: string): string {
77
+ if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
78
+ throw new Error(`invalid lock name: ${JSON.stringify(name)}`)
79
+ }
80
+ return `.${name}.lock`
81
+ }
82
+
83
+ /**
84
+ * Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
85
+ *
86
+ * Returns a release function: always callable, idempotent, never throws.
87
+ * A no-op (but callable) release is returned when the lock directory cannot be
88
+ * created or the wait times out — callers then proceed unlocked, which is the
89
+ * historical best-effort behavior, rather than failing the operation.
90
+ */
91
+ export function acquireProjectLock(
92
+ projectRoot: string,
93
+ name: string,
94
+ options: LockOptions = {},
95
+ ): () => void {
96
+ const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
97
+ ? (options.waitMs as number)
98
+ : DEFAULT_WAIT_MS
99
+ const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
100
+ ? (options.staleMs as number)
101
+ : DEFAULT_STALE_MS
102
+
103
+ const dir = join(projectRoot, LOCK_DIR)
104
+ if (!existsSync(dir)) {
105
+ try {
106
+ mkdirSync(dir, { recursive: true })
107
+ } catch {
108
+ return () => {}
109
+ }
110
+ }
111
+ const lockPath = join(dir, lockFileName(name))
112
+ const start = Date.now()
113
+ for (;;) {
114
+ let fd: number | null = null
115
+ try {
116
+ fd = openSync(lockPath, 'wx')
117
+ } catch (err) {
118
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return () => {}
119
+ // Lock exists — steal it when the owner is gone or the lock is ancient.
120
+ let stale = false
121
+ try {
122
+ const st = statSync(lockPath)
123
+ if (Date.now() - st.mtimeMs > staleMs) stale = true
124
+ else {
125
+ const pid = Number(readFileSync(lockPath, 'utf-8'))
126
+ if (!processAlive(pid)) stale = true
127
+ }
128
+ } catch {
129
+ stale = true // unreadable/vanishing lock → retry as stale
130
+ }
131
+ if (stale) {
132
+ try {
133
+ unlinkSync(lockPath)
134
+ } catch {
135
+ // another holder stole it first — retry immediately
136
+ }
137
+ continue
138
+ }
139
+ if (Date.now() - start > waitMs) return () => {}
140
+ sleepSync(POLL_MS)
141
+ continue
142
+ }
143
+ // Owned the lock: stamp the holder pid, then release once on return.
144
+ try {
145
+ writeSync(fd, String(process.pid))
146
+ } catch {
147
+ // pid stamp is advisory
148
+ }
149
+ try {
150
+ closeSync(fd)
151
+ } catch {
152
+ // best-effort
153
+ }
154
+ let released = false
155
+ return () => {
156
+ if (released) return
157
+ released = true
158
+ try {
159
+ unlinkSync(lockPath)
160
+ } catch {
161
+ // already gone
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Run `fn` while holding the advisory lock named `name`.
169
+ * The release is guaranteed via `finally`; `fn`'s value or error propagates
170
+ * unchanged. Not reentrant for the same (projectRoot, name) — see header.
171
+ */
172
+ export function withProjectLock<T>(
173
+ projectRoot: string,
174
+ name: string,
175
+ fn: () => T,
176
+ options: LockOptions = {},
177
+ ): T {
178
+ const release = acquireProjectLock(projectRoot, name, options)
179
+ try {
180
+ return fn()
181
+ } finally {
182
+ release()
183
+ }
184
+ }
package/src/index.ts CHANGED
@@ -51,36 +51,60 @@ export const name = 'iterate-plugin'
51
51
  export const inject = ['tools', 'systemPrompt'] as const
52
52
 
53
53
  export function apply(ctx: Context): void {
54
- // 1. Register the 17 tools (14 original + 3 v3.0)
55
- registerConfigTool(ctx)
56
- registerValidateTool(ctx)
57
- registerDecisionLogTool(ctx)
58
- registerContextTool(ctx)
59
- registerReviewTool(ctx)
60
- registerTriageTool(ctx)
61
- registerFixTool(ctx)
62
- registerDiffTool(ctx)
63
- registerRollbackTool(ctx)
64
- registerCheckpointTool(ctx)
65
- registerStatusTool(ctx)
66
- registerHistoryTool(ctx)
67
- registerPruneTool(ctx)
68
- registerTranscriptTool(ctx)
69
- // v3.0: Quality Command Center tools
70
- registerExperienceBankTool(ctx)
71
- registerQualityGateTool(ctx)
72
- registerDefenseEventsTool(ctx)
73
-
74
- // 2. Wire the observatory approval gate onto dsh's tools/pre-execute waterfall,
75
- // and the live reviewer-activity feed onto tools/result.
54
+ // 0. Wire the SAFETY hooks BEFORE any tool is registered.
55
+ // Registration is a list of 17 independent calls; if one of them threw
56
+ // (duplicate name, malformed definition, hostile ctx) while the gate was
57
+ // registered last, the tools registered up to that point would keep
58
+ // running with NO `tools/pre-execute` approval gate — a fail-open hole
59
+ // created by load order. Registering the gate first means a partial load
60
+ // can only ever err on the side of MORE gating, never less.
76
61
  registerSessionHooks(ctx)
77
62
  registerLiveCapture(ctx)
78
63
 
79
- // 2. Inject the iterate skill prompt as a system prompt section
80
- // This teaches the model how to write iterate workflow scripts using the tools.
81
- ctx.systemPrompt.section({
82
- name: 'iterate-skill',
83
- order: 100,
84
- text: ITERATE_SKILL_PROMPT,
85
- })
64
+ // 1. Register the 17 tools (14 original + 3 v3.0). Each registration is
65
+ // isolated: one failing tool must not take down the other 16, the gate
66
+ // above, or the prompt below (a partial plugin that still gates and
67
+ // teaches the workflow beats an aborted `apply`). Failures are logged,
68
+ // never swallowed silently.
69
+ const toolRegistrations: Array<[string, () => void]> = [
70
+ ['iterate_config', () => registerConfigTool(ctx)],
71
+ ['iterate_validate', () => registerValidateTool(ctx)],
72
+ ['iterate_decision_log', () => registerDecisionLogTool(ctx)],
73
+ ['iterate_context', () => registerContextTool(ctx)],
74
+ ['iterate_review', () => registerReviewTool(ctx)],
75
+ ['iterate_triage', () => registerTriageTool(ctx)],
76
+ ['iterate_fix', () => registerFixTool(ctx)],
77
+ ['iterate_diff', () => registerDiffTool(ctx)],
78
+ ['iterate_rollback', () => registerRollbackTool(ctx)],
79
+ ['iterate_checkpoint', () => registerCheckpointTool(ctx)],
80
+ ['iterate_status', () => registerStatusTool(ctx)],
81
+ ['iterate_history', () => registerHistoryTool(ctx)],
82
+ ['iterate_prune', () => registerPruneTool(ctx)],
83
+ ['iterate_transcript', () => registerTranscriptTool(ctx)],
84
+ // v3.0: Quality Command Center tools
85
+ ['iterate_experience', () => registerExperienceBankTool(ctx)],
86
+ ['iterate_quality_gate', () => registerQualityGateTool(ctx)],
87
+ ['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
88
+ ]
89
+ for (const [toolName, register] of toolRegistrations) {
90
+ try {
91
+ register()
92
+ } catch (err) {
93
+ console.warn(`[iterate] failed to register ${toolName}; continuing.`, err)
94
+ }
95
+ }
96
+
97
+ // 2. Inject the iterate skill prompt as a system prompt section.
98
+ // This teaches the model how to write iterate workflow scripts using the
99
+ // tools. Guarded for the same reason as above: a prompt-injection failure
100
+ // must not undo a load that already wired the gate and the tools.
101
+ try {
102
+ ctx.systemPrompt.section({
103
+ name: 'iterate-skill',
104
+ order: 100,
105
+ text: ITERATE_SKILL_PROMPT,
106
+ })
107
+ } catch (err) {
108
+ console.warn('[iterate] failed to inject the skill prompt section; continuing.', err)
109
+ }
86
110
  }
package/src/jobs.ts CHANGED
@@ -11,10 +11,19 @@
11
11
  * - `ctx.jobs` only exists when the dsh host loaded a job registry + a
12
12
  * controller serves the calling owner (`@deepseek-ai/dsh-tool-jobs` or an
13
13
  * equivalent). When it is missing, `start()` throws or is absent — we
14
- * detect both and fall through to plain execution, so the Job Panel is a
15
- * pure enhancement and never breaks a tool call.
14
+ * detect both (including a throwing `start` GETTER) and fall through to
15
+ * plain execution, so the Job Panel is a pure enhancement and never breaks
16
+ * a tool call.
16
17
  * - The registry is memory-only and panel rows are read-only (no progress
17
18
  * updates), so these jobs are completion records, not control channels.
19
+ * - Cancellation is honest, not theatrical. `JobHooks.cancel` is REQUIRED by
20
+ * `@deepseek-ai/dsh-jobs` (the registry calls it on kill/teardown), but the
21
+ * wrapped tool `fn` exposes no abort channel — a panel kill cannot stop it.
22
+ * So `cancel` records the REQUEST (synchronous + idempotent, first reason
23
+ * wins), best-effort surfaces it on the record's progress line, and lets the
24
+ * job settle with `fn`'s TRUE outcome, with the request annotated in
25
+ * `detail`. Settling the record `killed` while `fn()` keeps running would
26
+ * show a stopped job that is still running — a lie in the Job Panel.
18
27
  */
19
28
 
20
29
  import type { JobOutcome, JobRegistry } from '@deepseek-ai/dsh-jobs'
@@ -30,12 +39,18 @@ declare module '@deepseek-ai/dsh-jobs' {
30
39
  /** Custom job kinds this plugin registers. */
31
40
  export type IterateJobKind = 'iterate-review' | 'iterate-fix'
32
41
 
42
+ /** The `JobHandle` surface we use (the registry passes the real one). */
43
+ interface JobHandleLike {
44
+ updateProgress?(line: string): void
45
+ }
46
+
33
47
  /** Shape of the `ctx.jobs` surface we rely on (duck-typed for safety). */
34
48
  interface JobsLike {
35
49
  start(spec: {
36
50
  kind: IterateJobKind
37
51
  label: string
38
- run(): { done: Promise<JobOutcome>; cancel?: () => void }
52
+ // `cancel` mirrors JobHooks: required, synchronous, idempotent.
53
+ run(job: JobHandleLike): { done: Promise<JobOutcome>; cancel: (reason?: string) => void }
39
54
  }): string
40
55
  }
41
56
 
@@ -53,11 +68,25 @@ function safeJobs(ctx: unknown): JobsLike | undefined {
53
68
  }
54
69
  }
55
70
 
71
+ /**
72
+ * Read `jobs.start` defensively: the `start` GETTER itself can throw on a
73
+ * hostile/proxied registry, and that read must degrade the same way as a
74
+ * missing registry — never escape `runWithJob`.
75
+ */
76
+ function safeStart(jobs: JobsLike | undefined): JobsLike['start'] | undefined {
77
+ try {
78
+ return jobs?.start
79
+ } catch {
80
+ return undefined
81
+ }
82
+ }
83
+
56
84
  /**
57
85
  * Run `fn` wrapped in a dsh background job, settling it completed/failed
58
- * with the execution's outcome. When the host exposes no job registry (or
59
- * refuses the start), `fn` runs untouched and `null` is returned — the Job
60
- * Panel is an enhancement, never a dependency.
86
+ * with the execution's ACTUAL outcome (a requested cancel never falsifies it
87
+ * — see the header). When the host exposes no job registry (or refuses the
88
+ * start), `fn` runs untouched and `null` is returned — the Job Panel is an
89
+ * enhancement, never a dependency.
61
90
  *
62
91
  * @param ctx the dsh plugin context (may or may not expose `jobs`).
63
92
  * @param kind iterate job kind registered via {@link IterateJobKind}.
@@ -72,7 +101,8 @@ export async function runWithJob<T>(
72
101
  fn: () => Promise<T> | T,
73
102
  ): Promise<{ result: T; jobId: string | null }> {
74
103
  const jobs = safeJobs(ctx)
75
- if (!jobs || typeof jobs.start !== 'function') {
104
+ const start = safeStart(jobs)
105
+ if (!jobs || !start || typeof start !== 'function') {
76
106
  return { result: await fn(), jobId: null }
77
107
  }
78
108
 
@@ -81,14 +111,35 @@ export async function runWithJob<T>(
81
111
  settle = resolve
82
112
  })
83
113
 
114
+ // Honest cancel bookkeeping: the request is recorded (first reason wins),
115
+ // but `done` is NOT settled here — `fn()` is still running and the panel
116
+ // must not claim otherwise. The settlement below carries `fn`'s real
117
+ // outcome, annotated with the request.
118
+ let cancelRequested: string | null = null
119
+ const annotate = (detail: string): string =>
120
+ cancelRequested === null
121
+ ? detail
122
+ : `${detail} (cancel requested: ${cancelRequested} — the wrapped tool call has no abort channel and ran to completion)`
123
+
84
124
  let jobId: string | null = null
85
125
  try {
86
- jobId = jobs.start({
126
+ jobId = start.call(jobs, {
87
127
  kind,
88
128
  label,
89
- run: () => ({
129
+ run: (job: JobHandleLike) => ({
90
130
  done,
91
- cancel: () => settle({ status: 'killed', detail: 'cancelled' }),
131
+ cancel: (reason?: string) => {
132
+ if (cancelRequested !== null) return // idempotent — first reason wins
133
+ cancelRequested = reason ?? 'cancel requested'
134
+ // Best-effort panel hint while the record is stopping; the registry
135
+ // drops writes it cannot apply, and a throwing update must not
136
+ // propagate out of the registry's kill path.
137
+ try {
138
+ job?.updateProgress?.('cancel requested — waiting for the tool call to finish (no abort channel)')
139
+ } catch {
140
+ /* progress hint is cosmetic */
141
+ }
142
+ },
92
143
  }),
93
144
  })
94
145
  } catch {
@@ -99,12 +150,12 @@ export async function runWithJob<T>(
99
150
 
100
151
  try {
101
152
  const result = await fn()
102
- settle({ status: 'completed', detail: 'done' })
153
+ settle({ status: 'completed', detail: annotate('done') })
103
154
  return { result, jobId }
104
155
  } catch (error) {
105
156
  settle({
106
157
  status: 'failed',
107
- detail: error instanceof Error ? error.message : 'execution failed',
158
+ detail: annotate(error instanceof Error ? error.message : 'execution failed'),
108
159
  })
109
160
  throw error
110
161
  }
package/src/live.ts CHANGED
@@ -18,6 +18,9 @@
18
18
  * Safety:
19
19
  * - Read-only observer: never mutates source files; writes only the NDJSON
20
20
  * live file under `.iterate/`.
21
+ * - Privacy switch: when the project's effective config sets
22
+ * `observatory.capture: false`, nothing is persisted (the file stays
23
+ * absent/unchanged). An unreadable config keeps capture ON (defaults).
21
24
  * - The live file is byte-capped (rewrite to last N lines when it grows too
22
25
  * large) so it can never grow unbounded.
23
26
  * - Any capture failure is swallowed (fire-and-forget) so it can never block
@@ -30,7 +33,7 @@ import { writeTextAtomicAsync } from './atomic-fs.ts'
30
33
  import { join } from 'node:path'
31
34
  import type { Context } from '@deepseek-ai/cordis'
32
35
  import type { ToolExecution } from '@deepseek-ai/dsh-tools'
33
- import { resolveProjectRoot } from './config-loader.ts'
36
+ import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
34
37
 
35
38
  /** Keep at most this many live activity entries. */
36
39
  export const LIVE_MAX_ENTRIES = 300
@@ -85,14 +88,40 @@ function enqueueLiveWrite(task: () => Promise<void>): Promise<void> {
85
88
  return next
86
89
  }
87
90
 
88
- /** Append one activity record to the project's live feed (byte-capped). */
91
+ /**
92
+ * True when the project's effective config still allows live capture.
93
+ * `observatory.capture: false` opts the project out — nothing is persisted.
94
+ * Failure modes keep TODAY'S behavior: `loadEffectiveConfig` never throws on
95
+ * an unreadable config (it falls back to defaults, capture on), and any
96
+ * unexpected loader error here also degrades to capture ON rather than
97
+ * silently eating activity the operator expected to see.
98
+ */
99
+ function captureEnabled(projectRoot: string): boolean {
100
+ try {
101
+ return loadEffectiveConfig(projectRoot).config.observatory?.capture !== false
102
+ } catch {
103
+ return true
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Append one activity record to the project's live feed (byte-capped).
109
+ * Never rejects: every failure (capture disabled, `.iterate` existing as a
110
+ * regular file, locked/unwritable feed) is swallowed — the header promises
111
+ * this path can never crash or block a tool call (`void appendLive(...)`
112
+ * would otherwise turn a rejection into an unhandled rejection).
113
+ */
89
114
  export function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
90
115
  return enqueueLiveWrite(async () => {
91
- const file = liveFilePath(projectRoot)
92
- const line = JSON.stringify(entry) + '\n'
93
- await mkdir(join(projectRoot, '.iterate'), { recursive: true })
94
- // Amortized O(1): only read+rewrite when the file has grown past the cap.
95
116
  try {
117
+ // Privacy: capture off → do not touch the feed at all.
118
+ if (!captureEnabled(projectRoot)) return
119
+ const file = liveFilePath(projectRoot)
120
+ const line = JSON.stringify(entry) + '\n'
121
+ // mkdir is INSIDE the guard: `.iterate` may be a regular file (or the
122
+ // dir unwritable) — it must reject neither the caller nor the queue.
123
+ await mkdir(join(projectRoot, '.iterate'), { recursive: true })
124
+ // Amortized O(1): only read+rewrite when the file has grown past the cap.
96
125
  const st = await stat(file).catch(() => null)
97
126
  if (st && st.size > LIVE_MAX_BYTES) {
98
127
  const raw = await readFile(file, 'utf-8')
@@ -201,10 +230,18 @@ export async function readLive(projectRoot: string): Promise<LiveActivityEntry[]
201
230
  */
202
231
  export function registerLiveCapture(ctx: Context): void {
203
232
  ctx.on('tools/result', (exec: ToolExecution) => {
204
- const root = projectRootOf(exec)
205
- if (!root) return
206
- const entry = classifyTool(exec.name, exec.arguments, root)
207
- if (!entry) return
208
- void appendLive(root, entry)
233
+ // Defensive: a hostile/proxied exec whose getters throw (`agent`,
234
+ // `name`, `arguments`) must not abort the observer — unlike the guarded
235
+ // siblings below, an unguarded throw here would lose the record and
236
+ // surface an error from a read-only hook. Degrade to a no-op.
237
+ try {
238
+ const root = projectRootOf(exec)
239
+ if (!root) return
240
+ const entry = classifyTool(exec.name, exec.arguments, root)
241
+ if (!entry) return
242
+ void appendLive(root, entry)
243
+ } catch {
244
+ // An observer must never throw.
245
+ }
209
246
  })
210
247
  }