@yemi33/minions 0.1.2195 → 0.1.2196

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.
@@ -66,11 +66,11 @@
66
66
  var d = await res.json().catch(function() { return {}; });
67
67
  if (!res.ok) throw new Error(d.error || ('HTTP ' + res.status));
68
68
  closeSlimPinEditor();
69
- scheduleStatusRefresh(400); // refresh the Pinned-context tile
70
- // If the list modal is open, re-render it once the snapshot catches up.
69
+ scheduleStatusRefresh(400); // refresh the Knowledge tile count
70
+ // If the Knowledge modal's Pinned tab is showing, re-render it once the
71
+ // snapshot catches up.
71
72
  setTimeout(function() {
72
- var listModal = document.getElementById('slim-pinned-modal');
73
- if (listModal && listModal.classList.contains('open')) renderSlimPinnedList();
73
+ if (document.getElementById('slim-pinned-list')) renderSlimPinnedList();
74
74
  }, 700);
75
75
  } catch (e) {
76
76
  if (msg) { msg.style.color = 'var(--red)'; msg.textContent = 'Error: ' + (e && e.message ? e.message : 'failed'); }
@@ -79,19 +79,10 @@
79
79
  }
80
80
 
81
81
  // ── List / view / unpin ──────────────────────────────────────────
82
- function openSlimPinnedList() {
83
- var modal = document.getElementById('slim-pinned-modal');
84
- if (!modal) return;
85
- renderSlimPinnedList();
86
- modal.classList.add('open');
87
- }
88
- function closeSlimPinnedList() {
89
- var modal = document.getElementById('slim-pinned-modal');
90
- if (modal) modal.classList.remove('open');
91
- }
92
-
82
+ // Rendered into #slim-pinned-list — the container the Knowledge modal's
83
+ // Pinned-Context tab builds. No-op when that container isn't mounted.
93
84
  function renderSlimPinnedList() {
94
- var body = document.getElementById('slim-pinned-body');
85
+ var body = document.getElementById('slim-pinned-list');
95
86
  if (!body) return;
96
87
  var entries = slimPinnedEntries();
97
88
  body.textContent = '';
@@ -166,13 +157,10 @@
166
157
  }
167
158
  }
168
159
 
169
- bindModalClose('slim-pinned-modal', 'slim-pinned-close');
170
160
  bindModalClose('slim-pin-edit-modal', 'slim-pin-edit-close');
171
161
  (function bindPinnedUi() {
172
162
  var pinBtn = document.getElementById('slim-pin-btn');
173
163
  if (pinBtn) pinBtn.addEventListener('click', function() { openSlimPinEditor(null); });
174
- var addBtn = document.getElementById('slim-pinned-add');
175
- if (addBtn) addBtn.addEventListener('click', function() { openSlimPinEditor(null); });
176
164
  var tileChip = document.getElementById('slim-tile-pin-chip');
177
165
  if (tileChip) tileChip.addEventListener('click', function(ev) { ev.stopPropagation(); openSlimPinEditor(null); });
178
166
  var cancel = document.getElementById('slim-pin-cancel');
@@ -16,11 +16,10 @@
16
16
  }, delay || 0);
17
17
  }
18
18
 
19
- // Tiles whose numeric value should fire a one-shot cockpit-flash animation
20
- // whenever it changes (W-mqb02bga: status panel). Suppressed on first paint
21
- // — only deltas from a previously-seen number trigger the flash, so opening
22
- // the dashboard doesn't pulse every tile.
23
- var FLASH_ON_CHANGE_KEYS = { dispatches: true, prs: true };
19
+ // Track each tile's last numeric value so the number element can fade
20
+ // out-and-back-in whenever it changes (W-mqbaeuhm). Suppressed on first paint
21
+ // — only deltas from a previously-seen number trigger the fade, so opening
22
+ // the dashboard doesn't animate every tile.
24
23
  var lastTileValues = Object.create(null);
25
24
 
26
25
  // Apply lit-state and value/detail text to a tile.
@@ -36,17 +35,17 @@
36
35
  if (detEl) detEl.textContent = detail || '';
37
36
  tile.classList.remove('lit-blue', 'lit-green', 'lit-amber', 'lit-orange', 'lit-red');
38
37
  if (lit) tile.classList.add('lit-' + lit);
