@event4u/agent-config 8.3.0 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Duplicate-surface probe — `session_start` hook
4
+ * (road-to-install-path-convergence Phase 4).
5
+ *
6
+ * Cheap runtime self-detection on every install path, even for users who
7
+ * never run `agent-config doctor`: reads src/config/surface-matrix.yml from
8
+ * the package root, existence-checks each declared duplicate class, and — at
9
+ * most once per day — emits ONE stderr nudge naming the duplicate plus the
10
+ * converge command.
11
+ *
12
+ * Contract:
13
+ * - fail-open, never blocking — returns 0 on every path, including
14
+ * unreadable matrix, corrupted state file, and unwritable state dir.
15
+ * - rate-limited via agents/runtime/state/surface-probe.json in the
16
+ * CONSUMER project (last_check_utc, 24h window — same precedent as
17
+ * _lib/update_check.ts). A corrupted state file counts as "due" and is
18
+ * rewritten.
19
+ * - suppressed entirely when the surface is clean or when
20
+ * install.auto_converge is already true in the global settings (the
21
+ * user has standing consent — converge handles it, no nagging).
22
+ */
23
+
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { fileURLToPath, pathToFileURL } from 'node:url';
28
+ import { parse as parseYaml } from 'yaml';
29
+
30
+ import { is_replay_mode } from './hooks/state_io.js';
31
+
32
+ const CHECK_WINDOW_MS = 24 * 60 * 60 * 1000;
33
+ const STATE_REL = ['agents', 'runtime', 'state', 'surface-probe.json'] as const;
34
+
35
+ function _project_root(): string {
36
+ const env = process.env['CLAUDE_PROJECT_DIR'] || process.env['AGENT_CONFIG_PROJECT_DIR'];
37
+ if (env) {
38
+ return env;
39
+ }
40
+ return process.cwd();
41
+ }
42
+
43
+ function _package_root(): string {
44
+ const env = process.env['AGENT_CONFIG_PACKAGE_ROOT'];
45
+ if (env) {
46
+ return env;
47
+ }
48
+ // surface_probe_hook.ts lives at src/scripts/; parents[2] = package root.
49
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
50
+ }
51
+
52
+ function _readStdinIfNotTty(): void {
53
+ try {
54
+ if (!process.stdin.isTTY) {
55
+ fs.readFileSync(0, 'utf-8');
56
+ }
57
+ } catch {
58
+ /* ignore */
59
+ }
60
+ }
61
+
62
+ function _exists(p: string): boolean {
63
+ try {
64
+ fs.statSync(p);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ interface ProbeOptions {
72
+ project_root?: string;
73
+ package_root?: string;
74
+ home?: string;
75
+ global_settings_path?: string;
76
+ now_ms?: number;
77
+ }
78
+
79
+ /** Fail-open read of install.auto_converge from the global settings file. */
80
+ function _auto_converge(global_settings_path: string): boolean {
81
+ try {
82
+ const raw = parseYaml(fs.readFileSync(global_settings_path, 'utf-8'), { version: '1.1' }) as
83
+ | Record<string, unknown>
84
+ | null;
85
+ const install = raw?.['install'];
86
+ if (install && typeof install === 'object' && !Array.isArray(install)) {
87
+ return (install as Record<string, unknown>)['auto_converge'] === true;
88
+ }
89
+ } catch {
90
+ /* absent / unreadable → false */
91
+ }
92
+ return false;
93
+ }
94
+
95
+ function _default_global_settings(home: string): string {
96
+ return path.join(home, '.event4u', 'agent-config', 'agent-settings.yml');
97
+ }
98
+
99
+ /** Rate limit: true when the probe already ran inside the window. */
100
+ function _rate_limited(state_path: string, now_ms: number): boolean {
101
+ try {
102
+ const raw = JSON.parse(fs.readFileSync(state_path, 'utf-8')) as Record<string, unknown>;
103
+ const last = raw['last_check_utc'];
104
+ if (typeof last === 'number' && now_ms - last < CHECK_WINDOW_MS && last <= now_ms) {
105
+ return true;
106
+ }
107
+ } catch {
108
+ /* missing / corrupted → due */
109
+ }
110
+ return false;
111
+ }
112
+
113
+ function _stamp(state_path: string, now_ms: number): void {
114
+ try {
115
+ fs.mkdirSync(path.dirname(state_path), { recursive: true });
116
+ fs.writeFileSync(state_path, `${JSON.stringify({ last_check_utc: now_ms })}\n`, 'utf-8');
117
+ } catch {
118
+ /* unwritable state dir → fail open, probe just runs again next time */
119
+ }
120
+ }
121
+
122
+ interface Finding {
123
+ tool: string;
124
+ command: string;
125
+ }
126
+
127
+ function _detect(pkg_root: string, home: string): Finding[] {
128
+ const matrix_path = path.join(pkg_root, 'src', 'config', 'surface-matrix.yml');
129
+ let tools: Record<string, unknown> = {};
130
+ try {
131
+ const raw = parseYaml(fs.readFileSync(matrix_path, 'utf-8'), { version: '1.1' }) as Record<
132
+ string,
133
+ unknown
134
+ >;
135
+ const t = raw?.['tools'];
136
+ if (t && typeof t === 'object' && !Array.isArray(t)) {
137
+ tools = t as Record<string, unknown>;
138
+ }
139
+ } catch {
140
+ return []; // unreadable matrix → fail open, no nudge
141
+ }
142
+
143
+ const expand = (p: string): string =>
144
+ p.startsWith('~/') ? path.join(home, p.slice(2)) : path.join(pkg_root, p);
145
+
146
+ const findings: Finding[] = [];
147
+ for (const [tool_id, entry] of Object.entries(tools)) {
148
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
149
+ continue;
150
+ }
151
+ const e = entry as Record<string, unknown>;
152
+ const dup = e['duplicate'] as Record<string, unknown> | undefined;
153
+ const detect = dup?.['detect'] as Record<string, unknown> | undefined;
154
+ const all_of = detect?.['all_of'];
155
+ if (!Array.isArray(all_of) || all_of.length === 0) {
156
+ continue;
157
+ }
158
+ const present = all_of.every((p) => typeof p === 'string' && _exists(expand(p)));
159
+ if (!present) {
160
+ continue;
161
+ }
162
+ const conv = e['converge'] as Record<string, unknown> | undefined;
163
+ findings.push({ tool: tool_id, command: String(conv?.['command'] ?? '') });
164
+ }
165
+ return findings;
166
+ }
167
+
168
+ export function main(argv?: string[], opts: ProbeOptions = {}): number {
169
+ void argv;
170
+ _readStdinIfNotTty();
171
+
172
+ // Replay contract (state_io): fixture re-execution must not mutate
173
+ // state or emit nudges — the probe is a live-session UX concern only.
174
+ if (is_replay_mode()) {
175
+ return 0;
176
+ }
177
+
178
+ try {
179
+ const project_root = opts.project_root ?? _project_root();
180
+ const pkg_root = opts.package_root ?? _package_root();
181
+ const home = opts.home ?? os.homedir();
182
+ const global_settings = opts.global_settings_path ?? _default_global_settings(home);
183
+ const now_ms = opts.now_ms ?? Date.now();
184
+
185
+ const state_path = path.join(project_root, ...STATE_REL);
186
+ if (_rate_limited(state_path, now_ms)) {
187
+ return 0;
188
+ }
189
+ _stamp(state_path, now_ms);
190
+
191
+ if (_auto_converge(global_settings)) {
192
+ return 0; // standing consent — converge owns the cleanup, no nag
193
+ }
194
+
195
+ const findings = _detect(pkg_root, home);
196
+ if (findings.length === 0) {
197
+ return 0; // clean surface — fully silent
198
+ }
199
+
200
+ const tools = findings.map((f) => f.tool).join(', ');
201
+ const fix = findings.find((f) => f.command !== '')?.command ?? '';
202
+ process.stderr.write(
203
+ `[surface] duplicate install surface: ${tools} — run: agent-config converge` +
204
+ `${fix ? ` (or: ${fix})` : ''}\n`,
205
+ );
206
+ return 0;
207
+ } catch {
208
+ return 0; // fail-open — a probe must never block a session
209
+ }
210
+ }
211
+
212
+ function _isCliEntry(): boolean {
213
+ if (process.argv[1] === undefined) {
214
+ return false;
215
+ }
216
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
217
+ if (import.meta.url === argvUrl) {
218
+ return true;
219
+ }
220
+ try {
221
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
222
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
223
+ return here === argv;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ if (_isCliEntry()) {
230
+ process.exit(main(process.argv.slice(2)));
231
+ }