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
@@ -0,0 +1,211 @@
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
+ return makeLockRelease(lockPath)
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Build the release function for a lock file this process just acquired.
160
+ *
161
+ * Advisory locks here are STOLEN when the recorded owner dies or the mtime
162
+ * goes ancient: the steal unlinks our file and creates a new one stamped with
163
+ * the STEALER's pid. An unguarded release would then unlink the new holder's
164
+ * lock — turning our release into a double-release that admits a third process
165
+ * into a "held" critical section. So the release re-reads the stamp and only
166
+ * unlinks a file that is still ours (same pid) or unstamped (the shape left
167
+ * when our own advisory pid write failed, or when the content is not a pid).
168
+ *
169
+ * Always callable, idempotent, and never throws.
170
+ */
171
+ export function makeLockRelease(lockPath: string): () => void {
172
+ let released = false
173
+ return () => {
174
+ if (released) return
175
+ released = true
176
+ let raw = ''
177
+ try {
178
+ raw = readFileSync(lockPath, 'utf-8').trim()
179
+ } catch {
180
+ // Already gone (or unreadable) — nothing of ours left to remove.
181
+ return
182
+ }
183
+ // A stamp naming another pid means the lock was stolen and reassigned:
184
+ // it is no longer ours to delete.
185
+ if (raw !== '' && Number(raw) !== process.pid) return
186
+ try {
187
+ unlinkSync(lockPath)
188
+ } catch {
189
+ // already gone
190
+ }
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Run `fn` while holding the advisory lock named `name`.
196
+ * The release is guaranteed via `finally`; `fn`'s value or error propagates
197
+ * unchanged. Not reentrant for the same (projectRoot, name) — see header.
198
+ */
199
+ export function withProjectLock<T>(
200
+ projectRoot: string,
201
+ name: string,
202
+ fn: () => T,
203
+ options: LockOptions = {},
204
+ ): T {
205
+ const release = acquireProjectLock(projectRoot, name, options)
206
+ try {
207
+ return fn()
208
+ } finally {
209
+ release()
210
+ }
211
+ }
package/src/index.ts CHANGED
@@ -45,42 +45,73 @@ import { registerQualityGateTool } from './tools/quality-gate.ts'
45
45
  import { registerDefenseEventsTool } from './tools/defense-events.ts'
46
46
  import { registerSessionHooks } from './session-hooks.ts'
47
47
  import { registerLiveCapture } from './live.ts'
48
+ import { installWorkingDirectoryContext } from './working-directory.ts'
48
49
  import { ITERATE_SKILL_PROMPT } from './skill-prompt.ts'
49
50
 
50
51
  export const name = 'iterate-plugin'
51
52
  export const inject = ['tools', 'systemPrompt'] as const
52
53
 
53
54
  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.
55
+ // 0. Wire the SAFETY hooks BEFORE any tool is registered.
56
+ // Registration is a list of 17 independent calls; if one of them threw
57
+ // (duplicate name, malformed definition, hostile ctx) while the gate was
58
+ // registered last, the tools registered up to that point would keep
59
+ // running with NO `tools/pre-execute` approval gate — a fail-open hole
60
+ // created by load order. Registering the gate first means a partial load
61
+ // can only ever err on the side of MORE gating, never less.
62
+ //
63
+ // The working-directory context is installed first for the same reason:
64
+ // every cwd read below (gate policy, live capture, tool project roots)
65
+ // must see dsh 0.2.1-alpha.2+ directory switches, and installing it last
66
+ // would leave a partially-loaded plugin resolving the wrong workspace.
67
+ installWorkingDirectoryContext(ctx)
76
68
  registerSessionHooks(ctx)
77
69
  registerLiveCapture(ctx)
78
70
 
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
- })
71
+ // 1. Register the 17 tools (14 original + 3 v3.0). Each registration is
72
+ // isolated: one failing tool must not take down the other 16, the gate
73
+ // above, or the prompt below (a partial plugin that still gates and
74
+ // teaches the workflow beats an aborted `apply`). Failures are logged,
75
+ // never swallowed silently.
76
+ const toolRegistrations: Array<[string, () => void]> = [
77
+ ['iterate_config', () => registerConfigTool(ctx)],
78
+ ['iterate_validate', () => registerValidateTool(ctx)],
79
+ ['iterate_decision_log', () => registerDecisionLogTool(ctx)],
80
+ ['iterate_context', () => registerContextTool(ctx)],
81
+ ['iterate_review', () => registerReviewTool(ctx)],
82
+ ['iterate_triage', () => registerTriageTool(ctx)],
83
+ ['iterate_fix', () => registerFixTool(ctx)],
84
+ ['iterate_diff', () => registerDiffTool(ctx)],
85
+ ['iterate_rollback', () => registerRollbackTool(ctx)],
86
+ ['iterate_checkpoint', () => registerCheckpointTool(ctx)],
87
+ ['iterate_status', () => registerStatusTool(ctx)],
88
+ ['iterate_history', () => registerHistoryTool(ctx)],
89
+ ['iterate_prune', () => registerPruneTool(ctx)],
90
+ ['iterate_transcript', () => registerTranscriptTool(ctx)],
91
+ // v3.0: Quality Command Center tools
92
+ ['iterate_experience', () => registerExperienceBankTool(ctx)],
93
+ ['iterate_quality_gate', () => registerQualityGateTool(ctx)],
94
+ ['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
95
+ ]
96
+ for (const [toolName, register] of toolRegistrations) {
97
+ try {
98
+ register()
99
+ } catch (err) {
100
+ console.warn(`[iterate] failed to register ${toolName}; continuing.`, err)
101
+ }
102
+ }
103
+
104
+ // 2. Inject the iterate skill prompt as a system prompt section.
105
+ // This teaches the model how to write iterate workflow scripts using the
106
+ // tools. Guarded for the same reason as above: a prompt-injection failure
107
+ // must not undo a load that already wired the gate and the tools.
108
+ try {
109
+ ctx.systemPrompt.section({
110
+ name: 'iterate-skill',
111
+ order: 100,
112
+ text: ITERATE_SKILL_PROMPT,
113
+ })
114
+ } catch (err) {
115
+ console.warn('[iterate] failed to inject the skill prompt section; continuing.', err)
116
+ }
86
117
  }
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,8 @@ 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'
37
+ import { sessionCwdOf } from './working-directory.ts'
34
38
 
