nomarmy 0.1.0-alpha.8 → 0.1.0-alpha.9
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 +39 -32
- package/lib/connect.mjs +8 -0
- package/lib/execute.mjs +2 -2
- package/lib/health.mjs +3 -1
- package/lib/install-freshness.mjs +31 -3
- package/lib/scout.mjs +6 -2
- package/lib/verify.mjs +35 -4
- package/lib/worker-prompt.mjs +5 -2
- package/package.json +1 -1
package/bin/nomarmy.mjs
CHANGED
|
@@ -19,7 +19,7 @@ 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
21
|
import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir } from "../lib/connect.mjs";
|
|
22
|
-
import { compareVersions, readPackageVersion, readInstallVersions } from "../lib/install-freshness.mjs";
|
|
22
|
+
import { compareVersions, readPackageVersion, readInstallVersions, copyIsStale } from "../lib/install-freshness.mjs";
|
|
23
23
|
import { ID_RE, AUTH_ENV_NAME_RE, OPENCLAW_PROVIDER_ID_RE, openclawProviderId, isNativeProviderType } from "../lib/dispatch-schema.mjs";
|
|
24
24
|
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
25
|
import { loadArmy, mergeArmy, describeArmy, readArmyFile, updateArmyInFile, assignRoleInFile, parseTargetSpec, armyLayerPath, globalConfigDir, DEFAULT_ARMY, ARMY_PHASES, LOCAL_CONFIG_FILENAME } from "../lib/army.mjs";
|
|
@@ -543,14 +543,16 @@ function defaultLocalProfile() {
|
|
|
543
543
|
return spawnSync("nvidia-smi", ["-L"], { stdio: "ignore", timeout: 5000 }).status === 0 ? "nvidia-linux" : "cpu-linux";
|
|
544
544
|
}
|
|
545
545
|
|
|
546
|
-
|
|
546
|
+
// Which profile this machine is set up for, the same way for `setup` and
|
|
547
|
+
// `install`: the one setup recorded, else the install marker's, else the
|
|
548
|
+
// execution mode's, else (a working local install) install.sh's default.
|
|
549
|
+
function setupProfileState() {
|
|
547
550
|
const common = path.join(nomarmyRoot, "config", "common.env");
|
|
548
551
|
const chosen = readEnvValue(common, "NOMARMY_SETUP_PROFILE");
|
|
549
552
|
const probeCommand = (binary, args) => {
|
|
550
553
|
const result = spawnSync(binary, args, { encoding: "utf8", timeout: 10000 });
|
|
551
554
|
return result.status === 0 ? result.stdout.trim() : "";
|
|
552
555
|
};
|
|
553
|
-
const project = setupProjectDir();
|
|
554
556
|
const profileFile = chosen ? path.join(nomarmyRoot, "config", "profiles", `${chosen}.env`) : null;
|
|
555
557
|
const root = (process.env.NOMARMY_INSTALL_ROOT || (profileFile && readEnvValue(profileFile, "NOMARMY_INSTALL_ROOT")) || readEnvValue(common, "NOMARMY_INSTALL_ROOT") || "$HOME/.local/share/nomarmy-local-agents").replace(/\$HOME|\$\{HOME\}/g, os.homedir());
|
|
556
558
|
let marker = null;
|
|
@@ -564,6 +566,12 @@ function setupChecklist() {
|
|
|
564
566
|
const profile = chosen ?? marker?.profile
|
|
565
567
|
?? (["hosted", "remote", "bedrock"].includes(execution) ? execution : null)
|
|
566
568
|
?? (registered ? defaultLocalProfile() : null);
|
|
569
|
+
return { common, profile, marker, version, registered };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function setupChecklist() {
|
|
573
|
+
const { common, profile, marker, version, registered } = setupProfileState();
|
|
574
|
+
const project = setupProjectDir();
|
|
567
575
|
return setupSteps({
|
|
568
576
|
mode: () => ({ profile, host: readEnvValue(common, "NOMARMY_LLAMA_HOST"), port: readEnvValue(common, "NOMARMY_LLAMA_PORT") }),
|
|
569
577
|
install: () => ({ marker, version, registered }),
|
|
@@ -590,8 +598,9 @@ function runSetupChild(args) {
|
|
|
590
598
|
}
|
|
591
599
|
|
|
592
600
|
function cmdInstall() {
|
|
593
|
-
const profile = value("profile",
|
|
601
|
+
const profile = value("profile", null) ?? setupProfileState().profile;
|
|
594
602
|
if (!profile) throw new Error("Choose a profile first: nomarmy setup --choose (or install --profile <name>).");
|
|
603
|
+
if (!value("profile", null)) console.log(`Installing for profile ${profile} (from nomarmy setup; pass --profile to choose another).`);
|
|
595
604
|
const result = spawnSync("bash", [path.join(nomarmyRoot, "install.sh"), "--profile", profile, ...(flag("no-claude") ? ["--no-claude"] : [])], { stdio: "inherit", cwd: nomarmyRoot });
|
|
596
605
|
process.exitCode = result.status ?? 1;
|
|
597
606
|
}
|
|
@@ -1660,8 +1669,18 @@ async function cmdUpdate() {
|
|
|
1660
1669
|
const remote = git(["rev-parse", "@{u}"]);
|
|
1661
1670
|
const base = git(["merge-base", "HEAD", "@{u}"]);
|
|
1662
1671
|
if (local === remote) {
|
|
1663
|
-
|
|
1664
|
-
|
|
1672
|
+
// Nothing to pull, but the copy coordinators run can still be behind
|
|
1673
|
+
// this checkout (commits made or pulled here without a reconnect).
|
|
1674
|
+
if (!copyIsStale(defaultInstallDir(), nomarmyRoot)) {
|
|
1675
|
+
if (json) return out({ updated: false, reason: "already up to date" });
|
|
1676
|
+
console.log(c.green("✓ Already up to date, and your coordinators run this checkout."));
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
say(c.bold("🍪 nomArmy update\n"));
|
|
1680
|
+
say("Nothing to pull, but your coordinators run an older copy of this checkout.");
|
|
1681
|
+
const resynced = reconnectCoordinators();
|
|
1682
|
+
if (json) return out({ updated: false, resynced, sha: local });
|
|
1683
|
+
console.log(c.yellow("\nRestart every open Claude Code, Codex and Cursor session: each keeps the code it started with until then."));
|
|
1665
1684
|
return;
|
|
1666
1685
|
}
|
|
1667
1686
|
if (base !== local) {
|
|
@@ -1677,25 +1696,7 @@ async function cmdUpdate() {
|
|
|
1677
1696
|
say("\nInstalling dependencies...");
|
|
1678
1697
|
execFileSync("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], { cwd: nomarmyRoot, stdio: json ? "ignore" : "inherit" });
|
|
1679
1698
|
|
|
1680
|
-
const resynced =
|
|
1681
|
-
const runInherit = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: json ? "ignore" : "inherit", ...opts });
|
|
1682
|
-
if (commandExists("claude")) {
|
|
1683
|
-
say("\nRe-syncing the Claude Code MCP install...");
|
|
1684
|
-
connectClaude({ nomarmyRoot, run: runInherit });
|
|
1685
|
-
resynced.push("claude");
|
|
1686
|
-
}
|
|
1687
|
-
if (commandExists("codex")) {
|
|
1688
|
-
say("\nRe-syncing the Codex MCP install...");
|
|
1689
|
-
connectCodex({ nomarmyRoot, run: runInherit });
|
|
1690
|
-
resynced.push("codex");
|
|
1691
|
-
}
|
|
1692
|
-
// Cursor has no CLI/PATH binary to probe with commandExists -- "already
|
|
1693
|
-
// connected" is read from its own config file instead.
|
|
1694
|
-
if (cursorAlreadyConnected()) {
|
|
1695
|
-
say("\nRe-syncing the Cursor MCP install...");
|
|
1696
|
-
connectCursor({ nomarmyRoot, run: runInherit });
|
|
1697
|
-
resynced.push("cursor");
|
|
1698
|
-
}
|
|
1699
|
+
const resynced = reconnectCoordinators();
|
|
1699
1700
|
|
|
1700
1701
|
if (json) return out({ updated: true, sha: git(["rev-parse", "HEAD"]), resynced });
|
|
1701
1702
|
console.log(c.yellow("\nThe MCP server is a per-session child process: every open Claude Code / Codex / Cursor session needs a restart to pick this up, not just this one."));
|
|
@@ -1703,6 +1704,18 @@ async function cmdUpdate() {
|
|
|
1703
1704
|
|
|
1704
1705
|
// The coordinators nomArmy is registered with. Cursor has no CLI to probe,
|
|
1705
1706
|
// so it counts when its own config already lists nomArmy.
|
|
1707
|
+
// Reconnect every connected coordinator through a child process, so it runs
|
|
1708
|
+
// the code now on disk (just pulled or installed) rather than the old code
|
|
1709
|
+
// this process loaded. Returns the targets reconnected.
|
|
1710
|
+
function reconnectCoordinators() {
|
|
1711
|
+
const targets = connectedTargets();
|
|
1712
|
+
if (targets.length) {
|
|
1713
|
+
if (!json) console.log(`\nReconnecting ${targets.join(", ")}...`);
|
|
1714
|
+
execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", ...targets, ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
|
|
1715
|
+
}
|
|
1716
|
+
return targets;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1706
1719
|
function connectedTargets() {
|
|
1707
1720
|
return [commandExists("claude") && "claude", commandExists("codex") && "codex", cursorAlreadyConnected() && "cursor"].filter(Boolean);
|
|
1708
1721
|
}
|
|
@@ -1729,13 +1742,7 @@ async function updateFromNpm() {
|
|
|
1729
1742
|
if (!json) console.log(c.bold(`🍪 Updating nomArmy ${current} → ${latest}\n`));
|
|
1730
1743
|
execFileSync("npm", ["install", "-g", `nomarmy@${latest}`, "--no-audit", "--no-fund"], { stdio: json ? "ignore" : "inherit" });
|
|
1731
1744
|
}
|
|
1732
|
-
|
|
1733
|
-
// than the old code this process loaded.
|
|
1734
|
-
const targets = connectedTargets();
|
|
1735
|
-
if (targets.length) {
|
|
1736
|
-
if (!json) console.log(`\nReconnecting ${targets.join(", ")}...`);
|
|
1737
|
-
execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", ...targets, ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
|
|
1738
|
-
}
|
|
1745
|
+
const targets = reconnectCoordinators();
|
|
1739
1746
|
if (json) return out({ updated: upgrade, from: current, version: upgrade ? latest : current, resynced: targets });
|
|
1740
1747
|
console.log(c.yellow("\nRestart every open Claude Code, Codex and Cursor session: each keeps the code it started with until then."));
|
|
1741
1748
|
}
|
package/lib/connect.mjs
CHANGED
|
@@ -171,6 +171,14 @@ export function installMcpCopy({ nomarmyRoot, installDir, run = defaultRun }) {
|
|
|
171
171
|
fs.rmSync(path.join(installDir, "config"), { recursive: true, force: true });
|
|
172
172
|
fs.cpSync(configSrc, path.join(installDir, "config"), { recursive: true });
|
|
173
173
|
}
|
|
174
|
+
// lib/harnesses.mjs finds its definitions at ../harnesses/, so the copy
|
|
175
|
+
// needs them too: without this, every connected session matched no
|
|
176
|
+
// harness and built its jobs in the plain base image, silently.
|
|
177
|
+
const harnessSrc = path.join(nomarmyRoot, "harnesses");
|
|
178
|
+
if (fs.existsSync(harnessSrc)) {
|
|
179
|
+
fs.rmSync(path.join(installDir, "harnesses"), { recursive: true, force: true });
|
|
180
|
+
fs.cpSync(harnessSrc, path.join(installDir, "harnesses"), { recursive: true });
|
|
181
|
+
}
|
|
174
182
|
run("npm", ["install", "--omit=dev"], { cwd: installDir });
|
|
175
183
|
run(process.execPath, ["--check", "mcp/server.mjs"], { cwd: installDir });
|
|
176
184
|
}
|
package/lib/execute.mjs
CHANGED
|
@@ -212,7 +212,7 @@ export function createExecutor(deps) {
|
|
|
212
212
|
const recoveryResult = await runOpenClaw({
|
|
213
213
|
task, acceptance, verification, mode, cwd, baseRef: base.ref, baseSha: base.sha,
|
|
214
214
|
timeoutSeconds: timeBudget.reportReserveSeconds, runtimeDir, profile, reasoning, pool, subscriptionWorker, onBehalfOf, model, reportSize, jobDir, workerId: workerId || jobId,
|
|
215
|
-
overridePrompt: reportRecoveryPrompt({ report: budgetState.budgets.report.implement, changes }), logSuffix: "-recovery",
|
|
215
|
+
overridePrompt: reportRecoveryPrompt({ report: budgetState.budgets.report.implement, changes, task }), logSuffix: "-recovery",
|
|
216
216
|
});
|
|
217
217
|
const recoveryText = finalText(recoveryResult);
|
|
218
218
|
const recoveryValidation = parseWorkerReport(recoveryText);
|
|
@@ -568,7 +568,7 @@ export function createExecutor(deps) {
|
|
|
568
568
|
task, acceptance, verification: null, mode, cwd: worktree, baseRef: base.ref, baseSha: base.sha,
|
|
569
569
|
timeoutSeconds: remainingSeconds, runtimeDir, profile, reasoning, pool, subscriptionWorker, onBehalfOf, model, reportSize, jobDir, workerId: workerId || jobId,
|
|
570
570
|
evidenceTool: evidencePlaced ? evidenceTool : null,
|
|
571
|
-
overridePrompt: scoutReportRecoveryPrompt({ report: used.report.scout }), logSuffix: "-recovery",
|
|
571
|
+
overridePrompt: scoutReportRecoveryPrompt({ report: used.report.scout, question: task, acceptance }), logSuffix: "-recovery",
|
|
572
572
|
});
|
|
573
573
|
const recoveryReport = parseScoutReport(finalText(recoveryResult), (recoveryResult?.budgetsUsed ?? used).scout);
|
|
574
574
|
if (!isScoutReportUnusable(recoveryReport)) {
|
package/lib/health.mjs
CHANGED
|
@@ -324,7 +324,9 @@ export async function checkAndRecordHealth({ projectDir, stateRoot, configDir, n
|
|
|
324
324
|
if (ageMs !== null) { try { autoPruned = { ...pruneJobRuntime({ stateRoot, olderThanMs: ageMs, now }), olderThanHours: ageMs / 3600000 }; } catch { /* best-effort */ } }
|
|
325
325
|
const mode = executionMode(env).mode;
|
|
326
326
|
const { defaultInstallDir } = await import("./connect.mjs");
|
|
327
|
-
const
|
|
327
|
+
const installDir = defaultInstallDir();
|
|
328
|
+
const { loadHarnesses } = await import("./harnesses.mjs");
|
|
329
|
+
const install = { ...readInstallVersions(installDir), copyHarnesses: Object.keys(loadHarnesses(path.join(installDir, "harnesses")).harnesses).length };
|
|
328
330
|
const result = await runHealthChecks({ now, mode, armySummary, agentsError, jobsRoot: path.join(stateRoot, "jobs"), pidAlive,
|
|
329
331
|
agents, openclawConfig: readOpenclawConfig(), vendors: SUBSCRIPTION_VENDORS, modelsInUse, autoPruned, usageSnapshots: readUsageSnapshots(stateRoot), install });
|
|
330
332
|
const toNotify = recordHealth(path.join(stateRoot, "health.json"), result, { now });
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// (the session wasn't restarted); per session, so it is
|
|
11
11
|
// reported by that session's server, not in health.json
|
|
12
12
|
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
13
14
|
import fs from "node:fs";
|
|
14
15
|
import path from "node:path";
|
|
15
16
|
|
|
@@ -21,8 +22,30 @@ export function readPackageVersion(root) {
|
|
|
21
22
|
catch { return null; }
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** The checkout's commit, for a git install; null for an npm install. */
|
|
26
|
+
export function readSourceCommit(root) {
|
|
27
|
+
if (!fs.existsSync(path.join(root, ".git"))) return null;
|
|
28
|
+
try { return execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null; }
|
|
29
|
+
catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
export function recordCopySource(installDir, nomarmyRoot) {
|
|
25
|
-
|
|
33
|
+
const commit = readSourceCommit(nomarmyRoot);
|
|
34
|
+
fs.writeFileSync(path.join(installDir, SOURCE_FILE), JSON.stringify({ root: nomarmyRoot, version: readPackageVersion(nomarmyRoot), ...(commit ? { commit } : {}) }, null, 2) + "\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the copy in installDir is behind the checkout or package at
|
|
39
|
+
* nomarmyRoot: an older version, a different commit (a git install moves on
|
|
40
|
+
* without a version bump), or no record of its source at all.
|
|
41
|
+
*/
|
|
42
|
+
export function copyIsStale(installDir, nomarmyRoot) {
|
|
43
|
+
let source = null;
|
|
44
|
+
try { source = JSON.parse(fs.readFileSync(path.join(installDir, SOURCE_FILE), "utf8")); } catch { return true; }
|
|
45
|
+
const copyVersion = readPackageVersion(installDir), rootVersion = readPackageVersion(nomarmyRoot);
|
|
46
|
+
if (!copyVersion || !rootVersion || copyVersion !== rootVersion) return true;
|
|
47
|
+
const commit = readSourceCommit(nomarmyRoot);
|
|
48
|
+
return Boolean(commit && source.commit !== commit);
|
|
26
49
|
}
|
|
27
50
|
|
|
28
51
|
/** The copy's version and, when connect recorded it, the version now at its source. */
|
|
@@ -52,9 +75,14 @@ export function compareVersions(a, b) {
|
|
|
52
75
|
|
|
53
76
|
const valid = (v) => typeof v === "string" && /^v?\d+\.\d+\.\d+/.test(v.trim());
|
|
54
77
|
|
|
55
|
-
/** Health issues for a stale CLI
|
|
56
|
-
export function freshnessIssues({ copyVersion = null, sourceVersion = null, latestVersion = null }) {
|
|
78
|
+
/** Health issues for a stale CLI, a stale installed copy, or a copy missing its harnesses. */
|
|
79
|
+
export function freshnessIssues({ copyVersion = null, sourceVersion = null, latestVersion = null, copyHarnesses = null }) {
|
|
57
80
|
const issues = [];
|
|
81
|
+
if (valid(copyVersion) && copyHarnesses === 0) {
|
|
82
|
+
issues.push({ id: `nomarmy-harnesses:${copyVersion}`, severity: "error", title: "The nomArmy your coordinators run has no harnesses",
|
|
83
|
+
detail: "Jobs match no harness, so they run in the plain base image: no dependencies, fake services, browser tests or artifacts.",
|
|
84
|
+
fix: "nomarmy connect claude (and codex, cursor), then restart those sessions", short: "no harnesses" });
|
|
85
|
+
}
|
|
58
86
|
if (valid(latestVersion) && valid(sourceVersion) && compareVersions(sourceVersion, latestVersion) < 0) {
|
|
59
87
|
const latest = latestVersion.trim();
|
|
60
88
|
issues.push({ id: `nomarmy-update:${latest}`, severity: "info", title: `nomArmy ${latest} is out (you have ${sourceVersion})`,
|
package/lib/scout.mjs
CHANGED
|
@@ -435,8 +435,12 @@ 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
|
+
export function scoutReportRecoveryPrompt({ report = { targetTokens: 600, hardCapTokens: 1024 }, question = null, acceptance = [] } = {}) {
|
|
442
|
+
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")}` : ""}` : "";
|
|
443
|
+
return `Your previous reply ended without the required SCOUT REPORT, or was cut off before reaching END.${asked}\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.`;
|
|
440
444
|
}
|
|
441
445
|
|
|
442
446
|
// ---------------------------------------------------------------------------
|
package/lib/verify.mjs
CHANGED
|
@@ -135,10 +135,19 @@ function tailOf(result) {
|
|
|
135
135
|
* command that never started is absence of evidence, so if nothing ever ran the
|
|
136
136
|
* verdict is `not_run`, never `fail`.
|
|
137
137
|
*
|
|
138
|
+
* A command guarded on nomArmy's changed-file variables (`if [ -n
|
|
139
|
+
* "$NOMARMY_CHANGED_TEST_FILES" ]; then ...; fi`) exits 0 without running
|
|
140
|
+
* anything when those are empty, as on an unchanged checkout in mode: verify.
|
|
141
|
+
* That's skipped, not passed: exit 0, no output at all, and every
|
|
142
|
+
* NOMARMY_CHANGED_* variable the command names empty. A command with a
|
|
143
|
+
* fallback (`${NOMARMY_CHANGED_TEST_FILES:-tests/}`) prints test output, so
|
|
144
|
+
* it counts as run. If every command skipped, nothing was verified: not_run.
|
|
145
|
+
*
|
|
138
146
|
* @param {Array<object>} results
|
|
139
|
-
* @
|
|
147
|
+
* @param {{ env?: Record<string, string> }} [context] the NOMARMY_CHANGED_* values the commands saw
|
|
148
|
+
* @returns {{ status: "pass"|"fail"|"not_run", detail: string, basis?: string }}
|
|
140
149
|
*/
|
|
141
|
-
export function classifyResults(results) {
|
|
150
|
+
export function classifyResults(results, { env = {} } = {}) {
|
|
142
151
|
const list = Array.isArray(results) ? results.filter(Boolean) : [];
|
|
143
152
|
if (list.length === 0) {
|
|
144
153
|
return { status: "not_run", detail: "no commands were executed" };
|
|
@@ -176,9 +185,28 @@ export function classifyResults(results) {
|
|
|
176
185
|
}
|
|
177
186
|
}
|
|
178
187
|
|
|
188
|
+
const skipped = list.map((result, index) => ({ index, why: guardedNoOp(result, env) })).filter((s) => s.why);
|
|
189
|
+
if (skipped.length === total) {
|
|
190
|
+
return { status: "not_run", basis: "nothing-changed", detail: `every command is scoped to changed files and nothing changed, so none ran (${[...new Set(skipped.map((s) => s.why))].join("; ")}); add an unscoped profile to run the full suite` };
|
|
191
|
+
}
|
|
192
|
+
if (skipped.length) {
|
|
193
|
+
const which = skipped.map((s) => `command ${s.index + 1} (\`${list[s.index].command ?? "?"}\`) did nothing: ${s.why}`).join("; ");
|
|
194
|
+
return { status: "pass", detail: `${total - skipped.length} of ${total} commands passed; ${skipped.length} skipped: ${which}` };
|
|
195
|
+
}
|
|
179
196
|
return { status: "pass", detail: `${total} of ${total} commands passed` };
|
|
180
197
|
}
|
|
181
198
|
|
|
199
|
+
const CHANGED_VAR_RE = /\$\{?(NOMARMY_CHANGED_[A-Z_]+)/g;
|
|
200
|
+
|
|
201
|
+
/** Why a zero-exit, silent command was a guarded no-op, or null if it ran. */
|
|
202
|
+
function guardedNoOp(result, env) {
|
|
203
|
+
if (result.exitCode !== 0 || result.timedOut || result.started === false) return null;
|
|
204
|
+
if (result.silent === false || (result.silent === undefined && `${result.stdout ?? ""}${result.stderr ?? ""}`.trim())) return null;
|
|
205
|
+
const names = [...new Set([...String(result.command ?? "").matchAll(CHANGED_VAR_RE)].map((m) => m[1]))];
|
|
206
|
+
if (!names.length || names.some((name) => String(env[name] ?? "").trim())) return null;
|
|
207
|
+
return `${names.join(" and ")} ${names.length > 1 ? "are" : "is"} empty`;
|
|
208
|
+
}
|
|
209
|
+
|
|
182
210
|
// ---------------------------------------------------------------------------
|
|
183
211
|
// pure: output capping
|
|
184
212
|
// ---------------------------------------------------------------------------
|
|
@@ -853,6 +881,9 @@ export function createVerificationRunner(options = {}) {
|
|
|
853
881
|
timeoutMs,
|
|
854
882
|
stdout: stdout.text,
|
|
855
883
|
stderr: stderr.text,
|
|
884
|
+
// Whether the command printed nothing at all, judged before any
|
|
885
|
+
// output is withheld, so a guarded no-op is still recognized.
|
|
886
|
+
silent: !String(raw?.stdout ?? "").trim() && !String(raw?.stderr ?? "").trim(),
|
|
856
887
|
truncated: { stdout: stdout.dropped, stderr: stderr.dropped },
|
|
857
888
|
};
|
|
858
889
|
results.push(entry);
|
|
@@ -876,7 +907,7 @@ export function createVerificationRunner(options = {}) {
|
|
|
876
907
|
}
|
|
877
908
|
}
|
|
878
909
|
|
|
879
|
-
const verdict = classifyResults(results);
|
|
910
|
+
const verdict = classifyResults(results, { env: verificationEnv });
|
|
880
911
|
const executed = results.filter((r) => r.started).length;
|
|
881
912
|
const dropped = results.reduce(
|
|
882
913
|
(total, r) => total + (r.truncated?.stdout ?? 0) + (r.truncated?.stderr ?? 0),
|
|
@@ -888,7 +919,7 @@ export function createVerificationRunner(options = {}) {
|
|
|
888
919
|
: "";
|
|
889
920
|
|
|
890
921
|
if (verdict.status === "not_run") {
|
|
891
|
-
return notRun(verdict.detail, "sandbox-unavailable", { ...networkInfo, output: renderOutput() });
|
|
922
|
+
return notRun(verdict.detail, verdict.basis ?? "sandbox-unavailable", { ...networkInfo, output: renderOutput() });
|
|
892
923
|
}
|
|
893
924
|
|
|
894
925
|
return {
|
package/lib/worker-prompt.mjs
CHANGED
|
@@ -69,10 +69,13 @@ export function describeRecoveryChanges(record) {
|
|
|
69
69
|
return `${record.repoStatusFiles.length} file(s) differ from a clean checkout: ${record.repoStatusFiles.join(", ")}`;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
export function reportRecoveryPrompt({ report = { targetTokens: 256, hardCapTokens: 512 }, changes = null } = {}) {
|
|
72
|
+
export function reportRecoveryPrompt({ report = { targetTokens: 256, hardCapTokens: 512 }, changes = null, task = null } = {}) {
|
|
73
|
+
// The objective itself, so STATUS is judged against it even when the
|
|
74
|
+
// resumed session lost it with the cut-off reply.
|
|
75
|
+
const objective = task ? `\nThe objective you were working on:\n${String(task).trim()}\n` : "";
|
|
73
76
|
const changesLine = changes
|
|
74
77
|
? `\nThe repository (checked independently just now, not from your memory of this session) already shows: ${changes}. Trust this over any uncertainty about what you did or did not do.\n`
|
|
75
78
|
: `\nThe repository (checked independently just now, not from your memory of this session) shows no changes at all.\n`;
|
|
76
|
-
return `Your previous reply ended without the required final report, or was cut off before completing it.\n${changesLine}\nDo not repeat, redo, retry, or describe any action you already took. Do not call any tool. Reply with ONLY the four lines below, nothing before them, nothing after them:\n\nSTATUS: done | partial | blocked\nTESTS: pass | fail | not_run\nNOT_DONE: none | <brief>\nNOTE: <brief implementation or risk note>\n\nUse the exact field names above, including the underscore in NOT_DONE. Target ${report.targetTokens} tokens; ${report.hardCapTokens} is the hard cap. Base STATUS on the repository state above, not on what you recall attempting: if it shows the edit landed, you may report done; if it shows nothing relevant, report blocked or partial rather than guessing done.`;
|
|
79
|
+
return `Your previous reply ended without the required final report, or was cut off before completing it.\n${objective}${changesLine}\nDo not repeat, redo, retry, or describe any action you already took. Do not call any tool. Reply with ONLY the four lines below, nothing before them, nothing after them:\n\nSTATUS: done | partial | blocked\nTESTS: pass | fail | not_run\nNOT_DONE: none | <brief>\nNOTE: <brief implementation or risk note>\n\nUse the exact field names above, including the underscore in NOT_DONE. Target ${report.targetTokens} tokens; ${report.hardCapTokens} is the hard cap. Base STATUS on the repository state above, not on what you recall attempting: if it shows the edit landed, you may report done; if it shows nothing relevant, report blocked or partial rather than guessing done.`;
|
|
77
80
|
}
|
|
78
81
|
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Every byte verified: a harness for AI coding workers whose claims are never trusted. Your coding assistant stays in charge while workers implement and test in sandboxes, and nomArmy checks every change before it is committed.",
|
|
4
4
|
"author": "Rayson Technologies",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"version": "0.1.0-alpha.
|
|
6
|
+
"version": "0.1.0-alpha.9",
|
|
7
7
|
"private": false,
|
|
8
8
|
"type": "module",
|
|
9
9
|
"engines": {
|