nomarmy 0.1.0-alpha.0

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 (74) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +25 -0
  3. package/README.md +484 -0
  4. package/bin/nomarmy.mjs +2248 -0
  5. package/config/agents.yml.example +63 -0
  6. package/config/common.env +31 -0
  7. package/config/profiles/bedrock-cheap.env +26 -0
  8. package/config/profiles/bedrock.env +28 -0
  9. package/config/profiles/cpu-linux.env +8 -0
  10. package/config/profiles/dgx-spark.env +12 -0
  11. package/config/profiles/macbook-pro.env +9 -0
  12. package/config/profiles/nvidia-linux.env +9 -0
  13. package/docker/Dockerfile +15 -0
  14. package/docker/Dockerfile.go +29 -0
  15. package/docker/Dockerfile.rust +19 -0
  16. package/e2e.sh +153 -0
  17. package/install.sh +125 -0
  18. package/lib/agents.mjs +285 -0
  19. package/lib/army.mjs +400 -0
  20. package/lib/budget.mjs +368 -0
  21. package/lib/claude-transcript.mjs +150 -0
  22. package/lib/config.mjs +193 -0
  23. package/lib/connect.mjs +409 -0
  24. package/lib/coordinator-instructions.mjs +23 -0
  25. package/lib/decompose.mjs +389 -0
  26. package/lib/dispatch-config.mjs +164 -0
  27. package/lib/dispatch-schema.mjs +280 -0
  28. package/lib/doctor.mjs +443 -0
  29. package/lib/evidence.mjs +679 -0
  30. package/lib/gguf.mjs +589 -0
  31. package/lib/hardware.mjs +476 -0
  32. package/lib/health.mjs +278 -0
  33. package/lib/model-catalog.mjs +71 -0
  34. package/lib/notifier-app.mjs +95 -0
  35. package/lib/notify.mjs +66 -0
  36. package/lib/openclaw-config.mjs +65 -0
  37. package/lib/openclaw-errors.mjs +40 -0
  38. package/lib/propose.mjs +110 -0
  39. package/lib/prune.mjs +77 -0
  40. package/lib/repo-query.mjs +267 -0
  41. package/lib/runs.mjs +150 -0
  42. package/lib/sabotage.mjs +128 -0
  43. package/lib/sandbox-images.mjs +434 -0
  44. package/lib/scan.mjs +1538 -0
  45. package/lib/schema.mjs +288 -0
  46. package/lib/scout.mjs +544 -0
  47. package/lib/sizing.mjs +1322 -0
  48. package/lib/slots.mjs +112 -0
  49. package/lib/statusline.mjs +126 -0
  50. package/lib/subscription-config.mjs +68 -0
  51. package/lib/subscription-setup.mjs +217 -0
  52. package/lib/transcript.mjs +195 -0
  53. package/lib/verify.mjs +700 -0
  54. package/mcp/server.mjs +4206 -0
  55. package/notifier/icon.swift +34 -0
  56. package/notifier/main.swift +52 -0
  57. package/notifier/nomarmy-icon.png +0 -0
  58. package/package.json +67 -0
  59. package/playbooks/feature.md +43 -0
  60. package/policies/coder.md +49 -0
  61. package/policies/orchestrator.md +35 -0
  62. package/policies/reviewer.md +35 -0
  63. package/policies/scout.md +65 -0
  64. package/scripts/configure-openclaw.sh +96 -0
  65. package/scripts/configure-orchestrator.sh +84 -0
  66. package/scripts/install-llama-cpp.sh +16 -0
  67. package/scripts/lib.sh +198 -0
  68. package/scripts/select-model.mjs +96 -0
  69. package/scripts/select-model.sh +4 -0
  70. package/scripts/setup-sandbox.sh +38 -0
  71. package/scripts/start-inference.sh +46 -0
  72. package/scripts/stop-inference.sh +5 -0
  73. package/scripts/uninstall.sh +6 -0
  74. package/scripts/verify-install.sh +68 -0
