iteration-cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,8 @@ The collectors run on macOS or Linux with Node.js 22 or later. Cursor's local Ag
11
11
 
12
12
  Cursor's **This Mac** Agents sessions are read from `~/.cursor/projects/*/agent-transcripts/<conversation>/<conversation>.jsonl`. Connecting Cursor also installs user-level hooks in `~/.cursor/hooks.json` automatically. They capture tool output, thought blocks, model and usage fields, subagent transcripts, and other lifecycle records that the main transcript omits; the collector queues those records locally before upload. Existing hooks are preserved, and `iteration disconnect cursor` removes only Iteration's entries. No Cursor approval or settings step is needed. A custom Cursor directory can be selected with `iteration connect cursor --cursor-home DIR`. Local history predating connection has transcript messages and tool calls, but tool results that Cursor never wrote to its transcript cannot be recovered. Cloud Agents are outside this local collector.
13
13
 
14
+ The collector replaces detected secrets with `[REDACTED]` before writing trace events to the upload outbox. It also redacts pending events left by an older collector before uploading them. This applies to Codex, Claude Code, and Cursor, including Cursor subagent transcript bytes stored as base64. Detection is best effort; original local agent files and previously uploaded traces are unchanged.
15
+
14
16
  Claude Code sessions are read from `~/.claude/projects`, including session subagents. If Claude uses `CLAUDE_CONFIG_DIR`, the collector follows that setting during connection; `iteration connect claude --claude-home DIR` selects another directory explicitly. The collector reads only saved local JSONL records. Claude Code may remove old transcripts under its own retention settings, so connect before local history is removed. The records can contain prompts, reasoning, tool input and output, and context snapshots; workspace members can read what uploads. Unknown record shapes are reported as capture gaps in the collector log. Remote Claude sessions are outside this local collector.
15
17
 
16
18
  Connecting Codex or Claude Code also adds a Stop hook to the coding agent. After an agent turn in a git repository where you had friction episodes this week, it shows one line such as `◆ Iteration · 3 friction episodes in this repo this week · 1 new since yesterday`. The hook reads local files only and adds a few milliseconds to a turn. Codex asks you to approve it once with `/hooks`. `iteration disconnect` removes it.
@@ -14,6 +14,7 @@ const CONTEXT_RECORD_TYPES = new Set([
14
14
  "file-history-delta",
15
15
  "cost-state",
16
16
  "queue-operation",
17
+ "pr-link",
17
18
  ]);
