@blastin-dev/clocktopus-cli 0.2.0 → 0.3.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 (53) hide show
  1. package/README.md +91 -36
  2. package/dist/src/commands/agent/disable.d.ts +8 -1
  3. package/dist/src/commands/agent/disable.d.ts.map +1 -1
  4. package/dist/src/commands/agent/disable.js +79 -45
  5. package/dist/src/commands/agent/doctor.d.ts.map +1 -1
  6. package/dist/src/commands/agent/doctor.js +157 -95
  7. package/dist/src/commands/agent/hook.d.ts +4 -1
  8. package/dist/src/commands/agent/hook.d.ts.map +1 -1
  9. package/dist/src/commands/agent/hook.js +239 -98
  10. package/dist/src/commands/agent/setup.d.ts +1 -16
  11. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  12. package/dist/src/commands/agent/setup.js +198 -81
  13. package/dist/src/commands/agent/status.d.ts.map +1 -1
  14. package/dist/src/commands/agent/status.js +46 -24
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +18 -4
  17. package/dist/src/lib/agent-config.d.ts +5 -23
  18. package/dist/src/lib/agent-config.d.ts.map +1 -1
  19. package/dist/src/lib/agent-config.js +50 -36
  20. package/dist/src/lib/agent-hook-state.d.ts +11 -7
  21. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  22. package/dist/src/lib/agent-hook-state.js +34 -29
  23. package/dist/src/lib/agents.d.ts +52 -0
  24. package/dist/src/lib/agents.d.ts.map +1 -0
  25. package/dist/src/lib/agents.js +238 -0
  26. package/dist/src/lib/claude-settings.d.ts +0 -36
  27. package/dist/src/lib/claude-settings.d.ts.map +1 -1
  28. package/dist/src/lib/claude-settings.js +37 -62
  29. package/dist/src/lib/codex-config.d.ts +87 -0
  30. package/dist/src/lib/codex-config.d.ts.map +1 -0
  31. package/dist/src/lib/codex-config.js +399 -0
  32. package/dist/src/lib/codex-config.test.d.ts +2 -0
  33. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  34. package/dist/src/lib/codex-config.test.js +359 -0
  35. package/dist/src/lib/declared-commits.d.ts +43 -0
  36. package/dist/src/lib/declared-commits.d.ts.map +1 -0
  37. package/dist/src/lib/declared-commits.js +114 -0
  38. package/dist/src/lib/declared-commits.test.d.ts +2 -0
  39. package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
  40. package/dist/src/lib/declared-commits.test.js +129 -0
  41. package/dist/src/lib/git-remotes.d.ts +9 -0
  42. package/dist/src/lib/git-remotes.d.ts.map +1 -0
  43. package/dist/src/lib/git-remotes.js +50 -0
  44. package/dist/src/lib/git-remotes.test.d.ts +2 -0
  45. package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
  46. package/dist/src/lib/git-remotes.test.js +52 -0
  47. package/dist/src/lib/opencode-config.d.ts +23 -0
  48. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  49. package/dist/src/lib/opencode-config.js +290 -0
  50. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  51. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  52. package/dist/src/lib/opencode-config.test.js +140 -0
  53. package/package.json +2 -1
