fraim-hub 2.0.286 → 2.0.288

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.
@@ -82,6 +82,22 @@ function parseSeekMentoringSignal(line) {
82
82
  return sig;
83
83
  }
84
84
  }
85
+ // Copilot CLI shape (issue #1399): { type: 'tool.execution_start', data: {
86
+ // mcpServerName, mcpToolName, arguments } }. Live-verified via a direct
87
+ // spike against the installed CLI under --output-format json (see
88
+ // docs/rfcs/1399-copilot-host-chattiness-technical-design.md) — Copilot
89
+ // reports the MCP server and tool name as separate, unambiguous fields
90
+ // rather than a qualifier string, so this reads data.mcpToolName directly
91
+ // instead of going through canonicalToolName's __/./: splitting.
92
+ if (obj.type === 'tool.execution_start' && typeof obj.data === 'object' && obj.data !== null) {
93
+ const data = obj.data;
94
+ if (isFraimTool(data.mcpToolName, 'seekMentoring')) {
95
+ const args = normalizeToolArgs(data.arguments) || undefined;
96
+ const sig = extractSignalFromArgs(args);
97
+ if (sig)
98
+ return sig;
99
+ }
100
+ }
85
101
  // Claude Code shape: tool_use blocks live inside parsed.message.content.
86
102
  // The name is MCP-prefixed (e.g. 'mcp__fraim__seekMentoring').
87
103
  const candidates = [obj];
@@ -133,6 +149,16 @@ function parseFraimJobLoadSignal(line) {
133
149
  return sig;
134
150
  }
135
151
  }
152
+ // Copilot CLI shape (issue #1399): see parseSeekMentoringSignal's Copilot
153
+ // block above for the field-shape rationale.
154
+ if (obj.type === 'tool.execution_start' && typeof obj.data === 'object' && obj.data !== null) {
155
+ const data = obj.data;
156
+ if (isFraimTool(data.mcpToolName, 'get_fraim_job')) {
157
+ const sig = readFraimJobFromArgs(data.arguments);
158
+ if (sig)
159
+ return sig;
160
+ }
161
+ }
136
162
  // Claude Code shape: tool_use blocks live inside message.content with
137
163
  // an MCP-prefixed name such as mcp__fraim__get_fraim_job.
138
164
  const candidates = [obj];
@@ -289,6 +315,14 @@ function parseAgentIdentitySignal(line) {
289
315
  return readAgentFromArgs(normalizeToolArgs(item.arguments) || undefined);
290
316
  }
291
317
  }
318
+ // Copilot CLI shape (issue #1399): see parseSeekMentoringSignal's Copilot
319
+ // block for the field-shape rationale.
320
+ if (obj.type === 'tool.execution_start' && typeof obj.data === 'object' && obj.data !== null) {
321
+ const data = obj.data;
322
+ if (isFraimTool(data.mcpToolName, 'fraim_connect')) {
323
+ return readAgentFromArgs(normalizeToolArgs(data.arguments) || undefined);
324
+ }
325
+ }
292
326
  // Claude Code shape.
293
327
  const candidates = [obj];
