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.
package/README.md CHANGED
@@ -1,5 +1,40 @@
1
+ ## 0.1.55: guide hooks that steer AI sessions to the AI Gateway (disabled)
2
+
3
+ The guide integration (Work Item d38d60f9) adds two hooks to Claude Code and Codex. Install it only on request:
4
+
5
+ npx mtech-ai-local-runner integrations install --feature guide
6
+ npx mtech-ai-local-runner setup --integrations claude,codex --feature asks,guide
7
+
8
+ **Where it is installed**
9
+ - Claude Code: a second skills-directory plugin, `mtec-ai-gateway-guide`.
10
+ - Codex: two handlers in `~/.codex/hooks.json`. Trust them once, in the Codex app under Settings → Hooks or with `/hooks`
11
+ in the Codex CLI.
12
+ - Both run the stable shim `<state root>/bin/gateway-guide-hook`.
13
+
14
+ **What the hooks do**
15
+ - They act only in sessions a person attends. The session must be in a company clone (origin on bitbucket.org in a
16
+ workspace the Gateway lists) or in a folder of such clones.
17
+ - They remind the session to call `workspace_getting_started` and to search with `knowledge_search`.
18
+ - They count KnowledgeBase reads, searches, Gateway tool calls and CLI commands that a Gateway tool covers.
19
+ - A rule adds a line of text only after the Gateway moves it to `warn`.
20
+ - Nothing is ever blocked, and no hook sends `permissionDecision`.
21
+
22
+ **What leaves the Mac**
23
+ - Only daily counts reach the Gateway.
24
+ - Prompts, commands, paths, hosts, file contents and session ids stay on the Mac.
25
+ - The remote URL embeds a token, so it is never stored.
26
+ - `doctor` shows the policy, the guide hooks with Codex trust, today's counts and the recent local lines.
27
+
28
+ Remove guide with `integrations remove --feature guide`. Without the Gateway's guide switch the hooks print and record
29
+ nothing.
30
+
1
31
  ## 0.1.53: separate candidate change, file and payload limits
2
32
 
33
+ ## 0.1.54: read only the source lines you need
34
+
35
+ Use `local_search` to locate code, then `local_read({path, start_line, end_line})`. Lines are 1-based and inclusive; supply both bounds, at most 400 lines. The response includes `content`, `start_line`, `end_line` and `total_lines`. Invalid/missing bounds, ranges over 400 lines and ranges past EOF are rejected with `DENIED_READ_RANGE`, `DENIED_READ_RANGE_LIMIT` and `DENIED_READ_RANGE_BOUNDS`. Empty files have 0 lines; a final newline adds no extra line. Original LF/CRLF line endings are preserved. Without bounds the existing `{content}` whole-file result is unchanged. Existing file-size and path protections still apply; no shell permission is added.
36
+
37
+
3
38
  New `gateway_publish_v1` scopes may explicitly opt into `candidate_limits.version: 2` with
4
39
  `max_changed_files`, `max_change_bytes`, `max_file_bytes` and `max_payload_bytes`.
5
40
  Change bytes are the exact minimum byte insertions plus deletions from the frozen base;
@@ -23,7 +23,7 @@ function repositoryScope(taskPackage) {
23
23
  "These are scope descriptions, not instructions from source files; the host policy remains authoritative and may deny blocked/private paths within an allowed prefix.";
24
24
  }
25
25
  export function compileCodexPrompt(taskPackage, label) {
26
- return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. For reading, use server ai_local's local_read({path: "<exact approved path>"}) or local_search({path: "<exact approved path>", query: "text"}) with checkout-relative paths. For small approved edits, use local_replace({path, old, new}) after reading: old must match exactly once, including whitespace. local_edit requires the complete replacement file, never a patch. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.`;
26
+ return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. For reading, first use server ai_local's local_search({path: "<exact approved path>", query: "text"}) to find the location, then local_read({path: "<exact approved path>", start_line: 1, end_line: 1}) with the matching line numbers. Ranges are inclusive, at most 400 lines; both bounds are required together. Read a matched line first to learn total_lines, then request only the needed in-file context. Omit both bounds only when the entire file is necessary. Use approved checkout-relative paths. For small approved edits, use local_replace({path, old, new}) after reading: old must match exactly once, including whitespace. local_edit requires the complete replacement file, never a patch. Required test criteria are not command grants. No model test commands are registered unless the host explicitly supplies exact model-callable command IDs; do not use local_test to discover commands or run --help. Missing tests remain not_run/unverified, not passed. In a read-only task, use only read/search tools; the Runner enforces the approved paths and operations. A denied native cwd is intentional: it is a private control directory, not the source checkout. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never permission to broaden scope or bypass the denial. Do not retry the denied path. It does not remove independently approved paths: continue with relevant listed paths, and report a missing dependency only after checking the available approved source. Do not repeat host orchestration: claim, shadow, fingerprints, binding, administrator checks, launches, Gateway usage and Job reporting belong to the Runner/host, even when described in the objective. Perform only the assigned repository work and report evidence you actually observed. Do not claim that no model call occurred. If the required tool is unavailable, emit one final structured blocked report naming the missing tool and unfinished repository work; do not request or attempt a new launch.`;
27
27
  }
