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.
- package/README.md +86 -480
- package/bin/nomarmy.mjs +1081 -185
- package/docker/Dockerfile +2 -2
- package/docker/Dockerfile.go +6 -4
- package/docker/Dockerfile.rust +17 -2
- package/harnesses/_template/README.md +27 -0
- package/harnesses/_template/harness.yml +26 -0
- package/harnesses/browser-playwright/README.md +35 -0
- package/harnesses/browser-playwright/fixture/package.json +1 -0
- package/harnesses/browser-playwright/fixture/page.html +1 -0
- package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
- package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
- package/harnesses/browser-playwright/harness.yml +18 -0
- package/harnesses/go/README.md +45 -0
- package/harnesses/go/harness.yml +14 -0
- package/harnesses/mock-oidc/README.md +31 -0
- package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
- package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
- package/harnesses/mock-oidc/harness.yml +19 -0
- package/harnesses/node/README.md +53 -0
- package/harnesses/node/harness.yml +18 -0
- package/harnesses/python/README.md +46 -0
- package/harnesses/python/harness.yml +16 -0
- package/harnesses/rust/README.md +45 -0
- package/harnesses/rust/harness.yml +13 -0
- package/install.sh +29 -9
- package/lib/admission.mjs +178 -30
- package/lib/agents.mjs +8 -6
- package/lib/army.mjs +25 -10
- package/lib/codex-link.mjs +37 -0
- package/lib/config.mjs +15 -0
- package/lib/connect.mjs +232 -19
- package/lib/continue-from.mjs +103 -0
- package/lib/coordinator-instructions.mjs +5 -1
- package/lib/diff-checks.mjs +114 -0
- package/lib/dispatch-schema.mjs +14 -12
- package/lib/doctor.mjs +98 -9
- package/lib/egress-proxy.mjs +116 -0
- package/lib/execute.mjs +241 -33
- package/lib/git-record.mjs +27 -3
- package/lib/harness-schema.mjs +61 -0
- package/lib/harnesses.mjs +99 -0
- package/lib/health.mjs +162 -18
- package/lib/install-freshness.mjs +114 -0
- package/lib/jev-checks.mjs +110 -0
- package/lib/job-format.mjs +54 -0
- package/lib/judge.mjs +130 -0
- package/lib/limits.mjs +77 -0
- package/lib/model-probe.mjs +61 -0
- package/lib/mutation.mjs +159 -0
- package/lib/notify.mjs +30 -3
- package/lib/openclaw-install.mjs +122 -0
- package/lib/openclaw-path.mjs +28 -0
- package/lib/openclaw-run.mjs +74 -12
- package/lib/openclaw-runtime-health.mjs +56 -0
- package/lib/outcome.mjs +21 -2
- package/lib/outcomes.mjs +6 -0
- package/lib/path-utils.mjs +4 -0
- package/lib/podman-health.mjs +41 -0
- package/lib/process.mjs +4 -1
- package/lib/propose.mjs +10 -11
- package/lib/refusal-retry.mjs +16 -0
- package/lib/registry-python.mjs +98 -0
- package/lib/registry-secrets.mjs +140 -0
- package/lib/repo-query.mjs +13 -7
- package/lib/runs.mjs +7 -1
- package/lib/same-path.mjs +14 -0
- package/lib/sandbox-images.mjs +499 -83
- package/lib/sandbox-vm.mjs +32 -0
- package/lib/scan.mjs +5 -1
- package/lib/schema.mjs +20 -11
- package/lib/scout.mjs +21 -3
- package/lib/server-context.mjs +21 -1
- package/lib/setup-steps.mjs +55 -0
- package/lib/share.mjs +82 -0
- package/lib/stale-sessions.mjs +60 -0
- package/lib/stats.mjs +315 -0
- package/lib/statusline.mjs +32 -6
- package/lib/subscription-setup.mjs +13 -0
- package/lib/suggestions.mjs +153 -0
- package/lib/thinking.mjs +23 -0
- package/lib/transcript.mjs +30 -5
- package/lib/usage-limits.mjs +329 -0
- package/lib/user-config.mjs +106 -0
- package/lib/validators.mjs +220 -0
- package/lib/verification-artifacts.mjs +46 -0
- package/lib/verification-flow.mjs +52 -7
- package/lib/verification-network.mjs +66 -0
- package/lib/verify.mjs +338 -85
- package/lib/worker-prompt.mjs +5 -2
- package/lib/wsl-cli.mjs +152 -0
- package/lib/wsl.mjs +230 -0
- package/lib/zod-issues.mjs +15 -0
- package/mcp/server.mjs +165 -34
- package/package.json +7 -5
- package/playbooks/feature.md +8 -5
- package/scripts/configure-openclaw.sh +4 -2
- package/scripts/generate-harness-docs.mjs +42 -0
- package/scripts/install-openclaw.mjs +23 -0
- package/scripts/lib.sh +9 -2
- package/scripts/select-model.mjs +12 -5
- 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) =>
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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(), {
|
|
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
|
-
.
|
|
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
|
|
306
|
+
if (isUnknownDiscriminator(issue)) {
|
|
298
307
|
lines.push(`${where}: must be one of ${SERVICE_SOURCES.join(", ")}`);
|
|
299
308
|
continue;
|
|
300
309
|
}
|
|
301
|
-
if (issue
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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"
|
package/lib/server-context.mjs
CHANGED
|
@@ -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
|
|
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, "&")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/"/g, """)
|
|
47
|
+
.replace(/'/g, "'");
|
|
48
|
+
// Verdana 11px, the badge convention: rough widths by letter shape, plus padding.
|
|
49
|
+
function textWidth(s) {
|
|
50
|
+
let w = 0;
|
|
51
|
+
for (const ch of String(s)) w += /[mwMW]/.test(ch) ? 10.5 : /[ijlI|.,:;!' ]/.test(ch) ? 3.8 : /[frt]/.test(ch) ? 4.8 : /[A-Z]/.test(ch) ? 7.6 : /[0-9]/.test(ch) ? 7 : ch === "·" ? 4.5 : 6.6;
|
|
52
|
+
return Math.round(w + 14);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A shields-style SVG: "nomArmy | 78 claims checked · 11 caught". Caught
|
|
57
|
+
* claims are the point of running nomArmy, not something to hide, so the
|
|
58
|
+
* color is a neutral blue, and green when every claim held up.
|
|
59
|
+
*/
|
|
60
|
+
export function badgeSvg(stats) {
|
|
61
|
+
const { total, caught } = claims(stats);
|
|
62
|
+
const label = "nomArmy";
|
|
63
|
+
const message = !total ? "verified" : caught ? `${total} claims checked · ${caught} caught` : `${total} claims checked, all held up`;
|
|
64
|
+
const color = total && !caught ? "#2da44e" : "#0969da";
|
|
65
|
+
const lw = textWidth(label), mw = textWidth(message), w = lw + mw;
|
|
66
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20" role="img" aria-label="${esc(`${label}: ${message}`)}">
|
|
67
|
+
<title>${esc(`${label}: ${message}`)}</title>
|
|
68
|
+
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
|
|
69
|
+
<clipPath id="r"><rect width="${w}" height="20" rx="3" fill="#fff"/></clipPath>
|
|
70
|
+
<g clip-path="url(#r)"><rect width="${lw}" height="20" fill="#555"/><rect x="${lw}" width="${mw}" height="20" fill="${color}"/><rect width="${w}" height="20" fill="url(#s)"/></g>
|
|
71
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
|
72
|
+
<text x="${lw / 2}" y="14">${esc(label)}</text>
|
|
73
|
+
<text x="${lw + mw / 2}" y="14">${esc(message)}</text>
|
|
74
|
+
</g>
|
|
75
|
+
</svg>
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The README line for a committed badge, linking to nomArmy. */
|
|
80
|
+
export function badgeMarkdown(relPath) {
|
|
81
|
+
return `[](${REPO_URL})`;
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|