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.
- package/LICENSE +202 -0
- package/NOTICE +25 -0
- package/README.md +484 -0
- package/bin/nomarmy.mjs +2248 -0
- package/config/agents.yml.example +63 -0
- package/config/common.env +31 -0
- package/config/profiles/bedrock-cheap.env +26 -0
- package/config/profiles/bedrock.env +28 -0
- package/config/profiles/cpu-linux.env +8 -0
- package/config/profiles/dgx-spark.env +12 -0
- package/config/profiles/macbook-pro.env +9 -0
- package/config/profiles/nvidia-linux.env +9 -0
- package/docker/Dockerfile +15 -0
- package/docker/Dockerfile.go +29 -0
- package/docker/Dockerfile.rust +19 -0
- package/e2e.sh +153 -0
- package/install.sh +125 -0
- package/lib/agents.mjs +285 -0
- package/lib/army.mjs +400 -0
- package/lib/budget.mjs +368 -0
- package/lib/claude-transcript.mjs +150 -0
- package/lib/config.mjs +193 -0
- package/lib/connect.mjs +409 -0
- package/lib/coordinator-instructions.mjs +23 -0
- package/lib/decompose.mjs +389 -0
- package/lib/dispatch-config.mjs +164 -0
- package/lib/dispatch-schema.mjs +280 -0
- package/lib/doctor.mjs +443 -0
- package/lib/evidence.mjs +679 -0
- package/lib/gguf.mjs +589 -0
- package/lib/hardware.mjs +476 -0
- package/lib/health.mjs +278 -0
- package/lib/model-catalog.mjs +71 -0
- package/lib/notifier-app.mjs +95 -0
- package/lib/notify.mjs +66 -0
- package/lib/openclaw-config.mjs +65 -0
- package/lib/openclaw-errors.mjs +40 -0
- package/lib/propose.mjs +110 -0
- package/lib/prune.mjs +77 -0
- package/lib/repo-query.mjs +267 -0
- package/lib/runs.mjs +150 -0
- package/lib/sabotage.mjs +128 -0
- package/lib/sandbox-images.mjs +434 -0
- package/lib/scan.mjs +1538 -0
- package/lib/schema.mjs +288 -0
- package/lib/scout.mjs +544 -0
- package/lib/sizing.mjs +1322 -0
- package/lib/slots.mjs +112 -0
- package/lib/statusline.mjs +126 -0
- package/lib/subscription-config.mjs +68 -0
- package/lib/subscription-setup.mjs +217 -0
- package/lib/transcript.mjs +195 -0
- package/lib/verify.mjs +700 -0
- package/mcp/server.mjs +4206 -0
- package/notifier/icon.swift +34 -0
- package/notifier/main.swift +52 -0
- package/notifier/nomarmy-icon.png +0 -0
- package/package.json +67 -0
- package/playbooks/feature.md +43 -0
- package/policies/coder.md +49 -0
- package/policies/orchestrator.md +35 -0
- package/policies/reviewer.md +35 -0
- package/policies/scout.md +65 -0
- package/scripts/configure-openclaw.sh +96 -0
- package/scripts/configure-orchestrator.sh +84 -0
- package/scripts/install-llama-cpp.sh +16 -0
- package/scripts/lib.sh +198 -0
- package/scripts/select-model.mjs +96 -0
- package/scripts/select-model.sh +4 -0
- package/scripts/setup-sandbox.sh +38 -0
- package/scripts/start-inference.sh +46 -0
- package/scripts/stop-inference.sh +5 -0
- package/scripts/uninstall.sh +6 -0
- 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
|
+
}
|