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,330 @@
|
|
|
1
|
+
// Campaign core: fold(events) -> view, decide(view) -> next move.
|
|
2
|
+
// Pure: no I/O, no clock, no network. The rules are the cross-surface
|
|
3
|
+
// contract in test/fixtures/campaign/README.md; every surface's fold must
|
|
4
|
+
// match the golden files there.
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { TIMESTAMP, validateEvent, isHumanActor } = require('./campaign-schema');
|
|
9
|
+
|
|
10
|
+
function cents(amount) {
|
|
11
|
+
return Math.floor(amount * 100 + 0.5);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function dollars(value) {
|
|
15
|
+
return value / 100;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// An instant at full precision: whole seconds since the epoch plus nine
|
|
19
|
+
// fraction digits, so two events a microsecond apart never tie.
|
|
20
|
+
function instant(at) {
|
|
21
|
+
const [, date, time, fraction = '', zone] = TIMESTAMP.exec(at);
|
|
22
|
+
return { seconds: Date.parse(`${date}T${time}${zone}`) / 1000, nanos: fraction.padEnd(9, '0') };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function compareEvents(a, b) {
|
|
26
|
+
const x = instant(a.at);
|
|
27
|
+
const y = instant(b.at);
|
|
28
|
+
if (x.seconds !== y.seconds) return x.seconds - y.seconds;
|
|
29
|
+
if (x.nanos !== y.nanos) return x.nanos < y.nanos ? -1 : 1;
|
|
30
|
+
if (a.origin !== b.origin) return a.origin < b.origin ? -1 : 1;
|
|
31
|
+
if (a.seq !== b.seq) return a.seq - b.seq;
|
|
32
|
+
if (a.id !== b.id) return a.id < b.id ? -1 : 1;
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function seatFrom(seat) {
|
|
37
|
+
if (typeof seat === 'string') return { member: seat, role: null };
|
|
38
|
+
return { member: seat.member, role: seat.role === undefined ? null : seat.role };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function addSeat(seats, seat) {
|
|
42
|
+
if (seats.some((s) => s.member === seat.member && s.role === seat.role)) return;
|
|
43
|
+
seats.push(seat);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Statuses only move forward; dropped and vetoed are final.
|
|
47
|
+
const STATUS_RANK = { proposed: 0, claimed: 1, running: 2, ended: 3 };
|
|
48
|
+
|
|
49
|
+
function moveTo(m, status) {
|
|
50
|
+
const from = STATUS_RANK[m.status];
|
|
51
|
+
if (from === undefined || from >= STATUS_RANK[status]) return;
|
|
52
|
+
m.status = status;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function newMission(id) {
|
|
56
|
+
return {
|
|
57
|
+
id, member: null, status: 'proposed', outcome: null, costCents: null,
|
|
58
|
+
estimate_usd: null, steps: 0, kept: 0, reverted: 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Returns the folded view, or { error } when the events must be refused.
|
|
63
|
+
function fold(events) {
|
|
64
|
+
if (!Array.isArray(events)) return { error: 'invalid_event' };
|
|
65
|
+
for (const event of events) {
|
|
66
|
+
if (!validateEvent(event).ok) return { error: 'invalid_event' };
|
|
67
|
+
}
|
|
68
|
+
const campaignIds = new Set(events.map((e) => e.campaign_id));
|
|
69
|
+
if (campaignIds.size > 1) return { error: 'mixed_campaigns' };
|
|
70
|
+
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const unique = [];
|
|
73
|
+
for (const event of events) {
|
|
74
|
+
if (seen.has(event.id)) continue;
|
|
75
|
+
seen.add(event.id);
|
|
76
|
+
unique.push(event);
|
|
77
|
+
}
|
|
78
|
+
const ordered = unique.slice().sort(compareEvents);
|
|
79
|
+
const start = ordered.find((e) => e.type === 'campaign_started');
|
|
80
|
+
if (!start) return { error: 'no_start' };
|
|
81
|
+
|
|
82
|
+
const s = {
|
|
83
|
+
finished: null,
|
|
84
|
+
paused: false,
|
|
85
|
+
seats: [],
|
|
86
|
+
bet: null,
|
|
87
|
+
parent: null,
|
|
88
|
+
home: start.origin,
|
|
89
|
+
missions: new Map(),
|
|
90
|
+
claims: new Map(),
|
|
91
|
+
reservations: new Map(),
|
|
92
|
+
totalCents: cents(start.data.budget),
|
|
93
|
+
spentCents: 0,
|
|
94
|
+
asks: new Map(),
|
|
95
|
+
judgeRuns: [],
|
|
96
|
+
lastEndedPos: -1,
|
|
97
|
+
judgeSetPos: -1,
|
|
98
|
+
judge: start.data.judge,
|
|
99
|
+
lastJudgePos: -1,
|
|
100
|
+
warnings: [],
|
|
101
|
+
};
|
|
102
|
+
const d = start.data;
|
|
103
|
+
const owner = typeof d.owner === 'string' && d.owner ? d.owner : 'mission-lead';
|
|
104
|
+
for (const seat of d.seats || []) addSeat(s.seats, seatFrom(seat));
|
|
105
|
+
if (d.bet) s.bet = { text: d.bet, wrong_if: null };
|
|
106
|
+
|
|
107
|
+
const warn = (event, code) => s.warnings.push({ event_id: event.id, code });
|
|
108
|
+
const mission = (id) => {
|
|
109
|
+
if (!s.missions.has(id)) s.missions.set(id, newMission(id));
|
|
110
|
+
return s.missions.get(id);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const sequence = [start, ...ordered.filter((e) => e !== start)];
|
|
114
|
+
sequence.forEach((event, pos) => {
|
|
115
|
+
const data = event.data;
|
|
116
|
+
switch (event.type) {
|
|
117
|
+
case 'campaign_started':
|
|
118
|
+
if (event !== start) warn(event, 'duplicate_start');
|
|
119
|
+
break;
|
|
120
|
+
case 'seat_filled':
|
|
121
|
+
addSeat(s.seats, { member: data.member, role: data.role });
|
|
122
|
+
break;
|
|
123
|
+
case 'bet_set':
|
|
124
|
+
s.bet = { text: data.bet, wrong_if: data.wrong_if || null };
|
|
125
|
+
break;
|
|
126
|
+
case 'judge_set':
|
|
127
|
+
if (event.actor !== owner && !isHumanActor(event.actor)) {
|
|
128
|
+
warn(event, 'judge_set_not_owner');
|
|
129
|
+
} else {
|
|
130
|
+
s.judge = data.judge;
|
|
131
|
+
s.judgeSetPos = pos;
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
case 'attached':
|
|
135
|
+
s.parent = data.parent_id;
|
|
136
|
+
break;
|
|
137
|
+
case 'mission_proposed':
|
|
138
|
+
if (!s.missions.has(data.mission_id)) {
|
|
139
|
+
const m = mission(data.mission_id);
|
|
140
|
+
m.member = data.member || null;
|
|
141
|
+
m.estimate_usd = typeof data.estimate_usd === 'number' ? dollars(cents(data.estimate_usd)) : null;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
case 'mission_claimed': {
|
|
145
|
+
const holder = s.claims.get(data.claim_key);
|
|
146
|
+
const m = mission(data.mission_id);
|
|
147
|
+
if (holder !== undefined && holder !== data.mission_id) {
|
|
148
|
+
warn(event, 'duplicate_claim');
|
|
149
|
+
if (m.status === 'proposed') m.status = 'dropped';
|
|
150
|
+
} else {
|
|
151
|
+
s.claims.set(data.claim_key, data.mission_id);
|
|
152
|
+
moveTo(m, 'claimed');
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case 'mission_vetoed': {
|
|
157
|
+
if (event.actor !== owner && !isHumanActor(event.actor)) {
|
|
158
|
+
warn(event, 'veto_not_owner');
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
const m = mission(data.mission_id);
|
|
162
|
+
if (m.status === 'proposed' || m.status === 'claimed') m.status = 'vetoed';
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case 'mission_started': {
|
|
166
|
+
const m = mission(data.mission_id);
|
|
167
|
+
moveTo(m, 'running');
|
|
168
|
+
if (m.member === null) m.member = data.member;
|
|
169
|
+
else if (m.member !== data.member) warn(event, 'second_assignee');
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case 'step':
|
|
173
|
+
case 'kept':
|
|
174
|
+
case 'reverted': {
|
|
175
|
+
const m = mission(data.mission_id);
|
|
176
|
+
const key = event.type === 'step' ? 'steps' : event.type;
|
|
177
|
+
m[key] += 1;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case 'mission_ended': {
|
|
181
|
+
const m = mission(data.mission_id);
|
|
182
|
+
m.status = 'ended';
|
|
183
|
+
m.outcome = data.outcome;
|
|
184
|
+
m.costCents = cents(data.cost_usd);
|
|
185
|
+
s.spentCents += m.costCents;
|
|
186
|
+
s.reservations.delete(data.mission_id);
|
|
187
|
+
s.lastEndedPos = pos;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case 'budget_reserved': {
|
|
191
|
+
const m = s.missions.get(data.mission_id);
|
|
192
|
+
if (!m || m.status !== 'ended') s.reservations.set(data.mission_id, cents(data.amount_usd));
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case 'budget_released':
|
|
196
|
+
s.reservations.delete(data.mission_id);
|
|
197
|
+
break;
|
|
198
|
+
case 'budget_added':
|
|
199
|
+
s.totalCents += cents(data.amount_usd);
|
|
200
|
+
break;
|
|
201
|
+
case 'judge_ran':
|
|
202
|
+
s.judgeRuns.push({
|
|
203
|
+
passed: data.passed,
|
|
204
|
+
reading: typeof data.reading === 'number' ? data.reading : null,
|
|
205
|
+
summary: typeof data.summary === 'string' ? data.summary : null,
|
|
206
|
+
confidence: data.confidence || null,
|
|
207
|
+
at: event.at,
|
|
208
|
+
});
|
|
209
|
+
s.lastJudgePos = pos;
|
|
210
|
+
if (data.passed && !d.standing && !s.finished) s.finished = 'judge_passed';
|
|
211
|
+
break;
|
|
212
|
+
case 'ask_opened':
|
|
213
|
+
if (!s.asks.has(data.ask_id)) s.asks.set(data.ask_id, { id: data.ask_id, question: data.question, mission_id: data.mission_id || null });
|
|
214
|
+
break;
|
|
215
|
+
case 'ask_answered':
|
|
216
|
+
if (!isHumanActor(event.actor)) warn(event, 'ask_answered_by_agent');
|
|
217
|
+
else if (!s.asks.has(data.ask_id)) warn(event, 'unknown_ask');
|
|
218
|
+
else s.asks.delete(data.ask_id);
|
|
219
|
+
break;
|
|
220
|
+
case 'paused':
|
|
221
|
+
s.paused = true;
|
|
222
|
+
break;
|
|
223
|
+
case 'resumed':
|
|
224
|
+
s.paused = false;
|
|
225
|
+
break;
|
|
226
|
+
case 'stopped':
|
|
227
|
+
if (!s.finished) s.finished = 'stopped';
|
|
228
|
+
break;
|
|
229
|
+
case 'home_moved':
|
|
230
|
+
s.home = data.to;
|
|
231
|
+
break;
|
|
232
|
+
case 'done':
|
|
233
|
+
if (!isHumanActor(event.actor)) warn(event, 'done_without_judge');
|
|
234
|
+
else if (!s.finished) s.finished = 'done_by_human';
|
|
235
|
+
break;
|
|
236
|
+
default:
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const missions = [...s.missions.values()];
|
|
242
|
+
let state;
|
|
243
|
+
let stopReason = null;
|
|
244
|
+
if (s.finished) {
|
|
245
|
+
state = 'done';
|
|
246
|
+
stopReason = s.finished;
|
|
247
|
+
} else if (s.spentCents >= s.totalCents) {
|
|
248
|
+
state = 'out_of_budget';
|
|
249
|
+
stopReason = 'budget_spent';
|
|
250
|
+
} else if (s.paused) {
|
|
251
|
+
state = 'paused';
|
|
252
|
+
} else if (s.asks.size > 0) {
|
|
253
|
+
state = 'needs_you';
|
|
254
|
+
} else if (missions.some((m) => m.status === 'running')) {
|
|
255
|
+
state = 'running';
|
|
256
|
+
} else if (missions.some((m) => m.status === 'proposed' || m.status === 'claimed')) {
|
|
257
|
+
state = 'queued';
|
|
258
|
+
} else {
|
|
259
|
+
state = 'resting';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const runs = s.judgeRuns;
|
|
263
|
+
const latest = runs.length ? runs[runs.length - 1] : null;
|
|
264
|
+
let confidence = 'open';
|
|
265
|
+
if (latest && s.finished === 'judge_passed') {
|
|
266
|
+
confidence = 'answered';
|
|
267
|
+
} else if (latest && latest.passed) {
|
|
268
|
+
let streak = 0;
|
|
269
|
+
for (let i = runs.length - 1; i >= 0 && runs[i].passed; i--) streak++;
|
|
270
|
+
confidence = streak >= 3 ? 'stronger' : 'leaning';
|
|
271
|
+
} else if (latest) {
|
|
272
|
+
confidence = latest.confidence || 'open';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const reservedCents = [...s.reservations.values()].reduce((sum, value) => sum + value, 0);
|
|
276
|
+
return {
|
|
277
|
+
id: start.campaign_id,
|
|
278
|
+
title: d.title,
|
|
279
|
+
judge: s.judge,
|
|
280
|
+
owner,
|
|
281
|
+
standing: d.standing === true,
|
|
282
|
+
bet: s.bet,
|
|
283
|
+
parent: s.parent,
|
|
284
|
+
home: s.home,
|
|
285
|
+
created_from: d.created_from === undefined ? null : d.created_from,
|
|
286
|
+
seats: s.seats,
|
|
287
|
+
state,
|
|
288
|
+
stop_reason: stopReason,
|
|
289
|
+
answer: { text: latest ? latest.summary : null, confidence },
|
|
290
|
+
budget: {
|
|
291
|
+
total: dollars(s.totalCents),
|
|
292
|
+
spent: dollars(s.spentCents),
|
|
293
|
+
reserved: dollars(reservedCents),
|
|
294
|
+
available: dollars(s.totalCents - s.spentCents - reservedCents),
|
|
295
|
+
},
|
|
296
|
+
asks: [...s.asks.values()],
|
|
297
|
+
missions: missions.map((m) => ({
|
|
298
|
+
id: m.id,
|
|
299
|
+
member: m.member,
|
|
300
|
+
status: m.status,
|
|
301
|
+
outcome: m.outcome,
|
|
302
|
+
cost_usd: m.costCents === null ? null : dollars(m.costCents),
|
|
303
|
+
estimate_usd: m.estimate_usd,
|
|
304
|
+
steps: m.steps,
|
|
305
|
+
kept: m.kept,
|
|
306
|
+
reverted: m.reverted,
|
|
307
|
+
})),
|
|
308
|
+
judge_runs: runs.length,
|
|
309
|
+
judge_due: runs.length === 0 || Math.max(s.lastEndedPos, s.judgeSetPos) > s.lastJudgePos,
|
|
310
|
+
last_judge: latest ? { passed: latest.passed, reading: latest.reading, at: latest.at } : null,
|
|
311
|
+
warnings: s.warnings,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function decide(view) {
|
|
316
|
+
if (view.state === 'done') return { action: 'none', reason: 'finished' };
|
|
317
|
+
if (view.state === 'out_of_budget') return { action: 'none', reason: 'budget spent' };
|
|
318
|
+
if (view.state === 'paused') return { action: 'none', reason: 'paused' };
|
|
319
|
+
if (view.asks.length > 0) return { action: 'wait', reason: 'open asks', asks: view.asks.map((a) => a.id) };
|
|
320
|
+
if (view.judge_due) return { action: 'run_judge' };
|
|
321
|
+
const claimed = view.missions.filter((m) => m.status === 'claimed');
|
|
322
|
+
const available = cents(view.budget.available);
|
|
323
|
+
const fits = claimed.find((m) => cents(m.estimate_usd || 0) <= available);
|
|
324
|
+
if (fits) return { action: 'start_mission', mission_id: fits.id };
|
|
325
|
+
if (claimed.length > 0) return { action: 'wait', reason: 'budget' };
|
|
326
|
+
if (view.missions.some((m) => m.status === 'running')) return { action: 'wait', reason: 'missions running' };
|
|
327
|
+
return { action: 'propose', reason: 'no missions queued' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = { cents, compareEvents, decide, fold };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Held-out judge data: `atris campaign start --holdout <file>` moves the file
|
|
2
|
+
// out of the repo into the campaign's own folder beside the campaign store, so
|
|
3
|
+
// agents working in the checkout never read it. The judge finds it through
|
|
4
|
+
// ATRIS_CAMPAIGN_HOLDOUT, and any of its lines the judge echoes are scrubbed
|
|
5
|
+
// before the reading is stored, so show and export cannot print them.
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const MASK = '[held out]';
|
|
13
|
+
|
|
14
|
+
function holdoutDir(root, campaignId) {
|
|
15
|
+
return path.join(root, 'campaigns', campaignId, 'holdout');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The held-out file for a campaign, or null when it has none.
|
|
19
|
+
function findHoldout(root, campaignId) {
|
|
20
|
+
const dir = holdoutDir(root, campaignId);
|
|
21
|
+
let names;
|
|
22
|
+
try { names = fs.readdirSync(dir); } catch { return null; }
|
|
23
|
+
const name = names.sort().find((n) => fs.statSync(path.join(dir, n)).isFile());
|
|
24
|
+
return name ? path.join(dir, name) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function moveFile(from, to) {
|
|
28
|
+
try {
|
|
29
|
+
fs.renameSync(from, to);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code !== 'EXDEV') throw error;
|
|
32
|
+
fs.copyFileSync(from, to);
|
|
33
|
+
fs.unlinkSync(from);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Moves the file in and returns its new path, or { error } in plain words.
|
|
38
|
+
function moveHoldoutIn(root, campaignId, source) {
|
|
39
|
+
const from = path.resolve(source);
|
|
40
|
+
if (!fs.existsSync(from) || !fs.statSync(from).isFile()) return { error: `--holdout ${source} is not a file` };
|
|
41
|
+
const dir = holdoutDir(root, campaignId);
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
const to = path.join(dir, path.basename(from));
|
|
44
|
+
moveFile(from, to);
|
|
45
|
+
return { path: to, from };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Puts the file back where it came from, for a start that was refused.
|
|
49
|
+
function moveHoldoutBack(moved) {
|
|
50
|
+
if (moved && moved.path && fs.existsSync(moved.path) && !fs.existsSync(moved.from)) moveFile(moved.path, moved.from);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Replaces every held-out line that shows up in text.
|
|
54
|
+
function scrub(text, holdoutPath) {
|
|
55
|
+
if (typeof text !== 'string' || !text || !holdoutPath) return text;
|
|
56
|
+
let content;
|
|
57
|
+
try { content = fs.readFileSync(holdoutPath, 'utf8'); } catch { return text; }
|
|
58
|
+
const lines = [...new Set(content.split('\n').map((line) => line.trim()).filter((line) => line.length >= 4))];
|
|
59
|
+
lines.sort((a, b) => b.length - a.length);
|
|
60
|
+
let out = text;
|
|
61
|
+
for (const line of lines) out = out.split(line).join(MASK);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { findHoldout, holdoutDir, moveHoldoutBack, moveHoldoutIn, scrub };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// The campaign loop: a campaign runs itself until its judge passes, its money
|
|
2
|
+
// runs out, or it needs a person. Each step asks decide what to do, then does
|
|
3
|
+
// it: run the judge, start claimed missions (several at once), or ask the
|
|
4
|
+
// owner's engine to propose the next missions. Campaigns decide, missions do.
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
const { decide } = require('./campaign-core');
|
|
12
|
+
const { getCampaign, openCampaignStore } = require('./campaign-store');
|
|
13
|
+
const { defaultReserve, reconcileCampaign } = require('./campaign-missions');
|
|
14
|
+
const { judgeRanData, latestJudgeOutput } = require('./campaign-reading');
|
|
15
|
+
|
|
16
|
+
const TERMINAL = new Set(['stopped', 'complete']);
|
|
17
|
+
|
|
18
|
+
const CLI = path.join(__dirname, '..', 'bin', 'atris.js');
|
|
19
|
+
const PROPOSE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
function claimKey(objective) {
|
|
22
|
+
return String(objective).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 160);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function proposalId(objective) {
|
|
26
|
+
const slug = String(objective).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
|
|
27
|
+
return `m-${slug || 'mission'}-${crypto.randomBytes(3).toString('hex')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// What the owner's engine reads before proposing: the goal, how it is judged,
|
|
31
|
+
// what has been tried, and how it went.
|
|
32
|
+
function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
|
|
33
|
+
const tried = view.missions.slice(-12).map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none yet';
|
|
34
|
+
const seats = view.seats.map((s) => s.member).filter(Boolean);
|
|
35
|
+
const members = [...new Set([view.owner, ...seats])];
|
|
36
|
+
return [
|
|
37
|
+
`You plan the next missions for an Atris campaign. Answer with JSON only.`,
|
|
38
|
+
``,
|
|
39
|
+
`Campaign: ${view.title}`,
|
|
40
|
+
`Judge (the campaign is done when this command exits 0): ${view.judge}`,
|
|
41
|
+
`Last judge reading: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${typeof view.last_judge.reading === 'number' ? ` (${view.last_judge.reading} of 1)` : ''}: ${view.answer.text || ''}` : 'never run'}`,
|
|
42
|
+
judgeOutput ? `Last judge output:\n\`\`\`\n${judgeOutput.replace(/```/g, "'''")}\n\`\`\`` : '',
|
|
43
|
+
view.bet ? `Current bet: ${view.bet.text}${view.bet.wrong_if ? ` (wrong if ${view.bet.wrong_if})` : ''}` : '',
|
|
44
|
+
`Money left: $${view.budget.available.toFixed(2)} of $${view.budget.total.toFixed(2)}`,
|
|
45
|
+
`Members who can take a mission: ${members.join(', ')}`,
|
|
46
|
+
`Missions so far:`,
|
|
47
|
+
tried,
|
|
48
|
+
lessons ? `Lessons from earlier rounds (follow them):\n${lessons}` : '',
|
|
49
|
+
``,
|
|
50
|
+
`Propose at most ${count} missions that move the judge toward passing. Each is one member, one run, one checkable result.`,
|
|
51
|
+
`Do not repeat a mission that was already kept. Prefer small, independent missions that can run at the same time.`,
|
|
52
|
+
`Reply with a JSON array and nothing else:`,
|
|
53
|
+
`[{"objective": "<one sentence, imperative>", "member": "<one of the members above>", "verify": "<shell command that exits 0 when this mission's result is real>", "estimate_usd": <dollars, small>}]`,
|
|
54
|
+
].filter((line) => line !== '').join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseProposals(text, view, count) {
|
|
58
|
+
const match = String(text || '').match(/\[[\s\S]*\]/);
|
|
59
|
+
if (!match) return [];
|
|
60
|
+
let rows;
|
|
61
|
+
try {
|
|
62
|
+
rows = JSON.parse(match[0]);
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(rows)) return [];
|
|
67
|
+
const members = new Set([view.owner, ...view.seats.map((s) => s.member)]);
|
|
68
|
+
return rows.slice(0, count)
|
|
69
|
+
.filter((row) => row && typeof row.objective === 'string' && row.objective.trim())
|
|
70
|
+
.map((row) => ({
|
|
71
|
+
objective: row.objective.trim().slice(0, 300),
|
|
72
|
+
member: members.has(row.member) ? row.member : view.owner,
|
|
73
|
+
verify: typeof row.verify === 'string' && row.verify.trim() ? row.verify.trim() : view.judge,
|
|
74
|
+
estimate_usd: typeof row.estimate_usd === 'number' && row.estimate_usd > 0 ? Math.min(row.estimate_usd, view.budget.available) : null,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Ask the owner's engine for proposals through the read-only ask runner.
|
|
79
|
+
async function askOwner(root, view, prompt) {
|
|
80
|
+
const { resolveEngineForMember } = require('./member-engine');
|
|
81
|
+
const { runEngineAskJobs } = require('./engine-ask');
|
|
82
|
+
const pick = resolveEngineForMember(view.owner, root) || {};
|
|
83
|
+
const engine = (pick.engine && pick.engine.id) || 'claude';
|
|
84
|
+
const [answer] = await runEngineAskJobs([{ engine, model: pick.model || '', prompt, label: `campaign ${view.id} plan` }], { root, timeoutMs: PROPOSE_TIMEOUT_MS });
|
|
85
|
+
return { text: (answer && answer.stdout) || '', engine, ok: Boolean(answer && answer.ok) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Run one atris command and resolve with its exit code and output.
|
|
89
|
+
function runCli(args, { cwd, env = process.env, onLine = null } = {}) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const child = spawn(process.execPath, [CLI, ...args], { cwd, env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
92
|
+
let out = '';
|
|
93
|
+
let err = '';
|
|
94
|
+
child.stdout.on('data', (chunk) => {
|
|
95
|
+
out += chunk;
|
|
96
|
+
if (onLine) String(chunk).split('\n').filter(Boolean).forEach(onLine);
|
|
97
|
+
});
|
|
98
|
+
child.stderr.on('data', (chunk) => { err += chunk; });
|
|
99
|
+
child.on('close', (code) => resolve({ code, out, err }));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeLoop(root, options = {}, deps = {}) {
|
|
104
|
+
const store = deps.store || openCampaignStore(root);
|
|
105
|
+
const log = deps.log || ((line) => console.log(line));
|
|
106
|
+
const parallel = Math.max(1, Number(options.parallel) || 3);
|
|
107
|
+
const maxTicks = Math.max(1, Number(options.maxTicks) || 3);
|
|
108
|
+
const runner = options.runner || 'auto';
|
|
109
|
+
const actor = options.actor || 'mission-lead';
|
|
110
|
+
const ask = deps.ask || askOwner;
|
|
111
|
+
const cli = deps.cli || ((args) => runCli(args, { cwd: root }));
|
|
112
|
+
const judge = deps.judge;
|
|
113
|
+
|
|
114
|
+
const view = (id) => getCampaign(store, id);
|
|
115
|
+
const append = (id, type, data, as = actor) => store.append({ campaign_id: id, type, actor: as, data });
|
|
116
|
+
|
|
117
|
+
async function propose(id, count = parallel) {
|
|
118
|
+
const current = view(id);
|
|
119
|
+
const prompt = proposalPrompt(current, count, latestJudgeOutput(store.read(id)), deps.lessons ? deps.lessons(id) : '');
|
|
120
|
+
const answer = await ask(root, current, prompt);
|
|
121
|
+
const proposals = parseProposals(answer.text, current, count);
|
|
122
|
+
// Planning costs money too. It rides on the first mission it produced as a
|
|
123
|
+
// step, so it is counted when that mission ends without making the judge
|
|
124
|
+
// due. A plan that produced nothing becomes its own ended mission.
|
|
125
|
+
const plan = { kind: 'plan', engine: answer.engine };
|
|
126
|
+
if (typeof answer.cost_usd === 'number') plan.cost_usd = answer.cost_usd;
|
|
127
|
+
proposals.forEach((proposal, index) => {
|
|
128
|
+
const missionId = proposalId(proposal.objective);
|
|
129
|
+
append(id, 'mission_proposed', { mission_id: missionId, member: proposal.member, objective: proposal.objective, verify: proposal.verify, estimate_usd: proposal.estimate_usd || undefined }, proposal.member);
|
|
130
|
+
append(id, 'mission_claimed', { mission_id: missionId, claim_key: claimKey(proposal.objective) }, proposal.member);
|
|
131
|
+
if (index === 0) append(id, 'step', { mission_id: missionId, ...plan }, current.owner);
|
|
132
|
+
});
|
|
133
|
+
if (!proposals.length) {
|
|
134
|
+
const planId = `plan-${crypto.randomBytes(3).toString('hex')}`;
|
|
135
|
+
append(id, 'mission_started', { mission_id: planId, member: current.owner, kind: 'plan' }, current.owner);
|
|
136
|
+
append(id, 'mission_ended', { mission_id: planId, outcome: 'failed', cost_usd: plan.cost_usd || 0, uncosted_runs: plan.cost_usd === undefined ? 1 : 0, engine: answer.engine }, current.owner);
|
|
137
|
+
}
|
|
138
|
+
return proposals;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The proposal data behind each claimed mission (the fold keeps only ids).
|
|
142
|
+
function claimedWork(id) {
|
|
143
|
+
const current = view(id);
|
|
144
|
+
const proposals = new Map();
|
|
145
|
+
for (const event of store.read(id)) {
|
|
146
|
+
if (event.type === 'mission_proposed' && event.data.objective) proposals.set(event.data.mission_id, event.data);
|
|
147
|
+
}
|
|
148
|
+
return current.missions
|
|
149
|
+
.filter((m) => m.status === 'claimed' && proposals.has(m.id))
|
|
150
|
+
.map((m) => ({ ...proposals.get(m.id), mission_id: m.id, member: m.member || proposals.get(m.id).member || current.owner }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function startAndRun(id, work) {
|
|
154
|
+
const current = view(id);
|
|
155
|
+
const reserve = work.estimate_usd || defaultReserve(current);
|
|
156
|
+
const started = await cli([
|
|
157
|
+
'mission', 'start', work.objective, '--mission-id', work.mission_id, '--owner', work.member,
|
|
158
|
+
'--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve), '--json',
|
|
159
|
+
]);
|
|
160
|
+
if (started.code !== 0) {
|
|
161
|
+
const why = (started.out + started.err).trim().split('\n').pop();
|
|
162
|
+
log(` ${work.mission_id} did not start: ${why}`);
|
|
163
|
+
append(id, 'mission_vetoed', { mission_id: work.mission_id, reason: why.slice(0, 200) }, current.owner);
|
|
164
|
+
return { mission_id: work.mission_id, started: false };
|
|
165
|
+
}
|
|
166
|
+
log(` ${work.mission_id} started for ${work.member}: ${work.objective}`);
|
|
167
|
+
const ran = await cli(['mission', 'run', work.mission_id, '--max-ticks', String(maxTicks), '--complete-on-pass']);
|
|
168
|
+
const after = view(id).missions.find((m) => m.id === work.mission_id);
|
|
169
|
+
if (after && after.status !== 'ended') {
|
|
170
|
+
// The run stopped without finishing (tick cap, error): close it so its
|
|
171
|
+
// reserve comes back and its cost is counted.
|
|
172
|
+
await cli(['mission', 'stop', work.mission_id, '--reason', `campaign loop: run ended without finishing (exit ${ran.code})`]);
|
|
173
|
+
}
|
|
174
|
+
const final = view(id).missions.find((m) => m.id === work.mission_id) || {};
|
|
175
|
+
log(` ${work.mission_id} ${final.outcome || 'ended'} · $${(final.cost_usd || 0).toFixed(2)}`);
|
|
176
|
+
return { mission_id: work.mission_id, started: true, outcome: final.outcome || null };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A campaign linked to the cloud trades events before and after every step,
|
|
180
|
+
// so a person's answer from a phone reaches this loop. A failed sync never
|
|
181
|
+
// stops the loop; deps.sync warns, keeps running offline, and resolves with
|
|
182
|
+
// how many events it pulled.
|
|
183
|
+
const sync = deps.sync || (async () => 0);
|
|
184
|
+
|
|
185
|
+
// One step, with a sync on each side.
|
|
186
|
+
async function step(id) {
|
|
187
|
+
await sync(id);
|
|
188
|
+
const result = await stepOnce(id);
|
|
189
|
+
await sync(id);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// One step. Returns { stop, reason } or { stop: false, did }.
|
|
194
|
+
async function stepOnce(id) {
|
|
195
|
+
// Missions that ended without telling the campaign give their reserve back first.
|
|
196
|
+
if (!deps.skipReconcile) {
|
|
197
|
+
try { reconcileCampaign(root, id, TERMINAL, { store }); } catch {}
|
|
198
|
+
}
|
|
199
|
+
const current = view(id);
|
|
200
|
+
if (!current) return { stop: true, reason: `no campaign ${id}` };
|
|
201
|
+
if (current.error) return { stop: true, reason: `campaign cannot be read: ${current.error}` };
|
|
202
|
+
const move = decide(current);
|
|
203
|
+
if (move.action === 'none') return { stop: true, reason: move.reason };
|
|
204
|
+
if (move.action === 'wait' && move.reason === 'open asks') return { stop: true, reason: `needs you: ${move.asks.length} open ask${move.asks.length === 1 ? '' : 's'}` };
|
|
205
|
+
if (move.action === 'run_judge') {
|
|
206
|
+
const reading = await judge(current.judge);
|
|
207
|
+
append(id, 'judge_ran', judgeRanData(reading), 'judge');
|
|
208
|
+
log(`judge ${reading.passed ? 'passed' : 'failed'}: ${reading.summary || `exit ${reading.exit}`}`);
|
|
209
|
+
return { stop: false, did: 'judge' };
|
|
210
|
+
}
|
|
211
|
+
if (move.action === 'start_mission' || (move.action === 'wait' && move.reason === 'budget')) {
|
|
212
|
+
const work = claimedWork(id).slice(0, parallel);
|
|
213
|
+
if (!work.length) return { stop: true, reason: 'claimed missions have no objective to run; start them by hand or veto them' };
|
|
214
|
+
log(`starting ${work.length} mission${work.length === 1 ? '' : 's'} at once`);
|
|
215
|
+
const results = await Promise.all(work.map((w) => startAndRun(id, w)));
|
|
216
|
+
if (!results.some((r) => r.started)) return { stop: true, reason: 'no claimed mission could start' };
|
|
217
|
+
return { stop: false, did: 'missions' };
|
|
218
|
+
}
|
|
219
|
+
if (move.action === 'wait') return { stop: true, reason: `waiting: ${move.reason} (started elsewhere)` };
|
|
220
|
+
log(`asking ${current.owner} to propose up to ${parallel} missions`);
|
|
221
|
+
const proposals = await propose(id, parallel);
|
|
222
|
+
if (!proposals.length) return { stop: true, reason: `${current.owner} proposed nothing usable` };
|
|
223
|
+
for (const p of proposals) log(` proposed for ${p.member}: ${p.objective}`);
|
|
224
|
+
return { stop: false, did: 'propose' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function run(id, maxSteps = 20) {
|
|
228
|
+
await sync(id);
|
|
229
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
230
|
+
const result = await stepOnce(id);
|
|
231
|
+
// An answer pulled just now can unblock a step that stopped for a person.
|
|
232
|
+
const pulled = await sync(id);
|
|
233
|
+
if (result.stop && pulled > 0) continue;
|
|
234
|
+
if (result.stop) return { steps: i, reason: result.reason, campaign: view(id) };
|
|
235
|
+
}
|
|
236
|
+
return { steps: maxSteps, reason: `stopped after ${maxSteps} steps`, campaign: view(id) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { step, run, propose };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { askOwner, makeLoop, parseProposals, proposalPrompt };
|