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,193 @@
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
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync, } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ const LOCK_DIR = '.iterate';
32
+ const DEFAULT_WAIT_MS = 5000;
33
+ const DEFAULT_STALE_MS = 10000;
34
+ const POLL_MS = 25;
35
+ /** True when a pid refers to a live process (or we lack permission to tell). */
36
+ function processAlive(pid) {
37
+ if (!Number.isInteger(pid) || pid <= 0)
38
+ return false;
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ }
43
+ catch (err) {
44
+ return err.code === 'EPERM';
45
+ }
46
+ }
47
+ /** Synchronous sleep (Atomics.wait is a reliable sleep in Node). */
48
+ function sleepSync(ms) {
49
+ const sab = new Int32Array(new SharedArrayBuffer(4));
50
+ Atomics.wait(sab, 0, 0, ms);
51
+ }
52
+ /**
53
+ * Validate a lock name into a safe file-name fragment.
54
+ * Throws on anything that could escape `.iterate/` (path separators, dots).
55
+ */
56
+ function lockFileName(name) {
57
+ if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
58
+ throw new Error(`invalid lock name: ${JSON.stringify(name)}`);
59
+ }
60
+ return `.${name}.lock`;
61
+ }
62
+ /**
63
+ * Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
64
+ *
65
+ * Returns a release function: always callable, idempotent, never throws.
66
+ * A no-op (but callable) release is returned when the lock directory cannot be
67
+ * created or the wait times out — callers then proceed unlocked, which is the
68
+ * historical best-effort behavior, rather than failing the operation.
69
+ */
70
+ export function acquireProjectLock(projectRoot, name, options = {}) {
71
+ const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
72
+ ? options.waitMs
73
+ : DEFAULT_WAIT_MS;
74
+ const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
75
+ ? options.staleMs
76
+ : DEFAULT_STALE_MS;
77
+ const dir = join(projectRoot, LOCK_DIR);
78
+ if (!existsSync(dir)) {
79
+ try {
80
+ mkdirSync(dir, { recursive: true });
81
+ }
82
+ catch {
83
+ return () => { };
84
+ }
85
+ }
86
+ const lockPath = join(dir, lockFileName(name));
87
+ const start = Date.now();
88
+ for (;;) {
89
+ let fd = null;
90
+ try {
91
+ fd = openSync(lockPath, 'wx');
92
+ }
93
+ catch (err) {
94
+ if (err.code !== 'EEXIST')
95
+ return () => { };
96
+ // Lock exists — steal it when the owner is gone or the lock is ancient.
97
+ let stale = false;
98
+ try {
99
+ const st = statSync(lockPath);
100
+ if (Date.now() - st.mtimeMs > staleMs)
101
+ stale = true;
102
+ else {
103
+ const pid = Number(readFileSync(lockPath, 'utf-8'));
104
+ if (!processAlive(pid))
105
+ stale = true;
106
+ }
107
+ }
108
+ catch {
109
+ stale = true; // unreadable/vanishing lock → retry as stale
110
+ }
111
+ if (stale) {
112
+ try {
113
+ unlinkSync(lockPath);
114
+ }
115
+ catch {
116
+ // another holder stole it first — retry immediately
117
+ }
118
+ continue;
119
+ }
120
+ if (Date.now() - start > waitMs)
121
+ return () => { };
122
+ sleepSync(POLL_MS);
123
+ continue;
124
+ }
125
+ // Owned the lock: stamp the holder pid, then release once on return.
126
+ try {
127
+ writeSync(fd, String(process.pid));
128
+ }
129
+ catch {
130
+ // pid stamp is advisory
131
+ }
132
+ try {
133
+ closeSync(fd);
134
+ }
135
+ catch {
136
+ // best-effort
137
+ }
138
+ return makeLockRelease(lockPath);
139
+ }
140
+ }
141
+ /**
142
+ * Build the release function for a lock file this process just acquired.
143
+ *
144
+ * Advisory locks here are STOLEN when the recorded owner dies or the mtime
145
+ * goes ancient: the steal unlinks our file and creates a new one stamped with
146
+ * the STEALER's pid. An unguarded release would then unlink the new holder's
147
+ * lock — turning our release into a double-release that admits a third process
148
+ * into a "held" critical section. So the release re-reads the stamp and only
149
+ * unlinks a file that is still ours (same pid) or unstamped (the shape left
150
+ * when our own advisory pid write failed, or when the content is not a pid).
151
+ *
152
+ * Always callable, idempotent, and never throws.
153
+ */
154
+ export function makeLockRelease(lockPath) {
155
+ let released = false;
156
+ return () => {
157
+ if (released)
158
+ return;
159
+ released = true;
160
+ let raw = '';
161
+ try {
162
+ raw = readFileSync(lockPath, 'utf-8').trim();
163
+ }
164
+ catch {
165
+ // Already gone (or unreadable) — nothing of ours left to remove.
166
+ return;
167
+ }
168
+ // A stamp naming another pid means the lock was stolen and reassigned:
169
+ // it is no longer ours to delete.
170
+ if (raw !== '' && Number(raw) !== process.pid)
171
+ return;
172
+ try {
173
+ unlinkSync(lockPath);
174
+ }
175
+ catch {
176
+ // already gone
177
+ }
178
+ };
179
+ }
180
+ /**
181
+ * Run `fn` while holding the advisory lock named `name`.
182
+ * The release is guaranteed via `finally`; `fn`'s value or error propagates
183
+ * unchanged. Not reentrant for the same (projectRoot, name) — see header.
184
+ */
185
+ export function withProjectLock(projectRoot, name, fn, options = {}) {
186
+ const release = acquireProjectLock(projectRoot, name, options);
187
+ try {
188
+ return fn();
189
+ }
190
+ finally {
191
+ release();
192
+ }
193
+ }
package/dist/index.js CHANGED
@@ -43,38 +43,71 @@ import { registerQualityGateTool } from "./tools/quality-gate.js";
43
43
  import { registerDefenseEventsTool } from "./tools/defense-events.js";
