mtech-ai-local-runner 0.1.66 → 0.1.68

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.
@@ -243,7 +243,7 @@ export class ClaudeCodeAdapter {
243
243
  continue;
244
244
  if (parsed.eventType === "completed") {
245
245
  const observedUsage = parsed.payload?.usage;
246
- parsed = completionEvent(message.result, parsed.payload?.cost_usd);
246
+ parsed = completionEvent(message.result, parsed.payload?.cost_usd, { earlierCodeBlocks: true });
247
247
  if (this.p1)
248
248
  parsed = p1ModelEvent({ ...parsed, payload: { ...parsed.payload, usage: observedUsage } });
249
249
  }
@@ -1 +1,20 @@
1
- export { compilePrompt } from "../prompt-compiler.js";
1
+ import { compilePrompt as compileBrief } from "../prompt-compiler.js";
2
+ import { LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
3
+ // Claude's working directory is the job's private control directory, so without these rules it sends that directory's
4
+ // absolute path or shell commands to the ai_local tools (P4 Investigate of Work Item 1c8026f1, 2026-10-28). Without the
5
+ // search and citation rules it searched several words, found nothing and cited lines counted from a whole-file read.
6
+ const CLAUDE_PATH_RULES = "## Local tool path rules\n" +
7
+ "Every path argument is one checkout-relative file path inside the approved scope, without a leading slash: write " +
8
+ "/Modules/Example.php as Modules/Example.php. Never pass your working directory or any absolute path. " +
9
+ "local_search searches only inside that one file; there is no directory listing or repository-wide search, so take " +
10
+ "file paths from the objective and from files you have read. local_search returns the lines that contain the query " +
11
+ "exactly, case-sensitive, each with its line number, so search for one identifier or literal, such as assertBranchHead, " +
12
+ "not several words. Cite a line number only from a local_search match or a local_read with start_line and end_line, and " +
13
+ "never count lines in a whole-file read. local_test runs only a registered command ID, never a " +
14
+ "shell command. A DENIED_* result names the rule your call broke, such as DENIED_PATH for an absolute path, " +
15
+ "DENIED_FILE for a directory, DENIED_SCOPE for a path outside the approved scope or DENIED_COMMAND for an unregistered " +
16
+ "command: send a corrected call instead of concluding that source is unavailable. LOCAL_TOOL_FAILED means the call " +
17
+ "failed for another reason, such as a file that does not exist.";
18
+ export function compilePrompt(taskPackage, options) {
19
+ return `${compileBrief(taskPackage, options)}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}\n\n${CLAUDE_PATH_RULES}`;
20
+ }
@@ -1,29 +1,7 @@
1
1
  import { compilePrompt as compileBrief } from "../prompt-compiler.js";
2
- function repositoryScope(taskPackage) {
3
- const repositories = taskPackage.repositories;
4
- const repo = Array.isArray(repositories) && repositories.length === 1
5
- && repositories[0] && typeof repositories[0] === "object" && !Array.isArray(repositories[0])
6
- ? repositories[0] : undefined;
7
- const paths = (value) => Array.isArray(value)
8
- && value.every(item => typeof item === "string" && item.length > 0);
9
- if (!repo || !paths(repo.allowed_paths) || !repo.allowed_paths.length || !paths(repo.blocked_paths)
10
- || !["read", "write"].includes(String(repo.access))
11
- || typeof repo.repository_key !== "string" || typeof repo.base_commit_sha !== "string"
12
- || !/^[a-f0-9]{40}$/i.test(repo.base_commit_sha)) {
13
- return "## Approved repository scope\nRepository scope is unavailable; do not guess source paths or commands. Stop and emit one final blocked report.";
14
- }
15
- // Project only source-scope fields from the binding bundle. Never stringify
16
- // the whole repository: it may contain transport coordinates or write leases.
17
- const scope = { repository_key: repo.repository_key, base_commit_sha: repo.base_commit_sha,
18
- access: repo.access, allowed_paths: repo.allowed_paths, blocked_paths: repo.blocked_paths };
19
- return `## Approved repository scope\n${JSON.stringify(scope, null, 2)}\n` +
20
- "Use these checkout-relative paths exactly, including case. Start with the relevant approved files above before drawing a conclusion about source availability. " +
21
- "Do not invent paths or commands from framework conventions. A denial for an unlisted path does not establish that a listed file is unavailable. " +
22
- "Do not claim an approved file was denied unless its exact path was attempted and the tool returned a denial. " +
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
- }
2
+ import { LOCAL_TOOL_GUIDANCE, repositoryScope } from "../local-tool-prompt.js";
25
3
  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, 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.`;
4
+ return `${compileBrief(taskPackage, { workItemLabel: label, progressReporting: "native_messages" })}\n\n${repositoryScope(taskPackage)}\n\n${LOCAL_TOOL_GUIDANCE}`;
27
5
  }
28
6
  export const CODEX_REPORT_SCHEMA = {
29
7
  type: "object", additionalProperties: false, required: ["status", "report", "evidence", "pending_work"],
@@ -75,8 +75,33 @@ export const COMPLETION_REPORT_NOTICE = "Do the assigned work yourself using the
75
75
  function boundedText(value, max) {
76
76
  return typeof value === "string" && value.trim().length > 0 && value.length <= max;
77
77
  }
78
+ /** The body of the final fenced block when it can only be the report: it ends the text, and no other block may be one. */
79
+ function finalFencedReport(result, options) {
80
+ const text = result.trim(), blocks = [];
81
+ let open;
82
+ for (const line of text.split(/\r?\n/)) {
83
+ if (!open) {
84
+ const fence = line.match(/^```([^`\s]*)[ \t]*$/);
85
+ if (fence)
86
+ blocks.push(open = { info: fence[1], body: [] });
87
+ }
88
+ else if (line.trimEnd() === "```")
89
+ open = undefined;
90
+ else
91
+ open.body.push(line);
92
+ }
93
+ const last = blocks.at(-1);
94
+ if (!last || open || !text.endsWith("```") || (text.match(/```/g) ?? []).length !== blocks.length * 2)
95
+ return undefined;
96
+ const earlier = blocks.slice(0, -1);
97
+ if (earlier.length === 0)
98
+ return last.info === "json" || last.info === "" ? last.body.join("\n") : undefined;
99
+ if (!options.earlierCodeBlocks || last.info !== "json" || earlier.some(block => block.info === "json" || block.info === ""))
100
+ return undefined;
101
+ return last.body.join("\n");
102
+ }
78
103
  /** Decode bounded framing only. Callers must still validate status and evidence. */
79
- export function completionEnvelope(result) {
104
+ export function completionEnvelope(result, options = {}) {
80
105
  if (typeof result !== "string" || result.length > MAX_REPORT_ENVELOPE_CHARS)
81
106
  return undefined;
82
107
  let report;
@@ -84,13 +109,11 @@ export function completionEnvelope(result) {
84
109
  report = JSON.parse(result);
85
110
  }
86
111
  catch {
87
- if ((result.match(/```/g) ?? []).length !== 2)
88
- return undefined;
89
- const framed = result.trim().match(/^(?:[\s\S]*?\n)?```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/);
90
- if (!framed)
112
+ const framed = finalFencedReport(result, options);
113
+ if (framed === undefined)
91
114
  return undefined;
92
115
  try {
93
- report = JSON.parse(framed[1]);
116
+ report = JSON.parse(framed);
94
117
  }
95
118
  catch {
96
119
  return undefined;
@@ -100,7 +123,7 @@ export function completionEnvelope(result) {
100
123
  ? report : undefined;
101
124
  }
102
125
  /** Validate the full final result before truncation; never publish malformed/interim output. */
103
- export function completionEvent(result, costUsd) {
126
+ export function completionEvent(result, costUsd, options = {}) {
104
127
  const usage = typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0
105
128
  ? { cost_usd: costUsd }
106
129
  : {};
@@ -109,7 +132,7 @@ export function completionEvent(result, costUsd) {
109
132
  summary: "completion report is missing, incomplete or invalid",
110
133
  payload: usage,
111
134
  };
112
- const value = completionEnvelope(result);
135
+ const value = completionEnvelope(result, options);
113
136
  const reject = (code) => ({ ...failed, payload: { ...usage, completion_rejection: { version: 'completion-rejection-v1', code } } });
114
137
  if (!value)
115
138
  return reject('ENVELOPE_INVALID');
@@ -0,0 +1,25 @@
1
+ // Approved source scope and ai_local tool guidance for CLI adapters whose model sees only the job MCP tools.
2
+ export function repositoryScope(taskPackage) {
3
+ const repositories = taskPackage.repositories;
4
+ const repo = Array.isArray(repositories) && repositories.length === 1
5
+ && repositories[0] && typeof repositories[0] === "object" && !Array.isArray(repositories[0])
6
+ ? repositories[0] : undefined;
7
+ const paths = (value) => Array.isArray(value)
8
+ && value.every(item => typeof item === "string" && item.length > 0);
9
+ if (!repo || !paths(repo.allowed_paths) || !repo.allowed_paths.length || !paths(repo.blocked_paths)
10
+ || !["read", "write"].includes(String(repo.access))
11
+ || typeof repo.repository_key !== "string" || typeof repo.base_commit_sha !== "string"
12
+ || !/^[a-f0-9]{40}$/i.test(repo.base_commit_sha)) {
13
+ return "## Approved repository scope\nRepository scope is unavailable; do not guess source paths or commands. Stop and emit one final blocked report.";
14
+ }
15
+ // Project only source-scope fields from the binding bundle. Never stringify
16
+ // the whole repository: it may contain transport coordinates or write leases.
17
+ const scope = { repository_key: repo.repository_key, base_commit_sha: repo.base_commit_sha,
18
+ access: repo.access, allowed_paths: repo.allowed_paths, blocked_paths: repo.blocked_paths };
19
+ return `## Approved repository scope\n${JSON.stringify(scope, null, 2)}\n` +
20
+ "Use these checkout-relative paths exactly, including case. Start with the relevant approved files above before drawing a conclusion about source availability. " +
21
+ "Do not invent paths or commands from framework conventions. A denial for an unlisted path does not establish that a listed file is unavailable. " +
22
+ "Do not claim an approved file was denied unless its exact path was attempted and the tool returned a denial. " +
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
+ }
25
+ export const LOCAL_TOOL_GUIDANCE = "Use 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.";
@@ -6,7 +6,9 @@ function requiredTestsFromTaskPackage(taskPackage) {
6
6
  const pkg = taskPackage;
7
7
  const execution = pkg.agent_execution;
8
8
  const brief = execution?.implementation_brief;
9
- const requiredTests = brief?.required_tests;
9
+ // A missing list means no commands, as in the prompt compiler: Gateways before the empty-list fix dropped an
10
+ // investigation's required_tests: []. A value that is not a list is still a malformed package.
11
+ const requiredTests = brief?.required_tests ?? [];
10
12
  if (!Array.isArray(requiredTests)) {
11
13
  throw new Error("task package is missing agent_execution.implementation_brief.required_tests");
12
14
  }
@@ -1,6 +1,9 @@
1
1
  import { validateLocalReadRange } from './local-read-range.js';
2
2
  import { localToolInventory } from './local-tool-server.js';
3
3
  const rejected = () => new Error('JOB_TOOL_DISPATCH_REJECTED');
4
+ /** The scoped policy's stable denial code, which LocalToolServer already lets a model see, so a model can correct its
5
+ * call. Only an exact code qualifies: any other failure, including binding and lease checks, stays one masked rejection. */
6
+ export const localToolDenialCode = (error) => error instanceof Error && /^(?:DENIED_[A-Z_]+|LOCAL_SANDBOX_UNAVAILABLE)$/.test(error.message) ? error.message : undefined;
4
7
  function record(value) { return !!value && typeof value === 'object' && !Array.isArray(value); }
5
8
  const id = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[\r\n\0]/.test(value);
6
9
  const keys = (value, expected) => JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...expected].sort());
@@ -52,8 +55,9 @@ export class JobToolDispatcher {
52
55
  throw rejected();
53
56
  return result;
54
57
  }
55
- catch {
56
- throw rejected();
58
+ catch (error) {
59
+ const code = localToolDenialCode(error);
60
+ throw code ? new Error(code) : rejected();
57
61
  }
58
62
  }
59
63
  }
@@ -1,4 +1,4 @@
1
- import { JobToolDispatcher } from './job-tool-dispatcher.js';
1
+ import { JobToolDispatcher, localToolDenialCode } from './job-tool-dispatcher.js';
2
2
  import { LocalToolPolicy } from './local-tool-policy.js';
3
3
  import { LocalToolServer } from './local-tool-server.js';
4
4
  const sameBinding = (a, b) => b !== null && a.jobId === b.jobId && a.threadId === b.threadId && a.turnId === b.turnId && a.generation === b.generation;
@@ -32,8 +32,8 @@ export class JobToolSession {
32
32
  throw Error();
33
33
  return await this.dispatcher.dispatch({ ...this.bound, requestId: String(++this.sequence), tool, args });
34
34
  }
35
- catch {
36
- throw new Error('JOB_TOOL_DISPATCH_REJECTED');
35
+ catch (error) {
36
+ throw new Error(localToolDenialCode(error) ?? 'JOB_TOOL_DISPATCH_REJECTED');
37
37
  }
38
38
  }
39
39
  async start() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.66",
3
+ "version": "0.1.68",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {