atris 3.70.0 → 3.71.1
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 +18 -0
- 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 +38 -8
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +779 -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/integrations.js +17 -5
- package/commands/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +359 -94
- package/commands/mission.js +249 -36
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +71 -17
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +108 -15
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -0
- package/lib/account-bound.js +13 -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 +246 -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-run-lock.js +59 -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 +33 -3
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- 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 +395 -18
- package/lib/member-board.js +7 -1
- 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 +127 -8
- package/lib/member-self-next-job.js +97 -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 +47 -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,302 @@
|
|
|
1
|
+
// Campaign cloud sync: a campaign on this computer and its copy in the Atris
|
|
2
|
+
// cloud trade events by id, so web and iOS show it and a person's answer from
|
|
3
|
+
// a phone reaches a running loop. Contract: atris/features/campaign/api.md.
|
|
4
|
+
//
|
|
5
|
+
// Push sends the events the cloud lacks; pull ingests the ones this computer
|
|
6
|
+
// lacks (answers and actions from screens). Both are idempotent by event id.
|
|
7
|
+
// A 403 or 409 is the cloud saying no on purpose, so it is reported plainly
|
|
8
|
+
// and never retried.
|
|
9
|
+
//
|
|
10
|
+
// A computer that has never seen the campaign (an Atris cloud computer told to
|
|
11
|
+
// run it) pulls the whole log first, campaign_started before anything else.
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
|
|
17
|
+
const { loadCredentials } = require('../utils/auth');
|
|
18
|
+
const { CAMPAIGN_ID } = require('./campaign-schema');
|
|
19
|
+
|
|
20
|
+
const PUSH_BATCH = 200;
|
|
21
|
+
const PULL_LIMIT = 500;
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
23
|
+
// Only a person's screen may send these to the cloud (api.md, Write). A local
|
|
24
|
+
// copy stays local; a person answers on the phone, the web, or with
|
|
25
|
+
// atris campaign answer, which goes to the cloud for a synced campaign.
|
|
26
|
+
const PERSON_ONLY = new Set(['ask_answered', 'budget_added', 'done']);
|
|
27
|
+
|
|
28
|
+
const metaKey = (campaignId) => `campaign_sync:${campaignId}`;
|
|
29
|
+
|
|
30
|
+
function readState(store, campaignId) {
|
|
31
|
+
const raw = store.getMeta(metaKey(campaignId));
|
|
32
|
+
if (!raw) return null;
|
|
33
|
+
try {
|
|
34
|
+
const state = JSON.parse(raw);
|
|
35
|
+
return state && typeof state === 'object' ? state : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeState(store, campaignId, state) {
|
|
42
|
+
store.setMeta(metaKey(campaignId), JSON.stringify(state));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The cloud link for a campaign, or null when it has never been synced.
|
|
46
|
+
function cloudLink(store, campaignId) {
|
|
47
|
+
const state = readState(store, campaignId);
|
|
48
|
+
return state && state.link ? state : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function plainError(response) {
|
|
52
|
+
const status = response && response.status;
|
|
53
|
+
const said = response && (response.error || (response.data && (response.data.error || response.data.detail)));
|
|
54
|
+
if (!status) return `could not reach the Atris cloud${said ? ` (${said})` : ''}`;
|
|
55
|
+
if (status === 401) return 'the Atris cloud did not accept your login. run: atris login';
|
|
56
|
+
if (status === 403) return `the Atris cloud refused this (403)${said ? `: ${said}` : ''}`;
|
|
57
|
+
if (status === 409) return `the Atris cloud holds a different event at the same place (409)${said ? `: ${said}` : ''}`;
|
|
58
|
+
return `the Atris cloud answered ${status}${said ? `: ${said}` : ''}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function failure(result, response) {
|
|
62
|
+
return { ...result, ok: false, status: (response && response.status) || 0, error: plainError(response) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tokenFrom(load) {
|
|
66
|
+
try {
|
|
67
|
+
const credentials = load();
|
|
68
|
+
const token = credentials && String(credentials.token || '').trim();
|
|
69
|
+
return token || null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Every event the cloud holds for a campaign this computer has never seen,
|
|
76
|
+
// ingested with campaign_started first. Returns { ok, pulled, last_seq } or
|
|
77
|
+
// { ok: false, status, error } with what exactly went wrong.
|
|
78
|
+
async function pullWholeCampaign(store, campaignId, call) {
|
|
79
|
+
const base = `/campaigns/${encodeURIComponent(campaignId)}`;
|
|
80
|
+
const incoming = [];
|
|
81
|
+
let after = 0;
|
|
82
|
+
for (;;) {
|
|
83
|
+
const response = await call(`${base}/events?after=${after}&limit=${PULL_LIMIT}`, { method: 'GET' });
|
|
84
|
+
if (response && response.status === 404) {
|
|
85
|
+
return { ok: false, status: 404, error: `the Atris cloud has no campaign ${campaignId} for this login, so there is nothing to pull. Start it with --cloud on the computer that has it, or check the login (atris whoami)` };
|
|
86
|
+
}
|
|
87
|
+
if (!response || !response.ok) return { ok: false, status: (response && response.status) || 0, error: plainError(response) };
|
|
88
|
+
const data = response.data || {};
|
|
89
|
+
const page = (Array.isArray(data.events) ? data.events : []).filter((event) => event && event.campaign_id === campaignId);
|
|
90
|
+
incoming.push(...page);
|
|
91
|
+
const seq = Number(data.last_event_seq);
|
|
92
|
+
if (Number.isInteger(seq) && seq > after) after = seq;
|
|
93
|
+
if (!Array.isArray(data.events) || data.events.length < PULL_LIMIT) break;
|
|
94
|
+
}
|
|
95
|
+
if (!incoming.length) {
|
|
96
|
+
return { ok: false, status: 404, error: `the Atris cloud has no events for ${campaignId}, so there is nothing to pull. Start it with --cloud on the computer that has it` };
|
|
97
|
+
}
|
|
98
|
+
const starts = incoming.filter((event) => event.type === 'campaign_started');
|
|
99
|
+
if (starts.length !== 1) {
|
|
100
|
+
return { ok: false, status: 0, error: `the cloud copy of ${campaignId} has ${starts.length} campaign_started events, not 1, so this computer cannot start it` };
|
|
101
|
+
}
|
|
102
|
+
let pulled = 0;
|
|
103
|
+
for (const event of [starts[0], ...incoming.filter((e) => e !== starts[0])]) {
|
|
104
|
+
try {
|
|
105
|
+
if (store.ingest(event)) pulled += 1;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return { ok: false, status: 0, error: `cloud event ${event.id} could not be taken in: ${error.message}` };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, pulled, last_seq: after };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// An Atris cloud computer running a campaign holds a token for that one
|
|
114
|
+
// campaign's sync routes, in a file the backend placed (0600). The path comes
|
|
115
|
+
// in ATRIS_CAMPAIGN_TOKEN_FILE. It is read once and taken out of the
|
|
116
|
+
// environment, so no mission, engine, or child CLI started here inherits it.
|
|
117
|
+
const CAMPAIGN_TOKEN_ENV = 'ATRIS_CAMPAIGN_TOKEN_FILE';
|
|
118
|
+
|
|
119
|
+
function takeCampaignToken(env = process.env, read = fs.readFileSync) {
|
|
120
|
+
if (!env || env[CAMPAIGN_TOKEN_ENV] === undefined) return null;
|
|
121
|
+
const file = String(env[CAMPAIGN_TOKEN_ENV]).trim();
|
|
122
|
+
delete env[CAMPAIGN_TOKEN_ENV];
|
|
123
|
+
try {
|
|
124
|
+
const body = JSON.parse(read(file, 'utf8'));
|
|
125
|
+
const token = body && typeof body.token === 'string' ? body.token.trim() : '';
|
|
126
|
+
if (token && CAMPAIGN_ID.test(String(body.campaign_id || ''))) return { campaign_id: body.campaign_id, token };
|
|
127
|
+
} catch {}
|
|
128
|
+
return { error: `the campaign token file ${file} could not be read` };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The token a sync of this campaign uses: the campaign's own token when this
|
|
132
|
+
// computer holds one (never the login, even if that fails), else the login.
|
|
133
|
+
function syncToken(campaignId, deps) {
|
|
134
|
+
const scoped = deps.campaignToken;
|
|
135
|
+
if (scoped) {
|
|
136
|
+
if (scoped.error) return { error: scoped.error };
|
|
137
|
+
if (scoped.campaign_id !== campaignId) return { error: `the campaign token here is for ${scoped.campaign_id}, not ${campaignId}` };
|
|
138
|
+
return { token: scoped.token };
|
|
139
|
+
}
|
|
140
|
+
const token = tokenFrom(deps.loadCredentials || loadCredentials);
|
|
141
|
+
return token ? { token } : { error: 'not logged in to Atris. run: atris login' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// One sync of one campaign. Never throws for cloud trouble; returns
|
|
145
|
+
// { ok, pushed, duplicates, held, pulled, last_seq, link, error?, status? }.
|
|
146
|
+
async function syncCampaign(store, campaignId, deps = {}) {
|
|
147
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
148
|
+
const now = deps.now || (() => new Date().toISOString());
|
|
149
|
+
const base = `/campaigns/${encodeURIComponent(campaignId)}`;
|
|
150
|
+
const state = readState(store, campaignId) || { pushed: 0, last_seq: 0 };
|
|
151
|
+
// A campaign this computer has never seen is linked only once its pull
|
|
152
|
+
// works, so a wrong id leaves nothing behind.
|
|
153
|
+
const firstPull = store.read(campaignId).length === 0;
|
|
154
|
+
const link = state.link || { url: `${getApiBaseUrl()}${base}`, linked_at: now() };
|
|
155
|
+
if (!state.link && !firstPull) {
|
|
156
|
+
// Linked from the first try, so a computer that is offline now keeps
|
|
157
|
+
// trying on every later run step.
|
|
158
|
+
state.link = link;
|
|
159
|
+
writeState(store, campaignId, state);
|
|
160
|
+
}
|
|
161
|
+
const result = { ok: true, campaign_id: campaignId, pushed: 0, duplicates: 0, held: 0, pulled: 0, last_seq: state.last_seq || 0, link: link.url };
|
|
162
|
+
|
|
163
|
+
const { token, error: tokenError } = syncToken(campaignId, deps);
|
|
164
|
+
if (!token) return { ...result, ok: false, status: 401, error: tokenError };
|
|
165
|
+
const call = (path, options) => request(path, { token, retries: 0, timeoutMs: REQUEST_TIMEOUT_MS, ...options });
|
|
166
|
+
|
|
167
|
+
if (firstPull) {
|
|
168
|
+
const whole = await pullWholeCampaign(store, campaignId, call);
|
|
169
|
+
if (!whole.ok) return { ...result, ok: false, status: whole.status, error: whole.error, first_pull: true };
|
|
170
|
+
// Everything here came from the cloud, so none of it needs pushing back.
|
|
171
|
+
state.link = link;
|
|
172
|
+
state.pushed = store.read(campaignId).length;
|
|
173
|
+
state.last_seq = whole.last_seq;
|
|
174
|
+
writeState(store, campaignId, state);
|
|
175
|
+
result.pulled = whole.pulled;
|
|
176
|
+
result.first_pull = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Push: every local event past the last one the cloud is known to hold.
|
|
180
|
+
const events = store.read(campaignId);
|
|
181
|
+
if (!events.length) return { ...result, ok: false, error: `no campaign ${campaignId} on this computer` };
|
|
182
|
+
const known = new Set();
|
|
183
|
+
const outgoing = [];
|
|
184
|
+
for (const event of events.slice(state.pushed || 0)) {
|
|
185
|
+
if (event.origin === 'cloud') { known.add(event.id); continue; }
|
|
186
|
+
if (PERSON_ONLY.has(event.type)) { result.held += 1; known.add(event.id); continue; }
|
|
187
|
+
outgoing.push(event);
|
|
188
|
+
}
|
|
189
|
+
for (let i = 0; i < outgoing.length; i += PUSH_BATCH) {
|
|
190
|
+
const batch = outgoing.slice(i, i + PUSH_BATCH);
|
|
191
|
+
const response = await call(`${base}/events`, { method: 'POST', body: { events: batch } });
|
|
192
|
+
if (!response || !response.ok) return failure(result, response);
|
|
193
|
+
const data = response.data || {};
|
|
194
|
+
result.pushed += Number(data.accepted) || 0;
|
|
195
|
+
result.duplicates += Number(data.duplicates) || 0;
|
|
196
|
+
for (const event of batch) known.add(event.id);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Pull: everything the cloud took in after the last seq seen here.
|
|
200
|
+
let after = state.last_seq || 0;
|
|
201
|
+
for (;;) {
|
|
202
|
+
const response = await call(`${base}/events?after=${after}&limit=${PULL_LIMIT}`, { method: 'GET' });
|
|
203
|
+
if (!response || !response.ok) {
|
|
204
|
+
state.pushed = advance(events, state.pushed || 0, known);
|
|
205
|
+
writeState(store, campaignId, state);
|
|
206
|
+
return failure(result, response);
|
|
207
|
+
}
|
|
208
|
+
const data = response.data || {};
|
|
209
|
+
const incoming = Array.isArray(data.events) ? data.events : [];
|
|
210
|
+
for (const event of incoming) {
|
|
211
|
+
if (!event || event.campaign_id !== campaignId) continue;
|
|
212
|
+
try {
|
|
213
|
+
if (store.ingest(event)) result.pulled += 1;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
state.pushed = advance(events, state.pushed || 0, known);
|
|
216
|
+
writeState(store, campaignId, state);
|
|
217
|
+
return { ...result, ok: false, status: 0, error: `a cloud event could not be taken in: ${error.message}` };
|
|
218
|
+
}
|
|
219
|
+
known.add(event.id);
|
|
220
|
+
}
|
|
221
|
+
const seq = Number(data.last_event_seq);
|
|
222
|
+
if (Number.isInteger(seq) && seq > after) after = seq;
|
|
223
|
+
state.last_seq = after;
|
|
224
|
+
if (incoming.length < PULL_LIMIT) break;
|
|
225
|
+
}
|
|
226
|
+
result.last_seq = after;
|
|
227
|
+
|
|
228
|
+
// Everything the cloud now holds, including what was just pulled.
|
|
229
|
+
state.pushed = advance(store.read(campaignId), state.pushed || 0, known);
|
|
230
|
+
state.synced_at = now();
|
|
231
|
+
writeState(store, campaignId, state);
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Move the pushed mark forward over events the cloud is known to hold, and
|
|
236
|
+
// stop at the first one it may not have, so an event written mid-sync is sent
|
|
237
|
+
// next time.
|
|
238
|
+
function advance(events, from, known) {
|
|
239
|
+
let mark = from;
|
|
240
|
+
while (mark < events.length && known.has(events[mark].id)) mark += 1;
|
|
241
|
+
return mark;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// A person's answer or action on a synced campaign goes through the cloud's
|
|
245
|
+
// person routes, then comes back down by pull. Returns { ok, error?, status? }.
|
|
246
|
+
async function sendPersonAction(store, campaignId, action, deps = {}) {
|
|
247
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
248
|
+
const token = tokenFrom(deps.loadCredentials || loadCredentials);
|
|
249
|
+
if (!token) return { ok: false, status: 401, error: 'not logged in to Atris. run: atris login' };
|
|
250
|
+
const base = `/campaigns/${encodeURIComponent(campaignId)}`;
|
|
251
|
+
const route = action.ask_id ? `${base}/asks/${encodeURIComponent(action.ask_id)}/answer` : `${base}/actions`;
|
|
252
|
+
const body = action.ask_id ? { answer: action.answer } : { action: action.action, amount_usd: action.amount_usd };
|
|
253
|
+
const response = await request(route, { method: 'POST', token, retries: 0, timeoutMs: REQUEST_TIMEOUT_MS, body });
|
|
254
|
+
if (!response || !response.ok) return failure({}, response);
|
|
255
|
+
const synced = await syncCampaign(store, campaignId, deps);
|
|
256
|
+
return synced.ok ? { ok: true, sync: synced } : { ok: true, sync: synced, warning: synced.error };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Ask the cloud to run a campaign on the person's Atris computer
|
|
260
|
+
// (POST /campaigns/<id>/run-in-cloud), or read how that run is going
|
|
261
|
+
// (GET /campaigns/<id>/cloud-run). Returns { ok, data } or { ok: false, status, error }.
|
|
262
|
+
const CLOUD_RUN_TIMEOUT_MS = 120000; // waking a computer can take most of a minute
|
|
263
|
+
|
|
264
|
+
async function runInCloud(campaignId, body, deps = {}) {
|
|
265
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
266
|
+
const token = tokenFrom(deps.loadCredentials || loadCredentials);
|
|
267
|
+
if (!token) return { ok: false, status: 401, error: 'not logged in to Atris. run: atris login' };
|
|
268
|
+
const response = await request(`/campaigns/${encodeURIComponent(campaignId)}/run-in-cloud`, {
|
|
269
|
+
method: 'POST', token, retries: 0, timeoutMs: CLOUD_RUN_TIMEOUT_MS, body,
|
|
270
|
+
});
|
|
271
|
+
if (!response || !response.ok) return cloudRunFailure(response);
|
|
272
|
+
return { ok: true, data: response.data || {} };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function cloudRunStatus(campaignId, deps = {}) {
|
|
276
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
277
|
+
const token = tokenFrom(deps.loadCredentials || loadCredentials);
|
|
278
|
+
if (!token) return { ok: false, status: 401, error: 'not logged in to Atris. run: atris login' };
|
|
279
|
+
const response = await request(`/campaigns/${encodeURIComponent(campaignId)}/cloud-run`, {
|
|
280
|
+
method: 'GET', token, retries: 0, timeoutMs: REQUEST_TIMEOUT_MS,
|
|
281
|
+
});
|
|
282
|
+
if (!response || !response.ok) return cloudRunFailure(response);
|
|
283
|
+
return { ok: true, data: response.data || {} };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The run routes answer in plain sentences, so pass the cloud's own words on.
|
|
287
|
+
function cloudRunFailure(response) {
|
|
288
|
+
const status = (response && response.status) || 0;
|
|
289
|
+
const said = response && ((response.data && (response.data.error || response.data.detail)) || response.error);
|
|
290
|
+
if (!status) return { ok: false, status, error: plainError(response) };
|
|
291
|
+
if (status === 401) return { ok: false, status, error: plainError(response) };
|
|
292
|
+
if (status === 404 && !(response.data && response.data.error)) {
|
|
293
|
+
return { ok: false, status, error: 'the Atris cloud does not run campaigns on computers yet (404). Try again after the next backend deploy' };
|
|
294
|
+
}
|
|
295
|
+
return { ok: false, status, error: typeof said === 'string' && said ? said : plainError(response) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// What this CLI can do for an Atris cloud computer; the backend checks it
|
|
299
|
+
// (atris campaign features --json) before it starts a cloud run here.
|
|
300
|
+
const CLOUD_FEATURES = ['cloud_pull', 'campaign_token_file'];
|
|
301
|
+
|
|
302
|
+
module.exports = { CAMPAIGN_TOKEN_ENV, CLOUD_FEATURES, cloudLink, cloudRunStatus, runInCloud, sendPersonAction, syncCampaign, takeCampaignToken };
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Campaign traces: a campaign's event log as reinforcement-learning rows.
|
|
2
|
+
// State is the folded view, an action is one mission, the reward is the
|
|
3
|
+
// change in the judge's reading, and cost is the dollars the mission spent.
|
|
4
|
+
// buildTrace is pure and deterministic: the same events give the same rows.
|
|
5
|
+
// Doc: atris/features/campaign/traces.md.
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const { compareEvents, fold } = require('./campaign-core');
|
|
11
|
+
const { judgeReading } = require('./campaign-reading');
|
|
12
|
+
|
|
13
|
+
const SCHEMA = 'atris.campaign.trace';
|
|
14
|
+
const VERSION = 1;
|
|
15
|
+
|
|
16
|
+
const PRIVACY = {
|
|
17
|
+
redacted: 'Redacted: titles, objectives, verify commands, the judge command, judge summaries and output, and ask text are dropped. Campaign and mission ids are replaced by stable pseudonyms (Atris ids carry words from the title), and people show as "human". Numbers, members, engines, and outcomes stay.',
|
|
18
|
+
full: 'Full: includes titles, objectives, verify commands, the judge command, judge summaries and output tails, and ask text. Export this only for your own data, or with the written consent of everyone in the campaign.',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const REWARD = 'reward = reading_after - reading_before, where a reading is the judge score in [0, 1] from the judge runs on either side of the mission. Missions that ended between the same two judge runs form a batch and split the change equally.';
|
|
22
|
+
|
|
23
|
+
function round(value, places = 6) {
|
|
24
|
+
const scale = 10 ** places;
|
|
25
|
+
return Math.round(value * scale) / scale;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pseudonym(prefix, ...parts) {
|
|
29
|
+
return `${prefix}-${crypto.createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 12)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A judge run's reading: what the judge reported, else counts in its summary,
|
|
33
|
+
// else 1 for a pass and 0 for a fail.
|
|
34
|
+
function readingOf(run) {
|
|
35
|
+
if (!run) return { value: null, source: null };
|
|
36
|
+
if (typeof run.reading === 'number' && Number.isFinite(run.reading)) return { value: round(Math.min(1, Math.max(0, run.reading)), 4), source: 'graded' };
|
|
37
|
+
const parsed = run.summary ? judgeReading(String(run.summary)) : null;
|
|
38
|
+
if (parsed !== null) return { value: parsed, source: 'summary' };
|
|
39
|
+
return { value: run.passed ? 1 : 0, source: 'pass_fail' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The order the fold applies events in: duplicates dropped, sorted, the first
|
|
43
|
+
// start moved to the front.
|
|
44
|
+
function foldOrder(events) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const unique = [];
|
|
47
|
+
for (const event of events) {
|
|
48
|
+
if (seen.has(event.id)) continue;
|
|
49
|
+
seen.add(event.id);
|
|
50
|
+
unique.push(event);
|
|
51
|
+
}
|
|
52
|
+
const ordered = unique.sort(compareEvents);
|
|
53
|
+
const start = ordered.find((e) => e.type === 'campaign_started');
|
|
54
|
+
return [start, ...ordered.filter((e) => e !== start)];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function compactState(view, run) {
|
|
58
|
+
const missions = { proposed: 0, claimed: 0, running: 0, ended: 0, vetoed: 0, dropped: 0 };
|
|
59
|
+
for (const m of view.missions) missions[m.status] = (missions[m.status] || 0) + 1;
|
|
60
|
+
return {
|
|
61
|
+
state: view.state,
|
|
62
|
+
reading: readingOf(run).value,
|
|
63
|
+
confidence: view.answer.confidence,
|
|
64
|
+
judge_runs: view.judge_runs,
|
|
65
|
+
budget: view.budget,
|
|
66
|
+
missions,
|
|
67
|
+
open_asks: view.asks.length,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function judgeRunner(command) {
|
|
72
|
+
const first = String(command || '').trim().split(/\s+/)[0] || '';
|
|
73
|
+
return first.split('/').pop() || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// events: one campaign's events in any order. options.redact defaults to true.
|
|
77
|
+
// Returns { header, rows } or { error }.
|
|
78
|
+
function buildTrace(events, options = {}) {
|
|
79
|
+
const redact = options.redact !== false;
|
|
80
|
+
const final = fold(events);
|
|
81
|
+
if (final.error) return { error: final.error };
|
|
82
|
+
const sequence = foldOrder(events);
|
|
83
|
+
const campaignId = final.id;
|
|
84
|
+
|
|
85
|
+
const missions = new Map();
|
|
86
|
+
const judgeRuns = [];
|
|
87
|
+
const asks = new Map();
|
|
88
|
+
const info = (id) => {
|
|
89
|
+
if (!missions.has(id)) missions.set(id, { id, firstPos: null, endPos: null, objective: null, verify: null, member: null, engine: null, kind: 'mission', estimate: null, ticks: 0, uncostedTicks: 0, endData: null, asks: [] });
|
|
90
|
+
return missions.get(id);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
sequence.forEach((event, pos) => {
|
|
94
|
+
const d = event.data;
|
|
95
|
+
if (event.type === 'judge_ran') {
|
|
96
|
+
judgeRuns.push({ pos, at: event.at, passed: d.passed, reading: d.reading, summary: d.summary || null, outputTail: d.output_tail || null });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (event.type === 'ask_opened') {
|
|
100
|
+
if (!asks.has(d.ask_id)) asks.set(d.ask_id, { mission_id: d.mission_id || null, question: d.question, answer: null });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (event.type === 'ask_answered') {
|
|
104
|
+
const ask = asks.get(d.ask_id);
|
|
105
|
+
if (ask && ask.answer === null && String(event.actor).startsWith('human:')) ask.answer = d.answer;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const missionId = typeof d.mission_id === 'string' && d.mission_id ? d.mission_id : null;
|
|
109
|
+
if (!missionId || event.type === 'budget_reserved' || event.type === 'budget_released') return;
|
|
110
|
+
const m = info(missionId);
|
|
111
|
+
if (m.firstPos === null) m.firstPos = pos;
|
|
112
|
+
if (typeof d.objective === 'string' && (m.objective === null || event.type === 'mission_proposed')) m.objective = d.objective;
|
|
113
|
+
if (typeof d.verify === 'string' && m.verify === null) m.verify = d.verify;
|
|
114
|
+
if (typeof d.member === 'string' && m.member === null) m.member = d.member;
|
|
115
|
+
// A planning step names the planner's engine, not the one that did the work.
|
|
116
|
+
if (typeof d.engine === 'string' && m.engine === null && d.kind !== 'plan') m.engine = d.engine;
|
|
117
|
+
if (typeof d.engine === 'string' && event.type === 'mission_started' && d.kind === 'plan') m.engine = d.engine;
|
|
118
|
+
if (typeof d.estimate_usd === 'number' && m.estimate === null) m.estimate = d.estimate_usd;
|
|
119
|
+
if (event.type === 'mission_started' && d.kind === 'plan') m.kind = 'plan';
|
|
120
|
+
if (event.type === 'step') {
|
|
121
|
+
m.ticks += 1;
|
|
122
|
+
if (!(typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) && d.cost_usd >= 0)) m.uncostedTicks += 1;
|
|
123
|
+
}
|
|
124
|
+
if (event.type === 'mission_ended' && m.endPos === null) {
|
|
125
|
+
m.endPos = pos;
|
|
126
|
+
m.endData = d;
|
|
127
|
+
m.endAt = event.at;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
for (const ask of asks.values()) if (ask.mission_id && missions.has(ask.mission_id)) missions.get(ask.mission_id).asks.push(ask);
|
|
131
|
+
|
|
132
|
+
// The view as it stood just before a position, folded once per position.
|
|
133
|
+
const prefixViews = new Map();
|
|
134
|
+
const viewBefore = (pos) => {
|
|
135
|
+
if (!prefixViews.has(pos)) prefixViews.set(pos, fold(sequence.slice(0, Math.max(1, pos))));
|
|
136
|
+
return prefixViews.get(pos);
|
|
137
|
+
};
|
|
138
|
+
const lastRunBefore = (pos) => {
|
|
139
|
+
let found = null;
|
|
140
|
+
for (const run of judgeRuns) if (run.pos < pos) found = run;
|
|
141
|
+
return found;
|
|
142
|
+
};
|
|
143
|
+
const firstRunAfter = (pos) => judgeRuns.find((run) => run.pos > pos) || null;
|
|
144
|
+
|
|
145
|
+
// Where the campaign finished, if it did: the first position whose prefix is done.
|
|
146
|
+
let finishPos = null;
|
|
147
|
+
if (final.state === 'done') {
|
|
148
|
+
let lo = 0;
|
|
149
|
+
let hi = sequence.length - 1;
|
|
150
|
+
while (lo < hi) {
|
|
151
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
152
|
+
if (fold(sequence.slice(0, mid + 1)).state === 'done') hi = mid;
|
|
153
|
+
else lo = mid + 1;
|
|
154
|
+
}
|
|
155
|
+
finishPos = lo;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const finalMissions = new Map(final.missions.map((m) => [m.id, m]));
|
|
159
|
+
const ended = [...missions.values()].filter((m) => m.endPos !== null).sort((a, b) => a.endPos - b.endPos);
|
|
160
|
+
|
|
161
|
+
// A batch: missions that ended between the same two judge runs.
|
|
162
|
+
const batches = new Map();
|
|
163
|
+
for (const m of ended) {
|
|
164
|
+
const before = lastRunBefore(m.endPos);
|
|
165
|
+
const after = firstRunAfter(m.endPos);
|
|
166
|
+
const key = `${before ? before.pos : -1}:${after ? after.pos : 'open'}`;
|
|
167
|
+
if (!batches.has(key)) batches.set(key, { index: batches.size + 1, before, after, missions: [] });
|
|
168
|
+
batches.get(key).missions.push(m);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const memberFor = (member) => (redact && typeof member === 'string' && member.startsWith('human:') ? 'human' : member);
|
|
172
|
+
const idFor = (missionId) => (redact ? pseudonym('m', campaignId, missionId) : missionId);
|
|
173
|
+
const outCampaign = redact ? pseudonym('cmp', campaignId) : campaignId;
|
|
174
|
+
const lastPos = sequence.length - 1;
|
|
175
|
+
|
|
176
|
+
const rows = ended.map((m, i) => {
|
|
177
|
+
const before = lastRunBefore(m.endPos);
|
|
178
|
+
const after = firstRunAfter(m.endPos);
|
|
179
|
+
const batch = batches.get(`${before ? before.pos : -1}:${after ? after.pos : 'open'}`);
|
|
180
|
+
const size = batch.missions.length;
|
|
181
|
+
const readBefore = readingOf(before);
|
|
182
|
+
const readAfter = readingOf(after);
|
|
183
|
+
const delta = readBefore.value !== null && readAfter.value !== null ? readAfter.value - readBefore.value : null;
|
|
184
|
+
const startPos = m.firstPos === null ? m.endPos : m.firstPos;
|
|
185
|
+
const view = viewBefore(startPos);
|
|
186
|
+
const folded = finalMissions.get(m.id) || {};
|
|
187
|
+
const uncosted = typeof m.endData.uncosted_runs === 'number' ? m.endData.uncosted_runs : m.uncostedTicks;
|
|
188
|
+
let creditNote = null;
|
|
189
|
+
if (!after) creditNote = 'no judge run after this mission yet, so it has no reward';
|
|
190
|
+
else if (!before) creditNote = 'no judge run before this mission, so it has no reward';
|
|
191
|
+
else if (size > 1) creditNote = `one of ${size} missions that ended between the same two judge runs; the reading change is split equally`;
|
|
192
|
+
|
|
193
|
+
const action = {
|
|
194
|
+
mission_id: idFor(m.id),
|
|
195
|
+
kind: m.kind,
|
|
196
|
+
member: memberFor(folded.member || m.member),
|
|
197
|
+
engine: m.engine || (typeof m.endData.engine === 'string' ? m.endData.engine : null),
|
|
198
|
+
estimate_usd: m.estimate,
|
|
199
|
+
};
|
|
200
|
+
if (!redact) {
|
|
201
|
+
action.objective = m.objective;
|
|
202
|
+
action.verify = m.verify;
|
|
203
|
+
}
|
|
204
|
+
const row = {
|
|
205
|
+
kind: 'step',
|
|
206
|
+
campaign_id: outCampaign,
|
|
207
|
+
step: i + 1,
|
|
208
|
+
at: m.endAt,
|
|
209
|
+
batch: { index: batch.index, size },
|
|
210
|
+
state_before: compactState(view, lastRunBefore(startPos)),
|
|
211
|
+
action,
|
|
212
|
+
outcome: folded.outcome || m.endData.outcome,
|
|
213
|
+
cost_usd: folded.cost_usd === undefined || folded.cost_usd === null ? m.endData.cost_usd : folded.cost_usd,
|
|
214
|
+
ticks: m.ticks,
|
|
215
|
+
uncosted_ticks: uncosted,
|
|
216
|
+
human_asks: m.asks.length,
|
|
217
|
+
reading_before: readBefore.value,
|
|
218
|
+
reading_after: readAfter.value,
|
|
219
|
+
reading_source: readAfter.source,
|
|
220
|
+
reward: delta === null ? null : round(delta / size),
|
|
221
|
+
credit_note: creditNote,
|
|
222
|
+
done: finishPos !== null && finishPos <= (after ? after.pos : lastPos),
|
|
223
|
+
judge_passed: after ? after.passed : null,
|
|
224
|
+
};
|
|
225
|
+
if (!redact) {
|
|
226
|
+
row.judge_summary = after ? after.summary : null;
|
|
227
|
+
row.judge_output_tail = after ? after.outputTail : null;
|
|
228
|
+
row.asks = m.asks.map((ask) => ({ question: ask.question, answer: ask.answer }));
|
|
229
|
+
}
|
|
230
|
+
return row;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const lastRun = judgeRuns[judgeRuns.length - 1] || null;
|
|
234
|
+
const header = {
|
|
235
|
+
kind: 'header',
|
|
236
|
+
schema: SCHEMA,
|
|
237
|
+
version: VERSION,
|
|
238
|
+
campaign_id: outCampaign,
|
|
239
|
+
redacted: redact,
|
|
240
|
+
privacy: redact ? PRIVACY.redacted : PRIVACY.full,
|
|
241
|
+
judge: { runner: judgeRunner(final.judge), outside: true, runs: judgeRuns.length },
|
|
242
|
+
reward: REWARD,
|
|
243
|
+
rows: rows.length,
|
|
244
|
+
batches: batches.size,
|
|
245
|
+
pending_missions: final.missions.filter((m) => ['proposed', 'claimed', 'running'].includes(m.status)).length,
|
|
246
|
+
missions_not_run: final.missions.filter((m) => m.status === 'vetoed' || m.status === 'dropped').length,
|
|
247
|
+
campaign_asks: [...asks.values()].filter((ask) => !ask.mission_id).length,
|
|
248
|
+
events: sequence.length,
|
|
249
|
+
first_at: sequence[0].at,
|
|
250
|
+
last_at: sequence[lastPos].at,
|
|
251
|
+
final: {
|
|
252
|
+
state: final.state,
|
|
253
|
+
stop_reason: final.stop_reason,
|
|
254
|
+
confidence: final.answer.confidence,
|
|
255
|
+
reading: readingOf(lastRun).value,
|
|
256
|
+
standing: final.standing,
|
|
257
|
+
budget: final.budget,
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
if (!redact) {
|
|
261
|
+
header.title = final.title;
|
|
262
|
+
header.judge.command = final.judge;
|
|
263
|
+
}
|
|
264
|
+
return { header, rows };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function traceLines(trace) {
|
|
268
|
+
return [trace.header, ...trace.rows].map((row) => JSON.stringify(row)).join('\n') + '\n';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// atris campaign export <id> --trace [--out <file>] [--full]
|
|
272
|
+
function exportTrace(args, ctx) {
|
|
273
|
+
const words = [];
|
|
274
|
+
for (let i = 1; i < args.length; i++) {
|
|
275
|
+
const arg = String(args[i]);
|
|
276
|
+
if (arg === '--out' || arg === '--as') { i++; continue; }
|
|
277
|
+
if (!arg.startsWith('--')) words.push(arg);
|
|
278
|
+
}
|
|
279
|
+
const { readFlag } = require('./arg-parser');
|
|
280
|
+
const id = words[0];
|
|
281
|
+
if (!id) return ctx.fail('name the campaign: atris campaign export <id> --trace [--out <file.jsonl>] [--full]');
|
|
282
|
+
const events = ctx.store.read(id);
|
|
283
|
+
if (!events.length) return ctx.fail(`no campaign ${id}. See them all with: atris campaign`, 1);
|
|
284
|
+
const full = args.includes('--full');
|
|
285
|
+
if (full && args.includes('--redact')) return ctx.fail('pick one: --redact (the default) or --full');
|
|
286
|
+
const trace = buildTrace(events, { redact: !full });
|
|
287
|
+
if (trace.error) return ctx.fail(`campaign ${id} cannot be read: ${trace.error}`, 1);
|
|
288
|
+
const text = traceLines(trace);
|
|
289
|
+
const out = readFlag(args, '--out', '');
|
|
290
|
+
if (!out) {
|
|
291
|
+
process.stdout.write(text);
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
require('fs').writeFileSync(out, text);
|
|
295
|
+
const note = `wrote ${trace.rows.length} row${trace.rows.length === 1 ? '' : 's'} and a header to ${out}${full ? ' (full: includes objectives and judge output)' : ' (redacted)'}`;
|
|
296
|
+
ctx.out(note, { ok: true, action: 'export', path: out, rows: trace.rows.length, redacted: !full });
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
module.exports = { SCHEMA, VERSION, buildTrace, exportTrace, readingOf, traceLines };
|
|
@@ -10,20 +10,34 @@ const CONDUCTOR_UNTRACKED_PATTERN =
|
|
|
10
10
|
const CONDUCTOR_ROOT_OUTPUT_PATTERN = /^\.codex-last-message\.txt$/;
|
|
11
11
|
const CONDUCTOR_RUN_OUTPUT_PATTERN = /^atris\/runs\/[^/]+\.(?:json|live\.log)$/;
|
|
12
12
|
|
|
13
|
+
// Every atris call inside a work folder can write more of its own state under
|
|
14
|
+
// .atris/ (engine health, usage, briefs). A list of names always lagged: on
|
|
15
|
+
// 2026-10-15 .atris/state/engines.json and usage.jsonl made every dispatch
|
|
16
|
+
// guard fail on a fresh repo. A NEW file under .atris/ is always the tool's
|
|
17
|
+
// own, so untracked checks skip the whole folder. Tracked .atris/ files keep
|
|
18
|
+
// the exact list, since a repo may commit some of them on purpose.
|
|
19
|
+
const ATRIS_OWN_DIR_PATTERN = /^\.atris\//;
|
|
20
|
+
|
|
13
21
|
function isConductorArtifact(file) {
|
|
14
22
|
const value = String(file || '');
|
|
15
23
|
return CONDUCTOR_UNTRACKED_PATTERN.test(value) || CONDUCTOR_ROOT_OUTPUT_PATTERN.test(value)
|
|
16
24
|
|| CONDUCTOR_RUN_OUTPUT_PATTERN.test(value);
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
// For files git does not track yet.
|
|
28
|
+
function isConductorUntracked(file) {
|
|
29
|
+
return isConductorArtifact(file) || ATRIS_OWN_DIR_PATTERN.test(String(file || ''));
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
// Accepts a `git status --porcelain` line ("?? .atris/fleet-prompt-BCK-1.md").
|
|
20
33
|
function isConductorStatusLine(line) {
|
|
21
34
|
const text = String(line || '');
|
|
22
35
|
if (!text.startsWith('?? ')) return false;
|
|
23
|
-
return
|
|
36
|
+
return isConductorUntracked(text.slice(3));
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
module.exports = {
|
|
27
40
|
isConductorArtifact,
|
|
28
41
|
isConductorStatusLine,
|
|
42
|
+
isConductorUntracked,
|
|
29
43
|
};
|
package/lib/daily-log.js
CHANGED
|
@@ -46,7 +46,17 @@ function memberSlug(root, member) {
|
|
|
46
46
|
return slug;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// Member logs are gitignored, so one written inside a work folder dies when
|
|
50
|
+
// that folder is reaped. A linked worktree writes to its main checkout instead
|
|
51
|
+
// when the main checkout knows the member.
|
|
52
|
+
function memberLogRoot(root, member) {
|
|
53
|
+
const { mainCheckoutRoot } = require('./engine-registry');
|
|
54
|
+
const main = mainCheckoutRoot(root);
|
|
55
|
+
return main && memberSlug(main, member) ? main : root;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function appendMemberDailyEntry(workspaceRoot, member, title, fields) {
|
|
59
|
+
const root = memberLogRoot(workspaceRoot, member);
|
|
50
60
|
const slug = memberSlug(root, member);
|
|
51
61
|
if (!slug || !fs.existsSync(path.join(root, 'atris'))) return null;
|
|
52
62
|
const logPath = path.join(root, 'atris', 'team', slug, 'logs', todayLogName());
|