18
19
  function object(value) {
19
20
  const parsed = z.record(z.string(), z.json()).safeParse(value);
package/dist/collector.js CHANGED
@@ -5,8 +5,9 @@ import { collectorFetch } from "./collector-fetch.js";
5
5
  import { parseClaudeLine } from "./claude-transcript.js";
6
6
  import { cursorHookFiles } from "./cursor-hooks.js";
7
7
  import { parseCursorHookLine, parseCursorTranscriptLine, } from "./cursor-transcript.js";
8
+ import { redactTraceEvent } from "./redact-trace.js";
8
9
  import { parseRolloutLine } from "./rollout.js";
9
- import { acknowledgeEvents, appendEvents, collectorSource, collectorSourceDirectory, loadState, nextBatch, outboxSize, pendingEventIds, sourceFiles, saveState, } from "./storage.js";
10
+ import { acknowledgeEvents, appendEvents, collectorSource, collectorSourceDirectory, loadState, nextBatch, outboxSize, pendingEventIds, redactPendingOutbox, sourceFiles, saveState, } from "./storage.js";
10
11
  const MAX_SCAN_BYTES_PER_FILE = 16 * 1024 * 1024;
11
12
  const MAX_SCAN_BYTES_PER_TICK = 16 * 1024 * 1024;
12
13
  const MAX_RECORD_BYTES = 3 * 1024 * 1024;
@@ -152,13 +153,14 @@ export async function scanSourceOnce(config, paths, signal) {
152
153
  for (const parsedEvent of parsed.events) {
153
154
  if (queuedIds.has(parsedEvent.eventId))
154
155
  continue;
155
- const eventBytes = Buffer.byteLength(JSON.stringify(parsedEvent), "utf8");
156
+ const event = await redactTraceEvent(parsedEvent);
157
+ const eventBytes = Buffer.byteLength(JSON.stringify(event), "utf8");
156
158
  if (eventBytes > MAX_EVENT_BYTES) {
157
159
  logGap(filename, byteOffset, "normalized_event_exceeds_upload_limit");
158
160
  }
159
161
  else {
160
- events.push(parsedEvent);
161
- queuedIds.add(parsedEvent.eventId);
162
+ events.push(event);
163
+ queuedIds.add(event.eventId);
162
164
  }
163
165
  }
164
166
  lineStart = index + 1;
@@ -239,6 +241,7 @@ async function uploadPendingOnce(config, paths, fetcher = collectorFetch, signal
239
241
  return uploaded;
240
242
  }
241
243
  export async function collectorTick(config, paths, fetcher = collectorFetch, signal, onAcknowledged) {
244
+ await redactPendingOutbox(paths);
242
245
  let uploaded = await uploadPendingOnce(config, paths, fetcher, signal, onAcknowledged);
243
246
  const scan = (await outboxSize(paths)) >= SCAN_PAUSE_OUTBOX_BYTES
244
247
  ? { discovered: 0, caughtUp: false }
@@ -0,0 +1,2 @@
1
+ import type { TraceEvent } from "iteration-trace-contracts";
2
+ export declare function redactTraceEvent(event: TraceEvent): Promise<TraceEvent>;
@@ -0,0 +1,182 @@
1
+ import { lintSource } from "@secretlint/core";
2
+ import { rules as recommendedRules } from "@secretlint/secretlint-rule-preset-recommend";
3
+ import * as z from "zod";
4
+ const REDACTED = "[REDACTED]";
5
+ const secretlintConfig = {
6
+ rules: recommendedRules
7
+ .filter((rule) => rule.meta.id !== "@secretlint/secretlint-rule-filter-comments")
8
+ .map((rule) => ({ id: rule.meta.id, rule })),
9
+ };
10
+ const assignment = /\b([A-Za-z][A-Za-z0-9_-]{0,79})["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s"'`,;}&]+))/g;
11
+ const authorization = /\b(?:authorization|proxy-authorization)["']?\s*:\s*["']?(?:Bearer|Basic)\s+([A-Za-z0-9._~+/-]+={0,2})/gi;
12
+ const privateKey = /-----BEGIN ((?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY)-----[\s\S]*?-----END \1-----/g;
13
+ function normalizedKey(key) {
14
+ return key
15
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
16
+ .replace(/[^A-Za-z0-9]+/g, "_")
17
+ .toLowerCase();
18
+ }
19
+ function sensitiveKey(key) {
20
+ const normalized = normalizedKey(key);
21
+ if (/^(?:next_public|public|vite|react_app)_/.test(normalized))
22
+ return false;
23
+ return (/(?:^|_)(?:secret|password|passwd|credential)(?:_|$)/.test(normalized) ||
24
+ /(?:^|_)(?:api_key|private_key|database_url|db_url|dsn|connection_string|authorization|proxy_authorization|auth_header|cookie|set_cookie|token)$/.test(normalized));
25
+ }
26
+ function placeholder(value) {
27
+ const trimmed = value.trim();
28
+ return (trimmed === REDACTED ||
29
+ /^(?:<[^>]+>|\$\{[^}]+\}|\*+|redacted|placeholder|example|changeme|undefined|null)$/i.test(trimmed) ||
30
+ /^(?:process\.env|env)\.[A-Za-z0-9_]+$/.test(trimmed));
31
+ }
32
+ function addRange(ranges, start, end, length) {
33
+ if (!Number.isSafeInteger(start) ||
34
+ !Number.isSafeInteger(end) ||
35
+ start < 0 ||
36
+ end > length ||
37
+ start >= end) {
38
+ throw new Error("Local trace secret scanner returned an invalid location.");
39
+ }
40
+ ranges.push({ start, end });
41
+ }
42
+ function textRanges(value) {
43
+ const ranges = [];
44
+ for (const match of value.matchAll(assignment)) {
45
+ const key = match[1];
46
+ const candidate = match[2] ?? match[3] ?? match[4];
47
+ if (key === undefined ||
48
+ candidate === undefined ||
49
+ candidate.length === 0 ||
50
+ !sensitiveKey(key) ||
51
+ placeholder(candidate) ||
52
+ ((normalizedKey(key) === "authorization" ||
53
+ normalizedKey(key) === "proxy_authorization") &&
54
+ /^(?:Bearer|Basic)$/i.test(candidate)))
55
+ continue;
56
+ const start = match.index + match[0].lastIndexOf(candidate);
57
+ addRange(ranges, start, start + candidate.length, value.length);
58
+ }
59
+ for (const match of value.matchAll(authorization)) {
60
+ const candidate = match[1];
61
+ if (candidate === undefined || placeholder(candidate))
62
+ continue;
63
+ const start = match.index + match[0].lastIndexOf(candidate);
64
+ addRange(ranges, start, start + candidate.length, value.length);
65
+ }
66
+ for (const match of value.matchAll(privateKey)) {
67
+ addRange(ranges, match.index, match.index + match[0].length, value.length);
68
+ }
69
+ return ranges;
70
+ }
71
+ function replaceRanges(value, ranges) {
72
+ if (ranges.length === 0)
73
+ return value;
74
+ ranges.sort((a, b) => a.start - b.start || a.end - b.end);
75
+ const merged = [];
76
+ for (const range of ranges) {
77
+ const previous = merged.at(-1);
78
+ if (previous !== undefined && range.start <= previous.end) {
79
+ merged[merged.length - 1] = {
80
+ start: previous.start,
81
+ end: Math.max(previous.end, range.end),
82
+ };
83
+ }
84
+ else
85
+ merged.push(range);
86
+ }
87
+ let result = value;
88
+ for (let index = merged.length - 1; index >= 0; index -= 1) {
89
+ const range = merged[index];
90
+ if (range === undefined)
91
+ continue;
92
+ result = `${result.slice(0, range.start)}${REDACTED}${result.slice(range.end)}`;
93
+ }
94
+ return result;
95
+ }
96
+ async function redactText(value) {
97
+ if (value.length === 0 || value === REDACTED)
98
+ return value;
99
+ const ranges = textRanges(value);
100
+ let result;
101
+ try {
102
+ result = await lintSource({
103
+ source: {
104
+ contentType: "text",
105
+ content: value,
106
+ filePath: "trace.txt",
107
+ ext: "txt",
108
+ },
109
+ options: {
110
+ maskSecrets: true,
111
+ noPhysicFilePath: true,
112
+ config: secretlintConfig,
113
+ },
114
+ });
115
+ }
116
+ catch {
117
+ throw new Error("Local trace secret scanning failed; the event was not queued.");
118
+ }
119
+ for (const message of result.messages) {
120
+ addRange(ranges, message.range[0], message.range[1], value.length);
121
+ }
122
+ return replaceRanges(value, ranges);
123
+ }
124
+ async function redactValue(value, cache, force) {
125
+ const parsedText = z.string().safeParse(value);
126
+ if (parsedText.success) {
127
+ const text = parsedText.data;
128
+ if (force)
129
+ return placeholder(text) ? text : REDACTED;
130
+ let result = cache.get(text);
131
+ if (result === undefined) {
132
+ result = redactText(text);
133
+ cache.set(text, result);
134
+ }
135
+ return result;
136
+ }
137
+ if (value === null || value === true || value === false)
138
+ return value;
139
+ const parsedNumber = z.number().safeParse(value);
140
+ if (parsedNumber.success)
141
+ return force ? REDACTED : parsedNumber.data;
142
+ if (Array.isArray(value)) {
143
+ const items = await Promise.all(value.map((item) => redactValue(item, cache, force)));
144
+ return items.every((item, index) => item === value[index]) ? value : items;
145
+ }
146
+ const record = value;
147
+ const entries = await Promise.all(Object.entries(record).map(async ([key, item]) => [
148
+ key,
149
+ await redactValue(item, cache, force || sensitiveKey(key)),
150
+ ]));
151
+ return entries.every(([key, item]) => item === record[key])
152
+ ? value
153
+ : Object.fromEntries(entries);
154
+ }
155
+ export async function redactTraceEvent(event) {
156
+ const cache = new Map();
157
+ if (event.payload.sourceType === "cursor_subagent_transcript") {
158
+ const parsed = z.string().safeParse(event.payload.base64);
159
+ if (!parsed.success)
160
+ throw new Error("Cursor transcript has invalid encoded content.");
161
+ const bytes = Buffer.from(parsed.data, "base64");
162
+ if (bytes.toString("base64") !== parsed.data)
163
+ throw new Error("Cursor transcript has invalid encoded content.");
164
+ const raw = bytes.toString("latin1");
165
+ const redacted = await redactText(raw);
166
+ const { base64: _encoded, ...remaining } = event.payload;
167
+ const payload = await redactValue(remaining, cache, false);
168
+ return {
169
+ ...event,
170
+ payload: {
171
+ ...payload,
172
+ base64: redacted === raw
173
+ ? parsed.data
174
+ : Buffer.from(redacted, "latin1").toString("base64"),
175
+ },
176
+ };
177
+ }
178
+ const payload = await redactValue(event.payload, cache, false);
179
+ return payload === event.payload
180
+ ? event
181
+ : { ...event, payload: payload };
182
+ }
package/dist/storage.d.ts CHANGED
@@ -44,6 +44,7 @@ export type CollectorPaths = Readonly<{
44
44
  config: string;
45
45
  state: string;
46
46
  outbox: string;
47
+ redactionMarker: string;
47
48
  lock: string;
48
49
  historyBackfill: string;
49
50
  health: string;
@@ -63,6 +64,7 @@ export declare function saveState(state: CollectorState, paths: CollectorPaths):
63
64
  export declare function outboxSize(paths: CollectorPaths): Promise<number>;
64
65
  export declare function pendingEventIds(paths: CollectorPaths): Promise<Set<string>>;
65
66
  export declare function appendEvents(events: readonly TraceEvent[], paths: CollectorPaths): Promise<void>;
67
+ export declare function redactPendingOutbox(paths: CollectorPaths): Promise<void>;
66
68
  export declare function nextBatch(paths: CollectorPaths, maxBytes: number): Promise<readonly TraceEvent[]>;
67
69
  export declare function acknowledgeEvents(acknowledgedIds: ReadonlySet<string>, paths: CollectorPaths): Promise<void>;
68
70
  export declare function initializeState(source: CollectorSource, home: string, paths: CollectorPaths, includeHistory: boolean): Promise<void>;
package/dist/storage.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { once } from "node:events";
2
- import { createWriteStream, promises as fs } from "node:fs";
2
+ import { createReadStream, createWriteStream, promises as fs } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { createInterface } from "node:readline";
6
6
  import { MAX_TRACE_EVENTS_PER_BATCH, parseTraceEventBatch, } from "iteration-trace-contracts";
7
7
  import * as z from "zod";
8
+ import { redactTraceEvent } from "./redact-trace.js";
8
9
  const MAX_OUTBOX_BYTES = 64 * 1024 * 1024;
9
10
  export function collectorSource(config) {
10
11
  return "cursorHome" in config
@@ -66,6 +67,7 @@ const healthSchema = z
66
67
  .nullable(),
67
68
  })
68
69
  .strict();
70
+ const redactionMarkerSchema = z.object({ version: z.literal(1) }).strict();
69
71
  function parseOutboxEvent(line) {
70
72
  let decoded;
71
73
  try {
@@ -116,6 +118,7 @@ export function collectorPaths(directory = defaultCollectorDirectory("codex")) {
116
118
  config: path.join(directory, "config.json"),
117
119
  state: path.join(directory, "state.json"),
118
120
  outbox: path.join(directory, "outbox.jsonl"),
121
+ redactionMarker: path.join(directory, "redaction.json"),
119
122
  lock: path.join(directory, "collector.lock"),
120
123
  historyBackfill: path.join(directory, "history-backfill.json"),
121
124
  health: path.join(directory, "health.json"),
@@ -330,6 +333,72 @@ export async function appendEvents(events, paths) {
330
333
  await handle.close();
331
334
  }
332
335
  }
336
+ export async function redactPendingOutbox(paths) {
337
+ let marker;
338
+ try {
339
+ marker = await fs.readFile(paths.redactionMarker, "utf8");
340
+ }
341
+ catch (error) {
342
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
343
+ marker = null;
344
+ else
345
+ throw error;
346
+ }
347
+ if (marker !== null) {
348
+ let decoded;
349
+ try {
350
+ decoded = JSON.parse(marker);
351
+ }
352
+ catch {
353
+ throw new Error("Collector redaction marker is not valid JSON.");
354
+ }
355
+ if (!redactionMarkerSchema.safeParse(decoded).success)
356
+ throw new Error("Collector redaction marker has an invalid shape.");
357
+ return;
358
+ }
359
+ const size = await outboxSize(paths);
360
+ if (size > MAX_OUTBOX_BYTES)
361
+ throw new Error("Trace outbox exceeds its configured capacity.");
362
+ if (size > 0) {
363
+ const temporary = `${paths.outbox}.${process.pid}.redacting`;
364
+ const output = await fs.open(temporary, "w", 0o600);
365
+ try {
366
+ const lines = createInterface({
367
+ input: createReadStream(paths.outbox, { encoding: "utf8" }),
368
+ crlfDelay: Infinity,
369
+ });
370
+ for await (const line of lines) {
371
+ const event = await redactTraceEvent(parseOutboxEvent(line));
372
+ await output.writeFile(`${JSON.stringify(event)}\n`, "utf8");
373
+ }
374
+ await output.sync();
375
+ }
376
+ catch (error) {
377
+ await output.close();
378
+ await fs.rm(temporary, { force: true });
379
+ throw error;
380
+ }
381
+ await output.close();
382
+ try {
383
+ if ((await fs.stat(temporary)).size > MAX_OUTBOX_BYTES)
384
+ throw new Error("Redacted trace outbox exceeds its configured capacity.");
385
+ await fs.rename(temporary, paths.outbox);
386
+ const directory = await fs.open(path.dirname(paths.outbox), "r");
387
+ try {
388
+ await directory.sync();
389
+ }
390
+ finally {
391
+ await directory.close();
392
+ }
393
+ }
394
+ catch (error) {
395
+ await fs.rm(temporary, { force: true });
396
+ throw error;
397
+ }
398
+ }
399
+ await ensurePrivateDirectory(path.dirname(paths.redactionMarker));
400
+ await writeJsonAtomic(paths.redactionMarker, { version: 1 });
401
+ }
333
402
  export async function nextBatch(paths, maxBytes) {
334
403
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
335
404
  throw new Error("Maximum upload batch size must be a positive integer.");
@@ -493,9 +562,12 @@ export async function ensureHistoryBackfill(codexHome, paths) {
493
562
  });
494
563
  return true;
495
564
  }
565
+ // Version 2 re-reads Claude history so stored sessions gain each response's
566
+ // final usage report and the PR links Claude records.
567
+ const CLAUDE_HISTORY_BACKFILL_VERSION = 2;
496
568
  export async function ensureClaudeHistoryBackfill(claudeHome, paths) {
497
569
  const version = await readHistoryBackfillVersion(paths);
498
- if (version !== null && version >= 1)
570
+ if (version !== null && version >= CLAUDE_HISTORY_BACKFILL_VERSION)
499
571
  return false;
500
572
  const state = await loadState(paths);
501
573
  if (!state.initialized ||
@@ -504,7 +576,9 @@ export async function ensureClaudeHistoryBackfill(claudeHome, paths) {
504
576
  }
505
577
  await claudeFiles(path.join(claudeHome, "projects"));
506
578
  await saveState({ ...state, cursors: {}, skipping: [] }, paths);
507
- await writeJsonAtomic(paths.historyBackfill, { version: 1 });
579
+ await writeJsonAtomic(paths.historyBackfill, {
580
+ version: CLAUDE_HISTORY_BACKFILL_VERSION,
581
+ });
508
582
  return true;
509
583
  }
510
584
  export async function ensureCursorHistoryBackfill(cursorHome, paths) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,8 @@
17
17
  "iteration": "./dist/cli.js"
18
18
  },
19
19
  "dependencies": {
20
+ "@secretlint/core": "13.0.6",
21
+ "@secretlint/secretlint-rule-preset-recommend": "13.0.6",
20
22
  "zod": "4.6.4",
21
23
  "iteration-trace-contracts": "0.7.0"
22
24
  },