nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (102) hide show
  1. package/README.md +86 -480
  2. package/bin/nomarmy.mjs +1081 -185
  3. package/docker/Dockerfile +2 -2
  4. package/docker/Dockerfile.go +6 -4
  5. package/docker/Dockerfile.rust +17 -2
  6. package/harnesses/_template/README.md +27 -0
  7. package/harnesses/_template/harness.yml +26 -0
  8. package/harnesses/browser-playwright/README.md +35 -0
  9. package/harnesses/browser-playwright/fixture/package.json +1 -0
  10. package/harnesses/browser-playwright/fixture/page.html +1 -0
  11. package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
  12. package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
  13. package/harnesses/browser-playwright/harness.yml +18 -0
  14. package/harnesses/go/README.md +45 -0
  15. package/harnesses/go/harness.yml +14 -0
  16. package/harnesses/mock-oidc/README.md +31 -0
  17. package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
  18. package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
  19. package/harnesses/mock-oidc/harness.yml +19 -0
  20. package/harnesses/node/README.md +53 -0
  21. package/harnesses/node/harness.yml +18 -0
  22. package/harnesses/python/README.md +46 -0
  23. package/harnesses/python/harness.yml +16 -0
  24. package/harnesses/rust/README.md +45 -0
  25. package/harnesses/rust/harness.yml +13 -0
  26. package/install.sh +29 -9
  27. package/lib/admission.mjs +178 -30
  28. package/lib/agents.mjs +8 -6
  29. package/lib/army.mjs +25 -10
  30. package/lib/codex-link.mjs +37 -0
  31. package/lib/config.mjs +15 -0
  32. package/lib/connect.mjs +232 -19
  33. package/lib/continue-from.mjs +103 -0
  34. package/lib/coordinator-instructions.mjs +5 -1
  35. package/lib/diff-checks.mjs +114 -0
  36. package/lib/dispatch-schema.mjs +14 -12
  37. package/lib/doctor.mjs +98 -9
  38. package/lib/egress-proxy.mjs +116 -0
  39. package/lib/execute.mjs +241 -33
  40. package/lib/git-record.mjs +27 -3
  41. package/lib/harness-schema.mjs +61 -0
  42. package/lib/harnesses.mjs +99 -0
  43. package/lib/health.mjs +162 -18
  44. package/lib/install-freshness.mjs +114 -0
  45. package/lib/jev-checks.mjs +110 -0
  46. package/lib/job-format.mjs +54 -0
  47. package/lib/judge.mjs +130 -0
  48. package/lib/limits.mjs +77 -0
  49. package/lib/model-probe.mjs +61 -0
  50. package/lib/mutation.mjs +159 -0
  51. package/lib/notify.mjs +30 -3
  52. package/lib/openclaw-install.mjs +122 -0
  53. package/lib/openclaw-path.mjs +28 -0
  54. package/lib/openclaw-run.mjs +74 -12
  55. package/lib/openclaw-runtime-health.mjs +56 -0
  56. package/lib/outcome.mjs +21 -2
  57. package/lib/outcomes.mjs +6 -0
  58. package/lib/path-utils.mjs +4 -0
  59. package/lib/podman-health.mjs +41 -0
  60. package/lib/process.mjs +4 -1
  61. package/lib/propose.mjs +10 -11
  62. package/lib/refusal-retry.mjs +16 -0
  63. package/lib/registry-python.mjs +98 -0
  64. package/lib/registry-secrets.mjs +140 -0
  65. package/lib/repo-query.mjs +13 -7
  66. package/lib/runs.mjs +7 -1
  67. package/lib/same-path.mjs +14 -0
  68. package/lib/sandbox-images.mjs +499 -83
  69. package/lib/sandbox-vm.mjs +32 -0
  70. package/lib/scan.mjs +5 -1
  71. package/lib/schema.mjs +20 -11
  72. package/lib/scout.mjs +21 -3
  73. package/lib/server-context.mjs +21 -1
  74. package/lib/setup-steps.mjs +55 -0
  75. package/lib/share.mjs +82 -0
  76. package/lib/stale-sessions.mjs +60 -0
  77. package/lib/stats.mjs +315 -0
  78. package/lib/statusline.mjs +32 -6
  79. package/lib/subscription-setup.mjs +13 -0
  80. package/lib/suggestions.mjs +153 -0
  81. package/lib/thinking.mjs +23 -0
  82. package/lib/transcript.mjs +30 -5
  83. package/lib/usage-limits.mjs +329 -0
  84. package/lib/user-config.mjs +106 -0
  85. package/lib/validators.mjs +220 -0
  86. package/lib/verification-artifacts.mjs +46 -0
  87. package/lib/verification-flow.mjs +52 -7
  88. package/lib/verification-network.mjs +66 -0
  89. package/lib/verify.mjs +338 -85
  90. package/lib/worker-prompt.mjs +5 -2
  91. package/lib/wsl-cli.mjs +152 -0
  92. package/lib/wsl.mjs +230 -0
  93. package/lib/zod-issues.mjs +15 -0
  94. package/mcp/server.mjs +165 -34
  95. package/package.json +7 -5
  96. package/playbooks/feature.md +8 -5
  97. package/scripts/configure-openclaw.sh +4 -2
  98. package/scripts/generate-harness-docs.mjs +42 -0
  99. package/scripts/install-openclaw.mjs +23 -0
  100. package/scripts/lib.sh +9 -2
  101. package/scripts/select-model.mjs +12 -5
  102. package/scripts/start-inference.sh +2 -2
