mtech-ai-local-runner 0.1.53 → 0.1.55

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,102 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { MULTI_REPOSITORY, UNREGISTERED } from "./types.js";
4
+ const NOT_COMPANY = Object.freeze({ company: false, projectKey: null });
5
+ const SCAN_LIMIT = 200;
6
+ const SCAN_MS = 50;
7
+ const MAX_DEPTH = 64;
8
+ const URL_REMOTE = /^(?:https?|ssh|git):\/\/(?:[^@/]*@)?([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i;
9
+ const SCP_REMOTE = /^[^@\s/]+@([^:\s/]+):([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i;
10
+ /** The host, workspace and repository of a remote URL. The user part, and any token in it, is dropped. */
11
+ export function parseRemote(url) {
12
+ const trimmed = url.trim();
13
+ const match = URL_REMOTE.exec(trimmed) ?? SCP_REMOTE.exec(trimmed);
14
+ if (!match)
15
+ return null;
16
+ return { host: match[1].toLowerCase(), workspace: match[2].toLowerCase(), repository: match[3].toLowerCase() };
17
+ }
18
+ /** The url of [remote "origin"] in a git config text, or null. */
19
+ export function originUrl(config) {
20
+ let inOrigin = false;
21
+ for (const raw of config.split(/\r?\n/)) {
22
+ const line = raw.trim();
23
+ if (line.startsWith("[")) {
24
+ inOrigin = /^\[remote\s+"origin"\]$/.test(line);
25
+ continue;
26
+ }
27
+ const match = inOrigin ? /^url\s*=\s*(.+)$/.exec(line) : null;
28
+ if (match)
29
+ return match[1].trim();
30
+ }
31
+ return null;
32
+ }
33
+ const isBitbucket = (host) => host === "bitbucket.org" || host.endsWith(".bitbucket.org");
34
+ /** The repository name when the config's origin is a company remote, else null. */
35
+ async function companyRepository(configPath, policy) {
36
+ let text;
37
+ try {
38
+ text = await readFile(configPath, "utf8");
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ const url = originUrl(text);
44
+ const remote = url === null ? null : parseRemote(url);
45
+ return remote !== null && isBitbucket(remote.host) && policy.companyWorkspaces.includes(remote.workspace) ? remote.repository : null;
46
+ }
47
+ /** The config file of the clone that holds dir, following a worktree's .git file to the main clone; null outside one. */
48
+ async function gitConfigFor(start) {
49
+ let dir = resolve(start);
50
+ for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
51
+ const dotGit = join(dir, ".git");
52
+ const info = await stat(dotGit).catch(() => null);
53
+ if (info?.isDirectory())
54
+ return join(dotGit, "config");
55
+ if (info?.isFile()) {
56
+ const pointer = /^gitdir:\s*(.+)$/m.exec(await readFile(dotGit, "utf8").catch(() => ""))?.[1]?.trim();
57
+ if (!pointer)
58
+ return null;
59
+ const gitDir = isAbsolute(pointer) ? pointer : resolve(dir, pointer);
60
+ const common = (await readFile(join(gitDir, "commondir"), "utf8").catch(() => "")).trim();
61
+ return join(common === "" ? gitDir : isAbsolute(common) ? common : resolve(gitDir, common), "config");
62
+ }
63
+ const parent = dirname(dir);
64
+ if (parent === dir)
65
+ return null;
66
+ dir = parent;
67
+ }
68
+ return null;
69
+ }
70
+ export async function classifyWorkspace(cwd, policy, clock = Date.now) {
71
+ if (typeof cwd !== "string" || !isAbsolute(cwd))
72
+ return NOT_COMPANY;
73
+ const config = await gitConfigFor(cwd);
74
+ if (config !== null) {
75
+ const repository = await companyRepository(config, policy);
76
+ if (repository === null)
77
+ return NOT_COMPANY;
78
+ return { company: true, projectKey: Object.hasOwn(policy.repositories, repository) ? policy.repositories[repository] : UNREGISTERED };
79
+ }
80
+ // Not inside a clone: a folder that holds company clones, such as ~/Projects, counts as company work.
81
+ const started = clock();
82
+ let names;
83
+ try {
84
+ names = (await readdir(cwd)).sort().slice(0, SCAN_LIMIT);
85
+ }
86
+ catch {
87
+ return NOT_COMPANY;
88
+ }
89
+ for (const name of names) {
90
+ if (clock() - started > SCAN_MS)
91
+ break;
92
+ if (name.startsWith("."))
93
+ continue;
94
+ const dotGit = join(cwd, name, ".git");
95
+ const info = await stat(dotGit).catch(() => null);
96
+ if (!info?.isDirectory())
97
+ continue;
98
+ if (await companyRepository(join(dotGit, "config"), policy) !== null)
99
+ return { company: true, projectKey: MULTI_REPOSITORY };
100
+ }
101
+ return NOT_COMPANY;
102
+ }
@@ -0,0 +1,47 @@
1
+ import { guideStatusOf } from "../asks/commands.js";
2
+ import { aggregateDay, sumDays } from "./aggregate.js";
3
+ import { eventDays, lastEventAt, readDetails, readEventLines, readPolicyCache, utcDate } from "./files.js";
4
+ import { codexGuideTrust } from "./integrations.js";
5
+ import { GUIDE_APPS } from "./types.js";
6
+ const RECENT_LINES = 20;
7
+ const CODEX_GUIDE_TRUST = "Codex runs the two guide hooks only after you trust them once. In the Codex app, trust them under Settings → Hooks "
8
+ + "or with the Review hooks button. In the Codex CLI in a terminal, trust them with /hooks.";
9
+ export async function guideDoctorReport(input) {
10
+ const now = input.now ?? Date.now();
11
+ try {
12
+ const cached = await readPolicyCache(input.stateRoot, now);
13
+ const integrations = [];
14
+ for (const app of GUIDE_APPS) {
15
+ const status = await guideStatusOf(app, input.env, input.stateRoot);
16
+ if (status.state === "not_installed") {
17
+ integrations.push(status);
18
+ continue;
19
+ }
20
+ const last = await lastEventAt(input.stateRoot, app);
21
+ if (app === "codex") {
22
+ const trusted = await codexGuideTrust(input.env);
23
+ integrations.push({ ...status, trusted, last_event_at: last, ...(trusted === "yes" ? {} : { action: CODEX_GUIDE_TRUST }) });
24
+ }
25
+ else {
26
+ integrations.push({ ...status, last_event_at: last });
27
+ }
28
+ }
29
+ const today = (await eventDays(input.stateRoot)).get(utcDate(now));
30
+ const lines = today === undefined ? [] : await readEventLines([today.reported, today.fresh].filter((path) => path !== undefined));
31
+ const groups = [...aggregateDay(lines).values()];
32
+ return {
33
+ policy: cached === null ? null : { version: cached.policy.version, confirmed_seconds_ago: Math.max(0, Math.floor((now - cached.confirmedAt) / 1000)),
34
+ stale: cached.stale, rules: cached.policy.rules },
35
+ integrations,
36
+ today: groups.length === 0 ? null : sumDays(groups),
37
+ recent: (await readDetails(input.stateRoot, [utcDate(now - 86_400_000), utcDate(now)])).slice(-RECENT_LINES),
38
+ };
39
+ }
40
+ catch {
41
+ return { policy: null, integrations: [], today: null, recent: [] };
42
+ }
43
+ }
44
+ /** Doctor prints its guide section only once guide has a policy or a hook on this Mac. */
45
+ export function guideReportShown(report) {
46
+ return report.policy !== null || report.integrations.some(item => item.state !== "not_installed");
47
+ }
@@ -0,0 +1,237 @@
1
+ import { createHash } from "node:crypto";
2
+ import { appendFile, mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { GUIDE_DIR, STALE_POLICY_MS, isGuideApp, isOutcome, isRuleId, parseGuidancePolicy } from "./types.js";
5
+ const DIR_MODE = 0o700;
6
+ const FILE_MODE = 0o600;
7
+ const MAX_OWN_WRITES = 200;
8
+ export const SESSION_TTL_MS = 48 * 3_600_000;
9
+ export const LOCAL_RETENTION_DAYS = 14;
10
+ const DAY_FILE = /^(\d{4}-\d{2}-\d{2})(\.reported)?\.ndjson$/;
11
+ export const guidePath = (stateRoot, ...parts) => join(stateRoot, GUIDE_DIR, ...parts);
12
+ export const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
13
+ /** sha256 of the app and the session id. It names the session's file; its first 16 characters tag the session's count lines. */
14
+ export function sessionHash(app, sessionId) {
15
+ return createHash("sha256").update(`${app}\0${sessionId}`).digest("hex");
16
+ }
17
+ async function writeAtomic(path, content) {
18
+ await mkdir(dirname(path), { recursive: true, mode: DIR_MODE });
19
+ const temporary = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
20
+ try {
21
+ await writeFile(temporary, content, { mode: FILE_MODE, flag: "wx" });
22
+ await rename(temporary, path);
23
+ }
24
+ catch (error) {
25
+ await rm(temporary, { force: true });
26
+ throw error;
27
+ }
28
+ }
29
+ async function readJson(path) {
30
+ try {
31
+ return JSON.parse(await readFile(path, "utf8"));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ export async function readPolicyCache(stateRoot, now) {
38
+ const raw = await readJson(guidePath(stateRoot, "policy.json"));
39
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
40
+ return null;
41
+ const { policy, confirmed_at: confirmedAt } = raw;
42
+ const parsed = parseGuidancePolicy(policy);
43
+ if (parsed === null || typeof confirmedAt !== "number" || !Number.isSafeInteger(confirmedAt))
44
+ return null;
45
+ return { policy: parsed, confirmedAt, stale: now - confirmedAt > STALE_POLICY_MS };
46
+ }
47
+ /** Caches the policy exactly as the Gateway sent it, confirmed at confirmedAt. */
48
+ export async function writePolicyCache(stateRoot, wirePolicy, confirmedAt) {
49
+ await writeAtomic(guidePath(stateRoot, "policy.json"), `${JSON.stringify({ policy: wirePolicy, confirmed_at: confirmedAt })}\n`);
50
+ }
51
+ /** Moves the cached policy's confirmation time. Resolves false when there is no valid cache. */
52
+ export async function confirmPolicyCache(stateRoot, confirmedAt) {
53
+ const raw = await readJson(guidePath(stateRoot, "policy.json"));
54
+ const policy = raw !== null && typeof raw === "object" ? raw.policy : undefined;
55
+ if (parseGuidancePolicy(policy) === null)
56
+ return false;
57
+ await writePolicyCache(stateRoot, policy, confirmedAt);
58
+ return true;
59
+ }
60
+ /** Removes everything guide keeps. Used when the Gateway says guide is off for this person, so nothing waits to be sent. */
61
+ export async function deleteGuideState(stateRoot) {
62
+ await rm(join(stateRoot, GUIDE_DIR), { recursive: true, force: true });
63
+ }
64
+ function isSession(value) {
65
+ if (value === null || typeof value !== "object" || Array.isArray(value))
66
+ return false;
67
+ const state = value;
68
+ return typeof state.company === "boolean" && (state.projectKey === null || typeof state.projectKey === "string")
69
+ && typeof state.counted === "boolean" && typeof state.searched === "boolean"
70
+ && Array.isArray(state.warned) && state.warned.every(isRuleId)
71
+ && Array.isArray(state.ownWrites) && state.ownWrites.every(path => typeof path === "string")
72
+ && state.details !== null && typeof state.details === "object" && !Array.isArray(state.details);
73
+ }
74
+ export async function readSession(stateRoot, hash) {
75
+ const value = await readJson(guidePath(stateRoot, "sessions", `${hash}.json`));
76
+ return isSession(value) ? value : null;
77
+ }
78
+ export async function writeSession(stateRoot, hash, state) {
79
+ await writeAtomic(guidePath(stateRoot, "sessions", `${hash}.json`), `${JSON.stringify({ ...state, ownWrites: state.ownWrites.slice(-MAX_OWN_WRITES) })}\n`);
80
+ }
81
+ async function appendLines(stateRoot, folder, lines) {
82
+ if (lines.length === 0)
83
+ return;
84
+ const byDate = new Map();
85
+ for (const line of lines) {
86
+ const date = utcDate(line.t * 1000);
87
+ byDate.set(date, `${byDate.get(date) ?? ""}${JSON.stringify(line)}\n`);
88
+ }
89
+ await mkdir(guidePath(stateRoot, folder), { recursive: true, mode: DIR_MODE });
90
+ for (const [date, text] of byDate)
91
+ await appendFile(guidePath(stateRoot, folder, `${date}.ndjson`), text, { mode: FILE_MODE });
92
+ }
93
+ export async function appendEvents(stateRoot, lines) { await appendLines(stateRoot, "events", lines); }
94
+ export async function appendDetails(stateRoot, lines) { await appendLines(stateRoot, "detail", lines); }
95
+ export async function stampLastEvent(stateRoot, app, now) {
96
+ const path = guidePath(stateRoot, "last", app);
97
+ const time = new Date(now);
98
+ try {
99
+ await utimes(path, time, time);
100
+ return;
101
+ }
102
+ catch { /* Not there yet. */ }
103
+ await mkdir(dirname(path), { recursive: true, mode: DIR_MODE });
104
+ await writeFile(path, "", { mode: FILE_MODE });
105
+ await utimes(path, time, time);
106
+ }
107
+ /** The last hook run of an app, rounded down to the hour, so the heartbeat changes at most hourly; null before any. */
108
+ export async function lastEventAt(stateRoot, app) {
109
+ try {
110
+ return Math.floor((await stat(guidePath(stateRoot, "last", app))).mtimeMs / 3_600_000) * 3600;
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ export async function eventDays(stateRoot) {
117
+ const days = new Map();
118
+ let names = [];
119
+ try {
120
+ names = await readdir(guidePath(stateRoot, "events"));
121
+ }
122
+ catch {
123
+ return days;
124
+ }
125
+ for (const name of names) {
126
+ const match = DAY_FILE.exec(name);
127
+ if (!match)
128
+ continue;
129
+ const day = days.get(match[1]) ?? {};
130
+ if (match[2])
131
+ day.reported = guidePath(stateRoot, "events", name);
132
+ else
133
+ day.fresh = guidePath(stateRoot, "events", name);
134
+ days.set(match[1], day);
135
+ }
136
+ return days;
137
+ }
138
+ export async function readEventLines(paths) {
139
+ const lines = [];
140
+ for (const path of paths) {
141
+ let text = "";
142
+ try {
143
+ text = await readFile(path, "utf8");
144
+ }
145
+ catch {
146
+ continue;
147
+ }
148
+ for (const raw of text.split("\n")) {
149
+ if (raw === "")
150
+ continue;
151
+ try {
152
+ const line = JSON.parse(raw);
153
+ if (typeof line.t === "number" && isGuideApp(line.a) && typeof line.p === "string" && typeof line.s === "string"
154
+ && typeof line.m === "string" && typeof line.n === "number")
155
+ lines.push(line);
156
+ }
157
+ catch { /* A line a crash cut short is skipped. */ }
158
+ }
159
+ }
160
+ return lines;
161
+ }
162
+ export async function readDetails(stateRoot, dates) {
163
+ const lines = [];
164
+ for (const date of dates) {
165
+ let text = "";
166
+ try {
167
+ text = await readFile(guidePath(stateRoot, "detail", `${date}.ndjson`), "utf8");
168
+ }
169
+ catch {
170
+ continue;
171
+ }
172
+ for (const raw of text.split("\n")) {
173
+ if (raw === "")
174
+ continue;
175
+ try {
176
+ const line = JSON.parse(raw);
177
+ if (typeof line.t === "number" && isGuideApp(line.a) && isRuleId(line.r) && isOutcome(line.o) && typeof line.x === "string")
178
+ lines.push(line);
179
+ }
180
+ catch { /* Skipped. */ }
181
+ }
182
+ }
183
+ return lines;
184
+ }
185
+ /**
186
+ * Moves a reported day's fresh lines into <date>.reported.ndjson. A hook that appends while this runs writes a new fresh
187
+ * file, which the next pass reports together with everything else of that day.
188
+ */
189
+ export async function markReported(stateRoot, date) {
190
+ const fresh = guidePath(stateRoot, "events", `${date}.ndjson`);
191
+ const merging = `${fresh}.${process.pid}.merging`;
192
+ try {
193
+ await rename(fresh, merging);
194
+ }
195
+ catch {
196
+ return;
197
+ }
198
+ const text = await readFile(merging, "utf8").catch(() => "");
199
+ await appendFile(guidePath(stateRoot, "events", `${date}.reported.ndjson`), text, { mode: FILE_MODE });
200
+ await rm(merging, { force: true });
201
+ }
202
+ /** Removes a day the Gateway refused, so it is not sent again. */
203
+ export async function dropDay(stateRoot, date) {
204
+ await rm(guidePath(stateRoot, "events", `${date}.ndjson`), { force: true });
205
+ await rm(guidePath(stateRoot, "events", `${date}.reported.ndjson`), { force: true });
206
+ }
207
+ /** Day files older than fourteen days, and sessions untouched for two days, are deleted. */
208
+ export async function pruneGuideFiles(stateRoot, now) {
209
+ const oldest = utcDate(now - LOCAL_RETENTION_DAYS * 86_400_000);
210
+ for (const folder of ["events", "detail"]) {
211
+ let names = [];
212
+ try {
213
+ names = await readdir(guidePath(stateRoot, folder));
214
+ }
215
+ catch {
216
+ continue;
217
+ }
218
+ for (const name of names) {
219
+ const date = /^(\d{4}-\d{2}-\d{2})/.exec(name)?.[1];
220
+ if (date !== undefined && date < oldest)
221
+ await rm(guidePath(stateRoot, folder, name), { force: true });
222
+ }
223
+ }
224
+ let sessions = [];
225
+ try {
226
+ sessions = await readdir(guidePath(stateRoot, "sessions"));
227
+ }
228
+ catch {
229
+ return;
230
+ }
231
+ for (const name of sessions) {
232
+ const path = guidePath(stateRoot, "sessions", name);
233
+ const info = await stat(path).catch(() => null);
234
+ if (info !== null && now - info.mtimeMs > SESSION_TTL_MS)
235
+ await rm(path, { force: true });
236
+ }
237
+ }
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { stat } from "node:fs/promises";
4
+ import { isAbsolute, join, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { classifyWorkspace } from "./company.js";
7
+ import { appendDetails, appendEvents, readPolicyCache, readSession, sessionHash, stampLastEvent, writeSession } from "./files.js";
8
+ import { classifyToolCall, knowledgeBaseTail } from "./matching.js";
9
+ import { KB_SEARCH_FIRST_TEXT, SESSION_GUIDANCE_TEXT, cliRuleText } from "./templates.js";
10
+ import { MAX_HOOK_INPUT_BYTES, RULE_IDS, SESSION_ID, UNREGISTERED, isGuideApp, isGuideEvent } from "./types.js";
11
+ const STDIN_TIMEOUT_MS = 1000;
12
+ const STATE_ROOT_FLAG = "--state-root=";
13
+ const MAX_DETAILS_PER_RULE = 20;
14
+ const MAX_OWN_WRITES = 200;
15
+ const START_SOURCES = new Set(["startup", "clear", "compact"]);
16
+ /** The same values as the asks hook client (src/asks/hook-client.ts): a test keeps the two in step. */
17
+ const INTERACTIVE_ENTRYPOINTS = new Set(["cli", "claude-desktop", "claude-vscode"]);
18
+ const CLAUDE_JOBS_DIR = "claude-jobs";
19
+ const TRUNCATED_SESSION_ID = /"session_id"\s*:\s*"([^"\\]{1,128})"/;
20
+ function isRunnerJobHome(env, stateRoot) {
21
+ const home = env.HOME;
22
+ if (!home || !isAbsolute(home))
23
+ return false;
24
+ const roots = new Set([resolve(stateRoot)]);
25
+ try {
26
+ roots.add(realpathSync(stateRoot));
27
+ }
28
+ catch { /* A state root that does not exist holds no jobs. */ }
29
+ const resolved = resolve(home);
30
+ return [...roots].some(root => resolved.startsWith(join(root, CLAUDE_JOBS_DIR) + sep));
31
+ }
32
+ /** True when a person attends the session: the asks hook client's rule, repeated so this client loads nothing of asks. */
33
+ export function isHumanSession(app, env, stateRoot) {
34
+ if (isRunnerJobHome(env, stateRoot))
35
+ return false;
36
+ if (app !== "claude")
37
+ return true;
38
+ const attended = env.CLAUDE_CODE_SESSION_ATTENDED;
39
+ if (attended !== undefined && attended !== "")
40
+ return attended === "1";
41
+ return INTERACTIVE_ENTRYPOINTS.has(env.CLAUDE_CODE_ENTRYPOINT ?? "");
42
+ }
43
+ function stateRootFrom(env) {
44
+ return env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, "mtec-ai-local-runner") : join(env.HOME ?? "", ".local", "state", "mtec-ai-local-runner");
45
+ }
46
+ function parseArguments(argv, env) {
47
+ const rest = [...argv];
48
+ let stateRoot = stateRootFrom(env);
49
+ if (rest[0]?.startsWith(STATE_ROOT_FLAG)) {
50
+ stateRoot = rest.shift().slice(STATE_ROOT_FLAG.length);
51
+ if (!isAbsolute(stateRoot))
52
+ return null;
53
+ }
54
+ const [app, event, ...extra] = rest;
55
+ if (extra.length > 0 || !isGuideApp(app) || !isGuideEvent(event))
56
+ return null;
57
+ return { stateRoot, app, event };
58
+ }
59
+ /** At most 64 KiB of hook input within a second. truncated says the input was longer; null when nothing came in time. */
60
+ async function readInput(stdin, timeoutMs) {
61
+ let timer;
62
+ const timeout = new Promise(done => { timer = setTimeout(() => done(null), timeoutMs); });
63
+ const read = (async () => {
64
+ const chunks = [];
65
+ let size = 0;
66
+ for await (const chunk of stdin) {
67
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
68
+ if (size + bytes.length > MAX_HOOK_INPUT_BYTES) {
69
+ chunks.push(bytes.subarray(0, MAX_HOOK_INPUT_BYTES - size));
70
+ return { text: Buffer.concat(chunks).toString("utf8"), truncated: true };
71
+ }
72
+ size += bytes.length;
73
+ chunks.push(bytes);
74
+ }
75
+ return { text: Buffer.concat(chunks).toString("utf8"), truncated: false };
76
+ })().catch(() => null);
77
+ try {
78
+ return await Promise.race([read, timeout]);
79
+ }
80
+ finally {
81
+ clearTimeout(timer);
82
+ }
83
+ }
84
+ function parseRecord(raw) {
85
+ let value;
86
+ try {
87
+ value = JSON.parse(raw);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
93
+ }
94
+ /** The count and detail lines of one hook run, written together at its end. */
95
+ class Recorder {
96
+ app;
97
+ hash;
98
+ now;
99
+ events = [];
100
+ details = [];
101
+ constructor(app, hash, now) {
102
+ this.app = app;
103
+ this.hash = hash;
104
+ this.now = now;
105
+ }
106
+ line(session, metric, n) {
107
+ return { t: Math.floor(this.now / 1000), a: this.app, p: session.projectKey ?? UNREGISTERED, s: this.hash.slice(0, 16), m: metric, n };
108
+ }
109
+ count(session, metric, n = 1) {
110
+ this.events.push(this.line(session, metric, n));
111
+ }
112
+ rule(session, rule, outcome, subject) {
113
+ this.events.push({ ...this.line(session, "rule", 1), r: rule, o: outcome });
114
+ const written = session.details[rule] ?? 0;
115
+ if (subject !== "" && written < MAX_DETAILS_PER_RULE) {
116
+ session.details[rule] = written + 1;
117
+ this.details.push({ t: Math.floor(this.now / 1000), a: this.app, r: rule, o: outcome, x: subject });
118
+ }
119
+ }
120
+ async flush(stateRoot, session) {
121
+ await appendEvents(stateRoot, this.events);
122
+ await appendDetails(stateRoot, this.details);
123
+ await writeSession(stateRoot, this.hash, session);
124
+ await stampLastEvent(stateRoot, this.app, this.now);
125
+ }
126
+ }
127
+ /** A stale policy turns warn into observe; off stays off. */
128
+ function effectiveModes(rules, stale) {
129
+ if (!stale)
130
+ return rules;
131
+ return Object.fromEntries(RULE_IDS.map(rule => [rule, rules[rule] === "warn" ? "observe" : rules[rule]]));
132
+ }
133
+ /**
134
+ * One rule's mode applied to one match: the text to add, or null. A rule other than session_guidance adds its text once
135
+ * per session; later matches, and a match while another text was already chosen, are counted as observed.
136
+ */
137
+ function evaluate(rule, modes, session, recorder, subject, text, mayWarn) {
138
+ const mode = modes[rule];
139
+ if (mode === "off")
140
+ return null;
141
+ if (mode === "warn" && mayWarn && (rule === "session_guidance" || !session.warned.includes(rule))) {
142
+ if (rule !== "session_guidance")
143
+ session.warned.push(rule);
144
+ recorder.rule(session, rule, "warned", subject);
145
+ return text;
146
+ }
147
+ recorder.rule(session, rule, "observed", subject);
148
+ return null;
149
+ }
150
+ function absolute(path, cwd) {
151
+ return isAbsolute(path) ? resolve(path) : resolve(typeof cwd === "string" && isAbsolute(cwd) ? cwd : "/", path);
152
+ }
153
+ async function fileSize(path) {
154
+ try {
155
+ return (await stat(path)).size;
156
+ }
157
+ catch {
158
+ return 0;
159
+ }
160
+ }
161
+ async function toolText(app, record, modes, session, recorder, gatewayToolNames) {
162
+ const findings = classifyToolCall(app, record.tool_name, record.tool_input, gatewayToolNames);
163
+ if (findings === null) {
164
+ recorder.count(session, "undecidable");
165
+ return null;
166
+ }
167
+ let text = null;
168
+ for (const finding of findings) {
169
+ if (finding.kind === "knowledge_search") {
170
+ session.searched = true;
171
+ recorder.count(session, "knowledge_search_calls");
172
+ }
173
+ else if (finding.kind === "gateway_tool") {
174
+ recorder.count(session, "gateway_tool_calls");
175
+ }
176
+ else if (finding.kind === "kb_write") {
177
+ for (const path of finding.paths) {
178
+ const full = absolute(path, record.cwd);
179
+ if (!session.ownWrites.includes(full))
180
+ session.ownWrites = [...session.ownWrites, full].slice(-MAX_OWN_WRITES);
181
+ }
182
+ }
183
+ else if (finding.kind === "kb_read") {
184
+ recorder.count(session, `kb_reads_${finding.readKind}`);
185
+ if (finding.readKind === "read_tool")
186
+ recorder.count(session, "kb_read_bytes", await fileSize(absolute(finding.paths[0], record.cwd)));
187
+ const foreign = finding.paths.filter(path => !session.ownWrites.includes(absolute(path, record.cwd)));
188
+ if (!session.searched && foreign.length > 0) {
189
+ const chosen = evaluate("kb_search_first", modes, session, recorder, knowledgeBaseTail(foreign[0]), KB_SEARCH_FIRST_TEXT, text === null);
190
+ text ??= chosen;
191
+ }
192
+ }
193
+ else {
194
+ const chosen = evaluate(finding.rule, modes, session, recorder, finding.subject, cliRuleText(finding.rule), text === null);
195
+ text ??= chosen;
196
+ }
197
+ }
198
+ return text;
199
+ }
200
+ /** Never throws, never writes to stderr and always resolves 0. */
201
+ export async function runGuideHook(argv, io) {
202
+ try {
203
+ const args = parseArguments(argv, io.env);
204
+ if (args === null)
205
+ return 0;
206
+ const now = io.now?.() ?? Date.now();
207
+ const cached = await readPolicyCache(args.stateRoot, now);
208
+ // No policy: guide is off for this person, or the runner never synced. Nothing is printed or recorded.
209
+ if (cached === null || !isHumanSession(args.app, io.env, args.stateRoot))
210
+ return 0;
211
+ const input = await readInput(io.stdin, io.stdinTimeoutMs ?? STDIN_TIMEOUT_MS);
212
+ if (input === null)
213
+ return 0;
214
+ const record = input.truncated ? null : parseRecord(input.text);
215
+ const sessionId = record !== null ? record.session_id : TRUNCATED_SESSION_ID.exec(input.text)?.[1];
216
+ if (typeof sessionId !== "string" || !SESSION_ID.test(sessionId))
217
+ return 0;
218
+ const hash = sessionHash(args.app, sessionId);
219
+ const recorder = new Recorder(args.app, hash, now);
220
+ let session = await readSession(args.stateRoot, hash);
221
+ if (record === null) {
222
+ // Too large to read in full: counted only for a session already known as company work (planning decision 6).
223
+ if (session?.company) {
224
+ recorder.count(session, "undecidable");
225
+ await recorder.flush(args.stateRoot, session);
226
+ }
227
+ return 0;
228
+ }
229
+ if (session === null) {
230
+ const workspace = await classifyWorkspace(typeof record.cwd === "string" ? record.cwd : "", cached.policy);
231
+ session = { company: workspace.company, projectKey: workspace.projectKey, counted: false, searched: false, warned: [], ownWrites: [], details: {} };
232
+ if (!session.company) {
233
+ await writeSession(args.stateRoot, hash, session);
234
+ return 0;
235
+ }
236
+ }
237
+ if (!session.company)
238
+ return 0;
239
+ if (!session.counted) {
240
+ recorder.count(session, "session");
241
+ session.counted = true;
242
+ }
243
+ if (cached.stale)
244
+ recorder.count(session, "undecidable");
245
+ const modes = effectiveModes(cached.policy.rules, cached.stale);
246
+ let text = null;
247
+ if (args.event === "session-start") {
248
+ const source = record.source;
249
+ if (source === undefined || (typeof source === "string" && START_SOURCES.has(source))) {
250
+ text = evaluate("session_guidance", modes, session, recorder, "", SESSION_GUIDANCE_TEXT, true);
251
+ }
252
+ }
253
+ else {
254
+ text = await toolText(args.app, record, modes, session, recorder, cached.policy.gatewayToolNames);
255
+ }
256
+ if (text !== null)
257
+ recorder.count(session, "injected_chars", text.length);
258
+ // Recorded before anything is printed: a write that fails prints nothing (spec section 8).
259
+ await recorder.flush(args.stateRoot, session);
260
+ if (text !== null) {
261
+ io.stdout(JSON.stringify({ hookSpecificOutput: { hookEventName: args.event === "session-start" ? "SessionStart" : "PreToolUse", additionalContext: text } }));
262
+ }
263
+ return 0;
264
+ }
265
+ catch {
266
+ return 0;
267
+ }
268
+ }
269
+ /** True when Node runs this file as its entry point, directly or through a symlink. */
270
+ function isDirectInvocation(moduleUrl, argvPath) {
271
+ if (!argvPath)
272
+ return false;
273
+ try {
274
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath);
275
+ }
276
+ catch {
277
+ return false;
278
+ }
279
+ }
280
+ if (isDirectInvocation(import.meta.url, process.argv[1])) {
281
+ void runGuideHook(process.argv.slice(2), {
282
+ stdin: process.stdin,
283
+ stdout: text => { process.stdout.write(`${text}\n`); },
284
+ env: process.env,
285
+ }).then(() => {
286
+ process.exitCode = 0;
287
+ process.stdin.destroy();
288
+ });
289
+ }