nomarmy 0.1.0-alpha.10 → 0.1.0-alpha.12

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/bin/nomarmy.mjs CHANGED
@@ -18,8 +18,11 @@ import { buildConfigProposal } from "../lib/propose.mjs";
18
18
  import { detectHardware } from "../lib/hardware.mjs";
19
19
  import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf.mjs";
20
20
  import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
21
- import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir } from "../lib/connect.mjs";
21
+ import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir, installMcpCopy, SCOPES, claudeUserScoped, portableServerLaunch } from "../lib/connect.mjs";
22
22
  import { compareVersions, readPackageVersion, readInstallVersions, copyIsStale } from "../lib/install-freshness.mjs";
23
+ import { loadJobRecords, computeStats, formatStats, parseSince, resolveRepo } from "../lib/stats.mjs";
24
+ import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings } from "../lib/validators.mjs";
25
+ import { probeModel } from "../lib/model-probe.mjs";
23
26
  import { ID_RE, AUTH_ENV_NAME_RE, OPENCLAW_PROVIDER_ID_RE, openclawProviderId, isNativeProviderType } from "../lib/dispatch-schema.mjs";
24
27
  import { loadAgents, readAgentsFile, writeAgentsFile, agentsConfigPath, apiAgentAsPoolEntry, describeAgent as describeAgentLabel, agentRunsToolsOnHost, agentProviderId, AGENT_KINDS, API_PROVIDER_TYPES, RESERVED_AGENT_NAMES, BUILTIN_LOCAL_AGENT } from "../lib/agents.mjs";
25
28
  import { loadArmy, mergeArmy, describeArmy, readArmyFile, updateArmyInFile, assignRoleInFile, parseTargetSpec, armyLayerPath, globalConfigDir, DEFAULT_ARMY, ARMY_PHASES, LOCAL_CONFIG_FILENAME } from "../lib/army.mjs";
@@ -40,10 +43,10 @@ import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalo
40
43
  // "already connected, so resync" detection if it has no CLI to probe.
41
44
  const KNOWN_TARGETS = ["claude", "codex", "cursor"];
42
45
 
