total-recall-brain 3.40.0 → 3.40.2

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 (187) hide show
  1. package/README.md +152 -41
  2. package/bin/total-recall.mjs +16 -76
  3. package/docs/reference/cli-reference.md +10 -10
  4. package/extension/background.js +6 -2
  5. package/extension/config.js +8 -0
  6. package/extension/lib/brain-client.js +29 -4
  7. package/extension/options/options.html +1 -0
  8. package/extension/sidepanel/sidepanel.html +1 -0
  9. package/frontend/dist/assets/{index-CvQRdT2j.css → index-AGUes-VG.css} +1 -1
  10. package/frontend/dist/assets/index-Bt5iVPZj.js +5618 -0
  11. package/frontend/dist/index.html +2 -2
  12. package/frontend/src/api/keys.ts +9 -2
  13. package/frontend/src/api/plugins.ts +4 -0
  14. package/frontend/src/components/plugins/card/CardEngine.tsx +2 -1
  15. package/frontend/src/components/plugins/card/card.css +1 -1
  16. package/frontend/src/pages/IntegrationsPage.tsx +315 -8
  17. package/frontend/src/pages/PluginsPage.tsx +532 -87
  18. package/package.json +6 -5
  19. package/scaffold/.agent/skills/meta-harness/references/optimized/original.md +1 -1
  20. package/scaffold/.agent/skills/meta-harness/references/optimized/section-09.md +1 -1
  21. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +10 -10
  22. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -6
  23. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +0 -1
  24. package/scaffold/.agent/skills/total-recall/plugins/auth/src/browser-session.mjs +309 -4
  25. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +44 -34
  26. package/scaffold/.agent/skills/total-recall/plugins/auth/src/google-oauth.mjs +6 -4
  27. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-catalog.mjs +1 -1
  28. package/scaffold/.agent/skills/total-recall/plugins/auth/src/provider-rotation-recipes.mjs +118 -4
  29. package/scaffold/.agent/skills/total-recall/plugins/auth/src/routes/auth.mjs +2 -2
  30. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +3 -3
  31. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -1
  32. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-rotate.mjs +24 -6
  33. package/scaffold/.agent/skills/total-recall/plugins/auth/ui/card.js +164 -123
  34. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/README.md +65 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/cli.mjs +76 -0
  36. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/package.json +19 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +290 -0
  38. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/adapters/dsh.mjs +446 -0
  39. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/documentation.mjs +152 -0
  40. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/executor.mjs +71 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/runtime.mjs +36 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/setup.mjs +35 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/worker.mjs +175 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +18 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/code-quality/LICENSE +21 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/code-quality/README.md +84 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/code-quality/cli.mjs +79 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/check.mjs +6 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/report.mjs +6 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/run-core.mjs +27 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/snapshot.mjs +163 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/check.mjs +757 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/config.schema.json +86 -0
  54. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/detect.mjs +89 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report-data.mjs +145 -0
  56. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report.mjs +210 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/state.mjs +20 -0
  58. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +2869 -0
  59. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +28 -0
  60. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +186 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/DESIGN.md +18 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/card.js +272 -0
  63. package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/code-quality-report.mjs +129 -0
  64. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/LICENSE +21 -0
  65. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/README.md +73 -0
  66. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/cli.mjs +375 -0
  67. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/examples/commands/echo-tool.mjs +23 -0
  68. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/examples/commands/group.mjs +262 -0
  69. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/examples/commands/project-id.mjs +74 -0
  70. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +2869 -0
  71. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +31 -0
  72. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +221 -0
  73. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/schemas/cli_command.schema.json +107 -0
  74. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/skills/composable-cli/SKILL.md +63 -0
  75. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/command.mjs +313 -0
  76. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/compile.mjs +19 -0
  77. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/compose.mjs +144 -0
  78. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/contract.mjs +110 -0
  79. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/library.mjs +23 -0
  80. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/registry.mjs +192 -0
  81. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/resolve.mjs +221 -0
  82. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/risk.mjs +126 -0
  83. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/src/ssss-documents.mjs +29 -0
  84. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/DESIGN.md +18 -0
  85. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/card.js +273 -0
  86. package/scaffold/.agent/skills/total-recall/plugins/embeddings/LICENSE +21 -0
  87. package/scaffold/.agent/skills/total-recall/plugins/embeddings/README.md +20 -0
  88. package/scaffold/.agent/skills/total-recall/plugins/embeddings/cli.mjs +310 -0
  89. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +2826 -0
  90. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +11 -0
  91. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +62 -0
  92. package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/card.js +297 -0
  93. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/LICENSE +21 -0
  94. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +43 -0
  95. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/cli.mjs +258 -0
  96. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4005 -0
  97. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +20 -0
  98. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +125 -0
  99. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +18 -0
  100. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +278 -0
  101. package/scaffold/.agent/skills/total-recall/plugins/mesh/README.md +9 -0
  102. package/scaffold/.agent/skills/total-recall/plugins/mesh/cli.mjs +6 -0
  103. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +4721 -0
  104. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -21
  105. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +14 -1
  106. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +10 -0
  107. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/headscale-client.mjs +5 -1
  108. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/leader-election.mjs +4 -13
  109. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-access-verify.mjs +22 -0
  110. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh-files.mjs +18 -0
  111. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +2 -0
  112. package/scaffold/.agent/skills/total-recall/plugins/mesh/test-support/memory-root.mjs +14 -0
  113. package/scaffold/.agent/skills/total-recall/plugins/openwiki/LICENSE +21 -0
  114. package/scaffold/.agent/skills/total-recall/plugins/openwiki/README.md +35 -0
  115. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +124 -0
  116. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4097 -0
  117. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +12 -0
  118. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +165 -0
  119. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/ingest.mjs +127 -0
  120. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +18 -0
  121. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +279 -0
  122. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/panel.js +539 -0
  123. package/scaffold/.agent/skills/total-recall/plugins/security/LICENSE +21 -0
  124. package/scaffold/.agent/skills/total-recall/plugins/security/README.md +67 -0
  125. package/scaffold/.agent/skills/total-recall/plugins/security/cli.mjs +932 -0
  126. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3256 -0
  127. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +25 -0
  128. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +297 -0
  129. package/scaffold/.agent/skills/total-recall/plugins/security/scripts/build-ui.mjs +4 -0
  130. package/scaffold/.agent/skills/total-recall/plugins/security/skills/security/SKILL.md +37 -0
  131. package/scaffold/.agent/skills/total-recall/plugins/security/src/android.mjs +21 -0
  132. package/scaffold/.agent/skills/total-recall/plugins/security/src/devices.mjs +94 -0
  133. package/scaffold/.agent/skills/total-recall/plugins/security/src/machines.mjs +164 -0
  134. package/scaffold/.agent/skills/total-recall/plugins/security/src/scanner.mjs +41 -0
  135. package/scaffold/.agent/skills/total-recall/plugins/security/src/windows.mjs +42 -0
  136. package/scaffold/.agent/skills/total-recall/plugins/security/ui/DESIGN.md +18 -0
  137. package/scaffold/.agent/skills/total-recall/plugins/security/ui/card.js +95 -0
  138. package/scaffold/.agent/skills/total-recall/plugins/security/ui/panel.js +98 -0
  139. package/scaffold/.agent/skills/total-recall/references/auth.md +1 -1
  140. package/src/cli/backup.mjs +71 -3
  141. package/src/cli/brief.mjs +33 -9
  142. package/src/cli/command.mjs +2 -7
  143. package/src/cli/config.mjs +1 -0
  144. package/src/cli/connect.mjs +5 -0
  145. package/src/cli/context.mjs +2 -1
  146. package/src/cli/daemon.mjs +36 -1
  147. package/src/cli/doctor.mjs +6 -0
  148. package/src/cli/harness.mjs +3 -0
  149. package/src/cli/help.mjs +26 -0
  150. package/src/cli/init.mjs +4 -4
  151. package/src/cli/memory-entry.mjs +1 -0
  152. package/src/cli/memory-init.mjs +2 -0
  153. package/src/cli/plugin/index.mjs +11 -0
  154. package/src/cli/plugin/install.mjs +2 -1
  155. package/src/cli/rebuild.mjs +3 -1
  156. package/src/cli/startup.mjs +2 -1
  157. package/src/cli/update.mjs +19 -0
  158. package/src/core/automatic-brain-updates.mjs +67 -0
  159. package/src/core/brain-updates.mjs +21 -2
  160. package/src/core/cli-commands.mjs +71 -0
  161. package/src/core/cli-maintenance.mjs +45 -0
  162. package/src/core/cli-quick-reference.mjs +20 -0
  163. package/src/core/cli-semantic-index.mjs +722 -0
  164. package/src/core/context-compiler.mjs +27 -1
  165. package/src/core/crons.mjs +2 -13
  166. package/src/core/daemon-control.mjs +6 -4
  167. package/src/core/daemon-loop.mjs +12 -8
  168. package/src/core/default-plugins.mjs +5 -0
  169. package/src/core/global-brain-updates.mjs +39 -0
  170. package/src/core/mesh-brain-updates.mjs +30 -0
  171. package/src/core/meta-harness.mjs +108 -0
  172. package/src/core/plugin-dependencies.mjs +2 -1
  173. package/src/core/plugin-loader.mjs +2 -1
  174. package/src/core/plugin-runner.mjs +12 -4
  175. package/src/core/plugin-setup.mjs +20 -0
  176. package/src/core/plugin-store.mjs +22 -4
  177. package/src/core/plugin-tasks.mjs +1 -1
  178. package/src/core/project-brain.mjs +2 -0
  179. package/src/core/project-status.mjs +64 -0
  180. package/src/core/required-plugins.mjs +46 -0
  181. package/src/core/scaffold-plugins.mjs +17 -3
  182. package/src/core/source-brain-updates.mjs +47 -0
  183. package/src/core/startup-health.mjs +5 -3
  184. package/src/core/surface.mjs +44 -10
  185. package/src/server/routes/extension.mjs +53 -8
  186. package/templates/onboarding-interview.md +1 -1
  187. package/frontend/dist/assets/index-SOVUlZqm.js +0 -5596
