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.
@@ -10,6 +10,7 @@ exports.ruleUsageKey = ruleUsageKey;
10
10
  exports.resolveEntryIdentity = resolveEntryIdentity;
11
11
  exports.normalizeModelName = normalizeModelName;
12
12
  exports.readUsageSnapshot = readUsageSnapshot;
13
+ exports.resolveUsageCheckpointPath = resolveUsageCheckpointPath;
13
14
  exports.writeUsageSnapshot = writeUsageSnapshot;
14
15
  exports.foldUsageEvents = foldUsageEvents;
15
16
  exports.readUsageStore = readUsageStore;
@@ -46,18 +47,10 @@ exports.compactUsageEvents = compactUsageEvents;
46
47
  * (`src/ai-hub/server.ts`). That placement is what satisfies R18; there is no
47
48
  * second guardrail here.
48
49
  *
49
- * It is derived data (R17): deleting it loses no lesson, because every firing is
50
- * still written in the retrospective that attested it and every offer is
51
- * re-recorded the next time the entry is delivered.
52
- *
53
- * ## Issue #1752 — what changed and why
54
- *
55
- * The durable artifact is now the append-only event log in
56
- * `learning-usage-events.ts`; `usage/learning-usage.json` is a derived snapshot
57
- * carrying a `foldedThroughBytes` checkpoint into it. The old read-modify-write
58
- * lost 1589 of 1600 writes from eight concurrent processes on a plain local disk
59
- * (spike S1b), which is the real cause of both the impossible `fired > offered`
60
- * counts and the four OneDrive conflict copies.
50
+ * Sleep consolidates the append-only journal into learning-usage.json and its
51
+ * recoverable .checkpoint copy before clearing processed events. Those snapshots
52
+ * preserve lifetime aggregates and source bookmarks after journal cleanup.
53
+ * Raw retrospectives preserve lessons and attestations, not every runtime offer.
61
54
  *
62
55
  * Four smaller corrections ride with it, each removing a place where the record
63
56
  * gave a confident answer it could not prove:
@@ -306,32 +299,45 @@ function readCheckpoints(value) {
306
299
  }
307
300
  return out;
308
301
  }