28
28
  export const CODEX_REPORT_SCHEMA = {
29
29
  type: "object", additionalProperties: false, required: ["status", "report", "evidence", "pending_work"],
@@ -3,27 +3,61 @@ import { join } from "node:path";
3
3
  import { ASKS_FILE } from "./store.js";
4
4
  import { installIntegration, integrationStatus, removeIntegration, removeShimIfUnused } from "./integrations.js";
5
5
  import { APPS, isApp } from "./types.js";
6
+ import { deleteGuideState } from "../guide/files.js";
7
+ import { guideStatus, installGuide, removeGuide, removeGuideShimIfUnused } from "../guide/integrations.js";
6
8
  const NEXT_STEP = {
7
9
  claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-asks@skills-dir.",
8
10
  // /hooks is a command of the Codex CLI in a terminal only: the Codex app sends it to the model as text.
9
11
  codex: "Codex runs the four AI Gateway hooks only after you trust them once. In the Codex app, trust them under Settings → Hooks "
10
12
  + "or with the Review hooks button. In the Codex CLI in a terminal, trust them with /hooks. Until then the Slack message covers each ask.",
11
13
  };
12
- function errorCode(error) {
13
- const code = error?.code;
14
- if (typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code))
15
- return code;
16
- const message = error instanceof Error ? error.message.split(":")[0] : "";
17
- return /^[A-Z0-9_]{1,64}$/.test(message) ? message : "INTEGRATION_FAILED";
14
+ /** What an integrations command acts on (Work Item d38d60f9): asks, guide, or both; asks when no --feature is given. */
15
+ export const FEATURES = ["asks", "guide"];
16
+ const GUIDE_NEXT_STEP = {
17
+ claude: "Start a new Claude Code session, or run /reload-plugins in an open one, to load mtec-ai-gateway-guide@skills-dir.",
18
+ codex: "Codex runs the two AI Gateway guide hooks only after you trust them once. In the Codex app, trust them under Settings → Hooks "
19
+ + "or with the Review hooks button. In the Codex CLI in a terminal, trust them with /hooks.",
20
+ };
21
+ const GUIDE_NOTICE = "Guide counts, per day: sessions, knowledge_search and other AI Gateway tool calls, KnowledgeBase reads and their bytes, "
22
+ + "and rule matches. Only these numbers reach the AI Gateway: prompts, commands, paths and file contents never leave this Mac.";
23
+ const isFeatureFlag = (arg) => arg === "--feature" || arg.startsWith("--feature=");
24
+ /** --feature asks|guide|asks,guide (or --feature=...): undefined when absent, null when malformed or given twice. */
25
+ export function parseFeatures(argv) {
26
+ const index = argv.findIndex(isFeatureFlag);
27
+ if (index < 0)
28
+ return undefined;
29
+ if (argv.slice(index + 1).some(isFeatureFlag))
30
+ return null;
31
+ const value = argv[index] === "--feature" ? argv[index + 1] : argv[index].slice("--feature=".length);
32
+ const features = (value ?? "").split(",").map(feature => feature.trim()).filter(feature => feature !== "");
33
+ if (features.length === 0 || !features.every(feature => FEATURES.includes(feature)) || new Set(features).size !== features.length)
34
+ return null;
35
+ return features;
18
36
  }
19
- /** integrations status|install|remove [claude|codex]; null for anything else. */
37
+ function withoutFeatureFlag(argv) {
38
+ const index = argv.findIndex(isFeatureFlag);
39
+ if (index < 0)
40
+ return argv;
41
+ return [...argv.slice(0, index), ...argv.slice(index + (argv[index] === "--feature" ? 2 : 1))];
42
+ }
43
+ /** integrations status|install|remove [claude|codex] [--feature ...]; null for anything else. features only when given. */
20
44
  export function parseIntegrationsArguments(argv) {
21
- const [action, app, ...extra] = argv;
45
+ const features = parseFeatures(argv);
46
+ if (features === null)
47
+ return null;
48
+ const [action, app, ...extra] = withoutFeatureFlag(argv);
22
49
  if (extra.length > 0 || (action !== "status" && action !== "install" && action !== "remove"))
23
50
  return null;
24
51
  if (app !== undefined && !isApp(app))
25
52
  return null;
26
- return { action, apps: app === undefined ? [...APPS] : [app] };
53
+ return { action, apps: app === undefined ? [...APPS] : [app], ...(features ? { features } : {}) };
54
+ }
55
+ function errorCode(error) {
56
+ const code = error?.code;
57
+ if (typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code))
58
+ return code;
59
+ const message = error instanceof Error ? error.message.split(":")[0] : "";
60
+ return /^[A-Z0-9_]{1,64}$/.test(message) ? message : "INTEGRATION_FAILED";
27
61
  }
