memorix 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +18 -4
  3. package/README.zh-CN.md +18 -4
  4. package/TEAM.md +86 -86
  5. package/dist/cli/index.js +15919 -14055
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/index.js +1997 -1021
  8. package/dist/index.js.map +1 -1
  9. package/dist/maintenance-runner.d.ts +1 -1
  10. package/dist/maintenance-runner.js +8481 -8005
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +30 -1
  13. package/dist/sdk.d.ts +7 -2
  14. package/dist/sdk.js +2022 -1024
  15. package/dist/sdk.js.map +1 -1
  16. package/dist/types.d.ts +49 -1
  17. package/dist/types.js.map +1 -1
  18. package/docs/1.2.2-MEMORY-CONTROL-PLANE.md +434 -0
  19. package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -0
  20. package/docs/API_REFERENCE.md +27 -5
  21. package/docs/DESIGN_DECISIONS.md +357 -357
  22. package/docs/DEVELOPMENT.md +4 -0
  23. package/docs/README.md +1 -1
  24. package/docs/SETUP.md +7 -1
  25. package/docs/dev-log/progress.txt +91 -11
  26. package/docs/knowledge/workflows/memorix-release.md +57 -0
  27. package/package.json +1 -1
  28. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  29. package/src/audit/index.ts +156 -156
  30. package/src/cli/command-guide.ts +192 -0
  31. package/src/cli/commands/audit-list.ts +89 -89
  32. package/src/cli/commands/audit.ts +9 -4
  33. package/src/cli/commands/background.ts +659 -659
  34. package/src/cli/commands/cleanup.ts +5 -1
  35. package/src/cli/commands/codegraph.ts +17 -8
  36. package/src/cli/commands/context.ts +3 -2
  37. package/src/cli/commands/doctor.ts +4 -2
  38. package/src/cli/commands/explain.ts +9 -3
  39. package/src/cli/commands/formation.ts +48 -48
  40. package/src/cli/commands/git-hook-install.ts +111 -111
  41. package/src/cli/commands/handoff.ts +75 -61
  42. package/src/cli/commands/hooks-status.ts +63 -63
  43. package/src/cli/commands/identity.ts +116 -0
  44. package/src/cli/commands/ingest-commit.ts +153 -153
  45. package/src/cli/commands/ingest-image.ts +71 -69
  46. package/src/cli/commands/ingest-log.ts +180 -180
  47. package/src/cli/commands/ingest.ts +44 -44
  48. package/src/cli/commands/integrate-shared.ts +15 -15
  49. package/src/cli/commands/knowledge.ts +40 -0
  50. package/src/cli/commands/lock.ts +93 -92
  51. package/src/cli/commands/memory.ts +58 -21
  52. package/src/cli/commands/message.ts +123 -118
  53. package/src/cli/commands/operator-shared.ts +98 -3
  54. package/src/cli/commands/poll.ts +74 -64
  55. package/src/cli/commands/purge-all-memory.ts +85 -85
  56. package/src/cli/commands/purge-project-memory.ts +83 -83
  57. package/src/cli/commands/reasoning.ts +135 -121
  58. package/src/cli/commands/retention.ts +9 -4
  59. package/src/cli/commands/serve-http.ts +22 -43
  60. package/src/cli/commands/serve-shared.ts +118 -118
  61. package/src/cli/commands/session.ts +29 -3
  62. package/src/cli/commands/setup.ts +9 -3
  63. package/src/cli/commands/skills.ts +124 -119
  64. package/src/cli/commands/status.ts +4 -3
  65. package/src/cli/commands/task.ts +193 -184
  66. package/src/cli/commands/team.ts +14 -10
  67. package/src/cli/commands/transfer.ts +108 -55
  68. package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
  69. package/src/cli/identity.ts +89 -0
  70. package/src/cli/index.ts +96 -19
  71. package/src/cli/invocation.ts +115 -0
  72. package/src/cli/tui/ChatView.tsx +234 -234
  73. package/src/cli/tui/CommandBar.tsx +312 -312
  74. package/src/cli/tui/ContextRail.tsx +118 -118
  75. package/src/cli/tui/HeaderBar.tsx +72 -72
  76. package/src/cli/tui/LogoBanner.tsx +51 -51
  77. package/src/cli/tui/Sidebar.tsx +179 -179
  78. package/src/cli/tui/chat-service.ts +41 -18
  79. package/src/cli/tui/data.ts +23 -44
  80. package/src/cli/tui/index.ts +41 -41
  81. package/src/cli/tui/markdown-render.tsx +371 -371
  82. package/src/cli/tui/operator-context.ts +60 -0
  83. package/src/cli/tui/use-mouse.ts +157 -157
  84. package/src/cli/tui/useNavigation.ts +56 -56
  85. package/src/cli/tui/views/MemoryView.tsx +10 -8
  86. package/src/cli/update-checker.ts +211 -211
  87. package/src/cli/version.ts +7 -7
  88. package/src/cli/workbench.ts +1 -1
  89. package/src/codegraph/auto-context.ts +34 -17
  90. package/src/codegraph/context-pack.ts +1 -0
  91. package/src/codegraph/current-facts.ts +19 -1
  92. package/src/codegraph/project-context.ts +2 -0
  93. package/src/codegraph/task-lens.ts +49 -5
  94. package/src/compact/engine.ts +26 -10
  95. package/src/compact/index-format.ts +25 -2
  96. package/src/compact/token-budget.ts +74 -74
  97. package/src/dashboard/project-classification.ts +64 -64
  98. package/src/dashboard/server.ts +58 -52
  99. package/src/embedding/fastembed-provider.ts +142 -142
  100. package/src/embedding/transformers-provider.ts +111 -111
  101. package/src/git/extractor.ts +209 -209
  102. package/src/git/hooks-path.ts +85 -85
  103. package/src/hooks/admission.ts +117 -0
  104. package/src/hooks/handler.ts +98 -91
  105. package/src/hooks/pattern-detector.ts +173 -173
  106. package/src/hooks/significance-filter.ts +250 -250
  107. package/src/knowledge/claims.ts +51 -1
  108. package/src/knowledge/context-assembly.ts +97 -0
  109. package/src/knowledge/types.ts +1 -0
  110. package/src/knowledge/workflows.ts +34 -3
  111. package/src/knowledge/workset.ts +179 -10
  112. package/src/llm/memory-manager.ts +328 -328
  113. package/src/llm/provider.ts +885 -885
  114. package/src/llm/quality.ts +248 -248
  115. package/src/memory/admission.ts +57 -0
  116. package/src/memory/attribution-guard.ts +249 -249
  117. package/src/memory/auto-relations.ts +21 -0
  118. package/src/memory/consolidation.ts +13 -2
  119. package/src/memory/disclosure-policy.ts +140 -135
  120. package/src/memory/entity-extractor.ts +197 -197
  121. package/src/memory/export-import.ts +11 -3
  122. package/src/memory/formation/evaluate.ts +217 -217
  123. package/src/memory/formation/extract.ts +361 -361
  124. package/src/memory/formation/index.ts +417 -417
  125. package/src/memory/formation/resolve.ts +344 -344
  126. package/src/memory/formation/types.ts +315 -315
  127. package/src/memory/freshness.ts +122 -122
  128. package/src/memory/graph-context.ts +8 -2
  129. package/src/memory/graph-scope.ts +46 -0
  130. package/src/memory/graph.ts +197 -197
  131. package/src/memory/observations.ts +162 -4
  132. package/src/memory/quality-audit.ts +2 -0
  133. package/src/memory/refs.ts +94 -94
  134. package/src/memory/retention.ts +22 -2
  135. package/src/memory/secret-filter.ts +79 -79
  136. package/src/memory/session.ts +5 -2
  137. package/src/memory/visibility.ts +80 -0
  138. package/src/multimodal/image-loader.ts +143 -143
  139. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  140. package/src/orchestrate/adapters/claude.ts +111 -111
  141. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  142. package/src/orchestrate/adapters/codex.ts +41 -41
  143. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  144. package/src/orchestrate/adapters/gemini.ts +42 -42
  145. package/src/orchestrate/adapters/index.ts +73 -73
  146. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  147. package/src/orchestrate/adapters/opencode.ts +47 -47
  148. package/src/orchestrate/adapters/spawn-helper.ts +286 -286
  149. package/src/orchestrate/adapters/types.ts +77 -77
  150. package/src/orchestrate/capability-router.ts +284 -284
  151. package/src/orchestrate/context-compact.ts +188 -188
  152. package/src/orchestrate/cost-tracker.ts +219 -219
  153. package/src/orchestrate/error-recovery.ts +191 -191
  154. package/src/orchestrate/evidence.ts +140 -140
  155. package/src/orchestrate/ledger.ts +110 -110
  156. package/src/orchestrate/memorix-bridge.ts +378 -340
  157. package/src/orchestrate/output-budget.ts +80 -80
  158. package/src/orchestrate/permission.ts +152 -152
  159. package/src/orchestrate/pipeline-trace.ts +131 -131
  160. package/src/orchestrate/prompt-builder.ts +155 -155
  161. package/src/orchestrate/ring-buffer.ts +37 -37
  162. package/src/orchestrate/task-graph.ts +389 -389
  163. package/src/orchestrate/verify-gate.ts +33 -10
  164. package/src/orchestrate/worktree.ts +232 -232
  165. package/src/project/aliases.ts +374 -374
  166. package/src/project/detector.ts +268 -268
  167. package/src/rules/adapters/claude-code.ts +99 -99
  168. package/src/rules/adapters/codex.ts +97 -97
  169. package/src/rules/adapters/copilot.ts +124 -124
  170. package/src/rules/adapters/cursor.ts +114 -114
  171. package/src/rules/adapters/kiro.ts +126 -126
  172. package/src/rules/adapters/trae.ts +56 -56
  173. package/src/rules/adapters/windsurf.ts +83 -83
  174. package/src/rules/syncer.ts +235 -235
  175. package/src/runtime/control-plane-maintenance.ts +1 -0
  176. package/src/runtime/isolated-maintenance.ts +1 -0
  177. package/src/runtime/lifecycle.ts +18 -0
  178. package/src/runtime/maintenance-jobs.ts +1 -0
  179. package/src/runtime/maintenance-runner.ts +2 -0
  180. package/src/runtime/project-maintenance.ts +89 -0
  181. package/src/sdk.ts +334 -304
  182. package/src/search/intent-detector.ts +289 -289
  183. package/src/search/query-expansion.ts +52 -52
  184. package/src/server/formation-timeout.ts +27 -27
  185. package/src/server.ts +334 -93
  186. package/src/skills/mini-skills.ts +386 -386
  187. package/src/store/chat-store.ts +119 -119
  188. package/src/store/graph-store.ts +249 -249
  189. package/src/store/mini-skill-store.ts +349 -349
  190. package/src/store/orama-store.ts +61 -6
  191. package/src/store/persistence-json.ts +212 -212
  192. package/src/store/persistence.ts +291 -291
  193. package/src/store/project-affinity.ts +195 -195
  194. package/src/store/sqlite-db.ts +23 -1
  195. package/src/store/sqlite-store.ts +12 -2
  196. package/src/team/event-bus.ts +76 -76
  197. package/src/team/file-locks.ts +173 -173
  198. package/src/team/handoff.ts +168 -161
  199. package/src/team/messages.ts +203 -203
  200. package/src/team/poll.ts +132 -132
  201. package/src/team/tasks.ts +211 -211
  202. package/src/types.ts +51 -0
  203. package/src/wiki/generator.ts +2 -0
  204. package/src/workspace/mcp-adapters/codex.ts +191 -191
  205. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  206. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  207. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  208. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  209. package/src/workspace/mcp-adapters/trae.ts +134 -134
  210. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  211. package/src/workspace/sanitizer.ts +60 -60
  212. package/src/workspace/workflow-sync.ts +131 -131
