nomarmy 0.1.0-alpha.17 โ†’ 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
@@ -4,6 +4,7 @@
4
4
  <a href="https://github.com/rayson-tech/nomarmy/actions/workflows/ci.yml"><img src="https://github.com/rayson-tech/nomarmy/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
5
5
  <a href="https://www.npmjs.com/package/nomarmy"><img src="https://img.shields.io/npm/v/nomarmy/alpha?label=npm%40alpha" alt="npm"></a>
6
6
  <a href="https://github.com/rayson-tech/nomarmy/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache 2.0"></a>
7
+ <a href="https://github.com/rayson-tech/nomarmy/blob/main/docs/feature-runs.md"><img src="https://raw.githubusercontent.com/rayson-tech/nomarmy/main/.github/nomarmy-badge.svg" alt="nomArmy: AI worker claims checked on this repository"></a>
7
8
  </p>
8
9
 
9
10
  <p align="center"><em>Every byte verified.</em> ๐Ÿช</p>
@@ -15,6 +16,7 @@ AI coding workers are confident. Their "done, all tests pass" is a claim, not ev
15
16
  ## TL;DR
16
17
 
17
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).
18
20
  2. **Install and set up:**
19
21
  ```bash
20
22
  npm install -g nomarmy@alpha
@@ -50,6 +52,8 @@ Stuck? `nomarmy doctor` checks the machine and `nomarmy health` checks everythin
50
52
 
51
53
  Failing verification stays failed, unconditionally. A malformed report isn't automatically a failure: if the repository changed, nomArmy verifies independently and may recover the work. And the checks aren't the General's to waive: a repo's `.nomarmy.yml` policy (on by default for new repos) makes verification and the revert check mandatory for every job.
52
54
 
55
+ **Showing what was checked.** When a `/feature` run finishes, the General gets a "Verified by nomArmy" block for the pull request: how many "done, tests pass" claims held up, how many nomArmy caught and why, and the new tests shown to fail without their change. `nomarmy stats --share` prints the same for any period, and `nomarmy stats --badge` writes a README badge like `nomArmy | 78 claims checked ยท 11 caught`. Every number comes from the verified records.
56
+
53
57
  **Checking without building** costs nothing: `mode: verify` runs a verification profile against any branch, with no worker and no model tokens.
54
58
 
55
59
  **Want deeper checks?** Three optional [validators](https://github.com/rayson-tech/nomarmy/blob/main/docs/validators.md) go further, each only adding review flags: mutation testing (do the tests pin down the changed lines?), Jev (do a scout's citations support its findings, does a report match its diff?) and a model judge (acceptance criteria, weakened tests).
@@ -71,7 +75,7 @@ Developed and maintained by Rayson Technologies. This is an alpha (`0.1.0-alpha`
71
75
 
72
76
  | | |
73
77
  |---|---|
74
- | [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 |
75
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 |
76
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 |
77
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";
@@ -32,12 +34,30 @@ import { setupSteps, formatSetupSteps, runSetupPlaybook } from "../lib/setup-ste
32
34
  import { readUsageSnapshots } from "../lib/usage-limits.mjs";
33
35
  import { pickMachine, planResize } from "../lib/sandbox-vm.mjs";
34
36
  import { listProcesses, staleSessions, formatStaleSessions } from "../lib/stale-sessions.mjs";
37
+ import { readSetting, writeSetting, writeEnvLine, userCommonPath, userProfilePath, profilePathFor, tildePath } from "../lib/user-config.mjs";
35
38
  import { MIN_PODMAN_VM_MB } from "../lib/doctor.mjs";
36
39
  import { liveLeases } from "../lib/slots.mjs";
37
40
  import { ensureProviderConfig } from "../lib/openclaw-config.mjs";
38
41
  import { recordProbeSuccess } from "../lib/health.mjs";
39
42
  import { pruneJobRuntime } from "../lib/prune.mjs";
40
43
  import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
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();
51
+ // OpenClaw in ~/.npm-global/bin (no writable npm prefix) is found without the operator editing PATH.
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
+ }
41
61
 
42
62
  // Add a new coordinator: add its name here, teach commandExists/connectTarget
43
63
  // about it below (a JSON-file target like Cursor has no PATH binary to check
@@ -67,7 +87,7 @@ const repoDir = path.resolve(value("repo", process.cwd()));
67
87
  // through the job's requested reasoning); `--no-thinking` is always false.
68
88
  // Returns undefined when none of these flags were passed at all, so the
69
89
  // caller can tell "not touched" apart from "explicitly set".
70
- const THINKING_LEVELS = ["low", "medium", "high"];
90
+ // Every level OpenClaw accepts (lib/thinking.mjs).
71
91
  function resolveThinkingFlag() {
72
92
  const level = value("thinking");
73
93
  if (level && THINKING_LEVELS.includes(level)) return level;
@@ -155,7 +175,7 @@ Usage: nomarmy <command> [options]
155
175
  [--update-mcp] (api); --provider --model
156
176
  --owner (subscription); and optionally
157
177
  --max-concurrent --context-window
158
- --thinking [low|medium|high] --no-thinking
178
+ --thinking [minimal|low|medium|high|xhigh|adaptive|max|ultra] --no-thinking
159
179
  update <name>
160
180
  change the model (picked from what OpenClaw
161
181
  lists; a subscription gets a real test call),
@@ -239,7 +259,7 @@ Usage: nomarmy <command> [options]
239
259
  project for this repository, committed for the team
240
260
  (.mcp.json or .cursor/mcp.json, running \`nomarmy mcp\`).
241
261
  Codex has only the user scope.
242
- stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>] [--details] [--all-suggestions]
262
+ stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>] [--run <id>] [--details] [--all-suggestions] [--share] [--badge [path]]
243
263
  [--repo <path|name>] [--all-repos] [--json]
244
264
  What nomArmy's job records show for this repository (or
245
265
  all): volume by role and model, code committed, time,
@@ -248,7 +268,9 @@ Usage: nomarmy <command> [options]
248
268
  and review flags. From verified records, never reports.
249
269
  validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>
250
270
  Optional semantic checks from a model you configure with
251
- 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
252
274
  key without echoing it (or reads --key-stdin), saves it
253
275
  where only you can read it, and makes one test call. Its
254
276
  answers only add review flags, and it sends excerpts of
@@ -459,7 +481,9 @@ async function cmdInit() {
459
481
  // This file's own location, not --repo (the target repo being scanned) --
460
482
  // setup/model need to find THIS package's config/ and scripts/ as siblings
461
483
  // of bin/, the same way select-model.mjs resolves its own root.
462
- 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)), "..");
463
487
 
464
488
  /** Read one KEY=VALUE line's value, or null if the file or key doesn't exist. */
465
489
  function readEnvValue(filePath, key) {
@@ -468,15 +492,9 @@ function readEnvValue(filePath, key) {
468
492
  return m ? m[1].trim() : null;
469
493
  }
470
494
 
471
- /** Read-modify-write one KEY=VALUE line, replacing it if present, appending if not -- the exact pattern scripts/select-model.mjs already uses for config/common.env. */
472
- function writeEnvLine(filePath, key, value) {
473
- const existingText = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
474
- const line = `${key}=${value}`;
475
- const updated = new RegExp(`^${key}=.*$`, "m").test(existingText)
476
- ? existingText.replace(new RegExp(`^${key}=.*$`, "m"), line)
477
- : `${existingText.trimEnd()}\n${line}\n`.replace(/^\n/, "");
478
- fs.writeFileSync(filePath, updated);
479
- }
495
+ // Your settings live in ~/.config/nomarmy/, over the package's config/
496
+ // defaults, so an update can't reset them (lib/user-config.mjs).
497
+ const setting = (key) => readSetting(key, { nomarmyRoot });
480
498
 
481
499
  // Each entry's repo/quant/alias is verified against this project's own real
482
500
  // usage (downloaded, loaded, dispatched against), not guessed from a model
@@ -495,6 +513,14 @@ function writeEnvLine(filePath, key, value) {
495
513
  * not left to fail only at the very end via schema validation with no
496
514
  * indication of which of several answers was the problem.
497
515
  */
516
+ /** A numbered choice, asked again until it's one of 1..count (an empty or mistyped answer used to end the whole flow). */
517
+ async function askChoice(rl, count) {
518
+ const answer = await askUntilValid(rl, "Choice: ", { pattern: new RegExp(`^([1-9]|[1-9][0-9])$`), invalidMessage: `Choose a number from 1 to ${count}.` });
519
+ if (Number(answer) >= 1 && Number(answer) <= count) return Number(answer) - 1;
520
+ console.log(c.red(` โœ— Choose a number from 1 to ${count}.`));
521
+ return askChoice(rl, count);
522
+ }
523
+
498
524
  async function askUntilValid(rl, prompt, { pattern, invalidMessage, allowEmpty = false, fallback = "" }) {
499
525
  for (;;) {
500
526
  const answer = (await rl.question(c.bold(prompt))).trim();
@@ -527,15 +553,15 @@ async function askSecret(rl, prompt) {
527
553
 
528
554
  const KNOWN_MODELS = {
529
555
  default: {
530
- label: "Qwen3-Coder-Next (shipped default; no thinking mode -- 0 failures across every case tested tonight)",
556
+ label: "Qwen3-Coder-Next ยท 48 GB download ยท the most reliable in our tests (recommended)",
531
557
  repo: "Qwen/Qwen3-Coder-Next-GGUF", quant: "Q4_K_M", alias: "qwen3-coder-next", thinking: false, recommended: true,
532
558
  },
533
559
  "gpt-oss-20b": {
534
- label: "gpt-oss-20b (thinking, use reasoning: medium -- fastest of every model tested on the hardest case: 62s/0 failures; reasoning: high on the SAME ticket was the worst result measured: 318s/4 failures)",
560
+ label: "gpt-oss-20b ยท 12 GB download ยท the fastest; keep reasoning at medium",
535
561
  repo: "ggml-org/gpt-oss-20b-GGUF", quant: "MXFP4", alias: "gpt-oss-20b", thinking: true,
536
562
  },
537
563
  "qwen3.6-27b": {
538
- label: "Qwen3.6-27B (thinking, use reasoning: medium -- best measured reliability, noticeably slower per-token; reasoning: high caused a full timeout on an open-ended task)",
564
+ label: "Qwen3.6-27B ยท 17 GB download ยท very reliable, slower; keep reasoning at medium",
539
565
  repo: "unsloth/Qwen3.6-27B-GGUF", quant: "Q4_K_M", alias: "qwen3.6-27b", thinking: true,
540
566
  },
541
567
  };
@@ -588,14 +614,14 @@ function defaultLocalProfile() {
588
614
  // `install`: the one setup recorded, else the install marker's, else the
589
615
  // execution mode's, else (a working local install) install.sh's default.
590
616
  function setupProfileState() {
591
- const common = path.join(nomarmyRoot, "config", "common.env");
592
- const chosen = readEnvValue(common, "NOMARMY_SETUP_PROFILE");
617
+ const common = userCommonPath();
618
+ const chosen = setting("NOMARMY_SETUP_PROFILE");
593
619
  const probeCommand = (binary, args) => {
594
620
  const result = spawnSync(binary, args, { encoding: "utf8", timeout: 10000 });
595
621
  return result.status === 0 ? result.stdout.trim() : "";
596
622
  };
597
- const profileFile = chosen ? path.join(nomarmyRoot, "config", "profiles", `${chosen}.env`) : null;
598
- const root = (process.env.NOMARMY_INSTALL_ROOT || (profileFile && readEnvValue(profileFile, "NOMARMY_INSTALL_ROOT")) || readEnvValue(common, "NOMARMY_INSTALL_ROOT") || "$HOME/.local/share/nomarmy-local-agents").replace(/\$HOME|\$\{HOME\}/g, os.homedir());
623
+ const profileFile = profilePathFor(chosen, { nomarmyRoot });
624
+ const root = (process.env.NOMARMY_INSTALL_ROOT || (profileFile && readEnvValue(profileFile, "NOMARMY_INSTALL_ROOT")) || setting("NOMARMY_INSTALL_ROOT") || "$HOME/.local/share/nomarmy-local-agents").replace(/\$HOME|\$\{HOME\}/g, os.homedir());
599
625
  let marker = null;
600
626
  try { marker = JSON.parse(fs.readFileSync(path.join(root, "install.json"), "utf8")); } catch (error) { if (error.code !== "ENOENT") marker = {}; }
601
627
  const version = probeCommand("openclaw", ["--version"]);
@@ -603,7 +629,7 @@ function setupProfileState() {
603
629
  const registered = !marker && Boolean(version) && ["claude", "codex"].some((name) => spawnSync(name, ["mcp", "get", "nomarmy-local-worker"], { stdio: "ignore", timeout: 10000 }).status === 0);
604
630
  // An install from before setup recorded its profile: the marker's, else the
605
631
  // execution mode's, else (a working local install) install.sh's default.
606
- const execution = readEnvValue(common, "NOMARMY_EXECUTION");
632
+ const execution = setting("NOMARMY_EXECUTION");
607
633
  const profile = chosen ?? marker?.profile
608
634
  ?? (["hosted", "remote", "bedrock"].includes(execution) ? execution : null)
609
635
  ?? (registered ? defaultLocalProfile() : null);
@@ -614,7 +640,7 @@ function setupChecklist() {
614
640
  const { common, profile, marker, version, registered } = setupProfileState();
615
641
  const project = setupProjectDir();
616
642
  return setupSteps({
617
- mode: () => ({ profile, host: readEnvValue(common, "NOMARMY_LLAMA_HOST"), port: readEnvValue(common, "NOMARMY_LLAMA_PORT") }),
643
+ mode: () => ({ profile, host: setting("NOMARMY_LLAMA_HOST"), port: setting("NOMARMY_LLAMA_PORT") }),
618
644
  install: () => ({ marker, version, registered }),
619
645
  agents: () => Object.keys(loadAgents(globalConfigDir()).agents),
620
646
  army: () => {
@@ -647,11 +673,27 @@ function cmdInstall() {
647
673
  }
648
674
 
649
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
+ }
650
683
  if (flag("status") || (!flag("choose") && !flag("hosted") && !flag("llama-url") && !json)) {
651
684
  if (flag("status") || !process.stdin.isTTY) {
652
685
  const steps = setupChecklist();
653
686
  return json ? out(steps) : console.log(formatSetupSteps(steps));
654
687
  }
688
+ // What's ahead, before the first question: a practice run found setup
689
+ // felt heavy mostly because nothing said how much of it there was.
690
+ if (setupChecklist().find((step) => step.status === "todo")?.id === "mode") {
691
+ console.log(`${c.bold("๐Ÿช nomArmy setup")}\n\nAbout 10 to 20 minutes, one step at a time; stop any time and ${c.cyan("nomarmy setup")} picks up where you left off.`);
692
+ console.log(c.dim(" 1. Where models run: your API and subscription agents, or a model on this machine"));
693
+ console.log(c.dim(" 2. Install: OpenClaw (runs the models) and Podman (the sandbox every job runs in; on macOS a small VM, about a 1 GB download)"));
694
+ console.log(c.dim(" 3. Agents: log in to your subscription or add an API key"));
695
+ console.log(c.dim(" 4. Roles, this repo's checks, and a final health check\n"));
696
+ }
655
697
  process.exitCode = await runSetupPlaybook({
656
698
  evaluate: setupChecklist, print: console.log, run: runSetupChild,
657
699
  ask: async (prompt) => {
@@ -670,10 +712,8 @@ async function cmdSetup() {
670
712
  if (choice === "1") argv.push("--hosted");
671
713
  if (choice === "3") argv.push("--llama-url", (await rl.question("Server URL: ")).trim());
672
714
  if (choice === "4") {
673
- const common = path.join(nomarmyRoot, "config", "common.env");
674
- fs.mkdirSync(path.dirname(common), { recursive: true });
675
- writeEnvLine(common, "NOMARMY_EXECUTION", "bedrock");
676
- writeEnvLine(common, "NOMARMY_SETUP_PROFILE", "bedrock");
715
+ writeSetting("NOMARMY_EXECUTION", "bedrock");
716
+ writeSetting("NOMARMY_SETUP_PROFILE", "bedrock");
677
717
  console.log("Next: nomarmy install");
678
718
  return;
679
719
  }
@@ -684,7 +724,7 @@ async function cmdSetup() {
684
724
  if (hosted && hasLlamaUrl) throw new Error("--hosted and --llama-url cannot be used together.");
685
725
 
686
726
  if (hosted || hasLlamaUrl) {
687
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
727
+ const commonPath = userCommonPath();
688
728
 
689
729
  if (hosted) {
690
730
  const next = [
@@ -692,11 +732,10 @@ async function cmdSetup() {
692
732
  "nomarmy agents add",
693
733
  "nomarmy army init --agent <name>",
694
734
  ];
695
- fs.mkdirSync(path.dirname(commonPath), { recursive: true });
696
735
  writeEnvLine(commonPath, "NOMARMY_EXECUTION", "hosted");
697
736
  writeEnvLine(commonPath, "NOMARMY_SETUP_PROFILE", "hosted");
698
737
  if (json) return out({ written: commonPath, execution: "hosted", next });
699
- console.log(c.green(`โœ“ Wrote NOMARMY_EXECUTION=hosted to ${path.relative(nomarmyRoot, commonPath)}.`));
738
+ console.log(c.green(`โœ“ Jobs will run on your API and subscription agents (saved in ${tildePath(commonPath)}).`));
700
739
  console.log(c.dim("\nNext:"));
701
740
  for (const step of next) console.log(` ${c.bold(step)}`);
702
741
  return;
@@ -724,7 +763,7 @@ async function cmdSetup() {
724
763
  console.log(reachable
725
764
  ? c.green(`โœ“ llama-server is reachable at ${healthUrl}.`)
726
765
  : c.yellow(`โš  llama-server is not reachable at ${healthUrl} right now; configuration was still written.`));
727
- console.log(c.green(`โœ“ Wrote the remote llama-server settings to ${path.relative(nomarmyRoot, commonPath)}.`));
766
+ console.log(c.green(`โœ“ Wrote the remote llama-server settings to ${tildePath(commonPath)}.`));
728
767
  console.log(c.dim("\nNext:"));
729
768
  console.log(` ${c.bold(next)}`);
730
769
  return;
@@ -753,10 +792,10 @@ async function cmdSetup() {
753
792
  console.log(isCloud
754
793
  ? `Execution is '${execution}' -- hosted inference, local hardware does not bound this.\n`
755
794
  : `Hardware: ${c.cyan(`${hardware.platform}/${hardware.arch}`)}, ${hardware.cpu?.logicalCores ?? "?"} logical cores, ${(hardware.memory?.totalBytes / 1024 ** 3).toFixed(1)} GiB RAM\n`);
756
- console.log(`More noms ${c.dim(`(confidence: ${res.confidence})`)}: ${c.green(res.summary ?? JSON.stringify(res.env))}`);
757
- if (res.nominal && !res.nominal.sameAsRecommended) {
758
- console.log(`Nominal: ${res.nominal.fits ? c.dim(res.nominal.summary) : c.red(`${res.nominal.summary} DOES NOT FIT either -- nothing on this machine does.`)}`);
759
- }
795
+ // Plain words; the exact settings are shown before anything is written.
796
+ const plain = (env) => { const n = Number(env?.NOMARMY_MAX_WORKERS ?? env?.NOMARMY_LLAMA_PARALLEL ?? 1), ctx = Math.round(Number(env?.NOMARMY_LLAMA_CONTEXT ?? 0) / Math.max(1, Number(env?.NOMARMY_LLAMA_PARALLEL ?? 1)) / 1024); return `${n} local job${n === 1 ? "" : "s"} at a time${ctx ? `, ${ctx}K tokens of context each` : ""}`; };
797
+ console.log(`This machine fits ${c.green(plain(res.env))}${res.confidence === "low" ? c.dim(" (an estimate)") : ""}.`);
798
+ if (res.nominal && !res.nominal.fits) console.log(c.red("Even one local job doesn't fit in this machine's memory: choose hosted instead (nomarmy setup --choose)."));
760
799
  }
761
800
 
762
801
  // "More noms" fits as many noms as memory allows; "nominal" is 1 worker
@@ -774,8 +813,8 @@ async function cmdSetup() {
774
813
  if (sizingTier !== "more" && sizingTier !== "nominal") throw new Error('--tier must be "more" or "nominal".');
775
814
  } else {
776
815
  console.log(`\n${c.bold("Which sizing?")}`);
777
- console.log(` ${c.cyan("1.")} More noms -- as many as fit in memory`);
778
- console.log(` ${c.cyan("2.")} Nominal -- 1 nom, matching this project's own shipped profiles`);
816
+ console.log(` ${c.cyan("1.")} As many at once as fit in memory`);
817
+ console.log(` ${c.cyan("2.")} One at a time (lighter on the machine)`);
779
818
  const choice = (await rl.question(c.bold("Choice [1]: "))).trim() || "1";
