nomarmy 0.1.0-alpha.16 → 0.1.0-alpha.18

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>
@@ -50,6 +51,8 @@ Stuck? `nomarmy doctor` checks the machine and `nomarmy health` checks everythin
50
51
 
51
52
  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
53
 
54
+ **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.
55
+
53
56
  **Checking without building** costs nothing: `mode: verify` runs a verification profile against any branch, with no worker and no model tokens.
54
57
 
55
58
  **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).
package/bin/nomarmy.mjs CHANGED
@@ -20,7 +20,7 @@ import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf
20
20
  import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
21
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, agentLookup } from "../lib/stats.mjs";
23
+ import { loadJobRecords, computeStats, formatStats, formatStatsSummary, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
24
24
  import { requestJobStop } from "../lib/openclaw-run.mjs";
25
25
  import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings } from "../lib/validators.mjs";
26
26
  import { probeModel } from "../lib/model-probe.mjs";
@@ -31,12 +31,17 @@ import { parseLlamaUrl } from "../lib/execution.mjs";
31
31
  import { setupSteps, formatSetupSteps, runSetupPlaybook } from "../lib/setup-steps.mjs";
32
32
  import { readUsageSnapshots } from "../lib/usage-limits.mjs";
33
33
  import { pickMachine, planResize } from "../lib/sandbox-vm.mjs";
34
+ import { listProcesses, staleSessions, formatStaleSessions } from "../lib/stale-sessions.mjs";
35
+ import { readSetting, writeSetting, writeEnvLine, userCommonPath, userProfilePath, profilePathFor, tildePath } from "../lib/user-config.mjs";
34
36
  import { MIN_PODMAN_VM_MB } from "../lib/doctor.mjs";
35
37
  import { liveLeases } from "../lib/slots.mjs";
36
38
  import { ensureProviderConfig } from "../lib/openclaw-config.mjs";
37
39
  import { recordProbeSuccess } from "../lib/health.mjs";
38
40
  import { pruneJobRuntime } from "../lib/prune.mjs";
39
41
  import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
42
+ import { ensureOpenClawOnPath } from "../lib/openclaw-path.mjs";
43
+ // OpenClaw in ~/.npm-global/bin (no writable npm prefix) is found without the operator editing PATH.
44
+ ensureOpenClawOnPath();
40
45
 
41
46
  // Add a new coordinator: add its name here, teach commandExists/connectTarget
42
47
  // about it below (a JSON-file target like Cursor has no PATH binary to check
@@ -238,7 +243,7 @@ Usage: nomarmy <command> [options]
238
243
  project for this repository, committed for the team
