total-recall-brain 3.40.7 → 3.41.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.
Files changed (170) hide show
  1. package/bin/total-recall-memory.mjs +0 -0
  2. package/bin/total-recall.mjs +9 -40
  3. package/docs/reference/cli-reference.md +65 -1
  4. package/frontend/dist/assets/{index-AGUes-VG.css → index-BG9NkVUy.css} +1 -1
  5. package/frontend/dist/assets/{index-Bt5iVPZj.js → index-BtuiZqwP.js} +20 -7
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/src/components/plugins/PluginPanel.tsx +31 -5
  8. package/frontend/src/components/plugins/card/CardEngine.tsx +15 -23
  9. package/frontend/src/components/plugins/card/CardScenes.tsx +52 -23
  10. package/frontend/src/components/plugins/card/card.css +2 -5
  11. package/frontend/src/components/plugins/card/contract.ts +13 -19
  12. package/frontend/src/components/plugins/card/format.ts +8 -1
  13. package/frontend/src/components/plugins/card/widgets.tsx +4 -35
  14. package/frontend/src/components/plugins/plugin-frame.ts +30 -3
  15. package/frontend/src/pages/PluginsPage.tsx +3 -2
  16. package/package.json +1 -1
  17. package/scaffold/.agent/skills/plugin/SKILL.md +110 -0
  18. package/scaffold/.agent/skills/plugin/evals/README.md +53 -0
  19. package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +42 -0
  20. package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +39 -0
  21. package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +32 -0
  22. package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +46 -0
  23. package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +55 -0
  24. package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +92 -0
  25. package/scaffold/.agent/skills/plugin/evals/run.mjs +118 -0
  26. package/scaffold/.agent/skills/plugin/prompts/01-function.md +29 -0
  27. package/scaffold/.agent/skills/plugin/prompts/02-surfaces.md +43 -0
  28. package/scaffold/.agent/skills/plugin/prompts/03-scaffold.md +31 -0
  29. package/scaffold/.agent/skills/plugin/prompts/04-cli.md +49 -0
  30. package/scaffold/.agent/skills/plugin/prompts/05-credentials.md +41 -0
  31. package/scaffold/.agent/skills/plugin/prompts/06-card.md +46 -0
  32. package/scaffold/.agent/skills/plugin/prompts/07-scaffold.md +52 -0
  33. package/scaffold/.agent/skills/plugin/prompts/08-verify.md +43 -0
  34. package/scaffold/.agent/skills/plugin/references/anti-patterns.md +123 -0
  35. package/scaffold/.agent/skills/plugin/references/card.md +90 -0
  36. package/scaffold/.agent/skills/plugin/references/scaffold.md +120 -0
  37. package/scaffold/.agent/skills/plugin/references/surfaces.md +206 -0
  38. package/scaffold/.agent/skills/plugin/scripts/record.mjs +360 -0
  39. package/scaffold/.agent/skills/plugin/subagents/card-designer.md +60 -0
  40. package/scaffold/.agent/skills/plugin/subagents/plugin-reviewer.md +55 -0
  41. package/scaffold/.agent/skills/plugin/subagents/scaffold-extractor.md +71 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +71 -1441
  43. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -5
  44. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +3 -44
  45. package/scaffold/.agent/skills/total-recall/plugins/auth/scripts/agent-login-pty.py +185 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
  47. package/scaffold/.agent/skills/total-recall/plugins/auth/src/host-secrets-store.mjs +15 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +6 -4
  49. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -24
  51. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  52. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +0 -21
  53. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  54. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +0 -32
  55. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  56. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +11 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +5 -2
  58. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  59. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -28
  60. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +95 -13
  61. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +553 -340
  62. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +0 -36
  63. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +383 -400
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +0 -1
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +0 -28
  67. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  68. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +0 -32
  69. package/scaffold/.agent/skills/total-recall/references/auth.md +3 -1
  70. package/scaffold/.agent/skills/total-recall/references/memory.md +4 -0
  71. package/scaffold/.agent/skills/total-recall/references/openwiki.md +2 -0
  72. package/scaffold/.agent/skills/total-recall/references/operations.md +12 -0
  73. package/src/cli/brain.mjs +4 -2
  74. package/src/cli/context-stats.mjs +104 -0
  75. package/src/cli/context.mjs +8 -0
  76. package/src/cli/daemon.mjs +10 -1
  77. package/src/cli/export.mjs +3 -2
  78. package/src/cli/ingest-okf.mjs +12 -15
  79. package/src/cli/init.mjs +0 -85
  80. package/src/cli/lint.mjs +3 -2
  81. package/src/cli/plugin/copies.mjs +68 -0
  82. package/src/cli/plugin/create.mjs +22 -24
  83. package/src/cli/plugin/index.mjs +49 -3
  84. package/src/cli/plugin/search.mjs +19 -2
  85. package/src/cli/rebuild.mjs +2 -0
  86. package/src/cli/remember.mjs +23 -0
  87. package/src/cli/status.mjs +10 -3
  88. package/src/cli/update.mjs +3 -2
  89. package/src/core/action-predictor.mjs +161 -0
  90. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +20 -3
  91. package/src/core/app-deploy/source.mjs +16 -9
  92. package/src/core/automatic-brain-updates.mjs +6 -3
  93. package/src/core/brain-registry.mjs +19 -2
  94. package/src/core/brain-state.mjs +23 -0
  95. package/src/core/cli-commands.mjs +1 -0
  96. package/src/core/cli-maintenance.mjs +1 -1
  97. package/src/core/cli-semantic-index.mjs +1 -1
  98. package/src/core/command-surface.mjs +3 -3
  99. package/src/core/composed-command-resolution.mjs +21 -0
  100. package/src/core/config.mjs +84 -0
  101. package/src/core/confined-memory.mjs +93 -0
  102. package/src/core/context-telemetry.mjs +214 -0
  103. package/src/core/daemon-control.mjs +14 -43
  104. package/src/core/fast-recall.mjs +1 -1
  105. package/src/core/memory-brain.mjs +2 -0
  106. package/src/core/plugin-activation.mjs +38 -6
  107. package/src/core/plugin-bundle.mjs +54 -0
  108. package/src/core/plugin-catalog.mjs +1 -1
  109. package/src/core/plugin-context.mjs +1 -1
  110. package/src/core/plugin-contracts/card.mjs +69 -37
  111. package/src/core/plugin-loader.mjs +5 -6
  112. package/src/core/plugin-runner.mjs +13 -6
  113. package/src/core/plugin-runtime.mjs +5 -4
  114. package/src/core/plugin-setup.mjs +5 -3
  115. package/src/core/plugin-state-store.mjs +12 -1
  116. package/src/core/plugin-state.mjs +46 -5
  117. package/src/core/plugin-store.mjs +78 -3
  118. package/src/core/plugin-tasks.mjs +3 -1
  119. package/src/core/plugin-updates.mjs +117 -0
  120. package/src/core/plugin-vendor.mjs +10 -4
  121. package/src/core/project-brain.mjs +14 -41
  122. package/src/core/project-id.mjs +4 -1
  123. package/src/core/required-plugins.mjs +151 -13
  124. package/src/core/skills-registry.mjs +27 -3
  125. package/src/core/ssss-kernel-bridge.mjs +3 -1
  126. package/src/core/startup-health.mjs +66 -16
  127. package/src/core/surface.mjs +51 -44
  128. package/src/core/testing/plugin-fixture.mjs +69 -0
  129. package/src/core/vault-files.mjs +13 -0
  130. package/src/core/vault.mjs +4 -30
  131. package/src/server/index.mjs +13 -1
  132. package/src/server/invoke-decision-child.mjs +49 -0
  133. package/src/server/invoke-execution.mjs +87 -0
  134. package/src/server/invoke-policy.mjs +64 -0
  135. package/src/server/invoke-read-child.mjs +26 -0
  136. package/src/server/invoke-targets.mjs +49 -0
  137. package/src/server/invoke-write-child.mjs +19 -0
  138. package/src/server/routes/invoke.mjs +314 -0
  139. package/src/server/routes/plugins.mjs +1 -1
  140. package/scaffold/.agent/skills/plugins/SKILL.md +0 -57
  141. package/scaffold/.agent/skills/plugins/references/card-design.md +0 -55
  142. package/scaffold/.agent/skills/plugins/references/distribution.md +0 -23
  143. package/scaffold/.agent/skills/plugins/references/manifest-contract.md +0 -90
  144. package/scaffold/.agent/skills/plugins/references/optimized/index.md +0 -9
  145. package/scaffold/.agent/skills/plugins/references/optimized/manifest.json +0 -35
  146. package/scaffold/.agent/skills/plugins/references/optimized/original.md +0 -58
  147. package/scaffold/.agent/skills/plugins/references/optimized/section-01.md +0 -5
  148. package/scaffold/.agent/skills/plugins/references/optimized/section-02.md +0 -25
  149. package/scaffold/.agent/skills/plugins/references/optimized/section-03.md +0 -12
  150. package/scaffold/.agent/skills/plugins/references/optimized/section-04.md +0 -7
  151. package/scaffold/.agent/skills/plugins/references/optimized/section-05.md +0 -5
  152. package/scaffold/.agent/skills/total-recall/openwiki/README.md +0 -10
  153. package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +0 -5
  154. package/scaffold/.agent/skills/total-recall/openwiki/ide.md +0 -10
  155. package/scaffold/.agent/skills/total-recall/openwiki/memory.md +0 -11
  156. package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +0 -7
  157. package/scaffold/.agent/skills/total-recall/openwiki/skills.md +0 -7
  158. package/scaffold/openwiki/README.md +0 -10
  159. package/scaffold/openwiki/architecture.md +0 -5
  160. package/scaffold/openwiki/ide.md +0 -10
  161. package/scaffold/openwiki/memory.md +0 -11
  162. package/scaffold/openwiki/secrets.md +0 -7
  163. package/scaffold/openwiki/skills.md +0 -7
  164. package/src/core/okf-adapter.mjs +0 -559
  165. package/templates/openwiki/README.md +0 -10
  166. package/templates/openwiki/architecture.md +0 -5
  167. package/templates/openwiki/ide.md +0 -10
  168. package/templates/openwiki/memory.md +0 -11
  169. package/templates/openwiki/secrets.md +0 -7
  170. package/templates/openwiki/skills.md +0 -7