44
44
  import { registerSessionHooks } from "./session-hooks.js";
45
45
  import { registerLiveCapture } from "./live.js";
46
+ import { installWorkingDirectoryContext } from "./working-directory.js";
46
47
  import { ITERATE_SKILL_PROMPT } from "./skill-prompt.js";
47
48
  export const name = 'iterate-plugin';
48
49
  export const inject = ['tools', 'systemPrompt'];
49
50
  export function apply(ctx) {
50
- // 1. Register the 17 tools (14 original + 3 v3.0)
51
- registerConfigTool(ctx);
52
- registerValidateTool(ctx);
53
- registerDecisionLogTool(ctx);
54
- registerContextTool(ctx);
55
- registerReviewTool(ctx);
56
- registerTriageTool(ctx);
57
- registerFixTool(ctx);
58
- registerDiffTool(ctx);
59
- registerRollbackTool(ctx);
60
- registerCheckpointTool(ctx);
61
- registerStatusTool(ctx);
62
- registerHistoryTool(ctx);
63
- registerPruneTool(ctx);
64
- registerTranscriptTool(ctx);
65
- // v3.0: Quality Command Center tools
66
- registerExperienceBankTool(ctx);
67
- registerQualityGateTool(ctx);
68
- registerDefenseEventsTool(ctx);
69
- // 2. Wire the observatory approval gate onto dsh's tools/pre-execute waterfall,
70
- // and the live reviewer-activity feed onto tools/result.
51
+ // 0. Wire the SAFETY hooks BEFORE any tool is registered.
52
+ // Registration is a list of 17 independent calls; if one of them threw
53
+ // (duplicate name, malformed definition, hostile ctx) while the gate was
54
+ // registered last, the tools registered up to that point would keep
55
+ // running with NO `tools/pre-execute` approval gate — a fail-open hole
56
+ // created by load order. Registering the gate first means a partial load
57
+ // can only ever err on the side of MORE gating, never less.
58
+ //
59
+ // The working-directory context is installed first for the same reason:
60
+ // every cwd read below (gate policy, live capture, tool project roots)
61
+ // must see dsh 0.2.1-alpha.2+ directory switches, and installing it last
62
+ // would leave a partially-loaded plugin resolving the wrong workspace.
63
+ installWorkingDirectoryContext(ctx);
71
64
  registerSessionHooks(ctx);
72
65
  registerLiveCapture(ctx);
73
- // 2. Inject the iterate skill prompt as a system prompt section
74
- // This teaches the model how to write iterate workflow scripts using the tools.
75
- ctx.systemPrompt.section({
76
- name: 'iterate-skill',
77
- order: 100,
78
- text: ITERATE_SKILL_PROMPT,
79
- });
66
+ // 1. Register the 17 tools (14 original + 3 v3.0). Each registration is
67
+ // isolated: one failing tool must not take down the other 16, the gate
68
+ // above, or the prompt below (a partial plugin that still gates and
69
+ // teaches the workflow beats an aborted `apply`). Failures are logged,
70
+ // never swallowed silently.
71
+ const toolRegistrations = [
72
+ ['iterate_config', () => registerConfigTool(ctx)],
73
+ ['iterate_validate', () => registerValidateTool(ctx)],
74
+ ['iterate_decision_log', () => registerDecisionLogTool(ctx)],
75
+ ['iterate_context', () => registerContextTool(ctx)],
76
+ ['iterate_review', () => registerReviewTool(ctx)],
77
+ ['iterate_triage', () => registerTriageTool(ctx)],
78
+ ['iterate_fix', () => registerFixTool(ctx)],
79
+ ['iterate_diff', () => registerDiffTool(ctx)],
80
+ ['iterate_rollback', () => registerRollbackTool(ctx)],
81
+ ['iterate_checkpoint', () => registerCheckpointTool(ctx)],
82
+ ['iterate_status', () => registerStatusTool(ctx)],
83
+ ['iterate_history', () => registerHistoryTool(ctx)],
84
+ ['iterate_prune', () => registerPruneTool(ctx)],
85
+ ['iterate_transcript', () => registerTranscriptTool(ctx)],
86
+ // v3.0: Quality Command Center tools
87
+ ['iterate_experience', () => registerExperienceBankTool(ctx)],
88
+ ['iterate_quality_gate', () => registerQualityGateTool(ctx)],
89
+ ['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
90
+ ];
91
+ for (const [toolName, register] of toolRegistrations) {
92
+ try {
93
+ register();
94
+ }
95
+ catch (err) {
96
+ console.warn(`[iterate] failed to register ${toolName}; continuing.`, err);
97
+ }
98
+ }
99
+ // 2. Inject the iterate skill prompt as a system prompt section.
100
+ // This teaches the model how to write iterate workflow scripts using the
101
+ // tools. Guarded for the same reason as above: a prompt-injection failure
102
+ // must not undo a load that already wired the gate and the tools.
103
+ try {
104
+ ctx.systemPrompt.section({
105
+ name: 'iterate-skill',
106
+ order: 100,
107
+ text: ITERATE_SKILL_PROMPT,
108
+ });
109
+ }
110
+ catch (err) {
111
+ console.warn('[iterate] failed to inject the skill prompt section; continuing.', err);
112
+ }
80
113
  }
package/dist/jobs.js 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
  * Read `ctx.jobs` defensively: the dsh plugin context can be a Proxy whose
@@ -30,11 +39,25 @@ function safeJobs(ctx) {
30
39
  return undefined;
31
40
  }
32
41
  }