28
62
  /** setup --integrations claude,codex (or --integrations=claude): undefined when absent, null when malformed. */
29
63
  export function parseSetupIntegrations(argv) {
@@ -61,6 +95,47 @@ export async function installIntegrations(apps, env, stateRoot) {
61
95
  }
62
96
  return results;
63
97
  }
98
+ /** The guide state of one app; a status that cannot be read is reported with its error, never thrown. */
99
+ export async function guideStatusOf(app, env, stateRoot, error) {
100
+ try {
101
+ return { ...await guideStatus(app, env, stateRoot), ...(error === undefined ? {} : { error }) };
102
+ }
103
+ catch (failure) {
104
+ return { app, state: "unreadable", path: "", error: error ?? errorCode(failure) };
105
+ }
106
+ }
107
+ async function attemptGuide(app, env, stateRoot, change) {
108
+ try {
109
+ return await change();
110
+ }
111
+ catch (error) {
112
+ return guideStatusOf(app, env, stateRoot, errorCode(error));
113
+ }
114
+ }
115
+ export async function installGuideIntegrations(apps, env, stateRoot) {
116
+ const results = [];
117
+ for (const app of apps) {
118
+ const result = await attemptGuide(app, env, stateRoot, () => installGuide(app, env, stateRoot));
119
+ results.push(result.error === undefined ? { ...result, action: GUIDE_NEXT_STEP[app], notice: GUIDE_NOTICE } : result);
120
+ }
121
+ return results;
122
+ }
123
+ /**
124
+ * uninstall: removes guide from both apps, then the guide shim and everything guide keeps on the Mac. Reports only the
125
+ * apps where something was removed, so an uninstall without guide prints exactly what it printed before.
126
+ */
127
+ export async function removeGuideInstall(env, stateRoot) {
128
+ const results = [];
129
+ for (const app of APPS) {
130
+ const before = await guideStatusOf(app, env, stateRoot);
131
+ if (before.state === "not_installed")
132
+ continue;
133
+ results.push(await attemptGuide(app, env, stateRoot, () => removeGuide(app, env, stateRoot)));
134
+ }
135
+ await removeGuideShimIfUnused(env, stateRoot).catch(() => false);
136
+ await deleteGuideState(stateRoot);
137
+ return results;
138
+ }
64
139
  /**
65
140
  * uninstall: removes both integrations, then the shim once nothing uses it, asks.json and the store's temporary files
66
141
  * that a crash left behind (asks.json.<pid>.<uuid>.tmp), which can hold raw session ids.
@@ -81,22 +156,40 @@ export async function removeAsksInstall(env, stateRoot) {
81
156
  export async function runIntegrationsCommand(argv, io) {
82
157
  const parsed = parseIntegrationsArguments(argv);
83
158
  if (parsed === null) {
84
- io.stderr("Usage: mtec-ai-local-runner integrations status|install|remove [claude|codex]");
159
+ io.stderr("Usage: mtec-ai-local-runner integrations status|install|remove [claude|codex] [--feature asks|guide|asks,guide]");
85
160
  return 1;
86
161
  }
87
- let results;
162
+ const features = parsed.features ?? ["asks"];
163
+ let results = [];
164
+ if (features.includes("asks")) {
165
+ if (parsed.action === "install") {
166
+ results = await installIntegrations(parsed.apps, io.env, io.stateRoot);
167
+ }
168
+ else if (parsed.action === "remove") {
169
+ for (const app of parsed.apps)
170
+ results.push(await attempt(app, io.env, io.stateRoot, () => removeIntegration(app, io.env, io.stateRoot)));
171
+ await removeShimIfUnused(io.env, io.stateRoot).catch(() => false);
172
+ }
173
+ else {
174
+ results = await Promise.all(parsed.apps.map(app => statusOf(app, io.env, io.stateRoot)));
175
+ }
176
+ }
177
+ if (!features.includes("guide")) {
178
+ io.stdout(JSON.stringify({ integrations: results }));
179
+ return results.some(result => result.error !== undefined) ? 1 : 0;
180
+ }
181
+ let guide = [];
88
182
  if (parsed.action === "install") {
89
- results = await installIntegrations(parsed.apps, io.env, io.stateRoot);
183
+ guide = await installGuideIntegrations(parsed.apps, io.env, io.stateRoot);
90
184
  }
91
185
  else if (parsed.action === "remove") {
92
- results = [];
93
186
  for (const app of parsed.apps)
94
- results.push(await attempt(app, io.env, io.stateRoot, () => removeIntegration(app, io.env, io.stateRoot)));
95
- await removeShimIfUnused(io.env, io.stateRoot).catch(() => false);
187
+ guide.push(await attemptGuide(app, io.env, io.stateRoot, () => removeGuide(app, io.env, io.stateRoot)));
188
+ await removeGuideShimIfUnused(io.env, io.stateRoot).catch(() => false);
96
189
  }
97
190
  else {
98
- results = await Promise.all(parsed.apps.map(app => statusOf(app, io.env, io.stateRoot)));
191
+ guide = await Promise.all(parsed.apps.map(app => guideStatusOf(app, io.env, io.stateRoot)));
99
192
  }
100
- io.stdout(JSON.stringify({ integrations: results }));
101
- return results.some(result => result.error !== undefined) ? 1 : 0;
193
+ io.stdout(JSON.stringify({ integrations: results, guide }));
194
+ return [...results, ...guide].some(result => result.error !== undefined) ? 1 : 0;
102
195
  }
@@ -12,6 +12,7 @@ import { abortableDelay } from "../transport/abortable-delay.js";
12
12
  import { sideLoops, tickingHeartbeat } from "../transport/side-loops.js";
13
13
  import { packageVersion } from "../package-version.js";
14
14
  import { AskRuntime, withAsks } from "./runtime.js";
15
+ import { GuideRuntime, withGuide } from "../guide/runtime.js";
15
16
  /*
16
17
  * Messenger mode (Work Item e1c08b33, spec section 8.1). Setup chose it because no executor is admitted on
17
18
  * this Mac. The daemon runs the push transport only (Work Item 25d8ea74): there is no jobs loop, no adapter probe,
@@ -93,6 +94,8 @@ export async function buildMessengerDaemon(input) {
93
94
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
94
95
  const asks = new AskRuntime({ stateRoot: input.stateRoot, env: input.env, now: input.now, log, installedApps: input.installedApps,
95
96
  report: (deliveryId, report, signal) => gateway.reportAskDelivery({ deliveryId, ...report }, signal) });
97
+ const guide = new GuideRuntime({ stateRoot: input.stateRoot, env: input.env, now: input.now, log,
98
+ report: (day, signal) => gateway.reportGuidance(day, signal) });
96
99
  const heartbeat = messengerHeartbeat(async (signal) => {
97
100
  let sessionOpen;
98
101
  // A detection error only leaves session_open out; the Gateway keeps what it stored.
@@ -103,16 +106,18 @@ export async function buildMessengerDaemon(input) {
103
106
  sessionOpen = undefined;
104
107
  }
105
108
  const askInbox = await asks.advertisement();
109
+ const guidance = await guide.advertisement();
106
110
  await gateway.heartbeatLocalRunner({
107
111
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
108
112
  displayName: runnerDisplayName(), packageVersion: packageVersion(), capabilities: [], mode: "messenger",
109
113
  // Without an integration, session_open still goes out (clients only): the Gateway keeps the last one it stored, and would keep long-polling for asks.
110
114
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
115
+ ...(guidance ? { guidance } : {}),
111
116
  }, signal);
112
117
  // Without session_open the Gateway keeps its stored ask_inbox, which may be stale: send again, backing off.
113
118
  return sessionOpen !== undefined;
114
119
  }, { now: input.now, reportError: code => log({ event: "messenger_heartbeat_failed", code }),
115
- advertised: async () => JSON.stringify(await asks.advertisement() ?? null) });
120
+ advertised: async () => JSON.stringify([await asks.advertisement() ?? null, await guide.advertisement() ?? null]) });
116
121
  const opener = new SessionOpener({ home });
117
122
  const stopping = new AbortController();
118
123
  const closureAcks = new PendingClosureAcks();
@@ -122,6 +127,7 @@ export async function buildMessengerDaemon(input) {
122
127
  await gateway.reportSessionOpen({ openId, status: result.status, surface: result.surface,
123
128
  failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
124
129
  },
130
+ guide,
125
131
  asks,
126
132
  closureAcks,
127
133
  delay: abortableDelay,
@@ -133,7 +139,7 @@ export async function buildMessengerDaemon(input) {
133
139
  sync: async (reason, signal) => {
134
140
  const acknowledged = closureAcks.take();
135
141
  try {
136
- return await gateway.syncRunner({ adapterVersions: {}, reason, acknowledgedClosureIds: acknowledged }, signal);
142
+ return await gateway.syncRunner({ adapterVersions: {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion() }, signal);
137
143
  }
138
144
  catch (error) {
139
145
  closureAcks.restore(acknowledged);
@@ -156,7 +162,7 @@ export async function buildMessengerDaemon(input) {
156
162
  state: { load: async () => null },
157
163
  reconcile: async () => undefined,
158
164
  poller: idleJobPoller(),
159
- sessionOpens: withAsks(asks, sessionOpens),
165
+ sessionOpens: withGuide(guide, withAsks(asks, sessionOpens)),
160
166
  });
161
167
  return { daemon, close: async () => { daemon.stop(); } };
162
168
  }
@@ -8,12 +8,11 @@ import { fileURLToPath } from "node:url";
8
8
  * rewrites it at start so it follows the running release.
9
9
  */