39
- // Flash the tile when its number changes. Remove → force reflow → re-add
40
- // restarts the animation even when the class was already present from the
41
- // previous tick (otherwise repeated changes would only fire on the first
42
- // delta after class application). Only numeric values count as a change;
43
- // ignore null/undefined transitions.
44
- if (FLASH_ON_CHANGE_KEYS[key] && typeof value === 'number') {
38
+ // Fade the number out-and-back-in when it changes. Remove → force reflow →
39
+ // re-add restarts the animation even when the class was already present
40
+ // from the previous tick (otherwise repeated changes would only fire on the
41
+ // first delta after class application). Only numeric→different-numeric
42
+ // counts as a change; ignore null/undefined and unchanged values.
43
+ if (valEl && typeof value === 'number') {
45
44
  var prev = lastTileValues[key];
46
45
  if (typeof prev === 'number' && prev !== value) {
47
- tile.classList.remove('flash-change');
48
- void tile.offsetWidth;
49
- tile.classList.add('flash-change');
46
+ valEl.classList.remove('value-changed');
47
+ void valEl.offsetWidth;
48
+ valEl.classList.add('value-changed');
50
49
  }
51
50
  lastTileValues[key] = value;
52
51
  }
@@ -150,14 +149,11 @@
150
149
  activeWatches.length ? 'blue' : null
151
150
  );
152
151
 
153
- // ── Pinned context tile ────────────────────────────────────
152
+ // ── Knowledge tile (Pinned Context + Notes + KB) ──────────────
153
+ // Pinned count is live from the poll; notes/KB counts are fetched lazily
154
+ // (see knowledge.js loadKnowledgeCounts) to keep them off the 5s poll.
154
155
  var pinned = Array.isArray(data.pinned) ? data.pinned : [];
155
- updateTile(
156
- 'pinned',
157
- pinned.length,
158
- pinned.length ? (pinned.length === 1 ? '1 note for agents' : pinned.length + ' notes for agents') : 'nothing pinned',
159
- pinned.length ? 'blue' : null
160
- );
156
+ if (typeof renderKnowledgeTile === 'function') renderKnowledgeTile(pinned.length);
161
157
 
162
158
  // ── Team member cards ──────────────────────────────────────
163
159
  renderMembers(Array.isArray(data.agents) ? data.agents : []);
@@ -701,7 +701,7 @@
701
701
  .tile-section-label { font-weight: 700; letter-spacing: 0.3px; text-transform: uppercase; color: var(--text); }
702
702
  .tile-section-count { color: var(--muted); }
703
703
 
