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.
- package/.agents/skills/trekoon/SKILL.md +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -7
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +49 -16
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +5 -1
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +47 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +87 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- package/src/sync/service.ts +650 -147
package/src/sync/git-context.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
+
}
|