@@ -0,0 +1,757 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * code-quality / core/check.mjs — ONE-SHOT quality check (plugin-owned core).
4
+ *
5
+ * Reads the repo layer's config.json (a projection of the skill_config record
6
+ * that `total-recall skill config code-quality …` maintains) and writes
7
+ * reports/ next to it. Upgrades replace this file; never edit it in a repo.
8
+ *
9
+ * Runs the repo's real typecheck/lint commands exactly once, writes a report,
10
+ * and exits. There is no loop, no PID daemon, and nothing that respawns itself.
11
+ *
12
+ * Designed to be launched as a BACKGROUND job by the agent harness:
13
+ * Bash(command: "node .agent/skills/code-quality/core/check.mjs",
14
+ * run_in_background: true)
15
+ *
16
+ * Why one-shot: a detached forever-loop has no causal relationship to the edit
17
+ * you just made, so its report can only answer "what was true at some point".
18
+ * A run you launched after your edit answers "what is true now". See
19
+ * references/architecture.md.
20
+ *
21
+ * Invariants:
22
+ * - Locking is FAIL-CLOSED. If liveness cannot be verified, the lock is
23
+ * honored and we refuse to start. Refusing is recoverable; two writers
24
+ * competing over one report file are not.
25
+ * - Checks run strictly sequentially, one child process at a time.
26
+ * - Every child is killed on exit, including on SIGINT/SIGTERM.
27
+ */
28
+
29
+ import { spawn, execFileSync } from 'node:child_process';
30
+ import { createHash } from 'node:crypto';
31
+ import {
32
+ readFileSync, writeFileSync, openSync, writeSync, closeSync,
33
+ unlinkSync, existsSync, mkdirSync, statSync, readdirSync, realpathSync
34
+ } from 'node:fs';
35
+ import path from 'node:path';
36
+ import os from 'node:os';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { stateDir } from './state.mjs';
39
+
40
+ const __filename = fileURLToPath(import.meta.url);
41
+ const __dirname = path.dirname(__filename);
42
+ const SKILL_DIR = stateDir();
43
+ // Lock identity: the tail of the path this run was launched through, so a repo
44
+ // shim (scripts/check.mjs importing core/check.mjs) and a direct core launch
45
+ // are each recognized by the process that holds the lock.
46
+ const ENTRY_TAIL = (() => {
47
+ try {
48
+ return realpathSync(process.argv[1]).split(path.sep).slice(-3).join(path.sep);
49
+ } catch {
50
+ return path.join('code-quality', 'core', 'check.mjs');
51
+ }
52
+ })();
53
+
54
+ const argv = process.argv.slice(2);
55
+ const hasFlag = (f) => argv.includes(f);
56
+ const flagValue = (f) => {
57
+ const i = argv.indexOf(f);
58
+ return i >= 0 ? argv[i + 1] : undefined;
59
+ };
60
+
61
+ const STEAL_LOCK = hasFlag('--steal-lock');
62
+ const NO_GLOBAL_LOCK = hasFlag('--no-global-lock');
63
+ const ONLY = flagValue('--only');
64
+ const TIER = flagValue('--tier') || 'fast';
65
+
66
+ // Tiers keep the default run laptop-safe. Each repo decides what belongs in
67
+ // `fast` (cheap, local), `full` (conformance suites, test runs), and `remote`
68
+ // (work that must run on a dedicated test machine, never a small laptop).
69
+ const TIER_ORDER = { fast: 1, full: 2, remote: 3 };
70
+
71
+ // 'git' normally; 'filesystem' when the workspace has no .git (e.g. an rsynced
72
+ // remote test copy). grep gates then walk the tree instead of scanning nothing.
73
+ let sourceInventoryMode = 'git';
74
+
75
+ // ─── Config ───────────────────────────────────────────────────────────────────
76
+
77
+ const CONFIG_PATH = path.join(SKILL_DIR, 'config.json');
78
+
79
+ function loadConfig() {
80
+ if (!existsSync(CONFIG_PATH)) {
81
+ fail(
82
+ `No config.json at ${CONFIG_PATH}\n` +
83
+ `Run: total-recall skill config code-quality init`
84
+ );
85
+ }
86
+ let cfg;
87
+ try {
88
+ cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
89
+ } catch (err) {
90
+ fail(`config.json is not valid JSON: ${err.message}`);
91
+ }
92
+ if (!Array.isArray(cfg.checks) || cfg.checks.length === 0) {
93
+ fail('config.json defines no checks. Add one: total-recall skill config code-quality gate add …');
94
+ }
95
+ // Validate up front so a malformed check fails loudly at startup rather than
96
+ // halfway through a run that has already burned minutes of compile time.
97
+ const problems = [];
98
+ const seen = new Set();
99
+ cfg.checks.forEach((c, i) => {
100
+ const where = `checks[${i}]${c.id ? ` (${c.id})` : ''}`;
101
+ if (!c.id) problems.push(`${where}: missing "id"`);
102
+ else if (seen.has(c.id)) problems.push(`${where}: duplicate id`);
103
+ else seen.add(c.id);
104
+ if (c.kind === 'grep-forbid') {
105
+ if (!Array.isArray(c.patterns) || c.patterns.length === 0) problems.push(`${where}: grep-forbid needs a non-empty "patterns" array`);
106
+ else c.patterns.forEach((p, j) => {
107
+ if (!p.pattern) problems.push(`${where}.patterns[${j}]: missing "pattern"`);
108
+ else if (/^\(\?[a-z]+\)/.test(p.pattern)) {
109
+ problems.push(`${where}.patterns[${j}]: JS RegExp has no inline (?i) flags — use "flags": "i" instead`);
110
+ } else {
111
+ try { new RegExp(p.pattern, p.flags || ''); }
112
+ catch (e) { problems.push(`${where}.patterns[${j}]: invalid regex — ${e.message}`); }
113
+ }
114
+ });
115
+ } else if (!Array.isArray(c.cmd) || c.cmd.length === 0) {
116
+ problems.push(`${where}: needs "cmd" (array) or kind:"grep-forbid"`);
117
+ }
118
+ if (c.tier && !['fast', 'full', 'remote'].includes(c.tier)) problems.push(`${where}: unknown tier "${c.tier}"`);
119
+ });
120
+ if (problems.length) fail(`config.json is invalid:\n - ${problems.join('\n - ')}`);
121
+ return cfg;
122
+ }
123
+
124
+ function fail(msg, code = 1) {
125
+ console.error(` [code-quality] ${msg}`);
126
+ process.exit(code);
127
+ }
128
+
129
+ const config = loadConfig();
130
+ const REPO_ROOT = path.resolve(
131
+ flagValue('--repo') || config.repoRoot || path.resolve(SKILL_DIR, '../../..')
132
+ );
133
+ const REPORT_DIR = path.join(SKILL_DIR, 'reports');
134
+ const REPORT_JSON = path.join(REPORT_DIR, 'latest.json');
135
+ const REPORT_TXT = path.join(REPORT_DIR, 'latest.txt');
136
+ const LOCK_PATH = path.join(SKILL_DIR, '.check.lock');
137
+ const GLOBAL_LOCK_PATH = path.join(os.homedir(), '.agent', 'skills', 'code-quality', '.global-check.lock');
138
+ const STALE_AFTER_MS = (config.lock?.staleAfterMinutes ?? 45) * 60_000;
139
+
140
+ // ─── Locking (fail-closed) ────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * A lock is honored only when the recorded PID is alive AND its command line
144
+ * still looks like this program. If the command line cannot be read at all,
145
+ * we honor the lock rather than assuming it is stale — the opposite of the
146
+ * v2 daemon, whose `ps` scan failed with EPERM on every run and started anyway.
147
+ */
148
+ function holderState(meta) {
149
+ if (!meta || typeof meta.pid !== 'number') return 'unverifiable';
150
+ try {
151
+ process.kill(meta.pid, 0);
152
+ } catch (err) {
153
+ if (err.code === 'ESRCH') return 'dead';
154
+ if (err.code === 'EPERM') return 'alive-foreign'; // exists, owned by someone else
155
+ return 'unverifiable';
156
+ }
157
+ let cmdline;
158
+ try {
159
+ cmdline = execFileSync('ps', ['-p', String(meta.pid), '-o', 'command='], {
160
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000
161
+ });
162
+ } catch {
163
+ return 'unverifiable'; // <- fail CLOSED
164
+ }
165
+ return cmdline.includes(meta.entryTail ?? ENTRY_TAIL) ? 'alive' : 'recycled';
166
+ }
167
+
168
+ function acquireLock(lockPath, label) {
169
+ mkdirSync(path.dirname(lockPath), { recursive: true });
170
+ const meta = {
171
+ pid: process.pid,
172
+ entryTail: ENTRY_TAIL,
173
+ skillDir: SKILL_DIR,
174
+ repoRoot: REPO_ROOT,
175
+ startedAt: new Date().toISOString(),
176
+ host: os.hostname()
177
+ };
178
+ const payload = JSON.stringify(meta, null, 2);
179
+
180
+ const tryCreate = () => {
181
+ const fd = openSync(lockPath, 'wx'); // O_CREAT|O_EXCL — atomic
182
+ writeSync(fd, payload);
183
+ closeSync(fd);
184
+ };
185
+
186
+ try {
187
+ tryCreate();
188
+ return meta;
189
+ } catch (err) {
190
+ if (err.code !== 'EEXIST') throw err;
191
+ }
192
+
193
+ let held = null;
194
+ try {
195
+ held = JSON.parse(readFileSync(lockPath, 'utf8'));
196
+ } catch {
197
+ held = null;
198
+ }
199
+
200
+ const state = holderState(held);
201
+ const ageMs = held?.startedAt ? Date.now() - Date.parse(held.startedAt) : Infinity;
202
+ const expired = Number.isFinite(ageMs) && ageMs > STALE_AFTER_MS;
203
+
204
+ if (STEAL_LOCK && state === 'alive') {
205
+ // The machine-wide lock is shared by every repo on this host. A verified
206
+ // live holder is another repo's (or this repo's) real run: stealing its
207
+ // lock, or killing it, silently destroys that run's result.
208
+ fail(
209
+ `--steal-lock refused: the ${label} lock is held by a verified running check ` +
210
+ `(pid ${held.pid}, ${held.repoRoot ?? 'unknown repo'}, started ${held.startedAt ?? 'unknown'}).\n` +
211
+ ` Wait for it to finish, or stop that process yourself if you own it.`,
212
+ 3
213
+ );
214
+ } else if (STEAL_LOCK) {
215
+ console.error(` [code-quality] --steal-lock: taking ${label} lock from pid ${held?.pid ?? '?'} (state=${state}).`);
216
+ } else if (state === 'dead' || state === 'recycled') {
217
+ console.error(` [code-quality] Clearing stale ${label} lock (pid ${held?.pid ?? '?'}, state=${state}).`);
218
+ } else if (state === 'unverifiable' && expired) {
219
+ console.error(` [code-quality] Clearing ${label} lock: unverifiable and older than ${STALE_AFTER_MS / 60000}min.`);
220
+ } else {
221
+ fail(
222
+ `A check is already running (${label} lock, pid ${held?.pid ?? '?'}, state=${state}, ` +
223
+ `started ${held?.startedAt ?? 'unknown'}).\n` +
224
+ ` This is deliberate: only one check may run at a time.\n` +
225
+ ` If you are certain it is dead: node ${path.relative(process.cwd(), __filename)} --steal-lock`,
226
+ 3
227
+ );
228
+ }
229
+
230
+ try { unlinkSync(lockPath); } catch { /* raced away */ }
231
+ tryCreate();
232
+ return meta;
233
+ }
234
+
235
+ const heldLocks = [];
236
+ function releaseLocks() {
237
+ for (const lockPath of heldLocks.splice(0)) {
238
+ try {
239
+ const meta = JSON.parse(readFileSync(lockPath, 'utf8'));
240
+ if (meta.pid !== process.pid) continue; // never delete someone else's lock
241
+ } catch { /* unreadable — fall through and remove our own best guess */ }
242
+ try { unlinkSync(lockPath); } catch { /* already gone */ }
243
+ }
244
+ }
245
+
246
+ // ─── Child process lifecycle ──────────────────────────────────────────────────
247
+
248
+ let currentChild = null;
249
+ let shuttingDown = false;
250
+
251
+ function killCurrentChild(signal = 'SIGTERM') {
252
+ if (!currentChild || currentChild.killed) return;
253
+ try { process.kill(-currentChild.pid, signal); } catch { /* no group */ }
254
+ try { currentChild.kill(signal); } catch { /* already gone */ }
255
+ }
256
+
257
+ function shutdown(code) {
258
+ if (shuttingDown) return;
259
+ shuttingDown = true;
260
+ killCurrentChild('SIGTERM');
261
+ setTimeout(() => killCurrentChild('SIGKILL'), 2000).unref();
262
+ releaseLocks();
263
+ process.exit(code);
264
+ }
265
+
266
+ process.on('exit', releaseLocks);
267
+ process.on('SIGINT', () => shutdown(130));
268
+ process.on('SIGTERM', () => shutdown(143));
269
+ process.on('uncaughtException', (err) => {
270
+ console.error(` [code-quality] ${err.stack || err.message}`);
271
+ shutdown(1);
272
+ });
273
+
274
+ // ─── Parsers ──────────────────────────────────────────────────────────────────
275
+
276
+ const PARSERS = {
277
+ tsc(line) {
278
+ const m = line.match(/^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.*)$/);
279
+ return m && { file: m[1].trim(), line: +m[2], col: +m[3], code: m[4], message: m[5].trim() };
280
+ },
281
+ 'eslint-stylish'(line, state) {
282
+ const header = line.match(/^(\/.*\S)\s*$/);
283
+ if (header && !line.includes(':')) { state.file = header[1]; return null; }
284
+ if (/^\s*\//.test(line) && !/\d+:\d+/.test(line)) { state.file = line.trim(); return null; }
285
+ const m = line.match(/^\s*(\d+):(\d+)\s+(error|warning)\s+(.*?)\s\s+([\w@/-]+)\s*$/);
286
+ if (m && state.file) {
287
+ return { file: state.file, line: +m[1], col: +m[2], code: m[5], message: m[4].trim(), severity: m[3] };
288
+ }
289
+ return null;
290
+ },
291
+ 'eslint-compact'(line) {
292
+ const m = line.match(/^(.+?):\s*line\s+(\d+),\s*col\s+(\d+),\s*(Error|Warning)\s*-\s*(.*?)(?:\s+\(([\w@/-]+)\))?$/i);
293
+ return m && {
294
+ file: m[1].trim(), line: +m[2], col: +m[3],
295
+ code: m[6] || 'eslint', message: m[5].trim(), severity: m[4].toLowerCase()
296
+ };
297
+ },
298
+ flake8(line) {
299
+ const m = line.match(/^(.+?):(\d+):(\d+):\s+([A-Z]+\d+)\s+(.*)$/);
300
+ return m && { file: m[1].trim(), line: +m[2], col: +m[3], code: m[4], message: m[5].trim() };
301
+ },
302
+ mypy(line) {
303
+ const m = line.match(/^(.+?):(\d+):(?:(\d+):)?\s+error:\s+(.*?)(?:\s+\[([\w-]+)\])?$/);
304
+ return m && {
305
+ file: m[1].trim(), line: +m[2], col: m[3] ? +m[3] : 0,
306
+ code: m[5] || 'mypy', message: m[4].trim()
307
+ };
308
+ },
309
+ /**
310
+ * Biome, via `--reporter=github`. Biome's default pretty output is a
311
+ * multi-line box drawing that also truncates at --max-diagnostics, so a gate
312
+ * parsing it would silently under-report. The github reporter is one
313
+ * fully-structured line per diagnostic:
314
+ *
315
+ * ::warning title=lint/style/useConst,file=a.ts,line=3,endLine=3,col=7,endColumn=9::Use const.
316
+ *
317
+ * Always pair it with --max-diagnostics=none.
318
+ */
319
+ biome(line) {
320
+ const m = line.match(/^::(error|warning|notice)\s+(.+?)::(.*)$/);
321
+ if (!m) return null;
322
+ const attrs = {};
323
+ for (const kv of m[2].split(',')) {
324
+ const i = kv.indexOf('=');
325
+ if (i > 0) attrs[kv.slice(0, i)] = kv.slice(i + 1);
326
+ }
327
+ return {
328
+ file: attrs.file || '?',
329
+ line: +(attrs.line || 0),
330
+ col: +(attrs.col || 0),
331
+ code: attrs.title || 'biome',
332
+ message: m[3].trim(),
333
+ severity: m[1] === 'notice' ? 'info' : m[1]
334
+ };
335
+ },
336
+ black(line) {
337
+ const m = line.match(/^would reformat\s+(.+)$/);
338
+ return m && { file: m[1].trim(), line: 1, col: 1, code: 'BLACK-FORMAT', message: 'File needs black formatting.' };
339
+ },
340
+ generic(line) {
341
+ const m = line.match(/^(.+?):(\d+):(?:(\d+):)?\s*(?:error|ERROR)[:\s]+(.*)$/);
342
+ return m && { file: m[1].trim(), line: +m[2], col: m[3] ? +m[3] : 0, code: 'error', message: m[4].trim() };
343
+ }
344
+ };
345
+
346
+ function parseOutput(text, parserName, checkId) {
347
+ const parse = PARSERS[parserName] || PARSERS.generic;
348
+ const state = {};
349
+ const findings = [];
350
+ for (const raw of text.split('\n')) {
351
+ const line = raw.replace(/\[[0-9;]*m/g, '').trimEnd();
352
+ if (!line) continue;
353
+ let hit;
354
+ try { hit = parse(line, state); } catch { hit = null; }
355
+ if (hit) findings.push({ ...hit, check: checkId, severity: hit.severity || 'error' });
356
+ }
357
+ return findings;
358
+ }
359
+
360
+ // ─── Source fingerprint (causality) ───────────────────────────────────────────
361
+
362
+ /**
363
+ * The report records when the run STARTED. report.mjs compares that against the
364
+ * mtime of every tracked source file; anything modified after the start is
365
+ * flagged as not-yet-covered. This is what makes "is this report stale?" a
366
+ * question with a real answer instead of a warning banner.
367
+ */
368
+ /**
369
+ * Tracked files, filtered to an extension set.
370
+ *
371
+ * `extensions` exists because this used to filter by the GLOBAL
372
+ * config.sourceExtensions only, so a check declaring its own extensions could
373
+ * merely narrow that set and never widen it. A grep-forbid rule over .md/.yaml
374
+ * therefore matched zero files and reported a clean pass — a gate that scans
375
+ * nothing reads exactly like a gate that found nothing.
376
+ */
377
+ function trackedSources(extensions) {
378
+ const exts = new Set(extensions || config.sourceExtensions || ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.vue', '.svelte']);
379
+ try {
380
+ const out = execFileSync('git', ['ls-files', '-z'], {
381
+ cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 20_000,
382
+ stdio: ['ignore', 'pipe', 'ignore']
383
+ });
384
+ return out.split('\0').filter((f) => f && exts.has(path.extname(f)));
385
+ } catch {
386
+ // No git metadata (an rsynced test copy): walk the tree so grep gates fail
387
+ // closed instead of scanning zero files.
388
+ sourceInventoryMode = 'filesystem';
389
+ const skipped = new Set(['.git', '.next', '.pnpm-store', '.turbo', '.venv', '__pycache__', 'coverage', 'dist', 'node_modules', 'playwright-report', 'test-results']);
390
+ const results = [];
391
+ const walk = (dir, prefix = '') => {
392
+ let entries;
393
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
394
+ for (const entry of entries) {
395
+ if (entry.isDirectory() && skipped.has(entry.name)) continue;
396
+ const rel = prefix ? path.join(prefix, entry.name) : entry.name;
397
+ if (entry.isDirectory()) walk(path.join(dir, entry.name), rel);
398
+ else if (entry.isFile() && exts.has(path.extname(entry.name))) results.push(rel);
399
+ }
400
+ };
401
+ walk(REPO_ROOT);
402
+ return results;
403
+ }
404
+ }
405
+
406
+ function gitHead() {
407
+ try {
408
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
409
+ cwd: REPO_ROOT, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore']
410
+ }).trim();
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
415
+
416
+ function commandExists(name) {
417
+ return (process.env.PATH || '').split(path.delimiter).some((dir) => {
418
+ try { return dir && statSync(path.join(dir, name)).isFile(); } catch { return false; }
419
+ });
420
+ }
421
+
422
+ /** `${REPO_ROOT}` and `${env:NAME}` in configured env values. */
423
+ function expandEnv(env) {
424
+ if (!env) return {};
425
+ return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, String(value)
426
+ .replaceAll('${REPO_ROOT}', REPO_ROOT)
427
+ .replace(/\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => process.env[name] || '')]));
428
+ }
429
+
430
+ // ─── Runner ───────────────────────────────────────────────────────────────────
431
+
432
+ /**
433
+ * grep-forbid: fails when a forbidden pattern appears in tracked source.
434
+ * This is how the repo's own written invariants become enforceable gates —
435
+ * e.g. "never add @ts-nocheck to silence the typechecker".
436
+ */
437
+ /**
438
+ * Files this gate should look at.
439
+ *
440
+ * scope: "all" (default) every tracked source file
441
+ * scope: "changed" only files modified vs `baseline` (default origin/HEAD)
442
+ *
443
+ * "changed" exists for going-forward naming/style invariants on codebases with
444
+ * large pre-existing violation sets, where a blanket rename would break a live
445
+ * API or schema. The rule still fails the moment new code violates it; the
446
+ * historical backlog is a tracked migration, not a permanently red gate.
447
+ * Never use it for correctness gates (types, lint, bypass) — those must stay
448
+ * repo-wide.
449
+ */
450
+ function grepScopeFiles(check) {
451
+ const all = trackedSources(check.extensions);
452
+ if ((check.scope || 'all') !== 'changed') return all;
453
+ // Without git there is no diff: scan everything, then subtract the committed
454
+ // violation baseline (fallbackViolationBaseline) if the check declares one.
455
+ if (sourceInventoryMode === 'filesystem') return all;
456
+ const baseline = check.baseline || 'origin/HEAD';
457
+ let changed;
458
+ try {
459
+ const out = execFileSync('git', ['diff', '--name-only', '--diff-filter=d', baseline], {
460
+ cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 20_000,
461
+ stdio: ['ignore', 'pipe', 'ignore']
462
+ });
463
+ changed = new Set(out.split('\n').map((s) => s.trim()).filter(Boolean));
464
+ } catch {
465
+ // Cannot resolve the baseline — fail safe by checking everything rather
466
+ // than silently checking nothing.
467
+ console.error(` [code-quality] ${check.id}: baseline "${baseline}" unresolvable; falling back to full scope.`);
468
+ return all;
469
+ }
470
+ return all.filter((f) => changed.has(f));
471
+ }
472
+
473
+ /**
474
+ * scope:"changed" on a workspace without git scans every file. A committed
475
+ * baseline (per-file finding count + snippet hash) then drops files whose
476
+ * violations are unchanged, so only new violations fail. Any mismatch or an
477
+ * unreadable baseline keeps every finding (fail closed).
478
+ */
479
+ function applyFallbackViolationBaseline(check, findings) {
480
+ if ((check.scope || 'all') !== 'changed' || sourceInventoryMode !== 'filesystem' || !check.fallbackViolationBaseline) {
481
+ return findings;
482
+ }
483
+ let baseline;
484
+ try {
485
+ baseline = JSON.parse(readFileSync(path.resolve(SKILL_DIR, check.fallbackViolationBaseline), 'utf8'));
486
+ } catch (err) {
487
+ console.error(` [code-quality] ${check.id}: fallback violation baseline unreadable (${err.message}); retaining every finding.`);
488
+ return findings;
489
+ }
490
+ if (baseline?.schema !== 1 || baseline?.rule !== check.patterns?.[0]?.code || !baseline?.files || typeof baseline.files !== 'object') {
491
+ console.error(` [code-quality] ${check.id}: fallback violation baseline has an invalid schema; retaining every finding.`);
492
+ return findings;
493
+ }
494
+ const grouped = new Map();
495
+ for (const finding of findings) grouped.set(finding.file, [...(grouped.get(finding.file) || []), finding]);
496
+ const retained = [];
497
+ for (const [file, group] of grouped) {
498
+ const sha256 = createHash('sha256').update(group.map((f) => f.snippet).sort().join('\n')).digest('hex');
499
+ const expected = baseline.files[file];
500
+ if (expected?.count === group.length && expected?.sha256 === sha256) continue;
501
+ retained.push(...group);
502
+ }
503
+ return retained;
504
+ }
505
+
506
+ function runGrepForbid(check) {
507
+ let findings = [];
508
+ const patterns = (check.patterns || []).map((p) => ({
509
+ re: new RegExp(p.pattern, p.flags || ''), message: p.message, code: p.code || 'FORBIDDEN'
510
+ }));
511
+ const exts = new Set(check.extensions || config.sourceExtensions || ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py']);
512
+ const ignore = (check.ignorePaths || []).map((p) => new RegExp(p));
513
+ // Optional allowlist: when present, ONLY files matching one of these path
514
+ // regexes are scanned. Needed for a rule that applies to one subtree with
515
+ // different extensions than the repo-wide rule — expressing "scaffold/ only"
516
+ // with ignorePaths alone would mean listing every other directory.
517
+ const only = (check.paths || []).map((p) => new RegExp(p));
518
+
519
+ const inScope = (rel) => (!only.length || only.some((re) => re.test(rel)))
520
+ && exts.has(path.extname(rel)) && !ignore.some((re) => re.test(rel));
521
+ // The empty-scope guard asks whether this rule could EVER match a file. For
522
+ // scope:"changed", an empty diff is a clean result, not a misconfiguration.
523
+ const eligible = trackedSources(check.extensions).filter(inScope).length;
524
+
525
+ let scanned = 0;
526
+ for (const rel of grepScopeFiles(check)) {
527
+ if (!inScope(rel)) continue;
528
+ let text;
529
+ try { text = readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { continue; }
530
+ scanned++;
531
+ const lines = text.split('\n');
532
+ for (const { re, message, code } of patterns) {
533
+ for (let i = 0; i < lines.length; i++) {
534
+ if (re.test(lines[i])) {
535
+ findings.push({
536
+ file: rel, line: i + 1, col: 1, code, message,
537
+ check: check.id, severity: 'error', snippet: lines[i].trim().slice(0, 160)
538
+ });
539
+ }
540
+ }
541
+ }
542
+ }
543
+ if (eligible === 0) {
544
+ // Never report clean for a rule that looked at nothing — that is how a
545
+ // misconfigured extension list turns an invariant into decoration.
546
+ findings.push({
547
+ file: '.agent/skills/code-quality/config.json', line: 1, col: 1,
548
+ code: 'CQ-EMPTY-SCOPE', check: check.id, severity: 'error',
549
+ message: `grep-forbid check "${check.id}" matched 0 files — check its extensions/paths/ignorePaths.`,
550
+ snippet: `extensions=${JSON.stringify(check.extensions || null)} paths=${JSON.stringify(check.paths || null)}`
551
+ });
552
+ }
553
+
554
+ findings = applyFallbackViolationBaseline(check, findings);
555
+
556
+ return Promise.resolve({
557
+ id: check.id, ok: findings.length === 0, exitCode: findings.length ? 1 : 0,
558
+ timedOut: false, durationMs: 0, findings, scanned,
559
+ raw: findings.map((f) => `${f.file}:${f.line}: ${f.code} ${f.message}`).join('\n')
560
+ });
561
+ }
562
+
563
+ function runCheck(check) {
564
+ if (check.kind === 'grep-forbid') return runGrepForbid(check);
565
+ return new Promise((resolve) => {
566
+ let [cmd, ...args] = check.cmd;
567
+ if (cmd === 'pnpm' && !commandExists('pnpm')) {
568
+ // Test hosts often lack a global pnpm; run the repo's pinned one via npx.
569
+ let packageManager = 'pnpm';
570
+ try {
571
+ const manifest = JSON.parse(readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
572
+ if (typeof manifest.packageManager === 'string' && manifest.packageManager.startsWith('pnpm@')) packageManager = manifest.packageManager;
573
+ } catch { /* default pnpm */ }
574
+ args = ['--yes', packageManager, ...args];
575
+ cmd = 'npx';
576
+ }
577
+ const cwd = path.resolve(REPO_ROOT, check.cwd || '.');
578
+ const timeoutMs = (check.timeoutSeconds ?? config.timeoutSeconds ?? 900) * 1000;
579
+ const heapMb = config.memory?.maxOldSpaceMb;
580
+
581
+ const started = Date.now();
582
+ let output = '';
583
+ let timedOut = false;
584
+
585
+ const child = spawn(cmd, args, {
586
+ cwd,
587
+ detached: process.platform !== 'win32', // own process group => killable as a unit
588
+ env: {
589
+ ...process.env,
590
+ ...(heapMb ? { NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=${heapMb}`.trim() } : {}),
591
+ ...expandEnv(config.env), // repo-wide env, e.g. PYTHONPATH=${REPO_ROOT}
592
+ ...expandEnv(check.env), // per-check env, e.g. SSSS_ALLOW_AUTO_PROVISION=true
593
+ FORCE_COLOR: '0',
594
+ NO_COLOR: '1'
595
+ },
596
+ stdio: ['ignore', 'pipe', 'pipe']
597
+ });
598
+ currentChild = child;
599
+
600
+ const timer = setTimeout(() => {
601
+ timedOut = true;
602
+ killCurrentChild('SIGTERM');
603
+ setTimeout(() => killCurrentChild('SIGKILL'), 5000).unref();
604
+ }, timeoutMs);
605
+ timer.unref();
606
+
607
+ child.stdout.on('data', (d) => { output += d.toString(); });
608
+ child.stderr.on('data', (d) => { output += d.toString(); });
609
+
610
+ child.on('error', (err) => {
611
+ clearTimeout(timer);
612
+ currentChild = null;
613
+ resolve({
614
+ id: check.id, ok: false, spawnError: err.message, durationMs: Date.now() - started,
615
+ findings: [], raw: '', timedOut: false
616
+ });
617
+ });
618
+
619
+ child.on('close', (code) => {
620
+ clearTimeout(timer);
621
+ currentChild = null;
622
+ const findings = parseOutput(output, check.parser, check.id);
623
+ if (code !== 0 && findings.length === 0 && !timedOut) {
624
+ // The tool failed but printed nothing the parser recognizes. Surface it
625
+ // as a finding so the report names it; `opaque` keeps exit code 2.
626
+ findings.push({
627
+ file: path.relative(REPO_ROOT, CONFIG_PATH), line: 1, col: 1,
628
+ code: `${check.id.toUpperCase()}-FAIL`, check: check.id, severity: 'error', opaque: true,
629
+ message: `${check.id} exited ${code} with no parsed findings. Inspect: node ${path.relative(process.cwd(), path.join(__dirname, 'report.mjs'))} raw ${check.id}`,
630
+ snippet: output.trim().split('\n').filter(Boolean).slice(-3).join(' ').slice(0, 160)
631
+ });
632
+ }
633
+ resolve({
634
+ id: check.id,
635
+ ok: code === 0 && !timedOut,
636
+ exitCode: code,
637
+ timedOut,
638
+ durationMs: Date.now() - started,
639
+ findings,
640
+ raw: output.slice(-200_000)
641
+ });
642
+ });
643
+ });
644
+ }
645
+
646
+ // ─── Main ─────────────────────────────────────────────────────────────────────
647
+
648
+ async function main() {
649
+ const wantTier = TIER_ORDER[TIER];
650
+ if (!wantTier) fail(`Unknown --tier ${TIER} (expected: fast | full | remote)`);
651
+
652
+ const checks = ONLY
653
+ ? config.checks.filter((c) => c.id === ONLY)
654
+ : config.checks.filter((c) => (TIER_ORDER[c.tier || 'fast'] ?? 1) <= wantTier);
655
+
656
+ if (checks.length === 0) {
657
+ fail(ONLY ? `No check matches --only ${ONLY}` : `No checks at tier "${TIER}" or below.`);
658
+ }
659
+
660
+ const skipped = config.checks.length - checks.length;
661
+ if (skipped > 0 && !ONLY) {
662
+ // Never let a bounded run look like full coverage.
663
+ const names = config.checks
664
+ .filter((c) => (TIER_ORDER[c.tier || 'fast'] ?? 1) > wantTier)
665
+ .map((c) => `${c.id}(${c.tier})`)
666
+ .join(', ');
667
+ console.error(`ℹ [code-quality] tier=${TIER}: skipping ${skipped} higher-tier check(s): ${names}`);
668
+ }
669
+
670
+ if (config.globalLock !== false && !NO_GLOBAL_LOCK) {
671
+ acquireLock(GLOBAL_LOCK_PATH, 'machine-wide');
672
+ heldLocks.push(GLOBAL_LOCK_PATH);
673
+ }
674
+ acquireLock(LOCK_PATH, 'repo');
675
+ heldLocks.push(LOCK_PATH);
676
+
677
+ const startedAt = new Date().toISOString();
678
+ const startedMs = Date.now();
679
+ console.error(` [code-quality] ${config.toolchain} — ${checks.length} check(s) in ${REPO_ROOT}`);
680
+
681
+ const results = [];
682
+ for (const check of checks) {
683
+ const label = check.kind === 'grep-forbid'
684
+ ? `grep-forbid (${(check.patterns || []).length} pattern(s))`
685
+ : (check.cmd || []).join(' ');
686
+ console.error(` → ${check.id}: ${label}`);
687
+ const r = await runCheck(check);
688
+ // Mirror the exit contract: means nothing here blocks a push. Warnings
689
+ // are counted separately so a green gate with warnings does not read as
690
+ // failing, and a failing gate never reads as green.
691
+ const soft = r.findings.filter((f) => f.severity === 'warning' || f.severity === 'info').length;
692
+ const hard = r.findings.length - soft;
693
+ const note = r.spawnError ? `spawn failed: ${r.spawnError}`
694
+ : r.timedOut ? 'TIMED OUT'
695
+ : `${hard} error(s)${soft ? `, ${soft} warning(s)` : ''}, exit ${r.exitCode}`;
696
+ console.error(` ${r.ok && !hard ? '' : ' '} ${note} (${(r.durationMs / 1000).toFixed(1)}s)`);
697
+ results.push(r);
698
+ }
699
+
700
+ const findings = results.flatMap((r) => r.findings);
701
+ const sources = trackedSources();
702
+ const report = {
703
+ schema: 3,
704
+ toolchain: config.toolchain,
705
+ repoRoot: REPO_ROOT,
706
+ tier: TIER,
707
+ // Coverage is stated explicitly so a partial run can never be mistaken
708
+ // for a clean full run when someone reads the report later.
709
+ ranChecks: checks.map((c) => c.id),
710
+ skippedChecks: config.checks.filter((c) => !checks.includes(c)).map((c) => ({ id: c.id, tier: c.tier || 'fast' })),
711
+ startedAt,
712
+ finishedAt: new Date().toISOString(),
713
+ durationMs: Date.now() - startedMs,
714
+ gitHead: gitHead(),
715
+ sourceFileCount: sources.length,
716
+ checks: results.map(({ raw, findings: _f, ...meta }) => meta),
717
+ infrastructureFailure: results.some((r) => r.spawnError || r.timedOut),
718
+ totalFindings: findings.length,
719
+ findings
720
+ };
721
+
722
+ mkdirSync(REPORT_DIR, { recursive: true });
723
+ writeFileSync(REPORT_JSON, JSON.stringify(report, null, 2));
724
+ writeFileSync(REPORT_TXT, results.map((r) => `# ${r.id}\n${r.raw.trim()}`).join('\n\n'));
725
+
726
+ console.error(
727
+ ` [code-quality] ${report.totalFindings} finding(s) in ${(report.durationMs / 1000).toFixed(1)}s ` +
728
+ `— read with: node ${path.relative(process.cwd(), path.join(__dirname, 'report.mjs'))}`
729
+ );
730
+
731
+ // Exit contract — meaningful enough to gate a push on:
732
+ // 0 = every gate that ran passed cleanly
733
+ // 1 = findings present (the normal "there is work to do" case)
734
+ // 2 = infrastructure failure, or a gate failed in a way we could not parse
735
+ // (spawn error, timeout, or non-zero exit with zero findings — that
736
+ // last one is the dangerous case: it must never read as clean)
737
+ //
738
+ // A gate's verdict is the TOOL's verdict. Linters routinely exit 0 while
739
+ // emitting warnings; turning those into a red gate would mean the gate
740
+ // disagrees with the tool it wraps, and a gate nobody can get green stops
741
+ // being a gate. So warnings/infos are reported but do not fail the run on
742
+ // their own — a non-zero exit from the tool still does.
743
+ const opaqueFailure = results.some(
744
+ (r) => !r.ok && !r.spawnError && !r.timedOut && r.findings.every((f) => f.opaque)
745
+ );
746
+ const toolFailed = results.some((r) => !r.ok);
747
+ const blocking = findings.filter((f) => f.severity !== 'warning' && f.severity !== 'info');
748
+ releaseLocks();
749
+ process.exit(
750
+ report.infrastructureFailure || opaqueFailure ? 2 : toolFailed || blocking.length ? 1 : 0
751
+ );
752
+ }
753
+
754
+ main().catch((err) => {
755
+ console.error(` [code-quality] ${err.stack || err.message}`);
756
+ shutdown(1);
757
+ });