@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.1

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 (153) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +16 -2
  3. package/dist/agents.js +11 -0
  4. package/dist/cli-adapter.js +8 -0
  5. package/dist/component.js +65 -0
  6. package/dist/config-schema.js +265 -0
  7. package/dist/execution/activity.js +78 -0
  8. package/dist/execution/content.js +184 -0
  9. package/dist/execution/json-sections.js +231 -0
  10. package/dist/execution/presentation.js +79 -0
  11. package/dist/execution/preview.js +13 -0
  12. package/dist/execution/process.js +125 -0
  13. package/dist/execution/projection.js +327 -0
  14. package/dist/execution/query.js +18 -0
  15. package/dist/execution/routes.js +189 -0
  16. package/dist/execution/schema.js +322 -0
  17. package/dist/execution/service.js +1993 -0
  18. package/dist/features.js +22 -0
  19. package/dist/http.js +65 -0
  20. package/dist/performance/analysis.js +330 -0
  21. package/dist/performance/catalog.js +391 -0
  22. package/dist/performance/dashboard.js +483 -0
  23. package/dist/performance/error.js +15 -0
  24. package/dist/performance/index.js +6 -0
  25. package/dist/performance/inflight.js +43 -0
  26. package/dist/performance/projection.js +138 -0
  27. package/dist/performance/prompt/base.txt +38 -0
  28. package/dist/performance/prompt/builder.js +21 -0
  29. package/dist/performance/routes/performance-route.js +439 -0
  30. package/dist/performance/schema.js +628 -0
  31. package/dist/performance/timeline.js +98 -0
  32. package/dist/performance/trace-detail.js +91 -0
  33. package/dist/presentation/service.js +270 -0
  34. package/dist/presentation/tools/workbench-view.js +65 -0
  35. package/dist/presentation/tools.js +10 -0
  36. package/dist/project/directories.js +363 -0
  37. package/dist/project/git-health.js +498 -0
  38. package/dist/project/ide.js +66 -0
  39. package/dist/project/migration.js +22 -0
  40. package/dist/project/routes/directories.js +89 -0
  41. package/dist/project/routes/git.js +30 -0
  42. package/dist/project/routes/task-defaults.js +42 -0
  43. package/dist/project/session-health.js +9 -0
  44. package/dist/project/startup.js +28 -0
  45. package/dist/project/task-defaults.js +68 -0
  46. package/dist/project/tools/session-control.js +664 -0
  47. package/dist/project/tools/session-control.txt +60 -0
  48. package/dist/project/tools/worktree-archive.js +76 -0
  49. package/dist/project/tools/worktree-enter.js +175 -0
  50. package/dist/project/tools/worktree-leave.js +119 -0
  51. package/dist/project/tools/worktree-list.js +52 -0
  52. package/dist/project/tools.js +26 -0
  53. package/dist/project/vcs-branch-watcher.js +76 -0
  54. package/dist/project/vcs.js +114 -0
  55. package/dist/project/worktree-janitor.js +140 -0
  56. package/dist/project/worktrees.js +98 -0
  57. package/dist/push/bridge.js +171 -0
  58. package/dist/push/index.js +4 -0
  59. package/dist/push/routes/push.js +224 -0
  60. package/dist/push/service.js +93 -0
  61. package/dist/push/store.js +121 -0
  62. package/dist/push/types.js +58 -0
  63. package/dist/review/git.js +226 -0
  64. package/dist/review/routes.js +45 -0
  65. package/dist/review/schema.js +32 -0
  66. package/dist/review/state.js +79 -0
  67. package/dist/stats/aggregator.js +252 -0
  68. package/dist/stats/canonical.js +89 -0
  69. package/dist/stats/cli/stats.js +302 -0
  70. package/dist/stats/engine.js +176 -0
  71. package/dist/stats/index.js +5 -0
  72. package/dist/stats/rollup.js +460 -0
  73. package/dist/stats/routes/stats.js +94 -0
  74. package/dist/stats/routes/usage.js +83 -0
  75. package/dist/stats/storage.js +86 -0
  76. package/dist/stats/types.js +267 -0
  77. package/dist/worker.js +6 -0
  78. package/package.json +232 -4
  79. package/src/agents.ts +15 -0
  80. package/src/cli-adapter.ts +11 -0
  81. package/src/component.ts +73 -0
  82. package/src/config-schema.ts +289 -0
  83. package/src/execution/activity.ts +83 -0
  84. package/src/execution/content.ts +189 -0
  85. package/src/execution/json-sections.ts +230 -0
  86. package/src/execution/presentation.ts +91 -0
  87. package/src/execution/preview.ts +12 -0
  88. package/src/execution/process.ts +118 -0
  89. package/src/execution/projection.ts +330 -0
  90. package/src/execution/query.ts +19 -0
  91. package/src/execution/routes.ts +324 -0
  92. package/src/execution/schema.ts +336 -0
  93. package/src/execution/service.ts +2149 -0
  94. package/src/features.ts +22 -0
  95. package/src/http.ts +75 -0
  96. package/src/performance/analysis.ts +356 -0
  97. package/src/performance/catalog.ts +902 -0
  98. package/src/performance/dashboard.ts +521 -0
  99. package/src/performance/error.ts +32 -0
  100. package/src/performance/index.ts +6 -0
  101. package/src/performance/inflight.ts +46 -0
  102. package/src/performance/projection.ts +143 -0
  103. package/src/performance/prompt/base.txt +38 -0
  104. package/src/performance/prompt/builder.ts +24 -0
  105. package/src/performance/routes/performance-route.ts +541 -0
  106. package/src/performance/schema.ts +695 -0
  107. package/src/performance/timeline.ts +100 -0
  108. package/src/performance/trace-detail.ts +97 -0
  109. package/src/presentation/service.ts +303 -0
  110. package/src/presentation/tools/workbench-view.ts +75 -0
  111. package/src/presentation/tools.ts +11 -0
  112. package/src/project/directories.ts +391 -0
  113. package/src/project/git-health.ts +577 -0
  114. package/src/project/ide.ts +80 -0
  115. package/src/project/migration.ts +24 -0
  116. package/src/project/routes/directories.ts +124 -0
  117. package/src/project/routes/git.ts +42 -0
  118. package/src/project/routes/task-defaults.ts +50 -0
  119. package/src/project/session-health.ts +10 -0
  120. package/src/project/startup.ts +28 -0
  121. package/src/project/task-defaults.ts +70 -0
  122. package/src/project/tools/session-control.ts +725 -0
  123. package/src/project/tools/session-control.txt +60 -0
  124. package/src/project/tools/worktree-archive.ts +92 -0
  125. package/src/project/tools/worktree-enter.ts +227 -0
  126. package/src/project/tools/worktree-leave.ts +140 -0
  127. package/src/project/tools/worktree-list.ts +68 -0
  128. package/src/project/tools.ts +29 -0
  129. package/src/project/vcs-branch-watcher.ts +81 -0
  130. package/src/project/vcs.ts +124 -0
  131. package/src/project/worktree-janitor.ts +153 -0
  132. package/src/project/worktrees.ts +110 -0
  133. package/src/push/bridge.ts +182 -0
  134. package/src/push/index.ts +4 -0
  135. package/src/push/routes/push.ts +267 -0
  136. package/src/push/service.ts +108 -0
  137. package/src/push/store.ts +120 -0
  138. package/src/push/types.ts +67 -0
  139. package/src/review/git.ts +221 -0
  140. package/src/review/routes.ts +65 -0
  141. package/src/review/schema.ts +34 -0
  142. package/src/review/state.ts +77 -0
  143. package/src/stats/aggregator.ts +277 -0
  144. package/src/stats/canonical.ts +93 -0
  145. package/src/stats/cli/stats.ts +348 -0
  146. package/src/stats/engine.ts +197 -0
  147. package/src/stats/index.ts +5 -0
  148. package/src/stats/rollup.ts +522 -0
  149. package/src/stats/routes/stats.ts +107 -0
  150. package/src/stats/routes/usage.ts +103 -0
  151. package/src/stats/storage.ts +78 -0
  152. package/src/stats/types.ts +351 -0
  153. package/src/worker.ts +7 -0
