nomarmy 0.1.0-alpha.17 → 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
@@ -32,12 +32,16 @@ import { setupSteps, formatSetupSteps, runSetupPlaybook } from "../lib/setup-ste
32
32
  import { readUsageSnapshots } from "../lib/usage-limits.mjs";
33
33
  import { pickMachine, planResize } from "../lib/sandbox-vm.mjs";
34
34
  import { listProcesses, staleSessions, formatStaleSessions } from "../lib/stale-sessions.mjs";
35
+ import { readSetting, writeSetting, writeEnvLine, userCommonPath, userProfilePath, profilePathFor, tildePath } from "../lib/user-config.mjs";
35
36
  import { MIN_PODMAN_VM_MB } from "../lib/doctor.mjs";
36
37
  import { liveLeases } from "../lib/slots.mjs";
37
38
  import { ensureProviderConfig } from "../lib/openclaw-config.mjs";
38
39
  import { recordProbeSuccess } from "../lib/health.mjs";
39
40
  import { pruneJobRuntime } from "../lib/prune.mjs";
40
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();
41
45
 
42
46
  // Add a new coordinator: add its name here, teach commandExists/connectTarget
43
47
  // about it below (a JSON-file target like Cursor has no PATH binary to check
@@ -239,7 +243,7 @@ Usage: nomarmy <command> [options]
239
243
  project for this repository, committed for the team
240
244
  (.mcp.json or .cursor/mcp.json, running \`nomarmy mcp\`).
241
245
  Codex has only the user scope.
242
- stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>] [--details] [--all-suggestions]
246
+ stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>] [--run <id>] [--details] [--all-suggestions] [--share] [--badge [path]]
243
247
  [--repo <path|name>] [--all-repos] [--json]
244
248
  What nomArmy's job records show for this repository (or
245
249
  all): volume by role and model, code committed, time,
@@ -468,15 +472,9 @@ function readEnvValue(filePath, key) {
468
472
  return m ? m[1].trim() : null;
469
473
  }
470
474
 
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
- }
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 });
480
478
 
481
479
  // Each entry's repo/quant/alias is verified against this project's own real
482
480
  // usage (downloaded, loaded, dispatched against), not guessed from a model
@@ -495,6 +493,14 @@ function writeEnvLine(filePath, key, value) {
495
493
  * not left to fail only at the very end via schema validation with no
496
494
  * indication of which of several answers was the problem.
497
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
+
498
504
  async function askUntilValid(rl, prompt, { pattern, invalidMessage, allowEmpty = false, fallback = "" }) {
499
505
  for (;;) {
500
506
  const answer = (await rl.question(c.bold(prompt))).trim();
@@ -527,15 +533,15 @@ async function askSecret(rl, prompt) {
527
533
 
528
534
  const KNOWN_MODELS = {
529
535
  default: {
530
- 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)",
531
537
  repo: "Qwen/Qwen3-Coder-Next-GGUF", quant: "Q4_K_M", alias: "qwen3-coder-next", thinking: false, recommended: true,
532
538
  },
533
539
  "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)",
540
+ label: "gpt-oss-20b · 12 GB download · the fastest; keep reasoning at medium",
535
541
  repo: "ggml-org/gpt-oss-20b-GGUF", quant: "MXFP4", alias: "gpt-oss-20b", thinking: true,
536
542
  },
537
543
  "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)",
544
+ label: "Qwen3.6-27B · 17 GB download · very reliable, slower; keep reasoning at medium",
539
545
  repo: "unsloth/Qwen3.6-27B-GGUF", quant: "Q4_K_M", alias: "qwen3.6-27b", thinking: true,
540
546
  },
541
547
  };
@@ -588,14 +594,14 @@ function defaultLocalProfile() {
588
594
  // `install`: the one setup recorded, else the install marker's, else the
589
595
  // execution mode's, else (a working local install) install.sh's default.
590
596
  function setupProfileState() {
591
- const common = path.join(nomarmyRoot, "config", "common.env");
592
- const chosen = readEnvValue(common, "NOMARMY_SETUP_PROFILE");
597
+ const common = userCommonPath();
598
+ const chosen = setting("NOMARMY_SETUP_PROFILE");
593
599
  const probeCommand = (binary, args) => {
594
600
  const result = spawnSync(binary, args, { encoding: "utf8", timeout: 10000 });
595
601
  return result.status === 0 ? result.stdout.trim() : "";
596
602
  };
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());
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());
599
605
  let marker = null;
600
606
  try { marker = JSON.parse(fs.readFileSync(path.join(root, "install.json"), "utf8")); } catch (error) { if (error.code !== "ENOENT") marker = {}; }
601
607
  const version = probeCommand("openclaw", ["--version"]);
@@ -603,7 +609,7 @@ function setupProfileState() {
603
609
  const registered = !marker && Boolean(version) && ["claude", "codex"].some((name) => spawnSync(name, ["mcp", "get", "nomarmy-local-worker"], { stdio: "ignore", timeout: 10000 }).status === 0);
604
610
  // An install from before setup recorded its profile: the marker's, else the
605
611
  // execution mode's, else (a working local install) install.sh's default.
606
- const execution = readEnvValue(common, "NOMARMY_EXECUTION");
612
+ const execution = setting("NOMARMY_EXECUTION");
607
613
  const profile = chosen ?? marker?.profile
608
614
  ?? (["hosted", "remote", "bedrock"].includes(execution) ? execution : null)
609
615
  ?? (registered ? defaultLocalProfile() : null);
@@ -614,7 +620,7 @@ function setupChecklist() {
614
620
  const { common, profile, marker, version, registered } = setupProfileState();
615
621
  const project = setupProjectDir();
616
622
  return setupSteps({
617
- 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") }),
618
624
  install: () => ({ marker, version, registered }),
619
625
  agents: () => Object.keys(loadAgents(globalConfigDir()).agents),
620
626
  army: () => {
@@ -652,6 +658,15 @@ async function cmdSetup() {
652
658
  const steps = setupChecklist();
653
659
  return json ? out(steps) : console.log(formatSetupSteps(steps));
654
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
+ }
655
670
  process.exitCode = await runSetupPlaybook({
656
671
  evaluate: setupChecklist, print: console.log, run: runSetupChild,
657
672
  ask: async (prompt) => {
@@ -670,10 +685,8 @@ async function cmdSetup() {
670
685
  if (choice === "1") argv.push("--hosted");
671
686
  if (choice === "3") argv.push("--llama-url", (await rl.question("Server URL: ")).trim());
672
687
  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");
688
+ writeSetting("NOMARMY_EXECUTION", "bedrock");
689
+ writeSetting("NOMARMY_SETUP_PROFILE", "bedrock");
677
690
  console.log("Next: nomarmy install");
678
691
  return;
679
692
  }
@@ -684,7 +697,7 @@ async function cmdSetup() {
684
697
  if (hosted && hasLlamaUrl) throw new Error("--hosted and --llama-url cannot be used together.");
685
698
 
686
699
  if (hosted || hasLlamaUrl) {
687
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
700
+ const commonPath = userCommonPath();
688
701
 
689
702
  if (hosted) {
690
703
  const next = [
@@ -692,11 +705,10 @@ async function cmdSetup() {
692
705
  "nomarmy agents add",
693
706
  "nomarmy army init --agent <name>",
694
707
  ];
695
- fs.mkdirSync(path.dirname(commonPath), { recursive: true });
696
708
  writeEnvLine(commonPath, "NOMARMY_EXECUTION", "hosted");
697
709
  writeEnvLine(commonPath, "NOMARMY_SETUP_PROFILE", "hosted");
698
710
  if (json) return out({ written: commonPath, execution: "hosted", next });
699
- 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)}).`));
700
712
  console.log(c.dim("\nNext:"));
