trekoon 0.4.0 → 0.4.2

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 (54) hide show
  1. package/.agents/skills/trekoon/SKILL.md +20 -577
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
  3. package/.agents/skills/trekoon/reference/execution.md +246 -7
  4. package/.agents/skills/trekoon/reference/planning.md +138 -1
  5. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  6. package/.agents/skills/trekoon/reference/sync.md +129 -0
  7. package/README.md +8 -7
  8. package/docs/ai-agents.md +17 -2
  9. package/docs/commands.md +147 -3
  10. package/docs/machine-contracts.md +123 -0
  11. package/docs/quickstart.md +52 -0
  12. package/package.json +1 -1
  13. package/src/board/assets/app.js +49 -16
  14. package/src/board/assets/components/Component.js +22 -8
  15. package/src/board/assets/components/Workspace.js +9 -3
  16. package/src/board/assets/components/helpers.js +5 -1
  17. package/src/board/assets/runtime/delegation.js +8 -0
  18. package/src/board/assets/runtime/focus-trap.js +48 -0
  19. package/src/board/assets/state/actions.js +47 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +87 -11
  22. package/src/board/assets/state/url.js +10 -0
  23. package/src/board/assets/state/utils.js +2 -1
  24. package/src/board/event-bus.ts +72 -0
  25. package/src/board/routes.ts +412 -33
  26. package/src/board/server.ts +77 -8
  27. package/src/board/wal-watcher.ts +302 -0
  28. package/src/commands/board.ts +52 -17
  29. package/src/commands/epic.ts +7 -9
  30. package/src/commands/error-utils.ts +54 -1
  31. package/src/commands/help.ts +69 -4
  32. package/src/commands/migrate.ts +153 -24
  33. package/src/commands/quickstart.ts +7 -0
  34. package/src/commands/subtask.ts +71 -10
  35. package/src/commands/suggest.ts +6 -13
  36. package/src/commands/task.ts +137 -88
  37. package/src/domain/batch-validation.ts +329 -0
  38. package/src/domain/cascade-planner.ts +412 -0
  39. package/src/domain/dependency-rules.ts +15 -0
  40. package/src/domain/mutation-service.ts +828 -192
  41. package/src/domain/search.ts +113 -0
  42. package/src/domain/tracker-domain.ts +150 -680
  43. package/src/domain/types.ts +53 -2
  44. package/src/index.ts +37 -0
  45. package/src/runtime/cli-shell.ts +44 -0
  46. package/src/runtime/daemon.ts +639 -0
  47. package/src/storage/backup.ts +166 -0
  48. package/src/storage/database.ts +261 -4
  49. package/src/storage/migrations.ts +422 -20
  50. package/src/storage/path.ts +8 -0
  51. package/src/storage/schema.ts +5 -1
  52. package/src/sync/event-writes.ts +38 -11
  53. package/src/sync/git-context.ts +226 -8
  54. package/src/sync/service.ts +650 -147
@@ -1,5 +1,8 @@
1
1
  import { type Database } from "bun:sqlite";
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { isAbsolute, join, resolve as resolvePath } from "node:path";
2
4
 
5
+ import { writeTransaction } from "../storage/database";
3
6
  import { resolveStoragePaths } from "../storage/path";
4
7
  import { type GitContextSnapshot } from "./types";
5
8
 
@@ -23,21 +26,217 @@ function runGit(args: readonly string[], cwd: string): string | null {
23
26
  return output.length > 0 ? output : null;
24
27
  }
25
28
 
