@blastin-dev/clocktopus-cli 0.2.1 → 0.3.1

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 (41) hide show
  1. package/README.md +2 -2
  2. package/dist/src/commands/agent/doctor.d.ts.map +1 -1
  3. package/dist/src/commands/agent/doctor.js +26 -35
  4. package/dist/src/commands/agent/hook.d.ts.map +1 -1
  5. package/dist/src/commands/agent/hook.js +184 -180
  6. package/dist/src/commands/agent/setup.d.ts +0 -22
  7. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  8. package/dist/src/commands/agent/setup.js +40 -62
  9. package/dist/src/lib/agent-config.d.ts +0 -33
  10. package/dist/src/lib/agent-config.d.ts.map +1 -1
  11. package/dist/src/lib/agent-config.js +15 -26
  12. package/dist/src/lib/agent-hook-state.d.ts +2 -6
  13. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  14. package/dist/src/lib/agent-hook-state.js +29 -43
  15. package/dist/src/lib/agents.d.ts +0 -63
  16. package/dist/src/lib/agents.d.ts.map +1 -1
  17. package/dist/src/lib/agents.js +19 -26
  18. package/dist/src/lib/auth.d.ts.map +1 -1
  19. package/dist/src/lib/auth.js +11 -0
  20. package/dist/src/lib/claude-settings.d.ts +0 -36
  21. package/dist/src/lib/claude-settings.d.ts.map +1 -1
  22. package/dist/src/lib/claude-settings.js +37 -62
  23. package/dist/src/lib/codex-config.d.ts +0 -79
  24. package/dist/src/lib/codex-config.d.ts.map +1 -1
  25. package/dist/src/lib/codex-config.js +74 -116
  26. package/dist/src/lib/declared-commits.d.ts +43 -0
  27. package/dist/src/lib/declared-commits.d.ts.map +1 -0
  28. package/dist/src/lib/declared-commits.js +114 -0
  29. package/dist/src/lib/declared-commits.test.d.ts +2 -0
  30. package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
  31. package/dist/src/lib/declared-commits.test.js +129 -0
  32. package/dist/src/lib/git-remotes.d.ts +9 -0
  33. package/dist/src/lib/git-remotes.d.ts.map +1 -0
  34. package/dist/src/lib/git-remotes.js +50 -0
  35. package/dist/src/lib/git-remotes.test.d.ts +2 -0
  36. package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
  37. package/dist/src/lib/git-remotes.test.js +52 -0
  38. package/dist/src/lib/opencode-config.d.ts +0 -85
  39. package/dist/src/lib/opencode-config.d.ts.map +1 -1
  40. package/dist/src/lib/opencode-config.js +72 -112
  41. package/package.json +5 -5
@@ -2,51 +2,37 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
5
- /**
6
- * Reads and edits Codex CLI's configuration on the user's behalf.
7
- *
8
- * Codex splits what Claude Code keeps in one file: telemetry goes in
9
- * `~/.codex/config.toml` under `[otel]`, hooks go in a separate
10
- * `~/.codex/hooks.json`. Both are edited here under the same two rules as
11
- * `claude-settings.ts` — never write over something we could not parse, and
12
- * only ever touch keys we put there — plus a third that TOML forces on us:
13
- *
14
- * **Splice, don't re-serialise.** A round trip through a TOML parser throws
15
- * away every comment and blank line in the file. `config.toml` is a file
16
- * people hand-write and annotate, so only the `[otel]` region is replaced
17
- * textually; everything else survives byte for byte. `writeCodexConfig`
18
- * re-parses the spliced result and refuses to write if anything outside
19
- * `[otel]` moved, which is what makes the textual approach safe.
20
- *
21
- * ## What Codex does with these, and why it matters here
22
- *
23
- * - `otel.exporter` is the **log** exporter, and Codex POSTs to the URL
24
- * verbatim it does not append `/v1/logs` the way the OTel SDK does. The
25
- * path is therefore part of the configured endpoint, and
26
- * `readCodexTelemetry` strips it back off to recover the receiver base.
27
- * - Codex will not run a `hooks.json` it has not been shown: the first
28
- * session after this file changes prompts "Hooks need review" and
29
- * persists the answer under `[hooks.state]`. Setup cannot do that for the
30
- * user, so it tells them instead — see `setup.ts`.
31
- */
5
+ // Reads and edits Codex CLI's configuration on the user's behalf.
6
+ //
7
+ // Codex splits what Claude Code keeps in one file: telemetry in
8
+ // `~/.codex/config.toml` under `[otel]`, hooks in `~/.codex/hooks.json`. Both follow
9
+ // the same two rules as `claude-settings.ts` never write over something we could
10
+ // not parse, only ever touch keys we put there plus a third that TOML forces:
11
+ //
12
+ // Splice, don't re-serialise. A round trip through a TOML parser throws away every
13
+ // comment and blank line, and `config.toml` is hand-written and annotated. Only the
14
+ // `[otel]` region is replaced textually; `writeCodexConfig` re-parses the result and
15
+ // refuses to write if anything outside `[otel]` moved, which is what makes that safe.
16
+ //
17
+ // Two Codex behaviours matter here:
18
+ //
19
+ // - `otel.exporter` is the *log* exporter, and Codex POSTs to the URL verbatim — it
20
+ // does not append `/v1/logs` the way the OTel SDK does. The path is part of the
21
+ // configured endpoint, and `readCodexTelemetry` strips it back off.
22
+ // - Codex will not run a `hooks.json` it has not been shown: the first session after
23
+ // this file changes prompts "Hooks need review" and persists the answer under
24
+ // `[hooks.state]`. Setup cannot do that for the user, so it tells them — see
25
+ // `setup.ts`.
32
26
  export const CODEX_CONFIG_FILENAME = "config.toml";