309
- /**
310
- * Parse the snapshot alone, with no event folding. A missing or corrupt snapshot
311
- * reads as empty: it is derived data, so losing it is recoverable and must never
312
- * break the loader that reads it.
313
- */
302
+ /** Read the normal snapshot, recovering a missing or torn copy from its checkpoint. */
314
303
  function readUsageSnapshot(storePath = resolveUsageStorePath()) {
304
+ for (const candidate of [storePath, resolveUsageCheckpointPath(storePath)]) {
305
+ const snapshot = parseUsageSnapshot(candidate);
306
+ if (snapshot)
307
+ return snapshot;
308
+ }
309
+ if ((0, fs_1.existsSync)(resolveUsageCheckpointPath(storePath))) {
310
+ throw new Error('Usage snapshot and checkpoint backup are unreadable. Restore a matching backup; do not delete the usage directory.');
311
+ }
312
+ return emptyStore();
313
+ }
314
+ function resolveUsageCheckpointPath(storePath) {
315
+ return storePath + '.checkpoint';
316
+ }
317
+ function parseUsageSnapshot(storePath) {
315
318
  try {
316
319
  if (!(0, fs_1.existsSync)(storePath))
317
- return emptyStore();
320
+ return null;
318
321
  const parsed = JSON.parse((0, fs_1.readFileSync)(storePath, 'utf8'));
319
322
  // `typeof [] === 'object'`, so an array has to be rejected explicitly: otherwise
320
323
  // Object.values yields whatever the array held and every consumer that reads
321
324
  // `entry.offered` throws on it.
322
325
  if (!parsed || typeof parsed !== 'object' || !parsed.entries
323
326
  || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
324
- return emptyStore();
327
+ return null;
325
328
  }
326
329
  // Drop any value that is not a usable record rather than handing a caller
327
330
  // something it will crash on. A partially corrupt store degrades to the records
328
- // that are still readable, which is the right behaviour for derived data.
331
+ // that are still readable before consolidation. Consolidated snapshots must be complete.
329
332
  const entries = {};
330
333
  for (const [key, value] of Object.entries(parsed.entries)) {
331
334
  if (isUsableRecord(value))
332
335
  entries[key] = value;
336
+ else if (parsed.consolidated)
337
+ return null;
333
338
  }
334
339
  return {
340
+ consolidated: parsed.consolidated === true,
335
341
  version: parsed.version ?? STORE_VERSION,
336
342
  updatedAt: parsed.updatedAt ?? emptyStore().updatedAt,
337
343
  entries,
@@ -342,28 +348,18 @@ function readUsageSnapshot(storePath = resolveUsageStorePath()) {
342
348
  };
343
349
  }
344
350
  catch {
345
- return emptyStore();
351
+ return null;
346
352
  }
347
353
  }
348
354
  /**
349
- * Publish the derived snapshot.
350
- *
351
- * In place, with a plain `writeFileSync`, and never by rename. S3 measured a
352
- * rename over a target any process holds open failing EPERM 100 times in 100, on
353
- * both a synced and a local path, because that is Windows file-sharing semantics
354
- * rather than a OneDrive artefact. The usage store is read by concurrent MCP
355
- * processes by design, so a rename-published snapshot would fail essentially
356
- * always in production. This is safe precisely because the snapshot is derived: a
357
- * torn one is rejected by `readUsageSnapshot` and rebuilt from the log.
358
- *
359
- * Returns whether it landed. A failure is never thrown: the log is authoritative
360
- * and the next reader either uses the older snapshot with a longer tail, or folds
361
- * cold.
355
+ * Refresh the normal snapshot in place (Windows readers can prevent rename).
356
+ * Sleep alone publishes the separate flushed checkpoint backup before clearing
357
+ * journals, so a torn ordinary refresh can recover without replaying old history.
362
358
  */
363
359
  function writeUsageSnapshot(store, storePath = resolveUsageStorePath()) {
364
360
  try {
365
361
  (0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
366
- (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
362
+ (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf8', flush: true });
367
363
  return true;
368
364
  }
369
365
  catch {
@@ -385,6 +381,8 @@ function foldUsageEvents(options) {
385
381
  // A checkpoint past the end of its log means the log was compacted or replaced
386
382
  // under this snapshot, so the snapshot may already count events the log no
387
383
  // longer holds. Re-folding onto it would double count. Discard and fold cold.
384
+ if (tail.restarted && base.consolidated)
385
+ throw new Error('Usage journal no longer matches its consolidated snapshot. Restore matching checkpoint files before continuing.');
388
386
  if (tail.restarted) {
389
387
  const cold = (0, learning_usage_events_1.readEventsSince)(options.eventDir, {});
390
388
  return applyTail(emptyStore(), cold, limits, now, true);
@@ -393,6 +391,7 @@ function foldUsageEvents(options) {
393
391
  }
394
392
  function applyTail(base, tail, limits, now, restarted) {
395
393
  const store = {
394
+ consolidated: base.consolidated,
396
395
  version: base.version,
397
396
  updatedAt: base.updatedAt,
398
397
  entries: base.entries,
@@ -430,13 +429,23 @@ function readUsageStore(storePath = resolveUsageStorePath(), options = {}) {
430
429
  const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
431
430
  if ((0, learning_usage_events_1.listEventLogs)(eventDir).length === 0)
432
431
  return snapshot;
433
- const folded = foldUsageEvents({
432
+ const fold = (base) => foldUsageEvents({
434
433
  eventDir,
435
434
  limits: options.limits,
436
435
  now: options.now,
437
- base: snapshot,
438
- checkpoints: snapshot.foldedThroughBytes ?? {},
436
+ base,
437
+ checkpoints: base.foldedThroughBytes ?? {},
439
438
  });
439
+ let folded;
440
+ try {
441
+ folded = fold(snapshot);
442
+ }
443
+ catch (error) {
444
+ const backup = parseUsageSnapshot(resolveUsageCheckpointPath(storePath));
445
+ if (!backup)
446
+ throw error;
447
+ folded = fold(backup);
448
+ }
440
449
  const mode = options.refreshSnapshot ?? 'auto';
441
450
  const shouldRefresh = mode === 'always'
442
451
  || (mode === 'auto' && (folded.tailBytes >= SNAPSHOT_REFRESH_BYTES || !(0, fs_1.existsSync)(storePath) || folded.restarted));
@@ -747,7 +756,7 @@ function flagIntegrity(store) {
747
756
  /**
748
757
  * Materialize the snapshot when there is none.
749
758
  *
750
- * The snapshot is derived and normally published by readers, but a workspace that
759
+ * Before the first consolidation, readers publish snapshots, but a workspace that
751
760
  * has only ever been written to would have no snapshot at all, so every reader
752
761
  * would pay a cold fold and anything inspecting the store as a file would find
753
762
  * nothing there. Bounded: it only runs when the file is absent, and at that point
@@ -761,7 +770,7 @@ function ensureSnapshotExists(storePath, eventDir, limits, now) {
761
770
  readUsageStore(storePath, { limits, now, eventDir, refreshSnapshot: 'always' });
762
771
  }
763
772
  catch {
764
- // The log is authoritative. A missing snapshot costs a cold fold, nothing else.
773
+ // Initial cache publication can retry; consolidated history has a separate backup.
765
774
  }
766
775
  }
767
776
  /**
@@ -899,26 +908,48 @@ function pruneUsageStore(limits, options = {}) {
899
908
  const compaction = compactUsageEvents({ eventDir, snapshotPath: storePath, limits, now, store });
900
909
  return { prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length, compaction };
901
910
  }
902
- /**
903
- * Rewrite the event logs to the configured retention window and re-base the
904
- * snapshot's checkpoint onto the rewritten logs.
905
- *
906
- * Re-basing is not optional: leaving a checkpoint pointing into the old log would
907
- * make the next reader either re-count events the snapshot already holds, or skip
908
- * ones it does not.
911
+ /** Save totals and source bookmarks in both copies, then clear processed journals.
912
+ * Requires an interval without concurrent writers or sync activity.
909
913
  */
910
914
  function compactUsageEvents(options) {
911
915
  const now = options.now ?? new Date();
912
916
  const store = options.store
913
917
  ?? readUsageStore(options.snapshotPath, { limits: options.limits, now, eventDir: options.eventDir });
914
- const result = (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
918
+ for (const log of (0, learning_usage_events_1.listEventLogs)(options.eventDir)) {
919
+ if ((store.foldedThroughBytes?.[log.machineId] ?? 0) < log.baseOffset) {
920
+ throw new Error('Cannot consolidate from a snapshot older than the journal checkpoint.');
921
+ }
922
+ }
923
+ for (const entry of Object.values(store.entries))
924
+ trimLogs(entry, options.limits, now);
925
+ store.consolidated = true;
926
+ store.updatedAt = now.toISOString();
927
+ // Publish the recoverable checkpoint first, then the normal snapshot. No
928
+ // journal is cleared unless both writes succeed. The backup is never written
929
+ // by ordinary readers, so a torn cache refresh cannot destroy both copies.
930
+ const checkpoint = resolveUsageCheckpointPath(options.snapshotPath);
931
+ const temp = checkpoint + '.tmp';
932
+ try {
933
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(checkpoint), { recursive: true });
934
+ (0, fs_1.writeFileSync)(temp, JSON.stringify(store, null, 2) + '\n', { encoding: 'utf8', flush: true });
935
+ (0, fs_1.renameSync)(temp, checkpoint);
936
+ }
937
+ catch {
938
+ try {
939
+ (0, fs_1.rmSync)(temp, { force: true });
940
+ }
941
+ catch { /* Best effort. */ }
942
+ throw new Error('Cannot consolidate usage events: checkpoint backup could not be saved.');
943
+ }
944
+ if (!writeUsageSnapshot(store, options.snapshotPath)) {
945
+ throw new Error('Cannot consolidate usage events: aggregate snapshot could not be saved.');
946
+ }
947
+ return (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
948
+ if (event.kind !== 'offer' && event.kind !== 'firing')
949
+ return true;
915
950
  const date = typeof event.date === 'string' ? event.date : '';
916
- if (!isValidDate(date))
951
+ if (typeof event.key !== 'string' || !event.key || !isValidDate(date))
917
952
  return true;
918
- return withinRetention(date, options.limits.retentionDays, now);
919
- });
920
- store.foldedThroughBytes = Object.fromEntries(result.logs.map((log) => [log.machineId, log.size]));
921
- store.updatedAt = now.toISOString();
922
- writeUsageSnapshot(store, options.snapshotPath);
923
- return result;
953
+ return event.kind === 'firing' && !exports.FIRING_OUTCOMES.includes(event.outcome);
954
+ }, store.foldedThroughBytes ?? {});
924
955
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.339",
3
+ "version": "2.0.340",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "dist/registry/scripts/learning/learning-domains.json",
20
20
  "dist/registry/scripts/learning/entry-format.js",
21
+ "dist/registry/scripts/learning/completion-state.js",
21
22
  "dist/src/ai-hub/",
22
23
  "dist/src/api/admin/payments.js",
23
24
  "dist/src/api/admin/sales-leads.js",
@@ -72,6 +73,7 @@
72
73
  "dist/src/config/stripe.js",
73
74
  "dist/src/core/brand-store.js",
74
75
  "dist/src/core/capability-pack.js",
76
+ "dist/src/core/handoff-contracts.js",
75
77
  "dist/src/core/quality-evidence.js",
76
78
  "dist/src/core/job-visualization.js",
77
79
  "dist/src/core/resolve-phase-edge.js",
@@ -226,7 +228,7 @@
226
228
  "electron-updater": "^6.8.9",
227
229
  "express": "^5.2.1",
228
230
  "extract-zip": "^2.0.1",
229
- "fraim": "2.0.339",
231
+ "fraim": "2.0.340",
230
232
  "mongodb": "^7.0.0",
231
233
  "node-cron": "4.2.1",
232
234
  "node-edge-tts": "^1.2.10",
@@ -634,3 +634,381 @@
634
634
 
635
635
  .review-turn .review-actions { border: 0; margin: 0; padding: 0; }
636
636
  .review-turn .rb-handoff { flex-basis: 100%; }
637
+
638
+ /* ===========================================================================
639
+ Issue #1755 - the learnings library.
640
+ The corpus was a flat list of full-text cards with no summary, no filter and
641
+ no sign of whether an entry has ever worked. These rules add the stats row,
642
+ the filter toolbar, the domain grouping, and the per-card strength + usage
643
+ footer. Every colour comes from the existing token set.
644
+ =========================================================================== */
645
+
646
+ /* Stats row */
647
+ .ll-stats {
648
+ display: flex;
649
+ align-items: center;
650
+ flex-wrap: wrap;
651
+ gap: 6px 14px;
652
+ padding: 2px 2px 12px;
653
+ }
654
+ .ll-stats .ll-stat {
655
+ display: inline-flex;
656
+ align-items: baseline;
657
+ gap: 5px;
658
+ font-size: 11.5px;
659
+ color: var(--muted);
660
+ white-space: nowrap;
661
+ }
662
+ .ll-stats .ll-stat b { font-size: 14px; font-weight: 700; color: var(--text); }
663
+ .ll-stats .ll-total b { font-size: 16px; }
664
+ .ll-stats .ll-stat::before {
665
+ content: '';
666
+ width: 7px; height: 7px; border-radius: 50%;
667
+ background: var(--muted);
668
+ align-self: center;
669
+ }
670
+ .ll-stats .ll-total::before { display: none; }
671
+ .ll-stats .s-strong::before { background: var(--done); }
672
+ .ll-stats .s-wouldfire::before { background: var(--warn); }
673
+ .ll-stats .s-unresolved::before { background: var(--strength-unresolved); }
674
+ .ll-stats .s-weak::before { background: var(--line); box-shadow: inset 0 0 0 1px var(--muted); }
675
+ .ll-stats .s-dead::before { background: transparent; box-shadow: inset 0 0 0 1px var(--line); }
676
+
677
+ /* Filter + sort toolbar */
678
+ .ll-tools {
679
+ display: flex;
680
+ align-items: center;
681
+ justify-content: space-between;
682
+ flex-wrap: wrap;
683
+ gap: 8px;
684
+ padding-bottom: 12px;
685
+ }
686
+ .ll-tools .ll-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
687
+ .ll-tools .ll-tab {
688
+ font: inherit;
689
+ font-size: 11.5px;
690
+ font-weight: 600;
691
+ color: var(--muted);
692
+ background: var(--surface);
693
+ border: 1px solid var(--line);
694
+ border-radius: 999px;
695
+ padding: 4px 11px;
696
+ cursor: pointer;
697
+ transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
698
+ }
699
+ .ll-tools .ll-tab:hover { color: var(--text); border-color: var(--muted); }
700
+ .ll-tools .ll-tab.active {
701
+ color: var(--accent-strong);
702
+ background: var(--accent-soft);
703
+ border-color: var(--accent);
704
+ }
705
+ .ll-tools .ll-selects { display: flex; gap: 8px; }
706
+ .ll-tools .ll-sel {
707
+ font: inherit;
708
+ font-size: 11.5px;
709
+ color: var(--text);
710
+ background: var(--surface);
711
+ border: 1px solid var(--line);
712
+ border-radius: 8px;
713
+ padding: 4px 8px;
714
+ cursor: pointer;
715
+ max-width: 190px;
716
+ }
717
+ .ll-tools .ll-sel:focus { outline: none; border-color: var(--accent); }
718
+
719
+ /* Domain group header */
720
+ .ll-group-head {
721
+ display: flex;
722
+ align-items: baseline;
723
+ gap: 8px;
724
+ margin-top: 8px;
725
+ padding-bottom: 5px;
726
+ border-bottom: 1px solid var(--line);
727
+ }
728
+ .learn-list > .ll-group-head:first-child { margin-top: 0; }
729
+ .ll-group-head .llg-name {
730
+ font-size: 10.5px;
731
+ font-weight: 700;
732
+ letter-spacing: 0.06em;
733
+ text-transform: uppercase;
734
+ color: var(--text);
735
+ }
736
+ .ll-group-head .llg-reach { font-size: 11px; color: var(--muted); }
737
+ .ll-group-head .llg-count {
738
+ margin-left: auto;
739
+ font-size: 11px;
740
+ font-weight: 600;
741
+ color: var(--muted);
742
+ }
743
+
744
+ /* No-match state */
745
+ .ll-nomatch { font-size: 13px; color: var(--muted); padding: 18px 2px 4px; }
746
+
747
+ /* Card: domain, strength and score badges */
748
+ .learn-prop .lp-domain {
749
+ font-size: 10px;
750
+ font-weight: 700;
751
+ padding: 2px 8px;
752
+ border-radius: 6px;
753
+ letter-spacing: 0.03em;
754
+ flex-shrink: 0;
755
+ background: var(--bg);
756
+ color: var(--muted);
757
+ box-shadow: inset 0 0 0 1px var(--line);
758
+ }
759
+ .learn-prop .lp-domain.dom-global { color: var(--muted); }
760
+ .learn-prop .lp-domain.dom-c0 { color: var(--domain-slot-0); box-shadow: inset 0 0 0 1px var(--domain-slot-0-ring); }
761
+ .learn-prop .lp-domain.dom-c1 { color: var(--domain-slot-1); box-shadow: inset 0 0 0 1px var(--domain-slot-1-ring); }
762
+ .learn-prop .lp-domain.dom-c2 { color: var(--domain-slot-2); box-shadow: inset 0 0 0 1px var(--domain-slot-2-ring); }
763
+ .learn-prop .lp-domain.dom-c3 { color: var(--domain-slot-3); box-shadow: inset 0 0 0 1px var(--domain-slot-3-ring); }
764
+ .learn-prop .lp-domain.dom-c4 { color: var(--domain-slot-4); box-shadow: inset 0 0 0 1px var(--domain-slot-4-ring); }
765
+ .learn-prop .lp-domain.dom-c5 { color: var(--domain-slot-5); box-shadow: inset 0 0 0 1px var(--domain-slot-5-ring); }
766
+
767
+ .learn-prop .lp-strength {
768
+ display: inline-flex;
769
+ align-items: center;
770
+ gap: 5px;
771
+ font-size: 10.5px;
772
+ font-weight: 700;
773
+ padding: 2px 8px 2px 6px;
774
+ border-radius: 6px;
775
+ flex-shrink: 0;
776
+ background: var(--bg);
777
+ color: var(--muted);
778
+ }
779
+ .learn-prop .lp-strength .lp-dot {
780
+ width: 6px; height: 6px; border-radius: 50%; background: currentColor;
781
+ flex-shrink: 0;
782
+ }
783
+ .learn-prop .lp-strength.s-strong { color: var(--done); }
784
+ .learn-prop .lp-strength.s-wouldfire { color: var(--warn); }
785
+ .learn-prop .lp-strength.s-unresolved { color: var(--strength-unresolved); }
786
+ .learn-prop .lp-strength.s-weak { color: var(--muted); }
787
+ .learn-prop .lp-strength.s-dead { color: var(--muted); opacity: 0.65; }
788
+ .learn-prop .lp-strength.s-dead .lp-dot { background: transparent; box-shadow: inset 0 0 0 1px currentColor; }
789
+
790
+ .learn-prop .lp-score {
791
+ font-size: 11px;
792
+ font-weight: 700;
793
+ font-variant-numeric: tabular-nums;
794
+ color: var(--muted);
795
+ background: var(--bg);
796
+ border-radius: 6px;
797
+ padding: 2px 7px;
798
+ flex-shrink: 0;
799
+ }
800
+
801
+ /* Card body clamp */
802
+ .learn-prop .lp-body.lp-clamped {
803
+ display: -webkit-box;
804
+ -webkit-line-clamp: 3;
805
+ line-clamp: 3;
806
+ -webkit-box-orient: vertical;
807
+ overflow: hidden;
808
+ }
809
+ .learn-prop .lp-more {
810
+ align-self: flex-start;
811
+ font: inherit;
812
+ font-size: 11.5px;
813
+ font-weight: 600;
814
+ color: var(--accent);
815
+ background: none;
816
+ border: none;
817
+ padding: 0;
818
+ margin-top: -2px;
819
+ cursor: pointer;
820
+ }
821
+ .learn-prop .lp-more:hover { color: var(--accent-strong); text-decoration: underline; }
822
+
823
+ /* Card usage footer */
824
+ .learn-prop .lp-stats {
825
+ display: flex;
826
+ align-items: center;
827
+ flex-wrap: wrap;
828
+ gap: 6px 12px;
829
+ margin-top: 2px;
830
+ padding-top: 9px;
831
+ border-top: 1px solid var(--line);
832
+ font-size: 11px;
833
+ color: var(--muted);
834
+ }
835
+ .learn-prop .lp-stats .lps-none { font-style: italic; }
836
+ .learn-prop .lp-bar {
837
+ display: flex;
838
+ width: 68px;
839
+ height: 4px;
840
+ border-radius: 999px;
841
+ overflow: hidden;
842
+ background: var(--line);
843
+ flex-shrink: 0;
844
+ }
845
+ .learn-prop .lp-bar i { display: block; height: 100%; }
846
+ .learn-prop .lp-bar .lb-applied { background: var(--done); }
847
+ .learn-prop .lp-bar .lb-prevented { background: var(--accent); }
848
+ .learn-prop .lp-bar .lb-ignored { background: var(--warn); }
849
+ .learn-prop .lp-stats .lps-counts { display: flex; flex-wrap: wrap; gap: 10px; }
850
+ .learn-prop .lp-stats .lps-c { display: inline-flex; align-items: baseline; gap: 4px; }
851
+ .learn-prop .lp-stats .lps-c b { font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
852
+ .learn-prop .lp-stats .lps-last { margin-left: auto; white-space: nowrap; }
853
+
854
+ /* Domain select in the inline editor */
855
+ .learn-prop .lp-edit-domain {
856
+ font: inherit; font-size: 12px; padding: 6px 8px; border: 1px solid var(--line);
857
+ border-radius: 8px; background: var(--surface); color: var(--text);
858
+ }
859
+ .learn-prop .lp-edit-domain:focus { outline: none; border-color: var(--accent); }
860
+
861
+ /* Collapsed sections (below-threshold + archived). These had no rules at all,
862
+ so they rendered as an unstyled details block in the middle of a card list. */
863
+ .learn-dormant-section {
864
+ border: 1px dashed var(--line);
865
+ border-radius: 14px;
866
+ padding: 4px 12px;
867
+ background: transparent;
868
+ }
869
+ .learn-dormant-section > .learn-dormant-header {
870
+ font-size: 11.5px;
871
+ font-weight: 600;
872
+ color: var(--muted);
873
+ padding: 8px 2px;
874
+ cursor: pointer;
875
+ list-style: none;
876
+ }
877
+ .learn-dormant-section > .learn-dormant-header::-webkit-details-marker { display: none; }
878
+ .learn-dormant-section > .learn-dormant-header::before {
879
+ content: '\25B8';
880
+ display: inline-block;
881
+ width: 12px;
882
+ transition: transform 140ms ease;
883
+ }
884
+ .learn-dormant-section[open] > .learn-dormant-header::before { transform: rotate(90deg); }
885
+ .learn-dormant-section > .learn-prop {
886
+ margin-bottom: 10px;
887
+ background: var(--bg);
888
+ }
889
+
890
+
891
+ /* Issue #1755: entries are authored as short labelled lines (Takeaway / Why this
892
+ matters / Example / What to do next). Collapsing them into one paragraph is
893
+ what made the readable ones read like the dense ones. */
894
+ .learn-prop .lp-body { white-space: pre-line; }
895
+
896
+ /* The provenance tail, revealed with the rest of the entry. */
897
+ .learn-prop .lp-prov {
898
+ white-space: pre-line;
899
+ font-size: 11.5px;
900
+ line-height: 1.55;
901
+ color: var(--muted);
902
+ border-left: 2px solid var(--line);
903
+ padding-left: 10px;
904
+ }
905
+
906
+ /* Issue #1755: the accordion body is a capped scroll region (.ctx-acc-body), so
907
+ the library's summary and controls pin to its top rather than scrolling out of
908
+ reach behind a hundred cards. */
909
+ .ll-head {
910
+ position: sticky;
911
+ top: -4px;
912
+ z-index: 2;
913
+ background: var(--ll-head-bg);
914
+ margin: -4px -2px 0;
915
+ padding: 6px 2px 0;
916
+ box-shadow: var(--ll-head-shadow);
917
+ }
918
+ .ll-head .ll-tools { padding-bottom: 10px; }
919
+
920
+ /* Issue #1755 R6: the proposal card's two zones. Configuration sits in a recessed
921
+ block so the controls that change what gets written read as settings, and the
922
+ decision sits below a hairline so it reads as the commit. */
923
+ .learn-prop .lp-config {
924
+ display: flex;
925
+ flex-direction: column;
926
+ gap: 8px;
927
+ background: var(--lp-recessed);
928
+ border-radius: 10px;
929
+ padding: 9px 11px 11px;
930
+ margin-top: 2px;
931
+ }
932
+ .learn-prop .lp-config .lp-config-head {
933
+ font-size: 10px;
934
+ font-weight: 700;
935
+ letter-spacing: 0.06em;
936
+ text-transform: uppercase;
937
+ color: var(--muted);
938
+ }
939
+ .learn-prop .lp-config .lp-actions { margin-top: 0; }
940
+ .learn-prop .lp-decide {
941
+ display: flex;
942
+ align-items: center;
943
+ flex-wrap: wrap;
944
+ gap: 10px;
945
+ margin-top: 2px;
946
+ padding-top: 10px;
947
+ border-top: 1px solid var(--line);
948
+ }
949
+ .learn-prop .lp-decide .lp-scope-note { margin-left: auto; text-align: right; }
950
+ .learn-prop .lp-decided-badge {
951
+ font-size: 10.5px;
952
+ font-weight: 700;
953
+ padding: 2px 9px;
954
+ border-radius: 999px;
955
+ letter-spacing: 0.02em;
956
+ }
957
+ /* Tinted with its own outcome colour rather than --accent-soft: an org brand
958
+ overrides --accent, and a purple-tinted pill around green text reads as two
959
+ unrelated signals. */
960
+ .learn-prop .lp-decided-badge.is-saved { background: var(--bg); color: var(--done); box-shadow: inset 0 0 0 1px currentColor; }
961
+ .learn-prop .lp-decided-badge.is-skipped { background: var(--bg); color: var(--muted); box-shadow: inset 0 0 0 1px var(--line); }
962
+
963
+ /* Issue #1755: at a narrow panel width the head row's badges (domain, category,
964
+ priority, strength) plus the score chip outrun the row, and an unbreakable
965
+ title word pushes the card into horizontal overflow. Let the row wrap and let
966
+ a long title break, rather than letting the card scroll sideways. */
967
+ .learn-prop .lp-head { flex-wrap: wrap; row-gap: 6px; }
968
+ /* A 220px basis is the hinge: while that much room is left after the badges the
969
+ title stays on their line, and below it the title takes a full row of its own
970
+ instead of being squeezed into a four-word-wide column. */
971
+ .learn-prop .lp-head .lp-title { flex: 1 1 220px; overflow-wrap: break-word; }
972
+
973
+ /* Issue #1755: the row actions were display:none until hover, which takes them out
974
+ of the tab order entirely: a keyboard or screen-reader manager could not reach
975
+ Edit or Delete at all, and Edit is now also where an entry is re-scoped. Hide
976
+ them visually instead of structurally, and reveal on focus as well as hover.
977
+ Reserving their space also stops the title reflowing every time the pointer
978
+ crosses a card. */
979
+ .learn-prop .lp-act {
980
+ display: inline-flex;
981
+ opacity: 0;
982
+ transition: opacity 120ms ease;
983
+ }
984
+ .learn-prop:hover .lp-act,
985
+ .learn-prop:focus-within .lp-act,
986
+ .learn-prop .lp-act:focus-visible { opacity: 1; }
987
+ .learn-prop .lp-act:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
988
+ .ll-tools .ll-tab:focus-visible,
989
+ .ll-tools .ll-sel:focus-visible,
990
+ .learn-prop .lp-more:focus-visible {
991
+ outline: 2px solid var(--accent);
992
+ outline-offset: 2px;
993
+ }
994
+
995
+ /* Issue #1755 R5: the sleep-on-learnings proposal set, rendered in the
996
+ conversation where the job wrote it. Full conversation width, because a
997
+ proposal carries a lesson body, a configuration zone and a decision. */
998
+ .learn-proposal-turn .bubble {
999
+ display: flex;
1000
+ flex-direction: column;
1001
+ gap: 10px;
1002
+ width: 100%;
1003
+ max-width: 100%;
1004
+ box-sizing: border-box;
1005
+ }
1006
+ .learn-proposal-turn .review-turn-label { margin-bottom: 0; }
1007
+ .learn-proposal-turn .learn-batch {
1008
+ background: var(--lp-raised);
1009
+ border: 1px solid var(--lp-raised-line);
1010
+ border-radius: 12px;
1011
+ padding: 9px 12px;
1012
+ }
1013
+ .learn-proposal-turn .learn-list { gap: 10px; }
1014
+ .learn-proposal-turn .learn-prop { background: var(--surface); }