mtech-ai-local-runner 0.1.93 → 0.1.95
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 +32 -0
- package/dist/src/adapters/completion-report-preflight.js +20 -0
- package/dist/src/adapters/completion-report.js +13 -0
- package/dist/src/publication/git-snapshot.js +45 -8
- package/dist/src/publication/git-transport.js +4 -4
- package/dist/src/publication/raw-git-objects.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
## Unreleased: bounded report drafting and offline preflight
|
|
2
|
+
|
|
3
|
+
The final writing guidance targets 1,200 UTF-16 code units in `report`, below
|
|
4
|
+
the unchanged 2,000-unit acceptance limit. Findings and uncertainty belong in
|
|
5
|
+
the short report; detailed citations belong in `evidence`. No automatic
|
|
6
|
+
truncation, content repair, model retry, new tool or launch permission is added.
|
|
7
|
+
Prompt guidance alone cannot guarantee model compliance: an oversized final
|
|
8
|
+
response still fails closed. These changes are source-only, not part of the
|
|
9
|
+
installed/reviewed 0.1.93 package.
|
|
10
|
+
|
|
11
|
+
From the `runners/local-agent` source checkout, before another live validation,
|
|
12
|
+
replay saved **final response text**, not a transcript or stream-JSON wrapper,
|
|
13
|
+
through the production completion validator (the script is a repository-only
|
|
14
|
+
rehearsal utility, not an installed Runner command):
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm run build
|
|
18
|
+
node scripts/check-completion-report.mjs /absolute/path/to/final-response.txt
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This offline script reads a bounded UTF-8 file, emits only counts and fixed
|
|
22
|
+
diagnostics, and never rewrites the input, calls a model, accesses Gateway or
|
|
23
|
+
starts work. Exit 0 means valid transport (including a truthful `blocked`
|
|
24
|
+
report), 1 means rejected transport, and 2 means usage/read/encoding/input-size
|
|
25
|
+
failure. A reported `completed` status does not verify the investigation,
|
|
26
|
+
citations or Work Item completion. The exact 2026-11-05 failure is preserved as
|
|
27
|
+
an untrusted technical test fixture; its claims are not accepted as true.
|
|
28
|
+
|
|
29
|
+
No package publication, Gateway registration, Runner replacement or new live
|
|
30
|
+
execution has been performed for this source change. Those require separate
|
|
31
|
+
release review, exact hashes and execution approval.
|
|
32
|
+
|
|
1
33
|
## 0.1.93: strict investigation JSON completion guidance
|
|
2
34
|
|
|
3
35
|
Claude uses native-stream progress guidance and receives scoped tool/correction
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { completionEnvelope, completionEvent, COMPLETION_REPORT_LIMITS } from './completion-report.js';
|
|
2
|
+
/** Offline diagnostics only: never rewrites output, creates evidence, or authorizes execution. */
|
|
3
|
+
export function completionReportPreflight(raw) {
|
|
4
|
+
const options = { earlierCodeBlocks: true };
|
|
5
|
+
const envelope = completionEnvelope(raw, options);
|
|
6
|
+
const event = completionEvent(raw, undefined, options);
|
|
7
|
+
const rejection = event.payload?.completion_rejection;
|
|
8
|
+
const valid = event.eventType === 'completed' || event.payload?.reason === 'executor_blocked';
|
|
9
|
+
return {
|
|
10
|
+
valid_transport: valid,
|
|
11
|
+
// This is transport status only, not independent review or Work Item completion.
|
|
12
|
+
completed: event.eventType === 'completed',
|
|
13
|
+
status: valid ? (event.eventType === 'completed' ? 'completed' : 'blocked') : 'rejected',
|
|
14
|
+
rejection_code: rejection?.code ?? null,
|
|
15
|
+
report_code_units: typeof envelope?.report === 'string' ? envelope.report.length : null,
|
|
16
|
+
report_limit: COMPLETION_REPORT_LIMITS.reportCodeUnits,
|
|
17
|
+
evidence_count: Array.isArray(envelope?.evidence) ? envelope.evidence.length : null,
|
|
18
|
+
pending_work_count: Array.isArray(envelope?.pending_work) ? envelope.pending_work.length : null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -22,6 +22,14 @@ export function reportRejectionCounts(value, total) {
|
|
|
22
22
|
const MAX_EVIDENCE_ITEMS = 20;
|
|
23
23
|
const MAX_EVIDENCE_CHARS = 500;
|
|
24
24
|
const MAX_REPORT_ENVELOPE_CHARS = 16_000;
|
|
25
|
+
/** Construction/rehearsal hints; acceptance always goes through completionEvent. */
|
|
26
|
+
export const COMPLETION_REPORT_LIMITS = Object.freeze({
|
|
27
|
+
reportCodeUnits: MAX_EVENT_SUMMARY_CHARS,
|
|
28
|
+
targetReportCodeUnits: Math.min(1200, MAX_EVENT_SUMMARY_CHARS),
|
|
29
|
+
listItems: MAX_EVIDENCE_ITEMS,
|
|
30
|
+
itemCodeUnits: MAX_EVIDENCE_CHARS,
|
|
31
|
+
envelopeCodeUnits: MAX_REPORT_ENVELOPE_CHARS,
|
|
32
|
+
});
|
|
25
33
|
const REPORT_FAILURE_CODES = ["CODEX_COMPLETION_REPORT_MISSING", "CODEX_COMPLETION_REPORT_INVALID", "CODEX_COMPLETION_REPORT_MULTIPLE", "CODEX_COMPLETION_REPORT_NOT_FINAL", "CODEX_TURN_INCOMPLETE"];
|
|
26
34
|
const REPORT_DIAGNOSTIC_CODES = [...REPORT_FAILURE_CODES, "CODEX_COMPLETION_REPORT_BLOCKED"];
|
|
27
35
|
/** Pair counts only; no item IDs, text, stable digests or arbitrary keys. */
|
|
@@ -68,6 +76,11 @@ export const COMPLETION_REPORT_NOTICE = "Do the assigned work yourself using the
|
|
|
68
76
|
"Your final response must be one JSON object, without Markdown fences: " +
|
|
69
77
|
'{"status":"completed","report":"actual findings and results","evidence":["file path and observed finding, or command and actual result"],"pending_work":[]}. ' +
|
|
70
78
|
`The report must be nonempty and at most ${MAX_EVENT_SUMMARY_CHARS} characters. ` +
|
|
79
|
+
`Aim for at most ${COMPLETION_REPORT_LIMITS.targetReportCodeUnits} UTF-16 code units in the decoded report string; the hard limit remains ${MAX_EVENT_SUMMARY_CHARS}. ` +
|
|
80
|
+
"Count decoded string length, not tokens, words, UTF-8 bytes or JSON escape characters; an emoji can count as two units. " +
|
|
81
|
+
"Use short findings for the requested topics, then uncertainty and verification limits. Put detailed source citations in evidence instead of repeating them in report. " +
|
|
82
|
+
"Do not fill the available limit or duplicate a narrative before the JSON. Before sending your single final response, shorten your own draft to the target and recheck all fields. " +
|
|
83
|
+
"Never drop uncertainty or pending work to fit the report; use blocked and accurate pending_work when work is incomplete. " +
|
|
71
84
|
`Include 1-${MAX_EVIDENCE_ITEMS} nonempty evidence strings, each at most ${MAX_EVIDENCE_CHARS} characters. ` +
|
|
72
85
|
"Address the assigned objective and each applicable acceptance criterion with observed evidence, not a plan or promise. " +
|
|
73
86
|
"Only attest to executor work you observed; Gateway records transport, completion, and review receipts separately. " +
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
|
-
import { lstat, mkdir, mkdtemp, open, realpath, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { lstat, mkdir, mkdtemp, open, realpath, readdir, readFile, readlink, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
@@ -11,6 +11,7 @@ const execute = promisify(execFile), denied = () => Error('SNAPSHOT_REJECTED');
|
|
|
11
11
|
const digest = (data) => createHash('sha256').update(data).digest('hex');
|
|
12
12
|
const structural = (p) => p.length > 0 && !path.isAbsolute(p) && !p.includes('\\') && !p.includes('\0') && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^\.git$/i.test(s));
|
|
13
13
|
const safe = (p) => structural(p) && p.split('/').every(s => !/^(?:\.ssh|\.aws|\.codex|\.claude|\.env|\.netrc|\.npmrc|credentials(?:\..*)?)$/i.test(s));
|
|
14
|
+
const TRACKED_SYMLINK = /^120000 blob ([a-f0-9]{40})\t(.+)$/s;
|
|
14
15
|
/** P1 branch shape only. Authority still requires exact frozen-fence equality. */
|
|
15
16
|
/** Whole-checkout memory bound for a snapshot, per file and in total. A snapshot reads every
|
|
16
17
|
* file of the checkout, so callers bound candidate size on the changed files instead. */
|
|
@@ -136,6 +137,19 @@ export class GitSnapshotHost {
|
|
|
136
137
|
await fd.close();
|
|
137
138
|
}
|
|
138
139
|
}
|
|
140
|
+
/** A tracked symlink must stay the exact base link. Its target is read with readlink and never followed. */
|
|
141
|
+
async assertTrackedSymlink(relative, target) {
|
|
142
|
+
let component = this.options.root;
|
|
143
|
+
const parts = relative.split('/');
|
|
144
|
+
for (let i = 0; i < parts.length; i++) {
|
|
145
|
+
component = path.join(component, parts[i]);
|
|
146
|
+
const stat = await lstat(component);
|
|
147
|
+
if (i < parts.length - 1 ? stat.isSymbolicLink() || !stat.isDirectory() : !stat.isSymbolicLink())
|
|
148
|
+
throw denied();
|
|
149
|
+
}
|
|
150
|
+
if (!(await readlink(component, { encoding: 'buffer' })).equals(target))
|
|
151
|
+
throw denied();
|
|
152
|
+
}
|
|
139
153
|
/** Checks the options every read relies on, then that HEAD is the base commit on the working branch. */
|
|
140
154
|
async head() {
|
|
141
155
|
const o = this.options, branchAllowed = o.p1WorkingBranch === undefined ? /^ai-work\/local\/[A-Za-z0-9-]+$/.test(o.workingBranch) : o.p1WorkingBranch === o.workingBranch && isP1WorkingBranch(o.workingBranch);
|
|
@@ -149,7 +163,7 @@ export class GitSnapshotHost {
|
|
|
149
163
|
const o = this.options;
|
|
150
164
|
await this.head();
|
|
151
165
|
const head = o.baseCommitSha, branch = o.workingBranch;
|
|
152
|
-
const base = new Map(), gitlinks = new Map(), protectedNames = new Set(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
|
|
166
|
+
const base = new Map(), gitlinks = new Map(), symlinks = new Map(), protectedNames = new Set(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
|
|
153
167
|
for (const line of baseListing.toString().split('\0').filter(Boolean)) {
|
|
154
168
|
const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
155
169
|
if (link) {
|
|
@@ -158,6 +172,13 @@ export class GitSnapshotHost {
|
|
|
158
172
|
gitlinks.set(link[2], link[1]);
|
|
159
173
|
continue;
|
|
160
174
|
}
|
|
175
|
+
const symlink = TRACKED_SYMLINK.exec(line);
|
|
176
|
+
if (symlink) {
|
|
177
|
+
if (!safe(symlink[2]))
|
|
178
|
+
throw denied();
|
|
179
|
+
symlinks.set(symlink[2], symlink[1]);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
161
182
|
const match = /^(100644|100755) blob ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
162
183
|
if (!match || !structural(match[3]))
|
|
163
184
|
throw denied();
|
|
@@ -166,11 +187,11 @@ export class GitSnapshotHost {
|
|
|
166
187
|
protectedNames.add(match[3]);
|
|
167
188
|
}
|
|
168
189
|
// Protected names the base commit already tracks stay as exact base content; only a change to one is refused.
|
|
169
|
-
// Gitlinks remain exact base-tree references. Never traverse or copy their source.
|
|
190
|
+
// Gitlinks and tracked symlinks remain exact base-tree references. Never traverse or copy their source.
|
|
170
191
|
// Staged replacement/deletion is refused rather than publishing hidden changes.
|
|
171
192
|
const staged = new Map(), stagedListing = await this.git(['ls-files', '--stage', '-z']);
|
|
172
193
|
for (const line of stagedListing.toString().split('\0').filter(Boolean)) {
|
|
173
|
-
const entry = /^(100644|100755|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
|
|
194
|
+
const entry = /^(100644|100755|120000|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
|
|
174
195
|
if (!entry || !structural(entry[3]))
|
|
175
196
|
throw denied();
|
|
176
197
|
staged.set(entry[3], entry[1] + ' ' + entry[2]);
|
|
@@ -178,15 +199,26 @@ export class GitSnapshotHost {
|
|
|
178
199
|
const expected = new Map([...base.entries()].map(([name, value]) => [name, value.mode.toString(8) + ' ' + value.blob]));
|
|
179
200
|
for (const [name, oid] of gitlinks)
|
|
180
201
|
expected.set(name, '160000 ' + oid);
|
|
202
|
+
for (const [name, oid] of symlinks)
|
|
203
|
+
expected.set(name, '120000 ' + oid);
|
|
181
204
|
if (protocolHash([...expected].sort()) !== protocolHash([...staged].sort()))
|
|
182
205
|
throw denied();
|
|
183
206
|
const untrackedListing = await this.git(['ls-files', '--others', '--exclude-standard', '-z', '--']);
|
|
184
207
|
const names = [...new Set([...base.keys(), ...untrackedListing.toString().split('\0').filter(Boolean)])].sort();
|
|
185
|
-
const blobs = await this.batchObjects([...base.values()].map(v => v.blob));
|
|
208
|
+
const blobs = await this.batchObjects([...[...base.values()].map(v => v.blob), ...symlinks.values()]);
|
|
209
|
+
const symlinkTargets = new Map();
|
|
210
|
+
for (const [name, oid] of symlinks) {
|
|
211
|
+
const blob = blobs.get(oid);
|
|
212
|
+
if (!blob || blob.type !== 'blob')
|
|
213
|
+
throw denied();
|
|
214
|
+
symlinkTargets.set(name, blob.bytes);
|
|
215
|
+
await this.assertTrackedSymlink(name, blob.bytes);
|
|
216
|
+
}
|
|
217
|
+
const kept = [...gitlinks.keys(), ...symlinks.keys()];
|
|
186
218
|
const files = [], changes = [];
|
|
187
219
|
let total = 0;
|
|
188
220
|
for (const name of names) {
|
|
189
|
-
if (
|
|
221
|
+
if (kept.some(p => name === p || name.startsWith(p + '/') || p.startsWith(name + '/')))
|
|
190
222
|
throw denied();
|
|
191
223
|
const current = await this.currentFile(name, protectedNames.has(name)), prior = base.get(name);
|
|
192
224
|
let before = null;
|
|
@@ -211,7 +243,7 @@ export class GitSnapshotHost {
|
|
|
211
243
|
changes.push({ path: name, status: !prior ? 'added' : !current ? 'deleted' : 'modified', before: before?.toString('base64') ?? null, after: current?.bytes.toString('base64') ?? null, before_mode: prior?.mode ?? null, after_mode: current?.mode ?? null });
|
|
212
244
|
}
|
|
213
245
|
const observation = { base_commit_sha: o.baseCommitSha, head_commit_sha: head, working_branch: branch, diff: JSON.stringify({ format: 'local-candidate-binary-diff-v1', changes }), changed_files: changes.map(({ path, status }) => ({ path, status })) };
|
|
214
|
-
const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names, protectedNames };
|
|
246
|
+
const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names, protectedNames, symlinks: symlinkTargets };
|
|
215
247
|
return { observation, files, listing, fingerprint: protocolHash({ observation, files: files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) })) }) };
|
|
216
248
|
}
|
|
217
249
|
/** The checkout still equals a capture when HEAD, the branch, the three Git listings and every
|
|
@@ -222,6 +254,8 @@ export class GitSnapshotHost {
|
|
|
222
254
|
await this.head();
|
|
223
255
|
if (digest(await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha])) !== listing.base || digest(await this.git(['ls-files', '--stage', '-z'])) !== listing.staged || digest(await this.git(['ls-files', '--others', '--exclude-standard', '-z', '--'])) !== listing.untracked)
|
|
224
256
|
throw denied();
|
|
257
|
+
for (const [name, target] of listing.symlinks)
|
|
258
|
+
await this.assertTrackedSymlink(name, target);
|
|
225
259
|
const files = [];
|
|
226
260
|
let total = 0;
|
|
227
261
|
for (const name of listing.names) {
|
|
@@ -264,7 +298,7 @@ export class GitSnapshotHost {
|
|
|
264
298
|
for (const entry of (await this.git(['ls-tree', '-r', '-t', '-z', o.baseCommitSha])).toString().split('\0').filter(Boolean)) {
|
|
265
299
|
if (/^160000 commit [a-f0-9]{40}\t/.test(entry))
|
|
266
300
|
continue;
|
|
267
|
-
const match = /^(?:100644|100755|040000) (blob|tree) ([a-f0-9]{40})\t/.exec(entry);
|
|
301
|
+
const match = /^(?:100644|100755|120000|040000) (blob|tree) ([a-f0-9]{40})\t/.exec(entry);
|
|
268
302
|
if (!match)
|
|
269
303
|
throw denied();
|
|
270
304
|
objects.set(match[2], match[1]);
|
|
@@ -344,6 +378,9 @@ export class GitSnapshotHost {
|
|
|
344
378
|
const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
345
379
|
if (link)
|
|
346
380
|
entries.push({ path: link[2], mode: 0o160000, bytes: Buffer.alloc(0), gitlink: link[1] });
|
|
381
|
+
const symlink = TRACKED_SYMLINK.exec(line);
|
|
382
|
+
if (symlink)
|
|
383
|
+
entries.push({ path: symlink[2], mode: 0o120000, bytes: Buffer.alloc(0), baseBlob: symlink[1] });
|
|
347
384
|
}
|
|
348
385
|
const objects = new RawGitObjects(root), tree = await objects.tree(entries, true), commitSha = await objects.object('commit', Buffer.from(`tree ${tree}\nparent ${this.options.baseCommitSha}\nauthor Local Runner Verification <runner-verification@invalid> 1 +0000\ncommitter Local Runner Verification <runner-verification@invalid> 1 +0000\n\nLocal runner verification candidate v1\n`), true);
|
|
349
386
|
await writeFile(path.join(root, '.git', 'HEAD'), commitSha + '\n', { mode: 0o600 });
|
|
@@ -67,13 +67,13 @@ export class GitPublicationTransport {
|
|
|
67
67
|
if (protocolHash(entries.map(e => ({ path: e.path, mode: e.mode, sha256: createHash('sha256').update(e.bytes).digest('hex') }))) !== o.sealedTreeFingerprint)
|
|
68
68
|
throw denied();
|
|
69
69
|
for (const line of (await this.git(['ls-tree', '-r', '-z', request.base_commit_sha])).split('\0').filter(Boolean)) {
|
|
70
|
-
const gitlink = /^160000 commit ([a-f0-9]{40})\t(.+)$/.exec(line);
|
|
71
|
-
if (!
|
|
70
|
+
const gitlink = /^160000 commit ([a-f0-9]{40})\t(.+)$/.exec(line), symlink = /^120000 blob ([a-f0-9]{40})\t(.+)$/.exec(line), kept = gitlink ?? symlink;
|
|
71
|
+
if (!kept)
|
|
72
72
|
continue;
|
|
73
|
-
const name =
|
|
73
|
+
const name = kept[2];
|
|
74
74
|
if (!name || name.split('/').some(p => !p || p === '.' || p === '..' || p === '.git') || name.includes('\\') || entries.some(e => e.path === name || e.path.startsWith(name + '/') || name.startsWith(e.path + '/')))
|
|
75
75
|
throw denied();
|
|
76
|
-
entries.push({ path: name, mode: 0o160000, bytes: Buffer.alloc(0), gitlink: gitlink[1] });
|
|
76
|
+
entries.push(gitlink ? { path: name, mode: 0o160000, bytes: Buffer.alloc(0), gitlink: gitlink[1] } : { path: name, mode: 0o120000, bytes: Buffer.alloc(0), baseBlob: symlink[1] });
|
|
77
77
|
}
|
|
78
78
|
if (!await o.current())
|
|
79
79
|
throw denied();
|
|
@@ -42,7 +42,7 @@ export class RawGitObjects {
|
|
|
42
42
|
if (slash >= 0)
|
|
43
43
|
children.add(rest.slice(0, slash));
|
|
44
44
|
else
|
|
45
|
-
names.set(rest, { mode: entry.mode.toString(8), oid: entry.gitlink ?? await this.object('blob', entry.bytes, write) });
|
|
45
|
+
names.set(rest, { mode: entry.mode.toString(8), oid: entry.gitlink ?? entry.baseBlob ?? await this.object('blob', entry.bytes, write) });
|
|
46
46
|
}
|
|
47
47
|
for (const child of children)
|
|
48
48
|
names.set(child, { mode: '40000', oid: await this.tree(entries, write, prefix + child + '/') });
|