fraim-framework 2.0.186 → 2.0.187

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.
@@ -3,10 +3,27 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.AiHubConversationStore = void 0;
6
+ exports.AiHubConversationStore = exports.COMPANY_SCOPE_KEY = exports.MANAGER_SCOPE_KEY = void 0;
7
+ exports.conversationScopeKey = conversationScopeKey;
7
8
  const fs_1 = __importDefault(require("fs"));
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
11
+ // Issue #708: reserved, project-independent bucket keys for non-project scopes.
12
+ // They are NOT filesystem paths and must never be passed through path.resolve.
13
+ exports.MANAGER_SCOPE_KEY = '@manager';
14
+ exports.COMPANY_SCOPE_KEY = '@company';
15
+ /**
16
+ * Issue #708: resolve the conversation store bucket key for a given scope.
17
+ * - 'manager'/'company' → a stable sentinel key (project-independent home).
18
+ * - 'project' (or undefined) → the resolved project path (existing behavior).
19
+ */
20
+ function conversationScopeKey(scope, projectPath) {
21
+ if (scope === 'manager')
22
+ return exports.MANAGER_SCOPE_KEY;
23
+ if (scope === 'company')
24
+ return exports.COMPANY_SCOPE_KEY;
25
+ return normalizeConversationKey(projectPath);
26
+ }
10
27
  const emptyProjectState = () => ({
11
28
  activeId: null,
12
29
  conversations: [],
@@ -23,8 +40,58 @@ function timestampValue(value) {
23
40
  }
24
41
  return 0;
25
42
  }
43
+ // Issue #708: sentinel scope keys (e.g. '@manager') are stable bucket keys, not paths —
44
+ // pass them through untouched; resolve everything else as a real project path.
45
+ function normalizeConversationKey(key) {
46
+ if (typeof key === 'string' && (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)) {
47
+ return key;
48
+ }
49
+ return path_1.default.resolve(key || process.cwd());
50
+ }
51
+ // Retained name for project-path stamping; now sentinel-aware so manager/company
52
+ // records keep their scope key rather than being resolved into cwd/@manager.
26
53
  function normalizeProjectPath(projectPath) {
27
- return path_1.default.resolve(projectPath || process.cwd());
54
+ return normalizeConversationKey(projectPath);
55
+ }
56
+ // Issue #708: one-time, idempotent migration. Legacy manager/company-invoked runs were
57
+ // stored inside project buckets and distinguished only by the client-side `invokedArea`
58
+ // field. Fold them into the reserved scope buckets (stamping `scope`) so they have a
59
+ // stable, project-independent home. Runs already in a scope bucket are left untouched.
60
+ function migrateScopeBuckets(store) {
61
+ const projects = {};
62
+ const ensure = (key) => {
63
+ if (!projects[key])
64
+ projects[key] = { activeId: null, conversations: [] };
65
+ return projects[key];
66
+ };
67
+ // Seed existing scope buckets first so their activeId is preserved.
68
+ for (const [key, state] of Object.entries(store.projects || {})) {
69
+ if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY) {
70
+ ensure(key).conversations.push(...(state.conversations || []));
71
+ projects[key].activeId = state.activeId ?? null;
72
+ }
73
+ }
74
+ for (const [key, state] of Object.entries(store.projects || {})) {
75
+ if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)
76
+ continue;
77
+ const stay = [];
78
+ for (const conv of (state.conversations || [])) {
79
+ const scope = conv.scope
80
+ ?? conv.invokedArea;
81
+ if (scope === 'manager' || scope === 'company') {
82
+ const bucketKey = scope === 'manager' ? exports.MANAGER_SCOPE_KEY : exports.COMPANY_SCOPE_KEY;
83
+ const bucket = ensure(bucketKey);
84
+ if (!bucket.conversations.some((c) => c && c.id === conv.id)) {
85
+ bucket.conversations.push({ ...conv, scope: scope });
86
+ }
87
+ }
88
+ else {
89
+ stay.push(conv);
90
+ }
91
+ }
92
+ projects[key] = { activeId: state.activeId ?? null, conversations: stay };
93
+ }
94
+ return { version: 1, projects };
28
95
  }
