fraim-hub 2.0.341 → 2.0.343

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.
@@ -27,7 +27,7 @@ const HEADER_FIELDS = [
27
27
  'runId', 'status', 'pauseReason', 'createdAt', 'lastUpdatedAt', 'issueNumber',
28
28
  'sourceTrigger', 'blocked', 'stopped', 'reviewApproved', 'compareMode', 'compareRunId',
29
29
  'managedByRunId', 'managedByPersonaKey', 'managedReviewStatus', 'delegationTaskId',
30
- 'humanCoachingDisabled', 'personaSnapshot', 'reviewHandoff',
30
+ 'humanCoachingDisabled', 'personaSnapshot', 'reviewHandoff', 'proposalSet',
31
31
  ];
32
32
  const HEADER_FIELD_SET = new Set([...HEADER_FIELDS, 'personaSnapshot', 'delegation', 'reviewHandoff']);
33
33
  /**
@@ -571,7 +571,7 @@ class AiHubConversationStore {
571
571
  try {
572
572
  const parsed = JSON.parse(fs_1.default.readFileSync(this.indexPath(bucketDir), 'utf8'));
573
573
  if (Array.isArray(parsed.headers)) {
574
- return { activeId: parsed.activeId ?? null, headers: parsed.headers };
574
+ return { version: parsed.version, activeId: parsed.activeId ?? null, headers: parsed.headers };
575
575
  }
576
576
  }
577
577
  catch {
@@ -583,12 +583,12 @@ class AiHubConversationStore {
583
583
  // source of truth) and persist it best-effort.
584
584
  loadIndex(bucketDir, bucketKey) {
585
585
  const existing = this.readIndex(bucketDir);
586
- if (existing) {
586
+ if (existing && existing.version === 3) {
587
587
  const sanitized = sanitizeBucketIndex(existing);
588
588
  if (sanitized.changed) {
589
589
  try {
590
590
  (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), {
591
- version: 2,
591
+ version: 3,
592
592
  bucketKey,
593
593
  activeId: sanitized.index.activeId,
594
594
  headers: sanitized.index.headers,
@@ -601,10 +601,13 @@ class AiHubConversationStore {
601
601
  return sanitized.index;
602
602
  }
603
603
  const headers = this.readAllConversations(bucketDir, bucketKey).map(toHeader).sort(newestFirst);
604
- const rebuilt = { activeId: null, headers };
604
+ // Older indexes predate proposalSet in headers. Rebuild from canonical shards
605
+ // once so pending reviews are visible immediately after upgrading.
606
+ const activeId = existing?.activeId && headers.some((header) => header.id === existing.activeId) ? existing.activeId : null;
607
+ const rebuilt = { activeId, headers };
605
608
  if (headers.length > 0) {
606
609
  try {
607
- (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 2, bucketKey, activeId: null, headers });
610
+ (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 3, bucketKey, activeId, headers });
608
611
  }
609
612
  catch {
610
613
  /* best-effort */
@@ -623,7 +626,7 @@ class AiHubConversationStore {
623
626
  writeIndex(bucketDir, bucketKey, activeId, conversations) {
624
627
  const headers = conversations.map(toHeader).sort(newestFirst);
625
628
  const validActive = activeId && headers.some((h) => h.id === activeId) ? activeId : null;
626
- (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 2, bucketKey, activeId: validActive, headers });
629
+ (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 3, bucketKey, activeId: validActive, headers });
627
630
  }
628
631
  // Author a whole bucket to exactly `conversations` (used by migration and replaceProject).
629
632
  writeBucket(bucketKey, state) {
@@ -872,7 +875,7 @@ class AiHubConversationStore {
872
875
  const state = (0, conversation_store_lock_1.withBucketLock)(this.lockPath(bucketDir), () => {
873
876
  const idx = this.loadIndex(bucketDir, key);
874
877
  const valid = activeId && idx.headers.some((h) => h.id === activeId) ? activeId : null;
875
- (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 2, bucketKey: key, activeId: valid, headers: idx.headers });
878
+ (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 3, bucketKey: key, activeId: valid, headers: idx.headers });
876
879
  return normalizeProjectState(key, { activeId: valid, conversations: idx.headers });
877
880
  });
878
881
  if (!bucketAlreadyExisted)
@@ -937,7 +940,7 @@ class AiHubConversationStore {
937
940
  if (!conversationExists) {
938
941
  (0, conversation_search_projection_1.deleteConversationSearchEntry)(bucketDir, conversationId);
939
942
  if (headers.length !== index.headers.length || activeId !== index.activeId) {
940
- (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 2, bucketKey: key, activeId, headers });
943
+ (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 3, bucketKey: key, activeId, headers });
941
944
  }
942
945
  return { removed: false, activeId };
943
946
  }
@@ -947,7 +950,7 @@ class AiHubConversationStore {
947
950
  (0, conversation_search_projection_1.deleteConversationSearchEntry)(bucketDir, conversationId);
948
951
  fs_1.default.rmSync(this.indexPath(bucketDir), { force: true });
949
952
  fs_1.default.rmSync(conversationPath, { force: true });
950
- (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 2, bucketKey: key, activeId, headers });
953
+ (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 3, bucketKey: key, activeId, headers });
951
954
  return { removed: true, activeId };
952
955
  });
953
956
  }
@@ -960,7 +963,7 @@ class AiHubConversationStore {
960
963
  let nextActive = activeId === undefined ? idx.activeId : activeId;
961
964
  if (nextActive && !headers.some((h) => h.id === nextActive))
962
965
  nextActive = null;
963
- (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 2, bucketKey, activeId: nextActive, headers });
966
+ (0, atomic_json_file_1.writeJsonAtomic)(this.indexPath(bucketDir), { version: 3, bucketKey, activeId: nextActive, headers });
964
967
  return normalizeProjectState(bucketKey, { activeId: nextActive, conversations: headers });
965
968
  }