10
10
  export const SHIM_FILE = join("bin", "gateway-hook");
11
+ /** The guide hooks' own stable entry point (Work Item d38d60f9): it execs dist/src/guide/hook-client.js. */
12
+ export const GUIDE_SHIM_FILE = join("bin", "gateway-guide-hook");
11
13
  // Paths go into single quotes in the shim and double quotes in hook commands; only characters that stay literal in
12
14
  // both are allowed (the same set as the PreToolUse hook command), so no quote, $, backquote or backslash.
13
15
  const SAFE_PATH = /^[A-Za-z0-9._/:@+~,=% -]+$/;
14
- export function shimPath(stateRoot) {
15
- return join(stateRoot, SHIM_FILE);
16
- }
17
16
  /** The hook client installed next to this module (dist/src/asks/hook-client.js in the package). */
18
17
  export function hookClientScript() {
19
18
  return fileURLToPath(new URL("./hook-client.js", import.meta.url));
@@ -25,9 +24,16 @@ function safePath(label, path) {
25
24
  }
26
25
  return path;
27
26
  }
28
- /** The command line in every installed hook. It names only the shim, the app and the event. */
27
+ export function shimPath(stateRoot, file = SHIM_FILE) {
28
+ return join(stateRoot, file);
29
+ }
30
+ /** The command line of an installed hook: the quoted shim, then the words given (an app and an event). */
31
+ export function shimCommand(stateRoot, file, args) {
32
+ return `"${safePath("shim", shimPath(stateRoot, file))}" ${args.join(" ")}`;
33
+ }
34
+ /** The command line in every installed asks hook. It names only the shim, the app and the event. */
29
35
  export function hookCommand(stateRoot, app, event) {
30
- return `"${safePath("shim", shimPath(stateRoot))}" ${app} ${event}`;
36
+ return shimCommand(stateRoot, SHIM_FILE, [app, event]);
31
37
  }
