total-recall-brain 3.44.0 → 3.45.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 (212) hide show
  1. package/bin/total-recall.mjs +6 -4
  2. package/docs/ARCHITECTURE.md +10 -7
  3. package/docs/reference/cli-reference.md +13 -8
  4. package/frontend/dist/assets/index-DACh1dhx.js +5613 -0
  5. package/frontend/dist/index.html +1 -1
  6. package/frontend/src/App.tsx +2 -2
  7. package/frontend/src/pages/PluginsPage.tsx +1 -1
  8. package/package.json +1 -1
  9. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +7 -7
  10. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -1
  11. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  12. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
  13. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +12 -1
  14. package/scaffold/.agent/skills/total-recall/plugins/automations/BRICK.md +7 -0
  15. package/scaffold/.agent/skills/total-recall/plugins/automations/LICENSE +21 -0
  16. package/scaffold/.agent/skills/total-recall/plugins/automations/README.md +66 -0
  17. package/scaffold/.agent/skills/total-recall/plugins/automations/adapters.mjs +231 -0
  18. package/scaffold/.agent/skills/total-recall/plugins/automations/capability/index.mjs +16 -0
  19. package/scaffold/.agent/skills/total-recall/plugins/automations/cli.mjs +121 -0
  20. package/{src/core/plugin-cron.mjs → scaffold/.agent/skills/total-recall/plugins/automations/cron.mjs} +1 -3
  21. package/scaffold/.agent/skills/total-recall/plugins/automations/inventory.mjs +88 -0
  22. package/scaffold/.agent/skills/total-recall/plugins/automations/package-lock.json +7661 -0
  23. package/scaffold/.agent/skills/total-recall/plugins/automations/package.json +35 -0
  24. package/scaffold/.agent/skills/total-recall/plugins/automations/plugin.json +167 -0
  25. package/scaffold/.agent/skills/total-recall/plugins/automations/scheduler.mjs +246 -0
  26. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/DESIGN.md +42 -0
  27. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/automations.js +2 -0
  28. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/card.js +273 -0
  29. package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/run-core.mjs +1 -1
  30. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/check.mjs +57 -5
  31. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report-data.mjs +40 -14
  32. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report.mjs +15 -40
  33. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +7 -7
  34. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
  35. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +1 -1
  36. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +7 -7
  37. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
  38. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -1
  39. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +7 -7
  40. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +1 -1
  41. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +1 -1
  42. package/scaffold/.agent/skills/total-recall/plugins/git/README.md +112 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/cli.mjs +135 -23
  44. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/package-lock.json +7 -7
  45. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/package.json +2 -1
  46. package/scaffold/.agent/skills/total-recall/plugins/git/plugin.json +161 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/context.mjs +94 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/phase3-spec-support.mjs +50 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/runtime.mjs +40 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/spec-support.mjs +51 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/_support.mjs +195 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/auth.mjs +201 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/bisect.mjs +40 -0
  54. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/cli-meta.mjs +159 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit-memory.mjs +30 -0
  56. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit.mjs +46 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/fleet.mjs +156 -0
  58. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/force-push.mjs +58 -0
  59. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/group.mjs +119 -0
  60. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/handoff.mjs +59 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/message.mjs +22 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/resolve.mjs +60 -0
  63. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/rules.mjs +81 -0
  64. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/secret-firewall.mjs +66 -0
  65. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/undo.mjs +132 -0
  66. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/why.mjs +16 -0
  67. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/ui/card.js +2 -2
  68. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +7 -7
  69. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +1 -1
  70. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
  71. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +25 -9
  72. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/BRICK.md +1 -1
  73. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +18 -6
  74. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +124 -24
  75. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +7 -7
  76. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +2 -2
  77. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +147 -14
  78. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +17 -3
  79. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adoption.mjs +2 -2
  80. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +3 -3
  81. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +92 -18
  82. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/control.mjs +145 -0
  83. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +42 -21
  84. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch-request.mjs +4 -2
  85. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +33 -9
  86. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +55 -11
  87. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +26 -4
  88. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/hermes.mjs +29 -1
  89. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/index.mjs +1 -1
  90. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/included-usage.mjs +1 -1
  91. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/jobs.mjs +84 -0
  92. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +3 -12
  93. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/maintenance.mjs +17 -0
  94. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/mcp-server.mjs +228 -0
  95. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +25 -2
  96. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +1 -1
  97. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +5 -1
  98. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +34 -3
  99. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reviews.mjs +25 -0
  100. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +1 -1
  101. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/claude.mjs +319 -0
  102. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/codex.mjs +387 -0
  103. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/result.mjs +67 -0
  104. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/resume.mjs +373 -0
  105. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/run.mjs +223 -0
  106. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session-jobs.mjs +72 -0
  107. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/setup.mjs +6 -3
  108. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/termination.mjs +16 -12
  109. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +32 -1
  110. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/usage-queue.mjs +1 -1
  111. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/verify-ssss.mjs +3 -3
  112. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +285 -40
  113. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +76 -9
  114. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/DESIGN.md +5 -5
  115. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +3 -3
  116. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +47 -18
  117. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +7 -7
  118. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +1 -1
  119. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +1 -1
  120. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh-diagnostics.mjs +44 -0
  121. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +120 -113
  122. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +7 -7
  123. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
  124. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
  125. package/scaffold/.agent/skills/total-recall/references/operations.md +1 -1
  126. package/src/cli/agent.mjs +1 -1
  127. package/src/cli/command.mjs +41 -2
  128. package/src/cli/dream.mjs +93 -69
  129. package/src/cli/harness.mjs +4 -4
  130. package/src/cli/help.mjs +1 -1
  131. package/src/cli/init.mjs +0 -25
  132. package/src/cli/lessons.mjs +51 -0
  133. package/src/cli/memory-plugin.mjs +1 -1
  134. package/src/cli/plugin/copies.mjs +64 -14
  135. package/src/cli/plugin/create.mjs +6 -7
  136. package/src/cli/plugin/index.mjs +11 -7
  137. package/src/cli/plugin/install.mjs +10 -15
  138. package/src/cli/plugin/remove.mjs +1 -1
  139. package/src/cli/plugin/search.mjs +2 -2
  140. package/src/cli/preflight.mjs +19 -6
  141. package/src/cli/proposals.mjs +1 -1
  142. package/src/cli/recall.mjs +66 -23
  143. package/src/cli/remember.mjs +3 -14
  144. package/src/cli/server.mjs +18 -0
  145. package/src/cli/startup.mjs +1 -0
  146. package/src/cli/task.mjs +9 -2
  147. package/src/cli/update.mjs +6 -2
  148. package/src/core/access-ledger.mjs +78 -0
  149. package/src/core/agent-manager.mjs +2 -2
  150. package/src/core/brain-locate.mjs +15 -0
  151. package/src/core/brain-updates.mjs +31 -11
  152. package/src/core/claude-hooks.mjs +3 -1
  153. package/src/core/cleanup-task.mjs +15 -0
  154. package/src/core/cli-commands.mjs +2 -0
  155. package/src/core/cli-maintenance.mjs +4 -1
  156. package/src/core/cli-semantic-index.mjs +23 -3
  157. package/src/core/context-compiler.mjs +20 -0
  158. package/src/core/daemon-loop.mjs +39 -62
  159. package/src/core/default-plugins.mjs +1 -1
  160. package/src/core/dream.mjs +33 -105
  161. package/src/core/ensure-dependencies.mjs +148 -0
  162. package/src/core/fast-recall.mjs +2 -2
  163. package/src/core/field-evaluation.mjs +23 -0
  164. package/src/core/gate-mode.mjs +1 -1
  165. package/src/core/harness-hooks.mjs +14 -2
  166. package/src/core/lesson-hooks.mjs +74 -0
  167. package/src/core/lesson-memory.mjs +24 -0
  168. package/src/core/lesson-sessions.mjs +52 -0
  169. package/src/core/lessons.mjs +96 -0
  170. package/src/core/meta-harness.mjs +2 -2
  171. package/src/core/optimizer.mjs +66 -165
  172. package/src/core/plugin-contracts/card.mjs +3 -0
  173. package/src/core/plugin-loader.mjs +12 -3
  174. package/src/core/plugin-runtime.mjs +40 -1
  175. package/src/core/plugin-store.mjs +35 -23
  176. package/src/core/post-mortem.mjs +52 -36
  177. package/src/core/preflight-gate.mjs +23 -2
  178. package/src/core/proposal-applier.mjs +31 -2
  179. package/src/core/repo-sync.mjs +4 -6
  180. package/src/core/required-plugins.mjs +26 -4
  181. package/src/core/rule-length.mjs +23 -0
  182. package/src/core/runtime.mjs +1 -1
  183. package/src/core/scheduler.mjs +35 -43
  184. package/src/core/schema.mjs +1 -2
  185. package/src/core/search.mjs +4 -3
  186. package/src/core/server-service.mjs +148 -0
  187. package/src/core/session-watcher.mjs +19 -1
  188. package/src/core/skill-gap.mjs +16 -0
  189. package/src/core/ssss-kernel-bridge.mjs +32 -4
  190. package/src/core/startup-health.mjs +6 -1
  191. package/src/core/surface.mjs +65 -54
  192. package/src/core/task-executors.mjs +54 -106
  193. package/src/core/validated-write.mjs +1 -0
  194. package/src/core/vector-field.mjs +134 -192
  195. package/src/server/invoke-core-child.mjs +24 -0
  196. package/src/server/invoke-core-operations.mjs +102 -0
  197. package/src/server/route-manifest.json +0 -56
  198. package/src/server/routes/brains.mjs +0 -18
  199. package/src/server/routes/context.mjs +0 -41
  200. package/src/server/routes/invoke.mjs +26 -1
  201. package/src/server/routes/ssss.mjs +0 -169
  202. package/src/server/routes/system.mjs +0 -15
  203. package/src/server/routes/tasks.mjs +0 -26
  204. package/src/server/routes/vault.mjs +2 -23
  205. package/frontend/dist/assets/index-DD4qONvH.js +0 -5607
  206. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +0 -53
  207. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +0 -129
  208. package/src/core/append-log.mjs +0 -131
  209. package/src/core/plugin-tasks.mjs +0 -173
  210. package/templates/onboarding-interview.md +0 -82
  211. /package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/LICENSE +0 -0
  212. /package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/ui/DESIGN.md +0 -0
