nomarmy 0.1.0-alpha.10 → 0.1.0-alpha.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nomarmy.mjs +200 -12
- package/lib/admission.mjs +14 -1
- package/lib/connect.mjs +111 -6
- package/lib/execute.mjs +103 -6
- package/lib/jev-checks.mjs +110 -0
- package/lib/judge.mjs +130 -0
- package/lib/mutation.mjs +159 -0
- package/lib/schema.mjs +10 -0
- package/lib/scout.mjs +2 -1
- package/lib/stats.mjs +213 -0
- package/lib/validators.mjs +179 -0
- package/mcp/server.mjs +21 -0
- package/package.json +1 -1
package/bin/nomarmy.mjs
CHANGED
|
@@ -18,8 +18,11 @@ import { buildConfigProposal } from "../lib/propose.mjs";
|
|
|
18
18
|
import { detectHardware } from "../lib/hardware.mjs";
|
|
19
19
|
import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf.mjs";
|
|
20
20
|
import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
|
|
21
|
-
import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir } from "../lib/connect.mjs";
|
|
21
|
+
import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir, installMcpCopy, SCOPES, claudeUserScoped, portableServerLaunch } from "../lib/connect.mjs";
|
|
22
22
|
import { compareVersions, readPackageVersion, readInstallVersions, copyIsStale } from "../lib/install-freshness.mjs";
|
|
23
|
+
import { loadJobRecords, computeStats, formatStats, parseSince, resolveRepo } from "../lib/stats.mjs";
|
|
24
|
+
import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings } from "../lib/validators.mjs";
|
|
25
|
+
import { probeModel } from "../lib/model-probe.mjs";
|
|
23
26
|
import { ID_RE, AUTH_ENV_NAME_RE, OPENCLAW_PROVIDER_ID_RE, openclawProviderId, isNativeProviderType } from "../lib/dispatch-schema.mjs";
|
|
24
27
|
import { loadAgents, readAgentsFile, writeAgentsFile, agentsConfigPath, apiAgentAsPoolEntry, describeAgent as describeAgentLabel, agentRunsToolsOnHost, agentProviderId, AGENT_KINDS, API_PROVIDER_TYPES, RESERVED_AGENT_NAMES, BUILTIN_LOCAL_AGENT } from "../lib/agents.mjs";
|
|
25
28
|
import { loadArmy, mergeArmy, describeArmy, readArmyFile, updateArmyInFile, assignRoleInFile, parseTargetSpec, armyLayerPath, globalConfigDir, DEFAULT_ARMY, ARMY_PHASES, LOCAL_CONFIG_FILENAME } from "../lib/army.mjs";
|
|
@@ -40,10 +43,10 @@ import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalo
|
|
|
40
43
|
// "already connected, so resync" detection if it has no CLI to probe.
|
|
41
44
|
const KNOWN_TARGETS = ["claude", "codex", "cursor"];
|
|
42
45
|
|
|
43
|
-
function connectTarget(target, { nomarmyRoot, run }) {
|
|
44
|
-
if (target === "claude") return connectClaude({ nomarmyRoot, run });
|
|
45
|
-
if (target === "codex") return connectCodex({ nomarmyRoot, run });
|
|
46
|
-
if (target === "cursor") return connectCursor({ nomarmyRoot, run });
|
|
46
|
+
function connectTarget(target, { nomarmyRoot, run, scope = "user", projectDir = null }) {
|
|
47
|
+
if (target === "claude") return connectClaude({ nomarmyRoot, run, scope, projectDir });
|
|
48
|
+
if (target === "codex") return connectCodex({ nomarmyRoot, run, scope });
|
|
49
|
+
if (target === "cursor") return connectCursor({ nomarmyRoot, run, scope, projectDir });
|
|
47
50
|
throw new Error(`unknown connect target: ${target}`);
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -214,10 +217,34 @@ Usage: nomarmy <command> [options]
|
|
|
214
217
|
statusline the one-line summary Claude Code's status line shows
|
|
215
218
|
(installed by \`nomarmy connect claude\` when no status
|
|
216
219
|
line is set); reads the session JSON on stdin
|
|
217
|
-
connect [claude] [codex] [cursor]
|
|
220
|
+
connect [claude] [codex] [cursor] [--scope user|local|project]
|
|
218
221
|
(Re-)register the MCP server with one or more coordinators.
|
|
219
222
|
With no target and not --json, prompts an interactive
|
|
220
|
-
multi-select instead.
|
|
223
|
+
multi-select instead. --scope user (default) registers it
|
|
224
|
+
for every project; local for this repository, only you;
|
|
225
|
+
project for this repository, committed for the team
|
|
226
|
+
(.mcp.json or .cursor/mcp.json, running \`nomarmy mcp\`).
|
|
227
|
+
Codex has only the user scope.
|
|
228
|
+
stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>]
|
|
229
|
+
[--repo <path|name>] [--all-repos] [--json]
|
|
230
|
+
What nomArmy's job records show for this repository (or
|
|
231
|
+
all): volume by role and model, code committed, time,
|
|
232
|
+
tokens and spend, how often a "done" report failed
|
|
233
|
+
independent verification, what didn't finish, reviewers,
|
|
234
|
+
and review flags. From verified records, never reports.
|
|
235
|
+
validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>
|
|
236
|
+
Optional semantic checks from a model you configure with
|
|
237
|
+
your own key. Today: Jev (TypeSafe). \`add jev\` asks for the
|
|
238
|
+
key without echoing it (or reads --key-stdin), saves it
|
|
239
|
+
where only you can read it, and makes one test call. Its
|
|
240
|
+
answers only add review flags, and it sends excerpts of
|
|
241
|
+
your code to TypeSafe. \`add judge --agent <name> --model
|
|
242
|
+
<model>\` makes one of your agents a model judge: does the
|
|
243
|
+
diff meet each acceptance criterion, match the report, keep
|
|
244
|
+
its tests as strong? An agent whose tools run on this
|
|
245
|
+
machine needs --host-tools.
|
|
246
|
+
mcp Start nomArmy's MCP server on stdio with this machine's
|
|
247
|
+
settings. What a --scope project registration runs.
|
|
221
248
|
sandbox The Podman VM every sandbox shares (macOS, Windows): its
|
|
222
249
|
memory, disk and images. --memory <GiB> resizes it (stops,
|
|
223
250
|
sets, restarts; refused while jobs run); --prune removes
|
|
@@ -1709,6 +1736,13 @@ async function cmdUpdate() {
|
|
|
1709
1736
|
// this process loaded. Returns the targets reconnected.
|
|
1710
1737
|
function reconnectCoordinators() {
|
|
1711
1738
|
const targets = connectedTargets();
|
|
1739
|
+
// Per-repo registrations run the installed copy (or `nomarmy mcp`), so a
|
|
1740
|
+
// refreshed copy is all they need; re-registering them at user scope would
|
|
1741
|
+
// add nomArmy to every project.
|
|
1742
|
+
if (!targets.length) {
|
|
1743
|
+
execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", "--copy-only", ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
|
|
1744
|
+
return [];
|
|
1745
|
+
}
|
|
1712
1746
|
if (targets.length) {
|
|
1713
1747
|
if (!json) console.log(`\nReconnecting ${targets.join(", ")}...`);
|
|
1714
1748
|
execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", ...targets, ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
|
|
@@ -1717,7 +1751,7 @@ function reconnectCoordinators() {
|
|
|
1717
1751
|
}
|
|
1718
1752
|
|
|
1719
1753
|
function connectedTargets() {
|
|
1720
|
-
return [commandExists("claude") && "claude", commandExists("codex") && "codex", cursorAlreadyConnected() && "cursor"].filter(Boolean);
|
|
1754
|
+
return [commandExists("claude") && claudeUserScoped() && "claude", commandExists("codex") && "codex", cursorAlreadyConnected() && "cursor"].filter(Boolean);
|
|
1721
1755
|
}
|
|
1722
1756
|
|
|
1723
1757
|
async function updateFromNpm() {
|
|
@@ -1788,7 +1822,20 @@ async function cmdConnect() {
|
|
|
1788
1822
|
// scan the whole argv), and `nomarmy connect --json claude` once broke
|
|
1789
1823
|
// that promise by reading argv[1] directly -- --json landed in target's
|
|
1790
1824
|
// slot instead. Multiple bare tokens are now allowed too, for multi-select.
|
|
1791
|
-
const
|
|
1825
|
+
const flagValues = new Set(["--scope", "--repo"].map((name) => argv.indexOf(name)).filter((i) => i >= 0).map((i) => i + 1));
|
|
1826
|
+
const requested = argv.slice(1).filter((a, i) => !a.startsWith("--") && !flagValues.has(i + 1));
|
|
1827
|
+
const scope = value("scope", "user");
|
|
1828
|
+
if (!SCOPES.includes(scope)) throw new Error(`--scope must be one of ${SCOPES.join(", ")}, got "${scope}".`);
|
|
1829
|
+
let projectDir = null;
|
|
1830
|
+
if (scope !== "user") {
|
|
1831
|
+
try { projectDir = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
1832
|
+
catch { throw new Error(`--scope ${scope} registers nomArmy for one repository, and ${repoDir} isn't inside a git repository. Run it from the repository (or pass --repo <dir>).`); }
|
|
1833
|
+
}
|
|
1834
|
+
if (flag("copy-only")) {
|
|
1835
|
+
// `nomarmy update` with only per-repo registrations: refresh the copy they run.
|
|
1836
|
+
installMcpCopy({ nomarmyRoot, installDir: defaultInstallDir(), run: (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: json ? "ignore" : "inherit", ...opts }) });
|
|
1837
|
+
return json ? out({ refreshed: defaultInstallDir() }) : console.log(c.green(`✓ Refreshed the nomArmy copy in ${defaultInstallDir()}.`));
|
|
1838
|
+
}
|
|
1792
1839
|
let targets;
|
|
1793
1840
|
if (requested.length > 0) {
|
|
1794
1841
|
const unknown = requested.filter((t) => !KNOWN_TARGETS.includes(t));
|
|
@@ -1813,9 +1860,12 @@ async function cmdConnect() {
|
|
|
1813
1860
|
}
|
|
1814
1861
|
try {
|
|
1815
1862
|
if (!json) console.log(c.bold(`\n🍪 Connecting nomArmy to ${target}...`));
|
|
1816
|
-
const result = connectTarget(target, { nomarmyRoot, run });
|
|
1863
|
+
const result = connectTarget(target, { nomarmyRoot, run, scope, projectDir });
|
|
1817
1864
|
results.push({ target, connected: true, ...result });
|
|
1818
|
-
if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}.`));
|
|
1865
|
+
if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}${scope === "user" ? "." : scope === "local" ? ` for ${projectDir} only (not committed).` : ` in ${path.relative(projectDir, result.configPath ?? path.join(projectDir, ".mcp.json"))}, for everyone who clones this repository.`}`));
|
|
1866
|
+
if (!json && scope === "local" && result?.excluded?.length) console.log(c.dim(` Kept out of git (.git/info/exclude): ${result.excluded.join(", ")}`));
|
|
1867
|
+
if (!json && scope === "project") console.log(c.dim(` Commit it along with the playbook in ${path.relative(projectDir, result.commands.dir)}. Each teammate needs nomArmy installed and set up (npm install -g nomarmy@alpha, then nomarmy setup); the registration runs \`nomarmy mcp\`, which uses their own settings.`));
|
|
1868
|
+
if (!json && scope !== "user" && target === "claude" && result?.userScoped) console.log(c.yellow(` nomArmy is also registered for all your projects (user scope). To use it only where you register it per repository: claude mcp remove nomarmy-local-worker -s user`));
|
|
1819
1869
|
if (!json && result?.commands?.installed?.length) console.log(c.green(`✓ Playbooks: ${result.commands.installed.join(", ")}`) + c.dim(` in ${result.commands.dir} (restart ${target} to pick up a new one)`));
|
|
1820
1870
|
if (!json && result?.notifier?.status === "built") console.log(c.green("✓ Notifications: nomArmy.app, with nomArmy's icon") + c.dim(" (macOS asks once whether to allow it)"));
|
|
1821
1871
|
if (!json && result?.notifier?.status === "failed") console.log(c.yellow(`⚠ Couldn't build nomArmy.app (${result.notifier.reason}); notifications still work, with Script Editor's icon. Xcode's command-line tools provide swiftc: xcode-select --install`));
|
|
@@ -2617,7 +2667,145 @@ async function cmdStatusline() {
|
|
|
2617
2667
|
process.stdout.write(`${statusLineText({ session })}\n`);
|
|
2618
2668
|
}
|
|
2619
2669
|
|
|
2620
|
-
|
|
2670
|
+
function cmdStats() {
|
|
2671
|
+
const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
|
|
2672
|
+
const records = loadJobRecords(path.join(stateRoot, "jobs"));
|
|
2673
|
+
let repo = null;
|
|
2674
|
+
if (value("repo")) repo = resolveRepo(records, value("repo"));
|
|
2675
|
+
else if (!flag("all-repos")) {
|
|
2676
|
+
try { repo = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
2677
|
+
catch { throw new Error(`${repoDir} isn't inside a git repository; run nomarmy stats from one, or pass --repo <name> or --all-repos`); }
|
|
2678
|
+
}
|
|
2679
|
+
const stats = computeStats(records, { repo, sinceMs: parseSince(value("since")), untilMs: parseSince(value("until")), role: value("role"), model: value("model") });
|
|
2680
|
+
if (json) return out(stats);
|
|
2681
|
+
console.log(formatStats(stats));
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
// Read one line without echoing it: stty -echo around the read, restored
|
|
2685
|
+
// even if the read fails. Windows has no stty, so it says the input shows.
|
|
2686
|
+
async function readHiddenLine(prompt) {
|
|
2687
|
+
const hide = process.stdin.isTTY && process.platform !== "win32";
|
|
2688
|
+
if (!hide) console.log(c.yellow("(your input will be visible as you type)"));
|
|
2689
|
+
const rl = createInterface({ input, output });
|
|
2690
|
+
try {
|
|
2691
|
+
if (hide) spawnSync("stty", ["-echo"], { stdio: ["inherit", "ignore", "ignore"] });
|
|
2692
|
+
return (await rl.question(prompt)).trim();
|
|
2693
|
+
} finally {
|
|
2694
|
+
if (hide) { spawnSync("stty", ["echo"], { stdio: ["inherit", "ignore", "ignore"] }); process.stdout.write("\n"); }
|
|
2695
|
+
rl.close();
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
async function readStdin() {
|
|
2700
|
+
const chunks = [];
|
|
2701
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
2702
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
// One tiny System One request, to prove the key and the route work.
|
|
2706
|
+
async function testJev(settings) {
|
|
2707
|
+
const { answers } = await askJev({ key: settings.key, model: settings.model, state: { text: "The build finished and all 12 tests passed." },
|
|
2708
|
+
questions: { passed: { type: "noul", instructions: "Does the text say the tests passed?", criteria: { true: "It says the tests passed", false: "It doesn't" } } } });
|
|
2709
|
+
return typeof answers.passed?.noul === "number";
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
async function cmdValidators() {
|
|
2713
|
+
const [sub = "list", name] = argv.slice(1).filter((a) => !a.startsWith("--"));
|
|
2714
|
+
if (sub === "list") {
|
|
2715
|
+
let config = {};
|
|
2716
|
+
try { config = loadValidators(); } catch (error) { if (json) return out({ error: error.message }); console.log(c.red(error.message)); process.exitCode = 1; return; }
|
|
2717
|
+
const judge = config.judge ? { enabled: config.judge.enabled, agent: config.judge.agent, model: config.judge.model, checks: config.judge.checks, hostTools: config.judge.host_tools } : null;
|
|
2718
|
+
const jev = config.jev ? { enabled: config.jev.enabled, checks: config.jev.checks, model: config.jev.model, key: config.jev.key_env ? `env ${config.jev.key_env}` : config.jev.key_file, keyReadable: Boolean(jevSettings()) } : null;
|
|
2719
|
+
if (json) return out({ path: validatorsPath(), jev, judge });
|
|
2720
|
+
if (!jev && !judge) { console.log("No validators configured. Add one with: nomarmy validators add jev, or nomarmy validators add judge --agent <name> --model <model>"); return; }
|
|
2721
|
+
if (jev) console.log(`Jev: ${jev.enabled ? c.green("on") : "off"} (${jev.model}); checks: ${jev.checks.join(", ")}; key: ${jev.key}${jev.keyReadable ? "" : c.red(" (not readable)")}`);
|
|
2722
|
+
if (judge) console.log(`Judge: ${judge.enabled ? c.green("on") : "off"} (${judge.agent}/${judge.model}); checks: ${judge.checks.join(", ")}${judge.hostTools ? c.yellow("; host tools allowed") : ""}`);
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
if (name === "judge") return cmdValidatorsJudge(sub);
|
|
2726
|
+
if (name !== "jev") throw new Error("Usage: nomarmy validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>");
|
|
2727
|
+
if (sub === "add") {
|
|
2728
|
+
if (!json) {
|
|
2729
|
+
console.log(c.bold("🍪 Jev (TypeSafe) for nomArmy's semantic checks\n"));
|
|
2730
|
+
console.log("It checks that a scout's cited lines support its finding, and that a worker's report matches its diff.");
|
|
2731
|
+
console.log("Its answers only add review flags; they never pass a check or allow a commit.");
|
|
2732
|
+
console.log(c.yellow("It sends excerpts of your code (findings, cited lines, diffs, worker reports) to TypeSafe.\n"));
|
|
2733
|
+
}
|
|
2734
|
+
const key = flag("key-stdin") ? await readStdin() : await readHiddenLine("TypeSafe API key (not shown): ");
|
|
2735
|
+
const saved = saveJevKey(key);
|
|
2736
|
+
let ok = false, why = null;
|
|
2737
|
+
try { ok = await testJev(jevSettings()); } catch (error) { why = error.message; }
|
|
2738
|
+
if (json) return out({ saved: true, keyFile: saved.keyFile, configPath: saved.configPath, test: ok ? "pass" : "fail", reason: why });
|
|
2739
|
+
console.log(c.green(`✓ Saved the key to ${saved.keyFile} (readable only by you) and turned Jev on in ${saved.configPath}.`));
|
|
2740
|
+
console.log(ok ? c.green("✓ Test call answered. New jobs use it; restart open coordinator sessions to pick it up.") : c.red(`✗ Test call failed: ${why ?? "no answer"}. Check the key, then: nomarmy validators test jev`));
|
|
2741
|
+
if (!ok) process.exitCode = 1;
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
if (sub === "test") {
|
|
2745
|
+
const settings = jevSettings();
|
|
2746
|
+
if (!settings) throw new Error("Jev isn't configured, or its key isn't readable. Add it with: nomarmy validators add jev");
|
|
2747
|
+
let ok = false, why = null;
|
|
2748
|
+
try { ok = await testJev(settings); } catch (error) { why = error.message; }
|
|
2749
|
+
if (json) return out({ test: ok ? "pass" : "fail", reason: why });
|
|
2750
|
+
console.log(ok ? c.green("✓ Jev answered.") : c.red(`✗ Jev test call failed: ${why ?? "no answer"}`));
|
|
2751
|
+
if (!ok) process.exitCode = 1;
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
if (sub === "remove") {
|
|
2755
|
+
const result = removeJev();
|
|
2756
|
+
if (json) return out(result);
|
|
2757
|
+
console.log(c.green(`✓ Jev is off${result.removedKey ? ", and its saved key is deleted" : ""}.`));
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2760
|
+
throw new Error("Usage: nomarmy validators <list|add jev|test jev|remove jev>");
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
async function cmdValidatorsJudge(sub) {
|
|
2764
|
+
const agents = loadAgents(globalConfigDir()).agents;
|
|
2765
|
+
const resolve = () => judgeSettings({ agents, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
|
|
2766
|
+
const probe = async (settings) => probeModel({ provider: settings.provider, model: settings.model, stateRoot: process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents") });
|
|
2767
|
+
if (sub === "add") {
|
|
2768
|
+
const agent = value("agent"), model = value("model");
|
|
2769
|
+
if (!agent || !model) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
|
|
2770
|
+
if (!agents[agent]) throw new Error(`"${agent}" isn't an agent in agents.yml. Agents: ${Object.keys(agents).join(", ") || "(none)"}`);
|
|
2771
|
+
if (agentRunsToolsOnHost(agents[agent]) && !flag("host-tools")) throw new Error(`agent "${agent}" runs its tools on this machine, and a judge reads text the worker wrote. Pass --host-tools to accept that, or pick a sandboxed agent (an api key, Codex, Muse).`);
|
|
2772
|
+
const saved = saveJudge({ agent, model, hostTools: flag("host-tools") });
|
|
2773
|
+
const settings = resolve();
|
|
2774
|
+
if (settings?.problem) throw new Error(settings.problem);
|
|
2775
|
+
const test = await probe(settings);
|
|
2776
|
+
if (json) return out({ saved: true, configPath: saved.configPath, test: test.ok ? "pass" : test.refused ? "refused" : "inconclusive", reason: test.reason });
|
|
2777
|
+
console.log(c.green(`✓ The judge is ${agent}/${model}, in ${saved.configPath}.`));
|
|
2778
|
+
console.log(test.ok ? c.green("✓ Test call answered. New implement jobs use it; restart open coordinator sessions to pick it up.") : c.red(`✗ Test call ${test.refused ? "refused" : "didn't answer"}: ${test.reason ?? "no answer"}`));
|
|
2779
|
+
if (!test.ok) process.exitCode = 1;
|
|
2780
|
+
return;
|
|
2781
|
+
}
|
|
2782
|
+
if (sub === "test") {
|
|
2783
|
+
const settings = resolve();
|
|
2784
|
+
if (!settings) throw new Error("No judge configured. Add one with: nomarmy validators add judge --agent <name> --model <model>");
|
|
2785
|
+
if (settings.problem) throw new Error(settings.problem);
|
|
2786
|
+
const test = await probe(settings);
|
|
2787
|
+
if (json) return out({ test: test.ok ? "pass" : "fail", reason: test.reason });
|
|
2788
|
+
console.log(test.ok ? c.green(`✓ ${settings.agent}/${settings.model} answered.`) : c.red(`✗ ${test.reason ?? "no answer"}`));
|
|
2789
|
+
if (!test.ok) process.exitCode = 1;
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
if (sub === "remove") {
|
|
2793
|
+
removeJudge();
|
|
2794
|
+
return json ? out({ removed: true }) : console.log(c.green("✓ The judge is off."));
|
|
2795
|
+
}
|
|
2796
|
+
throw new Error("Usage: nomarmy validators <add judge --agent <name> --model <model> [--host-tools]|test judge|remove judge>");
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// `nomarmy mcp`: what a --scope project registration runs. Nothing goes to
|
|
2800
|
+
// stdout but the server's own protocol.
|
|
2801
|
+
function cmdMcp() {
|
|
2802
|
+
const { serverPath, env } = portableServerLaunch({ nomarmyRoot });
|
|
2803
|
+
const child = spawn(process.execPath, [serverPath], { stdio: "inherit", env });
|
|
2804
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal));
|
|
2805
|
+
child.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 1); });
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
const commands = { stats: cmdStats, validators: cmdValidators, mcp: cmdMcp, scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, install: cmdInstall, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, sandbox: cmdSandbox, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
|
|
2621
2809
|
// doctor command
|
|
2622
2810
|
async function cmdDoctor() {
|
|
2623
2811
|
// Import lazily to avoid circular dependencies
|
package/lib/admission.mjs
CHANGED
|
@@ -126,6 +126,19 @@ export function createJobRuntime(deps) {
|
|
|
126
126
|
try { return await fn(); } finally { slot.release(); }
|
|
127
127
|
})();
|
|
128
128
|
}
|
|
129
|
+
// Who ran the job and for whom, onto its finished record, so `nomarmy
|
|
130
|
+
// stats` can count by role and agent, and by repo for verify runs too
|
|
131
|
+
// (their records didn't carry the repo). Best-effort; never fails the job.
|
|
132
|
+
function stampJobRecord(entry, result) {
|
|
133
|
+
const file = path.join(result?.jobDir ?? path.join(jobsRoot, entry.jobId), "metadata.json");
|
|
134
|
+
try {
|
|
135
|
+
const record = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
136
|
+
record.labels = { role: entry.role ?? null, agent: entry.agent ?? null, label: entry.label ?? null, runId: entry.runId ?? null };
|
|
137
|
+
record.projectDir = record.projectDir ?? projectDir;
|
|
138
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify(record, null, 2));
|
|
139
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
140
|
+
} catch { /* no record (a refused or crashed job): nothing to label */ }
|
|
141
|
+
}
|
|
129
142
|
function track(jobId, meta, promise) {
|
|
130
143
|
const entry = { ...meta, jobId, startedAt: new Date().toISOString(), settled: false, result: null, error: null, promise: null };
|
|
131
144
|
// A machine-wide lease for as long as the job runs, so every session's
|
|
@@ -134,7 +147,7 @@ export function createJobRuntime(deps) {
|
|
|
134
147
|
if (meta.lane) writeLease(leasesRoot, jobId, { lane: meta.lane, agent: meta.agent ?? null, runId: meta.runId ?? null, role: meta.role ?? null, model: meta.model ?? null, repo: projectDir });
|
|
135
148
|
const release = () => removeLease(leasesRoot, jobId);
|
|
136
149
|
entry.promise = promise.then(
|
|
137
|
-
r => { entry.settled = true; entry.result = r; release(); notifyJobFinished(entry, r, null); return r; },
|
|
150
|
+
r => { entry.settled = true; entry.result = r; release(); stampJobRecord(entry, r); notifyJobFinished(entry, r, null); return r; },
|
|
138
151
|
e => { entry.settled = true; entry.error = e; release(); notifyJobFinished(entry, null, e); throw e; });
|
|
139
152
|
entry.promise.catch(() => {});
|
|
140
153
|
activeJobs.set(jobId, entry);
|
package/lib/connect.mjs
CHANGED
|
@@ -261,6 +261,50 @@ export function deriveWorkerModelEnv(nomarmyRoot) {
|
|
|
261
261
|
|
|
262
262
|
const SERVER_NAME = "nomarmy-local-worker";
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Where a coordinator registers nomArmy. user: every project (the default).
|
|
266
|
+
* local: this repository, only you. project: this repository, committed for
|
|
267
|
+
* the team. A committed registration can't carry one machine's install path
|
|
268
|
+
* or settings, so it runs `nomarmy mcp`, which starts nomArmy with each
|
|
269
|
+
* person's own.
|
|
270
|
+
*/
|
|
271
|
+
export const SCOPES = Object.freeze(["user", "local", "project"]);
|
|
272
|
+
export const PORTABLE_SERVER = Object.freeze({ command: "nomarmy", args: ["mcp"] });
|
|
273
|
+
|
|
274
|
+
/** Keep repo-local files out of git without touching the committed .gitignore. */
|
|
275
|
+
export function excludeFromGit(projectDir, relPaths, run = defaultRun) {
|
|
276
|
+
let excludeFile;
|
|
277
|
+
try { excludeFile = String(run("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: projectDir, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" })).trim(); } catch { return []; }
|
|
278
|
+
if (!excludeFile) return [];
|
|
279
|
+
excludeFile = path.resolve(projectDir, excludeFile);
|
|
280
|
+
const existing = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, "utf8") : "";
|
|
281
|
+
const lines = new Set(existing.split("\n").map((l) => l.trim()));
|
|
282
|
+
const added = relPaths.map((p) => `/${p}`).filter((p) => !lines.has(p));
|
|
283
|
+
if (added.length) {
|
|
284
|
+
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
|
|
285
|
+
fs.appendFileSync(excludeFile, `${existing && !existing.endsWith("\n") ? "\n" : ""}# nomArmy, registered for this repository only\n${added.join("\n")}\n`);
|
|
286
|
+
}
|
|
287
|
+
return added;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Undo excludeFromGit for these paths: a project registration is meant to be committed. */
|
|
291
|
+
export function includeInGit(projectDir, relPaths, run = defaultRun) {
|
|
292
|
+
let excludeFile;
|
|
293
|
+
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 []; }
|
|
294
|
+
if (!fs.existsSync(excludeFile)) return [];
|
|
295
|
+
const drop = new Set(relPaths.map((p) => `/${p}`));
|
|
296
|
+
const lines = fs.readFileSync(excludeFile, "utf8").split("\n");
|
|
297
|
+
const removed = lines.filter((l) => drop.has(l.trim()));
|
|
298
|
+
if (removed.length) fs.writeFileSync(excludeFile, lines.filter((l) => !drop.has(l.trim())).join("\n"));
|
|
299
|
+
return removed.map((l) => l.trim());
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function repoScoped(scope, projectDir) {
|
|
303
|
+
if (!SCOPES.includes(scope)) throw new Error(`unknown scope "${scope}"; use one of ${SCOPES.join(", ")}`);
|
|
304
|
+
if (scope !== "user" && !projectDir) throw new Error(`--scope ${scope} registers nomArmy for one repository; run it inside that repository`);
|
|
305
|
+
return scope !== "user";
|
|
306
|
+
}
|
|
307
|
+
|
|
264
308
|
/**
|
|
265
309
|
* Every distinct auth_env name declared across config/providers.yml's
|
|
266
310
|
* pools, mapped to a placeholder value -- NEVER the real credential, which
|
|
@@ -302,7 +346,8 @@ export function derivePoolAuthEnvPlaceholders(configDir = globalConfigDir()) {
|
|
|
302
346
|
/**
|
|
303
347
|
* @param {{ nomarmyRoot: string, installDir?: string, run?: Function, extraEnv?: Record<string,string>, configDir?: string }} input
|
|
304
348
|
*/
|
|
305
|
-
export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, extraEnv = {}, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().claude, settingsPath = defaultClaudeSettingsPath() }) {
|
|
349
|
+
export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, extraEnv = {}, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().claude, settingsPath = defaultClaudeSettingsPath(), scope = "user", projectDir = null }) {
|
|
350
|
+
if (repoScoped(scope, projectDir)) return connectClaudeToRepo({ nomarmyRoot, installDir, run, extraEnv, configDir, scope, projectDir });
|
|
306
351
|
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
307
352
|
const commands = installPlaybooks({ nomarmyRoot, target: "claude", dir: commandsDir });
|
|
308
353
|
const notifier = buildNotifierApp({ nomarmyRoot });
|
|
@@ -340,13 +385,62 @@ export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), r
|
|
|
340
385
|
run("claude", ["mcp", "add", SERVER_NAME, ...envArgs, "--", "node", serverPath]);
|
|
341
386
|
}
|
|
342
387
|
run("claude", ["mcp", "get", SERVER_NAME]);
|
|
343
|
-
return { installDir, serverPath, preservedEnv: finalEnv, commands, statusLine, notifier };
|
|
388
|
+
return { installDir, serverPath, preservedEnv: finalEnv, commands, statusLine, notifier, scope: "user" };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Claude Code's local scope (this repo, only you) or project scope (.mcp.json,
|
|
392
|
+
// committed). The /feature playbook goes in the repo's .claude/commands, so it
|
|
393
|
+
// shows up only where nomArmy is registered; no status line, which is a
|
|
394
|
+
// user-wide setting.
|
|
395
|
+
function connectClaudeToRepo({ nomarmyRoot, installDir, run, extraEnv, configDir, scope, projectDir }) {
|
|
396
|
+
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
397
|
+
const commands = installPlaybooks({ nomarmyRoot, target: "claude", dir: path.join(projectDir, ".claude", "commands") });
|
|
398
|
+
const playbookPaths = commands.installed.map((f) => path.relative(projectDir, path.join(commands.dir, f)));
|
|
399
|
+
const excluded = scope === "local" ? excludeFromGit(projectDir, playbookPaths, run) : [];
|
|
400
|
+
if (scope === "project") includeInGit(projectDir, playbookPaths, run);
|
|
401
|
+
const serverPath = path.join(installDir, "mcp", "server.mjs");
|
|
402
|
+
const opts = { cwd: projectDir };
|
|
403
|
+
quietRun(run, "claude", ["mcp", "remove", SERVER_NAME, "--scope", scope], opts);
|
|
404
|
+
let env = {};
|
|
405
|
+
if (scope === "local") {
|
|
406
|
+
env = { ...derivePoolAuthEnvPlaceholders(configDir), ...extraEnv, ...deriveWorkerModelEnv(nomarmyRoot) };
|
|
407
|
+
const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
|
|
408
|
+
run("claude", ["mcp", "add", "--scope", "local", SERVER_NAME, ...envArgs, "--", "node", serverPath], opts);
|
|
409
|
+
} else {
|
|
410
|
+
run("claude", ["mcp", "add", "--scope", "project", SERVER_NAME, "--", PORTABLE_SERVER.command, ...PORTABLE_SERVER.args], opts);
|
|
411
|
+
}
|
|
412
|
+
run("claude", ["mcp", "get", SERVER_NAME], opts);
|
|
413
|
+
return { installDir, serverPath: scope === "local" ? serverPath : null, preservedEnv: env, commands, excluded, scope, projectDir,
|
|
414
|
+
configPath: scope === "project" ? path.join(projectDir, ".mcp.json") : null, userScoped: claudeUserScoped(run) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* What `nomarmy mcp` runs: the installed copy when there is one (the code
|
|
419
|
+
* `nomarmy update` keeps current), else this package's own server, with the
|
|
420
|
+
* env connect would register (worker model, api-key placeholders) under
|
|
421
|
+
* whatever the caller already set.
|
|
422
|
+
*/
|
|
423
|
+
export function portableServerLaunch({ nomarmyRoot, installDir = defaultInstallDir(), configDir = globalConfigDir(), env = process.env }) {
|
|
424
|
+
const installed = path.join(installDir, "mcp", "server.mjs");
|
|
425
|
+
const serverPath = fs.existsSync(installed) ? installed : path.join(nomarmyRoot, "mcp", "server.mjs");
|
|
426
|
+
return { serverPath, env: { ...derivePoolAuthEnvPlaceholders(configDir), ...deriveWorkerModelEnv(nomarmyRoot), ...env } };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Whether Claude Code also has nomArmy registered for every project. */
|
|
430
|
+
export function claudeUserScoped(run = defaultRun) {
|
|
431
|
+
try {
|
|
432
|
+
const out = String(run("claude", ["mcp", "get", SERVER_NAME], { cwd: os.homedir(), stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }));
|
|
433
|
+
return /Scope:\s*User/i.test(out);
|
|
434
|
+
} catch { return false; }
|
|
344
435
|
}
|
|
345
436
|
|
|
346
437
|
/**
|
|
347
438
|
* @param {{ nomarmyRoot: string, installDir?: string, run?: Function }} input
|
|
348
439
|
*/
|
|
349
|
-
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") }) {
|
|
440
|
+
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" }) {
|
|
441
|
+
// `codex mcp add` has no scope: Codex registers MCP servers only in
|
|
442
|
+
// ~/.codex/config.toml, for every project.
|
|
443
|
+
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`);
|
|
350
444
|
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
351
445
|
const commands = installPlaybooks({ nomarmyRoot, target: "codex", dir: skillsDir });
|
|
352
446
|
removeLegacyCodexSkill(legacySkillsDir, skillsDir);
|
|
@@ -410,7 +504,13 @@ export function readCursorConfig(configPath) {
|
|
|
410
504
|
/**
|
|
411
505
|
* @param {{ nomarmyRoot: string, installDir?: string, configPath?: string }} input
|
|
412
506
|
*/
|
|
413
|
-
export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), configPath = defaultCursorConfigPath(), run = defaultRun, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().cursor }) {
|
|
507
|
+
export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), configPath = defaultCursorConfigPath(), run = defaultRun, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().cursor, scope = "user", projectDir = null }) {
|
|
508
|
+
// Cursor reads a project's own .cursor/mcp.json beside the user one. local
|
|
509
|
+
// keeps it out of git; project commits it, with the portable command.
|
|
510
|
+
if (repoScoped(scope, projectDir)) {
|
|
511
|
+
configPath = path.join(projectDir, ".cursor", "mcp.json");
|
|
512
|
+
commandsDir = path.join(projectDir, ".cursor", "commands");
|
|
513
|
+
}
|
|
414
514
|
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
415
515
|
const commands = installPlaybooks({ nomarmyRoot, target: "cursor", dir: commandsDir });
|
|
416
516
|
const notifier = buildNotifierApp({ nomarmyRoot });
|
|
@@ -427,10 +527,15 @@ export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), c
|
|
|
427
527
|
// Cursor starts a global MCP server in the home folder, not the open
|
|
428
528
|
// project; ${workspaceFolder} is Cursor's own placeholder for the latter.
|
|
429
529
|
const preservedEnv = { ...derivePoolAuthEnvPlaceholders(configDir), ...((existing && typeof existing.env === "object" && existing.env) || {}), ...deriveWorkerModelEnv(nomarmyRoot), NOMARMY_PROJECT_DIR: "${workspaceFolder}" };
|
|
430
|
-
config.mcpServers[SERVER_NAME] =
|
|
530
|
+
config.mcpServers[SERVER_NAME] = scope === "project"
|
|
531
|
+
? { command: PORTABLE_SERVER.command, args: [...PORTABLE_SERVER.args], env: { NOMARMY_PROJECT_DIR: "${workspaceFolder}" } }
|
|
532
|
+
: { command: "node", args: [serverPath], env: preservedEnv };
|
|
431
533
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
432
534
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
433
|
-
|
|
535
|
+
const repoPaths = scope === "user" ? [] : [path.relative(projectDir, configPath), ...commands.installed.map((f) => path.relative(projectDir, path.join(commands.dir, f)))];
|
|
536
|
+
const excluded = scope === "local" ? excludeFromGit(projectDir, repoPaths, run) : [];
|
|
537
|
+
if (scope === "project") includeInGit(projectDir, repoPaths, run);
|
|
538
|
+
return { installDir, serverPath, configPath, preservedEnv: scope === "project" ? config.mcpServers[SERVER_NAME].env : preservedEnv, commands, notifier, scope, excluded };
|
|
434
539
|
}
|
|
435
540
|
|
|
436
541
|
/** True when Cursor's config file already has a nomArmy entry -- used by
|