nomarmy 0.1.0-alpha.18 → 0.1.0-alpha.19

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 CHANGED
@@ -16,6 +16,7 @@ AI coding workers are confident. Their "done, all tests pass" is a claim, not ev
16
16
  ## TL;DR
17
17
 
18
18
  1. **Have** Git, Node 24.16+ (or 26.1+) and [Podman](https://podman.io). On macOS, give Podman 8 GiB: `brew install podman && podman machine init --memory 8192 && podman machine start`.
19
+ Windows is supported with the engine in WSL2. Follow the [Windows setup](https://github.com/rayson-tech/nomarmy/blob/main/docs/install.md#windows).
19
20
  2. **Install and set up:**
20
21
  ```bash
21
22
  npm install -g nomarmy@alpha
@@ -74,7 +75,7 @@ Developed and maintained by Rayson Technologies. This is an alpha (`0.1.0-alpha`
74
75
 
75
76
  | | |
76
77
  |---|---|
77
- | [Install](https://github.com/rayson-tech/nomarmy/blob/main/docs/install.md) | Every setup: hosted (API keys and subscriptions), a local model on macOS, Linux, Windows or DGX Spark, a shared model server, Bedrock |
78
+ | [Install](https://github.com/rayson-tech/nomarmy/blob/main/docs/install.md) | Every setup: hosted (API keys and subscriptions), a local model on macOS, Linux, [Windows with the engine in WSL2](https://github.com/rayson-tech/nomarmy/blob/main/docs/install.md#windows) or DGX Spark, a shared model server, Bedrock |
78
79
  | [Example setup](https://github.com/rayson-tech/nomarmy/blob/main/docs/setup/example.md) | Claude Code, Codex and an API key, command by command |
79
80
  | [Agents and the army](https://github.com/rayson-tech/nomarmy/blob/main/docs/agents-and-army.md) | Where a job can run, who does what, usage limits, picking an agent |
80
81
  | [`/feature` runs](https://github.com/rayson-tech/nomarmy/blob/main/docs/feature-runs.md) | A feature end to end, and watching what nomArmy is doing |
package/bin/nomarmy.mjs CHANGED
@@ -18,11 +18,13 @@ 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, installMcpCopy, SCOPES, claudeUserScoped, portableServerLaunch } from "../lib/connect.mjs";
21
+ import { windowsFrontEnd, dropWindowsPath, markWindowsCoordinator, pickDistro, resolveWslNomarmy, mcpBridgeLaunch, writeWindowsSettings } from "../lib/wsl.mjs";
22
+ import { windowsPlan, windowsForward, windowsSetup, windowsDoctor } from "../lib/wsl-cli.mjs";
23
+ import { connectViaWsl, connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir, installMcpCopy, SCOPES, claudeUserScoped, portableServerLaunch } from "../lib/connect.mjs";
22
24
  import { compareVersions, readPackageVersion, readInstallVersions, copyIsStale } from "../lib/install-freshness.mjs";
23
25
  import { loadJobRecords, computeStats, formatStats, formatStatsSummary, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
24
26
  import { requestJobStop } from "../lib/openclaw-run.mjs";
25
- import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings } from "../lib/validators.mjs";
27
+ import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings, judgeAgentChoices, chooseJudgeAgent, confirmJudgeHostTools } from "../lib/validators.mjs";
26
28
  import { probeModel } from "../lib/model-probe.mjs";
27
29
  import { ID_RE, AUTH_ENV_NAME_RE, OPENCLAW_PROVIDER_ID_RE, openclawProviderId, isNativeProviderType } from "../lib/dispatch-schema.mjs";
28
30
  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";
@@ -40,8 +42,22 @@ import { recordProbeSuccess } from "../lib/health.mjs";
40
42
  import { pruneJobRuntime } from "../lib/prune.mjs";
41
43
  import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
42
44
  import { ensureOpenClawOnPath } from "../lib/openclaw-path.mjs";
45
+ import { THINKING_LEVELS } from "../lib/thinking.mjs";
46
+ import { fileURLToPath } from "node:url";
47
+ // Inside WSL, only the distro's own tools (see lib/wsl.mjs).
48
+ dropWindowsPath();
49
+ // The Windows front end: whatever it forwards knows the coordinators are on Windows.
50
+ if (windowsFrontEnd()) markWindowsCoordinator();
43
51
  // OpenClaw in ~/.npm-global/bin (no writable npm prefix) is found without the operator editing PATH.
44
52
  ensureOpenClawOnPath();
53
+ // Windows starts the engine as `wsl.exe --exec <node> nomarmy.mjs`, with no
54
+ // login shell, so an nvm Node's directory isn't on PATH. Children that need
55
+ // `node` (install.sh, OpenClaw's `#!/usr/bin/env node`) get the one running.
56
+ {
57
+ const nodeDir = path.dirname(process.execPath);
58
+ const entries = (process.env.PATH ?? "").split(path.delimiter);
59
+ if (!entries.includes(nodeDir)) process.env.PATH = [nodeDir, ...entries.filter(Boolean)].join(path.delimiter);
60
+ }
45
61
 
46
62
  // Add a new coordinator: add its name here, teach commandExists/connectTarget
47
63
  // about it below (a JSON-file target like Cursor has no PATH binary to check
@@ -71,7 +87,7 @@ const repoDir = path.resolve(value("repo", process.cwd()));
71
87
  // through the job's requested reasoning); `--no-thinking` is always false.
72
88
  // Returns undefined when none of these flags were passed at all, so the
73
89
  // caller can tell "not touched" apart from "explicitly set".
74
- const THINKING_LEVELS = ["low", "medium", "high"];
90
+ // Every level OpenClaw accepts (lib/thinking.mjs).
75
91
  function resolveThinkingFlag() {
76
92
  const level = value("thinking");
77
93
  if (level && THINKING_LEVELS.includes(level)) return level;
@@ -159,7 +175,7 @@ Usage: nomarmy <command> [options]
159
175
  [--update-mcp] (api); --provider --model
160
176
  --owner (subscription); and optionally
161
177
  --max-concurrent --context-window
162
- --thinking [low|medium|high] --no-thinking
178
+ --thinking [minimal|low|medium|high|xhigh|adaptive|max|ultra] --no-thinking
163
179
  update <name>
164
180
  change the model (picked from what OpenClaw
165
181
  lists; a subscription gets a real test call),
@@ -252,7 +268,9 @@ Usage: nomarmy <command> [options]
252
268
  and review flags. From verified records, never reports.
253
269
  validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>
254
270
  Optional semantic checks from a model you configure with
255
- your own key. Today: Jev (TypeSafe). \`add jev\` asks for the
271
+ your own key. Jev checks focused claims with TypeSafe;
272
+ Judge reviews diffs with one of your configured agents.
273
+ Setup asks for any consent it needs. \`add jev\` asks for the
256
274
  key without echoing it (or reads --key-stdin), saves it
257
275
  where only you can read it, and makes one test call. Its
258
276
  answers only add review flags, and it sends excerpts of
@@ -463,7 +481,9 @@ async function cmdInit() {
463
481
  // This file's own location, not --repo (the target repo being scanned) --
464
482
  // setup/model need to find THIS package's config/ and scripts/ as siblings
465
483
  // of bin/, the same way select-model.mjs resolves its own root.
466
- const nomarmyRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
484
+ // fileURLToPath, not URL.pathname: on Windows that gave "/C:/Users/Jason%20Pugh/...",
485
+ // a stray slash and an encoded space, and connect failed copying from C:\\C:\\...
486
+ const nomarmyRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
467
487
 
468
488
  /** Read one KEY=VALUE line's value, or null if the file or key doesn't exist. */
469
489
  function readEnvValue(filePath, key) {
@@ -653,6 +673,13 @@ function cmdInstall() {
653
673
  }
654
674
 
655
675
  async function cmdSetup() {
676
+ if (windowsFrontEnd()) {
677
+ process.exitCode = await windowsSetup(argv, { ask: async (prompt) => {
678
+ const rl = createInterface({ input, output });
679
+ try { return await rl.question(prompt); } finally { rl.close(); }
680
+ } });
681
+ return;
682
+ }
656
683
  if (flag("status") || (!flag("choose") && !flag("hosted") && !flag("llama-url") && !json)) {
657
684
  if (flag("status") || !process.stdin.isTTY) {
658
685
  const steps = setupChecklist();
@@ -1870,6 +1897,9 @@ async function cmdConnect() {
1870
1897
  const requested = argv.slice(1).filter((a, i) => !a.startsWith("--") && !flagValues.has(i + 1));
1871
1898
  const scope = value("scope", "user");
1872
1899
  if (!SCOPES.includes(scope)) throw new Error(`--scope must be one of ${SCOPES.join(", ")}, got "${scope}".`);
1900
+ const nativeWindows = windowsFrontEnd();
1901
+ if (nativeWindows && scope !== "user") throw new Error("per-repo registration on Windows isn't supported yet; use the default --scope user");
1902
+ if (nativeWindows && flag("copy-only")) throw new Error("Windows runs nomArmy inside WSL; run nomarmy connect <target> instead of --copy-only");
1873
1903
  let projectDir = null;
1874
1904
  if (scope !== "user") {
1875
1905
  try { projectDir = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
@@ -1904,6 +1934,17 @@ async function cmdConnect() {
1904
1934
  }
1905
1935
  try {
1906
1936
  if (!json) console.log(c.bold(`\n🍪 Connecting nomArmy to ${target}...`));
1937
+ if (nativeWindows) {
1938
+ const capture = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
1939
+ const distro = pickDistro({ run: capture });
1940
+ const resolved = resolveWslNomarmy({ distro, run: capture });
1941
+ const launch = mcpBridgeLaunch({ distro, ...resolved });
1942
+ const result = connectViaWsl({ target, distro, launch, run, nomarmyRoot });
1943
+ writeWindowsSettings({ distro, ...resolved });
1944
+ results.push({ target, connected: true, ...result });
1945
+ if (!json) console.log(c.green(`✓ Registered nomArmy with ${target}, running inside WSL (${distro})`));
1946
+ continue;
1947
+ }
1907
1948
  const result = connectTarget(target, { nomarmyRoot, run, scope, projectDir });
1908
1949
  results.push({ target, connected: true, ...result });
1909
1950
  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.`}`));
@@ -2887,16 +2928,58 @@ async function cmdValidatorsJudge(sub) {
2887
2928
  const resolve = () => judgeSettings({ agents, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
2888
2929
  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") });
2889
2930
  if (sub === "add") {
2890
- const agent = value("agent"), model = value("model");
2891
- if (!agent || !model) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
2931
+ let agent = value("agent"), model = value("model"), dominantBuilderVendor = null;
2932
+ let rl = null;
2933
+ const question = async (prompt) => {
2934
+ rl ??= createInterface({ input, output });
2935
+ return rl.question(c.bold(prompt));
2936
+ };
2937
+ if (!agent) {
2938
+ if (!process.stdin.isTTY || json) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
2939
+ const roles = loadArmy({ projectDir: repoDir }).army.roles;
2940
+ const guided = judgeAgentChoices({ agents, roles, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
2941
+ dominantBuilderVendor = guided.dominantBuilderVendor;
2942
+ if (!guided.choices.length) throw new Error("No api or subscription agents are configured in agents.yml.");
2943
+ agent = (await chooseJudgeAgent({ choices: guided.choices, ask: question, write: (line) => console.log(line) })).name;
2944
+ }
2892
2945
  if (!agents[agent]) throw new Error(`"${agent}" isn't an agent in agents.yml. Agents: ${Object.keys(agents).join(", ") || "(none)"}`);
2893
- 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).`);
2894
- const saved = saveJudge({ agent, model, hostTools: flag("host-tools") });
2946
+ if (!model) {
2947
+ if (!process.stdin.isTTY || json) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
2948
+ const listed = catalogModelsFor(agentProviderId(agents[agent]));
2949
+ if (listed.length) {
2950
+ console.log(`Models for ${agent}:`);
2951
+ listed.forEach((item, index) => console.log(` ${index + 1}. ${item}`));
2952
+ }
2953
+ const fallback = agents[agent].model ?? "";
2954
+ const answer = String(await question(`Model id${fallback ? ` [${fallback}]` : ""}: `)).trim();
2955
+ model = /^\d+$/.test(answer) && listed[Number(answer) - 1] ? listed[Number(answer) - 1] : answer || fallback;
2956
+ if (!model) { rl?.close(); throw new Error("A model id is required."); }
2957
+ }
2958
+ let hostTools = flag("host-tools");
2959
+ if (agentRunsToolsOnHost(agents[agent]) && !hostTools) {
2960
+ if (!process.stdin.isTTY || json) {
2961
+ const quote = (arg) => /^[A-Za-z0-9_./:-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
2962
+ const rerun = ["nomarmy", ...argv, "--host-tools"].map(quote).join(" ");
2963
+ throw new Error(`agent "${agent}" runs its tools on this machine, and a judge reads text the worker wrote. Refusing without explicit consent. Re-run: ${rerun}`);
2964
+ }
2965
+ const allowed = await confirmJudgeHostTools({ agent, ask: question, write: (line) => console.log(line) });
2966
+ if (!allowed) { rl?.close(); console.log(c.dim("Canceled; nothing written.")); return; }
2967
+ hostTools = true;
2968
+ }
2969
+ rl?.close();
2970
+ const saved = saveJudge({ agent, model, hostTools });
2895
2971
  const settings = resolve();
2896
2972
  if (settings?.problem) throw new Error(settings.problem);
2897
2973
  const test = await probe(settings);
2898
2974
  if (json) return out({ saved: true, configPath: saved.configPath, test: test.ok ? "pass" : test.refused ? "refused" : "inconclusive", reason: test.reason });
2899
2975
  console.log(c.green(`✓ The judge is ${agent}/${model}, in ${saved.configPath}.`));
2976
+ if (dominantBuilderVendor === null) {
2977
+ try {
2978
+ const roles = loadArmy({ projectDir: repoDir }).army.roles;
2979
+ dominantBuilderVendor = judgeAgentChoices({ agents, roles, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost }).dominantBuilderVendor;
2980
+ } catch { /* no readable army means there is no builder comparison */ }
2981
+ }
2982
+ if (dominantBuilderVendor && agentProviderId(agents[agent]) === dominantBuilderVendor) console.log(c.yellow("Note: this judge uses the same vendor as most build roles, so its verdicts are not independent of those builders."));
2900
2983
  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"}`));
