fraim-hub 2.0.338 → 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.
@@ -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 > 0 && fileArtifacts.length === artifacts.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 = tfLearningRunByScope[tfLearningRunKey(scope)];
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
- : { action: 'edit', scope, level, category, originalTitle: entry.title, severity: prio.value, title, body: bodyIn.value },
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
- // A preserved learning card with category + priority + title + body, plus Edit and
14963
- // Delete that write the underlying file (#533 #3) at the card's scope/level.
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
- try { await tfWriteLearning({ action: 'delete', scope, level, category: entry.category, originalTitle: entry.title }, scope, level); }
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 = String(entry.body).replace(/\*\*/g, '').replace(/`/g, '');
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 (editable), and a
15042
- // per-section "+ Add" that writes a new entry to the right file/level.
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
- if (proposals.length > 0) {
15069
- host.appendChild(tfLearningBatchBar(proposals, scope));
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
- for (const e of injectedEntries) list.appendChild(tfPreservedCard(e, scope, level, shareMode, onSelectionChange));
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
- if (dormantHotEntries.length) {
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} — not injected into agent context (${dormantHotEntries.length})`;
15770
+ cutSummary.textContent = `Below threshold${thresholdLabel}: not injected into agent context (${dormantVisible.length})`;
15098
15771
  cut.appendChild(cutSummary);
15099
- for (const e of dormantHotEntries) cut.appendChild(tfPreservedCard(e, scope, level, shareMode, onSelectionChange));
15772
+ tfAppendGroupedCards(cut, dormantVisible, f.sort, { scope, level, shareMode, onSelectionChange, withHeaders: false });
15100
15773
  list.appendChild(cut);
15101
15774
  }
15102
15775
 
15103
- if (coldEntries.length) {
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 (${coldEntries.length})`;
15783
+ summary.textContent = `Archived (${coldVisible.length})`;
15110
15784
  dormant.appendChild(summary);
15111
- for (const e of coldEntries) dormant.appendChild(tfPreservedCard(e, scope, level, shareMode, onSelectionChange));
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 = tfLearningRunByScope[tfLearningRunKey(scope)];
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, live) {
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
- actions.appendChild(skip);
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
- card.appendChild(why);
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 = TF_LEVEL_TITLE[currentLevel] || tfScopeMeta(scope).note;
15645
- card.appendChild(note);
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 = tfLearningRunByScope[tfLearningRunKey(originScope)];
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) {