43
- function connectTarget(target, { nomarmyRoot, run }) {
44
- if (target === "claude") return connectClaude({ nomarmyRoot, run });
45
- if (target === "codex") return connectCodex({ nomarmyRoot, run });
46
- if (target === "cursor") return connectCursor({ nomarmyRoot, run });
46
+ function connectTarget(target, { nomarmyRoot, run, scope = "user", projectDir = null }) {
47
+ if (target === "claude") return connectClaude({ nomarmyRoot, run, scope, projectDir });
48
+ if (target === "codex") return connectCodex({ nomarmyRoot, run, scope });
49
+ if (target === "cursor") return connectCursor({ nomarmyRoot, run, scope, projectDir });
47
50
  throw new Error(`unknown connect target: ${target}`);
48
51
  }
49
52
 
@@ -214,10 +217,34 @@ Usage: nomarmy <command> [options]
214
217
  statusline the one-line summary Claude Code's status line shows
215
218
  (installed by \`nomarmy connect claude\` when no status
216
219
  line is set); reads the session JSON on stdin
217
- connect [claude] [codex] [cursor]
220
+ connect [claude] [codex] [cursor] [--scope user|local|project]
218
221
  (Re-)register the MCP server with one or more coordinators.
219
222
  With no target and not --json, prompts an interactive
220
- multi-select instead.
223
+ multi-select instead. --scope user (default) registers it
224
+ for every project; local for this repository, only you;
225
+ project for this repository, committed for the team
226
+ (.mcp.json or .cursor/mcp.json, running \`nomarmy mcp\`).
227
+ Codex has only the user scope.
228
+ stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>]
229
+ [--repo <path|name>] [--all-repos] [--json]
230
+ What nomArmy's job records show for this repository (or
231
+ all): volume by role and model, code committed, time,
232
+ tokens and spend, how often a "done" report failed
233
+ independent verification, what didn't finish, reviewers,
234
+ and review flags. From verified records, never reports.
235
+ validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>
236
+ Optional semantic checks from a model you configure with
237
+ your own key. Today: Jev (TypeSafe). \`add jev\` asks for the
238
+ key without echoing it (or reads --key-stdin), saves it
239
+ where only you can read it, and makes one test call. Its
240
+ answers only add review flags, and it sends excerpts of
241
+ your code to TypeSafe. \`add judge --agent <name> --model
242
+ <model>\` makes one of your agents a model judge: does the
243
+ diff meet each acceptance criterion, match the report, keep
244
+ its tests as strong? An agent whose tools run on this
245
+ machine needs --host-tools.
246
+ mcp Start nomArmy's MCP server on stdio with this machine's
247
+ settings. What a --scope project registration runs.
221
248
  sandbox The Podman VM every sandbox shares (macOS, Windows): its
222
249
  memory, disk and images. --memory <GiB> resizes it (stops,
223
250
  sets, restarts; refused while jobs run); --prune removes
@@ -1709,6 +1736,13 @@ async function cmdUpdate() {
1709
1736
  // this process loaded. Returns the targets reconnected.
1710
1737
  function reconnectCoordinators() {
1711
1738
  const targets = connectedTargets();
1739
+ // Per-repo registrations run the installed copy (or `nomarmy mcp`), so a
1740
+ // refreshed copy is all they need; re-registering them at user scope would
1741
+ // add nomArmy to every project.
1742
+ if (!targets.length) {
1743
+ execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", "--copy-only", ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
1744
+ return [];
1745
+ }
1712
1746
  if (targets.length) {
1713
1747
  if (!json) console.log(`\nReconnecting ${targets.join(", ")}...`);
1714
1748
  execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", ...targets, ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
@@ -1717,7 +1751,7 @@ function reconnectCoordinators() {
1717
1751
  }
1718
1752
 
1719
1753
  function connectedTargets() {
1720
- return [commandExists("claude") && "claude", commandExists("codex") && "codex", cursorAlreadyConnected() && "cursor"].filter(Boolean);
1754
+ return [commandExists("claude") && claudeUserScoped() && "claude", commandExists("codex") && "codex", cursorAlreadyConnected() && "cursor"].filter(Boolean);
1721
1755
  }
1722
1756
 
1723
1757
  async function updateFromNpm() {
@@ -1788,7 +1822,20 @@ async function cmdConnect() {
1788
1822
  // scan the whole argv), and `nomarmy connect --json claude` once broke
1789
1823
  // that promise by reading argv[1] directly -- --json landed in target's
1790
1824
  // slot instead. Multiple bare tokens are now allowed too, for multi-select.
1791
- const requested = argv.slice(1).filter((a) => !a.startsWith("--"));
1825
+ const flagValues = new Set(["--scope", "--repo"].map((name) => argv.indexOf(name)).filter((i) => i >= 0).map((i) => i + 1));
1826
+ const requested = argv.slice(1).filter((a, i) => !a.startsWith("--") && !flagValues.has(i + 1));
1827
+ const scope = value("scope", "user");
1828
+ if (!SCOPES.includes(scope)) throw new Error(`--scope must be one of ${SCOPES.join(", ")}, got "${scope}".`);
1829
+ let projectDir = null;
1830
+ if (scope !== "user") {
1831
+ try { projectDir = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
1832
+ catch { throw new Error(`--scope ${scope} registers nomArmy for one repository, and ${repoDir} isn't inside a git repository. Run it from the repository (or pass --repo <dir>).`); }
1833
+ }
1834
+ if (flag("copy-only")) {
1835
+ // `nomarmy update` with only per-repo registrations: refresh the copy they run.
1836
+ installMcpCopy({ nomarmyRoot, installDir: defaultInstallDir(), run: (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: json ? "ignore" : "inherit", ...opts }) });
1837
+ return json ? out({ refreshed: defaultInstallDir() }) : console.log(c.green(`✓ Refreshed the nomArmy copy in ${defaultInstallDir()}.`));
1838
+ }
1792
1839
  let targets;