2901
2984
  if (!test.ok) process.exitCode = 1;
2902
2985
  return;
@@ -2930,11 +3013,18 @@ function cmdMcp() {
2930
3013
  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) };
2931
3014
  // doctor command
2932
3015
  async function cmdDoctor() {
3016
+ if (windowsFrontEnd()) {
3017
+ process.exitCode = windowsDoctor({ json, argv });
3018
+ return;
3019
+ }
2933
3020
  // Import lazily to avoid circular dependencies
2934
3021
  const { runDoctor } = await import("../lib/doctor.mjs");
2935
3022
  await runDoctor({ json, exit: true, env: installEnv() });
2936
3023
  }
2937
3024
  commands.doctor = cmdDoctor;
3025
+ if (windowsFrontEnd() && windowsPlan(argv) === "FORWARD") {
3026
+ process.exit(windowsForward(argv));
3027
+ }
2938
3028
  if (!command && !flag("help")) {
2939
3029
  // New users typed `nomarmy` and got the whole command reference.
2940
3030
  console.log(`${c.bold("nomArmy")}: bounded coding workers with independently verified results.\n\n New here? ${c.cyan("nomarmy setup")} walks you through it, one step at a time\n All commands: ${c.cyan("nomarmy help")}\n Docs: https://github.com/rayson-tech/nomarmy`);
package/docker/Dockerfile CHANGED
@@ -1,9 +1,9 @@
1
- FROM node:24-bookworm-slim
1
+ FROM node:26-bookworm-slim
2
2
  ENV DEBIAN_FRONTEND=noninteractive
3
3
  RUN apt-get update && apt-get install -y --no-install-recommends \
4
4
  bash ca-certificates curl git jq python3 python3-pip python3-venv ripgrep \
5
5
  && rm -rf /var/lib/apt/lists/*
6
- # node:24-bookworm-slim already ships a non-root "node" user at uid/gid 1000,
6
+ # The node:*-bookworm-slim images ship a non-root "node" user at uid/gid 1000,
7
7
  # the same uid this Dockerfile used to try to create a "sandbox" user at.
8
8
  # useradd silently failed on every build (masked by `|| true`), so `USER
9
9
  # sandbox` below referred to a user that was never actually created --
@@ -1,4 +1,4 @@
1
- FROM node:24-bookworm-slim
1
+ FROM node:26-bookworm-slim
2
2
  ENV DEBIAN_FRONTEND=noninteractive
3
3
  # Same base as docker/Dockerfile plus a Go toolchain -- the worker's own
4
4
  # tool-calling harness needs Node regardless of the target repo's language.
@@ -1,4 +1,4 @@
1
- FROM node:24-bookworm-slim
1
+ FROM node:26-bookworm-slim
2
2
  ENV DEBIAN_FRONTEND=noninteractive
3
3
  # Same base as docker/Dockerfile plus a Rust toolchain -- the worker's own
4
4
  # tool-calling harness needs Node regardless of the target repo's language.
package/install.sh CHANGED
@@ -130,10 +130,16 @@ if nomarmy_has_local_model; then openclaw plugins install @openclaw/llama-cpp-pr
130
130
  # Bedrock credential unless the coder sandbox is already network-isolated.
131
131
  "$ROOT/scripts/setup-sandbox.sh"
132
132
  "$ROOT/scripts/configure-openclaw.sh" "$NOMARMY_PROFILE"
133
- if [[ "$WITH_CLAUDE" == 1 ]]; then
134
- if command -v claude >/dev/null 2>&1; then node "$ROOT/bin/nomarmy.mjs" connect claude; else echo 'NOTE: Claude Code not found; worker stack installed. Install Claude Code then run: nomarmy connect claude'; fi
133
+ if [[ "${NOMARMY_COORDINATOR_OS:-}" == windows ]]; then
134
+ # Run from the Windows front end: the coordinators are Windows programs,
135
+ # registered from Windows, not looked for inside this distro.
136
+ echo 'NOTE: register your coordinators from Windows PowerShell: nomarmy connect claude (or codex, cursor)'
137
+ else
138
+ if [[ "$WITH_CLAUDE" == 1 ]]; then
139
+ if command -v claude >/dev/null 2>&1; then node "$ROOT/bin/nomarmy.mjs" connect claude; else echo 'NOTE: Claude Code not found; worker stack installed. Install Claude Code then run: nomarmy connect claude'; fi
140
+ fi
141
+ if command -v codex >/dev/null 2>&1; then node "$ROOT/bin/nomarmy.mjs" connect codex; else echo 'NOTE: Codex not found; run: nomarmy connect codex (after installing Codex)'; fi
135
142
  fi
136
- if command -v codex >/dev/null 2>&1; then node "$ROOT/bin/nomarmy.mjs" connect codex; else echo 'NOTE: Codex not found; run: nomarmy connect codex (after installing Codex)'; fi
137
143
  "$ROOT/scripts/verify-install.sh" "$NOMARMY_PROFILE"
138
144
  if nomarmy_is_cloud && [[ "${NOMARMY_ORCHESTRATOR_RUNTIME:-}" == "claude-code" ]]; then
139
145
  echo
package/lib/agents.mjs CHANGED
@@ -37,6 +37,7 @@ import { providerEntrySchema, formatDispatchIssues, openclawProviderId, PROVIDER
37
37
  export { HOST_TOOL_PROVIDERS, agentRunsToolsOnHost };
38
38
  import { findProviderConflicts, describeProviderConflict } from "./subscription-config.mjs";
39
39
  import { privateConfigProblem } from "./army.mjs";
40
+ import { THINKING_LEVELS } from "./thinking.mjs";
40
41
 
41
42
  export const AGENTS_FILENAME = "agents.yml";
42
43
  export const AGENT_KINDS = Object.freeze(["local", "api", "subscription"]);
@@ -48,7 +49,7 @@ export const BUILTIN_LOCAL_AGENT = Object.freeze({ kind: "local", slot: "coder"
48
49
  const requiredString = () =>
49
50
  z.string({ error: typeError("a string") })
50
51
  .refine((value) => value.trim().length > 0, { message: "must not be empty" });
51
- const thinkingSchema = z.union([z.boolean(), z.enum(["low", "medium", "high"])]).default(true);
52
+ const thinkingSchema = z.union([z.boolean(), z.enum(THINKING_LEVELS)]).default(true);
52
53
  const positiveInt = () => z.number({ error: typeError("a number") }).int("must be a whole number").positive("must be a positive number");
53
54
 
54
55
  const localAgentSchema = z.object({
package/lib/connect.mjs CHANGED
@@ -24,6 +24,8 @@
24
24
  import fs from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
+ import { mcpBridgeLaunch } from "./wsl.mjs";
28
+ import { toPosix } from "./path-utils.mjs";
27
29
  import { execFileSync } from "node:child_process";
28
30
  import { loadAgents } from "./agents.mjs";
29
31
  import { globalConfigDir } from "./army.mjs";
@@ -281,7 +283,8 @@ export function excludeFromGit(projectDir, relPaths, run = defaultRun) {
281
283
  excludeFile = path.resolve(projectDir, excludeFile);
282
284
  const existing = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, "utf8") : "";
283
285
  const lines = new Set(existing.split("\n").map((l) => l.trim()));
284
- const added = relPaths.map((p) => `/${p}`).filter((p) => !lines.has(p));
286
+ // Git exclude patterns always use forward slashes, including on Windows.
287
+ const added = relPaths.map((p) => `/${toPosix(p)}`).filter((p) => !lines.has(p));
285
288
  if (added.length) {
286
289
  fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
287
290
  fs.appendFileSync(excludeFile, `${existing && !existing.endsWith("\n") ? "\n" : ""}# nomArmy, registered for this repository only\n${added.join("\n")}\n`);
@@ -294,7 +297,8 @@ export function includeInGit(projectDir, relPaths, run = defaultRun) {
294
297
  let excludeFile;
295
298
  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 []; }
296
299
  if (!fs.existsSync(excludeFile)) return [];
297
- const drop = new Set(relPaths.map((p) => `/${p}`));
300
+ // Match the same portable spelling written by excludeFromGit on Windows.
301
+ const drop = new Set(relPaths.map((p) => `/${toPosix(p)}`));
298
302
  const lines = fs.readFileSync(excludeFile, "utf8").split("\n");
299
303
  const removed = lines.filter((l) => drop.has(l.trim()));
300
304
  if (removed.length) fs.writeFileSync(excludeFile, lines.filter((l) => !drop.has(l.trim())).join("\n"));
@@ -551,3 +555,29 @@ export function cursorAlreadyConnected(configPath = defaultCursorConfigPath()) {
551
555
  return false;
552
556
  }
553
557
  }
558
+
559
+
560
+ // Windows installs only coordinator playbooks. The engine stays in WSL.
561
+ export function connectViaWsl({ target, distro, launch, run = defaultRun, commandsDir = defaultCommandDirs()[target], configPath = defaultCursorConfigPath(), nomarmyRoot }) {
562
+ if (!["claude", "codex", "cursor"].includes(target)) throw new Error(`unknown connect target: ${target}`);
563
+ // Revalidate the direct launch before passing any values to a coordinator.
564
+ const checked = mcpBridgeLaunch({ distro, node: launch?.args?.[3], script: launch?.args?.[4] });
565
+ if (JSON.stringify(launch) !== JSON.stringify(checked)) throw new Error("Invalid WSL MCP bridge launch");
566
+ const { command, args } = checked;
567
+ const env = { ...checked.env, WSLENV: `${checked.env.WSLENV}:NOMARMY_COORDINATOR_OS/u`, NOMARMY_COORDINATOR_OS: "windows" };
568
+ if (target === "claude") {
569
+ quietRun(run, "claude", ["mcp", "remove", SERVER_NAME, "--scope", "user"]);
570
+ run("claude", ["mcp", "add", "--scope", "user", SERVER_NAME, "-e", `WSLENV=${env.WSLENV}`, "-e", `NOMARMY_COORDINATOR_OS=${env.NOMARMY_COORDINATOR_OS}`, "--", command, ...args]);
571
+ } else if (target === "codex") {
572
+ quietRun(run, "codex", ["mcp", "remove", SERVER_NAME]);
573
+ run("codex", ["mcp", "add", SERVER_NAME, "--env", `WSLENV=${env.WSLENV}`, "--env", `NOMARMY_COORDINATOR_OS=${env.NOMARMY_COORDINATOR_OS}`, "--", command, ...args]);
574
+ } else {
575
+ const config = readCursorConfig(configPath);
576
+ if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers)) config.mcpServers = {};
577
+ config.mcpServers[SERVER_NAME] = { command, args, env: { ...env, NOMARMY_PROJECT_DIR: "${workspaceFolder}" } };
578
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
579
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
580
+ }
581
+ const commands = installPlaybooks({ nomarmyRoot, target, dir: commandsDir });
582
+ return { target, bridge: { distro }, commands };
583
+ }
@@ -29,6 +29,24 @@ export function isDocumentationPath(file) {
29
29
  return DOC_EXTENSIONS.has(ext) || DOC_BASENAMES.has(dot > 0 ? base.slice(0, dot) : base);
30
30
  }
31
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
+
32
50
  export function isTestPath(file) {
33
51
  const normalized = String(file ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
34
52
  if (!normalized) return false;
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { z } from "zod";
18
18
  import { typeError, isMissingField, isUnknownDiscriminator } from "./zod-issues.mjs";
19
+ import { THINKING_LEVELS } from "./thinking.mjs";
19
20
 
20
21
  const requiredString = () =>
21
22
  z
@@ -144,7 +145,7 @@ const urlSchema = () =>
144
145
  // OpenClaw names as supported (see parseUnsupportedThinkingError) -- so a
145
146
  // level this schema accepts is never a guarantee the provider/model
146
147
  // combination honors it today, only what to keep asking for.
147
- const thinkingSchema = z.union([z.boolean(), z.enum(["low", "medium", "high"])]).default(true);
148
+ const thinkingSchema = z.union([z.boolean(), z.enum(THINKING_LEVELS)]).default(true);
148
149
 
149
150
  // Optional override for the model's real context window, in tokens. Absent
150
151
  // (the common case), lib/dispatch-config.mjs looks this up from OpenClaw's
package/lib/doctor.mjs CHANGED
@@ -145,12 +145,19 @@ export function checkGitLongPaths(facts) {
145
145
  * @param {{podmanFound: boolean}} facts
146
146
  * @returns {{ok: boolean, message: string, fix?: string}}
147
147
  */
148
+ /** How to install Podman here. On Windows and macOS it runs in a small VM that needs creating once. */
149
+ export function installPodmanHint(platform = process.platform) {
150
+ if (platform === "win32") return "Install Podman ('winget install RedHat.Podman'), then create its VM once: 'podman machine init --memory 8192' and 'podman machine start' (it uses WSL2)";
151
+ if (platform === "darwin") return "Install Podman ('brew install podman'), then create its VM once: 'podman machine init --memory 8192' and 'podman machine start'";
152
+ return "Install Podman with your distro's package manager (e.g. 'sudo apt install podman')";
153
+ }
154
+
148
155
  export function checkPodmanPresent(facts) {
149
156
  if (facts.podmanFound) return { ok: true, message: "podman is available." };
150
157
  return {
151
158
  ok: false,
152
159
  message: "podman executable not found.",
153
- fix: "Install Podman ('brew install podman' on macOS, or your distro's package manager on Linux) and ensure 'podman' is on your PATH.",
160
+ fix: `${installPodmanHint(facts.platform)}, and make sure 'podman' is on your PATH.`,
154
161
  };
155
162
  }
156
163
 
@@ -217,7 +224,7 @@ export function checkPodmanDaemon(facts) {
217
224
  return {
218
225
  ok: false,
219
226
  message: "Podman check skipped: podman executable not found.",
220
- fix: "Install Podman ('brew install podman' on macOS, or your distro's package manager on Linux), then re-run 'nomarmy doctor'.",
227
+ fix: `${installPodmanHint(facts.platform)}, then re-run 'nomarmy doctor'.`,
221
228
  };
222
229
  }
223
230
  if (facts.podmanDaemonReachable) {
@@ -248,7 +255,7 @@ export function checkEndpoint(facts) {
248
255
  };
249
256
  }
250
257
  if (execution === "remote") return checkRemoteEndpoint(endpoint);
251
- if (execution === "local") return checkLocalEndpoint(endpoint);
258
+ if (execution === "local") return checkLocalEndpoint(endpoint, facts.platform);
252
259
  return {
253
260
  ok: false,
254
261
  message: `NOMARMY_EXECUTION '${execution}' is not recognized.`,
@@ -266,13 +273,14 @@ function checkRemoteEndpoint(endpoint) {
266
273
  };
267
274
  }
268
275
 
269
- function checkLocalEndpoint(endpoint) {
276
+ function checkLocalEndpoint(endpoint, platform = process.platform) {
270
277
  const { url, healthy, error } = endpoint;
271
278
  if (healthy) return { ok: true, message: `llama-server is healthy at ${url}.` };
272
279
  return {
273
280
  ok: false,
274
281
  message: `llama-server health check failed at ${url}${error ? `: ${error}` : "."}`,
275
- fix: "Start the local worker with './scripts/start-inference.sh' (after 'source scripts/lib.sh && load_profile <profile>'), then re-run 'nomarmy doctor'.",
282
+ // Most people run jobs on API and subscription agents: say how to choose that, not only how to start a model.
283
+ fix: `No local model is running. If your jobs run on API keys or subscriptions, choose that with 'nomarmy setup --choose' (hosted). To run a model on this machine: ${platform === "win32" ? "see docs/install.md#windows (the local model needs WSL2 or a native llama.cpp build)" : "'nomarmy start <profile>'"}. Then re-run 'nomarmy doctor'.`,
276
284
  };
277
285
  }
278
286
 
package/lib/execute.mjs CHANGED
@@ -21,7 +21,7 @@ import { describeRecoveryChanges, reportRecoveryPrompt } from "./worker-prompt.m
21
21
  import { parseWorkerReport } from "./report.mjs";
22
22
  import { OUTCOMES, COORDINATOR_STATUS_BY_OUTCOME } from "./outcomes.mjs";
23
23
  import { resolveOutcome, finalText, workerMetadata, applyRefactorContract, applyVerificationPolicy, HIGH_STAKES_NOTE } from "./outcome.mjs";
24
- import { parseAddedLineNumbers, isTestPath, isDocumentationPath, detectScopedTestSelectionRisk, detectUnwiredNewDefinitions, detectMislabeledTestNames, detectPossibleSecrets, detectVerificationInputChanges } from "./diff-checks.mjs";
24
+ import { parseAddedLineNumbers, isTestPath, planRegressionProductionFiles, detectScopedTestSelectionRisk, detectUnwiredNewDefinitions, detectMislabeledTestNames, detectPossibleSecrets, detectVerificationInputChanges } from "./diff-checks.mjs";
25
25
 
26
26
  // ---------------------------------------------------------------------------
27
27
  // Job status for polling. `status.json` is written at every phase transition
@@ -286,9 +286,9 @@ export function createExecutor(deps) {
286
286
  // profile (resolveVerifyRegression). It doubles verification wall-clock,
287
287
  // so it runs only when there's something to re-check: a passing
288
288
  // first-pass verification on a diff that touched production code.
289
- // Documentation isn't code a test can prove, so it's neither reverted
290
- // nor a reason to run the check (isDocumentationPath).
291
- const codeFilesChanged = preCommit.testChanges.production_files_changed.filter((f) => !isDocumentationPath(f));
289
+ // Documentation and CI configuration cannot be proven by local tests,
290
+ // so neither is reverted or used as a reason to run this check.
291
+ const codeFilesChanged = planRegressionProductionFiles(preCommit.testChanges.production_files_changed);
292
292
  let regressionCheck = null, regressionCheckFatal = false, regressionCheckElapsedMs = null;
293
293
  if (verifyRegression && independentVerification.status === "pass" && codeFilesChanged.length > 0) {
294
294
  const regressionStartedMs = Date.now();
package/lib/notify.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // macOS: nomArmy.app (lib/notifier-app.mjs), which shows nomArmy's own icon,
6
6
  // or osascript's `display notification` when the app isn't built (that one
7
- // shows Script Editor's icon). Linux: notify-send. Anything else, or
7
+ // shows Script Editor's icon). WSL: Windows toast via PowerShell, falling
8
+ // back to Linux notify-send. Other Linux: notify-send. Anything else, or
8
9
  // NOMARMY_NOTIFY=0: nothing. Fire-and-forget: a notification must never
9
10
  // delay or fail the job that triggered it.
10
11
 
@@ -23,6 +24,23 @@ export function appleScriptString(text) {
23
24
  export function notificationCommand(title, message, { platform = process.platform, env = process.env, app = null } = {}) {
24
25
  if (env.NOMARMY_NOTIFY === "0") return null;
25
26
  const t = String(title).slice(0, 120), m = String(message).slice(0, 400);
27
+ if (env.WSL_DISTRO_NAME) {
28
+ // EncodedCommand avoids shell/interop quoting. Single-quoted PowerShell
29
+ // literals and DOM InnerText keep user text out of code and XML markup.
30
+ const literal = text => `'${text.replace(/['\u2018\u2019\u201a\u201b]/g, quote => quote + quote)}'`;
31
+ const script = `$ErrorActionPreference = 'Stop'
32
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
33
+ [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
34
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
35
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
36
+ $xml.LoadXml('<toast><visual><binding template="ToastGeneric"><text/><text/></binding></visual></toast>')
37
+ $text = $xml.GetElementsByTagName('text')
38
+ $text.Item(0).InnerText = ${literal(t)}
39
+ $text.Item(1).InnerText = ${literal(m)}
40
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
41
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Microsoft.Windows.PowerShell').Show($toast)`;
42
+ return ["powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")]];
43
+ }
26
44
  if (platform === "darwin" && app) return ["open", ["-g", app.app, "--args", app.spool]];
27
45
  if (platform === "darwin") return ["osascript", ["-e", `display notification ${appleScriptString(m)} with title ${appleScriptString(t)}`]];
28
46
  if (platform === "linux") return ["notify-send", ["--app-name=nomArmy", t, m]];
@@ -57,10 +75,19 @@ export function notify(title, message, { platform = process.platform, env = proc
57
75
  try { if (app && !spoolNotification(app.spool, title, message)) return false; } catch { app = null; }
58
76
  const cmd = notificationCommand(title, message, { platform, env, app });
59
77
  if (!cmd) return false;
78
+ let fellBack = false;
79
+ const fallback = () => {
80
+ if (fellBack || cmd[0] !== "powershell.exe") return false;
81
+ fellBack = true;
82
+ return notify(title, message, { platform, env: { ...env, WSL_DISTRO_NAME: "" }, run, root });
83
+ };
60
84
  try {
85
+ // Launching PowerShell is also the interop reachability check. Missing or
86
+ // disabled interop emits error; a failed toast exits nonzero. Both fall back.
61
87
  const child = run(cmd[0], cmd[1], { stdio: "ignore", detached: true });
62
- child.on?.("error", () => {});
88
+ child.on?.("error", fallback);
89
+ child.on?.("exit", (code, signal) => { if (code !== 0 || signal) fallback(); });
63
90
  child.unref?.();
64
91
  return true;
65
- } catch { return false; }
92
+ } catch { return fallback(); }
66
93
  }
@@ -13,6 +13,7 @@ import { deriveBudgets } from "./budget.mjs";
13
13
  import { entryContextPerNom } from "./dispatch-config.mjs";
14
14
  import { readClaudeSessionUsage } from "./claude-transcript.mjs";
15
15
  import { modelRejection, modelRejectionLine } from "./openclaw-errors.mjs";
16
+ import { nearestThinkingLevel } from "./thinking.mjs";
16
17
 
17
18
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
18
19
 
@@ -580,7 +581,7 @@ export function createOpenClawRunner(deps) {
580
581
  // -- never loop on the same level, and never mask a real, unrelated
581
582
  // failure as a thinking-level problem it isn't.
582
583
  if (unsupported && !unsupported.supported.includes(selected.thinking)) {
583
- const fallback = unsupported.supported[0];
584
+ const fallback = nearestThinkingLevel(selected.thinking, unsupported.supported);
584
585
  fs.appendFileSync(path.join(jobDir, "coordinator.log"),
585
586
  `${new Date().toISOString()} "${selected.model}" rejected thinking level "${selected.thinking}" (OpenClaw supports: ${unsupported.supported.join(", ")}) -- retrying once with "${fallback}"\n`);
586
587
  selected.thinking = fallback; // the manifest's requestedReasoning field should reflect what was ACTUALLY used, not the level that failed
@@ -0,0 +1,4 @@
1
+ /** Docker, Git, and repository metadata paths are POSIX paths, even on Windows. */
2
+ export function toPosix(value) {
3
+ return value.replaceAll("\\", "/");
4
+ }
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { execFileSync } from 'node:child_process';
5
+ import { toPosix } from './path-utils.mjs';
5
6
 
6
7
  const refuse = (line, kind) => { throw new Error(`registries: Python line ${line}: ${kind} is not allowed with credentials`); };
7
8
  function requirement(value, line, cwd) {
@@ -21,7 +22,8 @@ export function requirementsInputs(cwd, roots, validate) {
21
22
  try {
22
23
  if (!fs.realpathSync(absolute).startsWith(root + path.sep) || !fs.statSync(absolute).isFile()) refuse(line, 'escaping requirements include');
23
24
  } catch { refuse(line, 'escaping requirements include'); }
24
- source = path.relative(cwd, absolute);
25
+ // Docker build-context paths must not contain Windows escape backslashes.
26
+ source = toPosix(path.relative(cwd, absolute));
25
27
  if (seen.has(source)) return seen.get(source);
26
28
  const index = files.length;
27
29
  seen.set(source, index);
@@ -67,24 +67,36 @@ export function readRegistrySecrets(cwd) {
67
67
  const resolved = path.resolve(cwd, source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source);
68
68
  let stat, bytes;
69
69
  try {
70
- stat = fs.statSync(resolved);
70
+ stat = fs.statSync(resolved, { bigint: true });
71
71
  if (!stat.isFile()) fail(ecosystem + " credential must be a regular file");
72
72
  bytes = fs.readFileSync(resolved);
73
73
  } catch { fail(ecosystem + " credential must be an existing readable regular file"); }
74
74
  return {
75
75
  ecosystem, id: ecosystem === "go" ? "go-netrc" : ecosystem, source: resolved, format, privatePatterns,
76
76
  digest: crypto.createHash("sha256").update(bytes).digest("hex"),
77
- device: stat.dev, inode: stat.ino,
77
+ ...fileIdentity(resolved, stat),
78
78
  };
79
79
  });
80
80
  }
81
81
 
82
+ // Same file, hardlinks included. Exact (bigint) IDs, as strings: Windows file
83
+ // IDs are 64-bit (128 on ReFS) and collide once rounded to a double, which
84
+ // flagged unrelated files as the credential on CI. A filesystem that reports
85
+ // no ID (inode 0) falls back to the real path.
86
+ function fileIdentity(file, stat = fs.statSync(file, { bigint: true })) {
87
+ const real = () => { const p = fs.realpathSync.native(file); return process.platform === "win32" ? p.toLowerCase() : p; };
88
+ return stat.ino === 0n ? { device: null, inode: null, realPath: real() }
89
+ : { device: String(stat.dev), inode: String(stat.ino), realPath: null };
90
+ }
91
+
92
+ const sameFile = (a, b) => a.inode !== null ? a.device === b.device && a.inode === b.inode : a.realPath === b.realPath;
93
+
82
94
  // A selected credential must not also be a dependency input (including hardlinks).
83
95
  export function assertNoRegistryCopies(cwd, files, secrets) {
84
96
  for (const { source } of files) {
85
97
  if (!source) continue;
86
- const stat = fs.statSync(path.resolve(cwd, source));
87
- if (secrets.some((secret) => secret.device === stat.dev && secret.inode === stat.ino)) {
98
+ const input = fileIdentity(path.resolve(cwd, source));
99
+ if (secrets.some((secret) => sameFile(secret, input))) {
88
100
  fail("a credential file cannot also be a build-context input");
89
101
  }
90
102
  }