nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +86 -480
- package/bin/nomarmy.mjs +1081 -185
- package/docker/Dockerfile +2 -2
- package/docker/Dockerfile.go +6 -4
- package/docker/Dockerfile.rust +17 -2
- package/harnesses/_template/README.md +27 -0
- package/harnesses/_template/harness.yml +26 -0
- package/harnesses/browser-playwright/README.md +35 -0
- package/harnesses/browser-playwright/fixture/package.json +1 -0
- package/harnesses/browser-playwright/fixture/page.html +1 -0
- package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
- package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
- package/harnesses/browser-playwright/harness.yml +18 -0
- package/harnesses/go/README.md +45 -0
- package/harnesses/go/harness.yml +14 -0
- package/harnesses/mock-oidc/README.md +31 -0
- package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
- package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
- package/harnesses/mock-oidc/harness.yml +19 -0
- package/harnesses/node/README.md +53 -0
- package/harnesses/node/harness.yml +18 -0
- package/harnesses/python/README.md +46 -0
- package/harnesses/python/harness.yml +16 -0
- package/harnesses/rust/README.md +45 -0
- package/harnesses/rust/harness.yml +13 -0
- package/install.sh +29 -9
- package/lib/admission.mjs +178 -30
- package/lib/agents.mjs +8 -6
- package/lib/army.mjs +25 -10
- package/lib/codex-link.mjs +37 -0
- package/lib/config.mjs +15 -0
- package/lib/connect.mjs +232 -19
- package/lib/continue-from.mjs +103 -0
- package/lib/coordinator-instructions.mjs +5 -1
- package/lib/diff-checks.mjs +114 -0
- package/lib/dispatch-schema.mjs +14 -12
- package/lib/doctor.mjs +98 -9
- package/lib/egress-proxy.mjs +116 -0
- package/lib/execute.mjs +241 -33
- package/lib/git-record.mjs +27 -3
- package/lib/harness-schema.mjs +61 -0
- package/lib/harnesses.mjs +99 -0
- package/lib/health.mjs +162 -18
- package/lib/install-freshness.mjs +114 -0
- package/lib/jev-checks.mjs +110 -0
- package/lib/job-format.mjs +54 -0
- package/lib/judge.mjs +130 -0
- package/lib/limits.mjs +77 -0
- package/lib/model-probe.mjs +61 -0
- package/lib/mutation.mjs +159 -0
- package/lib/notify.mjs +30 -3
- package/lib/openclaw-install.mjs +122 -0
- package/lib/openclaw-path.mjs +28 -0
- package/lib/openclaw-run.mjs +74 -12
- package/lib/openclaw-runtime-health.mjs +56 -0
- package/lib/outcome.mjs +21 -2
- package/lib/outcomes.mjs +6 -0
- package/lib/path-utils.mjs +4 -0
- package/lib/podman-health.mjs +41 -0
- package/lib/process.mjs +4 -1
- package/lib/propose.mjs +10 -11
- package/lib/refusal-retry.mjs +16 -0
- package/lib/registry-python.mjs +98 -0
- package/lib/registry-secrets.mjs +140 -0
- package/lib/repo-query.mjs +13 -7
- package/lib/runs.mjs +7 -1
- package/lib/same-path.mjs +14 -0
- package/lib/sandbox-images.mjs +499 -83
- package/lib/sandbox-vm.mjs +32 -0
- package/lib/scan.mjs +5 -1
- package/lib/schema.mjs +20 -11
- package/lib/scout.mjs +21 -3
- package/lib/server-context.mjs +21 -1
- package/lib/setup-steps.mjs +55 -0
- package/lib/share.mjs +82 -0
- package/lib/stale-sessions.mjs +60 -0
- package/lib/stats.mjs +315 -0
- package/lib/statusline.mjs +32 -6
- package/lib/subscription-setup.mjs +13 -0
- package/lib/suggestions.mjs +153 -0
- package/lib/thinking.mjs +23 -0
- package/lib/transcript.mjs +30 -5
- package/lib/usage-limits.mjs +329 -0
- package/lib/user-config.mjs +106 -0
- package/lib/validators.mjs +220 -0
- package/lib/verification-artifacts.mjs +46 -0
- package/lib/verification-flow.mjs +52 -7
- package/lib/verification-network.mjs +66 -0
- package/lib/verify.mjs +338 -85
- package/lib/worker-prompt.mjs +5 -2
- package/lib/wsl-cli.mjs +152 -0
- package/lib/wsl.mjs +230 -0
- package/lib/zod-issues.mjs +15 -0
- package/mcp/server.mjs +165 -34
- package/package.json +7 -5
- package/playbooks/feature.md +8 -5
- package/scripts/configure-openclaw.sh +4 -2
- package/scripts/generate-harness-docs.mjs +42 -0
- package/scripts/install-openclaw.mjs +23 -0
- package/scripts/lib.sh +9 -2
- package/scripts/select-model.mjs +12 -5
- package/scripts/start-inference.sh +2 -2
package/lib/connect.mjs
CHANGED
|
@@ -24,10 +24,15 @@
|
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { mcpBridgeLaunch } from "./wsl.mjs";
|
|
29
|
+
import { toPosix } from "./path-utils.mjs";
|
|
27
30
|
import { execFileSync } from "node:child_process";
|
|
28
31
|
import { loadAgents } from "./agents.mjs";
|
|
29
32
|
import { globalConfigDir } from "./army.mjs";
|
|
30
33
|
import { buildNotifierApp } from "./notifier-app.mjs";
|
|
34
|
+
import { recordCopySource } from "./install-freshness.mjs";
|
|
35
|
+
import { readSettings, migrateUserConfig } from "./user-config.mjs";
|
|
31
36
|
|
|
32
37
|
export function defaultInstallDir() {
|
|
33
38
|
return process.env.NOMARMY_AGENT_INSTALL_DIR || path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".local", "share", "nomarmy-local-worker");
|
|
@@ -45,8 +50,10 @@ export function defaultInstallDir() {
|
|
|
45
50
|
// `<!-- nomarmy:... -->` marker; a same-named file WITHOUT it is the
|
|
46
51
|
// operator's own and is never overwritten, only reported as skipped.
|
|
47
52
|
// Claude Code ~/.claude/commands/feature.md -> /feature <request>
|
|
48
|
-
// Codex ~/.
|
|
49
|
-
//
|
|
53
|
+
// Codex ~/.agents/skills/nomarmy-feature/SKILL.md (where the Codex
|
|
54
|
+
// CLI, desktop app and IDE extension all look for user
|
|
55
|
+
// skills; older nomArmy put it in $CODEX_HOME/skills, which
|
|
56
|
+
// connectCodex cleans up)
|
|
50
57
|
// Cursor ~/.cursor/commands/feature.md (Cursor's documented
|
|
51
58
|
// user-level commands directory; not verified against a
|
|
52
59
|
// real install here)
|
|
@@ -63,7 +70,7 @@ export function defaultCommandDirs(env = process.env) {
|
|
|
63
70
|
const home = os.homedir();
|
|
64
71
|
return {
|
|
65
72
|
claude: env.NOMARMY_CLAUDE_COMMANDS_DIR ? path.resolve(env.NOMARMY_CLAUDE_COMMANDS_DIR) : path.join(home, ".claude", "commands"),
|
|
66
|
-
codex: env.NOMARMY_CODEX_SKILLS_DIR ? path.resolve(env.NOMARMY_CODEX_SKILLS_DIR) : path.join(
|
|
73
|
+
codex: env.NOMARMY_CODEX_SKILLS_DIR ? path.resolve(env.NOMARMY_CODEX_SKILLS_DIR) : path.join(home, ".agents", "skills"),
|
|
67
74
|
cursor: env.NOMARMY_CURSOR_COMMANDS_DIR ? path.resolve(env.NOMARMY_CURSOR_COMMANDS_DIR) : path.join(home, ".cursor", "commands"),
|
|
68
75
|
};
|
|
69
76
|
}
|
|
@@ -137,9 +144,13 @@ export function installClaudeStatusLine({ installDir, settingsPath = defaultClau
|
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
export function installMcpCopy({ nomarmyRoot, installDir, run = defaultRun }) {
|
|
147
|
+
// Settings from before they moved to ~/.config/nomarmy/ survive in the old
|
|
148
|
+
// copy's config/ until this call replaces it: rescue them first.
|
|
149
|
+
try { migrateUserConfig({ oldConfigDir: path.join(installDir, "config"), nomarmyRoot }); } catch { /* never block a connect */ }
|
|
140
150
|
fs.mkdirSync(path.join(installDir, "mcp"), { recursive: true });
|
|
141
151
|
fs.copyFileSync(path.join(nomarmyRoot, "package.json"), path.join(installDir, "package.json"));
|
|
142
152
|
fs.copyFileSync(path.join(nomarmyRoot, "mcp", "server.mjs"), path.join(installDir, "mcp", "server.mjs"));
|
|
153
|
+
recordCopySource(installDir, nomarmyRoot);
|
|
143
154
|
fs.rmSync(path.join(installDir, "lib"), { recursive: true, force: true });
|
|
144
155
|
fs.cpSync(path.join(nomarmyRoot, "lib"), path.join(installDir, "lib"), { recursive: true });
|
|
145
156
|
// lib/sandbox-images.mjs resolves docker/ relative to its own location
|
|
@@ -167,6 +178,14 @@ export function installMcpCopy({ nomarmyRoot, installDir, run = defaultRun }) {
|
|
|
167
178
|
fs.rmSync(path.join(installDir, "config"), { recursive: true, force: true });
|
|
168
179
|
fs.cpSync(configSrc, path.join(installDir, "config"), { recursive: true });
|
|
169
180
|
}
|
|
181
|
+
// lib/harnesses.mjs finds its definitions at ../harnesses/, so the copy
|
|
182
|
+
// needs them too: without this, every connected session matched no
|
|
183
|
+
// harness and built its jobs in the plain base image, silently.
|
|
184
|
+
const harnessSrc = path.join(nomarmyRoot, "harnesses");
|
|
185
|
+
if (fs.existsSync(harnessSrc)) {
|
|
186
|
+
fs.rmSync(path.join(installDir, "harnesses"), { recursive: true, force: true });
|
|
187
|
+
fs.cpSync(harnessSrc, path.join(installDir, "harnesses"), { recursive: true });
|
|
188
|
+
}
|
|
170
189
|
run("npm", ["install", "--omit=dev"], { cwd: installDir });
|
|
171
190
|
run(process.execPath, ["--check", "mcp/server.mjs"], { cwd: installDir });
|
|
172
191
|
}
|
|
@@ -234,21 +253,65 @@ function readEnvValue(filePath, key) {
|
|
|
234
253
|
* such keys yet just contributes nothing here, unchanged from before.
|
|
235
254
|
*/
|
|
236
255
|
export function deriveWorkerModelEnv(nomarmyRoot) {
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
const thinking = readEnvValue(commonPath, "NOMARMY_MODEL_THINKING");
|
|
256
|
+
// Your settings over the package defaults (lib/user-config.mjs).
|
|
257
|
+
const settings = readSettings({ nomarmyRoot });
|
|
240
258
|
const env = {};
|
|
241
|
-
if (
|
|
242
|
-
if (
|
|
259
|
+
if (settings.NOMARMY_WORKER_MODEL) env.NOMARMY_WORKER_MODEL = settings.NOMARMY_WORKER_MODEL;
|
|
260
|
+
if (settings.NOMARMY_MODEL_THINKING !== undefined) env.NOMARMY_WORKER_MODEL_THINKING = settings.NOMARMY_MODEL_THINKING;
|
|
243
261
|
for (const key of ["NOMARMY_EXECUTION", "NOMARMY_LLAMA_HOST", "NOMARMY_LLAMA_PORT"]) {
|
|
244
|
-
|
|
245
|
-
if (value !== null) env[key] = value;
|
|
262
|
+
if (settings[key] !== undefined) env[key] = settings[key];
|
|
246
263
|
}
|
|
247
264
|
return env;
|
|
248
265
|
}
|
|
249
266
|
|
|
250
267
|
const SERVER_NAME = "nomarmy-local-worker";
|
|
251
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Where a coordinator registers nomArmy. user: every project (the default).
|
|
271
|
+
* local: this repository, only you. project: this repository, committed for
|
|
272
|
+
* the team. A committed registration can't carry one machine's install path
|
|
273
|
+
* or settings, so it runs `nomarmy mcp`, which starts nomArmy with each
|
|
274
|
+
* person's own.
|
|
275
|
+
*/
|
|
276
|
+
export const SCOPES = Object.freeze(["user", "local", "project"]);
|
|
277
|
+
export const PORTABLE_SERVER = Object.freeze({ command: "nomarmy", args: ["mcp"] });
|
|
278
|
+
|
|
279
|
+
/** Keep repo-local files out of git without touching the committed .gitignore. */
|
|
280
|
+
export function excludeFromGit(projectDir, relPaths, run = defaultRun) {
|
|
281
|
+
let excludeFile;
|
|
282
|
+
try { excludeFile = String(run("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: projectDir, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" })).trim(); } catch { return []; }
|
|
283
|
+
if (!excludeFile) return [];
|
|
284
|
+
excludeFile = path.resolve(projectDir, excludeFile);
|
|
285
|
+
const existing = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, "utf8") : "";
|
|
286
|
+
const lines = new Set(existing.split("\n").map((l) => l.trim()));
|
|
287
|
+
// Git exclude patterns always use forward slashes, including on Windows.
|
|
288
|
+
const added = relPaths.map((p) => `/${toPosix(p)}`).filter((p) => !lines.has(p));
|
|
289
|
+
if (added.length) {
|
|
290
|
+
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
|
|
291
|
+
fs.appendFileSync(excludeFile, `${existing && !existing.endsWith("\n") ? "\n" : ""}# nomArmy, registered for this repository only\n${added.join("\n")}\n`);
|
|
292
|
+
}
|
|
293
|
+
return added;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Undo excludeFromGit for these paths: a project registration is meant to be committed. */
|
|
297
|
+
export function includeInGit(projectDir, relPaths, run = defaultRun) {
|
|
298
|
+
let excludeFile;
|
|
299
|
+
try { excludeFile = path.resolve(projectDir, String(run("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: projectDir, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" })).trim()); } catch { return []; }
|
|
300
|
+
if (!fs.existsSync(excludeFile)) return [];
|
|
301
|
+
// Match the same portable spelling written by excludeFromGit on Windows.
|
|
302
|
+
const drop = new Set(relPaths.map((p) => `/${toPosix(p)}`));
|
|
303
|
+
const lines = fs.readFileSync(excludeFile, "utf8").split("\n");
|
|
304
|
+
const removed = lines.filter((l) => drop.has(l.trim()));
|
|
305
|
+
if (removed.length) fs.writeFileSync(excludeFile, lines.filter((l) => !drop.has(l.trim())).join("\n"));
|
|
306
|
+
return removed.map((l) => l.trim());
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function repoScoped(scope, projectDir) {
|
|
310
|
+
if (!SCOPES.includes(scope)) throw new Error(`unknown scope "${scope}"; use one of ${SCOPES.join(", ")}`);
|
|
311
|
+
if (scope !== "user" && !projectDir) throw new Error(`--scope ${scope} registers nomArmy for one repository; run it inside that repository`);
|
|
312
|
+
return scope !== "user";
|
|
313
|
+
}
|
|
314
|
+
|
|
252
315
|
/**
|
|
253
316
|
* Every distinct auth_env name declared across config/providers.yml's
|
|
254
317
|
* pools, mapped to a placeholder value -- NEVER the real credential, which
|
|
@@ -290,7 +353,8 @@ export function derivePoolAuthEnvPlaceholders(configDir = globalConfigDir()) {
|
|
|
290
353
|
/**
|
|
291
354
|
* @param {{ nomarmyRoot: string, installDir?: string, run?: Function, extraEnv?: Record<string,string>, configDir?: string }} input
|
|
292
355
|
*/
|
|
293
|
-
export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, extraEnv = {}, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().claude, settingsPath = defaultClaudeSettingsPath() }) {
|
|
356
|
+
export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, extraEnv = {}, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().claude, settingsPath = defaultClaudeSettingsPath(), scope = "user", projectDir = null }) {
|
|
357
|
+
if (repoScoped(scope, projectDir)) return connectClaudeToRepo({ nomarmyRoot, installDir, run, extraEnv, configDir, scope, projectDir });
|
|
294
358
|
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
295
359
|
const commands = installPlaybooks({ nomarmyRoot, target: "claude", dir: commandsDir });
|
|
296
360
|
const notifier = buildNotifierApp({ nomarmyRoot });
|
|
@@ -328,21 +392,129 @@ export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), r
|
|
|
328
392
|
run("claude", ["mcp", "add", SERVER_NAME, ...envArgs, "--", "node", serverPath]);
|
|
329
393
|
}
|
|
330
394
|
run("claude", ["mcp", "get", SERVER_NAME]);
|
|
331
|
-
return { installDir, serverPath, preservedEnv: finalEnv, commands, statusLine, notifier };
|
|
395
|
+
return { installDir, serverPath, preservedEnv: finalEnv, commands, statusLine, notifier, scope: "user" };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Claude Code's local scope (this repo, only you) or project scope (.mcp.json,
|
|
399
|
+
// committed). The /feature playbook goes in the repo's .claude/commands, so it
|
|
400
|
+
// shows up only where nomArmy is registered; no status line, which is a
|
|
401
|
+
// user-wide setting.
|
|
402
|
+
function connectClaudeToRepo({ nomarmyRoot, installDir, run, extraEnv, configDir, scope, projectDir }) {
|
|
403
|
+
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
404
|
+
const commands = installPlaybooks({ nomarmyRoot, target: "claude", dir: path.join(projectDir, ".claude", "commands") });
|
|
405
|
+
const playbookPaths = commands.installed.map((f) => path.relative(projectDir, path.join(commands.dir, f)));
|
|
406
|
+
const excluded = scope === "local" ? excludeFromGit(projectDir, playbookPaths, run) : [];
|
|
407
|
+
if (scope === "project") includeInGit(projectDir, playbookPaths, run);
|
|
408
|
+
const serverPath = path.join(installDir, "mcp", "server.mjs");
|
|
409
|
+
const opts = { cwd: projectDir };
|
|
410
|
+
quietRun(run, "claude", ["mcp", "remove", SERVER_NAME, "--scope", scope], opts);
|
|
411
|
+
let env = {};
|
|
412
|
+
if (scope === "local") {
|
|
413
|
+
env = { ...derivePoolAuthEnvPlaceholders(configDir), ...extraEnv, ...deriveWorkerModelEnv(nomarmyRoot) };
|
|
414
|
+
const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
|
|
415
|
+
run("claude", ["mcp", "add", "--scope", "local", SERVER_NAME, ...envArgs, "--", "node", serverPath], opts);
|
|
416
|
+
} else {
|
|
417
|
+
run("claude", ["mcp", "add", "--scope", "project", SERVER_NAME, "--", PORTABLE_SERVER.command, ...PORTABLE_SERVER.args], opts);
|
|
418
|
+
}
|
|
419
|
+
run("claude", ["mcp", "get", SERVER_NAME], opts);
|
|
420
|
+
return { installDir, serverPath: scope === "local" ? serverPath : null, preservedEnv: env, commands, excluded, scope, projectDir,
|
|
421
|
+
configPath: scope === "project" ? path.join(projectDir, ".mcp.json") : null, userScoped: claudeUserScoped(run) };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* What `nomarmy mcp` runs: the installed copy when there is one (the code
|
|
426
|
+
* `nomarmy update` keeps current), else this package's own server, with the
|
|
427
|
+
* env connect would register (worker model, api-key placeholders) under
|
|
428
|
+
* whatever the caller already set.
|
|
429
|
+
*/
|
|
430
|
+
export function portableServerLaunch({ nomarmyRoot, installDir = defaultInstallDir(), configDir = globalConfigDir(), env = process.env }) {
|
|
431
|
+
const installed = path.join(installDir, "mcp", "server.mjs");
|
|
432
|
+
const serverPath = fs.existsSync(installed) ? installed : path.join(nomarmyRoot, "mcp", "server.mjs");
|
|
433
|
+
return { serverPath, env: { ...derivePoolAuthEnvPlaceholders(configDir), ...deriveWorkerModelEnv(nomarmyRoot), ...env } };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The on-disk server scripts registered by connect for this repository. */
|
|
437
|
+
export async function registeredMcpCopies({ projectDir = null, installDir = defaultInstallDir(), nomarmyRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), cursorConfigPath = defaultCursorConfigPath(), homeDir = os.homedir(), run }) {
|
|
438
|
+
const copies = [];
|
|
439
|
+
const add = (target, scope, command, args = []) => {
|
|
440
|
+
let serverPath = null;
|
|
441
|
+
if (command === "node" && typeof args[0] === "string") serverPath = path.resolve(args[0]);
|
|
442
|
+
else if (command === PORTABLE_SERVER.command && args[0] === PORTABLE_SERVER.args[0]) {
|
|
443
|
+
serverPath = portableServerLaunch({ nomarmyRoot, installDir }).serverPath;
|
|
444
|
+
}
|
|
445
|
+
if (serverPath && path.basename(serverPath) === "server.mjs" && path.basename(path.dirname(serverPath)) === "mcp") copies.push({ target, scope, serverPath });
|
|
446
|
+
};
|
|
447
|
+
const output = async (cmd, args, cwd) => {
|
|
448
|
+
try {
|
|
449
|
+
const result = await run(cmd, args, { cwd, timeoutMs: 5000 });
|
|
450
|
+
return result?.ok ? result.stdout : null;
|
|
451
|
+
} catch { return null; }
|
|
452
|
+
};
|
|
453
|
+
const claude = (value) => {
|
|
454
|
+
if (!value || !value.includes(`${SERVER_NAME}:`)) return;
|
|
455
|
+
const scope = /^\s*Scope:\s*(User|Local|Project)\b/im.exec(value)?.[1]?.toLowerCase();
|
|
456
|
+
const command = /^\s*Command:\s*(\S+)\s*$/im.exec(value)?.[1];
|
|
457
|
+
const args = /^\s*Args:\s*(.*?)\s*$/im.exec(value)?.[1];
|
|
458
|
+
if (scope && command) add("claude", scope, command, args ? [args] : []);
|
|
459
|
+
};
|
|
460
|
+
claude(await output("claude", ["mcp", "get", SERVER_NAME], projectDir || undefined));
|
|
461
|
+
if (projectDir) claude(await output("claude", ["mcp", "get", SERVER_NAME], homeDir));
|
|
462
|
+
const codex = await output("codex", ["mcp", "get", SERVER_NAME, "--json"]);
|
|
463
|
+
if (codex) {
|
|
464
|
+
try {
|
|
465
|
+
const transport = JSON.parse(codex).transport;
|
|
466
|
+
if (transport) add("codex", "user", transport.command, transport.args);
|
|
467
|
+
} catch { /* no registration */ }
|
|
468
|
+
}
|
|
469
|
+
for (const [scope, configPath] of [["user", cursorConfigPath], ...(projectDir ? [["local", path.join(projectDir, ".cursor", "mcp.json")]] : [])]) {
|
|
470
|
+
try {
|
|
471
|
+
const entry = readCursorConfig(configPath).mcpServers?.[SERVER_NAME];
|
|
472
|
+
if (entry) add("cursor", scope === "local" && entry.command === PORTABLE_SERVER.command ? "project" : scope, entry.command, entry.args);
|
|
473
|
+
} catch { /* unreadable registration */ }
|
|
474
|
+
}
|
|
475
|
+
return [...new Map(copies.map((copy) => [`${copy.target}:${copy.scope}:${copy.serverPath}`, copy])).values()];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Whether Claude Code also has nomArmy registered for every project. */
|
|
479
|
+
export function claudeUserScoped(run = defaultRun) {
|
|
480
|
+
try {
|
|
481
|
+
const out = String(run("claude", ["mcp", "get", SERVER_NAME], { cwd: os.homedir(), stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }));
|
|
482
|
+
return /Scope:\s*User/i.test(out);
|
|
483
|
+
} catch { return false; }
|
|
332
484
|
}
|
|
333
485
|
|
|
334
486
|
/**
|
|
335
487
|
* @param {{ nomarmyRoot: string, installDir?: string, run?: Function }} input
|
|
336
488
|
*/
|
|
337
|
-
export function connectCodex({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, skillsDir = defaultCommandDirs().codex }) {
|
|
489
|
+
export function connectCodex({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, configDir = globalConfigDir(), skillsDir = defaultCommandDirs().codex, legacySkillsDir = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "skills"), scope = "user" }) {
|
|
490
|
+
// `codex mcp add` has no scope: Codex registers MCP servers only in
|
|
491
|
+
// ~/.codex/config.toml, for every project.
|
|
492
|
+
if (scope !== "user") throw new Error(`Codex registers MCP servers only for every project (~/.codex/config.toml), so --scope ${scope} isn't available for it; connect Codex without --scope, or use Claude Code or Cursor for a per-repo registration`);
|
|
338
493
|
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
339
494
|
const commands = installPlaybooks({ nomarmyRoot, target: "codex", dir: skillsDir });
|
|
495
|
+
removeLegacyCodexSkill(legacySkillsDir, skillsDir);
|
|
340
496
|
const notifier = buildNotifierApp({ nomarmyRoot });
|
|
497
|
+
// The same environment Claude's registration gets (connectClaude): the
|
|
498
|
+
// install's execution mode and worker model always win, anything else an
|
|
499
|
+
// operator set on the old registration is kept.
|
|
500
|
+
let preservedEnv = {};
|
|
501
|
+
try { preservedEnv = JSON.parse(run("codex", ["mcp", "get", SERVER_NAME, "--json"], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }))?.transport?.env ?? {}; } catch { /* not registered yet */ }
|
|
502
|
+
const finalEnv = { ...derivePoolAuthEnvPlaceholders(configDir), ...preservedEnv, ...deriveWorkerModelEnv(nomarmyRoot) };
|
|
341
503
|
quietRun(run, "codex", ["mcp", "remove", SERVER_NAME]);
|
|
342
504
|
const serverPath = path.join(installDir, "mcp", "server.mjs");
|
|
343
|
-
|
|
505
|
+
const envArgs = Object.entries(finalEnv).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
506
|
+
run("codex", ["mcp", "add", SERVER_NAME, ...envArgs, "--", "node", serverPath]);
|
|
344
507
|
run("codex", ["mcp", "list"]);
|
|
345
|
-
return { installDir, serverPath, commands, notifier };
|
|
508
|
+
return { installDir, serverPath, preservedEnv: finalEnv, commands, notifier };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Remove the skill older nomArmy installed under $CODEX_HOME/skills (only nomArmy's own copy, by its marker). */
|
|
512
|
+
function removeLegacyCodexSkill(legacyDir, currentDir) {
|
|
513
|
+
const file = path.join(legacyDir, "nomarmy-feature", "SKILL.md");
|
|
514
|
+
if (path.resolve(legacyDir) === path.resolve(currentDir)) return;
|
|
515
|
+
try {
|
|
516
|
+
if (fs.readFileSync(file, "utf8").includes(COMMAND_MARKER)) fs.rmSync(path.dirname(file), { recursive: true, force: true });
|
|
517
|
+
} catch { /* nothing there */ }
|
|
346
518
|
}
|
|
347
519
|
|
|
348
520
|
export function defaultCursorConfigPath() {
|
|
@@ -381,7 +553,13 @@ export function readCursorConfig(configPath) {
|
|
|
381
553
|
/**
|
|
382
554
|
* @param {{ nomarmyRoot: string, installDir?: string, configPath?: string }} input
|
|
383
555
|
*/
|
|
384
|
-
export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), configPath = defaultCursorConfigPath(), run = defaultRun, commandsDir = defaultCommandDirs().cursor }) {
|
|
556
|
+
export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), configPath = defaultCursorConfigPath(), run = defaultRun, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().cursor, scope = "user", projectDir = null }) {
|
|
557
|
+
// Cursor reads a project's own .cursor/mcp.json beside the user one. local
|
|
558
|
+
// keeps it out of git; project commits it, with the portable command.
|
|
559
|
+
if (repoScoped(scope, projectDir)) {
|
|
560
|
+
configPath = path.join(projectDir, ".cursor", "mcp.json");
|
|
561
|
+
commandsDir = path.join(projectDir, ".cursor", "commands");
|
|
562
|
+
}
|
|
385
563
|
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
386
564
|
const commands = installPlaybooks({ nomarmyRoot, target: "cursor", dir: commandsDir });
|
|
387
565
|
const notifier = buildNotifierApp({ nomarmyRoot });
|
|
@@ -392,12 +570,21 @@ export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), c
|
|
|
392
570
|
// connectClaude's captureExistingEnv guards against: silently dropping an
|
|
393
571
|
// operator-set NOMARMY_WORKER_MODEL on every reinstall) -- every OTHER
|
|
394
572
|
// configured server in the file is left completely untouched.
|
|
573
|
+
// As for Claude and Codex, the install's own settings (execution mode,
|
|
574
|
+
// worker model, api-key placeholders) win over whatever the entry had.
|
|
395
575
|
const existing = config.mcpServers[SERVER_NAME];
|
|
396
|
-
|
|
397
|
-
|
|
576
|
+
// Cursor starts a global MCP server in the home folder, not the open
|
|
577
|
+
// project; ${workspaceFolder} is Cursor's own placeholder for the latter.
|
|
578
|
+
const preservedEnv = { ...derivePoolAuthEnvPlaceholders(configDir), ...((existing && typeof existing.env === "object" && existing.env) || {}), ...deriveWorkerModelEnv(nomarmyRoot), NOMARMY_PROJECT_DIR: "${workspaceFolder}" };
|
|
579
|
+
config.mcpServers[SERVER_NAME] = scope === "project"
|
|
580
|
+
? { command: PORTABLE_SERVER.command, args: [...PORTABLE_SERVER.args], env: { NOMARMY_PROJECT_DIR: "${workspaceFolder}" } }
|
|
581
|
+
: { command: "node", args: [serverPath], env: preservedEnv };
|
|
398
582
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
399
583
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
400
|
-
|
|
584
|
+
const repoPaths = scope === "user" ? [] : [path.relative(projectDir, configPath), ...commands.installed.map((f) => path.relative(projectDir, path.join(commands.dir, f)))];
|
|
585
|
+
const excluded = scope === "local" ? excludeFromGit(projectDir, repoPaths, run) : [];
|
|
586
|
+
if (scope === "project") includeInGit(projectDir, repoPaths, run);
|
|
587
|
+
return { installDir, serverPath, configPath, preservedEnv: scope === "project" ? config.mcpServers[SERVER_NAME].env : preservedEnv, commands, notifier, scope, excluded };
|
|
401
588
|
}
|
|
402
589
|
|
|
403
590
|
/** True when Cursor's config file already has a nomArmy entry -- used by
|
|
@@ -411,3 +598,29 @@ export function cursorAlreadyConnected(configPath = defaultCursorConfigPath()) {
|
|
|
411
598
|
return false;
|
|
412
599
|
}
|
|
413
600
|
}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
// Windows installs only coordinator playbooks. The engine stays in WSL.
|
|
604
|
+
export function connectViaWsl({ target, distro, launch, run = defaultRun, commandsDir = defaultCommandDirs()[target], configPath = defaultCursorConfigPath(), nomarmyRoot }) {
|
|
605
|
+
if (!["claude", "codex", "cursor"].includes(target)) throw new Error(`unknown connect target: ${target}`);
|
|
606
|
+
// Revalidate the direct launch before passing any values to a coordinator.
|
|
607
|
+
const checked = mcpBridgeLaunch({ distro, node: launch?.args?.[3], script: launch?.args?.[4] });
|
|
608
|
+
if (JSON.stringify(launch) !== JSON.stringify(checked)) throw new Error("Invalid WSL MCP bridge launch");
|
|
609
|
+
const { command, args } = checked;
|
|
610
|
+
const env = { ...checked.env, WSLENV: `${checked.env.WSLENV}:NOMARMY_COORDINATOR_OS/u`, NOMARMY_COORDINATOR_OS: "windows" };
|
|
611
|
+
if (target === "claude") {
|
|
612
|
+
quietRun(run, "claude", ["mcp", "remove", SERVER_NAME, "--scope", "user"]);
|
|
613
|
+
run("claude", ["mcp", "add", "--scope", "user", SERVER_NAME, "-e", `WSLENV=${env.WSLENV}`, "-e", `NOMARMY_COORDINATOR_OS=${env.NOMARMY_COORDINATOR_OS}`, "--", command, ...args]);
|
|
614
|
+
} else if (target === "codex") {
|
|
615
|
+
quietRun(run, "codex", ["mcp", "remove", SERVER_NAME]);
|
|
616
|
+
run("codex", ["mcp", "add", SERVER_NAME, "--env", `WSLENV=${env.WSLENV}`, "--env", `NOMARMY_COORDINATOR_OS=${env.NOMARMY_COORDINATOR_OS}`, "--", command, ...args]);
|
|
617
|
+
} else {
|
|
618
|
+
const config = readCursorConfig(configPath);
|
|
619
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers)) config.mcpServers = {};
|
|
620
|
+
config.mcpServers[SERVER_NAME] = { command, args, env: { ...env, NOMARMY_PROJECT_DIR: "${workspaceFolder}" } };
|
|
621
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
622
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
623
|
+
}
|
|
624
|
+
const commands = installPlaybooks({ nomarmyRoot, target, dir: commandsDir });
|
|
625
|
+
return { target, bridge: { distro }, commands };
|
|
626
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// continue_from: a corrective job that starts from a retained job's
|
|
2
|
+
// unfinished work, so the finished whole goes through nomArmy's checks.
|
|
3
|
+
//
|
|
4
|
+
// From a real run: a builder's code was right but came back partial (one
|
|
5
|
+
// expected value in its own test was wrong). nomArmy never commits partial
|
|
6
|
+
// work, and a new job from the branch couldn't see the uncommitted changes, so
|
|
7
|
+
// the General fixed the number by hand and committed the foundation itself,
|
|
8
|
+
// outside verification. Now the General dispatches the correction with
|
|
9
|
+
// continue_from: the new worktree starts with the old job's changes, and the
|
|
10
|
+
// new job's diff, verification and revert check cover all of it.
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { isRuntimeJunk } from "./git-record.mjs";
|
|
16
|
+
|
|
17
|
+
const JOB_ID_RE = /^[A-Za-z0-9._-]{1,120}$/;
|
|
18
|
+
|
|
19
|
+
function readJson(file) {
|
|
20
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Why `continueFrom` can't be continued, or null with the retained job's
|
|
25
|
+
* record. Checked at admission, before anything starts.
|
|
26
|
+
* @returns {{ problem: string|null, record: object|null }}
|
|
27
|
+
*/
|
|
28
|
+
export function continuationProblem({ continueFrom, mode = "implement", baseRef = null, jobsRoot, projectDir, isRunning = () => false }) {
|
|
29
|
+
const refuse = (problem) => ({ problem: `continue_from "${continueFrom}": ${problem}`, record: null });
|
|
30
|
+
if (!JOB_ID_RE.test(String(continueFrom ?? ""))) return refuse("not a job id");
|
|
31
|
+
if (mode !== "implement") return refuse("only an implement job can continue another job's work");
|
|
32
|
+
if (baseRef) return refuse("starts from that job's own base commit; leave base_ref out");
|
|
33
|
+
if (isRunning(continueFrom)) return refuse("that job is still running; wait for it to finish");
|
|
34
|
+
const jobDir = path.join(jobsRoot, continueFrom);
|
|
35
|
+
const record = readJson(path.join(jobDir, "metadata.json"));
|
|
36
|
+
if (!record) return refuse("no finished job by that id (see local_worker_jobs)");
|
|
37
|
+
if (record.mode !== "implement") return refuse(`that job is a ${record.mode} job; only implement work can be continued`);
|
|
38
|
+
if (record.projectDir && path.resolve(record.projectDir) !== path.resolve(projectDir)) return refuse("that job belongs to another repository");
|
|
39
|
+
if (record.commit?.created || record.commit?.sha) return refuse(`its work is already committed on ${record.branch}; start a new job with base_ref: "${record.branch}" instead`);
|
|
40
|
+
const worktree = record.worktree ?? path.join(jobDir, "worktree");
|
|
41
|
+
if (!fs.existsSync(worktree)) return refuse("its worktree is gone (cleaned up), so there's no unfinished work to continue");
|
|
42
|
+
if (!record.git?.baseSha && !record.gitBeforeCoordinatorCommit?.baseSha && !record.baseSha) return refuse("its record has no base commit");
|
|
43
|
+
return { problem: null, record: { ...record, worktree } };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The retained job's base commit, however its record spells it. */
|
|
47
|
+
export function continuationBase(record) {
|
|
48
|
+
return record.git?.baseSha ?? record.gitBeforeCoordinatorCommit?.baseSha ?? record.baseSha;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Snapshot the retained worktree's changes against its base as a dangling
|
|
53
|
+
* commit, without touching that worktree or its index: a temporary index
|
|
54
|
+
* starts from the base, takes every change (new files and deletions too),
|
|
55
|
+
* and drops runtime junk. Worktrees share one object store, so the new
|
|
56
|
+
* worktree can check the commit out.
|
|
57
|
+
* @returns {Promise<{ commit: string, files: string[] }>}
|
|
58
|
+
*/
|
|
59
|
+
export async function snapshotRetainedWork({ worktree, baseSha, jobId, git }) {
|
|
60
|
+
const indexFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "nomarmy-continue-")), "index");
|
|
61
|
+
const env = { ...process.env, GIT_INDEX_FILE: indexFile };
|
|
62
|
+
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);
|
|
66
|
+
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();
|
|
70
|
+
return { commit, files: staged.filter((f) => !junk.includes(f)) };
|
|
71
|
+
} finally {
|
|
72
|
+
fs.rmSync(path.dirname(indexFile), { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Lay a snapshot into a fresh worktree at the same base as uncommitted
|
|
78
|
+
* changes: the files from the snapshot, deletions removed, index back at the
|
|
79
|
+
* base. The new job's git record then counts them as its own diff.
|
|
80
|
+
*/
|
|
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 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The brief's note, so the worker builds on the work instead of redoing it. */
|
|
89
|
+
export function continuationNote({ continueFrom, record, files }) {
|
|
90
|
+
const listed = files.slice(0, 20).join(", ") + (files.length > 20 ? `, and ${files.length - 20} more` : "");
|
|
91
|
+
// What stopped the work, for the worker: its own NOT DONE, a failed
|
|
92
|
+
// verification, a timeout. Not the notes addressed to the General (review
|
|
93
|
+
// flags, "accept only after an independent review"): a worker handed those
|
|
94
|
+
// took the pending review as its own unfinished task and reported partial.
|
|
95
|
+
const reasons = [];
|
|
96
|
+
const notDone = record.reportValidation?.notDone;
|
|
97
|
+
if (notDone && !/^none\.?$/i.test(String(notDone).trim())) reasons.push(`its worker's NOT DONE: ${notDone}`);
|
|
98
|
+
if (record.independentVerification?.status === "fail") reasons.push(`its verification failed: ${record.independentVerification.detail ?? record.independentVerification.reason ?? "see the job record"}`);
|
|
99
|
+
if (record.outcome === "WORKER_TIMEOUT") reasons.push("it ran out of time");
|
|
100
|
+
if (!reasons.length && record.outcome) reasons.push(`it ended as ${record.outcome}`);
|
|
101
|
+
const why = reasons.map((r) => `- ${String(r).slice(0, 300)}`).join("\n");
|
|
102
|
+
return `This worktree already holds the unfinished work of job ${continueFrom} (${files.length} file(s): ${listed}). Build on it; don't redo it. Your finished diff is verified as a whole, that work included.${why ? `\nThat job stopped because:\n${why}` : ""}`;
|
|
103
|
+
}
|
|
@@ -11,13 +11,17 @@ export const COORDINATOR_INSTRUCTIONS = `nomArmy runs bounded engineering jobs (
|
|
|
11
11
|
Before dispatching:
|
|
12
12
|
- Answer where-is / who-calls / grep questions with repo_evidence (deterministic, [path:line] on every hit). Use mode: scout only for read-only research that would otherwise pull many files into your own context.
|
|
13
13
|
- Call army to see this repo's roles and which agent each runs on; dispatch by army_role when a role fits. A job on a subscription agent needs on_behalf_of set to that agent's owner.
|
|
14
|
+
- Agents' usage limits show in army and local_worker_capacity; a job on an agent at its limit is held. Ask the operator before resubmitting with confirm_over_limit: true, or move the job to another agent. Never set it on your own.
|
|
14
15
|
- A Claude subscription agent (claude-cli) runs its tools on this machine, outside the sandbox: use it for scout and review work. nomArmy refuses implement jobs on it unless the operator set allow_host_tools; send build work to a sandboxed agent.
|
|
16
|
+
- To run tests without changing anything, use mode: verify; it costs no model usage.
|
|
17
|
+
- Mark an implement job stakes: high when a mistake would be costly (security, access control, personal or tenant data, data loss, money, irreversible changes), however small it is. It then needs a verification profile, keeps the revert check, and needs an independent review before you accept it: a scout on another vendor with reviews: <job id>.
|
|
15
18
|
- Brief outcomes, not edits: a task, explicit acceptance criteria, and the tests that prove it. Put facts you've already resolved in evidence.
|
|
16
|
-
- Prefer local_worker_start
|
|
19
|
+
- Prefer local_worker_start for anything longer than a few minutes. Right after, if you can run a background command, run \`nomarmy jobs --wait <job_id>\` in the background so you're told the moment it finishes and can tell the operator (for several jobs, \`nomarmy jobs --wait <id> <id> ...\`; for a whole run, \`nomarmy jobs --events --until-done --run <run-id>\`); otherwise poll local_worker_status with wait_seconds. To read a finished job, use local_worker_status with report: true (the report, a scout's cited findings, the outcome, issues and commit); full: true is the whole record and rarely needed. Review scouts (reviews set, or a review-phase role) default to 20 minutes, other jobs to 10; pass timeout_seconds for more. Never use unscoped \`--until-done\` when other sessions may have jobs. Never run the plain \`nomarmy jobs --events\` stream as a background command: it only reports when it exits, so you'd never hear; it's for a monitor that reads each line. For a whole feature, use /feature (run_start keeps a run's jobs, spend and hours bounded).
|
|
17
20
|
|
|
18
21
|
Trust boundary:
|
|
19
22
|
- A worker's four-line report is a claim; nomArmy's verified git record and independent verification are the evidence. A job isn't complete if its report is missing or malformed, its STATUS is partial or blocked, STATUS done lacks VERIFICATION pass, or its changes aren't committed by nomArmy.
|
|
20
23
|
- Read the diff of anything material before integrating it. nomArmy commits on the worker's branch and never merges into yours: integration, conflicts and pushes are yours.
|
|
24
|
+
- A job on the wrong track can be stopped with local_worker_stop; it keeps its worktree. To finish a job that came back partial, blocked, failing verification or stopped, dispatch the correction with continue_from: <that job id> (a cheaper model is fine). The new job starts with its unfinished work in place and verifies the whole. Don't fix and commit a worker's files yourself: that lands them unverified. If you must, commit them on a branch, run mode: verify on it before building on it, and say so in your report.
|
|
21
25
|
- Failed and incomplete worktrees are kept for review; clean up with local_worker_cleanup or local_worker_sweep once you've decided.
|
|
22
26
|
|
|
23
27
|
Never delegate deployments, production access, cloud or SSH credentials, secrets, Terraform state or kubectl contexts to a worker.`;
|
package/lib/diff-checks.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
1
3
|
// ---------------------------------------------------------------------------
|
|
2
4
|
// Test-change classification (plan 16). One tunable constant, on purpose:
|
|
3
5
|
// every heuristic about what counts as a test file lives here and nowhere else.
|
|
@@ -27,6 +29,24 @@ export function isDocumentationPath(file) {
|
|
|
27
29
|
return DOC_EXTENSIONS.has(ext) || DOC_BASENAMES.has(dot > 0 ? base.slice(0, dot) : base);
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
export function isCiConfigPath(file) {
|
|
33
|
+
const normalized = String(file ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
34
|
+
return /^\.github\/(workflows|actions)\//.test(normalized)
|
|
35
|
+
|| normalized === ".github/dependabot.yml"
|
|
36
|
+
|| normalized === ".gitlab-ci.yml"
|
|
37
|
+
|| /^\.circleci\//.test(normalized)
|
|
38
|
+
|| normalized === "azure-pipelines.yml"
|
|
39
|
+
|| /^\.buildkite\//.test(normalized)
|
|
40
|
+
|| normalized === "Jenkinsfile"
|
|
41
|
+
|| normalized === "bitbucket-pipelines.yml";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function planRegressionProductionFiles(files) {
|
|
45
|
+
// Local repository tests cannot exercise CI services, so reverting CI
|
|
46
|
+
// configuration cannot provide useful regression evidence.
|
|
47
|
+
return (files ?? []).filter((file) => !isDocumentationPath(file) && !isCiConfigPath(file));
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
export function isTestPath(file) {
|
|
31
51
|
const normalized = String(file ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
32
52
|
if (!normalized) return false;
|
|
@@ -109,6 +129,12 @@ const TEST_SELECTION_FLAG_PATTERNS = Object.freeze([
|
|
|
109
129
|
export function detectScopedTestSelectionRisk({ commands = [], testChanges = null } = {}) {
|
|
110
130
|
const touchedTestFiles = [...(testChanges?.new_tests_added ?? []), ...(testChanges?.existing_tests_modified ?? [])];
|
|
111
131
|
if (touchedTestFiles.length === 0) return null;
|
|
132
|
+
// A command that runs the changed test files by name, with no filter of its
|
|
133
|
+
// own, runs exactly the tests this diff touched: another command's -k can't
|
|
134
|
+
// exclude them. Senti's python profile does this and the warning fired on
|
|
135
|
+
// every job, which teaches people to ignore it.
|
|
136
|
+
const runsChangedTestsByName = commands.some((c) => /\$\{?NOMARMY_CHANGED_TEST_FILES\b/.test(String(c ?? "")) && !TEST_SELECTION_FLAG_PATTERNS.some((p) => p.re.test(String(c))));
|
|
137
|
+
if (runsChangedTestsByName) return null;
|
|
112
138
|
const flagged = [];
|
|
113
139
|
for (const command of commands) {
|
|
114
140
|
const match = TEST_SELECTION_FLAG_PATTERNS.find((p) => p.re.test(String(command ?? "")));
|
|
@@ -416,3 +442,91 @@ export function mergeUntrackedIntoNameStatus(nameStatus, untrackedFiles) {
|
|
|
416
442
|
return [...(nameStatus ?? []), ...extra];
|
|
417
443
|
}
|
|
418
444
|
|
|
445
|
+
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// Verification inputs: .nomarmy.yml is read from the operator's checkout so a
|
|
448
|
+
// worker can't weaken its own checks, but the files its commands run come
|
|
449
|
+
// from the worker's worktree. Found live: a worker whose verification ran
|
|
450
|
+
// `node check.js` rewrote check.js to print PASS and exit 0, and the job
|
|
451
|
+
// committed as done. The revert check can't see it: reverting check.js along
|
|
452
|
+
// with the code makes verification fail, which reads as coverage.
|
|
453
|
+
//
|
|
454
|
+
// Blocking (the diff changes what a command runs): a changed non-test file a
|
|
455
|
+
// command names (`node check.js`, `bash scripts/verify.sh`), a changed
|
|
456
|
+
// Makefile or justfile under `make`/`just`, or a changed package.json script
|
|
457
|
+
// that `npm test`/`pnpm run x`/`yarn x`/`bun run x` calls. Test files named by
|
|
458
|
+
// a command are left to the test-change review, since adding cases is normal.
|
|
459
|
+
// Flagged only: test-runner configuration, which is often a legitimate edit.
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
const RUNNER_CONFIG_RE = /(^|\/)(conftest\.py|pytest\.ini|tox\.ini|setup\.cfg|\.coveragerc|(jest|vitest|vite|playwright|karma|cypress)\.config\.[cm]?[jt]s|\.mocharc(\.[a-z]+)?|phpunit\.xml(\.dist)?|\.rspec)$/;
|
|
463
|
+
const TASK_FILES = { make: ["Makefile", "makefile", "GNUmakefile"], just: ["justfile", "Justfile", ".justfile"] };
|
|
464
|
+
|
|
465
|
+
function shellWords(segment) {
|
|
466
|
+
return (segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^["']|["']$/g, ""));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Which package.json script a package-manager command runs, if any.
|
|
470
|
+
function packageScript(words) {
|
|
471
|
+
const [tool, first, second] = words;
|
|
472
|
+
if (!["npm", "pnpm", "yarn", "bun"].includes(tool) || !first) return null;
|
|
473
|
+
if (["run", "run-script"].includes(first)) return second && !second.startsWith("-") ? second : null;
|
|
474
|
+
if (tool === "npm") return ["test", "t", "tst"].includes(first) ? "test" : ["start", "stop", "restart"].includes(first) ? first : null;
|
|
475
|
+
if (tool === "bun") return null; // `bun test` is bun's own runner, not a script
|
|
476
|
+
return first.startsWith("-") || ["install", "add", "remove", "exec", "dlx", "x"].includes(first) ? null : first;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function scriptsOf(text) {
|
|
480
|
+
try { return JSON.parse(text ?? "")?.scripts ?? {}; } catch { return {}; }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function pytestSection(text) {
|
|
484
|
+
const match = /^\[tool\.pytest[^\]]*\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m.exec(String(text ?? ""));
|
|
485
|
+
return match ? match[1].trim() : null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* @param {{ commands: string[], changedFiles: string[], readBase: (file: string) => string|null|Promise<string|null>,
|
|
490
|
+
* readHead: (file: string) => string|null|Promise<string|null>, isTestPathFn?: (file: string) => boolean }} input
|
|
491
|
+
* @returns {Promise<{ blocked: {file: string, command: string, why: string}[], flagged: {file: string, why: string}[] } | null>}
|
|
492
|
+
*/
|
|
493
|
+
export async function detectVerificationInputChanges({ commands = [], changedFiles = [], readBase = () => null, readHead = () => null, isTestPathFn = isTestPath } = {}) {
|
|
494
|
+
const changed = new Set(changedFiles.map((f) => path.posix.normalize(f)));
|
|
495
|
+
if (!changed.size) return null;
|
|
496
|
+
const blocked = [], flagged = [], seen = new Set();
|
|
497
|
+
const block = (file, command, why) => { if (!seen.has(file)) { seen.add(file); blocked.push({ file, command, why }); } };
|
|
498
|
+
for (const command of commands) {
|
|
499
|
+
let dir = "";
|
|
500
|
+
for (const segment of String(command ?? "").split(/\s*(?:&&|\|\||;|\|)\s*/)) {
|
|
501
|
+
const all = shellWords(segment);
|
|
502
|
+
let lead = 0;
|
|
503
|
+
while (lead < all.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(all[lead])) lead++; // FOO=1 cmd ...
|
|
504
|
+
const words = all.slice(lead);
|
|
505
|
+
if (!words.length) continue;
|
|
506
|
+
const at = (p) => path.posix.normalize(path.posix.join(dir, p));
|
|
507
|
+
if (words[0] === "cd" && words[1]) { dir = at(words[1]); continue; }
|
|
508
|
+
for (const word of words) {
|
|
509
|
+
if (word.startsWith("-") || word.includes("$")) continue;
|
|
510
|
+
const file = at(word);
|
|
511
|
+
if (changed.has(file) && !isTestPathFn(file)) block(file, command, `run by \`${command}\``);
|
|
512
|
+
}
|
|
513
|
+
for (const name of TASK_FILES[words[0]] ?? []) {
|
|
514
|
+
const file = at(name);
|
|
515
|
+
if (changed.has(file)) block(file, command, `read by \`${command}\``);
|
|
516
|
+
}
|
|
517
|
+
const script = packageScript(words);
|
|
518
|
+
const manifest = at("package.json");
|
|
519
|
+
if (script && changed.has(manifest)) {
|
|
520
|
+
const before = scriptsOf(await readBase(manifest)), after = scriptsOf(await readHead(manifest));
|
|
521
|
+
const touched = [script, `pre${script}`, `post${script}`].filter((s) => before[s] !== after[s]);
|
|
522
|
+
if (touched.length) block(manifest, command, `its script${touched.length > 1 ? "s" : ""} ${touched.map((s) => `"${s}"`).join(", ")} changed, and \`${command}\` runs it`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const file of changed) {
|
|
527
|
+
if (seen.has(file)) continue;
|
|
528
|
+
if (RUNNER_CONFIG_RE.test(file)) flagged.push({ file, why: "test-runner configuration" });
|
|
529
|
+
else if (/(^|\/)pyproject\.toml$/.test(file) && pytestSection(await readBase(file)) !== pytestSection(await readHead(file))) flagged.push({ file, why: "its [tool.pytest] settings changed" });
|
|
530
|
+
}
|
|
531
|
+
return blocked.length || flagged.length ? { blocked, flagged } : null;
|
|
532
|
+
}
|