1793
1840
  if (requested.length > 0) {
1794
1841
  const unknown = requested.filter((t) => !KNOWN_TARGETS.includes(t));
@@ -1813,9 +1860,12 @@ async function cmdConnect() {
1813
1860
  }
1814
1861
  try {
1815
1862
  if (!json) console.log(c.bold(`\n🍪 Connecting nomArmy to ${target}...`));
1816
- const result = connectTarget(target, { nomarmyRoot, run });
1863
+ const result = connectTarget(target, { nomarmyRoot, run, scope, projectDir });
1817
1864
  results.push({ target, connected: true, ...result });
1818
- if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}.`));
1865
+ if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}${scope === "user" ? "." : scope === "local" ? ` for ${projectDir} only (not committed).` : ` in ${path.relative(projectDir, result.configPath ?? path.join(projectDir, ".mcp.json"))}, for everyone who clones this repository.`}`));
1866
+ if (!json && scope === "local" && result?.excluded?.length) console.log(c.dim(` Kept out of git (.git/info/exclude): ${result.excluded.join(", ")}`));
1867
+ if (!json && scope === "project") console.log(c.dim(` Commit it along with the playbook in ${path.relative(projectDir, result.commands.dir)}. Each teammate needs nomArmy installed and set up (npm install -g nomarmy@alpha, then nomarmy setup); the registration runs \`nomarmy mcp\`, which uses their own settings.`));
1868
+ if (!json && scope !== "user" && target === "claude" && result?.userScoped) console.log(c.yellow(` nomArmy is also registered for all your projects (user scope). To use it only where you register it per repository: claude mcp remove nomarmy-local-worker -s user`));
1819
1869
  if (!json && result?.commands?.installed?.length) console.log(c.green(`✓ Playbooks: ${result.commands.installed.join(", ")}`) + c.dim(` in ${result.commands.dir} (restart ${target} to pick up a new one)`));
1820
1870
  if (!json && result?.notifier?.status === "built") console.log(c.green("✓ Notifications: nomArmy.app, with nomArmy's icon") + c.dim(" (macOS asks once whether to allow it)"));
1821
1871
  if (!json && result?.notifier?.status === "failed") console.log(c.yellow(`⚠ Couldn't build nomArmy.app (${result.notifier.reason}); notifications still work, with Script Editor's icon. Xcode's command-line tools provide swiftc: xcode-select --install`));
@@ -2617,7 +2667,145 @@ async function cmdStatusline() {
2617
2667
  process.stdout.write(`${statusLineText({ session })}\n`);
2618
2668
  }
2619
2669
 