32
38
  export function renderShim(input) {
33
39
  const node = safePath("Node executable", input.nodePath);
@@ -35,7 +41,7 @@ export function renderShim(input) {
35
41
  const stateRoot = safePath("state root", input.stateRoot);
36
42
  return [
37
43
  "#!/bin/sh",
38
- "# Written by mtech-ai-local-runner for AI Gateway asks. The runner rewrites this file; do not edit it.",
44
+ `# Written by mtech-ai-local-runner for AI Gateway ${input.feature ?? "asks"}. The runner rewrites this file; do not edit it.`,
39
45
  `node='${node}'`,
40
46
  `client='${client}'`,
41
47
  "# A runner release that is gone must never disturb a session: print nothing and succeed.",
@@ -46,8 +52,9 @@ export function renderShim(input) {
46
52
  }
47
53
  /** Writes the shim atomically with mode 0755 for this release. Returns whether its content changed. */
48
54
  export async function writeShim(input) {
49
- const path = shimPath(input.stateRoot);
50
- const content = renderShim({ nodePath: input.nodePath ?? process.execPath, clientPath: input.clientPath ?? hookClientScript(), stateRoot: input.stateRoot });
55
+ const path = shimPath(input.stateRoot, input.file);
56
+ const content = renderShim({ nodePath: input.nodePath ?? process.execPath, clientPath: input.clientPath ?? hookClientScript(),
57
+ stateRoot: input.stateRoot, feature: input.feature });
51
58
  if (await readFile(path, "utf8").catch(() => null) === content)
52
59
  return { path, changed: false };
53
60
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
@@ -66,13 +73,13 @@ export async function writeShim(input) {
66
73
  /** Points an existing shim at the running release. It never creates one: installing an integration does. */
67
74
  export async function refreshShim(input) {
68
75
  try {
69
- await access(shimPath(input.stateRoot));
76
+ await access(shimPath(input.stateRoot, input.file));
70
77
  }
71
78
  catch {
72
79
  return false;
73
80
  }
74
81
  return (await writeShim(input)).changed;
75
82
  }
76
- export async function removeShim(stateRoot) {
77
- await rm(shimPath(stateRoot), { force: true });
83
+ export async function removeShim(stateRoot, file = SHIM_FILE) {
84
+ await rm(shimPath(stateRoot, file), { force: true });
78
85
  }
package/dist/src/cli.js CHANGED
@@ -31,8 +31,9 @@ import { verifyPackagedHook } from "./safety/hook-command.js";
31
31
  import { access, mkdir, rm } from "node:fs/promises";
32
32
  import { buildMessengerDaemon } from "./asks/messenger.js";
33
33
  import { runHookClient } from "./asks/hook-client.js";
34
- import { installIntegrations, parseSetupIntegrations, removeAsksInstall, runIntegrationsCommand } from "./asks/commands.js";
34
+ import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall, runIntegrationsCommand } from "./asks/commands.js";
35
35
  import { asksDoctorReport } from "./asks/doctor.js";
36
+ import { guideDoctorReport, guideReportShown } from "./guide/doctor.js";
36
37
  function hookAction(result) {
37
38
  return result.reason === "invalid_command"
38
39
  ? "Run the runner with a Node executable and package path outside the workspace, without quotes, $, backquotes or backslashes, then rerun doctor."
@@ -199,6 +200,9 @@ export async function main(argv, env, io = {}) {
199
200
  const requestedIntegrations = parseSetupIntegrations(argv.slice(1));
200
201
  if (requestedIntegrations === null)
201
202
  throw new Error("--integrations takes claude, codex or claude,codex");
203
+ const requestedFeatures = parseFeatures(argv.slice(1));
204
+ if (requestedFeatures === null)
205
+ throw new Error("--feature takes asks, guide or asks,guide");
202
206
  const existing = await existingRunnerMode(config.stateRoot, localStore);
203
207
  const detected = await (io.discoverAdapters?.() ?? discoverEmployeeAdapters(env));
204
208
  // A Mac already set up as a full runner is never turned into a messenger: it fails as before, untouched.
@@ -239,11 +243,13 @@ export async function main(argv, env, io = {}) {
239
243
  await localStore.save({ schemaVersion: 1, gatewayMcpUrl: config.gatewayMcpUrl, runnerId: registered.runner.id, workspaceRoot: config.workspaceRoot, concurrency: 1, ...(mode === "messenger" ? { mode } : {}) });
240
244
  // Only the apps named with --integrations: setup never asks. Before the daemon starts, so its first heartbeat
241
245
  // already advertises what is installed.
242
- const integrations = await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot);
246
+ const features = requestedFeatures ?? ["asks"];
247
+ const integrations = features.includes("asks") ? await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : [];
248
+ const guide = features.includes("guide") ? await installGuideIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : undefined;
243
249
  await launchd.install(process.argv[1], config.stateRoot);
244
250
  stdout(JSON.stringify(mode === "full"
245
- ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations }
246
- : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations,
251
+ ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}) }
252
+ : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}),
247
253
  action: "No executor is admitted on this Mac, so the runner is set up as a messenger: it delivers AI Gateway asks and opens sessions, and runs no jobs. Resolve the adapter diagnostics, then rerun setup to run jobs." }));
