nomarmy 0.1.0-alpha.21 → 0.1.0-alpha.22
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 +155 -16
- package/lib/acceptance-fill.mjs +180 -0
- package/lib/acceptance-impact.mjs +215 -0
- package/lib/acceptance-result.mjs +21 -0
- package/lib/acceptance.mjs +166 -0
- package/lib/admission.mjs +33 -3
- package/lib/army.mjs +2 -2
- package/lib/codex-link.mjs +6 -1
- package/lib/connect.mjs +6 -2
- package/lib/continue-from.mjs +29 -12
- package/lib/coordinator-instructions.mjs +4 -0
- package/lib/diff-checks.mjs +9 -3
- package/lib/doctor.mjs +3 -1
- package/lib/execute.mjs +187 -32
- package/lib/git-record.mjs +9 -2
- package/lib/health.mjs +25 -3
- package/lib/install-freshness.mjs +2 -2
- package/lib/jev-checks.mjs +13 -0
- package/lib/job-format.mjs +3 -1
- package/lib/judge.mjs +26 -5
- package/lib/line-diff.mjs +46 -0
- package/lib/mutation.mjs +27 -13
- package/lib/openclaw-run.mjs +21 -7
- package/lib/process.mjs +30 -4
- package/lib/repo-query.mjs +4 -4
- package/lib/sandbox-images.mjs +31 -4
- package/lib/schema.mjs +28 -0
- package/lib/server-context.mjs +3 -2
- package/lib/share.mjs +53 -2
- package/lib/stats.mjs +11 -0
- package/lib/trust-checks.mjs +269 -0
- package/lib/trust-files.mjs +123 -0
- package/lib/trust-judgment.mjs +157 -0
- package/lib/trust-learning.mjs +153 -0
- package/lib/trust-map.mjs +149 -0
- package/lib/trust-reach.mjs +220 -0
- package/lib/trust.mjs +227 -0
- package/lib/validators.mjs +7 -0
- package/lib/verification-flow.mjs +25 -18
- package/lib/verification-network.mjs +2 -2
- package/lib/verify.mjs +46 -3
- package/lib/worktree-pointer.mjs +246 -0
- package/lib/worktree-write.mjs +128 -0
- package/mcp/server.mjs +28 -11
- package/package.json +1 -1
- package/playbooks/feature.md +5 -5
- package/scripts/trust-measure.mjs +139 -0
package/bin/nomarmy.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { acknowledgeTrust, collectReviewEvidence, trustSuggestions, reviewTrustLearning, trustAcknowledgment } from "../lib/trust-learning.mjs";
|
|
3
|
+
import { writeTrustScoutBrief, writeTrustProposal, loadTrustProposal, trustProposalOrigins } from "../lib/trust-map.mjs";
|
|
4
|
+
import { fillAcceptance, gatherAcceptanceProposals } from "../lib/acceptance-fill.mjs";
|
|
2
5
|
// nomArmy CLI. Every command proposes before it writes anything -- init,
|
|
3
6
|
// setup, model and update all show exactly what would change and write only
|
|
4
7
|
// after explicit confirmation ([y/N]) or an explicit non-interactive flag
|
|
@@ -9,10 +12,12 @@
|
|
|
9
12
|
import fs from "node:fs";
|
|
10
13
|
import os from "node:os";
|
|
11
14
|
import path from "node:path";
|
|
15
|
+
import { execHostGitSync, spawnHostGitSync } from "../lib/worktree-pointer.mjs";
|
|
12
16
|
import { spawn, spawnSync, execFileSync } from "node:child_process";
|
|
13
17
|
import { createInterface } from "node:readline/promises";
|
|
14
18
|
import { stdin as input, stdout as output } from "node:process";
|
|
15
19
|
import { loadConfig, validateConfig, stringifyConfig, findConfigFile, parseYaml, CONFIG_FILENAMES } from "../lib/config.mjs";
|
|
20
|
+
import { loadContract, loadContracts, checkContract, contractDisplayPath } from "../lib/acceptance.mjs";
|
|
16
21
|
import { scanRepository, compareEvidence } from "../lib/scan.mjs";
|
|
17
22
|
import { buildConfigProposal } from "../lib/propose.mjs";
|
|
18
23
|
import { detectHardware } from "../lib/hardware.mjs";
|
|
@@ -24,7 +29,7 @@ import { connectViaWsl, connectClaude, connectCodex, connectCursor, cursorAlread
|
|
|
24
29
|
import { compareVersions, readPackageVersion, readInstallVersions, copyIsStale } from "../lib/install-freshness.mjs";
|
|
25
30
|
import { loadJobRecords, computeStats, formatStats, formatStatsSummary, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
|
|
26
31
|
import { requestJobStop } from "../lib/openclaw-run.mjs";
|
|
27
|
-
import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings, judgeAgentChoices, chooseJudgeAgent, confirmJudgeHostTools } from "../lib/validators.mjs";
|
|
32
|
+
import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings, judgeAgentChoices, chooseJudgeAgent, confirmJudgeHostTools, validatorTrustDisclosure } from "../lib/validators.mjs";
|
|
28
33
|
import { probeModel } from "../lib/model-probe.mjs";
|
|
29
34
|
import { ID_RE, AUTH_ENV_NAME_RE, OPENCLAW_PROVIDER_ID_RE, openclawProviderId, isNativeProviderType } from "../lib/dispatch-schema.mjs";
|
|
30
35
|
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";
|
|
@@ -116,6 +121,11 @@ function usage(code = 0) {
|
|
|
116
121
|
|
|
117
122
|
Usage: nomarmy <command> [options]
|
|
118
123
|
|
|
124
|
+
acceptance fill <run-id|job-id> [--dry-run] [--json]
|
|
125
|
+
Append proposed test proofs to feature contracts.
|
|
126
|
+
acceptance check [file...] [--json] [--strict]
|
|
127
|
+
Check feature contracts in acceptance/*.yml; runs this repository's tests on this machine.
|
|
128
|
+
--strict fails on unproven criteria as well as broken or missing.
|
|
119
129
|
scan Inspect this repository and report its execution environment.
|
|
120
130
|
--check compare the evidence against a committed .nomarmy.yml
|
|
121
131
|
init Propose a .nomarmy.yml from this repository's scan evidence
|
|
@@ -146,6 +156,11 @@ Usage: nomarmy <command> [options]
|
|
|
146
156
|
update Update nomArmy and reconnect your coordinators: installs
|
|
147
157
|
npm's latest alpha, or for a git checkout pulls (fast-forward
|
|
148
158
|
only; refuses on local changes). Then restart open sessions.
|
|
159
|
+
trust map Write a read-only review scout brief for the General.
|
|
160
|
+
--from <scout.yml> --scout-job <job-id> records the cited scout origin.
|
|
161
|
+
trust review Accept, drop or edit scout and evidence-backed proposals.
|
|
162
|
+
--json lists; --accept-all or --decisions '<JSON array>' writes.
|
|
163
|
+
trust ack <job> --accept|--reject [--reason "text"] Sign off a human gate.
|
|
149
164
|
agents <list|add|update|remove>
|
|
150
165
|
Every account a job can run on, in one list:
|
|
151
166
|
~/.config/nomarmy/agents.yml (or NOMARMY_CONFIG_DIR).
|
|
@@ -179,8 +194,9 @@ Usage: nomarmy <command> [options]
|
|
|
179
194
|
--link-openclaw after CLI login; removing a
|
|
180
195
|
capturing email profile non-interactively needs
|
|
181
196
|
--remove-email-profiles. Interactive Codex setup
|
|
182
|
-
|
|
183
|
-
|
|
197
|
+
asks before copying the CLI login (default yes),
|
|
198
|
+
and offers email-profile removal separately.
|
|
199
|
+
For any kind, optionally
|
|
184
200
|
--max-concurrent --context-window
|
|
185
201
|
--thinking [minimal|low|medium|high|xhigh|adaptive|max|ultra] --no-thinking
|
|
186
202
|
update <name>
|
|
@@ -1319,7 +1335,7 @@ function reapProbeSandbox(stateDir) {
|
|
|
1319
1335
|
async function openclawProviderLogin(vendor, rl) {
|
|
1320
1336
|
if (vendor === SUBSCRIPTION_VENDORS.codex) return linkCodex({
|
|
1321
1337
|
run: runQuiet, command: openclawCmd(), isTTY: Boolean(input.isTTY),
|
|
1322
|
-
removeEmailProfiles: flag("remove-email-profiles"),
|
|
1338
|
+
importLogin: flag("link-openclaw"), removeEmailProfiles: flag("remove-email-profiles"),
|
|
1323
1339
|
confirm: (prompt, opts) => confirm(rl, prompt, opts), print: (message) => console.log(c.dim(message)),
|
|
1324
1340
|
});
|
|
1325
1341
|
const provider = vendor.credential.loginProvider ?? vendor.provider;
|
|
@@ -1557,7 +1573,7 @@ async function addApiAgent(rl, agents) {
|
|
|
1557
1573
|
const OWNER_EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
|
|
1558
1574
|
function validOwnerEmail(email) { return typeof email === "string" && OWNER_EMAIL_RE.test(email); }
|
|
1559
1575
|
function gitUserEmail() {
|
|
1560
|
-
const result =
|
|
1576
|
+
const result = spawnHostGitSync(repoDir, ["config", "user.email"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
1561
1577
|
const email = result.status === 0 ? result.stdout.trim() : "";
|
|
1562
1578
|
return validOwnerEmail(email) ? email : "";
|
|
1563
1579
|
}
|
|
@@ -1682,8 +1698,10 @@ async function cmdAgentsAddJson() {
|
|
|
1682
1698
|
if (kind === "subscription" && agent.provider === "openai" && (flag("link-openclaw") || flag("remove-email-profiles"))) {
|
|
1683
1699
|
if (!readLoginStatus("codex").loggedIn) throw new Error("Confirm Codex CLI login first: codex login");
|
|
1684
1700
|
const linked = await linkCodex({ run: runQuiet, command: openclawCmd(),
|
|
1685
|
-
removeEmailProfiles: flag("remove-email-profiles"), print: (message) => console.error(message) });
|
|
1686
|
-
if (!linked) throw new Error("
|
|
1701
|
+
importLogin: flag("link-openclaw"), removeEmailProfiles: flag("remove-email-profiles"), print: (message) => console.error(message) });
|
|
1702
|
+
if (!linked) throw new Error(flag("link-openclaw")
|
|
1703
|
+
? "Codex import failed; nothing was written."
|
|
1704
|
+
: "Codex login was not imported: pass --link-openclaw to authorize copying it into OpenClaw. Nothing was written.");
|
|
1687
1705
|
if (!probeWorker("openai", agent.model ?? SUBSCRIPTION_VENDORS.codex.defaultModel)) {
|
|
1688
1706
|
throw new Error(`Codex test call failed; nothing was written. Fix: ${codexImportRecovery()}`);
|
|
1689
1707
|
}
|
|
@@ -1842,7 +1860,7 @@ async function cmdAgents() {
|
|
|
1842
1860
|
}
|
|
1843
1861
|
|
|
1844
1862
|
function git(args) {
|
|
1845
|
-
try { return
|
|
1863
|
+
try { return execHostGitSync(nomarmyRoot, args, { cwd: nomarmyRoot, encoding: "utf8" }).trim(); }
|
|
1846
1864
|
catch (error) { throw new Error(`git ${args.join(" ")} failed: ${error.stderr ? String(error.stderr).trim() : error.message}`); }
|
|
1847
1865
|
}
|
|
1848
1866
|
|
|
@@ -2028,7 +2046,7 @@ async function cmdConnect() {
|
|
|
2028
2046
|
if (nativeWindows && flag("copy-only")) throw new Error("Windows runs nomArmy inside WSL; run nomarmy connect <target> instead of --copy-only");
|
|
2029
2047
|
let projectDir = null;
|
|
2030
2048
|
if (scope !== "user") {
|
|
2031
|
-
try { projectDir =
|
|
2049
|
+
try { projectDir = execHostGitSync(repoDir, ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
2032
2050
|
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>).`); }
|
|
2033
2051
|
}
|
|
2034
2052
|
if (flag("copy-only")) {
|
|
@@ -2769,6 +2787,7 @@ function collectJobs({ recent = 8, runId = null, projectDir = null } = {}) {
|
|
|
2769
2787
|
lastTool: status.lastTool ? `${status.lastTool.tool}${status.lastTool.target ? ` ${String(status.lastTool.target).slice(0, 40)}` : ""}` : null,
|
|
2770
2788
|
filesChanged: status.filesChangedLive ?? meta?.git?.filesChanged?.length ?? null,
|
|
2771
2789
|
heartbeatAgeSeconds: status.heartbeatAt ? Math.round((Date.now() - Date.parse(status.heartbeatAt)) / 1000) : null,
|
|
2790
|
+
...(meta?.trust ? { trust: meta.trust } : {}),
|
|
2772
2791
|
started, dir,
|
|
2773
2792
|
};
|
|
2774
2793
|
}).filter(Boolean).sort((a, b) => b.started - a.started);
|
|
@@ -2788,6 +2807,10 @@ function renderJobs({ running, recent }) {
|
|
|
2788
2807
|
}
|
|
2789
2808
|
lines.push("", c.bold("Recent"));
|
|
2790
2809
|
for (const j of recent) lines.push(` ${j.jobId.padEnd(40)} ${String(j.phase).padEnd(20)} ${fmtSeconds(j.elapsedSeconds).padStart(7)} ${c.dim(`${j.agent ?? ""}${j.model ? `/${j.model}` : ""}`)}`);
|
|
2810
|
+
for (const j of [...running, ...recent]) if (j.trust?.level === "human") {
|
|
2811
|
+
const ack = trustAcknowledgment(j.trust);
|
|
2812
|
+
lines.push(` ${j.jobId}: human review ${ack ? `acknowledged (${ack.decision}) by ${ack.who} at ${ack.when}${ack.reason ? `: ${ack.reason}` : ""}` : "pending"}`);
|
|
2813
|
+
}
|
|
2791
2814
|
return lines.join("\n");
|
|
2792
2815
|
}
|
|
2793
2816
|
|
|
@@ -2990,7 +3013,7 @@ async function shareStats(stats) {
|
|
|
2990
3013
|
if (flag("share")) console.log(shareMarkdown(stats, { scope }));
|
|
2991
3014
|
if (flag("badge")) {
|
|
2992
3015
|
const given = value("badge");
|
|
2993
|
-
const top =
|
|
3016
|
+
const top = spawnHostGitSync(process.cwd(), ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
2994
3017
|
const root = top.status === 0 ? top.stdout.trim() : process.cwd();
|
|
2995
3018
|
const file = path.resolve(root, given ?? path.join(".github", "nomarmy-badge.svg"));
|
|
2996
3019
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -3007,7 +3030,7 @@ function cmdStats() {
|
|
|
3007
3030
|
let repo = null;
|
|
3008
3031
|
if (value("repo")) repo = resolveRepo(records, value("repo"));
|
|
3009
3032
|
else if (!flag("all-repos")) {
|
|
3010
|
-
try { repo =
|
|
3033
|
+
try { repo = execHostGitSync(repoDir, ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
3011
3034
|
catch { throw new Error(`${repoDir} isn't inside a git repository; run nomarmy stats from one, or pass --repo <name> or --all-repos`); }
|
|
3012
3035
|
}
|
|
3013
3036
|
let agentFor = () => null;
|
|
@@ -3053,10 +3076,18 @@ async function cmdValidators() {
|
|
|
3053
3076
|
try { config = loadValidators(); } catch (error) { if (json) return out({ error: error.message }); console.log(c.red(error.message)); process.exitCode = 1; return; }
|
|
3054
3077
|
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;
|
|
3055
3078
|
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;
|
|
3079
|
+
if (jev) jev.trustJudgment = validatorTrustDisclosure("Jev", "TypeSafe");
|
|
3080
|
+
if (judge) {
|
|
3081
|
+
const agents = loadAgents(globalConfigDir()).agents;
|
|
3082
|
+
const vendor = agentProviderId(agents[judge.agent]) ?? `the configured vendor for agent "${judge.agent}" (agent unavailable)`;
|
|
3083
|
+
judge.trustJudgment = validatorTrustDisclosure("the judge", vendor);
|
|
3084
|
+
}
|
|
3056
3085
|
if (json) return out({ path: validatorsPath(), jev, judge });
|
|
3057
3086
|
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; }
|
|
3058
3087
|
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)")}`);
|
|
3088
|
+
if (jev) console.log(` Also drives the trust judgment. ${jev.trustJudgment}`);
|
|
3059
3089
|
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") : ""}`);
|
|
3090
|
+
if (judge) console.log(` Also drives the trust judgment. ${judge.trustJudgment}`);
|
|
3060
3091
|
return;
|
|
3061
3092
|
}
|
|
3062
3093
|
if (name === "judge") return cmdValidatorsJudge(sub);
|
|
@@ -3068,12 +3099,14 @@ async function cmdValidators() {
|
|
|
3068
3099
|
console.log("Its answers only add review flags; they never pass a check or allow a commit.");
|
|
3069
3100
|
console.log(c.yellow("It sends excerpts of your code (findings, cited lines, diffs, worker reports) to TypeSafe.\n"));
|
|
3070
3101
|
}
|
|
3102
|
+
const trustJudgment = validatorTrustDisclosure("Jev", "TypeSafe");
|
|
3103
|
+
(json ? console.error : console.log)(`Before saving: ${trustJudgment}`);
|
|
3071
3104
|
const key = flag("key-stdin") ? await readStdin() : await readHiddenLine("TypeSafe API key (not shown): ");
|
|
3072
3105
|
const saved = saveJevKey(key);
|
|
3073
3106
|
let ok = false, why = null;
|
|
3074
3107
|
try { ok = await testJev(jevSettings()); } catch (error) { why = error.message; }
|
|
3075
|
-
if (json) return out({ saved: true, keyFile: saved.keyFile, configPath: saved.configPath, test: ok ? "pass" : "fail", reason: why });
|
|
3076
|
-
console.log(c.green(`✓ Saved the key to ${saved.keyFile} (readable only by you) and turned Jev on in ${saved.configPath}
|
|
3108
|
+
if (json) return out({ saved: true, keyFile: saved.keyFile, configPath: saved.configPath, test: ok ? "pass" : "fail", reason: why, trustJudgment });
|
|
3109
|
+
console.log(c.green(`✓ Saved the key to ${saved.keyFile} (readable only by you) and turned Jev on in ${saved.configPath}. ${trustJudgment}`));
|
|
3077
3110
|
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`));
|
|
3078
3111
|
if (!ok) process.exitCode = 1;
|
|
3079
3112
|
return;
|
|
@@ -3129,6 +3162,8 @@ async function cmdValidatorsJudge(sub) {
|
|
|
3129
3162
|
model = /^\d+$/.test(answer) && listed[Number(answer) - 1] ? listed[Number(answer) - 1] : answer || fallback;
|
|
3130
3163
|
if (!model) { rl?.close(); throw new Error("A model id is required."); }
|
|
3131
3164
|
}
|
|
3165
|
+
const trustJudgment = validatorTrustDisclosure("the judge", agentProviderId(agents[agent]));
|
|
3166
|
+
(json ? console.error : console.log)(`Before saving: ${trustJudgment}`);
|
|
3132
3167
|
let hostTools = flag("host-tools");
|
|
3133
3168
|
if (agentRunsToolsOnHost(agents[agent]) && !hostTools) {
|
|
3134
3169
|
if (!process.stdin.isTTY || json) {
|
|
@@ -3145,8 +3180,8 @@ async function cmdValidatorsJudge(sub) {
|
|
|
3145
3180
|
const settings = resolve();
|
|
3146
3181
|
if (settings?.problem) throw new Error(settings.problem);
|
|
3147
3182
|
const test = await probe(settings);
|
|
3148
|
-
if (json) return out({ saved: true, configPath: saved.configPath, test: test.ok ? "pass" : test.refused ? "refused" : "inconclusive", reason: test.reason });
|
|
3149
|
-
console.log(c.green(`✓ The judge is ${agent}/${model}, in ${saved.configPath}
|
|
3183
|
+
if (json) return out({ saved: true, configPath: saved.configPath, test: test.ok ? "pass" : test.refused ? "refused" : "inconclusive", reason: test.reason, trustJudgment });
|
|
3184
|
+
console.log(c.green(`✓ The judge is ${agent}/${model}, in ${saved.configPath}. ${trustJudgment}`));
|
|
3150
3185
|
if (dominantBuilderVendor === null) {
|
|
3151
3186
|
try {
|
|
3152
3187
|
const roles = loadArmy({ projectDir: repoDir }).army.roles;
|
|
@@ -3184,7 +3219,111 @@ function cmdMcp() {
|
|
|
3184
3219
|
child.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 1); });
|
|
3185
3220
|
}
|
|
3186
3221
|
|
|
3187
|
-
|
|
3222
|
+
async function cmdAcceptance() {
|
|
3223
|
+
if (argv[1] === "fill") {
|
|
3224
|
+
const ids = [];
|
|
3225
|
+
for (let i = 2; i < argv.length; i++) {
|
|
3226
|
+
if (argv[i] === "--repo") { i++; continue; }
|
|
3227
|
+
if (["--json", "--dry-run"].includes(argv[i])) continue;
|
|
3228
|
+
if (argv[i].startsWith("--")) throw new Error(`Unknown acceptance option: ${argv[i]}`);
|
|
3229
|
+
ids.push(argv[i]);
|
|
3230
|
+
}
|
|
3231
|
+
if (ids.length !== 1) throw new Error("Usage: nomarmy acceptance fill <run-id|job-id> [--dry-run] [--json]");
|
|
3232
|
+
const proposals = gatherAcceptanceProposals({ id: ids[0], repoDir, jobsRoot: jobsRootDir(), runsRoot: path.join(agentStateRoot(), "runs") });
|
|
3233
|
+
const result = await fillAcceptance({ repoDir, proposals, dryRun: flag("dry-run") });
|
|
3234
|
+
if (json) out(result);
|
|
3235
|
+
else if (!result.criteria.length) console.log("No new acceptance proofs.");
|
|
3236
|
+
else for (const item of result.criteria) {
|
|
3237
|
+
console.log(`${item.criterion}: ${result.dryRun ? "would add" : "added"} ${item.added.length} proof(s) in ${item.file}; status ${item.status}`);
|
|
3238
|
+
for (const ref of item.added) console.log(` ${ref.file}: ${ref.test}`);
|
|
3239
|
+
}
|
|
3240
|
+
if (!json && result.verificationError) console.log(result.verificationError);
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
if (argv[1] !== "check") throw new Error("Usage: nomarmy acceptance check [file...] [--json] [--strict]");
|
|
3244
|
+
const files = [];
|
|
3245
|
+
for (let i = 2; i < argv.length; i++) {
|
|
3246
|
+
if (argv[i] === "--repo") { i++; continue; }
|
|
3247
|
+
if (["--json", "--strict"].includes(argv[i])) continue;
|
|
3248
|
+
if (argv[i].startsWith("--")) throw new Error(`Unknown acceptance option: ${argv[i]}`);
|
|
3249
|
+
files.push(argv[i]);
|
|
3250
|
+
}
|
|
3251
|
+
const contracts = files.length ? files.map((file) => loadContract(path.resolve(repoDir, file))) : loadContracts(repoDir);
|
|
3252
|
+
const results = contracts.map((contract) => ({
|
|
3253
|
+
file: contractDisplayPath(repoDir, contract.file),
|
|
3254
|
+
feature: contract.feature,
|
|
3255
|
+
criteria: checkContract(contract, { repoDir }),
|
|
3256
|
+
}));
|
|
3257
|
+
const totals = { met: 0, broken: 0, missing: 0, unproven: 0, retired: 0 };
|
|
3258
|
+
for (const result of results) for (const criterion of result.criteria) totals[criterion.status]++;
|
|
3259
|
+
if (json) out({ contracts: results, totals });
|
|
3260
|
+
else {
|
|
3261
|
+
results.forEach((result, index) => {
|
|
3262
|
+
console.log(c.bold(`${result.file}: ${result.feature}`));
|
|
3263
|
+
const idWidth = Math.max(0, ...result.criteria.map((criterion) => criterion.id.length));
|
|
3264
|
+
result.criteria.forEach((criterion, criterionIndex) => {
|
|
3265
|
+
const failed = ["broken", "missing"].includes(criterion.status);
|
|
3266
|
+
const label = failed ? c.red(criterion.status.toUpperCase())
|
|
3267
|
+
: criterion.status === "met" ? c.green("met") : c.yellow(criterion.status);
|
|
3268
|
+
const detail = criterion.failures.map((failure) => failure.command ?? `${failure.file}: ${failure.test}`).join("; ")
|
|
3269
|
+
|| (criterion.status === "unproven" ? criterion.note ?? contracts[index].criteria[criterionIndex].note ?? "" : "");
|
|
3270
|
+
const manual = criterion.status === "met" && criterion.manual?.length
|
|
3271
|
+
? ` (manual: ${criterion.manual.map((ref) => `${ref.checked_by}, ${ref.date}`).join("; ")})` : "";
|
|
3272
|
+
const skipped = criterion.notApplicable?.length ? ` (${criterion.notApplicable.length} proofs not run on ${process.platform})` : "";
|
|
3273
|
+
console.log(`${criterion.id.padEnd(idWidth)} ${label}${manual}${skipped}${detail ? ` ${detail}` : ""}`);
|
|
3274
|
+
});
|
|
3275
|
+
});
|
|
3276
|
+
console.log(`Total: ${Object.entries(totals).map(([status, count]) => `${count} ${status}`).join(", ")}`);
|
|
3277
|
+
}
|
|
3278
|
+
if (totals.broken || totals.missing || (flag("strict") && totals.unproven)) process.exitCode = 1;
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
async function cmdTrust() {
|
|
3282
|
+
let result;
|
|
3283
|
+
if (argv[1] === "ack") {
|
|
3284
|
+
if (flag("accept") === flag("reject")) throw new Error("Choose exactly one of --accept or --reject");
|
|
3285
|
+
result = acknowledgeTrust({ jobsRoot: jobsRootDir(), stateDir: agentStateRoot(), operatorDir: repoDir, jobId: argv[2], decision: flag("accept") ? "accept" : "reject", reason: value("reason", "") });
|
|
3286
|
+
} else if (argv[1] === "map") {
|
|
3287
|
+
result = value("from")
|
|
3288
|
+
? writeTrustProposal(repoDir, parseYaml(fs.readFileSync(path.resolve(repoDir, value("from")), "utf8")), { scoutJobId: value("scout-job") ?? null })
|
|
3289
|
+
: writeTrustScoutBrief({ operatorDir: repoDir, roles: loadArmy({ projectDir: repoDir }).army.roles });
|
|
3290
|
+
} else if (argv[1] === "review") {
|
|
3291
|
+
const learning = { stateDir: agentStateRoot(), operatorDir: repoDir };
|
|
3292
|
+
collectReviewEvidence({ ...learning, records: loadJobRecords(jobsRootDir()) });
|
|
3293
|
+
const stored = loadTrustProposal(repoDir);
|
|
3294
|
+
const suggestions = trustSuggestions(learning);
|
|
3295
|
+
const proposed = [...stored, ...suggestions.map(s => s.entry)];
|
|
3296
|
+
const origins = [...trustProposalOrigins(repoDir, stored), ...suggestions.map(s => ({ writer: "trust learning", status: "evidence-backed", evidence: s.evidence }))];
|
|
3297
|
+
if (json && !flag("accept-all") && !value("decisions")) return out({ status: "pending", entries: proposed, origins });
|
|
3298
|
+
let decisions = value("decisions") ? JSON.parse(value("decisions")) : undefined;
|
|
3299
|
+
if (!flag("accept-all") && decisions === undefined) {
|
|
3300
|
+
if (!input.isTTY) throw new Error("Use --accept-all or --decisions '<JSON array>' for scripted review; --json alone only lists proposals.");
|
|
3301
|
+
decisions = [];
|
|
3302
|
+
const rl = createInterface({ input, output });
|
|
3303
|
+
try {
|
|
3304
|
+
for (const [index, entry] of proposed.entries()) {
|
|
3305
|
+
const origin = origins[index];
|
|
3306
|
+
console.log(`Origin: ${origin.scoutJobId ?? "unknown"} (${origin.writer ?? origin.status})`);
|
|
3307
|
+
if (origin.evidence) console.log(`${origin.evidence.summary}; jobs: ${origin.evidence.jobIds.join(", ")}`);
|
|
3308
|
+
console.log(`${entry.symbol} (${entry.file}:${entry.line}), ${entry.category}: ${entry.reason}`);
|
|
3309
|
+
let action;
|
|
3310
|
+
do { action = (await rl.question("Accept, drop or edit? [a/d/e] ")).trim().toLowerCase(); } while (!["a", "d", "e"].includes(action));
|
|
3311
|
+
if (action !== "e") { decisions.push({ action: action === "a" ? "accept" : "drop" }); continue; }
|
|
3312
|
+
const edited = {};
|
|
3313
|
+
for (const key of ["symbol", "file", "category", "reason"]) edited[key] = (await rl.question(`${key} [${entry[key]}]: `)).trim() || entry[key];
|
|
3314
|
+
decisions.push({ action: "edit", entry: edited });
|
|
3315
|
+
}
|
|
3316
|
+
} finally { rl.close(); }
|
|
3317
|
+
}
|
|
3318
|
+
result = reviewTrustLearning({ ...learning, decisions, acceptAll: flag("accept-all"), expected: proposed });
|
|
3319
|
+
} else throw new Error("Usage: nomarmy trust map [--from scout.yml --scout-job job-id] | trust ack <job-id> --accept|--reject [--reason text] | trust review [--accept-all | --decisions '<JSON array>'] [--json]");
|
|
3320
|
+
if (json) out(result);
|
|
3321
|
+
else if (result.status === "acknowledged") console.log(`${result.jobId}: human review acknowledged (${result.ack.decision}) by ${result.ack.who} at ${result.ack.when}${result.ack.reason ? `: ${result.ack.reason}` : ""}`);
|
|
3322
|
+
else if (result.status === "awaiting-scout") console.log(`Scout brief: ${result.brief}. General must dispatch the read-only review scout, then import its cited YAML with nomarmy trust map --from scout.yml --scout-job job-id. Nothing is active yet.`);
|
|
3323
|
+
else console.log(`${result.status}: ${result.map ?? result.proposal} (${result.entries.length} entries)`);
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
const commands = { trust: cmdTrust, acceptance: cmdAcceptance, 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) };
|
|
3188
3327
|
// doctor command
|
|
3189
3328
|
async function cmdDoctor() {
|
|
3190
3329
|
if (windowsFrontEnd()) {
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { loadContracts, contractDisplayPath } from "./acceptance.mjs";
|
|
5
|
+
import { runContractChecks } from "./acceptance-impact.mjs";
|
|
6
|
+
import { isTestPath } from "./diff-checks.mjs";
|
|
7
|
+
import { loadRun } from "./runs.mjs";
|
|
8
|
+
import { samePath } from "./same-path.mjs";
|
|
9
|
+
|
|
10
|
+
export function criteriaProblems(criteria, repoDir, mode = "implement") {
|
|
11
|
+
if (!criteria?.length) return [];
|
|
12
|
+
if (mode !== "implement") return ["criteria is only supported for implement jobs"];
|
|
13
|
+
try {
|
|
14
|
+
const contracts = loadContracts(repoDir);
|
|
15
|
+
const defined = new Set(contracts.flatMap(c => c.criteria.map(c => c.id)));
|
|
16
|
+
const unknown = [...new Set(criteria)].filter(id => !defined.has(id));
|
|
17
|
+
return unknown.length ? [`Unknown acceptance criteria: ${unknown.join(", ")}. Contract files searched: ${contracts.map(c => contractDisplayPath(repoDir, c.file)).join(", ") || "acceptance/*.yml (none)"}`] : [];
|
|
18
|
+
} catch (error) { return [`Could not load acceptance contracts: ${error.message}`]; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Tokenize without executing worker source. Comments and quoted text cannot
|
|
22
|
+
// impersonate calls. Full-context diffs let body-only edits select their test.
|
|
23
|
+
function tokens(source) {
|
|
24
|
+
const re = /\/\/[^\n]*|\/\*[\s\S]*?\*\/|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`|[A-Za-z_$][\w$]*|[^\s]/g;
|
|
25
|
+
return [...source.matchAll(re)].filter(m => !m[0].startsWith("//") && !m[0].startsWith("/*")).map(m => ({ text: m[0], start: m.index, end: m.index + m[0].length }));
|
|
26
|
+
}
|
|
27
|
+
function literalName(raw) {
|
|
28
|
+
if (!/^["'`]/.test(raw)) return null;
|
|
29
|
+
let text = raw.slice(1, -1);
|
|
30
|
+
// Keep the source template for review; checkContract matches only its fixed prefix.
|
|
31
|
+
return text.replace(/\\(u\{[0-9a-f]+\}|u[0-9a-f]{4}|x[0-9a-f]{2}|\r?\n|.)/gi, (_, escape) => {
|
|
32
|
+
if (/^u\{/.test(escape)) return String.fromCodePoint(parseInt(escape.slice(2, -1), 16));
|
|
33
|
+
if (/^[ux]/.test(escape)) return String.fromCharCode(parseInt(escape.slice(1), 16));
|
|
34
|
+
return ({ n: "\n", r: "\r", t: "\t", b: "\b", f: "\f", v: "\v", "0": "\0", "\n": "", "\r\n": "" })[escape] ?? escape;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function proposalsFromDiff(diff, criteria) {
|
|
38
|
+
const files = [];
|
|
39
|
+
let file = null;
|
|
40
|
+
for (const line of String(diff).split("\n")) {
|
|
41
|
+
if (line.startsWith("diff --git ")) { file = null; continue; }
|
|
42
|
+
if (!file && line.startsWith("+++ ")) {
|
|
43
|
+
let name = line.slice(4).split("\t", 1)[0];
|
|
44
|
+
if (name.startsWith('"')) { try { name = JSON.parse(name); } catch { continue; } }
|
|
45
|
+
name = name.replace(/^b\//, "");
|
|
46
|
+
file = { name, source: "", changes: [] };
|
|
47
|
+
files.push(file);
|
|
48
|
+
} else if (file && /^[ +\-]/.test(line)) {
|
|
49
|
+
if (line[0] !== " ") file.changes.push({ offset: file.source.length, removed: line[0] === "-" });
|
|
50
|
+
if (line[0] !== "-") file.source += line.slice(1) + "\n";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const proposals = [];
|
|
54
|
+
for (const f of files.filter(f => isTestPath(f.name) && /\.[cm]?[jt]sx?$/.test(f.name))) {
|
|
55
|
+
const ts = tokens(f.source);
|
|
56
|
+
for (let i = 0; i < ts.length - 3; i++) {
|
|
57
|
+
if (!["test", "it"].includes(ts[i].text) || ts[i - 1]?.text === "." || ts[i + 1].text !== "(") continue;
|
|
58
|
+
const name = literalName(ts[i + 2].text);
|
|
59
|
+
if (!name || ts[i + 3].text !== ",") continue;
|
|
60
|
+
let depth = 1, end = i + 2;
|
|
61
|
+
for (; end < ts.length; end++) {
|
|
62
|
+
if (ts[end].text === "(") depth++;
|
|
63
|
+
if (ts[end].text === ")" && --depth === 0) break;
|
|
64
|
+
}
|
|
65
|
+
if (!ts[end] || !f.changes.some(({ offset, removed }) => (removed ? offset > ts[i].start : offset >= f.source.lastIndexOf("\n", ts[i].start - 1) + 1) && offset < ts[end].end)) continue;
|
|
66
|
+
for (const criterion of criteria ?? []) {
|
|
67
|
+
const proposal = { criterion, file: f.name, test: name };
|
|
68
|
+
if (!proposals.some(p => JSON.stringify(p) === JSON.stringify(proposal))) proposals.push(proposal);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return proposals;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function jobAcceptanceProposals({ criteria, commit, regressionCheck, diff }) {
|
|
76
|
+
if (!criteria?.length || !commit?.created || regressionCheck?.status !== "pass") return undefined;
|
|
77
|
+
return proposalsFromDiff(await diff(), criteria);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function gatherAcceptanceProposals({ id, repoDir, jobsRoot, runsRoot }) {
|
|
81
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id ?? "")) throw new Error("Expected a run ID or job ID");
|
|
82
|
+
const run = id.startsWith("run-") ? loadRun(runsRoot, id) : null;
|
|
83
|
+
if (run && !samePath(run.repo, repoDir)) throw new Error(`Run ${id} belongs to another repository`);
|
|
84
|
+
const ids = run ? run.jobs.map(j => j.jobId) : [id];
|
|
85
|
+
return ids.flatMap(jobId => {
|
|
86
|
+
if (!/^[A-Za-z0-9_-]+$/.test(jobId)) throw new Error("Invalid job ID in run");
|
|
87
|
+
let job;
|
|
88
|
+
try { job = JSON.parse(fs.readFileSync(path.join(jobsRoot, jobId, "metadata.json"), "utf8")); }
|
|
89
|
+
catch (error) {
|
|
90
|
+
// Failed jobs may have only failure.json, with no proposal record.
|
|
91
|
+
if (run && error.code === "ENOENT") return [];
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
if (!samePath(job.projectDir, repoDir)) throw new Error(`Job ${jobId} belongs to another repository`);
|
|
95
|
+
return job.acceptanceProposals ?? [];
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Edit only insertion points and an eligible status scalar, never stringify a contract. */
|
|
100
|
+
export async function fillAcceptance({ repoDir, proposals, dryRun = false, verify }) {
|
|
101
|
+
const contracts = loadContracts(repoDir);
|
|
102
|
+
const owners = new Map();
|
|
103
|
+
for (const contract of contracts) for (const criterion of contract.criteria) {
|
|
104
|
+
if (owners.has(criterion.id)) throw new Error(`Ambiguous acceptance criterion ${criterion.id}`);
|
|
105
|
+
owners.set(criterion.id, contract);
|
|
106
|
+
}
|
|
107
|
+
for (const p of proposals) if (!owners.has(p.criterion)) throw new Error(`Unknown acceptance criterion ${p.criterion}`);
|
|
108
|
+
// Snapshot proposed proofs read-only beside the working tree. Even a dry-run
|
|
109
|
+
// verifies inside the sandbox, never by spawning repository tests on the host.
|
|
110
|
+
const pending = contracts.map(contract => ({
|
|
111
|
+
...contract,
|
|
112
|
+
criteria: contract.criteria.filter(c => c.status === "unproven" && proposals.some(p => p.criterion === c.id)).map(c => ({
|
|
113
|
+
...c,
|
|
114
|
+
proven_by: [...c.proven_by, ...proposals.filter(p => p.criterion === c.id)
|
|
115
|
+
.map(({ file, test }) => ({ file, test }))
|
|
116
|
+
.filter((ref, i, refs) => !c.proven_by.some(r => r.file === ref.file && r.test === ref.test)
|
|
117
|
+
&& refs.findIndex(r => r.file === ref.file && r.test === ref.test) === i)],
|
|
118
|
+
})),
|
|
119
|
+
})).filter(c => c.criteria.length);
|
|
120
|
+
const met = new Set();
|
|
121
|
+
let verificationError;
|
|
122
|
+
if (pending.length) {
|
|
123
|
+
try {
|
|
124
|
+
const checked = await runContractChecks({ contracts: pending, worktree: repoDir, projectDir: repoDir, verify });
|
|
125
|
+
for (const contract of checked) for (const criterion of contract.results) {
|
|
126
|
+
if (criterion.status === "met") met.add(criterion.id);
|
|
127
|
+
}
|
|
128
|
+
} catch (error) { verificationError = `couldn't verify: ${error.message}`; }
|
|
129
|
+
}
|
|
130
|
+
const results = [], writes = [];
|
|
131
|
+
for (const contract of contracts) {
|
|
132
|
+
const source = fs.readFileSync(contract.file, "utf8");
|
|
133
|
+
const doc = YAML.parseDocument(source);
|
|
134
|
+
const edits = [];
|
|
135
|
+
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
|
136
|
+
for (const [index, criterion] of contract.criteria.entries()) {
|
|
137
|
+
const proposed = proposals.filter(p => p.criterion === criterion.id);
|
|
138
|
+
if (!proposed.length) continue;
|
|
139
|
+
const added = [];
|
|
140
|
+
for (const p of proposed) {
|
|
141
|
+
const ref = { file: p.file, test: p.test };
|
|
142
|
+
if (!criterion.proven_by.concat(added).some(r => r.file === ref.file && r.test === ref.test)) added.push(ref);
|
|
143
|
+
}
|
|
144
|
+
if (!added.length && criterion.status !== "unproven") continue;
|
|
145
|
+
const node = doc.get("criteria").items[index];
|
|
146
|
+
const seq = node.get("proven_by");
|
|
147
|
+
if (!YAML.isSeq(seq)) throw new Error(`${criterion.id}: proven_by must be a literal YAML sequence`);
|
|
148
|
+
const refs = added.map(r => `{ file: ${JSON.stringify(r.file)}, test: ${JSON.stringify(r.test)} }`);
|
|
149
|
+
if (added.length && seq.flow) {
|
|
150
|
+
const pos = seq.range[1] - 1;
|
|
151
|
+
edits.push({ start: pos, end: pos, text: `${seq.items.length && !source.slice(seq.items.at(-1).range[1], pos).trimStart().startsWith(",") ? "," : ""} ${refs.join(", ")}` });
|
|
152
|
+
} else if (added.length) {
|
|
153
|
+
const pos = seq.range[2];
|
|
154
|
+
const indent = seq.range[0] - source.lastIndexOf("\n", seq.range[0] - 1) - 1;
|
|
155
|
+
edits.push({ start: pos, end: pos, text: `${pos && source[pos - 1] !== "\n" ? newline : ""}${refs.map(r => " ".repeat(indent) + "- " + r + newline).join("")}` });
|
|
156
|
+
}
|
|
157
|
+
const status = criterion.status === "unproven" && met.has(criterion.id) ? "met" : criterion.status;
|
|
158
|
+
if (status !== criterion.status) {
|
|
159
|
+
const scalar = node.get("status", true);
|
|
160
|
+
edits.push({ start: scalar.range[0], end: scalar.range[1], text: status });
|
|
161
|
+
}
|
|
162
|
+
if (!added.length && status === criterion.status) continue;
|
|
163
|
+
results.push({ criterion: criterion.id, file: contractDisplayPath(repoDir, contract.file), added, status });
|
|
164
|
+
}
|
|
165
|
+
if (edits.length) {
|
|
166
|
+
let text = source;
|
|
167
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
|
|
168
|
+
// Validate our surgical insertion before touching any file.
|
|
169
|
+
const actual = YAML.parse(text);
|
|
170
|
+
for (const result of results.filter(r => r.file === contractDisplayPath(repoDir, contract.file))) {
|
|
171
|
+
const before = contract.criteria.find(c => c.id === result.criterion);
|
|
172
|
+
const after = actual.criteria.find(c => c.id === result.criterion);
|
|
173
|
+
if (JSON.stringify(after.proven_by) !== JSON.stringify([...before.proven_by, ...result.added])) throw new Error(`${result.criterion}: could not safely append proofs`);
|
|
174
|
+
}
|
|
175
|
+
writes.push({ file: contract.file, text });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!dryRun) for (const write of writes) fs.writeFileSync(write.file, write.text);
|
|
179
|
+
return { dryRun, criteria: results, ...(verificationError ? { verificationError } : {}) };
|
|
180
|
+
}
|