package/lib/army.mjs ADDED
@@ -0,0 +1,400 @@
1
+ // nomArmy's army: who the General (the coordinator session) calls for what.
2
+ //
3
+ // The General is the Claude Code / Codex session itself. Its charter is
4
+ // GENERAL below, fixed by how nomArmy works; no config layer can rewrite
5
+ // it. What IS configured is which agent the General is (`general: opus`),
6
+ // defined after agents exist, so nomArmy can warn when a role shares the
7
+ // General's model (a review that isn't independent) or its subscription
8
+ // login (the same usage limit). nomArmy never launches the General: the
9
+ // General is what calls nomArmy. Every other role
10
+ // is open to interpretation: a name, a description of when the General
11
+ // calls it, the phase it belongs to, and the agent (lib/agents.mjs) it
12
+ // runs on.
13
+ //
14
+ // Layers merge the way Claude Code's settings do, lowest to highest, each
15
+ // under an `army:` key:
16
+ // global ~/.config/nomarmy/config.yml
17
+ // project <repo>/.nomarmy.yml (committed, beside verification)
18
+ // local <repo>/.nomarmy.local.yml (gitignored)
19
+ //
20
+ // Security boundary: a project file is repository content, and a cloned
21
+ // repo is not trusted. So army files can only SELECT among agents defined
22
+ // globally in agents.yml; the schema has no field for a credential, an
23
+ // endpoint, an owner or a provider, and never will. A hostile
24
+ // .nomarmy.yml can at worst route a job to one of your own agents.
25
+
26
+ import { spawnSync } from "node:child_process";
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ import YAML from "yaml";
31
+ import { z } from "zod";
32
+ import { agentRunsToolsOnHost } from "./dispatch-schema.mjs";
33
+
34
+ export const GLOBAL_CONFIG_FILENAME = "config.yml";
35
+ export const PROJECT_CONFIG_FILENAMES = Object.freeze([".nomarmy.yml", ".nomarmy.yaml"]);
36
+ export const LOCAL_CONFIG_FILENAME = ".nomarmy.local.yml";
37
+ export const ARMY_PHASES = Object.freeze(["build", "review", "acceptance"]);
38
+ export const ARMY_LAYERS = Object.freeze(["global", "project", "local"]);
39
+ export const ROLE_NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
40
+ const NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
41
+
42
+ /**
43
+ * The General's charter. Static on purpose: the General is the session
44
+ * calling nomArmy's tools, and these responsibilities are what the rest of
45
+ * the system (the verified execution record, coordinator-owned commits,
46
+ * never auto-merging) is built around. Not a role, not in any config file,
47
+ * never assigned an agent.
48
+ */
49
+ export const GENERAL = Object.freeze({
50
+ who: "The coordinator session calling nomArmy's tools (Claude Code, Codex or Cursor). It runs outside every sandbox and is never a worker.",
51
+ responsibilities: Object.freeze([
52
+ "Plans and decomposes the work, and owns any uncertain diagnosis.",
53
+ "Makes the architecture and security decisions.",
54
+ "Briefs each role with the outcome and acceptance criteria, and dispatches it with army_role.",
55
+ "Reviews every result against nomArmy's verified execution record; a worker's report is a claim, not evidence.",
56
+ "Owns Git and integration: reviews diffs, resolves conflicts, merges. nomArmy never auto-merges.",
57
+ "Gives final acceptance, and decides which roles the work needs.",
58
+ ]),
59
+ });
60
+
61
+ /** Where global nomArmy config lives: NOMARMY_CONFIG_DIR, else $XDG_CONFIG_HOME/nomarmy, else ~/.config/nomarmy. */
62
+ export function globalConfigDir(env = process.env) {
63
+ if (env.NOMARMY_CONFIG_DIR) return path.resolve(env.NOMARMY_CONFIG_DIR);
64
+ return path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "nomarmy");
65
+ }
66
+
67
+ export function armyLayerPath(layer, { projectDir, env = process.env } = {}) {
68
+ if (layer === "global") return path.join(globalConfigDir(env), GLOBAL_CONFIG_FILENAME);
69
+ if (layer === "project") {
70
+ const existing = PROJECT_CONFIG_FILENAMES.map((f) => path.join(projectDir, f)).find(isFile);
71
+ return existing ?? path.join(projectDir, PROJECT_CONFIG_FILENAMES[0]);
72
+ }
73
+ if (layer === "local") return path.join(projectDir, LOCAL_CONFIG_FILENAME);
74
+ throw new Error(`not a writable army layer: "${layer}" (use global, project or local)`);
75
+ }
76
+
77
+ function isFile(candidate) {
78
+ try { return fs.statSync(candidate).isFile(); } catch { return false; }
79
+ }
80
+
81
+ /**
82
+ * Why a global config file (agents.yml) isn't safe to
83
+ * trust, or null. On a machine several people share (a team DGX Spark),
84
+ * each person has their own OS account, and these files are what bind a
85
+ * subscription to its owner and an API key to its endpoint: if another
86
+ * account can rewrite yours, it can point your key at its own base_url or
87
+ * run its jobs as your subscription. So the file must be owned by the
88
+ * account reading it and writable by nobody else. POSIX only; Windows ACLs
89
+ * don't map onto mode bits.
90
+ */
91
+ export function privateConfigProblem(filePath, { platform = process.platform, uid = process.getuid?.() } = {}) {
92
+ if (platform === "win32" || uid === undefined) return null;
93
+ let st;
94
+ try { st = fs.statSync(filePath); } catch { return null; }
95
+ if (st.uid !== uid) return `${filePath} is owned by another account (uid ${st.uid}); nomArmy only trusts global config its own OS user owns -- each person on a shared machine keeps their own`;
96
+ if (st.mode & 0o022) return `${filePath} is writable by other accounts (mode ${(st.mode & 0o777).toString(8)}); run \`chmod go-w ${filePath}\` -- on a shared machine anyone who can edit it can redirect your credentials`;
97
+ return null;
98
+ }
99
+
100
+ /** True when git tracks this file (committed or force-added past .gitignore). No git or no repo -> false. */
101
+ export function isTrackedByGit(filePath) {
102
+ const result = spawnSync("git", ["ls-files", "--error-unmatch", "--", path.basename(filePath)], { cwd: path.dirname(filePath), stdio: "ignore" });
103
+ return result.status === 0;
104
+ }
105
+
106
+ const roleSchema = z.object({
107
+ // Capped because it is prepended to the worker's brief on every dispatch.
108
+ description: z.string().min(1).max(800, "must be at most 800 characters (it is prepended to every brief for this role)").optional(),
109
+ phase: z.enum(ARMY_PHASES).optional(),
110
+ mode: z.enum(["implement", "scout"]).optional(),
111
+ agent: z.string().regex(NAME_RE, "must name an agent from agents.yml").optional(),
112
+ // A model id on that agent, or "auto" to let the General pick per job.
113
+ // Omitted, the agent's own default model applies.
114
+ model: z.string().regex(/^\S{1,200}$/, "must be a model id, or auto").optional(),
115
+ disabled: z.boolean().optional(),
116
+ }).strict();
117
+
118
+ const positiveNumber = () => z.number({ invalid_type_error: "must be a number" }).positive("must be positive");
119
+ // Limits on one /feature run (lib/runs.mjs). Personal, like the General:
120
+ // global or local only, never a committed project file.
121
+ const runLimitsSchema = z.object({
122
+ max_jobs: positiveNumber().int("must be a whole number").optional(),
123
+ max_api_usd: positiveNumber().optional(),
124
+ max_hours: positiveNumber().optional(),
125
+ warn_at: z.number().gt(0).lt(1, "is a share of the limit, e.g. 0.8").optional(),
126
+ }).strict();
127
+
128
+ export const armySchema = z.object({
129
+ general: z.string().regex(NAME_RE, "must name the agent from agents.yml that your coordinator session runs on").optional(),
130
+ run_limits: runLimitsSchema.optional(),
131
+ workflow: z.string().min(1).max(4000).optional(),
132
+ roles: z.record(z.string().regex(ROLE_NAME_RE, "role names are lowercase letters, digits and hyphens, starting with a letter"), roleSchema).optional(),
133
+ }).strict();
134
+
135
+ export class ArmyConfigError extends Error {
136
+ constructor(message, { path: filePath = null, errors = [] } = {}) {
137
+ super(message);
138
+ this.name = "ArmyConfigError";
139
+ this.path = filePath;
140
+ this.errors = errors;
141
+ }
142
+ }
143
+
144
+ function formatIssues(error) {
145
+ return error.issues.map((issue) => {
146
+ const where = issue.path.length ? issue.path.join(".") : "config";
147
+ if (issue.code === "unrecognized_keys") {
148
+ const keys = issue.keys.map((k) => `"${k}"`).join(", ");
149
+ return `${where}: unexpected field(s) ${keys} (army files can only select agents; credentials, endpoints and owners belong in your global agents.yml)`;
150
+ }
151
+ return `${where}: ${issue.message}`;
152
+ });
153
+ }
154
+
155
+ // The global and local files hold only `army:` for now (the global one is
156
+ // where machine-level settings such as the local model will move later);
157
+ // strict, so a typo'd key is an error rather than silently ignored. The
158
+ // project file is .nomarmy.yml, whose other sections lib/schema.mjs owns.
159
+ const armyOnlyFileSchema = z.object({ army: armySchema.optional() }).strict();
160
+
161
+ /** Parse one layer's file and validate its `army:` section. Missing file or no section -> null. */
162
+ export function readArmyFile(filePath, { armyOnly = false } = {}) {
163
+ if (!isFile(filePath)) return null;
164
+ let parsed;
165
+ try {
166
+ parsed = YAML.parse(fs.readFileSync(filePath, "utf8"));
167
+ } catch (error) {
168
+ throw new ArmyConfigError(`${filePath} is not valid YAML: ${error.message}`, { path: filePath, errors: [`config: is not valid YAML: ${error.message}`] });
169
+ }
170
+ const doc = parsed ?? {};
171
+ const result = armyOnly ? armyOnlyFileSchema.safeParse(doc) : armySchema.optional().safeParse(doc.army);
172
+ if (result.success) return (armyOnly ? result.data.army : result.data) ?? null;
173
+ const errors = formatIssues(result.error).map((line) => (armyOnly ? line : `army.${line}`));
174
+ throw new ArmyConfigError(`${filePath} has an invalid army section:\n${errors.map((l) => ` - ${l}`).join("\n")}`, { path: filePath, errors });
175
+ }
176
+
177
+ /**
178
+ * Merge layers (lowest first). Fields merge one at a time, so a project
179
+ * file can reassign a role without restating its description.
180
+ * @param {{ layer: string, path: string|null, army: object|null }[]} layers
181
+ */
182
+ export function mergeArmy(layers) {
183
+ const merged = { general: null, workflow: null, runLimits: {}, roles: {} };
184
+ const sources = { general: null, workflow: null, runLimits: {}, roles: {} };
185
+ for (const { layer, army } of layers) {
186
+ if (!army) continue;
187
+ if (army.general) { merged.general = army.general; sources.general = layer; }
188
+ for (const [k, v] of Object.entries(army.run_limits ?? {})) { merged.runLimits[k] = v; sources.runLimits[k] = layer; }
189
+ if (army.workflow) { merged.workflow = army.workflow; sources.workflow = layer; }
190
+ for (const [name, role] of Object.entries(army.roles ?? {})) {
191
+ const into = merged.roles[name] ?? (merged.roles[name] = {});
192
+ const from = sources.roles[name] ?? (sources.roles[name] = {});
193
+ for (const [field, value] of Object.entries(role)) {
194
+ into[field] = value;
195
+ from[field] = layer;
196
+ }
197
+ }
198
+ }
199
+ for (const [name, role] of Object.entries(merged.roles)) {
200
+ if (role.disabled) { delete merged.roles[name]; delete sources.roles[name]; }
201
+ }
202
+ return { army: merged, sources };
203
+ }
204
+
205
+ /** Read every layer and merge. Throws ArmyConfigError naming the bad file. */
206
+ export function loadArmy({ projectDir, env = process.env }) {
207
+ const layers = [];
208
+ for (const layer of ARMY_LAYERS) {
209
+ const filePath = armyLayerPath(layer, { projectDir, env });
210
+ // The local layer is one person's overrides. A tracked copy is shared
211
+ // with everyone who pulls, so it's refused rather than quietly used.
212
+ if (layer === "local" && isFile(filePath) && isTrackedByGit(filePath)) {
213
+ throw new ArmyConfigError(`${filePath} is tracked by git, so it isn't local: everyone who pulls gets it. Run \`git rm --cached ${LOCAL_CONFIG_FILENAME}\` and keep it in .gitignore, or move shared choices to .nomarmy.yml.`, { path: filePath });
214
+ }
215
+ const army = readArmyFile(filePath, { armyOnly: layer !== "project" });
216
+ // Who the General is and what a run may spend are one person's choices;
217
+ // a committed .nomarmy.yml is anyone's who can push to the repo.
218
+ for (const personal of ["general", "run_limits"]) {
219
+ if (layer === "project" && army?.[personal] !== undefined) {
220
+ throw new ArmyConfigError(`${filePath} sets army.${personal}, which is personal: set it in ~/.config/nomarmy/config.yml or .nomarmy.local.yml, never in the committed project file.`, { path: filePath });
221
+ }
222
+ }
223
+ layers.push({ layer, path: filePath, army });
224
+ }
225
+ // `exists` (the file is there) and `hasArmy` (it has an army section)
226
+ // are separate facts: a single `found` meaning the latter made a
227
+ // .nomarmy.yml holding only verification profiles read as missing.
228
+ return { ...mergeArmy(layers), layers: layers.map(({ layer, path: p, army }) => ({ layer, path: p, exists: isFile(p), hasArmy: Boolean(army) })) };
229
+ }
230
+
231
+ /** ("codex", "gpt-6-astra"|"auto"|undefined) -> { agent, model? }; "none" -> {} (unassigned). */
232
+ export function parseTargetSpec(spec, model) {
233
+ const text = String(spec ?? "").trim();
234
+ if (text === "none") {
235
+ if (model) throw new Error("none unassigns the role, so it takes no model");
236
+ return {};
237
+ }
238
+ if (!NAME_RE.test(text)) throw new Error(`"${text}" is not an agent name (see \`nomarmy agents list\`), or "none" to unassign`);
239
+ if (model !== undefined && !/^\S{1,200}$/.test(String(model))) throw new Error(`"${model}" is not a model id (or auto)`);
240
+ return model ? { agent: text, model: String(model) } : { agent: text };
241
+ }
242
+
243
+ /**
244
+ * Problems with each role's agent against the agents defined globally: a
245
+ * name that doesn't exist, or none at all. Returned per role rather than
246
+ * thrown, so `army show` can list them all.
247
+ */
248
+ export function armyTargetProblems(army, agents = {}) {
249
+ const problems = {};
250
+ for (const [name, role] of Object.entries(army.roles)) {
251
+ const agent = role.agent && Object.prototype.hasOwnProperty.call(agents, role.agent) ? agents[role.agent] : null;
252
+ if (!role.agent) problems[name] = "no agent assigned";
253
+ else if (!agent) problems[name] = `agent "${role.agent}" is not defined in your agents.yml`;
254
+ else if (agent.kind === "local" && role.model) problems[name] = `agent "${role.agent}" is the local model, so the role's model "${role.model}" can't apply (\`nomarmy model\` sets it)`;
255
+ else if (agent.kind !== "local" && !role.model && !agent.model) problems[name] = `agent "${role.agent}" has no default model, so this role needs one: \`nomarmy army assign ${name} ${role.agent} <model|auto>\``;
256
+ }
257
+ return problems;
258
+ }
259
+
260
+ /**
261
+ * Turn a job's `army_role` into `agent: <that role's agent>`, with the
262
+ * role's description at the top of the task so the worker knows which hat
263
+ * it's wearing. Refuses (throws) rather than falling back: an unknown
264
+ * role, an unassigned one, or a job that also names its own agent.
265
+ */
266
+ export function expandArmyRole(args, army) {
267
+ if (!args.army_role) return args;
268
+ if (args.agent) throw new Error(`army_role picks the agent itself; drop agent "${args.agent}" from this job, or drop army_role`);
269
+ const role = Object.prototype.hasOwnProperty.call(army.roles, args.army_role) ? army.roles[args.army_role] : undefined;
270
+ if (!role) {
271
+ const known = Object.keys(army.roles);
272
+ throw new Error(known.length ? `unknown army_role "${args.army_role}" -- this repo's roles are: ${known.join(", ")}` : `unknown army_role "${args.army_role}" -- no army is configured (run \`nomarmy army init\`)`);
273
+ }
274
+ if (!role.agent) throw new Error(`army_role "${args.army_role}" has no agent assigned -- run \`nomarmy army assign ${args.army_role} <agent>\``);
275
+ const { army_role: roleName, ...rest } = args;
276
+ const header = `[nomArmy role: ${roleName}${role.phase ? `, ${role.phase} phase` : ""}]${role.description ? `\n${role.description}` : ""}`;
277
+ return { ...rest, task: `${header}\n\n${args.task}`, agent: role.agent, armyRole: roleName, roleModel: role.model ?? null };
278
+ }
279
+
280
+ /**
281
+ * Change a layer file's `army:` section in place: `mutate(army)` gets the
282
+ * current section (or {}) and returns the new one. Uses YAML's document
283
+ * API so every other section of .nomarmy.yml, and every comment, survives.
284
+ */
285
+ export function updateArmyInFile(filePath, mutate) {
286
+ const text = isFile(filePath) ? fs.readFileSync(filePath, "utf8") : "";
287
+ const doc = YAML.parseDocument(text);
288
+ if (doc.errors.length) throw new ArmyConfigError(`${filePath} is not valid YAML: ${doc.errors[0].message}`, { path: filePath });
289
+ const current = doc.toJS()?.army ?? {};
290
+ const next = mutate(structuredClone(current));
291
+ const result = armySchema.safeParse(next);
292
+ if (!result.success) {
293
+ const errors = formatIssues(result.error);
294
+ throw new ArmyConfigError(`refusing to write an invalid army section:\n${errors.map((l) => ` - ${l}`).join("\n")}`, { path: filePath, errors });
295
+ }
296
+ if (doc.contents === null) doc.contents = doc.createNode({});
297
+ doc.set("army", doc.createNode(result.data));
298
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
299
+ fs.writeFileSync(filePath, String(doc));
300
+ return result.data;
301
+ }
302
+
303
+ /** Point one role at an agent (fields from parseTargetSpec) in one layer file. */
304
+ export function assignRoleInFile(filePath, roleName, targetFields) {
305
+ if (!ROLE_NAME_RE.test(roleName)) throw new Error(`"${roleName}" is not a valid role name (lowercase letters, digits and hyphens, starting with a letter)`);
306
+ return updateArmyInFile(filePath, (army) => {
307
+ const roles = army.roles ?? (army.roles = {});
308
+ const role = roles[roleName] ?? (roles[roleName] = {});
309
+ delete role.agent;
310
+ delete role.model;
311
+ Object.assign(role, targetFields);
312
+ return army;
313
+ });
314
+ }
315
+
316
+ /** The default army, from the operator's own description of how it runs. */
317
+ export const DEFAULT_ARMY = Object.freeze({
318
+ workflow: [
319
+ "Not every role runs every time; the General calls only the ones the work needs.",
320
+ "1. Build: the Sr Dev does the first cut, handing simple work to Jr Devs while keeping the harder implementation that needs more reasoning. UI work goes to UI/UX.",
321
+ "2. Review: once the General is told the build is done, it calls the specialists who apply (data architect, security analyst).",
322
+ "3. The PM reviews the work against the plan.",
323
+ "4. Acceptance: the PO and stakeholders test end to end.",
324
+ ].join("\n"),
325
+ roles: {
326
+ "sr-dev": { phase: "build", mode: "implement", agent: "local", description: "Senior developer. Does the first cut, keeps the harder implementation that needs more reasoning, and splits out simple, well-specified pieces for Jr Devs." },
327
+ "jr-dev": { phase: "build", mode: "implement", agent: "local", description: "Junior developer. Takes simple, well-specified work handed off by the Sr Dev: mechanical changes, tests, docs, repetitive edits." },
328
+ "ui-ux": { phase: "build", mode: "implement", agent: "local", description: "UI/UX. Handed UI work: components, layout, styling, interaction and accessibility." },
329
+ "data-architect": { phase: "review", mode: "scout", agent: "local", description: "Data architect. Reviews data work from a star schema / medallion (bronze, silver, gold) perspective: grain, keys, conformed dimensions, slowly changing dimensions, and clean layer boundaries." },
330
+ "security-analyst": { phase: "review", mode: "scout", agent: "local", description: "Security analyst. Reviews from a security perspective: authentication and authorization, input handling and injection, secrets, data exposure, and dependency risk." },
331
+ pm: { phase: "review", mode: "scout", agent: "local", description: "Project manager. Reviews the work against the plan and its acceptance criteria: what's done, what's missing, and what crept in out of scope." },
332
+ po: { phase: "acceptance", mode: "implement", agent: "local", description: "Product owner. Tests end to end against the user story: does it do what the user needs? Writes or runs e2e checks and reports gaps." },
333
+ stakeholder: { phase: "acceptance", mode: "implement", agent: "local", description: "Stakeholder. Exercises the finished feature end to end the way a real user would, and reports anything confusing, broken or missing." },
334
+ },
335
+ });
336
+
337
+ /**
338
+ * Roles that aren't independent of the General: on the very same agent
339
+ * (the General reviewing its own model's work), or on a subscription with
340
+ * the same provider and owner (drawing from the General's usage limit).
341
+ */
342
+ export function generalOverlap(army, agents = {}) {
343
+ const general = army.general && agents[army.general];
344
+ const out = {};
345
+ if (!general) return out;
346
+ for (const [name, role] of Object.entries(army.roles)) {
347
+ const agent = role.agent && agents[role.agent];
348
+ if (!agent) continue;
349
+ if (role.agent === army.general) out[name] = `runs on the General's own agent "${army.general}": its work isn't independently reviewed, and it shares the General's usage`;
350
+ else if (agent.kind === "subscription" && general.kind === "subscription" && agent.provider === general.provider && agent.owner === general.owner) {
351
+ out[name] = `shares the General's ${agent.provider} login (${agent.owner}), so it draws from the same usage limit`;
352
+ }
353
+ }
354
+ return out;
355
+ }
356
+
357
+ /**
358
+ * The merged army as the General sees it (the `army` MCP tool and
359
+ * `nomarmy army show --json` return exactly this): the General's fixed
360
+ * charter and the agent it's defined as, the workflow, then each role's
361
+ * description, phase, suggested mode, agent, any problem or overlap with
362
+ * the General, and which layer set each field.
363
+ */
364
+ export function describeArmy(loaded, { agents = {}, describeAgent = null } = {}) {
365
+ const army = loaded.army;
366
+ const problems = armyTargetProblems(army, agents);
367
+ const overlap = generalOverlap(army, agents);
368
+ const runsOn = (name) => (name && agents[name] && describeAgent ? describeAgent(agents[name]) : null);
369
+ // A subscription whose owner isn't the General's own is worth a word,
370
+ // not a refusal: it may be the same person's other account (a real Senti
371
+ // General skipped a role's agent for exactly this, unsure whose it was).
372
+ const generalAgent = army.general && agents[army.general];
373
+ const ownerNote = (name) => {
374
+ const agent = name && agents[name];
375
+ if (agent?.kind !== "subscription" || generalAgent?.kind !== "subscription" || agent.owner === generalAgent.owner) return null;
376
+ return `subscription owned by ${agent.owner}, not the General's own ${generalAgent.owner}; jobs on it need on_behalf_of "${agent.owner}". If that's the operator's own other account, it's fine to use.`;
377
+ };
378
+ const roles = Object.fromEntries(Object.entries(army.roles).map(([name, role]) => [name, {
379
+ description: role.description ?? null, phase: role.phase ?? null, mode: role.mode ?? null,
380
+ agent: role.agent ?? null, model: role.model ?? agents[role.agent]?.model ?? null, modelIsAuto: role.model === "auto",
381
+ agentRunsOn: runsOn(role.agent),
382
+ // Its agent's own tools run on this machine (dispatch-schema.mjs): an
383
+ // implement role there is refused per job unless the agent allows it.
384
+ hostTools: agentRunsToolsOnHost(agents[role.agent]) ? { allowed: agents[role.agent].allow_host_tools === true, implementRole: (role.mode ?? "implement") === "implement" } : null,
385
+ problem: problems[name] ?? null, overlapsGeneral: overlap[name] ?? null, ownerNote: ownerNote(role.agent), setBy: loaded.sources.roles[name],
386
+ }]));
387
+ const generalProblem = !army.general
388
+ ? "not defined -- run `nomarmy army general <agent>` so nomArmy can flag roles that share the General's model or usage"
389
+ : !Object.prototype.hasOwnProperty.call(agents, army.general) ? `agent "${army.general}" is not defined in your agents.yml` : null;
390
+ return {
391
+ general: { ...GENERAL, agent: army.general, agentRunsOn: runsOn(army.general), problem: generalProblem, setBy: loaded.sources.general },
392
+ workflow: army.workflow,
393
+ runLimits: army.runLimits ?? {},
394
+ roles,
395
+ layers: loaded.layers,
396
+ howToDispatch: Object.keys(roles).length
397
+ ? "Pass army_role: \"<role>\" on a job (plus on_behalf_of when its agent is a subscription). The role's description goes at the top of the brief; set `mode` on the job yourself, the role's is only a suggestion. A role with model \"auto\" needs model on the job, picked from that agent's models below; any job may pass model to override the role's. Use agent: \"<name>\" instead to pick an agent directly."
398
+ : "No army is configured. Run `nomarmy army init` for the default roster, or dispatch with agent: \"<name>\".",
399
+ };
400
+ }