fraim-hub 2.0.244 → 2.0.246

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.
@@ -22,14 +22,27 @@ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
22
22
  // - <project>/fraim/ai-employee/jobs/<category>/ - synced baseline
23
23
  // - <project>/fraim/personalized-employee/jobs/<category>/ - taught/customized override
24
24
  // Only the personalized-employee layer is "personalized" (issue #566).
25
+ // Issue #1002: the machine-level layers are added here so a capability authored
26
+ // at the manager level is DISCOVERABLE, not merely resolvable by exact name.
27
+ // Order matters and mirrors LocalRegistryResolver.resolveFile precedence
28
+ // inverted, because later entries win on a {categoryId, jobId} collision:
29
+ // synced baseline < company cache < manager synced cache < manager local < project
30
+ // A job that resolves from the manager level must therefore also DISPLAY as the
31
+ // manager's, otherwise the rail would show a baseline job the runtime never runs.
25
32
  const EMPLOYEE_JOB_LAYERS = [
26
33
  { segments: ['ai-employee', 'jobs'], personalized: false },
27
- { segments: ['personalized-employee', 'jobs'], personalized: true },
34
+ { base: 'user', segments: ['org', 'jobs'], personalized: true, scope: 'org' },
35
+ { base: 'user', segments: ['manager', 'jobs'], personalized: true, scope: 'manager' },
36
+ { base: 'user', segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'manager' },
37
+ { segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'project' },
28
38
  ];
29
39
  // Manager templates use the matching layer model.
30
40
  const MANAGER_JOB_LAYERS = [
31
41
  { segments: ['ai-manager', 'jobs'], personalized: false },
32
- { segments: ['personalized-employee', 'manager-jobs'], personalized: true },
42
+ { base: 'user', segments: ['org', 'manager-jobs'], personalized: true, scope: 'org' },
43
+ { base: 'user', segments: ['manager', 'manager-jobs'], personalized: true, scope: 'manager' },
44
+ { base: 'user', segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'manager' },
45
+ { segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'project' },
33
46
  ];
34
47
  const REGISTRY_EMPLOYEE_JOB_LAYERS = [
35
48
  { base: 'project', segments: ['registry', 'jobs', 'ai-employee'], personalized: false },
@@ -93,7 +106,18 @@ function readMarkdownFileNames(dirPath) {
93
106
  .map((entry) => entry.name)
94
107
  .sort((a, b) => a.localeCompare(b));
95
108
  }
96
- function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized) {
109
+ /**
110
+ * Issue #1002: a stub that lives under the machine-level FRAIM home is displayed
111
+ * as `~/.fraim/...` rather than as a long `../../..` walk out of the project,
112
+ * which is what path.relative would otherwise produce for a user-level layer.
113
+ */
114
+ function stubDisplayPath(filePath, projectPath, userLevel) {
115
+ if (!userLevel)
116
+ return toPosix(path_1.default.relative(projectPath, filePath));
117
+ const rel = toPosix(path_1.default.relative((0, project_fraim_paths_1.getUserFraimDirPath)(), filePath));
118
+ return (0, project_fraim_paths_1.getUserFraimDisplayPath)(rel);
119
+ }
120
+ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized, scope, userLevel) {
97
121
  const content = fs_1.default.readFileSync(filePath, 'utf8');
98
122
  const fileName = path_1.default.basename(filePath, '.md');
99
123
  const frontmatter = readJobFrontmatter(filePath);
@@ -107,11 +131,12 @@ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personal
107
131
  categoryLabel,
108
132
  intent,
109
133
  outcome,
110
- stubPath: toPosix(path_1.default.relative(projectPath, filePath)),
134
+ stubPath: stubDisplayPath(filePath, projectPath, userLevel),
111
135
  personalized: !!personalized,
136
+ ...(scope ? { scope } : {}),
112
137
  };
113
138
  }