42
+ /**
43
+ * Read `jobs.start` defensively: the `start` GETTER itself can throw on a
44
+ * hostile/proxied registry, and that read must degrade the same way as a
45
+ * missing registry — never escape `runWithJob`.
46
+ */
47
+ function safeStart(jobs) {
48
+ try {
49
+ return jobs?.start;
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
33
55
  /**
34
56
  * Run `fn` wrapped in a dsh background job, settling it completed/failed
35
- * with the execution's outcome. When the host exposes no job registry (or
36
- * refuses the start), `fn` runs untouched and `null` is returned — the Job
37
- * Panel is an enhancement, never a dependency.
57
+ * with the execution's ACTUAL outcome (a requested cancel never falsifies it
58
+ * — see the header). When the host exposes no job registry (or refuses the
59
+ * start), `fn` runs untouched and `null` is returned — the Job Panel is an
60
+ * enhancement, never a dependency.
38
61
  *
39
62
  * @param ctx the dsh plugin context (may or may not expose `jobs`).
40
63
  * @param kind iterate job kind registered via {@link IterateJobKind}.
@@ -44,21 +67,43 @@ function safeJobs(ctx) {
44
67
  */
45
68
  export async function runWithJob(ctx, kind, label, fn) {
46
69
  const jobs = safeJobs(ctx);
47
- if (!jobs || typeof jobs.start !== 'function') {
70
+ const start = safeStart(jobs);
71
+ if (!jobs || !start || typeof start !== 'function') {
48
72
  return { result: await fn(), jobId: null };
49
73
  }
50
74
  let settle;
51
75
  const done = new Promise((resolve) => {
52
76
  settle = resolve;
53
77
  });
78
+ // Honest cancel bookkeeping: the request is recorded (first reason wins),
79
+ // but `done` is NOT settled here — `fn()` is still running and the panel
80
+ // must not claim otherwise. The settlement below carries `fn`'s real
81
+ // outcome, annotated with the request.
82
+ let cancelRequested = null;
83
+ const annotate = (detail) => cancelRequested === null
84
+ ? detail
85
+ : `${detail} (cancel requested: ${cancelRequested} — the wrapped tool call has no abort channel and ran to completion)`;
54
86
  let jobId = null;
55
87
  try {
56
- jobId = jobs.start({
88
+ jobId = start.call(jobs, {
57
89
  kind,
58
90
  label,
59
- run: () => ({
91
+ run: (job) => ({
60
92
  done,
61
- cancel: () => settle({ status: 'killed', detail: 'cancelled' }),
93
+ cancel: (reason) => {
94
+ if (cancelRequested !== null)
95
+ return; // idempotent — first reason wins
96
+ cancelRequested = reason ?? 'cancel requested';
97
+ // Best-effort panel hint while the record is stopping; the registry
98
+ // drops writes it cannot apply, and a throwing update must not
99
+ // propagate out of the registry's kill path.
100
+ try {
101
+ job?.updateProgress?.('cancel requested — waiting for the tool call to finish (no abort channel)');
102
+ }
103
+ catch {
104
+ /* progress hint is cosmetic */
105
+ }
106
+ },
62
107
  }),
63
108
  });
64
109
  }
@@ -69,13 +114,13 @@ export async function runWithJob(ctx, kind, label, fn) {
69
114
  }
70
115
  try {
71
116
  const result = await fn();
72
- settle({ status: 'completed', detail: 'done' });
117
+ settle({ status: 'completed', detail: annotate('done') });
73
118
  return { result, jobId };
74
119
  }
75
120
  catch (error) {
76
121
  settle({
77
122
  status: 'failed',
78
- detail: error instanceof Error ? error.message : 'execution failed',
123
+ detail: annotate(error instanceof Error ? error.message : 'execution failed'),
79
124
  });
80
125
  throw error;
81
126
  }
package/dist/live.js 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
@@ -27,7 +30,8 @@ import { mkdir, readFile, stat, appendFile } from 'node:fs/promises';
27
30
  import { existsSync } from 'node:fs';
28
31
  import { writeTextAtomicAsync } from "./atomic-fs.js";
29
32
  import { join } from 'node:path';
30
- import { resolveProjectRoot } from "./config-loader.js";
33
+ import { loadEffectiveConfig, resolveProjectRoot } from "./config-loader.js";
34
+ import { sessionCwdOf } from "./working-directory.js";
31
35
  /** Keep at most this many live activity entries. */
32
36
  export const LIVE_MAX_ENTRIES = 300;
33
37
  /** Rewrite the live file when its byte size exceeds this threshold. */
@@ -51,14 +55,41 @@ function enqueueLiveWrite(task) {
51
55
  liveWriteQueue = next.catch(() => { });
52
56
  return next;
53
57
  }
54
- /** Append one activity record to the project's live feed (byte-capped). */
58
+ /**
59
+ * True when the project's effective config still allows live capture.
60
+ * `observatory.capture: false` opts the project out — nothing is persisted.
61
+ * Failure modes keep TODAY'S behavior: `loadEffectiveConfig` never throws on
62
+ * an unreadable config (it falls back to defaults, capture on), and any
63
+ * unexpected loader error here also degrades to capture ON rather than
64
+ * silently eating activity the operator expected to see.
65
+ */
66
+ function captureEnabled(projectRoot) {
67
+ try {
68
+ return loadEffectiveConfig(projectRoot).config.observatory?.capture !== false;
69
+ }
70
+ catch {
71
+ return true;
72
+ }
73
+ }
74
+ /**
75
+ * Append one activity record to the project's live feed (byte-capped).
76
+ * Never rejects: every failure (capture disabled, `.iterate` existing as a
77
+ * regular file, locked/unwritable feed) is swallowed — the header promises
78
+ * this path can never crash or block a tool call (`void appendLive(...)`
79
+ * would otherwise turn a rejection into an unhandled rejection).
80
+ */
55
81
  export function appendLive(projectRoot, entry) {
56
82
  return enqueueLiveWrite(async () => {
57
- const file = liveFilePath(projectRoot);
58
- const line = JSON.stringify(entry) + '\n';
59
- await mkdir(join(projectRoot, '.iterate'), { recursive: true });
60
- // Amortized O(1): only read+rewrite when the file has grown past the cap.
61
83
  try {
84
+ // Privacy: capture off → do not touch the feed at all.
85
+ if (!captureEnabled(projectRoot))
86
+ return;
87
+ const file = liveFilePath(projectRoot);
88
+ const line = JSON.stringify(entry) + '\n';
89
+ // mkdir is INSIDE the guard: `.iterate` may be a regular file (or the
90
+ // dir unwritable) — it must reject neither the caller nor the queue.
91
+ await mkdir(join(projectRoot, '.iterate'), { recursive: true });
92
+ // Amortized O(1): only read+rewrite when the file has grown past the cap.
62
93
  const st = await stat(file).catch(() => null);
63
94
  if (st && st.size > LIVE_MAX_BYTES) {
64
95
  const raw = await readFile(file, 'utf-8');
@@ -77,7 +108,7 @@ export function appendLive(projectRoot, entry) {
77
108
  }
78
109
  /** Resolve the project root a tool execution belongs to, if any. */
79
110
  function projectRootOf(exec) {
80
- const cwd = exec.agent?.session?.header?.cwd;
111
+ const cwd = sessionCwdOf(exec);
81
112
  if (!cwd)
82
113
  return null;
83
114
  const resolved = resolveProjectRoot(undefined, cwd);
@@ -167,12 +198,21 @@ export async function readLive(projectRoot) {
167
198
  */
168
199
  export function registerLiveCapture(ctx) {
169
200
  ctx.on('tools/result', (exec) => {
170
- const root = projectRootOf(exec);
171
- if (!root)
172
- return;
173
- const entry = classifyTool(exec.name, exec.arguments, root);
174
- if (!entry)
175
- return;
176
- void appendLive(root, entry);
201
+ // Defensive: a hostile/proxied exec whose getters throw (`agent`,
202
+ // `name`, `arguments`) must not abort the observer — unlike the guarded
203
+ // siblings below, an unguarded throw here would lose the record and
204
+ // surface an error from a read-only hook. Degrade to a no-op.
205
+ try {
206
+ const root = projectRootOf(exec);
207
+ if (!root)
208
+ return;
209
+ const entry = classifyTool(exec.name, exec.arguments, root);
210
+ if (!entry)
211
+ return;
212
+ void appendLive(root, entry);
213
+ }
214
+ catch {
215
+ // An observer must never throw.
216
+ }
177
217
  });
178
218
  }