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
package/commands/youtube.js
CHANGED
|
@@ -1239,17 +1239,32 @@ function buildDigestPrompt(briefs = []) {
|
|
|
1239
1239
|
|
|
1240
1240
|
function defaultDigestRunner(prompt, deps = {}) {
|
|
1241
1241
|
const spawn = deps.spawnSync || spawnSync;
|
|
1242
|
-
|
|
1242
|
+
// Machine memory and load at both ends of the launch, so one killed with
|
|
1243
|
+
// SIGKILL says whether the machine was short on memory.
|
|
1244
|
+
const endResource = require('../lib/engine-resource').startEngineRun(deps.cwd || process.cwd(), { engine: 'claude', task: 'youtube digest' });
|
|
1245
|
+
const result = spawn('claude', ['-p', prompt, '--model', 'claude-haiku-5-5'], {
|
|
1243
1246
|
encoding: 'utf8',
|
|
1244
1247
|
timeout: DIGEST_ENGINE_TIMEOUT_MS,
|
|
1245
1248
|
maxBuffer: 10 * 1024 * 1024,
|
|
1246
1249
|
});
|
|
1250
|
+
// spawnSync does not throw, so a failure is passed in the thrown shape.
|
|
1251
|
+
const failed = !result || result.error || result.signal || result.status !== 0;
|
|
1252
|
+
const end = endResource(failed ? {
|
|
1253
|
+
code: result && result.error && result.error.code,
|
|
1254
|
+
signal: result && result.signal,
|
|
1255
|
+
stderr: result && result.stderr,
|
|
1256
|
+
message: result && result.error ? result.error.message : `claude exited ${result && result.status}`,
|
|
1257
|
+
} : null);
|
|
1258
|
+
return end && end.cause && result && !(result.error && result.error.code === 'ETIMEDOUT') ? { ...result, cause: end.cause } : result;
|
|
1247
1259
|
}
|
|
1248
1260
|
|
|
1249
1261
|
function invokeDigestEngine(prompt, deps = {}) {
|
|
1250
1262
|
const runner = deps.runner || ((nextPrompt) => defaultDigestRunner(nextPrompt, deps));
|
|
1251
1263
|
const result = runner(prompt, deps);
|
|
1252
1264
|
if (typeof result === 'string') return result.trim();
|
|
1265
|
+
if (result && result.signal && !result.error) {
|
|
1266
|
+
throw new Error(`digest engine stopped by ${result.signal}${result.cause ? `; ${result.cause}` : ''}`);
|
|
1267
|
+
}
|
|
1253
1268
|
if (!result || result.error || (result.status != null && result.status !== 0)) {
|
|
1254
1269
|
const detail = String(result?.stderr || result?.error?.message || 'digest engine failed').trim();
|
|
1255
1270
|
throw new Error(detail || 'digest engine failed');
|
|
@@ -1401,7 +1416,7 @@ function runYoutubeDigest(args = [], deps = {}) {
|
|
|
1401
1416
|
const prompt = buildDigestPrompt(briefs);
|
|
1402
1417
|
let text;
|
|
1403
1418
|
try {
|
|
1404
|
-
text = invokeDigestEngine(prompt, deps);
|
|
1419
|
+
text = invokeDigestEngine(prompt, { ...deps, cwd });
|
|
1405
1420
|
} catch (err) {
|
|
1406
1421
|
output(err.message || 'digest engine failed');
|
|
1407
1422
|
return 1;
|
package/lib/account-bound.js
CHANGED
|
@@ -20,6 +20,14 @@ function isProvisionedAgentWorkspace(cwd = process.cwd()) {
|
|
|
20
20
|
return fs.existsSync(path.join(root, '.atris', 'agent-token.json'));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// A Desktop project folder carries an atris/ directory but no business.json.
|
|
24
|
+
// It is a real workspace, not a scratch folder, so account reads may run
|
|
25
|
+
// there. Writes (send, dm, archive, connect) still need the business binding.
|
|
26
|
+
function isProjectWorkspace(cwd = process.cwd()) {
|
|
27
|
+
const root = path.resolve(cwd);
|
|
28
|
+
return fs.existsSync(path.join(root, 'atris'));
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
function isHelpArg(arg) {
|
|
24
32
|
return arg === '--help' || arg === '-h' || arg === 'help';
|
|
25
33
|
}
|
|
@@ -33,6 +41,8 @@ function looksLikeBusinessSlug(token) {
|
|
|
33
41
|
/**
|
|
34
42
|
* Account-scoped verbs dump live CRM/cloud state. Outside a bound business
|
|
35
43
|
* workspace they must opt in with --account. Help always passes.
|
|
44
|
+
* Pass { read: true } for read-only verbs: a project folder with atris/ (no
|
|
45
|
+
* business.json) then counts as bound. Writes never get that relaxation.
|
|
36
46
|
* Returns { ok, args } where args has --account stripped when continuing.
|
|
37
47
|
*/
|
|
38
48
|
function requireAccountBound(args = [], options = {}) {
|
|
@@ -44,6 +54,9 @@ function requireAccountBound(args = [], options = {}) {
|
|
|
44
54
|
if (isBoundBusinessWorkspace(cwd) || isProvisionedAgentWorkspace(cwd)) {
|
|
45
55
|
return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true };
|
|
46
56
|
}
|
|
57
|
+
if (options.read === true && isProjectWorkspace(cwd)) {
|
|
58
|
+
return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true, project: true };
|
|
59
|
+
}
|
|
47
60
|
if (list.includes('--account')) {
|
|
48
61
|
return { ok: true, args: list.filter((arg) => arg !== '--account'), account: true };
|
|
49
62
|
}
|
package/lib/autoland.js
CHANGED
|
@@ -267,6 +267,8 @@ function acceptedInLastDay(tasks, now = Date.now()) {
|
|
|
267
267
|
result: String(t.result || t.metadata?.result || '').slice(0, 220),
|
|
268
268
|
happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
|
|
269
269
|
member: String(t.claimed_by || t.metadata?.executed_by || t.assigned_to || '').trim(),
|
|
270
|
+
// Accepted is a review verdict; only a passed landing proof is landed.
|
|
271
|
+
landed: t.metadata?.landing?.status === 'landed',
|
|
270
272
|
};
|
|
271
273
|
if (t.metadata?.auto_accepted_at) auto.push(entry);
|
|
272
274
|
else human.push(entry);
|
|
@@ -481,6 +483,9 @@ function unexplainedSummaryRows(items, label) {
|
|
|
481
483
|
if (label === 'landed') {
|
|
482
484
|
return [`${plur(count, 'landed result')} stayed hidden because ${count === 1 ? 'its outcome was' : 'their outcomes were'} unclear`];
|
|
483
485
|
}
|
|
486
|
+
if (label === 'accepted') {
|
|
487
|
+
return [`${plur(count, 'accepted result')} stayed hidden because ${count === 1 ? 'its outcome was' : 'their outcomes were'} unclear`];
|
|
488
|
+
}
|
|
484
489
|
if (label === 'in review') {
|
|
485
490
|
return [`${plur(count, 'review')} stayed hidden because ${count === 1 ? 'its next action was' : 'their next actions were'} unclear`];
|
|
486
491
|
}
|
|
@@ -547,6 +552,10 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
|
|
|
547
552
|
lines.push(`atris ${project}: last 24 hours`);
|
|
548
553
|
const autoCount = accepted.auto.length;
|
|
549
554
|
const humanCount = accepted.human.length;
|
|
555
|
+
const landedCount = accepted.auto.filter((item) => item.landed === true).length;
|
|
556
|
+
// "N landed" only when every one of them proved it; otherwise say both.
|
|
557
|
+
const finished = landedCount === autoCount ? `${autoCount} landed` : `${autoCount} accepted, ${landedCount} landed`;
|
|
558
|
+
const finishedWord = landedCount === autoCount ? 'landed' : 'accepted';
|
|
550
559
|
const wishWaits = (Array.isArray(waitingWishes) ? waitingWishes : [])
|
|
551
560
|
.filter((wish) => wish && String(wish.text || '').trim());
|
|
552
561
|
if (autoCount > 0) {
|
|
@@ -564,8 +573,8 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
|
|
|
564
573
|
// The header states the actual bar: under accept-all the claim
|
|
565
574
|
// "verified twice" would be a lie the operator builds trust on.
|
|
566
575
|
lines.push(acceptAll
|
|
567
|
-
? `${
|
|
568
|
-
: `${
|
|
576
|
+
? `${finished}; ${plur(ready.length, 'clear result')}, protected lanes held back for you:`
|
|
577
|
+
: `${finished}; ${plur(ready.length, 'clear result')}, each verified twice with proof on file:`);
|
|
569
578
|
for (const { item, line } of visibleWork) {
|
|
570
579
|
lines.push('');
|
|
571
580
|
lines.push(`- ${line || item.ref}${memberTrustLabel(item.member, root)}`);
|
|
@@ -577,9 +586,9 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
|
|
|
577
586
|
: `${plur(held, 'more result')} when you want them: atris autoland digest`);
|
|
578
587
|
}
|
|
579
588
|
} else {
|
|
580
|
-
lines.push(`${
|
|
589
|
+
lines.push(`${finished} with proof on file; no clear result was ready to show:`);
|
|
581
590
|
}
|
|
582
|
-
for (const row of unexplainedSummaryRows(unexplainedWork,
|
|
591
|
+
for (const row of unexplainedSummaryRows(unexplainedWork, finishedWord)) lines.push(row);
|
|
583
592
|
}
|
|
584
593
|
if (autoCount + humanCount === 0) lines.push('nothing finished in the last day');
|
|
585
594
|
if (waiting.length > 0) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Work and review in rounds (Astra's rule: let agents work 50 minutes, then
|
|
2
|
+
// take 10). In the work window the campaign loop runs: it plans, runs missions
|
|
3
|
+
// at once, and judges. In the break the judge runs if due, reserves are
|
|
4
|
+
// reconciled, and the owner writes a short review of the round into the
|
|
5
|
+
// campaign's lessons file, which the next round's planning reads. Each round
|
|
6
|
+
// leaves the campaign a little smarter than the last.
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { getCampaign, openCampaignStore } = require('./campaign-store');
|
|
13
|
+
|
|
14
|
+
const LESSON_LINES_FOR_PLANNING = 30;
|
|
15
|
+
|
|
16
|
+
function parseDuration(text, fallbackMs) {
|
|
17
|
+
if (text === undefined || text === null || text === '') return fallbackMs;
|
|
18
|
+
const match = /^(\d+(?:\.\d+)?)\s*(s|m|h)?$/i.exec(String(text).trim());
|
|
19
|
+
if (!match) throw new Error(`durations look like 50m, 10m, 90s, or 1h (got ${text})`);
|
|
20
|
+
const unit = (match[2] || 'm').toLowerCase();
|
|
21
|
+
return Math.round(Number(match[1]) * { s: 1000, m: 60000, h: 3600000 }[unit]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function lessonsPath(root, campaignId) {
|
|
25
|
+
return path.join(root, 'atris', 'campaigns', campaignId, 'lessons.md');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The newest lessons, for the planner.
|
|
29
|
+
function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
|
|
30
|
+
try {
|
|
31
|
+
const text = fs.readFileSync(lessonsPath(root, campaignId), 'utf8');
|
|
32
|
+
return text.split('\n').filter((line) => line.trim().startsWith('- ')).slice(-lines).join('\n');
|
|
33
|
+
} catch {
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function roundSummary(before, after) {
|
|
39
|
+
const fresh = after.missions.filter((m) => !before.missions.some((b) => b.id === m.id && b.status === m.status));
|
|
40
|
+
const kept = fresh.filter((m) => m.status === 'ended' && m.outcome === 'kept').length;
|
|
41
|
+
const failed = fresh.filter((m) => m.status === 'ended' && m.outcome !== 'kept').length;
|
|
42
|
+
const vetoed = fresh.filter((m) => m.status === 'vetoed').length;
|
|
43
|
+
const spent = Math.round((after.budget.spent - before.budget.spent) * 100) / 100;
|
|
44
|
+
return { kept, failed, vetoed, spent, fresh };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function reviewPrompt(view, summary, lessons) {
|
|
48
|
+
const missions = summary.fresh.map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none ended this round';
|
|
49
|
+
return [
|
|
50
|
+
'You review one round of an Atris campaign. Reply with 2 to 6 short lessons, one per line, each starting with "- ".',
|
|
51
|
+
'A lesson says what to do differently next round, in plain words. Do not repeat a lesson already listed.',
|
|
52
|
+
'',
|
|
53
|
+
`Campaign: ${view.title}`,
|
|
54
|
+
`Judge: ${view.judge}`,
|
|
55
|
+
`Judge now: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${view.last_judge.reading !== null ? ` (reading ${view.last_judge.reading})` : ''}: ${view.answer.text || ''}` : 'never run'}`,
|
|
56
|
+
`This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start, $${summary.spent} spent`,
|
|
57
|
+
'Missions this round:',
|
|
58
|
+
missions,
|
|
59
|
+
'',
|
|
60
|
+
'Lessons so far:',
|
|
61
|
+
lessons || '- none yet',
|
|
62
|
+
].join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeCadence(root, loop, options = {}, deps = {}) {
|
|
66
|
+
const store = deps.store || openCampaignStore(root);
|
|
67
|
+
const log = deps.log || ((line) => console.log(line));
|
|
68
|
+
const now = deps.now || (() => Date.now());
|
|
69
|
+
const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
70
|
+
const ask = deps.ask;
|
|
71
|
+
const workMs = parseDuration(options.work, 50 * 60000);
|
|
72
|
+
const breakMs = parseDuration(options.break, 10 * 60000);
|
|
73
|
+
const rounds = Number(options.rounds) > 0 ? Number(options.rounds) : Infinity;
|
|
74
|
+
|
|
75
|
+
async function review(id, before) {
|
|
76
|
+
const after = getCampaign(store, id);
|
|
77
|
+
const summary = roundSummary(before, after);
|
|
78
|
+
const lessons = readLessons(root, id, 200);
|
|
79
|
+
let written = [];
|
|
80
|
+
if (ask) {
|
|
81
|
+
const answer = await ask(root, after, reviewPrompt(after, summary, lessons));
|
|
82
|
+
// A lesson already on file is not written twice.
|
|
83
|
+
const known = new Set(lessons.split('\n').map((line) => line.trim().toLowerCase()));
|
|
84
|
+
written = String((answer && answer.text) || '').split('\n').map((line) => line.trim())
|
|
85
|
+
.filter((line) => line.startsWith('- ') && !known.has(line.toLowerCase())).slice(0, 6);
|
|
86
|
+
}
|
|
87
|
+
const file = lessonsPath(root, id);
|
|
88
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
89
|
+
if (!fs.existsSync(file)) fs.writeFileSync(file, `# Lessons: ${after.title}\n\nWritten by the owner at each review; the next round plans from them.\n`);
|
|
90
|
+
const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
|
|
91
|
+
const header = `\n## ${stamp}: ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent\n`;
|
|
92
|
+
fs.appendFileSync(file, `${header}${(written.length ? written : ['- (no review written this round)']).join('\n')}\n`);
|
|
93
|
+
return { summary, lessons: written };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function run(id) {
|
|
97
|
+
let round = 0;
|
|
98
|
+
let last = { reason: 'not started' };
|
|
99
|
+
while (round < rounds) {
|
|
100
|
+
round += 1;
|
|
101
|
+
const before = getCampaign(store, id);
|
|
102
|
+
const until = now() + workMs;
|
|
103
|
+
log(`round ${round}: working until ${new Date(until).toISOString().slice(11, 16)}`);
|
|
104
|
+
let stopped = null;
|
|
105
|
+
while (now() < until) {
|
|
106
|
+
const result = await loop.step(id);
|
|
107
|
+
if (result.stop) {
|
|
108
|
+
stopped = result.reason;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const breakStarted = now();
|
|
113
|
+
log(`round ${round}: review`);
|
|
114
|
+
const { summary, lessons } = await review(id, before);
|
|
115
|
+
log(` ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
|
|
116
|
+
const view = getCampaign(store, id);
|
|
117
|
+
last = { reason: stopped || 'work window ended', round };
|
|
118
|
+
// Finished, out of money, or waiting on a person: no point in another round.
|
|
119
|
+
if (['done', 'out_of_budget', 'needs_you', 'paused'].includes(view.state) || (stopped && !/missions running|nothing usable/.test(stopped))) {
|
|
120
|
+
return { rounds: round, reason: stopped || view.state, campaign: view };
|
|
121
|
+
}
|
|
122
|
+
const rest = breakMs - (now() - breakStarted);
|
|
123
|
+
if (round < rounds && rest > 0) await sleep(rest);
|
|
124
|
+
}
|
|
125
|
+
return { rounds: round, reason: last.reason, campaign: getCampaign(store, id) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { run, review };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { lessonsPath, makeCadence, parseDuration, readLessons, reviewPrompt };
|
|
@@ -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 };
|