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.
- package/.agents/skills/trekoon/SKILL.md +160 -6
- package/README.md +55 -2
- package/package.json +10 -2
- package/src/commands/events.ts +88 -0
- package/src/commands/help.ts +9 -1
- package/src/commands/migrate.ts +123 -0
- package/src/commands/skills.ts +265 -0
- package/src/commands/subtask.ts +121 -2
- package/src/domain/tracker-domain.ts +18 -37
- package/src/runtime/cli-shell.ts +15 -0
- package/src/storage/database.ts +11 -2
- package/src/storage/events-retention.ts +138 -0
- package/src/storage/migrations.ts +340 -19
- package/src/storage/schema.ts +1 -0
- package/src/storage/types.ts +1 -0
- package/src/sync/service.ts +9 -1
- package/AGENTS.md +0 -54
- package/CONTRIBUTING.md +0 -18
- package/bun.lock +0 -29
- package/tests/commands/dep.test.ts +0 -101
- package/tests/commands/epic.test.ts +0 -383
- package/tests/commands/subtask.test.ts +0 -132
- package/tests/commands/sync/sync-command.test.ts +0 -1
- package/tests/commands/sync.test.ts +0 -199
- package/tests/commands/task.test.ts +0 -474
- package/tests/integration/sync-workflow.test.ts +0 -279
- package/tests/io/human-table.test.ts +0 -81
- package/tests/runtime/output-mode.test.ts +0 -54
- package/tests/storage/database.test.ts +0 -91
- package/tsconfig.json +0 -19
|
@@ -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
|
+
}
|
package/src/commands/subtask.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
543
|
+
return row !== null;
|
|
563
544
|
}
|
|
564
545
|
}
|
|
565
546
|
|
package/src/runtime/cli-shell.ts
CHANGED
|
@@ -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",
|
package/src/storage/database.ts
CHANGED
|
@@ -11,7 +11,14 @@ export interface TrekoonDatabase {
|
|
|
11
11
|
close(): void;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export
|
|
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
|
-
|
|
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
|
+
}
|