2620
- const commands = { scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, install: cmdInstall, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, sandbox: cmdSandbox, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
2670
+ function cmdStats() {
2671
+ const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
2672
+ const records = loadJobRecords(path.join(stateRoot, "jobs"));
2673
+ let repo = null;
2674
+ if (value("repo")) repo = resolveRepo(records, value("repo"));
2675
+ else if (!flag("all-repos")) {
2676
+ try { repo = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
2677
+ catch { throw new Error(`${repoDir} isn't inside a git repository; run nomarmy stats from one, or pass --repo <name> or --all-repos`); }
2678
+ }
2679
+ const stats = computeStats(records, { repo, sinceMs: parseSince(value("since")), untilMs: parseSince(value("until")), role: value("role"), model: value("model") });
2680
+ if (json) return out(stats);
2681
+ console.log(formatStats(stats));
2682
+ }
2683
+
2684
+ // Read one line without echoing it: stty -echo around the read, restored
2685
+ // even if the read fails. Windows has no stty, so it says the input shows.
2686
+ async function readHiddenLine(prompt) {
2687
+ const hide = process.stdin.isTTY && process.platform !== "win32";
2688
+ if (!hide) console.log(c.yellow("(your input will be visible as you type)"));
2689
+ const rl = createInterface({ input, output });
2690
+ try {
2691
+ if (hide) spawnSync("stty", ["-echo"], { stdio: ["inherit", "ignore", "ignore"] });
2692
+ return (await rl.question(prompt)).trim();
2693
+ } finally {
2694
+ if (hide) { spawnSync("stty", ["echo"], { stdio: ["inherit", "ignore", "ignore"] }); process.stdout.write("\n"); }
2695
+ rl.close();
2696
+ }
2697
+ }
2698
+
2699
+ async function readStdin() {
2700
+ const chunks = [];
2701
+ for await (const chunk of process.stdin) chunks.push(chunk);
2702
+ return Buffer.concat(chunks).toString("utf8").trim();
2703
+ }
2704
+
2705
+ // One tiny System One request, to prove the key and the route work.
2706
+ async function testJev(settings) {
2707
+ const { answers } = await askJev({ key: settings.key, model: settings.model, state: { text: "The build finished and all 12 tests passed." },
2708
+ questions: { passed: { type: "noul", instructions: "Does the text say the tests passed?", criteria: { true: "It says the tests passed", false: "It doesn't" } } } });
2709
+ return typeof answers.passed?.noul === "number";
2710
+ }
2711
+
2712
+ async function cmdValidators() {
2713
+ const [sub = "list", name] = argv.slice(1).filter((a) => !a.startsWith("--"));
2714
+ if (sub === "list") {
2715
+ let config = {};
2716
+ try { config = loadValidators(); } catch (error) { if (json) return out({ error: error.message }); console.log(c.red(error.message)); process.exitCode = 1; return; }
2717
+ const judge = config.judge ? { enabled: config.judge.enabled, agent: config.judge.agent, model: config.judge.model, checks: config.judge.checks, hostTools: config.judge.host_tools } : null;
2718
+ const jev = config.jev ? { enabled: config.jev.enabled, checks: config.jev.checks, model: config.jev.model, key: config.jev.key_env ? `env ${config.jev.key_env}` : config.jev.key_file, keyReadable: Boolean(jevSettings()) } : null;
2719
+ if (json) return out({ path: validatorsPath(), jev, judge });
2720
+ if (!jev && !judge) { console.log("No validators configured. Add one with: nomarmy validators add jev, or nomarmy validators add judge --agent <name> --model <model>"); return; }
2721
+ if (jev) console.log(`Jev: ${jev.enabled ? c.green("on") : "off"} (${jev.model}); checks: ${jev.checks.join(", ")}; key: ${jev.key}${jev.keyReadable ? "" : c.red(" (not readable)")}`);
2722
+ if (judge) console.log(`Judge: ${judge.enabled ? c.green("on") : "off"} (${judge.agent}/${judge.model}); checks: ${judge.checks.join(", ")}${judge.hostTools ? c.yellow("; host tools allowed") : ""}`);
2723
+ return;
2724
+ }
2725
+ if (name === "judge") return cmdValidatorsJudge(sub);
2726
+ if (name !== "jev") throw new Error("Usage: nomarmy validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>");
2727
+ if (sub === "add") {
2728
+ if (!json) {
2729
+ console.log(c.bold("🍪 Jev (TypeSafe) for nomArmy's semantic checks\n"));
2730
+ console.log("It checks that a scout's cited lines support its finding, and that a worker's report matches its diff.");
2731
+ console.log("Its answers only add review flags; they never pass a check or allow a commit.");
2732
+ console.log(c.yellow("It sends excerpts of your code (findings, cited lines, diffs, worker reports) to TypeSafe.\n"));
2733
+ }
2734
+ const key = flag("key-stdin") ? await readStdin() : await readHiddenLine("TypeSafe API key (not shown): ");
2735
+ const saved = saveJevKey(key);
2736
+ let ok = false, why = null;
2737
+ try { ok = await testJev(jevSettings()); } catch (error) { why = error.message; }
2738
+ if (json) return out({ saved: true, keyFile: saved.keyFile, configPath: saved.configPath, test: ok ? "pass" : "fail", reason: why });
2739
+ console.log(c.green(`✓ Saved the key to ${saved.keyFile} (readable only by you) and turned Jev on in ${saved.configPath}.`));
2740
+ console.log(ok ? c.green("✓ Test call answered. New jobs use it; restart open coordinator sessions to pick it up.") : c.red(`✗ Test call failed: ${why ?? "no answer"}. Check the key, then: nomarmy validators test jev`));
2741
+ if (!ok) process.exitCode = 1;
2742
+ return;
2743
+ }
2744
+ if (sub === "test") {
2745
+ const settings = jevSettings();
2746
+ if (!settings) throw new Error("Jev isn't configured, or its key isn't readable. Add it with: nomarmy validators add jev");
2747
+ let ok = false, why = null;
2748
+ try { ok = await testJev(settings); } catch (error) { why = error.message; }
2749
+ if (json) return out({ test: ok ? "pass" : "fail", reason: why });
2750
+ console.log(ok ? c.green("✓ Jev answered.") : c.red(`✗ Jev test call failed: ${why ?? "no answer"}`));
2751
+ if (!ok) process.exitCode = 1;
2752
+ return;
2753
+ }
2754
+ if (sub === "remove") {
2755
+ const result = removeJev();
2756
+ if (json) return out(result);
2757
+ console.log(c.green(`✓ Jev is off${result.removedKey ? ", and its saved key is deleted" : ""}.`));
2758
+ return;
2759
+ }
2760
+ throw new Error("Usage: nomarmy validators <list|add jev|test jev|remove jev>");
2761
+ }
2762
+
2763
+ async function cmdValidatorsJudge(sub) {
2764
+ const agents = loadAgents(globalConfigDir()).agents;
2765
+ const resolve = () => judgeSettings({ agents, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
2766
+ const probe = async (settings) => probeModel({ provider: settings.provider, model: settings.model, stateRoot: process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents") });
2767
+ if (sub === "add") {
2768
+ const agent = value("agent"), model = value("model");
2769
+ if (!agent || !model) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
2770
+ if (!agents[agent]) throw new Error(`"${agent}" isn't an agent in agents.yml. Agents: ${Object.keys(agents).join(", ") || "(none)"}`);
2771
+ if (agentRunsToolsOnHost(agents[agent]) && !flag("host-tools")) throw new Error(`agent "${agent}" runs its tools on this machine, and a judge reads text the worker wrote. Pass --host-tools to accept that, or pick a sandboxed agent (an api key, Codex, Muse).`);
2772
+ const saved = saveJudge({ agent, model, hostTools: flag("host-tools") });
2773
+ const settings = resolve();
2774
+ if (settings?.problem) throw new Error(settings.problem);
2775
+ const test = await probe(settings);
2776
+ if (json) return out({ saved: true, configPath: saved.configPath, test: test.ok ? "pass" : test.refused ? "refused" : "inconclusive", reason: test.reason });
2777
+ console.log(c.green(`✓ The judge is ${agent}/${model}, in ${saved.configPath}.`));
2778
+ console.log(test.ok ? c.green("✓ Test call answered. New implement jobs use it; restart open coordinator sessions to pick it up.") : c.red(`✗ Test call ${test.refused ? "refused" : "didn't answer"}: ${test.reason ?? "no answer"}`));
2779
+ if (!test.ok) process.exitCode = 1;
2780
+ return;
2781
+ }
2782
+ if (sub === "test") {
2783
+ const settings = resolve();
2784
+ if (!settings) throw new Error("No judge configured. Add one with: nomarmy validators add judge --agent <name> --model <model>");
2785
+ if (settings.problem) throw new Error(settings.problem);
2786
+ const test = await probe(settings);
2787
+ if (json) return out({ test: test.ok ? "pass" : "fail", reason: test.reason });
2788
+ console.log(test.ok ? c.green(`✓ ${settings.agent}/${settings.model} answered.`) : c.red(`✗ ${test.reason ?? "no answer"}`));
2789
+ if (!test.ok) process.exitCode = 1;
2790
+ return;
2791
+ }
2792
+ if (sub === "remove") {
2793
+ removeJudge();
2794
+ return json ? out({ removed: true }) : console.log(c.green("✓ The judge is off."));
2795
+ }
2796
+ throw new Error("Usage: nomarmy validators <add judge --agent <name> --model <model> [--host-tools]|test judge|remove judge>");
2797
+ }
2798
+
2799
+ // `nomarmy mcp`: what a --scope project registration runs. Nothing goes to
2800
+ // stdout but the server's own protocol.
2801
+ function cmdMcp() {
2802
+ const { serverPath, env } = portableServerLaunch({ nomarmyRoot });
2803
+ const child = spawn(process.execPath, [serverPath], { stdio: "inherit", env });
2804
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal));
2805
+ child.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 1); });
2806
+ }
2807
+
2808
+ const commands = { stats: cmdStats, validators: cmdValidators, mcp: cmdMcp, scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, install: cmdInstall, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, sandbox: cmdSandbox, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
2621
2809
  // doctor command
2622
2810
  async function cmdDoctor() {
2623
2811
  // Import lazily to avoid circular dependencies
package/lib/admission.mjs CHANGED
@@ -126,6 +126,19 @@ export function createJobRuntime(deps) {
126
126
  try { return await fn(); } finally { slot.release(); }
127
127
  })();
128
128
  }
129
+ // Who ran the job and for whom, onto its finished record, so `nomarmy
130
+ // stats` can count by role and agent, and by repo for verify runs too
131
+ // (their records didn't carry the repo). Best-effort; never fails the job.
132
+ function stampJobRecord(entry, result) {
133
+ const file = path.join(result?.jobDir ?? path.join(jobsRoot, entry.jobId), "metadata.json");
134
+ try {
135
+ const record = JSON.parse(fs.readFileSync(file, "utf8"));
136
+ record.labels = { role: entry.role ?? null, agent: entry.agent ?? null, label: entry.label ?? null, runId: entry.runId ?? null };
137
+ record.projectDir = record.projectDir ?? projectDir;
138
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(record, null, 2));
139
+ fs.renameSync(`${file}.tmp`, file);
140
+ } catch { /* no record (a refused or crashed job): nothing to label */ }
141
+ }
129
142
  function track(jobId, meta, promise) {
130
143
  const entry = { ...meta, jobId, startedAt: new Date().toISOString(), settled: false, result: null, error: null, promise: null };
131
144
  // A machine-wide lease for as long as the job runs, so every session's
@@ -134,7 +147,7 @@ export function createJobRuntime(deps) {
134
147
  if (meta.lane) writeLease(leasesRoot, jobId, { lane: meta.lane, agent: meta.agent ?? null, runId: meta.runId ?? null, role: meta.role ?? null, model: meta.model ?? null, repo: projectDir });
135
148
  const release = () => removeLease(leasesRoot, jobId);
136
149
  entry.promise = promise.then(
137
- r => { entry.settled = true; entry.result = r; release(); notifyJobFinished(entry, r, null); return r; },
150
+ r => { entry.settled = true; entry.result = r; release(); stampJobRecord(entry, r); notifyJobFinished(entry, r, null); return r; },
138
151
  e => { entry.settled = true; entry.error = e; release(); notifyJobFinished(entry, null, e); throw e; });
139
152
  entry.promise.catch(() => {});
140
153
  activeJobs.set(jobId, entry);
package/lib/connect.mjs CHANGED
@@ -261,6 +261,50 @@ export function deriveWorkerModelEnv(nomarmyRoot) {
261
261
 
262
262
  const SERVER_NAME = "nomarmy-local-worker";
263
263
 
264
+ /**
265
+ * Where a coordinator registers nomArmy. user: every project (the default).
266
+ * local: this repository, only you. project: this repository, committed for
267
+ * the team. A committed registration can't carry one machine's install path
268
+ * or settings, so it runs `nomarmy mcp`, which starts nomArmy with each
269
+ * person's own.
270
+ */
271
+ export const SCOPES = Object.freeze(["user", "local", "project"]);
272
+ export const PORTABLE_SERVER = Object.freeze({ command: "nomarmy", args: ["mcp"] });
273
+
274
+ /** Keep repo-local files out of git without touching the committed .gitignore. */
275
+ export function excludeFromGit(projectDir, relPaths, run = defaultRun) {
276
+ let excludeFile;
277
+ try { excludeFile = String(run("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: projectDir, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" })).trim(); } catch { return []; }
278
+ if (!excludeFile) return [];
279
+ excludeFile = path.resolve(projectDir, excludeFile);
280
+ const existing = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, "utf8") : "";
281
+ const lines = new Set(existing.split("\n").map((l) => l.trim()));
282
+ const added = relPaths.map((p) => `/${p}`).filter((p) => !lines.has(p));
283
+ if (added.length) {
284
+ fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
285
+ fs.appendFileSync(excludeFile, `${existing && !existing.endsWith("\n") ? "\n" : ""}# nomArmy, registered for this repository only\n${added.join("\n")}\n`);
286
+ }
287
+ return added;
288
+ }
289
+
290
+ /** Undo excludeFromGit for these paths: a project registration is meant to be committed. */
291
+ export function includeInGit(projectDir, relPaths, run = defaultRun) {
292
+ let excludeFile;
293
+ try { excludeFile = path.resolve(projectDir, String(run("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: projectDir, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" })).trim()); } catch { return []; }
294
+ if (!fs.existsSync(excludeFile)) return [];
295
+ const drop = new Set(relPaths.map((p) => `/${p}`));
296
+ const lines = fs.readFileSync(excludeFile, "utf8").split("\n");
297
+ const removed = lines.filter((l) => drop.has(l.trim()));
298
+ if (removed.length) fs.writeFileSync(excludeFile, lines.filter((l) => !drop.has(l.trim())).join("\n"));
299
+ return removed.map((l) => l.trim());
300
+ }
301
+
302
+ function repoScoped(scope, projectDir) {
303
+ if (!SCOPES.includes(scope)) throw new Error(`unknown scope "${scope}"; use one of ${SCOPES.join(", ")}`);
304
+ if (scope !== "user" && !projectDir) throw new Error(`--scope ${scope} registers nomArmy for one repository; run it inside that repository`);
305
+ return scope !== "user";
306
+ }
307
+
264
308
  /**
265
309
  * Every distinct auth_env name declared across config/providers.yml's
266
310
  * pools, mapped to a placeholder value -- NEVER the real credential, which
@@ -302,7 +346,8 @@ export function derivePoolAuthEnvPlaceholders(configDir = globalConfigDir()) {
302
346
  /**
303
347
  * @param {{ nomarmyRoot: string, installDir?: string, run?: Function, extraEnv?: Record<string,string>, configDir?: string }} input
304
348
  */
