trekoon 0.4.2 → 0.4.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 +97 -208
- package/.agents/skills/trekoon/reference/execution-with-team.md +87 -149
- package/.agents/skills/trekoon/reference/execution.md +170 -380
- package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
- package/.agents/skills/trekoon/reference/planning.md +193 -330
- package/.agents/skills/trekoon/reference/sync.md +56 -103
- package/README.md +29 -10
- package/docs/ai-agents.md +48 -4
- package/docs/commands.md +34 -25
- package/docs/machine-contracts.md +1 -1
- package/docs/quickstart.md +9 -9
- package/package.json +2 -2
- package/src/board/asset-root.ts +73 -0
- package/src/board/assets/app.js +5 -3
- package/src/board/assets/components/Component.js +6 -8
- package/src/board/assets/state/actions.js +3 -0
- package/src/board/assets/state/api.js +48 -34
- package/src/board/assets/state/store.js +3 -0
- package/src/board/event-bus.ts +15 -0
- package/src/board/routes.ts +94 -83
- package/src/board/server.ts +35 -8
- package/src/board/snapshot.ts +6 -0
- package/src/board/types.ts +2 -34
- package/src/board/wal-watcher.ts +170 -28
- package/src/commands/board.ts +20 -42
- package/src/commands/help.ts +11 -12
- package/src/commands/init.ts +0 -29
- package/src/commands/quickstart.ts +1 -1
- package/src/commands/skills.ts +17 -5
- package/src/domain/mutation-service.ts +61 -42
- package/src/domain/tracker-domain.ts +20 -16
- package/src/domain/types.ts +3 -0
- package/src/export/render-markdown.ts +1 -2
- package/src/runtime/daemon.ts +110 -49
- package/src/runtime/version.ts +10 -2
- package/src/storage/database.ts +9 -2
- package/src/storage/migrations.ts +19 -2
- package/src/storage/path.ts +0 -36
- package/src/sync/service.ts +47 -27
- package/src/board/install.ts +0 -196
package/src/board/server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, extname, resolve } from "node:path";
|
|
4
4
|
|
|
5
|
+
import { resolveBoardAssetRoot, type BoardAssetRoot } from "./asset-root";
|
|
5
6
|
import { createBoardEventBus, type BoardEventBus } from "./event-bus";
|
|
6
7
|
import { createBoardApiHandler } from "./routes";
|
|
7
8
|
import { startWalWatcher, type WalWatcher } from "./wal-watcher";
|
|
@@ -38,6 +39,14 @@ export interface BoardServerInfo {
|
|
|
38
39
|
export interface StartBoardServerOptions {
|
|
39
40
|
readonly cwd?: string;
|
|
40
41
|
readonly token?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Optional override for the directory the board server reads static assets
|
|
44
|
+
* from. When unset, the server resolves the asset root via
|
|
45
|
+
* `resolveBoardAssetRoot()`, which honours `TREKOON_BOARD_ASSET_ROOT` and
|
|
46
|
+
* falls back to the assets shipped alongside the running Trekoon package.
|
|
47
|
+
* Database, API, SSE, and WAL watcher paths continue to use `cwd`.
|
|
48
|
+
*/
|
|
49
|
+
readonly assetRootOverride?: string;
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
function guessContentType(pathname: string): string {
|
|
@@ -90,7 +99,7 @@ function isUnavailablePortError(error: unknown): boolean {
|
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
function buildBoardSessionCookie(token: string): string {
|
|
93
|
-
return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
|
|
102
|
+
return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; Max-Age=86400; SameSite=Strict; HttpOnly`;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
function readBoardSessionCookie(request: Request): string | null {
|
|
@@ -122,6 +131,13 @@ function isAuthenticatedBoardRequest(request: Request, url: URL, token: string):
|
|
|
122
131
|
return cookieToken !== null && cookieToken === token;
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
function buildTokenStrippedLocation(url: URL): string {
|
|
135
|
+
const redirectUrl = new URL(url);
|
|
136
|
+
redirectUrl.searchParams.delete("token");
|
|
137
|
+
const pathname = `/${redirectUrl.pathname.replace(/^\/+/u, "")}`;
|
|
138
|
+
return `${pathname}${redirectUrl.search}${redirectUrl.hash}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
125
141
|
function serializeInlineJson(value: unknown): string {
|
|
126
142
|
return JSON.stringify(value)
|
|
127
143
|
.replace(/</g, "\\u003c")
|
|
@@ -152,11 +168,22 @@ function injectBoardBootstrap(html: string, bootstrapJson: string): string {
|
|
|
152
168
|
|
|
153
169
|
export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
|
|
154
170
|
const cwd: string = options.cwd ?? process.cwd();
|
|
171
|
+
// Resolve the asset root BEFORE generating a token or opening the database.
|
|
172
|
+
// If the installed assets are missing, `resolveBoardAssetRoot` raises a
|
|
173
|
+
// `BoardAssetError` carrying only path/source metadata — never the auth
|
|
174
|
+
// token, which has not been generated yet. The CLI's `instanceof
|
|
175
|
+
// BoardAssetError` branch translates this into a clean operator error
|
|
176
|
+
// without leaking secrets through machine output or logs.
|
|
177
|
+
const assetRoot: BoardAssetRoot = resolveBoardAssetRoot(
|
|
178
|
+
options.assetRootOverride === undefined
|
|
179
|
+
? {}
|
|
180
|
+
: { assetRootOverride: options.assetRootOverride },
|
|
181
|
+
);
|
|
182
|
+
const boardRoot: string = assetRoot.assetRoot;
|
|
155
183
|
const database: TrekoonDatabase = openTrekoonDatabase(cwd);
|
|
156
184
|
const paths = resolveStoragePaths(cwd);
|
|
157
|
-
const boardRoot: string = paths.boardDir;
|
|
158
185
|
const stateFile: string = resolve(paths.storageDir, BOARD_SERVER_STATE_FILENAME);
|
|
159
|
-
const token: string = options.token ?? randomBytes(
|
|
186
|
+
const token: string = options.token ?? randomBytes(32).toString("hex");
|
|
160
187
|
const eventBus: BoardEventBus = createBoardEventBus();
|
|
161
188
|
const walWatcher: WalWatcher = startWalWatcher({
|
|
162
189
|
db: database.db,
|
|
@@ -195,15 +222,15 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
195
222
|
// and Referer headers free of the secret on the very first
|
|
196
223
|
// navigation, severing the leakage surface that an open URL bar
|
|
197
224
|
// would otherwise expose. The cookie carries auth from here on.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
const location = `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
|
|
225
|
+
// Preserve a single-slash relative location so the redirect works
|
|
226
|
+
// regardless of how the client reached us, without creating a
|
|
227
|
+
// scheme-relative `//host` Location for odd incoming paths.
|
|
228
|
+
const location = buildTokenStrippedLocation(url);
|
|
203
229
|
return new Response(null, {
|
|
204
230
|
status: 302,
|
|
205
231
|
headers: {
|
|
206
232
|
...responseHeaders,
|
|
233
|
+
"referrer-policy": "no-referrer",
|
|
207
234
|
location: location.length > 0 ? location : "/",
|
|
208
235
|
},
|
|
209
236
|
});
|
package/src/board/snapshot.ts
CHANGED
|
@@ -34,6 +34,7 @@ interface BoardSnapshotSubtask {
|
|
|
34
34
|
readonly owner: string | null;
|
|
35
35
|
readonly createdAt: number;
|
|
36
36
|
readonly updatedAt: number;
|
|
37
|
+
readonly version: number;
|
|
37
38
|
readonly blockedBy: readonly string[];
|
|
38
39
|
readonly blocks: readonly string[];
|
|
39
40
|
readonly dependencyIds: readonly string[];
|
|
@@ -51,6 +52,7 @@ interface BoardSnapshotTask {
|
|
|
51
52
|
readonly owner: string | null;
|
|
52
53
|
readonly createdAt: number;
|
|
53
54
|
readonly updatedAt: number;
|
|
55
|
+
readonly version: number;
|
|
54
56
|
readonly blockedBy: readonly string[];
|
|
55
57
|
readonly blocks: readonly string[];
|
|
56
58
|
readonly dependencyIds: readonly string[];
|
|
@@ -66,6 +68,7 @@ interface BoardSnapshotEpic {
|
|
|
66
68
|
readonly status: string;
|
|
67
69
|
readonly createdAt: number;
|
|
68
70
|
readonly updatedAt: number;
|
|
71
|
+
readonly version: number;
|
|
69
72
|
readonly taskIds: readonly string[];
|
|
70
73
|
readonly counts: FlatCounts;
|
|
71
74
|
readonly searchText: string;
|
|
@@ -169,6 +172,7 @@ function mapSnapshotSubtask(subtask: SubtaskRecord, indexes: ReturnType<typeof b
|
|
|
169
172
|
owner: subtask.owner ?? null,
|
|
170
173
|
createdAt: subtask.createdAt,
|
|
171
174
|
updatedAt: subtask.updatedAt,
|
|
175
|
+
version: subtask.version,
|
|
172
176
|
blockedBy: indexes.blockedByIdsBySource.get(subtask.id) ?? [],
|
|
173
177
|
blocks: indexes.blocksByTarget.get(subtask.id) ?? [],
|
|
174
178
|
dependencyIds: indexes.dependencyIdsBySource.get(subtask.id) ?? [],
|
|
@@ -188,6 +192,7 @@ function mapSnapshotTask(task: TaskRecord, taskSubtasks: readonly BoardSnapshotS
|
|
|
188
192
|
owner: task.owner ?? null,
|
|
189
193
|
createdAt: task.createdAt,
|
|
190
194
|
updatedAt: task.updatedAt,
|
|
195
|
+
version: task.version,
|
|
191
196
|
blockedBy: indexes.blockedByIdsBySource.get(task.id) ?? [],
|
|
192
197
|
blocks: indexes.blocksByTarget.get(task.id) ?? [],
|
|
193
198
|
dependencyIds: indexes.dependencyIdsBySource.get(task.id) ?? [],
|
|
@@ -205,6 +210,7 @@ function mapSnapshotEpic(epic: EpicRecord, epicTasks: readonly BoardSnapshotTask
|
|
|
205
210
|
status: epic.status,
|
|
206
211
|
createdAt: epic.createdAt,
|
|
207
212
|
updatedAt: epic.updatedAt,
|
|
213
|
+
version: epic.version,
|
|
208
214
|
taskIds: epicTasks.map((task) => task.id),
|
|
209
215
|
counts: deriveFlatCounts(epicTasks),
|
|
210
216
|
searchText: [epic.title, epic.description, ...epicTasks.map((task) => task.searchText)].join(" ").toLowerCase(),
|
package/src/board/types.ts
CHANGED
|
@@ -1,42 +1,10 @@
|
|
|
1
|
-
export
|
|
2
|
-
export const BOARD_BUNDLED_ASSET_DIRNAME = "assets";
|
|
3
|
-
|
|
4
|
-
export interface BoardAssetManifest {
|
|
5
|
-
readonly contractVersion: string;
|
|
6
|
-
readonly assetVersion: string;
|
|
7
|
-
readonly entryFile: string;
|
|
8
|
-
readonly files: readonly string[];
|
|
9
|
-
readonly assetDigest: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface BoardAssetPaths {
|
|
13
|
-
readonly sourceRoot: string;
|
|
14
|
-
readonly runtimeRoot: string;
|
|
15
|
-
readonly entryFile: string;
|
|
16
|
-
readonly manifestFile: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export type BoardInstallAction = "installed" | "reinstalled" | "updated" | "unchanged";
|
|
20
|
-
|
|
21
|
-
export interface BoardInstallResult {
|
|
22
|
-
readonly action: BoardInstallAction;
|
|
23
|
-
readonly paths: BoardAssetPaths;
|
|
24
|
-
readonly manifest: BoardAssetManifest;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface EnsureBoardInstalledOptions {
|
|
28
|
-
readonly workingDirectory?: string;
|
|
29
|
-
readonly assetVersion?: string;
|
|
30
|
-
readonly bundledAssetRoot?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export class BoardInstallError extends Error {
|
|
1
|
+
export class BoardAssetError extends Error {
|
|
34
2
|
readonly code: string;
|
|
35
3
|
readonly details: Record<string, unknown>;
|
|
36
4
|
|
|
37
5
|
constructor(code: string, message: string, details: Record<string, unknown> = {}) {
|
|
38
6
|
super(message);
|
|
39
|
-
this.name = "
|
|
7
|
+
this.name = "BoardAssetError";
|
|
40
8
|
this.code = code;
|
|
41
9
|
this.details = details;
|
|
42
10
|
}
|
package/src/board/wal-watcher.ts
CHANGED
|
@@ -22,6 +22,8 @@ import { TrackerDomain } from "../domain/tracker-domain";
|
|
|
22
22
|
import { type BoardEventBus } from "./event-bus";
|
|
23
23
|
import { buildBoardSnapshot, type BoardSnapshot } from "./snapshot";
|
|
24
24
|
|
|
25
|
+
const IN_PROCESS_WAL_SUPPRESS_MS = 500;
|
|
26
|
+
|
|
25
27
|
interface CollectionDiff {
|
|
26
28
|
readonly upserted: unknown[];
|
|
27
29
|
readonly deletedIds: string[];
|
|
@@ -64,6 +66,65 @@ function changeKeyEqual(
|
|
|
64
66
|
return a.version === b.version && a.updatedAt === b.updatedAt;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
function derivedRecordFingerprint(value: unknown): string {
|
|
70
|
+
if (!value || typeof value !== "object") {
|
|
71
|
+
return JSON.stringify(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const record = value as Record<string, unknown>;
|
|
75
|
+
const kind = typeof record.kind === "string" ? record.kind : "";
|
|
76
|
+
|
|
77
|
+
if (kind === "task") {
|
|
78
|
+
return JSON.stringify({
|
|
79
|
+
blockedBy: record.blockedBy,
|
|
80
|
+
blocks: record.blocks,
|
|
81
|
+
dependencyIds: record.dependencyIds,
|
|
82
|
+
dependentIds: record.dependentIds,
|
|
83
|
+
subtasks: record.subtasks,
|
|
84
|
+
searchText: record.searchText,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (kind === "subtask") {
|
|
89
|
+
return JSON.stringify({
|
|
90
|
+
blockedBy: record.blockedBy,
|
|
91
|
+
blocks: record.blocks,
|
|
92
|
+
dependencyIds: record.dependencyIds,
|
|
93
|
+
dependentIds: record.dependentIds,
|
|
94
|
+
searchText: record.searchText,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if ("taskIds" in record || "counts" in record) {
|
|
99
|
+
return JSON.stringify({
|
|
100
|
+
taskIds: record.taskIds,
|
|
101
|
+
counts: record.counts,
|
|
102
|
+
searchText: record.searchText,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return JSON.stringify(record);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function recordMatchesPublishedDelta(record: unknown, publishedRecord: unknown): boolean {
|
|
110
|
+
const recordKey = recordChangeKey(record);
|
|
111
|
+
const publishedKey = recordChangeKey(publishedRecord);
|
|
112
|
+
return changeKeyEqual(recordKey, publishedKey) &&
|
|
113
|
+
derivedRecordFingerprint(record) === derivedRecordFingerprint(publishedRecord);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function recordChanged(previousRecord: unknown, currentRecord: unknown): boolean {
|
|
117
|
+
if (!changeKeyEqual(recordChangeKey(previousRecord), recordChangeKey(currentRecord))) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The board snapshot includes derived parent fields (for example epic task
|
|
122
|
+
// counts/search text and task subtask lists). Child writes do not bump the
|
|
123
|
+
// parent row version, but those derived fields still need to reach connected
|
|
124
|
+
// boards through WAL deltas.
|
|
125
|
+
return derivedRecordFingerprint(previousRecord) !== derivedRecordFingerprint(currentRecord);
|
|
126
|
+
}
|
|
127
|
+
|
|
67
128
|
function diffById(previous: readonly unknown[] | undefined, current: readonly unknown[] | undefined): CollectionDiff {
|
|
68
129
|
const previousIndex = new Map<string, unknown>();
|
|
69
130
|
for (const record of previous ?? []) {
|
|
@@ -87,9 +148,7 @@ function diffById(previous: readonly unknown[] | undefined, current: readonly un
|
|
|
87
148
|
upserted.push(record);
|
|
88
149
|
continue;
|
|
89
150
|
}
|
|
90
|
-
|
|
91
|
-
// domain write. Equal tuple → no content change → skip the upsert.
|
|
92
|
-
if (!changeKeyEqual(recordChangeKey(previousRecord), recordChangeKey(record))) {
|
|
151
|
+
if (recordChanged(previousRecord, record)) {
|
|
93
152
|
upserted.push(record);
|
|
94
153
|
}
|
|
95
154
|
}
|
|
@@ -104,6 +163,66 @@ function diffById(previous: readonly unknown[] | undefined, current: readonly un
|
|
|
104
163
|
return { upserted, deletedIds };
|
|
105
164
|
}
|
|
106
165
|
|
|
166
|
+
function recordsByIdFromDelta(delta: Record<string, unknown> | null, key: string): Map<string, unknown> {
|
|
167
|
+
const records = delta?.[key];
|
|
168
|
+
const index = new Map<string, unknown>();
|
|
169
|
+
if (!Array.isArray(records)) {
|
|
170
|
+
return index;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const record of records) {
|
|
174
|
+
const id = recordId(record);
|
|
175
|
+
if (id !== null) {
|
|
176
|
+
index.set(id, record);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return index;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function deletedIdsFromDelta(delta: Record<string, unknown> | null, key: string): Set<string> {
|
|
184
|
+
const ids = delta?.[key];
|
|
185
|
+
if (!Array.isArray(ids)) {
|
|
186
|
+
return new Set();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function suppressAlreadyPublishedDiff(
|
|
193
|
+
diff: CollectionDiff,
|
|
194
|
+
publishedRecords: Map<string, unknown>,
|
|
195
|
+
publishedDeletedIds: Set<string>,
|
|
196
|
+
): CollectionDiff {
|
|
197
|
+
return {
|
|
198
|
+
upserted: diff.upserted.filter((record) => {
|
|
199
|
+
const id = recordId(record);
|
|
200
|
+
if (id === null) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const publishedRecord = publishedRecords.get(id);
|
|
205
|
+
return publishedRecord === undefined || !recordMatchesPublishedDelta(record, publishedRecord);
|
|
206
|
+
}),
|
|
207
|
+
deletedIds: diff.deletedIds.filter((id) => !publishedDeletedIds.has(id)),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasDiffChanges(...diffs: readonly CollectionDiff[]): boolean {
|
|
212
|
+
return diffs.some((diff) => diff.upserted.length > 0 || diff.deletedIds.length > 0);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readMtime(path: string): number {
|
|
216
|
+
if (!existsSync(path)) {
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
return statSync(path).mtimeMs;
|
|
221
|
+
} catch {
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
107
226
|
export interface WalWatcherOptions {
|
|
108
227
|
readonly db: Database;
|
|
109
228
|
readonly databaseFile: string;
|
|
@@ -168,25 +287,59 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
|
|
|
168
287
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
169
288
|
let closed = false;
|
|
170
289
|
let failures = 0;
|
|
290
|
+
let lastSuppressedInProcessWriteAt = 0;
|
|
171
291
|
|
|
172
292
|
function reconcile(): void {
|
|
173
293
|
if (closed) {
|
|
174
294
|
return;
|
|
175
295
|
}
|
|
296
|
+
const inProcessWriteAt = options.eventBus.lastInProcessWriteAt;
|
|
297
|
+
const shouldSuppressInProcessTick =
|
|
298
|
+
inProcessWriteAt > lastSuppressedInProcessWriteAt &&
|
|
299
|
+
Date.now() - inProcessWriteAt <= IN_PROCESS_WAL_SUPPRESS_MS;
|
|
176
300
|
|
|
177
301
|
try {
|
|
178
302
|
const fresh = buildSnapshot(domain);
|
|
179
|
-
|
|
180
303
|
const epicsDiff = diffById(lastSnapshot.epics, fresh.epics);
|
|
181
304
|
const tasksDiff = diffById(lastSnapshot.tasks, fresh.tasks);
|
|
182
305
|
const subtasksDiff = diffById(lastSnapshot.subtasks, fresh.subtasks);
|
|
183
306
|
const dependenciesDiff = diffById(lastSnapshot.dependencies, fresh.dependencies);
|
|
184
307
|
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
308
|
+
const shouldSuppressDiff = shouldSuppressInProcessTick
|
|
309
|
+
? {
|
|
310
|
+
epics: suppressAlreadyPublishedDiff(
|
|
311
|
+
epicsDiff,
|
|
312
|
+
recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "epics"),
|
|
313
|
+
deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedEpicIds"),
|
|
314
|
+
),
|
|
315
|
+
tasks: suppressAlreadyPublishedDiff(
|
|
316
|
+
tasksDiff,
|
|
317
|
+
recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "tasks"),
|
|
318
|
+
deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedTaskIds"),
|
|
319
|
+
),
|
|
320
|
+
subtasks: suppressAlreadyPublishedDiff(
|
|
321
|
+
subtasksDiff,
|
|
322
|
+
recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "subtasks"),
|
|
323
|
+
deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedSubtaskIds"),
|
|
324
|
+
),
|
|
325
|
+
dependencies: suppressAlreadyPublishedDiff(
|
|
326
|
+
dependenciesDiff,
|
|
327
|
+
recordsByIdFromDelta(options.eventBus.lastInProcessSnapshotDelta, "dependencies"),
|
|
328
|
+
deletedIdsFromDelta(options.eventBus.lastInProcessSnapshotDelta, "deletedDependencyIds"),
|
|
329
|
+
),
|
|
330
|
+
}
|
|
331
|
+
: null;
|
|
332
|
+
|
|
333
|
+
if (shouldSuppressInProcessTick) {
|
|
334
|
+
lastSuppressedInProcessWriteAt = inProcessWriteAt;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const publishEpicsDiff = shouldSuppressDiff?.epics ?? epicsDiff;
|
|
338
|
+
const publishTasksDiff = shouldSuppressDiff?.tasks ?? tasksDiff;
|
|
339
|
+
const publishSubtasksDiff = shouldSuppressDiff?.subtasks ?? subtasksDiff;
|
|
340
|
+
const publishDependenciesDiff = shouldSuppressDiff?.dependencies ?? dependenciesDiff;
|
|
341
|
+
|
|
342
|
+
const hasChanges = hasDiffChanges(publishEpicsDiff, publishTasksDiff, publishSubtasksDiff, publishDependenciesDiff);
|
|
190
343
|
|
|
191
344
|
lastSnapshot = fresh;
|
|
192
345
|
|
|
@@ -197,14 +350,14 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
|
|
|
197
350
|
options.eventBus.publishSnapshotDelta({
|
|
198
351
|
generatedAt: Date.now(),
|
|
199
352
|
source: "wal-watcher",
|
|
200
|
-
epics:
|
|
201
|
-
tasks:
|
|
202
|
-
subtasks:
|
|
203
|
-
dependencies:
|
|
204
|
-
deletedEpicIds:
|
|
205
|
-
deletedTaskIds:
|
|
206
|
-
deletedSubtaskIds:
|
|
207
|
-
deletedDependencyIds:
|
|
353
|
+
epics: publishEpicsDiff.upserted,
|
|
354
|
+
tasks: publishTasksDiff.upserted,
|
|
355
|
+
subtasks: publishSubtasksDiff.upserted,
|
|
356
|
+
dependencies: publishDependenciesDiff.upserted,
|
|
357
|
+
deletedEpicIds: publishEpicsDiff.deletedIds,
|
|
358
|
+
deletedTaskIds: publishTasksDiff.deletedIds,
|
|
359
|
+
deletedSubtaskIds: publishSubtasksDiff.deletedIds,
|
|
360
|
+
deletedDependencyIds: publishDependenciesDiff.deletedIds,
|
|
208
361
|
});
|
|
209
362
|
} catch (error) {
|
|
210
363
|
// Reconciliation must never crash the server. Errors here usually mean
|
|
@@ -235,17 +388,6 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
|
|
|
235
388
|
// than spurious watcher fires (e.g. atime-only updates on some filesystems).
|
|
236
389
|
let lastWalMtime: number = readMtime(walFile);
|
|
237
390
|
|
|
238
|
-
function readMtime(path: string): number {
|
|
239
|
-
if (!existsSync(path)) {
|
|
240
|
-
return 0;
|
|
241
|
-
}
|
|
242
|
-
try {
|
|
243
|
-
return statSync(path).mtimeMs;
|
|
244
|
-
} catch {
|
|
245
|
-
return 0;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
391
|
function maybeScheduleReconcile(): void {
|
|
250
392
|
const currentMtime = readMtime(walFile);
|
|
251
393
|
// mtime can equal 0 when the WAL was just checkpointed and removed; treat
|
package/src/commands/board.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { parseArgs, readUnexpectedPositionals } from "./arg-parser";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { resolveBoardAssetRoot, type BoardAssetRoot, type ResolveBoardAssetRootOptions } from "../board/asset-root";
|
|
4
4
|
import { openBoardInBrowser, type OpenBrowserResult } from "../board/open-browser";
|
|
5
5
|
import { startBoardServer, type BoardServerInfo } from "../board/server";
|
|
6
|
-
import {
|
|
6
|
+
import { BoardAssetError } from "../board/types";
|
|
7
7
|
import { failResult, okResult } from "../io/output";
|
|
8
8
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
9
9
|
|
|
@@ -11,26 +11,23 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
|
11
11
|
// command modules: each subcommand declares the set of flags (and options)
|
|
12
12
|
// it accepts; everything else is rejected up-front.
|
|
13
13
|
const OPEN_FLAGS = ["reveal-token"] as const;
|
|
14
|
-
const UPDATE_FLAGS: readonly string[] = [];
|
|
15
14
|
|
|
16
15
|
const FLAGS_BY_SUBCOMMAND: Readonly<Record<string, readonly string[]>> = {
|
|
17
16
|
open: OPEN_FLAGS,
|
|
18
|
-
update: UPDATE_FLAGS,
|
|
19
17
|
};
|
|
20
18
|
|
|
21
|
-
type
|
|
22
|
-
type StartBoardServerFn = (options: { cwd: string }) => BoardServerInfo;
|
|
19
|
+
type ResolveAssetRootFn = (options: ResolveBoardAssetRootOptions) => BoardAssetRoot;
|
|
20
|
+
type StartBoardServerFn = (options: { cwd: string; assetRootOverride?: string }) => BoardServerInfo;
|
|
23
21
|
type OpenBoardInBrowserFn = (url: string) => Promise<OpenBrowserResult> | OpenBrowserResult;
|
|
24
22
|
|
|
25
|
-
let
|
|
26
|
-
let updateInstalledImpl: EnsureBoardInstalledFn = updateBoardInstallation;
|
|
23
|
+
let resolveAssetRootImpl: ResolveAssetRootFn = resolveBoardAssetRoot;
|
|
27
24
|
let startBoardServerImpl: StartBoardServerFn = (options) => startBoardServer(options);
|
|
28
25
|
let openBoardInBrowserImpl: OpenBoardInBrowserFn = openBoardInBrowser;
|
|
29
26
|
|
|
30
27
|
function usageResult(): CliResult {
|
|
31
28
|
return failResult({
|
|
32
29
|
command: "board",
|
|
33
|
-
human: "Usage: trekoon board <open
|
|
30
|
+
human: "Usage: trekoon board <open>",
|
|
34
31
|
data: {},
|
|
35
32
|
error: {
|
|
36
33
|
code: "invalid_subcommand",
|
|
@@ -39,15 +36,7 @@ function usageResult(): CliResult {
|
|
|
39
36
|
});
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
function
|
|
43
|
-
const bundledAssetRoot: string | undefined = process.env.TREKOON_BOARD_ASSET_ROOT;
|
|
44
|
-
return {
|
|
45
|
-
workingDirectory: context.cwd,
|
|
46
|
-
...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function boardInstallFailure(command: string, error: BoardInstallError): CliResult {
|
|
39
|
+
function boardAssetFailure(command: string, error: BoardAssetError): CliResult {
|
|
51
40
|
return failResult({
|
|
52
41
|
command,
|
|
53
42
|
human: error.message,
|
|
@@ -63,13 +52,11 @@ function boardInstallFailure(command: string, error: BoardInstallError): CliResu
|
|
|
63
52
|
}
|
|
64
53
|
|
|
65
54
|
export function setBoardCommandHooksForTests(hooks: {
|
|
66
|
-
|
|
67
|
-
updateInstalled?: EnsureBoardInstalledFn;
|
|
55
|
+
resolveAssetRoot?: ResolveAssetRootFn;
|
|
68
56
|
startBoardServer?: StartBoardServerFn;
|
|
69
57
|
openBoardInBrowser?: OpenBoardInBrowserFn;
|
|
70
58
|
} | null): void {
|
|
71
|
-
|
|
72
|
-
updateInstalledImpl = hooks?.updateInstalled ?? updateBoardInstallation;
|
|
59
|
+
resolveAssetRootImpl = hooks?.resolveAssetRoot ?? resolveBoardAssetRoot;
|
|
73
60
|
startBoardServerImpl = hooks?.startBoardServer ?? ((options) => startBoardServer(options));
|
|
74
61
|
openBoardInBrowserImpl = hooks?.openBoardInBrowser ?? openBoardInBrowser;
|
|
75
62
|
}
|
|
@@ -121,21 +108,12 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
|
|
|
121
108
|
|
|
122
109
|
try {
|
|
123
110
|
switch (subcommand) {
|
|
124
|
-
case "update": {
|
|
125
|
-
const install = updateInstalledImpl(boardInstallOptions(context));
|
|
126
|
-
return okResult({
|
|
127
|
-
command: "board.update",
|
|
128
|
-
human: `Board assets ${install.action} at ${install.paths.runtimeRoot}`,
|
|
129
|
-
data: {
|
|
130
|
-
action: install.action,
|
|
131
|
-
paths: install.paths,
|
|
132
|
-
manifest: install.manifest,
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
111
|
case "open": {
|
|
137
|
-
const
|
|
138
|
-
const server = startBoardServerImpl({
|
|
112
|
+
const assetRoot = resolveAssetRootImpl({});
|
|
113
|
+
const server = startBoardServerImpl({
|
|
114
|
+
cwd: context.cwd,
|
|
115
|
+
assetRootOverride: assetRoot.assetRoot,
|
|
116
|
+
});
|
|
139
117
|
const launch = await openBoardInBrowserImpl(server.url);
|
|
140
118
|
const humanLines: string[] = [
|
|
141
119
|
`Board ready at ${server.fallbackUrl}`,
|
|
@@ -170,10 +148,10 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
|
|
|
170
148
|
command: "board.open",
|
|
171
149
|
human: humanLines.join("\n"),
|
|
172
150
|
data: {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
151
|
+
assetRoot: {
|
|
152
|
+
path: assetRoot.assetRoot,
|
|
153
|
+
entryFile: assetRoot.entryFile,
|
|
154
|
+
source: assetRoot.source,
|
|
177
155
|
},
|
|
178
156
|
server: serverData,
|
|
179
157
|
launch: launchData,
|
|
@@ -184,8 +162,8 @@ export async function runBoard(context: CliContext): Promise<CliResult> {
|
|
|
184
162
|
return usageResult();
|
|
185
163
|
}
|
|
186
164
|
} catch (error: unknown) {
|
|
187
|
-
if (error instanceof
|
|
188
|
-
return
|
|
165
|
+
if (error instanceof BoardAssetError) {
|
|
166
|
+
return boardAssetFailure(`board.${subcommand}`, error);
|
|
189
167
|
}
|
|
190
168
|
|
|
191
169
|
throw error;
|
package/src/commands/help.ts
CHANGED
|
@@ -22,7 +22,7 @@ const ROOT_HELP = [
|
|
|
22
22
|
" init Create or re-bootstrap .trekoon storage",
|
|
23
23
|
" quickstart Storage model, agent loop, and common patterns",
|
|
24
24
|
" wipe Delete repo-wide .trekoon state (requires --yes)",
|
|
25
|
-
" board Open
|
|
25
|
+
" board Open the local board",
|
|
26
26
|
" epic Create, list, update, search, and track epics",
|
|
27
27
|
" task Create, list, update, search, and complete tasks",
|
|
28
28
|
" subtask Create, list, update, and search subtasks",
|
|
@@ -89,12 +89,12 @@ const WIPE_HELP = [
|
|
|
89
89
|
].join("\n");
|
|
90
90
|
|
|
91
91
|
const BOARD_HELP = [
|
|
92
|
-
"Usage: trekoon board <open
|
|
92
|
+
"Usage: trekoon board <open>",
|
|
93
93
|
"",
|
|
94
94
|
"Subcommands:",
|
|
95
|
-
" open
|
|
96
|
-
"
|
|
97
|
-
"
|
|
95
|
+
" open Start a local board server on 127.0.0.1 and open the browser.",
|
|
96
|
+
" Returns the board URL and a fallback URL. Board assets are served",
|
|
97
|
+
" directly from the running Trekoon package; no per-repo install.",
|
|
98
98
|
"",
|
|
99
99
|
"Token visibility:",
|
|
100
100
|
" By default the board token is redacted from machine output (shown as ****).",
|
|
@@ -108,20 +108,19 @@ const BOARD_HELP = [
|
|
|
108
108
|
"Examples:",
|
|
109
109
|
" trekoon board open",
|
|
110
110
|
" trekoon board open --reveal-token",
|
|
111
|
-
" trekoon --json board update",
|
|
112
111
|
].join("\n");
|
|
113
112
|
|
|
114
113
|
const EPIC_HELP = [
|
|
115
114
|
"Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress|export> [options]",
|
|
116
115
|
"",
|
|
117
116
|
"Create:",
|
|
118
|
-
" trekoon epic create --title \"...\" --description \"...\" [--status <status>]",
|
|
119
|
-
" trekoon epic create --title \"...\" --description \"...\" [--task <spec>] [--subtask <spec>] [--dep <spec>]",
|
|
117
|
+
" trekoon --toon epic create --title \"...\" --description \"...\" [--status <status>]",
|
|
118
|
+
" trekoon --toon epic create --title \"...\" --description \"...\" [--task <spec>] [--subtask <spec>] [--dep <spec>]",
|
|
120
119
|
" When the full tree is known, the second form creates everything in one shot",
|
|
121
120
|
" and returns mappings/counts. Same compact spec grammar as epic expand.",
|
|
122
121
|
"",
|
|
123
122
|
"Expand:",
|
|
124
|
-
" trekoon epic expand <epic-id> [--task <spec>] [--subtask <spec>] [--dep <spec>]",
|
|
123
|
+
" trekoon --toon epic expand <epic-id> [--task <spec>] [--subtask <spec>] [--dep <spec>]",
|
|
125
124
|
" --task <temp-key>|<title>|<description>|<status>",
|
|
126
125
|
` --subtask <parent-ref>|<temp-key>|<title>|<description>|<status> (${"@"}<temp-key> for new parents)`,
|
|
127
126
|
` --dep <source-ref>|<depends-on-ref> (refs can be IDs or ${"@"}<temp-key>)`,
|
|
@@ -441,8 +440,8 @@ const SUGGEST_HELP = [
|
|
|
441
440
|
|
|
442
441
|
const SKILLS_HELP = [
|
|
443
442
|
"Usage:",
|
|
444
|
-
" trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
|
|
445
|
-
" trekoon skills install -g|--global [--editor opencode|claude|pi]",
|
|
443
|
+
" trekoon skills install [--link --editor opencode|claude|codex|pi] [--to <path>] [--allow-outside-repo]",
|
|
444
|
+
" trekoon skills install -g|--global [--editor opencode|claude|codex|pi]",
|
|
446
445
|
" trekoon skills update",
|
|
447
446
|
"",
|
|
448
447
|
"Installs or refreshes the Trekoon skill so AI agents can plan and execute.",
|
|
@@ -451,7 +450,7 @@ const SKILLS_HELP = [
|
|
|
451
450
|
" Creates a symlink at .agents/skills/trekoon pointing to the bundled source,",
|
|
452
451
|
" so the skill always matches the installed CLI version.",
|
|
453
452
|
" --link Also create an editor symlink named 'trekoon'.",
|
|
454
|
-
" --editor <name> Required with --link (opencode|claude|pi).",
|
|
453
|
+
" --editor <name> Required with --link (opencode|claude|codex|pi).",
|
|
455
454
|
" --to <path> Override the symlink root for --link only.",
|
|
456
455
|
" --allow-outside-repo Allow links outside the repo (requires --link).",
|
|
457
456
|
"",
|