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 +3 -0
- package/bin/nomarmy.mjs +90 -55
- package/install.sh +6 -1
- package/lib/connect.mjs +9 -7
- package/lib/doctor.mjs +2 -2
- package/lib/openclaw-path.mjs +28 -0
- package/lib/share.mjs +82 -0
- package/lib/stats.mjs +2 -1
- package/lib/user-config.mjs +106 -0
- package/mcp/server.mjs +8 -1
- package/package.json +1 -1
- package/playbooks/feature.md +1 -1
- package/scripts/configure-openclaw.sh +4 -2
- package/scripts/lib.sh +9 -2
- package/scripts/select-model.mjs +8 -4
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
592
|
-
const chosen =
|
|
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
|
|
598
|
-
const root = (process.env.NOMARMY_INSTALL_ROOT || (profileFile && readEnvValue(profileFile, "NOMARMY_INSTALL_ROOT")) ||
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
674
|
-
|
|
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 =
|
|
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(`✓
|
|
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 ${
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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.")}
|
|
778
|
-
console.log(` ${c.cyan("2.")}
|
|
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 =
|
|
798
|
-
const commonPath =
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
823
|
-
|
|
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 =
|
|
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 ${
|
|
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 =
|
|
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") +
|
|
908
|
-
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 ${
|
|
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 ${
|
|
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[
|
|
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[
|
|
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[
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
250
|
-
const
|
|
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 (
|
|
254
|
-
if (
|
|
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
|
-
|
|
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' (
|
|
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 (
|
|
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, "&")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/"/g, """)
|
|
47
|
+
.replace(/'/g, "'");
|
|
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 `[](${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
|
-
|
|
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.
|
|
6
|
+
"version": "0.1.0-alpha.18",
|
|
7
7
|
"private": false,
|
|
8
8
|
"type": "module",
|
|
9
9
|
"engines": {
|
package/playbooks/feature.md
CHANGED
|
@@ -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`),
|
|
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
|
-
|
|
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
|
|
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="$
|
|
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
|
package/scripts/select-model.mjs
CHANGED
|
@@ -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
|
-
|
|
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 ${
|
|
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? [
|
|
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
|
-
|
|
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`;
|