305
- export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, extraEnv = {}, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().claude, settingsPath = defaultClaudeSettingsPath() }) {
349
+ export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, extraEnv = {}, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().claude, settingsPath = defaultClaudeSettingsPath(), scope = "user", projectDir = null }) {
350
+ if (repoScoped(scope, projectDir)) return connectClaudeToRepo({ nomarmyRoot, installDir, run, extraEnv, configDir, scope, projectDir });
306
351
  installMcpCopy({ nomarmyRoot, installDir, run });
307
352
  const commands = installPlaybooks({ nomarmyRoot, target: "claude", dir: commandsDir });
308
353
  const notifier = buildNotifierApp({ nomarmyRoot });
@@ -340,13 +385,62 @@ export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), r
340
385
  run("claude", ["mcp", "add", SERVER_NAME, ...envArgs, "--", "node", serverPath]);
341
386
  }
342
387
  run("claude", ["mcp", "get", SERVER_NAME]);
343
- return { installDir, serverPath, preservedEnv: finalEnv, commands, statusLine, notifier };
388
+ return { installDir, serverPath, preservedEnv: finalEnv, commands, statusLine, notifier, scope: "user" };
389
+ }
390
+
391
+ // Claude Code's local scope (this repo, only you) or project scope (.mcp.json,
392
+ // committed). The /feature playbook goes in the repo's .claude/commands, so it
393
+ // shows up only where nomArmy is registered; no status line, which is a
394
+ // user-wide setting.
395
+ function connectClaudeToRepo({ nomarmyRoot, installDir, run, extraEnv, configDir, scope, projectDir }) {
396
+ installMcpCopy({ nomarmyRoot, installDir, run });
397
+ const commands = installPlaybooks({ nomarmyRoot, target: "claude", dir: path.join(projectDir, ".claude", "commands") });
398
+ const playbookPaths = commands.installed.map((f) => path.relative(projectDir, path.join(commands.dir, f)));
399
+ const excluded = scope === "local" ? excludeFromGit(projectDir, playbookPaths, run) : [];
400
+ if (scope === "project") includeInGit(projectDir, playbookPaths, run);
401
+ const serverPath = path.join(installDir, "mcp", "server.mjs");
402
+ const opts = { cwd: projectDir };
403
+ quietRun(run, "claude", ["mcp", "remove", SERVER_NAME, "--scope", scope], opts);
404
+ let env = {};
405
+ if (scope === "local") {
406
+ env = { ...derivePoolAuthEnvPlaceholders(configDir), ...extraEnv, ...deriveWorkerModelEnv(nomarmyRoot) };
407
+ const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
408
+ run("claude", ["mcp", "add", "--scope", "local", SERVER_NAME, ...envArgs, "--", "node", serverPath], opts);
409
+ } else {
410
+ run("claude", ["mcp", "add", "--scope", "project", SERVER_NAME, "--", PORTABLE_SERVER.command, ...PORTABLE_SERVER.args], opts);
411
+ }
412
+ run("claude", ["mcp", "get", SERVER_NAME], opts);
413
+ return { installDir, serverPath: scope === "local" ? serverPath : null, preservedEnv: env, commands, excluded, scope, projectDir,
414
+ configPath: scope === "project" ? path.join(projectDir, ".mcp.json") : null, userScoped: claudeUserScoped(run) };
415
+ }
416
+
417
+ /**
418
+ * What `nomarmy mcp` runs: the installed copy when there is one (the code
419
+ * `nomarmy update` keeps current), else this package's own server, with the
420
+ * env connect would register (worker model, api-key placeholders) under
421
+ * whatever the caller already set.
422
+ */
423
+ export function portableServerLaunch({ nomarmyRoot, installDir = defaultInstallDir(), configDir = globalConfigDir(), env = process.env }) {
424
+ const installed = path.join(installDir, "mcp", "server.mjs");
425
+ const serverPath = fs.existsSync(installed) ? installed : path.join(nomarmyRoot, "mcp", "server.mjs");
426
+ return { serverPath, env: { ...derivePoolAuthEnvPlaceholders(configDir), ...deriveWorkerModelEnv(nomarmyRoot), ...env } };
427
+ }
428
+
429
+ /** Whether Claude Code also has nomArmy registered for every project. */
430
+ export function claudeUserScoped(run = defaultRun) {
431
+ try {
432
+ const out = String(run("claude", ["mcp", "get", SERVER_NAME], { cwd: os.homedir(), stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }));
433
+ return /Scope:\s*User/i.test(out);
434
+ } catch { return false; }
344
435
  }