780
819
  sizingTier = choice === "2" ? "nominal" : "more";
781
820
  }
@@ -794,8 +833,8 @@ async function cmdSetup() {
794
833
  }
795
834
 
796
835
  const profileName = nonInteractive ? value("profile-name") : (await rl.question(`\nProfile name [${hardware?.appleSilicon ? "macbook-pro" : "custom"}]: `)).trim() || (hardware?.appleSilicon ? "macbook-pro" : "custom");
797
- const profilePath = path.join(nomarmyRoot, "config", "profiles", `${profileName}.env`);
798
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
836
+ const profilePath = userProfilePath(profileName);
837
+ const commonPath = userCommonPath();
799
838
 
800
839
  const profileWrites = { ...sizingEnv };
801
840
  if (!isCloud) {
@@ -808,10 +847,10 @@ async function cmdSetup() {
808
847
  }
809
848
 
810
849
  if (!json) {
811
- console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, profilePath)}:`));
850
+ console.log(c.bold(`\nAbout to write ${tildePath(profilePath)}:`));
812
851
  for (const [k, v] of Object.entries(profileWrites)) console.log(c.dim(` ${k}=${v}`));
813
852
  if (model?.kind === "known") {
814
- console.log(c.bold(`\nAnd ${path.relative(nomarmyRoot, commonPath)}:`));
853
+ console.log(c.bold(`\nAnd ${tildePath(commonPath)}:`));
815
854
  console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}`));
