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.
Files changed (47) hide show
  1. package/bin/nomarmy.mjs +155 -16
  2. package/lib/acceptance-fill.mjs +180 -0
  3. package/lib/acceptance-impact.mjs +215 -0
  4. package/lib/acceptance-result.mjs +21 -0
  5. package/lib/acceptance.mjs +166 -0
  6. package/lib/admission.mjs +33 -3
  7. package/lib/army.mjs +2 -2
  8. package/lib/codex-link.mjs +6 -1
  9. package/lib/connect.mjs +6 -2
  10. package/lib/continue-from.mjs +29 -12
  11. package/lib/coordinator-instructions.mjs +4 -0
  12. package/lib/diff-checks.mjs +9 -3
  13. package/lib/doctor.mjs +3 -1
  14. package/lib/execute.mjs +187 -32
  15. package/lib/git-record.mjs +9 -2
  16. package/lib/health.mjs +25 -3
  17. package/lib/install-freshness.mjs +2 -2
  18. package/lib/jev-checks.mjs +13 -0
  19. package/lib/job-format.mjs +3 -1
  20. package/lib/judge.mjs +26 -5
  21. package/lib/line-diff.mjs +46 -0
  22. package/lib/mutation.mjs +27 -13
  23. package/lib/openclaw-run.mjs +21 -7
  24. package/lib/process.mjs +30 -4
  25. package/lib/repo-query.mjs +4 -4
  26. package/lib/sandbox-images.mjs +31 -4
  27. package/lib/schema.mjs +28 -0
  28. package/lib/server-context.mjs +3 -2
  29. package/lib/share.mjs +53 -2
  30. package/lib/stats.mjs +11 -0
  31. package/lib/trust-checks.mjs +269 -0
  32. package/lib/trust-files.mjs +123 -0
  33. package/lib/trust-judgment.mjs +157 -0
  34. package/lib/trust-learning.mjs +153 -0
  35. package/lib/trust-map.mjs +149 -0
  36. package/lib/trust-reach.mjs +220 -0
  37. package/lib/trust.mjs +227 -0
  38. package/lib/validators.mjs +7 -0
  39. package/lib/verification-flow.mjs +25 -18
  40. package/lib/verification-network.mjs +2 -2
  41. package/lib/verify.mjs +46 -3
  42. package/lib/worktree-pointer.mjs +246 -0
  43. package/lib/worktree-write.mjs +128 -0
  44. package/mcp/server.mjs +28 -11
  45. package/package.json +1 -1
  46. package/playbooks/feature.md +5 -5
  47. package/scripts/trust-measure.mjs +139 -0