345
436
 
346
437
  /**
347
438
  * @param {{ nomarmyRoot: string, installDir?: string, run?: Function }} input
348
439
  */
349
- export function connectCodex({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, configDir = globalConfigDir(), skillsDir = defaultCommandDirs().codex, legacySkillsDir = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "skills") }) {
440
+ export function connectCodex({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, configDir = globalConfigDir(), skillsDir = defaultCommandDirs().codex, legacySkillsDir = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "skills"), scope = "user" }) {
441
+ // `codex mcp add` has no scope: Codex registers MCP servers only in
442
+ // ~/.codex/config.toml, for every project.
443
+ if (scope !== "user") throw new Error(`Codex registers MCP servers only for every project (~/.codex/config.toml), so --scope ${scope} isn't available for it; connect Codex without --scope, or use Claude Code or Cursor for a per-repo registration`);
350
444
  installMcpCopy({ nomarmyRoot, installDir, run });
351
445
  const commands = installPlaybooks({ nomarmyRoot, target: "codex", dir: skillsDir });
352
446
  removeLegacyCodexSkill(legacySkillsDir, skillsDir);
@@ -410,7 +504,13 @@ export function readCursorConfig(configPath) {
410
504
  /**
411
505
  * @param {{ nomarmyRoot: string, installDir?: string, configPath?: string }} input
412
506
  */
413
- export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), configPath = defaultCursorConfigPath(), run = defaultRun, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().cursor }) {
507
+ export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), configPath = defaultCursorConfigPath(), run = defaultRun, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().cursor, scope = "user", projectDir = null }) {
508
+ // Cursor reads a project's own .cursor/mcp.json beside the user one. local
509
+ // keeps it out of git; project commits it, with the portable command.
510
+ if (repoScoped(scope, projectDir)) {
511
+ configPath = path.join(projectDir, ".cursor", "mcp.json");
512
+ commandsDir = path.join(projectDir, ".cursor", "commands");
513
+ }
414
514
  installMcpCopy({ nomarmyRoot, installDir, run });
415
515
  const commands = installPlaybooks({ nomarmyRoot, target: "cursor", dir: commandsDir });
416
516
  const notifier = buildNotifierApp({ nomarmyRoot });
@@ -427,10 +527,15 @@ export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), c
427
527
  // Cursor starts a global MCP server in the home folder, not the open
