ai-agentmanager 1.9.0 → 1.11.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/CHANGELOG.md +11 -0
- package/README.md +27 -3
- package/bin/acp-client.js +332 -0
- package/docker/agent.Dockerfile +15 -0
- package/docker/compose.runner.yml +22 -0
- package/docker/runner-entrypoint.sh +12 -0
- package/docker/runner.Dockerfile +31 -0
- package/docs/cloud-runner.md +62 -0
- package/package.json +2 -1
- package/public/app.js +40 -8
- package/public/index.html +14 -1
- package/public/land.js +2 -2
- package/public/menu.js +39 -0
- package/public/review.js +54 -2
- package/public/scorecard.js +68 -0
- package/public/style.css +34 -0
- package/src/backlog.js +4 -1
- package/src/cli.js +38 -2
- package/src/container.js +189 -0
- package/src/engines/acp.js +222 -0
- package/src/engines/index.js +10 -2
- package/src/fleet.js +5 -4
- package/src/hub.js +13 -3
- package/src/hublink.js +4 -2
- package/src/inbox.js +14 -0
- package/src/manager.js +90 -8
- package/src/review.js +34 -2
- package/src/scorecard.js +189 -0
- package/src/server.js +36 -2
- package/src/trust.js +1 -2
package/src/manager.js
CHANGED
|
@@ -47,6 +47,7 @@ const SUMMARIES = {
|
|
|
47
47
|
humanai: { doc: 'humanai', run: 'runHumanAi' },
|
|
48
48
|
diagrams: { doc: 'diagrams', run: 'runDiagrams' },
|
|
49
49
|
walkthrough: { doc: 'walkthrough', run: 'runWalkthrough' },
|
|
50
|
+
scorecard: { doc: 'scorecard', run: 'runScorecard' },
|
|
50
51
|
};
|
|
51
52
|
/** Read / write where a summary of `kind` is kept on the agent record. */
|
|
52
53
|
const getSummary = (agent, kind) => (SUMMARIES[kind].doc ? agent.docs?.[kind] : agent[SUMMARIES[kind].field]);
|
|
@@ -100,6 +101,8 @@ import { spawn } from 'node:child_process';
|
|
|
100
101
|
import * as git from './git.js';
|
|
101
102
|
import { parseDiff } from '../public/review.js';
|
|
102
103
|
import { issueSummary, leadingIssue, wordsAfterLink } from './jira.js';
|
|
104
|
+
import { combine, facts as scoreFacts, scorecardMarkdown, scorecardPrompt } from './scorecard.js';
|
|
105
|
+
import { CONTAINER_ENGINES, claudeToken, containerName, containerRunning, engineMounts, gitCommonDir, removeContainer, runArgs, stopContainer } from './container.js';
|
|
103
106
|
import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
|
|
104
107
|
import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
|
|
105
108
|
import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
|
|
@@ -592,6 +595,7 @@ export class AgentManager extends EventEmitter {
|
|
|
592
595
|
startedBy,
|
|
593
596
|
trigger,
|
|
594
597
|
attachments,
|
|
598
|
+
isolation,
|
|
595
599
|
}) {
|
|
596
600
|
intent = (intent ?? '').trim();
|
|
597
601
|
const staged = stagedFiles(attachments);
|
|
@@ -605,6 +609,12 @@ export class AgentManager extends EventEmitter {
|
|
|
605
609
|
}
|
|
606
610
|
// Planning while cloning moves the session between folders, which needs the engine's support.
|
|
607
611
|
if (!engineDef.supports.planHandoff) plan = false;
|
|
612
|
+
// Isolation: "container" runs it in Docker (config "isolation": { "default": "container" } for all).
|
|
613
|
+
isolation = isolation === undefined ? this.config.isolation?.default ?? null : isolation || null;
|
|
614
|
+
if (isolation && isolation !== 'container') throw Object.assign(new Error('isolation is "container" or nothing'), { status: 400 });
|
|
615
|
+
if (isolation && !CONTAINER_ENGINES.has(engineId)) throw Object.assign(new Error(`${engineDef.label} can't run in a container yet (Claude Code and Codex can)`), { status: 400 });
|
|
616
|
+
// The session moves between folders mid-plan; a container is bound to one folder.
|
|
617
|
+
if (isolation) plan = false;
|
|
608
618
|
const general = NO_REPO.has(kind);
|
|
609
619
|
// A project shortcut (or name) fills in the repo / folder / base branch.
|
|
610
620
|
const entry = !general && project ? this.resolveProject(project) : null;
|
|
@@ -637,6 +647,7 @@ export class AgentManager extends EventEmitter {
|
|
|
637
647
|
priority: parsePriority(priority),
|
|
638
648
|
...(startedBy ? { startedBy } : {}),
|
|
639
649
|
...(trigger ? { trigger } : {}),
|
|
650
|
+
...(isolation ? { isolation } : {}),
|
|
640
651
|
name: name?.trim() || jiraKey || slug,
|
|
641
652
|
slug,
|
|
642
653
|
intent,
|
|
@@ -993,6 +1004,26 @@ export class AgentManager extends EventEmitter {
|
|
|
993
1004
|
return this.view(this.store.get(agent.id));
|
|
994
1005
|
}
|
|
995
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* `docker run` for an agent in a container (see container.js): its folder, its repository's git
|
|
1009
|
+
* data, its brief and uploads, the engine's homes and sign-in; nothing else of this computer.
|
|
1010
|
+
*/
|
|
1011
|
+
containerSpec(agent, engine, cwd, args) {
|
|
1012
|
+
const name = containerName(agent.id);
|
|
1013
|
+
removeContainer(name); // a leftover from a crash or a restart would block the name
|
|
1014
|
+
const em = engineMounts(engine.id, cwd);
|
|
1015
|
+
const real = (p) => (fs.existsSync(p) ? fs.realpathSync(p) : null);
|
|
1016
|
+
const git = gitCommonDir(cwd);
|
|
1017
|
+
// Plus any folders config.json adds ("isolation": { "mounts": [{ "host": "~/.npm", "readOnly": true }] }).
|
|
1018
|
+
const extra = (this.config.isolation?.mounts ?? []).map((m) => ({ ...m, host: real(expandHome(m.host)) }));
|
|
1019
|
+
const mounts = [...em.mounts, ...(git ? [{ host: git }] : []), { host: real(briefDir(agent.id)) }, { host: real(path.join(paths.uploads, agent.id)), readOnly: true }, ...extra].filter((m) => m.host);
|
|
1020
|
+
const bin = this.config.isolation?.bin?.[engine.id] ?? em.bin;
|
|
1021
|
+
const env = { ...this.childEnv(agent), ...(claudeToken() ? { CLAUDE_CODE_OAUTH_TOKEN: claudeToken() } : {}) };
|
|
1022
|
+
// Variables passed in by name (values stay out of the command line): the engine's, plus config's "isolation.env".
|
|
1023
|
+
const inner = ['TERM=xterm-256color', 'COLORTERM=truecolor', `AGENTMGR_AGENT_ID=${agent.id}`, 'AGENTMGR_TERMINAL=1', ...em.env, ...(this.config.isolation?.env ?? [])];
|
|
1024
|
+
return { name, cmd: 'docker', env, args: runArgs({ agentId: agent.id, engineId: engine.id, cwd: fs.realpathSync(cwd), bin, args, config: this.config, mounts, env: inner }) };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
996
1027
|
/** What every launch of this agent passes its engine: options, its brief folder, instructions. */
|
|
997
1028
|
launchOptions(agent, { planning = false } = {}) {
|
|
998
1029
|
const engine = engineOf(agent);
|
|
@@ -1096,19 +1127,23 @@ export class AgentManager extends EventEmitter {
|
|
|
1096
1127
|
}
|
|
1097
1128
|
}
|
|
1098
1129
|
|
|
1099
|
-
|
|
1130
|
+
// In a container the engine is inside the image, not on this computer.
|
|
1131
|
+
const container = agent.isolation === 'container' && !NO_REPO.has(agent.kind);
|
|
1132
|
+
if (!container && !installed(engine, this.config)) {
|
|
1100
1133
|
const error = notInstalled(engine, this.config);
|
|
1101
1134
|
this.patch(agent.id, { status: 'error', error });
|
|
1102
1135
|
throw Object.assign(new Error(error), { status: 400 });
|
|
1103
1136
|
}
|
|
1104
1137
|
let proc;
|
|
1105
1138
|
try {
|
|
1106
|
-
|
|
1139
|
+
const spec = container ? this.containerSpec(agent, engine, cwd, args) : { cmd: engine.bin(this.config), args, env: this.childEnv(agent) };
|
|
1140
|
+
rt.container = spec.name ?? null;
|
|
1141
|
+
proc = pty.spawn(spec.cmd, spec.args, {
|
|
1107
1142
|
name: 'xterm-256color',
|
|
1108
1143
|
cols: rt.cols,
|
|
1109
1144
|
rows: rt.rows,
|
|
1110
1145
|
cwd,
|
|
1111
|
-
env:
|
|
1146
|
+
env: spec.env,
|
|
1112
1147
|
});
|
|
1113
1148
|
} catch (err) {
|
|
1114
1149
|
this.patch(agent.id, { status: 'error', error: `Failed to launch ${engine.bin(this.config)}: ${err.message}` });
|
|
@@ -1231,9 +1266,14 @@ export class AgentManager extends EventEmitter {
|
|
|
1231
1266
|
done.dispose();
|
|
1232
1267
|
resolve(this.view(this.store.get(agent.id) ?? agent));
|
|
1233
1268
|
});
|
|
1234
|
-
// A
|
|
1235
|
-
|
|
1236
|
-
rt.
|
|
1269
|
+
// A container: docker stop (the engine gets SIGTERM inside; the docker command then ends).
|
|
1270
|
+
// Otherwise a hangup, as when a terminal closes, unless the engine ends cleanly on another signal.
|
|
1271
|
+
if (rt.container) stopContainer(rt.container, 10);
|
|
1272
|
+
else rt.pty.kill(engineOf(agent).stopSignal ?? 'SIGHUP');
|
|
1273
|
+
rt.killTimer = setTimeout(() => {
|
|
1274
|
+
if (rt.container) removeContainer(rt.container);
|
|
1275
|
+
rt.pty?.kill('SIGKILL');
|
|
1276
|
+
}, timeoutMs);
|
|
1237
1277
|
});
|
|
1238
1278
|
}
|
|
1239
1279
|
|
|
@@ -1507,7 +1547,10 @@ export class AgentManager extends EventEmitter {
|
|
|
1507
1547
|
rows: prev?.rows ?? 34,
|
|
1508
1548
|
};
|
|
1509
1549
|
if (rt.size) this.appendOutput(rt, '\r\n\x1b[2m── new shell ──\x1b[0m\r\n');
|
|
1510
|
-
|
|
1550
|
+
// An agent in a running container: the shell opens inside it, with what the agent sees.
|
|
1551
|
+
const inside = agent.isolation === 'container' && containerRunning(containerName(agent.id));
|
|
1552
|
+
const [cmd, cmdArgs] = inside ? ['docker', ['exec', '-it', '-w', fs.realpathSync(agent.workdir), containerName(agent.id), 'bash', '-l']] : [this.config.shell, ['-l']];
|
|
1553
|
+
const proc = pty.spawn(cmd, cmdArgs, {
|
|
1511
1554
|
name: 'xterm-256color',
|
|
1512
1555
|
cols: rt.cols,
|
|
1513
1556
|
rows: rt.rows,
|
|
@@ -1887,6 +1930,8 @@ export class AgentManager extends EventEmitter {
|
|
|
1887
1930
|
this.patch(id, { brief });
|
|
1888
1931
|
// The agent says it is done: have the judge check the work before the person looks.
|
|
1889
1932
|
if (brief.status === 'done' && agent.brief?.status !== 'done' && this.judge.enabled) this.requestReview(id, { trigger: 'brief' }).catch(() => {});
|
|
1933
|
+
// ...and score it for the reviewer ("scorecard": { "auto": false } in config.json turns this off).
|
|
1934
|
+
if (brief.status === 'done' && agent.brief?.status !== 'done' && this.config.scorecard?.auto !== false && !NO_REPO.has(agent.kind) && this.changesSource) this.summarize(id, 'scorecard').catch(() => {});
|
|
1890
1935
|
}
|
|
1891
1936
|
if (agent.planReview) {
|
|
1892
1937
|
const file = loadPlanFile(id, agent.planReview);
|
|
@@ -2136,6 +2181,42 @@ export class AgentManager extends EventEmitter {
|
|
|
2136
2181
|
);
|
|
2137
2182
|
}
|
|
2138
2183
|
|
|
2184
|
+
/**
|
|
2185
|
+
* The review scorecard (see scorecard.js): the same diff the review screen shows, scored against
|
|
2186
|
+
* the intent, spec and plan, with the facts the app measures capping what the model says.
|
|
2187
|
+
*/
|
|
2188
|
+
async runScorecard(agent, ctx) {
|
|
2189
|
+
if (!this.changesSource) throw new Error('Changes are not available here');
|
|
2190
|
+
const data = await this.changesSource(agent.id);
|
|
2191
|
+
const { hunks, cut } = walkthroughHunks(parseDiff(data.diff), { maxChars: 50_000 });
|
|
2192
|
+
if (!hunks.length) throw Object.assign(new Error('No changes to score yet'), { status: 400 });
|
|
2193
|
+
const cur0 = this.store.get(agent.id) ?? agent;
|
|
2194
|
+
const doc = (n) => readSpecDoc(agent.id, n)?.markdown ?? '';
|
|
2195
|
+
const f = scoreFacts({ files: data.files, prs: Object.values(cur0.prs ?? {}), brief: cur0.brief, plan: doc('plan'), spec: doc('spec') });
|
|
2196
|
+
const writer = this.writerFor(agent);
|
|
2197
|
+
const prompt = scorecardPrompt({ ...ctx, briefMarkdown: this.briefFile(agent.id).markdown ?? '', intentDoc: doc('intent'), specDoc: doc('spec'), planDoc: doc('plan'), hunks, facts: f });
|
|
2198
|
+
const sc = combine(parseJsonReply(await this.oneShot(agent, prompt, writer)), f, hunks);
|
|
2199
|
+
const cur = this.store.get(agent.id);
|
|
2200
|
+
if (!cur) return null;
|
|
2201
|
+
return this.view(
|
|
2202
|
+
this.patch(
|
|
2203
|
+
agent.id,
|
|
2204
|
+
summaryPatch(cur, 'scorecard', {
|
|
2205
|
+
data: sc,
|
|
2206
|
+
cut,
|
|
2207
|
+
diffHash: hash(data.diff),
|
|
2208
|
+
markdown: scorecardMarkdown(sc, cur.name),
|
|
2209
|
+
writer: `${writer.engine.label}${writer.model ? ` (${writer.model})` : ''}`,
|
|
2210
|
+
postedTo: cur.docs?.scorecard?.postedTo ?? null,
|
|
2211
|
+
at: now(),
|
|
2212
|
+
briefUpdatedAt: cur.brief?.updatedAt ?? null,
|
|
2213
|
+
pending: false,
|
|
2214
|
+
error: null,
|
|
2215
|
+
}),
|
|
2216
|
+
),
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2139
2220
|
/** Diagrams only: the problem and its solution drawn from the documents already written and the live context. */
|
|
2140
2221
|
async runDiagrams(agent, ctx) {
|
|
2141
2222
|
const cur0 = this.store.get(agent.id) ?? agent;
|
|
@@ -2772,7 +2853,8 @@ export class AgentManager extends EventEmitter {
|
|
|
2772
2853
|
const keys = { esc: '\x1b', enter: '\r', up: '\x1b[A', down: '\x1b[B' };
|
|
2773
2854
|
const data = keys[key] ?? (/^\d$/.test(key) ? key : null);
|
|
2774
2855
|
if (!data) throw Object.assign(new Error('Unsupported key'), { status: 400 });
|
|
2775
|
-
|
|
2856
|
+
// Menus that read a line (the ACP client's) take Enter after the choice.
|
|
2857
|
+
this.write(agent.id, engineOf(agent).promptKeyEnter && key !== 'enter' ? `${data}\r` : data);
|
|
2776
2858
|
return { ok: true };
|
|
2777
2859
|
}
|
|
2778
2860
|
|
package/src/review.js
CHANGED
|
@@ -143,6 +143,32 @@ export class Review {
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Post the review scorecard on the agent's open pull request as a comment, so approvers see it in
|
|
148
|
+
* GitHub. Posting again edits the last comment instead of adding another.
|
|
149
|
+
*/
|
|
150
|
+
async postScorecard(ref) {
|
|
151
|
+
const agent = this.agentWithRepo(ref);
|
|
152
|
+
const sc = agent.docs?.scorecard;
|
|
153
|
+
if (!sc?.markdown) throw err(409, 'Score the changes first');
|
|
154
|
+
const pr = Object.values(agent.prs ?? {}).find((p) => p.state === 'OPEN');
|
|
155
|
+
if (!pr) throw err(409, 'No open pull request for this agent');
|
|
156
|
+
this.manager.plugins.check('agent.pr', { agent: this.manager.auditView(agent), title: 'scorecard comment', draft: false });
|
|
157
|
+
const opts = { cwd: agent.workdir, timeout: 60_000 };
|
|
158
|
+
const args = ['pr', 'comment', pr.url, '--body', sc.markdown];
|
|
159
|
+
const again = sc.postedTo === pr.url;
|
|
160
|
+
try {
|
|
161
|
+
await git.run(ghBin(), again ? [...args, '--edit-last'] : args, opts);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// The comment we edited may be gone; add a new one.
|
|
164
|
+
if (!again) throw err(502, `Posting the scorecard failed: ${e.message.replace(/^gh .*? failed: /, '')}`);
|
|
165
|
+
await git.run(ghBin(), args, opts);
|
|
166
|
+
}
|
|
167
|
+
const cur = this.manager.store.get(agent.id);
|
|
168
|
+
this.manager.patch(agent.id, { docs: { ...cur.docs, scorecard: { ...cur.docs.scorecard, postedTo: pr.url, postedAt: new Date().toISOString() } } });
|
|
169
|
+
return { url: pr.url, edited: again };
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
/** Send the comments to the agent as one message (typed in when it is idle). */
|
|
147
173
|
comment(ref, { comments = [], note = '' } = {}) {
|
|
148
174
|
const agent = this.agentWithRepo(ref);
|
|
@@ -194,8 +220,14 @@ export class Review {
|
|
|
194
220
|
}
|
|
195
221
|
const args = ['pr', 'create', '--head', branch];
|
|
196
222
|
if (target) args.push('--base', target);
|
|
197
|
-
// The description: your text (else the commits), then the intent, spec and plan.
|
|
198
|
-
const
|
|
223
|
+
// The description: your text (else the commits), the review scorecard, then the intent, spec and plan.
|
|
224
|
+
const sc = agent.docs?.scorecard;
|
|
225
|
+
let card = '';
|
|
226
|
+
if (sc?.markdown) {
|
|
227
|
+
const now = base ? hash(await git.fullDiff(dir, base.sha)) : null;
|
|
228
|
+
card = sc.diffHash && now && sc.diffHash !== now ? `${sc.markdown}\n\n<sub>Scored before the latest changes.</sub>` : sc.markdown;
|
|
229
|
+
}
|
|
230
|
+
const sections = [card, prSections(agent.id)].filter(Boolean).join('\n\n');
|
|
199
231
|
if (String(title).trim() || sections) {
|
|
200
232
|
const log = base ? await git.run('git', ['log', '--reverse', '--format=%s', `${base.sha}..HEAD`], opts).catch(() => '') : '';
|
|
201
233
|
const subjects = log.split('\n').filter(Boolean);
|
package/src/scorecard.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// The review scorecard: an agent's changes scored against what was asked (intent), what done means
|
|
2
|
+
// (spec), staying on task (scope), its plan, the tests and safety, with an overall readiness and the
|
|
3
|
+
// few places a person should actually read. A model judges; facts the app measures (CI, test files,
|
|
4
|
+
// plan ticks, the spec's criteria) cap and correct it, and the recommendation follows fixed rules,
|
|
5
|
+
// so a confident-sounding model can't talk a change into "ready".
|
|
6
|
+
|
|
7
|
+
export const DIMENSIONS = [
|
|
8
|
+
['intent', 'Intent', 'Does it do what was asked?'],
|
|
9
|
+
['spec', 'Spec', 'Are the acceptance criteria met?'],
|
|
10
|
+
['tests', 'Tests', 'Is it verified: CI, tests for the changed code, checks run?'],
|
|
11
|
+
['safety', 'Safety', 'Risky areas handled: security, data, migrations, dependencies, public APIs (higher is safer)'],
|
|
12
|
+
['scope', 'Scope', 'Only what the task needs, in proportion?'],
|
|
13
|
+
['plan', 'Plan', 'Done as planned, departures explained?'],
|
|
14
|
+
];
|
|
15
|
+
const WEIGHTS = { intent: 25, spec: 20, tests: 20, safety: 15, scope: 10, plan: 10 };
|
|
16
|
+
const TEST_FILE = /(^|\/)(tests?|__tests__|spec|specs)\/|[._-](test|spec)\.[a-z]+$|_test\.go$|Test\.java$/i;
|
|
17
|
+
|
|
18
|
+
/** Facts the app knows without a model: CI, test files touched, the agent's checks, plan and spec progress. */
|
|
19
|
+
export function facts({ files = [], prs = [], brief = null, plan = '', spec = '' }) {
|
|
20
|
+
const paths = files.map((f) => f.path);
|
|
21
|
+
const tests = paths.filter((p) => TEST_FILE.test(p));
|
|
22
|
+
const code = paths.filter((p) => !TEST_FILE.test(p) && !/\.(md|txt|rst|adoc)$/i.test(p) && !/(^|\/)docs?\//i.test(p));
|
|
23
|
+
const open = prs.find((p) => p.state === 'OPEN') ?? prs[0];
|
|
24
|
+
const checks = brief?.checks ?? [];
|
|
25
|
+
const count = (st) => checks.filter((c) => c.state === st).length;
|
|
26
|
+
const steps = [...String(plan).matchAll(/^\s*[-*]\s+\[([ xX~!])\]/gm)].map((m) => m[1].toLowerCase());
|
|
27
|
+
const criteriaBlock = /^##\s*Acceptance criteria\s*$([\s\S]*?)(?=^##\s|(?![\s\S]))/im.exec(String(spec))?.[1] ?? '';
|
|
28
|
+
return {
|
|
29
|
+
files: paths.length,
|
|
30
|
+
added: files.reduce((s, f) => s + (f.added ?? 0), 0),
|
|
31
|
+
removed: files.reduce((s, f) => s + (f.removed ?? 0), 0),
|
|
32
|
+
testFiles: tests.length,
|
|
33
|
+
codeFiles: code.length,
|
|
34
|
+
ci: open?.checks?.state ?? null, // passed | failed | running | null (no PR or no checks)
|
|
35
|
+
ciFailed: open?.checks?.failed ?? [],
|
|
36
|
+
checks: { passed: count('passed'), failed: count('failed'), pending: count('pending') + count('running') },
|
|
37
|
+
plan: steps.length ? { steps: steps.length, done: steps.filter((s) => s === 'x').length } : null,
|
|
38
|
+
criteria: criteriaBlock.split('\n').filter((l) => /^\s*[-*\d]/.test(l) && /\w/.test(l)).length,
|
|
39
|
+
hasSpec: Boolean(String(spec).trim()),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const factLines = (f) =>
|
|
44
|
+
[
|
|
45
|
+
`files changed: ${f.files} (+${f.added} -${f.removed}); code files: ${f.codeFiles}; test files: ${f.testFiles}`,
|
|
46
|
+
`CI on the pull request: ${f.ci ?? 'no pull request or no checks yet'}${f.ciFailed.length ? ` (failing: ${f.ciFailed.join(', ')})` : ''}`,
|
|
47
|
+
`the agent's own checks (from its brief): ${f.checks.passed} passed, ${f.checks.failed} failed, ${f.checks.pending} pending`,
|
|
48
|
+
`plan: ${f.plan ? `${f.plan.done} of ${f.plan.steps} steps ticked` : 'no plan written'}`,
|
|
49
|
+
`spec: ${f.hasSpec ? `${f.criteria} acceptance criteria` : 'no spec written'}`,
|
|
50
|
+
].join('\n');
|
|
51
|
+
|
|
52
|
+
export function scorecardPrompt({ intent, status, digest, briefMarkdown, intentDoc, specDoc, planDoc, hunks, facts: f }) {
|
|
53
|
+
return [
|
|
54
|
+
"You score an AI coding agent's changes for a reviewer who wants to approve with confidence and read as little as possible.",
|
|
55
|
+
'Be skeptical: a claim without evidence in the diff, the tests or the checks counts as unverified. Base everything on the material below.',
|
|
56
|
+
'',
|
|
57
|
+
'Reply with one JSON object only:',
|
|
58
|
+
'{"summary": "<two sentences: what the change does and how sure you are>",',
|
|
59
|
+
' "scores": {"intent": {"score": 0-100, "why": "<one sentence>"}, "scope": {...}, "plan": {...}, "tests": {...}, "safety": {...}},',
|
|
60
|
+
' "criteria": [{"criterion": "<an acceptance criterion from the spec>", "status": "met|not_met|unverified", "evidence": "<where: file, test, check>"}],',
|
|
61
|
+
' "look": [{"id": "H3", "reason": "<why a person should read this hunk>"}]}',
|
|
62
|
+
'',
|
|
63
|
+
'Scoring (100 is best; be calibrated, not generous):',
|
|
64
|
+
'- intent: how fully the change does what INTENT asks. Missing parts of the ask pull it down hard.',
|
|
65
|
+
'- scope: 100 when every change serves the task; lower for unrelated edits, drive-by refactors, or a change far larger than the task needs.',
|
|
66
|
+
'- plan: how closely the change follows PLAN (steps done, departures explained). Without a plan, judge whether the work is coherent.',
|
|
67
|
+
'- tests: whether the changed behavior is verified: tests added or updated for it, CI, checks the agent ran. Code changes with no tests and no passing checks score low.',
|
|
68
|
+
'- safety: 100 when nothing risky is touched or risks are handled; lower for security, auth, data loss, migrations, concurrency, money, new dependencies or public API changes without care.',
|
|
69
|
+
'- criteria: one entry per acceptance criterion in SPEC, in its order; leave the list empty if there is no spec.',
|
|
70
|
+
'- look: at most 5 hunks a person must read (the risky, subtle or unverified ones), most important first. Not the obvious ones.',
|
|
71
|
+
'',
|
|
72
|
+
'<facts_measured_by_the_app>',
|
|
73
|
+
factLines(f),
|
|
74
|
+
'</facts_measured_by_the_app>',
|
|
75
|
+
'<intent>',
|
|
76
|
+
intentDoc || intent || '(none)',
|
|
77
|
+
'</intent>',
|
|
78
|
+
'<spec>',
|
|
79
|
+
specDoc || '(no spec written)',
|
|
80
|
+
'</spec>',
|
|
81
|
+
'<plan>',
|
|
82
|
+
planDoc || '(no plan written)',
|
|
83
|
+
'</plan>',
|
|
84
|
+
'<agent_status>',
|
|
85
|
+
status,
|
|
86
|
+
'</agent_status>',
|
|
87
|
+
'<conversation>',
|
|
88
|
+
(digest || '(none)').slice(-12_000),
|
|
89
|
+
'</conversation>',
|
|
90
|
+
'<brief>',
|
|
91
|
+
briefMarkdown || '(none)',
|
|
92
|
+
'</brief>',
|
|
93
|
+
'<hunks>',
|
|
94
|
+
...hunks.map((h) => `<hunk id="${h.id}" file="${h.file}">\n${h.head}\n${h.text}\n</hunk>`),
|
|
95
|
+
'</hunks>',
|
|
96
|
+
].join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const clamp = (n) => Math.max(0, Math.min(100, Math.round(Number(n) || 0)));
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The scorecard from the model's reply and the facts: scores capped by what was measured, the spec
|
|
103
|
+
* score computed from the criteria, the overall weighted, and a recommendation by rule.
|
|
104
|
+
*/
|
|
105
|
+
export function combine(reply, f, hunks) {
|
|
106
|
+
const s = reply?.scores ?? {};
|
|
107
|
+
const score = (k) => (s[k] && Number.isFinite(Number(s[k].score)) ? { score: clamp(s[k].score), why: String(s[k].why ?? '').slice(0, 300), notes: [] } : null);
|
|
108
|
+
const out = { intent: score('intent'), scope: score('scope'), plan: score('plan'), tests: score('tests'), safety: score('safety'), spec: null };
|
|
109
|
+
const cap = (k, max, note) => {
|
|
110
|
+
if (out[k] && out[k].score > max) {
|
|
111
|
+
out[k].score = max;
|
|
112
|
+
out[k].notes.push(note);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
// Tests: failing CI or failing checks, or code changed with nothing verifying it.
|
|
116
|
+
if (f.ci === 'failed') cap('tests', 30, `CI is failing${f.ciFailed.length ? ` (${f.ciFailed.join(', ')})` : ''}`);
|
|
117
|
+
if (f.checks.failed) cap('tests', 40, `${f.checks.failed} of the agent's checks failed`);
|
|
118
|
+
if (f.codeFiles && !f.testFiles && !f.checks.passed && f.ci !== 'passed') cap('tests', 45, 'code changed with no tests and no passing checks');
|
|
119
|
+
// Plan: bounded by the steps ticked.
|
|
120
|
+
if (f.plan && out.plan) cap('plan', Math.round(40 + (60 * f.plan.done) / f.plan.steps), `${f.plan.done} of ${f.plan.steps} plan steps ticked`);
|
|
121
|
+
if (!f.plan && out.plan) out.plan.notes.push('no plan written');
|
|
122
|
+
// Spec: computed from the criteria (met 1, unverified ½, not met 0); none without a spec.
|
|
123
|
+
const criteria = (Array.isArray(reply?.criteria) ? reply.criteria : [])
|
|
124
|
+
.filter((c) => c?.criterion)
|
|
125
|
+
.slice(0, 20)
|
|
126
|
+
.map((c) => ({ criterion: String(c.criterion).slice(0, 300), status: ['met', 'not_met', 'unverified'].includes(c.status) ? c.status : 'unverified', evidence: String(c.evidence ?? '').slice(0, 300) }));
|
|
127
|
+
// A spec with criteria the model didn't assess: they count as unverified, not as passed.
|
|
128
|
+
if (f.hasSpec && f.criteria && !criteria.length) out.spec = { score: 50, why: `${f.criteria} acceptance criteri${f.criteria === 1 ? 'on' : 'a'} not assessed`, notes: ['treated as unverified'] };
|
|
129
|
+
if (f.hasSpec && criteria.length) {
|
|
130
|
+
const pts = criteria.reduce((n, c) => n + (c.status === 'met' ? 1 : c.status === 'unverified' ? 0.5 : 0), 0);
|
|
131
|
+
const met = criteria.filter((c) => c.status === 'met').length;
|
|
132
|
+
out.spec = { score: clamp((100 * pts) / criteria.length), why: `${met} of ${criteria.length} acceptance criteri${criteria.length === 1 ? 'on' : 'a'} met${criteria.some((c) => c.status === 'not_met') ? ', some not met' : ''}`, notes: [] };
|
|
133
|
+
}
|
|
134
|
+
// Overall: weighted over the scores there are.
|
|
135
|
+
const have = Object.entries(WEIGHTS).filter(([k]) => out[k]);
|
|
136
|
+
const total = have.reduce((n, [, w]) => n + w, 0);
|
|
137
|
+
const overall = total ? Math.round(have.reduce((n, [k, w]) => n + out[k].score * w, 0) / total) : null;
|
|
138
|
+
const notMet = criteria.filter((c) => c.status === 'not_met');
|
|
139
|
+
const reasons = [];
|
|
140
|
+
if (f.ci === 'failed') reasons.push('CI is failing');
|
|
141
|
+
if (notMet.length) reasons.push(`${notMet.length} acceptance criteri${notMet.length === 1 ? 'on' : 'a'} not met`);
|
|
142
|
+
if (out.intent && out.intent.score < 50) reasons.push('it does not do enough of what was asked');
|
|
143
|
+
if (out.tests && out.tests.score < 40 && f.ci !== 'failed') reasons.push('it is barely verified');
|
|
144
|
+
let recommendation;
|
|
145
|
+
if (reasons.length) recommendation = 'not_ready';
|
|
146
|
+
else if (overall != null && overall >= 80 && (out.tests?.score ?? 0) >= 70 && (out.safety?.score ?? 100) >= 70 && (out.spec?.score ?? 100) >= 70 && f.ci !== 'running') recommendation = 'ready';
|
|
147
|
+
else recommendation = 'look';
|
|
148
|
+
// Where to look, mapped back to hunks.
|
|
149
|
+
const byId = new Map(hunks.map((h) => [h.id, h]));
|
|
150
|
+
const look = (Array.isArray(reply?.look) ? reply.look : [])
|
|
151
|
+
.map((l) => ({ h: byId.get(String(l?.id ?? '').trim()), reason: String(l?.reason ?? '').slice(0, 300) }))
|
|
152
|
+
.filter((l) => l.h)
|
|
153
|
+
.slice(0, 5)
|
|
154
|
+
.map(({ h, reason }) => ({ file: h.file, head: h.head, reason }));
|
|
155
|
+
return { summary: String(reply?.summary ?? '').slice(0, 600), overall, recommendation, reasons, scores: out, criteria, look, facts: f };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const RECOMMENDATION = { ready: 'Ready to approve', look: 'Look at these first', not_ready: 'Not ready' };
|
|
159
|
+
|
|
160
|
+
/** Markdown: for Copy / Download, the pull request's description and a PR comment. */
|
|
161
|
+
export function scorecardMarkdown(sc, title = '') {
|
|
162
|
+
const bar = (n) => (n == null ? '—' : `${n}`);
|
|
163
|
+
const rows = DIMENSIONS.map(([k, label]) => {
|
|
164
|
+
const d = sc.scores[k];
|
|
165
|
+
return `| ${label} | ${bar(d?.score)} | ${d ? [d.why, ...d.notes].filter(Boolean).join('; ').replace(/\|/g, '/') : k === 'spec' ? 'no spec written' : '—'} |`;
|
|
166
|
+
});
|
|
167
|
+
const out = [
|
|
168
|
+
`### Review scorecard${title ? `: ${title}` : ''}`,
|
|
169
|
+
'',
|
|
170
|
+
`**${sc.overall ?? '—'}/100 · ${RECOMMENDATION[sc.recommendation]}**${sc.reasons.length ? ` (${sc.reasons.join('; ')})` : ''}`,
|
|
171
|
+
'',
|
|
172
|
+
sc.summary,
|
|
173
|
+
'',
|
|
174
|
+
'| Score | /100 | Why |',
|
|
175
|
+
'| --- | --- | --- |',
|
|
176
|
+
...rows,
|
|
177
|
+
];
|
|
178
|
+
if (sc.criteria.length) {
|
|
179
|
+
out.push('', '**Acceptance criteria**', '');
|
|
180
|
+
for (const c of sc.criteria) out.push(`- ${c.status === 'met' ? '✅' : c.status === 'not_met' ? '❌' : '❔'} ${c.criterion}${c.evidence ? ` (${c.evidence})` : ''}`);
|
|
181
|
+
}
|
|
182
|
+
if (sc.look.length) {
|
|
183
|
+
out.push('', '**Where to look**', '');
|
|
184
|
+
for (const l of sc.look) out.push(`- \`${l.file}\` \`${l.head.replace(/^(@@[^@]*@@).*/, '$1')}\`: ${l.reason}`);
|
|
185
|
+
}
|
|
186
|
+
const f = sc.facts;
|
|
187
|
+
out.push('', `<sub>Measured: ${f.files} files (+${f.added} −${f.removed}), ${f.testFiles} test files, CI ${f.ci ?? 'n/a'}, checks ${f.checks.passed}✓ ${f.checks.failed}✗${f.plan ? `, plan ${f.plan.done}/${f.plan.steps}` : ''}. Scored by AI Agent Manager.</sub>`);
|
|
188
|
+
return out.join('\n');
|
|
189
|
+
}
|
package/src/server.js
CHANGED
|
@@ -25,7 +25,7 @@ import { Studio } from './studio.js';
|
|
|
25
25
|
import { Memory } from './memory.js';
|
|
26
26
|
import { doctor } from './doctor.js';
|
|
27
27
|
import { Triggers, tokenMatches } from './triggers.js';
|
|
28
|
-
import { listEngines, registerEngine } from './engines/index.js';
|
|
28
|
+
import { listEngines, registerAcpEngines, registerEngine } from './engines/index.js';
|
|
29
29
|
import { Plugins, pluginSpecs } from './plugins.js';
|
|
30
30
|
import { HubLink, linkUrls } from './hublink.js';
|
|
31
31
|
import { Notifier, isWorkflowUrl, loadNotify, publicNotify, saveNotify, sendSlack } from './notify.js';
|
|
@@ -34,6 +34,7 @@ import { LiveBrowser } from './livebrowser.js';
|
|
|
34
34
|
import * as remote from './remote.js';
|
|
35
35
|
import * as service from './service.js';
|
|
36
36
|
import { jiraView, saveJira } from './jira.js';
|
|
37
|
+
import { CONTAINER_ENGINES, buildImage, claudeToken, dockerStatus, isBuilding, saveSecrets } from './container.js';
|
|
37
38
|
import { Store } from './store.js';
|
|
38
39
|
import { about, latestRelease } from './about.js';
|
|
39
40
|
|
|
@@ -63,7 +64,7 @@ function isAllowedOrigin(origin, port) {
|
|
|
63
64
|
const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
|
|
64
65
|
// Updates are not here: a signed-in device (a phone) can install the newest release too. It only
|
|
65
66
|
// ever installs an official release from where this copy came from (git tag or npm) and restarts.
|
|
66
|
-
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|service$|jira$|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
|
|
67
|
+
const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|service$|jira$|isolation\/|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
|
|
67
68
|
const HOOK_PATH = '/api/hooks/agents';
|
|
68
69
|
const NO_REPO_KINDS = new Set(['general', 'assistant']);
|
|
69
70
|
|
|
@@ -73,6 +74,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
73
74
|
const store = new Store(paths.state);
|
|
74
75
|
const plugins = await new Plugins({ log: (m) => console.log(`[agentmgr] ${m}`) }).load(pluginSpecs(config), { config, base: paths.home });
|
|
75
76
|
for (const { def } of plugins.engines) registerEngine(def);
|
|
77
|
+
// ACP agents (Gemini CLI, OpenCode, Goose, … and config's "acp") become engines.
|
|
78
|
+
registerAcpEngines(config);
|
|
76
79
|
const manager = new AgentManager({ store, config, plugins });
|
|
77
80
|
const backlog = new Backlog({ manager, store, config });
|
|
78
81
|
const races = new Races({ manager, store });
|
|
@@ -241,6 +244,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
241
244
|
}),
|
|
242
245
|
);
|
|
243
246
|
api.get('/agents/:id/specs/:name', wrap((req) => manager.specDoc(req.params.id, req.params.name)));
|
|
247
|
+
api.post('/agents/:id/scorecard/post', wrap((req) => review.postScorecard(req.params.id)));
|
|
244
248
|
api.post('/agents/:id/plan', wrap((req) => manager.startPlanning(req.params.id)));
|
|
245
249
|
api.post('/agents/:id/plan/approve', wrap((req) => manager.approvePlan(req.params.id, req.body?.note)));
|
|
246
250
|
api.post('/agents/:id/plan/changes', wrap((req) => manager.requestPlanChanges(req.params.id, req.body?.note)));
|
|
@@ -512,6 +516,28 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
512
516
|
api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
|
|
513
517
|
api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
|
|
514
518
|
api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
|
|
519
|
+
// Isolation: Docker, the agent image, the Claude token for containers, and the default for new agents.
|
|
520
|
+
let buildError = null;
|
|
521
|
+
const isolationView = async () => ({ ...(await dockerStatus(config)), building: isBuilding(), buildError, hasClaudeToken: Boolean(claudeToken()), engines: [...CONTAINER_ENGINES], default: config.isolation?.default ?? null });
|
|
522
|
+
api.get('/isolation', wrap(isolationView));
|
|
523
|
+
api.post(
|
|
524
|
+
'/isolation/build',
|
|
525
|
+
wrap(async () => {
|
|
526
|
+
buildError = null;
|
|
527
|
+
buildImage(config).catch((err) => (buildError = err.message));
|
|
528
|
+
return isolationView();
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
api.put('/isolation/token', wrap(async (req) => (saveSecrets({ claudeOauthToken: String(req.body?.token ?? '').trim() || null }), isolationView())));
|
|
532
|
+
api.put(
|
|
533
|
+
'/isolation/default',
|
|
534
|
+
wrap(async (req) => {
|
|
535
|
+
const value = req.body?.container ? 'container' : null;
|
|
536
|
+
config.isolation = { ...config.isolation, default: value };
|
|
537
|
+
saveConfig({ isolation: config.isolation });
|
|
538
|
+
return isolationView();
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
515
541
|
// Jira: the site and API token used to show an issue's summary for agents created from its link.
|
|
516
542
|
api.get('/jira', wrap(() => jiraView()));
|
|
517
543
|
api.put(
|
|
@@ -970,6 +996,14 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
970
996
|
}
|
|
971
997
|
manager.refreshAll().catch(() => {});
|
|
972
998
|
hubLink.start();
|
|
999
|
+
// A cloud runner joins its hub by itself on first start (AGENTMGR_HUB_URL and AGENTMGR_HUB_CODE: an
|
|
1000
|
+
// invite, or the hub's runner token), and stays joined across restarts (the link is kept on disk).
|
|
1001
|
+
if (process.env.AGENTMGR_HUB_URL && process.env.AGENTMGR_HUB_CODE && !hubLink.status().joined) {
|
|
1002
|
+
hubLink
|
|
1003
|
+
.join(process.env.AGENTMGR_HUB_URL, process.env.AGENTMGR_HUB_CODE, process.env.AGENTMGR_RUNNER_NAME || undefined)
|
|
1004
|
+
.then((st) => console.log(`[agentmgr] joined the hub ${st.url} as ${st.name}`))
|
|
1005
|
+
.catch((err) => console.error(`[agentmgr] could not join the hub ${process.env.AGENTMGR_HUB_URL}: ${err.message}`));
|
|
1006
|
+
}
|
|
973
1007
|
return {
|
|
974
1008
|
hubLink,
|
|
975
1009
|
server,
|
package/src/trust.js
CHANGED
|
@@ -16,9 +16,8 @@ export function claudeConfigFile() {
|
|
|
16
16
|
* before writing, only written when something changes, and replaced atomically.
|
|
17
17
|
* Returns true if it changed anything.
|
|
18
18
|
*/
|
|
19
|
-
export function trustFolder(dir) {
|
|
19
|
+
export function trustFolder(dir, file = claudeConfigFile()) {
|
|
20
20
|
const real = fs.realpathSync(dir);
|
|
21
|
-
const file = claudeConfigFile();
|
|
22
21
|
let raw = '{}';
|
|
23
22
|
try {
|
|
24
23
|
raw = fs.readFileSync(file, 'utf8');
|