114
- function parseManagerStub(filePath, groupId, groupLabel, projectPath) {
139
+ function parseManagerStub(filePath, groupId, groupLabel, projectPath, scope, userLevel) {
115
140
  const content = fs_1.default.readFileSync(filePath, 'utf8');
116
141
  const fileName = path_1.default.basename(filePath, '.md');
117
142
  const frontmatter = readJobFrontmatter(filePath);
@@ -123,7 +148,8 @@ function parseManagerStub(filePath, groupId, groupLabel, projectPath) {
123
148
  groupId,
124
149
  groupLabel,
125
150
  intent,
126
- stubPath: toPosix(path_1.default.relative(projectPath, filePath)),
151
+ stubPath: stubDisplayPath(filePath, projectPath, userLevel),
152
+ ...(scope ? { scope } : {}),
127
153
  };
128
154
  }
129
155
  function summarizeProject(projectPath) {
@@ -163,6 +189,8 @@ function summarizeProject(projectPath) {
163
189
  function resolveLayerRoot(projectPath, layer) {
164
190
  if (layer.base === 'project')
165
191
  return path_1.default.join(projectPath, ...layer.segments);
192
+ if (layer.base === 'user')
193
+ return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), ...layer.segments);
166
194
  return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
167
195
  }
168
196
  function discoverLayers(projectPath, layers) {
@@ -175,6 +203,8 @@ function discoverLayers(projectPath, layers) {
175
203
  categoryId: categoryName,
176
204
  categoryDir: path_1.default.join(layerRoot, categoryName),
177
205
  personalized: layer.personalized,
206
+ scope: layer.scope,
207
+ userLevel: layer.base === 'user',
178
208
  });
179
209
  }
180
210
  }
@@ -193,7 +223,7 @@ function discoverEmployeeJobs(projectPath, options = {}) {
193
223
  const categoryLabel = humanizeName(layer.categoryId);
194
224
  for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
195
225
  const filePath = path_1.default.join(layer.categoryDir, fileName);
196
- const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized);
226
+ const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized, layer.scope, layer.userLevel);
197
227
  // Later layers override earlier layers on {category, jobId} collision —
198
228
  // personalized-employee wins over the synced ai-employee baseline. The
199
229
  // winning job carries its own layer's `personalized` flag (issue #566).
@@ -219,7 +249,7 @@ function discoverManagerTemplates(projectPath, options = {}) {
219
249
  const groupLabel = humanizeName(layer.categoryId);
220
250
  for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
221
251
  const filePath = path_1.default.join(layer.categoryDir, fileName);
222
- const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath);
252
+ const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath, layer.scope, layer.userLevel);
223
253
  templatesByKey.set(`${template.groupId}::${template.id}`, template);
224
254
  }
225
255
  }
@@ -17,63 +17,129 @@ exports.buildCustomEmployeePersona = buildCustomEmployeePersona;
17
17
  // evaluatePersonaAccess) must never call resolveEmployee.
18
18
  const fs_1 = __importDefault(require("fs"));
19
19
  const path_1 = __importDefault(require("path"));
20
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
20
21
  const persona_hiring_1 = require("../config/persona-hiring");
21
22
  const EMPLOYEES_DIR_REL = path_1.default.join('fraim', 'personalized-employee', 'employees');
22
23
  function employeesDir(projectDir) {
23
24
  return path_1.default.join(projectDir, EMPLOYEES_DIR_REL);
24
25
  }
25
- function slugFor(key) {
26
- // key is 'custom:<slug>' file is named '<slug>.json'
27
- return key.replace(/^custom:/, '');
28
- }
29
- function safeSlug(key) {
30
- // Reject keys whose slug component contains path separators or traversal sequences.
31
- const slug = slugFor(key);
32
- if (!slug || /[/\\]|\.\./.test(slug))
33
- return null;
34
- return slug;
35
- }
36
- function filePath(projectDir, key) {
37
- const slug = safeSlug(key);
38
- if (!slug)
39
- return null;
40
- return path_1.default.join(employeesDir(projectDir), `${slug}.json`);
26
+ // Issue #1002: the manager level is the default home for a custom employee, so
27
+ // an employee the manager created is available in every project on the machine.
28
+ // Two roots, matching capability precedence: the writable home wins over the
29
+ // synced cache, so a just-created employee appears before any publish or sync.
30
+ function managerEmployeesDirs() {
31
+ return {
32
+ local: path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', 'employees'),
33
+ cache: path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'manager', 'employees'),
34
+ };
41
35
  }