294
328
  if (typeof obj.message === 'object' && obj.message !== null) {
@@ -1434,7 +1468,12 @@ function buildStartPlan(hostId, message, sessionId) {
1434
1468
  const browser = sharedBrowserHostConfig('copilot');
1435
1469
  return {
1436
1470
  command: COPILOT_BINARY,
1437
- args: ['--yolo', ...browser.args],
1471
+ // Issue #1399: request Copilot's structured JSONL output mode
1472
+ // (one JSON object per line) instead of its default human-
1473
+ // readable terminal rendering, so parseHostLine can classify
1474
+ // tool calls/results/deltas separately from genuine reply text
1475
+ // instead of pattern-matching rendered chrome characters.
1476
+ args: ['--yolo', '--output-format', 'json', ...browser.args],
1438
1477
  stdin: transformHeadlessFraimMessage(message, 'start'),
1439
1478
  env: browser.env,
1440
1479
  };
@@ -1495,7 +1534,8 @@ function buildContinuePlan(hostId, sessionId, message) {
1495
1534
  const browser = sharedBrowserHostConfig('copilot');
1496
1535
  return {
1497
1536
  command: COPILOT_BINARY,
1498
- args: ['--yolo', '--resume', sessionId, ...browser.args],
1537
+ // Issue #1399: see buildStartPlan's copilot branch for rationale.
1538
+ args: ['--yolo', '--output-format', 'json', '--resume', sessionId, ...browser.args],
1499
1539
  stdin: transformHeadlessFraimMessage(message, 'continue'),
1500
1540
  env: browser.env,
1501
1541
  };
@@ -1562,9 +1602,10 @@ function buildDirectStartPlan(hostId, message, sessionId) {
1562
1602
  }
1563
1603
  if (hostId === 'copilot') {
1564
1604
  // Direct (A/B) mode for Copilot: headless, no FRAIM MCP wiring.
1605
+ // Issue #1399: --output-format json for the same reason as buildStartPlan.
1565
1606
  return {
1566
1607
  command: COPILOT_BINARY,
1567
- args: ['--yolo'],
1608
+ args: ['--yolo', '--output-format', 'json'],
1568
1609
  stdin: DIRECT_PREAMBLE + message,
1569
1610
  };
1570
1611
  }
@@ -1608,9 +1649,10 @@ function buildDirectContinuePlan(hostId, sessionId, message) {
1608
1649
  }
1609
1650
  if (hostId === 'copilot') {
1610
1651
  // Direct continue mode for Copilot: resume session, no FRAIM MCP wiring.
1652
+ // Issue #1399: --output-format json for the same reason as buildStartPlan.
1611
1653
  return {
1612
1654
  command: COPILOT_BINARY,
1613
- args: ['--yolo', '--resume', sessionId],
1655
+ args: ['--yolo', '--output-format', 'json', '--resume', sessionId],
1614
1656
  stdin: DIRECT_PREAMBLE + message,
1615
1657
  };
1616
1658
  }
@@ -1723,31 +1765,69 @@ function parseHostLine(hostId, line) {
1723
1765
  if (hostId === 'antigravity') {
1724
1766
  return withSignal({ raw: trimmed });
1725
1767
  }
1726
- // GitHub Copilot CLI output: JSON stream where each event carries a `type`
1727
- // field. Known event shapes (from the agentic CLI stream):
1728
- // { "type": "session.started", "session_id": "..." } — session id
1729
- // { "type": "message", "role": "assistant", "content": "..." } reply text
1730
- // { "type": "turn.completed", "usage": { ... } } — token usage (same shape as Codex)
1731
- // For any JSON event not matching the above, signal scanning (seekMentoring,
1732
- // agent identity) still runs because withSignal is applied to every parsed result.
1733
- // Non-JSON lines from Copilot are treated as plain-text employee messages.
1768
+ // GitHub Copilot CLI output (issue #1399): Hub invokes Copilot with
1769
+ // --output-format json (see buildStartPlan's copilot branch), which
1770
+ // reports every event as one JSON object per line. Two shapes from the
1771
+ // original #531 integration are kept as defensive fallbacks never
1772
+ // confirmed against real traffic, but harmless to keep recognizing if a
1773
+ // future CLI version or mode emits them:
1774
+ // { "type": "session.started", "session_id": "..." }
1775
+ // { "type": "message", "role": "assistant", "content": "..." }
1776
+ // The real vocabulary below was captured via a live spike against the
1777
+ // installed CLI binary (see "Spike Findings" in
1778
+ // docs/rfcs/1399-copilot-host-chattiness-technical-design.md):
1779
+ // { "type": "assistant.message", "data": { "content": "...", "toolRequests": [...] } }
1780
+ // — the genuine reply. data.content is the message text; toolRequests
1781
+ // is metadata about an about-to-run tool call, not display text.
1782
+ // { "type": "result", "sessionId": "...", "exitCode": 0, "usage": {...} }
1783
+ // — terminal turn/session summary. This is the only place sessionId
1784
+ // was observed in a single-shot run (no early session-id event fired).
1785
+ // { "type": "tool.execution_start" | "tool.execution_complete", "data": {...} },
1786
+ // { "type": "model.tool_execution", "data": {...} }
1787
+ // — tool invocation and result. Diagnostic only, never a message.
1788
+ // seekMentoring/get_fraim_job/fraim_connect signal extraction (via
1789
+ // withSignal below) reads the call arguments off tool.execution_start's
1790
+ // data.arguments/data.mcpServerName/data.mcpToolName.
1791
+ // { "type": "assistant.message_delta" | "assistant.tool_call_delta", ...,
1792
+ // "ephemeral": true }
1793
+ // — streaming deltas. The CLI's own schema marks these ephemeral, an
1794
+ // explicit signal (not a heuristic) that they are safe to drop.
1795
+ // session.*/model.*/user.message/assistant.turn_*/assistant.idle
1796
+ // — setup, bookkeeping, and input-echo diagnostics.
1797
+ // Any other/unrecognized type, and any line that fails JSON.parse entirely,
1798
+ // fails CLOSED (raw only) — matching Codex's and Claude's existing
1799
+ // fail-closed default. This replaces the prior fail-OPEN catch-all that
1800
+ // promoted arbitrary unrecognized Copilot text (in practice, ~80% of a
1801
+ // real run's lines — the CLI's own tool-call/result terminal rendering) to
1802
+ // a manager-visible message.
1734
1803
  if (hostId === 'copilot') {
1804
+ let parsed;
1735
1805
  try {
1736
- const parsed = JSON.parse(trimmed);
1737
- if (parsed.type === 'session.started' && typeof parsed.session_id === 'string' && parsed.session_id.length > 0) {
1738
- return withSignal({ sessionId: parsed.session_id, raw: trimmed });
1739
- }
1740
- if (parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string') {
1741
- return withSignal({ message: parsed.content, raw: trimmed });
1742
- }
1743
- // All other JSON events: apply signal scanning and surface as raw.
1744
- return withSignal({ raw: trimmed });
1806
+ parsed = JSON.parse(trimmed);
1745
1807
  }
1746
1808
  catch {
1747
- // Non-JSON line from Copilot: treat as a plain-text employee message,
1748
- // same pattern as Gemini CLI's non-JSON output.
1749
- return withSignal({ message: trimmed, raw: trimmed });
1809
+ return withSignal({ raw: trimmed });
1810
+ }
1811
+ if (!parsed || typeof parsed !== 'object')
1812
+ return withSignal({ raw: trimmed });
1813
+ if (parsed.type === 'session.started' && typeof parsed.session_id === 'string' && parsed.session_id.length > 0) {
1814
+ return withSignal({ sessionId: parsed.session_id, raw: trimmed });
1815
+ }
1816
+ if (parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string') {
1817
+ return withSignal({ message: parsed.content, raw: trimmed });
1818
+ }
1819
+ if (parsed.type === 'assistant.message') {
1820
+ const content = typeof parsed.data?.content === 'string' ? parsed.data.content : undefined;
1821
+ return content ? withSignal({ message: content, raw: trimmed }) : withSignal({ raw: trimmed });
1750
1822
  }
1823
+ if (parsed.type === 'result') {
1824
+ const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId.length > 0 ? parsed.sessionId : undefined;
1825
+ return withSignal({ sessionId, raw: trimmed });
1826
+ }
1827
+ // tool.execution_start/complete, model.tool_execution, the *_delta
1828
+ // events, and session/model/user/turn bookkeeping all fall here:
1829
+ // diagnostic-only, but still signal-scanned by withSignal above.
1830
+ return withSignal({ raw: trimmed });
1751
1831
  }
1752
1832
  try {
1753
1833
  const parsed = JSON.parse(trimmed);
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HubConnectorStatusStore = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
+ exports.selfHealBrokenManagedAgents = selfHealBrokenManagedAgents;
40
41
  exports.configureFraimForHubAgent = configureFraimForHubAgent;
41
42
  exports.hubCommandVersion = hubCommandVersion;
42
43
  exports.buildOpenFileInvocation = buildOpenFileInvocation;
@@ -1699,6 +1700,35 @@ function hubAgentOption(hubId) {
1699
1700
  const frId = HUB_TO_FIRST_RUN_ID[hubId];
1700
1701
  return frId ? types_1.FIRST_RUN_AGENT_OPTIONS.find((o) => o.id === frId) : undefined;
1701
1702
  }
1703
+ const defaultSelfHealDeps = {
1704
+ checkHealth: agent_cli_health_checks_1.checkAgentCliHealthByCommand,
1705
+ install: (option, systemPath) => (0, managed_agent_install_1.installManagedAgent)(option, systemPath, { runProcess: hubRunProcess, commandVersion: hubCommandVersion }),
1706
+ invalidateCache: hosts_1.invalidateEmployeeDetectionCache,
1707
+ };
1708
+ async function selfHealBrokenManagedAgents(deps = defaultSelfHealDeps) {
1709
+ for (const hubId of ['claude', 'codex']) {
1710
+ try {
1711
+ const health = await deps.checkHealth(hubId);
1712
+ if (!health || health.status !== 'error')
1713
+ continue;
1714
+ const details = health.details;
1715
+ if (!details?.managedPath || details.managedVersion)
1716
+ continue;
1717
+ const option = hubAgentOption(hubId);
1718
+ if (!option || !option.installPackage)
1719
+ continue;
1720
+ console.warn(`[ai-hub] self-heal: ${option.label}'s FRAIM-managed install is broken (${health.message}) — attempting automatic reinstall`);
1721
+ const systemPath = (0, managed_agent_paths_1.stripManagedAgentBinDirsFromPath)(process.env.PATH);
1722
+ await deps.install({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath);
1723
+ deps.invalidateCache();
1724
+ const verified = await deps.checkHealth(hubId);
1725
+ console.warn(`[ai-hub] self-heal: ${option.label} reinstall ${verified?.status === 'error' ? 'did NOT fix it — manual reinstall needed' : 'succeeded'}`);
1726
+ }
1727
+ catch (error) {
1728
+ console.warn(`[ai-hub] self-heal check failed for ${hubId}:`, error instanceof Error ? error.message : String(error));
1729
+ }
1730
+ }
1731
+ }
1702
1732
  /**
1703
1733
  * Issue #747: after the Hub installs an agent CLI, run the `add-ide` command for that agent so the
1704
1734
  * FRAIM MCP (plus slash commands / rules) is wired into its config and its first run works.
@@ -1916,6 +1946,24 @@ function buildManagedLoginCommand(command) {
1916
1946
  function getUserHubDir() {
1917
1947
  return path_1.default.join(os_1.default.homedir(), '.fraim');
1918
1948
  }
1949
+ // Issue #1345: recursively find first file matching name under dir.
1950
+ function findFileRecursive(dir, filename) {
1951
+ try {
1952
+ for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
1953
+ const full = path_1.default.join(dir, entry.name);
1954
+ if (entry.isDirectory()) {
1955
+ const hit = findFileRecursive(full, filename);
1956
+ if (hit)
1957
+ return hit;
1958
+ }
1959
+ else if (entry.isFile() && entry.name === filename) {
1960
+ return full;
1961
+ }
1962
+ }
1963
+ }
1964
+ catch { /* ignore unreadable dirs */ }
1965
+ return null;
1966
+ }
1919
1967
  function ensureDirectoryPath(projectPath) {
1920
1968
  const trimmed = (projectPath || '').trim();
1921
1969
  if (!trimmed) {
@@ -2681,6 +2729,17 @@ class AiHubServer {
2681
2729
  void (0, hosts_1.detectEmployeesAsync)({ force: true }).catch((error) => {
2682
2730
  console.warn('[ai-hub] agent availability priming failed:', error?.message || error);
2683
2731
  });
2732
+ // Issue #1412: fire-and-forget self-heal of any broken FRAIM-managed CLI
2733
+ // install (e.g. an interrupted Claude Code/Codex update left a
2734
+ // non-executable launcher). Never blocks or fails startup. Skipped in
2735
+ // NODE_ENV=test so the test suite's many short-lived Hub instances never
2736
+ // trigger a real `npm install -g`, matching this file's existing
2737
+ // NODE_ENV-gated test-safety pattern (see hubCommandVersion/hubRunProcess).
2738
+ if (process.env.NODE_ENV !== 'test') {
2739
+ void selfHealBrokenManagedAgents().catch((error) => {
2740
+ console.warn('[ai-hub] self-heal pass failed:', error?.message || error);
2741
+ });
2742
+ }
2684
2743
  // Issue #578: rehydrate active scheduled deployments from disk. Test and preview
2685
2744
  // servers that inject a fake host but not a deployment store should not run the
2686
2745
  // user's real scheduled deployments from the default store.
@@ -3242,6 +3301,7 @@ class AiHubServer {
3242
3301
  // so completed conversations render "What's next?" chips after reload.
3243
3302
  nextJobRecommendations: run.nextJobRecommendations || null,
3244
3303
  issueNumber: run.issueNumber ?? null,
3304
+ executionMode: run.executionMode || null,
3245
3305
  managedByRunId: run.managedByRunId || null,
3246
3306
  managedByPersonaKey: run.managedByPersonaKey || null,
3247
3307
  humanCoachingDisabled: run.humanCoachingDisabled || false,
@@ -5686,6 +5746,25 @@ class AiHubServer {
5686
5746
  return res.status(404).json({ error: 'Configured agent not found.' });
5687
5747
  return res.json((0, configured_agents_1.checkConfiguredAgentReadiness)(agent, employees));
5688
5748
  });
5749
+ // Issue #1345: accept executionMode forwarded from get_fraim_job response by the local proxy.
5750
+ // The proxy parses the Execution Mode Context block and POSTs here; Hub script reads from run poll.
5751
+ this.app.post('/api/ai-hub/runs/by-session/:sessionId/execution-mode', (req, res) => {
5752
+ if (!this.requireTrustedHubOrigin(req, res))
5753
+ return;
5754
+ const { sessionId } = req.params;
5755
+ const { mode, completedRuns } = req.body;
5756
+ if (!sessionId)
5757
+ return res.status(400).json({ error: 'sessionId required' });
5758
+ const normalizedMode = mode === 'trusted' ? 'trusted' : 'coached';
5759
+ const normalizedRuns = typeof completedRuns === 'number' ? completedRuns : 0;
5760
+ const run = this.runRegistry.all().find((r) => r.sessionId === sessionId);
5761
+ if (!run)
5762
+ return res.status(404).json({ error: 'Run not found for session.' });
5763
+ this.runRegistry.update(run.id, (current) => {
5764
+ current.executionMode = { mode: normalizedMode, completedRuns: normalizedRuns };
5765
+ });
5766
+ return res.json({ ok: true });
5767
+ });
5689
5768
  this.app.post('/api/ai-hub/runs', (req, res) => {
5690
5769
  try {
5691
5770
  // Issue #892: project-independent (manager/company) runs resolve a working dir
@@ -5726,6 +5805,26 @@ class AiHubServer {
5726
5805
  if (!jobId) {
5727
5806
  throw new Error('Choose a FRAIM job before starting a run.');
5728
5807
  }
5808
+ // #1394: Dedup guard — return an already-running identical run rather than spawning a second
5809
+ // process. Key includes managerDisplay (messages[0].text) so runs for different issues are
5810
+ // never blocked even when they share the same jobId + hostId + projectPath.
5811
+ // Window of 300 ms: UI race duplicates arrive within ~100 ms (React render cycle);
5812
+ // sequential test runs with the same params are separated by 600 ms+ (test body +
5813
+ // beforeEach page.goto), so the window never fires across test boundaries.
5814
+ const MANAGER_DEDUP_WINDOW_MS = 300;
5815
+ if ((req.body.sourceTrigger ?? 'manager') === 'manager') {
5816
+ const now = Date.now();
5817
+ const activeRun = this.runRegistry.all().find((r) => r.status === 'running'
5818
+ && r.projectPath === projectPath
5819
+ && r.jobId === jobId
5820
+ && r.hostId === hostId
5821
+ && r.messages[0]?.text === managerDisplay
5822
+ && now - Date.parse(r.createdAt) < MANAGER_DEDUP_WINDOW_MS);
5823
+ if (activeRun) {
5824
+ console.warn('[ai-hub] hub.duplicate_run_blocked', { projectPath, jobId, hostId, existingRunId: activeRun.id });
5825
+ return res.json(this.enrichRunForResponse(activeRun));
5826
+ }
5827
+ }
5729
5828
  const startTimestamp = new Date().toISOString();
5730
5829
  const jobMetadata = this.resolveHubJob(projectPath, jobId);
5731
5830
  const fallbackJobTitle = typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim()
@@ -5765,7 +5864,9 @@ class AiHubServer {
5765
5864
  phaseVisits: [],
5766
5865
  totals: emptyTotals(),
5767
5866
  lastStatusChangeAt: startTimestamp,
5768
- personaKey: jobMetadata?.personaKey ?? getHubPersonaForJob(jobId),
5867
+ // Issue #1357: fall back to custom persona resolution before the catalog lookup
5868
+ // so custom employee owners (e.g. SidCoder for feature-implementation) are used.
5869
+ personaKey: jobMetadata?.personaKey ?? getCustomPersonaForJob(projectPath, jobId) ?? getHubPersonaForJob(jobId),
5769
5870
  // Issue #892: persist the invocation scope so the run is routed to the right
5770
5871
  // conversation bucket (manager/company get a project-independent home) and so
5771
5872
  // the resolved fallback working dir is never mistaken for the active project.
@@ -6023,6 +6124,13 @@ class AiHubServer {
6023
6124
  if (reviewApprovalSystemEventText)
6024
6125
  current.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventText));
6025
6126
  current.events.push((0, hosts_1.createHubEvent)('system', 'No resumable session - starting a fresh agent turn from conversation context.'));
6127
+ // Issue #1373 (Defect 2): nextJobRecommendations can only be stale
6128
+ // here — it is only ever set at retrospective (issue #848), and a
6129
+ // job that just received coaching has not reached retrospective
6130
+ // again yet. Clear it at send-time so a coached Done job stops
6131
+ // offering next-job recommendations immediately, not only once the
6132
+ // resumed session's next seekMentoring signal arrives.
6133
+ current.nextJobRecommendations = null;
6026
6134
  });
6027
6135
  const startedFresh = this.runRegistry.get(run.id);
6028
6136
  if (startedFresh)
@@ -6079,6 +6187,9 @@ class AiHubServer {
6079
6187
  current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message, deliveryStatus));
6080
6188
  if (reviewApprovalSystemEventText)
6081
6189
  current.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventText));
6190
+ // Issue #1373 (Defect 2): see the no-session branch above for why
6191
+ // this can only be stale at coaching-send time.
6192
+ current.nextJobRecommendations = null;
6082
6193
  });
6083
6194
  const started = this.runRegistry.get(run.id);
6084
6195
  if (started)
@@ -6221,17 +6332,28 @@ class AiHubServer {
6221
6332
  messages: persistedConversation ? persistedMessagesForRun(persistedConversation) : [],
6222
6333
  events: resumeEvents,
6223
6334
  eventLogRefs: persistedConversation?.eventLogRefs || [],
6224
- // Only carry phase state forward when the prior run was interrupted mid-job
6225
- // (status !== 'completed'). A completed conversation is a finished run;
6226
- // resuming the session starts a fresh job, so the tracker must be blank.
6227
- currentPhase: persistedConversation?.status !== 'completed' ? (persistedRun?.currentPhase || null) : null,
6228
- phaseHistory: persistedConversation?.status !== 'completed' ? (persistedRun?.phaseHistory || []) : [],
6229
- phaseVisits: persistedConversation?.status !== 'completed' ? (persistedRun?.phaseVisits || []) : [],
6335
+ // Issue #1373: a conversation is permanently scoped to one job for its
6336
+ // lifetime, including after that job's conversation is marked
6337
+ // 'completed' a coaching turn sent to a Done job resumes the SAME
6338
+ // job's phase history, it does not start unrelated new work. Always
6339
+ // carry the persisted run projection forward regardless of the
6340
+ // conversation's prior status. If the resumed session genuinely has
6341
+ // nothing left to do, it simply reaches the same completed state
6342
+ // again on its own the next time seekMentoring reports its phase.
6343
+ currentPhase: persistedRun?.currentPhase || null,
6344
+ phaseHistory: persistedRun?.phaseHistory || [],
6345
+ phaseVisits: persistedRun?.phaseVisits || [],
6230
6346
  totals: persistedRun?.totals || emptyTotals(),
6231
6347
  lastStatusChangeAt: now,
6232
- runDiscriminant: persistedConversation?.status !== 'completed' ? (persistedRun?.runDiscriminant || undefined) : undefined,
6233
- personaKey: getHubPersonaForJob(jobId),
6348
+ runDiscriminant: persistedRun?.runDiscriminant || undefined,
6349
+ // Issue #1357: consult custom persona owner before falling back to catalog.
6350
+ personaKey: getCustomPersonaForJob(projectPath, jobId) ?? getHubPersonaForJob(jobId),
6234
6351
  continuityDecision: conversationId ? 'same_continuity' : 'new_conversation',
6352
+ // Issue #1373 (Defect 2): nextJobRecommendations are only ever set at
6353
+ // retrospective/completion (issue #848); a resumed run has not reached
6354
+ // retrospective, so this is always null here. Explicit for readability
6355
+ // and to make the invariant visible next to the Defect 1 fix above.
6356
+ nextJobRecommendations: null,
6235
6357
  };
6236
6358
  host_session_state_1.hostSessionState.applySession(run, { configuredAgentId: configuredAgent.id, baseHostId: hostId }, sessionId, { sourceRunId: run.id, status: resolvedHostSession?.status || 'suspect' });
6237
6359
  // Continue-turn message (FRAIM invocation for the job + instructions) plus
@@ -7025,7 +7147,8 @@ class AiHubServer {
7025
7147
  phaseVisits: [],
7026
7148
  totals: emptyTotals(),
7027
7149
  lastStatusChangeAt: startTimestamp,
7028
- personaKey: getHubPersonaForJob(jobName),
7150
+ // Issue #1357: consult custom persona owner before falling back to catalog.
7151
+ personaKey: getCustomPersonaForJob(projectPath, jobName) ?? getHubPersonaForJob(jobName),
7029
7152
  };
7030
7153
  // Register the run before spawning so onEvent/onExit callbacks can
7031
7154
  // safely call update() even if they fire synchronously (FakeHostRuntime).
@@ -7178,7 +7301,8 @@ class AiHubServer {
7178
7301
  phaseVisits: [],
7179
7302
  totals: emptyTotals(),
7180
7303
  lastStatusChangeAt: startTimestamp,
7181
- personaKey: jobMetadata?.personaKey ?? getHubPersonaForJob(jobId),
7304
+ // Issue #1357: fall back to custom persona resolution before the catalog lookup.
7305
+ personaKey: jobMetadata?.personaKey ?? getCustomPersonaForJob(deployment.projectPath, jobId) ?? getHubPersonaForJob(jobId),
7182
7306
  };
7183
7307
  // Pre-register before startRun so synchronous onEvent calls (e.g. FakeHostRuntime)
7184
7308
  // can call runRegistry.update without "Run not found" throws.
@@ -97,6 +97,27 @@ async function runAgentCliHealthCheck(cli) {
97
97
  : managedPath === ambientPath
98
98
  ? ambientVersion
99
99
  : probeVersion(managedPath);
100
+ // Issue #1412: a resolved file that fails to execute at all (e.g. an
101
+ // interrupted update left a non-executable fallback launcher) is a harder
102
+ // failure than drift — `getSystemCommandPath()` only proves the file
103
+ // exists, not that it can run. Without this check, a single broken install
104
+ // with nothing else on PATH to disagree with (ambientPath === managedPath,
105
+ // both versions null) falls straight through the mismatch check below and
106
+ // reads as "consistent". Check this before computing npm-global/drift
107
+ // details that don't matter once the CLI can't run at all.
108
+ const brokenPath = ambientPath && !ambientVersion
109
+ ? ambientPath
110
+ : managedPath && !managedVersion
111
+ ? managedPath
112
+ : null;
113
+ if (brokenPath) {
114
+ return {
115
+ status: 'error',
116
+ message: `${cli.label} is installed at ${brokenPath} but failed to run (\`${cli.command} --version\` produced no output). An interrupted or partial update may have left a non-functional launcher.`,
117
+ suggestion: `Reinstall ${cli.label}, then run "${cli.command} --version" again to confirm it's fixed.`,
118
+ details: { ambientPath, ambientVersion, managedPath, managedVersion },
119
+ };
120
+ }
100
121
  const npmGlobalBinDirs = (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)();
101
122
  const npmGlobalPath = npmGlobalBinDirs.length > 0
102
123
  ? (0, command_resolution_1.getSystemCommandPath)(cli.command, npmGlobalBinDirs.join(path_1.default.delimiter))
@@ -29,7 +29,10 @@ exports.FRAIM_MCP_UNAVAILABLE_MANAGER_GUIDANCE = [
29
29
  'If required FRAIM MCP tools remain unavailable after the deferred-tool preload/retry step, stop the FRAIM job.',
30
30
  'Do not continue from memory, local stubs, cached instructions, or prior context.',
31
31
  'In the blocker sentence, state only the work-focused blocker and what outcome is blocked; do not narrate ToolSearch/tool_search, individual tool names, server internals, or retry mechanics.',
32
- 'Always help troubleshoot the unavailable FRAIM MCP connection: tell the user to reconnect or enable the FRAIM MCP server in their IDE/agent, restart or refresh the agent session, retry the FRAIM job, and if it still fails run `fraim doctor --test-mcp` and share the failing check output.'
32
+ 'Always help troubleshoot the unavailable FRAIM MCP connection: tell the user to reconnect or enable the FRAIM MCP server in their IDE/agent, restart or refresh the agent session, retry the FRAIM job, and if it still fails run `fraim doctor --test-mcp` and share the failing check output.',
33
+ 'Exception — troubleshooting FRAIM itself: if the user\'s request is to diagnose or fix a FRAIM issue and FRAIM MCP is unavailable, do NOT stop.',
34
+ 'Instead, read `~/.fraim/docs/TROUBLESHOOTING.md` directly from disk (it is always synced in full by `fraim sync` and is available without MCP) and follow its guidance to diagnose the issue.',
35
+ 'After completing the local diagnosis, prompt the user to re-run `fraim sync` and restart their agent session, then offer to run the `troubleshoot-fraim` FRAIM job for a structured investigation once MCP is restored.'
33
36
  ].join(' ');
34
37
  function buildDeferredToolBootstrapSection(profile) {
35
38
  if (profile === 'none') {
@@ -291,10 +291,20 @@ for (const bundle of Object.values(exports.PERSONA_CAPABILITY_BUNDLES)) {
291
291
  // named specialist persona. They resolve through ownership (never short-circuited
292
292
  // as "free") so the Hub attributes them to FRAIMworker, but they are never
293
293
  // hire-gated because FRAIMworker is not a purchasable persona.
294
+ //
295
+ // Issue #1398: manager-agreements / organization-onboarding / organizational-learning-
296
+ // synthesis are Manager/Company area-level jobs, not tied to a named specialist — they
297
+ // were falling through to DEFAULT_UNASSIGNED_PERSONA_KEY ('mandy'), which misattributed
298
+ // them to an employee who never runs them and left them outside the Manager/Company
299
+ // employee rail's FRAIMworker group.
294
300
  const GENERIC_WORKER_OWNED_JOBS = new Set([
295
301
  'contribute-to-fraim',
296
302
  'file-fraim-issue',
297
303
  'praise-fraim',
304
+ 'troubleshoot-fraim',
305
+ 'manager-agreements',
306
+ 'organization-onboarding',
307
+ 'organizational-learning-synthesis',
298
308
  ]);
299
309
  function getPersonaCapabilityBundle(personaKey) {
300
310
  return exports.PERSONA_CAPABILITY_BUNDLES[personaKey];
@@ -170,6 +170,8 @@ class FraimDbService {
170
170
  // Issue #563 — shared organization context (FRAIM-cloud backend).
171
171
  this.orgArtifactsCollection = this.db.collection('fraim_org_artifacts');
172
172
  this.orgAuditCollection = this.db.collection('fraim_org_audit');
173
+ // Issue #1345 — job execution modes (Coached/Trusted).
174
+ this.jobExecutionModesCollection = this.db.collection('fraim_job_execution_modes');
173
175
  }
174
176
  async initializeIndexes() {
175
177
  if (!this.db)
@@ -199,6 +201,7 @@ class FraimDbService {
199
201
  // requirement — swallow failures like the other Cosmos-sensitive indexes above.
200
202
  await this.orgArtifactsCollection.createIndex({ orgId: 1, relativePath: 1 }, { unique: true }).catch(() => { });
201
203
  await this.orgAuditCollection.createIndex({ orgId: 1, at: -1 }).catch(() => { });
204
+ await this.jobExecutionModesCollection.createIndex({ userId: 1, jobName: 1 }, { unique: true }).catch(() => { });
202
205
  await this.pendingVerificationsCollection.createIndex({ email: 1 });
203
206
  // Compound index covers `findOne({email}, { sort: { createdAt: -1 } })` —
204
207
  // the request-access flow's lookup of the most-recent pending row per
@@ -286,6 +289,36 @@ class FraimDbService {
286
289
  throw new Error('DB not connected');
287
290
  return await this.orgAuditCollection.find({ orgId }).sort({ at: -1 }).toArray();
288
291
  }
292
+ async getJobExecutionMode(userId, jobName) {
293
+ if (!this.jobExecutionModesCollection)
294
+ return null;
295
+ return await this.jobExecutionModesCollection.findOne({ userId, jobName }) ?? null;
296
+ }
297
+ async upsertJobExecutionMode(userId, update) {
298
+ if (!this.jobExecutionModesCollection)
299
+ return;
300
+ const jobName = String(update['jobName'] ?? '');
301
+ if (!jobName)
302
+ return;
303
+ const existing = await this.jobExecutionModesCollection.findOne({ userId, jobName });
304
+ let completedRuns = typeof existing?.completedRuns === 'number' ? existing.completedRuns : 0;
305
+ if (update['incrementRun'] === true)
306
+ completedRuns += 1;
307
+ const record = {
308
+ userId,
309
+ jobName,
310
+ mode: (update['mode'] === 'trusted' || update['mode'] === 'coached')
311
+ ? update['mode']
312
+ : (existing?.mode ?? 'coached'),
313
+ completedRuns,
314
+ trustedSince: typeof update['trustedSince'] === 'string' ? update['trustedSince'] : existing?.trustedSince,
315
+ trustedAtRun: typeof update['trustedAtRun'] === 'number' ? update['trustedAtRun'] : existing?.trustedAtRun,
316
+ lastGraduationOfferRun: typeof update['lastGraduationOfferRun'] === 'number' ? update['lastGraduationOfferRun'] : existing?.lastGraduationOfferRun,
317
+ graduationSuppressedUntilRun: typeof update['graduationSuppressedUntilRun'] === 'number' ? update['graduationSuppressedUntilRun'] : existing?.graduationSuppressedUntilRun,
318
+ updatedAt: new Date(),
319
+ };
320
+ await this.jobExecutionModesCollection.replaceOne({ userId, jobName }, record, { upsert: true });
321
+ }
289
322
  async verifyApiKey(key) {
290
323
  if (!this.keysCollection)
291
324
  throw new Error('DB not connected');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.286",
3
+ "version": "2.0.288",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -210,7 +210,7 @@
210
210
  "electron-updater": "^6.8.9",
211
211
  "express": "^5.2.1",
212
212
  "extract-zip": "^2.0.1",
213
- "fraim": "2.0.286",
213
+ "fraim": "2.0.288",
214
214
  "mongodb": "^7.0.0",
215
215
  "node-cron": "4.2.1",
216
216
  "node-edge-tts": "^1.2.10",
@@ -827,8 +827,10 @@
827
827
  </div>
828
828
 
829
829
  <!-- #1351: unified Add Agent dialog — add a configured profile directly, or delegate
830
- setup to FRAIM. Replaces the separate "Add AI agent" button + "setup another tool"
831
- disclosure both opening the same generic onboarding modal with no manual option. -->
830
+ setup to FRAIM. This is the single entry point for agent setup: the toolbar's
831
+ "Add AI agent" button and, on #1390, the per-tool quick-picks inside the Delegate
832
+ tab (which replaced the old standalone "setup another tool" disclosure) both open
833
+ this same dialog. -->
832
834
  <div id="add-agent-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="aam-title" hidden>
833
835
  <div class="modal-card">
834
836
  <div class="modal-hdr">
@@ -845,6 +847,11 @@
845
847
  <div class="modal-body">
846
848
  <div id="aam-manual-pane"></div>
847
849
  <div id="aam-delegate-pane" hidden>
850
+ <div id="aam-delegate-quickpicks" class="aam-delegate-quickpicks" data-testid="aam-delegate-quickpicks" hidden>
851
+ <p class="aam-delegate-quickpicks-label">Not set up yet on this machine:</p>
852
+ <div id="aam-delegate-quickpicks-list" class="aam-delegate-quickpicks-list"></div>
853
+ <p id="aam-delegate-quickpick-caption" class="aam-delegate-quickpick-caption" data-testid="aam-delegate-quickpick-caption" hidden></p>
854
+ </div>
848
855
  <div class="np-field">
849
856
  <label for="aam-delegate-context">Any specific direction for this run? <span class="np-optional">(optional, leave blank to start with defaults)</span></label>
850
857
  <textarea id="aam-delegate-context" rows="4" data-testid="add-agent-delegate-context" placeholder="e.g. Configure a CLI, a cloud-credit route, or a custom profile."></textarea>