nomarmy 0.1.0-alpha.0

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.
Files changed (74) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +25 -0
  3. package/README.md +484 -0
  4. package/bin/nomarmy.mjs +2248 -0
  5. package/config/agents.yml.example +63 -0
  6. package/config/common.env +31 -0
  7. package/config/profiles/bedrock-cheap.env +26 -0
  8. package/config/profiles/bedrock.env +28 -0
  9. package/config/profiles/cpu-linux.env +8 -0
  10. package/config/profiles/dgx-spark.env +12 -0
  11. package/config/profiles/macbook-pro.env +9 -0
  12. package/config/profiles/nvidia-linux.env +9 -0
  13. package/docker/Dockerfile +15 -0
  14. package/docker/Dockerfile.go +29 -0
  15. package/docker/Dockerfile.rust +19 -0
  16. package/e2e.sh +153 -0
  17. package/install.sh +125 -0
  18. package/lib/agents.mjs +285 -0
  19. package/lib/army.mjs +400 -0
  20. package/lib/budget.mjs +368 -0
  21. package/lib/claude-transcript.mjs +150 -0
  22. package/lib/config.mjs +193 -0
  23. package/lib/connect.mjs +409 -0
  24. package/lib/coordinator-instructions.mjs +23 -0
  25. package/lib/decompose.mjs +389 -0
  26. package/lib/dispatch-config.mjs +164 -0
  27. package/lib/dispatch-schema.mjs +280 -0
  28. package/lib/doctor.mjs +443 -0
  29. package/lib/evidence.mjs +679 -0
  30. package/lib/gguf.mjs +589 -0
  31. package/lib/hardware.mjs +476 -0
  32. package/lib/health.mjs +278 -0
  33. package/lib/model-catalog.mjs +71 -0
  34. package/lib/notifier-app.mjs +95 -0
  35. package/lib/notify.mjs +66 -0
  36. package/lib/openclaw-config.mjs +65 -0
  37. package/lib/openclaw-errors.mjs +40 -0
  38. package/lib/propose.mjs +110 -0
  39. package/lib/prune.mjs +77 -0
  40. package/lib/repo-query.mjs +267 -0
  41. package/lib/runs.mjs +150 -0
  42. package/lib/sabotage.mjs +128 -0
  43. package/lib/sandbox-images.mjs +434 -0
  44. package/lib/scan.mjs +1538 -0
  45. package/lib/schema.mjs +288 -0
  46. package/lib/scout.mjs +544 -0
  47. package/lib/sizing.mjs +1322 -0
  48. package/lib/slots.mjs +112 -0
  49. package/lib/statusline.mjs +126 -0
  50. package/lib/subscription-config.mjs +68 -0
  51. package/lib/subscription-setup.mjs +217 -0
  52. package/lib/transcript.mjs +195 -0
  53. package/lib/verify.mjs +700 -0
  54. package/mcp/server.mjs +4206 -0
  55. package/notifier/icon.swift +34 -0
  56. package/notifier/main.swift +52 -0
  57. package/notifier/nomarmy-icon.png +0 -0
  58. package/package.json +67 -0
  59. package/playbooks/feature.md +43 -0
  60. package/policies/coder.md +49 -0
  61. package/policies/orchestrator.md +35 -0
  62. package/policies/reviewer.md +35 -0
  63. package/policies/scout.md +65 -0
  64. package/scripts/configure-openclaw.sh +96 -0
  65. package/scripts/configure-orchestrator.sh +84 -0
  66. package/scripts/install-llama-cpp.sh +16 -0
  67. package/scripts/lib.sh +198 -0
  68. package/scripts/select-model.mjs +96 -0
  69. package/scripts/select-model.sh +4 -0
  70. package/scripts/setup-sandbox.sh +38 -0
  71. package/scripts/start-inference.sh +46 -0
  72. package/scripts/stop-inference.sh +5 -0
  73. package/scripts/uninstall.sh +6 -0
  74. package/scripts/verify-install.sh +68 -0