29
96
  function normalizeConversation(projectPath, raw) {
30
97
  if (!raw || typeof raw !== 'object')
@@ -77,6 +144,32 @@ class AiHubConversationStore {
77
144
  const normalizedProjectPath = normalizeProjectPath(projectPath);
78
145
  return normalizeProjectState(normalizedProjectPath, state.projects[normalizedProjectPath]);
79
146
  }
147
+ // Issue #719: delete a project's KEY from the store entirely. Writing an empty
148
+ // conversation list is not enough — knownProjects re-derives a project from
149
+ // every stored key (listProjectPaths), so removal must drop the key itself.
150
+ // Sentinel scope buckets (@manager/@company) are not projects and never removable.
151
+ removeProject(projectPath) {
152
+ if (projectPath === exports.MANAGER_SCOPE_KEY || projectPath === exports.COMPANY_SCOPE_KEY)
153
+ return false;
154
+ const canonicalKey = (value) => {
155
+ const resolved = path_1.default.resolve(value);
156
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
157
+ };
158
+ const target = canonicalKey(projectPath);
159
+ const state = this.readStore();
160
+ let removed = false;
161
+ for (const key of Object.keys(state.projects)) {
162
+ if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)
163
+ continue;
164
+ if (canonicalKey(key) === target) {
165
+ delete state.projects[key];
166
+ removed = true;
167
+ }
168
+ }
169
+ if (removed)
170
+ this.writeStore(state);
171
+ return removed;
172
+ }
80
173
  replaceProject(projectPath, next) {
81
174
  const normalizedProjectPath = normalizeProjectPath(projectPath);
82
175
  const normalized = normalizeProjectState(normalizedProjectPath, next);
@@ -149,10 +242,8 @@ class AiHubConversationStore {
149
242
  if (raw.version !== 1 || !raw.projects || typeof raw.projects !== 'object') {
150
243
  return { version: 1, projects: {} };
151
244
  }
152
- return {
153
- version: 1,
154
- projects: raw.projects,
155
- };
245
+ // Issue #708: fold legacy manager/company-invoked runs into scope buckets on read.
246
+ return migrateScopeBuckets({ version: 1, projects: raw.projects });
156
247
  }
157
248
  catch {
158
249
  return { version: 1, projects: {} };
@@ -1470,6 +1470,11 @@ class AiHubServer {
1470
1470
  compareRunId: run.compareRunId || null,
1471
1471
  // Issue #578: preserve trigger source so the UI can render the chip.
1472
1472
  sourceTrigger: run.sourceTrigger,
1473
+ // Issue #708: carry the invocation scope so the record lands in (and is keyed to)
1474
+ // the right bucket. Falls back to the legacy client `invokedArea` when present.
1475
+ scope: run.scope
1476
+ ?? run.invokedArea
1477
+ ?? 'project',
1473
1478
  run: {
1474
1479
  stages,
1475
1480
  currentPhase: run.currentPhase || null,
@@ -1481,7 +1486,11 @@ class AiHubServer {
1481
1486
  }
1482
1487
  persistRunConversation(run, activeId) {
1483
1488
  try {
1484
- this.conversationStore.upsertConversation(run.projectPath, this.conversationRecordFromRun(run), activeId);
1489
+ // Issue #708: route the record to its scope bucket (manager/company runs get a
1490
+ // project-independent home); project runs continue to key by project path.
1491
+ const record = this.conversationRecordFromRun(run);
1492
+ const key = (0, conversation_store_1.conversationScopeKey)(record.scope, run.projectPath);
1493
+ this.conversationStore.upsertConversation(key, record, activeId);
1485
1494
  }
1486
1495
  catch (error) {
1487
1496
  console.warn('[ai-hub] conversation store write failed:', error instanceof Error ? error.message : error);
@@ -2061,6 +2070,19 @@ class AiHubServer {
2061
2070
  }
2062
2071
  // Read API key from header — query-param API keys are prohibited (§3.14)
2063
2072
  const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2073
+ // #719: a bare reload (no projectPath query) must land on the last-recorded
2074
+ // workspace project, not the launch folder. The bootstrap saves its resolved
2075
+ // current path back into the projects list, so defaulting to the launch
2076
+ // folder would resurrect a removed launch-folder project on every reload
2077
+ // (tfSwitchProjectFolder already documents the recorded folder as "the
2078
+ // default on next load"). The launch folder stays the fallback when nothing
2079
+ // is recorded or the recorded folder no longer exists.
2080
+ if (!projectPath) {
2081
+ const recorded = this.preferencesStore.load(this.projectPath).projectPath;
2082
+ if (recorded && path_1.default.resolve(recorded) !== path_1.default.resolve(this.projectPath) && fs_1.default.existsSync(recorded)) {
2083
+ projectPath = recorded;
2084
+ }
2085
+ }
2064
2086
  res.json(await this.bootstrapResponse(projectPath || this.projectPath, apiKey));
2065
2087
  });
2066
2088
  // Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
@@ -2146,25 +2168,32 @@ class AiHubServer {
2146
2168
  await this.remoteGateway.removeManagerTeam(apiKey, personaKey);
2147
2169
  return res.status(204).end();
2148
2170
  });
2171
+ // Issue #708: manager/company scopes resolve to a project-independent sentinel bucket
2172
+ // key (which must bypass path/dir resolution); everything else keys by project path.
2173
+ const scopeParam = (raw) => (raw === 'manager' || raw === 'company') ? raw : undefined;
2149
2174
  this.app.get('/api/ai-hub/conversations', (req, res) => {
2150
- const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2151
- ? path_1.default.resolve(req.query.projectPath)
2152
- : this.projectPath;
2153
- const loaded = this.conversationStore.loadProject(projectPath);
2154
- return res.json({ projectPath, ...loaded, source: 'disk' });
2175
+ const scope = scopeParam(req.query.scope);
2176
+ const key = scope
2177
+ ? (0, conversation_store_1.conversationScopeKey)(scope, '')
2178
+ : (typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2179
+ ? path_1.default.resolve(req.query.projectPath)
2180
+ : this.projectPath);
2181
+ const loaded = this.conversationStore.loadProject(key);
2182
+ return res.json({ projectPath: key, scope: scope ?? 'project', ...loaded, source: 'disk' });
2155
2183
  });
2156
2184
  this.app.put('/api/ai-hub/conversations', (req, res) => {
2157
2185
  try {
2158
2186
  const body = (req.body ?? {});
2159
- const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
2187
+ const scope = scopeParam(body.scope);
2188
+ const key = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(body.projectPath || this.projectPath);
2160
2189
  if (!Array.isArray(body.conversations)) {
2161
2190
  return res.status(400).json({ error: 'conversations array required' });
2162
2191
  }
2163
- const saved = this.conversationStore.replaceProject(projectPath, {
2192
+ const saved = this.conversationStore.replaceProject(key, {
2164
2193
  activeId: body.activeId ?? null,
2165
2194
  conversations: body.conversations,
2166
2195
  });
2167
- return res.json({ projectPath, ...saved, source: 'disk' });
2196
+ return res.json({ projectPath: key, scope: scope ?? 'project', ...saved, source: 'disk' });
2168
2197
  }
2169
2198
  catch (error) {
2170
2199
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversations.' });
@@ -2173,7 +2202,8 @@ class AiHubServer {
2173
2202
  this.app.patch('/api/ai-hub/conversations/:conversationId', (req, res) => {
2174
2203
  try {
2175
2204
  const body = (req.body ?? {});
2176
- const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
2205
+ const scope = scopeParam(body.scope);
2206
+ const projectPath = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(body.projectPath || this.projectPath);
2177
2207
  const saved = this.conversationStore.patchConversation(projectPath, req.params.conversationId, body);
2178
2208
  if (body.activeId !== undefined) {
2179
2209
  const withActive = this.conversationStore.replaceProject(projectPath, { ...saved, activeId: body.activeId });
@@ -2205,6 +2235,46 @@ class AiHubServer {
2205
2235
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
2206
2236
  }
2207
2237
  });
2238
+ // Issue #719: DELETE /api/ai-hub/projects/:id — remove a project from the Hub.
2239
+ // PUT is a merge by design and cannot express removal, so removal gets its own
2240
+ // route (same convention as /schedules/:id, /webhooks/:id, /hosts/:id). Deletes
2241
+ // every server-side derivation layer — preferences entry, conversation-store
2242
+ // KEY, project-scoped deployments — so the project cannot resurrect (R3/R6/R9).
2243
+ // Never touches anything under the project's folderPath (R8).
2244
+ this.app.delete('/api/ai-hub/projects/:id', (req, res) => {
2245
+ const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2246
+ ? path_1.default.resolve(req.query.projectPath)
2247
+ : this.projectPath;
2248
+ const known = this.knownProjects(projectPath);
2249
+ const entry = known.find((project) => project.id === req.params.id);
2250
+ if (!entry)
2251
+ return res.status(404).json({ error: 'Project not found.' });
2252
+ // The current projectPath is unconditionally re-injected on every load, so
2253
+ // deleting it would resurrect on the next request — reject as a sequencing
2254
+ // error; the client switches workspaces before deleting (R5/D4).
2255
+ if (sameDirectoryPath(entry.folderPath, projectPath)) {
2256
+ return res.status(409).json({ error: 'Cannot remove the current project. Switch to another project first.' });
2257
+ }
2258
+ // A live schedule/webhook would fire later, write a conversation under the
2259
+ // removed folderPath, and resurrect the project in the background (R6).
2260
+ for (const deployment of this.deploymentStore.load()) {
2261
+ if (!deploymentBelongsToProject(deployment, entry.folderPath, this.projectPath))
2262
+ continue;
2263
+ const task = this.cronHandles.get(deployment.id);
2264
+ if (task) {
2265
+ task.stop();
2266
+ this.cronHandles.delete(deployment.id);
2267
+ }
2268
+ this.deploymentStore.delete(deployment.id);
2269
+ }
2270
+ // Delete the conversation-store KEY — an empty list would still re-derive
2271
+ // the project via listProjectPaths (R9).
2272
+ this.conversationStore.removeProject(entry.folderPath);
2273
+ // Persist the filtered list directly: saveKnownProjects unions with the
2274
+ // derived list by design and cannot express removal.
2275
+ const projects = this.preferencesStore.saveProjects(projectPath, known.filter((project) => project.id !== entry.id));
2276
+ return res.json({ ok: true, projects });
2277
+ });
2208
2278
  this.app.post('/api/ai-hub/api-key', (req, res) => {
2209
2279
  const { apiKey } = req.body;
2210
2280
  if (!apiKey || typeof apiKey !== 'string')
@@ -281,6 +281,7 @@ const listSupportedIDEs = () => {
281
281
  console.log(chalk_1.default.yellow(' Example: fraim add-ide --ide claude-code'));
282
282
  console.log(chalk_1.default.yellow(' Anthropic aliases: claude, claude-code, claude-desktop, claude-cowork'));
283
283
  console.log(chalk_1.default.yellow(' Gemini aliases: gemini, gemini-cli, gemini cli'));
284
+ console.log(chalk_1.default.yellow(' GitHub Copilot CLI aliases: copilot, copilot-cli, github copilot cli'));
284
285
  };
285
286
  const promptForIDESelection = async (availableIDEs, tokens) => {
286
287
  console.log(chalk_1.default.green(`✅ Found ${availableIDEs.length} IDEs that can be configured:\n`));
@@ -385,7 +386,7 @@ const runAddIDE = async (options) => {
385
386
  const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
386
387
  if (detectedIDEs.length === 0) {
387
388
  console.log(chalk_1.default.yellow('⚠️ No supported IDEs detected on your system.'));
388
- console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, Kiro, Cursor, VSCode, Codex, Grok, Windsurf'));
389
+ console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, GitHub Copilot CLI, Kiro, Cursor, VSCode, Codex, Grok, Windsurf'));
389
390
  console.log(chalk_1.default.blue('\n💡 Install an IDE and run this command again.'));
390
391
  return;
391
392
  }
@@ -455,7 +456,7 @@ const runAddIDE = async (options) => {
455
456
  exports.runAddIDE = runAddIDE;
456
457
  exports.addIDECommand = new commander_1.Command('add-ide')
457
458
  .description('Add FRAIM configuration to additional IDEs')
458
- .option('--ide <name>', 'Configure specific IDE (claude, claude-code, claude-desktop, claude-cowork, antigravity, gemini, gemini-cli, kiro, cursor, vscode, codex, grok, windsurf)')
459
+ .option('--ide <name>', 'Configure specific IDE (claude, claude-code, claude-desktop, claude-cowork, antigravity, gemini, gemini-cli, copilot, copilot-cli, kiro, cursor, vscode, codex, grok, windsurf)')
459
460
  .option('--all', 'Configure all detected IDEs')
460
461
  .option('--list', 'List all supported IDEs and their detection status')
461
462
  .action(exports.runAddIDE);
@@ -52,6 +52,10 @@ const forEachInstalledIDE = async (callback) => {
52
52
  const writeOAuthProviderToIDEConfigs = async (provider, mcpUrl) => {
53
53
  const urlOnlyEntry = { type: 'http', url: mcpUrl };
54
54
  await forEachInstalledIDE(async (ide, configPath) => {
55
+ if (provider === 'github' && ide.configType === 'copilot-cli') {
56
+ console.log(chalk_1.default.gray(`Skipped ${ide.name} GitHub MCP entry (built into Copilot CLI)`));
57
+ return;
58
+ }
55
59
  if (ide.configFormat === 'json') {
56
60
  let ideConfig = {};
57
61
  if (fs_1.default.existsSync(configPath)) {
@@ -61,7 +65,9 @@ const writeOAuthProviderToIDEConfigs = async (provider, mcpUrl) => {
61
65
  if (!ideConfig[serversKey]) {
62
66
  ideConfig[serversKey] = {};
63
67
  }
64
- ideConfig[serversKey][provider] = urlOnlyEntry;
68
+ ideConfig[serversKey][provider] = ide.configType === 'copilot-cli'
69
+ ? { ...urlOnlyEntry, tools: ['*'] }
70
+ : urlOnlyEntry;
65
71
  fs_1.default.writeFileSync(configPath, JSON.stringify(ideConfig, null, 2));
66
72
  console.log(chalk_1.default.green(`✅ Updated ${ide.name} config`));
67
73
  }
@@ -135,7 +135,8 @@ async function cleanupStaleAdapterFiles(projectRoot, allowedConfigTypes) {
135
135
  const { promisify } = await Promise.resolve().then(() => __importStar(require('util')));
136
136
  const execFileAsync = promisify(execFile);
137
137
  for (const [relPath, configType] of Object.entries((0, agent_adapters_1.getAdapterConfigTypes)())) {
138
- if (configType === 'standard' || allowedConfigTypes.includes(configType)) {
138
+ const configTypes = Array.isArray(configType) ? configType : [configType];
139
+ if (configTypes.includes('standard') || configTypes.some((type) => allowedConfigTypes.includes(type))) {
139
140
  continue;
140
141
  }
141
142
  const fullPath = path_1.default.join(projectRoot, relPath);
@@ -2,7 +2,7 @@
2
2
  // IDE Format Adapters - transform logical server structure to IDE-specific formats
3
3
  // Uses the centralized registry to determine server types
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
- exports.IDE_FORMATS = exports.GrokFormat = exports.CodexFormat = exports.WindsurfFormat = exports.ClaudeCodeFormat = exports.ClaudeFormat = exports.GeminiCliFormat = exports.VSCodeFormat = exports.KiroFormat = exports.StandardFormat = void 0;
5
+ exports.IDE_FORMATS = exports.GrokFormat = exports.CodexFormat = exports.WindsurfFormat = exports.ClaudeCodeFormat = exports.ClaudeFormat = exports.CopilotCliFormat = exports.GeminiCliFormat = exports.VSCodeFormat = exports.KiroFormat = exports.StandardFormat = void 0;
6
6
  exports.getIDEFormat = getIDEFormat;
7
7
  const mcp_server_registry_1 = require("./mcp-server-registry");
8
8
  const provider_registry_1 = require("../providers/provider-registry");
@@ -120,6 +120,40 @@ class GeminiCliFormat {
120
120
  }
121
121
  }
122
122
  exports.GeminiCliFormat = GeminiCliFormat;
123
+ class CopilotCliFormat {
124
+ constructor() {
125
+ this.name = 'copilot-cli';
126
+ }
127
+ transform(servers) {
128
+ const mcpServers = {};
129
+ for (const [key, server] of servers) {
130
+ // GitHub MCP is built into Copilot CLI. Avoid a duplicate user-configured
131
+ // github entry and let Copilot handle its native GitHub auth path.
132
+ if (key === 'github') {
133
+ continue;
134
+ }
135
+ if (server.url) {
136
+ mcpServers[key] = {
137
+ type: server.type === 'sse' ? 'sse' : 'http',
138
+ url: server.url,
139
+ ...(server.headers && { headers: server.headers }),
140
+ tools: ['*']
141
+ };
142
+ }
143
+ else {
144
+ mcpServers[key] = {
145
+ type: 'stdio',
146
+ command: server.command,
147
+ ...(server.args && { args: server.args }),
148
+ ...(server.env && { env: server.env }),
149
+ tools: ['*']
150
+ };
151
+ }
152
+ }
153
+ return { mcpServers };
154
+ }
155
+ }
156
+ exports.CopilotCliFormat = CopilotCliFormat;
123
157
  // Claude Desktop format (excludes provider servers - Issue #132)
124
158
  class ClaudeFormat {
125
159
  constructor() {
@@ -267,6 +301,7 @@ exports.IDE_FORMATS = {
267
301
  kiro: new KiroFormat(),
268
302
  vscode: new VSCodeFormat(),
269
303
  'gemini-cli': new GeminiCliFormat(),
304
+ 'copilot-cli': new CopilotCliFormat(),
270
305
  claude: new ClaudeFormat(),
271
306
  'claude-code': new ClaudeCodeFormat(),
272
307
  windsurf: new WindsurfFormat(),
@@ -233,7 +233,7 @@ const autoConfigureMCP = async (fraimKey, selectedIDEs) => {
233
233
  const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
234
234
  if (detectedIDEs.length === 0 && (!selectedIDEs || selectedIDEs.length === 0)) {
235
235
  console.log(chalk_1.default.yellow('⚠️ No supported IDEs detected.'));
236
- console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, Kiro, Cursor, VSCode, Codex, Grok, Windsurf'));
236
+ console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, GitHub Copilot CLI, Kiro, Cursor, VSCode, Codex, Grok, Windsurf'));
237
237
  console.log(chalk_1.default.blue('\n💡 You can install an IDE and run setup again later.'));
238
238
  console.log(chalk_1.default.gray(' Or continue with manual MCP configuration.'));
239
239
  if (process.env.FRAIM_NON_INTERACTIVE) {
@@ -121,6 +121,9 @@ const detectWindsurf = () => {
121
121
  const detectGeminiCli = () => {
122
122
  return availableByVersionProbe('gemini');
123
123
  };
124
+ const detectCopilotCli = () => {
125
+ return availableByVersionProbe('copilot');
126
+ };
124
127
  const detectGeminiSurface = () => {
125
128
  const paths = [
126
129
  '~/.gemini',
@@ -130,6 +133,15 @@ const detectGeminiSurface = () => {
130
133
  ];
131
134
  return checkMultiplePaths(paths);
132
135
  };
136
+ const detectCopilotSurface = () => {
137
+ const paths = [
138
+ process.env.COPILOT_HOME ? path_1.default.join(process.env.COPILOT_HOME, 'mcp-config.json') : '',
139
+ process.env.COPILOT_HOME || '',
140
+ '~/.copilot',
141
+ '~/.copilot/mcp-config.json'
142
+ ].filter(Boolean);
143
+ return detectCopilotCli() || checkMultiplePaths(paths);
144
+ };
133
145
  const detectCodexSurface = () => {
134
146
  const paths = [
135
147
  '~/.codex',
@@ -204,6 +216,20 @@ exports.IDE_CONFIGS = [
204
216
  description: 'Google Gemini CLI local settings',
205
217
  downloadUrl: 'https://github.com/google-gemini/gemini-cli',
206
218
  },
219
+ {
220
+ name: 'GitHub Copilot CLI',
221
+ configPath: process.env.COPILOT_HOME
222
+ ? path_1.default.join(process.env.COPILOT_HOME, 'mcp-config.json')
223
+ : '~/.copilot/mcp-config.json',
224
+ configFormat: 'json',
225
+ configType: 'copilot-cli',
226
+ invocationProfile: 'instructions-only',
227
+ detectMethod: detectCopilotSurface,
228
+ supportsConfigBootstrap: true,
229
+ aliases: ['copilot', 'copilot-cli', 'github copilot', 'github copilot cli'],
230
+ description: 'GitHub Copilot CLI local MCP settings',
231
+ downloadUrl: 'https://docs.github.com/copilot/how-tos/use-copilot-agents/use-copilot-cli',
232
+ },
207
233
  {
208
234
  name: 'Kiro',
209
235
  configPath: '~/.kiro/settings/mcp.json',
@@ -305,7 +331,7 @@ const _cacheTimestamps = new Map();
305
331
  const _cacheHomeDirs = new Map();
306
332
  const DETECT_CACHE_TTL_MS = 5000;
307
333
  /** configTypes that require a binary probe in cli-runnable mode; config folder alone is insufficient. */
308
- exports.CLI_PROBE_CONFIGTYPES = new Set(['claude-code', 'codex', 'grok', 'gemini-cli']);
334
+ exports.CLI_PROBE_CONFIGTYPES = new Set(['claude-code', 'codex', 'grok', 'gemini-cli', 'copilot-cli']);
309
335
  const isDetectedForMode = (ide, mode) => {
310
336
  if (mode === 'cli-runnable') {
311
337
  switch (ide.configType) {
@@ -317,6 +343,8 @@ const isDetectedForMode = (ide, mode) => {
317
343
  return availableByVersionProbe('grok');
318
344
  case 'gemini-cli':
319
345
  return detectGeminiCli();
346
+ case 'copilot-cli':
347
+ return detectCopilotCli();
320
348
  default:
321
349
  return false;
322
350
  }
@@ -55,7 +55,7 @@ async function installSlashCommands(homeDir) {
55
55
  }
56
56
  /**
57
57
  * Install FRAIM invocation artifacts for non-Claude IDEs.
58
- * Supports: Cursor, Codex, Grok, Gemini CLI, Windsurf, Kiro
58
+ * Supports: Cursor, Codex, Grok, Gemini CLI, Copilot CLI, Windsurf, Kiro
59
59
  * Does not overwrite existing files.
60
60
  */
61
61
  async function installGlobalRules(homeDir) {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.generateMCPConfig = exports.generateWindsurfMCPServers = exports.generateGeminiCliMCPServers = exports.generateVSCodeMCPServers = exports.generateGrokMCPServers = exports.generateCodexMCPServers = exports.generateKiroMCPServers = exports.generateClaudeCodeMCPServers = exports.generateClaudeMCPServers = exports.generateStandardMCPServers = exports.mergeTomlMCPServers = exports.extractTomlMcpServerBlock = void 0;
3
+ exports.generateMCPConfig = exports.generateWindsurfMCPServers = exports.generateCopilotCliMCPServers = exports.generateGeminiCliMCPServers = exports.generateVSCodeMCPServers = exports.generateGrokMCPServers = exports.generateCodexMCPServers = exports.generateKiroMCPServers = exports.generateClaudeCodeMCPServers = exports.generateClaudeMCPServers = exports.generateStandardMCPServers = exports.mergeTomlMCPServers = exports.extractTomlMcpServerBlock = void 0;
4
4
  const mcp_server_builder_1 = require("../mcp/mcp-server-builder");
5
5
  const ide_formats_1 = require("../mcp/ide-formats");
6
6
  const normalizeTokens = (tokenInput) => {
@@ -178,6 +178,15 @@ const generateGeminiCliMCPServers = async (fraimKey, tokenInput, providerConfigs
178
178
  return format.transform(builder.getServers());
179
179
  };
180
180
  exports.generateGeminiCliMCPServers = generateGeminiCliMCPServers;
181
+ const generateCopilotCliMCPServers = async (fraimKey, tokenInput, providerConfigs) => {
182
+ const tokens = normalizeTokens(tokenInput);
183
+ const builder = new mcp_server_builder_1.MCPServerBuilder();
184
+ builder.addBaseServers(fraimKey);
185
+ await addProviderServers(builder, tokens, providerConfigs);
186
+ const format = (0, ide_formats_1.getIDEFormat)('copilot-cli');
187
+ return format.transform(builder.getServers());
188
+ };
189
+ exports.generateCopilotCliMCPServers = generateCopilotCliMCPServers;
181
190
  const generateWindsurfMCPServers = async (fraimKey, tokenInput, providerConfigs) => {
182
191
  const tokens = normalizeTokens(tokenInput);
183
192
  const builder = new mcp_server_builder_1.MCPServerBuilder();
@@ -201,6 +210,8 @@ const generateMCPConfig = async (configType, fraimKey, tokenInput, providerConfi
201
210
  return await (0, exports.generateVSCodeMCPServers)(fraimKey, tokenInput, providerConfigs);
202
211
  case 'gemini-cli':
203
212
  return await (0, exports.generateGeminiCliMCPServers)(fraimKey, tokenInput, providerConfigs);
213
+ case 'copilot-cli':
214
+ return await (0, exports.generateCopilotCliMCPServers)(fraimKey, tokenInput, providerConfigs);
204
215
  case 'codex':
205
216
  return await (0, exports.generateCodexMCPServers)(fraimKey, tokenInput, providerConfigs);
206
217
  case 'grok':
@@ -21,6 +21,9 @@ const GEMINI_FRAIM_COMMAND_PATH = path_1.default.join('.gemini', 'commands', 'fr
21
21
  const GEMINI_PROJECT_INSTRUCTIONS_PATH = path_1.default.join('.gemini', 'GEMINI.md');
22
22
  const WINDSURF_FRAIM_COMMAND_PATH = path_1.default.join('.windsurf', 'commands', 'fraim.md');
23
23
  const KIRO_FRAIM_COMMAND_PATH = path_1.default.join('.kiro', 'commands', 'fraim.md');
24
+ function adapterConfigTypes(file) {
25
+ return Array.isArray(file.configType) ? file.configType : [file.configType];
26
+ }
24
27
  function buildManagedSection(body) {
25
28
  return `${START_MARKER}
26
29
  ${body.trim()}
@@ -113,7 +116,7 @@ ${(0, ide_invocation_surfaces_1.buildFraimInvocationBody)('generic-tool-discover
113
116
  const all = [
114
117
  { path: 'AGENTS.md', content: markdownBody, configType: 'standard' },
115
118
  { path: 'CLAUDE.md', content: markdownBody, configType: 'standard' },
116
- { path: path_1.default.join('.github', 'copilot-instructions.md'), content: copilotBody, configType: 'vscode' },
119
+ { path: path_1.default.join('.github', 'copilot-instructions.md'), content: copilotBody, configType: ['vscode', 'copilot-cli'] },
117
120
  { path: CURSOR_RULE_PATH, content: cursorManagedBody, configType: 'cursor' },
118
121
  { path: VSCODE_FRAIM_PROMPT_PATH, content: vscodePrompt, configType: 'vscode' },
119
122
  { path: path_1.default.join(project_fraim_paths_1.WORKSPACE_FRAIM_DIRNAME, 'README.md'), content: fraimReadme, configType: 'standard' },
@@ -129,7 +132,10 @@ ${(0, ide_invocation_surfaces_1.buildFraimInvocationBody)('generic-tool-discover
129
132
  if (allowedConfigTypes === null) {
130
133
  return all;
131
134
  }
132
- return all.filter(f => f.configType === 'standard' || allowedConfigTypes.includes(f.configType));
135
+ return all.filter((f) => {
136
+ const types = adapterConfigTypes(f);
137
+ return types.includes('standard') || types.some((type) => allowedConfigTypes.includes(type));
138
+ });
133
139
  }
134
140
  function getAdapterConfigTypes() {
135
141
  const result = {};
@@ -53,7 +53,7 @@ exports.FIRST_RUN_AGENT_OPTIONS = [
53
53
  {
54
54
  id: 'copilot-cli',
55
55
  label: 'GitHub Copilot',
56
- detectAliases: ['copilot'],
56
+ detectAliases: ['copilot', 'copilot-cli', 'github copilot cli'],
57
57
  loginCommand: 'copilot login',
58
58
  launchCommand: 'copilot',
59
59
  installPackage: '@github/copilot',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.186",
3
+ "version": "2.0.187",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -20,6 +20,21 @@ const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements
20
20
  // listed here — a persona can run a job from a project OR from the Manager tab, and a
21
21
  // run's placement is decided per-invocation by conv.invokedArea, not by job id.
22
22
  const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements']);
23
+ // Issue #708: project-independent conversation scope buckets (match server sentinels).
24
+ const MANAGER_CONV_KEY = '@manager';
25
+ const COMPANY_CONV_KEY = '@company';
26
+ // A conversation's scope: prefer the persisted `scope`, fall back to the legacy `invokedArea`.
27
+ function convScope(conv) {
28
+ const s = conv && (conv.scope || conv.invokedArea);
29
+ return (s === 'manager' || s === 'company') ? s : 'project';
30
+ }
31
+ // Which state.conversations bucket a conversation belongs to.
32
+ function convBucketKey(conv) {
33
+ const s = convScope(conv);
34
+ if (s === 'manager') return MANAGER_CONV_KEY;
35
+ if (s === 'company') return COMPANY_CONV_KEY;
36
+ return (conv && typeof conv.projectPath === 'string' && conv.projectPath) ? conv.projectPath : state.projectPath;
37
+ }
23
38
 
24
39
  const state = {
25
40
  bootstrap: null,
@@ -120,7 +135,11 @@ async function requestJson(url, options) {
120
135
  if (!response.ok) {
121
136
  const errVal = payload && payload.error;
122
137
  const message = (errVal && typeof errVal === 'object' ? errVal.message : errVal) || `Request failed (${response.status}).`;
123
- throw new Error(message);
138
+ const error = new Error(message);
139
+ // #719: callers branch on the HTTP status (e.g. DELETE 404 already-removed vs
140
+ // 409 sequencing error) — carry it on the thrown error.
141
+ error.status = response.status;
142
+ throw error;
124
143
  }
125
144
  return payload;
126
145
  }
@@ -447,16 +466,30 @@ function projectConversationPayload() {
447
466
  }
448
467
 
449
468
  function scheduleConversationDiskPersist() {
450
- if (!state.projectPath) return;
469
+ // Issue #708: no early-return on missing projectPath — manager/company scope buckets
470
+ // must persist even when no project is selected (their whole point is project-independence).
451
471
  if (state.conversationPersistTimer) window.clearTimeout(state.conversationPersistTimer);
452
472
  state.conversationPersistTimer = window.setTimeout(async () => {
453
473
  state.conversationPersistTimer = null;
454
474
  try {
455
- await requestJson('/api/ai-hub/conversations', {
456
- method: 'PUT',
457
- headers: { 'Content-Type': 'application/json' },
458
- body: JSON.stringify(projectConversationPayload()),
459
- });
475
+ if (state.projectPath) {
476
+ await requestJson('/api/ai-hub/conversations', {
477
+ method: 'PUT',
478
+ headers: { 'Content-Type': 'application/json' },
479
+ body: JSON.stringify(projectConversationPayload()),
480
+ });
481
+ }
482
+ // Issue #708: persist scope buckets (manager/company) to their project-independent
483
+ // home so their runs survive project switches/removal and reload correctly.
484
+ for (const [scope, key] of [['manager', MANAGER_CONV_KEY], ['company', COMPANY_CONV_KEY]]) {
485
+ const bucket = state.conversations[key];
486
+ if (!Array.isArray(bucket)) continue;
487
+ await requestJson('/api/ai-hub/conversations', {
488
+ method: 'PUT',
489
+ headers: { 'Content-Type': 'application/json' },
490
+ body: JSON.stringify({ scope, activeId: null, conversations: bucket }),
491
+ });
492
+ }
460
493
  state.conversationDiskAvailable = true;
461
494
  } catch (error) {
462
495
  state.conversationDiskAvailable = false;
@@ -471,7 +504,28 @@ function persistConversations(options) {
471
504
  }
472
505
 
473
506
  async function hydrateConversationsFromServer() {
474
- if (!state.projectPath) return;
507
+ // Issue #708: load the project-independent scope buckets (manager/company) first, so
508
+ // manager runs are present regardless of whether a project is selected.
509
+ try {
510
+ for (const [scope, key] of [['manager', MANAGER_CONV_KEY], ['company', COMPANY_CONV_KEY]]) {
511
+ const scopePayload = await requestJson(`/api/ai-hub/conversations?scope=${scope}`);
512
+ const scopeConvs = Array.isArray(scopePayload.conversations) ? scopePayload.conversations : [];
513
+ for (const conv of scopeConvs) normalizeGeminiConversationMessages(conv);
514
+ // Only materialize a scope bucket when it has content (or already exists). Creating
515
+ // empty @manager/@company buckets on every project hydrate pollutes state.conversations
516
+ // and, because they land ahead of the project bucket, breaks the "project bucket is the
517
+ // primary key" invariant that project-scoped rendering relies on (#550 Watercooler).
518
+ if (scopeConvs.length || Array.isArray(state.conversations[key])) {
519
+ state.conversations[key] = scopeConvs;
520
+ }
521
+ }
522
+ } catch (error) {
523
+ console.warn('Could not hydrate manager/company conversations:', error);
524
+ }
525
+ if (!state.projectPath) {
526
+ if (typeof tfRenderManager === 'function' && typeof tf !== 'undefined' && tf.area === 'manager') tfRenderManager();
527
+ return;
528
+ }
475
529
  try {
476
530
  const payload = await requestJson(`/api/ai-hub/conversations?projectPath=${encodeURIComponent(state.projectPath)}`);
477
531
  const conversations = Array.isArray(payload.conversations) ? payload.conversations : [];
@@ -810,12 +864,15 @@ function syncConversationPanels(conv, switchedConv) {
810
864
  }
811
865
 
812
866
  function upsertConversation(conv) {
813
- const list = projectConversations().slice();
867
+ // Issue #708: route to the conversation's scope bucket (manager/company → sentinel
868
+ // key; project → its project bucket) so manager runs have a project-independent home.
869
+ const key = convBucketKey(conv);
870
+ const list = (state.conversations[key] || []).slice();
814
871
  const idx = list.findIndex((c) => c.id === conv.id);
815
872
  if (idx >= 0) list[idx] = conv;
816
873
  else list.unshift(conv);
817
- setProjectConversations(list);
818
- persistConversations();
874
+ state.conversations[key] = list;
875
+ persistConversations({ scope: convScope(conv) });
819
876
  }
820
877
 
821
878
  function newConversationId() {
@@ -922,8 +979,8 @@ function renderRail() {
922
979
  (!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
923
980
  !isManagedDelegationChild(conv) &&
924
981
  !AREA_SCOPED_JOBS.has(conv.jobId) &&
925
- // #702: runs invoked from the Manager/Company tab surface there, not in a project.
926
- conv.invokedArea !== 'manager' && conv.invokedArea !== 'company'
982
+ // #702/#708: manager/company-scoped runs surface on those tabs, not in a project.
983
+ convScope(conv) === 'project'
927
984
  );
928
985
 
929
986
  // Issue #550: Two-path routing for ad-hoc (freeform) conversations.
@@ -4883,6 +4940,12 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
4883
4940
  // where it was invoked (project-invoked → that project; manager-invoked → Manager tab).
4884
4941
  // Defaults to the current area.
4885
4942
  invokedArea: invokedArea || (typeof tf !== 'undefined' && tf.area) || 'projects',
4943
+ // Issue #708: persisted scope drives which store bucket the run lives in
4944
+ // (manager/company get a project-independent home). Derived from invocation area.
4945
+ scope: (function () {
4946
+ const a = invokedArea || (typeof tf !== 'undefined' && tf.area) || 'projects';
4947
+ return (a === 'manager' || a === 'company') ? a : 'project';
4948
+ })(),
4886
4949
  };
4887
4950
  upsertConversation(conv);
4888
4951
  state.activeId = conv.id;
@@ -6604,6 +6667,9 @@ function tfRenderOverview() {
6604
6667
  }
6605
6668
  card.appendChild(team);
6606
6669
  card.addEventListener('click', () => tfSelectProjectView('workspace', proj.id));
6670
+ // #719 R1/R1b: hover/focus-revealed remove control — Overview cards only, and
6671
+ // only while another project remains (the Hub always keeps at least one).
6672
+ if (tf.projects.length > 1) tfAttachProjectRemove(card, proj);
6607
6673
  grid.appendChild(card);
6608
6674
  }
6609
6675
  const addCard = document.createElement('div');
@@ -6616,6 +6682,167 @@ function tfRenderOverview() {
6616
6682
  tfSwitchOverviewView(storedView || (tf.projects.length >= 3 ? 'kanban' : 'cards'));
6617
6683
  }
6618
6684
 
6685
+ // ---------------------------------------------------------------------------
6686
+ // Issue #719: remove a project from the Hub (Overview card 🗑 + inline confirm)
6687
+ // ---------------------------------------------------------------------------
6688
+
6689
+ // The card-positioned sibling of tfAttachRunDelete: a hover/focus-revealed 🗑 in
6690
+ // the card's top-right. Keyboard-operable (tabindex + Enter/Space) per R11.
6691
+ function tfAttachProjectRemove(cardEl, proj) {
6692
+ if (!cardEl || !proj) return;
6693
+ const del = document.createElement('span');
6694
+ del.className = 'proj-del';
6695
+ del.textContent = '🗑';
6696
+ del.title = 'Remove this project from the Hub';
6697
+ del.setAttribute('role', 'button');
6698
+ del.setAttribute('aria-label', 'Remove project from Hub');
6699
+ del.tabIndex = 0;
6700
+ del.addEventListener('click', (e) => {
6701
+ e.preventDefault();
6702
+ e.stopPropagation();
6703
+ tfConfirmProjectRemove(proj, cardEl);
6704
+ });
6705
+ del.addEventListener('keydown', (e) => {
6706
+ if (e.key !== 'Enter' && e.key !== ' ') return;
6707
+ e.preventDefault();
6708
+ e.stopPropagation();
6709
+ tfConfirmProjectRemove(proj, cardEl);
6710
+ });
6711
+ cardEl.appendChild(del);
6712
+ }
6713
+
6714
+ function tfCloseProjectConfirm() {
6715
+ document.querySelectorAll('.del-confirm').forEach((n) => n.remove());
6716
+ document.removeEventListener('keydown', tfProjectConfirmEscape, true);
6717
+ }
6718
+ function tfProjectConfirmEscape(e) {
6719
+ if (e.key === 'Escape') tfCloseProjectConfirm();
6720
+ }
6721
+
6722
+ // Inline confirm anchored inside the card — the run-delete idiom (tfConfirmRunDelete)
6723
+ // with the project scope-of-loss copy. Loads run/schedule state from the server so
6724
+ // the copy can state the schedule count (R6) and block removal while runs are in
6725
+ // progress (R7). Nothing is deleted until the confirm button is clicked (C1).
6726
+ function tfConfirmProjectRemove(proj, cardEl) {
6727
+ tfCloseProjectConfirm();
6728
+ const box = document.createElement('div');
6729
+ box.className = 'del-confirm';
6730
+ // Any click inside the confirm must not bubble into the card's open-workspace handler.
6731
+ box.addEventListener('click', (e) => e.stopPropagation());
6732
+ const msg = document.createElement('div');
6733
+ msg.textContent = 'Remove "' + (proj.name || 'this project') + '" from the Hub? Its run history, job assignments, and Hub settings are forgotten. The project’s files on disk are untouched.';
6734
+ const detail = document.createElement('div');
6735
+ detail.className = 'dc-detail';
6736
+ detail.textContent = 'Checking for runs and schedules…';
6737
+ const row = document.createElement('div'); row.className = 'dc-row';
6738
+ const del = document.createElement('button'); del.className = 'dc-del'; del.type = 'button'; del.textContent = 'Remove project';
6739
+ del.disabled = true; // enabled once the run/schedule check resolves (C1/R7)
6740
+ del.addEventListener('click', (e) => {
6741
+ e.stopPropagation();
6742
+ if (del.disabled) return;
6743
+ tfCloseProjectConfirm();
6744
+ tfRemoveProject(proj);
6745
+ });
6746
+ const cancel = document.createElement('button'); cancel.className = 'dc-cancel'; cancel.type = 'button'; cancel.textContent = 'Cancel';
6747
+ cancel.addEventListener('click', (e) => { e.stopPropagation(); tfCloseProjectConfirm(); });
6748
+ row.appendChild(del); row.appendChild(cancel);
6749
+ box.appendChild(msg); box.appendChild(detail); box.appendChild(row);
6750
+ cardEl.appendChild(box);
6751
+ document.addEventListener('keydown', tfProjectConfirmEscape, true);
6752
+
6753
+ const encodedPath = encodeURIComponent(proj.folderPath || '');
6754
+ Promise.all([
6755
+ requestJson('/api/ai-hub/conversations?projectPath=' + encodedPath).catch(() => null),
6756
+ requestJson('/api/ai-hub/schedules?projectPath=' + encodedPath).catch(() => []),
6757
+ requestJson('/api/ai-hub/webhooks?projectPath=' + encodedPath).catch(() => []),
6758
+ ]).then(([convPayload, schedules, webhooks]) => {
6759
+ if (!box.isConnected) return;
6760
+ const serverConvs = (convPayload && Array.isArray(convPayload.conversations)) ? convPayload.conversations : [];
6761
+ const cachedConvs = state.conversations[proj.folderPath] || [];
6762
+ const running = [...serverConvs, ...cachedConvs].some((c) => c && c.status === 'running');
6763
+ if (running) {
6764
+ // R7: never forget in-flight work — block until the runs finish or are stopped.
6765
+ detail.textContent = 'Runs are in progress in this project. Stop them or wait for them to finish before removing.';
6766
+ del.disabled = true;
6767
+ return;
6768
+ }
6769
+ const scheduleCount = Array.isArray(schedules) ? schedules.length : 0;
6770
+ const webhookCount = Array.isArray(webhooks) ? webhooks.length : 0;
6771
+ const parts = [];
6772
+ if (scheduleCount) parts.push(scheduleCount + ' scheduled deployment' + (scheduleCount === 1 ? '' : 's'));
6773
+ if (webhookCount) parts.push(webhookCount + ' webhook' + (webhookCount === 1 ? '' : 's'));
6774
+ detail.textContent = parts.length ? (parts.join(' and ') + ' will also be removed.') : '';
6775
+ if (!parts.length) detail.remove();
6776
+ del.disabled = false;
6777
+ });
6778
+ }
6779
+
6780
+ // R10: adopt a server projects list as the authoritative tf.projects — a REPLACE,
6781
+ // not a tfMergeProjects union, so a later tfPersistProjects PUT cannot re-add a
6782
+ // removed project from stale in-memory state.
6783
+ function tfAdoptServerProjects(projects) {
6784
+ const normalized = (Array.isArray(projects) ? projects : [])
6785
+ .map((project) => tfNormalizeProject(project))
6786
+ .filter(Boolean);
6787
+ tf.projects = tfApplyUniqueProjectIds(normalized);
6788
+ }
6789
+
6790
+ async function tfRemoveProject(proj) {
6791
+ const removedCanonical = tfCanonicalProjectPath(proj.folderPath || '');
6792
+ // R5/D4: never delete the workspace we are standing in — the server re-injects
6793
+ // the current projectPath on every load, so switch to another project first.
6794
+ if (removedCanonical === tfCanonicalProjectPath(state.projectPath)) {
6795
+ const next = tf.projects.find((p) => p.id !== proj.id && tfCanonicalProjectPath(p.folderPath || '') !== removedCanonical);
6796
+ if (!next) { showStatus('Cannot remove the only project in the Hub.', true); return; }
6797
+ try {
6798
+ await tfSwitchProjectFolder(next.folderPath);
6799
+ tf.activeProjectId = next.id;
6800
+ } catch (e) {
6801
+ showStatus('Could not switch to another project first; nothing was removed.', true);
6802
+ return;
6803
+ }
6804
+ }
6805
+
6806
+ let payload;
6807
+ try {
6808
+ payload = await requestJson(
6809
+ '/api/ai-hub/projects/' + encodeURIComponent(proj.id) + '?projectPath=' + encodeURIComponent(state.projectPath),
6810
+ { method: 'DELETE' },
6811
+ );
6812
+ } catch (error) {
6813
+ if (error && error.status === 404) {
6814
+ // Already removed elsewhere (double-click / second tab): treat as success
6815
+ // for rendering and adopt the server's current list.
6816
+ try { payload = { projects: (await requestJson('/api/ai-hub/projects?projectPath=' + encodeURIComponent(state.projectPath))).projects }; }
6817
+ catch { payload = { projects: tf.projects.filter((p) => p.id !== proj.id) }; }
6818
+ showStatus('That project was already removed.', false);
6819
+ } else if (error && error.status === 409) {
6820
+ showStatus('Switch to another project first, then remove this one.', true);
6821
+ return;
6822
+ } else {
6823
+ // Server unreachable or rejected: no optimistic client mutation.
6824
+ showStatus('Could not remove the project: ' + (error && error.message ? error.message : 'request failed.'), true);
6825
+ return;
6826
+ }
6827
+ }
6828
+
6829
+ // R3/R10: drop every client layer, adopting the server list as authoritative.
6830
+ tfAdoptServerProjects(payload && payload.projects);
6831
+ delete tf.assignments[proj.id];
6832
+ tfPersistAssignments();
6833
+ for (const key of Object.keys(state.conversations || {})) {
6834
+ if (tfCanonicalProjectPath(key) === removedCanonical) delete state.conversations[key];
6835
+ }
6836
+ if (!tf.projects.some((p) => p.id === tf.activeProjectId)) {
6837
+ tf.activeProjectId = tf.projects[0] ? tf.projects[0].id : null;
6838
+ }
6839
+ tfPersistProjects();
6840
+ tfRenderProjectTabs();
6841
+ tfRenderOverview();
6842
+ if (typeof renderRail === 'function') renderRail();
6843
+ showStatus('"' + (proj.name || 'Project') + '" was removed from the Hub. Its files on disk are untouched.', false);
6844
+ }
6845
+
6619
6846
  // ---------------------------------------------------------------------------
6620
6847
  // Issue #671: Kanban view for Projects Overview
6621
6848
  // ---------------------------------------------------------------------------
@@ -8121,7 +8348,7 @@ function tfActiveOrgConv() {
8121
8348
  // plus the area-only manager-agreements job (older runs predate invokedArea). An active
8122
8349
  // manager-invoked run surfaces in the Manager conversation host (Coach panel included).
8123
8350
  function tfActiveMgrConv() {
8124
- const convs = Object.values(state.conversations || {}).flat().filter((c) => c && (c.invokedArea === 'manager' || c.jobId === 'manager-agreements'));
8351
+ const convs = Object.values(state.conversations || {}).flat().filter((c) => c && (convScope(c) === 'manager' || c.jobId === 'manager-agreements'));
8125
8352
  return (
8126
8353
  convs.find((c) => c.status === 'running') ||
8127
8354
  convs.find((c) => c.status === 'waiting') ||
@@ -8177,7 +8404,7 @@ function tfBuildManagerRunItem(conv) {
8177
8404
  function tfRenderManagerPersonaGroup(rail, persona) {
8178
8405
  const sample = { personaKey: persona.key };
8179
8406
  const runs = Object.values(state.conversations || {}).flat()
8180
- .filter((c) => c && c.personaKey === persona.key && c.invokedArea === 'manager')
8407
+ .filter((c) => c && c.personaKey === persona.key && convScope(c) === 'manager')
8181
8408
  .sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
8182
8409
  const details = document.createElement('details');
8183
8410
  details.className = 'conv-employee-group';
@@ -3065,7 +3065,26 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
3065
3065
  .del-confirm .dc-row { display: flex; gap: 8px; margin-top: 8px; }
3066
3066
  .del-confirm button { font: inherit; font-size: 12px; border-radius: 7px; padding: 5px 12px; cursor: pointer; }
3067
3067
  .del-confirm .dc-del { background: var(--danger, #d2261f); color: #fff; border: none; }
3068
+ .del-confirm .dc-del:disabled { opacity: .55; cursor: not-allowed; }
3068
3069
  .del-confirm .dc-cancel { background: var(--surface); border: 1px solid var(--line); color: var(--text); }
3070
+ .del-confirm .dc-detail { margin-top: 6px; color: var(--muted); }
3071
+ .del-confirm .dc-detail:empty { display: none; }
3072
+
3073
+ /* #719 — remove a project from the Hub. The card-positioned copy of .run-del.
3074
+ Hidden via opacity (not display) so the control stays keyboard-focusable
3075
+ (R11: revealed on hover AND on keyboard focus). */
3076
+ .proj-card { position: relative; }
3077
+ .proj-card .proj-del { position: absolute; right: 10px; top: 10px;
3078
+ width: 22px; height: 22px; border-radius: 6px; border: 1px solid var(--line);
3079
+ background: var(--surface); color: var(--muted); font-size: 12px; line-height: 1;
3080
+ display: flex; align-items: center; justify-content: center; cursor: pointer;
3081
+ opacity: 0; pointer-events: none; }
3082
+ .proj-card:hover .proj-del, .proj-card .proj-del:focus-visible, .proj-card .proj-del:focus {
3083
+ opacity: 1; pointer-events: auto; }
3084
+ .proj-card .proj-del:hover { color: var(--danger, #d2261f); border-color: var(--danger, #d2261f); }
3085
+ /* Inside a card the confirm sits below the team row — zero the horizontal
3086
+ margin the run-list variant carries. */
3087
+ .proj-card .del-confirm { margin: 10px 0 0; }
3069
3088
 
3070
3089
  /* R1 (#521): employees are the heroes — large accordion rows replace .tree-emp.
3071
3090
  The summary is the employee (40px avatar, name, role, status dot); their jobs