@@ -0,0 +1,32 @@
1
+ // `nomarmy sandbox`: the Podman VM every sandbox, verification run and image
2
+ // build shares on macOS and Windows (on Linux, Podman runs natively and
3
+ // there's no VM to size). Pure decisions here; bin/nomarmy.mjs runs Podman.
4
+
5
+ import { MIN_PODMAN_VM_MB } from "./doctor.mjs";
6
+
7
+ /** The machine to act on from `podman machine inspect` output: the running one, else the first. */
8
+ export function pickMachine(inspectJson) {
9
+ let machines;
10
+ try { machines = JSON.parse(inspectJson || "[]"); } catch { return null; }
11
+ if (!Array.isArray(machines) || !machines.length) return null;
12
+ const m = machines.find((x) => x.State === "running") ?? machines[0];
13
+ return { name: m.Name ?? null, state: m.State ?? null, cpus: m.Resources?.CPUs ?? null, memoryMb: Number(m.Resources?.Memory) || null, diskGb: m.Resources?.DiskSize ?? null };
14
+ }
15
+
16
+ /**
17
+ * What resizing the VM to `gib` would do, or why it won't.
18
+ * @returns {{ ok: boolean, problems: string[], warnings: string[], memoryMb: number, commands: string[][] }}
19
+ */
20
+ export function planResize({ gib, machine, hostMemoryMb, runningJobs = 0 }) {
21
+ const problems = [], warnings = [];
22
+ const memoryMb = Math.round(Number(gib) * 1024);
23
+ if (!machine) problems.push("no Podman machine found: run `podman machine init`, or on Linux there's no VM to size");
24
+ if (!Number.isFinite(memoryMb) || memoryMb <= 0 || !Number.isInteger(Number(gib))) problems.push(`--memory takes a whole number of GiB (got "${gib}")`);
25
+ else if (memoryMb < MIN_PODMAN_VM_MB) problems.push(`${gib} GiB is below the ${MIN_PODMAN_VM_MB / 1024} GiB nomArmy needs`);
26
+ else if (hostMemoryMb && memoryMb > hostMemoryMb * 0.75) problems.push(`${gib} GiB is more than three quarters of this machine's ${Math.round(hostMemoryMb / 1024)} GiB`);
27
+ else if (hostMemoryMb && memoryMb > hostMemoryMb / 2) warnings.push(`${gib} GiB is more than half of this machine's ${Math.round(hostMemoryMb / 1024)} GiB; a local model needs room too`);
28
+ if (runningJobs > 0) problems.push(`${runningJobs} nomArmy job(s) are running; resizing restarts the VM and would kill their sandboxes`);
29
+ const name = machine?.name;
30
+ const commands = name ? [["machine", "stop", name], ["machine", "set", "--memory", String(memoryMb), name], ["machine", "start", name]] : [];
31
+ return { ok: problems.length === 0, problems, warnings, memoryMb, commands };
32
+ }
package/lib/scan.mjs CHANGED
@@ -451,6 +451,10 @@ const IGNORED_DIRS = new Set([
451
451
  // label that specific finding as sample data once it's found. Deliberately
452
452
  // narrower than "tests"/"__tests__" alone; see the comment at its call site.
453
453
  const FIXTURE_PATH_RE = /(^|\/)(fixtures?|__fixtures__|testdata|test-data)(\/|$)/i;
454
+ export function isFixturePath(relPath) {
455
+ // path.relative uses backslashes on Windows, while repository paths use '/'.
456
+ return FIXTURE_PATH_RE.test(relPath.replaceAll("\\", "/"));
457
+ }
454
458
 
455
459
  const GREPPABLE_EXT = new Set([
456
460
  ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts", ".cts",
@@ -1392,7 +1396,7 @@ export function scanRepository(repoDir, options = {}) {
1392
1396
  // there, and that evidence should still be reported normally. Labeled,
1393
1397
  // not excluded, from the evidence itself -- this scan still answers "what
1394
1398
  // did you find", any narrower proposal built from it decides what to trust.
1395
- const fixturePaths = [...new Set(matched.filter((f) => FIXTURE_PATH_RE.test(f.relPath)).map((f) => f.relPath))];
1399
+ const fixturePaths = [...new Set(matched.filter((f) => isFixturePath(f.relPath)).map((f) => f.relPath))];
1396
1400
  for (const source of fixturePaths) {
1397
1401
  raw.notes.push({ level: "info", message: "path looks like test fixture data, not real repository infrastructure -- excluded from any automated .nomarmy.yml proposal", source });
1398
1402
  }
package/lib/schema.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  // approval before the job runs. See `collectElevated`.
13
13
 
14
14
  import { z } from "zod";
15
+ import { typeError, isMissingField, isUnknownDiscriminator } from "./zod-issues.mjs";
15
16
  import { armySchema } from "./army.mjs";
16
17
 
17
18
  export const SERVICE_SOURCES = Object.freeze([
@@ -57,12 +58,12 @@ export const SERVICE_FIELDS = Object.freeze({
57
58
  // the dotted path, so repeating it reads as stutter.
58
59
  const requiredString = () =>
59
60
  z
60
- .string({ required_error: "is required", invalid_type_error: "must be a string" })
61
+ .string({ error: typeError("a string") })
61
62
  .refine((value) => value.trim().length > 0, { message: "must not be empty" });
62
63
 
63
64
  const enumOf = (values) =>
64
65
  z.enum(values, {
65
- errorMap: () => ({ message: `must be one of ${values.join(", ")}` }),
66
+ error: `must be one of ${values.join(", ")}`,
66
67
  });
67
68
 
68
69
  // A plain hostname: no scheme, no path, no port, no whitespace.
@@ -90,7 +91,7 @@ export function hostnameProblem(value) {
90
91
  }
91
92
 
92
93
  const hostnameSchema = z
93
- .string({ invalid_type_error: "must be a string hostname" })
94
+ .string({ error: typeError("a string hostname") })
94
95
  .superRefine((value, ctx) => {
95
96
  const problem = hostnameProblem(value);
96
97
  if (problem) ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem });
@@ -162,7 +163,7 @@ export const serviceSchema = z.discriminatedUnion("source", [
162
163
  export const pythonEnvironmentSchema = z
163
164
  .object({
164
165
  requirements: z
165
- .array(requiredString(), { invalid_type_error: "must be an array of strings" })
166
+ .array(requiredString(), { error: typeError("an array of strings") })
166
167
  .min(1, "must list at least one requirements file"),
167
168
  })
168
169
  .strict();
@@ -211,10 +212,7 @@ export const verificationProfileSchema = z
211
212
  .object({
212
213
  environment: enumOf(ENVIRONMENT_LEVELS).default("none"),
213
214
  commands: z
214
- .array(requiredString(), {
215
- required_error: "is required",
216
- invalid_type_error: "must be an array of strings",
217
- })
215
+ .array(requiredString(), { error: typeError("an array of strings") })
218
216
  .min(1, "must list at least one command"),
219
217
  })
220
218
  .strict();
@@ -235,14 +233,24 @@ export const environmentRetentionSchema = z
235
233
  debug: enumOf(RETENTION_ACTIONS).default(DEFAULT_RETENTION.debug),
236
234
  })
237
235
  .strict()
238
- .default({});
236
+ .prefault({});
239
237
 
240
238
  // ---------------------------------------------------------------------------
241
239
  // root
242
240
  // ---------------------------------------------------------------------------
243
241
 
242
+ // Mutation testing on the changed lines (lib/mutation.mjs): opt in per repo,
243
+ // since every mutant costs one run of the verification profile.
244
+ export const mutationSchema = z
245
+ .object({
246
+ mutants: z.number({ error: typeError("a number") }).int("must be a whole number").min(1, "must be at least 1").max(20, "must be at most 20").default(5),
247
+ max_seconds: z.number({ error: typeError("a number") }).int("must be a whole number").min(30, "must be at least 30").max(3600, "must be at most 3600").default(300),
248
+ })
249
+ .strict();
250
+
244
251
  export const configSchema = z
245
252
  .object({
253
+ harnesses: z.array(z.string().regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/)).optional(),
246
254
  environment: environmentSchema.optional(),
247
255
  verification: verificationSchema.optional(),
248
256
  environment_retention: environmentRetentionSchema,
@@ -250,6 +258,7 @@ export const configSchema = z
250
258
  // which merges it with the global and .nomarmy.local.yml layers.
251
259
  army: armySchema.optional(),
252
260
  policy: policySchema.optional(),
261
+ mutation: mutationSchema.optional(),
253
262
  })
254
263
  .strict();
255
264
 
@@ -294,11 +303,11 @@ export function formatIssues(error) {
294
303
  lines.push(`${where}: unexpected field(s) ${keys}`);
295
304
  continue;
296
305
  }
297
- if (issue.code === "invalid_union_discriminator") {
306
+ if (isUnknownDiscriminator(issue)) {
298
307
  lines.push(`${where}: must be one of ${SERVICE_SOURCES.join(", ")}`);
299
308
  continue;
300
309
  }
301
- if (issue.code === "invalid_type" && issue.received === "undefined") {
310
+ if (isMissingField(issue)) {
302
311
  lines.push(`${where}: is required`);
303
312
  continue;
304
313
  }
package/lib/scout.mjs CHANGED
@@ -435,8 +435,25 @@ export function isScoutReportUnusable(report) {
435
435
  * already found -- never asks it to look further, since a fresh read pass is
436
436
  * exactly the cost a scout exists to avoid paying twice.
437
437
  */
438
- export function scoutReportRecoveryPrompt({ report = { targetTokens: 600, hardCapTokens: 1024 } } = {}) {
439
- return `Your previous reply ended without the required SCOUT REPORT, or was cut off before reaching END.\n\nDo not repeat, redo, retry, or explore further. Do not call any tool. Based only on what you already found, reply with ONLY the report below, nothing before it, nothing after it:\n\nSCOUT REPORT\nQUESTION: <the question restated in one line>\nCONFIDENCE: high | medium | low\nFINDING: <one sentence> [src/example.js:10-24]\nNOT_FOUND: none | <what you looked for and could not find>\nEND\n\nIf you did not actually find anything worth a FINDING, say so under NOT_FOUND rather than inventing one. Target ${report.targetTokens} tokens; ${report.hardCapTokens} is the hard cap.`;
438
+ // The recovery call carries the question itself: a reply cut off mid-run can
439
+ // leave the resumed session without it, and a scout told only to "restate the
440
+ // question" then came back with an empty report (a live PM scout, twice).
441
+ // A recovery call can't count on seeing the first run's work: when OpenClaw's
442
+ // cleanup crashes ("state ownership retained"), the follow-up starts a fresh
443
+ // session, and a scout told "report only what you already found" came back
444
+ // with nothing after nine minutes of real research. So the recovery carries
445
+ // what the first run left: its cut-off reply and the files it read, which it
446
+ // may re-read to confirm line numbers, and nothing else.
447
+ const EARLIER_REPLY_CHARS = 12000;
448
+ export function scoutReportRecoveryPrompt({ report = { targetTokens: 600, hardCapTokens: 1024 }, question = null, acceptance = [], earlierReply = "", filesRead = [] } = {}) {
449
+ const asked = question ? `\n\nThe question you were answering:\n${String(question).trim()}${acceptance?.length ? `\n\nA complete answer covers:\n${acceptance.map((a) => `- ${a}`).join("\n")}` : ""}` : "";
450
+ const reply = String(earlierReply ?? "").trim();
451
+ const earlier = reply ? `\n\nYour earlier reply, as far as it got${reply.length > EARLIER_REPLY_CHARS ? " (its last part)" : ""}:\n${reply.length > EARLIER_REPLY_CHARS ? reply.slice(-EARLIER_REPLY_CHARS) : reply}` : "";
452
+ const files = filesRead?.length ? `\n\nFiles you read during that work: ${filesRead.slice(0, 60).join(", ")}` : "";
453
+ const tools = filesRead?.length
454
+ ? "Do not explore further. You may re-read the files listed above to confirm exact line numbers for what you found; call no other tool."
455
+ : "Do not repeat, redo, retry, or explore further. Do not call any tool.";
456
+ return `Your previous reply ended without the required SCOUT REPORT, or was cut off before reaching END.${asked}${earlier}${files}\n\n${tools} Based on what you already found, reply with ONLY the report below, nothing before it, nothing after it:\n\nSCOUT REPORT\nQUESTION: <the question restated in one line>\nCONFIDENCE: high | medium | low\nFINDING: <one sentence> [src/example.js:10-24]\nNOT_FOUND: none | <what you looked for and could not find>\nEND\n\nIf you did not actually find anything worth a FINDING, say so under NOT_FOUND rather than inventing one. Target ${report.targetTokens} tokens; ${report.hardCapTokens} is the hard cap.`;
440
457
  }
441
458
 
442
459
  // ---------------------------------------------------------------------------
@@ -515,7 +532,8 @@ export function renderScoutReport({ report, verified, outcome, baseSha }) {
515
532
  if (!supported.length) parts.push(" none");
516
533
  supported.forEach((f, i) => {
517
534
  const weakLabel = f.unrelated ? " [WEAK: the cited lines do not mention this finding's terms]" : f.weak ? " [file-level citation only]" : "";
518
- parts.push(`${i + 1}. ${f.text}${weakLabel}`);
535
+ const jevLabel = f.jev ? ` [JEV: the cited lines may not support this (${f.jev.verdict}, ${f.jev.probability.toFixed(2)})]` : "";
536
+ parts.push(`${i + 1}. ${f.text}${weakLabel}${jevLabel}`);
519
537
  for (const c of f.citations) {
520
538
  if (c.status !== "ok") { parts.push(` ${citeLabel(c)} -- ${c.status}`); continue; }
521
539
  const fileNote = c.granularity === "file"
@@ -1,8 +1,17 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
3
4
 
5
+ // The repository jobs run against: NOMARMY_PROJECT_DIR (set by `nomarmy
6
+ // connect cursor` to Cursor's ${workspaceFolder}), else Claude Code's
7
+ // CLAUDE_PROJECT_DIR, else the folder the server was started in (Claude Code
8
+ // and Codex start it in the open project; Cursor starts a global server in
9
+ // the home folder). A value still holding an unexpanded ${...} counts as
10
+ // unset, and a leading ~ is the home folder (Cursor fills ${workspaceFolder}
11
+ // in as "~/...", and nothing on the way expands it).
4
12
  export function createServerContext({ env = process.env, homedir = os.homedir(), cwd = process.cwd() } = {}) {
5
- const projectDir = path.resolve(env.CLAUDE_PROJECT_DIR || cwd);
13
+ const fromEnv = [env.NOMARMY_PROJECT_DIR, env.CLAUDE_PROJECT_DIR].find((v) => v && !v.includes("${"));
14
+ const projectDir = path.resolve(fromEnv ? fromEnv.replace(/^~(?=$|[\\/])/, homedir) : cwd);
6
15
  const stateRoot = env.NOMARMY_AGENT_STATE || path.join(homedir, ".local", "share", "nomarmy-local-agents");
7
16
  const jobsRoot = path.join(stateRoot, "jobs");
8
17
  const runsRoot = path.join(stateRoot, "runs");
@@ -11,3 +20,14 @@ export function createServerContext({ env = process.env, homedir = os.homedir(),
11
20
  const slotsRoot = path.join(stateRoot, "slots");
12
21
  return Object.freeze({ projectDir, stateRoot, jobsRoot, runsRoot, leasesRoot, slotsRoot });
13
22
  }
23
+
24
+ /**
25
+ * Why jobs can't run against projectDir, or null. Jobs need a git
26
+ * repository (worktrees, nomArmy's commits); anything else, like the home
27
+ * folder a coordinator started the server in, is refused before dispatch.
28
+ */
29
+ export function projectDirProblem(projectDir, { run = (args) => spawnSync("git", args, { encoding: "utf8" }) } = {}) {
30
+ const result = run(["-C", projectDir, "rev-parse", "--is-inside-work-tree"]);
31
+ if (result.status === 0 && String(result.stdout).trim() === "true") return null;
32
+ return `nomArmy's project folder is ${projectDir}, which isn't a git repository, so no job was sent. The coordinator started nomArmy's server outside the project: set NOMARMY_PROJECT_DIR to the repository in its MCP settings (\`nomarmy connect cursor\` does this for Cursor), or start the coordinator from inside the repository.`;
33
+ }
@@ -0,0 +1,55 @@
1
+ // `nomarmy setup`: the setup playbook as a checklist. Each step says whether
2
+ // it's done and which `nomarmy` command does it; the runner shows the list,
3
+ // runs the first unfinished step once the operator says yes, and repeats.
4
+ // All observations are injected functions; importing this module reads no state.
5
+ export function setupSteps(probes) {
6
+ const mode = probes.mode();
7
+ const profile = mode.profile;
8
+ const hosted = profile === "hosted";
9
+ const installation = probes.install(profile);
10
+ const agents = probes.agents().filter((name) => name !== "local");
11
+ const army = probes.army();
12
+ const roles = Object.values(army?.roles ?? {});
13
+ const rolesDone = roles.length > 0 && (!hosted || roles.every((role) => role.agent !== "local"));
14
+ const repo = probes.repo();
15
+ const step = (id, title, status, detail, command) => ({ id, title, status, detail, command });
16
+ const modeDetail = !profile ? "choose where models run" : profile === "remote" ? `remote ${mode.host}:${mode.port}` : ["hosted", "bedrock"].includes(profile) ? profile : `local ${profile}`;
17
+ const installed = installation.marker ? installation.marker.profile === profile : Boolean(installation.version && installation.registered);
18
+ return [
19
+ step("mode", "Where models run", profile ? "done" : "todo", modeDetail, ["setup", "--choose"]),
20
+ step("install", "Installed", installed ? "done" : "todo", installation.version || "OpenClaw not found", ["install"]),
21
+ step("agents", "Agents", agents.length ? "done" : hosted ? "todo" : "skipped", agents.length ? agents.join(", ") : hosted ? "add a hosted agent" : "optional: the local model works without one", ["agents", "add"]),
22
+ step("roles", "Roles", rolesDone ? "done" : "todo", rolesDone ? `${roles.length} role${roles.length === 1 ? "" : "s"}` : !agents.length ? (hosted ? "add an agent first" : "every role on the local model") : hosted && roles.length ? "roles must use a hosted agent" : "define an army", agents.length ? ["army", "init", "--agent", agents[0], "--force", ...(army?.repairLayer ? [`--${army.repairLayer}`] : [])] : ["army", "init", "--agent", "local", "--force"]),
23
+ step("repo", "This repo", !repo.inside ? "skipped" : repo.configured ? "done" : "todo", !repo.inside ? "run nomarmy setup inside a project to set it up" : repo.configured ? ".nomarmy.yml exists" : "configure this project", ["init"]),
24
+ step("check", "Check", "todo", "verify the installation", ["doctor"]),
25
+ ];
26
+ }
27
+
28
+ export function formatSetupSteps(steps) {
29
+ const first = steps.findIndex((step) => step.status === "todo");
30
+ return steps.map((step, i) => `${step.status === "done" ? "✓" : step.status === "skipped" ? "–" : i === first ? "→" : " "} ${step.title}: ${step.detail}`).join("\n");
31
+ }
32
+
33
+ export async function runSetupPlaybook({ evaluate, ask, run, print }) {
34
+ for (;;) {
35
+ const steps = await evaluate();
36
+ print(formatSetupSteps(steps));
37
+ const next = steps.find((step) => step.status === "todo");
38
+ if (!next) return 0;
39
+ const answer = (await ask(`Run \`nomarmy ${next.command.join(" ")}\` now? [Y/n] `)).trim().toLowerCase();
40
+ if (answer && answer !== "y" && answer !== "yes") {
41
+ print("Resume with: nomarmy setup");
42
+ return 0;
43
+ }
44
+ const code = await run(next.command);
45
+ if (code !== 0) {
46
+ print(`Setup step failed: ${next.title} (exit ${code})`);
47
+ return code;
48
+ }
49
+ if (next.id === "check") {
50
+ print("Setup complete");
51
+ print("Restart Claude Code in the project and ask it to use nomArmy.");
52
+ return 0;
53
+ }
54
+ }
55
+ }
package/lib/share.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // Sharing what nomArmy verified, where people already look: a block for a
2
+ // pull request's description, and a README badge. Every number comes from
3
+ // the job records (lib/stats.mjs), never from a worker's report, so what
4
+ // gets shared is evidence, not a claim.
5
+
6
+ const REPO_URL = "https://github.com/rayson-tech/nomarmy";
7
+
8
+ /** Claims that held up and those caught, from computeStats' claimVsEvidence. */
9
+ function claims(stats) {
10
+ const cv = stats.claimVsEvidence;
11
+ const caught = cv.verificationFailed + cv.revertStillPassed;
12
+ return { total: cv.claimedDone, caught, held: cv.claimedDone - caught, cv };
13
+ }
14
+
15
+ /**
16
+ * Markdown for a pull request (or anywhere): what the workers claimed, what
17
+ * nomArmy caught, the tests shown to catch their change, and high-stakes
18
+ * reviews. `scope` names what it covers ("this feature run", "the last 7 days").
19
+ */
20
+ export function shareMarkdown(stats, { scope = null } = {}) {
21
+ const { total, caught, held, cv } = claims(stats);
22
+ const rows = [];
23
+ if (total) {
24
+ const why = [cv.verificationFailed && `${cv.verificationFailed} failed when nomArmy ran the tests itself`, cv.revertStillPassed && `${cv.revertStillPassed} had tests that pass with the change reverted`].filter(Boolean).join(", ");
25
+ rows.push(["Worker claims checked", `${held} of ${total} "done, tests pass" claims held up${caught ? `; ${caught} caught (${why})` : ""}`]);
26
+ } else rows.push(["Worker claims checked", "none reported \"done, tests pass\""]);
27
+ if (cv.provenTestFiles) rows.push(["Tests proven", `${cv.provenTestFiles} new test file(s) shown to fail without their change`]);
28
+ if (stats.code?.committedJobs) rows.push(["Work committed", `${stats.code.committedJobs} verified job(s), +${stats.code.linesAdded} / -${stats.code.linesRemoved} lines`]);
29
+ if (stats.highStakes?.jobs) rows.push(["High-stakes changes", `${stats.highStakes.jobs}, ${stats.highStakes.reviewed === stats.highStakes.jobs ? "all" : stats.highStakes.reviewed} independently reviewed`]);
30
+ return [
31
+ `### ✓ Verified by nomArmy${scope ? ` (${scope})` : ""}`,
32
+ "",
33
+ "| | |",
34
+ "|---|---|",
35
+ ...rows.map(([k, v]) => `| ${k} | ${v} |`),
36
+ "",
37
+ `<sub>From nomArmy's verified job records, not the workers' own reports. [What is nomArmy?](${REPO_URL})</sub>`,
38
+ ].join("\n");
39
+ }
40
+
41
+ const esc = (s) =>
42
+ String(s)
43
+ .replace(/&/g, "&amp;")
44
+ .replace(/</g, "&lt;")
45
+ .replace(/>/g, "&gt;")
46
+ .replace(/"/g, "&quot;")
47
+ .replace(/'/g, "&#39;");
48
+ // Verdana 11px, the badge convention: rough widths by letter shape, plus padding.
49
+ function textWidth(s) {
50
+ let w = 0;
51
+ for (const ch of String(s)) w += /[mwMW]/.test(ch) ? 10.5 : /[ijlI|.,:;!' ]/.test(ch) ? 3.8 : /[frt]/.test(ch) ? 4.8 : /[A-Z]/.test(ch) ? 7.6 : /[0-9]/.test(ch) ? 7 : ch === "·" ? 4.5 : 6.6;
52
+ return Math.round(w + 14);
53
+ }
54
+
55
+ /**
56
+ * A shields-style SVG: "nomArmy | 78 claims checked · 11 caught". Caught
57
+ * claims are the point of running nomArmy, not something to hide, so the
58
+ * color is a neutral blue, and green when every claim held up.
59
+ */
60
+ export function badgeSvg(stats) {
61
+ const { total, caught } = claims(stats);
62
+ const label = "nomArmy";
63
+ const message = !total ? "verified" : caught ? `${total} claims checked · ${caught} caught` : `${total} claims checked, all held up`;
64
+ const color = total && !caught ? "#2da44e" : "#0969da";
65
+ const lw = textWidth(label), mw = textWidth(message), w = lw + mw;
66
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20" role="img" aria-label="${esc(`${label}: ${message}`)}">
67
+ <title>${esc(`${label}: ${message}`)}</title>
68
+ <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
69
+ <clipPath id="r"><rect width="${w}" height="20" rx="3" fill="#fff"/></clipPath>
70
+ <g clip-path="url(#r)"><rect width="${lw}" height="20" fill="#555"/><rect x="${lw}" width="${mw}" height="20" fill="${color}"/><rect width="${w}" height="20" fill="url(#s)"/></g>
71
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
72
+ <text x="${lw / 2}" y="14">${esc(label)}</text>
73
+ <text x="${lw + mw / 2}" y="14">${esc(message)}</text>
74
+ </g>
75
+ </svg>
76
+ `;
77
+ }
78
+
79
+ /** The README line for a committed badge, linking to nomArmy. */
80
+ export function badgeMarkdown(relPath) {
81
+ return `[![nomArmy](${relPath})](${REPO_URL})`;
82
+ }
@@ -0,0 +1,60 @@
1
+ // Which open coordinator sessions still run an older nomArmy. Each Claude
2
+ // Code, Codex or Cursor session starts its own nomArmy server and keeps the
3
+ // code it started with, so after an update "restart your sessions" wasn't
4
+ // enough: one real afternoon had a session on alpha.14 for hours and four
5
+ // more, days old, on alpha.6 to alpha.12. This names each one.
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import path from "node:path";
9
+
10
+ // The installed copy's server, or the portable `nomarmy mcp` launcher.
11
+ const SERVER_RE = /nomarmy-local-worker[\\/]mcp[\\/]server\.mjs|\bnomarmy(?:\.mjs)?\s+mcp\b/;
12
+
13
+ /** `ps -A -o pid=,ppid=,tty=,lstart=,args=` lines. lstart reads "Sun Sep 27 21:33:11 2026" on macOS and Linux alike. */
14
+ export function parsePs(text) {
15
+ const out = [];
16
+ for (const line of String(text ?? "").split("\n")) {
17
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+(\d{4})\s+(.*)$/.exec(line);
18
+ if (!m) continue;
19
+ const startedAt = new Date(`${m[4]} ${m[5]} ${m[7]} ${m[6]}`).getTime();
20
+ if (!Number.isFinite(startedAt)) continue;
21
+ out.push({ pid: Number(m[1]), ppid: Number(m[2]), tty: /^\?+$/.test(m[3]) ? null : m[3], startedAt, args: m[8].trim() });
22
+ }
23
+ return out;
24
+ }
25
+
26
+ /** The app a server belongs to, from its parent's command line. */
27
+ export function appName(args) {
28
+ const first = String(args ?? "").split(/\s+/)[0] ?? "";
29
+ const base = path.basename(first).toLowerCase();
30
+ if (base === "claude" || /claude/.test(base)) return "Claude Code";
31
+ if (base === "codex" || /codex/.test(base)) return "Codex";
32
+ if (/cursor/i.test(first)) return "Cursor";
33
+ return base || "a session";
34
+ }
35
+
36
+ /** Servers that started before the copy they'd now load was installed. */
37
+ export function staleSessions(procs, { installedAt }) {
38
+ if (!Number.isFinite(installedAt)) return [];
39
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
40
+ return procs
41
+ .filter((p) => SERVER_RE.test(p.args) && p.startedAt < installedAt)
42
+ .map((p) => {
43
+ const parent = byPid.get(p.ppid);
44
+ return { pid: p.pid, appPid: parent?.pid ?? p.ppid, app: appName(parent?.args), tty: p.tty ?? parent?.tty ?? null, startedAt: p.startedAt };
45
+ })
46
+ .sort((a, b) => a.startedAt - b.startedAt);
47
+ }
48
+
49
+ /** Running processes, or null where `ps` isn't available (Windows). */
50
+ export function listProcesses({ run = spawnSync, platform = process.platform } = {}) {
51
+ if (platform === "win32") return null;
52
+ const res = run("ps", ["-A", "-o", "pid=,ppid=,tty=,lstart=,args="], { encoding: "utf8", timeout: 10000 });
53
+ return res.status === 0 ? parsePs(res.stdout) : null;
54
+ }
55
+
56
+ export function formatStaleSessions(list, { now = Date.now() } = {}) {
57
+ const when = (ms) => new Date(ms).toLocaleString("en-US", { weekday: "short", hour: "numeric", minute: "2-digit" });
58
+ const age = (ms) => { const h = Math.round((now - ms) / 3600000); return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`; };
59
+ return list.map((s) => ` ${s.app}${s.tty ? ` on ${s.tty}` : ""}, started ${when(s.startedAt)} (${age(s.startedAt)}), pid ${s.appPid}`);
60
+ }