@@ -0,0 +1,215 @@
1
+ // Later jobs use the operator's promises and the worker's import graph.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { createVerificationRunner } from "./verify.mjs";
7
+ import { acceptanceEvidence } from "./acceptance-result.mjs";
8
+ import { loadContracts, contractDisplayPath } from "./acceptance.mjs";
9
+ import { toPosix } from "./path-utils.mjs";
10
+
11
+ const MAX_DEPTH = 8;
12
+ const MAX_FILES = 2000;
13
+
14
+ // Ignore comments and unrelated strings. Keep literal import/require operands;
15
+ // no module is evaluated while building this graph.
16
+ function imports(source) {
17
+ const tokens = source.match(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|[\w$]+|[^\s]/g) ?? [];
18
+ const result = [];
19
+ const literal = token => token && /^["']/.test(token) ? token.slice(1, -1) : null;
20
+ const clean = tokens.filter(t => !t.startsWith("//") && !t.startsWith("/*"));
21
+ for (let i = 0; i < clean.length; i++) {
22
+ const token = clean[i];
23
+ let specifier = null;
24
+ if ((token === "import" || token === "require") && clean[i + 1] === "(" && clean[i + 3] === ")") {
25
+ specifier = literal(clean[i + 2]);
26
+ } else if (token === "import") {
27
+ specifier = literal(clean[i + 1]);
28
+ if (!specifier) {
29
+ for (let j = i + 1; j < clean.length && ![";", "import", "export"].includes(clean[j]); j++) {
30
+ if (clean[j] === "from") { specifier = literal(clean[j + 1]); break; }
31
+ }
32
+ }
33
+ } else if (token === "export") {
34
+ for (let j = i + 1; j < clean.length && ![";", "import", "export"].includes(clean[j]); j++) {
35
+ if (clean[j] === "from") { specifier = literal(clean[j + 1]); break; }
36
+ }
37
+ }
38
+ if (specifier?.startsWith("./") || specifier?.startsWith("../")) result.push(specifier);
39
+ }
40
+ return result;
41
+ }
42
+
43
+ /** Bounds are shared across criteria. An incomplete traversal is conservatively affected. */
44
+ export function affectedContracts({ contracts, worktree, changedFiles }) {
45
+ const root = path.resolve(worktree);
46
+ const changed = new Set(changedFiles.map(file => path.resolve(root, file)));
47
+ const cache = new Map();
48
+ const hits = new Set();
49
+ const inside = file => file === root || (!path.relative(root, file).startsWith(".." + path.sep) && path.relative(root, file) !== ".." && !path.isAbsolute(path.relative(root, file)));
50
+ const resolve = (file, specifier) => {
51
+ const base = path.resolve(path.dirname(file), specifier);
52
+ const candidates = [base, ...[".mjs", ".js", ".cjs"].map(ext => base + ext), ...["index.mjs", "index.js", "index.cjs"].map(name => path.join(base, name))];
53
+ for (const candidate of candidates) {
54
+ if (!inside(candidate)) continue;
55
+ try { if (fs.statSync(candidate).isFile()) return candidate; } catch { /* try fallbacks */ }
56
+ // Deleted imports still connect their test to the change.
57
+ if (changed.has(candidate)) return candidate;
58
+ }
59
+ return null;
60
+ };
61
+ const reachesChange = start => {
62
+ const queue = [[path.resolve(root, start), 0]], visited = new Set();
63
+ let incomplete = false;
64
+ for (let index = 0; index < queue.length; index++) {
65
+ const [file, depth] = queue[index];
66
+ if (changed.has(file)) return true;
67
+ if (visited.has(file) || !inside(file)) continue;
68
+ visited.add(file);
69
+ if (!cache.has(file)) {
70
+ if (cache.size >= MAX_FILES) { hits.add("files"); incomplete = true; continue; }
71
+ let source = "";
72
+ try { source = fs.readFileSync(file, "utf8"); } catch { /* a missing reference is checked when affected */ }
73
+ cache.set(file, imports(source).map(specifier => resolve(file, specifier)).filter(Boolean));
74
+ }
75
+ const next = cache.get(file).filter(dependency => !visited.has(dependency));
76
+ if (depth >= MAX_DEPTH && next.length) { hits.add("depth"); incomplete = true; continue; }
77
+ queue.push(...next.map(dependency => [dependency, depth + 1]));
78
+ }
79
+ return incomplete;
80
+ };
81
+ const selected = changed.size ? contracts.map(contract => ({
82
+ ...contract,
83
+ criteria: contract.criteria.filter(criterion => criterion.status !== "retired"
84
+ && criterion.proven_by.some(ref => "file" in ref && reachesChange(ref.file))),
85
+ })).filter(contract => contract.criteria.length) : [];
86
+ return { contracts: selected, ...(hits.size ? { bounded: { depth: MAX_DEPTH, files: MAX_FILES, hit: [...hits].sort() } } : {}) };
87
+ }
88
+
89
+ /** Snapshot operator contracts outside the worker tree and check inside verification's sandbox. */
90
+ export async function runContractChecks({ contracts, worktree, projectDir, jobDir, jobId,
91
+ timeoutMs, verify = createVerificationRunner({ hostProjectDir: projectDir,
92
+ ...(timeoutMs === undefined ? {} : { commandTimeoutMs: timeoutMs, overallTimeoutMs: timeoutMs }) }) }) {
93
+ const directory = fs.mkdtempSync(path.join(jobDir ?? os.tmpdir(), "acceptance-contracts-"));
94
+ try {
95
+ fs.chmodSync(directory, 0o755);
96
+ contracts.forEach(({ file, ...contract }, index) => {
97
+ fs.writeFileSync(path.join(directory, `${index}.yml`), JSON.stringify(contract), { mode: 0o644 });
98
+ });
99
+ const checked = acceptanceEvidence(await verify({ cwd: worktree, jobId,
100
+ profile: "acceptance-contracts", acceptanceContractsDir: directory }));
101
+ // The checker reports mount-relative paths. Match by snapshot filename, not
102
+ // criterion IDs (IDs need only be unique within each contract).
103
+ if (checked.contracts.length !== contracts.length) throw new Error("incomplete acceptance result");
104
+ return contracts.map((contract, index) => {
105
+ const result = checked.contracts.find(entry => typeof entry.file === "string" && path.posix.basename(entry.file) === `${index}.yml`);
106
+ if (!result || JSON.stringify(result.criteria.map(c => c.id)) !== JSON.stringify(contract.criteria.map(c => c.id))) {
107
+ throw new Error("incomplete acceptance result");
108
+ }
109
+ return { file: contract.file, results: result.criteria };
110
+ });
111
+ } finally { fs.rmSync(directory, { recursive: true, force: true }); }
112
+ }
113
+
114
+ /** No contracts or affected criteria means no sandbox run. */
115
+ export async function checkJobContracts({ projectDir, worktree, changedFiles, timeoutMs, jobDir, jobId, verify, run = runContractChecks }) {
116
+ const impact = affectedContracts({ contracts: loadContracts(projectDir), worktree, changedFiles });
117
+ const criteria = impact.contracts.flatMap(contract => contract.criteria.map(({ id, text }) => ({
118
+ id, text, file: contractDisplayPath(projectDir, contract.file),
119
+ })));
120
+ const record = { affected: criteria.map(criterion => criterion.id), broken: [], ...(impact.bounded ? { bounded: impact.bounded } : {}) };
121
+ if (!criteria.length) return { record, criteria, issues: [] };
122
+ let checked;
123
+ try { checked = await run({ contracts: impact.contracts, worktree, projectDir, timeoutMs, jobDir, jobId, verify }); }
124
+ catch (error) {
125
+ checked = impact.contracts.map(contract => ({ file: contract.file, results: contract.criteria.map(criterion => ({
126
+ id: criterion.id, status: "broken", failures: [{ detail: `couldn't run: ${error.message}` }],
127
+ })) }));
128
+ }
129
+ for (const contract of checked) {
130
+ for (const result of contract.results) {
131
+ if (result.status === "broken" || result.status === "missing") record.broken.push({
132
+ id: result.id, file: contractDisplayPath(projectDir, contract.file), failures: result.failures,
133
+ });
134
+ }
135
+ }
136
+ const issues = record.broken.map(({ id, file, failures }) =>
137
+ `CONTRACT BROKEN: ${id} (${file}): ${failures.map(ref => ref.file ? `${ref.file}: ${ref.test}` : ref.command ?? ref.detail).join("; ")}`);
138
+ return { record, criteria, issues };
139
+ }
140
+
141
+ /** Trigger on both sides of every change, independent of test classification. */
142
+ export function touchesAcceptance(nameStatus) {
143
+ return nameStatus.some(entry => [entry.path, entry.oldPath].some(file =>
144
+ typeof file === "string" && /^acceptance\//i.test(file.replaceAll("\\", "/").replace(/^\.\//, ""))));
145
+ }
146
+
147
+ /** Compare the loader's complete declarations without executing either side's proofs.
148
+ * Git supplies the base tree, never the operator's possibly modified worktree.
149
+ * Accumulate failures and findings before reporting, including unexpected errors.
150
+ */
151
+ export async function changedContractWeakening({ worktree, baseSha, nameStatus = [], gitRaw, load = loadContracts }) {
152
+ const weakened = [], errors = [];
153
+ let files = [], directory;
154
+ const fail = error => {
155
+ let message = toPosix(error.message ?? String(error));
156
+ for (const root of [directory, worktree].filter(Boolean).map(toPosix).sort((a, b) => b.length - a.length)) {
157
+ message = message.replaceAll(`${root.replace(/\/$/, "")}/`, "");
158
+ }
159
+ errors.push(message);
160
+ };
161
+ try {
162
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), "acceptance-base-"));
163
+ const paths = (await gitRaw(["ls-tree", "-r", "--name-only", "-z", baseSha], worktree)).split("\0").filter(Boolean);
164
+ for (const file of paths.filter(file => /^acceptance\//i.test(file))) {
165
+ try {
166
+ const target = path.resolve(directory, file);
167
+ if (!target.startsWith(directory + path.sep)) throw new Error("invalid base contract path");
168
+ const source = await gitRaw(["show", `${baseSha}:${file}`], worktree);
169
+ fs.mkdirSync(path.dirname(target), { recursive: true });
170
+ fs.writeFileSync(target, source);
171
+ } catch (error) { fail(error); }
172
+ }
173
+ const before = load(directory, { onError: fail });
174
+ const after = load(worktree, { onError: fail });
175
+ const changed = new Set(nameStatus.map(entry => entry.path));
176
+ files = after.map(contract => contractDisplayPath(worktree, contract.file)).filter(file => changed.has(file));
177
+ const index = new Map();
178
+ for (const contract of after) {
179
+ for (const criterion of contract.criteria) {
180
+ if (index.has(criterion.id)) fail(new Error(`duplicate criterion ${criterion.id}`));
181
+ else index.set(criterion.id, criterion);
182
+ }
183
+ }
184
+ const normalizedText = text => text.trim().replace(/\s+/g, " ");
185
+ const rank = { met: 3, unproven: 2, broken: 1, missing: 1, retired: 0 };
186
+ for (const contract of before) {
187
+ for (const criterion of contract.criteria) {
188
+ const changes = [];
189
+ try {
190
+ const next = index.get(criterion.id);
191
+ if (!next) changes.push("criterion removed");
192
+ else {
193
+ if (normalizedText(criterion.text) !== normalizedText(next.text)) {
194
+ changes.push(`text changed from ${JSON.stringify(criterion.text)} to ${JSON.stringify(next.text)}`);
195
+ }
196
+ if (criterion.security === true && next.security !== true) changes.push("security protection removed");
197
+ if (rank[next.status] < rank[criterion.status]) changes.push(`${criterion.status} status changed to ${next.status}`);
198
+ if (criterion.proven_by.some(ref => !next.proven_by.some(value => isDeepStrictEqual(ref, value)))) changes.push("proofs removed");
199
+ }
200
+ } catch (error) { fail(error); }
201
+ if (changes.length) weakened.push({ id: criterion.id, file: contractDisplayPath(directory, contract.file), changes });
202
+ }
203
+ }
204
+ } catch (error) { fail(error); }
205
+ finally {
206
+ if (directory) {
207
+ try { fs.rmSync(directory, { recursive: true, force: true }); }
208
+ catch (error) { fail(error); }
209
+ }
210
+ }
211
+ const issues = weakened.map(({ id, file, changes }) =>
212
+ `CONTRACT WEAKENED: ${id} (${file}): ${changes.join("; ")}`);
213
+ if (errors.length) issues.push(`CONTRACT CHECK ERROR: ${errors.join("; ")}`);
214
+ return { weakened, issues, files };
215
+ }
@@ -0,0 +1,21 @@
1
+ // Only sandboxed checker output is evidence for automatic acceptance checks.
2
+ export function parseAcceptanceResult(stdout) {
3
+ let result;
4
+ try { result = JSON.parse(stdout); }
5
+ catch { throw new Error("acceptance check returned non-JSON output"); }
6
+ const statuses = ["met", "broken", "missing", "unproven", "retired"];
7
+ if (!Array.isArray(result?.contracts) || result.contracts.some(contract =>
8
+ !Array.isArray(contract?.criteria) || contract.criteria.some(criterion =>
9
+ typeof criterion?.id !== "string" || !statuses.includes(criterion.status)))) {
10
+ throw new Error("acceptance check returned invalid JSON result");
11
+ }
12
+ return result;
13
+ }
14
+
15
+ export function acceptanceEvidence(value) {
16
+ if (value?.status === "not_run" || !value?.acceptanceResult) {
17
+ throw new Error(["sandbox-unavailable", "sandbox-image-build-failed"].includes(value?.basis) ? "sandbox unavailable"
18
+ : value?.reason || value?.detail || "sandbox acceptance produced no result");
19
+ }
20
+ return value.acceptanceResult;
21
+ }
@@ -0,0 +1,166 @@
1
+ // Acceptance contracts keep feature promises tied to executable regression checks.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import YAML from "yaml";
6
+ import { z } from "zod";
7
+ import { typeError } from "./zod-issues.mjs";
8
+ import { toPosix } from "./path-utils.mjs";
9
+
10
+ export const criterionIdSchema = z.string().regex(/^[A-Z][A-Z0-9]*-\d+$/, "must be an ID such as WIN-6");
11
+
12
+ const string = () => z.string({ error: typeError("a string") }).min(1, "must not be empty");
13
+ const platformsSchema = z.array(z.enum([
14
+ "aix", "android", "cygwin", "darwin", "freebsd", "haiku", "linux", "netbsd", "openbsd", "sunos", "win32", "posix",
15
+ ]));
16
+ const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "must be YYYY-MM-DD").refine((value) => {
17
+ const date = new Date(`${value}T00:00:00Z`);
18
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
19
+ }, "must be a real date");
20
+ const referenceSchema = z.union([
21
+ z.object({ file: string(), test: string(), platforms: platformsSchema.optional() }).strict(),
22
+ z.object({ command: string(), cwd: string().optional(), platforms: platformsSchema.optional() }).strict(),
23
+ z.object({ manual: string(), checked_by: string(), date: dateSchema, expires_days: z.number().int().positive().optional(), platforms: platformsSchema.optional() }).strict(),
24
+ ]);
25
+ export const contractSchema = z.object({
26
+ feature: string(),
27
+ run: string().optional(),
28
+ pr: z.union([string(), z.number().int().positive()]).optional(),
29
+ criteria: z.array(z.object({
30
+ id: criterionIdSchema,
31
+ text: string(),
32
+ proven_by: z.array(referenceSchema),
33
+ status: z.enum(["met", "unproven", "broken", "retired"]),
34
+ security: z.boolean().optional(),
35
+ note: z.string().optional(),
36
+ }).strict()),
37
+ }).strict().superRefine((contract, ctx) => {
38
+ const seen = new Set();
39
+ contract.criteria.forEach(({ id }, index) => {
40
+ if (seen.has(id)) ctx.addIssue({ code: "custom", path: ["criteria", index, "id"], message: `duplicate criterion ${id}` });
41
+ seen.add(id);
42
+ });
43
+ });
44
+
45
+ /** Load one file while retaining its path for grouped CLI output and errors. */
46
+ export function loadContract(file) {
47
+ let data;
48
+ try {
49
+ data = YAML.parse(fs.readFileSync(file, "utf8"));
50
+ } catch (error) {
51
+ throw new Error(`${file}: ${error.message}`);
52
+ }
53
+ const parsed = contractSchema.safeParse(data);
54
+ if (!parsed.success) {
55
+ const issues = parsed.error.issues.map((issue) => {
56
+ const index = issue.path[0] === "criteria" ? issue.path[1] : undefined;
57
+ const id = index === undefined ? "contract" : data?.criteria?.[index]?.id ?? `criteria[${index}]`;
58
+ return `${file}: ${id}: ${issue.path.join(".") || "contract"} ${issue.message}`;
59
+ });
60
+ throw new Error(issues.join("\n"));
61
+ }
62
+ return { file, ...parsed.data };
63
+ }
64
+
65
+ export function contractDisplayPath(repoDir, file) {
66
+ return toPosix(path.relative(repoDir, file));
67
+ }
68
+
69
+ export function loadContracts(repoDir, { onError = error => { throw error; } } = {}) {
70
+ const directory = path.join(repoDir, "acceptance");
71
+ let files;
72
+ try {
73
+ files = fs.readdirSync(directory);
74
+ } catch (error) {
75
+ if (error.code === "ENOENT") return [];
76
+ throw error;
77
+ }
78
+ return files.filter((file) => file.endsWith(".yml")).sort().flatMap((file) => {
79
+ try { return [loadContract(path.join(directory, file))]; }
80
+ catch (error) { onError(error); return []; }
81
+ });
82
+ }
83
+
84
+ const fixedName = (name) => name.split("${", 1)[0];
85
+ const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
86
+ const failureFor = (ref, detail) => "file" in ref
87
+ ? { file: ref.file, test: ref.test, detail }
88
+ : { command: ref.command, detail };
89
+
90
+ const appliesOn = (ref, platform) => !ref.platforms || ref.platforms.some((value) => value === platform || (value === "posix" && platform !== "win32"));
91
+
92
+ /** Recompute status from evidence, never from a contract's recorded verdict. */
93
+ export function checkContract(contract, { repoDir, run = spawnSync, platform = process.platform, today = new Date().toISOString().slice(0, 10) }) {
94
+ // A checker invoked by node:test must start a fresh test runner, not inherit
95
+ // the parent's child-test context (which disables --test on some Node versions).
96
+ const env = { ...process.env };
97
+ delete env.NODE_TEST_CONTEXT;
98
+ const options = { cwd: repoDir, encoding: "utf8", env, maxBuffer: 16 * 1024 * 1024 };
99
+ const execute = (command, args, opts) => {
100
+ try { return run(command, args, opts); }
101
+ catch (error) { return { status: null, error }; }
102
+ };
103
+ const runFailure = (result) => result.error?.message || result.stderr?.trim() || result.stdout?.trim()
104
+ || `process exited ${result.status ?? result.signal ?? "without a status"}`;
105
+
106
+ return contract.criteria.map((criterion) => {
107
+ const result = { id: criterion.id, text: criterion.text, status: "met", security: criterion.security ?? false, failures: [] };
108
+ if (criterion.status === "retired") return { ...result, status: "retired" };
109
+ if (!criterion.proven_by.length) return { ...result, status: "unproven" };
110
+ const applicable = criterion.proven_by.filter((ref) => appliesOn(ref, platform));
111
+ const notApplicable = criterion.proven_by.filter((ref) => !appliesOn(ref, platform));
112
+ if (notApplicable.length) result.notApplicable = notApplicable;
113
+ if (!applicable.length) return { ...result, status: "unproven", note: `no proof applies on ${platform}` };
114
+ const manualRefs = applicable.filter((ref) => "manual" in ref);
115
+ const expired = manualRefs.filter((ref) => ref.expires_days !== undefined
116
+ && Date.parse(`${today}T00:00:00Z`) > Date.parse(`${ref.date}T00:00:00Z`) + ref.expires_days * 86400000);
117
+ const currentManual = manualRefs.filter((ref) => !expired.includes(ref));
118
+ if (currentManual.length) result.manual = currentManual;
119
+ const nodeRefs = applicable.filter((ref) => "file" in ref);
120
+ for (const ref of nodeRefs) {
121
+ try {
122
+ const source = fs.readFileSync(path.resolve(repoDir, ref.file), "utf8");
123
+ if (!fixedName(ref.test) || !source.includes(fixedName(ref.test))) {
124
+ throw new Error("test name is absent from the source");
125
+ }
126
+ } catch (error) {
127
+ result.failures.push(failureFor(ref, `${criterion.id}: ${ref.file}: ${ref.test}: ${error.message}`));
128
+ }
129
+ }
130
+ if (result.failures.length) return { ...result, status: "missing" };
131
+ if (nodeRefs.length) {
132
+ // Literal names are exact; template names select the loop's fixed prefix.
133
+ const pattern = nodeRefs.map((ref) => `^${escapeRegex(fixedName(ref.test))}${ref.test.includes("${") ? "" : "$"}`).join("|");
134
+ const files = [...new Set(nodeRefs.map((ref) => path.resolve(repoDir, ref.file)))];
135
+ const execution = execute(process.execPath, ["--test", "--test-reporter=tap", "--test-name-pattern", pattern, ...files], options);
136
+ // Newer Node versions report the file itself as passing when a filter
137
+ // matches nothing. Count named, non-skipped test results, not file passes.
138
+ const passedNames = [...String(execution.stdout ?? "").matchAll(/^\s*ok \d+ - (.+)$/gm)]
139
+ .map((match) => match[1].trim()).filter((name) => !/ # (?:SKIP|TODO)\b/i.test(name))
140
+ .map((name) => name.replace(/\\#/g, "#"));
141
+ if (execution.status !== 0 || execution.error) {
142
+ const failedNames = [...String(execution.stdout ?? "").matchAll(/^\s*not ok \d+ - (.+)$/gm)]
143
+ .map((match) => match[1].trim().replace(/\\#/g, "#"));
144
+ const failedRefs = nodeRefs.filter((ref) => failedNames.some((name) => ref.test.includes("${") ? name.startsWith(fixedName(ref.test)) : name === ref.test));
145
+ // Startup and runner failures may have no named test result at all.
146
+ result.failures.push(...(failedRefs.length ? failedRefs : nodeRefs).map((ref) => failureFor(ref, runFailure(execution))));
147
+ } else {
148
+ for (const ref of nodeRefs) {
149
+ const matched = passedNames.some((name) => ref.test.includes("${") ? name.startsWith(fixedName(ref.test)) : name === ref.test);
150
+ if (!matched) result.failures.push(failureFor(ref, "no tests passed (zero tests ran or all were skipped/todo)"));
151
+ }
152
+ }
153
+ }
154
+ for (const ref of applicable.filter((ref) => "command" in ref)) {
155
+ const execution = execute(ref.command, [], { ...options, shell: true, cwd: path.resolve(repoDir, ref.cwd ?? ".") });
156
+ if (execution.status !== 0 || execution.error) result.failures.push(failureFor(ref, runFailure(execution)));
157
+ }
158
+ if (result.failures.length) result.status = "broken";
159
+ else if (!currentManual.length && !nodeRefs.length && !applicable.some((ref) => "command" in ref)) {
160
+ result.status = "unproven";
161
+ const ref = expired[0];
162
+ result.note = `manual check by ${ref.checked_by} on ${ref.date} expired after ${ref.expires_days} days`;
163
+ }
164
+ return result;
165
+ });
166
+ }
package/lib/admission.mjs CHANGED
@@ -1,8 +1,11 @@
1
+ import { markBriefTrust, trustValidatorSettings } from "./trust-judgment.mjs";
2
+ import { criteriaProblems } from "./acceptance-fill.mjs";
3
+ import { registerJobsRoot, registeredWorktree, registerJobWorktree } from "./worktree-pointer.mjs";
1
4
  import fs from "node:fs";
2
5
  import path from "node:path";
3
6
  import { executionMode } from "./execution.mjs";
4
7
  import { jobLabel, jobElapsedSeconds } from "./job-format.mjs";
5
- import { continuationProblem } from "./continue-from.mjs";
8
+ import { continuationStakes, continuationProblem } from "./continue-from.mjs";
6
9
  import { loadConfig } from "./config.mjs";
7
10
  import { maxJobs } from "./limits.mjs";
8
11
  import { checkBrief, assessAdmission, describeBudgets } from "./budget.mjs";
@@ -67,6 +70,7 @@ export function refusalText(problems, snapshot) {
67
70
  }
68
71
 
69
72
  export function createJobRuntime(deps) {
73
+ registerJobsRoot(deps.jobsRoot);
70
74
  const { projectDir, stateRoot, jobsRoot, runsRoot, leasesRoot, slotsRoot, run, currentMaxWorkers, slug, agentsConfig, modelCatalogReady, budgetsForJob, resolveSubscriptionSelection, executeJob, subscriptionJobFieldProblems, repoPolicy, jobArgs } = deps;
71
75
 
72
76
  // Staggers concurrent job starts by `slot * staggerMs` before each runner
@@ -203,6 +207,9 @@ export function createJobRuntime(deps) {
203
207
  if (jobs.some(j => j.mode !== "verify")) await deps.budgetState.refresh();
204
208
  if (jobs.some((j) => j.mode !== "verify" && jobLane(j) === "remote")) await modelCatalogReady();
205
209
  const problems = [];
210
+ jobs.forEach((j, i) => {
211
+ for (const problem of criteriaProblems(j.criteria, projectDir, j.mode)) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}${problem}`);
212
+ });
206
213
  const notARepo = (deps.projectDirProblem ?? projectDirProblem)(projectDir);
207
214
  if (notARepo) problems.push(notARepo);
208
215
  // Every job needs its sandbox; the server wires the Podman check (tests don't).
@@ -256,6 +263,14 @@ export function createJobRuntime(deps) {
256
263
  } catch (error) { problems.push(error.message); }
257
264
  return;
258
265
  }
266
+ // A misspelled profile used to be found only after the worker ran, so the
267
+ // whole run was spent and nothing could be committed. Refuse it here.
268
+ if (j.verification && (j.mode ?? "implement") === "implement") {
269
+ try {
270
+ const profiles = Object.keys(loadConfig(projectDir)?.config?.verification ?? {});
271
+ if (!profiles.includes(j.verification)) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}verification profile '${j.verification}' is not defined in .nomarmy.yml; available profiles: ${profiles.join(", ") || "(none)"}`);
272
+ } catch (error) { problems.push(error.message); }
273
+ }
259
274
  const jobBudgets = budgetsForJob(j);
260
275
  for (const p of checkBrief(j, jobBudgets)) problems.push(jobs.length > 1 ? `job ${i + 1}: ${p}` : p);
261
276
  });
@@ -275,9 +290,10 @@ export function createJobRuntime(deps) {
275
290
  // and belong to this repo; see lib/continue-from.mjs.
276
291
  jobs.forEach((j, i) => {
277
292
  if (!j.continue_from) return;
278
- const { problem } = continuationProblem({ continueFrom: j.continue_from, mode: j.mode, baseRef: j.base_ref ?? null, jobsRoot, projectDir,
293
+ const { problem, record } = continuationProblem({ continueFrom: j.continue_from, mode: j.mode, baseRef: j.base_ref ?? null, jobsRoot, projectDir,
279
294
  isRunning: (id) => activeJobs.get(id)?.settled === false });
280
295
  if (problem) problems.push(`${jobs.length > 1 ? `job ${i + 1}: ` : ""}${problem}`);
296
+ else j.stakes = continuationStakes(record.stakes, j.stakes);
281
297
  });
282
298
  jobs.forEach((j, i) => {
283
299
  if (j.verify_regression && !j.verification) {
@@ -395,6 +411,15 @@ export function createJobRuntime(deps) {
395
411
  problems.push(`not admitted (capacity): ${runningRemote} remote job(s) (api or subscription agents) already running, the limit of ${remoteCeiling} at once (raise it with \`nomarmy config max-jobs <n>\`)`);
396
412
  }
397
413
  }
414
+ // Model marking runs after refusal checks so it never rejects admission.
415
+ const settings = trustValidatorSettings(deps);
416
+ // Only the operator checkout can opt out, never job or worktree config.
417
+ const judgment = !problems.length ? loadConfig(projectDir)?.config?.trust?.judgment : undefined;
418
+ if (!problems.length && (judgment === false || settings.jev || (settings.judge && !settings.judge.problem))) {
419
+ const notes = await Promise.all(jobs.map((job) => markBriefTrust(job, { ...settings, judgment, stateRoot,
420
+ askJev: deps.askTrustJev, askJudge: deps.askTrustJudge })));
421
+ admission.reasons.push(...notes.flat());
422
+ }
398
423
  for (const note of usageNotes) admission.reasons.push(note);
399
424
  return { problems, admission };
400
425
  }
@@ -472,6 +497,11 @@ export function createJobRuntime(deps) {
472
497
  try {
473
498
  const worktree = path.join(jobDir, "worktree");
474
499
  if (fs.existsSync(worktree)) {
500
+ if (!registeredWorktree(worktree)) {
501
+ const metadata = readJson(path.join(jobDir, "metadata.json"));
502
+ const record = metadata?.worktreePointerBefore ? metadata : readJson(path.join(jobDir, "status.json"));
503
+ registerJobWorktree(record, { worktree, repoRoot: projectDir });
504
+ }
475
505
  // --untracked-files=normal, not all: "all" descends into every
476
506
  // untracked directory (a virtualenv, a cache) a job creates. Bounded:
477
507
  // a live progress read must never hold anything up.
@@ -482,7 +512,7 @@ export function createJobRuntime(deps) {
482
512
  // filesChangedLive: 1 anyway.
483
513
  out.filesChangedLive = parseStatusPorcelainZ(statusOut).map(e => e.file).filter(f => !isRuntimeJunk(f)).length;
484
514
  }
485
- } catch { /* worktree not ready yet, or mutated mid-read; omit */ }
515
+ } catch (error) { if (error.message.includes("worktree Git pointer integrity failure")) out.worktreeIntegrityError = error.message; }
486
516
  try {
487
517
  const stateDir = path.join(jobDir, "runtime", "state");
488
518
  const transcript = await readOpenClawTranscriptTail(stateDir, { limit: 6 });
package/lib/army.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnHostGitSync } from "./worktree-pointer.mjs";
1
2
  // nomArmy's army: who the General (the coordinator session) calls for what.
2
3
  //
3
4
  // The General is the Claude Code / Codex session itself. Its charter is
@@ -23,7 +24,6 @@
23
24
  // endpoint, an owner or a provider, and never will. A hostile
24
25
  // .nomarmy.yml can at worst route a job to one of your own agents.
25
26
 
26
- import { spawnSync } from "node:child_process";
27
27
  import fs from "node:fs";
28
28
  import os from "node:os";
29
29
  import path from "node:path";
@@ -101,7 +101,7 @@ export function privateConfigProblem(filePath, { platform = process.platform, ui
101
101
 
102
102
  /** True when git tracks this file (committed or force-added past .gitignore). No git or no repo -> false. */
103
103
  export function isTrackedByGit(filePath) {
104
- const result = spawnSync("git", ["ls-files", "--error-unmatch", "--", path.basename(filePath)], { cwd: path.dirname(filePath), stdio: "ignore" });
104
+ const result = spawnHostGitSync(path.dirname(filePath), ["ls-files", "--error-unmatch", "--", path.basename(filePath)], { cwd: path.dirname(filePath), stdio: "ignore" });
105
105
  return result.status === 0;
106
106
  }
107
107
 
@@ -4,13 +4,17 @@ import { parseOpenclawAuthProfiles } from "./health.mjs";
4
4
  import { CODEX_IMPORT_ARGS, CODEX_IMPORT_RECOVERY, emailOpenaiProfiles, usableCodexImport } from "./openclaw-runtime-health.mjs";
5
5
 
6
6
  // Never relay migration output: --include-secrets may expose credential data.
7
- export async function linkCodex({ run, command = "openclaw", isTTY = false, removeEmailProfiles = false, confirm = async () => false, print = () => {}, codexDir = path.join(os.homedir(), ".codex"), now = Date.now() }) {
7
+ export async function linkCodex({ run, command = "openclaw", isTTY = false, importLogin = false, removeEmailProfiles = false, confirm = async () => false, print = () => {}, codexDir = path.join(os.homedir(), ".codex"), now = Date.now() }) {
8
8
  const read = async () => {
9
9
  const result = await run(command, ["models", "auth", "list", "--json"]);
10
10
  return result.ok ? parseOpenclawAuthProfiles(result.stdout) : null;
11
11
  };
12
12
  const profiles = await read();
13
13
  if (!profiles) { print("Cannot inspect OpenClaw auth profiles; nothing imported."); return false; }
14
+ if (!importLogin && !(isTTY && await confirm("Import your Codex login into OpenClaw? This copies the ChatGPT credential Codex stores into OpenClaw's auth store", { defaultYes: true }))) {
15
+ print("Nothing imported; use --link-openclaw to authorize the Codex login import non-interactively.");
16
+ return false;
17
+ }
14
18
  const emails = emailOpenaiProfiles(profiles);
15
19
  if (emails.length) {
16
20
  print(`Email-keyed profiles will capture the Codex import: ${emails.map((p) => p.id).join(", ")}.`);
@@ -32,6 +36,7 @@ export async function linkCodex({ run, command = "openclaw", isTTY = false, remo
32
36
  print("Sign-in has no usable auth profile: expected an unexpired openai:account- profile labeled (Codex import).");
33
37
  return false;
34
38
  }
39
+ print("Imported the ChatGPT credential stored by Codex into OpenClaw's auth store.");
35
40
  print("Confirmed an unexpired openai:account- (Codex import) profile.");
36
41
  return true;
37
42
  }
package/lib/connect.mjs CHANGED
@@ -306,6 +306,8 @@ export function includeInGit(projectDir, relPaths, run = defaultRun) {
306
306
  return removed.map((l) => l.trim());
307
307
  }
308
308
 
309
+ export const PROJECT_REGISTRATION_WARNING = "Warning: a project MCP registration is executable configuration committed to the repository. Other developers run its command when they open this repo. Review it before committing.";
310
+
309
311
  function repoScoped(scope, projectDir) {
310
312
  if (!SCOPES.includes(scope)) throw new Error(`unknown scope "${scope}"; use one of ${SCOPES.join(", ")}`);
311
313
  if (scope !== "user" && !projectDir) throw new Error(`--scope ${scope} registers nomArmy for one repository; run it inside that repository`);
@@ -353,7 +355,8 @@ export function derivePoolAuthEnvPlaceholders(configDir = globalConfigDir()) {
353
355
  /**
354
356
  * @param {{ nomarmyRoot: string, installDir?: string, run?: Function, extraEnv?: Record<string,string>, configDir?: string }} input
355
357
  */
356
- export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, extraEnv = {}, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().claude, settingsPath = defaultClaudeSettingsPath(), scope = "user", projectDir = null }) {
358
+ export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, extraEnv = {}, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().claude, settingsPath = defaultClaudeSettingsPath(), scope = "user", projectDir = null, warn = console.warn }) {
359
+ if (scope === "project") warn(PROJECT_REGISTRATION_WARNING);
357
360
  if (repoScoped(scope, projectDir)) return connectClaudeToRepo({ nomarmyRoot, installDir, run, extraEnv, configDir, scope, projectDir });
358
361
  installMcpCopy({ nomarmyRoot, installDir, run });
359
362
  const commands = installPlaybooks({ nomarmyRoot, target: "claude", dir: commandsDir });
@@ -553,7 +556,8 @@ export function readCursorConfig(configPath) {
553
556
  /**
554
557
  * @param {{ nomarmyRoot: string, installDir?: string, configPath?: string }} input
555
558
  */
556
- export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), configPath = defaultCursorConfigPath(), run = defaultRun, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().cursor, scope = "user", projectDir = null }) {
559
+ export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), configPath = defaultCursorConfigPath(), run = defaultRun, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().cursor, scope = "user", projectDir = null, warn = console.warn }) {
560
+ if (scope === "project") warn(PROJECT_REGISTRATION_WARNING);
557
561
  // Cursor reads a project's own .cursor/mcp.json beside the user one. local
558
562
  // keeps it out of git; project commits it, with the portable command.
559
563
  if (repoScoped(scope, projectDir)) {
@@ -13,6 +13,8 @@ import fs from "node:fs";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
15
  import { isRuntimeJunk } from "./git-record.mjs";
16
+ import { registerWorktreePointer, prepareHostGit } from "./worktree-pointer.mjs";
17
+ import { removeWorktreeEntry } from "./worktree-write.mjs";
16
18
 
17
19
  const JOB_ID_RE = /^[A-Za-z0-9._-]{1,120}$/;
18
20
 
@@ -43,6 +45,11 @@ export function continuationProblem({ continueFrom, mode = "implement", baseRef
43
45
  return { problem: null, record: { ...record, worktree } };
44
46
  }
45
47
 
48
+ /** A continuation may raise stakes, never lower the retained work's stakes. */
49
+ export function continuationStakes(retained, brief) {
50
+ return retained === "high" || brief === "high" ? "high" : brief ?? retained ?? null;
51
+ }
52
+
46
53
  /** The retained job's base commit, however its record spells it. */
47
54
  export function continuationBase(record) {
48
55
  return record.git?.baseSha ?? record.gitBeforeCoordinatorCommit?.baseSha ?? record.baseSha;
@@ -56,17 +63,26 @@ export function continuationBase(record) {
56
63
  * worktree can check the commit out.
57
64
  * @returns {Promise<{ commit: string, files: string[] }>}
58
65
  */
59
- export async function snapshotRetainedWork({ worktree, baseSha, jobId, git }) {
66
+ function guardedGit(git, { worktree, repoRoot, expectedPointer }) {
67
+ return async (args, opts = {}) => {
68
+ registerWorktreePointer(worktree, { expectedBytes: expectedPointer, repoRoot });
69
+ const prep = prepareHostGit(worktree, args);
70
+ return git([...prep.argsPrefix, ...args], { ...opts, env: prep.env(opts.env ?? process.env) });
71
+ };
72
+ }
73
+
74
+ export async function snapshotRetainedWork({ worktree, baseSha, jobId, git, repoRoot, expectedPointer }) {
75
+ const hostGit = guardedGit(git, { worktree, repoRoot, expectedPointer });
60
76
  const indexFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "nomarmy-continue-")), "index");
61
77
  const env = { ...process.env, GIT_INDEX_FILE: indexFile };
62
78
  try {
63
- await git(["read-tree", baseSha], { cwd: worktree, env });
64
- await git(["add", "-A"], { cwd: worktree, env });
65
- const staged = (await git(["diff", "--cached", "--name-only", "-z", baseSha], { cwd: worktree, env })).split("\0").filter(Boolean);
79
+ await hostGit(["read-tree", baseSha], { cwd: worktree, env });
80
+ await hostGit(["add", "-A"], { cwd: worktree, env });
81
+ const staged = (await hostGit(["diff", "--cached", "--name-only", "-z", baseSha], { cwd: worktree, env })).split("\0").filter(Boolean);
66
82
  const junk = staged.filter(isRuntimeJunk);
67
- for (const file of junk) await git(["rm", "--cached", "-q", "-r", "--ignore-unmatch", "--", file], { cwd: worktree, env });
68
- const tree = (await git(["write-tree"], { cwd: worktree, env })).trim();
69
- const commit = (await git(["commit-tree", tree, "-p", baseSha, "-m", `nomArmy: unfinished work of ${jobId}`], { cwd: worktree, env })).trim();
83
+ for (const file of junk) await hostGit(["rm", "--cached", "-q", "-r", "--ignore-unmatch", "--", file], { cwd: worktree, env });
84
+ const tree = (await hostGit(["write-tree"], { cwd: worktree, env })).trim();
85
+ const commit = (await hostGit(["commit-tree", tree, "-p", baseSha, "-m", `nomArmy: unfinished work of ${jobId}`], { cwd: worktree, env })).trim();
70
86
  return { commit, files: staged.filter((f) => !junk.includes(f)) };
71
87
  } finally {
72
88
  fs.rmSync(path.dirname(indexFile), { recursive: true, force: true });
@@ -78,11 +94,12 @@ export async function snapshotRetainedWork({ worktree, baseSha, jobId, git }) {
78
94
  * changes: the files from the snapshot, deletions removed, index back at the
79
95
  * base. The new job's git record then counts them as its own diff.
80
96
  */
81
- export async function applyRetainedWork({ worktree, baseSha, commit, git }) {
82
- await git(["checkout", commit, "--", "."], { cwd: worktree });
83
- const deleted = (await git(["diff", "--name-only", "-z", "--diff-filter=D", baseSha, commit], { cwd: worktree })).split("\0").filter(Boolean);
84
- for (const file of deleted) fs.rmSync(path.join(worktree, file), { force: true });
85
- await git(["reset", "-q"], { cwd: worktree });
97
+ export async function applyRetainedWork({ worktree, baseSha, commit, git, repoRoot, expectedPointer }) {
98
+ const hostGit = guardedGit(git, { worktree, repoRoot, expectedPointer });
99
+ await hostGit(["checkout", commit, "--", "."], { cwd: worktree });
100
+ const deleted = (await hostGit(["diff", "--name-only", "-z", "--diff-filter=D", baseSha, commit], { cwd: worktree })).split("\0").filter(Boolean);
101
+ for (const file of deleted) removeWorktreeEntry(worktree, path.join(worktree, file));
102
+ await hostGit(["reset", "-q"], { cwd: worktree });
86
103
  }
87
104
 
88
105
  /** The brief's note, so the worker builds on the work instead of redoing it. */