42
- function readCustomEmployees(projectDir) {
43
- const dir = employeesDir(projectDir);
36
+ function readEmployeesFromDir(dir, scope) {
44
37
  if (!fs_1.default.existsSync(dir))
45
38
  return [];
46
- const results = [];
47
- for (const file of fs_1.default.readdirSync(dir)) {
39
+ let names;
40
+ try {
41
+ names = fs_1.default.readdirSync(dir);
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ const out = [];
47
+ for (const file of names) {
48
48
  if (!file.endsWith('.json'))
49
49
  continue;
50
50
  try {
51
- const raw = fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8');
52
- const parsed = JSON.parse(raw);
51
+ const parsed = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8'));
53
52
  if (parsed && typeof parsed.key === 'string' && parsed.key.startsWith('custom:')) {
54
- results.push(parsed);
53
+ // The record's own scope is authoritative when present; otherwise the
54
+ // directory it was found in names its level. A record written before this
55
+ // change carries scope 'project' and is found in the project dir, so both
56
+ // agree and nothing is reinterpreted.
57
+ out.push({ ...parsed, scope: parsed.scope ?? scope });
55
58
  }
56
59
  }
57
60
  catch {
58
61
  // silently skip malformed files
59
62
  }
60
63
  }
61
- return results;
64
+ return out;
65
+ }
66
+ function slugFor(key) {
67
+ // key is 'custom:<slug>' — file is named '<slug>.json'
68
+ return key.replace(/^custom:/, '');
62
69
  }
70
+ function safeSlug(key) {
71
+ // Reject keys whose slug component contains path separators or traversal sequences.
72
+ const slug = slugFor(key);
73
+ if (!slug || /[/\\]|\.\./.test(slug))
74
+ return null;
75
+ return slug;
76
+ }
77
+ /**
78
+ * Read every custom employee visible from this project, across levels.
79
+ *
80
+ * Issue #1002: precedence matches capability resolution. The project copy wins
81
+ * on a key collision, then the manager's writable home, then the synced manager
82
+ * cache. Reading the manager level here is what makes the manager-level default
83
+ * usable: without it an employee created at the manager level would exist on
84
+ * disk and appear in no roster.
85
+ */
86
+ function readCustomEmployees(projectDir) {
87
+ const dirs = managerEmployeesDirs();
88
+ const byKey = new Map();
89
+ // Lowest precedence first, so a later write overwrites on key collision.
90
+ for (const emp of readEmployeesFromDir(dirs.cache, 'manager'))
91
+ byKey.set(emp.key, emp);
92
+ for (const emp of readEmployeesFromDir(dirs.local, 'manager'))
93
+ byKey.set(emp.key, emp);
94
+ for (const emp of readEmployeesFromDir(employeesDir(projectDir), 'project'))
95
+ byKey.set(emp.key, emp);
96
+ return [...byKey.values()];
97
+ }
98
+ /**
99
+ * Write a custom employee at its declared level.
100
+ *
101
+ * Issue #1002 R1: the manager level is the default, so a record with no scope,
102
+ * or scope 'manager', is written to the manager's writable home and is available
103
+ * in every project. Only scope 'project' writes into the repository.
104
+ */
63
105
  function writeCustomEmployee(projectDir, employee) {
64
- const fp = filePath(projectDir, employee.key);
65
- if (!fp)
66
- throw new Error(`Invalid employee key: ${employee.key}`);
67
- const dir = employeesDir(projectDir);
106
+ const scope = employee.scope ?? 'manager';
107
+ const record = { ...employee, scope };
108
+ const slug = safeSlug(record.key);
109
+ if (!slug)
110
+ throw new Error(`Invalid employee key: ${record.key}`);
111
+ const dir = scope === 'project' ? employeesDir(projectDir) : managerEmployeesDirs().local;
68
112
  fs_1.default.mkdirSync(dir, { recursive: true });
69
- fs_1.default.writeFileSync(fp, JSON.stringify(employee, null, 2), 'utf8');
113
+ fs_1.default.writeFileSync(path_1.default.join(dir, `${slug}.json`), JSON.stringify(record, null, 2), 'utf8');
70
114
  }
115
+ /**
116
+ * Delete a custom employee wherever it lives. Issue #1002: a manager-level
117
+ * employee is not in the project directory, so deleting only there would report
118
+ * success while leaving the record live in every other project.
119
+ */
71
120
  function deleteCustomEmployee(projectDir, key) {
72
- const fp = filePath(projectDir, key);
73
- if (!fp || !fs_1.default.existsSync(fp))
121
+ const slug = safeSlug(key);
122
+ if (!slug)
74
123
  return false;
75
- fs_1.default.unlinkSync(fp);
76
- return true;
124
+ const dirs = managerEmployeesDirs();
125
+ const candidates = [
126
+ path_1.default.join(employeesDir(projectDir), `${slug}.json`),
127
+ path_1.default.join(dirs.local, `${slug}.json`),
128
+ path_1.default.join(dirs.cache, `${slug}.json`),
129
+ ];
130
+ let removed = false;
131
+ for (const fp of candidates) {
132
+ if (!fs_1.default.existsSync(fp))
133
+ continue;
134
+ try {
135
+ fs_1.default.unlinkSync(fp);
136
+ removed = true;
137
+ }
138
+ catch {
139
+ // keep going, and report whether anything was removed
140
+ }
141
+ }
142
+ return removed;
77
143
  }
78
144
  function slugifyDisplayName(displayName, existingKeys = []) {
79
145
  const base = displayName
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.RestartRecoveryPolicy = exports.DEFAULT_RESTART_RECOVERY_LEASE_MS = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const crypto_1 = require("crypto");
10
+ const conversation_store_1 = require("./conversation-store");
11
+ exports.DEFAULT_RESTART_RECOVERY_LEASE_MS = 60_000;
12
+ function normalizedDirectoryPath(projectPath) {
13
+ const resolved = path_1.default.resolve(projectPath);
14
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
15
+ }
16
+ function sameDirectoryPath(left, right) {
17
+ return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
18
+ }
19
+ function timestampMs(value) {
20
+ if (typeof value === 'number')
21
+ return Number.isFinite(value) ? value : 0;
22
+ if (typeof value === 'string') {
23
+ const parsed = Date.parse(value);
24
+ if (Number.isFinite(parsed))
25
+ return parsed;
26
+ const numeric = Number(value);
27
+ return Number.isFinite(numeric) ? numeric : 0;
28
+ }
29
+ return 0;
30
+ }
31
+ function restartRecoveryBucketOwnershipReason(conversation, bucketKey) {
32
+ if (!bucketKey)
33
+ return null;
34
+ const scope = (conversation.scope ?? conversation.invokedArea) || 'project';
35
+ if (scope === 'manager')
36
+ return bucketKey === conversation_store_1.MANAGER_SCOPE_KEY ? null : 'bucket_scope_mismatch';
37
+ if (scope === 'company')
38
+ return bucketKey === conversation_store_1.COMPANY_SCOPE_KEY ? null : 'bucket_scope_mismatch';
39
+ if (bucketKey === conversation_store_1.MANAGER_SCOPE_KEY || bucketKey === conversation_store_1.COMPANY_SCOPE_KEY)
40
+ return 'bucket_scope_mismatch';
41
+ const ownProjectPath = typeof conversation.projectPath === 'string' ? conversation.projectPath.trim() : '';
42
+ if (ownProjectPath && !sameDirectoryPath(ownProjectPath, bucketKey))
43
+ return 'bucket_project_mismatch';
44
+ return null;
45
+ }
46
+ class RestartRecoveryPolicy {
47
+ constructor(options = {}) {
48
+ this.nowMs = options.nowMs || Date.now;
49
+ this.recoveryLeaseMs = options.recoveryLeaseMs ?? exports.DEFAULT_RESTART_RECOVERY_LEASE_MS;
50
+ this.projectExists = options.projectExists || ((projectPath) => fs_1.default.existsSync(projectPath));
51
+ this.machineLevelJobIds = options.machineLevelJobIds || new Set();
52
+ }
53
+ classify(conversation, bucketKey, options = {}) {
54
+ const bucketReason = restartRecoveryBucketOwnershipReason(conversation, bucketKey);
55
+ if (bucketReason)
56
+ return { action: 'skip', reason: bucketReason };
57
+ if (conversation.status !== 'running')
58
+ return { action: 'skip', reason: 'not_running' };
59
+ const pauseReason = typeof conversation.pauseReason === 'string' ? conversation.pauseReason : '';
60
+ if (['stopped', 'done', 'awaiting_review', 'awaiting_user', 'error'].includes(pauseReason)) {
61
+ return { action: 'skip', reason: `pause_${pauseReason}` };
62
+ }
63
+ if (!conversation.sessionId || typeof conversation.sessionId !== 'string' || !conversation.sessionId.trim()) {
64
+ return { action: 'skip', reason: 'missing_session' };
65
+ }
66
+ if (!conversation.jobId || typeof conversation.jobId !== 'string' || !conversation.jobId.trim()) {
67
+ return { action: 'skip', reason: 'missing_job' };
68
+ }
69
+ const scope = conversation.scope || 'project';
70
+ const projectPath = typeof conversation.projectPath === 'string' ? conversation.projectPath : '';
71
+ if (scope === 'project' && !this.machineLevelJobIds.has(conversation.jobId) && (!projectPath || !this.projectExists(projectPath))) {
72
+ return { action: 'skip', reason: 'missing_project' };
73
+ }
74
+ if (conversation.reviewHandoff?.reviewRequired) {
75
+ return { action: 'skip', reason: 'awaiting_review' };
76
+ }
77
+ if (options.activeRunExists) {
78
+ return { action: 'defer', reason: 'active_run_exists' };
79
+ }
80
+ const recoveredAt = timestampMs(conversation.restartRecovery?.recoveredAt);
81
+ if (recoveredAt > 0 && this.nowMs() - recoveredAt < this.recoveryLeaseMs) {
82
+ return { action: 'defer', reason: 'recent_recovery' };
83
+ }
84
+ return { action: 'recover', attempt: 1 };
85
+ }
86
+ buildContinueMessage(run, decision) {
87
+ return [
88
+ '[FRAIM Hub system recovery]',
89
+ 'The FRAIM Hub process restarted while this run was marked in progress in durable conversation state.',
90
+ 'This is not a manager-authored instruction. Do not say the manager asked you to continue.',
91
+ `Run id: ${run.id}`,
92
+ `Conversation id: ${run.conversationId || 'unknown'}`,
93
+ `Session id: ${run.sessionId || 'unknown'}`,
94
+ `Restart recovery attempt: ${decision.attempt}`,
95
+ 'Resume only if the tracked FRAIM phase is non-terminal and not waiting for human review or approval.',
96
+ ].join('\n');
97
+ }
98
+ createRecoveryEvent(run, decision) {
99
+ const now = new Date(this.nowMs()).toISOString();
100
+ return {
101
+ id: (0, crypto_1.randomUUID)(),
102
+ channel: 'system',
103
+ createdAt: now,
104
+ text: `Hub restart recovery attempt ${decision.attempt} for run ${run.id} session ${run.sessionId || 'unknown'}.`,
105
+ };
106
+ }
107
+ }
108
+ exports.RestartRecoveryPolicy = RestartRecoveryPolicy;
@@ -60,6 +60,7 @@ const manager_turns_1 = require("./manager-turns");
60
60
  const preferences_1 = require("./preferences");
61
61
  const conversation_store_1 = require("./conversation-store");
62
62
  const conversation_store_lock_1 = require("./conversation-store-lock");
63
+ const restart_recovery_policy_1 = require("./restart-recovery-policy");
63
64
  const remote_hub_gateway_1 = require("./remote-hub-gateway");
64
65
  const managed_browser_1 = require("./managed-browser");
65
66
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
@@ -186,7 +187,6 @@ const MACHINE_LEVEL_JOB_IDS = new Set([
186
187
  const DEFAULT_CONVERSATION_FLUSH_DELAY_MS = 2000;
187
188
  const HUB_RESTART_RECOVERY_LOCK_TIMEOUT_MS = 5000;
188
189
  const HUB_RESTART_RECOVERY_LOCK_STALE_MS = 10000;
189
- const HUB_RESTART_RECOVERY_CONTINUE_MESSAGE = 'Continue where you left off. The Hub process restarted and recovered this in-progress run from durable conversation state.';
190
190
  function conversationFlushDelayMs() {
191
191
  const configured = Number(process.env.FRAIM_HUB_CONVERSATION_FLUSH_MS);
192
192
  if (Number.isFinite(configured) && configured >= 0)
@@ -1578,47 +1578,6 @@ function isHumanActionGate(run) {
1578
1578
  const lastEntry = phaseHistory.length > 0 ? phaseHistory[phaseHistory.length - 1] : null;
1579
1579
  return lastEntry?.latestStatus === 'incomplete' || lastEntry?.latestStatus === 'failure';
1580
1580
  }
1581
- function restartRecoveryBucketOwnershipReason(conversation, bucketKey) {
1582
- if (!bucketKey)
1583
- return null;
1584
- const scope = (conversation.scope ?? conversation.invokedArea) || 'project';
1585
- if (scope === 'manager')
1586
- return bucketKey === conversation_store_1.MANAGER_SCOPE_KEY ? null : 'bucket_scope_mismatch';
1587
- if (scope === 'company')
1588
- return bucketKey === conversation_store_1.COMPANY_SCOPE_KEY ? null : 'bucket_scope_mismatch';
1589
- if (bucketKey === conversation_store_1.MANAGER_SCOPE_KEY || bucketKey === conversation_store_1.COMPANY_SCOPE_KEY)
1590
- return 'bucket_scope_mismatch';
1591
- const ownProjectPath = typeof conversation.projectPath === 'string' ? conversation.projectPath.trim() : '';
1592
- if (ownProjectPath && !sameDirectoryPath(ownProjectPath, bucketKey))
1593
- return 'bucket_project_mismatch';
1594
- return null;
1595
- }
1596
- function classifyRestartRecoveryEligibility(conversation, bucketKey) {
1597
- const bucketReason = restartRecoveryBucketOwnershipReason(conversation, bucketKey);
1598
- if (bucketReason)
1599
- return { eligible: false, reason: bucketReason };
1600
- if (conversation.status !== 'running')
1601
- return { eligible: false, reason: 'not_running' };
1602
- const pauseReason = typeof conversation.pauseReason === 'string' ? conversation.pauseReason : '';
1603
- if (['stopped', 'done', 'awaiting_review', 'awaiting_user', 'error'].includes(pauseReason)) {
1604
- return { eligible: false, reason: `pause_${pauseReason}` };
1605
- }
1606
- if (!conversation.sessionId || typeof conversation.sessionId !== 'string' || !conversation.sessionId.trim()) {
1607
- return { eligible: false, reason: 'missing_session' };
1608
- }
1609
- if (!conversation.jobId || typeof conversation.jobId !== 'string' || !conversation.jobId.trim()) {
1610
- return { eligible: false, reason: 'missing_job' };
1611
- }
1612
- const scope = conversation.scope || 'project';
1613
- const projectPath = typeof conversation.projectPath === 'string' ? conversation.projectPath : '';
1614
- if (scope === 'project' && !MACHINE_LEVEL_JOB_IDS.has(conversation.jobId) && (!projectPath || !fs_1.default.existsSync(projectPath))) {
1615
- return { eligible: false, reason: 'missing_project' };
1616
- }
1617
- if (conversation.reviewHandoff?.reviewRequired) {
1618
- return { eligible: false, reason: 'awaiting_review' };
1619
- }
1620
- return { eligible: true };
1621
- }
1622
1581
  function classifyExit(run, exitCode) {
1623
1582
  if (run.stoppedByUser) {
1624
1583
  return { action: 'park', pauseReason: 'stopped' };
@@ -1708,6 +1667,7 @@ class AiHubServer {
1708
1667
  this.deploymentStoreProvided = Boolean(options.deploymentStore);
1709
1668
  this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
1710
1669
  this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
1670
+ this.restartRecoveryPolicy = new restart_recovery_policy_1.RestartRecoveryPolicy({ machineLevelJobIds: MACHINE_LEVEL_JOB_IDS });
1711
1671
  this.app.use(express_1.default.json({ limit: '10mb' }));
1712
1672
  // CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
1713
1673
  // calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
@@ -2455,9 +2415,11 @@ class AiHubServer {
2455
2415
  for (const bucketKey of bucketKeys) {
2456
2416
  const headers = this.conversationStore.loadProjectHeaders(bucketKey);
2457
2417
  for (const header of headers) {
2458
- const quickDecision = classifyRestartRecoveryEligibility(header, bucketKey);
2459
- if (!quickDecision.eligible) {
2460
- if (header.status === 'running') {
2418
+ const quickDecision = this.restartRecoveryPolicy.classify(header, bucketKey, {
2419
+ activeRunExists: Boolean(header.runId && this.runRegistry.get(header.runId)),
2420
+ });
2421
+ if (quickDecision.action !== 'recover') {
2422
+ if (quickDecision.action === 'skip' && header.status === 'running') {
2461
2423
  const skipped = this.conversationStore.loadConversation(bucketKey, header.id);
2462
2424
  if (skipped)
2463
2425
  this.markRestartRecoverySkipped(bucketKey, skipped, quickDecision.reason || 'ineligible');
@@ -2467,11 +2429,13 @@ class AiHubServer {
2467
2429
  const conversation = this.conversationStore.loadConversation(bucketKey, header.id);
2468
2430
  if (!conversation)
2469
2431
  continue;
2470
- const decision = classifyRestartRecoveryEligibility(conversation, bucketKey);
2471
- if (decision.eligible) {
2472
- candidates.push({ bucketKey, conversation });
2432
+ const decision = this.restartRecoveryPolicy.classify(conversation, bucketKey, {
2433
+ activeRunExists: Boolean(conversation.runId && this.runRegistry.get(conversation.runId)),
2434
+ });
2435
+ if (decision.action === 'recover') {
2436
+ candidates.push({ bucketKey, conversation, decision });
2473
2437
  }
2474
- else if (conversation.status === 'running') {
2438
+ else if (decision.action === 'skip' && conversation.status === 'running') {
2475
2439
  this.markRestartRecoverySkipped(bucketKey, conversation, decision.reason || 'ineligible');
2476
2440
  }
2477
2441
  }
@@ -2498,7 +2462,8 @@ class AiHubServer {
2498
2462
  const activeId = activeIdByBucket.get(candidate.bucketKey) ?? null;
2499
2463
  this.persistRunConversationNow(run, activeId);
2500
2464
  recoveredBucketKeys.add(candidate.bucketKey);
2501
- this.continueRecoveredRun(run, HUB_RESTART_RECOVERY_CONTINUE_MESSAGE, {
2465
+ this.continueRecoveredRun(run, this.restartRecoveryPolicy.buildContinueMessage(run, candidate.decision), {
2466
+ recoveryEvent: this.restartRecoveryPolicy.createRecoveryEvent(run, candidate.decision),
2502
2467
  postPark: (updated) => this.maybeStartDelegatedChildRuns(updated),
2503
2468
  activeId,
2504
2469
  });
@@ -2616,18 +2581,18 @@ class AiHubServer {
2616
2581
  continueRecoveredRun(run, instructions, options = {}) {
2617
2582
  if (!run.sessionId)
2618
2583
  return;
2619
- const prepared = this.prepareContinueMessage(run, instructions);
2620
2584
  this.runRegistry.update(run.id, (current) => {
2621
2585
  current.status = 'running';
2622
2586
  current.pauseReason = 'working';
2623
- current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || instructions));
2587
+ if (options.recoveryEvent)
2588
+ current.events.push(options.recoveryEvent);
2624
2589
  });
2625
2590
  const currentRun = this.runRegistry.get(run.id) || run;
2626
2591
  const sessionId = currentRun.sessionId;
2627
2592
  if (!sessionId)
2628
2593
  return;
2629
2594
  const launch = this.resolveLaunchAgent(currentRun.configuredAgentId, currentRun.hostId);
2630
- const child = this.hostRuntime.continueRun(currentRun.hostId, currentRun.projectPath, sessionId, prepared.message, {
2595
+ const child = this.hostRuntime.continueRun(currentRun.hostId, currentRun.projectPath, sessionId, instructions, {
2631
2596
  onEvent: (event, channel) => {
2632
2597
  this.runRegistry.update(currentRun.id, (current) => {
2633
2598
  if (event.sessionId) {
@@ -3188,8 +3153,12 @@ class AiHubServer {
3188
3153
  computeBrain(projectPath, jobCount, userEmail) {
3189
3154
  const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, userEmail || '');
3190
3155
  if (!userEmail) {
3156
+ // Issue #1002 R11: reverseMentoring is individual-keyed like manager and
3157
+ // project, so it is zeroed on the same condition. Missing it would leak a
3158
+ // count for a user whose identity was never resolved.
3191
3159
  learnings.manager = 0;
3192
3160
  learnings.project = 0;
3161
+ learnings.reverseMentoring = 0;
3193
3162
  }
3194
3163
  return {
3195
3164
  learnings,