704
- /* Pinned-context list rows (slim-pinned-modal). */
704
+ /* Pinned-context list rows (Knowledge modal → Pinned Context tab). */
705
705
  .pinned-row {
706
706
  border: 1px solid var(--border);
707
707
  border-radius: var(--radius);
@@ -724,6 +724,85 @@
724
724
  .pinned-row-actions .btn-secondary { padding: 4px 12px; font-size: var(--text-base); }
725
725
  .pinned-row-unpin { color: var(--red); border-color: var(--red); }
726
726
 
727
+ /* Knowledge control panel (slim-knowledge-modal): Pinned Context / Notes /
728
+ KB tabs in one box. Reuses .tile-* and .pinned-row primitives. */
729
+ .slim-knowledge-modal-inner { width: 720px; max-width: calc(100vw - 32px); }
730
+ .kn-tabs { display: flex; gap: 6px; margin-left: 16px; }
731
+ .kn-tab {
732
+ border: 1px solid var(--border);
733
+ background: var(--surface2);
734
+ color: var(--muted);
735
+ font-size: var(--text-sm);
736
+ padding: 4px 12px;
737
+ border-radius: 999px;
738
+ cursor: pointer;
739
+ white-space: nowrap;
740
+ }
741
+ .kn-tab:hover { color: var(--text); border-color: var(--blue); }
742
+ .kn-tab.active {
743
+ color: var(--text);
744
+ border-color: var(--blue);
745
+ background: color-mix(in srgb, var(--blue) 14%, var(--surface2));
746
+ }
747
+ .kn-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
748
+ .kn-toolbar-end { justify-content: flex-end; }
749
+ .kn-section-head {
750
+ margin: 16px 0 8px;
751
+ font-size: var(--text-md);
752
+ font-weight: 600;
753
+ color: var(--text);
754
+ border-top: 1px solid var(--border);
755
+ padding-top: 12px;
756
+ }
757
+ .kn-msg { font-size: var(--text-base); min-height: 16px; color: var(--muted); }
758
+ .kn-msg-inline { margin-right: auto; }
759
+ .kn-notes-textarea { font-family: var(--mono, monospace); white-space: pre; }
760
+ .kn-create-form {
761
+ border: 1px solid var(--border);
762
+ border-radius: var(--radius);
763
+ background: var(--surface2);
764
+ padding: 12px;
765
+ margin-bottom: 12px;
766
+ }
767
+ /* KB list rows */
768
+ .kb-row {
769
+ border: 1px solid var(--border);
770
+ border-radius: var(--radius);
771
+ background: var(--surface2);
772
+ padding: 8px 10px;
773
+ margin-bottom: 8px;
774
+ cursor: pointer;
775
+ }
776
+ .kb-row:hover { border-color: var(--blue); }
777
+ .kb-row-pinned { border-color: var(--amber); }
778
+ .kb-row-top { display: flex; align-items: center; gap: 8px; }
779
+ .kb-row-title {
780
+ flex: 1; min-width: 0;
781
+ font-weight: 600; font-size: var(--text-md); color: var(--text);
782
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
783
+ }
784
+ .kb-pin-btn {
785
+ flex: 0 0 auto;
786
+ border: none; background: transparent; cursor: pointer;
787
+ color: var(--muted); font-size: var(--text-lg); line-height: 1; padding: 0 2px;
788
+ }
789
+ .kb-pin-btn.on { color: var(--amber); }
790
+ .kb-pin-btn:hover { color: var(--amber); }
791
+ .kb-row-meta { font-size: var(--text-base); color: var(--muted); margin-top: 3px; }
792
+ .kb-row-preview {
793
+ margin-top: 5px;
794
+ font-size: var(--text-base); color: var(--muted); line-height: 1.5;
795
+ word-break: break-word;
796
+ }
797
+ .kb-entry-content {
798
+ white-space: pre-wrap; word-break: break-word;
799
+ font-family: var(--mono, monospace); font-size: var(--text-base);
800
+ color: var(--text); line-height: 1.55;
801
+ background: var(--surface2); border: 1px solid var(--border);
802
+ border-radius: var(--radius); padding: 12px; margin: 0;
803
+ max-height: 60vh; overflow-y: auto;
804
+ }
805
+
727
806
  .chat-thinking {
728
807
  color: var(--muted);
729
808
  font-size: var(--text-md);
@@ -893,9 +972,9 @@
893
972
  .cockpit-grid > [data-tile="queued"],
894
973
  .cockpit-grid > [data-tile="dispatches"],
895
974
  .cockpit-grid > [data-tile="prs"] { grid-column: span 2; }
896
- /* Row 3: Watches | Pinned — two equal columns (3 of 6 each). */
975
+ /* Row 3: Watches | Knowledge — two equal columns (3 of 6 each). */
897
976
  .cockpit-grid > [data-tile="watches"],
898
- .cockpit-grid > [data-tile="pinned"] { grid-column: span 3; }
977
+ .cockpit-grid > [data-tile="knowledge"] { grid-column: span 3; }
899
978
  .cockpit-tile {
900
979
  background: var(--surface2);
901
980
  border: 1px solid var(--border);
@@ -905,37 +984,26 @@
905
984
  flex-direction: column;
906
985
  gap: 4px;
907
986
  transition: border-color 0.2s, background 0.2s;
908
- /* Per-state flash color (rgb triplet, no rgba wrapper) consumed by
909
- `cockpit-flash` so the change-flash ring matches the tile's lit state
910
- (amber for working dispatches, orange for queued, red for failing
911
- PRs, etc.). Each lit-* class below overrides this. Default is blue
912
- for unlit / generic tiles. */
913
- --flash-color: 88, 166, 255;
914
987
  }
915
988
  .cockpit-tile.lit-blue {
916
989
  border-color: rgba(88, 166, 255, 0.6);
917
990
  background: rgba(88, 166, 255, 0.06);
918
- --flash-color: 88, 166, 255;
919
991
  }
920
992
  .cockpit-tile.lit-green {
921
993
  border-color: rgba(63, 185, 80, 0.6);
922
994
  background: rgba(63, 185, 80, 0.06);
923
- --flash-color: 63, 185, 80;
924
995
  }
925
996
  .cockpit-tile.lit-amber {
926
997
  border-color: rgba(210, 153, 34, 0.6);
927
998
  background: rgba(210, 153, 34, 0.06);
928
- --flash-color: 210, 153, 34;
929
999
  }
930
1000
  .cockpit-tile.lit-orange {
931
1001
  border-color: rgba(234, 88, 12, 0.6);
932
1002
  background: rgba(234, 88, 12, 0.08);
933
- --flash-color: 234, 88, 12;
934
1003
  }
935
1004
  .cockpit-tile.lit-red {
936
1005
  border-color: rgba(248, 81, 73, 0.6);
937
1006
  background: rgba(248, 81, 73, 0.08);
938
- --flash-color: 248, 81, 73;
939
1007
  }
940
1008
  .cockpit-label {
941
1009
  font-size: var(--text-base);
@@ -998,32 +1066,22 @@
998
1066
  .cockpit-tile[data-tile="dispatches"].lit-amber {
999
1067
  animation: slim-pulse 1.6s ease-in-out infinite;
1000
1068
  }
1001
- /* Change-flash: brief one-shot ring around a tile whose value just
1002
- changed, used by status.js for the `dispatches` and `prs` tiles. Layered
1003
- on a pseudo-element so it composes cleanly with `slim-pulse` above
1004
- (running both on the tile itself would force a single `animation:` rule
1005
- and clobber one of them). The ring expands then fades — fully visible
1006
- even when the underlying tile is already lit. Respects
1069
+ /* Value-fade: when a cockpit tile's number changes, status.js toggles
1070
+ `.value-changed` on the `.cockpit-value` element to fade the number out
1071
+ then back in around the freshly-swapped text. Applies to every numeric
1072
+ tile (engine/dispatches/prs/watches/pinned). Respects
1007
1073
  prefers-reduced-motion. */
1008
- .cockpit-tile::after {
1009
- content: '';
1010
- position: absolute;
1011
- inset: -1px;
1012
- border-radius: inherit;
1013
- pointer-events: none;
1014
- box-shadow: 0 0 0 0 rgba(var(--flash-color), 0);
1015
- }
1016
- .cockpit-tile.flash-change::after {
1017
- animation: cockpit-flash 0.75s ease-out;
1018
- }
1019
- @keyframes cockpit-flash {
1020
- 0% { box-shadow: 0 0 0 0 rgba(var(--flash-color), 0); }
1021
- 30% { box-shadow: 0 0 0 6px rgba(var(--flash-color), 0.7); }
1022
- 100% { box-shadow: 0 0 0 0 rgba(var(--flash-color), 0); }
1074
+ .cockpit-value.value-changed {
1075
+ animation: cockpit-value-fade 0.45s ease;
1076
+ }
1077
+ @keyframes cockpit-value-fade {
1078
+ 0% { opacity: 1; transform: translateY(0); }
1079
+ 45% { opacity: 0; transform: translateY(-2px); }
1080
+ 100% { opacity: 1; transform: translateY(0); }
1023
1081
  }
1024
1082
  @media (prefers-reduced-motion: reduce) {
1025
1083
  .cockpit-tile[data-tile="dispatches"].lit-amber { animation: none; }
1026
- .cockpit-tile.flash-change::after { animation: none; }
1084
+ .cockpit-value.value-changed { animation: none; }
1027
1085
  }
1028
1086
 
1029
1087
  /* ── Status panel sub-divisions: Team cards over System tiles,
@@ -1042,6 +1042,40 @@
1042
1042
  .modal-close { background: none; border: none; color: var(--muted); font-size: var(--text-2xl); cursor: pointer; padding: var(--space-2) var(--space-4); }
1043
1043
  .modal-close:hover { color: var(--text); }
1044
1044
 
1045
+ /* P-ce1e5e47 — modal stack chrome (back button + depth chip). The back
1046
+ button reuses .modal-copy chrome; modal-back-btn just tightens the
1047
+ leading icon. The depth chip is a small badge that shows n/5 when the
1048
+ modal stack has depth > 1. */
1049
+ .modal-back-btn { color: var(--blue); }
1050
+ .modal-back-btn:hover { color: var(--text); border-color: var(--blue); }
1051
+ .modal-stack-chip {
1052
+ display: inline-flex; align-items: center; padding: var(--space-1) var(--space-4);
1053
+ border-radius: 10px; font-size: var(--text-xs); color: var(--muted);
1054
+ background: var(--surface2); border: 1px solid var(--border);
1055
+ font-variant-numeric: tabular-nums;
1056
+ }
1057
+
1058
+ /* P-ce1e5e47 — artifact-link chips. Re-usable inline pill used by chip
1059
+ callsites (renderArtifactLink in P-e265cd31 routes through this class).
1060
+ Hover lifts via border accent. .deleted shows a struck-out non-clickable
1061
+ hint so dead refs still render in context. */
1062
+ .artifact-chip {
1063
+ display: inline-flex; align-items: center; gap: var(--space-1);
1064
+ padding: var(--space-1) var(--space-4); border-radius: 10px;
1065
+ font-size: var(--text-sm); cursor: pointer;
1066
+ background: var(--surface2); border: 1px solid var(--border); color: var(--text);
1067
+ transition: border-color var(--transition-base), color var(--transition-base);
1068
+ user-select: none;
1069
+ }
1070
+ .artifact-chip:hover { border-color: var(--blue); color: var(--blue); }
1071
+ .artifact-chip-icon { font-size: var(--text-sm); line-height: 1; }
1072
+ .artifact-chip-label { font-size: var(--text-sm); }
1073
+ .artifact-chip.deleted {
1074
+ cursor: not-allowed; text-decoration: line-through;
1075
+ color: var(--muted); opacity: 0.6;
1076
+ }
1077
+ .artifact-chip.deleted:hover { border-color: var(--border); color: var(--muted); }
1078
+
1045
1079
  /* Custom confirm dialog (W-mq5dk1lq) — promise-based replacement for
1046
1080
  native window.confirm(). Scaffold in layout.html (#confirm-dialog),
1047
1081
  behavior in dashboard/js/confirm-dialog.js. Reuses .modal-bg / .modal
@@ -71,7 +71,7 @@ function buildDashboardHtml() {
71
71
  // original single-file source) inside the wrapper that layout.html provides.
72
72
  const SLIM_JS_ORDER = [
73
73
  'helpers', 'settings', 'link-pr', 'chat', 'projects',
74
- 'command-send', 'status', 'members', 'modals-tiles', 'history', 'pinned',
74
+ 'command-send', 'status', 'members', 'modals-tiles', 'history', 'pinned', 'knowledge',
75
75
  ];
76
76
 
77
77
  // Cache for the assembled slim source fragments (layout/css/body/js). Keyed on
package/dashboard.js CHANGED
@@ -6050,6 +6050,21 @@ const server = http.createServer(async (req, res) => {
6050
6050
  } catch (e) { return jsonReply(res, 500, { error: e.message }); }
6051
6051
  }
6052
6052
 
6053
+ // GET /api/prs/<id> — return a single fully-enriched PR record by canonical
6054
+ // id (`<host>:<slug>#<number>`) or by bare number (P-79b47b0c). The in-stack
6055
+ // PR modal (renderPrs.openPrDetail) calls this on demand. Always returns the
6056
+ // record exactly as queries.getPullRequests() produces it (no slimming).
6057
+ async function handlePrsById(req, res, match) {
6058
+ try {
6059
+ const id = decodeURIComponent(match[1] || '').trim();
6060
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
6061
+ const prs = queries.getPullRequests();
6062
+ const found = prs.find(p => p && (p.id === id || String(p.number) === id));
6063
+ if (!found) return jsonReply(res, 404, { error: 'pr not found' });
6064
+ return jsonReply(res, 200, { pr: found });
6065
+ } catch (e) { return jsonReply(res, 500, { error: e.message }); }
6066
+ }
6067
+
6053
6068
  async function handleWorkItemsReopen(req, res) {
6054
6069
  try {
6055
6070
  const body = await readBody(req);
@@ -8413,10 +8428,31 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8413
8428
  // Add frontmatter if not present
8414
8429
  const today = new Date().toISOString().slice(0, 10);
8415
8430
  let kbContent = content;
8431
+ // P-34fa5d79 — Note ↔ KB linkage. Parse the inbox note's own frontmatter
8432
+ // (if any) to recover the WI it cited so we can stamp the new KB file
8433
+ // with source_note + source_wi. This is the inverse-link the KB modal
8434
+ // renders as "From note:" / "From WI:" chips.
8435
+ const _inboxFm = queries._parseNoteFrontmatter(content) || {};
8436
+ const _sourceWi = queries._wiIdFromNoteFrontmatter(_inboxFm);
8416
8437
  if (!content.startsWith('---')) {
8417
8438
  const titleMatch = content.match(/^#+ (.+)$/m);
8418
8439
  const title = titleMatch ? titleMatch[1].trim() : name.replace('.md', '');
8419
- kbContent = `---\ntitle: ${title}\ncategory: ${category}\ndate: ${today}\nsource: inbox/${name}\n---\n\n${content}`;
8440
+ const wiLine = _sourceWi ? `source_wi: ${_sourceWi}\n` : '';
8441
+ kbContent = `---\ntitle: ${title}\ncategory: ${category}\ndate: ${today}\nsource: inbox/${name}\nsource_note: ${name}\n${wiLine}---\n\n${content}`;
8442
+ } else {
8443
+ // Inject source_note / source_wi into the existing frontmatter block.
8444
+ // Replace the inbox frontmatter terminator with the new lines + the
8445
+ // terminator; preserves the rest of the body verbatim.
8446
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?/);
8447
+ if (fmMatch) {
8448
+ const existingFm = fmMatch[1];
8449
+ const additions = [];
8450
+ if (!/^source_note:/m.test(existingFm)) additions.push(`source_note: ${name}`);
8451
+ if (_sourceWi && !/^source_wi:/m.test(existingFm)) additions.push(`source_wi: ${_sourceWi}`);
8452
+ if (additions.length > 0) {
8453
+ kbContent = `---\n${existingFm}\n${additions.join('\n')}\n---\n` + content.slice(fmMatch[0].length);
8454
+ }
8455
+ }
8420
8456
  }
8421
8457
 
8422
8458
  // Write to knowledge base
@@ -12124,6 +12160,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12124
12160
  builder: () => queries.getPullRequests(),
12125
12161
  });
12126
12162
  }},
12163
+ // GET /api/prs/<id> — single fully-enriched PR record by canonical id
12164
+ // (`<host>:<slug>#<number>`, URL-encode the `#`) or by bare number.
12165
+ // Backs renderPrs.openPrDetail() in the in-stack PR modal (P-79b47b0c).
12166
+ // Regex uses (.+) (not [^/?]+) because canonical ids embed `/` in the slug.
12167
+ { method: 'GET', path: /^\/api\/prs\/(.+)$/, template: '/api/prs/<id>', desc: 'Fetch a single fully-enriched PR record by canonical id (`<host>:<slug>#<number>`, URL-encode the `#`) or by bare number. Backs the in-stack PR detail modal.', handler: handlePrsById },
12127
12168
  { method: 'GET', path: '/api/dispatch', desc: 'Live dispatch queue with completion-report summaries (pending/active/completed slices)', handler: (req, res) => {
12128
12169
  return serveFreshJson(req, res, {
12129
12170
  tag: 'dispatch',
@@ -12220,7 +12261,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12220
12261
  const runEntry = runs[s.id];
12221
12262
  const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
12222
12263
  const extra = typeof runEntry === 'object' && runEntry ? { _lastWorkItemId: runEntry.lastWorkItemId, _lastResult: runEntry.lastResult, _lastCompletedAt: runEntry.lastCompletedAt } : {};
12223
- return { ...s, _lastRun, ...extra };
12264
+ // P-c549d07e surface the ring buffer of recent WI ids (newest
12265
+ // first, hard cap 5) so the schedule detail modal can render a
12266
+ // "Recent dispatches" chip row routed through openArtifact('wi').
12267
+ // Empty / missing array on legacy entries → empty list.
12268
+ const _recentWorkItemIds = (typeof runEntry === 'object' && runEntry && Array.isArray(runEntry.recentWorkItemIds))
12269
+ ? runEntry.recentWorkItemIds.slice(0, 5)
12270
+ : [];
12271
+ return { ...s, _lastRun, ...extra, _recentWorkItemIds };
12224
12272
  });
12225
12273
  },
12226
12274
  });
@@ -5144,7 +5144,13 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5144
5144
  const itemId = meta.item.id;
5145
5145
  const schedRunsPath = path.join(ENGINE_DIR, 'schedule-runs.json');
5146
5146
  mutateJsonFileLocked(schedRunsPath, (runs) => {
5147
+ // P-c549d07e — preserve existing fields (esp. recentWorkItemIds ring
5148
+ // populated at dispatch time by engine/scheduler.writeScheduleRunEntry).
5149
+ // The literal-object replacement that lived here previously silently
5150
+ // clobbered the ring on every successful completion.
5151
+ const existing = typeof runs[scheduleId] === 'object' && runs[scheduleId] ? runs[scheduleId] : {};
5147
5152
  runs[scheduleId] = {
5153
+ ...existing,
5148
5154
  lastRun: typeof runs[scheduleId] === 'string' ? runs[scheduleId] : (runs[scheduleId]?.lastRun || ts()),
5149
5155
  lastWorkItemId: itemId,
5150
5156
  lastResult: effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR,
@@ -427,6 +427,12 @@ function executeTaskStage(stage, stageState, run, config, pipeline = {}) {
427
427
  ...(wiBranch ? { branch: wiBranch } : {}),
428
428
  _pipelineRun: run.runId,
429
429
  _pipelineStage: stage.id,
430
+ // P-c549d07e — back-link to the dispatcher (pipeline:<id>) so the
431
+ // WI detail modal can render a clickable chip via openArtifact()
432
+ // → openPipelineDetail. Kept alongside the existing createdBy /
433
+ // _pipelineRun / _pipelineStage fields so we don't disturb any
434
+ // existing readers — meta.spawnedBy is purely additive.
435
+ meta: { spawnedBy: 'pipeline:' + run.pipelineId },
430
436
  });
431
437
  return workItems;
432
438
  });
@@ -561,6 +567,8 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
561
567
  planFile: existingPlanFile, created: ts(), createdBy: 'pipeline:' + run.pipelineId,
562
568
  // W-mp8ho6w500034a58: PLAN_TO_PRD is read-only — no branch needed.
563
569
  _pipelineRun: run.runId, _pipelineStage: stage.id,
570
+ // P-c549d07e — back-link chip (see executeTaskStage above).
571
+ meta: { spawnedBy: 'pipeline:' + run.pipelineId },
564
572
  ...(project ? { project: project.name } : {}),
565
573
  });
566
574
  }
@@ -657,6 +665,8 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
657
665
  // W-mp8ho6w500034a58: PLAN_TO_PRD is read-only — no branch needed.
658
666
  _pipelineRun: run.runId,
659
667
  _pipelineStage: stage.id,
668
+ // P-c549d07e — back-link chip (see executeTaskStage above).
669
+ meta: { spawnedBy: 'pipeline:' + run.pipelineId },
660
670
  ...(project ? { project: project.name } : {}),
661
671
  });
662
672
  }
package/engine/queries.js CHANGED
@@ -496,6 +496,72 @@ function getInboxFiles() {
496
496
  try { return fs.readdirSync(INBOX_DIR).filter(f => f.endsWith('.md')); } catch { return []; }
497
497
  }
498
498
 
499
+ // P-34fa5d79 — Note ↔ Work Item ↔ KB Entry linkage.
500
+ //
501
+ // Parse the YAML frontmatter from a note body and return the parsed fields.
502
+ // Same minimal contract as engine/kb-sweep.js#_parseFrontmatter — a single
503
+ // shared regex would have been nicer but importing kb-sweep from queries
504
+ // would form a circular dep with lifecycle (queries → kb-sweep → … →
505
+ // queries), so we keep a local copy of the four-line parser.
506
+ function _parseNoteFrontmatter(content) {
507
+ const m = String(content || '').match(/^---\n([\s\S]*?)\n---\n?/);
508
+ if (!m) return null;
509
+ const fm = {};
510
+ for (const line of m[1].split('\n')) {
511
+ const lm = line.match(/^([\w-]+):\s*(.*)$/);
512
+ if (lm) fm[lm[1]] = lm[2].trim();
513
+ }
514
+ return fm;
515
+ }
516
+
517
+ // Extract the WI id that a note's frontmatter cites, or null. Accepts the
518
+ // three field-name conventions used across the repo today:
519
+ // - `work_item:` (dallas fix-summary template, the common case)
520
+ // - `wi:` (terser variant, used in a handful of newer notes)
521
+ // - `sourceItem:` (engine-emitted notes such as pr-auto-link-unverified)
522
+ // `id:` itself is the note's own NOTE-… id and is intentionally NOT matched —
523
+ // it would never carry a WI value in any existing convention.
524
+ function _wiIdFromNoteFrontmatter(fm) {
525
+ if (!fm) return null;
526
+ return fm.work_item || fm.wi || fm.sourceItem || null;
527
+ }
528
+
529
+ // Build a wiId → [filename, ...] map by scanning the inbox + archive dirs.
530
+ // Archive entries are prefixed with `archive:` so the dashboard renderer can
531
+ // tell them apart (matches the convention already used by _artifacts.notes).
532
+ // Pure-function helper exposed for testing.
533
+ function _buildNotesByWiMap() {
534
+ const out = Object.create(null);
535
+ const addNote = (wiId, token) => {
536
+ if (!wiId || !token) return;
537
+ if (!out[wiId]) out[wiId] = [];
538
+ if (!out[wiId].includes(token)) out[wiId].push(token);
539
+ };
540
+ for (const f of safeReadDir(INBOX_DIR)) {
541
+ if (!f.endsWith('.md')) continue;
542
+ const fm = _parseNoteFrontmatter(safeRead(path.join(INBOX_DIR, f)));
543
+ const wiId = _wiIdFromNoteFrontmatter(fm);
544
+ if (wiId) addNote(wiId, f);
545
+ }
546
+ for (const f of safeReadDir(ARCHIVE_DIR)) {
547
+ if (!f.endsWith('.md')) continue;
548
+ const fm = _parseNoteFrontmatter(safeRead(path.join(ARCHIVE_DIR, f)));
549
+ const wiId = _wiIdFromNoteFrontmatter(fm);
550
+ if (wiId) addNote(wiId, 'archive:' + f);
551
+ }
552
+ return out;
553
+ }
554
+
555
+ // Public lookup — return the array of note tokens (filenames or
556
+ // `archive:<filename>`) whose frontmatter cites this WI id. Returns [] when
557
+ // nothing matches. Scans inbox + archive each call; for the bulk WI list
558
+ // payload getWorkItems() pre-builds the map once per cache cycle.
559
+ function notesForWorkItem(wiId) {
560
+ if (!wiId) return [];
561
+ const map = _buildNotesByWiMap();
562
+ return map[wiId] || [];
563
+ }
564
+
499
565
  function getInbox() {
500
566
  return safeReadDir(INBOX_DIR)
501
567
  .filter(f => f.endsWith('.md'))
@@ -1745,6 +1811,9 @@ function getWorkItems(config) {
1745
1811
  // Use snapshot — sync access; cold start before any async warm returns [].
1746
1812
  // Best-effort enrichment for work item _artifacts.notes, not correctness-critical.
1747
1813
  const _kbEntries = getKnowledgeBaseEntriesSnapshot();
1814
+ // P-34fa5d79 — pre-build the wiId → notes map so each item's _notes lookup
1815
+ // is O(1) instead of re-scanning inbox+archive per item.
1816
+ const _notesByWi = _buildNotesByWiMap();
1748
1817
  for (const item of allItems) {
1749
1818
  const arts = shared.isPlainObject(item._artifacts) ? { ...item._artifacts } : {};
1750
1819
  const agentId = item.dispatched_to || item.agent;
@@ -1779,6 +1848,13 @@ function getWorkItems(config) {
1779
1848
  else if (item.planFile) arts.plan = item.planFile;
1780
1849
  if (item._pr) arts.pr = item._pr;
1781
1850
  if (Object.keys(arts).length > 0) item._artifacts = arts;
1851
+ // P-34fa5d79 — Note ↔ WI ↔ KB linkage. Slim string[] field of note
1852
+ // filenames whose YAML frontmatter cites this WI. Archive notes are
1853
+ // prefixed with `archive:` so the dashboard chip renderer can pick the
1854
+ // right open path. Field is left undefined (not []) when empty so the
1855
+ // /api/work-items JSON stays compact for the common no-notes case.
1856
+ const mentions = _notesByWi[item.id];
1857
+ if (mentions && mentions.length > 0) item._notes = mentions;
1782
1858
  }
1783
1859
 
1784
1860
  const statusOrder = {
@@ -2897,6 +2973,11 @@ module.exports = {
2897
2973
 
2898
2974
  // Inbox
2899
2975
  getInboxFiles, getInbox,
2976
+ // P-34fa5d79 — Note ↔ Work Item ↔ KB Entry linkage.
2977
+ notesForWorkItem,
2978
+ _parseNoteFrontmatter, // exported for testing
2979
+ _wiIdFromNoteFrontmatter, // exported for testing
2980
+ _buildNotesByWiMap, // exported for testing
2900
2981
 
2901
2982
  // Agents
2902
2983
  getAgentStatus, getAgentCharter, getAgents, getAgentDetail,
@@ -328,6 +328,12 @@ function createScheduledWorkItem(sched) {
328
328
  ...(sched.agentLock === true || sched.hardAgent === true ? { agentLock: true } : {}),
329
329
  project: sched.project || null,
330
330
  _scheduleId: sched.id,
331
+ // P-c549d07e — back-link to the schedule that spawned this WI. The WI
332
+ // detail modal renders this as a clickable chip via renderArtifactLink
333
+ // so the operator can navigate back to the dispatcher. Kept alongside
334
+ // _scheduleId (used by queries / lifecycle / consolidation) so we don't
335
+ // disturb the existing readers — meta.spawnedBy is purely additive.
336
+ meta: { spawnedBy: 'schedule:' + sched.id },
331
337
  };
332
338
  // Walk every string-valued field on the work item so vars embedded in
333
339
  // nested fields (e.g. _harness.rubric added by callers, references[].url,
@@ -339,7 +345,19 @@ function createScheduledWorkItem(sched) {
339
345
 
340
346
  function writeScheduleRunEntry(runs, scheduleId, workItemId, extra) {
341
347
  const existing = typeof runs[scheduleId] === 'object' && runs[scheduleId] ? runs[scheduleId] : {};
342
- runs[scheduleId] = { ...existing, lastRun: ts(), lastWorkItemId: workItemId, ...(extra || {}) };
348
+ // P-c549d07e maintain a recentWorkItemIds ring (newest first, hard cap 5,
349
+ // deduplicated). Surfaces "Recent dispatches" chips in the schedule modal
350
+ // without forcing the dashboard to scan every WI for _scheduleId matches.
351
+ // Empty / falsy workItemId leaves the ring untouched (defensive — callers
352
+ // always pass a real id today, but the engine should never pollute the
353
+ // history with blanks if a future caller slips up).
354
+ let recent = Array.isArray(existing.recentWorkItemIds) ? existing.recentWorkItemIds.slice() : [];
355
+ if (workItemId) {
356
+ recent = recent.filter((id) => id !== workItemId);
357
+ recent.unshift(workItemId);
358
+ if (recent.length > 5) recent = recent.slice(0, 5);
359
+ }
360
+ runs[scheduleId] = { ...existing, lastRun: ts(), lastWorkItemId: workItemId, recentWorkItemIds: recent, ...(extra || {}) };
343
361
  return runs[scheduleId];
344
362
  }
345
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2195",
3
+ "version": "0.1.2196",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"