iteration-cli 0.4.4 → 0.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.
@@ -0,0 +1,302 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { isLaunchDirectory, parseCollectorFrictionSummary, } from "iteration-trace-contracts";
4
+ import * as z from "zod";
5
+ import { collectorSource, ensurePrivateDirectory, } from "./storage.js";
6
+ const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
7
+ const REQUEST_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
8
+ const MAX_REFRESHES_PER_PASS = 20;
9
+ const REQUEST_TIMEOUT_MS = 10_000;
10
+ const HOOK_TIMEOUT_SECONDS = 1;
11
+ const REQUEST_KEY_PATTERN = /^\d{1,10}$/;
12
+ export function turnSummaryPaths(dataDirectory) {
13
+ const directory = path.join(dataDirectory, "turn-summary");
14
+ return {
15
+ directory,
16
+ script: path.join(directory, "stop-hook.sh"),
17
+ requests: path.join(directory, "requests"),
18
+ lines: path.join(directory, "lines"),
19
+ shown: path.join(directory, "shown"),
20
+ };
21
+ }
22
+ function shellQuote(value) {
23
+ return `'${value.replaceAll("'", `'\\''`)}'`;
24
+ }
25
+ // The hook runs at the end of every agent turn, so it only reads files the
26
+ // collector keeps current and never waits on the network. Every failure prints
27
+ // the empty object both agents accept, so a broken summary never blocks a turn.
28
+ function hookScript(paths) {
29
+ return `#!/bin/sh
30
+ exec 2>/dev/null
31
+ cat >/dev/null
32
+ summary=${shellQuote(paths.directory)}
33
+ quiet() { printf '{}\\n'; exit 0; }
34
+ unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_PREFIX
35
+ cd "\${CLAUDE_PROJECT_DIR:-.}" || quiet
36
+ directory=$(git rev-parse --show-toplevel) || quiet
37
+ case $directory in /*) ;; *) quiet ;; esac
38
+ set -- $(printf '%s' "$directory" | cksum)
39
+ key=$1
40
+ [ -n "$key" ] || quiet
41
+ printf '%s\\n' "$directory" > "$summary/requests/$key"
42
+ [ -f "$summary/lines/$key" ] || quiet
43
+ { IFS= read -r owner && IFS= read -r message; } < "$summary/lines/$key" || quiet
44
+ [ "$owner" = "$directory" ] || quiet
45
+ last=
46
+ [ -f "$summary/shown/$key" ] && IFS= read -r last < "$summary/shown/$key"
47
+ [ "$last" = "$message" ] && quiet
48
+ printf '%s\\n' "$message" > "$summary/shown/$key" || quiet
49
+ printf '%s\\n' "$message"
50
+ `;
51
+ }
52
+ function hookCommand(paths) {
53
+ return `/bin/sh ${shellQuote(paths.script)}`;
54
+ }
55
+ function agentHookFile(config) {
56
+ return "claudeHome" in config
57
+ ? path.join(config.claudeHome, "settings.json")
58
+ : path.join(config.codexHome, "hooks.json");
59
+ }
60
+ const hookEntrySchema = z.object({ command: z.string().optional() }).loose();
61
+ const hookGroupSchema = z
62
+ .object({ hooks: z.array(hookEntrySchema).optional() })
63
+ .loose();
64
+ const hookFileSchema = z
65
+ .object({
66
+ hooks: z
67
+ .object({ Stop: z.array(hookGroupSchema).optional() })
68
+ .loose()
69
+ .optional(),
70
+ })
71
+ .loose();
72
+ async function readHookFile(filename) {
73
+ let contents;
74
+ let mode = 0o600;
75
+ try {
76
+ contents = await fs.readFile(filename, "utf8");
77
+ mode = (await fs.stat(filename)).mode & 0o777;
78
+ }
79
+ catch (error) {
80
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
81
+ return { file: {}, mode };
82
+ }
83
+ throw new Error(`Could not read agent hooks at ${filename}.`, {
84
+ cause: error,
85
+ });
86
+ }
87
+ let decoded;
88
+ try {
89
+ decoded = JSON.parse(contents);
90
+ }
91
+ catch (error) {
92
+ throw new Error(`Agent hooks at ${filename} are not valid JSON.`, {
93
+ cause: error,
94
+ });
95
+ }
96
+ const parsed = hookFileSchema.safeParse(decoded);
97
+ if (!parsed.success) {
98
+ throw new Error(`Agent hooks at ${filename} have an unexpected shape.`, {
99
+ cause: parsed.error,
100
+ });
101
+ }
102
+ return { file: parsed.data, mode };
103
+ }
104
+ async function writeHookFile(filename, file, mode) {
105
+ await fs.mkdir(path.dirname(filename), { recursive: true });
106
+ // Settings kept in a dotfiles repository are often symlinks; replacing the
107
+ // link with a file would silently detach them from that repository.
108
+ const target = await fs.realpath(filename).catch((error) => {
109
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
110
+ return filename;
111
+ throw error;
112
+ });
113
+ const temporary = `${target}.iteration-${process.pid}.tmp`;
114
+ await fs.writeFile(temporary, `${JSON.stringify(file, null, 2)}\n`, {
115
+ encoding: "utf8",
116
+ mode,
117
+ });
118
+ await fs.rename(temporary, target);
119
+ }
120
+ function withoutCommand(groups, command) {
121
+ return groups.flatMap((group) => {
122
+ const hooks = group.hooks?.filter((hook) => hook.command !== command);
123
+ if (hooks === undefined || hooks.length === group.hooks?.length)
124
+ return [group];
125
+ return hooks.length === 0 ? [] : [{ ...group, hooks }];
126
+ });
127
+ }
128
+ function hasCommand(groups, command) {
129
+ return groups.some((group) => group.hooks?.some((hook) => hook.command === command));
130
+ }
131
+ export async function installTurnSummary(config) {
132
+ const paths = turnSummaryPaths(config.dataDirectory);
133
+ await ensurePrivateDirectory(paths.directory);
134
+ await Promise.all([paths.requests, paths.lines, paths.shown].map(ensurePrivateDirectory));
135
+ await fs.writeFile(paths.script, hookScript(paths), {
136
+ encoding: "utf8",
137
+ mode: 0o700,
138
+ });
139
+ await fs.chmod(paths.script, 0o700);
140
+ const filename = agentHookFile(config);
141
+ const { file, mode } = await readHookFile(filename);
142
+ const command = hookCommand(paths);
143
+ const stop = file.hooks?.Stop ?? [];
144
+ if (hasCommand(stop, command))
145
+ return;
146
+ await writeHookFile(filename, {
147
+ ...file,
148
+ hooks: {
149
+ ...file.hooks,
150
+ Stop: [
151
+ ...stop,
152
+ {
153
+ hooks: [
154
+ { type: "command", command, timeout: HOOK_TIMEOUT_SECONDS },
155
+ ],
156
+ },
157
+ ],
158
+ },
159
+ }, mode);
160
+ }
161
+ export async function uninstallTurnSummary(config) {
162
+ const filename = agentHookFile(config);
163
+ const { file, mode } = await readHookFile(filename);
164
+ const stop = file.hooks?.Stop ?? [];
165
+ const command = hookCommand(turnSummaryPaths(config.dataDirectory));
166
+ if (!hasCommand(stop, command))
167
+ return;
168
+ const remaining = withoutCommand(stop, command);
169
+ const { Stop: _removed, ...otherEvents } = file.hooks ?? {};
170
+ await writeHookFile(filename, {
171
+ ...file,
172
+ hooks: remaining.length === 0
173
+ ? otherEvents
174
+ : { ...otherEvents, Stop: remaining },
175
+ }, mode);
176
+ }
177
+ export function turnSummaryMessage(summary) {
178
+ if (summary.weekEpisodes === 0)
179
+ return null;
180
+ const episodes = `${summary.weekEpisodes} friction episode${summary.weekEpisodes === 1 ? "" : "s"}`;
181
+ const recent = summary.dayEpisodes === 0
182
+ ? ""
183
+ : ` · ${summary.dayEpisodes} new since yesterday`;
184
+ return `◆ Iteration · ${episodes} in this repo this week${recent}`;
185
+ }
186
+ export class TurnSummaryRefreshError extends Error {
187
+ constructor(message, options) {
188
+ super(message, options);
189
+ this.name = "TurnSummaryRefreshError";
190
+ }
191
+ }
192
+ async function fetchSummary(config, directory, fetcher, signal) {
193
+ const url = new URL("/api/traces/friction-summary", config.endpoint);
194
+ url.searchParams.set("directory", directory);
195
+ let response;
196
+ try {
197
+ response = await fetcher(url, {
198
+ headers: { authorization: `Bearer ${config.token}` },
199
+ signal: signal
200
+ ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
201
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
202
+ });
203
+ }
204
+ catch (error) {
205
+ if (signal?.aborted)
206
+ throw error;
207
+ throw new TurnSummaryRefreshError("Friction summary could not reach Iteration.", { cause: error });
208
+ }
209
+ if (response.status !== 200) {
210
+ throw new TurnSummaryRefreshError(`Friction summary failed with HTTP ${response.status}.`);
211
+ }
212
+ let body;
213
+ try {
214
+ body = await response.json();
215
+ }
216
+ catch (error) {
217
+ throw new TurnSummaryRefreshError("Friction summary returned an unreadable response.", { cause: error });
218
+ }
219
+ const parsed = parseCollectorFrictionSummary(body);
220
+ if (!parsed.success)
221
+ throw new TurnSummaryRefreshError(parsed.error);
222
+ return parsed.value;
223
+ }
224
+ async function readRequest(filename) {
225
+ try {
226
+ const [directory] = (await fs.readFile(filename, "utf8")).split("\n");
227
+ return directory ?? null;
228
+ }
229
+ catch (error) {
230
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
231
+ return null;
232
+ throw error;
233
+ }
234
+ }
235
+ async function writeLine(paths, key, directory, message) {
236
+ const filename = path.join(paths.lines, key);
237
+ if (message === null) {
238
+ await fs.rm(filename, { force: true });
239
+ return;
240
+ }
241
+ const temporary = `${filename}.${process.pid}.tmp`;
242
+ await fs.writeFile(temporary, `${directory}\n${JSON.stringify({ systemMessage: message })}\n`, { encoding: "utf8", mode: 0o600 });
243
+ await fs.rename(temporary, filename);
244
+ }
245
+ async function forget(paths, key) {
246
+ await Promise.all([paths.requests, paths.lines, paths.shown].map((directory) => fs.rm(path.join(directory, key), { force: true })));
247
+ }
248
+ // Refreshes the summary line for each repository the hook asked about. The
249
+ // hook reads the result on a later turn, so a slow or failed request never
250
+ // delays the agent.
251
+ export async function refreshTurnSummaries(config, refreshedAt, nowMs, fetcher = fetch, signal) {
252
+ const paths = turnSummaryPaths(config.dataDirectory);
253
+ let keys;
254
+ try {
255
+ keys = await fs.readdir(paths.requests);
256
+ }
257
+ catch (error) {
258
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
259
+ return 0;
260
+ throw error;
261
+ }
262
+ let refreshed = 0;
263
+ for (const key of keys.sort()) {
264
+ signal?.throwIfAborted();
265
+ if (!REQUEST_KEY_PATTERN.test(key))
266
+ continue;
267
+ const filename = path.join(paths.requests, key);
268
+ const stats = await fs.stat(filename).catch((error) => {
269
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
270
+ return null;
271
+ throw error;
272
+ });
273
+ if (stats === null)
274
+ continue;
275
+ const directory = await readRequest(filename);
276
+ if (nowMs - stats.mtimeMs > REQUEST_RETENTION_MS ||
277
+ directory === null ||
278
+ !isLaunchDirectory(directory)) {
279
+ await forget(paths, key);
280
+ refreshedAt.delete(key);
281
+ continue;
282
+ }
283
+ const previous = refreshedAt.get(key);
284
+ if (previous !== undefined &&
285
+ previous.directory === directory &&
286
+ nowMs - previous.atMs < REFRESH_INTERVAL_MS) {
287
+ continue;
288
+ }
289
+ if (refreshed === MAX_REFRESHES_PER_PASS)
290
+ break;
291
+ refreshedAt.set(key, { directory, atMs: nowMs });
292
+ refreshed += 1;
293
+ const summary = await fetchSummary(config, directory, fetcher, signal);
294
+ await writeLine(paths, key, directory, turnSummaryMessage(summary));
295
+ }
296
+ return refreshed;
297
+ }
298
+ export function turnSummaryApprovalHint(config) {
299
+ return collectorSource(config) === "codex"
300
+ ? " Approve Iteration's turn summary once with /hooks in Codex."
301
+ : "";
302
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.4.4",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "zod": "4.6.4",
21
- "iteration-trace-contracts": "0.3.0"
21
+ "iteration-trace-contracts": "0.6.0"
22
22
  },
23
23
  "scripts": {
24
24
  "build": "pnpm --filter iteration-trace-contracts build && tsc -p tsconfig.json"