atris 3.69.0 → 3.71.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/README.md +40 -1
- package/atris/skills/README.md +1 -0
- package/atris/skills/campaigns/SKILL.md +81 -0
- package/atris/skills/meta/SKILL.md +52 -8
- package/ax +226 -42
- package/bin/atris.js +26 -6
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +759 -0
- package/commands/compile.js +19 -8
- package/commands/doctor.js +58 -0
- package/commands/dream.js +18 -3
- package/commands/engine.js +147 -5
- package/commands/improve.js +119 -9
- package/commands/init.js +18 -1
- package/commands/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +433 -99
- package/commands/mission.js +332 -41
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +132 -16
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +148 -18
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -0
- package/lib/auto-accept-certified.js +1 -0
- package/lib/autoland.js +13 -4
- package/lib/campaign-cadence.js +131 -0
- package/lib/campaign-core.js +330 -0
- package/lib/campaign-holdout.js +65 -0
- package/lib/campaign-loop.js +242 -0
- package/lib/campaign-missions.js +237 -0
- package/lib/campaign-new.js +251 -0
- package/lib/campaign-protect.js +75 -0
- package/lib/campaign-reading.js +147 -0
- package/lib/campaign-schema.js +196 -0
- package/lib/campaign-store.js +380 -0
- package/lib/campaign-sync.js +302 -0
- package/lib/campaign-trace.js +300 -0
- package/lib/conductor-artifacts.js +15 -1
- package/lib/daily-log.js +11 -1
- package/lib/dispatch-scout.js +18 -0
- package/lib/engine-ask.js +35 -4
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- package/lib/engine-validate.js +189 -9
- package/lib/fleet.js +125 -16
- package/lib/init-commit.js +80 -0
- package/lib/intents.js +8 -0
- package/lib/known-commands.js +1 -1
- package/lib/land-green.js +58 -7
- package/lib/landing-proof.js +352 -0
- package/lib/member-autoland.js +479 -10
- package/lib/member-board.js +86 -10
- package/lib/member-map-fold.js +126 -0
- package/lib/member-map-inbox.js +80 -0
- package/lib/member-progress.js +48 -1
- package/lib/member-report.js +433 -0
- package/lib/member-scaffold.js +1 -2
- package/lib/member-self-next-job.js +97 -0
- package/lib/member-switches.js +7 -0
- package/lib/member-task-pick.js +41 -14
- package/lib/member-up.js +249 -16
- package/lib/roster-prep.js +25 -2
- package/lib/runner-command.js +5 -0
- package/lib/task-db.js +63 -11
- package/lib/task-explanation.js +7 -0
- package/lib/task-list-keeper.js +8 -1
- package/lib/task-receipt.js +6 -1
- package/mcp/atris-mcp/index.mjs +212 -10
- package/package.json +2 -1
- package/scripts/det/engine-reading.js +37 -0
- package/scripts/det/ytnotes +29 -5
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Missions that belong to a campaign (build.md Phase 3). commands/mission.js
|
|
2
|
+
// calls these at start and on every save; a mission with no campaign_id never
|
|
3
|
+
// reaches the store, so plain missions run exactly as before.
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const { decide } = require('./campaign-core');
|
|
8
|
+
const { getCampaign, openCampaignStore } = require('./campaign-store');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_RESERVE_USD = 5;
|
|
11
|
+
const CLOSED_STATES = new Set(['done', 'out_of_budget', 'paused']);
|
|
12
|
+
|
|
13
|
+
function storeFor(root, deps = {}) {
|
|
14
|
+
return deps.store || openCampaignStore(root);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function warn(message) {
|
|
18
|
+
process.stderr.write(`campaign: ${message}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// How much a new mission holds back: what missions in this campaign have cost
|
|
22
|
+
// on average, or $5 before any has ended. --reserve overrides.
|
|
23
|
+
function defaultReserve(view) {
|
|
24
|
+
const ended = view.missions.filter((m) => m.status === 'ended' && typeof m.cost_usd === 'number');
|
|
25
|
+
if (!ended.length) return DEFAULT_RESERVE_USD;
|
|
26
|
+
const average = ended.reduce((sum, m) => sum + m.cost_usd, 0) / ended.length;
|
|
27
|
+
return Math.max(0.01, Math.ceil(average * 100) / 100);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Checked before a mission creates anything, so a refusal leaves nothing behind.
|
|
31
|
+
function checkCampaignStart(root, campaignId, options = {}, deps = {}) {
|
|
32
|
+
const store = storeFor(root, deps);
|
|
33
|
+
const view = getCampaign(store, campaignId);
|
|
34
|
+
if (!view) return { ok: false, error: `no campaign ${campaignId}. See them with: atris campaign` };
|
|
35
|
+
if (view.error) return { ok: false, error: `campaign ${campaignId} cannot be read: ${view.error}` };
|
|
36
|
+
if (CLOSED_STATES.has(view.state)) {
|
|
37
|
+
return { ok: false, error: `campaign ${campaignId} is ${view.state.replace(/_/g, ' ')}, so it takes no new missions` };
|
|
38
|
+
}
|
|
39
|
+
if (options.missionId) {
|
|
40
|
+
const owner = store.campaignForMission(options.missionId);
|
|
41
|
+
if (owner && owner !== campaignId) return { ok: false, error: `mission ${options.missionId} already belongs to campaign ${owner}` };
|
|
42
|
+
}
|
|
43
|
+
const reserve = options.reserveUsd !== undefined && options.reserveUsd !== null && options.reserveUsd !== ''
|
|
44
|
+
? Number(options.reserveUsd)
|
|
45
|
+
: defaultReserve(view);
|
|
46
|
+
if (!(reserve >= 0) || !Number.isFinite(reserve)) return { ok: false, error: '--reserve takes dollars, for example --reserve 5' };
|
|
47
|
+
if (reserve > view.budget.available) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: `campaign ${campaignId} has $${view.budget.available.toFixed(2)} left and this mission would hold $${reserve.toFixed(2)}. Add money with: atris campaign budget ${campaignId} --add <dollars>`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, campaign: view, reserve };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Hold the reserve in one transaction with the check, so missions starting at
|
|
57
|
+
// the same moment can never hold more than the campaign has left.
|
|
58
|
+
function reserveCampaignMission(root, campaignId, missionId, amount, actor, deps = {}) {
|
|
59
|
+
if (!(amount > 0)) return null;
|
|
60
|
+
const store = storeFor(root, deps);
|
|
61
|
+
return store.append({ campaign_id: campaignId, type: 'budget_reserved', actor, data: { mission_id: missionId, amount_usd: amount } }, {
|
|
62
|
+
check: (view) => {
|
|
63
|
+
if (view.error) throw Object.assign(new Error(`campaign ${campaignId} cannot be read: ${view.error}`), { code: 'campaign_unreadable' });
|
|
64
|
+
if (CLOSED_STATES.has(view.state)) throw Object.assign(new Error(`campaign ${campaignId} is ${view.state.replace(/_/g, ' ')}, so it takes no new missions`), { code: 'campaign_closed' });
|
|
65
|
+
if (amount > view.budget.available) {
|
|
66
|
+
throw Object.assign(new Error(`campaign ${campaignId} has $${view.budget.available.toFixed(2)} left and this mission would hold $${amount.toFixed(2)}. Add money with: atris campaign budget ${campaignId} --add <dollars>`), { code: 'campaign_tank' });
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Before each tick: a mission stops working when its campaign is done,
|
|
73
|
+
// paused, or out of money. Returns the pause reason, or null to keep going.
|
|
74
|
+
function campaignGate(root, mission, deps = {}) {
|
|
75
|
+
if (!mission || !mission.campaign_id) return null;
|
|
76
|
+
try {
|
|
77
|
+
const view = getCampaign(storeFor(root, deps), mission.campaign_id);
|
|
78
|
+
if (!view || view.error) return null;
|
|
79
|
+
if (CLOSED_STATES.has(view.state)) return `campaign-${view.state.replace(/_/g, '-')}`;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readMissionLedger(root) {
|
|
87
|
+
const file = require('path').join(root, '.atris', 'state', 'missions.jsonl');
|
|
88
|
+
const latest = new Map();
|
|
89
|
+
let text = '';
|
|
90
|
+
try { text = require('fs').readFileSync(file, 'utf8'); } catch { return latest; }
|
|
91
|
+
for (const line of text.split('\n')) {
|
|
92
|
+
if (!line.trim()) continue;
|
|
93
|
+
try {
|
|
94
|
+
const row = JSON.parse(line);
|
|
95
|
+
if (row && row.id) latest.set(row.id, row);
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
return latest;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Missions the campaign still counts as running whose mission record says
|
|
102
|
+
// they are over, or that never got a record (a start that crashed after its
|
|
103
|
+
// reserve): end them in the campaign so their reserve comes back.
|
|
104
|
+
function reconcileCampaign(root, campaignId, terminalStatuses, deps = {}) {
|
|
105
|
+
const store = storeFor(root, deps);
|
|
106
|
+
const view = getCampaign(store, campaignId);
|
|
107
|
+
if (!view || view.error) return [];
|
|
108
|
+
const ledger = (deps.readLedger || readMissionLedger)(root);
|
|
109
|
+
const ended = [];
|
|
110
|
+
const reservedOnly = new Set();
|
|
111
|
+
for (const event of store.read(campaignId)) {
|
|
112
|
+
if (event.type === 'budget_reserved' && !view.missions.some((m) => m.id === event.data.mission_id)) reservedOnly.add(event.data.mission_id);
|
|
113
|
+
if (event.type === 'budget_released') reservedOnly.delete(event.data.mission_id);
|
|
114
|
+
}
|
|
115
|
+
for (const missionId of reservedOnly) {
|
|
116
|
+
if (ledger.has(missionId)) continue;
|
|
117
|
+
store.append({ campaign_id: campaignId, type: 'budget_released', actor: 'system', data: { mission_id: missionId, reason: 'mission was never created' } });
|
|
118
|
+
ended.push(missionId);
|
|
119
|
+
}
|
|
120
|
+
for (const m of view.missions) {
|
|
121
|
+
if (m.status !== 'running' || m.id.startsWith('plan-')) continue;
|
|
122
|
+
const record = ledger.get(m.id);
|
|
123
|
+
if (record && !terminalStatuses.has(record.status)) continue;
|
|
124
|
+
if (!record && !deps.endMissing) continue;
|
|
125
|
+
const result = recordCampaignMissionEnded(root, { status: 'running' }, { ...(record || { id: m.id, owner: m.member, status: 'stopped' }), campaign_id: campaignId }, terminalStatuses, deps);
|
|
126
|
+
if (result) ended.push(m.id);
|
|
127
|
+
}
|
|
128
|
+
return ended;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function memberOf(mission) {
|
|
132
|
+
return mission.owner || mission.executed_by || 'mission-lead';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// After the mission is saved: it starts in the campaign and holds its reserve.
|
|
136
|
+
function recordCampaignMissionStarted(root, mission, reserve, deps = {}) {
|
|
137
|
+
if (!mission || !mission.campaign_id) return null;
|
|
138
|
+
try {
|
|
139
|
+
const store = storeFor(root, deps);
|
|
140
|
+
const actor = memberOf(mission);
|
|
141
|
+
store.append({
|
|
142
|
+
campaign_id: mission.campaign_id, type: 'mission_started', actor,
|
|
143
|
+
data: {
|
|
144
|
+
mission_id: mission.id, member: actor, objective: String(mission.objective || '').slice(0, 200),
|
|
145
|
+
...(mission.runner && mission.runner !== 'auto' ? { engine: mission.runner } : {}),
|
|
146
|
+
...(mission.model ? { model: mission.model } : {}),
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
if (reserve > 0) {
|
|
150
|
+
store.append({ campaign_id: mission.campaign_id, type: 'budget_reserved', actor, data: { mission_id: mission.id, amount_usd: reserve } });
|
|
151
|
+
}
|
|
152
|
+
return getCampaign(store, mission.campaign_id);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
warn(`mission ${mission.id} started, but campaign ${mission.campaign_id} did not record it: ${error.message}`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Each tick writes a step into the campaign with the dollars its engine
|
|
160
|
+
// reported, so the campaign's own log holds the spend: no lookback window, and
|
|
161
|
+
// every runner counts, including ones that run without a roster pick.
|
|
162
|
+
function recordCampaignTick(root, mission, usage = {}, deps = {}) {
|
|
163
|
+
if (!mission || !mission.campaign_id) return;
|
|
164
|
+
const data = { mission_id: mission.id };
|
|
165
|
+
if (typeof usage.cost_usd === 'number' && Number.isFinite(usage.cost_usd)) data.cost_usd = usage.cost_usd;
|
|
166
|
+
if (typeof usage.tokens === 'number' && Number.isFinite(usage.tokens)) data.tokens = usage.tokens;
|
|
167
|
+
// Which engine did the work: traces and per-engine cost need it.
|
|
168
|
+
if (typeof usage.engine === 'string' && usage.engine) data.engine = usage.engine;
|
|
169
|
+
try {
|
|
170
|
+
storeFor(root, deps).append({ campaign_id: mission.campaign_id, type: 'step', actor: memberOf(mission), data });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
warn(`a tick of mission ${mission.id} was not recorded in campaign ${mission.campaign_id}: ${error.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Dollars the mission's ticks reported, from the campaign's own steps. Ticks
|
|
177
|
+
// without a dollar figure (engines that print tokens only) are counted so the
|
|
178
|
+
// campaign can say spend is partial.
|
|
179
|
+
function missionCost(root, missionId, deps = {}) {
|
|
180
|
+
const store = storeFor(root, deps);
|
|
181
|
+
const campaignId = store.campaignForMission(missionId);
|
|
182
|
+
const rows = campaignId
|
|
183
|
+
? store.read(campaignId).filter((e) => e.type === 'step' && e.data.mission_id === missionId).map((e) => e.data)
|
|
184
|
+
: [];
|
|
185
|
+
let cents = 0;
|
|
186
|
+
let uncosted = 0;
|
|
187
|
+
// A cost that is not a real, non-negative number never blocks the mission's
|
|
188
|
+
// end; that tick counts as one without a dollar figure.
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
if (typeof row.cost_usd === 'number' && Number.isFinite(row.cost_usd) && row.cost_usd >= 0) cents += Math.floor(row.cost_usd * 100 + 0.5);
|
|
191
|
+
else uncosted += 1;
|
|
192
|
+
}
|
|
193
|
+
return { cost_usd: cents / 100, runs: rows.length, uncosted_runs: uncosted };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function outcomeFor(mission) {
|
|
197
|
+
return mission.status === 'complete' ? 'kept' : 'failed';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Called from saveMission with the record before and after. Only the step
|
|
201
|
+
// into a final status ends the mission in its campaign.
|
|
202
|
+
function recordCampaignMissionEnded(root, previous, next, terminalStatuses, deps = {}) {
|
|
203
|
+
if (!next || !next.campaign_id || !terminalStatuses.has(next.status)) return null;
|
|
204
|
+
if (previous && terminalStatuses.has(previous.status)) return null;
|
|
205
|
+
try {
|
|
206
|
+
const store = storeFor(root, deps);
|
|
207
|
+
const view = getCampaign(store, next.campaign_id);
|
|
208
|
+
const known = view && !view.error && view.missions.find((m) => m.id === next.id);
|
|
209
|
+
if (known && known.status === 'ended') return null;
|
|
210
|
+
const cost = missionCost(root, next.id, deps);
|
|
211
|
+
const actor = memberOf(next);
|
|
212
|
+
store.append({
|
|
213
|
+
campaign_id: next.campaign_id, type: 'mission_ended', actor,
|
|
214
|
+
data: {
|
|
215
|
+
mission_id: next.id, outcome: outcomeFor(next), cost_usd: cost.cost_usd,
|
|
216
|
+
runs: cost.runs, uncosted_runs: cost.uncosted_runs, receipt_path: next.receipt_path || null,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
const after = getCampaign(store, next.campaign_id);
|
|
220
|
+
return after ? { campaign: after, next: decide(after), cost } : null;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
warn(`mission ${next.id} ended, but campaign ${next.campaign_id} did not record it: ${error.message}`);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
campaignGate,
|
|
229
|
+
reconcileCampaign,
|
|
230
|
+
reserveCampaignMission,
|
|
231
|
+
checkCampaignStart,
|
|
232
|
+
defaultReserve,
|
|
233
|
+
missionCost,
|
|
234
|
+
recordCampaignMissionEnded,
|
|
235
|
+
recordCampaignMissionStarted,
|
|
236
|
+
recordCampaignTick,
|
|
237
|
+
};
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// atris campaign new "<goal>": the owner's engine drafts a judge for the goal,
|
|
2
|
+
// the CLI runs it to prove it fails today, shows the draft, and with --yes
|
|
3
|
+
// starts the campaign. A judge is a script outside the model: exit 0 means the
|
|
4
|
+
// goal is met. Asking goes through the read-only engine ask runner, the same
|
|
5
|
+
// way the campaign loop asks the owner for missions.
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { newCampaignId } = require('./campaign-store');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BUDGET = 20;
|
|
14
|
+
const DEFAULT_OWNER = 'mission-lead';
|
|
15
|
+
const DRAFT_TIMEOUT_SECONDS = 10 * 60;
|
|
16
|
+
const ASK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
17
|
+
const ATTEMPTS = 2;
|
|
18
|
+
const MAX_SEATS = 3;
|
|
19
|
+
const MAX_SCRIPT_BYTES = 8 * 1024;
|
|
20
|
+
|
|
21
|
+
function money(value) {
|
|
22
|
+
return `$${Number(value).toFixed(2)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shellQuote(value) {
|
|
26
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Members with a MEMBER.md can take a seat.
|
|
30
|
+
function teamMembers(root) {
|
|
31
|
+
const dir = path.join(root, 'atris', 'team');
|
|
32
|
+
try {
|
|
33
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
34
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('_') && fs.existsSync(path.join(dir, entry.name, 'MEMBER.md')))
|
|
35
|
+
.map((entry) => entry.name)
|
|
36
|
+
.sort();
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A few facts about the workspace so the judge uses commands that exist here.
|
|
43
|
+
function workspaceHints(root) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
try {
|
|
46
|
+
const entries = fs.readdirSync(root).filter((name) => !name.startsWith('.')).sort().slice(0, 30);
|
|
47
|
+
if (entries.length) lines.push(`Top-level files: ${entries.join(', ')}`);
|
|
48
|
+
} catch {}
|
|
49
|
+
try {
|
|
50
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
51
|
+
const scripts = Object.entries(pkg.scripts || {}).slice(0, 15).map(([name, cmd]) => ` npm run ${name}: ${String(cmd).slice(0, 120)}`);
|
|
52
|
+
if (scripts.length) lines.push('package.json scripts:', ...scripts);
|
|
53
|
+
} catch {}
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function judgePrompt({ goal, members, hints, previous }) {
|
|
58
|
+
return [
|
|
59
|
+
'You write the judge for a new Atris campaign. Answer with JSON only.',
|
|
60
|
+
'',
|
|
61
|
+
`Goal: ${goal}`,
|
|
62
|
+
...hints,
|
|
63
|
+
members.length ? `Members who can work on it: ${members.join(', ')}` : '',
|
|
64
|
+
'',
|
|
65
|
+
'A judge is a POSIX sh script that runs from the workspace root. It exits 0 only when the goal is met.',
|
|
66
|
+
'Today the goal is not met, so today it must exit non-zero.',
|
|
67
|
+
'Rules:',
|
|
68
|
+
'- Check real outcomes outside any model: run tests, count files, grep output, run a local command. Never ask a model whether it is done.',
|
|
69
|
+
'- Print graded progress so a person sees how close it is: a line "ℹ pass N" and a line "ℹ fail M" (checks met, checks not met).',
|
|
70
|
+
'- Read and run only; change nothing. Finish in under two minutes. Use only commands that exist here.',
|
|
71
|
+
previous ? `Your last draft did not work: ${previous}. Write a different judge.` : '',
|
|
72
|
+
'',
|
|
73
|
+
'Reply with one JSON object and nothing else:',
|
|
74
|
+
`{"title": "<short name, under 60 characters>", "judge_script": "<the whole sh script>", "why": "<one sentence: what it checks and why it fails today>", "seats": ["<up to ${MAX_SEATS} members from the list who should work on this>"]}`,
|
|
75
|
+
].filter((line) => line !== '').join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseDraft(text, members) {
|
|
79
|
+
const match = String(text || '').match(/\{[\s\S]*\}/);
|
|
80
|
+
if (!match) return null;
|
|
81
|
+
let row;
|
|
82
|
+
try {
|
|
83
|
+
row = JSON.parse(match[0]);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (!row || typeof row.judge_script !== 'string' || !row.judge_script.trim()) return null;
|
|
88
|
+
if (Buffer.byteLength(row.judge_script) > MAX_SCRIPT_BYTES) return null;
|
|
89
|
+
const known = new Set(members);
|
|
90
|
+
const seats = Array.isArray(row.seats) ? [...new Set(row.seats.filter((s) => typeof s === 'string' && known.has(s)))].slice(0, MAX_SEATS) : [];
|
|
91
|
+
return {
|
|
92
|
+
title: typeof row.title === 'string' && row.title.trim() ? row.title.trim().slice(0, 80) : '',
|
|
93
|
+
script: row.judge_script.trim(),
|
|
94
|
+
why: typeof row.why === 'string' ? row.why.trim().slice(0, 300) : '',
|
|
95
|
+
seats,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The owner's engine through the read-only ask runner.
|
|
100
|
+
async function askOwner(root, view, prompt) {
|
|
101
|
+
const { resolveEngineForMember } = require('./member-engine');
|
|
102
|
+
const { runEngineAskJobs } = require('./engine-ask');
|
|
103
|
+
const pick = resolveEngineForMember(view.owner, root) || {};
|
|
104
|
+
const engine = (pick.engine && pick.engine.id) || 'claude';
|
|
105
|
+
const [answer] = await runEngineAskJobs([{ engine, model: pick.model || '', prompt, label: 'campaign new judge' }], { root, timeoutMs: ASK_TIMEOUT_MS });
|
|
106
|
+
return { text: (answer && answer.stdout) || '', engine, ok: Boolean(answer && answer.ok) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeJudge(root, id, script) {
|
|
110
|
+
const rel = path.join('atris', 'campaigns', id, 'judge.sh');
|
|
111
|
+
const file = path.join(root, rel);
|
|
112
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
113
|
+
const body = script.startsWith('#!') ? script : `#!/bin/sh\n${script}`;
|
|
114
|
+
fs.writeFileSync(file, body.endsWith('\n') ? body : `${body}\n`, { mode: 0o755 });
|
|
115
|
+
return rel.split(path.sep).join('/');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 126 and 127 mean the shell could not run a command; null means it hung or
|
|
119
|
+
// never started. Those are broken judges, not failing ones.
|
|
120
|
+
function brokenReading(reading) {
|
|
121
|
+
return reading.exit === null || reading.exit === undefined || reading.exit === 126 || reading.exit === 127;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Ask, write, run; one retry with the reason when the draft passes today,
|
|
125
|
+
// cannot run, or is not a script.
|
|
126
|
+
async function draftJudge({ root, goal, owner, idFlag, timeout, ask, judge }) {
|
|
127
|
+
const members = teamMembers(root);
|
|
128
|
+
const hints = workspaceHints(root);
|
|
129
|
+
let previous = '';
|
|
130
|
+
let id = idFlag || '';
|
|
131
|
+
let last = null;
|
|
132
|
+
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
|
133
|
+
const prompt = judgePrompt({ goal, members, hints, previous });
|
|
134
|
+
const answer = await ask(root, { id: id || 'new', owner, title: goal }, prompt);
|
|
135
|
+
const draft = parseDraft(answer.text, members);
|
|
136
|
+
if (!draft) {
|
|
137
|
+
previous = 'the reply was not one JSON object with a judge_script';
|
|
138
|
+
last = { error: `${owner}'s engine (${answer.engine || 'unknown'}) did not return a judge script` };
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!draft.title) draft.title = goal.slice(0, 80);
|
|
142
|
+
if (!id) id = newCampaignId(draft.title);
|
|
143
|
+
const judgeFile = writeJudge(root, id, draft.script);
|
|
144
|
+
const command = `sh ${judgeFile}`;
|
|
145
|
+
const reading = await judge(command, timeout);
|
|
146
|
+
const result = { id, draft, judgeFile, command, reading, engine: answer.engine };
|
|
147
|
+
if (reading.passed) {
|
|
148
|
+
previous = `it passed today (${reading.summary || 'exit 0'}), so it cannot tell when the goal is met`;
|
|
149
|
+
last = { ...result, error: `the drafted judge already passes (${reading.summary || 'exit 0'}); a judge must fail first` };
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (brokenReading(reading)) {
|
|
153
|
+
previous = `it could not run (${reading.summary || `exit ${reading.exit}`})`;
|
|
154
|
+
last = { ...result, error: `the drafted judge could not run: ${reading.summary || `exit ${reading.exit}`}` };
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
return { ok: true, ...result };
|
|
158
|
+
}
|
|
159
|
+
if (id && !idFlag) fs.rmSync(path.join(root, 'atris', 'campaigns', id), { recursive: true, force: true });
|
|
160
|
+
return { ok: false, ...last };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function startArgs(result, { budget, owner, run }) {
|
|
164
|
+
const args = ['start', result.draft.title, '--judge', result.command, '--budget', String(budget), '--id', result.id, '--owner', owner];
|
|
165
|
+
for (const seat of result.draft.seats) args.push('--seat', seat);
|
|
166
|
+
if (run) args.push('--run');
|
|
167
|
+
return args;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function startCommand(result, options) {
|
|
171
|
+
const seats = result.draft.seats.map((seat) => ` --seat ${seat}`).join('');
|
|
172
|
+
return `atris campaign start ${shellQuote(result.draft.title)} --judge ${shellQuote(result.command)} --budget ${options.budget} --id ${result.id} --owner ${options.owner}${seats}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderDraft(result, options) {
|
|
176
|
+
const lines = [
|
|
177
|
+
`draft for: ${options.goal}`,
|
|
178
|
+
'',
|
|
179
|
+
`title ${result.draft.title}`,
|
|
180
|
+
`judge ${result.command}`,
|
|
181
|
+
`today fails, as it should: ${result.reading.summary || `exit ${result.reading.exit}`}`,
|
|
182
|
+
];
|
|
183
|
+
if (result.draft.why) lines.push(`checks ${result.draft.why}`);
|
|
184
|
+
lines.push(`owner ${options.owner}`);
|
|
185
|
+
lines.push(`seats ${result.draft.seats.length ? result.draft.seats.join(', ') : 'none yet'}`);
|
|
186
|
+
lines.push(`budget ${money(options.budget)}`);
|
|
187
|
+
lines.push('', `read or edit the judge: ${result.judgeFile}`);
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function askAtTerminal(question) {
|
|
192
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) return Promise.resolve(false);
|
|
193
|
+
const readline = require('readline');
|
|
194
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
195
|
+
return new Promise((resolve) => rl.question(question, (answer) => {
|
|
196
|
+
rl.close();
|
|
197
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// deps.start is the start verb; deps.ask replaces the owner's engine in tests.
|
|
202
|
+
async function draftAndStart(args, ctx, deps = {}) {
|
|
203
|
+
const { readFlag } = require('./arg-parser');
|
|
204
|
+
const goal = String(deps.goal || '').trim();
|
|
205
|
+
if (!goal) return ctx.fail('say the goal: atris campaign new "<goal>" [--budget 20] [--owner <member>] [--yes] [--run]');
|
|
206
|
+
const budgetText = readFlag(args, '--budget', '');
|
|
207
|
+
const budget = budgetText ? Number(budgetText) : DEFAULT_BUDGET;
|
|
208
|
+
if (!(budget > 0)) return ctx.fail('--budget must be dollars above 0');
|
|
209
|
+
const owner = readFlag(args, '--owner', '') || DEFAULT_OWNER;
|
|
210
|
+
const timeout = Number(readFlag(args, '--timeout', '')) || DRAFT_TIMEOUT_SECONDS;
|
|
211
|
+
const options = { goal, budget, owner, run: args.includes('--run') };
|
|
212
|
+
if (!ctx.asJson) console.error(`asking ${owner}'s engine to draft a judge that fails today`);
|
|
213
|
+
|
|
214
|
+
const result = await draftJudge({
|
|
215
|
+
root: deps.root || process.cwd(), goal, owner, timeout,
|
|
216
|
+
idFlag: readFlag(args, '--id', ''),
|
|
217
|
+
ask: deps.ask || askOwner,
|
|
218
|
+
judge: ctx.judge,
|
|
219
|
+
});
|
|
220
|
+
if (!result.ok) {
|
|
221
|
+
if (ctx.asJson) {
|
|
222
|
+
console.log(JSON.stringify({ ok: false, error: result.error, draft: result.draft || null, reading: result.reading || null }));
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
return ctx.fail(`${result.error}. Write the judge yourself: atris campaign start "<title>" --judge "<command that fails today>" --budget ${budget}`, 1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const draft = { id: result.id, title: result.draft.title, judge: result.command, judge_file: result.judgeFile, why: result.draft.why, seats: result.draft.seats, owner, budget, engine: result.engine };
|
|
229
|
+
// Only a person at a terminal can say yes here; agents and pipes pass --yes.
|
|
230
|
+
const confirm = deps.confirm || askAtTerminal;
|
|
231
|
+
const canAsk = deps.confirm ? true : Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
232
|
+
const asked = !args.includes('--yes') && !ctx.asJson && canAsk;
|
|
233
|
+
const yes = args.includes('--yes') || (asked && await confirm(`${renderDraft(result, options)}\n\nstart it${options.run ? ' and run it' : ''}? [y/N] `));
|
|
234
|
+
if (!yes) {
|
|
235
|
+
const command = startCommand(result, options);
|
|
236
|
+
const shown = asked ? 'not started.' : renderDraft(result, options);
|
|
237
|
+
ctx.out(`${shown}\nstart it: ${command}\n(or run this again with --yes)`,
|
|
238
|
+
{ ok: true, action: 'campaign_drafted', draft, reading: result.reading, start_command: command });
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
// Start with the reading already taken, so the judge does not run twice.
|
|
242
|
+
if (!ctx.asJson && args.includes('--yes')) console.log(renderDraft(result, options));
|
|
243
|
+
const startCtx = {
|
|
244
|
+
...ctx,
|
|
245
|
+
judge: async () => result.reading,
|
|
246
|
+
out: (human, json) => ctx.out(human, { ...json, draft }),
|
|
247
|
+
};
|
|
248
|
+
return deps.start(startArgs(result, options), startCtx);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { draftAndStart, judgePrompt, parseDraft };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Protected paths: files a campaign's agents must not touch, like the judge's
|
|
2
|
+
// questions. Start saves the paths and the commit they started from in the
|
|
3
|
+
// campaign folder beside the campaign store, outside the checkout, so agents
|
|
4
|
+
// cannot edit the list; guard names any of them changed since, committed or not.
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
function git(root, args) {
|
|
13
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
14
|
+
return { ok: result.status === 0, out: (result.stdout || '').trim(), err: (result.stderr || '').trim() };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function checkoutTop(root) {
|
|
18
|
+
const top = git(root, ['rev-parse', '--show-toplevel']);
|
|
19
|
+
return top.ok ? fs.realpathSync(top.out) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The folder holding every campaign's own files, beside the campaign store.
|
|
23
|
+
function campaignHome(store) {
|
|
24
|
+
const { campaignDbPath } = require('./campaign-store');
|
|
25
|
+
return path.dirname((store && store.path) || campaignDbPath());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function protectFile(home, campaignId) {
|
|
29
|
+
return path.join(home, 'campaigns', campaignId, 'protect.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The commit and checkout-relative paths to save, or an error in plain words.
|
|
33
|
+
function planProtect(root, paths) {
|
|
34
|
+
const topDir = checkoutTop(root);
|
|
35
|
+
if (!topDir) return { error: '--protect needs a git checkout' };
|
|
36
|
+
const head = git(root, ['rev-parse', 'HEAD']);
|
|
37
|
+
if (!head.ok) return { error: '--protect needs a checkout with at least one commit' };
|
|
38
|
+
const here = fs.realpathSync(root);
|
|
39
|
+
const rels = [];
|
|
40
|
+
for (const given of paths) {
|
|
41
|
+
const rel = path.relative(topDir, path.resolve(here, given)).split(path.sep).join('/');
|
|
42
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return { error: `--protect ${given} is not inside this checkout` };
|
|
43
|
+
if (!rels.includes(rel)) rels.push(rel);
|
|
44
|
+
}
|
|
45
|
+
return { paths: rels, base_commit: head.out };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function saveProtect(home, campaignId, plan) {
|
|
49
|
+
const file = protectFile(home, campaignId);
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
fs.writeFileSync(file, `${JSON.stringify({ paths: plan.paths, base_commit: plan.base_commit }, null, 2)}\n`);
|
|
52
|
+
return file;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readProtect(home, campaignId) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(fs.readFileSync(protectFile(home, campaignId), 'utf8'));
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Protected paths that differ from the base commit: committed, staged,
|
|
64
|
+
// unstaged, deleted, or new and untracked. Root is the checkout top, since
|
|
65
|
+
// the saved paths are relative to it.
|
|
66
|
+
function changedProtected(root, saved) {
|
|
67
|
+
const spec = ['--', ...saved.paths.map((rel) => `:(top,literal)${rel}`)];
|
|
68
|
+
const diff = git(root, ['diff', '--name-only', saved.base_commit, ...spec]);
|
|
69
|
+
if (!diff.ok) return { error: `cannot compare with ${String(saved.base_commit).slice(0, 12)}: ${diff.err || 'git diff failed'}` };
|
|
70
|
+
const untracked = git(root, ['ls-files', '--others', '--exclude-standard', '--full-name', ...spec]);
|
|
71
|
+
const changed = [...diff.out.split('\n'), ...untracked.out.split('\n')].filter(Boolean);
|
|
72
|
+
return { changed: [...new Set(changed)].sort() };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { campaignHome, changedProtected, checkoutTop, planProtect, protectFile, readProtect, saveProtect };
|