816
855
  console.log(c.dim(` NOMARMY_MODEL_QUANT=${model.quant}`));
817
856
  console.log(c.dim(` NOMARMY_MODEL_ALIAS=${model.alias}`));
@@ -819,8 +858,9 @@ async function cmdSetup() {
819
858
  console.log(c.dim(` NOMARMY_MODEL_THINKING=${model.thinking}`));
820
859
  }
821
860
  if (!nonInteractive) {
822
- const answer = (await rl.question(c.bold("\nWrite this configuration? [y/N] "))).trim().toLowerCase();
823
- if (answer !== "y") { console.log(c.dim("Canceled; nothing written.")); return; }
861
+ // Yes by default: someone who accepted every suggestion shouldn't lose it all at the last Enter.
862
+ const answer = (await rl.question(c.bold("\nWrite this configuration? [Y/n] "))).trim().toLowerCase();
863
+ if (answer === "n" || answer === "no") { console.log(c.dim("Canceled; nothing written.")); return; }
824
864
  }
825
865
  }
826
866
 
@@ -842,7 +882,7 @@ async function cmdSetup() {
842
882
  writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", model.alias);
843
883
  writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
844
884
  } else if (model?.kind === "search") {
845
- const searchedAlias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
885
+ const searchedAlias = setting("NOMARMY_MODEL_ALIAS");
846
886
  if (searchedAlias) {
847
887
  writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", searchedAlias);
848
888
  writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
@@ -851,7 +891,7 @@ async function cmdSetup() {
851
891
 
852
892
  const installCmd = "nomarmy install";
853
893
  if (json) return out({ written: { profile: profilePath, common: model?.kind === "known" ? commonPath : null }, env: profileWrites, sizingTier, installCommand: installCmd });
854
- console.log(c.green(`\nโœ“ Wrote ${path.relative(nomarmyRoot, profilePath)}${model?.kind === "known" ? ` and ${path.relative(nomarmyRoot, commonPath)}` : ""}.`));
894
+ console.log(c.green(`\nโœ“ Wrote ${tildePath(profilePath)}${model?.kind === "known" ? ` and ${tildePath(commonPath)}` : ""}.`));
855
895
  console.log(c.dim("\nThis proposes; it does not install. Run:\n"));
856
896
  console.log(` ${c.bold(installCmd)}\n`);
857
897
  } finally {
@@ -880,7 +920,7 @@ function restartInference() {
880
920
  runScript("start-inference.sh", []);
881
921
  }
882
922
  async function cmdModel() {
883
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
923
+ const commonPath = userCommonPath();
884
924
  if (json) {
885
925
  const which = value("model");
886
926
  if (!KNOWN_MODELS[which]) throw new Error(`--json requires --model one of ${Object.keys(KNOWN_MODELS).join(", ")} (Hugging Face search is interactive-only).`);
@@ -904,17 +944,17 @@ async function cmdModel() {
904
944
  const model = await chooseModel(rl);
905
945
  let alias;
906
946
  if (model.kind === "search") {
907
- console.log(c.green("\nโœ“ Done") + " -- config/common.env was already updated by the search above.");
908
- alias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
947
+ console.log(c.green("\nโœ“ Done") + ` -- ${tildePath(commonPath)} was already updated by the search above.`);
948
+ alias = setting("NOMARMY_MODEL_ALIAS");
909
949
  } else {
910
- console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, commonPath)}:`));
950
+ console.log(c.bold(`\nAbout to write ${tildePath(commonPath)}:`));
911
951
  console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}\n NOMARMY_MODEL_QUANT=${model.quant}\n NOMARMY_MODEL_ALIAS=${model.alias}`));
912
952
  const answer = (await rl.question(c.bold("\nApply this model configuration? [y/N] "))).trim().toLowerCase();
913
953
  if (answer !== "y") { console.log(c.dim("Canceled; nothing changed.")); return; }
914
954
  writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
915
955
  writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
916
956
  writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", model.alias);
917
- console.log(c.green(`โœ“ Wrote ${path.relative(nomarmyRoot, commonPath)}.`));
957
+ console.log(c.green(`โœ“ Wrote ${tildePath(commonPath)}.`));
918
958
  alias = model.alias;
919
959
  }
920
960
  if (alias) {
@@ -1371,8 +1411,7 @@ async function cmdAgentsAdd() {
1371
1411
  console.log(` ${c.cyan("1.")} local ${c.dim("the local model on this machine (no per-token bill, private; slower)")}`);
1372
1412
  console.log(` ${c.cyan("2.")} api ${c.dim("a metered API key (xAI, OpenAI, Anthropic, DeepSeek, ...)")}`);
1373
1413
  console.log(` ${c.cyan("3.")} subscription ${c.dim("your own Claude, ChatGPT or Muse Code plan (never shared)")}`);
1374
- kind = AGENT_KINDS[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1375
- if (!kind) throw new Error("Not a valid choice.");
1414
+ kind = AGENT_KINDS[await askChoice(rl, AGENT_KINDS.length)];
1376
1415
  }
1377
1416
  if (kind === "local") return await addLocalAgent(rl, agents);
1378
1417
  if (kind === "api") return await addApiAgent(rl, agents);
@@ -1409,8 +1448,7 @@ async function addLocalAgent(rl, agents) {
1409
1448
  async function addApiAgent(rl, agents) {
1410
1449
  console.log("\n" + c.bold("Which provider?"));
1411
1450
  API_PROVIDER_TYPES.forEach((t, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${KNOWN_PROVIDERS[t]?.label ?? t}`));
1412
- const provider = API_PROVIDER_TYPES[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1413
- if (!provider) throw new Error("Not a valid choice.");
1451
+ const provider = API_PROVIDER_TYPES[await askChoice(rl, API_PROVIDER_TYPES.length)];
1414
1452
  const info = KNOWN_PROVIDERS[provider] ?? {};
1415
1453
  const agent = { kind: "api", provider };
1416
1454
 
@@ -1485,8 +1523,7 @@ async function addSubscriptionAgent(rl, agents) {
1485
1523
  if (!SUBSCRIPTION_VENDORS[vendorKey]) {
1486
1524
  console.log("\n" + c.bold("Which subscription?"));
1487
1525
  vendorKeys.forEach((k, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${SUBSCRIPTION_VENDORS[k].label}`));
1488
- vendorKey = vendorKeys[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1489
- if (!vendorKey) throw new Error(`Not a valid choice. Supported: ${vendorKeys.join(", ")}. DeepSeek and others without a plan are api agents.`);
1526
+ vendorKey = vendorKeys[await askChoice(rl, vendorKeys.length)];
1490
1527
  }
1491
1528
  const vendor = SUBSCRIPTION_VENDORS[vendorKey];
1492
1529
 
@@ -1860,6 +1897,9 @@ async function cmdConnect() {
1860
1897
  const requested = argv.slice(1).filter((a, i) => !a.startsWith("--") && !flagValues.has(i + 1));
1861
1898
  const scope = value("scope", "user");
1862
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");
1863
1903
  let projectDir = null;
1864
1904
  if (scope !== "user") {
1865
1905
  try { projectDir = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
@@ -1894,6 +1934,17 @@ async function cmdConnect() {
1894
1934
  }
1895
1935
  try {
1896
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
+ }
1897
1948
  const result = connectTarget(target, { nomarmyRoot, run, scope, projectDir });
1898
1949
  results.push({ target, connected: true, ...result });
1899
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.`}`));
@@ -2045,7 +2096,7 @@ async function maybeRemoveAgentsDir({ force }) {
2045
2096
  * needs manual cleanup -- an honest, bounded scope beats guessing at which
2046
2097
  * cache entries are "ours". */
2047
2098
  function resolveConfiguredModelRepos() {
2048
- const repo = readEnvValue(path.join(nomarmyRoot, "config", "common.env"), "NOMARMY_MODEL_REPO");
2099
+ const repo = setting("NOMARMY_MODEL_REPO");
2049
2100
  return repo ? [repo] : [];
2050
2101
  }
2051
2102
 
@@ -2757,6 +2808,25 @@ async function cmdStatusline() {
2757
2808
  process.stdout.write(`${statusLineText({ session })}\n`);
2758
2809
  }
2759
2810
 
2811
+ // `stats --share`: markdown for a PR description or README; `--badge [path]`:
2812
+ // an SVG badge to commit, with the README line for it (lib/share.mjs).
2813
+ async function shareStats(stats) {
2814
+ const { shareMarkdown, badgeSvg, badgeMarkdown } = await import("../lib/share.mjs");
2815
+ const scope = value("run") ? "this feature run" : value("since") ? `since ${value("since")}` : null;
2816
+ if (flag("share")) console.log(shareMarkdown(stats, { scope }));
2817
+ if (flag("badge")) {
2818
+ const given = value("badge");
2819
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
2820
+ const root = top.status === 0 ? top.stdout.trim() : process.cwd();
2821
+ const file = path.resolve(root, given ?? path.join(".github", "nomarmy-badge.svg"));
2822
+ fs.mkdirSync(path.dirname(file), { recursive: true });
2823
+ fs.writeFileSync(file, badgeSvg(stats));
2824
+ const rel = path.relative(root, file).split(path.sep).join("/");
2825
+ console.log(`${flag("share") ? "\n" : ""}${c.green("โœ“")} Wrote ${rel}. Commit it and add this to your README:\n\n ${badgeMarkdown(rel)}\n`);
2826
+ console.log(c.dim("Re-run nomarmy stats --badge after more jobs to refresh the numbers."));
2827
+ }
2828
+ }
2829
+
2760
2830
  function cmdStats() {
2761
2831
  const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
2762
2832
  const records = loadJobRecords(path.join(stateRoot, "jobs"));
@@ -2768,8 +2838,9 @@ function cmdStats() {
2768
2838
  }
2769
2839
  let agentFor = () => null;
2770
2840
  try { agentFor = agentLookup(loadAgents(globalConfigDir()).agents, agentProviderId); } catch { /* no agents.yml: commands name <agent> */ }
2771
- const stats = computeStats(records, { repo, sinceMs: parseSince(value("since")), untilMs: parseSince(value("until")), role: value("role"), model: value("model"), agentFor, allSuggestions: flag("all-suggestions") });
2841
+ const stats = computeStats(records, { repo, sinceMs: parseSince(value("since")), untilMs: parseSince(value("until")), role: value("role"), model: value("model"), runId: value("run"), agentFor, allSuggestions: flag("all-suggestions") });
2772
2842
  if (json) return out(stats);
2843
+ if (flag("share") || flag("badge")) return shareStats(stats);
2773
2844
  console.log(flag("details") ? formatStats(stats) : formatStatsSummary(stats, { c }));
2774
2845
  }
2775
2846
 
@@ -2857,16 +2928,58 @@ async function cmdValidatorsJudge(sub) {
2857
2928
  const resolve = () => judgeSettings({ agents, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
2858
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") });
2859
2930
  if (sub === "add") {
2860
- const agent = value("agent"), model = value("model");
2861
- 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
+ }
2862
2945
  if (!agents[agent]) throw new Error(`"${agent}" isn't an agent in agents.yml. Agents: ${Object.keys(agents).join(", ") || "(none)"}`);
2863
- 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).`);
2864
- 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 });
2865
2971
  const settings = resolve();
2866
2972
  if (settings?.problem) throw new Error(settings.problem);
2867
2973
  const test = await probe(settings);
2868
2974
  if (json) return out({ saved: true, configPath: saved.configPath, test: test.ok ? "pass" : test.refused ? "refused" : "inconclusive", reason: test.reason });
2869
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."));
2870
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"}`));
2871
2984
  if (!test.ok) process.exitCode = 1;
2872
2985
  return;
@@ -2900,12 +3013,24 @@ function cmdMcp() {
2900
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) };
2901
3014
  // doctor command
2902
3015
  async function cmdDoctor() {
3016
+ if (windowsFrontEnd()) {
3017
+ process.exitCode = windowsDoctor({ json, argv });
3018
+ return;
3019
+ }
2903
3020
  // Import lazily to avoid circular dependencies
2904
3021
  const { runDoctor } = await import("../lib/doctor.mjs");
2905
3022
  await runDoctor({ json, exit: true, env: installEnv() });
2906
3023
  }
2907
3024
  commands.doctor = cmdDoctor;
2908
- if (!command || flag("help") || !commands[command]) usage(command && !commands[command] ? 2 : 0);
3025
+ if (windowsFrontEnd() && windowsPlan(argv) === "FORWARD") {
3026
+ process.exit(windowsForward(argv));
3027
+ }
3028
+ if (!command && !flag("help")) {
3029
+ // New users typed `nomarmy` and got the whole command reference.
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`);
3031
+ process.exit(0);
3032
+ }
3033
+ if (flag("help") || !commands[command]) usage(command && !commands[command] ? 2 : 0);
2909
3034
 
2910
3035
  try {
2911
3036
  await commands[command]();
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.