@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.0

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,577 @@
1
+ import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
2
+ import path from "path"
3
+ import fs from "fs"
4
+
5
+ export namespace GitHealth {
6
+ export interface Issue {
7
+ dimension:
8
+ | "diff_lines"
9
+ | "diff_files"
10
+ | "untracked"
11
+ | "large_files"
12
+ | "extra_branches"
13
+ | "detached_head"
14
+ | "gc_needed"
15
+ | "unpushed"
16
+ level: "warn" | "critical"
17
+ message: string
18
+ detail: Record<string, unknown>
19
+ }
20
+
21
+ interface CacheEntry {
22
+ issues: Issue[]
23
+ ts: number
24
+ }
25
+
26
+ interface RepoInfo {
27
+ root: string
28
+ gitDir: string
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Cache — per-directory with 5-minute TTL
33
+ // ---------------------------------------------------------------------------
34
+ const runtimeState = RuntimeContext.state(() => ({
35
+ _cache: new Map<string, CacheEntry>(),
36
+ _refreshing: new Map<string, Promise<Issue[]>>(),
37
+ _aliases: new Map<string, string>(),
38
+ _lastDir: undefined as string | undefined,
39
+ _generation: 0,
40
+ _revisions: new Map<string, number>(),
41
+ _dirty: new Set<string>(),
42
+ _failed: new Set<string>(),
43
+ }))
44
+
45
+ const CACHE_TTL_MS = 5 * 60 * 1000
46
+ const GIT_COMMAND_TIMEOUT_MS = 2_000
47
+ const SCAN_TIMEOUT_MS = 3_000
48
+ const LARGE_FILE_STAT_BATCH_SIZE = 64
49
+ const DEFAULT_GC_AUTO = 6700
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Helpers
53
+ // ---------------------------------------------------------------------------
54
+
55
+ function normalizeDir(cwd: string): string {
56
+ return path.resolve(cwd)
57
+ }
58
+
59
+ function cacheKey(cwd: string): string {
60
+ const instanceState = runtimeState()
61
+
62
+ const dir = normalizeDir(cwd)
63
+ return instanceState._aliases.get(dir) ?? dir
64
+ }
65
+
66
+ function remainingMs(deadline: number): number {
67
+ return Math.max(0, deadline - Date.now())
68
+ }
69
+
70
+ async function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
71
+ let timer: ReturnType<typeof setTimeout> | undefined
72
+ const fallback = new Promise<T>((_resolve, reject) => {
73
+ timer = setTimeout(() => reject(new Error("Git health scan timed out")), ms)
74
+ })
75
+ return Promise.race([promise, fallback]).finally(() => {
76
+ if (timer) clearTimeout(timer)
77
+ })
78
+ }
79
+
80
+ async function gitText(
81
+ cwd: string,
82
+ args: string[],
83
+ options?: { allowedExit?: number[]; notRepo?: boolean },
84
+ ): Promise<string | undefined> {
85
+ const proc = Bun.spawn(["git", ...args], {
86
+ cwd,
87
+ stdout: "pipe",
88
+ stderr: "pipe",
89
+ signal: AbortSignal.timeout(GIT_COMMAND_TIMEOUT_MS),
90
+ })
91
+ const stdout = new Response(proc.stdout).text()
92
+ const stderr = new Response(proc.stderr).text().catch(() => "")
93
+ const [output, exitCode] = await Promise.all([stdout, proc.exited])
94
+ const errorText = await stderr
95
+ if (exitCode !== 0) {
96
+ if (options?.allowedExit?.includes(exitCode) || (options?.notRepo && /not a git repository/i.test(errorText)))
97
+ return undefined
98
+ throw new Error(`Git health probe failed (${args[0]}, exit ${exitCode})`)
99
+ }
100
+ return output
101
+ }
102
+
103
+ async function resolveRepo(cwd: string): Promise<RepoInfo | undefined> {
104
+ const dir = normalizeDir(cwd)
105
+ const inside = await gitText(dir, ["rev-parse", "--is-inside-work-tree"], { notRepo: true })
106
+ if (inside?.trim() !== "true") return undefined
107
+
108
+ const [rootText, gitDirText] = await Promise.all([
109
+ gitText(dir, ["rev-parse", "--show-toplevel"]),
110
+ gitText(dir, ["rev-parse", "--git-dir"]),
111
+ ])
112
+ const root = rootText?.trim()
113
+ const gitDir = gitDirText?.trim()
114
+ if (!root || !gitDir) return undefined
115
+
116
+ const resolvedRoot = normalizeDir(root)
117
+ return {
118
+ root: resolvedRoot,
119
+ gitDir: path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir),
120
+ }
121
+ }
122
+
123
+ function render(issues: Issue[]): string | undefined {
124
+ const active = issues.filter((i) => i.level === "warn" || i.level === "critical")
125
+ if (active.length === 0) return undefined
126
+
127
+ const lines = [
128
+ "<git-health>",
129
+ "Latest completed diagnostic check; this is a bounded historical observation, not a live guarantee of repository state.",
130
+ ]
131
+ for (const issue of active) {
132
+ const label = issue.level === "critical" ? "Critical" : "Warning"
133
+ lines.push(`${label}: ${issue.message}`)
134
+ }
135
+ lines.push("</git-health>")
136
+ return lines.join("\n")
137
+ }
138
+
139
+ function parseNumstat(text: string): { additions: number; deletions: number; files: Set<string> } {
140
+ let additions = 0
141
+ let deletions = 0
142
+ const files = new Set<string>()
143
+
144
+ for (const line of text.split("\n")) {
145
+ const trimmed = line.trim()
146
+ if (!trimmed) continue
147
+ const parts = trimmed.split("\t")
148
+ const add = parseInt(parts[0])
149
+ const del = parseInt(parts[1])
150
+ if (!isNaN(add)) additions += add
151
+ if (!isNaN(del)) deletions += del
152
+ if (parts.length >= 3 && parts[2]) files.add(parts[2])
153
+ }
154
+
155
+ return { additions, deletions, files }
156
+ }
157
+
158
+ function countLooseObjectsOnDisk(gitDir: string): number {
159
+ const objectsDir = path.join(gitDir, "objects")
160
+ let count = 0
161
+ try {
162
+ for (const entry of fs.readdirSync(objectsDir)) {
163
+ if (entry === "pack" || entry === "info") continue
164
+ const subDir = path.join(objectsDir, entry)
165
+ const subStat = fs.statSync(subDir, { throwIfNoEntry: false })
166
+ if (subStat?.isDirectory()) {
167
+ count += fs.readdirSync(subDir).length
168
+ }
169
+ }
170
+ } catch {}
171
+ return count
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Dimensions 1+2: diff_lines and diff_files (single pass)
176
+ // diff_lines thresholds: 200→warn, 800→critical
177
+ // diff_files thresholds: 10→warn, 25→critical
178
+ // ---------------------------------------------------------------------------
179
+ async function checkDiff(cwd: string): Promise<Issue[]> {
180
+ const head = await gitText(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowedExit: [1] })
181
+ const reference = head ? ["HEAD"] : []
182
+ const [unstaged, staged] = await Promise.all([
183
+ gitText(cwd, ["diff", "--numstat", ...reference]).then((x) => x ?? ""),
184
+ gitText(cwd, ["diff", "--cached", "--numstat", ...reference]).then((x) => x ?? ""),
185
+ ])
186
+
187
+ const unstagedParsed = parseNumstat(unstaged)
188
+ const stagedParsed = parseNumstat(staged)
189
+
190
+ const issues: Issue[] = []
191
+
192
+ // diff_lines
193
+ const unstagedLines = unstagedParsed.additions + unstagedParsed.deletions
194
+ const stagedLines = stagedParsed.additions + stagedParsed.deletions
195
+ const total = unstagedLines + stagedLines
196
+
197
+ if (total >= 200) {
198
+ issues.push({
199
+ dimension: "diff_lines",
200
+ level: total >= 800 ? "critical" : "warn",
201
+ message: `${total} lines of uncommitted changes — commit now to avoid losing work`,
202
+ detail: { lines: total, staged: stagedLines, unstaged: unstagedLines },
203
+ })
204
+ }
205
+
206
+ // diff_files
207
+ const allFiles = new Set([...unstagedParsed.files, ...stagedParsed.files])
208
+ const count = allFiles.size
209
+
210
+ if (count >= 10) {
211
+ issues.push({
212
+ dimension: "diff_files",
213
+ level: count >= 25 ? "critical" : "warn",
214
+ message: `${count} files modified but not committed — split into focused commits while intent is fresh`,
215
+ detail: { files: count },
216
+ })
217
+ }
218
+
219
+ return issues
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Dimension 3: untracked
224
+ // Thresholds: 15→warn, 80→critical
225
+ // ---------------------------------------------------------------------------
226
+ async function checkUntracked(cwd: string): Promise<Issue | undefined> {
227
+ const result = (await gitText(cwd, ["ls-files", "--others", "--exclude-standard"])) ?? ""
228
+
229
+ const trimmed = result.trim()
230
+ const count = trimmed ? trimmed.split("\n").length : 0
231
+
232
+ if (count < 15) return undefined
233
+
234
+ const level: Issue["level"] = count >= 80 ? "critical" : "warn"
235
+
236
+ return {
237
+ dimension: "untracked",
238
+ level,
239
+ message: `${count} untracked files — review with git status: add, ignore, or clean up`,
240
+ detail: { count },
241
+ }
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Dimension 4: large_files
246
+ // Thresholds: >10MB→warn, >50MB→critical
247
+ // ---------------------------------------------------------------------------
248
+ async function checkLargeFiles(cwd: string, deadline: number): Promise<Issue | undefined> {
249
+ const result = (await gitText(cwd, ["ls-files", "-z"])) ?? ""
250
+
251
+ const fileNames = result.split("\0").filter(Boolean)
252
+ if (fileNames.length === 0) return undefined
253
+ // Cap to avoid dominating check time on repos with hundreds of thousands of files.
254
+ const cappedNames = fileNames.length > 10000 ? fileNames.slice(0, 10000) : fileNames
255
+ if (cappedNames.length === 0) return undefined
256
+
257
+ const large: { path: string; size: number }[] = []
258
+
259
+ for (let i = 0; i < cappedNames.length; i += LARGE_FILE_STAT_BATCH_SIZE) {
260
+ if (Date.now() >= deadline) throw new Error("Git health file scan timed out")
261
+ const batch = cappedNames.slice(i, i + LARGE_FILE_STAT_BATCH_SIZE)
262
+ const stats = await withDeadline(
263
+ Promise.all(
264
+ batch.map(async (fileName) => {
265
+ const filePath = path.join(cwd, fileName)
266
+ const stat = await fs.promises.lstat(filePath).catch((error: NodeJS.ErrnoException) => {
267
+ if (error.code === "ENOENT") return undefined
268
+ throw error
269
+ })
270
+ if (!stat || stat.isSymbolicLink()) return undefined
271
+ return { fileName, stat }
272
+ }),
273
+ ),
274
+ remainingMs(deadline),
275
+ )
276
+ for (const result of stats) {
277
+ if (result && result.stat.size > 10 * 1024 * 1024) {
278
+ large.push({ path: result.fileName, size: result.stat.size })
279
+ }
280
+ }
281
+ }
282
+
283
+ if (large.length === 0) return undefined
284
+
285
+ large.sort((a, b) => b.size - a.size)
286
+ const largest = large[0]
287
+
288
+ const level: Issue["level"] = largest.size >= 50 * 1024 * 1024 ? "critical" : "warn"
289
+
290
+ return {
291
+ dimension: "large_files",
292
+ level,
293
+ 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`,
294
+ detail: {
295
+ count: large.length,
296
+ largest: largest.path,
297
+ largestSize: largest.size,
298
+ files: large.slice(0, 5).map((f) => f.path),
299
+ },
300
+ }
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Dimension 5: extra_branches
305
+ // Thresholds: 10→warn, 40→critical
306
+ // ---------------------------------------------------------------------------
307
+ async function checkExtraBranches(cwd: string): Promise<Issue | undefined> {
308
+ const [headBranch, branchesText] = await Promise.all([
309
+ gitText(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], { allowedExit: [1] }).then((x) => x?.trim() ?? ""),
310
+ gitText(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/"]).then((x) => x ?? ""),
311
+ ])
312
+
313
+ const branches = branchesText.trim().split("\n").filter(Boolean)
314
+ if (branches.length === 0) return undefined
315
+
316
+ const count = branches.filter((b) => b !== headBranch).length
317
+ if (count < 10) return undefined
318
+
319
+ const level: Issue["level"] = count >= 40 ? "critical" : "warn"
320
+
321
+ return {
322
+ dimension: "extra_branches",
323
+ level,
324
+ 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`,
325
+ detail: { count },
326
+ }
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Dimension 6: detached_head
331
+ // ---------------------------------------------------------------------------
332
+ async function checkDetachedHead(cwd: string): Promise<Issue | undefined> {
333
+ const symRef = await gitText(cwd, ["symbolic-ref", "--quiet", "HEAD"], { allowedExit: [1] })
334
+ if (symRef !== undefined) return undefined
335
+
336
+ const headCommit = (await gitText(cwd, ["rev-parse", "HEAD"]))?.trim() ?? ""
337
+ if (!headCommit) return undefined
338
+
339
+ return {
340
+ dimension: "detached_head",
341
+ level: "critical",
342
+ message: "Detached HEAD — commits will be lost unless you create a branch",
343
+ detail: { headCommit },
344
+ }
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Dimension 7: gc_needed
349
+ // Thresholds derive from the repo's effective gc.auto (git's documented
350
+ // default is 6700): git auto-gcs once loose objects exceed gc.auto, so warn at
351
+ // a quarter of that. Uses both git count-objects and filesystem count (max of
352
+ // the two); git count-objects only counts valid objects, while the filesystem
353
+ // fallback catches hand-crafted test fixture objects.
354
+ // ---------------------------------------------------------------------------
355
+ async function checkGcNeeded(cwd: string, gitDir: string, deadline: number): Promise<Issue | undefined> {
356
+ const [output, gcAutoText] = await Promise.all([
357
+ gitText(cwd, ["count-objects", "-v"]).then((x) => x ?? ""),
358
+ gitText(cwd, ["config", "--get", "gc.auto"], { allowedExit: [1] }),
359
+ ])
360
+
361
+ const countMatch = output.match(/count:\s*(\d+)/)
362
+ const sizeMatch = output.match(/size:\s*(\d+)/)
363
+
364
+ const gitCount = countMatch ? parseInt(countMatch[1]) : 0
365
+ const diskCount = Date.now() >= deadline ? 0 : countLooseObjectsOnDisk(gitDir)
366
+ const looseObjects = Math.max(gitCount, diskCount)
367
+ const size = sizeMatch ? parseInt(sizeMatch[1]) : 0
368
+
369
+ // gc.auto <= 0 disables auto-gc, so treat it as absent rather than as a threshold.
370
+ const configuredGcAuto = parseInt(gcAutoText?.trim() ?? "")
371
+ const gcAuto = Number.isFinite(configuredGcAuto) && configuredGcAuto > 0 ? configuredGcAuto : DEFAULT_GC_AUTO
372
+
373
+ if (looseObjects < Math.floor(gcAuto / 4)) return undefined
374
+
375
+ const level: Issue["level"] = looseObjects >= gcAuto ? "critical" : "warn"
376
+
377
+ return {
378
+ dimension: "gc_needed",
379
+ level,
380
+ message: `Git gc recommended — ${looseObjects} loose objects (gc.auto ${gcAuto}). Run git gc to reclaim space`,
381
+ detail: { looseObjects, size },
382
+ }
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Dimension 8: unpushed
387
+ // ---------------------------------------------------------------------------
388
+ async function checkUnpushed(cwd: string): Promise<Issue | undefined> {
389
+ // With no remote-tracking refs `--not --remotes` subtracts nothing and the
390
+ // count degenerates to the entire local history, so only report when the
391
+ // repo actually tracks a remote.
392
+ const remoteRefs = await gitText(cwd, ["for-each-ref", "--count=1", "--format=%(refname)", "refs/remotes/"])
393
+ if (!remoteRefs?.trim()) return undefined
394
+ if (!(await gitText(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], { allowedExit: [1] }))) return undefined
395
+
396
+ const output = await gitText(cwd, ["rev-list", "--count", "HEAD", "--not", "--remotes"])
397
+
398
+ const count = parseInt(output?.trim() ?? "")
399
+ if (!Number.isFinite(count) || count <= 0) return undefined
400
+
401
+ return {
402
+ dimension: "unpushed",
403
+ level: "warn",
404
+ message: `${count} commit(s) not on any remote — push to preserve this work`,
405
+ detail: { unpushed: count },
406
+ }
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Public API
411
+ // ---------------------------------------------------------------------------
412
+
413
+ async function scan(cwd: string): Promise<{ dir: string; issues: Issue[] }> {
414
+ const fallbackDir = normalizeDir(cwd)
415
+ const deadline = Date.now() + SCAN_TIMEOUT_MS
416
+ const repo = await resolveRepo(fallbackDir)
417
+ if (!repo) return { dir: fallbackDir, issues: [] }
418
+
419
+ const remaining = remainingMs(deadline)
420
+ if (remaining === 0) throw new Error("Git health repository probe timed out")
421
+
422
+ const checks: Promise<Issue | Issue[] | undefined>[] = [
423
+ checkDiff(repo.root),
424
+ checkUntracked(repo.root),
425
+ checkLargeFiles(repo.root, deadline),
426
+ checkExtraBranches(repo.root),
427
+ checkDetachedHead(repo.root),
428
+ checkGcNeeded(repo.root, repo.gitDir, deadline),
429
+ checkUnpushed(repo.root),
430
+ ]
431
+ const results = await withDeadline(Promise.all(checks), remaining)
432
+
433
+ const issues = results.flat().filter((i): i is Issue => i !== undefined)
434
+ return { dir: repo.root, issues }
435
+ }
436
+
437
+ /**
438
+ * Live repository probe shared with the env prompt block. Deliberately
439
+ * bypasses the scan cache: callers (per-turn env assembly) need the current
440
+ * answer, and a stale repo probe is exactly the bug this replaces.
441
+ */
442
+ export async function isGitRepo(cwd?: string): Promise<boolean> {
443
+ const repo = await resolveRepo(normalizeDir(cwd ?? process.cwd())).catch(() => undefined)
444
+ return repo !== undefined
445
+ }
446
+
447
+ export function refresh(cwd?: string): Promise<Issue[]> {
448
+ const instanceState = runtimeState()
449
+
450
+ const inputDir = normalizeDir(cwd ?? process.cwd())
451
+ const key = cacheKey(inputDir)
452
+ const existing = instanceState._refreshing.get(key) ?? instanceState._refreshing.get(inputDir)
453
+ if (existing) return existing
454
+
455
+ const generation = instanceState._generation
456
+ const revisions = new Map(instanceState._revisions)
457
+ const unchanged = (dir: string) => (revisions.get(dir) ?? 0) === (instanceState._revisions.get(dir) ?? 0)
458
+ let promise!: Promise<Issue[]>
459
+ promise = scan(inputDir)
460
+ .then((result) => {
461
+ const instanceState = runtimeState()
462
+
463
+ if (generation === instanceState._generation) instanceState._aliases.set(inputDir, result.dir)
464
+ if (generation === instanceState._generation && unchanged(key) && unchanged(result.dir)) {
465
+ instanceState._cache.set(result.dir, { issues: result.issues, ts: Date.now() })
466
+ instanceState._lastDir = result.dir
467
+ instanceState._dirty.delete(key)
468
+ instanceState._dirty.delete(result.dir)
469
+ instanceState._dirty.delete(inputDir)
470
+ instanceState._failed.delete(key)
471
+ instanceState._failed.delete(result.dir)
472
+ }
473
+ return result.issues
474
+ })
475
+ .catch(() => {
476
+ if (generation === instanceState._generation && unchanged(key)) {
477
+ instanceState._failed.add(key)
478
+ instanceState._dirty.delete(key)
479
+ }
480
+ return instanceState._cache.get(key)?.issues ?? []
481
+ })
482
+ .finally(() => {
483
+ const instanceState = runtimeState()
484
+
485
+ if (instanceState._refreshing.get(key) === promise) instanceState._refreshing.delete(key)
486
+ if (instanceState._refreshing.get(inputDir) === promise) instanceState._refreshing.delete(inputDir)
487
+ })
488
+ .then((issues) =>
489
+ generation === instanceState._generation && (!unchanged(key) || !unchanged(cacheKey(inputDir)))
490
+ ? refresh(inputDir)
491
+ : issues,
492
+ )
493
+
494
+ instanceState._refreshing.set(key, promise)
495
+ if (key !== inputDir) instanceState._refreshing.set(inputDir, promise)
496
+ return promise
497
+ }
498
+
499
+ export async function check(cwd?: string): Promise<Issue[]> {
500
+ const instanceState = runtimeState()
501
+
502
+ const dir = normalizeDir(cwd ?? process.cwd())
503
+ const key = cacheKey(dir)
504
+ const cached = instanceState._cache.get(key)
505
+ if (
506
+ cached &&
507
+ !instanceState._dirty.has(key) &&
508
+ !instanceState._failed.has(key) &&
509
+ Date.now() - cached.ts < CACHE_TTL_MS
510
+ ) {
511
+ instanceState._lastDir = key
512
+ return cached.issues
513
+ }
514
+ return refresh(dir)
515
+ }
516
+
517
+ export async function inject(cwd?: string): Promise<string | undefined> {
518
+ return render(await check(cwd))
519
+ }
520
+
521
+ export function injectCached(cwd?: string): string | undefined {
522
+ const observation = observeCached(cwd)
523
+ return observation.state === "present" ? observation.text : undefined
524
+ }
525
+
526
+ export function observeCached(
527
+ cwd?: string,
528
+ ): { state: "present"; text: string } | { state: "cleared" | "unavailable" | "pending" } {
529
+ const instanceState = runtimeState()
530
+
531
+ const dir = normalizeDir(cwd ?? process.cwd())
532
+ const key = cacheKey(dir)
533
+ const cached = instanceState._cache.get(key)
534
+ if (
535
+ !cached ||
536
+ instanceState._dirty.has(key) ||
537
+ instanceState._failed.has(key) ||
538
+ Date.now() - cached.ts >= CACHE_TTL_MS
539
+ ) {
540
+ refresh(dir).catch(() => {})
541
+ }
542
+ if (instanceState._failed.has(key)) return { state: "unavailable" }
543
+ if (!cached || instanceState._dirty.has(key) || Date.now() - cached.ts >= CACHE_TTL_MS) return { state: "pending" }
544
+ instanceState._lastDir = key
545
+ const text = render(cached.issues)
546
+ return text ? { state: "present", text } : { state: "cleared" }
547
+ }
548
+
549
+ export function lastReport(): Issue[] | undefined {
550
+ const instanceState = runtimeState()
551
+
552
+ if (!instanceState._lastDir) return undefined
553
+ const cached = instanceState._cache.get(instanceState._lastDir)
554
+ return cached?.issues.length ? cached.issues : undefined
555
+ }
556
+
557
+ export function invalidate(cwd?: string): void {
558
+ const instanceState = runtimeState()
559
+
560
+ if (cwd === undefined) {
561
+ instanceState._generation++
562
+ instanceState._refreshing.clear()
563
+ instanceState._cache.clear()
564
+ instanceState._aliases.clear()
565
+ instanceState._lastDir = undefined
566
+ instanceState._revisions.clear()
567
+ instanceState._dirty.clear()
568
+ instanceState._failed.clear()
569
+ return
570
+ }
571
+
572
+ const dir = normalizeDir(cwd)
573
+ const key = cacheKey(dir)
574
+ instanceState._revisions.set(key, (instanceState._revisions.get(key) ?? 0) + 1)
575
+ instanceState._dirty.add(key)
576
+ }
577
+ }
@@ -0,0 +1,80 @@
1
+ import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
2
+ import { BusEvent } from "@ericsanchezok/synergy-harness/bus/bus-event"
3
+ import { Bus } from "@ericsanchezok/synergy-harness/bus"
4
+ import { spawn } from "bun"
5
+ import z from "zod"
6
+ import { NamedError } from "@ericsanchezok/synergy-util/error"
7
+ import { Log } from "@ericsanchezok/synergy-harness/util/log"
8
+
9
+ const SUPPORTED_IDES = [
10
+ { name: "Windsurf" as const, cmd: "windsurf" },
11
+ { name: "Visual Studio Code - Insiders" as const, cmd: "code-insiders" },
12
+ { name: "Visual Studio Code" as const, cmd: "code" },
13
+ { name: "Cursor" as const, cmd: "cursor" },
14
+ { name: "VSCodium" as const, cmd: "codium" },
15
+ ]
16
+
17
+ export namespace Ide {
18
+ const log = Log.create({ service: "ide" })
19
+
20
+ export const Event = {
21
+ Installed: BusEvent.define(
22
+ "ide.installed",
23
+ z.object({
24
+ ide: z.string(),
25
+ }),
26
+ ),
27
+ }
28
+
29
+ export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", z.object({}))
30
+
31
+ export const InstallFailedError = NamedError.create(
32
+ "InstallFailedError",
33
+ z.object({
34
+ stderr: z.string(),
35
+ }),
36
+ )
37
+
38
+ export function ide() {
39
+ if (RuntimeContext.current().host.env["TERM_PROGRAM"] === "vscode") {
40
+ const v = RuntimeContext.current().host.env["GIT_ASKPASS"]
41
+ for (const ide of SUPPORTED_IDES) {
42
+ if (v?.includes(ide.name)) return ide.name
43
+ }
44
+ }
45
+ return "unknown"
46
+ }
47
+
48
+ export function alreadyInstalled() {
49
+ return (
50
+ RuntimeContext.current().host.env["SYNERGY_CALLER"] === "vscode" ||
51
+ RuntimeContext.current().host.env["SYNERGY_CALLER"] === "vscode-insiders"
52
+ )
53
+ }
54
+
55
+ export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) {
56
+ const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd
57
+ if (!cmd) throw new Error(`Unknown IDE: ${ide}`)
58
+
59
+ const p = spawn([cmd, "--install-extension", "ericsanchezok.synergy"], {
60
+ stdout: "pipe",
61
+ stderr: "pipe",
62
+ })
63
+ await p.exited
64
+ const stdout = await new Response(p.stdout).text()
65
+ const stderr = await new Response(p.stderr).text()
66
+
67
+ log.info("installed", {
68
+ ide,
69
+ stdout,
70
+ stderr,
71
+ })
72
+
73
+ if (p.exitCode !== 0) {
74
+ throw new InstallFailedError({ stderr })
75
+ }
76
+ if (stdout.includes("already installed")) {
77
+ throw new AlreadyInstalledError({})
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,24 @@
1
+ import { MigrationRegistry } from "@ericsanchezok/synergy-harness/migration/registry"
2
+ import { Scope } from "@ericsanchezok/synergy-harness/scope"
3
+ import { ProjectDirectories } from "./directories"
4
+
5
+ export const projectMigrations = [
6
+ {
7
+ id: "20260929-project-directories",
8
+ scope: "global" as const,
9
+ execution: "startup" as const,
10
+ description: "Separate project folders from historical Worktrees without changing Scope identity",
11
+ async up(progress: (current: number, total: number) => void) {
12
+ const projects = await Scope.list()
13
+ progress(0, projects.length)
14
+ for (const [index, project] of projects.entries()) {
15
+ await ProjectDirectories.migrateProject(project)
16
+ progress(index + 1, projects.length)
17
+ }
18
+ },
19
+ },
20
+ ]
21
+
22
+ export function registerProjectMigrations() {
23
+ MigrationRegistry.register("workbench-projects", projectMigrations)
24
+ }