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.
Files changed (47) hide show
  1. package/bin/nomarmy.mjs +155 -16
  2. package/lib/acceptance-fill.mjs +180 -0
  3. package/lib/acceptance-impact.mjs +215 -0
  4. package/lib/acceptance-result.mjs +21 -0
  5. package/lib/acceptance.mjs +166 -0
  6. package/lib/admission.mjs +33 -3
  7. package/lib/army.mjs +2 -2
  8. package/lib/codex-link.mjs +6 -1
  9. package/lib/connect.mjs +6 -2
  10. package/lib/continue-from.mjs +29 -12
  11. package/lib/coordinator-instructions.mjs +4 -0
  12. package/lib/diff-checks.mjs +9 -3
  13. package/lib/doctor.mjs +3 -1
  14. package/lib/execute.mjs +187 -32
  15. package/lib/git-record.mjs +9 -2
  16. package/lib/health.mjs +25 -3
  17. package/lib/install-freshness.mjs +2 -2
  18. package/lib/jev-checks.mjs +13 -0
  19. package/lib/job-format.mjs +3 -1
  20. package/lib/judge.mjs +26 -5
  21. package/lib/line-diff.mjs +46 -0
  22. package/lib/mutation.mjs +27 -13
  23. package/lib/openclaw-run.mjs +21 -7
  24. package/lib/process.mjs +30 -4
  25. package/lib/repo-query.mjs +4 -4
  26. package/lib/sandbox-images.mjs +31 -4
  27. package/lib/schema.mjs +28 -0
  28. package/lib/server-context.mjs +3 -2
  29. package/lib/share.mjs +53 -2
  30. package/lib/stats.mjs +11 -0
  31. package/lib/trust-checks.mjs +269 -0
  32. package/lib/trust-files.mjs +123 -0
  33. package/lib/trust-judgment.mjs +157 -0
  34. package/lib/trust-learning.mjs +153 -0
  35. package/lib/trust-map.mjs +149 -0
  36. package/lib/trust-reach.mjs +220 -0
  37. package/lib/trust.mjs +227 -0
  38. package/lib/validators.mjs +7 -0
  39. package/lib/verification-flow.mjs +25 -18
  40. package/lib/verification-network.mjs +2 -2
  41. package/lib/verify.mjs +46 -3
  42. package/lib/worktree-pointer.mjs +246 -0
  43. package/lib/worktree-write.mjs +128 -0
  44. package/mcp/server.mjs +28 -11
  45. package/package.json +1 -1
  46. package/playbooks/feature.md +5 -5
  47. 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
- offers removal (default yes), then imports the
183
- CLI login. For any kind, optionally
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 = spawnSync("git", ["config", "user.email"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
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("Codex import failed; nothing was written.");
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 execFileSync("git", args, { cwd: nomarmyRoot, encoding: "utf8" }).trim(); }
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 = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
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 = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
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 = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
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
- const commands = { stats: cmdStats, validators: cmdValidators, mcp: cmdMcp, scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, install: cmdInstall, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, sandbox: cmdSandbox, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
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
+ }