248
254
  return 0;
249
255
  }
@@ -251,8 +257,9 @@ export async function main(argv, env, io = {}) {
251
257
  if (config.mode === "uninstall") {
252
258
  await launchd.uninstall();
253
259
  const integrations = await removeAsksInstall(env, config.stateRoot);
260
+ const guide = await removeGuideInstall(env, config.stateRoot);
254
261
  await rm(join(config.stateRoot, "config.json"), { force: true });
255
- stdout(JSON.stringify({ uninstalled: true, integrations }));
262
+ stdout(JSON.stringify({ uninstalled: true, integrations, ...(guide.length > 0 ? { guide } : {}) }));
256
263
  return 0;
257
264
  }
258
265
  const doctorAdapters = config.mode === "doctor" ? adapterDiagnosticsWithRemedies(await (io.discoverAdapters?.() ?? discoverEmployeeAdapters(env))) : undefined;
@@ -313,6 +320,7 @@ export async function main(argv, env, io = {}) {
313
320
  // A messenger runs no executor, so neither an adapter nor the PreToolUse self-test is required of it.
314
321
  const hookCheck = messenger ? Promise.resolve(null) : hookSelfTest(local.workspaceRoot);
315
322
  const asksCheck = asksDoctorReport({ stateRoot: config.stateRoot, env, mode: runnerMode(local), run: io.claudePluginList });
323
+ const guideCheck = guideDoctorReport({ stateRoot: config.stateRoot, env });
316
324
  // Refresh rotating credentials once before the Gateway client asks for a token.
317
325
  const [oauth] = await Promise.allSettled([tokenProvider.getToken()]);
318
326
  const [gateway] = oauth.status === "fulfilled"
@@ -321,11 +329,12 @@ export async function main(argv, env, io = {}) {
321
329
  const [service, workspace] = await localChecks;
322
330
  const hook = await hookCheck;
323
331
  const asks = await asksCheck;
332
+ const guide = await guideCheck;
324
333
  const checks = [oauth, gateway, service, workspace];
325
334
  const loaded = service.status === "fulfilled" && service.value.loaded;
326
335
  const ok = checks.every(check => check.status === "fulfilled") && loaded && (hook === null || (hasUsableAdapter(adapters) && hook.enforcing));
327
336
  stdout(JSON.stringify({ ok, configured: true, adapters, checks: { runner_registration: true, oauth: oauth.status === "fulfilled", gateway: gateway.status === "fulfilled", workspace: workspace.status === "fulfilled", launchd: loaded, ...(hook === null ? {} : { hook: hook.enforcing }) },
328
- hook: hook === null ? { status: "not_required", reason: "messenger_mode" } : hookReport(hook), ...asks, ...(gateway.status === "fulfilled" ? { runner: gateway.value } : {}), ...(service.status === "fulfilled" ? { launchd: service.value } : {}) }));
337
+ hook: hook === null ? { status: "not_required", reason: "messenger_mode" } : hookReport(hook), ...asks, ...(guideReportShown(guide) ? { guide } : {}), ...(gateway.status === "fulfilled" ? { runner: gateway.value } : {}), ...(service.status === "fulfilled" ? { launchd: service.value } : {}) }));
329
338
  return ok ? 0 : 1;