428
528
  // project; ${workspaceFolder} is Cursor's own placeholder for the latter.
429
529
  const preservedEnv = { ...derivePoolAuthEnvPlaceholders(configDir), ...((existing && typeof existing.env === "object" && existing.env) || {}), ...deriveWorkerModelEnv(nomarmyRoot), NOMARMY_PROJECT_DIR: "${workspaceFolder}" };
430
- config.mcpServers[SERVER_NAME] = { command: "node", args: [serverPath], env: preservedEnv };
530
+ config.mcpServers[SERVER_NAME] = scope === "project"
531
+ ? { command: PORTABLE_SERVER.command, args: [...PORTABLE_SERVER.args], env: { NOMARMY_PROJECT_DIR: "${workspaceFolder}" } }
532
+ : { command: "node", args: [serverPath], env: preservedEnv };
431
533
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
432
534
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
433
- return { installDir, serverPath, configPath, preservedEnv, commands, notifier };
535
+ const repoPaths = scope === "user" ? [] : [path.relative(projectDir, configPath), ...commands.installed.map((f) => path.relative(projectDir, path.join(commands.dir, f)))];
536
+ const excluded = scope === "local" ? excludeFromGit(projectDir, repoPaths, run) : [];
537
+ if (scope === "project") includeInGit(projectDir, repoPaths, run);
538
+ return { installDir, serverPath, configPath, preservedEnv: scope === "project" ? config.mcpServers[SERVER_NAME].env : preservedEnv, commands, notifier, scope, excluded };
434
539
  }
435
540
 
436
541
  /** True when Cursor's config file already has a nomArmy entry -- used by