@@ -9,7 +9,7 @@
9
9
  * is skipped and the task completes as before (backward compatible).
10
10
  */
11
11
 
12
- import { spawn, execSync } from 'node:child_process';
12
+ import { spawn } from 'node:child_process';
13
13
 
14
14
  // ── Types ──────────────────────────────────────────────────────────
15
15
 
@@ -63,6 +63,15 @@ export function runGate(
63
63
  return new Promise<GateResult>((resolve) => {
64
64
  const chunks: Buffer[] = [];
65
65
  let totalBytes = 0;
66
+ let settled = false;
67
+ let timer: ReturnType<typeof setTimeout> | undefined;
68
+
69
+ const finish = (result: GateResult) => {
70
+ if (settled) return;
71
+ settled = true;
72
+ if (timer) clearTimeout(timer);
73
+ resolve(result);
74
+ };
66
75
 
67
76
  const proc = spawn(command, {
68
77
  cwd,
@@ -86,9 +95,14 @@ export function runGate(
86
95
  let killed = false;
87
96
  const killTree = () => {
88
97
  try {
89
- if (process.platform === 'win32') {
90
- // Windows: kill entire process tree via taskkill
91
- execSync(`taskkill /F /T /PID ${proc.pid}`, { stdio: 'ignore' });
98
+ if (process.platform === 'win32' && proc.pid) {
99
+ // `shell: true` gives Windows a cmd.exe parent. Kill its whole tree
100
+ // without making the timeout path wait for a stubborn descendant.
101
+ const killer = spawn('taskkill.exe', ['/F', '/T', '/PID', String(proc.pid)], {
102
+ stdio: 'ignore',
103
+ windowsHide: true,
104
+ });
105
+ killer.unref();
92
106
  } else {
93
107
  // Unix: kill process group
94
108
  process.kill(-proc.pid!, 'SIGKILL');
@@ -97,20 +111,30 @@ export function runGate(
97
111
  // Fallback: kill the process directly
98
112
  try { proc.kill('SIGKILL'); } catch { /* already dead */ }
99
113
  }
114
+ try { proc.stdout?.destroy(); } catch { /* already closed */ }
115
+ try { proc.stderr?.destroy(); } catch { /* already closed */ }
116
+ proc.unref();
100
117
  };
101
118
 
102
- const timer = setTimeout(() => {
119
+ timer = setTimeout(() => {
103
120
  killed = true;
104
121
  killTree();
122
+ const output = Buffer.concat(chunks).toString('utf-8');
123
+ finish({
124
+ gate,
125
+ passed: false,
126
+ output: output + `\n[TIMEOUT] Gate "${gate}" killed after ${timeoutMs}ms`,
127
+ durationMs: Date.now() - start,
128
+ command,
129
+ });
105
130
  }, timeoutMs);
106
131
 
107
132
  proc.on('close', (code) => {
108
- clearTimeout(timer);
109
133
  const output = Buffer.concat(chunks).toString('utf-8');
110
134
  const durationMs = Date.now() - start;
111
135
 
112
136
  if (killed) {
113
- resolve({
137
+ finish({
114
138
  gate,
115
139
  passed: false,
116
140
  output: output + `\n[TIMEOUT] Gate "${gate}" killed after ${timeoutMs}ms`,
@@ -120,7 +144,7 @@ export function runGate(
120
144
  return;
121
145
  }
122
146
 
123
- resolve({
147
+ finish({
124
148
  gate,
125
149
  passed: code === 0,
126
150
  output,
@@ -130,8 +154,7 @@ export function runGate(
130
154
  });
131
155
 
132
156
  proc.on('error', (err) => {
133
- clearTimeout(timer);
134
- resolve({
157
+ finish({
135
158
  gate,
136
159
  passed: false,
137
160
  output: `[ERROR] Failed to spawn gate command: ${err.message}`,
@@ -1,232 +1,232 @@
1
- /**
2
- * Git Worktree — Phase 6i: Parallel agent isolation.
3
- *
4
- * Each parallel agent gets its own working directory + branch via
5
- * git worktree. Prevents file conflicts during concurrent execution.
6
- * Startup cleanup handles orphaned worktrees (pays D6 debt).
7
- */
8
-
9
- import { execSync } from 'node:child_process';
10
- import { existsSync, rmSync } from 'node:fs';
11
- import { join, basename } from 'node:path';
12
-
13
- // ── Types ──────────────────────────────────────────────────────────
14
-
15
- export interface WorktreeInfo {
16
- worktreePath: string;
17
- branch: string;
18
- }
19
-
20
- // ── Constants ──────────────────────────────────────────────────────
21
-
22
- const WORKTREE_DIR = '.worktrees';
23
-
24
- // ── Create ─────────────────────────────────────────────────────────
25
-
26
- /**
27
- * Create an isolated git worktree for a task.
28
- * The worktree lives at `<projectDir>/.worktrees/task-<shortId>/`
29
- * on branch `pipeline/<pipelineId>/task-<shortId>`.
30
- */
31
- export function createWorktree(
32
- projectDir: string,
33
- taskId: string,
34
- pipelineId: string,
35
- ): WorktreeInfo {
36
- const shortId = taskId.slice(0, 8);
37
- const shortPipeline = pipelineId.slice(0, 8);
38
- const worktreePath = join(projectDir, WORKTREE_DIR, `task-${shortId}`);
39
- const branch = `pipeline/${shortPipeline}/task-${shortId}`;
40
-
41
- // Ensure .worktrees directory exists (git worktree add handles the rest)
42
- execSync(`git worktree add "${worktreePath}" -b "${branch}"`, {
43
- cwd: projectDir,
44
- encoding: 'utf-8',
45
- timeout: 30_000,
46
- });
47
-
48
- return { worktreePath, branch };
49
- }
50
-
51
- // ── Merge ──────────────────────────────────────────────────────────
52
-
53
- export interface MergeResult {
54
- success: boolean;
55
- conflicts?: string;
56
- }
57
-
58
- /**
59
- * Commit any uncommitted changes in the worktree, then merge the worktree
60
- * branch back into the current branch in projectDir.
61
- * Returns success=false with conflict details if merge fails.
62
- *
63
- * @param worktreePath - path to the worktree directory (for add+commit)
64
- * @param projectDir - path to the main repo (for merge)
65
- * @param branch - the worktree branch name
66
- */
67
- export function mergeWorktree(
68
- projectDir: string,
69
- branch: string,
70
- worktreePath?: string,
71
- ): MergeResult {
72
- // Step 1: Commit any agent-produced changes in the worktree.
73
- // Without this, files written by the agent are untracked and the merge
74
- // will be a no-op (no commits on the branch beyond the checkout base).
75
- if (worktreePath) {
76
- try {
77
- execSync('git add -A', {
78
- cwd: worktreePath,
79
- encoding: 'utf-8',
80
- timeout: 15_000,
81
- });
82
- // Check if there's anything to commit
83
- const status = execSync('git status --porcelain', {
84
- cwd: worktreePath,
85
- encoding: 'utf-8',
86
- timeout: 5_000,
87
- }).trim();
88
- if (status) {
89
- execSync('git commit -m "task: agent work" --no-verify', {
90
- cwd: worktreePath,
91
- encoding: 'utf-8',
92
- timeout: 15_000,
93
- });
94
- }
95
- // else: nothing to commit — merge will be a no-op
96
- } catch {
97
- // "nothing to commit" is fine — the merge will just be a no-op.
98
- // Any other git error here (e.g., invalid index) is also non-fatal;
99
- // we still attempt the merge which will correctly report as no-op.
100
- }
101
- }
102
-
103
- // Step 2: Merge the branch into the main repo.
104
- try {
105
- execSync(`git merge --no-ff "${branch}" -m "merge: ${branch}"`, {
106
- cwd: projectDir,
107
- encoding: 'utf-8',
108
- timeout: 30_000,
109
- });
110
- return { success: true };
111
- } catch (err) {
112
- // Abort the failed merge to leave working tree clean
113
- try {
114
- execSync('git merge --abort', { cwd: projectDir, encoding: 'utf-8', timeout: 5_000 });
115
- } catch { /* best-effort */ }
116
-
117
- const msg = err instanceof Error ? err.message : String(err);
118
- return { success: false, conflicts: msg };
119
- }
120
- }
121
-
122
- // ── Remove ─────────────────────────────────────────────────────────
123
-
124
- /**
125
- * Remove a worktree and optionally delete the branch.
126
- */
127
- export function removeWorktree(
128
- projectDir: string,
129
- worktreePath: string,
130
- branch?: string,
131
- ): void {
132
- try {
133
- execSync(`git worktree remove "${worktreePath}" --force`, {
134
- cwd: projectDir,
135
- encoding: 'utf-8',
136
- timeout: 10_000,
137
- });
138
- } catch {
139
- // If git worktree remove fails, try manual cleanup
140
- if (existsSync(worktreePath)) {
141
- try { rmSync(worktreePath, { recursive: true, force: true }); } catch { /* best-effort */ }
142
- }
143
- try {
144
- execSync('git worktree prune', { cwd: projectDir, encoding: 'utf-8', timeout: 5_000 });
145
- } catch { /* best-effort */ }
146
- }
147
-
148
- // Delete the branch (best-effort)
149
- if (branch) {
150
- try {
151
- execSync(`git branch -D "${branch}"`, {
152
- cwd: projectDir,
153
- encoding: 'utf-8',
154
- timeout: 5_000,
155
- });
156
- } catch { /* may already be deleted or is current branch */ }
157
- }
158
- }
159
-
160
- // ── Cleanup Orphans ────────────────────────────────────────────────
161
-
162
- /**
163
- * List existing worktrees under .worktrees/ directory.
164
- * Returns array of { path, branch } from `git worktree list --porcelain`.
165
- */
166
- export function listWorktrees(projectDir: string): Array<{ path: string; branch: string | null }> {
167
- try {
168
- const raw = execSync('git worktree list --porcelain', {
169
- cwd: projectDir,
170
- encoding: 'utf-8',
171
- timeout: 5_000,
172
- });
173
-
174
- const worktrees: Array<{ path: string; branch: string | null }> = [];
175
- let current: { path: string; branch: string | null } | null = null;
176
-
177
- for (const line of raw.split('\n')) {
178
- if (line.startsWith('worktree ')) {
179
- if (current) worktrees.push(current);
180
- current = { path: line.slice('worktree '.length).trim(), branch: null };
181
- } else if (line.startsWith('branch ') && current) {
182
- // branch refs/heads/pipeline/xxx/task-yyy
183
- const ref = line.slice('branch '.length).trim();
184
- current.branch = ref.replace('refs/heads/', '');
185
- }
186
- }
187
- if (current) worktrees.push(current);
188
-
189
- // Filter to only our managed worktrees (normalize slashes for Windows compat)
190
- const worktreeBase = join(projectDir, WORKTREE_DIR).replace(/\\/g, '/');
191
- return worktrees.filter(w => w.path.replace(/\\/g, '/').startsWith(worktreeBase));
192
- } catch {
193
- return [];
194
- }
195
- }
196
-
197
- /**
198
- * Extract taskId from a worktree path.
199
- * Path format: .../task-<shortId>
200
- */
201
- export function extractTaskIdFromPath(worktreePath: string): string | null {
202
- const name = basename(worktreePath);
203
- const match = name.match(/^task-([a-f0-9]+)$/);
204
- return match ? match[1] : null;
205
- }
206
-
207
- /**
208
- * Clean up orphaned worktrees — those whose tasks no longer exist or are terminal.
209
- * Call this at coordinator startup.
210
- *
211
- * @param isTaskTerminal - callback to check if a task (by short ID prefix) is done/nonexistent
212
- * @returns number of worktrees removed
213
- */
214
- export function cleanupOrphanWorktrees(
215
- projectDir: string,
216
- isTaskTerminal: (shortId: string) => boolean,
217
- ): number {
218
- const worktrees = listWorktrees(projectDir);
219
- let removed = 0;
220
-
221
- for (const wt of worktrees) {
222
- const shortId = extractTaskIdFromPath(wt.path);
223
- if (!shortId) continue;
224
-
225
- if (isTaskTerminal(shortId)) {
226
- removeWorktree(projectDir, wt.path, wt.branch ?? undefined);
227
- removed++;
228
- }
229
- }
230
-
231
- return removed;
232
- }
1
+ /**
2
+ * Git Worktree — Phase 6i: Parallel agent isolation.
3
+ *
4
+ * Each parallel agent gets its own working directory + branch via
5
+ * git worktree. Prevents file conflicts during concurrent execution.
6
+ * Startup cleanup handles orphaned worktrees (pays D6 debt).
7
+ */
8
+
9
+ import { execSync } from 'node:child_process';
10
+ import { existsSync, rmSync } from 'node:fs';
11
+ import { join, basename } from 'node:path';
12
+
13
+ // ── Types ──────────────────────────────────────────────────────────
14
+
15
+ export interface WorktreeInfo {
16
+ worktreePath: string;
17
+ branch: string;
18
+ }
19
+
20
+ // ── Constants ──────────────────────────────────────────────────────
21
+
22
+ const WORKTREE_DIR = '.worktrees';
23
+
24
+ // ── Create ─────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Create an isolated git worktree for a task.
28
+ * The worktree lives at `<projectDir>/.worktrees/task-<shortId>/`
29
+ * on branch `pipeline/<pipelineId>/task-<shortId>`.
30
+ */
31
+ export function createWorktree(
32
+ projectDir: string,
33
+ taskId: string,
34
+ pipelineId: string,
35
+ ): WorktreeInfo {
36
+ const shortId = taskId.slice(0, 8);
37
+ const shortPipeline = pipelineId.slice(0, 8);
38
+ const worktreePath = join(projectDir, WORKTREE_DIR, `task-${shortId}`);
39
+ const branch = `pipeline/${shortPipeline}/task-${shortId}`;
40
+
41
+ // Ensure .worktrees directory exists (git worktree add handles the rest)
42
+ execSync(`git worktree add "${worktreePath}" -b "${branch}"`, {
43
+ cwd: projectDir,
44
+ encoding: 'utf-8',
45
+ timeout: 30_000,
46
+ });
47
+
48
+ return { worktreePath, branch };
49
+ }
50
+
51
+ // ── Merge ──────────────────────────────────────────────────────────
52
+
53
+ export interface MergeResult {
54
+ success: boolean;
55
+ conflicts?: string;
56
+ }
57
+
58
+ /**
59
+ * Commit any uncommitted changes in the worktree, then merge the worktree
60
+ * branch back into the current branch in projectDir.
61
+ * Returns success=false with conflict details if merge fails.
62
+ *
63
+ * @param worktreePath - path to the worktree directory (for add+commit)
64
+ * @param projectDir - path to the main repo (for merge)
65
+ * @param branch - the worktree branch name
66
+ */
67
+ export function mergeWorktree(
68
+ projectDir: string,
69
+ branch: string,
70
+ worktreePath?: string,
71
+ ): MergeResult {
72
+ // Step 1: Commit any agent-produced changes in the worktree.
73
+ // Without this, files written by the agent are untracked and the merge
74
+ // will be a no-op (no commits on the branch beyond the checkout base).
75
+ if (worktreePath) {
76
+ try {
77
+ execSync('git add -A', {
78
+ cwd: worktreePath,
79
+ encoding: 'utf-8',
80
+ timeout: 15_000,
81
+ });
82
+ // Check if there's anything to commit
83
+ const status = execSync('git status --porcelain', {
84
+ cwd: worktreePath,
85
+ encoding: 'utf-8',
86
+ timeout: 5_000,
87
+ }).trim();
88
+ if (status) {
89
+ execSync('git commit -m "task: agent work" --no-verify', {
90
+ cwd: worktreePath,
91
+ encoding: 'utf-8',
92
+ timeout: 15_000,
93
+ });
94
+ }
95
+ // else: nothing to commit — merge will be a no-op
96
+ } catch {
97
+ // "nothing to commit" is fine — the merge will just be a no-op.
98
+ // Any other git error here (e.g., invalid index) is also non-fatal;
99
+ // we still attempt the merge which will correctly report as no-op.
100
+ }
101
+ }
102
+
103
+ // Step 2: Merge the branch into the main repo.
104
+ try {
105
+ execSync(`git merge --no-ff "${branch}" -m "merge: ${branch}"`, {
106
+ cwd: projectDir,
107
+ encoding: 'utf-8',
108
+ timeout: 30_000,
109
+ });
110
+ return { success: true };
111
+ } catch (err) {
112
+ // Abort the failed merge to leave working tree clean
113
+ try {
114
+ execSync('git merge --abort', { cwd: projectDir, encoding: 'utf-8', timeout: 5_000 });
115
+ } catch { /* best-effort */ }
116
+
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ return { success: false, conflicts: msg };
119
+ }
120
+ }
121
+
122
+ // ── Remove ─────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Remove a worktree and optionally delete the branch.
126
+ */
127
+ export function removeWorktree(
128
+ projectDir: string,
129
+ worktreePath: string,
130
+ branch?: string,
131
+ ): void {
132
+ try {
133
+ execSync(`git worktree remove "${worktreePath}" --force`, {
134
+ cwd: projectDir,
135
+ encoding: 'utf-8',
136
+ timeout: 10_000,
137
+ });
138
+ } catch {
139
+ // If git worktree remove fails, try manual cleanup
140
+ if (existsSync(worktreePath)) {
141
+ try { rmSync(worktreePath, { recursive: true, force: true }); } catch { /* best-effort */ }
142
+ }
143
+ try {
144
+ execSync('git worktree prune', { cwd: projectDir, encoding: 'utf-8', timeout: 5_000 });
145
+ } catch { /* best-effort */ }
146
+ }
147
+
148
+ // Delete the branch (best-effort)
149
+ if (branch) {
150
+ try {
151
+ execSync(`git branch -D "${branch}"`, {
152
+ cwd: projectDir,
153
+ encoding: 'utf-8',
154
+ timeout: 5_000,
155
+ });
156
+ } catch { /* may already be deleted or is current branch */ }
157
+ }
158
+ }
159
+
160
+ // ── Cleanup Orphans ────────────────────────────────────────────────
161
+
162
+ /**
163
+ * List existing worktrees under .worktrees/ directory.
164
+ * Returns array of { path, branch } from `git worktree list --porcelain`.
165
+ */
166
+ export function listWorktrees(projectDir: string): Array<{ path: string; branch: string | null }> {
167
+ try {
168
+ const raw = execSync('git worktree list --porcelain', {
169
+ cwd: projectDir,
170
+ encoding: 'utf-8',
171
+ timeout: 5_000,
172
+ });
173
+
174
+ const worktrees: Array<{ path: string; branch: string | null }> = [];
175
+ let current: { path: string; branch: string | null } | null = null;
176
+
177
+ for (const line of raw.split('\n')) {
178
+ if (line.startsWith('worktree ')) {
179
+ if (current) worktrees.push(current);
180
+ current = { path: line.slice('worktree '.length).trim(), branch: null };
181
+ } else if (line.startsWith('branch ') && current) {
182
+ // branch refs/heads/pipeline/xxx/task-yyy
183
+ const ref = line.slice('branch '.length).trim();
184
+ current.branch = ref.replace('refs/heads/', '');
185
+ }
186
+ }
187
+ if (current) worktrees.push(current);
188
+
189
+ // Filter to only our managed worktrees (normalize slashes for Windows compat)
190
+ const worktreeBase = join(projectDir, WORKTREE_DIR).replace(/\\/g, '/');
191
+ return worktrees.filter(w => w.path.replace(/\\/g, '/').startsWith(worktreeBase));
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Extract taskId from a worktree path.
199
+ * Path format: .../task-<shortId>
200
+ */
201
+ export function extractTaskIdFromPath(worktreePath: string): string | null {
202
+ const name = basename(worktreePath);
203
+ const match = name.match(/^task-([a-f0-9]+)$/);
204
+ return match ? match[1] : null;
205
+ }
206
+
207
+ /**
208
+ * Clean up orphaned worktrees — those whose tasks no longer exist or are terminal.
209
+ * Call this at coordinator startup.
210
+ *
211
+ * @param isTaskTerminal - callback to check if a task (by short ID prefix) is done/nonexistent
212
+ * @returns number of worktrees removed
213
+ */
214
+ export function cleanupOrphanWorktrees(
215
+ projectDir: string,
216
+ isTaskTerminal: (shortId: string) => boolean,
217
+ ): number {
218
+ const worktrees = listWorktrees(projectDir);
219
+ let removed = 0;
220
+
221
+ for (const wt of worktrees) {
222
+ const shortId = extractTaskIdFromPath(wt.path);
223
+ if (!shortId) continue;
224
+
225
+ if (isTaskTerminal(shortId)) {
226
+ removeWorktree(projectDir, wt.path, wt.branch ?? undefined);
227
+ removed++;
228
+ }
229
+ }
230
+
231
+ return removed;
232
+ }