@grifhinz/logics-manager 2.3.3 → 2.4.0

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.
@@ -1,6 +1,5 @@
1
1
  (() => {
2
2
  const stateKey = "logics.localViewer.state";
3
- const autoRefreshIntervalMs = 60 * 1000;
4
3
  const meta = () => document.getElementById("viewer-meta");
5
4
  const documentPanel = () => document.getElementById("viewer-document");
6
5
  const documentTitle = () => document.getElementById("viewer-document-title");
@@ -10,6 +9,11 @@
10
9
  const updateCopy = () => document.getElementById("viewer-update-copy");
11
10
  const updateCommand = () => document.getElementById("viewer-update-command");
12
11
  const filterCount = () => document.getElementById("viewer-filter-count");
12
+ const repoPill = () => document.getElementById("viewer-repo-pill");
13
+ const autoRefreshControl = () => document.getElementById("viewer-auto-refresh");
14
+ const activityClearControl = () => document.getElementById("activity-clear");
15
+ const activityStorageLimit = 80;
16
+ const defaultAutoRefreshIntervalMs = 60 * 1000;
13
17
  const defaultFilterState = {
14
18
  focus: "active",
15
19
  type: "all",
@@ -19,6 +23,12 @@
19
23
  };
20
24
  let viewerFilterState = { ...defaultFilterState };
21
25
  let latestItems = [];
26
+ let latestRepoRoot = "";
27
+ let latestMetaText = "Read-only local viewer";
28
+ let autoRefreshIntervalMs = defaultAutoRefreshIntervalMs;
29
+ let nextAutoRefreshAt = 0;
30
+ let autoRefreshEnabled = true;
31
+ let autoRefreshTimeoutId = 0;
22
32
  let applyingLocalChrome = false;
23
33
  let autoRefreshStarted = false;
24
34
  let itemsLoadInFlight = false;
@@ -62,6 +72,52 @@
62
72
  writeStoredState({ ...nextState, viewerFilterState: { ...viewerFilterState } });
63
73
  }
64
74
 
75
+ function updateStoredActivity(nextItems) {
76
+ const storedState = readStoredState();
77
+ const baseState = storedState && typeof storedState === "object" ? storedState : {};
78
+ const previousSnapshot = baseState.activitySnapshot && typeof baseState.activitySnapshot === "object"
79
+ ? baseState.activitySnapshot
80
+ : {};
81
+ const history = Array.isArray(baseState.activityHistory) ? [...baseState.activityHistory] : [];
82
+ const nextSnapshot = {};
83
+ const now = new Date().toISOString();
84
+ const decorated = nextItems.map((item) => {
85
+ const relPath = String(item.relPath || item.path || item.id || "");
86
+ const status = String(item?.indicators?.Status || "").trim();
87
+ if (relPath) {
88
+ nextSnapshot[relPath] = { status, updatedAt: item.updatedAt || "" };
89
+ }
90
+ const previous = relPath ? previousSnapshot[relPath] : null;
91
+ const previousStatus = String(previous?.status || "").trim();
92
+ const statusChanged = Boolean(previousStatus && status && previousStatus !== status);
93
+ if (relPath && (statusChanged || !previous)) {
94
+ history.unshift({ path: relPath, at: now, status, previousStatus, type: statusChanged ? "status-change" : "updated" });
95
+ }
96
+ return statusChanged ? { ...item, activityType: "status-change" } : item;
97
+ });
98
+ writeStoredState({
99
+ ...baseState,
100
+ viewerFilterState: { ...viewerFilterState },
101
+ activitySnapshot: nextSnapshot,
102
+ activityHistory: history.slice(0, activityStorageLimit)
103
+ });
104
+ return decorated;
105
+ }
106
+
107
+ function clearActivityHistory() {
108
+ const storedState = readStoredState();
109
+ const nextState = storedState && typeof storedState === "object" ? { ...storedState } : {};
110
+ delete nextState.activitySnapshot;
111
+ delete nextState.activityHistory;
112
+ writeStoredState(nextState);
113
+ latestItems = latestItems.map((item) => {
114
+ const clone = { ...item };
115
+ delete clone.activityType;
116
+ return clone;
117
+ });
118
+ setMeta("Local activity history cleared.");
119
+ }
120
+
65
121
  function markdownApi() {
66
122
  if (typeof window.createCdxLogicsMarkdownApi === "function") {
67
123
  return window.createCdxLogicsMarkdownApi();
@@ -90,12 +146,45 @@
90
146
  }
91
147
 
92
148
  function setMeta(text) {
149
+ latestMetaText = text;
150
+ renderMeta();
151
+ }
152
+
153
+ function renderMeta() {
93
154
  const node = meta();
94
155
  if (node) {
95
- node.textContent = text;
156
+ const parts = [latestMetaText];
157
+ if (autoRefreshEnabled && nextAutoRefreshAt > 0) {
158
+ const seconds = Math.max(0, Math.ceil((nextAutoRefreshAt - Date.now()) / 1000));
159
+ parts.push(`next auto refresh in ${seconds}s`);
160
+ }
161
+ node.textContent = parts.join(" · ");
96
162
  }
97
163
  }
98
164
 
165
+ function scheduleNextAutoRefresh() {
166
+ if (autoRefreshTimeoutId) {
167
+ window.clearTimeout(autoRefreshTimeoutId);
168
+ autoRefreshTimeoutId = 0;
169
+ }
170
+ nextAutoRefreshAt = autoRefreshEnabled ? Date.now() + autoRefreshIntervalMs : 0;
171
+ if (autoRefreshEnabled) {
172
+ autoRefreshTimeoutId = window.setTimeout(autoRefreshItems, autoRefreshIntervalMs);
173
+ }
174
+ renderMeta();
175
+ }
176
+
177
+ function updateRepositoryIdentity(payload) {
178
+ latestRepoRoot = String(payload.root || latestRepoRoot || "");
179
+ const pill = repoPill();
180
+ if (!pill) {
181
+ return;
182
+ }
183
+ const repoName = String(payload.repoName || latestRepoRoot.split(/[\\/]/).filter(Boolean).pop() || "repository");
184
+ pill.textContent = repoName;
185
+ pill.title = latestRepoRoot || repoName;
186
+ }
187
+
99
188
  function findItemByPath(relPath) {
100
189
  const normalized = String(relPath || "").replace(/\\/g, "/").replace(/^\//, "");
101
190
  return latestItems.find((entry) => entry.relPath === normalized || entry.path === normalized) || null;
@@ -333,13 +422,20 @@
333
422
  }
334
423
 
335
424
  function postToApp(payload, options = {}) {
336
- latestItems = Array.isArray(payload.items) ? payload.items : [];
337
- const nextPayload = options.silent ? payload : applyFocusRequest(payload);
425
+ latestItems = updateStoredActivity(Array.isArray(payload.items) ? payload.items : []);
426
+ const intervalSeconds = Number(payload.autoRefreshIntervalSeconds);
427
+ autoRefreshIntervalMs = Number.isFinite(intervalSeconds) && intervalSeconds > 0
428
+ ? intervalSeconds * 1000
429
+ : defaultAutoRefreshIntervalMs;
430
+ updateRepositoryIdentity(payload);
431
+ const payloadWithActivity = { ...payload, items: latestItems };
432
+ const nextPayload = options.silent ? payloadWithActivity : applyFocusRequest(payloadWithActivity);
338
433
  window.dispatchEvent(new MessageEvent("message", { data: { type: "data", payload: nextPayload } }));
339
434
  const rootName = payload.root ? payload.root.split(/[\\/]/).filter(Boolean).pop() : "repository";
340
435
  if (!options.silent) {
341
436
  setMeta(`${rootName} · ${payload.items.length} docs · refreshed ${new Date().toLocaleTimeString()}`);
342
437
  }
438
+ scheduleNextAutoRefresh();
343
439
  renderUpdateNotice(payload.updateInfo);
344
440
  updateFilterSummary();
345
441
  applyLocalViewerChrome();
@@ -387,6 +483,9 @@
387
483
  }
388
484
 
389
485
  function autoRefreshItems() {
486
+ if (!autoRefreshEnabled) {
487
+ return;
488
+ }
390
489
  if (document.hidden) {
391
490
  refreshAfterVisible = true;
392
491
  return;
@@ -399,7 +498,9 @@
399
498
  return;
400
499
  }
401
500
  autoRefreshStarted = true;
402
- window.setInterval(autoRefreshItems, autoRefreshIntervalMs);
501
+ window.setInterval(() => {
502
+ renderMeta();
503
+ }, 1000);
403
504
  document.addEventListener("visibilitychange", () => {
404
505
  if (!document.hidden && refreshAfterVisible) {
405
506
  refreshAfterVisible = false;
@@ -408,6 +509,15 @@
408
509
  });
409
510
  }
410
511
 
512
+ function setAutoRefreshEnabled(enabled) {
513
+ autoRefreshEnabled = Boolean(enabled);
514
+ const control = autoRefreshControl();
515
+ if (control instanceof HTMLInputElement) {
516
+ control.checked = autoRefreshEnabled;
517
+ }
518
+ scheduleNextAutoRefresh();
519
+ }
520
+
411
521
  function statusValue(item) {
412
522
  return String(item?.indicators?.Status || "").toLowerCase();
413
523
  }
@@ -435,6 +545,37 @@
435
545
  return timestamp > 0 && timestamp < Date.now() - 30 * 24 * 60 * 60 * 1000 && !isClosed(item);
436
546
  }
437
547
 
548
+ function isRecent(item, days = 7) {
549
+ return updatedWithin(item, days);
550
+ }
551
+
552
+ function hasMissingOrAmbiguousStatus(item) {
553
+ const rawStatus = String(item?.indicators?.Status || "").trim();
554
+ if (!rawStatus) {
555
+ return true;
556
+ }
557
+ const normalized = rawStatus.toLowerCase();
558
+ return !["draft", "ready", "in progress", "blocked", "done", "archived", "obsolete"].includes(normalized);
559
+ }
560
+
561
+ function isSafeLogicsDocPath(value) {
562
+ const path = String(value || "").replace(/\\/g, "/").replace(/^\.?\//, "").trim();
563
+ if (!path || path.startsWith("/") || path.startsWith("~") || /^[A-Za-z]:/.test(path)) {
564
+ return false;
565
+ }
566
+ if (path.split("/").includes("..") || !path.endsWith(".md")) {
567
+ return false;
568
+ }
569
+ return [
570
+ "logics/request/",
571
+ "logics/backlog/",
572
+ "logics/tasks/",
573
+ "logics/product/",
574
+ "logics/architecture/",
575
+ "logics/specs/"
576
+ ].some((prefix) => path.startsWith(prefix));
577
+ }
578
+
438
579
  function matchesViewerFilter(item) {
439
580
  if (!item) {
440
581
  return false;
@@ -562,58 +703,233 @@
562
703
  count.textContent = `${visibleCount} of ${latestItems.length} docs shown${suffix}`;
563
704
  }
564
705
 
565
- function buildCorpusInsights() {
566
- const countsByStage = latestItems.reduce((acc, item) => {
567
- acc[item.stage] = (acc[item.stage] || 0) + 1;
706
+ function countBy(items, selector) {
707
+ return items.reduce((acc, item) => {
708
+ const key = selector(item) || "unknown";
709
+ acc[key] = (acc[key] || 0) + 1;
568
710
  return acc;
569
711
  }, {});
570
- const active = latestItems.filter((item) => !isClosed(item)).length;
571
- const blocked = latestItems.filter((item) => statusValue(item).includes("blocked")).length;
572
- const unlinked = latestItems.filter((item) => (item.references || []).length === 0 && (item.usedBy || []).length === 0).length;
573
- const incompleteChains = latestItems.filter((item) => ["request", "backlog"].includes(item.stage) && !item.isPromoted && !isClosed(item)).length;
574
- const cards = [
575
- ["Docs", latestItems.length],
576
- ["Active", active],
577
- ["Blocked", blocked],
578
- ["Unlinked", unlinked]
579
- ].map(([label, value]) => `
712
+ }
713
+
714
+ function renderMetricCards(entries) {
715
+ return entries.map(([label, value]) => `
580
716
  <div class="viewer-insights__card">
581
717
  <div class="viewer-insights__label">${escapeHtml(label)}</div>
582
718
  <div class="viewer-insights__value">${escapeHtml(value)}</div>
583
719
  </div>
584
720
  `).join("");
721
+ }
722
+
723
+ function renderInsightRows(items, emptyText = "No signals") {
724
+ if (!items.length) {
725
+ return `<li class="viewer-insights__item">${escapeHtml(emptyText)}</li>`;
726
+ }
727
+ return items.map(([label, value]) => `
728
+ <li class="viewer-insights__item"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></li>
729
+ `).join("");
730
+ }
731
+
732
+ function renderDocRows(items, emptyText = "None", limit = 6) {
733
+ if (!items.length) {
734
+ return `<li class="viewer-insights__row viewer-insights__row--empty">${escapeHtml(emptyText)}</li>`;
735
+ }
736
+ const rows = items.map((item, index) => {
737
+ const path = item.relPath || item.path || "";
738
+ const control = path && isSafeLogicsDocPath(path)
739
+ ? `<button class="viewer-insights__doc" type="button" data-viewer-doc-path="${escapeHtml(path)}">${escapeHtml(item.id || path)}</button>`
740
+ : `<span class="viewer-insights__doc">${escapeHtml(item.id || path || item.title)}</span>`;
741
+ return `
742
+ <li class="viewer-insights__row" ${index >= limit ? "hidden data-viewer-hidden-row" : ""}>
743
+ ${control}
744
+ <span>${escapeHtml(item.indicators?.Status || item.stage || "No status")}</span>
745
+ </li>
746
+ `;
747
+ });
748
+ const hiddenCount = Math.max(0, items.length - limit);
749
+ if (hiddenCount > 0) {
750
+ rows.push(`<li class="viewer-insights__row"><button class="viewer-insights__reveal" type="button" data-viewer-reveal>Show ${hiddenCount} more</button></li>`);
751
+ }
752
+ return rows.join("");
753
+ }
754
+
755
+ function renderPathRows(paths, emptyText = "None", limit = 6) {
756
+ if (!paths.length) {
757
+ return `<li class="viewer-insights__row viewer-insights__row--empty">${escapeHtml(emptyText)}</li>`;
758
+ }
759
+ const rows = paths.map((path, index) => {
760
+ const control = isSafeLogicsDocPath(path)
761
+ ? `<button class="viewer-insights__doc" type="button" data-viewer-doc-path="${escapeHtml(path)}">${escapeHtml(path)}</button>`
762
+ : `<span class="viewer-insights__doc">${escapeHtml(path)}</span>`;
763
+ return `<li class="viewer-insights__row" ${index >= limit ? "hidden data-viewer-hidden-row" : ""}>${control}</li>`;
764
+ });
765
+ const hiddenCount = Math.max(0, paths.length - limit);
766
+ if (hiddenCount > 0) {
767
+ rows.push(`<li class="viewer-insights__row"><button class="viewer-insights__reveal" type="button" data-viewer-reveal>Show ${hiddenCount} more</button></li>`);
768
+ }
769
+ return rows.join("");
770
+ }
771
+
772
+ function renderActionRows(actions) {
773
+ return actions.map((action) => {
774
+ if (action.filter) {
775
+ return `
776
+ <li class="viewer-insights__row">
777
+ <button class="viewer-insights__action" type="button" data-viewer-filter-group="${escapeHtml(action.filter.group)}" data-viewer-filter-value="${escapeHtml(action.filter.value)}">${escapeHtml(action.label)}</button>
778
+ <strong>${escapeHtml(action.value)}</strong>
779
+ </li>
780
+ `;
781
+ }
782
+ if (action.health) {
783
+ return `
784
+ <li class="viewer-insights__row">
785
+ <button class="viewer-insights__action" type="button" data-viewer-open-health>${escapeHtml(action.label)}</button>
786
+ <strong>${escapeHtml(action.value)}</strong>
787
+ </li>
788
+ `;
789
+ }
790
+ if (action.path && isSafeLogicsDocPath(action.path)) {
791
+ return `
792
+ <li class="viewer-insights__row">
793
+ <button class="viewer-insights__action" type="button" data-viewer-doc-path="${escapeHtml(action.path)}">${escapeHtml(action.label)}</button>
794
+ <strong>${escapeHtml(action.value)}</strong>
795
+ </li>
796
+ `;
797
+ }
798
+ return `<li class="viewer-insights__row"><span>${escapeHtml(action.label)}</span><strong>${escapeHtml(action.value)}</strong></li>`;
799
+ }).join("");
800
+ }
801
+
802
+ function itemLabel(item) {
803
+ return `${item.id || item.relPath || "doc"} - ${item.indicators?.Status || "No status"}`;
804
+ }
805
+
806
+ function buildCorpusInsights(lintData = null, auditData = null) {
807
+ const docs = latestItems;
808
+ const itemPaths = new Set(docs.map((item) => item.relPath).filter(Boolean));
809
+ const countsByStage = countBy(docs, (item) => item.stage);
810
+ const closed = docs.filter(isClosed);
811
+ const open = docs.filter((item) => !isClosed(item));
812
+ const blocked = docs.filter((item) => statusValue(item).includes("blocked"));
813
+ const missingStatus = docs.filter(hasMissingOrAmbiguousStatus);
814
+ const recentlyModified = docs.filter((item) => isRecent(item, 7));
815
+ const incompleteChains = docs.filter((item) => ["request", "backlog"].includes(item.stage) && !item.isPromoted && !isClosed(item));
816
+ const unlinked = docs.filter((item) => (item.references || []).length === 0 && (item.usedBy || []).length === 0);
817
+ const brokenRefs = [];
818
+ const relationshipCounts = {};
819
+ docs.forEach((item) => {
820
+ relationshipCounts[item.stage] = (relationshipCounts[item.stage] || 0) + (item.references || []).length + (item.usedBy || []).length;
821
+ (item.references || []).forEach((ref) => {
822
+ if (ref.path && !itemPaths.has(ref.path)) {
823
+ brokenRefs.push(`${item.id} -> ${ref.path}`);
824
+ }
825
+ });
826
+ });
827
+ const mostReferenced = [...docs]
828
+ .sort((left, right) => (right.usedBy || []).length - (left.usedBy || []).length)
829
+ .filter((item) => (item.usedBy || []).length > 0)
830
+ .slice(0, 8);
831
+ const recentRows = [...docs]
832
+ .sort((left, right) => (Date.parse(right.updatedAt || "") || 0) - (Date.parse(left.updatedAt || "") || 0))
833
+ .slice(0, 8);
834
+ const staleActive = open.filter(isStale).slice(0, 8);
835
+ const qualityFindings = lintData && auditData ? collectHealthFindings(lintData, auditData) : [];
836
+ const qualityBySource = countBy(qualityFindings, (finding) => finding.source || finding.code || "finding");
837
+ const qualityByDocType = countBy(qualityFindings, (finding) => {
838
+ const path = String(finding.path || "");
839
+ const matched = docs.find((item) => item.relPath === path);
840
+ return matched?.stage || (path ? "unknown document" : "repository");
841
+ });
842
+ const concentratedIssues = Object.entries(countBy(qualityFindings, (finding) => finding.path || "repository"))
843
+ .sort((left, right) => Number(right[1]) - Number(left[1]))
844
+ .slice(0, 8);
845
+ const actions = [];
846
+ if (blocked.length) {
847
+ actions.push({ label: "Review blocked workflow docs", value: blocked.length, filter: { group: "focus", value: "blocked" } });
848
+ }
849
+ if (incompleteChains.length) {
850
+ actions.push({ label: "Promote or close incomplete workflow chains", value: incompleteChains.length, filter: { group: "focus", value: "needs-promotion" } });
851
+ }
852
+ if (brokenRefs.length) {
853
+ actions.push({ label: "Repair broken references", value: brokenRefs.length, health: true });
854
+ }
855
+ if (qualityFindings.length) {
856
+ actions.push({ label: "Open validation health", value: qualityFindings.length, health: true });
857
+ }
858
+ if (missingStatus.length) {
859
+ actions.push({ label: "Normalize missing or ambiguous statuses", value: missingStatus.length, path: missingStatus[0]?.relPath || "" });
860
+ }
861
+ if (!actions.length) {
862
+ actions.push({ label: "No immediate operator action detected", value: "OK" });
863
+ }
864
+
585
865
  const stageRows = Object.entries(countsByStage)
586
866
  .sort((left, right) => String(left[0]).localeCompare(String(right[0])))
587
- .map(([stage, count]) => `<li class="viewer-insights__item"><span>${escapeHtml(stage)}</span><strong>${escapeHtml(count)}</strong></li>`)
588
- .join("");
589
- const recentRows = [...latestItems]
590
- .sort((left, right) => (Date.parse(right.updatedAt || "") || 0) - (Date.parse(left.updatedAt || "") || 0))
591
- .slice(0, 8)
592
- .map((item) => `<li class="viewer-insights__item"><span>${escapeHtml(item.id)}</span><strong>${escapeHtml(item.indicators?.Status || "No status")}</strong></li>`)
593
- .join("");
867
+ .map(([stage, count]) => [stage, count]);
594
868
  return `
595
869
  <div class="viewer-insights">
596
- <div class="viewer-insights__summary">${cards}</div>
597
- <div class="viewer-insights__grid">
598
- <div class="viewer-insights__card">
599
- <div class="viewer-insights__label">Incomplete chains</div>
600
- <div class="viewer-insights__value">${escapeHtml(incompleteChains)}</div>
601
- </div>
602
- </div>
870
+ <div class="viewer-insights__summary">${renderMetricCards([
871
+ ["Docs", docs.length],
872
+ ["Open", open.length],
873
+ ["Closed", closed.length],
874
+ ["Blocked", blocked.length],
875
+ ["Missing status", missingStatus.length],
876
+ ["Modified 7d", recentlyModified.length]
877
+ ])}</div>
878
+ <section class="viewer-insights__section">
879
+ <h2>Overview</h2>
880
+ <ul class="viewer-insights__list">${renderInsightRows(stageRows, "No docs loaded")}</ul>
881
+ </section>
882
+ <section class="viewer-insights__section">
883
+ <h2>Flow health</h2>
884
+ <ul class="viewer-insights__list">${renderInsightRows([
885
+ ["Incomplete workflow chains", incompleteChains.length],
886
+ ["Promotion gaps", incompleteChains.filter((item) => item.stage === "request" || item.stage === "backlog").length],
887
+ ["Orphan or unlinked docs", unlinked.length],
888
+ ["Broken reference risks", brokenRefs.length]
889
+ ])}</ul>
890
+ <ul class="viewer-insights__rows">${renderDocRows(incompleteChains, "No incomplete chains")}</ul>
891
+ </section>
892
+ <section class="viewer-insights__section">
893
+ <h2>Activity</h2>
894
+ <ul class="viewer-insights__list">${renderInsightRows([
895
+ ["Latest changes", recentRows.map(itemLabel).join(", ") || "None"],
896
+ ["Stale active docs", staleActive.map(itemLabel).join(", ") || "None"],
897
+ ["Recently active docs", recentlyModified.slice(0, 8).map(itemLabel).join(", ") || "None"],
898
+ ["Activity classification", `recent ${recentlyModified.length}, stale ${open.filter(isStale).length}, quiet ${Math.max(0, open.length - recentlyModified.length)}`]
899
+ ])}</ul>
900
+ <ul class="viewer-insights__rows">${renderDocRows(recentRows, "No recent documents")}</ul>
901
+ </section>
902
+ <section class="viewer-insights__section">
903
+ <h2>Traceability</h2>
904
+ <ul class="viewer-insights__list">${renderInsightRows([
905
+ ["Most referenced docs", mostReferenced.map((item) => `${item.id} (${(item.usedBy || []).length})`).join(", ") || "None"],
906
+ ["Unlinked docs", unlinked.slice(0, 8).map((item) => item.id).join(", ") || "None"],
907
+ ["Broken references", brokenRefs.slice(0, 8).join(", ") || "None"],
908
+ ["Relationships by type", Object.entries(relationshipCounts).map(([stage, count]) => `${stage} ${count}`).join(", ") || "None"]
909
+ ])}</ul>
910
+ <ul class="viewer-insights__rows">${renderDocRows(unlinked, "No unlinked documents")}${renderPathRows(brokenRefs, "No broken references")}</ul>
911
+ </section>
603
912
  <section class="viewer-insights__section">
604
- <h2>Corpus families</h2>
605
- <ul class="viewer-insights__list">${stageRows || '<li class="viewer-insights__item">No docs loaded</li>'}</ul>
913
+ <h2>Quality signals</h2>
914
+ <ul class="viewer-insights__list">${renderInsightRows([
915
+ ["Lint/audit categories", Object.entries(qualityBySource).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded"],
916
+ ["Findings by document type", Object.entries(qualityByDocType).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded"],
917
+ ["Concentrated issues", concentratedIssues.map(([key, count]) => `${key} ${count}`).join(", ") || "None"]
918
+ ])}</ul>
919
+ <ul class="viewer-insights__rows">${renderPathRows(concentratedIssues.map(([key, count]) => `${key} (${count})`), "No concentrated issues")}</ul>
606
920
  </section>
607
921
  <section class="viewer-insights__section">
608
- <h2>Recent activity</h2>
609
- <ul class="viewer-insights__list">${recentRows || '<li class="viewer-insights__item">No recent docs</li>'}</ul>
922
+ <h2>Operator actions</h2>
923
+ <ul class="viewer-insights__rows">${renderActionRows(actions)}</ul>
610
924
  </section>
611
925
  </div>
612
926
  `;
613
927
  }
614
928
 
615
- function showCorpusInsights() {
616
- setDocument("Corpus insights", buildCorpusInsights());
929
+ async function showCorpusInsights() {
930
+ const [lintResponse, auditResponse] = await Promise.all([fetch("/api/lint"), fetch("/api/audit")]);
931
+ const [lintData, auditData] = await Promise.all([lintResponse.json(), auditResponse.json()]);
932
+ setDocument("Corpus insights", buildCorpusInsights(lintData, auditData));
617
933
  setMeta("Corpus insights loaded.");
618
934
  }
619
935
 
@@ -711,9 +1027,9 @@
711
1027
  const list = findings.length
712
1028
  ? findings.slice(0, 50).map((finding) => {
713
1029
  const path = finding.path || "";
714
- const pathControl = path
1030
+ const pathControl = path && isSafeLogicsDocPath(path)
715
1031
  ? `<button class="viewer-health__path" type="button" data-viewer-doc-path="${escapeHtml(path)}">${escapeHtml(path)}</button>`
716
- : '<span class="viewer-health__meta">Repository-level finding</span>';
1032
+ : `<span class="viewer-health__meta">${escapeHtml(path ? `Repository-level or unsafe path: ${path}` : "Repository-level finding")}</span>`;
717
1033
  const severity = finding.severity || finding.code || finding.source || "finding";
718
1034
  return `
719
1035
  <li class="viewer-health__issue">
@@ -744,6 +1060,76 @@
744
1060
  setMeta("Health loaded.");
745
1061
  }
746
1062
 
1063
+ function renderGitStatus(payload) {
1064
+ if (!payload || payload.state !== "ok") {
1065
+ return `
1066
+ <div class="viewer-git">
1067
+ <div class="viewer-git__state">${escapeHtml(payload?.message || "Git status is unavailable.")}</div>
1068
+ </div>
1069
+ `;
1070
+ }
1071
+ const counts = payload.counts || {};
1072
+ const summary = [
1073
+ ["Branch", payload.branch || "HEAD"],
1074
+ ["Tracking", payload.tracking || "None"],
1075
+ ["Ahead", payload.ahead || 0],
1076
+ ["Behind", payload.behind || 0],
1077
+ ["State", payload.clean ? "Clean" : "Dirty"],
1078
+ ["Staged", counts.staged || 0],
1079
+ ["Modified/deleted", Number(counts.modified || 0) + Number(counts.deleted || 0)],
1080
+ ["Untracked", counts.untracked || 0]
1081
+ ];
1082
+ const cards = renderMetricCards(summary);
1083
+ const groupLabels = { staged: "Staged", modified: "Modified", deleted: "Deleted", renamed: "Renamed", untracked: "Untracked" };
1084
+ const groups = Object.entries(groupLabels).map(([key, label]) => {
1085
+ const entries = Array.isArray(payload.groups?.[key]) ? payload.groups[key] : [];
1086
+ if (!entries.length) {
1087
+ return "";
1088
+ }
1089
+ return `
1090
+ <section class="viewer-git__section">
1091
+ <h2>${escapeHtml(label)}</h2>
1092
+ <ul class="viewer-git__files">${entries.map((entry) => `
1093
+ <li><code>${escapeHtml(entry.from ? `${entry.from} -> ${entry.path}` : entry.path)}</code></li>
1094
+ `).join("")}</ul>
1095
+ </section>
1096
+ `;
1097
+ }).join("");
1098
+ const clean = payload.clean ? '<p class="viewer-git__state">Working tree clean.</p>' : "";
1099
+ return `
1100
+ <div class="viewer-git">
1101
+ <div class="viewer-git__summary">${cards}</div>
1102
+ ${payload.latestCommit ? `<p class="viewer-git__commit">Latest commit: <code>${escapeHtml(payload.latestCommit)}</code></p>` : ""}
1103
+ ${clean}
1104
+ ${groups}
1105
+ </div>
1106
+ `;
1107
+ }
1108
+
1109
+ async function showGitStatus() {
1110
+ setMeta("Checking Git status...");
1111
+ const response = await fetch("/api/git-status");
1112
+ let data = {};
1113
+ try {
1114
+ data = await response.json();
1115
+ } catch {
1116
+ data = {};
1117
+ }
1118
+ if (response.status === 404) {
1119
+ setDocument("Git status", renderGitStatus({
1120
+ state: "unavailable",
1121
+ message: "Git status endpoint unavailable. Restart the local viewer so it loads the current logics-manager backend."
1122
+ }));
1123
+ setMeta("Restart the local viewer to enable Git status.");
1124
+ return;
1125
+ }
1126
+ if (!response.ok || !data.ok) {
1127
+ throw new Error(data.error || "Unable to load Git status.");
1128
+ }
1129
+ setDocument("Git status", renderGitStatus(data.payload));
1130
+ setMeta("Git status loaded.");
1131
+ }
1132
+
747
1133
  window.acquireVsCodeApi = function acquireVsCodeApi() {
748
1134
  return {
749
1135
  postMessage(message) {
@@ -786,11 +1172,18 @@
786
1172
  setControlValue("show-companion-docs", true, "change");
787
1173
  setControlValue("hide-empty-columns", true, "change");
788
1174
  applyLocalViewerChrome();
789
- [document.getElementById("viewer-insights"), document.getElementById("header-logics-insights")].forEach((button) => {
1175
+ [document.getElementById("viewer-insights")].forEach((button) => {
790
1176
  button?.addEventListener("click", () => {
791
- showCorpusInsights();
1177
+ showCorpusInsights().catch((error) => setMeta(error.message));
792
1178
  });
793
1179
  });
1180
+ const autoControl = autoRefreshControl();
1181
+ if (autoControl instanceof HTMLInputElement) {
1182
+ autoControl.addEventListener("change", () => {
1183
+ setAutoRefreshEnabled(autoControl.checked);
1184
+ });
1185
+ setAutoRefreshEnabled(autoControl.checked);
1186
+ }
794
1187
  document.querySelectorAll('[data-action="refresh"]').forEach((element) => {
795
1188
  if (!(element instanceof HTMLElement)) {
796
1189
  return;
@@ -802,6 +1195,12 @@
802
1195
  document.getElementById("viewer-health")?.addEventListener("click", () => {
803
1196
  showHealth().catch((error) => setMeta(error.message));
804
1197
  });
1198
+ document.getElementById("viewer-git")?.addEventListener("click", () => {
1199
+ showGitStatus().catch((error) => setMeta(error.message));
1200
+ });
1201
+ activityClearControl()?.addEventListener("click", () => {
1202
+ clearActivityHistory();
1203
+ });
805
1204
  document.querySelectorAll("[data-viewer-filter-group]").forEach((element) => {
806
1205
  if (element instanceof HTMLSelectElement) {
807
1206
  element.addEventListener("change", () => {
@@ -828,10 +1227,30 @@
828
1227
  document.addEventListener("click", (event) => {
829
1228
  window.setTimeout(() => applyLocalViewerChrome(), 0);
830
1229
  const target = event.target instanceof Element ? event.target.closest("[data-viewer-doc-path]") : null;
831
- if (!(target instanceof HTMLElement)) {
1230
+ const healthTarget = event.target instanceof Element ? event.target.closest("[data-viewer-open-health]") : null;
1231
+ const filterTarget = event.target instanceof Element ? event.target.closest("[data-viewer-filter-group][data-viewer-filter-value]") : null;
1232
+ const revealTarget = event.target instanceof Element ? event.target.closest("[data-viewer-reveal]") : null;
1233
+ if (revealTarget instanceof HTMLElement) {
1234
+ const list = revealTarget.closest("ul");
1235
+ list?.querySelectorAll("[data-viewer-hidden-row]").forEach((row) => {
1236
+ if (row instanceof HTMLElement) {
1237
+ row.hidden = false;
1238
+ row.removeAttribute("data-viewer-hidden-row");
1239
+ }
1240
+ });
1241
+ revealTarget.closest("li")?.remove();
1242
+ return;
1243
+ }
1244
+ if (healthTarget instanceof HTMLElement) {
1245
+ showHealth().catch((error) => setMeta(error.message));
1246
+ return;
1247
+ }
1248
+ if (filterTarget instanceof HTMLElement) {
1249
+ applyViewerFilter(filterTarget.getAttribute("data-viewer-filter-group") || "", filterTarget.getAttribute("data-viewer-filter-value") || "");
1250
+ setMeta("Insight filter applied. Clear filters restores the normal viewer view.");
832
1251
  return;
833
1252
  }
834
- const path = target.getAttribute("data-viewer-doc-path");
1253
+ const path = target instanceof HTMLElement ? target.getAttribute("data-viewer-doc-path") : "";
835
1254
  if (path) {
836
1255
  showDocumentByPath(path).catch((error) => setMeta(error.message));
837
1256
  }