@mrpatronz/nexusflow 0.1.0 → 0.1.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 (69) hide show
  1. package/.github/dependabot.yml +25 -0
  2. package/.github/workflows/release.yml +90 -0
  3. package/.vscode/launch.json +17 -0
  4. package/.vscode/tasks.json +17 -0
  5. package/README.md +1 -1
  6. package/dist/commands/commit.d.ts +18 -0
  7. package/dist/commands/commit.d.ts.map +1 -0
  8. package/dist/commands/commit.js +105 -0
  9. package/dist/commands/commit.js.map +1 -0
  10. package/dist/commands/diff.d.ts +11 -0
  11. package/dist/commands/diff.d.ts.map +1 -0
  12. package/dist/commands/diff.js +101 -0
  13. package/dist/commands/diff.js.map +1 -0
  14. package/dist/commands/sync.d.ts +11 -0
  15. package/dist/commands/sync.d.ts.map +1 -0
  16. package/dist/commands/sync.js +96 -0
  17. package/dist/commands/sync.js.map +1 -0
  18. package/dist/generators/base.d.ts.map +1 -1
  19. package/dist/generators/base.js +3 -0
  20. package/dist/generators/base.js.map +1 -1
  21. package/dist/generators/index.d.ts +5 -2
  22. package/dist/generators/index.d.ts.map +1 -1
  23. package/dist/generators/index.js +88 -38
  24. package/dist/generators/index.js.map +1 -1
  25. package/dist/generators/plan-generator.d.ts +40 -0
  26. package/dist/generators/plan-generator.d.ts.map +1 -0
  27. package/dist/generators/plan-generator.js +312 -0
  28. package/dist/generators/plan-generator.js.map +1 -0
  29. package/dist/gui/assets/index-B9Ph2bHH.css +2 -0
  30. package/dist/gui/assets/index-s4nRkpqY.js +21 -0
  31. package/dist/gui/index.html +2 -2
  32. package/dist/index.js +57 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +141 -1
  36. package/dist/server.js.map +1 -1
  37. package/dist/types.d.ts +13 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/utils/multi-git.d.ts +108 -0
  40. package/dist/utils/multi-git.d.ts.map +1 -0
  41. package/dist/utils/multi-git.js +206 -0
  42. package/dist/utils/multi-git.js.map +1 -0
  43. package/extension/package-lock.json +2811 -0
  44. package/extension/package.json +61 -0
  45. package/extension/src/extension.ts +168 -0
  46. package/extension/tsconfig.json +19 -0
  47. package/gui/src/App.tsx +377 -24
  48. package/gui/src/features/changes/ChangesViewer.tsx +212 -0
  49. package/gui/src/features/knowledge/KnowledgeBase.tsx +84 -0
  50. package/gui/src/features/onboarding/OnboardingWizard.tsx +230 -0
  51. package/gui/src/features/plan/ImplementationPlan.tsx +32 -0
  52. package/gui/src/features/services/ServiceConsole.tsx +159 -0
  53. package/gui/src/features/sessions/SessionHistory.tsx +195 -0
  54. package/gui/src/features/workspace/WorkspaceBuilder.tsx +414 -0
  55. package/gui/src/features/workspace/WorkspaceList.tsx +382 -0
  56. package/gui/src/types.ts +61 -0
  57. package/package.json +6 -4
  58. package/src/commands/commit.ts +131 -0
  59. package/src/commands/diff.ts +125 -0
  60. package/src/commands/sync.ts +110 -0
  61. package/src/generators/base.ts +3 -0
  62. package/src/generators/index.ts +95 -40
  63. package/src/generators/plan-generator.ts +390 -0
  64. package/src/index.ts +59 -0
  65. package/src/server.ts +152 -1
  66. package/src/types.ts +17 -0
  67. package/src/utils/multi-git.ts +306 -0
  68. package/dist/gui/assets/index-Cq9V485S.js +0 -21
  69. package/dist/gui/assets/index-Dh1b5gSZ.css +0 -2