33
27
  export const CODEX_HOOKS_FILENAME = "hooks.json";
34
- /**
35
- * The receiver path Codex's log exporter is pointed at.
36
- *
37
- * Load-bearing in both directions: written onto the endpoint because Codex
38
- * sends to the literal URL, and stripped when reading because the hook and
39
- * `/v1/verify` need the base.
40
- */
28
+ // The receiver path Codex's log exporter is pointed at. Load-bearing both ways:
29
+ // written onto the endpoint because Codex sends to the literal URL, and stripped when
30
+ // reading because the hook and `/v1/verify` need the base.
41
31
  export const CODEX_LOGS_PATH = "/v1/logs";
42
- /**
43
- * Seconds Codex will wait for each hook.
44
- *
45
- * SessionEnd is 3 rather than 10 because Codex hard-clamps it to 3 and
46
- * prints `warning: clamping SessionEnd hook timeout to 3s` on every single
47
- * session start otherwise — a permanent warning about a value we chose is
48
- * worse than the shorter budget, and the hook's own request timeout is 4s.
49
- */
32
+ // Seconds Codex will wait for each hook. SessionEnd is 3 rather than 10 because Codex
33
+ // hard-clamps it to 3 and otherwise prints a warning on every session start — a
34
+ // permanent warning about a value we chose is worse than the shorter budget, and the
35
+ // hook's own request timeout is 4s.
50
36
  export const CODEX_HOOK_TIMEOUT_SECONDS = { SessionStart: 10, SessionEnd: 3 };
51
37
  const HOOK_EVENTS = ["SessionStart", "SessionEnd"];
52
38
  /** Exported so callers can tell "none approved" from "some approved". */
@@ -60,8 +46,8 @@ export class CodexConfigParseError extends Error {
60
46
  }
61
47
  }
62
48
  export function codexConfigDir() {
63
- // Codex honours CODEX_HOME; following it means setup writes where that
64
- // installation actually reads.
49
+ // Codex honours CODEX_HOME; following it means setup writes where that installation
50
+ // actually reads.
65
51
  return process.env.CODEX_HOME || join(homedir(), ".codex");
66
52
  }
