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.
- package/dist/src/ai-hub/conversation-store.js +14 -11
- package/dist/src/ai-hub/custom-employees.js +107 -9
- package/dist/src/ai-hub/server.js +29 -8
- package/dist/src/cli/setup/ide-invocation-surfaces.js +1 -1
- package/dist/src/config/persona-hiring.js +7 -1
- package/dist/src/core/handoff-contracts.js +8 -3
- package/dist/src/core/utils/local-registry-resolver.js +33 -6
- package/package.json +2 -2
- package/public/ai-hub/review.css +4 -3
- package/public/ai-hub/script.js +165 -72
- package/public/ai-hub/styles.css +25 -1
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
270
|
-
// Returns the key of the
|
|
271
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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:
|
|
532
|
+
content: resolvedManagerLocal.content,
|
|
525
533
|
source: 'local',
|
|
526
534
|
personalized: true,
|
|
527
|
-
inherited:
|
|
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:
|
|
552
|
+
content: resolvedManagerOverlay.content,
|
|
536
553
|
source: 'local',
|
|
537
554
|
personalized: true,
|
|
538
|
-
inherited:
|
|
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:
|
|
572
|
+
content: resolvedOrgOverlay.content,
|
|
547
573
|
source: 'local',
|
|
548
574
|
personalized: true,
|
|
549
|
-
inherited:
|
|
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.
|
|
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.
|
|
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",
|
package/public/ai-hub/review.css
CHANGED
|
@@ -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
|
-
/*
|
|
391
|
-
|
|
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) ── */
|
package/public/ai-hub/script.js
CHANGED
|
@@ -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 ||
|
|
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
|
|
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 = (
|
|
1827
|
-
const onboarding = convs.find((c) => c &&
|
|
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
|
-
//
|
|
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
|
|
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
|
-
:
|
|
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
|
|
3071
|
-
|
|
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
|
|
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 =
|
|
3085
|
-
const bHas =
|
|
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,
|
|
3175
|
+
details.open = preservedEmployeeGroupOpen(group.key, !!group.unowned);
|
|
3119
3176
|
const summary = document.createElement('summary');
|
|
3120
3177
|
summary.className = 'conv-employee-tab';
|
|
3121
|
-
|
|
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 (
|
|
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() {
|
|
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 (
|
|
13785
|
-
const
|
|
13786
|
-
|
|
13787
|
-
|
|
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 (
|
|
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
|
}
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -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:
|
|
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; }
|