330
339
  }
331
340
  if (config.mode === 'manual-codex') {
@@ -31,6 +31,7 @@ import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
31
31
  import { packagedHookCommandParts } from "../safety/hook-command.js";
32
32
  import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
33
33
  import { AskRuntime, withAsks } from '../asks/runtime.js';
34
+ import { GuideRuntime, withGuide } from '../guide/runtime.js';
34
35
  export async function buildDaemon(input) {
35
36
  const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
36
37
  if (claudeParts !== 0 && claudeParts !== 3)
@@ -54,6 +55,10 @@ export async function buildDaemon(input) {
54
55
  const asks = new AskRuntime({ stateRoot: input.stateRoot, env: input.env,
55
56
  report: (deliveryId, report, signal) => gateway.reportAskDelivery({ deliveryId, ...report }, signal),
56
57
  log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
58
+ // Guide hooks (Work Item d38d60f9) ride on the same sync and heartbeat; they start no executor.
59
+ const guide = new GuideRuntime({ stateRoot: input.stateRoot, env: input.env,
60
+ report: (day, signal) => gateway.reportGuidance(day, signal),
61
+ log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
57
62
  const localFixHost = input.localFixHost ?? await loadInstalledLocalFixHost({ stateRoot: input.stateRoot, runnerId: input.runnerId, gateway });
58
63
  // A test-injected host wins; otherwise only the bundled reviewed registration can enable P1.
59
64
  let p1Host = input.p1Host;
@@ -201,12 +206,14 @@ export async function buildDaemon(input) {
201
206
  sessionOpen = undefined;
202
207
  }
203
208
  const askInbox = await asks.advertisement();
209
+ const guidance = await guide.advertisement();
204
210
  return {
205
211
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
206
212
  displayName: runnerDisplayName(), packageVersion: packageVersion(),
207
213
  capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode), poller.accepting),
208
214
  mode: "full",
209
215
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
216
+ ...(guidance ? { guidance } : {}),
210
217
  };
211
218
  },
212
219
  send: async (payload) => { await gateway.heartbeatLocalRunner(payload); },
@@ -221,6 +228,7 @@ export async function buildDaemon(input) {
221
228
  await gateway.reportSessionOpen({ openId, status: result.status, surface: result.surface,
222
229
  failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
223
230
  },
231
+ guide,
224
232
  asks,
225
233
  closureAcks,
226
234
  jobs: poller,
@@ -233,7 +241,7 @@ export async function buildDaemon(input) {
233
241
  const acknowledged = closureAcks.take();
234
242
  // A runner that takes no jobs right now (reconciling, or suspended for recovery) names no adapter.
235
243
  try {
236
- return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged }, signal);
244
+ return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged, guidancePolicyVersion: await guide.policyVersion() }, signal);
237
245
  }
238
246
  catch (error) {
239
247
  closureAcks.restore(acknowledged);
@@ -259,7 +267,7 @@ export async function buildDaemon(input) {
259
267
  return;
260
268
  throw new Error('Runner state requires authoritative reconciliation');
261
269
  },
262
- poller, sessionOpens: withAsks(asks, sessionOpens),
270
+ poller, sessionOpens: withGuide(guide, withAsks(asks, sessionOpens)),
263
271
  reportRecoveryRequired: reason => { process.stderr.write(`${JSON.stringify({ event: 'runner_recovery_required', reason })}\n`); },
264
272
  });
265
273
  // Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
@@ -29,6 +29,8 @@ export function parseRunnerSync(result, runnerId) {
29
29
  opens: body.opens,
30
30
  ...(Array.isArray(body.ask_deliveries) ? { askDeliveries: body.ask_deliveries } : {}),
31
31
  ...(Array.isArray(body.closed_delivery_ids) ? { closedDeliveryIds: body.closed_delivery_ids } : {}),
32
+ ...("guidance_policy" in body ? { guidancePolicy: body.guidance_policy } : {}),
33
+ ...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
32
34
  jobAvailable: body.job_available === true,
33
35
  push: parsePush(body.push, runnerId),
34
36
  syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
@@ -489,6 +491,7 @@ export class McpGatewayClient {
489
491
  capabilities: input.capabilities,
490
492
  ...(input.mode ? { mode: input.mode } : {}),
491
493
  ...(input.sessionOpen ? { session_open: input.sessionOpen } : {}),
494
+ ...(input.guidance ? { guidance: input.guidance } : {}),
492
495
  },
493
496
  }, signal);
494
497
  }