@@ -0,0 +1,359 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { parse as parseToml } from "smol-toml";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { applyCodexHooks, applyCodexOtel, buildCodexOtel, CodexConfigParseError, readCodexConfig, readCodexHooks, readCodexHookTrust, readCodexTelemetry, removeCodexHooks, removeCodexOtel, spliceOtelSection, writeCodexConfig, writeCodexHooks, } from "./codex-config";
7
+ /**
8
+ * These tests exist because this module edits two files we do not own.
9
+ * `~/.codex/config.toml` holds the user's model, sandbox policy, MCP
10
+ * servers and project trust decisions — hand-written and usually commented
11
+ * — and `hooks.json` may already hold hooks that have nothing to do with
12
+ * us. A merge bug here destroys work unrelated to Clocktopus.
13
+ */
14
+ const INSTALL = {
15
+ token: "ctop_agt_testtoken",
16
+ endpoint: "https://otel.example.com",
17
+ hookCommand: "clocktopus agent hook --provider codex_cli",
18
+ };
19
+ /** A config.toml with the user's own settings, comments and layout. */
20
+ const USER_CONFIG = `# my codex setup
21
+ model = "gpt-5.6-sol"
22
+ approval_policy = "on-request"
23
+
24
+ [sandbox_workspace_write]
25
+ network_access = true # needed for npm
26
+
27
+ [mcp_servers.linear]
28
+ command = "npx"
29
+ args = ["-y", "linear-mcp"]
30
+ `;
31
+ let dir;
32
+ let configPath;
33
+ let hooksPath;
34
+ beforeEach(() => {
35
+ dir = mkdtempSync(join(tmpdir(), "clocktopus-codex-"));
36
+ configPath = join(dir, "config.toml");
37
+ hooksPath = join(dir, "hooks.json");
38
+ });
39
+ afterEach(() => {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ });
42
+ describe("buildCodexOtel", () => {
43
+ it("puts the receiver path on the endpoint, because Codex does not add one", () => {
44
+ const otel = buildCodexOtel(INSTALL);
45
+ const http = otel.exporter["otlp-http"];
46
+ // Codex POSTs to the configured URL verbatim — unlike the OTel SDK, it
47
+ // does not append `/v1/logs`. Omitting the path here would send every
48
+ // export to the receiver's root and 404 silently.
49
+ expect(http.endpoint).toBe("https://otel.example.com/v1/logs");
50
+ expect(http.protocol).toBe("json");
51
+ expect(http.headers.Authorization).toBe("Bearer ctop_agt_testtoken");
52
+ });
53
+ it("does not double the path when the endpoint already ends in a slash", () => {
54
+ const otel = buildCodexOtel({
55
+ ...INSTALL,
56
+ endpoint: "https://otel.example.com/",
57
+ });
58
+ const http = otel.exporter["otlp-http"];
59
+ expect(http.endpoint).toBe("https://otel.example.com/v1/logs");
60
+ });
61
+ it("turns prompt logging off explicitly rather than trusting the default", () => {
62
+ expect(buildCodexOtel(INSTALL).log_user_prompt).toBe(false);
63
+ });
64
+ });
65
+ describe("splicing [otel] into a hand-written config.toml", () => {
66
+ it("adds the section without disturbing comments or layout", () => {
67
+ const next = spliceOtelSection(USER_CONFIG, buildCodexOtel(INSTALL));
68
+ // Every line the user wrote survives, byte for byte. A round trip
69
+ // through a TOML serialiser would silently drop all three comments.
70
+ expect(next).toContain("# my codex setup");
71
+ expect(next).toContain("network_access = true # needed for npm");
72
+ expect(next).toContain('args = ["-y", "linear-mcp"]');
73
+ const parsed = parseToml(next);
74
+ expect(parsed.model).toBe("gpt-5.6-sol");
75
+ expect(parsed.mcp_servers).toEqual({
76
+ linear: { command: "npx", args: ["-y", "linear-mcp"] },
77
+ });
78
+ expect(parsed.otel.log_user_prompt).toBe(false);
79
+ });
80
+ it("replaces an existing [otel] section instead of appending a second", () => {
81
+ const withOtel = spliceOtelSection(USER_CONFIG, buildCodexOtel(INSTALL));
82
+ const twice = spliceOtelSection(withOtel, buildCodexOtel({ ...INSTALL, token: "ctop_agt_second" }));
83
+ // TOML rejects a duplicated table outright, so an append bug here would
84
+ // stop Codex booting at all.
85
+ expect(twice.match(/^\[otel\]/gm)).toHaveLength(1);
86
+ expect(twice).toContain("ctop_agt_second");
87
+ expect(twice).not.toContain("ctop_agt_testtoken");
88
+ expect(() => parseToml(twice)).not.toThrow();
89
+ });
90
+ it("keeps the user's own [otel] keys when merging ours in", () => {
91
+ const config = `${USER_CONFIG}
92
+ [otel]
93
+ environment = "staging"
94
+ `;
95
+ const parsed = parseToml(config);
96
+ const next = applyCodexOtel({ text: config, config: parsed }, INSTALL);
97
+ const result = parseToml(next);
98
+ expect(result.otel.environment).toBe("staging");
99
+ expect(result.otel.log_user_prompt).toBe(false);
100
+ });
101
+ it("removes only our keys, leaving the rest of [otel] behind", () => {
102
+ const config = `${USER_CONFIG}
103
+ [otel]
104
+ environment = "staging"
105
+ `;
106
+ const withOurs = applyCodexOtel({ text: config, config: parseToml(config) }, INSTALL);
107
+ const { nextText, removedKeys } = removeCodexOtel({
108
+ text: withOurs,
109
+ config: parseToml(withOurs),
110
+ });
111
+ expect(removedKeys.sort()).toEqual(["exporter", "log_user_prompt"]);
112
+ const parsed = parseToml(nextText);
113
+ expect(parsed.otel).toEqual({ environment: "staging" });
114
+ expect(parsed.model).toBe("gpt-5.6-sol");
115
+ });
116
+ it("drops [otel] entirely when nothing of the user's is left in it", () => {
117
+ const withOurs = spliceOtelSection(USER_CONFIG, buildCodexOtel(INSTALL));
118
+ const { nextText } = removeCodexOtel({
119
+ text: withOurs,
120
+ config: parseToml(withOurs),
121
+ });
122
+ expect(nextText).not.toContain("[otel]");
123
+ expect(parseToml(nextText)).toEqual(parseToml(USER_CONFIG));
124
+ });
125
+ it("survives an add/remove cycle without accumulating blank lines", () => {
126
+ let text = USER_CONFIG;
127
+ for (let i = 0; i < 3; i++) {
128
+ text = spliceOtelSection(text, buildCodexOtel(INSTALL));
129
+ text = removeCodexOtel({
130
+ text,
131
+ config: parseToml(text),
132
+ }).nextText;
133
+ }
134
+ expect(text).toBe(USER_CONFIG);
135
+ });
136
+ it("creates the section from nothing when the file does not exist yet", () => {
137
+ const next = spliceOtelSection("", buildCodexOtel(INSTALL));
138
+ expect(parseToml(next).otel).toBeDefined();
139
+ });
140
+ });
141
+ describe("writeCodexConfig — the guard on the splice", () => {
142
+ it("writes the edit and backs up what was there", () => {
143
+ writeFileSync(configPath, USER_CONFIG);
144
+ const current = readCodexConfig(configPath);
145
+ const { backupPath } = writeCodexConfig({
146
+ path: configPath,
147
+ previousText: current.text,
148
+ previousConfig: current.config,
149
+ nextText: applyCodexOtel(current, INSTALL),
150
+ });
151
+ expect(backupPath).toBe(`${configPath}.clocktopus-backup`);
152
+ expect(readFileSync(backupPath, "utf8")).toBe(USER_CONFIG);
153
+ expect(readCodexTelemetry(configPath).token).toBe(INSTALL.token);
154
+ });
155
+ it("refuses to write an edit that would change anything outside [otel]", () => {
156
+ writeFileSync(configPath, USER_CONFIG);
157
+ const current = readCodexConfig(configPath);
158
+ // Stands in for a splice that misread the file's layout. Rather than
159
+ // trusting the region scanner, the write verifies its own result — so a
160
+ // document it cannot handle costs the user nothing.
161
+ expect(() => writeCodexConfig({
162
+ path: configPath,
163
+ previousText: current.text,
164
+ previousConfig: current.config,
165
+ nextText: 'model = "gpt-5.6-sol"\n',
166
+ })).toThrow(/would have changed/);
167
+ expect(readFileSync(configPath, "utf8")).toBe(USER_CONFIG);
168
+ });
169
+ it("refuses to write anything that is not valid TOML", () => {
170
+ writeFileSync(configPath, USER_CONFIG);
171
+ const current = readCodexConfig(configPath);
172
+ expect(() => writeCodexConfig({
173
+ path: configPath,
174
+ previousText: current.text,
175
+ previousConfig: current.config,
176
+ nextText: "[[[nope",
177
+ })).toThrow(/did not produce valid TOML/);
178
+ expect(readFileSync(configPath, "utf8")).toBe(USER_CONFIG);
179
+ });
180
+ it("raises rather than overwriting a config.toml it cannot parse", () => {
181
+ // Far more likely a half-finished edit than something to clobber.
182
+ writeFileSync(configPath, "model = = =");
183
+ expect(() => readCodexConfig(configPath)).toThrow(CodexConfigParseError);
184
+ });
185
+ });
186
+ describe("readCodexTelemetry", () => {
187
+ it("strips the receiver path back off so the hook gets a base URL", () => {
188
+ writeFileSync(configPath, spliceOtelSection(USER_CONFIG, buildCodexOtel(INSTALL)));
189
+ const telemetry = readCodexTelemetry(configPath);
190
+ // The hook posts to `<endpoint>/v1/agent-session` and doctor to
191
+ // `<endpoint>/v1/verify`; leaving `/v1/logs` on would make both 404.
192
+ expect(telemetry.endpoint).toBe("https://otel.example.com");
193
+ expect(telemetry.token).toBe(INSTALL.token);
194
+ expect(telemetry.protocol).toBe("json");
195
+ });
196
+ it("reports nothing configured rather than throwing on a missing file", () => {
197
+ expect(readCodexTelemetry(configPath)).toMatchObject({
198
+ exists: false,
199
+ token: null,
200
+ endpoint: null,
201
+ });
202
+ });
203
+ });
204
+ describe("hooks.json", () => {
205
+ /** Reads one event's matcher groups back out with the shape Codex expects. */
206
+ const groups = (file, event) => file.hooks[event] ?? [];
207
+ const userHooks = () => ({
208
+ description: "my hooks",
209
+ hooks: {
210
+ SessionStart: [
211
+ { hooks: [{ type: "command", command: "echo user-session-start" }] },
212
+ ],
213
+ PreToolUse: [
214
+ { matcher: "shell", hooks: [{ type: "command", command: "echo pre" }] },
215
+ ],
216
+ },
217
+ });
218
+ it("appends our hook without removing the user's", () => {
219
+ const next = applyCodexHooks(userHooks(), INSTALL);
220
+ const start = groups(next, "SessionStart");
221
+ expect(start).toHaveLength(2);
222
+ expect(start[0].hooks[0].command).toBe("echo user-session-start");
223
+ expect(start[1].hooks[0].command).toBe(INSTALL.hookCommand);
224
+ expect(groups(next, "PreToolUse")).toEqual(userHooks().hooks.PreToolUse);
225
+ });
226
+ it("gives SessionEnd the 3s budget Codex clamps it to", () => {
227
+ const next = applyCodexHooks({}, INSTALL);
228
+ // Codex hard-clamps SessionEnd and prints a warning on every session
229
+ // start if we ask for more. 10s for SessionStart, which it honours.
230
+ expect(groups(next, "SessionStart")[0].hooks[0].timeout).toBe(10);
231
+ expect(groups(next, "SessionEnd")[0].hooks[0].timeout).toBe(3);
232
+ });
233
+ it("never writes an `async` key, on either event", () => {
234
+ // The regression this pins cost a real user their repository
235
+ // attribution. Codex 0.147 does not skip an async hook quietly-ish and
236
+ // carry on: it drops SessionStart entirely, so `cwd` — the only source
237
+ // of repository, branch and starting SHA anywhere in the pipeline —
238
+ // never arrives, while spend keeps flowing in unattributed. 0.148
239
+ // honours it on SessionStart but still forces SessionEnd synchronous
240
+ // and warns once per session.
241
+ //
242
+ // `agent hook` backgrounds itself instead, so this key has no upside on
243
+ // any version.
244
+ const next = applyCodexHooks({}, INSTALL);
245
+ for (const event of ["SessionStart", "SessionEnd"]) {
246
+ const entry = groups(next, event)[0].hooks[0];
247
+ expect(entry).not.toHaveProperty("async");
248
+ }
249
+ });
250
+ it("replaces our hook on re-run instead of installing a second", () => {
251
+ const once = applyCodexHooks(userHooks(), INSTALL);
252
+ const twice = applyCodexHooks(once, {
253
+ hookCommand: "/usr/local/bin/clocktopus agent hook --provider codex_cli",
254
+ });
255
+ // Two of our hooks would POST every SessionStart twice.
256
+ const start = groups(twice, "SessionStart");
257
+ expect(start).toHaveLength(2);
258
+ expect(start[1].hooks[0].command).toContain("/usr/local/bin/clocktopus");
259
+ });
260
+ it("removes ours and leaves the user's intact", () => {
261
+ const installed = applyCodexHooks(userHooks(), INSTALL);
262
+ const { file, removed } = removeCodexHooks(installed);
263
+ expect(removed).toBe(true);
264
+ expect(file).toMatchObject({ hooks: userHooks().hooks });
265
+ });
266
+ it("round-trips through disk and finds our command again", () => {
267
+ writeCodexHooks(applyCodexHooks(userHooks(), INSTALL), hooksPath);
268
+ const read = readCodexHooks(hooksPath);
269
+ expect(read.commands.SessionStart).toBe(INSTALL.hookCommand);
270
+ expect(read.commands.SessionEnd).toBe(INSTALL.hookCommand);
271
+ });
272
+ it("raises rather than overwriting a hooks.json it cannot parse", () => {
273
+ writeFileSync(hooksPath, "{ not json");
274
+ expect(() => readCodexHooks(hooksPath)).toThrow(CodexConfigParseError);
275
+ });
276
+ });
277
+ describe("readCodexHookTrust — Codex's per-entry trust records", () => {
278
+ /**
279
+ * The key format is taken verbatim from a real `~/.codex/config.toml`
280
+ * after Codex 0.147 prompted and the answer was persisted. Trust is
281
+ * recorded per hook *entry* — `<path>:<event>:<group>:<index>` — not per
282
+ * file, which an earlier version of this function assumed; matching the
283
+ * bare path found nothing and reported a correctly-approved install as
284
+ * still pending, forever.
285
+ */
286
+ const trustState = (entries) => `[hooks.state]\n${entries
287
+ .map((key) => `\n[hooks.state."${key}"]\ntrusted_hash = "sha256:c3ffbe52"\nenabled = true\n`)
288
+ .join("")}`;
289
+ it("reads a fully approved install", () => {
290
+ writeFileSync(configPath, trustState([
291
+ `${hooksPath}:session_start:0:0`,
292
+ `${hooksPath}:session_end:0:0`,
293
+ ]));
294
+ // `hooksPath` is the temp dir's file, so point the reader at it too.
295
+ process.env.CODEX_HOME = dir;
296
+ try {
297
+ expect(readCodexHookTrust(configPath)).toEqual({
298
+ trusted: ["SessionStart", "SessionEnd"],
299
+ untrusted: [],
300
+ });
301
+ }
302
+ finally {
303
+ delete process.env.CODEX_HOME;
304
+ }
305
+ });
306
+ it("spots the half-approved install that loses repository context", () => {
307
+ // Exactly the state an `async: true` SessionStart left behind on Codex
308
+ // 0.147: the hook was skipped outright, so it was never offered for
309
+ // approval, while SessionEnd was. Sessions close but never open.
310
+ writeFileSync(configPath, trustState([`${hooksPath}:session_end:0:0`]));
311
+ process.env.CODEX_HOME = dir;
312
+ try {
313
+ expect(readCodexHookTrust(configPath)).toEqual({
314
+ trusted: ["SessionEnd"],
315
+ untrusted: ["SessionStart"],
316
+ });
317
+ }
318
+ finally {
319
+ delete process.env.CODEX_HOME;
320
+ }
321
+ });
322
+ it("reads Codex 0.148's hash-only record, which omits `enabled`", () => {
323
+ // 0.148 persists `hooks.state."<key>".trusted_hash` and nothing else —
324
+ // its `HookStateToml` has no `enabled` field. Demanding `enabled = true`
325
+ // reported a just-approved install as still pending, and no amount of
326
+ // restarting Codex could clear it: it never prompts again once trusted.
327
+ writeFileSync(configPath, `[hooks.state."${hooksPath}:session_start:0:0"]\ntrusted_hash = "sha256:c3ffbe52"\n` +
328
+ `\n[hooks.state."${hooksPath}:session_end:0:0"]\ntrusted_hash = "sha256:8b152fa9"\nenabled = true\n`);
329
+ process.env.CODEX_HOME = dir;
330
+ try {
331
+ expect(readCodexHookTrust(configPath)).toEqual({
332
+ trusted: ["SessionStart", "SessionEnd"],
333
+ untrusted: [],
334
+ });
335
+ }
336
+ finally {
337
+ delete process.env.CODEX_HOME;
338
+ }
339
+ });
340
+ it("treats an entry the user explicitly disabled as untrusted", () => {
341
+ writeFileSync(configPath, `[hooks.state."${hooksPath}:session_start:0:0"]\ntrusted_hash = "sha256:c3ffbe52"\nenabled = false\n`);
342
+ process.env.CODEX_HOME = dir;
343
+ try {
344
+ expect(readCodexHookTrust(configPath).untrusted).toEqual([
345
+ "SessionStart",
346
+ "SessionEnd",
347
+ ]);
348
+ }
349
+ finally {
350
+ delete process.env.CODEX_HOME;
351
+ }
352
+ });
353
+ it("reports everything untrusted when there is no config at all", () => {
354
+ expect(readCodexHookTrust(join(dir, "missing.toml")).untrusted).toEqual([
355
+ "SessionStart",
356
+ "SessionEnd",
357
+ ]);
358
+ });
359
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Arguments for reading the session's slice of the reflog, or `null` when the
3
+ * session's start was never recorded.
4
+ *
5
+ * A null return means *declare nothing*, deliberately. Without a lower bound the
6
+ * reflog would hand back the checkout's entire history; the time-window rule already
7
+ * covers a session that cannot declare, and over-declaring is the one mistake it
8
+ * cannot undo — a `declared` link outranks the window and is permanent.
9
+ */
10
+ export declare function buildReflogArgs(input: {
11
+ /** ISO timestamp recorded at SessionStart. */
12
+ startedAt?: string;
13
+ /** Upper bound, for the sweep: a session that died hours ago owns nothing since. */
14
+ until?: string;
15
+ }): string[] | null;
16
+ /**
17
+ * The commits created in this checkout, newest first, from `buildReflogArgs` output.
18
+ *
19
+ * Three actions always author a commit here and are kept: `commit` (with its
20
+ * `(initial)`, `(amend)` and `(merge)` variants), `revert` and `cherry-pick`.
21
+ * `checkout` and `reset` move HEAD without writing anything.
22
+ *
23
+ * **`merge` and `pull` are excluded even though a clean merge does author a commit**,
24
+ * because nothing in the reflog separates one you made from one you fast-forwarded
25
+ * onto. Parent count looks like it would and does not — a plain `git pull` on a
26
+ * repository that merges its pull requests lands on a two-parent commit somebody else
27
+ * wrote:
28
+ *
29
+ * parents=14c3726 6d88961 | pull: Fast-forward
30
+ *
31
+ * A conflicted merge is still caught, since resolving one ends in `commit (merge)`.
32
+ * A clean one falls to the time-window rule.
33
+ *
34
+ * `rebase (pick)` is excluded for the matching reason: replaying a branch would
35
+ * declare every commit on it, including work authored long before the session. The
36
+ * cost is that a session which commits and then rebases declares the pre-rebase SHA,
37
+ * which was never pushed and simply joins to nothing.
38
+ *
39
+ * Every exclusion here is recoverable and every wrong inclusion is not: a `declared`
40
+ * link outranks the time window and is permanent.
41
+ */
42
+ export declare function parseReflogCommits(output: string | undefined): string[];
43
+ //# sourceMappingURL=declared-commits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"declared-commits.d.ts","sourceRoot":"","sources":["../../../src/lib/declared-commits.ts"],"names":[],"mappings":"AAoBA;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE;IACrC,8CAA8C;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GAAG,MAAM,EAAE,GAAG,IAAI,CA2BlB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAyBvE"}
@@ -0,0 +1,114 @@
1
+ // Resolving the commits a session actually created.
2
+ //
3
+ // The obvious answer, `git rev-list before..head`, answers a different question:
4
+ // "what is reachable from here that was not reachable from there". Merged and pulled
5
+ // history is reachable, so it gets declared — one real session claimed 105 commits
6
+ // reaching two months back (BLA-595) — and a mid-session switch to a *pre-existing*
7
+ // branch makes the session's own earlier commits unreachable, so they vanish.
8
+ //
9
+ // The reflog answers the right question. It records HEAD movements in this checkout,
10
+ // tagged with what caused them, so a `pull` or a `merge` is one entry rather than one
11
+ // per commit it brought along, and a branch switch is irrelevant to what was authored.
12
+ // It is also per-worktree (`.git/worktrees/<name>/logs/HEAD`), so parallel agents in
13
+ // separate worktrees observe disjoint histories and cannot both claim a commit.
14
+ //
15
+ // Two agents in one worktree share a reflog and remain genuinely indistinguishable.
16
+ // That is a real limit, not an oversight.
17
+ /** Ceiling on what one session may declare. */
18
+ const MAX_DECLARED_COMMITS = 200;
19
+ /**
20
+ * Arguments for reading the session's slice of the reflog, or `null` when the
21
+ * session's start was never recorded.
22
+ *
23
+ * A null return means *declare nothing*, deliberately. Without a lower bound the
24
+ * reflog would hand back the checkout's entire history; the time-window rule already
25
+ * covers a session that cannot declare, and over-declaring is the one mistake it
26
+ * cannot undo — a `declared` link outranks the window and is permanent.
27
+ */
28
+ export function buildReflogArgs(input) {
29
+ // No grace before `startedAt`, deliberately. Both timestamps come from the same
30
+ // machine clock, so there is no skew to absorb, and SessionStart fires before the
31
+ // agent can act, so nothing it wrote can predate the stamp. A backward grace could
32
+ // therefore only admit a commit made *before* the session — the `wip` a developer
33
+ // makes just before launching an agent — as `declared`, which outranks the time
34
+ // window and is permanent. `--prune` cannot undo it either: that cuts at an hour.
35
+ const since = asIso(input.startedAt);
36
+ if (!since)
37
+ return null;
38
+ const args = [
39
+ "reflog",
40
+ "show",
41
+ "HEAD",
42
+ "--format=%H %gs",
43
+ `--since=${since}`,
44
+ ];
45
+ const until = asIso(input.until);
46
+ if (until)
47
+ args.push(`--until=${until}`);
48
+ // A working tree containing a file called `HEAD` makes git refuse the whole read —
49
+ // "ambiguous argument 'HEAD': both revision and filename" — which `git()` swallows,
50
+ // so the session would silently declare nothing.
51
+ args.push("--");
52
+ return args;
53
+ }
54
+ /**
55
+ * The commits created in this checkout, newest first, from `buildReflogArgs` output.
56
+ *
57
+ * Three actions always author a commit here and are kept: `commit` (with its
58
+ * `(initial)`, `(amend)` and `(merge)` variants), `revert` and `cherry-pick`.
59
+ * `checkout` and `reset` move HEAD without writing anything.
60
+ *
61
+ * **`merge` and `pull` are excluded even though a clean merge does author a commit**,
62
+ * because nothing in the reflog separates one you made from one you fast-forwarded
63
+ * onto. Parent count looks like it would and does not — a plain `git pull` on a
64
+ * repository that merges its pull requests lands on a two-parent commit somebody else
65
+ * wrote:
66
+ *
67
+ * parents=14c3726 6d88961 | pull: Fast-forward
68
+ *
69
+ * A conflicted merge is still caught, since resolving one ends in `commit (merge)`.
70
+ * A clean one falls to the time-window rule.
71
+ *
72
+ * `rebase (pick)` is excluded for the matching reason: replaying a branch would
73
+ * declare every commit on it, including work authored long before the session. The
74
+ * cost is that a session which commits and then rebases declares the pre-rebase SHA,
75
+ * which was never pushed and simply joins to nothing.
76
+ *
77
+ * Every exclusion here is recoverable and every wrong inclusion is not: a `declared`
78
+ * link outranks the time window and is permanent.
79
+ */
80
+ export function parseReflogCommits(output) {
81
+ if (!output?.trim())
82
+ return [];
83
+ const shas = [];
84
+ const seen = new Set();
85
+ for (const line of output.split("\n")) {
86
+ // The SHA can contain no spaces, so the first one ends it; the subject keeps its
87
+ // own.
88
+ const separator = line.indexOf(" ");
89
+ if (separator === -1)
90
+ continue;
91
+ const sha = line.slice(0, separator).trim();
92
+ const subject = line.slice(separator + 1).trim();
93
+ if (!/^[0-9a-f]{7,64}$/i.test(sha))
94
+ continue;
95
+ if (!/^(commit|revert|cherry-pick)\b/.test(subject))
96
+ continue;
97
+ if (seen.has(sha))
98
+ continue;
99
+ seen.add(sha);
100
+ shas.push(sha);
101
+ if (shas.length === MAX_DECLARED_COMMITS)
102
+ break;
103
+ }
104
+ return shas;
105
+ }
106
+ function asIso(value) {
107
+ return asDate(value)?.toISOString() ?? null;
108
+ }
109
+ function asDate(value) {
110
+ if (!value)
111
+ return null;
112
+ const at = new Date(value);
113
+ return Number.isNaN(at.getTime()) ? null : at;
114
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=declared-commits.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"declared-commits.test.d.ts","sourceRoot":"","sources":["../../../src/lib/declared-commits.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildReflogArgs, parseReflogCommits } from "./declared-commits.js";
3
+ const STARTED_AT = "2026-08-20T05:35:45.728Z";
4
+ const SHA = "418a320a048f839f2200106fbcffbb6f7683d135";
5
+ const reflog = (...lines) => lines.join("\n");
6
+ describe("buildReflogArgs", () => {
7
+ it("bounds the read at the session start exactly", () => {
8
+ expect(buildReflogArgs({ startedAt: STARTED_AT })).toContain(`--since=${STARTED_AT}`);
9
+ });
10
+ /**
11
+ * No backward grace. Both stamps come from one machine clock, and SessionStart fires
12
+ * before the agent can act, so a grace could only admit a commit made *before* the
13
+ * session — as `declared`, which outranks the time window and is permanent.
14
+ */
15
+ it("admits nothing committed before the session started", () => {
16
+ const args = buildReflogArgs({ startedAt: STARTED_AT });
17
+ const since = args
18
+ .find((arg) => arg.startsWith("--since="))
19
+ .replace("--since=", "");
20
+ expect(new Date(since).getTime()).toBe(new Date(STARTED_AT).getTime());
21
+ });
22
+ /** The sweep runs hours late; a session that died owns nothing committed since. */
23
+ it("takes an upper bound, for the sweep", () => {
24
+ const args = buildReflogArgs({
25
+ startedAt: STARTED_AT,
26
+ until: "2026-08-20T11:56:30.103Z",
27
+ });
28
+ expect(args).toContain("--until=2026-08-20T11:56:30.103Z");
29
+ });
30
+ /**
31
+ * A working tree with a file called `HEAD` otherwise makes git refuse the read
32
+ * outright — "ambiguous argument 'HEAD': both revision and filename".
33
+ */
34
+ it("separates the revision from any path of the same name", () => {
35
+ expect(buildReflogArgs({ startedAt: STARTED_AT }).at(-1)).toBe("--");
36
+ });
37
+ it("omits the upper bound for a live SessionEnd", () => {
38
+ const args = buildReflogArgs({ startedAt: STARTED_AT });
39
+ expect(args.some((arg) => arg.startsWith("--until="))).toBe(false);
40
+ });
41
+ /**
42
+ * Declaring nothing is the deliberate answer. Without a lower bound the reflog is
43
+ * the checkout's entire history, and a `declared` link outranks the time window, so
44
+ * over-declaring is the one mistake that cannot be undone later.
45
+ */
46
+ it("refuses to read at all when the session start was never recorded", () => {
47
+ expect(buildReflogArgs({})).toBeNull();
48
+ expect(buildReflogArgs({ startedAt: "not a date" })).toBeNull();
49
+ });
50
+ });
51
+ describe("parseReflogCommits", () => {
52
+ it("takes the commits this checkout authored", () => {
53
+ const shas = parseReflogCommits(reflog(`${SHA} commit: fix(bla-597): read the reflog`, "cd72bebc2ce64099f27f28e251be57cb53189328 commit (merge): merge the release branch"));
54
+ expect(shas).toEqual([SHA, "cd72bebc2ce64099f27f28e251be57cb53189328"]);
55
+ });
56
+ it("takes a revert and a cherry-pick, which always author here", () => {
57
+ const shas = parseReflogCommits(reflog('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa revert: Revert "feat: c2"', "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cherry-pick: feat: c3"));
58
+ expect(shas).toHaveLength(2);
59
+ });
60
+ /**
61
+ * Parent count looks like it would separate a merge you made from one you
62
+ * fast-forwarded onto, and does not: a plain `git pull` on a repository that merges
63
+ * its pull requests lands on a two-parent commit somebody else wrote. So merges are
64
+ * excluded, and a conflicted one is still caught as `commit (merge)`.
65
+ */
66
+ it("ignores a clean merge rather than risk claiming a pulled one", () => {
67
+ const shas = parseReflogCommits(reflog("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa merge side: Merge made by the 'ort' strategy.", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb pull: Fast-forward"));
68
+ expect(shas).toEqual([]);
69
+ });
70
+ /**
71
+ * The whole point. A pull or a merge is one reflog entry however much history it
72
+ * brings — the range that replaced this declared 118 commits for one such merge.
73
+ */
74
+ it("ignores history that merely arrived in the checkout", () => {
75
+ const shas = parseReflogCommits(reflog("4257246e3860ef8d834e4e4efbb7ed0da648ad23 pull: Fast-forward", "9f0ae840a64bae586d8e816124278ee0212ad0cc merge develop: Merge made by the 'ort' strategy.", "64d934042b0568a7acded2b059148dbc8f36ac83 checkout: moving from develop to a-branch", "6f59f8ad63a21000406480c17e766ea0706dff70 reset: moving to HEAD~1"));
76
+ expect(shas).toEqual([]);
77
+ });
78
+ /**
79
+ * A rebase writes new objects, but replaying a branch would declare work authored
80
+ * long before the session. Excluded; the pushed SHAs fall to the time-window rule.
81
+ */
82
+ it("ignores rebase replays", () => {
83
+ const shas = parseReflogCommits(reflog("64d934042b0568a7acded2b059148dbc8f36ac83 rebase (start): checkout develop", "cd72bebc2ce64099f27f28e251be57cb53189328 rebase (pick): fix(bla-595): bound the range", "cd72bebc2ce64099f27f28e251be57cb53189328 rebase (finish): returning to a-branch"));
84
+ expect(shas).toEqual([]);
85
+ });
86
+ it("keeps an amend, whose old and new SHAs are both reflog commits", () => {
87
+ const shas = parseReflogCommits(reflog("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa commit (amend): fix(bla-597): reworded", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb commit: fix(bla-597): original"));
88
+ // Only the amended SHA is ever pushed; the original simply joins to nothing.
89
+ expect(shas).toHaveLength(2);
90
+ });
91
+ it("keeps an initial commit", () => {
92
+ expect(parseReflogCommits(`${SHA} commit (initial): base`)).toEqual([SHA]);
93
+ });
94
+ it("survives a subject containing the field separator", () => {
95
+ const shas = parseReflogCommits(`${SHA} commit: fix(bla-597): read HEAD, then the reflog`);
96
+ expect(shas).toEqual([SHA]);
97
+ });
98
+ it("does not repeat a SHA HEAD returned to", () => {
99
+ const shas = parseReflogCommits(reflog(`${SHA} commit: one`, `${SHA} commit (amend): one, reworded`));
100
+ expect(shas).toEqual([SHA]);
101
+ });
102
+ it("returns nothing for an empty or unreadable reflog", () => {
103
+ expect(parseReflogCommits("")).toEqual([]);
104
+ expect(parseReflogCommits(undefined)).toEqual([]);
105
+ expect(parseReflogCommits("not a reflog line")).toEqual([]);
106
+ });
107
+ it("caps what one session may declare", () => {
108
+ const lines = Array.from({ length: 250 }, (_, i) => `${i.toString(16).padStart(40, "0")} commit: commit ${i}`);
109
+ expect(parseReflogCommits(reflog(...lines))).toHaveLength(200);
110
+ });
111
+ });
112
+ /**
113
+ * The sweep's upper bound is the state file's mtime, which is only a record of "last
114
+ * activity" on a host that rewrites the file as the session goes. Everywhere else the
115
+ * file is written once at SessionStart, so `until` equals `startedAt` and the window
116
+ * collapses onto a point — which is why `sweptCommits` refuses to read it at all for
117
+ * those hosts rather than relying on this shape.
118
+ */
119
+ describe("the window a sweep would ask for", () => {
120
+ it("collapses to a point when until is the session start", () => {
121
+ const args = buildReflogArgs({
122
+ startedAt: STARTED_AT,
123
+ until: STARTED_AT,
124
+ });
125
+ const since = args.find((a) => a.startsWith("--since=")).slice(8);
126
+ const until = args.find((a) => a.startsWith("--until=")).slice(8);
127
+ expect(new Date(since).getTime()).toBe(new Date(until).getTime());
128
+ });
129
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Parses `git remote -v` into a deduplicated URL list, `origin` first.
3
+ *
4
+ * Order is load-bearing in one narrow way: the first entry is what a receiver older
5
+ * than BLA-598 reads as the only remote, so keeping `origin` there preserves the
6
+ * previous behaviour exactly.
7
+ */
8
+ export declare function parseGitRemotes(output: string | undefined): string[];
9
+ //# sourceMappingURL=git-remotes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git-remotes.d.ts","sourceRoot":"","sources":["../../../src/lib/git-remotes.ts"],"names":[],"mappings":"AAWA;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAkCpE"}