@@ -0,0 +1,390 @@
1
+ /**
2
+ * @module plan-generator
3
+ * Analyzes inter-repo dependencies within a workspace and generates a
4
+ * `nexusflow-plan.md` implementation plan with build-order phases.
5
+ */
6
+
7
+ import path from 'node:path';
8
+ import fse from 'fs-extra';
9
+ import chalk from 'chalk';
10
+ import type {
11
+ WorkspaceContext,
12
+ ProjectAnalysis,
13
+ RepoInfo,
14
+ DependencyNode,
15
+ DependencyGraph,
16
+ } from '../types.js';
17
+
18
+ // ─── Constants ────────────────────────────────────────────────────────────
19
+
20
+ /** Repo-name substrings that signal a shared/foundation package. */
21
+ const SHARED_PACKAGE_KEYWORDS = ['shared', 'common', 'contracts', 'types'];
22
+
23
+ /** Project types that act as backend producers. */
24
+ const BACKEND_TYPES = ['api', 'backend', 'service'];
25
+
26
+ /** Project types that act as frontend consumers. */
27
+ const FRONTEND_TYPES = ['frontend', 'webapp', 'app'];
28
+
29
+ // ─── Dependency Graph Builder ─────────────────────────────────────────────
30
+
31
+ /**
32
+ * Build a dependency graph by analysing package dependencies, API
33
+ * relationships and shared-package conventions across the workspace repos.
34
+ *
35
+ * @param analysis Per-repo analysis results, keyed by repo path.
36
+ * @param repos Metadata for every repo in the workspace.
37
+ * @returns A map of repo name → {@link DependencyNode}.
38
+ */
39
+ export function buildDependencyGraph(
40
+ analysis: Map<string, ProjectAnalysis>,
41
+ repos: RepoInfo[],
42
+ ): DependencyGraph {
43
+ const graph: DependencyGraph = new Map();
44
+
45
+ // ── Initialise a node for each repo ──────────────────────────────────
46
+ for (const repo of repos) {
47
+ graph.set(repo.name, {
48
+ repoName: repo.name,
49
+ repoPath: repo.path,
50
+ dependsOn: [],
51
+ dependedOnBy: [],
52
+ });
53
+ }
54
+
55
+ // Build a quick lookup: repo name → ProjectAnalysis
56
+ const analysisByName = new Map<string, ProjectAnalysis>();
57
+ for (const repo of repos) {
58
+ const a = analysis.get(repo.path);
59
+ if (a) analysisByName.set(repo.name, a);
60
+ }
61
+
62
+ const repoNames = new Set(repos.map((r) => r.name));
63
+
64
+ // ── 1. Shared-package dependencies ───────────────────────────────────
65
+ for (const repo of repos) {
66
+ const a = analysisByName.get(repo.name);
67
+ if (!a) continue;
68
+
69
+ for (const dep of a.dependencies) {
70
+ // Direct name match — e.g. "@acme/shared-contracts" contains "shared-contracts"
71
+ for (const otherName of repoNames) {
72
+ if (otherName === repo.name) continue;
73
+ if (dep.name === otherName || dep.name.includes(otherName)) {
74
+ addEdge(graph, repo.name, otherName);
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ // ── 2. API relationships (frontend → backend heuristic) ─────────────
81
+ for (const repoA of repos) {
82
+ const analysisA = analysisByName.get(repoA.name);
83
+ if (!analysisA) continue;
84
+
85
+ const typeA = analysisA.techStack.projectType;
86
+
87
+ if (!BACKEND_TYPES.includes(typeA)) continue;
88
+
89
+ for (const repoB of repos) {
90
+ if (repoB.name === repoA.name) continue;
91
+
92
+ const analysisB = analysisByName.get(repoB.name);
93
+ if (!analysisB) continue;
94
+
95
+ const typeB = analysisB.techStack.projectType;
96
+ if (FRONTEND_TYPES.includes(typeB)) {
97
+ // B (frontend) depends on A (backend)
98
+ addEdge(graph, repoB.name, repoA.name);
99
+ }
100
+ }
101
+ }
102
+
103
+ // ── 3. Shared-type packages are always foundation ───────────────────
104
+ for (const repo of repos) {
105
+ const isShared = SHARED_PACKAGE_KEYWORDS.some((kw) =>
106
+ repo.name.toLowerCase().includes(kw),
107
+ );
108
+ if (!isShared) continue;
109
+
110
+ const node = graph.get(repo.name);
111
+ if (!node) continue;
112
+
113
+ // Ensure no outgoing deps (it's a leaf producer)
114
+ node.dependsOn = [];
115
+
116
+ // Every other repo that doesn't already depend on it — add edge
117
+ for (const other of repos) {
118
+ if (other.name === repo.name) continue;
119
+ addEdge(graph, other.name, repo.name);
120
+ }
121
+ }
122
+
123
+ return graph;
124
+ }
125
+
126
+ // ─── Topological Sort ─────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Topologically sort the dependency graph into build phases.
130
+ * Each phase is a group of repos that can be built in parallel
131
+ * because all of their dependencies appear in earlier phases.
132
+ *
133
+ * If a cycle is detected, the remaining nodes are placed in a final phase
134
+ * with a warning logged to the console.
135
+ *
136
+ * @param graph The workspace dependency graph.
137
+ * @returns An array of phases, where each phase is an array of repo names.
138
+ */
139
+ export function topologicalSort(graph: DependencyGraph): string[][] {
140
+ // Calculate in-degrees
141
+ const inDegree = new Map<string, number>();
142
+ for (const [name, node] of graph) {
143
+ inDegree.set(name, node.dependsOn.length);
144
+ }
145
+
146
+ const phases: string[][] = [];
147
+ const placed = new Set<string>();
148
+
149
+ while (placed.size < graph.size) {
150
+ // Collect nodes whose in-degree is 0 and haven't been placed yet
151
+ const phase: string[] = [];
152
+ for (const [name, degree] of inDegree) {
153
+ if (degree === 0 && !placed.has(name)) {
154
+ phase.push(name);
155
+ }
156
+ }
157
+
158
+ // Cycle detection — no zero-in-degree nodes remain
159
+ if (phase.length === 0) {
160
+ const remaining = [...graph.keys()].filter((n) => !placed.has(n));
161
+ console.log(
162
+ chalk.yellow(' ⚠'),
163
+ `Dependency cycle detected among: ${remaining.join(', ')}`,
164
+ );
165
+ phases.push(remaining);
166
+ break;
167
+ }
168
+
169
+ phase.sort(); // Deterministic ordering within a phase
170
+ phases.push(phase);
171
+
172
+ // "Remove" placed nodes and decrement dependents' in-degrees
173
+ for (const name of phase) {
174
+ placed.add(name);
175
+ const node = graph.get(name)!;
176
+ for (const dependent of node.dependedOnBy) {
177
+ inDegree.set(dependent, (inDegree.get(dependent) ?? 1) - 1);
178
+ }
179
+ }
180
+ }
181
+
182
+ return phases;
183
+ }
184
+
185
+ // ─── Plan Generator ───────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Generate a `nexusflow-plan.md` implementation plan for the workspace.
189
+ *
190
+ * The plan includes:
191
+ * - A Mermaid dependency diagram
192
+ * - Phased implementation order derived from topological sort
193
+ * - A dependency cross-reference table
194
+ *
195
+ * @param ctx The current workspace context (feature + repos + analysis).
196
+ * @param workspacePath Absolute path to the workspace root directory.
197
+ */
198
+ export async function generateImplementationPlan(
199
+ ctx: WorkspaceContext,
200
+ workspacePath: string,
201
+ ): Promise<void> {
202
+ try {
203
+ const { feature, repos, analysis } = ctx;
204
+
205
+ // ── Fallback: no analysis available ─────────────────────────────────
206
+ if (!analysis || analysis.size === 0) {
207
+ const lines = [
208
+ `# Implementation Plan — ${feature.id}`,
209
+ '',
210
+ '> Auto-generated by NexusFlow.',
211
+ '> No project analysis data was available, so repos are listed alphabetically.',
212
+ '',
213
+ '## Repos',
214
+ '',
215
+ ...repos
216
+ .map((r) => r.name)
217
+ .sort()
218
+ .map((n) => `- ${n}`),
219
+ '',
220
+ ];
221
+ await fse.outputFile(
222
+ path.join(workspacePath, 'nexusflow-plan.md'),
223
+ lines.join('\n'),
224
+ );
225
+ console.log(chalk.green(' ✔'), 'Generated nexusflow-plan.md');
226
+ return;
227
+ }
228
+
229
+ // ── Build graph & sort ──────────────────────────────────────────────
230
+ const graph = buildDependencyGraph(analysis, repos);
231
+ const phases = topologicalSort(graph);
232
+
233
+ // ── Render markdown ─────────────────────────────────────────────────
234
+ const md: string[] = [];
235
+
236
+ md.push(`# Implementation Plan — ${feature.id}`);
237
+ md.push('');
238
+ md.push(
239
+ '> Auto-generated by NexusFlow based on dependency analysis between repos.',
240
+ );
241
+ md.push(
242
+ '> Follow the phase order to avoid blocking yourself on cross-repo dependencies.',
243
+ );
244
+ md.push('');
245
+
246
+ // ── Mermaid diagram ─────────────────────────────────────────────────
247
+ md.push('## Dependency Diagram');
248
+ md.push('');
249
+ md.push('```mermaid');
250
+ md.push('graph TD');
251
+
252
+ const alias = buildAliasMap(graph);
253
+
254
+ for (const [name, node] of graph) {
255
+ if (node.dependsOn.length === 0 && node.dependedOnBy.length === 0) {
256
+ // Isolated node — still show it
257
+ md.push(` ${alias.get(name)}["${name}"]`);
258
+ }
259
+ for (const dep of node.dependsOn) {
260
+ // Arrow: dependency → dependent (dep is built first)
261
+ md.push(
262
+ ` ${alias.get(dep)}["${dep}"] --> ${alias.get(name)}["${name}"]`,
263
+ );
264
+ }
265
+ }
266
+
267
+ md.push('```');
268
+ md.push('');
269
+
270
+ // ── Phase descriptions ──────────────────────────────────────────────
271
+ md.push('## Suggested Implementation Order');
272
+ md.push('');
273
+
274
+ const phaseLabels = [
275
+ 'Foundation',
276
+ 'Core Services',
277
+ 'Integration Layer',
278
+ 'Consumers',
279
+ 'Final',
280
+ ];
281
+
282
+ for (let i = 0; i < phases.length; i++) {
283
+ const phase = phases[i];
284
+ const label = phaseLabels[Math.min(i, phaseLabels.length - 1)];
285
+ const ordinal = ordinalWord(i + 1);
286
+
287
+ md.push(`### Phase ${i + 1}: ${label}`);
288
+ md.push('');
289
+ md.push(`**Repos:** ${phase.join(', ')}`);
290
+ md.push('');
291
+
292
+ if (i === 0) {
293
+ md.push(
294
+ `**Why first:** These repos have no dependencies on other workspace repos. Other repos depend on them.`,
295
+ );
296
+ } else if (i === phases.length - 1) {
297
+ md.push(
298
+ `**Why ${ordinal}:** Depends on APIs and types from earlier phases.`,
299
+ );
300
+ } else {
301
+ const prevPhases = phases
302
+ .slice(0, i)
303
+ .flat()
304
+ .join(', ');
305
+ md.push(
306
+ `**Why ${ordinal}:** Depends on Phase ${i === 1 ? '1' : `1–${i}`} repos (${prevPhases}). Build these before the consumers.`,
307
+ );
308
+ }
309
+
310
+ md.push('');
311
+ }
312
+
313
+ // ── Dependency table ────────────────────────────────────────────────
314
+ md.push('## Dependency Table');
315
+ md.push('');
316
+ md.push('| Repo | Depends On | Depended On By |');
317
+ md.push('|:---|:---|:---|');
318
+
319
+ // Sort repos by phase order for a natural reading experience
320
+ const orderedNames = phases.flat();
321
+ for (const name of orderedNames) {
322
+ const node = graph.get(name)!;
323
+ const deps = node.dependsOn.length > 0 ? node.dependsOn.join(', ') : '—';
324
+ const rdeps =
325
+ node.dependedOnBy.length > 0 ? node.dependedOnBy.join(', ') : '—';
326
+ md.push(`| ${name} | ${deps} | ${rdeps} |`);
327
+ }
328
+
329
+ md.push('');
330
+
331
+ // ── Write file ──────────────────────────────────────────────────────
332
+ const outPath = path.join(workspacePath, 'nexusflow-plan.md');
333
+ await fse.outputFile(outPath, md.join('\n'));
334
+ console.log(chalk.green(' ✔'), 'Generated nexusflow-plan.md');
335
+ } catch (error) {
336
+ const message = error instanceof Error ? error.message : String(error);
337
+ console.error(
338
+ chalk.red(' ✖'),
339
+ `Failed to generate implementation plan: ${message}`,
340
+ );
341
+ }
342
+ }
343
+
344
+ // ─── Helpers ──────────────────────────────────────────────────────────────
345
+
346
+ /**
347
+ * Add a directed edge: `from` depends on `to`.
348
+ * Idempotent — duplicate edges are ignored.
349
+ */
350
+ function addEdge(graph: DependencyGraph, from: string, to: string): void {
351
+ const fromNode = graph.get(from);
352
+ const toNode = graph.get(to);
353
+ if (!fromNode || !toNode) return;
354
+
355
+ if (!fromNode.dependsOn.includes(to)) {
356
+ fromNode.dependsOn.push(to);
357
+ }
358
+ if (!toNode.dependedOnBy.includes(from)) {
359
+ toNode.dependedOnBy.push(from);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Build a short single-letter alias map for Mermaid node IDs.
365
+ * Falls back to sanitised names when there are more than 26 repos.
366
+ */
367
+ function buildAliasMap(graph: DependencyGraph): Map<string, string> {
368
+ const map = new Map<string, string>();
369
+ const names = [...graph.keys()].sort();
370
+
371
+ if (names.length <= 26) {
372
+ let code = 65; // 'A'
373
+ for (const name of names) {
374
+ map.set(name, String.fromCharCode(code++));
375
+ }
376
+ } else {
377
+ for (const name of names) {
378
+ map.set(name, name.replace(/[^a-zA-Z0-9]/g, '_'));
379
+ }
380
+ }
381
+
382
+ return map;
383
+ }
384
+
385
+ /** Return an ordinal word for small numbers, or "nth" for larger ones. */
386
+ function ordinalWord(n: number): string {
387
+ const words = ['first', 'second', 'third', 'fourth', 'fifth'];
388
+ if (n >= 1 && n <= words.length) return words[n - 1];
389
+ return `${n}th`;
390
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,10 @@ import { stopCommand } from './commands/stop.js';
20
20
  import { logsCommand } from './commands/logs.js';
21
21
  import { statusCommand } from './commands/status.js';
22
22
  import { uiCommand } from './commands/ui.js';
23
+ import { syncCommand } from './commands/sync.js';
24
+ import { commitCommand } from './commands/commit.js';
25
+ import { diffCommand } from './commands/diff.js';
26
+
23
27
 
24
28
  const program = new Command();
25
29
 
@@ -175,4 +179,59 @@ program
175
179
  }
176
180
  });
177
181
 
182
+ program
183
+ .command('sync')
184
+ .description('Sync all repositories in a workspace')
185
+ .argument('[workspace]', 'Path to workspace (auto-detects from CWD)')
186
+ .action(async (workspace?: string) => {
187
+ try {
188
+ await syncCommand(workspace);
189
+ } catch (error) {
190
+ if (error instanceof Error && error.message.includes('User force closed')) {
191
+ console.log('\nCancelled.');
192
+ process.exit(0);
193
+ }
194
+ console.error(error);
195
+ process.exit(1);
196
+ }
197
+ });
198
+
199
+ program
200
+ .command('commit')
201
+ .description('Commit changes across all repositories in a workspace')
202
+ .argument('[workspace]', 'Path to workspace (auto-detects from CWD)')
203
+ .requiredOption('-m, --message <msg>', 'Commit message')
204
+ .option('--no-push', 'Stage and commit changes without pushing to remote')
205
+ .option('--dry-run', 'Preview changes without committing')
206
+ .action(async (workspace: string | undefined, options: { message: string; noPush?: boolean; dryRun?: boolean }) => {
207
+ try {
208
+ await commitCommand(options.message, workspace, options);
209
+ } catch (error) {
210
+ if (error instanceof Error && error.message.includes('User force closed')) {
211
+ console.log('\nCancelled.');
212
+ process.exit(0);
213
+ }
214
+ console.error(error);
215
+ process.exit(1);
216
+ }
217
+ });
218
+
219
+ program
220
+ .command('diff')
221
+ .description('Display a unified summary of changes across all repositories')
222
+ .argument('[workspace]', 'Path to workspace (auto-detects from CWD)')
223
+ .action(async (workspace?: string) => {
224
+ try {
225
+ await diffCommand(workspace);
226
+ } catch (error) {
227
+ if (error instanceof Error && error.message.includes('User force closed')) {
228
+ console.log('\nCancelled.');
229
+ process.exit(0);
230
+ }
231
+ console.error(error);
232
+ process.exit(1);
233
+ }
234
+ });
235
+
178
236
  program.parse();
237
+
package/src/server.ts CHANGED
@@ -20,6 +20,7 @@ import { generateContextFiles } from './generators/index.js';
20
20
  import { detectAIAssistants } from './utils/detect-ai.js';
21
21
  import { detectEditors } from './utils/detect-editors.js';
22
22
  import { findSessions, getSessionTranscript } from './utils/session-finder.js';
23
+ import { getWorkspaceRepos, rebaseRepo, commitAndPush, getRepoStatus } from './utils/multi-git.js';
23
24
  import {
24
25
  detectAllServices,
25
26
  detectOrchestrationTools,
@@ -301,6 +302,27 @@ app.get('/api/workspace/:id/changes', async (c) => {
301
302
  const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: worktreePath });
302
303
  const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
303
304
 
305
+ // Get numstat to determine additions and deletions per file
306
+ const numstatMap = new Map<string, { additions: number; deletions: number }>();
307
+ try {
308
+ const { stdout: numstatRaw } = await execa('git', ['diff', 'HEAD', '--numstat'], {
309
+ cwd: worktreePath,
310
+ });
311
+ const numstatLines = numstatRaw.split('\n').filter(Boolean);
312
+ for (const numLine of numstatLines) {
313
+ const parts = numLine.trim().split(/\s+/);
314
+ if (parts.length >= 3) {
315
+ const [add, del, file] = parts;
316
+ numstatMap.set(file, {
317
+ additions: add === '-' ? 0 : parseInt(add, 10) || 0,
318
+ deletions: del === '-' ? 0 : parseInt(del, 10) || 0,
319
+ });
320
+ }
321
+ }
322
+ } catch (e) {
323
+ // Ignore diff errors
324
+ }
325
+
304
326
  const files = lines.map((line) => {
305
327
  const status = line.slice(0, 2).trim();
306
328
  const file = line.slice(2).trim();
@@ -309,7 +331,15 @@ app.get('/api/workspace/:id/changes', async (c) => {
309
331
  if (status === 'A' || status === '??') type = 'added';
310
332
  else if (status === 'D') type = 'deleted';
311
333
 
312
- return { file, type, rawStatus: status };
334
+ const stats = numstatMap.get(file) || { additions: 0, deletions: 0 };
335
+
336
+ return {
337
+ file,
338
+ type,
339
+ rawStatus: status,
340
+ additions: stats.additions,
341
+ deletions: stats.deletions,
342
+ };
313
343
  });
314
344
 
315
345
  results.push({
@@ -334,6 +364,127 @@ app.get('/api/workspace/:id/changes', async (c) => {
334
364
  }
335
365
  });
336
366
 
367
+ // 13a. Get workspace knowledge (nexusflow-knowledge.md)
368
+ app.get('/api/workspace/:id/knowledge', async (c) => {
369
+ try {
370
+ const id = c.req.param('id');
371
+ const config = await loadConfig();
372
+ const workspacePath = path.join(config.workspacesDir, id);
373
+ const knowledgeFile = path.join(workspacePath, 'nexusflow-knowledge.md');
374
+
375
+ let content = '';
376
+ try {
377
+ content = await fs.readFile(knowledgeFile, 'utf-8');
378
+ } catch {
379
+ content = '# Workspace Knowledge\n\nNo knowledge file yet.';
380
+ }
381
+
382
+ return c.json({ content });
383
+ } catch (error) {
384
+ const msg = error instanceof Error ? error.message : String(error);
385
+ return c.json({ error: msg }, 500);
386
+ }
387
+ });
388
+
389
+ // 13b. Update workspace knowledge (nexusflow-knowledge.md)
390
+ app.put('/api/workspace/:id/knowledge', async (c) => {
391
+ try {
392
+ const id = c.req.param('id');
393
+ const { content } = await c.req.json() as { content: string };
394
+ const config = await loadConfig();
395
+ const workspacePath = path.join(config.workspacesDir, id);
396
+ const knowledgeFile = path.join(workspacePath, 'nexusflow-knowledge.md');
397
+
398
+ await fs.writeFile(knowledgeFile, content, 'utf-8');
399
+ return c.json({ success: true });
400
+ } catch (error) {
401
+ const msg = error instanceof Error ? error.message : String(error);
402
+ return c.json({ error: msg }, 500);
403
+ }
404
+ });
405
+
406
+ // 13c. Get workspace plan (nexusflow-plan.md)
407
+ app.get('/api/workspace/:id/plan', async (c) => {
408
+ try {
409
+ const id = c.req.param('id');
410
+ const config = await loadConfig();
411
+ const workspacePath = path.join(config.workspacesDir, id);
412
+ const planFile = path.join(workspacePath, 'nexusflow-plan.md');
413
+
414
+ let content = '';
415
+ try {
416
+ content = await fs.readFile(planFile, 'utf-8');
417
+ } catch {
418
+ content = '# Workspace Plan\n\nNo implementation plan file yet.';
419
+ }
420
+
421
+ return c.json({ content });
422
+ } catch (error) {
423
+ const msg = error instanceof Error ? error.message : String(error);
424
+ return c.json({ error: msg }, 500);
425
+ }
426
+ });
427
+
428
+ // 13d. Sync all repositories in workspace
429
+ app.post('/api/workspace/:id/sync', async (c) => {
430
+ try {
431
+ const id = c.req.param('id');
432
+ const config = await loadConfig();
433
+ const workspacePath = path.join(config.workspacesDir, id);
434
+
435
+ const repos = await getWorkspaceRepos(workspacePath);
436
+ const results = [];
437
+
438
+ for (const repo of repos) {
439
+ const result = await rebaseRepo(repo.path, 'main');
440
+ results.push({
441
+ repoName: repo.name,
442
+ success: result.success,
443
+ message: result.message,
444
+ conflict: result.conflict,
445
+ });
446
+ }
447
+
448
+ return c.json({ results });
449
+ } catch (error) {
450
+ const msg = error instanceof Error ? error.message : String(error);
451
+ return c.json({ error: msg }, 500);
452
+ }
453
+ });
454
+
455
+ // 13e. Commit changes in all repositories in workspace
456
+ app.post('/api/workspace/:id/commit', async (c) => {
457
+ try {
458
+ const id = c.req.param('id');
459
+ const { message } = await c.req.json() as { message: string };
460
+ const config = await loadConfig();
461
+ const workspacePath = path.join(config.workspacesDir, id);
462
+
463
+ const repos = await getWorkspaceRepos(workspacePath);
464
+ const results = [];
465
+
466
+ for (const repo of repos) {
467
+ const status = await getRepoStatus(repo.path);
468
+ if (status.hasChanges) {
469
+ const result = await commitAndPush(repo.path, message, repo.branchName);
470
+ results.push({
471
+ repoName: repo.name,
472
+ success: result.success,
473
+ commitHash: result.commitHash,
474
+ filesChanged: result.filesChanged,
475
+ message: result.message,
476
+ });
477
+ }
478
+ }
479
+
480
+ return c.json({ results });
481
+ } catch (error) {
482
+ const msg = error instanceof Error ? error.message : String(error);
483
+ return c.json({ error: msg }, 500);
484
+ }
485
+ });
486
+
487
+
337
488
  // 14. Resume session in workspace (copies CLI resume command and opens editor)
338
489
  app.post('/api/workspace/:id/resume', async (c) => {
339
490
  try {
package/src/types.ts CHANGED
@@ -292,3 +292,20 @@ export interface RunningState {
292
292
  /** Timestamp when the state was last updated. */
293
293
  updatedAt: string;
294
294
  }
295
+
296
+ // ─── Phase 3: Dependency Graph Types ──────────────────────────────────────
297
+
298
+ /** A node in the workspace dependency graph. */
299
+ export interface DependencyNode {
300
+ /** Repository name. */
301
+ repoName: string;
302
+ /** Absolute path to the repository. */
303
+ repoPath: string;
304
+ /** Names of repos this one depends on. */
305
+ dependsOn: string[];
306
+ /** Names of repos that depend on this one. */
307
+ dependedOnBy: string[];
308
+ }
309
+
310
+ /** The full dependency graph for a workspace. */
311
+ export type DependencyGraph = Map<string, DependencyNode>;