@@ -524,6 +527,16 @@ export class McpGatewayClient {
524
527
  ...(input.failureCode ? { failure_code: input.failureCode } : {}),
525
528
  }, signal);
526
529
  }
530
+ /** One UTC day of guide counts for one app and project (Work Item d38d60f9). Fixed keys and whole numbers only. */
531
+ async reportGuidance(day, signal) {
532
+ return this.callTool("local_agent_guidance_report", {
533
+ runner_id: this.runnerId,
534
+ date: day.date,
535
+ app: day.app,
536
+ project_key: day.projectKey,
537
+ counts: day.counts,
538
+ }, signal);
539
+ }
527
540
  /** acknowledgedClosureIds: closures handled since the last sync; the Gateway lists each again until acknowledged. */
528
541
  async syncRunner(input, signal) {
529
542
  const acknowledged = (input.acknowledgedClosureIds ?? []).slice(0, 50);
@@ -532,6 +545,7 @@ export class McpGatewayClient {
532
545
  adapter_versions: input.adapterVersions,
533
546
  reason: input.reason,
534
547
  ...(acknowledged.length > 0 ? { acknowledged_closure_ids: acknowledged } : {}),
548
+ ...(input.guidancePolicyVersion ? { guidance_policy_version: input.guidancePolicyVersion } : {}),
535
549
  }, signal);
536
550
  return parseRunnerSync(result, this.runnerId);
537
551
  }
@@ -0,0 +1,58 @@
1
+ import { COUNT_KEYS, PROJECT_KEY, RULE_IDS, emptyDayCounts, isGuideApp, isOutcome, isRuleId } from "./types.js";
2
+ /*
3
+ * One UTC day of count lines turned into the counts of local_agent_guidance_report (spec section 7), per app and project.
4
+ * A session counts once, through its "session" line, which the hook writes at its first event; it counts as reading,
5
+ * searching or both from its lines of the same day.
6
+ */
7
+ const SESSION_KEYS = new Set(["sessions", "sessions_with_kb_read", "sessions_with_search", "sessions_with_both"]);
8
+ export function aggregateDay(lines) {
9
+ const groups = new Map();
10
+ for (const line of lines) {
11
+ if (!isGuideApp(line.a) || !PROJECT_KEY.test(line.p))
12
+ continue;
13
+ const key = `${line.a}|${line.p}`;
14
+ let group = groups.get(key);
15
+ if (group === undefined) {
16
+ group = { counts: emptyDayCounts(), sessions: new Set(), read: new Set(), searched: new Set() };
17
+ groups.set(key, group);
18
+ }
19
+ const n = Number.isSafeInteger(line.n) && line.n >= 0 ? line.n : 0;
20
+ if (line.m === "session") {
21
+ group.sessions.add(line.s);
22
+ }
23
+ else if (line.m === "rule") {
24
+ if (isRuleId(line.r) && isOutcome(line.o))
25
+ group.counts.rules[line.r][line.o] += n;
26
+ }
27
+ else if (COUNT_KEYS.includes(line.m) && !SESSION_KEYS.has(line.m)) {
28
+ group.counts[line.m] += n;
29
+ if (line.m.startsWith("kb_reads_"))
30
+ group.read.add(line.s);
31
+ if (line.m === "knowledge_search_calls")
32
+ group.searched.add(line.s);
33
+ }
34
+ }
35
+ const result = new Map();
36
+ for (const [key, group] of groups) {
37
+ const sessions = [...group.sessions];
38
+ group.counts.sessions = sessions.length;
39
+ group.counts.sessions_with_kb_read = sessions.filter(session => group.read.has(session)).length;
40
+ group.counts.sessions_with_search = sessions.filter(session => group.searched.has(session)).length;
41
+ group.counts.sessions_with_both = sessions.filter(session => group.read.has(session) && group.searched.has(session)).length;
42
+ result.set(key, group.counts);
43
+ }
44
+ return result;
45
+ }
46
+ /** The sum of several days or groups, for doctor's today line. */
47
+ export function sumDays(days) {
48
+ const total = emptyDayCounts();
49
+ for (const day of days) {
50
+ for (const key of COUNT_KEYS)
51
+ total[key] += day[key];
52
+ for (const rule of RULE_IDS) {
53
+ total.rules[rule].observed += day.rules[rule].observed;
54
+ total.rules[rule].warned += day.rules[rule].warned;
55
+ }
56
+ }
57
+ return total;
58
+ }