fraim-hub 2.0.339 → 2.0.340
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/registry/scripts/learning/completion-state.js +153 -0
- package/dist/src/ai-hub/desktop-main.js +46 -0
- package/dist/src/ai-hub/hosts.js +18 -0
- package/dist/src/ai-hub/server.js +67 -69
- package/dist/src/core/handoff-contracts.js +617 -0
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +96 -19
- package/dist/src/local-mcp-server/learning-usage-events.js +63 -57
- package/dist/src/local-mcp-server/learning-usage-store.js +86 -55
- package/package.json +4 -2
- package/public/ai-hub/review.css +378 -0
- package/public/ai-hub/script.js +763 -33
- package/public/ai-hub/styles.css +53 -0
package/public/ai-hub/script.js
CHANGED
|
@@ -4186,6 +4186,9 @@ function renderActive() {
|
|
|
4186
4186
|
// Issue #512 R7 — review experience: completion card + format artifact strip
|
|
4187
4187
|
// + unified action bar (review actions sit beside the always-on coaching chips).
|
|
4188
4188
|
renderReviewExperience(conv);
|
|
4189
|
+
// Issue #1755 R5: the proposals are written into this conversation, so this is
|
|
4190
|
+
// where they are acted on.
|
|
4191
|
+
renderLearningProposalTurn(conv);
|
|
4189
4192
|
// Issue #848 — "What's next?" chips below a completed conversation (post-retro).
|
|
4190
4193
|
renderNextJobRecommendations(conv);
|
|
4191
4194
|
renderDeliverablesBar(conv);
|
|
@@ -5660,6 +5663,26 @@ function latestEmployeeSurfaceText(conv) {
|
|
|
5660
5663
|
return '';
|
|
5661
5664
|
}
|
|
5662
5665
|
|
|
5666
|
+
/**
|
|
5667
|
+
* Issue #1755: a sleep-on-learnings message carries its proposal set as a
|
|
5668
|
+
* "## Pending Review" markdown block. That block is the Hub's input, not prose
|
|
5669
|
+
* the manager should read: it renders below as cards they can act on. Printing
|
|
5670
|
+
* both puts every lesson title on the screen twice.
|
|
5671
|
+
*
|
|
5672
|
+
* Only the block is dropped, and only when the cards are actually rendering.
|
|
5673
|
+
* Whatever the job wrote above it (its analysis summary) is what the manager
|
|
5674
|
+
* came to read, so that stays. When no cards render, nothing is stripped, so a
|
|
5675
|
+
* proposal set can never go missing.
|
|
5676
|
+
*/
|
|
5677
|
+
function tfStripRenderedProposalBlock(text, conv) {
|
|
5678
|
+
const raw = String(text || '');
|
|
5679
|
+
if (!conv || conv.jobId !== 'sleep-on-learnings') return raw;
|
|
5680
|
+
const marker = raw.search(/^##\s*Pending Review\s*$/im);
|
|
5681
|
+
if (marker === -1) return raw;
|
|
5682
|
+
if (typeof tfConvHasProposals !== 'function' || !tfConvHasProposals(conv)) return raw;
|
|
5683
|
+
return raw.slice(0, marker).trimEnd();
|
|
5684
|
+
}
|
|
5685
|
+
|
|
5663
5686
|
function appendMessageDom(role, text, conv, message) {
|
|
5664
5687
|
const article = document.createElement('article');
|
|
5665
5688
|
article.className = 'message ' + role;
|
|
@@ -5723,7 +5746,7 @@ function appendMessageDom(role, text, conv, message) {
|
|
|
5723
5746
|
// surfaceText collapses whitespace which destroys markdown. Only use it
|
|
5724
5747
|
// when it returned a transformed value (Started/Resumed prefix overrides).
|
|
5725
5748
|
const useSurfaced = surfaced && surfaced !== raw.replace(/\s+/g, ' ').trim();
|
|
5726
|
-
const content = useSurfaced ? surfaced : raw;
|
|
5749
|
+
const content = tfStripRenderedProposalBlock(useSurfaced ? surfaced : raw, conv);
|
|
5727
5750
|
bubble.innerHTML = formatEmployeeText(content || (conv.status === 'completed' ? 'Done, please review.' : 'Working on it…'));
|
|
5728
5751
|
} else {
|
|
5729
5752
|
bubble.textContent = role === 'manager' ? managerBubbleText(text) : (surfaceText(role, text, conv) || text);
|
|
@@ -6502,6 +6525,7 @@ function normalizeReviewAction(raw, index) {
|
|
|
6502
6525
|
|
|
6503
6526
|
function normalizeReviewHandoff(raw) {
|
|
6504
6527
|
if (!raw || typeof raw !== 'object' || typeof raw.reviewRequired !== 'boolean') return null;
|
|
6528
|
+
const producingJobName = typeof raw.producingJobName === 'string' ? raw.producingJobName.trim() : '';
|
|
6505
6529
|
const artifacts = Array.isArray(raw.artifacts)
|
|
6506
6530
|
? raw.artifacts.map(normalizeReviewArtifact).filter(Boolean)
|
|
6507
6531
|
: [];
|
|
@@ -6517,6 +6541,7 @@ function normalizeReviewHandoff(raw) {
|
|
|
6517
6541
|
if (!raw.reviewRequired) {
|
|
6518
6542
|
return {
|
|
6519
6543
|
reviewRequired: false,
|
|
6544
|
+
...(producingJobName ? { producingJobName } : {}),
|
|
6520
6545
|
reviewTarget: rawTarget ? { type: targetType || 'none', label: targetLabel || 'Completed work' } : null,
|
|
6521
6546
|
artifacts,
|
|
6522
6547
|
summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
|
|
@@ -6531,6 +6556,7 @@ function normalizeReviewHandoff(raw) {
|
|
|
6531
6556
|
if (artifacts.length > 0) return null;
|
|
6532
6557
|
return {
|
|
6533
6558
|
reviewRequired: true,
|
|
6559
|
+
...(producingJobName ? { producingJobName } : {}),
|
|
6534
6560
|
reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
|
|
6535
6561
|
artifacts: [],
|
|
6536
6562
|
summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
|
|
@@ -6540,9 +6566,10 @@ function normalizeReviewHandoff(raw) {
|
|
|
6540
6566
|
}
|
|
6541
6567
|
|
|
6542
6568
|
const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
|
|
6543
|
-
if (targetType === 'artifact_set' && fileArtifacts.length
|
|
6569
|
+
if (targetType === 'artifact_set' && fileArtifacts.length === artifacts.length) {
|
|
6544
6570
|
return {
|
|
6545
6571
|
reviewRequired: true,
|
|
6572
|
+
...(producingJobName ? { producingJobName } : {}),
|
|
6546
6573
|
reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length || 1} artifact${artifacts.length === 1 ? '' : 's'}` },
|
|
6547
6574
|
artifacts: fileArtifacts,
|
|
6548
6575
|
summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
|
|
@@ -7014,6 +7041,18 @@ function renderReviewExperience(conv) {
|
|
|
7014
7041
|
const label = document.createElement('div');
|
|
7015
7042
|
label.className = 'review-turn-label';
|
|
7016
7043
|
label.textContent = 'Ready for your review';
|
|
7044
|
+
// A settled review keeps the producer captured with its own handoff,
|
|
7045
|
+
// even after another job submits work in this conversation.
|
|
7046
|
+
try {
|
|
7047
|
+
const handoff = normalizeReviewHandoff(JSON.parse(turn.signature));
|
|
7048
|
+
if (handoff && handoff.producingJobName && handoff.producingJobName !== conv.jobId) {
|
|
7049
|
+
const job = resolveNextJobFromCatalog(handoff.producingJobName);
|
|
7050
|
+
const producer = document.createElement('span');
|
|
7051
|
+
producer.className = 'js-review-job';
|
|
7052
|
+
producer.textContent = (job && job.title) || humanizeSlug(handoff.producingJobName);
|
|
7053
|
+
label.replaceChildren(producer);
|
|
7054
|
+
}
|
|
7055
|
+
} catch { /* Older/imported review history keeps the default label. */ }
|
|
7017
7056
|
bubble.appendChild(label);
|
|
7018
7057
|
row.appendChild(bubble);
|
|
7019
7058
|
host.insertBefore(row, rows[turn.messageCount] || host.querySelector('#deliverables-bar, #next-job-section'));
|
|
@@ -7042,6 +7081,63 @@ function renderReviewExperience(conv) {
|
|
|
7042
7081
|
}
|
|
7043
7082
|
}
|
|
7044
7083
|
|
|
7084
|
+
// Issue #1755 R5: a sleep-on-learnings run writes its proposal set into its own
|
|
7085
|
+
// conversation as markdown. Render it there as an actionable review turn rather
|
|
7086
|
+
// than leaving the manager to find the controls in a panel on another page.
|
|
7087
|
+
//
|
|
7088
|
+
// The turn is rebuilt on every conversation render, because the thread itself is
|
|
7089
|
+
// rebuilt on the poll cadence and a decision changes what the cards show.
|
|
7090
|
+
function renderLearningProposalTurn(conv) {
|
|
7091
|
+
const host = els['messages'];
|
|
7092
|
+
if (!host) return;
|
|
7093
|
+
const existing = host.querySelector('#learn-proposal-turn');
|
|
7094
|
+
if (existing) existing.remove();
|
|
7095
|
+
if (!conv || conv.jobId !== 'sleep-on-learnings' || !tfConvHasProposals(conv)) return;
|
|
7096
|
+
|
|
7097
|
+
// The scope the run was asked for decides where an approved lesson is written.
|
|
7098
|
+
const scope = tfProposalScopeForConversation(conv);
|
|
7099
|
+
const proposals = tfParsePendingProposals(conv, scope);
|
|
7100
|
+
if (!proposals.length) return;
|
|
7101
|
+
// Keep the relay pointed at this run even if the accordion never registered it.
|
|
7102
|
+
tfLearningRunByScope[tfLearningRunKey(scope)] = conv;
|
|
7103
|
+
|
|
7104
|
+
const row = document.createElement('article');
|
|
7105
|
+
row.className = 'message employee review-turn learn-proposal-turn';
|
|
7106
|
+
row.id = 'learn-proposal-turn';
|
|
7107
|
+
row.dataset.testid = 'learn-proposal-turn';
|
|
7108
|
+
const bubble = document.createElement('div');
|
|
7109
|
+
bubble.className = 'bubble';
|
|
7110
|
+
|
|
7111
|
+
const label = document.createElement('div');
|
|
7112
|
+
label.className = 'review-turn-label';
|
|
7113
|
+
const undecided = proposals.filter((p) => !tfProposalDecisions[p.id]).length;
|
|
7114
|
+
label.textContent = undecided
|
|
7115
|
+
? `${undecided} lesson${undecided === 1 ? '' : 's'} waiting on your decision`
|
|
7116
|
+
: 'Lessons reviewed';
|
|
7117
|
+
bubble.appendChild(label);
|
|
7118
|
+
|
|
7119
|
+
bubble.appendChild(tfLearningBatchBar(proposals, scope));
|
|
7120
|
+
const list = document.createElement('div');
|
|
7121
|
+
list.className = 'learn-list';
|
|
7122
|
+
for (const p of proposals) list.appendChild(tfProposalCard(p, scope));
|
|
7123
|
+
bubble.appendChild(list);
|
|
7124
|
+
|
|
7125
|
+
row.appendChild(bubble);
|
|
7126
|
+
// Below the thread, above the deliverables/next-job furniture.
|
|
7127
|
+
host.insertBefore(row, host.querySelector('#deliverables-bar, #next-job-section'));
|
|
7128
|
+
}
|
|
7129
|
+
|
|
7130
|
+
// Which corpus an approved proposal from this run belongs to. The kickoff text
|
|
7131
|
+
// names it, because `tfRunShareLearnings` writes the scope word into the message
|
|
7132
|
+
// it starts the run with; a run started another way defaults to the manager's own
|
|
7133
|
+
// corpus, which is where sleep-on-learnings writes when nothing narrows it.
|
|
7134
|
+
function tfProposalScopeForConversation(conv) {
|
|
7135
|
+
const kickoff = typeof rawKickoffText === 'function' ? (rawKickoffText(conv) || '') : '';
|
|
7136
|
+
if (/\borganization\b|\bcompany\b/i.test(kickoff)) return 'org';
|
|
7137
|
+
if (/\bproject\b/i.test(kickoff)) return 'project';
|
|
7138
|
+
return 'manager';
|
|
7139
|
+
}
|
|
7140
|
+
|
|
7045
7141
|
// Issue #848: "What's next?" chips below a completed conversation.
|
|
7046
7142
|
function clearNextJobSection() {
|
|
7047
7143
|
const messages = els['messages'];
|
|
@@ -12288,6 +12384,12 @@ async function tfSelectProjectView(view, projectId) {
|
|
|
12288
12384
|
// a switched project never shows the prior project's learnings.
|
|
12289
12385
|
delete tfPreservedCache()[tfLearnKey('manager', 'project')];
|
|
12290
12386
|
delete tfThresholdCache()[tfLearnKey('manager', 'project')];
|
|
12387
|
+
// #1755: the library's filters and expansions are per-corpus too. Carrying a
|
|
12388
|
+
// domain filter into a project that has no entries in that domain leaves the
|
|
12389
|
+
// manager looking at an empty list they did not ask for.
|
|
12390
|
+
if (state._learningFilters) delete state._learningFilters[tfLearnKey('manager', 'project')];
|
|
12391
|
+
if (state._learningDomains) delete state._learningDomains[tfLearnKey('manager', 'project')];
|
|
12392
|
+
state._learningExpanded = {};
|
|
12291
12393
|
tf._lastWorkspaceProject = tf.activeProjectId;
|
|
12292
12394
|
}
|
|
12293
12395
|
tfRenderTree();
|
|
@@ -14737,6 +14839,41 @@ const tfLearningRunByScope = { org: null, manager: null, project: null, reverse:
|
|
|
14737
14839
|
function tfLearningRunKey(scope) {
|
|
14738
14840
|
return scope === 'org' ? 'org' : scope === 'reverse' ? 'reverse' : 'manager';
|
|
14739
14841
|
}
|
|
14842
|
+
|
|
14843
|
+
/** True when a conversation's employee turns carry a sleep-on-learnings proposal set. */
|
|
14844
|
+
function tfConvHasProposals(conv) {
|
|
14845
|
+
if (!conv || !Array.isArray(conv.messages)) return false;
|
|
14846
|
+
const text = conv.messages.filter((m) => m && m.role === 'employee').map((m) => m.text || '').join('\n');
|
|
14847
|
+
return /pending review/i.test(text) && /####\s*\[?P-(CRITICAL|HIGH|MED|LOW)\]?/i.test(text);
|
|
14848
|
+
}
|
|
14849
|
+
|
|
14850
|
+
/**
|
|
14851
|
+
* Issue #1755: find the run whose proposals this scope should act on.
|
|
14852
|
+
*
|
|
14853
|
+
* `tfLearningRunByScope` is a session-local map that only the accordion's own
|
|
14854
|
+
* "Sleep on learnings" button writes to, and it is empty on every page load. A
|
|
14855
|
+
* run started from the Kanban board, from a schedule, from the composer, or
|
|
14856
|
+
* simply revisited after a reload left the proposals with no controls anywhere.
|
|
14857
|
+
* The conversation record is the durable source, so fall back to it and cache
|
|
14858
|
+
* the hit so a later Bless/Skip still relays into the right run.
|
|
14859
|
+
*/
|
|
14860
|
+
function tfFindLearningRun(scope) {
|
|
14861
|
+
const key = tfLearningRunKey(scope);
|
|
14862
|
+
const registered = tfLearningRunByScope[key];
|
|
14863
|
+
if (tfConvHasProposals(registered)) return registered;
|
|
14864
|
+
const list = (state.conversations && state.conversations[state.projectPath]) || [];
|
|
14865
|
+
// Most recent first: the newest proposal set is the one awaiting a decision.
|
|
14866
|
+
const candidates = list
|
|
14867
|
+
.filter((c) => c && c.jobId === 'sleep-on-learnings' && tfConvHasProposals(c))
|
|
14868
|
+
.sort((a, b) => (conversationTimestamp(b) || 0) - (conversationTimestamp(a) || 0));
|
|
14869
|
+
// Prefer a run asked for at THIS scope. A decision is relayed into the run as a
|
|
14870
|
+
// manager turn, and the job writes it to the corpus that run was scoped to, so
|
|
14871
|
+
// picking the newest run regardless of scope could apply a manager-level
|
|
14872
|
+
// decision to the company corpus when both have a set pending.
|
|
14873
|
+
const found = candidates.find((c) => tfProposalScopeForConversation(c) === scope) || candidates[0];
|
|
14874
|
+
if (found) tfLearningRunByScope[key] = found;
|
|
14875
|
+
return found || registered || null;
|
|
14876
|
+
}
|
|
14740
14877
|
// Per-scope decision record so a blessed/skipped proposal stays resolved in the
|
|
14741
14878
|
// UI after the relay (the durable write is the job's apply phase, not the Hub).
|
|
14742
14879
|
const tfProposalDecisions = {};
|
|
@@ -14807,7 +14944,7 @@ function tfParseLevelWord(raw) {
|
|
|
14807
14944
|
// approve → "Approve as-is" ; reject → "Reject (skip)". The job's review-pending
|
|
14808
14945
|
// phase consumes the decision and its apply phase performs the durable write.
|
|
14809
14946
|
async function tfRelayProposalDecision(scope, proposal, decision) {
|
|
14810
|
-
const conv =
|
|
14947
|
+
const conv = tfFindLearningRun(scope);
|
|
14811
14948
|
const verb = decision === 'approve' ? 'Approve as-is' : 'Reject (skip)';
|
|
14812
14949
|
const message = `${verb} the proposal: "${proposal.title}".`;
|
|
14813
14950
|
tfProposalDecisions[proposal.id] = decision;
|
|
@@ -14856,6 +14993,9 @@ async function tfRunShareLearnings(scope) {
|
|
|
14856
14993
|
// #533: per-(scope:level) cache of preserved learning entries read from disk via
|
|
14857
14994
|
// GET /api/ai-hub/learnings, so each section can DISPLAY and edit them. Keyed by
|
|
14858
14995
|
// `${scope}:${level}` (e.g. manager:machine, manager:project); `undefined` = unloaded.
|
|
14996
|
+
// Issue #1755: below this many entries the list is already scannable, so the
|
|
14997
|
+
// filter toolbar would be chrome with nothing to do. The stats row still shows.
|
|
14998
|
+
const TF_LEARN_TOOLBAR_MIN = 6;
|
|
14859
14999
|
function tfPreservedCache() { if (!state._preservedLearnings) state._preservedLearnings = {}; return state._preservedLearnings; }
|
|
14860
15000
|
function tfThresholdCache() { if (!state._learningThresholds) state._learningThresholds = {}; return state._learningThresholds; }
|
|
14861
15001
|
function tfLearnKey(scope, level) { return scope + ':' + level; }
|
|
@@ -14866,6 +15006,12 @@ async function tfFetchPreservedLearnings(scope, level) {
|
|
|
14866
15006
|
const data = await requestJson('/api/ai-hub/learnings?' + params.toString());
|
|
14867
15007
|
tfPreservedCache()[tfLearnKey(scope, level)] = Array.isArray(data.entries) ? data.entries : [];
|
|
14868
15008
|
if (typeof data.threshold === 'number') tfThresholdCache()[tfLearnKey(scope, level)] = data.threshold;
|
|
15009
|
+
// #1755 R9: the valid domains travel with the corpus so the re-scope selector
|
|
15010
|
+
// offers every domain, not only the ones already in use.
|
|
15011
|
+
if (Array.isArray(data.domains)) {
|
|
15012
|
+
if (!state._learningDomains) state._learningDomains = {};
|
|
15013
|
+
state._learningDomains[tfLearnKey(scope, level)] = data.domains;
|
|
15014
|
+
}
|
|
14869
15015
|
} catch {
|
|
14870
15016
|
tfPreservedCache()[tfLearnKey(scope, level)] = [];
|
|
14871
15017
|
}
|
|
@@ -14927,6 +15073,23 @@ function tfLearningEditor(opts) {
|
|
|
14927
15073
|
for (const p of ['P-CRITICAL', 'P-HIGH', 'P-MED', 'P-LOW']) { const o = document.createElement('option'); o.value = p; o.textContent = p.replace('P-', ''); prio.appendChild(o); }
|
|
14928
15074
|
prio.value = (entry && entry.severity) || 'P-MED';
|
|
14929
15075
|
row.appendChild(prio);
|
|
15076
|
+
// Issue #1755 R4: domain is the entry's reach, and it used to be changeable only
|
|
15077
|
+
// by renaming a file on disk. Here it is a control, and saving moves the entry.
|
|
15078
|
+
const domSel = document.createElement('select');
|
|
15079
|
+
domSel.className = 'lp-edit-domain';
|
|
15080
|
+
domSel.dataset.testid = 'lp-edit-domain';
|
|
15081
|
+
domSel.setAttribute('aria-label', 'Domain this learning applies to');
|
|
15082
|
+
const currentDomain = tfLearnDomainLabel(entry && entry.domain);
|
|
15083
|
+
const catalogue = tfDomainCatalogue(tfLearnKey(scope, level));
|
|
15084
|
+
const domainOptions = catalogue.indexOf(currentDomain) === -1 ? catalogue.concat([currentDomain]) : catalogue;
|
|
15085
|
+
for (const d of domainOptions) {
|
|
15086
|
+
const o = document.createElement('option');
|
|
15087
|
+
o.value = d;
|
|
15088
|
+
o.textContent = d === 'global' ? 'global: every job' : d + ' jobs only';
|
|
15089
|
+
domSel.appendChild(o);
|
|
15090
|
+
}
|
|
15091
|
+
domSel.value = currentDomain;
|
|
15092
|
+
row.appendChild(domSel);
|
|
14930
15093
|
const titleIn = document.createElement('input'); titleIn.className = 'lp-edit-title'; titleIn.type = 'text';
|
|
14931
15094
|
titleIn.placeholder = 'Title'; titleIn.value = (entry && entry.title) || '';
|
|
14932
15095
|
row.appendChild(titleIn);
|
|
@@ -14948,8 +15111,14 @@ function tfLearningEditor(opts) {
|
|
|
14948
15111
|
save.disabled = true; save.textContent = 'Saving…'; err.textContent = '';
|
|
14949
15112
|
try {
|
|
14950
15113
|
await tfWriteLearning(isNew
|
|
14951
|
-
? { action: 'add', scope, level, category, severity: prio.value, title, body: bodyIn.value }
|
|
14952
|
-
: {
|
|
15114
|
+
? { action: 'add', scope, level, category, domain: domSel.value, severity: prio.value, title, body: bodyIn.value }
|
|
15115
|
+
: {
|
|
15116
|
+
action: 'edit', scope, level, category,
|
|
15117
|
+
// `domain` is where the entry lives now; `targetDomain` is where it should
|
|
15118
|
+
// live after the save. The server moves the file when they differ.
|
|
15119
|
+
domain: currentDomain, targetDomain: domSel.value,
|
|
15120
|
+
originalTitle: entry.title, severity: prio.value, title, body: bodyIn.value,
|
|
15121
|
+
},
|
|
14953
15122
|
scope, level);
|
|
14954
15123
|
} catch (e) {
|
|
14955
15124
|
save.disabled = false; save.textContent = isNew ? 'Add' : 'Save';
|
|
@@ -14959,14 +15128,347 @@ function tfLearningEditor(opts) {
|
|
|
14959
15128
|
return form;
|
|
14960
15129
|
}
|
|
14961
15130
|
|
|
14962
|
-
//
|
|
14963
|
-
//
|
|
15131
|
+
// ── Issue #1755: the learnings library ──────────────────────────────────────
|
|
15132
|
+
// The corpus is the manager's, and until now the only thing the Hub showed of it
|
|
15133
|
+
// was a flat list of full-text cards. These helpers add the three facts the
|
|
15134
|
+
// manager could not see: which jobs an entry reaches (its domain), whether it has
|
|
15135
|
+
// ever worked (its usage), and how the two together rate it (its strength).
|
|
15136
|
+
|
|
15137
|
+
// Domains are read from the corpus response so the filter offers every valid
|
|
15138
|
+
// domain rather than only the ones already in use. Keyed by `${scope}:${level}`.
|
|
15139
|
+
function tfDomainCatalogue(key) {
|
|
15140
|
+
const all = state._learningDomains || {};
|
|
15141
|
+
return Array.isArray(all[key]) && all[key].length ? all[key] : ['global'];
|
|
15142
|
+
}
|
|
15143
|
+
|
|
15144
|
+
function tfLearnDomainLabel(domain) {
|
|
15145
|
+
return (!domain || domain === 'global') ? 'global' : domain;
|
|
15146
|
+
}
|
|
15147
|
+
|
|
15148
|
+
// A stable per-domain colour slot, so the same domain reads the same everywhere
|
|
15149
|
+
// without hard-coding a colour per domain name.
|
|
15150
|
+
const TF_DOMAIN_SLOTS = 6;
|
|
15151
|
+
function tfLearnDomainClass(domain) {
|
|
15152
|
+
const d = tfLearnDomainLabel(domain);
|
|
15153
|
+
if (d === 'global') return 'dom-global';
|
|
15154
|
+
let h = 0;
|
|
15155
|
+
for (let i = 0; i < d.length; i++) h = (h * 31 + d.charCodeAt(i)) % 9973;
|
|
15156
|
+
return 'dom-c' + (h % TF_DOMAIN_SLOTS);
|
|
15157
|
+
}
|
|
15158
|
+
|
|
15159
|
+
function tfDaysSince(iso) {
|
|
15160
|
+
if (!iso) return null;
|
|
15161
|
+
const t = Date.parse(iso);
|
|
15162
|
+
if (!isFinite(t)) return null;
|
|
15163
|
+
return Math.floor((Date.now() - t) / 86400000);
|
|
15164
|
+
}
|
|
15165
|
+
|
|
15166
|
+
// "2d ago" / "3mo ago" / "today". Returns null when there is no date at all, so a
|
|
15167
|
+
// caller renders "never offered" rather than a fabricated recency.
|
|
15168
|
+
function tfRelDays(iso) {
|
|
15169
|
+
const d = tfDaysSince(iso);
|
|
15170
|
+
if (d === null) return null;
|
|
15171
|
+
if (d <= 0) return 'today';
|
|
15172
|
+
if (d === 1) return 'yesterday';
|
|
15173
|
+
if (d < 30) return d + 'd ago';
|
|
15174
|
+
if (d < 365) return Math.round(d / 30) + 'mo ago';
|
|
15175
|
+
return Math.round(d / 365) + 'y ago';
|
|
15176
|
+
}
|
|
15177
|
+
|
|
15178
|
+
// Neural strength: what the counters say about whether an entry is working.
|
|
15179
|
+
// Order matters: dormancy and mis-targeting are read before the firing ratio,
|
|
15180
|
+
// because an entry nobody sees any more cannot be called strong on old firings.
|
|
15181
|
+
// The strength classifier's policy, named so a reader can question the thresholds
|
|
15182
|
+
// without reading the function, and so changing one is a single edit.
|
|
15183
|
+
const TF_STRENGTH_POLICY = {
|
|
15184
|
+
/** An entry below the injection threshold and unseen this long is Dead. */
|
|
15185
|
+
deadAfterDays: 90,
|
|
15186
|
+
/** Fewest attestations before the not-applicable ratio means anything. */
|
|
15187
|
+
minAttestationsToJudge: 3,
|
|
15188
|
+
/** At or above this share of not-applicable attestations, the entry is Unresolved. */
|
|
15189
|
+
unresolvedNotApplicableRatio: 0.5,
|
|
15190
|
+
/** Fewest firings before a firing ratio means anything. */
|
|
15191
|
+
minFiringsForStrong: 3,
|
|
15192
|
+
/** At or above this firings-per-offer ratio, the entry is Strong. */
|
|
15193
|
+
strongFiringRatio: 0.2,
|
|
15194
|
+
/** Fewest ignores before consistent passing-over means anything. */
|
|
15195
|
+
minIgnoresForWouldFire: 3,
|
|
15196
|
+
};
|
|
15197
|
+
|
|
15198
|
+
function tfLearningStrength(entry) {
|
|
15199
|
+
const u = (entry && entry.usage) || null;
|
|
15200
|
+
const offered = u ? (u.offered || 0) : 0;
|
|
15201
|
+
const applied = u ? (u.applied || 0) : 0;
|
|
15202
|
+
const prevented = u ? (u.prevented || 0) : 0;
|
|
15203
|
+
const ignored = u ? (u.ignored || 0) : 0;
|
|
15204
|
+
const notApplicable = u ? (u.notApplicable || 0) : 0;
|
|
15205
|
+
const fired = applied + prevented;
|
|
15206
|
+
const attested = fired + ignored + notApplicable;
|
|
15207
|
+
const daysSinceOffered = tfDaysSince(u && u.lastOfferedAt);
|
|
15208
|
+
// Dead: below the injection threshold AND not offered in the last 90 days.
|
|
15209
|
+
const P = TF_STRENGTH_POLICY;
|
|
15210
|
+
if (entry && entry.injected === false && (daysSinceOffered === null || daysSinceOffered > P.deadAfterDays)) return 'dead';
|
|
15211
|
+
// Unresolved: at least half of the attestations said the entry did not apply -
|
|
15212
|
+
// it is being offered into the wrong contexts.
|
|
15213
|
+
if (attested >= P.minAttestationsToJudge && notApplicable / attested >= P.unresolvedNotApplicableRatio) return 'unresolved';
|
|
15214
|
+
// Strong: it fires often enough relative to how often it is offered.
|
|
15215
|
+
if (fired >= P.minFiringsForStrong && (offered === 0 || fired / offered >= P.strongFiringRatio)) return 'strong';
|
|
15216
|
+
// Would-fire: offered repeatedly and consistently passed over.
|
|
15217
|
+
if (ignored >= P.minIgnoresForWouldFire && ignored > fired) return 'wouldfire';
|
|
15218
|
+
// Weak: offered but not yet proven either way.
|
|
15219
|
+
return 'weak';
|
|
15220
|
+
}
|
|
15221
|
+
|
|
15222
|
+
const TF_STRENGTH_META = {
|
|
15223
|
+
strong: { label: 'Strong', rank: 0, hint: 'Fires often relative to how often it is offered.' },
|
|
15224
|
+
wouldfire: { label: 'Would-fire', rank: 1, hint: 'Offered repeatedly and consistently passed over. The wording may be too broad.' },
|
|
15225
|
+
unresolved: { label: 'Unresolved', rank: 2, hint: 'Most attestations say it did not apply. It is reaching the wrong jobs.' },
|
|
15226
|
+
weak: { label: 'Weak', rank: 3, hint: 'Offered, but not yet proven either way.' },
|
|
15227
|
+
dead: { label: 'Dead', rank: 4, hint: 'Below the injection threshold and not offered in over 90 days.' },
|
|
15228
|
+
};
|
|
15229
|
+
function tfStrengthMeta(key) { return TF_STRENGTH_META[key] || TF_STRENGTH_META.weak; }
|
|
15230
|
+
|
|
15231
|
+
const TF_PRIO_RANK = { 'P-CRITICAL': 0, 'P-HIGH': 1, 'P-MED': 2, 'P-LOW': 3 };
|
|
15232
|
+
|
|
15233
|
+
// Per-section toolbar state, so a re-render (poll, write, project switch back)
|
|
15234
|
+
// does not silently reset the manager's filter to All.
|
|
15235
|
+
function tfLearnFilters(key) {
|
|
15236
|
+
if (!state._learningFilters) state._learningFilters = {};
|
|
15237
|
+
if (!state._learningFilters[key]) state._learningFilters[key] = { strength: 'all', domain: 'all', sort: 'strength' };
|
|
15238
|
+
return state._learningFilters[key];
|
|
15239
|
+
}
|
|
15240
|
+
|
|
15241
|
+
// Which card bodies the manager has expanded. Bodies are clamped by default -
|
|
15242
|
+
// the corpus averages over 100 words an entry, and an unclamped list of 121 of
|
|
15243
|
+
// them is the readability defect #1755 names.
|
|
15244
|
+
/** A lesson longer than this, or taller than the clamp, gets a Show more control. */
|
|
15245
|
+
const TF_CLAMP_CHARS = 220;
|
|
15246
|
+
const TF_CLAMP_LINES = 3;
|
|
15247
|
+
|
|
15248
|
+
function tfLearnExpanded() {
|
|
15249
|
+
if (!state._learningExpanded) state._learningExpanded = {};
|
|
15250
|
+
return state._learningExpanded;
|
|
15251
|
+
}
|
|
15252
|
+
function tfLearnCardKey(entry, key) { return key + '|' + (entry.id || entry.title || ''); }
|
|
15253
|
+
|
|
15254
|
+
// Synthesis writes a provenance tail onto each entry (Domain, Originating job,
|
|
15255
|
+
// Sources, Details and friends). It is bookkeeping, not the lesson, and it is
|
|
15256
|
+
// roughly half the text of a typical entry. Split it off so the card leads with
|
|
15257
|
+
// what the lesson says and keeps the trail one click away.
|
|
15258
|
+
const TF_PROVENANCE_LINE = /^(Domain|Domain basis|Originating jobs?|First recorded|Last recorded|Sources?|Details|Users|Scope|Applies to|Technical trace|Id)\s*:/i;
|
|
15259
|
+
// Issue #1755 compliance requirement: the library must not surface raw file paths
|
|
15260
|
+
// or user identifiers. Synthesis writes both into an entry's `Sources:` line as a
|
|
15261
|
+
// full absolute path ending in `<email>-<family>.md`. Shorten it to the repo-relative
|
|
15262
|
+
// file it names, with the owner prefix dropped: the manager already knows whose
|
|
15263
|
+
// corpus they are reading, and the absolute path is noise on every entry.
|
|
15264
|
+
function tfRedactProvenance(text) {
|
|
15265
|
+
return String(text)
|
|
15266
|
+
// Absolute path -> the part from the learnings root onward.
|
|
15267
|
+
.replace(/(?:[A-Za-z]:)?[\\/][^\s,;]*?(fraim[\\/]personalized-employee[\\/][^\s,;]*)/gi, '$1')
|
|
15268
|
+
// `<owner>-<family>.md` -> `<family>.md`.
|
|
15269
|
+
.replace(/([\\/])[^\s\\/]*?@[^\s\\/]*?-([a-z-]+\.md)/gi, '$1$2')
|
|
15270
|
+
// Any remaining bare address in the provenance prose.
|
|
15271
|
+
.replace(/\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b/g, 'you');
|
|
15272
|
+
}
|
|
15273
|
+
|
|
15274
|
+
function tfSplitLessonAndProvenance(text) {
|
|
15275
|
+
const lines = String(text).split('\n');
|
|
15276
|
+
const lesson = [];
|
|
15277
|
+
const provenance = [];
|
|
15278
|
+
// A provenance key opens a run that continues until the next blank line, because
|
|
15279
|
+
// a value like `Details: ...` wraps. Runs appear both before the lesson (`Scope:`)
|
|
15280
|
+
// and after it (the `Domain` / `Sources` tail), so this filters rather than cuts.
|
|
15281
|
+
let inRun = false;
|
|
15282
|
+
for (const line of lines) {
|
|
15283
|
+
const t = line.trim();
|
|
15284
|
+
if (TF_PROVENANCE_LINE.test(t)) { inRun = true; provenance.push(line); continue; }
|
|
15285
|
+
if (inRun) {
|
|
15286
|
+
if (!t) { inRun = false; provenance.push(''); continue; }
|
|
15287
|
+
provenance.push(line);
|
|
15288
|
+
continue;
|
|
15289
|
+
}
|
|
15290
|
+
lesson.push(line);
|
|
15291
|
+
}
|
|
15292
|
+
const lessonText = lesson.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
15293
|
+
// An entry that is nothing but bookkeeping still has to render something, so fall
|
|
15294
|
+
// back to showing it whole rather than a card with an empty body.
|
|
15295
|
+
if (!lessonText) return { lesson: String(text).trim(), provenance: '' };
|
|
15296
|
+
return {
|
|
15297
|
+
lesson: lessonText,
|
|
15298
|
+
provenance: tfRedactProvenance(provenance.join('\n').replace(/\n{3,}/g, '\n\n').trim()),
|
|
15299
|
+
};
|
|
15300
|
+
}
|
|
15301
|
+
|
|
15302
|
+
function tfSortLearnings(entries, sort) {
|
|
15303
|
+
const byScore = (e) => (typeof e.score === 'number' ? e.score : -Infinity);
|
|
15304
|
+
const copy = entries.slice();
|
|
15305
|
+
copy.sort((a, b) => {
|
|
15306
|
+
if (sort === 'score') return byScore(b) - byScore(a);
|
|
15307
|
+
if (sort === 'priority') {
|
|
15308
|
+
const d = (TF_PRIO_RANK[a.severity] ?? 9) - (TF_PRIO_RANK[b.severity] ?? 9);
|
|
15309
|
+
if (d) return d;
|
|
15310
|
+
return byScore(b) - byScore(a);
|
|
15311
|
+
}
|
|
15312
|
+
const d = tfStrengthMeta(tfLearningStrength(a)).rank - tfStrengthMeta(tfLearningStrength(b)).rank;
|
|
15313
|
+
if (d) return d;
|
|
15314
|
+
return byScore(b) - byScore(a);
|
|
15315
|
+
});
|
|
15316
|
+
return copy;
|
|
15317
|
+
}
|
|
15318
|
+
|
|
15319
|
+
function tfLearnMatchesFilters(entry, f) {
|
|
15320
|
+
if (f.strength !== 'all' && tfLearningStrength(entry) !== f.strength) return false;
|
|
15321
|
+
if (f.domain !== 'all' && tfLearnDomainLabel(entry.domain) !== f.domain) return false;
|
|
15322
|
+
return true;
|
|
15323
|
+
}
|
|
15324
|
+
|
|
15325
|
+
// The stats row: how the corpus is doing, in one line, before any card is read.
|
|
15326
|
+
function tfLearnStatsBar(entries) {
|
|
15327
|
+
const bar = document.createElement('div');
|
|
15328
|
+
bar.className = 'll-stats';
|
|
15329
|
+
bar.dataset.testid = 'learn-stats';
|
|
15330
|
+
const total = document.createElement('span');
|
|
15331
|
+
total.className = 'll-stat ll-total';
|
|
15332
|
+
const totalNum = document.createElement('b');
|
|
15333
|
+
totalNum.textContent = String(entries.length);
|
|
15334
|
+
const totalWord = document.createElement('span');
|
|
15335
|
+
totalWord.textContent = entries.length === 1 ? 'entry' : 'entries';
|
|
15336
|
+
total.appendChild(totalNum);
|
|
15337
|
+
total.appendChild(totalWord);
|
|
15338
|
+
bar.appendChild(total);
|
|
15339
|
+
for (const cls of ['strong', 'wouldfire', 'unresolved', 'weak', 'dead']) {
|
|
15340
|
+
const n = entries.filter((e) => tfLearningStrength(e) === cls).length;
|
|
15341
|
+
if (!n) continue;
|
|
15342
|
+
const meta = tfStrengthMeta(cls);
|
|
15343
|
+
const s = document.createElement('span');
|
|
15344
|
+
s.className = 'll-stat s-' + cls;
|
|
15345
|
+
s.dataset.strength = cls;
|
|
15346
|
+
s.title = meta.hint;
|
|
15347
|
+
const b = document.createElement('b'); b.textContent = String(n);
|
|
15348
|
+
const t = document.createElement('span'); t.textContent = meta.label;
|
|
15349
|
+
s.appendChild(b); s.appendChild(t);
|
|
15350
|
+
bar.appendChild(s);
|
|
15351
|
+
}
|
|
15352
|
+
return bar;
|
|
15353
|
+
}
|
|
15354
|
+
|
|
15355
|
+
// Strength pills + domain and sort dropdowns. Dropdowns for domain and sort
|
|
15356
|
+
// because both spaces are unbounded; pills for strength because there are five.
|
|
15357
|
+
function tfLearnToolbar(entries, key, onChange) {
|
|
15358
|
+
const f = tfLearnFilters(key);
|
|
15359
|
+
const wrap = document.createElement('div');
|
|
15360
|
+
wrap.className = 'll-tools';
|
|
15361
|
+
wrap.dataset.testid = 'learn-toolbar';
|
|
15362
|
+
|
|
15363
|
+
const tabs = document.createElement('div');
|
|
15364
|
+
tabs.className = 'll-tabs';
|
|
15365
|
+
// Each facet counts what the OTHER facet currently allows, so a count never
|
|
15366
|
+
// promises more rows than clicking it would show.
|
|
15367
|
+
const inDomain = entries.filter((e) => f.domain === 'all' || tfLearnDomainLabel(e.domain) === f.domain);
|
|
15368
|
+
const counts = { all: inDomain.length };
|
|
15369
|
+
for (const cls of ['strong', 'wouldfire', 'unresolved', 'weak', 'dead']) {
|
|
15370
|
+
counts[cls] = inDomain.filter((e) => tfLearningStrength(e) === cls).length;
|
|
15371
|
+
}
|
|
15372
|
+
for (const cls of ['all', 'strong', 'wouldfire', 'unresolved', 'weak', 'dead']) {
|
|
15373
|
+
if (cls !== 'all' && !counts[cls]) continue;
|
|
15374
|
+
const b = document.createElement('button');
|
|
15375
|
+
b.type = 'button';
|
|
15376
|
+
b.className = 'll-tab' + (f.strength === cls ? ' active' : '');
|
|
15377
|
+
b.dataset.strength = cls;
|
|
15378
|
+
b.textContent = (cls === 'all' ? 'All' : tfStrengthMeta(cls).label) + ' (' + counts[cls] + ')';
|
|
15379
|
+
if (cls !== 'all') b.title = tfStrengthMeta(cls).hint;
|
|
15380
|
+
b.addEventListener('click', () => { f.strength = cls; onChange(); });
|
|
15381
|
+
tabs.appendChild(b);
|
|
15382
|
+
}
|
|
15383
|
+
wrap.appendChild(tabs);
|
|
15384
|
+
|
|
15385
|
+
const right = document.createElement('div');
|
|
15386
|
+
right.className = 'll-selects';
|
|
15387
|
+
|
|
15388
|
+
const domainCounts = new Map();
|
|
15389
|
+
for (const e of entries) {
|
|
15390
|
+
if (f.strength !== 'all' && tfLearningStrength(e) !== f.strength) continue;
|
|
15391
|
+
const d = tfLearnDomainLabel(e.domain);
|
|
15392
|
+
domainCounts.set(d, (domainCounts.get(d) || 0) + 1);
|
|
15393
|
+
}
|
|
15394
|
+
// Keep the active domain selectable even when the strength filter empties it,
|
|
15395
|
+
// otherwise the control silently resets and the manager loses their place.
|
|
15396
|
+
if (f.domain !== 'all' && !domainCounts.has(f.domain)) domainCounts.set(f.domain, 0);
|
|
15397
|
+
const domSel = document.createElement('select');
|
|
15398
|
+
domSel.className = 'll-sel';
|
|
15399
|
+
domSel.dataset.testid = 'learn-domain-filter';
|
|
15400
|
+
domSel.setAttribute('aria-label', 'Filter by domain');
|
|
15401
|
+
const allOpt = document.createElement('option');
|
|
15402
|
+
allOpt.value = 'all'; allOpt.textContent = 'All domains';
|
|
15403
|
+
domSel.appendChild(allOpt);
|
|
15404
|
+
for (const d of Array.from(domainCounts.keys()).sort(tfDomainOrder)) {
|
|
15405
|
+
const o = document.createElement('option');
|
|
15406
|
+
o.value = d; o.textContent = d + ' (' + domainCounts.get(d) + ')';
|
|
15407
|
+
domSel.appendChild(o);
|
|
15408
|
+
}
|
|
15409
|
+
domSel.value = domainCounts.has(f.domain) || f.domain === 'all' ? f.domain : 'all';
|
|
15410
|
+
f.domain = domSel.value;
|
|
15411
|
+
domSel.addEventListener('change', () => { f.domain = domSel.value; onChange(); });
|
|
15412
|
+
right.appendChild(domSel);
|
|
15413
|
+
|
|
15414
|
+
const sortSel = document.createElement('select');
|
|
15415
|
+
sortSel.className = 'll-sel';
|
|
15416
|
+
sortSel.dataset.testid = 'learn-sort';
|
|
15417
|
+
sortSel.setAttribute('aria-label', 'Sort entries');
|
|
15418
|
+
for (const [v, label] of [['strength', 'Sort: Strength'], ['score', 'Sort: Score'], ['priority', 'Sort: Priority']]) {
|
|
15419
|
+
const o = document.createElement('option'); o.value = v; o.textContent = label; sortSel.appendChild(o);
|
|
15420
|
+
}
|
|
15421
|
+
sortSel.value = f.sort;
|
|
15422
|
+
sortSel.addEventListener('change', () => { f.sort = sortSel.value; onChange(); });
|
|
15423
|
+
right.appendChild(sortSel);
|
|
15424
|
+
|
|
15425
|
+
wrap.appendChild(right);
|
|
15426
|
+
return wrap;
|
|
15427
|
+
}
|
|
15428
|
+
|
|
15429
|
+
// Global first, then domains alphabetically: the reading order the manager asked
|
|
15430
|
+
// for: what applies everywhere, then what is narrowed.
|
|
15431
|
+
function tfDomainOrder(a, b) {
|
|
15432
|
+
if (a === b) return 0;
|
|
15433
|
+
if (a === 'global') return -1;
|
|
15434
|
+
if (b === 'global') return 1;
|
|
15435
|
+
return a < b ? -1 : 1;
|
|
15436
|
+
}
|
|
15437
|
+
|
|
15438
|
+
function tfLearnGroupHeader(domain, count) {
|
|
15439
|
+
const h = document.createElement('div');
|
|
15440
|
+
h.className = 'll-group-head';
|
|
15441
|
+
h.dataset.domain = domain;
|
|
15442
|
+
const name = document.createElement('span'); name.className = 'llg-name';
|
|
15443
|
+
const reach = document.createElement('span'); reach.className = 'llg-reach';
|
|
15444
|
+
if (domain === 'global') {
|
|
15445
|
+
name.textContent = 'Global';
|
|
15446
|
+
reach.textContent = 'loaded on every job';
|
|
15447
|
+
} else {
|
|
15448
|
+
name.textContent = domain + ' domain';
|
|
15449
|
+
reach.textContent = 'loaded only on ' + domain + ' jobs';
|
|
15450
|
+
}
|
|
15451
|
+
const n = document.createElement('span'); n.className = 'llg-count'; n.textContent = String(count);
|
|
15452
|
+
h.appendChild(name); h.appendChild(reach); h.appendChild(n);
|
|
15453
|
+
return h;
|
|
15454
|
+
}
|
|
15455
|
+
|
|
15456
|
+
// A preserved learning card: what it is (category, priority, domain), how well it
|
|
15457
|
+
// has worked (strength + usage counters), and what it says. Edit and Delete write
|
|
15458
|
+
// the underlying file (#533 #3) at the card's scope/level/domain.
|
|
14964
15459
|
// When shareMode is true, the card shows a checkbox instead of edit/delete actions
|
|
14965
15460
|
// and clicking anywhere on the card toggles the selection.
|
|
14966
15461
|
function tfPreservedCard(entry, scope, level, shareMode, onSelectionChange) {
|
|
15462
|
+
const key = tfLearnKey(scope, level);
|
|
14967
15463
|
const card = document.createElement('div');
|
|
14968
15464
|
card.className = 'learn-prop';
|
|
14969
15465
|
if (shareMode) card.classList.add('lp-share-mode');
|
|
15466
|
+
const strength = tfLearningStrength(entry);
|
|
15467
|
+
card.dataset.strength = strength;
|
|
15468
|
+
card.dataset.domain = tfLearnDomainLabel(entry.domain);
|
|
15469
|
+
// The entry's own id, so a card stays identifiable across a re-render and after
|
|
15470
|
+
// it flips into edit mode (where its title is no longer rendered as text).
|
|
15471
|
+
if (entry.id) card.dataset.entryId = entry.id;
|
|
14970
15472
|
const renderView = () => {
|
|
14971
15473
|
card.innerHTML = '';
|
|
14972
15474
|
const head = document.createElement('div'); head.className = 'lp-head';
|
|
@@ -14981,6 +15483,17 @@ function tfPreservedCard(entry, scope, level, shareMode, onSelectionChange) {
|
|
|
14981
15483
|
});
|
|
14982
15484
|
head.appendChild(chk);
|
|
14983
15485
|
}
|
|
15486
|
+
// Domain badge: which jobs this entry reaches. #1755 Gap 4: until now the
|
|
15487
|
+
// only place this fact lived was the filename.
|
|
15488
|
+
const dom = document.createElement('span');
|
|
15489
|
+
dom.className = 'lp-domain ' + tfLearnDomainClass(entry.domain);
|
|
15490
|
+
dom.dataset.testid = 'lp-domain';
|
|
15491
|
+
dom.textContent = tfLearnDomainLabel(entry.domain);
|
|
15492
|
+
dom.title = tfLearnDomainLabel(entry.domain) === 'global'
|
|
15493
|
+
? 'Loaded on every job'
|
|
15494
|
+
: 'Loaded only on ' + tfLearnDomainLabel(entry.domain) + ' jobs';
|
|
15495
|
+
head.appendChild(dom);
|
|
15496
|
+
|
|
14984
15497
|
const cat = tfCategoryMeta(entry.category);
|
|
14985
15498
|
const cb = document.createElement('span'); cb.className = 'lp-scope'; cb.textContent = cat.label;
|
|
14986
15499
|
cb.style.background = 'var(--bg)'; cb.style.color = cat.color;
|
|
@@ -14991,8 +15504,29 @@ function tfPreservedCard(entry, scope, level, shareMode, onSelectionChange) {
|
|
|
14991
15504
|
prio.style.background = 'var(--bg)'; prio.style.color = tfPrioColor(entry.severity);
|
|
14992
15505
|
head.appendChild(prio);
|
|
14993
15506
|
}
|
|
15507
|
+
// Neural strength badge.
|
|
15508
|
+
const sMeta = tfStrengthMeta(strength);
|
|
15509
|
+
const sb = document.createElement('span');
|
|
15510
|
+
sb.className = 'lp-strength s-' + strength;
|
|
15511
|
+
sb.dataset.testid = 'lp-strength';
|
|
15512
|
+
sb.dataset.strength = strength;
|
|
15513
|
+
sb.title = sMeta.hint;
|
|
15514
|
+
const dot = document.createElement('i'); dot.className = 'lp-dot';
|
|
15515
|
+
const sl = document.createElement('span'); sl.textContent = sMeta.label;
|
|
15516
|
+
sb.appendChild(dot); sb.appendChild(sl);
|
|
15517
|
+
head.appendChild(sb);
|
|
15518
|
+
|
|
14994
15519
|
const title = document.createElement('span'); title.className = 'lp-title'; title.textContent = entry.title || 'Learning';
|
|
14995
15520
|
head.appendChild(title);
|
|
15521
|
+
|
|
15522
|
+
if (typeof entry.score === 'number') {
|
|
15523
|
+
const sc = document.createElement('span');
|
|
15524
|
+
sc.className = 'lp-score'; sc.dataset.testid = 'lp-score';
|
|
15525
|
+
sc.textContent = entry.score.toFixed(1);
|
|
15526
|
+
sc.title = 'Injection score';
|
|
15527
|
+
head.appendChild(sc);
|
|
15528
|
+
}
|
|
15529
|
+
|
|
14996
15530
|
if (!shareMode && entry.tier !== 'cold') {
|
|
14997
15531
|
const edit = document.createElement('button'); edit.className = 'lp-act'; edit.type = 'button'; edit.title = 'Edit'; edit.textContent = '✎';
|
|
14998
15532
|
const del = document.createElement('button'); del.className = 'lp-act lp-act-del'; del.type = 'button'; del.title = 'Delete'; del.textContent = '🗑';
|
|
@@ -15012,7 +15546,10 @@ function tfPreservedCard(entry, scope, level, shareMode, onSelectionChange) {
|
|
|
15012
15546
|
no.addEventListener('click', () => c.remove());
|
|
15013
15547
|
yes.addEventListener('click', async () => {
|
|
15014
15548
|
yes.disabled = true; yes.textContent = 'Deleting…';
|
|
15015
|
-
|
|
15549
|
+
// #1755: the domain names the file the entry actually lives in. Without it
|
|
15550
|
+
// a delete resolved to the undecorated global file and failed, or removed
|
|
15551
|
+
// a same-titled global entry instead.
|
|
15552
|
+
try { await tfWriteLearning({ action: 'delete', scope, level, category: entry.category, domain: tfLearnDomainLabel(entry.domain), originalTitle: entry.title }, scope, level); }
|
|
15016
15553
|
catch (e) { yes.disabled = false; yes.textContent = 'Delete'; }
|
|
15017
15554
|
});
|
|
15018
15555
|
});
|
|
@@ -15028,23 +15565,145 @@ function tfPreservedCard(entry, scope, level, shareMode, onSelectionChange) {
|
|
|
15028
15565
|
}
|
|
15029
15566
|
card.appendChild(head);
|
|
15030
15567
|
if (entry.body) {
|
|
15568
|
+
const split = tfSplitLessonAndProvenance(String(entry.body).replace(/\*\*/g, '').replace(/`/g, ''));
|
|
15031
15569
|
const body = document.createElement('div'); body.className = 'lp-body';
|
|
15032
|
-
body.textContent =
|
|
15570
|
+
body.textContent = split.lesson;
|
|
15571
|
+
const cardKey = tfLearnCardKey(entry, key);
|
|
15572
|
+
const expanded = !!tfLearnExpanded()[cardKey];
|
|
15573
|
+
// Clamp long bodies. The corpus averages well over 100 words an entry; an
|
|
15574
|
+
// unclamped list is the "too dense to read" defect in #1755 Gap 8.
|
|
15575
|
+
const longBody = split.lesson.length > TF_CLAMP_CHARS || split.lesson.split('\n').length > TF_CLAMP_LINES;
|
|
15576
|
+
if (longBody && !expanded) body.classList.add('lp-clamped');
|
|
15033
15577
|
card.appendChild(body);
|
|
15578
|
+
let prov = null;
|
|
15579
|
+
if (split.provenance) {
|
|
15580
|
+
prov = document.createElement('div');
|
|
15581
|
+
prov.className = 'lp-prov'; prov.dataset.testid = 'lp-prov';
|
|
15582
|
+
prov.textContent = split.provenance;
|
|
15583
|
+
prov.hidden = !expanded;
|
|
15584
|
+
card.appendChild(prov);
|
|
15585
|
+
}
|
|
15586
|
+
if (longBody || prov) {
|
|
15587
|
+
const more = document.createElement('button');
|
|
15588
|
+
more.type = 'button'; more.className = 'lp-more'; more.dataset.testid = 'lp-more';
|
|
15589
|
+
more.textContent = expanded ? 'Show less' : 'Show more';
|
|
15590
|
+
more.addEventListener('click', () => {
|
|
15591
|
+
const now = !tfLearnExpanded()[cardKey];
|
|
15592
|
+
tfLearnExpanded()[cardKey] = now;
|
|
15593
|
+
body.classList.toggle('lp-clamped', longBody && !now);
|
|
15594
|
+
if (prov) prov.hidden = !now;
|
|
15595
|
+
more.textContent = now ? 'Show less' : 'Show more';
|
|
15596
|
+
});
|
|
15597
|
+
card.appendChild(more);
|
|
15598
|
+
}
|
|
15599
|
+
}
|
|
15600
|
+
if (!shareMode) {
|
|
15601
|
+
const stats = tfLearnUsageRow(entry);
|
|
15602
|
+
if (stats) card.appendChild(stats);
|
|
15034
15603
|
}
|
|
15035
15604
|
};
|
|
15036
15605
|
renderView();
|
|
15037
15606
|
return card;
|
|
15038
15607
|
}
|
|
15039
15608
|
|
|
15609
|
+
// The usage footer: a proportional bar plus the counts behind it. Returns null
|
|
15610
|
+
// when the store holds no record for the entry, so the card says "never offered"
|
|
15611
|
+
// instead of showing four zeroes that read like a measured result.
|
|
15612
|
+
function tfLearnUsageRow(entry) {
|
|
15613
|
+
const u = entry && entry.usage;
|
|
15614
|
+
const row = document.createElement('div');
|
|
15615
|
+
row.className = 'lp-stats';
|
|
15616
|
+
row.dataset.testid = 'lp-stats';
|
|
15617
|
+
if (!u || !u.offered) {
|
|
15618
|
+
const none = document.createElement('span');
|
|
15619
|
+
none.className = 'lps-none';
|
|
15620
|
+
none.textContent = 'Never offered to an agent yet';
|
|
15621
|
+
row.appendChild(none);
|
|
15622
|
+
return row;
|
|
15623
|
+
}
|
|
15624
|
+
const applied = u.applied || 0, prevented = u.prevented || 0, ignored = u.ignored || 0;
|
|
15625
|
+
const offered = u.offered || 0;
|
|
15626
|
+
const bar = document.createElement('span');
|
|
15627
|
+
bar.className = 'lp-bar'; bar.dataset.testid = 'lp-bar';
|
|
15628
|
+
bar.setAttribute('role', 'img');
|
|
15629
|
+
bar.setAttribute('aria-label', `${applied} applied, ${prevented} prevented, ${ignored} ignored of ${offered} offers`);
|
|
15630
|
+
for (const [cls, n] of [['lb-applied', applied], ['lb-prevented', prevented], ['lb-ignored', ignored]]) {
|
|
15631
|
+
if (!n) continue;
|
|
15632
|
+
const seg = document.createElement('i');
|
|
15633
|
+
seg.className = cls;
|
|
15634
|
+
seg.style.width = Math.max(2, Math.round((n / offered) * 100)) + '%';
|
|
15635
|
+
bar.appendChild(seg);
|
|
15636
|
+
}
|
|
15637
|
+
row.appendChild(bar);
|
|
15638
|
+
|
|
15639
|
+
const counts = document.createElement('span');
|
|
15640
|
+
counts.className = 'lps-counts';
|
|
15641
|
+
// Offered is always shown because it is the denominator. The outcome counts are
|
|
15642
|
+
// shown only when they happened: four zeroes read like a measured result rather
|
|
15643
|
+
// than an entry that has simply never fired.
|
|
15644
|
+
const parts = [['offered', offered]];
|
|
15645
|
+
if (applied) parts.push(['applied', applied]);
|
|
15646
|
+
if (prevented) parts.push(['prevented', prevented]);
|
|
15647
|
+
if (ignored) parts.push(['ignored', ignored]);
|
|
15648
|
+
if (u.notApplicable) parts.push(['not applicable', u.notApplicable]);
|
|
15649
|
+
if (parts.length === 1) parts.push(['no firings yet', null]);
|
|
15650
|
+
for (const [label, n] of parts) {
|
|
15651
|
+
const c = document.createElement('span');
|
|
15652
|
+
c.className = 'lps-c';
|
|
15653
|
+
if (n !== null) { const b = document.createElement('b'); b.textContent = String(n); c.appendChild(b); }
|
|
15654
|
+
const t = document.createElement('span'); t.textContent = label;
|
|
15655
|
+
c.appendChild(t);
|
|
15656
|
+
counts.appendChild(c);
|
|
15657
|
+
}
|
|
15658
|
+
row.appendChild(counts);
|
|
15659
|
+
|
|
15660
|
+
const last = tfRelDays(u.lastOfferedAt);
|
|
15661
|
+
if (last) {
|
|
15662
|
+
const l = document.createElement('span');
|
|
15663
|
+
l.className = 'lps-last'; l.textContent = 'last offered ' + last;
|
|
15664
|
+
row.appendChild(l);
|
|
15665
|
+
}
|
|
15666
|
+
return row;
|
|
15667
|
+
}
|
|
15668
|
+
|
|
15040
15669
|
// scope='org'|'manager'|'reverse', level='machine'|'project'|'org'. Renders pending
|
|
15041
|
-
// proposals (if a live run surfaced any) + the preserved learnings (
|
|
15042
|
-
// per-section "+ Add" that writes a
|
|
15670
|
+
// proposals (if a live run surfaced any) + the preserved learnings library (stats,
|
|
15671
|
+
// filters, domain groups, editable cards), and a per-section "+ Add" that writes a
|
|
15672
|
+
// new entry to the right file/level.
|
|
15673
|
+
// One sticky header holding the summary and the controls. The accordion body is a
|
|
15674
|
+
// capped scroll region, so an unpinned toolbar becomes unreachable the moment the
|
|
15675
|
+
// manager scrolls past the first few cards.
|
|
15676
|
+
function tfLearnLibraryHeader(preserved, key, onChange) {
|
|
15677
|
+
const libHead = document.createElement('div');
|
|
15678
|
+
libHead.className = 'll-head';
|
|
15679
|
+
libHead.dataset.testid = 'learn-lib-head';
|
|
15680
|
+
libHead.appendChild(tfLearnStatsBar(preserved));
|
|
15681
|
+
if (preserved.length >= TF_LEARN_TOOLBAR_MIN) libHead.appendChild(tfLearnToolbar(preserved, key, onChange));
|
|
15682
|
+
return libHead;
|
|
15683
|
+
}
|
|
15684
|
+
|
|
15685
|
+
// Append cards to a host, grouped by domain so reach is a heading rather than a
|
|
15686
|
+
// badge the manager has to scan for. `withHeaders` is false inside the collapsed
|
|
15687
|
+
// below-threshold and archived sections, which are already one labelled group.
|
|
15688
|
+
function tfAppendGroupedCards(host, entries, sort, opts) {
|
|
15689
|
+
const { scope, level, shareMode, onSelectionChange, withHeaders } = opts;
|
|
15690
|
+
const groups = new Map();
|
|
15691
|
+
for (const e of entries) {
|
|
15692
|
+
const d = tfLearnDomainLabel(e.domain);
|
|
15693
|
+
if (!groups.has(d)) groups.set(d, []);
|
|
15694
|
+
groups.get(d).push(e);
|
|
15695
|
+
}
|
|
15696
|
+
const groupNames = Array.from(groups.keys()).sort(tfDomainOrder);
|
|
15697
|
+
const showHeaders = withHeaders && groupNames.length > 1;
|
|
15698
|
+
for (const d of groupNames) {
|
|
15699
|
+
const sorted = tfSortLearnings(groups.get(d), sort);
|
|
15700
|
+
if (showHeaders) host.appendChild(tfLearnGroupHeader(d, sorted.length));
|
|
15701
|
+
for (const e of sorted) host.appendChild(tfPreservedCard(e, scope, level, shareMode, onSelectionChange));
|
|
15702
|
+
}
|
|
15703
|
+
}
|
|
15704
|
+
|
|
15043
15705
|
function tfRenderLearningsList(host, scope, level, emptyText) {
|
|
15044
15706
|
host.innerHTML = '';
|
|
15045
|
-
const liveConv = tfLearningRunByScope[tfLearningRunKey(scope)];
|
|
15046
|
-
const proposals = tfParsePendingProposals(liveConv, scope);
|
|
15047
|
-
const live = proposals.length > 0;
|
|
15048
15707
|
const key = tfLearnKey(scope, level);
|
|
15049
15708
|
const preserved = tfPreservedCache()[key];
|
|
15050
15709
|
|
|
@@ -15065,14 +15724,18 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
|
|
|
15065
15724
|
// Issue #1002 R13: the whole pending batch is reviewed in ONE surface, with a
|
|
15066
15725
|
// bulk accept that states its count. Serial presentation is prohibited: the
|
|
15067
15726
|
// manager must be able to act on any row without first resolving the others.
|
|
15068
|
-
|
|
15069
|
-
|
|
15070
|
-
|
|
15727
|
+
//
|
|
15728
|
+
// Issue #1755: that one surface is the run's own conversation, where the job
|
|
15729
|
+
// wrote the proposals. This panel is the corpus library: a durable list of what
|
|
15730
|
+
// the agent already knows, editable, deletable and shareable. It carries no
|
|
15731
|
+
// notice about a pending batch: a run waiting on a decision already reads
|
|
15732
|
+
// "Waiting on you" in the employee rail like every other job awaiting review,
|
|
15733
|
+
// so a second learnings-specific signal here would only point away from the
|
|
15734
|
+
// panel it sits in.
|
|
15071
15735
|
const shareMode = host.dataset.lpShareMode === 'true';
|
|
15072
15736
|
// When share mode is active, update the floating share bar whenever selection changes.
|
|
15073
15737
|
const onSelectionChange = shareMode ? () => tfUpdateShareBar(host, scope, level) : null;
|
|
15074
15738
|
|
|
15075
|
-
for (const p of proposals) list.appendChild(tfProposalCard(p, scope, live));
|
|
15076
15739
|
if (Array.isArray(preserved)) {
|
|
15077
15740
|
const hotEntries = preserved.filter((e) => e.tier !== 'cold');
|
|
15078
15741
|
const coldEntries = preserved.filter((e) => e.tier === 'cold');
|
|
@@ -15083,9 +15746,19 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
|
|
|
15083
15746
|
const injectedEntries = hotEntries.filter((e) => e.injected !== false);
|
|
15084
15747
|
const dormantHotEntries = hotEntries.filter((e) => e.injected === false);
|
|
15085
15748
|
|
|
15086
|
-
|
|
15749
|
+
// #1755: the library chrome: a strength summary over the whole corpus, and
|
|
15750
|
+
// filters once the list is long enough that scrolling stops working.
|
|
15751
|
+
const f = tfLearnFilters(key);
|
|
15752
|
+
const reRender = () => tfRenderLearningsList(host, scope, level, emptyText);
|
|
15753
|
+
if (!shareMode && preserved.length > 0) {
|
|
15754
|
+
host.appendChild(tfLearnLibraryHeader(preserved, key, reRender));
|
|
15755
|
+
}
|
|
15756
|
+
const visible = shareMode ? injectedEntries : injectedEntries.filter((e) => tfLearnMatchesFilters(e, f));
|
|
15757
|
+
|
|
15758
|
+
tfAppendGroupedCards(list, visible, f.sort, { scope, level, shareMode, onSelectionChange, withHeaders: !shareMode });
|
|
15087
15759
|
|
|
15088
|
-
|
|
15760
|
+
const dormantVisible = shareMode ? dormantHotEntries : dormantHotEntries.filter((e) => tfLearnMatchesFilters(e, f));
|
|
15761
|
+
if (dormantVisible.length) {
|
|
15089
15762
|
// Cut line: learnings below the score threshold. Agent won't see these.
|
|
15090
15763
|
const cut = document.createElement('details');
|
|
15091
15764
|
cut.className = 'learn-dormant-section learn-cutline-section';
|
|
@@ -15094,23 +15767,37 @@ function tfRenderLearningsList(host, scope, level, emptyText) {
|
|
|
15094
15767
|
cutSummary.className = 'learn-dormant-header';
|
|
15095
15768
|
const threshold = tfThresholdCache()[key];
|
|
15096
15769
|
const thresholdLabel = typeof threshold === 'number' ? ` (score < ${threshold})` : '';
|
|
15097
|
-
cutSummary.textContent = `Below threshold${thresholdLabel}
|
|
15770
|
+
cutSummary.textContent = `Below threshold${thresholdLabel}: not injected into agent context (${dormantVisible.length})`;
|
|
15098
15771
|
cut.appendChild(cutSummary);
|
|
15099
|
-
|
|
15772
|
+
tfAppendGroupedCards(cut, dormantVisible, f.sort, { scope, level, shareMode, onSelectionChange, withHeaders: false });
|
|
15100
15773
|
list.appendChild(cut);
|
|
15101
15774
|
}
|
|
15102
15775
|
|
|
15103
|
-
|
|
15776
|
+
const coldVisible = shareMode ? coldEntries : coldEntries.filter((e) => tfLearnMatchesFilters(e, f));
|
|
15777
|
+
if (coldVisible.length) {
|
|
15104
15778
|
const dormant = document.createElement('details');
|
|
15105
15779
|
dormant.className = 'learn-dormant-section';
|
|
15106
15780
|
dormant.setAttribute('data-testid', 'dormant-section');
|
|
15107
15781
|
const summary = document.createElement('summary');
|
|
15108
15782
|
summary.className = 'learn-dormant-header';
|
|
15109
|
-
summary.textContent = `Archived (${
|
|
15783
|
+
summary.textContent = `Archived (${coldVisible.length})`;
|
|
15110
15784
|
dormant.appendChild(summary);
|
|
15111
|
-
|
|
15785
|
+
tfAppendGroupedCards(dormant, coldVisible, f.sort, { scope, level, shareMode, onSelectionChange, withHeaders: false });
|
|
15112
15786
|
list.appendChild(dormant);
|
|
15113
15787
|
}
|
|
15788
|
+
|
|
15789
|
+
// A filter that hides everything must say so, or it reads as an empty corpus.
|
|
15790
|
+
if (preserved.length > 0 && !list.children.length) {
|
|
15791
|
+
const none = document.createElement('div');
|
|
15792
|
+
none.className = 'll-nomatch';
|
|
15793
|
+
none.dataset.testid = 'learn-no-match';
|
|
15794
|
+
none.textContent = 'No entries match this filter.';
|
|
15795
|
+
const clear = document.createElement('button');
|
|
15796
|
+
clear.type = 'button'; clear.className = 'lp-add-btn'; clear.textContent = 'Clear filters';
|
|
15797
|
+
clear.addEventListener('click', () => { f.strength = 'all'; f.domain = 'all'; reRender(); });
|
|
15798
|
+
host.appendChild(none); host.appendChild(clear);
|
|
15799
|
+
return;
|
|
15800
|
+
}
|
|
15114
15801
|
}
|
|
15115
15802
|
|
|
15116
15803
|
const wireAdd = () => {
|
|
@@ -15296,7 +15983,7 @@ function tfLevelWord(level) {
|
|
|
15296
15983
|
// Relay one manager turn into the running learning job, through the same
|
|
15297
15984
|
// continueRun path every other in-conversation control uses. No second write path.
|
|
15298
15985
|
async function tfRelayBatch(scope, message) {
|
|
15299
|
-
const conv =
|
|
15986
|
+
const conv = tfFindLearningRun(scope);
|
|
15300
15987
|
if (conv && conv.runId) {
|
|
15301
15988
|
const prev = state.activeId;
|
|
15302
15989
|
state.activeId = conv.id;
|
|
@@ -15311,6 +15998,12 @@ function tfReRenderLearnings(scope) {
|
|
|
15311
15998
|
if (scope === 'org' && typeof tfRenderCompany === 'function') tfRenderCompany();
|
|
15312
15999
|
else if (typeof tfRenderProjectContextTop === 'function') tfRenderProjectContextTop();
|
|
15313
16000
|
if (typeof tfRenderManager === 'function' && (scope === 'manager' || scope === 'reverse')) tfRenderManager();
|
|
16001
|
+
// #1755: a proposal decision is taken in the run's conversation, and none of the
|
|
16002
|
+
// panel renders above rebuild it. Without this the card the manager just acted
|
|
16003
|
+
// on keeps its pre-decision state while the panel's pending pointer updates.
|
|
16004
|
+
if (typeof renderLearningProposalTurn === 'function' && typeof activeConversation === 'function') {
|
|
16005
|
+
renderLearningProposalTurn(activeConversation());
|
|
16006
|
+
}
|
|
15314
16007
|
}
|
|
15315
16008
|
|
|
15316
16009
|
// Share-mode: tracks which learning entries the manager has checked for sharing.
|
|
@@ -15498,7 +16191,7 @@ async function tfLaunchShareWithOthers(scope, level, selectedTitles, job, projec
|
|
|
15498
16191
|
}
|
|
15499
16192
|
}
|
|
15500
16193
|
|
|
15501
|
-
function tfProposalCard(p, scope
|
|
16194
|
+
function tfProposalCard(p, scope) {
|
|
15502
16195
|
const meta = tfScopeMeta(scope);
|
|
15503
16196
|
const card = document.createElement('div');
|
|
15504
16197
|
card.className = 'learn-prop';
|
|
@@ -15529,6 +16222,17 @@ function tfProposalCard(p, scope, live) {
|
|
|
15529
16222
|
card.appendChild(ex);
|
|
15530
16223
|
}
|
|
15531
16224
|
|
|
16225
|
+
// Issue #1755 R6: a proposal card reads as two zones: what you can still change
|
|
16226
|
+
// before deciding, and the decision itself. They used to sit on one undivided
|
|
16227
|
+
// row, so a level button and a Skip button looked like the same kind of control.
|
|
16228
|
+
const config = document.createElement('div');
|
|
16229
|
+
config.className = 'lp-config';
|
|
16230
|
+
config.dataset.testid = 'lp-config';
|
|
16231
|
+
const configHead = document.createElement('div');
|
|
16232
|
+
configHead.className = 'lp-config-head';
|
|
16233
|
+
configHead.textContent = 'Configure before deciding';
|
|
16234
|
+
config.appendChild(configHead);
|
|
16235
|
+
|
|
15532
16236
|
const actions = document.createElement('div');
|
|
15533
16237
|
actions.className = 'lp-actions';
|
|
15534
16238
|
const reRender = () => {
|
|
@@ -15536,6 +16240,12 @@ function tfProposalCard(p, scope, live) {
|
|
|
15536
16240
|
else if (tf.area === 'company') tfRenderCompany();
|
|
15537
16241
|
else if (typeof tfRenderProjectContextTop === 'function') tfRenderProjectContextTop();
|
|
15538
16242
|
else tfRenderTree();
|
|
16243
|
+
// #1755: a proposal card lives in the conversation turn, which none of the
|
|
16244
|
+
// panel renders above rebuild. Without this a level change repaints the
|
|
16245
|
+
// panel and leaves the card the manager just clicked untouched.
|
|
16246
|
+
if (typeof renderLearningProposalTurn === 'function' && typeof activeConversation === 'function') {
|
|
16247
|
+
renderLearningProposalTurn(activeConversation());
|
|
16248
|
+
}
|
|
15539
16249
|
};
|
|
15540
16250
|
|
|
15541
16251
|
// Issue #1002 R13: EVERY row carries its level and its own actions, on every
|
|
@@ -15594,6 +16304,12 @@ function tfProposalCard(p, scope, live) {
|
|
|
15594
16304
|
pick.appendChild(b);
|
|
15595
16305
|
}
|
|
15596
16306
|
actions.appendChild(pick);
|
|
16307
|
+
config.appendChild(actions);
|
|
16308
|
+
card.appendChild(config);
|
|
16309
|
+
|
|
16310
|
+
const decide = document.createElement('div');
|
|
16311
|
+
decide.className = 'lp-decide';
|
|
16312
|
+
decide.dataset.testid = 'lp-decide';
|
|
15597
16313
|
|
|
15598
16314
|
const skip = document.createElement('button');
|
|
15599
16315
|
skip.className = 'lp-skip' + ((tfProposalOverrides[p.id] || {}).skipped || decided === 'reject' ? ' on' : '');
|
|
@@ -15606,8 +16322,7 @@ function tfProposalCard(p, scope, live) {
|
|
|
15606
16322
|
tfSetProposalOverride(p, { skipped: !wasSkipped });
|
|
15607
16323
|
reRender();
|
|
15608
16324
|
});
|
|
15609
|
-
|
|
15610
|
-
card.appendChild(actions);
|
|
16325
|
+
decide.appendChild(skip);
|
|
15611
16326
|
|
|
15612
16327
|
// Issue #1002 R14: the reason field appears ONLY on a row the manager changed,
|
|
15613
16328
|
// and is required before the batch can be saved. A row left at its suggested
|
|
@@ -15636,13 +16351,28 @@ function tfProposalCard(p, scope, live) {
|
|
|
15636
16351
|
why.appendChild(label);
|
|
15637
16352
|
why.appendChild(ta);
|
|
15638
16353
|
why.appendChild(hint);
|
|
15639
|
-
|
|
16354
|
+
config.appendChild(why);
|
|
16355
|
+
}
|
|
16356
|
+
|
|
16357
|
+
// R12: a decided card says which way it went, rather than leaving the manager to
|
|
16358
|
+
// infer it from a dimmed card and a disabled button.
|
|
16359
|
+
if (decided) {
|
|
16360
|
+
const badge = document.createElement('span');
|
|
16361
|
+
const skipped = decided === 'reject';
|
|
16362
|
+
badge.className = 'lp-decided-badge' + (skipped ? ' is-skipped' : ' is-saved');
|
|
16363
|
+
badge.dataset.testid = 'lp-decided-badge';
|
|
16364
|
+
badge.textContent = skipped ? 'Skipped' : 'Saved';
|
|
16365
|
+
decide.appendChild(badge);
|
|
15640
16366
|
}
|
|
15641
16367
|
|
|
16368
|
+
// R8: state what the decision will actually write, in the zone where it is made.
|
|
15642
16369
|
const note = document.createElement('span');
|
|
15643
16370
|
note.className = 'lp-scope-note';
|
|
15644
|
-
note.textContent =
|
|
15645
|
-
|
|
16371
|
+
note.textContent = (tfProposalOverrides[p.id] || {}).skipped
|
|
16372
|
+
? 'Nothing is written. The skip is recorded as a signal.'
|
|
16373
|
+
: (TF_LEVEL_TITLE[currentLevel] || tfScopeMeta(scope).note);
|
|
16374
|
+
decide.appendChild(note);
|
|
16375
|
+
card.appendChild(decide);
|
|
15646
16376
|
return card;
|
|
15647
16377
|
}
|
|
15648
16378
|
|
|
@@ -15650,7 +16380,7 @@ function tfProposalCard(p, scope, live) {
|
|
|
15650
16380
|
// (a scope-targeted instruction into the sleep-on-learnings run) — no new backend.
|
|
15651
16381
|
async function tfPromoteProposal(originScope, proposal, targetScope) {
|
|
15652
16382
|
const label = targetScope === 'org' ? 'Company' : targetScope === 'manager' ? 'Manager' : 'Project';
|
|
15653
|
-
const conv =
|
|
16383
|
+
const conv = tfFindLearningRun(originScope);
|
|
15654
16384
|
const message = `Promote the proposal "${proposal.title}" to ${label.toLowerCase()} learnings (scope: ${targetScope}).`;
|
|
15655
16385
|
tfProposalDecisions[proposal.id] = 'promote:' + targetScope;
|
|
15656
16386
|
if (conv && conv.runId) {
|