35
39
  /** Keep at most this many live activity entries. */
36
40
  export const LIVE_MAX_ENTRIES = 300
@@ -85,14 +89,40 @@ function enqueueLiveWrite(task: () => Promise<void>): Promise<void> {
85
89
  return next
86
90
  }
87
91
 
88
- /** Append one activity record to the project's live feed (byte-capped). */
92
+ /**
93
+ * True when the project's effective config still allows live capture.
94
+ * `observatory.capture: false` opts the project out — nothing is persisted.
95
+ * Failure modes keep TODAY'S behavior: `loadEffectiveConfig` never throws on
96
+ * an unreadable config (it falls back to defaults, capture on), and any
97
+ * unexpected loader error here also degrades to capture ON rather than
98
+ * silently eating activity the operator expected to see.
99
+ */
100
+ function captureEnabled(projectRoot: string): boolean {
101
+ try {
102
+ return loadEffectiveConfig(projectRoot).config.observatory?.capture !== false
103
+ } catch {
104
+ return true
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Append one activity record to the project's live feed (byte-capped).
110
+ * Never rejects: every failure (capture disabled, `.iterate` existing as a
111
+ * regular file, locked/unwritable feed) is swallowed — the header promises
112
+ * this path can never crash or block a tool call (`void appendLive(...)`
113
+ * would otherwise turn a rejection into an unhandled rejection).
114
+ */
89
115
  export function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
90
116
  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
117
  try {
118
+ // Privacy: capture off → do not touch the feed at all.
119
+ if (!captureEnabled(projectRoot)) return
120
+ const file = liveFilePath(projectRoot)
121
+ const line = JSON.stringify(entry) + '\n'
122
+ // mkdir is INSIDE the guard: `.iterate` may be a regular file (or the
123
+ // dir unwritable) — it must reject neither the caller nor the queue.
124
+ await mkdir(join(projectRoot, '.iterate'), { recursive: true })
125
+ // Amortized O(1): only read+rewrite when the file has grown past the cap.
96
126
  const st = await stat(file).catch(() => null)
97
127
  if (st && st.size > LIVE_MAX_BYTES) {
98
128
  const raw = await readFile(file, 'utf-8')
@@ -111,7 +141,7 @@ export function appendLive(projectRoot: string, entry: LiveActivityEntry): Promi
111
141
 
112
142
  /** Resolve the project root a tool execution belongs to, if any. */
113
143
  function projectRootOf(exec: ToolExecution): string | null {
114
- const cwd = exec.agent?.session?.header?.cwd
144
+ const cwd = sessionCwdOf(exec)
115
145
  if (!cwd) return null
116
146
  const resolved = resolveProjectRoot(undefined, cwd)
117
147
  return resolved.ok ? resolved.root : null
@@ -201,10 +231,18 @@ export async function readLive(projectRoot: string): Promise<LiveActivityEntry[]
201
231
  */
202
232
  export function registerLiveCapture(ctx: Context): void {
203
233
  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)
234
+ // Defensive: a hostile/proxied exec whose getters throw (`agent`,
235
+ // `name`, `arguments`) must not abort the observer — unlike the guarded
236
+ // siblings below, an unguarded throw here would lose the record and
237
+ // surface an error from a read-only hook. Degrade to a no-op.
238
+ try {
239
+ const root = projectRootOf(exec)
240
+ if (!root) return
241
+ const entry = classifyTool(exec.name, exec.arguments, root)
242
+ if (!entry) return
243
+ void appendLive(root, entry)
244
+ } catch {
245
+ // An observer must never throw.
246
+ }
209
247
  })
210
248
  }