701
713
  for (const step of next) console.log(` ${c.bold(step)}`);
702
714
  return;
@@ -724,7 +736,7 @@ async function cmdSetup() {
724
736
  console.log(reachable
725
737
  ? c.green(`✓ llama-server is reachable at ${healthUrl}.`)
726
738
  : 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)}.`));
739
+ console.log(c.green(`✓ Wrote the remote llama-server settings to ${tildePath(commonPath)}.`));
728
740
  console.log(c.dim("\nNext:"));
729
741
  console.log(` ${c.bold(next)}`);
730
742
  return;
@@ -753,10 +765,10 @@ async function cmdSetup() {
753
765
  console.log(isCloud
754
766
  ? `Execution is '${execution}' -- hosted inference, local hardware does not bound this.\n`
755
767
  : `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
- }
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)."));
760
772
  }
761
773
 
762
774
  // "More noms" fits as many noms as memory allows; "nominal" is 1 worker
@@ -774,8 +786,8 @@ async function cmdSetup() {
774
786
  if (sizingTier !== "more" && sizingTier !== "nominal") throw new Error('--tier must be "more" or "nominal".');
775
787
  } else {
776
788
  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`);
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)`);
779
791
  const choice = (await rl.question(c.bold("Choice [1]: "))).trim() || "1";
780
792
  sizingTier = choice === "2" ? "nominal" : "more";
781
793
  }
@@ -794,8 +806,8 @@ async function cmdSetup() {
794
806
  }
795
807
 
796
808
  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");
809
+ const profilePath = userProfilePath(profileName);
810
+ const commonPath = userCommonPath();
799
811
 
800
812
  const profileWrites = { ...sizingEnv };
801
813
  if (!isCloud) {
@@ -808,10 +820,10 @@ async function cmdSetup() {
808
820
  }
809
821
 
810
822
  if (!json) {
811
- console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, profilePath)}:`));
823
+ console.log(c.bold(`\nAbout to write ${tildePath(profilePath)}:`));
812
824
  for (const [k, v] of Object.entries(profileWrites)) console.log(c.dim(` ${k}=${v}`));
813
825
  if (model?.kind === "known") {
814
- console.log(c.bold(`\nAnd ${path.relative(nomarmyRoot, commonPath)}:`));
826
+ console.log(c.bold(`\nAnd ${tildePath(commonPath)}:`));
815
827
  console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}`));