@@ -1,409 +1,622 @@
1
1
  /**
2
- * Git Hygiene — Repository Hygiene, Worktree Integrity & Stash Tracker
3
- * Purpose-built domain interface: queries git audit on mount,
4
- * exposes unstaged diff inspection, upstream synchronization, and branch hygiene.
2
+ * Git Hygiene — repository hygiene.
5
3
  *
6
- * Adheres strictly to invariant: Always push everything, maintain pristine worktrees.
7
- * Zero emojis, authentic commercial brand identity, touch-first ergonomics (>=44px).
4
+ * This face answers one question with a deadline attached: is this repository
5
+ * safe to walk away from? So it leads with unpushed commits, then conflicts,
6
+ * then the four-way dirty split — ordered by the consequence of ignoring each.
7
+ *
8
+ * Every value comes from the returned payload. A value the command did not
9
+ * supply renders an em dash with its reason; nothing is seeded and nothing is
10
+ * defaulted to a reassuring word.
11
+ *
12
+ * Design rationale: ui/DESIGN.md. Requirements: GITSENTINEL_CARD_BESPOKE_PRD.md.
8
13
  */
9
14
 
15
+ const DASH = '\u2014';
16
+
17
+ /** Upstream states the command distinguishes, and whether each needs action. */
18
+ const UPSTREAM_STATES = {
19
+ tracking: { label: 'tracking', action: false },
20
+ none: { label: 'no upstream', action: false },
21
+ gone: { label: 'upstream gone', action: true },
22
+ detached: { label: 'detached HEAD', action: false },
23
+ unborn: { label: 'no commits yet', action: false },
24
+ unknown: { label: 'unresolved', action: true },
25
+ error: { label: 'check failed', action: true },
26
+ };
27
+
28
+ /** The four dirty categories, each with the action it implies. */
29
+ const DIRTY_ROWS = [
30
+ { key: 'stagedFiles', label: 'staged', action: 'stage and commit' },
31
+ { key: 'unstagedFiles', label: 'unstaged', action: 'review and commit' },
32
+ { key: 'untrackedFiles', label: 'untracked', action: 'decide whether to track' },
33
+ { key: 'conflictedFiles', label: 'conflicted', action: 'resolve before anything else' },
34
+ ];
35
+
36
+ function escapeHtml(value) {
37
+ return String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
38
+ }
39
+
40
+ /** A count only when the payload supplied a real number. */
41
+ function count(payload, key) {
42
+ const value = payload ? payload[key] : undefined;
43
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
44
+ }
45
+
46
+ function text(payload, key) {
47
+ const value = payload ? payload[key] : undefined;
48
+ return typeof value === 'string' && value ? value : undefined;
49
+ }
50
+
51
+ function asObject(result) {
52
+ if (result === null || result === undefined) return null;
53
+ if (typeof result === 'object') {
54
+ if (typeof result.output === 'string') {
55
+ try { const parsed = JSON.parse(result.output); return typeof parsed === 'object' ? parsed : null; } catch { return null; }
56
+ }
57
+ return result;
58
+ }
59
+ if (typeof result === 'string') {
60
+ try { const parsed = JSON.parse(result); return typeof parsed === 'object' ? parsed : null; } catch { return null; }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function rawText(result) {
66
+ if (result === null || result === undefined) return '';
67
+ if (typeof result === 'string') return result;
68
+ if (typeof result.output === 'string') return result.output;
69
+ try { return JSON.stringify(result, null, 2); } catch { return String(result); }
70
+ }
71
+
10
72
  export class GitSentinelCard extends HTMLElement {
11
73
  constructor() {
12
74
  super();
13
- this.executing = false;
14
- this.telemetry = {
15
- branch: 'main',
16
- worktree: 'Inspecting...',
17
- upstream: 'Checking Sync'
18
- };
75
+ this.state = 'loading';
76
+ this.audit = null;
77
+ this.auditRaw = '';
78
+ this.diff = null;
79
+ this.diffRaw = '';
80
+ this.lastError = null;
81
+ this.lastAttempt = null;
82
+ this.busy = false;
83
+ this.screen = 'hygiene';
84
+ }
85
+
86
+ /** Screens this face offers; the host only provides the flipper. */
87
+ get screens() {
88
+ return [
89
+ { id: 'hygiene', label: 'Hygiene' },
90
+ { id: 'changes', label: 'Changes' },
91
+ { id: 'upstream', label: 'Upstream' },
92
+ { id: 'provenance', label: 'Provenance' },
93
+ ];
19
94
  }
20
95
 
21
96
  connectedCallback() {
22
97
  if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
23
98
  this.render();
24
- this.refreshTelemetry();
99
+ this.onScreen = (event) => {
100
+ const detail = event.detail || {};
101
+ if (detail.pluginId && detail.pluginId !== this.host?.pluginId) return;
102
+ if (detail.screen && this.screens.some((s) => s.id === detail.screen)) this.show(detail.screen);
103
+ };
104
+ window.addEventListener('plugin-show-screen', this.onScreen);
105
+ void this.read();
25
106
  }
26
107
 
27
- async refreshTelemetry() {
28
- try {
29
- const res = await this.host?.run('audit', ['--json']);
30
- let data = null;
31
- if (typeof res === 'object' && res !== null) {
32
- data = res.output ? (typeof res.output === 'object' ? res.output : JSON.parse(res.output)) : res;
33
- } else if (typeof res === 'string') {
34
- try { data = JSON.parse(res); } catch {}
35
- }
36
-
37
- if (data) {
38
- const branch = data.branch || 'main';
39
- const isClean = data.worktree?.clean ?? (data.clean ?? false);
40
- const unstaged = data.worktree?.unstaged ?? 0;
41
- const staged = data.worktree?.staged ?? 0;
108
+ disconnectedCallback() {
109
+ if (this.onScreen) window.removeEventListener('plugin-show-screen', this.onScreen);
110
+ }
42
111
 
43
- let worktreeStatus = 'Clean';
44
- if (!isClean) {
45
- const count = unstaged + staged;
46
- worktreeStatus = count > 0 ? `Dirty (${count} files)` : 'Modified';
47
- }
112
+ /* ------------------------------------------------------------------ data */
48
113
 
49
- const upstream = data.upstream?.status || (data.synced ? 'Synced' : 'Up to date');
50
-
51
- this.telemetry = {
52
- branch: branch,
53
- worktree: worktreeStatus,
54
- upstream: upstream
55
- };
56
- } else {
57
- this.telemetry = {
58
- branch: 'main',
59
- worktree: 'Repository Active',
60
- upstream: 'Up to date'
61
- };
62
- }
63
- this.updateView();
64
- } catch {
65
- this.telemetry = {
66
- branch: 'main',
67
- worktree: 'Inspection Ready',
68
- upstream: 'Origin Sync'
69
- };
70
- this.updateView();
114
+ async call(subcommand, args) {
115
+ if (!this.host || typeof this.host.run !== 'function') {
116
+ throw new Error('The host did not provide a command runner, so the repository cannot be inspected.');
71
117
  }
118
+ return this.host.run(subcommand, args);
72
119
  }
73
120
 
74
- updateView() {
75
- const root = this.shadowRoot;
76
- if (!root) return;
77
-
78
- const brEl = root.querySelector('#stat-branch');
79
- const wtEl = root.querySelector('#stat-worktree');
80
- const upEl = root.querySelector('#stat-upstream');
81
- if (brEl) brEl.textContent = this.telemetry.branch;
82
- if (wtEl) wtEl.textContent = this.telemetry.worktree;
83
- if (upEl) upEl.textContent = this.telemetry.upstream;
121
+ /** Read the repository state. A failure clears every figure rather than keeping one. */
122
+ async read() {
123
+ if (this.busy) return;
124
+ this.busy = true;
125
+ this.lastAttempt = new Date();
126
+ this.paintControls();
127
+ try {
128
+ const returned = await this.call('audit', ['--json']);
129
+ const payload = asObject(returned);
130
+ if (!payload || typeof payload !== 'object') throw new Error('The audit command did not return a readable payload.');
131
+ this.audit = payload;
132
+ this.auditRaw = rawText(returned);
133
+ this.lastError = null;
134
+ this.state = Array.isArray(payload.errors) && payload.errors.length ? 'partial' : 'ok';
135
+ } catch (err) {
136
+ // No previous reading is retained: an unread repository is not a clean one.
137
+ this.audit = null;
138
+ this.auditRaw = '';
139
+ this.lastError = err && err.message ? err.message : String(err);
140
+ this.state = 'error';
141
+ } finally {
142
+ this.busy = false;
143
+ this.paintHygiene();
144
+ this.paintUpstream();
145
+ this.paintProvenance();
146
+ this.paintControls();
147
+ }
84
148
  }
85
149
 
86
- async execute(action, args = []) {
87
- const out = this.shadowRoot?.querySelector('#card-out');
88
- const statusPill = this.shadowRoot?.querySelector('#status-pill');
89
- const actionLabel = action.toUpperCase();
90
-
91
- if (out) out.textContent = `[SENTINEL AUDITOR] Scanning ${actionLabel} ${args.join(' ')}...`;
92
- if (statusPill) statusPill.textContent = 'SCANNING';
93
- this.executing = true;
94
-
150
+ async readDiff() {
95
151
  try {
96
- const res = await this.host?.run(action, args);
97
- let text = '';
98
- if (typeof res === 'object') {
99
- text = res.output || JSON.stringify(res, null, 2);
100
- } else {
101
- text = String(res || 'Audit completed without output.');
102
- }
103
- if (out) out.textContent = text.trim();
104
- if (statusPill) statusPill.textContent = 'SECURE';
105
-
106
- if (action === 'audit') {
107
- this.refreshTelemetry();
108
- }
152
+ const returned = await this.call('diff', ['--json']);
153
+ this.diff = asObject(returned);
154
+ this.diffRaw = rawText(returned);
109
155
  } catch (err) {
110
- if (out) out.textContent = `[SENTINEL ALERT] ${err.message}`;
111
- if (statusPill) statusPill.textContent = 'ALERT';
112
- } finally {
113
- this.executing = false;
156
+ this.diff = null;
157
+ this.diffRaw = err && err.message ? err.message : String(err);
114
158
  }
159
+ this.paintChanges();
115
160
  }
116
161
 
117
- copyOutput() {
118
- const out = this.shadowRoot?.querySelector('#card-out');
119
- const copyBtn = this.shadowRoot?.querySelector('#btn-copy');
120
- if (!out) return;
162
+ /* ----------------------------------------------------------------- events */
121
163
 
122
- navigator.clipboard?.writeText(out.textContent || '').then(() => {
123
- if (copyBtn) {
124
- const prev = copyBtn.textContent;
125
- copyBtn.textContent = 'COPIED';
126
- setTimeout(() => { copyBtn.textContent = prev; }, 1600);
127
- }
128
- }).catch(() => {});
164
+ show(id) {
165
+ this.screen = id;
166
+ this.shadowRoot.querySelectorAll('[data-screen]').forEach((el) => { el.hidden = el.dataset.screen !== id; });
167
+ this.shadowRoot.querySelectorAll('[data-tab]').forEach((el) => el.setAttribute('aria-selected', el.dataset.tab === id ? 'true' : 'false'));
168
+ if (id === 'changes' && !this.diff) void this.readDiff();
129
169
  }
130
170
 
131
- render() {
132
- const root = this.shadowRoot;
133
- if (!root) return;
171
+ /* ---------------------------------------------------------------- verdict */
172
+
173
+ /**
174
+ * The verdict, computed only from returned values and in priority order.
175
+ * The unknowable case is stated rather than implied to be clean.
176
+ */
177
+ verdict() {
178
+ if (this.state === 'error') return { tone: 'danger', text: 'Cannot verify — the repository audit failed.' };
179
+ const a = this.audit;
180
+ if (!a) return { tone: 'neutral', text: 'Reading the repository\u2026' };
181
+
182
+ const upstreamState = text(a, 'upstreamStatus');
183
+ const errors = Array.isArray(a.errors) ? a.errors : [];
184
+ if (upstreamState === 'error' || errors.length) {
185
+ return { tone: 'warn', text: `Partially verified — ${errors.length ? errors.join(', ') : 'the upstream check failed'}.` };
186
+ }
134
187
 
135
- root.innerHTML = `
136
- <style>
137
- :host {
138
- display: block;
139
- font-family: 'Space Grotesk', -apple-system, sans-serif;
140
- background: #140d04;
141
- color: #fef3c7;
142
- border-radius: 12px;
143
- border: 1px solid #78350f;
144
- padding: 14px;
145
- box-sizing: border-box;
146
- user-select: none;
147
- }
188
+ const conflicts = count(a, 'conflictedFiles');
189
+ if (conflicts) return { tone: 'danger', text: `${conflicts} conflict${conflicts === 1 ? '' : 's'} open — resolve before anything else.` };
148
190
 
149
- * { box-sizing: border-box; }
191
+ const unpushed = count(a, 'unpushedCommits');
192
+ if (unpushed) return { tone: 'danger', text: `${unpushed} commit${unpushed === 1 ? '' : 's'} unpushed.` };
150
193
 
151
- /* Bespoke Header */
152
- .card-header {
153
- display: flex;
154
- align-items: center;
155
- justify-content: space-between;
156
- margin-bottom: 12px;
157
- padding-bottom: 8px;
158
- border-bottom: 1px solid rgba(245, 158, 11, 0.25);
159
- }
194
+ if (upstreamState === 'gone') return { tone: 'danger', text: 'The upstream branch is gone — re-point it or push a new one.' };
195
+ if (upstreamState === 'none' || upstreamState === 'detached' || upstreamState === 'unborn') {
196
+ return { tone: 'warn', text: `Cannot tell whether work is pushed — ${UPSTREAM_STATES[upstreamState]?.label || upstreamState}.` };
197
+ }
198
+ if (upstreamState !== 'tracking') {
199
+ return { tone: 'warn', text: 'Cannot tell whether work is pushed — the upstream could not be resolved.' };
200
+ }
160
201
 
161
- .brand-cluster {
162
- display: flex;
163
- align-items: center;
164
- gap: 10px;
165
- }
202
+ const dirty = count(a, 'totalDirty');
203
+ if (dirty) return { tone: 'warn', text: `Dirty worktree — ${dirty} file${dirty === 1 ? '' : 's'} not committed.` };
166
204
 
167
- .brand-mark {
168
- width: 32px;
169
- height: 32px;
170
- border-radius: 8px;
171
- background: rgba(217, 119, 6, 0.2);
172
- border: 1px solid rgba(245, 158, 11, 0.4);
173
- display: flex;
174
- align-items: center;
175
- justify-content: center;
176
- flex-shrink: 0;
177
- }
205
+ const stashes = count(a, 'stashCount');
206
+ if (stashes) return { tone: 'warn', text: `Clean and pushed, with ${stashes} stash${stashes === 1 ? '' : 'es'} parked.` };
207
+ if (dirty === undefined) return { tone: 'neutral', text: 'Cannot confirm the worktree is clean.' };
208
+ return { tone: 'ok', text: 'Safe to leave — everything is pushed and the worktree is clean.' };
209
+ }
178
210
 
179
- .brand-meta {
180
- display: flex;
181
- flex-direction: column;
182
- }
211
+ /* ------------------------------------------------------------------ paint */
183
212
 
184
- .brand-title {
185
- font-size: 14px;
186
- font-weight: 700;
187
- color: #fef3c7;
188
- letter-spacing: 0.03em;
189
- text-transform: uppercase;
190
- margin: 0;
191
- }
213
+ paintHygiene() {
214
+ const root = this.shadowRoot;
215
+ if (!root) return;
216
+ const a = this.audit;
217
+ const v = this.verdict();
192
218
 
193
- .brand-subtitle {
194
- font-size: 11px;
195
- color: #fcd34d;
196
- margin: 0;
197
- font-family: 'JetBrains Mono', monospace;
198
- }
219
+ const verdictEl = root.querySelector('#verdict');
220
+ if (verdictEl) {
221
+ verdictEl.textContent = v.text;
222
+ verdictEl.dataset.tone = v.tone;
223
+ }
199
224
 
200
- .status-pill {
201
- font-family: 'JetBrains Mono', monospace;
202
- font-size: 10px;
203
- font-weight: 700;
204
- padding: 3px 8px;
205
- border-radius: 6px;
206
- background: rgba(245, 158, 11, 0.15);
207
- border: 1px solid rgba(245, 158, 11, 0.4);
208
- color: #fbbf24;
209
- letter-spacing: 0.05em;
210
- }
225
+ // Primary figure: unpushed commits. Unknowable is explained, never zeroed.
226
+ const unpushed = count(a, 'unpushedCommits');
227
+ const upstreamState = text(a, 'upstreamStatus');
228
+ // The accent border lives on the figure wrapper, so the tone goes on both.
229
+ const setTone = (figureId, numEl, tone) => {
230
+ if (numEl) numEl.dataset.tone = tone;
231
+ const wrap = root.querySelector(figureId);
232
+ if (wrap) wrap.dataset.tone = tone;
233
+ };
211
234
 
212
- /* Telemetry Metric Cards */
213
- .telemetry-row {
214
- display: grid;
215
- grid-template-columns: repeat(3, 1fr);
216
- gap: 8px;
217
- margin-bottom: 12px;
218
- }
235
+ const unpushedEl = root.querySelector('#unpushed');
236
+ if (unpushedEl) unpushedEl.textContent = unpushed === undefined ? DASH : String(unpushed);
237
+ setTone('#fig-unpushed', unpushedEl, unpushed === undefined ? 'neutral' : (unpushed > 0 ? 'danger' : 'ok'));
238
+ const unpushedNote = root.querySelector('#unpushed-note');
239
+ if (unpushedNote) {
240
+ unpushedNote.textContent = unpushed === undefined
241
+ ? `unknown \u00b7 ${UPSTREAM_STATES[upstreamState]?.label || 'no comparison available'}`
242
+ : (unpushed > 0 ? 'not yet on the upstream' : 'nothing waiting to push');
243
+ }
219
244
 
220
- .metric-card {
221
- background: rgba(120, 53, 15, 0.4);
222
- border: 1px solid rgba(245, 158, 11, 0.25);
223
- border-radius: 8px;
224
- padding: 8px 10px;
225
- display: flex;
226
- flex-direction: column;
227
- gap: 2px;
228
- }
245
+ const conflicts = count(a, 'conflictedFiles');
246
+ const conflictsEl = root.querySelector('#conflicts');
247
+ if (conflictsEl) conflictsEl.textContent = conflicts === undefined ? DASH : String(conflicts);
248
+ setTone('#fig-conflicts', conflictsEl, conflicts === undefined ? 'neutral' : (conflicts > 0 ? 'danger' : 'ok'));
249
+ const conflictsNote = root.querySelector('#conflicts-note');
250
+ if (conflictsNote) conflictsNote.textContent = conflicts ? 'merge in progress' : (conflicts === 0 ? 'none open' : 'unknown');
251
+
252
+ const dirty = count(a, 'totalDirty');
253
+ const dirtyEl = root.querySelector('#dirty');
254
+ if (dirtyEl) dirtyEl.textContent = dirty === undefined ? DASH : String(dirty);
255
+ setTone('#fig-dirty', dirtyEl, dirty === undefined ? 'neutral' : (dirty > 0 ? 'warn' : 'ok'));
256
+ const dirtyNote = root.querySelector('#dirty-note');
257
+ if (dirtyNote) {
258
+ if (dirty === undefined) dirtyNote.textContent = 'unknown';
259
+ else {
260
+ const parts = DIRTY_ROWS.map((r) => count(a, r.key) ?? 0);
261
+ // Compact abbreviations: the full words wrap inside a figure tile, and
262
+ // the four-way breakdown is already labelled in full below.
263
+ dirtyNote.textContent = `${parts[0]} stg \u00b7 ${parts[1]} unstg \u00b7 ${parts[2]} untr \u00b7 ${parts[3]} conf`;
264
+ }
265
+ }
229
266
 
230
- .metric-label {
231
- font-family: 'JetBrains Mono', monospace;
232
- font-size: 9px;
233
- font-weight: 700;
234
- color: #fcd34d;
235
- letter-spacing: 0.05em;
236
- text-transform: uppercase;
237
- }
267
+ const stashes = count(a, 'stashCount');
268
+ const stashEl = root.querySelector('#stashes');
269
+ if (stashEl) {
270
+ stashEl.textContent = stashes === undefined ? DASH : String(stashes);
271
+ }
272
+ setTone('#fig-stashes', stashEl, stashes === undefined ? 'neutral' : (stashes > 0 ? 'warn' : 'ok'));
273
+ const stashNote = root.querySelector('#stashes-note');
274
+ if (stashNote) stashNote.textContent = stashes ? 'parked work' : (stashes === 0 ? 'nothing parked' : 'unknown');
275
+
276
+ // The four-way breakdown, each row stating the action it implies.
277
+ const rows = root.querySelector('#dirty-rows');
278
+ if (rows) {
279
+ rows.innerHTML = DIRTY_ROWS.map((r) => {
280
+ const value = count(a, r.key);
281
+ const tone = value === undefined ? 'neutral' : (value > 0 ? (r.key === 'conflictedFiles' ? 'danger' : 'warn') : 'neutral');
282
+ return `<div class="dirty-row">
283
+ <span class="dirty-label">${r.label}</span>
284
+ <span class="dirty-count" data-tone="${tone}">${value === undefined ? DASH : value}</span>
285
+ <span class="dirty-action">${escapeHtml(r.action)}</span>
286
+ </div>`;
287
+ }).join('');
288
+ }
238
289
 
239
- .metric-value {
240
- font-size: 13px;
241
- font-weight: 700;
242
- color: #ffffff;
243
- font-family: 'JetBrains Mono', monospace;
244
- overflow: hidden;
245
- text-overflow: ellipsis;
246
- white-space: nowrap;
247
- }
290
+ const last = root.querySelector('#last-commit');
291
+ if (last) last.textContent = text(a, 'lastCommit') || DASH;
292
+
293
+ // Placement <-> Upstream screen: the branch is shown in the header too.
294
+ const branchHead = root.querySelector('#up-branch-head');
295
+ if (branchHead) {
296
+ const branch = text(a, 'branch');
297
+ const upstream = text(a, 'upstream');
298
+ const info = UPSTREAM_STATES[upstreamState];
299
+ branchHead.textContent = branch
300
+ ? `${branch}${upstream ? ` \u2192 ${upstream}` : ''}${info ? ` \u00b7 ${info.label}` : ''}`
301
+ : DASH;
302
+ }
248
303
 
249
- /* Action Buttons Grid (>=44px touch targets) */
250
- .actions-grid {
251
- display: grid;
252
- grid-template-columns: repeat(4, 1fr);
253
- gap: 6px;
254
- margin-bottom: 10px;
255
- }
304
+ const side = (selector, value) => { const el = root.querySelector(selector); if (el) el.textContent = value; };
305
+ side('#up-branch-side', text(a, 'branch') || DASH);
306
+ side('#up-name-side', text(a, 'upstream') || DASH);
307
+ const aheadSide = count(a, 'unpushedCommits');
308
+ const behindSide = count(a, 'behindCommits');
309
+ side('#up-ahead-side', aheadSide === undefined ? DASH : String(aheadSide));
310
+ side('#up-behind-side', behindSide === undefined ? DASH : String(behindSide));
311
+
312
+ const errEl = root.querySelector('#errors');
313
+ if (errEl) {
314
+ const errors = a && Array.isArray(a.errors) ? a.errors : [];
315
+ const failed = this.state === 'error';
316
+ errEl.hidden = !failed && errors.length === 0;
317
+ errEl.textContent = failed
318
+ ? `${this.lastError || 'The audit failed.'} Last attempt ${this.lastAttempt ? this.lastAttempt.toLocaleTimeString() : DASH}.`
319
+ : (errors.length ? `The command reported these failed checks: ${errors.join(', ')}. Figures below may be incomplete.` : '');
320
+ }
321
+ }
256
322
 
257
- .touch-btn {
258
- min-height: 44px;
259
- background: rgba(217, 119, 6, 0.16);
260
- border: 1px solid rgba(245, 158, 11, 0.3);
261
- border-radius: 8px;
262
- color: #fef3c7;
263
- font-family: 'JetBrains Mono', monospace;
264
- font-size: 11px;
265
- font-weight: 600;
266
- letter-spacing: 0.03em;
267
- display: flex;
268
- align-items: center;
269
- justify-content: center;
270
- cursor: pointer;
271
- transition: background 0.15s, border-color 0.15s, transform 0.05s;
272
- padding: 4px 6px;
273
- text-align: center;
274
- }
323
+ paintChanges() {
324
+ const root = this.shadowRoot;
325
+ if (!root) return;
326
+ const stat = root.querySelector('#diff-stat');
327
+ const raw = root.querySelector('#diff-raw');
328
+ const note = root.querySelector('#diff-note');
329
+ const dirty = count(this.audit, 'unstagedFiles');
330
+ if (note) {
331
+ note.textContent = this.audit
332
+ ? (dirty === undefined ? 'The unstaged count is unknown.'
333
+ : dirty === 0 ? 'Nothing unstaged to show.' : `${dirty} unstaged file${dirty === 1 ? '' : 's'} changed.`)
334
+ : 'Read the repository on the Hygiene screen first.';
335
+ }
336
+ const diffStat = this.diff && typeof this.diff.diffStat === 'string' ? this.diff.diffStat : '';
337
+ if (stat) stat.textContent = diffStat || (this.diffRaw ? '' : 'No diffstat yet.');
338
+ if (raw) raw.textContent = this.diffRaw || '';
339
+ }
275
340
 
276
- .touch-btn:hover {
277
- background: rgba(217, 119, 6, 0.3);
278
- border-color: rgba(251, 191, 36, 0.6);
279
- }
341
+ paintUpstream() {
342
+ const root = this.shadowRoot;
343
+ if (!root) return;
344
+ const a = this.audit;
345
+ const state = text(a, 'upstreamStatus');
346
+ const info = UPSTREAM_STATES[state];
347
+
348
+ const set = (selector, value, tone) => {
349
+ const el = root.querySelector(selector);
350
+ if (!el) return;
351
+ el.textContent = value;
352
+ if (tone) el.dataset.tone = tone; else delete el.dataset.tone;
353
+ };
280
354
 
281
- .touch-btn:active {
282
- transform: translateY(1px);
283
- }
355
+ set('#up-branch', text(a, 'branch') || DASH);
356
+ set('#up-head', text(a, 'headState') || DASH);
357
+ set('#up-name', text(a, 'upstream') || DASH);
358
+ set('#up-state', info ? info.label : (state || DASH), info ? (info.action ? 'danger' : 'neutral') : 'neutral');
359
+
360
+ const ahead = count(a, 'unpushedCommits');
361
+ const behind = count(a, 'behindCommits');
362
+ set('#up-ahead', ahead === undefined ? DASH : String(ahead), ahead ? 'danger' : (ahead === 0 ? 'ok' : 'neutral'));
363
+ set('#up-behind', behind === undefined ? DASH : String(behind));
364
+ set('#up-last', text(a, 'lastCommit') || DASH);
365
+
366
+ const note = root.querySelector('#up-note');
367
+ if (note) {
368
+ note.textContent = !a ? 'No reading yet.'
369
+ : state === 'tracking' ? 'Ahead and behind are counted against the upstream branch.'
370
+ : info?.action ? 'This state needs attention before the ahead and behind counts mean anything.'
371
+ : 'This is a fact about the repository, not a failure.';
372
+ }
284
373
 
285
- /* Terminal Execution Output */
286
- .terminal-deck {
287
- background: #0a0602;
288
- border: 1px solid rgba(245, 158, 11, 0.25);
289
- border-radius: 8px;
290
- overflow: hidden;
291
- }
374
+ const errEl = root.querySelector('#up-errors');
375
+ if (errEl) {
376
+ const errors = a && Array.isArray(a.errors) ? a.errors : [];
377
+ errEl.hidden = errors.length === 0;
378
+ errEl.textContent = errors.length ? `Failed checks: ${errors.join(', ')}` : '';
379
+ }
380
+ }
292
381
 
293
- .terminal-header {
294
- display: flex;
295
- align-items: center;
296
- justify-content: space-between;
297
- padding: 6px 10px;
298
- background: rgba(120, 53, 15, 0.4);
299
- border-bottom: 1px solid rgba(245, 158, 11, 0.2);
300
- }
382
+ paintProvenance() {
383
+ const root = this.shadowRoot;
384
+ const out = root?.querySelector('#prov-audit');
385
+ if (out) out.textContent = this.auditRaw || 'No audit reading yet.';
386
+ const diff = root?.querySelector('#prov-diff');
387
+ if (diff) diff.textContent = this.diffRaw || 'No diff reading yet.';
388
+ }
301
389
 
302
- .terminal-title {
303
- font-family: 'JetBrains Mono', monospace;
304
- font-size: 10px;
305
- color: #fcd34d;
306
- text-transform: uppercase;
307
- letter-spacing: 0.05em;
308
- }
390
+ paintControls() {
391
+ const root = this.shadowRoot;
392
+ if (!root) return;
393
+ const badge = root.querySelector('#read-state');
394
+ if (badge) {
395
+ const labels = { loading: 'Reading', ok: 'Verified', partial: 'Partial', error: 'Failed' };
396
+ badge.textContent = labels[this.state] || 'Reading';
397
+ badge.dataset.state = this.state;
398
+ }
399
+ const again = root.querySelector('#reread');
400
+ if (again) again.disabled = this.busy;
401
+ }
309
402
 
310
- .terminal-btn {
311
- background: rgba(245, 158, 11, 0.15);
312
- border: 1px solid rgba(245, 158, 11, 0.3);
313
- border-radius: 4px;
314
- color: #fef3c7;
315
- font-family: 'JetBrains Mono', monospace;
316
- font-size: 9px;
317
- padding: 2px 6px;
318
- cursor: pointer;
319
- }
403
+ /* ----------------------------------------------------------------- render */
320
404
 
321
- .terminal-btn:hover {
322
- background: rgba(245, 158, 11, 0.3);
405
+ render() {
406
+ this.shadowRoot.innerHTML = `
407
+ <style>
408
+ :host {
409
+ display: block; width: 100%; min-width: 0;
410
+ --face-ok: #047857;
411
+ --face-warn: #b45309;
412
+ --face-danger: #b91c1c;
413
+ --face-radius: 6px;
414
+ --face-motion: 140ms ease-out;
415
+ --face-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
416
+ --face-text: var(--text-primary, #111827);
417
+ --face-muted: var(--text-secondary, #6b7280);
418
+ --face-line: var(--border, #e5e7eb);
419
+ --face-surface: var(--bg-secondary, #f9fafb);
420
+ --face-accent: var(--card-color, #b45309);
421
+ font-family: 'Space Grotesk', var(--font-sans, system-ui, sans-serif);
422
+ color: var(--face-text);
323
423
  }
324
-
325
- .terminal-body {
326
- margin: 0;
327
- padding: 8px 10px;
328
- font-family: 'JetBrains Mono', monospace;
329
- font-size: 11px;
330
- line-height: 1.45;
331
- color: #fef3c7;
332
- min-height: 60px;
333
- max-height: 100px;
334
- overflow-y: auto;
335
- white-space: pre-wrap;
336
- word-break: break-word;
424
+ /* The light state colours are unreadable on a dark surface, including the
425
+ verdict line, so they are retuned whenever the host is dark. */
426
+ @media (prefers-color-scheme: dark) {
427
+ :host {
428
+ --face-ok: #34d399;
429
+ --face-warn: #fbbf24;
430
+ --face-danger: #f87171;
431
+ --face-text: var(--text-primary, #e8eaf0);
432
+ --face-muted: var(--text-secondary, #a2a8b8);
433
+ --face-line: var(--border, #2a3040);
434
+ --face-surface: var(--bg-secondary, #171a23);
435
+ }
436
+ }
437
+ :host-context([data-theme="dark"]),
438
+ :host-context(.theme-dark),
439
+ :host-context(.dark) {
440
+ --face-ok: #34d399;
441
+ --face-warn: #fbbf24;
442
+ --face-danger: #f87171;
443
+ --face-text: var(--text-primary, #e8eaf0);
444
+ --face-muted: var(--text-secondary, #a2a8b8);
445
+ --face-line: var(--border, #2a3040);
446
+ --face-surface: var(--bg-secondary, #171a23);
337
447
  }
448
+ * { box-sizing: border-box; }
449
+ .top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
450
+ .ident { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; min-width: 0; }
451
+ .ident h2 { margin: 0; font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; }
452
+ .ident .where { font-family: var(--face-mono); font-size: 12px; color: var(--face-muted); overflow-wrap: anywhere; }
453
+ #read-state { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--face-line); color: var(--face-muted); }
454
+ #read-state[data-state="ok"] { color: var(--face-ok); border-color: color-mix(in srgb, var(--face-ok) 35%, transparent); }
455
+ #read-state[data-state="partial"] { color: var(--face-warn); border-color: color-mix(in srgb, var(--face-warn) 35%, transparent); }
456
+ #read-state[data-state="error"] { color: var(--face-danger); border-color: color-mix(in srgb, var(--face-danger) 35%, transparent); }
457
+ .controls { display: flex; gap: 8px; flex-wrap: wrap; }
458
+ button { min-height: 44px; padding: 8px 14px; font: inherit; font-size: 12px; font-weight: 600; border-radius: var(--face-radius); cursor: pointer; color: var(--face-text); background: transparent; border: 1px solid var(--face-line); transition: border-color var(--face-motion); }
459
+ button:hover { border-color: var(--face-accent); }
460
+ button:focus-visible { outline: 2px solid var(--face-accent); outline-offset: 1px; }
461
+ button[disabled] { opacity: 0.5; cursor: not-allowed; }
462
+ .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--face-line); margin-bottom: 14px; overflow-x: auto; }
463
+ .tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; color: var(--face-muted); white-space: nowrap; }
464
+ .tabs button[aria-selected="true"] { color: var(--face-text); border-bottom-color: var(--face-accent); }
465
+ #verdict { font-size: 15px; font-weight: 700; margin: 0 0 16px; }
466
+ #verdict[data-tone="ok"] { color: var(--face-ok); }
467
+ #verdict[data-tone="warn"] { color: var(--face-warn); }
468
+ #verdict[data-tone="danger"] { color: var(--face-danger); }
469
+ #verdict[data-tone="neutral"] { color: var(--face-muted); }
470
+ .figures { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr)); gap: 12px; }
471
+ .figure { border: 1px solid var(--face-line); border-left: 3px solid var(--face-line); border-radius: var(--face-radius); padding: 12px; background: var(--face-surface); min-width: 0; }
472
+ .figure[data-tone="ok"] { border-left-color: var(--face-ok); }
473
+ .figure[data-tone="warn"] { border-left-color: var(--face-warn); }
474
+ .figure[data-tone="danger"] { border-left-color: var(--face-danger); }
475
+ .figure dt { font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--face-muted); margin: 0 0 6px; }
476
+ .figure dd { margin: 0; }
477
+ .figure .num { font-family: var(--face-mono); font-size: 38px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
478
+ .num[data-tone="ok"] { color: var(--face-ok); }
479
+ .num[data-tone="warn"] { color: var(--face-warn); }
480
+ .num[data-tone="danger"] { color: var(--face-danger); }
481
+ .num[data-tone="neutral"] { color: var(--face-muted); }
482
+ .figure .note { font-size: 11px; color: var(--face-muted); margin-top: 6px; font-family: var(--face-mono); overflow-wrap: anywhere; line-height: 1.5; }
483
+ .lower { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 16px; margin-top: 18px; }
484
+ @media (max-width: 760px) { .lower { grid-template-columns: 1fr; } }
485
+ h3 { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--face-muted); margin: 0 0 10px; }
486
+ .dirty-row { display: grid; grid-template-columns: 92px 44px minmax(0, 1fr); align-items: baseline; gap: 10px; padding: 5px 0; border-bottom: 1px solid color-mix(in srgb, var(--face-line) 60%, transparent); }
487
+ .dirty-row:last-child { border-bottom: 0; }
488
+ .dirty-label { font-size: 12px; }
489
+ .dirty-count { font-family: var(--face-mono); font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; text-align: right; }
490
+ .dirty-count[data-tone="danger"] { color: var(--face-danger); }
491
+ .dirty-count[data-tone="warn"] { color: var(--face-warn); }
492
+ .dirty-action { font-size: 11px; color: var(--face-muted); }
493
+ .facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); gap: 12px; margin: 0; }
494
+ /* Two columns read as a block; auto-fit strands the fourth item alone. */
495
+ .facts-side { grid-template-columns: repeat(2, minmax(0, 1fr)); }
496
+ .fact { border-top: 1px solid var(--face-line); padding-top: 8px; min-width: 0; }
497
+ .fact dt { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--face-muted); margin: 0 0 3px; }
498
+ .fact dd { margin: 0; font-family: var(--face-mono); font-size: 13px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
499
+ .fact dd[data-tone="ok"] { color: var(--face-ok); }
500
+ .fact dd[data-tone="danger"] { color: var(--face-danger); }
501
+ .last-commit { margin-top: 18px; border-top: 1px solid var(--face-line); padding-top: 10px; }
502
+ .last-commit p { margin: 0; font-family: var(--face-mono); font-size: 12px; color: var(--face-muted); overflow-wrap: anywhere; }
503
+ pre { font-family: var(--face-mono); font-size: 11px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; margin: 6px 0 0; color: var(--face-muted); }
504
+ .empty { font-size: 12px; color: var(--face-muted); margin: 0; }
505
+ .error { font-size: 12px; color: var(--face-danger); margin: 0 0 14px; }
506
+ .warn { font-size: 12px; color: var(--face-warn); margin: 0 0 14px; }
507
+ .prose { font-size: 12px; color: var(--face-muted); line-height: 1.6; margin: 0 0 12px; }
508
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
338
509
  </style>
339
510
 
340
- <div class="card-header">
341
- <div class="brand-cluster">
342
- <div class="brand-mark">
343
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
344
- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
345
- <line x1="12" y1="8" x2="12" y2="12"/>
346
- <line x1="12" y1="16" x2="12.01" y2="16"/>
347
- </svg>
348
- </div>
349
- <div class="brand-meta">
350
- <h3 class="brand-title">Git Hygiene</h3>
351
- <span class="brand-subtitle">Sentinel Shield • v1.2.1</span>
352
- </div>
511
+ <div class="top">
512
+ <div class="ident">
513
+ <h2>Git Hygiene</h2>
514
+ <span class="where" id="up-branch-head">${DASH}</span>
515
+ <span id="read-state" data-state="loading">Reading</span>
353
516
  </div>
354
- <div id="status-pill" class="status-pill">MONITOR</div>
517
+ <div class="controls"><button type="button" id="reread">Re-read</button></div>
355
518
  </div>
356
519
 
357
- <div class="telemetry-row">
358
- <div class="metric-card">
359
- <span class="metric-label">Active Branch</span>
360
- <span id="stat-branch" class="metric-value">main</span>
361
- </div>
362
- <div class="metric-card">
363
- <span class="metric-label">Worktree</span>
364
- <span id="stat-worktree" class="metric-value">Inspecting...</span>
365
- </div>
366
- <div class="metric-card">
367
- <span class="metric-label">Upstream</span>
368
- <span id="stat-upstream" class="metric-value">Checking Sync</span>
369
- </div>
520
+ <div class="tabs" role="tablist" aria-label="Repository screens">
521
+ <button type="button" role="tab" data-tab="hygiene" aria-selected="true">Hygiene</button>
522
+ <button type="button" role="tab" data-tab="changes" aria-selected="false">Changes</button>
523
+ <button type="button" role="tab" data-tab="upstream" aria-selected="false">Upstream</button>
524
+ <button type="button" role="tab" data-tab="provenance" aria-selected="false">Provenance</button>
370
525
  </div>
371
526
 
372
- <div class="actions-grid">
373
- <button id="btn-audit" class="touch-btn" type="button">AUDIT</button>
374
- <button id="btn-diff" class="touch-btn" type="button">DIFFSTAT</button>
375
- <button id="btn-json" class="touch-btn" type="button">JSON AUDIT</button>
376
- <button id="btn-refresh" class="touch-btn" type="button">REFRESH</button>
377
- </div>
527
+ <section data-screen="hygiene">
528
+ <p class="error" id="errors" hidden></p>
529
+ <p id="verdict" data-tone="neutral">Reading the repository\u2026</p>
378
530
 
379
- <div class="terminal-deck">
380
- <div class="terminal-header">
381
- <span class="terminal-title">Worktree Audit Stream</span>
382
- <button id="btn-copy" class="terminal-btn" type="button">COPY</button>
383
- </div>
384
- <pre id="card-out" class="terminal-body">Git worktree sentinel active. Click any action above to inspect hygiene state.</pre>
385
- </div>
386
- `;
387
-
388
- root.querySelector('#btn-audit')?.addEventListener('click', () => {
389
- this.execute('audit');
390
- });
531
+ <dl class="figures">
532
+ <div class="figure" id="fig-unpushed">
533
+ <dt>Unpushed commits</dt>
534
+ <dd><span class="num" id="unpushed" data-tone="neutral">${DASH}</span></dd>
535
+ <dd class="note" id="unpushed-note">reading</dd>
536
+ </div>
537
+ <div class="figure" id="fig-conflicts">
538
+ <dt>Conflicts</dt>
539
+ <dd><span class="num" id="conflicts" data-tone="neutral">${DASH}</span></dd>
540
+ <dd class="note" id="conflicts-note">reading</dd>
541
+ </div>
542
+ <div class="figure" id="fig-dirty">
543
+ <dt>Dirty files</dt>
544
+ <dd><span class="num" id="dirty" data-tone="neutral">${DASH}</span></dd>
545
+ <dd class="note" id="dirty-note">reading</dd>
546
+ </div>
547
+ <div class="figure" id="fig-stashes">
548
+ <dt>Stashes</dt>
549
+ <dd><span class="num" id="stashes" data-tone="neutral">${DASH}</span></dd>
550
+ <dd class="note" id="stashes-note">reading</dd>
551
+ </div>
552
+ </dl>
391
553
 
392
- root.querySelector('#btn-diff')?.addEventListener('click', () => {
393
- this.execute('diff');
394
- });
554
+ <div class="lower">
555
+ <div>
556
+ <h3>Working tree, and what each category asks for</h3>
557
+ <div id="dirty-rows"></div>
558
+ </div>
559
+ <div>
560
+ <h3>Upstream</h3>
561
+ <dl class="facts facts-side">
562
+ <div class="fact"><dt>Branch</dt><dd id="up-branch-side">${DASH}</dd></div>
563
+ <div class="fact"><dt>Upstream</dt><dd id="up-name-side">${DASH}</dd></div>
564
+ <div class="fact"><dt>Ahead</dt><dd id="up-ahead-side">${DASH}</dd></div>
565
+ <div class="fact"><dt>Behind</dt><dd id="up-behind-side">${DASH}</dd></div>
566
+ </dl>
567
+ </div>
568
+ </div>
395
569
 
396
- root.querySelector('#btn-json')?.addEventListener('click', () => {
397
- this.execute('audit', ['--json']);
398
- });
570
+ <div class="last-commit">
571
+ <h3>Last commit</h3>
572
+ <p id="last-commit">${DASH}</p>
573
+ </div>
574
+ </section>
575
+
576
+ <section data-screen="changes" hidden>
577
+ <p class="prose" id="diff-note"></p>
578
+ <h3>Diffstat of unstaged changes</h3>
579
+ <pre id="diff-stat"></pre>
580
+ <h3 style="margin-top:16px">Raw diff output</h3>
581
+ <pre id="diff-raw"></pre>
582
+ </section>
583
+
584
+ <section data-screen="upstream" hidden>
585
+ <p class="prose" id="up-note"></p>
586
+ <dl class="facts">
587
+ <div class="fact"><dt>Branch</dt><dd id="up-branch">${DASH}</dd></div>
588
+ <div class="fact"><dt>HEAD state</dt><dd id="up-head">${DASH}</dd></div>
589
+ <div class="fact"><dt>Upstream</dt><dd id="up-name">${DASH}</dd></div>
590
+ <div class="fact"><dt>Upstream state</dt><dd id="up-state">${DASH}</dd></div>
591
+ <div class="fact"><dt>Ahead</dt><dd id="up-ahead">${DASH}</dd></div>
592
+ <div class="fact"><dt>Behind</dt><dd id="up-behind">${DASH}</dd></div>
593
+ </dl>
594
+ <div class="last-commit"><h3>Last commit</h3><p id="up-last">${DASH}</p></div>
595
+ <p class="warn" id="up-errors" hidden></p>
596
+ </section>
597
+
598
+ <section data-screen="provenance" hidden>
599
+ <p class="prose">Every figure on the other screens comes from these two commands. Nothing is inferred.</p>
600
+ <h3>audit --json</h3>
601
+ <pre id="prov-audit"></pre>
602
+ <h3 style="margin-top:16px">diff --json</h3>
603
+ <pre id="prov-diff"></pre>
604
+ </section>
605
+ `;
399
606
 
400
- root.querySelector('#btn-refresh')?.addEventListener('click', () => {
401
- this.execute('audit');
607
+ const root = this.shadowRoot;
608
+ root.querySelector('#reread')?.addEventListener('click', () => void this.read());
609
+ root.querySelector('[aria-label="Repository screens"]')?.addEventListener('click', (event) => {
610
+ const button = event.target.closest('button[data-tab]');
611
+ if (button) this.show(button.dataset.tab);
402
612
  });
403
613
 
404
- root.querySelector('#btn-copy')?.addEventListener('click', () => {
405
- this.copyOutput();
406
- });
614
+ this.show(this.screen);
615
+ this.paintHygiene();
616
+ this.paintChanges();
617
+ this.paintUpstream();
618
+ this.paintProvenance();
619
+ this.paintControls();
407
620
  }
408
621
  }
409
622