@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,60 @@
1
+ Control a target session remotely — inspect its state and perform actions as if you were the user in that session.
2
+
3
+ The `target` parameter identifies which session to control:
4
+ - A session ID (e.g. "ses_xxx") to target a specific session
5
+
6
+ ## Actions
7
+
8
+ Abort and pending-input replies work while the target session is running. Compaction and worktree actions require an idle target.
9
+
10
+ ### `create` — Create a standalone primary session
11
+ Creates a normal top-level session in the sidebar. `target` is not used. Optional parameters:
12
+ - `title`: session title
13
+ - `scopeID` or `directory`: where to create it; defaults to the current scope. If you need a valid `scopeID`, call `scope_list` first. If you already know the absolute project path, pass `directory` instead.
14
+ - `workspace`: `{ "mode": "current" }`, `{ "mode": "existing", "target": "..." }`, or `{ "mode": "create", "name": "...", "baseRef": "current|fresh" }`
15
+ - `agent`: per-session agent override for new user messages
16
+ - `model`: per-session model override, `{ "providerID": "...", "modelID": "..." }`
17
+ - `mode`: `interactive` or `unattended`
18
+ - `controlProfile`: `guarded`, `autonomous`, or `full_access`
19
+ - `initialMessage`: enqueue a first user message and wake the new session asynchronously
20
+
21
+ ### `status` — Query session state
22
+ Returns the session's current runtime status, pending questions, and pending permission requests. Use this before other actions to understand what the session needs. For example, before answering a question, call `status` first to see what questions are pending and what options are available.
23
+
24
+ ### `compact` — Trigger compaction
25
+ Forces the target session to compact its conversation history. Useful when a child session is approaching context limits.
26
+
27
+ ### `abort` — Abort execution
28
+ Stops any ongoing processing in the target session. Use when a delegated agent is stuck or doing the wrong thing. The target can be the calling session itself; this also interrupts the calling turn, so do not rely on a successful tool result or a follow-up response from it.
29
+
30
+ ### `worktree_enter` — Create, enter, or switch the target session's worktree
31
+ Uses `worktreeTarget` as an existing worktree name/ID/branch/path when it matches; otherwise creates a new worktree using `worktreeTarget` as the name. Omit `worktreeTarget` to create a uniquely named worktree. Use `baseRef`, `baseRevision`, and `force` as needed.
32
+
33
+ ### `worktree_leave` — Return the target session to the main checkout
34
+ Use `cleanup: "keep"` (default) to keep the worktree, or `cleanup: "remove_if_clean"` to permanently delete it after leaving when it has no uncommitted changes. Permanent deletion drops the registry entry and cannot be undone; use `worktree_archive` when the checkout may be needed again.
35
+
36
+ ### `set_agent` — Set the target session's agent override
37
+ Requires `agent`. Future user messages without an explicit agent use this override.
38
+
39
+ ### `set_model` — Set the target session's model override
40
+ Requires `model: { "providerID": "...", "modelID": "..." }`. Future user messages without an explicit model use this override.
41
+
42
+ ### `set_mode` — Set interactive vs unattended behavior
43
+ Requires `mode: "interactive"` or `mode: "unattended"`. `modeSource` defaults to `session_control`.
44
+
45
+ ### `set_control_profile` — Set the target session's permission control profile
46
+ Requires `controlProfile`. This changes the target session's guarded/autonomous/full-access profile.
47
+
48
+ ### `question_reply` — Answer a pending question
49
+ Provide answers to a question that the target session's agent has asked. The `answers` format is an array of arrays of selected labels — one array per question, each containing the label(s) the user selected. For single-select questions, pass one label; for multiple-select, pass multiple. Use `status` first to discover pending question IDs and available options.
50
+
51
+ ### `question_reject` — Dismiss a pending question
52
+ Reject a question the target session's agent has asked. The agent will receive an error and can adjust its approach.
53
+
54
+ ### `permission_reply` — Approve or deny a permission request
55
+ Respond to a pending permission request in the target session. Use `reply: "once"` to approve this specific request, or `reply: "reject"` to deny it. When rejecting, you can optionally provide a `message` to guide the agent. Use `status` first to discover pending permission IDs.
56
+
57
+ ## Typical workflow
58
+ 1. To create a session in another project, call `scope_list` to discover available scope IDs, then call `session_control` with `action: "create"` and the chosen `scopeID`. For an existing session, call `action: "status"` first.
59
+ 2. Use `set_agent`, `set_model`, `set_mode`, or worktree actions to adjust the target session.
60
+ 3. Use `question_reply` or `permission_reply` when `status` reports pending user input.
@@ -0,0 +1,92 @@
1
+ import { z } from "zod"
2
+ import { Tool } from "@ericsanchezok/synergy-harness/tool/tool"
3
+ import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree"
4
+ import { Session } from "@ericsanchezok/synergy-harness/session"
5
+ import { PermissionNext } from "@ericsanchezok/synergy-harness/permission/next"
6
+
7
+ const parameters = z.object({
8
+ target: z
9
+ .string()
10
+ .min(1)
11
+ .optional()
12
+ .describe(
13
+ "Managed worktree name, ID, branch, or path. Defaults to the calling session's current worktree; use worktree_list to find another idle worktree.",
14
+ ),
15
+ reason: z.string().optional().describe("Optional short explanation of why the worktree is finished."),
16
+ })
17
+
18
+ interface WorktreeArchiveMetadata {
19
+ action: "archived" | "noop" | "denied"
20
+ worktree?: { id: string; name: string; path: string; branch?: string }
21
+ restored?: { type: string; path: string }
22
+ cleanup?: Worktree.ArchiveResult["cleanup"]
23
+ message: string
24
+ }
25
+
26
+ export const WorktreeArchiveTool = Tool.define<typeof parameters, WorktreeArchiveMetadata>("worktree_archive", {
27
+ description:
28
+ "Archive a finished Synergy-managed git worktree. Use when isolated work is complete, including from the running owner turn, or to reclaim another idle worktree. Optional target accepts a name, ID, branch or path; omitted target selects the caller's current worktree. The caller returns to its original checkout without stopping or archiving the conversation. Other sessions retain their Workspace identity and restore the checkout when execution resumes. Dirty or ignored files, local-only or unverifiable commits, locks and active users keep the checkout; there is no force option. Reports archived/noop/denied, restored checkout and whether cleanup actually happened with its reason. Keeps the branch; retained checkouts can be re-entered with worktree_enter. Released clean checkouts retain a pinned Git recovery receipt; worktree_enter and session execution restore them on demand. Use worktree_leave when you only need to return to the original checkout and keep the worktree.",
29
+ parameters,
30
+ async execute(params, ctx) {
31
+ ctx.abort.throwIfAborted()
32
+ const session = await Session.get(ctx.sessionID)
33
+ const target = params.target ?? (session.workspace?.type === "git_worktree" ? session.workspace.path : undefined)
34
+ if (!target) {
35
+ return {
36
+ title: "worktree_archive",
37
+ metadata: {
38
+ action: "noop",
39
+ message: "No current worktree to archive. Use worktree_list and supply a managed target.",
40
+ },
41
+ output: "No current worktree to archive. Use worktree_list and supply a managed target.",
42
+ }
43
+ }
44
+ const tree = await Worktree.resolve(target)
45
+ if (!tree.managed || tree.isMain || tree.stale) {
46
+ throw new Worktree.CreateFailedError({
47
+ message: "Only an available Synergy-managed worktree can be archived. Use worktree_list to select one.",
48
+ })
49
+ }
50
+ try {
51
+ await ctx.ask({
52
+ permission: "worktree_archive",
53
+ patterns: [tree.path],
54
+ metadata: { target: tree.id, branch: tree.branch, reason: params.reason },
55
+ })
56
+ } catch (error) {
57
+ if (!(error instanceof PermissionNext.RejectedError || error instanceof PermissionNext.DeniedError)) throw error
58
+ const message = "Worktree archive denied. No workspace binding or files were changed."
59
+ return { title: "worktree_archive", metadata: { action: "denied", message }, output: message }
60
+ }
61
+ ctx.abort.throwIfAborted()
62
+ const result = await Worktree.archive({ sessionID: ctx.sessionID, target: tree.id, signal: ctx.abort })
63
+ const lines = [`Archived worktree "${tree.name}". Conversation remains available and this turn can continue.`]
64
+ if (result.restored) lines.push(`Restored: ${result.restored.path}`)
65
+ if (result.cleanup.performed)
66
+ lines.push(
67
+ `Checkout released; branch "${tree.branch}" and recovery commit retained. Referencing sessions and worktree_enter restore it on demand.`,
68
+ )
69
+ else if (result.cleanup.state === "unknown")
70
+ lines.push(
71
+ `Checkout removal could not be confirmed.${result.cleanup.error ? ` ${result.cleanup.error}` : ""} Use worktree_list to inspect the target; do not assume it can be re-entered.`,
72
+ )
73
+ else
74
+ lines.push(
75
+ `Checkout kept at ${tree.path}: ${result.cleanup.reason ?? "removal_failed"}.${result.cleanup.error ? ` ${result.cleanup.error}` : ""} Re-enter it with worktree_enter or retry archive after resolving the blocker.`,
76
+ )
77
+ if (result.cleanup.performed && result.cleanup.error)
78
+ lines.push(`Checkout release was verified after an error: ${result.cleanup.error}`)
79
+ const message = lines.join("\n")
80
+ return {
81
+ title: "worktree_archive",
82
+ output: message,
83
+ metadata: {
84
+ action: "archived",
85
+ worktree: { id: tree.id, name: tree.name, path: tree.path, branch: tree.branch },
86
+ restored: result.restored,
87
+ cleanup: result.cleanup,
88
+ message,
89
+ },
90
+ }
91
+ },
92
+ })
@@ -0,0 +1,227 @@
1
+ import z from "zod"
2
+ import { Tool } from "@ericsanchezok/synergy-harness/tool/tool"
3
+ import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree"
4
+ import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
5
+ import { PermissionNext } from "@ericsanchezok/synergy-harness/permission/next"
6
+ import { EnforcementError } from "@ericsanchezok/synergy-harness/enforcement/errors"
7
+
8
+ const parameters = z.object({
9
+ target: z.string().min(1).optional().describe("Target worktree name, ID, branch, or path to enter"),
10
+ baseRef: z
11
+ .enum(["current", "fresh"])
12
+ .optional()
13
+ .default("current")
14
+ .describe("Base reference for new worktree: current HEAD or fresh from origin"),
15
+ baseRevision: z
16
+ .string()
17
+ .min(1)
18
+ .optional()
19
+ .describe("Explicit git revision/ref/commit to create the worktree from. Overrides baseRef when provided."),
20
+ reason: z.string().optional().describe("Optional short note about why the worktree is being entered"),
21
+ force: z
22
+ .boolean()
23
+ .optional()
24
+ .default(false)
25
+ .describe("Force enter even if the current or target worktree has uncommitted changes"),
26
+ })
27
+
28
+ interface WorktreeEnterMetadata {
29
+ action: "denied" | "entered"
30
+ reason?: string
31
+ message: string
32
+ created?: boolean
33
+ worktree?: Worktree.Info
34
+ workspace?: Record<string, unknown>
35
+ }
36
+
37
+ function buildWorkspaceMetadata(info: Worktree.Info): Record<string, unknown> {
38
+ return {
39
+ type: "git_worktree" as const,
40
+ path: info.path,
41
+ scopeID: info.scopeID,
42
+ worktreeID: info.id,
43
+ name: info.name,
44
+ branch: info.branch,
45
+ baseRef: info.baseRef,
46
+ baseRevision: info.baseRevision,
47
+ resolvedBaseCommit: info.resolvedBaseCommit,
48
+ }
49
+ }
50
+
51
+ function denialMetadata(
52
+ action: string,
53
+ reason: string,
54
+ message: string,
55
+ ): {
56
+ title: string
57
+ output: string
58
+ metadata: WorktreeEnterMetadata
59
+ } {
60
+ return {
61
+ title: "worktree_enter",
62
+ output: message,
63
+ metadata: { action: action as WorktreeEnterMetadata["action"], reason, message },
64
+ }
65
+ }
66
+
67
+ export const WorktreeEnterTool = Tool.define<typeof parameters, WorktreeEnterMetadata>("worktree_enter", {
68
+ description:
69
+ "Create or enter a git worktree for the current session. " +
70
+ "When already in a worktree, calling with a different 'target' switches to that worktree " +
71
+ "(leaving the current one). Calling with no 'target' switches to a new worktree. " +
72
+ "When 'target' matches an existing worktree (by name, ID, branch, or path), enter that worktree. " +
73
+ "When no match is found, create a new worktree using 'target' as the name. " +
74
+ "Omit 'target' to create a new worktree with an auto-generated unique name.",
75
+ parameters,
76
+ async execute(params, ctx) {
77
+ const currentWorkspace = ScopeContext.current.workspace
78
+ if (currentWorkspace?.type === "git_worktree") {
79
+ const cw = currentWorkspace as Record<string, unknown>
80
+ const currentName = (cw.name ?? cw.worktreeID ?? cw.path ?? "unknown") as string
81
+ const currentPath = cw.path as string
82
+ const currentID = cw.worktreeID as string | undefined
83
+ const currentBranch = cw.branch as string | undefined
84
+
85
+ // Noop: target matches current worktree
86
+ if (params.target) {
87
+ const t = params.target
88
+ if (t === currentID || t === currentName || t === currentPath || (currentBranch && t === currentBranch)) {
89
+ return {
90
+ title: "worktree_enter",
91
+ output: `Already in worktree "${currentName}" at ${currentPath}.`,
92
+ metadata: {
93
+ action: "entered",
94
+ reason: "already_in_this_worktree",
95
+ created: false,
96
+ message: `Session is already in worktree "${currentName}".`,
97
+ worktree: undefined,
98
+ workspace: currentWorkspace,
99
+ },
100
+ }
101
+ }
102
+ }
103
+
104
+ // Switching: refuse if current worktree is dirty and force not set
105
+ const st = await Worktree.status(ctx.sessionID)
106
+ if (st.dirty !== false && !params.force) {
107
+ return denialMetadata(
108
+ "denied",
109
+ "current_dirty",
110
+ `Current worktree "${currentName}" has uncommitted changes. Use 'force' to switch without saving.`,
111
+ )
112
+ }
113
+
114
+ await Worktree.leave(ctx.sessionID)
115
+ }
116
+
117
+ try {
118
+ await ctx.ask({
119
+ permission: "worktree_enter",
120
+ patterns: [params.target ?? "*"],
121
+ metadata: {
122
+ target: params.target,
123
+ baseRef: params.baseRef,
124
+ baseRevision: params.baseRevision,
125
+ reason: params.reason,
126
+ },
127
+ })
128
+ } catch (error) {
129
+ if (
130
+ error instanceof PermissionNext.RejectedError ||
131
+ error instanceof PermissionNext.CorrectedError ||
132
+ error instanceof PermissionNext.DeniedError ||
133
+ error instanceof EnforcementError.PolicyDenied
134
+ ) {
135
+ return denialMetadata(
136
+ "denied",
137
+ "user_denied",
138
+ error instanceof PermissionNext.CorrectedError ? error.message : "User declined worktree for this task.",
139
+ )
140
+ }
141
+ throw error
142
+ }
143
+
144
+ if (params.target) {
145
+ try {
146
+ const worktrees = await Worktree.list()
147
+ const match = worktrees.find(
148
+ (wt) =>
149
+ wt.id === params.target ||
150
+ wt.name === params.target ||
151
+ wt.branch === params.target ||
152
+ wt.path === params.target,
153
+ )
154
+ if (match) {
155
+ const entered = await Worktree.enter({
156
+ sessionID: ctx.sessionID,
157
+ target: params.target,
158
+ force: params.force,
159
+ })
160
+ return {
161
+ title: "worktree_enter",
162
+ output: `Entered existing worktree "${entered.name}" at ${entered.path}.`,
163
+ metadata: {
164
+ action: "entered",
165
+ created: false,
166
+ message: `Entered existing worktree "${entered.name}".`,
167
+ worktree: entered,
168
+ workspace: buildWorkspaceMetadata(entered),
169
+ },
170
+ }
171
+ }
172
+ } catch (error) {
173
+ if (error instanceof Worktree.NotGitError) {
174
+ return denialMetadata(
175
+ "denied",
176
+ "not_git_scope",
177
+ "Current scope is not a Git repository; git worktree is unavailable.",
178
+ )
179
+ }
180
+ throw error
181
+ }
182
+ }
183
+
184
+ try {
185
+ const created = await Worktree.create({
186
+ name: params.target,
187
+ sessionID: ctx.sessionID,
188
+ baseRef: params.baseRef,
189
+ baseRevision: params.baseRevision,
190
+ bind: true,
191
+ })
192
+ const baseLabel = params.baseRevision ?? `${params.baseRef} base`
193
+ return {
194
+ title: "worktree_enter",
195
+ output: `Created and entered worktree "${created.name}" at ${created.path}.`,
196
+ metadata: {
197
+ action: "entered",
198
+ created: true,
199
+ message: `Created new worktree "${created.name}" from ${baseLabel}.`,
200
+ worktree: created,
201
+ workspace: buildWorkspaceMetadata(created),
202
+ },
203
+ }
204
+ } catch (error) {
205
+ if (error instanceof Worktree.NotGitError) {
206
+ return denialMetadata(
207
+ "denied",
208
+ "not_git_scope",
209
+ "Current scope is not a Git repository; git worktree is unavailable.",
210
+ )
211
+ }
212
+ if (
213
+ error instanceof Worktree.NameGenerationFailedError ||
214
+ error instanceof Worktree.CreateFailedError ||
215
+ error instanceof Worktree.SetupConfigError ||
216
+ error instanceof Worktree.StartCommandFailedError
217
+ ) {
218
+ return denialMetadata(
219
+ "denied",
220
+ "setup_failed",
221
+ error instanceof Error ? error.message : "Worktree setup failed.",
222
+ )
223
+ }
224
+ throw error
225
+ }
226
+ },
227
+ })
@@ -0,0 +1,140 @@
1
+ import z from "zod"
2
+ import { Tool } from "@ericsanchezok/synergy-harness/tool/tool"
3
+ import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree"
4
+ import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
5
+ import { PermissionNext } from "@ericsanchezok/synergy-harness/permission/next"
6
+
7
+ const parameters = z.object({
8
+ cleanup: z
9
+ .enum(["keep", "remove_if_clean"])
10
+ .optional()
11
+ .default("keep")
12
+ .describe(
13
+ "Whether to destroy the worktree after leaving. 'keep' (default) leaves it in place. 'remove_if_clean' permanently deletes it when it has no uncommitted changes: the registry entry is removed, the branch is deleted when its content is already on the target branch, and worktree_enter cannot restore it. Use worktree_archive instead when the checkout may be needed again.",
14
+ ),
15
+ reason: z.string().optional().describe("Optional short note about why the session is leaving the worktree"),
16
+ })
17
+
18
+ interface WorktreeLeaveMetadata {
19
+ action: "left" | "noop" | "denied"
20
+ reason?: string
21
+ previous?: { type: string; path: string; name?: string }
22
+ restored?: { type: string; path: string }
23
+ cleanup?: { performed: boolean; skippedReason?: string; error?: string; cleanupDeferred?: boolean }
24
+ message?: string
25
+ }
26
+
27
+ export const WorktreeLeaveTool = Tool.define<typeof parameters, WorktreeLeaveMetadata>("worktree_leave", {
28
+ description:
29
+ "Leave the current git worktree and return to the main checkout. Unbinds the session from the worktree and " +
30
+ "restores the workspace to the main tree. The worktree stays on disk by default; the cleanup parameter can " +
31
+ "permanently delete it instead, and that deletion cannot be undone. Use worktree_archive when isolated work is " +
32
+ "finished and the checkout should stay restorable.",
33
+ parameters,
34
+ async execute(params, ctx) {
35
+ const workspace = ScopeContext.current.workspace
36
+
37
+ if (!workspace || workspace.type !== "git_worktree") {
38
+ return {
39
+ title: "worktree_leave",
40
+ output: "Already on the main checkout. No worktree to leave.",
41
+ metadata: {
42
+ action: "noop",
43
+ reason: "already_on_main",
44
+ message: "Already on the main checkout. No worktree to leave.",
45
+ },
46
+ }
47
+ }
48
+
49
+ const worktreeID: string | undefined = (workspace as any).worktreeID as string | undefined
50
+ const worktreePath = workspace.path
51
+ const worktreeName: string | undefined = (workspace as any).name as string | undefined
52
+
53
+ // Tri-state: `undefined` means the dirty probe could not answer, which is
54
+ // not the same as "clean" and must not be reported as "dirty".
55
+ let dirtyState: boolean | undefined
56
+ if (params.cleanup === "remove_if_clean") {
57
+ const st = await Worktree.status(ctx.sessionID)
58
+ dirtyState = st.dirty
59
+ }
60
+
61
+ try {
62
+ await ctx.ask({
63
+ permission: "worktree_leave",
64
+ patterns: [worktreePath],
65
+ metadata: {
66
+ previous: { type: workspace.type, path: worktreePath, name: worktreeName },
67
+ cleanup: params.cleanup,
68
+ },
69
+ })
70
+ } catch (error) {
71
+ if (error instanceof PermissionNext.RejectedError || error instanceof PermissionNext.DeniedError) {
72
+ return {
73
+ title: "worktree_leave",
74
+ output: "User declined leaving the worktree.",
75
+ metadata: {
76
+ action: "denied",
77
+ reason: "user_denied",
78
+ message: "User declined leaving the worktree.",
79
+ },
80
+ }
81
+ }
82
+ throw error
83
+ }
84
+
85
+ const previous = { type: workspace.type, path: worktreePath, name: worktreeName }
86
+
87
+ await Worktree.leave(ctx.sessionID)
88
+ const restored = { type: "main", path: ScopeContext.current.directory }
89
+
90
+ let cleanupResult: { performed: boolean; skippedReason?: string; error?: string; cleanupDeferred?: boolean } = {
91
+ performed: false,
92
+ }
93
+ if (params.cleanup === "remove_if_clean" && worktreeID) {
94
+ if (dirtyState === false) {
95
+ try {
96
+ // The turn issuing this call still holds the worktree's git lock and
97
+ // use token, so removal must be told that the caller is its own turn.
98
+ await Worktree.remove(
99
+ { sessionID: ctx.sessionID, target: worktreeID, force: false },
100
+ { insideCallerTurn: true },
101
+ )
102
+ cleanupResult = { performed: true }
103
+ } catch (error) {
104
+ // Leaving already succeeded, so a failed cleanup must not fail the
105
+ // turn: mark the worktree for the janitor and report the reason.
106
+ const message = error instanceof Error ? error.message : String(error)
107
+ await Worktree.markLifecycle(worktreeID, "gc_candidate").catch(() => undefined)
108
+ cleanupResult = { performed: false, error: message, cleanupDeferred: true }
109
+ }
110
+ } else if (dirtyState === true) {
111
+ cleanupResult = { performed: false, skippedReason: "dirty" }
112
+ } else {
113
+ cleanupResult = { performed: false, skippedReason: "unknown_dirty" }
114
+ }
115
+ }
116
+
117
+ const lines = [
118
+ `Left worktree "${worktreeName || worktreePath}" and returned to main checkout.`,
119
+ `Previous: ${worktreePath}`,
120
+ `Restored: ${restored.path}`,
121
+ ]
122
+ if (cleanupResult.performed) {
123
+ lines.push("Worktree removed (was clean).")
124
+ } else if (cleanupResult.skippedReason === "dirty") {
125
+ lines.push("Worktree kept (has uncommitted changes).")
126
+ }
127
+
128
+ return {
129
+ title: "worktree_leave",
130
+ output: lines.join("\n"),
131
+ metadata: {
132
+ action: "left",
133
+ previous,
134
+ restored,
135
+ cleanup: cleanupResult,
136
+ message: lines.join("\n"),
137
+ },
138
+ }
139
+ },
140
+ })
@@ -0,0 +1,68 @@
1
+ import z from "zod"
2
+ import { Tool } from "@ericsanchezok/synergy-harness/tool/tool"
3
+ import { Worktree } from "@ericsanchezok/synergy-local-runtime/workspace/worktree"
4
+ import { ScopeContext } from "@ericsanchezok/synergy-harness/scope/context"
5
+
6
+ const parameters = z.object({})
7
+
8
+ interface WorktreeListMetadata {
9
+ action: "listed"
10
+ active: {
11
+ id?: string
12
+ name?: string
13
+ path: string
14
+ branch?: string
15
+ } | null
16
+ worktrees: Array<Worktree.Info & { cleanupRecommendation: string }>
17
+ message: string
18
+ }
19
+
20
+ const description =
21
+ "List all git worktrees in the current repository. Returns each worktree's path, branch, state, and a cleanup recommendation for non-main managed worktrees. The currently active worktree is marked."
22
+
23
+ export const WorktreeListTool = Tool.define<typeof parameters, WorktreeListMetadata>("worktree_list", {
24
+ description,
25
+ parameters,
26
+ async execute(_params, _ctx) {
27
+ const worktrees = await Worktree.list()
28
+ const ws = ScopeContext.current.workspace
29
+
30
+ const activeWt = ws ? (worktrees.find((wt) => wt.path === ws.path) ?? null) : null
31
+
32
+ const enriched = worktrees.map((wt) => {
33
+ const recommendation = getCleanupRecommendation(wt, activeWt)
34
+ return { ...wt, cleanupRecommendation: recommendation }
35
+ })
36
+
37
+ const activeDisplay = activeWt
38
+ ? `${activeWt.name ?? activeWt.path}${activeWt.branch ? ` (${activeWt.branch})` : ""}`
39
+ : "none"
40
+
41
+ const message = `${worktrees.length} worktree${worktrees.length !== 1 ? "s" : ""} found. Active: ${activeDisplay}`
42
+
43
+ return {
44
+ title: "worktree_list",
45
+ output: message,
46
+ metadata: {
47
+ action: "listed",
48
+ active: activeWt
49
+ ? { id: activeWt.id, name: activeWt.name, path: activeWt.path, branch: activeWt.branch }
50
+ : null,
51
+ worktrees: enriched,
52
+ message,
53
+ },
54
+ }
55
+ },
56
+ })
57
+
58
+ function getCleanupRecommendation(wt: Worktree.Info, active: Worktree.Info | null): string {
59
+ if (active && wt.path === active.path) return "keep"
60
+ if (wt.isMain) return "keep"
61
+ if (!wt.managed) return "external_do_not_manage"
62
+ if (wt.stale) return "safe_to_remove"
63
+ // A lock Synergy did not write is never reclaimed, so surface it as a reason
64
+ // rather than letting the worktree look merely idle.
65
+ if (wt.locked !== undefined && !wt.locked.startsWith("synergy:v1:")) return "locked_outside_synergy"
66
+ if (wt.dirty) return "inspect_dirty"
67
+ return "keep"
68
+ }
@@ -0,0 +1,29 @@
1
+ import { RuntimeContext } from "@ericsanchezok/synergy-harness/lifecycle/context"
2
+ import { ToolRegistry } from "@ericsanchezok/synergy-harness/tool/registry"
3
+ import { SessionControlTool } from "./tools/session-control"
4
+ import { WorktreeArchiveTool } from "./tools/worktree-archive"
5
+ import { WorktreeEnterTool } from "./tools/worktree-enter"
6
+ import { WorktreeLeaveTool } from "./tools/worktree-leave"
7
+ import { WorktreeListTool } from "./tools/worktree-list"
8
+
9
+ /**
10
+ * Project domain tool registration. Loaded through src/registration.ts.
11
+ */
12
+ const runtimeState = RuntimeContext.state(() => ({
13
+ registered: false,
14
+ }))
15
+
16
+ export function registerProjectTools(): void {
17
+ const instanceState = runtimeState()
18
+
19
+ if (instanceState.registered) return
20
+ instanceState.registered = true
21
+
22
+ ToolRegistry.registerToolProvider("project", () => [
23
+ SessionControlTool,
24
+ WorktreeEnterTool,
25
+ WorktreeLeaveTool,
26
+ WorktreeArchiveTool,
27
+ WorktreeListTool,
28
+ ])
29
+ }