816
828
  console.log(c.dim(` NOMARMY_MODEL_QUANT=${model.quant}`));
817
829
  console.log(c.dim(` NOMARMY_MODEL_ALIAS=${model.alias}`));
@@ -819,8 +831,9 @@ async function cmdSetup() {
819
831
  console.log(c.dim(` NOMARMY_MODEL_THINKING=${model.thinking}`));
820
832
  }
821
833
  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; }
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; }
824
837
  }
825
838
  }
826
839
 
@@ -842,7 +855,7 @@ async function cmdSetup() {
842
855
  writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", model.alias);
843
856
  writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
844
857
  } else if (model?.kind === "search") {
845
- const searchedAlias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
858
+ const searchedAlias = setting("NOMARMY_MODEL_ALIAS");
846
859
  if (searchedAlias) {
847
860
  writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", searchedAlias);
848
861
  writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
@@ -851,7 +864,7 @@ async function cmdSetup() {
851
864
 
852
865
  const installCmd = "nomarmy install";
853
866
  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)}` : ""}.`));
867
+ console.log(c.green(`\n✓ Wrote ${tildePath(profilePath)}${model?.kind === "known" ? ` and ${tildePath(commonPath)}` : ""}.`));
855
868
  console.log(c.dim("\nThis proposes; it does not install. Run:\n"));
856
869
  console.log(` ${c.bold(installCmd)}\n`);
857
870
  } finally {
@@ -880,7 +893,7 @@ function restartInference() {
880
893
  runScript("start-inference.sh", []);
881
894
  }
882
895
  async function cmdModel() {
883
- const commonPath = path.join(nomarmyRoot, "config", "common.env");
896
+ const commonPath = userCommonPath();
884
897
  if (json) {
885
898
  const which = value("model");
886
899
  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 +917,17 @@ async function cmdModel() {
904
917
  const model = await chooseModel(rl);
905
918
  let alias;
906
919
  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");
920
+ console.log(c.green("\n✓ Done") + ` -- ${tildePath(commonPath)} was already updated by the search above.`);
921
+ alias = setting("NOMARMY_MODEL_ALIAS");
909
922
  } else {
910
- console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, commonPath)}:`));
923
+ console.log(c.bold(`\nAbout to write ${tildePath(commonPath)}:`));
911
924
  console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}\n NOMARMY_MODEL_QUANT=${model.quant}\n NOMARMY_MODEL_ALIAS=${model.alias}`));
912
925
  const answer = (await rl.question(c.bold("\nApply this model configuration? [y/N] "))).trim().toLowerCase();
913
926
  if (answer !== "y") { console.log(c.dim("Canceled; nothing changed.")); return; }
914
927
  writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
915
928
  writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
916
929
  writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", model.alias);
917
- console.log(c.green(`✓ Wrote ${path.relative(nomarmyRoot, commonPath)}.`));
930
+ console.log(c.green(`✓ Wrote ${tildePath(commonPath)}.`));
918
931
  alias = model.alias;
919
932
  }