966
969
  stateFromIndex(bucketDir, bucketKey) {
@@ -3,6 +3,7 @@ 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.readBuiltInEmployeeAssignments = readBuiltInEmployeeAssignments;
6
7
  exports.readCustomEmployees = readCustomEmployees;
7
8
  exports.writeCustomEmployee = writeCustomEmployee;
8
9
  exports.deleteCustomEmployee = deleteCustomEmployee;
@@ -34,7 +35,17 @@ function layerEmployeeReadDirs(layer) {
34
35
  function layerEmployeeWriteDir(layer) {
35
36
  return path_1.default.join((0, pack_home_1.resolvePackHome)(layer).contentRoot, EMPLOYEES_SUBDIR);
36
37
  }
37
- function readEmployeesFromDir(dir, scope) {
38
+ /**
39
+ * Every parseable `*.json` record in one employees directory.
40
+ *
41
+ * A missing directory, an unreadable directory, and a malformed file are all the
42
+ * same thing to a caller: that record is not there. Issue #1925 added a second
43
+ * reader over these same directories, so the sweep lives here once and each caller
44
+ * supplies only what differs — which records it keeps and what it makes of them.
45
+ * The only path ever constructed is `dir` joined with a name `readdirSync` returned,
46
+ * never a value taken from file content.
47
+ */
48
+ function readJsonRecordsFromDir(dir) {
38
49
  if (!fs_1.default.existsSync(dir))
39
50
  return [];
40
51
  let names;
@@ -49,14 +60,7 @@ function readEmployeesFromDir(dir, scope) {
49
60
  if (!file.endsWith('.json'))
50
61
  continue;
51
62
  try {
52
- const parsed = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8'));
53
- if (parsed && typeof parsed.key === 'string' && parsed.key.startsWith('custom:')) {
54
- // The record's own scope is authoritative when present; otherwise the
55
- // directory it was found in names its level. A record written before this
56
- // change carries scope 'project' and is found in the project dir, so both
57
- // agree and nothing is reinterpreted.
58
- out.push({ ...parsed, scope: parsed.scope ?? scope });
59
- }
63
+ out.push(JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8')));
60
64
  }
61
65
  catch {
62
66
  // silently skip malformed files
@@ -64,6 +68,100 @@ function readEmployeesFromDir(dir, scope) {
64
68
  }
65
69
  return out;
66
70
  }
71
+ function readEmployeesFromDir(dir, scope) {
72
+ const out = [];
73
+ for (const record of readJsonRecordsFromDir(dir)) {
74
+ const parsed = record;
75
+ if (parsed && typeof parsed.key === 'string' && parsed.key.startsWith('custom:')) {
76
+ // The record's own scope is authoritative when present; otherwise the
77
+ // directory it was found in names its level. A record written before this
78
+ // change carries scope 'project' and is found in the project dir, so both
79
+ // agree and nothing is reinterpreted.
80
+ out.push({ ...parsed, scope: parsed.scope ?? scope });
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+ function isCustomEmployeeScope(value) {
86
+ return value === 'manager' || value === 'project' || value === 'org';
87
+ }
88
+ /**
89
+ * Issue #1925: the assignment records in one employees directory.
90
+ *
91
+ * Same directory, same `*.json` sweep, different discriminator: `readEmployeesFromDir`
92
+ * keeps records whose `key` is `custom:<slug>`; this keeps records whose `key` names a
93
+ * built-in persona. The two sets are disjoint by construction, so a record is an
94
+ * employee or an assignment, never both.
95
+ *
96
+ * The file name is not the discriminator. The `assign-job-to-employee` skill writes
97
+ * `<key>.builtin.json` so an assignment can never overwrite a custom employee's
98
+ * `<slug>.json` (a slug from `slugifyDisplayName` is `[a-z0-9-]+` and holds no second
99
+ * dot), but a record hand-written as `aida.json` is read just the same — being
100
+ * silently ignored is the #1925 symptom, not the contract.
101
+ */
102
+ function readAssignmentsFromDir(dir, scope) {
103
+ const out = [];
104
+ for (const record of readJsonRecordsFromDir(dir)) {
105
+ const parsed = record;
106
+ if (!parsed || typeof parsed.key !== 'string' || !(0, persona_hiring_1.isPersonaHireKey)(parsed.key))
107
+ continue;
108
+ // A record that claims no job assigns nothing; carrying it would put an empty
109
+ // roster entry in front of the catalog for no reason.
110
+ const jobIds = Array.isArray(parsed.jobIds)
111
+ ? parsed.jobIds.filter((jobId) => typeof jobId === 'string' && jobId.trim().length > 0)
112
+ : [];
113
+ if (jobIds.length === 0)
114
+ continue;
115
+ out.push({
116
+ key: parsed.key,
117
+ jobIds,
118
+ createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : '',
119
+ // The directory a record was found in is authoritative unless the record names
120
+ // a level that actually exists. An unrecognized string would otherwise be
121
+ // returned in a field typed CustomEmployeeScope, making the type a lie.
122
+ scope: isCustomEmployeeScope(parsed.scope) ? parsed.scope : scope,
123
+ createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : '',
124
+ });
125
+ }
126
+ return out;
127
+ }
128
+ /**
129
+ * Read every built-in employee job assignment visible from this project.
130
+ *
131
+ * Levels UNION rather than override, which is the one place this differs from
132
+ * `readCustomEmployees`. An employee record is a whole employee, so the nearest
133
+ * level's copy replacing the others is right. An assignment is a list of jobs a
134
+ * level hands to a shared, already-existing employee: a project record naming AIDa
135
+ * and a manager record naming AIDa describe different jobs, and overriding would
136
+ * orphan the manager's the moment the project's appeared.
137
+ *
138
+ * Returned highest-precedence-first (project, then manager, then org) so a caller
139
+ * resolving a jobId claimed at two levels lands on the nearest one.
140
+ */
141
+ function readBuiltInEmployeeAssignments(projectDir) {
142
+ const byKey = new Map();
143
+ const merge = (assignments) => {
144
+ for (const assignment of assignments) {
145
+ const existing = byKey.get(assignment.key);
146
+ if (!existing) {
147
+ byKey.set(assignment.key, { ...assignment, jobIds: [...assignment.jobIds] });
148
+ continue;
149
+ }
150
+ for (const jobId of assignment.jobIds) {
151
+ if (!existing.jobIds.includes(jobId))
152
+ existing.jobIds.push(jobId);
153
+ }
154
+ }
155
+ };
156
+ // Highest precedence first: the first level to claim a jobId keeps it, and the
157
+ // scope reported for a persona is the nearest level that assigned anything.
158
+ merge(readAssignmentsFromDir(employeesDir(projectDir), 'project'));
159
+ for (const dir of layerEmployeeReadDirs('manager'))
160
+ merge(readAssignmentsFromDir(dir, 'manager'));
161
+ for (const dir of layerEmployeeReadDirs('org'))
162
+ merge(readAssignmentsFromDir(dir, 'org'));
163
+ return [...byKey.values()];
164
+ }
67
165
  function slugFor(key) {
68
166
  // key is 'custom:<slug>' — file is named '<slug>.json'
69
167
  return key.replace(/^custom:/, '');
@@ -247,6 +247,18 @@ function resolveConversationPersonaKey(conversation, projectPath, customJobOwner
247
247
  *
248
248
  * Building the jobId -> persona index once per request keeps the derivation O(1)
249
249
  * per conversation and the disk walk at exactly one.
250
+ *
251
+ * Issue #1925: the index now has two local sources, not one. A custom employee
252
+ * owns jobs through its own record; a BUILT-IN employee can now own a project-,
253
+ * manager-, or org-level job through an assignment record in the same employees
254
+ * directory, so a job local to one repository no longer has to be added to the
255
+ * shipped `protectedJobs` catalog to have an owner. Custom employees are indexed
256
+ * first, so every configuration that resolved before this change still resolves
257
+ * identically; assignments fill only jobIds nothing else already claims, and the
258
+ * catalog remains the fallback behind both.
259
+ *
260
+ * This is ownership for display and attribution only. Entitlement still reads
261
+ * `getProtectedPersonaForJob`, which sees neither local source.
250
262
  */
251
263
  function buildCustomJobOwnerIndex(projectPath) {
252
264
  const index = new Map();
@@ -258,6 +270,12 @@ function buildCustomJobOwnerIndex(projectPath) {
258
270
  index.set(jobId, employee.key);
259
271
  }
260
272
  }
273
+ for (const assignment of (0, custom_employees_1.readBuiltInEmployeeAssignments)(projectPath)) {
274
+ for (const jobId of assignment.jobIds) {
275
+ if (!index.has(jobId))
276
+ index.set(jobId, assignment.key);
277
+ }
278
+ }
261
279
  return index;
262
280
  }
263
281
  /** Apply read-time persona derivation to a conversation record before serving it. */
@@ -266,13 +284,16 @@ function withDerivedPersona(conversation, projectPath, customJobOwners) {
266
284
  delete result.microEvents;
267
285
  return result;
268
286
  }
269
- // Issue #991: consult custom employee records before falling back to the catalog.
270
- // Returns the key of the first custom employee whose jobIds array includes jobId,
271
- // or null if no custom employee owns it.
287
+ // Issue #991: consult local employee records before falling back to the catalog.
288
+ // Returns the key of the employee that owns jobId locally, or null if none does.
289
+ //
290
+ // Issue #1925: this is the single-lookup form of buildCustomJobOwnerIndex, and it
291
+ // now delegates to it rather than re-deriving the same answer from a subset of the
292
+ // sources. Two independent implementations of one rule drift; whichever one a new
293
+ // source is added to becomes the only one that knows about it, which is exactly how
294
+ // a built-in assignment could resolve in the conversations list and not at run launch.
272
295
  function getCustomPersonaForJob(projectPath, jobId) {
273
- const employees = (0, custom_employees_1.readCustomEmployees)(projectPath);
274
- const match = employees.find((e) => Array.isArray(e.jobIds) && e.jobIds.includes(jobId));
275
- return match ? match.key : null;
296
+ return buildCustomJobOwnerIndex(projectPath).get(jobId) ?? null;
276
297
  }
277
298
  // This set exists only for jobs that need hiding from the main picker for a
278
299
  // reason beyond ownership. `praise-fraim` is already surfaced in the Hub
@@ -1822,8 +1843,8 @@ function applySeekMentoringSignal(run, signal, onReviewFailure) {
1822
1843
  verifyReviewHandoffReflectsSubmission(run, signal.reviewHandoff, onReviewFailure);
1823
1844
  }
1824
1845
  }
1825
- else {
1826
- // The most recent mentoring intent owns review lifetime across all jobs.
1846
+ else if (signal.phaseStatus !== 'starting') {
1847
+ // Phase completions and failures own review lifetime; on-arrival calls do not.
1827
1848
  run.reviewHandoff = null;
1828
1849
  }
1829
1850
  if (signal.deliveryActionId !== undefined) {
@@ -69,7 +69,7 @@ ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
69
69
  Use this process only when the user explicitly invokes FRAIM, names a FRAIM job, asks what FRAIM job to run, or the active surface has already selected a FRAIM job. For ordinary requests, answer or work normally; do not scan FRAIM stubs first.
70
70
 
71
71
  2. **If the user did not specify a FRAIM job or topic after activation**:
72
- If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect \`fraim/personalized-employee/jobs/\` for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
72
+ If local FRAIM job stubs are present in the workspace, read that jobs directory's \`INDEX.md\` first (it lists every job's name, one-line intent, and category) and match the request against it; open an individual stub only to confirm a shortlisted candidate, or fall back to scanning the stubs directly if no \`INDEX.md\` is present. Also check \`fraim/personalized-employee/jobs/\` (and its own \`INDEX.md\`, if present) for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
73
73
 
74
74
  3. **Find the match**:
75
75
  If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, call \`get_fraim_job({ job: "adhoc-prompt" })\` directly and execute it with the user's instructions as the task input — no confirmation question, and do not pick the nearest catalog job.
@@ -282,7 +282,13 @@ function buildPersonaAvatarUrl(personaKey) {
282
282
  return `https://api.dicebear.com/9.x/${avatar.style}/svg?${params.toString()}`;
283
283
  }
284
284
  function isPersonaHireKey(value) {
285
- return value in exports.PERSONA_HIRE_CATALOG;
285
+ // Own properties only. `in` also answers true for every Object.prototype member,
286
+ // so 'constructor', 'toString', '__proto__' and friends passed this guard and then
287
+ // indexed the catalog to a prototype member rather than a persona. The guard is the
288
+ // input validation in front of the hire-checkout and entitlement reads, where the
289
+ // value is caller-supplied, and (issue #1925) in front of the employee-record reader,
290
+ // where it is file-supplied.
291
+ return Object.prototype.hasOwnProperty.call(exports.PERSONA_HIRE_CATALOG, value);
286
292
  }
287
293
  function getPersonaHireAmountCents(personaKey, mode) {
288
294
  const persona = exports.PERSONA_HIRE_CATALOG[personaKey];
@@ -297,11 +297,16 @@ function upsertPhaseStarted(marker, phaseId, timestamp) {
297
297
  startedPhases[phaseId] = timestamp;
298
298
  return { startedPhases };
299
299
  }
300
- /** Builds the warn-only notice for any phase completed without an on-arrival start. */
301
- function buildPhaseStartWarning(phaseId) {
302
- return (`⚠️ **Phase-start notice**: phase \`${phaseId}\` completed without an on-arrival ` +
300
+ /** Builds the warn-only notice for any phase completed without an on-arrival start.
301
+ * When nextPhase is supplied, a forward-looking instruction is appended so the agent
302
+ * knows to call seekMentoring({status:"starting"}) before beginning the next phase. */
303
+ function buildPhaseStartWarning(phaseId, nextPhase) {
304
+ const base = (`⚠️ **Phase-start notice**: phase \`${phaseId}\` completed without an on-arrival ` +
303
305
  `\`seekMentoring({ currentPhase: "${phaseId}", status: "starting" })\` call in this session. ` +
304
306
  `The missing call was flagged, but this completion remains accepted.\n\n`);
307
+ if (!nextPhase)
308
+ return base;
309
+ return base + `Before starting any work in **${nextPhase}**, call \`seekMentoring({ currentPhase: "${nextPhase}", status: "starting" })\` immediately.\n\n`;
305
310
  }
306
311
  function isAbsoluteHttpUrl(url) {
307
312
  if (typeof url !== 'string' || !url)
@@ -520,33 +520,60 @@ class LocalRegistryResolver {
520
520
  // publish or sync and resolves at all under `single-machine`).
521
521
  const managerLocalContent = this.readManagerLocalOverlayFile(path);
522
522
  if (managerLocalContent !== null) {
523
+ // Issue #1867: apply inheritance so an extends-based override merges with its parent.
524
+ let resolvedManagerLocal;
525
+ try {
526
+ resolvedManagerLocal = await this.resolveInheritance(managerLocalContent, path);
527
+ }
528
+ catch {
529
+ resolvedManagerLocal = { content: managerLocalContent, imports: [] };
530
+ }
523
531
  return {
524
- content: managerLocalContent,
532
+ content: resolvedManagerLocal.content,
525
533
  source: 'local',
526
534
  personalized: true,
527
- inherited: false,
535
+ inherited: resolvedManagerLocal.imports.length > 0,
536
+ imports: resolvedManagerLocal.imports.length > 0 ? resolvedManagerLocal.imports : undefined,
528
537
  scope: 'manager'
529
538
  };
530
539
  }
531
540
  // Issue #869: manager overlay (precedence 3 — the synced cross-machine copy)
532
541
  const managerOverlayContent = this.readManagerOverlayFile(path);
533
542
  if (managerOverlayContent !== null) {
543
+ // Issue #1867: apply inheritance so an extends-based override merges with its parent.
544
+ let resolvedManagerOverlay;
545
+ try {
546
+ resolvedManagerOverlay = await this.resolveInheritance(managerOverlayContent, path);
547
+ }
548
+ catch {
549
+ resolvedManagerOverlay = { content: managerOverlayContent, imports: [] };
550
+ }
534
551
  return {
535
- content: managerOverlayContent,
552
+ content: resolvedManagerOverlay.content,
536
553
  source: 'local',
537
554
  personalized: true,
538
- inherited: false,
555
+ inherited: resolvedManagerOverlay.imports.length > 0,
556
+ imports: resolvedManagerOverlay.imports.length > 0 ? resolvedManagerOverlay.imports : undefined,
539
557
  scope: 'manager'
540
558
  };
541
559
  }
542
560
  // Issue #869 Phase 2: org overlay (precedence 3 — after manager, before synced baseline)
543
561
  const orgOverlayContent = this.readOrgOverlayFile(path);
544
562
  if (orgOverlayContent !== null) {
563
+ // Issue #1867: apply inheritance so an extends-based override merges with its parent.
564
+ let resolvedOrgOverlay;
565
+ try {
566
+ resolvedOrgOverlay = await this.resolveInheritance(orgOverlayContent, path);
567
+ }
568
+ catch {
569
+ resolvedOrgOverlay = { content: orgOverlayContent, imports: [] };
570
+ }
545
571
  return {
546
- content: orgOverlayContent,
572
+ content: resolvedOrgOverlay.content,
547
573
  source: 'local',
548
574
  personalized: true,
549
- inherited: false,
575
+ inherited: resolvedOrgOverlay.imports.length > 0,
576
+ imports: resolvedOrgOverlay.imports.length > 0 ? resolvedOrgOverlay.imports : undefined,
550
577
  scope: 'org'
551
578
  };
552
579
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.341",
3
+ "version": "2.0.343",
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",
@@ -231,7 +231,7 @@
231
231
  "electron-updater": "^6.8.9",
232
232
  "express": "^5.2.1",
233
233
  "extract-zip": "^2.0.1",
234
- "fraim": "2.0.341",
234
+ "fraim": "2.0.343",
235
235
  "mongodb": "^7.0.0",
236
236
  "node-cron": "4.2.1",
237
237
  "node-edge-tts": "^1.2.10",
@@ -362,6 +362,8 @@
362
362
  .learn-prop .lp-skip.on {
363
363
  background: var(--danger, #a24747); color: #fff; border-color: transparent;
364
364
  }
365
+ :root[data-theme="dark"] .learn-prop .lp-promote.on,
366
+ :root[data-theme="dark"] .learn-prop .lp-skip.on { color: var(--bg); }
365
367
  .learn-prop .lp-reason { font-size: 11.5px; color: var(--muted); line-height: 1.5; }
366
368
  .learn-prop .lp-reason b { color: var(--text); font-weight: 600; }
367
369
  .learn-prop .lp-why { display: flex; flex-direction: column; gap: 5px; margin-top: 2px; }
@@ -387,9 +389,8 @@
387
389
  gets its own slightly darker text rather than restyling every managed-disabled
388
390
  control on surfaces this change did not validate. */
389
391
  .review-actions.learn-batch .rb-approve.managed-disabled { color: #5f5f5f; }
390
- /* A skipped row's level choice is moot: no level reads as chosen, and the whole
391
- picker recedes so the row's one live state is the skip. */
392
- .learn-prop .lp-level-pick.lp-moot { opacity: 0.55; }
392
+ /* Choosing a level restores a skipped proposal, so these controls stay readable. */
393
+ .learn-prop .lp-level-pick.lp-moot { opacity: 1; }
393
394
  :root[data-theme="dark"] .review-actions.learn-batch .rb-approve.managed-disabled { color: #cfcfcf; }
394
395
 
395
396
  /* ── R10: Reverse mentoring items (Got it / Dismiss) ── */
@@ -11,6 +11,7 @@
11
11
 
12
12
  const STORAGE_KEY_TREE_WIDTH = 'fraim.aiHub.treeSidebarWidth.v1';
13
13
  const STORAGE_KEY_TREE_COLLAPSED = 'fraim.aiHub.treeSidebarCollapsed.v1';
14
+ const STORAGE_KEY_DELIVERABLES_COLLAPSED = 'fraim.aiHub.deliverablesCollapsed.v1';
14
15
  const TREE_WIDTH_MIN = 176;
15
16
  const TREE_WIDTH_MAX = 380;
16
17
  const TREE_WIDTH_DEFAULT = 216;
@@ -563,6 +564,7 @@ async function tfRefreshPersonasFromServerInBackground() {
563
564
  }
564
565
 
565
566
  async function loadBootstrap(projectPath, docUrl, options) {
567
+ const sequence = state._bootstrapLoadSequence = (state._bootstrapLoadSequence || 0) + 1;
566
568
  const opts = options || {};
567
569
  const requestedKey = bootstrapCacheKey(projectPath || state.projectPath, docUrl);
568
570
  if (opts.preferCache && bootstrapCache.has(requestedKey)) {
@@ -572,7 +574,7 @@ async function loadBootstrap(projectPath, docUrl, options) {
572
574
  return cached;
573
575
  }
574
576
  const bootstrap = await fetchBootstrap(projectPath, docUrl);
575
- applyBootstrap(bootstrap, docUrl);
577
+ if (sequence === state._bootstrapLoadSequence) applyBootstrap(bootstrap, docUrl);
576
578
  return bootstrap;
577
579
  }
578
580
 
@@ -1799,11 +1801,19 @@ function stopBgVersionPoll() {
1799
1801
  // the onboarding run (e.g. on Project Info) nothing refreshed, and the
1800
1802
  // bootstrap-derived state (needsOnboarding, jobs, personas) stayed stale until a
1801
1803
  // restart. This runs from every poll path so active selection no longer matters.
1804
+ // Manager and Company onboarding refresh their corresponding context readiness.
1802
1805
  async function onProjectOnboardingSettled(conv) {
1803
- if (!conv || conv.jobId !== 'project-onboarding') return;
1806
+ if (!conv || !['project-onboarding', 'organization-onboarding', 'manager-agreements'].includes(conv.jobId)) return;
1804
1807
  const settled = (typeof convAwaitingReview === 'function' && convAwaitingReview(conv)) || (conv.status && conv.status !== 'running');
1805
1808
  if (!settled || conv._onboardingSettledHandled) return;
1806
1809
  conv._onboardingSettledHandled = true;
1810
+ if (conv.jobId !== 'project-onboarding') {
1811
+ const key = conv.jobId === 'organization-onboarding' ? 'org' : 'manager';
1812
+ const render = key === 'org' ? tfRenderCompany : tfRenderManager;
1813
+ try { await tfFetchContext(key); } catch (_) { /* Keep the last known readiness and onboarding action. */ }
1814
+ render();
1815
+ return;
1816
+ }
1807
1817
  // Drop cached project context so the Brief re-reads the freshly written files.
1808
1818
  const cache = (typeof tfCtxCache === 'function') ? tfCtxCache() : null;
1809
1819
  if (cache) { for (const k of ['projectContext', 'projectBrief', 'projectRules', 'projectQa']) delete cache[k]; }
@@ -1819,12 +1829,12 @@ async function onProjectOnboardingSettled(conv) {
1819
1829
  if (typeof tfRenderProjectContextTop === 'function') tfRenderProjectContextTop();
1820
1830
  }
1821
1831
 
1822
- // Scan the project's conversations for a settled-but-unhandled onboarding run and
1832
+ // Scan scoped conversations for a settled-but-unhandled onboarding run and
1823
1833
  // refresh once. Called from every poll path so completion is caught whether or
1824
1834
  // not the onboarding run is the actively-viewed conversation.
1825
1835
  async function maybeRefreshForSettledOnboarding() {
1826
- const convs = (typeof projectConversations === 'function') ? projectConversations() : [];
1827
- const onboarding = convs.find((c) => c && c.jobId === 'project-onboarding' && !c._onboardingSettledHandled
1836
+ const convs = [...projectConversations(), ...tfAreaScopedConversations('manager'), ...tfAreaScopedConversations('company')];
1837
+ const onboarding = convs.find((c) => c && ['project-onboarding', 'organization-onboarding', 'manager-agreements'].includes(c.jobId) && !c._onboardingSettledHandled
1828
1838
  && ((typeof convAwaitingReview === 'function' && convAwaitingReview(c)) || (c.status && c.status !== 'running')));
1829
1839
  if (onboarding) await onProjectOnboardingSettled(onboarding);
1830
1840
  }
@@ -2916,6 +2926,23 @@ function renderRail() {
2916
2926
  renderScopeEmployees(els['conv-list'], 'projects');
2917
2927
  }
2918
2928
 
2929
+ // Availability is a projection of the same readiness used by each Info view.
2930
+ // It gates rail assignment only; historical work and onboarding stay reachable.
2931
+ function employeeRailReadiness(area, conversations) {
2932
+ const scopeName = area === 'projects' ? 'Project' : area === 'company' ? 'Company' : 'Manager';
2933
+ const key = area === 'company' ? 'org' : 'manager';
2934
+ const project = state.bootstrap && state.bootstrap.project;
2935
+ const context = area === 'projects' ? null : tfCtxCache()[key];
2936
+ const currentProject = project && tfCanonicalProjectPath(project.path) === tfCanonicalProjectPath(state.projectPath);
2937
+ const ready = area === 'projects'
2938
+ ? currentProject && project.hasFraim && !project.needsOnboarding
2939
+ : context && context.present;
2940
+ if (ready) return { ready: true, message: '' };
2941
+ const jobId = area === 'projects' ? 'project-onboarding' : area === 'company' ? 'organization-onboarding' : 'manager-agreements';
2942
+ const pending = conversations.find(c => c.jobId === jobId && (c.status === 'running' || convAwaitingReview(c)));
2943
+ return { ready: false, message: scopeName + ' onboarding is incomplete.', pending };
2944
+ }
2945
+
2919
2946
  // All scopes share grouping, employee actions, nested workstreams and poll state.
2920
2947
  function renderScopeEmployees(host, area) {
2921
2948
  if (!host) return;
@@ -2946,6 +2973,7 @@ function renderScopeEmployees(host, area) {
2946
2973
  const scopedConversations = isProject
2947
2974
  ? projectConversations().filter((conv) => convScope(conv) === 'project')
2948
2975
  : tfAreaScopedConversations(area);
2976
+ const readiness = employeeRailReadiness(area, scopedConversations);
2949
2977
  const managedChildren = scopedConversations.filter(isManagedDelegationChild);
2950
2978
  const childrenByManager = new Map();
2951
2979
  const childIdentity = (child) => child && (child.id || child.runId);
@@ -2998,8 +3026,7 @@ function renderScopeEmployees(host, area) {
2998
3026
  // The employee tree mirrors the pre-851 per-employee accordion: each employee gets
2999
3027
  // their own details/summary row showing their COMPLETED runs underneath, plus the
3000
3028
  // "+" assign control on the summary row. No-run employees still appear.
3001
- // Runs with no owning FRAIM employee that are complete go to one absence
3002
- // group at the bottom of the employee tree.
3029
+ // Employee-less work shares the employee tree's activity ordering.
3003
3030
 
3004
3031
  // Helper: stable kickoff-time sort key for the rail tree. Live updates change
3005
3032
  // lastUpdatedAt frequently; createdAt pins a run where it was kicked off.
@@ -3048,10 +3075,10 @@ function renderScopeEmployees(host, area) {
3048
3075
  ]);
3049
3076
  // Apply persona filter.
3050
3077
  const areaPersonas = isProject ? [] : tfAreaRailPersonas(area);
3051
- const areaPersonaOrder = new Map(areaPersonas.map((persona, index) => [persona.key, index]));
3078
+ const areaPersonaKeys = new Set(areaPersonas.map((persona) => persona.key));
3052
3079
  const filteredEmployeeKeys = isProject
3053
3080
  ? (state.selectedPersonaKey ? new Set([state.selectedPersonaKey].filter((k) => projectEmployeeKeys.has(k))) : projectEmployeeKeys)
3054
- : new Set(areaPersonaOrder.keys());
3081
+ : areaPersonaKeys;
3055
3082
 
3056
3083
  for (const key of filteredEmployeeKeys) {
3057
3084
  if (doneGroups.has(key)) continue;
@@ -3065,24 +3092,31 @@ function renderScopeEmployees(host, area) {
3065
3092
  });
3066
3093
  }
3067
3094
 
3095
+ // Only started top-level work contributes activity. Drafts and managed children
3096
+ // must not create an employee-less group or promote its position.
3097
+ const startedConversations = list.filter(conv => conv.runId || ['running', 'completed', 'failed'].includes(conv.status));
3098
+ const unownedHistory = startedConversations.filter(conv => !conversationHasFraimEmployee(conv));
3099
+ if (unownedHistory.length) {
3100
+ doneGroups.set(NO_FRAIM_EMPLOYEE_GROUP_KEY, {
3101
+ key: NO_FRAIM_EMPLOYEE_GROUP_KEY, label: 'No FRAIM Employee', detail: '',
3102
+ sample: null, unowned: true, conversations: noFraimEmployeeConvs.sort(compareByKickoffTime),
3103
+ });
3104
+ }
3068
3105
  // Compute latest kickoff timestamp per employee for employee tree sorting.
3069
3106
  const employeeLatest = new Map();
3070
- for (const conv of scopedConversations) {
3071
- if (!conv) continue;
3072
- const key = conv.personaKey || conversationAgentName(conv);
3073
- if (!key) continue;
3107
+ for (const conv of startedConversations) {
3108
+ const key = conversationHasFraimEmployee(conv) ? conv.personaKey : NO_FRAIM_EMPLOYEE_GROUP_KEY;
3074
3109
  const ts = convStart(conv);
3075
3110
  if (!employeeLatest.has(key) || ts > employeeLatest.get(key)) employeeLatest.set(key, ts);
3076
3111
  }
3077
3112
 
3078
- // Sort employee groups: employees with completed runs first (latest kickoff),
3113
+ // Sort employee groups: employees with started work first (latest kickoff),
3079
3114
  // then no-run employees last (stable by key).
3080
3115
  const sortedDoneGroups = [...doneGroups.values()].sort((a, b) => {
3081
- if (!isProject) return (areaPersonaOrder.get(a.key) ?? Infinity) - (areaPersonaOrder.get(b.key) ?? Infinity);
3082
3116
  const aLatest = employeeLatest.get(a.key) || 0;
3083
3117
  const bLatest = employeeLatest.get(b.key) || 0;
3084
- const aHas = aLatest > 0 ? 1 : 0;
3085
- const bHas = bLatest > 0 ? 1 : 0;
3118
+ const aHas = employeeLatest.has(a.key) ? 1 : 0;
3119
+ const bHas = employeeLatest.has(b.key) ? 1 : 0;
3086
3120
  if (aHas !== bHas) return bHas - aHas;
3087
3121
  if (aLatest !== bLatest) return bLatest - aLatest;
3088
3122
  return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
@@ -3104,6 +3138,27 @@ function renderScopeEmployees(host, area) {
3104
3138
  inProgressSec.appendChild(emptyWrap);
3105
3139
  }
3106
3140
  host.appendChild(inProgressSec);
3141
+ const readinessId = 'employee-readiness-' + area;
3142
+ if (!readiness.ready && sortedDoneGroups.some(group => !group.unowned)) {
3143
+ const note = document.createElement('p');
3144
+ note.id = readinessId;
3145
+ note.className = 'employee-readiness-note';
3146
+ note.textContent = readiness.message;
3147
+ const onboard = document.createElement('button');
3148
+ onboard.type = 'button';
3149
+ onboard.className = 'ctx-acc-action';
3150
+ onboard.textContent = readiness.pending ? 'Continue onboarding' : 'Run onboarding';
3151
+ onboard.addEventListener('click', () => {
3152
+ const current = employeeRailReadiness(area, isProject ? projectConversations() : tfAreaScopedConversations(area));
3153
+ if (current.ready) return;
3154
+ if (current.pending) switchToConversation(current.pending.id);
3155
+ else if (isProject) tfOpenOnboardInput();
3156
+ else if (area === 'company') tfStartOrgOnboarding();
3157
+ else tfStartManagerOnboarding();
3158
+ });
3159
+ note.appendChild(onboard);
3160
+ host.appendChild(note);
3161
+ }
3107
3162
 
3108
3163
  // ----- SECTION 2: Per-employee accordion tree -----
3109
3164
  // Each employee gets their own accordion showing their COMPLETED runs underneath,
@@ -3113,12 +3168,15 @@ function renderScopeEmployees(host, area) {
3113
3168
  for (const group of sortedDoneGroups) {
3114
3169
  const details = document.createElement('details');
3115
3170
  details.className = 'conv-employee-group';
3171
+ if (group.unowned) details.classList.add('conv-employee-group--adhoc');
3172
+ else if (!readiness.ready) details.classList.add('conv-employee-group--unavailable');
3116
3173
  details.dataset.employeeKey = group.key;
3117
3174
  details.dataset.projectKey = activeRailProjectKey;
3118
- details.open = preservedEmployeeGroupOpen(group.key, false);
3175
+ details.open = preservedEmployeeGroupOpen(group.key, !!group.unowned);
3119
3176
  const summary = document.createElement('summary');
3120
3177
  summary.className = 'conv-employee-tab';
3121
- const avatar = buildConversationEmployeeAvatar(group.sample, 'conv-employee-avatar');
3178
+ if (!group.unowned && !readiness.ready) summary.title = readiness.message;
3179
+ const avatar = group.unowned ? buildNoFraimEmployeeAvatar('conv-employee-avatar') : buildConversationEmployeeAvatar(group.sample, 'conv-employee-avatar');
3122
3180
  const copy = document.createElement('span');
3123
3181
  copy.className = 'conv-employee-tab-copy';
3124
3182
  const label = document.createElement('strong');
@@ -3128,7 +3186,7 @@ function renderScopeEmployees(host, area) {
3128
3186
  detail.className = 'conv-employee-tab-detail';
3129
3187
  detail.textContent = group.detail;
3130
3188
  copy.appendChild(label);
3131
- copy.appendChild(detail);
3189
+ if (!group.unowned) copy.appendChild(detail);
3132
3190
  const count = document.createElement('span');
3133
3191
  count.className = 'conv-employee-tab-count';
3134
3192
  count.textContent = String(group.conversations.length);
@@ -3136,16 +3194,22 @@ function renderScopeEmployees(host, area) {
3136
3194
  const groupIsRemoved = isRemovedCustomPersona(groupPersona);
3137
3195
  const groupIsActiveCustom = group.key.startsWith('custom:') && isActiveCustomPersona(groupPersona);
3138
3196
  let addBtn = null;
3139
- if (!groupIsRemoved) {
3197
+ if (!groupIsRemoved && !group.unowned) {
3140
3198
  addBtn = document.createElement('button');
3141
3199
  addBtn.type = 'button';
3142
3200
  addBtn.className = 'conv-employee-add';
3143
3201
  addBtn.textContent = '+';
3144
3202
  addBtn.title = 'Assign a job to ' + group.label;
3145
3203
  addBtn.setAttribute('aria-label', 'Assign a job to ' + group.label);
3204
+ if (!readiness.ready) {
3205
+ addBtn.title = readiness.message;
3206
+ addBtn.setAttribute('aria-disabled', 'true');
3207
+ addBtn.setAttribute('aria-describedby', readinessId);
3208
+ }
3146
3209
  addBtn.addEventListener('click', (e) => {
3147
3210
  e.preventDefault();
3148
3211
  e.stopPropagation();
3212
+ if (!employeeRailReadiness(area, scopedConversations).ready) return;
3149
3213
  // Issue #945: custom employees open the standard palette pre-filtered to their assigned jobs.
3150
3214
  // prefixSearch uses the display name so the user sees /nova, not /custom:nova.
3151
3215
  // personaKey is passed separately so renderCpRows can look up the jobIds correctly.
@@ -3184,18 +3248,6 @@ function renderScopeEmployees(host, area) {
3184
3248
  badge.textContent = 'Personalized';
3185
3249
  label.appendChild(badge);
3186
3250
  }
3187
- // Hire-a-human-manager button when applicable (from #693/#538).
3188
- // Custom employees have no human-manager mapping — skip.
3189
- if (!group.key.startsWith('custom:') && state.bootstrap && state.bootstrap.managerHiring && state.bootstrap.managerHiring.roles && state.bootstrap.managerHiring.roles[group.key]) {
3190
- const hireBtn = document.createElement('button');
3191
- hireBtn.type = 'button';
3192
- hireBtn.className = 'eh-hire-manager';
3193
- hireBtn.textContent = '🧑‍💼';
3194
- hireBtn.title = 'Find a human manager for ' + group.label;
3195
- hireBtn.setAttribute('aria-label', 'Find a human manager for ' + group.label);
3196
- hireBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openHireManagerModal(group.key); });
3197
- summary.appendChild(hireBtn);
3198
- }
3199
3251
  summary.appendChild(avatar);
3200
3252
  summary.appendChild(copy);
3201
3253
  if (addBtn) summary.appendChild(addBtn);
@@ -3209,40 +3261,13 @@ function renderScopeEmployees(host, area) {
3209
3261
  emptyWrap.className = 'conv-employee-list';
3210
3262
  const em = document.createElement('div');
3211
3263
  em.className = 'eh-empty';
3212
- em.textContent = 'No jobs assigned yet. Click + to assign a job.';
3264
+ em.textContent = group.unowned ? 'Active jobs are in In Progress.' : !readiness.ready ? readiness.message : 'No jobs assigned yet. Click + to assign a job.';
3213
3265
  emptyWrap.appendChild(em);
3214
3266
  details.appendChild(emptyWrap);
3215
3267
  }
3216
3268
  appendEmployeeGroupWithActions(host, details);
3217
3269
  }
3218
3270
 
3219
- // No FRAIM employee group: completed runs with no owning persona.
3220
- if (noFraimEmployeeConvs.length > 0) {
3221
- const wcDetails = document.createElement('details');
3222
- wcDetails.className = 'conv-employee-group conv-employee-group--adhoc';
3223
- wcDetails.dataset.employeeKey = NO_FRAIM_EMPLOYEE_GROUP_KEY;
3224
- wcDetails.dataset.projectKey = activeRailProjectKey;
3225
- wcDetails.open = preservedEmployeeGroupOpen(NO_FRAIM_EMPLOYEE_GROUP_KEY, true);
3226
- const wcSummary = document.createElement('summary');
3227
- wcSummary.className = 'conv-employee-tab';
3228
- const wcAvatar = buildNoFraimEmployeeAvatar('conv-employee-avatar');
3229
- const wcCopy = document.createElement('span');
3230
- wcCopy.className = 'conv-employee-tab-copy';
3231
- const wcLabel = document.createElement('strong');
3232
- wcLabel.className = 'conv-employee-tab-label';
3233
- wcLabel.textContent = NO_FRAIM_EMPLOYEE_LABEL;
3234
- wcCopy.appendChild(wcLabel);
3235
- const wcCount = document.createElement('span');
3236
- wcCount.className = 'conv-employee-tab-count';
3237
- wcCount.textContent = String(noFraimEmployeeConvs.length);
3238
- wcSummary.appendChild(wcAvatar);
3239
- wcSummary.appendChild(wcCopy);
3240
- wcSummary.appendChild(wcCount);
3241
- wcDetails.appendChild(wcSummary);
3242
- wcDetails.appendChild(buildAreaGroupList(noFraimEmployeeConvs, {}, childrenByManager));
3243
- host.appendChild(wcDetails);
3244
- }
3245
-
3246
3271
  // #769: hire-pending polling indicator
3247
3272
  if (isProject && typeof tf !== 'undefined' && tf && tf.hirePendingState === 'polling') {
3248
3273
  const pendingRow = document.createElement('div');
@@ -7441,6 +7466,31 @@ function renderDeliverablesBar(conv) {
7441
7466
  }
7442
7467
  panel.dataset.signature = signature;
7443
7468
  body.innerHTML = '';
7469
+
7470
+ const header = document.createElement('div');
7471
+ header.className = 'deliverables-header';
7472
+ const headerLeft = document.createElement('div');
7473
+ headerLeft.className = 'deliverables-header-left';
7474
+ const headerLabel = document.createElement('span');
7475
+ headerLabel.className = 'deliverables-header-label';
7476
+ headerLabel.textContent = 'Deliverables';
7477
+ const headerCount = document.createElement('span');
7478
+ headerCount.className = 'deliverables-header-count';
7479
+ headerCount.hidden = true;
7480
+ if (actions.length > 1) headerCount.textContent = '(' + actions.length + ' items)';
7481
+ headerLeft.appendChild(headerLabel);
7482
+ headerLeft.appendChild(headerCount);
7483
+ const toggleBtn = document.createElement('button');
7484
+ toggleBtn.type = 'button';
7485
+ toggleBtn.className = 'deliverables-toggle';
7486
+ toggleBtn.setAttribute('aria-label', 'Toggle deliverables');
7487
+ const chev = document.createElement('span');
7488
+ chev.className = 'chev';
7489
+ chev.textContent = '▾';
7490
+ toggleBtn.appendChild(chev);
7491
+ header.appendChild(headerLeft);
7492
+ header.appendChild(toggleBtn);
7493
+
7444
7494
  const strip = document.createElement('div');
7445
7495
  strip.className = 'rc-artifact-strip';
7446
7496
  for (const action of actions) {
@@ -7471,7 +7521,27 @@ function renderDeliverablesBar(conv) {
7471
7521
  btn.addEventListener('click', () => handleArtifactAction(conv, action));
7472
7522
  strip.appendChild(btn);
7473
7523
  }
7524
+ body.appendChild(header);
7474
7525
  body.appendChild(strip);
7526
+
7527
+ let isCollapsed = false;
7528
+ try { isCollapsed = window.localStorage.getItem(STORAGE_KEY_DELIVERABLES_COLLAPSED) === '1'; } catch {}
7529
+ panel.classList.toggle('deliverables-collapsed', isCollapsed);
7530
+ headerCount.hidden = !isCollapsed || actions.length <= 1;
7531
+ toggleBtn.setAttribute('aria-expanded', String(!isCollapsed));
7532
+
7533
+ header.addEventListener('click', () => {
7534
+ const collapsed = !panel.classList.contains('deliverables-collapsed');
7535
+ panel.classList.toggle('deliverables-collapsed', collapsed);
7536
+ try {
7537
+ if (collapsed) window.localStorage.setItem(STORAGE_KEY_DELIVERABLES_COLLAPSED, '1');
7538
+ else window.localStorage.removeItem(STORAGE_KEY_DELIVERABLES_COLLAPSED);
7539
+ } catch {}
7540
+ toggleBtn.setAttribute('aria-expanded', String(!collapsed));
7541
+ headerCount.hidden = !collapsed || actions.length <= 1;
7542
+ syncThreadMessageViewport();
7543
+ });
7544
+
7475
7545
  messages.appendChild(panel);
7476
7546
  }
7477
7547
 
@@ -9366,6 +9436,7 @@ function foldRunIntoConversation(conv, run) {
9366
9436
  conv.hostSessions = { ...(conv.hostSessions || {}), ...run.hostSessions };
9367
9437
  }
9368
9438
  const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
9439
+ conv.proposalSet = run.proposalSet || null;
9369
9440
  if (runHandoff) {
9370
9441
  conv.reviewHandoff = runHandoff;
9371
9442
  } else if (run.reviewHandoff) {
@@ -9481,7 +9552,7 @@ function startPolling() {
9481
9552
  // (reaches the review gate) or completes, refresh the project view live so
9482
9553
  // the captured context/rules, employees, jobs, and onboarding state appear
9483
9554
  // without a restart. Re-fetches bootstrap, not just the Brief cache.
9484
- if (conv.jobId === 'project-onboarding') {
9555
+ if (['project-onboarding', 'organization-onboarding', 'manager-agreements'].includes(conv.jobId)) {
9485
9556
  await onProjectOnboardingSettled(conv);
9486
9557
  }
9487
9558
  const nowFraimDone = conv.status !== 'running';
@@ -13776,26 +13847,48 @@ function tfProfileRow(label, valueHtml) {
13776
13847
  // Caches live on `state` (guaranteed in scope) rather than free module vars so
13777
13848
  // they are robust to this file's scoping. key -> { present, displayPath, scope,
13778
13849
  // content } and key -> bool (row currently in edit mode).
13779
- function tfCtxCache() { if (!state._ctxCache) state._ctxCache = {}; return state._ctxCache; }
13850
+ function tfCtxCache() {
13851
+ const projectPath = tfCanonicalProjectPath(state.projectPath);
13852
+ if (state._ctxCacheProjectPath !== undefined && state._ctxCacheProjectPath !== projectPath) {
13853
+ state._ctxCache = {};
13854
+ state._ctxErrors = {};
13855
+ }
13856
+ state._ctxCacheProjectPath = projectPath;
13857
+ if (!state._ctxCache) state._ctxCache = {};
13858
+ return state._ctxCache;
13859
+ }
13780
13860
  function tfCtxEditing() { if (!state._ctxEditing) state._ctxEditing = {}; return state._ctxEditing; }
13781
13861
 
13782
13862
  async function tfFetchContext(key) {
13863
+ const projectPath = state.projectPath;
13783
13864
  const params = new URLSearchParams({ key });
13784
- if (state.projectPath) params.set('projectPath', state.projectPath);
13785
- const data = await requestJson('/api/ai-hub/context?' + params.toString());
13786
- tfCtxCache()[key] = data;
13787
- return data;
13865
+ if (projectPath) params.set('projectPath', projectPath);
13866
+ const isCurrent = () => tfCanonicalProjectPath(projectPath) === tfCanonicalProjectPath(state.projectPath);
13867
+ if (!state._ctxErrors) state._ctxErrors = {};
13868
+ try {
13869
+ const data = await requestJson('/api/ai-hub/context?' + params.toString());
13870
+ // A context response belongs to its requested project, including an org
13871
+ // override. Never repopulate the active cache from a previous project.
13872
+ if (isCurrent()) { tfCtxCache()[key] = data; delete state._ctxErrors[key]; }
13873
+ return data;
13874
+ } catch (error) {
13875
+ if (isCurrent()) state._ctxErrors[key] = true;
13876
+ throw error;
13877
+ }
13788
13878
  }
13789
13879
 
13790
13880
  async function tfSaveContext(key, content) {
13881
+ const projectPath = state.projectPath;
13791
13882
  const body = { key, content };
13792
- if (state.projectPath) body.projectPath = state.projectPath;
13883
+ if (projectPath) body.projectPath = projectPath;
13793
13884
  const data = await requestJson('/api/ai-hub/context', {
13794
13885
  method: 'POST',
13795
13886
  headers: { 'Content-Type': 'application/json' },
13796
13887
  body: JSON.stringify(body),
13797
13888
  });
13889
+ if (tfCanonicalProjectPath(projectPath) !== tfCanonicalProjectPath(state.projectPath)) return data;
13798
13890
  tfCtxCache()[key] = data;
13891
+ if (state._ctxErrors) delete state._ctxErrors[key];
13799
13892
  // Keep the bootstrap teamContext presence in sync so other surfaces
13800
13893
  // (Get-started rail, Project tabs) reflect the new file immediately.
13801
13894
  const bootstrapKey = {
@@ -14560,8 +14653,8 @@ function tfRenderCompany() {
14560
14653
  'Org-wide rules every employee respects before starting any job. Click Edit to add them.',
14561
14654
  tfRenderCompany
14562
14655
  ));
14563
- if (!orgCache) {
14564
- tfFetchContext('org').then(() => tfRenderCompany()).catch(() => {});
14656
+ if (!orgCache && !(state._ctxErrors && state._ctxErrors.org)) {
14657
+ tfFetchContext('org').then(() => tfRenderCompany()).catch(() => tfRenderCompany());
14565
14658
  }
14566
14659
  }
14567
14660
  }
@@ -14683,8 +14776,8 @@ function tfRenderManager() {
14683
14776
  'Standing rules for how employees should work with you. Click Edit to add them.',
14684
14777
  tfRenderManager
14685
14778
  ));
14686
- if (!mgrCache) {
14687
- tfFetchContext('manager').then(() => tfRenderManager()).catch(() => {});
14779
+ if (!mgrCache && !(state._ctxErrors && state._ctxErrors.manager)) {
14780
+ tfFetchContext('manager').then(() => tfRenderManager()).catch(() => tfRenderManager());
14688
14781
  }
14689
14782
  }
14690
14783
  }
@@ -638,14 +638,26 @@ img.conv-employee-avatar {
638
638
  gap: 1px;
639
639
  }
640
640
  .conv-employee-tab-label {
641
+ display: flex;
642
+ flex-wrap: wrap;
643
+ align-items: center;
644
+ gap: 6px;
645
+ overflow-wrap: anywhere;
641
646
  font-size: 11px;
642
647
  font-weight: 700;
643
648
  letter-spacing: 0.08em;
644
649
  text-transform: uppercase;
645
- white-space: nowrap;
650
+ white-space: normal;
646
651
  overflow: hidden;
647
652
  text-overflow: ellipsis;
648
653
  }
654
+ .conv-employee-tab-label .taught-badge { flex-shrink: 0; white-space: nowrap; }
655
+ .conv-employee-group--unavailable > summary .conv-employee-avatar { filter: grayscale(1); }
656
+ .conv-employee-group--unavailable > summary .conv-employee-tab-copy { color: var(--muted); }
657
+ .conv-employee-add[aria-disabled="true"] { color: var(--muted); cursor: not-allowed; background: var(--soft); }
658
+ .employee-readiness-note { margin: 8px 4px; color: var(--muted); font-size: 11px; line-height: 1.45; }
659
+ .employee-readiness-note .ctx-acc-action { display: flex; margin-top: 6px; max-width: 100%; white-space: normal; text-align: left; color: var(--accent-strong); }
660
+ .employee-readiness-note .ctx-acc-action:hover { background: var(--accent-strong); color: var(--surface); }
649
661
  .conv-employee-tab-detail {
650
662
  font-size: 10px;
651
663
  font-weight: 500;
@@ -6917,6 +6929,18 @@ img.eh-av { object-fit: cover; background: var(--surface); }
6917
6929
  #deliverables-bar[hidden], .hub-status-notification[hidden] { display: none; }
6918
6930
  #deliverables-bar .rc-art-btn:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
6919
6931
  #deliverables-bar .rc-art-btn { overflow-wrap: anywhere; max-width: 100%; box-sizing: border-box; }
6932
+ .deliverables-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
6933
+ .deliverables-header-left { display: flex; align-items: center; gap: 6px; min-width: 0; }
6934
+ .deliverables-header-label { font-size: 11px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
6935
+ .deliverables-header-count { font-size: 11px; color: var(--muted); }
6936
+ .deliverables-header-count[hidden] { display: none; }
6937
+ .deliverables-toggle { background: none; border: none; cursor: pointer; padding: 2px 4px; color: var(--muted); font-size: 14px; display: flex; align-items: center; border-radius: 3px; line-height: 1; }
6938
+ .deliverables-toggle:hover { background: var(--line); }
6939
+ .deliverables-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
6940
+ .deliverables-toggle .chev { display: inline-block; }
6941
+ #deliverables-bar.deliverables-collapsed .deliverables-toggle .chev { transform: rotate(-90deg); }
6942
+ #deliverables-bar.deliverables-collapsed .rc-artifact-strip { display: none; }
6943
+ #deliverables-bar.deliverables-collapsed .deliverables-header { margin-bottom: 0; }
6920
6944
  .review-turn { flex: 0 0 auto; }
6921
6945
  .review-turn .bubble { min-width: 0; max-width: 100%; }
6922
6946
  .review-turn-label { font-size: 11px; font-weight: 700; color: var(--muted); margin-bottom: 8px; }