29
+ /** Process-lifetime cache: cwd → resolved branch + headSha (without persistedAt). */
30
+ interface GitContextCore {
31
+ readonly worktreePath: string;
32
+ readonly branchName: string | null;
33
+ readonly headSha: string | null;
34
+ readonly headStatKey: string | null;
35
+ readonly gitDir: string | null;
36
+ }
37
+
38
+ interface GitDirInfo {
39
+ readonly gitDir: string | null;
40
+ readonly commonDir: string | null;
41
+ }
42
+
43
+ /**
44
+ * Process-level cache, keyed by worktree path. Bounded LRU so a long-running
45
+ * daemon serving requests for many distinct cwds (e.g. running across
46
+ * unrelated clones) does not grow this map without bound.
47
+ */
48
+ const GIT_CONTEXT_CACHE_CAPACITY = 16;
49
+ const gitContextCache: Map<string, GitContextCore> = new Map();
50
+
51
+ /** Cache of worktree path → resolved gitdir + commondir, populated lazily. */
52
+ const gitDirCache: Map<string, GitDirInfo> = new Map();
53
+
54
+ function evictLruIfNeeded<K, V>(map: Map<K, V>, capacity: number): void {
55
+ while (map.size >= capacity) {
56
+ const oldestKey = map.keys().next().value;
57
+ if (oldestKey === undefined) {
58
+ return;
59
+ }
60
+ map.delete(oldestKey);
61
+ }
62
+ }
63
+
64
+ function touchEntry<K, V>(map: Map<K, V>, key: K, value: V): void {
65
+ // Re-insert so this key moves to the MRU end of the insertion-order.
66
+ map.delete(key);
67
+ map.set(key, value);
68
+ }
69
+
70
+ function statKey(prefix: string, path: string): string | null {
71
+ try {
72
+ const stat = statSync(path);
73
+ return `${prefix}|${stat.mtimeMs}|${stat.size}|${stat.ino}`;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Resolve the absolute gitdir + commondir for a worktree path.
81
+ *
82
+ * - In a normal (primary) repo, gitdir = commondir = `<worktree>/.git`.
83
+ * - In a linked worktree, `<worktree>/.git` is a regular file containing
84
+ * `gitdir: <abs-or-rel-path>` pointing at `<main-gitdir>/worktrees/<name>`.
85
+ * The commondir (where shared refs/heads live) is read from
86
+ * `<gitdir>/commondir`, which contains a path (often relative) to the
87
+ * primary `.git` directory.
88
+ *
89
+ * Reads the on-disk pointer files directly when possible (cheap, no
90
+ * subprocess) and falls back to `git rev-parse --absolute-git-dir
91
+ * --git-common-dir` only when the files are missing or unreadable. Result
92
+ * is memoized per worktree path because the location only changes when a
93
+ * worktree is moved/recreated — safe to pin for process lifetime in CLI use.
94
+ */
95
+ function resolveGitDir(worktreePath: string): GitDirInfo {
96
+ const cached = gitDirCache.get(worktreePath);
97
+ if (cached !== undefined) {
98
+ touchEntry(gitDirCache, worktreePath, cached);
99
+ return cached;
100
+ }
101
+
102
+ const dotGit = join(worktreePath, ".git");
103
+ let gitDir: string | null = null;
104
+
105
+ try {
106
+ const stat = statSync(dotGit);
107
+ if (stat.isDirectory()) {
108
+ gitDir = dotGit;
109
+ } else if (stat.isFile()) {
110
+ const raw: string = readFileSync(dotGit, "utf8").trim();
111
+ const match = /^gitdir:\s*(.+)$/m.exec(raw);
112
+ if (match) {
113
+ const pointer: string = match[1]!.trim();
114
+ gitDir = isAbsolute(pointer) ? pointer : resolvePath(worktreePath, pointer);
115
+ }
116
+ }
117
+ } catch {
118
+ // Fall through to git rev-parse below.
119
+ }
120
+
121
+ if (gitDir === null) {
122
+ gitDir = runGit(["rev-parse", "--absolute-git-dir"], worktreePath);
123
+ }
124
+
125
+ let commonDir: string | null = gitDir;
126
+
127
+ if (gitDir !== null) {
128
+ // Linked worktrees record the shared refs location in <gitdir>/commondir.
129
+ // The file content is typically a path relative to the linked gitdir.
130
+ try {
131
+ const commonRaw: string = readFileSync(join(gitDir, "commondir"), "utf8").trim();
132
+ if (commonRaw.length > 0) {
133
+ commonDir = isAbsolute(commonRaw) ? commonRaw : resolvePath(gitDir, commonRaw);
134
+ }
135
+ } catch {
136
+ // Primary repos have no commondir file; commonDir stays === gitDir.
137
+ }
138
+ }
139
+
140
+ if (commonDir === null) {
141
+ commonDir = runGit(["rev-parse", "--git-common-dir"], worktreePath);
142
+ if (commonDir !== null && !isAbsolute(commonDir)) {
143
+ commonDir = resolvePath(worktreePath, commonDir);
144
+ }
145
+ }
146
+
147
+ const info: GitDirInfo = { gitDir, commonDir };
148
+ evictLruIfNeeded(gitDirCache, GIT_CONTEXT_CACHE_CAPACITY);
149
+ gitDirCache.set(worktreePath, info);
150
+ return info;
151
+ }
152
+
153
+ /**
154
+ * Compute a composite cache key that changes whenever HEAD moves — including
155
+ * commit advance on the same branch and checkouts in linked worktrees.
156
+ *
157
+ * Sources of variance:
158
+ * 1. `<gitdir>/HEAD` — changes on branch checkout (symbolic-ref content)
159
+ * or detached-HEAD updates.
160
+ * 2. The resolved branch ref tip (loose ref file or packed-refs) — changes
161
+ * on every commit on the current branch. Stat'ing this is what catches
162
+ * the previously-missed "same-branch commit advance" case.
163
+ */
164
+ function readHeadStatKey(worktreePath: string): string | null {
165
+ const { gitDir, commonDir } = resolveGitDir(worktreePath);
166
+ if (gitDir === null) {
167
+ // Best effort: fall back to stat'ing the dotgit entry. Better than
168
+ // pinning forever on a stale cache when git is missing.
169
+ return statKey("dotgit", join(worktreePath, ".git"));
170
+ }
171
+
172
+ const parts: string[] = [];
173
+
174
+ // 1. Per-worktree HEAD — changes on checkout in this worktree.
175
+ parts.push(statKey("head", join(gitDir, "HEAD")) ?? "head|missing");
176
+
177
+ // 2. Resolved branch ref tip — changes on every commit on the current
178
+ // branch. For linked worktrees, refs/heads live under the commonDir.
179
+ // Detached HEAD has no symbolic ref; stat'ing HEAD above already
180
+ // covers SHA changes in that case.
181
+ try {
182
+ const headContent: string = readFileSync(join(gitDir, "HEAD"), "utf8").trim();
183
+ const symMatch = /^ref:\s*(.+)$/m.exec(headContent);
184
+ if (symMatch) {
185
+ const refPath: string = symMatch[1]!.trim();
186
+ const refsRoot: string = commonDir ?? gitDir;
187
+ const refTipKey: string | null =
188
+ statKey("ref", join(refsRoot, refPath)) ?? statKey("packed", join(refsRoot, "packed-refs"));
189
+ if (refTipKey !== null) {
190
+ parts.push(refTipKey);
191
+ }
192
+ }
193
+ } catch {
194
+ // ignore — HEAD stat already captured above
195
+ }
196
+
197
+ return parts.join("::");
198
+ }
199
+
200
+ /**
201
+ * Clear the process-level git context cache.
202
+ * Intended for test isolation only — production code should never call this.
203
+ */
204
+ export function clearGitContextCache(): void {
205
+ gitContextCache.clear();
206
+ gitDirCache.clear();
207
+ }
208
+
209
+ /**
210
+ * Return the number of entries currently held in the process-level cache.
211
+ * Intended for test assertions only.
212
+ */
213
+ export function gitContextCacheSize(): number {
214
+ return gitContextCache.size;
215
+ }
216
+
26
217
  export function resolveGitContext(cwd: string, persistedAt: number = Date.now()): ResolvedGitContext {
27
218
  const storagePaths = resolveStoragePaths(cwd);
219
+ const worktreePath: string = storagePaths.worktreeRoot;
220
+ const headStatKey: string | null = readHeadStatKey(worktreePath);
221
+
222
+ const cached: GitContextCore | undefined = gitContextCache.get(worktreePath);
223
+ if (cached !== undefined && cached.headStatKey === headStatKey) {
224
+ touchEntry(gitContextCache, worktreePath, cached);
225
+ return { worktreePath: cached.worktreePath, branchName: cached.branchName, headSha: cached.headSha, persistedAt };
226
+ }
227
+
28
228
  const branchName: string | null = runGit(["branch", "--show-current"], cwd);
29
229
  const headSha: string | null = runGit(["rev-parse", "HEAD"], cwd);
30
230
 
31
- return {
32
- worktreePath: storagePaths.worktreeRoot,
33
- branchName,
34
- headSha,
35
- persistedAt,
36
- };
37
- }
231
+ const { gitDir } = resolveGitDir(worktreePath);
232
+ const core: GitContextCore = { worktreePath, branchName, headSha, headStatKey, gitDir };
233
+ evictLruIfNeeded(gitContextCache, GIT_CONTEXT_CACHE_CAPACITY);
234
+ gitContextCache.set(worktreePath, core);
38
235
 
39
- export function persistGitContext(db: Database, git: GitContextSnapshot, persistedAt: number = Date.now()): void {
236
+ return { worktreePath, branchName, headSha, persistedAt };
237
+ }
40
238
 
239
+ function persistGitContextInner(db: Database, git: GitContextSnapshot, persistedAt: number): void {
41
240
  db.query(
42
241
  `
43
242
  INSERT INTO git_context (
@@ -74,3 +273,22 @@ export function persistGitContext(db: Database, git: GitContextSnapshot, persist
74
273
  "@persistedAt": persistedAt,
75
274
  });
76
275
  }
276
+
277
+ /**
278
+ * Persist the git context snapshot to the database.
279
+ *
280
+ * If the caller is already inside a write transaction this function writes
281
+ * directly (no double-BEGIN). Otherwise it self-acquires a BEGIN IMMEDIATE
282
+ * transaction so concurrent callers — e.g. five parallel `session` invocations
283
+ * — never race on the deferred-to-immediate lock promotion that causes
284
+ * SQLITE_BUSY.
285
+ */
286
+ export function persistGitContext(db: Database, git: GitContextSnapshot, persistedAt: number = Date.now()): void {
287
+ if (db.inTransaction) {
288
+ persistGitContextInner(db, git, persistedAt);
289
+ } else {
290
+ writeTransaction(db, (txDb) => {
291
+ persistGitContextInner(txDb, git, persistedAt);
292
+ });
293
+ }
294
+ }