920
933
  if (alias) {
@@ -1371,8 +1384,7 @@ async function cmdAgentsAdd() {
1371
1384
  console.log(` ${c.cyan("1.")} local ${c.dim("the local model on this machine (no per-token bill, private; slower)")}`);
1372
1385
  console.log(` ${c.cyan("2.")} api ${c.dim("a metered API key (xAI, OpenAI, Anthropic, DeepSeek, ...)")}`);
1373
1386
  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.");
1387
+ kind = AGENT_KINDS[await askChoice(rl, AGENT_KINDS.length)];
1376
1388
  }
1377
1389
  if (kind === "local") return await addLocalAgent(rl, agents);
1378
1390
  if (kind === "api") return await addApiAgent(rl, agents);
@@ -1409,8 +1421,7 @@ async function addLocalAgent(rl, agents) {
1409
1421
  async function addApiAgent(rl, agents) {
1410
1422
  console.log("\n" + c.bold("Which provider?"));
1411
1423
  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.");
1424
+ const provider = API_PROVIDER_TYPES[await askChoice(rl, API_PROVIDER_TYPES.length)];
1414
1425
  const info = KNOWN_PROVIDERS[provider] ?? {};
1415
1426
  const agent = { kind: "api", provider };
1416
1427
 
@@ -1485,8 +1496,7 @@ async function addSubscriptionAgent(rl, agents) {
1485
1496
  if (!SUBSCRIPTION_VENDORS[vendorKey]) {
1486
1497
  console.log("\n" + c.bold("Which subscription?"));
1487
1498
  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.`);
1499
+ vendorKey = vendorKeys[await askChoice(rl, vendorKeys.length)];
1490
1500
  }
1491
1501
  const vendor = SUBSCRIPTION_VENDORS[vendorKey];
1492
1502
 
@@ -2045,7 +2055,7 @@ async function maybeRemoveAgentsDir({ force }) {
2045
2055
  * needs manual cleanup -- an honest, bounded scope beats guessing at which
2046
2056
  * cache entries are "ours". */
2047
2057
  function resolveConfiguredModelRepos() {
2048
- const repo = readEnvValue(path.join(nomarmyRoot, "config", "common.env"), "NOMARMY_MODEL_REPO");
2058
+ const repo = setting("NOMARMY_MODEL_REPO");
2049
2059
  return repo ? [repo] : [];
2050
2060
  }
2051
2061
 
@@ -2757,6 +2767,25 @@ async function cmdStatusline() {
2757
2767
  process.stdout.write(`${statusLineText({ session })}\n`);
2758
2768
  }
2759
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
+
2760
2789
  function cmdStats() {
2761
2790
  const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
2762
2791
  const records = loadJobRecords(path.join(stateRoot, "jobs"));
@@ -2768,8 +2797,9 @@ function cmdStats() {
2768
2797
  }
2769
2798
  let agentFor = () => null;
2770
2799
  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") });
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") });
2772
2801
  if (json) return out(stats);
2802
+ if (flag("share") || flag("badge")) return shareStats(stats);
2773
2803
  console.log(flag("details") ? formatStats(stats) : formatStatsSummary(stats, { c }));
2774
2804
  }
2775
2805
 
@@ -2905,7 +2935,12 @@ async function cmdDoctor() {
2905
2935
  await runDoctor({ json, exit: true, env: installEnv() });
2906
2936
  }
2907
2937
  commands.doctor = cmdDoctor;
2908
- 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);
2909
2944
 
2910
2945
  try {
2911
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) {
@@ -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
+ }
package/lib/share.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // Sharing what nomArmy verified, where people already look: a block for a
2
+ // pull request's description, and a README badge. Every number comes from
3
+ // the job records (lib/stats.mjs), never from a worker's report, so what
4
+ // gets shared is evidence, not a claim.
5
+
6
+ const REPO_URL = "https://github.com/rayson-tech/nomarmy";
7
+
8
+ /** Claims that held up and those caught, from computeStats' claimVsEvidence. */
9
+ function claims(stats) {
10
+ const cv = stats.claimVsEvidence;
11
+ const caught = cv.verificationFailed + cv.revertStillPassed;
12
+ return { total: cv.claimedDone, caught, held: cv.claimedDone - caught, cv };
13
+ }
14
+
15
+ /**
16
+ * Markdown for a pull request (or anywhere): what the workers claimed, what
17
+ * nomArmy caught, the tests shown to catch their change, and high-stakes
18
+ * reviews. `scope` names what it covers ("this feature run", "the last 7 days").
19
+ */
20
+ export function shareMarkdown(stats, { scope = null } = {}) {
21
+ const { total, caught, held, cv } = claims(stats);
22
+ const rows = [];
23
+ if (total) {
24
+ const why = [cv.verificationFailed && `${cv.verificationFailed} failed when nomArmy ran the tests itself`, cv.revertStillPassed && `${cv.revertStillPassed} had tests that pass with the change reverted`].filter(Boolean).join(", ");
25
+ rows.push(["Worker claims checked", `${held} of ${total} "done, tests pass" claims held up${caught ? `; ${caught} caught (${why})` : ""}`]);
26
+ } else rows.push(["Worker claims checked", "none reported \"done, tests pass\""]);
27
+ if (cv.provenTestFiles) rows.push(["Tests proven", `${cv.provenTestFiles} new test file(s) shown to fail without their change`]);
28
+ if (stats.code?.committedJobs) rows.push(["Work committed", `${stats.code.committedJobs} verified job(s), +${stats.code.linesAdded} / -${stats.code.linesRemoved} lines`]);
29
+ if (stats.highStakes?.jobs) rows.push(["High-stakes changes", `${stats.highStakes.jobs}, ${stats.highStakes.reviewed === stats.highStakes.jobs ? "all" : stats.highStakes.reviewed} independently reviewed`]);
30
+ return [
31
+ `### ✓ Verified by nomArmy${scope ? ` (${scope})` : ""}`,
32
+ "",
33
+ "| | |",
34
+ "|---|---|",
35
+ ...rows.map(([k, v]) => `| ${k} | ${v} |`),
36
+ "",
37
+ `<sub>From nomArmy's verified job records, not the workers' own reports. [What is nomArmy?](${REPO_URL})</sub>`,
38
+ ].join("\n");
39
+ }
40
+
41
+ const esc = (s) =>
42
+ String(s)
43
+ .replace(/&/g, "&amp;")
44
+ .replace(/</g, "&lt;")
45
+ .replace(/>/g, "&gt;")
46
+ .replace(/"/g, "&quot;")
47
+ .replace(/'/g, "&#39;");
48
+ // Verdana 11px, the badge convention: rough widths by letter shape, plus padding.
49
+ function textWidth(s) {
50
+ let w = 0;
51
+ for (const ch of String(s)) w += /[mwMW]/.test(ch) ? 10.5 : /[ijlI|.,:;!' ]/.test(ch) ? 3.8 : /[frt]/.test(ch) ? 4.8 : /[A-Z]/.test(ch) ? 7.6 : /[0-9]/.test(ch) ? 7 : ch === "·" ? 4.5 : 6.6;
52
+ return Math.round(w + 14);
53
+ }
54
+
55
+ /**
56
+ * A shields-style SVG: "nomArmy | 78 claims checked · 11 caught". Caught
57
+ * claims are the point of running nomArmy, not something to hide, so the
58
+ * color is a neutral blue, and green when every claim held up.
59
+ */
60
+ export function badgeSvg(stats) {
61
+ const { total, caught } = claims(stats);
62
+ const label = "nomArmy";
63
+ const message = !total ? "verified" : caught ? `${total} claims checked · ${caught} caught` : `${total} claims checked, all held up`;
64
+ const color = total && !caught ? "#2da44e" : "#0969da";
65
+ const lw = textWidth(label), mw = textWidth(message), w = lw + mw;
66
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20" role="img" aria-label="${esc(`${label}: ${message}`)}">
67
+ <title>${esc(`${label}: ${message}`)}</title>
68
+ <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
69
+ <clipPath id="r"><rect width="${w}" height="20" rx="3" fill="#fff"/></clipPath>
70
+ <g clip-path="url(#r)"><rect width="${lw}" height="20" fill="#555"/><rect x="${lw}" width="${mw}" height="20" fill="${color}"/><rect width="${w}" height="20" fill="url(#s)"/></g>
71
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
72
+ <text x="${lw / 2}" y="14">${esc(label)}</text>
73
+ <text x="${lw + mw / 2}" y="14">${esc(message)}</text>
74
+ </g>
75
+ </svg>
76
+ `;
77
+ }
78
+
79
+ /** The README line for a committed badge, linking to nomArmy. */
80
+ export function badgeMarkdown(relPath) {
81
+ return `[![nomArmy](${relPath})](${REPO_URL})`;
82
+ }
package/lib/stats.mjs CHANGED
@@ -99,7 +99,7 @@ export function resolveRepo(records, value) {
99
99
  * @param {object[]} records
100
100
  * @param {{ repo?: string|null, sinceMs?: number|null, untilMs?: number|null, role?: string|null, model?: string|null }} filter
101
101
  */
102
- export function computeStats(records, { repo = null, sinceMs = null, untilMs = null, role = null, model = null, agentFor = () => null, now = Date.now(), allSuggestions = false } = {}) {
102
+ export function computeStats(records, { repo = null, sinceMs = null, untilMs = null, role = null, model = null, runId = null, agentFor = () => null, now = Date.now(), allSuggestions = false } = {}) {
103
103
  const inRange = records.filter((r) => {
104
104
  const at = Date.parse(r.startedAt ?? r.finishedAt ?? "");
105
105
  if (sinceMs != null && !(at >= sinceMs)) return false;
@@ -107,6 +107,7 @@ export function computeStats(records, { repo = null, sinceMs = null, untilMs = n
107
107
  if (repo && r.projectDir && path.resolve(r.projectDir) !== path.resolve(repo)) return false;
108
108
  if (role && jobRole(r) !== role) return false;
109
109
  if (model && (r.metrics?.worker_model ?? r.worker?.model) !== model) return false;
110
+ if (runId && r.labels?.runId !== runId) return false;
110
111
  return true;
111
112
  });
112
113
  // Verify runs from before records carried the repo can't be placed in one.
@@ -0,0 +1,106 @@
1
+ // Your setup choices (where models run, the local model, hardware profiles)
2
+ // live in ~/.config/nomarmy/, layered over the package's config/ defaults.
3
+ // They used to be written into config/ itself, which for an npm install is
4
+ // inside the package: every `nomarmy update` put them back to the defaults
5
+ // (found in a fresh-install practice run: hosted silently became local).
6
+ //
7
+ // ~/.config/nomarmy/common.env your overrides of config/common.env
8
+ // ~/.config/nomarmy/profiles/<name>.env your profiles, used before config/profiles/
9
+ //
10
+ // scripts/lib.sh layers the same files the same way.
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { globalConfigDir } from "./army.mjs";
15
+
16
+ export function userCommonPath(env = process.env) { return path.join(globalConfigDir(env), "common.env"); }
17
+ export function userProfilePath(name, env = process.env) { return path.join(globalConfigDir(env), "profiles", `${name}.env`); }
18
+
19
+ export function parseEnvFile(filePath) {
20
+ const out = {};
21
+ let text;
22
+ try { text = fs.readFileSync(filePath, "utf8"); } catch { return out; }
23
+ for (const line of text.split("\n")) {
24
+ const m = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line.trim());
25
+ if (m) out[m[1]] = m[2].trim();
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** Your value, else the package default. */
31
+ export function readSetting(key, { nomarmyRoot, env = process.env }) {
32
+ const mine = parseEnvFile(userCommonPath(env));
33
+ if (key in mine) return mine[key];
34
+ return parseEnvFile(path.join(nomarmyRoot, "config", "common.env"))[key] ?? null;
35
+ }
36
+
37
+ /** Every common setting, package defaults with yours on top. */
38
+ export function readSettings({ nomarmyRoot, env = process.env }) {
39
+ return { ...parseEnvFile(path.join(nomarmyRoot, "config", "common.env")), ...parseEnvFile(userCommonPath(env)) };
40
+ }
41
+
42
+ /** A profile's file: yours if you have one by that name, else the shipped one (null if neither). */
43
+ export function profilePathFor(name, { nomarmyRoot, env = process.env }) {
44
+ if (!name) return null;
45
+ const mine = userProfilePath(name, env);
46
+ if (fs.existsSync(mine)) return mine;
47
+ const shipped = path.join(nomarmyRoot, "config", "profiles", `${name}.env`);
48
+ return fs.existsSync(shipped) ? shipped : null;
49
+ }
50
+
51
+ const HEADER = "# Your nomArmy settings, layered over the package's config/common.env.\n# Written by nomarmy setup and nomarmy model; kept across updates.\n";
52
+
53
+ /** Set one KEY=VALUE in a settings file, replacing it if present. */
54
+ export function writeEnvLine(filePath, key, value, { header = HEADER } = {}) {
55
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
56
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : header;
57
+ const line = `${key}=${value}`;
58
+ const re = new RegExp(`^${key}=.*$`, "m");
59
+ const updated = re.test(existing) ? existing.replace(re, line) : `${existing.trimEnd()}\n${line}\n`.replace(/^\n/, "");
60
+ fs.writeFileSync(filePath, updated);
61
+ }
62
+
63
+ export function writeSetting(key, value, { env = process.env } = {}) {
64
+ writeEnvLine(userCommonPath(env), key, value);
65
+ return userCommonPath(env);
66
+ }
67
+
68
+ /**
69
+ * One-time rescue, run before an update's copy replaces the installed one:
70
+ * settings that only ever lived in the package (or its installed copy) move
71
+ * to ~/.config/nomarmy/. A key is moved when the old copy's value differs from
72
+ * the new package's default; a profile when the new package doesn't ship it
73
+ * or ships it differently. Never overwrites what you already have there.
74
+ * @returns {{ keys: string[], profiles: string[] }}
75
+ */
76
+ export function migrateUserConfig({ oldConfigDir, nomarmyRoot, env = process.env }) {
77
+ const moved = { keys: [], profiles: [] };
78
+ if (!oldConfigDir || !fs.existsSync(oldConfigDir)) return moved;
79
+ if (path.resolve(oldConfigDir) === path.resolve(path.join(nomarmyRoot, "config"))) return moved;
80
+ const old = parseEnvFile(path.join(oldConfigDir, "common.env"));
81
+ const shipped = parseEnvFile(path.join(nomarmyRoot, "config", "common.env"));
82
+ const mine = parseEnvFile(userCommonPath(env));
83
+ for (const [key, value] of Object.entries(old)) {
84
+ if (key in mine || shipped[key] === value) continue;
85
+ writeSetting(key, value, { env });
86
+ moved.keys.push(key);
87
+ }
88
+ let names = [];
89
+ try { names = fs.readdirSync(path.join(oldConfigDir, "profiles")).filter((f) => f.endsWith(".env")); } catch { /* none */ }
90
+ for (const file of names) {
91
+ const target = userProfilePath(path.basename(file, ".env"), env);
92
+ if (fs.existsSync(target)) continue;
93
+ const text = fs.readFileSync(path.join(oldConfigDir, "profiles", file), "utf8");
94
+ const shippedFile = path.join(nomarmyRoot, "config", "profiles", file);
95
+ if (fs.existsSync(shippedFile) && fs.readFileSync(shippedFile, "utf8") === text) continue;
96
+ fs.mkdirSync(path.dirname(target), { recursive: true });
97
+ fs.writeFileSync(target, text);
98
+ moved.profiles.push(path.basename(file, ".env"));
99
+ }
100
+ return moved;
101
+ }
102
+
103
+ /** A path for messages: ~ for the home directory. */
104
+ export function tildePath(p, home = process.env.HOME) {
105
+ return home && p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
106
+ }
package/mcp/server.mjs CHANGED
@@ -38,12 +38,16 @@ import { podmanProblem, podmanVmStartedAt } from "../lib/podman-health.mjs";
38
38
  import { restartNotice } from "../lib/install-freshness.mjs";
39
39
  import { requestJobStop } from "../lib/openclaw-run.mjs";
40
40
  import { loadJobRecords, computeStats, formatStats, formatStatsSummary, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
41
+ import { shareMarkdown } from "../lib/share.mjs";
41
42
  import { recentSuggestions } from "../lib/suggestions.mjs";
42
43
  import { probeModel } from "../lib/model-probe.mjs";
43
44
  import { jevSettings, judgeSettings } from "../lib/validators.mjs";
44
45
  import { agentRunsToolsOnHost } from "../lib/dispatch-schema.mjs";
45
46
  import { createBuildMetrics, resolveOutcome, finalText, workerMetadata, usageMetrics, policyAdmissionProblems, applyRefactorContract, applyVerificationPolicy, resolveVerifyRegression } from "../lib/outcome.mjs";
46
47
  import { jobLabel, compactJobRecord, formatResult, reportView, formatUnion, testChangeBanner, regressionCheckBanner, decomposeOverlapBanner } from "../lib/job-format.mjs";
48
+ import { ensureOpenClawOnPath } from "../lib/openclaw-path.mjs";
49
+ // OpenClaw in ~/.npm-global/bin (no writable npm prefix) is found without the operator editing PATH.
50
+ ensureOpenClawOnPath();
47
51
 
48
52
  export { run, mapLimit };
49
53
  export { readsMeasurable, measureReads };
@@ -548,7 +552,10 @@ server.tool("run_finish", "Close a /feature run as complete or stopped, with a o
548
552
  try {
549
553
  const run = finishRun(runsRoot, run_id, { status, summary });
550
554
  if (activeRunId === run_id) activeRunId = null;
551
- return toolText(JSON.stringify({ id: run.id, status: run.status, ...runTotals(run) }, null, 2));
555
+ // What nomArmy verified in this run, ready for the pull request's description (lib/share.mjs).
556
+ let prBlock = null;
557
+ try { prBlock = shareMarkdown(computeStats(loadJobRecords(jobsRoot), { runId: run_id }), { scope: "this feature run" }); } catch { /* the totals stand without it */ }
558
+ return toolText(JSON.stringify({ id: run.id, status: run.status, ...runTotals(run), ...(prBlock ? { prBlock, prBlockNote: "Put prBlock in the pull request's description as it is: every number is from nomArmy's verified records." } : {}) }, null, 2));
552
559
  } catch (error) { return toolText(error.message, true); }
553
560
  });
554
561
  server.tool("army", "Who you, the General, are and who you call for what in this repository: your fixed charter and the agent you're defined as, the army's workflow, then each role's description, phase (build, review, acceptance), suggested mode, and the agent it runs on, with which config layer set each value (global, project .nomarmy.yml, local .nomarmy.local.yml). Flags roles with no usable agent, and roles that share your model or subscription (not an independent review). Dispatch a role with `army_role`, or an agent directly with `agent`. Read-only, re-read on every call.", {}, async () => {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Every byte verified: a harness for AI coding workers whose claims are never trusted. Your coding assistant stays in charge while workers implement and test in sandboxes, and nomArmy checks every change before it is committed.",
4
4
  "author": "Rayson Technologies",
5
5
  "license": "Apache-2.0",
6
- "version": "0.1.0-alpha.17",
6
+ "version": "0.1.0-alpha.18",
7
7
  "private": false,
8
8
  "type": "module",
9
9
  "engines": {
@@ -41,4 +41,4 @@ The `logPath` from `run_start`. Markdown, updated after every phase: the plan; e
41
41
 
42
42
  ## When it's done
43
43
 
44
- Call `run_finish` (`complete` or `stopped`), then report in one message: what was built and on which branch; what each role found and how it was resolved; the decisions made on the operator's behalf; test and verification results; and the run's cost from `run_status` (jobs and api spend per agent). If a push-notification tool is available, notify the operator that the run finished or stopped.
44
+ Call `run_finish` (`complete` or `stopped`). Its result has a `prBlock`: when you or the operator open a pull request for the run's branch, put it in the description as it is (every number is from nomArmy's verified records). Then report in one message: what was built and on which branch; what each role found and how it was resolved; the decisions made on the operator's behalf; test and verification results; and the run's cost from `run_status` (jobs and api spend per agent). If a push-notification tool is available, notify the operator that the run finished or stopped.
@@ -57,7 +57,9 @@ else
57
57
  if [[ -n "$REMOTE_MODEL" && "$REMOTE_MODEL" != "${NOMARMY_WORKER_MODEL:-}" ]]; then
58
58
  # Jobs ask for NOMARMY_WORKER_MODEL, so record the name this server
59
59
  # actually serves (`nomarmy connect`, run next by install.sh, reads it).
60
- COMMON="$ROOT/config/common.env"
60
+ # Your settings file, so an update can't reset it (lib/user-config.mjs).
61
+ COMMON="$(nomarmy_user_config_dir)/common.env"
62
+ mkdir -p "$(dirname "$COMMON")"; touch "$COMMON"
61
63
  for key in NOMARMY_MODEL_ALIAS NOMARMY_WORKER_MODEL; do
62
64
  if grep -q "^$key=" "$COMMON"; then
63
65
  KEY="$key" VALUE="$REMOTE_MODEL" node -e 'const fs=require("fs"),f=process.argv[1];fs.writeFileSync(f,fs.readFileSync(f,"utf8").replace(new RegExp(`^${process.env.KEY}=.*$`,"m"),`${process.env.KEY}=${process.env.VALUE}`))' "$COMMON"
@@ -66,7 +68,7 @@ else
66
68
  fi
67
69
  done
68
70
  export NOMARMY_MODEL_ALIAS="$REMOTE_MODEL" NOMARMY_WORKER_MODEL="$REMOTE_MODEL"
69
- echo "==> The server serves '$REMOTE_MODEL'; recorded it in config/common.env"
71
+ echo "==> The server serves '$REMOTE_MODEL'; recorded it in $COMMON"
70
72
  fi
71
73
  # llama-server reports the context of one slot, which is one nom's share.
72
74
  REMOTE_CTX="$(curl -fsS --max-time 5 "$SERVER/props" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const n=JSON.parse(s).default_generation_settings?.n_ctx;if(Number.isInteger(n)&&n>0)process.stdout.write(String(n))}catch{}})' || true)"
package/scripts/lib.sh CHANGED
@@ -19,9 +19,13 @@ nomarmy_compat_prefix(){
19
19
  done
20
20
  }
21
21
 
22
+ # Your settings live here, over the package's config/ defaults, so an update
23
+ # can't reset them (lib/user-config.mjs does the same for the CLI).
24
+ nomarmy_user_config_dir(){ echo "${NOMARMY_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/nomarmy}"; }
25
+
22
26
  nomarmy_available_profiles(){
23
27
  local root; root="$(nomarmy_root)"
24
- find "$root/config/profiles" -name '*.env' -exec basename {} .env \; | sort | tr '\n' ' '
28
+ { find "$root/config/profiles" -name '*.env' -exec basename {} .env \; ; find "$(nomarmy_user_config_dir)/profiles" -name '*.env' -exec basename {} .env \; 2>/dev/null; } | sort -u | tr '\n' ' '
25
29
  }
26
30
 
27
31
  # Where this install's models run (lib/execution.mjs is the same logic for the
@@ -129,7 +133,8 @@ load_profile(){
129
133
  elif command -v nvidia-smi >/dev/null 2>&1; then profile=nvidia-linux
130
134
  else profile=cpu-linux; fi
131
135
  fi
132
- profile_file="$root/config/profiles/$profile.env"
136
+ profile_file="$(nomarmy_user_config_dir)/profiles/$profile.env"
137
+ [[ -f "$profile_file" ]] || profile_file="$root/config/profiles/$profile.env"
133
138
  if [[ ! -f "$profile_file" ]]; then
134
139
  echo "ERROR: unknown profile '$profile'. Available: $(nomarmy_available_profiles)" >&2
135
140
  exit 2
@@ -137,6 +142,8 @@ load_profile(){
137
142
  set -a
138
143
  # shellcheck disable=SC1091
139
144
  source "$root/config/common.env"
145
+ # shellcheck disable=SC1091
146
+ [[ -f "$(nomarmy_user_config_dir)/common.env" ]] && source "$(nomarmy_user_config_dir)/common.env"
140
147
  # shellcheck disable=SC1090
141
148
  source "$profile_file"
142
149
  set +a
@@ -3,12 +3,14 @@ import { createInterface } from "node:readline/promises";
3
3
  import { stdin as input, stdout as output } from "node:process";
4
4
  import { readFile, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
+ import { userCommonPath } from "../lib/user-config.mjs";
6
7
 
7
8
  const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
8
9
  const args = process.argv.slice(2);
9
10
  let query = "";
10
11
  let repo = "";
11
- let configPath = path.join(root, "config", "common.env");
12
+ // Your settings file, not the package's config/ (an update would reset it): lib/user-config.mjs.
13
+ let configPath = userCommonPath();
12
14
 
13
15
  for (let index = 0; index < args.length; index += 1) {
14
16
  const value = args[index];
@@ -70,16 +72,18 @@ try {
70
72
  const alias = (await rl.question(`Local model alias [${defaultAlias}]: `)).trim() || defaultAlias;
71
73
  if (!/^[A-Za-z0-9._-]+$/.test(alias)) throw new Error("Alias may contain only letters, numbers, dots, underscores, and hyphens.");
72
74
 
73
- console.log(`\nReady to update ${path.relative(root, configPath)}:`);
75
+ console.log(`\nReady to update ${configPath}:`);
74
76
  console.log(` NOMARMY_MODEL_REPO=${repo}`);
75
77
  console.log(` NOMARMY_MODEL_QUANT=${requestedQuant}`);
76
78
  console.log(` NOMARMY_MODEL_ALIAS=${alias}`);
77
- if ((await rl.question("Apply this model configuration? [y/N] ")).trim().toLowerCase() !== "y") {
79
+ if (["n", "no"].includes((await rl.question("Apply this model configuration? [Y/n] ")).trim().toLowerCase())) {
78
80
  console.log("Canceled; no model configuration changed.");
79
81
  process.exit(0);
80
82
  }
81
83
 
82
- const existing = await readFile(configPath, "utf8");
84
+ // Your settings file may not exist yet: start it rather than fail.
85
+ const existing = await readFile(configPath, "utf8").catch(() => "");
86
+ await import("node:fs").then((fs) => fs.mkdirSync(path.dirname(configPath), { recursive: true }));
83
87
  const replace = (key, value, text) => {
84
88
  const line = `${key}=${value}`;
85
89
  return new RegExp(`^${key}=.*$`, "m").test(text) ? text.replace(new RegExp(`^${key}=.*$`, "m"), line) : `${text.trimEnd()}\n${line}\n`;