239
244
  (.mcp.json or .cursor/mcp.json, running \`nomarmy mcp\`).
240
245
  Codex has only the user scope.
241
- stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>]
246
+ stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>] [--run <id>] [--details] [--all-suggestions] [--share] [--badge [path]]
242
247
  [--repo <path|name>] [--all-repos] [--json]
243
248
  What nomArmy's job records show for this repository (or
244
249
  all): volume by role and model, code committed, time,
@@ -467,15 +472,9 @@ function readEnvValue(filePath, key) {
467
472
  return m ? m[1].trim() : null;
468
473
  }
469
474
 
470
- /** 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. */
471
- function writeEnvLine(filePath, key, value) {
472
- const existingText = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
473
- const line = `${key}=${value}`;
474
- const updated = new RegExp(`^${key}=.*$`, "m").test(existingText)
475
- ? existingText.replace(new RegExp(`^${key}=.*$`, "m"), line)
476
- : `${existingText.trimEnd()}\n${line}\n`.replace(/^\n/, "");
477
- fs.writeFileSync(filePath, updated);
478
- }
475
+ // Your settings live in ~/.config/nomarmy/, over the package's config/
476
+ // defaults, so an update can't reset them (lib/user-config.mjs).
477
+ const setting = (key) => readSetting(key, { nomarmyRoot });
479
478
 
480
479
  // Each entry's repo/quant/alias is verified against this project's own real
481
480
  // usage (downloaded, loaded, dispatched against), not guessed from a model
@@ -494,6 +493,14 @@ function writeEnvLine(filePath, key, value) {
494
493
  * not left to fail only at the very end via schema validation with no
495
494
  * indication of which of several answers was the problem.
496
495
  */
496
+ /** A numbered choice, asked again until it's one of 1..count (an empty or mistyped answer used to end the whole flow). */
497
+ async function askChoice(rl, count) {
498
+ const answer = await askUntilValid(rl, "Choice: ", { pattern: new RegExp(`^([1-9]|[1-9][0-9])$`), invalidMessage: `Choose a number from 1 to ${count}.` });
499
+ if (Number(answer) >= 1 && Number(answer) <= count) return Number(answer) - 1;
500
+ console.log(c.red(` ✗ Choose a number from 1 to ${count}.`));
501
+ return askChoice(rl, count);
502
+ }
503
+
497
504
  async function askUntilValid(rl, prompt, { pattern, invalidMessage, allowEmpty = false, fallback = "" }) {
498
505
  for (;;) {
499
506
  const answer = (await rl.question(c.bold(prompt))).trim();
@@ -526,15 +533,15 @@ async function askSecret(rl, prompt) {
526
533
 
527
534
  const KNOWN_MODELS = {
528
535
  default: {
529
- label: "Qwen3-Coder-Next (shipped default; no thinking mode -- 0 failures across every case tested tonight)",
536
+ label: "Qwen3-Coder-Next · 48 GB download · the most reliable in our tests (recommended)",
530
537
  repo: "Qwen/Qwen3-Coder-Next-GGUF", quant: "Q4_K_M", alias: "qwen3-coder-next", thinking: false, recommended: true,
531
538
  },
532
539
  "gpt-oss-20b": {
533
- 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)",
540
+ label: "gpt-oss-20b · 12 GB download · the fastest; keep reasoning at medium",
534
541
  repo: "ggml-org/gpt-oss-20b-GGUF", quant: "MXFP4", alias: "gpt-oss-20b", thinking: true,
535
542
  },
536
543
  "qwen3.6-27b": {
537
- 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)",
544
+ label: "Qwen3.6-27B · 17 GB download · very reliable, slower; keep reasoning at medium",
538
545
  repo: "unsloth/Qwen3.6-27B-GGUF", quant: "Q4_K_M", alias: "qwen3.6-27b", thinking: true,
539
546
  },
540
547
  };
@@ -587,14 +594,14 @@ function defaultLocalProfile() {
587
594
  // `install`: the one setup recorded, else the install marker's, else the
588
595
  // execution mode's, else (a working local install) install.sh's default.
589
596
  function setupProfileState() {
590
- const common = path.join(nomarmyRoot, "config", "common.env");
591
- const chosen = readEnvValue(common, "NOMARMY_SETUP_PROFILE");
597
+ const common = userCommonPath();
598
+ const chosen = setting("NOMARMY_SETUP_PROFILE");
592
599
  const probeCommand = (binary, args) => {
593
600
  const result = spawnSync(binary, args, { encoding: "utf8", timeout: 10000 });
594
601
  return result.status === 0 ? result.stdout.trim() : "";
595
602
  };
596
- const profileFile = chosen ? path.join(nomarmyRoot, "config", "profiles", `${chosen}.env`) : null;
597
- 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());
603
+ const profileFile = profilePathFor(chosen, { nomarmyRoot });
604
+ 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());
598
605
  let marker = null;
599
606
  try { marker = JSON.parse(fs.readFileSync(path.join(root, "install.json"), "utf8")); } catch (error) { if (error.code !== "ENOENT") marker = {}; }
600
607
  const version = probeCommand("openclaw", ["--version"]);
@@ -602,7 +609,7 @@ function setupProfileState() {
602
609
  const registered = !marker && Boolean(version) && ["claude", "codex"].some((name) => spawnSync(name, ["mcp", "get", "nomarmy-local-worker"], { stdio: "ignore", timeout: 10000 }).status === 0);
603
610
  // An install from before setup recorded its profile: the marker's, else the
604
611
  // execution mode's, else (a working local install) install.sh's default.
605
- const execution = readEnvValue(common, "NOMARMY_EXECUTION");
612
+ const execution = setting("NOMARMY_EXECUTION");
606
613
  const profile = chosen ?? marker?.profile
607
614
  ?? (["hosted", "remote", "bedrock"].includes(execution) ? execution : null)
608
615
  ?? (registered ? defaultLocalProfile() : null);
@@ -613,7 +620,7 @@ function setupChecklist() {
613
620
  const { common, profile, marker, version, registered } = setupProfileState();
614
621
  const project = setupProjectDir();
615
622
  return setupSteps({
616
- mode: () => ({ profile, host: readEnvValue(common, "NOMARMY_LLAMA_HOST"), port: readEnvValue(common, "NOMARMY_LLAMA_PORT") }),
623
+ mode: () => ({ profile, host: setting("NOMARMY_LLAMA_HOST"), port: setting("NOMARMY_LLAMA_PORT") }),
617
624
  install: () => ({ marker, version, registered }),
618
625
  agents: () => Object.keys(loadAgents(globalConfigDir()).agents),
619
626
  army: () => {
@@ -651,6 +658,15 @@ async function cmdSetup() {
651
658
  const steps = setupChecklist();
652
659
  return json ? out(steps) : console.log(formatSetupSteps(steps));
653
660
  }
661
+ // What's ahead, before the first question: a practice run found setup
662
+ // felt heavy mostly because nothing said how much of it there was.
663
+ if (setupChecklist().find((step) => step.status === "todo")?.id === "mode") {
664
+ 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.`);
665
+ console.log(c.dim(" 1. Where models run: your API and subscription agents, or a model on this machine"));
666
+ 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)"));
667
+ console.log(c.dim(" 3. Agents: log in to your subscription or add an API key"));
668
+ console.log(c.dim(" 4. Roles, this repo's checks, and a final health check\n"));
669
+ }
654
670
  process.exitCode = await runSetupPlaybook({
655
671
  evaluate: setupChecklist, print: console.log, run: runSetupChild,
656
672
  ask: async (prompt) => {
@@ -669,10 +685,8 @@ async function cmdSetup() {
669
685
  if (choice === "1") argv.push("--hosted");
670
686
  if (choice === "3") argv.push("--llama-url", (await rl.question("Server URL: ")).trim());
671
687
  if (choice === "4") {
672
- const common = path.join(nomarmyRoot, "config", "common.env");
673
- fs.mkdirSync(path.dirname(common), { recursive: true });
674
- writeEnvLine(common, "NOMARMY_EXECUTION", "bedrock");
675
- writeEnvLine(common, "NOMARMY_SETUP_PROFILE", "bedrock");
688
+ writeSetting("NOMARMY_EXECUTION", "bedrock");
689
+ writeSetting("NOMARMY_SETUP_PROFILE", "bedrock");
676
690
  console.log("Next: nomarmy install");
677
691
  return;
678
692
  }
@@ -683,7 +697,7 @@ async function cmdSetup() {
683
697
  if (hosted && hasLlamaUrl) throw new Error("--hosted and --llama-url cannot be used together.");
684
698
 
685
699
  if (hosted || hasLlamaUrl) {
686
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
700
+ const commonPath = userCommonPath();
687
701
 
688
702
  if (hosted) {
689
703
  const next = [
@@ -691,11 +705,10 @@ async function cmdSetup() {
691
705
  "nomarmy agents add",
692
706
  "nomarmy army init --agent <name>",
693
707
  ];
694
- fs.mkdirSync(path.dirname(commonPath), { recursive: true });
695
708
  writeEnvLine(commonPath, "NOMARMY_EXECUTION", "hosted");
696
709
  writeEnvLine(commonPath, "NOMARMY_SETUP_PROFILE", "hosted");
697
710
  if (json) return out({ written: commonPath, execution: "hosted", next });
698
- console.log(c.green(`✓ Wrote NOMARMY_EXECUTION=hosted to ${path.relative(nomarmyRoot, commonPath)}.`));
711
+ console.log(c.green(`✓ Jobs will run on your API and subscription agents (saved in ${tildePath(commonPath)}).`));
699
712
  console.log(c.dim("\nNext:"));
700
713
  for (const step of next) console.log(` ${c.bold(step)}`);
701
714
  return;
@@ -723,7 +736,7 @@ async function cmdSetup() {
723
736
  console.log(reachable
724
737
  ? c.green(`✓ llama-server is reachable at ${healthUrl}.`)
725
738
  : c.yellow(`⚠ llama-server is not reachable at ${healthUrl} right now; configuration was still written.`));
726
- console.log(c.green(`✓ Wrote the remote llama-server settings to ${path.relative(nomarmyRoot, commonPath)}.`));
739
+ console.log(c.green(`✓ Wrote the remote llama-server settings to ${tildePath(commonPath)}.`));
727
740
  console.log(c.dim("\nNext:"));
728
741
  console.log(` ${c.bold(next)}`);
729
742
  return;
@@ -752,10 +765,10 @@ async function cmdSetup() {
752
765
  console.log(isCloud
753
766
  ? `Execution is '${execution}' -- hosted inference, local hardware does not bound this.\n`
754
767
  : `Hardware: ${c.cyan(`${hardware.platform}/${hardware.arch}`)}, ${hardware.cpu?.logicalCores ?? "?"} logical cores, ${(hardware.memory?.totalBytes / 1024 ** 3).toFixed(1)} GiB RAM\n`);
755
- console.log(`More noms ${c.dim(`(confidence: ${res.confidence})`)}: ${c.green(res.summary ?? JSON.stringify(res.env))}`);
756
- if (res.nominal && !res.nominal.sameAsRecommended) {
757
- 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.`)}`);
758
- }
768
+ // Plain words; the exact settings are shown before anything is written.
769
+ 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` : ""}`; };
770
+ console.log(`This machine fits ${c.green(plain(res.env))}${res.confidence === "low" ? c.dim(" (an estimate)") : ""}.`);
771
+ 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)."));
759
772
  }
760
773
 
761
774
  // "More noms" fits as many noms as memory allows; "nominal" is 1 worker
@@ -773,8 +786,8 @@ async function cmdSetup() {
773
786
  if (sizingTier !== "more" && sizingTier !== "nominal") throw new Error('--tier must be "more" or "nominal".');
774
787
  } else {
775
788
  console.log(`\n${c.bold("Which sizing?")}`);
776
- console.log(` ${c.cyan("1.")} More noms -- as many as fit in memory`);
777
- console.log(` ${c.cyan("2.")} Nominal -- 1 nom, matching this project's own shipped profiles`);
789
+ console.log(` ${c.cyan("1.")} As many at once as fit in memory`);
790
+ console.log(` ${c.cyan("2.")} One at a time (lighter on the machine)`);
778
791
  const choice = (await rl.question(c.bold("Choice [1]: "))).trim() || "1";
779
792
  sizingTier = choice === "2" ? "nominal" : "more";
780
793
  }
@@ -793,8 +806,8 @@ async function cmdSetup() {
793
806
  }
794
807
 
795
808
  const profileName = nonInteractive ? value("profile-name") : (await rl.question(`\nProfile name [${hardware?.appleSilicon ? "macbook-pro" : "custom"}]: `)).trim() || (hardware?.appleSilicon ? "macbook-pro" : "custom");
796
- const profilePath = path.join(nomarmyRoot, "config", "profiles", `${profileName}.env`);
797
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
809
+ const profilePath = userProfilePath(profileName);
810
+ const commonPath = userCommonPath();
798
811
 
799
812
  const profileWrites = { ...sizingEnv };
800
813
  if (!isCloud) {
@@ -807,10 +820,10 @@ async function cmdSetup() {
807
820
  }
808
821
 
809
822
  if (!json) {
810
- console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, profilePath)}:`));
823
+ console.log(c.bold(`\nAbout to write ${tildePath(profilePath)}:`));
811
824
  for (const [k, v] of Object.entries(profileWrites)) console.log(c.dim(` ${k}=${v}`));
812
825
  if (model?.kind === "known") {
813
- console.log(c.bold(`\nAnd ${path.relative(nomarmyRoot, commonPath)}:`));
826
+ console.log(c.bold(`\nAnd ${tildePath(commonPath)}:`));
814
827
  console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}`));
815
828
  console.log(c.dim(` NOMARMY_MODEL_QUANT=${model.quant}`));
816
829
  console.log(c.dim(` NOMARMY_MODEL_ALIAS=${model.alias}`));
@@ -818,8 +831,9 @@ async function cmdSetup() {
818
831
  console.log(c.dim(` NOMARMY_MODEL_THINKING=${model.thinking}`));
819
832
  }
820
833
  if (!nonInteractive) {
821
- const answer = (await rl.question(c.bold("\nWrite this configuration? [y/N] "))).trim().toLowerCase();
822
- if (answer !== "y") { console.log(c.dim("Canceled; nothing written.")); return; }
834
+ // Yes by default: someone who accepted every suggestion shouldn't lose it all at the last Enter.
835
+ const answer = (await rl.question(c.bold("\nWrite this configuration? [Y/n] "))).trim().toLowerCase();
836
+ if (answer === "n" || answer === "no") { console.log(c.dim("Canceled; nothing written.")); return; }
823
837
  }
824
838
  }
825
839
 
@@ -841,7 +855,7 @@ async function cmdSetup() {
841
855
  writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", model.alias);
842
856
  writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
843
857
  } else if (model?.kind === "search") {
844
- const searchedAlias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
858
+ const searchedAlias = setting("NOMARMY_MODEL_ALIAS");
845
859
  if (searchedAlias) {
846
860
  writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", searchedAlias);
847
861
  writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
@@ -850,7 +864,7 @@ async function cmdSetup() {
850
864
 
851
865
  const installCmd = "nomarmy install";
852
866
  if (json) return out({ written: { profile: profilePath, common: model?.kind === "known" ? commonPath : null }, env: profileWrites, sizingTier, installCommand: installCmd });
853
- console.log(c.green(`\n✓ Wrote ${path.relative(nomarmyRoot, profilePath)}${model?.kind === "known" ? ` and ${path.relative(nomarmyRoot, commonPath)}` : ""}.`));
867
+ console.log(c.green(`\n✓ Wrote ${tildePath(profilePath)}${model?.kind === "known" ? ` and ${tildePath(commonPath)}` : ""}.`));
854
868
  console.log(c.dim("\nThis proposes; it does not install. Run:\n"));
855
869
  console.log(` ${c.bold(installCmd)}\n`);
856
870
  } finally {
@@ -879,7 +893,7 @@ function restartInference() {
879
893
  runScript("start-inference.sh", []);
880
894
  }
881
895
  async function cmdModel() {
882
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
896
+ const commonPath = userCommonPath();
883
897
  if (json) {
884
898
  const which = value("model");
885
899
  if (!KNOWN_MODELS[which]) throw new Error(`--json requires --model one of ${Object.keys(KNOWN_MODELS).join(", ")} (Hugging Face search is interactive-only).`);
@@ -903,17 +917,17 @@ async function cmdModel() {
903
917
  const model = await chooseModel(rl);
904
918
  let alias;
905
919
  if (model.kind === "search") {
906
- console.log(c.green("\n✓ Done") + " -- config/common.env was already updated by the search above.");
907
- alias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
920
+ console.log(c.green("\n✓ Done") + ` -- ${tildePath(commonPath)} was already updated by the search above.`);
921
+ alias = setting("NOMARMY_MODEL_ALIAS");
908
922
  } else {
909
- console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, commonPath)}:`));
923
+ console.log(c.bold(`\nAbout to write ${tildePath(commonPath)}:`));
910
924
  console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}\n NOMARMY_MODEL_QUANT=${model.quant}\n NOMARMY_MODEL_ALIAS=${model.alias}`));
911
925
  const answer = (await rl.question(c.bold("\nApply this model configuration? [y/N] "))).trim().toLowerCase();
912
926
  if (answer !== "y") { console.log(c.dim("Canceled; nothing changed.")); return; }
913
927
  writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
914
928
  writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
915
929
  writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", model.alias);
916
- console.log(c.green(`✓ Wrote ${path.relative(nomarmyRoot, commonPath)}.`));
930
+ console.log(c.green(`✓ Wrote ${tildePath(commonPath)}.`));
917
931
  alias = model.alias;
918
932
  }
919
933
  if (alias) {
@@ -1370,8 +1384,7 @@ async function cmdAgentsAdd() {
1370
1384
  console.log(` ${c.cyan("1.")} local ${c.dim("the local model on this machine (no per-token bill, private; slower)")}`);
1371
1385
  console.log(` ${c.cyan("2.")} api ${c.dim("a metered API key (xAI, OpenAI, Anthropic, DeepSeek, ...)")}`);
1372
1386
  console.log(` ${c.cyan("3.")} subscription ${c.dim("your own Claude, ChatGPT or Muse Code plan (never shared)")}`);
1373
- kind = AGENT_KINDS[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1374
- if (!kind) throw new Error("Not a valid choice.");
1387
+ kind = AGENT_KINDS[await askChoice(rl, AGENT_KINDS.length)];
1375
1388
  }
1376
1389
  if (kind === "local") return await addLocalAgent(rl, agents);
1377
1390
  if (kind === "api") return await addApiAgent(rl, agents);
@@ -1408,8 +1421,7 @@ async function addLocalAgent(rl, agents) {
1408
1421
  async function addApiAgent(rl, agents) {
1409
1422
  console.log("\n" + c.bold("Which provider?"));
1410
1423
  API_PROVIDER_TYPES.forEach((t, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${KNOWN_PROVIDERS[t]?.label ?? t}`));
1411
- const provider = API_PROVIDER_TYPES[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1412
- if (!provider) throw new Error("Not a valid choice.");
1424
+ const provider = API_PROVIDER_TYPES[await askChoice(rl, API_PROVIDER_TYPES.length)];
1413
1425
  const info = KNOWN_PROVIDERS[provider] ?? {};
1414
1426
  const agent = { kind: "api", provider };
1415
1427
 
@@ -1484,8 +1496,7 @@ async function addSubscriptionAgent(rl, agents) {
1484
1496
  if (!SUBSCRIPTION_VENDORS[vendorKey]) {
1485
1497
  console.log("\n" + c.bold("Which subscription?"));
1486
1498
  vendorKeys.forEach((k, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${SUBSCRIPTION_VENDORS[k].label}`));
1487
- vendorKey = vendorKeys[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
1488
- if (!vendorKey) throw new Error(`Not a valid choice. Supported: ${vendorKeys.join(", ")}. DeepSeek and others without a plan are api agents.`);
1499
+ vendorKey = vendorKeys[await askChoice(rl, vendorKeys.length)];
1489
1500
  }
1490
1501
  const vendor = SUBSCRIPTION_VENDORS[vendorKey];
1491
1502
 
@@ -1714,13 +1725,14 @@ async function cmdUpdate() {
1714
1725
  if (!copyIsStale(defaultInstallDir(), nomarmyRoot)) {
1715
1726
  if (json) return out({ updated: false, reason: "already up to date" });
1716
1727
  console.log(c.green("✓ Already up to date, and your coordinators run this checkout."));
1728
+ printSessionRestarts({ quietWhenNone: true });
1717
1729
  return;
1718
1730
  }
1719
1731
  say(c.bold("🍪 nomArmy update\n"));
1720
1732
  say("Nothing to pull, but your coordinators run an older copy of this checkout.");
1721
1733
  const resynced = reconnectCoordinators();
1722
1734
  if (json) return out({ updated: false, resynced, sha: local });
1723
- console.log(c.yellow("\nRestart every open Claude Code, Codex and Cursor session: each keeps the code it started with until then."));
1735
+ printSessionRestarts();
1724
1736
  return;
1725
1737
  }
1726
1738
  if (base !== local) {
@@ -1747,6 +1759,25 @@ async function cmdUpdate() {
1747
1759
  // Reconnect every connected coordinator through a child process, so it runs
1748
1760
  // the code now on disk (just pulled or installed) rather than the old code
1749
1761
  // this process loaded. Returns the targets reconnected.
1762
+ // Each open session keeps the nomArmy it started with: name the ones that
1763
+ // started before the installed copy, rather than a blanket "restart".
1764
+ function printSessionRestarts({ quietWhenNone = false } = {}) {
1765
+ let list = null;
1766
+ try {
1767
+ const installedAt = fs.statSync(path.join(defaultInstallDir(), "source.json")).mtimeMs;
1768
+ const procs = listProcesses();
1769
+ if (procs) list = staleSessions(procs, { installedAt });
1770
+ } catch { /* no installed copy yet, or ps unavailable */ }
1771
+ if (list === null) {
1772
+ if (!quietWhenNone) console.log(c.yellow("\nRestart every open Claude Code, Codex and Cursor session: each keeps the code it started with until then."));
1773
+ return;
1774
+ }
1775
+ if (!list.length) { if (!quietWhenNone) console.log(c.green("\n✓ No open session runs an older nomArmy.")); return; }
1776
+ console.log(c.yellow(`\n${list.length} open session(s) still run an older nomArmy, until each is restarted:`));
1777
+ for (const line of formatStaleSessions(list)) console.log(line);
1778
+ console.log(c.dim("In Claude Code: /exit, then claude --resume (or /mcp → nomarmy-local-worker → Reconnect). Close any you no longer use."));
1779
+ }
1780
+
1750
1781
  function reconnectCoordinators() {
1751
1782
  const targets = connectedTargets();
1752
1783
  // Per-repo registrations run the installed copy (or `nomarmy mcp`), so a
@@ -1791,7 +1822,7 @@ async function updateFromNpm() {
1791
1822
  }
1792
1823
  const targets = reconnectCoordinators();
1793
1824
  if (json) return out({ updated: upgrade, from: current, version: upgrade ? latest : current, resynced: targets });
1794
- console.log(c.yellow("\nRestart every open Claude Code, Codex and Cursor session: each keeps the code it started with until then."));
1825
+ printSessionRestarts();
1795
1826
  }
1796
1827
 
1797
1828
  function commandExists(cmd) {
@@ -2024,7 +2055,7 @@ async function maybeRemoveAgentsDir({ force }) {
2024
2055
  * needs manual cleanup -- an honest, bounded scope beats guessing at which
2025
2056
  * cache entries are "ours". */
2026
2057
  function resolveConfiguredModelRepos() {
2027
- const repo = readEnvValue(path.join(nomarmyRoot, "config", "common.env"), "NOMARMY_MODEL_REPO");
2058
+ const repo = setting("NOMARMY_MODEL_REPO");
2028
2059
  return repo ? [repo] : [];
2029
2060
  }
2030
2061
 
@@ -2736,6 +2767,25 @@ async function cmdStatusline() {
2736
2767
  process.stdout.write(`${statusLineText({ session })}\n`);
2737
2768
  }
2738
2769
 
2770
+ // `stats --share`: markdown for a PR description or README; `--badge [path]`:
2771
+ // an SVG badge to commit, with the README line for it (lib/share.mjs).
2772
+ async function shareStats(stats) {
2773
+ const { shareMarkdown, badgeSvg, badgeMarkdown } = await import("../lib/share.mjs");
2774
+ const scope = value("run") ? "this feature run" : value("since") ? `since ${value("since")}` : null;
2775
+ if (flag("share")) console.log(shareMarkdown(stats, { scope }));
2776
+ if (flag("badge")) {
2777
+ const given = value("badge");
2778
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
2779
+ const root = top.status === 0 ? top.stdout.trim() : process.cwd();
2780
+ const file = path.resolve(root, given ?? path.join(".github", "nomarmy-badge.svg"));
2781
+ fs.mkdirSync(path.dirname(file), { recursive: true });
2782
+ fs.writeFileSync(file, badgeSvg(stats));
2783
+ const rel = path.relative(root, file).split(path.sep).join("/");
2784
+ console.log(`${flag("share") ? "\n" : ""}${c.green("✓")} Wrote ${rel}. Commit it and add this to your README:\n\n ${badgeMarkdown(rel)}\n`);
2785
+ console.log(c.dim("Re-run nomarmy stats --badge after more jobs to refresh the numbers."));
2786
+ }
2787
+ }
2788
+
2739
2789
  function cmdStats() {
2740
2790
  const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
2741
2791
  const records = loadJobRecords(path.join(stateRoot, "jobs"));
@@ -2747,9 +2797,10 @@ function cmdStats() {
2747
2797
  }
2748
2798
  let agentFor = () => null;
2749
2799
  try { agentFor = agentLookup(loadAgents(globalConfigDir()).agents, agentProviderId); } catch { /* no agents.yml: commands name <agent> */ }
2750
- const stats = computeStats(records, { repo, sinceMs: parseSince(value("since")), untilMs: parseSince(value("until")), role: value("role"), model: value("model"), agentFor });
2800
+ 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") });
2751
2801
  if (json) return out(stats);
2752
- console.log(formatStats(stats));
2802
+ if (flag("share") || flag("badge")) return shareStats(stats);
2803
+ console.log(flag("details") ? formatStats(stats) : formatStatsSummary(stats, { c }));
2753
2804
  }
2754
2805
 
2755
2806
  // Read one line without echoing it: stty -echo around the read, restored
@@ -2884,7 +2935,12 @@ async function cmdDoctor() {
2884
2935
  await runDoctor({ json, exit: true, env: installEnv() });
2885
2936
  }
2886
2937
  commands.doctor = cmdDoctor;
2887
- if (!command || flag("help") || !commands[command]) usage(command && !commands[command] ? 2 : 0);
2938
+ if (!command && !flag("help")) {
2939
+ // New users typed `nomarmy` and got the whole command reference.
2940
+ 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`);
2941
+ process.exit(0);
2942
+ }
2943
+ if (flag("help") || !commands[command]) usage(command && !commands[command] ? 2 : 0);
2888
2944
 
2889
2945
  try {
2890
2946
  await commands[command]();
package/install.sh CHANGED
@@ -87,7 +87,9 @@ echo '==> Installing nomArmy dependencies'
87
87
 
88
88
  # Put `nomarmy` on PATH. Non-fatal by design: linking needs a writable npm
89
89
  # global prefix, and the CLI is equally usable as `node bin/nomarmy.mjs`.
90
- if (cd "$ROOT" && npm link >/dev/null 2>&1); then
90
+ if [[ "$ROOT" == */node_modules/* ]]; then
91
+ : # Installed from npm: `nomarmy` is already on PATH.
92
+ elif (cd "$ROOT" && npm link >/dev/null 2>&1); then
91
93
  echo '==> Linked the nomarmy CLI onto PATH'
92
94
  else
93
95
  echo 'NOTE: could not link the nomarmy CLI (npm global prefix not writable).'
@@ -115,6 +117,9 @@ if ! command -v openclaw >/dev/null 2>&1; then
115
117
  else
116
118
  # No writable global prefix: install for this user, as OpenClaw's own installer does.
117
119
  npm install -g --prefix "$HOME/.npm-global" "openclaw@$OPENCLAW_VERSION" --no-audit --no-fund
120
+ # nomArmy finds it there on its own (lib/openclaw-path.mjs); your shell won't until PATH has it.
121
+ echo "NOTE: OpenClaw is in ~/.npm-global/bin. nomArmy finds it there; to run openclaw yourself, add it to PATH:"
122
+ echo ' echo '"'"'export PATH="$HOME/.npm-global/bin:$PATH"'"'"' >> ~/.profile'
118
123
  fi
119
124
  export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
120
125
  fi
package/lib/connect.mjs CHANGED
@@ -29,6 +29,7 @@ import { loadAgents } from "./agents.mjs";
29
29
  import { globalConfigDir } from "./army.mjs";
30
30
  import { buildNotifierApp } from "./notifier-app.mjs";
31
31
  import { recordCopySource } from "./install-freshness.mjs";
32
+ import { readSettings, migrateUserConfig } from "./user-config.mjs";
32
33
 
33
34
  export function defaultInstallDir() {
34
35
  return process.env.NOMARMY_AGENT_INSTALL_DIR || path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".local", "share", "nomarmy-local-worker");
@@ -140,6 +141,9 @@ export function installClaudeStatusLine({ installDir, settingsPath = defaultClau
140
141
  }
141
142
 
142
143
  export function installMcpCopy({ nomarmyRoot, installDir, run = defaultRun }) {
144
+ // Settings from before they moved to ~/.config/nomarmy/ survive in the old
145
+ // copy's config/ until this call replaces it: rescue them first.
146
+ try { migrateUserConfig({ oldConfigDir: path.join(installDir, "config"), nomarmyRoot }); } catch { /* never block a connect */ }
143
147
  fs.mkdirSync(path.join(installDir, "mcp"), { recursive: true });
144
148
  fs.copyFileSync(path.join(nomarmyRoot, "package.json"), path.join(installDir, "package.json"));
145
149
  fs.copyFileSync(path.join(nomarmyRoot, "mcp", "server.mjs"), path.join(installDir, "mcp", "server.mjs"));
@@ -246,15 +250,13 @@ function readEnvValue(filePath, key) {
246
250
  * such keys yet just contributes nothing here, unchanged from before.
247
251
  */
248
252
  export function deriveWorkerModelEnv(nomarmyRoot) {
249
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
250
- const model = readEnvValue(commonPath, "NOMARMY_WORKER_MODEL");
251
- const thinking = readEnvValue(commonPath, "NOMARMY_MODEL_THINKING");
253
+ // Your settings over the package defaults (lib/user-config.mjs).
254
+ const settings = readSettings({ nomarmyRoot });
252
255
  const env = {};
253
- if (model) env.NOMARMY_WORKER_MODEL = model;
254
- if (thinking !== null) env.NOMARMY_WORKER_MODEL_THINKING = thinking;
256
+ if (settings.NOMARMY_WORKER_MODEL) env.NOMARMY_WORKER_MODEL = settings.NOMARMY_WORKER_MODEL;
257
+ if (settings.NOMARMY_MODEL_THINKING !== undefined) env.NOMARMY_WORKER_MODEL_THINKING = settings.NOMARMY_MODEL_THINKING;
255
258
  for (const key of ["NOMARMY_EXECUTION", "NOMARMY_LLAMA_HOST", "NOMARMY_LLAMA_PORT"]) {
256
- const value = readEnvValue(commonPath, key);
257
- if (value !== null) env[key] = value;
259
+ if (settings[key] !== undefined) env[key] = settings[key];
258
260
  }
259
261
  return env;
260
262
  }
package/lib/doctor.mjs CHANGED
@@ -252,7 +252,7 @@ export function checkEndpoint(facts) {
252
252
  return {
253
253
  ok: false,
254
254
  message: `NOMARMY_EXECUTION '${execution}' is not recognized.`,
255
- fix: "Set NOMARMY_EXECUTION to 'local', 'remote', 'hosted', or 'bedrock' (see config/common.env).",
255
+ fix: "Set NOMARMY_EXECUTION to 'local', 'remote', 'hosted', or 'bedrock' (nomarmy setup --choose, or ~/.config/nomarmy/common.env).",
256
256
  };
257
257
  }
258
258
 
@@ -282,7 +282,7 @@ function checkBedrockEndpoint(endpoint) {
282
282
  return {
283
283
  ok: false,
284
284
  message: "NOMARMY_BEDROCK_REGION is not set.",
285
- fix: "Set NOMARMY_BEDROCK_REGION, e.g. export NOMARMY_BEDROCK_REGION=eu-west-2 (see config/profiles/bedrock.env).",
285
+ fix: "Set NOMARMY_BEDROCK_REGION, e.g. export NOMARMY_BEDROCK_REGION=eu-west-2 (or set it in ~/.config/nomarmy/common.env).",
286
286
  };
287
287
  }
288
288
  if (!regionValid) {
package/lib/health.mjs CHANGED
@@ -337,8 +337,8 @@ export async function checkAndRecordHealth({ projectDir, stateRoot, configDir, n
337
337
  const { loadJobRecords, agentLookup } = await import("./stats.mjs");
338
338
  const { recentSuggestions } = await import("./suggestions.mjs");
339
339
  for (const s of recentSuggestions(loadJobRecords(path.join(stateRoot, "jobs")), { projectDir, agentFor: agents ? agentLookup(agents, agentProviderId) : () => null, now })) {
340
- if (s.level !== "warn") continue;
341
- result.issues.push({ id: `suggestion:${s.key}`, severity: "warn", title: s.title, detail: s.evidence, fix: s.command ?? "nomarmy stats (routing suggestions)", short: "routing tip" });
340
+ if (s.level !== "warn" && s.level !== "act") continue;
341
+ result.issues.push({ id: `suggestion:${s.key}`, severity: "warn", title: s.title, detail: s.evidence, fix: s.command ?? "nomarmy stats (routing suggestions)", short: s.level === "act" ? "review needed" : "routing tip" });
342
342
  }
343
343
  } catch { /* suggestions never break a health check */ }
344
344
  const toNotify = recordHealth(path.join(stateRoot, "health.json"), result, { now });
@@ -0,0 +1,28 @@
1
+ // Find OpenClaw where install.sh may have put it. With no writable npm global
2
+ // folder (common on Linux), it installs into ~/.npm-global/bin, which isn't on
3
+ // most PATHs: a fresh-install practice run ended "OpenClaw not found", and
4
+ // every job would then fail. The CLI and the MCP server call this first, so
5
+ // they and everything they spawn find it.
6
+
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+
11
+ const isExecutable = (file) => { try { fs.accessSync(file, fs.constants.X_OK); return fs.statSync(file).isFile(); } catch { return false; } };
12
+
13
+ export function openclawFallbackDirs(home = os.homedir()) {
14
+ return [path.join(home, ".npm-global", "bin"), path.join(home, ".local", "bin")];
15
+ }
16
+
17
+ /** Adds the folder holding openclaw to env.PATH when it isn't already reachable. Returns the folder added, or null. */
18
+ export function ensureOpenClawOnPath(env = process.env, { home = os.homedir(), platform = process.platform } = {}) {
19
+ if (env.NOMARMY_OPENCLAW_CMD) return null;
20
+ const sep = platform === "win32" ? ";" : ":";
21
+ const names = platform === "win32" ? ["openclaw.cmd", "openclaw.exe", "openclaw"] : ["openclaw"];
22
+ const dirs = String(env.PATH ?? "").split(sep).filter(Boolean);
23
+ if (dirs.some((d) => names.some((n) => isExecutable(path.join(d, n))))) return null;
24
+ const found = openclawFallbackDirs(home).find((d) => names.some((n) => isExecutable(path.join(d, n))));
25
+ if (!found) return null;
26
+ env.PATH = [found, ...dirs].join(sep);
27
+ return found;
28
+ }