@@ -0,0 +1,498 @@
1
+ import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ export var GitHealth;
5
+ (function (GitHealth) {
6
+ // ---------------------------------------------------------------------------
7
+ // Cache — per-directory with 5-minute TTL
8
+ // ---------------------------------------------------------------------------
9
+ const runtimeState = RuntimeContext.state(() => ({
10
+ _cache: new Map(),
11
+ _refreshing: new Map(),
12
+ _aliases: new Map(),
13
+ _lastDir: undefined,
14
+ _generation: 0,
15
+ _revisions: new Map(),
16
+ _dirty: new Set(),
17
+ _failed: new Set(),
18
+ }));
19
+ const CACHE_TTL_MS = 5 * 60 * 1000;
20
+ const GIT_COMMAND_TIMEOUT_MS = 2_000;
21
+ const SCAN_TIMEOUT_MS = 3_000;
22
+ const LARGE_FILE_STAT_BATCH_SIZE = 64;
23
+ const DEFAULT_GC_AUTO = 6700;
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers
26
+ // ---------------------------------------------------------------------------
27
+ function normalizeDir(cwd) {
28
+ return path.resolve(cwd);
29
+ }
30
+ function cacheKey(cwd) {
31
+ const instanceState = runtimeState();
32
+ const dir = normalizeDir(cwd);
33
+ return instanceState._aliases.get(dir) ?? dir;
34
+ }
35
+ function remainingMs(deadline) {
36
+ return Math.max(0, deadline - Date.now());
37
+ }
38
+ async function withDeadline(promise, ms) {
39
+ let timer;
40
+ const fallback = new Promise((_resolve, reject) => {
41
+ timer = setTimeout(() => reject(new Error("Git health scan timed out")), ms);
42
+ });
43
+ return Promise.race([promise, fallback]).finally(() => {
44
+ if (timer)
45
+ clearTimeout(timer);
46
+ });
47
+ }
48
+ async function gitText(cwd, args, options) {
49
+ const proc = Bun.spawn(["git", ...args], {
50
+ cwd,
51
+ stdout: "pipe",
52
+ stderr: "pipe",
53
+ signal: AbortSignal.timeout(GIT_COMMAND_TIMEOUT_MS),
54
+ });
55
+ const stdout = new Response(proc.stdout).text();
56
+ const stderr = new Response(proc.stderr).text().catch(() => "");
57
+ const [output, exitCode] = await Promise.all([stdout, proc.exited]);
58
+ const errorText = await stderr;
59
+ if (exitCode !== 0) {
60
+ if (options?.allowedExit?.includes(exitCode) || (options?.notRepo && /not a git repository/i.test(errorText)))
61
+ return undefined;
62
+ throw new Error(`Git health probe failed (${args[0]}, exit ${exitCode})`);
63
+ }
64
+ return output;
65
+ }
66
+ async function resolveRepo(cwd) {
67
+ const dir = normalizeDir(cwd);
68
+ const inside = await gitText(dir, ["rev-parse", "--is-inside-work-tree"], { notRepo: true });
69
+ if (inside?.trim() !== "true")
70
+ return undefined;
71
+ const [rootText, gitDirText] = await Promise.all([
72
+ gitText(dir, ["rev-parse", "--show-toplevel"]),
73
+ gitText(dir, ["rev-parse", "--git-dir"]),
74
+ ]);
75
+ const root = rootText?.trim();
76
+ const gitDir = gitDirText?.trim();
77
+ if (!root || !gitDir)
78
+ return undefined;
79
+ const resolvedRoot = normalizeDir(root);
80
+ return {
81
+ root: resolvedRoot,
82
+ gitDir: path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir),
83
+ };
84
+ }
85
+ function render(issues) {
86
+ const active = issues.filter((i) => i.level === "warn" || i.level === "critical");
87
+ if (active.length === 0)
88
+ return undefined;
89
+ const lines = [
90
+ "<git-health>",
91
+ "Latest completed diagnostic check; this is a bounded historical observation, not a live guarantee of repository state.",
92
+ ];
93
+ for (const issue of active) {
94
+ const label = issue.level === "critical" ? "Critical" : "Warning";
95
+ lines.push(`${label}: ${issue.message}`);
96
+ }
97
+ lines.push("</git-health>");
98
+ return lines.join("\n");
99
+ }
100
+ function parseNumstat(text) {
101
+ let additions = 0;
102
+ let deletions = 0;
103
+ const files = new Set();
104
+ for (const line of text.split("\n")) {
105
+ const trimmed = line.trim();
106
+ if (!trimmed)
107
+ continue;
108
+ const parts = trimmed.split("\t");
109
+ const add = parseInt(parts[0]);
110
+ const del = parseInt(parts[1]);
111
+ if (!isNaN(add))
112
+ additions += add;
113
+ if (!isNaN(del))
114
+ deletions += del;
115
+ if (parts.length >= 3 && parts[2])
116
+ files.add(parts[2]);
117
+ }
118
+ return { additions, deletions, files };
119
+ }
120
+ function countLooseObjectsOnDisk(gitDir) {
121
+ const objectsDir = path.join(gitDir, "objects");
122
+ let count = 0;
123
+ try {
124
+ for (const entry of fs.readdirSync(objectsDir)) {
125
+ if (entry === "pack" || entry === "info")
126
+ continue;
127
+ const subDir = path.join(objectsDir, entry);
128
+ const subStat = fs.statSync(subDir, { throwIfNoEntry: false });
129
+ if (subStat?.isDirectory()) {
130
+ count += fs.readdirSync(subDir).length;
131
+ }
132
+ }
133
+ }
134
+ catch { }
135
+ return count;
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // Dimensions 1+2: diff_lines and diff_files (single pass)
139
+ // diff_lines thresholds: 200→warn, 800→critical
140
+ // diff_files thresholds: 10→warn, 25→critical
141
+ // ---------------------------------------------------------------------------
142
+ async function checkDiff(cwd) {
143
+ const head = await gitText(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowedExit: [1] });
144
+ const reference = head ? ["HEAD"] : [];
145
+ const [unstaged, staged] = await Promise.all([
146
+ gitText(cwd, ["diff", "--numstat", ...reference]).then((x) => x ?? ""),
147
+ gitText(cwd, ["diff", "--cached", "--numstat", ...reference]).then((x) => x ?? ""),
148
+ ]);
149
+ const unstagedParsed = parseNumstat(unstaged);
150
+ const stagedParsed = parseNumstat(staged);
151
+ const issues = [];
152
+ // diff_lines
153
+ const unstagedLines = unstagedParsed.additions + unstagedParsed.deletions;
154
+ const stagedLines = stagedParsed.additions + stagedParsed.deletions;
155
+ const total = unstagedLines + stagedLines;
156
+ if (total >= 200) {
157
+ issues.push({
158
+ dimension: "diff_lines",
159
+ level: total >= 800 ? "critical" : "warn",
160
+ message: `${total} lines of uncommitted changes — commit now to avoid losing work`,
161
+ detail: { lines: total, staged: stagedLines, unstaged: unstagedLines },
162
+ });
163
+ }
164
+ // diff_files
165
+ const allFiles = new Set([...unstagedParsed.files, ...stagedParsed.files]);
166
+ const count = allFiles.size;
167
+ if (count >= 10) {
168
+ issues.push({
169
+ dimension: "diff_files",
170
+ level: count >= 25 ? "critical" : "warn",
171
+ message: `${count} files modified but not committed — split into focused commits while intent is fresh`,
172
+ detail: { files: count },
173
+ });
174
+ }
175
+ return issues;
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // Dimension 3: untracked
179
+ // Thresholds: 15→warn, 80→critical
180
+ // ---------------------------------------------------------------------------
181
+ async function checkUntracked(cwd) {
182
+ const result = (await gitText(cwd, ["ls-files", "--others", "--exclude-standard"])) ?? "";
183
+ const trimmed = result.trim();
184
+ const count = trimmed ? trimmed.split("\n").length : 0;
185
+ if (count < 15)
186
+ return undefined;
187
+ const level = count >= 80 ? "critical" : "warn";
188
+ return {
189
+ dimension: "untracked",
190
+ level,
191
+ message: `${count} untracked files — review with git status: add, ignore, or clean up`,
192
+ detail: { count },
193
+ };
194
+ }
195
+ // ---------------------------------------------------------------------------
196
+ // Dimension 4: large_files
197
+ // Thresholds: >10MB→warn, >50MB→critical
198
+ // ---------------------------------------------------------------------------
199
+ async function checkLargeFiles(cwd, deadline) {
200
+ const result = (await gitText(cwd, ["ls-files", "-z"])) ?? "";
201
+ const fileNames = result.split("\0").filter(Boolean);
202
+ if (fileNames.length === 0)
203
+ return undefined;
204
+ // Cap to avoid dominating check time on repos with hundreds of thousands of files.
205
+ const cappedNames = fileNames.length > 10000 ? fileNames.slice(0, 10000) : fileNames;
206
+ if (cappedNames.length === 0)
207
+ return undefined;
208
+ const large = [];
209
+ for (let i = 0; i < cappedNames.length; i += LARGE_FILE_STAT_BATCH_SIZE) {
210
+ if (Date.now() >= deadline)
211
+ throw new Error("Git health file scan timed out");
212
+ const batch = cappedNames.slice(i, i + LARGE_FILE_STAT_BATCH_SIZE);
213
+ const stats = await withDeadline(Promise.all(batch.map(async (fileName) => {
214
+ const filePath = path.join(cwd, fileName);
215
+ const stat = await fs.promises.lstat(filePath).catch((error) => {
216
+ if (error.code === "ENOENT")
217
+ return undefined;
218
+ throw error;
219
+ });
220
+ if (!stat || stat.isSymbolicLink())
221
+ return undefined;
222
+ return { fileName, stat };
223
+ })), remainingMs(deadline));
224
+ for (const result of stats) {
225
+ if (result && result.stat.size > 10 * 1024 * 1024) {
226
+ large.push({ path: result.fileName, size: result.stat.size });
227
+ }
228
+ }
229
+ }
230
+ if (large.length === 0)
231
+ return undefined;
232
+ large.sort((a, b) => b.size - a.size);
233
+ const largest = large[0];
234
+ const level = largest.size >= 50 * 1024 * 1024 ? "critical" : "warn";
235
+ return {
236
+ dimension: "large_files",
237
+ level,
238
+ message: `${large.length} large file(s) tracked — largest is ${largest.path} (${Math.round(largest.size / (1024 * 1024))}MB). If committed by accident, add to .gitignore and remove from tracking`,
239
+ detail: {
240
+ count: large.length,
241
+ largest: largest.path,
242
+ largestSize: largest.size,
243
+ files: large.slice(0, 5).map((f) => f.path),
244
+ },
245
+ };
246
+ }
247
+ // ---------------------------------------------------------------------------
248
+ // Dimension 5: extra_branches
249
+ // Thresholds: 10→warn, 40→critical
250
+ // ---------------------------------------------------------------------------
251
+ async function checkExtraBranches(cwd) {
252
+ const [headBranch, branchesText] = await Promise.all([
253
+ gitText(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], { allowedExit: [1] }).then((x) => x?.trim() ?? ""),
254
+ gitText(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/"]).then((x) => x ?? ""),
255
+ ]);
256
+ const branches = branchesText.trim().split("\n").filter(Boolean);
257
+ if (branches.length === 0)
258
+ return undefined;
259
+ const count = branches.filter((b) => b !== headBranch).length;
260
+ if (count < 10)
261
+ return undefined;
262
+ const level = count >= 40 ? "critical" : "warn";
263
+ return {
264
+ dimension: "extra_branches",
265
+ level,
266
+ message: `${count} non-current branches — archive or remove them via the worktree cleanup path; deleting is deferred there because squash- or rebase-merged branches are not detected as merged by ancestry alone, which needs content comparison`,
267
+ detail: { count },
268
+ };
269
+ }
270
+ // ---------------------------------------------------------------------------
271
+ // Dimension 6: detached_head
272
+ // ---------------------------------------------------------------------------
273
+ async function checkDetachedHead(cwd) {
274
+ const symRef = await gitText(cwd, ["symbolic-ref", "--quiet", "HEAD"], { allowedExit: [1] });
275
+ if (symRef !== undefined)
276
+ return undefined;
277
+ const headCommit = (await gitText(cwd, ["rev-parse", "HEAD"]))?.trim() ?? "";
278
+ if (!headCommit)
279
+ return undefined;
280
+ return {
281
+ dimension: "detached_head",
282
+ level: "critical",
283
+ message: "Detached HEAD — commits will be lost unless you create a branch",
284
+ detail: { headCommit },
285
+ };
286
+ }
287
+ // ---------------------------------------------------------------------------
288
+ // Dimension 7: gc_needed
289
+ // Thresholds derive from the repo's effective gc.auto (git's documented
290
+ // default is 6700): git auto-gcs once loose objects exceed gc.auto, so warn at
291
+ // a quarter of that. Uses both git count-objects and filesystem count (max of
292
+ // the two); git count-objects only counts valid objects, while the filesystem
293
+ // fallback catches hand-crafted test fixture objects.
294
+ // ---------------------------------------------------------------------------
295
+ async function checkGcNeeded(cwd, gitDir, deadline) {
296
+ const [output, gcAutoText] = await Promise.all([
297
+ gitText(cwd, ["count-objects", "-v"]).then((x) => x ?? ""),
298
+ gitText(cwd, ["config", "--get", "gc.auto"], { allowedExit: [1] }),
299
+ ]);
300
+ const countMatch = output.match(/count:\s*(\d+)/);
301
+ const sizeMatch = output.match(/size:\s*(\d+)/);
302
+ const gitCount = countMatch ? parseInt(countMatch[1]) : 0;
303
+ const diskCount = Date.now() >= deadline ? 0 : countLooseObjectsOnDisk(gitDir);
304
+ const looseObjects = Math.max(gitCount, diskCount);
305
+ const size = sizeMatch ? parseInt(sizeMatch[1]) : 0;
306
+ // gc.auto <= 0 disables auto-gc, so treat it as absent rather than as a threshold.
307
+ const configuredGcAuto = parseInt(gcAutoText?.trim() ?? "");
308
+ const gcAuto = Number.isFinite(configuredGcAuto) && configuredGcAuto > 0 ? configuredGcAuto : DEFAULT_GC_AUTO;
309
+ if (looseObjects < Math.floor(gcAuto / 4))
310
+ return undefined;
311
+ const level = looseObjects >= gcAuto ? "critical" : "warn";
312
+ return {
313
+ dimension: "gc_needed",
314
+ level,
315
+ message: `Git gc recommended — ${looseObjects} loose objects (gc.auto ${gcAuto}). Run git gc to reclaim space`,
316
+ detail: { looseObjects, size },
317
+ };
318
+ }
319
+ // ---------------------------------------------------------------------------
320
+ // Dimension 8: unpushed
321
+ // ---------------------------------------------------------------------------
322
+ async function checkUnpushed(cwd) {
323
+ // With no remote-tracking refs `--not --remotes` subtracts nothing and the
324
+ // count degenerates to the entire local history, so only report when the
325
+ // repo actually tracks a remote.
326
+ const remoteRefs = await gitText(cwd, ["for-each-ref", "--count=1", "--format=%(refname)", "refs/remotes/"]);
327
+ if (!remoteRefs?.trim())
328
+ return undefined;
329
+ if (!(await gitText(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowedExit: [1] })))
330
+ return undefined;
331
+ const output = await gitText(cwd, ["rev-list", "--count", "HEAD", "--not", "--remotes"]);
332
+ const count = parseInt(output?.trim() ?? "");
333
+ if (!Number.isFinite(count) || count <= 0)
334
+ return undefined;
335
+ return {
336
+ dimension: "unpushed",
337
+ level: "warn",
338
+ message: `${count} commit(s) not on any remote — push to preserve this work`,
339
+ detail: { unpushed: count },
340
+ };
341
+ }
342
+ // ---------------------------------------------------------------------------
343
+ // Public API
344
+ // ---------------------------------------------------------------------------
345
+ async function scan(cwd) {
346
+ const fallbackDir = normalizeDir(cwd);
347
+ const deadline = Date.now() + SCAN_TIMEOUT_MS;
348
+ const repo = await resolveRepo(fallbackDir);
349
+ if (!repo)
350
+ return { dir: fallbackDir, issues: [] };
351
+ const remaining = remainingMs(deadline);
352
+ if (remaining === 0)
353
+ throw new Error("Git health repository probe timed out");
354
+ const checks = [
355
+ checkDiff(repo.root),
356
+ checkUntracked(repo.root),
357
+ checkLargeFiles(repo.root, deadline),
358
+ checkExtraBranches(repo.root),
359
+ checkDetachedHead(repo.root),
360
+ checkGcNeeded(repo.root, repo.gitDir, deadline),
361
+ checkUnpushed(repo.root),
362
+ ];
363
+ const results = await withDeadline(Promise.all(checks), remaining);
364
+ const issues = results.flat().filter((i) => i !== undefined);
365
+ return { dir: repo.root, issues };
366
+ }
367
+ /**
368
+ * Live repository probe shared with the env prompt block. Deliberately
369
+ * bypasses the scan cache: callers (per-turn env assembly) need the current
370
+ * answer, and a stale repo probe is exactly the bug this replaces.
371
+ */
372
+ async function isGitRepo(cwd) {
373
+ const repo = await resolveRepo(normalizeDir(cwd ?? process.cwd())).catch(() => undefined);
374
+ return repo !== undefined;
375
+ }
376
+ GitHealth.isGitRepo = isGitRepo;
377
+ function refresh(cwd) {
378
+ const instanceState = runtimeState();
379
+ const inputDir = normalizeDir(cwd ?? process.cwd());
380
+ const key = cacheKey(inputDir);
381
+ const existing = instanceState._refreshing.get(key) ?? instanceState._refreshing.get(inputDir);
382
+ if (existing)
383
+ return existing;
384
+ const generation = instanceState._generation;
385
+ const revisions = new Map(instanceState._revisions);
386
+ const unchanged = (dir) => (revisions.get(dir) ?? 0) === (instanceState._revisions.get(dir) ?? 0);
387
+ let promise;
388
+ promise = scan(inputDir)
389
+ .then((result) => {
390
+ const instanceState = runtimeState();
391
+ if (generation === instanceState._generation)
392
+ instanceState._aliases.set(inputDir, result.dir);
393
+ if (generation === instanceState._generation && unchanged(key) && unchanged(result.dir)) {
394
+ instanceState._cache.set(result.dir, { issues: result.issues, ts: Date.now() });
395
+ instanceState._lastDir = result.dir;
396
+ instanceState._dirty.delete(key);
397
+ instanceState._dirty.delete(result.dir);
398
+ instanceState._dirty.delete(inputDir);
399
+ instanceState._failed.delete(key);
400
+ instanceState._failed.delete(result.dir);
401
+ }
402
+ return result.issues;
403
+ })
404
+ .catch(() => {
405
+ if (generation === instanceState._generation && unchanged(key)) {
406
+ instanceState._failed.add(key);
407
+ instanceState._dirty.delete(key);
408
+ }
409
+ return instanceState._cache.get(key)?.issues ?? [];
410
+ })
411
+ .finally(() => {
412
+ const instanceState = runtimeState();
413
+ if (instanceState._refreshing.get(key) === promise)
414
+ instanceState._refreshing.delete(key);
415
+ if (instanceState._refreshing.get(inputDir) === promise)
416
+ instanceState._refreshing.delete(inputDir);
417
+ })
418
+ .then((issues) => generation === instanceState._generation && (!unchanged(key) || !unchanged(cacheKey(inputDir)))
419
+ ? refresh(inputDir)
420
+ : issues);
421
+ instanceState._refreshing.set(key, promise);
422
+ if (key !== inputDir)
423
+ instanceState._refreshing.set(inputDir, promise);
424
+ return promise;
425
+ }
426
+ GitHealth.refresh = refresh;
427
+ async function check(cwd) {
428
+ const instanceState = runtimeState();
429
+ const dir = normalizeDir(cwd ?? process.cwd());
430
+ const key = cacheKey(dir);
431
+ const cached = instanceState._cache.get(key);
432
+ if (cached &&
433
+ !instanceState._dirty.has(key) &&
434
+ !instanceState._failed.has(key) &&
435
+ Date.now() - cached.ts < CACHE_TTL_MS) {
436
+ instanceState._lastDir = key;
437
+ return cached.issues;
438
+ }
439
+ return refresh(dir);
440
+ }
441
+ GitHealth.check = check;
442
+ async function inject(cwd) {
443
+ return render(await check(cwd));
444
+ }
445
+ GitHealth.inject = inject;
446
+ function injectCached(cwd) {
447
+ const observation = observeCached(cwd);
448
+ return observation.state === "present" ? observation.text : undefined;
449
+ }
450
+ GitHealth.injectCached = injectCached;
451
+ function observeCached(cwd) {
452
+ const instanceState = runtimeState();
453
+ const dir = normalizeDir(cwd ?? process.cwd());
454
+ const key = cacheKey(dir);
455
+ const cached = instanceState._cache.get(key);
456
+ if (!cached ||
457
+ instanceState._dirty.has(key) ||
458
+ instanceState._failed.has(key) ||
459
+ Date.now() - cached.ts >= CACHE_TTL_MS) {
460
+ refresh(dir).catch(() => { });
461
+ }
462
+ if (instanceState._failed.has(key))
463
+ return { state: "unavailable" };
464
+ if (!cached || instanceState._dirty.has(key) || Date.now() - cached.ts >= CACHE_TTL_MS)
465
+ return { state: "pending" };
466
+ instanceState._lastDir = key;
467
+ const text = render(cached.issues);
468
+ return text ? { state: "present", text } : { state: "cleared" };
469
+ }
470
+ GitHealth.observeCached = observeCached;
471
+ function lastReport() {
472
+ const instanceState = runtimeState();
473
+ if (!instanceState._lastDir)
474
+ return undefined;
475
+ const cached = instanceState._cache.get(instanceState._lastDir);
476
+ return cached?.issues.length ? cached.issues : undefined;
477
+ }
478
+ GitHealth.lastReport = lastReport;
479
+ function invalidate(cwd) {
480
+ const instanceState = runtimeState();
481
+ if (cwd === undefined) {
482
+ instanceState._generation++;
483
+ instanceState._refreshing.clear();
484
+ instanceState._cache.clear();
485
+ instanceState._aliases.clear();
486
+ instanceState._lastDir = undefined;
487
+ instanceState._revisions.clear();
488
+ instanceState._dirty.clear();
489
+ instanceState._failed.clear();
490
+ return;
491
+ }
492
+ const dir = normalizeDir(cwd);
493
+ const key = cacheKey(dir);
494
+ instanceState._revisions.set(key, (instanceState._revisions.get(key) ?? 0) + 1);
495
+ instanceState._dirty.add(key);
496
+ }
497
+ GitHealth.invalidate = invalidate;
498
+ })(GitHealth || (GitHealth = {}));
@@ -0,0 +1,66 @@
1
+ import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context";
2
+ import { BusEvent } from "@ericsanchezok/synergy-harness/bus/bus-event";
3
+ import { spawn } from "bun";
4
+ import z from "zod";
5
+ import { NamedError } from "@ericsanchezok/synergy-util/error";
6
+ import { Log } from "@ericsanchezok/synergy-harness/util/log";
7
+ const SUPPORTED_IDES = [
8
+ { name: "Windsurf", cmd: "windsurf" },
9
+ { name: "Visual Studio Code - Insiders", cmd: "code-insiders" },
10
+ { name: "Visual Studio Code", cmd: "code" },
11
+ { name: "Cursor", cmd: "cursor" },
12
+ { name: "VSCodium", cmd: "codium" },
13
+ ];
14
+ export var Ide;
15
+ (function (Ide) {
16
+ const log = Log.create({ service: "ide" });
17
+ Ide.Event = {
18
+ Installed: BusEvent.define("ide.installed", z.object({
19
+ ide: z.string(),
20
+ })),
21
+ };
22
+ Ide.AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({}));
23
+ Ide.InstallFailedError = NamedError.create("InstallFailedError", z.object({
24
+ stderr: z.string(),
25
+ }));
26
+ function ide() {
27
+ if (RuntimeContext.current().host.env["TERM_PROGRAM"] === "vscode") {
28
+ const v = RuntimeContext.current().host.env["GIT_ASKPASS"];
29
+ for (const ide of SUPPORTED_IDES) {
30
+ if (v?.includes(ide.name))
31
+ return ide.name;
32
+ }
33
+ }
34
+ return "unknown";
35
+ }
36
+ Ide.ide = ide;
37
+ function alreadyInstalled() {
38
+ return (RuntimeContext.current().host.env["SYNERGY_CALLER"] === "vscode" ||
39
+ RuntimeContext.current().host.env["SYNERGY_CALLER"] === "vscode-insiders");
40
+ }
41
+ Ide.alreadyInstalled = alreadyInstalled;
42
+ async function install(ide) {
43
+ const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd;
44
+ if (!cmd)
45
+ throw new Error(`Unknown IDE: ${ide}`);
46
+ const p = spawn([cmd, "--install-extension", "ericsanchezok.synergy"], {
47
+ stdout: "pipe",
48
+ stderr: "pipe",
49
+ });
50
+ await p.exited;
51
+ const stdout = await new Response(p.stdout).text();
52
+ const stderr = await new Response(p.stderr).text();
53
+ log.info("installed", {
54
+ ide,
55
+ stdout,
56
+ stderr,
57
+ });
58
+ if (p.exitCode !== 0) {
59
+ throw new Ide.InstallFailedError({ stderr });
60
+ }
61
+ if (stdout.includes("already installed")) {
62
+ throw new Ide.AlreadyInstalledError({});
63
+ }
64
+ }
65
+ Ide.install = install;
66
+ })(Ide || (Ide = {}));
@@ -0,0 +1,22 @@
1
+ import { MigrationRegistry } from "@ericsanchezok/synergy-harness/migration/registry";
2
+ import { Scope } from "@ericsanchezok/synergy-harness/scope";
3
+ import { ProjectDirectories } from "./directories.js";
4
+ export const projectMigrations = [
5
+ {
6
+ id: "20260929-project-directories",
7
+ scope: "global",
8
+ execution: "startup",
9
+ description: "Separate project folders from historical Worktrees without changing Scope identity",
10
+ async up(progress) {
11
+ const projects = await Scope.list();
12
+ progress(0, projects.length);
13
+ for (const [index, project] of projects.entries()) {
14
+ await ProjectDirectories.migrateProject(project);
15
+ progress(index + 1, projects.length);
16
+ }
17
+ },
18
+ },
19
+ ];
20
+ export function registerProjectMigrations() {
21
+ MigrationRegistry.register("workbench-projects", projectMigrations);
22
+ }
@@ -0,0 +1,89 @@
1
+ import { Hono } from "hono";
2
+ import { describeRoute, resolver, validator } from "hono-openapi";
3
+ import { z } from "zod";
4
+ import { Scope } from "@ericsanchezok/synergy-harness/scope";
5
+ import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context";
6
+ import { ProjectWorktrees } from "../worktrees.js";
7
+ import { WorkspaceAccess } from "@ericsanchezok/synergy-harness/workspace/access";
8
+ import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree";
9
+ import { ProjectDirectories } from "../directories.js";
10
+ const params = z.object({ scopeID: z.string().min(1) });
11
+ const response = (schema) => ({
12
+ description: "Project folders",
13
+ content: { "application/json": { schema: resolver(schema) } },
14
+ });
15
+ const failures = {
16
+ 400: response(ProjectDirectories.Invalid.Schema),
17
+ 409: response(ProjectDirectories.Conflict.Schema),
18
+ };
19
+ async function result(c, action) {
20
+ try {
21
+ return c.json(await action());
22
+ }
23
+ catch (error) {
24
+ if (error instanceof ProjectDirectories.Invalid)
25
+ return c.json(error.toObject(), 400);
26
+ if (error instanceof ProjectDirectories.Conflict)
27
+ return c.json(error.toObject(), 409);
28
+ if (error instanceof WorkspaceAccess.BusyError)
29
+ return c.json({
30
+ name: "ProjectDirectoriesConflict",
31
+ data: { message: "Project folders are in use. Keep your changes and retry when tasks are idle." },
32
+ }, 409);
33
+ throw error;
34
+ }
35
+ }
36
+ export function ProjectDirectoriesRoute() {
37
+ return new Hono()
38
+ .post("/", describeRoute({
39
+ summary: "Create a project from folders",
40
+ operationId: "project.create",
41
+ responses: { 200: response(ProjectDirectories.Created), ...failures },
42
+ }), validator("json", ProjectDirectories.CreateInput), (c) => result(c, () => ProjectDirectories.create(c.req.valid("json"))))
43
+ .get("/:scopeID/directories", describeRoute({
44
+ summary: "Get project folders",
45
+ operationId: "project.directories",
46
+ responses: { 200: response(ProjectDirectories.Result) },
47
+ }), validator("param", params), (c) => result(c, () => ProjectDirectories.get(c.req.valid("param").scopeID)))
48
+ .patch("/:scopeID/directories", describeRoute({
49
+ summary: "Save main and additional project folders",
50
+ operationId: "project.updateDirectories",
51
+ responses: { 200: response(ProjectDirectories.Result), ...failures },
52
+ }), validator("param", params), validator("json", ProjectDirectories.UpdateInput), (c) => result(c, () => ProjectDirectories.update(c.req.valid("param").scopeID, c.req.valid("json"))))
53
+ .get("/:scopeID/worktree-inventory", describeRoute({
54
+ summary: "List Worktree identities without computing status or disk usage",
55
+ operationId: "project.worktreeInventory",
56
+ responses: { 200: response(ProjectWorktrees.Inventory) },
57
+ }), validator("param", params), async (c) => {
58
+ const scope = await Scope.resolve(c.req.valid("param"));
59
+ const inventory = await ScopeContext.provide({
60
+ scope,
61
+ workspace: null,
62
+ fn: () => ProjectWorktrees.inventory(scope.id),
63
+ });
64
+ c.header("x-synergy-epoch", inventory.sync.epoch);
65
+ c.header("x-synergy-seq", String(inventory.sync.seq));
66
+ return c.json(inventory);
67
+ })
68
+ .get("/:scopeID/worktree-details", describeRoute({
69
+ summary: "Compute status, disk usage and cleanup protection for one Worktree",
70
+ operationId: "project.worktreeDetails",
71
+ responses: { 200: response(Worktree.Details), ...failures },
72
+ }), validator("param", params), validator("query", z.object({ target: z.string().min(1), sourceWorkspaceID: z.string().optional() })), async (c) => {
73
+ const scope = await Scope.resolve(c.req.valid("param"));
74
+ const input = c.req.valid("query");
75
+ return c.json(await ScopeContext.provide({
76
+ scope,
77
+ workspace: null,
78
+ fn: () => Worktree.withSource(input.sourceWorkspaceID, () => Worktree.details(input)),
79
+ }));
80
+ })
81
+ .get("/:scopeID/worktrees", describeRoute({
82
+ summary: "List project Worktrees with their original repositories",
83
+ operationId: "project.worktrees",
84
+ responses: { 200: response(Worktree.Info.array()) },
85
+ }), validator("param", params), async (c) => {
86
+ const scope = await Scope.resolve(c.req.valid("param"));
87
+ return c.json(await ScopeContext.provide({ scope, workspace: null, fn: () => ProjectWorktrees.list(scope.id) }));
88
+ });
89
+ }