trekoon 0.1.1 → 0.1.4

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,265 @@
1
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { hasFlag, parseArgs, readMissingOptionValue, readOption } from "./arg-parser";
6
+
7
+ import { failResult, okResult } from "../io/output";
8
+ import { type CliContext, type CliResult } from "../runtime/command-types";
9
+
10
+ const SKILLS_USAGE = "Usage: trekoon skills install [--link --editor opencode|claude] [--to <path>]";
11
+ const EDITOR_NAMES = ["opencode", "claude"] as const;
12
+
13
+ type EditorName = (typeof EDITOR_NAMES)[number];
14
+
15
+ interface InstallOutcome {
16
+ readonly sourcePath: string;
17
+ readonly installedPath: string;
18
+ readonly installedDir: string;
19
+ readonly linkPath: string | null;
20
+ readonly linkTarget: string | null;
21
+ }
22
+
23
+ function invalidArgs(message: string): CliResult {
24
+ return failResult({
25
+ command: "skills",
26
+ human: `${message}\n${SKILLS_USAGE}`,
27
+ data: { message },
28
+ error: {
29
+ code: "invalid_args",
30
+ message,
31
+ },
32
+ });
33
+ }
34
+
35
+ function invalidInput(command: string, message: string, data: Record<string, unknown>): CliResult {
36
+ return failResult({
37
+ command,
38
+ human: message,
39
+ data: {
40
+ code: "invalid_input",
41
+ ...data,
42
+ },
43
+ error: {
44
+ code: "invalid_input",
45
+ message,
46
+ },
47
+ });
48
+ }
49
+
50
+ function resolveBundledSkillFilePath(): string {
51
+ return fileURLToPath(new URL("../../.agents/skills/trekoon/SKILL.md", import.meta.url));
52
+ }
53
+
54
+ function toAbsolutePath(cwd: string, pathValue: string): string {
55
+ if (isAbsolute(pathValue)) {
56
+ return pathValue;
57
+ }
58
+
59
+ return resolve(cwd, pathValue);
60
+ }
61
+
62
+ function resolveLinkRoot(cwd: string, editor: EditorName, toOverride: string | undefined): string {
63
+ if (toOverride !== undefined) {
64
+ return toAbsolutePath(cwd, toOverride);
65
+ }
66
+
67
+ if (editor === "opencode") {
68
+ return join(cwd, ".opencode", "skills");
69
+ }
70
+
71
+ return join(cwd, ".claude", "skills");
72
+ }
73
+
74
+ function replaceOrCreateSymlink(linkPath: string, targetPath: string): CliResult | null {
75
+ if (!existsSync(linkPath)) {
76
+ mkdirSync(dirname(linkPath), { recursive: true });
77
+ symlinkSync(targetPath, linkPath, "dir");
78
+ return null;
79
+ }
80
+
81
+ const existing = lstatSync(linkPath);
82
+ if (!existing.isSymbolicLink()) {
83
+ return failResult({
84
+ command: "skills.install",
85
+ human: `Cannot create symlink: path exists and is not a link (${linkPath}).`,
86
+ data: {
87
+ code: "path_conflict",
88
+ linkPath,
89
+ targetPath,
90
+ },
91
+ error: {
92
+ code: "path_conflict",
93
+ message: "Symlink destination exists as a non-link path",
94
+ },
95
+ });
96
+ }
97
+
98
+ const existingRawTarget: string = readlinkSync(linkPath);
99
+ const existingAbsoluteTarget: string = toAbsolutePath(dirname(linkPath), existingRawTarget);
100
+ const expectedTarget: string = resolve(targetPath);
101
+ if (existingAbsoluteTarget !== expectedTarget) {
102
+ return failResult({
103
+ command: "skills.install",
104
+ human: `Cannot replace existing link at ${linkPath}; it points to ${existingAbsoluteTarget}.`,
105
+ data: {
106
+ code: "path_conflict",
107
+ linkPath,
108
+ existingTarget: existingAbsoluteTarget,
109
+ expectedTarget,
110
+ },
111
+ error: {
112
+ code: "path_conflict",
113
+ message: "Symlink destination points to a different target",
114
+ },
115
+ });
116
+ }
117
+
118
+ rmSync(linkPath, { force: true });
119
+ symlinkSync(targetPath, linkPath, "dir");
120
+ return null;
121
+ }
122
+
123
+ function runSkillsInstall(context: CliContext): CliResult {
124
+ const parsed = parseArgs(context.args);
125
+ const missingValue = readMissingOptionValue(parsed.missingOptionValues, "editor", "to");
126
+ if (missingValue !== undefined) {
127
+ return invalidInput("skills.install", `Option --${missingValue} requires a value.`, {
128
+ option: missingValue,
129
+ });
130
+ }
131
+
132
+ if (parsed.positional.length > 1) {
133
+ return invalidArgs("Unexpected positional arguments for skills install.");
134
+ }
135
+
136
+ const wantsLink: boolean = hasFlag(parsed.flags, "link");
137
+ const rawEditor: string | undefined = readOption(parsed.options, "editor");
138
+ const rawTo: string | undefined = readOption(parsed.options, "to");
139
+
140
+ if (!wantsLink && rawEditor !== undefined) {
141
+ return invalidInput("skills.install", "--editor requires --link.", {
142
+ editor: rawEditor,
143
+ });
144
+ }
145
+
146
+ if (!wantsLink && rawTo !== undefined) {
147
+ return invalidInput("skills.install", "--to requires --link.", {
148
+ to: rawTo,
149
+ });
150
+ }
151
+
152
+ if (wantsLink && rawEditor === undefined) {
153
+ return invalidArgs("skills install --link requires --editor opencode|claude.");
154
+ }
155
+
156
+ if (rawEditor !== undefined && !EDITOR_NAMES.includes(rawEditor as EditorName)) {
157
+ return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude", {
158
+ editor: rawEditor,
159
+ allowedEditors: EDITOR_NAMES,
160
+ });
161
+ }
162
+
163
+ const editor: EditorName | undefined = rawEditor as EditorName | undefined;
164
+
165
+ const sourcePath: string = resolveBundledSkillFilePath();
166
+ if (!existsSync(sourcePath)) {
167
+ return failResult({
168
+ command: "skills.install",
169
+ human: `Bundled skill asset not found at ${sourcePath}`,
170
+ data: {
171
+ code: "missing_asset",
172
+ sourcePath,
173
+ },
174
+ error: {
175
+ code: "missing_asset",
176
+ message: "Bundled skill asset not found",
177
+ },
178
+ });
179
+ }
180
+
181
+ const installPath = join(context.cwd, ".agents", "skills", "trekoon", "SKILL.md");
182
+ const installDir = dirname(installPath);
183
+
184
+ let outcome: InstallOutcome;
185
+
186
+ try {
187
+ mkdirSync(installDir, { recursive: true });
188
+ copyFileSync(sourcePath, installPath);
189
+
190
+ let linkPath: string | null = null;
191
+ let linkTarget: string | null = null;
192
+
193
+ if (wantsLink && editor !== undefined) {
194
+ const linkRoot: string = resolveLinkRoot(context.cwd, editor, rawTo);
195
+ linkPath = join(linkRoot, "trekoon");
196
+ linkTarget = installDir;
197
+ const linkFailure = replaceOrCreateSymlink(linkPath, linkTarget);
198
+ if (linkFailure) {
199
+ return linkFailure;
200
+ }
201
+ }
202
+
203
+ outcome = {
204
+ sourcePath,
205
+ installedPath: installPath,
206
+ installedDir: installDir,
207
+ linkPath,
208
+ linkTarget,
209
+ };
210
+ } catch (error: unknown) {
211
+ const message = error instanceof Error ? error.message : "Unknown skills install failure";
212
+ return failResult({
213
+ command: "skills.install",
214
+ human: `Failed to install skill: ${message}`,
215
+ data: {
216
+ code: "install_failed",
217
+ message,
218
+ },
219
+ error: {
220
+ code: "install_failed",
221
+ message,
222
+ },
223
+ });
224
+ }
225
+
226
+ return okResult({
227
+ command: "skills.install",
228
+ human: outcome.linkPath
229
+ ? [
230
+ "Installed Trekoon skill and linked editor path.",
231
+ `Source: ${outcome.sourcePath}`,
232
+ `Installed file: ${outcome.installedPath}`,
233
+ `Link path: ${outcome.linkPath}`,
234
+ `Link target: ${outcome.linkTarget}`,
235
+ ].join("\n")
236
+ : [
237
+ "Installed Trekoon skill.",
238
+ `Source: ${outcome.sourcePath}`,
239
+ `Installed file: ${outcome.installedPath}`,
240
+ ].join("\n"),
241
+ data: {
242
+ sourcePath: outcome.sourcePath,
243
+ installedPath: outcome.installedPath,
244
+ installedDir: outcome.installedDir,
245
+ linked: outcome.linkPath !== null,
246
+ linkPath: outcome.linkPath,
247
+ linkTarget: outcome.linkTarget,
248
+ },
249
+ });
250
+ }
251
+
252
+ export async function runSkills(context: CliContext): Promise<CliResult> {
253
+ const parsed = parseArgs(context.args);
254
+ const subcommand: string | undefined = parsed.positional[0];
255
+ if (!subcommand) {
256
+ return invalidArgs("Missing skills subcommand.");
257
+ }
258
+
259
+ switch (subcommand) {
260
+ case "install":
261
+ return runSkillsInstall(context);
262
+ default:
263
+ return invalidArgs(`Unknown skills subcommand '${subcommand}'.`);
264
+ }
265
+ }
@@ -1,4 +1,4 @@
1
- import { parseArgs, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
1
+ import { hasFlag, parseArgs, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
2
2
 
3
3
  import { DomainError, type SubtaskRecord } from "../domain/types";
4
4
  import { TrackerDomain } from "../domain/tracker-domain";
@@ -13,6 +13,21 @@ function formatSubtask(subtask: SubtaskRecord): string {
13
13
 
14
14
  const VIEW_MODES = ["table", "compact"] as const;
15
15
 
16
+ function parseIdsOption(rawIds: string | undefined): string[] {
17
+ if (rawIds === undefined) {
18
+ return [];
19
+ }
20
+
21
+ return rawIds
22
+ .split(",")
23
+ .map((value) => value.trim())
24
+ .filter((value) => value.length > 0);
25
+ }
26
+
27
+ function appendLine(existing: string, line: string): string {
28
+ return existing.length > 0 ? `${existing}\n${line}` : line;
29
+ }
30
+
16
31
  function formatSubtaskListTable(subtasks: readonly SubtaskRecord[]): string {
17
32
  return formatHumanTable(
18
33
  ["ID", "TASK", "TITLE", "STATUS"],
@@ -138,6 +153,8 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
138
153
  }
139
154
  case "update": {
140
155
  const missingUpdateOption =
156
+ readMissingOptionValue(parsed.missingOptionValues, "ids") ??
157
+ readMissingOptionValue(parsed.missingOptionValues, "append") ??
141
158
  readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
142
159
  readMissingOptionValue(parsed.missingOptionValues, "status", "s");
143
160
  if (missingUpdateOption !== undefined) {
@@ -145,10 +162,112 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
145
162
  }
146
163
 
147
164
  const subtaskId: string = parsed.positional[1] ?? "";
165
+ const updateAll: boolean = hasFlag(parsed.flags, "all");
166
+ const rawIds: string | undefined = readOption(parsed.options, "ids");
167
+ const ids = parseIdsOption(rawIds);
148
168
  const title: string | undefined = readOption(parsed.options, "title");
149
169
  const description: string | undefined = readOption(parsed.options, "description", "d");
170
+ const append: string | undefined = readOption(parsed.options, "append");
150
171
  const status: string | undefined = readOption(parsed.options, "status", "s");
151
- const subtask = domain.updateSubtask(subtaskId, { title, description, status });
172
+
173
+ if (updateAll && ids.length > 0) {
174
+ return failResult({
175
+ command: "subtask.update",
176
+ human: "Use either --all or --ids, not both.",
177
+ data: { code: "invalid_input", target: ["all", "ids"] },
178
+ error: {
179
+ code: "invalid_input",
180
+ message: "--all and --ids are mutually exclusive",
181
+ },
182
+ });
183
+ }
184
+
185
+ if (append !== undefined && description !== undefined) {
186
+ return failResult({
187
+ command: "subtask.update",
188
+ human: "Use either --append or --description, not both.",
189
+ data: { code: "invalid_input", fields: ["append", "description"] },
190
+ error: {
191
+ code: "invalid_input",
192
+ message: "--append and --description are mutually exclusive",
193
+ },
194
+ });
195
+ }
196
+
197
+ const hasBulkTarget = updateAll || ids.length > 0;
198
+ if (hasBulkTarget) {
199
+ if (subtaskId.length > 0) {
200
+ return failResult({
201
+ command: "subtask.update",
202
+ human: "Do not pass a subtask id when using --all or --ids.",
203
+ data: { code: "invalid_input", id: subtaskId },
204
+ error: {
205
+ code: "invalid_input",
206
+ message: "Positional id is not allowed with --all/--ids",
207
+ },
208
+ });
209
+ }
210
+
211
+ if (title !== undefined || description !== undefined) {
212
+ return failResult({
213
+ command: "subtask.update",
214
+ human: "Bulk update supports only --append and/or --status.",
215
+ data: { code: "invalid_input" },
216
+ error: {
217
+ code: "invalid_input",
218
+ message: "Bulk update supports only --append and --status",
219
+ },
220
+ });
221
+ }
222
+
223
+ if (append === undefined && status === undefined) {
224
+ return failResult({
225
+ command: "subtask.update",
226
+ human: "Bulk update requires --append and/or --status.",
227
+ data: { code: "invalid_input" },
228
+ error: {
229
+ code: "invalid_input",
230
+ message: "Missing bulk update fields",
231
+ },
232
+ });
233
+ }
234
+
235
+ const targets = updateAll ? [...domain.listSubtasks()] : ids.map((id) => domain.getSubtaskOrThrow(id));
236
+ const subtasks = targets.map((target) =>
237
+ domain.updateSubtask(target.id, {
238
+ status,
239
+ description: append === undefined ? undefined : appendLine(target.description, append),
240
+ }),
241
+ );
242
+
243
+ return okResult({
244
+ command: "subtask.update",
245
+ human: `Updated ${subtasks.length} subtask(s)`,
246
+ data: {
247
+ subtasks,
248
+ target: updateAll ? "all" : "ids",
249
+ ids: subtasks.map((subtask) => subtask.id),
250
+ },
251
+ });
252
+ }
253
+
254
+ if (subtaskId.length === 0) {
255
+ return failResult({
256
+ command: "subtask.update",
257
+ human: "Provide a subtask id, or use --all/--ids for bulk update.",
258
+ data: { code: "invalid_input" },
259
+ error: {
260
+ code: "invalid_input",
261
+ message: "Missing subtask id",
262
+ },
263
+ });
264
+ }
265
+
266
+ const nextDescription =
267
+ append === undefined
268
+ ? description
269
+ : appendLine(domain.getSubtaskOrThrow(subtaskId).description, append);
270
+ const subtask = domain.updateSubtask(subtaskId, { title, description: nextDescription, status });
152
271
 
153
272
  return okResult({
154
273
  command: "subtask.update",
@@ -522,44 +522,25 @@ export class TrackerDomain {
522
522
  }
523
523
 
524
524
  private wouldCreateCycle(sourceId: string, dependsOnId: string): boolean {
525
- const adjacency = new Map<string, string[]>();
526
- const rows = this.#db.query("SELECT source_id, depends_on_id FROM dependencies;").all() as Array<{
527
- source_id: string;
528
- depends_on_id: string;
529
- }>;
530
-
531
- for (const row of rows) {
532
- const existing = adjacency.get(row.source_id) ?? [];
533
- existing.push(row.depends_on_id);
534
- adjacency.set(row.source_id, existing);
535
- }
536
-
537
- const newEdges = adjacency.get(sourceId) ?? [];
538
- newEdges.push(dependsOnId);
539
- adjacency.set(sourceId, newEdges);
540
-
541
- const visited = new Set<string>();
542
- const queue: string[] = [dependsOnId];
543
-
544
- while (queue.length > 0) {
545
- const next = queue.shift();
546
- if (!next) {
547
- continue;
548
- }
549
- if (next === sourceId) {
550
- return true;
551
- }
552
- if (visited.has(next)) {
553
- continue;
554
- }
555
- visited.add(next);
556
- const outgoing = adjacency.get(next) ?? [];
557
- for (const neighbor of outgoing) {
558
- queue.push(neighbor);
559
- }
560
- }
525
+ const row = this.#db
526
+ .query(
527
+ `
528
+ WITH RECURSIVE reachable(id) AS (
529
+ SELECT ?
530
+ UNION
531
+ SELECT d.depends_on_id
532
+ FROM dependencies d
533
+ INNER JOIN reachable r ON d.source_id = r.id
534
+ )
535
+ SELECT 1 AS has_cycle
536
+ FROM reachable
537
+ WHERE id = ?
538
+ LIMIT 1;
539
+ `,
540
+ )
541
+ .get(dependsOnId, sourceId) as { has_cycle: number } | null;
561
542
 
562
- return false;
543
+ return row !== null;
563
544
  }
564
545
  }
565
546
 
@@ -1,8 +1,11 @@
1
1
  import { runHelp } from "../commands/help";
2
2
  import { runDep } from "../commands/dep";
3
3
  import { runEpic } from "../commands/epic";
4
+ import { runEvents } from "../commands/events";
4
5
  import { runInit } from "../commands/init";
6
+ import { runMigrate } from "../commands/migrate";
5
7
  import { runQuickstart } from "../commands/quickstart";
8
+ import { runSkills } from "../commands/skills";
6
9
  import { runSubtask } from "../commands/subtask";
7
10
  import { runSync } from "../commands/sync";
8
11
  import { runTask } from "../commands/task";
@@ -13,13 +16,17 @@ import { type CliContext, type CliResult, type OutputMode } from "./command-type
13
16
  const CLI_VERSION = "0.1.0";
14
17
 
15
18
  const SUPPORTED_ROOT_COMMANDS: readonly string[] = [
19
+ "help",
16
20
  "init",
17
21
  "quickstart",
18
22
  "epic",
19
23
  "task",
20
24
  "subtask",
21
25
  "dep",
26
+ "events",
27
+ "migrate",
22
28
  "sync",
29
+ "skills",
23
30
  "wipe",
24
31
  ];
25
32
 
@@ -128,6 +135,8 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
128
135
  };
129
136
 
130
137
  switch (parsed.command) {
138
+ case "help":
139
+ return runHelp(context);
131
140
  case "init":
132
141
  return runInit(context);
133
142
  case "quickstart":
@@ -142,8 +151,14 @@ export async function executeShell(parsed: ParsedInvocation, cwd: string = proce
142
151
  return runSubtask(context);
143
152
  case "dep":
144
153
  return runDep(context);
154
+ case "events":
155
+ return runEvents(context);
156
+ case "migrate":
157
+ return runMigrate(context);
145
158
  case "sync":
146
159
  return runSync(context);
160
+ case "skills":
161
+ return runSkills(context);
147
162
  default:
148
163
  return failResult({
149
164
  command: "shell",
@@ -11,7 +11,14 @@ export interface TrekoonDatabase {
11
11
  close(): void;
12
12
  }
13
13
 
14
- export function openTrekoonDatabase(workingDirectory: string = process.cwd()): TrekoonDatabase {
14
+ export interface OpenTrekoonDatabaseOptions {
15
+ readonly autoMigrate?: boolean;
16
+ }
17
+
18
+ export function openTrekoonDatabase(
19
+ workingDirectory: string = process.cwd(),
20
+ options: OpenTrekoonDatabaseOptions = {},
21
+ ): TrekoonDatabase {
15
22
  const paths: StoragePaths = resolveStoragePaths(workingDirectory);
16
23
 
17
24
  mkdirSync(paths.storageDir, { recursive: true });
@@ -22,7 +29,9 @@ export function openTrekoonDatabase(workingDirectory: string = process.cwd()): T
22
29
  db.exec("PRAGMA journal_mode = WAL;");
23
30
  db.exec("PRAGMA foreign_keys = ON;");
24
31
 
25
- migrateDatabase(db);
32
+ if (options.autoMigrate ?? true) {
33
+ migrateDatabase(db);
34
+ }
26
35
 
27
36
  return {
28
37
  db,
@@ -0,0 +1,138 @@
1
+ import { type Database } from "bun:sqlite";
2
+
3
+ export const DEFAULT_EVENT_RETENTION_DAYS = 90;
4
+ const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
5
+
6
+ export interface EventPruneOptions {
7
+ readonly retentionDays?: number;
8
+ readonly dryRun?: boolean;
9
+ readonly archive?: boolean;
10
+ readonly now?: number;
11
+ }
12
+
13
+ export interface EventPruneSummary {
14
+ readonly retentionDays: number;
15
+ readonly cutoffTimestamp: number;
16
+ readonly dryRun: boolean;
17
+ readonly archive: boolean;
18
+ readonly candidateCount: number;
19
+ readonly archivedCount: number;
20
+ readonly deletedCount: number;
21
+ }
22
+
23
+ function ensureArchiveTable(db: Database): void {
24
+ db.exec(`
25
+ CREATE TABLE IF NOT EXISTS event_archive (
26
+ id TEXT PRIMARY KEY,
27
+ entity_kind TEXT NOT NULL,
28
+ entity_id TEXT NOT NULL,
29
+ operation TEXT NOT NULL,
30
+ payload TEXT NOT NULL,
31
+ git_branch TEXT,
32
+ git_head TEXT,
33
+ created_at INTEGER NOT NULL,
34
+ updated_at INTEGER NOT NULL,
35
+ version INTEGER NOT NULL DEFAULT 1
36
+ );
37
+ `);
38
+ }
39
+
40
+ function assertRetentionDays(value: number): number {
41
+ if (!Number.isInteger(value) || value < 1) {
42
+ throw new Error("retentionDays must be a positive integer.");
43
+ }
44
+
45
+ return value;
46
+ }
47
+
48
+ function countCandidates(db: Database, cutoffTimestamp: number): number {
49
+ const row = db.query("SELECT COUNT(*) AS count FROM events WHERE created_at < ?;").get(cutoffTimestamp) as
50
+ | { count: number }
51
+ | null;
52
+
53
+ return row?.count ?? 0;
54
+ }
55
+
56
+ export function pruneEvents(db: Database, options: EventPruneOptions = {}): EventPruneSummary {
57
+ const retentionDays: number = assertRetentionDays(options.retentionDays ?? DEFAULT_EVENT_RETENTION_DAYS);
58
+ const dryRun: boolean = options.dryRun ?? false;
59
+ const archive: boolean = options.archive ?? false;
60
+ const now: number = options.now ?? Date.now();
61
+ const cutoffTimestamp: number = now - retentionDays * DAY_IN_MILLISECONDS;
62
+ const candidateCount: number = countCandidates(db, cutoffTimestamp);
63
+
64
+ if (dryRun || candidateCount === 0) {
65
+ return {
66
+ retentionDays,
67
+ cutoffTimestamp,
68
+ dryRun,
69
+ archive,
70
+ candidateCount,
71
+ archivedCount: 0,
72
+ deletedCount: 0,
73
+ };
74
+ }
75
+
76
+ return db.transaction((): EventPruneSummary => {
77
+ let archivedCount = 0;
78
+
79
+ if (archive) {
80
+ ensureArchiveTable(db);
81
+ const archived = db
82
+ .query(
83
+ `
84
+ INSERT INTO event_archive (
85
+ id,
86
+ entity_kind,
87
+ entity_id,
88
+ operation,
89
+ payload,
90
+ git_branch,
91
+ git_head,
92
+ created_at,
93
+ updated_at,
94
+ version
95
+ )
96
+ SELECT
97
+ id,
98
+ entity_kind,
99
+ entity_id,
100
+ operation,
101
+ payload,
102
+ git_branch,
103
+ git_head,
104
+ created_at,
105
+ updated_at,
106
+ version
107
+ FROM events
108
+ WHERE created_at < ?
109
+ ON CONFLICT(id) DO UPDATE SET
110
+ entity_kind = excluded.entity_kind,
111
+ entity_id = excluded.entity_id,
112
+ operation = excluded.operation,
113
+ payload = excluded.payload,
114
+ git_branch = excluded.git_branch,
115
+ git_head = excluded.git_head,
116
+ created_at = excluded.created_at,
117
+ updated_at = excluded.updated_at,
118
+ version = excluded.version;
119
+ `,
120
+ )
121
+ .run(cutoffTimestamp);
122
+
123
+ archivedCount = archived.changes;
124
+ }
125
+
126
+ const deleted = db.query("DELETE FROM events WHERE created_at < ?;").run(cutoffTimestamp);
127
+
128
+ return {
129
+ retentionDays,
130
+ cutoffTimestamp,
131
+ dryRun,
132
+ archive,
133
+ candidateCount,
134
+ archivedCount,
135
+ deletedCount: deleted.changes,
136
+ };
137
+ })();
138
+ }