@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,76 @@
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
+ const parameters = z.object({
7
+ target: z
8
+ .string()
9
+ .min(1)
10
+ .optional()
11
+ .describe("Managed worktree name, ID, branch, or path. Defaults to the calling session's current worktree; use worktree_list to find another idle worktree."),
12
+ reason: z.string().optional().describe("Optional short explanation of why the worktree is finished."),
13
+ });
14
+ export const WorktreeArchiveTool = Tool.define("worktree_archive", {
15
+ description: "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.",
16
+ parameters,
17
+ async execute(params, ctx) {
18
+ ctx.abort.throwIfAborted();
19
+ const session = await Session.get(ctx.sessionID);
20
+ const target = params.target ?? (session.workspace?.type === "git_worktree" ? session.workspace.path : undefined);
21
+ if (!target) {
22
+ return {
23
+ title: "worktree_archive",
24
+ metadata: {
25
+ action: "noop",
26
+ message: "No current worktree to archive. Use worktree_list and supply a managed target.",
27
+ },
28
+ output: "No current worktree to archive. Use worktree_list and supply a managed target.",
29
+ };
30
+ }
31
+ const tree = await Worktree.resolve(target);
32
+ if (!tree.managed || tree.isMain || tree.stale) {
33
+ throw new Worktree.CreateFailedError({
34
+ message: "Only an available Synergy-managed worktree can be archived. Use worktree_list to select one.",
35
+ });
36
+ }
37
+ try {
38
+ await ctx.ask({
39
+ permission: "worktree_archive",
40
+ patterns: [tree.path],
41
+ metadata: { target: tree.id, branch: tree.branch, reason: params.reason },
42
+ });
43
+ }
44
+ catch (error) {
45
+ if (!(error instanceof PermissionNext.RejectedError || error instanceof PermissionNext.DeniedError))
46
+ throw error;
47
+ const message = "Worktree archive denied. No workspace binding or files were changed.";
48
+ return { title: "worktree_archive", metadata: { action: "denied", message }, output: message };
49
+ }
50
+ ctx.abort.throwIfAborted();
51
+ const result = await Worktree.archive({ sessionID: ctx.sessionID, target: tree.id, signal: ctx.abort });
52
+ const lines = [`Archived worktree "${tree.name}". Conversation remains available and this turn can continue.`];
53
+ if (result.restored)
54
+ lines.push(`Restored: ${result.restored.path}`);
55
+ if (result.cleanup.performed)
56
+ lines.push(`Checkout released; branch "${tree.branch}" and recovery commit retained. Referencing sessions and worktree_enter restore it on demand.`);
57
+ else if (result.cleanup.state === "unknown")
58
+ lines.push(`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.`);
59
+ else
60
+ lines.push(`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.`);
61
+ if (result.cleanup.performed && result.cleanup.error)
62
+ lines.push(`Checkout release was verified after an error: ${result.cleanup.error}`);
63
+ const message = lines.join("\n");
64
+ return {
65
+ title: "worktree_archive",
66
+ output: message,
67
+ metadata: {
68
+ action: "archived",
69
+ worktree: { id: tree.id, name: tree.name, path: tree.path, branch: tree.branch },
70
+ restored: result.restored,
71
+ cleanup: result.cleanup,
72
+ message,
73
+ },
74
+ };
75
+ },
76
+ });
@@ -0,0 +1,175 @@
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
+ const parameters = z.object({
8
+ target: z.string().min(1).optional().describe("Target worktree name, ID, branch, or path to enter"),
9
+ baseRef: z
10
+ .enum(["current", "fresh"])
11
+ .optional()
12
+ .default("current")
13
+ .describe("Base reference for new worktree: current HEAD or fresh from origin"),
14
+ baseRevision: z
15
+ .string()
16
+ .min(1)
17
+ .optional()
18
+ .describe("Explicit git revision/ref/commit to create the worktree from. Overrides baseRef when provided."),
19
+ reason: z.string().optional().describe("Optional short note about why the worktree is being entered"),
20
+ force: z
21
+ .boolean()
22
+ .optional()
23
+ .default(false)
24
+ .describe("Force enter even if the current or target worktree has uncommitted changes"),
25
+ });
26
+ function buildWorkspaceMetadata(info) {
27
+ return {
28
+ type: "git_worktree",
29
+ path: info.path,
30
+ scopeID: info.scopeID,
31
+ worktreeID: info.id,
32
+ name: info.name,
33
+ branch: info.branch,
34
+ baseRef: info.baseRef,
35
+ baseRevision: info.baseRevision,
36
+ resolvedBaseCommit: info.resolvedBaseCommit,
37
+ };
38
+ }
39
+ function denialMetadata(action, reason, message) {
40
+ return {
41
+ title: "worktree_enter",
42
+ output: message,
43
+ metadata: { action: action, reason, message },
44
+ };
45
+ }
46
+ export const WorktreeEnterTool = Tool.define("worktree_enter", {
47
+ description: "Create or enter a git worktree for the current session. " +
48
+ "When already in a worktree, calling with a different 'target' switches to that worktree " +
49
+ "(leaving the current one). Calling with no 'target' switches to a new worktree. " +
50
+ "When 'target' matches an existing worktree (by name, ID, branch, or path), enter that worktree. " +
51
+ "When no match is found, create a new worktree using 'target' as the name. " +
52
+ "Omit 'target' to create a new worktree with an auto-generated unique name.",
53
+ parameters,
54
+ async execute(params, ctx) {
55
+ const currentWorkspace = ScopeContext.current.workspace;
56
+ if (currentWorkspace?.type === "git_worktree") {
57
+ const cw = currentWorkspace;
58
+ const currentName = (cw.name ?? cw.worktreeID ?? cw.path ?? "unknown");
59
+ const currentPath = cw.path;
60
+ const currentID = cw.worktreeID;
61
+ const currentBranch = cw.branch;
62
+ // Noop: target matches current worktree
63
+ if (params.target) {
64
+ const t = params.target;
65
+ if (t === currentID || t === currentName || t === currentPath || (currentBranch && t === currentBranch)) {
66
+ return {
67
+ title: "worktree_enter",
68
+ output: `Already in worktree "${currentName}" at ${currentPath}.`,
69
+ metadata: {
70
+ action: "entered",
71
+ reason: "already_in_this_worktree",
72
+ created: false,
73
+ message: `Session is already in worktree "${currentName}".`,
74
+ worktree: undefined,
75
+ workspace: currentWorkspace,
76
+ },
77
+ };
78
+ }
79
+ }
80
+ // Switching: refuse if current worktree is dirty and force not set
81
+ const st = await Worktree.status(ctx.sessionID);
82
+ if (st.dirty !== false && !params.force) {
83
+ return denialMetadata("denied", "current_dirty", `Current worktree "${currentName}" has uncommitted changes. Use 'force' to switch without saving.`);
84
+ }
85
+ await Worktree.leave(ctx.sessionID);
86
+ }
87
+ try {
88
+ await ctx.ask({
89
+ permission: "worktree_enter",
90
+ patterns: [params.target ?? "*"],
91
+ metadata: {
92
+ target: params.target,
93
+ baseRef: params.baseRef,
94
+ baseRevision: params.baseRevision,
95
+ reason: params.reason,
96
+ },
97
+ });
98
+ }
99
+ catch (error) {
100
+ if (error instanceof PermissionNext.RejectedError ||
101
+ error instanceof PermissionNext.CorrectedError ||
102
+ error instanceof PermissionNext.DeniedError ||
103
+ error instanceof EnforcementError.PolicyDenied) {
104
+ return denialMetadata("denied", "user_denied", error instanceof PermissionNext.CorrectedError ? error.message : "User declined worktree for this task.");
105
+ }
106
+ throw error;
107
+ }
108
+ if (params.target) {
109
+ try {
110
+ const worktrees = await Worktree.list();
111
+ const match = worktrees.find((wt) => wt.id === params.target ||
112
+ wt.name === params.target ||
113
+ wt.branch === params.target ||
114
+ wt.path === params.target);
115
+ if (match) {
116
+ const entered = await Worktree.enter({
117
+ sessionID: ctx.sessionID,
118
+ target: params.target,
119
+ force: params.force,
120
+ });
121
+ return {
122
+ title: "worktree_enter",
123
+ output: `Entered existing worktree "${entered.name}" at ${entered.path}.`,
124
+ metadata: {
125
+ action: "entered",
126
+ created: false,
127
+ message: `Entered existing worktree "${entered.name}".`,
128
+ worktree: entered,
129
+ workspace: buildWorkspaceMetadata(entered),
130
+ },
131
+ };
132
+ }
133
+ }
134
+ catch (error) {
135
+ if (error instanceof Worktree.NotGitError) {
136
+ return denialMetadata("denied", "not_git_scope", "Current scope is not a Git repository; git worktree is unavailable.");
137
+ }
138
+ throw error;
139
+ }
140
+ }
141
+ try {
142
+ const created = await Worktree.create({
143
+ name: params.target,
144
+ sessionID: ctx.sessionID,
145
+ baseRef: params.baseRef,
146
+ baseRevision: params.baseRevision,
147
+ bind: true,
148
+ });
149
+ const baseLabel = params.baseRevision ?? `${params.baseRef} base`;
150
+ return {
151
+ title: "worktree_enter",
152
+ output: `Created and entered worktree "${created.name}" at ${created.path}.`,
153
+ metadata: {
154
+ action: "entered",
155
+ created: true,
156
+ message: `Created new worktree "${created.name}" from ${baseLabel}.`,
157
+ worktree: created,
158
+ workspace: buildWorkspaceMetadata(created),
159
+ },
160
+ };
161
+ }
162
+ catch (error) {
163
+ if (error instanceof Worktree.NotGitError) {
164
+ return denialMetadata("denied", "not_git_scope", "Current scope is not a Git repository; git worktree is unavailable.");
165
+ }
166
+ if (error instanceof Worktree.NameGenerationFailedError ||
167
+ error instanceof Worktree.CreateFailedError ||
168
+ error instanceof Worktree.SetupConfigError ||
169
+ error instanceof Worktree.StartCommandFailedError) {
170
+ return denialMetadata("denied", "setup_failed", error instanceof Error ? error.message : "Worktree setup failed.");
171
+ }
172
+ throw error;
173
+ }
174
+ },
175
+ });
@@ -0,0 +1,119 @@
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
+ const parameters = z.object({
7
+ cleanup: z
8
+ .enum(["keep", "remove_if_clean"])
9
+ .optional()
10
+ .default("keep")
11
+ .describe("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."),
12
+ reason: z.string().optional().describe("Optional short note about why the session is leaving the worktree"),
13
+ });
14
+ export const WorktreeLeaveTool = Tool.define("worktree_leave", {
15
+ description: "Leave the current git worktree and return to the main checkout. Unbinds the session from the worktree and " +
16
+ "restores the workspace to the main tree. The worktree stays on disk by default; the cleanup parameter can " +
17
+ "permanently delete it instead, and that deletion cannot be undone. Use worktree_archive when isolated work is " +
18
+ "finished and the checkout should stay restorable.",
19
+ parameters,
20
+ async execute(params, ctx) {
21
+ const workspace = ScopeContext.current.workspace;
22
+ if (!workspace || workspace.type !== "git_worktree") {
23
+ return {
24
+ title: "worktree_leave",
25
+ output: "Already on the main checkout. No worktree to leave.",
26
+ metadata: {
27
+ action: "noop",
28
+ reason: "already_on_main",
29
+ message: "Already on the main checkout. No worktree to leave.",
30
+ },
31
+ };
32
+ }
33
+ const worktreeID = workspace.worktreeID;
34
+ const worktreePath = workspace.path;
35
+ const worktreeName = workspace.name;
36
+ // Tri-state: `undefined` means the dirty probe could not answer, which is
37
+ // not the same as "clean" and must not be reported as "dirty".
38
+ let dirtyState;
39
+ if (params.cleanup === "remove_if_clean") {
40
+ const st = await Worktree.status(ctx.sessionID);
41
+ dirtyState = st.dirty;
42
+ }
43
+ try {
44
+ await ctx.ask({
45
+ permission: "worktree_leave",
46
+ patterns: [worktreePath],
47
+ metadata: {
48
+ previous: { type: workspace.type, path: worktreePath, name: worktreeName },
49
+ cleanup: params.cleanup,
50
+ },
51
+ });
52
+ }
53
+ catch (error) {
54
+ if (error instanceof PermissionNext.RejectedError || error instanceof PermissionNext.DeniedError) {
55
+ return {
56
+ title: "worktree_leave",
57
+ output: "User declined leaving the worktree.",
58
+ metadata: {
59
+ action: "denied",
60
+ reason: "user_denied",
61
+ message: "User declined leaving the worktree.",
62
+ },
63
+ };
64
+ }
65
+ throw error;
66
+ }
67
+ const previous = { type: workspace.type, path: worktreePath, name: worktreeName };
68
+ await Worktree.leave(ctx.sessionID);
69
+ const restored = { type: "main", path: ScopeContext.current.directory };
70
+ let cleanupResult = {
71
+ performed: false,
72
+ };
73
+ if (params.cleanup === "remove_if_clean" && worktreeID) {
74
+ if (dirtyState === false) {
75
+ try {
76
+ // The turn issuing this call still holds the worktree's git lock and
77
+ // use token, so removal must be told that the caller is its own turn.
78
+ await Worktree.remove({ sessionID: ctx.sessionID, target: worktreeID, force: false }, { insideCallerTurn: true });
79
+ cleanupResult = { performed: true };
80
+ }
81
+ catch (error) {
82
+ // Leaving already succeeded, so a failed cleanup must not fail the
83
+ // turn: mark the worktree for the janitor and report the reason.
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ await Worktree.markLifecycle(worktreeID, "gc_candidate").catch(() => undefined);
86
+ cleanupResult = { performed: false, error: message, cleanupDeferred: true };
87
+ }
88
+ }
89
+ else if (dirtyState === true) {
90
+ cleanupResult = { performed: false, skippedReason: "dirty" };
91
+ }
92
+ else {
93
+ cleanupResult = { performed: false, skippedReason: "unknown_dirty" };
94
+ }
95
+ }
96
+ const lines = [
97
+ `Left worktree "${worktreeName || worktreePath}" and returned to main checkout.`,
98
+ `Previous: ${worktreePath}`,
99
+ `Restored: ${restored.path}`,
100
+ ];
101
+ if (cleanupResult.performed) {
102
+ lines.push("Worktree removed (was clean).");
103
+ }
104
+ else if (cleanupResult.skippedReason === "dirty") {
105
+ lines.push("Worktree kept (has uncommitted changes).");
106
+ }
107
+ return {
108
+ title: "worktree_leave",
109
+ output: lines.join("\n"),
110
+ metadata: {
111
+ action: "left",
112
+ previous,
113
+ restored,
114
+ cleanup: cleanupResult,
115
+ message: lines.join("\n"),
116
+ },
117
+ };
118
+ },
119
+ });
@@ -0,0 +1,52 @@
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
+ const parameters = z.object({});
6
+ const description = "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.";
7
+ export const WorktreeListTool = Tool.define("worktree_list", {
8
+ description,
9
+ parameters,
10
+ async execute(_params, _ctx) {
11
+ const worktrees = await Worktree.list();
12
+ const ws = ScopeContext.current.workspace;
13
+ const activeWt = ws ? (worktrees.find((wt) => wt.path === ws.path) ?? null) : null;
14
+ const enriched = worktrees.map((wt) => {
15
+ const recommendation = getCleanupRecommendation(wt, activeWt);
16
+ return { ...wt, cleanupRecommendation: recommendation };
17
+ });
18
+ const activeDisplay = activeWt
19
+ ? `${activeWt.name ?? activeWt.path}${activeWt.branch ? ` (${activeWt.branch})` : ""}`
20
+ : "none";
21
+ const message = `${worktrees.length} worktree${worktrees.length !== 1 ? "s" : ""} found. Active: ${activeDisplay}`;
22
+ return {
23
+ title: "worktree_list",
24
+ output: message,
25
+ metadata: {
26
+ action: "listed",
27
+ active: activeWt
28
+ ? { id: activeWt.id, name: activeWt.name, path: activeWt.path, branch: activeWt.branch }
29
+ : null,
30
+ worktrees: enriched,
31
+ message,
32
+ },
33
+ };
34
+ },
35
+ });
36
+ function getCleanupRecommendation(wt, active) {
37
+ if (active && wt.path === active.path)
38
+ return "keep";
39
+ if (wt.isMain)
40
+ return "keep";
41
+ if (!wt.managed)
42
+ return "external_do_not_manage";
43
+ if (wt.stale)
44
+ return "safe_to_remove";
45
+ // A lock Synergy did not write is never reclaimed, so surface it as a reason
46
+ // rather than letting the worktree look merely idle.
47
+ if (wt.locked !== undefined && !wt.locked.startsWith("synergy:v1:"))
48
+ return "locked_outside_synergy";
49
+ if (wt.dirty)
50
+ return "inspect_dirty";
51
+ return "keep";
52
+ }
@@ -0,0 +1,26 @@
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.js";
4
+ import { WorktreeArchiveTool } from "./tools/worktree-archive.js";
5
+ import { WorktreeEnterTool } from "./tools/worktree-enter.js";
6
+ import { WorktreeLeaveTool } from "./tools/worktree-leave.js";
7
+ import { WorktreeListTool } from "./tools/worktree-list.js";
8
+ /**
9
+ * Project domain tool registration. Loaded through src/registration.ts.
10
+ */
11
+ const runtimeState = RuntimeContext.state(() => ({
12
+ registered: false,
13
+ }));
14
+ export function registerProjectTools() {
15
+ const instanceState = runtimeState();
16
+ if (instanceState.registered)
17
+ return;
18
+ instanceState.registered = true;
19
+ ToolRegistry.registerToolProvider("project", () => [
20
+ SessionControlTool,
21
+ WorktreeEnterTool,
22
+ WorktreeLeaveTool,
23
+ WorktreeArchiveTool,
24
+ WorktreeListTool,
25
+ ]);
26
+ }
@@ -0,0 +1,76 @@
1
+ import path from "path";
2
+ export var VcsBranchWatcher;
3
+ (function (VcsBranchWatcher) {
4
+ function create(input) {
5
+ let branch;
6
+ let timer;
7
+ let inFlight;
8
+ let refreshPending = false;
9
+ let disposed = false;
10
+ const idleWaiters = new Set();
11
+ const settled = () => !timer && !inFlight;
12
+ const resolveIdle = () => {
13
+ if (!settled())
14
+ return;
15
+ for (const resolve of idleWaiters)
16
+ resolve();
17
+ idleWaiters.clear();
18
+ };
19
+ const refresh = () => {
20
+ if (disposed || inFlight)
21
+ return;
22
+ inFlight = Promise.resolve()
23
+ .then(async () => {
24
+ while (!disposed && refreshPending) {
25
+ refreshPending = false;
26
+ const previous = branch;
27
+ const next = await input.resolve();
28
+ branch = next;
29
+ if (next !== previous)
30
+ await input.onChange(next, previous);
31
+ }
32
+ })
33
+ .finally(() => {
34
+ inFlight = undefined;
35
+ if (!disposed && refreshPending)
36
+ refresh();
37
+ resolveIdle();
38
+ });
39
+ };
40
+ return {
41
+ async start() {
42
+ branch = await input.resolve();
43
+ return branch;
44
+ },
45
+ current() {
46
+ return branch;
47
+ },
48
+ notify(file) {
49
+ if (disposed || path.basename(file) !== "HEAD")
50
+ return;
51
+ refreshPending = true;
52
+ if (timer || inFlight)
53
+ return;
54
+ timer = setTimeout(() => {
55
+ timer = undefined;
56
+ refresh();
57
+ }, input.debounceMs);
58
+ },
59
+ idle() {
60
+ if (settled())
61
+ return Promise.resolve();
62
+ return new Promise((resolve) => idleWaiters.add(resolve));
63
+ },
64
+ async dispose() {
65
+ disposed = true;
66
+ if (timer)
67
+ clearTimeout(timer);
68
+ refreshPending = false;
69
+ timer = undefined;
70
+ await inFlight;
71
+ resolveIdle();
72
+ },
73
+ };
74
+ }
75
+ VcsBranchWatcher.create = create;
76
+ })(VcsBranchWatcher || (VcsBranchWatcher = {}));