package/lib/config.mjs ADDED
@@ -0,0 +1,193 @@
1
+ // nomArmy repository configuration loader (v1.3).
2
+ //
3
+ // `.nomarmy.yml` is the environment contract for a job: what services the
4
+ // worker's sandbox gets, how the application is started, which verification
5
+ // profiles exist, and what happens to the environment afterwards.
6
+ //
7
+ // All YAML parsing lives in this file so the parser stays swappable; the shape
8
+ // itself lives in `lib/schema.mjs`.
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import YAML from "yaml";
13
+
14
+ import {
15
+ collectElevated,
16
+ configSchema,
17
+ emptyElevated,
18
+ formatIssues,
19
+ } from "./schema.mjs";
20
+
21
+ export {
22
+ DEFAULT_RETENTION,
23
+ ELEVATED_SERVICE_SOURCES,
24
+ ENVIRONMENT_LEVELS,
25
+ RETENTION_ACTIONS,
26
+ SERVICE_SOURCES,
27
+ } from "./schema.mjs";
28
+
29
+ /** Filenames searched, in order. First hit wins. */
30
+ export const CONFIG_FILENAMES = Object.freeze([".nomarmy.yml", ".nomarmy.yaml"]);
31
+
32
+ /**
33
+ * Raised when a `.nomarmy.yml` exists but cannot be used. Carries the same
34
+ * readable `path: message` lines that `validateConfig` returns, so a caller
35
+ * never has to re-derive them from the message text.
36
+ */
37
+ export class ConfigError extends Error {
38
+ /**
39
+ * @param {string} message
40
+ * @param {{ errors?: string[], path?: string|null, cause?: unknown }} [details]
41
+ */
42
+ constructor(message, details = {}) {
43
+ super(message);
44
+ this.name = "ConfigError";
45
+ this.errors = details.errors || [];
46
+ this.path = details.path || null;
47
+ if (details.cause !== undefined) this.cause = details.cause;
48
+ }
49
+ }
50
+
51
+ function isReadableFile(candidate) {
52
+ try {
53
+ return fs.statSync(candidate).isFile();
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Absolute path of the repository's config file, or null when absent.
61
+ * @param {string} [repoDir]
62
+ * @returns {string|null}
63
+ */
64
+ export function findConfigFile(repoDir = process.cwd()) {
65
+ const base = path.resolve(repoDir);
66
+ for (const filename of CONFIG_FILENAMES) {
67
+ const candidate = path.join(base, filename);
68
+ if (isReadableFile(candidate)) return candidate;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Parse YAML text into a plain object. The only YAML entry point in nomArmy.
75
+ * An empty document (or a comments-only file) becomes `{}`.
76
+ * @param {string} text
77
+ * @param {string|null} [sourcePath] used only for the error message
78
+ * @returns {object}
79
+ */
80
+ export function parseYaml(text, sourcePath = null) {
81
+ let parsed;
82
+ try {
83
+ parsed = YAML.parse(text, { prettyErrors: true });
84
+ } catch (error) {
85
+ const where = sourcePath ? `${path.basename(sourcePath)}` : "configuration";
86
+ throw new ConfigError(`${where} is not valid YAML: ${error.message}`, {
87
+ path: sourcePath,
88
+ errors: [`config: is not valid YAML: ${error.message}`],
89
+ cause: error,
90
+ });
91
+ }
92
+ return parsed === null || parsed === undefined ? {} : parsed;
93
+ }
94
+
95
+ /**
96
+ * Serialize a plain object to YAML text. The only YAML *write* entry point,
97
+ * for the same swappable-parser reason parseYaml is the only read one.
98
+ * @param {object} config
99
+ * @returns {string}
100
+ */
101
+ export function stringifyConfig(config) {
102
+ return YAML.stringify(config);
103
+ }
104
+
105
+ /**
106
+ * Validate an already-parsed plain object against the `.nomarmy.yml` schema.
107
+ *
108
+ * `elevated` lists every `shared` and `remote` service found. Those sources
109
+ * reach outside the job sandbox, so they are accepted here but the caller is
110
+ * expected to require explicit policy approval before running the job. It is
111
+ * structured data on purpose, not a warning string.
112
+ *
113
+ * @param {unknown} input
114
+ * @returns {{ valid: boolean, config: object|null, errors: string[], elevated: { shared: string[], remote: string[] } }}
115
+ */
116
+ export function validateConfig(input) {
117
+ const candidate = input === null || input === undefined ? {} : input;
118
+
119
+ if (typeof candidate !== "object" || Array.isArray(candidate)) {
120
+ return {
121
+ valid: false,
122
+ config: null,
123
+ errors: ["config: must be a mapping of top-level sections"],
124
+ elevated: emptyElevated(),
125
+ };
126
+ }
127
+
128
+ const result = configSchema.safeParse(candidate);
129
+ if (!result.success) {
130
+ return {
131
+ valid: false,
132
+ config: null,
133
+ errors: formatIssues(result.error),
134
+ elevated: emptyElevated(),
135
+ };
136
+ }
137
+
138
+ return {
139
+ valid: true,
140
+ config: result.data,
141
+ errors: [],
142
+ elevated: collectElevated(result.data),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Find, parse and validate a repository's `.nomarmy.yml`.
148
+ *
149
+ * A missing file is not an error: the repo simply has no environment contract,
150
+ * and `{ found: false }` comes back cleanly. A file that exists but is broken
151
+ * throws `ConfigError`: an unusable contract must not be mistaken for none.
152
+ *
153
+ * @param {string} [repoDir] repository root to search
154
+ * @returns {{ found: boolean, path: string|null, config: object|null, elevated: { shared: string[], remote: string[] } }}
155
+ */
156
+ export function loadConfig(repoDir = process.cwd()) {
157
+ const configPath = findConfigFile(repoDir);
158
+ if (!configPath) {
159
+ return { found: false, path: null, config: null, elevated: emptyElevated() };
160
+ }
161
+
162
+ let text;
163
+ try {
164
+ text = fs.readFileSync(configPath, "utf8");
165
+ } catch (error) {
166
+ throw new ConfigError(
167
+ `${path.basename(configPath)} could not be read: ${error.message}`,
168
+ {
169
+ path: configPath,
170
+ errors: [`config: could not be read: ${error.message}`],
171
+ cause: error,
172
+ },
173
+ );
174
+ }
175
+
176
+ const parsed = parseYaml(text, configPath);
177
+ const result = validateConfig(parsed);
178
+
179
+ if (!result.valid) {
180
+ const detail = result.errors.map((line) => ` - ${line}`).join("\n");
181
+ throw new ConfigError(
182
+ `${path.basename(configPath)} is not a valid nomArmy configuration:\n${detail}`,
183
+ { path: configPath, errors: result.errors },
184
+ );
185
+ }
186
+
187
+ return {
188
+ found: true,
189
+ path: configPath,
190
+ config: result.config,
191
+ elevated: result.elevated,
192
+ };
193
+ }
@@ -0,0 +1,409 @@
1
+ // Registers the nomArmy MCP server with a coordinator (Claude Code, Codex,
2
+ // or Cursor). Originally two separate bash scripts (setup-claude-worker.sh /
3
+ // setup-codex-worker.sh); ported to real JS and now the ONLY implementation
4
+ // -- install.sh calls `nomarmy connect <target>` too, not the old scripts,
5
+ // which is exactly what let a real bug (env vars silently dropped on
6
+ // reinstall) go unnoticed in the bash version for a long time: only one of
7
+ // the two implementations ever got fixed, and the fresh-install path kept
8
+ // calling the other one. Unlike install.sh itself (OS package-manager
9
+ // detection, toolchain installs, curl-piped installers, genuinely risky to
10
+ // reimplement), this registration step is small and
11
+ // purely mechanical: copy files, npm install, then either call one external
12
+ // CLI's own `mcp add` (Claude, Codex) or edit a JSON config file directly
13
+ // (Cursor, which has no CLI for this at all). Nothing here needs bash to
14
+ // exist on the host at all.
15
+ //
16
+ // The registered MCP server is always a COPY under installDir, never the
17
+ // dev checkout directly -- server.mjs resolves "../lib/verify.mjs" relative
18
+ // to its own location, so lib/ ships as installDir's sibling of mcp/.
19
+ //
20
+ // Add a new coordinator here: implement connect<Name>({ nomarmyRoot,
21
+ // installDir, run }) returning at least { installDir, serverPath }, then
22
+ // wire it into KNOWN_TARGETS and the dispatch table in bin/nomarmy.mjs.
23
+
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { execFileSync } from "node:child_process";
28
+ import { loadAgents } from "./agents.mjs";
29
+ import { globalConfigDir } from "./army.mjs";
30
+ import { buildNotifierApp } from "./notifier-app.mjs";
31
+
32
+ export function defaultInstallDir() {
33
+ return process.env.NOMARMY_AGENT_INSTALL_DIR || path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".local", "share", "nomarmy-local-worker");
34
+ }
35
+
36
+ /**
37
+ * Copy package.json/mcp/lib into installDir and `npm install` there.
38
+ * Shared by both coordinators; has no opinion about which one is registering.
39
+ * @param {{ nomarmyRoot: string, installDir: string, run?: Function }} input
40
+ */
41
+ // --- The /feature playbook, installed into each coordinator ---------------
42
+ //
43
+ // One body (playbooks/*.md, with a {{REQUEST}} placeholder), rendered in
44
+ // each coordinator's own format. Every installed copy carries a
45
+ // `<!-- nomarmy:... -->` marker; a same-named file WITHOUT it is the
46
+ // operator's own and is never overwritten, only reported as skipped.
47
+ // Claude Code ~/.claude/commands/feature.md -> /feature <request>
48
+ // Codex ~/.codex/skills/nomarmy-feature/SKILL.md (confirmed from
49
+ // codex 0.156's own $CODEX_HOME/skills loader)
50
+ // Cursor ~/.cursor/commands/feature.md (Cursor's documented
51
+ // user-level commands directory; not verified against a
52
+ // real install here)
53
+
54
+ const COMMAND_MARKER = "<!-- nomarmy:";
55
+ const PLAYBOOKS = Object.freeze({
56
+ feature: {
57
+ description: "nomArmy -- build a feature end to end with the army, then come back with a branch ready to merge",
58
+ argumentHint: "<the feature you want> | resume <run-id>",
59
+ },
60
+ });
61
+
62
+ export function defaultCommandDirs(env = process.env) {
63
+ const home = os.homedir();
64
+ return {
65
+ claude: env.NOMARMY_CLAUDE_COMMANDS_DIR ? path.resolve(env.NOMARMY_CLAUDE_COMMANDS_DIR) : path.join(home, ".claude", "commands"),
66
+ codex: env.NOMARMY_CODEX_SKILLS_DIR ? path.resolve(env.NOMARMY_CODEX_SKILLS_DIR) : path.join(env.CODEX_HOME || path.join(home, ".codex"), "skills"),
67
+ cursor: env.NOMARMY_CURSOR_COMMANDS_DIR ? path.resolve(env.NOMARMY_CURSOR_COMMANDS_DIR) : path.join(home, ".cursor", "commands"),
68
+ };
69
+ }
70
+
71
+ /** One playbook rendered for one coordinator: { relPath, text }. */
72
+ export function renderPlaybook(name, body, target) {
73
+ const meta = PLAYBOOKS[name];
74
+ const marker = `${COMMAND_MARKER}${name} -- installed by \`nomarmy connect ${target}\`; edits here are overwritten on the next connect -->`;
75
+ if (target === "claude") {
76
+ return { relPath: `${name}.md`, text: `---\ndescription: ${meta.description}\nargument-hint: ${meta.argumentHint}\n---\n${marker}\n\n${body.replace("{{REQUEST}}", "$ARGUMENTS")}` };
77
+ }
78
+ if (target === "codex") {
79
+ // A skill is invoked by the model when the request matches its
80
+ // description, so the description says when to use it.
81
+ const description = `${meta.description}. Use when the user asks nomArmy (or "the army") to build a feature end to end, or to resume a nomArmy run by its run-id.`;
82
+ return { relPath: path.join(`nomarmy-${name}`, "SKILL.md"), text: `---\nname: nomarmy-${name}\ndescription: ${description}\n---\n${marker}\n\n${body.replace("{{REQUEST}}", "The feature is the one the user asked for when this skill was invoked (or the run-id they asked to resume).")}` };
83
+ }
84
+ return { relPath: `${name}.md`, text: `${marker}\n\n# nomArmy: ${name}\n\n${body.replace("{{REQUEST}}", `The feature is the text the user wrote after /${name} (or \`resume <run-id>\`).`)}` };
85
+ }
86
+
87
+ /**
88
+ * Install every playbook for one coordinator.
89
+ * @returns {{ installed: string[], skipped: string[] }} relative paths
90
+ */
91
+ export function installPlaybooks({ nomarmyRoot, target, dir = defaultCommandDirs()[target] }) {
92
+ const srcDir = path.join(nomarmyRoot, "playbooks");
93
+ const out = { installed: [], skipped: [], dir };
94
+ if (!fs.existsSync(srcDir)) return out;
95
+ for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith(".md"))) {
96
+ const name = file.replace(/\.md$/, "");
97
+ if (!PLAYBOOKS[name]) continue;
98
+ const { relPath, text } = renderPlaybook(name, fs.readFileSync(path.join(srcDir, file), "utf8"), target);
99
+ const dest = path.join(dir, relPath);
100
+ if (fs.existsSync(dest) && !fs.readFileSync(dest, "utf8").includes(COMMAND_MARKER)) { out.skipped.push(relPath); continue; }
101
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
102
+ fs.writeFileSync(dest, text);
103
+ out.installed.push(relPath);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ const STATUS_LINE_REFRESH_SECONDS = 5;
109
+
110
+ /** Claude Code's user settings file. */
111
+ export function defaultClaudeSettingsPath(env = process.env) {
112
+ return env.NOMARMY_CLAUDE_SETTINGS_PATH ? path.resolve(env.NOMARMY_CLAUDE_SETTINGS_PATH) : path.join(os.homedir(), ".claude", "settings.json");
113
+ }
114
+
115
+ /**
116
+ * Point Claude Code's status line at nomArmy's (lib/statusline.mjs in the
117
+ * installed copy) -- only when no status line is configured, or when the
118
+ * configured one is already nomArmy's (refreshed to the current path). An
119
+ * operator's own status line is never replaced; they're told how to add
120
+ * nomArmy's to it instead.
121
+ * @returns {"installed"|"updated"|"kept-yours"|"unchanged"|"skipped"}
122
+ */
123
+ export function installClaudeStatusLine({ installDir, settingsPath = defaultClaudeSettingsPath() }) {
124
+ let settings = {};
125
+ try { if (fs.existsSync(settingsPath)) settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
126
+ catch { return "skipped"; } // an unparseable settings file is never rewritten
127
+ const command = `node ${JSON.stringify(path.join(installDir, "lib", "statusline.mjs"))}`;
128
+ const current = settings.statusLine;
129
+ if (current && !String(current.command ?? "").includes("statusline.mjs")) return "kept-yours";
130
+ // refreshInterval: Claude Code otherwise re-runs the command only on
131
+ // conversation events, so an idle session's elapsed times froze.
132
+ if (current?.command === command && current?.refreshInterval === STATUS_LINE_REFRESH_SECONDS) return "unchanged";
133
+ settings.statusLine = { type: "command", command, padding: 0, refreshInterval: STATUS_LINE_REFRESH_SECONDS };
134
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
135
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
136
+ return current ? "updated" : "installed";
137
+ }
138
+
139
+ export function installMcpCopy({ nomarmyRoot, installDir, run = defaultRun }) {
140
+ fs.mkdirSync(path.join(installDir, "mcp"), { recursive: true });
141
+ fs.copyFileSync(path.join(nomarmyRoot, "package.json"), path.join(installDir, "package.json"));
142
+ fs.copyFileSync(path.join(nomarmyRoot, "mcp", "server.mjs"), path.join(installDir, "mcp", "server.mjs"));
143
+ fs.rmSync(path.join(installDir, "lib"), { recursive: true, force: true });
144
+ fs.cpSync(path.join(nomarmyRoot, "lib"), path.join(installDir, "lib"), { recursive: true });
145
+ // lib/sandbox-images.mjs resolves docker/ relative to its own location
146
+ // (lib/ and docker/ as siblings), so the installed copy needs this too,
147
+ // not just the dev checkout -- otherwise a lazy Go/Rust image build would
148
+ // work from a repo clone but silently fail to find its Dockerfile once
149
+ // installed.
150
+ const dockerSrc = path.join(nomarmyRoot, "docker");
151
+ if (fs.existsSync(dockerSrc)) {
152
+ fs.rmSync(path.join(installDir, "docker"), { recursive: true, force: true });
153
+ fs.cpSync(dockerSrc, path.join(installDir, "docker"), { recursive: true });
154
+ }
155
+ // mcp/server.mjs resolves config/providers.yml relative to its own
156
+ // location (mcp/ and config/ as siblings), the same lib/sandbox-images.mjs
157
+ // pattern above uses for docker/ -- without this, an installed server can
158
+ // never see a dispatch pool config an operator wrote into the dev
159
+ // checkout's config/ directory. This incidentally also copies
160
+ // common.env/profiles/*.env alongside it (the whole directory copies as
161
+ // one unit, same as docker/), but the server never relies on reading
162
+ // those from here: their values only ever reach it through connect-time
163
+ // -e env injection (see deriveWorkerModelEnv below), so a stale copy of
164
+ // either sitting in installDir is inert, never consulted.
165
+ const configSrc = path.join(nomarmyRoot, "config");
166
+ if (fs.existsSync(configSrc)) {
167
+ fs.rmSync(path.join(installDir, "config"), { recursive: true, force: true });
168
+ fs.cpSync(configSrc, path.join(installDir, "config"), { recursive: true });
169
+ }
170
+ run("npm", ["install", "--omit=dev"], { cwd: installDir });
171
+ run(process.execPath, ["--check", "mcp/server.mjs"], { cwd: installDir });
172
+ }
173
+
174
+ function defaultRun(cmd, args, opts = {}) {
175
+ return execFileSync(cmd, args, { stdio: "inherit", ...opts });
176
+ }
177
+ // Best-effort removal of an old registration: failure (nothing registered
178
+ // under that name yet, likely a fresh install) is expected, not an error.
179
+ // Takes the same injected `run` every other call in this module does, so a
180
+ // test's fake `run` sees every subprocess this module would spawn, never
181
+ // only some of them.
182
+ function quietRun(run, cmd, args, opts = {}) {
183
+ try { return run(cmd, args, opts); } catch { return null; }
184
+ }
185
+
186
+ /**
187
+ * Pull the "KEY=value" lines out of `claude mcp get <name>`'s "Environment:"
188
+ * section. Observed live: a registration carrying NOMARMY_WORKER_MODEL (an
189
+ * operator testing a non-default local model) silently lost that variable
190
+ * on the next `nomarmy connect`/`update`, because the re-add below used to
191
+ * pass no -e flags at all -- reinstalling the server code reset which model
192
+ * every dispatch actually used, with no warning. A blank line or a line that
193
+ * is not "KEY=value" ends the block; nothing registered yet is not an error.
194
+ */
195
+ export function parseClaudeEnv(output) {
196
+ const lines = String(output ?? "").split(/\r?\n/);
197
+ const start = lines.findIndex(l => /^\s*Environment:\s*$/.test(l));
198
+ if (start === -1) return {};
199
+ const env = {};
200
+ for (let i = start + 1; i < lines.length; i++) {
201
+ const m = lines[i].match(/^\s{2,}([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
202
+ if (!m) break;
203
+ env[m[1]] = m[2];
204
+ }
205
+ return env;
206
+ }
207
+
208
+ function captureExistingEnv(run, name) {
209
+ try {
210
+ const out = run("claude", ["mcp", "get", name], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
211
+ return parseClaudeEnv(out);
212
+ } catch {
213
+ return {};
214
+ }
215
+ }
216
+
217
+ function readEnvValue(filePath, key) {
218
+ try {
219
+ const m = fs.readFileSync(filePath, "utf8").match(new RegExp(`^${key}=(.*)$`, "m"));
220
+ return m ? m[1].trim() : null;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * config/common.env's NOMARMY_WORKER_MODEL sat unused for every model choice
228
+ * until now -- nothing ever read it back out to actually route dispatch, so
229
+ * picking a non-default model in `nomarmy setup`/`model` silently had no
230
+ * effect on which model workers were routed to. This makes that file the
231
+ * real source of truth: whatever it says the configured model and its
232
+ * thinking support are, the MCP registration is kept in sync with, every
233
+ * connect. Missing file or keys is not an error -- an older checkout with no
234
+ * such keys yet just contributes nothing here, unchanged from before.
235
+ */
236
+ export function deriveWorkerModelEnv(nomarmyRoot) {
237
+ const commonPath = path.join(nomarmyRoot, "config", "common.env");
238
+ const model = readEnvValue(commonPath, "NOMARMY_WORKER_MODEL");
239
+ const thinking = readEnvValue(commonPath, "NOMARMY_MODEL_THINKING");
240
+ const env = {};
241
+ if (model) env.NOMARMY_WORKER_MODEL = model;
242
+ if (thinking !== null) env.NOMARMY_WORKER_MODEL_THINKING = thinking;
243
+ return env;
244
+ }
245
+
246
+ const SERVER_NAME = "nomarmy-local-worker";
247
+
248
+ /**
249
+ * Every distinct auth_env name declared across config/providers.yml's
250
+ * pools, mapped to a placeholder value -- NEVER the real credential, which
251
+ * lives only in OpenClaw's own store from `nomarmy agents add`'s
252
+ * registration step.
253
+ *
254
+ * Why this needs to exist at all: an api agent's auth_env is checked for
255
+ * TRUTHINESS ONLY by the dispatcher (lib/dispatch-config.mjs's
256
+ * availableEntries), inside the MCP SERVER's own process.env -- and
257
+ * "export it in some shell" has no reliable path to that specific process.
258
+ * A GUI-launched coordinator never inherited a later shell export in the
259
+ * first place; a terminal-launched one only did if the export predated
260
+ * that specific launch. Live symptom this closes: the old providers list
261
+ * kept showing an entry as unset even after registration had genuinely
262
+ * succeeded and the variable really was exported -- just never in the
263
+ * shell that mattered.
264
+ *
265
+ * Called on every connect, the same way deriveWorkerModelEnv already is,
266
+ * so a newly added api agent is picked up the next time an operator
267
+ * reconnects for ANY reason (a model swap, an update, a fresh install) --
268
+ * not only if they remember a separate step right after `agents add`.
269
+ * A missing/invalid agents.yml contributes nothing here, silently -- that's
270
+ * `agents list`'s problem to report, not connect's to block on.
271
+ */
272
+ export function derivePoolAuthEnvPlaceholders(configDir = globalConfigDir()) {
273
+ let loaded;
274
+ try {
275
+ loaded = loadAgents(configDir);
276
+ } catch {
277
+ return {};
278
+ }
279
+ const env = {};
280
+ for (const agent of Object.values(loaded.agents)) {
281
+ if (agent.kind === "api" && agent.auth_env) env[agent.auth_env] = "registered";
282
+ }
283
+ return env;
284
+ }
285
+
286
+ /**
287
+ * @param {{ nomarmyRoot: string, installDir?: string, run?: Function, extraEnv?: Record<string,string>, configDir?: string }} input
288
+ */
289
+ export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, extraEnv = {}, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().claude, settingsPath = defaultClaudeSettingsPath() }) {
290
+ installMcpCopy({ nomarmyRoot, installDir, run });
291
+ const commands = installPlaybooks({ nomarmyRoot, target: "claude", dir: commandsDir });
292
+ const notifier = buildNotifierApp({ nomarmyRoot });
293
+ const statusLine = installClaudeStatusLine({ installDir, settingsPath });
294
+ const preservedEnv = captureExistingEnv(run, SERVER_NAME);
295
+ // config/common.env is the source of truth for which model is configured;
296
+ // its worker-routing keys always win over whatever the old registration
297
+ // happened to have, which may be stale (a previous model swap that never
298
+ // made it into the registration, or vice versa). Any OTHER custom env var
299
+ // an operator set some other way is still preserved untouched.
300
+ //
301
+ // Precedence, lowest to highest: a freshly-derived pool placeholder fills
302
+ // in only when nothing already covers that key; whatever's genuinely
303
+ // already registered (preservedEnv) wins over that; extraEnv is an
304
+ // explicit, immediate ask from THIS call (e.g. `nomarmy providers add
305
+ // --update-mcp`, right after writing a brand-new entry, before its
306
+ // placeholder would otherwise show up here on this same call); and
307
+ // config/common.env's derived worker-model keys still win over
308
+ // everything, exactly as before this existed.
309
+ const finalEnv = { ...derivePoolAuthEnvPlaceholders(configDir), ...preservedEnv, ...extraEnv, ...deriveWorkerModelEnv(nomarmyRoot) };
310
+ quietRun(run, "claude", ["mcp", "remove", SERVER_NAME, "--scope", "user"]);
311
+ quietRun(run, "claude", ["mcp", "remove", SERVER_NAME]);
312
+ const serverPath = path.join(installDir, "mcp", "server.mjs");
313
+ // -e is variadic (`-e <env...>`): it greedily swallows every bare token
314
+ // after it, including the server name, until the next recognized flag or
315
+ // `--`. The server name and any --scope must come before -e, not after,
316
+ // or `claude mcp add` rejects the server name itself as a malformed
317
+ // "KEY=value" environment entry.
318
+ const envArgs = Object.entries(finalEnv).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
319
+ try {
320
+ run("claude", ["mcp", "add", "--scope", "user", SERVER_NAME, ...envArgs, "--", "node", serverPath]);
321
+ } catch {
322
+ // Compatibility fallback for Claude Code versions whose MCP command
323
+ // does not support --scope user.
324
+ run("claude", ["mcp", "add", SERVER_NAME, ...envArgs, "--", "node", serverPath]);
325
+ }
326
+ run("claude", ["mcp", "get", SERVER_NAME]);
327
+ return { installDir, serverPath, preservedEnv: finalEnv, commands, statusLine, notifier };
328
+ }
329
+
330
+ /**
331
+ * @param {{ nomarmyRoot: string, installDir?: string, run?: Function }} input
332
+ */
333
+ export function connectCodex({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, skillsDir = defaultCommandDirs().codex }) {
334
+ installMcpCopy({ nomarmyRoot, installDir, run });
335
+ const commands = installPlaybooks({ nomarmyRoot, target: "codex", dir: skillsDir });
336
+ const notifier = buildNotifierApp({ nomarmyRoot });
337
+ quietRun(run, "codex", ["mcp", "remove", SERVER_NAME]);
338
+ const serverPath = path.join(installDir, "mcp", "server.mjs");
339
+ run("codex", ["mcp", "add", SERVER_NAME, "--", "node", serverPath]);
340
+ run("codex", ["mcp", "list"]);
341
+ return { installDir, serverPath, commands, notifier };
342
+ }
343
+
344
+ export function defaultCursorConfigPath() {
345
+ return process.env.NOMARMY_CURSOR_CONFIG_PATH
346
+ || path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".cursor", "mcp.json");
347
+ }
348
+
349
+ /**
350
+ * Cursor has no CLI for this: registration is a JSON file
351
+ * (~/.cursor/mcp.json), read-modify-written under { mcpServers: { name: {
352
+ * command, args, env } } }, the same schema Claude Desktop and most other
353
+ * MCP hosts converged on. A file that does not exist yet is fine (fresh
354
+ * install); a file that exists but fails to parse is NEVER silently
355
+ * overwritten -- that would discard whatever else was in it (other
356
+ * configured servers, hand edits) without the operator ever seeing it.
357
+ */
358
+ export function readCursorConfig(configPath) {
359
+ let raw;
360
+ try {
361
+ raw = fs.readFileSync(configPath, "utf8");
362
+ } catch {
363
+ return {};
364
+ }
365
+ let parsed;
366
+ try {
367
+ parsed = JSON.parse(raw);
368
+ } catch (err) {
369
+ throw new Error(`${configPath} exists but is not valid JSON (${err.message}). Fix or remove it by hand, then re-run -- it is never overwritten blindly.`);
370
+ }
371
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
372
+ throw new Error(`${configPath} exists but its top level is not a JSON object. Fix or remove it by hand, then re-run.`);
373
+ }
374
+ return parsed;
375
+ }
376
+
377
+ /**
378
+ * @param {{ nomarmyRoot: string, installDir?: string, configPath?: string }} input
379
+ */
380
+ export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), configPath = defaultCursorConfigPath(), run = defaultRun, commandsDir = defaultCommandDirs().cursor }) {
381
+ installMcpCopy({ nomarmyRoot, installDir, run });
382
+ const commands = installPlaybooks({ nomarmyRoot, target: "cursor", dir: commandsDir });
383
+ const notifier = buildNotifierApp({ nomarmyRoot });
384
+ const serverPath = path.join(installDir, "mcp", "server.mjs");
385
+ const config = readCursorConfig(configPath);
386
+ if (!config.mcpServers || typeof config.mcpServers !== "object") config.mcpServers = {};
387
+ // Preserve this entry's own existing env vars (the same incident
388
+ // connectClaude's captureExistingEnv guards against: silently dropping an
389
+ // operator-set NOMARMY_WORKER_MODEL on every reinstall) -- every OTHER
390
+ // configured server in the file is left completely untouched.
391
+ const existing = config.mcpServers[SERVER_NAME];
392
+ const preservedEnv = (existing && typeof existing.env === "object" && existing.env) || {};
393
+ config.mcpServers[SERVER_NAME] = { command: "node", args: [serverPath], env: preservedEnv };
394
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
395
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
396
+ return { installDir, serverPath, configPath, preservedEnv, commands, notifier };
397
+ }
398
+
399
+ /** True when Cursor's config file already has a nomArmy entry -- used by
400
+ * `nomarmy update` to decide whether to re-sync it, since Cursor has no
401
+ * `commandExists` equivalent to check the way Claude/Codex do. */
402
+ export function cursorAlreadyConnected(configPath = defaultCursorConfigPath()) {
403
+ try {
404
+ const config = readCursorConfig(configPath);
405
+ return Boolean(config.mcpServers && config.mcpServers[SERVER_NAME]);
406
+ } catch {
407
+ return false;
408
+ }
409
+ }
@@ -0,0 +1,23 @@
1
+ // What every coordinator (Claude Code, Codex, Cursor) is told when it
2
+ // connects to nomArmy's MCP server: the MCP `instructions` field. It used
3
+ // to live only in this repo's CLAUDE.md, which a person had to copy into
4
+ // each project, and which an npm install doesn't even include.
5
+ //
6
+ // Kept short: a coordinator reads it on every session. The detail is in each
7
+ // tool's own description.
8
+
9
+ export const COORDINATOR_INSTRUCTIONS = `nomArmy runs bounded engineering jobs (noms) in isolated git worktrees and sandboxes, on a local model or on the api and subscription agents the operator configured. You are the General: you plan, brief, dispatch, review, integrate and accept. Workers never are.
10
+
11
+ Before dispatching:
12
+ - Answer where-is / who-calls / grep questions with repo_evidence (deterministic, [path:line] on every hit). Use mode: scout only for read-only research that would otherwise pull many files into your own context.
13
+ - Call army to see this repo's roles and which agent each runs on; dispatch by army_role when a role fits. A job on a subscription agent needs on_behalf_of set to that agent's owner.
14
+ - A Claude subscription agent (claude-cli) runs its tools on this machine, outside the sandbox: use it for scout and review work. nomArmy refuses implement jobs on it unless the operator set allow_host_tools; send build work to a sandboxed agent.
15
+ - Brief outcomes, not edits: a task, explicit acceptance criteria, and the tests that prove it. Put facts you've already resolved in evidence.
16
+ - Prefer local_worker_start + local_worker_status for anything longer than a few minutes. For a whole feature, use /feature (run_start keeps a run's jobs, spend and hours bounded).
17
+
18
+ Trust boundary:
19
+ - A worker's four-line report is a claim; nomArmy's verified git record and independent verification are the evidence. A job isn't complete if its report is missing or malformed, its STATUS is partial or blocked, STATUS done lacks VERIFICATION pass, or its changes aren't committed by nomArmy.
20
+ - Read the diff of anything material before integrating it. nomArmy commits on the worker's branch and never merges into yours: integration, conflicts and pushes are yours.
21
+ - Failed and incomplete worktrees are kept for review; clean up with local_worker_cleanup or local_worker_sweep once you've decided.
22
+
23
+ Never delegate deployments, production access, cloud or SSH credentials, secrets, Terraform state or kubectl contexts to a worker.`;