nomarmy 0.1.0-alpha.21 → 0.1.0-alpha.22
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/bin/nomarmy.mjs +155 -16
- package/lib/acceptance-fill.mjs +180 -0
- package/lib/acceptance-impact.mjs +215 -0
- package/lib/acceptance-result.mjs +21 -0
- package/lib/acceptance.mjs +166 -0
- package/lib/admission.mjs +33 -3
- package/lib/army.mjs +2 -2
- package/lib/codex-link.mjs +6 -1
- package/lib/connect.mjs +6 -2
- package/lib/continue-from.mjs +29 -12
- package/lib/coordinator-instructions.mjs +4 -0
- package/lib/diff-checks.mjs +9 -3
- package/lib/doctor.mjs +3 -1
- package/lib/execute.mjs +187 -32
- package/lib/git-record.mjs +9 -2
- package/lib/health.mjs +25 -3
- package/lib/install-freshness.mjs +2 -2
- package/lib/jev-checks.mjs +13 -0
- package/lib/job-format.mjs +3 -1
- package/lib/judge.mjs +26 -5
- package/lib/line-diff.mjs +46 -0
- package/lib/mutation.mjs +27 -13
- package/lib/openclaw-run.mjs +21 -7
- package/lib/process.mjs +30 -4
- package/lib/repo-query.mjs +4 -4
- package/lib/sandbox-images.mjs +31 -4
- package/lib/schema.mjs +28 -0
- package/lib/server-context.mjs +3 -2
- package/lib/share.mjs +53 -2
- package/lib/stats.mjs +11 -0
- package/lib/trust-checks.mjs +269 -0
- package/lib/trust-files.mjs +123 -0
- package/lib/trust-judgment.mjs +157 -0
- package/lib/trust-learning.mjs +153 -0
- package/lib/trust-map.mjs +149 -0
- package/lib/trust-reach.mjs +220 -0
- package/lib/trust.mjs +227 -0
- package/lib/validators.mjs +7 -0
- package/lib/verification-flow.mjs +25 -18
- package/lib/verification-network.mjs +2 -2
- package/lib/verify.mjs +46 -3
- package/lib/worktree-pointer.mjs +246 -0
- package/lib/worktree-write.mjs +128 -0
- package/mcp/server.mjs +28 -11
- package/package.json +1 -1
- package/playbooks/feature.md +5 -5
- package/scripts/trust-measure.mjs +139 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { samePath } from "./same-path.mjs";
|
|
6
|
+
|
|
7
|
+
// A worker worktree's `.git` is a file nomArmy creates, pointing at the
|
|
8
|
+
// operator repo's `.git/worktrees/<name>`. Host git trusts whatever that file
|
|
9
|
+
// says. Record the exact bytes at creation and refuse to run git unless they
|
|
10
|
+
// are still a regular file (not a symlink) with those bytes, and the gitdir
|
|
11
|
+
// they name is one of this repo's own worktrees pointing back here.
|
|
12
|
+
|
|
13
|
+
const registry = new Map();
|
|
14
|
+
// Windows: git writes long, forward-slash paths while os.tmpdir() can be an
|
|
15
|
+
// 8.3 short name, and the filesystem ignores case. Only the native realpath
|
|
16
|
+
// expands 8.3 names; comparisons fold case there.
|
|
17
|
+
const fold = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
|
|
18
|
+
function realPath(p) { return fs.realpathSync.native(p); }
|
|
19
|
+
|
|
20
|
+
const jobsRoots = new Set();
|
|
21
|
+
export function registerJobsRoot(root) {
|
|
22
|
+
if (root) for (const key of registryKeys(root)) jobsRoots.add(key);
|
|
23
|
+
}
|
|
24
|
+
registerJobsRoot(path.join(process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents"), "jobs"));
|
|
25
|
+
function within(root, target) {
|
|
26
|
+
const rel = path.relative(root, target);
|
|
27
|
+
return !rel || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
28
|
+
}
|
|
29
|
+
function integrity(reason) { return new Error(`worktree Git pointer integrity failure: ${reason}`); }
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
function registryKeys(worktree) {
|
|
33
|
+
const resolved = path.resolve(worktree);
|
|
34
|
+
const keys = [fold(resolved)];
|
|
35
|
+
try {
|
|
36
|
+
const real = fold(realPath(resolved));
|
|
37
|
+
if (!keys.includes(real)) keys.push(real);
|
|
38
|
+
} catch { /* a missing path still registers under its resolved spelling */ }
|
|
39
|
+
return keys;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function toPointerBytes(value) {
|
|
43
|
+
if (Buffer.isBuffer(value)) return value;
|
|
44
|
+
if (value instanceof Uint8Array) return Buffer.from(value);
|
|
45
|
+
if (typeof value === "string" && value.length > 0) return Buffer.from(value, "base64");
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readRegularFile(file) {
|
|
50
|
+
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
|
51
|
+
let fd;
|
|
52
|
+
try { fd = fs.openSync(file, flags); }
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code === "ELOOP" || error.code === "EPERM") return { ok: false, reason: "symlink" };
|
|
55
|
+
if (error.code === "ENOENT") return { ok: false, reason: "missing" };
|
|
56
|
+
return { ok: false, reason: `unreadable (${error.code ?? error.message})` };
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const st = fs.fstatSync(fd);
|
|
60
|
+
if (st.isSymbolicLink()) return { ok: false, reason: "symlink" };
|
|
61
|
+
if (!st.isFile()) return { ok: false, reason: "not a regular file" };
|
|
62
|
+
return { ok: true, bytes: fs.readFileSync(fd) };
|
|
63
|
+
} finally {
|
|
64
|
+
fs.closeSync(fd);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseGitdir(bytes, worktree) {
|
|
69
|
+
const text = bytes.toString("utf8").replace(/^\uFEFF/, "");
|
|
70
|
+
const line = text.split(/\r?\n/).find((item) => item.startsWith("gitdir:"));
|
|
71
|
+
if (!line) return null;
|
|
72
|
+
const raw = line.slice("gitdir:".length).trim();
|
|
73
|
+
if (!raw) return null;
|
|
74
|
+
return path.resolve(worktree, raw);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function realDir(dir) {
|
|
78
|
+
try { return realPath(dir); } catch { return path.resolve(dir); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function insideOperatorWorktrees(repoRoot, gitdir) {
|
|
82
|
+
const root = realDir(path.resolve(repoRoot, ".git", "worktrees"));
|
|
83
|
+
let target = path.resolve(gitdir);
|
|
84
|
+
try { target = realPath(target); } catch { /* compare the spelling git wrote */ }
|
|
85
|
+
const rel = path.relative(fold(root), fold(target));
|
|
86
|
+
if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false;
|
|
87
|
+
return !rel.includes(path.sep);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Why this worktree's `.git` pointer cannot be trusted, or null.
|
|
92
|
+
* `expectedBytes` is the Buffer nomArmy read at creation, or its base64 record.
|
|
93
|
+
*/
|
|
94
|
+
export function worktreePointerProblem({ worktree, expectedBytes, repoRoot }) {
|
|
95
|
+
if (!worktree) return "no worktree";
|
|
96
|
+
if (!repoRoot) return "no operator repository";
|
|
97
|
+
const expected = toPointerBytes(expectedBytes);
|
|
98
|
+
if (!expected) return "no recorded pointer bytes";
|
|
99
|
+
try { if (fs.lstatSync(worktree).isSymbolicLink()) return "symlinked worktree root"; }
|
|
100
|
+
catch { return "missing worktree"; }
|
|
101
|
+
const dotGit = path.join(worktree, ".git");
|
|
102
|
+
let lst;
|
|
103
|
+
try { lst = fs.lstatSync(dotGit); }
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error.code === "ENOENT") return "missing";
|
|
106
|
+
return `unreadable (${error.code ?? error.message})`;
|
|
107
|
+
}
|
|
108
|
+
if (lst.isSymbolicLink()) return "symlink";
|
|
109
|
+
if (!lst.isFile()) return lst.isDirectory() ? "directory" : "not a regular file";
|
|
110
|
+
const read = readRegularFile(dotGit);
|
|
111
|
+
if (!read.ok) return read.reason;
|
|
112
|
+
if (!expected.equals(read.bytes)) return "bytes mismatch";
|
|
113
|
+
const gitdir = parseGitdir(read.bytes, worktree);
|
|
114
|
+
if (!gitdir) return "pointer is not a gitdir file";
|
|
115
|
+
if (!insideOperatorWorktrees(repoRoot, gitdir)) return "gitdir is not inside the operator repository worktrees directory";
|
|
116
|
+
const backFile = path.join(gitdir, "gitdir");
|
|
117
|
+
let backStat;
|
|
118
|
+
try { backStat = fs.lstatSync(backFile); }
|
|
119
|
+
catch { return "worktree back-reference missing"; }
|
|
120
|
+
if (backStat.isSymbolicLink() || !backStat.isFile()) return "worktree back-reference is not a regular file";
|
|
121
|
+
const backRead = readRegularFile(backFile);
|
|
122
|
+
if (!backRead.ok) return `worktree back-reference ${backRead.reason}`;
|
|
123
|
+
const backPath = path.resolve(gitdir, backRead.bytes.toString("utf8").trim());
|
|
124
|
+
// Git's back-reference names the worktree by the path of its `.git` file.
|
|
125
|
+
const namesWorktree = samePath(backPath, worktree) || samePath(backPath, path.join(worktree, ".git"));
|
|
126
|
+
if (!namesWorktree) return "worktree back-reference does not name this worktree";
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function assertWorktreePointer(options) {
|
|
131
|
+
const why = worktreePointerProblem(options);
|
|
132
|
+
if (why) throw new Error(`worktree Git pointer integrity failure: ${why}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Read the pointer nomArmy just created. Throws if it is not a regular file. */
|
|
136
|
+
export function captureWorktreePointer(worktree) {
|
|
137
|
+
const dotGit = path.join(worktree, ".git");
|
|
138
|
+
let lst;
|
|
139
|
+
try { lst = fs.lstatSync(dotGit); }
|
|
140
|
+
catch (error) {
|
|
141
|
+
throw new Error(`worktree Git pointer integrity failure: ${error.code === "ENOENT" ? "missing" : error.message}`);
|
|
142
|
+
}
|
|
143
|
+
if (lst.isSymbolicLink()) throw new Error("worktree Git pointer integrity failure: symlink");
|
|
144
|
+
if (!lst.isFile()) throw new Error("worktree Git pointer integrity failure: not a regular file");
|
|
145
|
+
const read = readRegularFile(dotGit);
|
|
146
|
+
if (!read.ok) throw new Error(`worktree Git pointer integrity failure: ${read.reason}`);
|
|
147
|
+
return read.bytes;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Remember a worktree's creation-time pointer so every later host git command
|
|
152
|
+
* there can be checked. Returns the serializable record stored on the job.
|
|
153
|
+
*/
|
|
154
|
+
export function sealWorktree(worktree, repoRoot) {
|
|
155
|
+
const bytes = captureWorktreePointer(worktree);
|
|
156
|
+
assertWorktreePointer({ worktree, expectedBytes: bytes, repoRoot });
|
|
157
|
+
const record = { worktree: path.resolve(worktree), expectedBytes: bytes, repoRoot: path.resolve(repoRoot) };
|
|
158
|
+
for (const key of registryKeys(worktree)) registry.set(key, record);
|
|
159
|
+
return { applicable: true, exists: true, kind: "file", bytes: bytes.toString("base64") };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function registerWorktreePointer(worktree, { expectedBytes, repoRoot }) {
|
|
163
|
+
const bytes = toPointerBytes(expectedBytes);
|
|
164
|
+
if (!bytes) throw new Error("worktree Git pointer integrity failure: no recorded pointer bytes");
|
|
165
|
+
const record = { worktree: path.resolve(worktree), expectedBytes: bytes, repoRoot: path.resolve(repoRoot) };
|
|
166
|
+
for (const key of registryKeys(worktree)) registry.set(key, record);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function registeredWorktree(cwd) {
|
|
170
|
+
if (!cwd) return null;
|
|
171
|
+
// Match the lexical spelling first, so an escaping symlink cannot turn a
|
|
172
|
+
// worker path into an unchecked operator path.
|
|
173
|
+
for (const key of registryKeys(cwd)) {
|
|
174
|
+
const matches = [...registry].filter(([root]) => within(root, key)).sort((a, b) => b[0].length - a[0].length);
|
|
175
|
+
if (matches.length) return matches[0][1];
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Only trusted job records can restore registration, never the live .git. */
|
|
181
|
+
export function registerJobWorktree(record, { worktree = record?.worktree, repoRoot = record?.projectDir } = {}) {
|
|
182
|
+
if (!worktree || !repoRoot) throw integrity("no recorded worktree or operator repository");
|
|
183
|
+
const expectedBytes = record?.worktreePointerBefore?.bytes;
|
|
184
|
+
registerWorktreePointer(worktree, { expectedBytes, repoRoot });
|
|
185
|
+
assertWorktreePointer({ worktree, expectedBytes, repoRoot });
|
|
186
|
+
return registeredWorktree(worktree);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function assertRegisteredWorktree(worktree) {
|
|
190
|
+
const registered = registeredWorktree(worktree);
|
|
191
|
+
if (!registered || !registryKeys(worktree).some(key => registryKeys(registered.worktree).includes(key))) throw integrity("unregistered worktree");
|
|
192
|
+
assertWorktreePointer(registered);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let hooksDir = null;
|
|
196
|
+
export function gitHooksPath() {
|
|
197
|
+
if (process.platform === "win32") {
|
|
198
|
+
if (!hooksDir) hooksDir = fs.mkdtempSync(path.join(os.tmpdir(), "nomarmy-hooks-"));
|
|
199
|
+
return hooksDir;
|
|
200
|
+
}
|
|
201
|
+
return "/dev/null";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function gitSafetyArgs() {
|
|
205
|
+
return ["-c", `core.hooksPath=${gitHooksPath()}`, "-c", "core.fsmonitor=false"];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function gitSafetyEnv(env = process.env) {
|
|
209
|
+
return { ...env, GIT_CONFIG_NOSYSTEM: "1" };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Before a host git command in a registered worker or continuation worktree:
|
|
214
|
+
* verify the pointer (throw, so the caller runs no git) and return the
|
|
215
|
+
* defense-in-depth flags. Operator-repo commands are not registered and pass.
|
|
216
|
+
*/
|
|
217
|
+
export function prepareHostGit(cwd = process.cwd(), args = []) {
|
|
218
|
+
// Removal runs from the operator repo, but its target is worker-controlled.
|
|
219
|
+
const remove = args.indexOf("worktree");
|
|
220
|
+
if (remove >= 0 && args[remove + 1] === "remove") {
|
|
221
|
+
const target = args.slice(remove + 2).find(arg => !arg.startsWith("-"));
|
|
222
|
+
if (target) {
|
|
223
|
+
const full = path.resolve(cwd, target);
|
|
224
|
+
if (registeredWorktree(full) || registryKeys(full).some(key => [...jobsRoots].some(root => within(root, key)))) assertRegisteredWorktree(full);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const registered = registeredWorktree(cwd);
|
|
228
|
+
if (!registered) {
|
|
229
|
+
if (registryKeys(cwd).some(key => [...jobsRoots].some(root => within(root, key)))) throw integrity("unregistered worktree under jobs root");
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
assertWorktreePointer(registered);
|
|
233
|
+
const gitdir = parseGitdir(registered.expectedBytes, registered.worktree);
|
|
234
|
+
if (!gitdir || !insideOperatorWorktrees(registered.repoRoot, gitdir)) throw integrity("recorded gitdir is outside operator worktrees");
|
|
235
|
+
return { argsPrefix: [...gitSafetyArgs(), `--git-dir=${realPath(gitdir)}`, `--work-tree=${registered.worktree}`,
|
|
236
|
+
...(args[0] === "status" ? ["--no-optional-locks"] : [])], env: gitSafetyEnv };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function execHostGitSync(cwd, args, options = {}) {
|
|
240
|
+
const prep = prepareHostGit(cwd, args);
|
|
241
|
+
return execFileSync("git", [...(prep?.argsPrefix ?? []), ...args], { ...options, cwd, env: prep ? prep.env(options.env ?? process.env) : options.env ?? process.env });
|
|
242
|
+
}
|
|
243
|
+
export function spawnHostGitSync(cwd, args, options = {}) {
|
|
244
|
+
const prep = prepareHostGit(cwd, args);
|
|
245
|
+
return spawnSync("git", [...(prep?.argsPrefix ?? []), ...args], { ...options, cwd, env: prep ? prep.env(options.env ?? process.env) : options.env ?? process.env });
|
|
246
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Host restores used to call writeFileSync, which follows symlinks. A sandbox
|
|
5
|
+
// can swap the file (or a parent directory) for a symlink to a host path
|
|
6
|
+
// between verify and the restore. Walk from the worktree root with lstat,
|
|
7
|
+
// refuse any symlinked component, and open the final path with O_NOFOLLOW.
|
|
8
|
+
|
|
9
|
+
export class WorktreeWriteError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "WorktreeWriteError";
|
|
13
|
+
this.restoreFailed = true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function lexicalInside(root, full) {
|
|
18
|
+
const rootResolved = path.resolve(root);
|
|
19
|
+
const target = path.resolve(full);
|
|
20
|
+
const rel = path.relative(rootResolved, target);
|
|
21
|
+
if (!rel || (rel === ".." || rel.startsWith(`..${path.sep}`)) || path.isAbsolute(rel)) {
|
|
22
|
+
throw new WorktreeWriteError(`refusing path outside worktree: ${full}`);
|
|
23
|
+
}
|
|
24
|
+
return { rootResolved, parts: rel.split(path.sep) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function walkParents(root, full, { createParents }) {
|
|
28
|
+
const { rootResolved, parts } = lexicalInside(root, full);
|
|
29
|
+
let current = rootResolved;
|
|
30
|
+
const rootStat = fs.lstatSync(current);
|
|
31
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new WorktreeWriteError(`refusing symlinked or non-directory worktree root ${current}`);
|
|
32
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
33
|
+
current = path.join(current, parts[i]);
|
|
34
|
+
let st;
|
|
35
|
+
try { st = fs.lstatSync(current); }
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code !== "ENOENT" || !createParents) {
|
|
38
|
+
throw new WorktreeWriteError(`refusing missing parent ${current}`);
|
|
39
|
+
}
|
|
40
|
+
fs.mkdirSync(current);
|
|
41
|
+
st = fs.lstatSync(current);
|
|
42
|
+
}
|
|
43
|
+
if (st.isSymbolicLink()) throw new WorktreeWriteError(`refusing symlinked parent ${current}`);
|
|
44
|
+
if (!st.isDirectory()) throw new WorktreeWriteError(`refusing non-directory parent ${current}`);
|
|
45
|
+
}
|
|
46
|
+
return path.join(current, parts[parts.length - 1]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function openNoFollow(finalPath, flags, mode) {
|
|
50
|
+
try { return fs.openSync(finalPath, flags, mode); }
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code === "ELOOP" || error.code === "EPERM") {
|
|
53
|
+
try { if (fs.lstatSync(finalPath).isSymbolicLink()) fs.unlinkSync(finalPath); } catch { /* already gone */ }
|
|
54
|
+
throw new WorktreeWriteError(`refusing symlink at ${finalPath}; removed without following`);
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Write `data` to `full` inside `root`. A symlink at the target is removed
|
|
62
|
+
* and the write fails; it is never followed. Symlinked parents fail too.
|
|
63
|
+
*/
|
|
64
|
+
export function writeWorktreeFile(root, full, data, { mode } = {}) {
|
|
65
|
+
if (!root) throw new WorktreeWriteError("refusing worktree write without a root");
|
|
66
|
+
const finalPath = walkParents(root, full, { createParents: true });
|
|
67
|
+
let st = null;
|
|
68
|
+
try { st = fs.lstatSync(finalPath); }
|
|
69
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
70
|
+
if (st?.isSymbolicLink()) {
|
|
71
|
+
fs.unlinkSync(finalPath);
|
|
72
|
+
throw new WorktreeWriteError(`refusing symlink at ${finalPath}; removed without following`);
|
|
73
|
+
}
|
|
74
|
+
if (st && !st.isFile()) throw new WorktreeWriteError(`refusing non-regular file at ${finalPath}`);
|
|
75
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW || 0);
|
|
76
|
+
const fd = openNoFollow(finalPath, flags, mode ?? 0o666);
|
|
77
|
+
try {
|
|
78
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
79
|
+
let offset = 0;
|
|
80
|
+
while (offset < buf.length) offset += fs.writeSync(fd, buf, offset);
|
|
81
|
+
if (mode != null && process.platform !== "win32") fs.fchmodSync(fd, mode);
|
|
82
|
+
} finally {
|
|
83
|
+
fs.closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Remove a worktree path without following a symlink at the target or any parent. */
|
|
88
|
+
export function removeWorktreeEntry(root, full) {
|
|
89
|
+
if (!root) throw new WorktreeWriteError("refusing worktree remove without a root");
|
|
90
|
+
const finalPath = walkParents(root, full, { createParents: false });
|
|
91
|
+
let st;
|
|
92
|
+
try { st = fs.lstatSync(finalPath); }
|
|
93
|
+
catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
94
|
+
if (st.isSymbolicLink() || st.isFile()) {
|
|
95
|
+
fs.unlinkSync(finalPath);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (st.isDirectory()) {
|
|
99
|
+
fs.rmSync(finalPath, { recursive: true, force: true });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
throw new WorktreeWriteError(`refusing to remove non-file ${finalPath}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Check every component, including the leaf, before host filesystem access. */
|
|
106
|
+
export function assertWorktreePath(root, full, { allowMissing = false } = {}) {
|
|
107
|
+
const finalPath = walkParents(root, full, { createParents: false });
|
|
108
|
+
try {
|
|
109
|
+
if (fs.lstatSync(finalPath).isSymbolicLink()) throw new WorktreeWriteError(`refusing symlink at ${finalPath}`);
|
|
110
|
+
} catch (error) { if (!(allowMissing && error.code === "ENOENT")) throw error; }
|
|
111
|
+
return finalPath;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Capture contents and mode through one no-follow descriptor. Missing is null. */
|
|
115
|
+
export function readWorktreeFile(root, full) {
|
|
116
|
+
if (!root) throw new WorktreeWriteError("refusing worktree read without a root");
|
|
117
|
+
let fd;
|
|
118
|
+
try {
|
|
119
|
+
const finalPath = assertWorktreePath(root, full, { allowMissing: true });
|
|
120
|
+
fd = fs.openSync(finalPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
121
|
+
const st = fs.fstatSync(fd);
|
|
122
|
+
if (!st.isFile()) throw new WorktreeWriteError(`refusing non-regular file at ${finalPath}`);
|
|
123
|
+
return { content: fs.readFileSync(fd), mode: st.mode & 0o777 };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.code === "ENOENT") return null;
|
|
126
|
+
throw error;
|
|
127
|
+
} finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
128
|
+
}
|
package/mcp/server.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { criterionIdSchema } from "../lib/acceptance.mjs";
|
|
2
|
+
import { registerJobWorktree, assertRegisteredWorktree } from "../lib/worktree-pointer.mjs";
|
|
3
|
+
import { removeWorktreeEntry } from "../lib/worktree-write.mjs";
|
|
1
4
|
import { createJobRuntime, jobLane, currentMaxPoolWorkers, splitJobsByLane, toolText, refusalText } from "../lib/admission.mjs";
|
|
2
5
|
export { jobLane, currentMaxPoolWorkers, splitJobsByLane, refusalText };
|
|
3
6
|
import { createExecutor, sleep } from "../lib/execute.mjs";
|
|
@@ -37,9 +40,9 @@ import { modelRefusals } from "../lib/health.mjs";
|
|
|
37
40
|
import { retryRefusedModelsInBackground } from "../lib/refusal-retry.mjs";
|
|
38
41
|
import { podmanProblem, podmanVmStartedAt } from "../lib/podman-health.mjs";
|
|
39
42
|
import { restartNotice } from "../lib/install-freshness.mjs";
|
|
40
|
-
import { requestJobStop } from "../lib/openclaw-run.mjs";
|
|
41
|
-
import { loadJobRecords, computeStats, formatStats, formatStatsSummary, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
|
|
42
|
-
import { shareMarkdown } from "../lib/share.mjs";
|
|
43
|
+
import { requestJobStop, JOB_ID_RE } from "../lib/openclaw-run.mjs";
|
|
44
|
+
import { loadJobRecords, gatedJobs, computeStats, formatStats, formatStatsSummary, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
|
|
45
|
+
import { acceptanceSummary, shareMarkdown } from "../lib/share.mjs";
|
|
43
46
|
import { recentSuggestions } from "../lib/suggestions.mjs";
|
|
44
47
|
import { probeModel } from "../lib/model-probe.mjs";
|
|
45
48
|
import { jevSettings, judgeSettings } from "../lib/validators.mjs";
|
|
@@ -308,6 +311,8 @@ const { executeJob, executeImplement, executeScout, executeDecompose } = createE
|
|
|
308
311
|
export { executeJob };
|
|
309
312
|
|
|
310
313
|
const { WORKER_START_STAGGER_MS, activeJobs, runningCount, agentMaxConcurrent, withAgentSlot, track, notifyJobFinished, capacitySnapshot, displayedCapacity, admit, refusal, runBrief, recordJobInRun, trackInRun, launch, liveProgress, summarize } = createJobRuntime({
|
|
314
|
+
jevSettings: () => jevSettings(),
|
|
315
|
+
judgeSettings: () => judgeSettings({ agents: agentsConfig().agents, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost }),
|
|
311
316
|
projectDir, stateRoot, jobsRoot, runsRoot, leasesRoot, slotsRoot, run, currentMaxWorkers, slug, agentsConfig, modelCatalogReady, budgetsForJob, resolveSubscriptionSelection, executeJob, subscriptionJobFieldProblems, repoPolicy, jobArgs,
|
|
312
317
|
env: process.env, budgetState, getActiveRunId: () => activeRunId,
|
|
313
318
|
sandboxProblem: () => podmanChecks?.problem() ?? null,
|
|
@@ -319,6 +324,7 @@ export const jobSchema = z.object({
|
|
|
319
324
|
task: z.string().min(1).max(maxTaskChars,
|
|
320
325
|
`Objective exceeds the ${maxTaskChars}-character worker context budget. This length limit does not by itself mean the job is too broad: a single-purpose objective that inlines file contents can hit it just from being verbose. If that's the case here, reference exact paths and line ranges instead (the worker can read them, or use \`evidence\` to hand it the answer already resolved) rather than pasting the file into the brief. If the objective genuinely covers multiple files or concerns, split it into separate jobs.`
|
|
321
326
|
).describe("implement: the OBJECTIVE the worker must achieve, not the edit it should make. scout: the QUESTION to answer from the repository. decompose: the broad OBJECTIVE to propose a split for."),
|
|
327
|
+
criteria: z.array(criterionIdSchema).optional().describe("implement: contract IDs from acceptance/*.yml served by this job."),
|
|
322
328
|
acceptance: z.array(z.string().min(1).max(maxAcceptanceItemChars,
|
|
323
329
|
`Acceptance item exceeds ${maxAcceptanceItemChars} characters. Keep each criterion to one concrete, checkable statement.`
|
|
324
330
|
)).max(20).optional().describe("implement: acceptance criteria the worker must satisfy. scout: points a complete answer must cover. decompose: constraints a good split must respect."),
|
|
@@ -385,10 +391,10 @@ export function repoPolicy(loadConfigFn = () => loadConfig(projectDir)) {
|
|
|
385
391
|
// `pool` or `subscription_worker`.
|
|
386
392
|
function jobArgs(args, workerId) {
|
|
387
393
|
const subscriptionWorker = args.subscription_worker;
|
|
388
|
-
return { task: args.task, acceptance: args.acceptance, verification: args.verification, mode: args.mode, baseRef: args.base_ref,
|
|
394
|
+
return { task: args.task, acceptance: args.acceptance, criteria: args.criteria, verification: args.verification, mode: args.mode, baseRef: args.base_ref,
|
|
389
395
|
timeoutSeconds: args.timeout_seconds, profile: args.profile, reasoning: args.reasoning, pool: args.pool,
|
|
390
396
|
subscriptionWorker, onBehalfOf: args.on_behalf_of, model: args.model ?? null, reportSize: args.report ?? null, evidence: args.evidence,
|
|
391
|
-
verifyRegression: resolveVerifyRegression(args), commitSubject: args.commit_subject ?? null, refactor: Boolean(args.refactor), continueFrom: args.continue_from ?? null, stakes: args.stakes ?? null, reviews: args.reviews ?? null, workerId };
|
|
397
|
+
verifyRegression: resolveVerifyRegression(args), commitSubject: args.commit_subject ?? null, refactor: Boolean(args.refactor), continueFrom: args.continue_from ?? null, stakes: args.stakes ?? null, trustAdmission: args.trustAdmission ?? null, reviews: args.reviews ?? null, workerId };
|
|
392
398
|
}
|
|
393
399
|
server.tool("local_worker", "Run one isolated local worker and wait for it. mode=implement edits in its own worktree and the coordinator commits only on a valid done report (or a recovered job that passed independent verification); failed or incomplete worktrees are retained. mode=scout answers a question from a read-only snapshot with mandatory [path:line] citations that nomArmy verifies and expands. mode=decompose (also read-only) proposes 2+ independent subtasks for a broad objective instead of one worker turn trying to do too much; the proposal is never auto-dispatched, review it and make a separate call with the subtasks you choose. Refuses under memory pressure or over capacity; use local_worker_start + local_worker_status to avoid blocking.", jobSchema.shape,
|
|
394
400
|
async rawArgs => {
|
|
@@ -398,7 +404,7 @@ server.tool("local_worker", "Run one isolated local worker and wait for it. mode
|
|
|
398
404
|
const { problems, admission } = await admit([args]);
|
|
399
405
|
if (problems.length) return refusal(problems);
|
|
400
406
|
const r = await launch(args).promise;
|
|
401
|
-
const refreshed = (admission.reasons ?? []).filter((line) => line.startsWith("stale usage reading "));
|
|
407
|
+
const refreshed = (admission.reasons ?? []).filter((line) => (line.startsWith("stale usage reading ") || line.startsWith("Brief trust:")));
|
|
402
408
|
return toolText(refreshed.length ? `${coordinatorResult(r)}\n\n${refreshed.join("\n")}` : coordinatorResult(r), !r.ok);
|
|
403
409
|
});
|
|
404
410
|
server.tool("local_worker_start", "Start one worker or scout in the background and return immediately with a job_id. Poll it with local_worker_status (optionally long-polling with wait_seconds). Same admission rules as local_worker: refuses under memory pressure or when NOMARMY_MAX_WORKERS jobs are already running.", jobSchema.shape,
|
|
@@ -486,7 +492,7 @@ server.tool("stats", "What nomArmy's own job records show for this repository (o
|
|
|
486
492
|
});
|
|
487
493
|
|
|
488
494
|
server.tool("local_worker_stop", "Stop a running job's worker, for example one burning a frontier model's usage on the wrong track. Its worker ends within about 15 seconds, without the report-recovery call a timeout gets and without running verification; its worktree is kept uncommitted, so a new job with continue_from: <job_id> (and a cheaper model if you like) can finish the work. Works for a job started by any session. Refuses a job that isn't running or is already past its worker.", {
|
|
489
|
-
job_id: z.string().regex(
|
|
495
|
+
job_id: z.string().regex(JOB_ID_RE),
|
|
490
496
|
reason: z.string().max(300).optional().describe("Why it's being stopped; recorded in the job's issues."),
|
|
491
497
|
}, async ({ job_id, reason }) => {
|
|
492
498
|
const r = requestJobStop({ jobsRoot, jobId: job_id, reason: reason ?? null });
|
|
@@ -571,8 +577,14 @@ server.tool("run_finish", "Close a /feature run as complete or stopped, with a o
|
|
|
571
577
|
if (activeRunId === run_id) activeRunId = null;
|
|
572
578
|
// What nomArmy verified in this run, ready for the pull request's description (lib/share.mjs).
|
|
573
579
|
let prBlock = null;
|
|
574
|
-
|
|
575
|
-
|
|
580
|
+
let gated = [];
|
|
581
|
+
try {
|
|
582
|
+
const records = loadJobRecords(jobsRoot);
|
|
583
|
+
gated = gatedJobs(records, run_id);
|
|
584
|
+
const acceptance = await acceptanceSummary(projectDir);
|
|
585
|
+
prBlock = shareMarkdown(computeStats(records, { runId: run_id }), { scope: "this feature run", acceptance });
|
|
586
|
+
} catch { /* the totals stand without it */ }
|
|
587
|
+
return toolText(JSON.stringify({ id: run.id, status: run.status, ...runTotals(run), ...(gated.length ? { gated } : {}), ...(prBlock ? { prBlock, prBlockNote: "Put prBlock in the pull request's description as it is: every number is from nomArmy's verified records." } : {}) }, null, 2));
|
|
576
588
|
} catch (error) { return toolText(error.message, true); }
|
|
577
589
|
});
|
|
578
590
|
server.tool("army", "Who you, the General, are and who you call for what in this repository: your fixed charter and the agent you're defined as, the army's workflow, then each role's description, phase (build, review, acceptance), suggested mode, and the agent it runs on, with which config layer set each value (global, project .nomarmy.yml, local .nomarmy.local.yml). Flags roles with no usable agent, and roles that share your model or subscription (not an independent review). Dispatch a role with `army_role`, or an agent directly with `agent`. Read-only, re-read on every call.", {}, async () => {
|
|
@@ -703,7 +715,7 @@ server.tool("local_workers", "Run independent jobs (implement or scout) with bou
|
|
|
703
715
|
jobs: results.map(r => ({ jobId: r.manifest.jobId, workerId: r.manifest.workerId, mode: r.manifest.mode, outcome: r.manifest.outcome || OUTCOMES.WORKER_FAILED, recovered: Boolean(r.manifest.recovered), status: r.manifest.coordinatorStatus || "failed", branch: r.manifest.branch, commit: r.manifest.commit?.sha || null, worktree: r.manifest.worktree, jobDir: r.jobDir })),
|
|
704
716
|
...(union ? { union } : {}) };
|
|
705
717
|
const unionSection = union ? `UNION\n\n${formatUnion(withWindowsPaths(union))}\n\n` : "";
|
|
706
|
-
const refreshed = (admission?.reasons ?? []).filter((line) => line.startsWith("stale usage reading "));
|
|
718
|
+
const refreshed = (admission?.reasons ?? []).filter((line) => (line.startsWith("stale usage reading ") || line.startsWith("Brief trust:")));
|
|
707
719
|
const text = `BATCH EXECUTION RECORD\n${coordinatorJson(summary)}\n\n${unionSection}WORKER RESULTS\n\n${results.map((r, i) => `===== WORKER ${i + 1} =====\n${coordinatorResult(r)}`).join("\n\n")}${refreshed.length ? `\n\n${refreshed.join("\n")}` : ""}`;
|
|
708
720
|
return toolText(text, results.some(r => !r.ok) || union?.status === "union_verification_failed" || union?.status === "union_error");
|
|
709
721
|
});
|
|
@@ -770,7 +782,7 @@ export async function stripRuntimeJunk(worktree) {
|
|
|
770
782
|
try {
|
|
771
783
|
const statusOut = await gitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktree);
|
|
772
784
|
for (const entry of parseStatusPorcelainZ(statusOut)) {
|
|
773
|
-
if (isRuntimeJunk(entry.file))
|
|
785
|
+
if (isRuntimeJunk(entry.file)) removeWorktreeEntry(worktree, path.join(worktree, entry.file));
|
|
774
786
|
}
|
|
775
787
|
} catch { /* best-effort; falls through to the normal remove attempt */ }
|
|
776
788
|
}
|
|
@@ -828,6 +840,8 @@ server.tool("local_worker_sweep", "Bulk-reap job worktrees/branches that are PRO
|
|
|
828
840
|
const target = resolveCleanupTarget({ jobDir, jobId, meta, status: meta ? null : readJson(path.join(jobDir, "status.json")) });
|
|
829
841
|
if (!target?.worktree || !fs.existsSync(target.worktree)) continue; // nothing here to reap at all
|
|
830
842
|
const { worktree, branch } = target;
|
|
843
|
+
try { registerJobWorktree(meta?.worktreePointerBefore ? meta : readJson(path.join(jobDir, "status.json")), { worktree, repoRoot: projectDir }); }
|
|
844
|
+
catch (error) { skipped.push({ jobId, reason: error.message }); continue; }
|
|
831
845
|
let finishedAtMs;
|
|
832
846
|
try { finishedAtMs = meta?.finishedAt ? Date.parse(meta.finishedAt) : fs.statSync(jobDir).mtimeMs; }
|
|
833
847
|
catch { finishedAtMs = Date.now(); }
|
|
@@ -848,6 +862,7 @@ server.tool("local_worker_sweep", "Bulk-reap job worktrees/branches that are PRO
|
|
|
848
862
|
try {
|
|
849
863
|
await releaseSandboxLocks(worktree);
|
|
850
864
|
await stripRuntimeJunk(worktree);
|
|
865
|
+
assertRegisteredWorktree(worktree);
|
|
851
866
|
await run("git", ["worktree", "remove", worktree], { cwd: projectDir });
|
|
852
867
|
let branchDeleted = false;
|
|
853
868
|
if (delete_branches && branch) {
|
|
@@ -876,8 +891,10 @@ server.tool("local_worker_cleanup", "Remove a retained worker worktree and optio
|
|
|
876
891
|
if (!target) throw new Error(`Unknown job: ${job_id}`);
|
|
877
892
|
const { worktree, branch } = target;
|
|
878
893
|
if (worktree && fs.existsSync(worktree)) {
|
|
894
|
+
registerJobWorktree(meta?.worktreePointerBefore ? meta : readJson(path.join(jobDir, "status.json")), { worktree, repoRoot: projectDir });
|
|
879
895
|
await releaseSandboxLocks(worktree);
|
|
880
896
|
if (!force) await stripRuntimeJunk(worktree);
|
|
897
|
+
assertRegisteredWorktree(worktree);
|
|
881
898
|
await run("git", ["worktree", "remove", ...(force ? ["--force"] : []), worktree], { cwd: projectDir });
|
|
882
899
|
}
|
|
883
900
|
let branchDeleteMode = null;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Every byte verified: a harness for AI coding workers whose claims are never trusted. Your coding assistant stays in charge while workers implement and test in sandboxes, and nomArmy checks every change before it is committed.",
|
|
4
4
|
"author": "Rayson Technologies",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"version": "0.1.0-alpha.
|
|
6
|
+
"version": "0.1.0-alpha.22",
|
|
7
7
|
"private": false,
|
|
8
8
|
"type": "module",
|
|
9
9
|
"engines": {
|
package/playbooks/feature.md
CHANGED
|
@@ -13,17 +13,17 @@ You are the General. Build this feature end to end with nomArmy's army and come
|
|
|
13
13
|
Follow the army's workflow, calling only the roles the work needs:
|
|
14
14
|
|
|
15
15
|
0. **Routing.** The `army` tool's `suggestions` come from this repo's recent jobs (a role that keeps failing, a cheaper model doing as well, unreviewed high-stakes work). Tell the operator about any at the start and in your final summary; change routing only if they say so.
|
|
16
|
-
1. **Plan.** Scout the repo as needed (`repo_evidence` first; a scout only for research that would pull many files into your context). Write the plan into the run log: the outcome, acceptance criteria, the pieces, and which role gets each.
|
|
17
|
-
2. **Build.** Dispatch with `army_role` (and `on_behalf_of` when the role's agent is a subscription). The Sr Dev takes the core and harder work; the Jr Dev takes simple, fully specified pieces; UI/UX takes UI. For a role on `auto`, pick the model from the agent's list in the `army` tool: the lighter model for routine work, the frontier one for subtle work. Mark a job `stakes: high` when a mistake would be costly (security or access control, personal or tenant data, data loss, money, anything irreversible), however small the change: that's separate from how hard it is.
|
|
18
|
-
3. **Review.** When the build is in, call the specialists that apply (data architect for data work, security analyst for anything touching auth, input, secrets or data exposure), then the PM against the plan. Every `stakes: high` build job gets an independent review before you accept it: a scout on a different vendor than its worker, with `reviews: <that job id>`. Send what they find back to the builders as new, bounded jobs. A build job that comes back partial, blocked or failing verification is finished with `continue_from: <its job id>` and a brief of just the correction, never by fixing its files yourself: only verified work lands.
|
|
16
|
+
1. **Plan.** Scout the repo as needed (`repo_evidence` first; a scout only for research that would pull many files into your context). Write the plan into the run log: the outcome, acceptance criteria, the pieces, and which role gets each. In the operator checkout, write `acceptance/<feature>.yml` with the feature name, run ID, and durable criteria: stable IDs (such as `ACC-8`), text, `proven_by: []`, and `status: unproven`. Keep job hygiene such as formatting and full-suite checks in the job brief, not the feature contract. Assign the stable IDs to pieces in the plan and each job brief before dispatch; parallel jobs must not choose the next free ID themselves.
|
|
17
|
+
2. **Build.** If this repository has no trust map, suggest `nomarmy trust map` once to the operator; its scout proposal only becomes active through `nomarmy trust review`. Dispatch with `army_role` (and `on_behalf_of` when the role's agent is a subscription). Give each implement job `criteria: ["ACC-8"]` naming the contract IDs it serves; admission refuses IDs not yet defined in the operator checkout. The Sr Dev takes the core and harder work; the Jr Dev takes simple, fully specified pieces; UI/UX takes UI. For a role on `auto`, pick the model from the agent's list in the `army` tool: the lighter model for routine work, the frontier one for subtle work. Mark a job `stakes: high` when a mistake would be costly (security or access control, personal or tenant data, data loss, money, anything irreversible), however small the change: that's separate from how hard it is.
|
|
18
|
+
3. **Review.** When the build is in, call the specialists that apply (data architect for data work, security analyst for anything touching auth, input, secrets or data exposure), then the PM against the plan. Every `stakes: high` build job, and every job with `trust.level: review` or `trust.level: human`, gets an independent review before you accept it: a scout on a different vendor than its worker, with `reviews: <that job id>`. Send what they find back to the builders as new, bounded jobs. A build job that comes back partial, blocked or failing verification is finished with `continue_from: <its job id>` and a brief of just the correction, never by fixing its files yourself: only verified work lands.
|
|
19
19
|
4. **Acceptance.** PO and stakeholder test end to end. Checks that only run existing tests use mode: verify; writing new e2e checks is still an implement job. Fix what they find the same way.
|
|
20
|
-
5. **Integrate.** Review every diff against nomArmy's verified record -- a worker's report is a claim, not evidence -- and bring the accepted work together on one branch. **Never merge into the developer's branch, and never push.** The finished state is a branch ready for the operator to review and merge.
|
|
20
|
+
5. **Integrate.** A job whose record has `trust.level: human` is **not integrated until the operator explicitly says so**. After the operator decides, record it with `nomarmy trust ack <job> --accept|--reject --reason ...`; rejection does not permit integration. Stop before integration, show the operator every `trust.reasons` entry on its own line, and wait. Never choose the conservative option and continue through this gate. Review every diff against nomArmy's verified record -- a worker's report is a claim, not evidence -- and bring the accepted work together on one branch. **Never merge into the developer's branch, and never push.** After integrating the accepted jobs, run `nomarmy acceptance fill <run-id> --dry-run`, review the proposed test-to-criterion mapping, then run `nomarmy acceptance fill <run-id>`. Every new or changed test is proposed for every criterion its job carries; prune unrelated mappings yourself in the contract after filling. Run `nomarmy acceptance check`, keep genuinely unproven criteria visible, and commit the contract with the feature on the review branch. A `CONTRACT BROKEN:` issue means either the change is wrong or the contract must intentionally change in the same pull request. The finished state is a branch ready for the operator to review and merge.
|
|
21
21
|
|
|
22
22
|
## Decisions along the way
|
|
23
23
|
|
|
24
24
|
When you hit a choice you'd normally ask the operator about, don't stop: pick the conservative option (the smaller change, the existing pattern, the reversible path), write the decision and the alternative you didn't take into the run log, and keep going. They'll see every such decision in your final report.
|
|
25
25
|
|
|
26
|
-
Stop and ask
|
|
26
|
+
Stop and ask for any `trust.level: human` gate before integrating its job, or for something irreversible or outside this repository: merging or pushing, deploying, anything needing cloud or production credentials, deleting data, or changing another repository.
|
|
27
27
|
|
|
28
28
|
A gate's result is not yours to reinterpret. When a check the plan or the operator set (an exact expected output, a count, a checksum, a contract) doesn't match, that's a failure, even when the difference looks cosmetic: whitespace from BSD versus GNU `wc`, line endings, ordering, a trailing newline. Don't judge it "equivalent" and pass it. Either fix the check so it compares what was meant (normalize both sides, and write that change into the run log as a decision), or send the mismatch back as a failed job. A gate the General can wave through is no gate.
|
|
29
29
|
|