67
53
  export function codexConfigPath() {
@@ -70,9 +56,6 @@ export function codexConfigPath() {
70
56
  export function codexHooksPath() {
71
57
  return join(codexConfigDir(), CODEX_HOOKS_FILENAME);
72
58
  }
73
- /* -------------------------------------------------------------------------
74
- * config.toml
75
- * ---------------------------------------------------------------------- */
76
59
  export function readCodexConfig(path = codexConfigPath()) {
77
60
  if (!existsSync(path)) {
78
61
  return { path, exists: false, modifiedAt: null, text: "", config: {} };
@@ -97,8 +80,8 @@ const OWNED_OTEL_KEYS = ["exporter", "log_user_prompt"];
97
80
  export function buildCodexOtel(input) {
98
81
  const endpoint = `${input.endpoint.replace(/\/$/, "")}${CODEX_LOGS_PATH}`;
99
82
  return {
100
- // The receiver answers protobuf with an explicit 415, and only JSON
101
- // actually works. Codex's default for this exporter is `binary`.
83
+ // The receiver answers protobuf with an explicit 415, and only JSON actually works.
84
+ // Codex's default for this exporter is `binary`.
102
85
  exporter: {
103
86
  "otlp-http": {
104
87
  endpoint,
@@ -106,21 +89,15 @@ export function buildCodexOtel(input) {
106
89
  headers: { Authorization: `Bearer ${input.token}` },
107
90
  },
108
91
  },
109
- // Already the default, set explicitly anyway: the receiver never reads
110
- // the `prompt` attribute, but not sending it at all is one fewer thing
111
- // in flight. It does not cover tool output — Codex has a single log
112
- // exporter and no switch for that, which is why the receiver's
92
+ // Already the default, set explicitly anyway. It does not cover tool output — Codex
93
+ // has a single log exporter and no switch for that, which is why the receiver's
113
94
  // allowlist, not this line, is what actually holds.
114
95
  log_user_prompt: false,
115
96
  };
116
97
  }
117
- /**
118
- * Replaces the `[otel]` region of a TOML document, preserving everything else.
119
- *
120
- * Returns the new text. Region detection is deliberately dumb — table
121
- * headers only — and `writeCodexConfig` verifies the result by re-parsing,
122
- * so a document this misreads is refused rather than mangled.
123
- */
98
+ // Replaces the `[otel]` region of a TOML document, preserving everything else.
99
+ // Region detection is deliberately dumb table headers only and `writeCodexConfig`
100
+ // verifies by re-parsing, so a document this misreads is refused rather than mangled.
124
101
  export function spliceOtelSection(text, otel) {
125
102
  const rendered = otel && Object.keys(otel).length > 0
126
103
  ? stringifyToml({ otel }).trimEnd()
@@ -151,8 +128,8 @@ export function spliceOtelSection(text, otel) {
151
128
  const before = lines.slice(0, start);
152
129
  const after = lines.slice(end);
153
130
  const middle = rendered ? rendered.split("\n") : [];
154
- // Drop the blank line that separated a removed section from what follows,
155
- // so repeated add/remove cycles cannot pile up empty lines.
131
+ // Drop the blank line that separated a removed section from what follows, so repeated
132
+ // add/remove cycles cannot pile up empty lines.
156
133
  if (!rendered) {
157
134
  while (before.length > 0 && before[before.length - 1]?.trim() === "")
158
135
  before.pop();
@@ -161,15 +138,11 @@ export function spliceOtelSection(text, otel) {
161
138
  }
162
139
  return `${[...before, ...middle, ...after].join("\n").trimEnd()}\n`;
163
140
  }
164
- /**
165
- * Writes config.toml, keeping a one-deep backup and verifying the splice.
166
- *
167
- * The verification is the point: it re-parses what is about to be written
168
- * and compares every top-level key *except* `otel` against what was there
169
- * before. If the textual splice disturbed anything else — an exotic layout
170
- * the region scanner misread — the write is refused with the user's file
171
- * still intact.
172
- */
141
+ // Writes config.toml, keeping a one-deep backup and verifying the splice.
142
+ //
143
+ // The verification is the point: it re-parses what is about to be written and compares
144
+ // every top-level key except `otel` against what was there before. If the splice
145
+ // disturbed anything else, the write is refused with the user's file still intact.
173
146
  export function writeCodexConfig(input) {
174
147
  const path = input.path ?? codexConfigPath();
175
148
  let nextConfig;
@@ -197,9 +170,8 @@ export function writeCodexConfig(input) {
197
170
  backupPath = `${path}.clocktopus-backup`;
198
171
  copyFileSync(path, backupPath);
199
172
  }
200
- // Rename rather than write in place: a crash midway leaves either the old
201
- // file or the new one, never a truncated config Codex would refuse to
202
- // start with.
173
+ // Rename rather than write in place: a crash midway leaves either the old file or the
174
+ // new one, never a truncated config Codex would refuse to start with.
203
175
  const temporaryPath = `${path}.clocktopus-tmp`;
204
176
  writeFileSync(temporaryPath, input.nextText, {
205
177
  encoding: "utf8",
@@ -310,9 +282,8 @@ export function applyCodexHooks(file, input) {
310
282
  const existing = asTable(file.hooks);
311
283
  const hooks = { ...existing };
312
284
  for (const event of HOOK_EVENTS) {
313
- // Remove-then-append rather than append: re-running setup must not
314
- // leave two hooks firing per session, which would double every
315
- // SessionStart POST.
285
+ // Remove-then-append rather than append: re-running setup must not leave two hooks
286
+ // firing per session, which would double every SessionStart POST.
316
287
  hooks[event] = [
317
288
  ...withoutOurHooks(asGroups(existing[event])),
318
289
  {
@@ -321,21 +292,18 @@ export function applyCodexHooks(file, input) {
321
292
  type: "command",
322
293
  command: input.hookCommand,
323
294
  timeout: CODEX_HOOK_TIMEOUT_SECONDS[event],
324
- // No `async` key, deliberately. Codex 0.147 *skips* an async
325
- // SessionStart hook outrightcosting every session its
326
- // repository context, with only a startup warning to say so
327
- // and every version so far still runs an async SessionEnd
328
- // synchronously and warns about it once per session. `agent
329
- // hook` backgrounds itself instead, which is correct on all of
330
- // them and quiet on all of them.
295
+ // No `async` key, deliberately. Codex 0.147 *skips* an async SessionStart outright —
296
+ // costing every session its repository context and every version so far still runs
297
+ // an async SessionEnd synchronously and warns once per session. `agent hook`
298
+ // backgrounds itself instead, which is correct and quiet on all of them.
331
299
  },
332
300
  ],
333
301
  },
334
302
  ];
335
303
  }
336
304
  return {
337
- // Codex shows this string when it asks the user to trust the file, so
338
- // it is the one chance to say where it came from.
305
+ // Codex shows this string when it asks the user to trust the file, so it is the one
306
+ // chance to say where it came from.
339
307
  description: typeof file.description === "string" && file.description
340
308
  ? file.description
341
309
  : "Clocktopus agent telemetry",
@@ -379,27 +347,20 @@ export function writeCodexHooks(file, path = codexHooksPath()) {
379
347
  renameSync(temporaryPath, path);
380
348
  return { backupPath };
381
349
  }
382
- /**
383
- * Which of our hooks Codex has been shown and told to run.
384
- *
385
- * Trust is recorded **per hook entry**, not per file:
386
- *
387
- * ```toml
388
- * [hooks.state."/home/you/.codex/hooks.json:session_end:0:0"]
389
- * trusted_hash = "sha256:…"
390
- * ```
391
- *
392
- * The key is `<path>:<event>:<group index>:<hook index>`, with the event in
393
- * snake_case. Per-entry granularity is why this reports each event rather
394
- * than a single boolean: partial trust is a real state, and the one that
395
- * matters. A machine whose `SessionEnd` is trusted and whose `SessionStart`
396
- * is not records spend with no repository attached to it — which is exactly
397
- * what an `async: true` SessionStart used to produce on Codex 0.147, since
398
- * the hook was skipped before it could ever be offered for approval.
399
- *
400
- * The hash is Codex's own and is not recomputed here; changing `hooks.json`
401
- * invalidates it and Codex re-prompts, which is the behaviour we want.
402
- */
350
+ // Which of our hooks Codex has been shown and told to run.
351
+ //
352
+ // Trust is recorded per hook *entry*, not per file, keyed
353
+ // `<path>:<event>:<group index>:<hook index>` with the event in snake_case:
354
+ //
355
+ // [hooks.state."/home/you/.codex/hooks.json:session_end:0:0"]
356
+ // trusted_hash = "sha256:…"
357
+ //
358
+ // Per-entry granularity is why this reports each event rather than a single boolean:
359
+ // partial trust is a real state, and the one that matters. A machine whose SessionEnd
360
+ // is trusted and whose SessionStart is not records spend with no repository attached.
361
+ //
362
+ // The hash is Codex's own and is not recomputed here; changing `hooks.json`
363
+ // invalidates it and Codex re-prompts, which is the behaviour we want.
403
364
  export function readCodexHookTrust(path = codexConfigPath()) {
404
365
  let config;
405
366
  try {
@@ -414,18 +375,15 @@ export function readCodexHookTrust(path = codexConfigPath()) {
414
375
  for (const [key, value] of Object.entries(state)) {
415
376
  if (!key.startsWith(prefix))
416
377
  continue;
417
- // `enabled` is optional and version-dependent: Codex 0.147 wrote
418
- // `enabled = true` next to the hash, 0.148 persists `trusted_hash`
419
- // alone (its `HookStateToml` has no such field) and leaves any older
420
- // key untouched. Requiring it read a freshly approved 0.148 install as
421
- // pending forever. Absent means approved; only an explicit `false`
422
- // — a hook the user disabled — counts as untrusted.
378
+ // `enabled` is optional and version-dependent: 0.147 wrote `enabled = true` next to
379
+ // the hash, 0.148 persists `trusted_hash` alone. Requiring it read a freshly approved
380
+ // 0.148 install as pending forever. Absent means approved; only an explicit `false`
381
+ // counts as untrusted.
423
382
  const entry = asTable(value);
424
383
  if (entry.enabled === false || typeof entry.trusted_hash !== "string")
425
384
  continue;
426
- // `<path>:<event>:<group>:<index>` — the event is the segment after the
427
- // path, which may itself contain colons on no sane platform but is
428
- // sliced by prefix length rather than split, just in case.
385
+ // `<path>:<event>:<group>:<index>` — sliced by prefix length rather than split, since
386
+ // a path could in principle contain a colon.
429
387
  const event = key.slice(prefix.length).split(":")[0];
430
388
  const match = HOOK_EVENTS.find((candidate) => toSnakeCase(candidate) === event);
431
389
  if (match)
@@ -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"}