@@ -0,0 +1,273 @@
1
+ /** Automations control ledger. Values come only from the inventory owner. */
2
+ const SCREENS = [
3
+ { id: 'scheduled', label: 'Scheduled' }, { id: 'nodes', label: 'Nodes' },
4
+ { id: 'audit', label: 'Audit' }, { id: 'run', label: 'Run' },
5
+ ];
6
+ const escape = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
7
+ const words = (value) => String(value ?? 'unknown').replaceAll('_', ' ').replaceAll('-', ' ');
8
+ const date = (value) => value && Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleString() : '— not recorded';
9
+ const stateClass = (value) => ['error', 'failed', 'offline', 'unreachable', 'unauthorized'].includes(value) ? 'bad' : 'neutral';
10
+
11
+ /** Decode the host wrapper without turning command failures into inventory. */
12
+ export function decodeInventory(result) {
13
+ if (result?.success === false || (typeof result?.exitCode === 'number' && result.exitCode !== 0) || (typeof result?.code === 'number' && result.code !== 0)) {
14
+ throw new Error(result.error || 'Inventory command failed');
15
+ }
16
+ let data = result?.output !== undefined ? result.output : result;
17
+ if (typeof data === 'string') {
18
+ try { data = JSON.parse(data); } catch { throw new Error('Inventory returned invalid JSON'); }
19
+ }
20
+ if (!data || !Array.isArray(data.nodes) || data.nodes.some((node) => !node || !Array.isArray(node.automations))) {
21
+ throw new Error('Inventory response is incomplete or unsupported');
22
+ }
23
+ return data;
24
+ }
25
+
26
+ export class AutomationsCard extends HTMLElement {
27
+ constructor() {
28
+ super();
29
+ this.attachShadow({ mode: 'open' });
30
+ this.screen = 'scheduled';
31
+ this.filters = { node: '', source: '', trigger: '', state: '' };
32
+ this.inventory = null;
33
+ this.phase = 'unchecked';
34
+ this.error = '';
35
+ this.feedback = '';
36
+ this.selected = '';
37
+ this.request = 0;
38
+ this.attemptedAt = null;
39
+ this.pendingControl = null;
40
+ this.boundClick = (event) => this.click(event);
41
+ this.boundChange = (event) => this.change(event);
42
+ this.boundKey = (event) => this.key(event);
43
+ this.boundScreen = (event) => {
44
+ if (event.detail?.pluginId === this.host?.pluginId) this.selectScreen(event.detail.screen);
45
+ };
46
+ }
47
+
48
+ get host() { return this._host; }
49
+ set host(value) {
50
+ this._host = value;
51
+ if (this.isConnected && value?.run) void this.refresh();
52
+ }
53
+ get screens() { return SCREENS; }
54
+
55
+ connectedCallback() {
56
+ this.shadowRoot.addEventListener('click', this.boundClick);
57
+ this.shadowRoot.addEventListener('change', this.boundChange);
58
+ this.shadowRoot.addEventListener('keydown', this.boundKey);
59
+ window.addEventListener('plugin-show-screen', this.boundScreen);
60
+ this.render();
61
+ void this.refresh();
62
+ }
63
+
64
+ disconnectedCallback() {
65
+ this.request++;
66
+ this.controller?.abort();
67
+ this.shadowRoot.removeEventListener('click', this.boundClick);
68
+ this.shadowRoot.removeEventListener('change', this.boundChange);
69
+ this.shadowRoot.removeEventListener('keydown', this.boundKey);
70
+ window.removeEventListener('plugin-show-screen', this.boundScreen);
71
+ }
72
+
73
+ async invoke(command, args = []) {
74
+ if (this.host?.run) return this.host.run(command, args);
75
+ const response = await fetch('/api/plugins/automations/run', {
76
+ method: 'POST', credentials: 'same-origin', signal: this.controller?.signal,
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: JSON.stringify({ subcommand: command, args }),
79
+ });
80
+ if (!response.ok) throw new Error(`Automation request failed (${response.status})`);
81
+ return response.json();
82
+ }
83
+
84
+ async refresh() {
85
+ const request = ++this.request;
86
+ this.controller?.abort();
87
+ this.controller = new AbortController();
88
+ this.phase = 'loading'; this.inventory = null; this.error = ''; this.feedback = ''; this.pendingControl = null;
89
+ this.attemptedAt = new Date().toISOString();
90
+ this.render();
91
+ try {
92
+ const data = decodeInventory(await this.invoke('list'));
93
+ if (request !== this.request || !this.isConnected) return;
94
+ this.inventory = data; this.phase = 'ready';
95
+ } catch (error) {
96
+ if (request !== this.request || !this.isConnected) return;
97
+ this.inventory = null; this.phase = 'error';
98
+ this.error = error.message || 'Automation inventory unavailable';
99
+ }
100
+ this.render();
101
+ }
102
+
103
+ records() {
104
+ return (this.inventory?.nodes || []).flatMap((node) => node.automations.map((item) => ({
105
+ ...item, _node: node, _key: `${node.node_id || node.ip || node.hostname}:${item.id || `${item.plugin_id}:${item.command}`}`,
106
+ })));
107
+ }
108
+ filteredRecords() {
109
+ return this.records().filter((item) =>
110
+ (!this.filters.node || this.nodeKey(item._node) === this.filters.node) &&
111
+ (!this.filters.source || item.source === this.filters.source) &&
112
+ (!this.filters.trigger || item.trigger?.kind === this.filters.trigger) &&
113
+ (!this.filters.state || item.state === this.filters.state));
114
+ }
115
+ nodeKey(node) { return String(node.node_id || node.ip || node.hostname || ''); }
116
+ selectedRecord() {
117
+ const records = this.filteredRecords();
118
+ return records.find((item) => item._key === this.selected) || records[0];
119
+ }
120
+ selectScreen(screen, focus = false) {
121
+ if (!SCREENS.some((item) => item.id === screen)) return;
122
+ this.screen = screen; this.feedback = ''; this.pendingControl = null; this.render();
123
+ this.host?.screenChanged?.(screen);
124
+ if (focus) this.shadowRoot.querySelector(`[data-screen="${screen}"]`)?.focus();
125
+ }
126
+ key(event) {
127
+ const tab = event.target.closest('[data-screen]');
128
+ if (!tab) return;
129
+ const index = SCREENS.findIndex((item) => item.id === tab.dataset.screen);
130
+ const next = event.key === 'ArrowRight' ? (index + 1) % SCREENS.length
131
+ : event.key === 'ArrowLeft' ? (index + SCREENS.length - 1) % SCREENS.length
132
+ : event.key === 'Home' ? 0 : event.key === 'End' ? SCREENS.length - 1 : null;
133
+ if (next === null) return;
134
+ event.preventDefault(); this.selectScreen(SCREENS[next].id, true);
135
+ }
136
+ change(event) {
137
+ const filter = event.target.dataset.filter;
138
+ if (!(filter in this.filters)) return;
139
+ this.filters[filter] = event.target.value; this.selected = ''; this.pendingControl = null; this.render();
140
+ this.shadowRoot.querySelector(`[data-filter="${filter}"]`)?.focus();
141
+ }
142
+ click(event) {
143
+ const button = event.target.closest('button');
144
+ if (!button || button.disabled) return;
145
+ if (button.dataset.screen) this.selectScreen(button.dataset.screen, true);
146
+ else if (button.dataset.record) {
147
+ this.selected = button.dataset.record; this.pendingControl = null; this.render();
148
+ const selected = [...this.shadowRoot.querySelectorAll('[data-record]')].find((item) => item.dataset.record === this.selected);
149
+ selected?.focus();
150
+ } else if (button.dataset.action === 'refresh') void this.refresh();
151
+ else if (button.dataset.action === 'copy') void this.copyOutput();
152
+ else if (button.dataset.action === 'confirm-control') void this.executeControl(this.pendingControl?.index, true);
153
+ else if (button.dataset.action === 'dismiss-control') { this.pendingControl = null; this.render(); }
154
+ else if (button.dataset.control) void this.executeControl(Number(button.dataset.control));
155
+ }
156
+
157
+ async copyOutput() {
158
+ if (!this.inventory) return;
159
+ try {
160
+ if (!navigator.clipboard?.writeText) throw new Error('Clipboard is unavailable');
161
+ await navigator.clipboard.writeText(JSON.stringify(this.inventory, null, 2));
162
+ if (this.isConnected) this.feedback = 'Inventory copied';
163
+ } catch (error) { if (this.isConnected) this.feedback = `Copy failed: ${error.message}`; }
164
+ if (this.isConnected) this.render();
165
+ }
166
+
167
+ async executeControl(index, confirmed = false) {
168
+ const item = this.selectedRecord();
169
+ const action = item?.actions?.[index];
170
+ if (!action || typeof action.command !== 'string' || !Array.isArray(action.args) || !action.args.every((arg) => typeof arg === 'string')) return;
171
+ if (Array.isArray(this.host?.operations) && !this.host.operations.includes(action.command)) {
172
+ this.feedback = 'Action unavailable for this access level'; this.render(); return;
173
+ }
174
+ if (!['read', 'write', 'destructive', 'send'].includes(action.effect)) {
175
+ this.feedback = 'Action unavailable: source did not declare its effect'; this.render(); return;
176
+ }
177
+ if (action.effect !== 'read') {
178
+ if (!confirmed) { this.pendingControl = { key: item._key, index }; this.render(); return; }
179
+ if (this.pendingControl?.key !== item._key || this.pendingControl.index !== index) return;
180
+ }
181
+ this.pendingControl = null;
182
+ this.feedback = 'Submitting source action…'; this.render();
183
+ const request = this.request;
184
+ try {
185
+ const result = await this.invoke(action.command, action.args);
186
+ if (request !== this.request || !this.isConnected) return;
187
+ if (result?.success === false || result?.code > 0 || result?.exitCode > 0) throw new Error(result.error || 'Source rejected the action');
188
+ await this.refresh();
189
+ if (this.isConnected) this.feedback = 'Source action completed; inventory refreshed';
190
+ } catch (error) {
191
+ if (request !== this.request || !this.isConnected) return;
192
+ this.feedback = `Action failed: ${error.message}`;
193
+ }
194
+ if (this.isConnected) this.render();
195
+ }
196
+
197
+ renderFilters() {
198
+ const records = this.records();
199
+ const options = (values, selected) => '<option value="">All</option>' + [...new Set(values.filter(Boolean))].sort().map((value) => `<option value="${escape(value)}" ${selected === value ? 'selected' : ''}>${escape(words(value))}</option>`).join('');
200
+ return `<div class="filters">
201
+ <label>Node<select data-filter="node">${'<option value="">All</option>' + (this.inventory?.nodes || []).map((node) => `<option value="${escape(this.nodeKey(node))}" ${this.filters.node === this.nodeKey(node) ? 'selected' : ''}>${escape(node.hostname || node.ip || 'Unnamed node')}</option>`).join('')}</select></label>
202
+ <label>Source<select data-filter="source">${options(records.map((item) => item.source), this.filters.source)}</select></label>
203
+ <label>Trigger<select data-filter="trigger">${options(records.map((item) => item.trigger?.kind), this.filters.trigger)}</select></label>
204
+ <label>State<select data-filter="state">${options(records.map((item) => item.state), this.filters.state)}</select></label>
205
+ </div>`;
206
+ }
207
+
208
+ renderDetails(item, controls = false) {
209
+ if (!item) return '<section class="detail"><h3>No automation selected</h3><p>Select a record after discovery.</p></section>';
210
+ const knownBool = (value, yes, no) => value === true ? yes : value === false ? no : '— not reported';
211
+ const run = item.last_run;
212
+ return `<section class="detail"><p class="eyebrow">${escape(words(item.source || 'source not reported'))}</p><h3>${escape(item.name || item.intent || item.id || item.command)}</h3>
213
+ <dl>
214
+ <dt>Trigger</dt><dd>${escape(item.trigger?.summary || item.schedule || '— not reported')} <span class="subtle">${escape(item.trigger?.kind || '')}</span></dd>
215
+ <dt>Enabled</dt><dd>${escape(knownBool(item.enabled, 'Enabled', 'Disabled'))}</dd>
216
+ <dt>Runner</dt><dd>${escape(knownBool(item.runner_available, 'Available', 'Unavailable'))}</dd>
217
+ <dt>Node</dt><dd>${escape(item._node.hostname || item._node.ip || '— not reported')}</dd>
218
+ <dt>Placement</dt><dd>${escape(words(item.placement || 'not reported'))}</dd>
219
+ <dt>Origin</dt><dd class="mono">${escape(item.origin || item.id || '— not reported')}</dd>
220
+ <dt>Last handled slot</dt><dd>${escape(item.last_handled_slot || '— not recorded')}</dd>
221
+ <dt>Observed run</dt><dd>${run ? `${escape(words(run.state))} · ${escape(date(run.at))}${run.exit_code != null ? ` · exit ${escape(run.exit_code)}` : ''}` : '— no run outcome recorded'}</dd>
222
+ </dl>
223
+ ${controls ? this.renderControls(item) : '<p class="note">A handled slot is scheduling evidence. It does not establish a successful run.</p>'}
224
+ </section>`;
225
+ }
226
+ renderControls(item) {
227
+ const actions = Array.isArray(item.actions) ? item.actions : [];
228
+ const pending = this.pendingControl?.key === item._key ? actions[this.pendingControl.index] : null;
229
+ return `<div class="source-actions">${actions.length ? actions.map((action, index) => `<button data-control="${index}" ${this.feedback === 'Submitting source action…' ? 'disabled' : ''}>${escape(action.label || words(action.command))}<span class="effect">${escape(action.effect || 'effect not declared')}</span></button>`).join('') : '<p>No executable controls were advertised by this source.</p>'}</div>${pending ? `<section class="confirmation" role="group" aria-label="Confirm source action"><p>${escape(pending.label || words(pending.command))}: <strong>${escape(item.name || item.id)}</strong>?</p><div class="source-actions"><button data-action="confirm-control">Confirm ${escape(pending.label || words(pending.command))}</button><button data-action="dismiss-control">Keep unchanged</button></div></section>` : ''}<p class="note">Actions are delegated to the source owner. Available controls depend on its capabilities and your access.</p>`;
230
+ }
231
+ renderScheduled() {
232
+ const records = this.filteredRecords();
233
+ return `${this.renderFilters()}<div class="ledger"><section class="index"><h3>Automation index <span class="count">${records.length}</span></h3>${records.length ? `<ul>${records.map((item) => `<li><button data-record="${escape(item._key)}" aria-pressed="${this.selectedRecord()?._key === item._key}"><span><strong>${escape(item.name || item.intent || item.id || item.command)}</strong><small>${escape(item._node.hostname || item._node.ip)} · ${escape(words(item.source || 'source not reported'))}</small></span><span class="state ${stateClass(item.state)}">${escape(words(item.state))}</span></button></li>`).join('')}</ul>` : '<p>No records match these filters.</p>'}</section>${this.renderDetails(this.selectedRecord())}</div>`;
234
+ }
235
+ renderNodes() {
236
+ const nodes = this.inventory?.nodes || [];
237
+ return `<div class="node-grid">${nodes.map((node) => `<section class="node"><header><h3>${escape(node.hostname || node.ip || 'Unnamed node')}</h3><span class="state ${stateClass(node.status)}">${escape(words(node.status))}</span></header><p class="mono">${escape(node.ip || node.node_id || '— address not reported')}</p>${node.error ? `<p class="bad">${escape(node.error)}</p>` : ''}${Array.isArray(node.coverage) && node.coverage.length ? `<ul class="coverage">${node.coverage.map((coverage) => `<li><div><strong>${escape(words(coverage.source))}</strong><span class="state ${stateClass(coverage.status)}">${escape(words(coverage.status))}</span></div>${coverage.error ? `<p>${escape(coverage.error)}</p>` : ''}<small>${escape(date(coverage.checked_at))}</small></li>`).join('')}</ul>` : '<p>— source coverage was not reported. This node cannot establish complete discovery.</p>'}</section>`).join('') || '<p>No mesh nodes were reported.</p>'}</div>`;
238
+ }
239
+ renderAudit() {
240
+ const records = this.filteredRecords();
241
+ return `${this.renderFilters()}<p class="note">Observed outcomes only. Declarations and handled slots do not prove execution.</p><ul class="audit">${records.map((item) => `<li><div><strong>${escape(item.name || item.id || item.command)}</strong><small>${escape(item._node.hostname || item._node.ip)} · ${escape(words(item.source))}</small></div><div><span class="state ${stateClass(item.last_run?.state)}">${escape(item.last_run ? words(item.last_run.state) : 'No observed run')}</span><small>${escape(date(item.last_run?.at))}</small>${item.last_run?.exit_code != null ? `<small>Exit ${escape(item.last_run.exit_code)}</small>` : ''}</div></li>`).join('') || '<li>No records match these filters.</li>'}</ul>`;
242
+ }
243
+ renderRun() {
244
+ const records = this.filteredRecords();
245
+ return `${this.renderFilters()}<div class="ledger"><section class="index"><h3>Choose a source-owned action</h3><p>Only advertised controls are executable.</p><ul>${records.map((item) => `<li><button data-record="${escape(item._key)}" aria-pressed="${this.selectedRecord()?._key === item._key}"><span><strong>${escape(item.name || item.id || item.command)}</strong><small>${escape(item._node.hostname || item._node.ip)} · ${(item.actions || []).length} advertised controls</small></span></button></li>`).join('')}</ul></section>${this.renderDetails(this.selectedRecord(), true)}</div>`;
246
+ }
247
+ render() {
248
+ const focus = this.shadowRoot.activeElement;
249
+ const focusedAction = focus?.dataset.action;
250
+ const status = this.phase === 'loading' ? 'Checking sources' : this.phase === 'error' ? 'Inventory unavailable'
251
+ : this.phase === 'ready' ? (this.inventory.complete === true ? 'Discovery complete' : 'Partial discovery — inspect source coverage') : 'Not checked';
252
+ const content = this.phase !== 'ready' ? `<div class="empty"><h3>${escape(status)}</h3><p>${escape(this.phase === 'error' ? this.error : this.phase === 'loading' ? 'Reading automation sources and node availability…' : 'Refresh to inspect automation sources.')}</p></div>`
253
+ : this.screen === 'nodes' ? this.renderNodes() : this.screen === 'audit' ? this.renderAudit() : this.screen === 'run' ? this.renderRun() : this.renderScheduled();
254
+ this.shadowRoot.innerHTML = `
255
+ <style>
256
+ :host { --face-bg:#181713; --face-surface:#24221c; --face-text:#f4efe5; --face-muted:#c5bca9; --face-border:#726652; --face-accent:#f2bd63; --face-error:#ffb5a9; --face-focus:#b8d9ff; --automation-bg:var(--bg-card,var(--face-bg)); --automation-surface:var(--bg-elevated,var(--face-surface)); --automation-text:var(--text-primary,var(--face-text)); --automation-muted:var(--text-secondary,var(--face-muted)); --automation-border:var(--border,var(--face-border)); --automation-accent:var(--accent,var(--face-accent)); --automation-error:var(--face-error); --automation-focus:var(--face-focus); display:block; color:var(--automation-text); font:14px/1.45 system-ui,sans-serif; min-width:0; color-scheme:dark; }
257
+ *,*::before,*::after { box-sizing:border-box; } button,select { font:inherit; color:inherit; min-height:44px; min-width:44px; } button { cursor:pointer; } button:disabled { cursor:wait; opacity:.6; } button:focus-visible,select:focus-visible { outline:3px solid var(--automation-focus); outline-offset:3px; } .workspace { border:1px solid var(--automation-border); border-radius:12px; background:var(--automation-bg); overflow:hidden; } .masthead { padding:20px; display:flex; gap:16px; align-items:center; flex-wrap:wrap; } .brand { display:flex; gap:12px; align-items:center; flex:1; min-width:0; } svg { width:40px; height:40px; color:var(--automation-accent); flex:none; } h2,h3,p { margin:0; } h2 { font-size:20px; letter-spacing:-.03em; } h3 { font-size:15px; } .eyebrow { text-transform:uppercase; letter-spacing:.11em; font-size:10px; color:var(--automation-muted); margin-bottom:4px; } .subtle,small,.note { color:var(--automation-muted); } small { display:block; font-size:11px; margin-top:3px; overflow-wrap:anywhere; } .refresh { border:1px solid var(--automation-accent); background:var(--automation-accent); color:var(--automation-bg); border-radius:7px; padding:8px 14px; font-weight:700; } .status-line { padding:12px 20px; background:var(--automation-surface); border-block:1px solid var(--automation-border); display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; } .status-line strong { font-size:12px; font-weight:600; } .tabs { display:flex; padding:8px 12px 0; gap:4px; border-bottom:1px solid var(--automation-border); } .tabs button { flex:1; background:transparent; border:0; border-bottom:3px solid transparent; padding:10px 4px; color:var(--automation-muted); } .tabs button[aria-selected=true] { border-color:var(--automation-accent); color:var(--automation-text); font-weight:700; } .body { padding:16px; min-height:200px; animation:reveal .16s ease-out; } .filters { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:16px; } label { font-size:11px; color:var(--automation-muted); display:grid; gap:4px; } select { width:100%; max-width:100%; background:var(--automation-surface); border:1px solid var(--automation-border); border-radius:5px; padding:6px; } .ledger { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:16px; } .index,.detail,.node { min-width:0; } .index h3 { margin-bottom:8px; } ul { list-style:none; padding:0; margin:0; } .index ul { max-height:380px; overflow:auto; } .index li+li { margin-top:6px; } .index button { width:100%; display:flex; justify-content:space-between; gap:12px; align-items:center; text-align:left; padding:12px; background:transparent; border:1px solid var(--automation-border); border-radius:7px; } .index button[aria-pressed=true] { border-color:var(--automation-accent); background:var(--automation-surface); } strong { overflow-wrap:anywhere; } .state { display:inline-block; text-transform:none; font:11px/1.4 ui-monospace,monospace; border:1px solid var(--automation-border); border-radius:4px; padding:3px 6px; overflow-wrap:anywhere; } .bad { color:var(--automation-error); } .detail { border-left:2px solid var(--automation-accent); padding:0 0 0 16px; } .detail h3 { font-size:19px; margin-bottom:12px; overflow-wrap:anywhere; } dl { display:grid; grid-template-columns:minmax(80px,.7fr) minmax(0,1fr); gap:9px 12px; margin:0; } dt { color:var(--automation-muted); font-size:11px; } dd { margin:0; overflow-wrap:anywhere; font-size:12px; } .mono { font-family:ui-monospace,monospace; overflow-wrap:anywhere; font-size:11px; } .note { font-size:11px; margin-top:16px; } .count { font:11px ui-monospace,monospace; margin-left:4px; color:var(--automation-muted); } .node-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr)); gap:16px; } .node { border:1px solid var(--automation-border); border-radius:8px; padding:14px; } .node header { display:flex; justify-content:space-between; align-items:start; gap:12px; margin-bottom:4px; } .coverage { margin-top:12px; } .coverage li { border-top:1px solid var(--automation-border); padding:10px 0; } .coverage li>div { display:flex; justify-content:space-between; align-items:center; gap:12px; } .coverage strong { font-size:12px; } .coverage p { font-size:12px; margin-top:6px; } .audit li { display:flex; justify-content:space-between; gap:16px; padding:12px 0; border-bottom:1px solid var(--automation-border); } .audit li>div { min-width:0; } .audit li>div:last-child { text-align:right; } .audit { margin-top:12px; } .source-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:16px; } .source-actions button { border:1px solid var(--automation-accent); border-radius:5px; background:var(--automation-surface); padding:8px 12px; } .effect { display:block; font:10px ui-monospace,monospace; color:var(--automation-muted); } .footer { display:flex; align-items:center; justify-content:space-between; padding:8px 16px; gap:12px; border-top:1px solid var(--automation-border); } .footer button { background:transparent; border:0; color:var(--automation-muted); padding:8px; font-size:12px; } .feedback { font-size:12px; overflow-wrap:anywhere; } .empty { padding:26px 12px; } .empty p { color:var(--automation-muted); margin-top:8px; } @keyframes reveal { from { opacity:.65; } to { opacity:1; } } @media(max-width:600px) { .masthead { padding:16px; gap:10px; } .filters { grid-template-columns:repeat(2,minmax(0,1fr)); } .ledger { grid-template-columns:minmax(0,1fr); } .detail { border-left:0; border-top:2px solid var(--automation-accent); padding:16px 0 0; } .index ul { max-height:260px; } .status-line { padding:10px 16px; } .body { padding:12px; } h2 { font-size:18px; } } @media(prefers-reduced-motion:reduce) { .body { animation:none; } }
258
+ </style>
259
+ <div class="workspace"><header class="masthead"><div class="brand"><svg viewBox="0 0 40 40" fill="none" aria-hidden="true"><path d="M4 20h9m0 0 7-11h12m-19 11 7 11h12M13 20h19" stroke="currentColor" stroke-width="2"/><circle cx="5" cy="20" r="3" fill="currentColor"/><path d="M30 6h6v6h-6zM30 17h6v6h-6zM30 28h6v6h-6z" stroke="currentColor" stroke-width="2"/></svg><div><p class="eyebrow">Triggers · sources · outcomes</p><h2>Automations</h2></div></div><button class="refresh" data-action="refresh" ${this.phase === 'loading' ? 'disabled' : ''}>Refresh</button></header>
260
+ <div class="status-line" role="status" aria-live="polite"><strong class="${this.phase === 'error' ? 'bad' : ''}">${escape(status)}</strong><small>${this.phase === 'ready' ? 'Checked' : 'Last attempt'} ${escape(date(this.phase === 'ready' ? this.inventory.checked_at : this.attemptedAt))}</small></div>
261
+ <nav class="tabs" role="tablist" aria-label="Automation views">${SCREENS.map((screen) => `<button id="tab-${screen.id}" role="tab" aria-controls="panel-${screen.id}" aria-selected="${this.screen === screen.id}" tabindex="${this.screen === screen.id ? '0' : '-1'}" data-screen="${screen.id}">${screen.label}</button>`).join('')}</nav>
262
+ <div class="body" id="panel-${this.screen}" role="tabpanel" aria-labelledby="tab-${this.screen}" tabindex="0" aria-busy="${this.phase === 'loading'}">${content}</div>
263
+ <footer class="footer"><span class="feedback" role="status">${escape(this.feedback || (this.phase === 'ready' ? 'Coverage determines completeness.' : 'No current inventory.'))}</span><button data-action="copy" ${this.inventory ? '' : 'disabled'}>Copy inventory</button></footer></div>`;
264
+ if (focusedAction) this.shadowRoot.querySelector(`[data-action="${focusedAction}"]`)?.focus();
265
+ }
266
+ }
267
+
268
+ // Card and panel share the same inventory contract.
269
+ export const ChronosAutomationsCard = AutomationsCard;
270
+ export class AutomationsElement extends AutomationsCard {}
271
+ if (!customElements.get('tr-automations-card')) customElements.define('tr-automations-card', AutomationsCard);
272
+ if (!customElements.get('automations-card')) customElements.define('automations-card', class extends AutomationsCard {});
273
+ if (!customElements.get('tr-automations')) customElements.define('tr-automations', AutomationsElement);
@@ -17,7 +17,7 @@ export function findSkillDir(start = process.cwd()) {
17
17
 
18
18
  export function runCore(script, args) {
19
19
  if (!fs.existsSync(stateDir())) {
20
- const error = new Error('No code-quality state in this repository (.agent/skills/code-quality). Create it: total-recall code-quality init');
20
+ const error = new Error('No code-quality state in this repository (.agent/skills/code-quality). Create configuration: total-recall code-quality detect --apply');
21
21
  error.exitCode = 2;
22
22
  throw error;
23
23
  }
@@ -80,7 +80,7 @@ function loadConfig() {
80
80
  if (!existsSync(CONFIG_PATH)) {
81
81
  fail(
82
82
  `No config.json at ${CONFIG_PATH}\n` +
83
- `Run: total-recall skill config code-quality init`
83
+ `Run: total-recall code-quality detect --apply`
84
84
  );
85
85
  }
86
86
  let cfg;
@@ -678,6 +678,51 @@ function runCheck(check) {
678
678
 
679
679
  // ─── Main ─────────────────────────────────────────────────────────────────────
680
680
 
681
+ /**
682
+ * Restore the repository's locked dependencies before any gate runs. Idle repositories have
683
+ * their node_modules removed by the scheduled disk cleanup, and a gate run against a missing
684
+ * tree reports import failures as if the code were broken. Installs exactly what the lockfile
685
+ * pins, only when the installed tree is missing or older than the lockfile.
686
+ */
687
+ const LOCKFILES = [
688
+ { file: 'package-lock.json', manager: 'npm', marker: path.join('node_modules', '.package-lock.json'), args: ['ci', '--no-audit', '--no-fund'] },
689
+ { file: 'pnpm-lock.yaml', manager: 'pnpm', marker: path.join('node_modules', '.modules.yaml'), args: ['install', '--frozen-lockfile'] },
690
+ { file: 'yarn.lock', manager: 'yarn', marker: path.join('node_modules', '.yarn-integrity'), args: ['install', '--frozen-lockfile'] },
691
+ ];
692
+
693
+ function dependencyState(root) {
694
+ if (!existsSync(path.join(root, 'package.json'))) return { action: 'none', reason: 'no package.json' };
695
+ const lock = LOCKFILES.find((entry) => existsSync(path.join(root, entry.file)));
696
+ if (!lock) return { action: 'none', reason: 'no lockfile' };
697
+ const marker = path.join(root, lock.marker);
698
+ const stale = !existsSync(marker) || statSync(marker).mtimeMs < statSync(path.join(root, lock.file)).mtimeMs;
699
+ return { action: stale ? 'install' : 'present', manager: lock.manager, lockfile: lock.file, args: lock.args };
700
+ }
701
+
702
+ function ensureDependencies() {
703
+ const state = dependencyState(REPO_ROOT);
704
+ if (config.installDependencies === false || state.action !== 'install') {
705
+ return { ...state, action: config.installDependencies === false && state.action === 'install' ? 'disabled' : state.action, args: undefined };
706
+ }
707
+ const started = Date.now();
708
+ console.error(` → dependencies: ${state.manager} ${state.args.join(' ')} (${state.lockfile})`);
709
+ return new Promise((resolve) => {
710
+ const child = spawn(state.manager, state.args, { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
711
+ currentChild = child;
712
+ let tail = '';
713
+ const keep = (chunk) => { tail = (tail + chunk).slice(-4000); };
714
+ child.stdout.on('data', keep);
715
+ child.stderr.on('data', keep);
716
+ const done = (code, error) => {
717
+ currentChild = null;
718
+ resolve({ action: code === 0 ? 'installed' : 'failed', manager: state.manager, lockfile: state.lockfile,
719
+ durationMs: Date.now() - started, ...(code === 0 ? {} : { error: error || tail.trim().split('\n').slice(-20).join('\n') }) });
720
+ };
721
+ child.on('error', (err) => done(null, err.message));
722
+ child.on('close', (code) => done(code));
723
+ });
724
+ }
725
+
681
726
  async function main() {
682
727
  const wantTier = TIER_ORDER[TIER];
683
728
  if (!wantTier) fail(`Unknown --tier ${TIER} (expected: fast | full | remote)`);
@@ -709,10 +754,16 @@ async function main() {
709
754
 
710
755
  const startedAt = new Date().toISOString();
711
756
  const startedMs = Date.now();
757
+ const testedHead = gitHead();
712
758
  console.error(` [code-quality] ${config.toolchain} — ${checks.length} check(s) in ${REPO_ROOT}`);
713
759
 
760
+ const dependencies = await ensureDependencies();
761
+ if (dependencies.action === 'failed') {
762
+ console.error(` dependency install failed (${dependencies.manager}, ${dependencies.lockfile}); no gate ran:\n${dependencies.error}`);
763
+ }
764
+
714
765
  const results = [];
715
- for (const check of checks) {
766
+ for (const check of dependencies.action === 'failed' ? [] : checks) {
716
767
  const label = check.kind === 'grep-forbid'
717
768
  ? `grep-forbid (${(check.patterns || []).length} pattern(s))`
718
769
  : (check.cmd || []).join(' ');
@@ -739,15 +790,16 @@ async function main() {
739
790
  tier: TIER,
740
791
  // Coverage is stated explicitly so a partial run can never be mistaken
741
792
  // for a clean full run when someone reads the report later.
742
- ranChecks: checks.map((c) => c.id),
793
+ ranChecks: results.map((r) => r.id),
743
794
  skippedChecks: config.checks.filter((c) => !checks.includes(c)).map((c) => ({ id: c.id, tier: c.tier || 'fast' })),
744
795
  startedAt,
745
796
  finishedAt: new Date().toISOString(),
746
797
  durationMs: Date.now() - startedMs,
747
- gitHead: gitHead(),
798
+ gitHead: testedHead,
748
799
  sourceFileCount: sources.length,
800
+ dependencies,
749
801
  checks: results.map(({ raw, findings: _f, ...meta }) => meta),
750
- infrastructureFailure: results.some((r) => r.spawnError || r.timedOut),
802
+ infrastructureFailure: dependencies.action === 'failed' || results.some((r) => r.spawnError || r.timedOut),
751
803
  totalFindings: findings.length,
752
804
  findings
753
805
  };
@@ -12,6 +12,7 @@
12
12
  * Status, most severe first:
13
13
  * missing no report file
14
14
  * unreadable report file is not valid JSON or has an unknown shape
15
+ * foreign report belongs to a different repository root
15
16
  * infrastructure a gate failed to spawn or timed out; results are incomplete
16
17
  * unparsed-failure a gate exited non-zero with no parseable findings
17
18
  * failed findings or a failing gate
@@ -21,27 +22,34 @@
21
22
  * partial clean, but higher-tier gates were skipped
22
23
  * clean every gate ran, nothing changed since
23
24
  */
24
- import { readFileSync, existsSync, statSync } from 'node:fs';
25
+ import { readFileSync, existsSync, statSync, realpathSync } from 'node:fs';
25
26
  import { execFileSync } from 'node:child_process';
26
27
  import path from 'node:path';
27
28
  import { fileURLToPath } from 'node:url';
29
+ import { stateDir } from './state.mjs';
28
30
 
29
31
  const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.vue', '.svelte']);
30
32
 
31
33
  function git(cwd, args) {
32
- return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 20_000 });
34
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024, timeout: 20_000 });
35
+ }
36
+
37
+ // Resolve the caller's working tree independently of cached report metadata.
38
+ function repositoryRoot(cwd) {
39
+ try { return realpathSync(git(cwd, ['rev-parse', '--show-toplevel']).trim()); }
40
+ catch { return realpathSync(cwd); }
33
41
  }
34
42
 
35
43
  /** Returns { head, changed } or null when git cannot be read. */
36
- function freshness(report) {
44
+ function freshness(report, repoRoot) {
37
45
  try {
38
- const head = git(report.repoRoot, ['rev-parse', 'HEAD']).trim();
46
+ const head = git(repoRoot, ['rev-parse', '--verify', 'HEAD']).trim();
39
47
  const startedMs = Date.parse(report.startedAt);
40
48
  const changed = [];
41
- for (const rel of git(report.repoRoot, ['ls-files', '-z']).split('\0')) {
49
+ for (const rel of git(repoRoot, ['ls-files', '-z']).split('\0')) {
42
50
  if (!rel || !SOURCE_EXTS.has(path.extname(rel))) continue;
43
51
  try {
44
- if (statSync(path.join(report.repoRoot, rel)).mtimeMs > startedMs) changed.push(rel);
52
+ if (statSync(path.join(repoRoot, rel)).mtimeMs > startedMs) changed.push(rel);
45
53
  } catch { /* deleted since the run */ }
46
54
  }
47
55
  return { head, changed };
@@ -50,10 +58,13 @@ function freshness(report) {
50
58
  }
51
59
  }
52
60
 
53
- export function buildReportView(reportFile, { now = Date.now(), inspectGit = true } = {}) {
54
- const base = { reportFile, status: 'missing', reasons: [] };
61
+ export function buildReportView(reportFile, { now = Date.now(), inspectGit = true, cwd = process.cwd() } = {}) {
62
+ const base = { reportFile, status: 'missing', clean: false, reasons: [] };
55
63
  if (!existsSync(reportFile)) {
56
- return { ...base, reasons: ['No report file. Run code-quality-check, then reload.'] };
64
+ const configured = existsSync(path.join(stateDir(cwd), 'config.json'));
65
+ return { ...base, reasons: [configured
66
+ ? 'No report file. Run code-quality-check, then reload.'
67
+ : 'No code-quality configuration. Run total-recall code-quality detect --apply, then run code-quality-check.'] };
57
68
  }
58
69
  let report;
59
70
  try {
@@ -67,6 +78,19 @@ export function buildReportView(reportFile, { now = Date.now(), inspectGit = tru
67
78
  return { ...base, status: 'unreadable', reasons: ['Report does not have the code-quality schema (checks, ranChecks, startedAt).'] };
68
79
  }
69
80
 
81
+ if (typeof report.repoRoot !== 'string' || !path.isAbsolute(report.repoRoot)) {
82
+ return { ...base, status: 'unreadable', reasons: ['Report has no absolute repository root. Run code-quality-check in this repository.'] };
83
+ }
84
+ let repoRoot;
85
+ try {
86
+ repoRoot = repositoryRoot(cwd);
87
+ if (realpathSync(report.repoRoot) !== repoRoot) {
88
+ return { ...base, status: 'foreign', reasons: ['Report belongs to another repository. Run code-quality-check in this repository.'] };
89
+ }
90
+ } catch {
91
+ return { ...base, status: 'unreadable', reasons: ['Cannot verify report repository root. Run code-quality-check in this repository.'] };
92
+ }
93
+
70
94
  const findings = Array.isArray(report.findings) ? report.findings : [];
71
95
  const hard = findings.filter((f) => f.severity !== 'warning' && f.severity !== 'info');
72
96
  const skipped = Array.isArray(report.skippedChecks) ? report.skippedChecks : [];
@@ -87,8 +111,9 @@ export function buildReportView(reportFile, { now = Date.now(), inspectGit = tru
87
111
  (c) => !c.ok && !c.spawnError && !c.timedOut && !findings.some((f) => f.check === c.id)
88
112
  );
89
113
  const zeroScope = report.checks.filter((c) => c.scanned === 0);
90
- const fresh = inspectGit ? freshness(report) : null;
91
- const headMoved = Boolean(fresh && report.gitHead && fresh.head !== report.gitHead);
114
+ const fresh = inspectGit ? freshness(report, repoRoot) : null;
115
+ const revisionKnown = typeof report.gitHead === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(report.gitHead);
116
+ const headMoved = Boolean(fresh && revisionKnown && fresh.head !== report.gitHead);
92
117
 
93
118
  const reasons = [];
94
119
  let status = 'clean';
@@ -102,7 +127,8 @@ export function buildReportView(reportFile, { now = Date.now(), inspectGit = tru
102
127
  }
103
128
  if (report.ranChecks.length === 0) set('zero-scope', 'No gates ran');
104
129
  if (zeroScope.length) set('zero-scope', `${zeroScope.map((c) => c.id).join(', ')}: scanned 0 files`);
105
- if (fresh === null && inspectGit) set('unknown-freshness', 'git could not be read; cannot tell whether this report matches the tree');
130
+ if (!revisionKnown) set('unknown-freshness', 'Report has no verified revision. Run code-quality-check in this repository.');
131
+ if (fresh === null) set('unknown-freshness', 'git freshness could not be verified; cannot tell whether this report matches the tree');
106
132
  else if (fresh && (fresh.changed.length || headMoved)) {
107
133
  set('stale', headMoved
108
134
  ? `HEAD moved from ${String(report.gitHead).slice(0, 8)} to ${fresh.head.slice(0, 8)}`
@@ -116,7 +142,7 @@ export function buildReportView(reportFile, { now = Date.now(), inspectGit = tru
116
142
  reasons,
117
143
  clean: status === 'clean',
118
144
  toolchain: report.toolchain,
119
- repoRoot: report.repoRoot,
145
+ repoRoot,
120
146
  tier: report.tier,
121
147
  gitHead: report.gitHead ?? null,
122
148
  currentHead: fresh?.head ?? null,
@@ -172,7 +198,7 @@ export async function printCachedReport(args, reportFile) {
172
198
  }
173
199
 
174
200
  export function defaultReportFile() {
175
- return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'reports', 'latest.json');
201
+ return path.join(stateDir(), 'reports', 'latest.json');
176
202
  }
177
203
 
178
204
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -17,12 +17,11 @@
17
17
  * report.mjs --review-diagnostics [--json] cached gate and finding details
18
18
  */
19
19
 
20
- import { readFileSync, existsSync, statSync } from 'node:fs';
21
- import { execFileSync } from 'node:child_process';
20
+ import { readFileSync, existsSync } from 'node:fs';
22
21
  import path from 'node:path';
23
22
  import { fileURLToPath } from 'node:url';
24
23
  import { stateDir } from './state.mjs';
25
- import { printCachedReport, wantsStructuredReport } from './report-data.mjs';
24
+ import { buildReportView, printCachedReport, wantsStructuredReport } from './report-data.mjs';
26
25
 
27
26
  const __filename = fileURLToPath(import.meta.url);
28
27
  const __dirname = path.dirname(__filename);
@@ -39,39 +38,15 @@ if (wantsStructuredReport(process.argv.slice(2))) {
39
38
  process.exit(await printCachedReport(process.argv.slice(2), REPORT_JSON));
40
39
  }
41
40
 
42
- if (!existsSync(REPORT_JSON)) {
43
- console.log(' No report yet.\n');
44
- console.log('Run a check as a BACKGROUND job, then come back:');
45
- console.log(` ${CHECK_CMD}`);
46
- process.exit(0);
41
+ const view = buildReportView(REPORT_JSON);
42
+ if (['missing', 'unreadable', 'foreign'].includes(view.status)) {
43
+ console.error(`Code Quality report: ${view.status}\n${view.reasons.join('\n')}`);
44
+ process.exit(1);
47
45
  }
46
+ process.exitCode = view.clean ? 0 : 1;
48
47
 
49
48
  const report = JSON.parse(readFileSync(REPORT_JSON, 'utf8'));
50
- const startedMs = Date.parse(report.startedAt);
51
-
52
- // ─── Staleness: which files changed after the run started? ────────────────────
53
-
54
- function changedSinceRun() {
55
- let files = [];
56
- try {
57
- const out = execFileSync('git', ['ls-files', '-z'], {
58
- cwd: report.repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 20_000
59
- });
60
- const exts = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.vue', '.svelte']);
61
- files = out.split('\0').filter((f) => f && exts.has(path.extname(f)));
62
- } catch {
63
- return null; // cannot determine — say so rather than implying freshness
64
- }
65
- const changed = [];
66
- for (const rel of files) {
67
- try {
68
- if (statSync(path.join(report.repoRoot, rel)).mtimeMs > startedMs) changed.push(rel);
69
- } catch { /* deleted since */ }
70
- }
71
- return changed;
72
- }
73
-
74
- const changed = changedSinceRun();
49
+ const changed = view.changedSinceRun;
75
50
  const ageMin = Math.round((Date.now() - Date.parse(report.finishedAt)) / 60000);
76
51
 
77
52
  // ─── Header ───────────────────────────────────────────────────────────────────
@@ -114,11 +89,11 @@ if (opaque.length) {
114
89
  }
115
90
  }
116
91
 
117
- if (changed === null) {
118
- console.log(` Could not read git — cannot tell whether this report matches your tree.`);
119
- } else if (changed.length === 0) {
92
+ console.log(` Report status: ${view.status}`);
93
+ for (const reason of view.reasons) console.log(` ${reason}`);
94
+ if (changed !== null && changed.length === 0 && view.currentHead === view.gitHead) {
120
95
  console.log(` FRESH — no tracked source file has changed since this run started.`);
121
- } else {
96
+ } else if (changed?.length) {
122
97
  console.log(` STALE for ${changed.length} file(s) edited after the run started:`);
123
98
  for (const f of changed.slice(0, 8)) console.log(` ${f}`);
124
99
  if (changed.length > 8) console.log(` … and ${changed.length - 8} more`);
@@ -146,15 +121,15 @@ const staleMark = (file) => (changed?.includes(file) ? ' ' : '');
146
121
  if (mode === 'raw') {
147
122
  if (!existsSync(REPORT_TXT)) { console.error('No raw output stored.'); process.exit(1); }
148
123
  const raw = readFileSync(REPORT_TXT, 'utf8');
149
- if (!arg) { console.log(raw.slice(0, 20000)); process.exit(0); }
124
+ if (!arg) { console.log(raw.slice(0, 20000)); process.exit(view.clean ? 0 : 1); }
150
125
  const section = raw.split(/^# /m).find((s) => s.startsWith(arg));
151
126
  console.log(section ? `# ${section}`.slice(0, 40000) : `No raw section for check "${arg}".`);
152
- process.exit(section ? 0 : 1);
127
+ process.exit(section && view.clean ? 0 : 1);
153
128
  }
154
129
 
155
130
  if (findings.length === 0) {
156
131
  console.log(' No findings.');
157
- process.exit(0);
132
+ process.exit(view.clean ? 0 : 1);
158
133
  }
159
134
 
160
135
  switch (mode) {